feat: add lifecycle reminder workbench

This commit is contained in:
cyuqei
2026-06-19 11:23:32 +08:00
parent b14be976d2
commit ae76b9a296
6 changed files with 350 additions and 4 deletions
+20
View File
@@ -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,
@@ -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()
@@ -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)
+86
View File
@@ -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(
+126 -3
View File
@@ -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<GeneratedContractListItem[]>([]);
const [lifecycleTasks, setLifecycleTasks] = useState<ContractLifecycleTask[]>([]);
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() {
<p>统一跟踪审批状态、生命周期、到期状态和最近合同,优先暴露需要跟进的事项。</p>
</div>
<div className="contract-ledger-actions">
<Button icon={<ReloadOutlined />} onClick={() => void loadContracts()} loading={loading}>
<Button
icon={<ReloadOutlined />}
onClick={() => {
void loadContracts();
void loadLifecycleTasks();
}}
loading={loading || taskLoading}
>
刷新
</Button>
<Button icon={<DownloadOutlined />} disabled={!filteredContracts.length} onClick={handleExportCsv}>
@@ -412,6 +454,87 @@ export default function ContractLedgerPage() {
</div>
</section>
<section className="landing-section">
<div className="landing-section-header">
<h2>到期与履约提醒</h2>
<p>同步合同到期、续约、履约节点和终止归档任务,提醒项在合同详情中处理。</p>
</div>
{activeLifecycleTasks.length === 0 ? (
<Empty description={taskLoading ? "提醒加载中..." : "当前没有待处理提醒。"} />
) : (
<div className="contract-ledger-table-shell">
<Table
rowKey="id"
dataSource={activeLifecycleTasks}
loading={taskLoading}
pagination={false}
columns={[
{
title: "提醒事项",
key: "task",
render: (_, record) => (
<Space direction="vertical" size={2}>
<Link href={`/contracts/${record.contract_id}`}>
<Text strong>{record.title}</Text>
</Link>
<Text type="secondary">{record.contract?.template_name ?? record.contract_id}</Text>
</Space>
),
},
{
title: "类型/优先级",
key: "type",
width: 190,
render: (_, record) => (
<Space wrap>
<Tag color={getLifecycleTaskTypeColor(record.task_type)}>
{formatLifecycleTaskType(record.task_type)}
</Tag>
<Tag color={getLifecycleTaskPriorityColor(record.priority)}>
{formatLifecycleTaskPriority(record.priority)}
</Tag>
</Space>
),
},
{
title: "状态",
dataIndex: "status",
width: 130,
render: (value: string) => (
<Tag color={getLifecycleTaskStatusColor(value)}>
{formatLifecycleTaskStatus(value)}
</Tag>
),
},
{
title: "截止时间",
key: "due_at",
width: 190,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Text>{formatDateTime(record.due_at)}</Text>
<Tag color={getDueStatusColor(record.due_status)}>
{record.days_to_due == null
? "未设置期限"
: record.days_to_due < 0
? `逾期 ${Math.abs(record.days_to_due)} 天`
: `剩余 ${record.days_to_due} 天`}
</Tag>
</Space>
),
},
{
title: "操作",
key: "actions",
width: 110,
render: (_, record) => <Link href={`/contracts/${record.contract_id}`}>处理</Link>,
},
]}
/>
</div>
)}
</section>
<section className="landing-section">
<div className="landing-section-header">
<h2>最近合同</h2>
+25 -1
View File
@@ -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<string, number>;
@@ -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<ApiResponse<ContractLifecycleTaskListResponse>> {
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<ApiResponse<ContractLifecycleTaskListResponse>>(
`/contracts/lifecycle/tasks${queryString ? `?${queryString}` : ""}`,
);
return response.data;
}
export async function syncContractLifecycleTasks(
contract_id: string,
): Promise<ApiResponse<ContractLifecycleTaskListResponse>> {