From a89f4c7050edac092069ebb6b5838f0e760fbeb5 Mon Sep 17 00:00:00 2001 From: yuqei Date: Sun, 21 Jun 2026 09:14:36 +0800 Subject: [PATCH] Refactor contract platform workflows and UI --- .gitignore | 1 + backend/app/api/audit_logs.py | 11 + backend/app/api/contract_approvals.py | 11 + backend/app/api/contracts.py | 193 ++ backend/app/api/notifications.py | 38 + backend/app/api/search.py | 38 + backend/app/api/system.py | 178 +- backend/app/clients/shared_ai_platform.py | 107 +- backend/app/core/contract_permissions.py | 118 ++ backend/app/core/roles.py | 2 + backend/app/main.py | 4 + backend/app/models/__init__.py | 1 + .../generated_contract_version_comment.py | 42 + .../app/repositories/audit_log_repository.py | 69 +- .../contract_approval_step_repository.py | 18 + .../generated_contract_repository.py | 161 +- ...ted_contract_version_comment_repository.py | 61 + backend/app/services/audit_log_service.py | 61 + .../app/services/contract_approval_service.py | 115 +- .../services/contract_content_normalizer.py | 139 ++ .../services/contract_generation_service.py | 1002 +++++++++- .../services/contract_lifecycle_service.py | 2 +- .../services/legal_document_draft_service.py | 23 +- .../legal_document_element_schema_service.py | 3 +- .../services/legal_document_type_service.py | 4 +- backend/app/services/legal_domain_registry.py | 8 + backend/app/services/notification_service.py | 249 +++ backend/app/services/operation_kpi_service.py | 168 +- backend/app/services/search_service.py | 273 +++ ...ate_generated_contract_version_comments.py | 74 + backend/tests/test_audit_log_service.py | 164 ++ backend/tests/test_audit_logs_api.py | 54 + backend/tests/test_contracts_api.py | 1062 +++++++++- ...t_legal_document_element_schema_service.py | 8 + .../tests/test_legal_document_type_service.py | 8 + backend/tests/test_search_api.py | 59 + backend/tests/test_search_service.py | 179 ++ .../tests/test_shared_ai_platform_client.py | 92 + backend/tests/test_system_api.py | 107 + backend/tests/test_user_service.py | 11 +- deploy/docker-compose.yml | 7 +- deploy/web-gateway.conf | 2 +- docker-compose.frontend.aliyun.yml | 9 +- .../2026-06-17-strategy-refresh/README.md | 6 - frontend/Dockerfile | 2 + frontend/next.config.ts | 14 +- frontend/package-lock.json | 217 +- frontend/package.json | 1 + .../scripts/phase1-mainline-signoff-e2e.mjs | 396 ++++ .../v1/knowledge-bases/[id]/versions/route.ts | 105 + frontend/src/app/audit-logs/page.tsx | 59 +- frontend/src/app/billing/page.tsx | 14 +- frontend/src/app/chat/page.tsx | 57 +- frontend/src/app/contract-approvals/page.tsx | 129 +- frontend/src/app/contracts/[id]/page.tsx | 1773 +++++++++++++---- frontend/src/app/contracts/page.tsx | 687 +++++-- frontend/src/app/documents/[id]/edit/page.tsx | 226 ++- .../src/app/documents/new/[type]/page.tsx | 12 +- frontend/src/app/documents/new/page.tsx | 2 +- frontend/src/app/files/page.tsx | 29 +- frontend/src/app/globals.css | 723 ++++++- .../src/app/governance/assets/[id]/page.tsx | 2 +- frontend/src/app/governance/imports/page.tsx | 2 +- frontend/src/app/governance/releases/page.tsx | 4 +- .../src/app/governance/workbench/page.tsx | 4 +- frontend/src/app/knowledge-bases/page.tsx | 298 +-- frontend/src/app/legal-articles/page.tsx | 47 +- .../src/app/legal-change-impacts/page.tsx | 2 +- frontend/src/app/model-settings/page.tsx | 10 +- frontend/src/app/operation-kpis/page.tsx | 30 +- frontend/src/app/prompt-assets/page.tsx | 8 +- frontend/src/app/quality-evals/page.tsx | 24 +- frontend/src/app/reviews/[id]/page.tsx | 23 +- frontend/src/app/reviews/page.tsx | 31 +- frontend/src/app/rules/page.tsx | 20 +- frontend/src/app/sales-followups/page.tsx | 8 +- frontend/src/app/search/page.tsx | 220 ++ frontend/src/app/templates/page.tsx | 29 +- frontend/src/app/users/page.tsx | 27 +- frontend/src/app/workspace/page.tsx | 44 +- frontend/src/components/layout/app-topbar.tsx | 122 +- frontend/src/hooks/use-contract-generation.ts | 7 +- frontend/src/lib/format.ts | 1 + frontend/src/lib/roles.ts | 7 +- frontend/src/services/audit-log.ts | 13 +- frontend/src/services/contract.ts | 308 ++- frontend/src/services/notification.ts | 48 + frontend/src/services/search.ts | 50 + frontend/src/types/api.ts | 9 + infra/nginx/contact.yuqei.com.conf | 52 +- ...‰与审查助手-Phase1主链路验收清单-2026-06-17.md | 47 +- ...¸Ž审查助手-Phase1实际开发执行方案-2026-06-17.md | 2 +- ...µ·草与审查助手-完整规划开发方案-2026-06-17.md | 41 +- ...µ·草与审查助手-开发闭环任务清单-2026-06-19.md | 1276 ++++++++++++ {docs => project-docs}/DEPLOYMENT.md | 50 + .../DEPLOYMENT_TROUBLESHOOTING.md | 40 + .../AI法律文书助手_合并整理版.md | 0 .../MIGRATION-NOTES.md | 31 + .../integration-plan.md | 0 .../npc-law-database-api.md | 0 project-docs/契阅品牌方案设计 V2.0.md | 440 ++++ scripts/deploy-backend-docker.ps1 | 8 +- scripts/deploy-frontend-aliyun-docker.ps1 | 184 +- scripts/deploy-frontend-docker.ps1 | 14 +- scripts/run-dependency-governance.ps1 | 92 +- scripts/run-deployment-preflight.ps1 | 188 ++ scripts/run-phase1-closed-loop.ps1 | 4 - scripts/run-phase1-mainline-signoff.ps1 | 158 ++ .../run-production-deployment-rehearsal.ps1 | 57 +- scripts/run-production-env-check.ps1 | 383 ++++ scripts/run-production-health-check.ps1 | 2 +- scripts/run-production-release-gate.ps1 | 2 + scripts/run-security-baseline.ps1 | 14 +- 113 files changed, 12367 insertions(+), 1504 deletions(-) create mode 100644 backend/app/api/notifications.py create mode 100644 backend/app/api/search.py create mode 100644 backend/app/core/contract_permissions.py create mode 100644 backend/app/models/generated_contract_version_comment.py create mode 100644 backend/app/repositories/generated_contract_version_comment_repository.py create mode 100644 backend/app/services/contract_content_normalizer.py create mode 100644 backend/app/services/notification_service.py create mode 100644 backend/app/services/search_service.py create mode 100644 backend/migrations/versions/20260619_0045_create_generated_contract_version_comments.py create mode 100644 backend/tests/test_search_api.py create mode 100644 backend/tests/test_search_service.py delete mode 100644 docs/archive/2026-06-17-strategy-refresh/README.md create mode 100644 frontend/scripts/phase1-mainline-signoff-e2e.mjs create mode 100644 frontend/src/app/api/v1/knowledge-bases/[id]/versions/route.ts create mode 100644 frontend/src/app/search/page.tsx create mode 100644 frontend/src/services/notification.ts create mode 100644 frontend/src/services/search.ts create mode 100644 project-docs/AI合同起草与审查助手-开发闭环任务清单-2026-06-19.md rename {docs => project-docs}/DEPLOYMENT.md (80%) rename {docs => project-docs}/DEPLOYMENT_TROUBLESHOOTING.md (74%) rename {docs => project-docs}/archive/2026-06-17-strategy-refresh/AI法律文书助手_合并整理版.md (100%) create mode 100644 project-docs/archive/2026-06-17-strategy-refresh/MIGRATION-NOTES.md rename {docs => project-docs}/archive/2026-06-17-strategy-refresh/integration-plan.md (100%) rename {docs => project-docs}/npc-law-database-api.md (100%) create mode 100644 project-docs/契阅品牌方案设计 V2.0.md create mode 100644 scripts/run-deployment-preflight.ps1 create mode 100644 scripts/run-phase1-mainline-signoff.ps1 create mode 100644 scripts/run-production-env-check.ps1 diff --git a/.gitignore b/.gitignore index 04ddee5f..56cdafd7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ project-docs/automation-artifacts/ backend/project-docs/qa-artifacts/ .claude/worktrees/ .gstack/ +.codex/phase1-mainline-signoff/ # Temporary directories temp-*/ diff --git a/backend/app/api/audit_logs.py b/backend/app/api/audit_logs.py index e214a7cb..ba6b125f 100644 --- a/backend/app/api/audit_logs.py +++ b/backend/app/api/audit_logs.py @@ -35,3 +35,14 @@ def list_audit_logs( current_user: dict = Depends(require_current_user), ): return success_response(audit_log_service.list_logs(current_user, page, page_size, action)) + + +@router.get("/contracts/{contract_id}") +def list_contract_audit_timeline( + contract_id: str, + page: int = 1, + page_size: int = 50, + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + return success_response(audit_log_service.list_contract_timeline(current_user, contract_id, page, page_size)) diff --git a/backend/app/api/contract_approvals.py b/backend/app/api/contract_approvals.py index 76810a9a..c0c93793 100644 --- a/backend/app/api/contract_approvals.py +++ b/backend/app/api/contract_approvals.py @@ -104,9 +104,14 @@ def submit_contract_approval( resource_id=result.get("id"), detail_json={ "contract_id": result.get("contract_id"), + "approval_id": result.get("id"), + "title": result.get("title"), "step_type": result.get("step_type"), + "submitter_id": result.get("submitter_id"), "assignee_id": result.get("assignee_id"), + "assignee_name": result.get("assignee_name"), "status": result.get("status"), + "audit_key": result.get("audit_key"), }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), @@ -137,8 +142,14 @@ def decide_contract_approval( resource_id=result.get("id"), detail_json={ "contract_id": result.get("contract_id"), + "approval_id": result.get("id"), "decision": payload.decision, + "comment": result.get("comment"), + "decided_by": result.get("decided_by"), + "decided_by_name": result.get("decided_by_name"), + "decided_at": result.get("decided_at"), "status": result.get("status"), + "audit_key": result.get("audit_key"), }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), diff --git a/backend/app/api/contracts.py b/backend/app/api/contracts.py index 3c61cd61..1e0b0994 100644 --- a/backend/app/api/contracts.py +++ b/backend/app/api/contracts.py @@ -17,6 +17,7 @@ router = APIRouter(prefix="/contracts", tags=["contracts"]) ROUTE_PERMISSION_SCOPE = "contract_generation" ROUTE_PERMISSION_ACTIONS = { "list": "contracts:list", + "export": "contracts:export", "templates": "contracts:templates:list", "generate": "contracts:generate", "preview": "contracts:preview", @@ -46,6 +47,23 @@ class GeneratedContractVersionCreateRequest(BaseModel): locate_status: str = "applied" +class GeneratedContractVersionCommentCreateRequest(BaseModel): + from_version_id: Optional[str] = None + to_version_id: Optional[str] = None + diff_key: str = Field(..., min_length=1, max_length=80) + change_type: str = Field(default="modified", max_length=32) + old_text: Optional[str] = Field(default=None, max_length=8000) + new_text: Optional[str] = Field(default=None, max_length=8000) + comment: str = Field(..., min_length=1, max_length=4000) + source: str = Field(default="version_diff", max_length=64) + metadata: Optional[dict] = None + + +class GeneratedContractVersionCommentUpdateRequest(BaseModel): + status: Optional[str] = Field(default=None, max_length=32) + comment: Optional[str] = Field(default=None, max_length=4000) + + class GeneratedContractLifecycleUpdateRequest(BaseModel): lifecycle_status: str = Field(..., min_length=1, max_length=32) counterparty_name: Optional[str] = Field(default=None, max_length=120) @@ -104,6 +122,12 @@ def list_generated_contracts( document_type_code: Optional[str] = None, lifecycle_status: Optional[str] = None, keyword: Optional[str] = None, + counterparty_name: Optional[str] = None, + owner_name: Optional[str] = None, + effective_from: Optional[datetime] = None, + effective_to: Optional[datetime] = None, + expires_from: Optional[datetime] = None, + expires_to: Optional[datetime] = None, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): @@ -116,10 +140,79 @@ def list_generated_contracts( document_type_code=document_type_code, lifecycle_status=lifecycle_status, keyword=keyword, + counterparty_name=counterparty_name, + owner_name=owner_name, + effective_from=effective_from, + effective_to=effective_to, + expires_from=expires_from, + expires_to=expires_to, ) ) +@router.get("/export") +def export_generated_contracts( + request: Request, + contract_type: Optional[str] = None, + document_type_code: Optional[str] = None, + lifecycle_status: Optional[str] = None, + keyword: Optional[str] = None, + counterparty_name: Optional[str] = None, + owner_name: Optional[str] = None, + effective_from: Optional[datetime] = None, + effective_to: Optional[datetime] = None, + expires_from: Optional[datetime] = None, + expires_to: Optional[datetime] = None, + limit: int = 1000, + 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.export_generated_contracts_csv( + current_user=current_user, + contract_type=contract_type, + document_type_code=document_type_code, + lifecycle_status=lifecycle_status, + keyword=keyword, + counterparty_name=counterparty_name, + owner_name=owner_name, + effective_from=effective_from, + effective_to=effective_to, + expires_from=expires_from, + expires_to=expires_to, + limit=limit, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="export_contract_ledger", + resource_type="contract", + resource_id=None, + detail_json={ + "file_name": result["file_name"], + "mime_type": result["mime_type"], + "count": result.get("count"), + "limit": result.get("limit"), + "filters": { + "contract_type": contract_type, + "document_type_code": document_type_code, + "lifecycle_status": lifecycle_status, + "keyword": keyword, + "counterparty_name": counterparty_name, + "owner_name": owner_name, + "effective_from": effective_from.isoformat() if effective_from else None, + "effective_to": effective_to.isoformat() if effective_to else None, + "expires_from": expires_from.isoformat() if expires_from else None, + "expires_to": expires_to.isoformat() if expires_to else None, + }, + }, + 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("/templates") def list_available_templates( contract_type: Optional[str] = None, @@ -526,6 +619,106 @@ def diff_generated_contract_versions( return success_response(result) +@router.get("/{contract_id}/version-comments") +def list_generated_contract_version_comments( + contract_id: str, + from_version_id: Optional[str] = None, + to_version_id: Optional[str] = None, + status_value: 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_contract_version_comments( + current_user=current_user, + contract_id=contract_id, + from_version_id=from_version_id, + to_version_id=to_version_id, + status_value=status_value, + ) + ) + + +@router.post("/{contract_id}/version-comments") +def create_generated_contract_version_comment( + contract_id: str, + payload: GeneratedContractVersionCommentCreateRequest, + 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_comment( + current_user=current_user, + contract_id=contract_id, + from_version_id=payload.from_version_id, + to_version_id=payload.to_version_id, + diff_key=payload.diff_key, + change_type=payload.change_type, + old_text=payload.old_text, + new_text=payload.new_text, + comment=payload.comment, + source=payload.source, + metadata=payload.metadata, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="contract_version_comment_create", + resource_type="contract_version_comment", + resource_id=result.get("id"), + detail_json={ + "contract_id": contract_id, + "from_version_id": result.get("from_version_id"), + "to_version_id": result.get("to_version_id"), + "diff_key": result.get("diff_key"), + "change_type": result.get("change_type"), + "status": result.get("status"), + "source": result.get("source"), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.patch("/{contract_id}/version-comments/{comment_id}") +def update_generated_contract_version_comment( + contract_id: str, + comment_id: str, + payload: GeneratedContractVersionCommentUpdateRequest, + 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_version_comment( + current_user=current_user, + contract_id=contract_id, + comment_id=comment_id, + status_value=payload.status, + comment=payload.comment, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="contract_version_comment_update", + resource_type="contract_version_comment", + resource_id=result.get("id"), + detail_json={ + "contract_id": contract_id, + "from_version_id": result.get("from_version_id"), + "to_version_id": result.get("to_version_id"), + "diff_key": result.get("diff_key"), + "status": result.get("status"), + "resolved_by": result.get("resolved_by"), + }, + 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, diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 00000000..3ad4c944 --- /dev/null +++ b/backend/app/api/notifications.py @@ -0,0 +1,38 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Header +from sqlalchemy.orm import Session + +from app.core.response import success_response +from app.db import get_db_session +from app.services.auth_service import AuthService +from app.services.notification_service import NotificationService + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + + +def get_notification_service(db: Session = Depends(get_db_session)) -> NotificationService: + return NotificationService(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_notifications( + limit: int = 20, + scope: str = "mine", + notification_service: NotificationService = Depends(get_notification_service), + current_user: dict = Depends(require_current_user), +): + return success_response( + notification_service.list_notifications( + current_user=current_user, + limit=limit, + scope=scope, + ) + ) diff --git a/backend/app/api/search.py b/backend/app/api/search.py new file mode 100644 index 00000000..ad9e5e18 --- /dev/null +++ b/backend/app/api/search.py @@ -0,0 +1,38 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Header +from sqlalchemy.orm import Session + +from app.core.response import success_response +from app.db import get_db_session +from app.services.auth_service import AuthService +from app.services.search_service import SearchService + + +router = APIRouter(prefix="/search", tags=["search"]) +ROUTE_PERMISSION_SCOPE = "unified_search" +ROUTE_PERMISSION_ACTIONS = { + "search": "search:query", +} + + +def get_search_service(db: Session = Depends(get_db_session)) -> SearchService: + return SearchService(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 unified_search( + q: str, + types: Optional[str] = None, + limit: int = 8, + search_service: SearchService = Depends(get_search_service), + current_user: dict = Depends(require_current_user), +): + return success_response(search_service.search(current_user, q=q, types=types, limit=limit)) diff --git a/backend/app/api/system.py b/backend/app/api/system.py index d8be6d4f..26754739 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -18,7 +18,27 @@ from app.services.governance_service import GovernanceService from app.services.template_service import TemplateService router = APIRouter(prefix="/system", tags=["system"]) -WEAK_SECRET_KEYS = {"change-me", "change-me-in-production"} +WEAK_SECRET_KEYS = { + "change-me", + "change-me-in-production", + "change_me", + "change_me_to_a_random_64_plus_character_secret_for_production", +} +PLACEHOLDER_CONFIG_VALUES = { + "change-me", + "change-me-in-production", + "change_me", + "changeme", + "replace_me", + "replace-me", + "todo", + "tbd", + "your-secret-key", + "change_me_to_a_random_64_plus_character_secret_for_production", + "change_me_minio_access_key", + "change_me_minio_secret_key", + "change_me_shared_ai_api_key", +} ROUTE_PERMISSION_SCOPE = "system" ROUTE_PERMISSION_ACTIONS = { "readiness": "system:readiness:get", @@ -501,6 +521,27 @@ def _build_check(key: str, label: str, status: str, detail: str, required: bool } +def _is_production_env() -> bool: + return settings.app_env.strip().lower() in {"production", "prod"} + + +def _clean_config_value(value: Optional[str]) -> str: + return (value or "").strip() + + +def _is_placeholder_config_value(value: Optional[str]) -> bool: + normalized_value = _clean_config_value(value).lower() + if not normalized_value: + return False + if normalized_value in PLACEHOLDER_CONFIG_VALUES: + return True + return any(token in normalized_value for token in PLACEHOLDER_CONFIG_VALUES if token.startswith("change_me")) + + +def _is_weak_minio_credential(value: Optional[str]) -> bool: + return _clean_config_value(value).lower() in {"minioadmin", "minio", "admin", "password"} + + def _check_database(db: Session) -> Dict[str, Any]: try: db.execute(text("SELECT 1")).scalar() @@ -511,21 +552,83 @@ def _check_database(db: Session) -> Dict[str, Any]: def _check_runtime_config() -> Dict[str, Any]: secret_key = settings.secret_key.strip() - if not secret_key or secret_key in WEAK_SECRET_KEYS or len(secret_key) < 32: + production = _is_production_env() + runtime_issues: List[str] = [] + if not secret_key or secret_key.lower() in WEAK_SECRET_KEYS or _is_placeholder_config_value(secret_key) or len(secret_key) < 32: + runtime_issues.append("SECRET_KEY 为空、过短或仍为默认占位值") + if not _clean_config_value(settings.database_url) or _is_placeholder_config_value(settings.database_url): + runtime_issues.append("DATABASE_URL 为空或仍包含占位值") + + if runtime_issues: + status_value = "failed" if production else "warning" + detail_prefix = "生产环境启动配置不完整" if production else "当前运行配置需要上线前确认" return _build_check( "runtime_config", "运行配置", - "warning", - "当前 SECRET_KEY 为空、过短或仍为默认占位值,试点前建议替换为环境专用密钥", - required=False, + status_value, + f"{detail_prefix}:{';'.join(runtime_issues)}", + required=production, ) return _build_check("runtime_config", "运行配置", "ok", "关键运行配置已设置", required=False) def _check_object_storage_config() -> Dict[str, Any]: - if not settings.minio_endpoint.strip() or not settings.minio_bucket.strip(): - return _build_check("object_storage_config", "对象存储配置", "failed", "对象存储 endpoint 或 bucket 未配置") + storage_backend = (settings.file_storage_backend or "minio").strip().lower() + production = _is_production_env() + if storage_backend == "local": + local_storage_dir = _clean_config_value(settings.local_storage_dir) + if not local_storage_dir: + return _build_check("object_storage_config", "对象存储配置", "failed", "本地文件存储目录未配置") + return _build_check( + "object_storage_config", + "对象存储配置", + "warning" if production else "ok", + f"当前使用本地文件存储:{local_storage_dir};生产环境建议使用 MinIO 或等价对象存储", + required=False, + ) + + if storage_backend != "minio": + return _build_check( + "object_storage_config", + "对象存储配置", + "failed", + f"未知文件存储后端:{storage_backend}", + ) + + missing_fields: List[str] = [] + placeholder_fields: List[str] = [] + weak_fields: List[str] = [] + minio_config = { + "MINIO_ENDPOINT": settings.minio_endpoint, + "MINIO_BUCKET": settings.minio_bucket, + "MINIO_ACCESS_KEY": settings.minio_access_key, + "MINIO_SECRET_KEY": settings.minio_secret_key, + } + for field_name, value in minio_config.items(): + if not _clean_config_value(value): + missing_fields.append(field_name) + elif _is_placeholder_config_value(value): + placeholder_fields.append(field_name) + if production: + for field_name in ("MINIO_ACCESS_KEY", "MINIO_SECRET_KEY"): + if _is_weak_minio_credential(minio_config[field_name]): + weak_fields.append(field_name) + + if missing_fields or placeholder_fields or weak_fields: + issue_parts: List[str] = [] + if missing_fields: + issue_parts.append(f"缺少 {', '.join(missing_fields)}") + if placeholder_fields: + issue_parts.append(f"{', '.join(placeholder_fields)} 仍为占位值") + if weak_fields: + issue_parts.append(f"{', '.join(weak_fields)} 仍为默认弱凭据") + return _build_check( + "object_storage_config", + "对象存储配置", + "failed", + ";".join(issue_parts), + ) return _build_check( "object_storage_config", @@ -535,6 +638,54 @@ def _check_object_storage_config() -> Dict[str, Any]: ) +def _check_shared_ai_config() -> Dict[str, Any]: + base_url = _clean_config_value(settings.shared_ai_platform_base_url) + api_key = _clean_config_value(settings.shared_ai_platform_api_key) + legal_review_enabled = settings.shared_ai_platform_legal_review_enabled + production = _is_production_env() + base_url_configured = bool(base_url) and not _is_placeholder_config_value(base_url) + api_key_configured = bool(api_key) and not _is_placeholder_config_value(api_key) + + if base_url_configured and api_key_configured: + return _build_check( + "shared_ai_config", + "共享 AI 配置", + "ok", + "共享 AI 远端地址与 API Key 已配置", + required=False, + ) + + if legal_review_enabled and production: + missing_parts = [] + if not base_url_configured: + missing_parts.append("SHARED_AI_PLATFORM_BASE_URL") + if not api_key_configured: + missing_parts.append("SHARED_AI_PLATFORM_API_KEY") + return _build_check( + "shared_ai_config", + "共享 AI 配置", + "failed", + f"生产环境已启用远端法律审查,但 {', '.join(missing_parts)} 未配置或仍为占位值", + ) + + if legal_review_enabled: + return _build_check( + "shared_ai_config", + "共享 AI 配置", + "warning", + "共享 AI 远端法律审查已启用,但远端地址或 API Key 未完整配置;本地开发可继续,生产上线前必须补齐或关闭远端审查", + required=False, + ) + + return _build_check( + "shared_ai_config", + "共享 AI 配置", + "ok", + "共享 AI 远端审查未启用,当前按本地/非远端模式运行", + required=False, + ) + + def _check_default_admin_password(db: Session) -> Dict[str, Any]: try: admin = db.execute(select(User).where(User.username == DEFAULT_ADMIN_USERNAME)).scalar_one_or_none() @@ -896,6 +1047,7 @@ def get_system_readiness(db: Session = Depends(get_db_session)): _check_database(db), _check_runtime_config(), _check_object_storage_config(), + _check_shared_ai_config(), _build_check("auth_seed", "默认管理员初始化", "ok", "默认管理员初始化入口可用", required=False), default_admin_password_check, ] @@ -912,6 +1064,18 @@ def get_system_readiness(db: Session = Depends(get_db_session)): "environment": settings.app_env, "api_prefix": settings.api_v1_prefix, }, + "deployment": { + "app_env": settings.app_env, + "api_prefix": settings.api_v1_prefix, + "file_storage_backend": (settings.file_storage_backend or "minio").strip().lower(), + "shared_ai_remote_configured": bool( + _clean_config_value(settings.shared_ai_platform_base_url) + and _clean_config_value(settings.shared_ai_platform_api_key) + and not _is_placeholder_config_value(settings.shared_ai_platform_base_url) + and not _is_placeholder_config_value(settings.shared_ai_platform_api_key) + ), + "shared_ai_legal_review_enabled": settings.shared_ai_platform_legal_review_enabled, + }, "checks": checks, "summary": { "total": len(checks), diff --git a/backend/app/clients/shared_ai_platform.py b/backend/app/clients/shared_ai_platform.py index 18f99a5a..66a5541c 100644 --- a/backend/app/clients/shared_ai_platform.py +++ b/backend/app/clients/shared_ai_platform.py @@ -1,3 +1,4 @@ +import inspect import json from typing import Any, Dict, Iterable, Mapping, Optional, Protocol @@ -8,6 +9,7 @@ from app.core.config import settings from app.db import get_db_session from app.repositories.conversation_repository import ConversationRepository from app.repositories.message_repository import MessageRepository +from app.services.contract_content_normalizer import normalize_contract_content from shared_ai_platform_sdk import RequestContext as SharedAIRequestContext from shared_ai_platform_sdk import SharedAIPlatformClient as SharedAIPlatformSdkClient from shared_ai_platform_sdk import SharedAIPlatformError @@ -203,8 +205,34 @@ class RemoteSharedAIPlatformClient: return None def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict: - payload = self.sdk.list_knowledge_bases(self._context(current_user), page=page, page_size=page_size) - return self._apply_remote_knowledge_defaults(payload) + context = self._context(current_user) + method = self.sdk.list_knowledge_bases + normalized_page = max(int(page or 1), 1) + normalized_page_size = max(min(int(page_size or 20), 100), 1) + try: + signature = inspect.signature(method) + accepts_pagination = ( + "page" in signature.parameters + or "page_size" in signature.parameters + or any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + ) + except (TypeError, ValueError): + accepts_pagination = True + + if accepts_pagination: + try: + payload = method(context, page=normalized_page, page_size=normalized_page_size) + except TypeError as exc: + if "unexpected keyword argument" not in str(exc): + raise + payload = method(context) + else: + payload = method(context) + return self._paginate_remote_knowledge_bases_payload( + self._apply_remote_knowledge_defaults(payload), + normalized_page, + normalized_page_size, + ) def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict: try: @@ -254,7 +282,14 @@ class RemoteSharedAIPlatformClient: return payload def list_knowledge_base_versions(self, current_user: Dict[str, Any], knowledge_base_id: str) -> list[dict]: - return self.sdk.list_knowledge_base_versions(self._context(current_user), knowledge_base_id) + try: + return self.sdk.list_knowledge_base_versions(self._context(current_user), knowledge_base_id) + except SharedAIPlatformError as exc: + if exc.status_code in (401, 403): + raise + return self._fallback_knowledge_base_versions(current_user, knowledge_base_id, exc) + except Exception as exc: + return self._fallback_knowledge_base_versions(current_user, knowledge_base_id, exc) def publish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict: payload = self.sdk.publish_knowledge_base(self._context(current_user), knowledge_base_id) @@ -386,12 +421,12 @@ class RemoteSharedAIPlatformClient: @staticmethod def _extract_enhanced_contract_content(structured_output: Optional[Dict[str, Any]], raw_content: str) -> str: if not isinstance(structured_output, Mapping): - return raw_content + return normalize_contract_content(raw_content) for key in ("html", "content", "text", "body", "markdown", "document"): value = structured_output.get(key) if isinstance(value, str) and value.strip(): - return value.strip() - return raw_content + return normalize_contract_content(value) + return normalize_contract_content(raw_content) @staticmethod def _extract_structured_output(raw_content: str) -> Optional[Dict[str, Any]]: @@ -422,12 +457,65 @@ class RemoteSharedAIPlatformClient: items.sort(key=self._knowledge_base_sort_key, reverse=True) return payload + def _paginate_remote_knowledge_bases_payload(self, payload: dict, page: int, page_size: int) -> dict: + items = payload.get("items") if isinstance(payload, Mapping) else None + if not isinstance(items, list): + return payload + + normalized = dict(payload) + total = int(normalized.get("total") or len(items)) + source_page_size = int(normalized.get("page_size") or len(items) or page_size) + source_page = int(normalized.get("page") or 1) + + if source_page == page and source_page_size == page_size: + normalized["total"] = total + normalized["page"] = page + normalized["page_size"] = page_size + return normalized + + if total == len(items): + start = (page - 1) * page_size + normalized["items"] = items[start : start + page_size] + normalized["total"] = total + normalized["page"] = page + normalized["page_size"] = page_size + return normalized + @staticmethod def _knowledge_base_sort_key(item: Any) -> str: if not isinstance(item, Mapping): return "" return str(item.get("updated_at") or item.get("created_at") or "") + def _fallback_knowledge_base_versions(self, current_user: Dict[str, Any], knowledge_base_id: str, exc: Exception) -> list[dict]: + try: + payload = self.list_knowledge_bases(current_user, 1, 100) + except Exception as list_exc: + raise HTTPException(status_code=503, detail=self._build_knowledge_base_versions_error_message(exc)) from list_exc + + items = payload.get("items") if isinstance(payload, Mapping) else [] + knowledge_base = next((item for item in items if isinstance(item, Mapping) and item.get("id") == knowledge_base_id), None) + if not knowledge_base: + raise HTTPException(status_code=404, detail="知识库不存在或无权访问") + + version_label = str(knowledge_base.get("current_version_label") or "current") + timestamp = knowledge_base.get("published_at") or knowledge_base.get("updated_at") or knowledge_base.get("created_at") + return [ + { + "id": f"{knowledge_base_id}-{version_label}", + "knowledge_base_id": knowledge_base_id, + "version_label": version_label, + "name": knowledge_base.get("name") or "", + "description": knowledge_base.get("description"), + "retrieval_mode": knowledge_base.get("retrieval_mode") or "hybrid", + "status": knowledge_base.get("status") or "active", + "published_at": knowledge_base.get("published_at"), + "published_by": knowledge_base.get("published_by"), + "change_summary": self._build_knowledge_base_versions_error_message(exc), + "created_at": timestamp, + } + ] + def _normalize_remote_knowledge_documents_payload(self, payload: dict) -> dict: items = payload.get("items") if not isinstance(items, list): @@ -533,6 +621,13 @@ class RemoteSharedAIPlatformClient: return f"共享 AI 会话历史暂时不可用,已切换为本地历史记录。原因:{detail[:200]}" return "共享 AI 会话历史暂时不可用,已切换为本地历史记录。" + @staticmethod + def _build_knowledge_base_versions_error_message(exc: Exception) -> str: + detail = str(exc).strip() + if detail: + return f"共享 AI 版本历史暂时不可用,已展示当前知识库快照。原因:{detail[:200]}" + return "共享 AI 版本历史暂时不可用,已展示当前知识库快照。" + def get_shared_ai_platform_client(db: Session = Depends(get_db_session)) -> SharedAIPlatformClient: diff --git a/backend/app/core/contract_permissions.py b/backend/app/core/contract_permissions.py new file mode 100644 index 00000000..742d0870 --- /dev/null +++ b/backend/app/core/contract_permissions.py @@ -0,0 +1,118 @@ +from typing import Any, Optional + +from app.core.roles import ROLE_ADMIN, ROLE_BUSINESS, ROLE_LEGAL, ROLE_MEMBER, ROLE_OBSERVER + + +ROLE_BUSINESS_SUBMITTER = ROLE_BUSINESS +ROLE_APPROVER = "approver" + +CONTRACT_DOMAIN_ROLES = { + ROLE_ADMIN, + ROLE_LEGAL, + ROLE_BUSINESS_SUBMITTER, + ROLE_MEMBER, + ROLE_APPROVER, + ROLE_OBSERVER, +} + + +def normalize_contract_role(role: Optional[str]) -> str: + normalized = (role or "").strip().lower() + if normalized == ROLE_MEMBER: + return ROLE_BUSINESS_SUBMITTER + if normalized in CONTRACT_DOMAIN_ROLES: + return normalized + return ROLE_BUSINESS_SUBMITTER + + +def is_admin_or_legal(current_user: Optional[dict[str, Any]]) -> bool: + return normalize_contract_role((current_user or {}).get("role")) in {ROLE_ADMIN, ROLE_LEGAL} + + +def is_observer(current_user: Optional[dict[str, Any]]) -> bool: + return normalize_contract_role((current_user or {}).get("role")) == ROLE_OBSERVER + + +def can_view_all_contracts(current_user: Optional[dict[str, Any]]) -> bool: + return normalize_contract_role((current_user or {}).get("role")) in {ROLE_ADMIN, ROLE_LEGAL, ROLE_OBSERVER} + + +def can_view_contract( + current_user: Optional[dict[str, Any]], + *, + generated_by: Optional[str], + participant_contract_ids: Optional[list[str]] = None, + contract_id: Optional[str] = None, +) -> bool: + if current_user is None: + return False + if can_view_all_contracts(current_user): + return True + user_id = str(current_user.get("id") or "") + if user_id and str(generated_by or "") == user_id: + return True + return bool(contract_id and contract_id in (participant_contract_ids or [])) + + +def can_create_contract(current_user: Optional[dict[str, Any]]) -> bool: + return not is_observer(current_user) + + +def can_update_contract(current_user: Optional[dict[str, Any]], *, generated_by: Optional[str]) -> bool: + if current_user is None or is_observer(current_user): + return False + if is_admin_or_legal(current_user): + return True + return str(generated_by or "") == str(current_user.get("id") or "") + + +def can_submit_contract_approval(current_user: Optional[dict[str, Any]], *, generated_by: Optional[str]) -> bool: + return can_update_contract(current_user, generated_by=generated_by) + + +def can_decide_contract_approval( + current_user: Optional[dict[str, Any]], + *, + assignee_id: Optional[str], +) -> bool: + if current_user is None or is_observer(current_user): + return False + if is_admin_or_legal(current_user): + return True + return bool(assignee_id and str(assignee_id) == str(current_user.get("id") or "")) + + +def can_comment_on_contract( + current_user: Optional[dict[str, Any]], + *, + generated_by: Optional[str], + participant_contract_ids: Optional[list[str]] = None, + contract_id: Optional[str] = None, +) -> bool: + if current_user is None or is_observer(current_user): + return False + return can_view_contract( + current_user, + generated_by=generated_by, + participant_contract_ids=participant_contract_ids, + contract_id=contract_id, + ) + + +def can_download_contract( + current_user: Optional[dict[str, Any]], + *, + generated_by: Optional[str], + participant_contract_ids: Optional[list[str]] = None, + contract_id: Optional[str] = None, +) -> bool: + if current_user is None or is_observer(current_user): + return False + if is_admin_or_legal(current_user): + return True + return can_view_contract( + current_user, + generated_by=generated_by, + participant_contract_ids=participant_contract_ids, + contract_id=contract_id, + ) diff --git a/backend/app/core/roles.py b/backend/app/core/roles.py index 552268f1..55e5f380 100644 --- a/backend/app/core/roles.py +++ b/backend/app/core/roles.py @@ -2,12 +2,14 @@ ROLE_ADMIN = "admin" ROLE_LEGAL = "legal" ROLE_BUSINESS = "business" ROLE_MEMBER = "member" +ROLE_OBSERVER = "observer" SUPPORTED_USER_ROLES = { ROLE_ADMIN, ROLE_LEGAL, ROLE_BUSINESS, ROLE_MEMBER, + ROLE_OBSERVER, } DEFAULT_SELF_REGISTER_ROLE = ROLE_BUSINESS diff --git a/backend/app/main.py b/backend/app/main.py index f66ffee3..b0d24247 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,6 +17,7 @@ from app.api.legal_change_impacts import router as legal_change_impacts_router from app.api.legal_document_drafts import router as legal_document_drafts_router from app.api.legal_document_types import router as legal_document_types_router from app.api.model_settings import router as model_settings_router +from app.api.notifications import router as notifications_router from app.api.operation_kpis import router as operation_kpis_router from app.api.pilot_requests import router as pilot_requests_router from app.api.quality_evals import router as quality_evals_router @@ -24,6 +25,7 @@ from app.api.prompt_assets import router as prompt_assets_router from app.api.review_rules import router as review_rules_router from app.api.reviews import router as reviews_router from app.api.sales_followups import router as sales_followups_router +from app.api.search import router as search_router from app.api.standard_clauses import router as standard_clauses_router from app.api.system import router as system_router from app.api.templates import router as templates_router @@ -58,11 +60,13 @@ app.include_router(legal_change_impacts_router, prefix=settings.api_v1_prefix) app.include_router(legal_document_drafts_router, prefix=settings.api_v1_prefix) app.include_router(legal_document_types_router, prefix=settings.api_v1_prefix) app.include_router(model_settings_router, prefix=settings.api_v1_prefix) +app.include_router(notifications_router, prefix=settings.api_v1_prefix) app.include_router(operation_kpis_router, prefix=settings.api_v1_prefix) app.include_router(pilot_requests_router, prefix=settings.api_v1_prefix) app.include_router(quality_evals_router, prefix=settings.api_v1_prefix) app.include_router(prompt_assets_router, prefix=settings.api_v1_prefix) app.include_router(review_rules_router, prefix=settings.api_v1_prefix) +app.include_router(search_router, prefix=settings.api_v1_prefix) app.include_router(templates_router, prefix=settings.api_v1_prefix) app.include_router(sales_followups_router, prefix=settings.api_v1_prefix) app.include_router(standard_clauses_router, prefix=settings.api_v1_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f8e94507..4a610fcb 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,6 +10,7 @@ from app.models.contract_lifecycle_task import ContractLifecycleTask from app.models.file import File from app.models.generated_contract import GeneratedContract from app.models.generated_contract_version import GeneratedContractVersion +from app.models.generated_contract_version_comment import GeneratedContractVersionComment from app.models.ai_quality_eval_item import AiQualityEvalItem from app.models.ai_quality_eval_run import AiQualityEvalRun from app.models.governance_asset import GovernanceAsset diff --git a/backend/app/models/generated_contract_version_comment.py b/backend/app/models/generated_contract_version_comment.py new file mode 100644 index 00000000..c44ddbed --- /dev/null +++ b/backend/app/models/generated_contract_version_comment.py @@ -0,0 +1,42 @@ +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 GeneratedContractVersionComment(Base): + __tablename__ = "generated_contract_version_comments" + __table_args__ = ( + Index("ix_generated_contract_version_comments_tenant_contract", "tenant_id", "contract_id"), + Index("ix_generated_contract_version_comments_tenant_status", "tenant_id", "status"), + Index("ix_generated_contract_version_comments_version_pair", "tenant_id", "contract_id", "from_version_id", "to_version_id"), + Index("ix_generated_contract_version_comments_tenant_diff_key", "tenant_id", "contract_id", "diff_key"), + ) + + 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) + from_version_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + to_version_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + diff_key: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + change_type: Mapped[str] = mapped_column(String(32), nullable=False, default="modified", index=True) + old_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + new_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + comment: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="open", index=True) + source: Mapped[str] = mapped_column(String(64), nullable=False, default="version_diff", index=True) + created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + created_by_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + resolved_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + resolved_by_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + resolved_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/audit_log_repository.py b/backend/app/repositories/audit_log_repository.py index 5817702f..6acff58b 100644 --- a/backend/app/repositories/audit_log_repository.py +++ b/backend/app/repositories/audit_log_repository.py @@ -1,5 +1,6 @@ from typing import List, Optional +from sqlalchemy import func, or_ from sqlalchemy.orm import Session from app.models.audit_log import AuditLog @@ -21,18 +22,72 @@ class AuditLogRepository: page: int, page_size: int, action: Optional[str] = None, + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + contract_id: Optional[str] = None, ) -> List[AuditLog]: - query = self.db.query(AuditLog) - if tenant_id: - query = query.filter(AuditLog.tenant_id == tenant_id) - if action: - query = query.filter(AuditLog.action == action) + query = self._build_query(tenant_id, action, resource_type, resource_id, contract_id) return query.order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() - def count(self, tenant_id: Optional[str], action: Optional[str] = None) -> int: + def count( + self, + tenant_id: Optional[str], + action: Optional[str] = None, + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + contract_id: Optional[str] = None, + ) -> int: + return self._build_query(tenant_id, action, resource_type, resource_id, contract_id).count() + + def list_recent( + self, + tenant_id: Optional[str], + *, + limit: int = 1000, + action: Optional[str] = None, + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + contract_id: Optional[str] = None, + ) -> List[AuditLog]: + query = self._build_query(tenant_id, action, resource_type, resource_id, contract_id) + return query.order_by(AuditLog.created_at.desc()).limit(limit).all() + + def count_by_action( + self, + tenant_id: Optional[str], + *, + contract_id: Optional[str] = None, + ) -> dict[str, int]: + rows = ( + self._build_query(tenant_id, contract_id=contract_id) + .with_entities(AuditLog.action, func.count(AuditLog.id)) + .group_by(AuditLog.action) + .all() + ) + return {action: count for action, count in rows} + + def _build_query( + self, + tenant_id: Optional[str], + action: Optional[str] = None, + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + contract_id: Optional[str] = None, + ): query = self.db.query(AuditLog) if tenant_id: query = query.filter(AuditLog.tenant_id == tenant_id) if action: query = query.filter(AuditLog.action == action) - return query.count() + if resource_type: + query = query.filter(AuditLog.resource_type == resource_type) + if resource_id: + query = query.filter(AuditLog.resource_id == resource_id) + if contract_id: + query = query.filter( + or_( + AuditLog.resource_id == contract_id, + AuditLog.detail_json["contract_id"].as_string() == contract_id, + ) + ) + return query diff --git a/backend/app/repositories/contract_approval_step_repository.py b/backend/app/repositories/contract_approval_step_repository.py index e980a019..2c1e9c73 100644 --- a/backend/app/repositories/contract_approval_step_repository.py +++ b/backend/app/repositories/contract_approval_step_repository.py @@ -66,6 +66,24 @@ class ContractApprovalStepRepository: .all() ) + def list_contract_ids_by_participant(self, tenant_id: str, participant_id: str) -> list[str]: + if not participant_id: + return [] + rows = ( + self.db.query(ContractApprovalStep.contract_id) + .filter( + ContractApprovalStep.tenant_id == tenant_id, + or_( + ContractApprovalStep.assignee_id == participant_id, + ContractApprovalStep.submitter_id == participant_id, + ContractApprovalStep.decided_by == participant_id, + ), + ) + .distinct() + .all() + ) + return [row[0] for row in rows if row[0]] + def list_paginated( self, tenant_id: str, diff --git a/backend/app/repositories/generated_contract_repository.py b/backend/app/repositories/generated_contract_repository.py index 784edc19..74b02a63 100644 --- a/backend/app/repositories/generated_contract_repository.py +++ b/backend/app/repositories/generated_contract_repository.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import List, Optional from sqlalchemy import or_ @@ -40,45 +41,126 @@ class GeneratedContractRepository: page: int, page_size: int, generated_by: Optional[str] = None, + participant_contract_ids: Optional[List[str]] = None, contract_type: Optional[str] = None, document_type_code: Optional[str] = None, lifecycle_status: Optional[str] = None, keyword: Optional[str] = None, + counterparty_name: Optional[str] = None, + owner_name: Optional[str] = None, + effective_from: Optional[datetime] = None, + effective_to: Optional[datetime] = None, + expires_from: Optional[datetime] = None, + expires_to: Optional[datetime] = None, ) -> List[GeneratedContract]: query = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id) - if generated_by: - query = query.filter(GeneratedContract.generated_by == generated_by) - if contract_type: - query = query.filter(GeneratedContract.contract_type == contract_type) - if document_type_code: - query = query.filter(GeneratedContract.document_type_code == document_type_code) - if lifecycle_status: - query = query.filter(GeneratedContract.lifecycle_status == lifecycle_status) - if keyword: - keyword_like = f"%{keyword.strip()}%" - query = query.filter( - or_( - GeneratedContract.template_name.ilike(keyword_like), - GeneratedContract.file_name.ilike(keyword_like), - GeneratedContract.generated_by_name.ilike(keyword_like), - GeneratedContract.counterparty_name.ilike(keyword_like), - GeneratedContract.owner_name.ilike(keyword_like), - ) - ) + query = self._apply_filters( + query, + generated_by=generated_by, + participant_contract_ids=participant_contract_ids, + contract_type=contract_type, + document_type_code=document_type_code, + lifecycle_status=lifecycle_status, + keyword=keyword, + counterparty_name=counterparty_name, + owner_name=owner_name, + effective_from=effective_from, + effective_to=effective_to, + expires_from=expires_from, + expires_to=expires_to, + ) return query.order_by(GeneratedContract.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + def list_filtered( + self, + tenant_id: str, + generated_by: Optional[str] = None, + participant_contract_ids: Optional[List[str]] = None, + contract_type: Optional[str] = None, + document_type_code: Optional[str] = None, + lifecycle_status: Optional[str] = None, + keyword: Optional[str] = None, + counterparty_name: Optional[str] = None, + owner_name: Optional[str] = None, + effective_from: Optional[datetime] = None, + effective_to: Optional[datetime] = None, + expires_from: Optional[datetime] = None, + expires_to: Optional[datetime] = None, + limit: Optional[int] = None, + ) -> List[GeneratedContract]: + query = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id) + query = self._apply_filters( + query, + generated_by=generated_by, + participant_contract_ids=participant_contract_ids, + contract_type=contract_type, + document_type_code=document_type_code, + lifecycle_status=lifecycle_status, + keyword=keyword, + counterparty_name=counterparty_name, + owner_name=owner_name, + effective_from=effective_from, + effective_to=effective_to, + expires_from=expires_from, + expires_to=expires_to, + ) + query = query.order_by(GeneratedContract.created_at.desc()) + if limit is not None: + query = query.limit(limit) + return query.all() + def count( self, tenant_id: str, generated_by: Optional[str] = None, + participant_contract_ids: Optional[List[str]] = None, contract_type: Optional[str] = None, document_type_code: Optional[str] = None, lifecycle_status: Optional[str] = None, keyword: Optional[str] = None, + counterparty_name: Optional[str] = None, + owner_name: Optional[str] = None, + effective_from: Optional[datetime] = None, + effective_to: Optional[datetime] = None, + expires_from: Optional[datetime] = None, + expires_to: Optional[datetime] = None, ) -> int: query = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id) - if generated_by: - query = query.filter(GeneratedContract.generated_by == generated_by) + query = self._apply_filters( + query, + generated_by=generated_by, + participant_contract_ids=participant_contract_ids, + contract_type=contract_type, + document_type_code=document_type_code, + lifecycle_status=lifecycle_status, + keyword=keyword, + counterparty_name=counterparty_name, + owner_name=owner_name, + effective_from=effective_from, + effective_to=effective_to, + expires_from=expires_from, + expires_to=expires_to, + ) + return query.count() + + def _apply_filters( + self, + query, + *, + generated_by: Optional[str], + participant_contract_ids: Optional[List[str]], + contract_type: Optional[str], + document_type_code: Optional[str], + lifecycle_status: Optional[str], + keyword: Optional[str], + counterparty_name: Optional[str], + owner_name: Optional[str], + effective_from: Optional[datetime], + effective_to: Optional[datetime], + expires_from: Optional[datetime], + expires_to: Optional[datetime], + ): + query = self._apply_visibility_filter(query, generated_by, participant_contract_ids) if contract_type: query = query.filter(GeneratedContract.contract_type == contract_type) if document_type_code: @@ -94,6 +176,41 @@ class GeneratedContractRepository: GeneratedContract.generated_by_name.ilike(keyword_like), GeneratedContract.counterparty_name.ilike(keyword_like), GeneratedContract.owner_name.ilike(keyword_like), + GeneratedContract.lifecycle_note.ilike(keyword_like), ) ) - return query.count() + if counterparty_name: + query = query.filter(GeneratedContract.counterparty_name.ilike(f"%{counterparty_name.strip()}%")) + if owner_name: + owner_like = f"%{owner_name.strip()}%" + query = query.filter( + or_( + GeneratedContract.owner_name.ilike(owner_like), + GeneratedContract.generated_by_name.ilike(owner_like), + ) + ) + if effective_from is not None: + query = query.filter(GeneratedContract.effective_at >= effective_from) + if effective_to is not None: + query = query.filter(GeneratedContract.effective_at <= effective_to) + if expires_from is not None: + query = query.filter(GeneratedContract.expires_at >= expires_from) + if expires_to is not None: + query = query.filter(GeneratedContract.expires_at <= expires_to) + return query + + @staticmethod + def _apply_visibility_filter(query, generated_by: Optional[str], participant_contract_ids: Optional[List[str]]): + participant_contract_ids = participant_contract_ids or [] + if generated_by and participant_contract_ids: + return query.filter( + or_( + GeneratedContract.generated_by == generated_by, + GeneratedContract.id.in_(participant_contract_ids), + ) + ) + if generated_by: + return query.filter(GeneratedContract.generated_by == generated_by) + if participant_contract_ids: + return query.filter(GeneratedContract.id.in_(participant_contract_ids)) + return query diff --git a/backend/app/repositories/generated_contract_version_comment_repository.py b/backend/app/repositories/generated_contract_version_comment_repository.py new file mode 100644 index 00000000..8a4c2fee --- /dev/null +++ b/backend/app/repositories/generated_contract_version_comment_repository.py @@ -0,0 +1,61 @@ +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.generated_contract_version_comment import GeneratedContractVersionComment + + +class GeneratedContractVersionCommentRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, comment: GeneratedContractVersionComment) -> GeneratedContractVersionComment: + self.db.add(comment) + self.db.commit() + self.db.refresh(comment) + return comment + + def update(self, comment: GeneratedContractVersionComment) -> GeneratedContractVersionComment: + self.db.add(comment) + self.db.commit() + self.db.refresh(comment) + return comment + + def get_by_id(self, tenant_id: str, contract_id: str, comment_id: str) -> Optional[GeneratedContractVersionComment]: + return ( + self.db.query(GeneratedContractVersionComment) + .filter( + GeneratedContractVersionComment.tenant_id == tenant_id, + GeneratedContractVersionComment.contract_id == contract_id, + GeneratedContractVersionComment.id == comment_id, + ) + .first() + ) + + def list_by_contract( + self, + tenant_id: str, + contract_id: str, + *, + from_version_id: Optional[str] = None, + to_version_id: Optional[str] = None, + status_value: Optional[str] = None, + ) -> list[GeneratedContractVersionComment]: + query = self.db.query(GeneratedContractVersionComment).filter( + GeneratedContractVersionComment.tenant_id == tenant_id, + GeneratedContractVersionComment.contract_id == contract_id, + ) + if from_version_id is not None: + query = query.filter(GeneratedContractVersionComment.from_version_id == self._normalize_version_id(from_version_id)) + if to_version_id is not None: + query = query.filter(GeneratedContractVersionComment.to_version_id == self._normalize_version_id(to_version_id)) + if status_value: + query = query.filter(GeneratedContractVersionComment.status == status_value) + return query.order_by(GeneratedContractVersionComment.created_at.desc()).all() + + @staticmethod + def _normalize_version_id(value: Optional[str]) -> Optional[str]: + if value is None: + return None + normalized = str(value).strip() + return None if normalized.lower() in {"", "base", "v1", "original"} else normalized diff --git a/backend/app/services/audit_log_service.py b/backend/app/services/audit_log_service.py index 8b282b72..1c30f193 100644 --- a/backend/app/services/audit_log_service.py +++ b/backend/app/services/audit_log_service.py @@ -4,8 +4,11 @@ from uuid import uuid4 from fastapi import HTTPException, status from sqlalchemy.orm import Session +from app.core.contract_permissions import can_view_all_contracts, can_view_contract from app.models.audit_log import AuditLog from app.repositories.audit_log_repository import AuditLogRepository +from app.repositories.contract_approval_step_repository import ContractApprovalStepRepository +from app.repositories.generated_contract_repository import GeneratedContractRepository from app.repositories.user_repository import UserRepository @@ -14,6 +17,8 @@ class AuditLogService: self.db = db self.repository = AuditLogRepository(db) self.user_repository = UserRepository(db) + self.generated_contract_repository = GeneratedContractRepository(db) + self.contract_approval_step_repository = ContractApprovalStepRepository(db) def record_event( self, @@ -62,6 +67,62 @@ class AuditLogService: "total": total, } + def list_contract_timeline( + self, + current_user: Dict[str, Any], + contract_id: str, + page: int = 1, + page_size: int = 50, + ) -> dict: + self._ensure_contract_visible(current_user, contract_id) + + normalized_page = max(page, 1) + normalized_page_size = max(min(page_size, 100), 1) + tenant_id = current_user.get("tenant_id") + page_logs = self.repository.list_paginated( + tenant_id, + normalized_page, + normalized_page_size, + contract_id=contract_id, + ) + total = self.repository.count(tenant_id, contract_id=contract_id) + by_action = self.repository.count_by_action(tenant_id, contract_id=contract_id) + items = [self._to_item(log) for log in page_logs] + return { + "contract_id": contract_id, + "items": items, + "page": normalized_page, + "page_size": normalized_page_size, + "total": total, + "summary": { + "total": total, + "by_action": by_action, + "latest_at": items[0]["created_at"] if items else None, + }, + } + + def _ensure_contract_visible(self, current_user: Dict[str, Any], contract_id: str) -> None: + tenant_id = current_user.get("tenant_id") + contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id) + if contract is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found") + + current_user_id = str(current_user.get("id") or "") + participant_contract_ids = ( + None + if can_view_all_contracts(current_user) + else self.contract_approval_step_repository.list_contract_ids_by_participant(tenant_id, current_user_id) + ) + if can_view_contract( + current_user, + generated_by=contract.generated_by, + participant_contract_ids=participant_contract_ids, + contract_id=contract_id, + ): + return + + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found") + def _to_item(self, audit_log: AuditLog) -> dict: operator = None if audit_log.user_id: diff --git a/backend/app/services/contract_approval_service.py b/backend/app/services/contract_approval_service.py index 7cc02cbf..dc618f3e 100644 --- a/backend/app/services/contract_approval_service.py +++ b/backend/app/services/contract_approval_service.py @@ -5,6 +5,12 @@ from uuid import uuid4 from fastapi import HTTPException, status from sqlalchemy.orm import Session +from app.core.contract_permissions import ( + can_decide_contract_approval, + can_submit_contract_approval, + can_view_all_contracts, + can_view_contract, +) from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, ValidationError from app.models.contract_approval_step import ContractApprovalStep from app.models.generated_contract import GeneratedContract @@ -61,7 +67,7 @@ class ContractApprovalService: 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") + can_view_all = can_view_all_contracts(current_user) assignee_id = None submitter_id = None @@ -72,7 +78,7 @@ class ContractApprovalService: submitter_id = user_id elif normalized_scope == "participated": participant_id = user_id - elif normalized_scope == "all" and role != "admin": + elif normalized_scope == "all" and not can_view_all: participant_id = user_id items = self.approval_repository.list_paginated( @@ -93,11 +99,11 @@ class ContractApprovalService: ) return { - "items": [self._to_approval_item(item) for item in items], + "items": [self._to_approval_item(item, current_user=current_user) for item in items], "total": total, "page": normalized_page, "page_size": normalized_page_size, - "summary": self._build_summary(tenant_id, user_id, role), + "summary": self._build_summary(tenant_id, user_id, can_view_all), } def list_contract_approvals(self, current_user: dict[str, Any], contract_id: str) -> dict[str, Any]: @@ -105,9 +111,9 @@ class ContractApprovalService: 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], + "items": [self._to_approval_item(item, contract=contract, current_user=current_user) for item in items], "total": len(items), - "latest": self._to_approval_item(items[0], contract=contract) if items else None, + "latest": self._to_approval_item(items[0], contract=contract, current_user=current_user) if items else None, } def submit_approval( @@ -121,7 +127,7 @@ class ContractApprovalService: assignee_name: Optional[str] = None, step_type: str = "legal_review", ) -> dict[str, Any]: - contract = self._get_owned_or_admin_contract(current_user, contract_id) + contract = self._get_contract_for_submit(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") @@ -149,7 +155,7 @@ class ContractApprovalService: ) 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) + return self._to_approval_item(created, contract=contract, current_user=current_user) def decide_approval( self, @@ -171,9 +177,13 @@ class ContractApprovalService: raise ForbiddenError("no permission to decide this approval") normalized_decision = self._normalize_decision(decision) + normalized_comment = self._optional_text(comment, max_length=2000) + if normalized_decision == "reject" and not normalized_comment: + raise ValidationError("reject reason is required") + 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.comment = normalized_comment 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 @@ -189,25 +199,32 @@ class ContractApprovalService: else: self._update_contract_lifecycle(contract, "generated", current_user, updated.comment or "审批已退回,请修改后重新提交。") - return self._to_approval_item(updated, contract=contract) + return self._to_approval_item(updated, contract=contract, current_user=current_user) 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 "")}: + participant_contract_ids = ( + None + if can_view_all_contracts(current_user) + else self.approval_repository.list_contract_ids_by_participant(current_user["tenant_id"], user_id) + ) + if can_view_contract( + current_user, + generated_by=contract.generated_by, + participant_contract_ids=participant_contract_ids, + contract_id=contract_id, + ): return contract raise NotFoundError("contract not found") - def _get_owned_or_admin_contract(self, current_user: dict[str, Any], contract_id: str) -> GeneratedContract: + def _get_contract_for_submit(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 ""): + if not can_submit_contract_approval(current_user, generated_by=contract.generated_by): raise NotFoundError("contract not found") return contract @@ -224,7 +241,12 @@ class ContractApprovalService: 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]: + def _to_approval_item( + self, + step: ContractApprovalStep, + contract: Optional[GeneratedContract] = None, + current_user: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: resolved_contract = contract or self.contract_repository.get_by_id(step.contract_id, step.tenant_id) return { "id": step.id, @@ -246,6 +268,9 @@ class ContractApprovalService: "metadata": step.metadata_json or {}, "created_at": self._serialize_optional_datetime(step.created_at), "updated_at": self._serialize_optional_datetime(step.updated_at), + "timeline": self._build_step_timeline(step), + "audit_key": f"contract_approval:{step.id}", + "can_decide": self._can_decide(current_user, step) if current_user is not None else False, } @staticmethod @@ -263,8 +288,8 @@ class ContractApprovalService: "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 + def _build_summary(self, tenant_id: str, user_id: str, can_view_all: bool) -> dict[str, int]: + participant_id = None if can_view_all else user_id pending_steps = self.approval_repository.list_pending_for_summary(tenant_id, participant_id=participant_id) return { "pending_all": self.approval_repository.count(tenant_id, status_value="pending", participant_id=participant_id), @@ -306,12 +331,52 @@ class ContractApprovalService: "calculated_at": cls._serialize_optional_datetime(now), } + def _build_step_timeline(self, step: ContractApprovalStep) -> list[dict[str, Any]]: + timeline = [ + { + "key": f"{step.id}:submitted", + "event": "submitted", + "status": "completed", + "title": "提交审批", + "description": step.comment, + "actor_id": step.submitter_id, + "actor_name": step.submitter_name, + "occurred_at": self._serialize_optional_datetime(step.created_at), + } + ] + if step.status == "pending": + timeline.append( + { + "key": f"{step.id}:pending", + "event": "pending", + "status": "pending", + "title": "等待处理", + "description": f"待 {step.assignee_name or '审批人'} 处理", + "actor_id": step.assignee_id, + "actor_name": step.assignee_name, + "occurred_at": None, + } + ) + elif step.status in {"approved", "rejected"}: + timeline.append( + { + "key": f"{step.id}:decision", + "event": "approved" if step.status == "approved" else "rejected", + "status": "completed" if step.status == "approved" else "rejected", + "title": "审批通过" if step.status == "approved" else "审批退回", + "description": step.comment, + "actor_id": step.decided_by, + "actor_name": step.decided_by_name, + "occurred_at": self._serialize_optional_datetime(step.decided_at), + } + ) + return timeline + @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) + if step.status != "pending": + return False + return can_decide_contract_approval(current_user, assignee_id=step.assignee_id) @staticmethod def _approval_title(title: Optional[str], contract: GeneratedContract) -> str: @@ -319,7 +384,7 @@ class ContractApprovalService: return normalized or f"合同审批:{contract.template_name}" def _default_assignee_id(self, current_user: dict[str, Any], contract: GeneratedContract) -> Optional[str]: - if current_user.get("role") == "admin": + if can_view_all_contracts(current_user): return str(current_user.get("id") or "") admin = self.user_repository.get_first_active_admin(current_user["tenant_id"]) if admin is not None: @@ -327,7 +392,7 @@ class ContractApprovalService: return str(contract.generated_by or current_user.get("id") or "") def _default_assignee_name(self, current_user: dict[str, Any], contract: GeneratedContract) -> Optional[str]: - if current_user.get("role") == "admin": + if can_view_all_contracts(current_user): return current_user.get("display_name") admin = self.user_repository.get_first_active_admin(current_user["tenant_id"]) if admin is not None: diff --git a/backend/app/services/contract_content_normalizer.py b/backend/app/services/contract_content_normalizer.py new file mode 100644 index 00000000..9b2571a8 --- /dev/null +++ b/backend/app/services/contract_content_normalizer.py @@ -0,0 +1,139 @@ +import json +import re +from html.parser import HTMLParser +from typing import Any, Mapping, Optional + + +class ContractHTMLTextExtractor(HTMLParser): + _BLOCK_TAGS = { + "address", + "article", + "aside", + "blockquote", + "br", + "dd", + "div", + "dl", + "dt", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "ul", + } + _SKIP_TAGS = {"script", "style"} + + def __init__(self): + super().__init__(convert_charrefs=True) + self._parts: list[str] = [] + self._skip_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]): + normalized = tag.lower() + if normalized in self._SKIP_TAGS: + self._skip_depth += 1 + return + if normalized in self._BLOCK_TAGS: + self._append_break() + + def handle_endtag(self, tag: str): + normalized = tag.lower() + if normalized in self._SKIP_TAGS and self._skip_depth > 0: + self._skip_depth -= 1 + return + if normalized in self._BLOCK_TAGS: + self._append_break() + + def handle_data(self, data: str): + if self._skip_depth or not data: + return + self._parts.append(data) + + def get_text(self) -> str: + text = "".join(self._parts) + text = re.sub(r"[ \t\r\f\v]+", " ", text) + text = re.sub(r" *\n *", "\n", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + def _append_break(self): + if self._parts and not str(self._parts[-1]).endswith("\n"): + self._parts.append("\n") + + +def normalize_contract_content(value: Any) -> str: + if isinstance(value, Mapping): + return _extract_mapping_contract_content(value) + + text = str(value or "").strip() + if not text: + return "" + + parsed = parse_contract_json_text(text) + if isinstance(parsed, Mapping): + return _extract_mapping_contract_content(parsed) or text + + if looks_like_html(text): + stripped = html_to_text(text) + return stripped or text + + return text + + +def parse_contract_json_text(value: str) -> Optional[Mapping[str, Any]]: + cleaned = str(value or "").strip() + if not cleaned: + return None + if cleaned.startswith("```"): + cleaned = cleaned.removeprefix("```json").removeprefix("```").strip() + if cleaned.endswith("```"): + cleaned = cleaned[:-3].strip() + if not cleaned.startswith("{"): + return None + try: + parsed = json.loads(cleaned) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, Mapping) else None + + +def html_to_text(value: str) -> str: + parser = ContractHTMLTextExtractor() + parser.feed(str(value or "")) + parser.close() + return parser.get_text() + + +def looks_like_html(value: str) -> bool: + return bool(re.search(r"]*>", str(value or ""))) + + +def _extract_mapping_contract_content(payload: Mapping[str, Any]) -> str: + for key in ("content", "text", "body", "markdown", "document", "html"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return normalize_contract_content(value) + return "" diff --git a/backend/app/services/contract_generation_service.py b/backend/app/services/contract_generation_service.py index 7f536120..f0956dab 100644 --- a/backend/app/services/contract_generation_service.py +++ b/backend/app/services/contract_generation_service.py @@ -3,6 +3,8 @@ import re import io import base64 import difflib +import hashlib +import csv from html import escape from typing import Any, Dict, List, Mapping, Optional, Tuple from datetime import datetime, timedelta, timezone @@ -11,6 +13,17 @@ from enum import Enum from urllib.parse import quote from fastapi import HTTPException, status +from app.core.contract_permissions import ( + can_comment_on_contract, + can_create_contract, + can_decide_contract_approval, + can_download_contract, + can_submit_contract_approval, + can_update_contract, + can_view_all_contracts, + can_view_contract, + is_observer, +) from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, ValidationError from sqlalchemy.orm import Session @@ -18,13 +31,16 @@ from app.clients.shared_ai_platform import SharedAIPlatformClient, build_shared_ from app.models.file import File from app.models.generated_contract import GeneratedContract from app.models.generated_contract_version import GeneratedContractVersion +from app.models.generated_contract_version_comment import GeneratedContractVersionComment 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 +from app.repositories.generated_contract_version_comment_repository import GeneratedContractVersionCommentRepository from app.repositories.generated_contract_version_repository import GeneratedContractVersionRepository from app.services.file_service import FileService +from app.services.contract_content_normalizer import normalize_contract_content from app.services.contract_template_service import ContractTemplateService from app.services.contract_export_service import ContractExportService from app.services.contract_lifecycle_service import ContractLifecycleService @@ -60,6 +76,7 @@ CONTRACT_LIFECYCLE_STATUSES = { CONTRACT_LIFECYCLE_TASK_TYPES = { "renewal_reminder", "performance_milestone", + "signoff", "termination_archive", "custom", } @@ -86,6 +103,7 @@ class ContractGenerationService: self.repository = ContractTemplateRepository(db) self.generated_contract_repository = GeneratedContractRepository(db) self.generated_contract_version_repository = GeneratedContractVersionRepository(db) + self.generated_contract_version_comment_repository = GeneratedContractVersionCommentRepository(db) self.contract_approval_step_repository = ContractApprovalStepRepository(db) self.contract_lifecycle_task_repository = ContractLifecycleTaskRepository(db) self.file_service = FileService(db) @@ -138,6 +156,8 @@ class ContractGenerationService: """根据模板和变量值生成合同(完整版)""" tenant_id = current_user["tenant_id"] options = options or {} + if not can_create_contract(current_user): + raise ForbiddenError("no permission to create contract") # 获取模板 template = self.repository.get_by_id(template_id, tenant_id) @@ -485,17 +505,17 @@ class ContractGenerationService: @staticmethod def _extract_enhancement_content(result: Optional[Dict[str, Any]]) -> str: if not isinstance(result, Mapping): - return str(result or "").strip() + return normalize_contract_content(result) structured_output = result.get("structured_output") if isinstance(structured_output, Mapping): for key in ("html", "content", "text", "body", "markdown", "document"): value = structured_output.get(key) if isinstance(value, str) and value.strip(): - return value.strip() + return normalize_contract_content(value) for key in ("content", "raw_content", "enhanced_content"): value = result.get(key) if isinstance(value, str) and value.strip(): - return value.strip() + return normalize_contract_content(value) return "" @staticmethod @@ -706,41 +726,159 @@ class ContractGenerationService: document_type_code: Optional[str] = None, lifecycle_status: Optional[str] = None, keyword: Optional[str] = None, + counterparty_name: Optional[str] = None, + owner_name: Optional[str] = None, + effective_from: Optional[datetime] = None, + effective_to: Optional[datetime] = None, + expires_from: Optional[datetime] = None, + expires_to: Optional[datetime] = None, ) -> Dict[str, Any]: - tenant_id = current_user["tenant_id"] - generated_by = None if current_user.get("role") == "admin" else str(current_user.get("id") or "") - normalized_lifecycle_status = self._normalize_optional_lifecycle_status(lifecycle_status) + query = self._resolve_contract_list_query( + current_user=current_user, + lifecycle_status=lifecycle_status, + effective_from=effective_from, + effective_to=effective_to, + expires_from=expires_from, + expires_to=expires_to, + ) items = self.generated_contract_repository.list_paginated( - tenant_id=tenant_id, + tenant_id=query["tenant_id"], page=page, page_size=page_size, - generated_by=generated_by, + generated_by=query["generated_by"], + participant_contract_ids=query["participant_contract_ids"], contract_type=contract_type, document_type_code=document_type_code, - lifecycle_status=normalized_lifecycle_status, + lifecycle_status=query["lifecycle_status"], keyword=keyword, + counterparty_name=counterparty_name, + owner_name=owner_name, + effective_from=query["effective_from"], + effective_to=query["effective_to"], + expires_from=query["expires_from"], + expires_to=query["expires_to"], ) total = self.generated_contract_repository.count( - tenant_id=tenant_id, - generated_by=generated_by, + tenant_id=query["tenant_id"], + generated_by=query["generated_by"], + participant_contract_ids=query["participant_contract_ids"], contract_type=contract_type, document_type_code=document_type_code, - lifecycle_status=normalized_lifecycle_status, + lifecycle_status=query["lifecycle_status"], keyword=keyword, + counterparty_name=counterparty_name, + owner_name=owner_name, + effective_from=query["effective_from"], + effective_to=query["effective_to"], + expires_from=query["expires_from"], + expires_to=query["expires_to"], ) return { - "items": [self._to_generated_contract_list_item(item) for item in items], + "items": [self._to_generated_contract_list_item(item, current_user=current_user) for item in items], "total": total, "page": page, "page_size": page_size, } + def export_generated_contracts_csv( + self, + current_user: Dict[str, Any], + contract_type: Optional[str] = None, + document_type_code: Optional[str] = None, + lifecycle_status: Optional[str] = None, + keyword: Optional[str] = None, + counterparty_name: Optional[str] = None, + owner_name: Optional[str] = None, + effective_from: Optional[datetime] = None, + effective_to: Optional[datetime] = None, + expires_from: Optional[datetime] = None, + expires_to: Optional[datetime] = None, + limit: int = 1000, + ) -> Dict[str, Any]: + if is_observer(current_user): + raise ForbiddenError("no permission to export contracts") + + query = self._resolve_contract_list_query( + current_user=current_user, + lifecycle_status=lifecycle_status, + effective_from=effective_from, + effective_to=effective_to, + expires_from=expires_from, + expires_to=expires_to, + ) + export_limit = min(max(int(limit or 1000), 1), 5000) + items = self.generated_contract_repository.list_filtered( + tenant_id=query["tenant_id"], + generated_by=query["generated_by"], + participant_contract_ids=query["participant_contract_ids"], + contract_type=contract_type, + document_type_code=document_type_code, + lifecycle_status=query["lifecycle_status"], + keyword=keyword, + counterparty_name=counterparty_name, + owner_name=owner_name, + effective_from=query["effective_from"], + effective_to=query["effective_to"], + expires_from=query["expires_from"], + expires_to=query["expires_to"], + limit=export_limit, + ) + rows = [self._build_contract_ledger_export_row(item, current_user=current_user) for item in items] + content = self._render_contract_ledger_csv(rows) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + return { + "file_name": f"contract-ledger-{timestamp}.csv", + "mime_type": "text/csv; charset=utf-8", + "content": content, + "count": len(rows), + "limit": export_limit, + } + + def _resolve_contract_list_query( + self, + *, + current_user: Dict[str, Any], + lifecycle_status: Optional[str], + effective_from: Optional[datetime], + effective_to: Optional[datetime], + expires_from: Optional[datetime], + expires_to: Optional[datetime], + ) -> Dict[str, Any]: + tenant_id = current_user["tenant_id"] + current_user_id = str(current_user.get("id") or "") + can_view_all = can_view_all_contracts(current_user) + generated_by = None if can_view_all else current_user_id + participant_contract_ids = None if can_view_all else self.contract_approval_step_repository.list_contract_ids_by_participant(tenant_id, current_user_id) + normalized_lifecycle_status = self._normalize_optional_lifecycle_status(lifecycle_status) + normalized_effective_from = self._normalize_optional_datetime(effective_from) + normalized_effective_to = self._normalize_optional_datetime(effective_to) + normalized_expires_from = self._normalize_optional_datetime(expires_from) + normalized_expires_to = self._normalize_optional_datetime(expires_to) + if normalized_effective_from and normalized_effective_to and normalized_effective_to < normalized_effective_from: + raise ValidationError("effective_to cannot be earlier than effective_from") + if normalized_expires_from and normalized_expires_to and normalized_expires_to < normalized_expires_from: + raise ValidationError("expires_to cannot be earlier than expires_from") + return { + "tenant_id": tenant_id, + "generated_by": generated_by, + "participant_contract_ids": participant_contract_ids, + "lifecycle_status": normalized_lifecycle_status, + "effective_from": normalized_effective_from, + "effective_to": normalized_effective_to, + "expires_from": normalized_expires_from, + "expires_to": normalized_expires_to, + } + def get_generated_contract(self, current_user: Dict[str, Any], contract_id: str) -> Dict: - generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + generated_contract = self._get_generated_contract_for_lifecycle_task( + current_user, + contract_id, + allow_approval_participant=True, + ) if not generated_contract: raise NotFoundError("合同不存在或无权访问") - return self._to_generated_contract_item(generated_contract) + return self._to_generated_contract_item(generated_contract, current_user=current_user) def update_generated_contract_lifecycle( self, @@ -758,8 +896,8 @@ class ContractGenerationService: generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) if not generated_contract: raise NotFoundError("contract not found") - if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"): - raise NotFoundError("contract not found") + if not can_update_contract(current_user, generated_by=generated_contract.generated_by): + raise ForbiddenError("no permission to update contract lifecycle") normalized_status = self._normalize_lifecycle_status(lifecycle_status) normalized_effective_at = self._normalize_optional_datetime(effective_at) @@ -855,6 +993,8 @@ class ContractGenerationService: def sync_contract_lifecycle_tasks(self, current_user: Dict[str, Any], contract_id: str) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + if not can_update_contract(current_user, generated_by=generated_contract.generated_by): + raise ForbiddenError("no permission to sync lifecycle tasks") lifecycle_status = generated_contract.lifecycle_status or generated_contract.status or "generated" expires_at = self._normalize_optional_datetime(generated_contract.expires_at) task_key = f"renewal:{contract_id}" @@ -947,7 +1087,9 @@ class ContractGenerationService: evidence_note: Optional[str] = None, ) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] - self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + if not can_update_contract(current_user, generated_by=generated_contract.generated_by): + raise ForbiddenError("no permission to create lifecycle task") normalized_task_type = self._normalize_lifecycle_task_type(task_type) normalized_priority = self._normalize_lifecycle_task_priority(priority) normalized_status = self._normalize_lifecycle_task_status(status_value) @@ -994,7 +1136,9 @@ class ContractGenerationService: evidence_note: Optional[str] = None, ) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] - self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + if not can_update_contract(current_user, generated_by=generated_contract.generated_by): + raise ForbiddenError("no permission to update lifecycle task") task = self.contract_lifecycle_task_repository.get_by_id(tenant_id, contract_id, task_id) if not task: raise NotFoundError("lifecycle task not found") @@ -1029,7 +1173,11 @@ class ContractGenerationService: def list_generated_contract_versions(self, current_user: Dict[str, Any], contract_id: str) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] - generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + generated_contract = self._get_generated_contract_for_lifecycle_task( + current_user, + contract_id, + allow_approval_participant=True, + ) if not generated_contract: raise NotFoundError("contract not found") versions = self.generated_contract_version_repository.list_by_contract(tenant_id, contract_id) @@ -1054,7 +1202,11 @@ class ContractGenerationService: to_version_id: Optional[str] = None, ) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] - generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + generated_contract = self._get_generated_contract_for_lifecycle_task( + current_user, + contract_id, + allow_approval_participant=True, + ) from_snapshot, to_snapshot = self._resolve_diff_version_pair( tenant_id=tenant_id, generated_contract=generated_contract, @@ -1062,17 +1214,171 @@ class ContractGenerationService: to_version_id=to_version_id, ) diff_result = self._build_contract_version_diff(from_snapshot["content"], to_snapshot["content"]) + comments = self.generated_contract_version_comment_repository.list_by_contract( + tenant_id, + contract_id, + from_version_id=from_snapshot.get("id"), + to_version_id=to_snapshot.get("id"), + ) + comment_items = [self._to_generated_contract_version_comment_item(item) for item in comments] return { "contract_id": contract_id, "from_version": self._public_version_snapshot(from_snapshot), "to_version": self._public_version_snapshot(to_snapshot), "summary": diff_result["summary"], "items": diff_result["items"], + "comments": comment_items, + "comment_summary": self._build_version_comment_summary(comment_items), } + def list_generated_contract_version_comments( + self, + current_user: Dict[str, Any], + contract_id: str, + *, + from_version_id: Optional[str] = None, + to_version_id: Optional[str] = None, + status_value: Optional[str] = None, + ) -> Dict[str, Any]: + tenant_id = current_user["tenant_id"] + generated_contract = self._get_generated_contract_for_lifecycle_task( + current_user, + contract_id, + allow_approval_participant=True, + ) + normalized_status = self._normalize_optional_version_comment_status(status_value) + comments = self.generated_contract_version_comment_repository.list_by_contract( + tenant_id, + contract_id, + from_version_id=from_version_id, + to_version_id=to_version_id, + status_value=normalized_status, + ) + items = [self._to_generated_contract_version_comment_item(item) for item in comments] + return { + "contract_id": contract_id, + "items": items, + "total": len(items), + "summary": self._build_version_comment_summary(items), + } + + def create_generated_contract_version_comment( + self, + current_user: Dict[str, Any], + contract_id: str, + *, + from_version_id: Optional[str], + to_version_id: Optional[str], + diff_key: str, + change_type: str, + old_text: Optional[str] = None, + new_text: Optional[str] = None, + comment: str, + source: str = "version_diff", + metadata: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + tenant_id = current_user["tenant_id"] + generated_contract = self._get_generated_contract_for_lifecycle_task( + current_user, + contract_id, + allow_approval_participant=True, + ) + participant_contract_ids = self.contract_approval_step_repository.list_contract_ids_by_participant(tenant_id, str(current_user.get("id") or "")) + if not can_comment_on_contract( + current_user, + generated_by=generated_contract.generated_by, + participant_contract_ids=participant_contract_ids, + contract_id=contract_id, + ): + raise ForbiddenError("no permission to create version comment") + normalized_comment = self._required_version_comment_text(comment, field_name="comment", max_length=4000) + normalized_diff_key = self._required_version_comment_text(diff_key, field_name="diff_key", max_length=80) + normalized_from_version_id = self._normalize_version_comment_version_id(from_version_id) + normalized_to_version_id = self._normalize_version_comment_version_id(to_version_id) + if normalized_from_version_id == normalized_to_version_id: + raise ValidationError("from_version_id and to_version_id must be different") + if normalized_from_version_id: + self._resolve_contract_version_snapshot( + tenant_id=tenant_id, + generated_contract=generated_contract, + version_id=normalized_from_version_id, + ) + if normalized_to_version_id: + self._resolve_contract_version_snapshot( + tenant_id=tenant_id, + generated_contract=generated_contract, + version_id=normalized_to_version_id, + ) + comment_item = self.generated_contract_version_comment_repository.create( + GeneratedContractVersionComment( + id=f"gcvc-{uuid4().hex[:12]}", + tenant_id=tenant_id, + contract_id=contract_id, + from_version_id=normalized_from_version_id, + to_version_id=normalized_to_version_id, + diff_key=normalized_diff_key, + change_type=self._normalize_diff_change_type(change_type), + old_text=self._optional_version_comment_text(old_text, max_length=8000), + new_text=self._optional_version_comment_text(new_text, max_length=8000), + comment=normalized_comment, + status="open", + source=self._normalize_version_comment_source(source), + created_by=current_user.get("id"), + created_by_name=current_user.get("display_name") or current_user.get("username"), + metadata_json=metadata or {}, + ) + ) + return self._to_generated_contract_version_comment_item(comment_item) + + def update_generated_contract_version_comment( + self, + current_user: Dict[str, Any], + contract_id: str, + comment_id: str, + *, + status_value: Optional[str] = None, + comment: Optional[str] = None, + ) -> Dict[str, Any]: + tenant_id = current_user["tenant_id"] + generated_contract = self._get_generated_contract_for_lifecycle_task( + current_user, + contract_id, + allow_approval_participant=True, + ) + participant_contract_ids = self.contract_approval_step_repository.list_contract_ids_by_participant(tenant_id, str(current_user.get("id") or "")) + if not can_comment_on_contract( + current_user, + generated_by=generated_contract.generated_by, + participant_contract_ids=participant_contract_ids, + contract_id=contract_id, + ): + raise ForbiddenError("no permission to update version comment") + comment_item = self.generated_contract_version_comment_repository.get_by_id(tenant_id, contract_id, comment_id) + if comment_item is None: + raise NotFoundError("version comment not found") + if comment is not None: + comment_item.comment = self._required_version_comment_text(comment, field_name="comment", max_length=4000) + if status_value is not None: + normalized_status = self._normalize_version_comment_status(status_value) + comment_item.status = normalized_status + if normalized_status == "resolved": + comment_item.resolved_by = current_user.get("id") + comment_item.resolved_by_name = current_user.get("display_name") or current_user.get("username") + comment_item.resolved_at = datetime.now(timezone.utc) + elif normalized_status == "open": + comment_item.resolved_by = None + comment_item.resolved_by_name = None + comment_item.resolved_at = None + updated = self.generated_contract_version_comment_repository.update(comment_item) + return self._to_generated_contract_version_comment_item(updated) + def get_generated_contract_version(self, current_user: Dict[str, Any], contract_id: str, version_id: str) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] - generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + generated_contract = self._get_generated_contract_for_lifecycle_task( + current_user, + contract_id, + allow_approval_participant=True, + ) if not generated_contract: raise NotFoundError("contract not found") version = self.generated_contract_version_repository.get_by_id(tenant_id, contract_id, version_id) @@ -1091,7 +1397,19 @@ class ContractGenerationService: if export_format != ExportFormat.DOCX: raise ValidationError("only docx export is supported for version delivery") tenant_id = current_user["tenant_id"] - generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + generated_contract = self._get_generated_contract_for_lifecycle_task( + current_user, + contract_id, + allow_approval_participant=True, + ) + participant_contract_ids = self.contract_approval_step_repository.list_contract_ids_by_participant(tenant_id, str(current_user.get("id") or "")) + if not can_download_contract( + current_user, + generated_by=generated_contract.generated_by, + participant_contract_ids=participant_contract_ids, + contract_id=contract_id, + ): + raise ForbiddenError("no permission to export contract version") snapshot = self._resolve_contract_version_snapshot( tenant_id=tenant_id, generated_contract=generated_contract, @@ -1140,6 +1458,8 @@ class ContractGenerationService: ) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + if not can_update_contract(current_user, generated_by=generated_contract.generated_by): + raise ForbiddenError("no permission to create contract version") normalized_content = str(content or "").strip() if not normalized_content: @@ -1180,11 +1500,19 @@ class ContractGenerationService: generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) if not generated_contract: raise NotFoundError("合同不存在或无权访问") + preview_content = normalize_contract_content(generated_contract.generated_content) + preview_html = self._build_generated_contract_html( + preview_content, + missing_fields=[], + related_legal_articles=[], + ai_hints=[], + ) return { "id": generated_contract.id, "template_name": generated_contract.template_name, "contract_type": generated_contract.contract_type, - "preview_content": generated_contract.generated_content, + "preview_content": preview_content, + "preview_html": preview_html, "export_format": generated_contract.export_format, "file_name": generated_contract.file_name, "generated_at": generated_contract.created_at.isoformat(), @@ -1194,25 +1522,69 @@ class ContractGenerationService: def get_generated_contract_download(self, current_user: Dict[str, Any], contract_id: str) -> Dict: tenant_id = current_user["tenant_id"] - generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id) - if not generated_contract: - raise NotFoundError("合同不存在或无权访问") + generated_contract = self._get_generated_contract_for_lifecycle_task( + current_user, + contract_id, + allow_approval_participant=True, + ) + participant_contract_ids = self.contract_approval_step_repository.list_contract_ids_by_participant(tenant_id, str(current_user.get("id") or "")) + if not can_download_contract( + current_user, + generated_by=generated_contract.generated_by, + participant_contract_ids=participant_contract_ids, + contract_id=contract_id, + ): + raise ForbiddenError("no permission to download contract") if generated_contract.file_id: - file_payload = self.file_service.get_download_payload(current_user, generated_contract.file_id, allow_owner_access=True) - return { - "id": generated_contract.id, - "file_id": generated_contract.file_id, - "file_name": file_payload["file_name"], - "mime_type": file_payload["mime_type"], - "content": file_payload["content"], - } + try: + file_payload = self.file_service.get_download_payload(current_user, generated_contract.file_id, allow_owner_access=True) + return { + "id": generated_contract.id, + "file_id": generated_contract.file_id, + "file_name": file_payload["file_name"], + "mime_type": file_payload["mime_type"], + "content": file_payload["content"], + } + except HTTPException as exc: + if exc.status_code == status.HTTP_403_FORBIDDEN: + raise + return self._recover_generated_contract_download(generated_contract) + except Exception: + return self._recover_generated_contract_download(generated_contract) return { "id": generated_contract.id, + "file_id": generated_contract.file_id, "file_name": generated_contract.file_name, "mime_type": generated_contract.mime_type, "content": base64.b64decode(generated_contract.file_content_base64), } + def _recover_generated_contract_download(self, generated_contract: GeneratedContract) -> Dict[str, Any]: + normalized_content = normalize_contract_content(generated_contract.generated_content) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + options = dict(generated_contract.export_options_json or {}) + options.setdefault("document_title", generated_contract.template_name) + options.setdefault("generated_by_name", generated_contract.generated_by_name) + try: + export_format = ExportFormat(str(generated_contract.export_format or ExportFormat.DOCX.value).lower()) + except ValueError: + export_format = ExportFormat.DOCX + content, file_name = self.export_service.export_contract( + normalized_content, + generated_contract.template_name, + timestamp, + export_format, + options, + ) + return { + "id": generated_contract.id, + "file_id": generated_contract.file_id, + "file_name": file_name or generated_contract.file_name, + "mime_type": self.export_service.get_mime_type(export_format), + "content": content, + "recovered_from_content": True, + } + def _persist_auto_review_summary(self, generated_contract: GeneratedContract, auto_review_summary: Dict[str, Any]) -> GeneratedContract: export_options = dict(generated_contract.export_options_json or {}) export_options["_auto_review"] = auto_review_summary @@ -1573,9 +1945,8 @@ class ContractGenerationService: review_result: Dict[str, Any], review_report: Optional[Dict[str, Any]], ) -> Dict[str, Any]: - workflow_state = review_detail.get("workflow_state") or {} - source_document = review_detail.get("source_document") or {} ai_trace = review_detail.get("ai_trace") or {} + workflow_state = review_detail.get("workflow_state") or {} prompt_runtime = (generated_contract.export_options_json or {}).get("_prompt_runtime") or {} if isinstance(ai_trace, dict) and prompt_runtime: ai_trace = dict(ai_trace) @@ -1585,41 +1956,10 @@ class ContractGenerationService: overall_risk = (review_result.get("risk_summary") or {}).get("overall") or workflow_state.get("risk_level") review_opinion = review_result.get("review_opinion") recommendation = review_result.get("recommendation") - chat_context = { - "reviewId": review_id, - "prompt": self._build_auto_review_prompt( - generated_contract=generated_contract, - review_detail=review_detail, - review_result=review_result, - ), - "metadata": { - "reviewTitle": generated_contract.template_name, - "reviewStatus": review_detail.get("status"), - "contractType": review_result.get("basic_info", {}).get("contract_type") or review_detail.get("contract_type"), - "current_stage": review_detail.get("current_stage"), - "current_stage_label": review_detail.get("current_stage_label"), - "status_detail": review_detail.get("status_detail"), - "failure_reason": review_detail.get("failure_reason"), - "next_action": review_detail.get("next_action"), - "overallRisk": overall_risk, - "reviewOpinion": review_opinion, - "focusLabel": generated_contract.template_name, - "actionLabel": "继续审查问答", - "focusType": "generated_contract", - "source_type": review_detail.get("source_type"), - "source_text_length": review_detail.get("source_text_length"), - "processing_time_ms": review_detail.get("processing_time_ms"), - "risk_level": workflow_state.get("risk_level"), - }, - "workflowState": workflow_state, - "aiTrace": ai_trace, - "sourceDocument": source_document, - } - detail_url = f"/reviews/{review_id}" if review_detail else None report_download_url = review_report.get("download_url") if review_report else None report_file_name = review_report.get("file_name") if review_report else None - chat_url = f"/chat?review={quote(json.dumps(chat_context, ensure_ascii=False), safe='')}" if review_detail else None + chat_url = f"/chat?reviewId={quote(review_id, safe='')}" if review_detail else None return { "review_id": review_id, "review_title": generated_contract.template_name, @@ -1942,7 +2282,7 @@ class ContractGenerationService: deduped[code] = item return sorted(deduped.values(), key=lambda item: (priority_order.get(str(item.get("priority") or "low"), 99), str(item.get("title") or ""))) - def _to_generated_contract_item(self, generated_contract: GeneratedContract) -> Dict: + def _to_generated_contract_item(self, generated_contract: GeneratedContract, current_user: Optional[Dict[str, Any]] = None) -> Dict: auto_review = self._resolve_auto_review_summary(generated_contract) export_options = generated_contract.export_options_json or {} document_type_code = generated_contract.document_type_code or export_options.get("_document_type_code") @@ -1960,12 +2300,14 @@ class ContractGenerationService: generation_quality = export_options.get("_generation_quality") or {} clause_suggestions = self._build_clause_completion_suggestions(generation_quality, auto_review) prompt_runtime = export_options.get("_prompt_runtime") or {} + display_content = normalize_contract_content(generated_contract.generated_content) generated_html = self._build_generated_contract_html( - generated_contract.generated_content, + display_content, missing_fields=missing_fields, related_legal_articles=related_legal_articles, ai_hints=ai_hints, ) + signoff_archive = self._build_contract_signoff_archive(generated_contract) return { "id": generated_contract.id, "template_id": generated_contract.template_id, @@ -1975,7 +2317,7 @@ class ContractGenerationService: "template_category": export_options.get("_template_category"), "industry_category": export_options.get("_industry_category"), "file_id": generated_contract.file_id, - "generated_content": generated_contract.generated_content, + "generated_content": display_content, "generated_html": generated_html, "variable_values": generated_contract.variable_values_json or {}, "elements_snapshot": elements_snapshot, @@ -1993,7 +2335,8 @@ 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), + "approval": self._build_contract_approval_summary(generated_contract, current_user=current_user), + "signoff_archive": signoff_archive, "metadata": { "generated_by": generated_contract.generated_by, "generated_by_name": generated_contract.generated_by_name, @@ -2026,8 +2369,8 @@ class ContractGenerationService: } } - def _to_generated_contract_list_item(self, generated_contract: GeneratedContract) -> Dict: - item = self._to_generated_contract_item(generated_contract) + def _to_generated_contract_list_item(self, generated_contract: GeneratedContract, current_user: Optional[Dict[str, Any]] = None) -> Dict: + item = self._to_generated_contract_item(generated_contract, current_user=current_user) return { "id": item["id"], "template_name": item["template_name"], @@ -2047,9 +2390,12 @@ class ContractGenerationService: "auto_review": item["auto_review"], "lifecycle": item["lifecycle"], "approval": item.get("approval"), + "signoff_archive": item.get("signoff_archive"), + "ledger_attention": self._build_contract_ledger_attention(generated_contract, item, current_user=current_user), "missing_field_count": item["metadata"].get("missing_field_count", 0), "related_legal_article_count": item["metadata"].get("related_legal_article_count", 0), "metadata": { + "generated_by": generated_contract.generated_by, "generated_by_name": item["metadata"]["generated_by_name"], "asset_file_id": item["metadata"]["asset_file_id"], "document_type_code": item["metadata"]["document_type_code"], @@ -2066,6 +2412,192 @@ class ContractGenerationService: }, } + def _build_contract_ledger_export_row(self, generated_contract: GeneratedContract, current_user: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + item = self._to_generated_contract_list_item(generated_contract, current_user=current_user) + lifecycle = item.get("lifecycle") or {} + auto_review = item.get("auto_review") or {} + signoff_summary = (item.get("signoff_archive") or {}).get("summary") or {} + ledger_attention = item.get("ledger_attention") or {} + primary_attention = ledger_attention.get("primary") or {} + return { + "合同ID": item.get("id"), + "合同名称": item.get("template_name"), + "合同类型": item.get("contract_type"), + "文书类型": item.get("document_type_code"), + "相对方": lifecycle.get("counterparty_name"), + "负责人": lifecycle.get("owner_name") or (item.get("metadata") or {}).get("generated_by_name"), + "起草人": (item.get("metadata") or {}).get("generated_by_name"), + "生命周期状态": lifecycle.get("status"), + "到期状态": lifecycle.get("due_status"), + "生效日期": lifecycle.get("effective_at"), + "到期日期": lifecycle.get("expires_at"), + "距到期天数": lifecycle.get("days_to_expiry"), + "签署证据": "已留存" if signoff_summary.get("has_signoff_evidence") else "缺失", + "归档证据": "已留存" if signoff_summary.get("has_archive_evidence") else "缺失", + "自动预审状态": auto_review.get("status"), + "风险等级": auto_review.get("risk_level"), + "当前待办": primary_attention.get("label"), + "待办说明": primary_attention.get("description"), + "文件名": item.get("file_name"), + "文件大小": item.get("file_size"), + "导出格式": item.get("export_format"), + "生成时间": item.get("generated_at"), + "最近更新": lifecycle.get("updated_at") or item.get("generated_at"), + "详情链接": f"/contracts/{item.get('id')}", + } + + @staticmethod + def _render_contract_ledger_csv(rows: List[Dict[str, Any]]) -> bytes: + headers = [ + "合同ID", + "合同名称", + "合同类型", + "文书类型", + "相对方", + "负责人", + "起草人", + "生命周期状态", + "到期状态", + "生效日期", + "到期日期", + "距到期天数", + "签署证据", + "归档证据", + "自动预审状态", + "风险等级", + "当前待办", + "待办说明", + "文件名", + "文件大小", + "导出格式", + "生成时间", + "最近更新", + "详情链接", + ] + output = io.StringIO(newline="") + writer = csv.DictWriter(output, fieldnames=headers, extrasaction="ignore") + writer.writeheader() + for row in rows: + writer.writerow({key: "" if row.get(key) is None else row.get(key) for key in headers}) + return ("\ufeff" + output.getvalue()).encode("utf-8") + + def _build_contract_ledger_attention( + self, + generated_contract: GeneratedContract, + item: Dict[str, Any], + current_user: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + lifecycle = item.get("lifecycle") or {} + approval = item.get("approval") or {} + auto_review = item.get("auto_review") or {} + lifecycle_status = lifecycle.get("status") or "generated" + due_status = lifecycle.get("due_status") or "not_set" + risk_level = auto_review.get("risk_level") + user_id = str((current_user or {}).get("id") or "") + is_owner = bool(user_id and str(generated_contract.generated_by or "") == user_id) + is_admin = (current_user or {}).get("role") == "admin" + + tasks = self.contract_lifecycle_task_repository.list_by_contract(generated_contract.tenant_id, generated_contract.id) + open_tasks = [task for task in tasks if task.status in {"open", "in_progress"}] + pending_task = open_tasks[0] if open_tasks else None + pending_approval = approval.get("pending") + + attention_items: List[Dict[str, Any]] = [] + if pending_approval: + can_decide = bool(pending_approval.get("can_decide")) + attention_items.append( + { + "code": "approval_decision" if can_decide else "approval_waiting", + "label": "待我审批" if can_decide else "审批中", + "description": pending_approval.get("comment") or approval.get("next_action", {}).get("description") or "合同正在审批流转中。", + "priority": "critical" if can_decide else "high", + "action_label": "处理审批" if can_decide else "查看审批", + "action_href": f"/contracts/{generated_contract.id}", + "is_mine": can_decide, + } + ) + elif approval.get("next_action", {}).get("code") == "revise_and_resubmit": + attention_items.append( + { + "code": "approval_resubmit", + "label": "退回待修改", + "description": approval.get("next_action", {}).get("description") or "审批已退回,请修改后重新提交。", + "priority": "high", + "action_label": "修改并重提", + "action_href": f"/contracts/{generated_contract.id}", + "is_mine": is_admin or is_owner, + } + ) + + if pending_task: + attention_items.append( + { + "code": f"lifecycle_task_{pending_task.task_type}", + "label": self._format_ledger_task_label(pending_task.task_type), + "description": pending_task.title, + "priority": pending_task.priority or "medium", + "action_label": "处理任务", + "action_href": f"/contracts/{generated_contract.id}", + "is_mine": is_admin or is_owner, + "due_at": self._serialize_optional_datetime(pending_task.due_at), + } + ) + + if due_status in {"overdue", "due_soon"}: + attention_items.append( + { + "code": "contract_overdue" if due_status == "overdue" else "contract_due_soon", + "label": "已逾期" if due_status == "overdue" else "即将到期", + "description": "请确认续约、结算、终止或归档动作。" if due_status == "overdue" else "请提前确认续约和履约节点。", + "priority": "critical" if due_status == "overdue" else "high", + "action_label": "同步提醒", + "action_href": f"/contracts/{generated_contract.id}", + "is_mine": is_admin or is_owner, + } + ) + + if risk_level in {"critical", "high"}: + attention_items.append( + { + "code": "high_risk_review", + "label": "高风险预审", + "description": auto_review.get("next_action") or auto_review.get("status_detail") or "自动预审提示高风险,请先复核关键条款。", + "priority": "high", + "action_label": "查看审查", + "action_href": auto_review.get("detail_url") or f"/contracts/{generated_contract.id}", + "is_mine": is_admin or is_owner, + } + ) + + priority_order = {"critical": 0, "high": 1, "medium": 2, "low": 3} + attention_items = sorted( + attention_items, + key=lambda value: (priority_order.get(str(value.get("priority") or "low"), 9), str(value.get("code") or "")), + ) + primary = attention_items[0] if attention_items else None + return { + "status": "action_required" if attention_items else "normal", + "is_mine": any(item.get("is_mine") for item in attention_items), + "primary": primary, + "items": attention_items, + "counts": { + "total": len(attention_items), + "mine": len([entry for entry in attention_items if entry.get("is_mine")]), + "open_tasks": len(open_tasks), + "pending_approval": 1 if pending_approval else 0, + }, + } + + @staticmethod + def _format_ledger_task_label(task_type: Optional[str]) -> str: + return { + "renewal_reminder": "续约提醒", + "performance_milestone": "履约节点", + "signoff": "签署证据", + "termination_archive": "归档证据", + "custom": "自定义任务", + }.get(str(task_type or ""), "履约任务") + @staticmethod def _to_generated_contract_version_item(version) -> Dict[str, Any]: return { @@ -2088,6 +2620,40 @@ class ContractGenerationService: "detail_url": f"/contracts/{version.contract_id}?version={version.id}", } + @staticmethod + def _to_generated_contract_version_comment_item(comment: GeneratedContractVersionComment) -> Dict[str, Any]: + return { + "id": comment.id, + "tenant_id": comment.tenant_id, + "contract_id": comment.contract_id, + "from_version_id": comment.from_version_id or "base", + "to_version_id": comment.to_version_id or "base", + "diff_key": comment.diff_key, + "change_type": comment.change_type, + "old_text": comment.old_text, + "new_text": comment.new_text, + "comment": comment.comment, + "status": comment.status, + "source": comment.source, + "created_by": comment.created_by, + "created_by_name": comment.created_by_name, + "resolved_by": comment.resolved_by, + "resolved_by_name": comment.resolved_by_name, + "resolved_at": comment.resolved_at.isoformat() if comment.resolved_at else None, + "metadata": comment.metadata_json or {}, + "created_at": comment.created_at.isoformat() if comment.created_at else None, + "updated_at": comment.updated_at.isoformat() if comment.updated_at else None, + } + + @staticmethod + def _build_version_comment_summary(items: List[Dict[str, Any]]) -> Dict[str, int]: + summary = {"open": 0, "resolved": 0, "dismissed": 0, "total": len(items)} + for item in items: + status_value = item.get("status") + if status_value in summary: + summary[status_value] += 1 + return summary + def _base_contract_version_snapshot(self, generated_contract: GeneratedContract) -> Dict[str, Any]: return { "id": "base", @@ -2241,8 +2807,16 @@ class ContractGenerationService: else: change_type = "removed" counters[change_type] += 1 + item_index = len(items) + 1 items.append( { + "index": item_index, + "diff_key": cls._build_contract_version_diff_key( + index=item_index, + change_type=change_type, + old_text=old_text, + new_text=new_text, + ), "change_type": change_type, "old_paragraph_index": old_start + offset + 1 if old_text is not None else None, "new_paragraph_index": new_start + offset + 1 if new_text is not None else None, @@ -2264,6 +2838,12 @@ class ContractGenerationService: "items": items, } + @staticmethod + def _build_contract_version_diff_key(*, index: int, change_type: str, old_text: Optional[str], new_text: Optional[str]) -> str: + raw = f"{index}|{change_type}|{old_text or ''}|{new_text or ''}" + digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] + return f"diff-{index}-{digest}" + @staticmethod def _normalize_optional_datetime(value: Optional[datetime]) -> Optional[datetime]: if value is None: @@ -2308,16 +2888,25 @@ class ContractGenerationService: self, current_user: Dict[str, Any], contract_id: str, + *, + allow_approval_participant: bool = False, ) -> GeneratedContract: tenant_id = current_user["tenant_id"] generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id) if not generated_contract: raise NotFoundError("contract not found") current_user_id = str(current_user.get("id") or "") - generated_by = str(generated_contract.generated_by or "") - if current_user.get("role") != "admin" and generated_by != current_user_id: - raise NotFoundError("contract not found") - return generated_contract + participant_contract_ids = None + if allow_approval_participant and not can_view_all_contracts(current_user): + participant_contract_ids = self.contract_approval_step_repository.list_contract_ids_by_participant(tenant_id, current_user_id) + if can_view_contract( + current_user, + generated_by=generated_contract.generated_by, + participant_contract_ids=participant_contract_ids, + contract_id=contract_id, + ): + return generated_contract + raise NotFoundError("contract not found") @classmethod def _normalize_lifecycle_task_type(cls, value: str) -> str: @@ -2480,6 +3069,78 @@ class ContractGenerationService: summary["with_evidence"] += 1 return summary + def _build_contract_signoff_archive(self, generated_contract: GeneratedContract) -> Dict[str, Any]: + tasks = self.contract_lifecycle_task_repository.list_by_contract( + generated_contract.tenant_id, + generated_contract.id, + ) + task_items = [self._to_contract_lifecycle_task_item(task) for task in tasks] + signoff_items = [item for item in task_items if item.get("task_type") == "signoff"] + archive_items = [item for item in task_items if item.get("task_type") == "termination_archive"] + evidence_items = [ + self._to_signoff_archive_evidence_item(item) + for item in [*signoff_items, *archive_items] + if item.get("evidence_file") + ] + signoff_evidence_count = len([item for item in signoff_items if item.get("evidence_file")]) + archive_evidence_count = len([item for item in archive_items if item.get("evidence_file")]) + lifecycle_status = generated_contract.lifecycle_status or generated_contract.status or "generated" + + return { + "signoff": self._build_signoff_archive_section(signoff_items), + "archive": self._build_signoff_archive_section(archive_items), + "items": evidence_items, + "summary": { + "signoff_task_count": len(signoff_items), + "archive_task_count": len(archive_items), + "signoff_evidence_count": signoff_evidence_count, + "archive_evidence_count": archive_evidence_count, + "total_evidence_count": len(evidence_items), + "has_signoff_evidence": signoff_evidence_count > 0, + "has_archive_evidence": archive_evidence_count > 0, + "ready_for_signed": lifecycle_status in {"signed", "effective", "expiring", "expired", "terminated", "archived"} or signoff_evidence_count > 0, + "ready_for_archive": lifecycle_status == "archived" or archive_evidence_count > 0, + }, + } + + @staticmethod + def _build_signoff_archive_section(items: List[Dict[str, Any]]) -> Dict[str, Any]: + preferred = next( + (item for item in items if item.get("status") == "completed" and item.get("evidence_file")), + None, + ) + if preferred is None: + preferred = next((item for item in items if item.get("evidence_file")), None) + if preferred is None: + preferred = next((item for item in items if item.get("status") == "completed"), None) + if preferred is None and items: + preferred = items[0] + + evidence_count = len([item for item in items if item.get("evidence_file")]) + return { + "status": preferred.get("status") if preferred else "missing", + "task": preferred, + "has_evidence": evidence_count > 0, + "evidence_count": evidence_count, + } + + @staticmethod + def _to_signoff_archive_evidence_item(item: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": item["id"], + "contract_id": item["contract_id"], + "task_id": item["id"], + "task_type": item["task_type"], + "task_title": item["title"], + "task_status": item["status"], + "evidence_file_id": item.get("evidence_file_id"), + "evidence_file": item.get("evidence_file"), + "evidence_note": item.get("evidence_note"), + "completed_at": item.get("completed_at"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + } + def _build_contract_lifecycle(self, generated_contract: GeneratedContract) -> Dict[str, Any]: lifecycle_status = generated_contract.lifecycle_status or generated_contract.status or "generated" expires_at = self._normalize_optional_datetime(generated_contract.expires_at) @@ -2510,22 +3171,36 @@ 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]: + def _build_contract_approval_summary( + self, + generated_contract: GeneratedContract, + current_user: Optional[Dict[str, Any]] = None, + ) -> 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) + latest_rejected = next((step for step in steps if step.status == "rejected"), None) + can_submit = self._can_submit_contract_approval(generated_contract, current_user, pending) 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], + "latest": self._to_contract_approval_step_summary(latest, current_user=current_user), + "pending": self._to_contract_approval_step_summary(pending, current_user=current_user), + "latest_rejected": self._to_contract_approval_step_summary(latest_rejected, current_user=current_user), + "history": [self._to_contract_approval_step_summary(step, current_user=current_user) for step in steps], + "timeline": self._build_contract_approval_timeline(steps), + "can_submit": can_submit, + "next_action": self._build_contract_approval_next_action(generated_contract, pending, latest_rejected, can_submit), "total": len(steps), } - def _to_contract_approval_step_summary(self, step) -> Optional[Dict[str, Any]]: + def _to_contract_approval_step_summary( + self, + step, + current_user: Optional[Dict[str, Any]] = None, + ) -> Optional[Dict[str, Any]]: if step is None: return None return { @@ -2542,8 +3217,110 @@ class ContractGenerationService: "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), + "timeline": self._build_contract_approval_step_timeline(step), + "audit_key": f"contract_approval:{step.id}", + "can_decide": self._can_current_user_decide_contract_approval(step, current_user), + } + + def _build_contract_approval_timeline(self, steps) -> List[Dict[str, Any]]: + timeline: List[Dict[str, Any]] = [] + fallback_time = datetime.min.replace(tzinfo=timezone.utc) + for step in sorted(steps, key=lambda item: item.created_at or fallback_time): + timeline.extend(self._build_contract_approval_step_timeline(step)) + return timeline + + def _build_contract_approval_step_timeline(self, step) -> List[Dict[str, Any]]: + timeline = [ + { + "key": f"{step.id}:submitted", + "approval_id": step.id, + "event": "submitted", + "status": "completed", + "title": "提交审批", + "description": step.comment, + "actor_id": step.submitter_id, + "actor_name": step.submitter_name, + "occurred_at": self._serialize_optional_datetime(step.created_at), + } + ] + if step.status == "pending": + timeline.append( + { + "key": f"{step.id}:pending", + "approval_id": step.id, + "event": "pending", + "status": "pending", + "title": "等待处理", + "description": f"待 {step.assignee_name or '审批人'} 处理", + "actor_id": step.assignee_id, + "actor_name": step.assignee_name, + "occurred_at": None, + } + ) + elif step.status in {"approved", "rejected"}: + timeline.append( + { + "key": f"{step.id}:decision", + "approval_id": step.id, + "event": "approved" if step.status == "approved" else "rejected", + "status": "completed" if step.status == "approved" else "rejected", + "title": "审批通过" if step.status == "approved" else "审批退回", + "description": step.comment, + "actor_id": step.decided_by, + "actor_name": step.decided_by_name, + "occurred_at": self._serialize_optional_datetime(step.decided_at), + } + ) + return timeline + + @staticmethod + def _can_current_user_decide_contract_approval(step, current_user: Optional[Dict[str, Any]]) -> bool: + if step is None or step.status != "pending" or current_user is None: + return False + return can_decide_contract_approval(current_user, assignee_id=step.assignee_id) + + @staticmethod + def _can_submit_contract_approval(generated_contract: GeneratedContract, current_user: Optional[Dict[str, Any]], pending) -> bool: + if pending is not None or current_user is None: + return False + lifecycle_status = generated_contract.lifecycle_status or generated_contract.status or "generated" + if lifecycle_status in {"internal_review", "approved", "signed", "effective", "expiring", "expired", "terminated", "archived"}: + return False + return can_submit_contract_approval(current_user, generated_by=generated_contract.generated_by) + + @staticmethod + def _build_contract_approval_next_action(generated_contract: GeneratedContract, pending, latest_rejected, can_submit: bool) -> Dict[str, Any]: + if pending is not None: + return { + "code": "wait_decision", + "label": "等待审批人处理", + "description": f"当前审批正在等待 {pending.assignee_name or '审批人'} 处理。", + } + if (generated_contract.lifecycle_status or generated_contract.status) == "approved": + return { + "code": "approved", + "label": "审批已通过", + "description": "合同已通过审批,可继续签署或归档准备。", + } + if latest_rejected is not None and can_submit: + return { + "code": "revise_and_resubmit", + "label": "修改后重新提交", + "description": latest_rejected.comment or "审批已退回,请根据处理意见修改后重新提交。", + } + if can_submit: + return { + "code": "submit", + "label": "提交审批", + "description": "合同可提交审批。", + } + return { + "code": "readonly", + "label": "查看审批记录", + "description": "当前账号可查看审批记录,但没有提交或处理权限。", } @staticmethod @@ -2555,11 +3332,68 @@ class ContractGenerationService: "clause_recommendation", "clause_rewrite", "review_suggestion", + "comment_resolution", "manual_edit", }: return "editor_save" return normalized + @staticmethod + def _normalize_version_comment_version_id(value: Optional[str]) -> Optional[str]: + if value is None: + return None + normalized = str(value).strip() + if normalized.lower() in {"", "base", "v1", "original"}: + return None + return normalized + + @staticmethod + def _normalize_version_comment_status(value: str) -> str: + normalized = str(value or "open").strip().lower().replace("-", "_") + if normalized not in {"open", "resolved", "dismissed"}: + raise ValidationError("invalid version comment status") + return normalized + + @classmethod + def _normalize_optional_version_comment_status(cls, value: Optional[str]) -> Optional[str]: + if value is None or not str(value).strip(): + return None + return cls._normalize_version_comment_status(value) + + @staticmethod + def _normalize_diff_change_type(value: str) -> str: + normalized = str(value or "modified").strip().lower().replace("-", "_") + if normalized not in {"added", "removed", "modified"}: + return "modified" + return normalized + + @staticmethod + def _normalize_version_comment_source(value: str) -> str: + normalized = str(value or "version_diff").strip().lower().replace("-", "_") + if normalized not in {"version_diff", "approval_reject", "editor_resolution"}: + return "version_diff" + return normalized + + @staticmethod + def _required_version_comment_text(value: Any, *, field_name: str, max_length: int) -> str: + normalized = str(value or "").strip() + if not normalized: + raise ValidationError(f"{field_name} is required") + if len(normalized) > max_length: + raise ValidationError(f"{field_name} is too long") + return normalized + + @staticmethod + def _optional_version_comment_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: + return normalized[:max_length] + return normalized + @staticmethod def _normalize_locate_status(value: str) -> str: normalized = str(value or "applied").strip().lower().replace("-", "_") diff --git a/backend/app/services/contract_lifecycle_service.py b/backend/app/services/contract_lifecycle_service.py index 762f4dc2..b4174e07 100644 --- a/backend/app/services/contract_lifecycle_service.py +++ b/backend/app/services/contract_lifecycle_service.py @@ -19,7 +19,7 @@ CONTRACT_LIFECYCLE_STATUSES = { "generated", "internal_review", "external_review", "approved", "signed", "effective", "expiring", "expired", "terminated", "archived", } -CONTRACT_LIFECYCLE_TASK_TYPES = {"renewal_reminder", "performance_milestone", "termination_archive", "custom"} +CONTRACT_LIFECYCLE_TASK_TYPES = {"renewal_reminder", "performance_milestone", "signoff", "termination_archive", "custom"} CONTRACT_LIFECYCLE_TASK_STATUSES = {"open", "in_progress", "completed", "skipped", "archived"} CONTRACT_LIFECYCLE_TASK_PRIORITIES = {"low", "medium", "high", "critical"} diff --git a/backend/app/services/legal_document_draft_service.py b/backend/app/services/legal_document_draft_service.py index 449ada4e..6cdcc6e7 100644 --- a/backend/app/services/legal_document_draft_service.py +++ b/backend/app/services/legal_document_draft_service.py @@ -11,19 +11,10 @@ from app.repositories.contract_template_repository import ContractTemplateReposi from app.repositories.legal_document_draft_repository import LegalDocumentDraftRepository from app.services.contract_generation_service import ContractGenerationService, ExportFormat from app.services.legal_document_element_schema_service import LegalDocumentElementSchemaService -from app.services.legal_domain_registry import supports_ai_enhanced_generation +from app.services.legal_domain_registry import canonical_document_type_code, document_type_candidates, supports_ai_enhanced_generation VALID_DRAFT_STATUS = {"draft", "generated", "deleted"} -TEMPLATE_DOCUMENT_TYPE_CODE_ALIASES = { - "property_service": ["property_service", "property_service_agreement"], - "property_service_agreement": ["property_service", "property_service_agreement"], - "rescission_notice": ["rescission_notice", "contract_termination_notice"], - "contract_termination_notice": ["rescission_notice", "contract_termination_notice"], - "termination_notice": ["rescission_notice", "contract_termination_notice"], -} - - class LegalDocumentDraftService: def __init__(self, db: Session): self.db = db @@ -45,7 +36,7 @@ class LegalDocumentDraftService: items = self._require_repository().list_recent( tenant_id=current_user["tenant_id"], user_id=current_user["id"], - document_type_code=self._optional_text(document_type_code), + document_type_code=self._optional_document_type_code(document_type_code), status_value=normalized_status, include_deleted=False, limit=max(1, min(int(limit or 20), 100)), @@ -177,8 +168,8 @@ class LegalDocumentDraftService: if template is None or not template.enabled: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="template not found") return template - normalized_document_type_code = document_type_code.strip().lower().replace("-", "_") - candidates = TEMPLATE_DOCUMENT_TYPE_CODE_ALIASES.get(normalized_document_type_code, [normalized_document_type_code]) + normalized_document_type_code = canonical_document_type_code(document_type_code) + candidates = document_type_candidates(normalized_document_type_code) for candidate in candidates: templates = repository.list_enabled(tenant_id=tenant_id, document_type_code=candidate) if templates: @@ -351,6 +342,12 @@ class LegalDocumentDraftService: return None return str(value).strip() + @staticmethod + def _optional_document_type_code(value: Any) -> Optional[str]: + if value is None or not str(value).strip(): + return None + return canonical_document_type_code(str(value)) + @staticmethod def _now() -> datetime: return datetime.now(timezone.utc) diff --git a/backend/app/services/legal_document_element_schema_service.py b/backend/app/services/legal_document_element_schema_service.py index e3445e37..d8cb35a8 100644 --- a/backend/app/services/legal_document_element_schema_service.py +++ b/backend/app/services/legal_document_element_schema_service.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from app.models.legal_document_element_schema import LegalDocumentElementSchema from app.repositories.legal_document_element_schema_repository import LegalDocumentElementSchemaRepository from app.repositories.legal_document_type_repository import LegalDocumentTypeRepository +from app.services.legal_domain_registry import canonical_document_type_code DOCUMENT_TYPE_CODE_PATTERN = re.compile(r"^[a-z0-9_]{2,64}$") @@ -1686,7 +1687,7 @@ class LegalDocumentElementSchemaService: @classmethod def _normalize_code(cls, value: str) -> str: - normalized = cls._required_text(value, "document_type_code").lower().replace("-", "_") + normalized = canonical_document_type_code(cls._required_text(value, "document_type_code")) if not DOCUMENT_TYPE_CODE_PATTERN.fullmatch(normalized): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="document_type_code must use lowercase letters, numbers, and underscores") return normalized diff --git a/backend/app/services/legal_document_type_service.py b/backend/app/services/legal_document_type_service.py index 4a46aaea..5abd463d 100644 --- a/backend/app/services/legal_document_type_service.py +++ b/backend/app/services/legal_document_type_service.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app.models.legal_document_type import LegalDocumentType from app.repositories.legal_document_type_repository import LegalDocumentTypeRepository -from app.services.legal_domain_registry import supports_ai_enhanced_generation +from app.services.legal_domain_registry import canonical_document_type_code, supports_ai_enhanced_generation DOCUMENT_TYPE_CODE_PATTERN = re.compile(r"^[a-z0-9_]{2,64}$") @@ -444,7 +444,7 @@ class LegalDocumentTypeService: @classmethod def _normalize_code(cls, value: str) -> str: - normalized = cls._required_text(value, "code").lower().replace("-", "_") + normalized = canonical_document_type_code(cls._required_text(value, "code")) if not DOCUMENT_TYPE_CODE_PATTERN.fullmatch(normalized): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="code must use lowercase letters, numbers, and underscores") return normalized diff --git a/backend/app/services/legal_domain_registry.py b/backend/app/services/legal_domain_registry.py index dcab9ba8..e14ef78e 100644 --- a/backend/app/services/legal_domain_registry.py +++ b/backend/app/services/legal_domain_registry.py @@ -104,6 +104,7 @@ DOCUMENT_TYPE_LEGAL_QUERY_TERMS = { } TEMPLATE_DOCUMENT_TYPE_CODE_ALIASES = { + "contract": ["procurement_goods", "contract"], "property_service": ["property_service", "property_service_agreement"], "property_service_agreement": ["property_service", "property_service_agreement"], "rescission_notice": ["rescission_notice", "contract_termination_notice"], @@ -697,6 +698,13 @@ def normalize_domain_key(value: Optional[str]) -> str: return str(value or "").strip().lower().replace("-", "_") +def canonical_document_type_code(value: Optional[str]) -> str: + normalized = normalize_domain_key(value) + if not normalized: + return "" + return TEMPLATE_DOCUMENT_TYPE_CODE_ALIASES.get(normalized, [normalized])[0] + + def supports_ai_enhanced_generation(document_type_code: Optional[str]) -> bool: normalized = normalize_domain_key(document_type_code) if not normalized: diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 00000000..2a4afbef --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,249 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy.orm import Session + +from app.services.contract_generation_service import ContractGenerationService + + +NOTIFICATION_SCOPES = {"mine", "all"} + + +class NotificationService: + def __init__(self, db: Session): + self.db = db + self.contract_generation_service = ContractGenerationService(db) + + def list_notifications( + self, + current_user: dict[str, Any], + *, + limit: int = 20, + scope: str = "mine", + ) -> dict[str, Any]: + normalized_limit = max(min(int(limit or 20), 100), 1) + normalized_scope = self._normalize_scope(scope) + contract_response = self.contract_generation_service.list_generated_contracts( + current_user=current_user, + page=1, + page_size=200, + ) + items: list[dict[str, Any]] = [] + for contract in contract_response.get("items", []): + items.extend(self._build_ledger_notifications(contract, current_user, normalized_scope)) + items.extend(self._build_evidence_gap_notifications(contract, current_user, normalized_scope)) + + deduped = self._dedupe_notifications(items) + sorted_items = sorted( + deduped, + key=lambda item: ( + self._priority_weight(item.get("priority")), + self._time_weight(item.get("due_at")), + self._time_weight(item.get("created_at")), + str(item.get("id") or ""), + ), + ) + return { + "items": sorted_items[:normalized_limit], + "total": len(sorted_items), + "limit": normalized_limit, + "scope": normalized_scope, + "summary": self._build_summary(sorted_items), + } + + def _build_ledger_notifications( + self, + contract: dict[str, Any], + current_user: dict[str, Any], + scope: str, + ) -> list[dict[str, Any]]: + attention_items = (contract.get("ledger_attention") or {}).get("items") or [] + notifications: list[dict[str, Any]] = [] + for attention in attention_items: + is_mine = bool(attention.get("is_mine")) + if scope == "mine" and not is_mine: + continue + notification_type = self._notification_type_from_attention(attention.get("code")) + notifications.append( + self._to_notification_item( + contract=contract, + notification_id=f"contract:{contract.get('id')}:{attention.get('code')}", + notification_type=notification_type, + title=str(attention.get("label") or "合同待办"), + description=str(attention.get("description") or "请进入合同详情继续处理。"), + priority=str(attention.get("priority") or "medium"), + action_label=str(attention.get("action_label") or "处理"), + href=str(attention.get("action_href") or f"/contracts/{contract.get('id')}"), + is_mine=is_mine, + due_at=attention.get("due_at") or (contract.get("lifecycle") or {}).get("expires_at"), + source_type="contract_attention", + source_id=str(attention.get("code") or contract.get("id")), + metadata={"attention": attention}, + ) + ) + return notifications + + def _build_evidence_gap_notifications( + self, + contract: dict[str, Any], + current_user: dict[str, Any], + scope: str, + ) -> list[dict[str, Any]]: + if scope == "mine" and not self._is_current_user_contract_owner_or_admin(contract, current_user): + return [] + + lifecycle = contract.get("lifecycle") or {} + lifecycle_status = lifecycle.get("status") or "generated" + signoff_archive = contract.get("signoff_archive") or {} + evidence_summary = signoff_archive.get("summary") or {} + notifications: list[dict[str, Any]] = [] + + if lifecycle_status == "approved" and not evidence_summary.get("has_signoff_evidence"): + notifications.append( + self._to_notification_item( + contract=contract, + notification_id=f"contract:{contract.get('id')}:signoff_evidence_missing", + notification_type="signoff_evidence_missing", + title="待上传签署证据", + description="合同已审批通过,请上传签署版或盖章确认文件。", + priority="high", + action_label="上传签署证据", + href=f"/contracts/{contract.get('id')}", + is_mine=True, + due_at=None, + source_type="contract", + source_id=str(contract.get("id") or ""), + metadata={"lifecycle_status": lifecycle_status}, + ) + ) + + if lifecycle_status in {"expired", "terminated", "archived"} and not evidence_summary.get("has_archive_evidence"): + notifications.append( + self._to_notification_item( + contract=contract, + notification_id=f"contract:{contract.get('id')}:archive_evidence_missing", + notification_type="archive_evidence_missing", + title="待上传归档证据", + description="合同已到终止、到期或归档阶段,请补齐最终签署版、终止确认或归档清单。", + priority="high" if lifecycle_status != "archived" else "critical", + action_label="上传归档证据", + href=f"/contracts/{contract.get('id')}", + is_mine=True, + due_at=lifecycle.get("expires_at"), + source_type="contract", + source_id=str(contract.get("id") or ""), + metadata={"lifecycle_status": lifecycle_status}, + ) + ) + + return notifications + + @staticmethod + def _to_notification_item( + *, + contract: dict[str, Any], + notification_id: str, + notification_type: str, + title: str, + description: str, + priority: str, + action_label: str, + href: str, + is_mine: bool, + due_at: Optional[str], + source_type: str, + source_id: str, + metadata: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + return { + "id": notification_id, + "type": notification_type, + "title": title, + "description": description, + "priority": priority, + "status": "open", + "action_label": action_label, + "href": href, + "source_type": source_type, + "source_id": source_id, + "contract_id": contract.get("id"), + "contract_title": contract.get("template_name"), + "is_mine": is_mine, + "due_at": due_at, + "created_at": (contract.get("lifecycle") or {}).get("updated_at") or contract.get("generated_at"), + "metadata": metadata or {}, + } + + @staticmethod + def _notification_type_from_attention(code: Any) -> str: + normalized = str(code or "") + if normalized == "approval_decision": + return "approval_pending" + if normalized == "approval_resubmit": + return "approval_rejected" + if normalized in {"contract_overdue", "contract_due_soon"}: + return "renewal_reminder" + if normalized == "high_risk_review": + return "risk_review" + if normalized.startswith("lifecycle_task_signoff"): + return "signoff_evidence" + if normalized.startswith("lifecycle_task_termination_archive"): + return "archive_evidence" + if normalized.startswith("lifecycle_task_renewal_reminder"): + return "renewal_reminder" + if normalized.startswith("lifecycle_task_"): + return "lifecycle_task" + return "contract_attention" + + @staticmethod + def _is_current_user_contract_owner_or_admin(contract: dict[str, Any], current_user: dict[str, Any]) -> bool: + if current_user.get("role") == "admin": + return True + user_id = str(current_user.get("id") or "") + generated_by = str((contract.get("metadata") or {}).get("generated_by") or "") + return bool(user_id and user_id == generated_by) + + @staticmethod + def _dedupe_notifications(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + deduped: dict[str, dict[str, Any]] = {} + for item in items: + item_id = str(item.get("id") or "") + if item_id and item_id not in deduped: + deduped[item_id] = item + return list(deduped.values()) + + @staticmethod + def _build_summary(items: list[dict[str, Any]]) -> dict[str, Any]: + by_type: dict[str, int] = {} + by_priority: dict[str, int] = {} + for item in items: + by_type[str(item.get("type") or "unknown")] = by_type.get(str(item.get("type") or "unknown"), 0) + 1 + by_priority[str(item.get("priority") or "medium")] = by_priority.get(str(item.get("priority") or "medium"), 0) + 1 + return { + "total": len(items), + "mine": len([item for item in items if item.get("is_mine")]), + "critical": by_priority.get("critical", 0), + "high": by_priority.get("high", 0), + "by_type": by_type, + "by_priority": by_priority, + } + + @staticmethod + def _normalize_scope(value: str) -> str: + normalized = str(value or "mine").strip().lower() + if normalized not in NOTIFICATION_SCOPES: + return "mine" + return normalized + + @staticmethod + def _priority_weight(value: Any) -> int: + return {"critical": 0, "high": 1, "medium": 2, "low": 3}.get(str(value or "medium"), 9) + + @staticmethod + def _time_weight(value: Any) -> float: + if not value: + return float("inf") + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() + except ValueError: + return datetime.now(timezone.utc).timestamp() diff --git a/backend/app/services/operation_kpi_service.py b/backend/app/services/operation_kpi_service.py index 95aca7e3..fb303b6e 100644 --- a/backend/app/services/operation_kpi_service.py +++ b/backend/app/services/operation_kpi_service.py @@ -21,6 +21,8 @@ from app.services.tenant_service import TenantService class OperationKpiService: + LEGAL_CITATION_SCAN_LIMIT = 500 + def __init__(self, db: Session): self.db = db self.tenant_service = TenantService(db) @@ -296,14 +298,24 @@ class OperationKpiService: } def _build_legal_article_vectorization_summary(self, tenant_id: str) -> dict[str, Any]: - articles = self.db.query(LegalArticle).filter(LegalArticle.tenant_id == tenant_id).all() - total_articles = len(articles) - vectorized_articles = sum(1 for article in articles if article.semantic_vector_version) + total_articles = int( + self.db.query(func.count(LegalArticle.id)) + .filter(LegalArticle.tenant_id == tenant_id) + .scalar() + or 0 + ) + vectorized_articles = int( + self.db.query(func.count(LegalArticle.id)) + .filter(LegalArticle.tenant_id == tenant_id, LegalArticle.semantic_vector_version.isnot(None)) + .scalar() + or 0 + ) pending_articles = max(total_articles - vectorized_articles, 0) - latest_vectorized = max( - [article for article in articles if article.semantic_vector_updated_at is not None], - key=lambda item: item.semantic_vector_updated_at, - default=None, + latest_vectorized = ( + self.db.query(LegalArticle) + .filter(LegalArticle.tenant_id == tenant_id, LegalArticle.semantic_vector_updated_at.isnot(None)) + .order_by(LegalArticle.semantic_vector_updated_at.desc(), LegalArticle.updated_at.desc()) + .first() ) return { "total_articles": total_articles, @@ -328,9 +340,25 @@ class OperationKpiService: contracts_query = contracts_query.filter(GeneratedContract.created_at >= start_at) messages_query = messages_query.filter(Message.created_at >= start_at) - review_results = review_results_query.all() - contracts = contracts_query.all() - messages = messages_query.all() + review_results_scanned = int(review_results_query.with_entities(func.count(ReviewResult.id)).scalar() or 0) + contracts_scanned = int(contracts_query.with_entities(func.count(GeneratedContract.id)).scalar() or 0) + chat_messages_scanned = int(messages_query.with_entities(func.count(Message.id)).scalar() or 0) + + review_results = ( + review_results_query.order_by(ReviewResult.created_at.desc()) + .limit(self.LEGAL_CITATION_SCAN_LIMIT) + .all() + ) + contracts = ( + contracts_query.order_by(GeneratedContract.created_at.desc()) + .limit(self.LEGAL_CITATION_SCAN_LIMIT) + .all() + ) + messages = ( + messages_query.order_by(Message.created_at.desc()) + .limit(self.LEGAL_CITATION_SCAN_LIMIT) + .all() + ) review_refs: list[dict[str, Any]] = [] review_reports_with_refs = 0 @@ -385,11 +413,14 @@ class OperationKpiService: ) return { - "review_results_scanned": len(review_results), + "review_results_scanned": review_results_scanned, + "review_results_sampled": len(review_results), "review_reports_with_legal_refs": review_reports_with_refs, - "generated_contracts_scanned": len(contracts), + "generated_contracts_scanned": contracts_scanned, + "generated_contracts_sampled": len(contracts), "generated_contracts_with_legal_refs": generated_contracts_with_refs, - "chat_messages_scanned": len(messages), + "chat_messages_scanned": chat_messages_scanned, + "chat_messages_sampled": len(messages), "chat_messages_with_legal_refs": chat_messages_with_refs, "total_reference_count": len(all_refs), "unique_reference_count": len(ref_ids), @@ -401,55 +432,84 @@ class OperationKpiService: } def _build_conversion_funnel(self) -> dict[str, Any]: - tenant_rows = self.db.query(Tenant).all() - order_rows = self.db.query(CommercialOrder).all() - plan_distribution: dict[tuple[str, str, str], int] = {} - for tenant in tenant_rows: - key = ( - tenant.plan_code or "trial", - tenant.plan_name or tenant.plan_code or "trial", - tenant.billing_status or "unknown", + total_tenants = int(self.db.query(func.count(Tenant.id)).scalar() or 0) + plan_rows = ( + self.db.query( + Tenant.plan_code, + Tenant.plan_name, + Tenant.billing_status, + func.count(Tenant.id).label("tenant_count"), ) - plan_distribution[key] = plan_distribution.get(key, 0) + 1 + .group_by(Tenant.plan_code, Tenant.plan_name, Tenant.billing_status) + .all() + ) + plan_distribution = [ + { + "plan_code": row.plan_code or "trial", + "plan_name": row.plan_name or row.plan_code or "trial", + "billing_status": row.billing_status or "unknown", + "tenant_count": int(row.tenant_count or 0), + } + for row in sorted( + plan_rows, + key=lambda item: ( + item.plan_code or "trial", + item.plan_name or item.plan_code or "trial", + item.billing_status or "unknown", + ), + ) + ] + trial_or_free_count = sum( + item["tenant_count"] + for item in plan_distribution + if str(item["billing_status"]).lower() == "trialing" or str(item["plan_code"]).lower() in {"trial", "free"} + ) + active_paid_count = sum( + item["tenant_count"] + for item in plan_distribution + if str(item["billing_status"]).lower() == "active" and str(item["plan_code"]).lower() not in {"trial", "free"} + ) + expired_or_blocked_count = sum( + item["tenant_count"] + for item in plan_distribution + if str(item["billing_status"]).lower() in {"expired", "past_due", "suspended"} + ) - trial_tenants = [ - tenant - for tenant in tenant_rows - if (tenant.billing_status or "").lower() == "trialing" or (tenant.plan_code or "").lower() in {"trial", "free"} - ] - active_paid_tenants = [ - tenant - for tenant in tenant_rows - if (tenant.billing_status or "").lower() == "active" and (tenant.plan_code or "").lower() not in {"trial", "free"} - ] - expired_trial_tenants = [ - tenant - for tenant in tenant_rows - if (tenant.billing_status or "").lower() in {"expired", "past_due", "suspended"} - ] - activated_orders = [order for order in order_rows if order.status == "activated"] - pending_orders = [order for order in order_rows if order.status == "pending_payment"] - activated_amount_cents = sum(order.amount_cents or 0 for order in activated_orders) + order_status_rows = ( + self.db.query( + CommercialOrder.plan_code, + CommercialOrder.plan_name, + CommercialOrder.status, + func.count(CommercialOrder.id).label("order_count"), + func.coalesce(func.sum(CommercialOrder.amount_cents), 0).label("amount_cents"), + ) + .group_by(CommercialOrder.plan_code, CommercialOrder.plan_name, CommercialOrder.status) + .all() + ) + pending_order_count = sum(int(row.order_count or 0) for row in order_status_rows if row.status == "pending_payment") + activated_order_count = sum(int(row.order_count or 0) for row in order_status_rows if row.status == "activated") + activated_amount_cents = sum(int(row.amount_cents or 0) for row in order_status_rows if row.status == "activated") return { - "total_tenants": len(tenant_rows), - "trial_or_free_tenants": len(trial_tenants), - "active_paid_tenants": len(active_paid_tenants), - "expired_or_blocked_tenants": len(expired_trial_tenants), - "pending_order_count": len(pending_orders), - "activated_order_count": len(activated_orders), + "total_tenants": total_tenants, + "trial_or_free_tenants": trial_or_free_count, + "active_paid_tenants": active_paid_count, + "expired_or_blocked_tenants": expired_or_blocked_count, + "pending_order_count": pending_order_count, + "activated_order_count": activated_order_count, "activated_amount_cents": activated_amount_cents, - "trial_to_paid_rate_percent": self._percent(len(active_paid_tenants), len(trial_tenants) + len(active_paid_tenants)), - "plan_distribution": [ + "trial_to_paid_rate_percent": self._percent(active_paid_count, trial_or_free_count + active_paid_count), + "plan_distribution": plan_distribution, + "order_status_distribution": [ { - "plan_code": plan_code, - "plan_name": plan_name, - "billing_status": billing_status, - "tenant_count": count, + "plan_code": row.plan_code, + "plan_name": row.plan_name, + "status": row.status, + "order_count": int(row.order_count or 0), + "amount_cents": int(row.amount_cents or 0), } - for (plan_code, plan_name, billing_status), count in sorted(plan_distribution.items()) + for row in order_status_rows ], - "order_status_distribution": self._build_order_status_distribution(order_rows), } def _build_order_status_distribution(self, order_rows: Iterable[CommercialOrder]) -> list[dict[str, Any]]: diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py new file mode 100644 index 00000000..51ac912e --- /dev/null +++ b/backend/app/services/search_service.py @@ -0,0 +1,273 @@ +from typing import Any, Optional + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.core.contract_permissions import can_view_all_contracts +from app.models.contract_template import ContractTemplate +from app.models.file import File +from app.models.generated_contract import GeneratedContract +from app.models.review_task import ReviewTask +from app.repositories.contract_approval_step_repository import ContractApprovalStepRepository + + +SEARCH_TYPES = {"contract", "review", "file", "template"} + + +class SearchService: + def __init__(self, db: Session): + self.db = db + self.contract_approval_step_repository = ContractApprovalStepRepository(db) + + def search( + self, + current_user: dict[str, Any], + *, + q: str, + types: Optional[str] = None, + limit: int = 8, + ) -> dict[str, Any]: + query_text = self._normalize_query(q) + normalized_limit = max(min(int(limit or 8), 20), 1) + requested_types = self._normalize_types(types) + + grouped = { + "contracts": self._search_contracts(current_user, query_text, normalized_limit) if "contract" in requested_types else [], + "reviews": self._search_reviews(current_user, query_text, normalized_limit) if "review" in requested_types else [], + "files": self._search_files(current_user, query_text, normalized_limit) if "file" in requested_types else [], + "templates": self._search_templates(current_user, query_text, normalized_limit) if "template" in requested_types else [], + } + items = [item for group in grouped.values() for item in group] + items.sort(key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True) + by_type = { + "contract": len(grouped["contracts"]), + "review": len(grouped["reviews"]), + "file": len(grouped["files"]), + "template": len(grouped["templates"]), + } + return { + "query": query_text, + "types": sorted(requested_types), + "limit": normalized_limit, + "items": items, + "groups": grouped, + "summary": { + "total": len(items), + "by_type": by_type, + }, + } + + @staticmethod + def _normalize_query(value: str) -> str: + normalized = str(value or "").strip() + if len(normalized) > 120: + normalized = normalized[:120] + return normalized + + @staticmethod + def _normalize_types(value: Optional[str]) -> set[str]: + if not value: + return set(SEARCH_TYPES) + requested = {item.strip().lower() for item in value.split(",") if item.strip()} + selected = requested & SEARCH_TYPES + return selected or set(SEARCH_TYPES) + + @staticmethod + def _like(query_text: str) -> str: + return f"%{query_text}%" + + def _search_contracts(self, current_user: dict[str, Any], query_text: str, limit: int) -> list[dict[str, Any]]: + tenant_id = current_user["tenant_id"] + query = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id) + + if not can_view_all_contracts(current_user): + user_id = str(current_user.get("id") or "") + participant_contract_ids = self.contract_approval_step_repository.list_contract_ids_by_participant(tenant_id, user_id) + visibility_conditions = [GeneratedContract.generated_by == user_id] + if participant_contract_ids: + visibility_conditions.append(GeneratedContract.id.in_(participant_contract_ids)) + query = query.filter(or_(*visibility_conditions)) + + if query_text: + like = self._like(query_text) + query = query.filter( + or_( + GeneratedContract.template_name.ilike(like), + GeneratedContract.file_name.ilike(like), + GeneratedContract.generated_by_name.ilike(like), + GeneratedContract.counterparty_name.ilike(like), + GeneratedContract.owner_name.ilike(like), + GeneratedContract.lifecycle_note.ilike(like), + ) + ) + + contracts = query.order_by(GeneratedContract.created_at.desc()).limit(limit).all() + return [self._contract_item(item, query_text) for item in contracts] + + def _search_reviews(self, current_user: dict[str, Any], query_text: str, limit: int) -> list[dict[str, Any]]: + query = self.db.query(ReviewTask).filter( + ReviewTask.tenant_id == current_user["tenant_id"], + ReviewTask.user_id == str(current_user.get("id") or ""), + ) + if query_text: + like = self._like(query_text) + query = query.filter( + or_( + ReviewTask.title.ilike(like), + ReviewTask.contract_type.ilike(like), + ReviewTask.risk_level.ilike(like), + ReviewTask.error_message.ilike(like), + ) + ) + reviews = query.order_by(ReviewTask.created_at.desc()).limit(limit).all() + return [self._review_item(item, query_text) for item in reviews] + + def _search_files(self, current_user: dict[str, Any], query_text: str, limit: int) -> list[dict[str, Any]]: + if current_user.get("role") != "admin": + return [] + query = self.db.query(File).filter( + File.tenant_id == current_user["tenant_id"], + File.status == "active", + ) + if query_text: + like = self._like(query_text) + query = query.filter( + or_( + File.file_name.ilike(like), + File.file_type.ilike(like), + File.mime_type.ilike(like), + File.bucket.ilike(like), + ) + ) + files = query.order_by(File.created_at.desc()).limit(limit).all() + return [self._file_item(item, query_text) for item in files] + + def _search_templates(self, current_user: dict[str, Any], query_text: str, limit: int) -> list[dict[str, Any]]: + query = self.db.query(ContractTemplate).filter(ContractTemplate.tenant_id == current_user["tenant_id"]) + if current_user.get("role") != "admin": + query = query.filter(ContractTemplate.enabled.is_(True)) + if query_text: + like = self._like(query_text) + query = query.filter( + or_( + ContractTemplate.name.ilike(like), + ContractTemplate.description.ilike(like), + ContractTemplate.contract_type.ilike(like), + ContractTemplate.document_type_code.ilike(like), + ContractTemplate.template_code.ilike(like), + ContractTemplate.template_category.ilike(like), + ContractTemplate.industry_category.ilike(like), + ) + ) + templates = query.order_by(ContractTemplate.updated_at.desc(), ContractTemplate.created_at.desc()).limit(limit).all() + return [self._template_item(item, query_text, is_admin=current_user.get("role") == "admin") for item in templates] + + @staticmethod + def _snippet(*values: Optional[str], query_text: str = "") -> Optional[str]: + for value in values: + text = str(value or "").strip() + if not text: + continue + if not query_text: + return text[:160] + index = text.lower().find(query_text.lower()) + if index < 0: + continue + start = max(index - 40, 0) + end = min(index + len(query_text) + 80, len(text)) + prefix = "..." if start > 0 else "" + suffix = "..." if end < len(text) else "" + return f"{prefix}{text[start:end]}{suffix}" + for value in values: + text = str(value or "").strip() + if text: + return text[:160] + return None + + def _contract_item(self, item: GeneratedContract, query_text: str) -> dict[str, Any]: + return { + "id": item.id, + "type": "contract", + "title": item.template_name, + "subtitle": item.counterparty_name or item.file_name, + "snippet": self._snippet(item.counterparty_name, item.owner_name, item.lifecycle_note, item.file_name, query_text=query_text), + "href": f"/contracts/{item.id}", + "status": item.lifecycle_status or item.status, + "tags": [value for value in [item.contract_type, item.document_type_code, item.generated_by_name] if value], + "created_at": item.created_at.isoformat() if item.created_at else None, + "updated_at": item.lifecycle_updated_at.isoformat() if item.lifecycle_updated_at else (item.created_at.isoformat() if item.created_at else None), + "metadata": { + "contract_type": item.contract_type, + "document_type_code": item.document_type_code, + "file_name": item.file_name, + "generated_by": item.generated_by, + "generated_by_name": item.generated_by_name, + "counterparty_name": item.counterparty_name, + "owner_name": item.owner_name, + "lifecycle_status": item.lifecycle_status, + }, + } + + def _review_item(self, item: ReviewTask, query_text: str) -> dict[str, Any]: + return { + "id": item.id, + "type": "review", + "title": item.title, + "subtitle": item.contract_type, + "snippet": self._snippet(item.error_message, item.risk_level, item.contract_type, query_text=query_text), + "href": f"/reviews/{item.id}", + "status": item.status, + "tags": [value for value in [item.contract_type, item.risk_level, item.review_as] if value], + "created_at": item.created_at.isoformat() if item.created_at else None, + "updated_at": item.completed_at.isoformat() if item.completed_at else (item.created_at.isoformat() if item.created_at else None), + "metadata": { + "contract_type": item.contract_type, + "review_as": item.review_as, + "risk_level": item.risk_level, + "source_type": item.source_type, + "source_file_id": item.source_file_id, + }, + } + + def _file_item(self, item: File, query_text: str) -> dict[str, Any]: + return { + "id": item.id, + "type": "file", + "title": item.file_name, + "subtitle": item.file_type, + "snippet": self._snippet(item.mime_type, item.bucket, item.object_key, query_text=query_text), + "href": f"/files?highlight={item.id}", + "status": item.status, + "tags": [value for value in [item.file_type, item.mime_type, item.bucket] if value], + "created_at": item.created_at.isoformat() if item.created_at else None, + "updated_at": item.created_at.isoformat() if item.created_at else None, + "metadata": { + "file_name": item.file_name, + "file_type": item.file_type, + "mime_type": item.mime_type, + "file_size": item.file_size, + "uploaded_by": item.uploaded_by, + }, + } + + def _template_item(self, item: ContractTemplate, query_text: str, *, is_admin: bool) -> dict[str, Any]: + return { + "id": item.id, + "type": "template", + "title": item.name, + "subtitle": item.description, + "snippet": self._snippet(item.description, item.contract_type, item.document_type_code, query_text=query_text), + "href": "/templates" if is_admin else f"/contracts?template={item.id}", + "status": "enabled" if item.enabled else "disabled", + "tags": [value for value in [item.contract_type, item.document_type_code, item.template_category, item.industry_category] if value], + "created_at": item.created_at.isoformat() if item.created_at else None, + "updated_at": item.updated_at.isoformat() if item.updated_at else (item.created_at.isoformat() if item.created_at else None), + "metadata": { + "contract_type": item.contract_type, + "document_type_code": item.document_type_code, + "template_code": item.template_code, + "template_category": item.template_category, + "industry_category": item.industry_category, + "enabled": item.enabled, + }, + } diff --git a/backend/migrations/versions/20260619_0045_create_generated_contract_version_comments.py b/backend/migrations/versions/20260619_0045_create_generated_contract_version_comments.py new file mode 100644 index 00000000..ef96c81b --- /dev/null +++ b/backend/migrations/versions/20260619_0045_create_generated_contract_version_comments.py @@ -0,0 +1,74 @@ +"""create generated contract version comments""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = "20260619_0045" +down_revision = "20260618_0044" +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 "generated_contract_version_comments" in _table_names(inspector): + return + + op.create_table( + "generated_contract_version_comments", + 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("from_version_id", sa.String(length=64), nullable=True), + sa.Column("to_version_id", sa.String(length=64), nullable=True), + sa.Column("diff_key", sa.String(length=80), nullable=False), + sa.Column("change_type", sa.String(length=32), nullable=False, server_default="modified"), + sa.Column("old_text", sa.Text(), nullable=True), + sa.Column("new_text", sa.Text(), nullable=True), + sa.Column("comment", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="open"), + sa.Column("source", sa.String(length=64), nullable=False, server_default="version_diff"), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("created_by_name", sa.String(length=100), nullable=True), + sa.Column("resolved_by", sa.String(length=64), nullable=True), + sa.Column("resolved_by_name", sa.String(length=100), nullable=True), + sa.Column("resolved_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_generated_contract_version_comments_change_type", "generated_contract_version_comments", ["change_type"]) + op.create_index("ix_generated_contract_version_comments_contract_id", "generated_contract_version_comments", ["contract_id"]) + op.create_index("ix_generated_contract_version_comments_created_at", "generated_contract_version_comments", ["created_at"]) + op.create_index("ix_generated_contract_version_comments_created_by", "generated_contract_version_comments", ["created_by"]) + op.create_index("ix_generated_contract_version_comments_tenant_diff_key", "generated_contract_version_comments", ["tenant_id", "contract_id", "diff_key"]) + op.create_index("ix_generated_contract_version_comments_from_version_id", "generated_contract_version_comments", ["from_version_id"]) + op.create_index("ix_generated_contract_version_comments_source", "generated_contract_version_comments", ["source"]) + op.create_index("ix_generated_contract_version_comments_status", "generated_contract_version_comments", ["status"]) + op.create_index("ix_generated_contract_version_comments_tenant_contract", "generated_contract_version_comments", ["tenant_id", "contract_id"]) + op.create_index("ix_generated_contract_version_comments_tenant_id", "generated_contract_version_comments", ["tenant_id"]) + op.create_index("ix_generated_contract_version_comments_tenant_status", "generated_contract_version_comments", ["tenant_id", "status"]) + op.create_index("ix_generated_contract_version_comments_to_version_id", "generated_contract_version_comments", ["to_version_id"]) + op.create_index( + "ix_generated_contract_version_comments_version_pair", + "generated_contract_version_comments", + ["tenant_id", "contract_id", "from_version_id", "to_version_id"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "generated_contract_version_comments" not in _table_names(inspector): + return + op.drop_table("generated_contract_version_comments") diff --git a/backend/tests/test_audit_log_service.py b/backend/tests/test_audit_log_service.py index 55f9bc83..157f02f5 100644 --- a/backend/tests/test_audit_log_service.py +++ b/backend/tests/test_audit_log_service.py @@ -3,7 +3,14 @@ from datetime import datetime, timezone from types import SimpleNamespace from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.db import Base +from app.models.audit_log import AuditLog +from app.models.contract_approval_step import ContractApprovalStep +from app.models.generated_contract import GeneratedContract +from app.models.user import User from app.services.audit_log_service import AuditLogService @@ -103,6 +110,163 @@ class AuditLogServiceTestCase(unittest.TestCase): self.assertEqual(len(result["items"]), 1) self.assertEqual(result["items"][0]["resource_id"], "review-1") +class AuditLogServiceContractTimelineTestCase(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=self.engine) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) + self.db = self.SessionLocal() + self.service = AuditLogService(self.db) + self.now = datetime.now(timezone.utc) + self.current_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin"} + self.member_user = {"id": "user-member", "tenant_id": "tenant-demo-001", "display_name": "成员", "role": "member"} + self.approver_user = {"id": "user-approver", "tenant_id": "tenant-demo-001", "display_name": "审批人", "role": "member"} + self.outsider_user = {"id": "user-outsider", "tenant_id": "tenant-demo-001", "display_name": "无关成员", "role": "member"} + self._seed_contract("contract-audit-001", generated_by="user-member", generated_by_name="成员") + self._seed_contract("contract-other-001", generated_by="user-member", generated_by_name="成员") + self._seed_users() + + def tearDown(self): + self.db.close() + self.engine.dispose() + + def _seed_users(self): + self.db.add_all( + [ + User( + id="user-admin", + tenant_id="tenant-demo-001", + username="admin@example.com", + display_name="管理员", + password_hash="hash", + role="admin", + ), + User( + id="user-member", + tenant_id="tenant-demo-001", + username="member@example.com", + display_name="成员", + password_hash="hash", + role="member", + ), + User( + id="user-approver", + tenant_id="tenant-demo-001", + username="approver@example.com", + display_name="审批人", + password_hash="hash", + role="member", + ), + ] + ) + self.db.commit() + + def _seed_contract(self, contract_id: str, *, generated_by: str, generated_by_name: str): + self.db.add( + GeneratedContract( + id=contract_id, + tenant_id="tenant-demo-001", + template_id="tpl-audit", + template_name="审计合同模板", + contract_type="procurement", + document_type_code="procurement_goods", + generated_by=generated_by, + generated_by_name=generated_by_name, + export_format="docx", + file_id=None, + file_name=f"{contract_id}.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=10, + generated_content="contract content", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + lifecycle_status="generated", + created_at=self.now, + ) + ) + self.db.commit() + + def _seed_audit_logs(self): + self.db.add_all( + [ + AuditLog( + id="audit-contract-direct", + tenant_id="tenant-demo-001", + user_id="user-member", + action="contract_lifecycle_update", + resource_type="contract", + resource_id="contract-audit-001", + detail_json={"lifecycle_status": "effective"}, + created_at=self.now, + ), + AuditLog( + id="audit-contract-detail", + tenant_id="tenant-demo-001", + user_id="user-approver", + action="contract_approval_submit", + resource_type="contract_approval", + resource_id="approval-audit-001", + detail_json={"contract_id": "contract-audit-001", "status": "pending"}, + created_at=self.now, + ), + AuditLog( + id="audit-other-contract", + tenant_id="tenant-demo-001", + user_id="user-member", + action="contract_lifecycle_update", + resource_type="contract", + resource_id="contract-other-001", + detail_json={"lifecycle_status": "effective"}, + created_at=self.now, + ), + ] + ) + self.db.commit() + + def test_contract_timeline_merges_direct_resource_and_detail_contract_logs(self): + self._seed_audit_logs() + + result = self.service.list_contract_timeline(self.current_user, "contract-audit-001") + + self.assertEqual(result["contract_id"], "contract-audit-001") + self.assertEqual(result["total"], 2) + self.assertEqual({item["id"] for item in result["items"]}, {"audit-contract-direct", "audit-contract-detail"}) + self.assertEqual(result["summary"]["by_action"]["contract_lifecycle_update"], 1) + self.assertEqual(result["summary"]["by_action"]["contract_approval_submit"], 1) + self.assertEqual(result["items"][0]["operator"]["display_name"], "成员") + + def test_contract_timeline_allows_approval_participant(self): + self._seed_audit_logs() + self.db.add( + ContractApprovalStep( + id="approval-audit-001", + tenant_id="tenant-demo-001", + contract_id="contract-audit-001", + step_type="legal_review", + status="pending", + title="合同审批", + submitter_id="user-member", + submitter_name="成员", + assignee_id="user-approver", + assignee_name="审批人", + created_at=self.now, + updated_at=self.now, + ) + ) + self.db.commit() + + result = self.service.list_contract_timeline(self.approver_user, "contract-audit-001") + + self.assertEqual(result["total"], 2) + + def test_contract_timeline_hides_contract_from_unrelated_member(self): + with self.assertRaises(HTTPException) as context: + self.service.list_contract_timeline(self.outsider_user, "contract-audit-001") + + self.assertEqual(context.exception.status_code, 404) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_audit_logs_api.py b/backend/tests/test_audit_logs_api.py index f4b56172..666d4eec 100644 --- a/backend/tests/test_audit_logs_api.py +++ b/backend/tests/test_audit_logs_api.py @@ -27,6 +27,32 @@ class StubAuditLogService: "total": 1, } + def list_contract_timeline(self, current_user, contract_id, page, page_size): + if contract_id == "contract-forbidden": + raise HTTPException(status_code=404, detail="contract not found") + return { + "contract_id": contract_id, + "items": [ + { + "id": "audit-contract-123", + "action": "contract_lifecycle_update", + "resource_type": "contract", + "resource_id": contract_id, + "detail_json": {"lifecycle_status": "effective"}, + "created_at": "2026-04-15T00:00:00+00:00", + "operator": {"id": current_user["id"], "display_name": "成员"}, + } + ], + "page": page, + "page_size": page_size, + "total": 1, + "summary": { + "total": 1, + "by_action": {"contract_lifecycle_update": 1}, + "latest_at": "2026-04-15T00:00:00+00:00", + }, + } + class AuditLogsApiTestCase(unittest.TestCase): def setUp(self): @@ -62,6 +88,34 @@ class AuditLogsApiTestCase(unittest.TestCase): self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["message"], "forbidden") + def test_member_can_list_visible_contract_audit_timeline(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + response = self.client.get("/api/v1/audit-logs/contracts/contract-123") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["code"], 0) + self.assertEqual(body["data"]["contract_id"], "contract-123") + self.assertEqual(body["data"]["items"][0]["action"], "contract_lifecycle_update") + self.assertEqual(body["data"]["summary"]["by_action"]["contract_lifecycle_update"], 1) + + def test_contract_audit_timeline_hides_invisible_contract(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + response = self.client.get("/api/v1/audit-logs/contracts/contract-forbidden") + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["message"], "contract not found") + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_contracts_api.py b/backend/tests/test_contracts_api.py index 7eaa7ad7..b97db236 100644 --- a/backend/tests/test_contracts_api.py +++ b/backend/tests/test_contracts_api.py @@ -7,6 +7,8 @@ from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, Va from fastapi.testclient import TestClient from app.api.contracts import get_audit_log_service, get_contract_generation_service, get_tenant_service, require_current_user +from app.api.notifications import require_current_user as require_notification_current_user +from app.api.notifications import get_notification_service from app.main import app from app.services.contract_generation_service import ExportFormat @@ -31,6 +33,36 @@ class StubTenantService: raise HTTPException(status_code=402, detail="quota exceeded") +class StubNotificationService: + def list_notifications(self, current_user, limit=20, scope="mine"): + return { + "items": [ + { + "id": "contract:contract-123:approval_decision", + "type": "approval_pending", + "title": "待我审批", + "description": "合同正在审批流转中。", + "priority": "critical", + "status": "open", + "action_label": "处理审批", + "href": "/contracts/contract-123", + "source_type": "contract_attention", + "source_id": "approval_decision", + "contract_id": "contract-123", + "contract_title": "Procurement Template", + "is_mine": True, + "due_at": None, + "created_at": "2026-04-17T00:00:00+00:00", + "metadata": {}, + } + ], + "total": 1, + "limit": limit, + "scope": scope, + "summary": {"total": 1, "mine": 1, "critical": 1, "high": 0, "by_type": {"approval_pending": 1}, "by_priority": {"critical": 1}}, + } + + class StubContractGenerationService: def __init__(self): self.last_generate_options = None @@ -103,6 +135,13 @@ class StubContractGenerationService: "updated_by": None, "updated_at": None, }, + "ledger_attention": { + "status": "normal", + "is_mine": generated_by == "user-admin", + "primary": None, + "items": [], + "counts": {"total": 0, "mine": 0, "open_tasks": 0, "pending_approval": 0}, + }, "metadata": { "generated_by": generated_by, "generated_by_name": generated_by_name, @@ -119,7 +158,23 @@ class StubContractGenerationService: "tenant_id": tenant_id, } - def list_generated_contracts(self, current_user, page, page_size, contract_type=None, document_type_code=None, lifecycle_status=None, keyword=None): + def list_generated_contracts( + self, + current_user, + page, + page_size, + contract_type=None, + document_type_code=None, + lifecycle_status=None, + keyword=None, + participant_contract_ids=None, + counterparty_name=None, + owner_name=None, + effective_from=None, + effective_to=None, + expires_from=None, + expires_to=None, + ): records = [ self._record("contract-123"), self._record("contract-456", contract_type="employment", document_type_code="labor_contract"), @@ -404,9 +459,109 @@ class StubContractGenerationService: "to_paragraph_count": 3, }, "items": [ - {"change_type": "modified", "old_paragraph_index": 1, "new_paragraph_index": 1, "old_text": "Old", "new_text": "New"}, - {"change_type": "added", "old_paragraph_index": None, "new_paragraph_index": 3, "old_text": None, "new_text": "Payment deadline added."}, + {"index": 1, "diff_key": "diff-1-stub", "change_type": "modified", "old_paragraph_index": 1, "new_paragraph_index": 1, "old_text": "Old", "new_text": "New"}, + {"index": 2, "diff_key": "diff-2-stub", "change_type": "added", "old_paragraph_index": None, "new_paragraph_index": 3, "old_text": None, "new_text": "Payment deadline added."}, ], + "comments": [], + "comment_summary": {"open": 0, "resolved": 0, "dismissed": 0, "total": 0}, + } + + def list_generated_contract_version_comments(self, current_user, contract_id, from_version_id=None, to_version_id=None, status_value=None): + self.get_generated_contract(current_user, contract_id) + items = [ + { + "id": "gcvc-001", + "tenant_id": current_user["tenant_id"], + "contract_id": contract_id, + "from_version_id": from_version_id or "base", + "to_version_id": to_version_id or "gcv-001", + "diff_key": "diff-1-stub", + "change_type": "modified", + "old_text": "Old", + "new_text": "New", + "comment": "Please align payment wording.", + "status": "open", + "source": "version_diff", + "created_by": current_user["id"], + "created_by_name": "Admin", + "resolved_by": None, + "resolved_by_name": None, + "resolved_at": None, + "metadata": {}, + "created_at": "2026-06-19T00:00:00+00:00", + "updated_at": "2026-06-19T00:00:00+00:00", + } + ] + if status_value: + items = [item for item in items if item["status"] == status_value] + return { + "contract_id": contract_id, + "items": items, + "total": len(items), + "summary": {"open": len([item for item in items if item["status"] == "open"]), "resolved": 0, "dismissed": 0, "total": len(items)}, + } + + def create_generated_contract_version_comment( + self, + current_user, + contract_id, + from_version_id=None, + to_version_id=None, + diff_key=None, + change_type="modified", + old_text=None, + new_text=None, + comment=None, + source="version_diff", + metadata=None, + ): + self.get_generated_contract(current_user, contract_id) + return { + "id": "gcvc-created-001", + "tenant_id": current_user["tenant_id"], + "contract_id": contract_id, + "from_version_id": from_version_id or "base", + "to_version_id": to_version_id or "gcv-001", + "diff_key": diff_key, + "change_type": change_type, + "old_text": old_text, + "new_text": new_text, + "comment": comment, + "status": "open", + "source": source, + "created_by": current_user["id"], + "created_by_name": "Admin", + "resolved_by": None, + "resolved_by_name": None, + "resolved_at": None, + "metadata": metadata or {}, + "created_at": "2026-06-19T00:00:00+00:00", + "updated_at": "2026-06-19T00:00:00+00:00", + } + + def update_generated_contract_version_comment(self, current_user, contract_id, comment_id, status_value=None, comment=None): + self.get_generated_contract(current_user, contract_id) + return { + "id": comment_id, + "tenant_id": current_user["tenant_id"], + "contract_id": contract_id, + "from_version_id": "base", + "to_version_id": "gcv-001", + "diff_key": "diff-1-stub", + "change_type": "modified", + "old_text": "Old", + "new_text": "New", + "comment": comment or "Please align payment wording.", + "status": status_value or "resolved", + "source": "version_diff", + "created_by": current_user["id"], + "created_by_name": "Admin", + "resolved_by": current_user["id"] if status_value == "resolved" else None, + "resolved_by_name": "Admin" if status_value == "resolved" else None, + "resolved_at": "2026-06-19T00:00:00+00:00" if status_value == "resolved" else None, + "metadata": {}, + "created_at": "2026-06-19T00:00:00+00:00", + "updated_at": "2026-06-19T00:00:00+00:00", } def export_generated_contract_version(self, current_user, contract_id, version_id=None, export_format=ExportFormat.DOCX): @@ -422,6 +577,56 @@ class StubContractGenerationService: "export_format": export_format.value, } + def export_generated_contracts_csv( + self, + current_user, + contract_type=None, + document_type_code=None, + lifecycle_status=None, + keyword=None, + counterparty_name=None, + owner_name=None, + effective_from=None, + effective_to=None, + expires_from=None, + expires_to=None, + limit=1000, + ): + listed = self.list_generated_contracts( + current_user=current_user, + page=1, + page_size=limit, + contract_type=contract_type, + document_type_code=document_type_code, + lifecycle_status=lifecycle_status, + keyword=keyword, + counterparty_name=counterparty_name, + owner_name=owner_name, + effective_from=effective_from, + effective_to=effective_to, + expires_from=expires_from, + expires_to=expires_to, + ) + lines = ["合同ID,合同名称,相对方,负责人"] + for item in listed["items"]: + lines.append( + ",".join( + [ + item["id"], + item["template_name"], + item["lifecycle"].get("counterparty_name") or "", + item["lifecycle"].get("owner_name") or item["metadata"].get("generated_by_name") or "", + ] + ) + ) + return { + "file_name": "contract-ledger-test.csv", + "mime_type": "text/csv; charset=utf-8", + "content": ("\ufeff" + "\n".join(lines)).encode("utf-8"), + "count": len(listed["items"]), + "limit": limit, + } + def create_generated_contract_version( self, current_user, @@ -604,26 +809,31 @@ class ContractsApiTestCase(unittest.TestCase): app.dependency_overrides[get_contract_generation_service] = lambda: StubContractGenerationService() app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service app.dependency_overrides[get_tenant_service] = lambda: self.tenant_service + app.dependency_overrides[get_notification_service] = lambda: StubNotificationService() self.client = TestClient(app) def tearDown(self): app.dependency_overrides.clear() def _login(self, tenant_id="tenant-demo-001"): - app.dependency_overrides[require_current_user] = lambda: { + user = { "id": "user-admin", "tenant_id": tenant_id, "display_name": "Admin", "role": "admin", } + app.dependency_overrides[require_current_user] = lambda: user + app.dependency_overrides[require_notification_current_user] = lambda: user def _login_member(self, tenant_id="tenant-demo-001"): - app.dependency_overrides[require_current_user] = lambda: { + user = { "id": "user-member", "tenant_id": tenant_id, "display_name": "Member", "role": "member", } + app.dependency_overrides[require_current_user] = lambda: user + app.dependency_overrides[require_notification_current_user] = lambda: user def test_user_can_list_contract_templates(self): self._login() @@ -639,6 +849,7 @@ class ContractsApiTestCase(unittest.TestCase): self.assertEqual(body["data"]["total"], 1) self.assertEqual(body["data"]["items"][0]["document_type_code"], "labor_contract") self.assertEqual(body["data"]["items"][0]["lifecycle"]["status"], "generated") + self.assertIn("ledger_attention", body["data"]["items"][0]) def test_member_generated_contract_list_only_returns_owned_records(self): self._login_member() @@ -708,6 +919,15 @@ class ContractsApiTestCase(unittest.TestCase): self.assertEqual(updated.json()["data"]["evidence_file_id"], "file-evidence-001") self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_lifecycle_task_update") + def test_user_can_list_notifications(self): + self._login() + response = self.client.get("/api/v1/notifications", params={"limit": 5}) + self.assertEqual(response.status_code, 200) + body = response.json()["data"] + self.assertEqual(body["total"], 1) + self.assertEqual(body["items"][0]["type"], "approval_pending") + self.assertEqual(body["summary"]["critical"], 1) + def test_generated_contracts_are_tenant_isolated(self): self._login("tenant-other-001") response = self.client.get("/api/v1/contracts") @@ -742,6 +962,7 @@ class ContractsApiTestCase(unittest.TestCase): diff_body = diff_response.json()["data"] self.assertEqual(diff_body["summary"]["change_count"], 2) self.assertEqual(diff_body["items"][0]["change_type"], "modified") + self.assertEqual(diff_body["items"][0]["diff_key"], "diff-1-stub") self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_version_diff") download_response = self.client.get("/api/v1/contracts/contract-123/versions/gcv-001/download") @@ -773,6 +994,39 @@ class ContractsApiTestCase(unittest.TestCase): self.assertEqual(self.audit_log_service.operations[0]["action"], "contract_version_create") self.assertEqual(self.audit_log_service.operations[0]["detail_json"]["contract_id"], "contract-123") + def test_user_can_create_and_resolve_generated_contract_version_comment(self): + self._login() + list_response = self.client.get( + "/api/v1/contracts/contract-123/version-comments", + params={"from_version_id": "base", "to_version_id": "gcv-001"}, + ) + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.json()["data"]["items"][0]["diff_key"], "diff-1-stub") + + create_response = self.client.post( + "/api/v1/contracts/contract-123/version-comments", + json={ + "from_version_id": "base", + "to_version_id": "gcv-001", + "diff_key": "diff-1-stub", + "change_type": "modified", + "old_text": "Old", + "new_text": "New", + "comment": "Please align payment wording.", + }, + ) + self.assertEqual(create_response.status_code, 200) + self.assertEqual(create_response.json()["data"]["status"], "open") + self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_version_comment_create") + + update_response = self.client.patch( + "/api/v1/contracts/contract-123/version-comments/gcvc-created-001", + json={"status": "resolved"}, + ) + self.assertEqual(update_response.status_code, 200) + self.assertEqual(update_response.json()["data"]["status"], "resolved") + self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_version_comment_update") + def test_user_can_generate_contract_and_write_audit_log(self): self._login() response = self.client.post( @@ -886,6 +1140,14 @@ class ContractsApiTestCase(unittest.TestCase): self.assertEqual(download.content, b"pdf-bytes") self.assertEqual(self.audit_log_service.operations[-1]["action"], "download_contract") + export_response = self.client.get("/api/v1/contracts/export?contract_type=employment&limit=20") + self.assertEqual(export_response.status_code, 200) + self.assertIn("text/csv", export_response.headers["content-type"]) + self.assertIn("filename*=UTF-8''contract-ledger-test.csv", export_response.headers["content-disposition"]) + self.assertIn("contract-456", export_response.content.decode("utf-8-sig")) + self.assertEqual(self.audit_log_service.operations[-1]["action"], "export_contract_ledger") + self.assertEqual(self.audit_log_service.operations[-1]["detail_json"]["count"], 1) + invalid_json = self.client.post( "/api/v1/contracts/preview", data={"template_id": "tpl-123", "variable_values_json": "{bad json}"}, @@ -917,6 +1179,7 @@ class ContractGenerationServiceTestCase(unittest.TestCase): from app.models.file import File from app.models.generated_contract import GeneratedContract from app.models.generated_contract_version import GeneratedContractVersion + from app.models.generated_contract_version_comment import GeneratedContractVersionComment from app.models.legal_article import LegalArticle from app.models.legal_document_element_schema import LegalDocumentElementSchema from app.models.legal_document_type import LegalDocumentType @@ -933,6 +1196,7 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.File = File self.GeneratedContract = GeneratedContract self.GeneratedContractVersion = GeneratedContractVersion + self.GeneratedContractVersionComment = GeneratedContractVersionComment self.LegalArticle = LegalArticle self.LegalDocumentType = LegalDocumentType self.LegalDocumentElementSchema = LegalDocumentElementSchema @@ -994,6 +1258,41 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.db.close() self.engine.dispose() + def _add_generated_contract( + self, + contract_id: str, + *, + generated_by: str = "user-admin", + generated_by_name: str = "Admin", + lifecycle_status: str = "generated", + ): + now = datetime.now(timezone.utc) + contract = self.GeneratedContract( + id=contract_id, + tenant_id="tenant-demo-001", + template_id="tpl-approval", + template_name="Approval Template", + contract_type="procurement", + document_type_code="procurement_goods", + generated_by=generated_by, + generated_by_name=generated_by_name, + export_format="docx", + file_id=None, + file_name=f"{contract_id}.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", + lifecycle_status=lifecycle_status, + created_at=now, + ) + self.db.add(contract) + self.db.commit() + return contract + def test_generation_prompt_preferred_keys_route_to_specialized_defaults(self): cases = [ ("technical_service", "technical-service", "technical_service_default"), @@ -1142,6 +1441,54 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.assertTrue(generation_source["ai_enhancement_applied"]) self.assertFalse(generation_source["fallback_used"]) + def test_ai_enhanced_generation_strips_html_from_structured_payload(self): + now = datetime.now(timezone.utc) + template = self.ContractTemplate( + id="tpl-ai-html-only", + tenant_id="tenant-demo-001", + name="AI HTML Template", + contract_type="guarantee", + description="AI html generation", + content="Guarantee contract with {{supplier_name}}", + variables_json=[{"name": "supplier_name", "label": "Supplier", "required": True}], + enabled=True, + created_by="seed", + updated_by="seed", + created_at=now, + updated_at=now, + ) + self.db.add(template) + self.db.commit() + + class FakeSharedAIClient: + def enhance_contract_generation(self, current_user, *, prompt_runtime, prompt, draft_content, metadata=None): + return { + "content": '{"title":"Guarantee Contract","html":"

