feat: 增强员工管理与报销单全流程功能

- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点
- 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力
- 报销单提交补充身份回填、部门信息透传及预审结果展示优化
- 认证流程增加部门信息(departmentName)并在schema中同步扩展
- 用户Agent服务增加部门关联与报销单回填逻辑
- 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能
- 前端审批中心、审计、差旅报销等视图交互与样式优化
- 新增TableLoadingState共享组件及员工导入测试用例
This commit is contained in:
caoxiaozhu
2026-05-20 14:21:56 +08:00
parent 57957d11a0
commit d7e98a58b9
46 changed files with 4022 additions and 305 deletions

View File

@@ -2,12 +2,19 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.common import ErrorResponse
from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead, EmployeeUpdate
from app.schemas.employee import (
EmployeeCreate,
EmployeeImportResultRead,
EmployeeMetaRead,
EmployeeRead,
EmployeeUpdate,
)
from app.services.employee import EmployeeService
router = APIRouter()
@@ -44,6 +51,67 @@ def list_employees(
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
@router.get(
"/import-template",
summary="下载员工导入模板",
description="下载固定格式的员工 Excel 导入模板。",
)
def download_employee_import_template(db: DbSession) -> Response:
content = EmployeeService(db).build_import_template()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": 'attachment; filename="employee-import-template.xlsx"'
},
)
@router.get(
"/export",
summary="导出员工 Excel",
description="按筛选条件导出员工目录 Excel 文件。",
)
def export_employees(
db: DbSession,
status_filter: Annotated[
str | None,
Query(alias="status", description="员工状态筛选值。"),
] = None,
keyword: Annotated[
str | None,
Query(description="姓名、工号、邮箱等关键字模糊查询。"),
] = None,
) -> Response:
content = EmployeeService(db).export_employees(status=status_filter, keyword=keyword)
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="employee-export.xlsx"'},
)
@router.post(
"/import",
response_model=EmployeeImportResultRead,
summary="导入员工 Excel",
description="按模板批量导入员工。全部校验通过后才写入数据库,任一行有错则整批不导入。",
)
async def import_employees(
db: DbSession,
file: Annotated[UploadFile, File(description="待导入的员工 Excel 文件。")],
) -> EmployeeImportResultRead:
filename = (file.filename or "").lower()
if not filename.endswith(".xlsx"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前仅支持上传 .xlsx 格式的员工表格。",
)
content = await file.read()
return EmployeeService(db).import_employees(content)
@router.post(
"",
response_model=EmployeeRead,

View File

@@ -16,6 +16,7 @@ from app.schemas.reimbursement import (
ExpenseClaimItemActionResponse,
ExpenseClaimItemUpdate,
ExpenseClaimRead,
ExpenseClaimReturnPayload,
ReimbursementCreate,
ReimbursementRead,
)
@@ -415,11 +416,11 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
return claim
@router.delete(
"/claims/{claim_id}",
response_model=ExpenseClaimActionResponse,
summary="删除个人报销草稿",
description="删除当前登录用户可见的草稿报销单",
@router.post(
"/claims/{claim_id}/return",
response_model=ExpenseClaimRead,
summary="退回报销单",
description="财务人员或高级管理人员可将可见报销单退回到待补充状态",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
@@ -427,7 +428,40 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "仅草稿状态允许删除",
"description": "当前用户或单据状态允许退回",
},
},
)
def return_expense_claim(
claim_id: str,
payload: ExpenseClaimReturnPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.return_claim(claim_id, current_user, reason=payload.reason)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.delete(
"/claims/{claim_id}",
response_model=ExpenseClaimActionResponse,
summary="删除报销单",
description="普通用户仅可删除草稿或待补充报销单;财务人员和高级管理人员可删除可见报销单。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许删除。",
},
},
)
@@ -442,7 +476,7 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return ExpenseClaimActionResponse(
message=f"{claim.claim_no} 草稿已删除。",
message=f"{claim.claim_no} 报销单已删除。",
claim_id=claim.id,
status="deleted",
)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, EmailStr, Field
@@ -16,6 +18,12 @@ class AuthUserRead(BaseModel):
departmentName: str = ""
position: str = ""
grade: str = ""
employeeNo: str = ""
managerName: str = ""
location: str = ""
costCenter: str = ""
financeOwnerName: str = ""
riskProfile: dict[str, Any] = Field(default_factory=dict)
roleCodes: list[str] = Field(default_factory=list)
email: EmailStr | str
avatar: str

View File

@@ -50,6 +50,7 @@ class EmployeeMetaRead(BaseModel):
totalEmployees: int
statusSummary: list[EmployeeStatusSummaryRead]
roleOptions: list[EmployeeRoleOptionRead]
organizationOptions: list[EmployeeOrganizationRead] = Field(default_factory=list)
class EmployeeRead(BaseModel):
@@ -63,6 +64,7 @@ class EmployeeRead(BaseModel):
position: str
grade: str
manager: str
managerEmployeeNo: str | None = None
financeOwner: str
roles: list[str] = Field(default_factory=list)
roleCodes: list[str] = Field(default_factory=list)
@@ -112,6 +114,28 @@ class EmployeeCreate(BaseModel):
return _parse_optional_date(self.join_date, "入职日期")
class EmployeeImportErrorRead(BaseModel):
row: int
column: str
employeeNo: str = ""
message: str
class EmployeeImportSummaryRead(BaseModel):
totalRows: int = 0
created: int = 0
updated: int = 0
errorCount: int = 0
class EmployeeImportResultRead(BaseModel):
success: bool
message: str
summary: EmployeeImportSummaryRead
errors: list[EmployeeImportErrorRead] = Field(default_factory=list)
importedAt: str | None = None
class EmployeeUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
gender: str | None = Field(default=None, max_length=20)
@@ -124,6 +148,8 @@ class EmployeeUpdate(BaseModel):
grade: str | None = Field(default=None, min_length=1, max_length=20)
cost_center: str | None = Field(default=None, max_length=50)
finance_owner_name: str | None = Field(default=None, max_length=100)
organization_unit_code: str | None = Field(default=None, max_length=50)
manager_employee_no: str | None = Field(default=None, max_length=50)
role_codes: list[str] | None = None
password: str | None = Field(default=None, min_length=5, max_length=128)

