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

@@ -135,6 +135,112 @@ def test_enable_employee_restores_status_and_logs_change() -> None:
assert any(item.action == "启用员工账号" for item in updated.history)
def test_profile_repairs_do_not_run_on_every_list() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
updated = service.update_employee(
employee.id,
EmployeeUpdate(position="测试岗位-不会被回滚"),
)
listed = next(item for item in service.list_employees() if item.id == employee.id)
assert updated.position == "测试岗位-不会被回滚"
assert listed.position == "测试岗位-不会被回滚"
def test_role_update_appends_recent_history() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
current_codes = list(employee.roleCodes)
next_codes = ["finance", "user"] if "finance" not in current_codes else ["user"]
updated = service.update_employee(employee.id, EmployeeUpdate(role_codes=next_codes))
assert any("更新系统角色" in item.action for item in updated.history)
def test_employee_change_logs_keep_only_latest_five() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
persisted = db.get(Employee, employee.id)
assert persisted is not None
for index in range(7):
service._append_change_log(
persisted,
action=f"测试变更-{index}",
owner="单元测试",
)
db.commit()
service._trim_employee_change_logs(persisted.id)
db.commit()
hydrated = db.get(Employee, employee.id)
assert hydrated is not None
assert len(hydrated.change_logs) == 5
assert hydrated.change_logs[0].action == "测试变更-6"
def test_employee_meta_includes_organization_options() -> None:
with build_session() as db:
service = EmployeeService(db)
meta = service.get_employee_meta()
assert meta.organizationOptions
assert all(item.code and item.name for item in meta.organizationOptions)
def test_update_employee_changes_organization() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
organizations = service.repository.list_organization_units()
current_code = employee.organization.code if employee.organization else None
target = next(unit for unit in organizations if unit.unit_code != current_code)
updated = service.update_employee(
employee.id,
EmployeeUpdate(organization_unit_code=target.unit_code),
)
assert updated.organization is not None
assert updated.organization.code == target.unit_code
assert updated.department == target.name
assert any("更新员工信息" in item.action for item in updated.history)
def test_update_employee_rejects_unknown_organization() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
with pytest.raises(ValueError, match="部门编码"):
service.update_employee(
employee.id,
EmployeeUpdate(organization_unit_code="ORG-NOT-EXISTS"),
)
def test_update_employee_changes_manager() -> None:
with build_session() as db:
service = EmployeeService(db)
employees = service.list_employees()
employee = employees[0]
manager = next(item for item in employees if item.id != employee.id)
updated = service.update_employee(
employee.id,
EmployeeUpdate(manager_employee_no=manager.employeeNo),
)
assert updated.managerEmployeeNo == manager.employeeNo
assert updated.manager == manager.name
def test_update_employee_rejects_invalid_date_format() -> None:
with build_session() as db:
service = EmployeeService(db)

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
from io import BytesIO
from openpyxl import Workbook
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.employee import Employee
from app.services.employee import EmployeeService
from app.services.employee_spreadsheet import EMPLOYEE_HEADERS, EMPLOYEE_SHEET_NAME
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def build_workbook_bytes(rows: list[list[object]]) -> 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 test_import_employees_rejects_invalid_row_without_writing() -> None:
with build_session() as db:
service = EmployeeService(db)
first = service.list_employees()[0]
content = build_workbook_bytes(
[
[
first.employeeNo,
"",
first.email,
"",
"",
"",
"",
"",
first.position,
first.grade,
"",
"",
"",
"",
"在职",
"user",
]
]
)
result = service.import_employees(content)
assert result.success is False
assert result.summary.errorCount >= 1
assert any("姓名" in item.message for item in result.errors)
refreshed = service.get_employee(first.id)
assert refreshed is not None
assert refreshed.name == first.name
def test_import_employees_updates_existing_employee() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
new_name = f"{employee.name}-导入"
content = build_workbook_bytes(
[
[
employee.employeeNo,
new_name,
employee.email,
"",
"",
"13900000001",
"",
"上海",
employee.position,
employee.grade,
"FIN-SSC",
"",
"华东财务组",
"CC-TEST",
"在职",
"user",
]
]
)
result = service.import_employees(content, actor="测试管理员")
assert result.success is True
assert result.summary.updated == 1
updated = service.get_employee(employee.id)
assert updated is not None
assert updated.name == new_name
assert updated.phone == "13900000001"
def test_import_employees_creates_new_employee() -> None:
with build_session() as db:
service = EmployeeService(db)
service.list_employees()
content = build_workbook_bytes(
[
[
"E90001",
"导入新员工",
"import.new.user@xfinance.com",
"",
"",
"13811112222",
"2025-01-01",
"上海",
"业务专员",
"P3",
"FIN-SSC",
"E10234",
"华东财务组",
"CC-9001",
"在职",
"user",
]
]
)
result = service.import_employees(content)
assert result.success is True
assert result.summary.created == 1
imported = db.execute(
select(Employee).where(Employee.employee_no == "E90001")
).scalar_one()
assert imported.name == "导入新员工"
assert imported.email == "import.new.user@xfinance.com"

View File

@@ -1222,6 +1222,111 @@ def test_list_claims_allows_finance_to_view_all_records() -> None:
assert {claim.claim_no for claim in claims} == {"EXP-FIN-101", "EXP-FIN-102"}
def test_list_claims_allows_executive_to_view_all_records() -> None:
current_user = CurrentUserContext(
username="executive@example.com",
name="高管",
role_codes=["executive"],
is_admin=False,
)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="EXP-EXE-101",
employee_name="",
department_name="A部",
project_code="PRJ-A",
expense_type="travel",
reason="A 报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-EXE-102",
employee_name="",
department_name="B部",
project_code="PRJ-B",
expense_type="meal",
reason="B 报销",
location="杭州",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
status="approved",
approval_stage="completed",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_claims(current_user)
assert len(claims) == 2
assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"}
def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
current_user = CurrentUserContext(
username="finance@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-RET-101",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
service = ExpenseClaimService(db)
returned = service.return_claim(claim_id, current_user, reason="资料不完整")
assert returned is not None
assert returned.status == "returned"
assert returned.approval_stage == "待补充"
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_return"
and flag.get("message") == "资料不完整"
for flag in returned.risk_flags_json
)
deleted = service.delete_claim(claim_id, current_user)
assert deleted is not None
assert deleted.claim_no == "EXP-RET-101"
assert db.get(ExpenseClaim, claim_id) is None
def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
current_user = CurrentUserContext(
username="manager@example.com",