diff --git a/server/src/app/api/v1/endpoints/employees.py b/server/src/app/api/v1/endpoints/employees.py
index ff207d0..7e834be 100644
--- a/server/src/app/api/v1/endpoints/employees.py
+++ b/server/src/app/api/v1/endpoints/employees.py
@@ -2,12 +2,19 @@ from __future__ import annotations
from typing import Annotated
-from fastapi import APIRouter, Depends, HTTPException, Query, status
+from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
+from fastapi.responses import Response
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.common import ErrorResponse
-from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead, EmployeeUpdate
+from app.schemas.employee import (
+ EmployeeCreate,
+ EmployeeImportResultRead,
+ EmployeeMetaRead,
+ EmployeeRead,
+ EmployeeUpdate,
+)
from app.services.employee import EmployeeService
router = APIRouter()
@@ -44,6 +51,67 @@ def list_employees(
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
+@router.get(
+ "/import-template",
+ summary="下载员工导入模板",
+ description="下载固定格式的员工 Excel 导入模板。",
+)
+def download_employee_import_template(db: DbSession) -> Response:
+ content = EmployeeService(db).build_import_template()
+ return Response(
+ content=content,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": 'attachment; filename="employee-import-template.xlsx"'
+ },
+ )
+
+
+@router.get(
+ "/export",
+ summary="导出员工 Excel",
+ description="按筛选条件导出员工目录 Excel 文件。",
+)
+def export_employees(
+ db: DbSession,
+ status_filter: Annotated[
+ str | None,
+ Query(alias="status", description="员工状态筛选值。"),
+ ] = None,
+ keyword: Annotated[
+ str | None,
+ Query(description="姓名、工号、邮箱等关键字模糊查询。"),
+ ] = None,
+) -> Response:
+ content = EmployeeService(db).export_employees(status=status_filter, keyword=keyword)
+ return Response(
+ content=content,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={"Content-Disposition": 'attachment; filename="employee-export.xlsx"'},
+ )
+
+
+@router.post(
+ "/import",
+ response_model=EmployeeImportResultRead,
+ summary="导入员工 Excel",
+ description="按模板批量导入员工。全部校验通过后才写入数据库,任一行有错则整批不导入。",
+)
+async def import_employees(
+ db: DbSession,
+ file: Annotated[UploadFile, File(description="待导入的员工 Excel 文件。")],
+) -> EmployeeImportResultRead:
+ filename = (file.filename or "").lower()
+ if not filename.endswith(".xlsx"):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="当前仅支持上传 .xlsx 格式的员工表格。",
+ )
+
+ content = await file.read()
+ return EmployeeService(db).import_employees(content)
+
+
@router.post(
"",
response_model=EmployeeRead,
diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py
index 2994952..437517e 100644
--- a/server/src/app/api/v1/endpoints/reimbursements.py
+++ b/server/src/app/api/v1/endpoints/reimbursements.py
@@ -16,6 +16,7 @@ from app.schemas.reimbursement import (
ExpenseClaimItemActionResponse,
ExpenseClaimItemUpdate,
ExpenseClaimRead,
+ ExpenseClaimReturnPayload,
ReimbursementCreate,
ReimbursementRead,
)
@@ -415,11 +416,11 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
return claim
-@router.delete(
- "/claims/{claim_id}",
- response_model=ExpenseClaimActionResponse,
- summary="删除个人报销草稿",
- description="删除当前登录用户可见的草稿报销单。",
+@router.post(
+ "/claims/{claim_id}/return",
+ response_model=ExpenseClaimRead,
+ summary="退回报销单",
+ description="财务人员或高级管理人员可将可见报销单退回到待补充状态。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
@@ -427,7 +428,40 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
- "description": "仅草稿状态允许删除。",
+ "description": "当前用户或单据状态不允许退回。",
+ },
+ },
+)
+def return_expense_claim(
+ claim_id: str,
+ payload: ExpenseClaimReturnPayload,
+ db: DbSession,
+ current_user: CurrentUser,
+) -> ExpenseClaimRead:
+ service = ExpenseClaimService(db)
+ try:
+ claim = service.return_claim(claim_id, current_user, reason=payload.reason)
+ except ValueError as error:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
+
+ if claim is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
+ return claim
+
+
+@router.delete(
+ "/claims/{claim_id}",
+ response_model=ExpenseClaimActionResponse,
+ summary="删除报销单",
+ description="普通用户仅可删除草稿或待补充报销单;财务人员和高级管理人员可删除可见报销单。",
+ responses={
+ status.HTTP_404_NOT_FOUND: {
+ "model": ErrorResponse,
+ "description": "报销单不存在。",
+ },
+ status.HTTP_400_BAD_REQUEST: {
+ "model": ErrorResponse,
+ "description": "当前用户或单据状态不允许删除。",
},
},
)
@@ -442,7 +476,7 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return ExpenseClaimActionResponse(
- message=f"{claim.claim_no} 草稿已删除。",
+ message=f"{claim.claim_no} 报销单已删除。",
claim_id=claim.id,
status="deleted",
)
diff --git a/server/src/app/schemas/auth.py b/server/src/app/schemas/auth.py
index b0eb257..c744476 100644
--- a/server/src/app/schemas/auth.py
+++ b/server/src/app/schemas/auth.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from typing import Any
+
from pydantic import BaseModel, EmailStr, Field
@@ -16,6 +18,12 @@ class AuthUserRead(BaseModel):
departmentName: str = ""
position: str = ""
grade: str = ""
+ employeeNo: str = ""
+ managerName: str = ""
+ location: str = ""
+ costCenter: str = ""
+ financeOwnerName: str = ""
+ riskProfile: dict[str, Any] = Field(default_factory=dict)
roleCodes: list[str] = Field(default_factory=list)
email: EmailStr | str
avatar: str
diff --git a/server/src/app/schemas/employee.py b/server/src/app/schemas/employee.py
index 2a93e8d..59d56fc 100644
--- a/server/src/app/schemas/employee.py
+++ b/server/src/app/schemas/employee.py
@@ -50,6 +50,7 @@ class EmployeeMetaRead(BaseModel):
totalEmployees: int
statusSummary: list[EmployeeStatusSummaryRead]
roleOptions: list[EmployeeRoleOptionRead]
+ organizationOptions: list[EmployeeOrganizationRead] = Field(default_factory=list)
class EmployeeRead(BaseModel):
@@ -63,6 +64,7 @@ class EmployeeRead(BaseModel):
position: str
grade: str
manager: str
+ managerEmployeeNo: str | None = None
financeOwner: str
roles: list[str] = Field(default_factory=list)
roleCodes: list[str] = Field(default_factory=list)
@@ -112,6 +114,28 @@ class EmployeeCreate(BaseModel):
return _parse_optional_date(self.join_date, "入职日期")
+class EmployeeImportErrorRead(BaseModel):
+ row: int
+ column: str
+ employeeNo: str = ""
+ message: str
+
+
+class EmployeeImportSummaryRead(BaseModel):
+ totalRows: int = 0
+ created: int = 0
+ updated: int = 0
+ errorCount: int = 0
+
+
+class EmployeeImportResultRead(BaseModel):
+ success: bool
+ message: str
+ summary: EmployeeImportSummaryRead
+ errors: list[EmployeeImportErrorRead] = Field(default_factory=list)
+ importedAt: str | None = None
+
+
class EmployeeUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
gender: str | None = Field(default=None, max_length=20)
@@ -124,6 +148,8 @@ class EmployeeUpdate(BaseModel):
grade: str | None = Field(default=None, min_length=1, max_length=20)
cost_center: str | None = Field(default=None, max_length=50)
finance_owner_name: str | None = Field(default=None, max_length=100)
+ organization_unit_code: str | None = Field(default=None, max_length=50)
+ manager_employee_no: str | None = Field(default=None, max_length=50)
role_codes: list[str] | None = None
password: str | None = Field(default=None, min_length=5, max_length=128)
diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py
index a71efe4..f42af5e 100644
--- a/server/src/app/schemas/reimbursement.py
+++ b/server/src/app/schemas/reimbursement.py
@@ -148,6 +148,10 @@ class ExpenseClaimActionResponse(BaseModel):
status: str | None = None
+class ExpenseClaimReturnPayload(BaseModel):
+ reason: str | None = Field(default=None, max_length=500)
+
+
class ExpenseClaimAttachmentActionResponse(BaseModel):
message: str
claim_id: str
diff --git a/server/src/app/schemas/user_agent.py b/server/src/app/schemas/user_agent.py
index e3b538c..8be367e 100644
--- a/server/src/app/schemas/user_agent.py
+++ b/server/src/app/schemas/user_agent.py
@@ -85,6 +85,8 @@ class UserAgentReviewRiskBrief(BaseModel):
title: str = Field(description="风险或注意事项标题。")
level: str = Field(default="info", description="级别,例如 info / warning / high。")
content: str = Field(description="面向用户展示的摘要说明。")
+ detail: str = Field(default="", description="点击风险项后展示的详细解释。")
+ suggestion: str = Field(default="", description="面向用户的处理建议。")
class UserAgentReviewSlotCard(BaseModel):
diff --git a/server/src/app/services/auth.py b/server/src/app/services/auth.py
index d946eb4..13fad71 100644
--- a/server/src/app/services/auth.py
+++ b/server/src/app/services/auth.py
@@ -1,14 +1,17 @@
from __future__ import annotations
from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from typing import Any
-from sqlalchemy import func, select
+from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.core.config import get_settings
from app.core.logging import get_logger
from app.core.security import verify_password
from app.models.employee import Employee
+from app.models.financial_record import ExpenseClaim
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
from app.services.employee import EmployeeService
from app.services.employee_seed import ROLE_DISPLAY_ORDER
@@ -34,6 +37,12 @@ class AuthenticatedUser:
department: str
position: str
grade: str
+ employee_no: str
+ manager_name: str
+ location: str
+ cost_center: str
+ finance_owner_name: str
+ risk_profile: dict[str, Any]
role_codes: list[str]
email: str
avatar: str
@@ -82,6 +91,12 @@ class AuthService:
department="",
position="系统管理员",
grade="",
+ employee_no="",
+ manager_name="",
+ location="",
+ cost_center="",
+ finance_owner_name="",
+ risk_profile={},
role_codes=["manager"],
email=admin_email or f"{admin_username}@local",
avatar=display_name[:1].upper(),
@@ -96,7 +111,11 @@ class AuthService:
stmt = (
select(Employee)
- .options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
+ .options(
+ selectinload(Employee.organization_unit),
+ selectinload(Employee.manager),
+ selectinload(Employee.roles),
+ )
.where(func.lower(Employee.email) == identifier.lower())
)
employee = self.db.execute(stmt).scalars().first()
@@ -117,20 +136,75 @@ class AuthService:
)
role_codes = [role.role_code for role in sorted_roles]
primary_role_code = role_codes[0] if role_codes else "user"
+ department = employee.organization_unit.name if employee.organization_unit is not None else ""
+ manager_name = self._resolve_manager_name(employee)
return AuthenticatedUser(
username=employee.email,
name=employee.name,
role=ROLE_LABELS.get(primary_role_code, "使用者"),
- department=employee.organization_unit.name if employee.organization_unit is not None else "",
+ department=department,
position=employee.position,
grade=employee.grade,
+ employee_no=employee.employee_no,
+ manager_name=manager_name,
+ location=employee.location or "",
+ cost_center=employee.cost_center or "",
+ finance_owner_name=employee.finance_owner_name or "",
+ risk_profile=self._build_risk_profile(employee),
role_codes=role_codes or ["user"],
email=employee.email,
avatar=(employee.name or "?")[:1].upper(),
is_admin=False,
)
+ @staticmethod
+ def _resolve_manager_name(employee: Employee) -> str:
+ if employee.manager is not None and employee.manager.name:
+ return str(employee.manager.name).strip()
+ if employee.organization_unit is not None and employee.organization_unit.manager_name:
+ return str(employee.organization_unit.manager_name).strip()
+ return ""
+
+ def _build_risk_profile(self, employee: Employee) -> dict[str, Any]:
+ since = datetime.now(UTC) - timedelta(days=90)
+ identity_values = [
+ str(employee.name or "").strip(),
+ str(employee.email or "").strip(),
+ str(employee.employee_no or "").strip(),
+ ]
+ name_candidates = [item for item in dict.fromkeys(identity_values) if item]
+ conditions = [ExpenseClaim.employee_id == employee.id]
+ if name_candidates:
+ conditions.append(ExpenseClaim.employee_name.in_(name_candidates))
+
+ stmt = (
+ select(ExpenseClaim)
+ .where(or_(*conditions), ExpenseClaim.occurred_at >= since)
+ .order_by(ExpenseClaim.occurred_at.desc())
+ .limit(30)
+ )
+ claims = list(self.db.scalars(stmt).all())
+ recent_risk_flags: list[str] = []
+ for claim in claims:
+ for flag in claim.risk_flags_json or []:
+ normalized = str(flag or "").strip()
+ if normalized and normalized not in recent_risk_flags:
+ recent_risk_flags.append(normalized)
+ if len(recent_risk_flags) >= 6:
+ break
+ if len(recent_risk_flags) >= 6:
+ break
+
+ return {
+ "windowDays": 90,
+ "totalClaimCount": len(claims),
+ "riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
+ "draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
+ "recentRiskFlags": recent_risk_flags,
+ "lastClaimAt": claims[0].occurred_at.isoformat() if claims and claims[0].occurred_at else "",
+ }
+
@staticmethod
def _serialize_user(user: AuthenticatedUser) -> AuthUserRead:
return AuthUserRead(
@@ -141,6 +215,12 @@ class AuthService:
departmentName=user.department,
position=user.position,
grade=user.grade,
+ employeeNo=user.employee_no,
+ managerName=user.manager_name,
+ location=user.location,
+ costCenter=user.cost_center,
+ financeOwnerName=user.finance_owner_name,
+ riskProfile=user.risk_profile,
roleCodes=user.role_codes,
email=user.email,
avatar=user.avatar,
diff --git a/server/src/app/services/employee.py b/server/src/app/services/employee.py
index 79545c7..ebddc4f 100644
--- a/server/src/app/services/employee.py
+++ b/server/src/app/services/employee.py
@@ -4,7 +4,7 @@ from collections import Counter
from datetime import date, datetime
from typing import Any
-from sqlalchemy import inspect, text
+from sqlalchemy import inspect, select, text
from sqlalchemy.orm import Session
from app.core.config import get_settings
@@ -20,6 +20,9 @@ from app.repositories.employee import EmployeeRepository
from app.schemas.employee import (
EmployeeCreate,
EmployeeHistoryRead,
+ EmployeeImportErrorRead,
+ EmployeeImportResultRead,
+ EmployeeImportSummaryRead,
EmployeeMetaRead,
EmployeeOrganizationRead,
EmployeeRead,
@@ -27,8 +30,16 @@ from app.schemas.employee import (
EmployeeStatusSummaryRead,
EmployeeUpdate,
)
+from app.services.employee_spreadsheet import (
+ EmployeeImportRow,
+ EmployeeSpreadsheetError,
+ build_export_workbook_bytes,
+ build_import_template_bytes,
+ parse_employee_workbook,
+)
from app.services.employee_seed import (
EMPLOYEE_DEFINITIONS,
+ EMPLOYEE_PROFILE_REPAIRS,
ORGANIZATION_DEFINITIONS,
ROLE_DEFINITIONS,
ROLE_DISPLAY_ORDER,
@@ -37,6 +48,7 @@ from app.services.employee_seed import (
logger = get_logger("app.services.employee")
DEFAULT_EMPLOYEE_PASSWORD = "123456"
+MAX_EMPLOYEE_CHANGE_LOGS = 5
STATUS_TONE_MAP = {
"在职": "success",
@@ -57,7 +69,9 @@ def prepare_employee_directory() -> None:
session_factory = get_session_factory()
with session_factory() as db:
- EmployeeService(db).ensure_directory_ready()
+ service = EmployeeService(db)
+ service.ensure_directory_ready()
+ service.apply_profile_repairs()
class EmployeeService:
@@ -120,10 +134,27 @@ class EmployeeService:
for role in self._sorted_roles(self.repository.list_roles())
]
+ organization_options = [
+ EmployeeOrganizationRead(
+ id=unit.id,
+ code=unit.unit_code,
+ name=unit.name,
+ unitType=unit.unit_type,
+ costCenter=unit.cost_center,
+ location=unit.location,
+ managerName=unit.manager_name,
+ )
+ for unit in sorted(
+ self.repository.list_organization_units(),
+ key=lambda item: item.name,
+ )
+ ]
+
return EmployeeMetaRead(
totalEmployees=len(employees),
statusSummary=status_summary,
roleOptions=role_options,
+ organizationOptions=organization_options,
)
def create_employee(self, payload: EmployeeCreate) -> EmployeeRead:
@@ -261,6 +292,43 @@ class EmployeeService:
employee.finance_owner_name = finance_owner_name
changed_fields.append("财务归口")
+ if "organization_unit_code" in payload.model_fields_set:
+ organization_code = self._normalize_optional_text(payload.organization_unit_code)
+ current_code = (
+ employee.organization_unit.unit_code if employee.organization_unit else None
+ )
+ if organization_code != current_code:
+ if organization_code:
+ organization = self.repository.get_organization_by_code(organization_code)
+ if organization is None:
+ raise ValueError(f"部门编码 {organization_code} 不存在")
+ employee.organization_unit = organization
+ else:
+ employee.organization_unit = None
+ changed_fields.append("所属部门")
+
+ if "manager_employee_no" in payload.model_fields_set:
+ manager_employee_no = self._normalize_optional_text(payload.manager_employee_no)
+ current_manager_no = employee.manager.employee_no if employee.manager else None
+
+ if manager_employee_no:
+ if manager_employee_no == employee.employee_no:
+ raise ValueError("直属上级不能是员工本人")
+
+ manager = self.repository.get_by_employee_no(manager_employee_no)
+ if manager is None:
+ raise ValueError(f"直属上级工号 {manager_employee_no} 不存在")
+
+ if manager_employee_no != current_manager_no:
+ employee.manager = manager
+ changed_fields.append("直属上级")
+ elif current_manager_no is not None:
+ employee.manager = None
+ changed_fields.append("直属上级")
+
+ role_changed = False
+ sorted_roles: list[Role] = []
+
if "role_codes" in payload.model_fields_set and payload.role_codes is not None:
requested_codes = list(dict.fromkeys(payload.role_codes))
roles: list[Role] = []
@@ -280,7 +348,7 @@ class EmployeeService:
current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))]
if next_role_codes != current_role_codes:
employee.roles = sorted_roles
- changed_fields.append("系统角色")
+ role_changed = True
if "password" in payload.model_fields_set and payload.password:
password = payload.password.strip()
@@ -289,7 +357,7 @@ class EmployeeService:
employee.password_hash = hash_password(password)
password_changed = True
- if not changed_fields and not password_changed:
+ if not changed_fields and not password_changed and not role_changed:
return self._serialize_employee(employee)
now = datetime.now()
@@ -303,13 +371,25 @@ class EmployeeService:
occurred_at=now,
)
+ if role_changed:
+ role_labels = "、".join(role.name for role in sorted_roles)
+ self._append_change_log(
+ employee,
+ action=f"更新系统角色({role_labels})",
+ occurred_at=now,
+ )
+
if password_changed:
self._append_change_log(employee, action="重置员工登录密码", occurred_at=now)
- saved = self.repository.save(employee)
- hydrated = self.repository.get(saved.id)
- logger.info("Updated employee id=%s fields=%s", employee.id, ",".join(changed_fields))
- return self._serialize_employee(hydrated or saved)
+ hydrated = self._save_employee_and_reload(employee)
+ logger.info(
+ "Updated employee id=%s fields=%s role_changed=%s",
+ employee.id,
+ ",".join(changed_fields),
+ role_changed,
+ )
+ return self._serialize_employee(hydrated)
def disable_employee(self, employee_id: str) -> EmployeeRead:
self.ensure_directory_ready()
@@ -328,10 +408,9 @@ class EmployeeService:
employee.spotlight = False
self._append_change_log(employee, action="停用员工账号", occurred_at=now)
- saved = self.repository.save(employee)
- hydrated = self.repository.get(saved.id)
+ hydrated = self._save_employee_and_reload(employee)
logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no)
- return self._serialize_employee(hydrated or saved)
+ return self._serialize_employee(hydrated)
def enable_employee(self, employee_id: str) -> EmployeeRead:
self.ensure_directory_ready()
@@ -349,10 +428,299 @@ class EmployeeService:
employee.last_sync_at = now
self._append_change_log(employee, action="启用员工账号", occurred_at=now)
- saved = self.repository.save(employee)
- hydrated = self.repository.get(saved.id)
+ hydrated = self._save_employee_and_reload(employee)
logger.info("Enabled employee id=%s no=%s", employee.id, employee.employee_no)
- return self._serialize_employee(hydrated or saved)
+ return self._serialize_employee(hydrated)
+
+ def build_import_template(self) -> bytes:
+ self.ensure_directory_ready()
+ return build_import_template_bytes()
+
+ def export_employees(self, status: str | None = None, keyword: str | None = None) -> bytes:
+ self.ensure_directory_ready()
+ employees = self.repository.list(status=status, keyword=keyword)
+ rows: list[list[str]] = []
+
+ for employee in employees:
+ organization = employee.organization_unit
+ role_codes = ",".join(role.role_code for role in self._sorted_roles(list(employee.roles)))
+ rows.append(
+ [
+ employee.employee_no,
+ employee.name,
+ employee.email,
+ employee.gender or "",
+ self._format_date(employee.birth_date) or "",
+ employee.phone or "",
+ self._format_date(employee.join_date) or "",
+ employee.location or "",
+ employee.position,
+ employee.grade,
+ organization.unit_code if organization else "",
+ employee.manager.employee_no if employee.manager else "",
+ employee.finance_owner_name or "",
+ employee.cost_center or "",
+ employee.employment_status,
+ role_codes,
+ ]
+ )
+
+ return build_export_workbook_bytes(rows)
+
+ def import_employees(self, content: bytes, actor: str = "系统管理员") -> EmployeeImportResultRead:
+ self.ensure_directory_ready()
+ parsed_rows, parse_errors = parse_employee_workbook(content)
+ if parse_errors:
+ return self._build_import_failure(parse_errors, total_rows=len(parsed_rows))
+
+ validation_errors = self._validate_import_rows(parsed_rows)
+ if validation_errors:
+ return self._build_import_failure(validation_errors, total_rows=len(parsed_rows))
+
+ try:
+ summary = self._apply_import_rows(parsed_rows, actor=actor)
+ except Exception:
+ self.db.rollback()
+ logger.exception("Employee import failed during database write")
+ raise
+
+ imported_at = self._format_datetime(datetime.now()) or ""
+ message = f"导入成功:新增 {summary['created']} 人,更新 {summary['updated']} 人。"
+ logger.info(
+ "Imported employees created=%d updated=%d total=%d",
+ summary["created"],
+ summary["updated"],
+ len(parsed_rows),
+ )
+ return EmployeeImportResultRead(
+ success=True,
+ message=message,
+ summary=EmployeeImportSummaryRead(
+ totalRows=len(parsed_rows),
+ created=summary["created"],
+ updated=summary["updated"],
+ errorCount=0,
+ ),
+ errors=[],
+ importedAt=imported_at,
+ )
+
+ def _validate_import_rows(
+ self, rows: list[EmployeeImportRow]
+ ) -> list[EmployeeSpreadsheetError]:
+ errors: list[EmployeeSpreadsheetError] = []
+ employee_nos_in_file: dict[str, int] = {}
+ emails_in_file: dict[str, int] = {}
+
+ roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
+ organizations_by_code = {
+ unit.unit_code: unit for unit in self.repository.list_organization_units()
+ }
+ employees_by_no = {
+ employee.employee_no: employee for employee in self.repository.list()
+ }
+ import_employee_nos = {row.employee_no for row in rows}
+
+ for row in rows:
+ if row.employee_no in employee_nos_in_file:
+ errors.append(
+ EmployeeSpreadsheetError(
+ row=row.row_number,
+ column="员工编号*",
+ employee_no=row.employee_no,
+ message=f"员工编号 {row.employee_no} 在文件中重复。",
+ )
+ )
+ else:
+ employee_nos_in_file[row.employee_no] = row.row_number
+
+ if row.email in emails_in_file:
+ errors.append(
+ EmployeeSpreadsheetError(
+ row=row.row_number,
+ column="邮箱*",
+ employee_no=row.employee_no,
+ message=f"邮箱 {row.email} 在文件中重复。",
+ )
+ )
+ else:
+ emails_in_file[row.email] = row.row_number
+
+ existing_by_email = self.repository.get_by_email(row.email)
+ if existing_by_email is not None and existing_by_email.employee_no != row.employee_no:
+ errors.append(
+ EmployeeSpreadsheetError(
+ row=row.row_number,
+ column="邮箱*",
+ employee_no=row.employee_no,
+ message=(
+ f"邮箱 {row.email} 已被员工 "
+ f"{existing_by_email.employee_no} 使用。"
+ ),
+ )
+ )
+
+ if row.organization_unit_code and row.organization_unit_code not in organizations_by_code:
+ errors.append(
+ EmployeeSpreadsheetError(
+ row=row.row_number,
+ column="部门编码",
+ employee_no=row.employee_no,
+ message=f"部门编码 {row.organization_unit_code} 不存在。",
+ )
+ )
+
+ if row.manager_employee_no:
+ manager_exists = (
+ row.manager_employee_no in employees_by_no
+ or row.manager_employee_no in import_employee_nos
+ )
+ if not manager_exists:
+ errors.append(
+ EmployeeSpreadsheetError(
+ row=row.row_number,
+ column="直属上级工号",
+ employee_no=row.employee_no,
+ message=f"直属上级工号 {row.manager_employee_no} 不存在。",
+ )
+ )
+ if row.manager_employee_no == row.employee_no:
+ errors.append(
+ EmployeeSpreadsheetError(
+ row=row.row_number,
+ column="直属上级工号",
+ employee_no=row.employee_no,
+ message="直属上级不能是员工本人。",
+ )
+ )
+
+ invalid_role_codes = [
+ code for code in row.role_codes if code not in roles_by_code
+ ]
+ if invalid_role_codes:
+ errors.append(
+ EmployeeSpreadsheetError(
+ row=row.row_number,
+ column="角色编码",
+ employee_no=row.employee_no,
+ message=f"角色不存在:{'、'.join(invalid_role_codes)}。",
+ )
+ )
+
+ return errors
+
+ def _apply_import_rows(
+ self,
+ rows: list[EmployeeImportRow],
+ *,
+ actor: str,
+ ) -> dict[str, int]:
+ roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
+ organizations_by_code = {
+ unit.unit_code: unit for unit in self.repository.list_organization_units()
+ }
+ employees_by_no = {
+ employee.employee_no: employee for employee in self.repository.list()
+ }
+ created = 0
+ updated = 0
+ now = datetime.now()
+
+ try:
+ for row in rows:
+ employee = employees_by_no.get(row.employee_no)
+ is_new = employee is None
+
+ if is_new:
+ employee = Employee(
+ employee_no=row.employee_no,
+ name=row.name,
+ email=row.email,
+ password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
+ )
+ self.db.add(employee)
+ employees_by_no[row.employee_no] = employee
+ created += 1
+ else:
+ updated += 1
+
+ employee.name = row.name
+ employee.email = row.email
+ employee.gender = row.gender
+ employee.birth_date = row.birth_date
+ employee.phone = row.phone
+ employee.join_date = row.join_date
+ employee.location = row.location
+ employee.position = row.position
+ employee.grade = row.grade
+ employee.finance_owner_name = row.finance_owner_name
+ employee.cost_center = row.cost_center
+ employee.employment_status = row.employment_status
+ employee.sync_state = "已同步"
+ employee.last_sync_at = now
+
+ if row.organization_unit_code:
+ employee.organization_unit = organizations_by_code[row.organization_unit_code]
+ else:
+ employee.organization_unit = None
+
+ employee.roles = self._sorted_roles(
+ [roles_by_code[code] for code in row.role_codes if code in roles_by_code]
+ )
+
+ action = (
+ "通过 Excel 导入新建员工档案"
+ if is_new
+ else "通过 Excel 导入更新员工档案"
+ )
+ self._append_change_log(employee, action=action, owner=actor, occurred_at=now)
+
+ self.db.flush()
+
+ for row in rows:
+ employee = employees_by_no[row.employee_no]
+ if row.manager_employee_no:
+ employee.manager = employees_by_no.get(row.manager_employee_no)
+ else:
+ employee.manager = None
+
+ self.db.commit()
+ except Exception:
+ self.db.rollback()
+ raise
+
+ return {"created": created, "updated": updated}
+
+ def _build_import_failure(
+ self,
+ errors: list[EmployeeSpreadsheetError],
+ *,
+ total_rows: int,
+ ) -> EmployeeImportResultRead:
+ error_reads = [
+ EmployeeImportErrorRead(
+ row=item.row,
+ column=item.column,
+ employeeNo=item.employee_no,
+ message=item.message,
+ )
+ for item in errors
+ ]
+ return EmployeeImportResultRead(
+ success=False,
+ message=(
+ f"导入未执行:共发现 {len(error_reads)} 处错误,请修正后重新导入。"
+ "原有员工数据未变更。"
+ ),
+ summary=EmployeeImportSummaryRead(
+ totalRows=total_rows,
+ created=0,
+ updated=0,
+ errorCount=len(error_reads),
+ ),
+ errors=error_reads,
+ importedAt=None,
+ )
def _seed_roles(self) -> None:
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
@@ -471,6 +839,69 @@ class EmployeeService:
self.db.flush()
+ def apply_profile_repairs(self) -> None:
+ """Apply one-off demo profile repairs. Intended for startup/bootstrap only."""
+ try:
+ self._repair_employee_profiles()
+ self._trim_all_employee_change_logs()
+ self.db.commit()
+ except Exception:
+ self.db.rollback()
+ logger.exception("Failed to apply employee profile repairs")
+ raise
+
+ def _repair_employee_profiles(self) -> None:
+ if not EMPLOYEE_PROFILE_REPAIRS:
+ return
+
+ employees = self.repository.list()
+ employees_by_email = {employee.email.lower(): employee for employee in employees if employee.email}
+ employees_by_no = {employee.employee_no: employee for employee in employees if employee.employee_no}
+ roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
+ organizations_by_code = {
+ unit.unit_code: unit for unit in self.repository.list_organization_units()
+ }
+
+ for definition in EMPLOYEE_PROFILE_REPAIRS:
+ email = str(definition.get("email") or "").strip().lower()
+ employee_no = str(definition.get("employee_no") or "").strip()
+ employee = employees_by_email.get(email) or employees_by_no.get(employee_no)
+ if employee is None:
+ continue
+
+ for field_name in (
+ "position",
+ "grade",
+ "location",
+ "cost_center",
+ "finance_owner_name",
+ "employment_status",
+ "sync_state",
+ ):
+ value = definition.get(field_name)
+ if value:
+ setattr(employee, field_name, value)
+
+ organization_code = definition.get("organization_unit_code")
+ if organization_code:
+ employee.organization_unit = organizations_by_code.get(organization_code)
+
+ manager_employee_no = definition.get("manager_employee_no")
+ if manager_employee_no:
+ employee.manager = employees_by_no.get(manager_employee_no)
+
+ if not employee.password_hash:
+ employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD)
+
+ role_codes = [item for item in definition.get("role_codes", []) if item in roles_by_code]
+ if role_codes:
+ merged_roles = {role.role_code: role for role in employee.roles}
+ for role_code in role_codes:
+ merged_roles[role_code] = roles_by_code[role_code]
+ employee.roles = self._sorted_roles(list(merged_roles.values()))
+
+ self.db.flush()
+
def _prune_extra_seed_employees(self) -> None:
if not EXTRA_SEED_EMPLOYEE_NOS:
return
@@ -530,6 +961,12 @@ class EmployeeService:
)
existing_keys.add(identity)
+ def _save_employee_and_reload(self, employee: Employee) -> Employee:
+ saved = self.repository.save(employee)
+ self._trim_employee_change_logs(saved.id)
+ self.db.commit()
+ return self.repository.get(saved.id) or saved
+
def _append_change_log(
self,
employee: Employee,
@@ -546,6 +983,26 @@ class EmployeeService:
)
)
+ def _trim_all_employee_change_logs(self) -> None:
+ for employee in self.repository.list():
+ self._trim_employee_change_logs(employee.id)
+
+ def _sorted_change_logs(self, employee: Employee) -> list[EmployeeChangeLog]:
+ return sorted(employee.change_logs, key=lambda item: item.occurred_at, reverse=True)
+
+ def _trim_employee_change_logs(self, employee_id: str) -> None:
+ stmt = (
+ select(EmployeeChangeLog)
+ .where(EmployeeChangeLog.employee_id == employee_id)
+ .order_by(EmployeeChangeLog.occurred_at.desc())
+ )
+ logs = list(self.db.execute(stmt).scalars().all())
+ if len(logs) <= MAX_EMPLOYEE_CHANGE_LOGS:
+ return
+
+ for stale in logs[MAX_EMPLOYEE_CHANGE_LOGS:]:
+ self.db.delete(stale)
+
def _serialize_employee(self, employee: Employee) -> EmployeeRead:
organization = employee.organization_unit
roles = self._sorted_roles(list(employee.roles))
@@ -556,10 +1013,10 @@ class EmployeeService:
EmployeeHistoryRead(
action=item.action,
owner=item.owner,
- time=self._format_datetime(item.occurred_at) or "",
- occurredAt=self._format_datetime(item.occurred_at) or "",
+ time=self._format_history_datetime(item.occurred_at),
+ occurredAt=self._format_history_datetime(item.occurred_at),
)
- for item in employee.change_logs
+ for item in self._sorted_change_logs(employee)[:MAX_EMPLOYEE_CHANGE_LOGS]
]
return EmployeeRead(
@@ -571,6 +1028,7 @@ class EmployeeService:
position=employee.position,
grade=employee.grade,
manager=employee.manager.name if employee.manager else "CEO",
+ managerEmployeeNo=employee.manager.employee_no if employee.manager else None,
financeOwner=employee.finance_owner_name or "",
roles=role_labels,
roleCodes=role_codes,
@@ -654,6 +1112,15 @@ class EmployeeService:
return None
return value.strftime("%Y-%m-%d %H:%M")
+ @staticmethod
+ def _format_history_datetime(value: datetime | None) -> str:
+ if value is None:
+ return ""
+ return (
+ f"{value.year}年{value.month}月{value.day}日"
+ f"{value.hour}时{value.minute}分{value.second}秒"
+ )
+
@staticmethod
def _calculate_age(birth_date: date | None) -> int | None:
if birth_date is None:
diff --git a/server/src/app/services/employee_seed.py b/server/src/app/services/employee_seed.py
index 84c2660..817608d 100644
--- a/server/src/app/services/employee_seed.py
+++ b/server/src/app/services/employee_seed.py
@@ -144,6 +144,24 @@ ORGANIZATION_DEFINITIONS = [
},
]
+EMPLOYEE_PROFILE_REPAIRS = [
+ {
+ "employee_no": "E90919",
+ "name": "曹笑竹",
+ "email": "caoxiaozhu@xf.com",
+ "location": "武汉",
+ "position": "财务智能化产品经理",
+ "grade": "P5",
+ "organization_unit_code": "RND-CENTER",
+ "manager_employee_no": "E11745",
+ "finance_owner_name": "研发财务BP",
+ "cost_center": "CC-6112",
+ "employment_status": "在职",
+ "sync_state": "已同步",
+ "role_codes": ["user"],
+ },
+]
+
EMPLOYEE_DEFINITIONS = [
{
"employee_no": "E10018",
diff --git a/server/src/app/services/employee_spreadsheet.py b/server/src/app/services/employee_spreadsheet.py
new file mode 100644
index 0000000..364203f
--- /dev/null
+++ b/server/src/app/services/employee_spreadsheet.py
@@ -0,0 +1,368 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import date, datetime
+from email.utils import parseaddr
+from io import BytesIO
+from typing import Any
+
+from openpyxl import Workbook, load_workbook
+
+EMPLOYEE_SHEET_NAME = "员工目录"
+INSTRUCTION_SHEET_NAME = "填表说明"
+
+EMPLOYEE_HEADERS: tuple[str, ...] = (
+ "员工编号*",
+ "姓名*",
+ "邮箱*",
+ "性别",
+ "出生日期",
+ "手机号",
+ "入职日期",
+ "办公地点",
+ "岗位*",
+ "职级*",
+ "部门编码",
+ "直属上级工号",
+ "财务归口",
+ "成本中心",
+ "在职状态*",
+ "角色编码",
+)
+
+HEADER_TO_FIELD: dict[str, str] = {
+ "员工编号*": "employee_no",
+ "姓名*": "name",
+ "邮箱*": "email",
+ "性别": "gender",
+ "出生日期": "birth_date",
+ "手机号": "phone",
+ "入职日期": "join_date",
+ "办公地点": "location",
+ "岗位*": "position",
+ "职级*": "grade",
+ "部门编码": "organization_unit_code",
+ "直属上级工号": "manager_employee_no",
+ "财务归口": "finance_owner_name",
+ "成本中心": "cost_center",
+ "在职状态*": "employment_status",
+ "角色编码": "role_codes",
+}
+
+VALID_EMPLOYMENT_STATUSES = {"在职", "试用中", "停用"}
+DEFAULT_ROLE_CODES = ("user",)
+MAX_IMPORT_ROWS = 2000
+MAX_IMPORT_BYTES = 5 * 1024 * 1024
+
+
+@dataclass(frozen=True)
+class EmployeeImportRow:
+ row_number: int
+ employee_no: str
+ name: str
+ email: str
+ gender: str | None
+ birth_date: date | None
+ phone: str | None
+ join_date: date | None
+ location: str | None
+ position: str
+ grade: str
+ organization_unit_code: str | None
+ manager_employee_no: str | None
+ finance_owner_name: str | None
+ cost_center: str | None
+ employment_status: str
+ role_codes: list[str]
+
+
+@dataclass(frozen=True)
+class EmployeeSpreadsheetError:
+ row: int
+ column: str
+ employee_no: str
+ message: str
+
+
+def build_import_template_bytes() -> bytes:
+ workbook = Workbook()
+ sheet = workbook.active
+ sheet.title = EMPLOYEE_SHEET_NAME
+ sheet.append(list(EMPLOYEE_HEADERS))
+
+ instructions = workbook.create_sheet(INSTRUCTION_SHEET_NAME)
+ instructions.append(["字段", "说明"])
+ instruction_rows = [
+ ("员工编号*", "必填,全局唯一,导入时用于判断新建或覆盖。"),
+ ("姓名*", "必填。"),
+ ("邮箱*", "必填,全局唯一。"),
+ ("性别", "可选:男、女,留空表示不填写。"),
+ ("出生日期", "可选,格式 YYYY-MM-DD。"),
+ ("手机号", "可选。"),
+ ("入职日期", "可选,格式 YYYY-MM-DD。"),
+ ("办公地点", "可选。"),
+ ("岗位*", "必填。"),
+ ("职级*", "必填,例如 P3、P5。"),
+ ("部门编码", "可选,须与系统组织编码一致,例如 FIN-SSC。"),
+ ("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"),
+ ("财务归口", "可选。"),
+ ("成本中心", "可选。"),
+ ("在职状态*", "必填:在职、试用中、停用。"),
+ ("角色编码", "可选,多个角色用英文逗号分隔,例如 user,finance;留空默认为 user。"),
+ ("导入规则", "全部校验通过后才写入数据库;任一行有错则整批不导入,原有数据保持不变。"),
+ ]
+ for row in instruction_rows:
+ instructions.append(list(row))
+
+ buffer = BytesIO()
+ workbook.save(buffer)
+ return buffer.getvalue()
+
+
+def build_export_workbook_bytes(rows: list[list[Any]]) -> 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 parse_employee_workbook(content: bytes) -> tuple[list[EmployeeImportRow], list[EmployeeSpreadsheetError]]:
+ errors: list[EmployeeSpreadsheetError] = []
+
+ if not content:
+ return [], [
+ EmployeeSpreadsheetError(
+ row=0,
+ column="文件",
+ employee_no="",
+ message="上传文件不能为空。",
+ )
+ ]
+
+ if len(content) > MAX_IMPORT_BYTES:
+ return [], [
+ EmployeeSpreadsheetError(
+ row=0,
+ column="文件",
+ employee_no="",
+ message=f"文件大小不能超过 {MAX_IMPORT_BYTES // (1024 * 1024)}MB。",
+ )
+ ]
+
+ try:
+ workbook = load_workbook(filename=BytesIO(content), read_only=True, data_only=True)
+ except Exception:
+ return [], [
+ EmployeeSpreadsheetError(
+ row=0,
+ column="文件",
+ employee_no="",
+ message="无法解析 Excel 文件,请使用系统提供的 .xlsx 模板。",
+ )
+ ]
+
+ if EMPLOYEE_SHEET_NAME not in workbook.sheetnames:
+ return [], [
+ EmployeeSpreadsheetError(
+ row=0,
+ column="工作表",
+ employee_no="",
+ message=f"缺少工作表“{EMPLOYEE_SHEET_NAME}”。",
+ )
+ ]
+
+ worksheet = workbook[EMPLOYEE_SHEET_NAME]
+ raw_rows = list(worksheet.iter_rows(values_only=True))
+ if not raw_rows:
+ return [], [
+ EmployeeSpreadsheetError(
+ row=0,
+ column="文件",
+ employee_no="",
+ message="Excel 中没有可导入的数据行。",
+ )
+ ]
+
+ header_row = [_normalize_cell(value) for value in raw_rows[0]]
+ if list(header_row) != list(EMPLOYEE_HEADERS):
+ return [], [
+ EmployeeSpreadsheetError(
+ row=1,
+ column="表头",
+ employee_no="",
+ message="表头与员工导入模板不一致,请下载最新模板后重试。",
+ )
+ ]
+
+ parsed_rows: list[EmployeeImportRow] = []
+ for index, raw_row in enumerate(raw_rows[1:], start=2):
+ if index - 1 > MAX_IMPORT_ROWS:
+ errors.append(
+ EmployeeSpreadsheetError(
+ row=index,
+ column="文件",
+ employee_no="",
+ message=f"单次最多导入 {MAX_IMPORT_ROWS} 行数据。",
+ )
+ )
+ break
+
+ if _is_empty_data_row(raw_row):
+ continue
+
+ row_errors, parsed = _parse_data_row(index, raw_row)
+ errors.extend(row_errors)
+ if parsed is not None:
+ parsed_rows.append(parsed)
+
+ if not parsed_rows and not errors:
+ errors.append(
+ EmployeeSpreadsheetError(
+ row=0,
+ column="文件",
+ employee_no="",
+ message="Excel 中没有可导入的数据行。",
+ )
+ )
+
+ return parsed_rows, errors
+
+
+def _parse_data_row(
+ row_number: int,
+ raw_row: tuple[Any, ...],
+) -> tuple[list[EmployeeSpreadsheetError], EmployeeImportRow | None]:
+ errors: list[EmployeeSpreadsheetError] = []
+ values = {
+ HEADER_TO_FIELD[header]: _normalize_cell(raw_row[index] if index < len(raw_row) else "")
+ for index, header in enumerate(EMPLOYEE_HEADERS)
+ }
+ employee_no = values["employee_no"]
+
+ def add_error(column: str, message: str) -> None:
+ errors.append(
+ EmployeeSpreadsheetError(
+ row=row_number,
+ column=column,
+ employee_no=employee_no,
+ message=message,
+ )
+ )
+
+ if not employee_no:
+ add_error("员工编号*", "员工编号不能为空。")
+
+ name = values["name"]
+ if not name:
+ add_error("姓名*", "姓名不能为空。")
+
+ email = values["email"].lower() if values["email"] else ""
+ if not email:
+ add_error("邮箱*", "邮箱不能为空。")
+ elif not _is_valid_email(email):
+ add_error("邮箱*", "邮箱格式不正确。")
+
+ position = values["position"]
+ if not position:
+ add_error("岗位*", "岗位不能为空。")
+
+ grade = values["grade"]
+ if not grade:
+ add_error("职级*", "职级不能为空。")
+
+ employment_status = values["employment_status"]
+ if not employment_status:
+ add_error("在职状态*", "在职状态不能为空。")
+ elif employment_status not in VALID_EMPLOYMENT_STATUSES:
+ add_error("在职状态*", "在职状态必须为:在职、试用中、停用。")
+
+ gender = values["gender"] or None
+ if gender and gender not in {"男", "女"}:
+ add_error("性别", "性别只能填写:男、女,或留空。")
+
+ birth_date, birth_error = _parse_optional_date(values["birth_date"], "出生日期")
+ if birth_error:
+ add_error("出生日期", birth_error)
+
+ join_date, join_error = _parse_optional_date(values["join_date"], "入职日期")
+ if join_error:
+ add_error("入职日期", join_error)
+
+ role_codes = _parse_role_codes(values["role_codes"])
+ if values["role_codes"] and not role_codes:
+ add_error("角色编码", "角色编码不能为空片段,多个角色请用英文逗号分隔。")
+
+ if errors:
+ return errors, None
+
+ return (
+ [],
+ EmployeeImportRow(
+ row_number=row_number,
+ employee_no=employee_no,
+ name=name,
+ email=email,
+ gender=gender,
+ birth_date=birth_date,
+ phone=values["phone"] or None,
+ join_date=join_date,
+ location=values["location"] or None,
+ position=position,
+ grade=grade,
+ organization_unit_code=values["organization_unit_code"] or None,
+ manager_employee_no=values["manager_employee_no"] or None,
+ finance_owner_name=values["finance_owner_name"] or None,
+ cost_center=values["cost_center"] or None,
+ employment_status=employment_status,
+ role_codes=role_codes or list(DEFAULT_ROLE_CODES),
+ ),
+ )
+
+
+def _parse_role_codes(value: str) -> list[str]:
+ if not value:
+ return []
+ codes = [item.strip() for item in value.replace(",", ",").split(",")]
+ return list(dict.fromkeys(code for code in codes if code))
+
+
+def _parse_optional_date(value: str, label: str) -> tuple[date | None, str | None]:
+ if not value:
+ return None, None
+
+ if isinstance(value, datetime):
+ return value.date(), None
+
+ if isinstance(value, date):
+ return value, None
+
+ text = str(value).strip()
+ try:
+ return datetime.strptime(text, "%Y-%m-%d").date(), None
+ except ValueError:
+ return None, f"{label}格式必须为 YYYY-MM-DD。"
+
+
+def _is_valid_email(value: str) -> bool:
+ _, address = parseaddr(value)
+ return bool(address) and "@" in address
+
+
+def _normalize_cell(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, datetime):
+ return value.strftime("%Y-%m-%d")
+ if isinstance(value, date):
+ return value.strftime("%Y-%m-%d")
+ return str(value).strip()
+
+
+def _is_empty_data_row(raw_row: tuple[Any, ...]) -> bool:
+ return not any(_normalize_cell(value) for value in raw_row)
diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py
index b2a0229..ccacf5d 100644
--- a/server/src/app/services/expense_claims.py
+++ b/server/src/app/services/expense_claims.py
@@ -54,7 +54,7 @@ EXPENSE_TYPE_LABELS = {
"welfare": "福利",
}
-PRIVILEGED_CLAIM_ROLE_CODES = {"finance"}
+PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
MAX_DRAFT_CLAIMS_PER_USER = 3
LOCATION_REQUIRED_EXPENSE_TYPES = {
@@ -814,17 +814,19 @@ class ExpenseClaimService:
"invoice_count": int(claim.invoice_count or 0),
}
- def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
- claim = self.get_claim(claim_id, current_user)
- if claim is None:
- return None
-
- self._ensure_draft_claim(claim)
- before_json = self._serialize_claim(claim)
- resource_id = claim.id
-
- self._delete_claim_attachment_root(claim.id)
- self.db.delete(claim)
+ def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
+ claim = self.get_claim(claim_id, current_user)
+ if claim is None:
+ return None
+
+ if not self._has_privileged_claim_access(current_user):
+ self._ensure_draft_claim(claim)
+
+ before_json = self._serialize_claim(claim)
+ resource_id = claim.id
+
+ self._delete_claim_attachment_root(claim.id)
+ self.db.delete(claim)
self.db.commit()
self.audit_service.log_action(
@@ -835,10 +837,60 @@ class ExpenseClaimService:
before_json=before_json,
after_json=None,
)
-
- return claim
-
- def upsert_draft_from_ontology(
+
+ return claim
+
+ def return_claim(
+ self,
+ claim_id: str,
+ current_user: CurrentUserContext,
+ *,
+ reason: str | None = None,
+ ) -> ExpenseClaim | None:
+ claim = self.get_claim(claim_id, current_user)
+ if claim is None:
+ return None
+
+ if not self._has_privileged_claim_access(current_user):
+ raise ValueError("只有财务人员或高级管理人员可以退回报销单。")
+
+ normalized_status = str(claim.status or "").strip().lower()
+ if normalized_status == "draft":
+ raise ValueError("草稿状态无需退回。")
+ if normalized_status in {"approved", "completed", "paid"}:
+ raise ValueError("已完成单据不允许退回。")
+
+ before_json = self._serialize_claim(claim)
+ operator = current_user.name or current_user.username
+ return_reason = str(reason or "").strip()
+ return_flag = {
+ "source": "manual_return",
+ "severity": "medium",
+ "label": "人工退回",
+ "message": return_reason or f"{operator} 已退回该报销单,请申请人补充后重新提交。",
+ "operator": operator,
+ "created_at": datetime.now(UTC).isoformat(),
+ }
+
+ claim.status = "returned"
+ claim.approval_stage = "待补充"
+ claim.risk_flags_json = [*list(claim.risk_flags_json or []), return_flag]
+
+ self.db.commit()
+ self.db.refresh(claim)
+
+ self.audit_service.log_action(
+ actor=operator,
+ action="expense_claim.return",
+ resource_type="expense_claim",
+ resource_id=claim.id,
+ before_json=before_json,
+ after_json=self._serialize_claim(claim),
+ )
+
+ return claim
+
+ def upsert_draft_from_ontology(
self,
*,
run_id: str,
diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py
index 0809cff..11087eb 100644
--- a/server/src/app/services/user_agent.py
+++ b/server/src/app/services/user_agent.py
@@ -530,8 +530,16 @@ class UserAgentService:
"entry_source": payload.context_json.get("entry_source"),
"user_name": payload.context_json.get("name"),
"user_role": payload.context_json.get("role"),
+ "user_department": payload.context_json.get("department_name")
+ or payload.context_json.get("department"),
"user_position": payload.context_json.get("position"),
"user_grade": payload.context_json.get("grade"),
+ "employee_no": payload.context_json.get("employee_no"),
+ "manager_name": payload.context_json.get("manager_name"),
+ "employee_location": payload.context_json.get("employee_location"),
+ "cost_center": payload.context_json.get("cost_center"),
+ "finance_owner_name": payload.context_json.get("finance_owner_name"),
+ "employee_risk_profile": payload.context_json.get("employee_risk_profile", {}),
"user_role_codes": payload.context_json.get("role_codes", []),
"is_admin": bool(payload.context_json.get("is_admin")),
"request_context": payload.context_json.get("request_context"),
@@ -2178,18 +2186,36 @@ class UserAgentService:
title="AI预审未通过",
level="high",
content=reason,
+ detail=(
+ "该项属于提交审批前的阻断条件。系统会先要求补齐基础字段、附件或业务说明,"
+ "否则审批人无法判断成本归属、业务真实性或票据有效性。"
+ ),
+ suggestion="按提示补齐对应信息;如果业务场景本身合理,请补充说明或佐证附件后再提交。",
)
)
- employee_name = self._collect_entity_values(payload).get("employee_name") or str(
- payload.context_json.get("name") or ""
- ).strip()
+ employee = self._resolve_employee_profile(payload)
+ employee_name = (
+ str(employee.name).strip()
+ if employee is not None and employee.name
+ else self._collect_entity_values(payload).get("employee_name")
+ or str(payload.context_json.get("name") or "").strip()
+ )
if employee_name:
since = datetime.now(UTC) - timedelta(days=90)
- stmt = select(ExpenseClaim).where(
- ExpenseClaim.employee_name == employee_name,
- ExpenseClaim.occurred_at >= since,
- )
+ claim_identity_conditions = [ExpenseClaim.employee_name == employee_name]
+ if employee is not None:
+ employee_identifiers = {
+ str(employee.name or "").strip(),
+ str(employee.email or "").strip(),
+ str(employee.employee_no or "").strip(),
+ }
+ employee_identifiers.discard("")
+ claim_identity_conditions = [
+ ExpenseClaim.employee_id == employee.id,
+ ExpenseClaim.employee_name.in_(list(employee_identifiers)),
+ ]
+ stmt = select(ExpenseClaim).where(or_(*claim_identity_conditions), ExpenseClaim.occurred_at >= since)
recent_claims = list(self.db.scalars(stmt).all())
if recent_claims:
risky_count = sum(1 for item in recent_claims if item.risk_flags_json)
@@ -2202,6 +2228,11 @@ class UserAgentService:
f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销,"
f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。"
),
+ detail=(
+ "该画像来自员工近 90 天报销记录,用于辅助判断是否存在频繁草稿、"
+ "历史风险或异常重复报销倾向,不会单独阻断审批。"
+ ),
+ suggestion="如历史记录中存在风险标记,本次提交时建议主动补充业务背景和票据说明。",
)
)
current_amount = self._resolve_amount_value(payload)
@@ -2220,6 +2251,11 @@ class UserAgentService:
f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录,"
"提交前建议核对是否为重复报销或拆分不当。"
),
+ detail=(
+ "系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规,"
+ "但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。"
+ ),
+ suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。",
)
)
@@ -2229,6 +2265,8 @@ class UserAgentService:
title="制度注意事项",
level="info",
content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。",
+ detail=f"本条来自规则或知识库引用:{citations[0].title}。提交前应确认当前单据符合该条口径。",
+ suggestion="如当前场景与制度口径存在差异,请补充审批说明或选择更准确的报销分类。",
)
)
@@ -2239,6 +2277,8 @@ class UserAgentService:
title="票据识别提醒",
level="warning",
content=f"当前共有 {warning_count} 条票据识别提示,建议逐张确认 OCR 识别字段。",
+ detail="票据 OCR 识别存在字段缺失、置信度偏低或类型判断不稳定时,会生成该提醒。",
+ suggestion="打开票据明细逐张核对日期、金额、商户和票据类型,必要时更正后再提交。",
)
)
@@ -2248,6 +2288,8 @@ class UserAgentService:
title="建议拆单",
level="high",
content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。",
+ detail="同一批附件中包含多类费用场景时,混在一张报销单里会影响规则匹配、附件核验和审批归口。",
+ suggestion="按费用场景拆成多张报销单,分别确认金额、事由和附件归属。",
)
)
@@ -2676,6 +2718,7 @@ class UserAgentService:
stmt = (
select(Employee)
+ .options(selectinload(Employee.organization_unit), selectinload(Employee.manager))
.where(
or_(
Employee.name.in_(normalized),
diff --git a/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf b/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf
new file mode 100644
index 0000000..d2aad17
Binary files /dev/null and b/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf differ
diff --git a/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf.meta.json b/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf.meta.json
new file mode 100644
index 0000000..b3d1c7a
--- /dev/null
+++ b/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf.meta.json
@@ -0,0 +1,84 @@
+{
+ "file_name": "发票_3_京S98876.pdf",
+ "storage_key": "1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf",
+ "media_type": "application/pdf",
+ "size_bytes": 61170,
+ "uploaded_at": "2026-05-20T02:21:35.637474+00:00",
+ "previewable": true,
+ "preview_kind": "image",
+ "preview_storage_key": "1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.preview.png",
+ "preview_media_type": "image/png",
+ "preview_file_name": "发票_3_京S98876.preview.png",
+ "analysis": {
+ "severity": "medium",
+ "label": "中风险",
+ "headline": "AI提示:附件存在明显待整改项",
+ "summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。",
+ "points": [
+ "用途字段:当前费用项目为其他,但附件内容更像住宿、交通相关票据。"
+ ],
+ "suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。"
+ },
+ "document_info": {
+ "document_type": "vat_invoice",
+ "document_type_label": "增值税发票",
+ "scene_code": "other",
+ "scene_label": "通用发票",
+ "fields": [
+ {
+ "key": "amount",
+ "label": "金额",
+ "value": "121.54元"
+ },
+ {
+ "key": "date",
+ "label": "日期",
+ "value": "2026-03-04"
+ },
+ {
+ "key": "merchant_name",
+ "label": "商户",
+ "value": "信息"
+ },
+ {
+ "key": "invoice_number",
+ "label": "票据号码",
+ "value": "26427004426998871533"
+ }
+ ]
+ },
+ "requirement_check": {
+ "matches": true,
+ "current_expense_type": "other",
+ "current_expense_type_label": "其他费用",
+ "allowed_scene_labels": [
+ "其他票据"
+ ],
+ "allowed_document_type_labels": [
+ "一般收据/凭证",
+ "增值税发票"
+ ],
+ "recognized_scene_code": "other",
+ "recognized_scene_label": "通用发票",
+ "recognized_document_type": "vat_invoice",
+ "recognized_document_type_label": "增值税发票",
+ "mismatch_severity": "medium",
+ "rule_code": "rule.expense.scene_submission_standard",
+ "rule_name": "报销场景提交与附件标准",
+ "message": "当前费用项目为其他费用,已识别为增值税发票,符合当前其他费用场景的附件要求。"
+ },
+ "ocr_status": "recognized",
+ "ocr_error": "",
+ "ocr_text": "发票号码:26427004426998871533\n旅普发票)\n电子发票\n开票日期:2026年03月04日\n购买方信息\n名称:北京京能电力股份有限公司\n销售方信息\n名称:北京小桔科技有限公司\n统一社会信用代码/纳税人识别1:110000717734559Y\n统一社会信用代码/纳税人识别1:110108MA00293G5X\n项目名称\n单价\n数量\n金额\n税率/征收率\n税额\n*运输服务*客运服务费\n118.00\n1\n118.00\n3%\n3.54\n合\n计\n¥118.00\n¥3.54\n出行人\n有效身份证件号\n出行日期\n出发地\n到达地\n等级\n交通工具\n类\n2026-03-04\n小汤山酒店\n林萃花园南门\n网约车\n价税合计(大写)\n壹佰贰拾壹圆伍角肆分\n(小写)¥121.54\n购方开户银行:-;\n银行账号:-;\n备注\n销方开户银行:中国建设银行北京中关村支行;\n银行账号:11001006500059041897;\n开票人:系统自动开票",
+ "ocr_summary": "发票号码:26427004426998871533;旅普发票);电子发票",
+ "ocr_avg_score": 0.9825071743194093,
+ "ocr_line_count": 47,
+ "ocr_classification_source": "rule",
+ "ocr_classification_confidence": 0.74,
+ "ocr_classification_evidence": [
+ "发票号码",
+ "价税合计",
+ "电子发票"
+ ],
+ "ocr_warnings": []
+}
\ No newline at end of file
diff --git a/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.preview.png b/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.preview.png
new file mode 100644
index 0000000..1b02f70
Binary files /dev/null and b/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.preview.png differ
diff --git a/server/tests/test_employee_service.py b/server/tests/test_employee_service.py
index 9a4f2e5..075dc81 100644
--- a/server/tests/test_employee_service.py
+++ b/server/tests/test_employee_service.py
@@ -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)
diff --git a/server/tests/test_employee_spreadsheet_import.py b/server/tests/test_employee_spreadsheet_import.py
new file mode 100644
index 0000000..af4a6e0
--- /dev/null
+++ b/server/tests/test_employee_spreadsheet_import.py
@@ -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"
diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py
index 841168f..37a1fa5 100644
--- a/server/tests/test_expense_claim_service.py
+++ b/server/tests/test_expense_claim_service.py
@@ -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",
diff --git a/web/src/assets/styles/global.css b/web/src/assets/styles/global.css
index 6a09aa6..ce7e677 100644
--- a/web/src/assets/styles/global.css
+++ b/web/src/assets/styles/global.css
@@ -103,3 +103,39 @@ h1 { margin-top: 4px; color: var(--ink); font-size: 24px; line-height: 1.25; fon
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; }
}
+
+.table-loading__spinner {
+ width: 38px;
+ height: 38px;
+ display: inline-grid;
+ place-items: center;
+ border: 3px solid #e2e8f0;
+ border-top-color: #10b981;
+ border-radius: 50%;
+ animation: table-spinner-rotate .8s linear infinite !important;
+}
+
+.table-loading.sky .table-loading__spinner {
+ border-top-color: #0ea5e9;
+}
+
+.table-loading.detail .table-loading__spinner {
+ width: 34px;
+ height: 34px;
+}
+
+.table-loading.banner .table-loading__spinner {
+ width: 18px;
+ height: 18px;
+ border-width: 2px;
+}
+
+.table-loading__spinner i {
+ display: none;
+}
+
+@keyframes table-spinner-rotate {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/web/src/assets/styles/views/audit-view.css b/web/src/assets/styles/views/audit-view.css
index e28a9ab..eac68bd 100644
--- a/web/src/assets/styles/views/audit-view.css
+++ b/web/src/assets/styles/views/audit-view.css
@@ -359,6 +359,11 @@
text-align: left;
}
+.table-state > .table-loading,
+.detail-inline-state > .table-loading {
+ width: 100%;
+}
+
.table-state i,
.detail-inline-state i {
font-size: 28px;
@@ -455,11 +460,7 @@ tbody tr:hover {
background: #f8fbff;
}
-tbody tr.spotlight {
- background: linear-gradient(90deg, rgba(16, 185, 129, 0.05), rgba(59, 130, 246, 0.03));
-}
-
-.skill-name-cell {
+.skill-name-cell {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
@@ -942,7 +943,7 @@ tbody tr.spotlight {
line-height: 1.5;
}
-.spreadsheet-change-center {
+.spreadsheet-change-center {
min-height: 0;
height: 100%;
align-self: stretch;
@@ -956,20 +957,20 @@ tbody tr.spotlight {
overflow: hidden;
}
-.change-center-head h3,
-.change-center-head p,
-.change-center-section header,
-.change-center-section p {
+.change-center-head h3,
+.change-center-head p,
+.change-center-section header,
+.change-center-section p {
margin: 0;
}
-.change-center-head h3 {
+.change-center-head h3 {
color: #0f172a;
font-size: 15px;
font-weight: 900;
}
-.change-center-head p {
+.change-center-head p {
margin-top: 3px;
color: #64748b;
font-size: 12px;
@@ -1030,37 +1031,37 @@ tbody tr.spotlight {
color: #2563eb;
}
-.change-center-section {
+.change-center-section {
display: grid;
gap: 8px;
}
-.change-history-section {
+.change-history-section {
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
-.change-center-section > header {
+.change-center-section > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
-.change-center-section > header strong {
+.change-center-section > header strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
-.change-center-section > header small,
-.change-center-section > header button {
+.change-center-section > header small,
+.change-center-section > header button {
color: #64748b;
font-size: 11px;
font-weight: 800;
}
-.change-center-section > header button {
+.change-center-section > header button {
padding: 0;
border: 0;
background: transparent;
@@ -1068,7 +1069,7 @@ tbody tr.spotlight {
cursor: pointer;
}
-.change-center-list {
+.change-center-list {
display: grid;
align-content: start;
gap: 8px;
@@ -1077,7 +1078,7 @@ tbody tr.spotlight {
padding-right: 2px;
}
-.change-center-item {
+.change-center-item {
display: grid;
gap: 8px;
padding: 10px;
@@ -1086,12 +1087,12 @@ tbody tr.spotlight {
background: #fff;
}
-.change-center-item.active {
+.change-center-item.active {
border-color: rgba(16, 185, 129, 0.35);
background: rgba(16, 185, 129, 0.05);
}
-.change-center-item > button {
+.change-center-item > button {
display: grid;
gap: 5px;
padding: 0;
@@ -1101,31 +1102,31 @@ tbody tr.spotlight {
cursor: pointer;
}
-.change-center-item > button:disabled {
+.change-center-item > button:disabled {
cursor: default;
}
-.change-center-item > button div {
+.change-center-item > button div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
-.change-center-item > button strong {
+.change-center-item > button strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
-.change-center-item > button span,
-.change-center-item > button p,
-.change-center-item > button small {
+.change-center-item > button span,
+.change-center-item > button p,
+.change-center-item > button small {
color: #64748b;
font-size: 11px;
}
-.change-center-item > button p {
+.change-center-item > button p {
margin: 0;
line-height: 1.45;
}
@@ -1250,7 +1251,7 @@ tbody tr.spotlight {
font-weight: 900;
}
-.change-flow-empty {
+.change-flow-empty {
color: #64748b;
font-size: 11px;
}
@@ -1746,11 +1747,19 @@ tbody tr.spotlight {
place-items: center;
gap: 8px;
padding: 24px;
- background: rgba(248, 250, 252, 0.94);
+ background:
+ radial-gradient(circle at 50% 38%, rgba(224, 242, 254, 0.72), rgba(248, 250, 252, 0) 58%),
+ rgba(248, 250, 252, 0.94);
color: #475569;
font-size: 13px;
font-weight: 800;
text-align: center;
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+}
+
+.rule-spreadsheet-state > .table-loading {
+ width: 100%;
}
.rule-spreadsheet-state i {
@@ -1857,6 +1866,14 @@ tbody tr.spotlight {
font-weight: 700;
}
+.subtle-banner > .table-loading {
+ width: 100%;
+}
+
+.rule-drawer-state > .table-loading {
+ width: 100%;
+}
+
.editor-foot {
margin-top: 12px;
color: #64748b;
diff --git a/web/src/assets/styles/views/employee-management-view.css b/web/src/assets/styles/views/employee-management-view.css
index 8c4be1a..33ab0d3 100644
--- a/web/src/assets/styles/views/employee-management-view.css
+++ b/web/src/assets/styles/views/employee-management-view.css
@@ -142,6 +142,8 @@
.picker-trigger,
.ghost-filter-btn,
+.template-btn,
+.export-btn,
.create-btn,
.row-action {
min-height: 38px;
@@ -282,6 +284,24 @@
color: #047857;
}
+.template-btn,
+.export-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 0 14px;
+ border: 1px solid #d7e0ea;
+ background: #fff;
+ color: #334155;
+}
+
+.template-btn:hover,
+.export-btn:hover {
+ border-color: rgba(16, 185, 129, 0.34);
+ background: #f6fffb;
+ color: #0f9f78;
+}
+
.create-btn {
display: inline-flex;
align-items: center;
@@ -293,6 +313,50 @@
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
}
+.create-btn:disabled,
+.template-btn:disabled,
+.export-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.import-file-input {
+ display: none;
+}
+
+.import-error-table-wrap {
+ max-height: 280px;
+ overflow: auto;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+}
+
+.import-error-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.import-error-table th,
+.import-error-table td {
+ padding: 10px 12px;
+ border-bottom: 1px solid #eef2f7;
+ text-align: left;
+ vertical-align: top;
+}
+
+.import-error-table th {
+ position: sticky;
+ top: 0;
+ background: #f8fafc;
+ color: #475569;
+ font-weight: 700;
+}
+
+.import-error-table td:last-child {
+ color: #b45309;
+}
+
.hint {
display: inline-flex;
align-items: center;
@@ -333,13 +397,14 @@
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
display: flex;
flex-direction: column;
- align-items: center;
- justify-content: center;
+ align-items: stretch;
+ justify-content: flex-start;
}
.table-wrap table {
width: 100%;
- align-self: flex-start;
+ flex: 0 0 auto;
+ align-self: stretch;
}
.list-foot {
@@ -503,7 +568,7 @@
}
table {
- height: 100%;
+ height: auto;
width: 100%;
min-width: 1180px;
border-collapse: collapse;
@@ -659,9 +724,12 @@ tbody tr:last-child td {
}
.role-stack {
- display: flex;
- gap: 6px;
+ display: inline-flex;
flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ gap: 6px;
+ max-width: 100%;
}
.role-pill {
@@ -770,6 +838,7 @@ tbody tr:last-child td {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.82fr);
gap: 16px;
+ align-items: start;
}
.detail-main,
@@ -777,6 +846,7 @@ tbody tr:last-child td {
display: grid;
gap: 16px;
align-content: start;
+ align-items: start;
}
.detail-card,
@@ -821,6 +891,7 @@ tbody tr:last-child td {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
+ overflow: visible;
}
.field {
@@ -850,6 +921,118 @@ tbody tr:last-child td {
color: #64748b;
}
+.manager-picker,
+.department-picker {
+ position: relative;
+ z-index: 2;
+}
+
+.manager-picker.open,
+.department-picker.open {
+ z-index: 12;
+}
+
+.manager-picker-trigger {
+ width: 100%;
+ min-height: 42px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 10px 12px;
+ border: 1px solid #d7e0ea;
+ border-radius: 10px;
+ background: #fff;
+ color: #0f172a;
+ font-size: 13px;
+ text-align: left;
+}
+
+.manager-picker.open .manager-picker-trigger,
+.manager-picker-trigger:hover {
+ border-color: rgba(16, 185, 129, 0.34);
+ background: #f6fffb;
+}
+
+.manager-picker-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.manager-picker-panel {
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 0;
+ width: min(420px, 100%);
+ z-index: 30;
+ display: grid;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid #d7e0ea;
+ border-radius: 12px;
+ background: #fff;
+ box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
+}
+
+.manager-picker-panel input[type='search'] {
+ width: 100%;
+ border: 1px solid #d7e0ea;
+ border-radius: 10px;
+ background: #fff;
+ color: #0f172a;
+ font-size: 13px;
+ padding: 10px 12px;
+}
+
+.manager-picker-panel input[type='search']:focus {
+ outline: none;
+ border-color: rgba(16, 185, 129, 0.6);
+ box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
+}
+
+.manager-picker-options {
+ max-height: 240px;
+ overflow: auto;
+ display: grid;
+ gap: 8px;
+}
+
+.manager-picker-option {
+ display: grid;
+ gap: 4px;
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid #edf2f7;
+ border-radius: 10px;
+ background: #fbfdff;
+ text-align: left;
+}
+
+.manager-picker-option strong {
+ color: #0f172a;
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.manager-picker-option span {
+ color: #64748b;
+ font-size: 12px;
+}
+
+.manager-picker-option:hover,
+.manager-picker-option.active {
+ border-color: rgba(16, 185, 129, 0.32);
+ background: linear-gradient(180deg, rgba(240, 253, 244, 0.85), #ffffff);
+}
+
+.manager-picker-empty {
+ margin: 0;
+ padding: 8px 4px;
+ color: #64748b;
+ font-size: 12px;
+}
+
.role-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -912,9 +1095,9 @@ tbody tr:last-child td {
.history-row {
display: flex;
- align-items: flex-start;
+ align-items: center;
justify-content: space-between;
- gap: 10px;
+ gap: 12px;
padding: 12px 0;
border-top: 1px solid #edf2f7;
}
@@ -925,19 +1108,47 @@ tbody tr:last-child td {
}
.history-row strong {
- display: block;
+ flex: 1 1 auto;
+ min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 800;
+ line-height: 1.45;
}
-.history-row span,
-.history-row small {
- display: block;
- margin-top: 4px;
+.history-row-meta {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 20px;
+ flex-shrink: 0;
+ margin-left: 16px;
+ padding-left: 16px;
+ border-left: 1px solid #e2e8f0;
+}
+
+.history-row-owner,
+.history-row-time {
+ display: inline-block;
+ margin-top: 0;
color: #64748b;
font-size: 12px;
- line-height: 1.5;
+ line-height: 1.45;
+ white-space: nowrap;
+}
+
+.history-row-owner {
+ color: #475569;
+ font-weight: 700;
+}
+
+.history-row-time {
+ color: #64748b;
+}
+
+td.cell-updated {
+ vertical-align: middle;
+ white-space: nowrap;
}
.publish-card {
diff --git a/web/src/assets/styles/views/logs-view.css b/web/src/assets/styles/views/logs-view.css
index a77fe74..035bf1b 100644
--- a/web/src/assets/styles/views/logs-view.css
+++ b/web/src/assets/styles/views/logs-view.css
@@ -707,6 +707,15 @@
text-align: center;
}
+.inline-empty.is-loading {
+ padding: 0;
+ background: transparent;
+}
+
+.inline-empty.is-loading > .table-loading {
+ min-height: 220px;
+}
+
.inspector-empty {
display: grid;
align-content: center;
diff --git a/web/src/assets/styles/views/policies-view.css b/web/src/assets/styles/views/policies-view.css
index 33129c7..e7c9800 100644
--- a/web/src/assets/styles/views/policies-view.css
+++ b/web/src/assets/styles/views/policies-view.css
@@ -418,12 +418,20 @@ th {
justify-content: center;
}
-.empty-row {
- color: #64748b;
- text-align: center;
-}
-
-.list-foot {
+.empty-row {
+ color: #64748b;
+ text-align: center;
+}
+
+.table-loading-row {
+ padding: 0;
+}
+
+.table-loading-row > .table-loading {
+ min-height: 220px;
+}
+
+.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view.css b/web/src/assets/styles/views/travel-reimbursement-create-view.css
index c2e0713..5850952 100644
--- a/web/src/assets/styles/views/travel-reimbursement-create-view.css
+++ b/web/src/assets/styles/views/travel-reimbursement-create-view.css
@@ -2246,10 +2246,78 @@
display: grid;
gap: 8px;
margin: 0;
- padding-left: 16px;
- color: #475569;
+}
+
+.review-side-risk-item {
+ width: 100%;
+ display: grid;
+ grid-template-columns: 30px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ min-height: 66px;
+ padding: 10px;
+ border: 1px solid rgba(226, 232, 240, 0.95);
+ border-radius: 14px;
+ background: rgba(255, 255, 255, 0.76);
+ color: #334155;
+ text-align: left;
+ transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
+}
+
+.review-side-risk-item:hover {
+ border-color: rgba(249, 115, 22, 0.38);
+ box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08);
+ transform: translateY(-1px);
+}
+
+.review-side-risk-icon {
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ border-radius: 10px;
+ background: rgba(14, 165, 233, 0.12);
+ color: #0284c7;
+ font-size: 16px;
+}
+
+.review-side-risk-item.warning .review-side-risk-icon {
+ background: rgba(245, 158, 11, 0.14);
+ color: #b45309;
+}
+
+.review-side-risk-item.high .review-side-risk-icon {
+ background: rgba(239, 68, 68, 0.12);
+ color: #dc2626;
+}
+
+.review-side-risk-copy {
+ min-width: 0;
+ display: grid;
+ gap: 3px;
+}
+
+.review-side-risk-copy strong {
+ color: #0f172a;
font-size: 12px;
- line-height: 1.6;
+ font-weight: 900;
+}
+
+.review-side-risk-copy p {
+ margin: 0;
+ color: #64748b;
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.review-side-risk-meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ color: #64748b;
+ font-size: 11px;
+ font-weight: 850;
+ white-space: nowrap;
}
.review-side-link {
@@ -4133,6 +4201,93 @@
flex: 1 1 168px;
}
+.review-risk-detail-modal {
+ width: min(560px, calc(100vw - 40px));
+ max-height: min(760px, calc(100vh - 48px));
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ overflow: hidden;
+ border-radius: 24px;
+ border: 1px solid #e7eef6;
+ background:
+ radial-gradient(circle at top right, rgba(245, 158, 11, 0.10), transparent 28%),
+ linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
+ box-shadow:
+ 0 24px 80px rgba(15, 23, 42, 0.22),
+ 0 2px 12px rgba(15, 23, 42, 0.08);
+}
+
+.review-risk-detail-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 22px 24px 18px;
+ border-bottom: 1px solid #eef2f7;
+}
+
+.review-risk-detail-head h3 {
+ margin: 12px 0 0;
+ color: #0f172a;
+ font-size: 21px;
+ font-weight: 900;
+ line-height: 1.35;
+}
+
+.review-risk-detail-body {
+ min-height: 0;
+ display: grid;
+ gap: 14px;
+ padding: 18px 24px 24px;
+ overflow-y: auto;
+}
+
+.review-risk-detail-level {
+ width: fit-content;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 30px;
+ padding: 0 11px;
+ border-radius: 999px;
+ background: rgba(14, 165, 233, 0.12);
+ color: #0284c7;
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.review-risk-detail-level.warning {
+ background: rgba(245, 158, 11, 0.14);
+ color: #b45309;
+}
+
+.review-risk-detail-level.high {
+ background: rgba(239, 68, 68, 0.12);
+ color: #dc2626;
+}
+
+.review-risk-detail-section {
+ display: grid;
+ gap: 8px;
+ padding: 14px;
+ border: 1px solid rgba(226, 232, 240, 0.92);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.72);
+}
+
+.review-risk-detail-section strong {
+ color: #0f172a;
+ font-size: 13px;
+ font-weight: 900;
+}
+
+.review-risk-detail-section p {
+ margin: 0;
+ color: #475569;
+ font-size: 13px;
+ line-height: 1.7;
+}
+
.review-edit-modal {
max-height: min(860px, calc(100vh - 48px));
display: grid;
diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css
index 17fa001..0165a7b 100644
--- a/web/src/assets/styles/views/travel-request-detail-view.css
+++ b/web/src/assets/styles/views/travel-request-detail-view.css
@@ -1061,6 +1061,13 @@
color: #ef4444;
}
+.return-action {
+ min-width: 98px;
+ border: 1px solid #fed7aa;
+ background: #fff7ed;
+ color: #c2410c;
+}
+
.detail-action-hint {
color: #64748b;
font-size: 13px;
@@ -1583,6 +1590,7 @@
.ai-preview-secondary:disabled,
.ai-preview-primary:disabled,
.approve-action:disabled,
+.return-action:disabled,
.ai-send-btn:disabled {
opacity: .45;
cursor: not-allowed;
diff --git a/web/src/components/shared/TableLoadingState.vue b/web/src/components/shared/TableLoadingState.vue
new file mode 100644
index 0000000..4b411da
--- /dev/null
+++ b/web/src/components/shared/TableLoadingState.vue
@@ -0,0 +1,176 @@
+
+ {{ message }}
直属领导和财务节点下可处理的报销单据会直接展示在这里。
+{{ detailError }}
+{{ selectedSkill.summary || '当前资产尚未补充说明。' }}