From ae76b9a296784f4e09072cd1071e190b25731e70 Mon Sep 17 00:00:00 2001 From: cyuqei Date: Fri, 19 Jun 2026 11:23:32 +0800 Subject: [PATCH] feat: add lifecycle reminder workbench --- backend/app/api/contracts.py | 20 +++ .../contract_lifecycle_task_repository.py | 45 ++++++ .../services/contract_generation_service.py | 48 +++++++ backend/tests/test_contracts_api.py | 86 ++++++++++++ frontend/src/app/contract-ledger/page.tsx | 129 +++++++++++++++++- frontend/src/services/contract.ts | 26 +++- 6 files changed, 350 insertions(+), 4 deletions(-) diff --git a/backend/app/api/contracts.py b/backend/app/api/contracts.py index 15f91628..3c61cd61 100644 --- a/backend/app/api/contracts.py +++ b/backend/app/api/contracts.py @@ -142,6 +142,26 @@ def list_available_templates( return success_response(contract_generation_service.list_available_templates(**kwargs)) +@router.get("/lifecycle/tasks") +def list_contract_lifecycle_task_workbench( + page: int = 1, + page_size: int = 50, + status_value: Optional[str] = None, + task_type: Optional[str] = None, + contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), + current_user: dict = Depends(require_current_user), +): + return success_response( + contract_generation_service.list_contract_lifecycle_task_workbench( + current_user=current_user, + page=page, + page_size=page_size, + status_value=status_value, + task_type=task_type, + ) + ) + + @router.post("/generate") def generate_contract( request: Request, diff --git a/backend/app/repositories/contract_lifecycle_task_repository.py b/backend/app/repositories/contract_lifecycle_task_repository.py index d9d3c426..33aa634e 100644 --- a/backend/app/repositories/contract_lifecycle_task_repository.py +++ b/backend/app/repositories/contract_lifecycle_task_repository.py @@ -3,6 +3,7 @@ from typing import Optional from sqlalchemy.orm import Session from app.models.contract_lifecycle_task import ContractLifecycleTask +from app.models.generated_contract import GeneratedContract class ContractLifecycleTaskRepository: @@ -62,3 +63,47 @@ class ContractLifecycleTaskRepository: ContractLifecycleTask.due_at.asc(), ContractLifecycleTask.created_at.desc(), ).all() + + def list_paginated( + self, + tenant_id: str, + page: int, + page_size: int, + status_value: Optional[str] = None, + task_type: Optional[str] = None, + generated_by: Optional[str] = None, + ) -> list[ContractLifecycleTask]: + query = self.db.query(ContractLifecycleTask).filter(ContractLifecycleTask.tenant_id == tenant_id) + if generated_by: + query = query.join(GeneratedContract, GeneratedContract.id == ContractLifecycleTask.contract_id).filter( + GeneratedContract.tenant_id == tenant_id, + GeneratedContract.generated_by == generated_by, + ) + if status_value: + query = query.filter(ContractLifecycleTask.status == status_value) + if task_type: + query = query.filter(ContractLifecycleTask.task_type == task_type) + return query.order_by( + ContractLifecycleTask.due_at.is_(None), + ContractLifecycleTask.due_at.asc(), + ContractLifecycleTask.created_at.desc(), + ).offset((page - 1) * page_size).limit(page_size).all() + + def count( + self, + tenant_id: str, + status_value: Optional[str] = None, + task_type: Optional[str] = None, + generated_by: Optional[str] = None, + ) -> int: + query = self.db.query(ContractLifecycleTask).filter(ContractLifecycleTask.tenant_id == tenant_id) + if generated_by: + query = query.join(GeneratedContract, GeneratedContract.id == ContractLifecycleTask.contract_id).filter( + GeneratedContract.tenant_id == tenant_id, + GeneratedContract.generated_by == generated_by, + ) + if status_value: + query = query.filter(ContractLifecycleTask.status == status_value) + if task_type: + query = query.filter(ContractLifecycleTask.task_type == task_type) + return query.count() diff --git a/backend/app/services/contract_generation_service.py b/backend/app/services/contract_generation_service.py index 4502b4f8..7f536120 100644 --- a/backend/app/services/contract_generation_service.py +++ b/backend/app/services/contract_generation_service.py @@ -804,6 +804,54 @@ class ContractGenerationService: "summary": self._build_lifecycle_task_summary(items), } + def list_contract_lifecycle_task_workbench( + self, + current_user: Dict[str, Any], + *, + page: int = 1, + page_size: int = 50, + status_value: Optional[str] = None, + task_type: Optional[str] = None, + ) -> Dict[str, Any]: + tenant_id = current_user["tenant_id"] + normalized_page = max(int(page or 1), 1) + normalized_page_size = max(min(int(page_size or 50), 100), 1) + normalized_status = self._normalize_optional_lifecycle_task_status(status_value) + normalized_task_type = self._normalize_optional_lifecycle_task_type(task_type) + generated_by = None if current_user.get("role") == "admin" else str(current_user.get("id") or "") + tasks = self.contract_lifecycle_task_repository.list_paginated( + tenant_id=tenant_id, + page=normalized_page, + page_size=normalized_page_size, + status_value=normalized_status, + task_type=normalized_task_type, + generated_by=generated_by, + ) + total = self.contract_lifecycle_task_repository.count( + tenant_id=tenant_id, + status_value=normalized_status, + task_type=normalized_task_type, + generated_by=generated_by, + ) + + items = [] + for task in tasks: + contract = self.generated_contract_repository.get_by_id(task.contract_id, tenant_id) + if contract is None: + continue + item = self._to_contract_lifecycle_task_item(task) + item["contract"] = self._to_generated_contract_list_item(contract) + items.append(item) + + summary = self._build_lifecycle_task_summary(items) + return { + "items": items, + "total": total, + "page": normalized_page, + "page_size": normalized_page_size, + "summary": summary, + } + def sync_contract_lifecycle_tasks(self, current_user: Dict[str, Any], contract_id: str) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) diff --git a/backend/tests/test_contracts_api.py b/backend/tests/test_contracts_api.py index 4caf5809..c9e00218 100644 --- a/backend/tests/test_contracts_api.py +++ b/backend/tests/test_contracts_api.py @@ -1904,6 +1904,92 @@ class ContractGenerationServiceTestCase(unittest.TestCase): persisted = self.db.query(self.ContractLifecycleTask).filter_by(contract_id="contract-renewal-001").one() self.assertEqual(persisted.task_key, "renewal:contract-renewal-001") + def test_lifecycle_task_workbench_scopes_to_owned_contracts(self): + now = datetime.now(timezone.utc) + self.db.add_all( + [ + self.GeneratedContract( + id="contract-task-owned", + tenant_id="tenant-demo-001", + template_id="tpl-task-owned", + template_name="Owned Task Contract", + contract_type="service", + document_type_code="technical_service", + generated_by="user-admin", + generated_by_name="Admin", + export_format="docx", + file_id=None, + file_name="owned-task.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=10, + generated_content="Owned task content.", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + created_at=now, + ), + self.GeneratedContract( + id="contract-task-other", + tenant_id="tenant-demo-001", + template_id="tpl-task-other", + template_name="Other Task Contract", + contract_type="service", + document_type_code="technical_service", + generated_by="user-other", + generated_by_name="Other", + export_format="docx", + file_id=None, + file_name="other-task.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=10, + generated_content="Other task content.", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + created_at=now, + ), + self.ContractLifecycleTask( + id="task-owned", + tenant_id="tenant-demo-001", + contract_id="contract-task-owned", + task_key="manual:owned", + task_type="renewal_reminder", + title="Owned renewal reminder", + priority="high", + status="open", + due_at=now + timedelta(days=3), + created_by="user-admin", + created_at=now, + ), + self.ContractLifecycleTask( + id="task-other", + tenant_id="tenant-demo-001", + contract_id="contract-task-other", + task_key="manual:other", + task_type="renewal_reminder", + title="Other renewal reminder", + priority="high", + status="open", + due_at=now + timedelta(days=3), + created_by="user-other", + created_at=now, + ), + ] + ) + self.db.commit() + + member_result = self.service.list_contract_lifecycle_task_workbench( + current_user={"id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "Admin", "role": "business"}, + ) + self.assertEqual(member_result["total"], 1) + self.assertEqual(member_result["items"][0]["contract_id"], "contract-task-owned") + self.assertEqual(member_result["items"][0]["contract"]["template_name"], "Owned Task Contract") + + admin_result = self.service.list_contract_lifecycle_task_workbench(current_user=self.current_user) + self.assertEqual(admin_result["total"], 2) + def test_contract_lifecycle_task_can_bind_archive_evidence(self): now = datetime.now(timezone.utc) self.db.add( diff --git a/frontend/src/app/contract-ledger/page.tsx b/frontend/src/app/contract-ledger/page.tsx index e3a3f81b..cb10112b 100644 --- a/frontend/src/app/contract-ledger/page.tsx +++ b/frontend/src/app/contract-ledger/page.tsx @@ -19,15 +19,26 @@ import { formatDateTime, formatDueStatus, formatLifecycleStatus, + formatLifecycleTaskPriority, + formatLifecycleTaskStatus, + formatLifecycleTaskType, formatRisk, getAutoReviewStatusColor, getContractApprovalStatusColor, getDueStatusColor, getLifecycleStatusColor, + getLifecycleTaskPriorityColor, + getLifecycleTaskStatusColor, + getLifecycleTaskTypeColor, getRiskColor, lifecycleStatusOptions, } from "@/lib/format"; -import { listGeneratedContracts, type GeneratedContractListItem } from "@/services/contract"; +import { + listContractLifecycleTaskWorkbench, + listGeneratedContracts, + type ContractLifecycleTask, + type GeneratedContractListItem, +} from "@/services/contract"; const { Text } = Typography; @@ -189,7 +200,9 @@ function csvEscape(value: unknown) { export default function ContractLedgerPage() { const [messageApi, contextHolder] = message.useMessage(); const [contracts, setContracts] = useState([]); + const [lifecycleTasks, setLifecycleTasks] = useState([]); const [loading, setLoading] = useState(true); + const [taskLoading, setTaskLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); const [lifecycleFilter, setLifecycleFilter] = useState(ALL_FILTER); const [dueFilter, setDueFilter] = useState(ALL_FILTER); @@ -212,12 +225,29 @@ export default function ContractLedgerPage() { } }, [messageApi]); + const loadLifecycleTasks = useCallback(async () => { + setTaskLoading(true); + try { + const result = await listContractLifecycleTaskWorkbench({ page: 1, page_size: 100 }); + if (result.code !== 0) { + messageApi.error(result.message || "到期提醒加载失败"); + return; + } + setLifecycleTasks(result.data.items); + } catch { + messageApi.error("到期提醒加载失败,请稍后重试"); + } finally { + setTaskLoading(false); + } + }, [messageApi]); + useEffect(() => { const timer = window.setTimeout(() => { void loadContracts(); + void loadLifecycleTasks(); }, 0); return () => window.clearTimeout(timer); - }, [loadContracts]); + }, [loadContracts, loadLifecycleTasks]); const filteredContracts = useMemo(() => { const keyword = searchQuery.trim(); @@ -247,6 +277,11 @@ export default function ContractLedgerPage() { }) .slice(0, 6); }, [filteredContracts]); + const activeLifecycleTasks = useMemo(() => { + return lifecycleTasks + .filter((task) => !["completed", "skipped", "archived"].includes(task.status)) + .slice(0, 8); + }, [lifecycleTasks]); const handleExportCsv = () => { const headers = ["合同名称", "文件名", "类型", "相对方", "负责人", "生命周期", "期限状态", "审批状态", "审批处理人", "自动预审", "风险等级", "最近更新"]; @@ -388,7 +423,14 @@ export default function ContractLedgerPage() {

统一跟踪审批状态、生命周期、到期状态和最近合同,优先暴露需要跟进的事项。

-
+
+
+

到期与履约提醒

+

同步合同到期、续约、履约节点和终止归档任务,提醒项在合同详情中处理。

+
+ {activeLifecycleTasks.length === 0 ? ( + + ) : ( +
+ ( + + + {record.title} + + {record.contract?.template_name ?? record.contract_id} + + ), + }, + { + title: "类型/优先级", + key: "type", + width: 190, + render: (_, record) => ( + + + {formatLifecycleTaskType(record.task_type)} + + + {formatLifecycleTaskPriority(record.priority)} + + + ), + }, + { + title: "状态", + dataIndex: "status", + width: 130, + render: (value: string) => ( + + {formatLifecycleTaskStatus(value)} + + ), + }, + { + title: "截止时间", + key: "due_at", + width: 190, + render: (_, record) => ( + + {formatDateTime(record.due_at)} + + {record.days_to_due == null + ? "未设置期限" + : record.days_to_due < 0 + ? `逾期 ${Math.abs(record.days_to_due)} 天` + : `剩余 ${record.days_to_due} 天`} + + + ), + }, + { + title: "操作", + key: "actions", + width: 110, + render: (_, record) => 处理, + }, + ]} + /> + + )} + +

