#!/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))