Compare commits

...

4 Commits

Author SHA1 Message Date
焦龙言
faa09101a1 更新部分功能实现逻辑 2026-06-22 13:23:04 +08:00
焦龙言
1e6230d412 feat: group sidebar navigation 2026-06-18 14:43:00 +08:00
焦龙言
dc74a618f7 feat: add realtime attendance and monthly payroll menus 2026-06-18 14:26:14 +08:00
焦龙言
a846ea5070 feat: add monthly payroll center tables 2026-06-18 14:23:49 +08:00
40 changed files with 5686 additions and 308 deletions

View File

@ -17,7 +17,8 @@
## 当前能力
- 支持导入钉钉月度汇总 Excel 计算工资。
- 支持预留钉钉实时打卡接口计算入口。
- 支持实时考勤看板:按员工档案中的钉钉 userId 自动同步本月至今打卡,不需要人工输入 userId。
- 支持月度核算中心:按月份导入 Excel 或同步钉钉计算,生成异常清单、核算批次、锁定状态和导出入口。
- 自动统计出勤天数、缺勤天数、请假工时、缺卡、迟到扣款。
- 按下班打卡时间自动计算加班17:00 下班18:00 计 1 小时20:30 计 3 小时。
- 自动计算:`剩余加班工时 = 打卡推算加班工时 - 请假工时`。
@ -28,6 +29,7 @@
- 加班费支持:工作日、周末、法定节假日独立单价。
- 提成支持手工维护和 Excel 导入,并按工资月份自动汇总进工资计算。
- 工资计算结果会落库为工资汇总、工资明细、考勤记录、请假记录和加班记录。
- 实时薪资预估只用于过程查看,最终工资以月度核算锁定结果为准。
## 项目结构
@ -144,6 +146,7 @@ mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/in
```bash
mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260617_payroll_modules.sql
mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260618_monthly_payroll_center.sql
```
SQL 文件说明:
@ -154,6 +157,7 @@ SQL 文件说明:
| `seed.sql` | 初始化默认超级管理员 |
| `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 |
| `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 |
| `upgrade_20260618_monthly_payroll_center.sql` | 实时考勤同步、薪资预估、月度核算批次和异常清单升级脚本 |
主要业务表:
@ -170,6 +174,10 @@ SQL 文件说明:
| `commission_record` | 提成/奖金记录 |
| `payroll_jobs` | 工资计算任务 |
| `payroll_results` | 员工工资计算结果 |
| `attendance_sync_jobs` | 钉钉/Excel 考勤同步批次 |
| `salary_preview` | 本月进行中的薪资预估 |
| `monthly_payroll_runs` | 月度正式核算批次 |
| `payroll_exceptions` | 月度核算异常清单 |
| `salary_record` | 月度工资汇总 |
| `salary_detail` | 工资明细项 |
| `salary_config` | 规则配置中心 |
@ -227,6 +235,8 @@ config/salary_rules.example.json
| --- | --- | --- |
| 登录 | `/login` | 用户登录 |
| 工作台 | `/dashboard` | 系统概览 |
| 实时考勤 | `/attendance/realtime` | 查看今日打卡、迟到、缺卡、请假、加班和本月薪资预估 |
| 月度核算 | `/payroll/monthly` | 按月导入/同步、计算、异常处理、锁定和导出 |
| 工资计算 | `/payroll` | Excel 导入和钉钉实时计算 |
| 计算记录 | `/payroll/jobs` | 查看历史计算任务 |
| 提成管理 | `/payroll/commissions` | 手工维护和 Excel 导入提成 |
@ -277,7 +287,32 @@ curl -X POST "http://127.0.0.1:8000/api/payroll/excel" \
-F "export_excel=true"
```
钉钉实时计算:
实时考勤看板同步钉钉:
```bash
curl -X POST "http://127.0.0.1:8000/api/attendance/realtime/sync" \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"attendance_date":"2026-06-22","include_salary_preview":true}'
```
查看实时考勤看板:
```bash
curl "http://127.0.0.1:8000/api/attendance/realtime/today?date=2026-06-22" \
-H "Authorization: Bearer <access_token>"
```
月度核算同步钉钉并计算:
```bash
curl -X POST "http://127.0.0.1:8000/api/payroll/monthly/dingtalk" \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"salary_month":"2026-06","source_type":"dingtalk","export_excel":true}'
```
旧版钉钉实时计算接口仍可使用,但需要手动传 user_ids
```bash
curl -X POST "http://127.0.0.1:8000/api/payroll/dingtalk/realtime" \

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,20 @@ from ..core.settings import PROJECT_ROOT, get_settings
from ..database import SessionLocal, init_database
from ..repositories import OrganizationRepository, SalaryConfigRepository, UserRepository
from ..services import AuthService, OrganizationService, SalaryConfigService
from .routers import auth, commissions, configs, employees, health, operation_logs, organization, payroll, reports, salary_profiles
from .routers import (
auth,
commissions,
configs,
employees,
health,
monthly_payroll,
operation_logs,
organization,
payroll,
realtime_attendance,
reports,
salary_profiles,
)
logger = AppLogger.get_logger(__name__)
@ -56,9 +69,11 @@ def create_app() -> FastAPI:
app.include_router(configs.router)
app.include_router(employees.router)
app.include_router(health.router)
app.include_router(monthly_payroll.router)
app.include_router(operation_logs.router)
app.include_router(organization.router)
app.include_router(payroll.router)
app.include_router(realtime_attendance.router)
app.include_router(reports.router)
app.include_router(salary_profiles.router)
return app

View File

@ -16,9 +16,11 @@ from ..database.orm import UserORM
from ..repositories import (
CommissionRepository,
EmployeeRepository,
MonthlyPayrollRepository,
OperationLogRepository,
OrganizationRepository,
PayrollRepository,
RealtimeAttendanceRepository,
ReportRepository,
SalaryConfigRepository,
SalaryProfileRepository,
@ -28,9 +30,11 @@ from ..services import (
AuthService,
CommissionService,
EmployeeService,
MonthlyPayrollService,
OperationLogService,
OrganizationService,
PayrollApplicationService,
RealtimeAttendanceService,
ReportService,
SalaryConfigService,
SalaryProfileService,
@ -58,6 +62,14 @@ def get_payroll_repository(session: Session = Depends(get_db_session)) -> Payrol
return PayrollRepository(session)
def get_monthly_payroll_repository(session: Session = Depends(get_db_session)) -> MonthlyPayrollRepository:
return MonthlyPayrollRepository(session)
def get_realtime_attendance_repository(session: Session = Depends(get_db_session)) -> RealtimeAttendanceRepository:
return RealtimeAttendanceRepository(session)
def get_commission_repository(session: Session = Depends(get_db_session)) -> CommissionRepository:
return CommissionRepository(session)
@ -162,6 +174,25 @@ def get_payroll_service(
)
def get_realtime_attendance_service(
repository: RealtimeAttendanceRepository = Depends(get_realtime_attendance_repository),
payroll_service: PayrollApplicationService = Depends(get_payroll_service),
) -> RealtimeAttendanceService:
return RealtimeAttendanceService(repository=repository, payroll_service=payroll_service)
def get_monthly_payroll_service(
repository: MonthlyPayrollRepository = Depends(get_monthly_payroll_repository),
payroll_service: PayrollApplicationService = Depends(get_payroll_service),
employee_repository: EmployeeRepository = Depends(get_employee_repository),
) -> MonthlyPayrollService:
return MonthlyPayrollService(
repository=repository,
payroll_service=payroll_service,
employee_repository=employee_repository,
)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
repository: UserRepository = Depends(get_user_repository),

View File

@ -0,0 +1,285 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from ...core.logger import AppLogger
from ...core.permissions import (
PERMISSION_MONTHLY_PAYROLL_CALCULATE,
PERMISSION_MONTHLY_PAYROLL_EXPORT,
PERMISSION_MONTHLY_PAYROLL_LOCK,
PERMISSION_MONTHLY_PAYROLL_VIEW,
)
from ...database.orm import MonthlyPayrollRunORM, PayrollExceptionORM, UserORM
from ...integrations.dingtalk import DingTalkError
from ...services import MonthlyPayrollDetailData, MonthlyPayrollService, OperationLogService
from ..dependencies import get_monthly_payroll_service, get_operation_log_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.monthly_payroll import (
MonthlyPayrollCreateRequest,
MonthlyPayrollDetailResponse,
MonthlyPayrollRunResponse,
PayrollExceptionItem,
)
from ..schemas.payroll import PayrollResultDTO
router = APIRouter(prefix="/api/payroll/monthly", tags=["monthly-payroll"])
logger = AppLogger.get_logger(__name__)
@router.get("/runs", response_model=list[MonthlyPayrollRunResponse])
def list_monthly_runs(
month: str | None = None,
_: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_VIEW)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
) -> list[MonthlyPayrollRunResponse]:
"""按月份查看月度核算批次。"""
return [_run_response(run) for run in service.list_runs(month)]
@router.get("/runs/{run_id}", response_model=MonthlyPayrollDetailResponse)
def get_monthly_run(
http_request: Request,
run_id: int,
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_VIEW)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> MonthlyPayrollDetailResponse:
"""查看月度核算批次明细、工资结果和异常清单。"""
try:
detail = service.get_detail(run_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.view",
target_type="monthly_payroll_run",
target_id=str(run_id),
status="success",
detail=f"查看月度核算 run_id={run_id}",
**operation_request_meta(http_request),
)
return _detail_response(detail)
@router.post("/excel", response_model=MonthlyPayrollDetailResponse)
def calculate_monthly_from_excel(
http_request: Request,
file: UploadFile = File(...),
salary_month: str = Form(...),
export_excel: bool = Form(default=True),
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_CALCULATE)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> MonthlyPayrollDetailResponse:
"""导入 Excel 并生成月度正式核算批次。"""
try:
detail = service.calculate_from_excel(
salary_month=salary_month,
filename=file.filename or "attendance.xlsx",
stream=file.file,
created_by=current_user.username,
export_excel=export_excel,
)
except ValueError as exc:
_record_failed(operation_logs, current_user, http_request, "monthly_payroll.excel.import", str(exc))
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.excel.import",
target_type="monthly_payroll_run",
target_id=str(detail.run.id),
status="success",
detail=f"Excel月度核算成功 salary_month={salary_month} employee_count={detail.run.employee_count}",
**operation_request_meta(http_request),
)
return _detail_response(detail)
@router.post("/dingtalk", response_model=MonthlyPayrollDetailResponse)
async def calculate_monthly_from_dingtalk(
http_request: Request,
request: MonthlyPayrollCreateRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_CALCULATE)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> MonthlyPayrollDetailResponse:
"""按员工档案中的钉钉 userId 自动同步整月考勤并生成核算批次。"""
try:
detail = await service.calculate_from_dingtalk(
salary_month=request.salary_month,
created_by=current_user.username,
export_excel=request.export_excel,
)
except ValueError as exc:
_record_failed(operation_logs, current_user, http_request, "monthly_payroll.dingtalk.calculate", str(exc))
raise HTTPException(status_code=400, detail=str(exc)) from exc
except DingTalkError as exc:
_record_failed(operation_logs, current_user, http_request, "monthly_payroll.dingtalk.calculate", str(exc))
raise HTTPException(status_code=502, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.dingtalk.calculate",
target_type="monthly_payroll_run",
target_id=str(detail.run.id),
status="success",
detail=f"钉钉月度核算成功 salary_month={request.salary_month} employee_count={detail.run.employee_count}",
**operation_request_meta(http_request),
)
return _detail_response(detail)
@router.post("/runs/{run_id}/recalculate", response_model=MonthlyPayrollDetailResponse)
async def recalculate_monthly_run(
http_request: Request,
run_id: int,
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_CALCULATE)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> MonthlyPayrollDetailResponse:
"""重新计算钉钉来源的月度核算批次。"""
detail = service.get_detail(run_id)
if detail.run.status == "locked":
raise HTTPException(status_code=400, detail="本月工资已锁定,不能重新计算")
if detail.run.source_type != "dingtalk":
raise HTTPException(status_code=400, detail="Excel 来源请重新导入文件后计算")
detail = await service.calculate_from_dingtalk(
salary_month=detail.run.salary_month,
created_by=current_user.username,
export_excel=True,
)
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.recalculate",
target_type="monthly_payroll_run",
target_id=str(run_id),
status="success",
detail=f"重新计算月度核算 run_id={run_id}",
**operation_request_meta(http_request),
)
return _detail_response(detail)
@router.post("/runs/{run_id}/lock", response_model=MonthlyPayrollRunResponse)
def lock_monthly_run(
http_request: Request,
run_id: int,
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_LOCK)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> MonthlyPayrollRunResponse:
"""锁定月度工资,锁定后不允许重新计算或覆盖。"""
try:
run = service.lock_run(run_id, current_user.username)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.lock",
target_type="monthly_payroll_run",
target_id=str(run_id),
status="success",
detail=f"锁定月度核算 run_id={run_id}",
**operation_request_meta(http_request),
)
return _run_response(run)
@router.get("/runs/{run_id}/export")
def export_monthly_run(
http_request: Request,
run_id: int,
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_EXPORT)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> FileResponse:
"""导出月度工资表。"""
try:
path = service.export_path(run_id)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.export",
target_type="monthly_payroll_run",
target_id=str(run_id),
status="success",
detail=f"导出月度核算 run_id={run_id} path={path}",
**operation_request_meta(http_request),
)
return FileResponse(
path,
filename=path.name,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
def _detail_response(detail: MonthlyPayrollDetailData) -> MonthlyPayrollDetailResponse:
return MonthlyPayrollDetailResponse(
run=_run_response(detail.run),
results=[PayrollResultDTO.from_orm_model(result) for result in detail.job.results] if detail.job else [],
exceptions=[_exception_response(item) for item in detail.exceptions],
)
def _run_response(run: MonthlyPayrollRunORM) -> MonthlyPayrollRunResponse:
return MonthlyPayrollRunResponse(
id=run.id,
salary_month=run.salary_month,
source_type=run.source_type,
source_job_id=run.source_job_id,
status=run.status,
employee_count=run.employee_count,
gross_total=run.gross_total,
net_total=run.net_total,
deduction_total=run.deduction_total,
overtime_total=run.overtime_total,
locked_by=run.locked_by,
locked_at=run.locked_at,
created_by=run.created_by,
created_at=run.created_at,
updated_at=run.updated_at,
)
def _exception_response(item: PayrollExceptionORM) -> PayrollExceptionItem:
return PayrollExceptionItem(
id=item.id,
run_id=item.run_id,
sync_job_id=item.sync_job_id,
employee_id=item.employee_id,
employee_no=item.employee_no,
employee_name=item.employee_name,
salary_month=item.salary_month,
exception_type=item.exception_type,
severity=item.severity,
message=item.message,
status=item.status,
created_at=item.created_at,
resolved_at=item.resolved_at,
)
def _record_failed(
operation_logs: OperationLogService,
current_user: UserORM,
http_request: Request,
action: str,
reason: str,
) -> None:
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action=action,
target_type="monthly_payroll_run",
target_id="",
status="failed",
detail=reason,
**operation_request_meta(http_request),
)

View File

