fix: 修复员工服务、报销单审批及前端交互细节

- 修复员工创建时组织架构关联与邮箱校验逻辑
- 修复报销单API端点参数及预审流程调用
- 优化审批中心、差旅详情等前端页面交互
- 更新侧边栏导航与请求视图模型
- 补充员工服务与报销单相关测试用例
This commit is contained in:
caoxiaozhu
2026-05-20 14:32:35 +08:00
parent d7e98a58b9
commit f8b25a7ccc
14 changed files with 84 additions and 52 deletions

View File

@@ -420,7 +420,7 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
"/claims/{claim_id}/return",
response_model=ExpenseClaimRead,
summary="退回报销单",
description="财务人员或高级管理人员可将可见报销单退回到待补充状态。",
description="财务人员或高级管理人员可将可见报销单退回到待提交状态。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,

View File

@@ -1,8 +1,9 @@
from __future__ import annotations
from collections import Counter
from datetime import date, datetime
from datetime import UTC, date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import inspect, select, text
from sqlalchemy.orm import Session
@@ -49,6 +50,7 @@ from app.services.employee_seed import (
logger = get_logger("app.services.employee")
DEFAULT_EMPLOYEE_PASSWORD = "123456"
MAX_EMPLOYEE_CHANGE_LOGS = 5
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
STATUS_TONE_MAP = {
"在职": "success",
@@ -183,7 +185,7 @@ class EmployeeService:
sync_state=payload.sync_state,
spotlight=payload.spotlight,
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
last_sync_at=datetime.now(),
last_sync_at=datetime.now(UTC),
)
if payload.organization_unit_code:
@@ -360,7 +362,7 @@ class EmployeeService:
if not changed_fields and not password_changed and not role_changed:
return self._serialize_employee(employee)
now = datetime.now()
now = datetime.now(UTC)
employee.last_sync_at = now
employee.sync_state = "已同步"
@@ -401,7 +403,7 @@ class EmployeeService:
if employee.employment_status == "停用":
return self._serialize_employee(employee)
now = datetime.now()
now = datetime.now(UTC)
employee.employment_status = "停用"
employee.sync_state = "已同步"
employee.last_sync_at = now
@@ -422,7 +424,7 @@ class EmployeeService:
if employee.employment_status != "停用":
return self._serialize_employee(employee)
now = datetime.now()
now = datetime.now(UTC)
employee.employment_status = "在职"
employee.sync_state = "已同步"
employee.last_sync_at = now
@@ -484,7 +486,7 @@ class EmployeeService:
logger.exception("Employee import failed during database write")
raise
imported_at = self._format_datetime(datetime.now()) or ""
imported_at = self._format_datetime(datetime.now(UTC)) or ""
message = f"导入成功:新增 {summary['created']} 人,更新 {summary['updated']} 人。"
logger.info(
"Imported employees created=%d updated=%d total=%d",
@@ -624,7 +626,7 @@ class EmployeeService:
}
created = 0
updated = 0
now = datetime.now()
now = datetime.now(UTC)
try:
for row in rows:
@@ -979,7 +981,7 @@ class EmployeeService:
employee=employee,
action=action,
owner=owner,
occurred_at=occurred_at or datetime.now(),
occurred_at=occurred_at or datetime.now(UTC),
)
)
@@ -1106,19 +1108,29 @@ class EmployeeService:
return None
return value.strftime("%Y-%m-%d")
@staticmethod
def _to_display_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
normalized = value.replace(tzinfo=UTC)
else:
normalized = value.astimezone(UTC)
return normalized.astimezone(DISPLAY_TIMEZONE)
@staticmethod
def _format_datetime(value: datetime | None) -> str | None:
if value is None:
return None
return value.strftime("%Y-%m-%d %H:%M")
local = EmployeeService._to_display_datetime(value)
return local.strftime("%Y-%m-%d %H:%M")
@staticmethod
def _format_history_datetime(value: datetime | None) -> str:
if value is None:
return ""
local = EmployeeService._to_display_datetime(value)
return (
f"{value.year}{value.month}{value.day}"
f"{value.hour}{value.minute}{value.second}"
f"{local.year}{local.month}{local.day}"
f"{local.hour}{local.minute}"
)
@staticmethod

View File

@@ -867,13 +867,13 @@ class ExpenseClaimService:
"source": "manual_return",
"severity": "medium",
"label": "人工退回",
"message": return_reason or f"{operator} 已退回该报销单,请申请人补充后重新提交。",
"message": return_reason or f"{operator} 已退回该报销单,请申请人调整后重新提交。",
"operator": operator,
"created_at": datetime.now(UTC).isoformat(),
}
claim.status = "returned"
claim.approval_stage = "补充"
claim.approval_stage = "提交"
claim.risk_flags_json = [*list(claim.risk_flags_json or []), return_flag]
self.db.commit()
@@ -2634,8 +2634,8 @@ class ExpenseClaimService:
def _ensure_draft_claim(self, claim: ExpenseClaim) -> None:
normalized_status = str(claim.status or "").strip().lower()
if normalized_status not in {"draft", "supplement"}:
raise ValueError("只有草稿待补充状态的报销单才允许执行该操作。")
if normalized_status not in {"draft", "supplement", "returned"}:
raise ValueError("只有草稿待补充或退回待提交状态的报销单才允许执行该操作。")
def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
base_flags = list(claim.risk_flags_json or [])

View File

@@ -14,8 +14,8 @@
"updated_at": "2026-05-17T09:28:28.999515+00:00",
"uploaded_by": "admin",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-20T06:29:01.123795+00:00",
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_document_name": "远光《公司支出管理办法2024》.pdf",
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
@@ -241,6 +243,14 @@ def test_update_employee_changes_manager() -> None:
assert updated.manager == manager.name
def test_format_history_datetime_uses_local_timezone_without_seconds() -> None:
value = datetime(2026, 5, 20, 6, 30, 45, tzinfo=UTC)
formatted = EmployeeService._format_history_datetime(value)
assert formatted == "2026年5月20日14时30分"
assert "" not in formatted
def test_update_employee_rejects_invalid_date_format() -> None:
with build_session() as db:
service = EmployeeService(db)

View File

@@ -1312,7 +1312,7 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
assert returned is not None
assert returned.status == "returned"
assert returned.approval_stage == "补充"
assert returned.approval_stage == "提交"
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_return"