""" 对账规则定义 实现 7 类异常检测规则(第一阶段)+ 预留银行实发对账规则(第二阶段) """ from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime from typing import Any, Callable, Dict, List, Optional, Tuple from app.models.exception_item import ExceptionType, ExceptionSeverity @dataclass class RuleContext: """规则上下文""" company_id: int task_id: int period: str employee_id: str employee_name: str salary_data: Dict[str, Any] social_security_data: Dict[str, Any] tax_data: Dict[str, Any] bank_data: Optional[Dict[str, Any]] = None @dataclass class RuleResult: """规则检测结果""" is_exception: bool exception_type: Optional[ExceptionType] = None severity: ExceptionSeverity = ExceptionSeverity.MEDIUM description: str = "" detail: Dict[str, Any] = field(default_factory=dict) salary_amount: Optional[float] = None social_security_amount: Optional[float] = None tax_amount: Optional[float] = None bank_amount: Optional[float] = None difference_amount: Optional[float] = None suggested_action: str = "" class ReconciliationRule(ABC): """对账规则基类""" def __init__(self, enabled: bool = True): self.enabled = enabled @property @abstractmethod def name(self) -> str: """规则名称""" pass @property @abstractmethod def exception_type(self) -> ExceptionType: """异常类型""" pass @property def severity(self) -> ExceptionSeverity: """默认严重程度""" return ExceptionSeverity.MEDIUM @abstractmethod def check(self, context: RuleContext) -> RuleResult: """执行规则检查""" pass class MissingEmployeeRule(ReconciliationRule): """员工缺失规则""" @property def name(self) -> str: return "员工缺失检测" @property def exception_type(self) -> ExceptionType: return ExceptionType.MISSING_EMPLOYEE def check(self, context: RuleContext) -> RuleResult: """检测员工是否在所有相关表中都存在""" has_salary = bool(context.employee_id in context.salary_data) has_social = bool(context.employee_id in context.social_security_data) has_tax = bool(context.employee_id in context.tax_data) # 至少需要在两个表中出现 present_count = sum([has_salary, has_social, has_tax]) if present_count == 0: return RuleResult( is_exception=False, # 不在任何一个表中,可能是新增员工 description="员工不在任何表中" ) if present_count < 3: missing_tables = [] if not has_salary: missing_tables.append("工资表") if not has_social: missing_tables.append("社保表") if not has_tax: missing_tables.append("个税表") return RuleResult( is_exception=True, exception_type=self.exception_type, severity=ExceptionSeverity.HIGH, description=f"员工在 {', '.join(missing_tables)} 中缺失", detail={ "has_salary": has_salary, "has_social_security": has_social, "has_tax": has_tax, }, suggested_action="确认该员工是否应该存在于此期间" ) return RuleResult(is_exception=False) class AmountMismatchRule(ReconciliationRule): """金额不匹配规则""" @property def name(self) -> str: return "金额不匹配检测" @property def exception_type(self) -> ExceptionType: return ExceptionType.AMOUNT_MISMATCH def check(self, context: RuleContext) -> RuleResult: """检测工资、社保、个税之间的金额一致性""" salary_record = context.salary_data.get(context.employee_id, {}) social_record = context.social_security_data.get(context.employee_id, {}) tax_record = context.tax_data.get(context.employee_id, {}) salary_net = salary_record.get("实发工资", 0) or salary_record.get("net_salary", 0) tax_amount = tax_record.get("应缴个税", 0) or tax_record.get("tax", 0) social_total = self._calc_social_total(social_record) # 应发工资 - 个税 - 社保 = 实发工资(理论上) salary_gross = salary_record.get("应发工资", 0) or salary_record.get("gross_salary", 0) expected_net = salary_gross - tax_amount - social_total # 允许一定误差(0.01元) tolerance = 0.01 diff = abs(salary_net - expected_net) if diff > tolerance: return RuleResult( is_exception=True, exception_type=self.exception_type, severity=ExceptionSeverity.HIGH, description=f"实发工资与计算值不一致(差异: {diff:.2f}元)", detail={ "salary_net": salary_net, "expected_net": expected_net, "salary_gross": salary_gross, "tax_amount": tax_amount, "social_total": social_total, }, difference_amount=diff, salary_amount=salary_net, tax_amount=tax_amount, social_security_amount=social_total, suggested_action="检查工资表、社保表、个税表数据是否正确" ) return RuleResult(is_exception=False) def _calc_social_total(self, record: Dict) -> float: """计算社保合计""" keys = ["养老保险", "医疗保险", "失业保险", "公积金", "社保合计"] for key in keys: if key in record: return float(record[key] or 0) # 如果没有合计,手动计算 total = 0 for key in ["养老保险", "医疗保险", "失业保险", "公积金"]: total += float(record.get(key, 0) or 0) return total class ZeroAmountRule(ReconciliationRule): """零金额规则""" @property def name(self) -> str: return "零金额检测" @property def exception_type(self) -> ExceptionType: return ExceptionType.ZERO_AMOUNT def check(self, context: RuleContext) -> RuleResult: """检测实发工资为零的情况""" salary_record = context.salary_data.get(context.employee_id, {}) net_salary = salary_record.get("实发工资", 0) or salary_record.get("net_salary", 0) if net_salary == 0: return RuleResult( is_exception=True, exception_type=self.exception_type, severity=ExceptionSeverity.MEDIUM, description="实发工资为零", detail={"net_salary": 0}, salary_amount=0, suggested_action="确认员工是否离职或暂停发放" ) return RuleResult(is_exception=False) class NegativeAmountRule(ReconciliationRule): """负数金额规则""" @property def name(self) -> str: return "负数金额检测" @property def exception_type(self) -> ExceptionType: return ExceptionType.NEGATIVE_AMOUNT def check(self, context: RuleContext) -> RuleResult: """检测负数金额""" salary_record = context.salary_data.get(context.employee_id, {}) negative_fields = [] for field_name in ["实发工资", "应发工资", "基本工资", "奖金", "补贴"]: value = salary_record.get(field_name, 0) or 0 if value < 0: negative_fields.append(f"{field_name}: {value}") if negative_fields: return RuleResult( is_exception=True, exception_type=self.exception_type, severity=ExceptionSeverity.CRITICAL, description=f"发现负数金额: {', '.join(negative_fields)}", detail={"negative_fields": negative_fields}, suggested_action="检查数据录入是否有误" ) return RuleResult(is_exception=False) class UnusualAmountRule(ReconciliationRule): """金额异常规则""" def __init__(self, max_salary: float = 1000000, min_salary: float = 0): super().__init__() self.max_salary = max_salary self.min_salary = min_salary @property def name(self) -> str: return "金额异常检测" @property def exception_type(self) -> ExceptionType: return ExceptionType.UNUSUAL_AMOUNT def check(self, context: RuleContext) -> RuleResult: """检测金额异常(过大或过小)""" salary_record = context.salary_data.get(context.employee_id, {}) net_salary = salary_record.get("实发工资", 0) or salary_record.get("net_salary", 0) if net_salary > self.max_salary: return RuleResult( is_exception=True, exception_type=self.exception_type, severity=ExceptionSeverity.HIGH, description=f"实发工资金额异常偏高: {net_salary:.2f}元", detail={"net_salary": net_salary, "max_allowed": self.max_salary}, salary_amount=net_salary, suggested_action="确认是否为高管或特殊人员" ) if 0 < net_salary < self.min_salary: return RuleResult( is_exception=True, exception_type=self.exception_type, severity=ExceptionSeverity.LOW, description=f"实发工资金额异常偏低: {net_salary:.2f}元", detail={"net_salary": net_salary, "min_expected": self.min_salary}, salary_amount=net_salary, suggested_action="确认是否为基础工资人员" ) return RuleResult(is_exception=False) class DuplicateEmployeeRule(ReconciliationRule): """重复员工规则""" def __init__(self): super().__init__() self._seen_employees: Dict[str, List[str]] = {} # employee_id -> [task_ids] def reset(self): """重置状态""" self._seen_employees = {} @property def name(self) -> str: return "重复员工检测" @property def exception_type(self) -> ExceptionType: return ExceptionType.DUPLICATE_EMPLOYEE def check(self, context: RuleContext) -> RuleResult: """检测同一任务中是否有重复员工""" employee_key = f"{context.task_id}:{context.employee_id}" if employee_key in self._seen_employees: return RuleResult( is_exception=True, exception_type=self.exception_type, severity=ExceptionSeverity.CRITICAL, description=f"员工 {context.employee_name} (ID: {context.employee_id}) 在本任务中重复出现", detail={"occurrences": len(self._seen_employees[employee_key]) + 1}, suggested_action="检查并删除重复记录" ) self._seen_employees[employee_key] = [context.task_id] return RuleResult(is_exception=False) # 第二阶段预留规则 class BankMismatchRule(ReconciliationRule): """银行实发不匹配规则""" def __init__(self, enabled: bool = False): super().__init__(enabled=enabled) @property def name(self) -> str: return "银行实发不匹配检测" @property def exception_type(self) -> ExceptionType: return ExceptionType.BANK_MISMATCH def check(self, context: RuleContext) -> RuleResult: """检测银行实发与工资表实发是否一致""" if not context.bank_data or context.employee_id not in context.bank_data: return RuleResult( is_exception=False, description="无银行数据或员工不在银行记录中" ) salary_record = context.salary_data.get(context.employee_id, {}) bank_record = context.bank_data.get(context.employee_id, {}) salary_net = salary_record.get("实发工资", 0) or salary_record.get("net_salary", 0) bank_net = bank_record.get("实发金额", 0) or bank_record.get("bank_amount", 0) diff = abs(salary_net - bank_net) tolerance = 0.01 if diff > tolerance: return RuleResult( is_exception=True, exception_type=self.exception_type, severity=ExceptionSeverity.HIGH, description=f"银行实发与工资表实发不一致(差异: {diff:.2f}元)", detail={ "salary_net": salary_net, "bank_net": bank_net, }, salary_amount=salary_net, bank_amount=bank_net, difference_amount=diff, suggested_action="联系银行确认交易状态" ) return RuleResult(is_exception=False) class BankMissingRule(ReconciliationRule): """银行记录缺失规则""" def __init__(self, enabled: bool = False): super().__init__(enabled=enabled) @property def name(self) -> str: return "银行记录缺失检测" @property def exception_type(self) -> ExceptionType: return ExceptionType.BANK_MISSING def check(self, context: RuleContext) -> RuleResult: """检测有工资但无银行记录的员工""" if not context.bank_data: return RuleResult( is_exception=False, description="无银行数据" ) has_salary = context.employee_id in context.salary_data has_bank = context.employee_id in context.bank_data if has_salary and not has_bank: return RuleResult( is_exception=True, exception_type=self.exception_type, severity=ExceptionSeverity.HIGH, description="工资表有记录但银行无记录", detail={"has_salary": True, "has_bank": False}, suggested_action="确认是否已发放或银行信息有误" ) return RuleResult(is_exception=False) # 规则工厂 class RuleFactory: """规则工厂""" @staticmethod def create_all_rules(enable_bank_rules: bool = False) -> List[ReconciliationRule]: """创建所有规则""" rules = [ DuplicateEmployeeRule(), # 需要先检测重复 ZeroAmountRule(), NegativeAmountRule(), MissingEmployeeRule(), AmountMismatchRule(), UnusualAmountRule(), ] if enable_bank_rules: rules.extend([ BankMismatchRule(enabled=True), BankMissingRule(enabled=True), ]) return rules @staticmethod def create_mvp_rules() -> List[ReconciliationRule]: """创建 MVP 阶段的规则(第一阶段 7 类异常检测)""" return [ DuplicateEmployeeRule(), ZeroAmountRule(), NegativeAmountRule(), MissingEmployeeRule(), AmountMismatchRule(), UnusualAmountRule(), ]