@shundoo-ai/dsh-cosmic 1.0.21 → 1.0.22

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.
Files changed (218) hide show
  1. package/package.json +1 -1
  2. package/skills/cosmic-build/LOCAL-PATCHES.md +43 -14
  3. package/skills/cosmic-build/SKILL.md +24 -9
  4. package/skills/cosmic-build/examples/java-rules/CosmicReportAggregation.java +26 -17
  5. package/skills/cosmic-build/examples/java-rules/CosmicReportFilters.java +96 -2
  6. package/skills/cosmic-build/knowledge/cosmic/arch/security/security_standard.md +4 -4
  7. package/skills/cosmic-build/knowledge/cosmic/platform/allimport.md +2 -1
  8. package/skills/cosmic-build/knowledge/cosmic/platform/core/dynamic-object-collection.md +6 -4
  9. package/skills/cosmic-build/knowledge/cosmic/platform/core/model.md +9 -9
  10. package/skills/cosmic-build/knowledge/cosmic/platform/core/view.md +5 -5
  11. package/skills/cosmic-build/knowledge/cosmic/platform/form-plugin.md +8 -4
  12. package/skills/cosmic-build/knowledge/cosmic/platform/list-plugin.md +5 -3
  13. package/skills/cosmic-build/knowledge/cosmic/platform/print-plugin-guide.md +1 -0
  14. package/skills/cosmic-build/knowledge/cosmic/platform/report-plugin.md +269 -57
  15. package/skills/cosmic-build/knowledge/cosmic/platform/reportlistdata-plugin.md +130 -23
  16. package/skills/cosmic-build/knowledge/cosmic/platform/reporttreedata-plugin.md +48 -28
  17. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/_index.md +12 -3
  18. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/common/attachment-name-process.md +17 -13
  19. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/common/dynamic-update-field.md +4 -4
  20. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/filter-grid/filter-grid-set-value.md +1 -1
  21. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/filter-basedatafield.md +2 -3
  22. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/filter-container-init.md +0 -1
  23. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/filter-popup.md +0 -1
  24. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/filter-value-parse.md +5 -6
  25. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/list-show-form.md +1 -1
  26. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/query-field-dynamic.md +0 -7
  27. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/data-access/business-data/delete-service.md +24 -29
  28. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/data-access/ksql/db-batch.md +4 -0
  29. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/data-access/ksql/db-direct-access.md +4 -0
  30. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/data-access/qfilter/entity-object-access.md +8 -3
  31. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/data-access/qfilter/qfilter-compose.md +6 -5
  32. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/event/after-bind-data-set-visible.md +5 -1
  33. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/event/click-return-data.md +2 -2
  34. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/event/closed-call-back-set-value.md +2 -2
  35. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/event/register-listener.md +1 -1
  36. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/event/set-filter-tab-org.md +3 -0
  37. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/dispatch-service/microservice-dev.md +18 -11
  38. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/dist-lock/dist-lock-basic.md +3 -6
  39. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/mq/mq-subscribe.md +5 -1
  40. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/thread-pool/async-operation.md +10 -6
  41. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/thread-pool/big-task-async.md +4 -2
  42. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/thread-pool/schedule-task.md +9 -7
  43. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/operation/operation-interactive.md +33 -34
  44. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/operation/operation-plugin-events.md +30 -22
  45. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-async-query.md +133 -0
  46. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-batch-query.md +26 -22
  47. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-chart.md +112 -0
  48. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-dataset-assembly.md +200 -0
  49. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-head-filter.md +177 -0
  50. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-hyperlink-drill.md +216 -0
  51. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-list-data-query.md +12 -12
  52. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-merge-column.md +44 -6
  53. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-print-data.md +163 -0
  54. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-row-data-toolbar.md +188 -0
  55. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-subtotal-total.md +139 -0
  56. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-tree-data-query.md +108 -12
  57. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-tree-list.md +181 -0
  58. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/system-service/report-plugin-interface.md +3 -0
  59. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/tree/tree-basedata-cascade-confirm.md +14 -11
  60. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/tree/tree-list-before-item-validate.md +1 -1
  61. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/tree/tree-list-build-filter.md +1 -1
  62. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/tree/tree-list-multi-level-filter.md +11 -11
  63. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/tree/tree-list-refresh-node.md +22 -15
  64. package/skills/cosmic-build/knowledge/cosmic/platform/sdk/sdk-algo.md +2 -2
  65. package/skills/cosmic-build/knowledge/cosmic/platform/sdk/sdk-entity-model.md +3 -4
  66. package/skills/cosmic-build/knowledge/cosmic/platform/sdk/sdk-exception.md +9 -5
  67. package/skills/cosmic-build/knowledge/cosmic/platform/sdk/sdk-orm-access.md +4 -0
  68. package/skills/cosmic-build/knowledge/cosmic/platform/sdk/sdk-request-context.md +7 -3
  69. package/skills/cosmic-build/knowledge/cosmic/platform/standards.md +28 -3
  70. package/skills/cosmic-build/references/compile-guide.md +36 -1
  71. package/skills/cosmic-build/references/dcs-code-rules.md +180 -0
  72. package/skills/cosmic-build/references/delivery.md +16 -2
  73. package/skills/cosmic-build/references/execution-contract.md +15 -3
  74. package/skills/cosmic-build/references/java-plugin-delivery.md +21 -11
  75. package/skills/cosmic-build/references/plugin-handoff-input.md +82 -24
  76. package/skills/cosmic-build/references/plugin-runtime-routing.md +23 -6
  77. package/skills/cosmic-build/references/report-filter-contract.md +53 -15
  78. package/skills/cosmic-build/references/sdk-inspection.md +4 -1
  79. package/skills/cosmic-build/references/type-registry.json +26 -17
  80. package/skills/cosmic-build/schemas/data-model-authoring.schema.json +1 -1
  81. package/skills/cosmic-build/scripts/_http_client.py +3 -0
  82. package/skills/cosmic-build/scripts/compile_plugin_source.py +251 -42
  83. package/skills/cosmic-build/scripts/configure_list.py +112 -0
  84. package/skills/cosmic-build/scripts/dcs-rule-catalog.json +799 -0
  85. package/skills/cosmic-build/scripts/dcs_code_gate.py +1976 -0
  86. package/skills/cosmic-build/scripts/execution_binding.py +25 -0
  87. package/skills/cosmic-build/scripts/platform_api_contract.py +352 -7
  88. package/skills/cosmic-build/scripts/plugin_source_gate.py +9 -1
  89. package/skills/cosmic-build/scripts/plugin_target_sdk.py +12 -8
  90. package/skills/cosmic-build/scripts/prepare_plugin_handoff.py +488 -88
  91. package/skills/cosmic-build/scripts/query_plugin_handoff.py +207 -20
  92. package/skills/cosmic-build/scripts/register_plugins.py +27 -10
  93. package/skills/cosmic-build/scripts/render_output_cards.py +57 -20
  94. package/skills/cosmic-build/scripts/resolve_output_path.py +60 -26
  95. package/skills/cosmic-build/scripts/run_java_tests.py +162 -0
  96. package/skills/cosmic-build/scripts/sync_generated_locale_assets.py +28 -0
  97. package/skills/cosmic-build/scripts/test/fixtures/ReportSdkCheck.java +1 -22
  98. package/skills/cosmic-build/scripts/test/fixtures/list_column_property_declarations.json +887 -0
  99. package/skills/cosmic-build/scripts/test/fixtures/operation-entry-field-prepare.java +78 -0
  100. package/skills/cosmic-build/scripts/test/test_add_field_required_contract.py +12 -0
  101. package/skills/cosmic-build/scripts/test/test_batch5_drift_governance.py +23 -2
  102. package/skills/cosmic-build/scripts/test/test_compile_plugin_source.py +287 -4
  103. package/skills/cosmic-build/scripts/test/test_configure_list_delivery.py +132 -0
  104. package/skills/cosmic-build/scripts/test/test_cosmic_sdk_examples.py +58 -1
  105. package/skills/cosmic-build/scripts/test/test_data_model_contract.py +19 -0
  106. package/skills/cosmic-build/scripts/test/test_dcs_code_gate.py +901 -0
  107. package/skills/cosmic-build/scripts/test/test_delivery_stage_boundary.py +1 -1
  108. package/skills/cosmic-build/scripts/test/test_direct_cli_architecture.py +2 -0
  109. package/skills/cosmic-build/scripts/test/test_http_client.py +6 -0
  110. package/skills/cosmic-build/scripts/test/test_java_toolchain_feedback.py +206 -0
  111. package/skills/cosmic-build/scripts/test/test_kd_api.py +21 -0
  112. package/skills/cosmic-build/scripts/test/test_list_configuration_closure.py +184 -0
  113. package/skills/cosmic-build/scripts/test/test_mcp_cli.py +139 -2
  114. package/skills/cosmic-build/scripts/test/test_menu_creation_closure.py +16 -0
  115. package/skills/cosmic-build/scripts/test/test_modeling_input_recovery.py +55 -1
  116. package/skills/cosmic-build/scripts/test/test_open_world_cli_catalog.py +1 -1
  117. package/skills/cosmic-build/scripts/test/test_operation_field_preparation.py +84 -0
  118. package/skills/cosmic-build/scripts/test/test_platform_api_contract.py +322 -1
  119. package/skills/cosmic-build/scripts/test/test_plugin_handoff.py +951 -4
  120. package/skills/cosmic-build/scripts/test/test_plugin_source_gate.py +30 -2
  121. package/skills/cosmic-build/scripts/test/test_plugin_target_sdk.py +21 -0
  122. package/skills/cosmic-build/scripts/test/test_property_reroute.py +165 -0
  123. package/skills/cosmic-build/scripts/test/test_property_schema_first.py +35 -0
  124. package/skills/cosmic-build/scripts/test/test_query_plugin_handoff.py +221 -0
  125. package/skills/cosmic-build/scripts/test/test_register_plugins.py +76 -4
  126. package/skills/cosmic-build/scripts/test/test_render_output_cards.py +75 -3
  127. package/skills/cosmic-build/scripts/test/test_report_business_contract.py +27 -5
  128. package/skills/cosmic-build/scripts/test/test_report_doc_contract_sync.py +103 -11
  129. package/skills/cosmic-build/scripts/test/test_report_plugin_source_gate.py +46 -16
  130. package/skills/cosmic-build/scripts/test/test_report_properties.py +74 -15
  131. package/skills/cosmic-build/scripts/test/test_report_query_data_source.py +3 -1
  132. package/skills/cosmic-build/scripts/test/test_report_sdk_optimization.py +1 -2
  133. package/skills/cosmic-build/scripts/test/test_test_environment.py +76 -1
  134. package/skills/cosmic-build/scripts/test_environment.py +67 -1
  135. package/skills/cosmic-build/skills/cosmic-coding-impl/SKILL.md +52 -10
  136. package/skills/cosmic-build/skills/cosmic-meta-api/apis/ai_meta.md +16 -3
  137. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/report-write-contracts.json +59 -26
  138. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/rule-domain.json +1 -1
  139. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/schema/report-write.schema.json +75 -18
  140. package/skills/cosmic-build/skills/cosmic-meta-api/modules/pdm/scripts/pdm_auto.py +39 -0
  141. package/skills/cosmic-build/skills/cosmic-meta-api/modules/pdm/scripts/pdm_client.py +97 -10
  142. package/skills/cosmic-build/skills/cosmic-meta-api/modules/verify/SKILL.md +10 -12
  143. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_apps.py +3 -1
  144. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_formactions.py +38 -29
  145. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_listui.py +400 -233
  146. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_metaops.py +87 -45
  147. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_properties.py +42 -15
  148. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_rules.py +135 -5
  149. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/config.py +1 -1
  150. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/execution_errors.py +1 -1
  151. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/formatters.py +17 -11
  152. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/property_reroute.py +15 -5
  153. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/report_ops.py +1429 -238
  154. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/validators.py +56 -28
  155. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/cosmic_api.py +380 -134
  156. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/field_decorator.py +1 -1
  157. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/meta_export.py +12 -11
  158. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/meta_verifier.py +42 -0
  159. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_add_field_placement_closure.py +8 -8
  160. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_build_configuration_readback.py +94 -0
  161. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_build_receipt_resume.py +47 -1
  162. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_metadata_unchanged_fail_closed.py +87 -0
  163. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_dym_evidence.py +23 -23
  164. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_entity_integrity.py +279 -4
  165. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_field_guard.py +1 -1
  166. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_plugin_contract.py +509 -32
  167. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_readback_derivation.py +178 -1
  168. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_recreate_plan.py +125 -10
  169. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_serve_contract.py +208 -0
  170. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_typed_filters.py +259 -50
  171. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_rule_inventory_perf.py +29 -0
  172. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_rule_write_closure.py +20 -0
  173. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_run_batch_post_build_recovery.py +18 -0
  174. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_run_batch_verification_evidence.py +35 -0
  175. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_run_identity_guard.py +25 -0
  176. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_time_range_rule.py +98 -0
  177. package/skills/cosmic-build/skills/cosmic-meta-knowledge/domain-model/report-model.md +20 -9
  178. package/skills/cosmic-build/skills/cosmic-meta-knowledge/generation/report-modeling-guide.md +55 -25
  179. package/skills/cosmic-build/skills/cosmic-meta-knowledge/property-guides/report-column-properties.md +15 -39
  180. package/skills/cosmic-build/skills/cosmic-meta-modeling/SKILL.md +19 -17
  181. package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/report/SKILL.md +82 -38
  182. package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/report/examples/golden-example-report-batch.md +64 -19
  183. package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/report/examples/golden-example-report-plugin.md +32 -20
  184. package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/report/examples/golden-example-report-tree.md +78 -31
  185. package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/report/report-artifact-schema.json +2 -1
  186. package/skills/cosmic-build/skills/cosmic-meta-modeling/references/authoring-companion-fields.md +3 -0
  187. package/skills/cosmic-build/skills/cosmic-meta-modeling/references/authoring-list-configuration.md +2 -0
  188. package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/data_model_contract.py +3 -1
  189. package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/data_model_emit.py +27 -4
  190. package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/report_validator.py +99 -44
  191. package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/test_report_examples.py +31 -4
  192. package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/test_report_validator.py +129 -29
  193. package/skills/cosmic-build/skills/cosmic-meta-modifier/SKILL.md +6 -2
  194. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/operation/validation.md +2 -0
  195. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/property/SKILL.md +1 -1
  196. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/report-op/SKILL.md +104 -25
  197. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/rule/SKILL.md +9 -0
  198. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/verify/usage-guide.md +2 -2
  199. package/skills/cosmic-build/skills/cosmic-meta-modifier/references/list-filter-modification.md +39 -12
  200. package/skills/cosmic-build/skills/mcp-server/SKILL.md +39 -24
  201. package/skills/cosmic-build/skills/mcp-server/scripts/mcp_cli.py +20 -6
  202. package/skills/cosmic-dev-best-practices/SKILL.md +1 -1
  203. package/skills/cosmic-dev-best-practices/references/README.md +2 -2
  204. package/skills/cosmic-dev-best-practices/references/basedata-dev-convention.md +2 -2
  205. package/skills/cosmic-dev-best-practices/references/bill-dev-convention.md +4 -4
  206. package/skills/cosmic-dev-best-practices/references/design-document-convention.md +2 -2
  207. package/skills/cosmic-dev-best-practices/references/design-examples/basedata-design-example.md +4 -4
  208. package/skills/cosmic-dev-best-practices/references/design-examples/bill-design-example.md +13 -13
  209. package/skills/cosmic-dev-best-practices/references/elements/CustomerField.md +1 -1
  210. package/skills/cosmic-dev-best-practices/references/elements/README.md +1 -1
  211. package/skills/cosmic-dev-best-practices/references/elements/SupplierField.md +1 -1
  212. package/skills/cosmic-dev-best-practices/references/elements/common-field-property-conventions.md +109 -13
  213. package/skills/cosmic-dev-best-practices/references/layout-control-convention.md +1 -1
  214. package/skills/erp-analysis/references/delivery-contract.md +2 -1
  215. package/skills/erp-analysis/templates/AGENTS.template.md +3 -1
  216. package/skills/cosmic-build/assets/platform-report-filters/TypedReportFilterFields.java +0 -169
  217. package/skills/cosmic-build/assets/platform-report-filters/platform.patch +0 -98
  218. package/skills/cosmic-build/scripts/apply_report_filter_extension.py +0 -39
@@ -1,18 +1,18 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """Report-specific API operations for cosmic_api.
3
3
 
4
- Provides functions to manage report data sources, columns, filters, sorts,
5
- and plugins via the Cosmic OpenAPI. Each function follows the incremental-modify
6
- pattern: fetch current schema → merge changes → submit via setComplexProperty
7
- or setProperties.
4
+ 管理报表查询数据源、列、过滤和插件的 API 写入与独立回读。
5
+ 默认排序属于 Java 查询契约;有缺陷的 ReportSorts 写入被阻断。
8
6
 
9
7
  Dependencies: only standard library + project-internal _core.config._api_call.
10
8
  """
11
9
 
10
+ import copy
12
11
  import json
13
12
  import os
14
13
  import re
15
14
  import sys
15
+ import urllib.error
16
16
  import xml.etree.ElementTree as ET
17
17
 
18
18
  from _core.cmd_forms import parse_schema_text
@@ -51,6 +51,109 @@ _FILTER_UNPERSISTED_KEYS = ("mustInput", "visible", "defaultValue")
51
51
  # says so once and every filter's comparison lands in the plugin contract for query() to apply.
52
52
  _FILTER_UNPERSISTED_COMPARE = "compare"
53
53
 
