first commit

This commit is contained in:
strepsiades
2026-03-09 16:31:42 +08:00
commit 4a9c444b10
486 changed files with 52479 additions and 0 deletions
+32
View File
@@ -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_idparent_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_idtask_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_idparent_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)
+17
View File
@@ -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_idparent_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_idmaterial_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_idparent_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_idpersonnel_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