Files
ai-agent-admin/backend-fastapi/online_dev/contract/api.py
T

774 lines
26 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合同管理 API(异步版本)
"""
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.base_schema import PaginatedResponse
from online_dev.contract.schema import (
ContractInstanceCreate,
ContractInstanceListItem,
ContractInstanceOut,
ContractInstanceUpdate,
ContractLogOut,
ContractSignatureCreate,
ContractSignatureOut,
ContractTemplateCreate,
ContractTemplateListItem,
ContractTemplateOut,
ContractTemplateUpdate,
MobileContractInfo,
MobileSignRequest,
MobileSignTokenCreate,
MobileSignTokenOut,
)
from online_dev.contract.service import (
ContractInstanceService,
ContractSignatureService,
ContractTemplateService,
MobileSignService,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/contract", tags=["合同管理"])
# ============ 辅助函数 ============
def _format_datetime(dt) -> str:
"""格式化日期时间"""
if dt:
return dt.strftime("%Y-%m-%d %H:%M:%S")
return ""
async def _build_template_out(template) -> dict:
"""构建模板输出"""
return {
"id": str(template.id),
"name": template.name,
"code": template.code,
"category": template.category or "",
"description": template.description or "",
"status": template.status,
"version": template.version,
"template_config": template.template_config or {},
"thumbnail": template.thumbnail or "",
"sys_create_datetime": template.sys_create_datetime,
"sys_update_datetime": template.sys_update_datetime,
}
async def _build_template_list_item(template) -> dict:
"""构建模板列表项"""
return {
"id": str(template.id),
"name": template.name,
"code": template.code,
"category": template.category or "",
"description": template.description or "",
"status": template.status,
"version": template.version,
"thumbnail": template.thumbnail or "",
"sys_create_datetime": template.sys_create_datetime,
"sys_update_datetime": template.sys_update_datetime,
}
async def _build_instance_out(db: AsyncSession, instance) -> dict:
"""构建合同实例输出"""
# 获取关联的模板信息
template = await ContractTemplateService.get_by_id(db, instance.template_id)
template_name = template.name if template else ""
template_code = template.code if template else ""
# 获取创建人信息
creator_name = ""
if instance.creator_id:
from core.user.model import User
from sqlalchemy import select
stmt = select(User).where(User.id == instance.creator_id)
result = await db.execute(stmt)
creator = result.scalar_one_or_none()
if creator:
creator_name = creator.name or creator.username or ""
return {
"id": str(instance.id),
"contract_no": instance.contract_no,
"title": instance.title,
"status": instance.status,
"template_id": str(instance.template_id),
"contract_config": instance.contract_config or {},
"variable_data": instance.variable_data or {},
"signature_data": instance.signature_data or {},
"created_at": instance.created_at,
"completed_at": instance.completed_at,
"expired_at": instance.expired_at,
"pdf_file": instance.pdf_file or "",
"template_name": template_name,
"template_code": template_code,
"creator_id": str(instance.creator_id) if instance.creator_id else "",
"creator_name": creator_name,
}
async def _build_instance_list_item(db: AsyncSession, instance) -> dict:
"""构建合同实例列表项"""
# 获取关联的模板信息
template = await ContractTemplateService.get_by_id(db, instance.template_id)
template_name = template.name if template else ""
# 获取创建人信息
creator_name = ""
if instance.creator_id:
from core.user.model import User
from sqlalchemy import select
stmt = select(User).where(User.id == instance.creator_id)
result = await db.execute(stmt)
creator = result.scalar_one_or_none()
if creator:
creator_name = creator.name or creator.username or ""
return {
"id": str(instance.id),
"contract_no": instance.contract_no,
"title": instance.title,
"status": instance.status,
"created_at": instance.created_at,
"completed_at": instance.completed_at,
"template_name": template_name,
"creator_name": creator_name,
}
def _build_signature_out(signature) -> dict:
"""构建签署记录输出"""
return {
"id": str(signature.id),
"element_id": signature.element_id,
"party_type": signature.party_type,
"party_label": signature.party_label or "",
"sign_type": signature.sign_type,
"signer_id": str(signature.signer_id) if signature.signer_id else None,
"signer_name": signature.signer_name or "",
"signature_image": signature.signature_image or "",
"status": signature.status,
"signed_at": signature.signed_at,
}
async def _build_log_out(db: AsyncSession, log) -> dict:
"""构建日志输出"""
# 获取操作人信息
operator_name = ""
if log.operator_id:
from core.user.model import User
from sqlalchemy import select
stmt = select(User).where(User.id == log.operator_id)
result = await db.execute(stmt)
operator = result.scalar_one_or_none()
if operator:
operator_name = operator.name or operator.username or ""
return {
"id": str(log.id),
"action": log.action,
"operator_id": str(log.operator_id) if log.operator_id else "",
"comment": log.comment or "",
"sys_create_datetime": log.sys_create_datetime,
"operator_name": operator_name,
}
# ============ 合同模板 API ============
@router.get("/template/list", response_model=PaginatedResponse[ContractTemplateListItem], summary="模板列表")
async def list_templates(
name: str = Query(None, description="模板名称"),
code: str = Query(None, description="模板编码"),
category: str = Query(None, description="分类"),
status: str = Query(None, description="状态"),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""分页查询合同模板列表"""
result = await ContractTemplateService.get_list(
db=db,
page=page,
page_size=page_size,
name=name,
code=code,
category=category,
status=status,
)
items = [await _build_template_list_item(item) for item in result["items"]]
return PaginatedResponse(
items=items,
total=result["total"],
)
@router.get("/template/categories", response_model=List[str], summary="获取模板分类")
async def get_template_categories(db: AsyncSession = Depends(get_db)):
"""获取所有模板分类"""
return await ContractTemplateService.get_categories(db)
@router.get("/template/{template_id}", response_model=ContractTemplateOut, summary="模板详情")
async def get_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取模板详情"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return await _build_template_out(template)
@router.get("/template/code/{code}", response_model=ContractTemplateOut, summary="根据编码获取模板")
async def get_template_by_code(
code: str,
db: AsyncSession = Depends(get_db),
):
"""根据编码获取模板"""
template = await ContractTemplateService.get_by_code(db, code)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return await _build_template_out(template)
@router.post("/template", response_model=ContractTemplateOut, summary="创建模板")
async def create_template(
request: Request,
data: ContractTemplateCreate,
db: AsyncSession = Depends(get_db),
):
"""创建合同模板"""
user_id = request.state.user_id
# 检查编码是否已存在
if await ContractTemplateService.get_by_code(db, data.code):
raise HTTPException(status_code=400, detail=f"模板编码 {data.code} 已存在")
try:
template = await ContractTemplateService.create(
db=db,
name=data.name,
code=data.code,
category=data.category,
description=data.description,
template_config=data.template_config,
creator_id=user_id,
)
return await _build_template_out(template)
except Exception as e:
logger.exception("创建模板失败")
raise HTTPException(status_code=400, detail=str(e))
@router.put("/template/{template_id}", response_model=ContractTemplateOut, summary="更新模板")
async def update_template(
template_id: str,
data: ContractTemplateUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
try:
template = await ContractTemplateService.update(
db=db,
template=template,
name=data.name,
category=data.category,
description=data.description,
template_config=data.template_config,
thumbnail=data.thumbnail,
)
return await _build_template_out(template)
except Exception as e:
logger.exception("更新模板失败")
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/template/{template_id}", summary="删除模板")
async def delete_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
await ContractTemplateService.delete(db, template)
return {"message": "删除成功"}
@router.delete("/template/batch", summary="批量删除模板")
async def batch_delete_templates(
ids: List[str] = Query(..., description="模板ID列表"),
db: AsyncSession = Depends(get_db),
):
"""批量删除合同模板"""
count = 0
for template_id in ids:
template = await ContractTemplateService.get_by_id(db, template_id)
if template:
await ContractTemplateService.delete(db, template)
count += 1
return {"count": count}
@router.post("/template/{template_id}/publish", response_model=ContractTemplateOut, summary="发布模板")
async def publish_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""发布合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
template = await ContractTemplateService.publish(db, template)
return await _build_template_out(template)
@router.post("/template/{template_id}/disable", response_model=ContractTemplateOut, summary="停用模板")
async def disable_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""停用合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
template = await ContractTemplateService.disable(db, template)
return await _build_template_out(template)
@router.post("/template/{template_id}/copy", response_model=ContractTemplateOut, summary="复制模板")
async def copy_template(
request: Request,
template_id: str,
new_code: str = Query(..., alias="newCode", description="新模板编码"),
new_name: str = Query(None, alias="newName", description="新模板名称"),
db: AsyncSession = Depends(get_db),
):
"""复制合同模板"""
user_id = request.state.user_id
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
# 检查新编码是否已存在
if await ContractTemplateService.get_by_code(db, new_code):
raise HTTPException(status_code=400, detail=f"模板编码 {new_code} 已存在")
new_template = await ContractTemplateService.copy(
db=db,
template=template,
new_code=new_code,
new_name=new_name,
creator_id=user_id,
)
return await _build_template_out(new_template)
# ============ 合同实例 API ============
@router.get("/instance/generate-no", summary="生成合同编号")
async def generate_contract_no():
"""生成一个新的合同编号"""
return {"contract_no": ContractInstanceService.generate_contract_no()}
@router.get("/instance/check-no", summary="检查合同编号唯一性")
async def check_contract_no(
contract_no: str = Query(..., alias="contractNo", description="合同编号"),
exclude_id: str = Query(None, alias="excludeId", description="排除的合同ID"),
db: AsyncSession = Depends(get_db),
):
"""检查合同编号是否已存在"""
exists = await ContractInstanceService.check_contract_no_exists(db, contract_no, exclude_id)
return {"exists": exists, "valid": not exists}
@router.get("/instance/list", response_model=PaginatedResponse[ContractInstanceListItem], summary="合同列表")
async def list_instances(
title: str = Query(None, description="合同标题"),
contract_no: str = Query(None, alias="contractNo", description="合同编号"),
status: str = Query(None, description="状态"),
template_id: str = Query(None, alias="templateId", description="模板ID"),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""分页查询合同实例列表"""
result = await ContractInstanceService.get_list(
db=db,
page=page,
page_size=page_size,
title=title,
contract_no=contract_no,
status=status,
template_id=template_id,
)
items = [await _build_instance_list_item(db, item) for item in result["items"]]
return PaginatedResponse(
items=items,
total=result["total"],
)
@router.get("/instance/my", response_model=PaginatedResponse[ContractInstanceListItem], summary="我的合同")
async def list_my_instances(
request: Request,
title: str = Query(None, description="合同标题"),
status: str = Query(None, description="状态"),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""获取我创建的合同"""
user_id = request.state.user_id
result = await ContractInstanceService.get_list(
db=db,
page=page,
page_size=page_size,
title=title,
status=status,
creator_id=user_id,
)
items = [await _build_instance_list_item(db, item) for item in result["items"]]
return PaginatedResponse(
items=items,
total=result["total"],
)
@router.get("/instance/{instance_id}", response_model=ContractInstanceOut, summary="合同详情")
async def get_instance(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取合同详情"""
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
return await _build_instance_out(db, instance)
@router.post("/instance", response_model=ContractInstanceOut, summary="创建合同")
async def create_instance(
request: Request,
data: ContractInstanceCreate,
db: AsyncSession = Depends(get_db),
):
"""创建合同实例"""
user_id = request.state.user_id
try:
instance = await ContractInstanceService.create(
db=db,
template_id=data.template_id,
title=data.title,
creator_id=user_id,
variable_data=data.variable_data,
contract_no=data.contract_no,
)
return await _build_instance_out(db, instance)
except Exception as e:
logger.exception("创建合同失败")
raise HTTPException(status_code=400, detail=str(e))
@router.put("/instance/{instance_id}", response_model=ContractInstanceOut, summary="更新合同")
async def update_instance(
request: Request,
instance_id: str,
data: ContractInstanceUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新合同"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
try:
instance = await ContractInstanceService.update(
db=db,
instance=instance,
title=data.title,
variable_data=data.variable_data,
signature_data=data.signature_data,
operator_id=user_id,
)
return await _build_instance_out(db, instance)
except Exception as e:
logger.exception("更新合同失败")
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/instance/{instance_id}", summary="删除合同")
async def delete_instance(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除合同"""
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
await ContractInstanceService.delete(db, instance)
return {"message": "删除成功"}
@router.post("/instance/{instance_id}/submit", response_model=ContractInstanceOut, summary="提交合同")
async def submit_instance(
request: Request,
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""提交合同(进入待签署状态)"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
instance = await ContractInstanceService.submit(db, instance, user_id)
return await _build_instance_out(db, instance)
@router.post("/instance/{instance_id}/complete", response_model=ContractInstanceOut, summary="完成合同")
async def complete_instance(
request: Request,
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""完成合同"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
instance = await ContractInstanceService.complete(db, instance, user_id)
return await _build_instance_out(db, instance)
@router.post("/instance/{instance_id}/cancel", summary="取消合同")
async def cancel_instance(
request: Request,
instance_id: str,
comment: str = Query("", description="取消原因"),
db: AsyncSession = Depends(get_db),
):
"""取消合同"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
await ContractInstanceService.cancel(db, instance, user_id, comment)
return {"message": "取消成功"}
@router.get("/instance/{instance_id}/logs", response_model=List[ContractLogOut], summary="合同日志")
async def get_instance_logs(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取合同操作日志"""
logs = await ContractInstanceService.get_logs(db, instance_id)
return [await _build_log_out(db, log) for log in logs]
# ============ 签署 API ============
@router.post("/instance/{instance_id}/sign", response_model=ContractSignatureOut, summary="签署合同")
async def sign_instance(
request: Request,
instance_id: str,
data: ContractSignatureCreate,
db: AsyncSession = Depends(get_db),
):
"""签署合同"""
user_id = request.state.user_id
user_name = getattr(request.state, "username", "")
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
# 获取客户端信息
sign_ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "")
sign_device = request.headers.get("User-Agent", "")
try:
signature = await ContractSignatureService.sign(
db=db,
instance=instance,
element_id=data.element_id,
signature_image=data.signature_image,
signer_id=user_id,
signer_name=data.signer_name or user_name,
sign_ip=sign_ip,
sign_device=sign_device,
)
return _build_signature_out(signature)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/instance/{instance_id}/signatures", response_model=List[ContractSignatureOut], summary="签署记录")
async def get_instance_signatures(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取合同签署记录"""
signatures = await ContractSignatureService.get_signatures(db, instance_id)
return [_build_signature_out(sig) for sig in signatures]
# ============ 手机签署 API ============
@router.post("/instance/{instance_id}/mobile-sign-token", response_model=MobileSignTokenOut, summary="生成手机签署二维码")
async def create_mobile_sign_token(
instance_id: str,
data: MobileSignTokenCreate,
db: AsyncSession = Depends(get_db),
):
"""生成手机签署令牌和二维码"""
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
if instance.status not in ["pending", "signing"]:
raise HTTPException(status_code=400, detail="合同状态不允许签署")
try:
sign_token = await MobileSignService.create_sign_token(
db=db,
instance=instance,
party_type=data.party_type,
signer_name=data.signer_name,
expire_minutes=data.expire_minutes,
)
# 只返回 token,由前端构建完整 URL
return {
"token": sign_token.token,
"sign_url": "", # 由前端构建
"expired_at": sign_token.expired_at,
"qrcode_data": "", # 由前端构建
}
except Exception as e:
logger.exception("生成签署令牌失败")
raise HTTPException(status_code=400, detail=str(e))
@router.get("/mobile/contract/{token}", response_model=MobileContractInfo, summary="获取移动端合同信息")
async def get_mobile_contract(
token: str,
db: AsyncSession = Depends(get_db),
):
"""根据令牌获取合同信息(无需登录)"""
sign_token = await MobileSignService.get_by_token(db, token)
if not sign_token:
raise HTTPException(status_code=404, detail="签署链接无效或已过期")
# 验证令牌
is_valid, error_msg = MobileSignService.validate_token(sign_token)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# 获取合同
contract = await ContractInstanceService.get_by_id(db, sign_token.contract_id)
if not contract:
raise HTTPException(status_code=404, detail="合同不存在")
# 获取模板名称
template = await ContractTemplateService.get_by_id(db, contract.template_id)
template_name = template.name if template else ""
return {
"id": str(contract.id),
"contract_no": contract.contract_no,
"title": contract.title,
"status": contract.status,
"contract_config": contract.contract_config or {},
"variable_data": contract.variable_data or {},
"signature_data": contract.signature_data or {},
"party_type": sign_token.party_type,
"signer_name": sign_token.signer_name or "",
"template_name": template_name,
}
@router.post("/mobile/sign/{token}", response_model=ContractSignatureOut, summary="移动端签署")
async def mobile_sign(
request: Request,
token: str,
data: MobileSignRequest,
db: AsyncSession = Depends(get_db),
):
"""移动端签署合同(无需登录)"""
sign_token = await MobileSignService.get_by_token(db, token)
if not sign_token:
raise HTTPException(status_code=404, detail="签署链接无效或已过期")
# 验证令牌
is_valid, error_msg = MobileSignService.validate_token(sign_token)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# 获取合同
instance = await ContractInstanceService.get_by_id(db, sign_token.contract_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
# 获取客户端信息
sign_ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "")
sign_device = request.headers.get("User-Agent", "")
try:
signature = await MobileSignService.mobile_sign(
db=db,
sign_token=sign_token,
instance=instance,
element_id=data.element_id,
signature_image=data.signature_image,
signer_name=data.signer_name,
sign_ip=sign_ip,
sign_device=sign_device,
)
return _build_signature_out(signature)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))