54
+ # ─── Typed report filter editors ──────────────────────────────────────────────
55
+ # The platform's report filter API (complexType "ReportFilters") takes an optional per-filter
56
+ # `fieldType` that builds the filter's native editor — an F7 picker, a dropdown, a date range —
57
+ # instead of a text box. It ships as a platform patch, while this skill ships with the client,
58
+ # so a client can be newer than the platform it writes to. The tables below mirror the patch's
59
+ # own ReportFilterFieldBuilder, so a request the patch would refuse fails here before the write.
60
+ # What a platform WITHOUT the patch does instead is never assumed: it ignores `fieldType` and
61
+ # renders the legacy control, and that is decided after the write from the platform's receipt
62
+ # and the read-back (see build_filter_editor_outcome / resolve_filter_editors).
63
+ REPORT_FILTER_FIELD_TYPES = (
64
+ "TextField", "IntegerField", "BigIntField", "DecimalField", "DateField", "DateTimeField",
65
+ "DateRangeField", "TimeField", "TimeRangeField", "CheckBoxField", "ComboField",
66
+ "MulComboField", "CheckBoxGroupField", "BasedataField", "MulBasedataField", "OrgField",
67
+ "UserField", "CurrencyField", "UnitField", "MaterielField", "CustomerField",
68
+ "SupplierField", "AssistantField", "ItemClassTypeField", "ItemClassField",
69
+ )
70
+ # The patch resolves these spellings case-insensitively; accepting the same set keeps any value
71
+ # the platform accepts acceptable here, while every artifact is normalized to the class name.
72
+ _REPORT_FILTER_TYPE_ALIASES = {
73
+ **{name.lower(): name for name in REPORT_FILTER_FIELD_TYPES},
74
+ "text": "TextField", "integer": "IntegerField", "int": "IntegerField",
75
+ "bigint": "BigIntField", "decimal": "DecimalField", "number": "DecimalField",
76
+ "date": "DateField", "datetime": "DateTimeField", "daterange": "DateRangeField",
77
+ "time": "TimeField", "timerange": "TimeRangeField", "checkbox": "CheckBoxField",
78
+ "boolean": "CheckBoxField", "switch": "CheckBoxField", "combo": "ComboField",
79
+ "mulcombo": "MulComboField", "checkboxgroup": "CheckBoxGroupField",
80
+ "basedata": "BasedataField", "mulbasedata": "MulBasedataField", "org": "OrgField",
81
+ "user": "UserField", "currency": "CurrencyField", "unit": "UnitField",
82
+ "material": "MaterielField", "materiel": "MaterielField", "customer": "CustomerField",
83
+ "supplier": "SupplierField", "assistant": "AssistantField",
84
+ "itemclasstype": "ItemClassTypeField", "itemclass": "ItemClassField",
85
+ }
86
+ # Parameter -> the editor types that take it. The patch refuses a parameter sent with any other
87
+ # type, and one sent without `fieldType` at all.
88
+ _REPORT_FILTER_TYPE_PARAMS = {
89
+ "basedataNumber": {"BasedataField", "MulBasedataField"},
90
+ "comboItems": {"ComboField", "MulComboField", "CheckBoxGroupField"},
91
+ "itemClassTypes": {"ItemClassTypeField"},
92
+ "typeFieldKey": {"ItemClassField"},
93
+ "asstTypeId": {"AssistantField"},
94
+ "orgFuncs": {"OrgField"},
95
+ "showStyle": {"CheckBoxField"},
96
+ "startDateFieldKey": {"DateRangeField", "TimeRangeField"},
97
+ "endDateFieldKey": {"DateRangeField", "TimeRangeField"},
98
+ }
99
+ # The parameter a new editor of that type cannot be built without. setReportFilters replaces the
100
+ # whole filter set, so every request is the complete desired state and always carries it.
101
+ _REPORT_FILTER_REQUIRED_PARAMS = {
102
+ "BasedataField": "basedataNumber", "MulBasedataField": "basedataNumber",
103
+ "ComboField": "comboItems", "MulComboField": "comboItems",
104
+ "CheckBoxGroupField": "comboItems", "ItemClassTypeField": "itemClassTypes",
105
+ "ItemClassField": "typeFieldKey", "AssistantField": "asstTypeId",
106
+ }
107
+ # Range editors split one filter into two runtime items. A single date or time editor asked to
108
+ # filter a range becomes the matching range editor, exactly as a legacy `between` filter does.
109
+ _REPORT_FILTER_RANGE_ITEM_SUFFIXES = {
110
+ "DateRangeField": ("_startdate", "_enddate"),
111
+ "TimeRangeField": ("_starttime", "_endtime"),
112
+ }
113
+ _REPORT_FILTER_RANGE_OF = {
114
+ "DateField": "DateRangeField", "DateTimeField": "DateRangeField", "TimeField": "TimeRangeField",
115
+ }
116
+ # F7 editors take no `defaultValue`: the patch refuses it and points at defValueDesign instead.
117
+ _REPORT_FILTER_F7_TYPES = {
118
+ "BasedataField", "MulBasedataField", "OrgField", "UserField", "CurrencyField", "UnitField",
119
+ "MaterielField", "CustomerField", "SupplierField", "AssistantField", "ItemClassField",
120
+ }
121
+ REPORT_FILTER_MULTI_VALUE_TYPES = {"MulBasedataField", "MulComboField", "CheckBoxGroupField"}
122
+ # The patch builds these two from optional extension classes and falls back to a plain
123
+ # BasedataField on the same base data when the class is absent, so either one is the editor.
124
+ _REPORT_FILTER_EQUIVALENT_TYPES = {
125
+ "CustomerField": {"BasedataField"},
126
+ "SupplierField": {"BasedataField"},
127
+ }
128
+ # How the plugin reads each editor's value from ReportQueryParam.getFilter(): the FilterInfo
129
+ # accessor (api/entity/report/FilterInfo.md), the Java value it yields, and how sure that is.
130
+ # "measured" shapes are recorded in references/report-filter-contract.md; "observeOnce" ones
131
+ # have a documented accessor but no measured value class yet, so the plugin confirms them with
132
+ # one request-scoped observation instead of trusting this table.
133
+ _REPORT_FILTER_VALUE_SHAPES = {
134
+ "TextField": ("getString", "String", "measured"),
135
+ "IntegerField": ("getValue", "Integer", "observeOnce"),
136
+ "BigIntField": ("getValue", "Long", "observeOnce"),
137
+ "DecimalField": ("getBigDecimal", "BigDecimal", "observeOnce"),
138
+ "DateField": ("getDate", "java.util.Date", "observeOnce"),
139
+ "DateTimeField": ("getDate", "java.util.Date", "observeOnce"),
140
+ "DateRangeField": ("getFilterItems", "java.util.Date per range item", "measured"),
141
+ "TimeField": ("getValue", "Integer seconds after midnight", "observeOnce"),
142
+ "TimeRangeField": ("getFilterItems", "Integer seconds after midnight per range item", "observeOnce"),
143
+ "CheckBoxField": ("getBoolean", "boolean", "observeOnce"),
144
+ "ComboField": ("getString", "String stored value", "measured"),
145
+ "MulComboField": ("getString", "String of stored values", "observeOnce"),
146
+ "CheckBoxGroupField": ("getString", "String of stored values", "observeOnce"),
147
+ "MulBasedataField": ("getDynamicObjectCollection", "DynamicObjectCollection", "measured"),
148
+ "ItemClassTypeField": ("getValue", "Object", "observeOnce"),
149
+ "ItemClassField": ("getDynamicObject", "DynamicObject", "observeOnce"),
150
+ **{name: ("getDynamicObject", "DynamicObject", "measured") for name in (
151
+ "BasedataField", "OrgField", "UserField", "CurrencyField", "UnitField",
152
+ "MaterielField", "CustomerField", "SupplierField", "AssistantField",
153
+ )},
154
+ }
155
+ REPORT_FILTER_EDITOR_OUTCOME_VERSION = "app-build-report-filter-editors/v1"
156
+
54
157
 
55
158
  def _requests_unpersisted(item, key):
56
159
  """True only when the value differs from the platform default, i.e. a real request.
@@ -224,22 +327,167 @@ def _normalize_column(raw, path, seen):
224
327
  return item
225
328
 
226
329
 
227
- def normalize_report_columns_input(value):
330
+ def _assert_report_precision_writable(columns):
331
+ """标量列不能证明精度引用已物化;写前拒绝,不做固定精度降级。"""
332
+ for column in _iter_leaf_columns(columns):
333
+ spec = column.get("presentation")
334
+ if not isinstance(spec, dict):
335
+ continue
336
+ key = _PRESENTATION_BINDING_KEY.get(str(spec.get("kind") or "").strip().lower())
337
+ if key:
338
+ _input_error(
339
+ f"REPORT_PRECISION_REFERENCE_UNVERIFIED: {column.get('fieldKey')!r} 的 "
340
+ f"presentation.{key}={spec.get(key)!r} 需要格式化前运行行上的 DynamicObject 引用;"
341
+ "隐藏 bigint 的 Long 主键、相同字段名和 getColumns() 工厂调用不能证明引用已物化。"
342
+ "先验证平台引用字段、旧字段迁移及批量物化链路,再开放技能契约;"
343
+ "不发送写入,不用固定精度或取消绑定代替需求。")
344
+
345
+
346
+ def _normalize_report_columns_structure(value):
347
+ """共用形状校验;历史需求编译不构成写入授权。"""
228
348
  if not isinstance(value, list) or not value:
229
349
  _input_error("columns must be a non-empty array")
230
350
  seen = set()
231
351
  return [_normalize_column(item, f"columns[{index}]", seen) for index, item in enumerate(value)]
232
352
 
233
353
 
354
+ def normalize_report_columns_input(value):
355
+ columns = _normalize_report_columns_structure(value)
356
+ _assert_report_precision_writable(columns)
357
+ return columns
358
+
359
+
360
+ def _combo_value(raw, path):
361
+ """A stored dropdown value as text, keeping 0 and leading zeros exactly as declared."""
362
+ if raw is None or isinstance(raw, bool) or isinstance(raw, (dict, list)):
363
+ _input_error(f"{path} must be a non-empty string or number")
364
+ text = raw.strip() if isinstance(raw, str) else str(raw)
365
+ if not text:
366
+ _input_error(f"{path} must be non-empty")
367
+ return text
368
+
369
+
370
+ def _normalize_filter_combo_items(value, path):
371
+ if not isinstance(value, list) or not value:
372
+ _input_error(f"{path} must be a non-empty array of {{value, caption}}")
373
+ items = []
374
+ seen = set()
375
+ for index, raw in enumerate(value):
376
+ item = _object(raw, f"{path}[{index}]", {"value", "caption"})
377
+ entry = {
378
+ "value": _combo_value(item.get("value"), f"{path}[{index}].value"),
379
+ "caption": _text(item.get("caption"), f"{path}[{index}].caption"),
380
+ }
381
+ if entry["value"] in seen:
382
+ _input_error(f"duplicate {path} value {entry['value']!r}")
383
+ seen.add(entry["value"])
384
+ items.append(entry)
385
+ return items
386
+
387
+
388
+ def _normalize_item_class_types(value, path):
389
+ if not isinstance(value, list) or not value:
390
+ _input_error(f"{path} must be a non-empty array of {{basedataNumber, caption}}")
391
+ rows = []
392
+ seen = set()
393
+ for index, raw in enumerate(value):
394
+ item = _object(raw, f"{path}[{index}]", {"basedataNumber", "caption"})
395
+ row = {"basedataNumber": _text(item.get("basedataNumber"), f"{path}[{index}].basedataNumber")}
396
+ if row["basedataNumber"] in seen:
397
+ _input_error(f"duplicate {path} basedataNumber {row['basedataNumber']!r}")
398
+ seen.add(row["basedataNumber"])
399
+ if "caption" in item:
400
+ row["caption"] = _text(item["caption"], f"{path}[{index}].caption")
401
+ rows.append(row)
402
+ return rows
403
+
404
+
405
+ def _normalize_filter_editor(item, path):
406
+ """Validate one filter's typed editor against the platform patch's own rules.
407
+
408
+ Every refusal here is one the patch would make too, so a request that passes is one the
409
+ patch accepts. A platform without the patch accepts it as well and ignores the editor part,
410
+ which the write reports as a degraded editor rather than failing on.
411
+ """
412
+ raw = item.get("fieldType")
413
+ if raw is None:
414
+ for name in _REPORT_FILTER_TYPE_PARAMS:
415
+ if name in item:
416
+ _input_error(f"{path}.{name} needs fieldType: it configures a typed filter editor")
417
+ return item
418
+ field_type = _REPORT_FILTER_TYPE_ALIASES.get(str(raw).strip().lower())
419
+ if not field_type:
420
+ _input_error(
421
+ f"{path}.fieldType {raw!r} is not a report filter editor type; use one of "
422
+ f"{list(REPORT_FILTER_FIELD_TYPES)!r}")
423
+ if item["compare"] == "between":
424
+ field_type = _REPORT_FILTER_RANGE_OF.get(field_type, field_type)
425
+ if field_type not in _REPORT_FILTER_RANGE_ITEM_SUFFIXES:
426
+ _input_error(
427
+ f"{path}: compare=between needs a range editor (DateRangeField or "
428
+ f"TimeRangeField), and {field_type} holds one value; declare a from/to pair of "
429
+ "filters instead")
430
+ elif field_type in _REPORT_FILTER_RANGE_ITEM_SUFFIXES:
431
+ _input_error(f"{path}.fieldType {field_type} is a range editor and needs compare=between")
432
+ item["fieldType"] = field_type
433
+ for name, owners in _REPORT_FILTER_TYPE_PARAMS.items():
434
+ if name in item and field_type not in owners:
435
+ _input_error(
436
+ f"{path}.{name} does not apply to fieldType {field_type}; it configures "
437
+ f"{sorted(owners)!r}")
438
+ required = _REPORT_FILTER_REQUIRED_PARAMS.get(field_type)
439
+ if required and item.get(required) in (None, "", []):
440
+ _input_error(f"{path}.{required} is required for fieldType {field_type}")
441
+ if "basedataNumber" in item:
442
+ item["basedataNumber"] = _text(item["basedataNumber"], f"{path}.basedataNumber")
443
+ if "comboItems" in item:
444
+ item["comboItems"] = _normalize_filter_combo_items(item["comboItems"], f"{path}.comboItems")
445
+ if "itemClassTypes" in item:
446
+ item["itemClassTypes"] = _normalize_item_class_types(
447
+ item["itemClassTypes"], f"{path}.itemClassTypes")
448
+ for name in ("typeFieldKey", "startDateFieldKey", "endDateFieldKey"):
449
+ if name in item:
450
+ item[name] = _text(item[name], f"{path}.{name}", identifier=True)
451
+ if "asstTypeId" in item:
452
+ item["asstTypeId"] = _text(item["asstTypeId"], f"{path}.asstTypeId")
453
+ if not item["asstTypeId"].isdigit():
454
+ _input_error(f"{path}.asstTypeId must be the numeric id of an assistant data group")
455
+ if "orgFuncs" in item:
456
+ item["orgFuncs"] = _text(item["orgFuncs"], f"{path}.orgFuncs")
457
+ if "showStyle" in item:
458
+ style = item["showStyle"]
459
+ if isinstance(style, bool) or not isinstance(style, int) or not 0 <= style <= 3:
460
+ _input_error(f"{path}.showStyle must be an integer from 0 to 3")
461
+ if item.get("defaultValue") not in (None, ""):
462
+ if field_type in _REPORT_FILTER_F7_TYPES:
463
+ _input_error(
464
+ f"{path}.defaultValue is not supported on {field_type}: the platform refuses a "
465
+ "default on an F7 filter editor")
466
+ if field_type == "CheckBoxField" and str(item["defaultValue"]).strip().lower() not in {
467
+ "true", "false", "1", "0"}:
468
+ _input_error(f"{path}.defaultValue on CheckBoxField must be true, false, 1 or 0")
469
+ return item
470
+
471
+
234
472
  def normalize_report_filters_input(value):
235
473
  if not isinstance(value, list) or not value:
236
474
  _input_error("filters must be a non-empty array")
237
475
  allowed = {
238
- "fieldPath", "displayName", "compare", "defaultValue", "mustInput", "visible", "source",
476
+ "fieldPath", "displayName", "compare", "defaultValue", "mustInput", "visible",
477
+ "fieldType", *_REPORT_FILTER_TYPE_PARAMS,
239
478
  }
240
479
  result = []
241
480
  seen = set()
242
481
  for index, raw in enumerate(value):
482
+ if isinstance(raw, dict) and "source" in raw:
483
+ _input_error(
484
+ f"filters[{index}].source is retired: declare fieldType and its parameters "
485
+ "instead (an F7 filter is fieldType plus basedataNumber); read the source field's "
486
+ "type and reference from that form's getFormSchema")
487
+ if isinstance(raw, dict) and "baseEntityId" in raw:
488
+ _input_error(
489
+ f"filters[{index}].baseEntityId is not accepted: pass basedataNumber and let the "
490
+ "platform resolve it, because a supplied id cannot be proven to exist")
243
491
  item = _object(raw, f"filters[{index}]", allowed)
244
492
  item["fieldPath"] = _text(item.get("fieldPath"), f"filters[{index}].fieldPath", identifier=True)
245
493
  if item["fieldPath"] in seen:
@@ -250,14 +498,21 @@ def normalize_report_filters_input(value):
250
498
  if item["compare"] not in {"=", "in", "between"}:
251
499
  _input_error(
252
500
  f"filters[{index}].compare is not in the verified set (=, in, between)")
253
- if "source" in item:
254
- source = _object(item["source"], f"filters[{index}].source", {"formNumber", "fieldKey"})
255
- item["source"] = {key: _text(source.get(key), f"filters[{index}].source.{key}", identifier=True)
256
- for key in ("formNumber", "fieldKey")}
257
501
  for key in ("mustInput", "visible"):
258
502
  if key in item:
259
503
  item[key] = _boolean(item[key], f"filters[{index}].{key}")
260
- result.append(item)
504
+ if item.get("mustInput") is True and item.get("visible") is False:
505
+ _input_error(
506
+ f"filters[{index}] is mustInput and visible:false at once; a required filter the "
507
+ "user cannot see can never be filled, and the platform refuses it")
508
+ result.append(_normalize_filter_editor(item, f"filters[{index}]"))
509
+ editors = {item["fieldPath"]: item.get("fieldType") for item in result}
510
+ for index, item in enumerate(result):
511
+ target = item.get("typeFieldKey")
512
+ if target and editors.get(target) != "ItemClassTypeField":
513
+ _input_error(
514
+ f"filters[{index}].typeFieldKey {target!r} must name an ItemClassTypeField "
515
+ "filter in the same request")
261
516
  return result
262
517
 
263
518
 
@@ -325,8 +580,28 @@ def _get_schema(cfg, token, form_id):
325
580
  return result
326
581
 
327
582
 
583
+ class ReportMutationError(RuntimeError):
584
+ """报表写入边界已越过;None 表示结果未知,True 仅表示已有成功回执。"""
585
+ def __init__(self, message, mutation_applied=None):
586
+ super().__init__(message)
587
+ self.mutation_applied = mutation_applied
588
+
589
+
590
+ def _read_after_report_write(callback, *args, **kwargs):
591
+ """成功写入后的回读失败保留写入证据,不能诱导重放。"""
592
+ try:
593
+ return callback(*args, **kwargs)
594
+ except Exception as exc:
595
+ raise ReportMutationError(
596
+ str(exc) if isinstance(exc, ValueError) else
597
+ f"REPORT_READBACK_UNAVAILABLE: {type(exc).__name__}; re-read before retrying",
598
+ mutation_applied=True) from exc
599
+
600
+
328
601
  def _set_complex(cfg, token, form_number, complex_type, data, element_key=None):
329
602
  """Wrapper for setComplexProperty API call."""
603
+ if complex_type == "ReportSorts":
604
+ raise RuntimeError(REPORT_SORTS_API_UNSAFE)
330
605
  payload = {
331
606
  "formNumber": form_number,
332
607
  "complexType": complex_type,
@@ -334,7 +609,12 @@ def _set_complex(cfg, token, form_number, complex_type, data, element_key=None):
334
609
  }
335
610
  if element_key:
336
611
  payload["elementKey"] = element_key
337
- return _api_call(cfg, token, "setComplexProperty", payload, method="POST")
612
+ try:
613
+ return _api_call(cfg, token, "setComplexProperty", payload, method="POST")
614
+ except Exception as exc:
615
+ raise ReportMutationError(
616
+ f"REPORT_WRITE_OUTCOME_UNKNOWN: {complex_type} request failed with {type(exc).__name__}; "
617
+ "re-read the report before retrying; do not replay the write") from exc
338
618
 
339
619
 
340
620
  def _extract_form_number(schema_result):
@@ -408,7 +688,8 @@ _REPORT_COLUMNS_ALREADY_SET = (
408
688
  "REPORT_COLUMNS_NOT_REPLACEABLE: report {form!r} already has report-entity fields for "
409
689
  "{existing}. Columns are create-time only, because this write would duplicate those fields "
410
690
  "and brick every later save. To add columns, send only the new ones to addReportColumns; to "
411
- "remove, reorder or re-type one, plan the rebuild with planReportRecreate. Do not add the "
691
+ "remove one leaf column, use removeReportColumn (requires the backend deletion patch); to "
692
+ "reorder or re-type one, plan the rebuild with planReportRecreate. Do not add the "
412
693
  "column in the designer: the platform attaches a loose control to the report page and the "
413
694
  "column list stays unchanged."
414
695
  )
@@ -508,6 +789,7 @@ def set_report_columns(cfg, token, form_id, columns, dry_run=False, form_number=
508
789
  """
