Files
s2f/backend/app/services/data_cleaner.py
T
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

399 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
数据清洗服务
清洗和标准化工资表数据,为对账做准备
"""
import re
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from dataclasses import dataclass
@dataclass
class CleaningResult:
"""清洗结果"""
original_value: Any
cleaned_value: Any
is_valid: bool
error_message: Optional[str] = None
@dataclass
class ValidationRule:
"""验证规则"""
name: str
validate: Callable[[Any], bool]
error_message: str
class DataCleaner:
"""
数据清洗器
用于清洗和验证工资表数据
"""
def __init__(self):
self.validation_rules: Dict[str, List[ValidationRule]] = {}
self._init_default_rules()
def _init_default_rules(self):
"""初始化默认验证规则"""
# 姓名验证:2-20个中文字符或英文字母
self.add_rule(
"employee_name",
ValidationRule(
name="name_length",
validate=lambda v: bool(v) and 1 < len(str(v).strip()) <= 50,
error_message="姓名长度应在2-50个字符之间"
)
)
# 工号验证:数字或字母组合
self.add_rule(
"employee_id",
ValidationRule(
name="id_format",
validate=lambda v: bool(v) and bool(re.match(r'^[\w\-]+$', str(v))),
error_message="工号格式不正确"
)
)
# 金额验证:正数且在合理范围内
self.add_rule(
"amount",
ValidationRule(
name="amount_positive",
validate=lambda v: self._parse_number(v) is not None and self._parse_number(v) >= 0,
error_message="金额必须为非负数"
)
)
# 身份证号验证
self.add_rule(
"id_card",
ValidationRule(
name="id_card_length",
validate=lambda v: bool(v) and len(str(v).strip()) in [15, 18],
error_message="身份证号长度应为15或18位"
)
)
# 银行账号验证
self.add_rule(
"bank_card",
ValidationRule(
name="bank_card_digits",
validate=lambda v: bool(v) and str(v).isdigit() and len(str(v)) >= 10,
error_message="银行账号应为至少10位数字"
)
)
def add_rule(self, field_type: str, rule: ValidationRule):
"""添加验证规则"""
if field_type not in self.validation_rules:
self.validation_rules[field_type] = []
self.validation_rules[field_type].append(rule)
def _parse_number(self, value: Any) -> Optional[Decimal]:
"""解析数字"""
if value is None:
return None
if isinstance(value, (int, float, Decimal)):
return Decimal(str(value))
if isinstance(value, str):
# 移除常见的货币符号、逗号和空格
cleaned = value.strip()
cleaned = re.sub(r'[¥$,\s]', '', cleaned)
# 处理负数
is_negative = cleaned.startswith('-')
cleaned = cleaned.lstrip('-')
# 检查是否包含有效数字
if not cleaned or not re.match(r'^\d+(\.\d+)?$', cleaned):
return None
try:
result = Decimal(cleaned)
return -result if is_negative else result
except InvalidOperation:
return None
return None
def _parse_date(self, value: Any) -> Optional[datetime]:
"""解析日期"""
if value is None:
return None
if isinstance(value, datetime):
return value
if isinstance(value, str):
# 尝试多种日期格式
formats = [
'%Y-%m-%d',
'%Y/%m/%d',
'%Y年%m月%d',
'%Y%m%d',
'%Y-%m-%d %H:%M:%S',
]
for fmt in formats:
try:
return datetime.strptime(value.strip(), fmt)
except ValueError:
continue
return None
def clean_string(self, value: Any, strip: bool = True) -> str:
"""清洗字符串"""
if value is None:
return ""
result = str(value)
if strip:
result = result.strip()
return result
def clean_amount(self, value: Any) -> CleaningResult:
"""清洗金额字段"""
original = value
parsed = self._parse_number(value)
if parsed is None:
return CleaningResult(
original_value=original,
cleaned_value=None,
is_valid=False,
error_message="无法解析为数字"
)
return CleaningResult(
original_value=original,
cleaned_value=float(parsed),
is_valid=True
)
def clean_employee_name(self, value: Any) -> CleaningResult:
"""清洗员工姓名"""
cleaned = self.clean_string(value)
if not cleaned:
return CleaningResult(
original_value=value,
cleaned_value=None,
is_valid=False,
error_message="姓名为空"
)
# 移除多余空格
cleaned = re.sub(r'\s+', ' ', cleaned)
return CleaningResult(
original_value=value,
cleaned_value=cleaned,
is_valid=True
)
def clean_employee_id(self, value: Any) -> CleaningResult:
"""清洗工号"""
cleaned = self.clean_string(value)
if not cleaned:
return CleaningResult(
original_value=value,
cleaned_value=None,
is_valid=False,
error_message="工号为空"
)
return CleaningResult(
original_value=value,
cleaned_value=cleaned.upper(), # 统一大写
is_valid=True
)
def clean_bank_card(self, value: Any) -> CleaningResult:
"""清洗银行账号"""
original = value
cleaned = self.clean_string(value)
# 只保留数字
cleaned = re.sub(r'\D', '', cleaned)
if len(cleaned) < 10:
return CleaningResult(
original_value=original,
cleaned_value=None,
is_valid=False,
error_message="银行账号长度不足"
)
return CleaningResult(
original_value=original,
cleaned_value=cleaned,
is_valid=True
)
def clean_id_card(self, value: Any) -> CleaningResult:
"""清洗身份证号"""
original = value
cleaned = self.clean_string(value)
# 统一大写
cleaned = cleaned.upper()
# 只保留数字和X
cleaned = re.sub(r'[^0-9X]', '', cleaned)
if len(cleaned) not in [15, 18]:
return CleaningResult(
original_value=original,
cleaned_value=None,
is_valid=False,
error_message="身份证号长度应为15或18位"
)
return CleaningResult(
original_value=original,
cleaned_value=cleaned,
is_valid=True
)
def validate_field(self, field_type: str, value: Any) -> Tuple[bool, Optional[str]]:
"""
验证字段
Returns:
(是否通过, 错误信息)
"""
rules = self.validation_rules.get(field_type, [])
for rule in rules:
if not rule.validate(value):
return False, rule.error_message
return True, None
def clean_row(self, row: Dict[str, Any], field_mappings: Dict[str, str]) -> Dict[str, Any]:
"""
清洗一行数据
Args:
row: 原始行数据 {源字段名: 值}
field_mappings: 字段映射 {标准字段名: 源字段名}
Returns:
清洗后的数据 {标准字段名: 清洗后的值}
"""
cleaned = {}
errors = {}
# 反转映射:标准字段 -> 源字段
standard_to_source = {v: k for k, v in field_mappings.items()}
for standard_field, source_field in standard_to_source.items():
raw_value = row.get(source_field)
# 根据字段类型选择清洗方法
if standard_field in ["员工姓名", "姓名"]:
result = self.clean_employee_name(raw_value)
elif standard_field in ["工号", "员工号"]:
result = self.clean_employee_id(raw_value)
elif standard_field in ["银行账号", "卡号"]:
result = self.clean_bank_card(raw_value)
elif standard_field in ["身份证号", "证件号"]:
result = self.clean_id_card(raw_value)
elif standard_field in ["基本工资", "奖金", "补贴", "加班费", "扣款",
"应发工资", "实发工资", "社保基数",
"养老保险", "医疗保险", "失业保险", "公积金",
"个税", "应税收入", "税后收入"]:
result = self.clean_amount(raw_value)
else:
result = CleaningResult(
original_value=raw_value,
cleaned_value=self.clean_string(raw_value),
is_valid=bool(raw_value)
)
cleaned[standard_field] = result.cleaned_value
if not result.is_valid:
errors[standard_field] = result.error_message
return cleaned
def batch_clean(
self,
data: List[Dict[str, Any]],
field_mappings: Dict[str, str]
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
批量清洗数据
Returns:
(有效数据, 无效数据)
"""
valid_data = []
invalid_data = []
for row in data:
cleaned_row = self.clean_row(row, field_mappings)
# 检查是否有无效字段
has_invalid = any(v is None for v in cleaned_row.values())
if has_invalid:
invalid_data.append({
"original": row,
"cleaned": cleaned_row
})
else:
valid_data.append(cleaned_row)
return valid_data, invalid_data
# 单例实例
_cleaner_instance: Optional[DataCleaner] = None
def get_cleaner() -> DataCleaner:
"""获取数据清洗器单例"""
global _cleaner_instance
if _cleaner_instance is None:
_cleaner_instance = DataCleaner()
return _cleaner_instance
async def clean_salary_data(
data: List[Dict[str, Any]],
field_mappings: Dict[str, str]
) -> Dict[str, Any]:
"""
清洗工资数据
Args:
data: 原始工资数据
field_mappings: 字段映射
Returns:
清洗结果
"""
cleaner = get_cleaner()
valid_data, invalid_data = cleaner.batch_clean(data, field_mappings)
return {
"total_count": len(data),
"valid_count": len(valid_data),
"invalid_count": len(invalid_data),
"valid_data": valid_data,
"invalid_data": invalid_data,
"cleaning_time": datetime.utcnow().isoformat(),
}