from datetime import datetime from typing import Optional from urllib.parse import quote from fastapi import APIRouter, BackgroundTasks, Depends, Form, Header, HTTPException, Request, Response 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_generation_service import ContractGenerationService, ExportFormat from app.services.tenant_service import TenantService router = APIRouter(prefix="/contracts", tags=["contracts"]) ROUTE_PERMISSION_SCOPE = "contract_generation" ROUTE_PERMISSION_ACTIONS = { "list": "contracts:list", "templates": "contracts:templates:list", "generate": "contracts:generate", "preview": "contracts:preview", "detail": "contracts:detail", "versions": "contracts:versions:list", "version_detail": "contracts:versions:detail", "version_create": "contracts:versions:create", "version_diff": "contracts:versions:diff", "version_download": "contracts:versions:download", "lifecycle_update": "contracts:lifecycle:update", "lifecycle_tasks": "contracts:lifecycle-tasks:list", "lifecycle_task_sync": "contracts:lifecycle-tasks:sync", "lifecycle_task_create": "contracts:lifecycle-tasks:create", "lifecycle_task_update": "contracts:lifecycle-tasks:update", "preview_generated": "contracts:generated:preview", "download": "contracts:download", } class GeneratedContractVersionCreateRequest(BaseModel): content: str = Field(..., min_length=1, max_length=1_000_000) change_type: str = "editor_save" change_summary: Optional[str] = None source_version_id: Optional[str] = None original_text: Optional[str] = None replacement_text: Optional[str] = None locate_status: str = "applied" class GeneratedContractLifecycleUpdateRequest(BaseModel): lifecycle_status: str = Field(..., min_length=1, max_length=32) counterparty_name: Optional[str] = Field(default=None, max_length=120) owner_name: Optional[str] = Field(default=None, max_length=100) effective_at: Optional[datetime] = None expires_at: Optional[datetime] = None lifecycle_note: Optional[str] = Field(default=None, max_length=1000) class ContractLifecycleTaskCreateRequest(BaseModel): task_type: str = Field(..., min_length=1, max_length=64) title: str = Field(..., min_length=1, max_length=200) description: Optional[str] = Field(default=None, max_length=4000) priority: str = Field(default="medium", max_length=32) status_value: str = Field(default="open", max_length=32) due_at: Optional[datetime] = None evidence_file_id: Optional[str] = Field(default=None, max_length=64) evidence_note: Optional[str] = Field(default=None, max_length=2000) class ContractLifecycleTaskUpdateRequest(BaseModel): task_type: Optional[str] = Field(default=None, max_length=64) title: Optional[str] = Field(default=None, max_length=200) description: Optional[str] = Field(default=None, max_length=4000) priority: Optional[str] = Field(default=None, max_length=32) status_value: Optional[str] = Field(default=None, max_length=32) due_at: Optional[datetime] = None evidence_file_id: Optional[str] = Field(default=None, max_length=64) evidence_note: Optional[str] = Field(default=None, max_length=2000) def get_contract_generation_service(db: Session = Depends(get_db_session)) -> ContractGenerationService: return ContractGenerationService(db) def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService: return AuditLogService(db) def get_tenant_service(db: Session = Depends(get_db_session)) -> TenantService: return TenantService(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_generated_contracts( page: int = 1, page_size: int = 10, contract_type: Optional[str] = None, document_type_code: Optional[str] = None, lifecycle_status: Optional[str] = None, keyword: Optional[str] = None, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): return success_response( contract_generation_service.list_generated_contracts( current_user=current_user, page=page, page_size=page_size, contract_type=contract_type, document_type_code=document_type_code, lifecycle_status=lifecycle_status, keyword=keyword, ) ) @router.get("/templates") def list_available_templates( contract_type: Optional[str] = None, document_type_code: Optional[str] = None, template_category: Optional[str] = None, industry_category: Optional[str] = None, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): kwargs = { "current_user": current_user, "contract_type": contract_type, } if document_type_code is not None: kwargs["document_type_code"] = document_type_code if template_category is not None: kwargs["template_category"] = template_category if industry_category is not None: kwargs["industry_category"] = industry_category return success_response(contract_generation_service.list_available_templates(**kwargs)) @router.get("/lifecycle/tasks") def list_contract_lifecycle_task_workbench( page: int = 1, page_size: int = 50, status_value: Optional[str] = None, task_type: Optional[str] = None, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): return success_response( contract_generation_service.list_contract_lifecycle_task_workbench( current_user=current_user, page=page, page_size=page_size, status_value=status_value, task_type=task_type, ) ) @router.post("/generate") def generate_contract( request: Request, background_tasks: BackgroundTasks, template_id: Optional[str] = Form(default=None), variable_values_json: Optional[str] = Form(default=None), export_format: str = Form(default="docx"), options_json: Optional[str] = Form(default=None), document_type_code: Optional[str] = Form(default=None), elements_json: Optional[str] = Form(default=None), element_schema_id: Optional[str] = Form(default=None), element_schema_version_label: Optional[str] = Form(default=None), contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), tenant_service: TenantService = Depends(get_tenant_service), current_user: dict = Depends(require_current_user), ): import json try: variable_values = json.loads(variable_values_json) if variable_values_json else {} if not isinstance(variable_values, dict): raise HTTPException(status_code=400, detail="变量值必须是JSON对象") options = {} if options_json: options = json.loads(options_json) if not isinstance(options, dict): raise HTTPException(status_code=400, detail="选项必须是JSON对象") elements_snapshot = None if elements_json: elements_snapshot = json.loads(elements_json) if not isinstance(elements_snapshot, dict): raise HTTPException(status_code=400, detail="elements_json must be a JSON object") try: export_format_enum = ExportFormat(export_format.lower()) except ValueError: raise HTTPException(status_code=400, detail=f"不支持的导出格式: {export_format}") tenant_service.assert_billing_allows(current_user, "generation") if elements_snapshot is not None: if not document_type_code: raise HTTPException(status_code=400, detail="document_type_code is required when elements_json is provided") result = contract_generation_service.generate_contract_from_elements( current_user=current_user, document_type_code=document_type_code, elements=elements_snapshot, export_format=export_format_enum, options=options, template_id=template_id, variable_values=variable_values, ) else: if not template_id: raise HTTPException(status_code=400, detail="template_id is required when elements_json is not provided") if not variable_values_json: raise HTTPException(status_code=400, detail="variable_values_json is required when elements_json is not provided") result = contract_generation_service.generate_contract( current_user=current_user, template_id=template_id, variable_values=variable_values, export_format=export_format_enum, options=options, document_type_code=document_type_code, elements_snapshot=elements_snapshot, element_schema_id=element_schema_id, element_schema_version_label=element_schema_version_label, ) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), action="contract_generation", resource_type="contract", resource_id=result["id"], detail_json={ "template_id": result.get("template_id") or template_id, "template_name": result["template_name"], "contract_type": result["contract_type"], "document_type_code": result.get("document_type_code"), "element_schema_id": result.get("metadata", {}).get("element_schema_id"), "element_schema_version_label": result.get("metadata", {}).get("element_schema_version_label"), "template_category": result.get("template_category"), "industry_category": result.get("industry_category"), "export_format": export_format, "variables_count": len(result.get("variable_values") or variable_values), "missing_field_count": len(result.get("missing_fields") or []), "related_legal_article_count": len(result.get("related_legal_articles") or []), "file_name": result["file_name"], }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) if result.get("id"): background_tasks.add_task( contract_generation_service._execute_deferred_auto_review, result["id"], current_user["tenant_id"], current_user, ) return success_response(result) except json.JSONDecodeError: raise HTTPException(status_code=400, detail="JSON解析错误") except HTTPException: raise except Exception as exc: raise HTTPException(status_code=500, detail=f"合同生成失败: {exc}") @router.post("/preview") def preview_contract( template_id: str = Form(...), variable_values_json: str = Form(...), contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): import json try: variable_values = json.loads(variable_values_json) if not isinstance(variable_values, dict): raise HTTPException(status_code=400, detail="变量值必须是JSON对象") result = contract_generation_service.preview_contract( current_user=current_user, template_id=template_id, variable_values=variable_values, ) return success_response(result) except json.JSONDecodeError: raise HTTPException(status_code=400, detail="JSON解析错误") except HTTPException: raise except Exception as exc: raise HTTPException(status_code=500, detail=f"合同预览失败: {exc}") @router.get("/{contract_id}") def get_generated_contract( contract_id: str, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): return success_response(contract_generation_service.get_generated_contract(current_user, contract_id)) @router.patch("/{contract_id}/lifecycle") def update_generated_contract_lifecycle( contract_id: str, payload: GeneratedContractLifecycleUpdateRequest, request: Request, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): result = contract_generation_service.update_generated_contract_lifecycle( current_user=current_user, contract_id=contract_id, lifecycle_status=payload.lifecycle_status, counterparty_name=payload.counterparty_name, owner_name=payload.owner_name, effective_at=payload.effective_at, expires_at=payload.expires_at, lifecycle_note=payload.lifecycle_note, ) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), action="contract_lifecycle_update", resource_type="contract", resource_id=contract_id, detail_json={ "lifecycle_status": result.get("lifecycle", {}).get("status"), "counterparty_name": result.get("lifecycle", {}).get("counterparty_name"), "owner_name": result.get("lifecycle", {}).get("owner_name"), "effective_at": result.get("lifecycle", {}).get("effective_at"), "expires_at": result.get("lifecycle", {}).get("expires_at"), "due_status": result.get("lifecycle", {}).get("due_status"), }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return success_response(result) @router.get("/{contract_id}/lifecycle/tasks") def list_contract_lifecycle_tasks( contract_id: str, status_value: Optional[str] = None, task_type: Optional[str] = None, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): return success_response( contract_generation_service.list_contract_lifecycle_tasks( current_user=current_user, contract_id=contract_id, status_value=status_value, task_type=task_type, ) ) @router.post("/{contract_id}/lifecycle/tasks/sync") def sync_contract_lifecycle_tasks( contract_id: str, request: Request, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): result = contract_generation_service.sync_contract_lifecycle_tasks(current_user=current_user, contract_id=contract_id) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), action="contract_lifecycle_task_sync", resource_type="contract", resource_id=contract_id, detail_json={ "synced": result.get("synced"), "reason": result.get("reason"), "total": result.get("total"), "summary": result.get("summary"), }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return success_response(result) @router.post("/{contract_id}/lifecycle/tasks") def create_contract_lifecycle_task( contract_id: str, payload: ContractLifecycleTaskCreateRequest, request: Request, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): result = contract_generation_service.create_contract_lifecycle_task( current_user=current_user, contract_id=contract_id, task_type=payload.task_type, title=payload.title, description=payload.description, priority=payload.priority, status_value=payload.status_value, due_at=payload.due_at, evidence_file_id=payload.evidence_file_id, evidence_note=payload.evidence_note, ) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), action="contract_lifecycle_task_create", resource_type="contract_lifecycle_task", resource_id=result.get("id"), detail_json={ "contract_id": contract_id, "task_type": result.get("task_type"), "priority": result.get("priority"), "status": result.get("status"), "due_at": result.get("due_at"), "evidence_file_id": result.get("evidence_file_id"), }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return success_response(result) @router.put("/{contract_id}/lifecycle/tasks/{task_id}") def update_contract_lifecycle_task( contract_id: str, task_id: str, payload: ContractLifecycleTaskUpdateRequest, request: Request, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): result = contract_generation_service.update_contract_lifecycle_task( current_user=current_user, contract_id=contract_id, task_id=task_id, task_type=payload.task_type, title=payload.title, description=payload.description, priority=payload.priority, status_value=payload.status_value, due_at=payload.due_at, evidence_file_id=payload.evidence_file_id, evidence_note=payload.evidence_note, ) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), action="contract_lifecycle_task_update", resource_type="contract_lifecycle_task", resource_id=task_id, detail_json={ "contract_id": contract_id, "task_type": result.get("task_type"), "priority": result.get("priority"), "status": result.get("status"), "due_at": result.get("due_at"), "evidence_file_id": result.get("evidence_file_id"), }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return success_response(result) @router.get("/{contract_id}/versions") def list_generated_contract_versions( contract_id: str, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): return success_response(contract_generation_service.list_generated_contract_versions(current_user, contract_id)) @router.get("/{contract_id}/versions/diff") def diff_generated_contract_versions( contract_id: str, request: Request, from_version_id: Optional[str] = None, to_version_id: Optional[str] = None, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): result = contract_generation_service.diff_generated_contract_versions( current_user=current_user, contract_id=contract_id, from_version_id=from_version_id, to_version_id=to_version_id, ) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), action="contract_version_diff", resource_type="contract", resource_id=contract_id, detail_json={ "from_version": result.get("from_version", {}).get("version_label"), "to_version": result.get("to_version", {}).get("version_label"), "change_count": result.get("summary", {}).get("change_count"), }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return success_response(result) @router.get("/{contract_id}/versions/{version_id}/download") def download_generated_contract_version( contract_id: str, version_id: str, request: Request, export_format: str = "docx", contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): try: export_format_enum = ExportFormat(export_format.lower()) except ValueError: raise HTTPException(status_code=400, detail=f"不支持的导出格式: {export_format}") result = contract_generation_service.export_generated_contract_version( current_user=current_user, contract_id=contract_id, version_id=version_id, export_format=export_format_enum, ) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), action="download_contract_version", resource_type="contract_version", resource_id=version_id, detail_json={ "contract_id": contract_id, "version_label": result.get("version", {}).get("version_label"), "file_name": result["file_name"], "mime_type": result["mime_type"], "export_format": result.get("export_format"), }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(result['file_name'])}"} return Response(content=result["content"], media_type=result["mime_type"], headers=headers) @router.get("/{contract_id}/versions/{version_id}") def get_generated_contract_version( contract_id: str, version_id: str, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): return success_response(contract_generation_service.get_generated_contract_version(current_user, contract_id, version_id)) @router.post("/{contract_id}/versions") def create_generated_contract_version( contract_id: str, payload: GeneratedContractVersionCreateRequest, request: Request, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): result = contract_generation_service.create_generated_contract_version( current_user=current_user, contract_id=contract_id, content=payload.content, change_type=payload.change_type, change_summary=payload.change_summary, source_version_id=payload.source_version_id, original_text=payload.original_text, replacement_text=payload.replacement_text, locate_status=payload.locate_status, ) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), action="contract_version_create", resource_type="contract_version", resource_id=result.get("id"), detail_json={ "contract_id": contract_id, "version_label": result.get("version_label"), "change_type": result.get("change_type"), "change_summary": result.get("change_summary"), "content_length": len(payload.content or ""), }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return success_response(result) @router.get("/{contract_id}/preview") def get_contract_preview( contract_id: str, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): return success_response(contract_generation_service.get_generated_contract_preview(current_user, contract_id)) @router.get("/{contract_id}/download") def download_contract( contract_id: str, request: Request, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): result = contract_generation_service.get_generated_contract_download(current_user, contract_id) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), action="download_contract", resource_type="contract", resource_id=contract_id, detail_json={ "file_name": result["file_name"], "mime_type": result["mime_type"], }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(result['file_name'])}"} return Response(content=result["content"], media_type=result["mime_type"], headers=headers)