feat: add lifecycle reminder workbench
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
Reference in New Issue
Block a user