Guarantee Contract

Article 1 guarantee scope.

"}', + "raw_content": '{"title":"Guarantee Contract","html":"

Guarantee Contract

Article 1 guarantee scope.

"}', + "structured_output": { + "title": "Guarantee Contract", + "html": "

Guarantee Contract

Article 1 guarantee scope.

", + }, + "provider": "shared-ai-platform", + "model": "test-model", + "prompt_key": "contract_generation_default", + "prompt_version": "contract_generation_default.v1.0", + } + + self.service.shared_ai_platform_client = FakeSharedAIClient() + + result = self.service.generate_contract( + current_user=self.current_user, + template_id="tpl-ai-html-only", + variable_values={"supplier_name": "Supplier A"}, + options={"ai_enhanced_generation": True}, + export_format=ExportFormat.TXT, + ) + + self.assertEqual(result["generated_content"], "Guarantee Contract\nArticle 1 guarantee scope.") + self.assertNotIn("

", result["generated_content"]) + self.assertNotIn("{\"title\"", result["generated_content"]) + def test_ai_enhanced_generation_falls_back_to_template_content(self): now = datetime.now(timezone.utc) template = self.ContractTemplate( @@ -1534,6 +1881,44 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.assertEqual(result["metadata"]["generation_source"]["template_selection"], "auto") # auto_review 已改为异步,不再同步调用 review_service.create_review_task + def test_get_generated_contract_detail_and_preview_normalize_saved_json_content(self): + now = datetime.now(timezone.utc) + saved_json_content = '{"title":"Guarantee Contract","html":"

Guarantee Contract

Article 1 guarantee scope.

"}' + self.db.add( + self.GeneratedContract( + id="contract-json-content-001", + tenant_id="tenant-demo-001", + template_id="tpl-json", + template_name="Guarantee Template", + contract_type="guarantee", + document_type_code="guarantee_general", + generated_by="user-admin", + generated_by_name="Admin", + export_format="txt", + file_id=None, + file_name="guarantee.txt", + mime_type="text/plain; charset=utf-8", + file_size=10, + generated_content=saved_json_content, + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + created_at=now, + ) + ) + self.db.commit() + + detail = self.service.get_generated_contract(self.current_user, "contract-json-content-001") + preview = self.service.get_generated_contract_preview(self.current_user, "contract-json-content-001") + + self.assertEqual(detail["generated_content"], "Guarantee Contract\nArticle 1 guarantee scope.") + self.assertIn("

Guarantee Contract

", detail["generated_html"]) + self.assertEqual(preview["preview_content"], "Guarantee Contract\nArticle 1 guarantee scope.") + self.assertIn("preview_html", preview) + self.assertNotIn("{\"title\"", detail["generated_content"]) + self.assertNotIn("

", preview["preview_content"]) + def test_get_generated_contract_download_allows_owner_access_to_file_payload(self): generated_contract = type( "GeneratedContractRecord", @@ -1545,6 +1930,7 @@ class ContractGenerationServiceTestCase(unittest.TestCase): "file_name": "contract.txt", "mime_type": "text/plain", "file_content_base64": "", + "generated_by": "user-member", }, )() captured = {} @@ -1577,6 +1963,46 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.assertEqual(result["file_name"], "contract.txt") self.assertTrue(captured["payload"]["allow_owner_access"]) + def test_get_generated_contract_download_recovers_when_file_storage_read_fails(self): + generated_contract = type( + "GeneratedContractRecord", + (), + { + "id": "contract-recover-001", + "tenant_id": "tenant-demo-001", + "file_id": "file-missing-001", + "file_name": "broken.txt", + "mime_type": "text/plain; charset=utf-8", + "file_content_base64": "", + "generated_by": "user-admin", + "generated_by_name": "Admin", + "generated_content": '{"title":"Guarantee Contract","html":"

Recovered clause.

"}', + "template_name": "Guarantee Template", + "export_format": "txt", + "export_options_json": {}, + }, + )() + self.service.generated_contract_repository = type( + "StubGeneratedContractRepository", + (), + {"get_by_id": lambda _self, contract_id, tenant_id: generated_contract}, + )() + + class FailingFileService: + def get_download_payload(self, current_user, file_id, allow_owner_access=False): + raise HTTPException(status_code=500, detail="file storage unavailable") + + self.service.file_service = FailingFileService() + result = self.service.get_generated_contract_download( + current_user={"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"}, + contract_id="contract-recover-001", + ) + + self.assertTrue(result["recovered_from_content"]) + self.assertEqual(result["mime_type"], "text/plain; charset=utf-8") + self.assertIn(b"Recovered clause.", result["content"]) + self.assertNotIn(b"

", result["content"]) + def test_create_generated_contract_version_saves_editor_content(self): now = datetime.now(timezone.utc) self.db.add( @@ -1763,8 +2189,101 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.assertEqual(result["to_version"]["version_label"], "v2") self.assertEqual(result["summary"]["added"], 1) self.assertEqual(result["items"][0]["change_type"], "added") + self.assertIn("diff_key", result["items"][0]) + self.assertEqual(result["comment_summary"]["total"], 0) self.assertIn("第三条 验收", result["items"][0]["new_text"]) + def test_generated_contract_version_comments_roundtrip(self): + now = datetime.now(timezone.utc) + self.db.add( + self.GeneratedContract( + id="contract-diff-comment-001", + tenant_id="tenant-demo-001", + template_id="tpl-diff", + template_name="Diff 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="diff-comment.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=10, + generated_content="第一条 标的\n第二条 价款", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + created_at=now, + ) + ) + self.db.add( + self.GeneratedContractVersion( + id="gcv-diff-comment-002", + tenant_id="tenant-demo-001", + contract_id="contract-diff-comment-001", + version_number=2, + version_label="v2", + change_type="editor_save", + change_summary="补充验收", + content="第一条 标的\n第二条 价款\n第三条 验收", + locate_status="applied", + created_by="user-admin", + created_at=now + timedelta(minutes=1), + ) + ) + self.db.commit() + + diff_result = self.service.diff_generated_contract_versions( + current_user=self.current_user, + contract_id="contract-diff-comment-001", + from_version_id="base", + to_version_id="gcv-diff-comment-002", + ) + diff_item = diff_result["items"][0] + + created = self.service.create_generated_contract_version_comment( + current_user=self.current_user, + contract_id="contract-diff-comment-001", + from_version_id="base", + to_version_id="gcv-diff-comment-002", + diff_key=diff_item["diff_key"], + change_type=diff_item["change_type"], + old_text=diff_item["old_text"], + new_text=diff_item["new_text"], + comment="请补充验收触发条件后再提交审批。", + ) + self.assertEqual(created["status"], "open") + self.assertEqual(created["from_version_id"], "base") + self.assertEqual(created["to_version_id"], "gcv-diff-comment-002") + + listed = self.service.list_generated_contract_version_comments( + current_user=self.current_user, + contract_id="contract-diff-comment-001", + from_version_id="base", + to_version_id="gcv-diff-comment-002", + ) + self.assertEqual(listed["summary"]["open"], 1) + self.assertEqual(listed["items"][0]["diff_key"], diff_item["diff_key"]) + + diff_with_comment = self.service.diff_generated_contract_versions( + current_user=self.current_user, + contract_id="contract-diff-comment-001", + from_version_id="base", + to_version_id="gcv-diff-comment-002", + ) + self.assertEqual(diff_with_comment["comment_summary"]["open"], 1) + + updated = self.service.update_generated_contract_version_comment( + current_user=self.current_user, + contract_id="contract-diff-comment-001", + comment_id=created["id"], + status_value="resolved", + ) + self.assertEqual(updated["status"], "resolved") + self.assertEqual(updated["resolved_by"], "user-admin") + def test_export_generated_contract_version_returns_docx_payload(self): now = datetime.now(timezone.utc) self.db.add( @@ -1989,6 +2508,181 @@ class ContractGenerationServiceTestCase(unittest.TestCase): admin_result = self.service.list_contract_lifecycle_task_workbench(current_user=self.current_user) self.assertEqual(admin_result["total"], 2) + def test_list_generated_contracts_includes_ledger_attention(self): + now = datetime.now(timezone.utc) + self._add_generated_contract( + "contract-ledger-attention-001", + generated_by="user-member", + generated_by_name="Member", + lifecycle_status="internal_review", + ) + self.db.add( + self.ContractApprovalStep( + id="approval-ledger-001", + tenant_id="tenant-demo-001", + contract_id="contract-ledger-attention-001", + step_type="legal_review", + status="pending", + title="法务审批", + comment="请处理审批。", + submitter_id="user-member", + submitter_name="Member", + assignee_id="user-approver", + assignee_name="Approver", + created_at=now, + updated_at=now, + ) + ) + self.db.commit() + + result = self.service.list_generated_contracts( + current_user={ + "id": "user-approver", + "tenant_id": "tenant-demo-001", + "display_name": "Approver", + "role": "member", + }, + page=1, + page_size=10, + ) + + self.assertEqual(result["total"], 1) + attention = result["items"][0]["ledger_attention"] + self.assertTrue(attention["is_mine"]) + self.assertEqual(attention["primary"]["code"], "approval_decision") + self.assertEqual(attention["counts"]["pending_approval"], 1) + + def test_list_generated_contracts_supports_ledger_advanced_filters(self): + now = datetime.now(timezone.utc) + alpha = self._add_generated_contract("contract-ledger-filter-alpha", lifecycle_status="effective") + alpha.counterparty_name = "Alpha Supplier" + alpha.owner_name = "Legal Ops" + alpha.effective_at = now - timedelta(days=10) + alpha.expires_at = now + timedelta(days=20) + beta = self._add_generated_contract("contract-ledger-filter-beta", lifecycle_status="effective") + beta.counterparty_name = "Beta Supplier" + beta.owner_name = "Business Owner" + beta.effective_at = now - timedelta(days=30) + beta.expires_at = now + timedelta(days=80) + self.db.add_all([alpha, beta]) + self.db.commit() + + by_counterparty = self.service.list_generated_contracts( + current_user=self.current_user, + page=1, + page_size=10, + counterparty_name="Alpha", + ) + by_owner = self.service.list_generated_contracts( + current_user=self.current_user, + page=1, + page_size=10, + owner_name="Legal", + ) + by_expiry = self.service.list_generated_contracts( + current_user=self.current_user, + page=1, + page_size=10, + expires_from=now, + expires_to=now + timedelta(days=30), + ) + + self.assertEqual([item["id"] for item in by_counterparty["items"]], ["contract-ledger-filter-alpha"]) + self.assertEqual([item["id"] for item in by_owner["items"]], ["contract-ledger-filter-alpha"]) + self.assertEqual([item["id"] for item in by_expiry["items"]], ["contract-ledger-filter-alpha"]) + + def test_list_generated_contracts_rejects_invalid_date_range(self): + now = datetime.now(timezone.utc) + with self.assertRaises(ValidationError): + self.service.list_generated_contracts( + current_user=self.current_user, + page=1, + page_size=10, + expires_from=now + timedelta(days=30), + expires_to=now, + ) + + def test_export_generated_contracts_csv_uses_ledger_filters(self): + now = datetime.now(timezone.utc) + alpha = self._add_generated_contract("contract-ledger-export-alpha", lifecycle_status="effective") + alpha.counterparty_name = "Alpha Supplier" + alpha.owner_name = "Legal Ops" + alpha.effective_at = now - timedelta(days=10) + alpha.expires_at = now + timedelta(days=20) + beta = self._add_generated_contract("contract-ledger-export-beta", lifecycle_status="effective") + beta.counterparty_name = "Beta Supplier" + beta.owner_name = "Business Owner" + beta.effective_at = now - timedelta(days=30) + beta.expires_at = now + timedelta(days=80) + self.db.add_all([alpha, beta]) + self.db.commit() + + exported = self.service.export_generated_contracts_csv( + current_user=self.current_user, + counterparty_name="Alpha", + expires_to=now + timedelta(days=30), + ) + csv_text = exported["content"].decode("utf-8-sig") + + self.assertEqual(exported["count"], 1) + self.assertIn("合同ID", csv_text) + self.assertIn("contract-ledger-export-alpha", csv_text) + self.assertIn("Alpha Supplier", csv_text) + self.assertNotIn("contract-ledger-export-beta", csv_text) + + def test_legal_role_can_view_and_update_tenant_contracts(self): + self._add_generated_contract( + "contract-legal-visible-001", + generated_by="user-member", + generated_by_name="Member", + ) + legal_user = { + "id": "user-legal", + "tenant_id": "tenant-demo-001", + "display_name": "Legal", + "role": "legal", + } + + listed = self.service.list_generated_contracts(current_user=legal_user, page=1, page_size=10) + updated = self.service.update_generated_contract_lifecycle( + current_user=legal_user, + contract_id="contract-legal-visible-001", + lifecycle_status="external_review", + lifecycle_note="法务已推进外部审查。", + ) + + self.assertEqual(listed["total"], 1) + self.assertEqual(updated["lifecycle"]["status"], "external_review") + + def test_observer_role_can_view_but_cannot_mutate_or_download_contracts(self): + self._add_generated_contract( + "contract-observer-visible-001", + generated_by="user-member", + generated_by_name="Member", + ) + observer = { + "id": "user-observer", + "tenant_id": "tenant-demo-001", + "display_name": "Observer", + "role": "observer", + } + + listed = self.service.list_generated_contracts(current_user=observer, page=1, page_size=10) + detail = self.service.get_generated_contract(observer, "contract-observer-visible-001") + + self.assertEqual(listed["total"], 1) + self.assertEqual(detail["id"], "contract-observer-visible-001") + self.assertFalse(detail["approval"]["can_submit"]) + with self.assertRaises(ForbiddenError): + self.service.export_generated_contracts_csv(observer) + with self.assertRaises(ForbiddenError): + self.service.update_generated_contract_lifecycle( + current_user=observer, + contract_id="contract-observer-visible-001", + lifecycle_status="external_review", + ) + with self.assertRaises(ForbiddenError): + self.service.get_generated_contract_download(observer, "contract-observer-visible-001") def test_contract_lifecycle_task_can_bind_archive_evidence(self): now = datetime.now(timezone.utc) @@ -2051,6 +2745,188 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.assertEqual(listed["summary"]["completed"], 1) self.assertEqual(listed["summary"]["with_evidence"], 1) + def test_contract_lifecycle_task_can_bind_signoff_evidence(self): + now = datetime.now(timezone.utc) + self.db.add( + self.GeneratedContract( + id="contract-signoff-001", + tenant_id="tenant-demo-001", + template_id="tpl-signoff", + template_name="Signoff 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="signoff.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=10, + generated_content="Signoff contract content.", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + lifecycle_status="approved", + created_at=now, + ) + ) + self.db.add( + self.File( + id="file-signoff-evidence", + tenant_id="tenant-demo-001", + uploaded_by="user-admin", + bucket="default", + object_key="contract-lifecycle-evidence/file-signoff-evidence.pdf", + file_name="signed-contract.pdf", + mime_type="application/pdf", + file_size=512, + file_hash="hash-signoff-evidence", + file_type="contract_lifecycle_evidence", + status="active", + created_at=now, + ) + ) + self.db.commit() + + created = self.service.create_contract_lifecycle_task( + current_user=self.current_user, + contract_id="contract-signoff-001", + task_type="signoff", + title="签署确认", + status_value="completed", + evidence_file_id="file-signoff-evidence", + evidence_note="双方已完成盖章签署。", + ) + + self.assertEqual(created["task_type"], "signoff") + self.assertEqual(created["status"], "completed") + self.assertEqual(created["evidence_file"]["file_name"], "signed-contract.pdf") + self.assertEqual(created["evidence_note"], "双方已完成盖章签署。") + + def test_generated_contract_includes_signoff_archive_evidence_summary(self): + now = datetime.now(timezone.utc) + self._add_generated_contract("contract-signoff-archive-001", lifecycle_status="signed") + self.db.add_all( + [ + self.File( + id="file-signoff-archive-signoff", + tenant_id="tenant-demo-001", + uploaded_by="user-admin", + bucket="default", + object_key="contract-lifecycle-evidence/file-signoff.pdf", + file_name="signed-contract.pdf", + mime_type="application/pdf", + file_size=512, + file_hash="hash-signoff", + file_type="contract_lifecycle_evidence", + status="active", + created_at=now, + ), + self.File( + id="file-signoff-archive-archive", + tenant_id="tenant-demo-001", + uploaded_by="user-admin", + bucket="default", + object_key="contract-lifecycle-evidence/file-archive.pdf", + file_name="archive-index.pdf", + mime_type="application/pdf", + file_size=256, + file_hash="hash-archive", + file_type="contract_lifecycle_evidence", + status="active", + created_at=now, + ), + ] + ) + self.db.commit() + + self.service.create_contract_lifecycle_task( + current_user=self.current_user, + contract_id="contract-signoff-archive-001", + task_type="signoff", + title="签署确认", + status_value="completed", + evidence_file_id="file-signoff-archive-signoff", + evidence_note="双方已完成签署。", + ) + self.service.create_contract_lifecycle_task( + current_user=self.current_user, + contract_id="contract-signoff-archive-001", + task_type="termination_archive", + title="归档确认", + status_value="completed", + evidence_file_id="file-signoff-archive-archive", + evidence_note="归档清单已留存。", + ) + + detail = self.service.get_generated_contract(self.current_user, "contract-signoff-archive-001") + signoff_archive = detail["signoff_archive"] + + self.assertTrue(signoff_archive["summary"]["has_signoff_evidence"]) + self.assertTrue(signoff_archive["summary"]["has_archive_evidence"]) + self.assertEqual(signoff_archive["summary"]["total_evidence_count"], 2) + self.assertEqual(signoff_archive["signoff"]["task"]["title"], "签署确认") + self.assertEqual(signoff_archive["archive"]["task"]["title"], "归档确认") + self.assertEqual(signoff_archive["items"][0]["evidence_file"]["file_name"], "signed-contract.pdf") + + listed = self.service.list_generated_contracts(self.current_user, page=1, page_size=10) + listed_item = next(item for item in listed["items"] if item["id"] == "contract-signoff-archive-001") + self.assertEqual(listed_item["signoff_archive"]["summary"]["signoff_evidence_count"], 1) + self.assertEqual(listed_item["signoff_archive"]["summary"]["archive_evidence_count"], 1) + + def test_notification_service_builds_contract_todo_notifications(self): + from app.services.contract_approval_service import ContractApprovalService + from app.services.notification_service import NotificationService + + approver = { + "id": "user-approver", + "tenant_id": "tenant-demo-001", + "display_name": "Approver", + "role": "member", + } + submitter = { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "display_name": "Member", + "role": "member", + } + self._add_generated_contract("contract-notice-approval", generated_by="user-member", generated_by_name="Member") + approval_service = ContractApprovalService(self.db) + approval_service.submit_approval( + submitter, + contract_id="contract-notice-approval", + title="法务审批", + assignee_id="user-approver", + assignee_name="Approver", + ) + + service = NotificationService(self.db) + approver_notifications = service.list_notifications(approver) + + self.assertGreaterEqual(approver_notifications["summary"]["mine"], 1) + self.assertTrue(any(item["type"] == "approval_pending" for item in approver_notifications["items"])) + + approved_contract = self._add_generated_contract("contract-notice-signoff", lifecycle_status="approved") + approved_contract.generated_by = "user-member" + approved_contract.generated_by_name = "Member" + self.db.add(approved_contract) + self.db.commit() + + expired_contract = self._add_generated_contract("contract-notice-archive", lifecycle_status="expired") + expired_contract.generated_by = "user-member" + expired_contract.generated_by_name = "Member" + expired_contract.expires_at = datetime.now(timezone.utc) - timedelta(days=1) + self.db.add(expired_contract) + self.db.commit() + + owner_notifications = service.list_notifications(submitter, limit=20) + notification_types = {item["type"] for item in owner_notifications["items"]} + + self.assertIn("signoff_evidence_missing", notification_types) + self.assertIn("archive_evidence_missing", notification_types) + self.assertIn("renewal_reminder", notification_types) + def test_contract_lifecycle_task_rejects_missing_evidence_file(self): now = datetime.now(timezone.utc) self.db.add( @@ -2130,31 +3006,7 @@ class ContractGenerationServiceTestCase(unittest.TestCase): 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() + self._add_generated_contract("contract-approval-001") approval_service = ContractApprovalService(self.db) submitted = approval_service.submit_approval( @@ -2191,7 +3043,9 @@ class ContractGenerationServiceTestCase(unittest.TestCase): detail = self.service.get_generated_contract(self.current_user, "contract-approval-001") self.assertEqual(detail["approval"]["status"], "approved") self.assertEqual(detail["approval"]["latest"]["comment"], "审批通过。") - self.assertEqual(detail["approval"]["latest"]["sla"]["status"], "closed") + self.assertEqual(decided["sla"]["status"], "closed") + self.assertEqual(detail["approval"]["next_action"]["code"], "approved") + self.assertEqual(detail["approval"]["timeline"][-1]["event"], "approved") def test_contract_approval_sla_summary_counts_pending_steps(self): from app.services.contract_approval_service import ContractApprovalService @@ -2410,6 +3264,152 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.assertEqual(submitted["assignee_id"], "user-admin-approval") self.assertEqual(submitted["assignee_name"], "Approval Admin") + def test_contract_approval_reject_requires_reason_and_can_resubmit(self): + from app.services.contract_approval_service import ContractApprovalService + + self._add_generated_contract("contract-approval-reject-001", generated_by="user-member", generated_by_name="Member") + approval_service = ContractApprovalService(self.db) + submitter = {"id": "user-member", "tenant_id": "tenant-demo-001", "display_name": "Member", "role": "member"} + approver = {"id": "user-legal", "tenant_id": "tenant-demo-001", "display_name": "Legal", "role": "member"} + + submitted = approval_service.submit_approval( + current_user=submitter, + contract_id="contract-approval-reject-001", + title="采购合同法务审批", + assignee_id="user-legal", + assignee_name="Legal", + ) + + with self.assertRaises(ValidationError): + approval_service.decide_approval( + current_user=approver, + approval_id=submitted["id"], + decision="reject", + comment=" ", + ) + + rejected = approval_service.decide_approval( + current_user=approver, + approval_id=submitted["id"], + decision="reject", + comment="请补充验收标准后重新提交。", + ) + self.assertEqual(rejected["status"], "rejected") + self.assertEqual(rejected["timeline"][-1]["event"], "rejected") + + detail = self.service.get_generated_contract(submitter, "contract-approval-reject-001") + self.assertEqual(detail["approval"]["status"], "rejected") + self.assertTrue(detail["approval"]["can_submit"]) + self.assertEqual(detail["approval"]["latest_rejected"]["comment"], "请补充验收标准后重新提交。") + self.assertEqual(detail["approval"]["next_action"]["code"], "revise_and_resubmit") + + resubmitted = approval_service.submit_approval( + current_user=submitter, + contract_id="contract-approval-reject-001", + title="采购合同重新提交", + comment="已补充验收标准,请复核。", + assignee_id="user-legal", + assignee_name="Legal", + ) + self.assertEqual(resubmitted["status"], "pending") + detail_after_resubmit = self.service.get_generated_contract(submitter, "contract-approval-reject-001") + self.assertEqual(detail_after_resubmit["approval"]["status"], "pending") + self.assertEqual(detail_after_resubmit["approval"]["total"], 2) + self.assertEqual(detail_after_resubmit["approval"]["timeline"][-1]["event"], "pending") + + def test_contract_approval_role_boundaries_allow_participants_only(self): + from app.services.contract_approval_service import ContractApprovalService + + self._add_generated_contract("contract-approval-boundary-001", generated_by="user-member", generated_by_name="Member") + approval_service = ContractApprovalService(self.db) + submitter = {"id": "user-member", "tenant_id": "tenant-demo-001", "display_name": "Member", "role": "member"} + approver = {"id": "user-legal", "tenant_id": "tenant-demo-001", "display_name": "Legal", "role": "member"} + outsider = {"id": "user-outsider", "tenant_id": "tenant-demo-001", "display_name": "Outsider", "role": "member"} + + submitted = approval_service.submit_approval( + current_user=submitter, + contract_id="contract-approval-boundary-001", + title="采购合同法务审批", + assignee_id="user-legal", + assignee_name="Legal", + ) + + approver_detail = self.service.get_generated_contract(approver, "contract-approval-boundary-001") + self.assertTrue(approver_detail["approval"]["pending"]["can_decide"]) + with self.assertRaises(NotFoundError): + self.service.update_generated_contract_lifecycle( + current_user=approver, + contract_id="contract-approval-boundary-001", + lifecycle_status="approved", + ) + with self.assertRaises(NotFoundError): + self.service.get_generated_contract(outsider, "contract-approval-boundary-001") + with self.assertRaises(NotFoundError): + approval_service.decide_approval( + current_user=outsider, + approval_id=submitted["id"], + decision="approve", + comment="越权处理。", + ) + + def test_contract_approval_legal_role_can_view_all_and_decide(self): + from app.services.contract_approval_service import ContractApprovalService + + self._add_generated_contract("contract-approval-legal-001", generated_by="user-member", generated_by_name="Member") + approval_service = ContractApprovalService(self.db) + submitter = {"id": "user-member", "tenant_id": "tenant-demo-001", "display_name": "Member", "role": "member"} + legal_user = {"id": "user-legal", "tenant_id": "tenant-demo-001", "display_name": "Legal", "role": "legal"} + + submitted = approval_service.submit_approval( + current_user=submitter, + contract_id="contract-approval-legal-001", + title="采购合同法务审批", + assignee_id="user-other-approver", + assignee_name="Other Approver", + ) + listed = approval_service.list_approvals(current_user=legal_user, scope="all") + decided = approval_service.decide_approval( + current_user=legal_user, + approval_id=submitted["id"], + decision="approve", + comment="法务通过。", + ) + + self.assertEqual(listed["total"], 1) + self.assertEqual(decided["status"], "approved") + + def test_contract_approval_observer_is_readonly(self): + from app.services.contract_approval_service import ContractApprovalService + + self._add_generated_contract("contract-approval-observer-001", generated_by="user-member", generated_by_name="Member") + approval_service = ContractApprovalService(self.db) + submitter = {"id": "user-member", "tenant_id": "tenant-demo-001", "display_name": "Member", "role": "member"} + observer = {"id": "user-observer", "tenant_id": "tenant-demo-001", "display_name": "Observer", "role": "observer"} + + submitted = approval_service.submit_approval( + current_user=submitter, + contract_id="contract-approval-observer-001", + title="采购合同法务审批", + assignee_id="user-member", + assignee_name="Member", + ) + listed = approval_service.list_approvals(current_user=observer, scope="all") + + self.assertEqual(listed["total"], 1) + with self.assertRaises(NotFoundError): + approval_service.submit_approval( + current_user=observer, + contract_id="contract-approval-observer-001", + title="观察者提交审批", + ) + with self.assertRaises(ForbiddenError): + approval_service.decide_approval( + current_user=observer, + approval_id=submitted["id"], + decision="approve", + comment="观察者越权处理。", + ) + class ClauseSuggestionRuleTests(unittest.TestCase): def setUp(self): diff --git a/backend/tests/test_legal_document_element_schema_service.py b/backend/tests/test_legal_document_element_schema_service.py index 740d24f3..19df4627 100644 --- a/backend/tests/test_legal_document_element_schema_service.py +++ b/backend/tests/test_legal_document_element_schema_service.py @@ -114,6 +114,14 @@ class LegalDocumentElementSchemaServiceTestCase(unittest.TestCase): ], ) + def test_contract_alias_reads_procurement_goods_schema(self): + self.service.seed_default_element_schemas(self.admin_user, "procurement_goods") + + current = self.service.get_current_element_schema(self.member_user, "contract") + + self.assertEqual(current["document_type_code"], "procurement_goods") + self.assertEqual(current["status"], "published") + def test_schema_validation_rejects_unsupported_field_type(self): with self.assertRaises(HTTPException) as context: self.service.create_element_schema( diff --git a/backend/tests/test_legal_document_type_service.py b/backend/tests/test_legal_document_type_service.py index a270f17f..403797a0 100644 --- a/backend/tests/test_legal_document_type_service.py +++ b/backend/tests/test_legal_document_type_service.py @@ -123,6 +123,14 @@ class LegalDocumentTypeServiceTestCase(unittest.TestCase): self.assertTrue(governance_flags["shareholder_resolution"]) self.assertTrue(governance_flags["power_of_attorney"]) + def test_contract_alias_resolves_to_procurement_goods_detail(self): + self.service.seed_default_document_types(self.admin_user) + + detail = self.service.get_document_type(self.member_user, "contract") + + self.assertEqual(detail["code"], "procurement_goods") + self.assertEqual(detail["name"], "货物买卖合同") + def test_create_rejects_duplicate_and_invalid_code(self): created = self.service.create_document_type( self.admin_user, diff --git a/backend/tests/test_search_api.py b/backend/tests/test_search_api.py new file mode 100644 index 00000000..8f252380 --- /dev/null +++ b/backend/tests/test_search_api.py @@ -0,0 +1,59 @@ +import unittest + +from fastapi.testclient import TestClient + +from app.api.search import get_search_service, require_current_user +from app.main import app + + +class StubSearchService: + def __init__(self): + self.calls = [] + + def search(self, current_user, *, q, types=None, limit=8): + self.calls.append({"current_user": current_user, "q": q, "types": types, "limit": limit}) + return { + "query": q, + "types": ["contract", "template"], + "limit": limit, + "items": [ + { + "id": "contract-search-001", + "type": "contract", + "title": "Search Contract", + "href": "/contracts/contract-search-001", + } + ], + "groups": {"contracts": [], "reviews": [], "files": [], "templates": []}, + "summary": {"total": 1, "by_type": {"contract": 1, "review": 0, "file": 0, "template": 0}}, + } + + +class SearchApiTestCase(unittest.TestCase): + def setUp(self): + self.search_service = StubSearchService() + app.dependency_overrides[get_search_service] = lambda: self.search_service + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def test_unified_search_returns_success_payload(self): + response = self.client.get("/api/v1/search", params={"q": "Alpha", "types": "contract,template", "limit": 5}) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["code"], 0) + self.assertEqual(body["data"]["query"], "Alpha") + self.assertEqual(body["data"]["summary"]["total"], 1) + self.assertEqual(self.search_service.calls[0]["types"], "contract,template") + self.assertEqual(self.search_service.calls[0]["limit"], 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_search_service.py b/backend/tests/test_search_service.py new file mode 100644 index 00000000..fed145d2 --- /dev/null +++ b/backend/tests/test_search_service.py @@ -0,0 +1,179 @@ +import unittest +from datetime import datetime, timezone + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.db import Base +from app.models.contract_template import ContractTemplate +from app.models.file import File +from app.models.generated_contract import GeneratedContract +from app.models.review_task import ReviewTask +from app.services.search_service import SearchService + + +class SearchServiceTestCase(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=self.engine) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) + self.db = self.SessionLocal() + self.service = SearchService(self.db) + self.now = datetime.now(timezone.utc) + self.admin_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"} + self.member_user = {"id": "user-member", "tenant_id": "tenant-demo-001", "role": "member"} + + self.db.add_all( + [ + GeneratedContract( + id="contract-search-owned", + tenant_id="tenant-demo-001", + template_id="tpl-search-enabled", + template_name="Alpha Procurement Contract", + contract_type="procurement", + document_type_code="procurement_goods", + generated_by="user-member", + generated_by_name="Member", + export_format="docx", + file_id=None, + file_name="alpha-procurement.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=128, + generated_content="Alpha contract content", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + lifecycle_status="effective", + counterparty_name="Alpha Supplier", + owner_name="Member", + created_at=self.now, + ), + GeneratedContract( + id="contract-search-other", + tenant_id="tenant-demo-001", + template_id="tpl-search-enabled", + template_name="Alpha Hidden Contract", + contract_type="procurement", + document_type_code="procurement_goods", + generated_by="user-other", + generated_by_name="Other", + export_format="docx", + file_id=None, + file_name="alpha-hidden.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=128, + generated_content="Alpha hidden content", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + lifecycle_status="generated", + counterparty_name="Alpha Hidden", + owner_name="Other", + created_at=self.now, + ), + ReviewTask( + id="review-search-owned", + tenant_id="tenant-demo-001", + user_id="user-member", + title="Alpha Review", + contract_type="procurement", + review_as="party_a", + status="completed", + source_type="text", + risk_level="high", + created_at=self.now, + ), + File( + id="file-search-admin", + tenant_id="tenant-demo-001", + uploaded_by="user-admin", + bucket="default", + object_key="tenant-demo-001/report/alpha-report.pdf", + file_name="alpha-report.pdf", + mime_type="application/pdf", + file_size=256, + file_hash="hash-alpha-report", + file_type="report", + status="active", + created_at=self.now, + ), + ContractTemplate( + id="tpl-search-enabled", + tenant_id="tenant-demo-001", + name="Alpha Template", + contract_type="procurement", + document_type_code="procurement_goods", + template_code="alpha-template", + template_category="business", + industry_category="trade", + description="Alpha enabled template", + content="Alpha {{supplier}}", + variables_json=[], + enabled=True, + created_by="user-admin", + updated_by="user-admin", + created_at=self.now, + updated_at=self.now, + ), + ContractTemplate( + id="tpl-search-disabled", + tenant_id="tenant-demo-001", + name="Alpha Disabled Template", + contract_type="procurement", + document_type_code="procurement_goods", + template_code="alpha-disabled", + template_category="business", + industry_category="trade", + description="Alpha disabled template", + content="Alpha disabled", + variables_json=[], + enabled=False, + created_by="user-admin", + updated_by="user-admin", + created_at=self.now, + updated_at=self.now, + ), + ] + ) + self.db.commit() + + def tearDown(self): + self.db.close() + self.engine.dispose() + + def test_admin_searches_all_result_types(self): + result = self.service.search(self.admin_user, q="Alpha", limit=10) + + self.assertGreaterEqual(result["summary"]["by_type"]["contract"], 2) + self.assertEqual(result["summary"]["by_type"]["review"], 0) + self.assertEqual(result["summary"]["by_type"]["file"], 1) + self.assertEqual(result["summary"]["by_type"]["template"], 2) + ids = {item["id"] for item in result["items"]} + self.assertIn("contract-search-other", ids) + self.assertIn("file-search-admin", ids) + self.assertIn("tpl-search-disabled", ids) + + def test_member_search_respects_contract_and_file_boundaries(self): + result = self.service.search(self.member_user, q="Alpha", limit=10) + ids = {item["id"] for item in result["items"]} + + self.assertIn("contract-search-owned", ids) + self.assertIn("review-search-owned", ids) + self.assertIn("tpl-search-enabled", ids) + self.assertNotIn("contract-search-other", ids) + self.assertNotIn("file-search-admin", ids) + self.assertNotIn("tpl-search-disabled", ids) + + def test_type_filter_limits_result_groups(self): + result = self.service.search(self.admin_user, q="Alpha", types="contract,template", limit=10) + + self.assertEqual(result["summary"]["by_type"]["file"], 0) + self.assertEqual(result["summary"]["by_type"]["review"], 0) + self.assertGreaterEqual(result["summary"]["by_type"]["contract"], 1) + self.assertGreaterEqual(result["summary"]["by_type"]["template"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_shared_ai_platform_client.py b/backend/tests/test_shared_ai_platform_client.py index 1fae6080..58e0d636 100644 --- a/backend/tests/test_shared_ai_platform_client.py +++ b/backend/tests/test_shared_ai_platform_client.py @@ -10,6 +10,7 @@ from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient from app.db import Base from app.models.conversation import Conversation from app.models.message import Message +from shared_ai_platform_sdk import SharedAIPlatformError from shared_ai_platform_sdk.client import SharedAIPlatformClient as SharedAiPlatformSdkClient from shared_ai_platform_sdk.types import RequestContext, SSEEvent @@ -370,6 +371,33 @@ class RemoteSharedAiPlatformClientStreamTestCase(unittest.TestCase): self.assertEqual([item["id"] for item in payload["items"]], ["kb-new", "kb-old"]) self.assertEqual(payload["items"][0]["retrieval_mode"], "hybrid") + def test_remote_knowledge_bases_support_sdk_without_pagination_arguments(self): + class StubSdk: + def list_knowledge_bases(self, context): + return { + "items": [ + {"id": "kb-1", "name": "one", "created_at": "2026-05-10T00:00:00Z"}, + {"id": "kb-2", "name": "two", "created_at": "2026-05-11T00:00:00Z"}, + {"id": "kb-3", "name": "three", "created_at": "2026-05-12T00:00:00Z"}, + ] + } + + client = RemoteSharedAIPlatformClient( + base_url="http://testserver", + app_id="test-app", + api_key="test-key", + timeout_seconds=30.0, + ) + client.sdk = StubSdk() + + payload = client.list_knowledge_bases({"id": "user-1", "tenant_id": "tenant-1", "role": "admin"}, 2, 2) + + self.assertEqual(payload["page"], 2) + self.assertEqual(payload["page_size"], 2) + self.assertEqual(payload["total"], 3) + self.assertEqual([item["id"] for item in payload["items"]], ["kb-1"]) + self.assertEqual(payload["items"][0]["retrieval_mode"], "hybrid") + def test_remote_knowledge_base_publish_chain_calls_sdk(self): class StubSdk: def list_knowledge_base_versions(self, context, knowledge_base_id): @@ -408,6 +436,46 @@ class RemoteSharedAiPlatformClientStreamTestCase(unittest.TestCase): self.assertEqual(unpublished["status"], "archived") self.assertEqual(rolled_back["current_version_label"], "v1") + def test_remote_knowledge_base_versions_falls_back_to_current_snapshot_when_sdk_fails(self): + class StubSdk: + def list_knowledge_base_versions(self, context, knowledge_base_id): + raise SharedAIPlatformError(500, "version endpoint unavailable") + + def list_knowledge_bases(self, context, page=1, page_size=100): + return { + "items": [ + { + "id": "kb-1", + "name": "Pilot KB", + "description": "demo", + "status": "active", + "retrieval_mode": "hybrid", + "current_version_label": "v2", + "published_at": "2026-06-20T00:00:00+00:00", + "published_by": "user-admin", + "created_at": "2026-06-19T00:00:00+00:00", + "updated_at": "2026-06-20T00:00:00+00:00", + } + ], + "page": page, + "page_size": page_size, + "total": 1, + } + + client = RemoteSharedAIPlatformClient( + base_url="http://testserver", + app_id="test-app", + api_key="test-key", + timeout_seconds=30.0, + ) + client.sdk = StubSdk() + + versions = client.list_knowledge_base_versions({"id": "user-1", "tenant_id": "tenant-1", "role": "admin"}, "kb-1") + + self.assertEqual(len(versions), 1) + self.assertEqual(versions[0]["version_label"], "v2") + self.assertIn("共享 AI 版本历史暂时不可用", versions[0]["change_summary"]) + def test_remote_conversation_history_falls_back_to_local_records_when_sdk_fails(self): engine = create_engine("sqlite:///:memory:", future=True) Base.metadata.create_all(engine) @@ -507,5 +575,29 @@ class RemoteSharedAiPlatformClientStreamTestCase(unittest.TestCase): self.assertEqual(result["structured_output"]["title"], "担保合同") + def test_enhance_contract_generation_strips_html_from_structured_json(self): + class StubClient(RemoteSharedAIPlatformClient): + def stream_answer(self, current_user, conversation_id, message, review_context=None, template_key=None): + payload = '{"title":"Guarantee Contract","html":"

Guarantee Contract

Article 1 guarantee scope.

"}' + yield f'data: {{"type":"text","content":{json.dumps(payload, ensure_ascii=False)} }}\n\n' + yield 'data: {"type":"end","conversation_id":"conv-1"}\n\n' + + client = StubClient( + base_url="http://testserver", + app_id="test-app", + api_key="test-key", + timeout_seconds=30.0, + ) + result = client.enhance_contract_generation( + {"id": "user-1", "tenant_id": "tenant-1", "role": "admin"}, + prompt_runtime={"prompt_key": "contract_generation_default", "prompt_version": "contract_generation_default.v1.0"}, + prompt="enhance contract", + draft_content="draft", + ) + + self.assertEqual(result["content"], "Guarantee Contract\nArticle 1 guarantee scope.") + self.assertNotIn("

", result["content"]) + + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_system_api.py b/backend/tests/test_system_api.py index 973f3e75..f9092746 100644 --- a/backend/tests/test_system_api.py +++ b/backend/tests/test_system_api.py @@ -211,6 +211,15 @@ class StubGovernanceService: class SystemApiTestCase(unittest.TestCase): def setUp(self): + self.original_app_env = settings.app_env + self.original_secret_key = settings.secret_key + self.original_database_url = settings.database_url + self.original_file_storage_backend = settings.file_storage_backend + self.original_local_storage_dir = settings.local_storage_dir + self.original_minio_endpoint = settings.minio_endpoint + self.original_minio_access_key = settings.minio_access_key + self.original_minio_secret_key = settings.minio_secret_key + self.original_minio_bucket = settings.minio_bucket self.original_shared_ai_platform_base_url = settings.shared_ai_platform_base_url self.original_shared_ai_platform_api_key = settings.shared_ai_platform_api_key self.original_shared_ai_platform_api_prefix = settings.shared_ai_platform_api_prefix @@ -219,7 +228,28 @@ class SystemApiTestCase(unittest.TestCase): self.original_shared_ai_platform_legal_review_enabled = settings.shared_ai_platform_legal_review_enabled self.original_shared_ai_platform_legal_review_fallback_to_local = settings.shared_ai_platform_legal_review_fallback_to_local + settings.app_env = "development" + settings.secret_key = "test-secret-key-for-readiness-0123456789" + settings.database_url = "postgresql+psycopg://postgres:postgres@localhost:5432/ai_contract_platform" + settings.file_storage_backend = "minio" + settings.minio_endpoint = "localhost:9000" + settings.minio_access_key = "test-minio-access-key" + settings.minio_secret_key = "test-minio-secret-key" + settings.minio_bucket = "contracts" + settings.shared_ai_platform_base_url = "" + settings.shared_ai_platform_api_key = "" + settings.shared_ai_platform_legal_review_enabled = False + def tearDown(self): + settings.app_env = self.original_app_env + settings.secret_key = self.original_secret_key + settings.database_url = self.original_database_url + settings.file_storage_backend = self.original_file_storage_backend + settings.local_storage_dir = self.original_local_storage_dir + settings.minio_endpoint = self.original_minio_endpoint + settings.minio_access_key = self.original_minio_access_key + settings.minio_secret_key = self.original_minio_secret_key + settings.minio_bucket = self.original_minio_bucket settings.shared_ai_platform_base_url = self.original_shared_ai_platform_base_url settings.shared_ai_platform_api_key = self.original_shared_ai_platform_api_key settings.shared_ai_platform_api_prefix = self.original_shared_ai_platform_api_prefix @@ -256,6 +286,11 @@ class SystemApiTestCase(unittest.TestCase): self.assertTrue(all(item["target_layer"] for item in payload["p0_mainline"]["capabilities"])) self.assertTrue(any(item["key"] == "database" and item["status"] == "ok" for item in payload["checks"])) self.assertTrue(any(item["key"] == "default_admin_password" and item["status"] == "ok" for item in payload["checks"])) + self.assertTrue(any(item["key"] == "runtime_config" and item["status"] == "ok" for item in payload["checks"])) + self.assertTrue(any(item["key"] == "object_storage_config" and item["status"] == "ok" for item in payload["checks"])) + self.assertTrue(any(item["key"] == "shared_ai_config" and item["status"] == "ok" for item in payload["checks"])) + self.assertEqual(payload["deployment"]["file_storage_backend"], "minio") + self.assertFalse(payload["deployment"]["shared_ai_remote_configured"]) def test_p0_mainline_returns_source_ownership_and_acceptance(self): client = TestClient(app) @@ -392,6 +427,78 @@ class SystemApiTestCase(unittest.TestCase): self.assertEqual(payload["summary"]["failed_required"], 1) self.assertTrue(any(item["key"] == "database" and item["status"] == "failed" for item in payload["checks"])) + def test_readiness_degrades_for_production_weak_runtime_config(self): + settings.app_env = "production" + settings.secret_key = "change_me_to_a_random_64_plus_character_secret_for_production" + settings.database_url = "postgresql+psycopg://yuqei:change_me@192.168.3.90:5432/ai_contract_platform" + app.dependency_overrides[get_db_session] = lambda: StubReadyDb() + client = TestClient(app) + + response = client.get("/api/v1/system/readiness") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["status"], "degraded") + self.assertGreaterEqual(payload["summary"]["failed_required"], 1) + runtime_check = next(item for item in payload["checks"] if item["key"] == "runtime_config") + self.assertEqual(runtime_check["status"], "failed") + self.assertTrue(runtime_check["required"]) + self.assertIn("SECRET_KEY", runtime_check["detail"]) + self.assertIn("DATABASE_URL", runtime_check["detail"]) + + def test_readiness_degrades_for_placeholder_minio_config(self): + settings.app_env = "production" + settings.minio_access_key = "change_me_minio_access_key" + settings.minio_secret_key = "change_me_minio_secret_key" + app.dependency_overrides[get_db_session] = lambda: StubReadyDb() + client = TestClient(app) + + response = client.get("/api/v1/system/readiness") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["status"], "degraded") + storage_check = next(item for item in payload["checks"] if item["key"] == "object_storage_config") + self.assertEqual(storage_check["status"], "failed") + self.assertIn("MINIO_ACCESS_KEY", storage_check["detail"]) + self.assertIn("MINIO_SECRET_KEY", storage_check["detail"]) + + def test_readiness_degrades_when_production_shared_ai_review_lacks_api_key(self): + settings.app_env = "production" + settings.shared_ai_platform_base_url = "http://192.168.3.90:18011" + settings.shared_ai_platform_api_key = "change_me_shared_ai_api_key" + settings.shared_ai_platform_legal_review_enabled = True + app.dependency_overrides[get_db_session] = lambda: StubReadyDb() + client = TestClient(app) + + response = client.get("/api/v1/system/readiness") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["status"], "degraded") + shared_ai_check = next(item for item in payload["checks"] if item["key"] == "shared_ai_config") + self.assertEqual(shared_ai_check["status"], "failed") + self.assertTrue(shared_ai_check["required"]) + self.assertIn("SHARED_AI_PLATFORM_API_KEY", shared_ai_check["detail"]) + self.assertFalse(payload["deployment"]["shared_ai_remote_configured"]) + + def test_readiness_warns_when_development_shared_ai_review_lacks_api_key(self): + settings.app_env = "development" + settings.shared_ai_platform_base_url = "http://127.0.0.1:18011" + settings.shared_ai_platform_api_key = "" + settings.shared_ai_platform_legal_review_enabled = True + app.dependency_overrides[get_db_session] = lambda: StubReadyDb() + client = TestClient(app) + + response = client.get("/api/v1/system/readiness") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["status"], "ready") + shared_ai_check = next(item for item in payload["checks"] if item["key"] == "shared_ai_config") + self.assertEqual(shared_ai_check["status"], "warning") + self.assertFalse(shared_ai_check["required"]) + def test_shared_ai_status_reports_local_only_mode(self): settings.shared_ai_platform_base_url = "" settings.shared_ai_platform_legal_review_enabled = False diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py index 4abd5f71..eda00bc1 100644 --- a/backend/tests/test_user_service.py +++ b/backend/tests/test_user_service.py @@ -34,7 +34,7 @@ class UserServiceRoleTestCase(unittest.TestCase): self.db.close() self.engine.dispose() - def test_create_user_accepts_legal_and_business_roles(self): + def test_create_user_accepts_contract_domain_roles(self): legal_user = self.service.create_user( self.admin_user, "legal@example.com", @@ -51,9 +51,18 @@ class UserServiceRoleTestCase(unittest.TestCase): "business", "active", ) + observer_user = self.service.create_user( + self.admin_user, + "observer@example.com", + "只读观察者", + "123456", + "observer", + "active", + ) self.assertEqual(legal_user["role"], "legal") self.assertEqual(business_user["role"], "business") + self.assertEqual(observer_user["role"], "observer") def test_update_user_accepts_legacy_member_role(self): created = self.service.create_user( diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1f5893ed..f2d5b58e 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -3,6 +3,9 @@ services: build: context: ./app dockerfile: Dockerfile + args: + BACKEND_BASE_URL: ${BACKEND_BASE_URL:-http://10.20.0.11:8001} + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-/api/v1} image: local/ai-contract-web:0.0.1 container_name: ai-contract-web restart: unless-stopped @@ -10,7 +13,7 @@ services: NODE_ENV: production HOSTNAME: 0.0.0.0 PORT: 3000 - BACKEND_BASE_URL: http://10.20.0.11:18083 + BACKEND_BASE_URL: http://10.20.0.11:8001 NEXT_PUBLIC_API_BASE_URL: /api/v1 extra_hosts: - "10.20.0.11:host-gateway" @@ -33,4 +36,4 @@ services: networks: ai-contract-web-proxy: - name: ai-contract-web-proxy \ No newline at end of file + name: ai-contract-web-proxy diff --git a/deploy/web-gateway.conf b/deploy/web-gateway.conf index 1b03ba9e..a901037e 100644 --- a/deploy/web-gateway.conf +++ b/deploy/web-gateway.conf @@ -3,7 +3,7 @@ upstream ai_contract_web { } upstream ai_contract_api { - server 10.20.0.11:18083; + server 10.20.0.11:8001; } server { diff --git a/docker-compose.frontend.aliyun.yml b/docker-compose.frontend.aliyun.yml index 8524f085..e4cabedf 100644 --- a/docker-compose.frontend.aliyun.yml +++ b/docker-compose.frontend.aliyun.yml @@ -3,7 +3,7 @@ # # ⚠️ 重要提示:NEXT_PUBLIC_API_BASE_URL 和 BACKEND_BASE_URL 必须在构建时设置! # 运行时的 environment 设置无效,因为这些值会被硬编码到 JS 文件中。 -# 构建命令:MSYS_NO_PATHCONV=1 NEXT_PUBLIC_API_BASE_URL="/api/v1" BACKEND_BASE_URL="http://10.20.0.11:18083" npm run build +# 构建命令:MSYS_NO_PATHCONV=1 NEXT_PUBLIC_API_BASE_URL="/api/v1" BACKEND_BASE_URL="http://10.20.0.11:8001" npm run build version: "3.9" @@ -12,6 +12,9 @@ services: build: context: ./app dockerfile: Dockerfile + args: + BACKEND_BASE_URL: ${BACKEND_BASE_URL:-http://10.20.0.11:8001} + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-/api/v1} image: local/ai-contract-web:0.0.1 container_name: ai-contract-web restart: unless-stopped @@ -21,7 +24,7 @@ services: PORT: 3000 # 注意:以下环境变量在运行时设置无效! # 它们必须在构建时通过 npm run build 设置: - # MSYS_NO_PATHCONV=1 NEXT_PUBLIC_API_BASE_URL="/api/v1" BACKEND_BASE_URL="http://10.20.0.11:18083" npm run build + # MSYS_NO_PATHCONV=1 NEXT_PUBLIC_API_BASE_URL="/api/v1" BACKEND_BASE_URL="http://10.20.0.11:8001" npm run build extra_hosts: # 让容器能访问内网 IP(映射到 Docker 宿主机) - "10.20.0.11:host-gateway" @@ -44,4 +47,4 @@ services: networks: ai-contract-web-proxy: - name: ai-contract-web-proxy \ No newline at end of file + name: ai-contract-web-proxy diff --git a/docs/archive/2026-06-17-strategy-refresh/README.md b/docs/archive/2026-06-17-strategy-refresh/README.md deleted file mode 100644 index 5a9b635b..00000000 --- a/docs/archive/2026-06-17-strategy-refresh/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# 2026-06-17 Strategy Refresh Archive - -本目录保存被新规划替代的旧 `docs/` 文档。 - -当前保留为历史资料,不再作为当前执行依据。 - diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 29976fdd..1b9bc5dc 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -8,7 +8,9 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ARG BACKEND_BASE_URL=http://backend:8000 +ARG NEXT_PUBLIC_API_BASE_URL=/api/v1 ENV BACKEND_BASE_URL=${BACKEND_BASE_URL} +ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 054b19ee..65e927f4 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -8,12 +8,14 @@ const nextConfig: NextConfig = { devIndicators: false, output: "standalone", async rewrites() { - return [ - { - source: "/api/v1/:path*", - destination: `${backendBaseUrl}/api/v1/:path*`, - }, - ]; + return { + fallback: [ + { + source: "/api/v1/:path*", + destination: `${backendBaseUrl}/api/v1/:path*`, + }, + ], + }; }, }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 723f5a34..c3145398 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -121,13 +121,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -136,9 +136,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -146,21 +146,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -177,14 +177,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -194,14 +194,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -211,9 +211,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -221,29 +221,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -253,9 +253,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -263,9 +263,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -273,9 +273,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -283,27 +283,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -322,33 +322,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -356,14 +356,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2353,9 +2353,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -3521,9 +3521,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.336", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", - "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==", + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", "dev": true, "license": "ISC" }, @@ -4298,16 +4298,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4591,9 +4591,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5132,10 +5132,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5504,11 +5514,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/object-assign": { "version": "4.1.1", diff --git a/frontend/package.json b/frontend/package.json index 7bd57584..c94a756c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "qa:dev-fix-action": "node scripts/dev-fix-action-regression.mjs", "qa:kb-failed-ui": "node scripts/knowledge-base-failed-document-ui-e2e.mjs", "qa:phase1": "node scripts/phase1-closed-loop-e2e.mjs", + "qa:phase1-mainline": "node scripts/phase1-mainline-signoff-e2e.mjs", "qa:phase2": "node scripts/phase2-closed-loop-e2e.mjs", "qa:phase3": "node scripts/phase3-closed-loop-e2e.mjs", "qa:slice4": "node scripts/slice4-auto-review-closed-loop-e2e.mjs", diff --git a/frontend/scripts/phase1-mainline-signoff-e2e.mjs b/frontend/scripts/phase1-mainline-signoff-e2e.mjs new file mode 100644 index 00000000..469a1bb2 --- /dev/null +++ b/frontend/scripts/phase1-mainline-signoff-e2e.mjs @@ -0,0 +1,396 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `phase1-signoff-${Date.now()}`; +const webBase = process.env.PHASE1_WEB_URL || "http://127.0.0.1:3004"; +const apiBase = process.env.PHASE1_API_URL || "http://127.0.0.1:8000/api/v1"; +const healthUrl = process.env.PHASE1_HEALTH_URL || "http://127.0.0.1:8000/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.PHASE1_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "..", "project-docs", "qa-artifacts", "phase1-mainline-signoff", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const state = { + api: null, + browser: null, + contexts: [], + member: null, + admin: null, + generatedContract: null, + review: null, +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { + id, + title, + status: "PASS", + duration_ms: Date.now() - started, + started_at: startedAt, + details: details || {}, + }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const { authToken, headers: optionHeaders = {}, ...requestOptions } = options; + const headers = { + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + ...optionHeaders, + }; + const response = await state.api[method](apiUrl(route), { ...requestOptions, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { + data: { account, password }, + }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +async function registerAndLoginMember() { + const username = `${runId}-member@example.com`; + const password = "123456"; + const register = await apiRequest("post", "/auth/register", { + data: { username, password, display_name: "Phase1 Signoff Member" }, + }); + expectOk(register, "register member"); + return login(username, password); +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ + viewport: { width: 1440, height: 1100 }, + acceptDownloads: true, + }); + await context.addInitScript(({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, { token: userSession.access_token, user: userSession.user }); + state.contexts.push(context); + return context; +} + +function buildVariableValues(template) { + const values = {}; + for (const variable of template.variables || []) { + values[variable.name] = variable.default_value || `${runId}-${variable.name}`; + } + return values; +} + +async function downloadAndSave(route, fileName, token) { + const response = await state.api.get(apiUrl(route), { + headers: { Authorization: `Bearer ${token}` }, + }); + assert(response.status() >= 200 && response.status() < 300, `download failed ${route}: ${response.status()}`); + const body = await response.body(); + const target = path.join(artifactDir, fileName); + fs.writeFileSync(target, body); + assert(body.length > 0, `downloaded file is empty: ${fileName}`); + return { file: target, bytes: body.length }; +} + +async function getBodyText(route, session, screenshotName) { + const context = await createAuthenticatedContext(session); + const page = await context.newPage(); + await page.goto(`${webBase}${route}`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1200); + await page.screenshot({ path: screenshotPath(screenshotName), fullPage: true }); + const body = await page.locator("body").innerText(); + await page.close(); + return body; +} + +function includesAny(text, candidates) { + return candidates.some((candidate) => text.includes(candidate)); +} + +async function run() { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "frontend and backend health", async () => { + const frontend = await state.api.get(`${webBase}/login`); + const health = await state.api.get(healthUrl); + assert(frontend.status() === 200, `frontend /login status ${frontend.status()}`); + assert(health.status() === 200, `backend /health status ${health.status()}`); + return { webBase, apiBase, healthUrl, artifactDir }; + }); + + await step("PUBLIC-001", "home page positions drafting and review platform", async () => { + const page = await state.browser.newPage({ viewport: { width: 1440, height: 1000 } }); + await page.goto(webBase, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1000); + await page.screenshot({ path: screenshotPath("home-positioning"), fullPage: true }); + const body = await page.locator("body").innerText(); + await page.close(); + assert(body.includes("AI 合同起草与审查平台") || (body.includes("合同起草") && body.includes("审查")), "home page positioning text missing", body.slice(0, 800)); + assert(includesAny(body, ["自动预审", "风险解释", "报告交付", "审查报告"]), "home page does not expose Phase1 value props", body.slice(0, 1200)); + return { screenshot: "home-positioning.png" }; + }); + + await step("AUTH-001", "member registration and login", async () => { + state.member = await registerAndLoginMember(); + const me = await apiRequest("get", "/auth/me", { authToken: state.member.access_token }); + expectOk(me, "auth me"); + return { username: me.body.data.username, role: me.body.data.role }; + }); + + await step("AUTH-002", "admin login for template management", async () => { + state.admin = await login("admin@example.com", adminPassword); + const me = await apiRequest("get", "/auth/me", { authToken: state.admin.access_token }); + expectOk(me, "admin me"); + assert(me.body.data.role === "admin", "admin role mismatch", me.body.data); + return { username: me.body.data.username, role: me.body.data.role }; + }); + + await step("WORKSPACE-001", "workspace exposes Phase1 entry points", async () => { + const body = await getBodyText("/workspace", state.member, "workspace-mainline"); + assert(body.includes("合同起草"), "workspace missing drafting entry", body.slice(0, 1200)); + assert(includesAny(body, ["上传审查", "发起审查", "审查"]), "workspace missing review entry", body.slice(0, 1200)); + assert(includesAny(body, ["最近", "任务", "进入最近详情"]), "workspace missing recent task area", body.slice(0, 1200)); + return { screenshot: "workspace-mainline.png" }; + }); + + await step("CONTRACT-001", "contract draft preview generate detail and download", async () => { + const templates = await apiRequest("get", "/contracts/templates", { authToken: state.member.access_token }); + expectOk(templates, "contract templates"); + const template = templates.body.data[0]; + assert(template, "no contract template available"); + const variableValues = buildVariableValues(template); + const preview = await apiRequest("post", "/contracts/preview", { + authToken: state.member.access_token, + multipart: { template_id: template.id, variable_values_json: JSON.stringify(variableValues) }, + }); + expectOk(preview, "contract preview"); + assert(preview.body.data.preview_content, "preview content missing", preview.body.data); + + const generated = await apiRequest("post", "/contracts/generate", { + authToken: state.member.access_token, + multipart: { + template_id: template.id, + variable_values_json: JSON.stringify(variableValues), + export_format: "txt", + }, + }); + expectOk(generated, "contract generate"); + state.generatedContract = generated.body.data; + + let detail = null; + for (let attempt = 0; attempt < 10; attempt += 1) { + detail = await apiRequest("get", `/contracts/${state.generatedContract.id}`, { authToken: state.member.access_token }); + expectOk(detail, "contract detail"); + if (detail.body.data.auto_review?.status || detail.body.data.auto_review?.review_id) break; + await delay(1000); + } + + const download = await downloadAndSave(`/contracts/${state.generatedContract.id}/download`, state.generatedContract.file_name, state.member.access_token); + assert(detail.body.data.auto_review, "auto review summary missing from generated contract detail", detail.body.data); + return { + template: template.name, + contract_id: state.generatedContract.id, + auto_review: detail.body.data.auto_review, + download_bytes: download.bytes, + }; + }); + + await step("CONTRACT-002", "contracts page exposes drafting workflow wording", async () => { + const body = await getBodyText("/contracts", state.member, "contracts-workflow"); + assert(body.includes("合同起草"), "contracts page missing title", body.slice(0, 1200)); + assert(includesAny(body, ["新建合同", "选择模板", "填写关键内容", "预览确认", "生成并预审"]), "contracts page missing workflow wording", body.slice(0, 1800)); + assert(includesAny(body, ["自动预审", "风险", "报告"]), "contracts page missing auto-review result wording", body.slice(0, 1800)); + return { screenshot: "contracts-workflow.png" }; + }); + + await step("REVIEW-001", "text review result and report download", async () => { + const text = [ + `${runId} Phase1 review sample.`, + "合同约定任意一方违约,应按合同总金额的50%支付违约金。", + "同时存在自动续约、免责和付款条件另行协商的表述。", + ].join("\n"); + const create = await apiRequest("post", "/reviews", { + authToken: state.member.access_token, + timeout: 90000, + multipart: { text, review_as: "party_a", contract_type: "procurement" }, + }); + expectOk(create, "create review"); + state.review = create.body.data; + const detail = await apiRequest("get", `/reviews/${state.review.id}`, { authToken: state.member.access_token }); + expectOk(detail, "review detail"); + const result = await apiRequest("get", `/reviews/${state.review.id}/result`, { authToken: state.member.access_token }); + expectOk(result, "review result"); + const report = await downloadAndSave(`/reviews/${state.review.id}/report/download`, `${runId}-review-report.pdf`, state.member.access_token); + assert(report.bytes > 500, "review report is too small to be useful", report); + return { + review_id: state.review.id, + risk_summary: result.body.data.risk_summary, + report_bytes: report.bytes, + }; + }); + + await step("REVIEW-002", "review detail defaults to business conclusion and keeps technical details available", async () => { + assert(state.review?.id, "review id missing before UI check"); + const body = await getBodyText(`/reviews/${state.review.id}`, state.member, "review-detail-business"); + assert(includesAny(body, ["总体结论", "风险等级", "试点演示摘要", "业务处理摘要"]), "review detail missing business summary", body.slice(0, 2000)); + assert(includesAny(body, ["必改事项", "可协商事项", "下一步", "报告交付", "下载审查报告"]), "review detail missing action/report area", body.slice(0, 2400)); + assert(includesAny(body, ["技术详情", "处理详情", "高级信息", "Workflow", "AI Trace"]), "review detail missing technical detail access", body.slice(0, 3000)); + return { screenshot: "review-detail-business.png" }; + }); + + await step("TEMPLATE-001", "template library exposes category variables and versions", async () => { + const list = await apiRequest("get", "/templates?page=1&page_size=10", { authToken: state.admin.access_token }); + expectOk(list, "template list"); + const items = list.body.data.items || []; + assert(items.length > 0, "template list is empty", list.body.data); + const template = items[0]; + assert(template.name && Array.isArray(template.variables), "template summary missing variables", template); + assert("enabled" in template, "template summary missing publish status", template); + assert(template.current_version_label || template.latest_change_summary || template.template_code, "template summary missing version identity", template); + const versions = await apiRequest("get", `/templates/${template.id}/versions`, { authToken: state.admin.access_token }); + expectOk(versions, "template versions"); + const versionItems = Array.isArray(versions.body.data) ? versions.body.data : versions.body.data?.items; + assert(Array.isArray(versionItems), "template versions response should include version items", versions.body.data); + return { + template: template.name, + variables: template.variables.length, + enabled: template.enabled, + version_count: versionItems.length, + }; + }); + + await step("TEMPLATE-002", "templates page is readable for admin", async () => { + const body = await getBodyText("/templates", state.admin, "templates-summary"); + assert(body.includes("模板"), "templates page missing template wording", body.slice(0, 1200)); + assert(includesAny(body, ["适用场景", "发布", "变量", "版本", "分类"]), "templates page missing summary/version wording", body.slice(0, 2400)); + return { screenshot: "templates-summary.png" }; + }); +} + +async function finalize() { + for (const context of state.contexts) { + await context.close().catch(() => undefined); + } + if (state.browser) { + await state.browser.close().catch(() => undefined); + } + if (state.api) { + await state.api.dispose().catch(() => undefined); + } + + const summary = { + name: "phase1-mainline-signoff", + run_id: runId, + web_base: webBase, + api_base: apiBase, + health_url: healthUrl, + artifact_dir: artifactDir, + total: results.length, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "phase1-mainline-signoff-summary.json"), JSON.stringify(summary, null, 2), "utf8"); + const markdown = [ + "# Phase1 Mainline Signoff", + "", + `- Run ID: \`${runId}\``, + `- Web: \`${webBase}\``, + `- API: \`${apiBase}\``, + `- Health: \`${healthUrl}\``, + `- Passed: ${summary.passed}/${summary.total}`, + "", + "| ID | Status | Title | Duration |", + "| --- | --- | --- | ---: |", + ...results.map((item) => `| ${item.id} | ${item.status} | ${item.title.replace(/\|/g, "/")} | ${item.duration_ms}ms |`), + "", + ].join("\n"); + fs.writeFileSync(path.join(artifactDir, "phase1-mainline-signoff-summary.md"), markdown, "utf8"); + console.log(JSON.stringify(summary, null, 2)); + if (summary.failed > 0) { + process.exitCode = 1; + } +} + +run() + .catch((error) => { + results.push({ + id: "RUNNER", + title: "unhandled runner error", + status: "FAIL", + duration_ms: 0, + started_at: new Date().toISOString(), + error: error.message, + }); + process.exitCode = 1; + }) + .finally(finalize); diff --git a/frontend/src/app/api/v1/knowledge-bases/[id]/versions/route.ts b/frontend/src/app/api/v1/knowledge-bases/[id]/versions/route.ts new file mode 100644 index 00000000..fadab5ae --- /dev/null +++ b/frontend/src/app/api/v1/knowledge-bases/[id]/versions/route.ts @@ -0,0 +1,105 @@ +import { NextResponse, type NextRequest } from "next/server"; + +type KnowledgeBaseItem = { + id: string; + name: string; + description: string | null; + status: string; + retrieval_mode?: string | null; + current_version_label?: string | null; + published_at?: string | null; + published_by?: string | null; + created_at: string; + updated_at: string; +}; + +const backendBaseUrl = + process.env.BACKEND_BASE_URL ?? + (process.env.NODE_ENV === "production" ? "http://backend:8000" : "http://localhost:8000"); + +function proxyHeaders(request: NextRequest) { + return { + authorization: request.headers.get("authorization") || "", + accept: "application/json", + }; +} + +function jsonError(status: number, message: string) { + return NextResponse.json({ code: status, message, data: null }, { status }); +} + +function buildCurrentSnapshot(item: KnowledgeBaseItem) { + const versionLabel = item.current_version_label?.trim() || "current"; + return { + id: `${item.id}-${versionLabel}-snapshot`, + knowledge_base_id: item.id, + version_label: versionLabel, + name: item.name, + description: item.description, + retrieval_mode: item.retrieval_mode, + status: item.status, + published_at: item.published_at ?? null, + published_by: item.published_by ?? null, + change_summary: "版本历史暂时不可用,已显示当前知识库快照。", + created_at: item.published_at ?? item.updated_at ?? item.created_at, + }; +} + +async function fallbackVersions(request: NextRequest, knowledgeBaseId: string) { + const targetUrl = new URL("/api/v1/knowledge-bases", backendBaseUrl); + targetUrl.searchParams.set("page", "1"); + targetUrl.searchParams.set("page_size", "100"); + + const response = await fetch(targetUrl, { + headers: proxyHeaders(request), + cache: "no-store", + }); + + if (!response.ok) { + return jsonError(response.status, "获取知识库版本失败,请稍后重试"); + } + + const payload = await response.json(); + const items = Array.isArray(payload?.data?.items) ? payload.data.items as KnowledgeBaseItem[] : []; + const knowledgeBase = items.find((item) => item.id === knowledgeBaseId); + + if (!knowledgeBase) { + return jsonError(404, "知识库不存在或无权访问"); + } + + return NextResponse.json({ + code: 0, + message: "ok", + data: [buildCurrentSnapshot(knowledgeBase)], + }); +} + +async function proxyRequest(request: NextRequest, knowledgeBaseId: string) { + const targetUrl = new URL(`/api/v1/knowledge-bases/${knowledgeBaseId}/versions`, backendBaseUrl); + const response = await fetch(targetUrl, { + headers: proxyHeaders(request), + cache: "no-store", + }); + + if (!response.ok) { + return fallbackVersions(request, knowledgeBaseId); + } + + const body = await response.text(); + return new Response(body, { + status: response.status, + headers: { "content-type": response.headers.get("content-type") || "application/json" }, + }); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + return await proxyRequest(request, id); + } catch { + return jsonError(500, "获取知识库版本失败,请稍后重试"); + } +} diff --git a/frontend/src/app/audit-logs/page.tsx b/frontend/src/app/audit-logs/page.tsx index 8f2aee0b..d09391f2 100644 --- a/frontend/src/app/audit-logs/page.tsx +++ b/frontend/src/app/audit-logs/page.tsx @@ -15,6 +15,21 @@ const actionColorMap: Record = { login: "blue", create_review: "gold", download_report: "green", + contract_generation: "cyan", + export_contract_ledger: "purple", + download_contract: "green", + contract_lifecycle_update: "geekblue", + contract_lifecycle_task_sync: "purple", + contract_lifecycle_task_create: "green", + contract_lifecycle_task_update: "orange", + contract_version_create: "blue", + contract_version_diff: "cyan", + download_contract_version: "lime", + contract_version_comment_create: "magenta", + contract_version_comment_update: "volcano", + contract_approval_submit: "blue", + contract_approval_approved: "green", + contract_approval_rejected: "red", }; function formatDateTime(value: string) { @@ -28,6 +43,21 @@ function formatActionLabel(value: string) { login: "登录", create_review: "创建审查任务", download_report: "下载审查报告", + contract_generation: "生成合同", + export_contract_ledger: "导出合同台账", + download_contract: "下载合同", + contract_lifecycle_update: "更新生命周期", + contract_lifecycle_task_sync: "同步履约任务", + contract_lifecycle_task_create: "新增履约/证据任务", + contract_lifecycle_task_update: "更新履约/证据任务", + contract_version_create: "创建合同版本", + contract_version_diff: "查看版本比对", + download_contract_version: "下载合同版本", + contract_version_comment_create: "新增版本批注", + contract_version_comment_update: "更新版本批注", + contract_approval_submit: "提交审批", + contract_approval_approved: "审批通过", + contract_approval_rejected: "审批退回", }; return actionLabelMap[value] ?? value; @@ -48,6 +78,7 @@ export default function AuditLogsPage() { const [items, setItems] = useState([]); const [keyword, setKeyword] = useState(""); const [actionFilter, setActionFilter] = useState("all"); + const [actionFilterReady, setActionFilterReady] = useState(false); const filteredItems = useMemo(() => { const normalizedKeyword = keyword.trim().toLowerCase(); @@ -70,6 +101,21 @@ export default function AuditLogsPage() { { value: "login", label: formatActionLabel("login") }, { value: "create_review", label: formatActionLabel("create_review") }, { value: "download_report", label: formatActionLabel("download_report") }, + { value: "contract_generation", label: formatActionLabel("contract_generation") }, + { value: "export_contract_ledger", label: formatActionLabel("export_contract_ledger") }, + { value: "download_contract", label: formatActionLabel("download_contract") }, + { value: "contract_lifecycle_update", label: formatActionLabel("contract_lifecycle_update") }, + { value: "contract_lifecycle_task_sync", label: formatActionLabel("contract_lifecycle_task_sync") }, + { value: "contract_lifecycle_task_create", label: formatActionLabel("contract_lifecycle_task_create") }, + { value: "contract_lifecycle_task_update", label: formatActionLabel("contract_lifecycle_task_update") }, + { value: "contract_version_create", label: formatActionLabel("contract_version_create") }, + { value: "contract_version_diff", label: formatActionLabel("contract_version_diff") }, + { value: "download_contract_version", label: formatActionLabel("download_contract_version") }, + { value: "contract_version_comment_create", label: formatActionLabel("contract_version_comment_create") }, + { value: "contract_version_comment_update", label: formatActionLabel("contract_version_comment_update") }, + { value: "contract_approval_submit", label: formatActionLabel("contract_approval_submit") }, + { value: "contract_approval_approved", label: formatActionLabel("contract_approval_approved") }, + { value: "contract_approval_rejected", label: formatActionLabel("contract_approval_rejected") }, ]; const columns: ColumnsType = [ @@ -119,13 +165,16 @@ export default function AuditLogsPage() { useEffect(() => { const loadAuditLogs = async () => { + if (!actionFilterReady) { + return; + } if (currentUser?.role !== "admin") { setLoading(false); return; } try { - const result = await getAuditLogs(); + const result = await getAuditLogs({ action: actionFilter === "all" ? undefined : actionFilter }); if (result.code !== 0) { messageApi.error(result.message || "获取审计日志失败"); @@ -141,7 +190,13 @@ export default function AuditLogsPage() { }; void loadAuditLogs(); - }, [currentUser?.role, messageApi]); + }, [actionFilter, actionFilterReady, currentUser?.role, messageApi]); + + useEffect(() => { + const queryAction = new URLSearchParams(window.location.search).get("action"); + setActionFilter(queryAction || "all"); + setActionFilterReady(true); + }, []); if (currentUser?.role !== "admin") { return ( diff --git a/frontend/src/app/billing/page.tsx b/frontend/src/app/billing/page.tsx index 31236e78..779afc7d 100644 --- a/frontend/src/app/billing/page.tsx +++ b/frontend/src/app/billing/page.tsx @@ -446,7 +446,7 @@ export default function BillingPage() { title: "套餐", key: "plan", render: (_, order) => ( - + {order.plan_name} {order.plan_code} @@ -731,7 +731,7 @@ export default function BillingPage() { return (
{contextHolder} - +
Usage & Billing @@ -761,7 +761,7 @@ export default function BillingPage() {
{summary?.warnings.length ? ( - + {summary.warnings.map((warning) => ( - + 试用开始:{formatDateTime(summary.plan.trial_started_at)} 试用到期:{formatDateTime(summary.plan.trial_ends_at)} {summary.upgrade_action.message} @@ -962,7 +962,7 @@ export default function BillingPage() { showIcon type="info" style={{ marginBottom: 16 }} - message="测试服务器没有公网时,先走线下收款确认。真实微信/支付宝回调以后会复用同一张订单。" + title="测试服务器没有公网时,先走线下收款确认。真实微信/支付宝回调以后会复用同一张订单。" />