View File

@@ -148,6 +148,10 @@ class ExpenseClaimActionResponse(BaseModel):
status: str | None = None
class ExpenseClaimReturnPayload(BaseModel):
reason: str | None = Field(default=None, max_length=500)
class ExpenseClaimAttachmentActionResponse(BaseModel):
message: str
claim_id: str

View File

@@ -85,6 +85,8 @@ class UserAgentReviewRiskBrief(BaseModel):
title: str = Field(description="风险或注意事项标题。")
level: str = Field(default="info", description="级别,例如 info / warning / high。")
content: str = Field(description="面向用户展示的摘要说明。")
detail: str = Field(default="", description="点击风险项后展示的详细解释。")
suggestion: str = Field(default="", description="面向用户的处理建议。")
class UserAgentReviewSlotCard(BaseModel):

View File

@@ -1,14 +1,17 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.core.config import get_settings
from app.core.logging import get_logger
from app.core.security import verify_password
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
from app.services.employee import EmployeeService
from app.services.employee_seed import ROLE_DISPLAY_ORDER
@@ -34,6 +37,12 @@ class AuthenticatedUser:
department: str
position: str
grade: str
employee_no: str
manager_name: str
location: str
cost_center: str
finance_owner_name: str
risk_profile: dict[str, Any]
role_codes: list[str]
email: str
avatar: str
@@ -82,6 +91,12 @@ class AuthService:
department="",
position="系统管理员",
grade="",
employee_no="",
manager_name="",
location="",
cost_center="",
finance_owner_name="",
risk_profile={},
role_codes=["manager"],
email=admin_email or f"{admin_username}@local",
avatar=display_name[:1].upper(),
@@ -96,7 +111,11 @@ class AuthService:
stmt = (
select(Employee)
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
.options(
selectinload(Employee.organization_unit),
selectinload(Employee.manager),
selectinload(Employee.roles),
)
.where(func.lower(Employee.email) == identifier.lower())
)
employee = self.db.execute(stmt).scalars().first()
@@ -117,20 +136,75 @@ class AuthService:
)
role_codes = [role.role_code for role in sorted_roles]
primary_role_code = role_codes[0] if role_codes else "user"
department = employee.organization_unit.name if employee.organization_unit is not None else ""
manager_name = self._resolve_manager_name(employee)
return AuthenticatedUser(
username=employee.email,
name=employee.name,
role=ROLE_LABELS.get(primary_role_code, "使用者"),
department=employee.organization_unit.name if employee.organization_unit is not None else "",
department=department,
position=employee.position,
grade=employee.grade,
employee_no=employee.employee_no,
manager_name=manager_name,
location=employee.location or "",
cost_center=employee.cost_center or "",
finance_owner_name=employee.finance_owner_name or "",
risk_profile=self._build_risk_profile(employee),
role_codes=role_codes or ["user"],
email=employee.email,
avatar=(employee.name or "?")[:1].upper(),
is_admin=False,
)
@staticmethod
def _resolve_manager_name(employee: Employee) -> str:
if employee.manager is not None and employee.manager.name:
return str(employee.manager.name).strip()
if employee.organization_unit is not None and employee.organization_unit.manager_name:
return str(employee.organization_unit.manager_name).strip()
return ""
def _build_risk_profile(self, employee: Employee) -> dict[str, Any]:
since = datetime.now(UTC) - timedelta(days=90)
identity_values = [
str(employee.name or "").strip(),
str(employee.email or "").strip(),
str(employee.employee_no or "").strip(),
]
name_candidates = [item for item in dict.fromkeys(identity_values) if item]
conditions = [ExpenseClaim.employee_id == employee.id]
if name_candidates:
conditions.append(ExpenseClaim.employee_name.in_(name_candidates))
stmt = (
select(ExpenseClaim)
.where(or_(*conditions), ExpenseClaim.occurred_at >= since)
.order_by(ExpenseClaim.occurred_at.desc())
.limit(30)
)
claims = list(self.db.scalars(stmt).all())
recent_risk_flags: list[str] = []
for claim in claims:
for flag in claim.risk_flags_json or []:
normalized = str(flag or "").strip()
if normalized and normalized not in recent_risk_flags:
recent_risk_flags.append(normalized)
if len(recent_risk_flags) >= 6:
break
if len(recent_risk_flags) >= 6:
break
return {
"windowDays": 90,
"totalClaimCount": len(claims),
"riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
"draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
"recentRiskFlags": recent_risk_flags,
"lastClaimAt": claims[0].occurred_at.isoformat() if claims and claims[0].occurred_at else "",
}
@staticmethod
def _serialize_user(user: AuthenticatedUser) -> AuthUserRead:
return AuthUserRead(
@@ -141,6 +215,12 @@ class AuthService:
departmentName=user.department,
position=user.position,
grade=user.grade,
employeeNo=user.employee_no,
managerName=user.manager_name,
location=user.location,
costCenter=user.cost_center,
financeOwnerName=user.finance_owner_name,
riskProfile=user.risk_profile,
roleCodes=user.role_codes,
email=user.email,
avatar=user.avatar,

View File

@@ -4,7 +4,7 @@ from collections import Counter
from datetime import date, datetime
from typing import Any
from sqlalchemy import inspect, text
from sqlalchemy import inspect, select, text
from sqlalchemy.orm import Session
from app.core.config import get_settings
@@ -20,6 +20,9 @@ from app.repositories.employee import EmployeeRepository
from app.schemas.employee import (
EmployeeCreate,
EmployeeHistoryRead,
EmployeeImportErrorRead,
EmployeeImportResultRead,
EmployeeImportSummaryRead,
EmployeeMetaRead,
EmployeeOrganizationRead,
EmployeeRead,
@@ -27,8 +30,16 @@ from app.schemas.employee import (
EmployeeStatusSummaryRead,
EmployeeUpdate,
)
from app.services.employee_spreadsheet import (
EmployeeImportRow,
EmployeeSpreadsheetError,
build_export_workbook_bytes,
build_import_template_bytes,
parse_employee_workbook,
)
from app.services.employee_seed import (
EMPLOYEE_DEFINITIONS,
EMPLOYEE_PROFILE_REPAIRS,
ORGANIZATION_DEFINITIONS,
ROLE_DEFINITIONS,
ROLE_DISPLAY_ORDER,
@@ -37,6 +48,7 @@ from app.services.employee_seed import (
logger = get_logger("app.services.employee")
DEFAULT_EMPLOYEE_PASSWORD = "123456"
MAX_EMPLOYEE_CHANGE_LOGS = 5
STATUS_TONE_MAP = {
"在职": "success",
@@ -57,7 +69,9 @@ def prepare_employee_directory() -> None:
session_factory = get_session_factory()
with session_factory() as db:
EmployeeService(db).ensure_directory_ready()
service = EmployeeService(db)
service.ensure_directory_ready()
service.apply_profile_repairs()
class EmployeeService:
@@ -120,10 +134,27 @@ class EmployeeService:
for role in self._sorted_roles(self.repository.list_roles())
]
organization_options = [
EmployeeOrganizationRead(
id=unit.id,
code=unit.unit_code,
name=unit.name,
unitType=unit.unit_type,
costCenter=unit.cost_center,
location=unit.location,
managerName=unit.manager_name,
)
for unit in sorted(
self.repository.list_organization_units(),
key=lambda item: item.name,
)
]
return EmployeeMetaRead(
totalEmployees=len(employees),
statusSummary=status_summary,
roleOptions=role_options,
organizationOptions=organization_options,
)
def create_employee(self, payload: EmployeeCreate) -> EmployeeRead:
@@ -261,6 +292,43 @@ class EmployeeService:
employee.finance_owner_name = finance_owner_name
changed_fields.append("财务归口")
if "organization_unit_code" in payload.model_fields_set:
organization_code = self._normalize_optional_text(payload.organization_unit_code)
current_code = (
employee.organization_unit.unit_code if employee.organization_unit else None
)
if organization_code != current_code:
if organization_code:
organization = self.repository.get_organization_by_code(organization_code)
if organization is None:
raise ValueError(f"部门编码 {organization_code} 不存在")
employee.organization_unit = organization
else:
employee.organization_unit = None
changed_fields.append("所属部门")
if "manager_employee_no" in payload.model_fields_set:
manager_employee_no = self._normalize_optional_text(payload.manager_employee_no)
current_manager_no = employee.manager.employee_no if employee.manager else None
if manager_employee_no:
if manager_employee_no == employee.employee_no:
raise ValueError("直属上级不能是员工本人")
manager = self.repository.get_by_employee_no(manager_employee_no)
if manager is None:
raise ValueError(f"直属上级工号 {manager_employee_no} 不存在")
if manager_employee_no != current_manager_no:
employee.manager = manager
changed_fields.append("直属上级")
elif current_manager_no is not None:
employee.manager = None
changed_fields.append("直属上级")
role_changed = False
sorted_roles: list[Role] = []
if "role_codes" in payload.model_fields_set and payload.role_codes is not None:
requested_codes = list(dict.fromkeys(payload.role_codes))
roles: list[Role] = []
@@ -280,7 +348,7 @@ class EmployeeService:
current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))]
if next_role_codes != current_role_codes:
employee.roles = sorted_roles
changed_fields.append("系统角色")
role_changed = True
if "password" in payload.model_fields_set and payload.password:
password = payload.password.strip()
@@ -289,7 +357,7 @@ class EmployeeService:
employee.password_hash = hash_password(password)
password_changed = True
if not changed_fields and not password_changed:
if not changed_fields and not password_changed and not role_changed:
return self._serialize_employee(employee)
now = datetime.now()
@@ -303,13 +371,25 @@ class EmployeeService:
occurred_at=now,
)
if role_changed:
role_labels = "".join(role.name for role in sorted_roles)
self._append_change_log(
employee,
action=f"更新系统角色({role_labels}",
occurred_at=now,
)
if password_changed:
self._append_change_log(employee, action="重置员工登录密码", occurred_at=now)
saved = self.repository.save(employee)
hydrated = self.repository.get(saved.id)
logger.info("Updated employee id=%s fields=%s", employee.id, ",".join(changed_fields))
return self._serialize_employee(hydrated or saved)
hydrated = self._save_employee_and_reload(employee)
logger.info(
"Updated employee id=%s fields=%s role_changed=%s",
employee.id,
",".join(changed_fields),
role_changed,
)
return self._serialize_employee(hydrated)
def disable_employee(self, employee_id: str) -> EmployeeRead:
self.ensure_directory_ready()
@@ -328,10 +408,9 @@ class EmployeeService:
employee.spotlight = False
self._append_change_log(employee, action="停用员工账号", occurred_at=now)
saved = self.repository.save(employee)
hydrated = self.repository.get(saved.id)
hydrated = self._save_employee_and_reload(employee)
logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no)
return self._serialize_employee(hydrated or saved)
return self._serialize_employee(hydrated)
def enable_employee(self, employee_id: str) -> EmployeeRead:
self.ensure_directory_ready()
@@ -349,10 +428,299 @@ class EmployeeService:
employee.last_sync_at = now
self._append_change_log(employee, action="启用员工账号", occurred_at=now)
saved = self.repository.save(employee)
hydrated = self.repository.get(saved.id)
hydrated = self._save_employee_and_reload(employee)
logger.info("Enabled employee id=%s no=%s", employee.id, employee.employee_no)
return self._serialize_employee(hydrated or saved)
return self._serialize_employee(hydrated)
def build_import_template(self) -> bytes:
self.ensure_directory_ready()
return build_import_template_bytes()
def export_employees(self, status: str | None = None, keyword: str | None = None) -> bytes:
self.ensure_directory_ready()
employees = self.repository.list(status=status, keyword=keyword)
rows: list[list[str]] = []
for employee in employees:
organization = employee.organization_unit
role_codes = ",".join(role.role_code for role in self._sorted_roles(list(employee.roles)))
rows.append(
[
employee.employee_no,
employee.name,
employee.email,
employee.gender or "",
self._format_date(employee.birth_date) or "",
employee.phone or "",
self._format_date(employee.join_date) or "",
employee.location or "",
employee.position,
employee.grade,
organization.unit_code if organization else "",
employee.manager.employee_no if employee.manager else "",
employee.finance_owner_name or "",
employee.cost_center or "",
employee.employment_status,
role_codes,
]
)
return build_export_workbook_bytes(rows)
def import_employees(self, content: bytes, actor: str = "系统管理员") -> EmployeeImportResultRead:
self.ensure_directory_ready()
parsed_rows, parse_errors = parse_employee_workbook(content)
if parse_errors:
return self._build_import_failure(parse_errors, total_rows=len(parsed_rows))
validation_errors = self._validate_import_rows(parsed_rows)
if validation_errors:
return self._build_import_failure(validation_errors, total_rows=len(parsed_rows))
try:
summary = self._apply_import_rows(parsed_rows, actor=actor)
except Exception:
self.db.rollback()
logger.exception("Employee import failed during database write")
raise
imported_at = self._format_datetime(datetime.now()) or ""
message = f"导入成功:新增 {summary['created']} 人,更新 {summary['updated']} 人。"
logger.info(
"Imported employees created=%d updated=%d total=%d",
summary["created"],
summary["updated"],
len(parsed_rows),
)
return EmployeeImportResultRead(
success=True,
message=message,
summary=EmployeeImportSummaryRead(
totalRows=len(parsed_rows),
created=summary["created"],
updated=summary["updated"],
errorCount=0,
),
errors=[],
importedAt=imported_at,
)
def _validate_import_rows(
self, rows: list[EmployeeImportRow]
) -> list[EmployeeSpreadsheetError]:
errors: list[EmployeeSpreadsheetError] = []
employee_nos_in_file: dict[str, int] = {}
emails_in_file: dict[str, int] = {}
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
organizations_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
employees_by_no = {
employee.employee_no: employee for employee in self.repository.list()
}
import_employee_nos = {row.employee_no for row in rows}
for row in rows:
if row.employee_no in employee_nos_in_file:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="员工编号*",
employee_no=row.employee_no,
message=f"员工编号 {row.employee_no} 在文件中重复。",
)
)
else:
employee_nos_in_file[row.employee_no] = row.row_number
if row.email in emails_in_file:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="邮箱*",
employee_no=row.employee_no,
message=f"邮箱 {row.email} 在文件中重复。",
)
)
else:
emails_in_file[row.email] = row.row_number
existing_by_email = self.repository.get_by_email(row.email)
if existing_by_email is not None and existing_by_email.employee_no != row.employee_no:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="邮箱*",
employee_no=row.employee_no,
message=(
f"邮箱 {row.email} 已被员工 "
f"{existing_by_email.employee_no} 使用。"
),
)
)
if row.organization_unit_code and row.organization_unit_code not in organizations_by_code:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="部门编码",
employee_no=row.employee_no,
message=f"部门编码 {row.organization_unit_code} 不存在。",
)
)
if row.manager_employee_no:
manager_exists = (
row.manager_employee_no in employees_by_no
or row.manager_employee_no in import_employee_nos
)
if not manager_exists:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="直属上级工号",
employee_no=row.employee_no,
message=f"直属上级工号 {row.manager_employee_no} 不存在。",
)
)
if row.manager_employee_no == row.employee_no:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="直属上级工号",
employee_no=row.employee_no,
message="直属上级不能是员工本人。",
)
)
invalid_role_codes = [
code for code in row.role_codes if code not in roles_by_code
]
if invalid_role_codes:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="角色编码",
employee_no=row.employee_no,
message=f"角色不存在:{''.join(invalid_role_codes)}",
)
)
return errors
def _apply_import_rows(
self,
rows: list[EmployeeImportRow],
*,
actor: str,
) -> dict[str, int]:
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
organizations_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
employees_by_no = {
employee.employee_no: employee for employee in self.repository.list()
}
created = 0
updated = 0
now = datetime.now()
try:
for row in rows:
employee = employees_by_no.get(row.employee_no)
is_new = employee is None
if is_new:
employee = Employee(
employee_no=row.employee_no,
name=row.name,
email=row.email,
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
)
self.db.add(employee)
employees_by_no[row.employee_no] = employee
created += 1
else:
updated += 1
employee.name = row.name
employee.email = row.email
employee.gender = row.gender
employee.birth_date = row.birth_date
employee.phone = row.phone
employee.join_date = row.join_date
employee.location = row.location
employee.position = row.position
employee.grade = row.grade
employee.finance_owner_name = row.finance_owner_name
employee.cost_center = row.cost_center
employee.employment_status = row.employment_status
employee.sync_state = "已同步"
employee.last_sync_at = now
if row.organization_unit_code:
employee.organization_unit = organizations_by_code[row.organization_unit_code]
else:
employee.organization_unit = None
employee.roles = self._sorted_roles(
[roles_by_code[code] for code in row.role_codes if code in roles_by_code]
)
action = (
"通过 Excel 导入新建员工档案"
if is_new
else "通过 Excel 导入更新员工档案"
)
self._append_change_log(employee, action=action, owner=actor, occurred_at=now)
self.db.flush()
for row in rows:
employee = employees_by_no[row.employee_no]
if row.manager_employee_no:
employee.manager = employees_by_no.get(row.manager_employee_no)
else:
employee.manager = None
self.db.commit()
except Exception:
self.db.rollback()
raise
return {"created": created, "updated": updated}
def _build_import_failure(
self,
errors: list[EmployeeSpreadsheetError],
*,
total_rows: int,
) -> EmployeeImportResultRead:
error_reads = [
EmployeeImportErrorRead(
row=item.row,
column=item.column,
employeeNo=item.employee_no,
message=item.message,
)
for item in errors
]
return EmployeeImportResultRead(
success=False,
message=(
f"导入未执行:共发现 {len(error_reads)} 处错误,请修正后重新导入。"
"原有员工数据未变更。"
),
summary=EmployeeImportSummaryRead(
totalRows=total_rows,
created=0,
updated=0,
errorCount=len(error_reads),
),
errors=error_reads,
importedAt=None,
)
def _seed_roles(self) -> None:
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
@@ -471,6 +839,69 @@ class EmployeeService:
self.db.flush()
def apply_profile_repairs(self) -> None:
"""Apply one-off demo profile repairs. Intended for startup/bootstrap only."""
try:
self._repair_employee_profiles()
self._trim_all_employee_change_logs()
self.db.commit()
except Exception:
self.db.rollback()
logger.exception("Failed to apply employee profile repairs")
raise
def _repair_employee_profiles(self) -> None:
if not EMPLOYEE_PROFILE_REPAIRS:
return
employees = self.repository.list()
employees_by_email = {employee.email.lower(): employee for employee in employees if employee.email}
employees_by_no = {employee.employee_no: employee for employee in employees if employee.employee_no}
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
organizations_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
for definition in EMPLOYEE_PROFILE_REPAIRS:
email = str(definition.get("email") or "").strip().lower()
employee_no = str(definition.get("employee_no") or "").strip()
employee = employees_by_email.get(email) or employees_by_no.get(employee_no)
if employee is None:
continue
for field_name in (
"position",
"grade",
"location",
"cost_center",
"finance_owner_name",
"employment_status",
"sync_state",
):
value = definition.get(field_name)
if value:
setattr(employee, field_name, value)
organization_code = definition.get("organization_unit_code")
if organization_code:
employee.organization_unit = organizations_by_code.get(organization_code)
manager_employee_no = definition.get("manager_employee_no")
if manager_employee_no:
employee.manager = employees_by_no.get(manager_employee_no)
if not employee.password_hash:
employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD)
role_codes = [item for item in definition.get("role_codes", []) if item in roles_by_code]
if role_codes:
merged_roles = {role.role_code: role for role in employee.roles}
for role_code in role_codes:
merged_roles[role_code] = roles_by_code[role_code]
employee.roles = self._sorted_roles(list(merged_roles.values()))
self.db.flush()
def _prune_extra_seed_employees(self) -> None:
if not EXTRA_SEED_EMPLOYEE_NOS:
return
@@ -530,6 +961,12 @@ class EmployeeService:
)
existing_keys.add(identity)
def _save_employee_and_reload(self, employee: Employee) -> Employee:
saved = self.repository.save(employee)
self._trim_employee_change_logs(saved.id)
self.db.commit()
return self.repository.get(saved.id) or saved
def _append_change_log(
self,
employee: Employee,
@@ -546,6 +983,26 @@ class EmployeeService:
)
)
def _trim_all_employee_change_logs(self) -> None:
for employee in self.repository.list():
self._trim_employee_change_logs(employee.id)
def _sorted_change_logs(self, employee: Employee) -> list[EmployeeChangeLog]:
return sorted(employee.change_logs, key=lambda item: item.occurred_at, reverse=True)
def _trim_employee_change_logs(self, employee_id: str) -> None:
stmt = (
select(EmployeeChangeLog)
.where(EmployeeChangeLog.employee_id == employee_id)
.order_by(EmployeeChangeLog.occurred_at.desc())
)
logs = list(self.db.execute(stmt).scalars().all())
if len(logs) <= MAX_EMPLOYEE_CHANGE_LOGS:
return
for stale in logs[MAX_EMPLOYEE_CHANGE_LOGS:]:
self.db.delete(stale)
def _serialize_employee(self, employee: Employee) -> EmployeeRead:
organization = employee.organization_unit
roles = self._sorted_roles(list(employee.roles))
@@ -556,10 +1013,10 @@ class EmployeeService:
EmployeeHistoryRead(
action=item.action,
owner=item.owner,
time=self._format_datetime(item.occurred_at) or "",
occurredAt=self._format_datetime(item.occurred_at) or "",
time=self._format_history_datetime(item.occurred_at),
occurredAt=self._format_history_datetime(item.occurred_at),
)
for item in employee.change_logs
for item in self._sorted_change_logs(employee)[:MAX_EMPLOYEE_CHANGE_LOGS]
]
return EmployeeRead(
@@ -571,6 +1028,7 @@ class EmployeeService:
position=employee.position,
grade=employee.grade,
manager=employee.manager.name if employee.manager else "CEO",
managerEmployeeNo=employee.manager.employee_no if employee.manager else None,
financeOwner=employee.finance_owner_name or "",
roles=role_labels,
roleCodes=role_codes,
@@ -654,6 +1112,15 @@ class EmployeeService:
return None
return value.strftime("%Y-%m-%d %H:%M")
@staticmethod
def _format_history_datetime(value: datetime | None) -> str:
if value is None:
return ""
return (
f"{value.year}{value.month}{value.day}"
f"{value.hour}{value.minute}{value.second}"
)
@staticmethod
def _calculate_age(birth_date: date | None) -> int | None:
if birth_date is None:

View File

@@ -144,6 +144,24 @@ ORGANIZATION_DEFINITIONS = [
},
]
EMPLOYEE_PROFILE_REPAIRS = [
{
"employee_no": "E90919",
"name": "曹笑竹",
"email": "caoxiaozhu@xf.com",
"location": "武汉",
"position": "财务智能化产品经理",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6112",
"employment_status": "在职",
"sync_state": "已同步",
"role_codes": ["user"],
},
]
EMPLOYEE_DEFINITIONS = [
{
"employee_no": "E10018",

View File

@@ -0,0 +1,368 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from email.utils import parseaddr
from io import BytesIO
from typing import Any
from openpyxl import Workbook, load_workbook
EMPLOYEE_SHEET_NAME = "员工目录"
INSTRUCTION_SHEET_NAME = "填表说明"
EMPLOYEE_HEADERS: tuple[str, ...] = (
"员工编号*",
"姓名*",
"邮箱*",
"性别",
"出生日期",
"手机号",
"入职日期",
"办公地点",
"岗位*",
"职级*",
"部门编码",
"直属上级工号",
"财务归口",
"成本中心",
"在职状态*",
"角色编码",
)
HEADER_TO_FIELD: dict[str, str] = {
"员工编号*": "employee_no",
"姓名*": "name",
"邮箱*": "email",
"性别": "gender",
"出生日期": "birth_date",
"手机号": "phone",
"入职日期": "join_date",
"办公地点": "location",
"岗位*": "position",
"职级*": "grade",
"部门编码": "organization_unit_code",
"直属上级工号": "manager_employee_no",
"财务归口": "finance_owner_name",
"成本中心": "cost_center",
"在职状态*": "employment_status",
"角色编码": "role_codes",
}
VALID_EMPLOYMENT_STATUSES = {"在职", "试用中", "停用"}
DEFAULT_ROLE_CODES = ("user",)
MAX_IMPORT_ROWS = 2000
MAX_IMPORT_BYTES = 5 * 1024 * 1024
@dataclass(frozen=True)
class EmployeeImportRow:
row_number: int
employee_no: str
name: str
email: str
gender: str | None
birth_date: date | None
phone: str | None
join_date: date | None
location: str | None
position: str
grade: str
organization_unit_code: str | None
manager_employee_no: str | None
finance_owner_name: str | None
cost_center: str | None
employment_status: str
role_codes: list[str]
@dataclass(frozen=True)
class EmployeeSpreadsheetError:
row: int
column: str
employee_no: str
message: str
def build_import_template_bytes() -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = EMPLOYEE_SHEET_NAME
sheet.append(list(EMPLOYEE_HEADERS))
instructions = workbook.create_sheet(INSTRUCTION_SHEET_NAME)
instructions.append(["字段", "说明"])
instruction_rows = [
("员工编号*", "必填,全局唯一,导入时用于判断新建或覆盖。"),
("姓名*", "必填。"),
("邮箱*", "必填,全局唯一。"),
("性别", "可选:男、女,留空表示不填写。"),
("出生日期", "可选,格式 YYYY-MM-DD。"),
("手机号", "可选。"),
("入职日期", "可选,格式 YYYY-MM-DD。"),
("办公地点", "可选。"),
("岗位*", "必填。"),
("职级*", "必填,例如 P3、P5。"),
("部门编码", "可选,须与系统组织编码一致,例如 FIN-SSC。"),
("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"),
("财务归口", "可选。"),
("成本中心", "可选。"),
("在职状态*", "必填:在职、试用中、停用。"),
("角色编码", "可选,多个角色用英文逗号分隔,例如 user,finance留空默认为 user。"),
("导入规则", "全部校验通过后才写入数据库;任一行有错则整批不导入,原有数据保持不变。"),
]
for row in instruction_rows:
instructions.append(list(row))
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def build_export_workbook_bytes(rows: list[list[Any]]) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = EMPLOYEE_SHEET_NAME
sheet.append(list(EMPLOYEE_HEADERS))
for row in rows:
sheet.append(row)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def parse_employee_workbook(content: bytes) -> tuple[list[EmployeeImportRow], list[EmployeeSpreadsheetError]]:
errors: list[EmployeeSpreadsheetError] = []
if not content:
return [], [
EmployeeSpreadsheetError(
row=0,
column="文件",
employee_no="",
message="上传文件不能为空。",
)
]
if len(content) > MAX_IMPORT_BYTES:
return [], [
EmployeeSpreadsheetError(
row=0,
column="文件",
employee_no="",
message=f"文件大小不能超过 {MAX_IMPORT_BYTES // (1024 * 1024)}MB。",
)
]
try:
workbook = load_workbook(filename=BytesIO(content), read_only=True, data_only=True)
except Exception:
return [], [
EmployeeSpreadsheetError(
row=0,
column="文件",
employee_no="",
message="无法解析 Excel 文件,请使用系统提供的 .xlsx 模板。",
)
]
if EMPLOYEE_SHEET_NAME not in workbook.sheetnames:
return [], [
EmployeeSpreadsheetError(
row=0,
column="工作表",
employee_no="",
message=f"缺少工作表“{EMPLOYEE_SHEET_NAME}”。",
)
]
worksheet = workbook[EMPLOYEE_SHEET_NAME]
raw_rows = list(worksheet.iter_rows(values_only=True))
if not raw_rows:
return [], [
EmployeeSpreadsheetError(
row=0,
column="文件",
employee_no="",
message="Excel 中没有可导入的数据行。",
)
]
header_row = [_normalize_cell(value) for value in raw_rows[0]]
if list(header_row) != list(EMPLOYEE_HEADERS):
return [], [
EmployeeSpreadsheetError(
row=1,
column="表头",
employee_no="",
message="表头与员工导入模板不一致,请下载最新模板后重试。",
)
]
parsed_rows: list[EmployeeImportRow] = []
for index, raw_row in enumerate(raw_rows[1:], start=2):
if index - 1 > MAX_IMPORT_ROWS:
errors.append(
EmployeeSpreadsheetError(
row=index,
column="文件",
employee_no="",
message=f"单次最多导入 {MAX_IMPORT_ROWS} 行数据。",
)
)
break
if _is_empty_data_row(raw_row):
continue
row_errors, parsed = _parse_data_row(index, raw_row)
errors.extend(row_errors)
if parsed is not None:
parsed_rows.append(parsed)
if not parsed_rows and not errors:
errors.append(
EmployeeSpreadsheetError(
row=0,
column="文件",
employee_no="",
message="Excel 中没有可导入的数据行。",
)
)
return parsed_rows, errors
def _parse_data_row(
row_number: int,
raw_row: tuple[Any, ...],
) -> tuple[list[EmployeeSpreadsheetError], EmployeeImportRow | None]:
errors: list[EmployeeSpreadsheetError] = []
values = {
HEADER_TO_FIELD[header]: _normalize_cell(raw_row[index] if index < len(raw_row) else "")
for index, header in enumerate(EMPLOYEE_HEADERS)
}
employee_no = values["employee_no"]
def add_error(column: str, message: str) -> None:
errors.append(
EmployeeSpreadsheetError(
row=row_number,
column=column,
employee_no=employee_no,
message=message,
)
)
if not employee_no:
add_error("员工编号*", "员工编号不能为空。")
name = values["name"]
if not name:
add_error("姓名*", "姓名不能为空。")
email = values["email"].lower() if values["email"] else ""
if not email:
add_error("邮箱*", "邮箱不能为空。")
elif not _is_valid_email(email):
add_error("邮箱*", "邮箱格式不正确。")
position = values["position"]
if not position:
add_error("岗位*", "岗位不能为空。")
grade = values["grade"]
if not grade:
add_error("职级*", "职级不能为空。")
employment_status = values["employment_status"]
if not employment_status:
add_error("在职状态*", "在职状态不能为空。")
elif employment_status not in VALID_EMPLOYMENT_STATUSES:
add_error("在职状态*", "在职状态必须为:在职、试用中、停用。")
gender = values["gender"] or None
if gender and gender not in {"", ""}:
add_error("性别", "性别只能填写:男、女,或留空。")
birth_date, birth_error = _parse_optional_date(values["birth_date"], "出生日期")
if birth_error:
add_error("出生日期", birth_error)
join_date, join_error = _parse_optional_date(values["join_date"], "入职日期")
if join_error:
add_error("入职日期", join_error)
role_codes = _parse_role_codes(values["role_codes"])
if values["role_codes"] and not role_codes:
add_error("角色编码", "角色编码不能为空片段,多个角色请用英文逗号分隔。")
if errors:
return errors, None
return (
[],
EmployeeImportRow(
row_number=row_number,
employee_no=employee_no,
name=name,
email=email,
gender=gender,
birth_date=birth_date,
phone=values["phone"] or None,
join_date=join_date,
location=values["location"] or None,
position=position,
grade=grade,
organization_unit_code=values["organization_unit_code"] or None,
manager_employee_no=values["manager_employee_no"] or None,
finance_owner_name=values["finance_owner_name"] or None,
cost_center=values["cost_center"] or None,
employment_status=employment_status,
role_codes=role_codes or list(DEFAULT_ROLE_CODES),
),
)
def _parse_role_codes(value: str) -> list[str]:
if not value:
return []
codes = [item.strip() for item in value.replace("", ",").split(",")]
return list(dict.fromkeys(code for code in codes if code))
def _parse_optional_date(value: str, label: str) -> tuple[date | None, str | None]:
if not value:
return None, None
if isinstance(value, datetime):
return value.date(), None
if isinstance(value, date):
return value, None
text = str(value).strip()
try:
return datetime.strptime(text, "%Y-%m-%d").date(), None
except ValueError:
return None, f"{label}格式必须为 YYYY-MM-DD。"
def _is_valid_email(value: str) -> bool:
_, address = parseaddr(value)
return bool(address) and "@" in address
def _normalize_cell(value: Any) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d")
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
return str(value).strip()
def _is_empty_data_row(raw_row: tuple[Any, ...]) -> bool:
return not any(_normalize_cell(value) for value in raw_row)

View File

@@ -54,7 +54,7 @@ EXPENSE_TYPE_LABELS = {
"welfare": "福利",
}
PRIVILEGED_CLAIM_ROLE_CODES = {"finance"}
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
MAX_DRAFT_CLAIMS_PER_USER = 3
LOCATION_REQUIRED_EXPENSE_TYPES = {
@@ -814,17 +814,19 @@ class ExpenseClaimService:
"invoice_count": int(claim.invoice_count or 0),
}
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
self._ensure_draft_claim(claim)
before_json = self._serialize_claim(claim)
resource_id = claim.id
self._delete_claim_attachment_root(claim.id)
self.db.delete(claim)
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
if not self._has_privileged_claim_access(current_user):
self._ensure_draft_claim(claim)
before_json = self._serialize_claim(claim)
resource_id = claim.id
self._delete_claim_attachment_root(claim.id)
self.db.delete(claim)
self.db.commit()
self.audit_service.log_action(
@@ -835,10 +837,60 @@ class ExpenseClaimService:
before_json=before_json,
after_json=None,
)
return claim
def upsert_draft_from_ontology(
return claim
def return_claim(
self,
claim_id: str,
current_user: CurrentUserContext,
*,
reason: str | None = None,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
if not self._has_privileged_claim_access(current_user):
raise ValueError("只有财务人员或高级管理人员可以退回报销单。")
normalized_status = str(claim.status or "").strip().lower()
if normalized_status == "draft":
raise ValueError("草稿状态无需退回。")
if normalized_status in {"approved", "completed", "paid"}:
raise ValueError("已完成单据不允许退回。")
before_json = self._serialize_claim(claim)
operator = current_user.name or current_user.username
return_reason = str(reason or "").strip()
return_flag = {
"source": "manual_return",
"severity": "medium",
"label": "人工退回",
"message": return_reason or f"{operator} 已退回该报销单,请申请人补充后重新提交。",
"operator": operator,
"created_at": datetime.now(UTC).isoformat(),
}
claim.status = "returned"
claim.approval_stage = "待补充"
claim.risk_flags_json = [*list(claim.risk_flags_json or []), return_flag]
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=operator,
action="expense_claim.return",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
def upsert_draft_from_ontology(
self,
*,
run_id: str,

View File

@@ -530,8 +530,16 @@ class UserAgentService:
"entry_source": payload.context_json.get("entry_source"),
"user_name": payload.context_json.get("name"),
"user_role": payload.context_json.get("role"),
"user_department": payload.context_json.get("department_name")
or payload.context_json.get("department"),
"user_position": payload.context_json.get("position"),
"user_grade": payload.context_json.get("grade"),
"employee_no": payload.context_json.get("employee_no"),
"manager_name": payload.context_json.get("manager_name"),
"employee_location": payload.context_json.get("employee_location"),
"cost_center": payload.context_json.get("cost_center"),
"finance_owner_name": payload.context_json.get("finance_owner_name"),
"employee_risk_profile": payload.context_json.get("employee_risk_profile", {}),
"user_role_codes": payload.context_json.get("role_codes", []),
"is_admin": bool(payload.context_json.get("is_admin")),
"request_context": payload.context_json.get("request_context"),
@@ -2178,18 +2186,36 @@ class UserAgentService:
title="AI预审未通过",
level="high",
content=reason,
detail=(
"该项属于提交审批前的阻断条件。系统会先要求补齐基础字段、附件或业务说明,"
"否则审批人无法判断成本归属、业务真实性或票据有效性。"
),
suggestion="按提示补齐对应信息;如果业务场景本身合理,请补充说明或佐证附件后再提交。",
)
)
employee_name = self._collect_entity_values(payload).get("employee_name") or str(
payload.context_json.get("name") or ""
).strip()
employee = self._resolve_employee_profile(payload)
employee_name = (
str(employee.name).strip()
if employee is not None and employee.name
else self._collect_entity_values(payload).get("employee_name")
or str(payload.context_json.get("name") or "").strip()
)
if employee_name:
since = datetime.now(UTC) - timedelta(days=90)
stmt = select(ExpenseClaim).where(
ExpenseClaim.employee_name == employee_name,
ExpenseClaim.occurred_at >= since,
)
claim_identity_conditions = [ExpenseClaim.employee_name == employee_name]
if employee is not None:
employee_identifiers = {
str(employee.name or "").strip(),
str(employee.email or "").strip(),
str(employee.employee_no or "").strip(),
}
employee_identifiers.discard("")
claim_identity_conditions = [
ExpenseClaim.employee_id == employee.id,
ExpenseClaim.employee_name.in_(list(employee_identifiers)),
]
stmt = select(ExpenseClaim).where(or_(*claim_identity_conditions), ExpenseClaim.occurred_at >= since)
recent_claims = list(self.db.scalars(stmt).all())
if recent_claims:
risky_count = sum(1 for item in recent_claims if item.risk_flags_json)
@@ -2202,6 +2228,11 @@ class UserAgentService:
f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销,"
f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。"
),
detail=(
"该画像来自员工近 90 天报销记录,用于辅助判断是否存在频繁草稿、"
"历史风险或异常重复报销倾向,不会单独阻断审批。"
),
suggestion="如历史记录中存在风险标记,本次提交时建议主动补充业务背景和票据说明。",
)
)
current_amount = self._resolve_amount_value(payload)
@@ -2220,6 +2251,11 @@ class UserAgentService:
f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录,"
"提交前建议核对是否为重复报销或拆分不当。"
),
detail=(
"系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规,"
"但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。"
),
suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。",
)
)
@@ -2229,6 +2265,8 @@ class UserAgentService:
title="制度注意事项",
level="info",
content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。",
detail=f"本条来自规则或知识库引用:{citations[0].title}。提交前应确认当前单据符合该条口径。",
suggestion="如当前场景与制度口径存在差异,请补充审批说明或选择更准确的报销分类。",
)
)
@@ -2239,6 +2277,8 @@ class UserAgentService:
title="票据识别提醒",
level="warning",
content=f"当前共有 {warning_count} 条票据识别提示,建议逐张确认 OCR 识别字段。",
detail="票据 OCR 识别存在字段缺失、置信度偏低或类型判断不稳定时,会生成该提醒。",
suggestion="打开票据明细逐张核对日期、金额、商户和票据类型,必要时更正后再提交。",
)
)
@@ -2248,6 +2288,8 @@ class UserAgentService:
title="建议拆单",
level="high",
content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。",
detail="同一批附件中包含多类费用场景时,混在一张报销单里会影响规则匹配、附件核验和审批归口。",
suggestion="按费用场景拆成多张报销单,分别确认金额、事由和附件归属。",
)
)
@@ -2676,6 +2718,7 @@ class UserAgentService:
stmt = (
select(Employee)
.options(selectinload(Employee.organization_unit), selectinload(Employee.manager))
.where(
or_(
Employee.name.in_(normalized),