@shundoo-ai/dsh-cosmic 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/cordis.patch.yml +5 -5
  2. package/package.json +1 -1
  3. package/skills/cosmic-build/CHANGELOG.md +4 -0
  4. package/skills/cosmic-build/LOCAL-PATCHES.md +91 -40
  5. package/skills/cosmic-build/SKILL.md +46 -13
  6. package/skills/cosmic-build/assets/platform-report-filters/TypedReportFilterFields.java +169 -0
  7. package/skills/cosmic-build/assets/platform-report-filters/platform.patch +98 -0
  8. package/skills/cosmic-build/assets/report-algo-benchmark/ReportAlgoBenchmark.java +48 -0
  9. package/skills/cosmic-build/assets/report-algo-benchmark/ReportBenchmarkLogFactory.java +7 -0
  10. package/skills/cosmic-build/examples/java-rules/CosmicEntryQuery.java +26 -0
  11. package/skills/cosmic-build/examples/java-rules/CosmicReportAggregation.java +35 -0
  12. package/skills/cosmic-build/examples/java-rules/CosmicReportFilters.java +77 -0
  13. package/skills/cosmic-build/examples/java-rules/CosmicTimeAdapter.java +25 -0
  14. package/skills/cosmic-build/examples/java-rules/CosmicTimeAdapterCheck.java +26 -0
  15. package/skills/cosmic-build/knowledge/cosmic/platform/api/algo/GroupbyDataSet.md +47 -0
  16. package/skills/cosmic-build/knowledge/cosmic/platform/api/entity/report/FilterInfo.md +12 -12
  17. package/skills/cosmic-build/knowledge/cosmic/platform/api/entity/report/FilterItemInfo.md +2 -0
  18. package/skills/cosmic-build/knowledge/cosmic/platform/form-plugin.md +2 -4
  19. package/skills/cosmic-build/knowledge/cosmic/platform/java-type-contracts.md +11 -2
  20. package/skills/cosmic-build/knowledge/cosmic/platform/list-plugin.md +18 -14
  21. package/skills/cosmic-build/knowledge/cosmic/platform/operation-plugin.md +4 -4
  22. package/skills/cosmic-build/knowledge/cosmic/platform/reportlistdata-plugin.md +17 -9
  23. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/operation/cross-entity-aggregate-writeback.md +2 -0
  24. package/skills/cosmic-build/knowledge/cosmic/platform/scenes/operation/pre-post-operation.md +15 -7
  25. package/skills/cosmic-build/knowledge/cosmic/platform/sdk/sdk-algo.md +24 -68
  26. package/skills/cosmic-build/knowledge/cosmic/platform/workflow-plugin.md +2 -7
  27. package/skills/cosmic-build/references/application-context.md +35 -1
  28. package/skills/cosmic-build/references/common-reference.md +1 -1
  29. package/skills/cosmic-build/references/compile-guide.md +55 -68
  30. package/skills/cosmic-build/references/delivery.md +59 -7
  31. package/skills/cosmic-build/references/execution-contract.md +38 -0
  32. package/skills/cosmic-build/references/form-discovery.md +7 -1
  33. package/skills/cosmic-build/references/java-plugin-delivery.md +32 -162
  34. package/skills/cosmic-build/references/operation-dispatch.md +17 -0
  35. package/skills/cosmic-build/references/plugin-handoff-input.md +131 -0
  36. package/skills/cosmic-build/references/plugin-runtime-routing.md +1 -1
  37. package/skills/cosmic-build/references/report-filter-contract.md +78 -0
  38. package/skills/cosmic-build/references/sdk-inspection.md +23 -0
  39. package/skills/cosmic-build/schemas/data-model-authoring.schema.json +2 -3
  40. package/skills/cosmic-build/scripts/_http_client.py +55 -55
  41. package/skills/cosmic-build/scripts/appconfig_load.py +2 -2
  42. package/skills/cosmic-build/scripts/apply_report_filter_extension.py +39 -0
  43. package/skills/cosmic-build/scripts/bootstrap_cosmic_context.py +37 -2
  44. package/skills/cosmic-build/scripts/compile_plugin_source.py +279 -156
  45. package/skills/cosmic-build/scripts/execution_binding.py +40 -0
  46. package/skills/cosmic-build/scripts/inspect_platform_sdk.py +169 -0
  47. package/skills/cosmic-build/scripts/kd_api.py +23 -22
  48. package/skills/cosmic-build/scripts/kd_auth.py +1 -1
  49. package/skills/cosmic-build/scripts/kd_env_loader.py +19 -10
  50. package/skills/cosmic-build/scripts/kd_token_cache.py +2 -2
  51. package/skills/cosmic-build/scripts/kingscript_sdk_sync.py +350 -0
  52. package/skills/cosmic-build/scripts/kingscript_source_gate.py +11 -8
  53. package/skills/cosmic-build/scripts/platform_api_contract.py +246 -11
  54. package/skills/cosmic-build/scripts/plugin_corpus/build_index.py +403 -0
  55. package/skills/cosmic-build/scripts/plugin_corpus/jvm_classfile.py +281 -0
  56. package/skills/cosmic-build/scripts/plugin_corpus/measure_api_usage.py +446 -0
  57. package/skills/cosmic-build/scripts/plugin_source_gate.py +48 -7
  58. package/skills/cosmic-build/scripts/plugin_target_sdk.py +45 -0
  59. package/skills/cosmic-build/scripts/prepare_plugin_handoff.py +493 -47
  60. package/skills/cosmic-build/scripts/preview_url.py +35 -6
  61. package/skills/cosmic-build/scripts/query_plugin_handoff.py +250 -0
  62. package/skills/cosmic-build/scripts/register_plugins.py +28 -2
  63. package/skills/cosmic-build/scripts/render_output_cards.py +42 -9
  64. package/skills/cosmic-build/scripts/report_algo_benchmark.py +46 -0
  65. package/skills/cosmic-build/scripts/run.py +2 -2
  66. package/skills/cosmic-build/scripts/sync_generated_locale_assets.py +43 -0
  67. package/skills/cosmic-build/scripts/test/fixtures/ReportSdkCheck.java +64 -0
  68. package/skills/cosmic-build/scripts/test/fixtures/java_context_booking_native.json +541 -0
  69. package/skills/cosmic-build/scripts/test/test_add_rule_business_contract.py +85 -14
  70. package/skills/cosmic-build/scripts/test/test_batch5_drift_governance.py +12 -3
  71. package/skills/cosmic-build/scripts/test/test_build_meta_order.py +0 -16
  72. package/skills/cosmic-build/scripts/test/test_cli_continuation.py +87 -0
  73. package/skills/cosmic-build/scripts/test/test_compile_plugin_source.py +224 -3
  74. package/skills/cosmic-build/scripts/test/test_control_authoring.py +123 -4
  75. package/skills/cosmic-build/scripts/test/test_cosmic_sdk_examples.py +141 -0
  76. package/skills/cosmic-build/scripts/test/test_data_model_contract.py +30 -5
  77. package/skills/cosmic-build/scripts/test/test_delivery_stage_boundary.py +2 -10
  78. package/skills/cosmic-build/scripts/test/test_dym_sync_background.py +11 -0
  79. package/skills/cosmic-build/scripts/test/test_dynamic_form_metadata_only_contract.py +59 -1
  80. package/skills/cosmic-build/scripts/test/test_guide_skeleton_contract.py +87 -43
  81. package/skills/cosmic-build/scripts/test/test_inspect_platform_sdk.py +71 -0
  82. package/skills/cosmic-build/scripts/test/test_kd_api.py +67 -19
  83. package/skills/cosmic-build/scripts/test/test_kingscript_sdk_sync.py +221 -0
  84. package/skills/cosmic-build/scripts/test/test_kingscript_source_gate.py +10 -0
  85. package/skills/cosmic-build/scripts/test/test_list_configuration_closure.py +18 -0
  86. package/skills/cosmic-build/scripts/test/test_long_session_regressions.py +116 -0
  87. package/skills/cosmic-build/scripts/test/test_mcp_cli.py +524 -0
  88. package/skills/cosmic-build/scripts/test/test_modeling_input_recovery.py +141 -0
  89. package/skills/cosmic-build/scripts/test/test_open_world_cli_catalog.py +36 -2
  90. package/skills/cosmic-build/scripts/test/test_platform_api_contract.py +161 -1
  91. package/skills/cosmic-build/scripts/test/test_plugin_business_contract.py +91 -0
  92. package/skills/cosmic-build/scripts/test/test_plugin_corpus.py +195 -0
  93. package/skills/cosmic-build/scripts/test/test_plugin_handoff.py +346 -1
  94. package/skills/cosmic-build/scripts/test/test_plugin_target_sdk.py +70 -0
  95. package/skills/cosmic-build/scripts/test/test_preview_url.py +44 -1
  96. package/skills/cosmic-build/scripts/test/test_property_batch.py +22 -0
  97. package/skills/cosmic-build/scripts/test/test_property_input_discovery.py +13 -11
  98. package/skills/cosmic-build/scripts/test/test_query_plugin_handoff.py +160 -0
  99. package/skills/cosmic-build/scripts/test/test_register_plugins.py +31 -1
  100. package/skills/cosmic-build/scripts/test/test_render_output_cards.py +50 -4
  101. package/skills/cosmic-build/scripts/test/test_report_business_contract.py +27 -0
  102. package/skills/cosmic-build/scripts/test/test_report_doc_contract_sync.py +5 -4
  103. package/skills/cosmic-build/scripts/test/test_report_sdk_optimization.py +36 -0
  104. package/skills/cosmic-build/scripts/test/test_rule_batch_evidence.py +1 -1
  105. package/skills/cosmic-build/scripts/test/test_rule_domain_contract.py +20 -0
  106. package/skills/cosmic-build/scripts/test/test_rule_function_formula.py +16 -6
  107. package/skills/cosmic-build/scripts/test/test_skill_entry_contract.py +1 -0
  108. package/skills/cosmic-build/scripts/test/test_test_environment.py +45 -0
  109. package/skills/cosmic-build/scripts/test/test_verify_plugin_loading.py +79 -0
  110. package/skills/cosmic-build/scripts/test_environment.py +49 -0
  111. package/skills/cosmic-build/scripts/verify_plugin_loading.py +71 -0
  112. package/skills/cosmic-build/skills/cosmic-coding-impl/SKILL.md +86 -162
  113. package/skills/cosmic-build/skills/cosmic-coding-script/ATTRIBUTION.md +1 -1
  114. package/skills/cosmic-build/skills/cosmic-coding-script/SKILL.md +45 -14
  115. package/skills/cosmic-build/skills/cosmic-coding-script/references/kwc-controller-authoring.md +6 -0
  116. package/skills/cosmic-build/skills/cosmic-coding-script/references/sdk/README.md +29 -19
  117. package/skills/cosmic-build/skills/cosmic-coding-script/references/sdk/docs//350/204/232/346/234/254SDK.md +3 -4
  118. package/skills/cosmic-build/skills/cosmic-coding-script/references/sdk/indexes/package-index.md +1 -1
  119. package/skills/cosmic-build/skills/cosmic-coding-script/references/sdk/strategy.md +27 -18
  120. package/skills/cosmic-build/skills/cosmic-meta-api/SKILL.md +11 -17
  121. package/skills/cosmic-build/skills/cosmic-meta-api/apis/ai_meta.md +1 -1
  122. package/skills/cosmic-build/skills/cosmic-meta-api/apis/app_menu.md +1 -1
  123. package/skills/cosmic-build/skills/cosmic-meta-api/apis/meta_log.md +1 -1
  124. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/control-authoring.json +45 -4
  125. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/form-control-traits.json +1 -1
  126. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/layout-ui.json +18 -0
  127. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/menu-publish.json +8 -2
  128. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/property-batch.json +7 -0
  129. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/property-input-index.json +3 -3
  130. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/report-write-contracts.json +1 -0
  131. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/rule-domain.json +37 -5
  132. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/rule-event-contract.json +24 -3
  133. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/cli/run-build-meta.json +61 -12
  134. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/schema/dropdown-menu.schema.json +69 -0
  135. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/schema/report-write.schema.json +2 -1
  136. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/schema/rule-set.schema.json +1 -0
  137. package/skills/cosmic-build/skills/cosmic-meta-api/contracts/schema/rule-write.schema.json +14 -7
  138. package/skills/cosmic-build/skills/cosmic-meta-api/docs/api-summary.md +2 -2
  139. package/skills/cosmic-build/skills/cosmic-meta-api/modules/app-action/SKILL.md +6 -3
  140. package/skills/cosmic-build/skills/cosmic-meta-api/modules/develop/SKILL.md +1 -2
  141. package/skills/cosmic-build/skills/cosmic-meta-api/modules/export/SKILL.md +5 -6
  142. package/skills/cosmic-build/skills/cosmic-meta-api/modules/init/SKILL.md +13 -36
  143. package/skills/cosmic-build/skills/cosmic-meta-api/modules/pdm/SKILL.md +8 -9
  144. package/skills/cosmic-build/skills/cosmic-meta-api/modules/pdm/scripts/config.json.example +5 -15
  145. package/skills/cosmic-build/skills/cosmic-meta-api/modules/reset/SKILL.md +6 -6
  146. package/skills/cosmic-build/skills/cosmic-meta-api/modules/usage/SKILL.md +20 -148
  147. package/skills/cosmic-build/skills/cosmic-meta-api/modules/verify/SKILL.md +1 -1
  148. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/build_receipts.py +171 -0
  149. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_apps.py +25 -6
  150. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_controls.py +55 -21
  151. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_fields.py +10 -3
  152. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_formactions.py +63 -6
  153. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_forms.py +59 -2
  154. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_listui.py +8 -0
  155. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_metaops.py +36 -1
  156. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_plugins.py +115 -90
  157. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_properties.py +666 -4
  158. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/cmd_rules.py +1415 -87
  159. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/commands.py +6 -1
  160. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/config.py +28 -15
  161. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/execution_errors.py +40 -0
  162. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/formatters.py +32 -5
  163. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/property_policy_contract.py +6 -0
  164. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/report_ops.py +113 -17
  165. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/reset.py +3 -3
  166. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/_core/rule_contract.py +107 -26
  167. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/build_meta_order.py +50 -49
  168. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/cosmic_api.py +856 -150
  169. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/field_decorator.py +47 -14
  170. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_build_receipt_resume.py +374 -0
  171. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_cli_failure_contract.py +24 -0
  172. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_creation_identity_boundary.py +4 -2
  173. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_dropdown_menu_contract.py +382 -0
  174. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_existing_form_ui.py +0 -25
  175. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_field_decorator.py +50 -6
  176. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_functional_group_resolution.py +83 -16
  177. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_identity_template_resolution.py +103 -0
  178. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_plugin_contract.py +28 -2
  179. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_readback_derivation.py +22 -0
  180. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_report_typed_filters.py +65 -0
  181. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_rule_write_closure.py +1041 -15
  182. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_run_batch_post_build_recovery.py +65 -0
  183. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/test/test_run_batch_verification_evidence.py +17 -0
  184. package/skills/cosmic-build/skills/cosmic-meta-knowledge/generation/api-param-checklist.md +1 -1
  185. package/skills/cosmic-build/skills/cosmic-meta-knowledge/generation/rule-modeling-guide.md +7 -4
  186. package/skills/cosmic-build/skills/cosmic-meta-modeling/SKILL.md +61 -13
  187. package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/app-menu/SKILL.md +8 -0
  188. package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/formtemplate/SKILL.md +5 -0
  189. package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/formtemplate/dynamic-form-artifact-schema.md +5 -0
  190. package/skills/cosmic-build/skills/cosmic-meta-modeling/modules/report/SKILL.md +21 -9
  191. package/skills/cosmic-build/skills/cosmic-meta-modeling/references/authoring-entity-graphs.md +1 -1
  192. package/skills/cosmic-build/skills/cosmic-meta-modeling/references/authoring-storage-constraints.md +5 -3
  193. package/skills/cosmic-build/skills/cosmic-meta-modeling/references/authoring-ui-composition.md +6 -3
  194. package/skills/cosmic-build/skills/cosmic-meta-modeling/references/creation-recovery.md +13 -0
  195. package/skills/cosmic-build/skills/cosmic-meta-modeling/references/parse-core.md +3 -0
  196. package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/data_model_contract.py +6 -1
  197. package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/data_model_emit.py +135 -87
  198. package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/field_type_registry.py +11 -1
  199. package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/isv_resolver.py +1 -3
  200. package/skills/cosmic-build/skills/cosmic-meta-modeling/scripts/report_validator.py +29 -0
  201. package/skills/cosmic-build/skills/cosmic-meta-modifier/SKILL.md +4 -2
  202. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/context/SKILL.md +2 -2
  203. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/execute/SKILL.md +1 -1
  204. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/operation/SKILL.md +5 -4
  205. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/operation/operation-write.md +6 -2
  206. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/operation/validation.md +2 -0
  207. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/property/SKILL.md +20 -5
  208. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/property/complex-apis.md +1 -1
  209. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/property/policy-grammar.md +4 -1
  210. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/report-op/SKILL.md +9 -0
  211. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/rule/SKILL.md +61 -4
  212. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/rule/raw-rule-authoring.md +3 -1
  213. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/rule/rule-actions.md +2 -2
  214. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/rule/rule-reference.md +6 -6
  215. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/rule/state.md +1 -1
  216. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/unit/SKILL.md +6 -3
  217. package/skills/cosmic-build/skills/cosmic-meta-modifier/modules/verify/usage-guide.md +4 -4
  218. package/skills/cosmic-build/skills/cosmic-meta-modifier/references/list-filter-modification.md +4 -1
  219. package/skills/cosmic-build/skills/cosmic-meta-ui/SKILL.md +9 -1
  220. package/skills/cosmic-build/skills/cosmic-meta-ui/modules/button/SKILL.md +3 -0
  221. package/skills/cosmic-build/skills/mcp-server/SKILL.md +210 -0
  222. package/skills/cosmic-build/skills/mcp-server/scripts/mcp_cli.py +431 -0
  223. package/skills/cosmic-build/skills/openapi-gen/SKILL.md +3 -3
  224. package/skills/cosmic-build/skills/openapi-gen/scripts/api/gen_runner.py +2 -2
  225. package/skills/cosmic-dev-best-practices/references/bill-dev-convention.md +19 -9
  226. package/skills/cosmic-dev-best-practices/references/cosmic-meta-api.md +14 -11
  227. package/skills/cosmic-dev-best-practices/references/design-examples/basedata-design-example.md +1 -1
  228. package/skills/cosmic-dev-best-practices/references/design-examples/bill-design-example.md +1 -1
  229. package/skills/cosmic-dev-best-practices/references/elements/AssistantField.md +1 -1
  230. package/skills/cosmic-dev-best-practices/references/elements/BasedataField.md +2 -2
  231. package/skills/cosmic-dev-best-practices/references/elements/BasedataPropField.md +3 -3
  232. package/skills/cosmic-dev-best-practices/references/elements/CreaterField.md +2 -2
  233. package/skills/cosmic-dev-best-practices/references/elements/CurrencyField.md +2 -2
  234. package/skills/cosmic-dev-best-practices/references/elements/CustomerField.md +2 -2
  235. package/skills/cosmic-dev-best-practices/references/elements/FlexField.md +1 -1
  236. package/skills/cosmic-dev-best-practices/references/elements/MaterielField.md +2 -2
  237. package/skills/cosmic-dev-best-practices/references/elements/OrgField.md +2 -2
  238. package/skills/cosmic-dev-best-practices/references/elements/SupplierField.md +2 -2
  239. package/skills/cosmic-dev-best-practices/references/elements/UnitField.md +1 -1
  240. package/skills/cosmic-dev-best-practices/references/elements/UserField.md +3 -3
  241. package/skills/cosmic-dev-best-practices/references/elements/common-field-property-conventions.md +16 -11
  242. package/skills/cosmic-dev-best-practices/references/layout-control-convention.md +5 -4
  243. package/skills/erp-analysis/references/delivery-contract.md +4 -2
  244. package/skills/erp-analysis/templates/AGENTS.template.md +1 -1
  245. package/skills/cosmic-build/skills/cosmic-meta-api/scripts/.dsh/cosmic/.kd/config.json +0 -21
