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", "/claims/{claim_id}/return",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,
summary="退回报销单", summary="退回报销单",
description="财务人员或高级管理人员可将可见报销单退回到待补充状态。", description="财务人员或高级管理人员可将可见报销单退回到待提交状态。",
responses={ responses={
status.HTTP_404_NOT_FOUND: { status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse, "model": ErrorResponse,

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
import pytest import pytest
from sqlalchemy import create_engine, func, select from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -241,6 +243,14 @@ def test_update_employee_changes_manager() -> None:
assert updated.manager == manager.name 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: def test_update_employee_rejects_invalid_date_format() -> None:
with build_session() as db: with build_session() as db:
service = EmployeeService(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 is not None
assert returned.status == "returned" assert returned.status == "returned"
assert returned.approval_stage == "补充" assert returned.approval_stage == "提交"
assert any( assert any(
isinstance(flag, dict) isinstance(flag, dict)
and flag.get("source") == "manual_return" and flag.get("source") == "manual_return"

View File

@@ -71,7 +71,7 @@ const sidebarMeta = {
overview: { label: '总览' }, overview: { label: '总览' },
workbench: { label: '个人工作台' }, workbench: { label: '个人工作台' },
requests: { label: '个人报销' }, requests: { label: '个人报销' },
approval: { label: '审批中心', badge: '12' }, approval: { label: '审批中心' },
policies: { label: '知识管理' }, policies: { label: '知识管理' },
audit: { label: '任务规则中心' }, audit: { label: '任务规则中心' },
logs: { label: '日志管理' }, logs: { label: '日志管理' },

View File

@@ -112,7 +112,11 @@ function resolveApprovalMeta(status) {
return { key: 'draft', label: '草稿', tone: 'draft' } return { key: 'draft', label: '草稿', tone: 'draft' }
} }
if (['supplement', 'returned'].includes(normalized)) { if (normalized === 'returned') {
return { key: 'supplement', label: '待提交', tone: 'warning' }
}
if (normalized === 'supplement') {
return { key: 'supplement', label: '待补充', tone: 'warning' } return { key: 'supplement', label: '待补充', tone: 'warning' }
} }
@@ -128,6 +132,10 @@ function resolveApprovalMeta(status) {
} }
function resolveWorkflowNode(claim, approvalMeta) { function resolveWorkflowNode(claim, approvalMeta) {
if (String(claim?.status || '').trim().toLowerCase() === 'returned') {
return '待提交'
}
const rawNode = String(claim?.approval_stage || '').trim() const rawNode = String(claim?.approval_stage || '').trim()
if (rawNode) { if (rawNode) {

View File

@@ -74,7 +74,7 @@ const BACKEND_STATUS_META = {
paid: { key: 'completed', label: '已完成', tone: 'success' }, paid: { key: 'completed', label: '已完成', tone: 'success' },
completed: { key: 'completed', label: '已完成', tone: 'success' }, completed: { key: 'completed', label: '已完成', tone: 'success' },
supplement: { key: 'supplement', label: '待补充', tone: 'warning' }, supplement: { key: 'supplement', label: '待补充', tone: 'warning' },
returned: { key: 'supplement', label: '待补充', tone: 'warning' }, returned: { key: 'supplement', label: '待提交', tone: 'warning' },
rejected: { key: 'rejected', label: '已退回', tone: 'danger' }, rejected: { key: 'rejected', label: '已退回', tone: 'danger' },
cancelled: { key: 'rejected', label: '已退回', tone: 'danger' } cancelled: { key: 'rejected', label: '已退回', tone: 'danger' }
} }

View File

@@ -535,7 +535,7 @@
badge="退回单据" badge="退回单据"
badge-tone="warning" badge-tone="warning"
:title="`确认退回 ${selectedRow?.id || ''} 吗?`" :title="`确认退回 ${selectedRow?.id || ''} 吗?`"
description="退回后该单据会进入待补充状态,申请人需要补充后重新提交。" description="退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。"
cancel-text="取消" cancel-text="取消"
confirm-text="确认退回" confirm-text="确认退回"
busy-text="退回中..." busy-text="退回中..."

View File

@@ -82,7 +82,7 @@
<span>智能录入</span> <span>智能录入</span>
</button> </button>
<button <button
v-if="isDraftRequest" v-if="isEditableRequest"
class="smart-entry-btn secondary" class="smart-entry-btn secondary"
type="button" type="button"
:disabled="actionBusy" :disabled="actionBusy"
@@ -104,7 +104,7 @@
<th class="col-amount">金额</th> <th class="col-amount">金额</th>
<th class="col-attachment">附件材料</th> <th class="col-attachment">附件材料</th>
<th v-if="hasExpenseRiskColumn" class="col-risk">系统校验</th> <th v-if="hasExpenseRiskColumn" class="col-risk">系统校验</th>
<th v-if="isDraftRequest" class="col-action">操作</th> <th v-if="isEditableRequest" class="col-action">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -297,7 +297,7 @@
</p> </p>
</template> </template>
</td> </td>
<td v-if="isDraftRequest" class="expense-action-cell col-action"> <td v-if="isEditableRequest" class="expense-action-cell col-action">
<div v-if="editingExpenseId === item.id" class="row-action-group"> <div v-if="editingExpenseId === item.id" class="row-action-group">
<button <button
class="inline-action primary" class="inline-action primary"
@@ -366,7 +366,7 @@
</div> </div>
</article> </article>
<article v-if="isDraftRequest" class="detail-card panel validation-card"> <article v-if="isEditableRequest" class="detail-card panel validation-card">
<div class="validation-head"> <div class="validation-head">
<div> <div>
<h3>AI建议</h3> <h3>AI建议</h3>
@@ -393,10 +393,10 @@
<i class="mdi mdi-arrow-left"></i> <i class="mdi mdi-arrow-left"></i>
<span>返回报销列表</span> <span>返回报销列表</span>
</button> </button>
<div v-if="isDraftRequest" class="approval-action-group" aria-label="申请操作"> <div v-if="isEditableRequest" class="approval-action-group" aria-label="申请操作">
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest"> <button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
<i class="mdi mdi-trash-can-outline"></i> <i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : '删除草稿' }} {{ deleteBusy ? '删除中' : deleteActionLabel }}
</button> </button>
<button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit"> <button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit">
<i class="mdi mdi-send-circle-outline"></i> <i class="mdi mdi-send-circle-outline"></i>
@@ -500,7 +500,7 @@
badge="退回单据" badge="退回单据"
badge-tone="warning" badge-tone="warning"
:title="`确认退回 ${request.id} 吗?`" :title="`确认退回 ${request.id} 吗?`"
description="退回后该单据会进入待补充状态,申请人需要补充后重新提交。" description="退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。"
cancel-text="取消" cancel-text="取消"
confirm-text="确认退回" confirm-text="确认退回"
busy-text="退回中..." busy-text="退回中..."

View File

@@ -428,9 +428,9 @@ export default {
actionBusy.value = true actionBusy.value = true
try { try {
await returnExpenseClaim(row.claimId, { await returnExpenseClaim(row.claimId, {
reason: '审批中心退回,请申请人补充后重新提交。' reason: '审批中心退回,请申请人调整后重新提交。'
}) })
toast(`${row.id} 已退回待补充`) toast(`${row.id} 已退回待提交`)
returnDialogOpen.value = false returnDialogOpen.value = false
selectedClaimId.value = '' selectedClaimId.value = ''
await reload() await reload()

View File

@@ -264,21 +264,21 @@ function formatEmployeeHistoryTime(value) {
return '' return ''
} }
const matched = raw.match( const chineseMatched = raw.match(
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/ /^(\d{4})(\d{1,2})(\d{1,2})(\d{1,2})(\d{1,2})(?:\d{1,2}秒)?$/
) )
if (!matched) { if (chineseMatched) {
return raw const [, year, month, day, hour, minute] = chineseMatched
return `${year}${Number(month)}${Number(day)}${Number(hour)}${Number(minute)}`
} }
const year = Number.parseInt(matched[1], 10) const isoMatched = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/)
const month = Number.parseInt(matched[2], 10) if (isoMatched) {
const day = Number.parseInt(matched[3], 10) const [, year, month, day, hour = '0', minute = '0'] = isoMatched
const hour = Number.parseInt(matched[4] || '0', 10) return `${year}${Number(month)}${Number(day)}${Number(hour)}${Number(minute)}`
const minute = Number.parseInt(matched[5] || '0', 10) }
const second = Number.parseInt(matched[6] || '0', 10)
return `${year}${month}${day}${hour}${minute}${second}` return raw.replace(/(\d{1,2}分)\d{1,2}秒$/, '$1')
} }
function resolveOrganizationUnitCode(employee) { function resolveOrganizationUnitCode(employee) {

View File

@@ -454,8 +454,9 @@ export default {
const isTravelRequest = computed(() => request.value.detailVariant === 'travel') const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
const isDraftRequest = computed(() => request.value.approvalKey === 'draft') const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value)) const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const canDeleteRequest = computed(() => isDraftRequest.value || canManageCurrentClaim.value) const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.value)
const canReturnRequest = computed(() => const canReturnRequest = computed(() =>
canManageCurrentClaim.value canManageCurrentClaim.value
&& request.value.approvalKey === 'in_progress' && request.value.approvalKey === 'in_progress'
@@ -584,7 +585,7 @@ export default {
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length) const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const hasExpenseRiskColumn = computed(() => expenseItems.value.some((item) => item.attachments.length)) const hasExpenseRiskColumn = computed(() => expenseItems.value.some((item) => item.attachments.length))
const expenseTableColumnCount = computed( const expenseTableColumnCount = computed(
() => 5 + (hasExpenseRiskColumn.value ? 1 : 0) + (isDraftRequest.value ? 1 : 0) () => 5 + (hasExpenseRiskColumn.value ? 1 : 0) + (isEditableRequest.value ? 1 : 0)
) )
const expenseSummaryText = computed( const expenseSummaryText = computed(
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。' () => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
@@ -595,9 +596,9 @@ export default {
|| '暂无附加说明。可在这里补充特殊背景、例外原因、补件计划或其他需要财务和审批人重点关注的信息。' || '暂无附加说明。可在这里补充特殊背景、例外原因、补件计划或其他需要财务和审批人重点关注的信息。'
) )
const draftBlockingIssues = computed(() => const draftBlockingIssues = computed(() =>
isDraftRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : [] isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
) )
const canSubmit = computed(() => isDraftRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value) const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType)) const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType))
function applyLocalExpenseItemPatch(itemId, patch) { function applyLocalExpenseItemPatch(itemId, patch) {
@@ -807,7 +808,7 @@ export default {
}) })
function startExpenseEdit(item) { function startExpenseEdit(item) {
if (!isDraftRequest.value || actionBusy.value) { if (!isEditableRequest.value || actionBusy.value) {
return return
} }
@@ -850,7 +851,7 @@ export default {
} }
async function handleAddExpenseItem() { async function handleAddExpenseItem() {
if (!isDraftRequest.value || actionBusy.value) { if (!isEditableRequest.value || actionBusy.value) {
return return
} }
@@ -884,7 +885,7 @@ export default {
} }
function triggerExpenseUpload(item) { function triggerExpenseUpload(item) {
if (!isDraftRequest.value || actionBusy.value) { if (!isEditableRequest.value || actionBusy.value) {
return return
} }
@@ -1198,10 +1199,10 @@ export default {
returnBusy.value = true returnBusy.value = true
try { try {
await returnExpenseClaim(request.value.claimId, { await returnExpenseClaim(request.value.claimId, {
reason: '详情页退回,请申请人补充后重新提交。' reason: '详情页退回,请申请人调整后重新提交。'
}) })
returnDialogOpen.value = false returnDialogOpen.value = false
toast(`${request.value.id} 已退回待补充`) toast(`${request.value.id} 已退回待提交`)
emit('request-updated', { claimId: request.value.claimId }) emit('request-updated', { claimId: request.value.claimId })
} catch (error) { } catch (error) {
toast(error?.message || '退回单据失败,请稍后重试。') toast(error?.message || '退回单据失败,请稍后重试。')
@@ -1270,6 +1271,7 @@ export default {
hasExpenseRiskColumn, hasExpenseRiskColumn,
heroFactItems, heroFactItems,
isDraftRequest, isDraftRequest,
isEditableRequest,
isTravelRequest, isTravelRequest,
locationInputPlaceholder, locationInputPlaceholder,
openAiEntry, openAiEntry,