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