feat: add contract lifecycle tasks
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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` 并部署前后端。
|
||||
|
||||
Reference in New Issue
Block a user