@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.
- package/package.json +1 -1
- package/skills/cosmic-build/LOCAL-PATCHES.md +43 -14
- package/skills/cosmic-build/SKILL.md +24 -9
- package/skills/cosmic-build/examples/java-rules/CosmicReportAggregation.java +26 -17
- package/skills/cosmic-build/examples/java-rules/CosmicReportFilters.java +96 -2
- package/skills/cosmic-build/knowledge/cosmic/arch/security/security_standard.md +4 -4
- package/skills/cosmic-build/knowledge/cosmic/platform/allimport.md +2 -1
- package/skills/cosmic-build/knowledge/cosmic/platform/core/dynamic-object-collection.md +6 -4
- package/skills/cosmic-build/knowledge/cosmic/platform/core/model.md +9 -9
- package/skills/cosmic-build/knowledge/cosmic/platform/core/view.md +5 -5
- package/skills/cosmic-build/knowledge/cosmic/platform/form-plugin.md +8 -4
- package/skills/cosmic-build/knowledge/cosmic/platform/list-plugin.md +5 -3
- package/skills/cosmic-build/knowledge/cosmic/platform/print-plugin-guide.md +1 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/report-plugin.md +269 -57
- package/skills/cosmic-build/knowledge/cosmic/platform/reportlistdata-plugin.md +130 -23
- package/skills/cosmic-build/knowledge/cosmic/platform/reporttreedata-plugin.md +48 -28
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/_index.md +12 -3
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/common/attachment-name-process.md +17 -13
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/common/dynamic-update-field.md +4 -4
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/filter-grid/filter-grid-set-value.md +1 -1
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/filter-basedatafield.md +2 -3
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/filter-container-init.md +0 -1
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/filter-popup.md +0 -1
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/filter-value-parse.md +5 -6
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/list-show-form.md +1 -1
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/control/list/query-field-dynamic.md +0 -7
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/data-access/business-data/delete-service.md +24 -29
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/data-access/ksql/db-batch.md +4 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/data-access/ksql/db-direct-access.md +4 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/data-access/qfilter/entity-object-access.md +8 -3
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/data-access/qfilter/qfilter-compose.md +6 -5
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/event/after-bind-data-set-visible.md +5 -1
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/event/click-return-data.md +2 -2
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/event/closed-call-back-set-value.md +2 -2
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/event/register-listener.md +1 -1
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/event/set-filter-tab-org.md +3 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/dispatch-service/microservice-dev.md +18 -11
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/dist-lock/dist-lock-basic.md +3 -6
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/mq/mq-subscribe.md +5 -1
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/thread-pool/async-operation.md +10 -6
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/thread-pool/big-task-async.md +4 -2
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/infra/thread-pool/schedule-task.md +9 -7
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/operation/operation-interactive.md +33 -34
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/operation/operation-plugin-events.md +30 -22
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-async-query.md +133 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-batch-query.md +26 -22
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-chart.md +112 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-dataset-assembly.md +200 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-head-filter.md +177 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-hyperlink-drill.md +216 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-list-data-query.md +12 -12
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-merge-column.md +44 -6
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-print-data.md +163 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-row-data-toolbar.md +188 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-subtotal-total.md +139 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-tree-data-query.md +108 -12
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/report/report-tree-list.md +181 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/system-service/report-plugin-interface.md +3 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/tree/tree-basedata-cascade-confirm.md +14 -11
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/tree/tree-list-before-item-validate.md +1 -1
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/tree/tree-list-build-filter.md +1 -1
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/tree/tree-list-multi-level-filter.md +11 -11
- package/skills/cosmic-build/knowledge/cosmic/platform/scenes/tree/tree-list-refresh-node.md +22 -15
- package/skills/cosmic-build/knowledge/cosmic/platform/sdk/sdk-algo.md +2 -2
- package/skills/cosmic-build/knowledge/cosmic/platform/sdk/sdk-entity-model.md +3 -4
- package/skills/cosmic-build/knowledge/cosmic/platform/sdk/sdk-exception.md +9 -5
- package/skills/cosmic-build/knowledge/cosmic/platform/sdk/sdk-orm-access.md +4 -0
- package/skills/cosmic-build/knowledge/cosmic/platform/sdk/sdk-request-context.md +7 -3
- package/skills/cosmic-build/knowledge/cosmic/platform/standards.md +28 -3
- package/skills/cosmic-build/references/compile-guide.md +36 -1
- package/skills/cosmic-build/references/dcs-code-rules.md +180 -0
- package/skills/cosmic-build/references/delivery.md +16 -2
- package/skills/cosmic-build/references/execution-contract.md +15 -3
- package/skills/cosmic-build/references/java-plugin-delivery.md +21 -11
- package/skills/cosmic-build/references/plugin-handoff-input.md +82 -24
- package/skills/cosmic-build/references/plugin-runtime-routing.md +23 -6
- package/skills/cosmic-build/references/report-filter-contract.md +53 -15
- package/skills/cosmic-build/references/sdk-inspection.md +4 -1
- package/skills/cosmic-build/references/type-registry.json +26 -17
- package/skills/cosmic-build/schemas/data-model-authoring.schema.json +1 -1
- package/skills/cosmic-build/scripts/_http_client.py +3 -0
- package/skills/cosmic-build/scripts/compile_plugin_source.py +251 -42
- package/skills/cosmic-build/scripts/configure_list.py +112 -0
- package/skills/cosmic-build/scripts/dcs-rule-catalog.json +799 -0
- package/skills/cosmic-build/scripts/dcs_code_gate.py +1976 -0
- package/skills/cosmic-build/scripts/execution_binding.py +25 -0
- package/skills/cosmic-build/scripts/platform_api_contract.py +352 -7
- package/skills/cosmic-build/scripts/plugin_source_gate.py +9 -1
- package/skills/cosmic-build/scripts/plugin_target_sdk.py +12 -8
- package/skills/cosmic-build/scripts/prepare_plugin_handoff.py +488 -88
- package/skills/cosmic-build/scripts/query_plugin_handoff.py +207 -20
- package/skills/cosmic-build/scripts/register_plugins.py +27 -10
- package/skills/cosmic-build/scripts/render_output_cards.py +57 -20
- package/skills/cosmic-build/scripts/resolve_output_path.py +60 -26
- package/skills/cosmic-build/scripts/run_java_tests.py +162 -0
- package/skills/cosmic-build/scripts/sync_generated_locale_assets.py +28 -0
- package/skills/cosmic-build/scripts/test/fixtures/ReportSdkCheck.java +1 -22
- package/skills/cosmic-build/scripts/test/fixtures/list_column_property_declarations.json +887 -0
- package/skills/cosmic-build/scripts/test/fixtures/operation-entry-field-prepare.java +78 -0
- package/skills/cosmic-build/scripts/test/test_add_field_required_contract.py +12 -0
- package/skills/cosmic-build/scripts/test/test_batch5_drift_governance.py +23 -2
- package/skills/cosmic-build/scripts/test/test_compile_plugin_source.py +287 -4
- package/skills/cosmic-build/scripts/test/test_configure_list_delivery.py +132 -0
- package/skills/cosmic-build/scripts/test/test_cosmic_sdk_examples.py +58 -1
- package/skills/cosmic-build/scripts/test/test_data_model_contract.py +19 -0
- package/skills/cosmic-build/scripts/test/test_dcs_code_gate.py +901 -0
- package/skills/cosmic-build/scripts/test/test_delivery_stage_boundary.py +1 -1
- package/skills/cosmic-build/scripts/test/test_direct_cli_architecture.py +2 -0
- package/skills/cosmic-build/scripts/test/test_http_client.py +6 -0
- package/skills/cosmic-build/scripts/test/test_java_toolchain_feedback.py +206 -0
- package/skills/cosmic-build/scripts/test/test_kd_api.py +21 -0
- package/skills/cosmic-build/scripts/test/test_list_configuration_closure.py +184 -0
- package/skills/cosmic-build/scripts/test/test_mcp_cli.py +139 -2
- package/skills/cosmic-build/scripts/test/test_menu_creation_closure.py +16 -0
- package/skills/cosmic-build/scripts/test/test_modeling_input_recovery.py +55 -1
- package/skills/cosmic-build/scripts/test/test_open_world_cli_catalog.py +1 -1
- package/skills/cosmic-build/scripts/test/test_operation_field_preparation.py +84 -0
- package/skills/cosmic-build/scripts/test/test_platform_api_contract.py +322 -1
- package/skills/cosmic-build/scripts/test/test_plugin_handoff.py +951 -4
- package/skills/cosmic-build/scripts/test/test_plugin_source_gate.py +30 -2
- package/skills/cosmic-build/scripts/test/test_plugin_target_sdk.py +21 -0
- package/skills/cosmic-build/scripts/test/test_property_reroute.py +165 -0
- package/skills/cosmic-build/scripts/test/test_property_schema_first.py +35 -0
- package/skills/cosmic-build/scripts/test/test_query_plugin_handoff.py +221 -0
- package/skills/cosmic-build/scripts/test/test_register_plugins.py +76 -4
- package/skills/cosmic-build/scripts/test/test_render_output_cards.py +75 -3
- package/skills/cosmic-build/scripts/test/test_report_business_contract.py +27 -5
- package/skills/cosmic-build/scripts/test/test_report_doc_contract_sync.py +103 -11
- package/skills/cosmic-build/scripts/test/test_report_plugin_source_gate.py +46 -16
- package/skills/cosmic-build/scripts/test/test_report_properties.py +74 -15
- package/skills/cosmic-build/scripts/test/test_report_query_data_source.py +3 -1
- package/skills/cosmic-build/scripts/test/test_report_sdk_optimization.py +1 -2
- package/skills/cosmic-build/scripts/test/test_test_environment.py +76 -1
- package/skills/cosmic-build/scripts/test_environment.py +67 -1
- package/skills/cosmic-build/skills/cosmic-coding-impl/SKILL.md +52 -10
- package/skills/cosmic-build/skills/cosmic-meta-api/apis/ai_meta.md +16 -3
- package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/report-write-contracts.json +59 -26
- package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/rule-domain.json +1 -1
- package/skills/cosmic-build/skills/cosmic-meta-api/contracts/schema/report-write.schema.json +75 -18
- package/skills/cosmic-build/skills/cosmic-meta-api/modules/pdm/scripts/pdm_auto.py +39 -0
- package/skills/cosmic-build/skills/cosmic-meta-api/modules/pdm/scripts/pdm_client.py +97 -10
- package/skills/cosmic-build/skills/cosmic-meta-api/modules/verify/SKILL.md +10 -12
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_apps.py +3 -1
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_formactions.py +38 -29
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_listui.py +400 -233
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_metaops.py +87 -45
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_properties.py +42 -15
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_rules.py +135 -5
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/config.py +1 -1
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/execution_errors.py +1 -1
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/formatters.py +17 -11
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/property_reroute.py +15 -5
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/report_ops.py +1429 -238
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/validators.py +56 -28
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/cosmic_api.py +380 -134
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/field_decorator.py +1 -1
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/meta_export.py +12 -11
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/meta_verifier.py +42 -0
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_add_field_placement_closure.py +8 -8
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_build_configuration_readback.py +94 -0
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_build_receipt_resume.py +47 -1
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_metadata_unchanged_fail_closed.py +87 -0
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_dym_evidence.py +23 -23
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_entity_integrity.py +279 -4
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_field_guard.py +1 -1
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_plugin_contract.py +509 -32
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_readback_derivation.py +178 -1
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_recreate_plan.py +125 -10
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_serve_contract.py +208 -0
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_typed_filters.py +259 -50
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_rule_inventory_perf.py +29 -0
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_rule_write_closure.py +20 -0
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_run_batch_post_build_recovery.py +18 -0
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_run_batch_verification_evidence.py +35 -0
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_run_identity_guard.py +25 -0
- package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_time_range_rule.py +98 -0
- package/skills/cosmic-build/skills/cosmic-meta-knowledge/domain-model/report-model.md +20 -9
- package/skills/cosmic-build/skills/cosmic-meta-knowledge/generation/report-modeling-guide.md +55 -25
- package/skills/cosmic-build/skills/cosmic-meta-knowledge/property-guides/report-column-properties.md +15 -39
- package/skills/cosmic-build/skills/cosmic-meta-modeling/SKILL.md +19 -17
- package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/report/SKILL.md +82 -38
- package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/report/examples/golden-example-report-batch.md +64 -19
- package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/report/examples/golden-example-report-plugin.md +32 -20
- package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/report/examples/golden-example-report-tree.md +78 -31
- package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/report/report-artifact-schema.json +2 -1
- package/skills/cosmic-build/skills/cosmic-meta-modeling/references/authoring-companion-fields.md +3 -0
- package/skills/cosmic-build/skills/cosmic-meta-modeling/references/authoring-list-configuration.md +2 -0
- package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/data_model_contract.py +3 -1
- package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/data_model_emit.py +27 -4
- package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/report_validator.py +99 -44
- package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/test_report_examples.py +31 -4
- package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/test_report_validator.py +129 -29
- package/skills/cosmic-build/skills/cosmic-meta-modifier/SKILL.md +6 -2
- package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/operation/validation.md +2 -0
- package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/property/SKILL.md +1 -1
- package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/report-op/SKILL.md +104 -25
- package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/rule/SKILL.md +9 -0
- package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/verify/usage-guide.md +2 -2
- package/skills/cosmic-build/skills/cosmic-meta-modifier/references/list-filter-modification.md +39 -12
- package/skills/cosmic-build/skills/mcp-server/SKILL.md +39 -24
- package/skills/cosmic-build/skills/mcp-server/scripts/mcp_cli.py +20 -6
- package/skills/cosmic-dev-best-practices/SKILL.md +1 -1
- package/skills/cosmic-dev-best-practices/references/README.md +2 -2
- package/skills/cosmic-dev-best-practices/references/basedata-dev-convention.md +2 -2
- package/skills/cosmic-dev-best-practices/references/bill-dev-convention.md +4 -4
- package/skills/cosmic-dev-best-practices/references/design-document-convention.md +2 -2
- package/skills/cosmic-dev-best-practices/references/design-examples/basedata-design-example.md +4 -4
- package/skills/cosmic-dev-best-practices/references/design-examples/bill-design-example.md +13 -13
- package/skills/cosmic-dev-best-practices/references/elements/CustomerField.md +1 -1
- package/skills/cosmic-dev-best-practices/references/elements/README.md +1 -1
- package/skills/cosmic-dev-best-practices/references/elements/SupplierField.md +1 -1
- package/skills/cosmic-dev-best-practices/references/elements/common-field-property-conventions.md +109 -13
- package/skills/cosmic-dev-best-practices/references/layout-control-convention.md +1 -1
- package/skills/erp-analysis/references/delivery-contract.md +2 -1
- package/skills/erp-analysis/templates/AGENTS.template.md +3 -1
- package/skills/cosmic-build/assets/platform-report-filters/TypedReportFilterFields.java +0 -169
- package/skills/cosmic-build/assets/platform-report-filters/platform.patch +0 -98
- 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
|
-
|
|
5
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
``
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
+ ".
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
|
650
|
-
"""
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
658
|
-
if
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
676
|
-
"""
|
|
1161
|
+
def _filter_receipt(result):
|
|
1162
|
+
"""The per-filter receipt a typed-filter platform returns, or None when there is none.
|
|
677
1163
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
687
|
-
|
|
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
|
-
|
|
690
|
-
|
|
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
|
-
|
|
695
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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 = ("
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
decide (mustInput, visible). Only what the export actually carries is promoted.
|
|
1995
|
+
仅提升 reportPlugins 及过滤属性 mustInput / visible。
|
|
1996
|
+
InitField 的排序污染条目不被解析或提升为排序证据。
|
|
1311
1997
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
1727
|
-
"sourceBinding":
|
|
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
|
-
"
|
|
1744
|
-
"query()
|
|
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
|
-
|
|
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
|
-
"
|
|
1786
|
-
"
|
|
1787
|
-
"
|
|
1788
|
-
"
|
|
1789
|
-
"
|
|
1790
|
-
"
|
|
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
|
|
1907
|
-
|
|
1908
|
-
|
|
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
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
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
|
-
|
|
2249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)).
|
|
2427
|
-
# property as a COMMA-JOINED string
|
|
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
|
-
"
|
|
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
|
-
"
|
|
2547
|
-
"
|
|
2548
|
-
"
|
|
2549
|
-
"
|
|
2550
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
2749
|
-
|
|
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.
|
|
2795
|
-
so both have to be readable or a
|
|
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
|
-
|
|
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"]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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",
|