509
790
  if not columns:
510
791
  raise ValueError("columns array must not be empty")
792
+ _assert_report_precision_writable(columns)
511
793
 
512
794
  column_payload = {"columns": _build_columns_payload(columns)}
513
795
  col_ids = _collect_column_ids(columns)
@@ -522,7 +804,7 @@ def set_report_columns(cfg, token, form_id, columns, dry_run=False, form_number=
522
804
  requested_column_keys=_leaf_column_keys(columns),
523
805
  )
524
806
  result = _set_complex(cfg, token, form_number, "ReportColumns", column_payload)
525
- _check_result(result, "setReportColumns")
807
+ _check_result(result, "setReportColumns", form_mutation_possible=True)
526
808
  return col_ids
527
809
 
528
810
 
@@ -557,6 +839,7 @@ def add_report_columns(cfg, token, form_id, columns, dry_run=False, form_number=
557
839
  """
558
840
  if not columns:
559
841
  raise ValueError("columns array must not be empty")
842
+ _assert_report_precision_writable(columns)
560
843
 
561
844
  column_payload = {"columns": _build_columns_payload(columns)}
562
845
  col_ids = _collect_column_ids(columns)
@@ -573,7 +856,139 @@ def add_report_columns(cfg, token, form_id, columns, dry_run=False, form_number=
573
856
  return col_ids
574
857
 
575
858
 
576
- def set_report_filters(cfg, token, form_id, filters, dry_run=False, form_number=None):
859
+ def normalize_report_column_removal_input(value):
860
+ """单项删除只接受精确叶子列标识,不接受数组、路径或模糊名称。"""
861
+ item = _object(value, "columnRemoval", {"fieldKey"})
862
+ if not isinstance(item.get("fieldKey"), str):
863
+ _input_error("columnRemoval.fieldKey must be a string identifier")
864
+ item["fieldKey"] = _text(item["fieldKey"], "columnRemoval.fieldKey", identifier=True)
865
+ return item
866
+
867
+
868
+ def _without_report_column(rows, field_key):
869
+ return [{**row, **({"children": _without_report_column(row["children"], field_key)}
870
+ if row.get("kind") == "group" else {})}
871
+ for row in rows if row["fieldKey"] != field_key]
872
+
873
+
874
+ def plan_report_column_removal(schema, field_key, app_root=None, form_number=None, form_id=None):
875
+ """写前验证唯一叶子与已知币别/单位依赖,不从回读猜测完整需求。"""
876
+ def nodes(rows):
877
+ if not isinstance(rows, list):
878
+ raise ValueError("REPORT_READBACK_UNAVAILABLE: report column tree must be an array")
879
+ result = []
880
+ for row in rows:
881
+ if not isinstance(row, dict) or row.get("kind") not in ("column", "group") or not row.get("fieldKey"):
882
+ raise ValueError("REPORT_READBACK_UNAVAILABLE: malformed report column tree")
883
+ result.append(row)
884
+ if row["kind"] == "group":
885
+ result.extend(nodes(row.get("children")))
886
+ return result
887
+ if not isinstance(schema, dict) or not schema.get("readBackAvailable") or "reportColumns" in (schema.get("unverifiableSections") or []):
888
+ raise ValueError("REPORT_READBACK_UNAVAILABLE: read the report before deleting a column")
889
+ if form_number and schema.get("formNumber") and schema["formNumber"] != form_number:
890
+ raise ValueError("REPORT_TARGET_MISMATCH: report read-back belongs to another form; no deletion submitted")
891
+ rows = nodes(schema.get("reportColumns"))
892
+ keys = [row["fieldKey"] for row in rows]
893
+ if len(keys) != len(set(keys)):
894
+ raise ValueError("REPORT_COLUMN_TARGET_AMBIGUOUS: duplicate column keys; reconcile before deletion")
895
+ target = next((row for row in rows if row["fieldKey"] == field_key), None)
896
+ if not target:
897
+ raise ValueError(f"REPORT_COLUMN_NOT_FOUND: {field_key!r}; no deletion submitted")
898
+ if target["kind"] != "column":
899
+ raise ValueError("REPORT_COLUMN_GROUP_NOT_REMOVABLE: only one leaf column can be removed; no deletion submitted")
900
+ if app_root:
901
+ path = report_authoring_path(app_root, form_number)
902
+ try:
903
+ previous = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
904
+ except (ValueError, UnicodeError):
905
+ previous = {}
906
+ if isinstance(previous, dict) and previous.get("formNumber") == form_number and previous.get("formId") == form_id:
907
+ authored = nodes((previous.get("artifact") or {}).get("reportColumns", []))
908
+ dependents = [row["fieldKey"] for row in authored if row["fieldKey"] != field_key
909
+ and any((row.get("presentation") or {}).get(key) == field_key
910
+ for key in ("currencyField", "measureUnitField"))]
911
+ if dependents:
912
+ raise ValueError(f"REPORT_COLUMN_IN_USE: {field_key!r} is required by {dependents}; reconcile presentation before deleting")
913
+ return {"fieldKey": field_key, "before": schema}
914
+
915
+
916
+ def _report_column_delete_unavailable(result):
917
+ """仅识别明确不支持删除分支/缺少路由的失败,保存、权限和目标错误不降级。"""
918
+ from _core.formatters import _is_api_success
919
+ if not isinstance(result, dict) or _is_api_success(result):
920
+ return False
921
+ layers = [layer for layer in (result, result.get("data")) if isinstance(layer, dict)]
922
+ if any(str(layer.get("errorCode") or "") not in ("", "0", "META_SAVE_FAIL", "404", "405", "501")
923
+ or layer.get("success") is True for layer in layers):
924
+ return False
925
+ # 内层 PropertyResult 是实际分支回执;外层文案不能覆盖其权限、保存或业务拒绝。
926
+ layer = layers[-1]
927
+ code = str(layer.get("errorCode") or "")
928
+ message = str(layer.get("message") or layer.get("msg") or "").strip()
929
+ if code in ("", "META_SAVE_FAIL") and re.fullmatch(
930
+ r"(?:不支持的复杂属性类型\s*[::]\s*ReportColumns\.remove[。.]?"
931
+ r"|Unsupported complex property type\s*:\s*ReportColumns\.remove[.]?)", message, re.I):
932
+ return True
933
+ return code in ("404", "405", "501") and any(
934
+ marker in message.lower() for marker in ("cannot found openapi", "接口未注册", "接口不存在",
935
+ "endpoint not found", "no handler found"))
936
+
937
+
938
+ def remove_report_column(cfg, token, form_id, field_key, dry_run=False, form_number=None):
939
+ """调用真实删除分支一次;旧后端明确不支持时保持本地基线,不自动隐藏或重建。"""
940
+ from _core.formatters import _is_api_success, _extract_error_message
941
+ field_key = normalize_report_column_removal_input({"fieldKey": field_key})["fieldKey"]
942
+ form_number = _text(form_number, "formNumber", identifier=True)
943
+ payload = {"formNumber": form_number, "complexType": "ReportColumns.remove", "data": {"fieldKey": field_key}}
944
+ if dry_run:
945
+ return {"dryRun": True, "payload": payload, "writeAttempted": False,
946
+ "capabilityVerified": False, "runtimeVerified": False}
947
+ _assert_report_entity_writable(cfg, token, form_id, form_number)
948
+ try:
949
+ result = _api_call(cfg, token, "setComplexProperty", payload, method="POST")
950
+ except urllib.error.HTTPError as exc:
951
+ # 网关 JSON 业务错误不能被 HTTP 404 包装吞掉;无业务体的缺路由状态才可兼容。
952
+ if exc.code not in (404, 405, 501):
953
+ raise ReportMutationError(f"REPORT_WRITE_OUTCOME_UNKNOWN: deletion HTTP {exc.code}; re-read before retrying; do not replay") from exc
954
+ try:
955
+ body = exc.read()
956
+ except Exception as read_error:
957
+ raise ReportMutationError("REPORT_WRITE_OUTCOME_UNKNOWN: deletion HTTP error body unavailable; re-read before retrying; do not replay") from read_error
958
+ try:
959
+ result = json.loads(body) if body else None
960
+ except (ValueError, UnicodeError):
961
+ result = None
962
+ if not isinstance(result, dict):
963
+ result = {"status": False, "errorCode": str(exc.code), "message": "endpoint not found"}
964
+ elif _is_api_success(result):
965
+ raise ReportMutationError("REPORT_WRITE_OUTCOME_UNKNOWN: deletion HTTP status contradicts response; re-read before retrying; do not replay") from exc
966
+ except Exception as exc:
967
+ raise ReportMutationError(f"REPORT_WRITE_OUTCOME_UNKNOWN: deletion request failed with {type(exc).__name__}; re-read before retrying; do not replay") from exc
968
+ if _report_column_delete_unavailable(result):
969
+ return {"status": False, "errorCode": "REPORT_COLUMN_DELETE_API_UNAVAILABLE",
970
+ "message": "Backend does not support ReportColumns.remove yet; upgrade the backend before deleting. No hide, rebuild or replay was attempted.",
971
+ "writeAttempted": True, "data": {"capability": "ReportColumns.remove", "capabilityAvailable": False,
972
+ "platformMutationApplied": False, "runtimeVerified": False, "platformResponse": result}}
973
+ inner = result.get("data") if isinstance(result, dict) else None
974
+ inner_error = isinstance(inner, dict) and str(inner.get("errorCode") or "0") != "0"
975
+ if not _is_api_success(result) or inner_error:
976
+ # 已核实这些内层错误在修改 context 前返回;通用 META_SAVE_FAIL 则不能这样断言。
977
+ rejection = inner if isinstance(inner, dict) and inner.get("success") is False else result
978
+ if isinstance(rejection, dict) and rejection.get("errorCode") in ("PARAM_INVALID", "PROPERTY_ELEMENT_NOT_FOUND", "ELEMENT_NOT_FOUND"):
979
+ raise RuntimeError(f"REPORT_COLUMN_DELETE_REJECTED: {_extract_error_message(result)}")
980
+ raise ReportMutationError(f"REPORT_COLUMN_DELETE_FAILED: {_extract_error_message(result)}; the report may already have changed; re-read before retrying; do not replay")
981
+ receipt = result.get("data") or {}
982
+ if isinstance(receipt, dict) and isinstance(receipt.get("data"), dict):
983
+ receipt = receipt["data"]
984
+ if not isinstance(receipt, dict) or receipt.get("fieldKey") != field_key or receipt.get("action") != "removed":
985
+ raise ReportMutationError("REPORT_COLUMN_DELETE_RECEIPT_INVALID: successful response did not identify the requested removal; re-read before retrying; do not replay", True)
986
+ return {"status": True, "data": {"formId": form_id, "formNumber": form_number,
987
+ "fieldKey": field_key, "fieldId": receipt.get("fieldId"), "action": "removed", "runtimeVerified": False}}
988
+
989
+
990
+ def set_report_filters(cfg, token, form_id, filters, dry_run=False, form_number=None,
991
+ outcome=None):
577
992
  """Set report filter fields.
578
993
 
579
994
  Args:
@@ -583,20 +998,24 @@ def set_report_filters(cfg, token, form_id, filters, dry_run=False, form_number=
583
998
  filters: reportFilters[] array from artifact.
584
999
  dry_run: If True, return payload without submitting.
585
1000
  form_number: Optional form number.
1001
+ outcome: Optional dict, filled with what the platform did with each filter's editor
1002
+ (see build_filter_editor_outcome). The return value stays the list of filter ids.
586
1003
 
587
1004
  Returns:
588
1005
  List of filter fieldPath ids.
589
1006
 
590
- Note: The platform's Java-side ``ReportPropertyService.setReportFilters``
591
- has been fixed to auto-create ``CommonFilterColumnAp`` children in the
592
- ``reportcommfilterpanelap`` layout panel, so this client does NOT need to
593
- pass ``elementKey``; ReportFilters bound to the entity is enough to make
594
- filter controls render at runtime.
1007
+ Typed editors (``fieldType``) need the platform's typed report filter patch. A platform
1008
+ without it accepts the same request, ignores the editor part and renders its legacy control
1009
+ — a text box, or a date range for ``between`` — so the write is not refused for that: the
1010
+ outcome reports the filter as degraded instead, and the read-back confirms which editor
1011
+ rendered. A platform carrying an older patch refuses an editor type it does not offer before
1012
+ saving anything; that filter is sent again without its editor and reported degraded too.
595
1013
  """
596
1014
  if not filters:
597
1015
  raise ValueError("filters array must not be empty")
598
1016
 
599
- filter_payload = {"filters": _build_filters_payload(filters)}
1017
+ payload_filters = _build_filters_payload(filters)
1018
+ filter_payload = {"filters": payload_filters}
600
1019
  filter_ids = [f.get("fieldPath", "") for f in filters]
601
1020
  unpersisted = sorted({
602
1021
  key for f in filters for key in _FILTER_UNPERSISTED_KEYS
@@ -609,6 +1028,11 @@ def set_report_filters(cfg, token, form_id, filters, dry_run=False, form_number=
609
1028
  "payload": filter_payload,
610
1029
  "filterIds": filter_ids,
611
1030
  "unpersistedRequestedProperties": unpersisted,
1031
+ "typedFilterEditors": [
1032
+ {"fieldPath": f["fieldPath"], "fieldType": f["fieldType"],
1033
+ "legacyFieldType": legacy_filter_editor(f)}
1034
+ for f in filters if f.get("fieldType")
1035
+ ],
612
1036
  }
613
1037
 
614
1038
  if unpersisted:
@@ -621,84 +1045,319 @@ def set_report_filters(cfg, token, form_id, filters, dry_run=False, form_number=
621
1045
  file=sys.stderr, flush=True,
622
1046
  )
623
1047
 
624
- # Comparison semantics remain a query requirement; editor shape depends on the
625
- # deployed platform version and must be observed instead of assumed to be plain text.
1048
+ # Comparison semantics remain a query requirement whatever editor renders the filter.
626
1049
  print(
627
1050
  "[setReportFilters] query comparisons: "
628
1051
  + ", ".join(
629
1052
  f"{f.get('fieldPath', '')}={f.get('compare', '')}" for f in filters
630
1053
  )
631
- + ". Verify actual filter editor types from report read-back; compare is not an "
632
- "arbitrary control-type selector. Apply the requested query conditions in query().",
1054
+ + ". 编辑器或比较配置回读不证明查询条件已下推;在所属 query() 中落实比较。",
633
1055
  file=sys.stderr, flush=True,
634
1056
  )
635
1057
 
636
1058
  form_number = _ensure_form_number(cfg, token, form_id, form_number)
637
- if any(f.get("source") for f in filters):
638
- report_filter_source_expectations(cfg, token, filters)
639
1059
  _assert_report_entity_writable(
640
1060
  cfg, token, form_id, form_number, requested_filter_keys=filter_ids)
641
- # A separate route makes an old platform reject before mutation instead of silently
642
- # ignoring source and creating TextField. Never fall back after an unsupported write.
643
- complex_type = "ReportFilters.typed" if any(f.get("source") for f in filters) else "ReportFilters"
644
- result = _set_complex(cfg, token, form_number, complex_type, filter_payload)
645
- _check_result(result, "setReportFilters", form_mutation_possible=True)
1061
+ typed_keys = [f["fieldPath"] for f in filters if f.get("fieldType")]
1062
+ degraded = {}
1063
+ platform_types = None
1064
+ while True:
1065
+ sent = [
1066
+ _legacy_filter_payload(item) if item["fieldPath"] in degraded else item
1067
+ for item in payload_filters
1068
+ ]
1069
+ result = _set_complex(cfg, token, form_number, "ReportFilters", {"filters": sent})
1070
+ rejection = _unsupported_filter_type_rejection(result, sent)
1071
+ newly = []
1072
+ if rejection:
1073
+ key, refused, offered = rejection
1074
+ platform_types = offered or platform_types
1075
+ for item in sent:
1076
+ field_type = item.get("fieldType")
1077
+ if not field_type or item["fieldPath"] in degraded:
1078
+ continue
1079
+ if item["fieldPath"] == key or (offered and field_type not in offered):
1080
+ degraded[item["fieldPath"]] = (
1081
+ f"the platform's report filter API does not offer {field_type}"
1082
+ + (f" (it refused {refused})" if field_type != refused else ""))
1083
+ newly.append(item["fieldPath"])
1084
+ if newly:
1085
+ # The patch validates every filter before it saves anything, so the refused request
1086
+ # changed nothing and resending without those editors is safe.
1087
+ print(
1088
+ f"[setReportFilters] the platform does not offer the editor of {newly}; "
1089
+ "resending them as legacy filters",
1090
+ file=sys.stderr, flush=True,
1091
+ )
1092
+ continue
1093
+ _check_result(result, "setReportFilters", form_mutation_possible=True)
1094
+ break
1095
+ editor_outcome = build_filter_editor_outcome(
1096
+ filters, _filter_receipt(result), degraded, platform_types, form_number)
1097
+ if typed_keys and editor_outcome["typedFilterSupport"] != "available":
1098
+ print(
1099
+ f"[setReportFilters] typed filter editors: {editor_outcome['typedFilterSupport']}. "
1100
+ + ("The platform returned no editor receipt, so it predates the typed report "
1101
+ "filter patch and renders legacy controls; the read-back confirms which editor "
1102
+ "each filter got. " if not editor_outcome["platformReceipt"] else "")
1103
+ + "Re-apply these filters after the platform patch is installed.",
1104
+ file=sys.stderr, flush=True,
1105
+ )
1106
+ if isinstance(outcome, dict):
1107
+ outcome.update(editor_outcome)
646
1108
  return filter_ids
647
1109
 
648
1110
 
649
- def report_filter_source_expectations(cfg, token, filters):
650
- """Read only the selected source forms once; no inferred reference IDs or enum values."""
651
- sources = {}
652
- expected = {}
653
- for item in filters:
654
- source = item.get("source")
655
- if not source:
1111
+ def legacy_filter_editor(item):
1112
+ """The control a platform without the typed filter patch renders for this filter."""
1113
+ return "DateRangeField" if (item or {}).get("compare") == "between" else "TextField"
1114
+
1115
+
1116
+ def _legacy_filter_payload(item):
1117
+ """The same filter without its editor part, as a platform without the patch reads it."""
1118
+ return {
1119
+ key: value for key, value in item.items()
1120
+ if key != "fieldType" and key not in _REPORT_FILTER_TYPE_PARAMS
1121
+ }
1122
+
1123
+
1124
+ def _result_message(result):
1125
+ if not isinstance(result, dict):
1126
+ return ""
1127
+ nested = result.get("data") if isinstance(result.get("data"), dict) else {}
1128
+ return " ".join(
1129
+ str(value) for value in (result.get("message"), nested.get("message")) if value)
1130
+
1131
+
1132
+ _FIELD_CLASS_TOKEN = re.compile(r"\b[A-Z][A-Za-z]*Field\b")
1133
+
1134
+
1135
+ def _unsupported_filter_type_rejection(result, sent):
1136
+ """(filterKey, refusedType, offeredTypes) when the platform refused an editor type itself.
1137
+
1138
+ An older patch answers an editor type it does not offer with one message, before saving:
1139
+ the filter key, the refused type and the list it does offer. The wording is localized, but
1140
+ the key and the class names are not, so those are what this reads. Any other refusal — a
1141
+ missing base data, a duplicated dropdown value — is not this, and returns None.
1142
+ """
1143
+ message = _result_message(result)
1144
+ if "fieldType" not in message:
1145
+ return None
1146
+ for item in sent:
1147
+ field_type = item.get("fieldType")
1148
+ key = str(item.get("fieldPath") or "")
1149
+ if not field_type or not key or key not in message:
656
1150
  continue
657
- number = source["formNumber"]
658
- if number not in sources:
659
- data = (_get_schema(cfg, token, number) or {}).get("data") or {}
660
- sources[number] = data.get("fieldSchemas")
661
- if not isinstance(sources[number], list):
662
- raise RuntimeError("REPORT_TYPED_FILTER_PLATFORM_UNSUPPORTED: source field schema unavailable; install the typed filter platform extension")
663
- matches = [row for row in sources[number] if row.get("fieldPath") == source["fieldKey"]]
664
- if len(matches) != 1:
665
- raise ValueError("REPORT_FILTER_SOURCE_FIELD_AMBIGUOUS_OR_MISSING: " + source["fieldKey"])
666
- wanted = {key: value for key, value in matches[0].items() if key not in {"fieldPath", "displayName", "mustInput"}}
667
- if item["compare"] == "between" and wanted.get("fieldType") != "DateRangeField":
668
- if wanted.get("fieldType") not in {"DateField", "DateTimeField"}:
669
- raise ValueError("REPORT_FILTER_RANGE_TYPE_UNSUPPORTED: " + str(wanted.get("fieldType")))
670
- wanted["fieldType"] = "DateRangeField"
671
- expected[item["fieldPath"]] = wanted
672
- return expected
1151
+ position = message.find(field_type, message.find(key))
1152
+ if position < 0:
1153
+ continue
1154
+ offered = set(_FIELD_CLASS_TOKEN.findall(message[position + len(field_type):]))
1155
+ if len(offered) < 2 or field_type in offered:
1156
+ continue
1157
+ return key, field_type, offered
1158
+ return None
673
1159
 
674
1160
 
675
- def set_report_sorts(cfg, token, form_id, sorts, dry_run=False, form_number=None):
676
- """Set report sort configuration.
1161
+ def _filter_receipt(result):
1162
+ """The per-filter receipt a typed-filter platform returns, or None when there is none.
677
1163
 
678
- Args:
679
- cfg: Config dict.
680
- token: Access token.
681
- form_id: Report form ID.
682
- sorts: reportSorts[] array from artifact.
683
- dry_run: If True, return payload without submitting.
684
- form_number: Optional form number.
1164
+ The patch answers every filter write with data.filters[] (fieldPath, the editor class it
1165
+ actually built, fieldId, action) and data.removed[]. Older platforms answer with no such
1166
+ list, which is how an unpatched platform is recognized without a separate probe.
1167
+ """
1168
+ data = result.get("data") if isinstance(result, dict) else None
1169
+ inner = data.get("data") if isinstance(data, dict) else None
1170
+ rows = inner.get("filters") if isinstance(inner, dict) else None
1171
+ if not isinstance(rows, list):
1172
+ return None
1173
+ return {
1174
+ "filters": [row for row in rows if isinstance(row, dict)],
1175
+ "removed": [str(key) for key in (inner.get("removed") or [])],
1176
+ }
685
1177
 
686
- Returns:
687
- True on success.
1178
+
1179
+ def _accepted_editor_types(requested):
1180
+ return {requested, *_REPORT_FILTER_EQUIVALENT_TYPES.get(requested, ())}
1181
+
1182
+
1183
+ def build_filter_editor_outcome(filters, receipt=None, degraded=None, platform_types=None,
1184
+ form_number=""):
1185
+ """What the platform did with each filter's editor, as far as the write can tell.
1186
+
1187
+ status per filter:
1188
+ typed the receipt shows the requested editor (or its documented equivalent);
1189
+ degraded the platform refused or ignored the editor and renders the legacy control;
1190
+ unconfirmed no receipt — the read-back decides between typed and degraded;
1191
+ mismatch the receipt shows an editor that is neither requested nor legacy;
1192
+ legacy the filter asked for no editor type.
688
1193
  """
689
- if not sorts:
690
- raise ValueError("sorts array must not be empty")
1194
+ degraded = degraded or {}
1195
+ by_key = {
1196
+ str(row.get("fieldPath") or ""): row for row in ((receipt or {}).get("filters") or [])
1197
+ }
1198
+ editors = []
1199
+ for item in filters:
1200
+ key = str(item.get("fieldPath") or "")
1201
+ requested = item.get("fieldType") or None
1202
+ legacy = legacy_filter_editor(item)
1203
+ row = by_key.get(key) or {}
1204
+ entry = {"fieldPath": key, "requestedFieldType": requested, "legacyFieldType": legacy}
1205
+ platform_type = str(row.get("fieldType") or "").strip()
1206
+ if platform_type:
1207
+ entry["platformFieldType"] = platform_type
1208
+ if row.get("action"):
1209
+ entry["action"] = row["action"]
1210
+ if not requested:
1211
+ entry["status"] = "legacy"
1212
+ elif key in degraded:
1213
+ entry["status"] = "degraded"
1214
+ entry["reason"] = degraded[key]
1215
+ elif receipt is None:
1216
+ entry["status"] = "unconfirmed"
1217
+ entry["reason"] = (
1218
+ "the platform returned no editor receipt; it renders the legacy control unless "
1219
+ "the read-back shows the requested editor")
1220
+ elif platform_type in _accepted_editor_types(requested):
1221
+ entry["status"] = "typed"
1222
+ elif platform_type == legacy:
1223
+ entry["status"] = "degraded"
1224
+ entry["reason"] = f"the platform built {legacy} instead of {requested}"
1225
+ else:
1226
+ entry["status"] = "mismatch"
1227
+ entry["reason"] = (
1228
+ f"the platform built {platform_type or 'no editor'} instead of {requested}")
1229
+ editors.append(entry)
1230
+ outcome = {
1231
+ "schemaVersion": REPORT_FILTER_EDITOR_OUTCOME_VERSION,
1232
+ "formNumber": str(form_number or ""),
1233
+ "platformReceipt": receipt is not None,
1234
+ "editors": editors,
1235
+ }
1236
+ if platform_types:
1237
+ outcome["platformFieldTypes"] = sorted(platform_types)
1238
+ if receipt and receipt.get("removed"):
1239
+ outcome["removed"] = receipt["removed"]
1240
+ return _summarize_filter_editors(outcome)
1241
+
1242
+
1243
+ def _summarize_filter_editors(outcome):
1244
+ """typedFilterSupport from the per-filter statuses."""
1245
+ statuses = [row["status"] for row in outcome["editors"] if row.get("requestedFieldType")]
1246
+ if not statuses:
1247
+ support = "notRequested"
1248
+ elif "mismatch" in statuses:
1249
+ support = "mismatch"
1250
+ elif "unconfirmed" in statuses:
1251
+ support = "unconfirmed"
1252
+ elif all(status == "typed" for status in statuses):
1253
+ support = "available"
1254
+ elif all(status == "degraded" for status in statuses):
1255
+ support = "unavailable"
1256
+ else:
1257
+ support = "partial"
1258
+ outcome["typedFilterSupport"] = support
1259
+ outcome["degradedFilters"] = [
1260
+ row["fieldPath"] for row in outcome["editors"] if row["status"] == "degraded"]
1261
+ return outcome
691
1262
 
692
- sort_payload = {"sorts": _build_sorts_payload(sorts)}
693
1263
 
694
- if dry_run:
695
- return {"dryRun": True, "payload": sort_payload}
1264
+ def resolve_filter_editors(outcome, observed_rows):
1265
+ """Settle each filter's editor against the read-back, and return the mismatches.
696
1266
 
697
- form_number = _ensure_form_number(cfg, token, form_id, form_number)
698
- _assert_report_entity_writable(cfg, token, form_id, form_number)
699
- result = _set_complex(cfg, token, form_number, "ReportSorts", sort_payload)
700
- _check_result(result, "setReportSorts", form_mutation_possible=True)
701
- return True
1267
+ The read-back's schemaText names the editor class of every filter field, so an unconfirmed
1268
+ editor becomes typed or degraded here, and a typed one is held to what the receipt claimed.
1269
+ A filter whose row carries no type stays as it was: that source cannot see it. A receipt
1270
+ that already showed a wrong editor is a mismatch whether or not the read-back can see it.
1271
+ """
1272
+ resolved = json.loads(json.dumps(outcome))
1273
+ observed_by_key = {
1274
+ str(row.get("fieldPath") or ""): row for row in (observed_rows or []) if isinstance(row, dict)
1275
+ }
1276
+ mismatches = []
1277
+ unobserved = []
1278
+ for index, entry in enumerate(resolved["editors"]):
1279
+ requested = entry.get("requestedFieldType")
1280
+ if not requested:
1281
+ continue
1282
+ accepted = _accepted_editor_types(requested)
1283
+ observed = str((observed_by_key.get(entry["fieldPath"]) or {}).get("fieldType") or "")
1284
+ if not observed:
1285
+ unobserved.append(entry["fieldPath"])
1286
+ if entry["status"] == "mismatch":
1287
+ mismatches.append({
1288
+ "path": f"/reportFilters/{index}/fieldType",
1289
+ "expected": sorted(accepted),
1290
+ "actual": entry.get("platformFieldType") or "<none>",
1291
+ })
1292
+ continue
1293
+ entry["observedFieldType"] = observed
1294
+ if entry["status"] == "degraded":
1295
+ wanted = {entry["legacyFieldType"]}
1296
+ elif entry["status"] in ("typed", "mismatch"):
1297
+ wanted = accepted
1298
+ else:
1299
+ wanted = accepted | {entry["legacyFieldType"]}
1300
+ if observed not in wanted:
1301
+ entry["status"] = "mismatch"
1302
+ entry["reason"] = f"the report holds {observed} where {sorted(wanted)} was expected"
1303
+ mismatches.append({
1304
+ "path": f"/reportFilters/{index}/fieldType",
1305
+ "expected": sorted(wanted),
1306
+ "actual": observed,
1307
+ })
1308
+ continue
1309
+ if entry["status"] in ("unconfirmed", "mismatch"):
1310
+ entry["status"] = "typed" if observed in accepted else "degraded"
1311
+ if entry["status"] == "degraded":
1312
+ entry["reason"] = (
1313
+ f"the platform rendered {observed}: it predates the typed report filter patch")
1314
+ else:
1315
+ entry.pop("reason", None)
1316
+ if unobserved:
1317
+ resolved["unobservedFilters"] = unobserved
1318
+ return _summarize_filter_editors(resolved), mismatches
1319
+
1320
+
1321
+ REPORT_SORTS_API_UNSAFE = (
1322
+ "REPORT_SORTS_API_UNSAFE: 未验证原生元数据排序通道;旧 API 曾误写 InitField,"
1323
+ "新 API 返回 REPORT_SORT_NOT_SUPPORTED。InitField 是默认列可见性,不是排序。"
1324
+ "未发送请求。Keep reportSorts in the complete "
1325
+ "artifact and implement reportContract.defaultSorts in the enabled Java reportlist "
1326
+ "plugin's query(), with interactive sorting taking priority. Do not patch isvisible or "
1327
+ "use generic metadata writes; a query-data-source-only report needs a verified sorting "
1328
+ "channel before it can be delivered. Existing invalid InitField requires separate repair."
1329
+ )
1330
+
1331
+
1332
+ def set_report_sorts(cfg, token, form_id, sorts, dry_run=False, form_number=None):
1333
+ """阻断有缺陷的元数据排序 API;dry-run 也不能暗示该写入受支持。"""
1334
+ normalize_report_sorts_input(sorts)
1335
+ raise RuntimeError(REPORT_SORTS_API_UNSAFE)
1336
+
1337
+
1338
+ def compile_report_default_sorts(artifact):
1339
+ """保留排序业务要求并绑定真实叶子列;创建前确认 Java 取数插件能承接。"""
1340
+ if not artifact.get("reportSorts"):
1341
+ return []
1342
+ sorts = normalize_report_sorts_input(artifact["reportSorts"], "reportSorts")
1343
+ columns = {
1344
+ col["fieldKey"]: col
1345
+ for col in _iter_leaf_columns(_normalize_report_columns_structure(artifact.get("reportColumns") or []))
1346
+ }
1347
+ missing = [row["fieldKey"] for row in sorts if row["fieldKey"] not in columns]
1348
+ if missing:
1349
+ raise ValueError(f"REPORT_INPUT_INVALID: sort columns not found: {missing}")
1350
+ plugins = artifact.get("reportPlugins") or []
1351
+ if (len(plugins) != 1 or not isinstance(plugins[0], dict)
1352
+ or str(plugins[0].get("type") or "Java").lower() != "java"
1353
+ or plugins[0].get("enabled") is False or not plugins[0].get("className")):
1354
+ raise ValueError(
1355
+ "REPORT_DEFAULT_SORT_PLUGIN_REQUIRED: reportSorts requires exactly one enabled "
1356
+ "Java reportlist data-fetch plugin; metadata ReportSorts is unsafe. Preserve the "
1357
+ "sorting requirement and confirm a supported query implementation before creating "
1358
+ "the report. Do not silently drop sorts or add/replace a plugin without authorization."
1359
+ )
1360
+ return [{**row, "dateIndex": columns[row["fieldKey"]]["dateIndex"]} for row in sorts]
702
1361
 
703
1362
 
704
1363
  def register_report_plugin(cfg, token, form_id, plugin_spec, dry_run=False, form_number=None):
@@ -901,14 +1560,14 @@ def set_report_query_data_source(cfg, token, form_number, data_source_number=Non
901
1560
  _set_complex(cfg, token, form_number, "ReportQueryDataSource", data),
902
1561
  "setReportQueryDataSource", form_mutation_possible=True)
903
1562
 
904
- observed = read_report_query_data_source(cfg, token, form_number) or {}
1563
+ observed = _read_after_report_write(read_report_query_data_source, cfg, token, form_number) or {}
905
1564
  actual_id = str(observed.get("DataSourceId") or "")
906
1565
  expected_id = "" if clear else resolved["dataSourceId"]
907
1566
  if actual_id != expected_id:
908
- raise RuntimeError(
1567
+ raise ReportMutationError(
909
1568
  f"REPORT_QUERY_DATA_SOURCE_MISMATCH: {form_number!r} reads back DataSourceId "
910
1569
  f"{actual_id!r} after a write of {expected_id!r}; the platform reported success, so "
911
- "re-read the report before retrying"
1570
+ "re-read the report before retrying", mutation_applied=True
912
1571
  )
913
1572
  return {
914
1573
  "formNumber": form_number,
@@ -953,63 +1612,114 @@ def get_report_schema(cfg, token, form_id):
953
1612
  "reportPlugins": canonical.get("reportPlugins") or [],
954
1613
  "readBackAvailable": bool(canonical.get("readBackAvailable")),
955
1614
  "readBackSource": "canonical" if canonical.get("readBackAvailable") else "unavailable",
956
- "unverifiableSections": [],
1615
+ "unverifiableSections": sorted(set(canonical.get("unverifiableSections") or []) | {"reportSorts"} | {
1616
+ name for name in ("reportColumns", "reportFilters", "reportSorts", "reportPlugins")
1617
+ if not isinstance(canonical.get(name), list)}),
957
1618
  }
958
1619
 
959
- typed = data.get("reportFilterSchema")
960
- if isinstance(typed, dict) and typed.get("schemaVersion") == "app-build-typed-report-filters/v1":
961
- report_schema["reportFilters"] = typed.get("reportFilters") or []
962
- report_schema["typedFiltersReadBack"] = True
963
-
964
- if report_schema["readBackAvailable"]:
965
- return report_schema
966
-
967
- if report_schema.get("typedFiltersReadBack"):
968
- report_schema["readBackAvailable"] = True
969
- report_schema["readBackSource"] = "typedFilters"
970
- report_schema["unverifiableSections"] = ["reportColumns", "reportSorts", "reportPlugins"]
971
- report_schema["undecidableFilterProperties"] = ["compare", "visible", "defaultValue"]
972
-
1620
+ # 过滤的新投影独立于 reportSchema;不能因其他分区已可读而提前返回。
973
1621
  # Derived read-back. The tested deployment exposes no data.reportSchema, but
974
1622
  # data.formLayout is the same ControlAp tree that setReportColumns / setReportFilters
975
1623
  # write, so the requested structure is provable from it. Returning empty arrays here used
976
1624
  # to make every write look unverified; deriving the projection keeps verification real and
977
1625
  # names exactly which properties the derived source cannot prove.
978
1626
  layout = data.get("formLayout")
979
- if isinstance(layout, dict):
1627
+ if not report_schema["readBackAvailable"] and isinstance(layout, dict):
980
1628
  report_schema["_raw_formLayout"] = layout
981
1629
  report_schema["reportColumns"] = _columns_from_layout(layout)
982
1630
  report_schema["reportFilters"] = _filters_from_layout(layout, data.get("schemaText") or "")
983
- if report_schema.get("typedFiltersReadBack"):
984
- report_schema["reportFilters"] = typed.get("reportFilters") or []
985
1631
  report_schema["readBackAvailable"] = True
986
1632
  report_schema["readBackSource"] = "formLayout"
987
- # formLayout carries the control tree only. Default sort lives in the ReportListAp
988
- # InitField property and the data-fetch plugin lives in ReportQueryPlugin; neither
989
- # is part of the layout payload, so they stay explicitly unverifiable instead of
990
- # being reported as passing.
1633
+ # 布局不承载查询插件。InitField 是默认显示列,不是默认排序;排序保持不可验证。
991
1634
  report_schema["unverifiableSections"] = ["reportSorts", "reportPlugins"]
992
1635
  report_schema["derivedColumnProperties"] = ["fieldKey", "caption", "kind", "children"]
993
1636
  report_schema["undecidableColumnProperties"] = [
994
1637
  "dateIndex", "fieldType", "width", "headerBackColor", "hide", "freeze",
995
1638
  ]
996
1639
  report_schema["derivedFilterProperties"] = ["fieldPath", "displayName", "controlType"]
1640
+ # schemaText names the class of every report-entity field, so a filter control backed
1641
+ # by one also proves which editor it renders.
1642
+ if data.get("schemaText"):
1643
+ report_schema["derivedFilterProperties"].append("fieldType")
997
1644
  # `compare` belongs here rather than being quietly absent: the tested deployment stores
998
1645
  # `=`, `in` and `between` as the same CommonFilterColumnAp, so neither this projection
999
1646
  # nor the design-state export can tell them apart. Naming it keeps the drop visible.
1000
1647
  report_schema["undecidableFilterProperties"] = [
1001
1648
  "compare", "mustInput", "visible", "defaultValue",
1002
1649
  ]
1003
- if report_schema.get("typedFiltersReadBack"):
1004
- report_schema["undecidableFilterProperties"].remove("mustInput")
1005
1650
 
1006
1651
  schema_text = data.get("schemaText") or ""
1007
- if schema_text:
1652
+ if schema_text and report_schema["readBackSource"] != "canonical":
1008
1653
  report_schema["_raw_schemaText"] = schema_text
1009
-
1654
+ if "reportFilters" in data:
1655
+ _attach_report_filter_projection(report_schema, data["reportFilters"], layout)
1010
1656
  return report_schema
1011
1657
 
1012
1658
 
1659
+ def _attach_report_filter_projection(schema, projection, layout):
1660
+ """消费 bos1030 的真实过滤投影;来源缺失不能回退成旧布局中的肯定证据。"""
1661
+ schema["reportFilterProjection"] = copy.deepcopy(projection)
1662
+ schema.setdefault("readBackSources", {})["reportFilters"] = "reportFilterProjection"
1663
+ unavailable = set(schema.get("unverifiableSections") or []) | {"reportFilters"}
1664
+ schema["unverifiableSections"] = sorted(unavailable)
1665
+ if not isinstance(projection, dict) or projection.get("available") is not True:
1666
+ return
1667
+ rows = projection.get("filters")
1668
+ panel = projection.get("filterPanel")
1669
+ if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
1670
+ return
1671
+ if not isinstance(panel, dict) or panel.get("commFilterPanelFound") is not True:
1672
+ return
1673
+ show = panel.get("showFilterFields")
1674
+ if not isinstance(show, dict) or show.get("format") not in {"json-array", "empty"}:
1675
+ return
1676
+ parsed = show.get("parsed")
1677
+ if (not isinstance(parsed, list) or not all(isinstance(key, str) and key for key in parsed)
1678
+ or len(set(parsed)) != len(parsed)
1679
+ or [row.get("fieldPath") for row in rows] != parsed):
1680
+ return
1681
+ # 新 API 会对坏 JSON 兼容拆分;必须核对原值,不能把兼容输出当正常空配置。
1682
+ try:
1683
+ raw = json.loads(show.get("raw")) if show.get("format") == "json-array" else []
1684
+ except (TypeError, ValueError):
1685
+ return
1686
+ if raw != parsed or (show.get("format") == "empty" and show.get("raw") not in (None, "")):
1687
+ return
1688
+ nodes = list(_iter_layout_items(layout))
1689
+ filters = []
1690
+ for original in rows:
1691
+ row = copy.deepcopy(original)
1692
+ unknown = set(row.get("unknown") or [])
1693
+ # compare 是配置对象,不是公开的比较运算符;完整保留,不猜 compareTypeId。
1694
+ row["compareConfig"] = row.pop("compare", None)
1695
+ for name in ("mustInput", "fieldType"):
1696
+ if name in unknown or row.get(name) is None:
1697
+ row.pop(name, None)
1698
+ if not isinstance(row.get("mustInput"), bool):
1699
+ row.pop("mustInput", None)
1700
+ if "visibleEffective" not in unknown and isinstance(row.get("visibleEffective"), bool):
1701
+ row["visible"] = row["visibleEffective"]
1702
+ reference = row.get("reference")
1703
+ if isinstance(reference, dict) and "reference" not in unknown:
1704
+ if "baseEntityNumber" not in (reference.get("unknown") or []) and reference.get("baseEntityNumber"):
1705
+ row["referenceNumber"] = reference["baseEntityNumber"]
1706
+ # 新投影没有名称;只从同一响应的真实控件身份取名,不用请求值补齐。
1707
+ matches = [node for node in nodes if row.get("controlId")
1708
+ and node.get("id") == row["controlId"]
1709
+ and node.get("fieldId") == row.get("fieldId")
1710
+ and node.get("key") == row.get("fieldPath")]
1711
+ if len(matches) == 1 and isinstance(matches[0].get("name"), str):
1712
+ row["displayName"] = matches[0]["name"]
1713
+ filters.append(row)
1714
+ schema["reportFilters"] = filters
1715
+ schema["readBackAvailable"] = True
1716
+ if schema.get("readBackSource") == "unavailable":
1717
+ schema["readBackSource"] = "reportFilterProjection"
1718
+ schema["unverifiableSections"] = sorted(unavailable - {"reportFilters"})
1719
+ schema["derivedFilterProperties"] = ["fieldPath", "fieldId", "fieldType", "mustInput", "visible", "reference", "rangeKeys"]
1720
+ schema["undecidableFilterProperties"] = ["compare", "defaultValue"]
1721
+
1722
+
1013
1723
  # Filter control types emitted under reportcommfilterpanelap, mapped back to the
1014
1724
  # semantic compare that produced them.
1015
1725
  _FILTER_CONTROL_COMPARE = {
@@ -1062,6 +1772,9 @@ def _filters_from_layout(layout, schema_text=""):
1062
1772
  entities = parse_schema_text(schema_text)
1063
1773
  fields = (entities[0].get("fields") or []) if entities else []
1064
1774
  field_types = {field["fieldKey"]: field["fieldType"] for field in fields if field.get("fieldType")}
1775
+ # schemaText prints the referenced base data after every BasedataField-derived field, which
1776
+ # is what proves an F7 filter points at the base data it was given.
1777
+ references = {field["fieldKey"]: field["refEntity"] for field in fields if field.get("refEntity")}
1065
1778
  result = []
1066
1779
  for child in panel.get("children") or []:
1067
1780
  if not isinstance(child, dict):
@@ -1079,6 +1792,8 @@ def _filters_from_layout(layout, schema_text=""):
1079
1792
  }
1080
1793
  if item["fieldPath"] in field_types:
1081
1794
  item["fieldType"] = field_types[item["fieldPath"]]
1795
+ if item["fieldPath"] in references:
1796
+ item["referenceNumber"] = references[item["fieldPath"]]
1082
1797
  # The layout's own `visible` is a control-phase list ("init,new,edit,...") and is not
1083
1798
  # the filter contract's boolean `visible`. Exposing it under the same key would make a
1084
1799
  # dropped `visible: false` look persisted, so keep the names apart.
@@ -1089,11 +1804,7 @@ def _filters_from_layout(layout, schema_text=""):
1089
1804
 
1090
1805
 
1091
1806
  # ─── Design-state (.dym) evidence ─────────────────────────────────────────────
1092
- # formLayout proves the control tree but carries neither the default sort nor the data-fetch
1093
- # plugin, so on the tested deployment those two sections had no observable read-back at all.
1094
- # syncDym exports the platform's own design-state metadata to the repository, and that
1095
- # export does carry both: <InitField> holds the sort list and <ReportQueryPlugin> holds the
1096
- # registered plugins. Parsing it turns "not carried by this source" into real evidence.
1807
+ # syncDym 的 ReportQueryPlugin 补充插件证据;InitField 只承载默认显示列,绝不作为排序证据。
1097
1808
  #
1098
1809
  # Freshness is the caller's responsibility: a .dym on disk only proves the platform state as
1099
1810
  # of the last syncDym. Callers must sync after their writes and before asking for this
@@ -1125,32 +1836,7 @@ def locate_report_dym(app_root, form_number):
1125
1836
  return max(matches, key=lambda path: os.path.getmtime(path))
1126
1837
 
1127
1838
 
1128
- def _dym_sorts(root):
1129
- """Report default sorts from every <InitField> payload in the export."""
1130
- sorts = []
1131
- for node in root.iter("InitField"):
1132
- raw = (node.text or "").strip()
1133
- if not raw:
1134
- continue
1135
- try:
1136
- entries = json.loads(raw)
1137
- except (ValueError, TypeError):
1138
- continue
1139
- if not isinstance(entries, list):
1140
- continue
1141
- for entry in entries:
1142
- if not isinstance(entry, dict):
1143
- continue
1144
- if str(entry.get("purpose") or "").strip().lower() != "sort":
1145
- continue
1146
- field_key = str(entry.get("listname") or entry.get("freezelistkey") or "").strip()
1147
- if not field_key:
1148
- continue
1149
- sorts.append({
1150
- "fieldKey": field_key,
1151
- "direction": str(entry.get("direction") or "asc").strip().lower(),
1152
- })
1153
- return sorts
1839
+ # InitField 是默认显示列配置;purpose=sort 是旧 API 的污染,不能作为排序证据。
1154
1840
 
1155
1841
 
1156
1842
  def _dym_plugins(root):
@@ -1242,7 +1928,7 @@ def _dym_filters(root):
1242
1928
  # failure mode this list exists to prevent: an undecidable property is not evidence that the
1243
1929
  # write failed. Once the fixed platform is deployed, remove "description" here and let the
1244
1930
  # comparison run again — the value becomes real evidence at that point.
1245
- _DYM_SECTIONS = ("reportSorts", "reportPlugins")
1931
+ _DYM_SECTIONS = ("reportPlugins",)
1246
1932
  _DYM_UNDECIDABLE_PLUGIN_PROPERTIES = ("type", "bizAppId", "fpk", "description")
1247
1933
 
1248
1934
 
@@ -1260,7 +1946,6 @@ def parse_report_dym(path):
1260
1946
  return {}
1261
1947
  evidence = {
1262
1948
  "path": path,
1263
- "reportSorts": _dym_sorts(root),
1264
1949
  "reportPlugins": _dym_plugins(root),
1265
1950
  "reportFilters": _dym_filters(root),
1266
1951
  }
@@ -1283,6 +1968,8 @@ def _merge_dym_filter_properties(report_schema, rows):
1283
1968
  is missing evidence, and filling some rows while leaving others bare would make an absent
1284
1969
  node look like a proven default.
1285
1970
  """
1971
+ if "reportFilterProjection" in report_schema:
1972
+ return [] # 导出只能补旧布局的缺口,不能覆盖新投影或将其 unknown 变成默认值。
1286
1973
  observed = [item for item in (report_schema.get("reportFilters") or []) if isinstance(item, dict)]
1287
1974
  if not observed or not rows:
1288
1975
  return []
@@ -1305,15 +1992,12 @@ def _merge_dym_filter_properties(report_schema, rows):
1305
1992
  def attach_dym_evidence(report_schema, evidence):
1306
1993
  """Promote what the .dym export proves onto a report schema that could not observe it.
1307
1994
 
1308
- Two kinds of promotion: a whole section the derived source does not carry at all
1309
- (reportSorts, reportPlugins), and individual filter properties the API projection cannot
1310
- decide (mustInput, visible). Only what the export actually carries is promoted.
1995
+ 仅提升 reportPlugins 及过滤属性 mustInput / visible。
1996
+ InitField 的排序污染条目不被解析或提升为排序证据。
1311
1997
 
1312
- This is confined to the formLayout tier by construction: a canonical projection already
1313
- proves everything and must not be overwritten by a file that may predate the last write,
1314
- and an `unavailable` projection lists no sections at all, so promoting into it would flip
1315
- readBackAvailable to true while columns and filters remained unobserved — turning an
1316
- unverified write into a passing one.
1998
+ 仅补充 formLayout 档的缺失证据,不以可能陈旧的导出覆盖 canonical 数据。
1999
+ canonical 排序投影也不证明排序生效。unavailable 档不能被提升:
2000
+ 否则列和过滤仍未观察到,却会把 readBackAvailable 置为 true,造成错误验收。
1317
2001
  """
1318
2002
  if not isinstance(report_schema, dict) or not isinstance(evidence, dict):
1319
2003
  return report_schema
@@ -1364,6 +2048,9 @@ _RECREATE_LOSSES = (
1364
2048
  "the old one has to be re-pointed",
1365
2049
  "anything adjusted by hand in the Cosmic designer on this report",
1366
2050
  "form-level permission configuration attached to the old formId",
2051
+ "plugins registered on the report form itself (plugin type reportform, read back by "
2052
+ "queryEditablePlugins as location BILL_FORM): deleteForm removes them with the form and "
2053
+ "run registers only the data plugins",
1367
2054
  )
1368
2055
 
1369
2056
 
@@ -1406,12 +2093,80 @@ def _menu_bindings(menus, form_number, form_id):
1406
2093
  return found
1407
2094
 
1408
2095
 
1409
- def plan_report_recreate(current_schema, target_artifact=None, menus=None):
2096
+ def _report_form_plugins(rows, data_plugins):
2097
+ """This app's plugins on the report form's own plugin list, from queryEditablePlugins rows.
2098
+
2099
+ queryEditablePlugins returns a report's root form plugins as location BILL_FORM, and only
2100
+ the ones this app owns, so template plugins are not listed. Its REPORT_QUERY_PLUGIN rows are
2101
+ the data plugins the artifact carries, which run registers again by itself. A data plugin
2102
+ found on the form list is split out: that slot casts it to a form plugin when the report
2103
+ opens, so re-registering it there would recreate the failure. ``None`` means not read, and so
2104
+ does a read-back that is not a row list.
2105
+ """
2106
+ if not isinstance(rows, list):
2107
+ return None, []
2108
+ plugins, misplaced, seen = [], [], set()
2109
+ for row in rows:
2110
+ if not isinstance(row, dict) or str(row.get("location") or "").strip() != "BILL_FORM":
2111
+ continue
2112
+ class_name = str(row.get("className") or "").strip()
2113
+ if not class_name or class_name in seen:
2114
+ continue
2115
+ seen.add(class_name)
2116
+ if class_name in data_plugins:
2117
+ misplaced.append(class_name)
2118
+ continue
2119
+ plugins.append({
2120
+ "className": class_name,
2121
+ "pluginType": str(row.get("pluginType") or "JAVA").strip().upper(),
2122
+ "enabled": row.get("enabled") is not False,
2123
+ })
2124
+ return plugins, misplaced
2125
+
2126
+
2127
+ def _form_plugin_step_detail(plugins, misplaced, form_number):
2128
+ if plugins is None:
2129
+ return (
2130
+ "the report form's own plugin list was not read; before deleting, list it with "
2131
+ f"queryEditablePlugins --formNumber {form_number or '<formNumber>'} (rows with "
2132
+ "location BILL_FORM), because deleteForm removes those plugins and run does not "
2133
+ "register them"
2134
+ )
2135
+ java = [item["className"] for item in plugins if item["pluginType"] == "JAVA"]
2136
+ scripts = [item["className"] for item in plugins if item["pluginType"] != "JAVA"]
2137
+ parts = []
2138
+ if java:
2139
+ parts.append(
2140
+ f"{len(java)} Java plugin(s) on the report form itself: {', '.join(java)}; register "
2141
+ "each again once step 3 has created the form, with targetType BILL_FORM, and check "
2142
+ "that the read-back lists it at location BILL_FORM. A class that is not in "
2143
+ "archive.json is registered with registerPlugin --formNumber instead"
2144
+ )
2145
+ if scripts:
2146
+ parts.append(
2147
+ f"script plugin(s) {', '.join(scripts)}: register_plugins.py registers Java classes "
2148
+ "only, so register them with registerPlugin and targetType BILL_FORM once "
2149
+ "queryAvailableScripts offers the script for the recreated form"
2150
+ )
2151
+ if misplaced:
2152
+ parts.append(
2153
+ f"{', '.join(misplaced)} is the report's data plugin but also sits on the form "
2154
+ "plugin list, where it fails when the report opens; step 3 registers it in the "
2155
+ "data-plugin slot, so do not register it on the form again"
2156
+ )
2157
+ return "; ".join(parts) or (
2158
+ "no plugin of this app is registered on the report form itself, so nothing to "
2159
+ "re-register"
2160
+ )
2161
+
2162
+
2163
+ def plan_report_recreate(current_schema, target_artifact=None, menus=None, plugin_rows=None):
1410
2164
  """Plan the only supported way to change a report's column set.
1411
2165
 
1412
2166
  Read-only: it decides nothing and writes nothing. ``current_schema`` is a
1413
2167
  get_report_schema projection, ``target_artifact`` the intended report artifact (optional),
1414
- and ``menus`` a queryMenuTree menu list (optional).
2168
+ ``menus`` a queryMenuTree menu list (optional) and ``plugin_rows`` the report's
2169
+ queryEditablePlugins rows (optional; ``None`` means they were not read).
1415
2170
  """
1416
2171
  if not isinstance(current_schema, dict):
1417
2172
  raise ValueError("REPORT_INPUT_INVALID: current report schema is required")
@@ -1419,6 +2174,10 @@ def plan_report_recreate(current_schema, target_artifact=None, menus=None):
1419
2174
  form_id = str(current_schema.get("formId") or "").strip()
1420
2175
 
1421
2176
  current_columns = _column_index(current_schema.get("reportColumns") or [])
2177
+ form_plugins, misplaced_plugins = _report_form_plugins(plugin_rows, {
2178
+ str(item.get("className") or "").strip()
2179
+ for item in current_schema.get("reportPlugins") or [] if isinstance(item, dict)
2180
+ })
1422
2181
  plan = {
1423
2182
  "schemaVersion": REPORT_RECREATE_PLAN_VERSION,
1424
2183
  "formNumber": form_number,
@@ -1435,6 +2194,7 @@ def plan_report_recreate(current_schema, target_artifact=None, menus=None):
1435
2194
  "reportFilters": current_schema.get("reportFilters") or [],
1436
2195
  "reportSorts": current_schema.get("reportSorts") or [],
1437
2196
  "reportPlugins": current_schema.get("reportPlugins") or [],
2197
+ "formPlugins": form_plugins,
1438
2198
  "unverifiableSections": current_schema.get("unverifiableSections") or [],
1439
2199
  },
1440
2200
  "lostOnRecreate": list(_RECREATE_LOSSES),
@@ -1501,13 +2261,23 @@ def plan_report_recreate(current_schema, target_artifact=None, menus=None):
1501
2261
  "command": 'run --app-root "<appRoot>" --artifact "<artifactPath>"',
1502
2262
  "detail": (
1503
2263
  "the artifact keeps the same formNumber and carries the complete intended "
1504
- "columns, filters, sorts and plugins; the pipeline verifies the result"
2264
+ "columns, filters, sorts and plugins; the pipeline verifies observable metadata, "
2265
+ "while default sorts remain Java query requirements pending runtime verification"
1505
2266
  ),
1506
2267
  },
1507
2268
  {
1508
- # Last, not first: the recreated form only gets its new formId in step 3, so a
1509
- # menu cannot be re-pointed before then.
1510
2269
  "order": 4,
2270
+ "action": "re-register the plugins listed in current.formPlugins on the report form",
2271
+ "command": (
2272
+ 'register_plugins.py --app-root "<appRoot>" --class-name "<className>" --verify'
2273
+ ),
2274
+ "detail": _form_plugin_step_detail(form_plugins, misplaced_plugins, form_number),
2275
+ },
2276
+ {
2277
+ # Last, not first: the recreated form only gets its new formId in step 3, so a
2278
+ # menu cannot be re-pointed before then, and pointing it at a report whose own
2279
+ # plugins are not back yet would hand users a report that misbehaves.
2280
+ "order": 5,
1511
2281
  "action": "re-point the menu entries listed in menuBindings",
1512
2282
  "detail": (
1513
2283
  f"{len(plan['menuBindings'])} menu entry(ies) point at this report"
@@ -1645,17 +2415,109 @@ def _iter_leaf_columns(columns):
1645
2415
  yield col
1646
2416
 
1647
2417
 
1648
- def compile_report_plugin_contract(artifact, form_number=""):
2418
+ def _filter_value_shape(field_type, filter_key, range_keys=None):
2419
+ """How query() reads one editor's value: accessor call, Java value and evidence level."""
2420
+ method, java_type, evidence = _REPORT_FILTER_VALUE_SHAPES.get(
2421
+ field_type, ("getValue", "Object", "observeOnce"))
2422
+ if range_keys:
2423
+ accessor = (
2424
+ "ReportQueryParam.getFilter().getFilterItems(), matching "
2425
+ f'"{range_keys["start"]}" and "{range_keys["end"]}"')
2426
+ else:
2427
+ accessor = f'ReportQueryParam.getFilter().{method}("{filter_key}")'
2428
+ return {"valueAccessor": accessor, "valueJavaType": java_type, "valueEvidence": evidence}
2429
+
2430
+
2431
+ def _filter_range_keys(item, field_type):
2432
+ """The two runtime item keys a range editor submits, or None for a single-value editor."""
2433
+ suffixes = _REPORT_FILTER_RANGE_ITEM_SUFFIXES.get(field_type)
2434
+ if not suffixes:
2435
+ return None
2436
+ key = str(item.get("fieldPath") or "")
2437
+ return {
2438
+ "start": str(item.get("startDateFieldKey") or key + suffixes[0]),
2439
+ "end": str(item.get("endDateFieldKey") or key + suffixes[1]),
2440
+ }
2441
+
2442
+
2443
+ def _filter_editor_contract(item, filter_key, editor_row=None):
2444
+ """The editor half of one filterValueContracts entry.
2445
+
2446
+ A typed editor needs the platform's typed report filter patch, and the client can be newer
2447
+ than the platform. So the entry names both the requested editor and the legacy one a
2448
+ platform without the patch renders, each with the accessor that reads it: query() written
2449
+ against both keeps working when the patch is installed later and the filters re-applied.
2450
+ """
2451
+ requested = item.get("fieldType") or None
2452
+ legacy = legacy_filter_editor(item)
2453
+ editor = requested or legacy
2454
+ entry = {"editorFieldType": editor, "editorRequested": bool(requested)}
2455
+ entry.update(_filter_value_shape(editor, filter_key, _filter_range_keys(item, editor)))
2456
+ if requested and requested != legacy:
2457
+ entry["legacyEditor"] = {
2458
+ "fieldType": legacy,
2459
+ **_filter_value_shape(legacy, filter_key, _filter_range_keys(item, legacy)),
2460
+ }
2461
+ for name in ("basedataNumber", "orgFuncs", "asstTypeId", "typeFieldKey"):
2462
+ if item.get(name):
2463
+ entry[name] = item[name]
2464
+ if item.get("comboItems"):
2465
+ entry["comboValues"] = [row["value"] for row in item["comboItems"]]
2466
+ if item.get("itemClassTypes"):
2467
+ entry["itemClassTypes"] = [row["basedataNumber"] for row in item["itemClassTypes"]]
2468
+ if requested in REPORT_FILTER_MULTI_VALUE_TYPES:
2469
+ entry["multiValue"] = True
2470
+ if editor_row:
2471
+ entry["editorStatus"] = editor_row.get("status")
2472
+ rendered = editor_row.get("observedFieldType") or editor_row.get("platformFieldType")
2473
+ if rendered:
2474
+ entry["renderedFieldType"] = rendered
2475
+ return entry
2476
+
2477
+
2478
+ def registered_report_plugin_types(artifact):
2479
+ """Every Java class the artifact registers, mapped to the plugin type its slot runs.
2480
+
2481
+ The slot decides the base class. reportPlugins lands on the report list's ReportQueryPlugin,
2482
+ which runs an AbstractReportListDataPlugin; reportProperties.tree.ReportPlugin is the left
2483
+ tree's own data plugin on ReportTreeAp, which runs an AbstractReportTreeDataPlugin. Reading only
2484
+ the first slot left a left-tree report's tree class outside the card gate, so it could reach
2485
+ the cards with no source. A JS or KingScript entry has no Java source to deliver, so listing it
2486
+ would make the gate demand a .java file that can never exist.
2487
+ """
2488
+ types = {}
2489
+ if not isinstance(artifact, dict):
2490
+ return types
2491
+ for plugin in artifact.get("reportPlugins") or []:
2492
+ if not isinstance(plugin, dict):
2493
+ continue
2494
+ if str(plugin.get("type") or "Java").strip().lower() != "java":
2495
+ continue
2496
+ name = str(plugin.get("className") or "").strip()
2497
+ if name:
2498
+ types.setdefault(name, "reportlist")
2499
+ properties = artifact.get("reportProperties")
2500
+ tree = properties.get("tree") if isinstance(properties, dict) else None
2501
+ tree_plugin = str(tree.get("ReportPlugin") or "").strip() if isinstance(tree, dict) else ""
2502
+ if tree_plugin:
2503
+ types.setdefault(tree_plugin, "reporttreelist")
2504
+ return types
2505
+
2506
+
2507
+ def compile_report_plugin_contract(artifact, form_number="", filter_editors=None):
1649
2508
  """Compile every requirement the report's plugin must implement.
1650
2509
 
1651
2510
  Returns None when the artifact asks nothing of the plugin beyond returning rows, so an
1652
- empty contract is never written just to have a file.
2511
+ empty contract is never written just to have a file. ``filter_editors`` is the outcome the
2512
+ filter write produced (build_filter_editor_outcome / resolve_filter_editors); when given, each
2513
+ filter also says which editor this report actually renders.
1653
2514
  """
1654
2515
  if not isinstance(artifact, dict):
1655
2516
  return None
1656
2517
  columns = artifact.get("reportColumns") or []
1657
2518
  filters = artifact.get("reportFilters") or []
1658
- plugins = artifact.get("reportPlugins") or []
2519
+ plugin_types = registered_report_plugin_types(artifact)
2520
+ default_sorts = compile_report_default_sorts(artifact)
1659
2521
 
1660
2522
  data_set_columns = []
1661
2523
  column_types = []
@@ -1693,6 +2555,19 @@ def compile_report_plugin_contract(artifact, form_number=""):
1693
2555
  bound = str(spec.get(_PRESENTATION_BINDING_KEY.get(spec["kind"], "")) or "").strip()
1694
2556
  if bound:
1695
2557
  entry["bindsTo"] = bound
2558
+ # 原始/历史 Artifact 仍保留需求,但工厂签名和 Long 主键不构成运行证明。
2559
+ entry["precisionSource"] = {
2560
+ "fieldKey": bound,
2561
+ "requiredRuntimeValueType": "DynamicObject",
2562
+ "runtimeVerified": False,
2563
+ "rawIdIsReference": False,
2564
+ "metadataWriteBlocked": True,
2565
+ "requiredEvidence": [
2566
+ "persisted currency/unit reference property and old scalar field migration",
2567
+ "batch row reference materialization before NumberFormatProvider",
2568
+ "mixed precision, grouping, totals, paging and export runtime verification",
2569
+ ],
2570
+ }
1696
2571
  bindings.setdefault(bound, []).append(entry["fieldKey"])
1697
2572
  presentation.append(entry)
1698
2573
 
@@ -1706,6 +2581,10 @@ def compile_report_plugin_contract(artifact, form_number=""):
1706
2581
  enforcement = []
1707
2582
  value_contracts = []
1708
2583
  field_config = (artifact.get("reportProperties") or {}).get("filterFieldConfig") or []
2584
+ editor_rows = {
2585
+ str(row.get("fieldPath") or ""): row
2586
+ for row in ((filter_editors or {}).get("editors") or []) if isinstance(row, dict)
2587
+ }
1709
2588
  for item in filters:
1710
2589
  filter_key = str(item.get("fieldPath") or "")
1711
2590
  binding = next((row for row in field_config if row.get("key") == filter_key), {})
@@ -1717,19 +2596,22 @@ def compile_report_plugin_contract(artifact, form_number=""):
1717
2596
  between = item.get("compare") == "between"
1718
2597
  accessor = ('ReportQueryParam.getFilter().getFilterItems()' if between
1719
2598
  else f'ReportQueryParam.getFilter().getValue("{filter_key}")')
1720
- value_contracts.append({
2599
+ range_keys = _filter_range_keys(item, item.get("fieldType") or legacy_filter_editor(item))
2600
+ contract_entry = {
1721
2601
  "filterKey": filter_key,
1722
2602
  "compare": item.get("compare"),
1723
2603
  "candidateValueKeys": candidates,
1724
2604
  "runtimeKeysVerified": False,
1725
2605
  "valueChannel": "getFilterItems" if between else "getValue",
1726
- "rangeItemKeys": {"start": filter_key + "_startdate", "end": filter_key + "_enddate"} if between else None,
1727
- "sourceBinding": item.get("source") or {key: binding[key] for key in ("dataSourceId", "fieldName") if binding.get(key)},
2606
+ "rangeItemKeys": range_keys if between else None,
2607
+ "sourceBinding": {key: binding[key] for key in ("dataSourceId", "fieldName") if binding.get(key)},
1728
2608
  "requiredEvidence": ["source field type and editor/reference/enum configuration",
1729
2609
  "actual runtime key mapping and comparison semantics",
1730
2610
  "query entity, field and value type",
1731
2611
  "F7 organization scope and number uniqueness when applicable"],
1732
- })
2612
+ }
2613
+ contract_entry.update(_filter_editor_contract(item, filter_key, editor_rows.get(filter_key)))
2614
+ value_contracts.append(contract_entry)
1733
2615
  # Every filter carries its comparison here. The platform stores one plain control per
1734
2616
  # key whatever the compare is, so `between` / `in` / `=` exist only as a requirement on
1735
2617
  # query() — without this entry they reached Java delivery as nothing at all.
@@ -1740,8 +2622,8 @@ def compile_report_plugin_contract(artifact, form_number=""):
1740
2622
  "requirement": _FILTER_UNPERSISTED_COMPARE,
1741
2623
  "value": item.get(_FILTER_UNPERSISTED_COMPARE),
1742
2624
  "reason": (
1743
- "this deployment stores every comparison as the same filter control, so "
1744
- "query() must apply the comparison itself"
2625
+ "编辑器或 ReportFilterFieldConfig 不证明用户条件已下推;"
2626
+ "query() 必须在所属数据源落实比较并保留权限条件"
1745
2627
  ),
1746
2628
  "java": accessor,
1747
2629
  })
@@ -1766,28 +2648,50 @@ def compile_report_plugin_contract(artifact, form_number=""):
1766
2648
  contract = {
1767
2649
  "schemaVersion": REPORT_PLUGIN_CONTRACT_VERSION,
1768
2650
  "formNumber": str(form_number or ""),
1769
- "pluginClasses": [
1770
- str(plugin.get("className") or "") for plugin in plugins
1771
- if str(plugin.get("className") or "")
1772
- ],
2651
+ "pluginClasses": list(plugin_types),
2652
+ "pluginTypes": plugin_types,
1773
2653
  "dataSetColumns": data_set_columns,
1774
2654
  "dataSetColumnTypes": column_types,
1775
2655
  "columnPresentation": presentation,
1776
2656
  "filterEnforcement": enforcement,
1777
2657
  "filterValueContracts": value_contracts,
2658
+ "defaultSorts": default_sorts,
2659
+ "defaultSortPolicy": {
2660
+ "implementation": "reportlist.query",
2661
+ "interactiveSortChannel": "ReportQueryParam.getSortInfo()",
2662
+ "priority": "interactiveThenDefault",
2663
+ "applyStage": "afterDataSetAssemblyBeforePagingOrExport",
2664
+ "metadataWritten": False,
2665
+ "runtimeVerified": False,
2666
+ },
1778
2667
  "permissionFilterChannel": "getQFilters",
1779
2668
  "notes": [
2669
+ "defaultSorts is a query() requirement, not persisted metadata. Map interactive sort "
2670
+ "keys through declared fieldKey -> dateIndex bindings and honor that ordering first; "
2671
+ "only when no interactive ordering is present apply defaultSorts in array order. "
2672
+ "Apply ordering after joins, unions and aggregation, before paging/export; preserve "
2673
+ "permission filters. Use target-SDK SortInfo and DataSet ordering APIs, never "
2674
+ "concatenate unvalidated user sort keys into SQL. Contract/source existence is not "
2675
+ "runtime evidence; test default and interactive ordering separately.",
2676
+ "pluginClasses are the Java classes this report registers, and pluginTypes names the "
2677
+ "plugin type each one's slot runs: reportlist for the report list's ReportQueryPlugin "
2678
+ "(AbstractReportListDataPlugin), reporttreelist for the left tree's "
2679
+ "ReportTreeAp.ReportPlugin (AbstractReportTreeDataPlugin). Deliver each class as "
2680
+ "that type; the card gate refuses the cards while any of them has no source.",
1780
2681
  "dataSetColumns are the column names query() must return, case-sensitive, and "
1781
2682
  "dataSetColumnTypes is the Java type each one must carry. Nothing in metadata can "
1782
2683
  "check either. The report sets every value into a metadata field, so a wrong type "
1783
2684
  "fails at set time rather than at read time: read each column with its rowAccessor, "
1784
2685
  "and declare it as its algoDataType when building a DataSet by hand.",
1785
- "A column with requiredBy is the hidden id another column's presentation binds to "
1786
- "(bindsTo names it from the other side). It looks droppable and is not: without it "
1787
- "the formatted columns listed there lose their currency or unit.",
1788
- "Bind columnPresentation in getColumns() using each java call plus its "
1789
- "javaFollowUp setters; do not guess ReportColumn.TYPE_* names. Recurse into "
1790
- "ReportColumnGroup.getChildren(): a grouped column is not in the top-level list.",
2686
+ "requiredBy and bindsTo retain dependencies, not runtime proof. A Long DataSet id "
2687
+ "is not a DynamicObject precision reference. precisionSource marks dynamic bindings "
2688
+ "unverified and blocked for metadata writes; preserve the business requirement until "
2689
+ "reference properties, migration and batch materialization are verified. Neither "
2690
+ "hide nor a factory call upgrades an existing BigInt property.",
2691
+ "columnPresentation java and javaFollowUp describe documented signatures, not a "
2692
+ "complete precision implementation. Do not execute dynamic bindings before satisfying "
2693
+ "precisionSource. For supported presentation, recurse into "
2694
+ "ReportColumnGroup.getChildren(); do not guess ReportColumn.TYPE_* names.",
1791
2695
  "Enforce filterEnforcement using filterValueContracts. candidateValueKeys are "
1792
2696
  "unverified candidates, not a universal prefix rule; confirm actual keys and reject "
1793
2697
  "conflicting values. Date ranges may use separate start/end FilterItemInfo values. "
@@ -1795,8 +2699,18 @@ def compile_report_plugin_contract(artifact, form_number=""):
1795
2699
  "getQFilters carries permissions and never substitutes for extracting user values. "
1796
2700
  "F7 number filters are String comparisons on field.number, while field/id comparisons "
1797
2701
  "require Long; preserve organization and permission scope.",
2702
+ "Each filterValueContracts entry names its editor (editorFieldType) and the "
2703
+ "FilterInfo call that reads it (valueAccessor). A typed editor needs the platform's "
2704
+ "typed report filter patch; a platform without it renders legacyEditor instead, and "
2705
+ "editorStatus says which one this report got when the write reported it. Read the "
2706
+ "value of the editor that rendered; unless editorStatus is typed, accept the "
2707
+ "legacyEditor shape as well, so installing the patch later and re-applying the "
2708
+ "filters does not break query(). valueEvidence observeOnce means no value class has "
2709
+ "been measured for that editor: confirm it with one request-scoped observation.",
1798
2710
  ],
1799
2711
  }
2712
+ if filter_editors:
2713
+ contract["filterEditorSupport"] = filter_editors.get("typedFilterSupport")
1800
2714
  return contract
1801
2715
 
1802
2716
 
@@ -1812,6 +2726,137 @@ def write_report_plugin_contract(app_root, form_number, contract):
1812
2726
  return path
1813
2727
 
1814
2728
 
2729
+ def report_authoring_path(app_root, form_number):
2730
+ from pathlib import Path
2731
+ if not re.fullmatch(r"[A-Za-z0-9_]+", str(form_number or "")):
2732
+ raise ValueError("REPORT_INPUT_INVALID: exact formNumber required for local report state")
2733
+ return Path(app_root).resolve() / "_runtime" / f"{form_number}.report-authoring.json"
2734
+
2735
+
2736
+ def _report_observation(schema):
2737
+ """仅对比平台实际可观测的结构,不能从回读推造 presentation 或 compare。"""
2738
+ def columns(rows):
2739
+ return [{**{key: row[key] for key in ("kind", "fieldKey", "caption", "dateIndex", "fieldType") if key in row},
2740
+ **({"children": columns(row.get("children") or [])} if row.get("kind") == "group" else {})}
2741
+ for row in rows]
2742
+ return {"reportColumns": columns(schema.get("reportColumns") or []),
2743
+ "reportFilters": [{key: row[key] for key in ("fieldPath", "displayName", "fieldType", "refId", "referenceNumber") if key in row}
2744
+ for row in schema.get("reportFilters") or []],
2745
+ **{key: schema[key] for key in ("reportSorts", "reportPlugins", "reportProperties", "queryDataSource") if key in schema}}
2746
+
2747
+
2748
+ def write_report_authoring_state(app_root, form_number, form_id, artifact, schema, filter_editors=None):
2749
+ """保存当前需求与独立回读证据;历史 run-result 不被改写成新的运行验证。"""
2750
+ from pathlib import Path
2751
+ import uuid
2752
+ # 拒绝将未验证动态精度刷新为新成功基线;原始契约编译仍可保留全部历史需求。
2753
+ _assert_report_precision_writable(artifact.get("reportColumns") or [])
2754
+ state_path = report_authoring_path(app_root, form_number)
2755
+ state_path.parent.mkdir(parents=True, exist_ok=True)
2756
+ artifact_path = state_path.with_name(f"{form_number}.report-artifact.json")
2757
+ # 先失效旧成功状态:中途失败时旧契约不能继续冒充当前。
2758
+ state_path.write_text(json.dumps({"status": "incomplete", "formNumber": form_number,
2759
+ "formId": form_id, "reason": "Local persistence pending; platform mutation must not be replayed"}), encoding="utf-8")
2760
+ compile_report_default_sorts(artifact)
2761
+ contract = (compile_report_plugin_contract(artifact, form_number, filter_editors)
2762
+ if not artifact.get("queryDataSource") or artifact.get("reportPlugins") else None)
2763
+ contract_path = state_path.with_name(f"{form_number}.report-plugin-contract.json")
2764
+ for destination, value in ((artifact_path, artifact),
2765
+ *(((contract_path, contract),) if contract else ()),
2766
+ (state_path, {"status": "current", "formNumber": form_number, "formId": form_id,
2767
+ "artifactPath": str(artifact_path), "artifact": artifact,
2768
+ "observation": _report_observation(schema), "filterEditors": filter_editors,
2769
+ "evidenceScope": "Authoring requirements and observable design state only; runtime unverified"})):
2770
+ temporary = Path(str(destination) + "." + uuid.uuid4().hex + ".tmp")
2771
+ try:
2772
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
2773
+ os.replace(temporary, destination)
2774
+ finally:
2775
+ temporary.unlink(missing_ok=True)
2776
+ return {"status": "current", "path": str(state_path), "artifactPath": str(artifact_path),
2777
+ "contractPath": str(contract_path) if contract else "",
2778
+ "runtimeVerified": False}
2779
+
2780
+
2781
+ def sync_report_authoring_state(app_root, form_number, form_id, section, expected, before, after, verification, filter_editors=None):
2782
+ """增量写入后刷新契约;缺基线、回读失败或设计器漂移均明确失效而非猜测。"""
2783
+ import copy
2784
+ path = report_authoring_path(app_root, form_number)
2785
+ try:
2786
+ previous = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
2787
+ except (ValueError, UnicodeError):
2788
+ previous = {}
2789
+ reason = ("Unsupported mutation requires full artifact reconciliation" if section not in ("columnsAppend", "columnsRemove", "columns", "filters", "sorts", "plugin") else
2790
+ "Independent read-back did not verify the write" if not verification.get("verified") else
2791
+ "No matching complete authoring baseline; reconcile the full artifact against current getReportSchema"
2792
+ if not isinstance(previous, dict) or previous.get("status") != "current" or previous.get("formId") != form_id or previous.get("formNumber") != form_number else
2793
+ "Observable report state changed outside the authoring baseline; reconcile before coding"
2794
+ if previous.get("observation") != _report_observation(before) else "")
2795
+ if reason:
2796
+ path.parent.mkdir(parents=True, exist_ok=True)
2797
+ path.write_text(json.dumps({"status": "incomplete", "formNumber": form_number, "formId": form_id,
2798
+ "reason": reason, "platformMutationApplied": True}), encoding="utf-8")
2799
+ return {"status": "incomplete", "path": str(path), "reason": reason, "platformMutationApplied": True}
2800
+ path.write_text(json.dumps({"status": "incomplete", "formNumber": form_number, "formId": form_id,
2801
+ "reason": "Refreshing authoring state after platform mutation", "platformMutationApplied": True}), encoding="utf-8")
2802
+ artifact = copy.deepcopy(previous["artifact"])
2803
+ if section == "columnsAppend":
2804
+ def nodes(rows):
2805
+ return {item["fieldKey"]: item for row in rows for item in [row, *nodes(row.get("children") or []).values()]}
2806
+ old = nodes(artifact.get("reportColumns") or [])
2807
+ requested = nodes(expected["requested"])
2808
+ def rebuild(rows):
2809
+ result = []
2810
+ for row in rows:
2811
+ key = row["fieldKey"]
2812
+ if key not in old and key not in requested:
2813
+ raise ValueError("REPORT_AUTHORING_STATE_INCOMPLETE: observed an unrequested column")
2814
+ column = copy.deepcopy(old.get(key, requested.get(key)))
2815
+ if column.get("kind") == "group":
2816
+ column["children"] = rebuild(row.get("children") or [])
2817
+ result.append(column)
2818
+ return result
2819
+ artifact["reportColumns"] = rebuild(after.get("reportColumns") or [])
2820
+ if section == "columnsRemove":
2821
+ artifact["reportColumns"] = _without_report_column(artifact.get("reportColumns") or [], expected["fieldKey"])
2822
+ try:
2823
+ normalize_report_columns_input(artifact["reportColumns"])
2824
+ except ValueError:
2825
+ return invalidate_report_authoring_state(app_root, form_number,
2826
+ "Remaining columns are empty or contain an empty group; reconcile the complete artifact before coding")
2827
+ if "reportSorts" in artifact:
2828
+ artifact["reportSorts"] = [row for row in artifact["reportSorts"] if row.get("fieldKey") != expected["fieldKey"]]
2829
+ if not artifact["reportSorts"]:
2830
+ artifact.pop("reportSorts")
2831
+ properties = (artifact.get("reportProperties") or {}).get("columns")
2832
+ if isinstance(properties, dict):
2833
+ properties.pop(expected["fieldKey"], None)
2834
+ if not properties:
2835
+ artifact["reportProperties"].pop("columns")
2836
+ if section in ("columns", "filters", "sorts"):
2837
+ artifact[{"columns": "reportColumns", "filters": "reportFilters", "sorts": "reportSorts"}[section]] = expected
2838
+ if section == "plugin":
2839
+ # 注册槽位可能替换旧类,不能把回读中不存在的旧类保留在新契约。
2840
+ previous_plugins = {row["className"]: row for row in artifact.get("reportPlugins") or []}
2841
+ artifact["reportPlugins"] = [expected if row.get("className") == expected.get("className") else
2842
+ previous_plugins.get(row.get("className"), row) for row in after.get("reportPlugins") or []]
2843
+ return write_report_authoring_state(app_root, form_number, form_id, artifact, after,
2844
+ filter_editors if section == "filters" else previous.get("filterEditors"))
2845
+
2846
+
2847
+ def invalidate_report_authoring_state(app_root, form_number, reason, mutation_applied=True):
2848
+ """未合并或结果未知的写入使旧需求失效;未知不能冒充已成功。"""
2849
+ path = report_authoring_path(app_root, form_number)
2850
+ evidence = ({"platformMutationApplied": True} if mutation_applied is True else
2851
+ {"platformMutationPossible": True})
2852
+ if not path.is_file():
2853
+ return {"status": "incomplete", "reason": "No local authoring baseline", **evidence, "runtimeVerified": False}
2854
+ path.write_text(json.dumps({"status": "incomplete", "formNumber": form_number,
2855
+ "reason": reason, **evidence}), encoding="utf-8")
2856
+ return {"status": "incomplete", "path": str(path), "reason": reason,
2857
+ **evidence, "runtimeVerified": False}
2858
+
2859
+
1815
2860
  def _subset_mismatches(expected, actual, path=""):
1816
2861
  """Compare every requested value while allowing extra read-back properties."""
1817
2862
  mismatches = []
@@ -1880,6 +2925,7 @@ def report_readback_verification(section, expected, schema):
1880
2925
  # Verify the pre-write tree and requested delta separately: comparing either against the
1881
2926
  # complete observed tree would mistake the other part for a mismatch.
1882
2927
  "columnsAppend": "reportColumns",
2928
+ "columnsRemove": "reportColumns",
1883
2929
  "filters": "reportFilters",
1884
2930
  "sorts": "reportSorts",
1885
2931
  "plugin": "reportPlugins",
@@ -1899,13 +2945,19 @@ def report_readback_verification(section, expected, schema):
1899
2945
  }],
