Compare commits

..
9 Commits
Author SHA1 Message Date
焦龙言 b70e29563a 更新前端样式 2026-06-29 15:00:17 +08:00
焦龙言 960d2aa028 更新前端样式 2026-06-29 14:59:49 +08:00
焦龙言 d6f858b363 修复后端时区BUG 2026-06-29 14:49:32 +08:00
焦龙言 af1db3de4f 添加docker配置启动项 2026-06-23 09:14:13 +08:00
焦龙言 f213848719 更新UI相关部分 2026-06-23 09:13:13 +08:00
焦龙言 faa09101a1 更新部分功能实现逻辑 2026-06-22 13:23:04 +08:00
焦龙言 1e6230d412 feat: group sidebar navigation 2026-06-18 14:43:00 +08:00
焦龙言 dc74a618f7 feat: add realtime attendance and monthly payroll menus 2026-06-18 14:26:14 +08:00
焦龙言 a846ea5070 feat: add monthly payroll center tables 2026-06-18 14:23:49 +08:00
95 changed files with 12384 additions and 1366 deletions
+16
View File
@@ -0,0 +1,16 @@
.git/
.idea/
.venv/
__pycache__/
*.py[cod]
.env
.env.*
!.env.example
!.env.docker.example
data/
logs/
outputs/
uploads/
static/uploads/
frontend/node_modules/
frontend/dist/
+9
View File
@@ -0,0 +1,9 @@
# Copy this file to .env and edit DATABASE_URL before running docker compose.
#
# If MySQL runs on the same host as Docker, use host.docker.internal.
# docker-compose.yml maps it to the Linux Docker host with host-gateway.
# If MySQL runs on another server, use that server's reachable IP address or domain name.
DATABASE_URL=mysql+pymysql://root:Plo6lvzOPtMNzVIA@host.docker.internal:3306/financial_system?charset=utf8mb4
BACKEND_PORT=8005
FRONTEND_PORT=5179
+9
View File
@@ -0,0 +1,9 @@
# Copy this file to .env and edit DATABASE_URL before running docker compose.
#
# If MySQL runs on the same host as Docker, use host.docker.internal.
# docker-compose.yml maps it to the Linux Docker host with host-gateway.
# If MySQL runs on another server, use that server's reachable IP address or domain name.
DATABASE_URL=mysql+pymysql://root:12345678@host.docker.internal:3306/financial_system?charset=utf8mb4
BACKEND_PORT=8000
FRONTEND_PORT=5173
+21
View File
@@ -0,0 +1,21 @@
FROM docker.m.daocloud.io/library/python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV APP_CONFIG_FILE=/app/config/app_settings.json
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
COPY config ./config
COPY financial_system ./financial_system
COPY static ./static
RUN mkdir -p logs uploads outputs static/uploads/avatars
EXPOSE 8000
CMD ["python", "main.py"]
+83 -2
View File
@@ -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 导入,并按工资月份自动汇总进工资计算。
- 工资计算结果会落库为工资汇总、工资明细、考勤记录、请假记录和加班记录。 - 工资计算结果会落库为工资汇总、工资明细、考勤记录、请假记录和加班记录。
- 实时薪资预估只用于过程查看,最终工资以月度核算锁定结果为准。
## 项目结构 ## 项目结构
@@ -83,6 +85,44 @@ cd frontend
npm run build npm run build
``` ```
## Docker 运行(外部 MySQL)
项目已提供后端镜像、前端 Nginx 镜像和 `docker-compose.yml`。数据库使用外部 MySQL,不会在 compose 里启动 MySQL 容器。
1. 准备环境变量:
```bash
cp .env.docker.example .env
```
2. 修改 `.env` 里的 `DATABASE_URL`:
```env
DATABASE_URL=mysql+pymysql://用户名:密码@MySQL地址:3306/financial_system?charset=utf8mb4
```
如果 MySQL 就运行在当前电脑,并且你使用 Docker Desktop,MySQL 地址通常写:
```env
DATABASE_URL=mysql+pymysql://root:12345678@host.docker.internal:3306/financial_system?charset=utf8mb4
```
如果 MySQL 在另一台服务器,写那台服务器的内网 IP、外网 IP 或域名。不要在容器里使用 `localhost` 连接外部 MySQL,因为 `localhost` 指向的是后端容器本身。
3. 启动:
```bash
docker compose up --build
```
默认访问:
- 前端系统:http://127.0.0.1:5173
- 后端接口:http://127.0.0.1:8000
- 接口文档:http://127.0.0.1:5173/docs
后端容器启动时会按外部 MySQL 自动建库、建表并初始化默认超级管理员。请确认外部 MySQL 已允许该账号远程连接,并且账号有创建数据库和建表权限。
## 统一配置 ## 统一配置
后端配置统一放在: 后端配置统一放在:
@@ -97,6 +137,7 @@ config/app_settings.json
| --- | --- | | --- | --- |
| `server.host` / `server.port` | 后端监听地址和端口 | | `server.host` / `server.port` | 后端监听地址和端口 |
| `database.url` | 数据库连接 | | `database.url` | 数据库连接 |
| 系统时区 | 后端统一使用 `Asia/Shanghai`,MySQL 会话启动时自动设置为 `+08:00` |
| `dingtalk.app_key` / `dingtalk.app_secret` | 钉钉开放平台凭证 | | `dingtalk.app_key` / `dingtalk.app_secret` | 钉钉开放平台凭证 |
| `storage.upload_dir` | 上传文件目录 | | `storage.upload_dir` | 上传文件目录 |
| `storage.output_dir` | 导出工资文件目录 | | `storage.output_dir` | 导出工资文件目录 |
@@ -144,6 +185,13 @@ 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
```
如果历史页面时间整体少 8 小时,说明旧数据曾按 UTC 写入,可确认后执行一次修正脚本:
```bash
mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260623_timezone_shanghai.sql
``` ```
SQL 文件说明: SQL 文件说明:
@@ -154,6 +202,8 @@ 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` | 实时考勤同步、薪资预估、月度核算批次和异常清单升级脚本 |
| `upgrade_20260623_timezone_shanghai.sql` | 一次性修正历史 UTC 时间为北京时间,确认整体少 8 小时后再执行 |
主要业务表: 主要业务表:
@@ -170,6 +220,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 +281,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 +333,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" \
+46
View File
@@ -0,0 +1,46 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile
container_name: financial-system-backend
environment:
APP_CONFIG_FILE: /app/config/app_settings.json
DATABASE_URL: ${DATABASE_URL:?Please set DATABASE_URL in .env}
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${BACKEND_PORT:-8000}:8000"
volumes:
- ./config/app_settings.json:/app/config/app_settings.json:ro
- ./uploads:/app/uploads
- ./outputs:/app/outputs
- ./logs:/app/logs
- ./static/uploads:/app/static/uploads
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)",
]
interval: 15s
timeout: 5s
retries: 10
start_period: 20s
restart: unless-stopped
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
args:
VITE_API_BASE_URL: ""
container_name: financial-system-frontend
depends_on:
backend:
condition: service_started
ports:
- "${FRONTEND_PORT:-5173}:80"
restart: unless-stopped
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -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
+33
View File
@@ -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)
@@ -150,6 +162,7 @@ def get_payroll_service(
repository: PayrollRepository = Depends(get_payroll_repository), repository: PayrollRepository = Depends(get_payroll_repository),
salary_repository: SalaryProfileRepository = Depends(get_salary_profile_repository), salary_repository: SalaryProfileRepository = Depends(get_salary_profile_repository),
commission_repository: CommissionRepository = Depends(get_commission_repository), commission_repository: CommissionRepository = Depends(get_commission_repository),
employee_repository: EmployeeRepository = Depends(get_employee_repository),
config_repository: SalaryConfigRepository = Depends(get_salary_config_repository), config_repository: SalaryConfigRepository = Depends(get_salary_config_repository),
settings: AppSettings = Depends(get_app_settings), settings: AppSettings = Depends(get_app_settings),
) -> PayrollApplicationService: ) -> PayrollApplicationService:
@@ -159,6 +172,26 @@ def get_payroll_service(
salary_repository=salary_repository, salary_repository=salary_repository,
commission_repository=commission_repository, commission_repository=commission_repository,
config_service=SalaryConfigService(config_repository), config_service=SalaryConfigService(config_repository),
employee_repository=employee_repository,
)
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,
) )
+12 -1
View File
@@ -40,6 +40,8 @@ def preview_next_employee_no(
def list_employees( def list_employees(
http_request: Request, http_request: Request,
keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"), keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"),
employee_keyword: str | None = Query(default=None, description="员工信息关键字:编号、姓名、钉钉用户ID或手机号"),
organization_keyword: str | None = Query(default=None, description="组织信息关键字:部门或岗位"),
employment_status: str | None = Query(default=None, description="员工状态:active或inactive"), employment_status: str | None = Query(default=None, description="员工状态:active或inactive"),
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)), current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)),
service: EmployeeService = Depends(get_employee_service), service: EmployeeService = Depends(get_employee_service),
@@ -47,7 +49,12 @@ def list_employees(
) -> list[EmployeeResponse]: ) -> list[EmployeeResponse]:
"""查询员工档案。""" """查询员工档案。"""
try: try:
employees = service.list_employees(keyword=keyword, employment_status=employment_status) employees = service.list_employees(
keyword=keyword,
employee_keyword=employee_keyword,
organization_keyword=organization_keyword,
employment_status=employment_status,
)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record( operation_logs.record(
@@ -69,6 +76,8 @@ def list_employees_page(
page: int = Query(default=1, ge=1, description="页码,从1开始"), page: int = Query(default=1, ge=1, description="页码,从1开始"),
page_size: int = Query(default=10, ge=1, le=100, description="每页条数,最大100"), page_size: int = Query(default=10, ge=1, le=100, description="每页条数,最大100"),
keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"), keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"),
employee_keyword: str | None = Query(default=None, description="员工信息关键字:编号、姓名、钉钉用户ID或手机号"),
organization_keyword: str | None = Query(default=None, description="组织信息关键字:部门或岗位"),
employment_status: str | None = Query(default=None, description="员工状态:active或inactive"), employment_status: str | None = Query(default=None, description="员工状态:active或inactive"),
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)), current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)),
service: EmployeeService = Depends(get_employee_service), service: EmployeeService = Depends(get_employee_service),
@@ -80,6 +89,8 @@ def list_employees_page(
page=page, page=page,
page_size=page_size, page_size=page_size,
keyword=keyword, keyword=keyword,
employee_keyword=employee_keyword,
organization_keyword=organization_keyword,
employment_status=employment_status, employment_status=employment_status,
) )
except ValueError as exc: except ValueError as exc:
@@ -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),
)
@@ -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())
+20
View File
@@ -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",
] ]
@@ -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]
@@ -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]
+26 -5
View File
@@ -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,8 +60,8 @@ MENUS = {
ROLE_MENU_CODES = { ROLE_MENU_CODES = {
ROLE_SUPERUSER: ( ROLE_SUPERUSER: (
"payroll", "attendance_realtime",
"jobs", "monthly_payroll",
"employees", "employees",
"organization", "organization",
"salary_profiles", "salary_profiles",
@@ -64,8 +72,8 @@ ROLE_MENU_CODES = {
"operation_logs", "operation_logs",
), ),
ROLE_MANAGER: ( ROLE_MANAGER: (
"payroll", "attendance_realtime",
"jobs", "monthly_payroll",
"employees", "employees",
"organization", "organization",
"salary_profiles", "salary_profiles",
@@ -74,11 +82,17 @@ ROLE_MENU_CODES = {
"configs", "configs",
"operation_logs", "operation_logs",
), ),
ROLE_VIEWER: ("jobs", "reports"), ROLE_VIEWER: ("attendance_realtime", "monthly_payroll", "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 +113,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 +137,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,
+6 -1
View File
@@ -76,7 +76,7 @@ def get_settings() -> AppSettings:
upload_dir=_project_path(storage.get("upload_dir", "uploads")), upload_dir=_project_path(storage.get("upload_dir", "uploads")),
output_dir=_project_path(storage.get("output_dir", "outputs")), output_dir=_project_path(storage.get("output_dir", "outputs")),
avatar_dir=_project_path(storage.get("avatar_dir", "static/uploads/avatars")), avatar_dir=_project_path(storage.get("avatar_dir", "static/uploads/avatars")),
database_url=str(database.get("url", DEFAULT_DATABASE_URL)).strip(), database_url=_env_string("DATABASE_URL", database.get("url", DEFAULT_DATABASE_URL)),
dingtalk_app_key=str(dingtalk.get("app_key", "")).strip(), dingtalk_app_key=str(dingtalk.get("app_key", "")).strip(),
dingtalk_app_secret=str(dingtalk.get("app_secret", "")).strip(), dingtalk_app_secret=str(dingtalk.get("app_secret", "")).strip(),
dingtalk_base_url=str(dingtalk.get("base_url", "https://oapi.dingtalk.com")).rstrip("/"), dingtalk_base_url=str(dingtalk.get("base_url", "https://oapi.dingtalk.com")).rstrip("/"),
@@ -141,6 +141,11 @@ def _string_tuple(value: object, default: tuple[str, ...]) -> tuple[str, ...]:
return normalized or default return normalized or default
def _env_string(name: str, fallback: object) -> str:
configured_value = os.getenv(name, "").strip()
return configured_value or str(fallback).strip()
def _bool(value: object) -> bool: def _bool(value: object) -> bool:
if isinstance(value, bool): if isinstance(value, bool):
return value return value
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
SYSTEM_TIMEZONE = "Asia/Shanghai"
def now_shanghai() -> datetime:
"""返回系统统一使用的北京时间,保存到 DATETIME 前去掉 tzinfo。"""
return datetime.now(ZoneInfo(SYSTEM_TIMEZONE)).replace(tzinfo=None)
+200 -32
View File
@@ -5,6 +5,8 @@ from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from ..core.timezone import now_shanghai
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
@@ -84,14 +86,14 @@ class UserORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@@ -124,12 +126,12 @@ class DepartmentORM(Base):
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前") sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="部门备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="部门备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@@ -165,12 +167,12 @@ class PositionORM(Base):
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前") sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="岗位备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="岗位备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@@ -217,14 +219,14 @@ class EmployeeORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@@ -306,14 +308,14 @@ class SalaryProfileORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@@ -362,7 +364,7 @@ class AttendanceRecordORM(Base):
overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当日打卡推算加班工时") overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当日打卡推算加班工时")
raw_text: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="原始考勤单元格内容") raw_text: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="原始考勤单元格内容")
source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk") source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
class LeaveRecordORM(Base): class LeaveRecordORM(Base):
@@ -397,7 +399,7 @@ class LeaveRecordORM(Base):
source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk") source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="approved", comment="状态:approved已生效") status: Mapped[str] = mapped_column(String(32), nullable=False, default="approved", comment="状态:approved已生效")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
class OvertimeRecordORM(Base): class OvertimeRecordORM(Base):
@@ -438,7 +440,173 @@ class OvertimeRecordORM(Base):
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班费金额") amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班费金额")
source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk") source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态:calculated已计算") status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态:calculated已计算")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, 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=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=now_shanghai,
onupdate=now_shanghai,
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=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=now_shanghai,
onupdate=now_shanghai,
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=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=now_shanghai,
onupdate=now_shanghai,
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=now_shanghai, 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):
@@ -464,12 +632,12 @@ class CommissionRecordORM(Base):
source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="manual", comment="来源:manual手工、excel导入") source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="manual", comment="来源:manual手工、excel导入")
business_ref: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="业务单号或来源标识") business_ref: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="业务单号或来源标识")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@@ -552,7 +720,7 @@ class OperationLogORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
index=True, index=True,
comment="操作时间", comment="操作时间",
) )
@@ -594,14 +762,14 @@ class PayrollJobORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@@ -795,12 +963,12 @@ class SalaryRecordORM(Base):
net_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="实发工资") net_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="实发工资")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态:calculated已计算") status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态:calculated已计算")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@@ -838,7 +1006,7 @@ class SalaryDetailORM(Base):
quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="数量或工时") quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="数量或工时")
unit_price: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="单价") unit_price: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="单价")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
salary_record: Mapped[SalaryRecordORM] = relationship(back_populates="details") salary_record: Mapped[SalaryRecordORM] = relationship(back_populates="details")
@@ -861,11 +1029,11 @@ class SalaryConfigORM(Base):
value_type: Mapped[str] = mapped_column(String(32), nullable=False, default="string", comment="值类型:string、number、integer、boolean、json") value_type: Mapped[str] = mapped_column(String(32), nullable=False, default="string", comment="值类型:string、number、integer、boolean、json")
is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否启用") is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否启用")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="配置说明") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="配置说明")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
+16 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import create_engine, inspect, text from sqlalchemy import create_engine, event, inspect, text
from sqlalchemy.engine import make_url from sqlalchemy.engine import make_url
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -13,6 +13,21 @@ logger = AppLogger.get_logger(__name__)
# SQLite 默认限制跨线程连接;FastAPI 请求线程会复用连接池,需要显式放开。 # SQLite 默认限制跨线程连接;FastAPI 请求线程会复用连接池,需要显式放开。
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args) engine = create_engine(settings.database_url, connect_args=connect_args)
@event.listens_for(engine, "connect")
def _set_mysql_session_timezone(dbapi_connection, connection_record) -> None:
"""MySQL 会话统一使用北京时间,避免 CURRENT_TIMESTAMP 少 8 小时。"""
if not settings.database_url.startswith("mysql"):
return
cursor = dbapi_connection.cursor()
try:
cursor.execute("SET time_zone = '+08:00'")
finally:
cursor.close()
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
USER_PROFILE_COLUMNS = { USER_PROFILE_COLUMNS = {
@@ -7,6 +7,7 @@ CREATE DATABASE IF NOT EXISTS `financial_system`
DEFAULT COLLATE utf8mb4_unicode_ci; DEFAULT COLLATE utf8mb4_unicode_ci;
USE `financial_system`; USE `financial_system`;
SET time_zone = '+08:00';
CREATE TABLE IF NOT EXISTS `users` ( CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID', `id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID',
@@ -474,6 +475,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',
+120
View File
@@ -6,6 +6,7 @@ CREATE DATABASE IF NOT EXISTS `financial_system`
DEFAULT COLLATE utf8mb4_unicode_ci; DEFAULT COLLATE utf8mb4_unicode_ci;
USE `financial_system`; USE `financial_system`;
SET time_zone = '+08:00';
CREATE TABLE IF NOT EXISTS `users` ( CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID', `id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID',
@@ -384,6 +385,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='工资核算异常表,保存月度核算或实时同步过程中发现的待处理问题';
@@ -0,0 +1,115 @@
-- 修正历史时间:将此前按 UTC 写入的 DATETIME 字段统一调整为北京时间。
-- 重要:本脚本只能在确认历史数据整体少 8 小时后执行一次,不能重复执行。
-- 执行示例:
-- mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260623_timezone_shanghai.sql
SET time_zone = '+08:00';
CREATE TABLE IF NOT EXISTS `schema_migrations` (
`version` VARCHAR(64) NOT NULL COMMENT '迁移版本号',
`description` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '迁移说明',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '执行时间',
PRIMARY KEY (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='数据库迁移执行记录表';
SET @migration_version = _utf8mb4'20260623_timezone_shanghai' COLLATE utf8mb4_unicode_ci;
SET @should_run = (
SELECT IF(COUNT(*) = 0, 1, 0)
FROM `schema_migrations`
WHERE `version` = @migration_version
);
START TRANSACTION;
UPDATE `users`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `departments`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `positions`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `employees`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `salary_profiles`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `operation_logs`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `payroll_jobs`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `attendance_record`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `leave_record`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `overtime_record`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `attendance_sync_jobs`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `salary_preview`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `monthly_payroll_runs`
SET `locked_at` = CASE WHEN `locked_at` IS NULL THEN NULL ELSE DATE_ADD(`locked_at`, INTERVAL 8 HOUR) END,
`created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `payroll_exceptions`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`resolved_at` = CASE WHEN `resolved_at` IS NULL THEN NULL ELSE DATE_ADD(`resolved_at`, INTERVAL 8 HOUR) END
WHERE @should_run = 1;
UPDATE `commission_record`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `salary_record`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `salary_detail`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `salary_config`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
INSERT INTO `schema_migrations` (`version`, `description`)
SELECT @migration_version, '修正历史 UTC 时间为北京时间'
WHERE @should_run = 1;
COMMIT;
@@ -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
+40 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from openpyxl import Workbook from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
@@ -61,6 +61,13 @@ DAILY_HEADERS = [
"请假/调休记录", "请假/调休记录",
] ]
SALARY_MODE_LABELS = {
"monthly": "包月",
"hourly": "计时",
"piecework": "计件",
"probation": "试用期",
}
def export_payroll_results(results: list[PayrollResult], config: AppConfig, output_path: str | Path) -> Path: def export_payroll_results(results: list[PayrollResult], config: AppConfig, output_path: str | Path) -> Path:
"""导出工资汇总、每日明细和规则说明三张表。""" """导出工资汇总、每日明细和规则说明三张表。"""
@@ -84,6 +91,26 @@ def export_payroll_results(results: list[PayrollResult], config: AppConfig, outp
return output_path return output_path
def localize_payroll_export_salary_modes(source_path: str | Path, output_path: str | Path) -> Path:
"""复制工资表并把汇总页的薪资模式编码转换为中文标签。"""
source_path = Path(source_path)
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
workbook = load_workbook(source_path)
if "工资汇总" in workbook.sheetnames:
sheet = workbook["工资汇总"]
salary_mode_column = _find_header_column(sheet, "薪资模式")
if salary_mode_column is not None:
for row_idx in range(2, sheet.max_row + 1):
cell = sheet.cell(row=row_idx, column=salary_mode_column)
if cell.value is not None:
cell.value = _salary_mode_label(str(cell.value))
workbook.save(output_path)
return output_path
def _write_summary(sheet, results: list[PayrollResult]) -> None: def _write_summary(sheet, results: list[PayrollResult]) -> None:
sheet.append(SUMMARY_HEADERS) sheet.append(SUMMARY_HEADERS)
for result in results: for result in results:
@@ -94,7 +121,7 @@ def _write_summary(sheet, results: list[PayrollResult]) -> None:
employee.position, employee.position,
employee.attendance_group, employee.attendance_group,
result.salary_month, result.salary_month,
result.salary_mode, _salary_mode_label(result.salary_mode),
result.attendance_days, result.attendance_days,
result.absence_days, result.absence_days,
result.actual_work_hours, result.actual_work_hours,
@@ -192,3 +219,14 @@ def _format_time(value) -> str:
if value is None: if value is None:
return "" return ""
return value.strftime("%H:%M") return value.strftime("%H:%M")
def _find_header_column(sheet, header: str) -> int | None:
for cell in sheet[1]:
if cell.value == header:
return cell.column
return None
def _salary_mode_label(value: str) -> str:
return SALARY_MODE_LABELS.get(value, value)
@@ -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",
@@ -90,9 +90,16 @@ class EmployeeRepository:
self, self,
*, *,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> list[EmployeeORM]: ) -> list[EmployeeORM]:
query = self._filtered_query(keyword=keyword, employment_status=employment_status) query = self._filtered_query(
keyword=keyword,
employee_keyword=employee_keyword,
organization_keyword=organization_keyword,
employment_status=employment_status,
)
return query.order_by(EmployeeORM.id.desc()).all() return query.order_by(EmployeeORM.id.desc()).all()
def list_employees_page( def list_employees_page(
@@ -101,9 +108,16 @@ class EmployeeRepository:
page: int, page: int,
page_size: int, page_size: int,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> tuple[list[EmployeeORM], int]: ) -> tuple[list[EmployeeORM], int]:
query = self._filtered_query(keyword=keyword, employment_status=employment_status) query = self._filtered_query(
keyword=keyword,
employee_keyword=employee_keyword,
organization_keyword=organization_keyword,
employment_status=employment_status,
)
total = query.count() total = query.count()
items = ( items = (
query.order_by(EmployeeORM.id.desc()) query.order_by(EmployeeORM.id.desc())
@@ -113,7 +127,14 @@ class EmployeeRepository:
) )
return items, total return items, total
def _filtered_query(self, *, keyword: str | None, employment_status: str | None): def _filtered_query(
self,
*,
keyword: str | None,
employee_keyword: str | None,
organization_keyword: str | None,
employment_status: str | None,
):
query = self.session.query(EmployeeORM) query = self.session.query(EmployeeORM)
if employment_status: if employment_status:
query = query.filter(EmployeeORM.employment_status == employment_status) query = query.filter(EmployeeORM.employment_status == employment_status)
@@ -129,4 +150,22 @@ class EmployeeRepository:
EmployeeORM.phone.like(like_keyword), EmployeeORM.phone.like(like_keyword),
) )
) )
if employee_keyword:
like_employee_keyword = f"%{employee_keyword}%"
query = query.filter(
or_(
EmployeeORM.employee_no.like(like_employee_keyword),
EmployeeORM.name.like(like_employee_keyword),
EmployeeORM.dingtalk_user_id.like(like_employee_keyword),
EmployeeORM.phone.like(like_employee_keyword),
)
)
if organization_keyword:
like_organization_keyword = f"%{organization_keyword}%"
query = query.filter(
or_(
EmployeeORM.department.like(like_organization_keyword),
EmployeeORM.position.like(like_organization_keyword),
)
)
return query return query
@@ -0,0 +1,121 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from ..core.timezone import now_shanghai
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 = now_shanghai()
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 = now_shanghai()
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 = now_shanghai()
run.updated_at = now_shanghai()
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()
)
@@ -0,0 +1,148 @@
from __future__ import annotations
from datetime import date
from uuid import uuid4
from sqlalchemy.orm import Session
from ..core.timezone import now_shanghai
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 = now_shanghai()
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 = now_shanghai()
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()
)
+5
View File
@@ -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",
@@ -108,6 +108,8 @@ class EmployeeService:
self, self,
*, *,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> list[EmployeeORM]: ) -> list[EmployeeORM]:
clean_status = _clean_optional(employment_status) clean_status = _clean_optional(employment_status)
@@ -115,6 +117,8 @@ class EmployeeService:
clean_status = _valid_status(clean_status) clean_status = _valid_status(clean_status)
employees = self.repository.list_employees( employees = self.repository.list_employees(
keyword=_clean_optional(keyword), keyword=_clean_optional(keyword),
employee_keyword=_clean_optional(employee_keyword),
organization_keyword=_clean_optional(organization_keyword),
employment_status=clean_status, employment_status=clean_status,
) )
logger.info("查询员工列表成功 count=%s", len(employees)) logger.info("查询员工列表成功 count=%s", len(employees))
@@ -126,6 +130,8 @@ class EmployeeService:
page: int, page: int,
page_size: int, page_size: int,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> EmployeePage: ) -> EmployeePage:
clean_status = _clean_optional(employment_status) clean_status = _clean_optional(employment_status)
@@ -137,6 +143,8 @@ class EmployeeService:
page=safe_page, page=safe_page,
page_size=safe_page_size, page_size=safe_page_size,
keyword=_clean_optional(keyword), keyword=_clean_optional(keyword),
employee_keyword=_clean_optional(employee_keyword),
organization_keyword=_clean_optional(organization_keyword),
employment_status=clean_status, employment_status=clean_status,
) )
logger.info("分页查询员工列表成功 total=%s page=%s page_size=%s", total, safe_page, safe_page_size) logger.info("分页查询员工列表成功 total=%s page=%s page_size=%s", total, safe_page, safe_page_size)
@@ -0,0 +1,202 @@
from __future__ import annotations
from calendar import monthrange
from dataclasses import dataclass
from datetime import date
from pathlib import Path
import re
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__)
SALARY_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
@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,
)
content_month = self._month_from_computation(computation)
if content_month != salary_month:
raise ValueError(f"Excel 内容月份为 {content_month},与当前核算月份 {salary_month} 不一致")
run = self._save_calculation(
salary_month=salary_month,
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 self.payroll_service.localize_export_file(path, f"monthly_{detail.run.source_type}")
resolved_path = self.payroll_service.resolve_output_file(path.name)
return self.payroll_service.localize_export_file(resolved_path, f"monthly_{detail.run.source_type}")
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:
_validate_salary_month(salary_month)
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]:
_validate_salary_month(salary_month)
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])
def _validate_salary_month(salary_month: str) -> None:
if not salary_month:
raise ValueError("请选择核算月份")
if not SALARY_MONTH_RE.match(salary_month):
raise ValueError("核算月份格式错误,请选择正确的月份")
+67 -2
View File
@@ -11,10 +11,10 @@ from ..core.settings import AppSettings
from ..domain.calculator import PayrollCalculator from ..domain.calculator import PayrollCalculator
from ..domain.models import EmployeeAttendance, EmployeePayConfig, PayrollResult from ..domain.models import EmployeeAttendance, EmployeePayConfig, PayrollResult
from ..integrations.dingtalk import DingTalkAttendanceAdapter, DingTalkClient, DingTalkSettings from ..integrations.dingtalk import DingTalkAttendanceAdapter, DingTalkClient, DingTalkSettings
from ..io.exporter import export_payroll_results from ..io.exporter import export_payroll_results, localize_payroll_export_salary_modes
from ..io.parser import MonthlySummaryParser from ..io.parser import MonthlySummaryParser
from ..io.storage import FileStorage from ..io.storage import FileStorage
from ..repositories import CommissionRepository, PayrollRepository, SalaryProfileRepository from ..repositories import CommissionRepository, EmployeeRepository, PayrollRepository, SalaryProfileRepository
from .salary_config_service import SalaryConfigService from .salary_config_service import SalaryConfigService
logger = AppLogger.get_logger(__name__) logger = AppLogger.get_logger(__name__)
@@ -63,12 +63,14 @@ class PayrollApplicationService:
salary_repository: SalaryProfileRepository | None = None, salary_repository: SalaryProfileRepository | None = None,
commission_repository: CommissionRepository | None = None, commission_repository: CommissionRepository | None = None,
config_service: SalaryConfigService | None = None, config_service: SalaryConfigService | None = None,
employee_repository: EmployeeRepository | None = None,
): ):
self.repository = repository self.repository = repository
self.settings = settings self.settings = settings
self.salary_repository = salary_repository self.salary_repository = salary_repository
self.commission_repository = commission_repository self.commission_repository = commission_repository
self.config_service = config_service self.config_service = config_service
self.employee_repository = employee_repository
self.storage = FileStorage(settings) self.storage = FileStorage(settings)
def calculate_from_excel_upload( def calculate_from_excel_upload(
@@ -85,6 +87,7 @@ class PayrollApplicationService:
try: try:
config = self._load_runtime_config(config_path) config = self._load_runtime_config(config_path)
employees = MonthlySummaryParser(config.attendance).parse(upload_path) employees = MonthlySummaryParser(config.attendance).parse(upload_path)
self._enrich_employees_from_maintenance(employees)
salary_month = self._salary_month_from_employees(employees) salary_month = self._salary_month_from_employees(employees)
config = self._load_runtime_config(config_path, salary_month=salary_month) config = self._load_runtime_config(config_path, salary_month=salary_month)
results = PayrollCalculator(config).calculate(employees) results = PayrollCalculator(config).calculate(employees)
@@ -129,6 +132,7 @@ class PayrollApplicationService:
records = await client.list_attendance_records(user_ids, start_dt, end_dt) records = await client.list_attendance_records(user_ids, start_dt, end_dt)
adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone) adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone)
employees = adapter.to_employee_attendance(records) employees = adapter.to_employee_attendance(records)
self._enrich_employees_from_maintenance(employees)
results = PayrollCalculator(config).calculate(employees) results = PayrollCalculator(config).calculate(employees)
output_file = self._export_if_needed(config, results, "dingtalk", export_excel) output_file = self._export_if_needed(config, results, "dingtalk", export_excel)
self.repository.save_results(job.id, results) self.repository.save_results(job.id, results)
@@ -155,6 +159,21 @@ 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 localize_export_file(self, source_path: str | Path, source_type: str) -> Path:
"""生成一份用户下载用工资表副本,修正历史文件中的薪资模式编码。"""
output_path = self.storage.payroll_output_path(source_type)
localize_payroll_export_salary_modes(source_path, output_path)
logger.info("工资表导出副本已中文化 source_path=%s output_path=%s", source_path, output_path)
return output_path
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,
@@ -220,6 +239,48 @@ class PayrollApplicationService:
), ),
) )
def _enrich_employees_from_maintenance(self, employees: list[EmployeeAttendance]) -> None:
"""优先使用员工维护中的员工编号、部门和岗位,未维护时保留 Excel/钉钉原值。"""
if self.employee_repository is None or not employees:
return
maintained_employees = self.employee_repository.list_employees()
by_employee_no = {
_clean_key(employee.employee_no): employee
for employee in maintained_employees
if _clean_key(employee.employee_no)
}
by_dingtalk_user_id = {
_clean_key(employee.dingtalk_user_id): employee
for employee in maintained_employees
if _clean_key(employee.dingtalk_user_id)
}
by_name = {
_clean_key(employee.name): employee
for employee in maintained_employees
if _clean_key(employee.name)
}
enriched_count = 0
for employee in employees:
maintained = (
by_employee_no.get(_clean_key(employee.employee_no))
or by_dingtalk_user_id.get(_clean_key(employee.user_id))
or by_name.get(_clean_key(employee.name))
)
if maintained is None:
continue
employee.employee_no = maintained.employee_no or employee.employee_no
employee.name = maintained.name or employee.name
employee.user_id = maintained.dingtalk_user_id or employee.user_id
employee.department = maintained.department or employee.department
employee.position = maintained.position or employee.position
enriched_count += 1
if enriched_count:
logger.info("已使用员工维护数据补齐工资计算员工信息 count=%s", enriched_count)
def _salary_month_from_employees(self, employees: list[EmployeeAttendance]) -> str | None: def _salary_month_from_employees(self, employees: list[EmployeeAttendance]) -> str | None:
for employee in employees: for employee in employees:
if employee.daily_records: if employee.daily_records:
@@ -236,3 +297,7 @@ class PayrollApplicationService:
base_url=self.settings.dingtalk_base_url, base_url=self.settings.dingtalk_base_url,
timezone=self.settings.dingtalk_timezone, timezone=self.settings.dingtalk_timezone,
) )
def _clean_key(value: object) -> str:
return str(value or "").strip()
@@ -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"
+18
View File
@@ -0,0 +1,18 @@
FROM docker.m.daocloud.io/library/node:20-alpine AS build
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
ARG VITE_API_BASE_URL=
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
FROM docker.m.daocloud.io/library/nginx:1.27-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+8 -4
View File
@@ -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` | 可查看实时考勤、计算记录、下载结果和统计报表。 |
## 构建 ## 构建
+53
View File
@@ -0,0 +1,53 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 50m;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://backend:8000/health;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /docs {
proxy_pass http://backend:8000/docs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /openapi.json {
proxy_pass http://backend:8000/openapi.json;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+2 -2
View File
@@ -4,9 +4,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1 --port 5173", "dev": "vite --host 0.0.0.0 --port 5173",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173" "preview": "vite preview --host 0.0.0.0 --port 4173"
}, },
"dependencies": { "dependencies": {
"@lucide/vue": "1.18.0", "@lucide/vue": "1.18.0",
+11 -2
View File
@@ -1,7 +1,9 @@
import { apiFetch } from "./http"; import { apiFetch } from "./http";
import type { EmployeeListResponse, EmployeeNoResponse, EmployeeRecord, EmployeeRequest } from "./types"; import type { EmployeeListResponse, EmployeeNoResponse, EmployeeRecord, EmployeeRequest } from "./types";
export function listEmployees(params: { keyword?: string; employment_status?: string } = {}): Promise<EmployeeRecord[]> { export function listEmployees(
params: { keyword?: string; employee_keyword?: string; organization_keyword?: string; employment_status?: string } = {},
): Promise<EmployeeRecord[]> {
const search = new URLSearchParams(); const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== "") { if (value !== undefined && value !== null && String(value).trim() !== "") {
@@ -13,7 +15,14 @@ export function listEmployees(params: { keyword?: string; employment_status?: st
} }
export function listEmployeePage( export function listEmployeePage(
params: { page?: number; page_size?: number; keyword?: string; employment_status?: string } = {}, params: {
page?: number;
page_size?: number;
keyword?: string;
employee_keyword?: string;
organization_keyword?: string;
employment_status?: string;
} = {},
): Promise<EmployeeListResponse> { ): Promise<EmployeeListResponse> {
const search = new URLSearchParams(); const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
+75
View File
@@ -0,0 +1,75 @@
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,
exportExcel = true,
): Promise<MonthlyPayrollDetailResponse> {
const formData = new FormData();
formData.append("file", file);
formData.append("salary_month", salaryMonth);
formData.append("export_excel", String(exportExcel));
return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/excel", {
method: "POST",
body: formData,
});
}
export function calculateMonthlyFromDingTalk(
salaryMonth: string,
exportExcel = true,
): Promise<MonthlyPayrollDetailResponse> {
return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/dingtalk", {
method: "POST",
body: JSON.stringify({
salary_month: salaryMonth,
source_type: "dingtalk",
export_excel: exportExcel,
}),
});
}
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);
}
-5
View File
@@ -1,7 +1,6 @@
import { ApiError, apiFetch, buildApiUrl, getStoredToken } from "./http"; import { ApiError, apiFetch, buildApiUrl, getStoredToken } from "./http";
import type { import type {
DingTalkPayrollRequest, DingTalkPayrollRequest,
PayrollJobResponse,
PayrollResponse, PayrollResponse,
RecentPayrollJob, RecentPayrollJob,
} from "./types"; } from "./types";
@@ -33,10 +32,6 @@ export function calculateFromDingTalk(payload: DingTalkPayrollRequest): Promise<
}); });
} }
export function getPayrollJob(jobId: string): Promise<PayrollJobResponse> {
return apiFetch<PayrollJobResponse>(`/api/payroll/jobs/${encodeURIComponent(jobId)}`);
}
export function getDownloadUrl(downloadUrl: string | null, outputFile?: string | null): string { export function getDownloadUrl(downloadUrl: string | null, outputFile?: string | null): string {
if (downloadUrl) { if (downloadUrl) {
return buildApiUrl(downloadUrl); return buildApiUrl(downloadUrl);
+16
View File
@@ -0,0 +1,16 @@
import { apiFetch } from "./http";
import type { RealtimeAttendanceResponse } from "./types";
export function getTodayAttendance(date: string): Promise<RealtimeAttendanceResponse> {
return apiFetch<RealtimeAttendanceResponse>(`/api/attendance/realtime/today?date=${encodeURIComponent(date)}`);
}
export function syncRealtimeAttendance(date: string): Promise<RealtimeAttendanceResponse> {
return apiFetch<RealtimeAttendanceResponse>("/api/attendance/realtime/sync", {
method: "POST",
body: JSON.stringify({
attendance_date: date,
include_salary_preview: true,
}),
});
}
+77 -13
View File
@@ -106,19 +106,6 @@ export interface PayrollResponse {
results: PayrollResult[]; results: PayrollResult[];
} }
export interface PayrollJobResponse {
job_id: string;
source_type: string;
status: string;
input_file: string | null;
output_file: string | null;
employee_count: number;
error_message: string | null;
created_at: string;
updated_at: string;
results: PayrollResult[];
}
export interface DingTalkPayrollRequest { export interface DingTalkPayrollRequest {
user_ids: string[]; user_ids: string[];
start_date: string; start_date: string;
@@ -376,3 +363,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[];
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because it is too large Load Diff
+38 -13
View File
@@ -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 class="appearance-header-actions">
<button class="secondary-button appearance-recommend" type="button" @click="theme.resetAppearance">
<RotateCcw :size="16" aria-hidden="true" />
<span>推荐外观</span>
</button>
<button class="icon-button" type="button" title="关闭" @click="emit('close')">
<X :size="21" aria-hidden="true" />
</button>
</div> </div>
<button class="icon-button" type="button" title="关闭" @click="emit('close')">
<X :size="21" aria-hidden="true" />
</button>
</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" />
+313
View File
@@ -0,0 +1,313 @@
<script setup lang="ts">
import { CalendarDays, ChevronLeft, ChevronRight } from "@lucide/vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
const props = withDefaults(
defineProps<{
modelValue: string;
mode: "month" | "date";
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
}>(),
{
placeholder: "请选择日期",
disabled: false,
clearable: true,
},
);
const emit = defineEmits<{
(event: "update:modelValue", value: string): void;
(event: "change", value: string): void;
}>();
type CalendarCell = {
dateValue: string;
day: number;
muted: boolean;
};
const PANEL_WIDTH = 292;
const PANEL_OFFSET = 8;
const VIEWPORT_GAP = 12;
const rootRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const panelStyle = ref<Record<string, string>>({});
const open = ref(false);
const panelYear = ref(new Date().getFullYear());
const panelMonth = ref(new Date().getMonth());
const monthNames = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
const weekNames = ["一", "二", "三", "四", "五", "六", "日"];
const displayValue = computed(() => {
if (!props.modelValue) {
return props.placeholder;
}
if (props.mode === "month") {
const parsed = parseMonth(props.modelValue);
return parsed ? `${parsed.year}年${String(parsed.month + 1).padStart(2, "0")}月` : props.modelValue;
}
const parsed = parseDate(props.modelValue);
return parsed
? `${parsed.year}年${String(parsed.month + 1).padStart(2, "0")}月${String(parsed.day).padStart(2, "0")}日`
: props.modelValue;
});
const selectedMonthValue = computed(() => props.modelValue.slice(0, 7));
const todayDateValue = computed(() => formatDate(new Date()));
const currentMonthValue = computed(() => todayDateValue.value.slice(0, 7));
const calendarCells = computed<CalendarCell[]>(() => {
const cells: CalendarCell[] = [];
const firstDay = new Date(panelYear.value, panelMonth.value, 1);
const firstOffset = (firstDay.getDay() + 6) % 7;
const firstVisible = new Date(panelYear.value, panelMonth.value, 1 - firstOffset);
for (let index = 0; index < 42; index += 1) {
const date = new Date(firstVisible);
date.setDate(firstVisible.getDate() + index);
cells.push({
dateValue: formatDate(date),
day: date.getDate(),
muted: date.getMonth() !== panelMonth.value,
});
}
return cells;
});
watch(
() => props.modelValue,
async () => {
if (open.value) {
syncPanelFromValue();
await nextTick();
updatePanelPosition();
}
},
);
onMounted(() => {
document.addEventListener("click", handleOutsideClick);
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
});
onBeforeUnmount(() => {
document.removeEventListener("click", handleOutsideClick);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
});
async function togglePanel(): Promise<void> {
if (props.disabled) {
return;
}
if (open.value) {
closePanel();
return;
}
syncPanelFromValue();
open.value = true;
await nextTick();
updatePanelPosition();
}
function closePanel(): void {
open.value = false;
}
function handleOutsideClick(event: MouseEvent): void {
const target = event.target as Node;
if (rootRef.value?.contains(target) || panelRef.value?.contains(target)) {
return;
}
closePanel();
}
function updatePanelPosition(): void {
if (!open.value) {
return;
}
const trigger = rootRef.value?.querySelector<HTMLElement>(".date-picker-trigger");
if (!trigger) {
return;
}
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const width = Math.min(PANEL_WIDTH, Math.max(220, viewportWidth - VIEWPORT_GAP * 2));
const measuredHeight = panelRef.value?.offsetHeight;
const panelHeight = measuredHeight || (props.mode === "date" ? 380 : 242);
const minLeft = VIEWPORT_GAP;
const maxLeft = Math.max(minLeft, viewportWidth - width - VIEWPORT_GAP);
const left = clamp(rect.left, minLeft, maxLeft);
const belowTop = rect.bottom + PANEL_OFFSET;
const aboveTop = rect.top - panelHeight - PANEL_OFFSET;
const fitsBelow = belowTop + panelHeight <= viewportHeight - VIEWPORT_GAP;
const fitsAbove = aboveTop >= VIEWPORT_GAP;
const top = fitsBelow
? belowTop
: fitsAbove
? aboveTop
: Math.max(VIEWPORT_GAP, viewportHeight - panelHeight - VIEWPORT_GAP);
panelStyle.value = {
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
width: `${Math.round(width)}px`,
};
}
function syncPanelFromValue(): void {
const parsed = props.mode === "month" ? parseMonth(props.modelValue) : parseDate(props.modelValue);
const fallback = new Date();
panelYear.value = parsed?.year ?? fallback.getFullYear();
panelMonth.value = parsed?.month ?? fallback.getMonth();
}
function previousPanel(): void {
if (props.mode === "month") {
panelYear.value -= 1;
return;
}
const date = new Date(panelYear.value, panelMonth.value - 1, 1);
panelYear.value = date.getFullYear();
panelMonth.value = date.getMonth();
}
function nextPanel(): void {
if (props.mode === "month") {
panelYear.value += 1;
return;
}
const date = new Date(panelYear.value, panelMonth.value + 1, 1);
panelYear.value = date.getFullYear();
panelMonth.value = date.getMonth();
}
function selectMonth(monthIndex: number): void {
commitValue(`${panelYear.value}-${String(monthIndex + 1).padStart(2, "0")}`);
}
function selectDate(dateValue: string): void {
commitValue(dateValue);
}
function selectCurrent(): void {
commitValue(props.mode === "month" ? currentMonthValue.value : todayDateValue.value);
}
function clearValue(): void {
commitValue("");
}
function commitValue(value: string): void {
emit("update:modelValue", value);
emit("change", value);
closePanel();
}
function parseMonth(value: string): { year: number; month: number } | null {
const match = /^(\d{4})-(\d{2})$/.exec(value || "");
if (!match) {
return null;
}
const month = Number(match[2]) - 1;
if (month < 0 || month > 11) {
return null;
}
return { year: Number(match[1]), month };
}
function parseDate(value: string): { year: number; month: number; day: number } | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || "");
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]) - 1;
const day = Number(match[3]);
const date = new Date(year, month, day);
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
return null;
}
return { year, month, day };
}
function formatDate(value: Date): string {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
</script>
<template>
<div ref="rootRef" class="date-picker" :class="{ 'is-open': open, 'is-disabled': disabled }">
<button class="date-picker-trigger" type="button" :disabled="disabled" @click="togglePanel">
<span :class="{ muted: !modelValue }">{{ displayValue }}</span>
<CalendarDays :size="17" aria-hidden="true" />
</button>
<Teleport to="body">
<div v-if="open" ref="panelRef" class="date-picker-panel" :style="panelStyle" role="dialog" aria-label="日期选择">
<div class="date-picker-panel-head">
<button class="date-picker-nav" type="button" title="上一个" @click="previousPanel">
<ChevronLeft :size="17" aria-hidden="true" />
</button>
<strong>{{ panelYear }}{{ mode === "date" ? `年${String(panelMonth + 1).padStart(2, "0")}月` : "年" }}</strong>
<button class="date-picker-nav" type="button" title="下一个" @click="nextPanel">
<ChevronRight :size="17" aria-hidden="true" />
</button>
</div>
<div v-if="mode === 'month'" class="month-grid">
<button
v-for="(month, index) in monthNames"
:key="month"
class="date-picker-cell"
:class="{ active: `${panelYear}-${String(index + 1).padStart(2, '0')}` === selectedMonthValue }"
type="button"
@click="selectMonth(index)"
>
{{ month }}
</button>
</div>
<div v-else class="calendar-shell">
<div class="calendar-week-row">
<span v-for="week in weekNames" :key="week">{{ week }}</span>
</div>
<div class="calendar-grid">
<button
v-for="cell in calendarCells"
:key="cell.dateValue"
class="date-picker-cell calendar-day"
:class="{ active: cell.dateValue === modelValue, today: cell.dateValue === todayDateValue, muted: cell.muted }"
type="button"
@click="selectDate(cell.dateValue)"
>
{{ cell.day }}
</button>
</div>
</div>
<div class="date-picker-actions">
<button v-if="clearable" class="date-picker-text-button" type="button" @click="clearValue">清空</button>
<button class="date-picker-text-button primary" type="button" @click="selectCurrent">
{{ mode === "month" ? "本月" : "今天" }}
</button>
</div>
</div>
</Teleport>
</div>
</template>
+286 -7
View File
@@ -1,9 +1,72 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PayrollResult } from "../api/types"; import { computed, ref, watch } from "vue";
defineProps<{ import type { PayrollException, PayrollResult } from "../api/types";
const props = withDefaults(defineProps<{
results: PayrollResult[]; results: PayrollResult[];
}>(); exceptions?: PayrollException[];
embedded?: boolean;
}>(), {
embedded: false,
exceptions: () => [],
});
const keyword = ref("");
const departmentFilter = ref("all");
const statusFilter = ref("all");
const anomalyFilter = ref("all");
const selectedResult = ref<PayrollResult | null>(null);
const exceptionMap = computed(() => {
const map = new Map<string, PayrollException[]>();
for (const item of props.exceptions) {
const keys = [item.employee_name, item.employee_no].filter(Boolean);
for (const key of keys) {
const list = map.get(key) || [];
list.push(item);
map.set(key, list);
}
}
return map;
});
const departmentOptions = computed(() =>
Array.from(new Set(props.results.map((item) => item.department).filter(Boolean))).sort((a, b) => a.localeCompare(b, "zh-CN")),
);
const filteredResults = computed(() => {
const query = keyword.value.trim().toLowerCase();
return props.results.filter((item) => {
const matchKeyword =
!query ||
[item.name, item.department, item.position, item.attendance_group, item.note]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query));
const matchDepartment = departmentFilter.value === "all" || item.department === departmentFilter.value;
const hasWarning = hasAnomaly(item);
const matchStatus =
statusFilter.value === "all" ||
(statusFilter.value === "normal" && !hasWarning) ||
(statusFilter.value === "warning" && hasWarning);
const matchAnomaly = anomalyFilter.value === "all" || anomalyTypes(item).includes(anomalyFilter.value);
return matchKeyword && matchDepartment && matchStatus && matchAnomaly;
});
});
watch(
filteredResults,
(items) => {
if (!items.length) {
selectedResult.value = null;
return;
}
if (!selectedResult.value || !items.includes(selectedResult.value)) {
selectedResult.value = items[0];
}
},
{ immediate: true },
);
function money(value: number | null): string { function money(value: number | null): string {
if (value === null || Number.isNaN(value)) { if (value === null || Number.isNaN(value)) {
@@ -29,19 +92,235 @@ function modeLabel(value: string): string {
}; };
return labels[value] || value; return labels[value] || value;
} }
function exceptionItems(item: PayrollResult): PayrollException[] {
return [...(exceptionMap.value.get(item.name) || []), ...(exceptionMap.value.get(item.attendance_group) || [])];
}
function anomalyTypes(item: PayrollResult): string[] {
const types = new Set<string>();
for (const exception of exceptionItems(item)) {
if (exception.exception_type) {
types.add(exception.exception_type);
}
}
if (item.note?.includes("未配置薪资档案")) {
types.add("missing_salary_profile");
}
if (item.penalized_late_count > 0 || item.late_deduction > 0) {
types.add("late_penalty");
}
if (item.missing_card_count > 0 || item.missing_card_deduction > 0) {
types.add("missing_card");
}
if (item.remaining_overtime_hours < 0) {
types.add("negative_remaining_overtime");
}
if ((item.net_salary || 0) <= 0) {
types.add("salary_calculation_failed");
}
return Array.from(types);
}
function hasAnomaly(item: PayrollResult): boolean {
return anomalyTypes(item).length > 0;
}
function anomalyLabel(item: PayrollResult): string {
const types = anomalyTypes(item);
if (!types.length) {
return "正常";
}
const labels: Record<string, string> = {
missing_employee: "未匹配员工",
missing_salary_profile: "缺薪资档案",
negative_remaining_overtime: "加班不足",
late_penalty: "迟到扣款",
missing_card: "缺卡",
missing_attendance: "缺考勤",
salary_calculation_failed: "工资异常",
};
return labels[types[0]] || "异常";
}
function anomalyClass(item: PayrollResult): string {
return hasAnomaly(item) ? "warning" : "green";
}
function anomalySummary(item: PayrollResult): string {
const parts = [
item.penalized_late_count > 0 ? `迟到 ${item.penalized_late_count} 次` : "",
item.missing_card_count > 0 ? `缺卡 ${item.missing_card_count} 次` : "",
item.remaining_overtime_hours < 0 ? "剩余加班不足" : "",
item.note?.includes("未配置薪资档案") ? "缺薪资档案" : "",
].filter(Boolean);
return parts.join(" / ");
}
function selectResult(item: PayrollResult): void {
selectedResult.value = item;
}
function closeDetail(): void {
selectedResult.value = null;
}
</script> </script>
<template> <template>
<section class="panel table-panel"> <section :class="embedded ? 'salary-detail-table-shell' : 'panel table-panel'">
<div class="panel-header"> <div v-if="!embedded" class="panel-header">
<div> <div>
<h2>员工工资结果</h2> <h2>员工工资结果</h2>
<p>共 {{ results.length }} 条</p> <p>共 {{ results.length }} 条</p>
</div> </div>
</div> </div>
<div v-if="results.length" class="table-wrap"> <template v-if="embedded">
<table class="data-table"> <div class="salary-summary-toolbar">
<label class="field salary-summary-search">
<span>搜索</span>
<input v-model="keyword" type="search" placeholder="员工、部门、岗位" />
</label>
<label class="field">
<span>部门</span>
<select v-model="departmentFilter">
<option value="all">全部部门</option>
<option v-for="department in departmentOptions" :key="department" :value="department">{{ department }}</option>
</select>
</label>
<label class="field">
<span>状态</span>
<select v-model="statusFilter">
<option value="all">全部状态</option>
<option value="normal">正常</option>
<option value="warning">有异常</option>
</select>
</label>
<label class="field">
<span>异常</span>
<select v-model="anomalyFilter">
<option value="all">全部异常</option>
<option value="late_penalty">迟到扣款</option>
<option value="missing_card">缺卡</option>
<option value="negative_remaining_overtime">加班不足</option>
<option value="missing_salary_profile">缺薪资档案</option>
<option value="salary_calculation_failed">工资异常</option>
</select>
</label>
</div>
<div v-if="filteredResults.length" class="salary-summary-layout">
<div class="table-wrap">
<table class="data-table salary-summary-table">
<thead>
<tr>
<th>姓名</th>
<th>部门/岗位</th>
<th>应发工资</th>
<th>实发工资</th>
<th>异常标记</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in filteredResults"
:key="`${item.name}-${item.department}-${item.position}`"
:class="{ 'is-selected': selectedResult === item, 'has-warning': hasAnomaly(item) }"
@click="selectResult(item)"
>
<td>
<button class="link-button salary-name-button" type="button" @click.stop="selectResult(item)">
<strong>{{ item.name }}</strong>
</button>
<span>{{ item.attendance_group || "默认考勤组" }}</span>
</td>
<td>
<strong>{{ item.department || "-" }}</strong>
<span>{{ item.position || "-" }}</span>
</td>
<td class="mono">{{ money(item.gross_salary) }}</td>
<td class="mono strong">{{ money(item.net_salary) }}</td>
<td>
<span class="status-chip" :class="anomalyClass(item)">{{ anomalyLabel(item) }}</span>
<small v-if="anomalySummary(item)" class="salary-anomaly-note">{{ anomalySummary(item) }}</small>
</td>
<td>
<button class="link-button" type="button" @click.stop="selectResult(item)">查看构成</button>
</td>
</tr>
</tbody>
</table>
</div>
<aside v-if="selectedResult" class="salary-detail-drawer" aria-label="员工薪资构成">
<div class="salary-detail-drawer-header">
<div>
<span>薪资构成</span>
<h3>{{ selectedResult.name }}</h3>
<p>{{ selectedResult.department || "-" }} · {{ selectedResult.position || "-" }}</p>
</div>
<button class="link-button" type="button" @click="closeDetail">关闭</button>
</div>
<div>
<span>薪资模式</span>
<strong>{{ modeLabel(selectedResult.salary_mode) }}</strong>
</div>
<div>
<span>出勤/缺勤</span>
<strong>{{ selectedResult.attendance_days }} / {{ selectedResult.absence_days }}</strong>
</div>
<div>
<span>实际工时</span>
<strong>{{ hours(selectedResult.actual_work_hours) }}</strong>
</div>
<div>
<span>剩余加班</span>
<strong>{{ hours(selectedResult.remaining_overtime_hours) }}</strong>
</div>
<div>
<span>请假工时</span>
<strong>{{ hours(selectedResult.leave_hours) }}</strong>
</div>
<div>
<span>迟到扣款</span>
<strong>{{ money(selectedResult.late_deduction) }}</strong>
</div>
<div>
<span>缺卡扣款</span>
<strong>{{ money(selectedResult.missing_card_deduction) }}</strong>
</div>
<div>
<span>提成</span>
<strong>{{ money(selectedResult.commission_amount) }}</strong>
</div>
<div>
<span>基础工资</span>
<strong>{{ money(selectedResult.base_pay) }}</strong>
</div>
<div>
<span>加班费</span>
<strong>{{ money(selectedResult.overtime_pay) }}</strong>
</div>
<div>
<span>应发工资</span>
<strong>{{ money(selectedResult.gross_salary) }}</strong>
</div>
<div>
<span>实发工资</span>
<strong>{{ money(selectedResult.net_salary) }}</strong>
</div>
<p class="salary-detail-note">{{ selectedResult.note || anomalySummary(selectedResult) || "无备注" }}</p>
</aside>
</div>
<div v-else class="empty-state">
<p>没有匹配当前筛选条件的工资结果</p>
</div>
</template>
<div v-else-if="results.length" class="table-wrap">
<table class="data-table salary-detail-table">
<thead> <thead>
<tr> <tr>
<th>姓名</th> <th>姓名</th>
+28 -23
View File
@@ -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", ...menu,
icon: LayoutDashboard, icon: menu.code === "dashboard" ? LayoutDashboard : backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
}, })),
...auth.menus.map((menu) => ({
...menu,
icon: 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="主导航">
<RouterLink <section v-for="section in menuSections" :key="section.code" class="nav-section">
v-for="item in menuItems" <p class="nav-section-title">{{ section.title }}</p>
:key="item.code" <RouterLink
:to="item.path" v-for="item in section.items"
class="nav-link" :key="item.code"
active-class="is-active" :to="item.path"
:title="item.name" class="nav-link"
@click="emit('close')" active-class="is-active"
> :title="`${section.title} / ${item.name}`"
<component :is="item.icon" :size="21" aria-hidden="true" /> @click="emit('close')"
<span>{{ item.name }}</span> >
</RouterLink> <component :is="item.icon" :size="20" aria-hidden="true" />
<span>{{ item.name }}</span>
</RouterLink>
</section>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
+8 -8
View File
@@ -11,14 +11,14 @@ defineProps<{
</script> </script>
<template> <template>
<section class="stat-card" :class="`tone-${tone || 'neutral'}`"> <section class="summary-metric" :class="`tone-${tone || 'neutral'}`">
<div class="stat-head"> <div class="summary-icon">
<div class="stat-icon"> <component :is="icon" :size="16" aria-hidden="true" />
<component :is="icon" :size="23" aria-hidden="true" />
</div>
<span v-if="meta" class="stat-meta">{{ meta }}</span>
</div> </div>
<p>{{ title }}</p> <div class="summary-copy">
<strong>{{ value }}</strong> <span>{{ title }}</span>
<strong>{{ value }}</strong>
</div>
<em v-if="meta" class="summary-meta">{{ meta }}</em>
</section> </section>
</template> </template>
+4 -1
View File
@@ -12,6 +12,7 @@ import {
X, X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, reactive, ref, watch } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ApiError, buildApiUrl } from "../api/http"; import { ApiError, buildApiUrl } from "../api/http";
import AppearancePanel from "./AppearancePanel.vue"; import AppearancePanel from "./AppearancePanel.vue";
@@ -22,6 +23,7 @@ defineEmits<{
}>(); }>();
const auth = useAuthStore(); const auth = useAuthStore();
const route = useRoute();
const showThemePanel = ref(false); const showThemePanel = ref(false);
const showProfileModal = ref(false); const showProfileModal = ref(false);
const savingProfile = ref(false); const savingProfile = ref(false);
@@ -50,6 +52,7 @@ const initials = computed(() => {
const source = auth.displayName || auth.user?.username || "U"; const source = auth.displayName || auth.user?.username || "U";
return source.slice(0, 2).toUpperCase(); return source.slice(0, 2).toUpperCase();
}); });
const showGlobalSearch = computed(() => route.path !== "/attendance/realtime");
const avatarSrc = computed(() => { const avatarSrc = computed(() => {
const avatarUrl = auth.user?.avatar_url || ""; const avatarUrl = auth.user?.avatar_url || "";
@@ -145,7 +148,7 @@ async function handleAvatarChange(event: Event): Promise<void> {
<button class="icon-button mobile-menu" type="button" title="打开菜单" @click="$emit('toggle-sidebar')"> <button class="icon-button mobile-menu" type="button" title="打开菜单" @click="$emit('toggle-sidebar')">
<Menu :size="22" aria-hidden="true" /> <Menu :size="22" aria-hidden="true" />
</button> </button>
<div class="global-search"> <div v-if="showGlobalSearch" class="global-search">
<Search :size="20" aria-hidden="true" /> <Search :size="20" aria-hidden="true" />
<input type="search" placeholder="搜索员工、任务或部门..." /> <input type="search" placeholder="搜索员工、任务或部门..." />
</div> </div>
+80
View File
@@ -0,0 +1,80 @@
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: "工资核算",
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", "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,
};
}
+28 -9
View File
@@ -6,11 +6,11 @@ import CommissionsView from "../views/CommissionsView.vue";
import ConfigCenterView from "../views/ConfigCenterView.vue"; import ConfigCenterView from "../views/ConfigCenterView.vue";
import DashboardView from "../views/DashboardView.vue"; 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 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 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,16 +44,27 @@ 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",
component: PayrollView, redirect: "/payroll/monthly",
meta: { permission: "payroll:excel:calculate" },
}, },
{ {
path: "payroll/jobs", path: "payroll/jobs",
name: "jobs", name: "jobs",
component: JobsView, redirect: "/payroll/monthly",
meta: { permission: "payroll:job:view" }, meta: { permission: "payroll:job:view" },
}, },
{ {
@@ -117,7 +128,12 @@ router.beforeEach(async (to) => {
const auth = useAuthStore(); const auth = useAuthStore();
if (!to.meta.public && !auth.loaded) { if (!to.meta.public && !auth.loaded) {
await auth.loadSession(); try {
await auth.loadSession();
} catch {
auth.logout();
return { path: "/login", query: { redirect: to.fullPath } };
}
} }
if (to.meta.public) { if (to.meta.public) {
@@ -137,11 +153,14 @@ router.beforeEach(async (to) => {
}); });
function firstAllowedPath(permissions: string[]): string { function firstAllowedPath(permissions: string[]): string {
if (permissions.includes("payroll:excel:calculate")) { if (permissions.includes("attendance:realtime:view")) {
return "/payroll"; return "/attendance/realtime";
}
if (permissions.includes("monthly_payroll:view")) {
return "/payroll/monthly";
} }
if (permissions.includes("payroll:job:view")) { if (permissions.includes("payroll:job:view")) {
return "/payroll/jobs"; return "/payroll/monthly";
} }
if (permissions.includes("commission:view")) { if (permissions.includes("commission:view")) {
return "/payroll/commissions"; return "/payroll/commissions";
+152 -57
View File
@@ -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();
}, },
}, },
@@ -696,48 +744,48 @@ function radiusVariables(radius: RadiusChoice): Record<string, string> {
function densityVariables(density: DensityChoice): Record<string, string> { function densityVariables(density: DensityChoice): Record<string, string> {
const densityMap: Record<DensityChoice, Record<string, string>> = { const densityMap: Record<DensityChoice, Record<string, string>> = {
compact: { compact: {
"--topbar-height": "64px", "--topbar-height": "60px",
"--nav-height": "42px", "--nav-height": "38px",
"--control-height": "40px", "--control-height": "38px",
"--base-font-size": "15px", "--base-font-size": "14px",
"--page-padding": "24px", "--page-padding": "20px",
"--content-gap": "18px", "--content-gap": "14px",
"--panel-padding": "20px", "--panel-padding": "16px",
"--table-cell-y": "12px", "--table-cell-y": "10px",
"--table-cell-x": "18px", "--table-cell-x": "14px",
}, },
default: { default: {
"--topbar-height": "66px",
"--nav-height": "44px",
"--control-height": "42px",
"--base-font-size": "15px",
"--page-padding": "26px",
"--content-gap": "18px",
"--panel-padding": "20px",
"--table-cell-y": "14px",
"--table-cell-x": "18px",
},
comfortable: {
"--topbar-height": "72px", "--topbar-height": "72px",
"--nav-height": "48px", "--nav-height": "50px",
"--control-height": "44px", "--control-height": "46px",
"--base-font-size": "16px", "--base-font-size": "16px",
"--page-padding": "32px", "--page-padding": "32px",
"--content-gap": "24px", "--content-gap": "24px",
"--panel-padding": "24px", "--panel-padding": "24px",
"--table-cell-y": "18px", "--table-cell-y": "18px",
"--table-cell-x": "24px", "--table-cell-x": "22px",
},
comfortable: {
"--topbar-height": "78px",
"--nav-height": "54px",
"--control-height": "48px",
"--base-font-size": "16px",
"--page-padding": "36px",
"--content-gap": "28px",
"--panel-padding": "28px",
"--table-cell-y": "22px",
"--table-cell-x": "28px",
}, },
large: { large: {
"--topbar-height": "88px", "--topbar-height": "82px",
"--nav-height": "62px", "--nav-height": "58px",
"--control-height": "56px", "--control-height": "52px",
"--base-font-size": "18px", "--base-font-size": "17px",
"--page-padding": "44px", "--page-padding": "38px",
"--content-gap": "34px", "--content-gap": "30px",
"--panel-padding": "34px", "--panel-padding": "30px",
"--table-cell-y": "28px", "--table-cell-y": "24px",
"--table-cell-x": "32px", "--table-cell-x": "28px",
}, },
}; };
return densityMap[density]; return densityMap[density];
@@ -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;
+54
View File
@@ -0,0 +1,54 @@
export interface ExcelMonthInfo {
month: string;
rangeLabel: string;
}
const SALARY_MONTH_RE = /^(\d{4})-(0[1-9]|1[0-2])$/;
export function isSalaryMonth(value: string): boolean {
return SALARY_MONTH_RE.test(value);
}
export function formatMonthRange(month: string): string {
if (!isSalaryMonth(month)) {
return "请选择核算月份";
}
const [yearText, monthText] = month.split("-");
const year = Number(yearText);
const monthIndex = Number(monthText) - 1;
const start = new Date(year, monthIndex, 1);
const end = new Date(year, monthIndex + 1, 0);
return `${formatDate(start)} 至 ${formatDate(end)}`;
}
export function extractMonthFromExcelFilename(filename: string): ExcelMonthInfo | null {
const compactDates = [...filename.matchAll(/((?:19|20)\d{2})(0[1-9]|1[0-2])([0-3]\d)/g)];
if (compactDates.length >= 2) {
const first = compactDates[0];
const last = compactDates[compactDates.length - 1];
const month = `${first[1]}-${first[2]}`;
return {
month,
rangeLabel: `${first[1]}/${first[2]}/${first[3]} 至 ${last[1]}/${last[2]}/${last[3]}`,
};
}
const dashedMonth = filename.match(/((?:19|20)\d{2})[-_/年](0[1-9]|1[0-2])/);
if (dashedMonth) {
const month = `${dashedMonth[1]}-${dashedMonth[2]}`;
return {
month,
rangeLabel: formatMonthRange(month),
};
}
return null;
}
function formatDate(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}/${month}/${day}`;
}
+112 -72
View File
@@ -5,11 +5,11 @@ import {
Loader2, Loader2,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
Save, Save,
Search, Search,
Trash2, Trash2,
Upload, Upload,
X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
@@ -23,6 +23,7 @@ import {
import { listEmployees } from "../api/employees"; import { listEmployees } from "../api/employees";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import type { CommissionRecord, CommissionRecordRequest, EmployeeRecord } from "../api/types"; import type { CommissionRecord, CommissionRecordRequest, EmployeeRecord } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
import StatCard from "../components/StatCard.vue"; import StatCard from "../components/StatCard.vue";
const employees = ref<EmployeeRecord[]>([]); const employees = ref<EmployeeRecord[]>([]);
@@ -33,6 +34,7 @@ const importing = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const editingId = ref<number | null>(null); const editingId = ref<number | null>(null);
const showCommissionModal = ref(false);
const page = ref(1); const page = ref(1);
const pageSize = ref(10); const pageSize = ref(10);
const total = ref(0); const total = ref(0);
@@ -97,8 +99,9 @@ async function submit(): Promise<void> {
await createCommission(normalizedForm()); await createCommission(normalizedForm());
successMessage.value = "提成记录已新增"; successMessage.value = "提成记录已新增";
} }
resetForm();
await loadData(); await loadData();
showCommissionModal.value = false;
resetForm();
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "提成记录保存失败"; errorMessage.value = error instanceof ApiError ? error.message : "提成记录保存失败";
} finally { } finally {
@@ -141,6 +144,8 @@ async function handleImport(event: Event): Promise<void> {
} }
function editRecord(record: CommissionRecord): void { function editRecord(record: CommissionRecord): void {
errorMessage.value = "";
successMessage.value = "";
editingId.value = record.id; editingId.value = record.id;
Object.assign(form, { Object.assign(form, {
employee_id: record.employee_id, employee_id: record.employee_id,
@@ -151,6 +156,7 @@ function editRecord(record: CommissionRecord): void {
business_ref: record.business_ref, business_ref: record.business_ref,
remark: record.remark, remark: record.remark,
}); });
showCommissionModal.value = true;
} }
function resetForm(): void { function resetForm(): void {
@@ -166,6 +172,19 @@ function resetForm(): void {
}); });
} }
function openCreateModal(): void {
errorMessage.value = "";
successMessage.value = "";
resetForm();
showCommissionModal.value = true;
}
function closeCommissionModal(): void {
showCommissionModal.value = false;
errorMessage.value = "";
resetForm();
}
function searchRecords(): void { function searchRecords(): void {
page.value = 1; page.value = 1;
loadData(); loadData();
@@ -199,6 +218,15 @@ function normalizedForm(): CommissionRecordRequest {
}; };
} }
function sourceTypeLabel(sourceType: string): string {
const labels: Record<string, string> = {
manual: "手工录入",
excel: "Excel导入",
dingtalk: "钉钉同步",
};
return labels[sourceType] || sourceType || "-";
}
function money(value: number): string { function money(value: number): string {
return new Intl.NumberFormat("zh-CN", { return new Intl.NumberFormat("zh-CN", {
style: "currency", style: "currency",
@@ -209,7 +237,7 @@ function money(value: number): string {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack commissions-page">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>提成管理</h1> <h1>提成管理</h1>
@@ -221,88 +249,39 @@ function money(value: number): string {
<span>{{ importing ? "导入中" : "导入Excel" }}</span> <span>{{ importing ? "导入中" : "导入Excel" }}</span>
<input accept=".xlsx,.xls" type="file" :disabled="importing" @change="handleImport" /> <input accept=".xlsx,.xls" type="file" :disabled="importing" @change="handleImport" />
</label> </label>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div> </div>
</header> </header>
<section class="stats-grid"> <section class="summary-strip commission-summary-strip">
<StatCard title="当前记录" :value="String(records.length)" :icon="FileSpreadsheet" tone="blue" meta="本页" /> <StatCard title="当前记录" :value="String(records.length)" :icon="FileSpreadsheet" tone="blue" meta="本页" />
<StatCard title="筛选总数" :value="String(total)" :icon="HandCoins" tone="neutral" meta="数据库" /> <StatCard title="筛选总数" :value="String(total)" :icon="HandCoins" tone="neutral" meta="数据库" />
<StatCard title="本页提成" :value="money(totalAmount)" :icon="HandCoins" tone="green" meta="当前页合计" /> <StatCard title="本页提成" :value="money(totalAmount)" :icon="HandCoins" tone="green" meta="当前页合计" />
</section> </section>
<section class="panel"> <p v-if="errorMessage && !showCommissionModal" class="form-error">{{ errorMessage }}</p>
<div class="panel-header"> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<div>
<h2>{{ editingId ? "编辑提成" : "新增提成" }}</h2>
<p>按员工和月份维护提成金额</p>
</div>
<button class="secondary-button" type="button" @click="resetForm">清空</button>
</div>
<form class="form-grid" @submit.prevent="submit">
<label class="field">
<span>员工</span>
<select v-model.number="form.employee_id" required>
<option :value="0">请选择员工</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.name }} / {{ employee.employee_no }}
</option>
</select>
</label>
<label class="field">
<span>月份</span>
<input v-model="form.commission_month" type="month" required />
</label>
<label class="field">
<span>提成类型</span>
<input v-model="form.commission_type" type="text" placeholder="销售提成、项目奖金..." />
</label>
<label class="field">
<span>金额</span>
<input v-model.number="form.amount" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>业务单号</span>
<input v-model="form.business_ref" type="text" />
</label>
<label class="field span-row">
<span>备注</span>
<textarea v-model="form.remark" />
</label>
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : editingId ? "保存修改" : "新增提成" }}</span>
</button>
</div>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
</section>
<section class="panel table-panel"> <section class="panel table-panel commission-list-panel">
<div class="panel-header"> <div class="panel-header commission-list-header">
<div> <div>
<h2>提成列表</h2> <h2>提成列表</h2>
<p>第 {{ page }} / {{ totalPages }} 页,共 {{ total }} 条</p> <p>第 {{ page }} / {{ totalPages }} 页,共 {{ total }} 条</p>
</div> </div>
<button class="primary-button" type="button" @click="openCreateModal">
<Plus :size="18" aria-hidden="true" />
<span>新增提成</span>
</button>
</div> </div>
<form class="table-filter-bar" @submit.prevent="searchRecords"> <form class="table-filter-bar commission-filter-bar" @submit.prevent="searchRecords">
<label class="field"> <label class="field commission-keyword-field">
<span>关键字</span> <span>关键字</span>
<input v-model="filters.keyword" type="search" placeholder="员工、类型或业务单号" /> <input v-model="filters.keyword" type="search" placeholder="员工、类型或业务单号" />
</label> </label>
<label class="field"> <label class="field commission-month-field">
<span>月份</span> <span>月份</span>
<input v-model="filters.commission_month" type="month" /> <DatePicker v-model="filters.commission_month" mode="month" placeholder="请选择月份" />
</label> </label>
<button class="primary-button" type="submit" :disabled="loading"> <button class="primary-button commission-search-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" /> <Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span> <span>{{ loading ? "查询中" : "查询" }}</span>
@@ -331,18 +310,18 @@ function money(value: number): string {
<td class="mono">{{ record.commission_month }}</td> <td class="mono">{{ record.commission_month }}</td>
<td>{{ record.commission_type }}</td> <td>{{ record.commission_type }}</td>
<td class="mono strong">{{ money(record.amount) }}</td> <td class="mono strong">{{ money(record.amount) }}</td>
<td><span class="status-chip neutral">{{ record.source_type }}</span></td> <td><span class="status-chip neutral">{{ sourceTypeLabel(record.source_type) }}</span></td>
<td>{{ record.business_ref || "-" }}</td> <td>{{ record.business_ref || "-" }}</td>
<td class="note-cell">{{ record.remark || "-" }}</td> <td class="note-cell">{{ record.remark || "-" }}</td>
<td> <td class="commission-action-cell">
<button class="link-button" type="button" @click="editRecord(record)"> <div class="row-action-group commission-row-actions">
<button class="icon-button table-action-icon" type="button" title="编辑提成" aria-label="编辑提成" @click="editRecord(record)">
<Pencil :size="15" aria-hidden="true" /> <Pencil :size="15" aria-hidden="true" />
<span>编辑</span>
</button> </button>
<button class="link-button danger" type="button" @click="removeRecord(record)"> <button class="icon-button table-action-icon danger" type="button" title="删除提成" aria-label="删除提成" @click="removeRecord(record)">
<Trash2 :size="15" aria-hidden="true" /> <Trash2 :size="15" aria-hidden="true" />
<span>删除</span>
</button> </button>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -356,5 +335,66 @@ function money(value: number): string {
<button class="secondary-button" type="button" :disabled="loading || page >= totalPages" @click="nextPage">下一页</button> <button class="secondary-button" type="button" :disabled="loading || page >= totalPages" @click="nextPage">下一页</button>
</div> </div>
</section> </section>
<Teleport to="body">
<div v-if="showCommissionModal" class="modal-backdrop commission-modal-backdrop" @click.self="closeCommissionModal">
<section class="profile-modal commission-modal" aria-label="提成维护">
<div class="modal-header">
<div>
<h2>{{ editingId ? "编辑提成" : "新增提成" }}</h2>
<p>按员工和月份维护提成金额</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="closeCommissionModal">
<X :size="21" aria-hidden="true" />
</button>
</div>
<form class="commission-form" @submit.prevent="submit">
<div class="commission-form-grid">
<label class="field">
<span>员工</span>
<select v-model.number="form.employee_id" required>
<option :value="0">请选择员工</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.name }} / {{ employee.employee_no }}
</option>
</select>
</label>
<label class="field">
<span>月份</span>
<DatePicker v-model="form.commission_month" mode="month" placeholder="请选择月份" />
</label>
<label class="field">
<span>提成类型</span>
<input v-model="form.commission_type" type="text" placeholder="销售提成、项目奖金..." />
</label>
<label class="field">
<span>金额</span>
<input v-model.number="form.amount" min="0" step="0.01" type="number" />
</label>
<label class="field commission-business-field">
<span>业务单号</span>
<input v-model="form.business_ref" type="text" />
</label>
<label class="field commission-remark-field">
<span>备注</span>
<textarea v-model="form.remark" rows="2" />
</label>
<p v-if="errorMessage" class="form-error commission-modal-message">{{ errorMessage }}</p>
<div class="form-actions align-right commission-form-actions">
<button class="secondary-button" type="button" @click="resetForm">清空</button>
<button class="secondary-button" type="button" @click="closeCommissionModal">取消</button>
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : editingId ? "保存修改" : "新增提成" }}</span>
</button>
</div>
</div>
</form>
</section>
</div>
</Teleport>
</div> </div>
</template> </template>
+308 -46
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Loader2, RefreshCw, Save, Settings2, SlidersHorizontal } from "@lucide/vue"; import { Loader2, Save, Settings2, SlidersHorizontal } from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ensureDefaultConfigs, listSalaryConfigs, updateSalaryConfig } from "../api/configs"; import { ensureDefaultConfigs, listSalaryConfigs, updateSalaryConfig } from "../api/configs";
@@ -14,7 +14,102 @@ const savingKey = ref("");
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const groups = computed(() => Array.from(new Set(configs.value.map((item) => item.config_group)))); type RulePresentation = {
name: string;
purpose: string;
};
const groupLabels: Record<string, string> = {
attendance: "考勤规则",
payroll: "薪资规则",
overtime: "加班规则",
leave: "请假规则",
penalty: "扣款规则",
};
const groupOrder = ["attendance", "payroll", "overtime", "leave", "penalty"];
const weekdayOptions = [
{ value: 0, label: "周一" },
{ value: 1, label: "周二" },
{ value: 2, label: "周三" },
{ value: 3, label: "周四" },
{ value: 4, label: "周五" },
{ value: 5, label: "周六" },
{ value: 6, label: "周日" },
];
const rulePresentation: Record<string, RulePresentation> = {
"attendance.on_work_time": {
name: "上班时间",
purpose: "员工晚于这个时间打卡,系统会开始判断迟到。",
},
"attendance.off_work_time": {
name: "下班时间",
purpose: "用于判断下班后是否产生加班工时。",
},
"attendance.overtime_round_minutes": {
name: "加班取整方式",
purpose: "下班打卡超过下班时间后,加班时长按这个分钟数向下取整。",
},
"attendance.standard_daily_hours": {
name: "每天标准工时",
purpose: "计时工资和请假折算会按这个工时作为一天。",
},
"attendance.minor_late_minutes": {
name: "轻微迟到范围",
purpose: "迟到不超过这个分钟数,先按轻微迟到统计。",
},
"attendance.minor_late_free_times": {
name: "轻微迟到免扣次数",
purpose: "轻微迟到在这个次数以内不扣款,超过后按次扣款。",
},
"attendance.late_penalty": {
name: "迟到扣款金额",
purpose: "超过轻微迟到免扣次数,或迟到超过轻微范围时,每次扣这个金额。",
},
"attendance.missing_card_penalty": {
name: "缺卡扣款金额",
purpose: "员工上班或下班缺少打卡记录时,每次扣这个金额。",
},
"attendance.weekend_days": {
name: "周末加班日",
purpose: "用于识别哪些日期按周末加班规则计算。",
},
"attendance.legal_holidays": {
name: "法定节假日",
purpose: "用于识别哪些日期按节假日加班规则计算。",
},
"attendance.leave_keywords": {
name: "请假识别词",
purpose: "钉钉或 Excel 中出现这些文字时,系统会按请假或调休处理。",
},
"payroll.default_overtime_rate": {
name: "工作日加班单价",
purpose: "员工薪资档案没有维护加班单价时,默认按这个单价计算。",
},
"payroll.default_weekend_overtime_rate": {
name: "周末加班单价",
purpose: "员工薪资档案没有维护周末加班单价时,默认按这个单价计算。",
},
"payroll.default_holiday_overtime_rate": {
name: "节假日加班单价",
purpose: "员工薪资档案没有维护节假日加班单价时,默认按这个单价计算。",
},
};
const groupOptions = computed(() => {
const values = Array.from(new Set(configs.value.map((item) => item.config_group))).filter(Boolean);
return values
.sort((left, right) => {
const leftIndex = groupOrder.indexOf(left);
const rightIndex = groupOrder.indexOf(right);
if (leftIndex !== -1 || rightIndex !== -1) {
return (leftIndex === -1 ? 99 : leftIndex) - (rightIndex === -1 ? 99 : rightIndex);
}
return groupLabel(left).localeCompare(groupLabel(right), "zh-Hans-CN");
})
.map((value) => ({ value, label: groupLabel(value) }));
});
const visibleConfigs = computed(() => { const visibleConfigs = computed(() => {
if (!activeGroup.value) { if (!activeGroup.value) {
return configs.value; return configs.value;
@@ -22,6 +117,7 @@ const visibleConfigs = computed(() => {
return configs.value.filter((item) => item.config_group === activeGroup.value); return configs.value.filter((item) => item.config_group === activeGroup.value);
}); });
const enabledCount = computed(() => configs.value.filter((item) => item.is_enabled).length); const enabledCount = computed(() => configs.value.filter((item) => item.is_enabled).length);
const activeGroupLabel = computed(() => (activeGroup.value ? groupLabel(activeGroup.value) : "全部规则"));
onMounted(loadData); onMounted(loadData);
@@ -73,87 +169,253 @@ async function saveConfig(config: SalaryConfigRecord): Promise<void> {
savingKey.value = ""; savingKey.value = "";
} }
} }
function groupLabel(group: string): string {
return groupLabels[group] || "其他规则";
}
function configTitle(config: SalaryConfigRecord): string {
return rulePresentation[config.config_key]?.name || config.config_name || "未命名规则";
}
function configPurpose(config: SalaryConfigRecord): string {
return rulePresentation[config.config_key]?.purpose || config.remark || "影响工资核算时的自动判断规则。";
}
function isLongValue(config: SalaryConfigRecord): boolean {
return isTextListRule(config) || config.config_value.length > 18;
}
function formatConfigValue(config: SalaryConfigRecord): string {
const value = config.config_value.trim();
switch (config.config_key) {
case "attendance.on_work_time":
return value ? `${value} 后开始判断迟到` : "未设置上班时间";
case "attendance.off_work_time":
return value ? `${value} 后判断加班` : "未设置下班时间";
case "attendance.overtime_round_minutes":
return value ? `按 ${value} 分钟向下取整` : "未设置取整方式";
case "attendance.standard_daily_hours":
return value ? `每天 ${value} 小时` : "未设置标准工时";
case "attendance.minor_late_minutes":
return value ? `${value} 分钟内算轻微迟到` : "未设置轻微迟到范围";
case "attendance.minor_late_free_times":
return value ? `每月前 ${value} 次不扣款` : "未设置免扣次数";
case "attendance.late_penalty":
return value ? `每次扣 ${formatMoney(value)}` : "未设置迟到扣款";
case "attendance.missing_card_penalty":
return value ? `每次扣 ${formatMoney(value)}` : "未设置缺卡扣款";
case "attendance.weekend_days":
return `按 ${formatWeekdays(value)} 识别周末`;
case "attendance.legal_holidays":
return formatHolidaySummary(value);
case "attendance.leave_keywords":
return formatKeywordSummary(value);
case "payroll.default_overtime_rate":
case "payroll.default_weekend_overtime_rate":
case "payroll.default_holiday_overtime_rate":
return value ? `${formatMoney(value)} / 小时` : "未设置默认单价";
default:
return value || "未设置";
}
}
function formatMoney(value: string): string {
const amount = Number(value);
if (!Number.isFinite(amount)) {
return value;
}
return `¥${amount.toFixed(2).replace(/\.00$/, "")}`;
}
function formatWeekdays(value: string): string {
const weekdayNames = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const items = parseArray(value);
const labels = items
.map((item) => Number(item))
.filter((item) => Number.isInteger(item) && item >= 0 && item <= 6)
.map((item) => weekdayNames[item]);
return labels.length ? labels.join("、") : "未设置";
}
function formatHolidaySummary(value: string): string {
const items = parseArray(value);
if (!items.length) {
return "暂未设置节假日";
}
return `已设置 ${items.length} 天节假日`;
}
function formatKeywordSummary(value: string): string {
const items = parseArray(value).map((item) => String(item)).filter(Boolean);
if (!items.length) {
return "暂未设置请假识别词";
}
const visibleItems = items.slice(0, 5).join("、");
return items.length > 5 ? `${visibleItems} 等 ${items.length} 个词` : visibleItems;
}
function parseArray(value: string): unknown[] {
try {
const parsed = JSON.parse(value || "[]");
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function isWeekdayRule(config: SalaryConfigRecord): boolean {
return config.config_key === "attendance.weekend_days";
}
function isTextListRule(config: SalaryConfigRecord): boolean {
return ["attendance.legal_holidays", "attendance.leave_keywords"].includes(config.config_key);
}
function displayEditValue(config: SalaryConfigRecord): string {
if (config.config_key === "attendance.legal_holidays") {
return parseArray(config.config_value).map((item) => String(item)).filter(Boolean).join("\n");
}
if (config.config_key === "attendance.leave_keywords") {
return parseArray(config.config_value).map((item) => String(item)).filter(Boolean).join("、");
}
return config.config_value;
}
function updateDisplayValue(config: SalaryConfigRecord, event: Event): void {
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
const value = target.value;
if (config.config_key === "attendance.legal_holidays" || config.config_key === "attendance.leave_keywords") {
config.config_value = JSON.stringify(splitListValue(value), null, 0);
return;
}
config.config_value = value;
}
function splitListValue(value: string): string[] {
return value
.split(/[\n,,、;;\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function textListPlaceholder(config: SalaryConfigRecord): string {
if (config.config_key === "attendance.legal_holidays") {
return "一行一个日期,例如 2026-10-01";
}
if (config.config_key === "attendance.leave_keywords") {
return "例如:调休、请假、事假、病假";
}
return "请输入当前设置";
}
function isWeekdaySelected(config: SalaryConfigRecord, weekday: number): boolean {
return parseArray(config.config_value).map((item) => Number(item)).includes(weekday);
}
function toggleWeekday(config: SalaryConfigRecord, weekday: number, event: Event): void {
const target = event.target as HTMLInputElement;
const selected = new Set(parseArray(config.config_value).map((item) => Number(item)).filter((item) => Number.isInteger(item)));
if (target.checked) {
selected.add(weekday);
} else {
selected.delete(weekday);
}
config.config_value = JSON.stringify(Array.from(selected).sort((left, right) => left - right));
}
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>规则配置</h1> <h1>薪资规则设置</h1>
<p>维护考勤扣款、加班取整、加班单价和请假识别规则。</p> <p>设置考勤、加班、迟到、缺卡和请假等工资核算规则。</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="secondary-button" type="button" :disabled="loading" @click="seedDefaults"> <button class="secondary-button" type="button" :disabled="loading" @click="seedDefaults">
<Settings2 :size="18" aria-hidden="true" /> <Settings2 :size="18" aria-hidden="true" />
<span>补齐默认项</span> <span>恢复默认规则</span>
</button>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button> </button>
</div> </div>
</header> </header>
<section class="stats-grid"> <section class="summary-strip">
<StatCard title="配置项" :value="String(configs.length)" :icon="SlidersHorizontal" tone="blue" meta="全部规则" /> <StatCard title="规则总数" :value="String(configs.length)" :icon="SlidersHorizontal" tone="blue" meta="可维护规则" />
<StatCard title="启用项" :value="String(enabledCount)" :icon="Settings2" tone="green" meta="参与计算" /> <StatCard title="使用中" :value="String(enabledCount)" :icon="Settings2" tone="green" meta="会参与核算" />
<StatCard title="当前分组" :value="activeGroup || '全部'" :icon="Settings2" tone="neutral" meta="筛选" /> <StatCard title="当前查看" :value="activeGroupLabel" :icon="Settings2" tone="neutral" meta="规则范围" />
</section> </section>
<section class="panel table-panel"> <section class="panel table-panel rules-panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>配置列表</h2> <h2>规则清单</h2>
<p>{{ visibleConfigs.length }} 项</p> <p>共 {{ visibleConfigs.length }} 项,修改后点击保存生效</p>
</div> </div>
<select v-model="activeGroup" class="compact-select"> <select v-model="activeGroup" class="compact-select">
<option value="">全部分组</option> <option value="">全部规则</option>
<option v-for="group in groups" :key="group" :value="group">{{ group }}</option> <option v-for="group in groupOptions" :key="group.value" :value="group.value">{{ group.label }}</option>
</select> </select>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table rules-table">
<thead> <thead>
<tr> <tr>
<th>配置</th> <th>规则</th>
<th>分组</th> <th>当前设置</th>
<th>类型</th> <th>是否使用</th>
<th>值</th> <th>用途说明</th>
<th>启用</th>
<th>说明</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="config in visibleConfigs" :key="config.config_key"> <tr v-for="config in visibleConfigs" :key="config.config_key">
<td> <td class="rule-name-cell">
<strong>{{ config.config_name }}</strong> <strong>{{ configTitle(config) }}</strong>
<span>{{ config.config_key }}</span> <span class="rule-group-chip">{{ groupLabel(config.config_group) }}</span>
</td> </td>
<td> <td class="rule-value-cell">
<input v-model="config.config_group" class="table-input" type="text" /> <span class="rule-value-preview">{{ formatConfigValue(config) }}</span>
</td> <div v-if="isWeekdayRule(config)" class="weekday-toggle-group" :aria-label="`${configTitle(config)}当前设置`">
<td> <label
<select v-model="config.value_type" class="table-input"> v-for="weekday in weekdayOptions"
<option value="string">string</option> :key="weekday.value"
<option value="integer">integer</option> class="weekday-toggle"
<option value="number">number</option> :class="{ 'is-selected': isWeekdaySelected(config, weekday.value) }"
<option value="boolean">boolean</option> >
<option value="json">json</option> <input
</select> type="checkbox"
</td> :checked="isWeekdaySelected(config, weekday.value)"
<td> @change="toggleWeekday(config, weekday.value, $event)"
<textarea v-model="config.config_value" class="table-textarea" /> />
<span>{{ weekday.label }}</span>
</label>
</div>
<textarea
v-else-if="isLongValue(config)"
:value="displayEditValue(config)"
class="table-textarea rule-value-input"
:placeholder="textListPlaceholder(config)"
:aria-label="`${configTitle(config)}当前设置`"
@input="updateDisplayValue(config, $event)"
/>
<input
v-else
:value="displayEditValue(config)"
class="table-input rule-value-input"
type="text"
:aria-label="`${configTitle(config)}当前设置`"
@input="updateDisplayValue(config, $event)"
/>
</td> </td>
<td> <td>
<label class="checkbox-line"> <label class="checkbox-line">
<input v-model="config.is_enabled" type="checkbox" /> <input v-model="config.is_enabled" type="checkbox" />
<span>{{ config.is_enabled ? "启用" : "停用" }}</span> <span>{{ config.is_enabled ? "使用" : "不用" }}</span>
</label> </label>
</td> </td>
<td> <td class="rule-purpose">
<textarea v-model="config.remark" class="table-textarea" /> <p>{{ configPurpose(config) }}</p>
</td> </td>
<td> <td>
<button class="link-button" type="button" :disabled="savingKey === config.config_key" @click="saveConfig(config)"> <button class="link-button" type="button" :disabled="savingKey === config.config_key" @click="saveConfig(config)">
+97 -39
View File
@@ -6,22 +6,75 @@ import {
ShieldCheck, ShieldCheck,
UsersRound, UsersRound,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { downloadPayrollFile, readRecentJobs } from "../api/payroll"; import { exportMonthlyRun, listMonthlyRuns } from "../api/monthlyPayroll";
import type { RecentPayrollJob } from "../api/types"; import type { MonthlyPayrollRun } from "../api/types";
import StatCard from "../components/StatCard.vue"; import StatCard from "../components/StatCard.vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
const auth = useAuthStore(); const auth = useAuthStore();
const recentJobs = ref<RecentPayrollJob[]>(readRecentJobs()); const runs = ref<MonthlyPayrollRun[]>([]);
const loading = ref(false);
const errorMessage = ref("");
const latestJob = computed(() => recentJobs.value[0]); const latestRun = computed(() => runs.value[0]);
const totalEmployees = computed(() => recentJobs.value.reduce((total, job) => total + job.employee_count, 0)); const recentRuns = computed(() => runs.value.slice(0, 5));
const totalEmployees = computed(() => runs.value.reduce((total, run) => total + run.employee_count, 0));
const chartBars = [32, 48, 58, 76, 68, 88]; const chartBars = [32, 48, 58, 76, 68, 88];
async function download(job: RecentPayrollJob): Promise<void> { onMounted(loadRuns);
await downloadPayrollFile(job.download_url, job.output_file);
async function loadRuns(): Promise<void> {
if (!auth.hasPermission("monthly_payroll:view")) {
return;
}
loading.value = true;
errorMessage.value = "";
try {
runs.value = await listMonthlyRuns();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "核算历史加载失败";
} finally {
loading.value = false;
}
}
async function download(run: MonthlyPayrollRun): Promise<void> {
await exportMonthlyRun(run.id);
}
function sourceText(sourceType: string | undefined): string {
const map: Record<string, string> = {
excel: "Excel导入",
dingtalk: "钉钉同步",
manual: "手动重算",
};
return sourceType ? map[sourceType] || sourceType : "暂无";
}
function statusText(status: string | undefined): string {
const map: Record<string, string> = {
draft: "草稿",
calculated: "已计算",
reviewed: "已复核",
locked: "已锁定",
failed: "失败",
};
return status ? map[status] || status : "-";
}
function dateTime(value: string | null | undefined): string {
return value ? new Date(value).toLocaleString() : "-";
}
function money(value: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 0,
}).format(value || 0);
} }
</script> </script>
@@ -33,38 +86,38 @@ async function download(job: RecentPayrollJob): Promise<void> {
<p>实时财务状况与薪资指标。</p> <p>实时财务状况与薪资指标。</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<RouterLink v-if="auth.hasPermission('payroll:excel:calculate')" class="primary-button" to="/payroll"> <RouterLink v-if="auth.hasPermission('monthly_payroll:view')" class="primary-button" to="/payroll/monthly">
<Banknote :size="19" aria-hidden="true" /> <Banknote :size="19" aria-hidden="true" />
<span>新建计算</span> <span>进入工资核算</span>
</RouterLink> </RouterLink>
<RouterLink v-if="auth.hasPermission('payroll:job:view')" class="secondary-button" to="/payroll/jobs"> <RouterLink v-if="auth.hasPermission('monthly_payroll:view')" class="secondary-button" to="/payroll/monthly">
<ClipboardList :size="19" aria-hidden="true" /> <ClipboardList :size="19" aria-hidden="true" />
<span>计算记录</span> <span>核算历史</span>
</RouterLink> </RouterLink>
</div> </div>
</header> </header>
<section class="stats-grid"> <section class="summary-strip">
<StatCard <StatCard
title="最近计算员工" title="最近核算人数"
:value="String(latestJob?.employee_count || 0)" :value="String(latestRun?.employee_count || 0)"
:icon="UsersRound" :icon="UsersRound"
tone="blue" tone="blue"
meta="当前浏览器" :meta="latestRun?.salary_month || '暂无月份'"
/> />
<StatCard <StatCard
title="任务记录" title="核算记录"
:value="String(recentJobs.length)" :value="String(runs.length)"
:icon="ClipboardList" :icon="ClipboardList"
tone="neutral" tone="neutral"
meta="最近 12 条" meta="月度历史"
/> />
<StatCard <StatCard
title="累计员工次数" title="累计核算人数"
:value="String(totalEmployees)" :value="String(totalEmployees)"
:icon="Gauge" :icon="Gauge"
tone="green" tone="green"
meta="本地记录" meta="全部历史"
/> />
<StatCard <StatCard
title="当前权限" title="当前权限"
@@ -86,15 +139,15 @@ async function download(job: RecentPayrollJob): Promise<void> {
</div> </div>
<dl class="compact-list"> <dl class="compact-list">
<div> <div>
<dt>最近任务</dt> <dt>最近来源</dt>
<dd>{{ latestJob?.job_id || "暂无" }}</dd> <dd>{{ sourceText(latestRun?.source_type) }}</dd>
</div> </div>
<div> <div>
<dt>任务来源</dt> <dt>最近生成</dt>
<dd>{{ latestJob?.source_type || "-" }}</dd> <dd>{{ dateTime(latestRun?.created_at) }}</dd>
</div> </div>
</dl> </dl>
<RouterLink v-if="auth.hasPermission('payroll:dingtalk:calculate')" class="primary-button full" to="/payroll"> <RouterLink v-if="auth.hasPermission('monthly_payroll:calculate')" class="primary-button full" to="/payroll/monthly">
<span>立即同步</span> <span>立即同步</span>
</RouterLink> </RouterLink>
</div> </div>
@@ -123,37 +176,42 @@ async function download(job: RecentPayrollJob): Promise<void> {
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>最近计算记录</h2> <h2>最近核算记录</h2>
<p>{{ recentJobs.length }} 条</p> <p>{{ runs.length }} 条</p>
</div> </div>
<RouterLink class="link-button" to="/payroll/jobs">查看全部</RouterLink> <RouterLink class="link-button" to="/payroll/monthly">查看全部</RouterLink>
</div> </div>
<div class="table-wrap"> <div v-if="errorMessage" class="inline-alert">{{ errorMessage }}</div>
<div v-if="recentRuns.length" class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>任务编号</th> <th>工资月份</th>
<th>来源</th> <th>来源</th>
<th>状态</th>
<th>员工数</th> <th>员工数</th>
<th>实发合计</th>
<th>创建时间</th> <th>创建时间</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="job in recentJobs.slice(0, 5)" :key="job.job_id"> <tr v-for="run in recentRuns" :key="run.id">
<td class="mono strong">{{ job.job_id }}</td> <td><strong>{{ run.salary_month }}</strong></td>
<td><span class="status-chip neutral">{{ job.source_type }}</span></td> <td><span class="status-chip neutral">{{ sourceText(run.source_type) }}</span></td>
<td>{{ job.employee_count }}</td> <td><span class="status-chip" :class="run.status === 'locked' ? 'green' : 'neutral'">{{ statusText(run.status) }}</span></td>
<td>{{ new Date(job.created_at).toLocaleString() }}</td> <td>{{ run.employee_count }}</td>
<td class="mono strong">{{ money(run.net_total) }}</td>
<td>{{ dateTime(run.created_at) }}</td>
<td> <td>
<button class="link-button" type="button" @click="download(job)">下载</button> <button class="link-button" type="button" @click="download(run)">导出</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="!recentJobs.length" class="empty-state"> <div v-else class="empty-state">
<p>暂无计算记录</p> <p>{{ loading ? "正在加载核算历史..." : "暂无核算历史" }}</p>
</div> </div>
</section> </section>
</div> </div>
+29 -34
View File
@@ -3,7 +3,6 @@ import {
Loader2, Loader2,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
Search, Search,
UsersRound, UsersRound,
X, X,
@@ -31,7 +30,8 @@ const pageSize = ref(10);
const total = ref(0); const total = ref(0);
const activeEmployeeTotal = ref(0); const activeEmployeeTotal = ref(0);
const filters = reactive({ const filters = reactive({
keyword: "", employee_info_keyword: "",
organization_keyword: "",
employment_status: "", employment_status: "",
}); });
@@ -133,7 +133,8 @@ async function loadEmployees(): Promise<void> {
const response = await listEmployeePage({ const response = await listEmployeePage({
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
keyword: filters.keyword.trim(), employee_keyword: filters.employee_info_keyword.trim(),
organization_keyword: filters.organization_keyword.trim(),
employment_status: filters.employment_status, employment_status: filters.employment_status,
}); });
employees.value = response.items; employees.value = response.items;
@@ -284,28 +285,11 @@ function nextPage(): void {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack employees-page">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>员工维护</h1> <h1>员工维护</h1>
<p>维护员工档案、钉钉用户 ID 与在职状态。</p> <p>管理员工资料,工资核算会优先使用这里的信息。</p>
</div>
<div class="employee-header-actions">
<div class="employee-inline-metric" aria-label="在职员工">
<span class="employee-inline-icon">
<UsersRound :size="21" aria-hidden="true" />
</span>
<div>
<span>在职员工</span>
<strong>{{ activeEmployeeTotal }}</strong>
</div>
<em>可参与薪资计算</em>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="refreshEmployees">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div> </div>
</header> </header>
@@ -313,9 +297,15 @@ function nextPage(): void {
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel table-panel"> <section class="panel table-panel">
<div class="panel-header"> <div class="panel-header employee-list-header">
<div> <div class="employee-list-title">
<h2>员工列表</h2> <h2>员工列表</h2>
<span class="employee-inline-metric" aria-label="在职员工">
<UsersRound :size="15" aria-hidden="true" />
<span>在职员工</span>
<strong>{{ activeEmployeeTotal }}</strong>
<em>人</em>
</span>
</div> </div>
<button class="primary-button" type="button" @click="openCreateModal"> <button class="primary-button" type="button" @click="openCreateModal">
<Plus :size="18" aria-hidden="true" /> <Plus :size="18" aria-hidden="true" />
@@ -323,11 +313,15 @@ function nextPage(): void {
</button> </button>
</div> </div>
<form class="table-filter-bar employee-filter-bar" @submit.prevent="searchEmployees"> <form class="table-filter-bar employee-filter-bar" @submit.prevent="searchEmployees">
<label class="field"> <label class="field employee-filter-employee">
<span>关键字</span> <span>找员工</span>
<input v-model="filters.keyword" type="search" placeholder="编号、姓名、钉钉ID、部门或岗位" /> <input v-model="filters.employee_info_keyword" type="search" placeholder="输入姓名、编号或手机号" />
</label> </label>
<label class="field"> <label class="field employee-filter-organization">
<span>找部门/岗位</span>
<input v-model="filters.organization_keyword" type="search" placeholder="输入部门或岗位名称" />
</label>
<label class="field employee-filter-status">
<span>状态</span> <span>状态</span>
<select v-model="filters.employment_status"> <select v-model="filters.employment_status">
<option value="">全部状态</option> <option value="">全部状态</option>
@@ -335,7 +329,7 @@ function nextPage(): void {
<option value="inactive">离职</option> <option value="inactive">离职</option>
</select> </select>
</label> </label>
<button class="primary-button" type="submit" :disabled="loading"> <button class="primary-button employee-search-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" /> <Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span> <span>{{ loading ? "查询中" : "查询" }}</span>
@@ -346,7 +340,7 @@ function nextPage(): void {
<thead> <thead>
<tr> <tr>
<th>员工</th> <th>员工</th>
<th>钉钉用户ID</th> <th>钉钉账号</th>
<th>部门/岗位</th> <th>部门/岗位</th>
<th>手机号</th> <th>手机号</th>
<th>状态</th> <th>状态</th>
@@ -361,14 +355,15 @@ function nextPage(): void {
<strong>{{ employee.name }}</strong> <strong>{{ employee.name }}</strong>
<span>{{ employee.employee_no }}</span> <span>{{ employee.employee_no }}</span>
</td> </td>
<td class="mono">{{ employee.dingtalk_user_id || "-" }}</td> <td class="mono">{{ employee.dingtalk_user_id || "未绑定" }}</td>
<td> <td>
<strong>{{ employee.department || "-" }}</strong> <strong>{{ employee.department || "-" }}</strong>
<span>{{ employee.position || "-" }}</span> <span>{{ employee.position || "-" }}</span>
</td> </td>
<td>{{ employee.phone || "-" }}</td> <td>{{ employee.phone || "-" }}</td>
<td> <td>
<span class="status-chip" :class="employee.employment_status === 'active' ? 'green' : 'red'"> <span class="employee-status-pill" :class="employee.employment_status === 'active' ? 'is-active' : 'is-inactive'">
<i class="employee-status-dot" aria-hidden="true"></i>
{{ statusLabel(employee.employment_status) }} {{ statusLabel(employee.employment_status) }}
</span> </span>
</td> </td>
@@ -424,8 +419,8 @@ function nextPage(): void {
<input v-model="form.name" required maxlength="128" type="text" /> <input v-model="form.name" required maxlength="128" type="text" />
</label> </label>
<label class="field"> <label class="field">
<span>钉钉用户ID</span> <span>钉钉账号</span>
<input v-model="form.dingtalk_user_id" maxlength="128" type="text" /> <input v-model="form.dingtalk_user_id" maxlength="128" placeholder="用于匹配钉钉考勤,可不填" type="text" />
</label> </label>
<label class="field"> <label class="field">
<span>状态</span> <span>状态</span>
-176
View File
@@ -1,176 +0,0 @@
<script setup lang="ts">
import {
Download,
FileClock,
Loader2,
Search,
} from "@lucide/vue";
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ApiError } from "../api/http";
import {
downloadPayrollFile,
getPayrollJob,
readRecentJobs,
} from "../api/payroll";
import type { PayrollJobResponse, RecentPayrollJob } from "../api/types";
import ResultsTable from "../components/ResultsTable.vue";
const route = useRoute();
const router = useRouter();
const queryJobId = ref("");
const loading = ref(false);
const errorMessage = ref("");
const selectedJob = ref<PayrollJobResponse | null>(null);
const recentJobs = ref<RecentPayrollJob[]>(readRecentJobs());
onMounted(() => {
const jobId = typeof route.query.job_id === "string" ? route.query.job_id : "";
if (jobId) {
queryJobId.value = jobId;
searchJob();
}
});
async function searchJob(): Promise<void> {
const jobId = queryJobId.value.trim();
if (!jobId) {
errorMessage.value = "请输入任务编号";
return;
}
loading.value = true;
errorMessage.value = "";
try {
selectedJob.value = await getPayrollJob(jobId);
router.replace({ path: "/payroll/jobs", query: { job_id: jobId } });
} catch (error) {
selectedJob.value = null;
errorMessage.value = error instanceof ApiError ? error.message : "任务查询失败";
} finally {
loading.value = false;
}
}
function chooseRecent(job: RecentPayrollJob): void {
queryJobId.value = job.job_id;
searchJob();
}
async function downloadCurrent(): Promise<void> {
if (!selectedJob.value) {
return;
}
await downloadPayrollFile(null, selectedJob.value.output_file);
}
async function downloadRecent(job: RecentPayrollJob): Promise<void> {
await downloadPayrollFile(job.download_url, job.output_file);
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>计算记录</h1>
<p>按任务编号查询已落库的工资计算结果。</p>
</div>
<button class="primary-button" type="button" :disabled="!selectedJob?.output_file" @click="downloadCurrent">
<Download :size="18" aria-hidden="true" />
<span>下载当前结果</span>
</button>
</header>
<section class="panel">
<div class="panel-header">
<div>
<h2>任务查询</h2>
<p>工资计算任务 ID</p>
</div>
</div>
<form class="search-form" @submit.prevent="searchJob">
<label class="field">
<span>任务编号</span>
<input v-model="queryJobId" type="text" placeholder="例如 8c21f..." />
</label>
<button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
</section>
<section v-if="selectedJob" class="panel">
<div class="panel-header">
<div>
<h2>任务详情</h2>
<p>{{ selectedJob.job_id }}</p>
</div>
<span class="status-chip" :class="selectedJob.status === 'completed' ? 'green' : 'neutral'">
{{ selectedJob.status }}
</span>
</div>
<dl class="detail-grid">
<div>
<dt>任务来源</dt>
<dd>{{ selectedJob.source_type }}</dd>
</div>
<div>
<dt>员工数量</dt>
<dd>{{ selectedJob.employee_count }}</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ new Date(selectedJob.created_at).toLocaleString() }}</dd>
</div>
<div>
<dt>更新时间</dt>
<dd>{{ new Date(selectedJob.updated_at).toLocaleString() }}</dd>
</div>
</dl>
<p v-if="selectedJob.error_message" class="form-error">{{ selectedJob.error_message }}</p>
</section>
<ResultsTable v-if="selectedJob" :results="selectedJob.results" />
<section class="panel">
<div class="panel-header">
<div>
<h2>最近任务</h2>
<p>当前浏览器记录</p>
</div>
</div>
<div class="recent-list">
<button v-for="job in recentJobs" :key="job.job_id" class="recent-item" type="button" @click="chooseRecent(job)">
<FileClock :size="20" aria-hidden="true" />
<span>
<strong>{{ job.job_id }}</strong>
<em>{{ job.source_type }} · {{ job.employee_count }} 人</em>
</span>
<small>{{ new Date(job.created_at).toLocaleDateString() }}</small>
</button>
</div>
<div v-if="!recentJobs.length" class="empty-state">
<p>暂无最近任务</p>
</div>
<div v-if="recentJobs.length" class="form-actions align-right">
<button
v-for="job in recentJobs.slice(0, 3)"
:key="`download-${job.job_id}`"
class="secondary-button"
type="button"
@click="downloadRecent(job)"
>
<Download :size="17" aria-hidden="true" />
<span>{{ job.job_id.slice(0, 8) }}</span>
</button>
</div>
</section>
</div>
</template>
+1
View File
@@ -98,6 +98,7 @@ async function submit(): Promise<void> {
</section> </section>
<section class="login-visual"> <section class="login-visual">
<div class="login-background-layer" aria-hidden="true"></div>
<div class="ledger-visual"> <div class="ledger-visual">
<span class="status-chip green">企业版</span> <span class="status-chip green">企业版</span>
<h2>智能 · 精准 · 值得信赖</h2> <h2>智能 · 精准 · 值得信赖</h2>
+502
View File
@@ -0,0 +1,502 @@
<script setup lang="ts">
import {
Download,
FileSpreadsheet,
Loader2,
LockKeyhole,
Play,
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 DatePicker from "../components/DatePicker.vue";
import ResultsTable from "../components/ResultsTable.vue";
import { useAuthStore } from "../stores/auth";
import {
extractMonthFromExcelFilename,
formatMonthRange,
isSalaryMonth,
type ExcelMonthInfo,
} from "../utils/payrollMonth";
const auth = useAuthStore();
const selectedMonth = ref(defaultSalaryMonth());
const selectedFile = ref<File | null>(null);
const selectedFileMonth = ref<ExcelMonthInfo | null>(null);
const exportExcel = ref(true);
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));
const overtimeTotal = computed(() => `${(activeRun.value?.overtime_total || 0).toFixed(1)}h`);
const monthRange = computed(() => formatMonthRange(selectedMonth.value));
const hasValidMonth = computed(() => isSalaryMonth(selectedMonth.value));
const dingtalkScopeText = computed(() =>
hasValidMonth.value ? `将同步 ${monthRange.value} 的钉钉考勤` : "请选择核算月份后再同步钉钉",
);
const excelMonthMismatch = computed(
() => Boolean(selectedFile.value && selectedFileMonth.value && selectedFileMonth.value.month !== selectedMonth.value),
);
const excelMonthHint = computed(() => {
if (!selectedFile.value) {
return "请先选择 Excel 文件。";
}
if (!selectedFileMonth.value) {
return "未从文件名识别月份,请确认核算月份与 Excel 内容一致。";
}
if (excelMonthMismatch.value) {
return `Excel 文件月份为 ${selectedFileMonth.value.month},当前核算月份为 ${selectedMonth.value || "未选择"},请保持一致后再导入。`;
}
return `Excel 文件月份:${selectedFileMonth.value.month}(${selectedFileMonth.value.rangeLabel})`;
});
onMounted(() => {
void loadRuns();
});
function defaultSalaryMonth(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
async function loadRuns(preferredRunId?: number): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
runs.value = await listMonthlyRuns(selectedMonth.value);
if (runs.value.length) {
const selectedRun = runs.value.find((run) => run.id === preferredRunId) || runs.value[0];
detail.value = await getMonthlyRun(selectedRun.id);
} else {
detail.value = null;
}
} catch (error) {
runs.value = [];
detail.value = null;
errorMessage.value = error instanceof ApiError ? error.message : "月度核算数据加载失败";
} finally {
loading.value = false;
}
}
async function handleMonthChange(): Promise<void> {
errorMessage.value = "";
successMessage.value = "";
await loadRuns();
}
async function viewRun(run: MonthlyPayrollRun): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
detail.value = await getMonthlyRun(run.id);
} catch (error) {
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;
}
selectedFile.value = file;
selectedFileMonth.value = extractMonthFromExcelFilename(file.name);
let detectedMonthMessage = "";
if (selectedFileMonth.value && selectedMonth.value !== selectedFileMonth.value.month) {
selectedMonth.value = selectedFileMonth.value.month;
detectedMonthMessage = `已根据文件识别核算月份:${selectedFileMonth.value.month}。`;
}
await importSelectedExcel(detectedMonthMessage);
}
async function calculateFromDingTalk(): Promise<void> {
if (!hasValidMonth.value) {
errorMessage.value = "请选择核算月份";
return;
}
await runAction("dingtalk", async () => {
const calculated = await calculateMonthlyFromDingTalk(selectedMonth.value, exportExcel.value);
detail.value = calculated;
successMessage.value = `钉钉考勤已同步并完成 ${calculated.run.salary_month} 工资核算`;
await loadRuns(calculated.run.id);
});
}
async function importSelectedExcel(prefixMessage = ""): Promise<void> {
if (!selectedFile.value) {
errorMessage.value = "请选择钉钉月度汇总 Excel 文件";
return;
}
if (!hasValidMonth.value) {
errorMessage.value = "请选择核算月份";
return;
}
if (excelMonthMismatch.value) {
errorMessage.value = excelMonthHint.value;
return;
}
await runAction("excel", async () => {
const calculated = await calculateMonthlyFromExcel(selectedFile.value!, selectedMonth.value, exportExcel.value);
detail.value = calculated;
successMessage.value = `${prefixMessage}Excel 已导入并完成 ${calculated.run.salary_month} 工资核算`;
await loadRuns(calculated.run.id);
});
}
async function lockRun(): Promise<void> {
if (!activeRun.value || !window.confirm("锁定后本月工资不能重新计算,只能导出。确认锁定?")) {
return;
}
const runId = activeRun.value.id;
await runAction("lock", async () => {
await lockMonthlyRun(runId);
detail.value = await getMonthlyRun(runId);
successMessage.value = "本月工资已锁定";
await loadRuns(runId);
});
}
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 sourceText(sourceType: string | undefined): string {
const map: Record<string, string> = {
excel: "Excel导入",
dingtalk: "钉钉同步",
manual: "手动重算",
};
return sourceType ? map[sourceType] || sourceType : "-";
}
function exceptionTypeText(type: string | undefined): string {
const map: Record<string, string> = {
missing_employee: "未匹配员工",
missing_salary_profile: "缺少薪资档案",
negative_remaining_overtime: "剩余加班不足",
late_penalty: "迟到扣款",
missing_card: "缺卡",
missing_attendance: "缺少考勤",
salary_calculation_failed: "工资计算失败",
};
return type ? map[type] || type : "-";
}
function severityText(severity: string | undefined): string {
const map: Record<string, string> = {
info: "提醒",
warning: "预警",
error: "错误",
};
return severity ? map[severity] || severity : "-";
}
function exceptionStatusText(status: string | undefined): string {
const map: Record<string, string> = {
pending: "待处理",
resolved: "已处理",
ignored: "已忽略",
};
return status ? map[status] || status : "-";
}
function dateTime(value: string | null | undefined): string {
return value ? new Date(value).toLocaleString() : "-";
}
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>
</header>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel payroll-compact-toolbar" aria-label="工资核算工具栏">
<div class="payroll-toolbar-month">
<span>本月核算</span>
<label class="field">
<span>核算月份</span>
<DatePicker v-model="selectedMonth" mode="month" placeholder="请选择核算月份" @change="handleMonthChange" />
</label>
</div>
<div class="payroll-toolbar-summary">
<div>
<span>实发合计</span>
<strong>{{ netTotal }}</strong>
<small>{{ sourceText(activeRun?.source_type) }} · {{ activeRun?.employee_count || 0 }} 人</small>
</div>
<div>
<span>应发合计</span>
<strong>{{ grossTotal }}</strong>
<small>{{ statusText(activeRun?.status) }}</small>
</div>
<div>
<span>扣款合计</span>
<strong>{{ deductionTotal }}</strong>
<small>加班 {{ overtimeTotal }}</small>
</div>
<div class="payroll-toolbar-range">
<span>同步范围</span>
<strong>{{ monthRange }}</strong>
<small>{{ dingtalkScopeText }}</small>
</div>
</div>
<div class="payroll-toolbar-actions">
<label class="payroll-toolbar-export">
<input v-model="exportExcel" type="checkbox" />
<span>生成 Excel 结果文件</span>
</label>
<button class="primary-button" type="button" :disabled="!canCalculate || !hasValidMonth || isLocked || actionLoading === 'dingtalk'" @click="calculateFromDingTalk">
<Loader2 v-if="actionLoading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<Play v-else :size="18" aria-hidden="true" />
<span>{{ actionLoading === "dingtalk" ? "同步中" : "同步钉钉核算" }}</span>
</button>
<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>{{ selectedFile ? "更换 Excel 核算" : "导入 Excel 核算" }}</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>
<button v-if="selectedFile" class="link-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'excel' || excelMonthMismatch" @click="importSelectedExcel()">
重新导入
</button>
</div>
<input ref="fileInput" accept=".xlsx,.xls" hidden type="file" @change="handleExcelChange" />
</section>
<section class="panel payroll-detail-panel">
<div class="payroll-detail-header">
<div>
<h2>工资明细</h2>
<p>锁定后本月工资不能重新计算,只能导出。</p>
</div>
<div class="payroll-detail-summary">
<div>
<span>员工数</span>
<strong>{{ activeRun?.employee_count || 0 }}</strong>
</div>
<div>
<span>实发合计</span>
<strong>{{ netTotal }}</strong>
</div>
<div>
<span>应发合计</span>
<strong>{{ grossTotal }}</strong>
</div>
<div>
<span>状态</span>
<strong>{{ statusText(activeRun?.status) }}</strong>
</div>
</div>
</div>
<ResultsTable v-if="detail?.results.length" :exceptions="detail.exceptions" :results="detail.results" embedded />
<div v-else class="empty-state">
<FileSpreadsheet :size="38" aria-hidden="true" />
<p>当前月份还没有核算结果,请导入 Excel 或同步钉钉并计算。</p>
</div>
</section>
<div class="payroll-secondary-grid">
<section class="panel monthly-history-panel">
<div class="panel-header">
<div>
<h2>核算历史</h2>
<p>展示本月最近生成的工资版本,可切换查看明细或导出。</p>
</div>
<span class="status-chip neutral">{{ runs.length }} 条记录</span>
</div>
<div v-if="runs.length" class="table-wrap">
<table class="data-table monthly-history-table">
<thead>
<tr>
<th>工资月份</th>
<th>来源</th>
<th>状态</th>
<th>员工数</th>
<th>实发合计</th>
<th>计算时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="run in runs" :key="run.id" :class="{ 'is-selected': activeRun?.id === run.id }">
<td>
<strong>{{ run.salary_month }}</strong>
<span v-if="activeRun?.id === run.id">当前查看</span>
</td>
<td>{{ sourceText(run.source_type) }}</td>
<td><span class="status-chip" :class="run.status === 'locked' ? 'green' : 'neutral'">{{ statusText(run.status) }}</span></td>
<td>{{ run.employee_count }}</td>
<td class="mono strong">{{ money(run.net_total) }}</td>
<td>{{ dateTime(run.created_at) }}</td>
<td>
<button class="link-button" type="button" :disabled="loading || activeRun?.id === run.id" @click="viewRun(run)">
查看明细
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-state">
<FileSpreadsheet :size="38" aria-hidden="true" />
<p>当前月份还没有核算历史,请导入 Excel 或同步钉钉并计算。</p>
</div>
</section>
<section v-if="detail?.exceptions.length" class="panel payroll-exception-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>{{ exceptionTypeText(item.exception_type) }}</td>
<td>{{ severityText(item.severity) }}</td>
<td>{{ item.message }}</td>
<td>{{ exceptionStatusText(item.status) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<details v-if="activeRun" class="panel monthly-technical-panel">
<summary>技术信息</summary>
<p class="technical-note">用于系统排查和审计,日常工资核对不需要使用这些字段。</p>
<dl class="detail-grid">
<div>
<dt>系统记录ID</dt>
<dd>{{ activeRun.id }}</dd>
</div>
<div>
<dt>来源任务编号</dt>
<dd class="mono">{{ activeRun.source_job_id || "-" }}</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ dateTime(activeRun.created_at) }}</dd>
</div>
<div>
<dt>更新时间</dt>
<dd>{{ dateTime(activeRun.updated_at) }}</dd>
</div>
</dl>
</details>
</div>
</template>
+1 -7
View File
@@ -3,7 +3,6 @@ import {
CheckCircle2, CheckCircle2,
Clock3, Clock3,
Loader2, Loader2,
RefreshCw,
ScrollText, ScrollText,
Search, Search,
XCircle, XCircle,
@@ -168,14 +167,9 @@ function targetLabel(log: OperationLog): string {
<h1>操作日志</h1> <h1>操作日志</h1>
<p>查看登录、工资计算、用户管理和下载等关键操作。</p> <p>查看登录、工资计算、用户管理和下载等关键操作。</p>
</div> </div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadLogs">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header> </header>
<section class="stats-grid"> <section class="summary-strip">
<StatCard title="日志总数" :value="String(response.total)" :icon="ScrollText" tone="blue" meta="全部记录" /> <StatCard title="日志总数" :value="String(response.total)" :icon="ScrollText" tone="blue" meta="全部记录" />
<StatCard title="本页成功" :value="String(successCount)" :icon="CheckCircle2" tone="green" meta="当前筛选" /> <StatCard title="本页成功" :value="String(successCount)" :icon="CheckCircle2" tone="green" meta="当前筛选" />
<StatCard title="本页失败" :value="String(failedCount)" :icon="XCircle" tone="red" meta="当前筛选" /> <StatCard title="本页失败" :value="String(failedCount)" :icon="XCircle" tone="red" meta="当前筛选" />
+475 -152
View File
@@ -1,5 +1,15 @@
<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,
Save,
} from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { import {
@@ -12,7 +22,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 +51,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 +75,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 +204,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 +218,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 +240,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 +253,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 +284,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 +300,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 +352,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 +387,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 +395,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,32 +497,149 @@ 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">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</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 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>
<button class="secondary-button" type="button" @click="resetDepartmentForm">清空</button>
</div> </div>
<form class="form-grid compact-form" @submit.prevent="submitDepartment">
<div class="organization-tree" aria-label="组织架构树">
<div v-if="loading" class="organization-tree-empty">
<Loader2 class="spin" :size="18" aria-hidden="true" />
<span>正在加载组织架构</span>
</div>
<div v-else-if="visibleTreeRows.length === 0" class="organization-tree-empty">
<span>暂无组织架构,请先新增部门</span>
</div>
<button
v-for="row in visibleTreeRows"
v-else
:key="row.node.key"
class="tree-node"
:class="[
`tree-node-${row.node.type}`,
{
'is-selected': selectedNodeKey === row.node.key,
'is-inactive': !row.node.is_active,
},
]"
:style="treeNodeStyle(row.depth)"
type="button"
@click="selectTreeNode(row.node)"
>
<span
v-if="row.node.type === 'department' && row.node.children.length"
class="tree-toggle"
role="button"
tabindex="-1"
@click.stop="toggleDepartment(row.node)"
>
<ChevronDown v-if="expandedDepartmentIds.has(row.node.id)" :size="15" aria-hidden="true" />
<ChevronRight v-else :size="15" aria-hidden="true" />
</span>
<span v-else class="tree-toggle is-placeholder" aria-hidden="true"></span>
<span class="tree-node-icon">
<Building2 v-if="row.node.type === 'department'" :size="16" aria-hidden="true" />
<BriefcaseBusiness v-else :size="16" aria-hidden="true" />
</span>
<span class="tree-node-main">
<strong>{{ row.node.name }}</strong>
<small>{{ row.node.code }}</small>
</span>
<span class="status-chip" :class="row.node.is_active ? 'green' : 'red'">
{{ row.node.is_active ? "启用" : "停用" }}
</span>
</button>
</div>
</aside>
<section class="panel organization-detail-panel">
<div class="panel-header organization-detail-header">
<div>
<h2>{{ detailTitle }}</h2>
<p>{{ detailSubtitle }}</p>
</div>
<div class="organization-detail-actions">
<button class="secondary-button compact-button" type="button" @click="startNewRootDepartment">
<Plus :size="16" aria-hidden="true" />
<span>新增部门</span>
</button>
<button
class="secondary-button compact-button"
type="button"
:disabled="!actionDepartmentId"
@click="startNewChildDepartment"
>
<Plus :size="16" aria-hidden="true" />
<span>新增下级</span>
</button>
<button
class="primary-button compact-button"
type="button"
:disabled="!actionDepartmentId && activeDepartments.length === 0"
@click="startNewPosition"
>
<Plus :size="16" aria-hidden="true" />
<span>新增岗位</span>
</button>
</div>
</div>
<div v-if="selectedDepartment" class="organization-node-summary">
<div>
<span>上级部门</span>
<strong>{{ selectedDepartment.parent_id ? departmentPath(selectedDepartment.parent_id).join(" / ") : "无上级" }}</strong>
</div>
<div>
<span>下级部门</span>
<strong>{{ selectedDepartmentChildren.length }} 个</strong>
</div>
<div>
<span>直属岗位</span>
<strong>{{ selectedDepartmentPositions.length }} 个</strong>
</div>
</div>
<form
v-if="currentEditor === 'department'"
class="form-grid compact-form organization-editor-form"
@submit.prevent="submitDepartment"
>
<label class="field"> <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 +651,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 +672,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 +711,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>
+158 -117
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ArrowRight,
Banknote, Banknote,
CalendarDays,
CloudCog, CloudCog,
Download, Download,
FileSpreadsheet, FileSpreadsheet,
@@ -9,115 +9,148 @@ import {
Play, Play,
Trash2, Trash2,
Upload, Upload,
UsersRound,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import { import {
calculateFromDingTalk, calculateMonthlyFromDingTalk,
calculateFromExcel, calculateMonthlyFromExcel,
downloadPayrollFile, exportMonthlyRun,
saveRecentJob, } from "../api/monthlyPayroll";
} from "../api/payroll"; import type { MonthlyPayrollDetailResponse } from "../api/types";
import type { PayrollResponse } from "../api/types"; import DatePicker from "../components/DatePicker.vue";
import ResultsTable from "../components/ResultsTable.vue"; import ResultsTable from "../components/ResultsTable.vue";
import StatCard from "../components/StatCard.vue"; import StatCard from "../components/StatCard.vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
import {
extractMonthFromExcelFilename,
formatMonthRange,
isSalaryMonth,
type ExcelMonthInfo,
} from "../utils/payrollMonth";
const auth = useAuthStore(); const auth = useAuthStore();
const selectedFile = ref<File | null>(null); const selectedFile = ref<File | null>(null);
const configPath = ref(""); const selectedFileMonth = ref<ExcelMonthInfo | null>(null);
const salaryMonth = ref(defaultSalaryMonth());
const exportExcel = ref(true); const exportExcel = ref(true);
const userIdsText = ref("001\n002"); const activeDetail = ref<MonthlyPayrollDetailResponse | null>(null);
const startDate = ref("2026-05-01");
const endDate = ref("2026-05-31");
const activeResult = ref<PayrollResponse | null>(null);
const loading = ref<"excel" | "dingtalk" | "">(""); const loading = ref<"excel" | "dingtalk" | "">("");
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const canCalculateExcel = computed(() => auth.hasPermission("payroll:excel:calculate")); const canCalculate = computed(() => auth.hasPermission("monthly_payroll:calculate"));
const canCalculateDingTalk = computed(() => auth.hasPermission("payroll:dingtalk:calculate")); const activeRun = computed(() => activeDetail.value?.run);
const monthRange = computed(() => formatMonthRange(salaryMonth.value));
const hasValidMonth = computed(() => isSalaryMonth(salaryMonth.value));
const excelMonthMismatch = computed(
() => Boolean(selectedFile.value && selectedFileMonth.value && selectedFileMonth.value.month !== salaryMonth.value),
);
const dingtalkScopeText = computed(() =>
hasValidMonth.value ? `将同步 ${monthRange.value} 的钉钉考勤` : "请选择核算月份后再同步钉钉",
);
const excelMonthHint = computed(() => {
if (!selectedFile.value) {
return "选择 Excel 后会自动识别文件月份。";
}
if (!selectedFileMonth.value) {
return "未从文件名识别月份,请确认核算月份与 Excel 内容一致。";
}
if (excelMonthMismatch.value) {
return `Excel 文件月份为 ${selectedFileMonth.value.month},当前核算月份为 ${salaryMonth.value || "未选择"},请保持一致后再导入。`;
}
return `Excel 文件月份:${selectedFileMonth.value.month}(${selectedFileMonth.value.rangeLabel})`;
});
const totalOvertime = computed(() => (activeRun.value?.overtime_total || 0).toFixed(1));
const totalDeduction = computed(() => money(activeRun.value?.deduction_total || 0));
const totalNetSalary = computed(() => money(activeRun.value?.net_total || 0));
const totalOvertime = computed(() => sumResults("remaining_overtime_hours").toFixed(1)); function defaultSalaryMonth(): string {
const totalDeduction = computed(() => money(sumResults("total_deduction"))); const now = new Date();
const totalNetSalary = computed(() => money(sumResults("net_salary"))); const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const year = previousMonth.getFullYear();
const month = String(previousMonth.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
function handleFileChange(event: Event): void { function handleFileChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
selectedFile.value = input.files?.[0] || null; selectedFile.value = input.files?.[0] || null;
selectedFileMonth.value = selectedFile.value ? extractMonthFromExcelFilename(selectedFile.value.name) : null;
errorMessage.value = "";
successMessage.value = "";
if (selectedFileMonth.value && salaryMonth.value !== selectedFileMonth.value.month) {
salaryMonth.value = selectedFileMonth.value.month;
successMessage.value = `已根据文件识别核算月份:${selectedFileMonth.value.month}`;
}
} }
async function submitExcel(): Promise<void> { async function submitExcel(): Promise<void> {
if (!selectedFile.value) { if (!selectedFile.value) {
errorMessage.value = "请选择 Excel 文件"; errorMessage.value = "请选择钉钉月度汇总 Excel 文件";
return;
}
if (!hasValidMonth.value) {
errorMessage.value = "请选择核算月份";
return;
}
if (excelMonthMismatch.value) {
errorMessage.value = excelMonthHint.value;
return; return;
} }
await runCalculation("excel", async () => { await runCalculation("excel", () => calculateMonthlyFromExcel(selectedFile.value!, salaryMonth.value, exportExcel.value));
const response = await calculateFromExcel(selectedFile.value!, exportExcel.value, configPath.value.trim());
saveRecentJob("excel", response);
return response;
});
} }
async function submitDingTalk(): Promise<void> { async function submitDingTalk(): Promise<void> {
const user_ids = userIdsText.value if (!hasValidMonth.value) {
.split(/[\n,,\s]+/) errorMessage.value = "请选择核算月份";
.map((item) => item.trim())
.filter(Boolean);
if (!user_ids.length) {
errorMessage.value = "请输入钉钉员工 userId";
return; return;
} }
await runCalculation("dingtalk", async () => { await runCalculation("dingtalk", () => calculateMonthlyFromDingTalk(salaryMonth.value, exportExcel.value));
const response = await calculateFromDingTalk({
user_ids,
start_date: startDate.value,
end_date: endDate.value,
config_path: configPath.value.trim() || null,
export_excel: exportExcel.value,
});
saveRecentJob("dingtalk", response);
return response;
});
} }
async function runCalculation( async function runCalculation(
source: "excel" | "dingtalk", source: "excel" | "dingtalk",
executor: () => Promise<PayrollResponse>, executor: () => Promise<MonthlyPayrollDetailResponse>,
): Promise<void> { ): Promise<void> {
loading.value = source; loading.value = source;
errorMessage.value = ""; errorMessage.value = "";
successMessage.value = ""; successMessage.value = "";
try { try {
activeResult.value = await executor(); activeDetail.value = await executor();
successMessage.value = `计算完成:${activeResult.value.employee_count} 名员工`; successMessage.value = `核算完成:${activeDetail.value.run.salary_month},${activeDetail.value.run.employee_count} 名员工`;
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "计算失败,请检查数据后重试"; errorMessage.value = error instanceof ApiError ? error.message : "核算失败,请检查数据后重试";
} finally { } finally {
loading.value = ""; loading.value = "";
} }
} }
async function downloadCurrent(): Promise<void> { async function downloadCurrent(): Promise<void> {
if (!activeResult.value) { if (!activeRun.value) {
return; return;
} }
await downloadPayrollFile(activeResult.value.download_url, activeResult.value.output_file); await exportMonthlyRun(activeRun.value.id);
} }
function clearResult(): void { function clearResult(): void {
activeResult.value = null; activeDetail.value = null;
successMessage.value = ""; successMessage.value = "";
errorMessage.value = ""; errorMessage.value = "";
} }
function sumResults(field: "remaining_overtime_hours" | "total_deduction" | "net_salary"): number { function sourceText(sourceType: string | undefined): string {
return (activeResult.value?.results || []).reduce((total, item) => total + (item[field] || 0), 0); const map: Record<string, string> = {
excel: "Excel导入",
dingtalk: "钉钉同步",
};
return sourceType ? map[sourceType] || sourceType : "未核算";
} }
function money(value: number): string { function money(value: number): string {
@@ -130,109 +163,117 @@ function money(value: number): string {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack payroll-import-view">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>薪资计算与集成</h1> <h1>同步导入</h1>
<p>Excel 月度汇总与钉钉实时考勤统一计算。</p> <p>按月份导入考勤并生成工资核算结果。</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="secondary-button" type="button" :disabled="!activeResult" @click="clearResult"> <button class="secondary-button" type="button" :disabled="!activeDetail" @click="clearResult">
<Trash2 :size="18" aria-hidden="true" /> <Trash2 :size="18" aria-hidden="true" />
<span>清空数据</span> <span>清空结果</span>
</button> </button>
<button class="primary-button" type="button" :disabled="!activeResult?.download_url && !activeResult?.output_file" @click="downloadCurrent"> <button class="primary-button" type="button" :disabled="!activeRun" @click="downloadCurrent">
<Download :size="18" aria-hidden="true" /> <Download :size="18" aria-hidden="true" />
<span>下载结果</span> <span>下载结果</span>
</button> </button>
</div> </div>
</header> </header>
<section class="action-grid"> <section class="panel payroll-settings-panel">
<div class="action-card">
<div class="action-icon blue">
<CloudCog :size="29" aria-hidden="true" />
</div>
<h2>实时同步钉钉考勤</h2>
<p>通过后端钉钉接口获取打卡、请假及加班数据。</p>
<button class="primary-button" type="button" :disabled="!canCalculateDingTalk || loading === 'dingtalk'" @click="submitDingTalk">
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<ArrowRight v-else :size="18" aria-hidden="true" />
<span>{{ loading === "dingtalk" ? "同步中" : "连接并抓取" }}</span>
</button>
</div>
<div class="action-card">
<div class="action-icon green">
<FileSpreadsheet :size="29" aria-hidden="true" />
</div>
<h2>离线 Excel 导入</h2>
<p>上传钉钉月度汇总表,按当前工资规则计算。</p>
<label class="file-button">
<Upload :size="18" aria-hidden="true" />
<span>{{ selectedFile?.name || "上传文件" }}</span>
<input accept=".xlsx,.xls" type="file" @change="handleFileChange" />
</label>
</div>
</section>
<section class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>计算参数</h2> <h2>核算设置</h2>
<p>{{ exportExcel ? "生成 Excel 结果文件" : "仅返回接口结果" }}</p> <p>核算月份会同时决定钉钉同步范围和 Excel 入库月份。</p>
</div> </div>
<span class="status-chip green">API 已连接</span> <span class="status-chip green">API 已连接</span>
</div> </div>
<div class="form-grid"> <div class="settings-grid">
<label class="field"> <label class="field">
<span>配置文件路径</span> <span>核算月份</span>
<input v-model="configPath" type="text" placeholder="默认规则配置" /> <DatePicker v-model="salaryMonth" mode="month" placeholder="请选择核算月份" />
</label>
<label class="field">
<span>开始日期</span>
<input v-model="startDate" type="date" />
</label>
<label class="field">
<span>结束日期</span>
<input v-model="endDate" type="date" />
</label> </label>
<div class="readonly-field">
<span>核算周期</span>
<strong>{{ monthRange }}</strong>
</div>
<div class="readonly-field">
<span>同步范围</span>
<strong>{{ dingtalkScopeText }}</strong>
</div>
<label class="checkbox-card"> <label class="checkbox-card">
<input v-model="exportExcel" type="checkbox" /> <input v-model="exportExcel" type="checkbox" />
<span>导出 Excel</span> <span>生成 Excel 结果文件</span>
</label> </label>
</div> </div>
</section>
<label class="field"> <section class="source-workflow" aria-label="数据来源">
<span>钉钉员工 userId</span> <div class="section-title-row">
<textarea v-model="userIdsText" rows="4" /> <div>
</label> <h2>选择导入方式</h2>
<p>两种方式都会生成所选月份的同一套月度核算结果。</p>
</div>
</div>
<div class="form-actions"> <div class="source-strip">
<button class="secondary-button" type="button" :disabled="!canCalculateExcel || loading === 'excel'" @click="submitExcel"> <article class="source-option">
<Loader2 v-if="loading === 'excel'" class="spin" :size="18" aria-hidden="true" /> <div class="source-option-main">
<Play v-else :size="18" aria-hidden="true" /> <span class="action-icon blue">
<span>{{ loading === "excel" ? "计算中" : "开始计算 Excel" }}</span> <CloudCog :size="22" aria-hidden="true" />
</button> </span>
<button class="primary-button" type="button" :disabled="!canCalculateDingTalk || loading === 'dingtalk'" @click="submitDingTalk"> <div>
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" /> <h2>钉钉同步</h2>
<Play v-else :size="18" aria-hidden="true" /> <p>{{ dingtalkScopeText }}</p>
<span>{{ loading === "dingtalk" ? "计算中" : "开始计算钉钉" }}</span> </div>
</button> </div>
<button class="primary-button" type="button" :disabled="!canCalculate || !hasValidMonth || loading === 'dingtalk'" @click="submitDingTalk">
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<Play v-else :size="18" aria-hidden="true" />
<span>{{ loading === "dingtalk" ? "同步中" : "开始同步" }}</span>
</button>
</article>
<article class="source-option">
<div class="source-option-main">
<span class="action-icon green">
<FileSpreadsheet :size="22" aria-hidden="true" />
</span>
<div>
<h2>Excel 导入</h2>
<p>{{ selectedFile?.name || "上传钉钉月度汇总表后开始核算。" }}</p>
<small class="source-note" :class="{ warning: excelMonthMismatch }">{{ excelMonthHint }}</small>
</div>
</div>
<div class="source-actions">
<label class="file-button secondary-file-button">
<Upload :size="18" aria-hidden="true" />
<span>{{ selectedFile ? "更换文件" : "选择文件" }}</span>
<input accept=".xlsx,.xls" type="file" @change="handleFileChange" />
</label>
<button class="secondary-button" type="button" :disabled="!canCalculate || !hasValidMonth || loading === 'excel' || !selectedFile || excelMonthMismatch" @click="submitExcel">
<Loader2 v-if="loading === 'excel'" class="spin" :size="18" aria-hidden="true" />
<Play v-else :size="18" aria-hidden="true" />
<span>{{ loading === "excel" ? "核算中" : "开始导入" }}</span>
</button>
</div>
</article>
</div> </div>
</section> </section>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p> <p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section v-if="activeResult" class="stats-grid"> <section v-if="activeRun" class="summary-strip">
<StatCard title="员工数量" :value="String(activeResult.employee_count)" :icon="FileSpreadsheet" tone="blue" :meta="activeResult.job_id" /> <StatCard title="工资月份" :value="activeRun.salary_month" :icon="CalendarDays" tone="blue" :meta="sourceText(activeRun.source_type)" />
<StatCard title="员工数量" :value="String(activeRun.employee_count)" :icon="UsersRound" tone="neutral" meta="参与核算" />
<StatCard title="剩余加班合计" :value="`${totalOvertime}h`" :icon="CloudCog" tone="green" meta="加班-请假" /> <StatCard title="剩余加班合计" :value="`${totalOvertime}h`" :icon="CloudCog" tone="green" meta="加班-请假" />
<StatCard title="扣款合计" :value="totalDeduction" :icon="Trash2" tone="red" meta="迟到+缺卡" /> <StatCard title="扣款合计" :value="totalDeduction" :icon="Trash2" tone="red" meta="迟到+缺卡" />
<StatCard title="实发合计" :value="totalNetSalary" :icon="Banknote" tone="neutral" meta="已计算" /> <StatCard title="实发合计" :value="totalNetSalary" :icon="Banknote" tone="neutral" meta="已计算" />
</section> </section>
<ResultsTable :results="activeResult?.results || []" /> <ResultsTable :results="activeDetail?.results || []" />
</div> </div>
</template> </template>
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { CalendarClock, CloudCog, Loader2 } 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 DatePicker from "../components/DatePicker.vue";
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 employeeQuery = ref("");
const departmentFilter = ref("all");
const summary = computed(() => response.value?.summary);
const rows = computed(() => response.value?.items || []);
const canSync = computed(() => auth.hasPermission("attendance:realtime:sync"));
const departmentOptions = computed(() =>
Array.from(new Set(rows.value.map((row) => row.department).filter(Boolean))).sort((a, b) => a.localeCompare(b, "zh-CN")),
);
const filteredRows = computed(() => {
const query = employeeQuery.value.trim().toLowerCase();
return rows.value.filter((row) => {
const matchDepartment = departmentFilter.value === "all" || row.department === departmentFilter.value;
const matchKeyword =
!query ||
[row.employee_name, row.employee_no, row.department, row.position]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query));
return matchDepartment && matchKeyword;
});
});
const hasRealtimeFilters = computed(() => departmentFilter.value !== "all" || employeeQuery.value.trim() !== "");
const syncStatusText = computed(() => {
const syncedAt = response.value?.synced_at;
if (syncedAt) {
const time = formatSyncTime(syncedAt);
return time ? `${time} 同步` : "已同步";
}
return response.value?.sync_job_id ? "已同步" : "未同步";
});
const syncStatusClass = computed(() => (response.value?.synced_at || response.value?.sync_job_id ? "green" : "neutral"));
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 formatSyncTime(value: string): string {
const text = String(value || "").trim();
const match = text.match(/(?:T|\s)(\d{2}):(\d{2})/);
if (match) {
return `${match[1]}:${match[2]}`;
}
const parsed = new Date(text.includes(" ") ? text.replace(" ", "T") : text);
if (Number.isNaN(parsed.getTime())) {
return "";
}
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(parsed);
}
function clearRealtimeFilters(): void {
departmentFilter.value = "all";
employeeQuery.value = "";
}
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 realtime-page-header">
<div>
<h1>实时考勤</h1>
<p>查看今日打卡状态,并预估本月薪资进度。</p>
</div>
<div class="header-actions realtime-header-actions">
<label class="field compact-field realtime-date-field">
<span>考勤日期</span>
<DatePicker v-model="selectedDate" mode="date" placeholder="请选择考勤日期" @change="loadTodayAttendance" />
</label>
<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 realtime-overview-panel">
<div class="panel-header realtime-overview-header">
<div class="realtime-overview-title">
<h2>今日考勤与薪资预估</h2>
<p>预估金额,最终以月度核算锁定结果为准。</p>
</div>
<span class="status-chip realtime-sync-chip" :class="syncStatusClass">
{{ syncStatusText }}
</span>
</div>
<div class="compact-stats-grid realtime-overview-stats">
<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" class="realtime-table-filter-bar">
<label class="field">
<span>部门筛选</span>
<select v-model="departmentFilter">
<option value="all">全部部门</option>
<option v-for="department in departmentOptions" :key="department" :value="department">{{ department }}</option>
</select>
</label>
<label class="field">
<span>员工搜索</span>
<input v-model="employeeQuery" type="search" placeholder="姓名 / 编号 / 岗位" />
</label>
<button
class="secondary-button realtime-clear-filter"
type="button"
:disabled="!hasRealtimeFilters"
@click="clearRealtimeFilters"
>
清空
</button>
<span class="realtime-filter-result">显示 {{ filteredRows.length }} / {{ rows.length }} 人</span>
</div>
<div v-if="!rows.length && !loading" class="empty-state">
<CalendarClock :size="38" aria-hidden="true" />
<p>当前日期暂无考勤数据,可点击同步钉钉拉取在职员工打卡记录。</p>
</div>
<div v-else-if="!filteredRows.length" 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 filteredRows" :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>
+126 -53
View File
@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Banknote, Building2, Clock3, Loader2, RefreshCw, Search, WalletCards } from "@lucide/vue"; import { Banknote, Building2, Clock3, Loader2, Search, WalletCards } from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import { getPayrollReport } from "../api/reports"; import { getPayrollReport } from "../api/reports";
import type { PayrollReportResponse } from "../api/types"; import type { PayrollReportResponse } from "../api/types";
import StatCard from "../components/StatCard.vue"; import DatePicker from "../components/DatePicker.vue";
const salaryMonth = ref(new Date().toISOString().slice(0, 7)); const salaryMonth = ref(new Date().toISOString().slice(0, 7));
const report = ref<PayrollReportResponse | null>(null); const report = ref<PayrollReportResponse | null>(null);
@@ -49,7 +49,7 @@ function modeLabel(value: string): string {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack report-page">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>统计报表</h1> <h1>统计报表</h1>
@@ -58,40 +58,127 @@ function modeLabel(value: string): string {
<form class="header-actions" @submit.prevent="loadReport"> <form class="header-actions" @submit.prevent="loadReport">
<label class="field inline-field"> <label class="field inline-field">
<span>工资月份</span> <span>工资月份</span>
<input v-model="salaryMonth" type="month" /> <DatePicker v-model="salaryMonth" mode="month" placeholder="请选择工资月份" />
</label> </label>
<button class="primary-button" type="submit" :disabled="loading"> <button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" /> <Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span> <span>{{ loading ? "查询中" : "查询" }}</span>
</button> </button>
<button class="secondary-button" type="button" :disabled="loading" @click="loadReport">
<RefreshCw :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</form> </form>
</header> </header>
<section class="stats-grid"> <p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<StatCard title="员工数" :value="String(totals.employee_count || 0)" :icon="WalletCards" tone="blue" :meta="salaryMonth" />
<StatCard title="应发工资" :value="money(totals.gross_salary)" :icon="Banknote" tone="neutral" meta="基础+提成+加班" /> <section class="panel table-panel report-ledger-workspace report-record-panel" aria-label="工资明细台账">
<StatCard title="实发工资" :value="money(totals.net_salary)" :icon="Banknote" tone="green" meta="扣款后" /> <div class="report-ledger-toolbar">
<StatCard title="扣款合计" :value="money(totals.deduction_amount)" :icon="Clock3" tone="red" meta="迟到+缺卡" /> <div class="report-ledger-title">
<span class="status-chip neutral">{{ report?.salary_month || salaryMonth }}</span>
<div>
<span class="report-ledger-eyebrow">工资汇总</span>
<h2>工资明细台账</h2>
</div>
</div>
<div class="report-ledger-totals" aria-label="工资汇总">
<div class="report-total-item">
<span>应发工资</span>
<strong>{{ money(totals.gross_salary) }}</strong>
<small>基础 + 提成 + 加班</small>
</div>
<div class="report-total-item">
<span>扣款合计</span>
<strong>{{ money(totals.deduction_amount) }}</strong>
<small>迟到 + 缺卡</small>
</div>
<div class="report-total-item is-primary">
<span>实发工资</span>
<strong>{{ money(totals.net_salary) }}</strong>
<small>财务最终金额</small>
</div>
</div>
</div>
<div class="report-inline-metrics" aria-label="报表核心指标">
<span>
<WalletCards :size="15" aria-hidden="true" />
员工数
<strong>{{ totals.employee_count || 0 }}</strong>
</span>
<span>
<Banknote :size="15" aria-hidden="true" />
提成
<strong>{{ money(totals.commission_amount) }}</strong>
</span>
<span>
<Clock3 :size="15" aria-hidden="true" />
加班费
<strong>{{ money(totals.overtime_pay) }}</strong>
</span>
<span>
<Clock3 :size="15" aria-hidden="true" />
加班工时
<strong>{{ Number(attendance.overtime_hours || 0).toFixed(1) }}h</strong>
</span>
<span>
<Clock3 :size="15" aria-hidden="true" />
请假工时
<strong>{{ Number(attendance.leave_hours || 0).toFixed(1) }}h</strong>
</span>
</div>
<div class="report-table-heading">
<div>
<h3>最近工资明细</h3>
<p>{{ report?.recent_records.length || 0 }} 条记录,按员工展示工资构成。</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table report-record-table">
<thead>
<tr>
<th>员工</th>
<th>部门 / 月份</th>
<th>模式</th>
<th>基础工资</th>
<th>提成</th>
<th>加班费</th>
<th>扣款</th>
<th>应发</th>
<th>实发</th>
</tr>
</thead>
<tbody>
<tr v-for="item in report?.recent_records || []" :key="item.id">
<td>
<strong>{{ item.employee_name }}</strong>
<span>{{ item.employee_no || "-" }}</span>
</td>
<td>
<strong>{{ item.department || "-" }}</strong>
<span>{{ item.salary_month }}</span>
</td>
<td><span class="status-chip neutral">{{ modeLabel(item.salary_mode) }}</span></td>
<td class="mono">{{ money(item.base_pay) }}</td>
<td class="mono">{{ money(item.commission_amount) }}</td>
<td class="mono">{{ money(item.overtime_pay) }}</td>
<td class="mono">{{ money(item.deduction_amount) }}</td>
<td class="mono">{{ money(item.gross_salary) }}</td>
<td class="mono strong report-net-cell">{{ money(item.net_salary) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!report?.recent_records.length && !loading" class="empty-state">
<p>暂无工资记录</p>
</div>
</section> </section>
<section class="stats-grid"> <section class="report-detail-grid">
<StatCard title="提成合计" :value="money(totals.commission_amount)" :icon="Banknote" tone="green" meta="当月维护" />
<StatCard title="加班费合计" :value="money(totals.overtime_pay)" :icon="Clock3" tone="blue" meta="三类加班" />
<StatCard title="加班工时" :value="`${Number(attendance.overtime_hours || 0).toFixed(1)}h`" :icon="Clock3" tone="neutral" meta="打卡推算" />
<StatCard title="请假工时" :value="`${Number(attendance.leave_hours || 0).toFixed(1)}h`" :icon="Clock3" tone="red" meta="统一折算" />
</section>
<section class="dashboard-grid">
<div class="panel table-panel"> <div class="panel table-panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>部门工资汇总</h2> <h2>部门工资汇总</h2>
<p>{{ report?.departments.length || 0 }} 个部门</p> <p>按部门查看人数、应发和实发成本。</p>
</div> </div>
<Building2 :size="22" aria-hidden="true" /> <Building2 :size="22" aria-hidden="true" />
</div> </div>
@@ -120,44 +207,30 @@ function modeLabel(value: string): string {
</div> </div>
</div> </div>
<div class="panel table-panel"> <div class="panel report-attendance-panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>最近工资记录</h2> <h2>考勤工时拆分</h2>
<p>{{ report?.recent_records.length || 0 }} 条</p> <p>加班和请假会影响剩余加班及工资结果。</p>
</div> </div>
<Clock3 :size="22" aria-hidden="true" />
</div> </div>
<div class="table-wrap"> <div class="report-attendance-list">
<table class="data-table"> <div class="report-attendance-card">
<thead> <span>加班工时</span>
<tr> <strong>{{ Number(attendance.overtime_hours || 0).toFixed(1) }}h</strong>
<th>员工</th> </div>
<th>模式</th> <div class="report-attendance-card">
<th>基础</th> <span>请假工时</span>
<th>提成</th> <strong>{{ Number(attendance.leave_hours || 0).toFixed(1) }}h</strong>
<th>实发</th> </div>
</tr> <div class="report-attendance-card">
</thead> <span>缺勤天数</span>
<tbody> <strong>{{ Number(attendance.absence_days || 0).toFixed(1) }}</strong>
<tr v-for="item in report?.recent_records || []" :key="item.id"> </div>
<td>
<strong>{{ item.employee_name }}</strong>
<span>{{ item.employee_no || item.department }}</span>
</td>
<td><span class="status-chip neutral">{{ modeLabel(item.salary_mode) }}</span></td>
<td class="mono">{{ money(item.base_pay) }}</td>
<td class="mono">{{ money(item.commission_amount) }}</td>
<td class="mono strong">{{ money(item.net_salary) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!report?.recent_records.length && !loading" class="empty-state">
<p>暂无工资记录</p>
</div> </div>
</div> </div>
</section> </section>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
</div> </div>
</template> </template>
+215 -141
View File
@@ -4,11 +4,12 @@ import {
HandCoins, HandCoins,
Loader2, Loader2,
Pencil, Pencil,
RefreshCw, Plus,
Save, Save,
Search, Search,
TimerReset, TimerReset,
WalletCards, WalletCards,
X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
@@ -16,12 +17,13 @@ import { listEmployees } from "../api/employees";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import { listSalaryProfiles, saveSalaryProfile } from "../api/salaryProfiles"; import { listSalaryProfiles, saveSalaryProfile } from "../api/salaryProfiles";
import type { EmployeeRecord, SalaryProfileRecord, SalaryProfileRequest } from "../api/types"; import type { EmployeeRecord, SalaryProfileRecord, SalaryProfileRequest } from "../api/types";
import StatCard from "../components/StatCard.vue"; import DatePicker from "../components/DatePicker.vue";
const employees = ref<EmployeeRecord[]>([]); const employees = ref<EmployeeRecord[]>([]);
const profiles = ref<SalaryProfileRecord[]>([]); const profiles = ref<SalaryProfileRecord[]>([]);
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const showEditor = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const keyword = ref(""); const keyword = ref("");
@@ -59,6 +61,9 @@ const avgOvertimeRate = computed(() => {
} }
return profiles.value.reduce((total, item) => total + Number(item.overtime_rate || 0), 0) / profiles.value.length; return profiles.value.reduce((total, item) => total + Number(item.overtime_rate || 0), 0) / profiles.value.length;
}); });
const editorTitle = computed(() => {
return profiles.value.some((item) => item.employee_id === form.employee_id) ? "编辑薪资" : "新增薪资";
});
onMounted(loadData); onMounted(loadData);
@@ -108,6 +113,7 @@ async function submit(): Promise<void> {
}); });
profiles.value = [profile, ...profiles.value.filter((item) => item.employee_id !== profile.employee_id)]; profiles.value = [profile, ...profiles.value.filter((item) => item.employee_id !== profile.employee_id)];
successMessage.value = "薪资档案已保存"; successMessage.value = "薪资档案已保存";
showEditor.value = false;
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "薪资档案保存失败"; errorMessage.value = error instanceof ApiError ? error.message : "薪资档案保存失败";
} finally { } finally {
@@ -135,6 +141,18 @@ function editProfile(profile: SalaryProfileRecord): void {
}); });
successMessage.value = ""; successMessage.value = "";
errorMessage.value = ""; errorMessage.value = "";
showEditor.value = true;
}
function openCreateProfile(): void {
resetForm();
successMessage.value = "";
errorMessage.value = "";
showEditor.value = true;
}
function closeEditor(): void {
showEditor.value = false;
} }
function resetForm(): void { function resetForm(): void {
@@ -186,158 +204,78 @@ function money(value: number): string {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack salary-profile-page">
<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">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header> </header>
<section class="stats-grid"> <section class="salary-profile-summary-strip" aria-label="薪资档案指标">
<StatCard title="薪资档案" :value="String(profiles.length)" :icon="WalletCards" tone="blue" meta="已维护" /> <div class="salary-profile-summary-item">
<StatCard title="基础工资合计" :value="money(totalBaseSalary)" :icon="BadgeDollarSign" tone="neutral" meta="当前筛选" /> <WalletCards :size="18" aria-hidden="true" />
<StatCard title="提成合计" :value="money(totalCommission)" :icon="HandCoins" tone="green" meta="手工维护" /> <span>薪资档案</span>
<StatCard title="平均加班单价" :value="money(avgOvertimeRate)" :icon="TimerReset" tone="red" meta="独立维护" /> <strong>{{ profiles.length }}</strong>
<small>已维护员工</small>
</div>
<div class="salary-profile-summary-item">
<BadgeDollarSign :size="18" aria-hidden="true" />
<span>基础工资合计</span>
<strong>{{ money(totalBaseSalary) }}</strong>
<small>当前筛选</small>
</div>
<div class="salary-profile-summary-item">
<HandCoins :size="18" aria-hidden="true" />
<span>提成合计</span>
<strong>{{ money(totalCommission) }}</strong>
<small>手工维护</small>
</div>
<div class="salary-profile-summary-item">
<TimerReset :size="18" aria-hidden="true" />
<span>平均加班单价</span>
<strong>{{ money(avgOvertimeRate) }}</strong>
<small>独立维护</small>
</div>
</section> </section>
<section class="panel"> <p v-if="errorMessage && !showEditor" class="form-error">{{ errorMessage }}</p>
<div class="panel-header"> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel salary-profile-list-panel">
<div class="panel-header salary-profile-list-header">
<div> <div>
<h2>查询薪资档案</h2> <h2>薪资结构一览</h2>
<p>按员工编号、姓名、部门或岗位查询</p> <p>{{ profiles.length }} 条薪资档案,按员工编号、姓名、部门或岗位查询</p>
</div> </div>
</div> <form class="salary-profile-list-tools salary-profile-toolbar" @submit.prevent="loadData">
<form class="search-form" @submit.prevent="loadData">
<label class="field">
<span>关键字</span>
<input v-model="keyword" type="search" placeholder="员工姓名、编号、部门..." /> <input v-model="keyword" type="search" placeholder="员工姓名、编号、部门..." />
</label> <button class="secondary-button compact-button" type="submit" :disabled="loading">
<button class="primary-button" type="submit" :disabled="loading"> <Loader2 v-if="loading" class="spin" :size="16" aria-hidden="true" />
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" /> <Search v-else :size="16" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" /> <span>{{ loading ? "查询中" : "查询" }}</span>
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
</form>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>维护薪资档案</h2>
<p>一名员工维护一条当前薪资档案</p>
</div>
<button class="secondary-button" type="button" @click="resetForm">清空</button>
</div>
<form class="form-grid salary-profile-form" @submit.prevent="submit">
<label class="field">
<span>关联员工</span>
<select v-model.number="form.employee_id" required>
<option :value="0">请选择员工</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.name }} / {{ employee.employee_no }}
</option>
</select>
</label>
<label class="field">
<span>薪资模式</span>
<select v-model="form.salary_mode">
<option v-for="item in salaryModeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
</label>
<label class="field">
<span>基础工资/底薪</span>
<input v-model.number="form.base_salary" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计时小时单价</span>
<input v-model.number="form.hourly_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计件数量</span>
<input v-model.number="form.piece_quantity" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计件单价</span>
<input v-model.number="form.piece_unit_price" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>试用期方式</span>
<select v-model="form.probation_type">
<option value="ratio">固定比例</option>
<option value="fixed">固定金额</option>
</select>
</label>
<label class="field">
<span>试用期比例</span>
<input v-model.number="form.probation_ratio" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>试用期固定金额</span>
<input v-model.number="form.probation_salary" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>默认提成</span>
<input v-model.number="form.commission_amount" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>工作日加班单价</span>
<input v-model.number="form.overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>周末加班单价</span>
<input v-model.number="form.weekend_overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>节假日加班单价</span>
<input v-model.number="form.holiday_overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>生效月份</span>
<input v-model="form.effective_month" type="month" />
</label>
<label class="field span-row">
<span>备注</span>
<textarea v-model="form.remark" />
</label>
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : "保存薪资" }}</span>
</button> </button>
</div> <button class="primary-button compact-button" type="button" @click="openCreateProfile">
</form> <Plus :size="16" aria-hidden="true" />
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p> <span>新增档案</span>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> </button>
</section> </form>
<section class="panel table-panel">
<div class="panel-header">
<div>
<h2>薪资档案列表</h2>
<p>{{ profiles.length }} 条薪资档案</p>
</div>
</div> </div>
<div class="table-wrap"> <div v-if="loading" class="empty-state">
<table class="data-table"> <Loader2 class="spin" :size="18" aria-hidden="true" />
<p>正在加载薪资档案</p>
</div>
<div v-else-if="profiles.length" class="table-wrap">
<table class="data-table salary-profile-table">
<thead> <thead>
<tr> <tr>
<th>员工</th> <th>员工</th>
<th>部门/岗位</th> <th>部门 / 岗位</th>
<th>薪资模式</th> <th>薪资模式</th>
<th>计算规则</th> <th>计算规则</th>
<th>默认提成</th> <th>底薪 / 提成</th>
<th>加班单价</th> <th>加班单价</th>
<th>生效月份</th> <th>生效月份</th>
<th>备注</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
@@ -353,27 +291,163 @@ function money(value: number): string {
</td> </td>
<td><span class="status-chip neutral">{{ salaryModeLabel(profile.salary_mode) }}</span></td> <td><span class="status-chip neutral">{{ salaryModeLabel(profile.salary_mode) }}</span></td>
<td class="mono strong">{{ profileRuleText(profile) }}</td> <td class="mono strong">{{ profileRuleText(profile) }}</td>
<td class="mono">{{ money(profile.commission_amount) }}</td> <td class="salary-profile-pay-cell">
<td class="mono"> <strong>{{ money(profile.base_salary) }}</strong>
工作日 {{ money(profile.overtime_rate) }}/h <span>提成 {{ money(profile.commission_amount) }}</span>
<span>周末 {{ money(profile.weekend_overtime_rate) }}/h</span> </td>
<span>节假日 {{ money(profile.holiday_overtime_rate) }}/h</span> <td>
<span class="salary-profile-rate-stack">
<span>工作日 {{ money(profile.overtime_rate) }}/h</span>
<span>周末 {{ money(profile.weekend_overtime_rate) }}/h</span>
<span>节假日 {{ money(profile.holiday_overtime_rate) }}/h</span>
</span>
</td> </td>
<td>{{ profile.effective_month || "长期" }}</td> <td>{{ profile.effective_month || "长期" }}</td>
<td class="note-cell">{{ profile.remark || "-" }}</td>
<td> <td>
<button class="link-button" type="button" @click="editProfile(profile)"> <button class="link-button" type="button" @click="editProfile(profile)">
<Pencil :size="15" aria-hidden="true" /> <Pencil :size="15" aria-hidden="true" />
<span>编辑</span> <span>编辑薪资</span>
</button> </button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="!profiles.length && !loading" class="empty-state"> <div v-else class="empty-state">
<p>暂无薪资档案</p> <p>暂无薪资档案</p>
</div> </div>
</section> </section>
<div v-if="showEditor" class="modal-backdrop salary-profile-edit-modal" @click.self="closeEditor">
<section class="profile-modal salary-profile-edit-card" aria-label="薪资档案维护">
<div class="modal-header">
<div>
<h2>{{ editorTitle }}</h2>
<p>在弹出卡片内维护员工当前薪资档案和独立加班单价。</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="closeEditor">
<X :size="18" aria-hidden="true" />
</button>
</div>
<form class="salary-profile-form salary-profile-modal-form" @submit.prevent="submit">
<section class="salary-profile-modal-section">
<div class="salary-profile-modal-section-title">
<strong>基础档案</strong>
<span>员工、薪资模式和生效月份</span>
</div>
<div class="salary-profile-modal-fields">
<label class="field">
<span>关联员工</span>
<select v-model.number="form.employee_id" required>
<option :value="0">请选择员工</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.name }} / {{ employee.employee_no }}
</option>
</select>
</label>
<label class="field">
<span>薪资模式</span>
<select v-model="form.salary_mode">
<option v-for="item in salaryModeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
</label>
<label class="field">
<span>基础工资/底薪</span>
<input v-model.number="form.base_salary" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>生效月份</span>
<DatePicker v-model="form.effective_month" mode="month" placeholder="长期" />
</label>
</div>
</section>
<section class="salary-profile-modal-section">
<div class="salary-profile-modal-section-title">
<strong>计时 / 计件 / 试用期</strong>
<span>按员工类型维护对应口径</span>
</div>
<div class="salary-profile-modal-fields">
<label class="field">
<span>计时小时单价</span>
<input v-model.number="form.hourly_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计件数量</span>
<input v-model.number="form.piece_quantity" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计件单价</span>
<input v-model.number="form.piece_unit_price" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>试用期方式</span>
<select v-model="form.probation_type">
<option value="ratio">固定比例</option>
<option value="fixed">固定金额</option>
</select>
</label>
<label class="field">
<span>试用期比例</span>
<input v-model.number="form.probation_ratio" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>试用期固定金额</span>
<input v-model.number="form.probation_salary" min="0" step="0.01" type="number" />
</label>
</div>
</section>
<section class="salary-profile-modal-section">
<div class="salary-profile-modal-section-title">
<strong>提成与加班费</strong>
<span>提成和不同加班单价单独维护</span>
</div>
<div class="salary-profile-modal-fields">
<label class="field">
<span>默认提成</span>
<input v-model.number="form.commission_amount" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>工作日加班单价</span>
<input v-model.number="form.overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>周末加班单价</span>
<input v-model.number="form.weekend_overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>节假日加班单价</span>
<input v-model.number="form.holiday_overtime_rate" min="0" step="0.01" type="number" />
</label>
</div>
</section>
<section class="salary-profile-modal-section salary-profile-modal-section-muted">
<div class="salary-profile-modal-section-title">
<strong>备注</strong>
<span>记录特殊薪资说明</span>
</div>
<label class="field">
<span>备注</span>
<textarea v-model="form.remark" />
</label>
</section>
<p v-if="errorMessage" class="form-error salary-profile-modal-message">{{ errorMessage }}</p>
<div class="form-actions align-right salary-profile-modal-actions">
<button class="secondary-button" type="button" @click="resetForm">清空</button>
<button class="secondary-button" type="button" @click="closeEditor">取消</button>
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : "保存薪资" }}</span>
</button>
</div>
</form>
</section>
</div>
</div> </div>
</template> </template>
+107 -74
View File
@@ -4,6 +4,7 @@ import {
Plus, Plus,
ShieldCheck, ShieldCheck,
UsersRound, UsersRound,
X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
@@ -17,6 +18,7 @@ const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const showUserModal = ref(false);
const form = reactive({ const form = reactive({
username: "", username: "",
@@ -64,16 +66,8 @@ async function submit(): Promise<void> {
role: form.role, role: form.role,
}); });
users.value = [user, ...users.value.filter((item) => item.id !== user.id)]; users.value = [user, ...users.value.filter((item) => item.id !== user.id)];
Object.assign(form, { resetForm();
username: "", showUserModal.value = false;
display_name: "",
email: "",
phone: "",
department: "",
position_title: "",
password: "",
role: "viewer",
});
successMessage.value = "用户已创建"; successMessage.value = "用户已创建";
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "用户创建失败"; errorMessage.value = error instanceof ApiError ? error.message : "用户创建失败";
@@ -82,6 +76,32 @@ async function submit(): Promise<void> {
} }
} }
function resetForm(): void {
Object.assign(form, {
username: "",
display_name: "",
email: "",
phone: "",
department: "",
position_title: "",
password: "",
role: "viewer",
});
}
function openCreateModal(): void {
errorMessage.value = "";
successMessage.value = "";
resetForm();
showUserModal.value = true;
}
function closeUserModal(): void {
showUserModal.value = false;
errorMessage.value = "";
resetForm();
}
function roleLabel(role: string): string { function roleLabel(role: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
superuser: "超级用户", superuser: "超级用户",
@@ -93,87 +113,33 @@ function roleLabel(role: string): string {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack users-page">
<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="loadUsers">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<UsersRound v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header> </header>
<section class="stats-grid"> <section class="summary-strip">
<StatCard title="超级用户" :value="String(superuserCount)" :icon="ShieldCheck" tone="blue" meta="全部权限" /> <StatCard title="超级用户" :value="String(superuserCount)" :icon="ShieldCheck" tone="blue" meta="全部权限" />
<StatCard title="管理者" :value="String(managerCount)" :icon="UsersRound" tone="green" meta="计算权限" /> <StatCard title="管理者" :value="String(managerCount)" :icon="UsersRound" tone="green" meta="计算权限" />
<StatCard title="查看权限" :value="String(viewerCount)" :icon="UsersRound" tone="neutral" meta="只读权限" /> <StatCard title="查看权限" :value="String(viewerCount)" :icon="UsersRound" tone="neutral" meta="只读权限" />
</section> </section>
<section class="panel"> <p v-if="errorMessage && !showUserModal" class="form-error">{{ errorMessage }}</p>
<div class="panel-header"> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<div>
<h2>新增用户</h2>
<p>角色决定可见菜单和操作权限</p>
</div>
</div>
<form class="form-grid user-form" @submit.prevent="submit">
<label class="field">
<span>用户名</span>
<input v-model="form.username" required minlength="2" type="text" />
</label>
<label class="field">
<span>显示名称</span>
<input v-model="form.display_name" type="text" />
</label>
<label class="field">
<span>初始密码</span>
<input v-model="form.password" required minlength="6" type="password" />
</label>
<label class="field">
<span>角色</span>
<select v-model="form.role">
<option value="viewer">查看权限</option>
<option value="manager">管理者</option>
<option value="superuser">超级用户</option>
</select>
</label>
<label class="field">
<span>邮箱</span>
<input v-model="form.email" type="email" />
</label>
<label class="field">
<span>手机号</span>
<input v-model="form.phone" type="tel" />
</label>
<label class="field">
<span>部门</span>
<input v-model="form.department" type="text" />
</label>
<label class="field">
<span>职位</span>
<input v-model="form.position_title" type="text" />
</label>
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "创建中" : "创建用户" }}</span>
</button>
</div>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
</section>
<section class="panel table-panel"> <section class="panel table-panel user-list-panel">
<div class="panel-header"> <div class="panel-header user-list-header">
<div> <div>
<h2>用户列表</h2> <h2>用户列表</h2>
<p>{{ users.length }} 个账号</p> <p>{{ users.length }} 个账号</p>
</div> </div>
<button class="primary-button" type="button" @click="openCreateModal">
<Plus :size="18" aria-hidden="true" />
<span>新增用户</span>
</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
@@ -220,5 +186,72 @@ function roleLabel(role: string): string {
<p>暂无用户</p> <p>暂无用户</p>
</div> </div>
</section> </section>
<Teleport to="body">
<div v-if="showUserModal" class="modal-backdrop user-modal-backdrop" @click.self="closeUserModal">
<section class="profile-modal user-modal" aria-label="用户维护">
<div class="modal-header">
<div>
<h2>新增用户</h2>
<p>角色决定可见菜单和操作权限</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="closeUserModal">
<X :size="21" aria-hidden="true" />
</button>
</div>
<form class="user-modal-form" @submit.prevent="submit">
<div class="user-modal-fields">
<label class="field">
<span>用户名</span>
<input v-model="form.username" required minlength="2" type="text" />
</label>
<label class="field">
<span>显示名称</span>
<input v-model="form.display_name" type="text" />
</label>
<label class="field">
<span>初始密码</span>
<input v-model="form.password" required minlength="6" type="password" />
</label>
<label class="field">
<span>角色</span>
<select v-model="form.role">
<option value="viewer">查看权限</option>
<option value="manager">管理者</option>
<option value="superuser">超级用户</option>
</select>
</label>
<label class="field">
<span>邮箱</span>
<input v-model="form.email" type="email" />
</label>
<label class="field">
<span>手机号</span>
<input v-model="form.phone" type="tel" />
</label>
<label class="field">
<span>部门</span>
<input v-model="form.department" type="text" />
</label>
<label class="field">
<span>职位</span>
<input v-model="form.position_title" type="text" />
</label>
<p v-if="errorMessage" class="form-error user-modal-message">{{ errorMessage }}</p>
<div class="form-actions user-modal-actions">
<button class="secondary-button" type="button" @click="resetForm">清空</button>
<button class="secondary-button" type="button" @click="closeUserModal">取消</button>
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "创建中" : "创建用户" }}</span>
</button>
</div>
</div>
</form>
</section>
</div>
</Teleport>
</div> </div>
</template> </template>
+36
View File
@@ -0,0 +1,36 @@
import {
DEFAULT_THEME_SETTINGS,
isLegacyDefaultAppearance,
themeOptions,
} from "../src/stores/theme";
assertEqual(DEFAULT_THEME_SETTINGS.theme, "enterprise");
assertEqual(DEFAULT_THEME_SETTINGS.mode, "light");
assertEqual(DEFAULT_THEME_SETTINGS.font, "sans");
assertEqual(DEFAULT_THEME_SETTINGS.radius, "0.3");
assertEqual(DEFAULT_THEME_SETTINGS.density, "compact");
assertEqual(DEFAULT_THEME_SETTINGS.sidebar, "embedded");
const enterprise = themeOptions.find((item) => item.name === "enterprise");
if (!enterprise) {
throw new Error("缺少企业清爽主题");
}
assertEqual(enterprise.label, "企业清爽");
assertEqual(enterprise.tokens.light.surface, "#f5f7fa");
assertEqual(enterprise.tokens.light.sidebarBg, "#ffffff");
assertEqual(enterprise.tokens.light.navActiveBg, "#e8f0ff");
assertTruthy(isLegacyDefaultAppearance("anthropic", "dark", "serif", "1", "floating"));
assertTruthy(isLegacyDefaultAppearance("navy", "dark", "serif", "1", "floating"));
function assertEqual(actual: string, expected: string): void {
if (actual !== expected) {
throw new Error(`期望 ${expected},实际 ${actual}`);
}
}
function assertTruthy(actual: boolean): void {
if (!actual) {
throw new Error("期望结果为 true");
}
}
+36
View File
@@ -0,0 +1,36 @@
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 routerPath = join(process.cwd(), "src/router/index.ts");
const routerSource = readFileSync(routerPath, "utf-8");
assertIncludes(routerSource, "await auth.loadSession();");
assertIncludes(routerSource, "catch");
assertIncludes(routerSource, "auth.logout();");
assertIncludes(routerSource, 'return { path: "/login", query: { redirect: to.fullPath } };');
assertBefore(routerSource, "try", "await auth.loadSession();");
assertBefore(routerSource, "await auth.loadSession();", "auth.logout();");
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertBefore(content: string, first: string, second: string): void {
const firstIndex = content.indexOf(first);
const secondIndex = content.indexOf(second);
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
throw new Error(`期望 ${first} 出现在 ${second} 之前`);
}
}
@@ -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 rootBlock = cssBlock(":root");
assertIncludes(rootBlock, "--card-border: 1px solid var(--line);");
assertIncludes(rootBlock, "--card-shadow: 0 6px 16px rgba(var(--primary-rgb), 0.035);");
assertIncludes(rootBlock, "--card-inner-border: 1px solid var(--line-soft);");
assertIncludes(rootBlock, "--card-inner-bg: var(--surface);");
assertIncludes(rootBlock, "--card-inner-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.56);");
const mainCardBlock = cssBlock(".panel,\\s*\\n\\.action-card,\\s*\\n\\.salary-profile-summary-strip,\\s*\\n\\.organization-summary-strip");
assertIncludes(mainCardBlock, "border: var(--card-border);");
assertIncludes(mainCardBlock, "background: var(--card);");
assertIncludes(mainCardBlock, "box-shadow: var(--card-shadow);");
const summaryStripBlock = cssBlock(".summary-strip");
assertIncludes(summaryStripBlock, "border: var(--card-border);");
assertIncludes(summaryStripBlock, "background: var(--card);");
assertIncludes(summaryStripBlock, "box-shadow: var(--card-shadow);");
const innerCardBlock = cssBlock(".summary-metric,\\s*\\n\\.compact-stat,\\s*\\n\\.salary-profile-summary-item,\\s*\\n\\.organization-summary-strip div,\\s*\\n\\.organization-node-summary div,\\s*\\n\\.workflow-steps span,\\s*\\n\\.recent-item,\\s*\\n\\.salary-profile-modal-section,\\s*\\n\\.report-total-item,\\s*\\n\\.report-inline-metrics span,\\s*\\n\\.report-attendance-card");
assertIncludes(innerCardBlock, "border: var(--card-inner-border);");
assertIncludes(innerCardBlock, "background: var(--card-inner-bg);");
assertIncludes(innerCardBlock, "box-shadow: var(--card-inner-shadow);");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${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}`);
}
}
+134
View File
@@ -0,0 +1,134 @@
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 root = process.cwd();
const view = readFileSync(join(root, "src/views/CommissionsView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
assertIncludes(view, "view-stack commissions-page");
assertIncludes(view, "summary-strip commission-summary-strip");
assertIncludes(view, "commission-list-panel");
assertIncludes(view, "commission-filter-bar");
assertIncludes(view, "commission-keyword-field");
assertIncludes(view, "commission-month-field");
assertIncludes(view, "commission-search-button");
assertIncludes(view, "row-action-group commission-row-actions");
assertIncludes(view, "table-action-icon");
assertIncludes(view, "title=\"编辑提成\"");
assertIncludes(view, "title=\"删除提成\"");
assertIncludes(view, "sourceTypeLabel");
assertIncludes(view, "manual: \"手工录入\"");
assertIncludes(view, "excel: \"Excel导入\"");
assertIncludes(view, "dingtalk: \"钉钉同步\"");
assertIncludes(view, "{{ sourceTypeLabel(record.source_type) }}");
assertNotIncludes(view, "{{ record.source_type }}");
assertIncludes(view, "openCreateModal");
assertIncludes(view, "showCommissionModal");
assertIncludes(view, "closeCommissionModal");
assertIncludes(view, "Teleport to=\"body\"");
assertIncludes(view, "modal-backdrop commission-modal-backdrop");
assertIncludes(view, "profile-modal commission-modal");
assertIncludes(view, "aria-label=\"提成维护\"");
assertIncludes(view, "commission-form");
assertIncludes(view, "commission-form-grid");
assertIncludes(view, "commission-business-field");
assertIncludes(view, "commission-remark-field");
assertIncludes(view, "rows=\"2\"");
assertIncludes(view, "commission-form-actions");
assertIncludes(view, "@click=\"openCreateModal\"");
assertIncludes(view, "@click=\"closeCommissionModal\"");
assertNotIncludes(view, "field span-row");
assertNotIncludes(view, "commission-entry-panel");
const pageBlock = cssBlock(".commissions-page");
assertIncludes(pageBlock, "gap: 14px;");
const summaryBlock = cssBlock(".commission-summary-strip");
assertIncludes(summaryBlock, "display: grid;");
assertIncludes(summaryBlock, "grid-template-columns: repeat(3, minmax(0, 1fr));");
assertIncludes(summaryBlock, "padding: 8px;");
const summaryMetricBlock = cssBlock(".commission-summary-strip .summary-metric");
assertIncludes(summaryMetricBlock, "min-height: 58px;");
assertIncludes(summaryMetricBlock, "padding: 8px 12px;");
const summaryStrongBlock = cssBlock(".commission-summary-strip .summary-copy strong");
assertIncludes(summaryStrongBlock, "font-size: 20px;");
const listPanelBlock = cssBlock(".commission-list-panel");
assertIncludes(listPanelBlock, "overflow: hidden;");
const listHeaderBlock = cssBlock(".commission-list-header");
assertIncludes(listHeaderBlock, "align-items: center;");
const filterBlock = cssBlock(".commission-filter-bar");
assertIncludes(filterBlock, "grid-template-columns: minmax(220px, 420px) 180px 104px minmax(0, 1fr);");
assertIncludes(filterBlock, "align-items: end;");
const searchButtonBlock = cssBlock(".commission-search-button");
assertIncludes(searchButtonBlock, "width: 104px;");
const rowActionsBlock = cssBlock(".commission-row-actions");
assertIncludes(rowActionsBlock, "display: inline-flex;");
assertIncludes(rowActionsBlock, "justify-content: flex-end;");
const actionIconBlock = cssBlock(".table-action-icon");
assertIncludes(actionIconBlock, "width: 34px;");
assertIncludes(actionIconBlock, "height: 34px;");
const modalBlock = cssBlock(".commission-modal");
assertIncludes(modalBlock, "width: min(900px, 100%);");
assertIncludes(modalBlock, "max-height: min(720px, calc(100vh - 48px));");
const formBlock = cssBlock(".commission-form");
assertIncludes(formBlock, "padding: 14px var(--panel-padding) var(--panel-padding);");
const formGridBlock = cssBlock(".commission-form-grid");
assertIncludes(formGridBlock, "grid-template-columns: minmax(220px, 1.2fr) minmax(160px, 0.75fr) minmax(190px, 1fr) minmax(150px, 0.7fr);");
assertIncludes(formGridBlock, "gap: 10px 14px;");
const remarkFieldBlock = cssBlock(".commission-remark-field textarea");
assertIncludes(remarkFieldBlock, "min-height: 44px;");
assertIncludes(remarkFieldBlock, "max-height: 76px;");
const formActionsBlock = cssBlock(".commission-form-actions");
assertIncludes(formActionsBlock, "align-self: end;");
assertIncludes(formActionsBlock, "justify-content: flex-end;");
assertIncludes(css, ".commission-business-field,\n .commission-remark-field,\n .commission-form-actions {\n grid-column: auto;");
assertIncludes(css, ".commission-summary-strip,\n .commission-form-grid,\n .commission-filter-bar {\n grid-template-columns: 1fr;");
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, "\\$&");
}
@@ -0,0 +1,69 @@
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 root = process.cwd();
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
const statCard = readFileSync(join(root, "src/components/StatCard.vue"), "utf-8");
const compactPages = [
"DashboardView.vue",
"CommissionsView.vue",
"ConfigCenterView.vue",
"UsersView.vue",
"OperationLogsView.vue",
"PayrollView.vue",
];
for (const page of compactPages) {
const view = readFileSync(join(root, "src/views", page), "utf-8");
assertIncludes(view, "summary-strip", `${page} 应使用紧凑摘要条`);
assertNotIncludes(view, "<section class=\"stats-grid", `${page} 不应再使用大统计卡片区`);
}
assertIncludes(statCard, "summary-metric", "StatCard 应输出紧凑指标项");
assertNotIncludes(statCard, "class=\"stat-card\"", "StatCard 不应再输出旧大卡片类名");
const summaryStripBlock = cssBlock(".summary-strip");
assertIncludes(summaryStripBlock, "display: flex;");
assertIncludes(summaryStripBlock, "padding: 6px 8px;");
const summaryMetricBlock = cssBlock(".summary-metric");
assertIncludes(summaryMetricBlock, "min-height: 42px;");
assertIncludes(summaryMetricBlock, "padding: 5px 8px;");
assertNotIncludes(css, "min-height: 118px;", "旧大统计卡片高度不应保留");
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, message?: string): void {
if (!content.includes(expected)) {
throw new Error(message || `期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string, message?: string): void {
if (content.includes(unexpected)) {
throw new Error(message || `不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -0,0 +1,72 @@
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 root = process.cwd();
const view = readFileSync(join(root, "src/views/ConfigCenterView.vue"), "utf-8");
const template = extractTemplate(view);
assertIncludes(template, "薪资规则设置");
assertIncludes(template, "设置考勤、加班、迟到、缺卡和请假等工资核算规则。");
assertIncludes(template, "恢复默认规则");
assertIncludes(template, "规则清单");
assertIncludes(template, "全部规则");
assertIncludes(view, "attendance: \"考勤规则\"");
assertIncludes(view, "payroll: \"薪资规则\"");
for (const label of ["规则", "当前设置", "是否使用", "用途说明", "操作"]) {
assertIncludes(template, `<th>${label}</th>`, `规则表格应展示业务化表头:${label}`);
}
for (const technicalText of ["配置列表", "<th>配置</th>", "<th>分组</th>", "<th>类型</th>", "全部分组"]) {
assertNotIncludes(template, technicalText, `规则页不应展示技术化文案:${technicalText}`);
}
for (const technicalBinding of [
"{{ config.config_key }}",
"v-model=\"config.config_group\"",
"v-model=\"config.value_type\"",
"<option value=\"string\">string</option>",
"<option value=\"integer\">integer</option>",
"<option value=\"number\">number</option>",
"<option value=\"boolean\">boolean</option>",
"<option value=\"json\">json</option>",
]) {
assertNotIncludes(template, technicalBinding, `规则页不应暴露系统字段:${technicalBinding}`);
}
assertIncludes(view, "groupLabel(config.config_group)", "规则分组应转换成业务名称展示");
assertIncludes(view, "formatConfigValue(config)", "规则值应转换成用户能理解的展示方式");
assertIncludes(template, "weekday-toggle-group", "周末规则应使用星期选择,不应展示原始数字数组");
assertIncludes(view, "displayEditValue(config)", "复杂规则编辑值应转换成业务可读格式");
assertIncludes(view, "updateDisplayValue(config", "用户输入应再转换回系统保存值");
assertNotIncludes(template, "v-model=\"config.config_value\"", "不应直接把系统保存值暴露给用户编辑");
function extractTemplate(content: string): string {
const match = content.match(/<template>([\s\S]*)<\/template>/);
if (!match) {
throw new Error("缺少 template");
}
return match[1];
}
function assertIncludes(content: string, expected: string, message?: string): void {
if (!content.includes(expected)) {
throw new Error(message || `期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string, message?: string): void {
if (content.includes(unexpected)) {
throw new Error(message || `不应包含 ${unexpected}`);
}
}
@@ -0,0 +1,88 @@
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 root = process.cwd();
const component = readFileSync(join(root, "src/components/DatePicker.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
const datePages = [
"CommissionsView.vue",
"MonthlyPayrollView.vue",
"PayrollView.vue",
"RealtimeAttendanceView.vue",
"ReportsView.vue",
"SalaryProfilesView.vue",
];
for (const page of datePages) {
const content = readFileSync(join(root, "src/views", page), "utf-8");
assertIncludes(content, "DatePicker", `${page} 应使用统一 DatePicker 组件`);
assertNotIncludes(content, "type=\"month\"", `${page} 不应再使用浏览器原生月份选择器`);
assertNotIncludes(content, "type=\"date\"", `${page} 不应再使用浏览器原生日期选择器`);
}
assertIncludes(component, 'mode: "month" | "date"');
assertIncludes(component, "date-picker-panel");
assertIncludes(component, "<Teleport to=\"body\">", "日期面板应挂到 body,避免被父级面板裁剪");
assertIncludes(component, "panelRef", "日期面板应独立追踪自身节点,保证弹层内部点击不会被当成外部点击");
assertIncludes(component, "panelStyle", "日期面板应使用运行时坐标定位到触发按钮附近");
assertIncludes(component, "updatePanelPosition", "日期面板打开、滚动或窗口变化时应重新定位");
assertIncludes(component, "month-grid");
assertIncludes(component, "calendar-grid");
assertIncludes(component, "emit(\"change\"");
assertIncludes(component, "本月");
assertIncludes(component, "今天");
const pickerBlock = cssBlock(".date-picker");
assertIncludes(pickerBlock, "position: relative;");
const triggerBlock = cssBlock(".date-picker-trigger");
assertIncludes(triggerBlock, "min-height: var(--control-height);");
assertIncludes(triggerBlock, "border: 1px solid var(--line);");
const panelBlock = cssBlock(".date-picker-panel");
assertIncludes(panelBlock, "position: fixed;");
assertIncludes(panelBlock, "max-height: min(420px, calc(100vh - 24px));");
assertIncludes(panelBlock, "overflow: auto;");
assertIncludes(panelBlock, "box-shadow: var(--shadow);");
const monthGridBlock = cssBlock(".month-grid");
assertIncludes(monthGridBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
const calendarGridBlock = cssBlock(".calendar-grid");
assertIncludes(calendarGridBlock, "grid-template-columns: repeat(7, minmax(0, 1fr));");
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, message?: string): void {
if (!content.includes(expected)) {
throw new Error(message || `期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string, message?: string): void {
if (content.includes(unexpected)) {
throw new Error(message || `不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+103
View File
@@ -0,0 +1,103 @@
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 root = process.cwd();
const view = readFileSync(join(root, "src/views/EmployeesView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
assertIncludes(view, "view-stack employees-page");
assertIncludes(view, "employee-list-header");
assertIncludes(view, "employee-list-title");
assertIncludes(view, "employee-inline-metric");
assertNotIncludes(view, "employee-header-actions");
assertIncludes(view, "employee_info_keyword");
assertIncludes(view, "organization_keyword");
assertIncludes(view, "employee-filter-employee");
assertIncludes(view, "employee-filter-organization");
assertIncludes(view, "employee-filter-status");
assertIncludes(view, "employee-search-button");
assertIncludes(view, "employee-status-pill");
assertIncludes(view, "employee-status-dot");
assertNotIncludes(view, "placeholder=\"编号、姓名、钉钉ID、部门或岗位\"");
assertNotIncludes(view, "class=\"status-chip\"");
assertIncludes(view, "管理员工资料,工资核算会优先使用这里的信息。");
assertIncludes(view, "在职员工");
assertIncludes(view, "<em>人</em>");
assertIncludes(view, "<span>找员工</span>");
assertIncludes(view, "placeholder=\"输入姓名、编号或手机号\"");
assertIncludes(view, "<span>找部门/岗位</span>");
assertIncludes(view, "placeholder=\"输入部门或岗位名称\"");
assertIncludes(view, "<th>钉钉账号</th>");
assertIncludes(view, "employee.dingtalk_user_id || \"未绑定\"");
assertIncludes(view, "<span>钉钉账号</span>");
assertIncludes(view, "placeholder=\"用于匹配钉钉考勤,可不填\"");
assertNotIncludes(view, "钉钉用户ID");
assertNotIncludes(view, "<span>组织</span>");
assertNotIncludes(view, "参与薪资计算");
const pageBlock = cssBlock(".employees-page");
assertIncludes(pageBlock, "gap: 14px;");
const headerBlock = cssBlock(".employee-list-header");
assertIncludes(headerBlock, "display: grid;");
assertIncludes(headerBlock, "grid-template-columns: minmax(0, 1fr) auto;");
const titleBlock = cssBlock(".employee-list-title");
assertIncludes(titleBlock, "display: flex;");
assertIncludes(titleBlock, "align-items: center;");
const metricBlock = cssBlock(".employee-inline-metric");
assertIncludes(metricBlock, "min-height: 30px;");
assertIncludes(metricBlock, "box-shadow: none;");
const filterBlock = cssBlock(".employee-filter-bar");
assertIncludes(filterBlock, "grid-template-columns: minmax(180px, 260px) minmax(180px, 260px) minmax(130px, 160px) 104px minmax(0, 1fr);");
const scopedFilterBlock = cssBlock(".table-filter-bar.employee-filter-bar");
assertIncludes(scopedFilterBlock, "grid-template-columns: minmax(180px, 260px) minmax(180px, 260px) minmax(130px, 160px) 104px minmax(0, 1fr);");
const statusBlock = cssBlock(".employee-status-pill");
assertIncludes(statusBlock, "display: inline-flex;");
assertIncludes(statusBlock, "min-width: 0;");
assertIncludes(statusBlock, "background: transparent;");
const dotBlock = cssBlock(".employee-status-dot");
assertIncludes(dotBlock, "width: 7px;");
assertIncludes(dotBlock, "height: 7px;");
assertIncludes(css, ".table-filter-bar.employee-filter-bar {\n grid-template-columns: 1fr;");
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, "\\$&");
}
+109
View File
@@ -0,0 +1,109 @@
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 root = process.cwd();
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
const themeStore = readFileSync(join(root, "src/stores/theme.ts"), "utf-8");
const appRootBlock = cssBlock("html,\nbody,\n#app");
assertIncludes(appRootBlock, "max-width: 100%;");
assertIncludes(appRootBlock, "overflow-x: hidden;");
const rootBlock = cssBlock(":root");
assertIncludes(rootBlock, "--base-font-size: 14px;");
assertIncludes(rootBlock, "--page-padding: 20px;");
assertIncludes(rootBlock, "--content-gap: 14px;");
assertIncludes(rootBlock, "--panel-padding: 16px;");
const pageHeaderBlock = cssBlock(".page-header");
assertIncludes(pageHeaderBlock, "min-height: 52px;");
assertIncludes(pageHeaderBlock, "padding-bottom: 2px;");
const bodyBlock = cssBlock("body");
assertIncludes(bodyBlock, "overflow-x: hidden;");
const pageTitleBlock = cssBlock(".page-header h1");
assertIncludes(pageTitleBlock, "font-size: 26px;");
const summaryStripBlock = cssBlock(".summary-strip");
assertIncludes(summaryStripBlock, "display: flex;");
assertIncludes(summaryStripBlock, "padding: 6px 8px;");
const summaryMetricBlock = cssBlock(".summary-metric");
assertIncludes(summaryMetricBlock, "min-height: 42px;");
assertIncludes(summaryMetricBlock, "padding: 5px 8px;");
const summaryIconBlock = cssBlock(".summary-icon");
assertIncludes(summaryIconBlock, "width: 26px;");
assertIncludes(summaryIconBlock, "height: 26px;");
const panelHeaderBlock = cssBlock(".panel-header");
assertIncludes(panelHeaderBlock, "padding: 14px var(--panel-padding);");
const tableFilterBlock = cssBlock(".table-filter-bar");
assertIncludes(tableFilterBlock, "padding: 10px var(--panel-padding);");
assertIncludes(tableFilterBlock, "background: var(--surface);");
const tableWrapBlock = cssBlock(".table-wrap");
assertIncludes(tableWrapBlock, "max-width: 100%;");
assertIncludes(tableWrapBlock, "min-width: 0;");
assertIncludes(tableWrapBlock, "contain: inline-size;");
assertIncludes(tableWrapBlock, "overflow-x: auto;");
const tableHeaderBlock = cssBlock(".data-table th");
assertIncludes(tableHeaderBlock, "position: sticky;");
assertIncludes(tableHeaderBlock, "top: 0;");
const modalBackdropBlock = cssBlock(".modal-backdrop");
assertIncludes(modalBackdropBlock, "backdrop-filter: blur(8px);");
const employeeMetricBlock = cssBlock(".employee-inline-metric");
assertIncludes(employeeMetricBlock, "min-height: 30px;");
assertIncludes(employeeMetricBlock, "box-shadow: none;");
const userFormBlock = cssBlock(".user-form");
assertIncludes(userFormBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
const paginationActionsBlock = cssBlock(".table-pagination .form-actions");
assertIncludes(paginationActionsBlock, "justify-content: flex-end;");
assertIncludes(paginationActionsBlock, "background: transparent;");
assertIncludes(themeStore, '"--base-font-size": "14px"');
assertIncludes(themeStore, '"--page-padding": "20px"');
assertIncludes(themeStore, '"--content-gap": "14px"');
assertIncludes(themeStore, '"--panel-padding": "16px"');
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, "\\$&");
}
@@ -0,0 +1,34 @@
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 loginView = readFileSync(join(root, "src/views/LoginView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
const backgroundPath = join(root, "src/assets/login-background.png");
assertIncludes(loginView, "login-background-layer");
assertIncludes(css, "url(\"./login-background.png\")");
assertIncludes(css, ".login-background-layer");
assertIncludes(css, ".login-visual::before");
assertIncludes(css, ".login-visual::after");
if (!existsSync(backgroundPath)) {
throw new Error("登录页背景图资产不存在");
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
@@ -0,0 +1,59 @@
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 loginScreen = cssBlock(".login-screen");
assertIncludes(loginScreen, "grid-template-columns: minmax(340px, 34vw) minmax(0, 1fr);");
const loginFormSide = cssBlock(".login-form-side");
assertIncludes(loginFormSide, "background: linear-gradient(180deg, #f8fafc 0%, #f3f6fa 100%);");
assertIncludes(loginFormSide, "box-shadow: inset -1px 0 0 rgba(var(--primary-rgb), 0.08);");
const loginCard = cssBlock(".login-card");
assertIncludes(loginCard, "width: min(360px, 100%);");
const loginVisual = cssBlock(".login-visual");
assertIncludes(loginVisual, "justify-items: start;");
assertNotIncludes(loginVisual, "border-left: 1px solid var(--line);");
const ledgerVisual = cssBlock(".ledger-visual");
assertIncludes(ledgerVisual, "width: min(360px, 100%);");
assertIncludes(ledgerVisual, "background: rgba(255, 255, 255, 0.68);");
assertIncludes(ledgerVisual, "backdrop-filter: blur(10px);");
assertIncludes(ledgerVisual, "padding: 22px;");
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, "\\$&");
}
@@ -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}`);
}
}
+88
View File
@@ -0,0 +1,88 @@
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 root = process.cwd();
const pages = [
"RealtimeAttendanceView.vue",
"ConfigCenterView.vue",
"ReportsView.vue",
"OperationLogsView.vue",
"UsersView.vue",
"CommissionsView.vue",
"MonthlyPayrollView.vue",
"EmployeesView.vue",
"SalaryProfilesView.vue",
"OrganizationView.vue",
];
for (const page of pages) {
const content = readFileSync(join(root, "src/views", page), "utf-8");
assertNotIncludes(content, "<span>刷新</span>", `${page} 不应展示无业务含义的刷新按钮`);
}
const realtimeView = readView("RealtimeAttendanceView.vue");
assertIncludes(realtimeView, "同步钉钉");
assertIncludes(realtimeView, "@change=\"loadTodayAttendance\"");
assertNotIncludes(realtimeView, "RefreshCw");
const reportsView = readView("ReportsView.vue");
assertIncludes(reportsView, "查询");
assertIncludes(reportsView, "@submit.prevent=\"loadReport\"");
assertNotIncludes(reportsView, "RefreshCw");
const configView = readView("ConfigCenterView.vue");
assertIncludes(configView, "恢复默认规则");
assertIncludes(configView, "保存");
assertNotIncludes(configView, "RefreshCw");
const commissionView = readView("CommissionsView.vue");
assertIncludes(commissionView, "导入Excel");
assertIncludes(commissionView, "新增提成");
assertNotIncludes(commissionView, "RefreshCw");
const monthlyView = readView("MonthlyPayrollView.vue");
assertIncludes(monthlyView, "同步钉钉核算");
assertIncludes(monthlyView, "导入 Excel 核算");
assertNotIncludes(monthlyView, "RefreshCw");
const employeesView = readView("EmployeesView.vue");
assertIncludes(employeesView, "新增员工");
assertIncludes(employeesView, "在职员工");
assertNotIncludes(employeesView, "RefreshCw");
const salaryProfilesView = readView("SalaryProfilesView.vue");
assertIncludes(salaryProfilesView, "薪资档案");
assertIncludes(salaryProfilesView, "编辑");
assertNotIncludes(salaryProfilesView, "RefreshCw");
const organizationView = readView("OrganizationView.vue");
assertIncludes(organizationView, "新增部门");
assertIncludes(organizationView, "新增岗位");
assertNotIncludes(organizationView, "RefreshCw");
function readView(filename: string): string {
return readFileSync(join(root, "src/views", filename), "utf-8");
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string, message?: string): void {
if (content.includes(unexpected)) {
throw new Error(message || `不应包含 ${unexpected}`);
}
}
+201
View File
@@ -0,0 +1,201 @@
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");
const resultsTablePath = join(root, "src/components/ResultsTable.vue");
const topBarPath = join(root, "src/components/TopBar.vue");
const dashboardViewPath = join(root, "src/views/DashboardView.vue");
const routerPath = join(root, "src/router/index.ts");
assertTruthy(existsSync(realtimeApiPath), "缺少实时考勤 API 客户端");
assertTruthy(existsSync(monthlyApiPath), "缺少月度核算 API 客户端");
assertTruthy(existsSync(resultsTablePath), "缺少工资明细表组件");
assertTruthy(existsSync(topBarPath), "缺少顶部导航组件");
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");
assertIncludes(monthlyApi, "/api/payroll/monthly/dingtalk");
assertIncludes(monthlyApi, "/api/payroll/monthly/excel");
const realtimeView = readFileSync(realtimeViewPath, "utf-8");
assertNotIncludes(realtimeView, "待接入菜单");
assertNotIncludes(realtimeView, "待接入钉钉同步");
assertIncludes(realtimeView, "预估金额,最终以月度核算锁定结果为准");
assertIncludes(realtimeView, "realtime-page-header");
assertIncludes(realtimeView, "realtime-header-actions");
assertIncludes(realtimeView, "realtime-date-field");
assertIncludes(realtimeView, "realtime-overview-panel");
assertIncludes(realtimeView, "realtime-overview-header");
assertIncludes(realtimeView, "realtime-overview-title");
assertIncludes(realtimeView, "realtime-overview-stats");
assertIncludes(realtimeView, "syncStatusText");
assertIncludes(realtimeView, "formatSyncTime");
assertIncludes(realtimeView, "realtime-sync-chip");
assertNotIncludes(realtimeView, 'response?.sync_job_id ? "已同步" : "未同步"');
assertIncludes(realtimeView, "employeeQuery");
assertIncludes(realtimeView, "departmentFilter");
assertIncludes(realtimeView, "departmentOptions");
assertIncludes(realtimeView, "filteredRows");
assertIncludes(realtimeView, "hasRealtimeFilters");
assertIncludes(realtimeView, "clearRealtimeFilters");
assertIncludes(realtimeView, "realtime-table-filter-bar");
assertIncludes(realtimeView, "部门筛选");
assertIncludes(realtimeView, "员工搜索");
assertIncludes(realtimeView, "realtime-clear-filter");
assertIncludes(realtimeView, ":disabled=\"!hasRealtimeFilters\"");
assertIncludes(realtimeView, "@click=\"clearRealtimeFilters\"");
assertIncludes(realtimeView, "清空");
assertIncludes(realtimeView, "显示 {{ filteredRows.length }} / {{ rows.length }} 人");
assertIncludes(realtimeView, "v-for=\"row in filteredRows\"");
const topBar = readFileSync(topBarPath, "utf-8");
assertIncludes(topBar, "useRoute");
assertIncludes(topBar, "showGlobalSearch");
assertIncludes(topBar, 'route.path !== "/attendance/realtime"');
assertIncludes(topBar, 'v-if="showGlobalSearch"');
const monthlyView = readFileSync(monthlyViewPath, "utf-8");
assertNotIncludes(monthlyView, "下一阶段接入");
assertNotIncludes(monthlyView, "核算流程");
assertNotIncludes(monthlyView, "计算编号仅用于系统排查");
assertIncludes(monthlyView, "本月核算");
assertNotIncludes(monthlyView, "工资核算工作台");
assertIncludes(monthlyView, "同步钉钉核算");
assertIncludes(monthlyView, "导入 Excel 核算");
assertNotIncludes(monthlyView, "payroll-workbench");
assertNotIncludes(monthlyView, "payroll-flow-strip");
assertIncludes(monthlyView, "payroll-compact-toolbar");
assertIncludes(monthlyView, "payroll-toolbar-month");
assertIncludes(monthlyView, "payroll-toolbar-summary");
assertIncludes(monthlyView, "payroll-toolbar-actions");
assertIncludes(monthlyView, "payroll-toolbar-export");
assertIncludes(monthlyView, "payroll-toolbar-range");
assertIncludes(monthlyView, "payroll-detail-panel");
assertIncludes(monthlyView, "payroll-detail-summary");
assertIncludes(monthlyView, "payroll-secondary-grid");
assertIncludes(monthlyView, ":exceptions=\"detail.exceptions\"");
assertNotIncludes(monthlyView, "payroll-command-copy");
assertNotIncludes(monthlyView, "payroll-command-card");
assertNotIncludes(monthlyView, "payroll-command-step");
assertNotIncludes(monthlyView, "payroll-step-index");
assertNotIncludes(monthlyView, "payroll-period-row");
assertNotIncludes(monthlyView, "payroll-source-row");
assertNotIncludes(monthlyView, "payroll-source-copy");
assertNotIncludes(monthlyView, "payroll-summary-band");
assertNotIncludes(monthlyView, "核算操作");
assertNotIncludes(monthlyView, "1 选择月份");
assertNotIncludes(monthlyView, "2 取数核算");
assertNotIncludes(monthlyView, "3 核对工资");
assertNotIncludes(monthlyView, "4 锁定导出");
assertNotIncludes(monthlyView, "payroll-action-card");
assertNotIncludes(monthlyView, "payroll-result-panel");
assertIncludes(monthlyView, "锁定后本月工资不能重新计算");
assertIncludes(monthlyView, "核算历史");
assertIncludes(monthlyView, "查看明细");
assertIncludes(monthlyView, "技术信息");
assertIncludes(monthlyView, "<summary>技术信息</summary>");
assertNotIncludes(monthlyView, "请输入任务编号");
assertIncludes(monthlyView, "calculateMonthlyFromDingTalk");
assertIncludes(monthlyView, "calculateMonthlyFromExcel");
assertIncludes(monthlyView, "monthly_payroll:calculate");
assertNotIncludes(monthlyView, "monthly-source-strip");
assertIncludes(monthlyView, "核算月份");
assertIncludes(monthlyView, "同步范围");
assertIncludes(monthlyView, "Excel 文件月份");
assertIncludes(monthlyView, "请选择核算月份后再同步钉钉");
assertIncludes(monthlyView, "已根据文件识别核算月份");
assertIncludes(monthlyView, "生成 Excel 结果文件");
assertNotIncludes(monthlyView, "优先同步钉钉,也可导入钉钉月度汇总 Excel。");
assertNotIncludes(monthlyView, "选择 Excel 后会自动识别文件月份。");
assertNotIncludes(monthlyView, ">01<");
assertNotIncludes(monthlyView, ">02<");
assertBefore(monthlyView, "核算月份", "同步钉钉核算");
assertBefore(monthlyView, "工资明细", "核算历史");
assertBefore(monthlyView, "工资明细", "异常清单");
assertBefore(monthlyView, "payroll-compact-toolbar", "payroll-detail-panel");
assertBefore(monthlyView, "payroll-detail-panel", "payroll-secondary-grid");
const resultsTable = readFileSync(resultsTablePath, "utf-8");
assertIncludes(resultsTable, "embedded");
assertIncludes(resultsTable, "salary-detail-table");
assertIncludes(resultsTable, "salary-summary-toolbar");
assertIncludes(resultsTable, "salary-summary-table");
assertIncludes(resultsTable, "salary-detail-drawer");
assertIncludes(resultsTable, "filteredResults");
assertIncludes(resultsTable, "anomalyLabel");
assertIncludes(resultsTable, "查看构成");
assertIncludes(resultsTable, "部门");
assertIncludes(resultsTable, "异常");
assertIncludes(resultsTable, "实发工资");
assertBefore(resultsTable, "salary-summary-table", "salary-detail-drawer");
const dashboardView = readFileSync(dashboardViewPath, "utf-8");
assertNotIncludes(dashboardView, "/payroll/jobs");
assertNotIncludes(dashboardView, 'to="/payroll"');
assertNotIncludes(dashboardView, "readRecentJobs");
assertNotIncludes(dashboardView, "RecentPayrollJob");
assertIncludes(dashboardView, "listMonthlyRuns");
assertIncludes(dashboardView, "exportMonthlyRun");
assertNotIncludes(dashboardView, "payroll:dingtalk:calculate");
assertIncludes(dashboardView, "monthly_payroll:calculate");
assertNotIncludes(dashboardView, "任务编号");
assertNotIncludes(dashboardView, "任务记录");
assertNotIncludes(dashboardView, "最近任务");
assertNotIncludes(dashboardView, "任务来源");
assertNotIncludes(dashboardView, "暂无计算记录");
assertIncludes(dashboardView, "核算历史");
assertIncludes(dashboardView, "最近核算人数");
assertIncludes(dashboardView, "工资月份");
assertIncludes(dashboardView, "实发合计");
const router = readFileSync(routerPath, "utf-8");
assertIncludes(router, 'path: "payroll"');
assertIncludes(router, 'redirect: "/payroll/monthly"');
assertNotIncludes(router, "../views/PayrollView.vue");
assertNotIncludes(router, "component: PayrollView");
assertNotIncludes(router, 'meta: { permission: "payroll:excel:calculate" }');
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);
}
}
function assertBefore(content: string, first: string, second: string): void {
const firstIndex = content.indexOf(first);
const secondIndex = content.indexOf(second);
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
throw new Error(`期望 ${first} 出现在 ${second} 之前`);
}
}
+24
View File
@@ -0,0 +1,24 @@
import {
extractMonthFromExcelFilename,
formatMonthRange,
isSalaryMonth,
} from "../src/utils/payrollMonth";
const detected = extractMonthFromExcelFilename("宁波兆安流体科技有限公司_月度汇总_20260501-20260531.xlsx");
assertEqual(detected?.month || "", "2026-05");
assertEqual(detected?.rangeLabel || "", "2026/05/01 至 2026/05/31");
const dashed = extractMonthFromExcelFilename("考勤汇总_2026-06.xlsx");
assertEqual(dashed?.month || "", "2026-06");
assertEqual(formatMonthRange("2026-05"), "2026/05/01 至 2026/05/31");
assertEqual(formatMonthRange(""), "请选择核算月份");
assertEqual(String(isSalaryMonth("2026-05")), "true");
assertEqual(String(isSalaryMonth("")), "false");
assertEqual(String(isSalaryMonth("2026-13")), "false");
function assertEqual(actual: string, expected: string): void {
if (actual !== expected) {
throw new Error(`期望 ${expected},实际 ${actual}`);
}
}
+87
View File
@@ -0,0 +1,87 @@
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/ReportsView.vue"), "utf-8");
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
assertIncludes(view, "report-page");
assertIncludes(view, "report-ledger-workspace");
assertIncludes(view, "工资明细台账");
assertIncludes(view, "report-ledger-toolbar");
assertIncludes(view, "report-ledger-totals");
assertIncludes(view, "report-inline-metrics");
assertIncludes(view, "report-detail-grid");
assertIncludes(view, "report-record-panel");
assertIncludes(view, "最近工资明细");
assertNotIncludes(view, "<section class=\"stats-grid\">");
assertNotIncludes(view, "StatCard");
assertNotIncludes(view, "report-summary-panel");
assertNotIncludes(view, "report-metric-card");
assertNotIncludes(view, "先看明细再看拆分");
assertNotIncludes(view, "汇总金额和最近工资明细");
assertBefore(view, "report-ledger-workspace", "report-detail-grid");
assertBefore(view, "最近工资明细", "report-detail-grid");
assertIncludes(css, ".report-ledger-workspace");
assertIncludes(css, ".report-ledger-toolbar");
assertIncludes(css, ".report-ledger-totals");
assertIncludes(css, ".report-inline-metrics");
assertIncludes(css, ".report-record-panel");
assertIncludes(css, ".report-detail-grid");
assertNotIncludes(css, ".report-summary-panel");
assertNotIncludes(css, ".report-metric-card");
const ledgerToolbarBlock = cssBlock(".report-ledger-toolbar");
assertIncludes(ledgerToolbarBlock, "padding: 10px var(--panel-padding) 8px;");
const totalItemBlock = cssBlock(".report-total-item");
assertIncludes(totalItemBlock, "min-height: 52px;");
const inlineMetricsBlock = cssBlock(".report-inline-metrics");
assertIncludes(inlineMetricsBlock, "padding: 7px var(--panel-padding);");
const tableHeadingBlock = cssBlock(".report-table-heading");
assertIncludes(tableHeadingBlock, "padding: 9px var(--panel-padding) 6px;");
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 assertBefore(content: string, earlier: string, later: string): void {
const earlierIndex = content.indexOf(earlier);
const laterIndex = content.indexOf(later);
if (earlierIndex === -1 || laterIndex === -1 || earlierIndex >= laterIndex) {
throw new Error(`期望 ${earlier} 出现在 ${later} 之前`);
}
}
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 escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -0,0 +1,71 @@
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/SalaryProfilesView.vue"), "utf-8");
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
assertIncludes(view, "salary-profile-page");
assertIncludes(view, "salary-profile-summary-strip");
assertIncludes(view, "salary-profile-summary-item");
assertIncludes(view, "salary-profile-toolbar");
assertIncludes(view, "salary-profile-modal-section");
assertIncludes(view, "salary-profile-modal-section-title");
assertBefore(view, "salary-profile-summary-strip", "薪资结构一览");
assertBefore(view, "薪资结构一览", "salary-profile-edit-modal");
const summaryBlock = cssBlock(".salary-profile-summary-strip");
assertIncludes(summaryBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
assertIncludes(summaryBlock, "padding: 10px;");
assertNotIncludes(summaryBlock, "min-height: 178px;");
const toolbarBlock = cssBlock(".salary-profile-toolbar");
assertIncludes(toolbarBlock, "display: grid;");
assertIncludes(toolbarBlock, "grid-template-columns:");
const modalSectionBlock = cssBlock(".salary-profile-modal-section");
assertIncludes(modalSectionBlock, "border: var(--card-inner-border);");
assertIncludes(css, ".salary-profile-modal-section {\n display: grid;");
assertIncludes(css, "padding: 14px;");
assertIncludes(css, ".salary-profile-modal-fields");
assertIncludes(css, ".salary-profile-summary-strip,");
assertIncludes(css, ".salary-profile-toolbar");
function cssBlock(selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`${escaped}\\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 assertBefore(content: string, earlier: string, later: string): void {
const earlierIndex = content.indexOf(earlier);
const laterIndex = content.indexOf(later);
if (earlierIndex === -1 || laterIndex === -1 || earlierIndex >= laterIndex) {
throw new Error(`期望 ${earlier} 出现在 ${later} 之前`);
}
}
@@ -0,0 +1,50 @@
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/SalaryProfilesView.vue"), "utf-8");
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
assertIncludes(view, "salary-profile-list-tools");
assertIncludes(view, "salary-profile-edit-modal");
assertIncludes(view, "salary-profile-edit-card");
assertIncludes(view, "薪资结构一览");
assertIncludes(view, "编辑薪资");
assertIncludes(view, "<table class=\"data-table salary-profile-table\">");
assertNotIncludes(view, "salary-profile-card-grid");
assertNotIncludes(view, "salary-profile-form-panel");
assertBefore(view, "薪资结构一览", "salary-profile-edit-modal");
assertIncludes(css, ".salary-profile-list-tools");
assertIncludes(css, ".salary-profile-table");
assertIncludes(css, ".salary-profile-edit-modal");
assertIncludes(css, ".salary-profile-edit-card");
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 assertBefore(content: string, earlier: string, later: string): void {
const earlierIndex = content.indexOf(earlier);
const laterIndex = content.indexOf(later);
if (earlierIndex === -1 || laterIndex === -1 || earlierIndex >= laterIndex) {
throw new Error(`期望 ${earlier} 出现在 ${later} 之前`);
}
}
+61
View File
@@ -0,0 +1,61 @@
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,commissions");
assertEqual(sectionNames("月度核算"), "工资核算,提成录入");
assertEqual(sectionCodes("员工薪资"), "employees,salary_profiles,organization");
assertEqual(sectionCodes("报表分析"), "reports");
assertEqual(sectionCodes("系统管理"), "configs,users,operation_logs");
assertEqual(hasMenu("payroll"), "false");
assertEqual(hasMenu("jobs"), "false");
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 sectionNames(title: string): string {
const section = sections.find((item) => item.title === title);
if (!section) {
throw new Error(`找不到菜单分组:${title}`);
}
return section.items.map((item) => item.name).join(",");
}
function assertEqual(actual: string, expected: string): void {
if (actual !== expected) {
throw new Error(`期望 ${expected},实际 ${actual}`);
}
}
function hasMenu(code: string): string {
return String(sections.some((section) => section.items.some((item) => item.code === code)));
}
+46
View File
@@ -0,0 +1,46 @@
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 messageBlock = cssBlock(".form-error,\\s*\\n\\.form-success");
assertIncludes(messageBlock, "position: fixed;");
assertIncludes(messageBlock, "z-index: 120;");
assertIncludes(messageBlock, "left: 50%;");
assertIncludes(messageBlock, "right: auto;");
assertIncludes(messageBlock, "animation: toast-autohide");
assertIncludes(messageBlock, "pointer-events: none;");
assertNotIncludes(messageBlock, "right: 28px;");
assertIncludes(css, "@keyframes toast-autohide");
assertIncludes(css, "visibility: hidden;");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${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}`);
}
}
+84
View File
@@ -0,0 +1,84 @@
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 root = process.cwd();
const view = readFileSync(join(root, "src/views/UsersView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
assertIncludes(view, "view-stack users-page");
assertIncludes(view, "showUserModal");
assertIncludes(view, "openCreateModal");
assertIncludes(view, "closeUserModal");
assertIncludes(view, "user-list-panel");
assertIncludes(view, "user-list-header");
assertIncludes(view, "@click=\"openCreateModal\"");
assertIncludes(view, "Teleport to=\"body\"");
assertIncludes(view, "modal-backdrop user-modal-backdrop");
assertIncludes(view, "profile-modal user-modal");
assertIncludes(view, "aria-label=\"用户维护\"");
assertIncludes(view, "user-modal-form");
assertIncludes(view, "user-modal-fields");
assertIncludes(view, "user-modal-actions");
assertIncludes(view, "@click=\"closeUserModal\"");
assertNotIncludes(view, "<section class=\"panel\">\n <div class=\"panel-header\">\n <div>\n <h2>新增用户</h2>");
const pageBlock = cssBlock(".users-page");
assertIncludes(pageBlock, "gap: 14px;");
const listPanelBlock = cssBlock(".user-list-panel");
assertIncludes(listPanelBlock, "overflow: hidden;");
const listHeaderBlock = cssBlock(".user-list-header");
assertIncludes(listHeaderBlock, "align-items: center;");
const modalBlock = cssBlock(".user-modal");
assertIncludes(modalBlock, "width: min(920px, 100%);");
assertIncludes(modalBlock, "max-height: min(760px, calc(100vh - 48px));");
const modalFormBlock = cssBlock(".user-modal-form");
assertIncludes(modalFormBlock, "padding: 14px var(--panel-padding) var(--panel-padding);");
const modalFieldsBlock = cssBlock(".user-modal-fields");
assertIncludes(modalFieldsBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
const modalActionsBlock = cssBlock(".user-modal-actions");
assertIncludes(modalActionsBlock, "grid-column: 1 / -1;");
assertIncludes(modalActionsBlock, "justify-content: flex-end;");
assertIncludes(css, ".user-modal-fields {\n grid-template-columns: 1fr;");
assertIncludes(css, ".user-modal-actions {\n justify-content: stretch;");
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, "\\$&");
}
+183
View File
@@ -0,0 +1,183 @@
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: 22px;");
const appearancePanel = cssBlock(".appearance-panel");
assertIncludes(appearancePanel, "width: min(480px, calc(100vw - 20px));");
const primaryFocus = cssBlock(".primary-button:focus-visible");
assertIncludes(primaryFocus, "outline: 0;");
assertIncludes(primaryFocus, "box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16);");
const tableRows = cssBlock(".data-table tbody tr");
assertIncludes(tableRows, "transition: background var(--motion-duration-fast) ease;");
const realtimeHeaderActions = cssBlock(".realtime-header-actions");
assertIncludes(realtimeHeaderActions, "display: grid;");
assertIncludes(realtimeHeaderActions, "grid-template-columns: minmax(210px, 242px) 140px;");
assertIncludes(realtimeHeaderActions, "align-items: end;");
const realtimeDateField = cssBlock(".realtime-date-field");
assertIncludes(realtimeDateField, "min-width: 0;");
const realtimeOverviewPanel = cssBlock(".realtime-overview-panel");
assertIncludes(realtimeOverviewPanel, "overflow: hidden;");
const realtimeOverviewHeader = cssBlock(".realtime-overview-header");
assertIncludes(realtimeOverviewHeader, "align-items: center;");
assertIncludes(realtimeOverviewHeader, "padding: 12px var(--panel-padding) 10px;");
const realtimeOverviewTitle = cssBlock(".realtime-overview-title");
assertIncludes(realtimeOverviewTitle, "display: flex;");
assertIncludes(realtimeOverviewTitle, "align-items: baseline;");
assertIncludes(realtimeOverviewTitle, "gap: 10px;");
const realtimeOverviewTitleText = cssBlock(".realtime-overview-title h2,\n.realtime-overview-title p");
assertIncludes(realtimeOverviewTitleText, "margin: 0;");
const realtimeOverviewStats = cssBlock(".realtime-overview-stats");
assertIncludes(realtimeOverviewStats, "padding-top: 0;");
const realtimeOverviewNumbers = cssBlock(".realtime-overview-stats .compact-stat strong");
assertIncludes(realtimeOverviewNumbers, "font-size: 24px;");
const realtimeSyncChip = cssBlock(".realtime-sync-chip");
assertIncludes(realtimeSyncChip, "min-width: 96px;");
assertIncludes(realtimeSyncChip, "justify-content: center;");
const realtimeTableFilterBar = cssBlock(".realtime-table-filter-bar");
assertIncludes(realtimeTableFilterBar, "display: grid;");
assertIncludes(realtimeTableFilterBar, "grid-template-columns: minmax(180px, 220px) minmax(240px, 1fr) 78px auto;");
assertIncludes(realtimeTableFilterBar, "align-items: end;");
assertIncludes(realtimeTableFilterBar, "padding: 10px var(--panel-padding);");
const realtimeClearFilter = cssBlock(".realtime-clear-filter");
assertIncludes(realtimeClearFilter, "min-height: var(--control-height);");
assertIncludes(realtimeClearFilter, "width: 78px;");
assertIncludes(realtimeClearFilter, "justify-self: start;");
assertIncludes(realtimeClearFilter, "padding: 0 12px;");
assertIncludes(realtimeClearFilter, "white-space: nowrap;");
const realtimeFilterResult = cssBlock(".realtime-filter-result");
assertIncludes(realtimeFilterResult, "justify-self: end;");
const payrollCompactToolbar = cssBlock(".payroll-compact-toolbar");
assertIncludes(payrollCompactToolbar, "grid-template-columns: minmax(190px, 240px) minmax(0, 1fr) auto;");
assertIncludes(payrollCompactToolbar, "padding: 12px var(--panel-padding);");
const payrollToolbarSummary = cssBlock(".payroll-toolbar-summary");
assertIncludes(payrollToolbarSummary, "grid-template-columns: minmax(110px, 0.7fr) minmax(110px, 0.7fr) minmax(110px, 0.7fr) minmax(270px, 1.45fr);");
assertIncludes(payrollToolbarSummary, "border-inline: 1px solid var(--line-soft);");
const payrollToolbarRangeText = cssBlock(".payroll-toolbar-range strong,\n.payroll-toolbar-range small");
assertIncludes(payrollToolbarRangeText, "overflow: visible;");
assertIncludes(payrollToolbarRangeText, "white-space: normal;");
assertIncludes(payrollToolbarRangeText, "text-overflow: clip;");
const payrollToolbarActions = cssBlock(".payroll-toolbar-actions");
assertIncludes(payrollToolbarActions, "grid-template-columns: repeat(2, 138px);");
assertIncludes(payrollToolbarActions, "justify-content: end;");
const payrollToolbarActionButtons = cssBlock(".payroll-toolbar-actions .primary-button,\n.payroll-toolbar-actions .secondary-button");
assertIncludes(payrollToolbarActionButtons, "min-height: 34px;");
assertIncludes(payrollToolbarActionButtons, "padding: 0 10px;");
assertIncludes(payrollToolbarActionButtons, "font-size: 13px;");
const payrollDetailPanel = cssBlock(".payroll-detail-panel");
assertIncludes(payrollDetailPanel, "border-color: rgba(var(--accent-rgb), 0.2);");
assertIncludes(payrollDetailPanel, "overflow: hidden;");
const payrollDetailHeader = cssBlock(".payroll-detail-header");
assertIncludes(payrollDetailHeader, "grid-template-columns: minmax(0, 1fr) auto;");
const payrollDetailSummary = cssBlock(".payroll-detail-summary");
assertIncludes(payrollDetailSummary, "grid-template-columns: repeat(4, minmax(110px, auto));");
const salaryDetailTable = cssBlock(".salary-detail-table");
assertIncludes(salaryDetailTable, "font-size: 13px;");
assertIncludes(salaryDetailTable, "min-width: 1560px;");
const salarySummaryToolbar = cssBlock(".salary-summary-toolbar");
assertIncludes(salarySummaryToolbar, "grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(140px, 180px));");
const salarySummaryTable = cssBlock(".salary-summary-table");
assertIncludes(salarySummaryTable, "min-width: 860px;");
const salarySummaryTableCells = cssBlock(".salary-summary-table th,\n.salary-summary-table td");
assertIncludes(salarySummaryTableCells, "padding: 12px 14px;");
const salaryDetailDrawer = cssBlock(".salary-detail-drawer");
assertIncludes(salaryDetailDrawer, "grid-template-columns: repeat(4, minmax(0, 1fr));");
const salaryDetailDrawerHeader = cssBlock(".salary-detail-drawer-header");
assertIncludes(salaryDetailDrawerHeader, "grid-column: 1 / -1;");
const salaryDetailCells = cssBlock(".salary-detail-table th,\n.salary-detail-table td");
assertIncludes(salaryDetailCells, "padding: 10px 12px;");
const payrollSecondaryGrid = cssBlock(".payroll-secondary-grid");
assertIncludes(payrollSecondaryGrid, "grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);");
assertNotIncludes(css, ".payroll-workbench {");
assertNotIncludes(css, ".payroll-flow-strip");
assertNotIncludes(css, ".payroll-summary-band");
assertIncludes(css, "@media (max-width: 1320px)");
assertIncludes(css, ".payroll-compact-toolbar {\n grid-template-columns: 1fr;\n }");
assertIncludes(css, ".payroll-toolbar-summary {\n grid-template-columns: repeat(2, minmax(0, 1fr));");
assertIncludes(css, ".payroll-secondary-grid {\n grid-template-columns: 1fr;\n }");
assertIncludes(css, ".payroll-toolbar-summary,\n .payroll-toolbar-actions,\n .payroll-detail-summary {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }");
assertIncludes(css, ".realtime-header-actions {\n grid-template-columns: 1fr;\n }");
assertIncludes(css, ".realtime-overview-title {\n align-items: flex-start;\n flex-direction: column;");
assertIncludes(css, ".realtime-table-filter-bar {\n grid-template-columns: 1fr;");
assertNotIncludes(css, ".payroll-command-step");
assertNotIncludes(css, ".payroll-step-index");
assertNotIncludes(css, ".payroll-command-copy");
assertNotIncludes(css, ".payroll-period-row");
assertNotIncludes(css, ".payroll-source-row");
assertNotIncludes(css, ".payroll-source-copy");
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, "\\$&");
}
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from financial_system.domain.models import EmployeeAttendance, PayrollResult
from financial_system.domain.payroll_exceptions import detect_payroll_exceptions
def test_detect_payroll_exceptions_flags_core_payroll_risks() -> None:
result = PayrollResult(
employee=EmployeeAttendance(name="张三", employee_no="ZA0001", department="生产中心"),
salary_month="2026-06",
salary_mode="monthly",
attendance_days=0,
absence_days=22,
actual_work_hours=0,
punch_overtime_hours=1,
leave_hours=3,
remaining_overtime_hours=-2,
workday_overtime_hours=0,
weekend_overtime_hours=0,
holiday_overtime_hours=0,
minor_late_count=4,
major_late_count=1,
penalized_late_count=2,
late_deduction=60,
missing_card_count=1,
missing_card_deduction=20,
total_deduction=80,
base_salary=None,
base_pay=None,
commission_amount=0,
overtime_rate=0,
weekend_overtime_rate=0,
holiday_overtime_rate=0,
overtime_pay=0,
gross_salary=None,
net_salary=None,
note="未配置薪资档案",
)
exception_types = {item.exception_type for item in detect_payroll_exceptions([result])}
assert "missing_salary_profile" in exception_types
assert "missing_card" in exception_types
assert "late_penalty" in exception_types
assert "negative_remaining_overtime" in exception_types
assert "empty_attendance" in exception_types
if __name__ == "__main__":
test_detect_payroll_exceptions_flags_core_payroll_risks()
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from financial_system.services.realtime_attendance_service import TodayAttendanceItemData, build_realtime_summary
def test_build_realtime_summary_counts_today_status() -> None:
items = [
TodayAttendanceItemData(
employee_id=1,
employee_no="ZA0001",
employee_name="张三",
department="生产中心",
position="技工",
first_punch="08:58",
last_punch="18:30",
attendance_status="present",
late_minutes=0,
missing_card_count=0,
leave_hours=0,
today_overtime_hours=1,
month_remaining_overtime_hours=4,
estimated_net_salary=6800,
note="",
),
TodayAttendanceItemData(
employee_id=2,
employee_no="ZA0002",
employee_name="李四",
department="行政人事部",
position="人事专员",
first_punch="09:08",
last_punch="",
attendance_status="missing",
late_minutes=8,
missing_card_count=1,
leave_hours=2,
today_overtime_hours=0,
month_remaining_overtime_hours=-2,
estimated_net_salary=None,
note="缺少下班卡",
),
TodayAttendanceItemData(
employee_id=3,
employee_no="ZA0003",
employee_name="王五",
department="财务部",
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=0,
estimated_net_salary=5000,
note="",
),
]
summary = build_realtime_summary(date(2026, 6, 22), items)
assert summary.employee_total == 3
assert summary.checked_in_count == 2
assert summary.unchecked_count == 1
assert summary.late_count == 1
assert summary.leave_count == 1
assert summary.missing_card_count == 1
assert summary.estimated_overtime_count == 1
assert summary.estimated_net_total == 11800
if __name__ == "__main__":
test_build_realtime_summary_counts_today_status()
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from financial_system.core.settings import get_settings
def test_database_url_environment_variable_overrides_config_file() -> None:
with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "app_settings.json"
config_path.write_text(
json.dumps(
{
"database": {
"url": "mysql+pymysql://json-user:json-pass@json-host:3306/financial_system?charset=utf8mb4"
}
}
),
encoding="utf-8",
)
docker_database_url = (
"mysql+pymysql://docker-user:docker-pass@mysql.example.com:3306/"
"financial_system?charset=utf8mb4"
)
with patch.dict(
os.environ,
{
"APP_CONFIG_FILE": str(config_path),
"DATABASE_URL": docker_database_url,
},
clear=False,
):
settings = get_settings()
assert settings.database_url == docker_database_url
if __name__ == "__main__":
test_database_url_environment_variable_overrides_config_file()