From 8c8fc432bb0c8a3fbb2881d3149107d9b89982d5 Mon Sep 17 00:00:00 2001 From: yuqei Date: Thu, 21 May 2026 17:08:47 +0800 Subject: [PATCH] feat: add contract lifecycle tasks --- backend/app/api/contracts.py | 155 +++++++ backend/app/models/__init__.py | 1 + backend/app/models/contract_lifecycle_task.py | 40 ++ .../contract_lifecycle_task_repository.py | 64 +++ .../services/contract_generation_service.py | 403 +++++++++++++++++- ...21_0037_create_contract_lifecycle_tasks.py | 68 +++ backend/tests/test_contracts_api.py | 294 +++++++++++++ frontend/src/app/contracts/[id]/page.tsx | 387 ++++++++++++++++- frontend/src/services/contract.ts | 107 +++++ ...µ·草与审查助手-当前工作进度保存-2026-05-11.md | 32 ++ 10 files changed, 1549 insertions(+), 2 deletions(-) create mode 100644 backend/app/models/contract_lifecycle_task.py create mode 100644 backend/app/repositories/contract_lifecycle_task_repository.py create mode 100644 backend/migrations/versions/20260521_0037_create_contract_lifecycle_tasks.py diff --git a/backend/app/api/contracts.py b/backend/app/api/contracts.py index 1df3239d..41a199e1 100644 --- a/backend/app/api/contracts.py +++ b/backend/app/api/contracts.py @@ -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, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a3bd6331..c3b2c434 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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 diff --git a/backend/app/models/contract_lifecycle_task.py b/backend/app/models/contract_lifecycle_task.py new file mode 100644 index 00000000..7f347ed0 --- /dev/null +++ b/backend/app/models/contract_lifecycle_task.py @@ -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), + ) diff --git a/backend/app/repositories/contract_lifecycle_task_repository.py b/backend/app/repositories/contract_lifecycle_task_repository.py new file mode 100644 index 00000000..d9d3c426 --- /dev/null +++ b/backend/app/repositories/contract_lifecycle_task_repository.py @@ -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() diff --git a/backend/app/services/contract_generation_service.py b/backend/app/services/contract_generation_service.py index ffcc7434..52b06759 100644 --- a/backend/app/services/contract_generation_service.py +++ b/backend/app/services/contract_generation_service.py @@ -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) diff --git a/backend/migrations/versions/20260521_0037_create_contract_lifecycle_tasks.py b/backend/migrations/versions/20260521_0037_create_contract_lifecycle_tasks.py new file mode 100644 index 00000000..a4d22da8 --- /dev/null +++ b/backend/migrations/versions/20260521_0037_create_contract_lifecycle_tasks.py @@ -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") diff --git a/backend/tests/test_contracts_api.py b/backend/tests/test_contracts_api.py index a49fa2c9..fbf016e8 100644 --- a/backend/tests/test_contracts_api.py +++ b/backend/tests/test_contracts_api.py @@ -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( diff --git a/frontend/src/app/contracts/[id]/page.tsx b/frontend/src/app/contracts/[id]/page.tsx index 5a65b255..d6f842e9 100644 --- a/frontend/src/app/contracts/[id]/page.tsx +++ b/frontend/src/app/contracts/[id]/page.tsx @@ -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 = { closed: { label: "已关闭", color: "default" }, }; +const lifecycleTaskTypeOptions = [ + { value: "renewal_reminder", label: "续约提醒" }, + { value: "performance_milestone", label: "履约节点" }, + { value: "termination_archive", label: "终止归档" }, + { value: "custom", label: "自定义任务" }, +]; + +const lifecycleTaskTypeMeta: Record = { + 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 = { + 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 = { + 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(); + const [lifecycleTaskForm] = Form.useForm(); const router = useRouter(); const currentUser = useAuthStore((state) => state.currentUser); const [loading, setLoading] = useState(true); const [downloading, setDownloading] = useState(false); const [detail, setDetail] = useState(null); const [preview, setPreview] = useState(null); + const [lifecycleTasks, setLifecycleTasks] = useState([]); + const [lifecycleTaskLoading, setLifecycleTaskLoading] = useState(false); + const [lifecycleTaskModalOpen, setLifecycleTaskModalOpen] = useState(false); + const [lifecycleTaskSaving, setLifecycleTaskSaving] = useState(false); + const [lifecycleTaskSyncing, setLifecycleTaskSyncing] = useState(false); + const [editingLifecycleTask, setEditingLifecycleTask] = useState(null); + const [evidenceUploadList, setEvidenceUploadList] = useState([]); const [notFound, setNotFound] = useState(false); const [loadError, setLoadError] = useState(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 (
@@ -567,6 +803,105 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis )} + + + + + + } + > + }} + columns={[ + { + title: "类型", + dataIndex: "task_type", + key: "task_type", + width: 120, + render: (value: string) => {formatLifecycleTaskType(value)}, + }, + { + title: "任务", + dataIndex: "title", + key: "title", + render: (_value: string, record: ContractLifecycleTask) => ( + + {record.title} + {record.description ? {record.description} : null} + + ), + }, + { + title: "状态", + dataIndex: "status", + key: "status", + width: 110, + render: (value: string) => {formatLifecycleTaskStatus(value)}, + }, + { + title: "优先级", + dataIndex: "priority", + key: "priority", + width: 100, + render: (value: string) => {formatLifecycleTaskPriority(value)}, + }, + { + title: "截止时间", + dataIndex: "due_at", + key: "due_at", + width: 180, + render: (_value: string | null, record: ContractLifecycleTask) => ( + + {formatDateTime(record.due_at)} + + {dueStatusMeta[record.due_status]?.label ?? record.due_status} + {typeof record.days_to_due === "number" && record.due_status !== "closed" ? `(${record.days_to_due}天)` : ""} + + + ), + }, + { + title: "证据", + dataIndex: "evidence_file", + key: "evidence_file", + width: 220, + render: (_value: unknown, record: ContractLifecycleTask) => record.evidence_file ? ( + + {record.evidence_file.file_name} + {record.evidence_note ? {record.evidence_note} : null} + + ) : ( + 未绑定 + ), + }, + { + title: "操作", + key: "actions", + width: 120, + render: (_value: unknown, record: ContractLifecycleTask) => ( + + ), + }, + ]} + /> + + {detail ? ( @@ -720,6 +1055,56 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis + void handleSaveLifecycleTask()} + onCancel={() => setLifecycleTaskModalOpen(false)} + > +
+ + + + + + + + + + +