@josephyan/qingflow-cli 0.2.0-beta.62 → 0.2.0-beta.64

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.
@@ -21,6 +21,7 @@ from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
21
21
  from ..solution.executor import _build_viewgraph_questions, _compact_dict, extract_field_map
22
22
  from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
23
23
  from ..tools.app_tools import AppTools
24
+ from ..tools.custom_button_tools import CustomButtonTools
24
25
  from ..tools.directory_tools import DirectoryTools
25
26
  from ..tools.package_tools import PackageTools
26
27
  from ..tools.portal_tools import PortalTools
@@ -39,6 +40,8 @@ from .models import (
39
40
  AppViewsReadResponse,
40
41
  ChartApplyRequest,
41
42
  ChartUpsertPatch,
43
+ CustomButtonMatchRulePatch,
44
+ CustomButtonPatch,
42
45
  FieldPatch,
43
46
  FieldRemovePatch,
44
47
  FieldSelector,
@@ -54,8 +57,12 @@ from .models import (
54
57
  PublicFieldType,
55
58
  PublicRelationMode,
56
59
  PublicChartType,
60
+ PublicButtonTriggerAction,
57
61
  PublicViewType,
62
+ PublicViewButtonConfigType,
63
+ PublicViewButtonType,
58
64
  SchemaPlanRequest,
65
+ ViewButtonBindingPatch,
59
66
  ViewUpsertPatch,
60
67
  ViewFilterOperator,
61
68
  ViewsPlanRequest,
@@ -142,6 +149,7 @@ class AiBuilderFacade:
142
149
  self,
143
150
  *,
144
151
  apps: AppTools,
152
+ buttons: CustomButtonTools,
145
153
  packages: PackageTools,
146
154
  views: ViewTools,
147
155
  workflows: WorkflowTools,
@@ -152,6 +160,7 @@ class AiBuilderFacade:
152
160
  solutions: SolutionTools,
153
161
  ) -> None:
154
162
  self.apps = apps
163
+ self.buttons = buttons
155
164
  self.packages = packages
156
165
  self.views = views
157
166
  self.workflows = workflows
@@ -1291,6 +1300,355 @@ class AiBuilderFacade:
1291
1300
  **match,
1292
1301
  }
1293
1302
 
