diff --git a/.gitignore b/.gitignore index 5ef864f1..d287a170 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ frontend/node_modules/ *.pyc __pycache__/ .DS_Store +._* backend/.pytest_cache/ # Local secrets and generated artifacts diff --git a/AGENTS.md b/AGENTS.md index ebb2e21d..5cd49ed1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,23 +1,8 @@ -## gstack (REQUIRED — global install) +## AI-assisted work -**Before doing ANY work, verify gstack is installed:** +This repo no longer uses gstack. -```bash -test -d ~/.Codex/skills/gstack/bin && echo "GSTACK_OK" || echo "GSTACK_MISSING" -``` - -If GSTACK_MISSING: STOP. Do not proceed. Tell the user: - -> gstack is required for all AI-assisted work in this repo. -> Install it: -> ```bash -> git clone --depth 1 https://github.com/garrytan/gstack.git ~/.Codex/skills/gstack -> cd ~/.Codex/skills/gstack && ./setup --team -> ``` -> Then restart your AI coding tool. - -Do not skip skills, ignore gstack errors, or work around missing gstack. - -Using gstack skills: After install, skills like /qa, /ship, /review, /investigate, -and /browse are available. Use /browse for all web browsing. -Use ~/.Codex/skills/gstack/... for gstack file paths (the global path). +- Do not require, install, or verify `~/.Codex/skills/gstack` before working. +- Do not block AI-assisted work when gstack is missing. +- Use the current AI coding tool's built-in capabilities and the repository's scripts directly. +- For web browsing, use the available browser or web tools directly; do not require `/browse`. diff --git a/CLAUDE.md b/CLAUDE.md index 02e14446..712d6e3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,23 +1,8 @@ -## gstack (REQUIRED — global install) +## AI-assisted work -**Before doing ANY work, verify gstack is installed:** +This repo no longer uses gstack. -```bash -test -d ~/.claude/skills/gstack/bin && echo "GSTACK_OK" || echo "GSTACK_MISSING" -``` - -If GSTACK_MISSING: STOP. Do not proceed. Tell the user: - -> gstack is required for all AI-assisted work in this repo. -> Install it: -> ```bash -> git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack -> cd ~/.claude/skills/gstack && ./setup --team -> ``` -> Then restart your AI coding tool. - -Do not skip skills, ignore gstack errors, or work around missing gstack. - -Using gstack skills: After install, skills like /qa, /ship, /review, /investigate, -and /browse are available. Use /browse for all web browsing. -Use ~/.claude/skills/gstack/... for gstack file paths (the global path). +- Do not require, install, or verify `~/.claude/skills/gstack` before working. +- Do not block AI-assisted work when gstack is missing. +- Use the current AI coding tool's built-in capabilities and the repository's scripts directly. +- For web browsing, use the available browser or web tools directly; do not require `/browse`. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..d466f601 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,7 @@ +._* +.DS_Store +__pycache__/ +*.pyc +.pytest_cache/ +storage/ +tmp_migration_check.db diff --git a/backend/Dockerfile b/backend/Dockerfile index 3c60c766..941b5ea1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -20,6 +20,7 @@ RUN set -eux; \ rm -rf /var/lib/apt/lists/* COPY requirements.txt ./ +COPY vendor/shared-ai-platform-sdk ./vendor/shared-ai-platform-sdk RUN pip install --no-cache-dir -r requirements.txt COPY . . diff --git a/backend/app/api/contract_approvals.py b/backend/app/api/contract_approvals.py new file mode 100644 index 00000000..12be354e --- /dev/null +++ b/backend/app/api/contract_approvals.py @@ -0,0 +1,138 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Header, Request +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.response import success_response +from app.db import get_db_session +from app.services.audit_log_service import AuditLogService +from app.services.auth_service import AuthService +from app.services.contract_approval_service import ContractApprovalService + +router = APIRouter(prefix="/contract-approvals", tags=["contract-approvals"]) + + +class ContractApprovalSubmitRequest(BaseModel): + contract_id: str = Field(..., min_length=1, max_length=64) + title: Optional[str] = Field(default=None, max_length=200) + comment: Optional[str] = Field(default=None, max_length=2000) + assignee_id: Optional[str] = Field(default=None, max_length=64) + assignee_name: Optional[str] = Field(default=None, max_length=100) + step_type: str = Field(default="legal_review", max_length=64) + + +class ContractApprovalDecisionRequest(BaseModel): + decision: str = Field(..., min_length=1, max_length=32) + comment: Optional[str] = Field(default=None, max_length=2000) + + +def get_contract_approval_service(db: Session = Depends(get_db_session)) -> ContractApprovalService: + return ContractApprovalService(db) + + +def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService: + return AuditLogService(db) + + +def require_current_user( + authorization: Optional[str] = Header(default=None), + db: Session = Depends(get_db_session), +): + return AuthService(db).require_current_user(authorization) + + +@router.get("") +def list_contract_approvals( + page: int = 1, + page_size: int = 20, + status_value: Optional[str] = None, + scope: str = "assigned_to_me", + contract_approval_service: ContractApprovalService = Depends(get_contract_approval_service), + current_user: dict = Depends(require_current_user), +): + return success_response( + contract_approval_service.list_approvals( + current_user=current_user, + page=page, + page_size=page_size, + status_value=status_value, + scope=scope, + ) + ) + + +@router.get("/contracts/{contract_id}") +def list_contract_approval_history( + contract_id: str, + contract_approval_service: ContractApprovalService = Depends(get_contract_approval_service), + current_user: dict = Depends(require_current_user), +): + return success_response(contract_approval_service.list_contract_approvals(current_user, contract_id)) + + +@router.post("") +def submit_contract_approval( + payload: ContractApprovalSubmitRequest, + request: Request, + contract_approval_service: ContractApprovalService = Depends(get_contract_approval_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = contract_approval_service.submit_approval( + current_user=current_user, + contract_id=payload.contract_id, + title=payload.title, + comment=payload.comment, + assignee_id=payload.assignee_id, + assignee_name=payload.assignee_name, + step_type=payload.step_type, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="contract_approval_submit", + resource_type="contract_approval", + resource_id=result.get("id"), + detail_json={ + "contract_id": result.get("contract_id"), + "step_type": result.get("step_type"), + "assignee_id": result.get("assignee_id"), + "status": result.get("status"), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/{approval_id}/decision") +def decide_contract_approval( + approval_id: str, + payload: ContractApprovalDecisionRequest, + request: Request, + contract_approval_service: ContractApprovalService = Depends(get_contract_approval_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = contract_approval_service.decide_approval( + current_user=current_user, + approval_id=approval_id, + decision=payload.decision, + comment=payload.comment, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action=f"contract_approval_{result.get('status')}", + resource_type="contract_approval", + resource_id=result.get("id"), + detail_json={ + "contract_id": result.get("contract_id"), + "decision": payload.decision, + "status": result.get("status"), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) diff --git a/backend/app/main.py b/backend/app/main.py index 6cb99b6d..f66ffee3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.audit_logs import router as audit_logs_router from app.api.auth import router as auth_router from app.api.chat import router as chat_router +from app.api.contract_approvals import router as contract_approvals_router from app.api.clauses import router as clauses_router from app.api.contracts import router as contracts_router from app.api.conversations import router as conversations_router @@ -46,6 +47,7 @@ app.include_router(auth_router, prefix=settings.api_v1_prefix) app.include_router(reviews_router, prefix=settings.api_v1_prefix) app.include_router(chat_router, prefix=settings.api_v1_prefix) app.include_router(clauses_router, prefix=settings.api_v1_prefix) +app.include_router(contract_approvals_router, prefix=settings.api_v1_prefix) app.include_router(contracts_router, prefix=settings.api_v1_prefix) app.include_router(conversations_router, prefix=settings.api_v1_prefix) app.include_router(files_router, prefix=settings.api_v1_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e7f4b3ff..f8e94507 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,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_approval_step import ContractApprovalStep from app.models.contract_lifecycle_task import ContractLifecycleTask from app.models.file import File from app.models.generated_contract import GeneratedContract diff --git a/backend/app/models/contract_approval_step.py b/backend/app/models/contract_approval_step.py new file mode 100644 index 00000000..36f7f647 --- /dev/null +++ b/backend/app/models/contract_approval_step.py @@ -0,0 +1,38 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import DateTime, ForeignKey, Index, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class ContractApprovalStep(Base): + __tablename__ = "contract_approval_steps" + __table_args__ = ( + Index("ix_contract_approval_steps_tenant_contract", "tenant_id", "contract_id"), + Index("ix_contract_approval_steps_tenant_status", "tenant_id", "status"), + Index("ix_contract_approval_steps_tenant_assignee", "tenant_id", "assignee_id"), + ) + + 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) + step_type: Mapped[str] = mapped_column(String(64), nullable=False, default="legal_review", index=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", index=True) + title: Mapped[str] = mapped_column(String(200), nullable=False) + comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + submitter_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + submitter_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + assignee_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + assignee_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + decided_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + decided_by_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + decided_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + metadata_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, 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_approval_step_repository.py b/backend/app/repositories/contract_approval_step_repository.py new file mode 100644 index 00000000..9d7cf64a --- /dev/null +++ b/backend/app/repositories/contract_approval_step_repository.py @@ -0,0 +1,113 @@ +from typing import Optional + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.models.contract_approval_step import ContractApprovalStep + + +class ContractApprovalStepRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, step: ContractApprovalStep) -> ContractApprovalStep: + self.db.add(step) + self.db.commit() + self.db.refresh(step) + return step + + def update(self, step: ContractApprovalStep) -> ContractApprovalStep: + self.db.add(step) + self.db.commit() + self.db.refresh(step) + return step + + def get_by_id(self, tenant_id: str, step_id: str) -> Optional[ContractApprovalStep]: + return ( + self.db.query(ContractApprovalStep) + .filter( + ContractApprovalStep.tenant_id == tenant_id, + ContractApprovalStep.id == step_id, + ) + .first() + ) + + def get_latest_by_contract(self, tenant_id: str, contract_id: str) -> Optional[ContractApprovalStep]: + return ( + self.db.query(ContractApprovalStep) + .filter( + ContractApprovalStep.tenant_id == tenant_id, + ContractApprovalStep.contract_id == contract_id, + ) + .order_by(ContractApprovalStep.created_at.desc()) + .first() + ) + + def get_pending_by_contract(self, tenant_id: str, contract_id: str) -> Optional[ContractApprovalStep]: + return ( + self.db.query(ContractApprovalStep) + .filter( + ContractApprovalStep.tenant_id == tenant_id, + ContractApprovalStep.contract_id == contract_id, + ContractApprovalStep.status == "pending", + ) + .order_by(ContractApprovalStep.created_at.desc()) + .first() + ) + + def list_by_contract(self, tenant_id: str, contract_id: str) -> list[ContractApprovalStep]: + return ( + self.db.query(ContractApprovalStep) + .filter( + ContractApprovalStep.tenant_id == tenant_id, + ContractApprovalStep.contract_id == contract_id, + ) + .order_by(ContractApprovalStep.created_at.desc()) + .all() + ) + + def list_paginated( + self, + tenant_id: str, + page: int, + page_size: int, + *, + status_value: Optional[str] = None, + assignee_id: Optional[str] = None, + submitter_id: Optional[str] = None, + participant_id: Optional[str] = None, + ) -> list[ContractApprovalStep]: + query = self.db.query(ContractApprovalStep).filter(ContractApprovalStep.tenant_id == tenant_id) + query = self._apply_filters(query, status_value, assignee_id, submitter_id, participant_id) + return query.order_by(ContractApprovalStep.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + def count( + self, + tenant_id: str, + *, + status_value: Optional[str] = None, + assignee_id: Optional[str] = None, + submitter_id: Optional[str] = None, + participant_id: Optional[str] = None, + ) -> int: + query = self.db.query(ContractApprovalStep).filter(ContractApprovalStep.tenant_id == tenant_id) + query = self._apply_filters(query, status_value, assignee_id, submitter_id, participant_id) + return query.count() + + @staticmethod + def _apply_filters(query, status_value, assignee_id, submitter_id, participant_id): + if status_value: + query = query.filter(ContractApprovalStep.status == status_value) + if assignee_id: + query = query.filter(ContractApprovalStep.assignee_id == assignee_id) + if submitter_id: + query = query.filter(ContractApprovalStep.submitter_id == submitter_id) + if participant_id: + query = query.filter( + or_( + ContractApprovalStep.assignee_id == participant_id, + ContractApprovalStep.submitter_id == participant_id, + ContractApprovalStep.decided_by == participant_id, + ) + ) + return query diff --git a/backend/app/services/contract_approval_service.py b/backend/app/services/contract_approval_service.py new file mode 100644 index 00000000..7a8bc5dd --- /dev/null +++ b/backend/app/services/contract_approval_service.py @@ -0,0 +1,332 @@ +from datetime import datetime, timezone +from typing import Any, Optional +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, ValidationError +from app.models.contract_approval_step import ContractApprovalStep +from app.models.generated_contract import GeneratedContract +from app.repositories.contract_approval_step_repository import ContractApprovalStepRepository +from app.repositories.generated_contract_repository import GeneratedContractRepository + + +APPROVAL_STATUSES = {"pending", "approved", "rejected", "cancelled"} +APPROVAL_STEP_TYPES = {"legal_review", "business_approval", "signoff"} +APPROVAL_LIST_SCOPES = {"all", "assigned_to_me", "submitted_by_me", "participated"} + + +class ContractApprovalService: + def __init__(self, db: Session): + self.db = db + self.contract_repository = GeneratedContractRepository(db) + self.approval_repository = ContractApprovalStepRepository(db) + + def list_approvals( + self, + current_user: dict[str, Any], + *, + page: int = 1, + page_size: int = 20, + status_value: Optional[str] = None, + scope: str = "assigned_to_me", + ) -> dict[str, Any]: + tenant_id = current_user["tenant_id"] + normalized_page = max(int(page or 1), 1) + normalized_page_size = max(min(int(page_size or 20), 100), 1) + normalized_status = self._normalize_optional_status(status_value) + normalized_scope = self._normalize_scope(scope) + user_id = str(current_user.get("id") or "") + role = current_user.get("role") + + assignee_id = None + submitter_id = None + participant_id = None + if normalized_scope == "assigned_to_me": + assignee_id = user_id + elif normalized_scope == "submitted_by_me": + submitter_id = user_id + elif normalized_scope == "participated": + participant_id = user_id + elif normalized_scope == "all" and role != "admin": + participant_id = user_id + + items = self.approval_repository.list_paginated( + tenant_id, + normalized_page, + normalized_page_size, + status_value=normalized_status, + assignee_id=assignee_id, + submitter_id=submitter_id, + participant_id=participant_id, + ) + total = self.approval_repository.count( + tenant_id, + status_value=normalized_status, + assignee_id=assignee_id, + submitter_id=submitter_id, + participant_id=participant_id, + ) + + return { + "items": [self._to_approval_item(item) for item in items], + "total": total, + "page": normalized_page, + "page_size": normalized_page_size, + "summary": self._build_summary(tenant_id, user_id, role), + } + + def list_contract_approvals(self, current_user: dict[str, Any], contract_id: str) -> dict[str, Any]: + contract = self._get_visible_contract(current_user, contract_id) + items = self.approval_repository.list_by_contract(current_user["tenant_id"], contract.id) + return { + "contract_id": contract.id, + "items": [self._to_approval_item(item, contract=contract) for item in items], + "total": len(items), + "latest": self._to_approval_item(items[0], contract=contract) if items else None, + } + + def submit_approval( + self, + current_user: dict[str, Any], + contract_id: str, + *, + title: Optional[str] = None, + comment: Optional[str] = None, + assignee_id: Optional[str] = None, + assignee_name: Optional[str] = None, + step_type: str = "legal_review", + ) -> dict[str, Any]: + contract = self._get_owned_or_admin_contract(current_user, contract_id) + pending = self.approval_repository.get_pending_by_contract(current_user["tenant_id"], contract_id) + if pending is not None: + raise ConflictError("contract approval is already pending") + + normalized_step_type = self._normalize_step_type(step_type) + now = datetime.now(timezone.utc) + step = ContractApprovalStep( + id=f"cap-{uuid4().hex[:12]}", + tenant_id=current_user["tenant_id"], + contract_id=contract.id, + step_type=normalized_step_type, + status="pending", + title=self._approval_title(title, contract), + comment=self._optional_text(comment, max_length=2000), + submitter_id=str(current_user.get("id") or ""), + submitter_name=current_user.get("display_name"), + assignee_id=self._optional_id(assignee_id) or self._default_assignee_id(current_user, contract), + assignee_name=self._optional_text(assignee_name, max_length=100) or self._default_assignee_name(current_user, contract), + metadata_json={ + "source_lifecycle_status": contract.lifecycle_status or contract.status or "generated", + "submitted_from": "contract_detail", + }, + created_at=now, + updated_at=now, + ) + created = self.approval_repository.create(step) + self._update_contract_lifecycle(contract, "internal_review", current_user, f"已提交审批:{created.title}") + return self._to_approval_item(created, contract=contract) + + def decide_approval( + self, + current_user: dict[str, Any], + approval_id: str, + *, + decision: str, + comment: Optional[str] = None, + ) -> dict[str, Any]: + tenant_id = current_user["tenant_id"] + step = self.approval_repository.get_by_id(tenant_id, approval_id) + if step is None: + raise NotFoundError("approval step not found") + if step.status != "pending": + raise ConflictError("approval step has already been decided") + + contract = self._get_visible_contract(current_user, step.contract_id) + if not self._can_decide(current_user, step): + raise ForbiddenError("no permission to decide this approval") + + normalized_decision = self._normalize_decision(decision) + now = datetime.now(timezone.utc) + step.status = "approved" if normalized_decision == "approve" else "rejected" + step.comment = self._optional_text(comment, max_length=2000) or step.comment + step.decided_by = str(current_user.get("id") or "") + step.decided_by_name = current_user.get("display_name") + step.decided_at = now + step.metadata_json = { + **(step.metadata_json or {}), + "decision": normalized_decision, + "decided_from": "approval_workbench", + } + updated = self.approval_repository.update(step) + + if updated.status == "approved": + self._update_contract_lifecycle(contract, "approved", current_user, "审批已通过,可进入签署或归档准备。") + else: + self._update_contract_lifecycle(contract, "generated", current_user, updated.comment or "审批已退回,请修改后重新提交。") + + return self._to_approval_item(updated, contract=contract) + + def _get_visible_contract(self, current_user: dict[str, Any], contract_id: str) -> GeneratedContract: + contract = self.contract_repository.get_by_id(contract_id, current_user["tenant_id"]) + if contract is None: + raise NotFoundError("contract not found") + user_id = str(current_user.get("id") or "") + if current_user.get("role") == "admin" or str(contract.generated_by or "") == user_id: + return contract + latest = self.approval_repository.get_latest_by_contract(current_user["tenant_id"], contract_id) + if latest and user_id in {str(latest.assignee_id or ""), str(latest.submitter_id or ""), str(latest.decided_by or "")}: + return contract + raise NotFoundError("contract not found") + + def _get_owned_or_admin_contract(self, current_user: dict[str, Any], contract_id: str) -> GeneratedContract: + contract = self.contract_repository.get_by_id(contract_id, current_user["tenant_id"]) + if contract is None: + raise NotFoundError("contract not found") + if current_user.get("role") != "admin" and str(contract.generated_by or "") != str(current_user.get("id") or ""): + raise NotFoundError("contract not found") + return contract + + def _update_contract_lifecycle( + self, + contract: GeneratedContract, + lifecycle_status: str, + current_user: dict[str, Any], + note: str, + ) -> None: + contract.lifecycle_status = lifecycle_status + contract.lifecycle_note = note + contract.lifecycle_updated_by = current_user.get("id") + contract.lifecycle_updated_at = datetime.now(timezone.utc) + self.contract_repository.update(contract) + + def _to_approval_item(self, step: ContractApprovalStep, contract: Optional[GeneratedContract] = None) -> dict[str, Any]: + resolved_contract = contract or self.contract_repository.get_by_id(step.contract_id, step.tenant_id) + return { + "id": step.id, + "tenant_id": step.tenant_id, + "contract_id": step.contract_id, + "contract": self._to_contract_summary(resolved_contract), + "step_type": step.step_type, + "status": step.status, + "title": step.title, + "comment": step.comment, + "submitter_id": step.submitter_id, + "submitter_name": step.submitter_name, + "assignee_id": step.assignee_id, + "assignee_name": step.assignee_name, + "decided_by": step.decided_by, + "decided_by_name": step.decided_by_name, + "decided_at": self._serialize_optional_datetime(step.decided_at), + "metadata": step.metadata_json or {}, + "created_at": self._serialize_optional_datetime(step.created_at), + "updated_at": self._serialize_optional_datetime(step.updated_at), + } + + @staticmethod + def _to_contract_summary(contract: Optional[GeneratedContract]) -> Optional[dict[str, Any]]: + if contract is None: + return None + return { + "id": contract.id, + "template_name": contract.template_name, + "contract_type": contract.contract_type, + "document_type_code": contract.document_type_code, + "file_name": contract.file_name, + "generated_by_name": contract.generated_by_name, + "lifecycle_status": contract.lifecycle_status or contract.status or "generated", + "created_at": ContractApprovalService._serialize_optional_datetime(contract.created_at), + } + + def _build_summary(self, tenant_id: str, user_id: str, role: Optional[str]) -> dict[str, int]: + participant_id = None if role == "admin" else user_id + return { + "pending_all": self.approval_repository.count(tenant_id, status_value="pending", participant_id=participant_id), + "assigned_to_me": self.approval_repository.count(tenant_id, status_value="pending", assignee_id=user_id), + "submitted_by_me": self.approval_repository.count(tenant_id, submitter_id=user_id), + "approved": self.approval_repository.count(tenant_id, status_value="approved", participant_id=participant_id), + "rejected": self.approval_repository.count(tenant_id, status_value="rejected", participant_id=participant_id), + } + + @staticmethod + def _can_decide(current_user: dict[str, Any], step: ContractApprovalStep) -> bool: + if current_user.get("role") == "admin": + return True + user_id = str(current_user.get("id") or "") + return bool(step.assignee_id and str(step.assignee_id) == user_id) + + @staticmethod + def _approval_title(title: Optional[str], contract: GeneratedContract) -> str: + normalized = ContractApprovalService._optional_text(title, max_length=200) + return normalized or f"合同审批:{contract.template_name}" + + @staticmethod + def _default_assignee_id(current_user: dict[str, Any], contract: GeneratedContract) -> Optional[str]: + if current_user.get("role") == "admin": + return str(current_user.get("id") or "") + return str(contract.generated_by or current_user.get("id") or "") + + @staticmethod + def _default_assignee_name(current_user: dict[str, Any], contract: GeneratedContract) -> Optional[str]: + if current_user.get("role") == "admin": + return current_user.get("display_name") + return contract.generated_by_name or current_user.get("display_name") + + @staticmethod + def _normalize_optional_status(value: Optional[str]) -> Optional[str]: + if value is None or not str(value).strip(): + return None + normalized = str(value).strip().lower().replace("-", "_") + if normalized not in APPROVAL_STATUSES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid approval status") + return normalized + + @staticmethod + def _normalize_step_type(value: str) -> str: + normalized = str(value or "legal_review").strip().lower().replace("-", "_") + if normalized not in APPROVAL_STEP_TYPES: + raise ValidationError("invalid approval step type") + return normalized + + @staticmethod + def _normalize_scope(value: str) -> str: + normalized = str(value or "assigned_to_me").strip().lower().replace("-", "_") + if normalized not in APPROVAL_LIST_SCOPES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid approval scope") + return normalized + + @staticmethod + def _normalize_decision(value: str) -> str: + normalized = str(value or "").strip().lower().replace("-", "_") + if normalized in {"approve", "approved"}: + return "approve" + if normalized in {"reject", "rejected", "return"}: + return "reject" + raise ValidationError("invalid approval decision") + + @staticmethod + def _optional_id(value: Optional[str]) -> Optional[str]: + if value is None: + return None + normalized = str(value).strip() + return normalized or None + + @staticmethod + def _optional_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 ValidationError("approval field is too long") + return normalized + + @staticmethod + def _serialize_optional_datetime(value: Optional[datetime]) -> Optional[str]: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.isoformat() diff --git a/backend/app/services/contract_generation_service.py b/backend/app/services/contract_generation_service.py index bc43e476..4502b4f8 100644 --- a/backend/app/services/contract_generation_service.py +++ b/backend/app/services/contract_generation_service.py @@ -19,6 +19,7 @@ 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_approval_step_repository import ContractApprovalStepRepository 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 @@ -47,6 +48,7 @@ CONTRACT_LIFECYCLE_STATUSES = { "generated", "internal_review", "external_review", + "approved", "signed", "effective", "expiring", @@ -84,6 +86,7 @@ class ContractGenerationService: self.repository = ContractTemplateRepository(db) self.generated_contract_repository = GeneratedContractRepository(db) self.generated_contract_version_repository = GeneratedContractVersionRepository(db) + self.contract_approval_step_repository = ContractApprovalStepRepository(db) self.contract_lifecycle_task_repository = ContractLifecycleTaskRepository(db) self.file_service = FileService(db) self.legal_article_service = LegalArticleService(db) @@ -1942,6 +1945,7 @@ class ContractGenerationService: "clause_completion_suggestions": clause_suggestions, "auto_review": auto_review, "lifecycle": self._build_contract_lifecycle(generated_contract), + "approval": self._build_contract_approval_summary(generated_contract), "metadata": { "generated_by": generated_contract.generated_by, "generated_by_name": generated_contract.generated_by_name, @@ -1994,6 +1998,7 @@ class ContractGenerationService: "clause_completion_suggestions": item.get("clause_completion_suggestions"), "auto_review": item["auto_review"], "lifecycle": item["lifecycle"], + "approval": item.get("approval"), "missing_field_count": item["metadata"].get("missing_field_count", 0), "related_legal_article_count": item["metadata"].get("related_legal_article_count", 0), "metadata": { @@ -2457,6 +2462,42 @@ class ContractGenerationService: "updated_at": self._serialize_optional_datetime(generated_contract.lifecycle_updated_at), } + def _build_contract_approval_summary(self, generated_contract: GeneratedContract) -> Dict[str, Any]: + steps = self.contract_approval_step_repository.list_by_contract( + generated_contract.tenant_id, + generated_contract.id, + ) + latest = steps[0] if steps else None + pending = next((step for step in steps if step.status == "pending"), None) + return { + "status": pending.status if pending else latest.status if latest else "not_submitted", + "latest": self._to_contract_approval_step_summary(latest), + "pending": self._to_contract_approval_step_summary(pending), + "history": [self._to_contract_approval_step_summary(step) for step in steps], + "total": len(steps), + } + + def _to_contract_approval_step_summary(self, step) -> Optional[Dict[str, Any]]: + if step is None: + return None + return { + "id": step.id, + "contract_id": step.contract_id, + "step_type": step.step_type, + "status": step.status, + "title": step.title, + "comment": step.comment, + "submitter_id": step.submitter_id, + "submitter_name": step.submitter_name, + "assignee_id": step.assignee_id, + "assignee_name": step.assignee_name, + "decided_by": step.decided_by, + "decided_by_name": step.decided_by_name, + "decided_at": self._serialize_optional_datetime(step.decided_at), + "created_at": self._serialize_optional_datetime(step.created_at), + "updated_at": self._serialize_optional_datetime(step.updated_at), + } + @staticmethod def _normalize_version_change_type(value: str) -> str: normalized = str(value or "editor_save").strip().lower().replace("-", "_") diff --git a/backend/app/services/contract_lifecycle_service.py b/backend/app/services/contract_lifecycle_service.py index 65181e37..762f4dc2 100644 --- a/backend/app/services/contract_lifecycle_service.py +++ b/backend/app/services/contract_lifecycle_service.py @@ -16,7 +16,7 @@ from app.repositories.contract_lifecycle_task_repository import ContractLifecycl from app.services.file_service import FileService CONTRACT_LIFECYCLE_STATUSES = { - "generated", "internal_review", "external_review", "signed", + "generated", "internal_review", "external_review", "approved", "signed", "effective", "expiring", "expired", "terminated", "archived", } CONTRACT_LIFECYCLE_TASK_TYPES = {"renewal_reminder", "performance_milestone", "termination_archive", "custom"} diff --git a/backend/app/services/review_report_template.py b/backend/app/services/review_report_template.py index e7941af5..3fca5a81 100644 --- a/backend/app/services/review_report_template.py +++ b/backend/app/services/review_report_template.py @@ -51,6 +51,7 @@ def build_report_html( ctx_payment_html = _build_payment_html(contract_structure) ctx_relation_html = _build_relations_html(contract_structure) ctx_risk_items_html = _build_risk_items_html(risk_items) + ctx_business_actions_html = _build_business_actions_html(risk_items, result) ctx_refs_html = _build_legal_refs_html(legal_article_refs) ctx_missing = contract_structure.get("missing_sections") or [] ctx_summary = contract_structure.get("summary") or "" @@ -144,6 +145,26 @@ def build_report_html( }} .opinion-box strong {{ color: #166534; display: block; margin-bottom: 4px; }} + .business-grid {{ + display: grid; grid-template-columns: 1fr 1fr 1fr; + gap: 10px; margin: 14px 0 18px; + }} + .business-card {{ + border: 1px solid #e2e8f0; border-radius: 8px; + padding: 12px 14px; background: #fff; + page-break-inside: avoid; + }} + .business-card span {{ + display: block; font-size: 10px; color: #64748b; + text-transform: uppercase; margin-bottom: 4px; + }} + .business-card strong {{ + display: block; font-size: 13px; color: #0f172a; + margin-bottom: 6px; + }} + .business-card ul {{ margin-left: 16px; color: #475569; font-size: 11px; }} + .business-card li {{ margin-bottom: 4px; }} + /* ── Risk Items ── */ .risk-item {{ border: 1px solid #e2e8f0; border-radius: 8px; @@ -272,6 +293,8 @@ def build_report_html( {_h(review_opinion)} +{ctx_business_actions_html} + {"

