Files
freedakgmail 33b4c734aa feat: 完善前后端核心功能模块
后端:
- 新增认证(auth)、任务(tasks)、映射(mappings)、对账(reconciliation)、异常(exceptions)、导出(exports) API
- 新增核心模块: database, security, permissions, tenant, exceptions, error_handlers
- 新增数据模型: user, company, reconciliation_task, field_mapping, uploaded_file 等
- 新增服务层: ai_recognizer, file_parser, file_storage, mapping, reconciliation 等
- 添加数据库迁移脚本

前端:
- 新增登录页面和仪表盘页面
- 新增任务列表、任务详情、字段映射页面
- 新增异常处理页面和规则设置页面
- 新增 API 代理路由 /api/[...path]
- 新增 UI 组件库 (button, card, dialog, input, table 等)
- 新增 auth 组件 (ProtectedRoute, PermissionGate)
- 新增 layout 组件 (Header, Sidebar)
- 新增 mapping 组件 (FieldMappingTable, AISuggestionPanel)
- 新增 API 客户端和 hooks (useAsync, useToast, usePermission 等)
- 新增状态管理 (auth-store, company-store, ui-store)
- 集成 Tailwind CSS 和 shadcn/ui 组件库

其他:
- 添加 Alembic 数据库迁移配置
- 添加初始化示例数据脚本
- 更新项目文档
2026-07-07 09:04:47 +08:00

459 lines
15 KiB
Python

"""
对账规则定义
实现 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(),
]