最近合同

diff --git a/frontend/src/services/contract.ts b/frontend/src/services/contract.ts index 3c889bec..24dee5f9 100644 --- a/frontend/src/services/contract.ts +++ b/frontend/src/services/contract.ts @@ -311,6 +311,7 @@ export interface ContractLifecycleTask { id: string; tenant_id?: string; contract_id: string; + contract?: GeneratedContractListItem | null; task_key?: string; task_type: string; title: string; @@ -332,7 +333,7 @@ export interface ContractLifecycleTask { } export interface ContractLifecycleTaskListResponse { - contract_id: string; + contract_id?: string; items: ContractLifecycleTask[]; total: number; summary: Record; @@ -671,6 +672,29 @@ export async function listContractLifecycleTasks( return response.data; } +export async function listContractLifecycleTaskWorkbench( + params: { page?: number; page_size?: number; status_value?: string; task_type?: string } = {}, +): Promise> { + const searchParams = new URLSearchParams(); + if (params.page) { + searchParams.append("page", String(params.page)); + } + if (params.page_size) { + searchParams.append("page_size", String(params.page_size)); + } + if (params.status_value) { + searchParams.append("status_value", params.status_value); + } + if (params.task_type) { + searchParams.append("task_type", params.task_type); + } + const queryString = searchParams.toString(); + const response = await http.get>( + `/contracts/lifecycle/tasks${queryString ? `?${queryString}` : ""}`, + ); + return response.data; +} + export async function syncContractLifecycleTasks( contract_id: string, ): Promise> {