缺失关注:" + "、".join(_h(m) for m in ctx_missing) + "

" if ctx_missing else ""} {_h(ctx_summary) if ctx_summary else ""} @@ -351,6 +374,58 @@ def _build_risk_items_html(risk_items: List[Dict[str, Any]]) -> str: return "\n".join(parts) +def _build_business_actions_html(risk_items: List[Dict[str, Any]], result: Dict[str, Any]) -> str: + structured_report = result.get("structured_report") or {} + priority_actions = result.get("priority_actions") or structured_report.get("priority_actions") or [] + negotiation_tips = result.get("negotiation_tips") or structured_report.get("negotiation_tips") or [] + missing_clauses = ((structured_report.get("structure_check") or {}).get("missing_clauses") or []) + + must_fix: List[str] = [] + for item in risk_items: + if item.get("level") in ("critical", "high"): + must_fix.append(item.get("suggestion") or item.get("title") or item.get("description") or "高风险事项需优先处理") + for item in priority_actions: + if item.get("priority") in ("critical", "high"): + must_fix.append(item.get("suggestion") or item.get("title") or "高优先级动作需优先处理") + for item in missing_clauses: + if item.get("importance") == "high": + must_fix.append(f"补充{item.get('name') or '关键条款'}:{item.get('reason') or '降低争议风险'}") + + negotiable: List[str] = [] + for item in risk_items: + if item.get("level") == "medium": + negotiable.append(item.get("suggestion") or item.get("title") or item.get("description") or "中风险事项建议协商") + negotiable.extend(str(item) for item in negotiation_tips) + + must_fix = must_fix[:5] or ["当前未识别到必须立即修改的高风险事项。"] + negotiable = negotiable[:5] or ["当前未识别到单独列出的可协商事项。"] + + return f""" +

