first commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Domain 模块
|
||||
|
||||
导入所有 domain 的 register 模块,触发 DomainRegistry 注册。
|
||||
"""
|
||||
|
||||
# 导入所有 domain register,触发注册
|
||||
from .company import register as _company_register
|
||||
from .user import register as _user_register
|
||||
from .supplier import register as _supplier_register
|
||||
from .project import register as _project_register
|
||||
from .project_detail import register as _project_detail_register
|
||||
from .contract import register as _contract_register
|
||||
from .construction import register as _construction_register
|
||||
from .construction_task_detail import register as _construction_task_detail_register
|
||||
from .material import register as _material_register
|
||||
from .material_detail import register as _material_detail_register
|
||||
from .contract_settlement import register as _contract_settlement_register
|
||||
from .contract_settlement_detail import register as _contract_settlement_detail_register
|
||||
from .personnel import register as _personnel_register
|
||||
from .personnel_detail import register as _personnel_detail_register
|
||||
from .preparation import register as _preparation_register
|
||||
from .production import register as _production_register
|
||||
from .lar import register as _lar_register
|
||||
from .lar_change import register as _lar_change_register
|
||||
from .units import register as _units_register
|
||||
from .contract_change import register as _contract_change_register
|
||||
from .attachment import register as _attachment_register
|
||||
|
||||
# 所有组件通过 DomainRegistry 访问,不再单独导出
|
||||
__all__ = []
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Attachment Domain
|
||||
"""
|
||||
from .sync_node import AttachmentSyncNode
|
||||
from .sync_strategy import AttachmentSyncStrategy
|
||||
from .jsonl_handler import AttachmentJsonlHandler
|
||||
|
||||
__all__ = ["AttachmentSyncNode", "AttachmentSyncStrategy", "AttachmentJsonlHandler"]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Attachment API Handler
|
||||
|
||||
职责:
|
||||
- 加载附件列表(依赖 Project)
|
||||
- 附件创建/更新/删除(批量接口)
|
||||
"""
|
||||
|
||||
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 AttachmentSyncNode, AttachmentSchema
|
||||
|
||||
|
||||
class AttachmentApiHandler(BaseApiHandler):
|
||||
"""附件 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "attachment"
|
||||
self._node_class = AttachmentSyncNode
|
||||
self._schema = AttachmentSchema
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""加载附件列表(依赖 Project)"""
|
||||
projects = self._collection.filter(node_type="project")
|
||||
project_ids = [p.data_id for p in projects if p.data_id]
|
||||
|
||||
if not project_ids:
|
||||
return []
|
||||
|
||||
all_attachments = []
|
||||
for project_id in project_ids:
|
||||
try:
|
||||
attachments = await api_get_attachment_list(self.api_client, project_id)
|
||||
all_attachments.extend(attachments)
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load attachments for project {project_id}: {e}")
|
||||
|
||||
return all_attachments
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建附件(已禁用)"""
|
||||
return [
|
||||
TaskResult.failed(node_id=node.node_id, error="Attachment push disabled")
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量更新附件(已禁用)"""
|
||||
return [
|
||||
TaskResult.failed(node_id=node.node_id, error="Attachment push disabled")
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量删除附件(已禁用)"""
|
||||
return [
|
||||
TaskResult.failed(node_id=node.node_id, error="Attachment push disabled")
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
|
||||
"""轮询异步任务状态(占位)"""
|
||||
return await super().poll_tasks(task_ids)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("project_id"):
|
||||
node.context["project_id"] = data["project_id"]
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_attachment_list(client, project_id: str) -> List[Dict[str, Any]]:
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/attachment/list",
|
||||
params={"project_id": project_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response.get("list", [])
|
||||
|
||||
|
||||
async def api_create_attachment(client, data: Dict[str, Any], push_id: str, biz_id: str) -> Dict[str, Any]:
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, **data}
|
||||
response = await client.request(method="POST", endpoint="/attachment/create", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_delete_attachment(client, attachment_id: str, push_id: str, biz_id: str) -> None:
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "id": attachment_id}
|
||||
response = await client.request(method="DELETE", endpoint="/attachment/delete", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Attachment JSONL Handler
|
||||
"""
|
||||
from typing import Dict, Any
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import AttachmentSyncNode
|
||||
|
||||
|
||||
class AttachmentJsonlHandler(BaseJsonlHandler):
|
||||
"""附件 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "attachment", AttachmentSyncNode, Dict[str, Any])
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Attachment domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from .sync_node import AttachmentSyncNode, AttachmentSchema
|
||||
from .sync_strategy import AttachmentSyncStrategy
|
||||
from .jsonl_handler import AttachmentJsonlHandler
|
||||
from .api_handler import AttachmentApiHandler
|
||||
|
||||
# 注册 attachment domain
|
||||
DomainRegistry.register(
|
||||
node_type="attachment",
|
||||
schema=AttachmentSchema,
|
||||
node_class=AttachmentSyncNode,
|
||||
strategy_class=AttachmentSyncStrategy,
|
||||
jsonl_handler_class=AttachmentJsonlHandler,
|
||||
api_handler_class=AttachmentApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Attachment Domain
|
||||
"""
|
||||
from typing import Dict, Any
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from ...common import SyncNode
|
||||
|
||||
|
||||
class AttachmentSchema(BaseModel):
|
||||
"""允许任意附件字段的 schema"""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class AttachmentSyncNode(SyncNode[Dict[str, Any]]):
|
||||
"""附件同步节点"""
|
||||
node_type = "attachment"
|
||||
schema = AttachmentSchema
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from .sync_node import AttachmentSchema
|
||||
|
||||
|
||||
class AttachmentSyncStrategy(DefaultSyncStrategy[AttachmentSchema]):
|
||||
"""
|
||||
Attachment 同步策略(仅作为依赖,不执行同步动作)。
|
||||
"""
|
||||
|
||||
schema = AttachmentSchema
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=False,
|
||||
auto_bind_fields=[],
|
||||
depend_fields={},
|
||||
local_orphan_action=OrphanAction.NONE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Company domain"""
|
||||
from .sync_node import CompanySyncNode
|
||||
from .sync_strategy import CompanySyncStrategy
|
||||
from .jsonl_handler import CompanyJsonlHandler
|
||||
|
||||
__all__ = ["CompanySyncNode", "CompanySyncStrategy", "CompanyJsonlHandler"]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Company API Handler (只读)
|
||||
|
||||
职责:
|
||||
- 加载公司列表
|
||||
- 不支持创建/更新/删除
|
||||
"""
|
||||
|
||||
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 CompanySyncNode
|
||||
from schemas.company.company import CompanyResponse
|
||||
|
||||
|
||||
class CompanyApiHandler(BaseApiHandler):
|
||||
"""公司 API Handler (只读)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "company"
|
||||
self._node_class = CompanySyncNode
|
||||
self._schema = CompanyResponse
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""加载公司列表(支持分页)"""
|
||||
try:
|
||||
nodes = []
|
||||
page = 1
|
||||
page_size = 1000
|
||||
|
||||
while True:
|
||||
# 获取一页数据
|
||||
companies, total = await api_get_company_list(
|
||||
self.api_client,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
|
||||
# 没有数据了,退出循环
|
||||
if not companies:
|
||||
break
|
||||
|
||||
# 转换为节点
|
||||
for company_model in companies:
|
||||
company_data = company_model.model_dump()
|
||||
nodes.append(self._create_node(company_data))
|
||||
|
||||
# 检查是否还有下一页
|
||||
if page * page_size >= total:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
return nodes
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load companies: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""不支持创建"""
|
||||
return [
|
||||
TaskResult.failed(node_id=node.node_id, error="Company creation not supported")
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""不支持更新"""
|
||||
return [
|
||||
TaskResult.failed(node_id=node.node_id, error="Company update not supported")
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""不支持删除"""
|
||||
return [
|
||||
TaskResult.failed(node_id=node.node_id, error="Company 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)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
return self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_company_list(client, page: int = 1, page_size: int = 100) -> tuple[List[CompanyResponse], int]:
|
||||
"""
|
||||
获取公司列表 GET /company/list
|
||||
|
||||
Args:
|
||||
client: API客户端
|
||||
page: 页码,从1开始
|
||||
page_size: 每页大小
|
||||
|
||||
Returns:
|
||||
tuple[List[CompanyResponse], int]: (验证过的公司列表, 总数)
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/company/list",
|
||||
params={"page": page, "page_size": page_size}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
# V2 API 返回格式: {code: 200, data: {list: [...], total: 123, ...}}
|
||||
data = response.get("data", {})
|
||||
items = data.get("list", [])
|
||||
total = data.get("total", 0)
|
||||
|
||||
# 验证每个返回项
|
||||
validated_items = []
|
||||
for item in items:
|
||||
try:
|
||||
validated_item = CompanyResponse.model_validate(item)
|
||||
validated_items.append(validated_item)
|
||||
except ValidationError as e:
|
||||
print(f"⚠️ Skipping invalid company item: {e}")
|
||||
continue
|
||||
|
||||
return validated_items, total
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Company Domain - JSONL Handler"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import CompanySyncNode
|
||||
from schemas.company.company import CompanyResponse
|
||||
|
||||
|
||||
class CompanyJsonlHandler(BaseJsonlHandler):
|
||||
"""公司 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "company", CompanySyncNode, CompanyResponse)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Company domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.company.company import CompanyResponse
|
||||
from .sync_node import CompanySyncNode
|
||||
from .sync_strategy import CompanySyncStrategy
|
||||
from .jsonl_handler import CompanyJsonlHandler
|
||||
from .api_handler import CompanyApiHandler
|
||||
|
||||
# 注册 company domain
|
||||
DomainRegistry.register(
|
||||
node_type="company",
|
||||
schema=CompanyResponse,
|
||||
node_class=CompanySyncNode,
|
||||
strategy_class=CompanySyncStrategy,
|
||||
jsonl_handler_class=CompanyJsonlHandler,
|
||||
api_handler_class=CompanyApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from ...common.sync_node import SyncNode
|
||||
from schemas.company.company import CompanyResponse
|
||||
|
||||
|
||||
class CompanySyncNode(SyncNode[CompanyResponse]):
|
||||
"""公司同步节点"""
|
||||
node_type = "company"
|
||||
schema = CompanyResponse
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -0,0 +1,29 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.company.company import CompanyResponse
|
||||
|
||||
|
||||
class CompanySyncStrategy(DefaultSyncStrategy[CompanyResponse]):
|
||||
"""
|
||||
Company 同步策略
|
||||
|
||||
业务规则:
|
||||
1. 使用 (code) 作为业务唯一键
|
||||
2. 支持自动绑定
|
||||
3. 默认配置为日常推送模式(本地创建推送到远程,不拉取远程孤儿)
|
||||
|
||||
schema 已在类定义中设置,禁止在初始化时传入其他 schema。
|
||||
"""
|
||||
|
||||
# 类变量:schema 固定为 ContractResponse
|
||||
schema = CompanyResponse
|
||||
|
||||
# 类变量:默认配置
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["credit_code"],
|
||||
depend_fields={},
|
||||
local_orphan_action=OrphanAction.NONE,
|
||||
remote_orphan_action=OrphanAction.CREATE_LOCAL,
|
||||
update_direction=UpdateDirection.PULL,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Construction domain"""
|
||||
from .sync_node import ConstructionTaskSyncNode
|
||||
from .sync_strategy import ConstructionTaskSyncStrategy
|
||||
from .jsonl_handler import ConstructionTaskJsonlHandler
|
||||
|
||||
__all__ = ["ConstructionTaskSyncNode", "ConstructionTaskSyncStrategy", "ConstructionTaskJsonlHandler"]
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
Construction Task API Handler (V2)
|
||||
|
||||
职责:
|
||||
- 加载施工任务列表(依赖 Project)
|
||||
- 施工任务创建/更新/删除(批量接口)
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import (
|
||||
ERROR_NODE_DATA_NONE,
|
||||
ERROR_VALIDATION_FAILED,
|
||||
ERROR_PROJECT_ID_NOT_FOUND
|
||||
)
|
||||
from .sync_node import ConstructionTaskSyncNode
|
||||
from schemas.construction.construction import (
|
||||
ConstructionResponse,
|
||||
ConstructionCreate,
|
||||
ConstructionUpdate,
|
||||
ConstructionPlanUpdate
|
||||
)
|
||||
|
||||
|
||||
class ConstructionTaskApiHandler(BaseApiHandler):
|
||||
"""施工任务 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "construction_task"
|
||||
self._node_class = ConstructionTaskSyncNode
|
||||
self._schema = ConstructionResponse
|
||||
self.update_schemas = [ConstructionCreate, ConstructionUpdate, ConstructionPlanUpdate]
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""加载施工任务列表(依赖 Project)"""
|
||||
projects = self._collection.filter(node_type="project")
|
||||
project_ids = [p.data_id for p in projects if p.data_id]
|
||||
|
||||
if not project_ids:
|
||||
return []
|
||||
|
||||
all_tasks = []
|
||||
for project_id in project_ids:
|
||||
try:
|
||||
tasks = await api_get_construction_task_list(self.api_client, project_id)
|
||||
for task in tasks:
|
||||
# 已验证的 Pydantic model,转换为 dict
|
||||
task_data = task.model_dump()
|
||||
all_tasks.append(self._create_node(task_data))
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load construction tasks for project {project_id}: {e}")
|
||||
|
||||
return all_tasks
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建施工任务"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
project_id = node.context.get("project_id") or data.get("project_id")
|
||||
if not project_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_PROJECT_ID_NOT_FOUND))
|
||||
continue
|
||||
|
||||
push_id = self._generate_push_id()
|
||||
|
||||
try:
|
||||
# 验证并转换为 Pydantic model
|
||||
from pydantic import ValidationError
|
||||
create_data = {"project_id": project_id, **data}
|
||||
try:
|
||||
create_model = ConstructionCreate.model_validate(create_data)
|
||||
except ValidationError as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
|
||||
continue
|
||||
|
||||
response = await api_create_construction_task(self.api_client, create_model, push_id)
|
||||
if self.poll_mode == "sync":
|
||||
created_id = self._extract_created_id_from_response(response)
|
||||
if not created_id:
|
||||
results.append(
|
||||
TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Missing created_id in create response"
|
||||
)
|
||||
)
|
||||
continue
|
||||
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
|
||||
else:
|
||||
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""
|
||||
批量更新施工任务
|
||||
|
||||
支持两种更新类型:
|
||||
1. ConstructionUpdate - 基础信息更新 (PUT /construction/update)
|
||||
业务逻辑:如果show_name字段不同,那么仅当type == 'QT'时才更新
|
||||
2. ConstructionPlanUpdate - 计划信息更新 (PUT /construction/plan)
|
||||
|
||||
更新逻辑:
|
||||
- 对每种 schema,将原始数据和新数据都转换为 schema 格式
|
||||
- dump 成字典后比较
|
||||
- 如果有差异且满足业务条件,调用对应 API
|
||||
"""
|
||||
results = []
|
||||
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="node data is None"))
|
||||
continue
|
||||
|
||||
origin_data = node.get_origin_data() or {}
|
||||
biz_id = node.data_id or data.get("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
|
||||
schema_status_lines: List[str] = []
|
||||
|
||||
# 1. 基础信息更新 (ConstructionUpdate)
|
||||
# 业务逻辑:如果 show_name 字段不同,那么仅当 type == 'QT' 时才更新
|
||||
base_new_schema_error = self._validate_schema(data, ConstructionUpdate)
|
||||
base_changed_fields = self._schema_changed_fields(ConstructionUpdate, data, origin_data)
|
||||
base_update_called = False
|
||||
base_update_error: str | None = None
|
||||
if base_new_schema_error:
|
||||
base_update_error = base_new_schema_error
|
||||
elif base_changed_fields:
|
||||
should_update = True
|
||||
|
||||
if "show_name" in base_changed_fields:
|
||||
task_type = data.get("task_type") or data.get("type")
|
||||
if task_type != "QT":
|
||||
should_update = False
|
||||
|
||||
if should_update:
|
||||
try:
|
||||
update_model = ConstructionUpdate.model_validate(data)
|
||||
push_id = self._generate_push_id()
|
||||
await api_update_construction_task(self.api_client, update_model, push_id, biz_id)
|
||||
node_updated = True
|
||||
base_update_called = True
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
base_update_error = str(e)
|
||||
else:
|
||||
base_update_error = "show_name changed but task_type != QT"
|
||||
|
||||
schema_status_lines.append(
|
||||
self._build_update_schema_status(
|
||||
schema_name="ConstructionUpdate",
|
||||
schema=ConstructionUpdate,
|
||||
new_data=data,
|
||||
origin_data=origin_data,
|
||||
will_update=base_update_called,
|
||||
update_error=base_update_error,
|
||||
)
|
||||
)
|
||||
|
||||
# 2. 计划信息更新 (ConstructionPlanUpdate)
|
||||
plan_new_schema_error = self._validate_schema(data, ConstructionPlanUpdate)
|
||||
plan_changed_fields = self._schema_changed_fields(ConstructionPlanUpdate, data, origin_data)
|
||||
plan_update_called = False
|
||||
plan_update_error: str | None = None
|
||||
if not error:
|
||||
if plan_new_schema_error:
|
||||
plan_update_error = plan_new_schema_error
|
||||
elif plan_changed_fields:
|
||||
try:
|
||||
plan_model = ConstructionPlanUpdate.model_validate(data)
|
||||
plan_dict = plan_model.model_dump(exclude_unset=True)
|
||||
push_id = self._generate_push_id()
|
||||
await api_update_construction_task_plan(self.api_client, plan_dict, push_id, biz_id)
|
||||
node_updated = True
|
||||
plan_update_called = True
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
plan_update_error = str(e)
|
||||
|
||||
schema_status_lines.append(
|
||||
self._build_update_schema_status(
|
||||
schema_name="ConstructionPlanUpdate",
|
||||
schema=ConstructionPlanUpdate,
|
||||
new_data=data,
|
||||
origin_data=origin_data,
|
||||
will_update=plan_update_called,
|
||||
update_error=plan_update_error,
|
||||
)
|
||||
)
|
||||
|
||||
for line in schema_status_lines:
|
||||
node.append_log(f"UPDATE_SCHEMA: {line}")
|
||||
|
||||
# 汇总结果
|
||||
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:
|
||||
# 没有任何字段需要更新
|
||||
results.append(TaskResult.skipped(
|
||||
node_id=node.node_id,
|
||||
reason="No updatable fields changed | " + "; ".join(schema_status_lines)
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量删除施工任务"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
push_id = self._generate_push_id()
|
||||
biz_id = node.data_id
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete"))
|
||||
continue
|
||||
|
||||
try:
|
||||
await api_delete_construction_task(self.api_client, biz_id, push_id)
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
|
||||
"""轮询异步任务状态"""
|
||||
return await super().poll_tasks(task_ids)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("project_id"):
|
||||
node.context["project_id"] = data["project_id"]
|
||||
node.context["task_id"] = node.data_id
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_construction_task_list(client, project_id: str) -> List[ConstructionResponse]:
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/construction/list",
|
||||
params={"project_id": project_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
items = response.get("data", [])
|
||||
|
||||
# 验证每个返回项
|
||||
from pydantic import ValidationError
|
||||
validated_items = []
|
||||
for item in items:
|
||||
try:
|
||||
validated_item = ConstructionResponse.model_validate(item)
|
||||
validated_items.append(validated_item)
|
||||
except ValidationError as e:
|
||||
print(f"⚠️ Skipping invalid item: {e}")
|
||||
continue
|
||||
|
||||
return validated_items
|
||||
|
||||
|
||||
async def api_create_construction_task(client, data: ConstructionCreate, push_id: str) -> Dict[str, Any]:
|
||||
request_data = {"push_id": push_id, "data": data.model_dump()}
|
||||
response = await client.request(method="POST", endpoint="/construction/create", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_update_construction_task(client, data: ConstructionUpdate, push_id: str, biz_id: str) -> None:
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
|
||||
response = await client.request(method="PUT", endpoint="/construction/update", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_get_construction_task(client, task_id: str) -> Dict[str, Any]:
|
||||
"""GET /construction - 获取单个施工任务"""
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/construction",
|
||||
params={"task_id": task_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response.get("data", {})
|
||||
|
||||
|
||||
async def api_update_construction_task_plan(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
|
||||
"""PUT /construction/plan - 更新施工计划"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
response = await client.request(method="PUT", endpoint="/construction/plan", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_delete_construction_task(client, task_id: str, push_id: str) -> None:
|
||||
request_data = {"push_id": push_id, "biz_id": task_id}
|
||||
response = await client.request(method="DELETE", endpoint="/construction/delete", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Construction Task Domain - JSONL Handler"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import ConstructionTaskSyncNode
|
||||
from schemas.construction.construction import ConstructionResponse
|
||||
|
||||
|
||||
class ConstructionTaskJsonlHandler(BaseJsonlHandler):
|
||||
"""施工任务 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "construction_task", ConstructionTaskSyncNode, ConstructionResponse)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Construction task domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.construction.construction import ConstructionResponse
|
||||
from .sync_node import ConstructionTaskSyncNode
|
||||
from .sync_strategy import ConstructionTaskSyncStrategy
|
||||
from .jsonl_handler import ConstructionTaskJsonlHandler
|
||||
from .api_handler import ConstructionTaskApiHandler
|
||||
|
||||
# 注册 construction_task domain
|
||||
DomainRegistry.register(
|
||||
node_type="construction_task",
|
||||
schema=ConstructionResponse,
|
||||
node_class=ConstructionTaskSyncNode,
|
||||
strategy_class=ConstructionTaskSyncStrategy,
|
||||
jsonl_handler_class=ConstructionTaskJsonlHandler,
|
||||
api_handler_class=ConstructionTaskApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from ...common.sync_node import SyncNode
|
||||
from schemas.construction.construction import ConstructionResponse
|
||||
|
||||
|
||||
class ConstructionTaskSyncNode(SyncNode[ConstructionResponse]):
|
||||
"""施工任务同步节点"""
|
||||
node_type = "construction_task"
|
||||
schema = ConstructionResponse
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.construction.construction import ConstructionResponse
|
||||
|
||||
|
||||
class ConstructionTaskSyncStrategy(DefaultSyncStrategy[ConstructionResponse]):
|
||||
"""
|
||||
Construction Task 同步策略(合同子业务)。
|
||||
"""
|
||||
|
||||
schema = ConstructionResponse
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["contract_id", "task_type"],
|
||||
depend_fields={"contract_id": "contract", "project_id": "project"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Construction Task Detail Domain
|
||||
"""
|
||||
from .sync_node import ConstructionTaskDetailSyncNode
|
||||
from .sync_strategy import ConstructionTaskDetailSyncStrategy
|
||||
from .jsonl_handler import ConstructionTaskDetailJsonlHandler
|
||||
|
||||
__all__ = ["ConstructionTaskDetailSyncNode", "ConstructionTaskDetailSyncStrategy", "ConstructionTaskDetailJsonlHandler"]
|
||||
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
Construction Task Detail API Handler (V2)
|
||||
|
||||
职责:
|
||||
- 加载施工明细列表(依赖 ConstructionTask)
|
||||
- 施工明细创建/更新/删除(批量接口)
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import ERROR_NODE_DATA_NONE, ERROR_VALIDATION_FAILED, ERROR_ID_NOT_FOUND
|
||||
from .sync_node import ConstructionTaskDetailSyncNode
|
||||
from schemas.construction.construction_detail import (
|
||||
ConstructionDetailResponse,
|
||||
ConstructionDetailCreate,
|
||||
ConstructionDetailUpdate
|
||||
)
|
||||
|
||||
|
||||
class ConstructionTaskDetailApiHandler(BaseApiHandler):
|
||||
"""施工明细 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "construction_task_detail"
|
||||
self._node_class = ConstructionTaskDetailSyncNode
|
||||
self._schema = ConstructionDetailResponse
|
||||
self.update_schemas = [ConstructionDetailCreate, ConstructionDetailUpdate]
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""加载施工明细列表(依赖 ConstructionTask)"""
|
||||
tasks = self._collection.filter(node_type="construction_task")
|
||||
task_ids = [t.data_id for t in tasks if t.data_id]
|
||||
|
||||
if not task_ids:
|
||||
return []
|
||||
|
||||
all_details = []
|
||||
for task_id in task_ids:
|
||||
try:
|
||||
details = await api_get_construction_task_detail_list(self.api_client, task_id)
|
||||
for detail_model in details:
|
||||
# 已验证的 Pydantic model,转换为 dict
|
||||
detail_data = detail_model.model_dump()
|
||||
all_details.append(self._create_node(detail_data))
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load construction details for task {task_id}: {e}")
|
||||
|
||||
return all_details
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建施工明细"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
# 从data的parent_id获取task_id
|
||||
task_id = data.get("parent_id")
|
||||
if not task_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_ID_NOT_FOUND))
|
||||
continue
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
create_data = {"task_id": task_id, **data}
|
||||
create_model = ConstructionDetailCreate.model_validate(create_data)
|
||||
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()
|
||||
|
||||
try:
|
||||
response = await api_create_construction_task_detail(self.api_client, create_model, push_id)
|
||||
if self.poll_mode == "sync":
|
||||
created_id = self._extract_created_id_from_response(response)
|
||||
if not created_id:
|
||||
results.append(
|
||||
TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Missing created_id in create response"
|
||||
)
|
||||
)
|
||||
continue
|
||||
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
|
||||
else:
|
||||
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""
|
||||
批量更新施工明细
|
||||
|
||||
按 task_id 分组,每组使用同一个 push_id 批量提交
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
from collections import defaultdict
|
||||
|
||||
results = []
|
||||
|
||||
# 按 task_id 分组
|
||||
groups: Dict[str, List[tuple[SyncNode, Dict[str, Any]]]] = defaultdict(list)
|
||||
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
origin_data = node.get_origin_data() or {}
|
||||
|
||||
# 使用 _get_schema_diff 检查差异并打印日志
|
||||
diff_fields = self._get_schema_diff(ConstructionDetailUpdate, data, origin_data, node.node_id)
|
||||
if not diff_fields:
|
||||
from ...common.types import SyncAction
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
|
||||
continue
|
||||
|
||||
# 获取 task_id(parent_id)
|
||||
task_id = data.get("parent_id") or node.context.get("task_id")
|
||||
if not task_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing task_id (parent_id)"))
|
||||
continue
|
||||
|
||||
# 需要完整的 schema 数据(包括必填字段)
|
||||
update_data = self._filter_update_data(data, origin_data, ConstructionDetailUpdate)
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
update_model = ConstructionDetailUpdate.model_validate(update_data)
|
||||
groups[task_id].append((node, update_model))
|
||||
except ValidationError as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
|
||||
|
||||
# 逐组批量更新
|
||||
for task_id, group_items in groups.items():
|
||||
push_id = self._generate_push_id()
|
||||
|
||||
# 准备批量数据
|
||||
detail_list = [model.model_dump(mode='json', exclude_unset=True) for node, model in group_items]
|
||||
|
||||
# 使用 parent_id(task_id)作为 biz_id
|
||||
biz_id = task_id
|
||||
|
||||
try:
|
||||
# 批量调用 API
|
||||
await api_update_construction_task_detail(
|
||||
self.api_client,
|
||||
{"detail_list": detail_list},
|
||||
push_id,
|
||||
biz_id,
|
||||
)
|
||||
# 整组成功
|
||||
for node, _ in group_items:
|
||||
if self.poll_mode == "sync":
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
else:
|
||||
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
|
||||
except Exception as e:
|
||||
# 整组失败
|
||||
error_msg = str(e)
|
||||
for node, _ in group_items:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=error_msg))
|
||||
|
||||
return results
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量删除施工明细"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
push_id = self._generate_push_id()
|
||||
biz_id = node.data_id
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete"))
|
||||
continue
|
||||
|
||||
try:
|
||||
await api_delete_construction_task_detail(self.api_client, biz_id, push_id)
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
|
||||
"""轮询异步任务状态"""
|
||||
return await super().poll_tasks(task_ids)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("task_id"):
|
||||
node.context["task_id"] = data["task_id"]
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_construction_task_detail_list(client, task_id: str) -> List[ConstructionDetailResponse]:
|
||||
"""
|
||||
获取施工明细列表 GET /construction_task_detail/list
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
task_id: 施工任务ID
|
||||
|
||||
Returns:
|
||||
List[ConstructionDetailResponse]: 验证过的明细列表
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/construction/detail/list",
|
||||
params={"task_id": task_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
items = response.get("data", [])
|
||||
|
||||
# 验证每个返回项
|
||||
validated_items = []
|
||||
for item in items:
|
||||
try:
|
||||
validated_item = ConstructionDetailResponse.model_validate(item)
|
||||
validated_items.append(validated_item)
|
||||
except ValidationError as e:
|
||||
print(f"⚠️ Skipping invalid detail item: {e}")
|
||||
continue
|
||||
|
||||
return validated_items
|
||||
|
||||
|
||||
async def api_create_construction_task_detail(client, data: ConstructionDetailCreate, push_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
创建施工明细 POST /construction/detail/create
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 创建数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {"push_id": push_id, "data": data.model_dump(mode='json')}
|
||||
response = await client.request(method="POST", endpoint="/construction/detail/create", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_update_construction_task_detail(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
|
||||
"""
|
||||
更新施工明细 PUT /construction/detail/update
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 更新数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
response = await client.request(method="PUT", endpoint="/construction/detail/update", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_delete_construction_task_detail(client, detail_id: str, push_id: str) -> None:
|
||||
request_data = {"push_id": push_id, "biz_id": detail_id}
|
||||
response = await client.request(method="DELETE", endpoint="/construction/detail/delete", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Construction Task Detail JSONL Handler
|
||||
"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import ConstructionTaskDetailSyncNode
|
||||
from schemas.construction.construction_detail import ConstructionDetailResponse
|
||||
|
||||
|
||||
class ConstructionTaskDetailJsonlHandler(BaseJsonlHandler):
|
||||
"""施工任务明细 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "construction_task_detail", ConstructionTaskDetailSyncNode, ConstructionDetailResponse)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Construction task detail domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.construction.construction_detail import ConstructionDetailResponse
|
||||
from .sync_node import ConstructionTaskDetailSyncNode
|
||||
from .sync_strategy import ConstructionTaskDetailSyncStrategy
|
||||
from .jsonl_handler import ConstructionTaskDetailJsonlHandler
|
||||
from .api_handler import ConstructionTaskDetailApiHandler
|
||||
|
||||
# 注册 construction_task_detail domain
|
||||
DomainRegistry.register(
|
||||
node_type="construction_task_detail",
|
||||
schema=ConstructionDetailResponse,
|
||||
node_class=ConstructionTaskDetailSyncNode,
|
||||
strategy_class=ConstructionTaskDetailSyncStrategy,
|
||||
jsonl_handler_class=ConstructionTaskDetailJsonlHandler,
|
||||
api_handler_class=ConstructionTaskDetailApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Construction Task Detail Domain
|
||||
"""
|
||||
from ...common import SyncNode
|
||||
from schemas.construction.construction_detail import ConstructionDetailResponse
|
||||
|
||||
|
||||
class ConstructionTaskDetailSyncNode(SyncNode[ConstructionDetailResponse]):
|
||||
"""施工任务明细同步节点"""
|
||||
node_type = "construction_task_detail"
|
||||
schema = ConstructionDetailResponse
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.construction.construction_detail import ConstructionDetailResponse
|
||||
|
||||
|
||||
class ConstructionTaskDetailSyncStrategy(DefaultSyncStrategy[ConstructionDetailResponse]):
|
||||
"""
|
||||
Construction Task Detail 同步策略(合同子业务明细)。
|
||||
"""
|
||||
|
||||
schema = ConstructionDetailResponse
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["parent_id", "date"],
|
||||
depend_fields={"parent_id": "construction_task"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Contract domain"""
|
||||
from .sync_node import ContractSyncNode
|
||||
from .sync_strategy import ContractSyncStrategy
|
||||
from .jsonl_handler import ContractJsonlHandler
|
||||
|
||||
__all__ = ["ContractSyncNode", "ContractSyncStrategy", "ContractJsonlHandler"]
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Contract API Handler
|
||||
|
||||
职责:
|
||||
- 加载合同列表(依赖 Project)
|
||||
- 合同创建(POST /contract/create,异步)
|
||||
- 合同更新(PUT /contract/update,同步)
|
||||
- 合同删除(DELETE /contract/delete,同步)
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from ...datasource.api.handler import BaseApiHandler
|
||||
from ...datasource.task_result import TaskResult
|
||||
from ...common.sync_node import SyncNode
|
||||
from ...datasource.api.client import ApiClient
|
||||
from .sync_node import ContractSyncNode
|
||||
from schemas.contract.contract import ContractResponse, ContractCreate, ContractUpdate
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_contract_list(client: ApiClient, project_id: str) -> List[ContractResponse]:
|
||||
"""GET /contract/list - 获取项目合同列表"""
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/contract/list",
|
||||
params={"project_id": project_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"Failed to get contract list: {response.get('message', 'Unknown error')}")
|
||||
|
||||
# V2 API 返回格式:{code: 200, data: [...]}
|
||||
items = response.get("data", [])
|
||||
|
||||
return [ContractResponse.model_validate(item) for item in items]
|
||||
|
||||
|
||||
async def api_get_contract(client: ApiClient, contract_id: str) -> ContractResponse:
|
||||
"""GET /contract - 获取合同详情"""
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/contract",
|
||||
params={"contract_id": contract_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"Failed to get contract: {response.get('message', 'Unknown error')}")
|
||||
return ContractResponse.model_validate(response["data"])
|
||||
|
||||
|
||||
async def api_create_contract(client: ApiClient, data: Dict[str, Any], push_id: str) -> Dict[str, Any]:
|
||||
"""POST /contract/create - 创建合同(异步)"""
|
||||
validated_data = ContractCreate.model_validate(data)
|
||||
request_data = {"push_id": push_id, "data": validated_data.model_dump(exclude_none=True)}
|
||||
response = await client.request("POST", "/contract/create", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"Failed to create contract: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_update_contract(client: ApiClient, data_id: str, data: Dict[str, Any], push_id: str) -> Dict[str, Any]:
|
||||
"""PUT /contract/update - 更新合同(同步)"""
|
||||
validated_data = ContractUpdate.model_validate(data)
|
||||
request_data = {"push_id": push_id, "biz_id": data_id, "data": validated_data.model_dump(exclude_none=True)}
|
||||
response = await client.request("PUT", "/contract/update", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"Failed to update contract: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_delete_contract(client: ApiClient, data_id: str, push_id: str) -> Dict[str, Any]:
|
||||
"""DELETE /contract/delete - 删除合同(同步)"""
|
||||
request_data = {"push_id": push_id, "biz_id": data_id}
|
||||
response = await client.request("DELETE", "/contract/delete", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"Failed to delete contract: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
# ========== Handler ==========
|
||||
|
||||
class ContractApiHandler(BaseApiHandler):
|
||||
"""合同 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "contract"
|
||||
self._node_class = ContractSyncNode
|
||||
self._schema = ContractResponse
|
||||
self.update_schemas = [ContractCreate, ContractUpdate]
|
||||
|
||||
# ========== Handler 接口实现 ==========
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""
|
||||
加载合同列表
|
||||
|
||||
依赖 Project:从 collection 获取项目列表,然后加载这些项目的合同
|
||||
|
||||
Returns:
|
||||
合同数据列表
|
||||
"""
|
||||
try:
|
||||
# 从 collection 获取项目列表
|
||||
projects = self._collection.filter(node_type="project")
|
||||
project_ids = [p.data_id for p in projects if p.data_id]
|
||||
|
||||
if not project_ids:
|
||||
print("⚠️ No projects found, skipping contract load")
|
||||
return []
|
||||
|
||||
# 为每个项目加载合同
|
||||
all_nodes = []
|
||||
for project_id in project_ids:
|
||||
try:
|
||||
contracts = await api_get_contract_list(self.api_client, project_id)
|
||||
|
||||
# 已验证的 Pydantic model,转换为 dict 并创建 node
|
||||
for contract in contracts:
|
||||
contract_data = contract.model_dump()
|
||||
all_nodes.append(self._create_node(contract_data))
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load contracts for project {project_id}: {e}")
|
||||
continue
|
||||
|
||||
return all_nodes
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load contracts: {e}")
|
||||
return []
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建合同"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
result = await self._create_single(node)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量更新合同"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
result = await self._update_single(node)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量删除合同"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
result = await self._delete_single(node)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
|
||||
"""轮询异步任务状态"""
|
||||
return await super().poll_tasks(task_ids)
|
||||
|
||||
async def _create_single(self, node: SyncNode) -> TaskResult:
|
||||
"""
|
||||
创建合同(异步)
|
||||
|
||||
Args:
|
||||
node: 同步节点
|
||||
|
||||
Returns:
|
||||
TaskResult (IN_PROGRESS with push_id)
|
||||
"""
|
||||
push_id: str | None = None
|
||||
try:
|
||||
# 获取数据
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
return TaskResult.failed(node_id=node.node_id, error="Node data is None or validation failed")
|
||||
|
||||
# 从 context 获取 project_id
|
||||
project_id = node.context.get("project_id")
|
||||
if not project_id:
|
||||
project_id = data.get("project_id")
|
||||
|
||||
if not project_id:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="project_id not found in context or data"
|
||||
)
|
||||
|
||||
# 构建请求数据(确保包含 project_id)
|
||||
request_data = {
|
||||
"project_id": project_id,
|
||||
**data
|
||||
}
|
||||
|
||||
# 调用 API(直接调用顶层函数)
|
||||
push_id = self._generate_push_id()
|
||||
response = await api_create_contract(self.api_client, request_data, push_id)
|
||||
if self.poll_mode == "sync":
|
||||
created_id = self._extract_created_id_from_response(response)
|
||||
if not created_id:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Missing created_id in create response"
|
||||
)
|
||||
return TaskResult.success(node_id=node.node_id, data_id=created_id)
|
||||
else:
|
||||
return TaskResult.in_progress(node_id=node.node_id, task_id=push_id)
|
||||
|
||||
except Exception as e:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"Create failed: {str(e)}",
|
||||
push_id=push_id,
|
||||
)
|
||||
|
||||
async def _update_single(self, node: SyncNode) -> TaskResult:
|
||||
"""
|
||||
更新合同(同步)
|
||||
|
||||
自动选择需要更新的字段(基于 update_schema)
|
||||
|
||||
Args:
|
||||
node: 同步节点
|
||||
|
||||
Returns:
|
||||
TaskResult (SUCCESS or FAILED)
|
||||
"""
|
||||
try:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
return TaskResult.failed(node_id=node.node_id, error="Missing data for update")
|
||||
|
||||
# 过滤出需要更新的字段(排除空值、未变化的字段)
|
||||
origin_data = node.get_origin_data() or {}
|
||||
update_data = self._filter_update_data(
|
||||
new_data=data,
|
||||
original_data=origin_data,
|
||||
schema=ContractUpdate,
|
||||
)
|
||||
|
||||
# 如果没有需要更新的字段,降级 action 并返回跳过
|
||||
if not update_data:
|
||||
return TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed")
|
||||
|
||||
# Schema 验证
|
||||
error = self._validate_schema(update_data, ContractUpdate)
|
||||
if error:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"Schema validation failed: {error}"
|
||||
)
|
||||
|
||||
# 生成 push_id 和 biz_id
|
||||
push_id = self._generate_push_id()
|
||||
biz_id = node.data_id or update_data.get("id")
|
||||
if not biz_id:
|
||||
return TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update")
|
||||
|
||||
# 调用 API
|
||||
response = await api_update_contract(self.api_client, biz_id, update_data, push_id)
|
||||
|
||||
# 检查响应状态(更新接口是同步的)
|
||||
if response.get("code") == 200:
|
||||
return TaskResult.success(node_id=node.node_id)
|
||||
else:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"API returned error: {response.get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"Update failed: {str(e)}"
|
||||
)
|
||||
|
||||
async def _delete_single(self, node: SyncNode) -> TaskResult:
|
||||
"""
|
||||
删除合同(同步)
|
||||
|
||||
Args:
|
||||
node: 同步节点
|
||||
|
||||
Returns:
|
||||
TaskResult (SUCCESS or FAILED)
|
||||
"""
|
||||
if not self.api_client:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="api_client not initialized"
|
||||
)
|
||||
|
||||
try:
|
||||
# 生成 push_id 和 biz_id
|
||||
push_id = self._generate_push_id()
|
||||
biz_id = node.data_id
|
||||
if not biz_id:
|
||||
return TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete")
|
||||
|
||||
# 调用 API
|
||||
response = await api_delete_contract(self.api_client, biz_id, push_id)
|
||||
|
||||
# 检查响应状态
|
||||
if response.get("code") == 200:
|
||||
return TaskResult.success(node_id=node.node_id)
|
||||
else:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"API returned error: {response.get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"Delete failed: {str(e)}"
|
||||
)
|
||||
|
||||
# ========== 辅助方法 ==========
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
"""创建合同同步节点(设置 project_id 到 context)"""
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
# 设置 project_id 到 context(供子节点使用)
|
||||
if data.get("project_id"):
|
||||
node.context["project_id"] = data["project_id"]
|
||||
|
||||
return node
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Contract JSONL Handler
|
||||
"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import ContractSyncNode
|
||||
from schemas.contract.contract import ContractResponse
|
||||
|
||||
|
||||
class ContractJsonlHandler(BaseJsonlHandler):
|
||||
"""合同 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "contract", ContractSyncNode, ContractResponse)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Contract domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.contract.contract import ContractResponse
|
||||
from .sync_node import ContractSyncNode
|
||||
from .sync_strategy import ContractSyncStrategy
|
||||
from .jsonl_handler import ContractJsonlHandler
|
||||
from .api_handler import ContractApiHandler
|
||||
|
||||
# 注册 contract domain
|
||||
DomainRegistry.register(
|
||||
node_type="contract",
|
||||
schema=ContractResponse,
|
||||
node_class=ContractSyncNode,
|
||||
strategy_class=ContractSyncStrategy,
|
||||
jsonl_handler_class=ContractJsonlHandler,
|
||||
api_handler_class=ContractApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from ...common.sync_node import SyncNode
|
||||
from schemas.contract.contract import ContractResponse
|
||||
|
||||
|
||||
class ContractSyncNode(SyncNode[ContractResponse]):
|
||||
"""合同同步节点"""
|
||||
node_type = "contract"
|
||||
schema = ContractResponse
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -0,0 +1,30 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.contract.contract import ContractResponse
|
||||
|
||||
|
||||
class ContractSyncStrategy(DefaultSyncStrategy[ContractResponse]):
|
||||
"""
|
||||
Contract 同步策略
|
||||
|
||||
业务规则:
|
||||
1. 使用 (project_id, code) 作为业务唯一键
|
||||
2. 依赖于 project 和 company
|
||||
3. 支持自动绑定
|
||||
4. 默认配置为日常推送模式(本地创建推送到远程,不拉取远程孤儿)
|
||||
|
||||
schema 已在类定义中设置,禁止在初始化时传入其他 schema。
|
||||
"""
|
||||
|
||||
# 类变量:schema 固定为 ContractResponse
|
||||
schema = ContractResponse
|
||||
|
||||
# 类变量:默认配置
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["project_id","company_id", "code"],
|
||||
depend_fields={"project_id": "project", "company_id": "supplier"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Contract Change (合同变更) Domain Module"""
|
||||
|
||||
from .sync_node import ContractChangeSyncNode
|
||||
from .sync_strategy import ContractChangeSyncStrategy
|
||||
from .api_handler import ContractChangeApiHandler
|
||||
from .jsonl_handler import ContractChangeJsonlHandler
|
||||
|
||||
__all__ = [
|
||||
"ContractChangeSyncNode",
|
||||
"ContractChangeSyncStrategy",
|
||||
"ContractChangeApiHandler",
|
||||
"ContractChangeJsonlHandler",
|
||||
]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Contract Change (合同变更) API Handler
|
||||
|
||||
职责:
|
||||
- 创建和更新合同变更记录(依赖 Project 和 Contract)
|
||||
|
||||
实现的接口:
|
||||
POST:
|
||||
- /contract_change/create - 创建合同变更
|
||||
PUT:
|
||||
- /contract_change/update - 更新合同变更
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import ERROR_NODE_DATA_NONE, ERROR_VALIDATION_FAILED
|
||||
from .sync_node import ContractChangeSyncNode
|
||||
from schemas.project_extensions.contract_change import ContractChangeResponse, PostContractChange, CreateContractChange
|
||||
|
||||
|
||||
class ContractChangeApiHandler(BaseApiHandler):
|
||||
"""合同变更 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "contract_change"
|
||||
self._node_class = ContractChangeSyncNode
|
||||
# TODO: ContractChangeResponse schema in schemas/project_extensions/contract_change.py is missing 'project_id'
|
||||
self._schema = ContractChangeResponse
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""加载合同变更数据"""
|
||||
return []
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建合同变更"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
for node in nodes:
|
||||
node_data = node.get_data()
|
||||
if node_data is None:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
create_model = CreateContractChange.model_validate(node_data)
|
||||
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()
|
||||
|
||||
try:
|
||||
response = await api_create_contract_change(self.api_client, create_model, push_id)
|
||||
if self.poll_mode == "sync":
|
||||
created_id = self._extract_created_id_from_response(response)
|
||||
if not created_id:
|
||||
results.append(
|
||||
TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Missing created_id in create response"
|
||||
)
|
||||
)
|
||||
continue
|
||||
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
|
||||
else:
|
||||
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量更新合同变更"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
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 update: {error_msg}"))
|
||||
continue
|
||||
|
||||
origin_data = node.get_origin_data() or {}
|
||||
update_data = self._filter_update_data(node_data, origin_data, PostContractChange)
|
||||
if not update_data:
|
||||
from ...common.types import SyncAction
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
|
||||
continue
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
update_model = PostContractChange.model_validate(update_data)
|
||||
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 = node.data_id or update_model.id
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
|
||||
continue
|
||||
|
||||
try:
|
||||
await api_update_contract_change(self.api_client, update_model, push_id, biz_id)
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
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="Contract change 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)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("project_id"):
|
||||
node.context["project_id"] = data["project_id"]
|
||||
if data.get("contract_id"):
|
||||
node.context["contract_id"] = data["contract_id"]
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_create_contract_change(client, data: CreateContractChange, push_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
POST /contract_change/create - 创建合同变更
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 创建数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {"push_id": push_id, "data": data.model_dump()}
|
||||
response = await client.request(method="POST", endpoint="/contract_change/create", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_update_contract_change(client, data: PostContractChange, push_id: str, biz_id: str) -> None:
|
||||
"""
|
||||
PUT /contract_change/update - 更新合同变更
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 更新数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
|
||||
response = await client.request(method="PUT", endpoint="/contract_change/update", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Contract Change Domain - JSONL Handler"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import ContractChangeSyncNode
|
||||
from schemas.project_extensions.contract_change import ContractChangeResponse
|
||||
|
||||
|
||||
class ContractChangeJsonlHandler(BaseJsonlHandler):
|
||||
"""合同变更 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "contract_change", ContractChangeSyncNode, ContractChangeResponse)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Contract change domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.project_extensions.contract_change import ContractChangeResponse
|
||||
from .sync_node import ContractChangeSyncNode
|
||||
from .sync_strategy import ContractChangeSyncStrategy
|
||||
from .api_handler import ContractChangeApiHandler
|
||||
from .jsonl_handler import ContractChangeJsonlHandler
|
||||
|
||||
# 注册 contract_change domain
|
||||
DomainRegistry.register(
|
||||
node_type="contract_change",
|
||||
schema=ContractChangeResponse,
|
||||
node_class=ContractChangeSyncNode,
|
||||
strategy_class=ContractChangeSyncStrategy,
|
||||
jsonl_handler_class=ContractChangeJsonlHandler,
|
||||
api_handler_class=ContractChangeApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Contract Change SyncNode"""
|
||||
|
||||
from ...common.sync_node import SyncNode
|
||||
from schemas.project_extensions.contract_change import ContractChangeResponse
|
||||
|
||||
|
||||
class ContractChangeSyncNode(SyncNode[ContractChangeResponse]):
|
||||
"""合同变更同步节点"""
|
||||
node_type = "contract_change"
|
||||
schema = ContractChangeResponse
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.project_extensions.contract_change import ContractChangeResponse
|
||||
|
||||
|
||||
class ContractChangeSyncStrategy(DefaultSyncStrategy[ContractChangeResponse]):
|
||||
"""
|
||||
Contract Change 同步策略。
|
||||
"""
|
||||
|
||||
schema = ContractChangeResponse
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["contract_id", "change_time", "title"],
|
||||
depend_fields={"project_id": "project", "contract_id": "contract"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Contract settlement domain"""
|
||||
from .sync_node import ContractSettlementSyncNode
|
||||
from .sync_strategy import ContractSettlementSyncStrategy
|
||||
from .jsonl_handler import ContractSettlementJsonlHandler
|
||||
|
||||
__all__ = ["ContractSettlementSyncNode", "ContractSettlementSyncStrategy", "ContractSettlementJsonlHandler"]
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Contract Settlement API Handler
|
||||
|
||||
职责:
|
||||
- 加载合同结算列表(依赖 Project)
|
||||
- 合同结算创建/更新/删除(批量接口)
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import (
|
||||
ERROR_NODE_DATA_NONE,
|
||||
ERROR_VALIDATION_FAILED,
|
||||
ERROR_PROJECT_ID_NOT_FOUND
|
||||
)
|
||||
from .sync_node import ContractSettlementSyncNode
|
||||
from schemas.contract_settlement.contract_settlement import (
|
||||
ContractSettlementResponse,
|
||||
ContractSettlementCreate,
|
||||
ContractSettlementUpdate,
|
||||
ContractSettlementPlanUpdate,
|
||||
)
|
||||
|
||||
|
||||
class ContractSettlementApiHandler(BaseApiHandler):
|
||||
"""合同结算 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "contract_settlement"
|
||||
self._node_class = ContractSettlementSyncNode
|
||||
self._schema = ContractSettlementResponse
|
||||
self.update_schemas = [ContractSettlementCreate, ContractSettlementUpdate, ContractSettlementPlanUpdate]
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""加载合同结算列表(依赖 Project)"""
|
||||
projects = self._collection.filter(node_type="project")
|
||||
project_ids = [p.data_id for p in projects if p.data_id]
|
||||
|
||||
if not project_ids:
|
||||
return []
|
||||
|
||||
all_settlements = []
|
||||
for project_id in project_ids:
|
||||
try:
|
||||
settlements = await api_get_contract_settlement_list(self.api_client, project_id)
|
||||
for settlement_model in settlements:
|
||||
# 已经过验证的 Pydantic model,转换为 dict
|
||||
settlement_data = settlement_model.model_dump()
|
||||
all_settlements.append(self._create_node(settlement_data))
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load settlements for project {project_id}: {e}")
|
||||
|
||||
return all_settlements
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建合同结算"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
project_id = node.context.get("project_id") or data.get("project_id")
|
||||
if not project_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_PROJECT_ID_NOT_FOUND))
|
||||
continue
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
create_data = {"project_id": project_id, **data}
|
||||
create_model = ContractSettlementCreate.model_validate(create_data)
|
||||
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()
|
||||
|
||||
try:
|
||||
response = await api_create_contract_settlement(self.api_client, create_model, push_id)
|
||||
if self.poll_mode == "sync":
|
||||
created_id = self._extract_created_id_from_response(response)
|
||||
if not created_id:
|
||||
results.append(
|
||||
TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Missing created_id in create response"
|
||||
)
|
||||
)
|
||||
continue
|
||||
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
|
||||
else:
|
||||
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""
|
||||
批量更新合同结算
|
||||
|
||||
支持两种更新类型:
|
||||
1. ContractSettlementUpdate - 基础信息更新
|
||||
2. ContractSettlementPlanUpdate - 计划信息更新
|
||||
|
||||
更新逻辑:
|
||||
- 对每种 schema,检查是否有差异
|
||||
- 如果有差异,调用对应 API
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
origin_data = node.get_origin_data() or {}
|
||||
biz_id = node.data_id or data.get("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
|
||||
|
||||
# 1. 基础信息更新 (ContractSettlementUpdate)
|
||||
base_diff = self._get_schema_diff(ContractSettlementUpdate, data, origin_data, node.node_id)
|
||||
if base_diff:
|
||||
try:
|
||||
update_model = ContractSettlementUpdate.model_validate(data)
|
||||
push_id = self._generate_push_id()
|
||||
await api_update_contract_settlement(self.api_client, update_model, push_id, biz_id)
|
||||
node_updated = True
|
||||
except ValidationError as e:
|
||||
error = f"Validation failed: {e}"
|
||||
except Exception as e:
|
||||
error = f"API error: {e}"
|
||||
|
||||
# 2. 计划信息更新 (ContractSettlementPlanUpdate)
|
||||
plan_diff = self._get_schema_diff(ContractSettlementPlanUpdate, data, origin_data, node.node_id)
|
||||
if not error and plan_diff:
|
||||
try:
|
||||
plan_model = ContractSettlementPlanUpdate.model_validate(data)
|
||||
push_id = self._generate_push_id()
|
||||
# 使用计划更新专用 API
|
||||
await api_update_settlement_plan(self.api_client, plan_model.model_dump(exclude_unset=True), push_id, biz_id)
|
||||
node_updated = True
|
||||
except ValidationError as e:
|
||||
error = f"Validation failed: {e}"
|
||||
except Exception as e:
|
||||
error = f"API error: {e}"
|
||||
|
||||
# 汇总结果
|
||||
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:
|
||||
# 没有任何字段需要更新
|
||||
from ...common.types import SyncAction
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
|
||||
|
||||
return results
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量删除合同结算"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
push_id = self._generate_push_id()
|
||||
biz_id = node.data_id
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete"))
|
||||
continue
|
||||
|
||||
try:
|
||||
await api_delete_contract_settlement(self.api_client, biz_id, push_id)
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
|
||||
"""轮询异步任务状态(占位)"""
|
||||
return await super().poll_tasks(task_ids)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("project_id"):
|
||||
node.context["project_id"] = data["project_id"]
|
||||
node.context["settlement_id"] = node.data_id
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_settlement(client, settlement_id: str) -> Dict[str, Any]:
|
||||
"""获取单个合同结算数据 GET /contract_settlement"""
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/contract_settlement",
|
||||
params={"settlement_id": settlement_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response.get("data", {})
|
||||
|
||||
|
||||
async def api_get_contract_settlement_list(client, project_id: str) -> List[ContractSettlementResponse]:
|
||||
"""
|
||||
获取合同结算列表 GET /contract_settlement/list
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
project_id: 项目ID
|
||||
|
||||
Returns:
|
||||
List[ContractSettlementResponse]: 验证过的结算列表
|
||||
|
||||
Raises:
|
||||
Exception: API 返回错误或数据验证失败
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/contract_settlement/list",
|
||||
params={"project_id": project_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
items = response.get("data", [])
|
||||
|
||||
# 验证每个返回项
|
||||
validated_items = []
|
||||
for item in items:
|
||||
try:
|
||||
validated_item = ContractSettlementResponse.model_validate(item)
|
||||
validated_items.append(validated_item)
|
||||
except ValidationError as e:
|
||||
print(f"⚠️ Skipping invalid settlement item: {e}")
|
||||
continue
|
||||
|
||||
return validated_items
|
||||
|
||||
|
||||
async def api_create_contract_settlement(client, data: ContractSettlementCreate, push_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
创建合同结算 POST /contract_settlement/create
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 创建数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
|
||||
Returns:
|
||||
str: push_id
|
||||
|
||||
Raises:
|
||||
Exception: API 返回错误
|
||||
"""
|
||||
request_data = {"push_id": push_id, "data": data.model_dump()}
|
||||
response = await client.request(method="POST", endpoint="/contract_settlement/create", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_update_contract_settlement(client, data: ContractSettlementUpdate, push_id: str, biz_id: str) -> None:
|
||||
"""
|
||||
更新合同结算 PUT /contract_settlement/update
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 更新数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
|
||||
Raises:
|
||||
Exception: API 返回错误
|
||||
"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
|
||||
response = await client.request(method="PUT", endpoint="/contract_settlement/update", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_delete_contract_settlement(client, settlement_id: str, push_id: str) -> None:
|
||||
"""删除合同结算 DELETE /contract_settlement/delete"""
|
||||
request_data = {"push_id": push_id, "biz_id": settlement_id}
|
||||
response = await client.request(method="DELETE", endpoint="/contract_settlement/delete", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_update_settlement_plan(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
|
||||
"""更新合同结算计划 PUT /contract_settlement/plan"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
response = await client.request(method="PUT", endpoint="/contract_settlement/plan", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Contract Settlement Domain - JSONL Handler"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import ContractSettlementSyncNode
|
||||
from schemas.contract_settlement.contract_settlement import ContractSettlementResponse
|
||||
|
||||
|
||||
class ContractSettlementJsonlHandler(BaseJsonlHandler):
|
||||
"""合同结算 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "contract_settlement", ContractSettlementSyncNode, ContractSettlementResponse)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Contract settlement domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.contract_settlement.contract_settlement import ContractSettlementResponse
|
||||
from .sync_node import ContractSettlementSyncNode
|
||||
from .sync_strategy import ContractSettlementSyncStrategy
|
||||
from .jsonl_handler import ContractSettlementJsonlHandler
|
||||
from .api_handler import ContractSettlementApiHandler
|
||||
|
||||
# 注册 contract_settlement domain
|
||||
DomainRegistry.register(
|
||||
node_type="contract_settlement",
|
||||
schema=ContractSettlementResponse,
|
||||
node_class=ContractSettlementSyncNode,
|
||||
strategy_class=ContractSettlementSyncStrategy,
|
||||
jsonl_handler_class=ContractSettlementJsonlHandler,
|
||||
api_handler_class=ContractSettlementApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from ...common.sync_node import SyncNode
|
||||
from schemas.contract_settlement.contract_settlement import ContractSettlementResponse
|
||||
|
||||
|
||||
class ContractSettlementSyncNode(SyncNode[ContractSettlementResponse]):
|
||||
"""合同结算同步节点"""
|
||||
node_type = "contract_settlement"
|
||||
schema = ContractSettlementResponse
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.contract_settlement.contract_settlement import ContractSettlementResponse
|
||||
|
||||
|
||||
class ContractSettlementSyncStrategy(DefaultSyncStrategy[ContractSettlementResponse]):
|
||||
"""
|
||||
Contract Settlement 同步策略(合同子业务)。
|
||||
"""
|
||||
|
||||
schema = ContractSettlementResponse
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["contract_id"],
|
||||
depend_fields={"contract_id": "contract", "project_id": "project"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Contract Settlement Detail Domain
|
||||
"""
|
||||
from .sync_node import ContractSettlementDetailSyncNode
|
||||
from .sync_strategy import ContractSettlementDetailSyncStrategy
|
||||
from .jsonl_handler import ContractSettlementDetailJsonlHandler
|
||||
|
||||
__all__ = ["ContractSettlementDetailSyncNode", "ContractSettlementDetailSyncStrategy", "ContractSettlementDetailJsonlHandler"]
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Contract Settlement Detail API Handler
|
||||
|
||||
职责:
|
||||
- 加载合同结算明细列表(依赖 ContractSettlement)
|
||||
- 合同结算明细创建/更新/删除(批量接口)
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import ERROR_NODE_DATA_NONE, ERROR_VALIDATION_FAILED, ERROR_ID_NOT_FOUND
|
||||
from .sync_node import ContractSettlementDetailSyncNode
|
||||
from schemas.contract_settlement.contract_settlement_detail import (
|
||||
ContractSettlementDetailResponse,
|
||||
ContractSettlementDetailCreate,
|
||||
ContractSettlementDetailUpdate
|
||||
)
|
||||
|
||||
|
||||
class ContractSettlementDetailApiHandler(BaseApiHandler):
|
||||
"""合同结算明细 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "contract_settlement_detail"
|
||||
self._node_class = ContractSettlementDetailSyncNode
|
||||
self._schema = ContractSettlementDetailResponse
|
||||
self.update_schemas = [ContractSettlementDetailCreate, ContractSettlementDetailUpdate]
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""加载合同结算明细列表(依赖 ContractSettlement)"""
|
||||
settlements = self._collection.filter(node_type="contract_settlement")
|
||||
settlement_ids = [s.data_id for s in settlements if s.data_id]
|
||||
|
||||
if not settlement_ids:
|
||||
return []
|
||||
|
||||
all_details = []
|
||||
for settlement_id in settlement_ids:
|
||||
try:
|
||||
details = await api_get_contract_settlement_detail_list(self.api_client, settlement_id)
|
||||
for detail_model in details:
|
||||
detail_data = detail_model.model_dump()
|
||||
all_details.append(self._create_node(detail_data))
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load settlement details for {settlement_id}: {e}")
|
||||
|
||||
return all_details
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建合同结算明细"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
# 从data的parent_id获取settlement_id
|
||||
settlement_id = data.get("parent_id")
|
||||
if not settlement_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_ID_NOT_FOUND))
|
||||
continue
|
||||
|
||||
try:
|
||||
create_data = {"settlement_id": settlement_id, **data}
|
||||
create_model = ContractSettlementDetailCreate.model_validate(create_data)
|
||||
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()
|
||||
|
||||
try:
|
||||
response = await api_create_contract_settlement_detail(self.api_client, create_model, push_id)
|
||||
if self.poll_mode == "sync":
|
||||
created_id = self._extract_created_id_from_response(response)
|
||||
if not created_id:
|
||||
results.append(
|
||||
TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Missing created_id in create response"
|
||||
)
|
||||
)
|
||||
continue
|
||||
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
|
||||
else:
|
||||
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""
|
||||
批量更新合同结算明细
|
||||
|
||||
按 settlement_id 分组,每组使用同一个 push_id 批量提交
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
from collections import defaultdict
|
||||
|
||||
results = []
|
||||
|
||||
# 按 settlement_id 分组
|
||||
groups: Dict[str, List[tuple[SyncNode, Dict[str, Any]]]] = defaultdict(list)
|
||||
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
origin_data = node.get_origin_data() or {}
|
||||
|
||||
# 使用 _get_schema_diff 检查差异并打印日志
|
||||
diff_fields = self._get_schema_diff(ContractSettlementDetailUpdate, data, origin_data, node.node_id)
|
||||
if not diff_fields:
|
||||
from ...common.types import SyncAction
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
|
||||
continue
|
||||
|
||||
# 获取 settlement_id(parent_id)
|
||||
settlement_id = data.get("parent_id") or node.context.get("settlement_id")
|
||||
if not settlement_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing settlement_id (parent_id)"))
|
||||
continue
|
||||
|
||||
# 需要完整的 schema 数据(包括必填字段)
|
||||
update_data = self._filter_update_data(data, origin_data, ContractSettlementDetailUpdate)
|
||||
|
||||
try:
|
||||
update_model = ContractSettlementDetailUpdate.model_validate(update_data)
|
||||
groups[settlement_id].append((node, update_model))
|
||||
except ValidationError as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
|
||||
|
||||
# 逐组批量更新
|
||||
for settlement_id, group_items in groups.items():
|
||||
push_id = self._generate_push_id()
|
||||
|
||||
# 准备批量数据
|
||||
detail_list = [model.model_dump(mode='json', exclude_unset=True) for node, model in group_items]
|
||||
|
||||
# 使用第一个节点的 biz_id(对于批量更新,通常使用 parent_id)
|
||||
biz_id = settlement_id
|
||||
|
||||
try:
|
||||
# 批量调用 API
|
||||
await api_update_contract_settlement_detail(
|
||||
self.api_client,
|
||||
{"detail_list": detail_list},
|
||||
push_id,
|
||||
biz_id,
|
||||
)
|
||||
# 整组成功
|
||||
for node, _ in group_items:
|
||||
results.append(TaskResult.success(node_id=node.node_id, push_id=push_id))
|
||||
except Exception as e:
|
||||
# 整组失败
|
||||
error_msg = str(e)
|
||||
for node, _ in group_items:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=error_msg))
|
||||
|
||||
return results
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量删除合同结算明细"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
push_id = self._generate_push_id()
|
||||
biz_id = node.data_id
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete"))
|
||||
continue
|
||||
|
||||
try:
|
||||
await api_delete_contract_settlement_detail(self.api_client, biz_id, push_id)
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
|
||||
"""轮询异步任务状态(占位)"""
|
||||
return await super().poll_tasks(task_ids)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("settlement_id"):
|
||||
node.context["settlement_id"] = data["settlement_id"]
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_contract_settlement_detail_list(client, settlement_id: str) -> List[ContractSettlementDetailResponse]:
|
||||
"""
|
||||
获取合同结算明细列表
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
settlement_id: 结算ID
|
||||
|
||||
Returns:
|
||||
List[ContractSettlementDetailResponse]: 验证过的明细列表
|
||||
|
||||
Raises:
|
||||
Exception: API 返回错误或数据验证失败
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/contract_settlement/detail/list",
|
||||
params={"settlement_id": settlement_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
items = response.get("data", [])
|
||||
|
||||
validated_items = []
|
||||
for item in items:
|
||||
try:
|
||||
validated_item = ContractSettlementDetailResponse.model_validate(item)
|
||||
validated_items.append(validated_item)
|
||||
except ValidationError as e:
|
||||
print(f"⚠️ Skipping invalid detail item: {e}")
|
||||
continue
|
||||
|
||||
return validated_items
|
||||
|
||||
|
||||
async def api_create_contract_settlement_detail(client, data: ContractSettlementDetailCreate, push_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
创建合同结算明细
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 创建数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
|
||||
Returns:
|
||||
str: push_id
|
||||
|
||||
Raises:
|
||||
Exception: API 返回错误
|
||||
"""
|
||||
request_data = {"push_id": push_id, "data": data.model_dump(mode='json')}
|
||||
response = await client.request(method="POST", endpoint="/contract_settlement/detail/create", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_update_contract_settlement_detail(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
|
||||
"""
|
||||
更新合同结算明细
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 更新数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
|
||||
Raises:
|
||||
Exception: API 返回错误
|
||||
"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
response = await client.request(method="PUT", endpoint="/contract_settlement/detail/update", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_delete_contract_settlement_detail(client, detail_id: str, push_id: str) -> None:
|
||||
request_data = {"push_id": push_id, "biz_id": detail_id}
|
||||
response = await client.request(method="DELETE", endpoint="/contract_settlement/detail/delete", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Contract Settlement Detail JSONL Handler
|
||||
"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import ContractSettlementDetailSyncNode
|
||||
from schemas.contract_settlement.contract_settlement_detail import ContractSettlementDetailResponse
|
||||
|
||||
|
||||
class ContractSettlementDetailJsonlHandler(BaseJsonlHandler):
|
||||
"""合同结算明细 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "contract_settlement_detail", ContractSettlementDetailSyncNode, ContractSettlementDetailResponse)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Contract settlement detail domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.contract_settlement.contract_settlement_detail import ContractSettlementDetailResponse
|
||||
from .sync_node import ContractSettlementDetailSyncNode
|
||||
from .sync_strategy import ContractSettlementDetailSyncStrategy
|
||||
from .jsonl_handler import ContractSettlementDetailJsonlHandler
|
||||
from .api_handler import ContractSettlementDetailApiHandler
|
||||
|
||||
# 注册 contract_settlement_detail domain
|
||||
DomainRegistry.register(
|
||||
node_type="contract_settlement_detail",
|
||||
schema=ContractSettlementDetailResponse,
|
||||
node_class=ContractSettlementDetailSyncNode,
|
||||
strategy_class=ContractSettlementDetailSyncStrategy,
|
||||
jsonl_handler_class=ContractSettlementDetailJsonlHandler,
|
||||
api_handler_class=ContractSettlementDetailApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Contract Settlement Detail Domain
|
||||
"""
|
||||
from ...common import SyncNode
|
||||
from schemas.contract_settlement.contract_settlement_detail import ContractSettlementDetailResponse
|
||||
|
||||
|
||||
class ContractSettlementDetailSyncNode(SyncNode[ContractSettlementDetailResponse]):
|
||||
"""合同结算明细同步节点"""
|
||||
node_type = "contract_settlement_detail"
|
||||
schema = ContractSettlementDetailResponse
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.contract_settlement.contract_settlement_detail import ContractSettlementDetailResponse
|
||||
|
||||
|
||||
class ContractSettlementDetailSyncStrategy(DefaultSyncStrategy[ContractSettlementDetailResponse]):
|
||||
"""
|
||||
Contract Settlement Detail 同步策略(合同子业务明细)。
|
||||
"""
|
||||
|
||||
schema = ContractSettlementDetailResponse
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["parent_id", "date"],
|
||||
depend_fields={"parent_id": "contract_settlement"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""LAR domain"""
|
||||
from .sync_node import LarSyncNode
|
||||
from .sync_strategy import LarSyncStrategy
|
||||
from .jsonl_handler import LarJsonlHandler
|
||||
|
||||
__all__ = ["LarSyncNode", "LarSyncStrategy", "LarJsonlHandler"]
|
||||
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
LAR (移民征地) API Handler
|
||||
|
||||
职责:
|
||||
- 更新移民征地数据(依赖 Project)
|
||||
- 支持主数据、搬迁信息、投资信息三种更新
|
||||
- 不支持创建和删除(数据随项目自动生成)
|
||||
|
||||
实现的接口:
|
||||
PUT:
|
||||
- /lar/main - 更新主数据
|
||||
- /lar/resettlement - 更新搬迁信息
|
||||
- /lar/investment - 更新投资信息
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import ERROR_VALIDATION_FAILED
|
||||
from .sync_node import LarSyncNode
|
||||
from schemas.project_extensions.lar import (
|
||||
LarDetailSchema,
|
||||
LarMainInfoUpdateSchema,
|
||||
LarResettlementSchema,
|
||||
LarInvestmentSchema
|
||||
)
|
||||
|
||||
|
||||
class LarApiHandler(BaseApiHandler):
|
||||
"""移民征地 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "lar"
|
||||
self._node_class = LarSyncNode
|
||||
self._schema = LarDetailSchema
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""
|
||||
加载移民征地数据(从 project all_info 中提取)
|
||||
|
||||
注意:LAR 是可选的,只有特定项目类型(水电、抽蓄)才有
|
||||
"""
|
||||
from ..project.api_handler import ProjectApiHandler
|
||||
|
||||
# 获取已加载的项目
|
||||
projects = self._collection.filter(node_type="project")
|
||||
if not projects:
|
||||
print("⚠️ No projects found, skipping LAR load")
|
||||
return []
|
||||
|
||||
nodes = []
|
||||
for project in projects:
|
||||
project_id = project.data_id
|
||||
|
||||
try:
|
||||
# 从 project context 或缓存获取 all_info
|
||||
all_info = project.context.get("all_info")
|
||||
if not all_info:
|
||||
all_info = await ProjectApiHandler.get_all_info(
|
||||
self.api_client,
|
||||
project_id
|
||||
)
|
||||
|
||||
# 提取 lar(注意:lar 是单个对象,不是列表)
|
||||
lar_data = all_info.get("lar")
|
||||
if lar_data:
|
||||
try:
|
||||
validated = self._schema.model_validate(lar_data)
|
||||
node = self._create_node(validated.model_dump())
|
||||
node.context["project_id"] = project_id
|
||||
nodes.append(node)
|
||||
print(f" Project {project_id}: 1 LAR")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Skipping invalid LAR for project {project_id}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load LAR for project {project_id}: {e}")
|
||||
continue
|
||||
|
||||
return nodes
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""不支持创建(LAR随项目自动生成)"""
|
||||
return [
|
||||
TaskResult.failed(node_id=node.node_id, error="LAR creation not supported (auto-generated with project)")
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量更新移民征地数据"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
|
||||
# 定义三种更新类型
|
||||
update_configs = [
|
||||
(LarMainInfoUpdateSchema, api_update_lar_main),
|
||||
(LarResettlementSchema, api_update_lar_resettlement),
|
||||
(LarInvestmentSchema, api_update_lar_investment),
|
||||
]
|
||||
|
||||
for node in nodes:
|
||||
node_data = node.get_data()
|
||||
if not node_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: {error_msg}"))
|
||||
continue
|
||||
|
||||
origin_data = node.get_origin_data() or {}
|
||||
node_updated = False
|
||||
|
||||
for schema, api_func in update_configs:
|
||||
# 获取过滤后的更新数据
|
||||
update_data = self._filter_update_data(node_data, origin_data, schema)
|
||||
if update_data:
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
update_model = schema.model_validate(update_data)
|
||||
except ValidationError as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
|
||||
break
|
||||
|
||||
push_id = self._generate_push_id()
|
||||
update_model_data = update_model.model_dump()
|
||||
biz_id = node.data_id or update_model_data.get("id")
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
|
||||
break
|
||||
|
||||
try:
|
||||
await api_func(self.api_client, update_model, push_id, biz_id)
|
||||
node_updated = True
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
break
|
||||
|
||||
if node_updated:
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
elif not any(r.node_id == node.node_id for r in results):
|
||||
# 如果没有任何更新且没有报错,视为跳过(归一化后无差异)
|
||||
reason = f"No physical diff found across all 3 LAR sub-schemas for {node.data_id} despite strategy update request (Normalization Discrepancy)."
|
||||
print(f" [WARNING] [lar] {reason}")
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason=reason))
|
||||
|
||||
return results
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""不支持删除(LAR随项目存在)"""
|
||||
return [
|
||||
TaskResult.failed(node_id=node.node_id, error="LAR 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)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("project_id"):
|
||||
node.context["project_id"] = data["project_id"]
|
||||
node.context["lar_id"] = node.data_id
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_update_lar_main(client, data: LarMainInfoUpdateSchema, push_id: str, biz_id: str) -> None:
|
||||
"""
|
||||
PUT /lar/main - 更新主数据
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 更新数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
|
||||
response = await client.request(method="PUT", endpoint="/lar/main", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_update_lar_resettlement(client, data: LarResettlementSchema, push_id: str, biz_id: str) -> None:
|
||||
"""
|
||||
PUT /lar/resettlement - 更新搬迁信息
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 更新数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
|
||||
response = await client.request(method="PUT", endpoint="/lar/resettlement", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_update_lar_investment(client, data: LarInvestmentSchema, push_id: str, biz_id: str) -> None:
|
||||
"""
|
||||
PUT /lar/investment - 更新投资信息
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 更新数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
|
||||
response = await client.request(method="PUT", endpoint="/lar/investment", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""LAR Domain - JSONL Handler"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import LarSyncNode
|
||||
from schemas.project_extensions.lar import LarDetailSchema
|
||||
|
||||
|
||||
class LarJsonlHandler(BaseJsonlHandler):
|
||||
"""移民征地 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "lar", LarSyncNode, LarDetailSchema)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""LAR domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.project_extensions.lar import LarDetailSchema
|
||||
from .sync_node import LarSyncNode
|
||||
from .sync_strategy import LarSyncStrategy
|
||||
from .jsonl_handler import LarJsonlHandler
|
||||
from .api_handler import LarApiHandler
|
||||
|
||||
# 注册 lar domain
|
||||
DomainRegistry.register(
|
||||
node_type="lar",
|
||||
schema=LarDetailSchema,
|
||||
node_class=LarSyncNode,
|
||||
strategy_class=LarSyncStrategy,
|
||||
jsonl_handler_class=LarJsonlHandler,
|
||||
api_handler_class=LarApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""LAR SyncNode"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from ...common.sync_node import SyncNode
|
||||
from schemas.project_extensions.lar import LarDetailSchema
|
||||
|
||||
|
||||
class LarSyncNode(SyncNode[LarDetailSchema]):
|
||||
"""移民征地同步节点"""
|
||||
node_type = "lar"
|
||||
schema = LarDetailSchema
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.project_extensions.lar import LarDetailSchema
|
||||
|
||||
|
||||
class LarSyncStrategy(DefaultSyncStrategy[LarDetailSchema]):
|
||||
"""
|
||||
LAR 同步策略(项目扩展)。
|
||||
"""
|
||||
|
||||
schema = LarDetailSchema
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["project_id"],
|
||||
depend_fields={"project_id": "project"},
|
||||
local_orphan_action=OrphanAction.NONE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""LAR Change (移民变更) Domain Module"""
|
||||
|
||||
from .sync_node import LarChangeSyncNode
|
||||
from .sync_strategy import LarChangeSyncStrategy
|
||||
from .api_handler import LarChangeApiHandler
|
||||
from .jsonl_handler import LarChangeJsonlHandler
|
||||
|
||||
__all__ = ["LarChangeSyncNode", "LarChangeSyncStrategy", "LarChangeApiHandler", "LarChangeJsonlHandler"]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
LAR Change (移民变更) API Handler
|
||||
|
||||
职责:
|
||||
- 创建和更新移民变更记录(依赖 Project)
|
||||
|
||||
实现的接口:
|
||||
POST:
|
||||
- /lar/change - 创建移民变更
|
||||
PUT:
|
||||
- /lar/change - 更新移民变更
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import ERROR_NODE_DATA_NONE, ERROR_VALIDATION_FAILED
|
||||
from .sync_node import LarChangeSyncNode
|
||||
from schemas.project_extensions.lar import (
|
||||
LarChangeDetailSchema,
|
||||
LarChangeCreateSchema,
|
||||
LarChangeUpdateSchema
|
||||
)
|
||||
|
||||
|
||||
class LarChangeApiHandler(BaseApiHandler):
|
||||
"""移民变更 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "lar_change"
|
||||
self._node_class = LarChangeSyncNode
|
||||
self._schema = LarChangeDetailSchema
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""
|
||||
加载移民变更数据(依赖 Project)
|
||||
"""
|
||||
projects = self._collection.filter(node_type="project")
|
||||
project_ids = [p.data_id for p in projects if p.data_id]
|
||||
|
||||
if not project_ids:
|
||||
return []
|
||||
|
||||
# 移民变更通常需要单独查询,这里暂时返回空
|
||||
return []
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建移民变更"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
for node in nodes:
|
||||
node_data = node.get_data()
|
||||
if node_data is None:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
create_model = LarChangeCreateSchema.model_validate(node_data)
|
||||
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()
|
||||
|
||||
try:
|
||||
response = await api_create_lar_change(self.api_client, create_model, push_id)
|
||||
if self.poll_mode == "sync":
|
||||
created_id = self._extract_created_id_from_response(response)
|
||||
if not created_id:
|
||||
results.append(
|
||||
TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Missing created_id in create response"
|
||||
)
|
||||
)
|
||||
continue
|
||||
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
|
||||
else:
|
||||
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量更新移民变更"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
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 update: {error_msg}"))
|
||||
continue
|
||||
origin_data = node.get_origin_data() or {}
|
||||
update_data = self._filter_update_data(node_data, origin_data, LarChangeUpdateSchema)
|
||||
if not update_data:
|
||||
from ...common.types import SyncAction
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
|
||||
continue
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
update_model = LarChangeUpdateSchema.model_validate(update_data)
|
||||
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 = node.data_id or update_model.id
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
|
||||
continue
|
||||
|
||||
try:
|
||||
await api_update_lar_change(self.api_client, update_model, push_id, biz_id)
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
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="LAR change 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)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("project_id"):
|
||||
node.context["project_id"] = data["project_id"]
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_create_lar_change(client, data: LarChangeCreateSchema, push_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
POST /lar/change - 创建移民变更
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 创建数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {"push_id": push_id, "data": data.model_dump()}
|
||||
response = await client.request(method="POST", endpoint="/lar/change", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_update_lar_change(client, data: LarChangeUpdateSchema, push_id: str, biz_id: str) -> None:
|
||||
"""
|
||||
PUT /lar/change - 更新移民变更
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 更新数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
|
||||
response = await client.request(method="PUT", endpoint="/lar/change", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""LAR Change JSONL Data Handler"""
|
||||
from ...datasource.jsonl.handler import BaseJsonlHandler
|
||||
from ...datasource.jsonl.datasource import JsonlDataSource
|
||||
from .sync_node import LarChangeSyncNode
|
||||
from schemas.project_extensions.lar import LarChangeDetailSchema
|
||||
|
||||
|
||||
class LarChangeJsonlHandler(BaseJsonlHandler):
|
||||
"""LAR Change (移民变更) JSONL 数据处理器"""
|
||||
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "lar_change", LarChangeSyncNode, LarChangeDetailSchema)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""LAR change domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.project_extensions.lar import LarChangeDetailSchema
|
||||
from .sync_node import LarChangeSyncNode
|
||||
from .sync_strategy import LarChangeSyncStrategy
|
||||
from .api_handler import LarChangeApiHandler
|
||||
from .jsonl_handler import LarChangeJsonlHandler
|
||||
|
||||
# 注册 lar_change domain
|
||||
DomainRegistry.register(
|
||||
node_type="lar_change",
|
||||
schema=LarChangeDetailSchema,
|
||||
node_class=LarChangeSyncNode,
|
||||
strategy_class=LarChangeSyncStrategy,
|
||||
api_handler_class=LarChangeApiHandler,
|
||||
jsonl_handler_class=LarChangeJsonlHandler,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""LAR Change SyncNode"""
|
||||
|
||||
from ...common.sync_node import SyncNode
|
||||
from schemas.project_extensions.lar import LarChangeDetailSchema
|
||||
|
||||
|
||||
class LarChangeSyncNode(SyncNode[LarChangeDetailSchema]):
|
||||
"""移民变更同步节点"""
|
||||
node_type = "lar_change"
|
||||
schema = LarChangeDetailSchema
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.project_extensions.lar import LarChangeDetailSchema
|
||||
|
||||
|
||||
class LarChangeSyncStrategy(DefaultSyncStrategy[LarChangeDetailSchema]):
|
||||
"""
|
||||
LAR Change 同步策略。
|
||||
"""
|
||||
|
||||
schema = LarChangeDetailSchema
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["project_id", "change_time", "title"],
|
||||
depend_fields={"project_id": "project"},
|
||||
local_orphan_action=OrphanAction.NONE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Material domain"""
|
||||
from .sync_node import MaterialSyncNode
|
||||
from .sync_strategy import MaterialSyncStrategy
|
||||
from .jsonl_handler import MaterialJsonlHandler
|
||||
|
||||
__all__ = ["MaterialSyncNode", "MaterialSyncStrategy", "MaterialJsonlHandler"]
|
||||
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
Material API Handler (V2)
|
||||
|
||||
职责:
|
||||
- 加载物资列表(依赖 Project)
|
||||
- 物资创建/更新/删除(批量接口)
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import (
|
||||
ERROR_NODE_DATA_NONE,
|
||||
ERROR_VALIDATION_FAILED,
|
||||
ERROR_PROJECT_ID_NOT_FOUND
|
||||
)
|
||||
from .sync_node import MaterialSyncNode
|
||||
from schemas.material.material import MaterialResponse, MaterialCreate, MaterialUpdate, MaterialPlanUpdate
|
||||
|
||||
|
||||
class MaterialApiHandler(BaseApiHandler):
|
||||
"""物资 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "material"
|
||||
self._node_class = MaterialSyncNode
|
||||
self._schema = MaterialResponse
|
||||
self.update_schemas = [MaterialCreate, MaterialUpdate, MaterialPlanUpdate]
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""
|
||||
加载物资列表
|
||||
|
||||
依赖 Project
|
||||
使用 GET /material/list?project_id={id} 接口
|
||||
"""
|
||||
try:
|
||||
projects = self._collection.filter(node_type="project")
|
||||
project_ids = [p.data_id for p in projects if p.data_id]
|
||||
|
||||
if not project_ids:
|
||||
print("⚠️ No projects found, skipping material load")
|
||||
return []
|
||||
|
||||
all_materials = []
|
||||
for project_id in project_ids:
|
||||
try:
|
||||
materials = await api_get_material_list(self.api_client, project_id)
|
||||
|
||||
for material in materials:
|
||||
# 已验证的 Pydantic model,转换为 dict
|
||||
material_data = material.model_dump()
|
||||
all_materials.append(self._create_node(material_data))
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load materials for project {project_id}: {e}")
|
||||
continue
|
||||
|
||||
return all_materials
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load materials: {e}")
|
||||
return []
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建物资"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
result = await self._create_single(node)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""
|
||||
批量更新物资
|
||||
|
||||
支持两种更新类型:
|
||||
1. MaterialUpdate - 基础信息更新
|
||||
2. MaterialPlanUpdate - 计划信息更新
|
||||
|
||||
更新逻辑:
|
||||
- 对每种 schema,检查是否有差异
|
||||
- 如果有差异,调用对应 API
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
origin_data = node.get_origin_data() or {}
|
||||
biz_id = node.data_id or data.get("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
|
||||
|
||||
# 1. 基础信息更新 (MaterialUpdate)
|
||||
base_diff = self._get_schema_diff(MaterialUpdate, data, origin_data, node.node_id)
|
||||
if base_diff:
|
||||
try:
|
||||
update_model = MaterialUpdate.model_validate(data)
|
||||
push_id = self._generate_push_id()
|
||||
await api_update_material(self.api_client, update_model, push_id, biz_id)
|
||||
node_updated = True
|
||||
except ValidationError as e:
|
||||
error = f"{ERROR_VALIDATION_FAILED}: {e}"
|
||||
|
||||
# 2. 计划信息更新 (MaterialPlanUpdate)
|
||||
plan_diff = self._get_schema_diff(MaterialPlanUpdate, data, origin_data, node.node_id)
|
||||
if not error and plan_diff:
|
||||
try:
|
||||
plan_model = MaterialPlanUpdate.model_validate(data)
|
||||
push_id = self._generate_push_id()
|
||||
# 需要调用对应的计划更新 API
|
||||
await api_update_material_plan(
|
||||
self.api_client,
|
||||
plan_model.model_dump(exclude_unset=True),
|
||||
push_id,
|
||||
biz_id
|
||||
)
|
||||
node_updated = True
|
||||
except ValidationError as e:
|
||||
error = f"{ERROR_VALIDATION_FAILED}: {e}"
|
||||
|
||||
# 汇总结果
|
||||
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:
|
||||
# 没有任何字段需要更新
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
|
||||
|
||||
return results
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量删除物资"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
result = await self._delete_single(node)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def _create_single(self, node: SyncNode) -> TaskResult:
|
||||
"""创建物资"""
|
||||
try:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=ERROR_NODE_DATA_NONE
|
||||
)
|
||||
|
||||
push_id = self._generate_push_id()
|
||||
|
||||
project_id = node.context.get("project_id")
|
||||
if not project_id:
|
||||
project_id = data.get("project_id")
|
||||
|
||||
if not project_id:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=ERROR_PROJECT_ID_NOT_FOUND
|
||||
)
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
from pydantic import ValidationError
|
||||
create_data = {"project_id": project_id, **data}
|
||||
try:
|
||||
create_model = MaterialCreate.model_validate(create_data)
|
||||
except ValidationError as e:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"{ERROR_VALIDATION_FAILED}: {e}"
|
||||
)
|
||||
|
||||
response = await api_create_material(self.api_client, create_model, push_id)
|
||||
if self.poll_mode == "sync":
|
||||
created_id = self._extract_created_id_from_response(response)
|
||||
if not created_id:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Missing created_id in create response"
|
||||
)
|
||||
return TaskResult.success(node_id=node.node_id, data_id=created_id)
|
||||
else:
|
||||
return TaskResult.in_progress(node_id=node.node_id, task_id=push_id)
|
||||
|
||||
except Exception as e:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"Create failed: {str(e)}"
|
||||
)
|
||||
|
||||
async def _update_single(self, node: SyncNode) -> TaskResult:
|
||||
"""更新物资"""
|
||||
if not self.api_client:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="api_client not initialized"
|
||||
)
|
||||
|
||||
try:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Node data is None or validation failed"
|
||||
)
|
||||
|
||||
push_id = self._generate_push_id()
|
||||
biz_id = node.data_id or data.get("id")
|
||||
if not biz_id:
|
||||
return TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update")
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
from pydantic import ValidationError
|
||||
try:
|
||||
update_model = MaterialUpdate.model_validate(data)
|
||||
except ValidationError as e:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"{ERROR_VALIDATION_FAILED}: {e}"
|
||||
)
|
||||
|
||||
await api_update_material(self.api_client, update_model, push_id, biz_id)
|
||||
return TaskResult.success(node_id=node.node_id)
|
||||
|
||||
except Exception as e:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"Update failed: {str(e)}"
|
||||
)
|
||||
|
||||
async def _delete_single(self, node: SyncNode) -> TaskResult:
|
||||
"""删除物资"""
|
||||
try:
|
||||
push_id = self._generate_push_id()
|
||||
biz_id = node.data_id
|
||||
if not biz_id:
|
||||
return TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete")
|
||||
|
||||
request_data = {
|
||||
"push_id": push_id,
|
||||
"biz_id": biz_id,
|
||||
}
|
||||
|
||||
response = await self.api_client.request(
|
||||
method="DELETE",
|
||||
endpoint="/material/delete",
|
||||
data=request_data
|
||||
)
|
||||
|
||||
if response.get("code") == 200:
|
||||
return TaskResult.success(node_id=node.node_id)
|
||||
else:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"API returned error: {response.get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error=f"Delete failed: {str(e)}"
|
||||
)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
"""
|
||||
创建物资同步节点
|
||||
|
||||
设置 project_id 和 material_id 到 context
|
||||
"""
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("project_id"):
|
||||
node.context["project_id"] = data["project_id"]
|
||||
node.context["material_id"] = node.data_id
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_material(client, material_id: str) -> Dict[str, Any]:
|
||||
"""获取单个物资数据 GET /material"""
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/material",
|
||||
params={"material_id": material_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response.get("data", {})
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_material_list(client, project_id: str) -> List[MaterialResponse]:
|
||||
"""GET /material/list - 获取物资列表"""
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/material/list",
|
||||
params={"project_id": project_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
items = response.get("data", [])
|
||||
|
||||
# 验证每个返回项
|
||||
from pydantic import ValidationError
|
||||
validated_items = []
|
||||
for item in items:
|
||||
try:
|
||||
validated_item = MaterialResponse.model_validate(item)
|
||||
validated_items.append(validated_item)
|
||||
except ValidationError as e:
|
||||
print(f"⚠️ Skipping invalid material item: {e}")
|
||||
continue
|
||||
|
||||
return validated_items
|
||||
|
||||
|
||||
async def api_create_material(client, data: MaterialCreate, push_id: str) -> Dict[str, Any]:
|
||||
"""创建物资 POST /material/create"""
|
||||
request_data = {"push_id": push_id, "data": data.model_dump()}
|
||||
response = await client.request(method="POST", endpoint="/material/create", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_update_material(client, data: MaterialUpdate, push_id: str, biz_id: str) -> None:
|
||||
"""更新物资 PUT /material/update"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
|
||||
response = await client.request(method="PUT", endpoint="/material/update", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_update_material_plan(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
|
||||
"""更新物资计划 PUT /material/plan"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
response = await client.request(method="PUT", endpoint="/material/plan", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Material Domain - JSONL Handler"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import MaterialSyncNode
|
||||
from schemas.material.material import MaterialResponse
|
||||
|
||||
|
||||
class MaterialJsonlHandler(BaseJsonlHandler):
|
||||
"""物资 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "material", MaterialSyncNode, MaterialResponse)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Material domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.material.material import MaterialResponse
|
||||
from .sync_node import MaterialSyncNode
|
||||
from .sync_strategy import MaterialSyncStrategy
|
||||
from .jsonl_handler import MaterialJsonlHandler
|
||||
from .api_handler import MaterialApiHandler
|
||||
|
||||
# 注册 material domain
|
||||
DomainRegistry.register(
|
||||
node_type="material",
|
||||
schema=MaterialResponse,
|
||||
node_class=MaterialSyncNode,
|
||||
strategy_class=MaterialSyncStrategy,
|
||||
jsonl_handler_class=MaterialJsonlHandler,
|
||||
api_handler_class=MaterialApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from ...common.sync_node import SyncNode
|
||||
from schemas.material.material import MaterialResponse
|
||||
|
||||
|
||||
class MaterialSyncNode(SyncNode[MaterialResponse]):
|
||||
"""物资同步节点"""
|
||||
node_type = "material"
|
||||
schema = MaterialResponse
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.material.material import MaterialResponse
|
||||
|
||||
|
||||
class MaterialSyncStrategy(DefaultSyncStrategy[MaterialResponse]):
|
||||
"""
|
||||
Material 同步策略(合同子业务)。
|
||||
"""
|
||||
|
||||
schema = MaterialResponse
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["contract_id", "material_type"],
|
||||
depend_fields={"contract_id": "contract", "project_id": "project"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Material Detail Domain
|
||||
"""
|
||||
from .sync_node import MaterialDetailSyncNode
|
||||
from .sync_strategy import MaterialDetailSyncStrategy
|
||||
from .jsonl_handler import MaterialDetailJsonlHandler
|
||||
|
||||
__all__ = ["MaterialDetailSyncNode", "MaterialDetailSyncStrategy", "MaterialDetailJsonlHandler"]
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Material Detail API Handler
|
||||
|
||||
职责:
|
||||
- 加载物资明细列表(依赖 Material)
|
||||
- 物资明细创建/更新/删除
|
||||
|
||||
实现的接口:
|
||||
GET:
|
||||
- /material_detail/list - 获取物资明细列表
|
||||
|
||||
POST:
|
||||
- /material_detail/create - 创建物资明细
|
||||
|
||||
PUT:
|
||||
- /material_detail/update - 更新物资明细
|
||||
|
||||
DELETE:
|
||||
- /material_detail/delete - 删除物资明细
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
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_NODE_DATA_NONE, ERROR_VALIDATION_FAILED, ERROR_ID_NOT_FOUND
|
||||
from .sync_node import MaterialDetailSyncNode
|
||||
from schemas.material.material_detail import (
|
||||
MaterialDetailResponse,
|
||||
MaterialDetailCreate,
|
||||
MaterialDetailUpdate
|
||||
)
|
||||
|
||||
|
||||
class MaterialDetailApiHandler(BaseApiHandler):
|
||||
"""物资明细 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "material_detail"
|
||||
self._node_class = MaterialDetailSyncNode
|
||||
self._schema = MaterialDetailResponse
|
||||
self.update_schemas = [MaterialDetailCreate, MaterialDetailUpdate]
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""加载物资明细列表(依赖 Material)"""
|
||||
try:
|
||||
materials = self._collection.filter(node_type="material")
|
||||
material_ids = [m.data_id for m in materials if m.data_id]
|
||||
|
||||
if not material_ids:
|
||||
return []
|
||||
|
||||
all_nodes = []
|
||||
for material_id in material_ids:
|
||||
try:
|
||||
details = await api_get_material_detail_list(self.api_client, material_id)
|
||||
for detail_model in details:
|
||||
# 已验证的 Pydantic model,转换为 dict
|
||||
detail_data = detail_model.model_dump()
|
||||
node = self._create_node(detail_data)
|
||||
all_nodes.append(node)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load material details for {material_id}: {e}")
|
||||
|
||||
return all_nodes
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load material details: {e}")
|
||||
return []
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建物资明细"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
try:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
# 从data的parent_id获取material_id
|
||||
material_id = data.get("parent_id")
|
||||
|
||||
if not material_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_ID_NOT_FOUND))
|
||||
continue
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
create_data = {"material_id": material_id, **data}
|
||||
create_model = MaterialDetailCreate.model_validate(create_data)
|
||||
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()
|
||||
|
||||
response = await api_create_material_detail(
|
||||
self.api_client,
|
||||
create_model,
|
||||
push_id,
|
||||
)
|
||||
if self.poll_mode == "sync":
|
||||
created_id = self._extract_created_id_from_response(response)
|
||||
if not created_id:
|
||||
results.append(
|
||||
TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Missing created_id in create response"
|
||||
)
|
||||
)
|
||||
continue
|
||||
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
|
||||
else:
|
||||
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=f"Create failed: {str(e)}"))
|
||||
|
||||
return results
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""
|
||||
批量更新物资明细
|
||||
|
||||
按 material_id 分组,每组使用同一个 push_id 批量提交
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
results = []
|
||||
|
||||
# 按 material_id 分组
|
||||
groups: Dict[str, List[tuple[SyncNode, Dict[str, Any]]]] = defaultdict(list)
|
||||
|
||||
for node in nodes:
|
||||
try:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
origin_data = node.get_origin_data() or {}
|
||||
|
||||
# 使用 _get_schema_diff 检查差异并打印日志
|
||||
diff_fields = self._get_schema_diff(MaterialDetailUpdate, data, origin_data, node.node_id)
|
||||
if not diff_fields:
|
||||
from ...common.types import SyncAction
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
|
||||
continue
|
||||
|
||||
# 获取 material_id(parent_id)
|
||||
material_id = data.get("parent_id") or node.context.get("material_id")
|
||||
if not material_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing material_id (parent_id)"))
|
||||
continue
|
||||
|
||||
# 需要完整的 schema 数据(包括必填字段)
|
||||
update_data = self._filter_update_data(data, origin_data, MaterialDetailUpdate)
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
update_model = MaterialDetailUpdate.model_validate(update_data)
|
||||
groups[material_id].append((node, update_model))
|
||||
except ValidationError as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=f"Update failed: {str(e)}"))
|
||||
|
||||
# 逐组批量更新
|
||||
for material_id, group_items in groups.items():
|
||||
push_id = self._generate_push_id()
|
||||
|
||||
# 准备批量数据
|
||||
detail_list = [model.model_dump(mode='json', exclude_unset=True) for node, model in group_items]
|
||||
|
||||
# 使用 parent_id(material_id)作为 biz_id
|
||||
biz_id = material_id
|
||||
|
||||
try:
|
||||
# 批量调用 API
|
||||
await api_update_material_detail(
|
||||
self.api_client,
|
||||
{"detail_list": detail_list},
|
||||
push_id,
|
||||
biz_id
|
||||
)
|
||||
# 整组成功
|
||||
for node, _ in group_items:
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
# 整组失败
|
||||
error_msg = str(e)
|
||||
for node, _ in group_items:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=error_msg))
|
||||
|
||||
return results
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量删除物资明细"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
try:
|
||||
push_id = self._generate_push_id()
|
||||
biz_id = node.data_id
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete"))
|
||||
continue
|
||||
|
||||
await api_delete_material_detail(
|
||||
self.api_client,
|
||||
push_id,
|
||||
biz_id
|
||||
)
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=f"Delete failed: {str(e)}"))
|
||||
|
||||
return results
|
||||
|
||||
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
|
||||
"""轮询异步任务状态"""
|
||||
return await super().poll_tasks(task_ids)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
"""创建物资明细节点,设置 material_id 到 context"""
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("material_id"):
|
||||
node.context["material_id"] = data["material_id"]
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_material_detail_list(client, material_id: str) -> List[MaterialDetailResponse]:
|
||||
"""
|
||||
获取物资明细列表 GET /material_detail/list
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
material_id: 物资ID
|
||||
|
||||
Returns:
|
||||
List[MaterialDetailResponse]: 验证过的明细列表
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/material/detail/list",
|
||||
params={"material_id": material_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
items = response.get("data", [])
|
||||
|
||||
# 验证每个返回项
|
||||
validated_items = []
|
||||
for item in items:
|
||||
try:
|
||||
validated_item = MaterialDetailResponse.model_validate(item)
|
||||
validated_items.append(validated_item)
|
||||
except ValidationError as e:
|
||||
print(f"⚠️ Skipping invalid material detail item: {e}")
|
||||
continue
|
||||
|
||||
return validated_items
|
||||
|
||||
|
||||
async def api_create_material_detail(client, data: MaterialDetailCreate, push_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
创建物资明细 POST /material_detail/create
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 创建数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {"push_id": push_id, "data": data.model_dump(mode='json')}
|
||||
response = await client.request(
|
||||
method="POST",
|
||||
endpoint="/material/detail/create",
|
||||
data=request_data
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_update_material_detail(client, data: Dict[str, Any], push_id: str, biz_id: str):
|
||||
"""
|
||||
更新物资明细 PUT /material_detail/update
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 更新数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {
|
||||
"push_id": push_id,
|
||||
"biz_id": biz_id,
|
||||
"data": data
|
||||
}
|
||||
response = await client.request(
|
||||
method="PUT",
|
||||
endpoint="/material/detail/update",
|
||||
data=request_data
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_delete_material_detail(client, push_id: str, biz_id: str):
|
||||
"""删除物资明细"""
|
||||
request_data = {
|
||||
"push_id": push_id,
|
||||
"biz_id": biz_id
|
||||
}
|
||||
response = await client.request(
|
||||
method="DELETE",
|
||||
endpoint="/material/detail/delete",
|
||||
data=request_data
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Material Detail JSONL Handler
|
||||
"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import MaterialDetailSyncNode
|
||||
from schemas.material.material_detail import MaterialDetailResponse
|
||||
|
||||
|
||||
class MaterialDetailJsonlHandler(BaseJsonlHandler):
|
||||
"""物资明细 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "material_detail", MaterialDetailSyncNode, MaterialDetailResponse)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Material detail domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.material.material_detail import MaterialDetailResponse
|
||||
from .sync_node import MaterialDetailSyncNode
|
||||
from .sync_strategy import MaterialDetailSyncStrategy
|
||||
from .jsonl_handler import MaterialDetailJsonlHandler
|
||||
from .api_handler import MaterialDetailApiHandler
|
||||
|
||||
# 注册 material_detail domain
|
||||
DomainRegistry.register(
|
||||
node_type="material_detail",
|
||||
schema=MaterialDetailResponse,
|
||||
node_class=MaterialDetailSyncNode,
|
||||
strategy_class=MaterialDetailSyncStrategy,
|
||||
jsonl_handler_class=MaterialDetailJsonlHandler,
|
||||
api_handler_class=MaterialDetailApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Material Detail Domain
|
||||
"""
|
||||
from ...common import SyncNode
|
||||
from schemas.material.material_detail import MaterialDetailResponse
|
||||
|
||||
|
||||
class MaterialDetailSyncNode(SyncNode[MaterialDetailResponse]):
|
||||
"""物资明细同步节点"""
|
||||
node_type = "material_detail"
|
||||
schema = MaterialDetailResponse
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.material.material_detail import MaterialDetailResponse
|
||||
|
||||
|
||||
class MaterialDetailSyncStrategy(DefaultSyncStrategy[MaterialDetailResponse]):
|
||||
"""
|
||||
Material Detail 同步策略(合同子业务明细)。
|
||||
"""
|
||||
|
||||
schema = MaterialDetailResponse
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["parent_id", "date"],
|
||||
depend_fields={"parent_id": "material"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Personnel domain"""
|
||||
from .sync_node import PersonnelSyncNode
|
||||
from .sync_strategy import PersonnelSyncStrategy
|
||||
from .jsonl_handler import PersonnelJsonlHandler
|
||||
|
||||
__all__ = ["PersonnelSyncNode", "PersonnelSyncStrategy", "PersonnelJsonlHandler"]
|
||||
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Personnel API Handler
|
||||
|
||||
职责:
|
||||
- 加载人员列表(依赖 Project)
|
||||
- 人员创建/更新/删除(批量接口)
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import ERROR_NODE_DATA_NONE, ERROR_PROJECT_ID_NOT_FOUND
|
||||
from .sync_node import PersonnelSyncNode
|
||||
from schemas.power.power import PowerResponse, PowerCreate, PowerUpdate, PowerPlanUpdate
|
||||
|
||||
|
||||
class PersonnelApiHandler(BaseApiHandler):
|
||||
"""人员 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "personnel"
|
||||
self._node_class = PersonnelSyncNode
|
||||
self._schema = PowerResponse
|
||||
self.update_schemas = [PowerCreate, PowerUpdate, PowerPlanUpdate]
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""加载人员列表(依赖 Project)"""
|
||||
projects = self._collection.filter(node_type="project")
|
||||
project_ids = [p.data_id for p in projects if p.data_id]
|
||||
|
||||
if not project_ids:
|
||||
return []
|
||||
|
||||
all_personnel = []
|
||||
for project_id in project_ids:
|
||||
try:
|
||||
personnel_list = await api_get_personnel_list(self.api_client, project_id)
|
||||
for personnel in personnel_list:
|
||||
all_personnel.append(self._create_node(personnel))
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load personnel for project {project_id}: {e}")
|
||||
|
||||
return all_personnel
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建人员"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
project_id = node.context.get("project_id") or data.get("project_id")
|
||||
if not project_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_PROJECT_ID_NOT_FOUND))
|
||||
continue
|
||||
|
||||
push_id = self._generate_push_id()
|
||||
|
||||
try:
|
||||
request_data = {"project_id": project_id, **data}
|
||||
PowerCreate.model_validate(request_data)
|
||||
response = await api_create_personnel(self.api_client, request_data, push_id)
|
||||
if self.poll_mode == "sync":
|
||||
created_id = self._extract_created_id_from_response(response)
|
||||
if not created_id:
|
||||
results.append(
|
||||
TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Missing created_id in create response"
|
||||
)
|
||||
)
|
||||
continue
|
||||
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
|
||||
else:
|
||||
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""
|
||||
批量更新人员
|
||||
|
||||
支持两种更新类型:
|
||||
1. PowerUpdate - 基础信息更新
|
||||
2. PowerPlanUpdate - 计划信息更新
|
||||
|
||||
更新逻辑:
|
||||
- 对每种 schema,检查是否有差异
|
||||
- 如果有差异,调用对应 API
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
origin_data = node.get_origin_data() or {}
|
||||
biz_id = node.data_id or data.get("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
|
||||
|
||||
# 1. 基础信息更新 (PowerUpdate)
|
||||
base_diff = self._get_schema_diff(PowerUpdate, data, origin_data, node.node_id)
|
||||
if base_diff:
|
||||
try:
|
||||
update_model = PowerUpdate.model_validate(data)
|
||||
push_id = self._generate_push_id()
|
||||
await api_update_personnel(self.api_client, update_model.model_dump(), push_id, biz_id)
|
||||
node_updated = True
|
||||
except ValidationError as e:
|
||||
error = f"Validation failed: {e}"
|
||||
|
||||
# 2. 计划信息更新 (PowerPlanUpdate)
|
||||
plan_fields = [
|
||||
"plan_start_date",
|
||||
"plan_start_quantity",
|
||||
"plan_exit_date",
|
||||
"plan_node",
|
||||
]
|
||||
plan_diff = {
|
||||
key: data.get(key)
|
||||
for key in plan_fields
|
||||
if data.get(key) != origin_data.get(key)
|
||||
}
|
||||
if not error and plan_diff:
|
||||
try:
|
||||
plan_model = PowerPlanUpdate.model_validate(data)
|
||||
push_id = self._generate_push_id()
|
||||
await api_update_personnel_plan(
|
||||
self.api_client,
|
||||
plan_model.model_dump(),
|
||||
push_id,
|
||||
biz_id,
|
||||
)
|
||||
node_updated = True
|
||||
except ValidationError as e:
|
||||
error = f"Validation failed: {e}"
|
||||
|
||||
# 汇总结果
|
||||
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:
|
||||
# 没有任何字段需要更新
|
||||
from ...common.types import SyncAction
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
|
||||
|
||||
return results
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量删除人员"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
push_id = self._generate_push_id()
|
||||
biz_id = node.data_id
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete"))
|
||||
continue
|
||||
|
||||
try:
|
||||
await api_delete_personnel(self.api_client, biz_id, push_id)
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
|
||||
"""轮询异步任务状态(占位)"""
|
||||
return await super().poll_tasks(task_ids)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("project_id"):
|
||||
node.context["project_id"] = data["project_id"]
|
||||
node.context["personnel_id"] = node.data_id
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_personnel(client, personnel_id: str) -> Dict[str, Any]:
|
||||
"""获取单个力能数据 GET /personnel"""
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/personnel",
|
||||
params={"personnel_id": personnel_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response.get("data", {})
|
||||
|
||||
|
||||
async def api_create_personnel(client, data: Dict[str, Any], push_id: str) -> Dict[str, Any]:
|
||||
"""创建力能 POST /personnel/create"""
|
||||
request_data = {"push_id": push_id, "data": data}
|
||||
response = await client.request(method="POST", endpoint="/personnel/create", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_update_personnel(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
|
||||
"""更新力能 PUT /personnel/update"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
response = await client.request(method="PUT", endpoint="/personnel/update", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_personnel_list(client, project_id: str) -> List[Dict[str, Any]]:
|
||||
"""GET /personnel/list - 获取人员列表"""
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/personnel/list",
|
||||
params={"project_id": project_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
items = response.get("data", [])
|
||||
validated_items: List[Dict[str, Any]] = []
|
||||
for item in items:
|
||||
validated = PowerResponse.model_validate(item)
|
||||
validated_items.append(validated.model_dump())
|
||||
return validated_items
|
||||
|
||||
|
||||
async def api_delete_personnel(client, personnel_id: str, push_id: str) -> None:
|
||||
"""删除力能 DELETE /personnel/delete"""
|
||||
request_data = {"push_id": push_id, "biz_id": personnel_id}
|
||||
response = await client.request(method="DELETE", endpoint="/personnel/delete", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_update_personnel_plan(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
|
||||
"""更新力能计划 PUT /personnel/plan"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
response = await client.request(method="PUT", endpoint="/personnel/plan", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Personnel Domain - JSONL Handler"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import PersonnelSyncNode
|
||||
from schemas.power.power import PowerResponse
|
||||
|
||||
|
||||
class PersonnelJsonlHandler(BaseJsonlHandler):
|
||||
"""人员 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "personnel", PersonnelSyncNode, PowerResponse)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Personnel domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.power.power import PowerResponse
|
||||
from .sync_node import PersonnelSyncNode
|
||||
from .sync_strategy import PersonnelSyncStrategy
|
||||
from .jsonl_handler import PersonnelJsonlHandler
|
||||
from .api_handler import PersonnelApiHandler
|
||||
|
||||
# 注册 personnel domain
|
||||
DomainRegistry.register(
|
||||
node_type="personnel",
|
||||
schema=PowerResponse,
|
||||
node_class=PersonnelSyncNode,
|
||||
strategy_class=PersonnelSyncStrategy,
|
||||
jsonl_handler_class=PersonnelJsonlHandler,
|
||||
api_handler_class=PersonnelApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from ...common.sync_node import SyncNode
|
||||
from schemas.power.power import PowerResponse
|
||||
|
||||
|
||||
class PersonnelSyncNode(SyncNode[PowerResponse]):
|
||||
"""人员同步节点"""
|
||||
node_type = "personnel"
|
||||
schema = PowerResponse
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.power.power import PowerResponse
|
||||
|
||||
|
||||
class PersonnelSyncStrategy(DefaultSyncStrategy[PowerResponse]):
|
||||
"""
|
||||
Personnel 同步策略(合同子业务)。
|
||||
"""
|
||||
|
||||
schema = PowerResponse
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["project_id", "code"],
|
||||
depend_fields={"project_id": "project", "contract_id": "contract"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Personnel Detail Domain
|
||||
"""
|
||||
from .sync_node import PersonnelDetailSyncNode
|
||||
from .sync_strategy import PersonnelDetailSyncStrategy
|
||||
from .jsonl_handler import PersonnelDetailJsonlHandler
|
||||
|
||||
__all__ = ["PersonnelDetailSyncNode", "PersonnelDetailSyncStrategy", "PersonnelDetailJsonlHandler"]
|
||||
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Personnel Detail API Handler
|
||||
|
||||
职责:
|
||||
- 加载人员明细列表(依赖 Personnel)
|
||||
- 人员明细创建/更新/删除(批量接口)
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import ERROR_NODE_DATA_NONE, ERROR_ID_NOT_FOUND
|
||||
from .sync_node import PersonnelDetailSyncNode
|
||||
from schemas.power.power_detail import PowerDetail, PowerDetailCreate, PowerDetailUpdate
|
||||
|
||||
|
||||
class PersonnelDetailApiHandler(BaseApiHandler):
|
||||
"""人员明细 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "personnel_detail"
|
||||
self._node_class = PersonnelDetailSyncNode
|
||||
self._schema = PowerDetail
|
||||
self.update_schemas = [PowerDetailCreate, PowerDetailUpdate]
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""加载人员明细列表(依赖 Personnel)"""
|
||||
personnel_list = self._collection.filter(node_type="personnel")
|
||||
personnel_ids = [p.data_id for p in personnel_list if p.data_id]
|
||||
|
||||
if not personnel_ids:
|
||||
return []
|
||||
|
||||
all_details = []
|
||||
for personnel_id in personnel_ids:
|
||||
try:
|
||||
details = await api_get_personnel_detail_list(self.api_client, personnel_id)
|
||||
for detail in details:
|
||||
validated = PowerDetail.model_validate(detail)
|
||||
all_details.append(self._create_node(validated.model_dump()))
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load personnel details for {personnel_id}: {e}")
|
||||
|
||||
return all_details
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量创建人员明细"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
# 从data的parent_id获取personnel_id
|
||||
personnel_id = data.get("parent_id")
|
||||
if not personnel_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_ID_NOT_FOUND))
|
||||
continue
|
||||
|
||||
push_id = self._generate_push_id()
|
||||
|
||||
try:
|
||||
request_data = {"personnel_id": personnel_id, **data}
|
||||
PowerDetailCreate.model_validate(request_data)
|
||||
response = await api_create_personnel_detail(self.api_client, request_data, push_id)
|
||||
if self.poll_mode == "sync":
|
||||
created_id = self._extract_created_id_from_response(response)
|
||||
if not created_id:
|
||||
results.append(
|
||||
TaskResult.failed(
|
||||
node_id=node.node_id,
|
||||
error="Missing created_id in create response"
|
||||
)
|
||||
)
|
||||
continue
|
||||
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
|
||||
else:
|
||||
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""
|
||||
批量更新人员明细
|
||||
|
||||
按 personnel_id 分组,每组使用同一个 push_id 批量提交
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
from collections import defaultdict
|
||||
|
||||
results = []
|
||||
|
||||
# 按 personnel_id 分组
|
||||
groups: Dict[str, List[tuple[SyncNode, Dict[str, Any]]]] = defaultdict(list)
|
||||
|
||||
for node in nodes:
|
||||
data = node.get_data()
|
||||
if not data:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
|
||||
continue
|
||||
|
||||
origin_data = node.get_origin_data() or {}
|
||||
|
||||
# 使用 _get_schema_diff 检查差异并打印日志
|
||||
diff_fields = self._get_schema_diff(PowerDetailUpdate, data, origin_data, node.node_id)
|
||||
if not diff_fields:
|
||||
from ...common.types import SyncAction
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
|
||||
continue
|
||||
|
||||
# 获取 personnel_id(parent_id)
|
||||
personnel_id = data.get("parent_id") or node.context.get("personnel_id")
|
||||
if not personnel_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing personnel_id (parent_id)"))
|
||||
continue
|
||||
|
||||
# 需要完整的 schema 数据(包括必填字段)
|
||||
update_data = self._filter_update_data(data, origin_data, PowerDetailUpdate)
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
update_model = PowerDetailUpdate.model_validate(update_data)
|
||||
groups[personnel_id].append((node, update_model))
|
||||
except ValidationError as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=f"Validation failed: {e}"))
|
||||
|
||||
# 逐组批量更新
|
||||
for personnel_id, group_items in groups.items():
|
||||
push_id = self._generate_push_id()
|
||||
|
||||
# 准备批量数据
|
||||
detail_list = [model.model_dump(exclude_unset=True) for node, model in group_items]
|
||||
|
||||
# 使用 parent_id(personnel_id)作为 biz_id
|
||||
biz_id = personnel_id
|
||||
|
||||
try:
|
||||
# 批量调用 API
|
||||
await api_update_personnel_detail(
|
||||
self.api_client,
|
||||
{"detail_list": detail_list},
|
||||
push_id,
|
||||
biz_id,
|
||||
)
|
||||
# 整组成功
|
||||
for node, _ in group_items:
|
||||
results.append(TaskResult.success(node_id=node.node_id, push_id=push_id))
|
||||
except Exception as e:
|
||||
# 整组失败
|
||||
error_msg = str(e)
|
||||
for node, _ in group_items:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=error_msg))
|
||||
|
||||
return results
|
||||
|
||||
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量删除人员明细"""
|
||||
results = []
|
||||
for node in nodes:
|
||||
push_id = self._generate_push_id()
|
||||
biz_id = node.data_id
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete"))
|
||||
continue
|
||||
|
||||
try:
|
||||
await api_delete_personnel_detail(self.api_client, biz_id, push_id)
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
|
||||
|
||||
return results
|
||||
|
||||
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
|
||||
"""轮询异步任务状态(占位)"""
|
||||
return await super().poll_tasks(task_ids)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("personnel_id"):
|
||||
node.context["personnel_id"] = data["personnel_id"]
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_get_personnel_detail_list(client, personnel_id: str) -> List[Dict[str, Any]]:
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
endpoint="/personnel/detail/list",
|
||||
params={"personnel_id": personnel_id}
|
||||
)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response.get("data", [])
|
||||
|
||||
|
||||
async def api_create_personnel_detail(client, data: Dict[str, Any], push_id: str) -> Dict[str, Any]:
|
||||
request_data = {"push_id": push_id, "data": data}
|
||||
response = await client.request(method="POST", endpoint="/personnel/detail/create", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
return response
|
||||
|
||||
|
||||
async def api_update_personnel_detail(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
response = await client.request(method="PUT", endpoint="/personnel/detail/update", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
|
||||
|
||||
async def api_delete_personnel_detail(client, detail_id: str, push_id: str) -> None:
|
||||
request_data = {"push_id": push_id, "biz_id": detail_id}
|
||||
response = await client.request(method="DELETE", endpoint="/personnel/detail/delete", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Personnel Detail JSONL Handler
|
||||
"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import PersonnelDetailSyncNode
|
||||
from schemas.power.power_detail import PowerDetail
|
||||
|
||||
|
||||
class PersonnelDetailJsonlHandler(BaseJsonlHandler):
|
||||
"""人员明细 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "personnel_detail", PersonnelDetailSyncNode, PowerDetail)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Personnel detail domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.power.power_detail import PowerDetail
|
||||
from .sync_node import PersonnelDetailSyncNode
|
||||
from .sync_strategy import PersonnelDetailSyncStrategy
|
||||
from .jsonl_handler import PersonnelDetailJsonlHandler
|
||||
from .api_handler import PersonnelDetailApiHandler
|
||||
|
||||
# 注册 personnel_detail domain
|
||||
DomainRegistry.register(
|
||||
node_type="personnel_detail",
|
||||
schema=PowerDetail,
|
||||
node_class=PersonnelDetailSyncNode,
|
||||
strategy_class=PersonnelDetailSyncStrategy,
|
||||
jsonl_handler_class=PersonnelDetailJsonlHandler,
|
||||
api_handler_class=PersonnelDetailApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Personnel Detail Domain
|
||||
"""
|
||||
from ...common import SyncNode
|
||||
from schemas.power.power_detail import PowerDetail
|
||||
|
||||
|
||||
class PersonnelDetailSyncNode(SyncNode[PowerDetail]):
|
||||
"""人员明细同步节点"""
|
||||
node_type = "personnel_detail"
|
||||
schema = PowerDetail
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.power.power_detail import PowerDetail
|
||||
|
||||
|
||||
class PersonnelDetailSyncStrategy(DefaultSyncStrategy[PowerDetail]):
|
||||
"""
|
||||
Personnel Detail 同步策略(合同子业务明细)。
|
||||
"""
|
||||
|
||||
schema = PowerDetail
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["parent_id", "date"],
|
||||
depend_fields={"parent_id": "personnel"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Preparation domain"""
|
||||
from .sync_node import PreparationSyncNode
|
||||
from .sync_strategy import PreparationSyncStrategy
|
||||
from .jsonl_handler import PreparationJsonlHandler
|
||||
|
||||
__all__ = ["PreparationSyncNode", "PreparationSyncStrategy", "PreparationJsonlHandler"]
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Preparation (里程碑) API Handler
|
||||
|
||||
职责:
|
||||
- 更新项目里程碑数据
|
||||
- 不支持创建和删除(里程碑随项目自动生成)
|
||||
|
||||
实现的接口:
|
||||
PUT:
|
||||
- /preparation - 更新里程碑数据
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import ERROR_VALIDATION_FAILED
|
||||
from .sync_node import PreparationSyncNode
|
||||
from schemas.project_extensions.preparation import PreparationDetail, PostPreparation
|
||||
|
||||
|
||||
class PreparationApiHandler(BaseApiHandler):
|
||||
"""里程碑 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "preparation"
|
||||
self._node_class = PreparationSyncNode
|
||||
self._schema = PreparationDetail
|
||||
self._filter_project_id = None
|
||||
self.update_schemas = [PostPreparation]
|
||||
|
||||
def set_filter_project_id(self, project_id: str | List[str]):
|
||||
"""设置要加载的项目ID过滤列表"""
|
||||
self._filter_project_id = project_id
|
||||
|
||||
def set_target_project_ids(self, target_project_ids: List[str]) -> None:
|
||||
"""设置目标项目ID列表"""
|
||||
self.set_filter_project_id(target_project_ids)
|
||||
|
||||
async def load(self) -> List[SyncNode]:
|
||||
"""从 API 加载里程碑数据(需要项目上下文)"""
|
||||
from ..project.api_handler import ProjectApiHandler
|
||||
|
||||
# 获取要加载的项目列表
|
||||
projects = self._collection.filter(node_type="project")
|
||||
if not projects:
|
||||
print(" No projects found, skipping preparation load")
|
||||
return []
|
||||
|
||||
nodes = []
|
||||
for project in projects:
|
||||
project_id = project.data_id
|
||||
|
||||
try:
|
||||
# 从 project context 或缓存获取 all_info
|
||||
all_info = project.context.get("all_info")
|
||||
if not all_info:
|
||||
all_info = await ProjectApiHandler.get_all_info(
|
||||
self.api_client,
|
||||
project_id
|
||||
)
|
||||
|
||||
# 提取 preparation_list
|
||||
preparation_list = all_info.get("preparation_list", [])
|
||||
print(f" Project {project_id}: {len(preparation_list)} preparations")
|
||||
|
||||
for prep_data in preparation_list:
|
||||
# 验证数据
|
||||
try:
|
||||
validated = self._schema.model_validate(prep_data)
|
||||
node = self._create_node(validated.model_dump())
|
||||
nodes.append(node)
|
||||
except Exception as e:
|
||||
print(f" Skipping invalid preparation: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f" Failed to load preparations for project {project_id}: {e}")
|
||||
continue
|
||||
|
||||
return nodes
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""不支持创建(里程碑随项目自动生成)"""
|
||||
return [
|
||||
TaskResult.failed(node_id=node.node_id, error="Preparation creation not supported (auto-generated with project)")
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量更新里程碑数据"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
|
||||
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 update: {error_msg}"))
|
||||
continue
|
||||
|
||||
origin_data = node.get_origin_data() or {}
|
||||
update_data = self._filter_update_data(node_data, origin_data, PostPreparation)
|
||||
if not update_data:
|
||||
reason = f"No physical diff found for preparation {node.data_id} despite strategy update request (Normalization Discrepancy)."
|
||||
print(f" [WARNING] [preparation] {reason}")
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason=reason))
|
||||
continue
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
update_model = PostPreparation.model_validate(update_data)
|
||||
except Exception 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 = node.data_id or update_model.id
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
|
||||
continue
|
||||
|
||||
try:
|
||||
await api_update_preparation(self.api_client, update_model, push_id, biz_id)
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
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="Preparation deletion not supported")
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
return self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_update_preparation(client, data: PostPreparation, push_id: str, biz_id: str) -> None:
|
||||
"""
|
||||
PUT /preparation_nodes - 更新里程碑数据
|
||||
"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
|
||||
response = await client.request(method="PUT", endpoint="/preparation_nodes", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Preparation Domain - JSONL Handler"""
|
||||
from ...datasource.jsonl.handler import BaseJsonlHandler
|
||||
from ...datasource.jsonl.datasource import JsonlDataSource
|
||||
from .sync_node import PreparationSyncNode
|
||||
from schemas.project_extensions.preparation import PreparationDetail
|
||||
|
||||
|
||||
class PreparationJsonlHandler(BaseJsonlHandler):
|
||||
"""里程碑 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "preparation", PreparationSyncNode, PreparationDetail)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Preparation domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from .sync_node import PreparationSyncNode, PreparationDetail
|
||||
from .sync_strategy import PreparationSyncStrategy
|
||||
from .jsonl_handler import PreparationJsonlHandler
|
||||
from .api_handler import PreparationApiHandler
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction
|
||||
|
||||
# 注册 preparation domain
|
||||
DomainRegistry.register(
|
||||
node_type="preparation",
|
||||
schema=PreparationDetail,
|
||||
node_class=PreparationSyncNode,
|
||||
strategy_class=PreparationSyncStrategy,
|
||||
jsonl_handler_class=PreparationJsonlHandler,
|
||||
api_handler_class=PreparationApiHandler,
|
||||
config=StrategyConfig(
|
||||
auto_bind_fields=["project_id", "code"],
|
||||
local_orphan_action=OrphanAction.NONE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
depend_fields={"project_id": "project"}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Preparation SyncNode"""
|
||||
|
||||
from ...common.sync_node import SyncNode
|
||||
from schemas.project_extensions.preparation import PreparationDetail
|
||||
from pydantic import Field
|
||||
from typing import Optional
|
||||
|
||||
class PreparationSyncNode(SyncNode[PreparationDetail]):
|
||||
"""里程碑同步节点"""
|
||||
node_type = "preparation"
|
||||
schema = PreparationDetail
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -0,0 +1,38 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from ...sync_system.strategy_ops.compare_ops import normalized_data_for_compare, collect_payload_diffs
|
||||
from schemas.project_extensions.preparation import PreparationDetail, PostPreparation
|
||||
|
||||
|
||||
class PreparationSyncStrategy(DefaultSyncStrategy[PreparationDetail]):
|
||||
"""
|
||||
Preparation 同步策略(项目扩展)。
|
||||
"""
|
||||
|
||||
schema = PreparationDetail
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["project_id", "code"],
|
||||
depend_fields={"project_id": "project"},
|
||||
local_orphan_action=OrphanAction.NONE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
|
||||
def _needs_update(self, source_node, target_node, resolved_data=None, data_id_map=None) -> bool:
|
||||
if resolved_data is None:
|
||||
raise ValueError(f"[{self.node_type}] resolved_data is required for differential sync.")
|
||||
|
||||
target_data = target_node.get_data()
|
||||
if target_data is None:
|
||||
return False
|
||||
|
||||
update_fields = set(PostPreparation.model_fields.keys())
|
||||
source_payload = {key: value for key, value in resolved_data.items() if key in update_fields}
|
||||
target_payload = {key: value for key, value in target_data.items() if key in update_fields}
|
||||
|
||||
normalized_source = normalized_data_for_compare(source_payload, data_id_map or {})
|
||||
normalized_target = normalized_data_for_compare(target_payload, data_id_map or {})
|
||||
diff_map = collect_payload_diffs(normalized_source, normalized_target, max_items=8)
|
||||
return bool(diff_map)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Production domain"""
|
||||
from .sync_node import ProductionSyncNode
|
||||
from .sync_strategy import ProductionSyncStrategy
|
||||
from .jsonl_handler import ProductionJsonlHandler
|
||||
|
||||
__all__ = ["ProductionSyncNode", "ProductionSyncStrategy", "ProductionJsonlHandler"]
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Production (投产节点) API Handler
|
||||
|
||||
职责:
|
||||
- 更新项目投产节点数据
|
||||
- 不支持创建和删除(投产节点随项目自动生成)
|
||||
|
||||
实现的接口:
|
||||
PUT:
|
||||
- /production - 更新投产节点数据
|
||||
"""
|
||||
|
||||
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 ...datasource.api.errors import ERROR_VALIDATION_FAILED
|
||||
from .sync_node import ProductionSyncNode
|
||||
from schemas.project_extensions.production import ProductionDetail, PostProduction
|
||||
|
||||
|
||||
class ProductionApiHandler(BaseApiHandler):
|
||||
"""投产节点 API Handler"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._node_type = "production"
|
||||
self._node_class = ProductionSyncNode
|
||||
self._schema = ProductionDetail
|
||||
self.update_schemas = [PostProduction]
|
||||
|
||||
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 production load")
|
||||
return []
|
||||
|
||||
nodes = []
|
||||
for project in projects:
|
||||
project_id = project.data_id
|
||||
|
||||
try:
|
||||
# 从 project context 或缓存获取 all_info
|
||||
all_info = project.context.get("all_info")
|
||||
if not all_info:
|
||||
all_info = await ProjectApiHandler.get_all_info(
|
||||
self.api_client,
|
||||
project_id
|
||||
)
|
||||
|
||||
# 提取 production_list
|
||||
production_list = all_info.get("production_list", [])
|
||||
print(f" Project {project_id}: {len(production_list)} productions")
|
||||
|
||||
for prod_data in production_list:
|
||||
# 验证数据
|
||||
try:
|
||||
validated = self._schema.model_validate(prod_data)
|
||||
node = self._create_node(validated.model_dump())
|
||||
node.context["project_id"] = project_id
|
||||
nodes.append(node)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Skipping invalid production: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load productions for project {project_id}: {e}")
|
||||
continue
|
||||
|
||||
return nodes
|
||||
|
||||
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""不支持创建"""
|
||||
return [
|
||||
TaskResult.failed(node_id=node.node_id, error="Production creation not supported (auto-generated with project)")
|
||||
for node in nodes
|
||||
]
|
||||
|
||||
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
|
||||
"""批量更新投产节点数据"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
results = []
|
||||
|
||||
for node in nodes:
|
||||
# 获取节点数据 (仅限 schema 内字段)
|
||||
node_data = node.get_data()
|
||||
if not node_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: {error_msg}"))
|
||||
continue
|
||||
|
||||
# 获取来自 API load 的原始数据进行对比
|
||||
origin_data = node.get_origin_data() or {}
|
||||
|
||||
# 获取过滤后的更新数据
|
||||
update_data = self._filter_update_data(node_data, origin_data, PostProduction)
|
||||
|
||||
if not update_data:
|
||||
reason = f"No physical diff found for production {node.data_id} despite strategy update request (Normalization Discrepancy)."
|
||||
print(f" [WARNING] [production] {reason}")
|
||||
results.append(TaskResult.skipped(node_id=node.node_id, reason=reason))
|
||||
continue
|
||||
|
||||
# 验证并转换为 Pydantic model
|
||||
try:
|
||||
update_model = PostProduction.model_validate(update_data)
|
||||
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()
|
||||
update_model_data = update_model.model_dump()
|
||||
biz_id = node.data_id or update_model_data.get("id")
|
||||
if not biz_id:
|
||||
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
|
||||
continue
|
||||
|
||||
try:
|
||||
await api_update_production(self.api_client, update_model, push_id, biz_id)
|
||||
results.append(TaskResult.success(node_id=node.node_id))
|
||||
except Exception as e:
|
||||
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="Production 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)
|
||||
|
||||
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
|
||||
node = self._create_basic_node(data, depend_ids=[])
|
||||
|
||||
if data.get("project_id"):
|
||||
node.context["project_id"] = data["project_id"]
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# ========== 静态 API 函数 ==========
|
||||
|
||||
async def api_update_production(client, data: PostProduction, push_id: str, biz_id: str) -> None:
|
||||
"""
|
||||
PUT /production - 更新投产节点数据
|
||||
|
||||
Args:
|
||||
client: API 客户端
|
||||
data: 更新数据(Pydantic model)
|
||||
push_id: 推送ID
|
||||
biz_id: 业务ID
|
||||
"""
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
|
||||
response = await client.request(method="PUT", endpoint="/production_nodes", data=request_data)
|
||||
if response.get("code") != 200:
|
||||
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Production Domain - JSONL Handler"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import ProductionSyncNode
|
||||
from schemas.project_extensions.production import ProductionDetail
|
||||
|
||||
|
||||
class ProductionJsonlHandler(BaseJsonlHandler):
|
||||
"""投产节点 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "production", ProductionSyncNode, ProductionDetail)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Production domain registration"""
|
||||
from ...common.registry import DomainRegistry
|
||||
from schemas.project_extensions.production import ProductionDetail
|
||||
from .sync_node import ProductionSyncNode
|
||||
from .sync_strategy import ProductionSyncStrategy
|
||||
from .jsonl_handler import ProductionJsonlHandler
|
||||
from .api_handler import ProductionApiHandler
|
||||
|
||||
# 注册 production domain
|
||||
DomainRegistry.register(
|
||||
node_type="production",
|
||||
schema=ProductionDetail,
|
||||
node_class=ProductionSyncNode,
|
||||
strategy_class=ProductionSyncStrategy,
|
||||
jsonl_handler_class=ProductionJsonlHandler,
|
||||
api_handler_class=ProductionApiHandler,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Production SyncNode"""
|
||||
|
||||
from ...common.sync_node import SyncNode
|
||||
from schemas.project_extensions.production import ProductionDetail
|
||||
|
||||
|
||||
class ProductionSyncNode(SyncNode[ProductionDetail]):
|
||||
"""投产节点同步节点"""
|
||||
node_type = "production"
|
||||
schema = ProductionDetail
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -0,0 +1,38 @@
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from ...sync_system.strategy_ops.compare_ops import normalized_data_for_compare, collect_payload_diffs
|
||||
from schemas.project_extensions.production import ProductionDetail, PostProduction
|
||||
|
||||
|
||||
class ProductionSyncStrategy(DefaultSyncStrategy[ProductionDetail]):
|
||||
"""
|
||||
Production 同步策略(项目扩展)。
|
||||
"""
|
||||
|
||||
schema = ProductionDetail
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["project_id", "code"],
|
||||
depend_fields={"project_id": "project"},
|
||||
local_orphan_action=OrphanAction.NONE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
|
||||
def _needs_update(self, source_node, target_node, resolved_data=None, data_id_map=None) -> bool:
|
||||
if resolved_data is None:
|
||||
raise ValueError(f"[{self.node_type}] resolved_data is required for differential sync.")
|
||||
|
||||
target_data = target_node.get_data()
|
||||
if target_data is None:
|
||||
return False
|
||||
|
||||
update_fields = set(PostProduction.model_fields.keys())
|
||||
source_payload = {key: value for key, value in resolved_data.items() if key in update_fields}
|
||||
target_payload = {key: value for key, value in target_data.items() if key in update_fields}
|
||||
|
||||
normalized_source = normalized_data_for_compare(source_payload, data_id_map or {})
|
||||
normalized_target = normalized_data_for_compare(target_payload, data_id_map or {})
|
||||
diff_map = collect_payload_diffs(normalized_source, normalized_target, max_items=8)
|
||||
return bool(diff_map)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Project domain"""
|
||||
from .sync_node import ProjectSyncNode
|
||||
from .sync_strategy import ProjectSyncStrategy
|
||||
from .jsonl_handler import ProjectJsonlHandler
|
||||
|
||||
__all__ = ["ProjectSyncNode", "ProjectSyncStrategy", "ProjectJsonlHandler"]
|
||||
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
node = self._create_node(project_data.model_dump())
|
||||
# 设置 context
|
||||
node.context["project_id"] = project_data.id
|
||||
|
||||
# 预加载并缓存 all_info,同时存储到 node.context
|
||||
all_info = await self.get_all_info(self.api_client, 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
|
||||
|
||||
# 遍历所有更新 schema,检查差异并更新
|
||||
for schema, api_func in update_configs:
|
||||
update_data = self._get_schema_diff(schema, data, original_data, node.node_id)
|
||||
if not update_data:
|
||||
continue
|
||||
|
||||
# 执行更新(差异已由 _get_schema_diff 打印)
|
||||
try:
|
||||
push_id = self._generate_push_id()
|
||||
# project 需要使用 _filter_update_data 返回的完整 schema 数据
|
||||
full_update_data = self._filter_update_data(data, original_data, schema)
|
||||
await api_func(self.api_client, full_update_data, push_id, biz_id)
|
||||
node_updated = True
|
||||
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)
|
||||
|
||||
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) -> None:
|
||||
"""PUT /project - 更新项目基本信息"""
|
||||
ProjectBaseInfoUpdate.model_validate(data)
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
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) -> None:
|
||||
"""PUT /project/key_constraints - 更新关键节点"""
|
||||
ProjectKeyConstraints.model_validate(data)
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
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) -> None:
|
||||
"""PUT /project/complete_investment - 更新完成投资"""
|
||||
ProjectCompleteInvestment.model_validate(data)
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
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) -> None:
|
||||
"""PUT /project/investment_decision - 更新投资决策"""
|
||||
ProjectInvestmentDecision.model_validate(data)
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
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) -> None:
|
||||
"""PUT /project/dynamic_investment - 更新动态投资"""
|
||||
ProjectDynamicInvestment.model_validate(data)
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
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) -> None:
|
||||
"""PUT /project/settlement_investment - 更新结算投资"""
|
||||
ProjectSettlementInvestment.model_validate(data)
|
||||
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
|
||||
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')}")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Project JSONL Handler
|
||||
"""
|
||||
from ...datasource import BaseJsonlHandler, JsonlDataSource
|
||||
from .sync_node import ProjectSyncNode
|
||||
from schemas.project.project_base import ProjectResponseBase
|
||||
|
||||
|
||||
class ProjectJsonlHandler(BaseJsonlHandler):
|
||||
"""项目 JSONL Handler"""
|
||||
def __init__(self, datasource: JsonlDataSource):
|
||||
super().__init__(datasource, "project", ProjectSyncNode, ProjectResponseBase)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user