1303
+ def app_custom_button_list(self, *, profile: str, app_key: str) -> JSONObject:
1304
+ normalized_args = {"app_key": app_key}
1305
+ permission_outcomes: list[PermissionCheckOutcome] = []
1306
+ permission_outcome = self._guard_app_permission(
1307
+ profile=profile,
1308
+ app_key=app_key,
1309
+ required_permission="edit_app",
1310
+ normalized_args=normalized_args,
1311
+ )
1312
+ if permission_outcome.block is not None:
1313
+ return permission_outcome.block
1314
+ permission_outcomes.append(permission_outcome)
1315
+
1316
+ def finalize(response: JSONObject) -> JSONObject:
1317
+ return _apply_permission_outcomes(response, *permission_outcomes)
1318
+
1319
+ listing = self.buttons.custom_button_list(profile=profile, app_key=app_key, being_draft=True, include_raw=False)
1320
+ items = [
1321
+ _normalize_custom_button_summary(item)
1322
+ for item in (listing.get("items") or [])
1323
+ if isinstance(item, dict)
1324
+ ]
1325
+ return finalize(
1326
+ {
1327
+ "status": "success",
1328
+ "error_code": None,
1329
+ "recoverable": False,
1330
+ "message": "read custom button summary",
1331
+ "normalized_args": normalized_args,
1332
+ "missing_fields": [],
1333
+ "allowed_values": {},
1334
+ "details": {},
1335
+ "request_id": None,
1336
+ "suggested_next_call": None,
1337
+ "noop": False,
1338
+ "warnings": [],
1339
+ "verification": {"custom_buttons_verified": True},
1340
+ "verified": True,
1341
+ "app_key": app_key,
1342
+ "count": len(items),
1343
+ "buttons": items,
1344
+ }
1345
+ )
1346
+
1347
+ def app_custom_button_get(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
1348
+ normalized_args = {"app_key": app_key, "button_id": button_id}
1349
+ permission_outcomes: list[PermissionCheckOutcome] = []
1350
+ permission_outcome = self._guard_app_permission(
1351
+ profile=profile,
1352
+ app_key=app_key,
1353
+ required_permission="edit_app",
1354
+ normalized_args=normalized_args,
1355
+ )
1356
+ if permission_outcome.block is not None:
1357
+ return permission_outcome.block
1358
+ permission_outcomes.append(permission_outcome)
1359
+
1360
+ def finalize(response: JSONObject) -> JSONObject:
1361
+ return _apply_permission_outcomes(response, *permission_outcomes)
1362
+
1363
+ detail = self.buttons.custom_button_get(
1364
+ profile=profile,
1365
+ app_key=app_key,
1366
+ button_id=button_id,
1367
+ being_draft=True,
1368
+ include_raw=False,
1369
+ )
1370
+ button = _normalize_custom_button_detail(detail.get("result") if isinstance(detail.get("result"), dict) else {})
1371
+ return finalize(
1372
+ {
1373
+ "status": "success",
1374
+ "error_code": None,
1375
+ "recoverable": False,
1376
+ "message": "read custom button detail",
1377
+ "normalized_args": normalized_args,
1378
+ "missing_fields": [],
1379
+ "allowed_values": {},
1380
+ "details": {},
1381
+ "request_id": None,
1382
+ "suggested_next_call": None,
1383
+ "noop": False,
1384
+ "warnings": [],
1385
+ "verification": {"custom_button_verified": True},
1386
+ "verified": True,
1387
+ "app_key": app_key,
1388
+ "button_id": button_id,
1389
+ "button": button,
1390
+ }
1391
+ )
1392
+
1393
+ def app_custom_button_create(self, *, profile: str, app_key: str, payload: CustomButtonPatch) -> JSONObject:
1394
+ normalized_args = {"app_key": app_key, "payload": payload.model_dump(mode="json")}
1395
+ permission_outcomes: list[PermissionCheckOutcome] = []
1396
+ permission_outcome = self._guard_app_permission(
1397
+ profile=profile,
1398
+ app_key=app_key,
1399
+ required_permission="edit_app",
1400
+ normalized_args=normalized_args,
1401
+ )
1402
+ if permission_outcome.block is not None:
1403
+ return permission_outcome.block
1404
+ permission_outcomes.append(permission_outcome)
1405
+
1406
+ def finalize(response: JSONObject) -> JSONObject:
1407
+ return _apply_permission_outcomes(response, *permission_outcomes)
1408
+
1409
+ edit_version_no, edit_context_error = self._ensure_app_edit_context(
1410
+ profile=profile,
1411
+ app_key=app_key,
1412
+ normalized_args=normalized_args,
1413
+ failure_code="CUSTOM_BUTTON_CREATE_FAILED",
1414
+ )
1415
+ if edit_context_error is not None:
1416
+ return finalize(edit_context_error)
1417
+
1418
+ create_result = self.buttons.custom_button_create(
1419
+ profile=profile,
1420
+ app_key=app_key,
1421
+ payload=_serialize_custom_button_payload(payload),
1422
+ )
1423
+ raw_result = create_result.get("result")
1424
+ button_id = _extract_custom_button_id(raw_result)
1425
+ if button_id is None:
1426
+ return finalize(
1427
+ _failed(
1428
+ "CUSTOM_BUTTON_CREATE_FAILED",
1429
+ "custom button create succeeded but no button_id was returned",
1430
+ normalized_args=normalized_args,
1431
+ details={"app_key": app_key, "result": deepcopy(raw_result), "edit_version_no": edit_version_no},
1432
+ suggested_next_call={"tool_name": "app_custom_button_list", "arguments": {"profile": profile, "app_key": app_key}},
1433
+ )
1434
+ )
1435
+ try:
1436
+ readback = self.buttons.custom_button_get(
1437
+ profile=profile,
1438
+ app_key=app_key,
1439
+ button_id=button_id,
1440
+ being_draft=True,
1441
+ include_raw=False,
1442
+ )
1443
+ except (QingflowApiError, RuntimeError) as error:
1444
+ api_error = _coerce_api_error(error)
1445
+ response = {
1446
+ "status": "partial_success",
1447
+ "error_code": "CUSTOM_BUTTON_READBACK_PENDING",
1448
+ "recoverable": True,
1449
+ "message": "created custom button; detail readback unavailable",
1450
+ "normalized_args": normalized_args,
1451
+ "missing_fields": [],
1452
+ "allowed_values": {},
1453
+ "details": {
1454
+ "app_key": app_key,
1455
+ "button_id": button_id,
1456
+ "edit_version_no": edit_version_no,
1457
+ "transport_error": _transport_error_payload(api_error),
1458
+ },
1459
+ "request_id": api_error.request_id,
1460
+ "suggested_next_call": {
1461
+ "tool_name": "app_custom_button_get",
1462
+ "arguments": {"profile": profile, "app_key": app_key, "button_id": button_id},
1463
+ },
1464
+ "noop": False,
1465
+ "warnings": [_warning("CUSTOM_BUTTON_READBACK_PENDING", "custom button was created, but detail readback is not available")],
1466
+ "verification": {"custom_button_verified": False},
1467
+ "verified": False,
1468
+ "app_key": app_key,
1469
+ "button_id": button_id,
1470
+ }
1471
+ if _is_permission_restricted_api_error(api_error):
1472
+ response = _apply_permission_outcomes(
1473
+ response,
1474
+ _verification_read_outcome(
1475
+ resource="custom_button",
1476
+ target={"app_key": app_key, "button_id": button_id},
1477
+ transport_error=api_error,
1478
+ ),
1479
+ )
1480
+ return finalize(response)
1481
+ button = _normalize_custom_button_detail(readback.get("result") if isinstance(readback.get("result"), dict) else {})
1482
+ return finalize(
1483
+ {
1484
+ "status": "success",
1485
+ "error_code": None,
1486
+ "recoverable": False,
1487
+ "message": "created custom button",
1488
+ "normalized_args": normalized_args,
1489
+ "missing_fields": [],
1490
+ "allowed_values": {},
1491
+ "details": {},
1492
+ "request_id": None,
1493
+ "suggested_next_call": None,
1494
+ "noop": False,
1495
+ "warnings": [],
1496
+ "verification": {"custom_button_verified": True},
1497
+ "verified": True,
1498
+ "app_key": app_key,
1499
+ "button_id": button_id,
1500
+ "edit_version_no": edit_version_no,
1501
+ "button": button,
1502
+ }
1503
+ )
1504
+
1505
+ def app_custom_button_update(
1506
+ self,
1507
+ *,
1508
+ profile: str,
1509
+ app_key: str,
1510
+ button_id: int,
1511
+ payload: CustomButtonPatch,
1512
+ ) -> JSONObject:
1513
+ normalized_args = {"app_key": app_key, "button_id": button_id, "payload": payload.model_dump(mode="json")}
1514
+ permission_outcomes: list[PermissionCheckOutcome] = []
1515
+ permission_outcome = self._guard_app_permission(
1516
+ profile=profile,
1517
+ app_key=app_key,
1518
+ required_permission="edit_app",
1519
+ normalized_args=normalized_args,
1520
+ )
1521
+ if permission_outcome.block is not None:
1522
+ return permission_outcome.block
1523
+ permission_outcomes.append(permission_outcome)
1524
+
1525
+ def finalize(response: JSONObject) -> JSONObject:
1526
+ return _apply_permission_outcomes(response, *permission_outcomes)
1527
+
1528
+ edit_version_no, edit_context_error = self._ensure_app_edit_context(
1529
+ profile=profile,
1530
+ app_key=app_key,
1531
+ normalized_args=normalized_args,
1532
+ failure_code="CUSTOM_BUTTON_UPDATE_FAILED",
1533
+ )
1534
+ if edit_context_error is not None:
1535
+ return finalize(edit_context_error)
1536
+
1537
+ self.buttons.custom_button_update(
1538
+ profile=profile,
1539
+ app_key=app_key,
1540
+ button_id=button_id,
1541
+ payload=_serialize_custom_button_payload(payload),
1542
+ )
1543
+ try:
1544
+ readback = self.buttons.custom_button_get(
1545
+ profile=profile,
1546
+ app_key=app_key,
1547
+ button_id=button_id,
1548
+ being_draft=True,
1549
+ include_raw=False,
1550
+ )
1551
+ except (QingflowApiError, RuntimeError) as error:
1552
+ api_error = _coerce_api_error(error)
1553
+ response = {
1554
+ "status": "partial_success",
1555
+ "error_code": "CUSTOM_BUTTON_READBACK_PENDING",
1556
+ "recoverable": True,
1557
+ "message": "updated custom button; detail readback unavailable",
1558
+ "normalized_args": normalized_args,
1559
+ "missing_fields": [],
1560
+ "allowed_values": {},
1561
+ "details": {
1562
+ "app_key": app_key,
1563
+ "button_id": button_id,
1564
+ "edit_version_no": edit_version_no,
1565
+ "transport_error": _transport_error_payload(api_error),
1566
+ },
1567
+ "request_id": api_error.request_id,
1568
+ "suggested_next_call": {
1569
+ "tool_name": "app_custom_button_get",
1570
+ "arguments": {"profile": profile, "app_key": app_key, "button_id": button_id},
1571
+ },
1572
+ "noop": False,
1573
+ "warnings": [_warning("CUSTOM_BUTTON_READBACK_PENDING", "custom button was updated, but detail readback is not available")],
1574
+ "verification": {"custom_button_verified": False},
1575
+ "verified": False,
1576
+ "app_key": app_key,
1577
+ "button_id": button_id,
1578
+ }
1579
+ if _is_permission_restricted_api_error(api_error):
1580
+ response = _apply_permission_outcomes(
1581
+ response,
1582
+ _verification_read_outcome(
1583
+ resource="custom_button",
1584
+ target={"app_key": app_key, "button_id": button_id},
1585
+ transport_error=api_error,
1586
+ ),
1587
+ )
1588
+ return finalize(response)
1589
+ button = _normalize_custom_button_detail(readback.get("result") if isinstance(readback.get("result"), dict) else {})
1590
+ return finalize(
1591
+ {
1592
+ "status": "success",
1593
+ "error_code": None,
1594
+ "recoverable": False,
1595
+ "message": "updated custom button",
1596
+ "normalized_args": normalized_args,
1597
+ "missing_fields": [],
1598
+ "allowed_values": {},
1599
+ "details": {},
1600
+ "request_id": None,
1601
+ "suggested_next_call": None,
1602
+ "noop": False,
1603
+ "warnings": [],
1604
+ "verification": {"custom_button_verified": True},
1605
+ "verified": True,
1606
+ "app_key": app_key,
1607
+ "button_id": button_id,
1608
+ "edit_version_no": edit_version_no,
1609
+ "button": button,
1610
+ }
1611
+ )
1612
+
1613
+ def app_custom_button_delete(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
1614
+ normalized_args = {"app_key": app_key, "button_id": button_id}
1615
+ permission_outcomes: list[PermissionCheckOutcome] = []
1616
+ permission_outcome = self._guard_app_permission(
1617
+ profile=profile,
1618
+ app_key=app_key,
1619
+ required_permission="edit_app",
1620
+ normalized_args=normalized_args,
1621
+ )
1622
+ if permission_outcome.block is not None:
1623
+ return permission_outcome.block
1624
+ permission_outcomes.append(permission_outcome)
1625
+
1626
+ def finalize(response: JSONObject) -> JSONObject:
1627
+ return _apply_permission_outcomes(response, *permission_outcomes)
1628
+
1629
+ self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
1630
+ return finalize(
1631
+ {
1632
+ "status": "success",
1633
+ "error_code": None,
1634
+ "recoverable": False,
1635
+ "message": "deleted custom button",
1636
+ "normalized_args": normalized_args,
1637
+ "missing_fields": [],
1638
+ "allowed_values": {},
1639
+ "details": {},
1640
+ "request_id": None,
1641
+ "suggested_next_call": None,
1642
+ "noop": False,
1643
+ "warnings": [],
1644
+ "verification": {"custom_button_deleted": True},
1645
+ "verified": True,
1646
+ "app_key": app_key,
1647
+ "button_id": button_id,
1648
+ "deleted": True,
1649
+ }
1650
+ )
1651
+
1294
1652
  def _resolve_app_matches_in_visible_apps(
1295
1653
  self,
1296
1654
  *,
@@ -2231,7 +2589,17 @@ class AiBuilderFacade:
2231
2589
  upsert_views = _build_views_preset(request.preset, list(field_names))
2232
2590
  blocking_issues: list[dict[str, Any]] = []
2233
2591
  for patch in upsert_views:
2234
- columns = patch.get("columns") or []
2592
+ raw_columns = [str(name or "").strip() for name in (patch.get("columns") or []) if str(name or "").strip()]
2593
+ columns = _filter_known_system_view_columns(raw_columns)
2594
+ if patch.get("type") in {"table", "card"} and raw_columns and not columns:
2595
+ blocking_issues.append(
2596
+ {
2597
+ "error_code": "VALIDATION_ERROR",
2598
+ "view_name": patch.get("name"),
2599
+ "message": "view columns must include at least one real app field; system columns cannot be applied directly",
2600
+ "ignored_system_columns": [name for name in raw_columns if name in _KNOWN_SYSTEM_VIEW_COLUMNS],
2601
+ }
2602
+ )
2235
2603
  missing_columns = [name for name in columns if name not in field_names]
2236
2604
  if missing_columns:
2237
2605
  blocking_issues.append({"error_code": "UNKNOWN_VIEW_FIELD", "view_name": patch.get("name"), "missing_fields": missing_columns})
@@ -3303,6 +3671,56 @@ class AiBuilderFacade:
3303
3671
  for field in parsed_schema["fields"]
3304
3672
  if isinstance(field, dict) and str(field.get("name") or "")
3305
3673
  }
3674
+ requires_custom_button_validation = any(
3675
+ any(binding.button_type == PublicViewButtonType.custom for binding in (patch.buttons or []))
3676
+ for patch in upsert_views
3677
+ )
3678
+ valid_custom_button_ids: set[int] = set()
3679
+ custom_button_details_by_id: dict[int, dict[str, Any]] = {}
3680
+ if requires_custom_button_validation:
3681
+ try:
3682
+ button_listing = self.buttons.custom_button_list(
3683
+ profile=profile,
3684
+ app_key=app_key,
3685
+ being_draft=True,
3686
+ include_raw=False,
3687
+ )
3688
+ except (QingflowApiError, RuntimeError) as error:
3689
+ api_error = _coerce_api_error(error)
3690
+ return finalize(
3691
+ _failed_from_api_error(
3692
+ "CUSTOM_BUTTON_LIST_FAILED",
3693
+ api_error,
3694
+ normalized_args=normalized_args,
3695
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="custom_button", error=api_error),
3696
+ suggested_next_call={"tool_name": "app_custom_button_list", "arguments": {"profile": profile, "app_key": app_key}},
3697
+ )
3698
+ )
3699
+ valid_custom_button_ids = {
3700
+ button_id
3701
+ for item in (button_listing.get("items") or [])
3702
+ if isinstance(item, dict) and (button_id := _coerce_positive_int(item.get("button_id"))) is not None
3703
+ }
3704
+ referenced_custom_button_ids = {
3705
+ binding.button_id
3706
+ for patch in upsert_views
3707
+ for binding in (patch.buttons or [])
3708
+ if binding.button_type == PublicViewButtonType.custom and binding.button_id in valid_custom_button_ids
3709
+ }
3710
+ for button_id in sorted(referenced_custom_button_ids):
3711
+ try:
3712
+ detail = self.buttons.custom_button_get(
3713
+ profile=profile,
3714
+ app_key=app_key,
3715
+ button_id=button_id,
3716
+ being_draft=True,
3717
+ include_raw=False,
3718
+ )
3719
+ except (QingflowApiError, RuntimeError):
3720
+ continue
3721
+ detail_result = detail.get("result")
3722
+ if isinstance(detail_result, dict):
3723
+ custom_button_details_by_id[button_id] = _normalize_custom_button_detail(detail_result)
3306
3724
  removed: list[str] = []
3307
3725
  view_results: list[dict[str, Any]] = []
3308
3726
  for name in remove_views:
@@ -3339,7 +3757,24 @@ class AiBuilderFacade:
3339
3757
  and _extract_view_name(view) not in remove_views
3340
3758
  ]
3341
3759
  for ordinal, patch in enumerate(upsert_views, start=1):
3342
- missing_columns = [name for name in patch.columns if name not in field_names]
3760
+ apply_columns = _resolve_view_visible_field_names(patch)
3761
+ ignored_system_columns = [
3762
+ name
3763
+ for name in [str(value or "").strip() for value in patch.columns]
3764
+ if name in _KNOWN_SYSTEM_VIEW_COLUMNS
3765
+ ]
3766
+ if patch.type in {PublicViewType.table, PublicViewType.card} and patch.columns and not apply_columns:
3767
+ return _failed(
3768
+ "VALIDATION_ERROR",
3769
+ "view columns must include at least one real app field; system columns cannot be applied directly",
3770
+ normalized_args=normalized_args,
3771
+ details={
3772
+ "app_key": app_key,
3773
+ "view_name": patch.name,
3774
+ "ignored_system_columns": ignored_system_columns,
3775
+ },
3776
+ )
3777
+ missing_columns = [name for name in apply_columns if name not in field_names]
3343
3778
  if missing_columns:
3344
3779
  return _failed(
3345
3780
  "UNKNOWN_VIEW_FIELD",
@@ -3349,6 +3784,7 @@ class AiBuilderFacade:
3349
3784
  "app_key": app_key,
3350
3785
  "view_name": patch.name,
3351
3786
  "missing_fields": missing_columns,
3787
+ "ignored_system_columns": ignored_system_columns,
3352
3788
  },
