修复一个因为项目双向更新更新导致第二次更新无法进行的问题
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import copy
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
@@ -65,13 +67,42 @@ class ProjectSyncStrategy(DefaultSyncStrategy[ProjectResponseBase]):
|
||||
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _snapshot_node(node):
|
||||
# project 是一个很特殊的双向更新场景:
|
||||
# 1. 先做一次 PULL,把远端 ic_plan_* 回填到本地;
|
||||
# 2. 再做一次常规 PUSH,把本地其余字段推到远端。
|
||||
#
|
||||
# 但 prepare_update_for_direction -> e30_update_prepare 会校验
|
||||
# source_node 当前仍然处于可进入更新准备的稳定态(S01)。
|
||||
# 如果直接复用同一个真实节点作为两次 update 的 source,第一次
|
||||
# 准备完成后真实节点的 action/status 已经变化,第二次就不再满足
|
||||
# “source 仍在 S01”的前置条件,导致第二次更新被状态机拒绝。
|
||||
#
|
||||
# 这里复制一个只用于“提供源数据/源状态视图”的快照,让两次 update
|
||||
# 都从各自的原始稳定态出发做判断;真正被推进到 S07 / 写入 payload
|
||||
# 的仍然是目标侧真实节点,而不是这个 snapshot。
|
||||
snapshot = copy.deepcopy(node)
|
||||
if hasattr(snapshot, "sync_log"):
|
||||
snapshot.sync_log = None
|
||||
return snapshot
|
||||
|
||||
async def update_pair(self, local_node, remote_node, data_id_map=None):
|
||||
updated_by_id = {}
|
||||
# 注意:这两个 snapshot 只会在各自方向上充当 source_node。
|
||||
# - PULL: source=remote_snapshot, target=local_node
|
||||
# - PUSH: source=local_snapshot, target=remote_node
|
||||
#
|
||||
# 因此真实 project 节点的状态变更仍只发生在 target 节点上,
|
||||
# 不会因为传入 snapshot 而把本体节点替换掉,也不会把本体节点的
|
||||
# action/status/error/sync_log 写到 snapshot 上后丢失掉。
|
||||
local_snapshot = self._snapshot_node(local_node)
|
||||
remote_snapshot = self._snapshot_node(remote_node)
|
||||
|
||||
if self.domain_option.pull_group_plan_production_date:
|
||||
pull_update = await self.prepare_update_for_direction(
|
||||
local_node,
|
||||
remote_node,
|
||||
remote_snapshot,
|
||||
UpdateDirection.PULL,
|
||||
data_id_map,
|
||||
include_fields=self._PULL_ONLY_FIELDS,
|
||||
@@ -85,7 +116,7 @@ class ProjectSyncStrategy(DefaultSyncStrategy[ProjectResponseBase]):
|
||||
|
||||
if self.config.update_direction != UpdateDirection.NONE:
|
||||
generic_update = await self.prepare_update_for_direction(
|
||||
local_node,
|
||||
local_snapshot,
|
||||
remote_node,
|
||||
self.config.update_direction,
|
||||
data_id_map,
|
||||
|
||||
@@ -3,6 +3,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from sync_state_machine.common.binding import BindingManager
|
||||
from sync_state_machine.common.collection import DataCollection
|
||||
from sync_state_machine.common.types import SyncAction
|
||||
from sync_state_machine.domain.project.sync_node import ProjectSyncNode
|
||||
from sync_state_machine.domain.project.sync_strategy import ProjectSyncStrategy
|
||||
from sync_state_machine.sync_system.config import UpdateDirection
|
||||
|
||||
@@ -64,5 +68,118 @@ async def test_project_update_pair_runs_special_pull_then_generic_update():
|
||||
assert pull_call.kwargs["include_fields"] == strategy._PULL_ONLY_FIELDS
|
||||
|
||||
generic_call = mock_prepare.await_args_list[1]
|
||||
assert generic_call.args == (local_node, remote_node, UpdateDirection.PUSH, {"project": "remote-project"})
|
||||
assert generic_call.kwargs["exclude_fields"] == strategy._PULL_ONLY_FIELDS
|
||||
assert generic_call.args[1:] == (remote_node, UpdateDirection.PUSH, {"project": "remote-project"})
|
||||
assert generic_call.args[0] is not local_node
|
||||
assert generic_call.kwargs["exclude_fields"] == strategy._PULL_ONLY_FIELDS
|
||||
|
||||
|
||||
def _project_payload(
|
||||
*,
|
||||
project_id: str,
|
||||
company_id: str,
|
||||
company_name: str,
|
||||
name: str,
|
||||
plan_first: str | None,
|
||||
plan_full: str | None,
|
||||
):
|
||||
return {
|
||||
"id": project_id,
|
||||
"project_type": "photovoltaic",
|
||||
"region_name": ["中国", "云南省"],
|
||||
"region_path": ["100000", "530000"],
|
||||
"province": "530000",
|
||||
"is_domestic": True,
|
||||
"company_id": company_id,
|
||||
"company_name": company_name,
|
||||
"syxs": None,
|
||||
"sub_type": None,
|
||||
"scgc_type": "self",
|
||||
"address": "测试地址",
|
||||
"code": "",
|
||||
"name": name,
|
||||
"short_name": "",
|
||||
"manager_name": "测试负责人",
|
||||
"manager_phone": "13800000000",
|
||||
"authorization_date": "2024-01-01",
|
||||
"authorized_capacity": 7.0,
|
||||
"investment_decision_capacity": 7.0,
|
||||
"investment_decision_date": "2024-01-02",
|
||||
"plan_construction_capacity": 7.0,
|
||||
"plan_construction_date": "2024-01-03",
|
||||
"actual_construction_capacity": 0.0,
|
||||
"actual_construction_date": None,
|
||||
"accumulated_construction_capacity": 0.0,
|
||||
"constructing_capacity": 0.0,
|
||||
"ensure_production_capacity": 2.0,
|
||||
"challenge_production_capacity": 2.0,
|
||||
"climb_production_capacity": 2.0,
|
||||
"plan_production_date": None,
|
||||
"plan_full_production_date": None,
|
||||
"actual_production_date": None,
|
||||
"actual_full_production_date": None,
|
||||
"production_capacity": 0.0,
|
||||
"production_date": None,
|
||||
"total_production_capacity": 0.0,
|
||||
"dynamic_investment": 0.0,
|
||||
"complete_investment": 0.0,
|
||||
"progress_type": None,
|
||||
"key_constraints": "NONE",
|
||||
"key_constraints_remark": "",
|
||||
"apply_file_ids": [],
|
||||
"dbtc_score_list": [],
|
||||
"ic_plan_first_production_date": plan_first,
|
||||
"ic_plan_full_production_date": plan_full,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_update_pair_can_prepare_pull_and_push_on_same_pair():
|
||||
local_collection = DataCollection("local")
|
||||
remote_collection = DataCollection("remote")
|
||||
binding_manager = BindingManager(None)
|
||||
|
||||
local_node = ProjectSyncNode(
|
||||
node_id="local-project-node",
|
||||
data_id="local-project-id",
|
||||
data=_project_payload(
|
||||
project_id="local-project-id",
|
||||
company_id="local-company-id",
|
||||
company_name="云南公司",
|
||||
name="本地项目",
|
||||
plan_first=None,
|
||||
plan_full=None,
|
||||
),
|
||||
)
|
||||
remote_node = ProjectSyncNode(
|
||||
node_id="remote-project-node",
|
||||
data_id="remote-project-id",
|
||||
data=_project_payload(
|
||||
project_id="remote-project-id",
|
||||
company_id="remote-company-id",
|
||||
company_name="清洁能源",
|
||||
name="远端项目",
|
||||
plan_first="2026-01-01",
|
||||
plan_full="2026-06-01",
|
||||
),
|
||||
)
|
||||
|
||||
await local_collection.add(local_node)
|
||||
await remote_collection.add(remote_node)
|
||||
await binding_manager.bind("project", local_node.node_id, remote_node.node_id)
|
||||
|
||||
strategy = ProjectSyncStrategy("project", local_collection, remote_collection, binding_manager)
|
||||
strategy.config.update_direction = UpdateDirection.PUSH
|
||||
strategy.config.domain_option = {"pull_group_plan_production_date": True}
|
||||
strategy.config.depend_fields = {}
|
||||
|
||||
await strategy.bind()
|
||||
|
||||
updated_nodes = await strategy.update_pair(local_node, remote_node, data_id_map={})
|
||||
|
||||
assert [node.node_id for node in updated_nodes] == [local_node.node_id, remote_node.node_id]
|
||||
assert local_node.action == SyncAction.UPDATE
|
||||
assert remote_node.action == SyncAction.UPDATE
|
||||
assert local_node.get_data()["ic_plan_first_production_date"] == "2026-01-01"
|
||||
assert local_node.get_data()["ic_plan_full_production_date"] == "2026-06-01"
|
||||
assert remote_node.get_data()["name"] == "本地项目"
|
||||
assert remote_node.get_data()["company_name"] == "云南公司"
|
||||
Reference in New Issue
Block a user