fix: 修复员工服务、报销单审批及前端交互细节
- 修复员工创建时组织架构关联与邮箱校验逻辑 - 修复报销单API端点参数及预审流程调用 - 优化审批中心、差旅详情等前端页面交互 - 更新侧边栏导航与请求视图模型 - 补充员工服务与报销单相关测试用例
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user