业务处理摘要

+
+
+ Must Fix + 必改事项 + +
+
+ Negotiable + 可协商事项 + +
+
+ Delivery + 报告交付 + +
+
+""" + + def _build_clauses_html(clauses: List[Dict[str, Any]]) -> str: if not clauses: return "" diff --git a/backend/migrations/versions/20260618_0044_create_contract_approval_steps.py b/backend/migrations/versions/20260618_0044_create_contract_approval_steps.py new file mode 100644 index 00000000..987463d4 --- /dev/null +++ b/backend/migrations/versions/20260618_0044_create_contract_approval_steps.py @@ -0,0 +1,64 @@ +"""create contract approval steps""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = "20260618_0044" +down_revision = "20260610_0043" +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_approval_steps" in _table_names(inspector): + return + + op.create_table( + "contract_approval_steps", + 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("step_type", sa.String(length=64), nullable=False, server_default="legal_review"), + sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("submitter_id", sa.String(length=64), nullable=False), + sa.Column("submitter_name", sa.String(length=100), nullable=True), + sa.Column("assignee_id", sa.String(length=64), nullable=True), + sa.Column("assignee_name", sa.String(length=100), nullable=True), + sa.Column("decided_by", sa.String(length=64), nullable=True), + sa.Column("decided_by_name", sa.String(length=100), nullable=True), + sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata_json", sa.JSON(), 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.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_contract_approval_steps_assignee_id", "contract_approval_steps", ["assignee_id"]) + op.create_index("ix_contract_approval_steps_contract_id", "contract_approval_steps", ["contract_id"]) + op.create_index("ix_contract_approval_steps_created_at", "contract_approval_steps", ["created_at"]) + op.create_index("ix_contract_approval_steps_status", "contract_approval_steps", ["status"]) + op.create_index("ix_contract_approval_steps_step_type", "contract_approval_steps", ["step_type"]) + op.create_index("ix_contract_approval_steps_submitter_id", "contract_approval_steps", ["submitter_id"]) + op.create_index("ix_contract_approval_steps_tenant_assignee", "contract_approval_steps", ["tenant_id", "assignee_id"]) + op.create_index("ix_contract_approval_steps_tenant_contract", "contract_approval_steps", ["tenant_id", "contract_id"]) + op.create_index("ix_contract_approval_steps_tenant_id", "contract_approval_steps", ["tenant_id"]) + op.create_index("ix_contract_approval_steps_tenant_status", "contract_approval_steps", ["tenant_id", "status"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "contract_approval_steps" not in _table_names(inspector): + return + op.drop_table("contract_approval_steps") diff --git a/backend/requirements.txt b/backend/requirements.txt index 0f734fee..d53762a9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,4 +16,4 @@ email-validator==2.2.0 reportlab==4.4.1 python-docx==1.1.2 pytest==8.3.5 --e ../../shared-ai-python-sdk +-e ./vendor/shared-ai-platform-sdk diff --git a/backend/tests/test_contracts_api.py b/backend/tests/test_contracts_api.py index 3e7b514e..5476935f 100644 --- a/backend/tests/test_contracts_api.py +++ b/backend/tests/test_contracts_api.py @@ -912,6 +912,7 @@ class ContractGenerationServiceTestCase(unittest.TestCase): from app.db import Base from app.models.contract_lifecycle_task import ContractLifecycleTask + from app.models.contract_approval_step import ContractApprovalStep from app.models.contract_template import ContractTemplate from app.models.file import File from app.models.generated_contract import GeneratedContract @@ -927,6 +928,7 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.db = self.SessionLocal() self.ContractTemplate = ContractTemplate self.ContractLifecycleTask = ContractLifecycleTask + self.ContractApprovalStep = ContractApprovalStep self.File = File self.GeneratedContract = GeneratedContract self.GeneratedContractVersion = GeneratedContractVersion @@ -2037,6 +2039,69 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.assertEqual(ctx.exception.status_code, 400) + def test_contract_approval_submit_and_approve_updates_lifecycle(self): + from app.services.contract_approval_service import ContractApprovalService + + now = datetime.now(timezone.utc) + self.db.add( + self.GeneratedContract( + id="contract-approval-001", + tenant_id="tenant-demo-001", + template_id="tpl-approval", + template_name="Approval 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="approval.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=10, + generated_content="Approval contract content.", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + created_at=now, + ) + ) + self.db.commit() + + approval_service = ContractApprovalService(self.db) + submitted = approval_service.submit_approval( + current_user=self.current_user, + contract_id="contract-approval-001", + title="采购合同法务审批", + comment="请重点关注付款和验收条款。", + assignee_id="user-admin", + assignee_name="Admin", + ) + + self.assertEqual(submitted["status"], "pending") + self.assertEqual(submitted["contract"]["lifecycle_status"], "internal_review") + pending_list = approval_service.list_approvals( + current_user=self.current_user, + scope="assigned_to_me", + status_value="pending", + ) + self.assertEqual(pending_list["total"], 1) + self.assertEqual(pending_list["summary"]["assigned_to_me"], 1) + + decided = approval_service.decide_approval( + current_user=self.current_user, + approval_id=submitted["id"], + decision="approve", + comment="审批通过。", + ) + + self.assertEqual(decided["status"], "approved") + contract = self.db.query(self.GeneratedContract).filter_by(id="contract-approval-001").one() + self.assertEqual(contract.lifecycle_status, "approved") + detail = self.service.get_generated_contract(self.current_user, "contract-approval-001") + self.assertEqual(detail["approval"]["status"], "approved") + self.assertEqual(detail["approval"]["latest"]["comment"], "审批通过。") + class ClauseSuggestionRuleTests(unittest.TestCase): def setUp(self): diff --git a/backend/vendor/shared-ai-platform-sdk/README.md b/backend/vendor/shared-ai-platform-sdk/README.md new file mode 100644 index 00000000..53bae316 --- /dev/null +++ b/backend/vendor/shared-ai-platform-sdk/README.md @@ -0,0 +1,40 @@ +# Python SDK + +Example: + +```python +from shared_ai_platform_sdk import RequestContext, SharedAIPlatformClient, SyncRecord + +client = SharedAIPlatformClient( + base_url="http://127.0.0.1:8011", + api_key="your-app-secret", + app_id="saas-web", +) + +context = RequestContext( + tenant_id="tenant-001", + user_id="user-admin", + user_role="admin", +) + +tasks = client.list_tasks(context) +run = client.run_task( + context, + "general.text.summary", + inputs={"text": "Your text here"}, +) + +sync_result = client.sync_content( + context, + "kb-001", + records=[ + SyncRecord( + source_type="product", + source_id="product-001", + title="Smart Water Platform", + content="Structured product content", + metadata={"url": "/products/water"}, + ) + ], +) +``` diff --git a/backend/vendor/shared-ai-platform-sdk/pyproject.toml b/backend/vendor/shared-ai-platform-sdk/pyproject.toml new file mode 100644 index 00000000..1848d807 --- /dev/null +++ b/backend/vendor/shared-ai-platform-sdk/pyproject.toml @@ -0,0 +1,12 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "shared-ai-platform-sdk" +version = "0.1.0" +description = "Python SDK for the shared AI platform" +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.28,<0.29", +] diff --git a/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/__init__.py b/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/__init__.py new file mode 100644 index 00000000..da08436c --- /dev/null +++ b/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/__init__.py @@ -0,0 +1,12 @@ +from shared_ai_platform_sdk.client import SharedAIPlatformClient +from shared_ai_platform_sdk.errors import SharedAIPlatformError +from shared_ai_platform_sdk.types import PollOptions, RequestContext, SSEEvent, SyncRecord + +__all__ = [ + "SharedAIPlatformClient", + "SharedAIPlatformError", + "RequestContext", + "SSEEvent", + "PollOptions", + "SyncRecord", +] diff --git a/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/client.py b/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/client.py new file mode 100644 index 00000000..af84c0a3 --- /dev/null +++ b/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/client.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import json +import time +from typing import Iterator + +import httpx + +from .errors import SharedAIPlatformError +from .types import PollOptions, RequestContext, SSEEvent, SyncRecord + + +class SharedAIPlatformClient: + def __init__( + self, + *, + base_url: str = "http://127.0.0.1:8011", + api_key: str, + app_id: str, + timeout_seconds: float = 30.0, + client: httpx.Client | None = None, + ): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.app_id = app_id + self._owns_client = client is None + self._client = client or httpx.Client(base_url=self.base_url, timeout=timeout_seconds) + + def close(self): + if self._owns_client: + self._client.close() + + def __enter__(self) -> "SharedAIPlatformClient": + return self + + def __exit__(self, exc_type, exc, tb): + self.close() + + def list_tasks(self, context: RequestContext) -> list[dict]: + return self._request_json("GET", "/api/v0.2/tasks", context=context)["data"] + + def run_task( + self, + context: RequestContext, + task_key: str, + *, + inputs: dict, + provider: str | None = None, + metadata: dict | None = None, + ) -> dict: + return self._request_json( + "POST", + f"/api/v0.2/tasks/{task_key}/run", + context=context, + json_body={"inputs": inputs, "provider": provider, "metadata": metadata or {}}, + )["data"] + + def enqueue_task( + self, + context: RequestContext, + task_key: str, + *, + inputs: dict, + provider: str | None = None, + metadata: dict | None = None, + ) -> dict: + return self._request_json( + "POST", + f"/api/v0.2/tasks/{task_key}/enqueue", + context=context, + json_body={"inputs": inputs, "provider": provider, "metadata": metadata or {}}, + )["data"] + + def get_task_run(self, context: RequestContext, run_id: str) -> dict: + return self._request_json("GET", f"/api/v0.2/task-runs/{run_id}", context=context)["data"] + + def list_jobs(self, context: RequestContext, *, status_value: str | None = None, limit: int = 50) -> list[dict]: + params = {"limit": limit} + if status_value: + params["status_value"] = status_value + return self._request_json("GET", "/api/v0.2/jobs", context=context, params=params)["data"] + + def list_conversations(self, context: RequestContext, *, page: int = 1, page_size: int = 20) -> dict: + return self._request_json( + "GET", + "/api/v0.2/conversations", + context=context, + params={"page": page, "page_size": page_size}, + )["data"] + + def get_conversation_detail(self, context: RequestContext, conversation_id: str) -> dict: + return self._request_json("GET", f"/api/v0.2/conversations/{conversation_id}", context=context)["data"] + + def get_job(self, context: RequestContext, job_id: str) -> dict: + return self._request_json("GET", f"/api/v0.2/jobs/{job_id}", context=context)["data"] + + def run_next_job(self, context: RequestContext) -> dict | None: + return self._request_json("POST", "/api/v0.2/jobs/run-next", context=context)["data"]["job"] + + def poll_job_until_terminal(self, context: RequestContext, job_id: str, options: PollOptions | None = None) -> dict: + opts = options or PollOptions() + deadline = time.time() + opts.timeout_seconds + while True: + job = self.get_job(context, job_id) + if job["status"] in opts.terminal_statuses: + return job + if time.time() >= deadline: + raise TimeoutError(f"job {job_id} did not reach a terminal state within {opts.timeout_seconds} seconds") + time.sleep(opts.interval_seconds) + + def list_knowledge_bases(self, context: RequestContext) -> dict: + return self._request_json("GET", "/api/v0.2/knowledge-bases", context=context)["data"] + + def list_documents(self, context: RequestContext, knowledge_base_id: str, *, page: int = 1, page_size: int = 20) -> dict: + return self._request_json( + "GET", + f"/api/v0.2/knowledge-bases/{knowledge_base_id}/documents", + context=context, + params={"page": page, "page_size": page_size}, + )["data"] + + def create_knowledge_base( + self, + context: RequestContext, + *, + name: str, + description: str | None = None, + retrieval_mode: str = "hybrid", + ) -> dict: + return self._request_json( + "POST", + "/api/v0.2/knowledge-bases", + context=context, + json_body={"name": name, "description": description, "retrieval_mode": retrieval_mode}, + )["data"] + + def upload_document( + self, + context: RequestContext, + knowledge_base_id: str, + *, + file_name: str, + mime_type: str | None, + content: bytes, + ) -> dict: + response = self._client.post( + f"/api/v0.2/knowledge-bases/{knowledge_base_id}/documents", + headers=self._headers(context), + files={"file": (file_name, content, mime_type or "application/octet-stream")}, + ) + self._raise_for_status(response) + payload = response.json() + if payload.get("code") not in (0, None): + raise SharedAIPlatformError( + status_code=response.status_code, + message=str(payload.get("message") or "request failed"), + code=payload.get("code"), + data=payload.get("data"), + ) + return payload["data"] + + def retry_document( + self, + context: RequestContext, + knowledge_base_id: str, + document_id: str, + *, + file_name: str | None = None, + mime_type: str | None = None, + content: bytes | None = None, + ) -> dict: + request_kwargs = { + "headers": self._headers(context), + } + if content is not None: + request_kwargs["files"] = {"file": (file_name or "document.txt", content, mime_type or "application/octet-stream")} + response = self._client.post( + f"/api/v0.2/knowledge-bases/{knowledge_base_id}/documents/{document_id}/retry", + **request_kwargs, + ) + self._raise_for_status(response) + payload = response.json() + if payload.get("code") not in (0, None): + raise SharedAIPlatformError( + status_code=response.status_code, + message=str(payload.get("message") or "request failed"), + code=payload.get("code"), + data=payload.get("data"), + ) + return payload["data"] + + def sync_content( + self, + context: RequestContext, + knowledge_base_id: str, + *, + records: list[SyncRecord | dict], + schema_version: str = "content-sync.v1", + sync_mode: str = "incremental", + batch_id: str | None = None, + cursor_token: str | None = None, + ) -> dict: + normalized_records = [ + record.to_dict() if isinstance(record, SyncRecord) else record + for record in records + ] + return self._request_json( + "POST", + f"/api/v0.2/knowledge-bases/{knowledge_base_id}/sync-content", + context=context, + json_body={ + "schema_version": schema_version, + "sync_mode": sync_mode, + "batch_id": batch_id, + "cursor_token": cursor_token, + "records": normalized_records, + }, + )["data"] + + def list_sync_jobs(self, context: RequestContext, knowledge_base_id: str) -> dict: + return self._request_json( + "GET", + f"/api/v0.2/knowledge-bases/{knowledge_base_id}/sync-jobs", + context=context, + )["data"] + + def run_legal_review( + self, + context: RequestContext, + *, + title: str, + text: str, + contract_type: str | None = None, + review_as: str = "party_a", + provider: str | None = None, + ) -> dict: + return self._request_json( + "POST", + "/api/v0.1/domain-packs/legal-review/reviews/run", + context=context, + json_body={ + "title": title, + "contract_type": contract_type, + "review_as": review_as, + "text": text, + "provider": provider, + }, + )["data"] + + def stream_conversation( + self, + context: RequestContext, + *, + message: str, + chat_context: dict | None = None, + provider: str | None = None, + template_key: str | None = None, + conversation_id: str | None = None, + ) -> Iterator[SSEEvent]: + headers = self._headers(context) + with self._client.stream( + "POST", + "/api/v0.2/conversations/stream", + headers=headers, + json={ + "conversation_id": conversation_id, + "message": message, + "chat_context": chat_context or {}, + "provider": provider, + "template_key": template_key, + }, + ) as response: + self._raise_for_status(response) + for line in response.iter_lines(): + if not line: + continue + if isinstance(line, bytes): + line = line.decode("utf-8") + if not line.startswith("data: "): + continue + payload = json.loads(line.removeprefix("data: ").strip()) + yield SSEEvent(event_type=str(payload.get("type") or "unknown"), payload=payload) + + def _request_json( + self, + method: str, + path: str, + *, + context: RequestContext | None = None, + json_body: dict | None = None, + params: dict | None = None, + ) -> dict: + response = self._client.request( + method, + path, + headers=self._headers(context), + json=json_body, + params=params, + ) + self._raise_for_status(response) + payload = response.json() + if payload.get("code") not in (0, None): + raise SharedAIPlatformError( + status_code=response.status_code, + message=str(payload.get("message") or "request failed"), + code=payload.get("code"), + data=payload.get("data"), + ) + return payload + + def _headers(self, context: RequestContext | None = None) -> dict[str, str]: + headers: dict[str, str] = {} + if self.api_key.strip(): + headers["Authorization"] = f"Bearer {self.api_key}" + if context is not None: + headers.update( + { + "X-Shared-AI-App-Id": self.app_id, + "X-Shared-AI-Tenant-Id": context.tenant_id, + "X-Shared-AI-User-Id": context.user_id, + "X-Shared-AI-User-Role": context.user_role, + } + ) + return headers + + def _raise_for_status(self, response: httpx.Response): + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + message = response.text + try: + payload = response.json() + message = str(payload.get("message") or message) + code = payload.get("code") + data = payload.get("data") + except Exception: + code = None + data = None + raise SharedAIPlatformError( + status_code=response.status_code, + message=message, + code=code, + data=data, + ) from exc diff --git a/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/errors.py b/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/errors.py new file mode 100644 index 00000000..7aaecaf7 --- /dev/null +++ b/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/errors.py @@ -0,0 +1,6 @@ +class SharedAIPlatformError(Exception): + def __init__(self, status_code: int, message: str, code: int | None = None, data=None): + super().__init__(message) + self.status_code = status_code + self.code = code + self.data = data diff --git a/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/types.py b/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/types.py new file mode 100644 index 00000000..25933e4e --- /dev/null +++ b/backend/vendor/shared-ai-platform-sdk/shared_ai_platform_sdk/types.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class RequestContext: + tenant_id: str + user_id: str + user_role: str = "admin" + + +@dataclass(frozen=True) +class SSEEvent: + event_type: str + payload: dict[str, Any] + + +@dataclass(frozen=True) +class PollOptions: + interval_seconds: float = 1.0 + timeout_seconds: float = 60.0 + terminal_statuses: tuple[str, ...] = ("succeeded", "failed", "cancelled") + + +@dataclass(frozen=True) +class SyncRecord: + source_type: str + source_id: str + title: str | None = None + content: str | None = None + operation: str = "upsert" + schema_version: str = "content-sync.v1" + source_version: str | None = None + source_updated_at: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "operation": self.operation, + "source_type": self.source_type, + "source_id": self.source_id, + "source_version": self.source_version, + "source_updated_at": self.source_updated_at, + "title": self.title, + "content": self.content, + "metadata": self.metadata, + } diff --git a/docs/AI法律文书助手_合并整理版.md b/docs/archive/2026-06-17-strategy-refresh/AI法律文书助手_合并整理版.md similarity index 100% rename from docs/AI法律文书助手_合并整理版.md rename to docs/archive/2026-06-17-strategy-refresh/AI法律文书助手_合并整理版.md diff --git a/docs/archive/2026-06-17-strategy-refresh/README.md b/docs/archive/2026-06-17-strategy-refresh/README.md new file mode 100644 index 00000000..5a9b635b --- /dev/null +++ b/docs/archive/2026-06-17-strategy-refresh/README.md @@ -0,0 +1,6 @@ +# 2026-06-17 Strategy Refresh Archive + +本目录保存被新规划替代的旧 `docs/` 文档。 + +当前保留为历史资料,不再作为当前执行依据。 + diff --git a/docs/integration-plan.md b/docs/archive/2026-06-17-strategy-refresh/integration-plan.md similarity index 100% rename from docs/integration-plan.md rename to docs/archive/2026-06-17-strategy-refresh/integration-plan.md diff --git a/frontend/src/app/contract-approvals/page.tsx b/frontend/src/app/contract-approvals/page.tsx new file mode 100644 index 00000000..7fc3b8dd --- /dev/null +++ b/frontend/src/app/contract-approvals/page.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { + CheckCircleOutlined, + CloseCircleOutlined, + FileSearchOutlined, + ReloadOutlined, +} from "@ant-design/icons"; +import { Button, Empty, Modal, Select, Space, Table, Tag, Typography, message } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { + decideContractApproval, + listContractApprovals, + type ContractApprovalStep, +} from "@/services/contract"; +import { + formatContractApprovalStatus, + formatContractApprovalStepType, + formatContractType, + formatDateTime, + formatLifecycleStatus, + getContractApprovalStatusColor, + getContractApprovalStepTypeColor, + getLifecycleStatusColor, +} from "@/lib/format"; + +const { Text } = Typography; + +const scopeOptions = [ + { label: "待我处理", value: "assigned_to_me" }, + { label: "我提交的", value: "submitted_by_me" }, + { label: "我参与的", value: "participated" }, + { label: "全部审批", value: "all" }, +]; + +const statusOptions = [ + { label: "全部状态", value: "" }, + { label: "审批中", value: "pending" }, + { label: "已通过", value: "approved" }, + { label: "已退回", value: "rejected" }, +]; + +export default function ContractApprovalsPage() { + const [messageApi, contextHolder] = message.useMessage(); + const [items, setItems] = useState([]); + const [summary, setSummary] = useState>({}); + const [loading, setLoading] = useState(true); + const [scope, setScope] = useState("assigned_to_me"); + const [statusValue, setStatusValue] = useState(""); + const [decidingId, setDecidingId] = useState(null); + + const loadApprovals = useCallback(async () => { + setLoading(true); + try { + const result = await listContractApprovals({ + page: 1, + page_size: 100, + scope, + status_value: statusValue || undefined, + }); + if (result.code !== 0) { + messageApi.error(result.message || "审批工作台加载失败"); + return; + } + setItems(result.data.items); + setSummary(result.data.summary ?? {}); + } catch { + messageApi.error("审批工作台加载失败,请稍后重试"); + } finally { + setLoading(false); + } + }, [messageApi, scope, statusValue]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void loadApprovals(); + }, 0); + return () => window.clearTimeout(timer); + }, [loadApprovals]); + + const metrics = useMemo(() => [ + { label: "待我审批", value: summary.assigned_to_me ?? 0, tone: "warning" }, + { label: "全部待审批", value: summary.pending_all ?? 0, tone: "warning" }, + { label: "我提交的", value: summary.submitted_by_me ?? 0 }, + { label: "已通过", value: summary.approved ?? 0, tone: "success" }, + { label: "已退回", value: summary.rejected ?? 0, tone: "danger" }, + ], [summary]); + + const handleDecision = useCallback((item: ContractApprovalStep, decision: "approve" | "reject") => { + Modal.confirm({ + title: decision === "approve" ? "确认通过审批?" : "确认退回审批?", + content: decision === "approve" ? "通过后合同会进入已审批状态,可继续签署或归档。" : "退回后合同会回到已生成状态,提交人可修改后重新提交。", + okText: decision === "approve" ? "通过" : "退回", + cancelText: "取消", + okButtonProps: { danger: decision === "reject" }, + onOk: async () => { + setDecidingId(item.id); + try { + const result = await decideContractApproval(item.id, { decision }); + if (result.code !== 0) { + messageApi.error(result.message || "审批处理失败"); + return; + } + messageApi.success(decision === "approve" ? "审批已通过" : "审批已退回"); + await loadApprovals(); + } catch { + messageApi.error("审批处理失败,请稍后重试"); + } finally { + setDecidingId(null); + } + }, + }); + }, [loadApprovals, messageApi]); + + const columns = useMemo>(() => [ + { + title: "合同", + key: "contract", + fixed: "left", + width: 280, + render: (_, record) => ( + + + {record.contract?.template_name ?? record.title} + + {record.contract?.file_name ?? "合同文件"} + + ), + }, + { + title: "类型", + key: "type", + width: 150, + render: (_, record) => formatContractType(record.contract?.document_type_code ?? record.contract?.contract_type), + }, + { + title: "审批", + key: "approval", + width: 180, + render: (_, record) => ( + + + {formatContractApprovalStepType(record.step_type)} + + + {formatContractApprovalStatus(record.status)} + + + ), + }, + { + title: "生命周期", + key: "lifecycle", + width: 140, + render: (_, record) => ( + + {formatLifecycleStatus(record.contract?.lifecycle_status)} + + ), + }, + { + title: "提交/处理人", + key: "people", + width: 210, + render: (_, record) => ( + + {record.submitter_name || "提交人未记录"} + 处理人:{record.assignee_name || "-"} + + ), + }, + { + title: "提交时间", + dataIndex: "created_at", + width: 170, + render: (value: string | null) => formatDateTime(value), + sorter: (a, b) => new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime(), + }, + { + title: "操作", + key: "actions", + width: 210, + render: (_, record) => ( + + 详情 + {record.status === "pending" ? ( + <> + + + + ) : null} + + ), + }, + ], [decidingId, handleDecision]); + + return ( +
+ {contextHolder} +
+
+
+ Contract Approval +

