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 数据库迁移配置 - 添加初始化示例数据脚本 - 更新项目文档
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
对账引擎
|
||||
|
||||
执行对账逻辑,协调各个规则
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.exception_item import ExceptionItem, ExceptionStatus
|
||||
from app.services.reconciliation.rules import (
|
||||
RuleContext,
|
||||
RuleResult,
|
||||
RuleFactory,
|
||||
ReconciliationRule,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReconciliationResult:
|
||||
"""对账结果"""
|
||||
task_id: int
|
||||
period: str
|
||||
total_employees: int
|
||||
matched_employees: int
|
||||
exception_count: int
|
||||
|
||||
# 统计
|
||||
exceptions_by_type: Dict[str, int] = field(default_factory=dict)
|
||||
exceptions_by_severity: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
# 详细结果
|
||||
matched_employees_list: List[Dict[str, Any]] = field(default_factory=list)
|
||||
exception_items: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# 性能指标
|
||||
execution_time_ms: float = 0
|
||||
rules_executed: int = 0
|
||||
|
||||
# 时间戳
|
||||
completed_at: str = ""
|
||||
|
||||
|
||||
class ReconciliationEngine:
|
||||
"""
|
||||
对账引擎
|
||||
|
||||
协调数据加载、规则执行、结果存储
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
company_id: int,
|
||||
task_id: int,
|
||||
period: str,
|
||||
enable_bank_rules: bool = False,
|
||||
):
|
||||
self.db = db
|
||||
self.company_id = company_id
|
||||
self.task_id = task_id
|
||||
self.period = period
|
||||
self.enable_bank_rules = enable_bank_rules
|
||||
|
||||
# 创建规则
|
||||
self.rules = RuleFactory.create_all_rules(enable_bank_rules)
|
||||
|
||||
# 数据存储
|
||||
self.salary_data: Dict[str, Dict[str, Any]] = {}
|
||||
self.social_security_data: Dict[str, Dict[str, Any]] = {}
|
||||
self.tax_data: Dict[str, Dict[str, Any]] = {}
|
||||
self.bank_data: Optional[Dict[str, Dict[str, Any]]] = None
|
||||
|
||||
# 结果
|
||||
self.exceptions: List[ExceptionItem] = []
|
||||
self.matched_count = 0
|
||||
self.exception_count = 0
|
||||
|
||||
def load_data(
|
||||
self,
|
||||
salary_records: List[Dict[str, Any]],
|
||||
social_security_records: List[Dict[str, Any]],
|
||||
tax_records: List[Dict[str, Any]],
|
||||
bank_records: Optional[List[Dict[str, Any]]] = None,
|
||||
employee_id_field: str = "employee_id",
|
||||
employee_name_field: str = "employee_name",
|
||||
):
|
||||
"""
|
||||
加载数据
|
||||
|
||||
Args:
|
||||
salary_records: 工资表数据
|
||||
social_security_records: 社保表数据
|
||||
tax_records: 个税表数据
|
||||
bank_records: 银行数据(可选)
|
||||
employee_id_field: 员工ID字段名
|
||||
employee_name_field: 员工姓名字段名
|
||||
"""
|
||||
# 加载工资数据
|
||||
for record in salary_records:
|
||||
emp_id = str(record.get(employee_id_field, ""))
|
||||
if emp_id:
|
||||
self.salary_data[emp_id] = record
|
||||
|
||||
# 加载社保数据
|
||||
for record in social_security_records:
|
||||
emp_id = str(record.get(employee_id_field, ""))
|
||||
if emp_id:
|
||||
self.social_security_data[emp_id] = record
|
||||
|
||||
# 加载个税数据
|
||||
for record in tax_records:
|
||||
emp_id = str(record.get(employee_id_field, ""))
|
||||
if emp_id:
|
||||
self.tax_data[emp_id] = record
|
||||
|
||||
# 加载银行数据
|
||||
if bank_records:
|
||||
self.bank_data = {}
|
||||
for record in bank_records:
|
||||
emp_id = str(record.get(employee_id_field, ""))
|
||||
if emp_id:
|
||||
self.bank_data[emp_id] = record
|
||||
|
||||
async def execute(self) -> ReconciliationResult:
|
||||
"""
|
||||
执行对账
|
||||
|
||||
Returns:
|
||||
对账结果
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# 获取所有员工ID(合并三个表)
|
||||
all_employee_ids = set()
|
||||
all_employee_ids.update(self.salary_data.keys())
|
||||
all_employee_ids.update(self.social_security_data.keys())
|
||||
all_employee_ids.update(self.tax_data.keys())
|
||||
if self.bank_data:
|
||||
all_employee_ids.update(self.bank_data.keys())
|
||||
|
||||
total_employees = len(all_employee_ids)
|
||||
matched_employees = 0
|
||||
exception_items: List[Dict[str, Any]] = []
|
||||
|
||||
# 统计
|
||||
exceptions_by_type: Dict[str, int] = {}
|
||||
exceptions_by_severity: Dict[str, int] = {}
|
||||
matched_list: List[Dict[str, Any]] = []
|
||||
|
||||
# 重置需要状态的规则
|
||||
for rule in self.rules:
|
||||
if hasattr(rule, 'reset'):
|
||||
rule.reset()
|
||||
|
||||
# 遍历每个员工
|
||||
for employee_id in all_employee_ids:
|
||||
# 获取员工姓名
|
||||
employee_name = (
|
||||
self.salary_data.get(employee_id, {}).get("employee_name") or
|
||||
self.social_security_data.get(employee_id, {}).get("employee_name") or
|
||||
self.tax_data.get(employee_id, {}).get("employee_name") or
|
||||
employee_id
|
||||
)
|
||||
|
||||
# 构建规则上下文
|
||||
context = RuleContext(
|
||||
company_id=self.company_id,
|
||||
task_id=self.task_id,
|
||||
period=self.period,
|
||||
employee_id=employee_id,
|
||||
employee_name=employee_name,
|
||||
salary_data=self.salary_data,
|
||||
social_security_data=self.social_security_data,
|
||||
tax_data=self.tax_data,
|
||||
bank_data=self.bank_data,
|
||||
)
|
||||
|
||||
# 执行所有规则
|
||||
employee_exceptions = []
|
||||
for rule in self.rules:
|
||||
if not rule.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
result = rule.check(context)
|
||||
if result.is_exception:
|
||||
employee_exceptions.append((rule, result))
|
||||
except Exception as e:
|
||||
# 规则执行出错,记录但不中断
|
||||
pass
|
||||
|
||||
# 处理该员工的异常
|
||||
if employee_exceptions:
|
||||
for rule, result in employee_exceptions:
|
||||
# 保存异常到数据库
|
||||
exception_item = await self._save_exception(rule, result, context)
|
||||
self.exceptions.append(exception_item)
|
||||
|
||||
exception_items.append({
|
||||
"id": exception_item.id,
|
||||
"type": result.exception_type.value if result.exception_type else rule.exception_type.value,
|
||||
"severity": result.severity.value,
|
||||
"description": result.description,
|
||||
"employee_id": employee_id,
|
||||
"employee_name": employee_name,
|
||||
})
|
||||
|
||||
# 统计
|
||||
type_key = result.exception_type.value if result.exception_type else rule.exception_type.value
|
||||
exceptions_by_type[type_key] = exceptions_by_type.get(type_key, 0) + 1
|
||||
exceptions_by_severity[result.severity.value] = (
|
||||
exceptions_by_severity.get(result.severity.value, 0) + 1
|
||||
)
|
||||
else:
|
||||
matched_employees += 1
|
||||
matched_list.append({
|
||||
"employee_id": employee_id,
|
||||
"employee_name": employee_name,
|
||||
})
|
||||
|
||||
# 更新任务统计
|
||||
await self._update_task_stats(matched_employees, len(exception_items))
|
||||
|
||||
execution_time = (time.time() - start_time) * 1000
|
||||
|
||||
return ReconciliationResult(
|
||||
task_id=self.task_id,
|
||||
period=self.period,
|
||||
total_employees=total_employees,
|
||||
matched_employees=matched_employees,
|
||||
exception_count=len(exception_items),
|
||||
exceptions_by_type=exceptions_by_type,
|
||||
exceptions_by_severity=exceptions_by_severity,
|
||||
matched_employees_list=matched_list,
|
||||
exception_items=exception_items,
|
||||
execution_time_ms=execution_time,
|
||||
rules_executed=len([r for r in self.rules if r.enabled]),
|
||||
completed_at=datetime.utcnow().isoformat(),
|
||||
)
|
||||
|
||||
async def _save_exception(
|
||||
self,
|
||||
rule: ReconciliationRule,
|
||||
result: RuleResult,
|
||||
context: RuleContext,
|
||||
) -> ExceptionItem:
|
||||
"""保存异常到数据库"""
|
||||
exception_item = ExceptionItem(
|
||||
company_id=self.company_id,
|
||||
task_id=self.task_id,
|
||||
exception_type=result.exception_type.value if result.exception_type else rule.exception_type.value,
|
||||
severity=result.severity.value,
|
||||
status=ExceptionStatus.PENDING.value,
|
||||
employee_id=context.employee_id,
|
||||
employee_name=context.employee_name,
|
||||
description=result.description,
|
||||
detail=result.detail,
|
||||
salary_amount=result.salary_amount,
|
||||
social_security_amount=result.social_security_amount,
|
||||
tax_amount=result.tax_amount,
|
||||
bank_amount=result.bank_amount,
|
||||
difference_amount=result.difference_amount,
|
||||
suggested_action=result.suggested_action,
|
||||
)
|
||||
|
||||
self.db.add(exception_item)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(exception_item)
|
||||
|
||||
return exception_item
|
||||
|
||||
async def _update_task_stats(self, matched: int, exceptions: int):
|
||||
"""更新任务统计"""
|
||||
from app.models.reconciliation_task import ReconciliationTask
|
||||
|
||||
task = await self.db.get(ReconciliationTask, self.task_id)
|
||||
if task:
|
||||
task.matched_count = matched
|
||||
task.exception_count = exceptions
|
||||
task.total_employees = matched + exceptions
|
||||
task.status = "COMPLETED"
|
||||
await self.db.commit()
|
||||
|
||||
|
||||
# 便捷函数
|
||||
async def run_reconciliation(
|
||||
db: AsyncSession,
|
||||
company_id: int,
|
||||
task_id: int,
|
||||
period: str,
|
||||
salary_records: List[Dict[str, Any]],
|
||||
social_security_records: List[Dict[str, Any]],
|
||||
tax_records: List[Dict[str, Any]],
|
||||
bank_records: Optional[List[Dict[str, Any]]] = None,
|
||||
enable_bank_rules: bool = False,
|
||||
) -> ReconciliationResult:
|
||||
"""
|
||||
运行对账
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
company_id: 企业ID
|
||||
task_id: 任务ID
|
||||
period: 对账期间
|
||||
salary_records: 工资表数据
|
||||
social_security_records: 社保表数据
|
||||
tax_records: 个税表数据
|
||||
bank_records: 银行数据(可选)
|
||||
enable_bank_rules: 是否启用银行规则
|
||||
|
||||
Returns:
|
||||
对账结果
|
||||
"""
|
||||
engine = ReconciliationEngine(
|
||||
db=db,
|
||||
company_id=company_id,
|
||||
task_id=task_id,
|
||||
period=period,
|
||||
enable_bank_rules=enable_bank_rules,
|
||||
)
|
||||
|
||||
engine.load_data(
|
||||
salary_records=salary_records,
|
||||
social_security_records=social_security_records,
|
||||
tax_records=tax_records,
|
||||
bank_records=bank_records,
|
||||
)
|
||||
|
||||
return await engine.execute()
|
||||
@@ -0,0 +1,459 @@
|
||||
"""
|
||||
对账规则定义
|
||||
|
||||
实现 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(),
|
||||
]
|
||||
Reference in New Issue
Block a user