464 lines
18 KiB
Python
464 lines
18 KiB
Python
"""
|
||
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, **handler_config: Any):
|
||
super().__init__(**handler_config)
|
||
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.ensure_api_client()
|
||
self.ensure_collection()
|
||
# 清空旧缓存
|
||
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:
|
||
project_item = project_data.model_dump()
|
||
if self.should_skip_by_data_id_filter(project_item, project_data.id):
|
||
continue
|
||
|
||
# 预加载并缓存 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_item,
|
||
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_update_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_update_schema_diff 打印)
|
||
try:
|
||
full_update_data = self.build_update_request_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')}")
|