3353
3789
  missing_fields=missing_columns,
3354
3790
  suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
@@ -3396,6 +3832,33 @@ class AiBuilderFacade:
3396
3832
  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]},
3397
3833
  suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
3398
3834
  )
3835
+ explicit_button_dtos: list[dict[str, Any]] | None = None
3836
+ expected_button_summary: list[dict[str, Any]] | None = None
3837
+ if patch.buttons is not None:
3838
+ explicit_button_dtos, button_issues = _build_view_button_dtos(
3839
+ current_fields_by_name=current_fields_by_name,
3840
+ bindings=patch.buttons,
3841
+ valid_custom_button_ids=valid_custom_button_ids,
3842
+ )
3843
+ if button_issues:
3844
+ first_issue = button_issues[0]
3845
+ return _failed(
3846
+ str(first_issue.get("error_code") or "INVALID_VIEW_BUTTON"),
3847
+ "view buttons reference invalid fields, values, or custom buttons",
3848
+ normalized_args=normalized_args,
3849
+ details={
3850
+ "app_key": app_key,
3851
+ "view_name": patch.name,
3852
+ **first_issue,
3853
+ },
3854
+ missing_fields=list(first_issue.get("missing_fields") or []),
3855
+ 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]},
3856
+ suggested_next_call={"tool_name": "app_custom_button_list", "arguments": {"profile": profile, "app_key": app_key}},
3857
+ )
3858
+ expected_button_summary = _normalize_expected_view_buttons_for_compare(
3859
+ explicit_button_dtos or [],
3860
+ custom_button_details_by_id=custom_button_details_by_id,
3861
+ )
3399
3862
  matched_existing_view: dict[str, Any] | None = None
3400
3863
  existing_key: str | None = None
3401
3864
  if patch.view_key:
@@ -3441,17 +3904,21 @@ class AiBuilderFacade:
3441
3904
  schema=schema,
3442
3905
  patch=patch,
3443
3906
  view_filters=translated_filters,
3907
+ current_fields_by_name=current_fields_by_name,
3908
+ explicit_button_dtos=explicit_button_dtos,
3444
3909
  )
3445
3910
  self.views.view_update(profile=profile, viewgraph_key=existing_key, payload=payload)
3446
3911
  system_view_sync: dict[str, Any] | None = None
3447
3912
  if system_view_list_type is not None and patch.type.value == "table":
3448
3913
  operation_phase = "default_view_apply_config_sync"
3449
- system_view_sync = self._sync_system_view_apply_config(
3914
+ system_view_sync = self._sync_system_view_and_restore_buttons(
3450
3915
  profile=profile,
3451
3916
  app_key=app_key,
3917
+ viewgraph_key=existing_key,
3918
+ payload=payload,
3452
3919
  list_type=system_view_list_type,
3453
3920
  schema=schema,
3454
- visible_field_names=patch.columns,
3921
+ visible_field_names=apply_columns,
3455
3922
  )
3456
3923
  if not bool(system_view_sync.get("verified")):
3457
3924
  failure_entry = {
@@ -3473,6 +3940,7 @@ class AiBuilderFacade:
3473
3940
  "list_type": system_view_list_type,
3474
3941
  "expected_visible_order": system_view_sync.get("expected_visible_order"),
3475
3942
  "actual_visible_order": system_view_sync.get("actual_visible_order"),
3943
+ "apply_columns": apply_columns,
3476
3944
  },
3477
3945
  }
3478
3946
  failed_views.append(failure_entry)
@@ -3481,14 +3949,16 @@ class AiBuilderFacade:
3481
3949
  updated.append(patch.name)
3482
3950
  view_results.append(
3483
3951
  {
3484
- "name": patch.name,
3485
- "view_key": existing_key,
3486
- "type": patch.type.value,
3487
- "status": "updated",
3488
- "expected_filters": deepcopy(translated_filters),
3489
- "system_view_sync": system_view_sync,
3490
- }
3491
- )
3952
+ "name": patch.name,
3953
+ "view_key": existing_key,
3954
+ "type": patch.type.value,
3955
+ "status": "updated",
3956
+ "expected_filters": deepcopy(translated_filters),
3957
+ "expected_buttons": deepcopy(expected_button_summary),
3958
+ "system_view_sync": system_view_sync,
3959
+ "apply_columns": deepcopy(apply_columns),
3960
+ }
3961
+ )
3492
3962
  else:
3493
3963
  template_key = _pick_view_template_key(existing_view_list, desired_type=patch.type.value)
3494
3964
  should_copy_template = patch.type.value == "table" and template_key and not translated_filters
@@ -3502,6 +3972,8 @@ class AiBuilderFacade:
3502
3972
  schema=schema,
3503
3973
  patch=patch,
3504
3974
  view_filters=translated_filters,
3975
+ current_fields_by_name=current_fields_by_name,
3976
+ explicit_button_dtos=explicit_button_dtos,
3505
3977
  )
3506
3978
  self.views.view_update(profile=profile, viewgraph_key=created_key, payload=payload)
3507
3979
  else:
@@ -3512,6 +3984,8 @@ class AiBuilderFacade:
3512
3984
  patch=patch,
3513
3985
  ordinal=ordinal,
3514
3986
  view_filters=translated_filters,
3987
+ current_fields_by_name=current_fields_by_name,
3988
+ explicit_button_dtos=explicit_button_dtos,
3515
3989
  )
3516
3990
  create_result = self.views.view_create(profile=profile, payload=payload)
3517
3991
  raw_created = create_result.get("result")
@@ -3522,14 +3996,15 @@ class AiBuilderFacade:
3522
3996
  created_key = raw_created.strip() or None
3523
3997
  created.append(patch.name)
3524
3998
  view_results.append(
3525
- {
3526
- "name": patch.name,
3527
- "view_key": created_key,
3528
- "type": patch.type.value,
3529
- "status": "created",
3530
- "expected_filters": deepcopy(translated_filters),
3531
- }
3532
- )
3999
+ {
4000
+ "name": patch.name,
4001
+ "view_key": created_key,
4002
+ "type": patch.type.value,
4003
+ "status": "created",
4004
+ "expected_filters": deepcopy(translated_filters),
4005
+ "expected_buttons": deepcopy(expected_button_summary),
4006
+ }
4007
+ )
3533
4008
  except (QingflowApiError, RuntimeError) as error:
3534
4009
  api_error = _coerce_api_error(error)
3535
4010
  should_retry_minimal = operation_phase != "default_view_apply_config_sync" and (
@@ -3540,14 +4015,68 @@ class AiBuilderFacade:
3540
4015
  try:
3541
4016
  if existing_key or created_key:
3542
4017
  target_key = created_key or existing_key or ""
4018
+ fallback_button_dtos = explicit_button_dtos
4019
+ if fallback_button_dtos is None:
4020
+ fallback_config_response = self.views.view_get_config(profile=profile, viewgraph_key=target_key)
4021
+ fallback_config = (
4022
+ fallback_config_response.get("result")
4023
+ if isinstance(fallback_config_response.get("result"), dict)
4024
+ else {}
4025
+ )
4026
+ fallback_button_dtos = _extract_existing_view_button_dtos(fallback_config)
3543
4027
  fallback_payload = _build_minimal_view_payload(
3544
4028
  app_key=app_key,
3545
4029
  schema=schema,
3546
4030
  patch=patch,
3547
4031
  ordinal=ordinal,
3548
4032
  view_filters=translated_filters,
4033
+ current_fields_by_name=current_fields_by_name,
4034
+ explicit_button_dtos=fallback_button_dtos,
3549
4035
  )
3550
4036
  self.views.view_update(profile=profile, viewgraph_key=target_key, payload=fallback_payload)
4037
+ system_view_sync: dict[str, Any] | None = None
4038
+ fallback_system_view_list_type = (
4039
+ _resolve_system_view_list_type(view_key=target_key, view_name=patch.name)
4040
+ if patch.type.value == "table" and target_key
4041
+ else None
4042
+ )
4043
+ if fallback_system_view_list_type is not None:
4044
+ operation_phase = "default_view_apply_config_sync"
4045
+ system_view_sync = self._sync_system_view_and_restore_buttons(
4046
+ profile=profile,
4047
+ app_key=app_key,
4048
+ viewgraph_key=target_key,
4049
+ payload=fallback_payload,
4050
+ list_type=fallback_system_view_list_type,
4051
+ schema=schema,
4052
+ visible_field_names=apply_columns,
4053
+ )
4054
+ if not bool(system_view_sync.get("verified")):
4055
+ failure_entry = {
4056
+ "name": patch.name,
4057
+ "view_key": target_key,
4058
+ "type": patch.type.value,
4059
+ "status": "failed",
4060
+ "error_code": "SYSTEM_VIEW_ORDER_SYNC_FAILED",
4061
+ "message": "default view column order did not verify through app apply/baseInfo readback",
4062
+ "request_id": None,
4063
+ "backend_code": None,
4064
+ "http_status": None,
4065
+ "operation": "sync_default_view",
4066
+ "details": {
4067
+ "app_key": app_key,
4068
+ "view_name": patch.name,
4069
+ "view_key": target_key,
4070
+ "view_type": patch.type.value,
4071
+ "list_type": fallback_system_view_list_type,
4072
+ "expected_visible_order": system_view_sync.get("expected_visible_order"),
4073
+ "actual_visible_order": system_view_sync.get("actual_visible_order"),
4074
+ "apply_columns": apply_columns,
4075
+ },
4076
+ }
4077
+ failed_views.append(failure_entry)
4078
+ view_results.append(failure_entry)
4079
+ continue
3551
4080
  if existing_key:
3552
4081
  updated.append(patch.name)
3553
4082
  view_results.append(
@@ -3558,6 +4087,9 @@ class AiBuilderFacade:
3558
4087
  "status": "updated",
3559
4088
  "fallback_applied": True,
3560
4089
  "expected_filters": deepcopy(translated_filters),
4090
+ "expected_buttons": deepcopy(expected_button_summary),
4091
+ "system_view_sync": system_view_sync,
4092
+ "apply_columns": deepcopy(apply_columns),
3561
4093
  }
3562
4094
  )
3563
4095
  else:
@@ -3570,6 +4102,9 @@ class AiBuilderFacade:
3570
4102
  "status": "created",
3571
4103
  "fallback_applied": True,
3572
4104
  "expected_filters": deepcopy(translated_filters),
4105
+ "expected_buttons": deepcopy(expected_button_summary),
4106
+ "system_view_sync": system_view_sync,
4107
+ "apply_columns": deepcopy(apply_columns),
3573
4108
  }
3574
4109
  )
3575
4110
  continue
@@ -3579,6 +4114,8 @@ class AiBuilderFacade:
3579
4114
  patch=patch,
3580
4115
  ordinal=ordinal,
3581
4116
  view_filters=translated_filters,
