""" 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, filter_project_id: str | List[str] | None = None): """ 初始化 Project Handler Args: filter_project_id: 指定只加载的项目ID列表(可选,用于多项目测试) """ super().__init__() self._node_type = "project" self._node_class = ProjectSyncNode self._schema = ProjectResponseBase if filter_project_id is None: self._filter_project_id = None elif isinstance(filter_project_id, str): self._filter_project_id = [filter_project_id] else: self._filter_project_id = filter_project_id # 保存过滤条件 def set_target_project_ids(self, target_project_ids: List[str]) -> None: self._filter_project_id = list(target_project_ids) if target_project_ids else None @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 = [] # 确定要加载的项目ID列表:参数 > 构造函数配置 target_project_ids = self._filter_project_id or [] if isinstance(target_project_ids, str): target_project_ids = [target_project_ids] if target_project_ids: # 只加载指定项目列表 projects : List[ProjectResponseBase]= [] for project_id in target_project_ids: 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')}")