forked from jiaoly/financial_system
更新部分功能实现逻辑
This commit is contained in:
parent
1e6230d412
commit
faa09101a1
39
README.md
39
README.md
@ -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" \
|
||||
|
||||
1412
docs/plans/2026-06-18-monthly-payroll-center.md
Normal file
1412
docs/plans/2026-06-18-monthly-payroll-center.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
285
financial_system/api/routers/monthly_payroll.py
Normal file
285
financial_system/api/routers/monthly_payroll.py
Normal 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),
|
||||
)
|
||||
95
financial_system/api/routers/realtime_attendance.py
Normal file
95
financial_system/api/routers/realtime_attendance.py
Normal 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())
|
||||
@ -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",
|
||||
]
|
||||
|
||||
61
financial_system/api/schemas/monthly_payroll.py
Normal file
61
financial_system/api/schemas/monthly_payroll.py
Normal 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]
|
||||
55
financial_system/api/schemas/realtime_attendance.py
Normal file
55
financial_system/api/schemas/realtime_attendance.py
Normal 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]
|
||||
77
financial_system/domain/payroll_exceptions.py
Normal file
77
financial_system/domain/payroll_exceptions.py
Normal 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
|
||||
@ -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",
|
||||
|
||||
122
financial_system/repositories/monthly_payroll_repository.py
Normal file
122
financial_system/repositories/monthly_payroll_repository.py
Normal 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()
|
||||
)
|
||||
147
financial_system/repositories/realtime_attendance_repository.py
Normal file
147
financial_system/repositories/realtime_attendance_repository.py
Normal 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()
|
||||
)
|
||||
@ -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",
|
||||
|
||||
187
financial_system/services/monthly_payroll_service.py
Normal file
187
financial_system/services/monthly_payroll_service.py
Normal 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])
|
||||
@ -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,
|
||||
|
||||
395
financial_system/services/realtime_attendance_service.py
Normal file
395
financial_system/services/realtime_attendance_service.py
Normal 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"
|
||||
@ -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` | 可查看实时考勤、计算记录、下载结果和统计报表。 |
|
||||
|
||||
## 构建
|
||||
|
||||
|
||||
68
frontend/src/api/monthlyPayroll.ts
Normal file
68
frontend/src/api/monthlyPayroll.ts
Normal 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);
|
||||
}
|
||||
16
frontend/src/api/realtimeAttendance.ts
Normal file
16
frontend/src/api/realtimeAttendance.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -561,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;
|
||||
@ -570,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,
|
||||
@ -628,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 {
|
||||
@ -649,7 +660,7 @@ a {
|
||||
}
|
||||
|
||||
.appearance-panel-header h2 {
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.appearance-panel-header p {
|
||||
@ -664,7 +675,7 @@ a {
|
||||
}
|
||||
|
||||
.appearance-section + .appearance-section {
|
||||
margin-top: 16px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.appearance-section-head {
|
||||
@ -674,7 +685,7 @@ a {
|
||||
}
|
||||
|
||||
.appearance-section-head h3 {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.appearance-reset {
|
||||
@ -701,7 +712,7 @@ a {
|
||||
|
||||
.appearance-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mode-grid,
|
||||
@ -712,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 {
|
||||
@ -734,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;
|
||||
@ -757,7 +768,7 @@ a {
|
||||
.appearance-choice strong,
|
||||
.color-preset-choice strong {
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@ -800,7 +811,7 @@ a {
|
||||
}
|
||||
|
||||
.mode-preview {
|
||||
height: 78px;
|
||||
height: 66px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@ -916,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;
|
||||
@ -1748,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));
|
||||
}
|
||||
@ -2049,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 {
|
||||
@ -2064,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;
|
||||
}
|
||||
|
||||
@ -2081,7 +2455,7 @@ a {
|
||||
|
||||
.form-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.input-with-icon {
|
||||
@ -2118,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);
|
||||
}
|
||||
|
||||
@ -2139,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,
|
||||
@ -2184,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 {
|
||||
@ -2199,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;
|
||||
}
|
||||
@ -2212,7 +2586,7 @@ a {
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.appearance-panel {
|
||||
width: min(540px, calc(100vw - 16px));
|
||||
width: min(480px, calc(100vw - 16px));
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
@ -2228,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;
|
||||
}
|
||||
@ -2344,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;
|
||||
}
|
||||
|
||||
@ -2379,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) {
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,5 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { Calculator, FileSpreadsheet, LockKeyhole, Upload } from "@lucide/vue";
|
||||
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>
|
||||
@ -7,41 +146,131 @@ import { Calculator, FileSpreadsheet, LockKeyhole, Upload } from "@lucide/vue";
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>月度核算</h1>
|
||||
<p>按工资月份完成导入、计算、复核、锁定和导出。</p>
|
||||
<p>按工资月份完成导入、计算、异常处理、锁定和导出。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="secondary-button" type="button" disabled>
|
||||
<Upload :size="18" aria-hidden="true" />
|
||||
<span>导入 Excel</span>
|
||||
</button>
|
||||
<button class="primary-button" type="button" disabled>
|
||||
<Calculator :size="18" aria-hidden="true" />
|
||||
<span>同步并计算</span>
|
||||
<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 neutral">草稿</span>
|
||||
<span class="status-chip" :class="isLocked ? 'green' : 'neutral'">{{ statusText(activeRun?.status) }}</span>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<FileSpreadsheet :size="38" aria-hidden="true" />
|
||||
<p>月度核算中心已接入菜单,下一阶段接入月度计算、异常清单和锁定逻辑。</p>
|
||||
|
||||
<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>
|
||||
<h2>工资明细</h2>
|
||||
<p>锁定后本月工资不能重新计算,只能导出。</p>
|
||||
</div>
|
||||
<LockKeyhole :size="22" aria-hidden="true" />
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,5 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarClock, CloudCog, RefreshCw } from "@lucide/vue";
|
||||
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>
|
||||
@ -10,28 +91,117 @@ import { CalendarClock, CloudCog, RefreshCw } from "@lucide/vue";
|
||||
<p>查看今日打卡状态,并预估本月薪资进度。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="secondary-button" type="button" disabled>
|
||||
<RefreshCw :size="18" aria-hidden="true" />
|
||||
<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>
|
||||
<CloudCog :size="18" aria-hidden="true" />
|
||||
<span>同步钉钉</span>
|
||||
<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 neutral">待接入</span>
|
||||
<span class="status-chip" :class="response?.sync_job_id ? 'green' : 'neutral'">
|
||||
{{ response?.sync_job_id ? "已同步" : "未同步" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
36
frontend/tests/appearanceTheme.test.ts
Normal file
36
frontend/tests/appearanceTheme.test.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
41
frontend/tests/organizationTreeLayout.test.ts
Normal file
41
frontend/tests/organizationTreeLayout.test.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
56
frontend/tests/payrollCenterFlow.test.ts
Normal file
56
frontend/tests/payrollCenterFlow.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
frontend/tests/visualPolishCss.test.ts
Normal file
51
frontend/tests/visualPolishCss.test.ts
Normal 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, "\\$&");
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user