4117
+ current_fields_by_name=current_fields_by_name,
4118
+ explicit_button_dtos=explicit_button_dtos,
3582
4119
  )
3583
4120
  self.views.view_create(profile=profile, payload=fallback_payload)
3584
4121
  created.append(patch.name)
@@ -3665,6 +4202,10 @@ class AiBuilderFacade:
3665
4202
  verification_by_view: list[dict[str, Any]] = []
3666
4203
  filter_readback_pending = False
3667
4204
  filter_mismatches: list[dict[str, Any]] = []
4205
+ button_readback_pending = False
4206
+ button_mismatches: list[dict[str, Any]] = []
4207
+ custom_button_readback_pending = False
4208
+ custom_button_readback_pending_entries: list[dict[str, Any]] = []
3668
4209
  for item in view_results:
3669
4210
  status = str(item.get("status") or "")
3670
4211
  name = str(item.get("name") or "")
@@ -3688,6 +4229,7 @@ class AiBuilderFacade:
3688
4229
  if isinstance(system_view_sync, dict):
3689
4230
  verification_entry["system_view_sync"] = deepcopy(system_view_sync)
3690
4231
  expected_filters = item.get("expected_filters") or []
4232
+ expected_buttons = item.get("expected_buttons") if isinstance(item.get("expected_buttons"), list) else None
3691
4233
  if expected_filters:
3692
4234
  if verified_views_unavailable or not present_in_readback:
3693
4235
  verification_entry["filters_verified"] = None
@@ -3743,6 +4285,70 @@ class AiBuilderFacade:
3743
4285
  "category": api_error.category,
3744
4286
  }
3745
4287
  filter_readback_pending = True
4288
+ if expected_buttons is not None:
4289
+ if verified_views_unavailable or not present_in_readback:
4290
+ verification_entry["buttons_verified"] = None
4291
+ verification_entry["button_readback_pending"] = True
4292
+ button_readback_pending = True
4293
+ else:
4294
+ verification_key = item_view_key
4295
+ if not verification_key:
4296
+ matched_keys = verified_view_keys_by_name.get(name) or []
4297
+ if len(matched_keys) == 1:
4298
+ verification_key = matched_keys[0]
4299
+ else:
4300
+ verification_entry["buttons_verified"] = None
4301
+ verification_entry["button_readback_pending"] = True
4302
+ verification_entry["readback_ambiguous"] = True
4303
+ verification_entry["matching_view_keys"] = matched_keys
4304
+ button_readback_pending = True
4305
+ verification_by_view.append(verification_entry)
4306
+ continue
4307
+ try:
4308
+ config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
4309
+ config_result = (config_response.get("result") or {}) if isinstance(config_response.get("result"), dict) else {}
4310
+ actual_buttons = _normalize_view_buttons_for_compare(config_result)
4311
+ button_comparison = _compare_view_button_summaries(
4312
+ expected=expected_buttons,
4313
+ actual=actual_buttons,
4314
+ )
4315
+ buttons_verified = bool(button_comparison.get("verified"))
4316
+ verification_entry["buttons_verified"] = buttons_verified
4317
+ verification_entry["view_key"] = verification_key
4318
+ verification_entry["expected_buttons"] = deepcopy(expected_buttons)
4319
+ verification_entry["actual_buttons"] = actual_buttons
4320
+ if button_comparison.get("custom_button_readback_pending"):
4321
+ verification_entry["custom_button_readback_pending"] = True
4322
+ verification_entry["pending_custom_buttons"] = deepcopy(button_comparison.get("pending_custom_buttons") or [])
4323
+ custom_button_readback_pending = True
4324
+ custom_button_readback_pending_entries.append(
4325
+ {
4326
+ "name": name,
4327
+ "type": item.get("type"),
4328
+ "view_key": verification_key,
4329
+ "pending_custom_buttons": deepcopy(button_comparison.get("pending_custom_buttons") or []),
4330
+ }
4331
+ )
4332
+ elif not buttons_verified:
4333
+ button_mismatches.append(
4334
+ {
4335
+ "name": name,
4336
+ "type": item.get("type"),
4337
+ "expected_buttons": deepcopy(expected_buttons),
4338
+ "actual_buttons": actual_buttons,
4339
+ }
4340
+ )
4341
+ except (QingflowApiError, RuntimeError) as error:
4342
+ api_error = _coerce_api_error(error)
4343
+ verification_entry["buttons_verified"] = None
4344
+ verification_entry["button_readback_pending"] = True
4345
+ verification_entry["request_id"] = api_error.request_id
4346
+ verification_entry["transport_error"] = {
4347
+ "http_status": api_error.http_status,
4348
+ "backend_code": api_error.backend_code,
4349
+ "category": api_error.category,
4350
+ }
4351
+ button_readback_pending = True
3746
4352
  verification_by_view.append(verification_entry)
3747
4353
  elif status == "removed":
3748
4354
  verification_by_view.append(
@@ -3769,6 +4375,7 @@ class AiBuilderFacade:
3769
4375
  and all(name not in verified_names for name in removed)
3770
4376
  )
3771
4377
  view_filters_verified = verified and not filter_readback_pending and not filter_mismatches
4378
+ view_buttons_verified = verified and not button_readback_pending and not button_mismatches
3772
4379
  noop = not created and not updated and not removed
3773
4380
  if failed_views:
3774
4381
  successful_changes = bool(created or updated or removed)
@@ -3781,34 +4388,73 @@ class AiBuilderFacade:
3781
4388
  "normalized_args": normalized_args,
3782
4389
  "missing_fields": [],
3783
4390
  "allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
3784
- "details": {"per_view_results": view_results, "filter_mismatches": filter_mismatches},
4391
+ "details": {
4392
+ "per_view_results": view_results,
4393
+ "filter_mismatches": filter_mismatches,
4394
+ "button_mismatches": button_mismatches,
4395
+ **(
4396
+ {"custom_button_readback_pending": deepcopy(custom_button_readback_pending_entries)}
4397
+ if custom_button_readback_pending_entries
4398
+ else {}
4399
+ ),
4400
+ },
3785
4401
  "request_id": first_failure.get("request_id"),
3786
4402
  "suggested_next_call": {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
3787
4403
  "backend_code": first_failure.get("backend_code"),
3788
4404
  "http_status": first_failure.get("http_status"),
3789
4405
  "noop": noop,
3790
- "warnings": [_warning("VIEW_FILTERS_UNVERIFIED", "view definitions may exist, but saved filter behavior is not fully verified")] if (filter_readback_pending or filter_mismatches) else [],
4406
+ "warnings": (
4407
+ (
4408
+ [_warning("VIEW_FILTERS_UNVERIFIED", "view definitions may exist, but saved filter behavior is not fully verified")]
4409
+ if (filter_readback_pending or filter_mismatches)
4410
+ else []
4411
+ )
4412
+ + (
4413
+ [_warning("VIEW_BUTTONS_UNVERIFIED", "view definitions may exist, but saved button behavior is not fully verified")]
4414
+ if (button_readback_pending or button_mismatches)
4415
+ else []
4416
+ )
4417
+ + (
4418
+ [_warning("VIEW_CUSTOM_BUTTON_READBACK_PENDING", "system buttons verified, but draft custom button bindings are not fully visible through view readback yet")]
4419
+ if custom_button_readback_pending
4420
+ else []
4421
+ )
4422
+ ),
3791
4423
  "verification": {
3792
4424
  "views_verified": verified,
3793
4425
  "view_filters_verified": view_filters_verified,
4426
+ "view_buttons_verified": view_buttons_verified,
3794
4427
  "views_read_unavailable": verified_views_unavailable,
3795
4428
  "by_view": verification_by_view,
4429
+ "custom_button_readback_pending": custom_button_readback_pending,
4430
+ "custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
3796
4431
  },
3797
4432
  "app_key": app_key,
3798
4433
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
3799
- "verified": verified and view_filters_verified,
4434
+ "verified": verified and view_filters_verified and view_buttons_verified,
3800
4435
  }
3801
4436
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3802
4437
  warnings: list[dict[str, Any]] = []
3803
4438
  if filter_readback_pending or filter_mismatches:
3804
4439
  warnings.append(_warning("VIEW_FILTERS_UNVERIFIED", "view definitions were applied, but saved filter behavior is not fully verified"))
