feat: 增强员工管理与报销单全流程功能

- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点
- 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力
- 报销单提交补充身份回填、部门信息透传及预审结果展示优化
- 认证流程增加部门信息(departmentName)并在schema中同步扩展
- 用户Agent服务增加部门关联与报销单回填逻辑
- 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能
- 前端审批中心、审计、差旅报销等视图交互与样式优化
- 新增TableLoadingState共享组件及员工导入测试用例
This commit is contained in:
caoxiaozhu
2026-05-20 14:21:56 +08:00
parent 57957d11a0
commit d7e98a58b9
46 changed files with 4022 additions and 305 deletions

View File

@@ -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,

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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,

View File

@@ -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:

View File

@@ -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",

View File

@@ -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)

View File

@@ -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 = {
@@ -819,7 +819,9 @@ class ExpenseClaimService:
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
@@ -838,6 +840,56 @@ class ExpenseClaimService:
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,
*,

View File

@@ -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),

View File

@@ -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": []
}

View File

@@ -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)

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
from io import BytesIO
from openpyxl import Workbook
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.employee import Employee
from app.services.employee import EmployeeService
from app.services.employee_spreadsheet import EMPLOYEE_HEADERS, EMPLOYEE_SHEET_NAME
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def build_workbook_bytes(rows: list[list[object]]) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = EMPLOYEE_SHEET_NAME
sheet.append(list(EMPLOYEE_HEADERS))
for row in rows:
sheet.append(row)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def test_import_employees_rejects_invalid_row_without_writing() -> None:
with build_session() as db:
service = EmployeeService(db)
first = service.list_employees()[0]
content = build_workbook_bytes(
[
[
first.employeeNo,
"",
first.email,
"",
"",
"",
"",
"",
first.position,
first.grade,
"",
"",
"",
"",
"在职",
"user",
]
]
)
result = service.import_employees(content)
assert result.success is False
assert result.summary.errorCount >= 1
assert any("姓名" in item.message for item in result.errors)
refreshed = service.get_employee(first.id)
assert refreshed is not None
assert refreshed.name == first.name
def test_import_employees_updates_existing_employee() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
new_name = f"{employee.name}-导入"
content = build_workbook_bytes(
[
[
employee.employeeNo,
new_name,
employee.email,
"",
"",
"13900000001",
"",
"上海",
employee.position,
employee.grade,
"FIN-SSC",
"",
"华东财务组",
"CC-TEST",
"在职",
"user",
]
]
)
result = service.import_employees(content, actor="测试管理员")
assert result.success is True
assert result.summary.updated == 1
updated = service.get_employee(employee.id)
assert updated is not None
assert updated.name == new_name
assert updated.phone == "13900000001"
def test_import_employees_creates_new_employee() -> None:
with build_session() as db:
service = EmployeeService(db)
service.list_employees()
content = build_workbook_bytes(
[
[
"E90001",
"导入新员工",
"import.new.user@xfinance.com",
"",
"",
"13811112222",
"2025-01-01",
"上海",
"业务专员",
"P3",
"FIN-SSC",
"E10234",
"华东财务组",
"CC-9001",
"在职",
"user",
]
]
)
result = service.import_employees(content)
assert result.success is True
assert result.summary.created == 1
imported = db.execute(
select(Employee).where(Employee.employee_no == "E90001")
).scalar_one()
assert imported.name == "导入新员工"
assert imported.email == "import.new.user@xfinance.com"

View File

@@ -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",

View File

@@ -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);
}
}

View File

