feat: add contract lifecycle tasks

This commit is contained in:
2026-05-21 17:08:47 +08:00
parent c1bd9d6e7b
commit 8c8fc432bb
10 changed files with 1549 additions and 2 deletions
+155
View File
@@ -25,6 +25,10 @@ ROUTE_PERMISSION_ACTIONS = {
"version_detail": "contracts:versions:detail",
"version_create": "contracts:versions:create",
"lifecycle_update": "contracts:lifecycle:update",
"lifecycle_tasks": "contracts:lifecycle-tasks:list",
"lifecycle_task_sync": "contracts:lifecycle-tasks:sync",
"lifecycle_task_create": "contracts:lifecycle-tasks:create",
"lifecycle_task_update": "contracts:lifecycle-tasks:update",
"preview_generated": "contracts:generated:preview",
"download": "contracts:download",
}
@@ -49,6 +53,28 @@ class GeneratedContractLifecycleUpdateRequest(BaseModel):
lifecycle_note: Optional[str] = Field(default=None, max_length=1000)
class ContractLifecycleTaskCreateRequest(BaseModel):
task_type: str = Field(..., min_length=1, max_length=64)
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = Field(default=None, max_length=4000)
priority: str = Field(default="medium", max_length=32)
status_value: str = Field(default="open", max_length=32)
due_at: Optional[datetime] = None
evidence_file_id: Optional[str] = Field(default=None, max_length=64)
evidence_note: Optional[str] = Field(default=None, max_length=2000)
class ContractLifecycleTaskUpdateRequest(BaseModel):
task_type: Optional[str] = Field(default=None, max_length=64)
title: Optional[str] = Field(default=None, max_length=200)
description: Optional[str] = Field(default=None, max_length=4000)
priority: Optional[str] = Field(default=None, max_length=32)
status_value: Optional[str] = Field(default=None, max_length=32)
due_at: Optional[datetime] = None
evidence_file_id: Optional[str] = Field(default=None, max_length=64)
evidence_note: Optional[str] = Field(default=None, max_length=2000)
def get_contract_generation_service(db: Session = Depends(get_db_session)) -> ContractGenerationService:
return ContractGenerationService(db)
@@ -298,6 +324,135 @@ def update_generated_contract_lifecycle(
return success_response(result)
@router.get("/{contract_id}/lifecycle/tasks")
def list_contract_lifecycle_tasks(
contract_id: str,
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_tasks(
current_user=current_user,
contract_id=contract_id,
status_value=status_value,
task_type=task_type,
)
)
@router.post("/{contract_id}/lifecycle/tasks/sync")
def sync_contract_lifecycle_tasks(
contract_id: str,
request: Request,
contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = contract_generation_service.sync_contract_lifecycle_tasks(current_user=current_user, contract_id=contract_id)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="contract_lifecycle_task_sync",
resource_type="contract",
resource_id=contract_id,
detail_json={
"synced": result.get("synced"),
"reason": result.get("reason"),
"total": result.get("total"),
"summary": result.get("summary"),
},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return success_response(result)
@router.post("/{contract_id}/lifecycle/tasks")
def create_contract_lifecycle_task(
contract_id: str,
payload: ContractLifecycleTaskCreateRequest,
request: Request,
contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = contract_generation_service.create_contract_lifecycle_task(
current_user=current_user,
contract_id=contract_id,
task_type=payload.task_type,
title=payload.title,
description=payload.description,
priority=payload.priority,
status_value=payload.status_value,
due_at=payload.due_at,
evidence_file_id=payload.evidence_file_id,
evidence_note=payload.evidence_note,
)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="contract_lifecycle_task_create",
resource_type="contract_lifecycle_task",
resource_id=result.get("id"),
detail_json={
"contract_id": contract_id,
"task_type": result.get("task_type"),
"priority": result.get("priority"),
"status": result.get("status"),
"due_at": result.get("due_at"),
"evidence_file_id": result.get("evidence_file_id"),
},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return success_response(result)
@router.put("/{contract_id}/lifecycle/tasks/{task_id}")
def update_contract_lifecycle_task(
contract_id: str,
task_id: str,
payload: ContractLifecycleTaskUpdateRequest,
request: Request,
contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = contract_generation_service.update_contract_lifecycle_task(
current_user=current_user,
contract_id=contract_id,
task_id=task_id,
task_type=payload.task_type,
title=payload.title,
description=payload.description,
priority=payload.priority,
status_value=payload.status_value,
due_at=payload.due_at,
evidence_file_id=payload.evidence_file_id,
evidence_note=payload.evidence_note,
)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="contract_lifecycle_task_update",
resource_type="contract_lifecycle_task",
resource_id=task_id,
detail_json={
"contract_id": contract_id,
"task_type": result.get("task_type"),
"priority": result.get("priority"),
"status": result.get("status"),
"due_at": result.get("due_at"),
"evidence_file_id": result.get("evidence_file_id"),
},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return success_response(result)
@router.get("/{contract_id}/versions")
def list_generated_contract_versions(
contract_id: str,
+1
View File
@@ -4,6 +4,7 @@ from app.models.commercial_order import CommercialOrder
from app.models.conversation import Conversation
from app.models.contract_template import ContractTemplate
from app.models.contract_template_version import ContractTemplateVersion
from app.models.contract_lifecycle_task import ContractLifecycleTask
from app.models.file import File
from app.models.generated_contract import GeneratedContract
from app.models.generated_contract_version import GeneratedContractVersion
@@ -0,0 +1,40 @@
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import DateTime, ForeignKey, Index, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class ContractLifecycleTask(Base):
__tablename__ = "contract_lifecycle_tasks"
__table_args__ = (
UniqueConstraint("tenant_id", "task_key", name="uq_contract_lifecycle_tasks_tenant_task_key"),
Index("ix_contract_lifecycle_tasks_tenant_contract", "tenant_id", "contract_id"),
Index("ix_contract_lifecycle_tasks_tenant_status", "tenant_id", "status"),
Index("ix_contract_lifecycle_tasks_tenant_due_at", "tenant_id", "due_at"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
contract_id: Mapped[str] = mapped_column(String(64), ForeignKey("generated_contracts.id"), nullable=False, index=True)
task_key: Mapped[str] = mapped_column(String(160), nullable=False)
task_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
priority: Mapped[str] = mapped_column(String(32), nullable=False, default="medium", index=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="open", index=True)
due_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
completed_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
evidence_file_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("files.id"), nullable=True, index=True)
evidence_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
metadata_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True)
created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
@@ -0,0 +1,64 @@
from typing import Optional
from sqlalchemy.orm import Session
from app.models.contract_lifecycle_task import ContractLifecycleTask
class ContractLifecycleTaskRepository:
def __init__(self, db: Session):
self.db = db
def create(self, task: ContractLifecycleTask) -> ContractLifecycleTask:
self.db.add(task)
self.db.commit()
self.db.refresh(task)
return task
def update(self, task: ContractLifecycleTask) -> ContractLifecycleTask:
self.db.add(task)
self.db.commit()
self.db.refresh(task)
return task
def get_by_id(self, tenant_id: str, contract_id: str, task_id: str) -> Optional[ContractLifecycleTask]:
return (
self.db.query(ContractLifecycleTask)
.filter(
ContractLifecycleTask.tenant_id == tenant_id,
ContractLifecycleTask.contract_id == contract_id,
ContractLifecycleTask.id == task_id,
)
.first()
)
def get_by_task_key(self, tenant_id: str, task_key: str) -> Optional[ContractLifecycleTask]:
return (
self.db.query(ContractLifecycleTask)
.filter(
ContractLifecycleTask.tenant_id == tenant_id,
ContractLifecycleTask.task_key == task_key,
)
.first()
)
def list_by_contract(
self,
tenant_id: str,
contract_id: str,
status_value: Optional[str] = None,
task_type: Optional[str] = None,
) -> list[ContractLifecycleTask]:
query = self.db.query(ContractLifecycleTask).filter(
ContractLifecycleTask.tenant_id == tenant_id,
ContractLifecycleTask.contract_id == contract_id,
)
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(),
).all()
@@ -4,7 +4,7 @@ import io
import base64
from html import escape
from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from enum import Enum
from urllib.parse import quote
@@ -12,8 +12,11 @@ from urllib.parse import quote
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.file import File
from app.models.generated_contract import GeneratedContract
from app.models.generated_contract_version import GeneratedContractVersion
from app.models.contract_lifecycle_task import ContractLifecycleTask
from app.repositories.contract_lifecycle_task_repository import ContractLifecycleTaskRepository
from app.repositories.contract_template_repository import ContractTemplateRepository
from app.repositories.generated_contract_repository import GeneratedContractRepository
from app.repositories.generated_contract_version_repository import GeneratedContractVersionRepository
@@ -56,6 +59,28 @@ CONTRACT_LIFECYCLE_STATUSES = {
"archived",
}
CONTRACT_LIFECYCLE_TASK_TYPES = {
"renewal_reminder",
"performance_milestone",
"termination_archive",
"custom",
}
CONTRACT_LIFECYCLE_TASK_STATUSES = {
"open",
"in_progress",
"completed",
"skipped",
"archived",
}
CONTRACT_LIFECYCLE_TASK_PRIORITIES = {
"low",
"medium",
"high",
"critical",
}
class ContractGenerationService:
def __init__(self, db: Session, review_service: Optional[ReviewService] = None):
@@ -63,6 +88,7 @@ class ContractGenerationService:
self.repository = ContractTemplateRepository(db)
self.generated_contract_repository = GeneratedContractRepository(db)
self.generated_contract_version_repository = GeneratedContractVersionRepository(db)
self.contract_lifecycle_task_repository = ContractLifecycleTaskRepository(db)
self.file_service = FileService(db)
self.legal_article_service = LegalArticleService(db)
self.element_schema_service = LegalDocumentElementSchemaService(db)
@@ -414,6 +440,207 @@ class ContractGenerationService:
updated_contract = self.generated_contract_repository.update(generated_contract)
return self._to_generated_contract_item(updated_contract)
def list_contract_lifecycle_tasks(
self,
current_user: Dict[str, Any],
contract_id: str,
*,
status_value: Optional[str] = None,
task_type: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
normalized_status = self._normalize_optional_lifecycle_task_status(status_value)
normalized_task_type = self._normalize_optional_lifecycle_task_type(task_type)
tasks = self.contract_lifecycle_task_repository.list_by_contract(
tenant_id=tenant_id,
contract_id=contract_id,
status_value=normalized_status,
task_type=normalized_task_type,
)
items = [self._to_contract_lifecycle_task_item(task) for task in tasks]
return {
"contract_id": contract_id,
"items": items,
"total": len(items),
"summary": self._build_lifecycle_task_summary(items),
}
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)
lifecycle_status = generated_contract.lifecycle_status or generated_contract.status or "generated"
expires_at = self._normalize_optional_datetime(generated_contract.expires_at)
task_key = f"renewal:{contract_id}"
existing = self.contract_lifecycle_task_repository.get_by_task_key(tenant_id, task_key)
if expires_at is None:
if existing and existing.status in {"open", "in_progress"}:
existing.status = "archived"
existing.metadata_json = {
**(existing.metadata_json or {}),
"sync_reason": "expires_at_not_set",
"synced_at": datetime.now(timezone.utc).isoformat(),
}
self.contract_lifecycle_task_repository.update(existing)
result = self.list_contract_lifecycle_tasks(current_user, contract_id)
return {**result, "synced": False, "reason": "expires_at_not_set"}
if lifecycle_status in {"terminated", "archived"}:
if existing and existing.status in {"open", "in_progress"}:
existing.status = "archived"
existing.metadata_json = {
**(existing.metadata_json or {}),
"sync_reason": "contract_closed",
"synced_at": datetime.now(timezone.utc).isoformat(),
}
self.contract_lifecycle_task_repository.update(existing)
result = self.list_contract_lifecycle_tasks(current_user, contract_id)
return {**result, "synced": False, "reason": "contract_closed"}
now = datetime.now(timezone.utc)
days_to_expiry = (expires_at.date() - now.date()).days
due_at = expires_at - timedelta(days=30)
priority = "critical" if days_to_expiry < 0 else "high" if days_to_expiry <= 30 else "medium"
title = f"续约提醒:{generated_contract.template_name}"
description = (
f"合同将在 {expires_at.date().isoformat()} 到期,请确认是否续约、变更或终止归档。"
if days_to_expiry >= 0
else f"合同已于 {expires_at.date().isoformat()} 到期,请立即确认续约、补签或终止归档。"
)
metadata = {
"sync_type": "renewal_by_expires_at",
"contract_lifecycle_status": lifecycle_status,
"days_to_expiry": days_to_expiry,
"source_expires_at": expires_at.isoformat(),
"synced_at": now.isoformat(),
}
if existing is None:
task = ContractLifecycleTask(
id=f"clt-{uuid4().hex[:12]}",
tenant_id=tenant_id,
contract_id=contract_id,
task_key=task_key,
task_type="renewal_reminder",
title=title,
description=description,
priority=priority,
status="open",
due_at=due_at,
metadata_json=metadata,
created_by=current_user.get("id"),
created_at=now,
updated_at=now,
)
self.contract_lifecycle_task_repository.create(task)
else:
existing.title = title
existing.description = description
existing.priority = priority
if existing.status in {"archived", "skipped"}:
existing.status = "open"
existing.due_at = due_at
existing.metadata_json = {**(existing.metadata_json or {}), **metadata}
self.contract_lifecycle_task_repository.update(existing)
result = self.list_contract_lifecycle_tasks(current_user, contract_id)
return {**result, "synced": True, "reason": "renewal_reminder_synced"}
def create_contract_lifecycle_task(
self,
current_user: Dict[str, Any],
contract_id: str,
*,
task_type: str,
title: str,
description: Optional[str] = None,
priority: str = "medium",
status_value: str = "open",
due_at: Optional[datetime] = None,
evidence_file_id: Optional[str] = None,
evidence_note: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
normalized_task_type = self._normalize_lifecycle_task_type(task_type)
normalized_priority = self._normalize_lifecycle_task_priority(priority)
normalized_status = self._normalize_lifecycle_task_status(status_value)
normalized_due_at = self._normalize_optional_datetime(due_at)
title_text = self._required_task_text(title, field_name="title", max_length=200)
evidence_file = self._resolve_contract_lifecycle_evidence_file(tenant_id, evidence_file_id)
now = datetime.now(timezone.utc)
task = ContractLifecycleTask(
id=f"clt-{uuid4().hex[:12]}",
tenant_id=tenant_id,
contract_id=contract_id,
task_key=f"manual:{uuid4().hex}",
task_type=normalized_task_type,
title=title_text,
description=self._optional_task_text(description, max_length=4000),
priority=normalized_priority,
status=normalized_status,
due_at=normalized_due_at,
completed_by=current_user.get("id") if normalized_status == "completed" else None,
completed_at=now if normalized_status == "completed" else None,
evidence_file_id=evidence_file.id if evidence_file is not None else None,
evidence_note=self._optional_task_text(evidence_note, max_length=2000),
metadata_json={"source": "manual"},
created_by=current_user.get("id"),
created_at=now,
updated_at=now,
)
created_task = self.contract_lifecycle_task_repository.create(task)
return self._to_contract_lifecycle_task_item(created_task)
def update_contract_lifecycle_task(
self,
current_user: Dict[str, Any],
contract_id: str,
task_id: str,
*,
task_type: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
priority: Optional[str] = None,
status_value: Optional[str] = None,
due_at: Optional[datetime] = None,
evidence_file_id: Optional[str] = None,
evidence_note: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
task = self.contract_lifecycle_task_repository.get_by_id(tenant_id, contract_id, task_id)
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="lifecycle task not found")
if task_type is not None:
task.task_type = self._normalize_lifecycle_task_type(task_type)
if title is not None:
task.title = self._required_task_text(title, field_name="title", max_length=200)
if description is not None:
task.description = self._optional_task_text(description, max_length=4000)
if priority is not None:
task.priority = self._normalize_lifecycle_task_priority(priority)
if status_value is not None:
normalized_status = self._normalize_lifecycle_task_status(status_value)
task.status = normalized_status
if normalized_status == "completed" and task.completed_at is None:
task.completed_by = current_user.get("id")
task.completed_at = datetime.now(timezone.utc)
elif normalized_status != "completed":
task.completed_by = None
task.completed_at = None
if due_at is not None:
task.due_at = self._normalize_optional_datetime(due_at)
if evidence_file_id is not None:
evidence_file = self._resolve_contract_lifecycle_evidence_file(tenant_id, evidence_file_id)
task.evidence_file_id = evidence_file.id if evidence_file is not None else None
if evidence_note is not None:
task.evidence_note = self._optional_task_text(evidence_note, max_length=2000)
updated_task = self.contract_lifecycle_task_repository.update(task)
return self._to_contract_lifecycle_task_item(updated_task)
def list_generated_contract_versions(self, current_user: Dict[str, Any], contract_id: str) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
@@ -989,6 +1216,180 @@ class ContractGenerationService:
return None
return cls._normalize_lifecycle_status(value)
def _get_generated_contract_for_lifecycle_task(
self,
current_user: Dict[str, Any],
contract_id: str,
) -> GeneratedContract:
tenant_id = current_user["tenant_id"]
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
if not generated_contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
return generated_contract
@classmethod
def _normalize_lifecycle_task_type(cls, value: str) -> str:
normalized = str(value or "").strip().lower().replace("-", "_")
if normalized not in CONTRACT_LIFECYCLE_TASK_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid lifecycle task type")
return normalized
@classmethod
def _normalize_optional_lifecycle_task_type(cls, value: Optional[str]) -> Optional[str]:
if value is None or not str(value).strip():
return None
return cls._normalize_lifecycle_task_type(value)
@classmethod
def _normalize_lifecycle_task_status(cls, value: str) -> str:
normalized = str(value or "").strip().lower().replace("-", "_")
if normalized not in CONTRACT_LIFECYCLE_TASK_STATUSES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid lifecycle task status")
return normalized
@classmethod
def _normalize_optional_lifecycle_task_status(cls, value: Optional[str]) -> Optional[str]:
if value is None or not str(value).strip():
return None
return cls._normalize_lifecycle_task_status(value)
@classmethod
def _normalize_lifecycle_task_priority(cls, value: str) -> str:
normalized = str(value or "medium").strip().lower().replace("-", "_")
if normalized not in CONTRACT_LIFECYCLE_TASK_PRIORITIES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid lifecycle task priority")
return normalized
@staticmethod
def _required_task_text(value: Any, *, field_name: str, max_length: int) -> str:
normalized = str(value or "").strip()
if not normalized:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is required")
if len(normalized) > max_length:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is too long")
return normalized
@staticmethod
def _optional_task_text(value: Optional[Any], *, max_length: int) -> Optional[str]:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
return None
if len(normalized) > max_length:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="task field is too long")
return normalized
def _resolve_contract_lifecycle_evidence_file(self, tenant_id: str, file_id: Optional[str]) -> Optional[File]:
if file_id is None or not str(file_id).strip():
return None
evidence_file = (
self.db.query(File)
.filter(
File.id == str(file_id).strip(),
File.tenant_id == tenant_id,
File.status == "active",
)
.first()
)
if evidence_file is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="evidence file not found")
return evidence_file
def _to_contract_lifecycle_task_item(self, task: ContractLifecycleTask) -> Dict[str, Any]:
evidence_file = None
if task.evidence_file_id:
evidence_file = (
self.db.query(File)
.filter(
File.id == task.evidence_file_id,
File.tenant_id == task.tenant_id,
File.status == "active",
)
.first()
)
return {
"id": task.id,
"tenant_id": task.tenant_id,
"contract_id": task.contract_id,
"task_key": task.task_key,
"task_type": task.task_type,
"title": task.title,
"description": task.description,
"priority": task.priority,
"status": task.status,
"due_at": self._serialize_optional_datetime(task.due_at),
"due_status": self._build_lifecycle_task_due_status(task),
"days_to_due": self._build_lifecycle_task_days_to_due(task),
"completed_by": task.completed_by,
"completed_at": self._serialize_optional_datetime(task.completed_at),
"evidence_file_id": task.evidence_file_id,
"evidence_file": self._to_lifecycle_task_evidence_file(evidence_file),
"evidence_note": task.evidence_note,
"metadata": task.metadata_json or {},
"created_by": task.created_by,
"created_at": self._serialize_optional_datetime(task.created_at),
"updated_at": self._serialize_optional_datetime(task.updated_at),
}
@staticmethod
def _to_lifecycle_task_evidence_file(evidence_file: Optional[File]) -> Optional[Dict[str, Any]]:
if evidence_file is None:
return None
return {
"id": evidence_file.id,
"file_name": evidence_file.file_name,
"file_type": evidence_file.file_type,
"mime_type": evidence_file.mime_type,
"file_size": evidence_file.file_size,
"download_url": f"/api/v1/files/{evidence_file.id}/download",
"created_at": evidence_file.created_at.isoformat() if evidence_file.created_at else None,
}
def _build_lifecycle_task_days_to_due(self, task: ContractLifecycleTask) -> Optional[int]:
if task.due_at is None:
return None
due_at = self._normalize_optional_datetime(task.due_at)
return (due_at.date() - datetime.now(timezone.utc).date()).days if due_at is not None else None
def _build_lifecycle_task_due_status(self, task: ContractLifecycleTask) -> str:
if task.status in {"completed", "skipped", "archived"}:
return "closed"
days_to_due = self._build_lifecycle_task_days_to_due(task)
if days_to_due is None:
return "not_set"
if days_to_due < 0:
return "overdue"
if days_to_due <= 7:
return "due_soon"
return "normal"
@staticmethod
def _build_lifecycle_task_summary(items: List[Dict[str, Any]]) -> Dict[str, int]:
summary = {
"open": 0,
"in_progress": 0,
"completed": 0,
"skipped": 0,
"archived": 0,
"overdue": 0,
"due_soon": 0,
"with_evidence": 0,
}
for item in items:
status_value = item.get("status")
if status_value in summary:
summary[status_value] += 1
if item.get("due_status") == "overdue":
summary["overdue"] += 1
if item.get("due_status") == "due_soon":
summary["due_soon"] += 1
if item.get("evidence_file_id"):
summary["with_evidence"] += 1
return summary
def _build_contract_lifecycle(self, generated_contract: GeneratedContract) -> Dict[str, Any]:
lifecycle_status = generated_contract.lifecycle_status or generated_contract.status or "generated"
expires_at = self._normalize_optional_datetime(generated_contract.expires_at)
@@ -0,0 +1,68 @@
"""create contract lifecycle tasks"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20260521_0037"
down_revision = "20260521_0036"
branch_labels = None
depends_on = None
def _table_names(inspector: sa.Inspector) -> set[str]:
return set(inspector.get_table_names())
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "contract_lifecycle_tasks" in _table_names(inspector):
return
op.create_table(
"contract_lifecycle_tasks",
sa.Column("id", sa.String(length=64), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("contract_id", sa.String(length=64), nullable=False),
sa.Column("task_key", sa.String(length=160), nullable=False),
sa.Column("task_type", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("priority", sa.String(length=32), nullable=False, server_default="medium"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_by", sa.String(length=64), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("evidence_file_id", sa.String(length=64), nullable=True),
sa.Column("evidence_note", sa.Text(), nullable=True),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["contract_id"], ["generated_contracts.id"]),
sa.ForeignKeyConstraint(["evidence_file_id"], ["files.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "task_key", name="uq_contract_lifecycle_tasks_tenant_task_key"),
)
op.create_index("ix_contract_lifecycle_tasks_contract_id", "contract_lifecycle_tasks", ["contract_id"])
op.create_index("ix_contract_lifecycle_tasks_created_at", "contract_lifecycle_tasks", ["created_at"])
op.create_index("ix_contract_lifecycle_tasks_due_at", "contract_lifecycle_tasks", ["due_at"])
op.create_index("ix_contract_lifecycle_tasks_evidence_file_id", "contract_lifecycle_tasks", ["evidence_file_id"])
op.create_index("ix_contract_lifecycle_tasks_priority", "contract_lifecycle_tasks", ["priority"])
op.create_index("ix_contract_lifecycle_tasks_status", "contract_lifecycle_tasks", ["status"])
op.create_index("ix_contract_lifecycle_tasks_task_type", "contract_lifecycle_tasks", ["task_type"])
op.create_index("ix_contract_lifecycle_tasks_tenant_contract", "contract_lifecycle_tasks", ["tenant_id", "contract_id"])
op.create_index("ix_contract_lifecycle_tasks_tenant_due_at", "contract_lifecycle_tasks", ["tenant_id", "due_at"])
op.create_index("ix_contract_lifecycle_tasks_tenant_id", "contract_lifecycle_tasks", ["tenant_id"])
op.create_index("ix_contract_lifecycle_tasks_tenant_status", "contract_lifecycle_tasks", ["tenant_id", "status"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "contract_lifecycle_tasks" not in _table_names(inspector):
return
op.drop_table("contract_lifecycle_tasks")
+294
View File
@@ -161,6 +161,117 @@ class StubContractGenerationService:
}
return record
def _lifecycle_task(self, contract_id="contract-123", task_id="clt-001", status_value="open"):
return {
"id": task_id,
"tenant_id": "tenant-demo-001",
"contract_id": contract_id,
"task_key": "renewal:contract-123",
"task_type": "renewal_reminder",
"title": "Renewal reminder",
"description": "Confirm renewal decision",
"priority": "high",
"status": status_value,
"due_at": "2026-06-01T00:00:00+00:00",
"due_status": "due_soon",
"days_to_due": 7,
"completed_by": "user-admin" if status_value == "completed" else None,
"completed_at": "2026-05-21T00:00:00+00:00" if status_value == "completed" else None,
"evidence_file_id": "file-evidence-001" if status_value == "completed" else None,
"evidence_file": {
"id": "file-evidence-001",
"file_name": "termination-proof.pdf",
"file_type": "contract_lifecycle_evidence",
"mime_type": "application/pdf",
"file_size": 128,
"download_url": "/api/v1/files/file-evidence-001/download",
"created_at": "2026-05-21T00:00:00+00:00",
} if status_value == "completed" else None,
"evidence_note": "Archive proof" if status_value == "completed" else None,
"metadata": {"source": "manual"},
"created_by": "user-admin",
"created_at": "2026-05-21T00:00:00+00:00",
"updated_at": "2026-05-21T00:00:00+00:00",
}
def list_contract_lifecycle_tasks(self, current_user, contract_id, status_value=None, task_type=None):
self.get_generated_contract(current_user, contract_id)
task = self._lifecycle_task(contract_id=contract_id)
if status_value and task["status"] != status_value:
items = []
elif task_type and task["task_type"] != task_type:
items = []
else:
items = [task]
return {
"contract_id": contract_id,
"items": items,
"total": len(items),
"summary": {"open": len(items), "in_progress": 0, "completed": 0, "skipped": 0, "archived": 0, "overdue": 0, "due_soon": len(items), "with_evidence": 0},
}
def sync_contract_lifecycle_tasks(self, current_user, contract_id):
result = self.list_contract_lifecycle_tasks(current_user, contract_id)
result["synced"] = True
result["reason"] = "renewal_reminder_synced"
return result
def create_contract_lifecycle_task(
self,
current_user,
contract_id,
task_type,
title,
description=None,
priority="medium",
status_value="open",
due_at=None,
evidence_file_id=None,
evidence_note=None,
):
self.get_generated_contract(current_user, contract_id)
task = self._lifecycle_task(contract_id=contract_id, task_id="clt-created", status_value=status_value)
task["task_type"] = task_type
task["title"] = title
task["description"] = description
task["priority"] = priority
task["due_at"] = due_at.isoformat() if due_at else None
task["evidence_file_id"] = evidence_file_id
task["evidence_note"] = evidence_note
return task
def update_contract_lifecycle_task(
self,
current_user,
contract_id,
task_id,
task_type=None,
title=None,
description=None,
priority=None,
status_value=None,
due_at=None,
evidence_file_id=None,
evidence_note=None,
):
self.get_generated_contract(current_user, contract_id)
task = self._lifecycle_task(contract_id=contract_id, task_id=task_id, status_value=status_value or "open")
if task_type is not None:
task["task_type"] = task_type
if title is not None:
task["title"] = title
if description is not None:
task["description"] = description
if priority is not None:
task["priority"] = priority
if due_at is not None:
task["due_at"] = due_at.isoformat()
if evidence_file_id is not None:
task["evidence_file_id"] = evidence_file_id
if evidence_note is not None:
task["evidence_note"] = evidence_note
return task
def list_available_templates(self, current_user, contract_type, document_type_code=None, template_category=None, industry_category=None):
return [
{
@@ -437,6 +548,45 @@ class ContractsApiTestCase(unittest.TestCase):
self.assertEqual(self.audit_log_service.operations[0]["action"], "contract_lifecycle_update")
self.assertEqual(self.audit_log_service.operations[0]["detail_json"]["counterparty_name"], "Supplier A")
def test_user_can_manage_contract_lifecycle_tasks_and_write_audit_logs(self):
self._login()
listed = self.client.get("/api/v1/contracts/contract-123/lifecycle/tasks")
self.assertEqual(listed.status_code, 200)
self.assertEqual(listed.json()["data"]["items"][0]["task_type"], "renewal_reminder")
synced = self.client.post("/api/v1/contracts/contract-123/lifecycle/tasks/sync")
self.assertEqual(synced.status_code, 200)
self.assertTrue(synced.json()["data"]["synced"])
self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_lifecycle_task_sync")
created = self.client.post(
"/api/v1/contracts/contract-123/lifecycle/tasks",
json={
"task_type": "performance_milestone",
"title": "验收节点",
"description": "确认验收材料",
"priority": "high",
"status_value": "open",
"due_at": "2026-06-01T00:00:00+08:00",
},
)
self.assertEqual(created.status_code, 200)
self.assertEqual(created.json()["data"]["task_type"], "performance_milestone")
self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_lifecycle_task_create")
updated = self.client.put(
"/api/v1/contracts/contract-123/lifecycle/tasks/clt-created",
json={
"status_value": "completed",
"evidence_file_id": "file-evidence-001",
"evidence_note": "终止归档证据已绑定",
},
)
self.assertEqual(updated.status_code, 200)
self.assertEqual(updated.json()["data"]["status"], "completed")
self.assertEqual(updated.json()["data"]["evidence_file_id"], "file-evidence-001")
self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_lifecycle_task_update")
def test_generated_contracts_are_tenant_isolated(self):
self._login("tenant-other-001")
response = self.client.get("/api/v1/contracts")
@@ -571,7 +721,9 @@ class ContractGenerationServiceTestCase(unittest.TestCase):
from sqlalchemy.orm import sessionmaker
from app.db import Base
from app.models.contract_lifecycle_task import ContractLifecycleTask
from app.models.contract_template import ContractTemplate
from app.models.file import File
from app.models.generated_contract import GeneratedContract
from app.models.legal_article import LegalArticle
from app.models.legal_document_element_schema import LegalDocumentElementSchema
@@ -583,6 +735,8 @@ class ContractGenerationServiceTestCase(unittest.TestCase):
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
self.db = self.SessionLocal()
self.ContractTemplate = ContractTemplate
self.ContractLifecycleTask = ContractLifecycleTask
self.File = File
self.GeneratedContract = GeneratedContract
self.LegalArticle = LegalArticle
self.LegalDocumentType = LegalDocumentType
@@ -973,6 +1127,146 @@ class ContractGenerationServiceTestCase(unittest.TestCase):
self.assertEqual(result["lifecycle"]["days_to_expiry"], 20)
self.assertEqual(result["lifecycle"]["updated_by"], "user-admin")
def test_sync_contract_lifecycle_tasks_creates_renewal_reminder(self):
now = datetime.now(timezone.utc)
self.db.add(
self.GeneratedContract(
id="contract-renewal-001",
tenant_id="tenant-demo-001",
template_id="tpl-renewal",
template_name="Renewal Template",
contract_type="procurement",
document_type_code="procurement_goods",
generated_by="user-admin",
generated_by_name="Admin",
export_format="docx",
file_id=None,
file_name="renewal.docx",
mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
file_size=10,
generated_content="Renewal contract content.",
variable_values_json={},
export_options_json={},
file_content_base64="",
status="generated",
lifecycle_status="effective",
expires_at=now + timedelta(days=20),
created_at=now,
)
)
self.db.commit()
result = self.service.sync_contract_lifecycle_tasks(
current_user=self.current_user,
contract_id="contract-renewal-001",
)
self.assertTrue(result["synced"])
self.assertEqual(result["items"][0]["task_type"], "renewal_reminder")
self.assertEqual(result["items"][0]["priority"], "high")
self.assertEqual(result["summary"]["open"], 1)
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_contract_lifecycle_task_can_bind_archive_evidence(self):
now = datetime.now(timezone.utc)
self.db.add(
self.GeneratedContract(
id="contract-archive-001",
tenant_id="tenant-demo-001",
template_id="tpl-archive",
template_name="Archive Template",
contract_type="procurement",
document_type_code="procurement_goods",
generated_by="user-admin",
generated_by_name="Admin",
export_format="docx",
file_id=None,
file_name="archive.docx",
mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
file_size=10,
generated_content="Archive contract content.",
variable_values_json={},
export_options_json={},
file_content_base64="",
status="generated",
lifecycle_status="terminated",
created_at=now,
)
)
self.db.add(
self.File(
id="file-archive-evidence",
tenant_id="tenant-demo-001",
uploaded_by="user-admin",
bucket="default",
object_key="contract-lifecycle-evidence/file-archive-evidence.pdf",
file_name="termination-proof.pdf",
mime_type="application/pdf",
file_size=256,
file_hash="hash-archive-evidence",
file_type="contract_lifecycle_evidence",
status="active",
created_at=now,
)
)
self.db.commit()
created = self.service.create_contract_lifecycle_task(
current_user=self.current_user,
contract_id="contract-archive-001",
task_type="termination_archive",
title="终止归档",
status_value="completed",
evidence_file_id="file-archive-evidence",
evidence_note="双方确认终止并归档。",
)
self.assertEqual(created["status"], "completed")
self.assertEqual(created["evidence_file"]["file_name"], "termination-proof.pdf")
self.assertEqual(created["due_status"], "closed")
listed = self.service.list_contract_lifecycle_tasks(self.current_user, "contract-archive-001")
self.assertEqual(listed["summary"]["completed"], 1)
self.assertEqual(listed["summary"]["with_evidence"], 1)
def test_contract_lifecycle_task_rejects_missing_evidence_file(self):
now = datetime.now(timezone.utc)
self.db.add(
self.GeneratedContract(
id="contract-missing-evidence",
tenant_id="tenant-demo-001",
template_id="tpl-archive",
template_name="Archive Template",
contract_type="procurement",
document_type_code="procurement_goods",
generated_by="user-admin",
generated_by_name="Admin",
export_format="docx",
file_id=None,
file_name="archive.docx",
mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
file_size=10,
generated_content="Archive contract content.",
variable_values_json={},
export_options_json={},
file_content_base64="",
status="generated",
created_at=now,
)
)
self.db.commit()
with self.assertRaises(HTTPException) as ctx:
self.service.create_contract_lifecycle_task(
current_user=self.current_user,
contract_id="contract-missing-evidence",
task_type="termination_archive",
title="终止归档",
evidence_file_id="file-missing",
)
self.assertEqual(ctx.exception.status_code, 404)
def test_update_generated_contract_lifecycle_rejects_invalid_range(self):
now = datetime.now(timezone.utc)
self.db.add(
+386 -1
View File
@@ -2,19 +2,26 @@
import Link from "next/link";
import { useMemo, useEffect, useState } from "react";
import { Button, Card, Descriptions, Empty, Form, Input, Modal, Select, Space, Table, Tag, Typography, message } from "antd";
import { Button, Card, Descriptions, Empty, Form, Input, Modal, Select, Space, Table, Tag, Typography, Upload, message } from "antd";
import type { UploadFile } from "antd/es/upload/interface";
import { useRouter } from "next/navigation";
import {
createContractLifecycleTask,
downloadContract,
getGeneratedContract,
getGeneratedContractPreview,
listContractLifecycleTasks,
syncContractLifecycleTasks,
updateContractLifecycleTask,
updateGeneratedContractLifecycle,
type AutoReviewSummary,
type ContractLifecycle,
type ContractLifecycleTask,
type GeneratedContract,
type GeneratedContractPreview,
} from "@/services/contract";
import { uploadFile } from "@/services/file";
import { downloadReviewReport } from "@/services/review";
import { isRequestError } from "@/services/http";
import { useAuthStore } from "@/stores/auth.store";
@@ -31,6 +38,17 @@ type LifecycleFormValues = {
lifecycle_note?: string;
};
type LifecycleTaskFormValues = {
task_type: string;
title: string;
description?: string;
priority: string;
status_value: string;
due_at?: string;
evidence_file_id?: string;
evidence_note?: string;
};
function formatDateTime(value: string | null | undefined) {
if (!value) {
return "-";
@@ -107,6 +125,50 @@ const dueStatusMeta: Record<string, { label: string; color: string }> = {
closed: { label: "已关闭", color: "default" },
};
const lifecycleTaskTypeOptions = [
{ value: "renewal_reminder", label: "续约提醒" },
{ value: "performance_milestone", label: "履约节点" },
{ value: "termination_archive", label: "终止归档" },
{ value: "custom", label: "自定义任务" },
];
const lifecycleTaskTypeMeta: Record<string, { label: string; color: string }> = {
renewal_reminder: { label: "续约提醒", color: "gold" },
performance_milestone: { label: "履约节点", color: "blue" },
termination_archive: { label: "终止归档", color: "volcano" },
custom: { label: "自定义任务", color: "default" },
};
const lifecycleTaskStatusOptions = [
{ value: "open", label: "待处理" },
{ value: "in_progress", label: "处理中" },
{ value: "completed", label: "已完成" },
{ value: "skipped", label: "已跳过" },
{ value: "archived", label: "已归档" },
];
const lifecycleTaskStatusMeta: Record<string, { label: string; color: string }> = {
open: { label: "待处理", color: "gold" },
in_progress: { label: "处理中", color: "blue" },
completed: { label: "已完成", color: "green" },
skipped: { label: "已跳过", color: "default" },
archived: { label: "已归档", color: "default" },
};
const lifecycleTaskPriorityOptions = [
{ value: "low", label: "低" },
{ value: "medium", label: "中" },
{ value: "high", label: "高" },
{ value: "critical", label: "紧急" },
];
const lifecycleTaskPriorityMeta: Record<string, { label: string; color: string }> = {
low: { label: "低", color: "default" },
medium: { label: "中", color: "blue" },
high: { label: "高", color: "orange" },
critical: { label: "紧急", color: "red" },
};
function formatAutoReviewStatus(value: string | null | undefined) {
if (!value) {
return "未触发";
@@ -167,6 +229,48 @@ function getDueStatusColor(value: string | null | undefined) {
return dueStatusMeta[value]?.color ?? "default";
}
function formatLifecycleTaskType(value: string | null | undefined) {
if (!value) {
return "未分类";
}
return lifecycleTaskTypeMeta[value]?.label ?? value;
}
function getLifecycleTaskTypeColor(value: string | null | undefined) {
if (!value) {
return "default";
}
return lifecycleTaskTypeMeta[value]?.color ?? "default";
}
function formatLifecycleTaskStatus(value: string | null | undefined) {
if (!value) {
return "未设置";
}
return lifecycleTaskStatusMeta[value]?.label ?? value;
}
function getLifecycleTaskStatusColor(value: string | null | undefined) {
if (!value) {
return "default";
}
return lifecycleTaskStatusMeta[value]?.color ?? "default";
}
function formatLifecycleTaskPriority(value: string | null | undefined) {
if (!value) {
return "中";
}
return lifecycleTaskPriorityMeta[value]?.label ?? value;
}
function getLifecycleTaskPriorityColor(value: string | null | undefined) {
if (!value) {
return "blue";
}
return lifecycleTaskPriorityMeta[value]?.color ?? "default";
}
function toDatetimeLocalValue(value: string | null | undefined) {
if (!value) {
return undefined;
@@ -219,12 +323,20 @@ function buildContractChatHref(
export default function GeneratedContractDetailPage({ params }: { params: Promise<{ id: string }> }) {
const [messageApi, contextHolder] = message.useMessage();
const [lifecycleForm] = Form.useForm<LifecycleFormValues>();
const [lifecycleTaskForm] = Form.useForm<LifecycleTaskFormValues>();
const router = useRouter();
const currentUser = useAuthStore((state) => state.currentUser);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
const [detail, setDetail] = useState<GeneratedContract | null>(null);
const [preview, setPreview] = useState<GeneratedContractPreview | null>(null);
const [lifecycleTasks, setLifecycleTasks] = useState<ContractLifecycleTask[]>([]);
const [lifecycleTaskLoading, setLifecycleTaskLoading] = useState(false);
const [lifecycleTaskModalOpen, setLifecycleTaskModalOpen] = useState(false);
const [lifecycleTaskSaving, setLifecycleTaskSaving] = useState(false);
const [lifecycleTaskSyncing, setLifecycleTaskSyncing] = useState(false);
const [editingLifecycleTask, setEditingLifecycleTask] = useState<ContractLifecycleTask | null>(null);
const [evidenceUploadList, setEvidenceUploadList] = useState<UploadFile[]>([]);
const [notFound, setNotFound] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [lifecycleModalOpen, setLifecycleModalOpen] = useState(false);
@@ -233,6 +345,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
useEffect(() => {
const loadDetail = async () => {
setLoading(true);
setLifecycleTaskLoading(true);
setNotFound(false);
setLoadError(null);
@@ -244,8 +357,10 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
return;
}
const previewResponse = await getGeneratedContractPreview(id);
const lifecycleTaskResponse = await listContractLifecycleTasks(id);
setDetail(detailResponse.data);
setPreview(previewResponse.code === 0 ? previewResponse.data : null);
setLifecycleTasks(lifecycleTaskResponse.code === 0 ? lifecycleTaskResponse.data.items : []);
} catch (error: unknown) {
if (isRequestError(error) && error.status === 404) {
setNotFound(true);
@@ -254,6 +369,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
setLoadError(error instanceof Error ? error.message : "合同详情加载失败,请稍后重试");
} finally {
setLoading(false);
setLifecycleTaskLoading(false);
}
};
@@ -401,6 +517,126 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
}
};
const reloadLifecycleTasks = async (contractId = detail?.id) => {
if (!contractId) {
return;
}
setLifecycleTaskLoading(true);
try {
const result = await listContractLifecycleTasks(contractId);
if (result.code !== 0) {
messageApi.error(result.message || "履约任务加载失败");
return;
}
setLifecycleTasks(result.data.items);
} catch (error: unknown) {
messageApi.error(error instanceof Error ? error.message : "履约任务加载失败,请稍后重试");
} finally {
setLifecycleTaskLoading(false);
}
};
const handleSyncLifecycleTasks = async () => {
if (!detail) {
return;
}
setLifecycleTaskSyncing(true);
try {
const result = await syncContractLifecycleTasks(detail.id);
if (result.code !== 0) {
messageApi.error(result.message || "续约提醒同步失败");
return;
}
setLifecycleTasks(result.data.items);
messageApi.success(result.data.synced ? "续约提醒已同步" : "当前没有需要同步的续约提醒");
} catch (error: unknown) {
messageApi.error(error instanceof Error ? error.message : "续约提醒同步失败,请稍后重试");
} finally {
setLifecycleTaskSyncing(false);
}
};
const handleOpenLifecycleTaskModal = (task?: ContractLifecycleTask | null, defaultTaskType = "performance_milestone") => {
setEditingLifecycleTask(task ?? null);
setEvidenceUploadList([]);
lifecycleTaskForm.setFieldsValue({
task_type: task?.task_type ?? defaultTaskType,
title: task?.title ?? "",
description: task?.description ?? undefined,
priority: task?.priority ?? "medium",
status_value: task?.status ?? "open",
due_at: toDatetimeLocalValue(task?.due_at),
evidence_file_id: task?.evidence_file_id ?? undefined,
evidence_note: task?.evidence_note ?? undefined,
});
setLifecycleTaskModalOpen(true);
};
const handleSaveLifecycleTask = async () => {
if (!detail) {
return;
}
const values = await lifecycleTaskForm.validateFields();
setLifecycleTaskSaving(true);
try {
let evidenceFileId = values.evidence_file_id ?? "";
const uploadCandidate = evidenceUploadList[0]?.originFileObj as File | undefined;
if (uploadCandidate) {
const uploadResult = await uploadFile({
file: uploadCandidate,
file_type: "contract_lifecycle_evidence",
bucket: "contract-lifecycle",
});
if (uploadResult.code !== 0) {
messageApi.error(uploadResult.message || "证据文件上传失败");
return;
}
evidenceFileId = uploadResult.data.id;
}
if (editingLifecycleTask) {
const result = await updateContractLifecycleTask(detail.id, editingLifecycleTask.id, {
task_type: values.task_type,
title: values.title,
description: values.description ?? "",
priority: values.priority,
status_value: values.status_value,
due_at: values.due_at || null,
evidence_file_id: evidenceFileId,
evidence_note: values.evidence_note ?? "",
});
if (result.code !== 0) {
messageApi.error(result.message || "履约任务更新失败");
return;
}
messageApi.success("履约任务已更新");
} else {
const result = await createContractLifecycleTask(detail.id, {
task_type: values.task_type,
title: values.title,
description: values.description ?? "",
priority: values.priority,
status_value: values.status_value,
due_at: values.due_at || null,
evidence_file_id: evidenceFileId,
evidence_note: values.evidence_note ?? "",
});
if (result.code !== 0) {
messageApi.error(result.message || "履约任务创建失败");
return;
}
messageApi.success("履约任务已创建");
}
setLifecycleTaskModalOpen(false);
await reloadLifecycleTasks(detail.id);
} catch (error: unknown) {
messageApi.error(error instanceof Error ? error.message : "履约任务保存失败,请稍后重试");
} finally {
setLifecycleTaskSaving(false);
}
};
if (loading) {
return (
<main className="page-shell review-detail-page contract-detail-page">
@@ -567,6 +803,105 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
)}
</Card>
<Card
className="review-surface-card"
title="履约任务与归档证据"
extra={
<Space wrap>
<Button loading={lifecycleTaskSyncing} onClick={() => void handleSyncLifecycleTasks()}>
同步续约提醒
</Button>
<Button onClick={() => handleOpenLifecycleTaskModal(null, "performance_milestone")}>
新增履约节点
</Button>
<Button onClick={() => handleOpenLifecycleTaskModal(null, "termination_archive")}>
新增终止归档
</Button>
</Space>
}
>
<Table
rowKey="id"
loading={lifecycleTaskLoading}
dataSource={lifecycleTasks}
pagination={false}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无履约任务。可先同步续约提醒或新增履约节点。" /> }}
columns={[
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 120,
render: (value: string) => <Tag color={getLifecycleTaskTypeColor(value)}>{formatLifecycleTaskType(value)}</Tag>,
},
{
title: "任务",
dataIndex: "title",
key: "title",
render: (_value: string, record: ContractLifecycleTask) => (
<Space orientation="vertical" size={2}>
<Text strong>{record.title}</Text>
{record.description ? <Text type="secondary">{record.description}</Text> : null}
</Space>
),
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 110,
render: (value: string) => <Tag color={getLifecycleTaskStatusColor(value)}>{formatLifecycleTaskStatus(value)}</Tag>,
},
{
title: "优先级",
dataIndex: "priority",
key: "priority",
width: 100,
render: (value: string) => <Tag color={getLifecycleTaskPriorityColor(value)}>{formatLifecycleTaskPriority(value)}</Tag>,
},
{
title: "截止时间",
dataIndex: "due_at",
key: "due_at",
width: 180,
render: (_value: string | null, record: ContractLifecycleTask) => (
<Space orientation="vertical" size={2}>
<span>{formatDateTime(record.due_at)}</span>
<Tag color={getDueStatusColor(record.due_status)}>
{dueStatusMeta[record.due_status]?.label ?? record.due_status}
{typeof record.days_to_due === "number" && record.due_status !== "closed" ? `(${record.days_to_due}天)` : ""}
</Tag>
</Space>
),
},
{
title: "证据",
dataIndex: "evidence_file",
key: "evidence_file",
width: 220,
render: (_value: unknown, record: ContractLifecycleTask) => record.evidence_file ? (
<Space orientation="vertical" size={2}>
<a href={record.evidence_file.download_url ?? "#"}>{record.evidence_file.file_name}</a>
{record.evidence_note ? <Text type="secondary">{record.evidence_note}</Text> : null}
</Space>
) : (
<Text type="secondary">未绑定</Text>
),
},
{
title: "操作",
key: "actions",
width: 120,
render: (_value: unknown, record: ContractLifecycleTask) => (
<Button size="small" onClick={() => handleOpenLifecycleTaskModal(record)}>
编辑
</Button>
),
},
]}
/>
</Card>
<Card className="review-surface-card" loading={loading} title="基本信息">
{detail ? (
<Descriptions column={2} bordered>
@@ -720,6 +1055,56 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
</Form.Item>
</Form>
</Modal>
<Modal
title={editingLifecycleTask ? "编辑履约任务" : "新增履约任务"}
open={lifecycleTaskModalOpen}
okText="保存任务"
cancelText="取消"
confirmLoading={lifecycleTaskSaving}
onOk={() => void handleSaveLifecycleTask()}
onCancel={() => setLifecycleTaskModalOpen(false)}
>
<Form
form={lifecycleTaskForm}
layout="vertical"
initialValues={{ task_type: "performance_milestone", priority: "medium", status_value: "open" }}
>
<Form.Item label="任务类型" name="task_type" rules={[{ required: true, message: "请选择任务类型" }]}>
<Select options={lifecycleTaskTypeOptions} />
</Form.Item>
<Form.Item label="任务标题" name="title" rules={[{ required: true, message: "请输入任务标题" }]}>
<Input placeholder="例如:验收资料收齐、续约确认、终止归档" maxLength={200} />
</Form.Item>
<Form.Item label="优先级" name="priority" rules={[{ required: true, message: "请选择优先级" }]}>
<Select options={lifecycleTaskPriorityOptions} />
</Form.Item>
<Form.Item label="任务状态" name="status_value" rules={[{ required: true, message: "请选择任务状态" }]}>
<Select options={lifecycleTaskStatusOptions} />
</Form.Item>
<Form.Item label="截止时间" name="due_at">
<Input type="datetime-local" />
</Form.Item>
<Form.Item label="任务说明" name="description">
<TextArea rows={3} placeholder="记录履约节点、续约判断依据或终止归档要求" maxLength={4000} showCount />
</Form.Item>
<Form.Item label="证据文件ID" name="evidence_file_id">
<Input placeholder="可填写文件中心已有文件ID,或直接上传下方证据文件" maxLength={64} />
</Form.Item>
<Form.Item label="上传证据文件">
<Upload
beforeUpload={() => false}
maxCount={1}
fileList={evidenceUploadList}
onChange={({ fileList }) => setEvidenceUploadList(fileList.slice(-1))}
>
<Button>选择证据文件</Button>
</Upload>
</Form.Item>
<Form.Item label="证据说明" name="evidence_note">
<TextArea rows={3} placeholder="例如:双方终止确认书、验收单、续约邮件纪要" maxLength={2000} showCount />
</Form.Item>
</Form>
</Modal>
</Space>
</main>
);
+107
View File
@@ -168,6 +168,49 @@ export interface ContractLifecycle {
updated_at?: string | null;
}
export interface ContractLifecycleTaskEvidenceFile {
id: string;
file_name: string;
file_type?: string | null;
mime_type?: string | null;
file_size?: number | null;
download_url?: string | null;
created_at?: string | null;
}
export interface ContractLifecycleTask {
id: string;
tenant_id?: string;
contract_id: string;
task_key?: string;
task_type: string;
title: string;
description?: string | null;
priority: string;
status: string;
due_at?: string | null;
due_status: string;
days_to_due?: number | null;
completed_by?: string | null;
completed_at?: string | null;
evidence_file_id?: string | null;
evidence_file?: ContractLifecycleTaskEvidenceFile | null;
evidence_note?: string | null;
metadata?: Record<string, unknown>;
created_by?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
export interface ContractLifecycleTaskListResponse {
contract_id: string;
items: ContractLifecycleTask[];
total: number;
summary: Record<string, number>;
synced?: boolean;
reason?: string;
}
export interface GeneratedContractPreview {
id: string;
template_name: string;
@@ -260,6 +303,28 @@ export interface UpdateGeneratedContractLifecycleRequest {
lifecycle_note?: string | null;
}
export interface CreateContractLifecycleTaskRequest {
task_type: string;
title: string;
description?: string | null;
priority?: string;
status_value?: string;
due_at?: string | null;
evidence_file_id?: string | null;
evidence_note?: string | null;
}
export interface UpdateContractLifecycleTaskRequest {
task_type?: string | null;
title?: string | null;
description?: string | null;
priority?: string | null;
status_value?: string | null;
due_at?: string | null;
evidence_file_id?: string | null;
evidence_note?: string | null;
}
/**
* 获取可用的合同模板列表
*/
@@ -357,6 +422,48 @@ export async function updateGeneratedContractLifecycle(
return response.data;
}
export async function listContractLifecycleTasks(
contract_id: string,
params: { status_value?: string; task_type?: string } = {},
): Promise<ApiResponse<ContractLifecycleTaskListResponse>> {
const searchParams = new URLSearchParams();
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/${contract_id}/lifecycle/tasks${queryString ? `?${queryString}` : ""}`,
);
return response.data;
}
export async function syncContractLifecycleTasks(
contract_id: string,
): Promise<ApiResponse<ContractLifecycleTaskListResponse>> {
const response = await http.post<ApiResponse<ContractLifecycleTaskListResponse>>(`/contracts/${contract_id}/lifecycle/tasks/sync`);
return response.data;
}
export async function createContractLifecycleTask(
contract_id: string,
payload: CreateContractLifecycleTaskRequest,
): Promise<ApiResponse<ContractLifecycleTask>> {
const response = await http.post<ApiResponse<ContractLifecycleTask>>(`/contracts/${contract_id}/lifecycle/tasks`, payload);
return response.data;
}
export async function updateContractLifecycleTask(
contract_id: string,
task_id: string,
payload: UpdateContractLifecycleTaskRequest,
): Promise<ApiResponse<ContractLifecycleTask>> {
const response = await http.put<ApiResponse<ContractLifecycleTask>>(`/contracts/${contract_id}/lifecycle/tasks/${task_id}`, payload);
return response.data;
}
/**
* 生成合同
*/
@@ -3985,3 +3985,35 @@
- 当前闭环结论:
- 合同生命周期/履约跟踪已完成本地编码、本地测试、Git 推送、远端 Alembic 迁移、远端容器部署、远端 HTTP 烟测和真实 PostgreSQL API 烟测。
- 当前剩余不是功能阻塞,而是后续增强:续约提醒任务、履约节点清单、合同终止归档证据、电子签名第三方接入。
## 2026-05-21 合同履约任务、续约提醒和归档证据闭环
- 本轮继续做不依赖外网的履约增强,不接第三方电子签名、不依赖公网回调。
- 后端能力:
- 新增 Alembic 迁移 `20260521_0037_create_contract_lifecycle_tasks.py`。
- 新增 `contract_lifecycle_tasks` 表,用一张轻量任务表承载续约提醒、履约节点、终止归档和自定义任务。
- 新增任务字段:任务类型、标题、说明、优先级、状态、截止时间、完成人、完成时间、证据文件、证据说明和元数据。
- 新增 `GET /api/v1/contracts/{contract_id}/lifecycle/tasks` 查询合同履约任务。
- 新增 `POST /api/v1/contracts/{contract_id}/lifecycle/tasks/sync`,按合同到期日自动生成或更新续约提醒。
- 新增 `POST /api/v1/contracts/{contract_id}/lifecycle/tasks` 手工新增履约节点或终止归档任务。
- 新增 `PUT /api/v1/contracts/{contract_id}/lifecycle/tasks/{task_id}` 更新任务状态、优先级、截止时间和证据。
- 后端校验任务枚举、合同租户/创建人权限、证据文件归属和文件状态。
- 同步、新建和更新动作分别写入审计日志 `contract_lifecycle_task_sync`、`contract_lifecycle_task_create`、`contract_lifecycle_task_update`。
- 前端能力:
- `/contracts/[id]` 合同详情页新增“履约任务与归档证据”卡片。
- 支持一键同步续约提醒。
- 支持新增履约节点和终止归档任务。
- 支持编辑任务类型、标题、说明、优先级、状态、截止时间。
- 支持上传证据文件到文件中心 `contract-lifecycle`,并自动绑定到履约任务。
- 支持使用已有文件 ID 绑定证据,任务列表可展示证据文件和下载入口。
- 本轮验证:
- `$env:PYTHONPATH='backend'; python -m compileall backend/app/models/contract_lifecycle_task.py backend/app/repositories/contract_lifecycle_task_repository.py backend/app/services/contract_generation_service.py backend/app/api/contracts.py`
- `python -m py_compile backend\migrations\versions\20260521_0037_create_contract_lifecycle_tasks.py`
- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_contracts_api backend.tests.test_permission_metadata -v`
- `cd frontend; npm run lint`
- `cd frontend; npm run build`
- Browser 打开 `http://localhost:3000/contracts`,未登录状态按预期进入登录页,浏览器控制台无 error。
- `git diff --check`
- 当前闭环结论:
- 已形成“合同到期日 -> 同步续约提醒 -> 新增履约节点 -> 完成/归档任务 -> 绑定证据文件 -> 审计留痕”的本地闭环。
- 该能力可以部署到 `192.168.3.90` 做内网真实库烟测;远端需要先执行 Alembic `20260521_0037` 并部署前后端。