1900
2946
  }
1901
2947
 
1902
- source = schema.get("readBackSource") or "canonical"
2948
+ source = (schema.get("readBackSources") or {}).get(key) or schema.get("readBackSource") or "canonical"
1903
2949
  derived = source != "canonical"
1904
2950
  result = {"section": key, "source": source}
2951
+ if key == "reportFilters" and "reportFilterProjection" in schema:
2952
+ result["projectionDiagnostics"] = schema["reportFilterProjection"]
1905
2953
 
1906
- if derived and key in (schema.get("unverifiableSections") or []):
1907
- # The derived source does not carry this section at all. Say so instead of
1908
- # comparing against an empty array and calling it a pass.
2954
+ if section == "sorts":
2955
+ return {**result, "verified": False, "errorCode": "REPORT_SORTS_API_UNSAFE",
2956
+ "mismatches": [{"path": "/reportSorts", "expected": "runtime ordering evidence",
2957
+ "actual": "not carried by a verified native sorting contract; InitField is visibility"}]}
2958
+
2959
+ if key in (schema.get("unverifiableSections") or []):
2960
+ # 任一回读来源缺少该分区时,都不能将归一化空数组作为验证通过的证据。
1909
2961
  result["verified"] = False
1910
2962
  result["mismatches"] = [{
1911
2963
  "path": f"/{key}",
@@ -1939,6 +2991,27 @@ def report_readback_verification(section, expected, schema):
1939
2991
  mismatches = _subset_mismatches(wanted, observed, f"/{key}")
1940
2992
  if derived:
1941
2993
  result["unverifiedProperties"] = list(schema.get("undecidableColumnProperties") or [])
2994
+ elif section == "columnsRemove":
2995
+ before = expected["before"]
2996
+ field_key = expected["fieldKey"]
2997
+ # 同级 index 会重排;比较树顺序和其余属性,不将 index 当成稳定身份。
2998
+ def comparable(rows):
2999
+ return [{**{name: value for name, value in row.items() if name not in ("index", "children")},
3000
+ **({"children": comparable(row["children"])} if row.get("kind") == "group" else {})}
3001
+ for row in rows]
3002
+ wanted = comparable(_without_report_column(before["reportColumns"], field_key))
3003
+ mismatches = _subset_mismatches(wanted, comparable(observed), "/reportColumns")
3004
+ unverified = ["entityFieldCleanup"]
3005
+ for name in ("reportSorts", "reportFilters", "reportPlugins"):
3006
+ if name in before and name in schema and name not in (before.get("unverifiableSections") or []) and name not in (schema.get("unverifiableSections") or []):
3007
+ values = ([row for row in before[name] if row.get("fieldKey") != field_key]
3008
+ if name == "reportSorts" else before[name])
3009
+ mismatches.extend(_subset_mismatches(values, schema[name], f"/{name}"))
3010
+ else:
3011
+ unverified.append(name)
3012
+ result["unverifiedProperties"] = sorted(set(unverified + (schema.get("undecidableColumnProperties") or [])))
3013
+ result["entityFieldCleanup"] = {"verified": False,
3014
+ "reason": "Compact report schema does not expose the field-id reference graph; orphan cleanup is backend receipt evidence only"}
1942
3015
  elif section == "columnsAppend":
1943
3016
  before = expected.get("before") or []
1944
3017
  requested = expected.get("requested") or []
@@ -2040,8 +3113,19 @@ def report_readback_verification(section, expected, schema):
2040
3113
  elif section == "filters":
2041
3114
  if derived:
2042
3115
  wanted = _expected_filter_structure(expected)
3116
+ projection = source == "reportFilterProjection"
3117
+ if projection and isinstance(observed, list):
3118
+ # 未输出名称是缺证据,不是空名称;仍按有序注册键和实际控件核对结构。
3119
+ wanted = [{name: value for name, value in item.items()
3120
+ if name != "displayName" or (index < len(observed) and "displayName" in observed[index])}
3121
+ for index, item in enumerate(wanted)]
2043
3122
  undecidable = set(schema.get("undecidableFilterProperties") or [])
2044
3123
  mismatches = _subset_mismatches(wanted, observed, f"/{key}")
3124
+ if projection:
3125
+ for index, row in enumerate(observed or []):
3126
+ if row.get("controlFound") is not True or row.get("duplicateControlCount") != 1:
3127
+ mismatches.append({"path": f"/{key}/{index}/control",
3128
+ "expected": "one resolved control", "actual": row})
2045
3129
  observed_by_key = {
2046
3130
  str(row.get("fieldPath") or ""): row for row in (observed or []) if isinstance(row, dict)
2047
3131
  }
@@ -2053,10 +3137,12 @@ def report_readback_verification(section, expected, schema):
2053
3137
  unpersisted = set()
2054
3138
  for index, item in enumerate(expected):
2055
3139
  row = observed_by_key.get(str(item.get("fieldPath") or ""), {})
3140
+ if projection and "displayName" not in row:
3141
+ unverifiable_requested.add("displayName")
2056
3142
  for name in _FILTER_UNPERSISTED_KEYS:
2057
- if not _requests_unpersisted(item, name):
3143
+ if not (name in item if projection else _requests_unpersisted(item, name)):
2058
3144
  continue
2059
- if name in undecidable or name not in row:
3145
+ if name in undecidable or name not in row or row[name] is None:
2060
3146
  unverifiable_requested.add(name)
2061
3147
  continue
2062
3148
  if row[name] != item[name]:
@@ -2072,18 +3158,14 @@ def report_readback_verification(section, expected, schema):
2072
3158
  result["unverifiableRequestedProperties"] = sorted(unverifiable_requested)
2073
3159
  result["unverifiedProperties"] = sorted(undecidable)
2074
3160
  else:
2075
- mismatches = _subset_mismatches(_build_filters_payload([{k: v for k, v in item.items() if k != "source"} for item in expected]), observed, f"/{key}")
2076
- # Structure-only receipts cannot prove source-derived editor configuration.
2077
- by_key = {row.get("fieldPath"): row for row in observed or []}
2078
- for index, item in enumerate(expected):
2079
- if not item.get("source"):
2080
- continue
2081
- actual = by_key.get(item["fieldPath"], {})
2082
- wanted = (schema.get("filterSourceExpectations") or {}).get(item["fieldPath"])
2083
- if (not schema.get("typedFiltersReadBack") or not wanted
2084
- or any(actual.get(name) != value for name, value in wanted.items())):
2085
- mismatches.append({"path": f"/{key}/{index}/source", "expected": item["source"],
2086
- "actual": actual.get("fieldType"), "reason": "typed-editor-source-not-proven"})
3161
+ # The canonical projection is compared as written, minus the editor parameters: they
3162
+ # configure the field the platform builds, and the receipt and fieldType prove it.
3163
+ mismatches = _subset_mismatches([
3164
+ {name: value for name, value in item.items() if name not in _REPORT_FILTER_TYPE_PARAMS}
3165
+ for item in _build_filters_payload(expected)
3166
+ ], observed, f"/{key}")
3167
+ if any(item.get("fieldType") for item in expected):
3168
+ mismatches.extend(_verify_filter_editors(expected, observed, schema, result))
2087
3169
  else:
2088
3170
  mismatches = _subset_mismatches(expected, observed, f"/{key}")
2089
3171
 
@@ -2092,6 +3174,57 @@ def report_readback_verification(section, expected, schema):
2092
3174
  return result
2093
3175
 
2094
3176
 
3177
+ def _verify_filter_editors(expected, observed, schema, result):
3178
+ """Hold every typed filter to the editor the platform actually rendered.
3179
+
3180
+ The write's own outcome (attached as filterEditorOutcome) says what the receipt showed; the
3181
+ read-back settles the rest. A degraded editor is not a mismatch — a platform without the
3182
+ typed filter patch renders the legacy control, and that is reported, not failed. An editor
3183
+ that is neither the requested one nor the legacy one is a mismatch.
3184
+ """
3185
+ outcome = schema.get("filterEditorOutcome") or build_filter_editor_outcome(expected)
3186
+ editors, mismatches = resolve_filter_editors(outcome, observed)
3187
+ result["filterEditors"] = editors
3188
+ unverifiable = set(result.get("unverifiableRequestedProperties") or [])
3189
+ if editors.get("unobservedFilters"):
3190
+ unverifiable.add("fieldType")
3191
+ observed_by_key = {
3192
+ str(row.get("fieldPath") or ""): row for row in (observed or []) if isinstance(row, dict)
3193
+ }
3194
+ status_by_key = {row["fieldPath"]: row["status"] for row in editors["editors"]}
3195
+ for index, item in enumerate(expected):
3196
+ number = item.get("basedataNumber")
3197
+ if not number or status_by_key.get(item["fieldPath"]) != "typed":
3198
+ continue
3199
+ # 旧 schemaText 不输出多选引用;新投影通过实际 baseEntityNumber 提供该证据。
3200
+ reference = (observed_by_key.get(item["fieldPath"]) or {}).get("referenceNumber")
3201
+ if not reference:
3202
+ unverifiable.add("basedataNumber")
3203
+ elif str(reference).strip().lower() != number.strip().lower():
3204
+ mismatches.append({
3205
+ "path": f"/reportFilters/{index}/basedataNumber",
3206
+ "expected": number,
3207
+ "actual": reference,
3208
+ })
3209
+ if result.get("source") == "reportFilterProjection":
3210
+ for index, item in enumerate(expected):
3211
+ row = observed_by_key.get(item["fieldPath"], {})
3212
+ if status_by_key.get(item["fieldPath"]) != "typed":
3213
+ continue
3214
+ for name, wanted in (("rangeKeys", _filter_range_keys(item, row.get("fieldType"))),
3215
+ ("comboItems", item.get("comboItems"))):
3216
+ if wanted is None:
3217
+ continue
3218
+ actual = row.get(name)
3219
+ if name in (row.get("unknown") or []) or actual is None:
3220
+ unverifiable.add(name)
3221
+ continue
3222
+ mismatches.extend(_subset_mismatches(wanted, actual, f"/reportFilters/{index}/{name}"))
3223
+ if unverifiable:
3224
+ result["unverifiableRequestedProperties"] = sorted(unverifiable)
3225
+ return mismatches
3226
+
3227
+
2095
3228
  def validate_report_references(section, payload, schema):
2096
3229
  """Validate cross-section references that the read-back projection can decide.
2097
3230
 
@@ -2245,8 +3378,13 @@ def _build_filters_payload(filters):
2245
3378
  "displayName": f.get("displayName", ""),
2246
3379
  "compare": _map_compare_operator(f.get("compare", "=")),
2247
3380
  }
2248
- if f.get("source"):
2249
- item["source"] = f["source"]
3381
+ # The editor part travels only with a declared fieldType: the patch refuses a type
3382
+ # parameter sent without one, and a platform without the patch ignores both.
3383
+ if f.get("fieldType"):
3384
+ item["fieldType"] = f["fieldType"]
3385
+ for name in _REPORT_FILTER_TYPE_PARAMS:
3386
+ if f.get(name) is not None:
3387
+ item[name] = f[name]
2250
3388
  # Only forward keys the normalized contract accepts. The previous version also
2251
3389
  # emitted required / enableFuzzy / basedataFilter, which normalize_report_filters_input
2252
3390
  # already rejects — dead branches that could only reintroduce silent drift.
@@ -2257,18 +3395,6 @@ def _build_filters_payload(filters):
2257
3395
  return result
2258
3396
 
2259
3397
 
2260
- def _build_sorts_payload(sorts):
2261
- """Build report sorts payload for setComplexProperty."""
2262
- result = []
2263
- for s in sorts:
2264
- item = {
2265
- "fieldKey": s.get("fieldKey", ""),
2266
- "direction": s.get("direction", "asc"),
2267
- }
2268
- result.append(item)
2269
- return result
2270
-
2271
-
2272
3398
  def _map_compare_operator(compare_str):
2273
3399
  """Map semantic compare operators to platform numeric codes.
2274
3400
 
@@ -2310,10 +3436,11 @@ def _check_result(result, operation_name, form_mutation_possible=False):
2310
3436
  )
2311
3437
 
2312
3438
  def fail(message):
2313
- raise RuntimeError(f"{operation_name} failed: {message}{suffix}")
3439
+ error = ReportMutationError if form_mutation_possible else RuntimeError
3440
+ raise error(f"{operation_name} failed: {message}{suffix}")
2314
3441
 
2315
3442
  if not isinstance(result, dict):
2316
- raise RuntimeError(f"{operation_name}: unexpected response type {type(result)}")
3443
+ fail(f"unexpected response type {type(result)}")
2317
3444
  # Check common error patterns; errorCode may be string "0" for success
2318
3445
  _err = result.get("errorCode")
2319
3446
  if _err is not None and str(_err) not in ("", "0"):
@@ -2423,9 +3550,9 @@ _FILTER_PANEL_PROPERTIES = {
2423
3550
  "DirectExportExcel": _prop(_KIND_BOOL, _READ_BACK_GET_PROPERTIES),
2424
3551
  # Input is a list of filter keys. It is serialised to a JSON ARRAY string, which is the
2425
3552
  # form the runtime parses (ReportFilter.getShowFilterFieldList casts it with
2426
- # JSONUtils.cast(..., ArrayList.class, String.class)). setReportFilters writes the same
2427
- # property as a COMMA-JOINED string instead, which that cast cannot read — see
2428
- # _SHOW_FILTER_FIELDS_HAZARD.
3553
+ # JSONUtils.cast(..., ArrayList.class, String.class)). A platform without the typed report
3554
+ # filter patch has setReportFilters write the same property as a COMMA-JOINED string
3555
+ # instead, which that cast cannot read — see _SHOW_FILTER_FIELDS_HAZARD.
2429
3556
  "ShowFilterFields": _prop(_KIND_IDENTIFIER_LIST, _READ_BACK_GET_PROPERTIES),
2430
3557
  }
2431
3558
 
@@ -2535,7 +3662,8 @@ _REPORT_PROPERTY_REFUSED = {
2535
3662
  "Columns are addReportColumns; a column set is not a scalar property."
2536
3663
  ),
2537
3664
  "InitField": (
2538
- "Default sorting is setReportSorts."
3665
+ "InitField is default column visibility, not sorting. ReportSorts is blocked; "
3666
+ "preserve default sorts in reportContract.defaultSorts for Java query()."
2539
3667
  ),
2540
3668
  "QueryDataSource": (
2541
3669
  "The query data source is setReportQueryDataSource --dataSourceNumber."
@@ -2543,11 +3671,12 @@ _REPORT_PROPERTY_REFUSED = {
2543
3671
  }
2544
3672
 
2545
3673
  _SHOW_FILTER_FIELDS_HAZARD = (
2546
- "setReportFilters also writes ShowFilterFields, as a comma-joined string "
2547
- "(observed live: \"flt_biztime,flt_supplier,flt_billstatus\"). The runtime parses that "
2548
- "property as a JSON array, so the comma form raises KDException when the user updates the "
2549
- "selected conditions. Re-apply this command after any setReportFilters call until the "
2550
- "platform's own join is fixed."
3674
+ "On a platform without the typed report filter patch, setReportFilters also writes "
3675
+ "ShowFilterFields, as a comma-joined string (observed live: "
3676
+ "\"flt_biztime,flt_supplier,flt_billstatus\"). The runtime parses that property as a JSON "
3677
+ "array, so the comma form raises KDException when the user updates the selected "
3678
+ "conditions. Re-apply this command after any setReportFilters call on such a platform; a "
3679
+ "patched platform writes the JSON array itself."
2551
3680
  )
2552
3681
 
2553
3682
  _REPORT_PROPERTY_TARGETS = {
@@ -2582,10 +3711,13 @@ _REPORT_PROPERTY_TARGETS = {
2582
3711
  },
2583
3712
  "tree": {
2584
3713
  "complexType": "ReportTree",
2585
- "elementKey": "reporttreeap",
3714
+ # No public template presets a report tree: a user adds the control in the designer,
3715
+ # under whatever key they give it, so the key is found by element type per call.
3716
+ "elementKey": None,
2586
3717
  "hostType": "ReportTreeAp",
2587
3718
  "properties": _TREE_PROPERTIES,
2588
3719
  "requiresElement": False,
3720
+ "resolveByType": True,
2589
3721
  },
2590
3722
  "column": {
2591
3723
  "complexType": "ReportColumnProps",
@@ -2690,6 +3822,16 @@ def resolve_report_property_target(target, form_number, element_key=None, elemen
2690
3822
  spec["hostType"] = host
2691
3823
  spec["properties"] = _COLUMN_PROPERTIES_BY_HOST[host]
2692
3824
  return spec
3825
+ if spec.get("resolveByType"):
3826
+ # The key is whatever the control was given; set_report_properties finds it by type.
3827
+ if supplied and element_type != spec["hostType"]:
3828
+ _report_property_error(
3829
+ f"--element {supplied!r} is a {element_type or 'unknown'} element; target "
3830
+ f"{name!r} writes a {spec['hostType']}"
3831
+ )
3832
+ spec["elementKey"] = supplied or None
3833
+ spec["target"] = name
3834
+ return spec
2693
3835
  if supplied and supplied != (spec["elementKey"] or form_number):
2694
3836
  _report_property_error(
2695
3837
  f"target {name!r} writes {spec['elementKey'] or 'the form root'} and takes no "
@@ -2739,18 +3881,23 @@ def _closest_property_name(given, allowed):
2739
3881
  return None
2740
3882
 
2741
3883
 
3884
+ def _report_controls(cfg, token, form_number):
3885
+ """The form's control rows from its own element list."""
3886
+ result = _api_call(cfg, token, "listElements",
3887
+ {"formNumber": form_number, "category": "control"}, method="POST")
3888
+ _check_result(result, "listElements")
3889
+ rows = result.get("data") if isinstance(result, dict) else None
3890
+ return [row for row in (rows if isinstance(rows, list) else []) if isinstance(row, dict)]
3891
+
3892
+
2742
3893
  def resolve_report_element_type(cfg, token, form_number, element_key):
2743
3894
  """The element type of one exact key, from the form's own element list.
2744
3895
 
2745
3896
  Raises:
2746
3897
  ValueError: the key is not an element of this form.
2747
3898
  """
2748
- result = _api_call(cfg, token, "listElements",
2749
- {"formNumber": form_number, "category": "control"}, method="POST")
2750
- _check_result(result, "listElements")
2751
- rows = result.get("data") if isinstance(result, dict) else None
2752
- for row in rows if isinstance(rows, list) else []:
2753
- if isinstance(row, dict) and str(row.get("key") or "") == element_key:
3899
+ for row in _report_controls(cfg, token, form_number):
3900
+ if str(row.get("key") or "") == element_key:
2754
3901
  return str(row.get("type") or "")
2755
3902
  _report_property_error(
2756
3903
  f"--element {element_key!r} is not a control of {form_number!r}; a report column key "
@@ -2758,6 +3905,39 @@ def resolve_report_element_type(cfg, token, form_number, element_key):
2758
3905
  )
2759
3906
 
2760
3907
 
3908
+ def resolve_report_element_by_type(cfg, token, form_number, host_type, element_key=None):
3909
+ """The key of the form's one control of `host_type`, or of the named one after checking it.
3910
+
3911
+ A user-added control carries whatever key it was given, so a fixed key would refuse a tree
3912
+ that is there and its read-back would prove nothing.
3913
+
3914
+ Raises:
3915
+ ValueError: REPORT_PROPERTY_ELEMENT_MISMATCH when the form has no such control;
3916
+ REPORT_PROPERTY_INPUT_INVALID when several exist and none is named, or the named
3917
+ key is not one of them.
3918
+ """
3919
+ keys = [str(row.get("key")) for row in _report_controls(cfg, token, form_number)
3920
+ if str(row.get("type") or "") == host_type and row.get("key")]
3921
+ supplied = str(element_key or "").strip()
3922
+ if supplied:
3923
+ if supplied not in keys:
3924
+ _report_property_error(
3925
+ f"--element {supplied!r} is not a {host_type} of {form_number!r}; its "
3926
+ f"{host_type} controls are {keys}"
3927
+ )
3928
+ return supplied
3929
+ if not keys:
3930
+ raise ValueError(
3931
+ f"REPORT_PROPERTY_ELEMENT_MISMATCH: {form_number!r} has no {host_type} control, so "
3932
+ "there is nothing to write to. No public report template presets one; a user adds "
3933
+ "it in the designer first."
3934
+ )
3935
+ if len(keys) > 1:
3936
+ _report_property_error(
3937
+ f"{form_number!r} has several {host_type} controls {keys}; name one with --element")
3938
+ return keys[0]
3939
+
3940
+
2761
3941
  def _read_back_request_name(name):
2762
3942
  """The name to ASK getProperties for, which is not always the name written."""
2763
3943
  return _READ_BACK_KEY_OVERRIDES.get(name, name)
@@ -2791,9 +3971,9 @@ def read_report_element_properties(cfg, token, form_number, element_key, names):
2791
3971
  def _identifier_list_value(value):
2792
3972
  """The key list behind either persisted form of ShowFilterFields, or None.
2793
3973
 
2794
- The runtime expects a JSON array. setReportFilters writes a comma-joined string instead,
2795
- so both have to be readable or a verification would fail on a report the platform itself
2796
- wrote.
3974
+ The runtime expects a JSON array. A platform without the typed report filter patch has
3975
+ setReportFilters write a comma-joined string instead, so both have to be readable or a
3976
+ verification would fail on a report the platform itself wrote.
2797
3977
  """
2798
3978
  if isinstance(value, list):
2799
3979
  return [str(item) for item in value]
@@ -2838,12 +4018,17 @@ def set_report_properties(cfg, token, form_number, target, data,
2838
4018
  _report_property_error("formNumber is required")
2839
4019
 
2840
4020
  element_type = None
2841
- if _REPORT_PROPERTY_TARGETS.get(str(target or "").strip(), {}).get("requiresElement"):
4021
+ target_spec = _REPORT_PROPERTY_TARGETS.get(str(target or "").strip(), {})
4022
+ if target_spec.get("requiresElement"):
2842
4023
  if not str(element_key or "").strip():
2843
4024
  _report_property_error(
2844
4025
  "target 'column' needs --element, the exact report column or column group key")
2845
4026
  element_type = resolve_report_element_type(
2846
4027
  cfg, token, form_number, str(element_key).strip())
4028
+ elif target_spec.get("resolveByType"):
4029
+ element_key = resolve_report_element_by_type(
4030
+ cfg, token, form_number, target_spec["hostType"], element_key)
4031
+ element_type = target_spec["hostType"]
2847
4032
  spec = resolve_report_property_target(target, form_number, element_key, element_type)
2848
4033
  payload = normalize_report_properties_input(spec, data)
2849
4034
 
@@ -2873,10 +4058,11 @@ def set_report_properties(cfg, token, form_number, target, data,
2873
4058
 
2874
4059
  _check_result(
2875
4060
  _set_complex(cfg, token, form_number, spec["complexType"], payload,
2876
- element_key=spec["elementKey"] if spec["requiresElement"] else None),
4061
+ element_key=spec["elementKey"]
4062
+ if spec["requiresElement"] or spec.get("resolveByType") else None),
2877
4063
  f"setReportProperties[{spec.get('target') or 'column'}]", form_mutation_possible=True)
2878
4064
 
2879
- after = read_report_element_properties(
4065
+ after = _read_after_report_write(read_report_element_properties,
2880
4066
  cfg, token, form_number, spec["elementKey"], readable) if readable else {}
2881
4067
 
2882
4068
  mismatches, indistinguishable, unverifiable = [], [], []
@@ -2901,9 +4087,10 @@ def set_report_properties(cfg, token, form_number, target, data,
2901
4087
  indistinguishable.append(name)
2902
4088
 
2903
4089
  if mismatches:
2904
- raise RuntimeError(
4090
+ raise ReportMutationError(
2905
4091
  f"REPORT_PROPERTY_MISMATCH: {form_number!r} reads back {mismatches} after a "
2906
- "write the platform reported as successful; re-read the report before retrying."
4092
+ "write the platform reported as successful; re-read the report before retrying.",
4093
+ mutation_applied=True
2907
4094
  )
2908
4095
 
2909
4096
  result = {
@@ -3034,7 +4221,7 @@ def set_report_filter_field_config(cfg, token, form_number, fields, dry_run=Fals
3034
4221
  _check_result(
3035
4222
  _set_complex(cfg, token, form_number, "ReportFilterFieldConfig", payload),
3036
4223
  "setReportFilterFieldConfig", form_mutation_possible=True)
3037
- observed = read_report_element_properties(
4224
+ observed = _read_after_report_write(read_report_element_properties,
3038
4225
  cfg, token, form_number, "reportfilterap", ["ReportFilterFieldConfig"])
3039
4226
  stored = observed.get("ReportFilterFieldConfig")
3040
4227
  # This one projects as a native object rather than as a JSON string, unlike
@@ -3045,9 +4232,10 @@ def set_report_filter_field_config(cfg, token, form_number, fields, dry_run=Fals
3045
4232
  actual_keys = [str(row.get("key") or row.get("Key") or "")
3046
4233
  for row in rows if isinstance(row, dict)]
3047
4234
  if actual_keys and actual_keys != expected_keys:
3048
- raise RuntimeError(
4235
+ raise ReportMutationError(
3049
4236
  f"REPORT_FILTER_FIELD_CONFIG_MISMATCH: {form_number!r} reads back {actual_keys} "
3050
- f"after a write of {expected_keys}; re-read the report before retrying."
4237
+ f"after a write of {expected_keys}; re-read the report before retrying.",
4238
+ mutation_applied=True
3051
4239
  )
3052
4240
  return {
3053
4241
  "formNumber": form_number,
@@ -3107,7 +4295,7 @@ def set_report_default_hidden_fields(cfg, token, form_number, fields, dry_run=Fa
3107
4295
  _check_result(
3108
4296
  _set_complex(cfg, token, form_number, "ReportDefaultHiddenFields", payload),
3109
4297
  "setReportDefaultHiddenFields", form_mutation_possible=True)
3110
- observed = read_report_element_properties(
4298
+ observed = _read_after_report_write(read_report_element_properties,
3111
4299
  cfg, token, form_number, "reportfilterap", ["DefaultHiddenFields"])
3112
4300
  rows = _json_rows(observed.get("DefaultHiddenFields"))
3113
4301
  expected_keys = [entry["fieldKey"] for entry in stored_fields]
@@ -3117,9 +4305,10 @@ def set_report_default_hidden_fields(cfg, token, form_number, fields, dry_run=Fa
3117
4305
  # and both were measured preserving the authored order, so comparing sorted keys would
3118
4306
  # silently accept a reordering the caller did not ask for.
3119
4307
  if actual_keys and actual_keys != expected_keys:
3120
- raise RuntimeError(
4308
+ raise ReportMutationError(
3121
4309
  f"REPORT_DEFAULT_HIDDEN_FIELDS_MISMATCH: {form_number!r} reads back {actual_keys} "
3122
- f"after a write of {expected_keys}; re-read the report before retrying."
4310
+ f"after a write of {expected_keys}; re-read the report before retrying.",
4311
+ mutation_applied=True
3123
4312
  )
3124
4313
  result = {
3125
4314
  "formNumber": form_number,
@@ -3200,11 +4389,12 @@ def register_report_plugin_ext(cfg, token, form_number, plugin_spec, dry_run=Fal
3200
4389
  _set_complex(cfg, token, form_number, "ReportPluginExt", payload),
3201
4390
  "registerReportPluginExt", form_mutation_possible=True)
3202
4391
  observed = [_plugin_class_name(item)
3203
- for item in _report_plugin_ext_entries(cfg, token, form_number)]
4392
+ for item in _read_after_report_write(_report_plugin_ext_entries, cfg, token, form_number)]
3204
4393
  if not any(name.casefold() == class_name.casefold() for name in observed):
3205
- raise RuntimeError(
4394
+ raise ReportMutationError(
3206
4395
  f"REPORT_PLUGIN_EXT_MISMATCH: {form_number!r} does not read back {class_name!r} "
3207
- f"after a write the platform reported as successful; it carries {observed}."
4396
+ f"after a write the platform reported as successful; it carries {observed}.",
4397
+ mutation_applied=True
3208
4398
  )
3209
4399
  return {
3210
4400
  "formNumber": form_number,
@@ -3363,7 +4553,8 @@ def attach_report_property_design_state(result, dym_path, host_type, expected):
3363
4553
  # Ordering matters and is fixed by the pipeline, not by this function: the property section is
3364
4554
  # applied LAST, after setReportFilters. That is what makes a single run leave ShowFilterFields
3365
4555
  # in the JSON-array form the runtime parses rather than the comma-joined form the filter write
3366
- # produces.
4556
+ # produces on a platform without the typed report filter patch, and what lets a declared
4557
+ # FilterMustInput or initial field list outlast the patched filter write, which resets both.
3367
4558
 
3368
4559
  _ARTIFACT_SCALAR_TARGETS = ("list", "filterPanel", "moreFilterPanel", "form", "tree")
3369
4560
  _ARTIFACT_STRUCTURED_KEYS = ("rowBackColor", "filterFieldConfig", "defaultHiddenFields",