@@ -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,10 +460,6 @@ 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 {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -423,6 +423,14 @@ th {
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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,176 @@
<template>
<div
class="table-loading"
:class="[variant, tone]"
role="status"
:aria-label="ariaLabel"
aria-live="polite"
>
<span class="table-loading__spinner" aria-hidden="true">
<i :class="icon"></i>
</span>
<div v-if="hasCopy" class="table-loading__copy">
<strong v-if="title">{{ title }}</strong>
<p v-if="message">{{ message }}</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
variant: {
type: String,
default: 'panel',
validator: (value) => ['panel', 'detail', 'overlay', 'drawer', 'banner'].includes(value)
},
tone: {
type: String,
default: 'emerald',
validator: (value) => ['emerald', 'sky'].includes(value)
},
title: { type: String, default: '' },
message: { type: String, default: '' },
icon: { type: String, default: 'mdi mdi-loading' },
showSkeleton: { type: Boolean, default: true },
skeletonRows: { type: Number, default: 5 }
})
const hasCopy = computed(() => Boolean(props.title || props.message))
const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).join(', ') || 'Loading')
</script>
<style scoped>
.table-loading {
--accent: #10b981;
--accent-deep: #059669;
width: 100%;
color: #64748b;
}
.table-loading.sky {
--accent: #0ea5e9;
--accent-deep: #0284c7;
}
.table-loading.panel {
min-height: 220px;
display: grid;
place-items: center;
gap: 12px;
padding: 28px 24px;
text-align: center;
}
.table-loading.detail {
min-height: 180px;
display: flex;
align-items: center;
gap: 14px;
padding: 22px 24px;
text-align: left;
}
.table-loading.overlay,
.table-loading.drawer {
display: grid;
place-items: center;
gap: 12px;
text-align: center;
}
.table-loading.overlay {
min-height: 0;
}
.table-loading.drawer {
min-height: 160px;
}
.table-loading.banner {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 0;
padding: 0;
color: #0369a1;
}
.table-loading__spinner {
width: 38px;
height: 38px;
display: inline-grid;
place-items: center;
border: 3px solid #e2e8f0;
border-top-color: var(--accent);
border-radius: 50%;
color: var(--accent-deep);
animation: table-spinner-rotate 0.8s linear infinite !important;
}
.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;
}
.table-loading__copy {
display: grid;
gap: 6px;
min-width: 0;
}
.table-loading.panel .table-loading__copy,
.table-loading.overlay .table-loading__copy,
.table-loading.drawer .table-loading__copy {
max-width: 360px;
}
.table-loading.detail .table-loading__copy {
flex: 1;
}
.table-loading.banner .table-loading__copy {
display: inline;
}
.table-loading__copy strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
line-height: 1.4;
}
.table-loading__copy p {
margin: 0;
font-size: 13px;
line-height: 1.65;
}
.table-loading.banner .table-loading__copy strong {
display: none;
}
.table-loading.banner .table-loading__copy p {
font-size: 12px;
font-weight: 700;
}
@keyframes table-spinner-rotate {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -94,6 +94,12 @@ function buildAnonymousUser() {
departmentName: '',
position: '',
grade: '',
employeeNo: '',
managerName: '',
location: '',
costCenter: '',
financeOwnerName: '',
riskProfile: {},
roleCodes: [],
email: '',
avatar: '',
@@ -113,6 +119,12 @@ function buildLegacyAdminUser(username = '') {
departmentName: '',
position: DEFAULT_USER_ROLE,
grade: '',
employeeNo: '',
managerName: '',
location: '',
costCenter: '',
financeOwnerName: '',
riskProfile: {},
roleCodes: ['manager'],
email: '',
avatar: name.slice(0, 1).toUpperCase(),
@@ -143,6 +155,12 @@ function readStoredUser() {
departmentName: String(payload.departmentName || payload.department || ''),
position: String(payload.position || ''),
grade: String(payload.grade || ''),
employeeNo: String(payload.employeeNo || payload.employee_no || ''),
managerName: String(payload.managerName || payload.manager_name || ''),
location: String(payload.location || ''),
costCenter: String(payload.costCenter || payload.cost_center || ''),
financeOwnerName: String(payload.financeOwnerName || payload.finance_owner_name || ''),
riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {},
roleCodes,
email: String(payload.email || ''),
avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),

View File

@@ -1,6 +1,6 @@
import { apiRequest } from './api.js'
export function fetchEmployees(params = {}) {
function buildEmployeesQuery(params = {}) {
const search = new URLSearchParams()
if (params.status && params.status !== '全部员工') {
@@ -11,10 +11,54 @@ export function fetchEmployees(params = {}) {
search.set('keyword', params.keyword)
}
const query = search.toString()
return search
}
export function fetchEmployees(params = {}) {
const query = buildEmployeesQuery(params).toString()
return apiRequest(`/employees${query ? `?${query}` : ''}`)
}
function triggerBlobDownload(blob, filename) {
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = filename
anchor.rel = 'noopener'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(objectUrl)
}
export async function downloadEmployeeImportTemplate() {
const blob = await apiRequest('/employees/import-template', {
responseType: 'blob',
contentType: null
})
triggerBlobDownload(blob, '员工导入模板.xlsx')
}
export async function exportEmployees(params = {}) {
const query = buildEmployeesQuery(params).toString()
const blob = await apiRequest(`/employees/export${query ? `?${query}` : ''}`, {
responseType: 'blob',
contentType: null
})
triggerBlobDownload(blob, '员工目录导出.xlsx')
}
export function importEmployees(file) {
const formData = new FormData()
formData.append('file', file)
return apiRequest('/employees/import', {
method: 'POST',
body: formData,
contentType: null
})
}
export function fetchEmployeeMeta() {
return apiRequest('/employees/meta')
}

View File

@@ -87,6 +87,13 @@ export function submitExpenseClaim(claimId) {
})
}
export function returnExpenseClaim(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/return`, {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function deleteExpenseClaim(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, {
method: 'DELETE'

View File

@@ -13,19 +13,22 @@ export const DEFAULT_APP_VIEW_ORDER = [
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
approval: ['approver'],
approval: ['approver', 'finance', 'executive'],
audit: ['auditor', 'finance'],
logs: ['manager'],
employees: ['manager'],
settings: ['manager']
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['finance', 'executive'])
function normalizedRoleCodes(user) {
if (!user) {
return []
}
return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : []
return Array.isArray(user.roleCodes)
? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
: []
}
export function isManagerUser(user) {
@@ -36,6 +39,18 @@ export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance')
}
export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive')
}
export function canManageExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false

View File

@@ -391,9 +391,34 @@
<span>退回列表</span>
</button>
<div class="approval-action-group" aria-label="审批操作">
<button class="approve-action" type="button"><i class="mdi mdi-check-circle-outline"></i> 通过</button>
<button class="reject-action" type="button"><i class="mdi mdi-close-circle-outline"></i> 驳回</button>
<button class="supplement-action" type="button"><i class="mdi mdi-undo"></i> 补充</button>
<button class="approve-action" type="button" :disabled="actionBusy">
<i class="mdi mdi-check-circle-outline"></i> 通过
</button>
<button
class="reject-action"
type="button"
:disabled="!canManageClaims || actionBusy"
@click="handleReturnSelected"
>
<i class="mdi mdi-close-circle-outline"></i> 驳回
</button>
<button
class="supplement-action"
type="button"
:disabled="!canManageClaims || actionBusy"
@click="handleReturnSelected"
>
<i class="mdi mdi-undo"></i> 补充
</button>
<button
v-if="canManageClaims"
class="reject-action"
type="button"
:disabled="actionBusy"
@click="handleDeleteSelected"
>
<i class="mdi mdi-trash-can-outline"></i> 删除
</button>
</div>
</footer>
</div>
@@ -430,9 +455,11 @@
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<i class="mdi mdi-loading mdi-spin"></i>
<strong>正在加载审批待办</strong>
<p>直属领导和财务节点下可处理的报销单据会直接展示在这里</p>
<TableLoadingState
title="审批待办同步中"
message="正在加载当前可见的待审报销单据"
icon="mdi mdi-clipboard-check-outline"
/>
</div>
<div v-else-if="error" class="table-state error">
@@ -502,6 +529,38 @@
</table>
</div>
</article>
<ConfirmDialog
:open="returnDialogOpen"
badge="退回单据"
badge-tone="warning"
:title="`确认退回 ${selectedRow?.id || ''} 吗?`"
description="退回后该单据会进入待补充状态,申请人需要补充后重新提交。"
cancel-text="取消"
confirm-text="确认退回"
busy-text="退回中..."
confirm-tone="primary"
confirm-icon="mdi mdi-undo"
:busy="actionBusy"
@close="closeReturnDialog"
@confirm="confirmReturnSelected"
/>
<ConfirmDialog
:open="deleteDialogOpen"
badge="删除单据"
badge-tone="danger"
:title="`确认删除 ${selectedRow?.id || ''} 吗?`"
description="删除后该报销单及费用明细将不可恢复,请确认本次操作。"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-trash-can-outline"
:busy="actionBusy"
@close="closeDeleteDialog"
@confirm="confirmDeleteSelected"
/>
</section>
</template>

View File

@@ -64,6 +64,40 @@
<div class="hero-stat">
<span>{{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }}</span>
<strong>{{ selectedSkill.displayVersion || selectedSkill.version }}</strong>
</div>
<div class="hero-stat">
<span>最近更新</span>
<strong>{{ selectedSkill.updatedAt }}</strong>
</div>
</div>
</section>
<section v-if="detailError" class="detail-inline-state panel error">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
<strong>资产详情加载失败</strong>
<p>{{ detailError }}</p>
</div>
</section>
<TableLoadingState
v-else-if="detailLoading && selectedSkill.loading"
class="detail-inline-state panel"
variant="detail"
title="正在加载资产详情"
message="列表数据已就绪,正在补充版本、审核和运行信息"
icon="mdi mdi-file-document-outline"
:show-skeleton="false"
/>
<section
v-else-if="selectedSkill.usesSpreadsheetRule"
class="spreadsheet-editor-shell panel"
>
<header class="spreadsheet-editor-head">
<div class="spreadsheet-editor-title">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<div>
<h2>{{ selectedSkill.name }}</h2>
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
</div>
@@ -99,10 +133,15 @@
class="rule-spreadsheet-host"
:class="{ hidden: !spreadsheetOnlyOfficeReady && !spreadsheetOnlyOfficeError }"
></div>
<div v-if="spreadsheetOnlyOfficeLoading" class="rule-spreadsheet-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载 Excel 规则表...</span>
</div>
<TableLoadingState
v-if="spreadsheetOnlyOfficeLoading"
class="rule-spreadsheet-state"
variant="overlay"
tone="sky"
message="正在加载 Excel 规则表"
icon="mdi mdi-table-large"
:show-skeleton="false"
/>
<div v-else-if="spreadsheetOnlyOfficeError" class="rule-spreadsheet-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ spreadsheetOnlyOfficeError }}</span>
@@ -319,10 +358,15 @@
</button>
</div>
<div v-if="detailLoading" class="subtle-banner">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在刷新规则详情...</span>
</div>
<TableLoadingState
v-if="detailLoading"
class="subtle-banner"
variant="banner"
tone="sky"
message="正在刷新规则详情"
icon="mdi mdi-refresh"
:show-skeleton="false"
/>
<label class="field">
<span>{{ selectedSkill.code }}</span>
@@ -866,8 +910,12 @@
<div class="table-wrap" :class="{ 'is-empty': !loading && !errorMessage && !visibleSkills.length }">
<div v-if="loading" class="table-state">
<i class="mdi mdi-loading mdi-spin"></i>
<p>正在加载{{ activeTabLabel }}资产...</p>
<TableLoadingState
variant="panel"
:title="`${activeTabLabel}资产同步中`"
:message="`正在加载${activeTabLabel}资产`"
icon="mdi mdi-view-list-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
@@ -907,7 +955,6 @@
<tr
v-for="skill in visibleSkills"
:key="skill.id"
:class="{ spotlight: skill.spotlight }"
@click="openAssetDetail(skill)"
>
<td>
@@ -1032,10 +1079,14 @@
</button>
</header>
<div v-if="versionTimelineLoading" class="rule-drawer-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载操作记录...</span>
</div>
<TableLoadingState
v-if="versionTimelineLoading"
class="rule-drawer-state"
variant="drawer"
message="正在加载操作记录"
icon="mdi mdi-history"
:show-skeleton="false"
/>
<div v-else-if="versionTimelineError" class="rule-drawer-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ versionTimelineError }}</span>

View File

@@ -61,11 +61,23 @@
</label>
<label class="field">
<span>年龄</span>
<input :value="detailAge" readonly />
<input
v-model="employeeForm.age"
type="number"
min="0"
max="120"
inputmode="numeric"
placeholder="请输入年龄"
@input="syncBirthDateFromAge"
/>
</label>
<label class="field">
<span>出生日期</span>
<input v-model="employeeForm.birthDate" type="date" />
<input
v-model="employeeForm.birthDate"
type="date"
@change="syncAgeFromBirthDate"
/>
</label>
<label class="field">
<span>手机号</span>
@@ -104,9 +116,50 @@
</div>
<div class="form-grid">
<label class="field">
<label
class="field manager-picker department-picker"
:class="{ open: departmentPickerOpen }"
>
<span>所属部门</span>
<input v-model="employeeForm.department" readonly />
<button
class="manager-picker-trigger"
type="button"
@click.stop="toggleDepartmentPicker"
>
<span class="manager-picker-label">{{ departmentDisplayLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="departmentPickerOpen"
class="manager-picker-panel"
@click.stop
>
<input
v-model="departmentSearchKeyword"
type="search"
placeholder="输入部门名称或编码搜索"
@keydown.enter.prevent="resolveDepartmentSelectionFromKeyword"
/>
<div class="manager-picker-options">
<button
v-for="option in filteredDepartmentOptions"
:key="option.id"
type="button"
class="manager-picker-option"
:class="{ active: employeeForm.organizationUnitCode === option.code }"
@click="selectDepartment(option)"
>
<strong>{{ option.name }}{{ option.code }}</strong>
<span v-if="option.unitType">{{ option.unitType }}</span>
</button>
<p
v-if="!filteredDepartmentOptions.length"
class="manager-picker-empty"
>
没有匹配的部门请调整搜索关键词
</p>
</div>
</div>
</label>
<label class="field">
<span>岗位</span>
@@ -116,9 +169,56 @@
<span>职级</span>
<input v-model="employeeForm.grade" />
</label>
<label class="field">
<label class="field manager-picker" :class="{ open: managerPickerOpen }">
<span>直属上级</span>
<input v-model="employeeForm.manager" readonly />
<button
class="manager-picker-trigger"
type="button"
@click.stop="toggleManagerPicker"
>
<span class="manager-picker-label">{{ managerDisplayLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="managerPickerOpen"
class="manager-picker-panel"
@click.stop
>
<input
v-model="managerSearchKeyword"
type="search"
placeholder="输入姓名、工号或部门搜索"
@keydown.enter.prevent="resolveManagerSelectionFromKeyword"
/>
<div class="manager-picker-options">
<button
type="button"
class="manager-picker-option"
:class="{ active: !hasManagerAssignment }"
@click="selectManager(null)"
>
<strong>无直属上级</strong>
<span>清空当前设置</span>
</button>
<button
v-for="option in filteredManagerOptions"
:key="option.id"
type="button"
class="manager-picker-option"
:class="{ active: employeeForm.managerEmployeeNo === option.employeeNo }"
@click="selectManager(option)"
>
<strong>{{ option.name }}{{ option.employeeNo }}</strong>
<span>{{ option.department }} / {{ option.position }}</span>
</button>
<p
v-if="!filteredManagerOptions.length"
class="manager-picker-empty"
>
没有匹配的员工请调整搜索关键词
</p>
</div>
</div>
</label>
<label class="field">
<span>财务归口</span>
@@ -177,16 +277,27 @@
<div class="card-head">
<div>
<h3>最近变更</h3>
<p>查看角色与档案调整记录</p>
<p>仅保留最近 5 角色与档案调整记录</p>
</div>
</div>
<div class="history-list">
<div v-for="item in selectedEmployee.history" :key="item.time" class="history-row">
<div
v-for="item in recentEmployeeHistory"
:key="`${item.occurredAt || item.time}-${item.action}`"
class="history-row"
>
<strong>{{ item.action }}</strong>
<span>{{ item.owner }}</span>
<small>{{ item.time }}</small>
<div class="history-row-meta">
<span class="history-row-owner">{{ item.owner }}</span>
<small class="history-row-time">{{
formatEmployeeHistoryTime(item.time || item.occurredAt)
}}</small>
</div>
</div>
<p v-if="!recentEmployeeHistory.length" class="manager-picker-empty">
暂无变更记录
</p>
</div>
</article>
<article class="side-card panel publish-card">
@@ -392,16 +503,34 @@
<span>清空筛选</span>
</button>
<button class="create-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新增员工</span>
<button class="template-btn" type="button" :disabled="importExportBusy" @click="handleDownloadTemplate">
<i class="mdi mdi-file-download-outline"></i>
<span>下载模板</span>
</button>
<button class="export-btn" type="button" :disabled="importExportBusy" @click="handleExportEmployees">
<i class="mdi mdi-export"></i>
<span>{{ actionState === 'export' ? '导出中...' : '导出员工' }}</span>
</button>
<button class="create-btn" type="button" :disabled="importExportBusy" @click="openImportFilePicker">
<i class="mdi mdi-file-upload-outline"></i>
<span>{{ actionState === 'import' ? '导入中...' : '导入员工' }}</span>
</button>
<input
ref="importFileInput"
class="import-file-input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@change="handleImportFileChange"
/>
</div>
</div>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
点击任意员工行可进入基础信息与角色权限编辑界面
点击任意员工行可进入基础信息与角色权限编辑界面导入将按员工编号覆盖已有档案任一行校验失败则整批不写入
</p>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
@@ -412,8 +541,11 @@
<div class="table-wrap">
<div v-if="loading" class="table-state">
<i class="mdi mdi-loading mdi-spin"></i>
<p>正在加载员工数据...</p>
<TableLoadingState
title="员工数据同步中"
message="正在加载员工档案与角色权限"
icon="mdi mdi-account-group-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
@@ -496,7 +628,7 @@
<td>
<span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span>
</td>
<td>{{ employee.updatedAt }}</td>
<td class="cell-updated">{{ employee.updatedAt }}</td>
</tr>
</tbody>
</table>
@@ -572,9 +704,62 @@
@close="closeDisableDialog"
@confirm="confirmDisableEmployeeAccount"
/>
<ConfirmDialog
:open="importConfirmDialogOpen"
badge="导入确认"
badge-tone="warning"
title="确认导入员工 Excel"
description="系统将先校验全部数据,全部通过后才写入数据库。若存在错误,将不会修改任何现有员工信息。"
cancel-text="取消"
confirm-text="开始导入"
busy-text="导入中..."
confirm-tone="primary"
confirm-icon="mdi mdi-file-upload-outline"
:busy="actionState === 'import'"
@close="closeImportConfirmDialog"
@confirm="confirmImportEmployees"
/>
<ConfirmDialog
:open="importErrorDialogOpen"
badge="导入失败"
badge-tone="danger"
title="导入未执行"
:description="importResultMessage"
cancel-text="关闭"
confirm-text="下载模板"
confirm-tone="primary"
confirm-icon="mdi mdi-file-download-outline"
@close="closeImportErrorDialog"
@confirm="handleDownloadTemplate"
>
<div class="import-error-table-wrap">
<table class="import-error-table">
<thead>
<tr>
<th>行号</th>
<th>字段</th>
<th>工号</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in importErrors" :key="`${item.row}-${item.column}-${index}`">
<td>{{ item.row || '-' }}</td>
<td>{{ item.column }}</td>
<td>{{ item.employeeNo || '-' }}</td>
<td>{{ item.message }}</td>
</tr>
</tbody>
</table>
</div>
</ConfirmDialog>
</section>
</template>
<script src="./scripts/EmployeeManagementView.js"></script>
<style scoped src="../assets/styles/views/employee-management-view.css"></style>
<style scoped>
@import "../assets/styles/views/employee-management-view.css";
</style>

View File

@@ -128,8 +128,18 @@
</tbody>
</table>
<div v-if="!filteredHermesRuns.length" class="inline-empty">
{{ hermesLoading ? '正在加载 Hermes 运行日志...' : '当前筛选条件下没有 Hermes 记录' }}
<div
v-if="!filteredHermesRuns.length"
class="inline-empty"
:class="{ 'is-loading': hermesLoading }"
>
<TableLoadingState
v-if="hermesLoading"
title="Hermes 日志同步中"
message="正在加载运行日志记录"
icon="mdi mdi-text-box-search-outline"
/>
<span v-else>当前筛选条件下没有 Hermes 记录</span>
</div>
</div>
@@ -174,8 +184,18 @@
</tbody>
</table>
<div v-if="!filteredSystemLogEntries.length" class="inline-empty">
{{ systemLogLoading ? '正在加载系统日志...' : '当前筛选条件下没有系统日志记录' }}
<div
v-if="!filteredSystemLogEntries.length"
class="inline-empty"
:class="{ 'is-loading': systemLogLoading }"
>
<TableLoadingState
v-if="systemLogLoading"
title="系统日志同步中"
message="正在加载系统日志记录"
icon="mdi mdi-text-box-search-outline"
/>
<span v-else>当前筛选条件下没有系统日志记录</span>
</div>
</div>

View File

@@ -129,9 +129,18 @@
</div>
</td>
</tr>
<tr v-if="!visibleDocuments.length">
<tr v-if="loading && !visibleDocuments.length">
<td colspan="7" class="empty-row table-loading-row">
<TableLoadingState
title="知识库文件同步中"
message="正在加载当前文件夹的知识库文件"
icon="mdi mdi-folder-table-outline"
/>
</td>
</tr>
<tr v-else-if="!visibleDocuments.length">
<td colspan="7" class="empty-row">
{{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }}
当前文件夹暂无文件
</td>
</tr>
</tbody>

View File

@@ -62,9 +62,11 @@
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<i class="mdi mdi-loading mdi-spin"></i>
<strong>正在加载真实报销数据</strong>
<p>列表将直接展示后端返回的个人报销单据</p>
<TableLoadingState
title="真实报销数据同步中"
message="正在加载后端返回的个人报销单据"
icon="mdi mdi-file-document-outline"
/>
</div>
<div v-else-if="error" class="table-state error">

View File

@@ -511,18 +511,23 @@
<div v-if="composerDateMode === 'single'" class="composer-date-fields">
<label class="composer-date-field">
<span>日期</span>
<input v-model="composerSingleDate" type="date" />
<input v-model="composerSingleDate" type="date" @change="handleComposerDateInputChange" />
</label>
</div>
<div v-else class="composer-date-fields composer-date-fields-range">
<label class="composer-date-field">
<span>开始</span>
<input v-model="composerRangeStartDate" type="date" />
<input v-model="composerRangeStartDate" type="date" @change="handleComposerDateInputChange" />
</label>
<span class="composer-date-range-sep"></span>
<label class="composer-date-field">
<span>结束</span>
<input v-model="composerRangeEndDate" type="date" :min="composerRangeStartDate" />
<input
v-model="composerRangeEndDate"
type="date"
:min="composerRangeStartDate"
@change="handleComposerDateInputChange"
/>
</label>
</div>
<p v-if="composerDateMode === 'range' && !composerCanApplyDateSelection" class="composer-date-hint">
@@ -1078,9 +1083,28 @@
</span>
</div>
<p class="review-side-risk-summary">{{ reviewRiskSummary }}</p>
<ul v-if="reviewRiskItems.length" class="review-side-risk-list">
<li v-for="item in reviewRiskItems" :key="item">{{ item }}</li>
</ul>
<div v-if="reviewRiskItems.length" class="review-side-risk-list">
<button
v-for="item in reviewRiskItems"
:key="item.key"
type="button"
class="review-side-risk-item"
:class="item.level"
@click="openReviewRiskDetail(item)"
>
<span class="review-side-risk-icon">
<i :class="item.icon"></i>
</span>
<span class="review-side-risk-copy">
<strong>{{ item.title }}</strong>
<p>{{ item.summary }}</p>
</span>
<span class="review-side-risk-meta">
{{ item.levelLabel }}
<i class="mdi mdi-chevron-right"></i>
</span>
</button>
</div>
<div v-else-if="reviewRiskEmpty" class="review-side-empty">
<span class="review-side-empty-icon">
<i class="mdi mdi-shield-check-outline"></i>
@@ -1088,16 +1112,6 @@
<strong>暂无风险评分</strong>
<p>当前版本还没有返回结构化风险评分结果这里先不展示虚拟分数</p>
</div>
<button
v-if="reviewRiskActionAvailable"
type="button"
class="review-side-link"
:disabled="submitting || reviewActionBusy"
@click="explainCurrentReviewRisk"
>
查看全部风险项
<i class="mdi mdi-chevron-right"></i>
</button>
</section>
</template>
@@ -1192,6 +1206,41 @@
@confirm="confirmCancelReview"
/>
<Transition name="assistant-modal">
<div v-if="reviewRiskDetailDialog.open" class="assistant-overlay review-overlay">
<section class="review-risk-detail-modal">
<header class="review-risk-detail-head">
<div>
<span class="assistant-badge warning">{{ reviewRiskDetailDialog.item?.sourceLabel || 'AI预审' }}</span>
<h3>{{ reviewRiskDetailDialog.item?.title || '风险提示' }}</h3>
</div>
<button class="close-btn" type="button" aria-label="关闭风险说明" @click="closeReviewRiskDetail">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="review-risk-detail-body">
<div class="review-risk-detail-level" :class="reviewRiskDetailDialog.item?.level">
<i :class="reviewRiskDetailDialog.item?.icon || 'mdi mdi-information-outline'"></i>
<span>{{ reviewRiskDetailDialog.item?.levelLabel || '提示' }}</span>
</div>
<article class="review-risk-detail-section">
<strong>提示情况</strong>
<p>{{ reviewRiskDetailDialog.item?.summary }}</p>
</article>
<article class="review-risk-detail-section">
<strong>详细解释</strong>
<p>{{ reviewRiskDetailDialog.item?.detail }}</p>
</article>
<article class="review-risk-detail-section">
<strong>处理建议</strong>
<p>{{ reviewRiskDetailDialog.item?.suggestion }}</p>
</article>
</div>
</section>
</div>
</Transition>
<Transition name="assistant-modal">
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
<section class="review-confirm-modal review-upload-decision-modal">

View File

@@ -394,7 +394,7 @@
<span>返回报销列表</span>
</button>
<div v-if="isDraftRequest" class="approval-action-group" aria-label="申请操作">
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteDraft">
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : '删除草稿' }}
</button>
@@ -403,6 +403,22 @@
{{ submitBusy ? '提交中' : '提交审批' }}
</button>
</div>
<div v-else-if="canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
<button
v-if="canReturnRequest"
class="return-action"
type="button"
:disabled="actionBusy"
@click="handleReturnRequest"
>
<i class="mdi mdi-undo"></i>
{{ returnBusy ? '退回中' : '退回单据' }}
</button>
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : '删除单据' }}
</button>
</div>
<p v-else class="detail-action-hint">当前单据已进入流程详情页仅展示状态与费用明细</p>
</footer>
</div>
@@ -465,10 +481,10 @@
<ConfirmDialog
:open="deleteDialogOpen"
badge="删除草稿"
:badge="deleteActionLabel"
badge-tone="danger"
:title="`确认删除草稿 ${request.id} 吗?`"
description="删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。"
:title="deleteDialogTitle"
:description="deleteDialogDescription"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
@@ -476,7 +492,23 @@
confirm-icon="mdi mdi-trash-can-outline"
:busy="deleteBusy"
@close="closeDeleteDialog"
@confirm="confirmDeleteDraft"
@confirm="confirmDeleteRequest"
/>
<ConfirmDialog
:open="returnDialogOpen"
badge="退回单据"
badge-tone="warning"
:title="`确认退回 ${request.id} 吗?`"
description="退回后该单据会进入待补充状态,申请人需要补充后重新提交。"
cancel-text="取消"
confirm-text="确认退回"
busy-text="退回中..."
confirm-tone="primary"
confirm-icon="mdi mdi-undo"
:busy="returnBusy"
@close="closeReturnDialog"
@confirm="confirmReturnRequest"
/>
</section>
</template>

View File

@@ -1,9 +1,13 @@
import { computed, ref } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { useSystemState } from '../../composables/useSystemState.js'
import { fetchExpenseClaims } from '../../services/reimbursements.js'
import { useToast } from '../../composables/useToast.js'
import { deleteExpenseClaim, fetchExpenseClaims, returnExpenseClaim } from '../../services/reimbursements.js'
import { canManageExpenseClaims } from '../../utils/accessControl.js'
const DEFAULT_SLA_HOURS = 24
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
@@ -195,7 +199,6 @@ function buildFlowItems(request) {
function canCurrentUserProcessRequest(request, currentUser) {
const node = String(request?.workflowNode || '').trim()
const roleCodes = Array.isArray(currentUser?.roleCodes) ? currentUser.roleCodes.filter(Boolean) : []
const currentName = String(currentUser?.name || '').trim()
const applicantName = String(request?.person || request?.employeeName || '').trim()
@@ -203,8 +206,8 @@ function canCurrentUserProcessRequest(request, currentUser) {
return false
}
if (currentUser?.isAdmin || roleCodes.includes('finance')) {
return node.includes('财务')
if (canManageExpenseClaims(currentUser)) {
return true
}
return (
@@ -251,10 +254,13 @@ function buildApprovalRow(request) {
export default {
name: 'ApprovalCenterView',
components: {
ConfirmDialog,
TableLoadingState,
TableEmptyState
},
setup() {
const { currentUser } = useSystemState()
const { toast } = useToast()
const activeTab = ref('全部待审')
const selectedClaimId = ref('')
const expandedExpenseId = ref(null)
@@ -262,6 +268,9 @@ export default {
const rows = ref([])
const loading = ref(false)
const error = ref('')
const actionBusy = ref(false)
const returnDialogOpen = ref(false)
const deleteDialogOpen = ref(false)
const selectedRow = computed({
get() {
@@ -303,6 +312,7 @@ export default {
})
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const canManageClaims = computed(() => canManageExpenseClaims(currentUser.value))
const approvalEmptyState = computed(() => {
if (!rows.value.length) {
return {
@@ -381,6 +391,76 @@ export default {
activeTab.value = '全部待审'
}
function handleReturnSelected() {
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
return
}
returnDialogOpen.value = true
}
function handleDeleteSelected() {
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
return
}
deleteDialogOpen.value = true
}
function closeReturnDialog() {
if (!actionBusy.value) {
returnDialogOpen.value = false
}
}
function closeDeleteDialog() {
if (!actionBusy.value) {
deleteDialogOpen.value = false
}
}
async function confirmReturnSelected() {
const row = selectedRow.value
if (!row?.claimId || actionBusy.value) {
return
}
actionBusy.value = true
try {
await returnExpenseClaim(row.claimId, {
reason: '审批中心退回,请申请人补充后重新提交。'
})
toast(`${row.id} 已退回待补充。`)
returnDialogOpen.value = false
selectedClaimId.value = ''
await reload()
} catch (nextError) {
toast(nextError?.message || '退回单据失败,请稍后重试。')
} finally {
actionBusy.value = false
}
}
async function confirmDeleteSelected() {
const row = selectedRow.value
if (!row?.claimId || actionBusy.value) {
return
}
actionBusy.value = true
try {
const payload = await deleteExpenseClaim(row.claimId)
toast(payload?.message || `${row.id} 报销单已删除。`)
deleteDialogOpen.value = false
selectedClaimId.value = ''
await reload()
} catch (nextError) {
toast(nextError?.message || '删除单据失败,请稍后重试。')
} finally {
actionBusy.value = false
}
}
async function reload() {
loading.value = true
error.value = ''
@@ -420,8 +500,15 @@ export default {
visibleRows,
showTable,
showEmpty,
actionBusy,
approvalEmptyState,
approvalSteps,
canManageClaims,
closeDeleteDialog,
closeReturnDialog,
confirmDeleteSelected,
confirmReturnSelected,
deleteDialogOpen,
summaryItems,
heroSummaryItems,
currentProgressRingMotion,
@@ -434,8 +521,11 @@ export default {
riskItems,
flowItems,
handleEmptyAction,
handleDeleteSelected,
handleReturnSelected,
loading,
error,
returnDialogOpen,
reload
}
}

View File

@@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import { fetchEmployees } from '../../services/employees.js'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -1130,7 +1131,6 @@ function buildListItem(asset) {
changeCount,
updatedAt: formatDateTime(asset.updated_at),
badgeTone: tabMeta.badgeTone,
spotlight: asset.status === 'active',
domainValue: asset.domain
}
}
@@ -1653,6 +1653,7 @@ export default {
name: 'AuditView',
components: {
ConfirmDialog,
TableLoadingState,
TableEmptyState
},
emits: ['detail-open-change'],

View File

@@ -1,13 +1,18 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useToast } from '../../composables/useToast.js'
import {
disableEmployee,
downloadEmployeeImportTemplate,
enableEmployee,
exportEmployees,
fetchEmployeeDetail,
fetchEmployeeMeta,
fetchEmployees,
importEmployees,
updateEmployee
} from '../../services/employees.js'
@@ -56,6 +61,7 @@ function createEmployeeForm() {
name: '',
employeeNo: '',
gender: '',
age: '',
birthDate: '',
phone: '',
email: '',
@@ -64,7 +70,9 @@ function createEmployeeForm() {
position: '',
grade: '',
department: '',
organizationUnitCode: '',
manager: '',
managerEmployeeNo: '',
financeOwner: '',
costCenter: '',
roleCodes: [],
@@ -72,24 +80,120 @@ function createEmployeeForm() {
}
}
function buildEmployeeForm(employee) {
function isPlaceholderManagerName(name) {
const normalized = normalizeText(name)
return !normalized || normalized === 'CEO' || normalized === '无'
}
function resolveManagerEmployeeNo(employee, roster = []) {
const fromApi = normalizeText(employee?.managerEmployeeNo)
if (fromApi) {
return fromApi
}
const managerName = normalizeText(employee?.manager)
if (isPlaceholderManagerName(managerName)) {
return ''
}
const matches = roster.filter((item) => normalizeText(item.name) === managerName)
if (matches.length === 1) {
return matches[0].employeeNo
}
return ''
}
function enrichEmployeeRecord(employee, roster = []) {
if (!employee) {
return employee
}
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
if (!managerEmployeeNo || managerEmployeeNo === employee.managerEmployeeNo) {
return employee
}
return {
...employee,
managerEmployeeNo
}
}
function mergeEmployeeRecords(listItem, detailItem, roster = []) {
if (!listItem && !detailItem) {
return null
}
if (!listItem) {
return enrichEmployeeRecord(detailItem, roster)
}
if (!detailItem) {
return enrichEmployeeRecord(listItem, roster)
}
const managerEmployeeNo =
normalizeText(detailItem.managerEmployeeNo) ||
normalizeText(listItem.managerEmployeeNo) ||
resolveManagerEmployeeNo(detailItem, roster) ||
resolveManagerEmployeeNo(listItem, roster)
const history =
Array.isArray(detailItem.history) && detailItem.history.length
? detailItem.history
: listItem.history || []
const permissions =
Array.isArray(detailItem.permissions) && detailItem.permissions.length
? detailItem.permissions
: listItem.permissions || []
return enrichEmployeeRecord(
{
...listItem,
...detailItem,
manager: detailItem.manager || listItem.manager,
managerEmployeeNo: managerEmployeeNo || null,
history,
permissions,
roleCodes: detailItem.roleCodes?.length ? detailItem.roleCodes : listItem.roleCodes,
roles: detailItem.roles?.length ? detailItem.roles : listItem.roles,
organization: detailItem.organization || listItem.organization,
department: detailItem.department || listItem.department
},
roster
)
}
function buildEmployeeForm(employee, roster = []) {
if (!employee) {
return createEmployeeForm()
}
const birthDate = employee.birthDate || ''
const managerName = employee.manager || ''
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
return {
name: employee.name || '',
employeeNo: employee.employeeNo || '',
gender: employee.gender || '',
birthDate: employee.birthDate || '',
age:
employee.age !== null && employee.age !== undefined && employee.age !== ''
? String(employee.age)
: calculateAgeFromDate(birthDate),
birthDate,
phone: employee.phone || '',
email: employee.email || '',
joinDate: employee.joinDate || '',
location: employee.location || '',
position: employee.position || '',
grade: employee.grade || '',
department: employee.department || '',
manager: employee.manager || '',
department: resolveOrganizationUnitName(employee),
organizationUnitCode: resolveOrganizationUnitCode(employee),
manager: managerName,
managerEmployeeNo,
financeOwner: employee.financeOwner || '',
costCenter: employee.costCenter || '',
roleCodes: [...(employee.roleCodes || [])],
@@ -154,6 +258,60 @@ function sameValues(left, right) {
return left.every((value, index) => value === right[index])
}
function formatEmployeeHistoryTime(value) {
const raw = normalizeText(value)
if (!raw) {
return ''
}
const matched = raw.match(
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/
)
if (!matched) {
return raw
}
const year = Number.parseInt(matched[1], 10)
const month = Number.parseInt(matched[2], 10)
const day = Number.parseInt(matched[3], 10)
const hour = Number.parseInt(matched[4] || '0', 10)
const minute = Number.parseInt(matched[5] || '0', 10)
const second = Number.parseInt(matched[6] || '0', 10)
return `${year}${month}${day}${hour}${minute}${second}`
}
function resolveOrganizationUnitCode(employee) {
return normalizeText(employee?.organization?.code)
}
function resolveOrganizationUnitName(employee) {
return normalizeText(employee?.department) || normalizeText(employee?.organization?.name)
}
function captureEmployeeDetailSnapshot(form) {
return {
roleCodes: [...(form.roleCodes || [])].sort(),
organizationUnitCode: normalizeText(form.organizationUnitCode) || ''
}
}
function resolveOrganizationOptions(metaOrganizations) {
if (!Array.isArray(metaOrganizations) || !metaOrganizations.length) {
return []
}
return metaOrganizations
.map((item) => ({
id: item.id,
code: item.code,
name: item.name,
unitType: item.unitType,
label: `${item.name}${item.code}`
}))
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
}
function calculateAgeFromDate(dateString) {
if (!dateString) {
return ''
@@ -177,6 +335,33 @@ function calculateAgeFromDate(dateString) {
return age >= 0 ? String(age) : ''
}
function calculateBirthDateFromAge(ageValue, existingBirthDate = '') {
const age = Number.parseInt(String(ageValue ?? '').trim(), 10)
if (Number.isNaN(age) || age < 0 || age > 120) {
return existingBirthDate || ''
}
const today = new Date()
let month = '01'
let day = '01'
if (existingBirthDate && isValidIsoDate(existingBirthDate)) {
const [, monthText, dayText] = existingBirthDate.split('-')
month = monthText
day = dayText
}
let birthYear = today.getFullYear() - age
let candidate = `${birthYear}-${month}-${day}`
if (Number(calculateAgeFromDate(candidate)) > age) {
birthYear -= 1
candidate = `${birthYear}-${month}-${day}`
}
return candidate
}
function matchKeyword(employee, keyword) {
if (!keyword) {
return true
@@ -249,6 +434,7 @@ export default {
name: 'EmployeeManagementView',
components: {
ConfirmDialog,
TableLoadingState,
TableEmptyState
},
emits: ['overview-change'],
@@ -272,17 +458,37 @@ export default {
const loading = ref(false)
const errorMessage = ref('')
const disableDialogOpen = ref(false)
const importFileInput = ref(null)
const pendingImportFile = ref(null)
const importConfirmDialogOpen = ref(false)
const importErrorDialogOpen = ref(false)
const importErrors = ref([])
const importResultMessage = ref('')
const managerPickerOpen = ref(false)
const managerSearchKeyword = ref('')
const departmentPickerOpen = ref(false)
const departmentSearchKeyword = ref('')
const organizationUnitOptions = ref([])
const employeeDetailSnapshot = ref(null)
const tabs = computed(() => buildStatusTabs(employees.value))
const employeeSummary = computed(() => buildEmployeeSummary(employees.value))
const detailAge = computed(() => calculateAgeFromDate(employeeForm.value.birthDate))
const roleCount = computed(() => employeeForm.value.roleCodes.length)
const selectedRoleLabels = computed(() =>
roleOptions.value
.filter((role) => employeeForm.value.roleCodes.includes(role.code))
.map((role) => role.label)
)
const actionBusy = computed(() => actionState.value === 'save' || actionState.value === 'disable')
const actionBusy = computed(
() =>
actionState.value === 'save' ||
actionState.value === 'disable' ||
actionState.value === 'import' ||
actionState.value === 'export'
)
const importExportBusy = computed(
() => actionState.value === 'import' || actionState.value === 'export'
)
const disableActionDisabled = computed(() => actionBusy.value || !selectedEmployee.value)
const selectedEmployeeDisabled = computed(() => selectedEmployee.value?.status === '停用')
const statusActionCopy = computed(() => {
@@ -333,6 +539,94 @@ export default {
)
)
const managerOptions = computed(() => {
const currentId = selectedEmployee.value?.id
return employees.value.filter((item) => item.id !== currentId)
})
const filteredManagerOptions = computed(() => {
const keyword = managerSearchKeyword.value.trim().toLowerCase()
if (!keyword) {
return managerOptions.value.slice(0, 20)
}
return managerOptions.value
.filter((item) => {
const haystack = [
item.name,
item.employeeNo,
item.department,
item.position,
item.email
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(keyword)
})
.slice(0, 20)
})
const managerDisplayLabel = computed(() => {
const managerNo = normalizeText(employeeForm.value.managerEmployeeNo)
const managerName = normalizeText(employeeForm.value.manager)
if (managerNo) {
const matched =
managerOptions.value.find((item) => item.employeeNo === managerNo) ||
employees.value.find((item) => item.employeeNo === managerNo)
if (matched) {
return `${matched.name}${matched.employeeNo}`
}
return managerName ? `${managerName}${managerNo}` : managerNo
}
if (!isPlaceholderManagerName(managerName)) {
return managerName
}
return '未设置直属上级'
})
const filteredDepartmentOptions = computed(() => {
const keyword = departmentSearchKeyword.value.trim().toLowerCase()
const options = organizationUnitOptions.value
if (!keyword) {
return options.slice(0, 20)
}
return options
.filter((item) => {
const haystack = [item.name, item.code, item.unitType, item.label]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(keyword)
})
.slice(0, 20)
})
const departmentDisplayLabel = computed(() => {
const code = normalizeText(employeeForm.value.organizationUnitCode)
const name = normalizeText(employeeForm.value.department)
if (code) {
const matched = organizationUnitOptions.value.find((item) => item.code === code)
if (matched) {
return matched.label
}
return name ? `${name}${code}` : code
}
return name || '请选择所属部门'
})
const filteredEmployees = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
@@ -431,14 +725,66 @@ export default {
{ immediate: true }
)
function syncFormFromEmployee(employee) {
if (!employee) {
employeeForm.value = createEmployeeForm()
employeeDetailSnapshot.value = null
return
}
const preservedPassword = employeeForm.value.password
employeeForm.value = buildEmployeeForm(employee, employees.value)
employeeForm.value.password = preservedPassword
employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value)
}
watch(
selectedEmployee,
(employee) => {
employeeForm.value = buildEmployeeForm(employee)
() => selectedEmployee.value?.id ?? null,
(employeeId, previousId) => {
if (!employeeId) {
syncFormFromEmployee(null)
return
}
if (employeeId === previousId) {
return
}
syncFormFromEmployee(selectedEmployee.value)
},
{ immediate: true }
)
watch(employees, () => {
if (!selectedEmployee.value?.id) {
return
}
const preserved = selectedEmployee.value
const fromList = employees.value.find((item) => item.id === preserved.id)
if (!fromList) {
return
}
selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value)
})
const hasManagerAssignment = computed(() => {
return (
Boolean(normalizeText(employeeForm.value.managerEmployeeNo)) ||
!isPlaceholderManagerName(employeeForm.value.manager)
)
})
const recentEmployeeHistory = computed(() => {
const history = selectedEmployee.value?.history
if (!Array.isArray(history)) {
return []
}
return history.slice(0, 5)
})
watch(filteredEmployees, () => {
currentPage.value = 1
pageSizeOpen.value = false
@@ -510,15 +856,105 @@ export default {
closeFilterPopover()
}
if (!target.closest('.manager-picker')) {
closeManagerPicker()
}
if (!target.closest('.department-picker')) {
closeDepartmentPicker()
}
if (!target.closest('.page-size-wrap')) {
pageSizeOpen.value = false
}
if (target.closest('.picker-filter') || target.closest('.page-size-wrap')) {
if (
target.closest('.picker-filter') ||
target.closest('.page-size-wrap') ||
target.closest('.manager-picker') ||
target.closest('.department-picker')
) {
return
}
}
function toggleDepartmentPicker() {
departmentPickerOpen.value = !departmentPickerOpen.value
if (!departmentPickerOpen.value) {
departmentSearchKeyword.value = ''
}
}
function closeDepartmentPicker() {
departmentPickerOpen.value = false
departmentSearchKeyword.value = ''
}
function selectDepartment(option) {
if (!option) {
return
}
employeeForm.value.organizationUnitCode = option.code
employeeForm.value.department = option.name
closeDepartmentPicker()
}
function resolveDepartmentSelectionFromKeyword() {
const keyword = normalizeText(departmentSearchKeyword.value)
if (!keyword || normalizeText(employeeForm.value.organizationUnitCode)) {
return
}
const exactMatches = organizationUnitOptions.value.filter(
(item) => item.code === keyword || item.name === keyword
)
if (exactMatches.length === 1) {
selectDepartment(exactMatches[0])
}
}
function toggleManagerPicker() {
managerPickerOpen.value = !managerPickerOpen.value
if (!managerPickerOpen.value) {
managerSearchKeyword.value = ''
}
}
function closeManagerPicker() {
managerPickerOpen.value = false
managerSearchKeyword.value = ''
}
function selectManager(option) {
if (!option) {
employeeForm.value.managerEmployeeNo = ''
employeeForm.value.manager = ''
closeManagerPicker()
return
}
employeeForm.value.managerEmployeeNo = option.employeeNo
employeeForm.value.manager = option.name
closeManagerPicker()
}
function resolveManagerSelectionFromKeyword() {
const keyword = normalizeText(managerSearchKeyword.value)
if (!keyword || normalizeText(employeeForm.value.managerEmployeeNo)) {
return
}
const exactMatches = managerOptions.value.filter(
(item) => item.employeeNo === keyword || item.name === keyword
)
if (exactMatches.length === 1) {
selectManager(exactMatches[0])
}
}
function openEmployeeDetail(employee) {
selectedEmployee.value = employee
}
@@ -527,6 +963,24 @@ export default {
selectedEmployee.value = null
employeeForm.value = createEmployeeForm()
actionState.value = ''
closeManagerPicker()
closeDepartmentPicker()
}
function syncAgeFromBirthDate() {
employeeForm.value.age = calculateAgeFromDate(employeeForm.value.birthDate)
}
function syncBirthDateFromAge() {
const ageText = normalizeText(employeeForm.value.age)
if (!ageText) {
return
}
employeeForm.value.birthDate = calculateBirthDateFromAge(
ageText,
employeeForm.value.birthDate
)
}
function buildUpdatePayload() {
@@ -583,6 +1037,15 @@ export default {
payload.grade = nextGrade
}
const nextOrganizationCode = normalizeText(form.organizationUnitCode)
const currentOrganizationCode =
normalizeText(employeeDetailSnapshot.value?.organizationUnitCode) ||
resolveOrganizationUnitCode(current) ||
''
if (nextOrganizationCode !== currentOrganizationCode) {
payload.organization_unit_code = nextOrganizationCode
}
const nextFinanceOwner = normalizeNullableText(form.financeOwner)
if (nextFinanceOwner !== (current.financeOwner || null)) {
payload.finance_owner_name = nextFinanceOwner
@@ -593,10 +1056,19 @@ export default {
payload.cost_center = nextCostCenter
}
const nextManagerEmployeeNo = normalizeNullableText(form.managerEmployeeNo)
const currentManagerEmployeeNo =
normalizeNullableText(current.managerEmployeeNo) ||
resolveManagerEmployeeNo(current, employees.value) ||
null
if (nextManagerEmployeeNo !== currentManagerEmployeeNo) {
payload.manager_employee_no = nextManagerEmployeeNo || ''
}
const nextRoleCodes = [...form.roleCodes].sort()
const currentRoleCodes = [...(current.roleCodes || [])].sort()
const currentRoleCodes = [...(employeeDetailSnapshot.value?.roleCodes || current.roleCodes || [])].sort()
if (!sameValues(nextRoleCodes, currentRoleCodes)) {
payload.role_codes = form.roleCodes
payload.role_codes = [...form.roleCodes]
}
const nextPassword = normalizeText(form.password)
@@ -637,6 +1109,16 @@ export default {
return
}
const ageText = normalizeText(employeeForm.value.age)
if (ageText) {
const age = Number.parseInt(ageText, 10)
if (Number.isNaN(age) || age < 0 || age > 120) {
toast('年龄请输入 0 到 120 之间的整数。')
return
}
syncBirthDateFromAge()
}
const birthDate = normalizeNullableText(employeeForm.value.birthDate)
if (birthDate && !isValidIsoDate(birthDate)) {
toast('出生日期格式不正确,请使用 YYYY-MM-DD。')
@@ -654,18 +1136,56 @@ export default {
return
}
resolveManagerSelectionFromKeyword()
resolveDepartmentSelectionFromKeyword()
if (!normalizeText(employeeForm.value.organizationUnitCode)) {
toast('请选择所属部门。')
return
}
const payload = buildUpdatePayload()
if (!Object.keys(payload).length) {
toast('未检测到需要保存的变更。')
return
}
if (normalizeText(employeeForm.value.managerEmployeeNo) === selectedEmployee.value.employeeNo) {
toast('直属上级不能设置为员工本人。')
return
}
actionState.value = 'save'
try {
const updated = await updateEmployee(selectedEmployee.value.id, payload)
const employeeId = selectedEmployee.value.id
const updated = await updateEmployee(employeeId, payload)
selectedEmployee.value = updated
await loadEmployees()
let refreshed = updated
try {
refreshed = await fetchEmployeeDetail(employeeId)
} catch {
refreshed = updated
}
const fromList = employees.value.find((item) => item.id === employeeId)
const merged = mergeEmployeeRecords(fromList, refreshed, employees.value)
selectedEmployee.value = merged
const listIndex = employees.value.findIndex((item) => item.id === employeeId)
if (listIndex >= 0) {
employees.value[listIndex] = {
...employees.value[listIndex],
...merged
}
}
closeManagerPicker()
closeDepartmentPicker()
syncFormFromEmployee(selectedEmployee.value)
employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value)
toast('员工信息已保存并生效。')
} catch (error) {
toast(error?.message || '员工信息保存失败,请稍后重试。')
@@ -723,6 +1243,95 @@ export default {
}
}
function openImportFilePicker() {
importFileInput.value?.click()
}
function handleImportFileChange(event) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) {
return
}
pendingImportFile.value = file
importConfirmDialogOpen.value = true
}
function closeImportConfirmDialog() {
if (actionState.value === 'import') {
return
}
importConfirmDialogOpen.value = false
pendingImportFile.value = null
}
function closeImportErrorDialog() {
importErrorDialogOpen.value = false
importErrors.value = []
importResultMessage.value = ''
}
async function handleDownloadTemplate() {
try {
await downloadEmployeeImportTemplate()
toast('员工导入模板已开始下载。')
} catch (error) {
toast(error?.message || '模板下载失败,请稍后重试。')
}
}
async function handleExportEmployees() {
actionState.value = 'export'
try {
await exportEmployees({
status: activeTab.value,
keyword: searchKeyword.value.trim()
})
toast('员工目录已开始导出。')
} catch (error) {
toast(error?.message || '员工导出失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function confirmImportEmployees() {
const file = pendingImportFile.value
if (!file) {
closeImportConfirmDialog()
return
}
actionState.value = 'import'
try {
const result = await importEmployees(file)
if (!result?.success) {
importErrors.value = Array.isArray(result?.errors) ? result.errors : []
importResultMessage.value =
result?.message || '导入未执行,请根据下方错误提示修正 Excel 后重试。'
importConfirmDialogOpen.value = false
importErrorDialogOpen.value = true
pendingImportFile.value = null
return
}
importConfirmDialogOpen.value = false
pendingImportFile.value = null
await loadEmployees()
toast(result.message || '员工导入成功。')
} catch (error) {
toast(error?.message || '员工导入失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function loadEmployees() {
loading.value = true
errorMessage.value = ''
@@ -735,6 +1344,7 @@ export default {
if (employeesResult.status !== 'fulfilled') {
employees.value = []
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
organizationUnitOptions.value = []
selectedEmployee.value = null
errorMessage.value =
employeesResult.reason?.message || '员工数据加载失败,请稍后重试。'
@@ -742,12 +1352,17 @@ export default {
return
}
employees.value = Array.isArray(employeesResult.value) ? employeesResult.value : []
const roster = Array.isArray(employeesResult.value) ? employeesResult.value : []
employees.value = roster.map((item) => enrichEmployeeRecord(item, roster))
if (metaResult.status === 'fulfilled') {
roleOptions.value = resolveRoleOptions(metaResult.value?.roleOptions, employees.value)
organizationUnitOptions.value = resolveOrganizationOptions(
metaResult.value?.organizationOptions
)
} else {
roleOptions.value = resolveRoleOptions([], employees.value)
organizationUnitOptions.value = []
}
if (!DEFAULT_STATUS_TABS.includes(activeTab.value)) {
@@ -755,8 +1370,9 @@ export default {
}
if (selectedEmployee.value) {
selectedEmployee.value =
employees.value.find((item) => item.id === selectedEmployee.value.id) || null
const preserved = selectedEmployee.value
const fromList = employees.value.find((item) => item.id === preserved.id) || null
selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value)
}
loading.value = false
@@ -767,6 +1383,7 @@ export default {
loadEmployees().catch((error) => {
employees.value = []
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
organizationUnitOptions.value = []
selectedEmployee.value = null
errorMessage.value = error?.message || '员工数据加载失败,请稍后重试。'
loading.value = false
@@ -781,13 +1398,27 @@ export default {
tabs,
activeTab,
employeeForm,
detailAge,
roleCount,
syncAgeFromBirthDate,
syncBirthDateFromAge,
selectedRoleLabels,
selectedEmployeeDisabled,
statusActionCopy,
actionState,
actionBusy,
importExportBusy,
importFileInput,
importConfirmDialogOpen,
importErrorDialogOpen,
importErrors,
importResultMessage,
openImportFilePicker,
handleImportFileChange,
closeImportConfirmDialog,
closeImportErrorDialog,
handleDownloadTemplate,
handleExportEmployees,
confirmImportEmployees,
disableActionDisabled,
selectedEmployee,
roleOptions,
@@ -806,6 +1437,25 @@ export default {
departmentOptions,
gradeOptions,
roleFilterOptions,
managerPickerOpen,
managerSearchKeyword,
managerDisplayLabel,
hasManagerAssignment,
departmentPickerOpen,
departmentSearchKeyword,
departmentDisplayLabel,
filteredDepartmentOptions,
toggleDepartmentPicker,
closeDepartmentPicker,
selectDepartment,
resolveDepartmentSelectionFromKeyword,
recentEmployeeHistory,
formatEmployeeHistoryTime,
filteredManagerOptions,
toggleManagerPicker,
closeManagerPicker,
selectManager,
resolveManagerSelectionFromKeyword,
activeFilterTokens,
hasActiveFilters,
hasEmployeeFilters,

View File

@@ -3,6 +3,7 @@ import { useRouter } from 'vue-router'
import LogTrendChart from '../../components/charts/LogTrendChart.vue'
import DonutChart from '../../components/charts/DonutChart.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { fetchAgentRuns } from '../../services/agentAssets.js'
import { fetchSystemLogEntries } from '../../services/systemLogs.js'
import { useSystemState } from '../../composables/useSystemState.js'
@@ -219,7 +220,8 @@ export default {
name: 'LogsView',
components: {
LogTrendChart,
DonutChart
DonutChart,
TableLoadingState
},
emits: ['summary-change'],
setup(_, { emit }) {

View File

@@ -1,6 +1,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
@@ -81,7 +82,8 @@ function setBodyScrollLocked(isLocked) {
export default {
name: 'PoliciesView',
components: {
ConfirmDialog
ConfirmDialog,
TableLoadingState
},
emits: ['summary-change'],
setup(_, { emit }) {

View File

@@ -1,5 +1,6 @@
import { computed, ref, watch } from 'vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
@@ -11,6 +12,7 @@ function extractRowDate(value) {
export default {
name: 'RequestsView',
components: {
TableLoadingState,
TableEmptyState
},
props: {

View File

@@ -43,6 +43,24 @@ const INTENT_LABELS = {
operate: '动作请求'
}
const REVIEW_RISK_LEVEL_META = {
high: {
label: '高风险',
icon: 'mdi mdi-alert-octagon-outline',
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
},
warning: {
label: '需关注',
icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
},
info: {
label: '提示',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况继续核对。'
}
}
const DOCUMENT_TYPE_LABELS = {
travel_ticket: '行程单/机票/车票',
flight_itinerary: '机票/航班行程单',
@@ -1503,7 +1521,7 @@ function buildDraftSavedPayload({
secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态',
secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据',
secondaryStatusTone: documents.length ? 'warning' : 'neutral',
riskSummary: riskItems[0] || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
riskSummary: riskItems[0]?.summary || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
attachmentSummary,
expenseTableSummary: documents.length
? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认`
@@ -2451,16 +2469,43 @@ function buildReviewRiskSummary(reviewPayload) {
return '当前版本暂未生成风险评分结果。'
}
function normalizeReviewRiskLevel(level) {
const normalized = String(level || '').trim().toLowerCase()
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
if (normalized === 'warn' || normalized === 'medium') return 'warning'
if (normalized === 'high' || normalized === 'warning' || normalized === 'info') return normalized
return 'info'
}
function buildReviewRiskItems(reviewPayload) {
return resolveReviewRiskBriefs(reviewPayload)
.map((brief) => {
.map((brief, index) => {
const title = String(brief?.title || '').trim()
const content = String(brief?.content || '').trim()
if (title && content) return `${title}${content}`
return content || title
const detail = String(brief?.detail || '').trim()
const suggestion = String(brief?.suggestion || '').trim()
const level = normalizeReviewRiskLevel(brief?.level)
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.info
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
const normalizedTitle = title || fallbackTitle
const summary = content || normalizedTitle
if (!normalizedTitle && !summary) return null
return {
key: `${level}-${normalizedTitle}-${index}`,
title: normalizedTitle,
summary,
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
level,
levelLabel: meta.label,
icon: meta.icon,
sourceLabel: title === '历史报销画像' ? '历史记录' : 'AI预审',
suggestion: suggestion || meta.suggestion
}
})
.filter(Boolean)
.slice(0, 4)
.slice(0, 6)
}
function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
@@ -2904,6 +2949,7 @@ export default {
const composerRangeStartDate = ref(formatDateInputValue())
const composerRangeEndDate = ref(formatDateInputValue())
const composerBusinessTimeTags = ref([])
const composerBusinessTimeDraftTouched = ref(false)
const attachedFiles = ref([])
const composerFilesExpanded = ref(false)
const submitting = ref(false)
@@ -2947,6 +2993,10 @@ export default {
const activeReviewDocumentIndex = ref(0)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const insightPanelCollapsed = ref(false)
const reviewRiskDetailDialog = ref({
open: false,
item: null
})
const documentPreviewDialog = ref({
open: false,
filename: '',
@@ -3107,7 +3157,6 @@ export default {
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
const reviewRiskActionAvailable = computed(() => reviewRiskItems.value.length > 0)
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value))
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
@@ -3932,6 +3981,91 @@ export default {
return `业务发生时间:${composerRangeStartDate.value}${composerRangeEndDate.value}`
}
function hasComposerBusinessTimeSelection() {
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
}
function buildComposerBusinessTimeContext() {
if (!hasComposerBusinessTimeSelection()) {
return null
}
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return null
}
const displayValue = mode === 'range' && startDate !== endDate
? `${startDate}${endDate}`
: startDate
return {
mode,
start_date: startDate,
end_date: endDate,
occurred_date: startDate,
time_range: displayValue,
business_time: displayValue,
time_range_raw: buildComposerBusinessTimeLabel()
}
}
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
if (!businessTimeContext) {
return extraContext
}
const baseReviewFormValues =
extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
? extraContext.review_form_values
: {}
return {
...extraContext,
occurred_date: businessTimeContext.occurred_date,
business_time: businessTimeContext.business_time,
business_time_context: {
mode: businessTimeContext.mode,
start_date: businessTimeContext.start_date,
end_date: businessTimeContext.end_date,
display_value: businessTimeContext.business_time
},
review_form_values: {
...baseReviewFormValues,
occurred_date: businessTimeContext.occurred_date,
time_range: businessTimeContext.time_range,
business_time: businessTimeContext.business_time,
time_range_raw: businessTimeContext.time_range_raw
}
}
}
function syncComposerBusinessTimeToReviewCard(businessTimeContext) {
if (!businessTimeContext || !activeReviewPayload.value) {
return
}
const nextInlineState = {
...reviewInlineForm.value,
occurred_date: businessTimeContext.occurred_date
}
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
reviewInlineForm.value = nextInlineState
if (latestReviewMessage.value) {
latestReviewMessage.value.reviewPayload = nextReviewPayload
}
if (currentInsight.value?.agent) {
currentInsight.value = {
...currentInsight.value,
agent: {
...currentInsight.value.agent,
reviewPayload: nextReviewPayload
}
}
}
}
function resolveComposerSubmitText(explicitRawText) {
const draftPart = String(explicitRawText ?? composerDraft.value).trim()
const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join('')
@@ -3956,8 +4090,16 @@ export default {
composerDateMode.value = mode === 'range' ? 'range' : 'single'
}
function handleComposerDateInputChange() {
composerBusinessTimeDraftTouched.value = true
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
}
function removeComposerBusinessTimeTag(tagId) {
composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId)
if (!composerBusinessTimeTags.value.length) {
composerBusinessTimeDraftTouched.value = false
}
}
function handleComposerDatePickerOutside(event) {
@@ -3975,12 +4117,14 @@ export default {
return
}
composerBusinessTimeDraftTouched.value = true
composerBusinessTimeTags.value = [
{
id: `biz-time-${Date.now()}`,
label: buildComposerBusinessTimeLabel()
}
]
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
composerDatePickerOpen.value = false
await nextTick()
adjustComposerTextareaHeight()
@@ -4432,13 +4576,19 @@ export default {
})
}
function explainCurrentReviewRisk() {
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
submitComposer({
rawText: '请解释一下当前这笔报销的合规风险和待补充项。',
userText: '查看全部风险项',
systemGenerated: true
})
function openReviewRiskDetail(item) {
if (!item) return
reviewRiskDetailDialog.value = {
open: true,
item
}
}
function closeReviewRiskDetail() {
reviewRiskDetailDialog.value = {
...reviewRiskDetailDialog.value,
open: false
}
}
function goReviewDocument(direction) {
@@ -4642,9 +4792,13 @@ export default {
}
if (!rawText && !files.length) return
const extraContext = options.extraContext && typeof options.extraContext === 'object'
const initialExtraContext = options.extraContext && typeof options.extraContext === 'object'
? { ...options.extraContext }
: {}
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
const extraContext = isKnowledgeSession.value
? initialExtraContext
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim()
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
const hasExistingDocumentEvent =
@@ -4699,6 +4853,7 @@ export default {
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
@@ -4769,6 +4924,12 @@ export default {
department_name: user.department || user.departmentName || '',
position: user.position || '',
grade: user.grade || '',
employee_no: user.employeeNo || user.employee_no || '',
manager_name: user.managerName || user.manager_name || '',
employee_location: user.location || '',
cost_center: user.costCenter || user.cost_center || '',
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
...buildClientTimeContext(),
session_type: activeSessionType.value,
entry_source: props.entrySource,
@@ -4802,16 +4963,6 @@ export default {
? ''
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
try {
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
} catch (error) {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已识别,但附件持久化失败,请重试上传。')
}
}
replaceMessage(
pendingMessage.id,
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
@@ -4832,6 +4983,14 @@ export default {
mergeFilePreviews(filePreviews, ocrFilePreviews)
)
completeFlowResult(payload, flowRunDetail)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
syncComposerFilesToDraft(resolvedDraftClaimId, files).catch((error) => {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
})
}
} catch (error) {
clearFlowSimulationTimers()
failCurrentFlowStep(error)
@@ -5144,6 +5303,7 @@ export default {
toggleComposerDatePicker,
closeComposerDatePicker,
setComposerDateMode,
handleComposerDateInputChange,
removeComposerBusinessTimeTag,
flowSteps,
flowRunId,
@@ -5213,7 +5373,7 @@ export default {
reviewRiskSummary,
reviewRiskItems,
reviewRiskEmpty,
reviewRiskActionAvailable,
reviewRiskDetailDialog,
recognizedNarratives,
reviewRecognitionNotes,
reviewDocumentSummaries,
@@ -5298,7 +5458,8 @@ export default {
selectReviewCategory,
selectReviewOtherCategory,
queryDraftByClaimNo,
explainCurrentReviewRisk,
openReviewRiskDetail,
closeReviewRiskDetail,
goReviewDocument,
openActiveReviewDocumentPreview,
closeDocumentPreview,

View File

@@ -1,5 +1,6 @@
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import {
@@ -9,10 +10,12 @@ import {
deleteExpenseClaim,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview,
returnExpenseClaim,
submitExpenseClaim,
uploadExpenseClaimItemAttachment,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import { canManageExpenseClaims } from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
const EXPENSE_TYPE_OPTIONS = [
@@ -380,6 +383,7 @@ export default {
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
setup(props, { emit }) {
const { toast } = useToast()
const { currentUser } = useSystemState()
const editingExpenseId = ref('')
const savingExpenseId = ref('')
const creatingExpense = ref(false)
@@ -390,6 +394,8 @@ export default {
const submitBusy = ref(false)
const deleteBusy = ref(false)
const deleteDialogOpen = ref(false)
const returnBusy = ref(false)
const returnDialogOpen = ref(false)
const expenseUploadInput = ref(null)
const expenseAttachmentMeta = reactive({})
const attachmentPreviewOpen = ref(false)
@@ -448,10 +454,25 @@ export default {
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const canDeleteRequest = computed(() => isDraftRequest.value || canManageCurrentClaim.value)
const canReturnRequest = computed(() =>
canManageCurrentClaim.value
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
)
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
const deleteDialogDescription = computed(() =>
isDraftRequest.value
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
: '删除后该报销单及费用明细将不可恢复,请确认本次操作。'
)
const actionBusy = computed(() =>
Boolean(savingExpenseId.value)
|| submitBusy.value
|| deleteBusy.value
|| returnBusy.value
|| creatingExpense.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
@@ -1105,9 +1126,14 @@ export default {
}
}
async function handleDeleteDraft() {
async function handleDeleteRequest() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法删除。')
toast('当前单据缺少 claimId暂时无法删除。')
return
}
if (!canDeleteRequest.value) {
toast('当前单据已进入流程,只有财务人员或高级管理人员可以删除。')
return
}
@@ -1122,9 +1148,9 @@ export default {
deleteDialogOpen.value = false
}
async function confirmDeleteDraft() {
async function confirmDeleteRequest() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法删除。')
toast('当前单据缺少 claimId暂时无法删除。')
return
}
@@ -1132,15 +1158,58 @@ export default {
try {
const payload = await deleteExpenseClaim(request.value.claimId)
deleteDialogOpen.value = false
toast(payload?.message || `${request.value.id} 草稿已删除。`)
toast(payload?.message || `${request.value.id} 报销单已删除。`)
emit('request-deleted', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '删除草稿失败,请稍后重试。')
toast(error?.message || '删除单据失败,请稍后重试。')
} finally {
deleteBusy.value = false
}
}
function handleReturnRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
}
if (!canReturnRequest.value) {
toast('当前状态不支持退回。')
return
}
returnDialogOpen.value = true
}
function closeReturnDialog() {
if (returnBusy.value) {
return
}
returnDialogOpen.value = false
}
async function confirmReturnRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
}
returnBusy.value = true
try {
await returnExpenseClaim(request.value.claimId, {
reason: '详情页退回,请申请人补充后重新提交。'
})
returnDialogOpen.value = false
toast(`${request.value.id} 已退回待补充。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '退回单据失败,请稍后重试。')
} finally {
returnBusy.value = false
}
}
function openAiEntry() {
emit('openAssistant', {
source: 'detail',
@@ -1164,14 +1233,22 @@ export default {
attachmentPreviewName,
attachmentPreviewOpen,
attachmentPreviewUrl,
canDeleteRequest,
canManageCurrentClaim,
canReturnRequest,
canSubmit,
canPreviewAttachment,
closeDeleteDialog,
closeAttachmentPreview,
confirmDeleteDraft,
closeReturnDialog,
confirmDeleteRequest,
confirmReturnRequest,
currentProgressRingMotion,
deleteActionLabel,
deleteBusy,
deleteDialogDescription,
deleteDialogOpen,
deleteDialogTitle,
deletingAttachmentId,
deletingExpenseId,
detailNote,
@@ -1186,8 +1263,9 @@ export default {
expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
handleAddExpenseItem,
handleDeleteDraft,
handleDeleteRequest,
handleExpenseFileChange,
handleReturnRequest,
handleSubmit,
hasExpenseRiskColumn,
heroFactItems,
@@ -1205,6 +1283,8 @@ export default {
resolveAttachmentRecognition,
resolveExpenseRiskState,
resolveExpenseIssues,
returnBusy,
returnDialogOpen,
savingExpenseId,
showExpenseRisk,
startExpenseEdit,