@ -0,0 +1,95 @@
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ...core.logger import AppLogger
from ...core.permissions import PERMISSION_ATTENDANCE_REALTIME_SYNC, PERMISSION_ATTENDANCE_REALTIME_VIEW
from ...database.orm import UserORM
from ...integrations.dingtalk import DingTalkError
from ...services import OperationLogService, RealtimeAttendanceService
from ..dependencies import get_operation_log_service, get_realtime_attendance_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.realtime_attendance import RealtimeAttendanceResponse, RealtimeAttendanceSyncRequest
router = APIRouter(prefix="/api/attendance/realtime", tags=["realtime-attendance"])
logger = AppLogger.get_logger(__name__)
@router.get("/today", response_model=RealtimeAttendanceResponse)
def get_today_attendance(
http_request: Request,
attendance_date: date = Query(alias="date"),
current_user: UserORM = Depends(require_permission(PERMISSION_ATTENDANCE_REALTIME_VIEW)),
service: RealtimeAttendanceService = Depends(get_realtime_attendance_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> RealtimeAttendanceResponse:
"""查看某一天的实时考勤看板,通常用于今日打卡状态。"""
response = service.get_today(attendance_date)
operation_logs.record(
actor=current_user,
module="attendance",
action="attendance.realtime.view",
target_type="attendance_date",
target_id=attendance_date.isoformat(),
status="success",
detail=f"查看实时考勤 attendance_date={attendance_date} employee_count={response.summary.employee_total}",
**operation_request_meta(http_request),
)
return RealtimeAttendanceResponse(**response.to_dict())
@router.post("/sync", response_model=RealtimeAttendanceResponse)
async def sync_realtime_attendance(
http_request: Request,
request: RealtimeAttendanceSyncRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_ATTENDANCE_REALTIME_SYNC)),
service: RealtimeAttendanceService = Depends(get_realtime_attendance_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> RealtimeAttendanceResponse:
"""按员工档案中的钉钉 userId 自动同步本月至今打卡,并刷新今日看板。"""
try:
response = await service.sync_today(
attendance_date=request.attendance_date,
created_by=current_user.username,
include_salary_preview=request.include_salary_preview,
)
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="attendance",
action="attendance.realtime.sync",
target_type="attendance_sync_job",
target_id="",
status="failed",
detail=f"实时考勤同步失败 attendance_date={request.attendance_date} reason={exc}",
**operation_request_meta(http_request),
)
logger.error("实时考勤同步参数错误 attendance_date=%s reason=%s", request.attendance_date, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except DingTalkError as exc:
operation_logs.record(
actor=current_user,
module="attendance",
action="attendance.realtime.sync",
target_type="attendance_sync_job",
target_id="",
status="failed",
detail=f"实时考勤钉钉调用失败 attendance_date={request.attendance_date} reason={exc}",
**operation_request_meta(http_request),
)
logger.error("实时考勤钉钉调用失败 attendance_date=%s reason=%s", request.attendance_date, exc)
raise HTTPException(status_code=502, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="attendance",
action="attendance.realtime.sync",
target_type="attendance_sync_job",
target_id=response.sync_job_id or "",
status="success",
detail=f"实时考勤同步成功 attendance_date={request.attendance_date} employee_count={response.summary.employee_total}",
**operation_request_meta(http_request),
)
return RealtimeAttendanceResponse(**response.to_dict())

View File

@ -14,6 +14,18 @@ from .payroll import (
PayrollJobResponse,
PayrollResultDTO,
)
from .monthly_payroll import (
MonthlyPayrollCreateRequest,
MonthlyPayrollDetailResponse,
MonthlyPayrollRunResponse,
PayrollExceptionItem,
)
from .realtime_attendance import (
RealtimeAttendanceResponse,
RealtimeAttendanceSummary,
RealtimeAttendanceSyncRequest,
TodayAttendanceItem,
)
from .operation_log import OperationLogDTO, OperationLogListResponse
from .employee import EmployeeListResponse, EmployeeNoResponse, EmployeeRequest, EmployeeResponse, EmployeeUpdateRequest
from .salary_profile import SalaryProfileRequest, SalaryProfileResponse
@ -32,12 +44,20 @@ __all__ = [
"ExcelPayrollResponse",
"LoginRequest",
"LoginResponse",
"MonthlyPayrollCreateRequest",
"MonthlyPayrollDetailResponse",
"MonthlyPayrollRunResponse",
"OperationLogDTO",
"OperationLogListResponse",
"PayrollJobResponse",
"PayrollExceptionItem",
"PayrollResultDTO",
"RealtimeAttendanceResponse",
"RealtimeAttendanceSummary",
"RealtimeAttendanceSyncRequest",
"SalaryProfileRequest",
"SalaryProfileResponse",
"TodayAttendanceItem",
"UserProfileDTO",
"UserResponse",
]

View File

@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from .payroll import PayrollResultDTO
class MonthlyPayrollCreateRequest(BaseModel):
"""月度核算创建请求,用于钉钉计算。"""
salary_month: str
source_type: str = "dingtalk"
export_excel: bool = True
class MonthlyPayrollRunResponse(BaseModel):
"""月度核算批次响应。"""
id: int
salary_month: str
source_type: str
source_job_id: str
status: str
employee_count: int
gross_total: float
net_total: float
deduction_total: float
overtime_total: float
locked_by: str
locked_at: datetime | None
created_by: str
created_at: datetime
updated_at: datetime
class PayrollExceptionItem(BaseModel):
"""月度核算异常响应。"""
id: int
run_id: int | None
sync_job_id: str | None
employee_id: int | None
employee_no: str
employee_name: str
salary_month: str
exception_type: str
severity: str
message: str
status: str
created_at: datetime
resolved_at: datetime | None
class MonthlyPayrollDetailResponse(BaseModel):
"""月度核算批次详情。"""
run: MonthlyPayrollRunResponse
results: list[PayrollResultDTO]
exceptions: list[PayrollExceptionItem]

View File

@ -0,0 +1,55 @@
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel
class RealtimeAttendanceSyncRequest(BaseModel):
"""实时考勤同步请求。"""
attendance_date: date
include_salary_preview: bool = True
class TodayAttendanceItem(BaseModel):
"""实时考勤看板中的员工行。"""
employee_id: int | None
employee_no: str
employee_name: str
department: str
position: str
first_punch: str
last_punch: str
attendance_status: str
late_minutes: int
missing_card_count: int
leave_hours: float
today_overtime_hours: float
month_remaining_overtime_hours: float
estimated_net_salary: float | None
note: str
class RealtimeAttendanceSummary(BaseModel):
"""实时考勤看板摘要。"""
attendance_date: date
employee_total: int
checked_in_count: int
unchecked_count: int
late_count: int
leave_count: int
missing_card_count: int
estimated_overtime_count: int
estimated_net_total: float
class RealtimeAttendanceResponse(BaseModel):
"""实时考勤看板响应。"""
sync_job_id: str | None
synced_at: datetime | None
summary: RealtimeAttendanceSummary
items: list[TodayAttendanceItem]

View File

@ -12,6 +12,12 @@ PERMISSION_PAYROLL_EXCEL_CALCULATE = "payroll:excel:calculate"
PERMISSION_PAYROLL_DINGTALK_CALCULATE = "payroll:dingtalk:calculate"
PERMISSION_PAYROLL_JOB_VIEW = "payroll:job:view"
PERMISSION_PAYROLL_FILE_DOWNLOAD = "payroll:file:download"
PERMISSION_ATTENDANCE_REALTIME_VIEW = "attendance:realtime:view"
PERMISSION_ATTENDANCE_REALTIME_SYNC = "attendance:realtime:sync"
PERMISSION_MONTHLY_PAYROLL_VIEW = "monthly_payroll:view"
PERMISSION_MONTHLY_PAYROLL_CALCULATE = "monthly_payroll:calculate"
PERMISSION_MONTHLY_PAYROLL_LOCK = "monthly_payroll:lock"
PERMISSION_MONTHLY_PAYROLL_EXPORT = "monthly_payroll:export"
PERMISSION_USER_CREATE = "user:create"
PERMISSION_USER_LIST = "user:list"
PERMISSION_OPERATION_LOG_VIEW = "operation_log:view"
@ -38,6 +44,8 @@ class MenuPermission:
MENUS = {
"attendance_realtime": MenuPermission(code="attendance_realtime", name="实时考勤", path="/attendance/realtime"),
"monthly_payroll": MenuPermission(code="monthly_payroll", name="月度核算", path="/payroll/monthly"),
"payroll": MenuPermission(code="payroll", name="工资计算", path="/payroll"),
"jobs": MenuPermission(code="jobs", name="计算记录", path="/payroll/jobs"),
"employees": MenuPermission(code="employees", name="员工维护", path="/system/employees"),
@ -52,6 +60,8 @@ MENUS = {
ROLE_MENU_CODES = {
ROLE_SUPERUSER: (
"attendance_realtime",
"monthly_payroll",
"payroll",
"jobs",
"employees",
@ -64,6 +74,8 @@ ROLE_MENU_CODES = {
"operation_logs",
),
ROLE_MANAGER: (
"attendance_realtime",
"monthly_payroll",
"payroll",
"jobs",
"employees",
@ -74,11 +86,17 @@ ROLE_MENU_CODES = {
"configs",
"operation_logs",
),
ROLE_VIEWER: ("jobs", "reports"),
ROLE_VIEWER: ("attendance_realtime", "jobs", "reports"),
}
ROLE_OPERATION_PERMISSIONS = {
ROLE_SUPERUSER: (
PERMISSION_ATTENDANCE_REALTIME_VIEW,
PERMISSION_ATTENDANCE_REALTIME_SYNC,
PERMISSION_MONTHLY_PAYROLL_VIEW,
PERMISSION_MONTHLY_PAYROLL_CALCULATE,
PERMISSION_MONTHLY_PAYROLL_LOCK,
PERMISSION_MONTHLY_PAYROLL_EXPORT,
PERMISSION_PAYROLL_EXCEL_CALCULATE,
PERMISSION_PAYROLL_DINGTALK_CALCULATE,
PERMISSION_PAYROLL_JOB_VIEW,
@ -99,6 +117,12 @@ ROLE_OPERATION_PERMISSIONS = {
PERMISSION_REPORT_VIEW,
),
ROLE_MANAGER: (
PERMISSION_ATTENDANCE_REALTIME_VIEW,
PERMISSION_ATTENDANCE_REALTIME_SYNC,
PERMISSION_MONTHLY_PAYROLL_VIEW,
PERMISSION_MONTHLY_PAYROLL_CALCULATE,
PERMISSION_MONTHLY_PAYROLL_LOCK,
PERMISSION_MONTHLY_PAYROLL_EXPORT,
PERMISSION_PAYROLL_EXCEL_CALCULATE,
PERMISSION_PAYROLL_DINGTALK_CALCULATE,
PERMISSION_PAYROLL_JOB_VIEW,
@ -117,6 +141,7 @@ ROLE_OPERATION_PERMISSIONS = {
PERMISSION_REPORT_VIEW,
),
ROLE_VIEWER: (
PERMISSION_ATTENDANCE_REALTIME_VIEW,
PERMISSION_PAYROLL_JOB_VIEW,
PERMISSION_PAYROLL_FILE_DOWNLOAD,
PERMISSION_REPORT_VIEW,

View File

@ -441,6 +441,172 @@ class OvertimeRecordORM(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
class AttendanceSyncJobORM(Base):
"""钉钉或 Excel 考勤同步批次,用于实时看板和月度核算追踪。"""
__tablename__ = "attendance_sync_jobs"
__table_args__ = {
"comment": "考勤同步任务表记录钉钉实时同步或Excel导入的批次状态",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="同步任务IDUUID十六进制字符串")
source_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="数据来源dingtalk钉钉、excel表格")
sync_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="today", index=True, comment="同步模式today今日、month本月至今、range自定义范围")
attendance_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True, comment="实时看板对应考勤日期")
salary_month: Mapped[str] = mapped_column(String(7), nullable=False, default="", index=True, comment="工资月份格式YYYY-MM")
start_date: Mapped[date | None] = mapped_column(Date, nullable=True, comment="同步开始日期")
end_date: Mapped[date | None] = mapped_column(Date, nullable=True, comment="同步结束日期")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="created", index=True, comment="任务状态created、running、completed、failed")
employee_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="本次同步涉及员工数量")
record_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="本次同步获得或解析的考勤记录数量")
error_message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="任务失败时的错误信息")
created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="创建人用户名快照")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)
class SalaryPreviewORM(Base):
"""本月进行中的薪资预估结果,不作为最终工资发放依据。"""
__tablename__ = "salary_preview"
__table_args__ = (
UniqueConstraint("sync_job_id", "employee_id", name="uq_salary_preview_sync_employee"),
{
"comment": "实时薪资预估表,保存当前同步批次下的员工级薪资预估",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="薪资预估主键ID")
sync_job_id: Mapped[str] = mapped_column(
ForeignKey("attendance_sync_jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="关联考勤同步任务ID",
)
employee_id: Mapped[int | None] = mapped_column(
ForeignKey("employees.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="关联员工ID未匹配员工时为空",
)
employee_no: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True, comment="员工编号快照")
employee_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="员工姓名快照")
department: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="部门名称快照")
position: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="岗位名称快照")
salary_month: Mapped[str] = mapped_column(String(7), nullable=False, default="", index=True, comment="工资月份格式YYYY-MM")
attendance_days: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="本月当前出勤天数")
actual_work_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="本月当前实际工时")
punch_overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="打卡推算加班工时")
leave_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="请假或调休工时")
remaining_overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="剩余加班工时")
late_deduction: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当前迟到扣款")
missing_card_deduction: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当前缺卡扣款")
commission_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当前已录入提成")
overtime_pay: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当前加班费")
estimated_gross_salary: Mapped[float | None] = mapped_column(Float, nullable=True, comment="当前预估应发工资")
estimated_net_salary: Mapped[float | None] = mapped_column(Float, nullable=True, comment="当前预估实发工资")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="estimated", index=True, comment="预估状态estimated已预估、warning有异常")
note: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="预估备注或异常说明")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)
sync_job: Mapped[AttendanceSyncJobORM] = relationship()
employee: Mapped[EmployeeORM | None] = relationship()
class MonthlyPayrollRunORM(Base):
"""月度正式核算批次,支持复核、锁定和导出。"""
__tablename__ = "monthly_payroll_runs"
__table_args__ = {
"comment": "月度工资核算批次表,记录每月工资计算、复核和锁定状态",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="月度核算批次主键ID")
salary_month: Mapped[str] = mapped_column(String(7), nullable=False, index=True, comment="工资月份格式YYYY-MM")
source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="数据来源excel或dingtalk")
source_job_id: Mapped[str] = mapped_column(String(32), nullable=False, default="", index=True, comment="来源任务ID可关联工资任务或同步任务")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft", index=True, comment="核算状态draft、calculated、reviewed、locked、failed")
employee_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="参与核算员工数量")
gross_total: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="应发工资合计")
net_total: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="实发工资合计")
deduction_total: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="扣款合计")
overtime_total: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="剩余加班工时合计")
locked_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="锁定人用户名快照")
locked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="锁定时间")
created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="创建人用户名快照")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)
class PayrollExceptionORM(Base):
"""核算异常记录,例如缺员工、缺薪资档案、缺卡、请假无法识别。"""
__tablename__ = "payroll_exceptions"
__table_args__ = {
"comment": "工资核算异常表,保存月度核算或实时同步过程中发现的待处理问题",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="异常记录主键ID")
run_id: Mapped[int | None] = mapped_column(
ForeignKey("monthly_payroll_runs.id", ondelete="CASCADE"),
nullable=True,
index=True,
comment="关联月度核算批次ID",
)
sync_job_id: Mapped[str | None] = mapped_column(
ForeignKey("attendance_sync_jobs.id", ondelete="CASCADE"),
nullable=True,
index=True,
comment="关联考勤同步任务ID",
)
employee_id: Mapped[int | None] = mapped_column(
ForeignKey("employees.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="关联员工ID未匹配员工时为空",
)
employee_no: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True, comment="员工编号快照")
employee_name: Mapped[str] = mapped_column(String(128), nullable=False, default="", index=True, comment="员工姓名快照")
salary_month: Mapped[str] = mapped_column(String(7), nullable=False, default="", index=True, comment="工资月份格式YYYY-MM")
exception_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="异常类型编码")
severity: Mapped[str] = mapped_column(String(32), nullable=False, default="warning", index=True, comment="严重级别info、warning、error")
message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="异常说明")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="open", index=True, comment="处理状态open待处理、resolved已处理、ignored已忽略")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="处理完成时间")
run: Mapped[MonthlyPayrollRunORM | None] = relationship()
sync_job: Mapped[AttendanceSyncJobORM | None] = relationship()
employee: Mapped[EmployeeORM | None] = relationship()
class CommissionRecordORM(Base):
"""月度提成或奖金记录,支持手工维护和 Excel 导入。"""

View File