合同审批

+

集中处理合同提交审批、通过、退回和后续签署前状态确认。

+
+
+ + + + +
+
+ +
+ {metrics.map((metric) => ( +
+ {metric.label} + {metric.value} +
+ ))} +
+
+ +
+
+
+

审批任务

+

当前显示 {items.length} 条审批记录。

+
+ + + +
+
+ }} + pagination={{ pageSize: 10 }} + rowKey="id" + scroll={{ x: 1330 }} + /> + + + + ); +} diff --git a/frontend/src/app/contract-ledger/page.tsx b/frontend/src/app/contract-ledger/page.tsx new file mode 100644 index 00000000..dd907973 --- /dev/null +++ b/frontend/src/app/contract-ledger/page.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { + FileDoneOutlined, + ReloadOutlined, + SearchOutlined, + WarningOutlined, +} from "@ant-design/icons"; +import { Button, Empty, Input, Space, Table, Tag, Typography, message } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { + formatAutoReviewStatus, + formatContractType, + formatDateTime, + formatDueStatus, + formatLifecycleStatus, + formatRisk, + getAutoReviewStatusColor, + getDueStatusColor, + getLifecycleStatusColor, + getRiskColor, +} from "@/lib/format"; +import { listGeneratedContracts, type GeneratedContractListItem } from "@/services/contract"; + +const { Text } = Typography; + +type LedgerMetric = { + label: string; + value: number; + tone?: "warning" | "danger" | "success"; +}; + +function getLifecycleStatus(item: GeneratedContractListItem) { + return item.lifecycle?.status ?? "generated"; +} + +function getDueStatus(item: GeneratedContractListItem) { + return item.lifecycle?.due_status ?? "not_set"; +} + +function isHighRisk(item: GeneratedContractListItem) { + return item.auto_review?.risk_level === "critical" || item.auto_review?.risk_level === "high"; +} + +function getTimeValue(value: string | null | undefined) { + if (!value) return 0; + const time = new Date(value).getTime(); + return Number.isNaN(time) ? 0 : time; +} + +function getPriorityWeight(item: GeneratedContractListItem) { + const dueStatus = getDueStatus(item); + const lifecycleStatus = getLifecycleStatus(item); + + if (dueStatus === "overdue") return 0; + if (dueStatus === "due_soon") return 1; + if (isHighRisk(item)) return 2; + if (lifecycleStatus === "internal_review" || lifecycleStatus === "external_review") return 3; + return 99; +} + +function getPriorityLabel(item: GeneratedContractListItem) { + const dueStatus = getDueStatus(item); + const lifecycleStatus = getLifecycleStatus(item); + + if (dueStatus === "overdue") return "逾期处理"; + if (dueStatus === "due_soon") return "到期跟进"; + if (isHighRisk(item)) return "高风险预审"; + if (lifecycleStatus === "internal_review" || lifecycleStatus === "external_review") return "审查推进"; + return "合同跟进"; +} + +function getPriorityDescription(item: GeneratedContractListItem) { + const dueStatus = getDueStatus(item); + const lifecycleStatus = getLifecycleStatus(item); + + if (dueStatus === "overdue") return "合同期限已经越过,建议补齐续约、终止或归档动作。"; + if (dueStatus === "due_soon") return "合同即将到期,建议提前确认续约、结算和履约节点。"; + if (isHighRisk(item)) return "自动预审提示高风险,建议先处理关键条款再推进签署。"; + if (lifecycleStatus === "internal_review" || lifecycleStatus === "external_review") return "合同仍处于审查流转中,需要继续跟进修改和确认。"; + return "继续完善合同状态和后续处理记录。"; +} + +function buildMetrics(items: GeneratedContractListItem[]): LedgerMetric[] { + const inReview = items.filter((item) => ["internal_review", "external_review"].includes(getLifecycleStatus(item))).length; + const active = items.filter((item) => ["signed", "effective", "expiring"].includes(getLifecycleStatus(item))).length; + const dueSoon = items.filter((item) => getDueStatus(item) === "due_soon").length; + const overdue = items.filter((item) => getDueStatus(item) === "overdue").length; + const highRisk = items.filter(isHighRisk).length; + + return [ + { label: "已入台账", value: items.length }, + { label: "审查流转中", value: inReview }, + { label: "签署/履约中", value: active, tone: "success" }, + { label: "即将到期", value: dueSoon, tone: "warning" }, + { label: "已逾期", value: overdue, tone: "danger" }, + { label: "高风险预审", value: highRisk, tone: "danger" }, + ]; +} + +function matchesKeyword(item: GeneratedContractListItem, keyword: string) { + const lifecycle = item.lifecycle; + const searchText = [ + item.template_name, + item.file_name, + item.contract_type, + item.metadata?.generated_by_name, + lifecycle?.counterparty_name, + lifecycle?.owner_name, + formatContractType(item.contract_type), + formatLifecycleStatus(lifecycle?.status), + formatDueStatus(lifecycle), + formatAutoReviewStatus(item.auto_review?.status), + formatRisk(item.auto_review?.risk_level), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return searchText.includes(keyword.toLowerCase()); +} + +export default function ContractLedgerPage() { + const [messageApi, contextHolder] = message.useMessage(); + const [contracts, setContracts] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + + const loadContracts = useCallback(async () => { + setLoading(true); + try { + const result = await listGeneratedContracts({ page: 1, page_size: 200 }); + if (result.code !== 0) { + messageApi.error(result.message || "合同台账加载失败"); + return; + } + setContracts(result.data.items); + } catch { + messageApi.error("合同台账加载失败,请稍后重试"); + } finally { + setLoading(false); + } + }, [messageApi]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void loadContracts(); + }, 0); + return () => window.clearTimeout(timer); + }, [loadContracts]); + + const metrics = useMemo(() => buildMetrics(contracts), [contracts]); + const priorityContracts = useMemo(() => { + return contracts + .filter((item) => getPriorityWeight(item) < 99) + .sort((a, b) => { + const weightDiff = getPriorityWeight(a) - getPriorityWeight(b); + if (weightDiff !== 0) return weightDiff; + return getTimeValue(b.lifecycle?.updated_at ?? b.generated_at) - getTimeValue(a.lifecycle?.updated_at ?? a.generated_at); + }) + .slice(0, 6); + }, [contracts]); + const filteredContracts = useMemo(() => { + const keyword = searchQuery.trim(); + if (!keyword) return contracts; + return contracts.filter((item) => matchesKeyword(item, keyword)); + }, [contracts, searchQuery]); + + const columns = useMemo>(() => [ + { + title: "合同", + key: "contract", + fixed: "left", + width: 260, + render: (_, record) => ( + + + {record.template_name} + + {record.file_name} + + ), + sorter: (a, b) => a.template_name.localeCompare(b.template_name, "zh-CN"), + }, + { + title: "类型", + dataIndex: "contract_type", + width: 150, + render: (value: string) => formatContractType(value), + sorter: (a, b) => formatContractType(a.contract_type).localeCompare(formatContractType(b.contract_type), "zh-CN"), + }, + { + title: "相对方/负责人", + key: "owner", + width: 190, + render: (_, record) => ( + + {record.lifecycle?.counterparty_name || "未填写相对方"} + {record.lifecycle?.owner_name || record.metadata?.generated_by_name || "未设置负责人"} + + ), + }, + { + title: "生命周期", + key: "lifecycle", + width: 220, + render: (_, record) => ( + + + {formatLifecycleStatus(record.lifecycle?.status)} + + + {formatDueStatus(record.lifecycle)} + + + ), + }, + { + title: "自动预审", + key: "auto_review", + width: 220, + render: (_, record) => ( + + + {formatAutoReviewStatus(record.auto_review?.status)} + + + {record.auto_review?.risk_level ? formatRisk(record.auto_review.risk_level) : "未评级"} + + + ), + }, + { + title: "最近更新", + key: "updated_at", + width: 180, + render: (_, record) => formatDateTime(record.lifecycle?.updated_at ?? record.generated_at), + sorter: (a, b) => getTimeValue(a.lifecycle?.updated_at ?? a.generated_at) - getTimeValue(b.lifecycle?.updated_at ?? b.generated_at), + }, + { + title: "操作", + key: "actions", + width: 140, + render: (_, record) => ( + + 详情 + {record.auto_review?.review_id ? 审查 : null} + + ), + }, + ], []); + + return ( +
+ {contextHolder} +
+
+
+ Contract Ledger +

合同台账

+

生成后的合同进入统一台账,优先暴露到期、逾期和高风险事项。

+
+
+ + + + +
+
+ +
+ {metrics.map((metric) => ( +
+ {metric.label} + {metric.value} +
+ ))} +
+
+ +
+
+

优先处理

+

聚焦逾期、即将到期、高风险预审和仍在审查流转中的合同。

+
+ {priorityContracts.length === 0 ? ( + + ) : ( +
+ {priorityContracts.map((item) => ( + + {getPriorityLabel(item)} + {item.template_name} + {getPriorityDescription(item)} + + + {formatLifecycleStatus(item.lifecycle?.status)} + + + {formatDueStatus(item.lifecycle)} + + {isHighRisk(item) ? ( + }> + {formatRisk(item.auto_review?.risk_level)} + + ) : null} + + + ))} +
+ )} +
+ +
+
+
+

