@@ -0,0 +1,535 @@
"""
Project Detail API Handler
职责:
- 加载项目容量信息(project_detail)
- 更新项目容量信息(对应项目详情更新接口)
实现的接口:
PUT:
- /project/plan_construction - 更新计划施工
- /project/actual_construction - 更新实际施工
- /project/ensure_capacity - 更新确保产能
- /project/climb_capacity - 更新登高产能
- /project/challenge_capacity - 更新揭榜产能
- /project/production_capacity - 更新投产数据
不支持: CREATE, DELETE
"""
from typing import List , Dict , Any , Optional , Tuple , Set
from pydantic import ValidationError
from . . . datasource . api . handler import BaseApiHandler
from . . . datasource . task_result import TaskResult
from . . . common . sync_node import SyncNode
from . . . datasource . api . errors import ERROR_VALIDATION_FAILED
from . sync_node import ProjectDetailSyncNode
from schemas . project_extensions . project_detail import ProjectDetailProject , ProjectDetailKey
from schemas . project . project_base import (
ProjectPlanConstruction ,
ProjectActualConstruction ,
ProjectEnsureCapacity ,
ProjectClimbCapacity ,
ProjectChallengeCapacity ,
ProjectProductionCapacity ,
)
_PRODUCTION_PROJECT_FIELDS = [
" plan_production_date " ,
" plan_full_production_date " ,
" ic_plan_first_production_date " ,
" ic_plan_full_production_date " ,
" actual_production_date " ,
" actual_full_production_date " ,
]
class ProjectDetailApiHandler ( BaseApiHandler ) :
""" 项目容量信息 API Handler """
def __init__ ( self ) :
super ( ) . __init__ ( )
self . _node_type = " project_detail_data "
self . _node_class = ProjectDetailSyncNode
self . _schema = ProjectDetailProject
self . _pending_push_context : Dict [ str , Dict [ str , str ] ] = { }
async def load ( self ) - > List [ SyncNode ] :
""" 加载项目容量信息(从 project all_info 中提取) """
from . . project . api_handler import ProjectApiHandler
projects = self . _collection . filter ( node_type = " project " )
if not projects :
print ( " ⚠️ No projects found, skipping project_detail load " )
return [ ]
nodes : List [ SyncNode ] = [ ]
for project in projects :
project_id = project . data_id
try :
all_info = project . context . get ( " all_info " )
if not all_info :
all_info = await ProjectApiHandler . get_all_info ( self . api_client , project_id )
project_detail_list = all_info . get ( " project_detail " , [ ] )
print ( f " Project { project_id } : { len ( project_detail_list ) } project_detail records " )
for detail_data in project_detail_list :
try :
candidate_data = dict ( detail_data ) if isinstance ( detail_data , dict ) else detail_data
if isinstance ( candidate_data , dict ) and not candidate_data . get ( " project_id " ) :
candidate_data [ " project_id " ] = project_id
validated = self . _schema . model_validate ( candidate_data )
detail_dict = validated . model_dump ( )
node = self . _create_node ( detail_dict )
node . context [ " project_id " ] = project_id
nodes . append ( node )
except Exception as e :
print ( f " ⚠️ Skipping invalid project_detail: { e } " )
continue
except Exception as e :
print ( f " ❌ Failed to load project_detail for project { project_id } : { e } " )
continue
return nodes
async def create_all ( self , nodes : List [ SyncNode ] ) - > List [ TaskResult ] :
""" 创建语义通过 project 详情更新接口实现(服务端隐式创建)。 """
return await self . _upsert_all ( nodes , force_execute = True )
async def update_all ( self , nodes : List [ SyncNode ] ) - > List [ TaskResult ] :
""" 批量更新项目容量信息 """
return await self . _upsert_all ( nodes , force_execute = False )
async def _upsert_all ( self , nodes : List [ SyncNode ] , * , force_execute : bool ) - > List [ TaskResult ] :
""" 批量 upsert:对 project_detail_data 统一走 project update 类接口。 """
results : List [ TaskResult ] = [ ]
seen_project_key : Set [ Tuple [ str , str ] ] = set ( )
for node in nodes :
node_data = node . get_data ( )
if node_data is None :
error_msg = node . error or " Node data is missing or invalid "
results . append ( TaskResult . failed ( node_id = node . node_id , error = f " Cannot upsert: { error_msg } " ) )
continue
origin_data = node . get_origin_data ( ) or { }
key = self . _normalize_key ( node_data . get ( " key " ) )
project_id = node_data . get ( " project_id " ) or origin_data . get ( " project_id " )
if not project_id :
results . append ( TaskResult . failed ( node_id = node . node_id , error = " Missing project_id for upsert " ) )
continue
if not key :
results . append ( TaskResult . failed ( node_id = node . node_id , error = " Missing key for upsert " ) )
continue
pair = ( str ( project_id ) , str ( key ) )
if pair in seen_project_key :
results . append (
TaskResult . failed (
node_id = node . node_id ,
error = f " Duplicate project_detail_data in one batch: project_id= { project_id } , key= { key } " ,
)
)
continue
seen_project_key . add ( pair )
project_data : Dict [ str , Any ] = { }
project_origin_data : Dict [ str , Any ] = { }
project_node = self . _collection . get_by_data_id ( " project " , project_id )
if project_node :
project_data = project_node . get_data ( ) or { }
project_origin_data = project_node . get_origin_data ( ) or { }
if key == " production " :
if project_data :
self . _merge_production_fields ( node_data , project_data )
if project_origin_data :
self . _merge_production_fields ( origin_data , project_origin_data )
config = self . _get_update_config ( key )
if not config :
results . append ( TaskResult . failed ( node_id = node . node_id , error = f " Unsupported project_detail key: { key } " ) )
continue
schema , api_func = config
new_payload = self . _build_update_payload ( key , node_data )
origin_payload = self . _build_update_payload ( key , origin_data )
if new_payload is None :
results . append ( TaskResult . failed ( node_id = node . node_id , error = f " Unable to build payload for key= { key } " ) )
continue
if origin_payload is None :
origin_payload = { }
if not force_execute and not self . _payload_changed ( schema , new_payload , origin_payload ) :
reason = f " No physical diff found for project_detail { key } despite strategy update request (Normalization Discrepancy). "
print ( f " [WARNING] { reason } " )
# 记录为跳过
results . append ( TaskResult . skipped ( node_id = node . node_id , reason = reason ) )
continue
try :
schema . model_validate ( new_payload )
except ValidationError as e :
results . append ( TaskResult . failed ( node_id = node . node_id , error = f " { ERROR_VALIDATION_FAILED } : { e } " ) )
continue
push_id = self . _generate_push_id ( )
biz_id = project_id
self . _pending_push_context [ push_id ] = {
" project_id " : str ( project_id ) ,
" key " : str ( key ) ,
}
try :
await api_func ( self . api_client , new_payload , push_id , biz_id )
results . append (
TaskResult . in_progress (
node_id = node . node_id ,
task_id = push_id ,
push_id = push_id ,
project_id = str ( project_id ) ,
key = str ( key ) ,
)
)
except Exception as e :
self . _pending_push_context . pop ( push_id , None )
results . append ( TaskResult . failed ( node_id = node . node_id , error = str ( e ) ) )
return results
async def delete_all ( self , nodes : List [ SyncNode ] ) - > List [ TaskResult ] :
""" 不支持删除 """
return [
TaskResult . failed ( node_id = node . node_id , error = " Project detail deletion not supported " )
for node in nodes
]
async def poll_tasks ( self , task_ids : List [ str ] ) - > Dict [ str , TaskResult ] :
""" 轮询异步任务状态 """
polled = await super ( ) . poll_tasks ( task_ids )
results : Dict [ str , TaskResult ] = { }
success_context_by_task : Dict [ str , Dict [ str , str ] ] = { }
project_order : List [ str ] = [ ]
task_ids_by_project : Dict [ str , List [ str ] ] = { }
for task_id in task_ids :
result = polled . get ( task_id )
if result is None :
continue
if result . status == result . status . IN_PROGRESS :
results [ task_id ] = result
continue
context = self . _pending_push_context . get ( task_id )
if context is None :
results [ task_id ] = result
continue
if result . status == result . status . FAILED :
self . _pending_push_context . pop ( task_id , None )
results [ task_id ] = result
continue
project_id = context [ " project_id " ]
if result . data_id and result . data_id != project_id :
self . _pending_push_context . pop ( task_id , None )
results [ task_id ] = result
continue
success_context_by_task [ task_id ] = context
if project_id not in task_ids_by_project :
project_order . append ( project_id )
task_ids_by_project [ project_id ] = [ ]
task_ids_by_project [ project_id ] . append ( task_id )
for project_id in project_order :
task_ids_for_project = task_ids_by_project . get ( project_id , [ ] )
try :
all_info = await self . _get_project_all_info ( project_id , force_refresh = True )
except Exception as exc :
for task_id in task_ids_for_project :
self . _pending_push_context . pop ( task_id , None )
results [ task_id ] = TaskResult . failed ( error = f " Poll resolve data_id failed: { exc } " , push_id = task_id )
continue
for task_id in task_ids_for_project :
context = success_context_by_task [ task_id ]
key = context [ " key " ]
try :
resolved_data_id = self . _resolve_data_id_from_all_info ( all_info , project_id = project_id , key = key )
except Exception as exc :
self . _pending_push_context . pop ( task_id , None )
results [ task_id ] = TaskResult . failed ( error = f " Poll resolve data_id failed: { exc } " , push_id = task_id )
continue
if not resolved_data_id :
self . _pending_push_context . pop ( task_id , None )
results [ task_id ] = TaskResult . failed (
error = (
" Poll resolve data_id failed: "
f " project_detail not found in all_info for project_id= { project_id } , key= { key } "
) ,
push_id = task_id ,
)
continue
self . _pending_push_context . pop ( task_id , None )
results [ task_id ] = TaskResult . success ( data_id = resolved_data_id , push_id = task_id )
return results
def _create_node ( self , data : Dict [ str , Any ] ) - > SyncNode :
project_id_raw = data . get ( " project_id " )
key_raw = self . _normalize_key ( data . get ( " key " ) )
project_id = str ( project_id_raw ) if project_id_raw else " "
key = str ( key_raw ) if key_raw else " "
loaded_data_id = str ( data . get ( " id " ) or data . get ( " _id " ) or " " )
existing_by_pair = self . _find_existing_by_project_key ( project_id = project_id , key = key )
if existing_by_pair is not None :
if loaded_data_id and existing_by_pair . data_id != loaded_data_id :
with existing_by_pair . allow_core_state_write ( " project_detail_pair_rebind " ) :
existing_by_pair . data_id = loaded_data_id
existing_by_pair . set_data ( data )
existing_by_pair . set_origin_data ( data )
if project_id :
existing_by_pair . context [ " project_id " ] = project_id
return existing_by_pair
node = self . _create_basic_node ( data , depend_ids = [ ] )
if project_id :
node . context [ " project_id " ] = project_id
return node
def _find_existing_by_project_key ( self , * , project_id : str , key : str ) - > Optional [ SyncNode ] :
if not project_id or not key or self . _collection is None :
return None
candidates = self . _collection . filter (
node_type = self . node_type ,
node_filter = lambda n : (
( ( n . get_data ( ) or { } ) . get ( " project_id " ) == project_id )
and ( self . _normalize_key ( ( n . get_data ( ) or { } ) . get ( " key " ) ) == key )
) ,
)
if not candidates :
return None
if len ( candidates ) > 1 :
raise RuntimeError (
f " Duplicate project_detail_data detected: project_id= { project_id } , key= { key } , count= { len ( candidates ) } "
)
return candidates [ 0 ]
async def _get_project_all_info ( self , project_id : str , * , force_refresh : bool = False ) - > Dict [ str , Any ] :
from . . project . api_handler import ProjectApiHandler
project_node = self . _collection . get_by_data_id ( " project " , project_id )
if project_node is not None and not force_refresh :
all_info = project_node . context . get ( " all_info " )
if isinstance ( all_info , dict ) :
return all_info
all_info = await ProjectApiHandler . get_all_info ( self . api_client , project_id )
if project_node is not None :
project_node . context [ " all_info " ] = all_info
return all_info
def _resolve_data_id_from_all_info ( self , all_info : Dict [ str , Any ] , * , project_id : str , key : str ) - > Optional [ str ] :
details = all_info . get ( " project_detail " , [ ] )
if not isinstance ( details , list ) :
return None
matched : List [ str ] = [ ]
for item in details :
if not isinstance ( item , dict ) :
continue
item_key = self . _normalize_key ( item . get ( " key " ) )
if item_key != key :
continue
item_id = item . get ( " id " ) or item . get ( " _id " )
if not item_id :
raise ValueError ( f " project_detail item missing id for project_id= { project_id } , key= { key } " )
matched . append ( str ( item_id ) )
if len ( matched ) > 1 :
raise ValueError ( f " project_detail duplicated for project_id= { project_id } , key= { key } " )
if len ( matched ) == 1 :
return matched [ 0 ]
return None
async def _resolve_data_id_from_project_all_info ( self , project_id : str , key : str ) - > Optional [ str ] :
all_info = await self . _get_project_all_info ( project_id )
return self . _resolve_data_id_from_all_info ( all_info , project_id = project_id , key = key )
def _normalize_key ( self , key : Any ) - > Optional [ str ] :
if isinstance ( key , ProjectDetailKey ) :
return key . value
if isinstance ( key , str ) :
return key
return None
def _get_update_config ( self , key : Optional [ str ] ) - > Optional [ Tuple [ type , Any ] ] :
if not key :
return None
return {
" plan_construction " : ( ProjectPlanConstruction , api_update_project_plan_construction ) ,
" actual_construction " : ( ProjectActualConstruction , api_update_project_actual_construction ) ,
" ensure_production " : ( ProjectEnsureCapacity , api_update_project_ensure_capacity ) ,
" climb_production " : ( ProjectClimbCapacity , api_update_project_climb_capacity ) ,
" challenge_production " : ( ProjectChallengeCapacity , api_update_project_challenge_capacity ) ,
" production " : ( ProjectProductionCapacity , api_update_project_production_capacity ) ,
} . get ( key )
def _build_update_payload ( self , key : Optional [ str ] , data : Dict [ str , Any ] ) - > Optional [ Dict [ str , Any ] ] :
if not key :
return None
value_list = data . get ( " value " ) or [ ]
if key == " plan_construction " :
return {
" plan_construction_list " : self . _build_capacity_list ( value_list , require_date = True )
}
if key == " actual_construction " :
return {
" actual_construction_list " : self . _build_capacity_list ( value_list , require_date = True )
}
if key == " ensure_production " :
return {
" ensure_production_capacity_list " : self . _build_capacity_list ( value_list , require_year = True )
}
if key == " climb_production " :
return {
" climb_production_capacity_list " : self . _build_capacity_list ( value_list , require_year = True )
}
if key == " challenge_production " :
return {
" challenge_production_capacity_list " : self . _build_capacity_list ( value_list , require_year = True )
}
if key == " production " :
payload : Dict [ str , Any ] = {
" actual_production_list " : self . _build_capacity_list ( value_list , require_date = True )
}
payload . update ( self . _extract_production_fields ( data ) )
return payload
return None
def _build_capacity_list (
self ,
value_list : List [ Dict [ str , Any ] ] ,
require_year : bool = False ,
require_date : bool = False ,
) - > List [ Dict [ str , Any ] ] :
items : List [ Dict [ str , Any ] ] = [ ]
for item in value_list :
if not isinstance ( item , dict ) :
continue
year = item . get ( " year " )
date = item . get ( " date " )
capacity = item . get ( " capacity " )
if require_year and ( year is None or year == " " ) :
continue
if require_date and ( date is None or date == " " ) :
continue
payload_item : Dict [ str , Any ] = { " capacity " : capacity }
if require_year :
payload_item [ " year " ] = year
if require_date :
payload_item [ " date " ] = date
items . append ( payload_item )
return items
def _payload_changed ( self , schema : type , new_payload : Dict [ str , Any ] , origin_payload : Dict [ str , Any ] ) - > bool :
try :
new_dump = schema . model_validate ( new_payload ) . model_dump ( )
except ValidationError :
return True
try :
origin_dump = schema . model_validate ( origin_payload ) . model_dump ( )
except ValidationError :
return True
return new_dump != origin_dump
def _extract_production_fields ( self , data : Dict [ str , Any ] ) - > Dict [ str , Any ] :
return { k : data . get ( k ) for k in _PRODUCTION_PROJECT_FIELDS if k in data }
def _merge_production_fields ( self , target : Dict [ str , Any ] , project_data : Dict [ str , Any ] ) - > None :
for field in _PRODUCTION_PROJECT_FIELDS :
if ( field not in target or target . get ( field ) in ( None , " " ) ) and field in project_data :
target [ field ] = project_data . get ( field )
# ========== 静态 API 函数(可抽成 SDK) ==========
async def api_update_project_plan_construction ( client , data : Dict [ str , Any ] , push_id : str , biz_id : str ) - > None :
""" PUT /project/plan_construction - 更新计划施工 """
ProjectPlanConstruction . model_validate ( data )
request_data = { " push_id " : push_id , " biz_id " : biz_id , " data " : data }
response = await client . request ( method = " PUT " , endpoint = " /project/plan_construction " , data = request_data )
if response . get ( " code " ) != 200 :
raise Exception ( f " API error: { response . get ( ' message ' , ' Unknown error ' ) } " )
async def api_update_project_actual_construction ( client , data : Dict [ str , Any ] , push_id : str , biz_id : str ) - > None :
""" PUT /project/actual_construction - 更新实际施工 """
ProjectActualConstruction . model_validate ( data )
request_data = { " push_id " : push_id , " biz_id " : biz_id , " data " : data }
response = await client . request ( method = " PUT " , endpoint = " /project/actual_construction " , data = request_data )
if response . get ( " code " ) != 200 :
raise Exception ( f " API error: { response . get ( ' message ' , ' Unknown error ' ) } " )
async def api_update_project_ensure_capacity ( client , data : Dict [ str , Any ] , push_id : str , biz_id : str ) - > None :
""" PUT /project/ensure_capacity - 更新确保产能 """
ProjectEnsureCapacity . model_validate ( data )
request_data = { " push_id " : push_id , " biz_id " : biz_id , " data " : data }
response = await client . request ( method = " PUT " , endpoint = " /project/ensure_capacity " , data = request_data )
if response . get ( " code " ) != 200 :
raise Exception ( f " API error: { response . get ( ' message ' , ' Unknown error ' ) } " )
async def api_update_project_climb_capacity ( client , data : Dict [ str , Any ] , push_id : str , biz_id : str ) - > None :
""" PUT /project/climb_capacity - 更新登高产能 """
ProjectClimbCapacity . model_validate ( data )
request_data = { " push_id " : push_id , " biz_id " : biz_id , " data " : data }
response = await client . request ( method = " PUT " , endpoint = " /project/climb_capacity " , data = request_data )
if response . get ( " code " ) != 200 :
raise Exception ( f " API error: { response . get ( ' message ' , ' Unknown error ' ) } " )
async def api_update_project_challenge_capacity ( client , data : Dict [ str , Any ] , push_id : str , biz_id : str ) - > None :
""" PUT /project/challenge_capacity - 更新揭榜产能 """
ProjectChallengeCapacity . model_validate ( data )
request_data = { " push_id " : push_id , " biz_id " : biz_id , " data " : data }
response = await client . request ( method = " PUT " , endpoint = " /project/challenge_capacity " , data = request_data )
if response . get ( " code " ) != 200 :
raise Exception ( f " API error: { response . get ( ' message ' , ' Unknown error ' ) } " )
async def api_update_project_production_capacity ( client , data : Dict [ str , Any ] , push_id : str , biz_id : str ) - > None :
""" PUT /project/production_capacity - 更新投产数据 """
ProjectProductionCapacity . model_validate ( data )
request_data = { " push_id " : push_id , " biz_id " : biz_id , " data " : data }
response = await client . request ( method = " PUT " , endpoint = " /project/production_capacity " , data = request_data )
if response . get ( " code " ) != 200 :
raise Exception ( f " API error: { response . get ( ' message ' , ' Unknown error ' ) } " )