Files
ecm_sync_system/sync_state_machine/domain/project/api_handler.py
T
2026-03-20 14:38:56 +08:00

458 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Project API Handler
实现的接口:
GET:
- /project/list - 获取项目列表
- /project - 获取项目详情
- /project/all_info - 获取项目完整信息
PUT (6个更新接口):
- /project - 更新项目基本信息
- /project/key_constraints - 关键节点
- /project/complete_investment - 完成投资
- /project/investment_decision - 投资决策
- /project/dynamic_investment - 动态投资
- /project/settlement_investment - 结算投资
不支持: CREATE, DELETE
"""
import asyncio
from typing import List, Dict, Any
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from .sync_node import ProjectSyncNode
from schemas.project.project_base import (
ProjectResponseBase,
ProjectBaseInfoUpdate,
ProjectKeyConstraints,
ProjectCompleteInvestment,
ProjectInvestmentDecision,
ProjectDynamicInvestment,
ProjectSettlementInvestment
)
from schemas.project.project import ProjectInfoResponse
_PROJECT_EXTENSION_FIELD_NAMES = {
"implementation_estimate",
"static_total_investment",
"completed_investment",
"total_contract_quantity",
"completed_settlement_quantity",
}
def _merge_project_all_info_fields(
project_data: Dict[str, Any],
all_info: Dict[str, Any],
) -> Dict[str, Any]:
"""把 all_info 中项目扩展字段拍平到 project 主数据上。"""
merged = dict(project_data)
for key, value in all_info.items():
if not key.endswith("_extension") or not isinstance(value, dict):
continue
for field_name in _PROJECT_EXTENSION_FIELD_NAMES:
if field_name not in value:
continue
current_value = merged.get(field_name)
if current_value in (None, ""):
merged[field_name] = value.get(field_name)
return merged
class ProjectApiHandler(BaseApiHandler):
"""项目 API Handler"""
# 类级别缓存:存储 all_info 数据
_all_info_cache: Dict[str, Dict[str, Any]] = {}
def __init__(self):
super().__init__()
self._node_type = "project"
self._node_class = ProjectSyncNode
self._schema = ProjectResponseBase
@classmethod
async def get_all_info(cls, client, project_id: str) -> Dict[str, Any]:
"""获取项目完整信息(带缓存)
Args:
client: API client
project_id: 项目ID
Returns:
项目完整信息字典
"""
if project_id in cls._all_info_cache:
return cls._all_info_cache[project_id]
all_info = await api_get_project_all_info(client, project_id)
cls._all_info_cache[project_id] = all_info
return all_info
@classmethod
def clear_cache(cls):
"""清空缓存(每次同步开始前调用)"""
cls._all_info_cache.clear()
async def load(self) -> List[SyncNode]:
"""加载项目列表并预加载 all_info(支持分页)
Args:
project_id: 可选,指定项目ID列表则只加载这些项目(优先级高于构造函数的filter_project_id
"""
try:
# 清空旧缓存
self.clear_cache()
nodes = []
project_id_filter = self.get_data_id_filter()
if project_id_filter:
# 只加载指定项目列表
projects : List[ProjectResponseBase]= []
for project_id in project_id_filter:
project_response = await api_get_project(self.api_client, project_id)
projects.append(project_response)
else:
# 加载所有项目(分页)
projects : List[ProjectResponseBase]= []
page = 1
page_size = 100
while True:
# 获取一页数据
page_projects, total = await api_get_project_list(
self.api_client,
page=page,
page_size=page_size
)
# 没有数据了,退出循环
if not page_projects:
break
projects.extend(page_projects)
# 检查是否还有下一页
if page * page_size >= total:
break
page += 1
# 处理所有项目
for project_data in projects:
# 预加载并缓存 all_info,同时存储到 node.context
all_info = await self.get_all_info(self.api_client, project_data.id)
normalized_project_data = _merge_project_all_info_fields(
project_data.model_dump(),
all_info,
)
node = self._create_node(normalized_project_data)
# 设置 context
node.context["project_id"] = project_data.id
node.context["all_info"] = all_info
nodes.append(node)
return nodes
except Exception as e:
# 打印详细错误信息并重新抛出,让上层处理
print(f"\n{'='*70}")
print("❌ 项目加载失败")
print(f"{'='*70}")
print(f"错误类型: {type(e).__name__}")
print(f"错误信息: {str(e)}")
print(f"{'='*70}")
import traceback
traceback.print_exc()
print(f"{'='*70}\n")
# 重新抛出异常,让DataSource能正确处理
raise
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""不支持创建"""
return [
TaskResult.failed(node_id=node.node_id, error="Project creation not supported")
for node in nodes
]
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""
批量更新项目(支持多种更新类型)
支持 6 种更新 schema
1. ProjectBaseInfoUpdate - 基础信息
2. ProjectKeyConstraints - 关键约束
3. ProjectCompleteInvestment - 竣工投资
4. ProjectInvestmentDecision - 投资决策
5. ProjectDynamicInvestment - 动态投资
6. ProjectSettlementInvestment - 结算投资
"""
results = []
# 定义所有更新 schema 和对应的 API 函数
update_configs = [
(ProjectBaseInfoUpdate, api_update_project_base_info),
(ProjectKeyConstraints, api_update_project_key_constraints),
(ProjectCompleteInvestment, api_update_project_complete_investment),
(ProjectInvestmentDecision, api_update_project_investment_decision),
(ProjectDynamicInvestment, api_update_project_dynamic_investment),
(ProjectSettlementInvestment, api_update_project_settlement_investment),
]
for node in nodes:
data = node.get_data()
if not data:
error_msg = node.error or "Node data is missing or invalid"
results.append(TaskResult.failed(node_id=node.node_id, error=f"Cannot update project: {error_msg}"))
continue
original_data = node.get_origin_data() or {}
biz_id = node.data_id
if not biz_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
continue
node_updated = False
error = None
force_steps_update = self._steps_changed(node)
steps_payload = self._get_request_steps(node)
steps_sent = False
allow_force_steps = True
# 遍历所有更新 schema,检查差异并更新
for schema, api_func in update_configs:
update_data = self._get_schema_diff(
schema,
data,
original_data,
node.node_id,
force_full_schema=force_steps_update and allow_force_steps,
)
if not update_data:
continue
# 执行更新(差异已由 _get_schema_diff 打印)
try:
full_update_data = self._filter_update_data(
data,
original_data,
schema,
force_full_schema=force_steps_update and allow_force_steps,
)
await self._submit_update_and_wait(
api_func=api_func,
data=full_update_data,
biz_id=biz_id,
steps=steps_payload if not steps_sent else [],
)
node_updated = True
steps_sent = steps_sent or bool(steps_payload)
allow_force_steps = False
self._mark_approval_snapshot_synced(node)
except Exception as e:
error = str(e)
break
# 汇总结果
if error:
results.append(TaskResult.failed(node_id=node.node_id, error=error))
elif node_updated:
results.append(TaskResult.success(node_id=node.node_id))
else:
# 没有任何字段需要更新
reason = f"No physical diff found across all 6 sub-schemas for project {biz_id} despite strategy update request (Normalization Discrepancy)."
print(f" [WARNING] {reason}")
results.append(TaskResult.skipped(node_id=node.node_id, reason=reason))
return results
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""不支持删除"""
return [
TaskResult.failed(node_id=node.node_id, error="Project deletion not supported")
for node in nodes
]
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务状态"""
return await super().poll_tasks(task_ids)
async def _submit_update_and_wait(self, *, api_func, data: Dict[str, Any], biz_id: str, steps: List[Dict[str, Any]] | None = None) -> None:
push_id = self._generate_push_id()
await api_func(self.api_client, data, push_id, biz_id, steps=steps or [])
max_attempts = 20
poll_interval = 0.2
last_result: TaskResult | None = None
for attempt in range(max_attempts):
polled = await super().poll_tasks([push_id])
result = polled.get(push_id)
last_result = result
if result is None:
raise RuntimeError(f"Project update poll missing result: push_id={push_id}")
if result.status == result.status.SUCCESS:
return
if result.status == result.status.FAILED:
raise RuntimeError(result.error or f"Project update failed: push_id={push_id}")
if attempt < max_attempts - 1:
await asyncio.sleep(poll_interval)
raise RuntimeError(
f"Project update poll timeout: push_id={push_id}, last_status={getattr(last_result, 'status', None)}"
)
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
node = self._create_basic_node(data, depend_ids=[])
# 设置 context
node.context["project_id"] = node.data_id
return node
# ========== 静态 API 函数(可抽成 SDK ==========
async def api_get_project_list(client, page: int = 1, page_size: int = 100) -> tuple[List[ProjectResponseBase], int]:
"""GET /project/list - 获取项目列表
Args:
client: API客户端
page: 页码,从1开始
page_size: 每页大小
Returns:
tuple[List[ProjectResponseBase], int]: (验证过的项目列表, 总数)
"""
response = await client.request(
method="GET",
endpoint="/project/list",
params={"page": page, "page_size": page_size}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
data = response.get("data", {})
list_data = data.get("list", [])
total = data.get("total", 0)
# 验证每个返回项
validated_items = []
for item in list_data:
validated_item = ProjectResponseBase.model_validate(item)
validated_items.append(validated_item)
return validated_items, total
async def api_get_project(client, project_id: str) -> ProjectResponseBase:
"""GET /project - 获取项目详情"""
response = await client.request(
method="GET",
endpoint="/project",
params={"project_id": project_id}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return ProjectResponseBase.model_validate(response.get("data"))
async def api_get_project_all_info(client, project_id: str) -> Dict[str, Any]:
"""GET /project/all_info - 获取项目完整信息(校验为 ProjectInfoResponse"""
response = await client.request(
method="GET",
endpoint="/project/all_info",
params={"project_id": project_id}
)
# 检查响应状态
code = response.get('code')
if code != 200:
error_msg = response.get('message', 'Unknown error')
# 422 验证错误时打印详细信息
if code == 422:
print(f"\n{'='*70}")
print("❌ API 422 验证错误")
print(f"{'='*70}")
print("接口: GET /project/all_info")
print(f"项目ID: {project_id}")
print(f"错误信息: {error_msg}")
print("完整响应:")
import json
print(json.dumps(response, indent=2, ensure_ascii=False))
print(f"{'='*70}\n")
raise Exception(f"API error {code}: {error_msg}")
data = response.get('data', {})
validated = ProjectInfoResponse.model_validate(data)
return validated.model_dump()
async def api_update_project_base_info(client, data: Dict[str, Any], push_id: str, biz_id: str, *, steps: List[Dict[str, Any]] | None = None) -> None:
"""PUT /project - 更新项目基本信息"""
ProjectBaseInfoUpdate.model_validate(data)
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data, "steps": steps or []}
response = await client.request(method="PUT", endpoint="/project", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_update_project_key_constraints(client, data: Dict[str, Any], push_id: str, biz_id: str, *, steps: List[Dict[str, Any]] | None = None) -> None:
"""PUT /project/key_constraints - 更新关键节点"""
ProjectKeyConstraints.model_validate(data)
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data, "steps": steps or []}
response = await client.request(method="PUT", endpoint="/project/key_constraints", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_update_project_complete_investment(client, data: Dict[str, Any], push_id: str, biz_id: str, *, steps: List[Dict[str, Any]] | None = None) -> None:
"""PUT /project/complete_investment - 更新完成投资"""
ProjectCompleteInvestment.model_validate(data)
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data, "steps": steps or []}
response = await client.request(method="PUT", endpoint="/project/complete_investment", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_update_project_investment_decision(client, data: Dict[str, Any], push_id: str, biz_id: str, *, steps: List[Dict[str, Any]] | None = None) -> None:
"""PUT /project/investment_decision - 更新投资决策"""
ProjectInvestmentDecision.model_validate(data)
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data, "steps": steps or []}
response = await client.request(method="PUT", endpoint="/project/investment_decision", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_update_project_dynamic_investment(client, data: Dict[str, Any], push_id: str, biz_id: str, *, steps: List[Dict[str, Any]] | None = None) -> None:
"""PUT /project/dynamic_investment - 更新动态投资"""
ProjectDynamicInvestment.model_validate(data)
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data, "steps": steps or []}
response = await client.request(method="PUT", endpoint="/project/dynamic_investment", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_update_project_settlement_investment(client, data: Dict[str, Any], push_id: str, biz_id: str, *, steps: List[Dict[str, Any]] | None = None) -> None:
"""PUT /project/settlement_investment - 更新结算投资"""
ProjectSettlementInvestment.model_validate(data)
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data, "steps": steps or []}
response = await client.request(method="PUT", endpoint="/project/settlement_investment", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")