Compare commits
4 Commits
99c7ba776f
...
faa09101a1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa09101a1 | ||
|
|
1e6230d412 | ||
|
|
dc74a618f7 | ||
|
|
a846ea5070 |
39
README.md
39
README.md
@ -17,7 +17,8 @@
|
|||||||
## 当前能力
|
## 当前能力
|
||||||
|
|
||||||
- 支持导入钉钉月度汇总 Excel 计算工资。
|
- 支持导入钉钉月度汇总 Excel 计算工资。
|
||||||
- 支持预留钉钉实时打卡接口计算入口。
|
- 支持实时考勤看板:按员工档案中的钉钉 userId 自动同步本月至今打卡,不需要人工输入 userId。
|
||||||
|
- 支持月度核算中心:按月份导入 Excel 或同步钉钉计算,生成异常清单、核算批次、锁定状态和导出入口。
|
||||||
- 自动统计出勤天数、缺勤天数、请假工时、缺卡、迟到扣款。
|
- 自动统计出勤天数、缺勤天数、请假工时、缺卡、迟到扣款。
|
||||||
- 按下班打卡时间自动计算加班:17:00 下班,18:00 计 1 小时,20:30 计 3 小时。
|
- 按下班打卡时间自动计算加班:17:00 下班,18:00 计 1 小时,20:30 计 3 小时。
|
||||||
- 自动计算:`剩余加班工时 = 打卡推算加班工时 - 请假工时`。
|
- 自动计算:`剩余加班工时 = 打卡推算加班工时 - 请假工时`。
|
||||||
@ -28,6 +29,7 @@
|
|||||||
- 加班费支持:工作日、周末、法定节假日独立单价。
|
- 加班费支持:工作日、周末、法定节假日独立单价。
|
||||||
- 提成支持手工维护和 Excel 导入,并按工资月份自动汇总进工资计算。
|
- 提成支持手工维护和 Excel 导入,并按工资月份自动汇总进工资计算。
|
||||||
- 工资计算结果会落库为工资汇总、工资明细、考勤记录、请假记录和加班记录。
|
- 工资计算结果会落库为工资汇总、工资明细、考勤记录、请假记录和加班记录。
|
||||||
|
- 实时薪资预估只用于过程查看,最终工资以月度核算锁定结果为准。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@ -144,6 +146,7 @@ mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/in
|
|||||||
|
|
||||||
```bash
|
```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_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 文件说明:
|
SQL 文件说明:
|
||||||
@ -154,6 +157,7 @@ SQL 文件说明:
|
|||||||
| `seed.sql` | 初始化默认超级管理员 |
|
| `seed.sql` | 初始化默认超级管理员 |
|
||||||
| `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 |
|
| `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 |
|
||||||
| `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 |
|
| `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 |
|
||||||
|
| `upgrade_20260618_monthly_payroll_center.sql` | 实时考勤同步、薪资预估、月度核算批次和异常清单升级脚本 |
|
||||||
|
|
||||||
主要业务表:
|
主要业务表:
|
||||||
|
|
||||||
@ -170,6 +174,10 @@ SQL 文件说明:
|
|||||||
| `commission_record` | 提成/奖金记录 |
|
| `commission_record` | 提成/奖金记录 |
|
||||||
| `payroll_jobs` | 工资计算任务 |
|
| `payroll_jobs` | 工资计算任务 |
|
||||||
| `payroll_results` | 员工工资计算结果 |
|
| `payroll_results` | 员工工资计算结果 |
|
||||||
|
| `attendance_sync_jobs` | 钉钉/Excel 考勤同步批次 |
|
||||||
|
| `salary_preview` | 本月进行中的薪资预估 |
|
||||||
|
| `monthly_payroll_runs` | 月度正式核算批次 |
|
||||||
|
| `payroll_exceptions` | 月度核算异常清单 |
|
||||||
| `salary_record` | 月度工资汇总 |
|
| `salary_record` | 月度工资汇总 |
|
||||||
| `salary_detail` | 工资明细项 |
|
| `salary_detail` | 工资明细项 |
|
||||||
| `salary_config` | 规则配置中心 |
|
| `salary_config` | 规则配置中心 |
|
||||||
@ -227,6 +235,8 @@ config/salary_rules.example.json
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 登录 | `/login` | 用户登录 |
|
| 登录 | `/login` | 用户登录 |
|
||||||
| 工作台 | `/dashboard` | 系统概览 |
|
| 工作台 | `/dashboard` | 系统概览 |
|
||||||
|
| 实时考勤 | `/attendance/realtime` | 查看今日打卡、迟到、缺卡、请假、加班和本月薪资预估 |
|
||||||
|
| 月度核算 | `/payroll/monthly` | 按月导入/同步、计算、异常处理、锁定和导出 |
|
||||||
| 工资计算 | `/payroll` | Excel 导入和钉钉实时计算 |
|
| 工资计算 | `/payroll` | Excel 导入和钉钉实时计算 |
|
||||||
| 计算记录 | `/payroll/jobs` | 查看历史计算任务 |
|
| 计算记录 | `/payroll/jobs` | 查看历史计算任务 |
|
||||||
| 提成管理 | `/payroll/commissions` | 手工维护和 Excel 导入提成 |
|
| 提成管理 | `/payroll/commissions` | 手工维护和 Excel 导入提成 |
|
||||||
@ -277,7 +287,32 @@ curl -X POST "http://127.0.0.1:8000/api/payroll/excel" \
|
|||||||
-F "export_excel=true"
|
-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
|
```bash
|
||||||
curl -X POST "http://127.0.0.1:8000/api/payroll/dingtalk/realtime" \
|
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 ..database import SessionLocal, init_database
|
||||||
from ..repositories import OrganizationRepository, SalaryConfigRepository, UserRepository
|
from ..repositories import OrganizationRepository, SalaryConfigRepository, UserRepository
|
||||||
from ..services import AuthService, OrganizationService, SalaryConfigService
|
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__)
|
logger = AppLogger.get_logger(__name__)
|
||||||
|
|
||||||
@ -56,9 +69,11 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(configs.router)
|
app.include_router(configs.router)
|
||||||
app.include_router(employees.router)
|
app.include_router(employees.router)
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
|
app.include_router(monthly_payroll.router)
|
||||||
app.include_router(operation_logs.router)
|
app.include_router(operation_logs.router)
|
||||||
app.include_router(organization.router)
|
app.include_router(organization.router)
|
||||||
app.include_router(payroll.router)
|
app.include_router(payroll.router)
|
||||||
|
app.include_router(realtime_attendance.router)
|
||||||
app.include_router(reports.router)
|
app.include_router(reports.router)
|
||||||
app.include_router(salary_profiles.router)
|
app.include_router(salary_profiles.router)
|
||||||
return app
|
return app
|
||||||
|
|||||||
@ -16,9 +16,11 @@ from ..database.orm import UserORM
|
|||||||
from ..repositories import (
|
from ..repositories import (
|
||||||
CommissionRepository,
|
CommissionRepository,
|
||||||
EmployeeRepository,
|
EmployeeRepository,
|
||||||
|
MonthlyPayrollRepository,
|
||||||
OperationLogRepository,
|
OperationLogRepository,
|
||||||
OrganizationRepository,
|
OrganizationRepository,
|
||||||
PayrollRepository,
|
PayrollRepository,
|
||||||
|
RealtimeAttendanceRepository,
|
||||||
ReportRepository,
|
ReportRepository,
|
||||||
SalaryConfigRepository,
|
SalaryConfigRepository,
|
||||||
SalaryProfileRepository,
|
SalaryProfileRepository,
|
||||||
@ -28,9 +30,11 @@ from ..services import (
|
|||||||
AuthService,
|
AuthService,
|
||||||
CommissionService,
|
CommissionService,
|
||||||
EmployeeService,
|
EmployeeService,
|
||||||
|
MonthlyPayrollService,
|
||||||
OperationLogService,
|
OperationLogService,
|
||||||
OrganizationService,
|
OrganizationService,
|
||||||
PayrollApplicationService,
|
PayrollApplicationService,
|
||||||
|
RealtimeAttendanceService,
|
||||||
ReportService,
|
ReportService,
|
||||||
SalaryConfigService,
|
SalaryConfigService,
|
||||||
SalaryProfileService,
|
SalaryProfileService,
|
||||||
@ -58,6 +62,14 @@ def get_payroll_repository(session: Session = Depends(get_db_session)) -> Payrol
|
|||||||
return PayrollRepository(session)
|
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:
|
def get_commission_repository(session: Session = Depends(get_db_session)) -> CommissionRepository:
|
||||||
return CommissionRepository(session)
|
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(
|
def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
repository: UserRepository = Depends(get_user_repository),
|
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,
|
PayrollJobResponse,
|
||||||
PayrollResultDTO,
|
PayrollResultDTO,
|
||||||
)
|
)
|
||||||
|
from .monthly_payroll import (
|
||||||
|
MonthlyPayrollCreateRequest,
|
||||||
|
MonthlyPayrollDetailResponse,
|
||||||
|
MonthlyPayrollRunResponse,
|
||||||
|
PayrollExceptionItem,
|
||||||
|
)
|
||||||
|
from .realtime_attendance import (
|
||||||
|
RealtimeAttendanceResponse,
|
||||||
|
RealtimeAttendanceSummary,
|
||||||
|
RealtimeAttendanceSyncRequest,
|
||||||
|
TodayAttendanceItem,
|
||||||
|
)
|
||||||
from .operation_log import OperationLogDTO, OperationLogListResponse
|
from .operation_log import OperationLogDTO, OperationLogListResponse
|
||||||
from .employee import EmployeeListResponse, EmployeeNoResponse, EmployeeRequest, EmployeeResponse, EmployeeUpdateRequest
|
from .employee import EmployeeListResponse, EmployeeNoResponse, EmployeeRequest, EmployeeResponse, EmployeeUpdateRequest
|
||||||
from .salary_profile import SalaryProfileRequest, SalaryProfileResponse
|
from .salary_profile import SalaryProfileRequest, SalaryProfileResponse
|
||||||
@ -32,12 +44,20 @@ __all__ = [
|
|||||||
"ExcelPayrollResponse",
|
"ExcelPayrollResponse",
|
||||||
"LoginRequest",
|
"LoginRequest",
|
||||||
"LoginResponse",
|
"LoginResponse",
|
||||||
|
"MonthlyPayrollCreateRequest",
|
||||||
|
"MonthlyPayrollDetailResponse",
|
||||||
|
"MonthlyPayrollRunResponse",
|
||||||
"OperationLogDTO",
|
"OperationLogDTO",
|
||||||
"OperationLogListResponse",
|
"OperationLogListResponse",
|
||||||
"PayrollJobResponse",
|
"PayrollJobResponse",
|
||||||
|
"PayrollExceptionItem",
|
||||||
"PayrollResultDTO",
|
"PayrollResultDTO",
|
||||||
|
"RealtimeAttendanceResponse",
|
||||||
|
"RealtimeAttendanceSummary",
|
||||||
|
"RealtimeAttendanceSyncRequest",
|
||||||
"SalaryProfileRequest",
|
"SalaryProfileRequest",
|
||||||
"SalaryProfileResponse",
|
"SalaryProfileResponse",
|
||||||
|
"TodayAttendanceItem",
|
||||||
"UserProfileDTO",
|
"UserProfileDTO",
|
||||||
"UserResponse",
|
"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]
|
||||||
@ -12,6 +12,12 @@ PERMISSION_PAYROLL_EXCEL_CALCULATE = "payroll:excel:calculate"
|
|||||||
PERMISSION_PAYROLL_DINGTALK_CALCULATE = "payroll:dingtalk:calculate"
|
PERMISSION_PAYROLL_DINGTALK_CALCULATE = "payroll:dingtalk:calculate"
|
||||||
PERMISSION_PAYROLL_JOB_VIEW = "payroll:job:view"
|
PERMISSION_PAYROLL_JOB_VIEW = "payroll:job:view"
|
||||||
PERMISSION_PAYROLL_FILE_DOWNLOAD = "payroll:file:download"
|
PERMISSION_PAYROLL_FILE_DOWNLOAD = "payroll:file:download"
|
||||||
|
PERMISSION_ATTENDANCE_REALTIME_VIEW = "attendance:realtime:view"
|
||||||
|
PERMISSION_ATTENDANCE_REALTIME_SYNC = "attendance:realtime:sync"
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_VIEW = "monthly_payroll:view"
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_CALCULATE = "monthly_payroll:calculate"
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_LOCK = "monthly_payroll:lock"
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_EXPORT = "monthly_payroll:export"
|
||||||
PERMISSION_USER_CREATE = "user:create"
|
PERMISSION_USER_CREATE = "user:create"
|
||||||
PERMISSION_USER_LIST = "user:list"
|
PERMISSION_USER_LIST = "user:list"
|
||||||
PERMISSION_OPERATION_LOG_VIEW = "operation_log:view"
|
PERMISSION_OPERATION_LOG_VIEW = "operation_log:view"
|
||||||
@ -38,6 +44,8 @@ class MenuPermission:
|
|||||||
|
|
||||||
|
|
||||||
MENUS = {
|
MENUS = {
|
||||||
|
"attendance_realtime": MenuPermission(code="attendance_realtime", name="实时考勤", path="/attendance/realtime"),
|
||||||
|
"monthly_payroll": MenuPermission(code="monthly_payroll", name="月度核算", path="/payroll/monthly"),
|
||||||
"payroll": MenuPermission(code="payroll", name="工资计算", path="/payroll"),
|
"payroll": MenuPermission(code="payroll", name="工资计算", path="/payroll"),
|
||||||
"jobs": MenuPermission(code="jobs", name="计算记录", path="/payroll/jobs"),
|
"jobs": MenuPermission(code="jobs", name="计算记录", path="/payroll/jobs"),
|
||||||
"employees": MenuPermission(code="employees", name="员工维护", path="/system/employees"),
|
"employees": MenuPermission(code="employees", name="员工维护", path="/system/employees"),
|
||||||
@ -52,6 +60,8 @@ MENUS = {
|
|||||||
|
|
||||||
ROLE_MENU_CODES = {
|
ROLE_MENU_CODES = {
|
||||||
ROLE_SUPERUSER: (
|
ROLE_SUPERUSER: (
|
||||||
|
"attendance_realtime",
|
||||||
|
"monthly_payroll",
|
||||||
"payroll",
|
"payroll",
|
||||||
"jobs",
|
"jobs",
|
||||||
"employees",
|
"employees",
|
||||||
@ -64,6 +74,8 @@ ROLE_MENU_CODES = {
|
|||||||
"operation_logs",
|
"operation_logs",
|
||||||
),
|
),
|
||||||
ROLE_MANAGER: (
|
ROLE_MANAGER: (
|
||||||
|
"attendance_realtime",
|
||||||
|
"monthly_payroll",
|
||||||
"payroll",
|
"payroll",
|
||||||
"jobs",
|
"jobs",
|
||||||
"employees",
|
"employees",
|
||||||
@ -74,11 +86,17 @@ ROLE_MENU_CODES = {
|
|||||||
"configs",
|
"configs",
|
||||||
"operation_logs",
|
"operation_logs",
|
||||||
),
|
),
|
||||||
ROLE_VIEWER: ("jobs", "reports"),
|
ROLE_VIEWER: ("attendance_realtime", "jobs", "reports"),
|
||||||
}
|
}
|
||||||
|
|
||||||
ROLE_OPERATION_PERMISSIONS = {
|
ROLE_OPERATION_PERMISSIONS = {
|
||||||
ROLE_SUPERUSER: (
|
ROLE_SUPERUSER: (
|
||||||
|
PERMISSION_ATTENDANCE_REALTIME_VIEW,
|
||||||
|
PERMISSION_ATTENDANCE_REALTIME_SYNC,
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_VIEW,
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_CALCULATE,
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_LOCK,
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_EXPORT,
|
||||||
PERMISSION_PAYROLL_EXCEL_CALCULATE,
|
PERMISSION_PAYROLL_EXCEL_CALCULATE,
|
||||||
PERMISSION_PAYROLL_DINGTALK_CALCULATE,
|
PERMISSION_PAYROLL_DINGTALK_CALCULATE,
|
||||||
PERMISSION_PAYROLL_JOB_VIEW,
|
PERMISSION_PAYROLL_JOB_VIEW,
|
||||||
@ -99,6 +117,12 @@ ROLE_OPERATION_PERMISSIONS = {
|
|||||||
PERMISSION_REPORT_VIEW,
|
PERMISSION_REPORT_VIEW,
|
||||||
),
|
),
|
||||||
ROLE_MANAGER: (
|
ROLE_MANAGER: (
|
||||||
|
PERMISSION_ATTENDANCE_REALTIME_VIEW,
|
||||||
|
PERMISSION_ATTENDANCE_REALTIME_SYNC,
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_VIEW,
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_CALCULATE,
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_LOCK,
|
||||||
|
PERMISSION_MONTHLY_PAYROLL_EXPORT,
|
||||||
PERMISSION_PAYROLL_EXCEL_CALCULATE,
|
PERMISSION_PAYROLL_EXCEL_CALCULATE,
|
||||||
PERMISSION_PAYROLL_DINGTALK_CALCULATE,
|
PERMISSION_PAYROLL_DINGTALK_CALCULATE,
|
||||||
PERMISSION_PAYROLL_JOB_VIEW,
|
PERMISSION_PAYROLL_JOB_VIEW,
|
||||||
@ -117,6 +141,7 @@ ROLE_OPERATION_PERMISSIONS = {
|
|||||||
PERMISSION_REPORT_VIEW,
|
PERMISSION_REPORT_VIEW,
|
||||||
),
|
),
|
||||||
ROLE_VIEWER: (
|
ROLE_VIEWER: (
|
||||||
|
PERMISSION_ATTENDANCE_REALTIME_VIEW,
|
||||||
PERMISSION_PAYROLL_JOB_VIEW,
|
PERMISSION_PAYROLL_JOB_VIEW,
|
||||||
PERMISSION_PAYROLL_FILE_DOWNLOAD,
|
PERMISSION_PAYROLL_FILE_DOWNLOAD,
|
||||||
PERMISSION_REPORT_VIEW,
|
PERMISSION_REPORT_VIEW,
|
||||||
|
|||||||
@ -441,6 +441,172 @@ class OvertimeRecordORM(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
|
||||||
|
|
||||||
|
|
||||||
|
class AttendanceSyncJobORM(Base):
|
||||||
|
"""钉钉或 Excel 考勤同步批次,用于实时看板和月度核算追踪。"""
|
||||||
|
|
||||||
|
__tablename__ = "attendance_sync_jobs"
|
||||||
|
__table_args__ = {
|
||||||
|
"comment": "考勤同步任务表,记录钉钉实时同步或Excel导入的批次状态",
|
||||||
|
"mysql_charset": "utf8mb4",
|
||||||
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
|
}
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="同步任务ID,UUID十六进制字符串")
|
||||||
|
source_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="数据来源:dingtalk钉钉、excel表格")
|
||||||
|
sync_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="today", index=True, comment="同步模式:today今日、month本月至今、range自定义范围")
|
||||||
|
attendance_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True, comment="实时看板对应考勤日期")
|
||||||
|
salary_month: Mapped[str] = mapped_column(String(7), nullable=False, default="", index=True, comment="工资月份,格式YYYY-MM")
|
||||||
|
start_date: Mapped[date | None] = mapped_column(Date, nullable=True, comment="同步开始日期")
|
||||||
|
end_date: Mapped[date | None] = mapped_column(Date, nullable=True, comment="同步结束日期")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="created", index=True, comment="任务状态:created、running、completed、failed")
|
||||||
|
employee_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="本次同步涉及员工数量")
|
||||||
|
record_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="本次同步获得或解析的考勤记录数量")
|
||||||
|
error_message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="任务失败时的错误信息")
|
||||||
|
created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="创建人用户名快照")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
default=datetime.utcnow,
|
||||||
|
onupdate=datetime.utcnow,
|
||||||
|
comment="更新时间",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SalaryPreviewORM(Base):
|
||||||
|
"""本月进行中的薪资预估结果,不作为最终工资发放依据。"""
|
||||||
|
|
||||||
|
__tablename__ = "salary_preview"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("sync_job_id", "employee_id", name="uq_salary_preview_sync_employee"),
|
||||||
|
{
|
||||||
|
"comment": "实时薪资预估表,保存当前同步批次下的员工级薪资预估",
|
||||||
|
"mysql_charset": "utf8mb4",
|
||||||
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="薪资预估主键ID")
|
||||||
|
sync_job_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("attendance_sync_jobs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="关联考勤同步任务ID",
|
||||||
|
)
|
||||||
|
employee_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("employees.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="关联员工ID,未匹配员工时为空",
|
||||||
|
)
|
||||||
|
employee_no: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True, comment="员工编号快照")
|
||||||
|
employee_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="员工姓名快照")
|
||||||
|
department: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="部门名称快照")
|
||||||
|
position: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="岗位名称快照")
|
||||||
|
salary_month: Mapped[str] = mapped_column(String(7), nullable=False, default="", index=True, comment="工资月份,格式YYYY-MM")
|
||||||
|
attendance_days: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="本月当前出勤天数")
|
||||||
|
actual_work_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="本月当前实际工时")
|
||||||
|
punch_overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="打卡推算加班工时")
|
||||||
|
leave_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="请假或调休工时")
|
||||||
|
remaining_overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="剩余加班工时")
|
||||||
|
late_deduction: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当前迟到扣款")
|
||||||
|
missing_card_deduction: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当前缺卡扣款")
|
||||||
|
commission_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当前已录入提成")
|
||||||
|
overtime_pay: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当前加班费")
|
||||||
|
estimated_gross_salary: Mapped[float | None] = mapped_column(Float, nullable=True, comment="当前预估应发工资")
|
||||||
|
estimated_net_salary: Mapped[float | None] = mapped_column(Float, nullable=True, comment="当前预估实发工资")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="estimated", index=True, comment="预估状态:estimated已预估、warning有异常")
|
||||||
|
note: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="预估备注或异常说明")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
default=datetime.utcnow,
|
||||||
|
onupdate=datetime.utcnow,
|
||||||
|
comment="更新时间",
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_job: Mapped[AttendanceSyncJobORM] = relationship()
|
||||||
|
employee: Mapped[EmployeeORM | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlyPayrollRunORM(Base):
|
||||||
|
"""月度正式核算批次,支持复核、锁定和导出。"""
|
||||||
|
|
||||||
|
__tablename__ = "monthly_payroll_runs"
|
||||||
|
__table_args__ = {
|
||||||
|
"comment": "月度工资核算批次表,记录每月工资计算、复核和锁定状态",
|
||||||
|
"mysql_charset": "utf8mb4",
|
||||||
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
|
}
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="月度核算批次主键ID")
|
||||||
|
salary_month: Mapped[str] = mapped_column(String(7), nullable=False, index=True, comment="工资月份,格式YYYY-MM")
|
||||||
|
source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="数据来源:excel或dingtalk")
|
||||||
|
source_job_id: Mapped[str] = mapped_column(String(32), nullable=False, default="", index=True, comment="来源任务ID,可关联工资任务或同步任务")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft", index=True, comment="核算状态:draft、calculated、reviewed、locked、failed")
|
||||||
|
employee_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="参与核算员工数量")
|
||||||
|
gross_total: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="应发工资合计")
|
||||||
|
net_total: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="实发工资合计")
|
||||||
|
deduction_total: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="扣款合计")
|
||||||
|
overtime_total: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="剩余加班工时合计")
|
||||||
|
locked_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="锁定人用户名快照")
|
||||||
|
locked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="锁定时间")
|
||||||
|
created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="创建人用户名快照")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
default=datetime.utcnow,
|
||||||
|
onupdate=datetime.utcnow,
|
||||||
|
comment="更新时间",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollExceptionORM(Base):
|
||||||
|
"""核算异常记录,例如缺员工、缺薪资档案、缺卡、请假无法识别。"""
|
||||||
|
|
||||||
|
__tablename__ = "payroll_exceptions"
|
||||||
|
__table_args__ = {
|
||||||
|
"comment": "工资核算异常表,保存月度核算或实时同步过程中发现的待处理问题",
|
||||||
|
"mysql_charset": "utf8mb4",
|
||||||
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
|
}
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="异常记录主键ID")
|
||||||
|
run_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("monthly_payroll_runs.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="关联月度核算批次ID",
|
||||||
|
)
|
||||||
|
sync_job_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("attendance_sync_jobs.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="关联考勤同步任务ID",
|
||||||
|
)
|
||||||
|
employee_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("employees.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="关联员工ID,未匹配员工时为空",
|
||||||
|
)
|
||||||
|
employee_no: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True, comment="员工编号快照")
|
||||||
|
employee_name: Mapped[str] = mapped_column(String(128), nullable=False, default="", index=True, comment="员工姓名快照")
|
||||||
|
salary_month: Mapped[str] = mapped_column(String(7), nullable=False, default="", index=True, comment="工资月份,格式YYYY-MM")
|
||||||
|
exception_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="异常类型编码")
|
||||||
|
severity: Mapped[str] = mapped_column(String(32), nullable=False, default="warning", index=True, comment="严重级别:info、warning、error")
|
||||||
|
message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="异常说明")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="open", index=True, comment="处理状态:open待处理、resolved已处理、ignored已忽略")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="处理完成时间")
|
||||||
|
|
||||||
|
run: Mapped[MonthlyPayrollRunORM | None] = relationship()
|
||||||
|
sync_job: Mapped[AttendanceSyncJobORM | None] = relationship()
|
||||||
|
employee: Mapped[EmployeeORM | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
class CommissionRecordORM(Base):
|
class CommissionRecordORM(Base):
|
||||||
"""月度提成或奖金记录,支持手工维护和 Excel 导入。"""
|
"""月度提成或奖金记录,支持手工维护和 Excel 导入。"""
|
||||||
|
|
||||||
|
|||||||
@ -474,6 +474,125 @@ CREATE TABLE IF NOT EXISTS `overtime_record` (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额';
|
COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `attendance_sync_jobs` (
|
||||||
|
`id` VARCHAR(32) NOT NULL COMMENT '同步任务ID,UUID十六进制字符串',
|
||||||
|
`source_type` VARCHAR(32) NOT NULL COMMENT '数据来源:dingtalk钉钉、excel表格',
|
||||||
|
`sync_mode` VARCHAR(32) NOT NULL DEFAULT 'today' COMMENT '同步模式:today今日、month本月至今、range自定义范围',
|
||||||
|
`attendance_date` DATE NULL COMMENT '实时看板对应考勤日期',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`start_date` DATE NULL COMMENT '同步开始日期',
|
||||||
|
`end_date` DATE NULL COMMENT '同步结束日期',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '任务状态:created、running、completed、failed',
|
||||||
|
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步涉及员工数量',
|
||||||
|
`record_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步获得或解析的考勤记录数量',
|
||||||
|
`error_message` TEXT NOT NULL COMMENT '任务失败时的错误信息',
|
||||||
|
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `ix_attendance_sync_jobs_source_type` (`source_type`),
|
||||||
|
KEY `ix_attendance_sync_jobs_sync_mode` (`sync_mode`),
|
||||||
|
KEY `ix_attendance_sync_jobs_attendance_date` (`attendance_date`),
|
||||||
|
KEY `ix_attendance_sync_jobs_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_attendance_sync_jobs_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='考勤同步任务表,记录钉钉实时同步或Excel导入的批次状态';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `salary_preview` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资预估主键ID',
|
||||||
|
`sync_job_id` VARCHAR(32) NOT NULL COMMENT '关联考勤同步任务ID',
|
||||||
|
`employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空',
|
||||||
|
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
|
||||||
|
`employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照',
|
||||||
|
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
|
||||||
|
`position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称快照',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前出勤天数',
|
||||||
|
`actual_work_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前实际工时',
|
||||||
|
`punch_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '打卡推算加班工时',
|
||||||
|
`leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假或调休工时',
|
||||||
|
`remaining_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时',
|
||||||
|
`late_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前迟到扣款',
|
||||||
|
`missing_card_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前缺卡扣款',
|
||||||
|
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '当前已录入提成',
|
||||||
|
`overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '当前加班费',
|
||||||
|
`estimated_gross_salary` FLOAT NULL COMMENT '当前预估应发工资',
|
||||||
|
`estimated_net_salary` FLOAT NULL COMMENT '当前预估实发工资',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'estimated' COMMENT '预估状态:estimated已预估、warning有异常',
|
||||||
|
`note` TEXT NOT NULL COMMENT '预估备注或异常说明',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_salary_preview_sync_employee` (`sync_job_id`, `employee_id`),
|
||||||
|
KEY `ix_salary_preview_sync_job_id` (`sync_job_id`),
|
||||||
|
KEY `ix_salary_preview_employee_id` (`employee_id`),
|
||||||
|
KEY `ix_salary_preview_employee_no` (`employee_no`),
|
||||||
|
KEY `ix_salary_preview_employee_name` (`employee_name`),
|
||||||
|
KEY `ix_salary_preview_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_salary_preview_status` (`status`),
|
||||||
|
CONSTRAINT `fk_salary_preview_sync_job_id`
|
||||||
|
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_salary_preview_employee_id`
|
||||||
|
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='实时薪资预估表,保存当前同步批次下的员工级薪资预估';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `monthly_payroll_runs` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '月度核算批次主键ID',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '数据来源:excel或dingtalk',
|
||||||
|
`source_job_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源任务ID,可关联工资任务或同步任务',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '核算状态:draft、calculated、reviewed、locked、failed',
|
||||||
|
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '参与核算员工数量',
|
||||||
|
`gross_total` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资合计',
|
||||||
|
`net_total` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资合计',
|
||||||
|
`deduction_total` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计',
|
||||||
|
`overtime_total` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时合计',
|
||||||
|
`locked_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '锁定人用户名快照',
|
||||||
|
`locked_at` DATETIME NULL COMMENT '锁定时间',
|
||||||
|
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `ix_monthly_payroll_runs_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_monthly_payroll_runs_source_job_id` (`source_job_id`),
|
||||||
|
KEY `ix_monthly_payroll_runs_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='月度工资核算批次表,记录每月工资计算、复核和锁定状态';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `payroll_exceptions` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '异常记录主键ID',
|
||||||
|
`run_id` INT NULL COMMENT '关联月度核算批次ID',
|
||||||
|
`sync_job_id` VARCHAR(32) NULL COMMENT '关联考勤同步任务ID',
|
||||||
|
`employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空',
|
||||||
|
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
|
||||||
|
`employee_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`exception_type` VARCHAR(64) NOT NULL COMMENT '异常类型编码',
|
||||||
|
`severity` VARCHAR(32) NOT NULL DEFAULT 'warning' COMMENT '严重级别:info、warning、error',
|
||||||
|
`message` TEXT NOT NULL COMMENT '异常说明',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT '处理状态:open待处理、resolved已处理、ignored已忽略',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`resolved_at` DATETIME NULL COMMENT '处理完成时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `ix_payroll_exceptions_run_id` (`run_id`),
|
||||||
|
KEY `ix_payroll_exceptions_sync_job_id` (`sync_job_id`),
|
||||||
|
KEY `ix_payroll_exceptions_employee_id` (`employee_id`),
|
||||||
|
KEY `ix_payroll_exceptions_employee_no` (`employee_no`),
|
||||||
|
KEY `ix_payroll_exceptions_employee_name` (`employee_name`),
|
||||||
|
KEY `ix_payroll_exceptions_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_payroll_exceptions_exception_type` (`exception_type`),
|
||||||
|
KEY `ix_payroll_exceptions_severity` (`severity`),
|
||||||
|
KEY `ix_payroll_exceptions_status` (`status`),
|
||||||
|
CONSTRAINT `fk_payroll_exceptions_run_id`
|
||||||
|
FOREIGN KEY (`run_id`) REFERENCES `monthly_payroll_runs` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_payroll_exceptions_sync_job_id`
|
||||||
|
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_payroll_exceptions_employee_id`
|
||||||
|
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='工资核算异常表,保存月度核算或实时同步过程中发现的待处理问题';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `commission_record` (
|
CREATE TABLE IF NOT EXISTS `commission_record` (
|
||||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID',
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID',
|
||||||
`employee_id` INT NOT NULL COMMENT '关联员工ID',
|
`employee_id` INT NOT NULL COMMENT '关联员工ID',
|
||||||
|
|||||||
@ -384,6 +384,125 @@ CREATE TABLE IF NOT EXISTS `overtime_record` (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额';
|
COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `attendance_sync_jobs` (
|
||||||
|
`id` VARCHAR(32) NOT NULL COMMENT '同步任务ID,UUID十六进制字符串',
|
||||||
|
`source_type` VARCHAR(32) NOT NULL COMMENT '数据来源:dingtalk钉钉、excel表格',
|
||||||
|
`sync_mode` VARCHAR(32) NOT NULL DEFAULT 'today' COMMENT '同步模式:today今日、month本月至今、range自定义范围',
|
||||||
|
`attendance_date` DATE NULL COMMENT '实时看板对应考勤日期',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`start_date` DATE NULL COMMENT '同步开始日期',
|
||||||
|
`end_date` DATE NULL COMMENT '同步结束日期',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '任务状态:created、running、completed、failed',
|
||||||
|
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步涉及员工数量',
|
||||||
|
`record_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步获得或解析的考勤记录数量',
|
||||||
|
`error_message` TEXT NOT NULL COMMENT '任务失败时的错误信息',
|
||||||
|
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `ix_attendance_sync_jobs_source_type` (`source_type`),
|
||||||
|
KEY `ix_attendance_sync_jobs_sync_mode` (`sync_mode`),
|
||||||
|
KEY `ix_attendance_sync_jobs_attendance_date` (`attendance_date`),
|
||||||
|
KEY `ix_attendance_sync_jobs_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_attendance_sync_jobs_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='考勤同步任务表,记录钉钉实时同步或Excel导入的批次状态';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `salary_preview` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资预估主键ID',
|
||||||
|
`sync_job_id` VARCHAR(32) NOT NULL COMMENT '关联考勤同步任务ID',
|
||||||
|
`employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空',
|
||||||
|
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
|
||||||
|
`employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照',
|
||||||
|
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
|
||||||
|
`position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称快照',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前出勤天数',
|
||||||
|
`actual_work_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前实际工时',
|
||||||
|
`punch_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '打卡推算加班工时',
|
||||||
|
`leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假或调休工时',
|
||||||
|
`remaining_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时',
|
||||||
|
`late_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前迟到扣款',
|
||||||
|
`missing_card_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前缺卡扣款',
|
||||||
|
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '当前已录入提成',
|
||||||
|
`overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '当前加班费',
|
||||||
|
`estimated_gross_salary` FLOAT NULL COMMENT '当前预估应发工资',
|
||||||
|
`estimated_net_salary` FLOAT NULL COMMENT '当前预估实发工资',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'estimated' COMMENT '预估状态:estimated已预估、warning有异常',
|
||||||
|
`note` TEXT NOT NULL COMMENT '预估备注或异常说明',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_salary_preview_sync_employee` (`sync_job_id`, `employee_id`),
|
||||||
|
KEY `ix_salary_preview_sync_job_id` (`sync_job_id`),
|
||||||
|
KEY `ix_salary_preview_employee_id` (`employee_id`),
|
||||||
|
KEY `ix_salary_preview_employee_no` (`employee_no`),
|
||||||
|
KEY `ix_salary_preview_employee_name` (`employee_name`),
|
||||||
|
KEY `ix_salary_preview_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_salary_preview_status` (`status`),
|
||||||
|
CONSTRAINT `fk_salary_preview_sync_job_id`
|
||||||
|
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_salary_preview_employee_id`
|
||||||
|
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='实时薪资预估表,保存当前同步批次下的员工级薪资预估';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `monthly_payroll_runs` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '月度核算批次主键ID',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '数据来源:excel或dingtalk',
|
||||||
|
`source_job_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源任务ID,可关联工资任务或同步任务',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '核算状态:draft、calculated、reviewed、locked、failed',
|
||||||
|
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '参与核算员工数量',
|
||||||
|
`gross_total` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资合计',
|
||||||
|
`net_total` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资合计',
|
||||||
|
`deduction_total` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计',
|
||||||
|
`overtime_total` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时合计',
|
||||||
|
`locked_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '锁定人用户名快照',
|
||||||
|
`locked_at` DATETIME NULL COMMENT '锁定时间',
|
||||||
|
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `ix_monthly_payroll_runs_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_monthly_payroll_runs_source_job_id` (`source_job_id`),
|
||||||
|
KEY `ix_monthly_payroll_runs_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='月度工资核算批次表,记录每月工资计算、复核和锁定状态';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `payroll_exceptions` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '异常记录主键ID',
|
||||||
|
`run_id` INT NULL COMMENT '关联月度核算批次ID',
|
||||||
|
`sync_job_id` VARCHAR(32) NULL COMMENT '关联考勤同步任务ID',
|
||||||
|
`employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空',
|
||||||
|
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
|
||||||
|
`employee_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`exception_type` VARCHAR(64) NOT NULL COMMENT '异常类型编码',
|
||||||
|
`severity` VARCHAR(32) NOT NULL DEFAULT 'warning' COMMENT '严重级别:info、warning、error',
|
||||||
|
`message` TEXT NOT NULL COMMENT '异常说明',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT '处理状态:open待处理、resolved已处理、ignored已忽略',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`resolved_at` DATETIME NULL COMMENT '处理完成时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `ix_payroll_exceptions_run_id` (`run_id`),
|
||||||
|
KEY `ix_payroll_exceptions_sync_job_id` (`sync_job_id`),
|
||||||
|
KEY `ix_payroll_exceptions_employee_id` (`employee_id`),
|
||||||
|
KEY `ix_payroll_exceptions_employee_no` (`employee_no`),
|
||||||
|
KEY `ix_payroll_exceptions_employee_name` (`employee_name`),
|
||||||
|
KEY `ix_payroll_exceptions_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_payroll_exceptions_exception_type` (`exception_type`),
|
||||||
|
KEY `ix_payroll_exceptions_severity` (`severity`),
|
||||||
|
KEY `ix_payroll_exceptions_status` (`status`),
|
||||||
|
CONSTRAINT `fk_payroll_exceptions_run_id`
|
||||||
|
FOREIGN KEY (`run_id`) REFERENCES `monthly_payroll_runs` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_payroll_exceptions_sync_job_id`
|
||||||
|
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_payroll_exceptions_employee_id`
|
||||||
|
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='工资核算异常表,保存月度核算或实时同步过程中发现的待处理问题';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `commission_record` (
|
CREATE TABLE IF NOT EXISTS `commission_record` (
|
||||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID',
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID',
|
||||||
`employee_id` INT NOT NULL COMMENT '关联员工ID',
|
`employee_id` INT NOT NULL COMMENT '关联员工ID',
|
||||||
|
|||||||
@ -0,0 +1,124 @@
|
|||||||
|
-- 2026-06-18 月度核算中心与实时考勤看板升级脚本。
|
||||||
|
-- Execute with:
|
||||||
|
-- mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260618_monthly_payroll_center.sql
|
||||||
|
|
||||||
|
USE `financial_system`;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `attendance_sync_jobs` (
|
||||||
|
`id` VARCHAR(32) NOT NULL COMMENT '同步任务ID,UUID十六进制字符串',
|
||||||
|
`source_type` VARCHAR(32) NOT NULL COMMENT '数据来源:dingtalk钉钉、excel表格',
|
||||||
|
`sync_mode` VARCHAR(32) NOT NULL DEFAULT 'today' COMMENT '同步模式:today今日、month本月至今、range自定义范围',
|
||||||
|
`attendance_date` DATE NULL COMMENT '实时看板对应考勤日期',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`start_date` DATE NULL COMMENT '同步开始日期',
|
||||||
|
`end_date` DATE NULL COMMENT '同步结束日期',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '任务状态:created、running、completed、failed',
|
||||||
|
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步涉及员工数量',
|
||||||
|
`record_count` INT NOT NULL DEFAULT 0 COMMENT '本次同步获得或解析的考勤记录数量',
|
||||||
|
`error_message` TEXT NOT NULL COMMENT '任务失败时的错误信息',
|
||||||
|
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `ix_attendance_sync_jobs_source_type` (`source_type`),
|
||||||
|
KEY `ix_attendance_sync_jobs_sync_mode` (`sync_mode`),
|
||||||
|
KEY `ix_attendance_sync_jobs_attendance_date` (`attendance_date`),
|
||||||
|
KEY `ix_attendance_sync_jobs_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_attendance_sync_jobs_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='考勤同步任务表,记录钉钉实时同步或Excel导入的批次状态';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `salary_preview` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资预估主键ID',
|
||||||
|
`sync_job_id` VARCHAR(32) NOT NULL COMMENT '关联考勤同步任务ID',
|
||||||
|
`employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空',
|
||||||
|
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
|
||||||
|
`employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照',
|
||||||
|
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
|
||||||
|
`position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称快照',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前出勤天数',
|
||||||
|
`actual_work_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '本月当前实际工时',
|
||||||
|
`punch_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '打卡推算加班工时',
|
||||||
|
`leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假或调休工时',
|
||||||
|
`remaining_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时',
|
||||||
|
`late_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前迟到扣款',
|
||||||
|
`missing_card_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '当前缺卡扣款',
|
||||||
|
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '当前已录入提成',
|
||||||
|
`overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '当前加班费',
|
||||||
|
`estimated_gross_salary` FLOAT NULL COMMENT '当前预估应发工资',
|
||||||
|
`estimated_net_salary` FLOAT NULL COMMENT '当前预估实发工资',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'estimated' COMMENT '预估状态:estimated已预估、warning有异常',
|
||||||
|
`note` TEXT NOT NULL COMMENT '预估备注或异常说明',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_salary_preview_sync_employee` (`sync_job_id`, `employee_id`),
|
||||||
|
KEY `ix_salary_preview_sync_job_id` (`sync_job_id`),
|
||||||
|
KEY `ix_salary_preview_employee_id` (`employee_id`),
|
||||||
|
KEY `ix_salary_preview_employee_no` (`employee_no`),
|
||||||
|
KEY `ix_salary_preview_employee_name` (`employee_name`),
|
||||||
|
KEY `ix_salary_preview_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_salary_preview_status` (`status`),
|
||||||
|
CONSTRAINT `fk_salary_preview_sync_job_id`
|
||||||
|
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_salary_preview_employee_id`
|
||||||
|
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='实时薪资预估表,保存当前同步批次下的员工级薪资预估';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `monthly_payroll_runs` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '月度核算批次主键ID',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '数据来源:excel或dingtalk',
|
||||||
|
`source_job_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源任务ID,可关联工资任务或同步任务',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '核算状态:draft、calculated、reviewed、locked、failed',
|
||||||
|
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '参与核算员工数量',
|
||||||
|
`gross_total` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资合计',
|
||||||
|
`net_total` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资合计',
|
||||||
|
`deduction_total` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计',
|
||||||
|
`overtime_total` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时合计',
|
||||||
|
`locked_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '锁定人用户名快照',
|
||||||
|
`locked_at` DATETIME NULL COMMENT '锁定时间',
|
||||||
|
`created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人用户名快照',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `ix_monthly_payroll_runs_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_monthly_payroll_runs_source_job_id` (`source_job_id`),
|
||||||
|
KEY `ix_monthly_payroll_runs_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='月度工资核算批次表,记录每月工资计算、复核和锁定状态';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `payroll_exceptions` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '异常记录主键ID',
|
||||||
|
`run_id` INT NULL COMMENT '关联月度核算批次ID',
|
||||||
|
`sync_job_id` VARCHAR(32) NULL COMMENT '关联考勤同步任务ID',
|
||||||
|
`employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空',
|
||||||
|
`employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照',
|
||||||
|
`employee_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
|
||||||
|
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM',
|
||||||
|
`exception_type` VARCHAR(64) NOT NULL COMMENT '异常类型编码',
|
||||||
|
`severity` VARCHAR(32) NOT NULL DEFAULT 'warning' COMMENT '严重级别:info、warning、error',
|
||||||
|
`message` TEXT NOT NULL COMMENT '异常说明',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT '处理状态:open待处理、resolved已处理、ignored已忽略',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`resolved_at` DATETIME NULL COMMENT '处理完成时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `ix_payroll_exceptions_run_id` (`run_id`),
|
||||||
|
KEY `ix_payroll_exceptions_sync_job_id` (`sync_job_id`),
|
||||||
|
KEY `ix_payroll_exceptions_employee_id` (`employee_id`),
|
||||||
|
KEY `ix_payroll_exceptions_employee_no` (`employee_no`),
|
||||||
|
KEY `ix_payroll_exceptions_employee_name` (`employee_name`),
|
||||||
|
KEY `ix_payroll_exceptions_salary_month` (`salary_month`),
|
||||||
|
KEY `ix_payroll_exceptions_exception_type` (`exception_type`),
|
||||||
|
KEY `ix_payroll_exceptions_severity` (`severity`),
|
||||||
|
KEY `ix_payroll_exceptions_status` (`status`),
|
||||||
|
CONSTRAINT `fk_payroll_exceptions_run_id`
|
||||||
|
FOREIGN KEY (`run_id`) REFERENCES `monthly_payroll_runs` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_payroll_exceptions_sync_job_id`
|
||||||
|
FOREIGN KEY (`sync_job_id`) REFERENCES `attendance_sync_jobs` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_payroll_exceptions_employee_id`
|
||||||
|
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='工资核算异常表,保存月度核算或实时同步过程中发现的待处理问题';
|
||||||
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 .employee_repository import EmployeeRepository
|
||||||
from .operation_log_repository import OperationLogRepository
|
from .operation_log_repository import OperationLogRepository
|
||||||
from .organization_repository import OrganizationRepository
|
from .organization_repository import OrganizationRepository
|
||||||
|
from .monthly_payroll_repository import MonthlyPayrollRepository
|
||||||
from .payroll_repository import PayrollRepository
|
from .payroll_repository import PayrollRepository
|
||||||
|
from .realtime_attendance_repository import RealtimeAttendanceRepository
|
||||||
from .report_repository import ReportRepository
|
from .report_repository import ReportRepository
|
||||||
from .salary_profile_repository import SalaryProfileRepository
|
from .salary_profile_repository import SalaryProfileRepository
|
||||||
from .salary_config_repository import SalaryConfigRepository
|
from .salary_config_repository import SalaryConfigRepository
|
||||||
@ -13,7 +15,9 @@ __all__ = [
|
|||||||
"EmployeeRepository",
|
"EmployeeRepository",
|
||||||
"OperationLogRepository",
|
"OperationLogRepository",
|
||||||
"OrganizationRepository",
|
"OrganizationRepository",
|
||||||
|
"MonthlyPayrollRepository",
|
||||||
"PayrollRepository",
|
"PayrollRepository",
|
||||||
|
"RealtimeAttendanceRepository",
|
||||||
"ReportRepository",
|
"ReportRepository",
|
||||||
"SalaryConfigRepository",
|
"SalaryConfigRepository",
|
||||||
"SalaryProfileRepository",
|
"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 .auth_service import AuthService, AuthSession
|
||||||
from .commission_service import CommissionPage, CommissionService
|
from .commission_service import CommissionPage, CommissionService
|
||||||
from .employee_service import EmployeePage, EmployeeService
|
from .employee_service import EmployeePage, EmployeeService
|
||||||
|
from .monthly_payroll_service import MonthlyPayrollDetailData, MonthlyPayrollService
|
||||||
from .operation_log_service import OperationLogPage, OperationLogService
|
from .operation_log_service import OperationLogPage, OperationLogService
|
||||||
from .organization_service import OrganizationService
|
from .organization_service import OrganizationService
|
||||||
from .payroll_service import PayrollApplicationService, PayrollComputation, PayrollService
|
from .payroll_service import PayrollApplicationService, PayrollComputation, PayrollService
|
||||||
|
from .realtime_attendance_service import RealtimeAttendanceService
|
||||||
from .report_service import PayrollReport, ReportService
|
from .report_service import PayrollReport, ReportService
|
||||||
from .salary_config_service import SalaryConfigService
|
from .salary_config_service import SalaryConfigService
|
||||||
from .salary_profile_service import SalaryProfileService
|
from .salary_profile_service import SalaryProfileService
|
||||||
@ -15,6 +17,8 @@ __all__ = [
|
|||||||
"CommissionService",
|
"CommissionService",
|
||||||
"EmployeePage",
|
"EmployeePage",
|
||||||
"EmployeeService",
|
"EmployeeService",
|
||||||
|
"MonthlyPayrollDetailData",
|
||||||
|
"MonthlyPayrollService",
|
||||||
"OperationLogPage",
|
"OperationLogPage",
|
||||||
"OperationLogService",
|
"OperationLogService",
|
||||||
"OrganizationService",
|
"OrganizationService",
|
||||||
@ -22,6 +26,7 @@ __all__ = [
|
|||||||
"PayrollApplicationService",
|
"PayrollApplicationService",
|
||||||
"PayrollComputation",
|
"PayrollComputation",
|
||||||
"PayrollService",
|
"PayrollService",
|
||||||
|
"RealtimeAttendanceService",
|
||||||
"ReportService",
|
"ReportService",
|
||||||
"SalaryConfigService",
|
"SalaryConfigService",
|
||||||
"SalaryProfileService",
|
"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)
|
logger.info("解析工资结果下载文件 filename=%s", filename)
|
||||||
return self.storage.resolve_output_file(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(
|
def _export_if_needed(
|
||||||
self,
|
self,
|
||||||
config: AppConfig,
|
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/stores/` | Pinia 状态管理,目前主要是登录态、菜单、权限和主题色。 |
|
||||||
| `src/router/` | 路由配置和登录/权限守卫。 |
|
| `src/router/` | 路由配置和登录/权限守卫。 |
|
||||||
| `src/components/` | 通用布局、侧边栏、顶部栏、统计卡片和工资结果表格。 |
|
| `src/components/` | 通用布局、侧边栏、顶部栏、统计卡片和工资结果表格。 |
|
||||||
| `src/views/` | 登录、工作台、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 |
|
| `src/views/` | 登录、工作台、实时考勤、月度核算、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 |
|
||||||
| `src/assets/` | 全局样式和 UI 设计变量。 |
|
| `src/assets/` | 全局样式和 UI 设计变量。 |
|
||||||
|
|
||||||
## 本地启动
|
## 本地启动
|
||||||
@ -55,7 +55,7 @@ http://127.0.0.1:5173/
|
|||||||
/static -> http://127.0.0.1:8000
|
/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`,并设置:
|
如果部署时前端和后端不在同一个域名,可以复制 `.env.example` 为 `.env`,并设置:
|
||||||
|
|
||||||
@ -90,6 +90,8 @@ VITE_DEV_PROXY_TARGET=http://127.0.0.1:8001
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 登录 | `/login` | 用户登录入口。 |
|
| 登录 | `/login` | 用户登录入口。 |
|
||||||
| 工作台 | `/dashboard` | 展示最近计算任务、统计卡片和趋势面板。 |
|
| 工作台 | `/dashboard` | 展示最近计算任务、统计卡片和趋势面板。 |
|
||||||
|
| 实时考勤 | `/attendance/realtime` | 查看今日打卡、迟到、缺卡、请假、加班和本月薪资预估,可同步钉钉刷新。 |
|
||||||
|
| 月度核算 | `/payroll/monthly` | 按工资月份完成 Excel 导入、钉钉同步计算、异常处理、锁定和导出。 |
|
||||||
| 工资计算 | `/payroll` | 支持 Excel 导入和钉钉实时计算。 |
|
| 工资计算 | `/payroll` | 支持 Excel 导入和钉钉实时计算。 |
|
||||||
| 计算记录 | `/payroll/jobs` | 支持按任务编号查询已落库工资结果。 |
|
| 计算记录 | `/payroll/jobs` | 支持按任务编号查询已落库工资结果。 |
|
||||||
| 提成管理 | `/payroll/commissions` | 支持手工维护提成和 Excel 批量导入。 |
|
| 提成管理 | `/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` | 拥有全部菜单和全部操作权限。 |
|
| `superuser` | 拥有全部菜单和全部操作权限。 |
|
||||||
| `manager` | 可进行工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 |
|
| `manager` | 可进行实时考勤同步、月度核算、工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 |
|
||||||
| `viewer` | 可查看计算记录、下载结果和统计报表。 |
|
| `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;
|
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 {
|
:root {
|
||||||
--surface: #f7f9fb;
|
--surface: #f5f7fa;
|
||||||
--surface-soft: #eef2f6;
|
--surface-soft: #eef2f7;
|
||||||
--surface-strong: #e3e8ee;
|
--surface-strong: #d9e1ec;
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
--primary: #041627;
|
--primary: #22313f;
|
||||||
--primary-soft: #1a2b3c;
|
--primary-soft: #d3d7db;
|
||||||
--text: #191c1e;
|
--text: #1f2933;
|
||||||
--muted: #5f6670;
|
--muted: #667381;
|
||||||
--line: #d8dee6;
|
--line: #c7d0dc;
|
||||||
--line-soft: #edf1f5;
|
--line-soft: #f4f7fa;
|
||||||
--blue: #0f62c6;
|
--blue: #2563eb;
|
||||||
--blue-soft: #e8f1ff;
|
--blue-soft: #e8f1ff;
|
||||||
--green: #008c61;
|
--green: #008c61;
|
||||||
--green-soft: #ddf8ee;
|
--green-soft: #ddf8ee;
|
||||||
--red: #bd1e1e;
|
--red: #bd1e1e;
|
||||||
--red-soft: #ffebe8;
|
--red-soft: #ffebe8;
|
||||||
--shadow: 0 16px 32px rgba(4, 22, 39, 0.06);
|
--shadow: 0 16px 32px rgba(4, 22, 39, 0.06);
|
||||||
--sidebar-bg: #f3f6f9;
|
--sidebar-bg: #ffffff;
|
||||||
--nav-text: #303842;
|
--nav-text: #334155;
|
||||||
--nav-active-bg: #e1e6ec;
|
--nav-active-bg: #e8f0ff;
|
||||||
--topbar-bg: rgba(247, 249, 251, 0.94);
|
--topbar-bg: rgba(245, 247, 250, 0.94);
|
||||||
--search-bg: #f1f4f8;
|
--search-bg: #f6f8fb;
|
||||||
--control-bg: #fbfcfe;
|
--control-bg: #ffffff;
|
||||||
--secondary-bg: #e8edf2;
|
--secondary-bg: #eef2f7;
|
||||||
--secondary-hover-bg: #dde5ed;
|
--secondary-hover-bg: #d9e1ec;
|
||||||
--secondary-hover-border: #c6d0db;
|
--secondary-hover-border: #acb8c7;
|
||||||
--table-header-bg: #f5f7fa;
|
--table-header-bg: #f5f8fb;
|
||||||
--table-row-hover-bg: #fafcff;
|
--table-row-hover-bg: #f9fbff;
|
||||||
--modal-backdrop: rgba(4, 22, 39, 0.32);
|
--modal-backdrop: rgba(34, 49, 63, 0.32);
|
||||||
--primary-rgb: 4, 22, 39;
|
--primary-rgb: 34, 49, 63;
|
||||||
--accent-rgb: 15, 98, 198;
|
--accent-rgb: 37, 99, 235;
|
||||||
--app-font-family:
|
--app-font-family:
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
"PingFang SC", "Microsoft YaHei", sans-serif;
|
"PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
--radius-sm: 7px;
|
--radius-sm: 5px;
|
||||||
--radius-md: 8px;
|
--radius-md: 7px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 10px;
|
||||||
--topbar-height: 72px;
|
--topbar-height: 64px;
|
||||||
--nav-height: 48px;
|
--nav-height: 42px;
|
||||||
--control-height: 44px;
|
--control-height: 40px;
|
||||||
--base-font-size: 16px;
|
--base-font-size: 15px;
|
||||||
--page-padding: 32px;
|
--page-padding: 24px;
|
||||||
--content-gap: 24px;
|
--content-gap: 18px;
|
||||||
--panel-padding: 24px;
|
--panel-padding: 20px;
|
||||||
--table-cell-y: 18px;
|
--table-cell-y: 12px;
|
||||||
--table-cell-x: 24px;
|
--table-cell-x: 18px;
|
||||||
--layout-sidebar-width: 280px;
|
--layout-sidebar-width: 280px;
|
||||||
--layout-sidebar-offset: 280px;
|
--layout-sidebar-offset: 280px;
|
||||||
--collapsed-sidebar-width: 88px;
|
--collapsed-sidebar-width: 88px;
|
||||||
@ -163,6 +163,7 @@ a {
|
|||||||
|
|
||||||
:root[data-sidebar="edge"] .brand-title,
|
:root[data-sidebar="edge"] .brand-title,
|
||||||
:root[data-sidebar="edge"] .brand-subtitle,
|
:root[data-sidebar="edge"] .brand-subtitle,
|
||||||
|
:root[data-sidebar="edge"] .nav-section-title,
|
||||||
:root[data-sidebar="edge"] .nav-link {
|
:root[data-sidebar="edge"] .nav-link {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@ -292,7 +293,8 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-shell.is-sidebar-collapsed .brand-copy,
|
.app-shell.is-sidebar-collapsed .brand-copy,
|
||||||
.app-shell.is-sidebar-collapsed .nav-link span {
|
.app-shell.is-sidebar-collapsed .nav-link span,
|
||||||
|
.app-shell.is-sidebar-collapsed .nav-section-title {
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@ -315,6 +317,39 @@ a {
|
|||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell.is-sidebar-collapsed .nav-section-title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section + .nav-section {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section-title {
|
||||||
|
max-width: 180px;
|
||||||
|
margin: 0 8px 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
opacity: 0.78;
|
||||||
|
transform: translateX(0);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
max-width var(--motion-duration) var(--motion-easing),
|
||||||
|
margin var(--motion-duration) var(--motion-easing),
|
||||||
|
opacity var(--motion-duration-fast) ease,
|
||||||
|
transform var(--motion-duration) var(--motion-easing);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -325,7 +360,7 @@ a {
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--nav-text);
|
color: var(--nav-text);
|
||||||
padding: 0 16px;
|
padding: 0 14px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition:
|
transition:
|
||||||
background var(--motion-duration-fast) ease,
|
background var(--motion-duration-fast) ease,
|
||||||
@ -526,7 +561,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.appearance-panel {
|
.appearance-panel {
|
||||||
width: min(560px, calc(100vw - 20px));
|
width: min(480px, calc(100vw - 20px));
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@ -535,7 +570,7 @@ a {
|
|||||||
background: var(--card);
|
background: var(--card);
|
||||||
box-shadow: 0 28px 70px rgba(var(--primary-rgb), 0.26);
|
box-shadow: 0 28px 70px rgba(var(--primary-rgb), 0.26);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 16px;
|
padding: 14px;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
transition:
|
transition:
|
||||||
opacity var(--motion-duration-fast) ease,
|
opacity var(--motion-duration-fast) ease,
|
||||||
@ -593,8 +628,19 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
margin-bottom: 14px;
|
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 {
|
.appearance-panel-header .icon-button {
|
||||||
@ -614,7 +660,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.appearance-panel-header h2 {
|
.appearance-panel-header h2 {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appearance-panel-header p {
|
.appearance-panel-header p {
|
||||||
@ -629,7 +675,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.appearance-section + .appearance-section {
|
.appearance-section + .appearance-section {
|
||||||
margin-top: 16px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appearance-section-head {
|
.appearance-section-head {
|
||||||
@ -639,7 +685,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.appearance-section-head h3 {
|
.appearance-section-head h3 {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appearance-reset {
|
.appearance-reset {
|
||||||
@ -666,7 +712,7 @@ a {
|
|||||||
|
|
||||||
.appearance-grid {
|
.appearance-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-grid,
|
.mode-grid,
|
||||||
@ -677,7 +723,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-preset-grid {
|
.color-preset-grid {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.radius-grid {
|
.radius-grid {
|
||||||
@ -699,7 +745,7 @@ a {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-md);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
@ -722,7 +768,7 @@ a {
|
|||||||
.appearance-choice strong,
|
.appearance-choice strong,
|
||||||
.color-preset-choice strong {
|
.color-preset-choice strong {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -765,7 +811,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mode-preview {
|
.mode-preview {
|
||||||
height: 78px;
|
height: 66px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -881,26 +927,102 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-preset-choice {
|
.color-preset-choice {
|
||||||
min-height: 68px;
|
min-height: 74px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
background: transparent;
|
border-color: var(--line);
|
||||||
border-color: transparent;
|
background: var(--card);
|
||||||
padding: 0;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-preset-swatch {
|
.color-preset-swatch {
|
||||||
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
height: 42px;
|
height: 44px;
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-md);
|
||||||
background: linear-gradient(135deg, var(--preset-from), var(--preset-to));
|
background: var(--preset-surface);
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-preset-choice strong {
|
.color-preset-choice strong {
|
||||||
text-align: center;
|
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 {
|
.font-choice {
|
||||||
min-height: 54px;
|
min-height: 54px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@ -1713,6 +1835,293 @@ a {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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 {
|
.user-form {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@ -2014,14 +2423,14 @@ a {
|
|||||||
.login-screen {
|
.login-screen {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
grid-template-columns: minmax(420px, 42vw) minmax(0, 1fr);
|
grid-template-columns: minmax(400px, 40vw) minmax(0, 1fr);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form-side {
|
.login-form-side {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 48px;
|
padding: 42px 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
@ -2029,13 +2438,13 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-brand {
|
.login-brand {
|
||||||
margin-bottom: 28px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand h1 {
|
.login-brand h1 {
|
||||||
margin: 22px 0 8px;
|
margin: 18px 0 7px;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-size: 28px;
|
font-size: 26px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2046,7 +2455,7 @@ a {
|
|||||||
|
|
||||||
.form-stack {
|
.form-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-with-icon {
|
.input-with-icon {
|
||||||
@ -2083,9 +2492,9 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 14px 22px;
|
gap: 14px 22px;
|
||||||
margin-top: 42px;
|
margin-top: 34px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
padding-top: 24px;
|
padding-top: 20px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2104,43 +2513,42 @@ a {
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
border-left: 1px solid var(--line);
|
||||||
linear-gradient(135deg, rgba(var(--primary-rgb), 0.94), rgba(var(--accent-rgb), 0.86)),
|
background: var(--surface-soft);
|
||||||
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0 1px, transparent 1px 74px),
|
color: var(--text);
|
||||||
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.05) 0 1px, transparent 1px 74px);
|
padding: 44px;
|
||||||
color: white;
|
|
||||||
padding: 48px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ledger-visual {
|
.ledger-visual {
|
||||||
width: min(620px, 100%);
|
width: min(560px, 100%);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: rgba(255, 255, 255, 0.07);
|
background: var(--card);
|
||||||
padding: 48px;
|
padding: 32px;
|
||||||
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ledger-visual h2 {
|
.ledger-visual h2 {
|
||||||
margin: 24px 0 18px;
|
margin: 22px 0 14px;
|
||||||
font-size: 44px;
|
color: var(--primary);
|
||||||
line-height: 1.18;
|
font-size: 31px;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ledger-visual p {
|
.ledger-visual p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: rgba(255, 255, 255, 0.82);
|
color: var(--muted);
|
||||||
font-size: 17px;
|
font-size: 15px;
|
||||||
line-height: 1.7;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visual-metrics {
|
.visual-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--content-gap);
|
gap: 18px;
|
||||||
margin-top: 34px;
|
margin-top: 28px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.16);
|
border-top: 1px solid var(--line);
|
||||||
padding-top: 28px;
|
padding-top: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visual-metrics strong,
|
.visual-metrics strong,
|
||||||
@ -2149,12 +2557,13 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.visual-metrics strong {
|
.visual-metrics strong {
|
||||||
font-size: 32px;
|
color: var(--primary);
|
||||||
|
font-size: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visual-metrics span {
|
.visual-metrics span {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: rgba(255, 255, 255, 0.68);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-line {
|
.security-line {
|
||||||
@ -2164,7 +2573,7 @@ a {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: rgba(255, 255, 255, 0.58);
|
color: var(--muted);
|
||||||
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@ -2177,7 +2586,7 @@ a {
|
|||||||
|
|
||||||
@media (max-width: 1120px) {
|
@media (max-width: 1120px) {
|
||||||
.appearance-panel {
|
.appearance-panel {
|
||||||
width: min(540px, calc(100vw - 16px));
|
width: min(480px, calc(100vw - 16px));
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2193,11 +2602,17 @@ a {
|
|||||||
.stats-grid,
|
.stats-grid,
|
||||||
.dashboard-grid,
|
.dashboard-grid,
|
||||||
.action-grid,
|
.action-grid,
|
||||||
|
.compact-stats-grid,
|
||||||
.form-grid,
|
.form-grid,
|
||||||
|
.workflow-steps,
|
||||||
.password-form {
|
.password-form {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.organization-tree-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.login-screen {
|
.login-screen {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@ -2309,14 +2724,17 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stats-grid,
|
.stats-grid,
|
||||||
|
.organization-summary-strip,
|
||||||
.dashboard-grid,
|
.dashboard-grid,
|
||||||
.action-grid,
|
.action-grid,
|
||||||
|
.compact-stats-grid,
|
||||||
.form-grid,
|
.form-grid,
|
||||||
.user-form,
|
.user-form,
|
||||||
.password-form,
|
.password-form,
|
||||||
.search-form,
|
.search-form,
|
||||||
.table-filter-bar,
|
.table-filter-bar,
|
||||||
.visual-metrics {
|
.visual-metrics,
|
||||||
|
.workflow-steps {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2344,6 +2762,24 @@ a {
|
|||||||
.page-header h1 {
|
.page-header h1 {
|
||||||
font-size: 27px;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@ -26,13 +26,13 @@ const modeOptions: Array<{ name: ThemeMode; label: string }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const fontOptions: Array<{ name: FontChoice; label: string }> = [
|
const fontOptions: Array<{ name: FontChoice; label: string }> = [
|
||||||
{ name: "auto", label: "Auto" },
|
{ name: "auto", label: "系统" },
|
||||||
{ name: "sans", label: "Sans" },
|
{ name: "sans", label: "现代" },
|
||||||
{ name: "serif", label: "Serif" },
|
{ name: "serif", label: "宋体" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const radiusOptions: Array<{ name: RadiusChoice; label: string; previewRadius: string }> = [
|
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", label: "0", previewRadius: "0px" },
|
||||||
{ name: "0.3", label: "0.3", previewRadius: "8px" },
|
{ name: "0.3", label: "0.3", previewRadius: "8px" },
|
||||||
{ name: "0.5", label: "0.5", previewRadius: "14px" },
|
{ 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 }> = [
|
const layoutOptions: Array<{ name: LayoutChoice; label: string }> = [
|
||||||
{ name: "default", label: "默认" },
|
{ name: "default", label: "默认" },
|
||||||
{ name: "compact", label: "紧凑" },
|
{ name: "compact", label: "紧凑" },
|
||||||
{ name: "fullscreen", label: "全屏布局" },
|
{ name: "fullscreen", label: "全屏" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> = [
|
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="外观设置">
|
<aside class="appearance-panel" role="dialog" aria-label="外观设置">
|
||||||
<header class="appearance-panel-header">
|
<header class="appearance-panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>外观</h2>
|
<h2>界面外观</h2>
|
||||||
<p>主题、颜色与布局</p>
|
<p>整套界面的颜色、字体和布局</p>
|
||||||
</div>
|
</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')">
|
<button class="icon-button" type="button" title="关闭" @click="emit('close')">
|
||||||
<X :size="21" aria-hidden="true" />
|
<X :size="21" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="appearance-section">
|
<section class="appearance-section">
|
||||||
@ -118,7 +124,7 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
|
|||||||
|
|
||||||
<section class="appearance-section">
|
<section class="appearance-section">
|
||||||
<div class="appearance-section-head">
|
<div class="appearance-section-head">
|
||||||
<h3>颜色预设</h3>
|
<h3>整套配色</h3>
|
||||||
<button class="appearance-reset" type="button" title="恢复默认颜色" @click="theme.resetColor">
|
<button class="appearance-reset" type="button" title="恢复默认颜色" @click="theme.resetColor">
|
||||||
<RotateCcw :size="17" aria-hidden="true" />
|
<RotateCcw :size="17" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@ -129,11 +135,30 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
|
|||||||
:key="item.name"
|
:key="item.name"
|
||||||
class="color-preset-choice"
|
class="color-preset-choice"
|
||||||
:class="{ 'is-active': theme.current === item.name }"
|
: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"
|
type="button"
|
||||||
@click="theme.setTheme(item.name)"
|
@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>
|
<strong>{{ item.label }}</strong>
|
||||||
<span v-if="theme.current === item.name" class="choice-check" aria-hidden="true">
|
<span v-if="theme.current === item.name" class="choice-check" aria-hidden="true">
|
||||||
<Check :size="18" />
|
<Check :size="18" />
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import {
|
|||||||
Banknote,
|
Banknote,
|
||||||
BadgeDollarSign,
|
BadgeDollarSign,
|
||||||
Building2,
|
Building2,
|
||||||
|
Calculator,
|
||||||
|
CalendarClock,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileChartColumn,
|
FileChartColumn,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@ -19,6 +21,7 @@ import {
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import { buildSidebarSections } from "../navigation/sidebarMenu";
|
||||||
import { useAuthStore } from "../stores/auth";
|
import { useAuthStore } from "../stores/auth";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@ -35,6 +38,8 @@ const auth = useAuthStore();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const backendIconMap = {
|
const backendIconMap = {
|
||||||
|
attendance_realtime: CalendarClock,
|
||||||
|
monthly_payroll: Calculator,
|
||||||
payroll: Banknote,
|
payroll: Banknote,
|
||||||
jobs: ClipboardList,
|
jobs: ClipboardList,
|
||||||
employees: UserCog,
|
employees: UserCog,
|
||||||
@ -47,18 +52,15 @@ const backendIconMap = {
|
|||||||
operation_logs: ScrollText,
|
operation_logs: ScrollText,
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = computed(() => [
|
const menuSections = computed(() =>
|
||||||
{
|
buildSidebarSections(auth.menus).map((section) => ({
|
||||||
code: "dashboard",
|
...section,
|
||||||
name: "工作台",
|
items: section.items.map((menu) => ({
|
||||||
path: "/dashboard",
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
},
|
|
||||||
...auth.menus.map((menu) => ({
|
|
||||||
...menu,
|
...menu,
|
||||||
icon: backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
|
icon: menu.code === "dashboard" ? LayoutDashboard : backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
|
||||||
})),
|
})),
|
||||||
]);
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
function logout(): void {
|
function logout(): void {
|
||||||
auth.logout();
|
auth.logout();
|
||||||
@ -82,18 +84,21 @@ function logout(): void {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav" aria-label="主导航">
|
<nav class="sidebar-nav" aria-label="主导航">
|
||||||
|
<section v-for="section in menuSections" :key="section.code" class="nav-section">
|
||||||
|
<p class="nav-section-title">{{ section.title }}</p>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in menuItems"
|
v-for="item in section.items"
|
||||||
:key="item.code"
|
:key="item.code"
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
active-class="is-active"
|
active-class="is-active"
|
||||||
:title="item.name"
|
:title="`${section.title} / ${item.name}`"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
>
|
>
|
||||||
<component :is="item.icon" :size="21" aria-hidden="true" />
|
<component :is="item.icon" :size="20" aria-hidden="true" />
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
</section>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
|||||||
81
frontend/src/navigation/sidebarMenu.ts
Normal file
81
frontend/src/navigation/sidebarMenu.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
export interface SidebarMenuInput {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarMenuSection {
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
items: SidebarMenuInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DASHBOARD_MENU: SidebarMenuInput = {
|
||||||
|
code: "dashboard",
|
||||||
|
name: "工作台",
|
||||||
|
path: "/dashboard",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MENU_LABELS: Record<string, string> = {
|
||||||
|
monthly_payroll: "工资核算",
|
||||||
|
jobs: "计算记录",
|
||||||
|
commissions: "提成录入",
|
||||||
|
salary_profiles: "薪资档案",
|
||||||
|
reports: "工资报表",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_DEFINITIONS: Array<{
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
itemCodes: string[];
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
code: "core",
|
||||||
|
title: "核心工作",
|
||||||
|
itemCodes: ["dashboard", "attendance_realtime"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "monthly",
|
||||||
|
title: "月度核算",
|
||||||
|
itemCodes: ["monthly_payroll", "jobs", "commissions"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "employee",
|
||||||
|
title: "员工薪资",
|
||||||
|
itemCodes: ["employees", "salary_profiles", "organization"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "reports",
|
||||||
|
title: "报表分析",
|
||||||
|
itemCodes: ["reports"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "system",
|
||||||
|
title: "系统管理",
|
||||||
|
itemCodes: ["configs", "users", "operation_logs"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildSidebarSections(backendMenus: SidebarMenuInput[]): SidebarMenuSection[] {
|
||||||
|
const menuByCode = new Map<string, SidebarMenuInput>();
|
||||||
|
menuByCode.set(DASHBOARD_MENU.code, DASHBOARD_MENU);
|
||||||
|
for (const menu of backendMenus) {
|
||||||
|
menuByCode.set(menu.code, menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SECTION_DEFINITIONS.map((section) => ({
|
||||||
|
code: section.code,
|
||||||
|
title: section.title,
|
||||||
|
items: section.itemCodes
|
||||||
|
.map((code) => menuByCode.get(code))
|
||||||
|
.filter((item): item is SidebarMenuInput => Boolean(item))
|
||||||
|
.map(normalizeMenuLabel),
|
||||||
|
})).filter((section) => section.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMenuLabel(menu: SidebarMenuInput): SidebarMenuInput {
|
||||||
|
return {
|
||||||
|
...menu,
|
||||||
|
name: MENU_LABELS[menu.code] || menu.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -8,9 +8,11 @@ import DashboardView from "../views/DashboardView.vue";
|
|||||||
import EmployeesView from "../views/EmployeesView.vue";
|
import EmployeesView from "../views/EmployeesView.vue";
|
||||||
import JobsView from "../views/JobsView.vue";
|
import JobsView from "../views/JobsView.vue";
|
||||||
import LoginView from "../views/LoginView.vue";
|
import LoginView from "../views/LoginView.vue";
|
||||||
|
import MonthlyPayrollView from "../views/MonthlyPayrollView.vue";
|
||||||
import OperationLogsView from "../views/OperationLogsView.vue";
|
import OperationLogsView from "../views/OperationLogsView.vue";
|
||||||
import OrganizationView from "../views/OrganizationView.vue";
|
import OrganizationView from "../views/OrganizationView.vue";
|
||||||
import PayrollView from "../views/PayrollView.vue";
|
import PayrollView from "../views/PayrollView.vue";
|
||||||
|
import RealtimeAttendanceView from "../views/RealtimeAttendanceView.vue";
|
||||||
import ReportsView from "../views/ReportsView.vue";
|
import ReportsView from "../views/ReportsView.vue";
|
||||||
import SalaryProfilesView from "../views/SalaryProfilesView.vue";
|
import SalaryProfilesView from "../views/SalaryProfilesView.vue";
|
||||||
import UsersView from "../views/UsersView.vue";
|
import UsersView from "../views/UsersView.vue";
|
||||||
@ -44,6 +46,18 @@ const router = createRouter({
|
|||||||
name: "dashboard",
|
name: "dashboard",
|
||||||
component: DashboardView,
|
component: DashboardView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "attendance/realtime",
|
||||||
|
name: "attendance-realtime",
|
||||||
|
component: RealtimeAttendanceView,
|
||||||
|
meta: { permission: "attendance:realtime:view" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "payroll/monthly",
|
||||||
|
name: "monthly-payroll",
|
||||||
|
component: MonthlyPayrollView,
|
||||||
|
meta: { permission: "monthly_payroll:view" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "payroll",
|
path: "payroll",
|
||||||
name: "payroll",
|
name: "payroll",
|
||||||
@ -137,6 +151,12 @@ router.beforeEach(async (to) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function firstAllowedPath(permissions: string[]): string {
|
function firstAllowedPath(permissions: string[]): string {
|
||||||
|
if (permissions.includes("attendance:realtime:view")) {
|
||||||
|
return "/attendance/realtime";
|
||||||
|
}
|
||||||
|
if (permissions.includes("monthly_payroll:view")) {
|
||||||
|
return "/payroll/monthly";
|
||||||
|
}
|
||||||
if (permissions.includes("payroll:excel:calculate")) {
|
if (permissions.includes("payroll:excel:calculate")) {
|
||||||
return "/payroll";
|
return "/payroll";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
export type ThemeName =
|
export type ThemeName =
|
||||||
|
| "enterprise"
|
||||||
| "default"
|
| "default"
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "mono"
|
| "mono"
|
||||||
@ -103,26 +104,62 @@ const DENSITY_KEY = "financial-system-density";
|
|||||||
const SIDEBAR_KEY = "financial-system-sidebar";
|
const SIDEBAR_KEY = "financial-system-sidebar";
|
||||||
const LAYOUT_KEY = "financial-system-layout";
|
const LAYOUT_KEY = "financial-system-layout";
|
||||||
const CONTENT_WIDTH_KEY = "financial-system-content-width";
|
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 = {
|
export const DEFAULT_THEME_SETTINGS = {
|
||||||
theme: "anthropic" as ThemeName,
|
theme: "enterprise" as ThemeName,
|
||||||
mode: "dark" as ThemeMode,
|
mode: "light" as ThemeMode,
|
||||||
font: "serif" as FontChoice,
|
font: "sans" as FontChoice,
|
||||||
radius: "1" as RadiusChoice,
|
radius: "0.3" as RadiusChoice,
|
||||||
density: "compact" as DensityChoice,
|
density: "compact" as DensityChoice,
|
||||||
sidebar: "floating" as SidebarChoice,
|
sidebar: "embedded" as SidebarChoice,
|
||||||
layout: "default" as LayoutChoice,
|
layout: "default" as LayoutChoice,
|
||||||
contentWidth: "full" as ContentWidthChoice,
|
contentWidth: "full" as ContentWidthChoice,
|
||||||
};
|
};
|
||||||
|
|
||||||
const legacyThemeMap: Record<string, ThemeName> = {
|
const legacyThemeMap: Record<string, ThemeName> = {
|
||||||
navy: "anthropic",
|
navy: "enterprise",
|
||||||
blue: "ocean",
|
blue: "ocean",
|
||||||
emerald: "forest",
|
emerald: "forest",
|
||||||
burgundy: "rose",
|
burgundy: "rose",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
migrateLegacyAppearanceDefaults();
|
||||||
|
|
||||||
const themeSeeds: ThemeSeed[] = [
|
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",
|
name: "default",
|
||||||
label: "默认",
|
label: "默认",
|
||||||
@ -467,13 +504,13 @@ export const themeOptions: ThemeOption[] = themeSeeds.map((seed) => ({
|
|||||||
export const useThemeStore = defineStore("theme", {
|
export const useThemeStore = defineStore("theme", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
current: readThemeName(),
|
current: readThemeName(),
|
||||||
mode: readSetting(THEME_MODE_KEY, DEFAULTS.mode, ["system", "light", "dark"] as const),
|
mode: readSetting(THEME_MODE_KEY, DEFAULT_THEME_SETTINGS.mode, ["system", "light", "dark"] as const),
|
||||||
font: readSetting(FONT_KEY, DEFAULTS.font, ["auto", "sans", "serif"] as const),
|
font: readSetting(FONT_KEY, DEFAULT_THEME_SETTINGS.font, ["auto", "sans", "serif"] as const),
|
||||||
radius: readSetting(RADIUS_KEY, DEFAULTS.radius, ["auto", "0", "0.3", "0.5", "0.75", "1"] 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, DEFAULTS.density, ["compact", "default", "comfortable", "large"] as const),
|
density: readSetting(DENSITY_KEY, DEFAULT_THEME_SETTINGS.density, ["compact", "default", "comfortable", "large"] as const),
|
||||||
sidebar: readSetting(SIDEBAR_KEY, DEFAULTS.sidebar, ["embedded", "floating", "edge"] as const),
|
sidebar: readSetting(SIDEBAR_KEY, DEFAULT_THEME_SETTINGS.sidebar, ["embedded", "floating", "edge"] as const),
|
||||||
layout: readSetting(LAYOUT_KEY, DEFAULTS.layout, ["default", "compact", "fullscreen"] as const),
|
layout: readSetting(LAYOUT_KEY, DEFAULT_THEME_SETTINGS.layout, ["default", "compact", "fullscreen"] as const),
|
||||||
contentWidth: readSetting(CONTENT_WIDTH_KEY, DEFAULTS.contentWidth, ["full", "centered"] as const),
|
contentWidth: readSetting(CONTENT_WIDTH_KEY, DEFAULT_THEME_SETTINGS.contentWidth, ["full", "centered"] as const),
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
activeTheme: (state) => themeOptions.find((item) => item.name === state.current) || themeOptions[0],
|
activeTheme: (state) => themeOptions.find((item) => item.name === state.current) || themeOptions[0],
|
||||||
@ -579,36 +616,47 @@ export const useThemeStore = defineStore("theme", {
|
|||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
},
|
},
|
||||||
resetTheme(): void {
|
resetTheme(): void {
|
||||||
this.current = DEFAULTS.theme;
|
this.current = DEFAULT_THEME_SETTINGS.theme;
|
||||||
this.mode = DEFAULTS.mode;
|
this.mode = DEFAULT_THEME_SETTINGS.mode;
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
},
|
},
|
||||||
resetColor(): void {
|
resetColor(): void {
|
||||||
this.current = DEFAULTS.theme;
|
this.current = DEFAULT_THEME_SETTINGS.theme;
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
},
|
},
|
||||||
resetFont(): void {
|
resetFont(): void {
|
||||||
this.font = DEFAULTS.font;
|
this.font = DEFAULT_THEME_SETTINGS.font;
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
},
|
},
|
||||||
resetRadius(): void {
|
resetRadius(): void {
|
||||||
this.radius = DEFAULTS.radius;
|
this.radius = DEFAULT_THEME_SETTINGS.radius;
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
},
|
},
|
||||||
resetDensity(): void {
|
resetDensity(): void {
|
||||||
this.density = DEFAULTS.density;
|
this.density = DEFAULT_THEME_SETTINGS.density;
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
},
|
},
|
||||||
resetSidebar(): void {
|
resetSidebar(): void {
|
||||||
this.sidebar = DEFAULTS.sidebar;
|
this.sidebar = DEFAULT_THEME_SETTINGS.sidebar;
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
},
|
},
|
||||||
resetLayout(): void {
|
resetLayout(): void {
|
||||||
this.layout = DEFAULTS.layout;
|
this.layout = DEFAULT_THEME_SETTINGS.layout;
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
},
|
},
|
||||||
resetContentWidth(): void {
|
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();
|
this.applyTheme();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -760,8 +808,8 @@ function layoutVariables(
|
|||||||
|
|
||||||
function readThemeName(): ThemeName {
|
function readThemeName(): ThemeName {
|
||||||
const stored = readRawSetting(THEME_KEY);
|
const stored = readRawSetting(THEME_KEY);
|
||||||
const migrated = stored ? legacyThemeMap[stored] || stored : DEFAULTS.theme;
|
const migrated = stored ? legacyThemeMap[stored] || stored : DEFAULT_THEME_SETTINGS.theme;
|
||||||
return themeOptions.some((item) => item.name === migrated) ? (migrated as ThemeName) : DEFAULTS.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] {
|
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" {
|
function resolveThemeMode(mode: ThemeMode): "light" | "dark" {
|
||||||
if (mode !== "system") {
|
if (mode !== "system") {
|
||||||
return mode;
|
return mode;
|
||||||
|
|||||||
277
frontend/src/views/MonthlyPayrollView.vue
Normal file
277
frontend/src/views/MonthlyPayrollView.vue
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Calculator, Download, FileSpreadsheet, Loader2, LockKeyhole, RefreshCw, Upload } from "@lucide/vue";
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
import { ApiError } from "../api/http";
|
||||||
|
import {
|
||||||
|
calculateMonthlyFromDingTalk,
|
||||||
|
calculateMonthlyFromExcel,
|
||||||
|
exportMonthlyRun,
|
||||||
|
getMonthlyRun,
|
||||||
|
listMonthlyRuns,
|
||||||
|
lockMonthlyRun,
|
||||||
|
} from "../api/monthlyPayroll";
|
||||||
|
import type { MonthlyPayrollDetailResponse, MonthlyPayrollRun } from "../api/types";
|
||||||
|
import ResultsTable from "../components/ResultsTable.vue";
|
||||||
|
import { useAuthStore } from "../stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const selectedMonth = ref(new Date().toISOString().slice(0, 7));
|
||||||
|
const runs = ref<MonthlyPayrollRun[]>([]);
|
||||||
|
const detail = ref<MonthlyPayrollDetailResponse | null>(null);
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const actionLoading = ref<"" | "excel" | "dingtalk" | "lock" | "export">("");
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const successMessage = ref("");
|
||||||
|
|
||||||
|
const activeRun = computed(() => detail.value?.run || runs.value[0] || null);
|
||||||
|
const isLocked = computed(() => activeRun.value?.status === "locked");
|
||||||
|
const canCalculate = computed(() => auth.hasPermission("monthly_payroll:calculate"));
|
||||||
|
const canLock = computed(() => auth.hasPermission("monthly_payroll:lock"));
|
||||||
|
const canExport = computed(() => auth.hasPermission("monthly_payroll:export"));
|
||||||
|
const grossTotal = computed(() => money(activeRun.value?.gross_total || 0));
|
||||||
|
const netTotal = computed(() => money(activeRun.value?.net_total || 0));
|
||||||
|
const deductionTotal = computed(() => money(activeRun.value?.deduction_total || 0));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadRuns();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadRuns(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
try {
|
||||||
|
runs.value = await listMonthlyRuns(selectedMonth.value);
|
||||||
|
if (runs.value.length) {
|
||||||
|
detail.value = await getMonthlyRun(runs.value[0].id);
|
||||||
|
} else {
|
||||||
|
detail.value = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
runs.value = [];
|
||||||
|
detail.value = null;
|
||||||
|
errorMessage.value = error instanceof ApiError ? error.message : "月度核算数据加载失败";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseExcel(): void {
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExcelChange(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = "";
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction("excel", async () => {
|
||||||
|
detail.value = await calculateMonthlyFromExcel(file, selectedMonth.value);
|
||||||
|
successMessage.value = "Excel 已导入并完成月度核算";
|
||||||
|
await loadRuns();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function calculateFromDingTalk(): Promise<void> {
|
||||||
|
await runAction("dingtalk", async () => {
|
||||||
|
detail.value = await calculateMonthlyFromDingTalk(selectedMonth.value);
|
||||||
|
successMessage.value = "钉钉考勤已同步并完成月度核算";
|
||||||
|
await loadRuns();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockRun(): Promise<void> {
|
||||||
|
if (!activeRun.value || !window.confirm("锁定后本月工资不能重新计算,只能导出。确认锁定?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction("lock", async () => {
|
||||||
|
await lockMonthlyRun(activeRun.value!.id);
|
||||||
|
detail.value = await getMonthlyRun(activeRun.value!.id);
|
||||||
|
successMessage.value = "本月工资已锁定";
|
||||||
|
await loadRuns();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportRun(): Promise<void> {
|
||||||
|
if (!activeRun.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction("export", async () => {
|
||||||
|
await exportMonthlyRun(activeRun.value!.id);
|
||||||
|
successMessage.value = "月度工资表已开始下载";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(
|
||||||
|
action: "excel" | "dingtalk" | "lock" | "export",
|
||||||
|
executor: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
actionLoading.value = action;
|
||||||
|
errorMessage.value = "";
|
||||||
|
successMessage.value = "";
|
||||||
|
try {
|
||||||
|
await executor();
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof ApiError ? error.message : "操作失败,请检查后重试";
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(status: string | undefined): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
draft: "草稿",
|
||||||
|
calculated: "已计算",
|
||||||
|
reviewed: "已复核",
|
||||||
|
locked: "已锁定",
|
||||||
|
failed: "失败",
|
||||||
|
};
|
||||||
|
return status ? map[status] || status : "未开始";
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: number): string {
|
||||||
|
return new Intl.NumberFormat("zh-CN", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CNY",
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="view-stack">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>月度核算</h1>
|
||||||
|
<p>按工资月份完成导入、计算、异常处理、锁定和导出。</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<label class="field compact-field">
|
||||||
|
<span>工资月份</span>
|
||||||
|
<input v-model="selectedMonth" type="month" @change="loadRuns" />
|
||||||
|
</label>
|
||||||
|
<button class="secondary-button" type="button" :disabled="loading" @click="loadRuns">
|
||||||
|
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
|
||||||
|
<RefreshCw v-else :size="18" aria-hidden="true" />
|
||||||
|
<span>刷新</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||||
|
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>核算流程</h2>
|
||||||
|
<p>数据导入 -> 考勤转换 -> 异常处理 -> 工资计算 -> 复核锁定</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-chip" :class="isLocked ? 'green' : 'neutral'">{{ statusText(activeRun?.status) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="workflow-steps">
|
||||||
|
<span>1 数据导入</span>
|
||||||
|
<span>2 考勤转换</span>
|
||||||
|
<span>3 异常处理</span>
|
||||||
|
<span>4 工资计算</span>
|
||||||
|
<span>5 复核锁定</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compact-stats-grid">
|
||||||
|
<div class="compact-stat">
|
||||||
|
<span>员工数</span>
|
||||||
|
<strong>{{ activeRun?.employee_count || 0 }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="compact-stat">
|
||||||
|
<span>应发合计</span>
|
||||||
|
<strong>{{ grossTotal }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="compact-stat">
|
||||||
|
<span>实发合计</span>
|
||||||
|
<strong>{{ netTotal }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="compact-stat">
|
||||||
|
<span>扣款合计</span>
|
||||||
|
<strong>{{ deductionTotal }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="secondary-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'excel'" @click="chooseExcel">
|
||||||
|
<Loader2 v-if="actionLoading === 'excel'" class="spin" :size="18" aria-hidden="true" />
|
||||||
|
<Upload v-else :size="18" aria-hidden="true" />
|
||||||
|
<span>导入 Excel</span>
|
||||||
|
</button>
|
||||||
|
<button class="primary-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'dingtalk'" @click="calculateFromDingTalk">
|
||||||
|
<Loader2 v-if="actionLoading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
|
||||||
|
<Calculator v-else :size="18" aria-hidden="true" />
|
||||||
|
<span>同步钉钉并计算</span>
|
||||||
|
</button>
|
||||||
|
<button class="secondary-button" type="button" :disabled="!activeRun || !canLock || isLocked || actionLoading === 'lock'" @click="lockRun">
|
||||||
|
<Loader2 v-if="actionLoading === 'lock'" class="spin" :size="18" aria-hidden="true" />
|
||||||
|
<LockKeyhole v-else :size="18" aria-hidden="true" />
|
||||||
|
<span>锁定工资</span>
|
||||||
|
</button>
|
||||||
|
<button class="secondary-button" type="button" :disabled="!activeRun || !canExport || actionLoading === 'export'" @click="exportRun">
|
||||||
|
<Loader2 v-if="actionLoading === 'export'" class="spin" :size="18" aria-hidden="true" />
|
||||||
|
<Download v-else :size="18" aria-hidden="true" />
|
||||||
|
<span>导出</span>
|
||||||
|
</button>
|
||||||
|
<input ref="fileInput" accept=".xlsx,.xls" hidden type="file" @change="handleExcelChange" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="detail?.exceptions.length" class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>异常清单</h2>
|
||||||
|
<p>优先处理缺薪资档案、缺卡、迟到扣款和未匹配员工。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>员工</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>级别</th>
|
||||||
|
<th>说明</th>
|
||||||
|
<th>状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in detail.exceptions" :key="item.id">
|
||||||
|
<td>
|
||||||
|
<strong>{{ item.employee_name || "-" }}</strong>
|
||||||
|
<span>{{ item.employee_no || "-" }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.exception_type }}</td>
|
||||||
|
<td>{{ item.severity }}</td>
|
||||||
|
<td>{{ item.message }}</td>
|
||||||
|
<td>{{ item.status }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>工资明细</h2>
|
||||||
|
<p>锁定后本月工资不能重新计算,只能导出。</p>
|
||||||
|
</div>
|
||||||
|
<FileSpreadsheet :size="22" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<ResultsTable v-if="detail?.results.length" :results="detail.results" />
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<FileSpreadsheet :size="38" aria-hidden="true" />
|
||||||
|
<p>当前月份还没有核算结果,请导入 Excel 或同步钉钉并计算。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,5 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<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 { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -12,7 +23,26 @@ import {
|
|||||||
} from "../api/organization";
|
} from "../api/organization";
|
||||||
import { ApiError } from "../api/http";
|
import { ApiError } from "../api/http";
|
||||||
import type { DepartmentRecord, DepartmentRequest, PositionRecord, PositionRequest } from "../api/types";
|
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 departments = ref<DepartmentRecord[]>([]);
|
||||||
const positions = ref<PositionRecord[]>([]);
|
const positions = ref<PositionRecord[]>([]);
|
||||||
@ -22,6 +52,10 @@ const savingPosition = ref(false);
|
|||||||
const editingDepartmentId = ref<number | null>(null);
|
const editingDepartmentId = ref<number | null>(null);
|
||||||
const editingPositionId = ref<number | null>(null);
|
const editingPositionId = ref<number | null>(null);
|
||||||
const editingPositionCode = ref("");
|
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 errorMessage = ref("");
|
||||||
const successMessage = ref("");
|
const successMessage = ref("");
|
||||||
|
|
||||||
@ -42,9 +76,122 @@ const positionForm = reactive<PositionRequest>({
|
|||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeDepartments = computed(() => departments.value.filter((item) => item.is_active));
|
const sortedDepartments = computed(() => [...departments.value].sort(sortByOrderThenId));
|
||||||
const activePositions = computed(() => positions.value.filter((item) => item.is_active));
|
const sortedPositions = computed(() => [...positions.value].sort(sortByOrderThenId));
|
||||||
const positionRows = computed(() => positions.value);
|
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);
|
onMounted(loadData);
|
||||||
|
|
||||||
@ -58,6 +205,8 @@ async function loadData(): Promise<void> {
|
|||||||
]);
|
]);
|
||||||
departments.value = departmentRows;
|
departments.value = departmentRows;
|
||||||
positions.value = positionRowsData;
|
positions.value = positionRowsData;
|
||||||
|
reconcileExpandedDepartments(departmentRows);
|
||||||
|
syncSelectionAfterLoad();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof ApiError ? error.message : "组织架构加载失败";
|
errorMessage.value = error instanceof ApiError ? error.message : "组织架构加载失败";
|
||||||
} finally {
|
} finally {
|
||||||
@ -70,14 +219,11 @@ async function submitDepartment(): Promise<void> {
|
|||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
successMessage.value = "";
|
successMessage.value = "";
|
||||||
try {
|
try {
|
||||||
if (editingDepartmentId.value) {
|
const department = editingDepartmentId.value
|
||||||
await updateDepartment(editingDepartmentId.value, normalizedDepartment());
|
? await updateDepartment(editingDepartmentId.value, normalizedDepartment())
|
||||||
successMessage.value = "部门已更新";
|
: await createDepartment(normalizedDepartment());
|
||||||
} else {
|
selectedNodeKey.value = departmentKey(department.id);
|
||||||
await createDepartment(normalizedDepartment());
|
successMessage.value = editingDepartmentId.value ? "部门已更新" : "部门已新增";
|
||||||
successMessage.value = "部门已新增";
|
|
||||||
}
|
|
||||||
resetDepartmentForm();
|
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof ApiError ? error.message : "部门保存失败";
|
errorMessage.value = error instanceof ApiError ? error.message : "部门保存失败";
|
||||||
@ -95,14 +241,11 @@ async function submitPosition(): Promise<void> {
|
|||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
successMessage.value = "";
|
successMessage.value = "";
|
||||||
try {
|
try {
|
||||||
if (editingPositionId.value) {
|
const position = editingPositionId.value
|
||||||
await updatePosition(editingPositionId.value, normalizedPosition());
|
? await updatePosition(editingPositionId.value, normalizedPosition())
|
||||||
successMessage.value = "岗位已更新";
|
: await createPosition(normalizedPosition());
|
||||||
} else {
|
selectedNodeKey.value = positionKey(position.id);
|
||||||
await createPosition(normalizedPosition());
|
successMessage.value = editingPositionId.value ? "岗位已更新" : "岗位已新增";
|
||||||
successMessage.value = "岗位已新增";
|
|
||||||
}
|
|
||||||
resetPositionForm();
|
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof ApiError ? error.message : "岗位保存失败";
|
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;
|
editingDepartmentId.value = department.id;
|
||||||
|
editingPositionId.value = null;
|
||||||
|
editingPositionCode.value = "";
|
||||||
|
expandDepartmentPath(department.id);
|
||||||
Object.assign(departmentForm, {
|
Object.assign(departmentForm, {
|
||||||
parent_id: department.parent_id,
|
parent_id: department.parent_id,
|
||||||
department_code: department.department_code,
|
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;
|
editingPositionId.value = position.id;
|
||||||
editingPositionCode.value = position.position_code;
|
editingPositionCode.value = position.position_code;
|
||||||
|
editingDepartmentId.value = null;
|
||||||
|
expandDepartmentPath(position.department_id);
|
||||||
Object.assign(positionForm, {
|
Object.assign(positionForm, {
|
||||||
department_id: position.department_id,
|
department_id: position.department_id,
|
||||||
name: position.name,
|
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 {
|
function resetDepartmentForm(): void {
|
||||||
editingDepartmentId.value = null;
|
editingDepartmentId.value = null;
|
||||||
Object.assign(departmentForm, {
|
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 {
|
function normalizedDepartment(): DepartmentRequest {
|
||||||
return {
|
return {
|
||||||
parent_id: departmentForm.parent_id || null,
|
parent_id: departmentForm.parent_id || null,
|
||||||
@ -173,6 +388,7 @@ function normalizedDepartment(): DepartmentRequest {
|
|||||||
function normalizedPosition(): PositionRequest {
|
function normalizedPosition(): PositionRequest {
|
||||||
return {
|
return {
|
||||||
department_id: Number(positionForm.department_id),
|
department_id: Number(positionForm.department_id),
|
||||||
|
position_code: editingPositionCode.value,
|
||||||
name: positionForm.name.trim(),
|
name: positionForm.name.trim(),
|
||||||
sort_order: Number(positionForm.sort_order || 0),
|
sort_order: Number(positionForm.sort_order || 0),
|
||||||
is_active: positionForm.is_active,
|
is_active: positionForm.is_active,
|
||||||
@ -180,11 +396,100 @@ function normalizedPosition(): PositionRequest {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function departmentName(id: number | null): string {
|
function syncSelectionAfterLoad(): void {
|
||||||
if (!id) {
|
const node = allTreeNodes.value.find((item) => item.key === selectedNodeKey.value);
|
||||||
return "-";
|
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>
|
</script>
|
||||||
|
|
||||||
@ -193,7 +498,7 @@ function departmentName(id: number | null): string {
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>组织架构</h1>
|
<h1>组织架构</h1>
|
||||||
<p>维护部门上下级和部门下的岗位,员工维护会按这里的关系联动。</p>
|
<p>用树状关系维护部门上下级和部门下岗位,员工维护会按这里的关系联动。</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
|
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
|
||||||
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
|
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
|
||||||
@ -202,23 +507,145 @@ function departmentName(id: number | null): string {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="organization-summary-strip">
|
||||||
<StatCard title="部门总数" :value="String(departments.length)" :icon="Building2" tone="blue" meta="含停用" />
|
<div>
|
||||||
<StatCard title="启用部门" :value="String(activeDepartments.length)" :icon="Building2" tone="green" meta="可选" />
|
<Building2 :size="18" aria-hidden="true" />
|
||||||
<StatCard title="岗位总数" :value="String(positions.length)" :icon="GitBranch" tone="neutral" meta="含停用" />
|
<span>部门</span>
|
||||||
<StatCard title="启用岗位" :value="String(activePositions.length)" :icon="GitBranch" tone="green" meta="员工可选" />
|
<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>
|
||||||
|
|
||||||
<section class="dashboard-grid">
|
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||||
<div class="panel">
|
<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 class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ editingDepartmentId ? "编辑部门" : "新增部门" }}</h2>
|
<h2>组织树</h2>
|
||||||
<p>部门可选择父级,形成上下级关系</p>
|
<p>{{ departments.length }} 个部门,{{ positions.length }} 个岗位</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary-button" type="button" @click="resetDepartmentForm">清空</button>
|
<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>
|
</div>
|
||||||
<form class="form-grid compact-form" @submit.prevent="submitDepartment">
|
</div>
|
||||||
|
|
||||||
|
<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">
|
<label class="field">
|
||||||
<span>部门编码</span>
|
<span>部门编码</span>
|
||||||
<input v-model="departmentForm.department_code" required maxlength="64" type="text" />
|
<input v-model="departmentForm.department_code" required maxlength="64" type="text" />
|
||||||
@ -230,13 +657,9 @@ function departmentName(id: number | null): string {
|
|||||||
<label class="field">
|
<label class="field">
|
||||||
<span>父级部门</span>
|
<span>父级部门</span>
|
||||||
<select v-model.number="departmentForm.parent_id">
|
<select v-model.number="departmentForm.parent_id">
|
||||||
<option :value="null">无父级</option>
|
<option :value="null">无上级部门</option>
|
||||||
<option
|
<option v-for="department in availableParentDepartments" :key="department.id" :value="department.id">
|
||||||
v-for="department in departments.filter((item) => item.id !== editingDepartmentId)"
|
{{ departmentOptionLabel(department) }}
|
||||||
:key="department.id"
|
|
||||||
:value="department.id"
|
|
||||||
>
|
|
||||||
{{ department.name }}
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@ -255,29 +678,19 @@ function departmentName(id: number | null): string {
|
|||||||
<div class="form-actions align-right span-row">
|
<div class="form-actions align-right span-row">
|
||||||
<button class="primary-button" type="submit" :disabled="savingDepartment">
|
<button class="primary-button" type="submit" :disabled="savingDepartment">
|
||||||
<Loader2 v-if="savingDepartment" class="spin" :size="18" aria-hidden="true" />
|
<Loader2 v-if="savingDepartment" class="spin" :size="18" aria-hidden="true" />
|
||||||
<Save v-else-if="editingDepartmentId" :size="18" aria-hidden="true" />
|
<Save v-else :size="18" aria-hidden="true" />
|
||||||
<Plus v-else :size="18" aria-hidden="true" />
|
<span>{{ savingDepartment ? "保存中" : editingDepartmentId ? "保存部门" : "创建部门" }}</span>
|
||||||
<span>{{ savingDepartment ? "保存中" : editingDepartmentId ? "保存部门" : "新增部门" }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
<form v-else class="form-grid compact-form organization-editor-form" @submit.prevent="submitPosition">
|
||||||
<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">
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>所属部门</span>
|
<span>所属部门</span>
|
||||||
<select v-model.number="positionForm.department_id" required>
|
<select v-model.number="positionForm.department_id" required>
|
||||||
<option :value="0">请选择部门</option>
|
<option :value="0">请选择部门</option>
|
||||||
<option v-for="department in activeDepartments" :key="department.id" :value="department.id">
|
<option v-for="department in activeDepartments" :key="department.id" :value="department.id">
|
||||||
{{ department.name }}
|
{{ departmentOptionLabel(department) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@ -304,96 +717,12 @@ function departmentName(id: number | null): string {
|
|||||||
<div class="form-actions align-right span-row">
|
<div class="form-actions align-right span-row">
|
||||||
<button class="primary-button" type="submit" :disabled="savingPosition">
|
<button class="primary-button" type="submit" :disabled="savingPosition">
|
||||||
<Loader2 v-if="savingPosition" class="spin" :size="18" aria-hidden="true" />
|
<Loader2 v-if="savingPosition" class="spin" :size="18" aria-hidden="true" />
|
||||||
<Save v-else-if="editingPositionId" :size="18" aria-hidden="true" />
|
<Save v-else :size="18" aria-hidden="true" />
|
||||||
<Plus v-else :size="18" aria-hidden="true" />
|
<span>{{ savingPosition ? "保存中" : editingPositionId ? "保存岗位" : "创建岗位" }}</span>
|
||||||
<span>{{ savingPosition ? "保存中" : editingPositionId ? "保存岗位" : "新增岗位" }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
208
frontend/src/views/RealtimeAttendanceView.vue
Normal file
208
frontend/src/views/RealtimeAttendanceView.vue
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CalendarClock, CloudCog, Loader2, RefreshCw } from "@lucide/vue";
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
import { ApiError } from "../api/http";
|
||||||
|
import { getTodayAttendance, syncRealtimeAttendance } from "../api/realtimeAttendance";
|
||||||
|
import type { RealtimeAttendanceResponse, TodayAttendanceItem } from "../api/types";
|
||||||
|
import { useAuthStore } from "../stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const selectedDate = ref(new Date().toISOString().slice(0, 10));
|
||||||
|
const response = ref<RealtimeAttendanceResponse | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const syncing = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const successMessage = ref("");
|
||||||
|
|
||||||
|
const summary = computed(() => response.value?.summary);
|
||||||
|
const rows = computed(() => response.value?.items || []);
|
||||||
|
const canSync = computed(() => auth.hasPermission("attendance:realtime:sync"));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadTodayAttendance();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadTodayAttendance(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
try {
|
||||||
|
response.value = await getTodayAttendance(selectedDate.value);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof ApiError ? error.message : "实时考勤加载失败";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncDingTalk(): Promise<void> {
|
||||||
|
syncing.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
successMessage.value = "";
|
||||||
|
try {
|
||||||
|
response.value = await syncRealtimeAttendance(selectedDate.value);
|
||||||
|
successMessage.value = "钉钉考勤已同步,薪资预估已刷新";
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "钉钉同步失败";
|
||||||
|
errorMessage.value = message.includes("dingtalk.app_key")
|
||||||
|
? "钉钉配置缺失,请先在 config/app_settings.json 中配置 app_key 和 app_secret"
|
||||||
|
: message;
|
||||||
|
} finally {
|
||||||
|
syncing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
present: "已打卡",
|
||||||
|
missing: "缺卡",
|
||||||
|
leave: "请假",
|
||||||
|
unchecked: "未打卡",
|
||||||
|
absent: "未打卡",
|
||||||
|
};
|
||||||
|
return map[status] || status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("zh-CN", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CNY",
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberText(value: number, unit = ""): string {
|
||||||
|
return `${Number(value || 0).toFixed(1)}${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowKey(row: TodayAttendanceItem): string {
|
||||||
|
return row.employee_id ? String(row.employee_id) : `${row.employee_no}-${row.employee_name}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="view-stack">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>实时考勤</h1>
|
||||||
|
<p>查看今日打卡状态,并预估本月薪资进度。</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<label class="field compact-field">
|
||||||
|
<span>考勤日期</span>
|
||||||
|
<input v-model="selectedDate" type="date" @change="loadTodayAttendance" />
|
||||||
|
</label>
|
||||||
|
<button class="secondary-button" type="button" :disabled="loading || syncing" @click="loadTodayAttendance">
|
||||||
|
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
|
||||||
|
<RefreshCw v-else :size="18" aria-hidden="true" />
|
||||||
|
<span>刷新</span>
|
||||||
|
</button>
|
||||||
|
<button class="primary-button" type="button" :disabled="!canSync || syncing" @click="syncDingTalk">
|
||||||
|
<Loader2 v-if="syncing" class="spin" :size="18" aria-hidden="true" />
|
||||||
|
<CloudCog v-else :size="18" aria-hidden="true" />
|
||||||
|
<span>{{ syncing ? "同步中" : "同步钉钉" }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||||
|
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>今日考勤与薪资预估</h2>
|
||||||
|
<p>预估金额,最终以月度核算锁定结果为准。</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-chip" :class="response?.sync_job_id ? 'green' : 'neutral'">
|
||||||
|
{{ response?.sync_job_id ? "已同步" : "未同步" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compact-stats-grid">
|
||||||
|
<div class="compact-stat">
|
||||||
|
<span>在职员工</span>
|
||||||
|
<strong>{{ summary?.employee_total || 0 }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="compact-stat">
|
||||||
|
<span>已打卡</span>
|
||||||
|
<strong>{{ summary?.checked_in_count || 0 }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="compact-stat">
|
||||||
|
<span>未打卡</span>
|
||||||
|
<strong>{{ summary?.unchecked_count || 0 }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="compact-stat">
|
||||||
|
<span>迟到</span>
|
||||||
|
<strong>{{ summary?.late_count || 0 }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="compact-stat">
|
||||||
|
<span>缺卡</span>
|
||||||
|
<strong>{{ summary?.missing_card_count || 0 }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="compact-stat">
|
||||||
|
<span>预估实发</span>
|
||||||
|
<strong>{{ money(summary?.estimated_net_total ?? 0) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>员工今日考勤</h2>
|
||||||
|
<p>{{ response?.synced_at ? `最近同步:${response.synced_at}` : "可先查看已入库数据,也可同步钉钉刷新。" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!rows.length && !loading" class="empty-state">
|
||||||
|
<CalendarClock :size="38" aria-hidden="true" />
|
||||||
|
<p>当前日期暂无考勤数据,可点击同步钉钉拉取在职员工打卡记录。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>员工</th>
|
||||||
|
<th>部门/岗位</th>
|
||||||
|
<th>上班</th>
|
||||||
|
<th>下班</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>迟到</th>
|
||||||
|
<th>缺卡</th>
|
||||||
|
<th>请假</th>
|
||||||
|
<th>今日加班</th>
|
||||||
|
<th>本月剩余加班</th>
|
||||||
|
<th>预估实发</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in rows" :key="rowKey(row)">
|
||||||
|
<td>
|
||||||
|
<strong>{{ row.employee_name }}</strong>
|
||||||
|
<span>{{ row.employee_no || "-" }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ row.department || "-" }}</strong>
|
||||||
|
<span>{{ row.position || "-" }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.first_punch || "-" }}</td>
|
||||||
|
<td>{{ row.last_punch || "-" }}</td>
|
||||||
|
<td>{{ statusText(row.attendance_status) }}</td>
|
||||||
|
<td>{{ row.late_minutes ? `${row.late_minutes} 分钟` : "-" }}</td>
|
||||||
|
<td>{{ row.missing_card_count || "-" }}</td>
|
||||||
|
<td>{{ numberText(row.leave_hours, "h") }}</td>
|
||||||
|
<td>{{ numberText(row.today_overtime_hours, "h") }}</td>
|
||||||
|
<td>{{ numberText(row.month_remaining_overtime_hours, "h") }}</td>
|
||||||
|
<td>{{ money(row.estimated_net_salary) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
frontend/tests/sidebarMenu.test.ts
Normal file
46
frontend/tests/sidebarMenu.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
buildSidebarSections,
|
||||||
|
type SidebarMenuInput,
|
||||||
|
} from "../src/navigation/sidebarMenu";
|
||||||
|
|
||||||
|
const backendMenus: SidebarMenuInput[] = [
|
||||||
|
{ code: "attendance_realtime", name: "实时考勤", path: "/attendance/realtime" },
|
||||||
|
{ code: "monthly_payroll", name: "月度核算", path: "/payroll/monthly" },
|
||||||
|
{ code: "payroll", name: "工资计算", path: "/payroll" },
|
||||||
|
{ code: "jobs", name: "计算记录", path: "/payroll/jobs" },
|
||||||
|
{ code: "commissions", name: "提成管理", path: "/payroll/commissions" },
|
||||||
|
{ code: "employees", name: "员工维护", path: "/system/employees" },
|
||||||
|
{ code: "salary_profiles", name: "薪资维护", path: "/system/salary-profiles" },
|
||||||
|
{ code: "organization", name: "组织架构", path: "/system/organization" },
|
||||||
|
{ code: "reports", name: "统计报表", path: "/reports" },
|
||||||
|
{ code: "configs", name: "规则配置", path: "/system/configs" },
|
||||||
|
{ code: "users", name: "用户管理", path: "/system/users" },
|
||||||
|
{ code: "operation_logs", name: "操作日志", path: "/system/operation-logs" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sections = buildSidebarSections(backendMenus);
|
||||||
|
|
||||||
|
assertEqual(
|
||||||
|
sections.map((section) => section.title).join(","),
|
||||||
|
"核心工作,月度核算,员工薪资,报表分析,系统管理",
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEqual(sectionCodes("核心工作"), "dashboard,attendance_realtime");
|
||||||
|
assertEqual(sectionCodes("月度核算"), "monthly_payroll,jobs,commissions");
|
||||||
|
assertEqual(sectionCodes("员工薪资"), "employees,salary_profiles,organization");
|
||||||
|
assertEqual(sectionCodes("报表分析"), "reports");
|
||||||
|
assertEqual(sectionCodes("系统管理"), "configs,users,operation_logs");
|
||||||
|
|
||||||
|
function sectionCodes(title: string): string {
|
||||||
|
const section = sections.find((item) => item.title === title);
|
||||||
|
if (!section) {
|
||||||
|
throw new Error(`找不到菜单分组:${title}`);
|
||||||
|
}
|
||||||
|
return section.items.map((item) => item.code).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEqual(actual: string, expected: string): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`期望 ${expected},实际 ${actual}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
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