合同列表

+

共 {contracts.length} 份合同,当前显示 {filteredContracts.length} 份。

+
+ setSearchQuery(event.target.value)} + placeholder="搜索合同、相对方、负责人或状态" + prefix={} + value={searchQuery} + /> +
+ +
+
+ + + + ); +} diff --git a/frontend/src/app/contracts/[id]/page.tsx b/frontend/src/app/contracts/[id]/page.tsx index 2350a33a..ce097d24 100644 --- a/frontend/src/app/contracts/[id]/page.tsx +++ b/frontend/src/app/contracts/[id]/page.tsx @@ -10,6 +10,8 @@ import { dueStatusMeta, formatAutoReviewRisk, formatAutoReviewStatus, + formatContractApprovalStatus, + formatContractApprovalStepType, formatContractType, formatDateTime, formatDueStatus, @@ -21,6 +23,8 @@ import { formatLifecycleTaskType, getAutoReviewRiskColor, getAutoReviewStatusColor, + getContractApprovalStatusColor, + getContractApprovalStepTypeColor, getDueStatusColor, getGenerationModeMeta, getGenerationQualityColor, @@ -30,7 +34,6 @@ import { getLifecycleTaskStatusColor, getLifecycleTaskTypeColor, isLifecycleTerminal, - lifecycleStatusOptions, lifecycleTaskPriorityOptions, lifecycleTaskStatusOptions, lifecycleTaskTypeOptions, @@ -38,18 +41,25 @@ import { } from "@/lib/format"; import { createContractLifecycleTask, + decideContractApproval, + diffGeneratedContractVersions, downloadContract, + downloadGeneratedContractVersion, getGeneratedContract, getGeneratedContractPreview, + listGeneratedContractVersions, listContractLifecycleTasks, + submitContractApproval, syncContractLifecycleTasks, updateContractLifecycleTask, updateGeneratedContractLifecycle, + type ContractApprovalStep, type AutoReviewSummary, - type ContractLifecycle, type ContractLifecycleTask, type GeneratedContract, type GeneratedContractPreview, + type GeneratedContractVersionDiffResponse, + type GeneratedContractVersionItem, } from "@/services/contract"; import { uploadFile } from "@/services/file"; import { downloadReviewReport } from "@/services/review"; @@ -70,11 +80,71 @@ type LifecycleTaskFormValues = { evidence_note?: string; }; +type ApprovalFormValues = { + title?: string; + assignee_name?: string; + comment?: string; +}; + +type ContractVersionRow = { + id: string; + version_number: number; + version_label: string; + change_type: string; + change_summary?: string | null; + created_at?: string | null; + is_base?: boolean; +}; + +const contractVersionChangeTypeLabelMap: Record = { + generated: "原始生成", + editor_save: "手动保存", + editor_autosave: "自动保存", + clause_recommendation: "补条款", + clause_rewrite: "条款改写", + review_suggestion: "审查建议", +}; + +const contractVersionChangeTypeColorMap: Record = { + generated: "blue", + editor_save: "green", + editor_autosave: "cyan", + clause_recommendation: "purple", + clause_rewrite: "geekblue", + review_suggestion: "gold", +}; + +const diffChangeTypeLabelMap: Record = { + added: "新增", + removed: "删除", + modified: "修改", +}; + +const diffChangeTypeColorMap: Record = { + added: "green", + removed: "red", + modified: "gold", +}; + +function formatContractVersionChangeType(value?: string | null) { + if (!value) { + return "版本变更"; + } + return contractVersionChangeTypeLabelMap[value] ?? value; +} + +function getContractVersionChangeTypeColor(value?: string | null) { + if (!value) { + return "default"; + } + return contractVersionChangeTypeColorMap[value] ?? "default"; +} + function getGenerationQualityWarnings(detail?: GeneratedContract | null) { const quality = detail?.generation_quality; const source = detail?.metadata.generation_source; return [ - ...(source?.fallback_used ? [`AI 增强生成已回退:${source.fallback_reason ?? "请重点复核生成内容"}`] : []), + ...(source?.fallback_used ? [`AI 增强起草已回退:${source.fallback_reason ?? "请重点复核合同正文"}`] : []), ...(quality?.blocking_issues ?? []), ...(quality?.warning_issues ?? []), ...(detail?.clause_completion_suggestions ?? []).map((item) => `${item.title}:${item.recommendation}`), @@ -97,10 +167,10 @@ function buildContractReadabilityItems( return [ { label: "交付文件", value: detail?.file_name ?? "-" }, { label: "导出格式", value: detail?.export_format?.toUpperCase() ?? "-" }, - { label: "生成模板", value: detail?.template_name ?? "-" }, - { label: "自动审查", value: formatAutoReviewStatus(autoReview?.status) }, + { label: "起草模板", value: detail?.template_name ?? "-" }, + { label: "自动预审", value: formatAutoReviewStatus(autoReview?.status) }, { label: "总体风险", value: formatAutoReviewRisk(autoReview?.risk_level) }, - { label: "下一步动作", value: autoReview?.next_action ?? autoReview?.status_detail ?? "可进入详情继续审查或问答复核" }, + { label: "下一步动作", value: autoReview?.next_action ?? autoReview?.status_detail ?? "可进入详情继续预审或问答复核" }, ]; } function shouldWarnBeforeDownload(detail?: GeneratedContract | null) { @@ -122,7 +192,7 @@ function buildContractChatHref( reviewId: null, prompt, metadata: { - reviewTitle: detail?.template_name ?? "合同生成结果", + reviewTitle: detail?.template_name ?? "合同起草结果", reviewStatus: detail ? `已生成 ${detail.export_format.toUpperCase()}` : null, contractType: detail?.contract_type ?? null, overallRisk: null, @@ -139,6 +209,7 @@ function buildContractChatHref( export default function GeneratedContractDetailPage({ params }: { params: Promise<{ id: string }> }) { const [messageApi, contextHolder] = message.useMessage(); const [lifecycleTaskForm] = Form.useForm(); + const [approvalForm] = Form.useForm(); const router = useRouter(); const currentUser = useAuthStore((state) => state.currentUser); const [loading, setLoading] = useState(true); @@ -147,9 +218,20 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis const [preview, setPreview] = useState(null); const [lifecycleTasks, setLifecycleTasks] = useState([]); const [lifecycleTaskLoading, setLifecycleTaskLoading] = useState(false); + const [versionLoading, setVersionLoading] = useState(false); + const [versionDiffLoading, setVersionDiffLoading] = useState(false); + const [contractVersions, setContractVersions] = useState([]); + const [baseVersionCreatedAt, setBaseVersionCreatedAt] = useState(null); + const [versionDiff, setVersionDiff] = useState(null); + const [fromVersionId, setFromVersionId] = useState("base"); + const [toVersionId, setToVersionId] = useState("base"); + const [versionDownloadingId, setVersionDownloadingId] = useState(null); const [lifecycleTaskModalOpen, setLifecycleTaskModalOpen] = useState(false); const [lifecycleTaskSaving, setLifecycleTaskSaving] = useState(false); const [lifecycleTaskSyncing, setLifecycleTaskSyncing] = useState(false); + const [approvalModalOpen, setApprovalModalOpen] = useState(false); + const [approvalSubmitting, setApprovalSubmitting] = useState(false); + const [approvalDecidingId, setApprovalDecidingId] = useState(null); const [editingLifecycleTask, setEditingLifecycleTask] = useState(null); const [evidenceUploadList, setEvidenceUploadList] = useState([]); const [notFound, setNotFound] = useState(false); @@ -162,6 +244,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis const loadDetail = async () => { setLoading(true); setLifecycleTaskLoading(true); + setVersionLoading(true); setNotFound(false); setLoadError(null); @@ -181,9 +264,35 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis throw error; } } + let versionItems: GeneratedContractVersionItem[] = []; + let baseCreatedAt: string | null = detailResponse.data.generated_at ?? null; + let initialToVersionId = "base"; + let initialVersionDiff: GeneratedContractVersionDiffResponse | null = null; + try { + const versionsResponse = await listGeneratedContractVersions(id); + if (versionsResponse.code === 0) { + versionItems = versionsResponse.data.items; + baseCreatedAt = versionsResponse.data.base_version.created_at ?? baseCreatedAt; + initialToVersionId = versionItems[0]?.id ?? "base"; + if (initialToVersionId !== "base") { + const diffResponse = await diffGeneratedContractVersions(id, { + from_version_id: "base", + to_version_id: initialToVersionId, + }); + initialVersionDiff = diffResponse.code === 0 ? diffResponse.data : null; + } + } + } catch { + initialVersionDiff = null; + } setDetail(detailResponse.data); setPreview(previewResponse.code === 0 ? previewResponse.data : null); setLifecycleTasks(lifecycleTaskResponse?.code === 0 ? lifecycleTaskResponse.data.items : []); + setContractVersions(versionItems); + setBaseVersionCreatedAt(baseCreatedAt); + setFromVersionId("base"); + setToVersionId(initialToVersionId); + setVersionDiff(initialVersionDiff); } catch (error: unknown) { if (isRequestError(error) && error.status === 404) { setNotFound(true); @@ -193,6 +302,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis } finally { setLoading(false); setLifecycleTaskLoading(false); + setVersionLoading(false); } }; @@ -212,19 +322,51 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis })); }, [detail]); - const exportOptionRows = useMemo(() => { - if (!detail) { - return []; - } + const versionRows = useMemo(() => { + const baseRow: ContractVersionRow = { + id: "base", + version_number: 1, + version_label: "v1", + change_type: "generated", + change_summary: "原始生成版本", + created_at: baseVersionCreatedAt ?? detail?.generated_at ?? null, + is_base: true, + }; - return Object.entries(detail.metadata.export_options ?? {}).map(([name, value]) => ({ - key: name, - name, - value: typeof value === "string" ? value : JSON.stringify(value), - })); - }, [detail]); + return [ + ...contractVersions.map((version) => ({ + id: version.id, + version_number: version.version_number, + version_label: version.version_label, + change_type: version.change_type, + change_summary: version.change_summary, + created_at: version.created_at, + })), + baseRow, + ]; + }, [baseVersionCreatedAt, contractVersions, detail?.generated_at]); + + const versionSelectOptions = useMemo( + () => [...versionRows] + .sort((a, b) => a.version_number - b.version_number) + .map((version) => ({ + value: version.id, + label: `${version.version_label} · ${formatContractVersionChangeType(version.change_type)}`, + })), + [versionRows], + ); + + const latestVersionLabel = contractVersions[0]?.version_label ?? "v1"; const autoReview = detail?.auto_review ?? null; + const approvalSummary = detail?.approval ?? null; + const approvalHistory = approvalSummary?.history ?? []; + const pendingApproval = approvalSummary?.pending ?? null; + const canDecideApproval = (approval?: ContractApprovalStep | null) => { + if (!approval || approval.status !== "pending") return false; + if (currentUser?.role === "admin") return true; + return Boolean(approval.assignee_id && approval.assignee_id === currentUser?.id); + }; const readabilityItems = useMemo( () => buildContractReadabilityItems(detail, autoReview), [detail, autoReview], @@ -308,19 +450,165 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis const handleOpenAutoReviewDetail = (autoReview?: AutoReviewSummary | null) => { if (!autoReview) { - messageApi.warning("自动审查结果暂不可用"); + messageApi.warning("自动预审结果暂不可用"); return; } const target = autoReview.detail_url ?? (autoReview.review_id ? `/reviews/${autoReview.review_id}` : null); if (!target) { - messageApi.warning("自动审查详情尚未生成"); + messageApi.warning("自动预审详情尚未生成"); return; } router.push(target); }; + const reloadContractDetail = async (contractId = detail?.id) => { + if (!contractId) { + return; + } + const result = await getGeneratedContract(contractId); + if (result.code !== 0) { + messageApi.error(result.message || "合同详情刷新失败"); + return; + } + setDetail(result.data); + }; + + const refreshVersionData = async (contractId = detail?.id, quiet = false) => { + if (!contractId) { + return; + } + + setVersionLoading(true); + try { + const result = await listGeneratedContractVersions(contractId); + if (result.code !== 0) { + if (!quiet) { + messageApi.warning(result.message || "版本历史加载失败"); + } + return; + } + + const items = result.data.items; + const availableIds = new Set(["base", ...items.map((item) => item.id)]); + const nextFromVersionId = availableIds.has(fromVersionId) ? fromVersionId : "base"; + const nextToVersionId = availableIds.has(toVersionId) ? toVersionId : items[0]?.id ?? "base"; + setContractVersions(items); + setBaseVersionCreatedAt(result.data.base_version.created_at ?? detail?.generated_at ?? null); + setFromVersionId(nextFromVersionId); + setToVersionId(nextToVersionId); + + if (nextFromVersionId !== nextToVersionId) { + const diffResponse = await diffGeneratedContractVersions(contractId, { + from_version_id: nextFromVersionId, + to_version_id: nextToVersionId, + }); + setVersionDiff(diffResponse.code === 0 ? diffResponse.data : null); + } else { + setVersionDiff(null); + } + + if (!quiet) { + messageApi.success("版本历史已刷新"); + } + } catch (error: unknown) { + if (!quiet) { + messageApi.warning(error instanceof Error ? error.message : "版本历史加载失败"); + } + } finally { + setVersionLoading(false); + } + }; + + const handleCompareVersions = async () => { + if (!detail) { + return; + } + if (fromVersionId === toVersionId) { + setVersionDiff(null); + messageApi.info("请选择两个不同版本进行对比"); + return; + } + + setVersionDiffLoading(true); + try { + const result = await diffGeneratedContractVersions(detail.id, { + from_version_id: fromVersionId, + to_version_id: toVersionId, + }); + if (result.code !== 0) { + messageApi.warning(result.message || "版本对比加载失败"); + return; + } + setVersionDiff(result.data); + messageApi.success("版本对比已更新"); + } catch (error: unknown) { + messageApi.warning(error instanceof Error ? error.message : "版本对比加载失败"); + } finally { + setVersionDiffLoading(false); + } + }; + + const handleCompareVersionWithBase = async (versionId: string) => { + if (!detail || versionId === "base") { + return; + } + + setFromVersionId("base"); + setToVersionId(versionId); + setVersionDiffLoading(true); + try { + const result = await diffGeneratedContractVersions(detail.id, { + from_version_id: "base", + to_version_id: versionId, + }); + if (result.code !== 0) { + messageApi.warning(result.message || "版本对比加载失败"); + return; + } + setVersionDiff(result.data); + messageApi.success("已对比原始版本与所选版本"); + } catch (error: unknown) { + messageApi.warning(error instanceof Error ? error.message : "版本对比加载失败"); + } finally { + setVersionDiffLoading(false); + } + }; + + const handleDownloadContractVersion = async (version: ContractVersionRow) => { + if (!detail) { + return; + } + + setVersionDownloadingId(version.id); + try { + await downloadGeneratedContractVersion(detail.id, version.id, `${detail.template_name}_${version.version_label}.docx`); + messageApi.success("版本文件下载开始"); + } catch (error: unknown) { + messageApi.error(error instanceof Error ? error.message : "版本文件下载失败,请稍后重试"); + } finally { + setVersionDownloadingId(null); + } + }; + + const handleOpenVersionInEditor = (version: ContractVersionRow) => { + if (!detail) { + return; + } + + const searchParams = new URLSearchParams(); + searchParams.set("version", version.id); + router.push(`/documents/${detail.id}/edit?${searchParams.toString()}`); + }; + + const handleOpenLatestVersionInEditor = () => { + if (!detail) { + return; + } + router.push(`/documents/${detail.id}/edit`); + }; + const handleRestore = () => { if (!detail) { return; @@ -358,6 +646,72 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis } }; + const handleOpenApprovalModal = () => { + approvalForm.setFieldsValue({ + title: detail ? `合同审批:${detail.template_name}` : "合同审批", + assignee_name: currentUser?.role === "admin" ? currentUser.display_name : detail?.metadata.generated_by_name, + comment: "", + }); + setApprovalModalOpen(true); + }; + + const handleSubmitApproval = async () => { + if (!detail) { + return; + } + const values = await approvalForm.validateFields(); + setApprovalSubmitting(true); + try { + const result = await submitContractApproval({ + contract_id: detail.id, + title: values.title, + assignee_name: values.assignee_name, + comment: values.comment, + step_type: "legal_review", + }); + if (result.code !== 0) { + messageApi.error(result.message || "提交审批失败"); + return; + } + messageApi.success("合同已提交审批"); + setApprovalModalOpen(false); + await reloadContractDetail(detail.id); + } catch (error: unknown) { + messageApi.error(error instanceof Error ? error.message : "提交审批失败,请稍后重试"); + } finally { + setApprovalSubmitting(false); + } + }; + + const handleApprovalDecision = async (approval: ContractApprovalStep, decision: "approve" | "reject") => { + if (!detail) { + return; + } + Modal.confirm({ + title: decision === "approve" ? "确认通过审批?" : "确认退回审批?", + content: decision === "approve" ? "通过后合同会进入已审批状态,可继续签署或归档准备。" : "退回后合同会回到已生成状态,可修改后重新提交。", + okText: decision === "approve" ? "通过" : "退回", + cancelText: "取消", + okButtonProps: { danger: decision === "reject" }, + onOk: async () => { + setApprovalDecidingId(approval.id); + try { + const result = await decideContractApproval(approval.id, { decision }); + if (result.code !== 0) { + messageApi.error(result.message || "审批处理失败"); + return; + } + messageApi.success(decision === "approve" ? "审批已通过" : "审批已退回"); + await reloadContractDetail(detail.id); + } catch (error: unknown) { + messageApi.error(error instanceof Error ? error.message : "审批处理失败,请稍后重试"); + } finally { + setApprovalDecidingId(null); + } + }, + }); + }; + const reloadLifecycleTasks = async (contractId = detail?.id) => { if (!contractId) { return; @@ -540,7 +894,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis {detail?.metadata.generated_by_name ?? "-"}
- 生成时间 + 起草时间 {formatDateTime(detail?.generated_at)}
@@ -555,10 +909,10 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis 下一步动作 - 下载文件、回填生成参数,或把生成内容带入问答复核。 + 下载文件、继续编辑,或把起草内容带入自动预审和问答复核。 - + @@ -600,30 +954,39 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis 更新生命周期 + + {currentUser?.role === "admin" && detail?.file_id ? : null}
- 当前生成链路已可继续接入审查助手,对生成结果做风险复核、改写建议和谈判准备。 + 当前起草链路已可继续接入审查助手,对合同内容做风险复核、改写建议和谈判准备。
- 审查生成结果 + 预审起草结果 生成修改建议 准备谈判策略 @@ -657,7 +1020,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis {detail?.generation_quality ? ( - + @@ -672,7 +1035,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis ) : null} @@ -699,7 +1062,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
{item.priority} - 自动审查 + 自动预审 {item.risk_level ? {item.risk_level} : null} {item.title} @@ -758,6 +1121,267 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis )} + + + {formatContractApprovalStatus(approvalSummary?.status)} + + + + } + > + {approvalHistory.length === 0 ? ( + + ) : ( +
{formatContractApprovalStepType(value)}, + }, + { + title: "状态", + dataIndex: "status", + width: 110, + render: (value: string) => {formatContractApprovalStatus(value)}, + }, + { + title: "标题/意见", + key: "title", + render: (_value: unknown, record: ContractApprovalStep) => ( + + {record.title} + {record.comment ? {record.comment} : null} + + ), + }, + { + title: "提交/处理人", + key: "people", + width: 220, + render: (_value: unknown, record: ContractApprovalStep) => ( + + {record.submitter_name || "-"} + 处理人:{record.assignee_name || "-"} + + ), + }, + { + title: "时间", + key: "time", + width: 180, + render: (_value: unknown, record: ContractApprovalStep) => formatDateTime(record.decided_at ?? record.created_at), + }, + { + title: "操作", + key: "actions", + width: 160, + render: (_value: unknown, record: ContractApprovalStep) => canDecideApproval(record) ? ( + + + + + ) : ( + 已处理 + ), + }, + ]} + /> + )} + + + + + + + } + > + +
+
+ 版本总数 + {versionRows.length} +
+
+ 最新版本 + {latestVersionLabel} +
+
+ 当前差异 + {versionDiff?.summary.change_count ?? 0} 处 +
+
+ 原始版本 + {formatDateTime(baseVersionCreatedAt ?? detail?.generated_at)} +
+
+ +
+ + +
+ + {versionDiff ? ( +
+
+ {versionDiff.from_version.version_label} → {versionDiff.to_version.version_label} + 新增 {versionDiff.summary.added} + 删除 {versionDiff.summary.removed} + 修改 {versionDiff.summary.modified} + 未变 {versionDiff.summary.unchanged} +
+ {versionDiff.items.length ? ( +
+ {versionDiff.items.map((item, index) => ( +
+ + {diffChangeTypeLabelMap[item.change_type] ?? item.change_type} + + {item.old_text ?

{item.old_text}

: null} + {item.new_text ?

{item.new_text}

: null} +
+ ))} +
+ ) : ( + 当前两个版本没有段落级变化。 + )} +
+ ) : ( + + )} + +
( + + {record.version_label} + {record.is_base ? 原始版本 : null} + + ), + }, + { + title: "变更类型", + dataIndex: "change_type", + key: "change_type", + width: 130, + render: (value: string) => ( + + {formatContractVersionChangeType(value)} + + ), + }, + { + title: "变更说明", + dataIndex: "change_summary", + key: "change_summary", + render: (value: string | null | undefined) => value || "-", + }, + { + title: "保存时间", + dataIndex: "created_at", + key: "created_at", + width: 180, + render: (value: string | null | undefined) => formatDateTime(value), + }, + { + title: "操作", + key: "actions", + width: 220, + render: (_value: unknown, record: ContractVersionRow) => ( + + {!record.is_base ? ( + + ) : null} + {!record.is_base ? ( + + ) : null} + + + ), + }, + ]} + /> + + + {detail.export_format.toUpperCase()} {detail.file_name} {detail.metadata.generated_by_name} - {formatDateTime(detail.generated_at)} + {formatDateTime(detail.generated_at)} {formatLifecycleStatus(detail.lifecycle?.status)} @@ -906,10 +1530,10 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis {autoReview ? ( - + {autoReview.review_id ?? "-"} - + {formatAutoReviewStatus(autoReview.status)} {autoReview.risk_level ?? "-"} @@ -930,16 +1554,16 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
@@ -987,7 +1611,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis )}
- + {generationModeMeta.label} @@ -999,12 +1623,12 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis ) : null} - + {generationSource?.generation_mode || detail?.generation_quality?.generation_mode || "template_render"} @@ -1057,7 +1681,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis ) : ( - 当前生成结果未返回结构化法条命中,后续可继续补强法条引用展示。 + 当前合同未返回结构化法条命中,后续可继续补强法条引用展示。 )} @@ -1072,14 +1696,36 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis )} - + {detail ? (
{detail.generated_content}
) : ( - + )}
+ void handleSubmitApproval()} + onCancel={() => setApprovalModalOpen(false)} + > +
+ + + + + + + +