feat: restore source parity and harden agent runtime
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
合同管理模块
|
||||
"""
|
||||
@@ -0,0 +1,773 @@
|
||||
#!/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))
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
合同管理数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, String, Text, Integer, DateTime, Boolean, JSON
|
||||
|
||||
from app.base_model import BaseModel
|
||||
|
||||
|
||||
class ContractTemplate(BaseModel):
|
||||
"""合同模板"""
|
||||
__tablename__ = "contract_template"
|
||||
|
||||
name = Column(String(100), nullable=False, comment="模板名称")
|
||||
code = Column(String(100), unique=True, nullable=False, comment="模板编码")
|
||||
category = Column(String(50), default="", comment="分类")
|
||||
description = Column(Text, default="", comment="描述")
|
||||
status = Column(String(20), default="draft", index=True, comment="状态: draft/published/disabled")
|
||||
version = Column(Integer, default=1, comment="版本号")
|
||||
|
||||
# 模板配置(JSON格式存储页面设置、元素、变量等)
|
||||
template_config = Column(JSON, default=dict, comment="模板配置")
|
||||
|
||||
# 缩略图
|
||||
thumbnail = Column(Text, default="", comment="缩略图(base64)")
|
||||
|
||||
|
||||
class ContractInstance(BaseModel):
|
||||
"""合同实例(基于模板创建的合同)"""
|
||||
__tablename__ = "contract_instance"
|
||||
|
||||
# 关联模板(逻辑外键)
|
||||
template_id = Column(String(21), nullable=False, index=True, comment="合同模板ID")
|
||||
|
||||
# 合同信息
|
||||
contract_no = Column(String(50), unique=True, nullable=False, comment="合同编号")
|
||||
title = Column(String(200), nullable=False, comment="合同标题")
|
||||
status = Column(String(20), default="draft", index=True, comment="状态: draft/pending/signing/completed/expired/canceled")
|
||||
|
||||
# 创建人(逻辑外键)
|
||||
creator_id = Column(String(21), nullable=False, index=True, comment="创建人ID")
|
||||
|
||||
# 合同配置(继承自模板,可修改)
|
||||
contract_config = Column(JSON, default=dict, comment="合同配置")
|
||||
|
||||
# 变量数据
|
||||
variable_data = Column(JSON, default=dict, comment="变量数据")
|
||||
|
||||
# 签署数据
|
||||
signature_data = Column(JSON, default=dict, comment="签署数据")
|
||||
|
||||
# 时间记录
|
||||
created_at = Column(DateTime, nullable=True, comment="创建时间")
|
||||
completed_at = Column(DateTime, nullable=True, comment="完成时间")
|
||||
expired_at = Column(DateTime, nullable=True, comment="过期时间")
|
||||
|
||||
# PDF 文件
|
||||
pdf_file = Column(Text, default="", comment="PDF文件路径")
|
||||
|
||||
|
||||
class ContractSignature(BaseModel):
|
||||
"""合同签署记录"""
|
||||
__tablename__ = "contract_signature"
|
||||
|
||||
# 关联合同(逻辑外键)
|
||||
contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID")
|
||||
|
||||
# 签署区信息
|
||||
element_id = Column(String(50), nullable=False, comment="元素ID")
|
||||
party_type = Column(String(20), default="party_a", comment="签署方类型: party_a/party_b/party_c/witness")
|
||||
party_label = Column(String(50), default="", comment="签署方标签")
|
||||
sign_type = Column(String(20), default="signature", comment="签署类型: signature/seal")
|
||||
|
||||
# 签署人(逻辑外键)
|
||||
signer_id = Column(String(21), nullable=True, comment="签署人ID")
|
||||
signer_name = Column(String(50), default="", comment="签署人姓名")
|
||||
|
||||
# 签署数据
|
||||
signature_image = Column(Text, default="", comment="签名/印章图片(base64)")
|
||||
status = Column(String(20), default="pending", comment="状态: pending/signed/rejected")
|
||||
signed_at = Column(DateTime, nullable=True, comment="签署时间")
|
||||
|
||||
# IP 和设备信息
|
||||
sign_ip = Column(String(50), default="", comment="签署IP")
|
||||
sign_device = Column(String(200), default="", comment="签署设备")
|
||||
|
||||
|
||||
class ContractSignToken(BaseModel):
|
||||
"""合同签署令牌(用于手机扫码签署)"""
|
||||
__tablename__ = "contract_sign_token"
|
||||
|
||||
# 关联合同(逻辑外键)
|
||||
contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID")
|
||||
|
||||
# 令牌
|
||||
token = Column(String(64), unique=True, nullable=False, comment="签署令牌")
|
||||
|
||||
# 签署方信息
|
||||
party_type = Column(String(20), default="party_a", comment="签署方类型")
|
||||
signer_name = Column(String(50), default="", comment="签署人姓名")
|
||||
|
||||
# 有效期
|
||||
expired_at = Column(DateTime, nullable=False, comment="过期时间")
|
||||
|
||||
# 是否已使用
|
||||
is_used = Column(Boolean, default=False, comment="是否已使用")
|
||||
used_at = Column(DateTime, nullable=True, comment="使用时间")
|
||||
|
||||
|
||||
class ContractLog(BaseModel):
|
||||
"""合同操作日志"""
|
||||
__tablename__ = "contract_log"
|
||||
|
||||
# 关联合同(逻辑外键)
|
||||
contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID")
|
||||
|
||||
# 操作信息
|
||||
action = Column(String(20), nullable=False, comment="操作类型: create/update/submit/sign/reject/cancel/complete/export/view")
|
||||
|
||||
# 操作人(逻辑外键)
|
||||
operator_id = Column(String(21), nullable=False, comment="操作人ID")
|
||||
|
||||
# 操作详情
|
||||
comment = Column(Text, default="", comment="备注")
|
||||
extra_data = Column(JSON, default=dict, comment="额外数据")
|
||||
|
||||
# IP 信息
|
||||
ip_address = Column(String(50), default="", comment="IP地址")
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
合同管理 Schema 定义
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
from app.base_schema import CSTDatetime
|
||||
|
||||
|
||||
# ============ 合同模板 Schema ============
|
||||
|
||||
class ContractTemplateCreate(BaseModel):
|
||||
"""创建合同模板"""
|
||||
name: str = Field(..., max_length=100, description="模板名称")
|
||||
code: str = Field(..., max_length=100, description="模板编码")
|
||||
category: str = Field(default="", max_length=50, description="分类")
|
||||
description: str = Field(default="", description="描述")
|
||||
template_config: Dict[str, Any] = Field(default_factory=dict, description="模板配置")
|
||||
|
||||
|
||||
class ContractTemplateUpdate(BaseModel):
|
||||
"""更新合同模板"""
|
||||
name: Optional[str] = Field(None, max_length=100, description="模板名称")
|
||||
category: Optional[str] = Field(None, max_length=50, description="分类")
|
||||
description: Optional[str] = Field(None, description="描述")
|
||||
template_config: Optional[Dict[str, Any]] = Field(None, description="模板配置")
|
||||
thumbnail: Optional[str] = Field(None, description="缩略图")
|
||||
|
||||
|
||||
class ContractTemplateOut(BaseModel):
|
||||
"""合同模板输出"""
|
||||
id: str
|
||||
name: str
|
||||
code: str
|
||||
category: str
|
||||
description: str
|
||||
status: str
|
||||
version: int
|
||||
template_config: Dict[str, Any]
|
||||
thumbnail: str
|
||||
sys_create_datetime: CSTDatetime
|
||||
sys_update_datetime: CSTDatetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ContractTemplateListItem(BaseModel):
|
||||
"""合同模板列表项"""
|
||||
id: str
|
||||
name: str
|
||||
code: str
|
||||
category: str
|
||||
description: str
|
||||
status: str
|
||||
version: int
|
||||
thumbnail: str
|
||||
sys_create_datetime: CSTDatetime
|
||||
sys_update_datetime: CSTDatetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ============ 合同实例 Schema ============
|
||||
|
||||
class ContractInstanceCreate(BaseModel):
|
||||
"""创建合同实例"""
|
||||
template_id: str = Field(..., description="模板ID")
|
||||
title: str = Field(..., max_length=200, description="合同标题")
|
||||
contract_no: Optional[str] = Field(None, max_length=50, description="合同编号(可选,不填则自动生成)")
|
||||
variable_data: Dict[str, Any] = Field(default_factory=dict, description="变量数据")
|
||||
|
||||
|
||||
class ContractInstanceUpdate(BaseModel):
|
||||
"""更新合同实例"""
|
||||
title: Optional[str] = Field(None, max_length=200, description="合同标题")
|
||||
variable_data: Optional[Dict[str, Any]] = Field(None, description="变量数据")
|
||||
signature_data: Optional[Dict[str, Any]] = Field(None, description="签署数据")
|
||||
|
||||
|
||||
class ContractInstanceOut(BaseModel):
|
||||
"""合同实例输出"""
|
||||
id: str
|
||||
contract_no: str
|
||||
title: str
|
||||
status: str
|
||||
template_id: str
|
||||
contract_config: Dict[str, Any]
|
||||
variable_data: Dict[str, Any]
|
||||
signature_data: Dict[str, Any]
|
||||
created_at: Optional[CSTDatetime] = None
|
||||
completed_at: Optional[CSTDatetime] = None
|
||||
expired_at: Optional[CSTDatetime] = None
|
||||
pdf_file: str
|
||||
|
||||
# 关联字段
|
||||
template_name: str = ""
|
||||
template_code: str = ""
|
||||
creator_id: str = ""
|
||||
creator_name: str = ""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ContractInstanceListItem(BaseModel):
|
||||
"""合同实例列表项"""
|
||||
id: str
|
||||
contract_no: str
|
||||
title: str
|
||||
status: str
|
||||
created_at: Optional[CSTDatetime] = None
|
||||
completed_at: Optional[CSTDatetime] = None
|
||||
|
||||
# 关联字段
|
||||
template_name: str = ""
|
||||
creator_name: str = ""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ============ 签署记录 Schema ============
|
||||
|
||||
class ContractSignatureCreate(BaseModel):
|
||||
"""创建签署记录"""
|
||||
element_id: str = Field(..., description="元素ID")
|
||||
signature_image: str = Field(..., description="签名图片(base64)")
|
||||
signer_name: str = Field(default="", description="签署人姓名")
|
||||
|
||||
|
||||
class ContractSignatureOut(BaseModel):
|
||||
"""签署记录输出"""
|
||||
id: str
|
||||
element_id: str
|
||||
party_type: str
|
||||
party_label: str
|
||||
sign_type: str
|
||||
signer_id: Optional[str] = None
|
||||
signer_name: str
|
||||
signature_image: str
|
||||
status: str
|
||||
signed_at: Optional[CSTDatetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ============ 操作日志 Schema ============
|
||||
|
||||
class ContractLogOut(BaseModel):
|
||||
"""操作日志输出"""
|
||||
id: str
|
||||
action: str
|
||||
operator_id: str
|
||||
comment: str
|
||||
sys_create_datetime: CSTDatetime
|
||||
|
||||
# 关联字段
|
||||
operator_name: str = ""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ============ 手机签署 Schema ============
|
||||
|
||||
class MobileSignTokenCreate(BaseModel):
|
||||
"""创建手机签署令牌"""
|
||||
party_type: str = Field(default="party_a", description="签署方类型")
|
||||
signer_name: str = Field(default="", description="签署人姓名")
|
||||
expire_minutes: int = Field(default=30, description="有效期(分钟)")
|
||||
|
||||
|
||||
class MobileSignTokenOut(BaseModel):
|
||||
"""手机签署令牌输出"""
|
||||
token: str
|
||||
sign_url: str
|
||||
expired_at: CSTDatetime
|
||||
qrcode_data: str # 二维码内容
|
||||
|
||||
|
||||
class MobileContractInfo(BaseModel):
|
||||
"""移动端合同信息(简化版)"""
|
||||
id: str
|
||||
contract_no: str
|
||||
title: str
|
||||
status: str
|
||||
contract_config: Dict[str, Any]
|
||||
variable_data: Dict[str, Any]
|
||||
signature_data: Dict[str, Any]
|
||||
party_type: str = ""
|
||||
signer_name: str = ""
|
||||
template_name: str = ""
|
||||
|
||||
|
||||
class MobileSignRequest(BaseModel):
|
||||
"""移动端签署请求"""
|
||||
element_id: str = Field(..., description="元素ID")
|
||||
signature_image: str = Field(..., description="签名图片(base64)")
|
||||
signer_name: str = Field(default="", description="签署人姓名")
|
||||
|
||||
|
||||
# ============ 通用响应 ============
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""消息响应"""
|
||||
message: str
|
||||
|
||||
|
||||
class CountResponse(BaseModel):
|
||||
"""计数响应"""
|
||||
count: int
|
||||
@@ -0,0 +1,736 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
合同管理服务(异步版本)
|
||||
"""
|
||||
import secrets
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from online_dev.contract.model import (
|
||||
ContractInstance,
|
||||
ContractLog,
|
||||
ContractSignature,
|
||||
ContractSignToken,
|
||||
ContractTemplate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContractTemplateService:
|
||||
"""合同模板服务"""
|
||||
|
||||
@staticmethod
|
||||
async def get_list(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
name: str = None,
|
||||
code: str = None,
|
||||
category: str = None,
|
||||
status: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取模板列表"""
|
||||
conditions = [ContractTemplate.is_deleted == False]
|
||||
|
||||
if name:
|
||||
conditions.append(ContractTemplate.name.ilike(f"%{name}%"))
|
||||
if code:
|
||||
conditions.append(ContractTemplate.code.ilike(f"%{code}%"))
|
||||
if category:
|
||||
conditions.append(ContractTemplate.category == category)
|
||||
if status:
|
||||
conditions.append(ContractTemplate.status == status)
|
||||
|
||||
# 获取总数
|
||||
count_stmt = select(func.count(ContractTemplate.id)).where(and_(*conditions))
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 获取列表
|
||||
offset = (page - 1) * page_size
|
||||
stmt = select(ContractTemplate).where(and_(*conditions)).order_by(
|
||||
ContractTemplate.sort, ContractTemplate.sys_create_datetime.desc()
|
||||
).offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(db: AsyncSession, template_id: str) -> Optional[ContractTemplate]:
|
||||
"""根据ID获取模板"""
|
||||
stmt = select(ContractTemplate).where(
|
||||
ContractTemplate.id == template_id,
|
||||
ContractTemplate.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_by_code(db: AsyncSession, code: str) -> Optional[ContractTemplate]:
|
||||
"""根据编码获取模板"""
|
||||
stmt = select(ContractTemplate).where(
|
||||
ContractTemplate.code == code,
|
||||
ContractTemplate.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
code: str,
|
||||
category: str = "",
|
||||
description: str = "",
|
||||
template_config: Dict = None,
|
||||
creator_id: str = None,
|
||||
) -> ContractTemplate:
|
||||
"""创建模板"""
|
||||
template = ContractTemplate(
|
||||
name=name,
|
||||
code=code,
|
||||
category=category,
|
||||
description=description,
|
||||
template_config=template_config or {},
|
||||
sys_creator_id=creator_id,
|
||||
sys_modifier_id=creator_id,
|
||||
)
|
||||
db.add(template)
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
@staticmethod
|
||||
async def update(
|
||||
db: AsyncSession,
|
||||
template: ContractTemplate,
|
||||
name: str = None,
|
||||
category: str = None,
|
||||
description: str = None,
|
||||
template_config: Dict = None,
|
||||
thumbnail: str = None,
|
||||
) -> ContractTemplate:
|
||||
"""更新模板"""
|
||||
if name is not None:
|
||||
template.name = name
|
||||
if category is not None:
|
||||
template.category = category
|
||||
if description is not None:
|
||||
template.description = description
|
||||
if template_config is not None:
|
||||
template.template_config = template_config
|
||||
if thumbnail is not None:
|
||||
template.thumbnail = thumbnail
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
@staticmethod
|
||||
async def delete(db: AsyncSession, template: ContractTemplate):
|
||||
"""删除模板(软删除)"""
|
||||
template.is_deleted = True
|
||||
await db.commit()
|
||||
|
||||
@staticmethod
|
||||
async def publish(db: AsyncSession, template: ContractTemplate) -> ContractTemplate:
|
||||
"""发布模板"""
|
||||
template.status = "published"
|
||||
template.version += 1
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
@staticmethod
|
||||
async def disable(db: AsyncSession, template: ContractTemplate) -> ContractTemplate:
|
||||
"""停用模板"""
|
||||
template.status = "disabled"
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
@staticmethod
|
||||
async def copy(
|
||||
db: AsyncSession,
|
||||
template: ContractTemplate,
|
||||
new_code: str,
|
||||
new_name: str = None,
|
||||
creator_id: str = None,
|
||||
) -> ContractTemplate:
|
||||
"""复制模板"""
|
||||
new_template = ContractTemplate(
|
||||
name=new_name or f"{template.name}_副本",
|
||||
code=new_code,
|
||||
category=template.category,
|
||||
description=template.description,
|
||||
template_config=template.template_config,
|
||||
thumbnail=template.thumbnail,
|
||||
status="draft",
|
||||
version=1,
|
||||
sys_creator_id=creator_id,
|
||||
sys_modifier_id=creator_id,
|
||||
)
|
||||
db.add(new_template)
|
||||
await db.commit()
|
||||
await db.refresh(new_template)
|
||||
return new_template
|
||||
|
||||
@staticmethod
|
||||
async def get_categories(db: AsyncSession) -> List[str]:
|
||||
"""获取所有分类"""
|
||||
stmt = select(ContractTemplate.category).where(
|
||||
ContractTemplate.is_deleted == False,
|
||||
ContractTemplate.category != ""
|
||||
).distinct()
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
class ContractInstanceService:
|
||||
"""合同实例服务"""
|
||||
|
||||
@staticmethod
|
||||
def generate_contract_no() -> str:
|
||||
"""生成合同编号"""
|
||||
now = datetime.now()
|
||||
prefix = now.strftime("JZ%Y%m%d")
|
||||
suffix = uuid.uuid4().hex[:6].upper()
|
||||
return f"{prefix}{suffix}"
|
||||
|
||||
@staticmethod
|
||||
async def check_contract_no_exists(
|
||||
db: AsyncSession,
|
||||
contract_no: str,
|
||||
exclude_id: str = None
|
||||
) -> bool:
|
||||
"""检查合同编号是否已存在"""
|
||||
conditions = [
|
||||
ContractInstance.contract_no == contract_no,
|
||||
ContractInstance.is_deleted == False
|
||||
]
|
||||
if exclude_id:
|
||||
conditions.append(ContractInstance.id != exclude_id)
|
||||
|
||||
stmt = select(func.count(ContractInstance.id)).where(and_(*conditions))
|
||||
result = await db.execute(stmt)
|
||||
count = result.scalar() or 0
|
||||
return count > 0
|
||||
|
||||
@staticmethod
|
||||
async def get_list(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
title: str = None,
|
||||
contract_no: str = None,
|
||||
status: str = None,
|
||||
template_id: str = None,
|
||||
creator_id: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取合同列表"""
|
||||
conditions = [ContractInstance.is_deleted == False]
|
||||
|
||||
if title:
|
||||
conditions.append(ContractInstance.title.ilike(f"%{title}%"))
|
||||
if contract_no:
|
||||
conditions.append(ContractInstance.contract_no.ilike(f"%{contract_no}%"))
|
||||
if status:
|
||||
conditions.append(ContractInstance.status == status)
|
||||
if template_id:
|
||||
conditions.append(ContractInstance.template_id == template_id)
|
||||
if creator_id:
|
||||
conditions.append(ContractInstance.creator_id == creator_id)
|
||||
|
||||
# 获取总数
|
||||
count_stmt = select(func.count(ContractInstance.id)).where(and_(*conditions))
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 获取列表
|
||||
offset = (page - 1) * page_size
|
||||
stmt = select(ContractInstance).where(and_(*conditions)).order_by(
|
||||
ContractInstance.sys_create_datetime.desc()
|
||||
).offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(db: AsyncSession, instance_id: str) -> Optional[ContractInstance]:
|
||||
"""根据ID获取合同"""
|
||||
stmt = select(ContractInstance).where(
|
||||
ContractInstance.id == instance_id,
|
||||
ContractInstance.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
template_id: str,
|
||||
title: str,
|
||||
creator_id: str,
|
||||
variable_data: Dict = None,
|
||||
contract_no: str = None,
|
||||
) -> ContractInstance:
|
||||
"""创建合同实例"""
|
||||
# 获取模板
|
||||
template = await ContractTemplateService.get_by_id(db, template_id)
|
||||
if not template:
|
||||
raise ValueError(f"模板不存在: {template_id}")
|
||||
|
||||
# 如果没有提供合同编号,自动生成
|
||||
if not contract_no:
|
||||
contract_no = ContractInstanceService.generate_contract_no()
|
||||
|
||||
# 检查合同编号唯一性
|
||||
if await ContractInstanceService.check_contract_no_exists(db, contract_no):
|
||||
raise ValueError(f"合同编号 {contract_no} 已存在")
|
||||
|
||||
instance = ContractInstance(
|
||||
template_id=template_id,
|
||||
contract_no=contract_no,
|
||||
title=title,
|
||||
creator_id=creator_id,
|
||||
contract_config=template.template_config,
|
||||
variable_data=variable_data or {},
|
||||
created_at=datetime.now(),
|
||||
sys_creator_id=creator_id,
|
||||
sys_modifier_id=creator_id,
|
||||
)
|
||||
db.add(instance)
|
||||
await db.flush()
|
||||
|
||||
# 记录日志
|
||||
log = ContractLog(
|
||||
contract_id=instance.id,
|
||||
action="create",
|
||||
operator_id=creator_id,
|
||||
sys_creator_id=creator_id,
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(instance)
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
async def update(
|
||||
db: AsyncSession,
|
||||
instance: ContractInstance,
|
||||
title: str = None,
|
||||
variable_data: Dict = None,
|
||||
signature_data: Dict = None,
|
||||
operator_id: str = None,
|
||||
) -> ContractInstance:
|
||||
"""更新合同"""
|
||||
if title is not None:
|
||||
instance.title = title
|
||||
if variable_data is not None:
|
||||
instance.variable_data = variable_data
|
||||
if signature_data is not None:
|
||||
instance.signature_data = signature_data
|
||||
|
||||
instance.sys_modifier_id = operator_id
|
||||
|
||||
# 记录日志
|
||||
if operator_id:
|
||||
log = ContractLog(
|
||||
contract_id=instance.id,
|
||||
action="update",
|
||||
operator_id=operator_id,
|
||||
sys_creator_id=operator_id,
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(instance)
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
async def submit(
|
||||
db: AsyncSession,
|
||||
instance: ContractInstance,
|
||||
operator_id: str
|
||||
) -> ContractInstance:
|
||||
"""提交合同(进入待签署状态)"""
|
||||
instance.status = "pending"
|
||||
instance.sys_modifier_id = operator_id
|
||||
|
||||
log = ContractLog(
|
||||
contract_id=instance.id,
|
||||
action="submit",
|
||||
operator_id=operator_id,
|
||||
sys_creator_id=operator_id,
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(instance)
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
async def complete(
|
||||
db: AsyncSession,
|
||||
instance: ContractInstance,
|
||||
operator_id: str
|
||||
) -> ContractInstance:
|
||||
"""完成合同"""
|
||||
instance.status = "completed"
|
||||
instance.completed_at = datetime.now()
|
||||
instance.sys_modifier_id = operator_id
|
||||
|
||||
log = ContractLog(
|
||||
contract_id=instance.id,
|
||||
action="complete",
|
||||
operator_id=operator_id,
|
||||
sys_creator_id=operator_id,
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(instance)
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
async def cancel(
|
||||
db: AsyncSession,
|
||||
instance: ContractInstance,
|
||||
operator_id: str,
|
||||
comment: str = ""
|
||||
) -> ContractInstance:
|
||||
"""取消合同"""
|
||||
instance.status = "canceled"
|
||||
instance.sys_modifier_id = operator_id
|
||||
|
||||
log = ContractLog(
|
||||
contract_id=instance.id,
|
||||
action="cancel",
|
||||
operator_id=operator_id,
|
||||
comment=comment,
|
||||
sys_creator_id=operator_id,
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(instance)
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
async def delete(db: AsyncSession, instance: ContractInstance):
|
||||
"""删除合同(软删除)"""
|
||||
instance.is_deleted = True
|
||||
await db.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_logs(db: AsyncSession, instance_id: str) -> List[ContractLog]:
|
||||
"""获取合同日志"""
|
||||
stmt = select(ContractLog).where(
|
||||
ContractLog.contract_id == instance_id
|
||||
).order_by(ContractLog.sys_create_datetime)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
class ContractSignatureService:
|
||||
"""合同签署服务"""
|
||||
|
||||
@staticmethod
|
||||
async def sign(
|
||||
db: AsyncSession,
|
||||
instance: ContractInstance,
|
||||
element_id: str,
|
||||
signature_image: str,
|
||||
signer_id: str,
|
||||
signer_name: str = "",
|
||||
sign_ip: str = "",
|
||||
sign_device: str = "",
|
||||
) -> ContractSignature:
|
||||
"""签署合同"""
|
||||
# 查找元素信息
|
||||
element = None
|
||||
for el in instance.contract_config.get("elements", []):
|
||||
if el.get("id") == element_id:
|
||||
element = el
|
||||
break
|
||||
|
||||
if not element:
|
||||
raise ValueError("签署区域不存在")
|
||||
|
||||
# 获取签署方信息
|
||||
signature_config = element.get("signature", {})
|
||||
party_type = signature_config.get("partyType", "party_a")
|
||||
party_label = signature_config.get("partyLabel", "")
|
||||
sign_type = "seal" if element.get("type") == "seal-zone" else "signature"
|
||||
|
||||
# 查找是否已存在签署记录
|
||||
stmt = select(ContractSignature).where(
|
||||
ContractSignature.contract_id == instance.id,
|
||||
ContractSignature.element_id == element_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# 更新现有记录
|
||||
existing.party_type = party_type
|
||||
existing.party_label = party_label
|
||||
existing.sign_type = sign_type
|
||||
existing.signer_id = signer_id
|
||||
existing.signer_name = signer_name
|
||||
existing.signature_image = signature_image
|
||||
existing.status = "signed"
|
||||
existing.signed_at = datetime.now()
|
||||
existing.sign_ip = sign_ip
|
||||
existing.sign_device = sign_device
|
||||
signature = existing
|
||||
else:
|
||||
# 创建新记录
|
||||
signature = ContractSignature(
|
||||
contract_id=instance.id,
|
||||
element_id=element_id,
|
||||
party_type=party_type,
|
||||
party_label=party_label,
|
||||
sign_type=sign_type,
|
||||
signer_id=signer_id,
|
||||
signer_name=signer_name,
|
||||
signature_image=signature_image,
|
||||
status="signed",
|
||||
signed_at=datetime.now(),
|
||||
sign_ip=sign_ip,
|
||||
sign_device=sign_device,
|
||||
sys_creator_id=signer_id,
|
||||
)
|
||||
db.add(signature)
|
||||
|
||||
# 更新合同签署数据
|
||||
signature_data = instance.signature_data or {}
|
||||
signature_data[element_id] = signature_image
|
||||
instance.signature_data = signature_data
|
||||
|
||||
# 检查是否所有必须签署区域都已签署
|
||||
all_signed = await ContractSignatureService.check_all_signed(instance)
|
||||
if all_signed and instance.status == "pending":
|
||||
instance.status = "signing"
|
||||
|
||||
# 记录日志
|
||||
log = ContractLog(
|
||||
contract_id=instance.id,
|
||||
action="sign",
|
||||
operator_id=signer_id,
|
||||
comment=f"{party_label}签署",
|
||||
extra_data={"element_id": element_id},
|
||||
ip_address=sign_ip,
|
||||
sys_creator_id=signer_id,
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(signature)
|
||||
return signature
|
||||
|
||||
@staticmethod
|
||||
async def check_all_signed(instance: ContractInstance) -> bool:
|
||||
"""检查是否所有必须签署区域都已签署"""
|
||||
elements = instance.contract_config.get("elements", [])
|
||||
signature_data = instance.signature_data or {}
|
||||
|
||||
for element in elements:
|
||||
if element.get("type") in ["signature-zone", "seal-zone"]:
|
||||
signature_config = element.get("signature", {})
|
||||
if signature_config.get("required", True):
|
||||
if element.get("id") not in signature_data:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_signatures(db: AsyncSession, instance_id: str) -> List[ContractSignature]:
|
||||
"""获取合同签署记录"""
|
||||
stmt = select(ContractSignature).where(
|
||||
ContractSignature.contract_id == instance_id
|
||||
).order_by(ContractSignature.sys_create_datetime)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
class MobileSignService:
|
||||
"""移动端签署服务"""
|
||||
|
||||
@staticmethod
|
||||
async def create_sign_token(
|
||||
db: AsyncSession,
|
||||
instance: ContractInstance,
|
||||
party_type: str = "party_a",
|
||||
signer_name: str = "",
|
||||
expire_minutes: int = 30,
|
||||
) -> ContractSignToken:
|
||||
"""创建签署令牌"""
|
||||
# 生成随机令牌
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# 计算过期时间
|
||||
expired_at = datetime.now() + timedelta(minutes=expire_minutes)
|
||||
|
||||
# 创建令牌记录
|
||||
sign_token = ContractSignToken(
|
||||
contract_id=instance.id,
|
||||
token=token,
|
||||
party_type=party_type,
|
||||
signer_name=signer_name,
|
||||
expired_at=expired_at,
|
||||
)
|
||||
db.add(sign_token)
|
||||
await db.commit()
|
||||
await db.refresh(sign_token)
|
||||
|
||||
return sign_token
|
||||
|
||||
@staticmethod
|
||||
async def get_by_token(db: AsyncSession, token: str) -> Optional[ContractSignToken]:
|
||||
"""根据令牌获取签署令牌"""
|
||||
stmt = select(ContractSignToken).where(
|
||||
ContractSignToken.token == token,
|
||||
ContractSignToken.is_used == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
def validate_token(sign_token: ContractSignToken) -> Tuple[bool, str]:
|
||||
"""验证令牌有效性"""
|
||||
if sign_token.is_used:
|
||||
return False, "该签署链接已被使用"
|
||||
|
||||
if sign_token.expired_at < datetime.now():
|
||||
return False, "该签署链接已过期"
|
||||
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
async def mobile_sign(
|
||||
db: AsyncSession,
|
||||
sign_token: ContractSignToken,
|
||||
instance: ContractInstance,
|
||||
element_id: str,
|
||||
signature_image: str,
|
||||
signer_name: str = "",
|
||||
sign_ip: str = "",
|
||||
sign_device: str = "",
|
||||
) -> ContractSignature:
|
||||
"""移动端签署"""
|
||||
# 查找元素信息
|
||||
element = None
|
||||
for el in instance.contract_config.get("elements", []):
|
||||
if el.get("id") == element_id:
|
||||
element = el
|
||||
break
|
||||
|
||||
if not element:
|
||||
raise ValueError("签署区域不存在")
|
||||
|
||||
# 获取签署方信息
|
||||
signature_config = element.get("signature", {})
|
||||
party_type = signature_config.get("partyType", "party_a")
|
||||
party_label = signature_config.get("partyLabel", "")
|
||||
sign_type = "seal" if element.get("type") == "seal-zone" else "signature"
|
||||
|
||||
# 使用令牌中的签署人姓名(如果提供)
|
||||
final_signer_name = signer_name or sign_token.signer_name
|
||||
|
||||
# 截断设备信息,避免超出数据库字段长度
|
||||
device_info = (sign_device + " (Mobile)")[:200] if sign_device else "Mobile"
|
||||
|
||||
# 查找是否已存在签署记录
|
||||
stmt = select(ContractSignature).where(
|
||||
ContractSignature.contract_id == instance.id,
|
||||
ContractSignature.element_id == element_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# 更新现有记录
|
||||
existing.party_type = party_type
|
||||
existing.party_label = party_label
|
||||
existing.sign_type = sign_type
|
||||
existing.signer_id = None
|
||||
existing.signer_name = final_signer_name
|
||||
existing.signature_image = signature_image
|
||||
existing.status = "signed"
|
||||
existing.signed_at = datetime.now()
|
||||
existing.sign_ip = sign_ip
|
||||
existing.sign_device = device_info
|
||||
signature = existing
|
||||
else:
|
||||
# 创建新记录
|
||||
signature = ContractSignature(
|
||||
contract_id=instance.id,
|
||||
element_id=element_id,
|
||||
party_type=party_type,
|
||||
party_label=party_label,
|
||||
sign_type=sign_type,
|
||||
signer_id=None,
|
||||
signer_name=final_signer_name,
|
||||
signature_image=signature_image,
|
||||
status="signed",
|
||||
signed_at=datetime.now(),
|
||||
sign_ip=sign_ip,
|
||||
sign_device=device_info,
|
||||
)
|
||||
db.add(signature)
|
||||
|
||||
# 更新合同签署数据
|
||||
signature_data = instance.signature_data or {}
|
||||
signature_data[element_id] = signature_image
|
||||
instance.signature_data = signature_data
|
||||
|
||||
# 检查是否所有必须签署区域都已签署
|
||||
all_signed = await ContractSignatureService.check_all_signed(instance)
|
||||
if all_signed and instance.status == "pending":
|
||||
instance.status = "signing"
|
||||
|
||||
# 标记令牌已使用
|
||||
sign_token.is_used = True
|
||||
sign_token.used_at = datetime.now()
|
||||
|
||||
# 记录日志
|
||||
log = ContractLog(
|
||||
contract_id=instance.id,
|
||||
action="sign",
|
||||
operator_id=instance.creator_id,
|
||||
comment=f"{party_label}通过手机签署 (签署人: {final_signer_name})",
|
||||
extra_data={"element_id": element_id, "mobile": True},
|
||||
ip_address=sign_ip,
|
||||
sys_creator_id=instance.creator_id,
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(signature)
|
||||
return signature
|
||||
Reference in New Issue
Block a user