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

直属领导和财务节点下可处理的报销单据会直接展示在这里。

+
@@ -502,6 +529,38 @@
+ + + + diff --git a/web/src/views/AuditView.vue b/web/src/views/AuditView.vue index c7492ca..2c166d9 100644 --- a/web/src/views/AuditView.vue +++ b/web/src/views/AuditView.vue @@ -64,6 +64,40 @@
{{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }} {{ selectedSkill.displayVersion || selectedSkill.version }} +
+
+ 最近更新 + {{ selectedSkill.updatedAt }} +
+
+ + +
+ +
+ 资产详情加载失败 +

{{ detailError }}

+
+
+ + + +
+
+
+
{{ selectedSkill.typeLabel }}
+

{{ selectedSkill.name }}

{{ selectedSkill.summary || '当前资产尚未补充说明。' }}

@@ -71,7 +105,7 @@
- {{ selectedSpreadsheetModeLabel }} + {{ selectedSpreadsheetModeLabel }}
@@ -99,10 +133,15 @@ class="rule-spreadsheet-host" :class="{ hidden: !spreadsheetOnlyOfficeReady && !spreadsheetOnlyOfficeError }" > -
- - 正在加载 Excel 规则表... -
+
{{ spreadsheetOnlyOfficeError }} @@ -121,34 +160,34 @@
- +

暂无修改记录

+ + @@ -319,10 +358,15 @@ -
- - 正在刷新规则详情... -
+