4440
+ if button_readback_pending or button_mismatches:
4441
+ warnings.append(_warning("VIEW_BUTTONS_UNVERIFIED", "view definitions were applied, but saved button behavior is not fully verified"))
4442
+ if custom_button_readback_pending:
4443
+ warnings.append(
4444
+ _warning(
4445
+ "VIEW_CUSTOM_BUTTON_READBACK_PENDING",
4446
+ "system buttons verified, but draft custom button bindings are not fully visible through view readback yet",
4447
+ )
4448
+ )
3805
4449
  response = {
3806
- "status": "success" if verified and view_filters_verified else "partial_success",
3807
- "error_code": None if verified and view_filters_verified else ("VIEW_FILTER_READBACK_MISMATCH" if filter_mismatches else "VIEWS_READBACK_PENDING"),
3808
- "recoverable": not (verified and view_filters_verified),
4450
+ "status": "success" if verified and view_filters_verified and view_buttons_verified else "partial_success",
4451
+ "error_code": None if verified and view_filters_verified and view_buttons_verified else ("VIEW_BUTTON_READBACK_MISMATCH" if button_mismatches else "VIEW_FILTER_READBACK_MISMATCH" if filter_mismatches else "VIEWS_READBACK_PENDING"),
4452
+ "recoverable": not (verified and view_filters_verified and view_buttons_verified),
3809
4453
  "message": (
3810
4454
  "applied view patch"
3811
- if verified and view_filters_verified
4455
+ if verified and view_filters_verified and view_buttons_verified
4456
+ else "applied view patch; buttons did not fully verify"
4457
+ if button_mismatches
3812
4458
  else "applied view patch; filters did not fully verify"
3813
4459
  if filter_mismatches
3814
4460
  else "applied view patch; views readback pending"
@@ -3816,21 +4462,33 @@ class AiBuilderFacade:
3816
4462
  "normalized_args": normalized_args,
3817
4463
  "missing_fields": [],
3818
4464
  "allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
3819
- "details": {"filter_mismatches": filter_mismatches} if filter_mismatches else {},
4465
+ "details": {
4466
+ **({"filter_mismatches": filter_mismatches} if filter_mismatches else {}),
4467
+ **({"button_mismatches": button_mismatches} if button_mismatches else {}),
4468
+ **(
4469
+ {"custom_button_readback_pending": deepcopy(custom_button_readback_pending_entries)}
4470
+ if custom_button_readback_pending_entries
4471
+ else {}
4472
+ ),
4473
+ },
3820
4474
  "request_id": None,
3821
- "suggested_next_call": None if verified and view_filters_verified else {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
4475
+ "suggested_next_call": None if verified and view_filters_verified and view_buttons_verified else {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
3822
4476
  "noop": noop,
3823
4477
  "warnings": warnings,
3824
4478
  "verification": {
3825
4479
  "views_verified": verified,
3826
4480
  "view_filters_verified": view_filters_verified,
4481
+ "view_buttons_verified": view_buttons_verified,
3827
4482
  "views_read_unavailable": verified_views_unavailable,
3828
4483
  "filter_readback_pending": filter_readback_pending,
4484
+ "button_readback_pending": button_readback_pending,
4485
+ "custom_button_readback_pending": custom_button_readback_pending,
4486
+ "custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
3829
4487
  "by_view": verification_by_view,
3830
4488
  },
3831
4489
  "app_key": app_key,
3832
4490
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
3833
- "verified": verified and view_filters_verified,
4491
+ "verified": verified and view_filters_verified and view_buttons_verified,
3834
4492
  }
3835
4493
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3836
4494
 
@@ -4572,6 +5230,28 @@ class AiBuilderFacade:
4572
5230
  version_result = {}
4573
5231
  return _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or int(current_schema.get("editVersionNo") or 1)
4574
5232
 
5233
+ def _ensure_app_edit_context(
5234
+ self,
5235
+ *,
5236
+ profile: str,
5237
+ app_key: str,
5238
+ normalized_args: dict[str, Any],
5239
+ failure_code: str,
5240
+ ) -> tuple[int | None, JSONObject | None]:
5241
+ try:
5242
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
5243
+ except (QingflowApiError, RuntimeError) as error:
5244
+ api_error = _coerce_api_error(error)
5245
+ return None, _failed_from_api_error(
5246
+ failure_code,
5247
+ api_error,
5248
+ normalized_args=normalized_args,
5249
+ details={"app_key": app_key, "phase": "prepare_edit_context"},
5250
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
5251
+ )
5252
+ edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
5253
+ return edit_version_no, None
5254
+
4575
5255
  def _append_publish_result(self, *, profile: str, app_key: str, publish: bool, response: JSONObject) -> JSONObject:
4576
5256
  verification = response.get("verification")
4577
5257
  if not isinstance(verification, dict):
@@ -4651,6 +5331,29 @@ class AiBuilderFacade:
4651
5331
  "verified": verified,
4652
5332
  }
4653
5333
 
5334
+ def _sync_system_view_and_restore_buttons(
5335
+ self,
5336
+ *,
5337
+ profile: str,
5338
+ app_key: str,
5339
+ viewgraph_key: str,
5340
+ payload: dict[str, Any],
5341
+ list_type: int,
5342
+ schema: dict[str, Any],
5343
+ visible_field_names: list[str],
5344
+ ) -> dict[str, Any]:
5345
+ sync_result = self._sync_system_view_apply_config(
5346
+ profile=profile,
5347
+ app_key=app_key,
5348
+ list_type=list_type,
5349
+ schema=schema,
5350
+ visible_field_names=visible_field_names,
5351
+ )
5352
+ if bool(sync_result.get("verified")) and "buttonConfigDTOList" in payload:
5353
+ self.views.view_update(profile=profile, viewgraph_key=viewgraph_key, payload=payload)
5354
+ sync_result = {**sync_result, "button_config_restored": True}
5355
+ return sync_result
5356
+
4654
5357
  def _load_views_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
4655
5358
  try:
4656
5359
  views = self.views.view_list_flat(profile=profile, app_key=app_key)
@@ -5002,6 +5705,158 @@ class AiBuilderFacade:
5002
5705
  raise QingflowApiError(category="runtime", message="failed to attach app to package")
5003
5706
 
5004
5707
 
5708
+ def _extract_custom_button_id(value: Any) -> int | None:
5709
+ if isinstance(value, dict):
5710
+ for key in ("buttonId", "customButtonId", "id"):
5711
+ button_id = _coerce_positive_int(value.get(key))
5712
+ if button_id is not None:
5713
+ return button_id
5714
+ nested_result = value.get("result")
5715
+ if nested_result is not value:
5716
+ return _extract_custom_button_id(nested_result)
5717
+ return None
5718
+ return _coerce_positive_int(value)
5719
+
5720
+
5721
+ def _serialize_custom_button_payload(payload: CustomButtonPatch) -> dict[str, Any]:
5722
+ data = payload.model_dump(mode="json", exclude_none=True)
5723
+ serialized: dict[str, Any] = {
5724
+ "buttonText": data["button_text"],
5725
+ "backgroundColor": data["background_color"],
5726
+ "textColor": data["text_color"],
5727
+ "buttonIcon": data["button_icon"],
5728
+ "triggerAction": data["trigger_action"],
5729
+ }
5730
+ if str(data.get("trigger_link_url") or "").strip():
5731
+ serialized["triggerLinkUrl"] = data["trigger_link_url"]
5732
+ trigger_add_data_config = data.get("trigger_add_data_config")
5733
+ if isinstance(trigger_add_data_config, dict):
5734
+ serialized["triggerAddDataConfig"] = _serialize_custom_button_add_data_config(trigger_add_data_config)
5735
+ else:
5736
+ serialized["triggerAddDataConfig"] = _serialize_custom_button_add_data_config({})
5737
+ external_qrobot_config = data.get("external_qrobot_config")
5738
+ if isinstance(external_qrobot_config, dict):
5739
+ serialized["customButtonExternalQRobotRelationVO"] = _serialize_custom_button_external_qrobot_config(external_qrobot_config)
5740
+ trigger_wings_config = data.get("trigger_wings_config")
5741
+ if isinstance(trigger_wings_config, dict):
5742
+ serialized["triggerWingsConfig"] = _serialize_custom_button_wings_config(trigger_wings_config)
5743
+ return serialized
5744
+
5745
+
5746
+ def _serialize_custom_button_add_data_config(value: dict[str, Any]) -> dict[str, Any]:
5747
+ relation_rules = value.get("que_relation") or []
5748
+ return {
5749
+ "relatedAppKey": value.get("related_app_key"),
5750
+ "relatedAppName": value.get("related_app_name"),
5751
+ "queRelation": [_serialize_custom_button_match_rule(rule) for rule in relation_rules if isinstance(rule, dict)],
5752
+ }
5753
+
5754
+
5755
+ def _serialize_custom_button_external_qrobot_config(value: dict[str, Any]) -> dict[str, Any]:
5756
+ return {
5757
+ "externalQRobotConfigId": value.get("external_qrobot_config_id"),
5758
+ "triggeredText": value.get("triggered_text"),
5759
+ }
5760
+
5761
+
5762
+ def _serialize_custom_button_wings_config(value: dict[str, Any]) -> dict[str, Any]:
5763
+ return {
5764
+ "wingsAgentId": value.get("wings_agent_id"),
5765
+ "wingsAgentName": value.get("wings_agent_name"),
5766
+ "bindQueIdList": list(value.get("bind_que_id_list") or []),
5767
+ "bindFileQueIdList": list(value.get("bind_file_que_id_list") or []),
5768
+ "defaultPrompt": value.get("default_prompt"),
5769
+ "beingAutoSend": value.get("being_auto_send"),
5770
+ }
5771
+
5772
+
5773
+ def _serialize_custom_button_match_rule(value: dict[str, Any]) -> dict[str, Any]:
5774
+ serialized = {
5775
+ "queId": value.get("que_id"),
5776
+ "queTitle": value.get("que_title"),
5777
+ "queType": value.get("que_type"),
5778
+ "dateType": value.get("date_type"),
5779
+ "judgeType": value.get("judge_type"),
5780
+ "matchType": value.get("match_type"),
5781
+ "judgeValues": list(value.get("judge_values") or []),
5782
+ "judgeQueType": value.get("judge_que_type"),
5783
+ "judgeQueId": value.get("judge_que_id"),
5784
+ "pathValue": value.get("path_value"),
5785
+ "tableUpdateType": value.get("table_update_type"),
5786
+ "multiValue": value.get("multi_value"),
5787
+ "addRule": value.get("add_rule"),
5788
+ "fieldIdPrefix": value.get("field_id_prefix"),
5789
+ }
5790
+ judge_que_detail = value.get("judge_que_detail")
5791
+ if isinstance(judge_que_detail, dict):
5792
+ serialized["judgeQueDetail"] = {
5793
+ "queId": judge_que_detail.get("que_id"),
5794
+ "queTitle": judge_que_detail.get("que_title"),
5795
+ "queType": judge_que_detail.get("que_type"),
5796
+ }
5797
+ judge_value_details = value.get("judge_value_details")
5798
+ if isinstance(judge_value_details, list):
5799
+ serialized["judgeValueDetails"] = [
5800
+ {"id": item.get("id"), "value": item.get("value")}
5801
+ for item in judge_value_details
5802
+ if isinstance(item, dict)
5803
+ ]
5804
+ filter_condition = value.get("filter_condition")
5805
+ if isinstance(filter_condition, list):
5806
+ serialized["filterCondition"] = [
5807
+ [_serialize_custom_button_match_rule(item) for item in group if isinstance(item, dict)]
5808
+ for group in filter_condition
5809
+ if isinstance(group, list)
5810
+ ]
5811
+ return {key: deepcopy(item) for key, item in serialized.items() if item is not None}
5812
+
5813
+
5814
+ def _normalize_custom_button_summary(item: dict[str, Any]) -> dict[str, Any]:
5815
+ normalized = {
5816
+ "button_id": _coerce_positive_int(item.get("button_id") or item.get("buttonId") or item.get("id")),
5817
+ "button_text": str(item.get("button_text") or item.get("buttonText") or "").strip() or None,
5818
+ "button_icon": str(item.get("button_icon") or item.get("buttonIcon") or "").strip() or None,
5819
+ "background_color": str(item.get("background_color") or item.get("backgroundColor") or "").strip() or None,
5820
+ "text_color": str(item.get("text_color") or item.get("textColor") or "").strip() or None,
5821
+ "used_in_chart_count": _coerce_nonnegative_int(item.get("used_in_chart_count") or item.get("userInChartCount")),
5822
+ "being_effective_external_qrobot": bool(item.get("being_effective_external_qrobot") or item.get("beingEffectiveExternalQRobot")),
5823
+ }
5824
+ creator = item.get("creator_user_info") if isinstance(item.get("creator_user_info"), dict) else item.get("creatorUserInfo")
5825
+ if isinstance(creator, dict):
5826
+ normalized["creator_user_info"] = {
5827
+ "uid": creator.get("uid"),
5828
+ "name": creator.get("name"),
5829
+ "email": creator.get("email"),
5830
+ }
5831
+ return normalized
5832
+
5833
+
5834
+ def _normalize_custom_button_detail(item: dict[str, Any]) -> dict[str, Any]:
5835
+ normalized = _normalize_custom_button_summary(item)
5836
+ normalized.update(
5837
+ {
5838
+ "trigger_action": str(item.get("trigger_action") or item.get("triggerAction") or "").strip() or None,
5839
+ "trigger_link_url": str(item.get("trigger_link_url") or item.get("triggerLinkUrl") or "").strip() or None,
5840
+ }
5841
+ )
5842
+ trigger_add_data_config = item.get("trigger_add_data_config")
5843
+ if not isinstance(trigger_add_data_config, dict):
5844
+ trigger_add_data_config = item.get("triggerAddDataConfig")
5845
+ if isinstance(trigger_add_data_config, dict):
5846
+ normalized["trigger_add_data_config"] = deepcopy(trigger_add_data_config)
5847
+ external_qrobot_config = item.get("external_qrobot_config")
5848
+ if not isinstance(external_qrobot_config, dict):
5849
+ external_qrobot_config = item.get("customButtonExternalQRobotRelationVO")
5850
+ if isinstance(external_qrobot_config, dict):
5851
+ normalized["external_qrobot_config"] = deepcopy(external_qrobot_config)
5852
+ trigger_wings_config = item.get("trigger_wings_config")
5853
+ if not isinstance(trigger_wings_config, dict):
5854
+ trigger_wings_config = item.get("triggerWingsConfig")
5855
+ if isinstance(trigger_wings_config, dict):
5856
+ normalized["trigger_wings_config"] = deepcopy(trigger_wings_config)
5857
+ return normalized
5858
+
5859
+
5005
5860
  def _failed(
5006
5861
  error_code: str,
5007
5862
  message: str,
@@ -7439,6 +8294,36 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
7439
8294
  for entry in display_entries
7440
8295
  if str(entry.get("name") or "").strip()
7441
8296
  ]
8297
+ apply_entries = [
8298
+ entry
8299
+ for entry in display_entries
8300
+ if _coerce_nonnegative_int(entry.get("field_id")) is not None
8301
+ and str(entry.get("name") or "").strip()
8302
+ and str(entry.get("name") or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
8303
+ ]
8304
+ apply_column_ids = [
8305
+ field_id
8306
+ for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in apply_entries)
8307
+ if field_id is not None
8308
+ ]
8309
+ apply_columns = [
8310
+ str(entry.get("name") or "").strip()
8311
+ for entry in apply_entries
8312
+ if str(entry.get("name") or "").strip()
8313
+ ]
8314
+ if not apply_columns and configured_column_ids:
8315
+ apply_columns = [
8316
+ str((question_entries_by_id.get(field_id) or {}).get("name") or "").strip()
8317
+ for field_id in configured_column_ids
8318
+ if str((question_entries_by_id.get(field_id) or {}).get("name") or "").strip()
8319
+ and str((question_entries_by_id.get(field_id) or {}).get("name") or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
8320
+ ]
8321
+ apply_column_ids = [
8322
+ field_id
8323
+ for field_id in configured_column_ids
8324
+ if str((question_entries_by_id.get(field_id) or {}).get("name") or "").strip()
8325
+ and str((question_entries_by_id.get(field_id) or {}).get("name") or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
8326
+ ]
7442
8327
  if not display_columns and configured_columns:
7443
8328
  display_columns = configured_columns
7444
8329
  display_column_ids = list(configured_column_ids)
@@ -7454,6 +8339,10 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
7454
8339
  summary["configured_columns"] = configured_columns
7455
8340
  summary["configured_column_ids"] = configured_column_ids
7456
8341
  config_enriched = True
8342
+ if apply_columns:
8343
+ summary["apply_columns"] = apply_columns
8344
+ summary["apply_column_ids"] = apply_column_ids
8345
+ config_enriched = True
7457
8346
  if question_entries:
7458
8347
  summary["column_details"] = display_entries or _sort_view_question_entries(question_entries)
7459
8348
  config_enriched = True
@@ -7467,6 +8356,18 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
7467
8356
  if not summary.get("group_by") and display_config.get("group_by"):
7468
8357
  summary["group_by"] = display_config.get("group_by")
7469
8358
  config_enriched = True
8359
+ button_entries, button_source = _extract_view_button_entries(config)
8360
+ if button_entries:
8361
+ summary["buttons"] = [_normalize_view_button_entry(entry) for entry in button_entries]
8362
+ summary["button_count"] = len(button_entries)
8363
+ if button_source:
8364
+ summary["button_read_source"] = button_source
8365
+ config_enriched = True
8366
+ elif button_source:
8367
+ summary["buttons"] = []
8368
+ summary["button_count"] = 0
8369
+ summary["button_read_source"] = button_source
8370
+ config_enriched = True
7470
8371
  if config_enriched:
7471
8372
  summary["read_source"] = "view_config"
7472
8373
  return summary
@@ -7580,6 +8481,454 @@ def _extract_view_display_config(
7580
8481
  return display_config
7581
8482
 
7582
8483
 
8484
+ def _normalize_view_button_type(value: Any) -> str | None:
8485
+ normalized = str(value or "").strip().upper()
8486
+ if normalized in {"SYSTEM", "CUSTOM"}:
8487
+ return normalized
8488
+ return None
8489
+
8490
+
8491
+ def _normalize_view_button_config_type(value: Any) -> str | None:
8492
+ normalized = str(value or "").strip().upper()
8493
+ if normalized in {"TOP", "DETAIL"}:
8494
+ return normalized
8495
+ return None
8496
+
8497
+
8498
+ def _normalize_print_tpls(value: Any) -> list[dict[str, Any]]:
8499
+ if not isinstance(value, list):
8500
+ return []
8501
+ items: list[dict[str, Any]] = []
8502
+ for item in value:
8503
+ if isinstance(item, dict):
8504
+ tpl_id = item.get("printTplId")
8505
+ if tpl_id is None:
8506
+ tpl_id = item.get("templateId")
8507
+ if tpl_id is None:
8508
+ tpl_id = item.get("id")
8509
+ tpl_name = item.get("printTplName")
8510
+ if tpl_name is None:
8511
+ tpl_name = item.get("templateName")
8512
+ if tpl_name is None:
8513
+ tpl_name = item.get("name")
8514
+ normalized: dict[str, Any] = {}
8515
+ if tpl_id is not None:
8516
+ normalized["id"] = str(tpl_id)
8517
+ if str(tpl_name or "").strip():
8518
+ normalized["name"] = str(tpl_name).strip()
8519
+ if normalized:
8520
+ items.append(normalized)
8521
+ continue
8522
+ scalar = str(item or "").strip()
8523
+ if scalar:
8524
+ items.append({"id": scalar})
8525
+ return items
8526
+
8527
+
8528
+ def _normalize_print_tpls_for_compare(value: Any) -> list[str]:
8529
+ normalized_items: list[str] = []
8530
+ for item in _normalize_print_tpls(value):
8531
+ item_id = str(item.get("id") or "").strip()
8532
+ item_name = str(item.get("name") or "").strip()
8533
+ normalized_items.append(item_id or item_name)
8534
+ return normalized_items
8535
+
8536
+
8537
+ def _serialize_print_tpl_ids(value: Any) -> list[str]:
8538
+ return [item for item in _normalize_print_tpls_for_compare(value) if item]
8539
+
8540
+
8541
+ def _extract_view_button_entries(config: dict[str, Any]) -> tuple[list[dict[str, Any]], str | None]:
8542
+ if not isinstance(config, dict):
8543
+ return [], None
8544
+ raw_vo = config.get("buttonConfigVO")
8545
+ if isinstance(raw_vo, list):
8546
+ return [deepcopy(item) for item in raw_vo if isinstance(item, dict)], "buttonConfigVO"
8547
+ raw_dtos = config.get("buttonConfigDTOList")
8548
+ if isinstance(raw_dtos, list):
8549
+ return [deepcopy(item) for item in raw_dtos if isinstance(item, dict)], "buttonConfigDTOList"
8550
+ grouped = config.get("buttonConfig")
8551
+ if not isinstance(grouped, dict):
8552
+ return [], None
8553
+ entries: list[dict[str, Any]] = []
8554
+ for item in grouped.get("topButtonList") or []:
8555
+ if not isinstance(item, dict):
8556
+ continue
8557
+ entry = deepcopy(item)
8558
+ entry.setdefault("configType", "TOP")
8559
+ entry.setdefault("beingMain", True)
8560
+ entries.append(entry)
8561
+ for item in grouped.get("mainButtonDetailList") or []:
8562
+ if not isinstance(item, dict):
8563
+ continue
8564
+ entry = deepcopy(item)
8565
+ entry.setdefault("configType", "DETAIL")
8566
+ entry["beingMain"] = True
8567
+ entries.append(entry)
8568
+ for item in grouped.get("moreButtonDetailList") or []:
8569
+ if not isinstance(item, dict):
8570
+ continue
8571
+ entry = deepcopy(item)
8572
+ entry.setdefault("configType", "DETAIL")
8573
+ entry["beingMain"] = False
8574
+ entries.append(entry)
8575
+ return entries, "buttonConfig"
8576
+
8577
+
8578
+ def _normalize_view_button_entry(entry: dict[str, Any]) -> dict[str, Any]:
8579
+ button_id = _coerce_positive_int(entry.get("buttonId") or entry.get("button_id") or entry.get("id"))
8580
+ button_text = str(entry.get("buttonText") or "").strip() or None
8581
+ default_button_text = str(entry.get("defaultButtonText") or "").strip() or None
8582
+ normalized: dict[str, Any] = {
8583
+ "button_type": _normalize_view_button_type(entry.get("buttonType") or entry.get("button_type")),
8584
+ "config_type": _normalize_view_button_config_type(entry.get("configType") or entry.get("config_type")),
8585
+ "button_id": button_id,
8586
+ "button_text": button_text or default_button_text,
8587
+ "being_main": bool(entry.get("beingMain", False)),
8588
+ "trigger_action": str(entry.get("triggerAction") or "").strip() or None,
8589
+ "print_tpls": _normalize_print_tpls(entry.get("printTpls")),
8590
+ "button_formula_type": _coerce_positive_int(entry.get("buttonFormulaType")) or 1,
8591
+ "button_limit": _normalize_view_filter_groups_for_compare(entry.get("buttonLimit")),
8592
+ }
8593
+ for public_key, source_key in (
8594
+ ("default_button_text", "defaultButtonText"),
8595
+ ("button_icon", "buttonIcon"),
8596
+ ("background_color", "backgroundColor"),
8597
+ ("text_color", "textColor"),
8598
+ ("trigger_link_url", "triggerLinkUrl"),
8599
+ ("button_formula", "buttonFormula"),
8600
+ ):
8601
+ value = entry.get(source_key)
8602
+ if isinstance(value, str):
8603
+ value = value.strip() or None
8604
+ if value not in {None, ""}:
8605
+ normalized[public_key] = deepcopy(value)
8606
+ trigger_add_data_config = entry.get("triggerAddDataConfig")
8607
+ if isinstance(trigger_add_data_config, dict):
8608
+ normalized["trigger_add_data_config"] = deepcopy(trigger_add_data_config)
8609
+ trigger_wings_config = entry.get("triggerWingsConfig")
8610
+ if isinstance(trigger_wings_config, dict):
8611
+ normalized["trigger_wings_config"] = deepcopy(trigger_wings_config)
8612
+ return normalized
8613
+
8614
+
8615
+ def _normalize_view_buttons_for_compare(value: Any) -> list[dict[str, Any]]:
8616
+ if isinstance(value, dict):
8617
+ entries, _ = _extract_view_button_entries(value)
8618
+ elif isinstance(value, list):
8619
+ entries = [item for item in value if isinstance(item, dict)]
8620
+ else:
8621
+ entries = []
8622
+ normalized_entries: list[dict[str, Any]] = []
8623
+ for entry in entries:
8624
+ normalized = _normalize_view_button_entry(entry)
8625
+ normalized_entries.append(
8626
+ {
8627
+ "button_type": normalized.get("button_type"),
8628
+ "config_type": normalized.get("config_type"),
8629
+ "button_id": normalized.get("button_id") if normalized.get("button_type") == "CUSTOM" else None,
8630
+ "button_text": normalized.get("button_text"),
8631
+ "button_icon": normalized.get("button_icon"),
8632
+ "background_color": normalized.get("background_color"),
8633
+ "text_color": normalized.get("text_color"),
8634
+ "trigger_action": normalized.get("trigger_action"),
8635
+ "trigger_link_url": normalized.get("trigger_link_url"),
8636
+ "being_main": bool(normalized.get("being_main", False)),
8637
+ "print_tpls": _normalize_print_tpls_for_compare(normalized.get("print_tpls")),
8638
+ "button_formula": str(normalized.get("button_formula") or ""),
8639
+ "button_formula_type": _coerce_positive_int(normalized.get("button_formula_type")) or 1,
8640
+ "button_limit": deepcopy(normalized.get("button_limit") or []),
8641
+ }
8642
+ )
8643
+ return normalized_entries
8644
+
8645
+
8646
+ _SYSTEM_VIEW_BUTTON_ID_BY_ACTION: dict[tuple[str, str], int] = {
8647
+ ("TOP", "set"): 1,
8648
+ ("TOP", "switchView"): 2,
8649
+ ("TOP", "setRowHeight"): 3,
8650
+ ("TOP", "search"): 6,
8651
+ ("DETAIL", "share"): 7,
8652
+ ("DETAIL", "edit"): 8,
8653
+ }
8654
+
8655
+ _SYSTEM_VIEW_BUTTON_ID_BY_TEXT: dict[tuple[str, str], int] = {
8656
+ ("TOP", "字段管理"): 1,
8657
+ ("TOP", "视图类型"): 2,
8658
+ ("TOP", "行高"): 3,
8659
+ ("TOP", "搜索"): 6,
8660
+ ("DETAIL", "分享"): 7,
8661
+ ("DETAIL", "修改"): 8,
8662
+ ("DETAIL", "修改记录"): 8,
8663
+ }
8664
+
8665
+
8666
+ def _resolve_system_view_button_logical_id(entry: dict[str, Any]) -> int | None:
8667
+ config_type = _normalize_view_button_config_type(entry.get("configType") or entry.get("config_type")) or ""
8668
+ trigger_action = str(entry.get("triggerAction") or entry.get("trigger_action") or "").strip()
8669
+ if config_type and trigger_action:
8670
+ mapped = _SYSTEM_VIEW_BUTTON_ID_BY_ACTION.get((config_type, trigger_action))
8671
+ if mapped is not None:
8672
+ return mapped
8673
+ button_text = str(entry.get("buttonText") or entry.get("button_text") or "").strip()
8674
+ default_button_text = str(entry.get("defaultButtonText") or entry.get("default_button_text") or "").strip()
8675
+ for candidate in (button_text, default_button_text):
8676
+ if config_type and candidate:
8677
+ mapped = _SYSTEM_VIEW_BUTTON_ID_BY_TEXT.get((config_type, candidate))
8678
+ if mapped is not None:
8679
+ return mapped
8680
+ button_id = _coerce_positive_int(entry.get("buttonId") or entry.get("button_id") or entry.get("id"))
8681
+ if button_id is not None and button_id < 1000:
8682
+ return button_id
8683
+ return None
8684
+
8685
+
8686
+ def _normalize_expected_view_buttons_for_compare(
8687
+ value: Any,
8688
+ *,
8689
+ custom_button_details_by_id: dict[int, dict[str, Any]] | None = None,
8690
+ ) -> list[dict[str, Any]]:
8691
+ normalized_entries = _normalize_view_buttons_for_compare(value)
8692
+ if not custom_button_details_by_id:
8693
+ return normalized_entries
8694
+ enriched_entries: list[dict[str, Any]] = []
8695
+ for item in normalized_entries:
8696
+ enriched = deepcopy(item)
8697
+ if enriched.get("button_type") == "CUSTOM":
8698
+ button_id = _coerce_positive_int(enriched.get("button_id"))
8699
+ detail = custom_button_details_by_id.get(button_id or -1)
8700
+ if isinstance(detail, dict):
8701
+ for key in (
8702
+ "button_text",
8703
+ "button_icon",
8704
+ "background_color",
8705
+ "text_color",
8706
+ "trigger_action",
8707
+ "trigger_link_url",
8708
+ ):
8709
+ value = detail.get(key)
8710
+ if value not in {None, ""}:
8711
+ enriched[key] = deepcopy(value)
8712
+ enriched_entries.append(enriched)
8713
+ return enriched_entries
8714
+
8715
+
8716
+ def _partition_view_button_summaries(
8717
+ items: list[dict[str, Any]],
8718
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
8719
+ system_buttons: list[dict[str, Any]] = []
8720
+ custom_buttons: list[dict[str, Any]] = []
8721
+ other_buttons: list[dict[str, Any]] = []
8722
+ for item in items:
8723
+ button_type = str(item.get("button_type") or "").strip().upper()
8724
+ if button_type == "SYSTEM":
8725
+ system_buttons.append(item)
8726
+ elif button_type == "CUSTOM":
8727
+ custom_buttons.append(item)
8728
+ else:
8729
+ other_buttons.append(item)
8730
+ return system_buttons, custom_buttons, other_buttons
8731
+
8732
+
8733
+ def _compare_view_button_summaries(
8734
+ *,
8735
+ expected: list[dict[str, Any]],
8736
+ actual: list[dict[str, Any]],
8737
+ ) -> dict[str, Any]:
8738
+ if actual == expected:
8739
+ return {
8740
+ "verified": True,
8741
+ "custom_button_readback_pending": False,
8742
+ "pending_custom_buttons": [],
8743
+ }
8744
+ expected_system, expected_custom, expected_other = _partition_view_button_summaries(expected)
8745
+ actual_system, actual_custom, actual_other = _partition_view_button_summaries(actual)
8746
+ custom_button_readback_pending = (
8747
+ bool(expected_custom)
8748
+ and not actual_custom
8749
+ and actual_system == expected_system
8750
+ and actual_other == expected_other
8751
+ )
8752
+ return {
8753
+ "verified": custom_button_readback_pending,
8754
+ "custom_button_readback_pending": custom_button_readback_pending,
8755
+ "pending_custom_buttons": deepcopy(expected_custom) if custom_button_readback_pending else [],
8756
+ }
8757
+
8758
+
8759
+ def _serialize_existing_view_button_entry(entry: dict[str, Any]) -> dict[str, Any]:
8760
+ dto: dict[str, Any] = {}
8761
+ button_type = _normalize_view_button_type(entry.get("buttonType") or entry.get("button_type"))
8762
+ button_id = _coerce_positive_int(entry.get("buttonId") or entry.get("button_id") or entry.get("id"))
8763
+ if button_type == "SYSTEM":
8764
+ button_id = _resolve_system_view_button_logical_id(entry)
8765
+ if button_id is not None:
8766
+ dto["buttonId"] = button_id
8767
+ if button_type is not None:
8768
+ dto["buttonType"] = button_type
8769
+ config_type = _normalize_view_button_config_type(entry.get("configType") or entry.get("config_type"))
8770
+ if config_type is not None:
8771
+ dto["configType"] = config_type
8772
+ dto["beingMain"] = bool(entry.get("beingMain", False))
8773
+ dto["buttonLimit"] = deepcopy(entry.get("buttonLimit") or [])
8774
+ dto["buttonFormula"] = str(entry.get("buttonFormula") or "")
8775
+ dto["buttonFormulaType"] = _coerce_positive_int(entry.get("buttonFormulaType")) or 1
8776
+ dto["printTpls"] = _serialize_print_tpl_ids(entry.get("printTpls"))
8777
+ for source_key, target_key in (
8778
+ ("buttonText", "buttonText"),
8779
+ ("defaultButtonText", "defaultButtonText"),
8780
+ ("buttonIcon", "buttonIcon"),
8781
+ ("backgroundColor", "backgroundColor"),
8782
+ ("textColor", "textColor"),
8783
+ ("triggerAction", "triggerAction"),
8784
+ ("triggerLinkUrl", "triggerLinkUrl"),
8785
+ ):
8786
+ if source_key in entry:
8787
+ dto[target_key] = deepcopy(entry.get(source_key))
8788
+ if isinstance(entry.get("triggerAddDataConfig"), dict):
8789
+ dto["triggerAddDataConfig"] = deepcopy(entry.get("triggerAddDataConfig"))
8790
+ if isinstance(entry.get("triggerWingsConfig"), dict):
8791
+ dto["triggerWingsConfig"] = deepcopy(entry.get("triggerWingsConfig"))
8792
+ return dto
8793
+
8794
+
8795
+ def _extract_existing_view_button_dtos(config: dict[str, Any]) -> list[dict[str, Any]]:
8796
+ if not isinstance(config, dict):
8797
+ return []
8798
+ entries, source = _extract_view_button_entries(config)
8799
+ if source == "buttonConfigDTOList":
8800
+ return [deepcopy(item) for item in entries if isinstance(item, dict)]
8801
+ return [_serialize_existing_view_button_entry(entry) for entry in entries if isinstance(entry, dict)]
8802
+
8803
+
8804
+ def _resolve_view_button_dtos_for_patch(
8805
+ *,
8806
+ config: dict[str, Any],
8807
+ patch: ViewUpsertPatch,
8808
+ explicit_button_dtos: list[dict[str, Any]] | None,
8809
+ ) -> list[dict[str, Any]] | None:
8810
+ if patch.buttons is None:
8811
+ return _extract_existing_view_button_dtos(config)
8812
+ return deepcopy(explicit_button_dtos or [])
8813
+
8814
+
8815
+ def _build_grouped_view_button_config(button_config_dtos: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
8816
+ grouped = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": []}
8817
+ for raw_item in button_config_dtos:
8818
+ if not isinstance(raw_item, dict):
8819
+ continue
8820
+ item = deepcopy(raw_item)
8821
+ config_type = _normalize_view_button_config_type(item.get("configType"))
8822
+ being_main = bool(item.get("beingMain", False))
8823
+ if config_type == "TOP":
8824
+ grouped["topButtonList"].append(item)
8825
+ elif being_main:
8826
+ grouped["mainButtonDetailList"].append(item)
8827
+ else:
8828
+ grouped["moreButtonDetailList"].append(item)
8829
+ return grouped
8830
+
8831
+
8832
+ def _build_view_button_dtos(
8833
+ *,
8834
+ current_fields_by_name: dict[str, dict[str, Any]],
8835
+ bindings: list[ViewButtonBindingPatch],
8836
+ valid_custom_button_ids: set[int],
8837
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
8838
+ dtos: list[dict[str, Any]] = []
8839
+ issues: list[dict[str, Any]] = []
8840
+ for binding in bindings:
8841
+ dto, binding_issues = _serialize_view_button_binding(
8842
+ binding=binding,
8843
+ current_fields_by_name=current_fields_by_name,
8844
+ valid_custom_button_ids=valid_custom_button_ids,
8845
+ )
8846
+ if binding_issues:
8847
+ issues.extend(binding_issues)
8848
+ continue
8849
+ dtos.append(dto)
8850
+ return dtos, issues
8851
+
8852
+
8853
+ def _serialize_view_button_binding(
8854
+ *,
8855
+ binding: ViewButtonBindingPatch,
8856
+ current_fields_by_name: dict[str, dict[str, Any]],
8857
+ valid_custom_button_ids: set[int],
8858
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
8859
+ if binding.button_type == PublicViewButtonType.custom and binding.button_id not in valid_custom_button_ids:
8860
+ return {}, [
8861
+ {
8862
+ "error_code": "UNKNOWN_CUSTOM_BUTTON",
8863
+ "reason_path": "buttons[].button_id",
8864
+ "missing_fields": [],
8865
+ "details": {"button_id": binding.button_id},
8866
+ }
8867
+ ]
8868
+ translated_limits, limit_issues = _build_view_button_limit_groups(
8869
+ current_fields_by_name=current_fields_by_name,
8870
+ groups=binding.button_limit,
8871
+ )
8872
+ if limit_issues:
8873
+ return {}, limit_issues
8874
+ dto: dict[str, Any] = {
8875
+ "buttonId": binding.button_id,
8876
+ "buttonType": binding.button_type.value,
8877
+ "configType": binding.config_type.value,
8878
+ "beingMain": bool(binding.being_main),
8879
+ "buttonLimit": translated_limits,
8880
+ "buttonFormula": binding.button_formula or "",
8881
+ "buttonFormulaType": binding.button_formula_type,
8882
+ "printTpls": _serialize_print_tpl_ids(binding.print_tpls),
8883
+ }
8884
+ if binding.button_type == PublicViewButtonType.system:
8885
+ dto["buttonText"] = binding.button_text
8886
+ dto["buttonIcon"] = binding.button_icon
8887
+ dto["backgroundColor"] = binding.background_color
8888
+ dto["textColor"] = binding.text_color
8889
+ dto["triggerAction"] = binding.trigger_action
8890
+ return dto, []
8891
+
8892
+
8893
+ def _build_view_button_limit_groups(
8894
+ *,
8895
+ current_fields_by_name: dict[str, dict[str, Any]],
8896
+ groups: list[list[ViewFilterRulePatch]],
8897
+ ) -> tuple[list[list[dict[str, Any]]], list[dict[str, Any]]]:
8898
+ translated_groups: list[list[dict[str, Any]]] = []
8899
+ issues: list[dict[str, Any]] = []
8900
+ for raw_group in groups:
8901
+ if not isinstance(raw_group, list):
8902
+ continue
8903
+ translated_rules: list[dict[str, Any]] = []
8904
+ for raw_rule in raw_group:
8905
+ if hasattr(raw_rule, "model_dump"):
8906
+ raw_rule = raw_rule.model_dump(mode="json")
8907
+ if not isinstance(raw_rule, dict):
8908
+ continue
8909
+ field_name = str(raw_rule.get("field_name") or "").strip()
8910
+ field = current_fields_by_name.get(field_name)
8911
+ if field is None:
8912
+ issues.append(
8913
+ {
8914
+ "error_code": "UNKNOWN_VIEW_FIELD",
8915
+ "missing_fields": [field_name] if field_name else [],
8916
+ "reason_path": "buttons[].button_limit[].field_name",
8917
+ }
8918
+ )
8919
+ continue
8920
+ translated_rule, issue = _translate_view_filter_rule(field=field, rule=raw_rule)
8921
+ if issue:
8922
+ issue = deepcopy(issue)
8923
+ issue["reason_path"] = "buttons[].button_limit[].values"
8924
+ issues.append(issue)
8925
+ continue
8926
+ translated_rules.append(translated_rule)
8927
+ if translated_rules:
8928
+ translated_groups.append(translated_rules)
8929
+ return translated_groups, issues
8930
+
8931
+
7583
8932
  def _summarize_charts(result: Any) -> list[dict[str, Any]]:
7584
8933
  if not isinstance(result, list):
7585
8934
  return []
@@ -7929,6 +9278,8 @@ def _build_view_create_payload(
7929
9278
  patch: ViewUpsertPatch,
7930
9279
  ordinal: int,
7931
9280
  view_filters: list[list[dict[str, Any]]],
9281
+ current_fields_by_name: dict[str, dict[str, Any]],
9282
+ explicit_button_dtos: list[dict[str, Any]] | None = None,
7932
9283
  ) -> JSONObject:
7933
9284
  entity = _entity_spec_from_app(base_info=base_info, schema=schema, views=None)
7934
9285
  parsed_schema = _parse_schema(schema)
@@ -7969,6 +9320,7 @@ def _build_view_create_payload(
7969
9320
  group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
7970
9321
  view_filters=view_filters,
7971
9322
  gantt_payload=gantt_config,
9323
+ button_config_dtos=explicit_button_dtos,
7972
9324
  )
7973
9325
 
7974
9326
 
@@ -8111,6 +9463,8 @@ def _build_view_update_payload(
8111
9463
  schema: dict[str, Any],
8112
9464
  patch: ViewUpsertPatch,
8113
9465
  view_filters: list[list[dict[str, Any]]],
9466
+ current_fields_by_name: dict[str, dict[str, Any]],
9467
+ explicit_button_dtos: list[dict[str, Any]] | None = None,
8114
9468
  ) -> JSONObject:
8115
9469
  config_response = views.view_get_config(profile=profile, viewgraph_key=source_viewgraph_key)
8116
9470
  config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
@@ -8149,7 +9503,11 @@ def _build_view_update_payload(
8149
9503
  payload.setdefault("defaultRowHigh", "compact")
8150
9504
  payload.setdefault("viewgraphLimitType", 1)
8151
9505
  payload.setdefault("viewgraphLimit", deepcopy(view_filters) if view_filters else [])
8152
- payload.setdefault("buttonConfigDTOList", [])
9506
+ button_config_dtos = _resolve_view_button_dtos_for_patch(
9507
+ config=config,
9508
+ patch=patch,
9509
+ explicit_button_dtos=explicit_button_dtos,
9510
+ )
8153
9511
 
8154
9512
  normalized_type = patch.type.value
8155
9513
  existing_type = _normalize_view_type_name(payload.get("viewgraphType") or payload.get("type"))
@@ -8180,9 +9538,26 @@ def _build_view_update_payload(
8180
9538
  group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
8181
9539
  view_filters=view_filters,
8182
9540
  gantt_payload=gantt_payload,
9541
+ button_config_dtos=button_config_dtos,
8183
9542
  )
8184
9543
 
8185
9544
 
9545
+ _KNOWN_SYSTEM_VIEW_COLUMNS = {
9546
+ "编号",
9547
+ "当前流程状态",
9548
+ "申请人",
9549
+ "申请时间",
9550
+ "更新时间",
9551
+ "流程标题",
9552
+ "当前处理人",
9553
+ "当前处理节点",
9554
+ }
9555
+
9556
+
9557
+ def _filter_known_system_view_columns(columns: list[str]) -> list[str]:
9558
+ return [name for name in columns if str(name or "").strip() and str(name).strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS]
9559
+
9560
+
8186
9561
  def _build_minimal_view_payload(
8187
9562
  *,
8188
9563
  app_key: str,
@@ -8190,6 +9565,8 @@ def _build_minimal_view_payload(
8190
9565
  patch: ViewUpsertPatch,
8191
9566
  ordinal: int,
8192
9567
  view_filters: list[list[dict[str, Any]]],
9568
+ current_fields_by_name: dict[str, dict[str, Any]],
9569
+ explicit_button_dtos: list[dict[str, Any]] | None = None,
8193
9570
  ) -> JSONObject:
8194
9571
  field_map = extract_field_map(schema)
8195
9572
  parsed_schema = _parse_schema(schema)
@@ -8222,6 +9599,7 @@ def _build_minimal_view_payload(
8222
9599
  group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
8223
9600
  view_filters=view_filters,
8224
9601
  gantt_payload=gantt_payload,
9602
+ button_config_dtos=explicit_button_dtos,
8225
9603
  )
8226
9604
 
8227
9605
 
@@ -8233,6 +9611,7 @@ def _hydrate_view_backend_payload(
8233
9611
  group_que_id: int | None,
8234
9612
  view_filters: list[list[dict[str, Any]]] | None = None,
8235
9613
  gantt_payload: dict[str, Any] | None = None,
9614
+ button_config_dtos: list[dict[str, Any]] | None = None,
8236
9615
  ) -> JSONObject:
8237
9616
  data = deepcopy(payload)
8238
9617
  data.setdefault("beingPinNavigate", True)
@@ -8268,9 +9647,11 @@ def _hydrate_view_backend_payload(
8268
9647
  data.setdefault("asosChartConfig", {"limitType": 1, "asosChartIdList": []})
8269
9648
  data.setdefault("viewgraphGanttConfigVO", None)
8270
9649
  data.setdefault("viewgraphHierarchyConfigVO", None)
8271
- data.setdefault("buttonConfigDTOList", [])
8272
- if "buttonConfig" not in data:
8273
- data["buttonConfig"] = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": []}
9650
+ if button_config_dtos is not None:
9651
+ data["buttonConfigDTOList"] = deepcopy(button_config_dtos)
9652
+ data["buttonConfig"] = _build_grouped_view_button_config(button_config_dtos)
9653
+ else:
9654
+ data.pop("buttonConfigVO", None)
8274
9655
  if view_type == "table":
8275
9656
  data["viewgraphType"] = "tableView"
8276
9657
  data["beingShowTitleQue"] = False
@@ -8305,7 +9686,7 @@ def _resolve_view_visible_field_names(patch: ViewUpsertPatch) -> list[str]:
8305
9686
  ordered: list[str] = []
8306
9687
  for value in [*patch.columns, patch.title_field, patch.start_field, patch.end_field, patch.group_by]:
8307
9688
  name = str(value or "").strip()
8308
- if name and name not in ordered:
9689
+ if name and name not in _KNOWN_SYSTEM_VIEW_COLUMNS and name not in ordered:
8309
9690
  ordered.append(name)
8310
9691
  return ordered
8311
9692