@ -474,6 +474,125 @@ CREATE TABLE IF NOT EXISTS `overtime_record` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额';
CREATE TABLE IF NOT EXISTS `attendance_sync_jobs` (
`id` VARCHAR(32) NOT NULL COMMENT '同步任务IDUUID十六进制字符串',
`source_type` VARCHAR(32) NOT NULL COMMENT '数据来源dingtalk钉钉、excel表格',
`sync_mode` VARCHAR(32) NOT NULL DEFAULT 'today' COMMENT '同步模式today今日、month本月至今、range自定义范围',
`attendance_date` DATE NULL COMMENT '实时看板对应考勤日期',
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM',
`start_date` DATE NULL COMMENT '同步开始日期',
`end_date` DATE NULL COMMENT '同步结束日期',
`status` VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '任务状态created、running、completed、failed',
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步涉及员工数量',
`record_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步获得或解析的考勤记录数量',
`error_message` TEXT NOT NULL COMMENT '任务失败时的错误信息',
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `ix_attendance_sync_jobs_source_type` (`source_type`),
KEY `ix_attendance_sync_jobs_sync_mode` (`sync_mode`),
KEY `ix_attendance_sync_jobs_attendance_date` (`attendance_date`),
KEY `ix_attendance_sync_jobs_salary_month` (`salary_month`),
KEY `ix_attendance_sync_jobs_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='考勤同步任务表记录钉钉实时同步或Excel导入的批次状态';
CREATE TABLE IF NOT EXISTS `salary_preview` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资预估主键ID',
`sync_job_id` VARCHAR(32) NOT NULL COMMENT '关联考勤同步任务ID',
`employee_id` INT NULL COMMENT '关联员工ID未匹配员工时为空',
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
`employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
`position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称快照',
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM',
`attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前出勤天数',
`actual_work_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前实际工时',
`punch_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '打卡推算加班工时',
`leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假或调休工时',
`remaining_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时',
`late_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前迟到扣款',
`missing_card_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前缺卡扣款',
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '当前已录入提成',
`overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '当前加班费',
`estimated_gross_salary` FLOAT NULL COMMENT '当前预估应发工资',
`estimated_net_salary` FLOAT NULL COMMENT '当前预估实发工资',
`status` VARCHAR(32) NOT NULL DEFAULT 'estimated' COMMENT '预估状态estimated已预估、warning有异常',
`note` TEXT NOT NULL COMMENT '预估备注或异常说明',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_salary_preview_sync_employee` (`sync_job_id`, `employee_id`),
KEY `ix_salary_preview_sync_job_id` (`sync_job_id`),
KEY `ix_salary_preview_employee_id` (`employee_id`),
KEY `ix_salary_preview_employee_no` (`employee_no`),
KEY `ix_salary_preview_employee_name` (`employee_name`),
KEY `ix_salary_preview_salary_month` (`salary_month`),
KEY `ix_salary_preview_status` (`status`),
CONSTRAINT `fk_salary_preview_sync_job_id`
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_salary_preview_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='实时薪资预估表,保存当前同步批次下的员工级薪资预估';
CREATE TABLE IF NOT EXISTS `monthly_payroll_runs` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '月度核算批次主键ID',
`salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份格式YYYY-MM',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '数据来源excel或dingtalk',
`source_job_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源任务ID可关联工资任务或同步任务',
`status` VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '核算状态draft、calculated、reviewed、locked、failed',
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '参与核算员工数量',
`gross_total` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资合计',
`net_total` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资合计',
`deduction_total` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计',
`overtime_total` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时合计',
`locked_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '锁定人用户名快照',
`locked_at` DATETIME NULL COMMENT '锁定时间',
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `ix_monthly_payroll_runs_salary_month` (`salary_month`),
KEY `ix_monthly_payroll_runs_source_job_id` (`source_job_id`),
KEY `ix_monthly_payroll_runs_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='月度工资核算批次表,记录每月工资计算、复核和锁定状态';
CREATE TABLE IF NOT EXISTS `payroll_exceptions` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '异常记录主键ID',
`run_id` INT NULL COMMENT '关联月度核算批次ID',
`sync_job_id` VARCHAR(32) NULL COMMENT '关联考勤同步任务ID',
`employee_id` INT NULL COMMENT '关联员工ID未匹配员工时为空',
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
`employee_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM',
`exception_type` VARCHAR(64) NOT NULL COMMENT '异常类型编码',
`severity` VARCHAR(32) NOT NULL DEFAULT 'warning' COMMENT '严重级别info、warning、error',
`message` TEXT NOT NULL COMMENT '异常说明',
`status` VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT '处理状态open待处理、resolved已处理、ignored已忽略',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`resolved_at` DATETIME NULL COMMENT '处理完成时间',
PRIMARY KEY (`id`),
KEY `ix_payroll_exceptions_run_id` (`run_id`),
KEY `ix_payroll_exceptions_sync_job_id` (`sync_job_id`),
KEY `ix_payroll_exceptions_employee_id` (`employee_id`),
KEY `ix_payroll_exceptions_employee_no` (`employee_no`),
KEY `ix_payroll_exceptions_employee_name` (`employee_name`),
KEY `ix_payroll_exceptions_salary_month` (`salary_month`),
KEY `ix_payroll_exceptions_exception_type` (`exception_type`),
KEY `ix_payroll_exceptions_severity` (`severity`),
KEY `ix_payroll_exceptions_status` (`status`),
CONSTRAINT `fk_payroll_exceptions_run_id`
FOREIGN KEY (`run_id`) REFERENCES `monthly_payroll_runs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_payroll_exceptions_sync_job_id`
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_payroll_exceptions_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='工资核算异常表,保存月度核算或实时同步过程中发现的待处理问题';
CREATE TABLE IF NOT EXISTS `commission_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID',
`employee_id` INT NOT NULL COMMENT '关联员工ID',

View File

@ -384,6 +384,125 @@ CREATE TABLE IF NOT EXISTS `overtime_record` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额';
CREATE TABLE IF NOT EXISTS `attendance_sync_jobs` (
`id` VARCHAR(32) NOT NULL COMMENT '同步任务IDUUID十六进制字符串',
`source_type` VARCHAR(32) NOT NULL COMMENT '数据来源dingtalk钉钉、excel表格',
`sync_mode` VARCHAR(32) NOT NULL DEFAULT 'today' COMMENT '同步模式today今日、month本月至今、range自定义范围',
`attendance_date` DATE NULL COMMENT '实时看板对应考勤日期',
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM',
`start_date` DATE NULL COMMENT '同步开始日期',
`end_date` DATE NULL COMMENT '同步结束日期',
`status` VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '任务状态created、running、completed、failed',
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步涉及员工数量',
`record_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步获得或解析的考勤记录数量',
`error_message` TEXT NOT NULL COMMENT '任务失败时的错误信息',
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `ix_attendance_sync_jobs_source_type` (`source_type`),
KEY `ix_attendance_sync_jobs_sync_mode` (`sync_mode`),
KEY `ix_attendance_sync_jobs_attendance_date` (`attendance_date`),
KEY `ix_attendance_sync_jobs_salary_month` (`salary_month`),
KEY `ix_attendance_sync_jobs_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='考勤同步任务表记录钉钉实时同步或Excel导入的批次状态';
CREATE TABLE IF NOT EXISTS `salary_preview` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资预估主键ID',
`sync_job_id` VARCHAR(32) NOT NULL COMMENT '关联考勤同步任务ID',
`employee_id` INT NULL COMMENT '关联员工ID未匹配员工时为空',
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
`employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
`position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称快照',
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM',
`attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前出勤天数',
`actual_work_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前实际工时',
`punch_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '打卡推算加班工时',
`leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假或调休工时',
`remaining_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时',
`late_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前迟到扣款',
`missing_card_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前缺卡扣款',
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '当前已录入提成',
`overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '当前加班费',
`estimated_gross_salary` FLOAT NULL COMMENT '当前预估应发工资',
`estimated_net_salary` FLOAT NULL COMMENT '当前预估实发工资',
`status` VARCHAR(32) NOT NULL DEFAULT 'estimated' COMMENT '预估状态estimated已预估、warning有异常',
`note` TEXT NOT NULL COMMENT '预估备注或异常说明',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_salary_preview_sync_employee` (`sync_job_id`, `employee_id`),
KEY `ix_salary_preview_sync_job_id` (`sync_job_id`),
KEY `ix_salary_preview_employee_id` (`employee_id`),
KEY `ix_salary_preview_employee_no` (`employee_no`),
KEY `ix_salary_preview_employee_name` (`employee_name`),
KEY `ix_salary_preview_salary_month` (`salary_month`),
KEY `ix_salary_preview_status` (`status`),
CONSTRAINT `fk_salary_preview_sync_job_id`
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_salary_preview_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='实时薪资预估表,保存当前同步批次下的员工级薪资预估';
CREATE TABLE IF NOT EXISTS `monthly_payroll_runs` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '月度核算批次主键ID',
`salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份格式YYYY-MM',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '数据来源excel或dingtalk',
`source_job_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源任务ID可关联工资任务或同步任务',
`status` VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '核算状态draft、calculated、reviewed、locked、failed',
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '参与核算员工数量',
`gross_total` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资合计',
`net_total` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资合计',
`deduction_total` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计',
`overtime_total` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时合计',
`locked_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '锁定人用户名快照',
`locked_at` DATETIME NULL COMMENT '锁定时间',
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `ix_monthly_payroll_runs_salary_month` (`salary_month`),
KEY `ix_monthly_payroll_runs_source_job_id` (`source_job_id`),
KEY `ix_monthly_payroll_runs_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='月度工资核算批次表,记录每月工资计算、复核和锁定状态';
CREATE TABLE IF NOT EXISTS `payroll_exceptions` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '异常记录主键ID',
`run_id` INT NULL COMMENT '关联月度核算批次ID',
`sync_job_id` VARCHAR(32) NULL COMMENT '关联考勤同步任务ID',
`employee_id` INT NULL COMMENT '关联员工ID未匹配员工时为空',
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
`employee_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM',
`exception_type` VARCHAR(64) NOT NULL COMMENT '异常类型编码',
`severity` VARCHAR(32) NOT NULL DEFAULT 'warning' COMMENT '严重级别info、warning、error',
`message` TEXT NOT NULL COMMENT '异常说明',
`status` VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT '处理状态open待处理、resolved已处理、ignored已忽略',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`resolved_at` DATETIME NULL COMMENT '处理完成时间',
PRIMARY KEY (`id`),
KEY `ix_payroll_exceptions_run_id` (`run_id`),
KEY `ix_payroll_exceptions_sync_job_id` (`sync_job_id`),
KEY `ix_payroll_exceptions_employee_id` (`employee_id`),
KEY `ix_payroll_exceptions_employee_no` (`employee_no`),
KEY `ix_payroll_exceptions_employee_name` (`employee_name`),
KEY `ix_payroll_exceptions_salary_month` (`salary_month`),
KEY `ix_payroll_exceptions_exception_type` (`exception_type`),
KEY `ix_payroll_exceptions_severity` (`severity`),
KEY `ix_payroll_exceptions_status` (`status`),
CONSTRAINT `fk_payroll_exceptions_run_id`
FOREIGN KEY (`run_id`) REFERENCES `monthly_payroll_runs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_payroll_exceptions_sync_job_id`
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_payroll_exceptions_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='工资核算异常表,保存月度核算或实时同步过程中发现的待处理问题';
CREATE TABLE IF NOT EXISTS `commission_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID',
`employee_id` INT NOT NULL COMMENT '关联员工ID',

View File

@ -0,0 +1,124 @@
-- 2026-06-18 月度核算中心与实时考勤看板升级脚本。
-- Execute with:
-- mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260618_monthly_payroll_center.sql
USE `financial_system`;
CREATE TABLE IF NOT EXISTS `attendance_sync_jobs` (
`id` VARCHAR(32) NOT NULL COMMENT '同步任务IDUUID十六进制字符串',
`source_type` VARCHAR(32) NOT NULL COMMENT '数据来源dingtalk钉钉、excel表格',
`sync_mode` VARCHAR(32) NOT NULL DEFAULT 'today' COMMENT '同步模式today今日、month本月至今、range自定义范围',
`attendance_date` DATE NULL COMMENT '实时看板对应考勤日期',
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM',
`start_date` DATE NULL COMMENT '同步开始日期',
`end_date` DATE NULL COMMENT '同步结束日期',
`status` VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '任务状态created、running、completed、failed',
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步涉及员工数量',
`record_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步获得或解析的考勤记录数量',
`error_message` TEXT NOT NULL COMMENT '任务失败时的错误信息',
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `ix_attendance_sync_jobs_source_type` (`source_type`),
KEY `ix_attendance_sync_jobs_sync_mode` (`sync_mode`),
KEY `ix_attendance_sync_jobs_attendance_date` (`attendance_date`),
KEY `ix_attendance_sync_jobs_salary_month` (`salary_month`),
KEY `ix_attendance_sync_jobs_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='考勤同步任务表记录钉钉实时同步或Excel导入的批次状态';
CREATE TABLE IF NOT EXISTS `salary_preview` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资预估主键ID',
`sync_job_id` VARCHAR(32) NOT NULL COMMENT '关联考勤同步任务ID',
`employee_id` INT NULL COMMENT '关联员工ID未匹配员工时为空',
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
`employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
`position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称快照',
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM',
`attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前出勤天数',
`actual_work_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前实际工时',
`punch_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '打卡推算加班工时',
`leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假或调休工时',
`remaining_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时',
`late_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前迟到扣款',
`missing_card_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前缺卡扣款',
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '当前已录入提成',
`overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '当前加班费',
`estimated_gross_salary` FLOAT NULL COMMENT '当前预估应发工资',
`estimated_net_salary` FLOAT NULL COMMENT '当前预估实发工资',
`status` VARCHAR(32) NOT NULL DEFAULT 'estimated' COMMENT '预估状态estimated已预估、warning有异常',
`note` TEXT NOT NULL COMMENT '预估备注或异常说明',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_salary_preview_sync_employee` (`sync_job_id`, `employee_id`),
KEY `ix_salary_preview_sync_job_id` (`sync_job_id`),
KEY `ix_salary_preview_employee_id` (`employee_id`),
KEY `ix_salary_preview_employee_no` (`employee_no`),
KEY `ix_salary_preview_employee_name` (`employee_name`),
KEY `ix_salary_preview_salary_month` (`salary_month`),
KEY `ix_salary_preview_status` (`status`),
CONSTRAINT `fk_salary_preview_sync_job_id`
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_salary_preview_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='实时薪资预估表,保存当前同步批次下的员工级薪资预估';
CREATE TABLE IF NOT EXISTS `monthly_payroll_runs` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '月度核算批次主键ID',
`salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份格式YYYY-MM',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '数据来源excel或dingtalk',
`source_job_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源任务ID可关联工资任务或同步任务',
`status` VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '核算状态draft、calculated、reviewed、locked、failed',
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '参与核算员工数量',
`gross_total` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资合计',
`net_total` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资合计',
`deduction_total` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计',
`overtime_total` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时合计',
`locked_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '锁定人用户名快照',
`locked_at` DATETIME NULL COMMENT '锁定时间',
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `ix_monthly_payroll_runs_salary_month` (`salary_month`),
KEY `ix_monthly_payroll_runs_source_job_id` (`source_job_id`),
KEY `ix_monthly_payroll_runs_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='月度工资核算批次表,记录每月工资计算、复核和锁定状态';
CREATE TABLE IF NOT EXISTS `payroll_exceptions` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '异常记录主键ID',
`run_id` INT NULL COMMENT '关联月度核算批次ID',
`sync_job_id` VARCHAR(32) NULL COMMENT '关联考勤同步任务ID',
`employee_id` INT NULL COMMENT '关联员工ID未匹配员工时为空',
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
`employee_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM',
`exception_type` VARCHAR(64) NOT NULL COMMENT '异常类型编码',
`severity` VARCHAR(32) NOT NULL DEFAULT 'warning' COMMENT '严重级别info、warning、error',
`message` TEXT NOT NULL COMMENT '异常说明',
`status` VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT '处理状态open待处理、resolved已处理、ignored已忽略',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`resolved_at` DATETIME NULL COMMENT '处理完成时间',
PRIMARY KEY (`id`),
KEY `ix_payroll_exceptions_run_id` (`run_id`),
KEY `ix_payroll_exceptions_sync_job_id` (`sync_job_id`),
KEY `ix_payroll_exceptions_employee_id` (`employee_id`),
KEY `ix_payroll_exceptions_employee_no` (`employee_no`),
KEY `ix_payroll_exceptions_employee_name` (`employee_name`),
KEY `ix_payroll_exceptions_salary_month` (`salary_month`),
KEY `ix_payroll_exceptions_exception_type` (`exception_type`),
KEY `ix_payroll_exceptions_severity` (`severity`),
KEY `ix_payroll_exceptions_status` (`status`),
CONSTRAINT `fk_payroll_exceptions_run_id`
FOREIGN KEY (`run_id`) REFERENCES `monthly_payroll_runs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_payroll_exceptions_sync_job_id`
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_payroll_exceptions_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='工资核算异常表,保存月度核算或实时同步过程中发现的待处理问题';

View File

@ -0,0 +1,77 @@
from __future__ import annotations
from dataclasses import dataclass
from .models import PayrollResult
@dataclass(frozen=True)
class PayrollException:
"""工资核算异常,供月度核算服务转换为数据库记录。"""
employee_id: int | None
employee_no: str
employee_name: str
salary_month: str
exception_type: str
severity: str
message: str
def detect_payroll_exceptions(results: list[PayrollResult]) -> list[PayrollException]:
"""根据工资计算结果识别需要人事或财务复核的异常。"""
exceptions: list[PayrollException] = []
for result in results:
employee = result.employee
base = {
"employee_id": None,
"employee_no": employee.employee_no,
"employee_name": employee.name,
"salary_month": result.salary_month,
}
if result.base_pay is None:
exceptions.append(
PayrollException(
**base,
exception_type="missing_salary_profile",
severity="error",
message="员工未配置薪资档案,无法计算应发和实发工资",
)
)
if result.missing_card_count > 0:
exceptions.append(
PayrollException(
**base,
exception_type="missing_card",
severity="warning",
message=f"缺卡 {result.missing_card_count} 次,已产生扣款 {result.missing_card_deduction:.2f}",
)
)
if result.penalized_late_count > 0:
exceptions.append(
PayrollException(
**base,
exception_type="late_penalty",
severity="warning",
message=f"迟到扣款 {result.penalized_late_count} 次,扣款 {result.late_deduction:.2f}",
)
)
if result.remaining_overtime_hours < 0:
exceptions.append(
PayrollException(
**base,
exception_type="negative_remaining_overtime",
severity="warning",
message=f"请假工时超过加班工时,剩余加班 {result.remaining_overtime_hours:.1f} 小时",
)
)
if result.attendance_days == 0:
exceptions.append(
PayrollException(
**base,
exception_type="empty_attendance",
severity="error",
message="本月出勤天数为 0请检查考勤数据或员工状态",
)
)
return exceptions

View File

@ -2,7 +2,9 @@ from .commission_repository import CommissionRepository
from .employee_repository import EmployeeRepository
from .operation_log_repository import OperationLogRepository
from .organization_repository import OrganizationRepository
from .monthly_payroll_repository import MonthlyPayrollRepository
from .payroll_repository import PayrollRepository
from .realtime_attendance_repository import RealtimeAttendanceRepository
from .report_repository import ReportRepository
from .salary_profile_repository import SalaryProfileRepository
from .salary_config_repository import SalaryConfigRepository
@ -13,7 +15,9 @@ __all__ = [
"EmployeeRepository",
"OperationLogRepository",
"OrganizationRepository",
"MonthlyPayrollRepository",
"PayrollRepository",
"RealtimeAttendanceRepository",
"ReportRepository",
"SalaryConfigRepository",
"SalaryProfileRepository",

View File

@ -0,0 +1,122 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy.orm import Session
from ..database.orm import MonthlyPayrollRunORM, PayrollExceptionORM
class MonthlyPayrollRepository:
"""月度核算仓库:维护核算批次和异常清单。"""
def __init__(self, session: Session):
self.session = session
def create_run(
self,
*,
salary_month: str,
source_type: str,
source_job_id: str,
created_by: str,
status: str = "calculated",
) -> MonthlyPayrollRunORM:
existing = self.get_run_by_month(salary_month)
if existing and existing.status == "locked":
raise ValueError("本月工资已锁定,不能重新计算")
if existing is not None:
existing.source_type = source_type
existing.source_job_id = source_job_id
existing.status = status
existing.created_by = created_by
existing.updated_at = datetime.utcnow()
self.session.commit()
self.session.refresh(existing)
return existing
run = MonthlyPayrollRunORM(
salary_month=salary_month,
source_type=source_type,
source_job_id=source_job_id,
status=status,
created_by=created_by,
)
self.session.add(run)
self.session.commit()
self.session.refresh(run)
return run
def get_run(self, run_id: int) -> MonthlyPayrollRunORM | None:
return self.session.query(MonthlyPayrollRunORM).filter(MonthlyPayrollRunORM.id == run_id).one_or_none()
def require_run(self, run_id: int) -> MonthlyPayrollRunORM:
run = self.get_run(run_id)
if run is None:
raise ValueError("月度核算批次不存在")
return run
def get_run_by_month(self, salary_month: str) -> MonthlyPayrollRunORM | None:
return (
self.session.query(MonthlyPayrollRunORM)
.filter(MonthlyPayrollRunORM.salary_month == salary_month)
.order_by(MonthlyPayrollRunORM.updated_at.desc(), MonthlyPayrollRunORM.id.desc())
.first()
)
def list_runs(self, salary_month: str | None = None) -> list[MonthlyPayrollRunORM]:
query = self.session.query(MonthlyPayrollRunORM)
if salary_month:
query = query.filter(MonthlyPayrollRunORM.salary_month == salary_month)
return query.order_by(MonthlyPayrollRunORM.salary_month.desc(), MonthlyPayrollRunORM.id.desc()).all()
def update_run_totals(
self,
run_id: int,
*,
employee_count: int,
gross_total: float,
net_total: float,
deduction_total: float,
overtime_total: float,
status: str = "calculated",
) -> MonthlyPayrollRunORM:
run = self.require_run(run_id)
if run.status == "locked":
raise ValueError("本月工资已锁定,不能重新计算")
run.employee_count = employee_count
run.gross_total = gross_total
run.net_total = net_total
run.deduction_total = deduction_total
run.overtime_total = overtime_total
run.status = status
run.updated_at = datetime.utcnow()
self.session.commit()
self.session.refresh(run)
return run
def lock_run(self, run_id: int, locked_by: str) -> MonthlyPayrollRunORM:
run = self.require_run(run_id)
run.status = "locked"
run.locked_by = locked_by
run.locked_at = datetime.utcnow()
run.updated_at = datetime.utcnow()
self.session.commit()
self.session.refresh(run)
return run
def replace_exceptions(self, run_id: int, rows: list[PayrollExceptionORM]) -> None:
self.session.query(PayrollExceptionORM).filter(PayrollExceptionORM.run_id == run_id).delete(
synchronize_session=False
)
if rows:
self.session.add_all(rows)
self.session.commit()
def list_exceptions(self, run_id: int) -> list[PayrollExceptionORM]:
return (
self.session.query(PayrollExceptionORM)
.filter(PayrollExceptionORM.run_id == run_id)
.order_by(PayrollExceptionORM.severity.desc(), PayrollExceptionORM.id.asc())
.all()
)

View File

@ -0,0 +1,147 @@
from __future__ import annotations
from datetime import date, datetime
from uuid import uuid4
from sqlalchemy.orm import Session
from ..database.orm import AttendanceRecordORM, AttendanceSyncJobORM, EmployeeORM, SalaryPreviewORM
class RealtimeAttendanceRepository:
"""实时考勤仓库:封装同步批次、考勤记录和薪资预估的数据库读写。"""
def __init__(self, session: Session):
self.session = session
def list_active_employees(self) -> list[EmployeeORM]:
return (
self.session.query(EmployeeORM)
.filter(EmployeeORM.employment_status == "active")
.order_by(EmployeeORM.id.asc())
.all()
)
def list_active_employees_with_dingtalk(self) -> list[EmployeeORM]:
return (
self.session.query(EmployeeORM)
.filter(EmployeeORM.employment_status == "active")
.filter(EmployeeORM.dingtalk_user_id != "")
.order_by(EmployeeORM.id.asc())
.all()
)
def create_sync_job(
self,
*,
source_type: str,
sync_mode: str,
attendance_date: date,
salary_month: str,
start_date: date,
end_date: date,
created_by: str,
) -> AttendanceSyncJobORM:
job = AttendanceSyncJobORM(
id=uuid4().hex,
source_type=source_type,
sync_mode=sync_mode,
attendance_date=attendance_date,
salary_month=salary_month,
start_date=start_date,
end_date=end_date,
status="running",
created_by=created_by,
)
self.session.add(job)
self.session.commit()
self.session.refresh(job)
return job
def mark_sync_completed(
self,
job_id: str,
*,
employee_count: int,
record_count: int,
) -> AttendanceSyncJobORM:
job = self.require_sync_job(job_id)
job.status = "completed"
job.employee_count = employee_count
job.record_count = record_count
job.error_message = ""
job.updated_at = datetime.utcnow()
self.session.commit()
self.session.refresh(job)
return job
def mark_sync_failed(self, job_id: str, error_message: str) -> AttendanceSyncJobORM:
job = self.require_sync_job(job_id)
job.status = "failed"
job.error_message = error_message
job.updated_at = datetime.utcnow()
self.session.commit()
self.session.refresh(job)
return job
def require_sync_job(self, job_id: str) -> AttendanceSyncJobORM:
job = self.session.query(AttendanceSyncJobORM).filter(AttendanceSyncJobORM.id == job_id).one_or_none()
if job is None:
raise ValueError("考勤同步任务不存在")
return job
def latest_completed_job(self, attendance_date: date) -> AttendanceSyncJobORM | None:
return (
self.session.query(AttendanceSyncJobORM)
.filter(AttendanceSyncJobORM.attendance_date == attendance_date)
.filter(AttendanceSyncJobORM.status == "completed")
.order_by(AttendanceSyncJobORM.updated_at.desc(), AttendanceSyncJobORM.created_at.desc())
.first()
)
def replace_attendance_records(
self,
*,
source_type: str,
start_date: date,
end_date: date,
employee_ids: list[int],
rows: list[AttendanceRecordORM],
) -> None:
if employee_ids:
(
self.session.query(AttendanceRecordORM)
.filter(AttendanceRecordORM.source_type == source_type)
.filter(AttendanceRecordORM.employee_id.in_(employee_ids))
.filter(AttendanceRecordORM.work_date >= start_date)
.filter(AttendanceRecordORM.work_date <= end_date)
.delete(synchronize_session=False)
)
if rows:
self.session.add_all(rows)
self.session.commit()
def replace_salary_previews(self, sync_job_id: str, rows: list[SalaryPreviewORM]) -> None:
self.session.query(SalaryPreviewORM).filter(SalaryPreviewORM.sync_job_id == sync_job_id).delete(
synchronize_session=False
)
if rows:
self.session.add_all(rows)
self.session.commit()
def list_today_records(self, attendance_date: date) -> list[AttendanceRecordORM]:
return (
self.session.query(AttendanceRecordORM)
.filter(AttendanceRecordORM.work_date == attendance_date)
.filter(AttendanceRecordORM.source_type == "dingtalk")
.order_by(AttendanceRecordORM.employee_name.asc(), AttendanceRecordORM.id.asc())
.all()
)
def list_salary_previews(self, salary_month: str) -> list[SalaryPreviewORM]:
return (
self.session.query(SalaryPreviewORM)
.filter(SalaryPreviewORM.salary_month == salary_month)
.order_by(SalaryPreviewORM.updated_at.desc(), SalaryPreviewORM.id.desc())
.all()
)

View File

@ -1,9 +1,11 @@
from .auth_service import AuthService, AuthSession
from .commission_service import CommissionPage, CommissionService
from .employee_service import EmployeePage, EmployeeService
from .monthly_payroll_service import MonthlyPayrollDetailData, MonthlyPayrollService
from .operation_log_service import OperationLogPage, OperationLogService
from .organization_service import OrganizationService
from .payroll_service import PayrollApplicationService, PayrollComputation, PayrollService
from .realtime_attendance_service import RealtimeAttendanceService
from .report_service import PayrollReport, ReportService
from .salary_config_service import SalaryConfigService
from .salary_profile_service import SalaryProfileService
@ -15,6 +17,8 @@ __all__ = [
"CommissionService",
"EmployeePage",
"EmployeeService",
"MonthlyPayrollDetailData",
"MonthlyPayrollService",
"OperationLogPage",
"OperationLogService",
"OrganizationService",
@ -22,6 +26,7 @@ __all__ = [
"PayrollApplicationService",
"PayrollComputation",
"PayrollService",
"RealtimeAttendanceService",
"ReportService",
"SalaryConfigService",
"SalaryProfileService",

View File

@ -0,0 +1,187 @@
from __future__ import annotations
from calendar import monthrange
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import BinaryIO
from ..core.logger import AppLogger
from ..database.orm import MonthlyPayrollRunORM, PayrollExceptionORM, PayrollJobORM
from ..domain.payroll_exceptions import PayrollException, detect_payroll_exceptions
from ..repositories import EmployeeRepository, MonthlyPayrollRepository
from .payroll_service import PayrollApplicationService, PayrollComputation
logger = AppLogger.get_logger(__name__)
@dataclass(frozen=True)
class MonthlyPayrollDetailData:
run: MonthlyPayrollRunORM
job: PayrollJobORM | None
exceptions: list[PayrollExceptionORM]
class MonthlyPayrollService:
"""月度核算应用服务:编排正式工资计算、异常识别、锁定和导出。"""
def __init__(
self,
repository: MonthlyPayrollRepository,
payroll_service: PayrollApplicationService,
employee_repository: EmployeeRepository,
):
self.repository = repository
self.payroll_service = payroll_service
self.employee_repository = employee_repository
def list_runs(self, salary_month: str | None = None) -> list[MonthlyPayrollRunORM]:
logger.info("查询月度核算批次 salary_month=%s", salary_month)
return self.repository.list_runs(salary_month)
def get_detail(self, run_id: int) -> MonthlyPayrollDetailData:
run = self.repository.require_run(run_id)
job = self.payroll_service.get_job(run.source_job_id) if run.source_job_id else None
exceptions = self.repository.list_exceptions(run.id)
logger.info("查询月度核算详情 run_id=%s job_found=%s exception_count=%s", run_id, job is not None, len(exceptions))
return MonthlyPayrollDetailData(run=run, job=job, exceptions=exceptions)
def calculate_from_excel(
self,
*,
salary_month: str,
filename: str,
stream: BinaryIO,
created_by: str,
export_excel: bool,
) -> MonthlyPayrollDetailData:
self._ensure_month_can_calculate(salary_month)
computation = self.payroll_service.calculate_from_excel_upload(
filename=filename,
stream=stream,
config_path=None,
export_excel=export_excel,
)
run = self._save_calculation(
salary_month=salary_month or self._month_from_computation(computation),
source_type="excel",
created_by=created_by,
computation=computation,
)
return self.get_detail(run.id)
async def calculate_from_dingtalk(
self,
*,
salary_month: str,
created_by: str,
export_excel: bool,
) -> MonthlyPayrollDetailData:
self._ensure_month_can_calculate(salary_month)
employees = [
employee
for employee in self.employee_repository.list_employees(employment_status="active")
if employee.dingtalk_user_id
]
if not employees:
raise ValueError("没有绑定钉钉 userId 的在职员工请先在员工维护中补齐钉钉用户ID")
start_date, end_date = _month_range(salary_month)
computation = await self.payroll_service.calculate_from_dingtalk_realtime(
user_ids=[employee.dingtalk_user_id for employee in employees],
start_date=start_date,
end_date=end_date,
config_path=None,
export_excel=export_excel,
)
run = self._save_calculation(
salary_month=salary_month,
source_type="dingtalk",
created_by=created_by,
computation=computation,
)
return self.get_detail(run.id)
def lock_run(self, run_id: int, locked_by: str) -> MonthlyPayrollRunORM:
logger.info("锁定月度核算 run_id=%s locked_by=%s", run_id, locked_by)
return self.repository.lock_run(run_id, locked_by)
def export_path(self, run_id: int) -> Path:
detail = self.get_detail(run_id)
if detail.job is None or not detail.job.output_file:
raise FileNotFoundError("该月度核算批次没有可导出的工资表")
path = Path(detail.job.output_file)
if path.exists():
return path
return self.payroll_service.resolve_output_file(path.name)
def _save_calculation(
self,
*,
salary_month: str,
source_type: str,
created_by: str,
computation: PayrollComputation,
) -> MonthlyPayrollRunORM:
run = self.repository.create_run(
salary_month=salary_month,
source_type=source_type,
source_job_id=computation.job_id,
created_by=created_by,
status="calculated",
)
self.repository.update_run_totals(
run.id,
employee_count=computation.employee_count,
gross_total=round(sum(result.gross_salary or 0 for result in computation.results), 2),
net_total=round(sum(result.net_salary or 0 for result in computation.results), 2),
deduction_total=round(sum(result.total_deduction for result in computation.results), 2),
overtime_total=round(sum(result.remaining_overtime_hours for result in computation.results), 2),
status="calculated",
)
self.repository.replace_exceptions(
run.id,
self._exception_rows(run.id, detect_payroll_exceptions(computation.results)),
)
logger.info("月度核算保存完成 run_id=%s job_id=%s", run.id, computation.job_id)
return self.repository.require_run(run.id)
def _exception_rows(
self,
run_id: int,
exceptions: list[PayrollException],
) -> list[PayrollExceptionORM]:
rows: list[PayrollExceptionORM] = []
for item in exceptions:
employee = self.employee_repository.get_by_employee_no(item.employee_no) if item.employee_no else None
rows.append(
PayrollExceptionORM(
run_id=run_id,
employee_id=employee.id if employee else None,
employee_no=item.employee_no,
employee_name=item.employee_name,
salary_month=item.salary_month,
exception_type=item.exception_type,
severity=item.severity,
message=item.message,
status="open",
)
)
return rows
def _ensure_month_can_calculate(self, salary_month: str) -> None:
existing = self.repository.get_run_by_month(salary_month)
if existing and existing.status == "locked":
raise ValueError("本月工资已锁定,不能重新计算")
def _month_from_computation(self, computation: PayrollComputation) -> str:
for result in computation.results:
if result.salary_month:
return result.salary_month
raise ValueError("无法从工资结果识别工资月份")
def _month_range(salary_month: str) -> tuple[date, date]:
year_text, month_text = salary_month.split("-", 1)
year = int(year_text)
month = int(month_text)
return date(year, month, 1), date(year, month, monthrange(year, month)[1])

View File

@ -155,6 +155,14 @@ class PayrollApplicationService:
logger.info("解析工资结果下载文件 filename=%s", filename)
return self.storage.resolve_output_file(filename)
def load_runtime_config(self, config_path: str | None, salary_month: str | None = None) -> AppConfig:
"""对外提供当前数据库规则合并后的工资配置。"""
return self._load_runtime_config(config_path, salary_month=salary_month)
def dingtalk_settings(self) -> DingTalkSettings:
"""对外提供钉钉配置读取,统一错误提示。"""
return self._dingtalk_settings()
def _export_if_needed(
self,
config: AppConfig,

View File

@ -0,0 +1,395 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import date, datetime, time, timedelta
from ..core.logger import AppLogger
from ..database.orm import AttendanceRecordORM, EmployeeORM, SalaryPreviewORM
from ..domain.calculator import PayrollCalculator
from ..domain.models import DailyAttendance, EmployeeAttendance, PayrollResult
from ..integrations.dingtalk import DingTalkAttendanceAdapter, DingTalkClient
from ..repositories import RealtimeAttendanceRepository
from .payroll_service import PayrollApplicationService
logger = AppLogger.get_logger(__name__)
@dataclass(frozen=True)
class TodayAttendanceItemData:
employee_id: int | None
employee_no: str
employee_name: str
department: str
position: str
first_punch: str
last_punch: str
attendance_status: str
late_minutes: int
missing_card_count: int
leave_hours: float
today_overtime_hours: float
month_remaining_overtime_hours: float
estimated_net_salary: float | None
note: str
@dataclass(frozen=True)
class RealtimeAttendanceSummaryData:
attendance_date: date
employee_total: int
checked_in_count: int
unchecked_count: int
late_count: int
leave_count: int
missing_card_count: int
estimated_overtime_count: int
estimated_net_total: float
@dataclass(frozen=True)
class RealtimeAttendanceResponseData:
sync_job_id: str | None
synced_at: datetime | None
summary: RealtimeAttendanceSummaryData
items: list[TodayAttendanceItemData]
def to_dict(self) -> dict:
return asdict(self)
class RealtimeAttendanceService:
"""实时考勤应用服务:同步钉钉、落库标准考勤,并生成薪资预估看板。"""
def __init__(
self,
repository: RealtimeAttendanceRepository,
payroll_service: PayrollApplicationService,
):
self.repository = repository
self.payroll_service = payroll_service
def get_today(self, attendance_date: date) -> RealtimeAttendanceResponseData:
logger.info("实时考勤看板查询开始 attendance_date=%s", attendance_date)
employees = self.repository.list_active_employees()
records = self.repository.list_today_records(attendance_date)
previews = self.repository.list_salary_previews(attendance_date.strftime("%Y-%m"))
latest_job = self.repository.latest_completed_job(attendance_date)
items = self._build_today_items(attendance_date, employees, records, previews)
logger.info("实时考勤看板查询完成 attendance_date=%s employee_count=%s", attendance_date, len(items))
return RealtimeAttendanceResponseData(
sync_job_id=latest_job.id if latest_job else None,
synced_at=latest_job.updated_at if latest_job else None,
summary=build_realtime_summary(attendance_date, items),
items=items,
)
async def sync_today(
self,
*,
attendance_date: date,
created_by: str,
include_salary_preview: bool = True,
) -> RealtimeAttendanceResponseData:
employees = self.repository.list_active_employees_with_dingtalk()
if not employees:
logger.error("实时考勤同步失败 reason=no_dingtalk_user_id")
raise ValueError("没有绑定钉钉 userId 的在职员工请先在员工维护中补齐钉钉用户ID")
salary_month = attendance_date.strftime("%Y-%m")
start_date = attendance_date.replace(day=1)
job = self.repository.create_sync_job(
source_type="dingtalk",
sync_mode="month_to_date",
attendance_date=attendance_date,
salary_month=salary_month,
start_date=start_date,
end_date=attendance_date,
created_by=created_by,
)
logger.info(
"实时考勤同步任务创建 job_id=%s attendance_date=%s employee_count=%s",
job.id,
attendance_date,
len(employees),
)
try:
settings = self.payroll_service.dingtalk_settings()
config = self.payroll_service.load_runtime_config(None, salary_month=salary_month)
client = DingTalkClient(settings)
raw_records = await client.list_attendance_records(
[employee.dingtalk_user_id for employee in employees],
datetime.combine(start_date, time.min),
datetime.combine(attendance_date, time(23, 59, 59)),
)
adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone)
adapted_employees = adapter.to_employee_attendance(raw_records)
attendance = self._merge_employee_directory(
employees=employees,
adapted_employees=adapted_employees,
start_date=start_date,
end_date=attendance_date,
)
results = PayrollCalculator(config).calculate(attendance)
employee_by_no = {employee.employee_no: employee for employee in employees}
employee_ids = [employee.id for employee in employees]
self.repository.replace_attendance_records(
source_type="dingtalk",
start_date=start_date,
end_date=attendance_date,
employee_ids=employee_ids,
rows=self._attendance_rows(results, employee_by_no),
)
if include_salary_preview:
self.repository.replace_salary_previews(
job.id,
self._salary_preview_rows(job.id, results, employee_by_no),
)
self.repository.mark_sync_completed(
job.id,
employee_count=len(employees),
record_count=len(raw_records),
)
logger.info(
"实时考勤同步任务完成 job_id=%s raw_record_count=%s employee_count=%s",
job.id,
len(raw_records),
len(employees),
)
except Exception as exc:
self.repository.mark_sync_failed(job.id, str(exc))
logger.exception("实时考勤同步任务失败 job_id=%s", job.id)
raise
return self.get_today(attendance_date)
def _merge_employee_directory(
self,
*,
employees: list[EmployeeORM],
adapted_employees: list[EmployeeAttendance],
start_date: date,
end_date: date,
) -> list[EmployeeAttendance]:
adapted_by_user_id = {employee.user_id: employee for employee in adapted_employees}
days = list(_date_range(start_date, end_date))
merged: list[EmployeeAttendance] = []
for employee in employees:
adapted = adapted_by_user_id.get(employee.dingtalk_user_id)
daily_by_date = {
record.work_date: record
for record in adapted.daily_records
} if adapted else {}
merged.append(
EmployeeAttendance(
name=employee.name,
attendance_group="钉钉实时考勤",
department=employee.department,
employee_no=employee.employee_no,
position=employee.position,
user_id=employee.dingtalk_user_id,
daily_records=[daily_by_date.get(day) or DailyAttendance(work_date=day) for day in days],
)
)
return merged
def _build_today_items(
self,
attendance_date: date,
employees: list[EmployeeORM],
records: list[AttendanceRecordORM],
previews: list[SalaryPreviewORM],
) -> list[TodayAttendanceItemData]:
records_by_employee_id = {
record.employee_id: record
for record in records
if record.employee_id is not None
}
records_by_employee_no = {record.employee_no: record for record in records if record.employee_no}
previews_by_employee_id: dict[int, SalaryPreviewORM] = {}
previews_by_employee_no: dict[str, SalaryPreviewORM] = {}
for preview in previews:
if preview.employee_id is not None and preview.employee_id not in previews_by_employee_id:
previews_by_employee_id[preview.employee_id] = preview
if preview.employee_no and preview.employee_no not in previews_by_employee_no:
previews_by_employee_no[preview.employee_no] = preview
items = [
self._today_item_from_employee(
attendance_date,
employee,
records_by_employee_id.get(employee.id) or records_by_employee_no.get(employee.employee_no),
previews_by_employee_id.get(employee.id) or previews_by_employee_no.get(employee.employee_no),
)
for employee in employees
]
known_employee_ids = {employee.id for employee in employees}
for record in records:
if record.employee_id in known_employee_ids:
continue
items.append(self._today_item_from_record(record, previews_by_employee_no.get(record.employee_no)))
return items
def _today_item_from_employee(
self,
attendance_date: date,
employee: EmployeeORM,
record: AttendanceRecordORM | None,
preview: SalaryPreviewORM | None,
) -> TodayAttendanceItemData:
if record is None:
note = "未绑定钉钉 userId" if not employee.dingtalk_user_id else ""
return TodayAttendanceItemData(
employee_id=employee.id,
employee_no=employee.employee_no,
employee_name=employee.name,
department=employee.department,
position=employee.position,
first_punch="",
last_punch="",
attendance_status="unchecked",
late_minutes=0,
missing_card_count=0,
leave_hours=0,
today_overtime_hours=0,
month_remaining_overtime_hours=preview.remaining_overtime_hours if preview else 0,
estimated_net_salary=preview.estimated_net_salary if preview else None,
note=note,
)
return self._today_item_from_record(record, preview, fallback_position=employee.position)
def _today_item_from_record(
self,
record: AttendanceRecordORM,
preview: SalaryPreviewORM | None,
fallback_position: str = "",
) -> TodayAttendanceItemData:
return TodayAttendanceItemData(
employee_id=record.employee_id,
employee_no=record.employee_no,
employee_name=record.employee_name,
department=record.department,
position=preview.position if preview and preview.position else fallback_position,
first_punch=record.first_punch,
last_punch=record.last_punch,
attendance_status=_attendance_status(record),
late_minutes=record.late_minutes,
missing_card_count=record.missing_card_count,
leave_hours=record.leave_hours,
today_overtime_hours=record.overtime_hours,
month_remaining_overtime_hours=preview.remaining_overtime_hours if preview else 0,
estimated_net_salary=preview.estimated_net_salary if preview else None,
note=preview.note if preview else "",
)
def _attendance_rows(
self,
results: list[PayrollResult],
employee_by_no: dict[str, EmployeeORM],
) -> list[AttendanceRecordORM]:
rows: list[AttendanceRecordORM] = []
for result in results:
employee = employee_by_no.get(result.employee.employee_no)
for record in result.employee.daily_records:
rows.append(
AttendanceRecordORM(
employee_id=employee.id if employee else None,
employee_no=result.employee.employee_no,
employee_name=result.employee.name,
department=result.employee.department,
work_date=record.work_date,
first_punch=_time_text(record.first_punch),
last_punch=_time_text(record.last_punch),
attendance_status=_daily_status(record),
late_minutes=sum(record.late_minutes),
missing_card_count=record.missing_card_count,
leave_hours=record.leave_hours_in_cell,
overtime_hours=record.punch_overtime_hours,
raw_text=record.raw_text,
source_type="dingtalk",
)
)
return rows
def _salary_preview_rows(
self,
sync_job_id: str,
results: list[PayrollResult],
employee_by_no: dict[str, EmployeeORM],
) -> list[SalaryPreviewORM]:
rows: list[SalaryPreviewORM] = []
for result in results:
employee = employee_by_no.get(result.employee.employee_no)
rows.append(
SalaryPreviewORM(
sync_job_id=sync_job_id,
employee_id=employee.id if employee else None,
employee_no=result.employee.employee_no,
employee_name=result.employee.name,
department=result.employee.department,
position=result.employee.position,
salary_month=result.salary_month,
attendance_days=result.attendance_days,
actual_work_hours=result.actual_work_hours,
punch_overtime_hours=result.punch_overtime_hours,
leave_hours=result.leave_hours,
remaining_overtime_hours=result.remaining_overtime_hours,
late_deduction=result.late_deduction,
missing_card_deduction=result.missing_card_deduction,
commission_amount=result.commission_amount,
overtime_pay=result.overtime_pay,
estimated_gross_salary=result.gross_salary,
estimated_net_salary=result.net_salary,
status="warning" if result.note or result.remaining_overtime_hours < 0 else "estimated",
note=result.note,
)
)
return rows
def build_realtime_summary(
attendance_date: date,
items: list[TodayAttendanceItemData],
) -> RealtimeAttendanceSummaryData:
"""按页面行聚合实时考勤摘要,保持统计口径集中可测。"""
checked_in_count = sum(1 for item in items if item.first_punch or item.last_punch)
estimated_net_total = round(
sum(item.estimated_net_salary or 0 for item in items),
2,
)
return RealtimeAttendanceSummaryData(
attendance_date=attendance_date,
employee_total=len(items),
checked_in_count=checked_in_count,
unchecked_count=max(0, len(items) - checked_in_count),
late_count=sum(1 for item in items if item.late_minutes > 0),
leave_count=sum(1 for item in items if item.leave_hours > 0),
missing_card_count=sum(item.missing_card_count for item in items),
estimated_overtime_count=sum(1 for item in items if item.today_overtime_hours > 0),
estimated_net_total=estimated_net_total,
)
def _date_range(start_date: date, end_date: date):
current = start_date
while current <= end_date:
yield current
current += timedelta(days=1)
def _time_text(value) -> str:
return value.strftime("%H:%M") if value else ""
def _daily_status(record: DailyAttendance) -> str:
if record.first_punch or record.last_punch:
return "missing" if record.missing_card_count else "present"
if record.leave_hours_in_cell:
return "leave"
return "unchecked"
def _attendance_status(record: AttendanceRecordORM) -> str:
if record.attendance_status == "absent":
return "unchecked"
return record.attendance_status or "unchecked"

View File

@ -19,7 +19,7 @@
| `src/stores/` | Pinia 状态管理,目前主要是登录态、菜单、权限和主题色。 |
| `src/router/` | 路由配置和登录/权限守卫。 |
| `src/components/` | 通用布局、侧边栏、顶部栏、统计卡片和工资结果表格。 |
| `src/views/` | 登录、工作台、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 |
| `src/views/` | 登录、工作台、实时考勤、月度核算、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 |
| `src/assets/` | 全局样式和 UI 设计变量。 |
## 本地启动
@ -55,7 +55,7 @@ http://127.0.0.1:5173/
/static -> http://127.0.0.1:8000
```
因此前端代码中可以直接请求 `/api/auth/login`、`/api/payroll/excel` 等路径,头像静态资源也可以直接访问 `/static/uploads/avatars/...`
因此前端代码中可以直接请求 `/api/auth/login`、`/api/attendance/realtime/today`、`/api/payroll/monthly/runs`、`/api/payroll/excel` 等路径,头像静态资源也可以直接访问 `/static/uploads/avatars/...`
如果部署时前端和后端不在同一个域名,可以复制 `.env.example``.env`,并设置:
@ -90,6 +90,8 @@ VITE_DEV_PROXY_TARGET=http://127.0.0.1:8001
| --- | --- | --- |
| 登录 | `/login` | 用户登录入口。 |
| 工作台 | `/dashboard` | 展示最近计算任务、统计卡片和趋势面板。 |
| 实时考勤 | `/attendance/realtime` | 查看今日打卡、迟到、缺卡、请假、加班和本月薪资预估,可同步钉钉刷新。 |
| 月度核算 | `/payroll/monthly` | 按工资月份完成 Excel 导入、钉钉同步计算、异常处理、锁定和导出。 |
| 工资计算 | `/payroll` | 支持 Excel 导入和钉钉实时计算。 |
| 计算记录 | `/payroll/jobs` | 支持按任务编号查询已落库工资结果。 |
| 提成管理 | `/payroll/commissions` | 支持手工维护提成和 Excel 批量导入。 |
@ -105,6 +107,8 @@ VITE_DEV_PROXY_TARGET=http://127.0.0.1:8001
## 个性化与个人资料
实时考勤中的薪资金额是过程预估,最终工资以月度核算锁定结果为准。月度核算锁定后,本月工资不能重新计算,只保留导出。
顶部栏提供两个常用入口:
- 调色盘按钮:切换主题色,支持深海蓝、专业蓝、翡翠绿、勃艮第,选择结果保存在浏览器本地。
@ -121,8 +125,8 @@ static/uploads/avatars
| 角色 | 可见菜单和操作 |
| --- | --- |
| `superuser` | 拥有全部菜单和全部操作权限。 |
| `manager` | 可进行工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 |
| `viewer` | 可查看计算记录、下载结果和统计报表。 |
| `manager` | 可进行实时考勤同步、月度核算、工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 |
| `viewer` | 可查看实时考勤、计算记录、下载结果和统计报表。 |
## 构建

View File

@ -0,0 +1,68 @@
import { apiFetch, buildApiUrl, getStoredToken } from "./http";
import type { MonthlyPayrollDetailResponse, MonthlyPayrollRun } from "./types";
export function listMonthlyRuns(month?: string): Promise<MonthlyPayrollRun[]> {
const query = month ? `?month=${encodeURIComponent(month)}` : "";
return apiFetch<MonthlyPayrollRun[]>(`/api/payroll/monthly/runs${query}`);
}
export function getMonthlyRun(runId: number): Promise<MonthlyPayrollDetailResponse> {
return apiFetch<MonthlyPayrollDetailResponse>(`/api/payroll/monthly/runs/${runId}`);
}
export function calculateMonthlyFromExcel(file: File, salaryMonth: string): Promise<MonthlyPayrollDetailResponse> {
const formData = new FormData();
formData.append("file", file);
formData.append("salary_month", salaryMonth);
formData.append("export_excel", "true");
return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/excel", {
method: "POST",
body: formData,
});
}
export function calculateMonthlyFromDingTalk(salaryMonth: string): Promise<MonthlyPayrollDetailResponse> {
return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/dingtalk", {
method: "POST",
body: JSON.stringify({
salary_month: salaryMonth,
source_type: "dingtalk",
export_excel: true,
}),
});
}
export function recalculateMonthlyRun(runId: number): Promise<MonthlyPayrollDetailResponse> {
return apiFetch<MonthlyPayrollDetailResponse>(`/api/payroll/monthly/runs/${runId}/recalculate`, {
method: "POST",
});
}
export function lockMonthlyRun(runId: number): Promise<MonthlyPayrollRun> {
return apiFetch<MonthlyPayrollRun>(`/api/payroll/monthly/runs/${runId}/lock`, {
method: "POST",
});
}
export async function exportMonthlyRun(runId: number): Promise<void> {
const headers = new Headers();
const token = getStoredToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(buildApiUrl(`/api/payroll/monthly/runs/${runId}/export`), { headers });
if (!response.ok) {
throw new Error("月度工资表导出失败");
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `月度工资核算-${runId}.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}

View File

@ -0,0 +1,16 @@
import { apiFetch } from "./http";
import type { RealtimeAttendanceResponse } from "./types";
export function getTodayAttendance(date: string): Promise<RealtimeAttendanceResponse> {
return apiFetch<RealtimeAttendanceResponse>(`/api/attendance/realtime/today?date=${encodeURIComponent(date)}`);
}
export function syncRealtimeAttendance(date: string): Promise<RealtimeAttendanceResponse> {
return apiFetch<RealtimeAttendanceResponse>("/api/attendance/realtime/sync", {
method: "POST",
body: JSON.stringify({
attendance_date: date,
include_salary_preview: true,
}),
});
}

View File

@ -376,3 +376,80 @@ export interface PayrollReportResponse {
created_at: string;
}>;
}
export interface RealtimeAttendanceSummary {
attendance_date: string;
employee_total: number;
checked_in_count: number;
unchecked_count: number;
late_count: number;
leave_count: number;
missing_card_count: number;
estimated_overtime_count: number;
estimated_net_total: number;
}
export interface TodayAttendanceItem {
employee_id: number | null;
employee_no: string;
employee_name: string;
department: string;
position: string;
first_punch: string;
last_punch: string;
attendance_status: string;
late_minutes: number;
missing_card_count: number;
leave_hours: number;
today_overtime_hours: number;
month_remaining_overtime_hours: number;
estimated_net_salary: number | null;
note: string;
}
export interface RealtimeAttendanceResponse {
sync_job_id: string | null;
synced_at: string | null;
summary: RealtimeAttendanceSummary;
items: TodayAttendanceItem[];
}
export interface MonthlyPayrollRun {
id: number;
salary_month: string;
source_type: string;
source_job_id: string;
status: "draft" | "calculated" | "reviewed" | "locked" | "failed" | string;
employee_count: number;
gross_total: number;
net_total: number;
deduction_total: number;
overtime_total: number;
locked_by: string;
locked_at: string | null;
created_by: string;
created_at: string;
updated_at: string;
}
export interface PayrollException {
id: number;
run_id: number | null;
sync_job_id: string | null;
employee_id: number | null;
employee_no: string;
employee_name: string;
salary_month: string;
exception_type: string;
severity: string;
message: string;
status: string;
created_at: string;
resolved_at: string | null;
}
export interface MonthlyPayrollDetailResponse {
run: MonthlyPayrollRun;
results: PayrollResult[];
exceptions: PayrollException[];
}

View File

@ -1,50 +1,50 @@
:root {
--surface: #f7f9fb;
--surface-soft: #eef2f6;
--surface-strong: #e3e8ee;
--surface: #f5f7fa;
--surface-soft: #eef2f7;
--surface-strong: #d9e1ec;
--card: #ffffff;
--primary: #041627;
--primary-soft: #1a2b3c;
--text: #191c1e;
--muted: #5f6670;
--line: #d8dee6;
--line-soft: #edf1f5;
--blue: #0f62c6;
--primary: #22313f;
--primary-soft: #d3d7db;
--text: #1f2933;
--muted: #667381;
--line: #c7d0dc;
--line-soft: #f4f7fa;
--blue: #2563eb;
--blue-soft: #e8f1ff;
--green: #008c61;
--green-soft: #ddf8ee;
--red: #bd1e1e;
--red-soft: #ffebe8;
--shadow: 0 16px 32px rgba(4, 22, 39, 0.06);
--sidebar-bg: #f3f6f9;
--nav-text: #303842;
--nav-active-bg: #e1e6ec;
--topbar-bg: rgba(247, 249, 251, 0.94);
--search-bg: #f1f4f8;
--control-bg: #fbfcfe;
--secondary-bg: #e8edf2;
--secondary-hover-bg: #dde5ed;
--secondary-hover-border: #c6d0db;
--table-header-bg: #f5f7fa;
--table-row-hover-bg: #fafcff;
--modal-backdrop: rgba(4, 22, 39, 0.32);
--primary-rgb: 4, 22, 39;
--accent-rgb: 15, 98, 198;
--sidebar-bg: #ffffff;
--nav-text: #334155;
--nav-active-bg: #e8f0ff;
--topbar-bg: rgba(245, 247, 250, 0.94);
--search-bg: #f6f8fb;
--control-bg: #ffffff;
--secondary-bg: #eef2f7;
--secondary-hover-bg: #d9e1ec;
--secondary-hover-border: #acb8c7;
--table-header-bg: #f5f8fb;
--table-row-hover-bg: #f9fbff;
--modal-backdrop: rgba(34, 49, 63, 0.32);
--primary-rgb: 34, 49, 63;
--accent-rgb: 37, 99, 235;
--app-font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Microsoft YaHei", sans-serif;
--radius-sm: 7px;
--radius-md: 8px;
--radius-lg: 12px;
--topbar-height: 72px;
--nav-height: 48px;
--control-height: 44px;
--base-font-size: 16px;
--page-padding: 32px;
--content-gap: 24px;
--panel-padding: 24px;
--table-cell-y: 18px;
--table-cell-x: 24px;
--radius-sm: 5px;
--radius-md: 7px;
--radius-lg: 10px;
--topbar-height: 64px;
--nav-height: 42px;
--control-height: 40px;
--base-font-size: 15px;
--page-padding: 24px;
--content-gap: 18px;
--panel-padding: 20px;
--table-cell-y: 12px;
--table-cell-x: 18px;
--layout-sidebar-width: 280px;
--layout-sidebar-offset: 280px;
--collapsed-sidebar-width: 88px;
@ -163,6 +163,7 @@ a {
:root[data-sidebar="edge"] .brand-title,
:root[data-sidebar="edge"] .brand-subtitle,
:root[data-sidebar="edge"] .nav-section-title,
:root[data-sidebar="edge"] .nav-link {
color: white;
}
@ -292,7 +293,8 @@ a {
}
.app-shell.is-sidebar-collapsed .brand-copy,
.app-shell.is-sidebar-collapsed .nav-link span {
.app-shell.is-sidebar-collapsed .nav-link span,
.app-shell.is-sidebar-collapsed .nav-section-title {
max-width: 0;
opacity: 0;
pointer-events: none;
@ -315,6 +317,39 @@ a {
padding-inline: 0;
}
.app-shell.is-sidebar-collapsed .nav-section-title {
margin: 0;
}
.nav-section {
display: grid;
gap: 5px;
}
.nav-section + .nav-section {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--line-soft);
}
.nav-section-title {
max-width: 180px;
margin: 0 8px 2px;
overflow: hidden;
color: var(--muted);
font-size: 12px;
font-weight: 700;
line-height: 1.4;
opacity: 0.78;
transform: translateX(0);
white-space: nowrap;
transition:
max-width var(--motion-duration) var(--motion-easing),
margin var(--motion-duration) var(--motion-easing),
opacity var(--motion-duration-fast) ease,
transform var(--motion-duration) var(--motion-easing);
}
.nav-link {
display: flex;
align-items: center;
@ -325,7 +360,7 @@ a {
border-radius: var(--radius-md);
background: transparent;
color: var(--nav-text);
padding: 0 16px;
padding: 0 14px;
text-align: left;
transition:
background var(--motion-duration-fast) ease,
@ -526,7 +561,7 @@ a {
}
.appearance-panel {
width: min(560px, calc(100vw - 20px));
width: min(480px, calc(100vw - 20px));
max-height: 100vh;
margin: 0;
overflow-y: auto;
@ -535,7 +570,7 @@ a {
background: var(--card);
box-shadow: 0 28px 70px rgba(var(--primary-rgb), 0.26);
color: var(--text);
padding: 16px;
padding: 14px;
transform: translateX(0);
transition:
opacity var(--motion-duration-fast) ease,
@ -593,8 +628,19 @@ a {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
gap: 10px;
margin-bottom: 12px;
}
.appearance-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.appearance-recommend {
min-height: 30px;
padding-inline: 9px;
}
.appearance-panel-header .icon-button {
@ -614,7 +660,7 @@ a {
}
.appearance-panel-header h2 {
font-size: 18px;
font-size: 17px;
}
.appearance-panel-header p {
@ -629,7 +675,7 @@ a {
}
.appearance-section + .appearance-section {
margin-top: 16px;
margin-top: 14px;
}
.appearance-section-head {
@ -639,7 +685,7 @@ a {
}
.appearance-section-head h3 {
font-size: 16px;
font-size: 14px;
}
.appearance-reset {
@ -666,7 +712,7 @@ a {
.appearance-grid {
display: grid;
gap: 10px;
gap: 8px;
}
.mode-grid,
@ -677,7 +723,7 @@ a {
}
.color-preset-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.radius-grid {
@ -699,7 +745,7 @@ a {
gap: 6px;
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
padding: 6px;
@ -722,7 +768,7 @@ a {
.appearance-choice strong,
.color-preset-choice strong {
color: var(--primary);
font-size: 13px;
font-size: 12px;
line-height: 1.25;
}
@ -765,7 +811,7 @@ a {
}
.mode-preview {
height: 78px;
height: 66px;
border: 1px solid var(--line);
}
@ -881,26 +927,102 @@ a {
}
.color-preset-choice {
min-height: 68px;
min-height: 74px;
align-content: start;
background: transparent;
border-color: transparent;
padding: 0;
border-color: var(--line);
background: var(--card);
padding: 6px;
}
.color-preset-swatch {
position: relative;
display: block;
height: 42px;
height: 44px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(135deg, var(--preset-from), var(--preset-to));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
border-radius: var(--radius-md);
background: var(--preset-surface);
}
.color-preset-choice strong {
text-align: center;
}
.preset-sidebar {
position: absolute;
inset: 0 auto 0 0;
display: grid;
width: 34%;
align-content: start;
gap: 4px;
background: var(--preset-sidebar);
padding: 7px 6px;
}
.preset-sidebar b,
.preset-main b {
display: block;
height: 4px;
border-radius: 999px;
}
.preset-sidebar b {
background: var(--preset-primary);
opacity: 0.7;
}
.preset-sidebar b:first-child {
width: 18px;
}
.preset-sidebar b:nth-child(2) {
width: 30px;
background: var(--preset-active);
opacity: 1;
}
.preset-sidebar b:nth-child(3) {
width: 22px;
}
.preset-main {
position: absolute;
inset: 6px 6px 6px 42%;
display: grid;
align-content: start;
gap: 5px;
border-radius: var(--radius-sm);
background: var(--preset-card);
padding: 7px;
}
.preset-main b {
background: var(--preset-primary);
opacity: 0.22;
}
.preset-main b:first-child {
width: 68%;
}
.preset-main b:nth-child(2) {
width: 88%;
}
.preset-main b:nth-child(3) {
width: 52%;
}
.preset-accent {
position: absolute;
right: 10px;
bottom: 10px;
width: 22px;
height: 5px;
border-radius: 999px;
background: var(--preset-accent);
}
.font-choice {
min-height: 54px;
place-items: center;
@ -1713,6 +1835,293 @@ a {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-field {
min-width: 170px;
}
.compact-field span {
font-size: 12px;
}
.compact-stats-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
padding: 0 var(--panel-padding) var(--panel-padding);
}
.compact-stat {
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface);
padding: 12px 14px;
}
.compact-stat span,
.workflow-steps span {
color: var(--muted);
font-size: 12px;
}
.compact-stat strong {
display: block;
margin-top: 6px;
color: var(--primary);
font-size: 19px;
line-height: 1.2;
}
.organization-summary-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--card);
box-shadow: 0 8px 20px rgba(var(--primary-rgb), 0.04);
padding: 10px;
}
.organization-summary-strip div {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 50px;
border-radius: var(--radius-md);
background: var(--surface);
padding: 10px 12px;
}
.organization-summary-strip svg {
color: var(--blue);
}
.organization-summary-strip span,
.organization-summary-strip small {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.organization-summary-strip strong {
min-width: 0;
overflow: hidden;
color: var(--primary);
font-size: 18px;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.organization-tree-layout {
display: grid;
grid-template-columns: minmax(340px, 0.9fr) minmax(520px, 1.45fr);
gap: var(--content-gap);
align-items: start;
}
.organization-tree-panel,
.organization-detail-panel {
min-height: 620px;
}
.organization-tree-tools,
.organization-detail-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.link-button.muted {
color: var(--muted);
}
.compact-button {
min-height: 36px;
padding: 0 12px;
font-size: 13px;
}
.organization-tree {
display: grid;
align-content: start;
gap: 4px;
max-height: calc(100vh - 330px);
min-height: 520px;
overflow: auto;
padding: 12px;
}
.organization-tree-empty {
display: flex;
min-height: 240px;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--muted);
font-weight: 800;
}
.tree-node {
display: grid;
grid-template-columns: 22px 32px minmax(0, 1fr) auto;
width: 100%;
min-height: 50px;
align-items: center;
gap: 8px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
padding: 7px 10px 7px calc(10px + (var(--tree-depth) * 20px));
text-align: left;
transition:
background 0.18s ease,
box-shadow 0.18s ease,
color 0.18s ease,
opacity 0.18s ease;
}
.tree-node:hover {
background: var(--surface);
}
.tree-node.is-selected {
background: var(--blue-soft);
box-shadow: inset 3px 0 0 var(--blue);
}
.tree-node.is-inactive {
opacity: 0.66;
}
.tree-toggle {
display: grid;
width: 22px;
height: 22px;
place-items: center;
border-radius: var(--radius-sm);
color: var(--muted);
}
.tree-toggle:not(.is-placeholder):hover {
background: rgba(var(--accent-rgb), 0.1);
color: var(--blue);
}
.tree-toggle.is-placeholder {
visibility: hidden;
}
.tree-node-icon {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border-radius: var(--radius-sm);
background: var(--surface-soft);
color: var(--primary);
}
.tree-node-position .tree-node-icon {
background: var(--green-soft);
color: var(--green);
}
.tree-node-main {
display: grid;
gap: 3px;
min-width: 0;
}
.tree-node-main strong,
.tree-node-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-node-main strong {
color: var(--primary);
font-size: 14px;
line-height: 1.2;
}
.tree-node-main small {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.tree-node .status-chip {
min-height: 22px;
padding: 0 8px;
font-size: 11px;
}
.organization-detail-header {
align-items: flex-start;
}
.organization-node-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
border-bottom: 1px solid var(--line-soft);
background: var(--surface);
padding: 12px var(--panel-padding);
}
.organization-node-summary div {
min-width: 0;
border: 1px solid var(--line-soft);
border-radius: var(--radius-md);
background: var(--card);
padding: 10px 12px;
}
.organization-node-summary span {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.organization-node-summary strong {
display: block;
min-width: 0;
overflow: hidden;
margin-top: 5px;
color: var(--primary);
font-size: 14px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.organization-editor-form {
padding-top: 18px;
}
.workflow-steps {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
padding: 0 var(--panel-padding) var(--panel-padding);
}
.workflow-steps span {
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface);
padding: 9px 10px;
text-align: center;
}
.user-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@ -2014,14 +2423,14 @@ a {
.login-screen {
display: grid;
min-height: 100vh;
grid-template-columns: minmax(420px, 42vw) minmax(0, 1fr);
grid-template-columns: minmax(400px, 40vw) minmax(0, 1fr);
background: var(--surface);
}
.login-form-side {
display: grid;
place-items: center;
padding: 48px;
padding: 42px 48px;
}
.login-card {
@ -2029,13 +2438,13 @@ a {
}
.login-brand {
margin-bottom: 28px;
margin-bottom: 24px;
}
.login-brand h1 {
margin: 22px 0 8px;
margin: 18px 0 7px;
color: var(--primary);
font-size: 28px;
font-size: 26px;
line-height: 1.2;
}
@ -2046,7 +2455,7 @@ a {
.form-stack {
display: grid;
gap: 18px;
gap: 16px;
}
.input-with-icon {
@ -2083,9 +2492,9 @@ a {
display: flex;
flex-wrap: wrap;
gap: 14px 22px;
margin-top: 42px;
margin-top: 34px;
border-top: 1px solid var(--line);
padding-top: 24px;
padding-top: 20px;
color: var(--muted);
}
@ -2104,43 +2513,42 @@ a {
display: grid;
place-items: center;
overflow: hidden;
background:
linear-gradient(135deg, rgba(var(--primary-rgb), 0.94), rgba(var(--accent-rgb), 0.86)),
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0 1px, transparent 1px 74px),
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.05) 0 1px, transparent 1px 74px);
color: white;
padding: 48px;
border-left: 1px solid var(--line);
background: var(--surface-soft);
color: var(--text);
padding: 44px;
}
.ledger-visual {
width: min(620px, 100%);
border: 1px solid rgba(255, 255, 255, 0.16);
width: min(560px, 100%);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.07);
padding: 48px;
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24);
background: var(--card);
padding: 32px;
box-shadow: var(--shadow);
}
.ledger-visual h2 {
margin: 24px 0 18px;
font-size: 44px;
line-height: 1.18;
margin: 22px 0 14px;
color: var(--primary);
font-size: 31px;
line-height: 1.25;
}
.ledger-visual p {
margin: 0;
color: rgba(255, 255, 255, 0.82);
font-size: 17px;
line-height: 1.7;
color: var(--muted);
font-size: 15px;
line-height: 1.65;
}
.visual-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--content-gap);
margin-top: 34px;
border-top: 1px solid rgba(255, 255, 255, 0.16);
padding-top: 28px;
gap: 18px;
margin-top: 28px;
border-top: 1px solid var(--line);
padding-top: 22px;
}
.visual-metrics strong,
@ -2149,12 +2557,13 @@ a {
}
.visual-metrics strong {
font-size: 32px;
color: var(--primary);
font-size: 25px;
}
.visual-metrics span {
margin-top: 4px;
color: rgba(255, 255, 255, 0.68);
color: var(--muted);
}
.security-line {
@ -2164,7 +2573,7 @@ a {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(255, 255, 255, 0.58);
color: var(--muted);
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
font-size: 13px;
}
@ -2177,7 +2586,7 @@ a {
@media (max-width: 1120px) {
.appearance-panel {
width: min(540px, calc(100vw - 16px));
width: min(480px, calc(100vw - 16px));
padding: 14px;
}
@ -2193,11 +2602,17 @@ a {
.stats-grid,
.dashboard-grid,
.action-grid,
.compact-stats-grid,
.form-grid,
.workflow-steps,
.password-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.organization-tree-layout {
grid-template-columns: 1fr;
}
.login-screen {
grid-template-columns: 1fr;
}
@ -2309,14 +2724,17 @@ a {
}
.stats-grid,
.organization-summary-strip,
.dashboard-grid,
.action-grid,
.compact-stats-grid,
.form-grid,
.user-form,
.password-form,
.search-form,
.table-filter-bar,
.visual-metrics {
.visual-metrics,
.workflow-steps {
grid-template-columns: 1fr;
}
@ -2344,6 +2762,24 @@ a {
.page-header h1 {
font-size: 27px;
}
.organization-tree {
max-height: none;
min-height: 360px;
}
.organization-tree-panel,
.organization-detail-panel {
min-height: auto;
}
.organization-detail-actions {
justify-content: flex-start;
}
.organization-node-summary {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {

View File

@ -26,13 +26,13 @@ const modeOptions: Array<{ name: ThemeMode; label: string }> = [
];
const fontOptions: Array<{ name: FontChoice; label: string }> = [
{ name: "auto", label: "Auto" },
{ name: "sans", label: "Sans" },
{ name: "serif", label: "Serif" },
{ name: "auto", label: "系统" },
{ name: "sans", label: "现代" },
{ name: "serif", label: "宋体" },
];
const radiusOptions: Array<{ name: RadiusChoice; label: string; previewRadius: string }> = [
{ name: "auto", label: "Auto", previewRadius: "20px" },
{ name: "auto", label: "默认", previewRadius: "20px" },
{ name: "0", label: "0", previewRadius: "0px" },
{ name: "0.3", label: "0.3", previewRadius: "8px" },
{ name: "0.5", label: "0.5", previewRadius: "14px" },
@ -56,7 +56,7 @@ const sidebarOptions: Array<{ name: SidebarChoice; label: string }> = [
const layoutOptions: Array<{ name: LayoutChoice; label: string }> = [
{ name: "default", label: "默认" },
{ name: "compact", label: "紧凑" },
{ name: "fullscreen", label: "全屏布局" },
{ name: "fullscreen", label: "全屏" },
];
const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> = [
@ -70,12 +70,18 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
<aside class="appearance-panel" role="dialog" aria-label="外观设置">
<header class="appearance-panel-header">
<div>
<h2>外观</h2>
<p>主题颜色与布局</p>
<h2>界面外观</h2>
<p>整套界面的颜色字体和布局</p>
</div>
<div class="appearance-header-actions">
<button class="secondary-button appearance-recommend" type="button" @click="theme.resetAppearance">
<RotateCcw :size="16" aria-hidden="true" />
<span>推荐外观</span>
</button>
<button class="icon-button" type="button" title="关闭" @click="emit('close')">
<X :size="21" aria-hidden="true" />
</button>
</div>
<button class="icon-button" type="button" title="关闭" @click="emit('close')">
<X :size="21" aria-hidden="true" />
</button>
</header>
<section class="appearance-section">
@ -118,7 +124,7 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
<section class="appearance-section">
<div class="appearance-section-head">
<h3>颜色预设</h3>
<h3>整套配色</h3>
<button class="appearance-reset" type="button" title="恢复默认颜色" @click="theme.resetColor">
<RotateCcw :size="17" aria-hidden="true" />
</button>
@ -129,11 +135,30 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
:key="item.name"
class="color-preset-choice"
:class="{ 'is-active': theme.current === item.name }"
:style="{ '--preset-from': item.gradient[0], '--preset-to': item.gradient[1] }"
:style="{
'--preset-surface': item.tokens.light.surface,
'--preset-sidebar': item.tokens.light.sidebarBg,
'--preset-active': item.tokens.light.navActiveBg,
'--preset-card': item.tokens.light.card,
'--preset-primary': item.tokens.light.primary,
'--preset-accent': item.tokens.light.accent,
}"
type="button"
@click="theme.setTheme(item.name)"
>
<span class="color-preset-swatch" aria-hidden="true" />
<span class="color-preset-swatch" aria-hidden="true">
<i class="preset-sidebar">
<b />
<b />
<b />
</i>
<i class="preset-main">
<b />
<b />
<b />
</i>
<i class="preset-accent" />
</span>
<strong>{{ item.label }}</strong>
<span v-if="theme.current === item.name" class="choice-check" aria-hidden="true">
<Check :size="18" />

View File

@ -3,6 +3,8 @@ import {
Banknote,
BadgeDollarSign,
Building2,
Calculator,
CalendarClock,
ClipboardList,
FileChartColumn,
LayoutDashboard,
@ -19,6 +21,7 @@ import {
import { computed } from "vue";
import { useRouter } from "vue-router";
import { buildSidebarSections } from "../navigation/sidebarMenu";
import { useAuthStore } from "../stores/auth";
defineProps<{
@ -35,6 +38,8 @@ const auth = useAuthStore();
const router = useRouter();
const backendIconMap = {
attendance_realtime: CalendarClock,
monthly_payroll: Calculator,
payroll: Banknote,
jobs: ClipboardList,
employees: UserCog,
@ -47,18 +52,15 @@ const backendIconMap = {
operation_logs: ScrollText,
};
const menuItems = computed(() => [
{
code: "dashboard",
name: "工作台",
path: "/dashboard",
icon: LayoutDashboard,
},
...auth.menus.map((menu) => ({
...menu,
icon: backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
const menuSections = computed(() =>
buildSidebarSections(auth.menus).map((section) => ({
...section,
items: section.items.map((menu) => ({
...menu,
icon: menu.code === "dashboard" ? LayoutDashboard : backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
})),
})),
]);
);
function logout(): void {
auth.logout();
@ -82,18 +84,21 @@ function logout(): void {
</div>
<nav class="sidebar-nav" aria-label="主导航">
<RouterLink
v-for="item in menuItems"
:key="item.code"
:to="item.path"
class="nav-link"
active-class="is-active"
:title="item.name"
@click="emit('close')"
>
<component :is="item.icon" :size="21" aria-hidden="true" />
<span>{{ item.name }}</span>
</RouterLink>
<section v-for="section in menuSections" :key="section.code" class="nav-section">
<p class="nav-section-title">{{ section.title }}</p>
<RouterLink
v-for="item in section.items"
:key="item.code"
:to="item.path"
class="nav-link"
active-class="is-active"
:title="`${section.title} / ${item.name}`"
@click="emit('close')"
>
<component :is="item.icon" :size="20" aria-hidden="true" />
<span>{{ item.name }}</span>
</RouterLink>
</section>
</nav>
<div class="sidebar-footer">

View File

@ -0,0 +1,81 @@
export interface SidebarMenuInput {
code: string;
name: string;
path: string;
}
export interface SidebarMenuSection {
code: string;
title: string;
items: SidebarMenuInput[];
}
const DASHBOARD_MENU: SidebarMenuInput = {
code: "dashboard",
name: "工作台",
path: "/dashboard",
};
const MENU_LABELS: Record<string, string> = {
monthly_payroll: "工资核算",
jobs: "计算记录",
commissions: "提成录入",
salary_profiles: "薪资档案",
reports: "工资报表",
};
const SECTION_DEFINITIONS: Array<{
code: string;
title: string;
itemCodes: string[];
}> = [
{
code: "core",
title: "核心工作",
itemCodes: ["dashboard", "attendance_realtime"],
},
{
code: "monthly",
title: "月度核算",
itemCodes: ["monthly_payroll", "jobs", "commissions"],
},
{
code: "employee",
title: "员工薪资",
itemCodes: ["employees", "salary_profiles", "organization"],
},
{
code: "reports",
title: "报表分析",
itemCodes: ["reports"],
},
{
code: "system",
title: "系统管理",
itemCodes: ["configs", "users", "operation_logs"],
},
];
export function buildSidebarSections(backendMenus: SidebarMenuInput[]): SidebarMenuSection[] {
const menuByCode = new Map<string, SidebarMenuInput>();
menuByCode.set(DASHBOARD_MENU.code, DASHBOARD_MENU);
for (const menu of backendMenus) {
menuByCode.set(menu.code, menu);
}
return SECTION_DEFINITIONS.map((section) => ({
code: section.code,
title: section.title,
items: section.itemCodes
.map((code) => menuByCode.get(code))
.filter((item): item is SidebarMenuInput => Boolean(item))
.map(normalizeMenuLabel),
})).filter((section) => section.items.length > 0);
}
function normalizeMenuLabel(menu: SidebarMenuInput): SidebarMenuInput {
return {
...menu,
name: MENU_LABELS[menu.code] || menu.name,
};
}

View File

@ -8,9 +8,11 @@ import DashboardView from "../views/DashboardView.vue";
import EmployeesView from "../views/EmployeesView.vue";
import JobsView from "../views/JobsView.vue";
import LoginView from "../views/LoginView.vue";
import MonthlyPayrollView from "../views/MonthlyPayrollView.vue";
import OperationLogsView from "../views/OperationLogsView.vue";
import OrganizationView from "../views/OrganizationView.vue";
import PayrollView from "../views/PayrollView.vue";
import RealtimeAttendanceView from "../views/RealtimeAttendanceView.vue";
import ReportsView from "../views/ReportsView.vue";
import SalaryProfilesView from "../views/SalaryProfilesView.vue";
import UsersView from "../views/UsersView.vue";
@ -44,6 +46,18 @@ const router = createRouter({
name: "dashboard",
component: DashboardView,
},
{
path: "attendance/realtime",
name: "attendance-realtime",
component: RealtimeAttendanceView,
meta: { permission: "attendance:realtime:view" },
},
{
path: "payroll/monthly",
name: "monthly-payroll",
component: MonthlyPayrollView,
meta: { permission: "monthly_payroll:view" },
},
{
path: "payroll",
name: "payroll",
@ -137,6 +151,12 @@ router.beforeEach(async (to) => {
});
function firstAllowedPath(permissions: string[]): string {
if (permissions.includes("attendance:realtime:view")) {
return "/attendance/realtime";
}
if (permissions.includes("monthly_payroll:view")) {
return "/payroll/monthly";
}
if (permissions.includes("payroll:excel:calculate")) {
return "/payroll";
}

View File

@ -1,6 +1,7 @@
import { defineStore } from "pinia";
export type ThemeName =
| "enterprise"
| "default"
| "anthropic"
| "mono"
@ -103,26 +104,62 @@ const DENSITY_KEY = "financial-system-density";
const SIDEBAR_KEY = "financial-system-sidebar";
const LAYOUT_KEY = "financial-system-layout";
const CONTENT_WIDTH_KEY = "financial-system-content-width";
const APPEARANCE_VERSION_KEY = "financial-system-appearance-version";
const CURRENT_APPEARANCE_VERSION = "2026-06-enterprise-clean";
const DEFAULTS = {
theme: "anthropic" as ThemeName,
mode: "dark" as ThemeMode,
font: "serif" as FontChoice,
radius: "1" as RadiusChoice,
export const DEFAULT_THEME_SETTINGS = {
theme: "enterprise" as ThemeName,
mode: "light" as ThemeMode,
font: "sans" as FontChoice,
radius: "0.3" as RadiusChoice,
density: "compact" as DensityChoice,
sidebar: "floating" as SidebarChoice,
sidebar: "embedded" as SidebarChoice,
layout: "default" as LayoutChoice,
contentWidth: "full" as ContentWidthChoice,
};
const legacyThemeMap: Record<string, ThemeName> = {
navy: "anthropic",
navy: "enterprise",
blue: "ocean",
emerald: "forest",
burgundy: "rose",
};
migrateLegacyAppearanceDefaults();
const themeSeeds: ThemeSeed[] = [
{
name: "enterprise",
label: "企业清爽",
primary: "#22313f",
accent: "#2563eb",
soft: "#e8f0ff",
gradient: ["#22313f", "#2563eb"],
light: createLightSeed({
primary: "#22313f",
accent: "#2563eb",
soft: "#e8f0ff",
surface: "#f5f7fa",
surfaceSoft: "#eef2f7",
surfaceStrong: "#d9e1ec",
sidebarBg: "#ffffff",
navActiveBg: "#e8f0ff",
}),
dark: createDarkSeed({
primary: "#edf4ff",
primarySoft: "#b8cff8",
accent: "#7aa7ff",
accentSoft: "rgba(122, 167, 255, 0.18)",
surface: "#101418",
surfaceSoft: "#171d23",
surfaceStrong: "#26313c",
card: "#151a20",
sidebarBg: "#141a20",
navActiveBg: "#233750",
line: "#303b46",
lineSoft: "#242d36",
}),
},
{
name: "default",
label: "默认",
@ -467,13 +504,13 @@ export const themeOptions: ThemeOption[] = themeSeeds.map((seed) => ({
export const useThemeStore = defineStore("theme", {
state: () => ({
current: readThemeName(),
mode: readSetting(THEME_MODE_KEY, DEFAULTS.mode, ["system", "light", "dark"] as const),
font: readSetting(FONT_KEY, DEFAULTS.font, ["auto", "sans", "serif"] as const),
radius: readSetting(RADIUS_KEY, DEFAULTS.radius, ["auto", "0", "0.3", "0.5", "0.75", "1"] as const),
density: readSetting(DENSITY_KEY, DEFAULTS.density, ["compact", "default", "comfortable", "large"] as const),
sidebar: readSetting(SIDEBAR_KEY, DEFAULTS.sidebar, ["embedded", "floating", "edge"] as const),
layout: readSetting(LAYOUT_KEY, DEFAULTS.layout, ["default", "compact", "fullscreen"] as const),
contentWidth: readSetting(CONTENT_WIDTH_KEY, DEFAULTS.contentWidth, ["full", "centered"] as const),
mode: readSetting(THEME_MODE_KEY, DEFAULT_THEME_SETTINGS.mode, ["system", "light", "dark"] as const),
font: readSetting(FONT_KEY, DEFAULT_THEME_SETTINGS.font, ["auto", "sans", "serif"] as const),
radius: readSetting(RADIUS_KEY, DEFAULT_THEME_SETTINGS.radius, ["auto", "0", "0.3", "0.5", "0.75", "1"] as const),
density: readSetting(DENSITY_KEY, DEFAULT_THEME_SETTINGS.density, ["compact", "default", "comfortable", "large"] as const),
sidebar: readSetting(SIDEBAR_KEY, DEFAULT_THEME_SETTINGS.sidebar, ["embedded", "floating", "edge"] as const),
layout: readSetting(LAYOUT_KEY, DEFAULT_THEME_SETTINGS.layout, ["default", "compact", "fullscreen"] as const),
contentWidth: readSetting(CONTENT_WIDTH_KEY, DEFAULT_THEME_SETTINGS.contentWidth, ["full", "centered"] as const),
}),
getters: {
activeTheme: (state) => themeOptions.find((item) => item.name === state.current) || themeOptions[0],
@ -579,36 +616,47 @@ export const useThemeStore = defineStore("theme", {
this.applyTheme();
},
resetTheme(): void {
this.current = DEFAULTS.theme;
this.mode = DEFAULTS.mode;
this.current = DEFAULT_THEME_SETTINGS.theme;
this.mode = DEFAULT_THEME_SETTINGS.mode;
this.applyTheme();
},
resetColor(): void {
this.current = DEFAULTS.theme;
this.current = DEFAULT_THEME_SETTINGS.theme;
this.applyTheme();
},
resetFont(): void {
this.font = DEFAULTS.font;
this.font = DEFAULT_THEME_SETTINGS.font;
this.applyTheme();
},
resetRadius(): void {
this.radius = DEFAULTS.radius;
this.radius = DEFAULT_THEME_SETTINGS.radius;
this.applyTheme();
},
resetDensity(): void {
this.density = DEFAULTS.density;
this.density = DEFAULT_THEME_SETTINGS.density;
this.applyTheme();
},
resetSidebar(): void {
this.sidebar = DEFAULTS.sidebar;
this.sidebar = DEFAULT_THEME_SETTINGS.sidebar;
this.applyTheme();
},
resetLayout(): void {
this.layout = DEFAULTS.layout;
this.layout = DEFAULT_THEME_SETTINGS.layout;
this.applyTheme();
},
resetContentWidth(): void {
this.contentWidth = DEFAULTS.contentWidth;
this.contentWidth = DEFAULT_THEME_SETTINGS.contentWidth;
this.applyTheme();
},
resetAppearance(): void {
this.current = DEFAULT_THEME_SETTINGS.theme;
this.mode = DEFAULT_THEME_SETTINGS.mode;
this.font = DEFAULT_THEME_SETTINGS.font;
this.radius = DEFAULT_THEME_SETTINGS.radius;
this.density = DEFAULT_THEME_SETTINGS.density;
this.sidebar = DEFAULT_THEME_SETTINGS.sidebar;
this.layout = DEFAULT_THEME_SETTINGS.layout;
this.contentWidth = DEFAULT_THEME_SETTINGS.contentWidth;
this.applyTheme();
},
},
@ -760,8 +808,8 @@ function layoutVariables(
function readThemeName(): ThemeName {
const stored = readRawSetting(THEME_KEY);
const migrated = stored ? legacyThemeMap[stored] || stored : DEFAULTS.theme;
return themeOptions.some((item) => item.name === migrated) ? (migrated as ThemeName) : DEFAULTS.theme;
const migrated = stored ? legacyThemeMap[stored] || stored : DEFAULT_THEME_SETTINGS.theme;
return themeOptions.some((item) => item.name === migrated) ? (migrated as ThemeName) : DEFAULT_THEME_SETTINGS.theme;
}
function readSetting<T extends readonly StoredSetting[]>(key: string, fallback: T[number], values: T): T[number] {
@ -787,6 +835,53 @@ function persistSetting(key: string, value: string): void {
}
}
function migrateLegacyAppearanceDefaults(): void {
try {
if (typeof localStorage === "undefined") {
return;
}
if (localStorage.getItem(APPEARANCE_VERSION_KEY) === CURRENT_APPEARANCE_VERSION) {
return;
}
const lookedLikeOldDefault = isLegacyDefaultAppearance(
localStorage.getItem(THEME_KEY),
localStorage.getItem(THEME_MODE_KEY),
localStorage.getItem(FONT_KEY),
localStorage.getItem(RADIUS_KEY),
localStorage.getItem(SIDEBAR_KEY),
);
if (lookedLikeOldDefault) {
localStorage.setItem(THEME_KEY, DEFAULT_THEME_SETTINGS.theme);
localStorage.setItem(THEME_MODE_KEY, DEFAULT_THEME_SETTINGS.mode);
localStorage.setItem(FONT_KEY, DEFAULT_THEME_SETTINGS.font);
localStorage.setItem(RADIUS_KEY, DEFAULT_THEME_SETTINGS.radius);
localStorage.setItem(DENSITY_KEY, DEFAULT_THEME_SETTINGS.density);
localStorage.setItem(SIDEBAR_KEY, DEFAULT_THEME_SETTINGS.sidebar);
localStorage.setItem(LAYOUT_KEY, DEFAULT_THEME_SETTINGS.layout);
localStorage.setItem(CONTENT_WIDTH_KEY, DEFAULT_THEME_SETTINGS.contentWidth);
}
localStorage.setItem(APPEARANCE_VERSION_KEY, CURRENT_APPEARANCE_VERSION);
} catch {
// Appearance migration is best-effort only.
}
}
export function isLegacyDefaultAppearance(
theme: string | null,
mode: string | null,
font: string | null,
radius: string | null,
sidebar: string | null,
): boolean {
return (!theme || theme === "anthropic" || theme === "navy")
&& (!mode || mode === "dark")
&& (!font || font === "serif")
&& (!radius || radius === "1")
&& (!sidebar || sidebar === "floating");
}
function resolveThemeMode(mode: ThemeMode): "light" | "dark" {
if (mode !== "system") {
return mode;

View File

@ -0,0 +1,277 @@
<script setup lang="ts">
import { Calculator, Download, FileSpreadsheet, Loader2, LockKeyhole, RefreshCw, Upload } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http";
import {
calculateMonthlyFromDingTalk,
calculateMonthlyFromExcel,
exportMonthlyRun,
getMonthlyRun,
listMonthlyRuns,
lockMonthlyRun,
} from "../api/monthlyPayroll";
import type { MonthlyPayrollDetailResponse, MonthlyPayrollRun } from "../api/types";
import ResultsTable from "../components/ResultsTable.vue";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const selectedMonth = ref(new Date().toISOString().slice(0, 7));
const runs = ref<MonthlyPayrollRun[]>([]);
const detail = ref<MonthlyPayrollDetailResponse | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const loading = ref(false);
const actionLoading = ref<"" | "excel" | "dingtalk" | "lock" | "export">("");
const errorMessage = ref("");
const successMessage = ref("");
const activeRun = computed(() => detail.value?.run || runs.value[0] || null);
const isLocked = computed(() => activeRun.value?.status === "locked");
const canCalculate = computed(() => auth.hasPermission("monthly_payroll:calculate"));
const canLock = computed(() => auth.hasPermission("monthly_payroll:lock"));
const canExport = computed(() => auth.hasPermission("monthly_payroll:export"));
const grossTotal = computed(() => money(activeRun.value?.gross_total || 0));
const netTotal = computed(() => money(activeRun.value?.net_total || 0));
const deductionTotal = computed(() => money(activeRun.value?.deduction_total || 0));
onMounted(() => {
void loadRuns();
});
async function loadRuns(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
runs.value = await listMonthlyRuns(selectedMonth.value);
if (runs.value.length) {
detail.value = await getMonthlyRun(runs.value[0].id);
} else {
detail.value = null;
}
} catch (error) {
runs.value = [];
detail.value = null;
errorMessage.value = error instanceof ApiError ? error.message : "月度核算数据加载失败";
} finally {
loading.value = false;
}
}
function chooseExcel(): void {
fileInput.value?.click();
}
async function handleExcelChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) {
return;
}
await runAction("excel", async () => {
detail.value = await calculateMonthlyFromExcel(file, selectedMonth.value);
successMessage.value = "Excel 已导入并完成月度核算";
await loadRuns();
});
}
async function calculateFromDingTalk(): Promise<void> {
await runAction("dingtalk", async () => {
detail.value = await calculateMonthlyFromDingTalk(selectedMonth.value);
successMessage.value = "钉钉考勤已同步并完成月度核算";
await loadRuns();
});
}
async function lockRun(): Promise<void> {
if (!activeRun.value || !window.confirm("锁定后本月工资不能重新计算,只能导出。确认锁定?")) {
return;
}
await runAction("lock", async () => {
await lockMonthlyRun(activeRun.value!.id);
detail.value = await getMonthlyRun(activeRun.value!.id);
successMessage.value = "本月工资已锁定";
await loadRuns();
});
}
async function exportRun(): Promise<void> {
if (!activeRun.value) {
return;
}
await runAction("export", async () => {
await exportMonthlyRun(activeRun.value!.id);
successMessage.value = "月度工资表已开始下载";
});
}
async function runAction(
action: "excel" | "dingtalk" | "lock" | "export",
executor: () => Promise<void>,
): Promise<void> {
actionLoading.value = action;
errorMessage.value = "";
successMessage.value = "";
try {
await executor();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "操作失败,请检查后重试";
} finally {
actionLoading.value = "";
}
}
function statusText(status: string | undefined): string {
const map: Record<string, string> = {
draft: "草稿",
calculated: "已计算",
reviewed: "已复核",
locked: "已锁定",
failed: "失败",
};
return status ? map[status] || status : "未开始";
}
function money(value: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(value);
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>月度核算</h1>
<p>按工资月份完成导入计算异常处理锁定和导出</p>
</div>
<div class="header-actions">
<label class="field compact-field">
<span>工资月份</span>
<input v-model="selectedMonth" type="month" @change="loadRuns" />
</label>
<button class="secondary-button" type="button" :disabled="loading" @click="loadRuns">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div>
</header>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel">
<div class="panel-header">
<div>
<h2>核算流程</h2>
<p>数据导入 -> 考勤转换 -> 异常处理 -> 工资计算 -> 复核锁定</p>
</div>
<span class="status-chip" :class="isLocked ? 'green' : 'neutral'">{{ statusText(activeRun?.status) }}</span>
</div>
<div class="workflow-steps">
<span>1 数据导入</span>
<span>2 考勤转换</span>
<span>3 异常处理</span>
<span>4 工资计算</span>
<span>5 复核锁定</span>
</div>
<div class="compact-stats-grid">
<div class="compact-stat">
<span>员工数</span>
<strong>{{ activeRun?.employee_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>应发合计</span>
<strong>{{ grossTotal }}</strong>
</div>
<div class="compact-stat">
<span>实发合计</span>
<strong>{{ netTotal }}</strong>
</div>
<div class="compact-stat">
<span>扣款合计</span>
<strong>{{ deductionTotal }}</strong>
</div>
</div>
<div class="form-actions">
<button class="secondary-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'excel'" @click="chooseExcel">
<Loader2 v-if="actionLoading === 'excel'" class="spin" :size="18" aria-hidden="true" />
<Upload v-else :size="18" aria-hidden="true" />
<span>导入 Excel</span>
</button>
<button class="primary-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'dingtalk'" @click="calculateFromDingTalk">
<Loader2 v-if="actionLoading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<Calculator v-else :size="18" aria-hidden="true" />
<span>同步钉钉并计算</span>
</button>
<button class="secondary-button" type="button" :disabled="!activeRun || !canLock || isLocked || actionLoading === 'lock'" @click="lockRun">
<Loader2 v-if="actionLoading === 'lock'" class="spin" :size="18" aria-hidden="true" />
<LockKeyhole v-else :size="18" aria-hidden="true" />
<span>锁定工资</span>
</button>
<button class="secondary-button" type="button" :disabled="!activeRun || !canExport || actionLoading === 'export'" @click="exportRun">
<Loader2 v-if="actionLoading === 'export'" class="spin" :size="18" aria-hidden="true" />
<Download v-else :size="18" aria-hidden="true" />
<span>导出</span>
</button>
<input ref="fileInput" accept=".xlsx,.xls" hidden type="file" @change="handleExcelChange" />
</div>
</section>
<section v-if="detail?.exceptions.length" class="panel">
<div class="panel-header">
<div>
<h2>异常清单</h2>
<p>优先处理缺薪资档案缺卡迟到扣款和未匹配员工</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>员工</th>
<th>类型</th>
<th>级别</th>
<th>说明</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="item in detail.exceptions" :key="item.id">
<td>
<strong>{{ item.employee_name || "-" }}</strong>
<span>{{ item.employee_no || "-" }}</span>
</td>
<td>{{ item.exception_type }}</td>
<td>{{ item.severity }}</td>
<td>{{ item.message }}</td>
<td>{{ item.status }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>工资明细</h2>
<p>锁定后本月工资不能重新计算只能导出</p>
</div>
<FileSpreadsheet :size="22" aria-hidden="true" />
</div>
<ResultsTable v-if="detail?.results.length" :results="detail.results" />
<div v-else class="empty-state">
<FileSpreadsheet :size="38" aria-hidden="true" />
<p>当前月份还没有核算结果请导入 Excel 或同步钉钉并计算</p>
</div>
</section>
</div>
</template>

View File

@ -1,5 +1,16 @@
<script setup lang="ts">
import { Building2, GitBranch, Loader2, Pencil, Plus, RefreshCw, Save } from "@lucide/vue";
import {
BriefcaseBusiness,
Building2,
ChevronDown,
ChevronRight,
FolderTree,
GitBranch,
Loader2,
Plus,
RefreshCw,
Save,
} from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue";
import {
@ -12,7 +23,26 @@ import {
} from "../api/organization";
import { ApiError } from "../api/http";
import type { DepartmentRecord, DepartmentRequest, PositionRecord, PositionRequest } from "../api/types";
import StatCard from "../components/StatCard.vue";
type OrganizationNodeType = "department" | "position";
type DetailEditor = "department" | "position";
interface OrganizationTreeNode {
key: string;
type: OrganizationNodeType;
id: number;
name: string;
code: string;
is_active: boolean;
parentId: number | null;
departmentId: number | null;
children: OrganizationTreeNode[];
}
interface OrganizationTreeRow {
node: OrganizationTreeNode;
depth: number;
}
const departments = ref<DepartmentRecord[]>([]);
const positions = ref<PositionRecord[]>([]);
@ -22,6 +52,10 @@ const savingPosition = ref(false);
const editingDepartmentId = ref<number | null>(null);
const editingPositionId = ref<number | null>(null);
const editingPositionCode = ref("");
const currentEditor = ref<DetailEditor>("department");
const selectedNodeKey = ref("");
const expandedDepartmentIds = ref<Set<number>>(new Set());
const hasInitializedExpansion = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
@ -42,9 +76,122 @@ const positionForm = reactive<PositionRequest>({
remark: "",
});
const activeDepartments = computed(() => departments.value.filter((item) => item.is_active));
const activePositions = computed(() => positions.value.filter((item) => item.is_active));
const positionRows = computed(() => positions.value);
const sortedDepartments = computed(() => [...departments.value].sort(sortByOrderThenId));
const sortedPositions = computed(() => [...positions.value].sort(sortByOrderThenId));
const activeDepartments = computed(() => sortedDepartments.value.filter((item) => item.is_active));
const activePositions = computed(() => sortedPositions.value.filter((item) => item.is_active));
const organizationTree = computed<OrganizationTreeNode[]>(() => {
const departmentNodeMap = new Map<number, OrganizationTreeNode>();
const roots: OrganizationTreeNode[] = [];
for (const department of sortedDepartments.value) {
departmentNodeMap.set(department.id, {
key: departmentKey(department.id),
type: "department",
id: department.id,
name: department.name,
code: department.department_code,
is_active: department.is_active,
parentId: department.parent_id,
departmentId: department.id,
children: [],
});
}
for (const department of sortedDepartments.value) {
const node = departmentNodeMap.get(department.id);
if (!node) {
continue;
}
const parentNode = department.parent_id ? departmentNodeMap.get(department.parent_id) : null;
if (parentNode) {
parentNode.children.push(node);
} else {
roots.push(node);
}
}
for (const position of sortedPositions.value) {
const parentNode = departmentNodeMap.get(position.department_id);
if (!parentNode) {
continue;
}
parentNode.children.push({
key: positionKey(position.id),
type: "position",
id: position.id,
name: position.name,
code: position.position_code,
is_active: position.is_active,
parentId: position.department_id,
departmentId: position.department_id,
children: [],
});
}
return roots;
});
const allTreeNodes = computed(() => flattenAllNodes(organizationTree.value));
const selectedNode = computed(() => allTreeNodes.value.find((node) => node.key === selectedNodeKey.value) || null);
const visibleTreeRows = computed<OrganizationTreeRow[]>(() => flattenVisibleNodes(organizationTree.value));
const availableParentDepartments = computed(() => {
const excludedIds = editingDepartmentId.value ? descendantDepartmentIds(editingDepartmentId.value) : new Set<number>();
if (editingDepartmentId.value) {
excludedIds.add(editingDepartmentId.value);
}
return sortedDepartments.value.filter((department) => !excludedIds.has(department.id));
});
const actionDepartmentId = computed(() => {
if (selectedNode.value?.type === "department") {
return selectedNode.value.id;
}
if (selectedNode.value?.type === "position") {
return selectedNode.value.departmentId || 0;
}
if (editingDepartmentId.value) {
return editingDepartmentId.value;
}
return positionForm.department_id || departmentForm.parent_id || 0;
});
const selectedDepartment = computed(() => {
if (!actionDepartmentId.value) {
return null;
}
return departments.value.find((department) => department.id === actionDepartmentId.value) || null;
});
const selectedDepartmentChildren = computed(() => {
if (!actionDepartmentId.value) {
return [];
}
return sortedDepartments.value.filter((department) => department.parent_id === actionDepartmentId.value);
});
const selectedDepartmentPositions = computed(() => {
if (!actionDepartmentId.value) {
return [];
}
return sortedPositions.value.filter((position) => position.department_id === actionDepartmentId.value);
});
const detailTitle = computed(() => {
if (currentEditor.value === "department") {
return editingDepartmentId.value ? "部门详情" : "新增部门";
}
return editingPositionId.value ? "岗位详情" : "新增岗位";
});
const detailSubtitle = computed(() => {
if (currentEditor.value === "department") {
return editingDepartmentId.value ? "调整部门编码、父级和启用状态" : "新增部门时可直接指定上级部门";
}
return editingPositionId.value ? "调整岗位名称、所属部门和启用状态" : "新增岗位会挂到当前选中的部门下";
});
onMounted(loadData);
@ -58,6 +205,8 @@ async function loadData(): Promise<void> {
]);
departments.value = departmentRows;
positions.value = positionRowsData;
reconcileExpandedDepartments(departmentRows);
syncSelectionAfterLoad();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "组织架构加载失败";
} finally {
@ -70,14 +219,11 @@ async function submitDepartment(): Promise<void> {
errorMessage.value = "";
successMessage.value = "";
try {
if (editingDepartmentId.value) {
await updateDepartment(editingDepartmentId.value, normalizedDepartment());
successMessage.value = "部门已更新";
} else {
await createDepartment(normalizedDepartment());
successMessage.value = "部门已新增";
}
resetDepartmentForm();
const department = editingDepartmentId.value
? await updateDepartment(editingDepartmentId.value, normalizedDepartment())
: await createDepartment(normalizedDepartment());
selectedNodeKey.value = departmentKey(department.id);
successMessage.value = editingDepartmentId.value ? "部门已更新" : "部门已新增";
await loadData();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "部门保存失败";
@ -95,14 +241,11 @@ async function submitPosition(): Promise<void> {
errorMessage.value = "";
successMessage.value = "";
try {
if (editingPositionId.value) {
await updatePosition(editingPositionId.value, normalizedPosition());
successMessage.value = "岗位已更新";
} else {
await createPosition(normalizedPosition());
successMessage.value = "岗位已新增";
}
resetPositionForm();
const position = editingPositionId.value
? await updatePosition(editingPositionId.value, normalizedPosition())
: await createPosition(normalizedPosition());
selectedNodeKey.value = positionKey(position.id);
successMessage.value = editingPositionId.value ? "岗位已更新" : "岗位已新增";
await loadData();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "岗位保存失败";
@ -111,8 +254,27 @@ async function submitPosition(): Promise<void> {
}
}
function editDepartment(department: DepartmentRecord): void {
function selectTreeNode(node: OrganizationTreeNode): void {
if (node.type === "department") {
const department = departments.value.find((item) => item.id === node.id);
if (department) {
selectDepartment(department);
}
return;
}
const position = positions.value.find((item) => item.id === node.id);
if (position) {
selectPosition(position);
}
}
function selectDepartment(department: DepartmentRecord): void {
currentEditor.value = "department";
selectedNodeKey.value = departmentKey(department.id);
editingDepartmentId.value = department.id;
editingPositionId.value = null;
editingPositionCode.value = "";
expandDepartmentPath(department.id);
Object.assign(departmentForm, {
parent_id: department.parent_id,
department_code: department.department_code,
@ -123,9 +285,13 @@ function editDepartment(department: DepartmentRecord): void {
});
}
function editPosition(position: PositionRecord): void {
function selectPosition(position: PositionRecord): void {
currentEditor.value = "position";
selectedNodeKey.value = positionKey(position.id);
editingPositionId.value = position.id;
editingPositionCode.value = position.position_code;
editingDepartmentId.value = null;
expandDepartmentPath(position.department_id);
Object.assign(positionForm, {
department_id: position.department_id,
name: position.name,
@ -135,6 +301,34 @@ function editPosition(position: PositionRecord): void {
});
}
function startNewRootDepartment(): void {
currentEditor.value = "department";
selectedNodeKey.value = "new-department";
resetDepartmentForm();
}
function startNewChildDepartment(): void {
const parentId = actionDepartmentId.value;
currentEditor.value = "department";
selectedNodeKey.value = "new-child-department";
resetDepartmentForm();
departmentForm.parent_id = parentId || null;
if (parentId) {
expandDepartmentPath(parentId);
}
}
function startNewPosition(): void {
const departmentId = actionDepartmentId.value || activeDepartments.value[0]?.id || 0;
currentEditor.value = "position";
selectedNodeKey.value = "new-position";
resetPositionForm();
positionForm.department_id = departmentId;
if (departmentId) {
expandDepartmentPath(departmentId);
}
}
function resetDepartmentForm(): void {
editingDepartmentId.value = null;
Object.assign(departmentForm, {
@ -159,6 +353,27 @@ function resetPositionForm(): void {
});
}
function toggleDepartment(node: OrganizationTreeNode): void {
if (node.type !== "department") {
return;
}
const nextExpanded = new Set(expandedDepartmentIds.value);
if (nextExpanded.has(node.id)) {
nextExpanded.delete(node.id);
} else {
nextExpanded.add(node.id);
}
expandedDepartmentIds.value = nextExpanded;
}
function expandAllDepartments(): void {
expandedDepartmentIds.value = new Set(departments.value.map((department) => department.id));
}
function collapseAllDepartments(): void {
expandedDepartmentIds.value = new Set();
}
function normalizedDepartment(): DepartmentRequest {
return {
parent_id: departmentForm.parent_id || null,
@ -173,6 +388,7 @@ function normalizedDepartment(): DepartmentRequest {
function normalizedPosition(): PositionRequest {
return {
department_id: Number(positionForm.department_id),
position_code: editingPositionCode.value,
name: positionForm.name.trim(),
sort_order: Number(positionForm.sort_order || 0),
is_active: positionForm.is_active,
@ -180,11 +396,100 @@ function normalizedPosition(): PositionRequest {
};
}
function departmentName(id: number | null): string {
if (!id) {
return "-";
function syncSelectionAfterLoad(): void {
const node = allTreeNodes.value.find((item) => item.key === selectedNodeKey.value);
if (node) {
selectTreeNode(node);
return;
}
return departments.value.find((item) => item.id === id)?.name || "-";
if (sortedDepartments.value.length > 0) {
selectDepartment(sortedDepartments.value[0]);
}
}
function reconcileExpandedDepartments(nextDepartments: DepartmentRecord[]): void {
const availableIds = new Set(nextDepartments.map((department) => department.id));
if (!hasInitializedExpansion.value) {
expandedDepartmentIds.value = new Set(availableIds);
hasInitializedExpansion.value = true;
return;
}
expandedDepartmentIds.value = new Set(
[...expandedDepartmentIds.value].filter((departmentId) => availableIds.has(departmentId)),
);
}
function flattenAllNodes(nodes: OrganizationTreeNode[]): OrganizationTreeNode[] {
return nodes.flatMap((node) => [node, ...flattenAllNodes(node.children)]);
}
function flattenVisibleNodes(nodes: OrganizationTreeNode[], depth = 0): OrganizationTreeRow[] {
const rows: OrganizationTreeRow[] = [];
for (const node of nodes) {
rows.push({ node, depth });
if (node.type === "department" && expandedDepartmentIds.value.has(node.id)) {
rows.push(...flattenVisibleNodes(node.children, depth + 1));
}
}
return rows;
}
function expandDepartmentPath(departmentId: number): void {
const nextExpanded = new Set(expandedDepartmentIds.value);
let current = departments.value.find((department) => department.id === departmentId) || null;
while (current) {
nextExpanded.add(current.id);
current = current.parent_id
? departments.value.find((department) => department.id === current?.parent_id) || null
: null;
}
expandedDepartmentIds.value = nextExpanded;
}
function descendantDepartmentIds(departmentId: number): Set<number> {
const result = new Set<number>();
const collect = (parentId: number): void => {
for (const department of departments.value) {
if (department.parent_id === parentId) {
result.add(department.id);
collect(department.id);
}
}
};
collect(departmentId);
return result;
}
function departmentOptionLabel(department: DepartmentRecord): string {
return departmentPath(department.id).join(" / ");
}
function departmentPath(departmentId: number): string[] {
const path: string[] = [];
let current = departments.value.find((department) => department.id === departmentId) || null;
while (current) {
path.unshift(current.name);
current = current.parent_id
? departments.value.find((department) => department.id === current?.parent_id) || null
: null;
}
return path;
}
function treeNodeStyle(depth: number): Record<string, string> {
return { "--tree-depth": String(depth) };
}
function departmentKey(id: number): string {
return `department-${id}`;
}
function positionKey(id: number): string {
return `position-${id}`;
}
function sortByOrderThenId<T extends { sort_order: number; id: number }>(left: T, right: T): number {
return left.sort_order - right.sort_order || left.id - right.id;
}
</script>
@ -193,7 +498,7 @@ function departmentName(id: number | null): string {
<header class="page-header">
<div>
<h1>组织架构</h1>
<p>维护部门上下级和部门下岗位员工维护会按这里的关系联动</p>
<p>用树状关系维护部门上下级和部门下岗位员工维护会按这里的关系联动</p>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
@ -202,23 +507,145 @@ function departmentName(id: number | null): string {
</button>
</header>
<section class="stats-grid">
<StatCard title="部门总数" :value="String(departments.length)" :icon="Building2" tone="blue" meta="含停用" />
<StatCard title="启用部门" :value="String(activeDepartments.length)" :icon="Building2" tone="green" meta="可选" />
<StatCard title="岗位总数" :value="String(positions.length)" :icon="GitBranch" tone="neutral" meta="含停用" />
<StatCard title="启用岗位" :value="String(activePositions.length)" :icon="GitBranch" tone="green" meta="员工可选" />
<section class="organization-summary-strip">
<div>
<Building2 :size="18" aria-hidden="true" />
<span>部门</span>
<strong>{{ departments.length }}</strong>
<small>{{ activeDepartments.length }} 个启用</small>
</div>
<div>
<GitBranch :size="18" aria-hidden="true" />
<span>岗位</span>
<strong>{{ positions.length }}</strong>
<small>{{ activePositions.length }} 个启用</small>
</div>
<div>
<FolderTree :size="18" aria-hidden="true" />
<span>当前部门</span>
<strong>{{ selectedDepartment?.name || "-" }}</strong>
<small>{{ selectedDepartmentPositions.length }} 个直属岗位</small>
</div>
</section>
<section class="dashboard-grid">
<div class="panel">
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="organization-tree-layout">
<aside class="panel organization-tree-panel">
<div class="panel-header">
<div>
<h2>{{ editingDepartmentId ? "编辑部门" : "新增部门" }}</h2>
<p>部门可选择父级形成上下级关系</p>
<h2>组织树</h2>
<p>{{ departments.length }} 个部门{{ positions.length }} 个岗位</p>
</div>
<div class="organization-tree-tools">
<button class="link-button" type="button" @click="expandAllDepartments">展开</button>
<button class="link-button muted" type="button" @click="collapseAllDepartments">收起</button>
</div>
<button class="secondary-button" type="button" @click="resetDepartmentForm">清空</button>
</div>
<form class="form-grid compact-form" @submit.prevent="submitDepartment">
<div class="organization-tree" aria-label="组织架构树">
<div v-if="loading" class="organization-tree-empty">
<Loader2 class="spin" :size="18" aria-hidden="true" />
<span>正在加载组织架构</span>
</div>
<div v-else-if="visibleTreeRows.length === 0" class="organization-tree-empty">
<span>暂无组织架构请先新增部门</span>
</div>
<button
v-for="row in visibleTreeRows"
v-else
:key="row.node.key"
class="tree-node"
:class="[
`tree-node-${row.node.type}`,
{
'is-selected': selectedNodeKey === row.node.key,
'is-inactive': !row.node.is_active,
},
]"
:style="treeNodeStyle(row.depth)"
type="button"
@click="selectTreeNode(row.node)"
>
<span
v-if="row.node.type === 'department' && row.node.children.length"
class="tree-toggle"
role="button"
tabindex="-1"
@click.stop="toggleDepartment(row.node)"
>
<ChevronDown v-if="expandedDepartmentIds.has(row.node.id)" :size="15" aria-hidden="true" />
<ChevronRight v-else :size="15" aria-hidden="true" />
</span>
<span v-else class="tree-toggle is-placeholder" aria-hidden="true"></span>
<span class="tree-node-icon">
<Building2 v-if="row.node.type === 'department'" :size="16" aria-hidden="true" />
<BriefcaseBusiness v-else :size="16" aria-hidden="true" />
</span>
<span class="tree-node-main">
<strong>{{ row.node.name }}</strong>
<small>{{ row.node.code }}</small>
</span>
<span class="status-chip" :class="row.node.is_active ? 'green' : 'red'">
{{ row.node.is_active ? "启用" : "停用" }}
</span>
</button>
</div>
</aside>
<section class="panel organization-detail-panel">
<div class="panel-header organization-detail-header">
<div>
<h2>{{ detailTitle }}</h2>
<p>{{ detailSubtitle }}</p>
</div>
<div class="organization-detail-actions">
<button class="secondary-button compact-button" type="button" @click="startNewRootDepartment">
<Plus :size="16" aria-hidden="true" />
<span>新增部门</span>
</button>
<button
class="secondary-button compact-button"
type="button"
:disabled="!actionDepartmentId"
@click="startNewChildDepartment"
>
<Plus :size="16" aria-hidden="true" />
<span>新增下级</span>
</button>
<button
class="primary-button compact-button"
type="button"
:disabled="!actionDepartmentId && activeDepartments.length === 0"
@click="startNewPosition"
>
<Plus :size="16" aria-hidden="true" />
<span>新增岗位</span>
</button>
</div>
</div>
<div v-if="selectedDepartment" class="organization-node-summary">
<div>
<span>上级部门</span>
<strong>{{ selectedDepartment.parent_id ? departmentPath(selectedDepartment.parent_id).join(" / ") : "无上级" }}</strong>
</div>
<div>
<span>下级部门</span>
<strong>{{ selectedDepartmentChildren.length }} </strong>
</div>
<div>
<span>直属岗位</span>
<strong>{{ selectedDepartmentPositions.length }} </strong>
</div>
</div>
<form
v-if="currentEditor === 'department'"
class="form-grid compact-form organization-editor-form"
@submit.prevent="submitDepartment"
>
<label class="field">
<span>部门编码</span>
<input v-model="departmentForm.department_code" required maxlength="64" type="text" />
@ -230,13 +657,9 @@ function departmentName(id: number | null): string {
<label class="field">
<span>父级部门</span>
<select v-model.number="departmentForm.parent_id">
<option :value="null">无父级</option>
<option
v-for="department in departments.filter((item) => item.id !== editingDepartmentId)"
:key="department.id"
:value="department.id"
>
{{ department.name }}
<option :value="null">无上级部门</option>
<option v-for="department in availableParentDepartments" :key="department.id" :value="department.id">
{{ departmentOptionLabel(department) }}
</option>
</select>
</label>
@ -255,29 +678,19 @@ function departmentName(id: number | null): string {
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="savingDepartment">
<Loader2 v-if="savingDepartment" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingDepartmentId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ savingDepartment ? "保存中" : editingDepartmentId ? "保存部门" : "新增部门" }}</span>
<Save v-else :size="18" aria-hidden="true" />
<span>{{ savingDepartment ? "保存中" : editingDepartmentId ? "保存部门" : "创建部门" }}</span>
</button>
</div>
</form>
</div>
<div class="panel">
<div class="panel-header">
<div>
<h2>{{ editingPositionId ? "编辑岗位" : "新增岗位" }}</h2>
<p>岗位必须归属到一个部门</p>
</div>
<button class="secondary-button" type="button" @click="resetPositionForm">清空</button>
</div>
<form class="form-grid compact-form" @submit.prevent="submitPosition">
<form v-else class="form-grid compact-form organization-editor-form" @submit.prevent="submitPosition">
<label class="field">
<span>所属部门</span>
<select v-model.number="positionForm.department_id" required>
<option :value="0">请选择部门</option>
<option v-for="department in activeDepartments" :key="department.id" :value="department.id">
{{ department.name }}
{{ departmentOptionLabel(department) }}
</option>
</select>
</label>
@ -304,96 +717,12 @@ function departmentName(id: number | null): string {
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="savingPosition">
<Loader2 v-if="savingPosition" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingPositionId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ savingPosition ? "保存中" : editingPositionId ? "保存岗位" : "新增岗位" }}</span>
<Save v-else :size="18" aria-hidden="true" />
<span>{{ savingPosition ? "保存中" : editingPositionId ? "保存岗位" : "创建岗位" }}</span>
</button>
</div>
</form>
</div>
</section>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="dashboard-grid">
<div class="panel table-panel">
<div class="panel-header">
<div>
<h2>部门列表</h2>
<p>{{ departments.length }} 个部门</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>部门</th>
<th>父级</th>
<th>排序</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="department in departments" :key="department.id">
<td>
<strong>{{ department.name }}</strong>
<span>{{ department.department_code }}</span>
</td>
<td>{{ departmentName(department.parent_id) }}</td>
<td>{{ department.sort_order }}</td>
<td><span class="status-chip" :class="department.is_active ? 'green' : 'red'">{{ department.is_active ? "启用" : "停用" }}</span></td>
<td>
<button class="link-button" type="button" @click="editDepartment(department)">
<Pencil :size="15" aria-hidden="true" />
<span>编辑</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel table-panel">
<div class="panel-header">
<div>
<h2>岗位列表</h2>
<p>{{ positions.length }} 个岗位</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>岗位</th>
<th>部门</th>
<th>排序</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="position in positionRows" :key="position.id">
<td>
<strong>{{ position.name }}</strong>
<span>{{ position.position_code }}</span>
</td>
<td>{{ position.department.name }}</td>
<td>{{ position.sort_order }}</td>
<td><span class="status-chip" :class="position.is_active ? 'green' : 'red'">{{ position.is_active ? "启用" : "停用" }}</span></td>
<td>
<button class="link-button" type="button" @click="editPosition(position)">
<Pencil :size="15" aria-hidden="true" />
<span>编辑</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</section>
</div>
</template>

View File

@ -0,0 +1,208 @@
<script setup lang="ts">
import { CalendarClock, CloudCog, Loader2, RefreshCw } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http";
import { getTodayAttendance, syncRealtimeAttendance } from "../api/realtimeAttendance";
import type { RealtimeAttendanceResponse, TodayAttendanceItem } from "../api/types";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const selectedDate = ref(new Date().toISOString().slice(0, 10));
const response = ref<RealtimeAttendanceResponse | null>(null);
const loading = ref(false);
const syncing = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const summary = computed(() => response.value?.summary);
const rows = computed(() => response.value?.items || []);
const canSync = computed(() => auth.hasPermission("attendance:realtime:sync"));
onMounted(() => {
void loadTodayAttendance();
});
async function loadTodayAttendance(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
response.value = await getTodayAttendance(selectedDate.value);
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "实时考勤加载失败";
} finally {
loading.value = false;
}
}
async function syncDingTalk(): Promise<void> {
syncing.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
response.value = await syncRealtimeAttendance(selectedDate.value);
successMessage.value = "钉钉考勤已同步,薪资预估已刷新";
} catch (error) {
const message = error instanceof ApiError ? error.message : "钉钉同步失败";
errorMessage.value = message.includes("dingtalk.app_key")
? "钉钉配置缺失,请先在 config/app_settings.json 中配置 app_key 和 app_secret"
: message;
} finally {
syncing.value = false;
}
}
function statusText(status: string): string {
const map: Record<string, string> = {
present: "已打卡",
missing: "缺卡",
leave: "请假",
unchecked: "未打卡",
absent: "未打卡",
};
return map[status] || status || "-";
}
function money(value: number | null): string {
if (value === null) {
return "-";
}
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(value);
}
function numberText(value: number, unit = ""): string {
return `${Number(value || 0).toFixed(1)}${unit}`;
}
function rowKey(row: TodayAttendanceItem): string {
return row.employee_id ? String(row.employee_id) : `${row.employee_no}-${row.employee_name}`;
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>实时考勤</h1>
<p>查看今日打卡状态并预估本月薪资进度</p>
</div>
<div class="header-actions">
<label class="field compact-field">
<span>考勤日期</span>
<input v-model="selectedDate" type="date" @change="loadTodayAttendance" />
</label>
<button class="secondary-button" type="button" :disabled="loading || syncing" @click="loadTodayAttendance">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
<button class="primary-button" type="button" :disabled="!canSync || syncing" @click="syncDingTalk">
<Loader2 v-if="syncing" class="spin" :size="18" aria-hidden="true" />
<CloudCog v-else :size="18" aria-hidden="true" />
<span>{{ syncing ? "同步中" : "同步钉钉" }}</span>
</button>
</div>
</header>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel">
<div class="panel-header">
<div>
<h2>今日考勤与薪资预估</h2>
<p>预估金额最终以月度核算锁定结果为准</p>
</div>
<span class="status-chip" :class="response?.sync_job_id ? 'green' : 'neutral'">
{{ response?.sync_job_id ? "已同步" : "未同步" }}
</span>
</div>
<div class="compact-stats-grid">
<div class="compact-stat">
<span>在职员工</span>
<strong>{{ summary?.employee_total || 0 }}</strong>
</div>
<div class="compact-stat">
<span>已打卡</span>
<strong>{{ summary?.checked_in_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>未打卡</span>
<strong>{{ summary?.unchecked_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>迟到</span>
<strong>{{ summary?.late_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>缺卡</span>
<strong>{{ summary?.missing_card_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>预估实发</span>
<strong>{{ money(summary?.estimated_net_total ?? 0) }}</strong>
</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>员工今日考勤</h2>
<p>{{ response?.synced_at ? `最近同步:${response.synced_at}` : "可先查看已入库数据,也可同步钉钉刷新。" }}</p>
</div>
</div>
<div v-if="!rows.length && !loading" class="empty-state">
<CalendarClock :size="38" aria-hidden="true" />
<p>当前日期暂无考勤数据可点击同步钉钉拉取在职员工打卡记录</p>
</div>
<div v-else class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>员工</th>
<th>部门/岗位</th>
<th>上班</th>
<th>下班</th>
<th>状态</th>
<th>迟到</th>
<th>缺卡</th>
<th>请假</th>
<th>今日加班</th>
<th>本月剩余加班</th>
<th>预估实发</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="rowKey(row)">
<td>
<strong>{{ row.employee_name }}</strong>
<span>{{ row.employee_no || "-" }}</span>
</td>
<td>
<strong>{{ row.department || "-" }}</strong>
<span>{{ row.position || "-" }}</span>
</td>
<td>{{ row.first_punch || "-" }}</td>
<td>{{ row.last_punch || "-" }}</td>
<td>{{ statusText(row.attendance_status) }}</td>
<td>{{ row.late_minutes ? `${row.late_minutes} 分钟` : "-" }}</td>
<td>{{ row.missing_card_count || "-" }}</td>
<td>{{ numberText(row.leave_hours, "h") }}</td>
<td>{{ numberText(row.today_overtime_hours, "h") }}</td>
<td>{{ numberText(row.month_remaining_overtime_hours, "h") }}</td>
<td>{{ money(row.estimated_net_salary) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>

View File

@ -0,0 +1,36 @@
import {
DEFAULT_THEME_SETTINGS,
isLegacyDefaultAppearance,
themeOptions,
} from "../src/stores/theme";
assertEqual(DEFAULT_THEME_SETTINGS.theme, "enterprise");
assertEqual(DEFAULT_THEME_SETTINGS.mode, "light");
assertEqual(DEFAULT_THEME_SETTINGS.font, "sans");
assertEqual(DEFAULT_THEME_SETTINGS.radius, "0.3");
assertEqual(DEFAULT_THEME_SETTINGS.density, "compact");
assertEqual(DEFAULT_THEME_SETTINGS.sidebar, "embedded");
const enterprise = themeOptions.find((item) => item.name === "enterprise");
if (!enterprise) {
throw new Error("缺少企业清爽主题");
}
assertEqual(enterprise.label, "企业清爽");
assertEqual(enterprise.tokens.light.surface, "#f5f7fa");
assertEqual(enterprise.tokens.light.sidebarBg, "#ffffff");
assertEqual(enterprise.tokens.light.navActiveBg, "#e8f0ff");
assertTruthy(isLegacyDefaultAppearance("anthropic", "dark", "serif", "1", "floating"));
assertTruthy(isLegacyDefaultAppearance("navy", "dark", "serif", "1", "floating"));
function assertEqual(actual: string, expected: string): void {
if (actual !== expected) {
throw new Error(`期望 ${expected},实际 ${actual}`);
}
}
function assertTruthy(actual: boolean): void {
if (!actual) {
throw new Error("期望结果为 true");
}
}

View File

@ -0,0 +1,41 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const view = readFileSync(join(process.cwd(), "src/views/OrganizationView.vue"), "utf-8");
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
assertIncludes(view, "organization-tree-layout");
assertIncludes(view, "organization-tree");
assertIncludes(view, "tree-node");
assertIncludes(view, "selectedNode");
assertIncludes(view, "部门详情");
assertIncludes(view, "岗位详情");
assertNotIncludes(view, "<h2>部门列表</h2>");
assertNotIncludes(view, "<h2>岗位列表</h2>");
assertIncludes(css, ".organization-tree-layout");
assertIncludes(css, ".organization-tree-panel");
assertIncludes(css, ".organization-detail-panel");
assertIncludes(css, ".tree-node");
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string): void {
if (content.includes(unexpected)) {
throw new Error(`不应包含 ${unexpected}`);
}
}

View File

@ -0,0 +1,56 @@
declare const process: { cwd(): string };
declare function require(name: string): {
existsSync?: (path: string) => boolean;
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { existsSync, readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!existsSync || !readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const realtimeApiPath = join(root, "src/api/realtimeAttendance.ts");
const monthlyApiPath = join(root, "src/api/monthlyPayroll.ts");
const realtimeViewPath = join(root, "src/views/RealtimeAttendanceView.vue");
const monthlyViewPath = join(root, "src/views/MonthlyPayrollView.vue");
assertTruthy(existsSync(realtimeApiPath), "缺少实时考勤 API 客户端");
assertTruthy(existsSync(monthlyApiPath), "缺少月度核算 API 客户端");
const realtimeApi = readFileSync(realtimeApiPath, "utf-8");
assertIncludes(realtimeApi, "/api/attendance/realtime/today");
assertIncludes(realtimeApi, "/api/attendance/realtime/sync");
const monthlyApi = readFileSync(monthlyApiPath, "utf-8");
assertIncludes(monthlyApi, "/api/payroll/monthly/runs");
const realtimeView = readFileSync(realtimeViewPath, "utf-8");
assertNotIncludes(realtimeView, "待接入菜单");
assertNotIncludes(realtimeView, "待接入钉钉同步");
assertIncludes(realtimeView, "预估金额,最终以月度核算锁定结果为准");
const monthlyView = readFileSync(monthlyViewPath, "utf-8");
assertNotIncludes(monthlyView, "下一阶段接入");
assertIncludes(monthlyView, "锁定后本月工资不能重新计算");
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string): void {
if (content.includes(unexpected)) {
throw new Error(`不应包含 ${unexpected}`);
}
}
function assertTruthy(value: boolean, message: string): void {
if (!value) {
throw new Error(message);
}
}

View File

@ -0,0 +1,46 @@
import {
buildSidebarSections,
type SidebarMenuInput,
} from "../src/navigation/sidebarMenu";
const backendMenus: SidebarMenuInput[] = [
{ code: "attendance_realtime", name: "实时考勤", path: "/attendance/realtime" },
{ code: "monthly_payroll", name: "月度核算", path: "/payroll/monthly" },
{ code: "payroll", name: "工资计算", path: "/payroll" },
{ code: "jobs", name: "计算记录", path: "/payroll/jobs" },
{ code: "commissions", name: "提成管理", path: "/payroll/commissions" },
{ code: "employees", name: "员工维护", path: "/system/employees" },
{ code: "salary_profiles", name: "薪资维护", path: "/system/salary-profiles" },
{ code: "organization", name: "组织架构", path: "/system/organization" },
{ code: "reports", name: "统计报表", path: "/reports" },
{ code: "configs", name: "规则配置", path: "/system/configs" },
{ code: "users", name: "用户管理", path: "/system/users" },
{ code: "operation_logs", name: "操作日志", path: "/system/operation-logs" },
];
const sections = buildSidebarSections(backendMenus);
assertEqual(
sections.map((section) => section.title).join(","),
"核心工作,月度核算,员工薪资,报表分析,系统管理",
);
assertEqual(sectionCodes("核心工作"), "dashboard,attendance_realtime");
assertEqual(sectionCodes("月度核算"), "monthly_payroll,jobs,commissions");
assertEqual(sectionCodes("员工薪资"), "employees,salary_profiles,organization");
assertEqual(sectionCodes("报表分析"), "reports");
assertEqual(sectionCodes("系统管理"), "configs,users,operation_logs");
function sectionCodes(title: string): string {
const section = sections.find((item) => item.title === title);
if (!section) {
throw new Error(`找不到菜单分组:${title}`);
}
return section.items.map((item) => item.code).join(",");
}
function assertEqual(actual: string, expected: string): void {
if (actual !== expected) {
throw new Error(`期望 ${expected},实际 ${actual}`);
}
}

View File

@ -0,0 +1,51 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
const loginVisual = cssBlock(".login-visual");
assertIncludes(loginVisual, "background: var(--surface-soft);");
assertIncludes(loginVisual, "color: var(--text);");
assertNotIncludes(loginVisual, "rgba(var(--primary-rgb), 0.94)");
const ledgerVisual = cssBlock(".ledger-visual");
assertIncludes(ledgerVisual, "background: var(--card);");
assertIncludes(ledgerVisual, "padding: 32px;");
const appearancePanel = cssBlock(".appearance-panel");
assertIncludes(appearancePanel, "width: min(480px, calc(100vw - 20px));");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern);
if (!match) {
throw new Error(`缺少样式块 ${selector}`);
}
return match[1];
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string): void {
if (content.includes(unexpected)) {
throw new Error(`不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}