33b4c734aa
后端: - 新增认证(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 数据库迁移配置 - 添加初始化示例数据脚本 - 更新项目文档
225 lines
9.1 KiB
Python
225 lines
9.1 KiB
Python
"""
|
|
AI 字段识别服务
|
|
|
|
使用 AI 自动识别 Excel 表头对应的标准字段
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from typing import List, Dict, Optional, Tuple
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
|
|
from app.models.standard_field import StandardField, FIELD_TYPES
|
|
|
|
|
|
class AIFieldRecognizer:
|
|
"""AI 字段识别器"""
|
|
|
|
# 字段关键词映射(用于规则匹配兜底)
|
|
FIELD_KEYWORDS = {
|
|
StandardField.EMPLOYEE_NAME.value: ["姓名", "名字", "员工名", "name", "员工姓名"],
|
|
StandardField.EMPLOYEE_ID.value: ["工号", "员工号", "编号", "id", "员工编号"],
|
|
StandardField.DEPARTMENT.value: ["部门", "科室", "事业部", "department", "所属部门"],
|
|
StandardField.POSITION.value: ["岗位", "职位", "职务", "position", "job"],
|
|
StandardField.BASE_SALARY.value: ["基本工资", "岗位工资", "底薪", "base", "基本薪资"],
|
|
StandardField.BONUS.value: ["奖金", "绩效", "bonus", "绩效工资", "奖励"],
|
|
StandardField.ALLOWANCE.value: ["补贴", "津贴", "allowance", "餐补", "交通补贴"],
|
|
StandardField.OVERTIME_PAY.value: ["加班费", "加班工资", "overtime"],
|
|
StandardField.DEDUCTION.value: ["扣款", "扣除", "deduction", "罚款", "迟到扣款"],
|
|
StandardField.GROSS_SALARY.value: ["应发工资", "应发", "税前工资", "gross", "工资总额"],
|
|
StandardField.NET_SALARY.value: ["实发工资", "实发", "净工资", "net", "实发金额", "银行实发"],
|
|
StandardField.BANK_CARD.value: ["银行账号", "卡号", "账号", "bank", "银行卡"],
|
|
StandardField.ID_CARD.value: ["身份证", "证件号", "id_card", "身份证号"],
|
|
StandardField.SOCIAL_SECURITY_BASE.value: ["社保基数", "缴费基数", "基数"],
|
|
StandardField.PENSION_INSURANCE.value: ["养老保险", "养老", "pension", "养保"],
|
|
StandardField.MEDICAL_INSURANCE.value: ["医疗保险", "医保", "medical"],
|
|
StandardField.UNEMPLOYMENT_INSURANCE.value: ["失业保险", "失业", "unemployment"],
|
|
StandardField.HOUSING_FUND.value: ["公积金", "住房基金", "housing", "住房公金"],
|
|
StandardField.PENSION_INSURANCE_COMPANY.value: ["养老保险(公司)", "养老保险公司", "养保公司"],
|
|
StandardField.MEDICAL_INSURANCE_COMPANY.value: ["医疗保险(公司)", "医疗公司"],
|
|
StandardField.UNEMPLOYMENT_INSURANCE_COMPANY.value: ["失业保险(公司)", "失业公司"],
|
|
StandardField.HOUSING_FUND_COMPANY.value: ["公积金(公司)", "公积金公司"],
|
|
StandardField.SOCIAL_SECURITY_TOTAL.value: ["社保合计", "社保总计", "社保总额"],
|
|
StandardField.TAXABLE_INCOME.value: ["应税收入", "应税工资", "税前收入", "taxable"],
|
|
StandardField.PRE_TAX_DEDUCTION.value: ["税前扣除", "三险一金", "个人缴费"],
|
|
StandardField.TAX_FREE_INCOME.value: ["免税收入", "免税", "tax_free"],
|
|
StandardField.TAX_EXEMPT_INCOME.value: ["税前减免", "减免"],
|
|
StandardField.QUICK_DEDUCTION.value: ["速算扣除", "速算"],
|
|
StandardField.TAX_AMOUNT.value: ["应缴个税", "个人所得税", "个税", "tax"],
|
|
StandardField.TAX_PAID.value: ["已缴个税", "已扣税", "已缴税"],
|
|
StandardField.AFTER_TAX_INCOME.value: ["税后收入", "税后工资", "after_tax"],
|
|
}
|
|
|
|
def __init__(self, db: AsyncSession, company_id: int):
|
|
self.db = db
|
|
self.company_id = company_id
|
|
|
|
async def recognize_fields(
|
|
self,
|
|
headers: List[str],
|
|
sample_data: List[Dict],
|
|
file_type: str = "工资表"
|
|
) -> List[Dict]:
|
|
"""
|
|
识别字段映射
|
|
|
|
Args:
|
|
headers: 表头列表
|
|
sample_data: 样例数据(前10行)
|
|
file_type: 文件类型
|
|
|
|
Returns:
|
|
字段映射列表
|
|
"""
|
|
mappings = []
|
|
|
|
for header in headers:
|
|
# 获取该列的样例值
|
|
samples = [row.get(header) for row in sample_data if row.get(header)]
|
|
sample_values = samples[:5] if samples else []
|
|
|
|
# 先尝试规则匹配
|
|
matched_field, confidence, reasoning = self._rule_match(header, sample_values)
|
|
|
|
if matched_field:
|
|
mappings.append({
|
|
"source_field": header,
|
|
"standard_field": matched_field,
|
|
"confidence": confidence,
|
|
"reasoning": reasoning,
|
|
"sample_values": sample_values,
|
|
})
|
|
else:
|
|
# 无法匹配
|
|
mappings.append({
|
|
"source_field": header,
|
|
"standard_field": "",
|
|
"confidence": 0.0,
|
|
"reasoning": "无法识别字段类型",
|
|
"sample_values": sample_values,
|
|
})
|
|
|
|
return mappings
|
|
|
|
def _rule_match(
|
|
self,
|
|
header: str,
|
|
sample_values: List
|
|
) -> Tuple[Optional[str], float, str]:
|
|
"""
|
|
规则匹配
|
|
|
|
Args:
|
|
header: 字段名
|
|
sample_values: 样例值
|
|
|
|
Returns:
|
|
(标准字段, 置信度, 判断依据)
|
|
"""
|
|
header_lower = header.lower().strip()
|
|
header_normalized = header.strip()
|
|
|
|
best_match = None
|
|
best_confidence = 0.0
|
|
best_reasoning = ""
|
|
|
|
for standard_field, keywords in self.FIELD_KEYWORDS.items():
|
|
for keyword in keywords:
|
|
keyword_lower = keyword.lower()
|
|
|
|
# 精确匹配(完全相同)
|
|
if header_normalized == keyword or header_lower == keyword_lower:
|
|
return (
|
|
standard_field,
|
|
1.0,
|
|
f"字段名完全匹配:'{keyword}'"
|
|
)
|
|
|
|
# 包含匹配
|
|
if keyword_lower in header_lower or header_lower in keyword_lower:
|
|
confidence = 0.9
|
|
reasoning = f"字段名包含关键词:'{keyword}'"
|
|
|
|
# 数值字段检查样例值
|
|
field_type = FIELD_TYPES.get(standard_field, "string")
|
|
if field_type == "number" and sample_values:
|
|
if self._validate_numeric_samples(sample_values):
|
|
confidence = 0.95
|
|
reasoning += ",样例值验证为数值类型"
|
|
|
|
if confidence > best_confidence:
|
|
best_match = standard_field
|
|
best_confidence = confidence
|
|
best_reasoning = reasoning
|
|
|
|
# 模糊匹配(编辑距离)
|
|
distance = self._levenshtein_distance(header_lower, keyword_lower)
|
|
max_len = max(len(header_lower), len(keyword_lower))
|
|
similarity = 1 - (distance / max_len) if max_len > 0 else 0
|
|
|
|
if similarity > 0.7 and similarity > best_confidence:
|
|
best_match = standard_field
|
|
best_confidence = similarity * 0.8 # 模糊匹配降权
|
|
best_reasoning = f"字段名相似度:{similarity:.0%},参考词:'{keyword}'"
|
|
|
|
return best_match, best_confidence, best_reasoning
|
|
|
|
def _validate_numeric_samples(self, samples: List) -> bool:
|
|
"""验证样例值是否为数值"""
|
|
numeric_count = 0
|
|
for sample in samples[:5]:
|
|
if sample is None:
|
|
continue
|
|
sample_str = str(sample).strip()
|
|
# 移除常见的货币符号和逗号
|
|
sample_str = sample_str.replace("¥", "").replace(",", "").replace("元", "")
|
|
try:
|
|
float(sample_str)
|
|
numeric_count += 1
|
|
except ValueError:
|
|
pass
|
|
return numeric_count >= len(samples) * 0.8
|
|
|
|
@staticmethod
|
|
def _levenshtein_distance(s1: str, s2: str) -> int:
|
|
"""计算编辑距离"""
|
|
if len(s1) < len(s2):
|
|
return AIFieldRecognizer._levenshtein_distance(s2, s1)
|
|
|
|
if len(s2) == 0:
|
|
return len(s1)
|
|
|
|
previous_row = range(len(s2) + 1)
|
|
for i, c1 in enumerate(s1):
|
|
current_row = [i + 1]
|
|
for j, c2 in enumerate(s2):
|
|
insertions = previous_row[j + 1] + 1
|
|
deletions = current_row[j] + 1
|
|
substitutions = previous_row[j] + (c1 != c2)
|
|
current_row.append(min(insertions, deletions, substitutions))
|
|
previous_row = current_row
|
|
|
|
return previous_row[-1]
|
|
|
|
|
|
async def recognize_fields_with_ai(
|
|
db: AsyncSession,
|
|
company_id: int,
|
|
headers: List[str],
|
|
sample_data: List[Dict],
|
|
file_type: str = "工资表"
|
|
) -> List[Dict]:
|
|
"""
|
|
使用 AI 识别字段(带重试的版本)
|
|
|
|
优先使用 OpenAI API,失败时降级到规则匹配
|
|
"""
|
|
recognizer = AIFieldRecognizer(db, company_id)
|
|
|
|
# 优先使用规则匹配(当前实现)
|
|
# TODO: 后续集成 OpenAI API
|
|
mappings = await recognizer.recognize_fields(headers, sample_data, file_type)
|
|
|
|
return mappings |