Compare commits
9
Commits
99c7ba776f
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b70e29563a | ||
|
|
960d2aa028 | ||
|
|
d6f858b363 | ||
|
|
af1db3de4f | ||
|
|
f213848719 | ||
|
|
faa09101a1 | ||
|
|
1e6230d412 | ||
|
|
dc74a618f7 | ||
|
|
a846ea5070 |
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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
@@ -13,7 +13,20 @@ from ..core.settings import PROJECT_ROOT, get_settings
|
|||||||
from ..database import SessionLocal, init_database
|
from ..database import SessionLocal, init_database
|
||||||
from ..repositories import OrganizationRepository, SalaryConfigRepository, UserRepository
|
from ..repositories import OrganizationRepository, SalaryConfigRepository, UserRepository
|
||||||
from ..services import AuthService, OrganizationService, SalaryConfigService
|
from ..services import AuthService, OrganizationService, SalaryConfigService
|
||||||
from .routers import auth, commissions, configs, employees, health, operation_logs, organization, payroll, reports, salary_profiles
|
from .routers import (
|
||||||
|
auth,
|
||||||
|
commissions,
|
||||||
|
configs,
|
||||||
|
employees,
|
||||||
|
health,
|
||||||
|
monthly_payroll,
|
||||||
|
operation_logs,
|
||||||
|
organization,
|
||||||
|
payroll,
|
||||||
|
realtime_attendance,
|
||||||
|
reports,
|
||||||
|
salary_profiles,
|
||||||
|
)
|
||||||
|
|
||||||
logger = AppLogger.get_logger(__name__)
|
logger = AppLogger.get_logger(__name__)
|
||||||
|
|
||||||
@@ -56,9 +69,11 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(configs.router)
|
app.include_router(configs.router)
|
||||||
app.include_router(employees.router)
|
app.include_router(employees.router)
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
|
app.include_router(monthly_payroll.router)
|
||||||
app.include_router(operation_logs.router)
|
app.include_router(operation_logs.router)
|
||||||
app.include_router(organization.router)
|
app.include_router(organization.router)
|
||||||
app.include_router(payroll.router)
|
app.include_router(payroll.router)
|
||||||
|
app.include_router(realtime_attendance.router)
|
||||||
app.include_router(reports.router)
|
app.include_router(reports.router)
|
||||||
app.include_router(salary_profiles.router)
|
app.include_router(salary_profiles.router)
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ from ..database.orm import UserORM
|
|||||||
from ..repositories import (
|
from ..repositories import (
|
||||||
CommissionRepository,
|
CommissionRepository,
|
||||||
EmployeeRepository,
|
EmployeeRepository,
|
||||||
|
MonthlyPayrollRepository,
|
||||||
OperationLogRepository,
|
OperationLogRepository,
|
||||||
OrganizationRepository,
|
OrganizationRepository,
|
||||||
PayrollRepository,
|
PayrollRepository,
|
||||||
|
RealtimeAttendanceRepository,
|
||||||
ReportRepository,
|
ReportRepository,
|
||||||
SalaryConfigRepository,
|
SalaryConfigRepository,
|
||||||
SalaryProfileRepository,
|
SalaryProfileRepository,
|
||||||
@@ -28,9 +30,11 @@ from ..services import (
|
|||||||
AuthService,
|
AuthService,
|
||||||
CommissionService,
|
CommissionService,
|
||||||
EmployeeService,
|
EmployeeService,
|
||||||
|
MonthlyPayrollService,
|
||||||
OperationLogService,
|
OperationLogService,
|
||||||
OrganizationService,
|
OrganizationService,
|
||||||
PayrollApplicationService,
|
PayrollApplicationService,
|
||||||
|
RealtimeAttendanceService,
|
||||||
ReportService,
|
ReportService,
|
||||||
SalaryConfigService,
|
SalaryConfigService,
|
||||||
SalaryProfileService,
|
SalaryProfileService,
|
||||||
@@ -58,6 +62,14 @@ def get_payroll_repository(session: Session = Depends(get_db_session)) -> Payrol
|
|||||||
return PayrollRepository(session)
|
return PayrollRepository(session)
|
||||||
|
|
||||||
|
|
||||||
|
def get_monthly_payroll_repository(session: Session = Depends(get_db_session)) -> MonthlyPayrollRepository:
|
||||||
|
return MonthlyPayrollRepository(session)
|
||||||
|
|
||||||
|
|
||||||
|
def get_realtime_attendance_repository(session: Session = Depends(get_db_session)) -> RealtimeAttendanceRepository:
|
||||||
|
return RealtimeAttendanceRepository(session)
|
||||||
|
|
||||||
|
|
||||||
def get_commission_repository(session: Session = Depends(get_db_session)) -> CommissionRepository:
|
def get_commission_repository(session: Session = Depends(get_db_session)) -> CommissionRepository:
|
||||||
return CommissionRepository(session)
|
return CommissionRepository(session)
|
||||||
|
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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="更新时间",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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("核算月份格式错误,请选择正确的月份")
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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` | 可查看实时考勤、计算记录、下载结果和统计报表。 |
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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 |
+2790
-261
File diff suppressed because it is too large
Load Diff
@@ -26,13 +26,13 @@ const modeOptions: Array<{ name: ThemeMode; label: string }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const fontOptions: Array<{ name: FontChoice; label: string }> = [
|
const fontOptions: Array<{ name: FontChoice; label: string }> = [
|
||||||
{ name: "auto", label: "Auto" },
|
{ name: "auto", label: "系统" },
|
||||||
{ name: "sans", label: "Sans" },
|
{ name: "sans", label: "现代" },
|
||||||
{ name: "serif", label: "Serif" },
|
{ name: "serif", label: "宋体" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const radiusOptions: Array<{ name: RadiusChoice; label: string; previewRadius: string }> = [
|
const radiusOptions: Array<{ name: RadiusChoice; label: string; previewRadius: string }> = [
|
||||||
{ name: "auto", label: "Auto", previewRadius: "20px" },
|
{ name: "auto", label: "默认", previewRadius: "20px" },
|
||||||
{ name: "0", label: "0", previewRadius: "0px" },
|
{ name: "0", label: "0", previewRadius: "0px" },
|
||||||
{ name: "0.3", label: "0.3", previewRadius: "8px" },
|
{ name: "0.3", label: "0.3", previewRadius: "8px" },
|
||||||
{ name: "0.5", label: "0.5", previewRadius: "14px" },
|
{ name: "0.5", label: "0.5", previewRadius: "14px" },
|
||||||
@@ -56,7 +56,7 @@ const sidebarOptions: Array<{ name: SidebarChoice; label: string }> = [
|
|||||||
const layoutOptions: Array<{ name: LayoutChoice; label: string }> = [
|
const layoutOptions: Array<{ name: LayoutChoice; label: string }> = [
|
||||||
{ name: "default", label: "默认" },
|
{ name: "default", label: "默认" },
|
||||||
{ name: "compact", label: "紧凑" },
|
{ name: "compact", label: "紧凑" },
|
||||||
{ name: "fullscreen", label: "全屏布局" },
|
{ name: "fullscreen", label: "全屏" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> = [
|
const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> = [
|
||||||
@@ -70,12 +70,18 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
|
|||||||
<aside class="appearance-panel" role="dialog" aria-label="外观设置">
|
<aside class="appearance-panel" role="dialog" aria-label="外观设置">
|
||||||
<header class="appearance-panel-header">
|
<header class="appearance-panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>外观</h2>
|
<h2>界面外观</h2>
|
||||||
<p>主题、颜色与布局</p>
|
<p>整套界面的颜色、字体和布局</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="appearance-header-actions">
|
||||||
|
<button class="secondary-button appearance-recommend" type="button" @click="theme.resetAppearance">
|
||||||
|
<RotateCcw :size="16" aria-hidden="true" />
|
||||||
|
<span>推荐外观</span>
|
||||||
|
</button>
|
||||||
<button class="icon-button" type="button" title="关闭" @click="emit('close')">
|
<button class="icon-button" type="button" title="关闭" @click="emit('close')">
|
||||||
<X :size="21" aria-hidden="true" />
|
<X :size="21" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="appearance-section">
|
<section class="appearance-section">
|
||||||
@@ -118,7 +124,7 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
|
|||||||
|
|
||||||
<section class="appearance-section">
|
<section class="appearance-section">
|
||||||
<div class="appearance-section-head">
|
<div class="appearance-section-head">
|
||||||
<h3>颜色预设</h3>
|
<h3>整套配色</h3>
|
||||||
<button class="appearance-reset" type="button" title="恢复默认颜色" @click="theme.resetColor">
|
<button class="appearance-reset" type="button" title="恢复默认颜色" @click="theme.resetColor">
|
||||||
<RotateCcw :size="17" aria-hidden="true" />
|
<RotateCcw :size="17" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -129,11 +135,30 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
|
|||||||
:key="item.name"
|
:key="item.name"
|
||||||
class="color-preset-choice"
|
class="color-preset-choice"
|
||||||
:class="{ 'is-active': theme.current === item.name }"
|
:class="{ 'is-active': theme.current === item.name }"
|
||||||
:style="{ '--preset-from': item.gradient[0], '--preset-to': item.gradient[1] }"
|
:style="{
|
||||||
|
'--preset-surface': item.tokens.light.surface,
|
||||||
|
'--preset-sidebar': item.tokens.light.sidebarBg,
|
||||||
|
'--preset-active': item.tokens.light.navActiveBg,
|
||||||
|
'--preset-card': item.tokens.light.card,
|
||||||
|
'--preset-primary': item.tokens.light.primary,
|
||||||
|
'--preset-accent': item.tokens.light.accent,
|
||||||
|
}"
|
||||||
type="button"
|
type="button"
|
||||||
@click="theme.setTheme(item.name)"
|
@click="theme.setTheme(item.name)"
|
||||||
>
|
>
|
||||||
<span class="color-preset-swatch" aria-hidden="true" />
|
<span class="color-preset-swatch" aria-hidden="true">
|
||||||
|
<i class="preset-sidebar">
|
||||||
|
<b />
|
||||||
|
<b />
|
||||||
|
<b />
|
||||||
|
</i>
|
||||||
|
<i class="preset-main">
|
||||||
|
<b />
|
||||||
|
<b />
|
||||||
|
<b />
|
||||||
|
</i>
|
||||||
|
<i class="preset-accent" />
|
||||||
|
</span>
|
||||||
<strong>{{ item.label }}</strong>
|
<strong>{{ item.label }}</strong>
|
||||||
<span v-if="theme.current === item.name" class="choice-check" aria-hidden="true">
|
<span v-if="theme.current === item.name" class="choice-check" aria-hidden="true">
|
||||||
<Check :size="18" />
|
<Check :size="18" />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
Banknote,
|
Banknote,
|
||||||
BadgeDollarSign,
|
BadgeDollarSign,
|
||||||
Building2,
|
Building2,
|
||||||
|
Calculator,
|
||||||
|
CalendarClock,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileChartColumn,
|
FileChartColumn,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import { buildSidebarSections } from "../navigation/sidebarMenu";
|
||||||
import { useAuthStore } from "../stores/auth";
|
import { useAuthStore } from "../stores/auth";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -35,6 +38,8 @@ const auth = useAuthStore();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const backendIconMap = {
|
const backendIconMap = {
|
||||||
|
attendance_realtime: CalendarClock,
|
||||||
|
monthly_payroll: Calculator,
|
||||||
payroll: Banknote,
|
payroll: Banknote,
|
||||||
jobs: ClipboardList,
|
jobs: ClipboardList,
|
||||||
employees: UserCog,
|
employees: UserCog,
|
||||||
@@ -47,18 +52,15 @@ const backendIconMap = {
|
|||||||
operation_logs: ScrollText,
|
operation_logs: ScrollText,
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = computed(() => [
|
const menuSections = computed(() =>
|
||||||
{
|
buildSidebarSections(auth.menus).map((section) => ({
|
||||||
code: "dashboard",
|
...section,
|
||||||
name: "工作台",
|
items: section.items.map((menu) => ({
|
||||||
path: "/dashboard",
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
},
|
|
||||||
...auth.menus.map((menu) => ({
|
|
||||||
...menu,
|
...menu,
|
||||||
icon: backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
|
icon: menu.code === "dashboard" ? LayoutDashboard : backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
|
||||||
})),
|
})),
|
||||||
]);
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
function logout(): void {
|
function logout(): void {
|
||||||
auth.logout();
|
auth.logout();
|
||||||
@@ -82,18 +84,21 @@ function logout(): void {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav" aria-label="主导航">
|
<nav class="sidebar-nav" aria-label="主导航">
|
||||||
|
<section v-for="section in menuSections" :key="section.code" class="nav-section">
|
||||||
|
<p class="nav-section-title">{{ section.title }}</p>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in menuItems"
|
v-for="item in section.items"
|
||||||
:key="item.code"
|
:key="item.code"
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
active-class="is-active"
|
active-class="is-active"
|
||||||
:title="item.name"
|
:title="`${section.title} / ${item.name}`"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
>
|
>
|
||||||
<component :is="item.icon" :size="21" aria-hidden="true" />
|
<component :is="item.icon" :size="20" aria-hidden="true" />
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
</section>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
|||||||
@@ -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>
|
</div>
|
||||||
<span v-if="meta" class="stat-meta">{{ meta }}</span>
|
<div class="summary-copy">
|
||||||
</div>
|
<span>{{ title }}</span>
|
||||||
<p>{{ title }}</p>
|
|
||||||
<strong>{{ value }}</strong>
|
<strong>{{ value }}</strong>
|
||||||
|
</div>
|
||||||
|
<em v-if="meta" class="summary-meta">{{ meta }}</em>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
try {
|
||||||
await auth.loadSession();
|
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
@@ -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;
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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">
|
|
||||||
<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>
|
<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>
|
||||||
|
|||||||
@@ -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)">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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="当前筛选" />
|
||||||
|
|||||||
@@ -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>
|
||||||
<button class="secondary-button" type="button" @click="resetDepartmentForm">清空</button>
|
<div class="organization-tree-tools">
|
||||||
|
<button class="link-button" type="button" @click="expandAllDepartments">展开</button>
|
||||||
|
<button class="link-button muted" type="button" @click="collapseAllDepartments">收起</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="form-grid compact-form" @submit.prevent="submitDepartment">
|
</div>
|
||||||
|
|
||||||
|
<div class="organization-tree" aria-label="组织架构树">
|
||||||
|
<div v-if="loading" class="organization-tree-empty">
|
||||||
|
<Loader2 class="spin" :size="18" aria-hidden="true" />
|
||||||
|
<span>正在加载组织架构</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="visibleTreeRows.length === 0" class="organization-tree-empty">
|
||||||
|
<span>暂无组织架构,请先新增部门</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="row in visibleTreeRows"
|
||||||
|
v-else
|
||||||
|
:key="row.node.key"
|
||||||
|
class="tree-node"
|
||||||
|
:class="[
|
||||||
|
`tree-node-${row.node.type}`,
|
||||||
|
{
|
||||||
|
'is-selected': selectedNodeKey === row.node.key,
|
||||||
|
'is-inactive': !row.node.is_active,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:style="treeNodeStyle(row.depth)"
|
||||||
|
type="button"
|
||||||
|
@click="selectTreeNode(row.node)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="row.node.type === 'department' && row.node.children.length"
|
||||||
|
class="tree-toggle"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
@click.stop="toggleDepartment(row.node)"
|
||||||
|
>
|
||||||
|
<ChevronDown v-if="expandedDepartmentIds.has(row.node.id)" :size="15" aria-hidden="true" />
|
||||||
|
<ChevronRight v-else :size="15" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span v-else class="tree-toggle is-placeholder" aria-hidden="true"></span>
|
||||||
|
<span class="tree-node-icon">
|
||||||
|
<Building2 v-if="row.node.type === 'department'" :size="16" aria-hidden="true" />
|
||||||
|
<BriefcaseBusiness v-else :size="16" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span class="tree-node-main">
|
||||||
|
<strong>{{ row.node.name }}</strong>
|
||||||
|
<small>{{ row.node.code }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="status-chip" :class="row.node.is_active ? 'green' : 'red'">
|
||||||
|
{{ row.node.is_active ? "启用" : "停用" }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="panel organization-detail-panel">
|
||||||
|
<div class="panel-header organization-detail-header">
|
||||||
|
<div>
|
||||||
|
<h2>{{ detailTitle }}</h2>
|
||||||
|
<p>{{ detailSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="organization-detail-actions">
|
||||||
|
<button class="secondary-button compact-button" type="button" @click="startNewRootDepartment">
|
||||||
|
<Plus :size="16" aria-hidden="true" />
|
||||||
|
<span>新增部门</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="secondary-button compact-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="!actionDepartmentId"
|
||||||
|
@click="startNewChildDepartment"
|
||||||
|
>
|
||||||
|
<Plus :size="16" aria-hidden="true" />
|
||||||
|
<span>新增下级</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="primary-button compact-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="!actionDepartmentId && activeDepartments.length === 0"
|
||||||
|
@click="startNewPosition"
|
||||||
|
>
|
||||||
|
<Plus :size="16" aria-hidden="true" />
|
||||||
|
<span>新增岗位</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedDepartment" class="organization-node-summary">
|
||||||
|
<div>
|
||||||
|
<span>上级部门</span>
|
||||||
|
<strong>{{ selectedDepartment.parent_id ? departmentPath(selectedDepartment.parent_id).join(" / ") : "无上级" }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>下级部门</span>
|
||||||
|
<strong>{{ selectedDepartmentChildren.length }} 个</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>直属岗位</span>
|
||||||
|
<strong>{{ selectedDepartmentPositions.length }} 个</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-if="currentEditor === 'department'"
|
||||||
|
class="form-grid compact-form organization-editor-form"
|
||||||
|
@submit.prevent="submitDepartment"
|
||||||
|
>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>部门编码</span>
|
<span>部门编码</span>
|
||||||
<input v-model="departmentForm.department_code" required maxlength="64" type="text" />
|
<input v-model="departmentForm.department_code" required maxlength="64" type="text" />
|
||||||
@@ -230,13 +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>
|
||||||
|
|||||||
+155
-114
@@ -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>
|
||||||
|
<h2>钉钉同步</h2>
|
||||||
|
<p>{{ dingtalkScopeText }}</p>
|
||||||
|
</div>
|
||||||
|
</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" />
|
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
|
||||||
<Play v-else :size="18" aria-hidden="true" />
|
<Play v-else :size="18" aria-hidden="true" />
|
||||||
<span>{{ loading === "dingtalk" ? "计算中" : "开始计算钉钉" }}</span>
|
<span>{{ loading === "dingtalk" ? "同步中" : "开始同步" }}</span>
|
||||||
</button>
|
</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>
|
||||||
@@ -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">
|
|
||||||
<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>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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,55 +204,138 @@ 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>
|
</button>
|
||||||
|
<button class="primary-button compact-button" type="button" @click="openCreateProfile">
|
||||||
|
<Plus :size="16" aria-hidden="true" />
|
||||||
|
<span>新增档案</span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="empty-state">
|
||||||
|
<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>
|
||||||
|
<tr>
|
||||||
|
<th>员工</th>
|
||||||
|
<th>部门 / 岗位</th>
|
||||||
|
<th>薪资模式</th>
|
||||||
|
<th>计算规则</th>
|
||||||
|
<th>底薪 / 提成</th>
|
||||||
|
<th>加班单价</th>
|
||||||
|
<th>生效月份</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="profile in profiles" :key="profile.id">
|
||||||
|
<td>
|
||||||
|
<strong>{{ profile.employee.name }}</strong>
|
||||||
|
<span>{{ profile.employee.employee_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ profile.employee.department || "-" }}</strong>
|
||||||
|
<span>{{ profile.employee.position || "-" }}</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="status-chip neutral">{{ salaryModeLabel(profile.salary_mode) }}</span></td>
|
||||||
|
<td class="mono strong">{{ profileRuleText(profile) }}</td>
|
||||||
|
<td class="salary-profile-pay-cell">
|
||||||
|
<strong>{{ money(profile.base_salary) }}</strong>
|
||||||
|
<span>提成 {{ money(profile.commission_amount) }}</span>
|
||||||
|
</td>
|
||||||
|
<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>{{ profile.effective_month || "长期" }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="link-button" type="button" @click="editProfile(profile)">
|
||||||
|
<Pencil :size="15" aria-hidden="true" />
|
||||||
|
<span>编辑薪资</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p>暂无薪资档案</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<div v-if="showEditor" class="modal-backdrop salary-profile-edit-modal" @click.self="closeEditor">
|
||||||
<div class="panel-header">
|
<section class="profile-modal salary-profile-edit-card" aria-label="薪资档案维护">
|
||||||
|
<div class="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>维护薪资档案</h2>
|
<h2>{{ editorTitle }}</h2>
|
||||||
<p>一名员工维护一条当前薪资档案</p>
|
<p>在弹出卡片内维护员工当前薪资档案和独立加班单价。</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary-button" type="button" @click="resetForm">清空</button>
|
<button class="icon-button" type="button" title="关闭" @click="closeEditor">
|
||||||
|
<X :size="18" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="form-grid salary-profile-form" @submit.prevent="submit">
|
<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">
|
<label class="field">
|
||||||
<span>关联员工</span>
|
<span>关联员工</span>
|
||||||
<select v-model.number="form.employee_id" required>
|
<select v-model.number="form.employee_id" required>
|
||||||
@@ -256,6 +357,19 @@ function money(value: number): string {
|
|||||||
<span>基础工资/底薪</span>
|
<span>基础工资/底薪</span>
|
||||||
<input v-model.number="form.base_salary" min="0" step="0.01" type="number" />
|
<input v-model.number="form.base_salary" min="0" step="0.01" type="number" />
|
||||||
</label>
|
</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">
|
<label class="field">
|
||||||
<span>计时小时单价</span>
|
<span>计时小时单价</span>
|
||||||
<input v-model.number="form.hourly_rate" min="0" step="0.01" type="number" />
|
<input v-model.number="form.hourly_rate" min="0" step="0.01" type="number" />
|
||||||
@@ -283,6 +397,15 @@ function money(value: number): string {
|
|||||||
<span>试用期固定金额</span>
|
<span>试用期固定金额</span>
|
||||||
<input v-model.number="form.probation_salary" min="0" step="0.01" type="number" />
|
<input v-model.number="form.probation_salary" min="0" step="0.01" type="number" />
|
||||||
</label>
|
</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">
|
<label class="field">
|
||||||
<span>默认提成</span>
|
<span>默认提成</span>
|
||||||
<input v-model.number="form.commission_amount" min="0" step="0.01" type="number" />
|
<input v-model.number="form.commission_amount" min="0" step="0.01" type="number" />
|
||||||
@@ -299,15 +422,24 @@ function money(value: number): string {
|
|||||||
<span>节假日加班单价</span>
|
<span>节假日加班单价</span>
|
||||||
<input v-model.number="form.holiday_overtime_rate" min="0" step="0.01" type="number" />
|
<input v-model.number="form.holiday_overtime_rate" min="0" step="0.01" type="number" />
|
||||||
</label>
|
</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">
|
<label class="field">
|
||||||
<span>生效月份</span>
|
|
||||||
<input v-model="form.effective_month" type="month" />
|
|
||||||
</label>
|
|
||||||
<label class="field span-row">
|
|
||||||
<span>备注</span>
|
<span>备注</span>
|
||||||
<textarea v-model="form.remark" />
|
<textarea v-model="form.remark" />
|
||||||
</label>
|
</label>
|
||||||
<div class="form-actions align-right span-row">
|
</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">
|
<button class="primary-button" type="submit" :disabled="saving">
|
||||||
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
|
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
|
||||||
<Save v-else :size="18" aria-hidden="true" />
|
<Save v-else :size="18" aria-hidden="true" />
|
||||||
@@ -315,65 +447,7 @@ function money(value: number): string {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
|
||||||
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel table-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>薪资档案列表</h2>
|
|
||||||
<p>{{ profiles.length }} 条薪资档案</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div 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>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="profile in profiles" :key="profile.id">
|
|
||||||
<td>
|
|
||||||
<strong>{{ profile.employee.name }}</strong>
|
|
||||||
<span>{{ profile.employee.employee_no }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<strong>{{ profile.employee.department || "-" }}</strong>
|
|
||||||
<span>{{ profile.employee.position || "-" }}</span>
|
|
||||||
</td>
|
|
||||||
<td><span class="status-chip neutral">{{ salaryModeLabel(profile.salary_mode) }}</span></td>
|
|
||||||
<td class="mono strong">{{ profileRuleText(profile) }}</td>
|
|
||||||
<td class="mono">{{ money(profile.commission_amount) }}</td>
|
|
||||||
<td class="mono">
|
|
||||||
工作日 {{ money(profile.overtime_rate) }}/h
|
|
||||||
<span>周末 {{ money(profile.weekend_overtime_rate) }}/h</span>
|
|
||||||
<span>节假日 {{ money(profile.holiday_overtime_rate) }}/h</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ profile.effective_month || "长期" }}</td>
|
|
||||||
<td class="note-cell">{{ profile.remark || "-" }}</td>
|
|
||||||
<td>
|
|
||||||
<button class="link-button" type="button" @click="editProfile(profile)">
|
|
||||||
<Pencil :size="15" aria-hidden="true" />
|
|
||||||
<span>编辑</span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div v-if="!profiles.length && !loading" class="empty-state">
|
|
||||||
<p>暂无薪资档案</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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,6 +66,17 @@ 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)];
|
||||||
|
resetForm();
|
||||||
|
showUserModal.value = false;
|
||||||
|
successMessage.value = "用户已创建";
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof ApiError ? error.message : "用户创建失败";
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm(): void {
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
username: "",
|
username: "",
|
||||||
display_name: "",
|
display_name: "",
|
||||||
@@ -74,12 +87,19 @@ async function submit(): Promise<void> {
|
|||||||
password: "",
|
password: "",
|
||||||
role: "viewer",
|
role: "viewer",
|
||||||
});
|
});
|
||||||
successMessage.value = "用户已创建";
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = error instanceof ApiError ? error.message : "用户创建失败";
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -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">
|
|
||||||
<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>
|
<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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "\\$&");
|
||||||
|
}
|
||||||
@@ -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, "\\$&");
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} 之前`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} 之前`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "\\$&");
|
||||||
|
}
|
||||||
@@ -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, "\\$&");
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user