@@ -32,6 +32,9 @@ from .mutation_contract import mutation_contract, combine_mutation_evidence, cla
32
32
  from .rule_contract import (
33
33
  FORM_RULE_DEFAULT_RET,
34
34
  normalize_rule_config,
35
+ merge_action_events,
36
+ select_action_events,
37
+ runtime_action_warnings,
35
38
  trigger_field_selectors,
36
39
  )
37
40
 
@@ -665,8 +668,10 @@ def _validate_rule_write_input(rule_config, *, command):
665
668
  if rule_type not in {"formRule", "bizRule"}:
666
669
  raise ValueError("RULE_TYPE_INVALID: ruleType must be formRule or bizRule")
667
670
  if command == "addRule":
668
- missing = [field for field in ("description", "preDescription", "preCondition", "trueActions")
671
+ missing = [field for field in ("description", "preDescription", "preCondition")
669
672
  if rule_config.get(field) in (None, "", [])]
673
+ if not (rule_config.get("trueActions") or rule_config.get("falseActions")):
674
+ missing.append("trueActions or falseActions")
670
675
  if missing:
671
676
  raise ValueError(
672
677
  f"RULE_INPUT_REQUIRED: {', '.join('addRule.' + field for field in missing)}; "
@@ -1078,6 +1083,61 @@ def _validate_clear_value_action_scope(cfg, token, form_number, rule_config):
1078
1083
  return rule_config
1079
1084
 
1080
1085
 
1086
+ def _resolve_call_field_change_targets(cfg, token, form_number, rule_config):
1087
+ if rule_config.get("ruleType") != "bizRule":
1088
+ return rule_config
1089
+ normalized = normalize_rule_config(rule_config)
1090
+ if not any(
1091
+ action.get("ActionType") == "CallFieldChangeAction"
1092
+ for branch in ("trueActions", "falseActions")
1093
+ for action in (normalized.get(branch) or [])
1094
+ ):
1095
+ return rule_config
1096
+ listed = do_list_elements(cfg, token, form_number=form_number, category="field")
1097
+ rows = listed.get("data") if isinstance(listed, dict) else None
1098
+ if not _is_api_success(listed) or not isinstance(rows, list):
1099
+ raise RuntimeError(
1100
+ "RULE_CALL_FIELD_INVENTORY_UNAVAILABLE: listElements returned no field list"
1101
+ )
1102
+ mounted = str(rule_config.get("mountPoint") or "").strip()
1103
+ resolved = copy.deepcopy(rule_config)
1104
+ for branch in ("trueActions", "falseActions"):
1105
+ for action in resolved.get(branch) or []:
1106
+ kind = action.get("actionType") or action.get("ActionType")
1107
+ if kind != "CallFieldChangeAction":
1108
+ continue
1109
+ slot = "expression" if "expression" in action else "Expression"
1110
+ wanted = str(action.get(slot) or "").strip()
1111
+ candidates = [
1112
+ row for row in rows
1113
+ if isinstance(row, dict)
1114
+ and wanted in {
1115
+ str(row.get("key") or "").strip(),
1116
+ str(row.get("name") or "").strip(),
1117
+ str(row.get("id") or "").strip(),
1118
+ }
1119
+ ]
1120
+ exact = [row for row in candidates if row.get("key") == wanted]
1121
+ if exact:
1122
+ candidates = exact
1123
+ if mounted:
1124
+ candidates = [
1125
+ row for row in candidates
1126
+ if not str(row.get("parentKey") or "").strip()
1127
+ or str(row.get("parentKey") or "").strip() == mounted
1128
+ ]
1129
+ if len(candidates) != 1:
1130
+ code = "UNRESOLVED" if not candidates else "AMBIGUOUS"
1131
+ raise ValueError(
1132
+ f"RULE_CALL_FIELD_TARGET_{code}: {wanted!r}; "
1133
+ "provide an exact field key and entity when names repeat"
1134
+ )
1135
+ for alias in ("expression", "Expression"):
1136
+ if alias in action:
1137
+ action[alias] = str(candidates[0].get("key") or "").strip()
1138
+ return resolved
1139
+
1140
+
1081
1141
  def _validate_set_equation_action_scope(cfg, token, form_number, rule_config):
1082
1142
  if rule_config.get("ruleType") != "bizRule":
1083
1143
  return rule_config
@@ -1103,24 +1163,24 @@ def _validate_set_equation_action_scope(cfg, token, form_number, rule_config):
1103
1163
  for row in rows if isinstance(row, dict) and str(row.get("key") or "").strip()
1104
1164
  }
1105
1165
  for action in actions:
1106
- mapping = (action.get("Expression") or [])[0]
1107
- for slot in ("TargetField", "SrcField"):
1108
- field_key = str(mapping.get(slot) or "").strip()
1109
- if "." in field_key:
1110
- raise ValueError(
1111
- f"RULE_SET_EQUATION_CROSS_ENTITY_UNSUPPORTED: {slot}={field_key!r}"
1112
- )
1113
- row = by_key.get(field_key)
1114
- if row is None:
1115
- raise ValueError(
1116
- f"RULE_SET_EQUATION_FIELD_UNRESOLVED: {field_key!r}"
1117
- )
1118
- owner = str(row.get("parentKey") or "").strip()
1119
- if mounted and owner and owner != mounted:
1120
- raise ValueError(
1121
- "RULE_SET_EQUATION_ENTITY_MISMATCH: "
1122
- f"field {field_key!r} belongs to {owner!r}, rule mount is {mounted!r}"
1123
- )
1166
+ for mapping in action.get("Expression") or []:
1167
+ for slot in ("TargetField", "SrcField"):
1168
+ field_key = str(mapping.get(slot) or "").strip()
1169
+ if "." in field_key:
1170
+ raise ValueError(
1171
+ f"RULE_SET_EQUATION_CROSS_ENTITY_UNSUPPORTED: {slot}={field_key!r}"
1172
+ )
1173
+ row = by_key.get(field_key)
1174
+ if row is None:
1175
+ raise ValueError(
1176
+ f"RULE_SET_EQUATION_FIELD_UNRESOLVED: {field_key!r}"
1177
+ )
1178
+ owner = str(row.get("parentKey") or "").strip()
1179
+ if mounted and owner and owner != mounted:
1180
+ raise ValueError(
1181
+ "RULE_SET_EQUATION_ENTITY_MISMATCH: "
1182
+ f"field {field_key!r} belongs to {owner!r}, rule mount is {mounted!r}"
1183
+ )
1124
1184
  return rule_config
1125
1185
 
1126
1186
 
@@ -1716,6 +1776,11 @@ def _attach_rule_readback(cfg, token, form_number, rule_config, result, preserve
1716
1776
  metadata = (read_back or {}).get("data")
1717
1777
  observed = _find_rule_projection(metadata, rule_id)
1718
1778
  expected = normalize_rule_config(rule_config)
1779
+ if rule_config.get("ruleId"):
1780
+ for branch in ("trueActions", "falseActions"):
1781
+ for action in expected.get(branch) or []:
1782
+ if isinstance(action, dict):
1783
+ action.pop("Id", None)
1719
1784
  mismatches = rule_readback_mismatches(expected, observed, metadata)
1720
1785
  preserved_mismatches = []
1721
1786
  if preserved_rule is not None and isinstance(observed, dict):
@@ -1838,6 +1903,9 @@ def do_add_rule_verified(
1838
1903
  resolved = _validate_clear_value_action_scope(
1839
1904
  cfg, token, form_number, resolved
1840
1905
  )
1906
+ resolved = _resolve_call_field_change_targets(
1907
+ cfg, token, form_number, resolved
1908
+ )
1841
1909
  resolved = _validate_set_equation_action_scope(
1842
1910
  cfg, token, form_number, resolved
1843
1911
  )
@@ -1855,12 +1923,15 @@ def do_add_rule_verified(
1855
1923
  result = do_add_rule(cfg, token, form_number, resolved)
1856
1924
  if _is_api_success(result):
1857
1925
  result = _attach_rule_readback(cfg, token, form_number, resolved, result)
1926
+ warnings = runtime_action_warnings(resolved)
1927
+ if _is_api_success(result) and warnings:
1928
+ result["runtimeVerification"] = {"verified": False, "warnings": warnings}
1858
1929
  if isinstance(result, dict) and trigger_results:
1859
1930
  result["triggerPropertyResults"] = trigger_results
1860
1931
  return result
1861
1932
 
1862
1933
 
1863
- def _resolve_calculation_field(rows, entity_key, selector):
1934
+ def _resolve_same_entity_field(rows, entity_key, selector, error_prefix):
1864
1935
  wanted = str(selector or "").strip()
1865
1936
  matches = [
1866
1937
  row for row in rows
@@ -1881,15 +1952,37 @@ def _resolve_calculation_field(rows, entity_key, selector):
1881
1952
  if len(matches) != 1:
1882
1953
  error = "UNRESOLVED" if not matches else "AMBIGUOUS"
1883
1954
  raise ValueError(
1884
- f"FIELD_CALCULATION_FIELD_{error}: {wanted!r} in entity {entity_key!r}"
1955
+ f"{error_prefix}_FIELD_{error}: {wanted!r} in entity {entity_key!r}"
1885
1956
  )
1886
- field_type = str(matches[0].get("type") or "").strip()
1957
+ return matches[0]
1958
+
1959
+
1960
+ def _resolve_calculation_field(rows, entity_key, selector):
1961
+ row = _resolve_same_entity_field(
1962
+ rows, entity_key, selector, "FIELD_CALCULATION",
1963
+ )
1964
+ wanted = str(selector or "").strip()
1965
+ field_type = str(row.get("type") or "").strip()
1887
1966
  if field_type not in _CALCULATION_NUMERIC_FIELD_TYPES:
1888
1967
  raise ValueError(
1889
1968
  "FIELD_CALCULATION_NUMERIC_FIELD_REQUIRED: "
1890
1969
  f"{wanted!r} is {field_type or 'unknown'}"
1891
1970
  )
1892
- return matches[0]
1971
+ return row
1972
+
1973
+
1974
+ def _resolve_time_difference_operand(rows, entity_key, selector):
1975
+ row = _resolve_same_entity_field(
1976
+ rows, entity_key, selector, "TIME_DIFFERENCE",
1977
+ )
1978
+ wanted = str(selector or "").strip()
1979
+ field_type = str(row.get("type") or "").strip()
1980
+ if field_type != "TimeField":
1981
+ raise ValueError(
1982
+ "TIME_DIFFERENCE_TIME_FIELD_REQUIRED: "
1983
+ f"{wanted!r} is {field_type or 'unknown'}"
1984
+ )
1985
+ return row
1893
1986
 
1894
1987
 
1895
1988
  def _front_value_action_matches(expected, actual):
@@ -2051,6 +2144,145 @@ def do_ensure_field_calculation(
2051
2144
  }
2052
2145
 
2053
2146
 
2147
+ def do_ensure_time_difference_calculation(
2148
+ cfg, token, form_number, entity, target, start, end,
2149
+ ):
2150
+ """Ensure a same-entity TimeField difference expressed in decimal hours."""
2151
+ entity_row, rows = _resolve_rule_entity_context(
2152
+ cfg, token, form_number, entity, [target, start, end]
2153
+ )
2154
+ entity_key = str(entity_row.get("key") or "").strip()
2155
+ entity_id = str(entity_row.get("id") or "").strip()
2156
+ target_row = _resolve_calculation_field(rows, entity_key, target)
2157
+ start_row = _resolve_time_difference_operand(rows, entity_key, start)
2158
+ end_row = _resolve_time_difference_operand(rows, entity_key, end)
2159
+ target_key = str(target_row.get("key") or "").strip()
2160
+ start_key = str(start_row.get("key") or "").strip()
2161
+ end_key = str(end_row.get("key") or "").strip()
2162
+ if len({target_key, start_key, end_key}) != 3:
2163
+ raise ValueError(
2164
+ "TIME_DIFFERENCE_DISTINCT_FIELDS_REQUIRED: target, start and end "
2165
+ "must be different fields"
2166
+ )
2167
+ target_name = str(target_row.get("name") or target_key).strip()
2168
+ start_name = str(start_row.get("name") or start_key).strip()
2169
+ end_name = str(end_row.get("name") or end_key).strip()
2170
+ formula = f"{target_key} = ({end_key} - {start_key}) / 3600.0"
2171
+ condition = f"{start_key} >= 0 and {end_key} >= 0"
2172
+ trigger_field = f"{start_name}({start_key});{end_name}({end_key})"
2173
+ description = f"{start_name}、{end_name}非空时计算{target_name}"
2174
+ requested = {
2175
+ "ruleType": "bizRule",
2176
+ "itemId": entity_id,
2177
+ "mountPoint": entity_key,
2178
+ "description": description,
2179
+ "preDescription": description,
2180
+ "preCondition": condition,
2181
+ "triggerField": trigger_field,
2182
+ "enabled": True,
2183
+ "trueActions": [{
2184
+ "actionType": "FormulaAction",
2185
+ "description": f"{target_name} = ({end_name} - {start_name}) / 3600.0",
2186
+ "expression": formula,
2187
+ "ret": 1021,
2188
+ }],
2189
+ "falseActions": [{
2190
+ "actionType": "ClearValueAction",
2191
+ "description": f"时间不完整时清空{target_name}",
2192
+ "expression": target_key,
2193
+ "ret": 1021,
2194
+ }],
2195
+ }
2196
+ current = do_load_full_metadata(cfg, token, form_number)
2197
+ if not _is_api_success(current):
2198
+ raise RuntimeError(
2199
+ "TIME_DIFFERENCE_READ_FAILED: "
2200
+ + (_extract_error_message(current) or "loadFullMetadata failed")
2201
+ )
2202
+ metadata = current.get("data")
2203
+ target_paths = {target_key, f"{entity_key}.{target_key}"}
2204
+ for row in rows:
2205
+ if str(row.get("parentKey") or "") != entity_key:
2206
+ continue
2207
+ for item in _metadata_items_by_key(metadata, str(row.get("key") or "")):
2208
+ actions = item.get("ExecuteActions") or {}
2209
+ if isinstance(actions, str):
2210
+ actions = json.loads(actions)
2211
+ if isinstance(actions, dict) and any(
2212
+ isinstance(action, dict)
2213
+ and action.get("action") == "updateField"
2214
+ and action.get("target") in target_paths
2215
+ for action in actions.get("valueChange") or []
2216
+ ):
2217
+ raise ValueError(
2218
+ f"TIME_DIFFERENCE_TARGET_CONFLICT: {target_key!r} already has "
2219
+ "a field value-change calculation"
2220
+ )
2221
+ semantic = [
2222
+ rule for rule in _all_rule_projections(metadata, "BizRule")
2223
+ if _rule_function_formula_matches(rule, entity_key, formula)
2224
+ ]
2225
+ conflicts = [
2226
+ rule for rule in _all_rule_projections(metadata, "BizRule")
2227
+ if _rule_function_formula_conflicts(rule, entity_key, target_key, formula)
2228
+ ]
2229
+ if len(semantic) > 1:
2230
+ raise ValueError("TIME_DIFFERENCE_DUPLICATE_RULES")
2231
+ if conflicts:
2232
+ raise ValueError(
2233
+ "TIME_DIFFERENCE_TARGET_CONFLICT: "
2234
+ f"target={target_key!r}, candidateRuleIds="
2235
+ f"{[str(rule.get('Id') or '') for rule in conflicts]!r}"
2236
+ )
2237
+ changed = True
2238
+ if semantic:
2239
+ expected = normalize_rule_config(requested)
2240
+ mismatches = rule_readback_mismatches(expected, semantic[0], metadata)
2241
+ if not mismatches:
2242
+ return {
2243
+ "status": True,
2244
+ "errorCode": "0",
2245
+ "message": "Time difference calculation already matches",
2246
+ "writeAttempted": False,
2247
+ "data": {
2248
+ "changed": False,
2249
+ "ruleId": str(semantic[0].get("Id") or ""),
2250
+ "entityKey": entity_key,
2251
+ "targetField": target_key,
2252
+ "sourceFields": [start_key, end_key],
2253
+ "unit": "hours",
2254
+ "formula": formula,
2255
+ },
2256
+ "verification": {"verified": True, "mismatches": []},
2257
+ }
2258
+ result = do_update_rule_verified(cfg, token, form_number, {
2259
+ "ruleType": "bizRule",
2260
+ "ruleId": str(semantic[0].get("Id") or ""),
2261
+ "description": requested["description"],
2262
+ "preDescription": requested["preDescription"],
2263
+ "preCondition": requested["preCondition"],
2264
+ "triggerField": requested["triggerField"],
2265
+ "enabled": True,
2266
+ "trueActions": requested["trueActions"],
2267
+ "falseActions": requested["falseActions"],
2268
+ })
2269
+ else:
2270
+ result = do_add_rule_verified(cfg, token, form_number, requested)
2271
+ if isinstance(result, dict):
2272
+ result["writeAttempted"] = changed
2273
+ data = result.setdefault("data", {})
2274
+ if isinstance(data, dict):
2275
+ data.update({
2276
+ "changed": changed,
2277
+ "entityKey": entity_key,
2278
+ "targetField": target_key,
2279
+ "sourceFields": [start_key, end_key],
2280
+ "unit": "hours",
2281
+ "formula": formula,
2282
+ })
2283
+ return result
2284
+
2285
+
2054
2286
  def _resolve_rule_function_field(rows, entity_key, selector):
2055
2287
  wanted = str(selector or "").strip()
2056
2288
  matches = [
@@ -2133,7 +2365,9 @@ def _rule_function_formula_conflicts(rule, entity_key, target_key, formula):
2133
2365
  )
2134
2366
 
2135
2367
 
2136
- def do_ensure_rule_function_formula(cfg, token, form_number, entity, target, call):
2368
+ def do_ensure_rule_function_formula(
2369
+ cfg, token, form_number, entity, target, call, add_events=None, events=None,
2370
+ ):
2137
2371
  """Compile one released FX call into a same-entity FormulaAction.
2138
2372
 
2139
2373
  The call tree has no raw-expression escape hatch: function IDs resolve against
@@ -2200,15 +2434,19 @@ def do_ensure_rule_function_formula(cfg, token, form_number, entity, target, cal
2200
2434
  "actionType": "FormulaAction",
2201
2435
  "description": f"{target_key} = {expression}",
2202
2436
  "expression": formula,
2203
- "ret": 253,
2437
+ "ret": _requested_action_ret("FormulaAction", 253, events, add_events),
2204
2438
  }],
2205
2439
  "falseActions": ([{
2206
2440
  "actionType": "ClearValueAction",
2207
2441
  "description": f"clear {target_key} when an input is null",
2208
2442
  "expression": target_key,
2209
- "ret": 253,
2443
+ "ret": _requested_action_ret("ClearValueAction", 253, events, add_events),
2210
2444
  }] if guarded_source_keys else []),
2211
2445
  }
2446
+ legacy_description = description
2447
+ tagged_description = f"{requested['preDescription']} [RFF_{legacy_description.rsplit('_', 1)[1]}]"
2448
+ description = requested["preDescription"]
2449
+ requested["description"] = description
2212
2450
  current = do_load_full_metadata(cfg, token, form_number)
2213
2451
  if not _is_api_success(current):
2214
2452
  raise RuntimeError(
@@ -2253,10 +2491,29 @@ def do_ensure_rule_function_formula(cfg, token, form_number, entity, target, cal
2253
2491
  f"{[str(rule.get('Id') or '') for rule in conflicts]!r}"
2254
2492
  )
2255
2493
  if semantic:
2494
+ timing_result = _reconcile_existing_rule_events(
2495
+ cfg, token, form_number, semantic[0], "bizRule", events, add_events,
2496
+ )
2497
+ if timing_result is not None:
2498
+ return timing_result
2499
+ expected = normalize_rule_config(requested)
2500
+ if events is not None or add_events is not None:
2501
+ for branch, stored_branch in (("trueActions", "TrueActions"),
2502
+ ("falseActions", "FalseActions")):
2503
+ for action, stored_action in zip(
2504
+ expected.get(branch) or [], semantic[0].get(stored_branch) or [],
2505
+ ):
2506
+ action["RET"] = stored_action["RET"]
2256
2507
  mismatches = rule_readback_mismatches(
2257
- normalize_rule_config(requested), semantic[0], metadata
2508
+ expected, semantic[0], metadata
2258
2509
  )
2259
2510
  if mismatches:
2511
+ if (str(semantic[0].get("Description") or "") in {legacy_description, tagged_description}
2512
+ and all(item.get("path") == "description" for item in mismatches)):
2513
+ return _upgrade_generated_rule_description(
2514
+ cfg, token, form_number, semantic[0],
2515
+ (legacy_description, tagged_description), description,
2516
+ )
2260
2517
  if str(semantic[0].get("Description") or "") != description:
2261
2518
  raise ValueError(
2262
2519
  "RULE_FUNCTION_FORMULA_CONFLICT: "
@@ -2344,6 +2601,47 @@ def _rule_projections_by_description(value, description):
2344
2601
  return matches
2345
2602
 
2346
2603
 
2604
+ def _upgrade_generated_rule_description(
2605
+ cfg, token, form_number, rule, legacy_description, readable_description,
2606
+ ):
2607
+ """Rename one exact legacy generated rule without replacing its actions."""
2608
+ names = ({legacy_description} if isinstance(legacy_description, str)
2609
+ else set(legacy_description))
2610
+ if str(rule.get("Description") or "") not in names:
2611
+ return None
2612
+ rule_id = str(rule.get("Id") or "").strip()
2613
+ if not rule_id:
2614
+ raise ValueError("RULE_DESCRIPTION_LEGACY_ID_UNAVAILABLE")
2615
+ result = do_update_rule_verified(cfg, token, form_number, {
2616
+ "ruleType": "bizRule", "ruleId": rule_id,
2617
+ "description": readable_description,
2618
+ })
2619
+ if isinstance(result, dict):
2620
+ result["writeAttempted"] = True
2621
+ data = result.setdefault("data", {})
2622
+ if isinstance(data, dict):
2623
+ data.update({
2624
+ "changed": True if _is_api_success(result) else None,
2625
+ "ruleId": rule_id,
2626
+ "renamedGeneratedRule": True if _is_api_success(result) else None,
2627
+ })
2628
+ return result
2629
+
2630
+
2631
+ def _rule_matches_except_generated_action_ids(expected, observed):
2632
+ """A rule update may regenerate action IDs while retaining its behavior."""
2633
+ if not isinstance(expected, dict) or not isinstance(observed, dict):
2634
+ return False
2635
+ def comparable(rule):
2636
+ result = copy.deepcopy(rule)
2637
+ for branch in ("TrueActions", "FalseActions"):
2638
+ for action in result.get(branch) or []:
2639
+ if isinstance(action, dict):
2640
+ action.pop("Id", None)
2641
+ return result
2642
+ return comparable(expected) == comparable(observed)
2643
+
2644
+
2347
2645
  def _all_rule_projections(value, rule_type=None):
2348
2646
  matches = []
2349
2647
  stack = [value]
@@ -2461,11 +2759,18 @@ def _resolve_rule_entity_context(
2461
2759
  str(value or "").strip()
2462
2760
  for value in condition_selectors if value not in (None, "")
2463
2761
  ]
2762
+ action_selectors = [str(value or "").strip() for value in selectors if value not in (None, "")]
2763
+ owner_sets = [{str(row.get("parentKey") or "").strip()
2764
+ for row in _match_field_selector(scoped_field_rows, selector)} for selector in action_selectors]
2765
+ # A unique amount/quantity target can select the entry that owns a repeated price caption.
2766
+ # Infer only when all participating action fields prove the same single owner.
2767
+ common_owners = set.intersection(*owner_sets) if owner_sets and all(owner_sets) else set()
2768
+ if len(common_owners) == 1:
2769
+ scoped_field_rows = [row for row in scoped_field_rows
2770
+ if str(row.get("parentKey") or "").strip() in common_owners]
2464
2771
  selected_rows = [
2465
2772
  resolve_scoped(selector)
2466
- for selector in [
2467
- str(value or "").strip() for value in selectors if value not in (None, "")
2468
- ]
2773
+ for selector in action_selectors
2469
2774
  ]
2470
2775
  if not selected_rows:
2471
2776
  # No action target names the mount, so the condition field selects it.
@@ -2683,11 +2988,13 @@ def _repair_legacy_boolean_rule(cfg, token, form_number, metadata, requested,
2683
2988
  def _repair_first_option_rule(
2684
2989
  cfg, token, form_number, metadata, requested, *, rule_type, family,
2685
2990
  entity_id, trigger_field, markers, replacement_exists=False,
2991
+ family_marker=None,
2686
2992
  ):
2687
2993
  """Replace one legacy @first rule occupying the same semantic slot."""
2688
2994
  matches = [
2689
2995
  rule for rule in _all_rule_projections(metadata, rule_type)
2690
- if str(rule.get("Description") or "").startswith(family)
2996
+ if (str(rule.get("Description") or "").startswith(family)
2997
+ or (family_marker and family_marker in str(rule.get("Description") or "")))
2691
2998
  and "@first" in str(rule.get("PreDescription") or "")
2692
2999
  and (
2693
3000
  not str(rule.get("ItemId") or "").strip()
@@ -2741,10 +3048,78 @@ def _repair_first_option_rule(
2741
3048
  return result
2742
3049
 
2743
3050
 
3051
+ def _requested_action_ret(action_type, default_ret, events=None, add_events=None):
3052
+ if events is not None and add_events is not None:
3053
+ raise ValueError("RULE_EVENTS_CONFLICT: choose events or addEvents")
3054
+ if events is not None:
3055
+ return select_action_events(action_type, events)
3056
+ if add_events is not None:
3057
+ return merge_action_events(action_type, default_ret, add_events)
3058
+ return default_ret
3059
+
3060
+
3061
+ def _reconcile_existing_rule_events(
3062
+ cfg, token, form_number, rule, rule_type, events=None, add_events=None,
3063
+ ):
3064
+ """Apply a timing-only update without reconstructing any service or rule metadata."""
3065
+ if events is None and add_events is None:
3066
+ return None
3067
+ if events is not None and add_events is not None:
3068
+ raise ValueError("RULE_EVENTS_CONFLICT: choose events or addEvents")
3069
+ stored = copy.deepcopy(rule)
3070
+ rule_id = str(stored.get("Id") or "").strip()
3071
+ if not rule_id:
3072
+ raise ValueError("RULE_EVENT_RULE_ID_MISSING")
3073
+ for branch in ("TrueActions", "FalseActions"):
3074
+ for action in stored.get(branch) or []:
3075
+ current = action.get("RET")
3076
+ kind = action.get("ActionType")
3077
+ if events is not None:
3078
+ requested = select_action_events(kind, events)
3079
+ if current != requested:
3080
+ return {
3081
+ "status": False, "errorCode": "RULE_EVENT_REPLACE_REQUIRES_RULE_ID",
3082
+ "message": "Replacing timings on an existing rule requires an exact ruleId",
3083
+ "writeAttempted": False,
3084
+ "data": {"ruleId": rule_id, "actionType": kind,
3085
+ "currentRET": current, "requestedRET": requested},
3086
+ }
3087
+ if add_events is not None:
3088
+ action["RET"] = merge_action_events(kind, current, add_events)
3089
+ if _rule_matches_except_generated_action_ids(rule, stored):
3090
+ return None
3091
+ patch = {"ruleType": rule_type, "ruleId": rule_id,
3092
+ "trueActions": stored.get("TrueActions") or [],
3093
+ "falseActions": stored.get("FalseActions") or []}
3094
+ result = do_update_rule_verified(cfg, token, form_number, patch)
3095
+ if not _is_api_success(result):
3096
+ return result
3097
+ try:
3098
+ readback = do_load_full_metadata(cfg, token, form_number)
3099
+ except Exception as exc:
3100
+ readback = {"status": False, "message": str(exc)}
3101
+ observed = (_find_rule_projection(readback.get("data"), rule_id)
3102
+ if _is_api_success(readback) else None)
3103
+ if not _rule_matches_except_generated_action_ids(stored, observed):
3104
+ return {
3105
+ "status": False, "errorCode": "RULE_EVENT_READBACK_MISMATCH",
3106
+ "message": "Timing update could not verify preservation of the complete rule",
3107
+ "writeAttempted": True, "retryable": False,
3108
+ "data": {"changed": None, "ruleId": rule_id},
3109
+ "verification": {"verified": False, "preservationVerified": False},
3110
+ }
3111
+ result.setdefault("data", {}).update({"changed": True, "ruleId": rule_id})
3112
+ result["writeAttempted"] = True
3113
+ result.setdefault("verification", {}).update({
3114
+ "verified": True, "preservationVerified": True,
3115
+ })
3116
+ return result
3117
+
3118
+
2744
3119
  @mutation_contract
2745
3120
  def do_ensure_conditional_field_state(
2746
3121
  cfg, token, form_number, entity, condition_field, operator, value,
2747
- target, behavior,
3122
+ target, behavior, add_events=None, events=None,
2748
3123
  ):
2749
3124
  """Ensure one reversible conditional rule for one or many same-entity fields."""
2750
3125
  action_pair = _CONDITIONAL_STATE_ACTIONS.get(
@@ -2754,6 +3129,8 @@ def do_ensure_conditional_field_state(
2754
3129
  raise ValueError(
2755
3130
  "CONDITIONAL_FIELD_STATE_BEHAVIOR_UNSUPPORTED: use show, hide, lock, or unlock"
2756
3131
  )
3132
+ if events is not None and add_events is not None:
3133
+ raise ValueError("RULE_EVENTS_CONFLICT: choose exact events or add-events")
2757
3134
  targets = [target] if isinstance(target, str) else list(target or [])
2758
3135
  if not targets or any(not str(item or "").strip() for item in targets):
2759
3136
  raise ValueError("CONDITIONAL_FIELD_STATE_TARGET_REQUIRED")
@@ -2784,6 +3161,12 @@ def do_ensure_conditional_field_state(
2784
3161
  ))
2785
3162
  digest = hashlib.sha1(identity.encode("utf-8")).hexdigest()[:12]
2786
3163
  true_action, false_action = action_pair
3164
+ true_ret = (select_action_events(true_action, events) if events is not None
3165
+ else merge_action_events(true_action, 251, add_events)
3166
+ if add_events is not None else 251)
3167
+ false_ret = (select_action_events(false_action, events) if events is not None
3168
+ else merge_action_events(false_action, 251, add_events)
3169
+ if add_events is not None else 251)
2787
3170
  condition_phrase = (
2788
3171
  f"{condition_name} {'为空' if compiled_operator == 'empty' else '不为空'}"
2789
3172
  if compiled_operator in {"empty", "not-empty"}
@@ -2811,11 +3194,13 @@ def do_ensure_conditional_field_state(
2811
3194
  "actionType": true_action,
2812
3195
  "description": f"{behavior_label}{target_phrase}",
2813
3196
  "fields": target_keys,
3197
+ "ret": true_ret,
2814
3198
  }],
2815
3199
  "falseActions": [{
2816
3200
  "actionType": false_action,
2817
3201
  "description": f"{inverse_label}{target_phrase}",
2818
3202
  "fields": target_keys,
3203
+ "ret": false_ret,
2819
3204
  }],
2820
3205
  }
2821
3206
 
@@ -2834,6 +3219,12 @@ def do_ensure_conditional_field_state(
2834
3219
  )
2835
3220
  expected = normalize_rule_config(requested)
2836
3221
  if matches:
3222
+ if add_events is not None:
3223
+ for branch, stored_branch in (("trueActions", "TrueActions"),
3224
+ ("falseActions", "FalseActions")):
3225
+ actions = matches[0].get(stored_branch) or []
3226
+ if len(actions) == 1 and "RET" in actions[0]:
3227
+ expected[branch][0]["RET"] = actions[0]["RET"]
2837
3228
  mismatches = rule_readback_mismatches(expected, matches[0], metadata)
2838
3229
  if mismatches:
2839
3230
  raise ValueError(
@@ -2841,6 +3232,47 @@ def do_ensure_conditional_field_state(
2841
3232
  f"already exists with different metadata; mismatches="
2842
3233
  f"{json.dumps(mismatches, ensure_ascii=False)}"
2843
3234
  )
3235
+ if add_events is not None:
3236
+ stored = copy.deepcopy(matches[0])
3237
+ rule_id = str(stored.get("Id") or "").strip()
3238
+ if not rule_id:
3239
+ raise ValueError("RULE_EVENT_RULE_ID_MISSING")
3240
+ for branch in ("TrueActions", "FalseActions"):
3241
+ action = stored[branch][0]
3242
+ action["RET"] = merge_action_events(
3243
+ action["ActionType"], action["RET"], add_events,
3244
+ )
3245
+ if _rule_matches_except_generated_action_ids(matches[0], stored):
3246
+ return {"status": True, "writeAttempted": False,
3247
+ "data": {"changed": False, "ruleId": rule_id},
3248
+ "verification": {"verified": True, "preservationVerified": True}}
3249
+ result = do_update_rule_verified(cfg, token, form_number, {
3250
+ "ruleType": "formRule", "ruleId": rule_id,
3251
+ "trueActions": stored["TrueActions"],
3252
+ "falseActions": stored["FalseActions"],
3253
+ })
3254
+ if not _is_api_success(result):
3255
+ return result
3256
+ try:
3257
+ readback = do_load_full_metadata(cfg, token, form_number)
3258
+ except Exception as exc:
3259
+ readback = {"status": False, "message": str(exc)}
3260
+ observed = (_find_rule_projection(readback.get("data"), rule_id)
3261
+ if _is_api_success(readback) else None)
3262
+ if not _rule_matches_except_generated_action_ids(stored, observed):
3263
+ return {"status": False, "errorCode": "RULE_EVENT_READBACK_MISMATCH",
3264
+ "message": "Event update could not verify preservation of the complete rule",
3265
+ "writeAttempted": True, "retryable": False,
3266
+ "data": {"changed": None, "ruleId": rule_id},
3267
+ "verification": {"verified": False, "preservationVerified": False}}
3268
+ result.setdefault("data", {}).update({"changed": True, "ruleId": rule_id})
3269
+ result["writeAttempted"] = True
3270
+ result.setdefault("verification", {}).update({
3271
+ "verified": True, "preservationVerified": True,
3272
+ "trueRET": stored["TrueActions"][0]["RET"],
3273
+ "falseRET": stored["FalseActions"][0]["RET"],
3274
+ })
3275
+ return result
2844
3276
  cleaned = _repair_first_option_rule(
2845
3277
  cfg, token, form_number, metadata, requested,
2846
3278
  rule_type="FormRule", family="ConditionalFieldState_",
@@ -2966,7 +3398,7 @@ def do_ensure_conditional_field_state(
2966
3398
 
2967
3399
  def do_ensure_conditional_control_state(
2968
3400
  cfg, token, form_number, entity, condition_field, operator, value,
2969
- target_control, behavior,
3401
+ target_control, behavior, add_events=None, events=None,
2970
3402
  ):
2971
3403
  """Ensure one reversible conditional control hide/lock rule."""
2972
3404
  action_pair = _CONDITIONAL_CONTROL_ACTIONS.get(
@@ -3003,6 +3435,8 @@ def do_ensure_conditional_control_state(
3003
3435
  identity.encode("utf-8")
3004
3436
  ).hexdigest()[:12]
3005
3437
  true_action, false_action = action_pair
3438
+ true_ret = _requested_action_ret(true_action, 251, events, add_events)
3439
+ false_ret = _requested_action_ret(false_action, 251, events, add_events)
3006
3440
  requested = {
3007
3441
  "ruleType": "formRule",
3008
3442
  "itemId": context["entityId"],
@@ -3019,11 +3453,13 @@ def do_ensure_conditional_control_state(
3019
3453
  "actionType": true_action,
3020
3454
  "description": f"{str(behavior).strip().casefold()} {control_name}",
3021
3455
  "fields": [control_key],
3456
+ "ret": true_ret,
3022
3457
  }],
3023
3458
  "falseActions": [{
3024
3459
  "actionType": false_action,
3025
3460
  "description": f"restore {control_name}",
3026
3461
  "fields": [control_key],
3462
+ "ret": false_ret,
3027
3463
  }],
3028
3464
  }
3029
3465
  current = do_load_full_metadata(cfg, token, form_number)
@@ -3041,6 +3477,15 @@ def do_ensure_conditional_control_state(
3041
3477
  )
3042
3478
  expected = normalize_rule_config(requested)
3043
3479
  if matches:
3480
+ timing_result = _reconcile_existing_rule_events(
3481
+ cfg, token, form_number, matches[0], "formRule", events, add_events,
3482
+ )
3483
+ if timing_result is not None:
3484
+ return timing_result
3485
+ if events is not None or add_events is not None:
3486
+ for branch, stored_branch in (("trueActions", "TrueActions"),
3487
+ ("falseActions", "FalseActions")):
3488
+ expected[branch][0]["RET"] = matches[0][stored_branch][0]["RET"]
3044
3489
  mismatches = rule_readback_mismatches(expected, matches[0], metadata)
3045
3490
  if mismatches:
3046
3491
  raise ValueError(
@@ -3312,6 +3757,7 @@ def do_ensure_conditional_client_appearance(cfg, token, form_number, request):
3312
3757
  raise ValueError("CONDITIONAL_CLIENT_APPEARANCE_INPUT_INVALID: input must be an object")
3313
3758
  allowed = {
3314
3759
  "clientMount", "entity", "conditionField", "operator", "value", "effects",
3760
+ "events", "addEvents",
3315
3761
  }
3316
3762
  unknown = sorted(set(request) - allowed)
3317
3763
  if unknown:
@@ -3352,6 +3798,10 @@ def do_ensure_conditional_client_appearance(cfg, token, form_number, request):
3352
3798
  description = "ConditionalClientAppearance_" + hashlib.sha1(
3353
3799
  identity.encode("utf-8")
3354
3800
  ).hexdigest()[:12]
3801
+ for action in actions:
3802
+ action["ret"] = _requested_action_ret(
3803
+ action["actionType"], 251, request.get("events"), request.get("addEvents"),
3804
+ )
3355
3805
  requested = {
3356
3806
  "ruleType": "formRule",
3357
3807
  "clientMount": client_mount,
@@ -3382,9 +3832,19 @@ def do_ensure_conditional_client_appearance(cfg, token, form_number, request):
3382
3832
  cfg, token, form_number, requested
3383
3833
  )
3384
3834
  if matches:
3385
- mismatches = rule_readback_mismatches(
3386
- normalize_rule_config(resolved_requested), matches[0], metadata
3835
+ timing_result = _reconcile_existing_rule_events(
3836
+ cfg, token, form_number, matches[0], "formRule",
3837
+ request.get("events"), request.get("addEvents"),
3387
3838
  )
3839
+ if timing_result is not None:
3840
+ return timing_result
3841
+ expected = normalize_rule_config(resolved_requested)
3842
+ if request.get("events") is not None or request.get("addEvents") is not None:
3843
+ for action, stored_action in zip(
3844
+ expected.get("trueActions") or [], matches[0].get("TrueActions") or [],
3845
+ ):
3846
+ action["RET"] = stored_action["RET"]
3847
+ mismatches = rule_readback_mismatches(expected, matches[0], metadata)
3388
3848
  if not mismatches:
3389
3849
  return {
3390
3850
  "status": True, "errorCode": "0",
@@ -3416,11 +3876,159 @@ def do_ensure_conditional_client_appearance(cfg, token, form_number, request):
3416
3876
  return result
3417
3877
 
3418
3878
 
3879
+ def do_ensure_conditional_field_change(
3880
+ cfg, token, form_number, entity, condition_field, operator, value,
3881
+ target, add_events=None, events=None,
3882
+ ):
3883
+ """Ensure one conditional field-change service action with typed selectors."""
3884
+ if events is not None and add_events is not None:
3885
+ raise ValueError("RULE_EVENTS_CONFLICT: choose exact events or add-events")
3886
+ event_ret = (select_action_events("CallFieldChangeAction", events)
3887
+ if events is not None else
3888
+ merge_action_events("CallFieldChangeAction", 773, add_events)
3889
+ if add_events is not None else 773)
3890
+ context = _resolve_conditional_context(
3891
+ cfg, token, form_number, entity, condition_field, operator, value,
3892
+ target=target,
3893
+ )
3894
+ target_key = context["targetKey"]
3895
+ target_name = context["targetName"]
3896
+ condition_key = context["conditionKey"]
3897
+ condition_name = context["conditionName"]
3898
+ condition_phrase = (
3899
+ f"{condition_name}{'为空' if context['operator'] == 'empty' else '不为空'}"
3900
+ if context["operator"] in {"empty", "not-empty"}
3901
+ else f"{condition_name} {context['operator']} {context['requestedValue']}"
3902
+ )
3903
+ pre_description = f"{condition_phrase}时调用{target_name}字段值更新服务"
3904
+ identity = ":".join((form_number, context["entityKey"], context["expression"], target_key))
3905
+ tagged_description = pre_description + " [CFC_" + hashlib.sha1(
3906
+ identity.encode("utf-8")
3907
+ ).hexdigest()[:12] + "]"
3908
+ description = pre_description
3909
+ requested = {
3910
+ "ruleType": "bizRule", "itemId": context["entityId"],
3911
+ "mountPoint": context["entityKey"], "description": description,
3912
+ "preDescription": pre_description,
3913
+ "preCondition": context["expression"],
3914
+ "triggerField": f"{condition_name}({condition_key})",
3915
+ "enabled": True,
3916
+ "trueActions": [{
3917
+ "actionType": "CallFieldChangeAction", "expression": target_key,
3918
+ "description": f"调用{target_name}字段值更新服务", "ret": event_ret,
3919
+ }], "falseActions": [],
3920
+ }
3921
+ current = do_load_full_metadata(cfg, token, form_number)
3922
+ if not _is_api_success(current):
3923
+ raise RuntimeError("CONDITIONAL_FIELD_CHANGE_READ_FAILED: "
3924
+ + (_extract_error_message(current) or "loadFullMetadata failed"))
3925
+ metadata = current.get("data")
3926
+ related = [
3927
+ rule for rule in _all_rule_projections(metadata, "BizRule")
3928
+ if str(rule.get("TriggerField") or "") == requested["triggerField"]
3929
+ and str(rule.get("ItemId") or context["entityId"]) == context["entityId"]
3930
+ and not (rule.get("FalseActions") or [])
3931
+ and len(rule.get("TrueActions") or []) == 1
3932
+ and (rule.get("TrueActions") or [{}])[0].get("ActionType") == "CallFieldChangeAction"
3933
+ and str((rule.get("TrueActions") or [{}])[0].get("Expression") or "") == target_key
3934
+ ]
3935
+ matches = [rule for rule in related
3936
+ if str(rule.get("PreCondition") or "") == requested["preCondition"]]
3937
+ if len(matches) > 1:
3938
+ raise ValueError("CONDITIONAL_FIELD_CHANGE_DUPLICATE_RULES: select the intended ruleId")
3939
+ if not matches and related:
3940
+ return {
3941
+ "status": False, "errorCode": "RULE_CONDITION_UPDATE_REQUIRES_RULE_ID",
3942
+ "message": "A rule already calls the same field from this trigger; select the existing ruleId before changing its condition",
3943
+ "writeAttempted": False,
3944
+ "data": {"candidates": [{"ruleId": rule.get("Id"),
3945
+ "preCondition": rule.get("PreCondition")}
3946
+ for rule in related]},
3947
+ }
3948
+ if matches:
3949
+ stored = copy.deepcopy(matches[0])
3950
+ rule_id = str(stored.get("Id") or "").strip()
3951
+ if not rule_id:
3952
+ raise ValueError("CONDITIONAL_FIELD_CHANGE_RULE_ID_MISSING")
3953
+ generated_name = stored.get("Description") == tagged_description
3954
+ if generated_name:
3955
+ stored["Description"] = description
3956
+ if events is not None and stored["TrueActions"][0].get("RET") != event_ret:
3957
+ return {"status": False, "errorCode": "RULE_EVENT_REPLACE_REQUIRES_RULE_ID",
3958
+ "message": "Replacing timings on an existing rule requires an exact ruleId",
3959
+ "writeAttempted": False,
3960
+ "data": {"ruleId": rule_id, "currentRET": stored["TrueActions"][0].get("RET"),
3961
+ "requestedRET": event_ret}}
3962
+ if add_events is not None:
3963
+ action = stored["TrueActions"][0]
3964
+ action["RET"] = merge_action_events(
3965
+ "CallFieldChangeAction", action["RET"], add_events,
3966
+ )
3967
+ if not _rule_matches_except_generated_action_ids(matches[0], stored):
3968
+ patch = {"ruleType": "bizRule", "ruleId": rule_id}
3969
+ if generated_name:
3970
+ patch["description"] = description
3971
+ if stored["TrueActions"] != matches[0].get("TrueActions"):
3972
+ patch["trueActions"] = stored["TrueActions"]
3973
+ result = do_update_rule_verified(cfg, token, form_number, patch)
3974
+ if not _is_api_success(result):
3975
+ return result
3976
+ try:
3977
+ readback = do_load_full_metadata(cfg, token, form_number)
3978
+ except Exception as exc:
3979
+ readback = {"status": False, "message": str(exc)}
3980
+ observed = (_find_rule_projection(readback.get("data"), rule_id)
3981
+ if _is_api_success(readback) else None)
3982
+ if not _rule_matches_except_generated_action_ids(stored, observed):
3983
+ return {"status": False, "errorCode": "RULE_EVENT_READBACK_MISMATCH",
3984
+ "message": "Field-change event update could not verify the complete rule",
3985
+ "writeAttempted": True, "retryable": False,
3986
+ "data": {"changed": None, "ruleId": rule_id},
3987
+ "verification": {"verified": False, "preservationVerified": False}}
3988
+ result.setdefault("data", {}).update({
3989
+ "changed": True, "ruleId": rule_id,
3990
+ "targetField": target_key, "condition": context["expression"],
3991
+ })
3992
+ result["writeAttempted"] = True
3993
+ result.setdefault("verification", {}).update({
3994
+ "verified": True, "preservationVerified": True,
3995
+ "ret": stored["TrueActions"][0]["RET"],
3996
+ })
3997
+ return result
3998
+ return {
3999
+ "status": True, "errorCode": "0", "writeAttempted": False,
4000
+ "data": {"changed": False, "ruleId": rule_id,
4001
+ "targetField": target_key, "condition": context["expression"]},
4002
+ "verification": {"scope": "design-metadata", "verified": True,
4003
+ "preservationVerified": True},
4004
+ "runtimeVerification": {"verified": False,
4005
+ "warnings": runtime_action_warnings(requested)},
4006
+ }
4007
+ result = do_add_rule_verified(cfg, token, form_number, requested)
4008
+ if isinstance(result, dict):
4009
+ result["writeAttempted"] = True
4010
+ data = result.setdefault("data", {})
4011
+ if isinstance(data, dict):
4012
+ data.update({"changed": True if _is_api_success(result) else None,
4013
+ "entityKey": context["entityKey"],
4014
+ "condition": context["expression"],
4015
+ "targetField": target_key})
4016
+ return result
4017
+
4018
+
3419
4019
  def do_ensure_conditional_clear_fields(
3420
4020
  cfg, token, form_number, entity, condition_field, operator, value,
3421
- targets,
4021
+ targets, add_events=None, events=None,
3422
4022
  ):
3423
4023
  """Ensure one entity-scoped conditional ClearValueAction rule."""
4024
+ if events is not None and add_events is not None:
4025
+ raise ValueError("RULE_EVENTS_CONFLICT: choose exact events or add-events")
4026
+ event_ret = (
4027
+ select_action_events("ClearValueAction", events)
4028
+ if events is not None else
4029
+ merge_action_events("ClearValueAction", 253, add_events)
4030
+ if add_events is not None else 253
4031
+ )
3424
4032
  selectors = [str(item or "").strip() for item in (targets or [])]
3425
4033
  selectors = [item for item in selectors if item]
3426
4034
  if not selectors:
@@ -3445,18 +4053,20 @@ def do_ensure_conditional_clear_fields(
3445
4053
  identity = ":".join((
3446
4054
  form_number, context["entityKey"], context["expression"], expression,
3447
4055
  ))
3448
- description = "ConditionalClearFields_" + hashlib.sha1(
3449
- identity.encode("utf-8")
3450
- ).hexdigest()[:12]
4056
+ digest = hashlib.sha1(identity.encode("utf-8")).hexdigest()[:12]
4057
+ legacy_description = "ConditionalClearFields_" + digest
4058
+ pre_description = (
4059
+ f"{context['conditionName']} {context['operator']} "
4060
+ f"{context['requestedValue']} 时清空{'、'.join(target_names)}"
4061
+ )
4062
+ tagged_description = f"{pre_description} [CCF_{digest}]"
4063
+ description = pre_description
3451
4064
  requested = {
3452
4065
  "ruleType": "bizRule",
3453
4066
  "itemId": context["entityId"],
3454
4067
  "mountPoint": context["entityKey"],
3455
4068
  "description": description,
3456
- "preDescription": (
3457
- f"{context['conditionName']} {context['operator']} "
3458
- f"{context['requestedValue']} 时清空{'、'.join(target_names)}"
3459
- ),
4069
+ "preDescription": pre_description,
3460
4070
  "preCondition": context["expression"],
3461
4071
  "triggerField": f"{context['conditionName']}({context['conditionKey']})",
3462
4072
  "enabled": True,
@@ -3464,7 +4074,7 @@ def do_ensure_conditional_clear_fields(
3464
4074
  "actionType": "ClearValueAction",
3465
4075
  "description": "清除指定字段值",
3466
4076
  "expression": expression,
3467
- "ret": 253,
4077
+ "ret": event_ret,
3468
4078
  }],
3469
4079
  "falseActions": [],
3470
4080
  }
@@ -3473,7 +4083,8 @@ def do_ensure_conditional_clear_fields(
3473
4083
  requested_targets = set(target_keys)
3474
4084
  predecessors = []
3475
4085
  for rule in _all_rule_projections(metadata, "BizRule"):
3476
- if not str(rule.get("Description") or "").startswith("ConditionalClearFields_"):
4086
+ text = str(rule.get("Description") or "")
4087
+ if not (text.startswith("ConditionalClearFields_") or " [CCF_" in text):
3477
4088
  continue
3478
4089
  if str(rule.get("PreCondition") or "") != requested["preCondition"]:
3479
4090
  continue
@@ -3506,7 +4117,16 @@ def do_ensure_conditional_clear_fields(
3506
4117
  + (_extract_error_message(current) or "loadFullMetadata failed")
3507
4118
  )
3508
4119
  metadata = current.get("data")
3509
- matches = _rule_projections_by_description(metadata, description)
4120
+ matches = [
4121
+ rule for rule in _all_rule_projections(metadata, "BizRule")
4122
+ if str(rule.get("PreCondition") or "") == requested["preCondition"]
4123
+ and str(rule.get("TriggerField") or "") == requested["triggerField"]
4124
+ and str(rule.get("ItemId") or context["entityId"]) == context["entityId"]
4125
+ and not (rule.get("FalseActions") or [])
4126
+ and len(rule.get("TrueActions") or []) == 1
4127
+ and (rule.get("TrueActions") or [{}])[0].get("ActionType") == "ClearValueAction"
4128
+ and str((rule.get("TrueActions") or [{}])[0].get("Expression") or "") == expression
4129
+ ]
3510
4130
  if len(matches) > 1:
3511
4131
  raise ValueError(
3512
4132
  "CONDITIONAL_CLEAR_FIELDS_DUPLICATE_RULES: "
@@ -3514,6 +4134,22 @@ def do_ensure_conditional_clear_fields(
3514
4134
  )
3515
4135
  expected = normalize_rule_config(requested)
3516
4136
  if matches:
4137
+ current_ret = (matches[0].get("TrueActions") or [{}])[0].get("RET")
4138
+ if events is not None and current_ret != event_ret:
4139
+ return {
4140
+ "status": False, "errorCode": "RULE_EVENT_REPLACE_REQUIRES_RULE_ID",
4141
+ "message": "Replacing timings on an existing rule requires an exact ruleId",
4142
+ "writeAttempted": False,
4143
+ "data": {"ruleId": matches[0].get("Id"),
4144
+ "currentRET": current_ret, "requestedRET": event_ret},
4145
+ }
4146
+ expected["description"] = matches[0].get("Description")
4147
+ expected["preDescription"] = matches[0].get("PreDescription")
4148
+ current_actions = matches[0].get("TrueActions") or []
4149
+ if len(current_actions) == 1 and "RET" in current_actions[0]:
4150
+ expected["trueActions"][0]["RET"] = current_actions[0]["RET"]
4151
+ expected["trueActions"][0]["Description"] = current_actions[0].get("Description")
4152
+ expected["trueActions"][0]["Seq"] = current_actions[0].get("Seq", 1)
3517
4153
  mismatches = rule_readback_mismatches(expected, matches[0], metadata)
3518
4154
  if mismatches:
3519
4155
  raise ValueError(
@@ -3521,11 +4157,64 @@ def do_ensure_conditional_clear_fields(
3521
4157
  f"already exists with different metadata; mismatches="
3522
4158
  f"{json.dumps(mismatches, ensure_ascii=False)}"
3523
4159
  )
4160
+ generated_name = matches[0].get("Description") in {
4161
+ legacy_description, tagged_description,
4162
+ }
4163
+ if add_events is not None or generated_name:
4164
+ stored = copy.deepcopy(matches[0])
4165
+ rule_id = str(stored.get("Id") or "").strip()
4166
+ if not rule_id:
4167
+ raise ValueError("RULE_EVENT_RULE_ID_MISSING")
4168
+ if generated_name:
4169
+ stored["Description"] = description
4170
+ if add_events is not None:
4171
+ action = stored["TrueActions"][0]
4172
+ action["RET"] = merge_action_events(
4173
+ "ClearValueAction", action["RET"], add_events,
4174
+ )
4175
+ if stored == matches[0]:
4176
+ return {
4177
+ "status": True, "writeAttempted": False,
4178
+ "data": {"changed": False, "ruleId": rule_id},
4179
+ "verification": {"verified": True, "preservationVerified": True},
4180
+ }
4181
+ patch = {"ruleType": "bizRule", "ruleId": rule_id}
4182
+ if generated_name:
4183
+ patch["description"] = description
4184
+ if stored["TrueActions"] != matches[0].get("TrueActions"):
4185
+ patch["trueActions"] = stored["TrueActions"]
4186
+ result = do_update_rule_verified(cfg, token, form_number, patch)
4187
+ if not _is_api_success(result):
4188
+ return result
4189
+ try:
4190
+ readback = do_load_full_metadata(cfg, token, form_number)
4191
+ except Exception as exc:
4192
+ readback = {"status": False, "message": str(exc)}
4193
+ observed = (
4194
+ _find_rule_projection(readback.get("data"), rule_id)
4195
+ if _is_api_success(readback) else None
4196
+ )
4197
+ if not _rule_matches_except_generated_action_ids(stored, observed):
4198
+ return {
4199
+ "status": False, "errorCode": "RULE_EVENT_READBACK_MISMATCH",
4200
+ "message": "Event update could not verify preservation of the complete rule",
4201
+ "writeAttempted": True, "retryable": False,
4202
+ "data": {"changed": None, "ruleId": rule_id},
4203
+ "verification": {"verified": False, "preservationVerified": False},
4204
+ }
4205
+ result.setdefault("data", {}).update({"changed": True, "ruleId": rule_id})
4206
+ result["writeAttempted"] = True
4207
+ result.setdefault("verification", {}).update({
4208
+ "verified": True, "preservationVerified": True,
4209
+ "ret": stored["TrueActions"][0]["RET"],
4210
+ })
4211
+ return result
3524
4212
  cleaned = _repair_first_option_rule(
3525
4213
  cfg, token, form_number, metadata, requested,
3526
4214
  rule_type="BizRule", family="ConditionalClearFields_",
3527
4215
  entity_id=context["entityId"], trigger_field=requested["triggerField"],
3528
4216
  markers=["清空", *target_names], replacement_exists=True,
4217
+ family_marker=" [CCF_",
3529
4218
  )
3530
4219
  if cleaned is not None:
3531
4220
  return cleaned
@@ -3560,7 +4249,7 @@ def do_ensure_conditional_clear_fields(
3560
4249
  cfg, token, form_number, metadata, requested,
3561
4250
  rule_type="BizRule", family="ConditionalClearFields_",
3562
4251
  entity_id=context["entityId"], trigger_field=requested["triggerField"],
3563
- markers=["清空", *target_names],
4252
+ markers=["清空", *target_names], family_marker=" [CCF_",
3564
4253
  )
3565
4254
  if migrated is not None:
3566
4255
  return migrated
@@ -3594,7 +4283,7 @@ def do_ensure_conditional_clear_fields(
3594
4283
 
3595
4284
  def do_ensure_conditional_calculation(
3596
4285
  cfg, token, form_number, entity, condition_field, operator, value,
3597
- target, left, calculation, right,
4286
+ target, left, calculation, right, add_events=None, events=None,
3598
4287
  ):
3599
4288
  """Ensure one conditional same-entity binary FormulaAction."""
3600
4289
  symbol = _CALCULATION_OPERATORS.get(
@@ -3664,10 +4353,14 @@ def do_ensure_conditional_calculation(
3664
4353
  "actionType": "FormulaAction",
3665
4354
  "description": f"{target_name} = {left_name} {symbol} {right_name}",
3666
4355
  "expression": formula,
3667
- "ret": 253,
4356
+ "ret": _requested_action_ret("FormulaAction", 253, events, add_events),
3668
4357
  }],
3669
4358
  "falseActions": [],
3670
4359
  }
4360
+ legacy_description = description
4361
+ tagged_description = f"{requested['preDescription']} [CALC_{legacy_description.rsplit('_', 1)[1]}]"
4362
+ description = requested["preDescription"]
4363
+ requested["description"] = description
3671
4364
  current = do_load_full_metadata(cfg, token, form_number)
3672
4365
  if not _is_api_success(current):
3673
4366
  raise RuntimeError(
@@ -3675,17 +4368,41 @@ def do_ensure_conditional_calculation(
3675
4368
  + (_extract_error_message(current) or "loadFullMetadata failed")
3676
4369
  )
3677
4370
  metadata = current.get("data")
3678
- matches = _rule_projections_by_description(metadata, description)
4371
+ matches = [
4372
+ rule for rule in _all_rule_projections(metadata, "BizRule")
4373
+ if str(rule.get("PreCondition") or "") == requested["preCondition"]
4374
+ and str(rule.get("TriggerField") or "") == requested["triggerField"]
4375
+ and str(rule.get("ItemId") or context["entityId"]) == context["entityId"]
4376
+ and not (rule.get("FalseActions") or [])
4377
+ and len(rule.get("TrueActions") or []) == 1
4378
+ and (rule.get("TrueActions") or [{}])[0].get("ActionType") == "FormulaAction"
4379
+ and str((rule.get("TrueActions") or [{}])[0].get("Expression") or "") == formula
4380
+ ]
3679
4381
  if len(matches) > 1:
3680
4382
  raise ValueError("CONDITIONAL_CALCULATION_DUPLICATE_RULES")
3681
4383
  expected = normalize_rule_config(requested)
3682
4384
  if matches:
4385
+ timing_result = _reconcile_existing_rule_events(
4386
+ cfg, token, form_number, matches[0], "bizRule", events, add_events,
4387
+ )
4388
+ if timing_result is not None:
4389
+ return timing_result
4390
+ if events is not None or add_events is not None:
4391
+ expected["trueActions"][0]["RET"] = matches[0]["TrueActions"][0]["RET"]
4392
+ expected["description"] = matches[0].get("Description")
4393
+ expected["preDescription"] = matches[0].get("PreDescription")
3683
4394
  mismatches = rule_readback_mismatches(expected, matches[0], metadata)
3684
4395
  if mismatches:
3685
4396
  raise ValueError(
3686
4397
  "CONDITIONAL_CALCULATION_CONFLICT: "
3687
4398
  + json.dumps(mismatches, ensure_ascii=False)
3688
4399
  )
4400
+ renamed = _upgrade_generated_rule_description(
4401
+ cfg, token, form_number, matches[0],
4402
+ (legacy_description, tagged_description), description,
4403
+ )
4404
+ if renamed is not None:
4405
+ return renamed
3689
4406
  return {
3690
4407
  "status": True, "errorCode": "0",
3691
4408
  "message": "Conditional calculation already matches",
@@ -3777,6 +4494,7 @@ def _quantity_conversion_rule_matches(rule, source_key, target_keys):
3777
4494
 
3778
4495
  def do_ensure_quantity_conversion(
3779
4496
  cfg, token, form_number, entity, source_qty, target_qty,
4497
+ add_events=None, events=None,
3780
4498
  ):
3781
4499
  """Ensure one FillQtyAction from a source QtyField to one or more targets."""
3782
4500
  raw_targets = [target_qty] if isinstance(target_qty, str) else (target_qty or [])
@@ -3852,11 +4570,18 @@ def do_ensure_quantity_conversion(
3852
4570
  "actionType": "FillQtyAction",
3853
4571
  "description": "根据数量关联的单位自动计算目标数量",
3854
4572
  "expression": expression,
3855
- "ret": 249,
4573
+ "ret": _requested_action_ret("FillQtyAction", 249, events, add_events),
3856
4574
  }],
3857
4575
  "falseActions": [],
3858
4576
  }
3859
- descriptions = _rule_projections_by_description(metadata, description)
4577
+ legacy_description = description
4578
+ tagged_description = f"{requested['preDescription']} [QCV_{legacy_description.rsplit('_', 1)[1]}]"
4579
+ description = requested["preDescription"]
4580
+ requested["description"] = description
4581
+ descriptions = [
4582
+ *_rule_projections_by_description(metadata, legacy_description),
4583
+ *_rule_projections_by_description(metadata, tagged_description),
4584
+ ]
3860
4585
  semantic = [
3861
4586
  rule for rule in _all_rule_projections(metadata, "BizRule")
3862
4587
  if _quantity_conversion_rule_matches(rule, source_key, target_keys)
@@ -3866,6 +4591,17 @@ def do_ensure_quantity_conversion(
3866
4591
  if descriptions and not semantic:
3867
4592
  raise ValueError("QUANTITY_CONVERSION_CONFLICT")
3868
4593
  if semantic:
4594
+ timing_result = _reconcile_existing_rule_events(
4595
+ cfg, token, form_number, semantic[0], "bizRule", events, add_events,
4596
+ )
4597
+ if timing_result is not None:
4598
+ return timing_result
4599
+ renamed = _upgrade_generated_rule_description(
4600
+ cfg, token, form_number, semantic[0],
4601
+ (legacy_description, tagged_description), description,
4602
+ )
4603
+ if renamed is not None:
4604
+ return renamed
3869
4605
  return {
3870
4606
  "status": True, "errorCode": "0",
3871
4607
  "message": "Quantity conversion already matches",
@@ -3939,25 +4675,32 @@ def _reference_entity_field_candidates(cfg, token, base_entity_id):
3939
4675
  data = inventory.get("data") or {}
3940
4676
  header = data.get("headerFields") or {}
3941
4677
  entries = data.get("entryFields") or {}
3942
- groups = [header, *(entries.values() if isinstance(entries, dict) else [])]
4678
+ groups = [(header, False), *((group, True) for group in (entries.values() if isinstance(entries, dict) else []))]
3943
4679
  return [
3944
- field for group in groups if isinstance(group, dict)
4680
+ {**field, "referenceCollection": collection,
4681
+ "referenceEntityKey": group.get("entryKey") or group.get("headerKey")}
4682
+ for group, collection in groups if isinstance(group, dict)
3945
4683
  for field in (group.get("fields") or []) if isinstance(field, dict)
3946
4684
  ]
3947
4685
 
3948
4686
 
3949
4687
  def _ensure_reference_property_for_carry(
3950
- cfg, token, form_number, source_key, requested_property,
4688
+ cfg, token, form_number, source_key, requested_property, write=True,
4689
+ target_row=None, source_row=None, inventory_cache=None,
3951
4690
  ):
3952
- state = do_get_properties(
3953
- cfg, token, form_number, source_key, ["RefProps", "BaseEntityId"]
3954
- )
4691
+ cache = inventory_cache if inventory_cache is not None else {}
4692
+ state_key = ("properties", source_key)
4693
+ if state_key not in cache:
4694
+ cache[state_key] = do_get_properties(
4695
+ cfg, token, form_number, source_key, ["RefProps", "BaseEntityId"]
4696
+ )
4697
+ state = cache[state_key]
3955
4698
  ref_props = _parse_ref_props_value(state)
3956
4699
  existing = [
3957
4700
  name for name in ref_props
3958
4701
  if name.casefold() == requested_property.casefold()
3959
4702
  ]
3960
- if len(existing) == 1:
4703
+ if len(existing) == 1 and target_row is None:
3961
4704
  return existing[0], None
3962
4705
  data = state.get("data") or {}
3963
4706
  base_entity_id = str(
@@ -3969,12 +4712,13 @@ def _ensure_reference_property_for_carry(
3969
4712
  f"requested={requested_property!r}, available={ref_props!r}; the "
3970
4713
  "source reference does not expose one BaseEntityId for exact discovery"
3971
4714
  )
3972
- candidates = _reference_entity_field_candidates(
3973
- cfg, token, base_entity_id
3974
- )
4715
+ if base_entity_id not in cache:
4716
+ cache[base_entity_id] = _reference_entity_field_candidates(cfg, token, base_entity_id)
4717
+ candidates = cache[base_entity_id]
4718
+ property_selector = existing[0] if len(existing) == 1 else requested_property
3975
4719
  exact_key = [
3976
4720
  row for row in candidates
3977
- if str(row.get("key") or "").strip() == requested_property
4721
+ if str(row.get("key") or "").strip() == property_selector
3978
4722
  ]
3979
4723
  matches = exact_key or [
3980
4724
  row for row in candidates
@@ -3987,7 +4731,23 @@ def _ensure_reference_property_for_carry(
3987
4731
  f"{json.dumps([{'key': row.get('key'), 'name': row.get('name'), 'type': row.get('type')} for row in candidates], ensure_ascii=False)}"
3988
4732
  )
3989
4733
  property_key = str(matches[0].get("key") or "").strip()
4734
+ if target_row is not None:
4735
+ collection = (matches[0].get("referenceCollection")
4736
+ or str(matches[0].get("type") or "").startswith("Mul")
4737
+ or str((source_row or {}).get("type") or "").startswith("Mul"))
4738
+ if collection:
4739
+ raise ValueError(
4740
+ "CARRY_REFERENCE_COLLECTION_UNSUPPORTED: "
4741
+ f"source={source_key}.{property_key}, sourceEntity={matches[0].get('referenceEntityKey')!r}, "
4742
+ f"target={target_row.get('key')!r}, targetType={target_row.get('type')!r}; "
4743
+ "the source projects a collection, not one scalar value. No RefProps or rule was written. "
4744
+ "Use a plugin with an explicit business selection/aggregation policy; do not take the first row implicitly."
4745
+ )
4746
+ if property_key in ref_props:
4747
+ return property_key, None
3990
4748
  updated = [{"name": name} for name in [*ref_props, property_key]]
4749
+ if not write:
4750
+ return property_key, updated
3991
4751
  written = do_set_ref_props(
3992
4752
  cfg, token, form_number, source_key, updated
3993
4753
  )
@@ -4036,6 +4796,7 @@ def _take_base_rule_matches(
4036
4796
  def do_ensure_conditional_carry_reference_property(
4037
4797
  cfg, token, form_number, entity, condition_field, operator, value,
4038
4798
  source_reference, source_property, target, only_empty_target=False,
4799
+ add_events=None, events=None,
4039
4800
  ):
4040
4801
  """Ensure one conditional TakeBaseProAction from an existing RefProps declaration."""
4041
4802
  if condition_field in (None, ""):
@@ -4060,12 +4821,13 @@ def do_ensure_conditional_carry_reference_property(
4060
4821
  requested_property = str(source_property or "").strip()
4061
4822
  if not requested_property:
4062
4823
  raise ValueError("CONDITIONAL_CARRY_SOURCE_PROPERTY_REQUIRED")
4063
- property_key, ref_props_write = _ensure_reference_property_for_carry(
4064
- cfg, token, form_number, source_key, requested_property
4065
- )
4066
4824
  by_key = {
4067
4825
  str(row.get("key") or "").strip(): row for row in context["fieldRows"]
4068
4826
  }
4827
+ property_key, ref_props_write = _ensure_reference_property_for_carry(
4828
+ cfg, token, form_number, source_key, requested_property,
4829
+ target_row=by_key[target_key], source_row=by_key[source_key],
4830
+ )
4069
4831
  source_name = str((by_key.get(source_key) or {}).get("name") or source_key).strip()
4070
4832
  target_name = str((by_key.get(target_key) or {}).get("name") or target_key).strip()
4071
4833
  source_path = f"{source_key}.{property_key}"
@@ -4102,10 +4864,14 @@ def do_ensure_conditional_carry_reference_property(
4102
4864
  "description": "携带基础资料属性到目标字段",
4103
4865
  "expression": [physical_mapping],
4104
4866
  "fieldName": [display_mapping],
4105
- "ret": 253,
4867
+ "ret": _requested_action_ret("TakeBaseProAction", 253, events, add_events),
4106
4868
  }],
4107
4869
  "falseActions": [],
4108
4870
  }
4871
+ legacy_description = description
4872
+ tagged_description = f"{requested['preDescription']} [CARRY_{legacy_description.rsplit('_', 1)[1]}]"
4873
+ description = requested["preDescription"]
4874
+ requested["description"] = description
4109
4875
  current = do_load_full_metadata(cfg, token, form_number)
4110
4876
  if not _is_api_success(current):
4111
4877
  raise RuntimeError(
@@ -4113,7 +4879,10 @@ def do_ensure_conditional_carry_reference_property(
4113
4879
  + (_extract_error_message(current) or "loadFullMetadata failed")
4114
4880
  )
4115
4881
  metadata = current.get("data")
4116
- descriptions = _rule_projections_by_description(metadata, description)
4882
+ descriptions = [
4883
+ *_rule_projections_by_description(metadata, legacy_description),
4884
+ *_rule_projections_by_description(metadata, tagged_description),
4885
+ ]
4117
4886
  semantic = [
4118
4887
  rule for rule in _all_rule_projections(metadata, "BizRule")
4119
4888
  if _take_base_rule_matches(
@@ -4125,6 +4894,21 @@ def do_ensure_conditional_carry_reference_property(
4125
4894
  if descriptions and not semantic:
4126
4895
  raise ValueError("CONDITIONAL_CARRY_CONFLICT")
4127
4896
  if semantic:
4897
+ timing_result = _reconcile_existing_rule_events(
4898
+ cfg, token, form_number, semantic[0], "bizRule", events, add_events,
4899
+ )
4900
+ if timing_result is not None:
4901
+ if ref_props_write is not None and isinstance(timing_result, dict):
4902
+ timing_result["refPropsResult"] = ref_props_write
4903
+ return timing_result
4904
+ renamed = _upgrade_generated_rule_description(
4905
+ cfg, token, form_number, semantic[0],
4906
+ (legacy_description, tagged_description), description,
4907
+ )
4908
+ if renamed is not None:
4909
+ if ref_props_write is not None and isinstance(renamed, dict):
4910
+ renamed["refPropsResult"] = ref_props_write
4911
+ return renamed
4128
4912
  result = {
4129
4913
  "status": True, "errorCode": "0",
4130
4914
  "message": "Conditional carry reference property already matches",
@@ -4173,9 +4957,235 @@ def do_ensure_conditional_carry_reference_property(
4173
4957
  return result
4174
4958
 
4175
4959
 
4960
+ @mutation_contract
4961
+ def do_ensure_carry_reference_mappings(cfg, token, form_number, request):
4962
+ """Write one TakeBaseProAction whose mapping table has several rows."""
4963
+ if not isinstance(request, dict):
4964
+ raise ValueError("CARRY_MAPPINGS_INPUT_INVALID: expected an object")
4965
+ allowed = {"entity", "conditionField", "operator", "value", "mappings", "branch",
4966
+ "events", "addEvents", "ruleId"}
4967
+ unknown = sorted(set(request) - allowed)
4968
+ mappings = request.get("mappings")
4969
+ if unknown or not isinstance(mappings, list) or not 1 <= len(mappings) <= 50:
4970
+ raise ValueError(
4971
+ f"CARRY_MAPPINGS_INPUT_INVALID: unknown={unknown}; mappings need 1..50 rows. "
4972
+ 'Use {"mappings":[{"sourceReference":"<reference>","sourceProperty":"<property>","target":"<target>"}]}; '
4973
+ "all three selectors belong inside each mappings row. For one mapping with source-not-empty default, use --source-reference/--source-property/--target without --input."
4974
+ )
4975
+ branch = str(request.get("branch") or "true").strip().casefold()
4976
+ if branch not in {"true", "false"}:
4977
+ raise ValueError("CARRY_MAPPINGS_BRANCH_INVALID: use true or false")
4978
+ selected_branch = "TrueActions" if branch == "true" else "FalseActions"
4979
+ opposite_branch = "FalseActions" if branch == "true" else "TrueActions"
4980
+ selected_input = "trueActions" if branch == "true" else "falseActions"
4981
+ required = {"sourceReference", "sourceProperty", "target"}
4982
+ for index, mapping in enumerate(mappings):
4983
+ if not isinstance(mapping, dict) or required - set(mapping) or set(mapping) - (required | {"onlyEmptyTarget"}):
4984
+ raise ValueError(f"CARRY_MAPPINGS_ROW_INVALID: mappings[{index}]")
4985
+ if any(not str(mapping[key] or "").strip() for key in required):
4986
+ raise ValueError(f"CARRY_MAPPINGS_ROW_INVALID: mappings[{index}] needs non-empty selectors")
4987
+ if "onlyEmptyTarget" in mapping and not isinstance(mapping["onlyEmptyTarget"], bool):
4988
+ raise ValueError(f"CARRY_MAPPINGS_ROW_INVALID: mappings[{index}].onlyEmptyTarget must be boolean")
4989
+ condition_field = request.get("conditionField")
4990
+ if condition_field in (None, "") and (request.get("operator") not in (None, "") or request.get("value") is not None):
4991
+ raise ValueError("CARRY_MAPPINGS_CONDITION_INVALID: operator/value need conditionField")
4992
+ if condition_field not in (None, "") and not str(request.get("operator") or "").strip():
4993
+ raise ValueError("CARRY_MAPPINGS_CONDITION_INVALID: conditionField needs operator")
4994
+ action_ret = _requested_action_ret(
4995
+ "TakeBaseProAction", 253, request.get("events"), request.get("addEvents"),
4996
+ )
4997
+ selectors = [selector for mapping in mappings
4998
+ for selector in (mapping["sourceReference"], mapping["target"])]
4999
+ entity_row, rows = _resolve_rule_entity_context(
5000
+ cfg, token, form_number, request.get("entity"), selectors,
5001
+ condition_selectors=[condition_field] if condition_field else [],
5002
+ )
5003
+ entity_key = str(entity_row.get("key") or "").strip()
5004
+ by_key = {str(row.get("key") or "").strip(): row for row in rows if isinstance(row, dict)}
5005
+ resolved = []
5006
+ seen_targets = set()
5007
+ for index, mapping in enumerate(mappings):
5008
+ source_key = resolve_element_rows(rows, mapping["sourceReference"], form_number)
5009
+ target_key = resolve_element_rows(rows, mapping["target"], form_number)
5010
+ if source_key == target_key or target_key in seen_targets:
5011
+ raise ValueError(f"CARRY_MAPPINGS_TARGET_CONFLICT: mappings[{index}] target={target_key!r}")
5012
+ seen_targets.add(target_key)
5013
+ resolved.append((source_key, target_key, str(mapping["sourceProperty"]).strip(),
5014
+ bool(mapping.get("onlyEmptyTarget"))))
5015
+ if condition_field:
5016
+ context = _resolve_conditional_context(
5017
+ cfg, token, form_number, request.get("entity"), condition_field,
5018
+ request["operator"], request.get("value"), participants=selectors,
5019
+ )
5020
+ condition = context["expression"]
5021
+ condition_key = context["conditionKey"]
5022
+ else:
5023
+ condition = "1 = 1"
5024
+ condition_key = None
5025
+ physical = []
5026
+ display = []
5027
+ ref_props_results = []
5028
+ pending_ref_props = {}
5029
+ inventory_cache = {}
5030
+ for source_key, target_key, source_property, only_empty in resolved:
5031
+ property_key, pending = _ensure_reference_property_for_carry(
5032
+ cfg, token, form_number, source_key, source_property, write=False,
5033
+ target_row=by_key[target_key], source_row=by_key[source_key],
5034
+ inventory_cache=inventory_cache,
5035
+ )
5036
+ if pending is not None:
5037
+ existing_names = pending_ref_props.get(source_key)
5038
+ if existing_names is None:
5039
+ existing_names = [item["name"] for item in pending]
5040
+ pending_ref_props[source_key] = list(dict.fromkeys([
5041
+ *existing_names, property_key,
5042
+ ]))
5043
+ source_name = str((by_key.get(source_key) or {}).get("name") or source_key).strip()
5044
+ target_name = str((by_key.get(target_key) or {}).get("name") or target_key).strip()
5045
+ physical.append({"TargetField": target_key, "SrcField": f"{source_key}.{property_key}",
5046
+ **({"OnlyEmptyRow": True} if only_empty else {})})
5047
+ display.append({"TargetFieldName": f"{target_name}({target_key})",
5048
+ "SrcFieldName": f"{source_name}.{property_key}({source_key}.{property_key})",
5049
+ **({"OnlyEmptyRow": True} if only_empty else {})})
5050
+ trigger_keys = list(dict.fromkeys([
5051
+ *([condition_key] if condition_key else []),
5052
+ *(source_key for source_key, _, _, _ in resolved),
5053
+ ]))
5054
+ trigger_field = ";".join(
5055
+ f"{str((by_key.get(key) or {}).get('name') or key).strip()}({key})"
5056
+ for key in trigger_keys
5057
+ )
5058
+ description = "带出" + "、".join(
5059
+ str((by_key.get(target_key) or {}).get("name") or target_key).strip()
5060
+ for _, target_key, _, _ in resolved
5061
+ )
5062
+ requested = {
5063
+ "ruleType": "bizRule", "itemId": str(entity_row.get("id") or "").strip(),
5064
+ "mountPoint": entity_key, "description": description,
5065
+ "preDescription": description, "preCondition": condition,
5066
+ "triggerField": trigger_field, "enabled": True,
5067
+ "trueActions": [], "falseActions": [],
5068
+ }
5069
+ requested[selected_input] = [{"actionType": "TakeBaseProAction",
5070
+ "description": "携带基础资料属性到目标字段",
5071
+ "expression": physical, "fieldName": display,
5072
+ "ret": action_ret}]
5073
+
5074
+ def write_ref_props():
5075
+ for source_key, names in pending_ref_props.items():
5076
+ written = do_set_ref_props(
5077
+ cfg, token, form_number, source_key,
5078
+ [{"name": name} for name in names],
5079
+ )
5080
+ if not _is_api_success(written) or not bool(
5081
+ (written.get("verification") or {}).get("verified")
5082
+ ):
5083
+ raise RuntimeError("CARRY_MAPPINGS_REFPROPS_WRITE_FAILED: " +
5084
+ (_extract_error_message(written) or str(written)))
5085
+ ref_props_results.append(written)
5086
+ current = do_load_full_metadata(cfg, token, form_number)
5087
+ if not _is_api_success(current):
5088
+ raise RuntimeError("CARRY_MAPPINGS_READ_FAILED: " + (_extract_error_message(current) or "loadFullMetadata failed"))
5089
+ metadata = current.get("data")
5090
+ selected_id = str(request.get("ruleId") or "").strip()
5091
+ candidates = [rule for rule in _all_rule_projections(metadata, "BizRule")
5092
+ if str(rule.get("PreCondition") or "") == condition
5093
+ and str(rule.get("ItemId") or str(entity_row.get("id") or "")) == str(entity_row.get("id") or "")
5094
+ and not (rule.get(opposite_branch) or [])
5095
+ and (rule.get(selected_branch) or [])
5096
+ and all(isinstance(action, dict) and action.get("ActionType") == "TakeBaseProAction"
5097
+ for action in rule[selected_branch])]
5098
+ requested_pairs = [(item["TargetField"], item["SrcField"], bool(item.get("OnlyEmptyRow")))
5099
+ for item in physical]
5100
+ matching = [rule for rule in candidates
5101
+ if sorted((str(item.get("TargetField") or ""), str(item.get("SrcField") or ""),
5102
+ bool(item.get("OnlyEmptyRow")))
5103
+ for action in rule[selected_branch]
5104
+ for item in (action.get("Expression") or [])) == sorted(requested_pairs)
5105
+ and all(len(action.get("Expression") or []) == len(action.get("FieldName") or [])
5106
+ for action in rule[selected_branch])]
5107
+ overlap = [rule for rule in candidates if rule not in matching and seen_targets.intersection({
5108
+ str(item.get("TargetField") or "")
5109
+ for action in rule[selected_branch]
5110
+ for item in (action.get("Expression") or [])
5111
+ })]
5112
+ if overlap and not selected_id:
5113
+ return {"status": False, "writeAttempted": False,
5114
+ "errorCode": "CARRY_MAPPINGS_TARGET_OVERLAP",
5115
+ "message": "Existing rules already carry part of these targets; inspect the candidate and update its complete mapping list explicitly",
5116
+ "data": {"candidateRuleIds": [rule.get("Id") for rule in overlap]}}
5117
+ if matching and not selected_id and any(
5118
+ str(rule.get("TriggerField") or "") != trigger_field for rule in matching
5119
+ ):
5120
+ return {"status": False, "writeAttempted": bool(ref_props_results),
5121
+ "errorCode": "CARRY_MAPPINGS_EXISTING_RULE_REQUIRES_ID",
5122
+ "message": "Matching mappings already exist with different trigger metadata; select the exact ruleId",
5123
+ "data": {"candidateRuleIds": [rule.get("Id") for rule in matching]}}
5124
+ if selected_id:
5125
+ matching = [rule for rule in matching if str(rule.get("Id") or "") == selected_id]
5126
+ if not matching:
5127
+ raise ValueError("CARRY_MAPPINGS_RULE_ID_MISMATCH: selected rule does not match all mappings")
5128
+ if len(matching) > 1:
5129
+ raise ValueError("CARRY_MAPPINGS_AMBIGUOUS: select one exact ruleId")
5130
+ if matching:
5131
+ rule = matching[0]
5132
+ actions = rule.get(selected_branch) or []
5133
+ if len(actions) == 1:
5134
+ if len(actions[0].get("Expression") or []) == len(physical):
5135
+ if not selected_id and request.get("events") is not None and actions[0].get("RET") != action_ret:
5136
+ return {"status": False, "writeAttempted": False,
5137
+ "errorCode": "RULE_EVENT_REPLACE_REQUIRES_RULE_ID",
5138
+ "message": "Replacing existing timings requires the exact ruleId",
5139
+ "data": {"candidateRuleId": rule.get("Id")}}
5140
+ write_ref_props()
5141
+ if selected_id and request.get("events") is not None:
5142
+ patch = {"ruleType": "bizRule", "ruleId": selected_id,
5143
+ selected_input: [{**actions[0], "RET": action_ret}]}
5144
+ if actions[0].get("RET") != action_ret:
5145
+ return do_update_rule_verified(cfg, token, form_number, patch)
5146
+ timing_result = _reconcile_existing_rule_events(
5147
+ cfg, token, form_number, rule, "bizRule",
5148
+ None if selected_id else request.get("events"),
5149
+ request.get("addEvents"),
5150
+ )
5151
+ if timing_result is not None:
5152
+ return timing_result
5153
+ return {"status": True, "writeAttempted": bool(ref_props_results),
5154
+ "data": {"changed": bool(ref_props_results), "ruleId": rule.get("Id"),
5155
+ "mappingCount": len(physical)},
5156
+ "verification": {"verified": True, "preservationVerified": True}}
5157
+ if not selected_id:
5158
+ return {"status": False, "writeAttempted": bool(ref_props_results),
5159
+ "errorCode": "CARRY_MAPPINGS_CONSOLIDATION_REQUIRES_RULE_ID",
5160
+ "message": "Matching mappings exist as separate services; select their ruleId before consolidation",
5161
+ "data": {"candidateRuleId": rule.get("Id")}}
5162
+ if len({action.get("RET") for action in actions}) != 1:
5163
+ raise ValueError("CARRY_MAPPINGS_TIMING_CONFLICT: existing services have different timings")
5164
+ requested[selected_input][0]["ret"] = _requested_action_ret(
5165
+ "TakeBaseProAction", actions[0]["RET"],
5166
+ request.get("events"), request.get("addEvents"),
5167
+ )
5168
+ patch = {"ruleType": "bizRule", "ruleId": selected_id,
5169
+ selected_input: requested[selected_input]}
5170
+ write_ref_props()
5171
+ result = do_update_rule_verified(cfg, token, form_number, patch)
5172
+ else:
5173
+ if selected_id:
5174
+ raise ValueError("CARRY_MAPPINGS_RULE_ID_NOT_FOUND")
5175
+ write_ref_props()
5176
+ result = do_add_rule_verified(cfg, token, form_number, requested)
5177
+ if isinstance(result, dict):
5178
+ result["writeAttempted"] = True
5179
+ result.setdefault("data", {}).update({"changed": _is_api_success(result),
5180
+ "mappingCount": len(physical)})
5181
+ if ref_props_results:
5182
+ result["refPropsResults"] = ref_props_results
5183
+ return result
5184
+
5185
+
4176
5186
  def do_ensure_conditional_copy_field_value(
4177
5187
  cfg, token, form_number, entity, condition_field, operator, value,
4178
- source, target, only_empty_target=False,
5188
+ source, target, only_empty_target=False, add_events=None, events=None,
4179
5189
  ):
4180
5190
  """Ensure one conditional same-entity SetEqutionAction mapping."""
4181
5191
  context = _resolve_conditional_context(
@@ -4226,10 +5236,14 @@ def do_ensure_conditional_copy_field_value(
4226
5236
  "description": "携带字段值到目标字段",
4227
5237
  "expression": [physical_mapping],
4228
5238
  "fieldName": [display_mapping],
4229
- "ret": 1021,
5239
+ "ret": _requested_action_ret("SetEqutionAction", 1021, events, add_events),
4230
5240
  }],
4231
5241
  "falseActions": [],
4232
5242
  }
5243
+ legacy_description = description
5244
+ tagged_description = f"{requested['preDescription']} [COPY_{legacy_description.rsplit('_', 1)[1]}]"
5245
+ description = requested["preDescription"]
5246
+ requested["description"] = description
4233
5247
  current = do_load_full_metadata(cfg, token, form_number)
4234
5248
  if not _is_api_success(current):
4235
5249
  raise RuntimeError(
@@ -4237,7 +5251,10 @@ def do_ensure_conditional_copy_field_value(
4237
5251
  + (_extract_error_message(current) or "loadFullMetadata failed")
4238
5252
  )
4239
5253
  metadata = current.get("data")
4240
- descriptions = _rule_projections_by_description(metadata, description)
5254
+ descriptions = [
5255
+ *_rule_projections_by_description(metadata, legacy_description),
5256
+ *_rule_projections_by_description(metadata, tagged_description),
5257
+ ]
4241
5258
  semantic = [
4242
5259
  rule for rule in _all_rule_projections(metadata, "BizRule")
4243
5260
  if _set_equation_rule_matches(
@@ -4249,6 +5266,17 @@ def do_ensure_conditional_copy_field_value(
4249
5266
  if descriptions and not semantic:
4250
5267
  raise ValueError("CONDITIONAL_COPY_FIELD_CONFLICT")
4251
5268
  if semantic:
5269
+ timing_result = _reconcile_existing_rule_events(
5270
+ cfg, token, form_number, semantic[0], "bizRule", events, add_events,
5271
+ )
5272
+ if timing_result is not None:
5273
+ return timing_result
5274
+ renamed = _upgrade_generated_rule_description(
5275
+ cfg, token, form_number, semantic[0],
5276
+ (legacy_description, tagged_description), description,
5277
+ )
5278
+ if renamed is not None:
5279
+ return renamed
4252
5280
  return {
4253
5281
  "status": True, "errorCode": "0",
4254
5282
  "message": "Conditional copy field value already matches",
@@ -4274,12 +5302,219 @@ def do_ensure_conditional_copy_field_value(
4274
5302
  return result
4275
5303
 
4276
5304
 
5305
+ @mutation_contract
5306
+ def do_ensure_copy_field_mappings(cfg, token, form_number, request):
5307
+ """Write one SetEqutionAction with parallel source/target mapping rows."""
5308
+ if not isinstance(request, dict):
5309
+ raise ValueError("COPY_MAPPINGS_INPUT_INVALID: expected an object")
5310
+ allowed = {"entity", "conditionField", "operator", "value", "mappings", "branch",
5311
+ "events", "addEvents", "ruleId"}
5312
+ mappings = request.get("mappings")
5313
+ if set(request) - allowed or not isinstance(mappings, list) or not 1 <= len(mappings) <= 50:
5314
+ raise ValueError("COPY_MAPPINGS_INPUT_INVALID: expected 1..50 mapping rows")
5315
+ branch = str(request.get("branch") or "true").strip().casefold()
5316
+ if branch not in {"true", "false"}:
5317
+ raise ValueError("COPY_MAPPINGS_BRANCH_INVALID: use true or false")
5318
+ selected_branch = "TrueActions" if branch == "true" else "FalseActions"
5319
+ opposite_branch = "FalseActions" if branch == "true" else "TrueActions"
5320
+ selected_input = "trueActions" if branch == "true" else "falseActions"
5321
+ for index, mapping in enumerate(mappings):
5322
+ if not isinstance(mapping, dict) or {"source", "target"} - set(mapping) or set(mapping) - {"source", "target", "onlyEmptyTarget"}:
5323
+ raise ValueError(f"COPY_MAPPINGS_ROW_INVALID: mappings[{index}]")
5324
+ if not str(mapping["source"] or "").strip() or not str(mapping["target"] or "").strip():
5325
+ raise ValueError(f"COPY_MAPPINGS_ROW_INVALID: mappings[{index}] needs source and target")
5326
+ if "onlyEmptyTarget" in mapping and not isinstance(mapping["onlyEmptyTarget"], bool):
5327
+ raise ValueError(f"COPY_MAPPINGS_ROW_INVALID: mappings[{index}].onlyEmptyTarget must be boolean")
5328
+ condition_field = request.get("conditionField")
5329
+ if condition_field in (None, "") and (request.get("operator") not in (None, "") or request.get("value") is not None):
5330
+ raise ValueError("COPY_MAPPINGS_CONDITION_INVALID: operator/value need conditionField")
5331
+ if condition_field not in (None, "") and not str(request.get("operator") or "").strip():
5332
+ raise ValueError("COPY_MAPPINGS_CONDITION_INVALID: conditionField needs operator")
5333
+ selectors = [selector for mapping in mappings for selector in (mapping["source"], mapping["target"])]
5334
+ entity_row, rows = _resolve_rule_entity_context(
5335
+ cfg, token, form_number, request.get("entity"), selectors,
5336
+ condition_selectors=[condition_field] if condition_field else [],
5337
+ )
5338
+ by_key = {str(row.get("key") or "").strip(): row for row in rows if isinstance(row, dict)}
5339
+ physical = []
5340
+ display = []
5341
+ source_keys = []
5342
+ targets = set()
5343
+ for index, mapping in enumerate(mappings):
5344
+ source_key = resolve_element_rows(rows, mapping["source"], form_number)
5345
+ target_key = resolve_element_rows(rows, mapping["target"], form_number)
5346
+ if source_key == target_key or target_key in targets:
5347
+ raise ValueError(f"COPY_MAPPINGS_TARGET_CONFLICT: mappings[{index}] target={target_key!r}")
5348
+ targets.add(target_key)
5349
+ source_keys.append(source_key)
5350
+ only_empty = bool(mapping.get("onlyEmptyTarget"))
5351
+ physical.append({"TargetField": target_key, "SrcField": source_key,
5352
+ **({"OnlyEmptyRow": True} if only_empty else {})})
5353
+ display.append({"TargetFieldName": f"{str((by_key.get(target_key) or {}).get('name') or target_key).strip()}({target_key})",
5354
+ "SrcFieldName": f"{str((by_key.get(source_key) or {}).get('name') or source_key).strip()}({source_key})",
5355
+ **({"OnlyEmptyRow": True} if only_empty else {})})
5356
+ if condition_field:
5357
+ context = _resolve_conditional_context(
5358
+ cfg, token, form_number, request.get("entity"), condition_field,
5359
+ request["operator"], request.get("value"), participants=selectors,
5360
+ )
5361
+ condition = context["expression"]
5362
+ trigger_keys = [context["conditionKey"], *source_keys]
5363
+ else:
5364
+ condition = "1 = 1"
5365
+ trigger_keys = source_keys
5366
+ trigger_field = ";".join(
5367
+ f"{str((by_key.get(key) or {}).get('name') or key).strip()}({key})"
5368
+ for key in dict.fromkeys(trigger_keys)
5369
+ )
5370
+ description = "携带" + "、".join(
5371
+ str((by_key.get(item["TargetField"]) or {}).get("name") or item["TargetField"]).strip()
5372
+ for item in physical
5373
+ )
5374
+ requested = {
5375
+ "ruleType": "bizRule", "itemId": str(entity_row.get("id") or "").strip(),
5376
+ "mountPoint": str(entity_row.get("key") or "").strip(),
5377
+ "description": description, "preDescription": description,
5378
+ "preCondition": condition, "triggerField": trigger_field, "enabled": True,
5379
+ "trueActions": [], "falseActions": [],
5380
+ }
5381
+ requested[selected_input] = [{"actionType": "SetEqutionAction",
5382
+ "description": "携带字段值到目标字段",
5383
+ "expression": physical, "fieldName": display,
5384
+ "ret": _requested_action_ret(
5385
+ "SetEqutionAction", 1021,
5386
+ request.get("events"), request.get("addEvents"),
5387
+ )}]
5388
+ current = do_load_full_metadata(cfg, token, form_number)
5389
+ if not _is_api_success(current):
5390
+ raise RuntimeError("COPY_MAPPINGS_READ_FAILED: " + (_extract_error_message(current) or "loadFullMetadata failed"))
5391
+ requested_pairs = sorted((item["TargetField"], item["SrcField"], bool(item.get("OnlyEmptyRow")))
5392
+ for item in physical)
5393
+ matches = [rule for rule in _all_rule_projections(current.get("data"), "BizRule")
5394
+ if str(rule.get("PreCondition") or "") == condition
5395
+ and str(rule.get("ItemId") or requested["itemId"]) == requested["itemId"]
5396
+ and not (rule.get(opposite_branch) or [])
5397
+ and (rule.get(selected_branch) or [])
5398
+ and all(isinstance(action, dict) and action.get("ActionType") == "SetEqutionAction"
5399
+ for action in rule[selected_branch])
5400
+ and sorted((str(item.get("TargetField") or ""), str(item.get("SrcField") or ""),
5401
+ bool(item.get("OnlyEmptyRow")))
5402
+ for action in rule[selected_branch]
5403
+ for item in (action.get("Expression") or [])) == requested_pairs
5404
+ and all(len(action.get("Expression") or []) == len(action.get("FieldName") or [])
5405
+ for action in rule[selected_branch])]
5406
+ selected_id = str(request.get("ruleId") or "").strip()
5407
+ overlapping = [rule for rule in _all_rule_projections(current.get("data"), "BizRule")
5408
+ if rule not in matches
5409
+ and str(rule.get("PreCondition") or "") == condition
5410
+ and str(rule.get("ItemId") or requested["itemId"]) == requested["itemId"]
5411
+ and not (rule.get(opposite_branch) or [])
5412
+ and (rule.get(selected_branch) or [])
5413
+ and all(isinstance(action, dict) and action.get("ActionType") == "SetEqutionAction"
5414
+ for action in rule[selected_branch])
5415
+ and targets.intersection({
5416
+ str(item.get("TargetField") or "")
5417
+ for action in rule[selected_branch]
5418
+ for item in (action.get("Expression") or [])
5419
+ })]
5420
+ if overlapping and not selected_id:
5421
+ return {"status": False, "writeAttempted": False,
5422
+ "errorCode": "COPY_MAPPINGS_TARGET_OVERLAP",
5423
+ "message": "Existing rules already copy into part of these targets; inspect the candidate and update its complete mapping list explicitly",
5424
+ "data": {"candidateRuleIds": [rule.get("Id") for rule in overlapping]}}
5425
+ if selected_id:
5426
+ matches = [rule for rule in matches if str(rule.get("Id") or "") == selected_id]
5427
+ if not matches:
5428
+ raise ValueError("COPY_MAPPINGS_RULE_ID_MISMATCH: selected rule does not match all mappings")
5429
+ elif matches and any(str(rule.get("TriggerField") or "") != trigger_field for rule in matches):
5430
+ return {"status": False, "writeAttempted": False,
5431
+ "errorCode": "COPY_MAPPINGS_EXISTING_RULE_REQUIRES_ID",
5432
+ "message": "Matching mappings exist with different trigger metadata; select the exact ruleId",
5433
+ "data": {"candidateRuleIds": [rule.get("Id") for rule in matches]}}
5434
+ if len(matches) > 1:
5435
+ raise ValueError("COPY_MAPPINGS_AMBIGUOUS: select one exact ruleId")
5436
+ if matches:
5437
+ rule = matches[0]
5438
+ actions = rule[selected_branch]
5439
+ if len(actions) == 1 and len(actions[0].get("Expression") or []) == len(physical):
5440
+ if selected_id and request.get("events") is not None and actions[0].get("RET") != requested[selected_input][0]["ret"]:
5441
+ return do_update_rule_verified(cfg, token, form_number, {
5442
+ "ruleType": "bizRule", "ruleId": selected_id,
5443
+ selected_input: [{**actions[0], "RET": requested[selected_input][0]["ret"]}],
5444
+ })
5445
+ timing = _reconcile_existing_rule_events(
5446
+ cfg, token, form_number, rule, "bizRule",
5447
+ None if selected_id else request.get("events"), request.get("addEvents"),
5448
+ )
5449
+ if timing is not None:
5450
+ return timing
5451
+ return {"status": True, "writeAttempted": False,
5452
+ "data": {"changed": False, "ruleId": rule.get("Id"), "mappingCount": len(physical)},
5453
+ "verification": {"verified": True, "preservationVerified": True}}
5454
+ if not selected_id:
5455
+ return {"status": False, "writeAttempted": False,
5456
+ "errorCode": "COPY_MAPPINGS_CONSOLIDATION_REQUIRES_RULE_ID",
5457
+ "message": "Matching mappings exist as separate services; select their ruleId",
5458
+ "data": {"candidateRuleId": rule.get("Id")}}
5459
+ if len({action.get("RET") for action in actions}) != 1:
5460
+ raise ValueError("COPY_MAPPINGS_TIMING_CONFLICT: existing services have different timings")
5461
+ requested[selected_input][0]["ret"] = _requested_action_ret(
5462
+ "SetEqutionAction", actions[0]["RET"], request.get("events"), request.get("addEvents"),
5463
+ )
5464
+ result = do_update_rule_verified(cfg, token, form_number, {
5465
+ "ruleType": "bizRule", "ruleId": selected_id, selected_input: requested[selected_input],
5466
+ })
5467
+ else:
5468
+ if selected_id:
5469
+ raise ValueError("COPY_MAPPINGS_RULE_ID_NOT_FOUND")
5470
+ result = do_add_rule_verified(cfg, token, form_number, requested)
5471
+ if isinstance(result, dict):
5472
+ result["writeAttempted"] = True
5473
+ result.setdefault("data", {}).update({"changed": _is_api_success(result),
5474
+ "mappingCount": len(physical)})
5475
+ return result
5476
+
5477
+
5478
+ def _rule_set_action_types(command, args):
5479
+ if command == "ensureConditionalFieldState":
5480
+ pair = _CONDITIONAL_STATE_ACTIONS.get(str(args.get("behavior") or "").casefold())
5481
+ if pair is None:
5482
+ raise ValueError("RULE_SET_ARGS_INVALID: unsupported field-state behavior")
5483
+ return [(kind, 251) for kind in pair]
5484
+ if command == "ensureConditionalControlState":
5485
+ pair = _CONDITIONAL_CONTROL_ACTIONS.get(str(args.get("behavior") or "").casefold())
5486
+ if pair is None:
5487
+ raise ValueError("RULE_SET_ARGS_INVALID: unsupported control-state behavior")
5488
+ return [(kind, 251) for kind in pair]
5489
+ if command == "ensureRuleFunctionFormula":
5490
+ return [("FormulaAction", 253), ("ClearValueAction", 253)]
5491
+ if command == "ensureTimeDifferenceCalculation":
5492
+ return [("FormulaAction", 1021), ("ClearValueAction", 1021)]
5493
+ return [{
5494
+ "ensureConditionalClearFields": ("ClearValueAction", 253),
5495
+ "ensureConditionalFieldChange": ("CallFieldChangeAction", 773),
5496
+ "ensureConditionalCopyFieldValue": ("SetEqutionAction", 1021),
5497
+ "ensureConditionalCalculation": ("FormulaAction", 253),
5498
+ "ensureConditionalCarryReferenceProperty": ("TakeBaseProAction", 253),
5499
+ "ensureQuantityConversion": ("FillQtyAction", 249),
5500
+ }[command]]
5501
+
5502
+
4277
5503
  _RULE_SET_COMMANDS = {
5504
+ "ensureConditionalClientAppearance": (
5505
+ {"request"}, set(),
5506
+ ),
5507
+ "ensureRuleFunctionFormula": (
5508
+ {"target", "call"}, {"entity", "events", "addEvents"},
5509
+ ),
4278
5510
  "ensureFieldCalculation": (
4279
5511
  {"target", "left", "operator", "right"}, {"entity"},
4280
5512
  ),
5513
+ "ensureTimeDifferenceCalculation": (
5514
+ {"target", "start", "end"}, {"entity"},
5515
+ ),
4281
5516
  "ensureConditionalFieldState": (
4282
- {"conditionField", "operator", "target", "behavior"}, {"entity", "value"},
5517
+ {"conditionField", "operator", "target", "behavior"}, {"entity", "value", "addEvents", "events"},
4283
5518
  ),
4284
5519
  "ensureConditionalEntryStyle": (
4285
5520
  {"conditionField", "operator", "value", "scope"},
@@ -4287,25 +5522,30 @@ _RULE_SET_COMMANDS = {
4287
5522
  ),
4288
5523
  "ensureConditionalControlState": (
4289
5524
  {"conditionField", "operator", "value", "targetControl", "behavior"},
4290
- {"entity"},
5525
+ {"entity", "events", "addEvents"},
4291
5526
  ),
4292
5527
  "ensureConditionalClearFields": (
4293
- {"conditionField", "operator", "value", "targets"}, {"entity"},
5528
+ {"conditionField", "operator", "value", "targets"}, {"entity", "addEvents", "events"},
5529
+ ),
5530
+ "ensureConditionalFieldChange": (
5531
+ {"conditionField", "operator", "target"}, {"entity", "value", "addEvents", "events"},
4294
5532
  ),
4295
5533
  "ensureConditionalCopyFieldValue": (
4296
- {"conditionField", "operator", "value", "source", "target"},
4297
- {"entity", "onlyEmptyTarget"},
5534
+ set(),
5535
+ {"conditionField", "operator", "value", "source", "target", "mappings", "ruleId",
5536
+ "entity", "onlyEmptyTarget", "events", "addEvents", "branch"},
4298
5537
  ),
4299
5538
  "ensureConditionalCalculation": (
4300
5539
  {"conditionField", "operator", "value", "target", "left", "calculation", "right"},
4301
- {"entity"},
5540
+ {"entity", "events", "addEvents"},
4302
5541
  ),
4303
5542
  "ensureConditionalCarryReferenceProperty": (
4304
- {"sourceReference", "sourceProperty", "target"},
4305
- {"entity", "conditionField", "operator", "value", "onlyEmptyTarget"},
5543
+ set(),
5544
+ {"sourceReference", "sourceProperty", "target", "mappings", "ruleId",
5545
+ "entity", "conditionField", "operator", "value", "onlyEmptyTarget", "events", "addEvents", "branch"},
4306
5546
  ),
4307
5547
  "ensureQuantityConversion": (
4308
- {"sourceQty", "targetQty"}, {"entity"},
5548
+ {"sourceQty", "targetQty"}, {"entity", "events", "addEvents"},
4309
5549
  ),
4310
5550
  }
4311
5551
 
@@ -4346,7 +5586,42 @@ def _validate_rule_set_request(request):
4346
5586
  )
4347
5587
  if "onlyEmptyTarget" in args and not isinstance(args["onlyEmptyTarget"], bool):
4348
5588
  raise ValueError(f"RULE_SET_ARGS_INVALID: rules[{index}].onlyEmptyTarget must be boolean")
4349
- if command == "ensureConditionalFieldState":
5589
+ if command == "ensureConditionalCarryReferenceProperty":
5590
+ single = {"sourceReference", "sourceProperty", "target"}
5591
+ if "mappings" in args:
5592
+ if single.intersection(args) or "onlyEmptyTarget" in args:
5593
+ raise ValueError(f"RULE_SET_ARGS_INVALID: rules[{index}] mixes mappings with single-row arguments")
5594
+ if not isinstance(args["mappings"], list) or not args["mappings"]:
5595
+ raise ValueError(f"RULE_SET_ARGS_INVALID: rules[{index}].mappings requires rows")
5596
+ elif single - set(args) or "ruleId" in args or "branch" in args:
5597
+ raise ValueError(f"RULE_SET_ARGS_INVALID: rules[{index}] needs one sourceReference/sourceProperty/target")
5598
+ if command == "ensureConditionalCopyFieldValue":
5599
+ single = {"conditionField", "operator", "value", "source", "target"}
5600
+ if "mappings" in args:
5601
+ if {"source", "target", "onlyEmptyTarget"}.intersection(args):
5602
+ raise ValueError(f"RULE_SET_ARGS_INVALID: rules[{index}] mixes mappings with single-row arguments")
5603
+ if not isinstance(args["mappings"], list) or not args["mappings"]:
5604
+ raise ValueError(f"RULE_SET_ARGS_INVALID: rules[{index}].mappings requires rows")
5605
+ elif single - set(args) or "ruleId" in args or "branch" in args:
5606
+ raise ValueError(f"RULE_SET_ARGS_INVALID: rules[{index}] needs condition/source/target")
5607
+ if command == "ensureConditionalClientAppearance":
5608
+ if not isinstance(args["request"], dict):
5609
+ raise ValueError(f"RULE_SET_ARGS_INVALID: rules[{index}].request must be an object")
5610
+ allowed_request = {"clientMount", "entity", "conditionField", "operator",
5611
+ "value", "effects", "events", "addEvents"}
5612
+ missing_request = {"clientMount", "conditionField", "operator", "effects"} - set(args["request"])
5613
+ unknown_request = set(args["request"]) - allowed_request
5614
+ if missing_request or unknown_request:
5615
+ raise ValueError(
5616
+ f"RULE_SET_ARGS_INVALID: rules[{index}].request "
5617
+ f"missing={sorted(missing_request)}, unknown={sorted(unknown_request)}"
5618
+ )
5619
+ if "events" in args["request"] or "addEvents" in args["request"]:
5620
+ _requested_action_ret(
5621
+ "ClientSetFieldAction", 251,
5622
+ args["request"].get("events"), args["request"].get("addEvents"),
5623
+ )
5624
+ if command in {"ensureConditionalFieldState", "ensureConditionalFieldChange"}:
4350
5625
  blank = str(args.get("operator") or "").strip() in {"empty", "not-empty"}
4351
5626
  if blank and args.get("value") is not None:
4352
5627
  raise ValueError(
@@ -4358,6 +5633,15 @@ def _validate_rule_set_request(request):
4358
5633
  f"RULE_SET_ARGS_INVALID: rules[{index}].value is required with "
4359
5634
  f"operator {args.get('operator')!r}"
4360
5635
  )
5636
+ if "addEvents" in args:
5637
+ if "events" in args:
5638
+ raise ValueError(f"RULE_SET_ARGS_INVALID: rules[{index}] mixes events and addEvents")
5639
+ if "events" in args or "addEvents" in args:
5640
+ action_types = _rule_set_action_types(command, args)
5641
+ for action_type, default_ret in action_types:
5642
+ _requested_action_ret(
5643
+ action_type, default_ret, args.get("events"), args.get("addEvents"),
5644
+ )
4361
5645
  normalized.append({
4362
5646
  "id": str(item.get("id") or f"rule-{index + 1}").strip(),
4363
5647
  "command": command,
@@ -4365,7 +5649,10 @@ def _validate_rule_set_request(request):
4365
5649
  })
4366
5650
  calculations = [
4367
5651
  item for item in normalized
4368
- if item["command"] in {"ensureFieldCalculation", "ensureConditionalCalculation"}
5652
+ if item["command"] in {
5653
+ "ensureFieldCalculation", "ensureTimeDifferenceCalculation",
5654
+ "ensureConditionalCalculation",
5655
+ }
4369
5656
  ]
4370
5657
  for item in normalized:
4371
5658
  if item["command"] != "ensureConditionalClearFields":
@@ -4378,7 +5665,11 @@ def _validate_rule_set_request(request):
4378
5665
  item["args"].get("entity") or ""
4379
5666
  ).casefold():
4380
5667
  continue
4381
- operands = {calculation["args"]["left"], calculation["args"]["right"]}
5668
+ operands = (
5669
+ {calculation["args"]["start"], calculation["args"]["end"]}
5670
+ if calculation["command"] == "ensureTimeDifferenceCalculation"
5671
+ else {calculation["args"]["left"], calculation["args"]["right"]}
5672
+ )
4382
5673
  target = calculation["args"]["target"]
4383
5674
  if operands.intersection(targets) and target not in targets:
4384
5675
  targets.append(target)
@@ -4393,15 +5684,30 @@ def _execute_rule_set_item(cfg, token, form_number, item):
4393
5684
  args = item["args"]
4394
5685
  entity = args.get("entity")
4395
5686
  command = item["command"]
5687
+ if command == "ensureConditionalClientAppearance":
5688
+ return do_ensure_conditional_client_appearance(
5689
+ cfg, token, form_number, args["request"],
5690
+ )
5691
+ if command == "ensureRuleFunctionFormula":
5692
+ return do_ensure_rule_function_formula(
5693
+ cfg, token, form_number, entity, args["target"], args["call"],
5694
+ args.get("addEvents"), args.get("events"),
5695
+ )
4396
5696
  if command == "ensureFieldCalculation":
4397
5697
  return do_ensure_field_calculation(
4398
5698
  cfg, token, form_number, entity, args["target"], args["left"],
4399
5699
  args["operator"], args["right"],
4400
5700
  )
5701
+ if command == "ensureTimeDifferenceCalculation":
5702
+ return do_ensure_time_difference_calculation(
5703
+ cfg, token, form_number, entity, args["target"],
5704
+ args["start"], args["end"],
5705
+ )
4401
5706
  if command == "ensureConditionalFieldState":
4402
5707
  return do_ensure_conditional_field_state(
4403
5708
  cfg, token, form_number, entity, args["conditionField"],
4404
5709
  args["operator"], args.get("value"), args["target"], args["behavior"],
5710
+ args.get("addEvents"), args.get("events"),
4405
5711
  )
4406
5712
  if command == "ensureConditionalEntryStyle":
4407
5713
  return do_ensure_conditional_entry_style(
@@ -4413,6 +5719,7 @@ def _execute_rule_set_item(cfg, token, form_number, item):
4413
5719
  return do_ensure_conditional_control_state(
4414
5720
  cfg, token, form_number, entity, args["conditionField"],
4415
5721
  args["operator"], args["value"], args["targetControl"], args["behavior"],
5722
+ args.get("addEvents"), args.get("events"),
4416
5723
  )
4417
5724
  if command == "ensureConditionalClearFields":
4418
5725
  targets = args["targets"]
@@ -4420,28 +5727,40 @@ def _execute_rule_set_item(cfg, token, form_number, item):
4420
5727
  targets = [targets]
4421
5728
  return do_ensure_conditional_clear_fields(
4422
5729
  cfg, token, form_number, entity, args["conditionField"],
4423
- args["operator"], args["value"], targets,
5730
+ args["operator"], args["value"], targets, args.get("addEvents"), args.get("events"),
5731
+ )
5732
+ if command == "ensureConditionalFieldChange":
5733
+ return do_ensure_conditional_field_change(
5734
+ cfg, token, form_number, entity, args["conditionField"],
5735
+ args["operator"], args.get("value"), args["target"],
5736
+ args.get("addEvents"), args.get("events"),
4424
5737
  )
4425
5738
  if command == "ensureConditionalCopyFieldValue":
5739
+ if "mappings" in args:
5740
+ return do_ensure_copy_field_mappings(cfg, token, form_number, args)
4426
5741
  return do_ensure_conditional_copy_field_value(
4427
5742
  cfg, token, form_number, entity, args["conditionField"],
4428
5743
  args["operator"], args["value"], args["source"], args["target"],
4429
- bool(args.get("onlyEmptyTarget")),
5744
+ bool(args.get("onlyEmptyTarget")), args.get("addEvents"), args.get("events"),
4430
5745
  )
4431
5746
  if command == "ensureConditionalCalculation":
4432
5747
  return do_ensure_conditional_calculation(
4433
5748
  cfg, token, form_number, entity, args["conditionField"],
4434
5749
  args["operator"], args["value"], args["target"], args["left"],
4435
- args["calculation"], args["right"],
5750
+ args["calculation"], args["right"], args.get("addEvents"), args.get("events"),
4436
5751
  )
4437
5752
  if command == "ensureConditionalCarryReferenceProperty":
5753
+ if "mappings" in args:
5754
+ return do_ensure_carry_reference_mappings(cfg, token, form_number, args)
4438
5755
  return do_ensure_conditional_carry_reference_property(
4439
5756
  cfg, token, form_number, entity, args.get("conditionField"),
4440
5757
  args.get("operator"), args.get("value"), args["sourceReference"],
4441
5758
  args["sourceProperty"], args["target"], bool(args.get("onlyEmptyTarget")),
5759
+ args.get("addEvents"), args.get("events"),
4442
5760
  )
4443
5761
  return do_ensure_quantity_conversion(
4444
5762
  cfg, token, form_number, entity, args["sourceQty"], args["targetQty"],
5763
+ args.get("addEvents"), args.get("events"),
4445
5764
  )
4446
5765
 
4447
5766
 
@@ -4477,8 +5796,11 @@ def _apply_rule_set_items(cfg, token, form_number, items):
4477
5796
  inventory = _RULE_BATCH_INVENTORY.get()
4478
5797
  if inventory is not None:
4479
5798
  inventory["rows"].clear()
5799
+ from .execution_errors import attach_execution_error
5800
+ error = attach_execution_error(result)
4480
5801
  results.append({
4481
5802
  "id": item["id"], "command": item["command"], "result": result,
5803
+ **({key: error[key] for key in ("errorCategory", "capabilityLimitConfirmed")} if error.get("status") is False else {}),
4482
5804
  **({"derivedTargets": item["derivedTargets"]} if item.get("derivedTargets") else {}),
4483
5805
  })
4484
5806
  if not _is_api_success(result):
@@ -4836,9 +6158,12 @@ def do_update_rule_verified(cfg, token, form_number, rule_config):
4836
6158
  },
4837
6159
  }
4838
6160
  preserved_rule = copy.deepcopy(preserved_rule)
4839
- resolved = _validate_fill_qty_action_scope(
6161
+ resolved = _resolve_call_field_change_targets(
4840
6162
  cfg, token, form_number, rule_config
4841
6163
  )
6164
+ resolved = _validate_fill_qty_action_scope(
6165
+ cfg, token, form_number, resolved
6166
+ )
4842
6167
  resolved = _validate_set_field_fmt_targets(cfg, token, form_number, resolved)
4843
6168
  resolved = _resolve_row_fmt_targets(cfg, token, form_number, resolved)
4844
6169
  resolved = _resolve_form_control_targets(cfg, token, form_number, resolved)
@@ -4852,6 +6177,9 @@ def do_update_rule_verified(cfg, token, form_number, rule_config):
4852
6177
  if _is_api_success(result):
4853
6178
  try:
4854
6179
  result = _attach_rule_readback(cfg, token, form_number, resolved, result, preserved_rule=preserved_rule)
6180
+ warnings = runtime_action_warnings(resolved)
6181
+ if _is_api_success(result) and warnings:
6182
+ result["runtimeVerification"] = {"verified": False, "warnings": warnings}
4855
6183
  except Exception as exc:
4856
6184
  result.update({"status": False, "errorCode": "RULE_READBACK_FAILED", "message": str(exc),
4857
6185
  "writeAttempted": True, "metadataRetained": True,