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:
freedakgmail
2026-07-07 09:04:47 +08:00
parent 8487f6eadf
commit 33b4c734aa
117 changed files with 22070 additions and 295 deletions
@@ -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(),
]