first commit

This commit is contained in:
strepsiades
2026-03-09 16:31:42 +08:00
commit 4a9c444b10
486 changed files with 52479 additions and 0 deletions
@@ -0,0 +1,6 @@
"""ProjectDetail domain"""
from .sync_node import ProjectDetailSyncNode
from .sync_strategy import ProjectDetailSyncStrategy
from .jsonl_handler import ProjectDetailJsonlHandler
__all__ = ["ProjectDetailSyncNode", "ProjectDetailSyncStrategy", "ProjectDetailJsonlHandler"]
@@ -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')}")
@@ -0,0 +1,10 @@
"""ProjectDetail Domain - JSONL Handler"""
from ...datasource import BaseJsonlHandler, JsonlDataSource
from .sync_node import ProjectDetailSyncNode
from schemas.project_extensions.project_detail import ProjectDetailProject
class ProjectDetailJsonlHandler(BaseJsonlHandler):
"""项目容量信息 JSONL Handler"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "project_detail_data", ProjectDetailSyncNode, ProjectDetailProject)
@@ -0,0 +1,17 @@
"""ProjectDetail domain registration"""
from ...common.registry import DomainRegistry
from schemas.project_extensions.project_detail import ProjectDetailProject
from .sync_node import ProjectDetailSyncNode
from .sync_strategy import ProjectDetailSyncStrategy
from .jsonl_handler import ProjectDetailJsonlHandler
from .api_handler import ProjectDetailApiHandler
# 注册 project_detail_data domain
DomainRegistry.register(
node_type="project_detail_data",
schema=ProjectDetailProject,
node_class=ProjectDetailSyncNode,
strategy_class=ProjectDetailSyncStrategy,
jsonl_handler_class=ProjectDetailJsonlHandler,
api_handler_class=ProjectDetailApiHandler,
)
@@ -0,0 +1,41 @@
"""Project Detail SyncNode"""
from typing import Any, Dict, Optional
from ...common.sync_node import SyncNode
from schemas.project_extensions.project_detail import ProjectDetailProject
_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 ProjectDetailSyncNode(SyncNode[ProjectDetailProject]):
"""项目容量信息同步节点"""
node_type = "project_detail_data"
schema = ProjectDetailProject
def __init__(self, **kwargs):
super().__init__(**kwargs)
@staticmethod
def _merge_production_extra_fields(target: Optional[Dict[str, Any]], source: Optional[Dict[str, Any]]) -> None:
if not target or not source or source.get("key") != "production":
return
for field in _PRODUCTION_PROJECT_FIELDS:
if field in source:
target[field] = source.get(field)
def _validate_and_set_data(self, data: Dict[str, Any]) -> None:
super()._validate_and_set_data(data)
self._merge_production_extra_fields(self.data, data)
def set_origin_data(self, data: Optional[Dict[str, Any]]) -> None:
super().set_origin_data(data)
self._merge_production_extra_fields(self.origin_data, data)
@@ -0,0 +1,30 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from schemas.project_extensions.project_detail import ProjectDetailProject
class ProjectDetailSyncStrategy(DefaultSyncStrategy[ProjectDetailProject]):
"""
ProjectDetail 同步策略(项目容量信息)。
默认行为:
- plan_construction / actual_construction / production → push(本地 → 远程)
- ensure_production / climb_production / challenge_production → pull(远程 → 本地)
"""
schema = ProjectDetailProject
default_config = StrategyConfig(
auto_bind=True,
auto_bind_fields=["project_id", "key"],
depend_fields={"project_id": "project"},
local_orphan_action=OrphanAction.NONE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
field_direction_key="key",
field_direction_overrides={
"ensure_production": UpdateDirection.PULL,
"climb_production": UpdateDirection.PULL,
"challenge_production": UpdateDirection.PULL,
},
)