feat: 增强员工管理与报销单全流程功能
- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点 - 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力 - 报销单提交补充身份回填、部门信息透传及预审结果展示优化 - 认证流程增加部门信息(departmentName)并在schema中同步扩展 - 用户Agent服务增加部门关联与报销单回填逻辑 - 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能 - 前端审批中心、审计、差旅报销等视图交互与样式优化 - 新增TableLoadingState共享组件及员工导入测试用例
This commit is contained in:
@@ -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)
|
||||
|
||||
153
server/tests/test_employee_spreadsheet_import.py
Normal file
153
server/tests/test_employee_spreadsheet_import.py
Normal 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"
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user