初步提交

This commit is contained in:
焦龙言 2026-06-18 13:21:20 +08:00
commit 99c7ba776f
133 changed files with 20680 additions and 0 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
APP_CONFIG_FILE=config/app_settings.json

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
__pycache__/
*.py[cod]
.env
uploads/
outputs/
data/
logs/
static/uploads/
frontend/node_modules/
frontend/dist/

10
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.10 (Financial_System)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -0,0 +1,25 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N806" />
<option value="N803" />
<option value="N802" />
<option value="N801" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="list.to_numpy" />
<option value="str.__or__" />
<option value="str.like" />
<option value="nacos.client" />
</list>
</option>
</inspection_tool>
</profile>
</component>

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.10 (Financial_System)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (Financial_System)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Financial_System.iml" filepath="$PROJECT_DIR$/.idea/Financial_System.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

316
README.md Normal file
View File

@ -0,0 +1,316 @@
# 薪酬考勤管理系统
本项目是一套前后端分离的薪酬考勤管理系统,用于处理钉钉月度汇总 Excel、后续钉钉实时接口、员工档案、薪资档案、提成、考勤扣款、加班费、工资汇总和统计报表。
核心目标是把人工工资核算链路拆成可维护的数据流:
```text
钉钉考勤接口 / Excel导入
-> 数据标准化
-> 考勤 / 请假 / 加班引擎
-> 工时统计
-> 薪资 / 提成 / 奖惩计算
-> 工资汇总
-> 工资条 / 月报表 / 财务报表
```
## 当前能力
- 支持导入钉钉月度汇总 Excel 计算工资。
- 支持预留钉钉实时打卡接口计算入口。
- 自动统计出勤天数、缺勤天数、请假工时、缺卡、迟到扣款。
- 按下班打卡时间自动计算加班17:00 下班18:00 计 1 小时20:30 计 3 小时。
- 自动计算:`剩余加班工时 = 打卡推算加班工时 - 请假工时`。
- 迟到规则5 分钟内 3 次合格,超过后每次扣 30 元;超过 5 分钟每次扣 30 元。
- 缺卡规则:每次扣 20 元。
- 支持组织架构、员工维护、薪资维护、提成管理、规则配置、操作日志、统计报表。
- 薪资模式支持:包月、计时、计件、试用期。
- 加班费支持:工作日、周末、法定节假日独立单价。
- 提成支持手工维护和 Excel 导入,并按工资月份自动汇总进工资计算。
- 工资计算结果会落库为工资汇总、工资明细、考勤记录、请假记录和加班记录。
## 项目结构
```text
main.py 后端启动入口
config/ 后端配置文件
financial_system/
api/ FastAPI app、路由、schema、依赖注入
core/ 配置、权限、安全、日志
database/ ORM、数据库会话、SQL脚本
domain/ 领域模型、工资计算器
integrations/ 钉钉等外部系统适配
io/ Excel解析、导出、文件存储
repositories/ 数据库读写层
services/ 业务服务层
frontend/ Vue3 前端项目
```
分层约定:
- `routers` 只处理 HTTP、权限、参数和操作日志。
- `schemas` 只定义请求响应结构。
- `services` 只做业务编排和校验。
- `repositories` 只负责 ORM 读写。
- `domain` 只负责纯计算规则,不依赖数据库和 HTTP。
## 运行环境
后端使用 Conda 环境:
```bash
/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python main.py
```
默认访问:
- 后端接口文档http://127.0.0.1:8000/docs
- 健康检查http://127.0.0.1:8000/health
- 前端开发地址http://127.0.0.1:5173
前端启动:
```bash
cd frontend
npm install
npm run dev
```
前端构建:
```bash
cd frontend
npm run build
```
## 统一配置
后端配置统一放在:
```text
config/app_settings.json
```
常用配置:
| 配置 | 说明 |
| --- | --- |
| `server.host` / `server.port` | 后端监听地址和端口 |
| `database.url` | 数据库连接 |
| `dingtalk.app_key` / `dingtalk.app_secret` | 钉钉开放平台凭证 |
| `storage.upload_dir` | 上传文件目录 |
| `storage.output_dir` | 导出工资文件目录 |
| `storage.avatar_dir` | 头像上传目录 |
| `logging.*` | info/error 日志文件和滚动策略 |
| `auth.secret_key` | Token 签名密钥 |
| `bootstrap_superuser.*` | 首次启动超级管理员 |
| `cors.allowed_origins` | 前端跨域来源 |
默认 MySQL
```json
{
"database": {
"url": "mysql+pymysql://root:12345678@localhost:3306/financial_system?charset=utf8mb4"
}
}
```
如需使用其他配置文件:
```bash
APP_CONFIG_FILE=/path/to/app_settings.json /Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python main.py
```
## 数据库
本地 MySQL
```text
host: localhost
port: 3306
user: root
password: 12345678
database: financial_system
```
完整初始化:
```bash
mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/init_mysql.sql
```
已有旧库升级到当前版本:
```bash
mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260617_payroll_modules.sql
```
SQL 文件说明:
| 文件 | 说明 |
| --- | --- |
| `schema.sql` | 建库建表脚本,包含所有表和字段注释 |
| `seed.sql` | 初始化默认超级管理员 |
| `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 |
| `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 |
主要业务表:
| 表 | 说明 |
| --- | --- |
| `users` | 系统用户 |
| `departments` | 部门及上下级关系 |
| `positions` | 部门下的岗位 |
| `employees` | 员工档案 |
| `salary_profiles` | 员工薪资档案 |
| `attendance_record` | 标准化考勤记录 |
| `leave_record` | 请假/调休记录 |
| `overtime_record` | 加班记录 |
| `commission_record` | 提成/奖金记录 |
| `payroll_jobs` | 工资计算任务 |
| `payroll_results` | 员工工资计算结果 |
| `salary_record` | 月度工资汇总 |
| `salary_detail` | 工资明细项 |
| `salary_config` | 规则配置中心 |
| `operation_logs` | 操作日志 |
服务启动时会执行 ORM 建表检查,并补齐默认超级管理员和规则配置。
组织架构启动时会按内置层级补齐部门和岗位:
- 部门顺序按总经办、行政人事部、财务部、订单部、工程部、杭州运营中心、华南/华中/西南运营中心、生产中心等层级维护。
- `工程部-安装队`、`工程部-项目预算` 会挂到自动补齐的 `工程部` 父级下。
- `杭州运营中心-销售部` 会挂到自动补齐的 `杭州运营中心` 父级下。
- `生产中心-采购部/仓库/品管部/生产部` 会挂到 `生产中心` 下。
- `西南运营中心-办事处/销售部` 会挂到 `西南运营中心` 下。
- 岗位编码由系统自动生成,页面只展示编码,不需要人工维护。
员工维护中,员工编号同样由系统自动生成,默认格式为 `ZA0001`、`ZA0002` 递增,前端只展示不手工维护。
## 规则配置
规则配置既可以通过前端“规则配置”页面维护,也可以参考:
```text
config/salary_rules.example.json
```
默认关键规则:
- 上班时间08:30
- 下班时间17:00
- 加班取整60 分钟向下取整
- 标准日工时8 小时
- 5 分钟内迟到免扣次数3 次
- 迟到扣款30 元/次
- 缺卡扣款20 元/次
- 周末定义:周六、周日
- 法定节假日:可在规则配置中维护日期数组
工资公式:
```text
应发工资 = 基础工资 + 提成 + 加班费 - 迟到扣款 - 缺卡扣款 - 其他扣款
```
基础工资按薪资模式计算:
- 包月:固定底薪
- 计时:实际工作工时 × 小时单价
- 计件:计件数量 × 计件单价
- 试用期:底薪 × 固定比例,或固定金额
## 前端页面
| 页面 | 路由 | 说明 |
| --- | --- | --- |
| 登录 | `/login` | 用户登录 |
| 工作台 | `/dashboard` | 系统概览 |
| 工资计算 | `/payroll` | Excel 导入和钉钉实时计算 |
| 计算记录 | `/payroll/jobs` | 查看历史计算任务 |
| 提成管理 | `/payroll/commissions` | 手工维护和 Excel 导入提成 |
| 统计报表 | `/reports` | 工资汇总、部门汇总、考勤工时 |
| 组织架构 | `/system/organization` | 维护部门上下级和部门岗位 |
| 员工维护 | `/system/employees` | 员工分页维护 |
| 薪资维护 | `/system/salary-profiles` | 员工薪资模式和加班单价 |
| 规则配置 | `/system/configs` | 考勤和薪资规则 |
| 用户管理 | `/system/users` | 超级用户创建账号 |
| 操作日志 | `/system/operation-logs` | 查看系统操作审计 |
前端支持主题色切换、侧边栏收起、个人资料修改、头像上传、修改密码。
## 登录与权限
首次启动默认超级管理员:
```text
用户名admin
密码Admin@123456
角色superuser
```
角色:
| 角色 | 说明 |
| --- | --- |
| `superuser` | 超级用户,拥有全部菜单和操作权限 |
| `manager` | 管理者,可维护组织架构、员工、薪资、提成、规则、报表和工资计算 |
| `viewer` | 查看用户,可查看计算记录、下载结果和统计报表 |
登录示例:
```bash
curl -X POST "http://127.0.0.1:8000/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin@123456"}'
```
## 常用接口
上传 Excel 计算工资:
```bash
curl -X POST "http://127.0.0.1:8000/api/payroll/excel" \
-H "Authorization: Bearer <access_token>" \
-F "file=@/path/to/月度汇总.xlsx" \
-F "export_excel=true"
```
钉钉实时计算:
```bash
curl -X POST "http://127.0.0.1:8000/api/payroll/dingtalk/realtime" \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"user_ids":["001","002"],"start_date":"2026-05-01","end_date":"2026-05-31","export_excel":true}'
```
新增提成:
```bash
curl -X POST "http://127.0.0.1:8000/api/commissions" \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"employee_id":1,"commission_month":"2026-06","commission_type":"销售提成","amount":1000,"source_type":"manual","business_ref":"","remark":""}'
```
查询统计报表:
```bash
curl "http://127.0.0.1:8000/api/reports/payroll-summary?salary_month=2026-06" \
-H "Authorization: Bearer <access_token>"
```
## 日志
日志统一由 `financial_system/core/logger.py` 封装。
默认文件:
| 文件 | 说明 |
| --- | --- |
| `logs/info.log` | 正常流程日志 |
| `logs/error.log` | 错误和异常日志 |
修改 `config/app_settings.json``logging.*` 后重启服务即可生效。

47
config/app_settings.json Normal file
View File

@ -0,0 +1,47 @@
{
"server": {
"host": "0.0.0.0",
"port": 8000,
"fallback_port": 8001
},
"logging": {
"dir": "logs",
"level": "INFO",
"info_file": "info.log",
"error_file": "error.log",
"max_bytes": 10485760,
"backup_count": 10,
"console_enabled": true
},
"database": {
"url": "mysql+pymysql://root:12345678@localhost:3306/financial_system?charset=utf8mb4"
},
"storage": {
"upload_dir": "uploads",
"output_dir": "outputs",
"avatar_dir": "static/uploads/avatars"
},
"dingtalk": {
"app_key": "",
"app_secret": "",
"base_url": "https://oapi.dingtalk.com",
"timezone": "Asia/Shanghai"
},
"auth": {
"secret_key": "financial-system-dev-secret",
"access_token_expire_minutes": 480
},
"bootstrap_superuser": {
"username": "admin",
"password": "Admin@123456",
"display_name": "超级管理员"
},
"cors": {
"allowed_origins": [
"http://127.0.0.1:5173",
"http://localhost:5173",
"http://127.0.0.1:5174",
"http://localhost:5174"
]
}
}

View File

@ -0,0 +1,36 @@
{
"attendance": {
"on_work_time": "08:30",
"off_work_time": "17:00",
"overtime_round_minutes": 60,
"standard_daily_hours": 8,
"minor_late_minutes": 5,
"minor_late_free_times": 3,
"late_penalty": 30,
"missing_card_penalty": 20,
"weekend_days": [5, 6],
"legal_holidays": ["2026-10-01", "2026-10-02"],
"leave_keywords": ["调休", "请假", "事假", "病假", "年假", "婚假", "产假", "陪产假", "丧假"]
},
"payroll": {
"default_overtime_rate": 0,
"default_weekend_overtime_rate": 0,
"default_holiday_overtime_rate": 0,
"employees": {
"张三": {
"salary_mode": "monthly",
"base_salary": 6000,
"hourly_rate": 0,
"piece_quantity": 0,
"piece_unit_price": 0,
"probation_type": "ratio",
"probation_ratio": 1,
"probation_salary": null,
"commission_amount": 500,
"overtime_rate": 25,
"weekend_overtime_rate": 35,
"holiday_overtime_rate": 50
}
}
}
}

View File

@ -0,0 +1,5 @@
"""Attendance-driven payroll calculator for monthly DingTalk-style summaries."""
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@ -0,0 +1,3 @@
from .app import app, create_app
__all__ = ["app", "create_app"]

138
financial_system/api/app.py Normal file
View File

@ -0,0 +1,138 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from ..core.logger import AppLogger
from ..core.settings import PROJECT_ROOT, get_settings
from ..database import SessionLocal, init_database
from ..repositories import OrganizationRepository, SalaryConfigRepository, UserRepository
from ..services import AuthService, OrganizationService, SalaryConfigService
from .routers import auth, commissions, configs, employees, health, operation_logs, organization, payroll, reports, salary_profiles
logger = AppLogger.get_logger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI):
"""应用启动时建表并保证至少有一个超级用户。"""
logger.info("应用启动初始化开始")
try:
init_database()
_bootstrap_superuser()
_bootstrap_salary_configs()
_bootstrap_departments()
except Exception:
logger.exception("应用启动初始化失败")
raise
logger.info("应用启动初始化完成")
yield
logger.info("应用关闭")
def create_app() -> FastAPI:
"""集中创建 FastAPI 应用,路由注册和启动生命周期都在这里完成。"""
settings = get_settings()
AppLogger.configure(settings)
logger.info("创建 FastAPI 应用 config_path=%s", settings.config_path)
app = FastAPI(
title="Financial System Payroll API",
description="支持导入月度汇总 Excel也支持对接钉钉实时打卡记录进行工资计算。",
version="0.1.0",
docs_url=None,
lifespan=lifespan,
)
_register_cors(app, settings.cors_allowed_origins)
_mount_local_swagger_assets(app)
_register_docs_route(app)
_register_exception_handlers(app)
app.include_router(auth.router)
app.include_router(commissions.router)
app.include_router(configs.router)
app.include_router(employees.router)
app.include_router(health.router)
app.include_router(operation_logs.router)
app.include_router(organization.router)
app.include_router(payroll.router)
app.include_router(reports.router)
app.include_router(salary_profiles.router)
return app
def _register_cors(app: FastAPI, allowed_origins: tuple[str, ...]) -> None:
"""允许前端独立端口访问 API生产环境可通过环境变量收窄来源。"""
app.add_middleware(
CORSMiddleware,
allow_origins=list(allowed_origins),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def _register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception("未处理异常 method=%s path=%s", request.method, request.url.path)
return JSONResponse(status_code=500, content={"detail": "服务器内部错误,请查看错误日志"})
def _mount_local_swagger_assets(app: FastAPI) -> None:
"""挂载本地 Swagger 静态文件,避免依赖外部 CDN。"""
static_dir = PROJECT_ROOT / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=static_dir), name="static")
logger.info("已挂载本地 Swagger 静态资源 static_dir=%s", static_dir)
def _register_docs_route(app: FastAPI) -> None:
@app.get("/docs", include_in_schema=False)
def custom_swagger_ui_html():
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=f"{app.title} - Swagger UI",
swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
swagger_css_url="/static/swagger-ui/swagger-ui.css",
swagger_favicon_url="/static/swagger-ui/favicon.png",
)
def _bootstrap_superuser() -> None:
"""首次运行时初始化超级用户;已有用户时不覆盖数据库。"""
session = SessionLocal()
try:
service = AuthService(UserRepository(session), get_settings())
service.ensure_bootstrap_superuser()
logger.info("超级管理员初始化检查完成")
finally:
session.close()
def _bootstrap_salary_configs() -> None:
"""首次运行时补齐规则配置中心默认项。"""
session = SessionLocal()
try:
service = SalaryConfigService(SalaryConfigRepository(session))
service.ensure_defaults()
logger.info("系统规则配置初始化检查完成")
finally:
session.close()
def _bootstrap_departments() -> None:
"""首次运行时补齐组织架构默认部门和岗位。"""
session = SessionLocal()
try:
service = OrganizationService(OrganizationRepository(session))
service.ensure_default_departments()
logger.info("组织架构默认部门和岗位初始化检查完成")
finally:
session.close()
app = create_app()

View File

@ -0,0 +1,209 @@
from __future__ import annotations
from collections.abc import Generator
from typing import Callable
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from ..core.logger import AppLogger
from ..core.permissions import has_permission
from ..core.security import TokenError, decode_access_token
from ..core.settings import AppSettings, get_settings
from ..database import SessionLocal
from ..database.orm import UserORM
from ..repositories import (
CommissionRepository,
EmployeeRepository,
OperationLogRepository,
OrganizationRepository,
PayrollRepository,
ReportRepository,
SalaryConfigRepository,
SalaryProfileRepository,
UserRepository,
)
from ..services import (
AuthService,
CommissionService,
EmployeeService,
OperationLogService,
OrganizationService,
PayrollApplicationService,
ReportService,
SalaryConfigService,
SalaryProfileService,
)
from ..io.storage import FileStorage
bearer_scheme = HTTPBearer(auto_error=False)
logger = AppLogger.get_logger(__name__)
def get_db_session() -> Generator[Session, None, None]:
"""为单次 HTTP 请求提供数据库会话,并在请求结束后关闭。"""
session = SessionLocal()
try:
yield session
finally:
session.close()
def get_app_settings() -> AppSettings:
return get_settings()
def get_payroll_repository(session: Session = Depends(get_db_session)) -> PayrollRepository:
return PayrollRepository(session)
def get_commission_repository(session: Session = Depends(get_db_session)) -> CommissionRepository:
return CommissionRepository(session)
def get_employee_repository(session: Session = Depends(get_db_session)) -> EmployeeRepository:
return EmployeeRepository(session)
def get_report_repository(session: Session = Depends(get_db_session)) -> ReportRepository:
return ReportRepository(session)
def get_salary_config_repository(session: Session = Depends(get_db_session)) -> SalaryConfigRepository:
return SalaryConfigRepository(session)
def get_salary_profile_repository(session: Session = Depends(get_db_session)) -> SalaryProfileRepository:
return SalaryProfileRepository(session)
def get_user_repository(session: Session = Depends(get_db_session)) -> UserRepository:
return UserRepository(session)
def get_operation_log_repository(session: Session = Depends(get_db_session)) -> OperationLogRepository:
return OperationLogRepository(session)
def get_organization_repository(session: Session = Depends(get_db_session)) -> OrganizationRepository:
return OrganizationRepository(session)
def get_auth_service(
repository: UserRepository = Depends(get_user_repository),
settings: AppSettings = Depends(get_app_settings),
) -> AuthService:
return AuthService(repository=repository, settings=settings)
def get_operation_log_service(
repository: OperationLogRepository = Depends(get_operation_log_repository),
) -> OperationLogService:
return OperationLogService(repository=repository)
def get_organization_service(
repository: OrganizationRepository = Depends(get_organization_repository),
) -> OrganizationService:
return OrganizationService(repository=repository)
def get_employee_service(
repository: EmployeeRepository = Depends(get_employee_repository),
) -> EmployeeService:
return EmployeeService(repository=repository)
def get_salary_profile_service(
repository: SalaryProfileRepository = Depends(get_salary_profile_repository),
employee_repository: EmployeeRepository = Depends(get_employee_repository),
) -> SalaryProfileService:
return SalaryProfileService(repository=repository, employee_repository=employee_repository)
def get_commission_service(
repository: CommissionRepository = Depends(get_commission_repository),
employee_repository: EmployeeRepository = Depends(get_employee_repository),
settings: AppSettings = Depends(get_app_settings),
) -> CommissionService:
return CommissionService(
repository=repository,
employee_repository=employee_repository,
storage=FileStorage(settings),
)
def get_salary_config_service(
repository: SalaryConfigRepository = Depends(get_salary_config_repository),
) -> SalaryConfigService:
return SalaryConfigService(repository=repository)
def get_report_service(
repository: ReportRepository = Depends(get_report_repository),
) -> ReportService:
return ReportService(repository=repository)
def get_payroll_service(
repository: PayrollRepository = Depends(get_payroll_repository),
salary_repository: SalaryProfileRepository = Depends(get_salary_profile_repository),
commission_repository: CommissionRepository = Depends(get_commission_repository),
config_repository: SalaryConfigRepository = Depends(get_salary_config_repository),
settings: AppSettings = Depends(get_app_settings),
) -> PayrollApplicationService:
return PayrollApplicationService(
repository=repository,
settings=settings,
salary_repository=salary_repository,
commission_repository=commission_repository,
config_service=SalaryConfigService(config_repository),
)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
repository: UserRepository = Depends(get_user_repository),
settings: AppSettings = Depends(get_app_settings),
) -> UserORM:
"""解析 Bearer token并返回仍然有效的当前用户。"""
if credentials is None:
logger.error("认证失败 reason=missing_bearer_token")
raise HTTPException(status_code=401, detail="请先登录")
try:
payload = decode_access_token(credentials.credentials, settings.auth_secret_key)
user_id = int(payload["sub"])
except (KeyError, TypeError, ValueError, TokenError) as exc:
logger.error("认证失败 reason=invalid_or_expired_token")
raise HTTPException(status_code=401, detail="登录状态无效或已过期") from exc
user = repository.get_by_id(user_id)
if user is None or not user.is_active:
logger.error("认证失败 user_id=%s reason=user_not_found_or_inactive", user_id)
raise HTTPException(status_code=401, detail="用户不存在或已停用")
logger.info("认证成功 user_id=%s username=%s role=%s", user.id, user.username, user.role)
return user
def require_permission(permission: str) -> Callable:
"""生成路由级权限依赖,让路由只声明所需权限。"""
def dependency(user: UserORM = Depends(get_current_user)) -> UserORM:
if not has_permission(user.role, permission):
logger.error(
"权限校验失败 user_id=%s username=%s role=%s permission=%s",
user.id,
user.username,
user.role,
permission,
)
raise HTTPException(status_code=403, detail="没有该操作权限")
logger.info(
"权限校验通过 user_id=%s username=%s role=%s permission=%s",
user.id,
user.username,
user.role,
permission,
)
return user
return dependency

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi import Request
def request_client_ip(request: Request) -> str:
"""提取客户端 IP优先读取代理转发头。"""
forwarded_for = request.headers.get("x-forwarded-for", "")
if forwarded_for:
return forwarded_for.split(",", 1)[0].strip()
return request.client.host if request.client else ""
def request_user_agent(request: Request) -> str:
"""提取浏览器 User-Agent。"""
return request.headers.get("user-agent", "")
def operation_request_meta(request: Request) -> dict[str, str]:
"""生成操作日志使用的请求元信息。"""
return {
"ip_address": request_client_ip(request),
"user_agent": request_user_agent(request),
}

View File

@ -0,0 +1 @@
__all__ = ["auth", "employees", "health", "operation_logs", "payroll", "salary_profiles"]

View File

@ -0,0 +1,266 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from ...core.logger import AppLogger
from ...core.permissions import PERMISSION_USER_CREATE, PERMISSION_USER_LIST
from ...database.orm import UserORM
from ...services import AuthService, OperationLogService
from ..dependencies import get_auth_service, get_current_user, get_operation_log_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.auth import (
ChangePasswordRequest,
ChangePasswordResponse,
CreateUserRequest,
LoginRequest,
LoginResponse,
UpdateProfileRequest,
UserProfileDTO,
UserResponse,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
logger = AppLogger.get_logger(__name__)
@router.post("/login", response_model=LoginResponse)
def login(
http_request: Request,
request: LoginRequest,
service: AuthService = Depends(get_auth_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> LoginResponse:
"""登录成功后返回 token、可见菜单和可执行操作权限。"""
try:
session = service.login(request.username, request.password)
except ValueError as exc:
operation_logs.record(
actor=None,
module="auth",
action="login",
target_type="user",
target_id=request.username,
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("登录接口失败 username=%s reason=%s", request.username, exc)
raise HTTPException(status_code=401, detail=str(exc)) from exc
operation_logs.record(
actor=session.user,
module="auth",
action="login",
target_type="user",
target_id=str(session.user.id),
status="success",
detail="用户登录成功",
**operation_request_meta(http_request),
)
return LoginResponse.from_session(session)
@router.get("/me", response_model=UserProfileDTO)
def me(current_user: UserORM = Depends(get_current_user)) -> UserProfileDTO:
"""前端刷新页面时用它恢复当前用户权限上下文。"""
return UserProfileDTO.from_orm_model(current_user)
@router.put("/me", response_model=UserProfileDTO)
def update_me(
http_request: Request,
request: UpdateProfileRequest,
current_user: UserORM = Depends(get_current_user),
service: AuthService = Depends(get_auth_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> UserProfileDTO:
"""当前登录用户修改个人基础信息。"""
try:
user = service.update_profile(
current_user.id,
display_name=request.display_name,
email=request.email,
phone=request.phone,
department=request.department,
position_title=request.position_title,
)
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="auth",
action="profile.update",
target_type="user",
target_id=str(current_user.id),
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("修改个人资料接口失败 user_id=%s reason=%s", current_user.id, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=user,
module="auth",
action="profile.update",
target_type="user",
target_id=str(user.id),
status="success",
detail="修改个人资料成功",
**operation_request_meta(http_request),
)
return UserProfileDTO.from_orm_model(user)
@router.put("/me/password", response_model=ChangePasswordResponse)
def change_my_password(
http_request: Request,
request: ChangePasswordRequest,
current_user: UserORM = Depends(get_current_user),
service: AuthService = Depends(get_auth_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> ChangePasswordResponse:
"""当前登录用户修改自己的登录密码。"""
try:
service.change_password(
current_user.id,
old_password=request.old_password,
new_password=request.new_password,
confirm_password=request.confirm_password,
)
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="auth",
action="password.change",
target_type="user",
target_id=str(current_user.id),
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("修改密码接口失败 user_id=%s reason=%s", current_user.id, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="auth",
action="password.change",
target_type="user",
target_id=str(current_user.id),
status="success",
detail="修改密码成功",
**operation_request_meta(http_request),
)
return ChangePasswordResponse(message="密码修改成功")
@router.post("/me/avatar", response_model=UserProfileDTO)
def update_my_avatar(
http_request: Request,
file: UploadFile = File(...),
current_user: UserORM = Depends(get_current_user),
service: AuthService = Depends(get_auth_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> UserProfileDTO:
"""当前登录用户上传并修改头像。"""
try:
user = service.update_avatar(
current_user.id,
filename=file.filename or "avatar.png",
stream=file.file,
)
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="auth",
action="avatar.update",
target_type="user",
target_id=str(current_user.id),
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("修改头像接口失败 user_id=%s filename=%s reason=%s", current_user.id, file.filename, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=user,
module="auth",
action="avatar.update",
target_type="user",
target_id=str(user.id),
status="success",
detail=f"修改头像成功 filename={file.filename or 'avatar.png'}",
**operation_request_meta(http_request),
)
return UserProfileDTO.from_orm_model(user)
@router.post(
"/users",
response_model=UserResponse,
)
def create_user(
http_request: Request,
request: CreateUserRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_USER_CREATE)),
service: AuthService = Depends(get_auth_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> UserResponse:
"""只有超级用户可以创建用户,角色决定菜单和操作权限。"""
try:
user = service.create_user(
username=request.username,
password=request.password,
role=request.role,
display_name=request.display_name,
email=request.email,
phone=request.phone,
department=request.department,
position_title=request.position_title,
)
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="system",
action="user.create",
target_type="user",
target_id=request.username,
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("创建用户接口失败 username=%s role=%s reason=%s", request.username, request.role, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="user.create",
target_type="user",
target_id=str(user.id),
status="success",
detail=f"创建用户成功 username={user.username} role={user.role}",
**operation_request_meta(http_request),
)
return UserResponse.from_orm_model(user)
@router.get(
"/users",
response_model=list[UserResponse],
)
def list_users(
http_request: Request,
current_user: UserORM = Depends(require_permission(PERMISSION_USER_LIST)),
service: AuthService = Depends(get_auth_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> list[UserResponse]:
"""用户管理菜单使用的用户列表。"""
users = service.list_users()
operation_logs.record(
actor=current_user,
module="system",
action="user.list",
target_type="user",
target_id="",
status="success",
detail=f"查询用户列表 count={len(users)}",
**operation_request_meta(http_request),
)
return [UserResponse.from_orm_model(user) for user in users]

View File

@ -0,0 +1,191 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from ...core.logger import AppLogger
from ...core.permissions import PERMISSION_COMMISSION_MANAGE, PERMISSION_COMMISSION_VIEW
from ...database.orm import UserORM
from ...services import CommissionService, OperationLogService
from ..dependencies import get_commission_service, get_operation_log_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.commission import (
CommissionImportResponse,
CommissionListResponse,
CommissionRecordRequest,
CommissionRecordResponse,
)
router = APIRouter(prefix="/api/commissions", tags=["commissions"])
logger = AppLogger.get_logger(__name__)
@router.get("", response_model=CommissionListResponse)
def list_commissions(
http_request: Request,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=10, ge=1, le=100),
commission_month: str | None = Query(default=None, description="提成月份YYYY-MM"),
keyword: str | None = Query(default=None, description="员工、类型或业务单号关键字"),
current_user: UserORM = Depends(require_permission(PERMISSION_COMMISSION_VIEW)),
service: CommissionService = Depends(get_commission_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> CommissionListResponse:
"""分页查询提成记录。"""
try:
page_data = service.list_records(
page=page,
page_size=page_size,
commission_month=commission_month,
keyword=keyword,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="payroll",
action="commission.list",
target_type="commission_record",
target_id="",
status="success",
detail=f"查询提成记录 total={page_data.total} page={page}",
**operation_request_meta(http_request),
)
return CommissionListResponse.from_page(page_data)
@router.post("", response_model=CommissionRecordResponse)
def create_commission(
http_request: Request,
request: CommissionRecordRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_COMMISSION_MANAGE)),
service: CommissionService = Depends(get_commission_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> CommissionRecordResponse:
"""新增提成记录。"""
try:
record = service.create_record(**request.model_dump())
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="payroll",
action="commission.create",
target_type="commission_record",
target_id=str(request.employee_id),
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("新增提成记录失败 employee_id=%s reason=%s", request.employee_id, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="payroll",
action="commission.create",
target_type="commission_record",
target_id=str(record.id),
status="success",
detail=f"新增提成记录 amount={record.amount} month={record.commission_month}",
**operation_request_meta(http_request),
)
return CommissionRecordResponse.from_orm_model(record)
@router.put("/{record_id}", response_model=CommissionRecordResponse)
def update_commission(
http_request: Request,
record_id: int,
request: CommissionRecordRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_COMMISSION_MANAGE)),
service: CommissionService = Depends(get_commission_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> CommissionRecordResponse:
"""更新提成记录。"""
try:
record = service.update_record(record_id, **request.model_dump())
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="payroll",
action="commission.update",
target_type="commission_record",
target_id=str(record_id),
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("更新提成记录失败 record_id=%s reason=%s", record_id, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="payroll",
action="commission.update",
target_type="commission_record",
target_id=str(record.id),
status="success",
detail=f"更新提成记录 amount={record.amount} month={record.commission_month}",
**operation_request_meta(http_request),
)
return CommissionRecordResponse.from_orm_model(record)
@router.delete("/{record_id}")
def delete_commission(
http_request: Request,
record_id: int,
current_user: UserORM = Depends(require_permission(PERMISSION_COMMISSION_MANAGE)),
service: CommissionService = Depends(get_commission_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> dict[str, str]:
"""删除提成记录。"""
try:
service.delete_record(record_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="payroll",
action="commission.delete",
target_type="commission_record",
target_id=str(record_id),
status="success",
detail="删除提成记录成功",
**operation_request_meta(http_request),
)
return {"message": "删除成功"}
@router.post("/import", response_model=CommissionImportResponse)
def import_commissions(
http_request: Request,
file: UploadFile = File(...),
current_user: UserORM = Depends(require_permission(PERMISSION_COMMISSION_MANAGE)),
service: CommissionService = Depends(get_commission_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> CommissionImportResponse:
"""导入提成 Excel。"""
try:
count = service.import_excel(file.filename or "commission.xlsx", file.file)
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="payroll",
action="commission.import",
target_type="file",
target_id=file.filename or "",
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("导入提成Excel失败 filename=%s reason=%s", file.filename, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="payroll",
action="commission.import",
target_type="file",
target_id=file.filename or "",
status="success",
detail=f"导入提成Excel成功 count={count}",
**operation_request_meta(http_request),
)
return CommissionImportResponse(imported_count=count, message="导入成功")

View File

@ -0,0 +1,101 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ...core.logger import AppLogger
from ...core.permissions import PERMISSION_CONFIG_MANAGE, PERMISSION_CONFIG_VIEW
from ...database.orm import UserORM
from ...services import OperationLogService, SalaryConfigService
from ..dependencies import get_operation_log_service, get_salary_config_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.salary_config import (
SalaryConfigDefaultsResponse,
SalaryConfigRequest,
SalaryConfigResponse,
)
router = APIRouter(prefix="/api/configs", tags=["configs"])
logger = AppLogger.get_logger(__name__)
@router.get("", response_model=list[SalaryConfigResponse])
def list_configs(
http_request: Request,
config_group: str | None = Query(default=None, description="配置分组attendance、payroll等"),
current_user: UserORM = Depends(require_permission(PERMISSION_CONFIG_VIEW)),
service: SalaryConfigService = Depends(get_salary_config_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> list[SalaryConfigResponse]:
"""查询系统规则配置。"""
configs = service.list_configs(config_group=config_group)
operation_logs.record(
actor=current_user,
module="system",
action="config.list",
target_type="salary_config",
target_id=config_group or "",
status="success",
detail=f"查询系统配置 count={len(configs)}",
**operation_request_meta(http_request),
)
return [SalaryConfigResponse.from_orm_model(config) for config in configs]
@router.put("/{config_key:path}", response_model=SalaryConfigResponse)
def update_config(
http_request: Request,
config_key: str,
request: SalaryConfigRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_CONFIG_MANAGE)),
service: SalaryConfigService = Depends(get_salary_config_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> SalaryConfigResponse:
"""更新单个规则配置。"""
try:
config = service.update_config(config_key, request.to_update())
except (ValueError, TypeError) as exc:
operation_logs.record(
actor=current_user,
module="system",
action="config.update",
target_type="salary_config",
target_id=config_key,
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("更新系统配置失败 config_key=%s reason=%s", config_key, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="config.update",
target_type="salary_config",
target_id=config_key,
status="success",
detail="更新系统配置成功",
**operation_request_meta(http_request),
)
return SalaryConfigResponse.from_orm_model(config)
@router.post("/defaults", response_model=SalaryConfigDefaultsResponse)
def ensure_default_configs(
http_request: Request,
current_user: UserORM = Depends(require_permission(PERMISSION_CONFIG_MANAGE)),
service: SalaryConfigService = Depends(get_salary_config_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> SalaryConfigDefaultsResponse:
"""补齐系统默认规则配置。"""
count = service.ensure_defaults()
operation_logs.record(
actor=current_user,
module="system",
action="config.defaults",
target_type="salary_config",
target_id="defaults",
status="success",
detail=f"补齐默认配置 created={count}",
**operation_request_meta(http_request),
)
return SalaryConfigDefaultsResponse(created_count=count, message="默认配置已检查")

View File

@ -0,0 +1,199 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ...core.logger import AppLogger
from ...core.permissions import PERMISSION_EMPLOYEE_MANAGE, PERMISSION_EMPLOYEE_VIEW
from ...database.orm import UserORM
from ...services import EmployeeService, OperationLogService
from ..dependencies import get_employee_service, get_operation_log_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.employee import EmployeeListResponse, EmployeeNoResponse, EmployeeRequest, EmployeeResponse, EmployeeUpdateRequest
router = APIRouter(prefix="/api/employees", tags=["employees"])
logger = AppLogger.get_logger(__name__)
@router.get("/next-no", response_model=EmployeeNoResponse)
def preview_next_employee_no(
http_request: Request,
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_MANAGE)),
service: EmployeeService = Depends(get_employee_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> EmployeeNoResponse:
"""预览下一个系统员工编号。"""
employee_no = service.preview_next_employee_no()
operation_logs.record(
actor=current_user,
module="system",
action="employee.next_no",
target_type="employee",
target_id=employee_no,
status="success",
detail=f"预览员工编号 employee_no={employee_no}",
**operation_request_meta(http_request),
)
return EmployeeNoResponse(employee_no=employee_no)
@router.get("", response_model=list[EmployeeResponse])
def list_employees(
http_request: Request,
keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"),
employment_status: str | None = Query(default=None, description="员工状态active或inactive"),
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)),
service: EmployeeService = Depends(get_employee_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> list[EmployeeResponse]:
"""查询员工档案。"""
try:
employees = service.list_employees(keyword=keyword, employment_status=employment_status)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="employee.list",
target_type="employee",
target_id="",
status="success",
detail=f"查询员工列表 count={len(employees)}",
**operation_request_meta(http_request),
)
return [EmployeeResponse.from_orm_model(employee) for employee in employees]
@router.get("/page", response_model=EmployeeListResponse)
def list_employees_page(
http_request: Request,
page: int = Query(default=1, ge=1, description="页码从1开始"),
page_size: int = Query(default=10, ge=1, le=100, description="每页条数最大100"),
keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"),
employment_status: str | None = Query(default=None, description="员工状态active或inactive"),
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)),
service: EmployeeService = Depends(get_employee_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> EmployeeListResponse:
"""分页查询员工档案,员工维护页面使用。"""
try:
page_data = service.list_employees_page(
page=page,
page_size=page_size,
keyword=keyword,
employment_status=employment_status,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="employee.page",
target_type="employee",
target_id="",
status="success",
detail=f"分页查询员工列表 total={page_data.total} page={page_data.page} page_size={page_data.page_size}",
**operation_request_meta(http_request),
)
return EmployeeListResponse.from_page(page_data)
@router.get("/{employee_id}", response_model=EmployeeResponse)
def get_employee(
http_request: Request,
employee_id: int,
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)),
service: EmployeeService = Depends(get_employee_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> EmployeeResponse:
"""查询员工详情,供编辑时回填最新数据。"""
try:
employee = service.get_employee(employee_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="employee.detail",
target_type="employee",
target_id=str(employee.id),
status="success",
detail=f"查询员工详情 employee_no={employee.employee_no} name={employee.name}",
**operation_request_meta(http_request),
)
return EmployeeResponse.from_orm_model(employee)
@router.post("", response_model=EmployeeResponse)
def create_employee(
http_request: Request,
request: EmployeeRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_MANAGE)),
service: EmployeeService = Depends(get_employee_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> EmployeeResponse:
"""新增员工档案。"""
try:
employee = service.create_employee(**request.model_dump())
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="system",
action="employee.create",
target_type="employee",
target_id=request.employee_no,
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("新增员工失败 employee_no=%s reason=%s", request.employee_no, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="employee.create",
target_type="employee",
target_id=str(employee.id),
status="success",
detail=f"新增员工成功 employee_no={employee.employee_no} name={employee.name}",
**operation_request_meta(http_request),
)
return EmployeeResponse.from_orm_model(employee)
@router.patch("/{employee_id}", response_model=EmployeeResponse)
@router.put("/{employee_id}", response_model=EmployeeResponse)
def update_employee(
http_request: Request,
employee_id: int,
request: EmployeeUpdateRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_MANAGE)),
service: EmployeeService = Depends(get_employee_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> EmployeeResponse:
"""更新员工档案。"""
try:
employee = service.update_employee(employee_id, **request.model_dump(exclude_unset=True))
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="system",
action="employee.update",
target_type="employee",
target_id=str(employee_id),
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("更新员工失败 employee_id=%s reason=%s", employee_id, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="employee.update",
target_type="employee",
target_id=str(employee.id),
status="success",
detail=f"更新员工成功 employee_no={employee.employee_no} name={employee.name}",
**operation_request_meta(http_request),
)
return EmployeeResponse.from_orm_model(employee)

View File

@ -0,0 +1,10 @@
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}

View File

@ -0,0 +1,57 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request
from ...core.logger import AppLogger
from ...core.permissions import PERMISSION_OPERATION_LOG_VIEW
from ...database.orm import UserORM
from ...services import OperationLogService
from ..dependencies import get_operation_log_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.operation_log import OperationLogListResponse
router = APIRouter(prefix="/api/operation-logs", tags=["operation-logs"])
logger = AppLogger.get_logger(__name__)
@router.get("", response_model=OperationLogListResponse)
def list_operation_logs(
http_request: Request,
page: int = Query(default=1, ge=1, description="页码从1开始"),
page_size: int = Query(default=20, ge=1, le=100, description="每页条数最大100"),
username: str | None = Query(default=None, description="按用户名模糊筛选"),
module: str | None = Query(default=None, description="按模块精确筛选"),
action: str | None = Query(default=None, description="按动作精确筛选"),
status: str | None = Query(default=None, description="按状态筛选success或failed"),
keyword: str | None = Query(default=None, description="关键字模糊筛选"),
start_time: datetime | None = Query(default=None, description="开始时间"),
end_time: datetime | None = Query(default=None, description="结束时间"),
current_user: UserORM = Depends(require_permission(PERMISSION_OPERATION_LOG_VIEW)),
service: OperationLogService = Depends(get_operation_log_service),
) -> OperationLogListResponse:
"""查看操作日志,支持分页和常用筛选。"""
page_data = service.list_logs(
page=page,
page_size=page_size,
username=username,
module=module,
action=action,
status=status,
keyword=keyword,
start_time=start_time,
end_time=end_time,
)
service.record(
actor=current_user,
module="system",
action="operation_log.view",
target_type="operation_log",
target_id="",
status="success",
detail=f"查看操作日志 page={page_data.page} page_size={page_data.page_size} total={page_data.total}",
**operation_request_meta(http_request),
)
logger.info("操作日志接口查询成功 user_id=%s total=%s", current_user.id, page_data.total)
return OperationLogListResponse.from_page(page_data)

View File

@ -0,0 +1,176 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ...core.logger import AppLogger
from ...core.permissions import PERMISSION_ORGANIZATION_MANAGE, PERMISSION_ORGANIZATION_VIEW
from ...database.orm import UserORM
from ...services import OperationLogService, OrganizationService
from ..dependencies import get_operation_log_service, get_organization_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.organization import DepartmentRequest, DepartmentResponse, PositionRequest, PositionResponse
router = APIRouter(prefix="/api/organization", tags=["organization"])
logger = AppLogger.get_logger(__name__)
@router.get("/departments", response_model=list[DepartmentResponse])
def list_departments(
http_request: Request,
active_only: bool = Query(default=True, description="是否只查询启用部门"),
keyword: str | None = Query(default=None, description="部门编码或名称关键字"),
current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_VIEW)),
service: OrganizationService = Depends(get_organization_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> list[DepartmentResponse]:
"""查询部门列表。"""
departments = service.list_departments(active_only=active_only, keyword=keyword)
operation_logs.record(
actor=current_user,
module="system",
action="organization.department.list",
target_type="department",
target_id="",
status="success",
detail=f"查询部门 count={len(departments)}",
**operation_request_meta(http_request),
)
return [DepartmentResponse.from_orm_model(department) for department in departments]
@router.post("/departments", response_model=DepartmentResponse)
def create_department(
http_request: Request,
request: DepartmentRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_MANAGE)),
service: OrganizationService = Depends(get_organization_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> DepartmentResponse:
"""新增部门。"""
try:
department = service.create_department(**request.model_dump())
except ValueError as exc:
logger.error("新增部门失败 name=%s reason=%s", request.name, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="organization.department.create",
target_type="department",
target_id=str(department.id),
status="success",
detail=f"新增部门 name={department.name}",
**operation_request_meta(http_request),
)
return DepartmentResponse.from_orm_model(department)
@router.put("/departments/{department_id}", response_model=DepartmentResponse)
def update_department(
http_request: Request,
department_id: int,
request: DepartmentRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_MANAGE)),
service: OrganizationService = Depends(get_organization_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> DepartmentResponse:
"""更新部门。"""
try:
department = service.update_department(department_id, **request.model_dump())
except ValueError as exc:
logger.error("更新部门失败 department_id=%s reason=%s", department_id, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="organization.department.update",
target_type="department",
target_id=str(department.id),
status="success",
detail=f"更新部门 name={department.name}",
**operation_request_meta(http_request),
)
return DepartmentResponse.from_orm_model(department)
@router.get("/positions", response_model=list[PositionResponse])
def list_positions(
http_request: Request,
department_id: int | None = Query(default=None, description="部门ID"),
active_only: bool = Query(default=True, description="是否只查询启用岗位"),
keyword: str | None = Query(default=None, description="岗位编码或名称关键字"),
current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_VIEW)),
service: OrganizationService = Depends(get_organization_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> list[PositionResponse]:
"""查询岗位列表。"""
try:
positions = service.list_positions(department_id=department_id, active_only=active_only, keyword=keyword)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="organization.position.list",
target_type="position",
target_id=str(department_id or ""),
status="success",
detail=f"查询岗位 count={len(positions)}",
**operation_request_meta(http_request),
)
return [PositionResponse.from_orm_model(position) for position in positions]
@router.post("/positions", response_model=PositionResponse)
def create_position(
http_request: Request,
request: PositionRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_MANAGE)),
service: OrganizationService = Depends(get_organization_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> PositionResponse:
"""新增岗位。"""
try:
position = service.create_position(**request.model_dump())
except ValueError as exc:
logger.error("新增岗位失败 name=%s reason=%s", request.name, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="organization.position.create",
target_type="position",
target_id=str(position.id),
status="success",
detail=f"新增岗位 name={position.name}",
**operation_request_meta(http_request),
)
return PositionResponse.from_orm_model(position)
@router.put("/positions/{position_id}", response_model=PositionResponse)
def update_position(
http_request: Request,
position_id: int,
request: PositionRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_MANAGE)),
service: OrganizationService = Depends(get_organization_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> PositionResponse:
"""更新岗位。"""
try:
position = service.update_position(position_id, **request.model_dump())
except ValueError as exc:
logger.error("更新岗位失败 position_id=%s reason=%s", position_id, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="system",
action="organization.position.update",
target_type="position",
target_id=str(position.id),
status="success",
detail=f"更新岗位 name={position.name}",
**operation_request_meta(http_request),
)
return PositionResponse.from_orm_model(position)

View File

@ -0,0 +1,239 @@
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_PAYROLL_DINGTALK_CALCULATE,
PERMISSION_PAYROLL_EXCEL_CALCULATE,
PERMISSION_PAYROLL_FILE_DOWNLOAD,
PERMISSION_PAYROLL_JOB_VIEW,
)
from ...database.orm import UserORM
from ...integrations.dingtalk import DingTalkError
from ...services import OperationLogService, PayrollApplicationService
from ..dependencies import get_operation_log_service, get_payroll_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.payroll import (
DingTalkPayrollRequest,
DingTalkPayrollResponse,
ExcelPayrollResponse,
PayrollJobResponse,
)
router = APIRouter(prefix="/api/payroll", tags=["payroll"])
logger = AppLogger.get_logger(__name__)
@router.post(
"/excel",
response_model=ExcelPayrollResponse,
)
def calculate_from_excel(
http_request: Request,
file: UploadFile = File(...),
config_path: str | None = Form(default=None),
export_excel: bool = Form(default=True),
current_user: UserORM = Depends(require_permission(PERMISSION_PAYROLL_EXCEL_CALCULATE)),
service: PayrollApplicationService = Depends(get_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> ExcelPayrollResponse:
"""上传月度汇总表并立即计算,计算任务和员工结果都会落库。"""
try:
computation = service.calculate_from_excel_upload(
filename=file.filename or "attendance.xlsx",
stream=file.file,
config_path=config_path,
export_excel=export_excel,
)
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="payroll",
action="payroll.excel.calculate",
target_type="payroll_job",
target_id="",
status="failed",
detail=f"Excel工资计算失败 filename={file.filename} reason={exc}",
**operation_request_meta(http_request),
)
logger.error("Excel 工资计算接口失败 filename=%s reason=%s", file.filename, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
operation_logs.record(
actor=current_user,
module="payroll",
action="payroll.excel.calculate",
target_type="payroll_job",
target_id="",
status="failed",
detail=f"Excel工资计算异常 filename={file.filename} reason={exc}",
**operation_request_meta(http_request),
)
raise
operation_logs.record(
actor=current_user,
module="payroll",
action="payroll.excel.calculate",
target_type="payroll_job",
target_id=computation.job_id,
status="success",
detail=f"Excel工资计算成功 filename={file.filename} employee_count={computation.employee_count}",
**operation_request_meta(http_request),
)
return ExcelPayrollResponse.from_computation(computation)
@router.post(
"/dingtalk/realtime",
response_model=DingTalkPayrollResponse,
)
async def calculate_from_dingtalk(
http_request: Request,
request: DingTalkPayrollRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_PAYROLL_DINGTALK_CALCULATE)),
service: PayrollApplicationService = Depends(get_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> DingTalkPayrollResponse:
"""从钉钉实时拉打卡记录,再复用同一套工资计算规则。"""
try:
computation = await service.calculate_from_dingtalk_realtime(
user_ids=request.user_ids,
start_date=request.start_date,
end_date=request.end_date,
config_path=request.config_path,
export_excel=request.export_excel,
)
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="payroll",
action="payroll.dingtalk.calculate",
target_type="payroll_job",
target_id="",
status="failed",
detail=f"钉钉工资计算参数错误 user_count={len(request.user_ids)} reason={exc}",
**operation_request_meta(http_request),
)
logger.error("钉钉工资计算接口参数错误 user_count=%s reason=%s", len(request.user_ids), exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except DingTalkError as exc:
operation_logs.record(
actor=current_user,
module="payroll",
action="payroll.dingtalk.calculate",
target_type="payroll_job",
target_id="",
status="failed",
detail=f"钉钉工资计算调用失败 user_count={len(request.user_ids)} reason={exc}",
**operation_request_meta(http_request),
)
logger.error("钉钉工资计算接口调用失败 user_count=%s reason=%s", len(request.user_ids), exc)
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc:
operation_logs.record(
actor=current_user,
module="payroll",
action="payroll.dingtalk.calculate",
target_type="payroll_job",
target_id="",
status="failed",
detail=f"钉钉工资计算异常 user_count={len(request.user_ids)} reason={exc}",
**operation_request_meta(http_request),
)
raise
operation_logs.record(
actor=current_user,
module="payroll",
action="payroll.dingtalk.calculate",
target_type="payroll_job",
target_id=computation.job_id,
status="success",
detail=f"钉钉工资计算成功 user_count={len(request.user_ids)} employee_count={computation.employee_count}",
**operation_request_meta(http_request),
)
return DingTalkPayrollResponse.from_computation(computation)
@router.get(
"/jobs/{job_id}",
response_model=PayrollJobResponse,
)
def get_payroll_job(
http_request: Request,
job_id: str,
current_user: UserORM = Depends(require_permission(PERMISSION_PAYROLL_JOB_VIEW)),
service: PayrollApplicationService = Depends(get_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> PayrollJobResponse:
"""查看已保存的计算任务及员工级结果。"""
job = service.get_job(job_id)
if job is None:
operation_logs.record(
actor=current_user,
module="payroll",
action="payroll.job.view",
target_type="payroll_job",
target_id=job_id,
status="failed",
detail="计算任务不存在",
**operation_request_meta(http_request),
)
logger.error("查询工资计算任务失败 job_id=%s reason=not_found", job_id)
raise HTTPException(status_code=404, detail="计算任务不存在")
operation_logs.record(
actor=current_user,
module="payroll",
action="payroll.job.view",
target_type="payroll_job",
target_id=job_id,
status="success",
detail=f"查询工资计算任务成功 status={job.status} employee_count={job.employee_count}",
**operation_request_meta(http_request),
)
return PayrollJobResponse.from_orm_model(job)
@router.get(
"/download/{filename}",
)
def download_payroll_file(
http_request: Request,
filename: str,
current_user: UserORM = Depends(require_permission(PERMISSION_PAYROLL_FILE_DOWNLOAD)),
service: PayrollApplicationService = Depends(get_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> FileResponse:
"""下载计算时导出的 Excel 结果文件。"""
try:
path = service.resolve_output_file(filename)
except FileNotFoundError as exc:
operation_logs.record(
actor=current_user,
module="payroll",
action="payroll.file.download",
target_type="file",
target_id=filename,
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("下载工资结果失败 filename=%s reason=%s", filename, exc)
raise HTTPException(status_code=404, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="payroll",
action="payroll.file.download",
target_type="file",
target_id=filename,
status="success",
detail=f"下载工资结果成功 path={path}",
**operation_request_meta(http_request),
)
logger.info("下载工资结果成功 filename=%s path=%s", filename, path)
return FileResponse(
path,
filename=path.name,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)

View File

@ -0,0 +1,41 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ...core.logger import AppLogger
from ...core.permissions import PERMISSION_REPORT_VIEW
from ...database.orm import UserORM
from ...services import OperationLogService, ReportService
from ..dependencies import get_operation_log_service, get_report_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.report import PayrollReportResponse
router = APIRouter(prefix="/api/reports", tags=["reports"])
logger = AppLogger.get_logger(__name__)
@router.get("/payroll-summary", response_model=PayrollReportResponse)
def payroll_summary(
http_request: Request,
salary_month: str | None = Query(default=None, description="工资月份YYYY-MM"),
current_user: UserORM = Depends(require_permission(PERMISSION_REPORT_VIEW)),
service: ReportService = Depends(get_report_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> PayrollReportResponse:
"""查询薪资、考勤和部门维度的统计报表。"""
try:
report = service.payroll_summary(salary_month=salary_month)
except ValueError as exc:
logger.error("查询统计报表失败 salary_month=%s reason=%s", salary_month, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="report",
action="report.payroll_summary",
target_type="salary_record",
target_id=salary_month or "",
status="success",
detail=f"查询工资统计报表 employee_count={report.totals['employee_count']}",
**operation_request_meta(http_request),
)
return PayrollReportResponse.from_report(report)

View File

@ -0,0 +1,80 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ...core.logger import AppLogger
from ...core.permissions import PERMISSION_SALARY_PROFILE_MANAGE, PERMISSION_SALARY_PROFILE_VIEW
from ...database.orm import UserORM
from ...services import OperationLogService, SalaryProfileService
from ..dependencies import get_operation_log_service, get_salary_profile_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.salary_profile import SalaryProfileRequest, SalaryProfileResponse
router = APIRouter(prefix="/api/salary-profiles", tags=["salary-profiles"])
logger = AppLogger.get_logger(__name__)
@router.get("", response_model=list[SalaryProfileResponse])
def list_salary_profiles(
http_request: Request,
keyword: str | None = Query(default=None, description="员工编号、姓名、部门或岗位关键字"),
current_user: UserORM = Depends(require_permission(PERMISSION_SALARY_PROFILE_VIEW)),
service: SalaryProfileService = Depends(get_salary_profile_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> list[SalaryProfileResponse]:
"""查询薪资档案。"""
profiles = service.list_profiles(keyword=keyword)
operation_logs.record(
actor=current_user,
module="payroll",
action="salary_profile.list",
target_type="salary_profile",
target_id="",
status="success",
detail=f"查询薪资档案 count={len(profiles)}",
**operation_request_meta(http_request),
)
return [SalaryProfileResponse.from_orm_model(profile) for profile in profiles]
@router.put("/{employee_id}", response_model=SalaryProfileResponse)
def upsert_salary_profile(
http_request: Request,
employee_id: int,
request: SalaryProfileRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_SALARY_PROFILE_MANAGE)),
service: SalaryProfileService = Depends(get_salary_profile_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> SalaryProfileResponse:
"""按员工维护当前薪资档案,包含底薪、提成和独立加班费单价。"""
if request.employee_id != employee_id:
raise HTTPException(status_code=400, detail="路径员工ID与请求员工ID不一致")
try:
profile = service.upsert_profile(**request.model_dump())
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="payroll",
action="salary_profile.upsert",
target_type="employee",
target_id=str(employee_id),
status="failed",
detail=str(exc),
**operation_request_meta(http_request),
)
logger.error("保存薪资档案失败 employee_id=%s reason=%s", employee_id, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="payroll",
action="salary_profile.upsert",
target_type="employee",
target_id=str(employee_id),
status="success",
detail=(
f"保存薪资档案成功 base_salary={profile.base_salary} "
f"commission={profile.commission_amount} overtime_rate={profile.overtime_rate}"
),
**operation_request_meta(http_request),
)
return SalaryProfileResponse.from_orm_model(profile)

View File

@ -0,0 +1,43 @@
from .auth import (
ChangePasswordRequest,
ChangePasswordResponse,
CreateUserRequest,
LoginRequest,
LoginResponse,
UserProfileDTO,
UserResponse,
)
from .payroll import (
DingTalkPayrollRequest,
DingTalkPayrollResponse,
ExcelPayrollResponse,
PayrollJobResponse,
PayrollResultDTO,
)
from .operation_log import OperationLogDTO, OperationLogListResponse
from .employee import EmployeeListResponse, EmployeeNoResponse, EmployeeRequest, EmployeeResponse, EmployeeUpdateRequest
from .salary_profile import SalaryProfileRequest, SalaryProfileResponse
__all__ = [
"CreateUserRequest",
"ChangePasswordRequest",
"ChangePasswordResponse",
"DingTalkPayrollRequest",
"DingTalkPayrollResponse",
"EmployeeRequest",
"EmployeeResponse",
"EmployeeListResponse",
"EmployeeNoResponse",
"EmployeeUpdateRequest",
"ExcelPayrollResponse",
"LoginRequest",
"LoginResponse",
"OperationLogDTO",
"OperationLogListResponse",
"PayrollJobResponse",
"PayrollResultDTO",
"SalaryProfileRequest",
"SalaryProfileResponse",
"UserProfileDTO",
"UserResponse",
]

View File

@ -0,0 +1,168 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from ...core.permissions import MenuPermission, menus_for_role, permissions_for_role
from ...database.orm import UserORM
from ...services import AuthSession
class MenuDTO(BaseModel):
"""返回给前端的菜单项。"""
code: str
name: str
path: str
@classmethod
def from_domain(cls, menu: MenuPermission) -> "MenuDTO":
return cls(code=menu.code, name=menu.name, path=menu.path)
class UserProfileDTO(BaseModel):
"""当前登录用户及其权限上下文。"""
id: int
username: str
display_name: str
avatar_url: str
email: str
phone: str
department: str
position_title: str
role: str
is_active: bool
menus: list[MenuDTO]
permissions: list[str]
@classmethod
def from_orm_model(cls, user: UserORM) -> "UserProfileDTO":
menus, permissions = _role_access(user.role)
return cls(
id=user.id,
username=user.username,
display_name=user.display_name,
avatar_url=user.avatar_url,
email=user.email,
phone=user.phone,
department=user.department,
position_title=user.position_title,
role=user.role,
is_active=user.is_active,
menus=menus,
permissions=permissions,
)
class LoginRequest(BaseModel):
"""登录请求。"""
username: str
password: str
class LoginResponse(BaseModel):
"""登录响应,前端据此渲染菜单和按钮权限。"""
access_token: str
token_type: str
user: UserProfileDTO
menus: list[MenuDTO]
permissions: list[str]
@classmethod
def from_session(cls, session: AuthSession) -> "LoginResponse":
user = UserProfileDTO.from_orm_model(session.user)
return cls(
access_token=session.access_token,
token_type=session.token_type,
user=user,
menus=user.menus,
permissions=user.permissions,
)
class CreateUserRequest(BaseModel):
"""创建用户请求,角色只能取 permissions.py 中定义的角色。"""
username: str = Field(..., min_length=2, max_length=64)
password: str = Field(..., min_length=6, max_length=128)
role: str
display_name: str = ""
email: str = Field(default="", max_length=128)
phone: str = Field(default="", max_length=32)
department: str = Field(default="", max_length=128)
position_title: str = Field(default="", max_length=128)
class ChangePasswordRequest(BaseModel):
"""当前登录用户修改自己密码请求。"""
old_password: str = Field(..., min_length=1, max_length=128)
new_password: str = Field(..., min_length=6, max_length=128)
confirm_password: str = Field(..., min_length=6, max_length=128)
class ChangePasswordResponse(BaseModel):
"""修改密码成功响应。"""
message: str
class UserResponse(BaseModel):
"""用户管理列表/创建接口的返回模型。"""
id: int
username: str
display_name: str
avatar_url: str
email: str
phone: str
department: str
position_title: str
role: str
is_active: bool
created_at: datetime
updated_at: datetime
menus: list[MenuDTO]
permissions: list[str]
@classmethod
def from_orm_model(cls, user: UserORM) -> "UserResponse":
profile = UserProfileDTO.from_orm_model(user)
return cls(
id=user.id,
username=user.username,
display_name=user.display_name,
avatar_url=user.avatar_url,
email=user.email,
phone=user.phone,
department=user.department,
position_title=user.position_title,
role=user.role,
is_active=user.is_active,
created_at=user.created_at,
updated_at=user.updated_at,
menus=profile.menus,
permissions=profile.permissions,
)
class UpdateProfileRequest(BaseModel):
"""当前登录用户修改个人资料请求。"""
display_name: str = Field(default="", max_length=128)
email: str = Field(default="", max_length=128)
phone: str = Field(default="", max_length=32)
department: str = Field(default="", max_length=128)
position_title: str = Field(default="", max_length=128)
def _role_access(role: str) -> tuple[list[MenuDTO], list[str]]:
"""统一生成前端菜单和按钮/接口操作权限,避免响应模型各自拼装。"""
return (
[MenuDTO.from_domain(menu) for menu in menus_for_role(role)],
permissions_for_role(role),
)

View File

@ -0,0 +1,78 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from ...database.orm import CommissionRecordORM
from ...services.commission_service import CommissionPage
from .employee import EmployeeResponse
class CommissionRecordRequest(BaseModel):
"""提成记录维护请求。"""
employee_id: int
commission_month: str = Field(..., max_length=7)
commission_type: str = Field(default="销售提成", max_length=64)
amount: float = Field(default=0, ge=0)
source_type: str = Field(default="manual", max_length=32)
business_ref: str = Field(default="", max_length=128)
remark: str = ""
class CommissionRecordResponse(BaseModel):
"""提成记录响应。"""
id: int
employee_id: int
employee: EmployeeResponse
commission_month: str
commission_type: str
amount: float
source_type: str
business_ref: str
remark: str
created_at: datetime
updated_at: datetime
@classmethod
def from_orm_model(cls, record: CommissionRecordORM) -> "CommissionRecordResponse":
return cls(
id=record.id,
employee_id=record.employee_id,
employee=EmployeeResponse.from_orm_model(record.employee),
commission_month=record.commission_month,
commission_type=record.commission_type,
amount=record.amount,
source_type=record.source_type,
business_ref=record.business_ref,
remark=record.remark,
created_at=record.created_at,
updated_at=record.updated_at,
)
class CommissionListResponse(BaseModel):
"""提成记录分页响应。"""
items: list[CommissionRecordResponse]
total: int
page: int
page_size: int
@classmethod
def from_page(cls, page: CommissionPage) -> "CommissionListResponse":
return cls(
items=[CommissionRecordResponse.from_orm_model(item) for item in page.items],
total=page.total,
page=page.page,
page_size=page.page_size,
)
class CommissionImportResponse(BaseModel):
"""提成 Excel 导入响应。"""
imported_count: int
message: str

View File

@ -0,0 +1,90 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from ...database.orm import EmployeeORM
from ...services import EmployeePage
class EmployeeRequest(BaseModel):
"""员工维护请求。"""
employee_no: str = Field(default="", max_length=64)
name: str = Field(..., min_length=1, max_length=128)
dingtalk_user_id: str = Field(default="", max_length=128)
department: str = Field(default="", max_length=128)
position: str = Field(default="", max_length=128)
phone: str = Field(default="", max_length=32)
employment_status: str = Field(default="active", max_length=32)
remark: str = ""
class EmployeeUpdateRequest(BaseModel):
"""员工编辑请求,员工编号由后端保留,不需要前端维护。"""
employee_no: str = Field(default="", max_length=64)
name: str | None = Field(default=None, min_length=1, max_length=128)
dingtalk_user_id: str | None = Field(default=None, max_length=128)
department: str | None = Field(default=None, max_length=128)
position: str | None = Field(default=None, max_length=128)
phone: str | None = Field(default=None, max_length=32)
employment_status: str | None = Field(default=None, max_length=32)
remark: str | None = None
class EmployeeResponse(BaseModel):
"""员工档案响应。"""
id: int
employee_no: str
name: str
dingtalk_user_id: str
department: str
position: str
phone: str
employment_status: str
remark: str
created_at: datetime
updated_at: datetime
@classmethod
def from_orm_model(cls, employee: EmployeeORM) -> "EmployeeResponse":
return cls(
id=employee.id,
employee_no=employee.employee_no,
name=employee.name,
dingtalk_user_id=employee.dingtalk_user_id,
department=employee.department,
position=employee.position,
phone=employee.phone,
employment_status=employee.employment_status,
remark=employee.remark,
created_at=employee.created_at,
updated_at=employee.updated_at,
)
class EmployeeNoResponse(BaseModel):
"""员工编号响应。"""
employee_no: str
class EmployeeListResponse(BaseModel):
"""员工分页列表响应。"""
items: list[EmployeeResponse]
total: int
page: int
page_size: int
@classmethod
def from_page(cls, page: EmployeePage) -> "EmployeeListResponse":
return cls(
items=[EmployeeResponse.from_orm_model(employee) for employee in page.items],
total=page.total,
page=page.page,
page_size=page.page_size,
)

View File

@ -0,0 +1,62 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from ...database.orm import OperationLogORM
from ...services import OperationLogPage
class OperationLogDTO(BaseModel):
"""操作日志列表项。"""
id: int
user_id: int | None
username: str
role: str
module: str
action: str
target_type: str
target_id: str
status: str
detail: str
ip_address: str
user_agent: str
created_at: datetime
@classmethod
def from_orm_model(cls, log: OperationLogORM) -> "OperationLogDTO":
return cls(
id=log.id,
user_id=log.user_id,
username=log.username,
role=log.role,
module=log.module,
action=log.action,
target_type=log.target_type,
target_id=log.target_id,
status=log.status,
detail=log.detail,
ip_address=log.ip_address,
user_agent=log.user_agent,
created_at=log.created_at,
)
class OperationLogListResponse(BaseModel):
"""操作日志分页响应。"""
items: list[OperationLogDTO]
total: int
page: int
page_size: int
@classmethod
def from_page(cls, page: OperationLogPage) -> "OperationLogListResponse":
return cls(
items=[OperationLogDTO.from_orm_model(log) for log in page.items],
total=page.total,
page=page.page,
page_size=page.page_size,
)

View File

@ -0,0 +1,87 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from ...database.orm import DepartmentORM, PositionORM
class DepartmentRequest(BaseModel):
"""部门维护请求。"""
parent_id: int | None = None
department_code: str = Field(..., max_length=64)
name: str = Field(..., max_length=128)
sort_order: int = 0
is_active: bool = True
remark: str = ""
class DepartmentResponse(BaseModel):
"""部门响应。"""
id: int
parent_id: int | None
department_code: str
name: str
sort_order: int
is_active: bool
remark: str
created_at: datetime
updated_at: datetime
@classmethod
def from_orm_model(cls, department: DepartmentORM) -> "DepartmentResponse":
return cls(
id=department.id,
parent_id=department.parent_id,
department_code=department.department_code,
name=department.name,
sort_order=department.sort_order,
is_active=department.is_active,
remark=department.remark,
created_at=department.created_at,
updated_at=department.updated_at,
)
class PositionRequest(BaseModel):
"""岗位维护请求。"""
department_id: int
position_code: str = Field(default="", max_length=64)
name: str = Field(..., max_length=128)
sort_order: int = 0
is_active: bool = True
remark: str = ""
class PositionResponse(BaseModel):
"""岗位响应。"""
id: int
department_id: int
department: DepartmentResponse
position_code: str
name: str
sort_order: int
is_active: bool
remark: str
created_at: datetime
updated_at: datetime
@classmethod
def from_orm_model(cls, position: PositionORM) -> "PositionResponse":
return cls(
id=position.id,
department_id=position.department_id,
department=DepartmentResponse.from_orm_model(position.department),
position_code=position.position_code,
name=position.name,
sort_order=position.sort_order,
is_active=position.is_active,
remark=position.remark,
created_at=position.created_at,
updated_at=position.updated_at,
)

View File

@ -0,0 +1,190 @@
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, Field
from ...database.orm import PayrollJobORM, PayrollResultORM
from ...domain.models import PayrollResult
from ...services import PayrollComputation
class PayrollResultDTO(BaseModel):
"""员工级工资结果响应模型。"""
name: str
department: str
position: str
attendance_group: str
salary_month: str
salary_mode: str
attendance_days: float
absence_days: float
actual_work_hours: float
punch_overtime_hours: float
leave_hours: float
remaining_overtime_hours: float
workday_overtime_hours: float
weekend_overtime_hours: float
holiday_overtime_hours: float
minor_late_count: int
major_late_count: int
penalized_late_count: int
late_deduction: float
missing_card_count: int
missing_card_deduction: float
total_deduction: float
base_salary: float | None
base_pay: float | None
commission_amount: float
overtime_rate: float
weekend_overtime_rate: float
holiday_overtime_rate: float
overtime_pay: float
gross_salary: float | None
net_salary: float | None
note: str
@classmethod
def from_domain(cls, result: PayrollResult) -> "PayrollResultDTO":
employee = result.employee
return cls(
name=employee.name,
department=employee.department,
position=employee.position,
attendance_group=employee.attendance_group,
salary_month=result.salary_month,
salary_mode=result.salary_mode,
attendance_days=result.attendance_days,
absence_days=result.absence_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,
workday_overtime_hours=result.workday_overtime_hours,
weekend_overtime_hours=result.weekend_overtime_hours,
holiday_overtime_hours=result.holiday_overtime_hours,
minor_late_count=result.minor_late_count,
major_late_count=result.major_late_count,
penalized_late_count=result.penalized_late_count,
late_deduction=result.late_deduction,
missing_card_count=result.missing_card_count,
missing_card_deduction=result.missing_card_deduction,
total_deduction=result.total_deduction,
base_salary=result.base_salary,
base_pay=result.base_pay,
commission_amount=result.commission_amount,
overtime_rate=result.overtime_rate,
weekend_overtime_rate=result.weekend_overtime_rate,
holiday_overtime_rate=result.holiday_overtime_rate,
overtime_pay=result.overtime_pay,
gross_salary=result.gross_salary,
net_salary=result.net_salary,
note=result.note,
)
@classmethod
def from_orm_model(cls, result: PayrollResultORM) -> "PayrollResultDTO":
return cls(
name=result.name,
department=result.department,
position=result.position,
attendance_group=result.attendance_group,
salary_month=result.salary_month,
salary_mode=result.salary_mode,
attendance_days=result.attendance_days,
absence_days=result.absence_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,
workday_overtime_hours=result.workday_overtime_hours,
weekend_overtime_hours=result.weekend_overtime_hours,
holiday_overtime_hours=result.holiday_overtime_hours,
minor_late_count=result.minor_late_count,
major_late_count=result.major_late_count,
penalized_late_count=result.penalized_late_count,
late_deduction=result.late_deduction,
missing_card_count=result.missing_card_count,
missing_card_deduction=result.missing_card_deduction,
total_deduction=result.total_deduction,
base_salary=result.base_salary,
base_pay=result.base_pay,
commission_amount=result.commission_amount,
overtime_rate=result.overtime_rate,
weekend_overtime_rate=result.weekend_overtime_rate,
holiday_overtime_rate=result.holiday_overtime_rate,
overtime_pay=result.overtime_pay,
gross_salary=result.gross_salary,
net_salary=result.net_salary,
note=result.note,
)
class PayrollResponseBase(BaseModel):
"""Excel 和钉钉计算接口共用的响应结构。"""
job_id: str
employee_count: int
output_file: str | None = None
download_url: str | None = None
results: list[PayrollResultDTO]
@classmethod
def from_computation(cls, computation: PayrollComputation):
output_filename = computation.output_filename
return cls(
job_id=computation.job_id,
employee_count=computation.employee_count,
output_file=computation.output_file,
download_url=f"/api/payroll/download/{output_filename}" if output_filename else None,
results=[PayrollResultDTO.from_domain(result) for result in computation.results],
)
class ExcelPayrollResponse(PayrollResponseBase):
pass
class DingTalkPayrollRequest(BaseModel):
"""钉钉实时计算请求参数。"""
user_ids: list[str] = Field(..., description="钉钉员工 userId 列表")
start_date: date
end_date: date
config_path: str | None = None
export_excel: bool = True
class DingTalkPayrollResponse(PayrollResponseBase):
pass
class PayrollJobResponse(BaseModel):
"""已落库工资计算任务的查询响应。"""
job_id: str
source_type: str
status: str
input_file: str | None
output_file: str | None
employee_count: int
error_message: str | None
created_at: datetime
updated_at: datetime
results: list[PayrollResultDTO]
@classmethod
def from_orm_model(cls, job: PayrollJobORM) -> "PayrollJobResponse":
return cls(
job_id=job.id,
source_type=job.source_type,
status=job.status,
input_file=job.input_file,
output_file=job.output_file,
employee_count=job.employee_count,
error_message=job.error_message,
created_at=job.created_at,
updated_at=job.updated_at,
results=[PayrollResultDTO.from_orm_model(result) for result in job.results],
)

View File

@ -0,0 +1,75 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from ...database.orm import SalaryRecordORM
from ...services.report_service import PayrollReport
class DepartmentPayrollReportItem(BaseModel):
department: str
employee_count: int
gross_salary: float
net_salary: float
class SalaryRecordSummary(BaseModel):
id: int
employee_no: str
employee_name: str
department: str
salary_month: str
salary_mode: str
base_pay: float
commission_amount: float
overtime_pay: float
deduction_amount: float
gross_salary: float
net_salary: float
created_at: datetime
@classmethod
def from_orm_model(cls, record: SalaryRecordORM) -> "SalaryRecordSummary":
return cls(
id=record.id,
employee_no=record.employee_no,
employee_name=record.employee_name,
department=record.department,
salary_month=record.salary_month,
salary_mode=record.salary_mode,
base_pay=record.base_pay,
commission_amount=record.commission_amount,
overtime_pay=record.overtime_pay,
deduction_amount=record.deduction_amount,
gross_salary=record.gross_salary,
net_salary=record.net_salary,
created_at=record.created_at,
)
class PayrollReportResponse(BaseModel):
salary_month: str | None
totals: dict[str, float]
attendance: dict[str, float]
departments: list[DepartmentPayrollReportItem]
recent_records: list[SalaryRecordSummary]
@classmethod
def from_report(cls, report: PayrollReport) -> "PayrollReportResponse":
return cls(
salary_month=report.salary_month,
totals=report.totals,
attendance=report.attendance,
departments=[
DepartmentPayrollReportItem(
department=str(item["department"]),
employee_count=int(item["employee_count"]),
gross_salary=float(item["gross_salary"]),
net_salary=float(item["net_salary"]),
)
for item in report.departments
],
recent_records=[SalaryRecordSummary.from_orm_model(record) for record in report.recent_records],
)

View File

@ -0,0 +1,66 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from ...database.orm import SalaryConfigORM
from ...services.salary_config_service import SalaryConfigUpdate
class SalaryConfigRequest(BaseModel):
"""系统配置更新请求。"""
config_name: str = Field(..., max_length=128)
config_group: str = Field(..., max_length=64)
config_value: str
value_type: str = Field(default="string", max_length=32)
is_enabled: bool = True
remark: str = ""
def to_update(self) -> SalaryConfigUpdate:
return SalaryConfigUpdate(
config_name=self.config_name,
config_group=self.config_group,
config_value=self.config_value,
value_type=self.value_type,
is_enabled=self.is_enabled,
remark=self.remark,
)
class SalaryConfigResponse(BaseModel):
"""系统配置响应。"""
id: int
config_key: str
config_name: str
config_group: str
config_value: str
value_type: str
is_enabled: bool
remark: str
created_at: datetime
updated_at: datetime
@classmethod
def from_orm_model(cls, config: SalaryConfigORM) -> "SalaryConfigResponse":
return cls(
id=config.id,
config_key=config.config_key,
config_name=config.config_name,
config_group=config.config_group,
config_value=config.config_value,
value_type=config.value_type,
is_enabled=config.is_enabled,
remark=config.remark,
created_at=config.created_at,
updated_at=config.updated_at,
)
class SalaryConfigDefaultsResponse(BaseModel):
"""默认配置初始化响应。"""
created_count: int
message: str

View File

@ -0,0 +1,76 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from ...database.orm import SalaryProfileORM
from .employee import EmployeeResponse
class SalaryProfileRequest(BaseModel):
"""薪资档案维护请求。"""
employee_id: int
salary_mode: str = Field(default="monthly", max_length=32)
base_salary: float = Field(default=0, ge=0)
hourly_rate: float = Field(default=0, ge=0)
piece_quantity: float = Field(default=0, ge=0)
piece_unit_price: float = Field(default=0, ge=0)
probation_type: str = Field(default="ratio", max_length=32)
probation_ratio: float = Field(default=1, ge=0)
probation_salary: float = Field(default=0, ge=0)
commission_amount: float = Field(default=0, ge=0)
overtime_rate: float = Field(default=0, ge=0)
weekend_overtime_rate: float = Field(default=0, ge=0)
holiday_overtime_rate: float = Field(default=0, ge=0)
effective_month: str = Field(default="", max_length=7)
remark: str = ""
class SalaryProfileResponse(BaseModel):
"""薪资档案响应。"""
id: int
employee_id: int
employee: EmployeeResponse
salary_mode: str
base_salary: float
hourly_rate: float
piece_quantity: float
piece_unit_price: float
probation_type: str
probation_ratio: float
probation_salary: float
commission_amount: float
overtime_rate: float
weekend_overtime_rate: float
holiday_overtime_rate: float
effective_month: str
remark: str
created_at: datetime
updated_at: datetime
@classmethod
def from_orm_model(cls, profile: SalaryProfileORM) -> "SalaryProfileResponse":
return cls(
id=profile.id,
employee_id=profile.employee_id,
employee=EmployeeResponse.from_orm_model(profile.employee),
salary_mode=profile.salary_mode,
base_salary=profile.base_salary,
hourly_rate=profile.hourly_rate,
piece_quantity=profile.piece_quantity,
piece_unit_price=profile.piece_unit_price,
probation_type=profile.probation_type,
probation_ratio=profile.probation_ratio,
probation_salary=profile.probation_salary,
commission_amount=profile.commission_amount,
overtime_rate=profile.overtime_rate,
weekend_overtime_rate=profile.weekend_overtime_rate,
holiday_overtime_rate=profile.holiday_overtime_rate,
effective_month=profile.effective_month,
remark=profile.remark,
created_at=profile.created_at,
updated_at=profile.updated_at,
)

View File

@ -0,0 +1,5 @@
"""Command line entry points."""
from .payroll import main
__all__ = ["main"]

View File

@ -0,0 +1,61 @@
from __future__ import annotations
import argparse
from pathlib import Path
from ..core.config import load_config
from ..domain.calculator import PayrollCalculator
from ..io.exporter import export_payroll_results
from ..io.parser import MonthlySummaryParser
DEFAULT_OUTPUT = Path("outputs/payroll_result.xlsx")
DEFAULT_INPUT = Path(
"/Users/jiaolongyan/Library/Containers/com.tencent.xinWeChat/Data/Documents/"
"xwechat_files/wxid_iy9g88794bjh22_e458/msg/file/2026-06/"
"宁波兆安流体科技有限公司_月度汇总_20260501-20260531.xlsx"
)
def build_parser() -> argparse.ArgumentParser:
"""命令行入口保留给本地批处理 Excel不参与 Web API。"""
parser = argparse.ArgumentParser(description="根据月度考勤汇总 Excel 计算工资和考勤扣款。")
parser.add_argument(
"input",
nargs="?",
help="月度汇总 Excel 路径;不传时默认使用本次提供的 2026-05 月度汇总表",
)
parser.add_argument(
"-c",
"--config",
help="工资规则 JSON。未提供时使用内置规则示例见 config/salary_rules.example.json",
)
parser.add_argument(
"-o",
"--output",
default=str(DEFAULT_OUTPUT),
help=f"输出 Excel 路径,默认:{DEFAULT_OUTPUT}",
)
return parser
def main() -> None:
"""直接运行脚本时生成一份工资 Excel。"""
args = build_parser().parse_args()
config = load_config(args.config)
input_path = Path(args.input) if args.input else DEFAULT_INPUT
if not input_path.exists():
raise FileNotFoundError(
f"未找到输入 Excel{input_path}\n"
"请在运行参数里传入月度汇总 Excel 路径例如python main.py /path/to/月度汇总.xlsx"
)
parser = MonthlySummaryParser(config.attendance)
employees = parser.parse(input_path)
calculator = PayrollCalculator(config)
results = calculator.calculate(employees)
output_path = export_payroll_results(results, config, args.output)
print(f"已生成工资计算结果:{output_path.resolve()}")
print(f"员工数:{len(results)}")

View File

@ -0,0 +1 @@
"""Core configuration, security, settings, and permissions."""

View File

@ -0,0 +1,115 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from ..domain.models import EmployeePayConfig
@dataclass(frozen=True)
class AttendanceRules:
"""考勤规则配置,默认值来自当前工资计算约定。"""
on_work_time: str = "08:30"
off_work_time: str = "17:00"
overtime_round_minutes: int = 60
standard_daily_hours: float = 8
minor_late_minutes: int = 5
minor_late_free_times: int = 3
late_penalty: float = 30
missing_card_penalty: float = 20
weekend_days: tuple[int, ...] = (5, 6)
legal_holidays: tuple[str, ...] = ()
leave_keywords: tuple[str, ...] = (
"调休",
"请假",
"事假",
"病假",
"年假",
"婚假",
"产假",
"陪产假",
"丧假",
)
@dataclass(frozen=True)
class PayrollRules:
default_overtime_rate: float = 0
default_weekend_overtime_rate: float = 0
default_holiday_overtime_rate: float = 0
employees: dict[str, EmployeePayConfig] = field(default_factory=dict)
@dataclass(frozen=True)
class AppConfig:
attendance: AttendanceRules = field(default_factory=AttendanceRules)
payroll: PayrollRules = field(default_factory=PayrollRules)
def load_config(path: str | Path | None) -> AppConfig:
"""加载可选 JSON 配置;未传路径时使用内置默认规则。"""
if not path:
return AppConfig()
raw = json.loads(Path(path).read_text(encoding="utf-8"))
attendance_raw = raw.get("attendance", {})
payroll_raw = raw.get("payroll", {})
employees_raw = payroll_raw.get("employees", {})
attendance = AttendanceRules(
on_work_time=attendance_raw.get("on_work_time", AttendanceRules.on_work_time),
off_work_time=attendance_raw.get("off_work_time", AttendanceRules.off_work_time),
overtime_round_minutes=int(
attendance_raw.get("overtime_round_minutes", AttendanceRules.overtime_round_minutes)
),
standard_daily_hours=float(
attendance_raw.get("standard_daily_hours", AttendanceRules.standard_daily_hours)
),
minor_late_minutes=int(
attendance_raw.get("minor_late_minutes", AttendanceRules.minor_late_minutes)
),
minor_late_free_times=int(
attendance_raw.get("minor_late_free_times", AttendanceRules.minor_late_free_times)
),
late_penalty=float(attendance_raw.get("late_penalty", AttendanceRules.late_penalty)),
missing_card_penalty=float(
attendance_raw.get("missing_card_penalty", AttendanceRules.missing_card_penalty)
),
weekend_days=tuple(int(item) for item in attendance_raw.get("weekend_days", AttendanceRules.weekend_days)),
legal_holidays=tuple(attendance_raw.get("legal_holidays", AttendanceRules.legal_holidays)),
leave_keywords=tuple(attendance_raw.get("leave_keywords", AttendanceRules.leave_keywords)),
)
employees = {
name: EmployeePayConfig(
salary_mode=values.get("salary_mode", "monthly") or "monthly",
base_salary=_optional_float(values.get("base_salary")),
hourly_rate=float(values.get("hourly_rate", 0) or 0),
piece_quantity=float(values.get("piece_quantity", 0) or 0),
piece_unit_price=float(values.get("piece_unit_price", 0) or 0),
probation_type=values.get("probation_type", "ratio") or "ratio",
probation_ratio=float(values.get("probation_ratio", 1) or 1),
probation_salary=_optional_float(values.get("probation_salary")),
commission_amount=float(values.get("commission_amount", 0) or 0),
overtime_rate=_optional_float(values.get("overtime_rate")),
weekend_overtime_rate=_optional_float(values.get("weekend_overtime_rate")),
holiday_overtime_rate=_optional_float(values.get("holiday_overtime_rate")),
)
for name, values in employees_raw.items()
}
payroll = PayrollRules(
default_overtime_rate=float(payroll_raw.get("default_overtime_rate", 0)),
default_weekend_overtime_rate=float(payroll_raw.get("default_weekend_overtime_rate", 0)),
default_holiday_overtime_rate=float(payroll_raw.get("default_holiday_overtime_rate", 0)),
employees=employees,
)
return AppConfig(attendance=attendance, payroll=payroll)
def _optional_float(value: object) -> float | None:
if value in (None, ""):
return None
return float(value)

View File

@ -0,0 +1,101 @@
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from .settings import AppSettings, get_settings
class _MaxLevelFilter(logging.Filter):
"""只允许指定等级及以下日志进入 handler用于拆分 info 和 error 文件。"""
def __init__(self, max_level: int):
super().__init__()
self.max_level = max_level
def filter(self, record: logging.LogRecord) -> bool:
return record.levelno <= self.max_level
class AppLogger:
"""项目统一日志入口,负责初始化 handler 并返回模块 logger。"""
_configured = False
_handler_marker = "_financial_system_handler"
@classmethod
def configure(cls, settings: AppSettings | None = None) -> None:
settings = settings or get_settings()
log_level = getattr(logging, settings.log_level, logging.INFO)
settings.log_dir.mkdir(parents=True, exist_ok=True)
handlers = cls._build_handlers(settings)
app_logger = logging.getLogger("financial_system")
app_logger.setLevel(log_level)
app_logger.propagate = False
cls._replace_handlers(app_logger, handlers)
cls._configured = True
@classmethod
def get_logger(cls, name: str) -> logging.Logger:
if not cls._configured:
cls.configure()
if not name.startswith("financial_system"):
name = f"financial_system.{name}"
return logging.getLogger(name)
@classmethod
def _build_handlers(cls, settings: AppSettings) -> list[logging.Handler]:
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
info_handler = cls._file_handler(
settings.log_dir / settings.log_info_file,
logging.INFO,
settings,
)
info_handler.addFilter(_MaxLevelFilter(logging.WARNING))
info_handler.setFormatter(formatter)
error_handler = cls._file_handler(
settings.log_dir / settings.log_error_file,
logging.ERROR,
settings,
)
error_handler.setFormatter(formatter)
handlers: list[logging.Handler] = [info_handler, error_handler]
if settings.log_console_enabled:
console_handler = logging.StreamHandler()
console_handler.setLevel(getattr(logging, settings.log_level, logging.INFO))
console_handler.setFormatter(formatter)
setattr(console_handler, cls._handler_marker, True)
handlers.append(console_handler)
for handler in handlers:
setattr(handler, cls._handler_marker, True)
return handlers
@staticmethod
def _file_handler(path: Path, level: int, settings: AppSettings) -> RotatingFileHandler:
handler = RotatingFileHandler(
path,
maxBytes=settings.log_max_bytes,
backupCount=settings.log_backup_count,
encoding="utf-8",
)
handler.setLevel(level)
return handler
@classmethod
def _replace_handlers(cls, logger: logging.Logger, handlers: list[logging.Handler]) -> None:
for handler in list(logger.handlers):
if getattr(handler, cls._handler_marker, False):
logger.removeHandler(handler)
handler.close()
for handler in handlers:
logger.addHandler(handler)

View File

@ -0,0 +1,139 @@
from __future__ import annotations
from dataclasses import dataclass
ROLE_SUPERUSER = "superuser"
ROLE_MANAGER = "manager"
ROLE_VIEWER = "viewer"
VALID_ROLES = {ROLE_SUPERUSER, ROLE_MANAGER, ROLE_VIEWER}
PERMISSION_PAYROLL_EXCEL_CALCULATE = "payroll:excel:calculate"
PERMISSION_PAYROLL_DINGTALK_CALCULATE = "payroll:dingtalk:calculate"
PERMISSION_PAYROLL_JOB_VIEW = "payroll:job:view"
PERMISSION_PAYROLL_FILE_DOWNLOAD = "payroll:file:download"
PERMISSION_USER_CREATE = "user:create"
PERMISSION_USER_LIST = "user:list"
PERMISSION_OPERATION_LOG_VIEW = "operation_log:view"
PERMISSION_EMPLOYEE_VIEW = "employee:view"
PERMISSION_EMPLOYEE_MANAGE = "employee:manage"
PERMISSION_ORGANIZATION_VIEW = "organization:view"
PERMISSION_ORGANIZATION_MANAGE = "organization:manage"
PERMISSION_SALARY_PROFILE_VIEW = "salary_profile:view"
PERMISSION_SALARY_PROFILE_MANAGE = "salary_profile:manage"
PERMISSION_COMMISSION_VIEW = "commission:view"
PERMISSION_COMMISSION_MANAGE = "commission:manage"
PERMISSION_CONFIG_VIEW = "config:view"
PERMISSION_CONFIG_MANAGE = "config:manage"
PERMISSION_REPORT_VIEW = "report:view"
@dataclass(frozen=True)
class MenuPermission:
"""前端菜单权限描述。"""
code: str
name: str
path: str
MENUS = {
"payroll": MenuPermission(code="payroll", name="工资计算", path="/payroll"),
"jobs": MenuPermission(code="jobs", name="计算记录", path="/payroll/jobs"),
"employees": MenuPermission(code="employees", name="员工维护", path="/system/employees"),
"organization": MenuPermission(code="organization", name="组织架构", path="/system/organization"),
"salary_profiles": MenuPermission(code="salary_profiles", name="薪资维护", path="/system/salary-profiles"),
"commissions": MenuPermission(code="commissions", name="提成管理", path="/payroll/commissions"),
"reports": MenuPermission(code="reports", name="统计报表", path="/reports"),
"configs": MenuPermission(code="configs", name="规则配置", path="/system/configs"),
"users": MenuPermission(code="users", name="用户管理", path="/system/users"),
"operation_logs": MenuPermission(code="operation_logs", name="操作日志", path="/system/operation-logs"),
}
ROLE_MENU_CODES = {
ROLE_SUPERUSER: (
"payroll",
"jobs",
"employees",
"organization",
"salary_profiles",
"commissions",
"reports",
"configs",
"users",
"operation_logs",
),
ROLE_MANAGER: (
"payroll",
"jobs",
"employees",
"organization",
"salary_profiles",
"commissions",
"reports",
"configs",
"operation_logs",
),
ROLE_VIEWER: ("jobs", "reports"),
}
ROLE_OPERATION_PERMISSIONS = {
ROLE_SUPERUSER: (
PERMISSION_PAYROLL_EXCEL_CALCULATE,
PERMISSION_PAYROLL_DINGTALK_CALCULATE,
PERMISSION_PAYROLL_JOB_VIEW,
PERMISSION_PAYROLL_FILE_DOWNLOAD,
PERMISSION_USER_CREATE,
PERMISSION_USER_LIST,
PERMISSION_OPERATION_LOG_VIEW,
PERMISSION_EMPLOYEE_VIEW,
PERMISSION_EMPLOYEE_MANAGE,
PERMISSION_ORGANIZATION_VIEW,
PERMISSION_ORGANIZATION_MANAGE,
PERMISSION_SALARY_PROFILE_VIEW,
PERMISSION_SALARY_PROFILE_MANAGE,
PERMISSION_COMMISSION_VIEW,
PERMISSION_COMMISSION_MANAGE,
PERMISSION_CONFIG_VIEW,
PERMISSION_CONFIG_MANAGE,
PERMISSION_REPORT_VIEW,
),
ROLE_MANAGER: (
PERMISSION_PAYROLL_EXCEL_CALCULATE,
PERMISSION_PAYROLL_DINGTALK_CALCULATE,
PERMISSION_PAYROLL_JOB_VIEW,
PERMISSION_PAYROLL_FILE_DOWNLOAD,
PERMISSION_OPERATION_LOG_VIEW,
PERMISSION_EMPLOYEE_VIEW,
PERMISSION_EMPLOYEE_MANAGE,
PERMISSION_ORGANIZATION_VIEW,
PERMISSION_ORGANIZATION_MANAGE,
PERMISSION_SALARY_PROFILE_VIEW,
PERMISSION_SALARY_PROFILE_MANAGE,
PERMISSION_COMMISSION_VIEW,
PERMISSION_COMMISSION_MANAGE,
PERMISSION_CONFIG_VIEW,
PERMISSION_CONFIG_MANAGE,
PERMISSION_REPORT_VIEW,
),
ROLE_VIEWER: (
PERMISSION_PAYROLL_JOB_VIEW,
PERMISSION_PAYROLL_FILE_DOWNLOAD,
PERMISSION_REPORT_VIEW,
),
}
def menus_for_role(role: str) -> list[MenuPermission]:
"""返回角色可见菜单。"""
return [MENUS[code] for code in ROLE_MENU_CODES.get(role, ())]
def permissions_for_role(role: str) -> list[str]:
"""返回角色可执行的操作权限码。"""
return list(ROLE_OPERATION_PERMISSIONS.get(role, ()))
def has_permission(role: str, permission: str) -> bool:
"""路由鉴权使用的权限判断。"""
return permission in ROLE_OPERATION_PERMISSIONS.get(role, ())

View File

@ -0,0 +1,98 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
HASH_NAME = "sha256"
HASH_ITERATIONS = 260_000
class TokenError(ValueError):
pass
def hash_password(password: str) -> str:
"""使用 PBKDF2 保存密码摘要,数据库永远不存明文密码。"""
salt = os.urandom(16)
digest = hashlib.pbkdf2_hmac(HASH_NAME, password.encode("utf-8"), salt, HASH_ITERATIONS)
return "$".join(
[
"pbkdf2_sha256",
str(HASH_ITERATIONS),
_b64encode(salt),
_b64encode(digest),
]
)
def verify_password(password: str, password_hash: str) -> bool:
"""用常量时间比较校验密码,降低时序侧信道风险。"""
try:
algorithm, iterations_text, salt_text, digest_text = password_hash.split("$", 3)
except ValueError:
return False
if algorithm != "pbkdf2_sha256":
return False
salt = _b64decode(salt_text)
expected = _b64decode(digest_text)
actual = hashlib.pbkdf2_hmac(
HASH_NAME,
password.encode("utf-8"),
salt,
int(iterations_text),
)
return hmac.compare_digest(actual, expected)
def create_access_token(
*,
user_id: int,
username: str,
role: str,
secret_key: str,
expires_minutes: int,
) -> str:
"""生成轻量 HMAC tokenpayload 明文可读但不可被客户端篡改。"""
payload = {
"sub": str(user_id),
"username": username,
"role": role,
"exp": int(time.time()) + expires_minutes * 60,
}
payload_text = _b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
signature = _sign(payload_text, secret_key)
return f"{payload_text}.{signature}"
def decode_access_token(token: str, secret_key: str) -> dict:
"""校验 token 签名和过期时间,失败时抛出 TokenError。"""
try:
payload_text, signature = token.split(".", 1)
except ValueError as exc:
raise TokenError("无效 token") from exc
expected_signature = _sign(payload_text, secret_key)
if not hmac.compare_digest(signature, expected_signature):
raise TokenError("无效 token 签名")
payload = json.loads(_b64decode(payload_text))
if int(payload.get("exp", 0)) < int(time.time()):
raise TokenError("登录已过期")
return payload
def _sign(payload_text: str, secret_key: str) -> str:
digest = hmac.new(secret_key.encode("utf-8"), payload_text.encode("utf-8"), hashlib.sha256).digest()
return _b64encode(digest)
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def _b64decode(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(value + padding)

View File

@ -0,0 +1,147 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CONFIG_PATH = PROJECT_ROOT / "config" / "app_settings.json"
DEFAULT_DATABASE_URL = "mysql+pymysql://root:12345678@localhost:3306/financial_system?charset=utf8mb4"
DEFAULT_CORS_ALLOWED_ORIGINS = (
"http://127.0.0.1:5173",
"http://localhost:5173",
"http://127.0.0.1:5174",
"http://localhost:5174",
)
@dataclass(frozen=True)
class AppSettings:
"""运行时配置,统一来自 config/app_settings.json。"""
project_root: Path = PROJECT_ROOT
config_path: Path = DEFAULT_CONFIG_PATH
server_host: str = "0.0.0.0"
server_port: int = 8000
server_fallback_port: int = 8001
log_dir: Path = PROJECT_ROOT / "logs"
log_level: str = "INFO"
log_info_file: str = "info.log"
log_error_file: str = "error.log"
log_max_bytes: int = 10 * 1024 * 1024
log_backup_count: int = 10
log_console_enabled: bool = True
upload_dir: Path = PROJECT_ROOT / "uploads"
output_dir: Path = PROJECT_ROOT / "outputs"
avatar_dir: Path = PROJECT_ROOT / "static" / "uploads" / "avatars"
database_url: str = DEFAULT_DATABASE_URL
dingtalk_app_key: str = ""
dingtalk_app_secret: str = ""
dingtalk_base_url: str = "https://oapi.dingtalk.com"
dingtalk_timezone: str = "Asia/Shanghai"
auth_secret_key: str = "financial-system-dev-secret"
access_token_expire_minutes: int = 480
cors_allowed_origins: tuple[str, ...] = DEFAULT_CORS_ALLOWED_ORIGINS
bootstrap_superuser_username: str = "admin"
bootstrap_superuser_password: str = "Admin@123456"
bootstrap_superuser_display_name: str = "超级管理员"
def get_settings() -> AppSettings:
raw = _load_settings_file()
server = _section(raw, "server")
logging_config = _section(raw, "logging")
storage = _section(raw, "storage")
database = _section(raw, "database")
dingtalk = _section(raw, "dingtalk")
auth = _section(raw, "auth")
bootstrap_superuser = _section(raw, "bootstrap_superuser")
cors = _section(raw, "cors")
return AppSettings(
config_path=_resolve_config_path(),
server_host=str(server.get("host", "0.0.0.0")),
server_port=int(server.get("port", 8000)),
server_fallback_port=int(server.get("fallback_port", 8001)),
log_dir=_project_path(logging_config.get("dir", "logs")),
log_level=str(logging_config.get("level", "INFO")).upper(),
log_info_file=str(logging_config.get("info_file", "info.log")),
log_error_file=str(logging_config.get("error_file", "error.log")),
log_max_bytes=int(logging_config.get("max_bytes", 10 * 1024 * 1024)),
log_backup_count=int(logging_config.get("backup_count", 10)),
log_console_enabled=_bool(logging_config.get("console_enabled", True)),
upload_dir=_project_path(storage.get("upload_dir", "uploads")),
output_dir=_project_path(storage.get("output_dir", "outputs")),
avatar_dir=_project_path(storage.get("avatar_dir", "static/uploads/avatars")),
database_url=str(database.get("url", DEFAULT_DATABASE_URL)).strip(),
dingtalk_app_key=str(dingtalk.get("app_key", "")).strip(),
dingtalk_app_secret=str(dingtalk.get("app_secret", "")).strip(),
dingtalk_base_url=str(dingtalk.get("base_url", "https://oapi.dingtalk.com")).rstrip("/"),
dingtalk_timezone=str(dingtalk.get("timezone", "Asia/Shanghai")),
auth_secret_key=str(auth.get("secret_key", "financial-system-dev-secret")),
access_token_expire_minutes=int(auth.get("access_token_expire_minutes", 480)),
cors_allowed_origins=_string_tuple(
cors.get("allowed_origins", DEFAULT_CORS_ALLOWED_ORIGINS),
DEFAULT_CORS_ALLOWED_ORIGINS,
),
bootstrap_superuser_username=str(bootstrap_superuser.get("username", "admin")),
bootstrap_superuser_password=str(bootstrap_superuser.get("password", "Admin@123456")),
bootstrap_superuser_display_name=str(bootstrap_superuser.get("display_name", "超级管理员")),
)
def _load_settings_file() -> dict[str, Any]:
config_path = _resolve_config_path()
if not config_path.exists():
if os.getenv("APP_CONFIG_FILE"):
raise FileNotFoundError(f"配置文件不存在:{config_path}")
return {}
raw = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError(f"配置文件根节点必须是 JSON 对象:{config_path}")
return raw
def _resolve_config_path() -> Path:
configured_path = os.getenv("APP_CONFIG_FILE", "").strip()
if not configured_path:
return DEFAULT_CONFIG_PATH
path = Path(configured_path).expanduser()
return path if path.is_absolute() else PROJECT_ROOT / path
def _section(raw: dict[str, Any], name: str) -> dict[str, Any]:
value = raw.get(name, {})
if not isinstance(value, dict):
raise ValueError(f"配置项 {name} 必须是 JSON 对象")
return value
def _project_path(value: object) -> Path:
path = Path(str(value)).expanduser()
return path if path.is_absolute() else PROJECT_ROOT / path
def _string_tuple(value: object, default: tuple[str, ...]) -> tuple[str, ...]:
if value is None:
return default
if isinstance(value, str):
items = value.split(",")
elif isinstance(value, list | tuple):
items = value
else:
raise ValueError("字符串列表配置必须是数组或逗号分隔字符串")
normalized = tuple(str(item).strip() for item in items if str(item).strip())
return normalized or default
def _bool(value: object) -> bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}

View File

@ -0,0 +1,3 @@
from .session import SessionLocal, init_database
__all__ = ["SessionLocal", "init_database"]

View File

@ -0,0 +1,871 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class UserORM(Base):
"""系统登录用户role 决定菜单和操作权限。"""
__tablename__ = "users"
__table_args__ = {
"comment": "系统用户表,保存登录账号、角色和启用状态",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="用户主键ID")
username: Mapped[str] = mapped_column(
String(64),
unique=True,
nullable=False,
index=True,
comment="登录用户名,全局唯一",
)
display_name: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="用户显示名称",
)
avatar_url: Mapped[str] = mapped_column(
String(512),
nullable=False,
default="",
comment="用户头像URL或静态资源路径",
)
email: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="用户邮箱",
)
phone: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="",
comment="用户手机号",
)
department: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="用户所属部门",
)
position_title: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="用户职位或岗位名称",
)
password_hash: Mapped[str] = mapped_column(
String(256),
nullable=False,
comment="密码哈希值,不保存明文密码",
)
role: Mapped[str] = mapped_column(
String(32),
nullable=False,
index=True,
comment="用户角色superuser、manager、viewer",
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用账号",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
comment="创建时间",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)
class DepartmentORM(Base):
"""组织部门基础数据,支持父子级部门结构。"""
__tablename__ = "departments"
__table_args__ = {
"comment": "部门表,维护组织架构中的部门及上下级关系",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="部门主键ID")
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("departments.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="父级部门ID顶级部门为空",
)
department_code: Mapped[str] = mapped_column(
String(64),
unique=True,
nullable=False,
index=True,
comment="部门编码,全局唯一",
)
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True, 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="是否启用")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="部门备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)
class PositionORM(Base):
"""岗位基础数据,岗位隶属于一个部门。"""
__tablename__ = "positions"
__table_args__ = (
UniqueConstraint("department_id", "name", name="uq_positions_department_name"),
{
"comment": "岗位表,维护部门下的职位/岗位",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="岗位主键ID")
department_id: Mapped[int] = mapped_column(
ForeignKey("departments.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="关联部门ID",
)
position_code: Mapped[str] = mapped_column(
String(64),
unique=True,
nullable=False,
index=True,
comment="岗位编码,全局唯一",
)
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, 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="是否启用")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="岗位备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)
department: Mapped[DepartmentORM] = relationship()
class EmployeeORM(Base):
"""员工基础档案,用于工资计算和薪资档案关联。"""
__tablename__ = "employees"
__table_args__ = {
"comment": "员工基础档案表维护员工编号、姓名、部门、岗位和钉钉用户ID",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="员工主键ID")
employee_no: Mapped[str] = mapped_column(
String(64),
unique=True,
nullable=False,
index=True,
comment="员工编号全局唯一系统自动生成默认ZA前缀",
)
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="员工姓名")
dingtalk_user_id: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
index=True,
comment="钉钉用户ID用于对接钉钉考勤",
)
department: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="部门名称")
position: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="岗位名称")
phone: Mapped[str] = mapped_column(String(32), nullable=False, default="", comment="手机号")
employment_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="active",
index=True,
comment="员工状态active在职、inactive离职",
)
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="员工备注")
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
comment="创建时间",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)
salary_profile: Mapped["SalaryProfileORM | None"] = relationship(
back_populates="employee",
cascade="all, delete-orphan",
uselist=False,
)
class SalaryProfileORM(Base):
"""员工薪资档案,独立维护薪资模式、底薪、计时/计件规则和加班费单价。"""
__tablename__ = "salary_profiles"
__table_args__ = {
"comment": "员工薪资档案表,维护薪资模式、底薪、计时计件规则、默认提成和加班费单价",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="薪资档案主键ID")
employee_id: Mapped[int] = mapped_column(
ForeignKey("employees.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
comment="关联员工ID一名员工一条当前薪资档案",
)
salary_mode: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="monthly",
index=True,
comment="薪资模式monthly包月、hourly计时、piecework计件、probation试用期",
)
base_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="基础工资或底薪")
hourly_rate: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="计时工资小时单价")
piece_quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="计件数量")
piece_unit_price: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="计件单价")
probation_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="ratio",
comment="试用期计算方式ratio固定比例、fixed固定金额",
)
probation_ratio: Mapped[float] = mapped_column(Float, nullable=False, default=1, comment="试用期工资比例")
probation_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="试用期固定工资")
commission_amount: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="默认提成金额月度提成优先维护到commission_records",
)
overtime_rate: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="工作日加班费小时单价,独立于基础工资维护",
)
weekend_overtime_rate: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="周末加班费小时单价",
)
holiday_overtime_rate: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="法定节假日加班费小时单价",
)
effective_month: Mapped[str] = mapped_column(
String(7),
nullable=False,
default="",
comment="生效月份格式YYYY-MM为空表示长期当前档案",
)
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="薪资备注")
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
comment="创建时间",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)
employee: Mapped[EmployeeORM] = relationship(back_populates="salary_profile")
class AttendanceRecordORM(Base):
"""标准化后的每日考勤记录,来自 Excel 或钉钉接口。"""
__tablename__ = "attendance_record"
__table_args__ = {
"comment": "考勤记录表,保存标准化后的每日打卡、迟到、缺卡、请假和加班工时",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="考勤记录主键ID")
job_id: Mapped[str | None] = mapped_column(
ForeignKey("payroll_jobs.id", ondelete="SET NULL"),
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, index=True, comment="员工姓名快照")
department: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="部门名称快照")
work_date: Mapped[date] = mapped_column(Date, nullable=False, index=True, comment="考勤日期")
first_punch: Mapped[str] = mapped_column(String(5), nullable=False, default="", comment="上班打卡时间HH:MM")
last_punch: Mapped[str] = mapped_column(String(5), nullable=False, default="", comment="下班打卡时间HH:MM")
attendance_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="present",
index=True,
comment="考勤状态present出勤、absent缺勤、leave请假、missing缺卡",
)
late_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="当日迟到分钟合计")
missing_card_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="当日缺卡次数")
leave_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="原始考勤单元格内容")
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="创建时间")
class LeaveRecordORM(Base):
"""请假/调休记录,统一折算为工时。"""
__tablename__ = "leave_record"
__table_args__ = {
"comment": "请假记录表,保存事假、病假、年假、调休等统一工时记录",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="请假记录主键ID")
job_id: Mapped[str | None] = mapped_column(
ForeignKey("payroll_jobs.id", ondelete="SET NULL"),
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, index=True, comment="员工姓名快照")
leave_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="请假类型")
start_text: Mapped[str] = mapped_column(String(32), nullable=False, default="", comment="原始开始时间文本")
end_text: Mapped[str] = mapped_column(String(32), nullable=False, default="", comment="原始结束时间文本")
hours: 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")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="approved", comment="状态approved已生效")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
class OvertimeRecordORM(Base):
"""标准化后的加班记录,按工作日、周末、节假日拆分。"""
__tablename__ = "overtime_record"
__table_args__ = {
"comment": "加班记录表,保存每日加班工时、加班类型、单价和金额",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="加班记录主键ID")
job_id: Mapped[str | None] = mapped_column(
ForeignKey("payroll_jobs.id", ondelete="SET NULL"),
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, index=True, comment="员工姓名快照")
work_date: Mapped[date] = mapped_column(Date, nullable=False, index=True, comment="加班日期")
overtime_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="workday",
index=True,
comment="加班类型workday工作日、weekend周末、holiday法定节假日",
)
hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班工时")
rate: 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")
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="创建时间")
class CommissionRecordORM(Base):
"""月度提成或奖金记录,支持手工维护和 Excel 导入。"""
__tablename__ = "commission_record"
__table_args__ = {
"comment": "提成记录表,保存销售提成、项目奖金、绩效奖励等月度金额",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="提成记录主键ID")
employee_id: Mapped[int] = mapped_column(
ForeignKey("employees.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="关联员工ID",
)
commission_month: Mapped[str] = mapped_column(String(7), nullable=False, index=True, comment="提成月份格式YYYY-MM")
commission_type: Mapped[str] = mapped_column(String(64), nullable=False, default="销售提成", comment="提成类型")
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="提成金额")
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="业务单号或来源标识")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)
employee: Mapped[EmployeeORM] = relationship()
class OperationLogORM(Base):
"""系统操作日志,用于审计用户在后台执行的关键动作。"""
__tablename__ = "operation_logs"
__table_args__ = {
"comment": "系统操作日志表,记录用户登录、资料维护、工资计算、下载和系统管理动作",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="日志主键ID")
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="操作用户ID匿名或用户删除后为空",
)
username: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="",
index=True,
comment="操作用户名快照",
)
role: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="",
comment="操作用户角色快照",
)
module: Mapped[str] = mapped_column(
String(64),
nullable=False,
index=True,
comment="业务模块例如auth、payroll、system",
)
action: Mapped[str] = mapped_column(
String(128),
nullable=False,
index=True,
comment="操作动作编码例如login、payroll.excel.calculate",
)
target_type: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="",
comment="操作对象类型例如user、payroll_job、file",
)
target_id: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="操作对象ID或业务标识",
)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
index=True,
comment="操作状态success或failed",
)
detail: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="操作详情或失败原因")
ip_address: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="",
comment="客户端IP地址",
)
user_agent: Mapped[str] = mapped_column(
String(512),
nullable=False,
default="",
comment="客户端浏览器User-Agent",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
index=True,
comment="操作时间",
)
user: Mapped[UserORM | None] = relationship()
class PayrollJobORM(Base):
"""一次工资计算任务,记录来源、状态、输入输出文件。"""
__tablename__ = "payroll_jobs"
__table_args__ = {
"comment": "工资计算任务表记录每次Excel或钉钉实时计算任务",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="任务IDUUID十六进制字符串")
source_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="任务来源excel或dingtalk",
)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="created",
comment="任务状态running、completed、failed",
)
input_file: Mapped[str | None] = mapped_column(Text, comment="上传的原始Excel文件路径")
output_file: Mapped[str | None] = mapped_column(Text, comment="导出的工资结果Excel文件路径")
employee_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="本次任务计算的员工数量",
)
error_message: Mapped[str | None] = mapped_column(Text, comment="任务失败时的错误信息")
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
comment="创建时间",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)
results: Mapped[list["PayrollResultORM"]] = relationship(
back_populates="job",
cascade="all, delete-orphan",
)
class PayrollResultORM(Base):
"""员工级工资计算结果,便于后续查询和审计。"""
__tablename__ = "payroll_results"
__table_args__ = {
"comment": "员工工资计算结果表,保存任务下每个员工的加班、请假、扣款和工资结果",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="结果主键ID")
job_id: Mapped[str] = mapped_column(
ForeignKey("payroll_jobs.id"),
nullable=False,
index=True,
comment="所属工资计算任务ID",
)
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="员工姓名")
department: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="部门名称")
position: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="岗位名称")
attendance_group: 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")
salary_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="monthly", comment="薪资模式快照")
attendance_days: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="出勤天数")
absence_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="剩余加班工时:打卡推算加班工时减请假调休工时",
)
workday_overtime_hours: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="工作日剩余可计费加班工时",
)
weekend_overtime_hours: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="周末剩余可计费加班工时",
)
holiday_overtime_hours: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="法定节假日剩余可计费加班工时",
)
minor_late_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="5分钟内迟到次数",
)
major_late_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="超过5分钟迟到次数",
)
penalized_late_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="实际计扣迟到次数",
)
late_deduction: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="迟到扣款金额",
)
missing_card_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="缺卡次数",
)
missing_card_deduction: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="缺卡扣款金额",
)
total_deduction: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="扣款合计金额",
)
base_salary: Mapped[float | None] = mapped_column(Float, comment="员工底薪")
base_pay: Mapped[float | None] = mapped_column(Float, comment="按薪资模式计算后的基础工资")
commission_amount: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="手工维护的提成费用",
)
overtime_rate: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="加班小时单价",
)
weekend_overtime_rate: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="周末加班小时单价",
)
holiday_overtime_rate: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="法定节假日加班小时单价",
)
overtime_pay: Mapped[float] = mapped_column(
Float,
nullable=False,
default=0,
comment="加班费金额",
)
gross_salary: Mapped[float | None] = mapped_column(Float, comment="应发工资")
net_salary: Mapped[float | None] = mapped_column(Float, comment="实发工资")
note: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
job: Mapped[PayrollJobORM] = relationship(back_populates="results")
class SalaryRecordORM(Base):
"""月度工资汇总记录,用于工资条、月报表和财务报表查询。"""
__tablename__ = "salary_record"
__table_args__ = {
"comment": "工资记录表,保存员工月度工资汇总结果",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="工资记录主键ID")
job_id: Mapped[str | None] = mapped_column(
ForeignKey("payroll_jobs.id", ondelete="SET NULL"),
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, index=True, comment="员工姓名快照")
department: Mapped[str] = mapped_column(String(128), nullable=False, default="", index=True, comment="部门名称快照")
salary_month: Mapped[str] = mapped_column(String(7), nullable=False, index=True, comment="工资月份格式YYYY-MM")
salary_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="monthly", comment="薪资模式快照")
base_pay: 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="加班费金额")
deduction_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="扣款合计金额")
gross_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已计算")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)
details: Mapped[list["SalaryDetailORM"]] = relationship(
back_populates="salary_record",
cascade="all, delete-orphan",
)
class SalaryDetailORM(Base):
"""工资明细记录,按工资项拆分基础工资、提成、加班费和扣款。"""
__tablename__ = "salary_detail"
__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_record_id: Mapped[int] = mapped_column(
ForeignKey("salary_record.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="关联工资记录ID",
)
item_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
index=True,
comment="工资项类型earning收入、deduction扣款、stat统计",
)
item_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="工资项名称")
amount: 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="单价")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
salary_record: Mapped[SalaryRecordORM] = relationship(back_populates="details")
class SalaryConfigORM(Base):
"""系统配置中心,保存考勤规则、薪资规则和后续集成参数。"""
__tablename__ = "salary_config"
__table_args__ = {
"comment": "系统配置表,保存可后台维护的考勤、薪资和集成规则",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="配置主键ID")
config_key: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True, comment="配置键")
config_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="配置名称")
config_group: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="配置分组")
config_value: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="配置值按value_type解析")
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="是否启用")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="配置说明")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间",
)

View File

@ -0,0 +1,239 @@
from __future__ import annotations
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import make_url
from sqlalchemy.orm import sessionmaker
from ..core.logger import AppLogger
from ..core.settings import get_settings
from .orm import Base
settings = get_settings()
logger = AppLogger.get_logger(__name__)
# SQLite 默认限制跨线程连接FastAPI 请求线程会复用连接池,需要显式放开。
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
USER_PROFILE_COLUMNS = {
"avatar_url": {
"mysql": "VARCHAR(512) NOT NULL DEFAULT '' COMMENT '用户头像URL或静态资源路径'",
"sqlite": "VARCHAR(512) NOT NULL DEFAULT ''",
},
"email": {
"mysql": "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户邮箱'",
"sqlite": "VARCHAR(128) NOT NULL DEFAULT ''",
},
"phone": {
"mysql": "VARCHAR(32) NOT NULL DEFAULT '' COMMENT '用户手机号'",
"sqlite": "VARCHAR(32) NOT NULL DEFAULT ''",
},
"department": {
"mysql": "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户所属部门'",
"sqlite": "VARCHAR(128) NOT NULL DEFAULT ''",
},
"position_title": {
"mysql": "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户职位或岗位名称'",
"sqlite": "VARCHAR(128) NOT NULL DEFAULT ''",
},
}
PAYROLL_RESULT_COLUMNS = {
"salary_month": {
"mysql": "VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM'",
"sqlite": "VARCHAR(7) NOT NULL DEFAULT ''",
},
"salary_mode": {
"mysql": "VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照'",
"sqlite": "VARCHAR(32) NOT NULL DEFAULT 'monthly'",
},
"attendance_days": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '出勤天数'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"absence_days": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '缺勤天数'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"actual_work_hours": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '实际工作工时'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"workday_overtime_hours": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '工作日剩余可计费加班工时'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"weekend_overtime_hours": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '周末剩余可计费加班工时'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"holiday_overtime_hours": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日剩余可计费加班工时'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"commission_amount": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '手工维护的提成费用'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"base_pay": {
"mysql": "FLOAT NULL COMMENT '按薪资模式计算后的基础工资'",
"sqlite": "FLOAT NULL",
},
"weekend_overtime_rate": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班小时单价'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"holiday_overtime_rate": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班小时单价'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
}
SALARY_PROFILE_COLUMNS = {
"salary_mode": {
"mysql": "VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式monthly包月、hourly计时、piecework计件、probation试用期'",
"sqlite": "VARCHAR(32) NOT NULL DEFAULT 'monthly'",
},
"hourly_rate": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '计时工资小时单价'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"piece_quantity": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '计件数量'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"piece_unit_price": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '计件单价'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"probation_type": {
"mysql": "VARCHAR(32) NOT NULL DEFAULT 'ratio' COMMENT '试用期计算方式ratio固定比例、fixed固定金额'",
"sqlite": "VARCHAR(32) NOT NULL DEFAULT 'ratio'",
},
"probation_ratio": {
"mysql": "FLOAT NOT NULL DEFAULT 1 COMMENT '试用期工资比例'",
"sqlite": "FLOAT NOT NULL DEFAULT 1",
},
"probation_salary": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '试用期固定工资'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"weekend_overtime_rate": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班费小时单价'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
"holiday_overtime_rate": {
"mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班费小时单价'",
"sqlite": "FLOAT NOT NULL DEFAULT 0",
},
}
def init_database() -> None:
"""创建 MySQL 库和缺失表;当前项目暂不引入迁移工具。"""
logger.info("数据库初始化开始 database_url=%s", _safe_database_url(settings.database_url))
try:
_ensure_database_exists()
Base.metadata.create_all(bind=engine)
_ensure_user_profile_columns()
_ensure_salary_profile_columns()
_ensure_payroll_result_columns()
except Exception:
logger.exception("数据库初始化失败")
raise
logger.info("数据库初始化完成")
def _ensure_user_profile_columns() -> None:
"""为已有 users 表补齐个人资料字段create_all 不会修改旧表结构。"""
existing_columns = {column["name"] for column in inspect(engine).get_columns("users")}
missing_columns = [
(column_name, definitions)
for column_name, definitions in USER_PROFILE_COLUMNS.items()
if column_name not in existing_columns
]
if not missing_columns:
return
dialect = "mysql" if settings.database_url.startswith("mysql") else "sqlite"
with engine.begin() as connection:
for column_name, definitions in missing_columns:
column_sql = definitions[dialect]
connection.execute(text(f"ALTER TABLE users ADD COLUMN `{column_name}` {column_sql}"))
logger.info("users 表字段已补齐 column=%s", column_name)
def _ensure_payroll_result_columns() -> None:
"""为已有 payroll_results 表补齐工资结果新增字段。"""
if not inspect(engine).has_table("payroll_results"):
return
existing_columns = {column["name"] for column in inspect(engine).get_columns("payroll_results")}
missing_columns = [
(column_name, definitions)
for column_name, definitions in PAYROLL_RESULT_COLUMNS.items()
if column_name not in existing_columns
]
if not missing_columns:
return
dialect = "mysql" if settings.database_url.startswith("mysql") else "sqlite"
with engine.begin() as connection:
for column_name, definitions in missing_columns:
column_sql = definitions[dialect]
connection.execute(text(f"ALTER TABLE payroll_results ADD COLUMN `{column_name}` {column_sql}"))
logger.info("payroll_results 表字段已补齐 column=%s", column_name)
def _ensure_salary_profile_columns() -> None:
"""为已有 salary_profiles 表补齐薪资模式和加班费单价字段。"""
if not inspect(engine).has_table("salary_profiles"):
return
existing_columns = {column["name"] for column in inspect(engine).get_columns("salary_profiles")}
missing_columns = [
(column_name, definitions)
for column_name, definitions in SALARY_PROFILE_COLUMNS.items()
if column_name not in existing_columns
]
if not missing_columns:
return
dialect = "mysql" if settings.database_url.startswith("mysql") else "sqlite"
with engine.begin() as connection:
for column_name, definitions in missing_columns:
column_sql = definitions[dialect]
connection.execute(text(f"ALTER TABLE salary_profiles ADD COLUMN `{column_name}` {column_sql}"))
logger.info("salary_profiles 表字段已补齐 column=%s", column_name)
def _ensure_database_exists() -> None:
if not settings.database_url.startswith("mysql"):
return
url = make_url(settings.database_url)
if not url.database:
return
database_name = url.database
admin_url = url.set(database=None)
admin_engine = create_engine(admin_url)
safe_database_name = database_name.replace("`", "``")
try:
with admin_engine.begin() as connection:
connection.execute(
text(
f"CREATE DATABASE IF NOT EXISTS `{safe_database_name}` "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
)
logger.info("MySQL 数据库检查完成 database=%s", database_name)
finally:
admin_engine.dispose()
def _safe_database_url(database_url: str) -> str:
try:
return str(make_url(database_url).render_as_string(hide_password=True))
except Exception:
return "***invalid-database-url***"

View File

@ -0,0 +1,613 @@
-- Financial System full MySQL initialization script.
-- Execute with:
-- mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/init_mysql.sql
CREATE DATABASE IF NOT EXISTS `financial_system`
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE `financial_system`;
CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID',
`username` VARCHAR(64) NOT NULL COMMENT '登录用户名,全局唯一',
`display_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户显示名称',
`avatar_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '用户头像URL或静态资源路径',
`email` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户邮箱',
`phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '用户手机号',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户所属部门',
`position_title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户职位或岗位名称',
`password_hash` VARCHAR(256) NOT NULL COMMENT '密码哈希值,不保存明文密码',
`role` VARCHAR(32) NOT NULL COMMENT '用户角色superuser、manager、viewer',
`is_active` BOOL NOT NULL DEFAULT TRUE 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 `ix_users_username` (`username`),
KEY `ix_users_role` (`role`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='系统用户表,保存登录账号、角色和启用状态';
SET @has_avatar_url := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'avatar_url'
);
SET @ddl := IF(@has_avatar_url = 0,
'ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(512) NOT NULL DEFAULT '''' COMMENT ''用户头像URL或静态资源路径''',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_email := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'email'
);
SET @ddl := IF(@has_email = 0,
'ALTER TABLE `users` ADD COLUMN `email` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户邮箱''',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_phone := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'phone'
);
SET @ddl := IF(@has_phone = 0,
'ALTER TABLE `users` ADD COLUMN `phone` VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''用户手机号''',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_department := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'department'
);
SET @ddl := IF(@has_department = 0,
'ALTER TABLE `users` ADD COLUMN `department` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户所属部门''',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_position_title := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'position_title'
);
SET @ddl := IF(@has_position_title = 0,
'ALTER TABLE `users` ADD COLUMN `position_title` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户职位或岗位名称''',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS `departments` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '部门主键ID',
`parent_id` INT NULL COMMENT '父级部门ID顶级部门为空',
`department_code` VARCHAR(64) NOT NULL COMMENT '部门编码,全局唯一',
`name` VARCHAR(128) NOT NULL COMMENT '部门名称',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前',
`is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用',
`remark` 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 `ix_departments_department_code` (`department_code`),
UNIQUE KEY `ix_departments_name` (`name`),
KEY `ix_departments_parent_id` (`parent_id`),
KEY `ix_departments_is_active` (`is_active`),
CONSTRAINT `fk_departments_parent_id`
FOREIGN KEY (`parent_id`) REFERENCES `departments` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='部门表,维护组织架构中的部门及上下级关系';
CREATE TABLE IF NOT EXISTS `positions` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '岗位主键ID',
`department_id` INT NOT NULL COMMENT '关联部门ID',
`position_code` VARCHAR(64) NOT NULL COMMENT '岗位编码,全局唯一',
`name` VARCHAR(128) NOT NULL COMMENT '岗位名称',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前',
`is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用',
`remark` 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 `ix_positions_position_code` (`position_code`),
UNIQUE KEY `uq_positions_department_name` (`department_id`, `name`),
KEY `ix_positions_department_id` (`department_id`),
KEY `ix_positions_name` (`name`),
KEY `ix_positions_is_active` (`is_active`),
CONSTRAINT `fk_positions_department_id`
FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='岗位表,维护部门下的职位/岗位';
SET @has_position_department_name_index := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'positions'
AND INDEX_NAME = 'uq_positions_department_name'
);
SET @ddl := IF(@has_position_department_name_index = 0,
'ALTER TABLE `positions` ADD UNIQUE KEY `uq_positions_department_name` (`department_id`, `name`)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`;
CREATE TEMPORARY TABLE `tmp_organization_departments` (
`department_code` VARCHAR(64) NOT NULL,
`name` VARCHAR(128) NOT NULL,
`parent_name` VARCHAR(128) NULL,
`sort_order` INT NOT NULL,
`remark` VARCHAR(255) NOT NULL
) ENGINE=MEMORY;
INSERT INTO `tmp_organization_departments`
(`department_code`, `name`, `parent_name`, `sort_order`, `remark`) VALUES
('ORG001', '总经办', NULL, 1, '系统初始化部门'),
('ORG002', '行政人事部', NULL, 2, '系统初始化部门'),
('ORG003', '财务部', NULL, 3, '系统初始化部门'),
('ORG004', '订单部', NULL, 4, '系统初始化部门'),
('ORG005', '工程部', NULL, 5, '系统根据工程部子部门自动补齐的父级部门'),
('ORG006', '工程部-安装队', '工程部', 6, '系统初始化部门'),
('ORG007', '工程部-项目预算', '工程部', 7, '系统初始化部门'),
('ORG008', '杭州运营中心', NULL, 8, '系统根据杭州运营中心子部门自动补齐的父级部门'),
('ORG009', '杭州运营中心-销售部', '杭州运营中心', 9, '系统初始化部门'),
('ORG010', '华南运营中心', NULL, 10, '系统初始化部门'),
('ORG011', '华中运营中心', NULL, 11, '系统初始化部门'),
('ORG012', '生产中心', NULL, 12, '系统初始化部门'),
('ORG013', '生产中心-采购部', '生产中心', 13, '系统初始化部门'),
('ORG014', '生产中心-仓库', '生产中心', 14, '系统初始化部门'),
('ORG015', '生产中心-品管部', '生产中心', 15, '系统初始化部门'),
('ORG016', '生产中心-生产部', '生产中心', 16, '系统初始化部门'),
('ORG017', '西南运营中心', NULL, 17, '系统初始化部门'),
('ORG018', '西南运营中心-办事处', '西南运营中心', 18, '系统初始化部门'),
('ORG019', '西南运营中心-销售部', '西南运营中心', 19, '系统初始化部门');
INSERT INTO `departments` (`department_code`, `name`, `sort_order`, `is_active`, `remark`)
SELECT `department_code`, `name`, `sort_order`, TRUE, `remark`
FROM `tmp_organization_departments`
ON DUPLICATE KEY UPDATE
`department_code` = VALUES(`department_code`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`remark` = VALUES(`remark`);
UPDATE `departments` child
JOIN `tmp_organization_departments` seed ON seed.name = child.name
LEFT JOIN `departments` parent ON parent.name = seed.parent_name
SET child.parent_id = parent.id;
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`;
CREATE TEMPORARY TABLE `tmp_organization_positions` (
`department_name` VARCHAR(128) NOT NULL,
`position_code` VARCHAR(64) NOT NULL,
`name` VARCHAR(128) NOT NULL,
`sort_order` INT NOT NULL
) ENGINE=MEMORY;
INSERT INTO `tmp_organization_positions`
(`department_name`, `position_code`, `name`, `sort_order`) VALUES
('总经办', 'POS001', '总经理', 1),
('总经办', 'POS002', '副总经理', 2),
('总经办', 'POS003', '总经理助理', 3),
('行政人事部', 'POS004', '人事经理', 1),
('行政人事部', 'POS005', '人事专员', 2),
('财务部', 'POS006', '财务', 1),
('财务部', 'POS007', '出纳专员', 2),
('订单部', 'POS008', '销售助理', 1),
('订单部', 'POS009', '订单助理', 2),
('工程部-安装队', 'POS010', '经理助理', 1),
('工程部-安装队', 'POS011', '安装师傅', 2),
('工程部-安装队', 'POS012', '项目经理', 3),
('工程部-项目预算', 'POS013', '项目预算', 1),
('杭州运营中心-销售部', 'POS014', '销售总监', 1),
('杭州运营中心-销售部', 'POS015', '销售', 2),
('华南运营中心', 'POS016', '销售总监', 1),
('华中运营中心', 'POS017', '销售总监', 1),
('生产中心', 'POS018', '机械工程师', 1),
('生产中心-采购部', 'POS019', '采购', 1),
('生产中心-仓库', 'POS020', '仓库', 1),
('生产中心-仓库', 'POS021', '仓库物料员', 2),
('生产中心-品管部', 'POS022', '品管', 1),
('生产中心-生产部', 'POS023', '焊接师傅', 1),
('生产中心-生产部', 'POS024', '普工', 2),
('生产中心-生产部', 'POS025', '技工', 3),
('西南运营中心', 'POS026', '销售总监', 1),
('西南运营中心-办事处', 'POS027', '经理助理', 1),
('西南运营中心-销售部', 'POS028', '销售工程师', 1);
INSERT INTO `positions` (`department_id`, `position_code`, `name`, `sort_order`, `is_active`, `remark`)
SELECT d.id, p.position_code, p.name, p.sort_order, TRUE, '系统初始化岗位'
FROM `tmp_organization_positions` p
JOIN `departments` d ON d.name = p.department_name
ON DUPLICATE KEY UPDATE
`department_id` = VALUES(`department_id`),
`position_code` = VALUES(`position_code`),
`name` = VALUES(`name`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`remark` = VALUES(`remark`);
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`;
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`;
CREATE TABLE IF NOT EXISTS `employees` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '员工主键ID',
`employee_no` VARCHAR(64) NOT NULL COMMENT '员工编号全局唯一系统自动生成默认ZA前缀',
`name` VARCHAR(128) NOT NULL COMMENT '员工姓名',
`dingtalk_user_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钉钉用户ID用于对接钉钉考勤',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称',
`position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称',
`phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号',
`employment_status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '员工状态active在职、inactive离职',
`remark` 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 `ix_employees_employee_no` (`employee_no`),
KEY `ix_employees_name` (`name`),
KEY `ix_employees_dingtalk_user_id` (`dingtalk_user_id`),
KEY `ix_employees_employment_status` (`employment_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='员工基础档案表维护员工编号、姓名、部门、岗位和钉钉用户ID';
CREATE TABLE IF NOT EXISTS `salary_profiles` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资档案主键ID',
`employee_id` INT NOT NULL COMMENT '关联员工ID一名员工一条当前薪资档案',
`salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式monthly包月、hourly计时、piecework计件、probation试用期',
`base_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资或底薪',
`hourly_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '计时工资小时单价',
`piece_quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '计件数量',
`piece_unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '计件单价',
`probation_type` VARCHAR(32) NOT NULL DEFAULT 'ratio' COMMENT '试用期计算方式ratio固定比例、fixed固定金额',
`probation_ratio` FLOAT NOT NULL DEFAULT 1 COMMENT '试用期工资比例',
`probation_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '试用期固定工资',
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '默认提成金额月度提成优先维护到commission_record',
`overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '工作日加班费小时单价,独立于基础工资维护',
`weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班费小时单价',
`holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班费小时单价',
`effective_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '生效月份格式YYYY-MM为空表示长期当前档案',
`remark` 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 `ix_salary_profiles_employee_id` (`employee_id`),
KEY `ix_salary_profiles_salary_mode` (`salary_mode`),
CONSTRAINT `fk_salary_profiles_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='员工薪资档案表,维护薪资模式、底薪、计时计件规则、默认提成和加班费单价';
CREATE TABLE IF NOT EXISTS `operation_logs` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '日志主键ID',
`user_id` INT NULL COMMENT '操作用户ID匿名或用户删除后为空',
`username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作用户名快照',
`role` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '操作用户角色快照',
`module` VARCHAR(64) NOT NULL COMMENT '业务模块例如auth、payroll、system',
`action` VARCHAR(128) NOT NULL COMMENT '操作动作编码例如login、payroll.excel.calculate',
`target_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作对象类型例如user、payroll_job、file',
`target_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '操作对象ID或业务标识',
`status` VARCHAR(32) NOT NULL COMMENT '操作状态success或failed',
`detail` TEXT NOT NULL COMMENT '操作详情或失败原因',
`ip_address` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端IP地址',
`user_agent` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '客户端浏览器User-Agent',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `ix_operation_logs_user_id` (`user_id`),
KEY `ix_operation_logs_username` (`username`),
KEY `ix_operation_logs_module` (`module`),
KEY `ix_operation_logs_action` (`action`),
KEY `ix_operation_logs_status` (`status`),
KEY `ix_operation_logs_created_at` (`created_at`),
CONSTRAINT `fk_operation_logs_user_id`
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='系统操作日志表,记录用户登录、资料维护、工资计算、下载和系统管理动作';
CREATE TABLE IF NOT EXISTS `payroll_jobs` (
`id` VARCHAR(32) NOT NULL COMMENT '任务IDUUID十六进制字符串',
`source_type` VARCHAR(32) NOT NULL COMMENT '任务来源excel或dingtalk',
`status` VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '任务状态running、completed、failed',
`input_file` TEXT NULL COMMENT '上传的原始Excel文件路径',
`output_file` TEXT NULL COMMENT '导出的工资结果Excel文件路径',
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '本次任务计算的员工数量',
`error_message` TEXT 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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='工资计算任务表记录每次Excel或钉钉实时计算任务';
CREATE TABLE IF NOT EXISTS `payroll_results` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '结果主键ID',
`job_id` VARCHAR(32) NOT NULL COMMENT '所属工资计算任务ID',
`name` VARCHAR(128) NOT NULL COMMENT '员工姓名',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称',
`position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称',
`attendance_group` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '考勤组名称',
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM',
`salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照',
`attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '出勤天数',
`absence_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 '剩余加班工时:打卡推算加班工时减请假调休工时',
`workday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '工作日剩余可计费加班工时',
`weekend_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '周末剩余可计费加班工时',
`holiday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日剩余可计费加班工时',
`minor_late_count` INT NOT NULL DEFAULT 0 COMMENT '5分钟内迟到次数',
`major_late_count` INT NOT NULL DEFAULT 0 COMMENT '超过5分钟迟到次数',
`penalized_late_count` INT NOT NULL DEFAULT 0 COMMENT '实际计扣迟到次数',
`late_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '迟到扣款金额',
`missing_card_count` INT NOT NULL DEFAULT 0 COMMENT '缺卡次数',
`missing_card_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '缺卡扣款金额',
`total_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计金额',
`base_salary` FLOAT NULL COMMENT '员工底薪',
`base_pay` FLOAT NULL COMMENT '按薪资模式计算后的基础工资',
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '手工维护的提成费用',
`overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班小时单价',
`weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班小时单价',
`holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班小时单价',
`overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额',
`gross_salary` FLOAT NULL COMMENT '应发工资',
`net_salary` FLOAT NULL COMMENT '实发工资',
`note` TEXT NOT NULL COMMENT '备注',
PRIMARY KEY (`id`),
KEY `ix_payroll_results_job_id` (`job_id`),
KEY `ix_payroll_results_salary_month` (`salary_month`),
CONSTRAINT `fk_payroll_results_job_id`
FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='员工工资计算结果表,保存任务下每个员工的加班、请假、扣款和工资结果';
SET @has_payroll_commission_amount := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payroll_results' AND COLUMN_NAME = 'commission_amount'
);
SET @ddl := IF(@has_payroll_commission_amount = 0,
'ALTER TABLE `payroll_results` ADD COLUMN `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT ''手工维护的提成费用'' AFTER `base_salary`',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS `attendance_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '考勤记录主键ID',
`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 COMMENT '员工姓名快照',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
`work_date` DATE NOT NULL COMMENT '考勤日期',
`first_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '上班打卡时间HH:MM',
`last_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '下班打卡时间HH:MM',
`attendance_status` VARCHAR(32) NOT NULL DEFAULT 'present' COMMENT '考勤状态present出勤、absent缺勤、leave请假、missing缺卡',
`late_minutes` INT NOT NULL DEFAULT 0 COMMENT '当日迟到分钟合计',
`missing_card_count` INT NOT NULL DEFAULT 0 COMMENT '当日缺卡次数',
`leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日请假或调休工时',
`overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日打卡推算加班工时',
`raw_text` TEXT NOT NULL COMMENT '原始考勤单元格内容',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源excel或dingtalk',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_attendance_record_job_id` (`job_id`),
KEY `ix_attendance_record_employee_id` (`employee_id`),
KEY `ix_attendance_record_employee_no` (`employee_no`),
KEY `ix_attendance_record_employee_name` (`employee_name`),
KEY `ix_attendance_record_work_date` (`work_date`),
KEY `ix_attendance_record_attendance_status` (`attendance_status`),
CONSTRAINT `fk_attendance_record_job_id`
FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_attendance_record_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 `leave_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '请假记录主键ID',
`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 COMMENT '员工姓名快照',
`leave_type` VARCHAR(32) NOT NULL COMMENT '请假类型',
`start_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始开始时间文本',
`end_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始结束时间文本',
`hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假工时',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源excel或dingtalk',
`status` VARCHAR(32) NOT NULL DEFAULT 'approved' COMMENT '状态approved已生效',
`remark` TEXT NOT NULL COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_leave_record_job_id` (`job_id`),
KEY `ix_leave_record_employee_id` (`employee_id`),
KEY `ix_leave_record_employee_no` (`employee_no`),
KEY `ix_leave_record_employee_name` (`employee_name`),
KEY `ix_leave_record_leave_type` (`leave_type`),
CONSTRAINT `fk_leave_record_job_id`
FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_leave_record_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 `overtime_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '加班记录主键ID',
`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 COMMENT '员工姓名快照',
`work_date` DATE NOT NULL COMMENT '加班日期',
`overtime_type` VARCHAR(32) NOT NULL DEFAULT 'workday' COMMENT '加班类型workday工作日、weekend周末、holiday法定节假日',
`hours` FLOAT NOT NULL DEFAULT 0 COMMENT '加班工时',
`rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班小时单价',
`amount` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源excel或dingtalk',
`status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态calculated已计算',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_overtime_record_job_id` (`job_id`),
KEY `ix_overtime_record_employee_id` (`employee_id`),
KEY `ix_overtime_record_employee_no` (`employee_no`),
KEY `ix_overtime_record_employee_name` (`employee_name`),
KEY `ix_overtime_record_work_date` (`work_date`),
KEY `ix_overtime_record_overtime_type` (`overtime_type`),
CONSTRAINT `fk_overtime_record_job_id`
FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_overtime_record_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额';
CREATE TABLE IF NOT EXISTS `commission_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID',
`employee_id` INT NOT NULL COMMENT '关联员工ID',
`commission_month` VARCHAR(7) NOT NULL COMMENT '提成月份格式YYYY-MM',
`commission_type` VARCHAR(64) NOT NULL DEFAULT '销售提成' COMMENT '提成类型',
`amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源manual手工、excel导入',
`business_ref` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '业务单号或来源标识',
`remark` 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`),
KEY `ix_commission_record_employee_id` (`employee_id`),
KEY `ix_commission_record_commission_month` (`commission_month`),
CONSTRAINT `fk_commission_record_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='提成记录表,保存销售提成、项目奖金、绩效奖励等月度金额';
CREATE TABLE IF NOT EXISTS `salary_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '工资记录主键ID',
`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 COMMENT '员工姓名快照',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
`salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份格式YYYY-MM',
`salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照',
`base_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资金额',
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额',
`overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额',
`deduction_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计金额',
`gross_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资',
`net_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资',
`status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态calculated已计算',
`remark` 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`),
KEY `ix_salary_record_job_id` (`job_id`),
KEY `ix_salary_record_employee_id` (`employee_id`),
KEY `ix_salary_record_employee_no` (`employee_no`),
KEY `ix_salary_record_employee_name` (`employee_name`),
KEY `ix_salary_record_department` (`department`),
KEY `ix_salary_record_salary_month` (`salary_month`),
CONSTRAINT `fk_salary_record_job_id`
FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_salary_record_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 `salary_detail` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '工资明细主键ID',
`salary_record_id` INT NOT NULL COMMENT '关联工资记录ID',
`item_type` VARCHAR(32) NOT NULL COMMENT '工资项类型earning收入、deduction扣款、stat统计',
`item_name` VARCHAR(64) NOT NULL COMMENT '工资项名称',
`amount` FLOAT NOT NULL DEFAULT 0 COMMENT '金额',
`quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '数量或工时',
`unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '单价',
`remark` TEXT NOT NULL COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_salary_detail_salary_record_id` (`salary_record_id`),
KEY `ix_salary_detail_item_type` (`item_type`),
CONSTRAINT `fk_salary_detail_salary_record_id`
FOREIGN KEY (`salary_record_id`) REFERENCES `salary_record` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='工资明细表,保存工资记录下的每个工资项';
CREATE TABLE IF NOT EXISTS `salary_config` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '配置主键ID',
`config_key` VARCHAR(128) NOT NULL COMMENT '配置键',
`config_name` VARCHAR(128) NOT NULL COMMENT '配置名称',
`config_group` VARCHAR(64) NOT NULL COMMENT '配置分组',
`config_value` TEXT NOT NULL COMMENT '配置值按value_type解析',
`value_type` VARCHAR(32) NOT NULL DEFAULT 'string' COMMENT '值类型string、number、integer、boolean、json',
`is_enabled` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用',
`remark` 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 `ix_salary_config_config_key` (`config_key`),
KEY `ix_salary_config_config_group` (`config_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='系统配置表,保存可后台维护的考勤、薪资和集成规则';
INSERT INTO `salary_config` (`config_key`, `config_name`, `config_group`, `config_value`, `value_type`, `is_enabled`, `remark`) VALUES
('attendance.on_work_time', '上班时间', 'attendance', '08:30', 'string', TRUE, '用于迟到识别格式HH:MM'),
('attendance.off_work_time', '下班时间', 'attendance', '17:00', 'string', TRUE, '加班按下班打卡超过该时间后向下取整'),
('attendance.overtime_round_minutes', '加班取整分钟', 'attendance', '60', 'integer', TRUE, '例如60表示20:30按3小时计算'),
('attendance.standard_daily_hours', '标准日工时', 'attendance', '8', 'number', TRUE, '计时工资按出勤天数折算工时'),
('attendance.minor_late_minutes', '轻微迟到分钟', 'attendance', '5', 'integer', TRUE, '小于等于该分钟数按轻微迟到统计'),
('attendance.minor_late_free_times', '轻微迟到免扣次数', 'attendance', '3', 'integer', TRUE, '5分钟内迟到3次内合格超过后按次扣款'),
('attendance.late_penalty', '迟到扣款', 'attendance', '30', 'number', TRUE, '每次计扣迟到扣款金额'),
('attendance.missing_card_penalty', '缺卡扣款', 'attendance', '20', 'number', TRUE, '每次缺卡扣款金额'),
('attendance.weekend_days', '周末星期', 'attendance', '[5, 6]', 'json', TRUE, 'Python weekday周一0周六5周日6'),
('attendance.legal_holidays', '法定节假日', 'attendance', '[]', 'json', TRUE, '日期数组,例如["2026-10-01"]'),
('attendance.leave_keywords', '请假关键字', 'attendance', '["调休", "请假", "事假", "病假", "年假", "婚假", "产假", "陪产假", "丧假"]', 'json', TRUE, '从钉钉单元格识别请假/调休类型'),
('payroll.default_overtime_rate', '工作日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工单价时使用'),
('payroll.default_weekend_overtime_rate', '周末加班单价', 'payroll', '0', 'number', TRUE, '未维护员工周末单价时使用'),
('payroll.default_holiday_overtime_rate', '节假日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工节假日单价时使用')
ON DUPLICATE KEY UPDATE `config_key` = VALUES(`config_key`);
INSERT INTO `users` (
`username`,
`display_name`,
`avatar_url`,
`email`,
`phone`,
`department`,
`position_title`,
`password_hash`,
`role`,
`is_active`,
`created_at`,
`updated_at`
) VALUES (
'admin',
'超级管理员',
'',
'',
'',
'系统管理',
'超级管理员',
'pbkdf2_sha256$260000$b3a9c1TDdorYhXOj4usDsA$BPH38v3hjJ-vj8tGw-senDM5czyhRX-wr_zS8lV4bBo',
'superuser',
TRUE,
NOW(),
NOW()
) ON DUPLICATE KEY UPDATE
`display_name` = VALUES(`display_name`),
`department` = VALUES(`department`),
`position_title` = VALUES(`position_title`),
`role` = VALUES(`role`),
`is_active` = VALUES(`is_active`),
`updated_at` = NOW();

View File

@ -0,0 +1,489 @@
-- Financial System MySQL schema.
-- 新库建表脚本:创建 financial_system 数据库、全部业务表和默认规则配置。
CREATE DATABASE IF NOT EXISTS `financial_system`
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE `financial_system`;
CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID',
`username` VARCHAR(64) NOT NULL COMMENT '登录用户名,全局唯一',
`display_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户显示名称',
`avatar_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '用户头像URL或静态资源路径',
`email` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户邮箱',
`phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '用户手机号',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户所属部门',
`position_title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户职位或岗位名称',
`password_hash` VARCHAR(256) NOT NULL COMMENT '密码哈希值,不保存明文密码',
`role` VARCHAR(32) NOT NULL COMMENT '用户角色superuser、manager、viewer',
`is_active` BOOL NOT NULL DEFAULT TRUE 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 `ix_users_username` (`username`),
KEY `ix_users_role` (`role`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='系统用户表,保存登录账号、角色和启用状态';
CREATE TABLE IF NOT EXISTS `departments` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '部门主键ID',
`parent_id` INT NULL COMMENT '父级部门ID顶级部门为空',
`department_code` VARCHAR(64) NOT NULL COMMENT '部门编码,全局唯一',
`name` VARCHAR(128) NOT NULL COMMENT '部门名称',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前',
`is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用',
`remark` 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 `ix_departments_department_code` (`department_code`),
UNIQUE KEY `ix_departments_name` (`name`),
KEY `ix_departments_parent_id` (`parent_id`),
KEY `ix_departments_is_active` (`is_active`),
CONSTRAINT `fk_departments_parent_id`
FOREIGN KEY (`parent_id`) REFERENCES `departments` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='部门表,维护组织架构中的部门及上下级关系';
CREATE TABLE IF NOT EXISTS `positions` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '岗位主键ID',
`department_id` INT NOT NULL COMMENT '关联部门ID',
`position_code` VARCHAR(64) NOT NULL COMMENT '岗位编码,全局唯一',
`name` VARCHAR(128) NOT NULL COMMENT '岗位名称',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前',
`is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用',
`remark` 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 `ix_positions_position_code` (`position_code`),
UNIQUE KEY `uq_positions_department_name` (`department_id`, `name`),
KEY `ix_positions_department_id` (`department_id`),
KEY `ix_positions_name` (`name`),
KEY `ix_positions_is_active` (`is_active`),
CONSTRAINT `fk_positions_department_id`
FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='岗位表,维护部门下的职位/岗位';
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`;
CREATE TEMPORARY TABLE `tmp_organization_departments` (
`department_code` VARCHAR(64) NOT NULL,
`name` VARCHAR(128) NOT NULL,
`parent_name` VARCHAR(128) NULL,
`sort_order` INT NOT NULL,
`remark` VARCHAR(255) NOT NULL
) ENGINE=MEMORY;
INSERT INTO `tmp_organization_departments`
(`department_code`, `name`, `parent_name`, `sort_order`, `remark`) VALUES
('ORG001', '总经办', NULL, 1, '系统初始化部门'),
('ORG002', '行政人事部', NULL, 2, '系统初始化部门'),
('ORG003', '财务部', NULL, 3, '系统初始化部门'),
('ORG004', '订单部', NULL, 4, '系统初始化部门'),
('ORG005', '工程部', NULL, 5, '系统根据工程部子部门自动补齐的父级部门'),
('ORG006', '工程部-安装队', '工程部', 6, '系统初始化部门'),
('ORG007', '工程部-项目预算', '工程部', 7, '系统初始化部门'),
('ORG008', '杭州运营中心', NULL, 8, '系统根据杭州运营中心子部门自动补齐的父级部门'),
('ORG009', '杭州运营中心-销售部', '杭州运营中心', 9, '系统初始化部门'),
('ORG010', '华南运营中心', NULL, 10, '系统初始化部门'),
('ORG011', '华中运营中心', NULL, 11, '系统初始化部门'),
('ORG012', '生产中心', NULL, 12, '系统初始化部门'),
('ORG013', '生产中心-采购部', '生产中心', 13, '系统初始化部门'),
('ORG014', '生产中心-仓库', '生产中心', 14, '系统初始化部门'),
('ORG015', '生产中心-品管部', '生产中心', 15, '系统初始化部门'),
('ORG016', '生产中心-生产部', '生产中心', 16, '系统初始化部门'),
('ORG017', '西南运营中心', NULL, 17, '系统初始化部门'),
('ORG018', '西南运营中心-办事处', '西南运营中心', 18, '系统初始化部门'),
('ORG019', '西南运营中心-销售部', '西南运营中心', 19, '系统初始化部门');
INSERT INTO `departments` (`department_code`, `name`, `sort_order`, `is_active`, `remark`)
SELECT `department_code`, `name`, `sort_order`, TRUE, `remark`
FROM `tmp_organization_departments`
ON DUPLICATE KEY UPDATE
`department_code` = VALUES(`department_code`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`remark` = VALUES(`remark`);
UPDATE `departments` child
JOIN `tmp_organization_departments` seed ON seed.name = child.name
LEFT JOIN `departments` parent ON parent.name = seed.parent_name
SET child.parent_id = parent.id;
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`;
CREATE TEMPORARY TABLE `tmp_organization_positions` (
`department_name` VARCHAR(128) NOT NULL,
`position_code` VARCHAR(64) NOT NULL,
`name` VARCHAR(128) NOT NULL,
`sort_order` INT NOT NULL
) ENGINE=MEMORY;
INSERT INTO `tmp_organization_positions`
(`department_name`, `position_code`, `name`, `sort_order`) VALUES
('总经办', 'POS001', '总经理', 1),
('总经办', 'POS002', '副总经理', 2),
('总经办', 'POS003', '总经理助理', 3),
('行政人事部', 'POS004', '人事经理', 1),
('行政人事部', 'POS005', '人事专员', 2),
('财务部', 'POS006', '财务', 1),
('财务部', 'POS007', '出纳专员', 2),
('订单部', 'POS008', '销售助理', 1),
('订单部', 'POS009', '订单助理', 2),
('工程部-安装队', 'POS010', '经理助理', 1),
('工程部-安装队', 'POS011', '安装师傅', 2),
('工程部-安装队', 'POS012', '项目经理', 3),
('工程部-项目预算', 'POS013', '项目预算', 1),
('杭州运营中心-销售部', 'POS014', '销售总监', 1),
('杭州运营中心-销售部', 'POS015', '销售', 2),
('华南运营中心', 'POS016', '销售总监', 1),
('华中运营中心', 'POS017', '销售总监', 1),
('生产中心', 'POS018', '机械工程师', 1),
('生产中心-采购部', 'POS019', '采购', 1),
('生产中心-仓库', 'POS020', '仓库', 1),
('生产中心-仓库', 'POS021', '仓库物料员', 2),
('生产中心-品管部', 'POS022', '品管', 1),
('生产中心-生产部', 'POS023', '焊接师傅', 1),
('生产中心-生产部', 'POS024', '普工', 2),
('生产中心-生产部', 'POS025', '技工', 3),
('西南运营中心', 'POS026', '销售总监', 1),
('西南运营中心-办事处', 'POS027', '经理助理', 1),
('西南运营中心-销售部', 'POS028', '销售工程师', 1);
INSERT INTO `positions` (`department_id`, `position_code`, `name`, `sort_order`, `is_active`, `remark`)
SELECT d.id, p.position_code, p.name, p.sort_order, TRUE, '系统初始化岗位'
FROM `tmp_organization_positions` p
JOIN `departments` d ON d.name = p.department_name
ON DUPLICATE KEY UPDATE
`department_id` = VALUES(`department_id`),
`position_code` = VALUES(`position_code`),
`name` = VALUES(`name`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`remark` = VALUES(`remark`);
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`;
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`;
CREATE TABLE IF NOT EXISTS `employees` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '员工主键ID',
`employee_no` VARCHAR(64) NOT NULL COMMENT '员工编号全局唯一系统自动生成默认ZA前缀',
`name` VARCHAR(128) NOT NULL COMMENT '员工姓名',
`dingtalk_user_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钉钉用户ID用于对接钉钉考勤',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称',
`position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称',
`phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号',
`employment_status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '员工状态active在职、inactive离职',
`remark` 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 `ix_employees_employee_no` (`employee_no`),
KEY `ix_employees_name` (`name`),
KEY `ix_employees_dingtalk_user_id` (`dingtalk_user_id`),
KEY `ix_employees_employment_status` (`employment_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='员工基础档案表维护员工编号、姓名、部门、岗位和钉钉用户ID';
CREATE TABLE IF NOT EXISTS `salary_profiles` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资档案主键ID',
`employee_id` INT NOT NULL COMMENT '关联员工ID一名员工一条当前薪资档案',
`salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式monthly包月、hourly计时、piecework计件、probation试用期',
`base_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资或底薪',
`hourly_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '计时工资小时单价',
`piece_quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '计件数量',
`piece_unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '计件单价',
`probation_type` VARCHAR(32) NOT NULL DEFAULT 'ratio' COMMENT '试用期计算方式ratio固定比例、fixed固定金额',
`probation_ratio` FLOAT NOT NULL DEFAULT 1 COMMENT '试用期工资比例',
`probation_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '试用期固定工资',
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '默认提成金额月度提成优先维护到commission_record',
`overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '工作日加班费小时单价,独立于基础工资维护',
`weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班费小时单价',
`holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班费小时单价',
`effective_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '生效月份格式YYYY-MM为空表示长期当前档案',
`remark` 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 `ix_salary_profiles_employee_id` (`employee_id`),
KEY `ix_salary_profiles_salary_mode` (`salary_mode`),
CONSTRAINT `fk_salary_profiles_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='员工薪资档案表,维护薪资模式、底薪、计时计件规则、默认提成和加班费单价';
CREATE TABLE IF NOT EXISTS `operation_logs` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '日志主键ID',
`user_id` INT NULL COMMENT '操作用户ID匿名或用户删除后为空',
`username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作用户名快照',
`role` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '操作用户角色快照',
`module` VARCHAR(64) NOT NULL COMMENT '业务模块例如auth、payroll、system',
`action` VARCHAR(128) NOT NULL COMMENT '操作动作编码例如login、payroll.excel.calculate',
`target_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作对象类型例如user、payroll_job、file',
`target_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '操作对象ID或业务标识',
`status` VARCHAR(32) NOT NULL COMMENT '操作状态success或failed',
`detail` TEXT NOT NULL COMMENT '操作详情或失败原因',
`ip_address` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端IP地址',
`user_agent` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '客户端浏览器User-Agent',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `ix_operation_logs_user_id` (`user_id`),
KEY `ix_operation_logs_username` (`username`),
KEY `ix_operation_logs_module` (`module`),
KEY `ix_operation_logs_action` (`action`),
KEY `ix_operation_logs_status` (`status`),
KEY `ix_operation_logs_created_at` (`created_at`),
CONSTRAINT `fk_operation_logs_user_id`
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='系统操作日志表,记录用户登录、资料维护、工资计算、下载和系统管理动作';
CREATE TABLE IF NOT EXISTS `payroll_jobs` (
`id` VARCHAR(32) NOT NULL COMMENT '任务IDUUID十六进制字符串',
`source_type` VARCHAR(32) NOT NULL COMMENT '任务来源excel或dingtalk',
`status` VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '任务状态running、completed、failed',
`input_file` TEXT NULL COMMENT '上传的原始Excel文件路径',
`output_file` TEXT NULL COMMENT '导出的工资结果Excel文件路径',
`employee_count` INT NOT NULL DEFAULT 0 COMMENT '本次任务计算的员工数量',
`error_message` TEXT 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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='工资计算任务表记录每次Excel或钉钉实时计算任务';
CREATE TABLE IF NOT EXISTS `payroll_results` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '结果主键ID',
`job_id` VARCHAR(32) NOT NULL COMMENT '所属工资计算任务ID',
`name` VARCHAR(128) NOT NULL COMMENT '员工姓名',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称',
`position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称',
`attendance_group` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '考勤组名称',
`salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM',
`salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照',
`attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '出勤天数',
`absence_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 '剩余加班工时:打卡推算加班工时减请假调休工时',
`workday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '工作日剩余可计费加班工时',
`weekend_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '周末剩余可计费加班工时',
`holiday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日剩余可计费加班工时',
`minor_late_count` INT NOT NULL DEFAULT 0 COMMENT '5分钟内迟到次数',
`major_late_count` INT NOT NULL DEFAULT 0 COMMENT '超过5分钟迟到次数',
`penalized_late_count` INT NOT NULL DEFAULT 0 COMMENT '实际计扣迟到次数',
`late_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '迟到扣款金额',
`missing_card_count` INT NOT NULL DEFAULT 0 COMMENT '缺卡次数',
`missing_card_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '缺卡扣款金额',
`total_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计金额',
`base_salary` FLOAT NULL COMMENT '员工底薪',
`base_pay` FLOAT NULL COMMENT '按薪资模式计算后的基础工资',
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '手工维护的提成费用',
`overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班小时单价',
`weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班小时单价',
`holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班小时单价',
`overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额',
`gross_salary` FLOAT NULL COMMENT '应发工资',
`net_salary` FLOAT NULL COMMENT '实发工资',
`note` TEXT NOT NULL COMMENT '备注',
PRIMARY KEY (`id`),
KEY `ix_payroll_results_job_id` (`job_id`),
KEY `ix_payroll_results_salary_month` (`salary_month`),
CONSTRAINT `fk_payroll_results_job_id`
FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='员工工资计算结果表,保存任务下每个员工的加班、请假、扣款和工资结果';
CREATE TABLE IF NOT EXISTS `attendance_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '考勤记录主键ID',
`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 COMMENT '员工姓名快照',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
`work_date` DATE NOT NULL COMMENT '考勤日期',
`first_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '上班打卡时间HH:MM',
`last_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '下班打卡时间HH:MM',
`attendance_status` VARCHAR(32) NOT NULL DEFAULT 'present' COMMENT '考勤状态present出勤、absent缺勤、leave请假、missing缺卡',
`late_minutes` INT NOT NULL DEFAULT 0 COMMENT '当日迟到分钟合计',
`missing_card_count` INT NOT NULL DEFAULT 0 COMMENT '当日缺卡次数',
`leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日请假或调休工时',
`overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日打卡推算加班工时',
`raw_text` TEXT NOT NULL COMMENT '原始考勤单元格内容',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源excel或dingtalk',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_attendance_record_job_id` (`job_id`),
KEY `ix_attendance_record_employee_id` (`employee_id`),
KEY `ix_attendance_record_employee_no` (`employee_no`),
KEY `ix_attendance_record_employee_name` (`employee_name`),
KEY `ix_attendance_record_work_date` (`work_date`),
KEY `ix_attendance_record_attendance_status` (`attendance_status`),
CONSTRAINT `fk_attendance_record_job_id`
FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_attendance_record_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 `leave_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '请假记录主键ID',
`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 COMMENT '员工姓名快照',
`leave_type` VARCHAR(32) NOT NULL COMMENT '请假类型',
`start_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始开始时间文本',
`end_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始结束时间文本',
`hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假工时',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源excel或dingtalk',
`status` VARCHAR(32) NOT NULL DEFAULT 'approved' COMMENT '状态approved已生效',
`remark` TEXT NOT NULL COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_leave_record_job_id` (`job_id`),
KEY `ix_leave_record_employee_id` (`employee_id`),
KEY `ix_leave_record_employee_no` (`employee_no`),
KEY `ix_leave_record_employee_name` (`employee_name`),
KEY `ix_leave_record_leave_type` (`leave_type`),
CONSTRAINT `fk_leave_record_job_id`
FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_leave_record_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 `overtime_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '加班记录主键ID',
`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 COMMENT '员工姓名快照',
`work_date` DATE NOT NULL COMMENT '加班日期',
`overtime_type` VARCHAR(32) NOT NULL DEFAULT 'workday' COMMENT '加班类型workday工作日、weekend周末、holiday法定节假日',
`hours` FLOAT NOT NULL DEFAULT 0 COMMENT '加班工时',
`rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班小时单价',
`amount` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源excel或dingtalk',
`status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态calculated已计算',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_overtime_record_job_id` (`job_id`),
KEY `ix_overtime_record_employee_id` (`employee_id`),
KEY `ix_overtime_record_employee_no` (`employee_no`),
KEY `ix_overtime_record_employee_name` (`employee_name`),
KEY `ix_overtime_record_work_date` (`work_date`),
KEY `ix_overtime_record_overtime_type` (`overtime_type`),
CONSTRAINT `fk_overtime_record_job_id`
FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_overtime_record_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额';
CREATE TABLE IF NOT EXISTS `commission_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID',
`employee_id` INT NOT NULL COMMENT '关联员工ID',
`commission_month` VARCHAR(7) NOT NULL COMMENT '提成月份格式YYYY-MM',
`commission_type` VARCHAR(64) NOT NULL DEFAULT '销售提成' COMMENT '提成类型',
`amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源manual手工、excel导入',
`business_ref` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '业务单号或来源标识',
`remark` 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`),
KEY `ix_commission_record_employee_id` (`employee_id`),
KEY `ix_commission_record_commission_month` (`commission_month`),
CONSTRAINT `fk_commission_record_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='提成记录表,保存销售提成、项目奖金、绩效奖励等月度金额';
CREATE TABLE IF NOT EXISTS `salary_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '工资记录主键ID',
`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 COMMENT '员工姓名快照',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
`salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份格式YYYY-MM',
`salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照',
`base_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资金额',
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额',
`overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额',
`deduction_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计金额',
`gross_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资',
`net_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资',
`status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态calculated已计算',
`remark` 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`),
KEY `ix_salary_record_job_id` (`job_id`),
KEY `ix_salary_record_employee_id` (`employee_id`),
KEY `ix_salary_record_employee_no` (`employee_no`),
KEY `ix_salary_record_employee_name` (`employee_name`),
KEY `ix_salary_record_department` (`department`),
KEY `ix_salary_record_salary_month` (`salary_month`),
CONSTRAINT `fk_salary_record_job_id`
FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_salary_record_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 `salary_detail` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '工资明细主键ID',
`salary_record_id` INT NOT NULL COMMENT '关联工资记录ID',
`item_type` VARCHAR(32) NOT NULL COMMENT '工资项类型earning收入、deduction扣款、stat统计',
`item_name` VARCHAR(64) NOT NULL COMMENT '工资项名称',
`amount` FLOAT NOT NULL DEFAULT 0 COMMENT '金额',
`quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '数量或工时',
`unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '单价',
`remark` TEXT NOT NULL COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_salary_detail_salary_record_id` (`salary_record_id`),
KEY `ix_salary_detail_item_type` (`item_type`),
CONSTRAINT `fk_salary_detail_salary_record_id`
FOREIGN KEY (`salary_record_id`) REFERENCES `salary_record` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='工资明细表,保存工资记录下的每个工资项';
CREATE TABLE IF NOT EXISTS `salary_config` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '配置主键ID',
`config_key` VARCHAR(128) NOT NULL COMMENT '配置键',
`config_name` VARCHAR(128) NOT NULL COMMENT '配置名称',
`config_group` VARCHAR(64) NOT NULL COMMENT '配置分组',
`config_value` TEXT NOT NULL COMMENT '配置值按value_type解析',
`value_type` VARCHAR(32) NOT NULL DEFAULT 'string' COMMENT '值类型string、number、integer、boolean、json',
`is_enabled` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用',
`remark` 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 `ix_salary_config_config_key` (`config_key`),
KEY `ix_salary_config_config_group` (`config_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='系统配置表,保存可后台维护的考勤、薪资和集成规则';
INSERT INTO `salary_config` (`config_key`, `config_name`, `config_group`, `config_value`, `value_type`, `is_enabled`, `remark`) VALUES
('attendance.on_work_time', '上班时间', 'attendance', '08:30', 'string', TRUE, '用于迟到识别格式HH:MM'),
('attendance.off_work_time', '下班时间', 'attendance', '17:00', 'string', TRUE, '加班按下班打卡超过该时间后向下取整'),
('attendance.overtime_round_minutes', '加班取整分钟', 'attendance', '60', 'integer', TRUE, '例如60表示20:30按3小时计算'),
('attendance.standard_daily_hours', '标准日工时', 'attendance', '8', 'number', TRUE, '计时工资按出勤天数折算工时'),
('attendance.minor_late_minutes', '轻微迟到分钟', 'attendance', '5', 'integer', TRUE, '小于等于该分钟数按轻微迟到统计'),
('attendance.minor_late_free_times', '轻微迟到免扣次数', 'attendance', '3', 'integer', TRUE, '5分钟内迟到3次内合格超过后按次扣款'),
('attendance.late_penalty', '迟到扣款', 'attendance', '30', 'number', TRUE, '每次计扣迟到扣款金额'),
('attendance.missing_card_penalty', '缺卡扣款', 'attendance', '20', 'number', TRUE, '每次缺卡扣款金额'),
('attendance.weekend_days', '周末星期', 'attendance', '[5, 6]', 'json', TRUE, 'Python weekday周一0周六5周日6'),
('attendance.legal_holidays', '法定节假日', 'attendance', '[]', 'json', TRUE, '日期数组,例如["2026-10-01"]'),
('attendance.leave_keywords', '请假关键字', 'attendance', '["调休", "请假", "事假", "病假", "年假", "婚假", "产假", "陪产假", "丧假"]', 'json', TRUE, '从钉钉单元格识别请假/调休类型'),
('payroll.default_overtime_rate', '工作日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工单价时使用'),
('payroll.default_weekend_overtime_rate', '周末加班单价', 'payroll', '0', 'number', TRUE, '未维护员工周末单价时使用'),
('payroll.default_holiday_overtime_rate', '节假日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工节假日单价时使用')
ON DUPLICATE KEY UPDATE `config_key` = VALUES(`config_key`);

View File

@ -0,0 +1,41 @@
-- Financial System seed data.
-- Default superuser:
-- username: admin
-- password: Admin@123456
-- Change the password after first login in production.
USE `financial_system`;
INSERT INTO `users` (
`username`,
`display_name`,
`avatar_url`,
`email`,
`phone`,
`department`,
`position_title`,
`password_hash`,
`role`,
`is_active`,
`created_at`,
`updated_at`
) VALUES (
'admin',
'超级管理员',
'',
'',
'',
'系统管理',
'超级管理员',
'pbkdf2_sha256$260000$b3a9c1TDdorYhXOj4usDsA$BPH38v3hjJ-vj8tGw-senDM5czyhRX-wr_zS8lV4bBo',
'superuser',
TRUE,
NOW(),
NOW()
) ON DUPLICATE KEY UPDATE
`display_name` = VALUES(`display_name`),
`department` = VALUES(`department`),
`position_title` = VALUES(`position_title`),
`role` = VALUES(`role`),
`is_active` = VALUES(`is_active`),
`updated_at` = NOW();

View File

@ -0,0 +1,55 @@
-- Add employee and salary maintenance tables to an existing financial_system database.
-- Execute with:
-- mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/upgrade_20260616_employee_salary.sql
USE `financial_system`;
CREATE TABLE IF NOT EXISTS `employees` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '员工主键ID',
`employee_no` VARCHAR(64) NOT NULL COMMENT '员工编号全局唯一系统自动生成默认ZA前缀',
`name` VARCHAR(128) NOT NULL COMMENT '员工姓名',
`dingtalk_user_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钉钉用户ID用于对接钉钉考勤',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称',
`position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称',
`phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号',
`employment_status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '员工状态active在职、inactive离职',
`remark` 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 `ix_employees_employee_no` (`employee_no`),
KEY `ix_employees_name` (`name`),
KEY `ix_employees_dingtalk_user_id` (`dingtalk_user_id`),
KEY `ix_employees_employment_status` (`employment_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='员工基础档案表维护员工编号、姓名、部门、岗位和钉钉用户ID';
CREATE TABLE IF NOT EXISTS `salary_profiles` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资档案主键ID',
`employee_id` INT NOT NULL COMMENT '关联员工ID一名员工一条当前薪资档案',
`base_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资或底薪',
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '手工维护的提成费用',
`overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费小时单价,独立于基础工资维护',
`effective_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '生效月份格式YYYY-MM为空表示长期当前档案',
`remark` 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 `ix_salary_profiles_employee_id` (`employee_id`),
CONSTRAINT `fk_salary_profiles_employee_id`
FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='员工薪资档案表,维护底薪、手工提成和独立加班费单价';
SET @has_payroll_commission_amount := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payroll_results' AND COLUMN_NAME = 'commission_amount'
);
SET @ddl := IF(@has_payroll_commission_amount = 0,
'ALTER TABLE `payroll_results` ADD COLUMN `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT ''手工维护的提成费用'' AFTER `base_salary`',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,32 @@
-- Add operation log table to an existing financial_system database.
-- Execute with:
-- mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/upgrade_20260616_operation_logs.sql
USE `financial_system`;
CREATE TABLE IF NOT EXISTS `operation_logs` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '日志主键ID',
`user_id` INT NULL COMMENT '操作用户ID匿名或用户删除后为空',
`username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作用户名快照',
`role` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '操作用户角色快照',
`module` VARCHAR(64) NOT NULL COMMENT '业务模块例如auth、payroll、system',
`action` VARCHAR(128) NOT NULL COMMENT '操作动作编码例如login、payroll.excel.calculate',
`target_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作对象类型例如user、payroll_job、file',
`target_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '操作对象ID或业务标识',
`status` VARCHAR(32) NOT NULL COMMENT '操作状态success或failed',
`detail` TEXT NOT NULL COMMENT '操作详情或失败原因',
`ip_address` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端IP地址',
`user_agent` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '客户端浏览器User-Agent',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `ix_operation_logs_user_id` (`user_id`),
KEY `ix_operation_logs_username` (`username`),
KEY `ix_operation_logs_module` (`module`),
KEY `ix_operation_logs_action` (`action`),
KEY `ix_operation_logs_status` (`status`),
KEY `ix_operation_logs_created_at` (`created_at`),
CONSTRAINT `fk_operation_logs_user_id`
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='系统操作日志表,记录用户登录、资料维护、工资计算、下载和系统管理动作';

View File

@ -0,0 +1,71 @@
-- Add user profile and avatar fields to an existing financial_system database.
-- Execute with:
-- mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/upgrade_20260616_user_profile.sql
USE `financial_system`;
SET @has_avatar_url := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'avatar_url'
);
SET @ddl := IF(@has_avatar_url = 0,
'ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(512) NOT NULL DEFAULT '''' COMMENT ''用户头像URL或静态资源路径''',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_email := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'email'
);
SET @ddl := IF(@has_email = 0,
'ALTER TABLE `users` ADD COLUMN `email` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户邮箱''',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_phone := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'phone'
);
SET @ddl := IF(@has_phone = 0,
'ALTER TABLE `users` ADD COLUMN `phone` VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''用户手机号''',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_department := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'department'
);
SET @ddl := IF(@has_department = 0,
'ALTER TABLE `users` ADD COLUMN `department` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户所属部门''',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_position_title := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'position_title'
);
SET @ddl := IF(@has_position_title = 0,
'ALTER TABLE `users` ADD COLUMN `position_title` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户职位或岗位名称''',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE `users`
SET
`department` = IF(`department` = '', '系统管理', `department`),
`position_title` = IF(`position_title` = '', '超级管理员', `position_title`)
WHERE `username` = 'admin';

View File

@ -0,0 +1,362 @@
-- 2026-06-17 薪酬考勤完整模块升级脚本。
-- Execute with:
-- mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260617_payroll_modules.sql
USE `financial_system`;
CREATE TABLE IF NOT EXISTS `departments` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '部门主键ID',
`parent_id` INT NULL COMMENT '父级部门ID顶级部门为空',
`department_code` VARCHAR(64) NOT NULL COMMENT '部门编码,全局唯一',
`name` VARCHAR(128) NOT NULL COMMENT '部门名称',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前',
`is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用',
`remark` 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 `ix_departments_department_code` (`department_code`),
UNIQUE KEY `ix_departments_name` (`name`),
KEY `ix_departments_parent_id` (`parent_id`),
KEY `ix_departments_is_active` (`is_active`),
CONSTRAINT `fk_departments_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `departments` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='部门表,维护组织架构中的部门及上下级关系';
CREATE TABLE IF NOT EXISTS `positions` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '岗位主键ID',
`department_id` INT NOT NULL COMMENT '关联部门ID',
`position_code` VARCHAR(64) NOT NULL COMMENT '岗位编码,全局唯一',
`name` VARCHAR(128) NOT NULL COMMENT '岗位名称',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前',
`is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用',
`remark` 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 `ix_positions_position_code` (`position_code`),
UNIQUE KEY `uq_positions_department_name` (`department_id`, `name`),
KEY `ix_positions_department_id` (`department_id`),
KEY `ix_positions_name` (`name`),
KEY `ix_positions_is_active` (`is_active`),
CONSTRAINT `fk_positions_department_id` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='岗位表,维护部门下的职位/岗位';
SET @has_position_department_name_index := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'positions'
AND INDEX_NAME = 'uq_positions_department_name'
);
SET @ddl := IF(@has_position_department_name_index = 0,
'ALTER TABLE `positions` ADD UNIQUE KEY `uq_positions_department_name` (`department_id`, `name`)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`;
CREATE TEMPORARY TABLE `tmp_organization_departments` (
`department_code` VARCHAR(64) NOT NULL,
`name` VARCHAR(128) NOT NULL,
`parent_name` VARCHAR(128) NULL,
`sort_order` INT NOT NULL,
`remark` VARCHAR(255) NOT NULL
) ENGINE=MEMORY;
INSERT INTO `tmp_organization_departments`
(`department_code`, `name`, `parent_name`, `sort_order`, `remark`) VALUES
('ORG001', '总经办', NULL, 1, '系统初始化部门'),
('ORG002', '行政人事部', NULL, 2, '系统初始化部门'),
('ORG003', '财务部', NULL, 3, '系统初始化部门'),
('ORG004', '订单部', NULL, 4, '系统初始化部门'),
('ORG005', '工程部', NULL, 5, '系统根据工程部子部门自动补齐的父级部门'),
('ORG006', '工程部-安装队', '工程部', 6, '系统初始化部门'),
('ORG007', '工程部-项目预算', '工程部', 7, '系统初始化部门'),
('ORG008', '杭州运营中心', NULL, 8, '系统根据杭州运营中心子部门自动补齐的父级部门'),
('ORG009', '杭州运营中心-销售部', '杭州运营中心', 9, '系统初始化部门'),
('ORG010', '华南运营中心', NULL, 10, '系统初始化部门'),
('ORG011', '华中运营中心', NULL, 11, '系统初始化部门'),
('ORG012', '生产中心', NULL, 12, '系统初始化部门'),
('ORG013', '生产中心-采购部', '生产中心', 13, '系统初始化部门'),
('ORG014', '生产中心-仓库', '生产中心', 14, '系统初始化部门'),
('ORG015', '生产中心-品管部', '生产中心', 15, '系统初始化部门'),
('ORG016', '生产中心-生产部', '生产中心', 16, '系统初始化部门'),
('ORG017', '西南运营中心', NULL, 17, '系统初始化部门'),
('ORG018', '西南运营中心-办事处', '西南运营中心', 18, '系统初始化部门'),
('ORG019', '西南运营中心-销售部', '西南运营中心', 19, '系统初始化部门');
INSERT INTO `departments` (`department_code`, `name`, `sort_order`, `is_active`, `remark`)
SELECT `department_code`, `name`, `sort_order`, TRUE, `remark`
FROM `tmp_organization_departments`
ON DUPLICATE KEY UPDATE
`department_code` = VALUES(`department_code`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`remark` = VALUES(`remark`);
UPDATE `departments` child
JOIN `tmp_organization_departments` seed ON seed.name = child.name
LEFT JOIN `departments` parent ON parent.name = seed.parent_name
SET child.parent_id = parent.id;
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`;
CREATE TEMPORARY TABLE `tmp_organization_positions` (
`department_name` VARCHAR(128) NOT NULL,
`position_code` VARCHAR(64) NOT NULL,
`name` VARCHAR(128) NOT NULL,
`sort_order` INT NOT NULL
) ENGINE=MEMORY;
INSERT INTO `tmp_organization_positions`
(`department_name`, `position_code`, `name`, `sort_order`) VALUES
('总经办', 'POS001', '总经理', 1),
('总经办', 'POS002', '副总经理', 2),
('总经办', 'POS003', '总经理助理', 3),
('行政人事部', 'POS004', '人事经理', 1),
('行政人事部', 'POS005', '人事专员', 2),
('财务部', 'POS006', '财务', 1),
('财务部', 'POS007', '出纳专员', 2),
('订单部', 'POS008', '销售助理', 1),
('订单部', 'POS009', '订单助理', 2),
('工程部-安装队', 'POS010', '经理助理', 1),
('工程部-安装队', 'POS011', '安装师傅', 2),
('工程部-安装队', 'POS012', '项目经理', 3),
('工程部-项目预算', 'POS013', '项目预算', 1),
('杭州运营中心-销售部', 'POS014', '销售总监', 1),
('杭州运营中心-销售部', 'POS015', '销售', 2),
('华南运营中心', 'POS016', '销售总监', 1),
('华中运营中心', 'POS017', '销售总监', 1),
('生产中心', 'POS018', '机械工程师', 1),
('生产中心-采购部', 'POS019', '采购', 1),
('生产中心-仓库', 'POS020', '仓库', 1),
('生产中心-仓库', 'POS021', '仓库物料员', 2),
('生产中心-品管部', 'POS022', '品管', 1),
('生产中心-生产部', 'POS023', '焊接师傅', 1),
('生产中心-生产部', 'POS024', '普工', 2),
('生产中心-生产部', 'POS025', '技工', 3),
('西南运营中心', 'POS026', '销售总监', 1),
('西南运营中心-办事处', 'POS027', '经理助理', 1),
('西南运营中心-销售部', 'POS028', '销售工程师', 1);
INSERT INTO `positions` (`department_id`, `position_code`, `name`, `sort_order`, `is_active`, `remark`)
SELECT d.id, p.position_code, p.name, p.sort_order, TRUE, '系统初始化岗位'
FROM `tmp_organization_positions` p
JOIN `departments` d ON d.name = p.department_name
ON DUPLICATE KEY UPDATE
`department_id` = VALUES(`department_id`),
`position_code` = VALUES(`position_code`),
`name` = VALUES(`name`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`remark` = VALUES(`remark`);
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`;
DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`;
ALTER TABLE `salary_profiles`
ADD COLUMN IF NOT EXISTS `salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式monthly包月、hourly计时、piecework计件、probation试用期' AFTER `employee_id`,
ADD COLUMN IF NOT EXISTS `hourly_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '计时工资小时单价' AFTER `base_salary`,
ADD COLUMN IF NOT EXISTS `piece_quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '计件数量' AFTER `hourly_rate`,
ADD COLUMN IF NOT EXISTS `piece_unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '计件单价' AFTER `piece_quantity`,
ADD COLUMN IF NOT EXISTS `probation_type` VARCHAR(32) NOT NULL DEFAULT 'ratio' COMMENT '试用期计算方式ratio固定比例、fixed固定金额' AFTER `piece_unit_price`,
ADD COLUMN IF NOT EXISTS `probation_ratio` FLOAT NOT NULL DEFAULT 1 COMMENT '试用期工资比例' AFTER `probation_type`,
ADD COLUMN IF NOT EXISTS `probation_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '试用期固定工资' AFTER `probation_ratio`,
ADD COLUMN IF NOT EXISTS `weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班费小时单价' AFTER `overtime_rate`,
ADD COLUMN IF NOT EXISTS `holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班费小时单价' AFTER `weekend_overtime_rate`;
ALTER TABLE `payroll_results`
ADD COLUMN IF NOT EXISTS `salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份格式YYYY-MM' AFTER `attendance_group`,
ADD COLUMN IF NOT EXISTS `salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照' AFTER `salary_month`,
ADD COLUMN IF NOT EXISTS `attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '出勤天数' AFTER `salary_mode`,
ADD COLUMN IF NOT EXISTS `absence_days` FLOAT NOT NULL DEFAULT 0 COMMENT '缺勤天数' AFTER `attendance_days`,
ADD COLUMN IF NOT EXISTS `actual_work_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '实际工作工时' AFTER `absence_days`,
ADD COLUMN IF NOT EXISTS `workday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '工作日剩余可计费加班工时' AFTER `remaining_overtime_hours`,
ADD COLUMN IF NOT EXISTS `weekend_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '周末剩余可计费加班工时' AFTER `workday_overtime_hours`,
ADD COLUMN IF NOT EXISTS `holiday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日剩余可计费加班工时' AFTER `weekend_overtime_hours`,
ADD COLUMN IF NOT EXISTS `base_pay` FLOAT NULL COMMENT '按薪资模式计算后的基础工资' AFTER `base_salary`,
ADD COLUMN IF NOT EXISTS `weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班小时单价' AFTER `overtime_rate`,
ADD COLUMN IF NOT EXISTS `holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班小时单价' AFTER `weekend_overtime_rate`;
CREATE TABLE IF NOT EXISTS `attendance_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '考勤记录主键ID',
`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 COMMENT '员工姓名快照',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
`work_date` DATE NOT NULL COMMENT '考勤日期',
`first_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '上班打卡时间HH:MM',
`last_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '下班打卡时间HH:MM',
`attendance_status` VARCHAR(32) NOT NULL DEFAULT 'present' COMMENT '考勤状态present出勤、absent缺勤、leave请假、missing缺卡',
`late_minutes` INT NOT NULL DEFAULT 0 COMMENT '当日迟到分钟合计',
`missing_card_count` INT NOT NULL DEFAULT 0 COMMENT '当日缺卡次数',
`leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日请假或调休工时',
`overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日打卡推算加班工时',
`raw_text` TEXT NOT NULL COMMENT '原始考勤单元格内容',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源excel或dingtalk',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_attendance_record_job_id` (`job_id`),
KEY `ix_attendance_record_employee_id` (`employee_id`),
KEY `ix_attendance_record_employee_no` (`employee_no`),
KEY `ix_attendance_record_employee_name` (`employee_name`),
KEY `ix_attendance_record_work_date` (`work_date`),
KEY `ix_attendance_record_attendance_status` (`attendance_status`),
CONSTRAINT `fk_attendance_record_job_id` FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_attendance_record_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 `leave_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '请假记录主键ID',
`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 COMMENT '员工姓名快照',
`leave_type` VARCHAR(32) NOT NULL COMMENT '请假类型',
`start_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始开始时间文本',
`end_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始结束时间文本',
`hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假工时',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源excel或dingtalk',
`status` VARCHAR(32) NOT NULL DEFAULT 'approved' COMMENT '状态approved已生效',
`remark` TEXT NOT NULL COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_leave_record_job_id` (`job_id`),
KEY `ix_leave_record_employee_id` (`employee_id`),
KEY `ix_leave_record_employee_no` (`employee_no`),
KEY `ix_leave_record_employee_name` (`employee_name`),
KEY `ix_leave_record_leave_type` (`leave_type`),
CONSTRAINT `fk_leave_record_job_id` FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_leave_record_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 `overtime_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '加班记录主键ID',
`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 COMMENT '员工姓名快照',
`work_date` DATE NOT NULL COMMENT '加班日期',
`overtime_type` VARCHAR(32) NOT NULL DEFAULT 'workday' COMMENT '加班类型workday工作日、weekend周末、holiday法定节假日',
`hours` FLOAT NOT NULL DEFAULT 0 COMMENT '加班工时',
`rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班小时单价',
`amount` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源excel或dingtalk',
`status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态calculated已计算',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_overtime_record_job_id` (`job_id`),
KEY `ix_overtime_record_employee_id` (`employee_id`),
KEY `ix_overtime_record_employee_no` (`employee_no`),
KEY `ix_overtime_record_employee_name` (`employee_name`),
KEY `ix_overtime_record_work_date` (`work_date`),
KEY `ix_overtime_record_overtime_type` (`overtime_type`),
CONSTRAINT `fk_overtime_record_job_id` FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_overtime_record_employee_id` FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额';
CREATE TABLE IF NOT EXISTS `commission_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID',
`employee_id` INT NOT NULL COMMENT '关联员工ID',
`commission_month` VARCHAR(7) NOT NULL COMMENT '提成月份格式YYYY-MM',
`commission_type` VARCHAR(64) NOT NULL DEFAULT '销售提成' COMMENT '提成类型',
`amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额',
`source_type` VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源manual手工、excel导入',
`business_ref` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '业务单号或来源标识',
`remark` 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`),
KEY `ix_commission_record_employee_id` (`employee_id`),
KEY `ix_commission_record_commission_month` (`commission_month`),
CONSTRAINT `fk_commission_record_employee_id` FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='提成记录表,保存销售提成、项目奖金、绩效奖励等月度金额';
CREATE TABLE IF NOT EXISTS `salary_record` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '工资记录主键ID',
`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 COMMENT '员工姓名快照',
`department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照',
`salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份格式YYYY-MM',
`salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照',
`base_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资金额',
`commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额',
`overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额',
`deduction_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计金额',
`gross_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资',
`net_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资',
`status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态calculated已计算',
`remark` 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`),
KEY `ix_salary_record_job_id` (`job_id`),
KEY `ix_salary_record_employee_id` (`employee_id`),
KEY `ix_salary_record_employee_no` (`employee_no`),
KEY `ix_salary_record_employee_name` (`employee_name`),
KEY `ix_salary_record_department` (`department`),
KEY `ix_salary_record_salary_month` (`salary_month`),
CONSTRAINT `fk_salary_record_job_id` FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_salary_record_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 `salary_detail` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '工资明细主键ID',
`salary_record_id` INT NOT NULL COMMENT '关联工资记录ID',
`item_type` VARCHAR(32) NOT NULL COMMENT '工资项类型earning收入、deduction扣款、stat统计',
`item_name` VARCHAR(64) NOT NULL COMMENT '工资项名称',
`amount` FLOAT NOT NULL DEFAULT 0 COMMENT '金额',
`quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '数量或工时',
`unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '单价',
`remark` TEXT NOT NULL COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `ix_salary_detail_salary_record_id` (`salary_record_id`),
KEY `ix_salary_detail_item_type` (`item_type`),
CONSTRAINT `fk_salary_detail_salary_record_id` FOREIGN KEY (`salary_record_id`) REFERENCES `salary_record` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='工资明细表,保存工资记录下的每个工资项';
CREATE TABLE IF NOT EXISTS `salary_config` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '配置主键ID',
`config_key` VARCHAR(128) NOT NULL COMMENT '配置键',
`config_name` VARCHAR(128) NOT NULL COMMENT '配置名称',
`config_group` VARCHAR(64) NOT NULL COMMENT '配置分组',
`config_value` TEXT NOT NULL COMMENT '配置值按value_type解析',
`value_type` VARCHAR(32) NOT NULL DEFAULT 'string' COMMENT '值类型string、number、integer、boolean、json',
`is_enabled` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用',
`remark` 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 `ix_salary_config_config_key` (`config_key`),
KEY `ix_salary_config_config_group` (`config_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='系统配置表,保存可后台维护的考勤、薪资和集成规则';
INSERT INTO `salary_config` (`config_key`, `config_name`, `config_group`, `config_value`, `value_type`, `is_enabled`, `remark`) VALUES
('attendance.on_work_time', '上班时间', 'attendance', '08:30', 'string', TRUE, '用于迟到识别格式HH:MM'),
('attendance.off_work_time', '下班时间', 'attendance', '17:00', 'string', TRUE, '加班按下班打卡超过该时间后向下取整'),
('attendance.overtime_round_minutes', '加班取整分钟', 'attendance', '60', 'integer', TRUE, '例如60表示20:30按3小时计算'),
('attendance.standard_daily_hours', '标准日工时', 'attendance', '8', 'number', TRUE, '计时工资按出勤天数折算工时'),
('attendance.minor_late_minutes', '轻微迟到分钟', 'attendance', '5', 'integer', TRUE, '小于等于该分钟数按轻微迟到统计'),
('attendance.minor_late_free_times', '轻微迟到免扣次数', 'attendance', '3', 'integer', TRUE, '5分钟内迟到3次内合格超过后按次扣款'),
('attendance.late_penalty', '迟到扣款', 'attendance', '30', 'number', TRUE, '每次计扣迟到扣款金额'),
('attendance.missing_card_penalty', '缺卡扣款', 'attendance', '20', 'number', TRUE, '每次缺卡扣款金额'),
('attendance.weekend_days', '周末星期', 'attendance', '[5, 6]', 'json', TRUE, 'Python weekday周一0周六5周日6'),
('attendance.legal_holidays', '法定节假日', 'attendance', '[]', 'json', TRUE, '日期数组,例如["2026-10-01"]'),
('attendance.leave_keywords', '请假关键字', 'attendance', '["调休", "请假", "事假", "病假", "年假", "婚假", "产假", "陪产假", "丧假"]', 'json', TRUE, '从钉钉单元格识别请假/调休类型'),
('payroll.default_overtime_rate', '工作日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工单价时使用'),
('payroll.default_weekend_overtime_rate', '周末加班单价', 'payroll', '0', 'number', TRUE, '未维护员工周末单价时使用'),
('payroll.default_holiday_overtime_rate', '节假日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工节假日单价时使用')
ON DUPLICATE KEY UPDATE `config_key` = VALUES(`config_key`);

View File

@ -0,0 +1 @@
"""Domain models and payroll calculation rules."""

View File

@ -0,0 +1,227 @@
from __future__ import annotations
from datetime import datetime, time
from ..core.config import AppConfig
from .models import DailyAttendance, EmployeeAttendance, PayrollResult
class PayrollCalculator:
"""工资规则计算器,不处理 Excel、HTTP、数据库等外部细节。"""
def __init__(self, config: AppConfig):
self.config = config
self.off_work_time = datetime.strptime(config.attendance.off_work_time, "%H:%M").time()
def calculate(self, employees: list[EmployeeAttendance]) -> list[PayrollResult]:
"""先按每日下班打卡推算加班,再按员工聚合扣款和工资。"""
for employee in employees:
for record in employee.daily_records:
record.punch_overtime_hours = self._overtime_from_checkout(record)
return [self._calculate_employee(employee) for employee in employees]
def _calculate_employee(self, employee: EmployeeAttendance) -> PayrollResult:
punch_overtime_hours = round(sum(r.punch_overtime_hours for r in employee.daily_records), 2)
leave_hours = round(sum(event.hours for event in employee.unique_leave_events), 2)
remaining_overtime_hours = round(punch_overtime_hours - leave_hours, 2)
salary_month = _salary_month(employee)
attendance_days = round(
_number(employee.summary_fields.get("出勤天数")) or _count_attendance_days(employee),
2,
)
expected_work_days = _expected_work_days(employee, self.config.attendance.weekend_days)
absence_days = round(
_number(employee.summary_fields.get("缺勤天数")) or max(0.0, expected_work_days - attendance_days),
2,
)
actual_work_hours = round(attendance_days * self.config.attendance.standard_daily_hours, 2)
raw_workday_hours, raw_weekend_hours, raw_holiday_hours = self._split_overtime_hours(employee)
workday_overtime_hours, weekend_overtime_hours, holiday_overtime_hours = _allocate_payable_overtime(
raw_workday_hours,
raw_weekend_hours,
raw_holiday_hours,
leave_hours,
)
# 5 分钟内迟到先累计免扣次数,超过免扣次数后按次扣款。
late_minutes = [minutes for record in employee.daily_records for minutes in record.late_minutes]
minor_late_count = sum(1 for minutes in late_minutes if minutes <= self.config.attendance.minor_late_minutes)
major_late_count = sum(1 for minutes in late_minutes if minutes > self.config.attendance.minor_late_minutes)
penalized_minor_count = max(0, minor_late_count - self.config.attendance.minor_late_free_times)
penalized_late_count = penalized_minor_count + major_late_count
late_deduction = round(penalized_late_count * self.config.attendance.late_penalty, 2)
daily_missing_count = sum(record.missing_card_count for record in employee.daily_records)
summary_missing_count = int(
_number(employee.summary_fields.get("上班缺卡次数"))
+ _number(employee.summary_fields.get("下班缺卡次数"))
)
# 汇总列和每日明细都可能记录缺卡,取较大值避免漏扣。
missing_card_count = max(daily_missing_count, summary_missing_count)
missing_card_deduction = round(
missing_card_count * self.config.attendance.missing_card_penalty, 2
)
total_deduction = round(late_deduction + missing_card_deduction, 2)
pay_config = self.config.payroll.employees.get(employee.name)
salary_mode = pay_config.salary_mode if pay_config else "monthly"
base_salary = pay_config.base_salary if pay_config else None
base_pay = _base_pay(pay_config, actual_work_hours)
overtime_rate = (
pay_config.overtime_rate
if pay_config and pay_config.overtime_rate is not None
else self.config.payroll.default_overtime_rate
)
weekend_overtime_rate = (
pay_config.weekend_overtime_rate
if pay_config and pay_config.weekend_overtime_rate is not None
else self.config.payroll.default_weekend_overtime_rate
)
holiday_overtime_rate = (
pay_config.holiday_overtime_rate
if pay_config and pay_config.holiday_overtime_rate is not None
else self.config.payroll.default_holiday_overtime_rate
)
overtime_pay = round(
workday_overtime_hours * overtime_rate
+ weekend_overtime_hours * weekend_overtime_rate
+ holiday_overtime_hours * holiday_overtime_rate,
2,
)
commission_amount = round(pay_config.commission_amount, 2) if pay_config else 0.0
gross_salary = None
net_salary = None
note = ""
if base_pay is None:
note = "未配置薪资档案,已计算考勤扣款和剩余加班工时"
else:
gross_salary = round(base_pay + commission_amount + overtime_pay, 2)
net_salary = round(gross_salary - total_deduction, 2)
return PayrollResult(
employee=employee,
salary_month=salary_month,
salary_mode=salary_mode,
attendance_days=attendance_days,
absence_days=absence_days,
actual_work_hours=actual_work_hours,
punch_overtime_hours=punch_overtime_hours,
leave_hours=leave_hours,
remaining_overtime_hours=remaining_overtime_hours,
workday_overtime_hours=workday_overtime_hours,
weekend_overtime_hours=weekend_overtime_hours,
holiday_overtime_hours=holiday_overtime_hours,
minor_late_count=minor_late_count,
major_late_count=major_late_count,
penalized_late_count=penalized_late_count,
late_deduction=late_deduction,
missing_card_count=missing_card_count,
missing_card_deduction=missing_card_deduction,
total_deduction=total_deduction,
base_salary=base_salary,
base_pay=base_pay,
commission_amount=commission_amount,
overtime_rate=overtime_rate,
weekend_overtime_rate=weekend_overtime_rate,
holiday_overtime_rate=holiday_overtime_rate,
overtime_pay=overtime_pay,
gross_salary=gross_salary,
net_salary=net_salary,
note=note,
)
def _split_overtime_hours(self, employee: EmployeeAttendance) -> tuple[float, float, float]:
workday_hours = 0.0
weekend_hours = 0.0
holiday_hours = 0.0
holidays = set(self.config.attendance.legal_holidays)
weekend_days = set(self.config.attendance.weekend_days)
for record in employee.daily_records:
if record.work_date.isoformat() in holidays:
holiday_hours += record.punch_overtime_hours
elif record.work_date.weekday() in weekend_days:
weekend_hours += record.punch_overtime_hours
else:
workday_hours += record.punch_overtime_hours
return round(workday_hours, 2), round(weekend_hours, 2), round(holiday_hours, 2)
def _overtime_from_checkout(self, record: DailyAttendance) -> float:
"""按下班打卡时间向下取整计算加班,例如 20:30 -> 3 小时。"""
checkout = record.last_punch
if checkout is None or not _is_after(checkout, self.off_work_time):
return 0.0
checkout_minutes = checkout.hour * 60 + checkout.minute
off_work_minutes = self.off_work_time.hour * 60 + self.off_work_time.minute
overtime_minutes = checkout_minutes - off_work_minutes
round_minutes = self.config.attendance.overtime_round_minutes
return (overtime_minutes // round_minutes) * round_minutes / 60
def _is_after(left: time, right: time) -> bool:
return (left.hour, left.minute) > (right.hour, right.minute)
def _number(value: object) -> float:
if value in (None, ""):
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _salary_month(employee: EmployeeAttendance) -> str:
if not employee.daily_records:
return ""
return employee.daily_records[0].work_date.strftime("%Y-%m")
def _count_attendance_days(employee: EmployeeAttendance) -> float:
return float(
sum(1 for record in employee.daily_records if record.first_punch is not None or record.last_punch is not None)
)
def _expected_work_days(employee: EmployeeAttendance, weekend_days: tuple[int, ...]) -> float:
configured = _number(employee.summary_fields.get("应出勤天数"))
if configured:
return configured
weekends = set(weekend_days)
return float(sum(1 for record in employee.daily_records if record.work_date.weekday() not in weekends))
def _allocate_payable_overtime(
workday_hours: float,
weekend_hours: float,
holiday_hours: float,
leave_hours: float,
) -> tuple[float, float, float]:
"""请假工时先冲抵工作日加班,再冲抵周末和节假日加班。"""
remaining_leave = max(0.0, leave_hours)
workday_payable = max(0.0, workday_hours - remaining_leave)
remaining_leave = max(0.0, remaining_leave - workday_hours)
weekend_payable = max(0.0, weekend_hours - remaining_leave)
remaining_leave = max(0.0, remaining_leave - weekend_hours)
holiday_payable = max(0.0, holiday_hours - remaining_leave)
return round(workday_payable, 2), round(weekend_payable, 2), round(holiday_payable, 2)
def _base_pay(pay_config, actual_work_hours: float) -> float | None:
if pay_config is None:
return None
mode = pay_config.salary_mode
if mode == "hourly":
return round(pay_config.hourly_rate * actual_work_hours, 2)
if mode == "piecework":
return round(pay_config.piece_quantity * pay_config.piece_unit_price, 2)
if mode == "probation":
if pay_config.probation_type == "fixed" and pay_config.probation_salary is not None:
return round(pay_config.probation_salary, 2)
if pay_config.base_salary is None:
return None
return round(pay_config.base_salary * pay_config.probation_ratio, 2)
if pay_config.base_salary is None:
return None
return round(pay_config.base_salary, 2)

View File

@ -0,0 +1,115 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, time
from typing import Optional
@dataclass(frozen=True)
class LeaveEvent:
"""请假/调休等工时事件key 用于跨天重复记录去重。"""
kind: str
start_text: str
end_text: str
hours: float
@property
def key(self) -> tuple[str, str, str, float]:
return (self.kind, self.start_text, self.end_text, round(self.hours, 4))
@dataclass
class DailyAttendance:
"""单个员工某一天的考勤明细。"""
work_date: date
raw_text: str = ""
first_punch: Optional[time] = None
last_punch: Optional[time] = None
late_minutes: list[int] = field(default_factory=list)
missing_card_count: int = 0
leave_events: list[LeaveEvent] = field(default_factory=list)
punch_overtime_hours: float = 0.0
@property
def leave_hours_in_cell(self) -> float:
return sum(event.hours for event in self.leave_events)
@dataclass
class EmployeeAttendance:
"""员工月度考勤数据,包含汇总字段和每日明细。"""
name: str
attendance_group: str = ""
department: str = ""
employee_no: str = ""
position: str = ""
user_id: str = ""
summary_fields: dict[str, float | str] = field(default_factory=dict)
daily_records: list[DailyAttendance] = field(default_factory=list)
@property
def unique_leave_events(self) -> list[LeaveEvent]:
seen: set[tuple[str, str, str, float]] = set()
result: list[LeaveEvent] = []
for record in self.daily_records:
for event in record.leave_events:
if event.key not in seen:
seen.add(event.key)
result.append(event)
return result
@dataclass(frozen=True)
class EmployeePayConfig:
"""员工工资配置;计算器只关心规则值,不关心这些值来自数据库还是配置文件。"""
salary_mode: str = "monthly"
base_salary: Optional[float] = None
hourly_rate: float = 0.0
piece_quantity: float = 0.0
piece_unit_price: float = 0.0
probation_type: str = "ratio"
probation_ratio: float = 1.0
probation_salary: Optional[float] = None
commission_amount: float = 0.0
overtime_rate: Optional[float] = None
weekend_overtime_rate: Optional[float] = None
holiday_overtime_rate: Optional[float] = None
@dataclass(frozen=True)
class PayrollResult:
"""员工级工资计算结果。"""
employee: EmployeeAttendance
salary_month: str
salary_mode: str
attendance_days: float
absence_days: float
actual_work_hours: float
punch_overtime_hours: float
leave_hours: float
remaining_overtime_hours: float
workday_overtime_hours: float
weekend_overtime_hours: float
holiday_overtime_hours: float
minor_late_count: int
major_late_count: int
penalized_late_count: int
late_deduction: float
missing_card_count: int
missing_card_deduction: float
total_deduction: float
base_salary: Optional[float]
base_pay: Optional[float]
commission_amount: float
overtime_rate: float
weekend_overtime_rate: float
holiday_overtime_rate: float
overtime_pay: float
gross_salary: Optional[float]
net_salary: Optional[float]
note: str = ""

View File

@ -0,0 +1 @@
"""External system integrations."""

View File

@ -0,0 +1,193 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, datetime, time
from typing import Any, Iterable
from zoneinfo import ZoneInfo
import httpx
from ..core.config import AttendanceRules
from ..core.logger import AppLogger
from ..domain.models import DailyAttendance, EmployeeAttendance
logger = AppLogger.get_logger(__name__)
class DingTalkError(RuntimeError):
pass
@dataclass(frozen=True)
class DingTalkSettings:
app_key: str
app_secret: str
base_url: str = "https://oapi.dingtalk.com"
timeout_seconds: float = 15.0
timezone: str = "Asia/Shanghai"
class DingTalkClient:
"""钉钉 OpenAPI 客户端,只负责 HTTP 请求和接口错误转换。"""
def __init__(self, settings: DingTalkSettings):
self.settings = settings
async def get_access_token(self) -> str:
logger.info("钉钉 access_token 请求开始 base_url=%s", self.settings.base_url)
async with httpx.AsyncClient(
base_url=self.settings.base_url,
timeout=self.settings.timeout_seconds,
) as client:
response = await client.get(
"/gettoken",
params={"appkey": self.settings.app_key, "appsecret": self.settings.app_secret},
)
payload = response.json()
self._raise_for_dingtalk_error(payload)
token = payload.get("access_token")
if not token:
logger.error("钉钉 access_token 响应缺失 payload=%s", payload)
raise DingTalkError(f"钉钉未返回 access_token{payload}")
logger.info("钉钉 access_token 请求成功")
return token
async def list_attendance_records(
self,
user_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> list[dict[str, Any]]:
logger.info(
"钉钉考勤记录请求开始 user_count=%s start_time=%s end_time=%s",
len(user_ids),
start_time,
end_time,
)
token = await self.get_access_token()
body = {
"userIds": user_ids,
"checkDateFrom": start_time.strftime("%Y-%m-%d %H:%M:%S"),
"checkDateTo": end_time.strftime("%Y-%m-%d %H:%M:%S"),
}
async with httpx.AsyncClient(
base_url=self.settings.base_url,
timeout=self.settings.timeout_seconds,
) as client:
response = await client.post(
"/topapi/attendance/listRecord",
params={"access_token": token},
json=body,
)
payload = response.json()
self._raise_for_dingtalk_error(payload)
records = payload.get("recordresult") or payload.get("result", {}).get("recordresult") or []
logger.info("钉钉考勤记录请求成功 record_count=%s", len(records))
return records
def _raise_for_dingtalk_error(self, payload: dict[str, Any]) -> None:
errcode = payload.get("errcode")
if errcode not in (None, 0):
errmsg = payload.get("errmsg", "未知错误")
logger.error("钉钉接口返回错误 errcode=%s errmsg=%s", errcode, errmsg)
raise DingTalkError(f"钉钉接口错误 {errcode}: {errmsg}")
class DingTalkAttendanceAdapter:
"""把钉钉实时打卡记录适配成工资计算器可消费的考勤模型。"""
def __init__(self, rules: AttendanceRules, timezone: str = "Asia/Shanghai"):
self.timezone = ZoneInfo(timezone)
self.on_work_time = datetime.strptime(rules.on_work_time, "%H:%M").time()
def to_employee_attendance(self, records: Iterable[dict[str, Any]]) -> list[EmployeeAttendance]:
records = list(records)
logger.info("钉钉考勤记录适配开始 record_count=%s", len(records))
grouped: dict[str, dict[date, list[dict[str, Any]]]] = defaultdict(lambda: defaultdict(list))
names: dict[str, str] = {}
departments: dict[str, str] = {}
for record in records:
user_id = str(record.get("userId") or record.get("userid") or "").strip()
if not user_id:
continue
check_dt = self._record_datetime(record)
if check_dt is None:
continue
grouped[user_id][check_dt.date()].append(record)
names[user_id] = str(record.get("userName") or record.get("name") or user_id)
departments[user_id] = str(record.get("departmentName") or record.get("department") or "")
employees: list[EmployeeAttendance] = []
for user_id, by_day in grouped.items():
employee = EmployeeAttendance(
name=names.get(user_id, user_id),
user_id=user_id,
department=departments.get(user_id, ""),
attendance_group="钉钉实时考勤",
)
for work_date in sorted(by_day):
employee.daily_records.append(self._build_daily_record(work_date, by_day[work_date]))
employees.append(employee)
logger.info("钉钉考勤记录适配完成 employee_count=%s", len(employees))
return employees
def _build_daily_record(self, work_date: date, records: list[dict[str, Any]]) -> DailyAttendance:
dated_records = [
(check_dt, record)
for record in records
if (check_dt := self._record_datetime(record)) is not None
]
dated_records.sort(key=lambda item: item[0])
punch_times = [check_dt.time() for check_dt, _ in dated_records]
raw_text = "".join(
self._record_summary(record, check_dt) for check_dt, record in dated_records
)
first_punch = punch_times[0] if punch_times else None
late_minutes = self._late_minutes(first_punch)
return DailyAttendance(
work_date=work_date,
raw_text=raw_text,
first_punch=first_punch,
last_punch=punch_times[-1] if punch_times else None,
late_minutes=[late_minutes] if late_minutes > 0 else [],
missing_card_count=sum(1 for item in records if self._is_missing_card(item)),
)
def _record_datetime(self, record: dict[str, Any]) -> datetime | None:
value = record.get("userCheckTime") or record.get("checkTime") or record.get("baseCheckTime")
if value in (None, ""):
return None
if isinstance(value, (int, float)):
return datetime.fromtimestamp(value / 1000, tz=self.timezone).replace(tzinfo=None)
text = str(value).strip()
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M"):
try:
return datetime.strptime(text[:19], fmt)
except ValueError:
continue
return None
def _record_summary(self, record: dict[str, Any], check_dt: datetime | None) -> str:
check_time = check_dt.strftime("%H:%M") if check_dt else "-"
check_type = record.get("checkType") or record.get("type") or ""
time_result = record.get("timeResult") or ""
location_result = record.get("locationResult") or ""
return f"{check_type} {check_time} {time_result} {location_result}".strip()
def _late_minutes(self, first_punch: time | None) -> int:
if first_punch is None:
return 0
punch_minutes = first_punch.hour * 60 + first_punch.minute
on_work_minutes = self.on_work_time.hour * 60 + self.on_work_time.minute
return max(0, punch_minutes - on_work_minutes)
def _is_missing_card(self, record: dict[str, Any]) -> bool:
values = {
str(record.get("timeResult") or ""),
str(record.get("checkType") or ""),
str(record.get("sourceType") or ""),
}
joined = " ".join(values).lower()
return "notsigned" in joined or "缺卡" in joined

View File

@ -0,0 +1 @@
"""Input/output adapters for Excel and local files."""

View File

@ -0,0 +1,194 @@
from __future__ import annotations
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from ..core.config import AppConfig
from ..domain.models import PayrollResult
from .parser import iter_leave_event_descriptions
SUMMARY_HEADERS = [
"姓名",
"部门",
"职位",
"考勤组",
"工资月份",
"薪资模式",
"出勤天数",
"缺勤天数",
"实际工作工时",
"休息天数",
"系统加班总时长",
"打卡推算加班工时",
"请假/调休工时",
"剩余加班工时",
"工作日加班工时",
"周末加班工时",
"节假日加班工时",
"5分钟内迟到次数",
"超过5分钟迟到次数",
"计扣迟到次数",
"迟到扣款",
"缺卡次数",
"缺卡扣款",
"扣款合计",
"底薪",
"模式基础工资",
"提成",
"工作日加班单价",
"周末加班单价",
"节假日加班单价",
"加班费",
"应发工资",
"实发工资",
"备注",
]
DAILY_HEADERS = [
"姓名",
"日期",
"原始考勤结果",
"上班打卡",
"下班打卡",
"打卡推算加班工时",
"迟到分钟",
"缺卡次数",
"请假/调休工时(本格)",
"请假/调休记录",
]
def export_payroll_results(results: list[PayrollResult], config: AppConfig, output_path: str | Path) -> Path:
"""导出工资汇总、每日明细和规则说明三张表。"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
workbook = Workbook()
summary_sheet = workbook.active
summary_sheet.title = "工资汇总"
daily_sheet = workbook.create_sheet("每日明细")
rules_sheet = workbook.create_sheet("规则说明")
_write_summary(summary_sheet, results)
_write_daily_details(daily_sheet, results)
_write_rules(rules_sheet, config)
for sheet in workbook.worksheets:
_style_sheet(sheet)
workbook.save(output_path)
return output_path
def _write_summary(sheet, results: list[PayrollResult]) -> None:
sheet.append(SUMMARY_HEADERS)
for result in results:
employee = result.employee
row = [
employee.name,
employee.department,
employee.position,
employee.attendance_group,
result.salary_month,
result.salary_mode,
result.attendance_days,
result.absence_days,
result.actual_work_hours,
employee.summary_fields.get("休息天数", 0),
employee.summary_fields.get("加班总时长", 0),
result.punch_overtime_hours,
result.leave_hours,
result.remaining_overtime_hours,
result.workday_overtime_hours,
result.weekend_overtime_hours,
result.holiday_overtime_hours,
result.minor_late_count,
result.major_late_count,
result.penalized_late_count,
result.late_deduction,
result.missing_card_count,
result.missing_card_deduction,
result.total_deduction,
result.base_salary,
result.base_pay,
result.commission_amount,
result.overtime_rate,
result.weekend_overtime_rate,
result.holiday_overtime_rate,
result.overtime_pay,
result.gross_salary,
result.net_salary,
result.note,
]
sheet.append(row)
def _write_daily_details(sheet, results: list[PayrollResult]) -> None:
sheet.append(DAILY_HEADERS)
for result in results:
for record in result.employee.daily_records:
sheet.append(
[
result.employee.name,
record.work_date,
record.raw_text,
_format_time(record.first_punch),
_format_time(record.last_punch),
record.punch_overtime_hours,
",".join(str(minutes) for minutes in record.late_minutes),
record.missing_card_count,
record.leave_hours_in_cell,
"".join(iter_leave_event_descriptions(record.leave_events)),
]
)
def _write_rules(sheet, config: AppConfig) -> None:
rows = [
("下班时间", config.attendance.off_work_time),
("加班取整分钟", config.attendance.overtime_round_minutes),
("加班计算", "以下班打卡时间为准,超过下班时间后按取整分钟向下取整"),
("5分钟内迟到免扣次数", config.attendance.minor_late_free_times),
("迟到分钟阈值", config.attendance.minor_late_minutes),
("迟到扣款/次", config.attendance.late_penalty),
("缺卡扣款/次", config.attendance.missing_card_penalty),
("请假/调休关键字", "".join(config.attendance.leave_keywords)),
("剩余加班工时", "打卡推算加班工时 - 去重后的请假/调休工时"),
]
sheet.append(["规则", ""])
for row in rows:
sheet.append(row)
def _style_sheet(sheet) -> None:
header_fill = PatternFill("solid", fgColor="1F4E78")
header_font = Font(color="FFFFFF", bold=True)
for cell in sheet[1]:
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = "A2"
for row in sheet.iter_rows(min_row=2):
for cell in row:
cell.alignment = Alignment(vertical="top", wrap_text=True)
for col_idx, cells in enumerate(sheet.columns, start=1):
max_length = 0
for cell in cells:
value = cell.value
if value is None:
continue
text = str(value)
max_length = max(max_length, min(len(text), 45))
sheet.column_dimensions[get_column_letter(col_idx)].width = max(10, max_length + 2)
def _format_time(value) -> str:
if value is None:
return ""
return value.strftime("%H:%M")

View File

@ -0,0 +1,168 @@
from __future__ import annotations
import re
from datetime import date, datetime, time, timedelta
from pathlib import Path
from typing import Iterable
from openpyxl import load_workbook
from ..core.config import AttendanceRules
from ..domain.models import DailyAttendance, EmployeeAttendance, LeaveEvent
PUNCH_LINE_RE = re.compile(r"\((?P<punches>[^()]*)\)\s*$", re.S)
TIME_RE = re.compile(r"^\d{1,2}:\d{2}$")
LATE_RE = re.compile(r"迟到(?P<minutes>\d+(?:\.\d+)?)分钟")
PERIOD_RE = re.compile(r"统计日期:(?P<start>\d{4}-\d{2}-\d{2})\s+至\s+(?P<end>\d{4}-\d{2}-\d{2})")
BASE_COLUMNS = ("姓名", "考勤组", "部门", "工号", "职位", "UserId")
class MonthlySummaryParser:
"""解析钉钉月度汇总 Excel输出统一的员工考勤领域模型。"""
def __init__(self, rules: AttendanceRules):
self.rules = rules
self.leave_re = re.compile(
rf"(?P<kind>{'|'.join(map(re.escape, rules.leave_keywords))})"
r"(?P<start>\d{2}-\d{2}\s+\d{2}:\d{2})到"
r"(?P<end>\d{2}-\d{2}\s+\d{2}:\d{2})\s+"
r"(?P<hours>\d+(?:\.\d+)?)小时"
)
def parse(self, workbook_path: str | Path) -> list[EmployeeAttendance]:
workbook = load_workbook(workbook_path, data_only=True)
sheet = workbook.active
start_date, _ = self._period(sheet["A1"].value)
header_row = 3
data_start_row = 5
daily_start_col = self._find_daily_start_col(sheet, header_row)
header_map = self._header_map(sheet, header_row, daily_start_col)
employees: list[EmployeeAttendance] = []
for row_idx in range(data_start_row, sheet.max_row + 1):
name = self._cell_text(sheet.cell(row_idx, header_map["姓名"]).value)
if not name:
continue
employee = EmployeeAttendance(
name=name,
attendance_group=self._value_by_header(sheet, row_idx, header_map, "考勤组"),
department=self._value_by_header(sheet, row_idx, header_map, "部门"),
employee_no=self._value_by_header(sheet, row_idx, header_map, "工号"),
position=self._value_by_header(sheet, row_idx, header_map, "职位"),
user_id=self._value_by_header(sheet, row_idx, header_map, "UserId"),
summary_fields=self._summary_fields(sheet, row_idx, header_map),
)
for col_idx in range(daily_start_col, sheet.max_column + 1):
work_date = start_date + timedelta(days=col_idx - daily_start_col)
raw_text = self._cell_text(sheet.cell(row_idx, col_idx).value)
record = self._parse_daily_record(work_date, raw_text)
employee.daily_records.append(record)
employees.append(employee)
return employees
def _parse_daily_record(self, work_date: date, raw_text: str) -> DailyAttendance:
punch_times = self._parse_punch_times(raw_text)
record = DailyAttendance(
work_date=work_date,
raw_text=raw_text,
first_punch=punch_times[0] if punch_times else None,
last_punch=punch_times[-1] if punch_times else None,
late_minutes=self._parse_late_minutes(raw_text),
missing_card_count=self._parse_missing_cards(raw_text),
leave_events=self._parse_leave_events(raw_text),
)
return record
def _parse_punch_times(self, raw_text: str) -> list[time]:
"""每日单元格最后一行形如 (08:30,17:00),这里只取真实打卡时间。"""
match = PUNCH_LINE_RE.search(raw_text or "")
if not match:
return []
result: list[time] = []
for item in match.group("punches").split(","):
text = item.strip()
if not TIME_RE.match(text):
continue
result.append(datetime.strptime(text, "%H:%M").time())
return result
def _parse_late_minutes(self, raw_text: str) -> list[int]:
return [int(float(match.group("minutes"))) for match in LATE_RE.finditer(raw_text or "")]
def _parse_missing_cards(self, raw_text: str) -> int:
return (raw_text or "").count("缺卡")
def _parse_leave_events(self, raw_text: str) -> list[LeaveEvent]:
"""提取请假/调休事件;跨天申请后续会按事件 key 去重。"""
return [
LeaveEvent(
kind=match.group("kind"),
start_text=match.group("start"),
end_text=match.group("end"),
hours=float(match.group("hours")),
)
for match in self.leave_re.finditer(raw_text or "")
]
def _period(self, title: object) -> tuple[date, date]:
match = PERIOD_RE.search(self._cell_text(title))
if not match:
raise ValueError("未能从 A1 识别统计日期期望格式统计日期YYYY-MM-DD 至 YYYY-MM-DD")
return (
datetime.strptime(match.group("start"), "%Y-%m-%d").date(),
datetime.strptime(match.group("end"), "%Y-%m-%d").date(),
)
def _find_daily_start_col(self, sheet, header_row: int) -> int:
for col_idx in range(1, sheet.max_column + 1):
if self._cell_text(sheet.cell(header_row, col_idx).value) == "考勤结果":
return col_idx
raise ValueError("未找到“考勤结果”列,无法识别每日考勤明细。")
def _header_map(self, sheet, header_row: int, stop_col: int) -> dict[str, int]:
mapping: dict[str, int] = {}
for col_idx in range(1, stop_col):
header = self._cell_text(sheet.cell(header_row, col_idx).value)
if header:
mapping[header] = col_idx
missing = [header for header in BASE_COLUMNS if header not in mapping]
if missing:
raise ValueError(f"缺少必要表头:{', '.join(missing)}")
return mapping
def _summary_fields(self, sheet, row_idx: int, header_map: dict[str, int]) -> dict[str, float | str]:
fields: dict[str, float | str] = {}
for header, col_idx in header_map.items():
if header in BASE_COLUMNS:
continue
value = sheet.cell(row_idx, col_idx).value
fields[header] = self._normalize_value(value)
return fields
def _value_by_header(self, sheet, row_idx: int, header_map: dict[str, int], header: str) -> str:
return self._cell_text(sheet.cell(row_idx, header_map[header]).value)
def _normalize_value(self, value: object) -> float | str:
text = self._cell_text(value)
if text == "":
return 0.0
try:
return float(text)
except ValueError:
return text
def _cell_text(self, value: object) -> str:
if value is None:
return ""
return str(value).strip()
def iter_leave_event_descriptions(events: Iterable[LeaveEvent]) -> Iterable[str]:
for event in events:
yield f"{event.kind}{event.start_text}{event.end_text} {event.hours:g}小时"

View File

@ -0,0 +1,61 @@
from __future__ import annotations
import shutil
from pathlib import Path
from typing import BinaryIO
from uuid import uuid4
from ..core.logger import AppLogger
from ..core.settings import AppSettings
logger = AppLogger.get_logger(__name__)
ALLOWED_AVATAR_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
class FileStorage:
"""统一管理上传文件和导出文件路径,避免路由层直接操作磁盘。"""
def __init__(self, settings: AppSettings):
self.settings = settings
def save_upload(self, filename: str, stream: BinaryIO) -> Path:
if not filename.lower().endswith(".xlsx"):
logger.error("上传文件格式错误 filename=%s", filename)
raise ValueError("请上传 .xlsx 格式的月度汇总 Excel")
self.settings.upload_dir.mkdir(parents=True, exist_ok=True)
path = self.settings.upload_dir / f"attendance_{uuid4().hex}{Path(filename).suffix}"
with path.open("wb") as buffer:
shutil.copyfileobj(stream, buffer)
logger.info("上传文件保存完成 filename=%s path=%s", filename, path)
return path
def payroll_output_path(self, source_type: str) -> Path:
self.settings.output_dir.mkdir(parents=True, exist_ok=True)
path = self.settings.output_dir / f"payroll_{source_type}_{uuid4().hex}.xlsx"
logger.info("工资结果输出路径已生成 source_type=%s path=%s", source_type, path)
return path
def resolve_output_file(self, filename: str) -> Path:
"""只允许下载输出目录内文件,避免路径穿越。"""
path = (self.settings.output_dir / Path(filename).name).resolve()
output_dir = self.settings.output_dir.resolve()
if output_dir not in path.parents or not path.is_file():
logger.error("下载文件不存在或越权 filename=%s resolved_path=%s", filename, path)
raise FileNotFoundError("文件不存在")
logger.info("下载文件解析完成 filename=%s path=%s", filename, path)
return path
def save_avatar(self, filename: str, stream: BinaryIO) -> str:
"""保存用户头像并返回可供前端访问的静态资源路径。"""
suffix = Path(filename).suffix.lower()
if suffix not in ALLOWED_AVATAR_SUFFIXES:
logger.error("头像文件格式错误 filename=%s", filename)
raise ValueError("请上传 jpg、jpeg、png、webp 或 gif 格式头像")
self.settings.avatar_dir.mkdir(parents=True, exist_ok=True)
path = self.settings.avatar_dir / f"avatar_{uuid4().hex}{suffix}"
with path.open("wb") as buffer:
shutil.copyfileobj(stream, buffer)
logger.info("头像文件保存完成 filename=%s path=%s", filename, path)
return f"/static/uploads/avatars/{path.name}"

View File

@ -0,0 +1,21 @@
from .commission_repository import CommissionRepository
from .employee_repository import EmployeeRepository
from .operation_log_repository import OperationLogRepository
from .organization_repository import OrganizationRepository
from .payroll_repository import PayrollRepository
from .report_repository import ReportRepository
from .salary_profile_repository import SalaryProfileRepository
from .salary_config_repository import SalaryConfigRepository
from .user_repository import UserRepository
__all__ = [
"CommissionRepository",
"EmployeeRepository",
"OperationLogRepository",
"OrganizationRepository",
"PayrollRepository",
"ReportRepository",
"SalaryConfigRepository",
"SalaryProfileRepository",
"UserRepository",
]

View File

@ -0,0 +1,121 @@
from __future__ import annotations
from sqlalchemy import func, or_
from sqlalchemy.orm import Session, joinedload
from ..database.orm import CommissionRecordORM, EmployeeORM
class CommissionRepository:
"""提成仓库:只负责 commission_record 表的读写和月度汇总。"""
def __init__(self, session: Session):
self.session = session
def create_record(
self,
*,
employee_id: int,
commission_month: str,
commission_type: str,
amount: float,
source_type: str,
business_ref: str,
remark: str,
) -> CommissionRecordORM:
record = CommissionRecordORM(
employee_id=employee_id,
commission_month=commission_month,
commission_type=commission_type,
amount=amount,
source_type=source_type,
business_ref=business_ref,
remark=remark,
)
self.session.add(record)
self.session.commit()
return self.require_by_id(record.id)
def update_record(
self,
record_id: int,
*,
employee_id: int,
commission_month: str,
commission_type: str,
amount: float,
source_type: str,
business_ref: str,
remark: str,
) -> CommissionRecordORM:
record = self.require_by_id(record_id)
record.employee_id = employee_id
record.commission_month = commission_month
record.commission_type = commission_type
record.amount = amount
record.source_type = source_type
record.business_ref = business_ref
record.remark = remark
self.session.commit()
return self.require_by_id(record_id)
def delete_record(self, record_id: int) -> None:
record = self.require_by_id(record_id)
self.session.delete(record)
self.session.commit()
def require_by_id(self, record_id: int) -> CommissionRecordORM:
record = (
self.session.query(CommissionRecordORM)
.options(joinedload(CommissionRecordORM.employee))
.filter(CommissionRecordORM.id == record_id)
.one_or_none()
)
if record is None:
raise ValueError("提成记录不存在")
return record
def list_records(
self,
*,
page: int,
page_size: int,
commission_month: str | None = None,
keyword: str | None = None,
) -> tuple[list[CommissionRecordORM], int]:
query = self.session.query(CommissionRecordORM).options(joinedload(CommissionRecordORM.employee))
if commission_month:
query = query.filter(CommissionRecordORM.commission_month == commission_month)
if keyword:
like_keyword = f"%{keyword}%"
query = query.join(CommissionRecordORM.employee).filter(
or_(
EmployeeORM.employee_no.like(like_keyword),
EmployeeORM.name.like(like_keyword),
EmployeeORM.department.like(like_keyword),
CommissionRecordORM.commission_type.like(like_keyword),
CommissionRecordORM.business_ref.like(like_keyword),
)
)
total = query.count()
items = (
query.order_by(CommissionRecordORM.commission_month.desc(), CommissionRecordORM.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return items, total
def aggregate_by_month(self, commission_month: str) -> dict[int, float]:
rows = (
self.session.query(CommissionRecordORM.employee_id, func.sum(CommissionRecordORM.amount))
.filter(CommissionRecordORM.commission_month == commission_month)
.group_by(CommissionRecordORM.employee_id)
.all()
)
return {employee_id: float(amount or 0) for employee_id, amount in rows}
def create_many(self, records: list[CommissionRecordORM]) -> int:
self.session.add_all(records)
self.session.commit()
return len(records)

View File

@ -0,0 +1,132 @@
from __future__ import annotations
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ..database.orm import EmployeeORM
class EmployeeRepository:
"""员工仓库:封装 employees 表的基础维护。"""
def __init__(self, session: Session):
self.session = session
def create_employee(
self,
*,
employee_no: str,
name: str,
dingtalk_user_id: str = "",
department: str = "",
position: str = "",
phone: str = "",
employment_status: str = "active",
remark: str = "",
) -> EmployeeORM:
employee = EmployeeORM(
employee_no=employee_no,
name=name,
dingtalk_user_id=dingtalk_user_id,
department=department,
position=position,
phone=phone,
employment_status=employment_status,
remark=remark,
)
self.session.add(employee)
self.session.commit()
self.session.refresh(employee)
return employee
def update_employee(
self,
employee_id: int,
*,
employee_no: str,
name: str,
dingtalk_user_id: str,
department: str,
position: str,
phone: str,
employment_status: str,
remark: str,
) -> EmployeeORM:
employee = self.require_by_id(employee_id)
employee.employee_no = employee_no
employee.name = name
employee.dingtalk_user_id = dingtalk_user_id
employee.department = department
employee.position = position
employee.phone = phone
employee.employment_status = employment_status
employee.remark = remark
self.session.commit()
self.session.refresh(employee)
return employee
def get_by_id(self, employee_id: int) -> EmployeeORM | None:
return self.session.query(EmployeeORM).filter(EmployeeORM.id == employee_id).one_or_none()
def require_by_id(self, employee_id: int) -> EmployeeORM:
employee = self.get_by_id(employee_id)
if employee is None:
raise ValueError("员工不存在")
return employee
def get_by_employee_no(self, employee_no: str) -> EmployeeORM | None:
return self.session.query(EmployeeORM).filter(EmployeeORM.employee_no == employee_no).one_or_none()
def list_employee_numbers_by_prefix(self, prefix: str) -> list[str]:
"""查询指定前缀的员工编号,供服务层生成下一个编号。"""
rows = (
self.session.query(EmployeeORM.employee_no)
.filter(EmployeeORM.employee_no.like(f"{prefix}%"))
.all()
)
return [row[0] for row in rows]
def list_employees(
self,
*,
keyword: str | None = None,
employment_status: str | None = None,
) -> list[EmployeeORM]:
query = self._filtered_query(keyword=keyword, employment_status=employment_status)
return query.order_by(EmployeeORM.id.desc()).all()
def list_employees_page(
self,
*,
page: int,
page_size: int,
keyword: str | None = None,
employment_status: str | None = None,
) -> tuple[list[EmployeeORM], int]:
query = self._filtered_query(keyword=keyword, employment_status=employment_status)
total = query.count()
items = (
query.order_by(EmployeeORM.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return items, total
def _filtered_query(self, *, keyword: str | None, employment_status: str | None):
query = self.session.query(EmployeeORM)
if employment_status:
query = query.filter(EmployeeORM.employment_status == employment_status)
if keyword:
like_keyword = f"%{keyword}%"
query = query.filter(
or_(
EmployeeORM.employee_no.like(like_keyword),
EmployeeORM.name.like(like_keyword),
EmployeeORM.dingtalk_user_id.like(like_keyword),
EmployeeORM.department.like(like_keyword),
EmployeeORM.position.like(like_keyword),
EmployeeORM.phone.like(like_keyword),
)
)
return query

View File

@ -0,0 +1,100 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ..database.orm import OperationLogORM
class OperationLogRepository:
"""操作日志仓库:只负责 operation_logs 表的写入和查询。"""
def __init__(self, session: Session):
self.session = session
def create_log(
self,
*,
user_id: int | None,
username: str,
role: str,
module: str,
action: str,
target_type: str,
target_id: str,
status: str,
detail: str,
ip_address: str,
user_agent: str,
) -> OperationLogORM:
log = OperationLogORM(
user_id=user_id,
username=username,
role=role,
module=module,
action=action,
target_type=target_type,
target_id=target_id,
status=status,
detail=detail,
ip_address=ip_address,
user_agent=user_agent,
)
self.session.add(log)
self.session.commit()
self.session.refresh(log)
return log
def list_logs(
self,
*,
page: int,
page_size: int,
username: str | None = None,
module: str | None = None,
action: str | None = None,
status: str | None = None,
keyword: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
) -> tuple[list[OperationLogORM], int]:
query = self.session.query(OperationLogORM)
if username:
query = query.filter(OperationLogORM.username.like(f"%{username}%"))
if module:
query = query.filter(OperationLogORM.module == module)
if action:
query = query.filter(OperationLogORM.action == action)
if status:
query = query.filter(OperationLogORM.status == status)
if start_time:
query = query.filter(OperationLogORM.created_at >= start_time)
if end_time:
query = query.filter(OperationLogORM.created_at <= end_time)
if keyword:
like_keyword = f"%{keyword}%"
query = query.filter(
or_(
OperationLogORM.username.like(like_keyword),
OperationLogORM.module.like(like_keyword),
OperationLogORM.action.like(like_keyword),
OperationLogORM.target_type.like(like_keyword),
OperationLogORM.target_id.like(like_keyword),
OperationLogORM.detail.like(like_keyword),
)
)
total = query.count()
items = (
query.order_by(OperationLogORM.created_at.desc(), OperationLogORM.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return items, total
def rollback(self) -> None:
self.session.rollback()

View File

@ -0,0 +1,185 @@
from __future__ import annotations
from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload
from ..database.orm import DepartmentORM, PositionORM
class OrganizationRepository:
"""组织架构仓库:封装部门和岗位表的读写。"""
def __init__(self, session: Session):
self.session = session
def list_departments(self, *, active_only: bool = False, keyword: str | None = None) -> list[DepartmentORM]:
query = self.session.query(DepartmentORM)
if active_only:
query = query.filter(DepartmentORM.is_active.is_(True))
if keyword:
like_keyword = f"%{keyword}%"
query = query.filter(
or_(
DepartmentORM.department_code.like(like_keyword),
DepartmentORM.name.like(like_keyword),
)
)
return query.order_by(DepartmentORM.sort_order.asc(), DepartmentORM.id.asc()).all()
def create_department(
self,
*,
parent_id: int | None,
department_code: str,
name: str,
sort_order: int,
is_active: bool,
remark: str,
) -> DepartmentORM:
department = DepartmentORM(
parent_id=parent_id,
department_code=department_code,
name=name,
sort_order=sort_order,
is_active=is_active,
remark=remark,
)
self.session.add(department)
self.session.commit()
self.session.refresh(department)
return department
def update_department(
self,
department_id: int,
*,
parent_id: int | None,
department_code: str,
name: str,
sort_order: int,
is_active: bool,
remark: str,
) -> DepartmentORM:
department = self.require_department(department_id)
department.parent_id = parent_id
department.department_code = department_code
department.name = name
department.sort_order = sort_order
department.is_active = is_active
department.remark = remark
self.session.commit()
self.session.refresh(department)
return department
def require_department(self, department_id: int) -> DepartmentORM:
department = self.session.query(DepartmentORM).filter(DepartmentORM.id == department_id).one_or_none()
if department is None:
raise ValueError("部门不存在")
return department
def get_department_by_name(self, name: str) -> DepartmentORM | None:
return self.session.query(DepartmentORM).filter(DepartmentORM.name == name).one_or_none()
def get_department_by_code(self, department_code: str) -> DepartmentORM | None:
return (
self.session.query(DepartmentORM)
.filter(DepartmentORM.department_code == department_code)
.one_or_none()
)
def list_positions(
self,
*,
department_id: int | None = None,
active_only: bool = False,
keyword: str | None = None,
) -> list[PositionORM]:
query = self.session.query(PositionORM).join(PositionORM.department).options(joinedload(PositionORM.department))
if department_id:
query = query.filter(PositionORM.department_id == department_id)
if active_only:
query = query.filter(PositionORM.is_active.is_(True))
if keyword:
like_keyword = f"%{keyword}%"
query = query.filter(
or_(
PositionORM.position_code.like(like_keyword),
PositionORM.name.like(like_keyword),
)
)
return query.order_by(
DepartmentORM.sort_order.asc(),
PositionORM.sort_order.asc(),
PositionORM.id.asc(),
).all()
def create_position(
self,
*,
department_id: int,
position_code: str,
name: str,
sort_order: int,
is_active: bool,
remark: str,
) -> PositionORM:
position = PositionORM(
department_id=department_id,
position_code=position_code,
name=name,
sort_order=sort_order,
is_active=is_active,
remark=remark,
)
self.session.add(position)
self.session.commit()
return self.require_position(position.id)
def update_position(
self,
position_id: int,
*,
department_id: int,
position_code: str,
name: str,
sort_order: int,
is_active: bool,
remark: str,
) -> PositionORM:
position = self.require_position(position_id)
position.department_id = department_id
position.position_code = position_code
position.name = name
position.sort_order = sort_order
position.is_active = is_active
position.remark = remark
self.session.commit()
return self.require_position(position_id)
def require_position(self, position_id: int) -> PositionORM:
position = (
self.session.query(PositionORM)
.options(joinedload(PositionORM.department))
.filter(PositionORM.id == position_id)
.one_or_none()
)
if position is None:
raise ValueError("岗位不存在")
return position
def get_position_by_code(self, position_code: str) -> PositionORM | None:
return (
self.session.query(PositionORM)
.options(joinedload(PositionORM.department))
.filter(PositionORM.position_code == position_code)
.one_or_none()
)
def get_position_by_department_and_name(self, department_id: int, name: str) -> PositionORM | None:
"""按部门和岗位名称查询,用于避免同一部门下重复维护同名岗位。"""
return (
self.session.query(PositionORM)
.options(joinedload(PositionORM.department))
.filter(PositionORM.department_id == department_id, PositionORM.name == name)
.one_or_none()
)

View File

@ -0,0 +1,300 @@
from __future__ import annotations
from uuid import uuid4
from sqlalchemy.orm import Session, selectinload
from ..database.orm import (
AttendanceRecordORM,
EmployeeORM,
LeaveRecordORM,
OvertimeRecordORM,
PayrollJobORM,
PayrollResultORM,
SalaryDetailORM,
SalaryRecordORM,
)
from ..domain.models import PayrollResult
class PayrollRepository:
"""工资任务仓库:只负责 Payroll ORM 的读写,不包含业务计算。"""
def __init__(self, session: Session):
self.session = session
def create_job(self, source_type: str, input_file: str | None = None) -> PayrollJobORM:
job = PayrollJobORM(
id=uuid4().hex,
source_type=source_type,
status="running",
input_file=input_file,
)
self.session.add(job)
self.session.commit()
self.session.refresh(job)
return job
def save_results(self, job_id: str, results: list[PayrollResult]) -> None:
"""批量保存员工结果,保持计算服务和 ORM 字段映射隔离。"""
job = self.require_job(job_id)
rows = [self._result_row(job_id, result) for result in results]
normalized_rows = []
salary_rows = []
for result in results:
employee = self._match_employee(result)
normalized_rows.extend(self._attendance_rows(job.id, job.source_type, result, employee))
normalized_rows.extend(self._leave_rows(job.id, job.source_type, result, employee))
normalized_rows.extend(self._overtime_rows(job.id, job.source_type, result, employee))
salary_rows.append(self._salary_record_row(job_id, result, employee))
self.session.add_all([*rows, *normalized_rows, *salary_rows])
self.session.commit()
def mark_completed(
self,
job_id: str,
employee_count: int,
output_file: str | None = None,
) -> PayrollJobORM:
job = self.require_job(job_id)
job.status = "completed"
job.employee_count = employee_count
job.output_file = output_file
self.session.commit()
self.session.refresh(job)
return job
def mark_failed(self, job_id: str, error_message: str) -> PayrollJobORM:
job = self.require_job(job_id)
job.status = "failed"
job.error_message = error_message
self.session.commit()
self.session.refresh(job)
return job
def get_job(self, job_id: str) -> PayrollJobORM | None:
return (
self.session.query(PayrollJobORM)
.options(selectinload(PayrollJobORM.results))
.filter(PayrollJobORM.id == job_id)
.one_or_none()
)
def require_job(self, job_id: str) -> PayrollJobORM:
job = self.get_job(job_id)
if job is None:
raise KeyError(f"Payroll job not found: {job_id}")
return job
def _result_row(self, job_id: str, result: PayrollResult) -> PayrollResultORM:
employee = result.employee
return PayrollResultORM(
job_id=job_id,
name=employee.name,
department=employee.department,
position=employee.position,
attendance_group=employee.attendance_group,
salary_month=result.salary_month,
salary_mode=result.salary_mode,
attendance_days=result.attendance_days,
absence_days=result.absence_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,
workday_overtime_hours=result.workday_overtime_hours,
weekend_overtime_hours=result.weekend_overtime_hours,
holiday_overtime_hours=result.holiday_overtime_hours,
minor_late_count=result.minor_late_count,
major_late_count=result.major_late_count,
penalized_late_count=result.penalized_late_count,
late_deduction=result.late_deduction,
missing_card_count=result.missing_card_count,
missing_card_deduction=result.missing_card_deduction,
total_deduction=result.total_deduction,
base_salary=result.base_salary,
base_pay=result.base_pay,
commission_amount=result.commission_amount,
overtime_rate=result.overtime_rate,
weekend_overtime_rate=result.weekend_overtime_rate,
holiday_overtime_rate=result.holiday_overtime_rate,
overtime_pay=result.overtime_pay,
gross_salary=result.gross_salary,
net_salary=result.net_salary,
note=result.note,
)
def _match_employee(self, result: PayrollResult) -> EmployeeORM | None:
employee = result.employee
query = self.session.query(EmployeeORM)
if employee.employee_no:
matched = query.filter(EmployeeORM.employee_no == employee.employee_no).one_or_none()
if matched is not None:
return matched
return query.filter(EmployeeORM.name == employee.name).one_or_none()
def _attendance_rows(
self,
job_id: str,
source_type: str,
result: PayrollResult,
employee: EmployeeORM | None,
) -> list[AttendanceRecordORM]:
rows: list[AttendanceRecordORM] = []
for record in result.employee.daily_records:
rows.append(
AttendanceRecordORM(
job_id=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,
work_date=record.work_date,
first_punch=_time_text(record.first_punch),
last_punch=_time_text(record.last_punch),
attendance_status=_attendance_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=source_type,
)
)
return rows
def _leave_rows(
self,
job_id: str,
source_type: str,
result: PayrollResult,
employee: EmployeeORM | None,
) -> list[LeaveRecordORM]:
return [
LeaveRecordORM(
job_id=job_id,
employee_id=employee.id if employee else None,
employee_no=result.employee.employee_no,
employee_name=result.employee.name,
leave_type=event.kind,
start_text=event.start_text,
end_text=event.end_text,
hours=event.hours,
source_type=source_type,
status="approved",
)
for event in result.employee.unique_leave_events
]
def _overtime_rows(
self,
job_id: str,
source_type: str,
result: PayrollResult,
employee: EmployeeORM | None,
) -> list[OvertimeRecordORM]:
rows: list[OvertimeRecordORM] = []
for record in result.employee.daily_records:
if record.punch_overtime_hours <= 0:
continue
overtime_type = _overtime_type(record.work_date)
rate = result.weekend_overtime_rate if overtime_type == "weekend" else result.overtime_rate
rows.append(
OvertimeRecordORM(
job_id=job_id,
employee_id=employee.id if employee else None,
employee_no=result.employee.employee_no,
employee_name=result.employee.name,
work_date=record.work_date,
overtime_type=overtime_type,
hours=record.punch_overtime_hours,
rate=rate,
amount=round(record.punch_overtime_hours * rate, 2),
source_type=source_type,
)
)
return rows
def _salary_record_row(
self,
job_id: str,
result: PayrollResult,
employee: EmployeeORM | None,
) -> SalaryRecordORM:
record = SalaryRecordORM(
job_id=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,
salary_month=result.salary_month,
salary_mode=result.salary_mode,
base_pay=result.base_pay or 0,
commission_amount=result.commission_amount,
overtime_pay=result.overtime_pay,
deduction_amount=result.total_deduction,
gross_salary=result.gross_salary or 0,
net_salary=result.net_salary or 0,
remark=result.note,
)
record.details = [
SalaryDetailORM(item_type="earning", item_name="基础工资", amount=result.base_pay or 0),
SalaryDetailORM(item_type="earning", item_name="提成/奖金", amount=result.commission_amount),
SalaryDetailORM(
item_type="earning",
item_name="工作日加班费",
amount=round(result.workday_overtime_hours * result.overtime_rate, 2),
quantity=result.workday_overtime_hours,
unit_price=result.overtime_rate,
),
SalaryDetailORM(
item_type="earning",
item_name="周末加班费",
amount=round(result.weekend_overtime_hours * result.weekend_overtime_rate, 2),
quantity=result.weekend_overtime_hours,
unit_price=result.weekend_overtime_rate,
),
SalaryDetailORM(
item_type="earning",
item_name="节假日加班费",
amount=round(result.holiday_overtime_hours * result.holiday_overtime_rate, 2),
quantity=result.holiday_overtime_hours,
unit_price=result.holiday_overtime_rate,
),
SalaryDetailORM(
item_type="deduction",
item_name="迟到扣款",
amount=result.late_deduction,
quantity=result.penalized_late_count,
),
SalaryDetailORM(
item_type="deduction",
item_name="缺卡扣款",
amount=result.missing_card_deduction,
quantity=result.missing_card_count,
),
SalaryDetailORM(item_type="stat", item_name="出勤天数", amount=0, quantity=result.attendance_days),
SalaryDetailORM(item_type="stat", item_name="请假工时", amount=0, quantity=result.leave_hours),
SalaryDetailORM(item_type="stat", item_name="剩余加班工时", amount=0, quantity=result.remaining_overtime_hours),
]
return record
def _time_text(value) -> str:
if value is None:
return ""
return value.strftime("%H:%M")
def _attendance_status(record) -> str:
if record.first_punch or record.last_punch:
if record.missing_card_count:
return "missing"
return "present"
if record.leave_hours_in_cell:
return "leave"
return "absent"
def _overtime_type(work_date) -> str:
return "weekend" if work_date.weekday() >= 5 else "workday"

View File

@ -0,0 +1,79 @@
from __future__ import annotations
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database.orm import AttendanceRecordORM, SalaryRecordORM
class ReportRepository:
"""报表仓库:只负责聚合工资、考勤等已落库数据。"""
def __init__(self, session: Session):
self.session = session
def payroll_totals(self, *, salary_month: str | None = None) -> dict[str, float]:
query = self.session.query(
func.count(SalaryRecordORM.id),
func.sum(SalaryRecordORM.base_pay),
func.sum(SalaryRecordORM.commission_amount),
func.sum(SalaryRecordORM.overtime_pay),
func.sum(SalaryRecordORM.deduction_amount),
func.sum(SalaryRecordORM.gross_salary),
func.sum(SalaryRecordORM.net_salary),
)
if salary_month:
query = query.filter(SalaryRecordORM.salary_month == salary_month)
row = query.one()
return {
"employee_count": int(row[0] or 0),
"base_pay": float(row[1] or 0),
"commission_amount": float(row[2] or 0),
"overtime_pay": float(row[3] or 0),
"deduction_amount": float(row[4] or 0),
"gross_salary": float(row[5] or 0),
"net_salary": float(row[6] or 0),
}
def attendance_totals(self, *, salary_month: str | None = None) -> dict[str, float]:
query = self.session.query(
func.count(AttendanceRecordORM.id),
func.sum(AttendanceRecordORM.overtime_hours),
func.sum(AttendanceRecordORM.leave_hours),
func.sum(AttendanceRecordORM.missing_card_count),
)
if salary_month:
query = query.filter(func.date_format(AttendanceRecordORM.work_date, "%Y-%m") == salary_month)
row = query.one()
return {
"attendance_record_count": int(row[0] or 0),
"overtime_hours": float(row[1] or 0),
"leave_hours": float(row[2] or 0),
"missing_card_count": float(row[3] or 0),
}
def department_totals(self, *, salary_month: str | None = None) -> list[dict[str, float | str]]:
query = self.session.query(
SalaryRecordORM.department,
func.count(SalaryRecordORM.id),
func.sum(SalaryRecordORM.gross_salary),
func.sum(SalaryRecordORM.net_salary),
)
if salary_month:
query = query.filter(SalaryRecordORM.salary_month == salary_month)
rows = query.group_by(SalaryRecordORM.department).order_by(func.sum(SalaryRecordORM.net_salary).desc()).all()
return [
{
"department": department or "未分配",
"employee_count": int(count or 0),
"gross_salary": float(gross or 0),
"net_salary": float(net or 0),
}
for department, count, gross, net in rows
]
def recent_salary_records(self, *, salary_month: str | None = None, limit: int = 10) -> list[SalaryRecordORM]:
query = self.session.query(SalaryRecordORM)
if salary_month:
query = query.filter(SalaryRecordORM.salary_month == salary_month)
return query.order_by(SalaryRecordORM.created_at.desc(), SalaryRecordORM.id.desc()).limit(limit).all()

View File

@ -0,0 +1,63 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from ..database.orm import SalaryConfigORM
class SalaryConfigRepository:
"""系统配置仓库:封装 salary_config 表的查询和保存。"""
def __init__(self, session: Session):
self.session = session
def list_configs(self, *, config_group: str | None = None) -> list[SalaryConfigORM]:
query = self.session.query(SalaryConfigORM)
if config_group:
query = query.filter(SalaryConfigORM.config_group == config_group)
return query.order_by(SalaryConfigORM.config_group.asc(), SalaryConfigORM.id.asc()).all()
def get_by_key(self, config_key: str) -> SalaryConfigORM | None:
return self.session.query(SalaryConfigORM).filter(SalaryConfigORM.config_key == config_key).one_or_none()
def require_by_key(self, config_key: str) -> SalaryConfigORM:
config = self.get_by_key(config_key)
if config is None:
raise ValueError("配置项不存在")
return config
def upsert_config(
self,
*,
config_key: str,
config_name: str,
config_group: str,
config_value: str,
value_type: str,
is_enabled: bool = True,
remark: str = "",
) -> SalaryConfigORM:
config = self.get_by_key(config_key)
if config is None:
config = SalaryConfigORM(config_key=config_key)
self.session.add(config)
config.config_name = config_name
config.config_group = config_group
config.config_value = config_value
config.value_type = value_type
config.is_enabled = is_enabled
config.remark = remark
self.session.commit()
self.session.refresh(config)
return config
def create_defaults(self, defaults: list[dict]) -> int:
created = 0
for item in defaults:
if self.get_by_key(item["config_key"]) is not None:
continue
self.session.add(SalaryConfigORM(**item))
created += 1
if created:
self.session.commit()
return created

View File

@ -0,0 +1,93 @@
from __future__ import annotations
from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload
from ..database.orm import EmployeeORM, SalaryProfileORM
class SalaryProfileRepository:
"""薪资档案仓库:维护 salary_profiles 表和员工关联查询。"""
def __init__(self, session: Session):
self.session = session
def upsert_profile(
self,
*,
employee_id: int,
salary_mode: str,
base_salary: float,
hourly_rate: float,
piece_quantity: float,
piece_unit_price: float,
probation_type: str,
probation_ratio: float,
probation_salary: float,
commission_amount: float,
overtime_rate: float,
weekend_overtime_rate: float,
holiday_overtime_rate: float,
effective_month: str = "",
remark: str = "",
) -> SalaryProfileORM:
profile = self.get_by_employee_id(employee_id)
if profile is None:
profile = SalaryProfileORM(employee_id=employee_id)
self.session.add(profile)
profile.salary_mode = salary_mode
profile.base_salary = base_salary
profile.hourly_rate = hourly_rate
profile.piece_quantity = piece_quantity
profile.piece_unit_price = piece_unit_price
profile.probation_type = probation_type
profile.probation_ratio = probation_ratio
profile.probation_salary = probation_salary
profile.commission_amount = commission_amount
profile.overtime_rate = overtime_rate
profile.weekend_overtime_rate = weekend_overtime_rate
profile.holiday_overtime_rate = holiday_overtime_rate
profile.effective_month = effective_month
profile.remark = remark
self.session.commit()
self.session.refresh(profile)
return self.require_by_employee_id(employee_id)
def get_by_employee_id(self, employee_id: int) -> SalaryProfileORM | None:
return (
self.session.query(SalaryProfileORM)
.options(joinedload(SalaryProfileORM.employee))
.filter(SalaryProfileORM.employee_id == employee_id)
.one_or_none()
)
def require_by_employee_id(self, employee_id: int) -> SalaryProfileORM:
profile = self.get_by_employee_id(employee_id)
if profile is None:
raise ValueError("薪资档案不存在")
return profile
def list_profiles(self, *, keyword: str | None = None) -> list[SalaryProfileORM]:
query = self.session.query(SalaryProfileORM).options(joinedload(SalaryProfileORM.employee))
if keyword:
like_keyword = f"%{keyword}%"
query = query.join(SalaryProfileORM.employee).filter(
or_(
EmployeeORM.employee_no.like(like_keyword),
EmployeeORM.name.like(like_keyword),
EmployeeORM.department.like(like_keyword),
EmployeeORM.position.like(like_keyword),
)
)
return query.order_by(SalaryProfileORM.updated_at.desc(), SalaryProfileORM.id.desc()).all()
def list_active_profiles(self) -> list[SalaryProfileORM]:
return (
self.session.query(SalaryProfileORM)
.join(SalaryProfileORM.employee)
.options(joinedload(SalaryProfileORM.employee))
.filter(EmployeeORM.employment_status == "active")
.order_by(SalaryProfileORM.id.asc())
.all()
)

View File

@ -0,0 +1,96 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from ..database.orm import UserORM
class UserRepository:
"""用户仓库:封装 users 表 CRUD供认证服务调用。"""
def __init__(self, session: Session):
self.session = session
def create_user(
self,
*,
username: str,
password_hash: str,
role: str,
display_name: str = "",
avatar_url: str = "",
email: str = "",
phone: str = "",
department: str = "",
position_title: str = "",
is_active: bool = True,
) -> UserORM:
user = UserORM(
username=username,
password_hash=password_hash,
role=role,
display_name=display_name,
avatar_url=avatar_url,
email=email,
phone=phone,
department=department,
position_title=position_title,
is_active=is_active,
)
self.session.add(user)
self.session.commit()
self.session.refresh(user)
return user
def get_by_username(self, username: str) -> UserORM | None:
return self.session.query(UserORM).filter(UserORM.username == username).one_or_none()
def get_by_id(self, user_id: int) -> UserORM | None:
return self.session.query(UserORM).filter(UserORM.id == user_id).one_or_none()
def list_users(self) -> list[UserORM]:
return self.session.query(UserORM).order_by(UserORM.id.asc()).all()
def has_users(self) -> bool:
return self.session.query(UserORM.id).first() is not None
def update_profile(
self,
user_id: int,
*,
display_name: str | None = None,
avatar_url: str | None = None,
email: str | None = None,
phone: str | None = None,
department: str | None = None,
position_title: str | None = None,
) -> UserORM:
user = self.get_by_id(user_id)
if user is None:
raise ValueError("用户不存在")
values = {
"display_name": display_name,
"avatar_url": avatar_url,
"email": email,
"phone": phone,
"department": department,
"position_title": position_title,
}
for field, value in values.items():
if value is not None:
setattr(user, field, value)
self.session.commit()
self.session.refresh(user)
return user
def update_password_hash(self, user_id: int, password_hash: str) -> UserORM:
user = self.get_by_id(user_id)
if user is None:
raise ValueError("用户不存在")
user.password_hash = password_hash
self.session.commit()
self.session.refresh(user)
return user

View File

@ -0,0 +1,28 @@
from .auth_service import AuthService, AuthSession
from .commission_service import CommissionPage, CommissionService
from .employee_service import EmployeePage, EmployeeService
from .operation_log_service import OperationLogPage, OperationLogService
from .organization_service import OrganizationService
from .payroll_service import PayrollApplicationService, PayrollComputation, PayrollService
from .report_service import PayrollReport, ReportService
from .salary_config_service import SalaryConfigService
from .salary_profile_service import SalaryProfileService
__all__ = [
"AuthService",
"AuthSession",
"CommissionPage",
"CommissionService",
"EmployeePage",
"EmployeeService",
"OperationLogPage",
"OperationLogService",
"OrganizationService",
"PayrollReport",
"PayrollApplicationService",
"PayrollComputation",
"PayrollService",
"ReportService",
"SalaryConfigService",
"SalaryProfileService",
]

View File

@ -0,0 +1,156 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import BinaryIO
from ..core.logger import AppLogger
from ..core.permissions import VALID_ROLES
from ..core.security import create_access_token, hash_password, verify_password
from ..core.settings import AppSettings
from ..database.orm import UserORM
from ..io.storage import FileStorage
from ..repositories import UserRepository
logger = AppLogger.get_logger(__name__)
@dataclass(frozen=True)
class AuthSession:
"""登录成功后的短生命周期对象,只承载用户和签发出的 token。"""
user: UserORM
access_token: str
token_type: str = "bearer"
class AuthService:
"""认证应用服务:登录、创建用户和首次启动超级用户初始化。"""
def __init__(self, repository: UserRepository, settings: AppSettings):
self.repository = repository
self.settings = settings
self.storage = FileStorage(settings)
def login(self, username: str, password: str) -> AuthSession:
logger.info("用户登录校验开始 username=%s", username)
user = self.repository.get_by_username(username)
if user is None or not user.is_active:
logger.error("用户登录失败 username=%s reason=user_not_found_or_inactive", username)
raise ValueError("用户名或密码错误")
if not verify_password(password, user.password_hash):
logger.error("用户登录失败 username=%s reason=invalid_password", username)
raise ValueError("用户名或密码错误")
token = create_access_token(
user_id=user.id,
username=user.username,
role=user.role,
secret_key=self.settings.auth_secret_key,
expires_minutes=self.settings.access_token_expire_minutes,
)
logger.info("用户登录成功 user_id=%s username=%s role=%s", user.id, user.username, user.role)
return AuthSession(user=user, access_token=token)
def create_user(
self,
*,
username: str,
password: str,
role: str,
display_name: str = "",
email: str = "",
phone: str = "",
department: str = "",
position_title: str = "",
) -> UserORM:
logger.info("创建用户开始 username=%s role=%s", username, role)
if role not in VALID_ROLES:
logger.error("创建用户失败 username=%s reason=invalid_role role=%s", username, role)
raise ValueError(f"无效角色:{role}")
if self.repository.get_by_username(username):
logger.error("创建用户失败 username=%s reason=duplicate_username", username)
raise ValueError("用户名已存在")
user = self.repository.create_user(
username=username,
password_hash=hash_password(password),
role=role,
display_name=display_name,
email=email,
phone=phone,
department=department,
position_title=position_title,
)
logger.info("创建用户成功 user_id=%s username=%s role=%s", user.id, user.username, user.role)
return user
def update_profile(
self,
user_id: int,
*,
display_name: str,
email: str,
phone: str,
department: str,
position_title: str,
) -> UserORM:
logger.info("修改个人资料开始 user_id=%s", user_id)
user = self.repository.update_profile(
user_id,
display_name=display_name,
email=email,
phone=phone,
department=department,
position_title=position_title,
)
logger.info("修改个人资料成功 user_id=%s username=%s", user.id, user.username)
return user
def update_avatar(self, user_id: int, filename: str, stream: BinaryIO) -> UserORM:
logger.info("修改头像开始 user_id=%s filename=%s", user_id, filename)
avatar_url = self.storage.save_avatar(filename, stream)
user = self.repository.update_profile(user_id, avatar_url=avatar_url)
logger.info("修改头像成功 user_id=%s avatar_url=%s", user.id, avatar_url)
return user
def change_password(
self,
user_id: int,
*,
old_password: str,
new_password: str,
confirm_password: str,
) -> UserORM:
logger.info("修改密码开始 user_id=%s", user_id)
user = self.repository.get_by_id(user_id)
if user is None or not user.is_active:
logger.error("修改密码失败 user_id=%s reason=user_not_found_or_inactive", user_id)
raise ValueError("用户不存在或已停用")
if not verify_password(old_password, user.password_hash):
logger.error("修改密码失败 user_id=%s username=%s reason=invalid_old_password", user.id, user.username)
raise ValueError("原密码错误")
if new_password != confirm_password:
logger.error("修改密码失败 user_id=%s username=%s reason=password_confirm_mismatch", user.id, user.username)
raise ValueError("两次输入的新密码不一致")
if verify_password(new_password, user.password_hash):
logger.error("修改密码失败 user_id=%s username=%s reason=same_as_old_password", user.id, user.username)
raise ValueError("新密码不能与原密码相同")
updated_user = self.repository.update_password_hash(user.id, hash_password(new_password))
logger.info("修改密码成功 user_id=%s username=%s", updated_user.id, updated_user.username)
return updated_user
def list_users(self) -> list[UserORM]:
users = self.repository.list_users()
logger.info("查询用户列表成功 count=%s", len(users))
return users
def ensure_bootstrap_superuser(self) -> None:
if self.repository.has_users():
logger.info("用户表已有数据,跳过初始化超级管理员")
return
self.repository.create_user(
username=self.settings.bootstrap_superuser_username,
password_hash=hash_password(self.settings.bootstrap_superuser_password),
role="superuser",
display_name=self.settings.bootstrap_superuser_display_name,
)
logger.info("默认超级管理员初始化完成 username=%s", self.settings.bootstrap_superuser_username)

View File

@ -0,0 +1,182 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO
from openpyxl import load_workbook
from ..core.logger import AppLogger
from ..database.orm import CommissionRecordORM
from ..io.storage import FileStorage
from ..repositories import CommissionRepository, EmployeeRepository
logger = AppLogger.get_logger(__name__)
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
@dataclass(frozen=True)
class CommissionPage:
items: list[CommissionRecordORM]
total: int
page: int
page_size: int
class CommissionService:
"""提成应用服务封装手工维护、Excel导入和月度汇总。"""
def __init__(
self,
repository: CommissionRepository,
employee_repository: EmployeeRepository,
storage: FileStorage | None = None,
):
self.repository = repository
self.employee_repository = employee_repository
self.storage = storage
def list_records(
self,
*,
page: int,
page_size: int,
commission_month: str | None = None,
keyword: str | None = None,
) -> CommissionPage:
month = _clean_optional(commission_month)
if month:
_validate_month(month)
items, total = self.repository.list_records(
page=page,
page_size=page_size,
commission_month=month,
keyword=_clean_optional(keyword),
)
logger.info("查询提成记录成功 total=%s page=%s page_size=%s", total, page, page_size)
return CommissionPage(items=items, total=total, page=page, page_size=page_size)
def create_record(self, **values) -> CommissionRecordORM:
values = self._validated_values(values)
record = self.repository.create_record(**values)
logger.info("新增提成记录成功 record_id=%s employee_id=%s amount=%s", record.id, record.employee_id, record.amount)
return record
def update_record(self, record_id: int, **values) -> CommissionRecordORM:
values = self._validated_values(values)
record = self.repository.update_record(record_id, **values)
logger.info("更新提成记录成功 record_id=%s employee_id=%s amount=%s", record.id, record.employee_id, record.amount)
return record
def delete_record(self, record_id: int) -> None:
self.repository.delete_record(record_id)
logger.info("删除提成记录成功 record_id=%s", record_id)
def aggregate_by_employee_id(self, commission_month: str) -> dict[int, float]:
_validate_month(commission_month)
return self.repository.aggregate_by_month(commission_month)
def import_excel(self, filename: str, stream: BinaryIO) -> int:
if self.storage is None:
raise ValueError("文件存储未初始化")
upload_path = self.storage.save_upload(filename, stream)
count = self._import_from_path(upload_path)
logger.info("提成Excel导入完成 filename=%s count=%s", filename, count)
return count
def _validated_values(self, values: dict) -> dict:
employee_id = int(values.get("employee_id") or 0)
self.employee_repository.require_by_id(employee_id)
commission_month = _normalize_month(str(values.get("commission_month") or "").strip())
_validate_month(commission_month)
amount = float(values.get("amount") or 0)
if amount < 0:
raise ValueError("提成金额不能小于0")
return {
"employee_id": employee_id,
"commission_month": commission_month,
"commission_type": str(values.get("commission_type") or "销售提成").strip() or "销售提成",
"amount": amount,
"source_type": str(values.get("source_type") or "manual").strip() or "manual",
"business_ref": str(values.get("business_ref") or "").strip(),
"remark": str(values.get("remark") or "").strip(),
}
def _import_from_path(self, path: Path) -> int:
workbook = load_workbook(path, data_only=True)
sheet = workbook.active
headers = {
str(sheet.cell(1, col_idx).value or "").strip(): col_idx
for col_idx in range(1, sheet.max_column + 1)
}
required = ("月份", "金额")
missing = [name for name in required if name not in headers]
if missing:
raise ValueError(f"提成导入缺少必要表头:{', '.join(missing)}")
rows: list[CommissionRecordORM] = []
for row_idx in range(2, sheet.max_row + 1):
month = _normalize_month(_cell(sheet, row_idx, headers.get("月份")))
amount_text = _cell(sheet, row_idx, headers.get("金额"))
if not month and not amount_text:
continue
employee = self._employee_from_row(sheet, row_idx, headers)
if employee is None:
raise ValueError(f"{row_idx} 行未匹配到员工,请提供员工编号或姓名")
_validate_month(month)
amount = float(amount_text or 0)
if amount < 0:
raise ValueError(f"{row_idx} 行提成金额不能小于0")
rows.append(
CommissionRecordORM(
employee_id=employee.id,
commission_month=month,
commission_type=_cell(sheet, row_idx, headers.get("提成类型")) or "销售提成",
amount=amount,
source_type="excel",
business_ref=_cell(sheet, row_idx, headers.get("业务单号")),
remark=_cell(sheet, row_idx, headers.get("备注")),
)
)
return self.repository.create_many(rows) if rows else 0
def _employee_from_row(self, sheet, row_idx: int, headers: dict[str, int]):
employee_no = _cell(sheet, row_idx, headers.get("员工编号"))
if employee_no:
employee = self.employee_repository.get_by_employee_no(employee_no)
if employee is not None:
return employee
name = _cell(sheet, row_idx, headers.get("姓名"))
if name:
matches = self.employee_repository.list_employees(keyword=name, employment_status="active")
exact = [employee for employee in matches if employee.name == name]
if len(exact) == 1:
return exact[0]
return None
def _validate_month(value: str) -> None:
if not MONTH_RE.match(value):
raise ValueError("月份格式必须为YYYY-MM")
def _normalize_month(value: str) -> str:
text = value.strip().replace("/", "-")
if re.match(r"^\d{4}-\d{2}(-\d{2})?", text):
return text[:7]
return text
def _clean_optional(value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
def _cell(sheet, row_idx: int, col_idx: int | None) -> str:
if not col_idx:
return ""
value = sheet.cell(row_idx, col_idx).value
return "" if value is None else str(value).strip()

View File

@ -0,0 +1,183 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from ..core.logger import AppLogger
from ..database.orm import EmployeeORM
from ..repositories import EmployeeRepository
logger = AppLogger.get_logger(__name__)
VALID_EMPLOYMENT_STATUS = {"active", "inactive"}
EMPLOYEE_NO_PREFIX = "ZA"
EMPLOYEE_NO_WIDTH = 4
@dataclass(frozen=True)
class EmployeePage:
"""员工分页结果。"""
items: list[EmployeeORM]
total: int
page: int
page_size: int
class EmployeeService:
"""员工应用服务:处理员工档案维护校验。"""
def __init__(self, repository: EmployeeRepository):
self.repository = repository
def preview_next_employee_no(self) -> str:
"""预览下一个员工编号,实际新增时仍以后端最终生成为准。"""
employee_no = self._next_employee_no()
logger.info("预览下一个员工编号 employee_no=%s", employee_no)
return employee_no
def create_employee(
self,
*,
employee_no: str = "",
name: str,
dingtalk_user_id: str = "",
department: str = "",
position: str = "",
phone: str = "",
employment_status: str = "active",
remark: str = "",
) -> EmployeeORM:
employee_no = self._next_employee_no()
name = _required(name, "员工姓名")
employment_status = _valid_status(employment_status)
if self.repository.get_by_employee_no(employee_no):
raise ValueError("员工编号已存在")
employee = self.repository.create_employee(
employee_no=employee_no,
name=name,
dingtalk_user_id=dingtalk_user_id.strip(),
department=department.strip(),
position=position.strip(),
phone=phone.strip(),
employment_status=employment_status,
remark=remark.strip(),
)
logger.info("员工创建成功 employee_id=%s employee_no=%s name=%s", employee.id, employee.employee_no, employee.name)
return employee
def update_employee(
self,
employee_id: int,
*,
employee_no: str = "",
name: str | None = None,
dingtalk_user_id: str | None = None,
department: str | None = None,
position: str | None = None,
phone: str | None = None,
employment_status: str | None = None,
remark: str | None = None,
) -> EmployeeORM:
current = self.repository.require_by_id(employee_id)
employee_no = current.employee_no
clean_name = _required(name if name is not None else current.name, "员工姓名")
clean_status = _valid_status(employment_status if employment_status is not None else current.employment_status)
employee = self.repository.update_employee(
employee_id,
employee_no=employee_no,
name=clean_name,
dingtalk_user_id=_clean_update_value(dingtalk_user_id, current.dingtalk_user_id),
department=_clean_update_value(department, current.department),
position=_clean_update_value(position, current.position),
phone=_clean_update_value(phone, current.phone),
employment_status=clean_status,
remark=_clean_update_value(remark, current.remark),
)
logger.info("员工更新成功 employee_id=%s employee_no=%s name=%s", employee.id, employee.employee_no, employee.name)
return employee
def get_employee(self, employee_id: int) -> EmployeeORM:
employee = self.repository.require_by_id(employee_id)
logger.info("查询员工详情成功 employee_id=%s employee_no=%s", employee.id, employee.employee_no)
return employee
def list_employees(
self,
*,
keyword: str | None = None,
employment_status: str | None = None,
) -> list[EmployeeORM]:
clean_status = _clean_optional(employment_status)
if clean_status is not None:
clean_status = _valid_status(clean_status)
employees = self.repository.list_employees(
keyword=_clean_optional(keyword),
employment_status=clean_status,
)
logger.info("查询员工列表成功 count=%s", len(employees))
return employees
def list_employees_page(
self,
*,
page: int,
page_size: int,
keyword: str | None = None,
employment_status: str | None = None,
) -> EmployeePage:
clean_status = _clean_optional(employment_status)
if clean_status is not None:
clean_status = _valid_status(clean_status)
safe_page = max(page, 1)
safe_page_size = min(max(page_size, 1), 100)
employees, total = self.repository.list_employees_page(
page=safe_page,
page_size=safe_page_size,
keyword=_clean_optional(keyword),
employment_status=clean_status,
)
logger.info("分页查询员工列表成功 total=%s page=%s page_size=%s", total, safe_page, safe_page_size)
return EmployeePage(items=employees, total=total, page=safe_page, page_size=safe_page_size)
def _next_employee_no(self) -> str:
existing_numbers = self.repository.list_employee_numbers_by_prefix(EMPLOYEE_NO_PREFIX)
pattern = re.compile(rf"^{re.escape(EMPLOYEE_NO_PREFIX)}(\d+)$")
used_numbers = {
int(match.group(1))
for employee_no in existing_numbers
if (match := pattern.match(employee_no))
}
number = 1
while number in used_numbers:
number += 1
return f"{EMPLOYEE_NO_PREFIX}{number:0{EMPLOYEE_NO_WIDTH}d}"
def _required(value: str, label: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError(f"{label}不能为空")
return stripped
def _clean_update_value(value: str | None, fallback: str) -> str:
if value is None:
return fallback
return value.strip()
def _valid_status(value: str) -> str:
stripped = value.strip() or "active"
if stripped not in VALID_EMPLOYMENT_STATUS:
raise ValueError("员工状态只能是 active 或 inactive")
return stripped
def _clean_optional(value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None

View File

@ -0,0 +1,114 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from ..core.logger import AppLogger
from ..database.orm import OperationLogORM, UserORM
from ..repositories import OperationLogRepository
logger = AppLogger.get_logger(__name__)
@dataclass(frozen=True)
class OperationLogPage:
"""操作日志分页结果,供 API schema 统一转换。"""
items: list[OperationLogORM]
total: int
page: int
page_size: int
class OperationLogService:
"""操作日志应用服务:记录关键操作并提供分页查询。"""
def __init__(self, repository: OperationLogRepository):
self.repository = repository
def record(
self,
*,
actor: UserORM | None,
module: str,
action: str,
status: str,
target_type: str = "",
target_id: str = "",
detail: str = "",
ip_address: str = "",
user_agent: str = "",
) -> OperationLogORM | None:
"""写入操作日志;失败只进错误日志,不阻断原业务请求。"""
try:
log = self.repository.create_log(
user_id=actor.id if actor else None,
username=_clip(actor.username if actor else "", 64),
role=_clip(actor.role if actor else "", 32),
module=_clip(module, 64),
action=_clip(action, 128),
target_type=_clip(target_type, 64),
target_id=_clip(target_id, 128),
status=_clip(status, 32),
detail=_clip(detail, 4000),
ip_address=_clip(ip_address, 64),
user_agent=_clip(user_agent, 512),
)
logger.info(
"操作日志写入成功 log_id=%s username=%s module=%s action=%s status=%s",
log.id,
log.username,
log.module,
log.action,
log.status,
)
return log
except Exception:
self.repository.rollback()
logger.exception("操作日志写入失败 module=%s action=%s status=%s", module, action, status)
return None
def list_logs(
self,
*,
page: int,
page_size: int,
username: str | None = None,
module: str | None = None,
action: str | None = None,
status: str | None = None,
keyword: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
) -> OperationLogPage:
safe_page = max(page, 1)
safe_page_size = min(max(page_size, 1), 100)
clean_username = _clean_optional(username)
clean_module = _clean_optional(module)
clean_action = _clean_optional(action)
clean_status = _clean_optional(status)
clean_keyword = _clean_optional(keyword)
items, total = self.repository.list_logs(
page=safe_page,
page_size=safe_page_size,
username=clean_username,
module=clean_module,
action=clean_action,
status=clean_status,
keyword=clean_keyword,
start_time=start_time,
end_time=end_time,
)
logger.info("查询操作日志成功 total=%s page=%s page_size=%s", total, safe_page, safe_page_size)
return OperationLogPage(items=items, total=total, page=safe_page, page_size=safe_page_size)
def _clean_optional(value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
def _clip(value: str, max_length: int) -> str:
return value[:max_length]

View File

@ -0,0 +1,392 @@
from __future__ import annotations
import re
from ..core.logger import AppLogger
from ..database.orm import DepartmentORM, PositionORM
from ..repositories import OrganizationRepository
logger = AppLogger.get_logger(__name__)
DEFAULT_ORGANIZATION = [
{
"code": "ORG001",
"name": "总经办",
"parent": None,
"positions": ["总经理", "副总经理", "总经理助理"],
},
{
"code": "ORG002",
"name": "行政人事部",
"parent": None,
"positions": ["人事经理", "人事专员"],
},
{
"code": "ORG003",
"name": "财务部",
"parent": None,
"positions": ["财务", "出纳专员"],
},
{
"code": "ORG004",
"name": "订单部",
"parent": None,
"positions": ["销售助理", "订单助理"],
},
{
"code": "ORG005",
"name": "工程部",
"parent": None,
"positions": [],
"remark": "系统根据工程部子部门自动补齐的父级部门",
},
{
"code": "ORG006",
"name": "工程部-安装队",
"parent": "工程部",
"positions": ["经理助理", "安装师傅", "项目经理"],
},
{
"code": "ORG007",
"name": "工程部-项目预算",
"parent": "工程部",
"positions": ["项目预算"],
},
{
"code": "ORG008",
"name": "杭州运营中心",
"parent": None,
"positions": [],
"remark": "系统根据杭州运营中心子部门自动补齐的父级部门",
},
{
"code": "ORG009",
"name": "杭州运营中心-销售部",
"parent": "杭州运营中心",
"positions": ["销售总监", "销售"],
},
{
"code": "ORG010",
"name": "华南运营中心",
"parent": None,
"positions": ["销售总监"],
},
{
"code": "ORG011",
"name": "华中运营中心",
"parent": None,
"positions": ["销售总监"],
},
{
"code": "ORG012",
"name": "生产中心",
"parent": None,
"positions": ["机械工程师"],
},
{
"code": "ORG013",
"name": "生产中心-采购部",
"parent": "生产中心",
"positions": ["采购"],
},
{
"code": "ORG014",
"name": "生产中心-仓库",
"parent": "生产中心",
"positions": ["仓库", "仓库物料员"],
},
{
"code": "ORG015",
"name": "生产中心-品管部",
"parent": "生产中心",
"positions": ["品管"],
},
{
"code": "ORG016",
"name": "生产中心-生产部",
"parent": "生产中心",
"positions": ["焊接师傅", "普工", "技工"],
},
{
"code": "ORG017",
"name": "西南运营中心",
"parent": None,
"positions": ["销售总监"],
},
{
"code": "ORG018",
"name": "西南运营中心-办事处",
"parent": "西南运营中心",
"positions": ["经理助理"],
},
{
"code": "ORG019",
"name": "西南运营中心-销售部",
"parent": "西南运营中心",
"positions": ["销售工程师"],
},
]
DEFAULT_DEPARTMENTS = [item["name"] for item in DEFAULT_ORGANIZATION]
class OrganizationService:
"""组织架构服务:维护部门、岗位和二者的上下级关系。"""
def __init__(self, repository: OrganizationRepository):
self.repository = repository
def ensure_default_departments(self) -> int:
"""启动时补齐默认组织架构,包含部门树和部门岗位。"""
department_created = 0
department_updated = 0
position_created = 0
position_updated = 0
seeded_departments: dict[str, DepartmentORM] = {}
for index, item in enumerate(DEFAULT_ORGANIZATION, start=1):
parent_name = item["parent"]
parent = seeded_departments.get(parent_name) if parent_name else None
if parent is None and parent_name:
parent = self.repository.get_department_by_name(parent_name)
existing = self.repository.get_department_by_name(item["name"])
department_code = self._usable_department_code(str(item["code"]), existing)
payload = {
"parent_id": parent.id if parent else None,
"department_code": department_code,
"name": item["name"],
"sort_order": index,
"is_active": True,
"remark": item.get("remark", "系统初始化部门"),
}
if existing is None:
department = self.repository.create_department(**payload)
department_created += 1
elif _department_changed(existing, payload):
department = self.repository.update_department(existing.id, **payload)
department_updated += 1
else:
department = existing
seeded_departments[department.name] = department
position_index = 1
for item in DEFAULT_ORGANIZATION:
department = seeded_departments[item["name"]]
for position_sort, position_name in enumerate(item["positions"], start=1):
preferred_code = f"POS{position_index:03d}"
existing_position = self.repository.get_position_by_department_and_name(
department.id,
position_name,
)
payload = {
"department_id": department.id,
"position_code": self._usable_position_code(preferred_code, existing_position),
"name": position_name,
"sort_order": position_sort,
"is_active": True,
"remark": "系统初始化岗位",
}
if existing_position is None:
self.repository.create_position(**payload)
position_created += 1
elif _position_changed(existing_position, payload):
self.repository.update_position(existing_position.id, **payload)
position_updated += 1
position_index += 1
logger.info(
"默认组织架构初始化完成 department_created=%s department_updated=%s position_created=%s position_updated=%s",
department_created,
department_updated,
position_created,
position_updated,
)
return department_created + position_created
def list_departments(self, *, active_only: bool = False, keyword: str | None = None) -> list[DepartmentORM]:
departments = self.repository.list_departments(active_only=active_only, keyword=_clean_optional(keyword))
logger.info("查询部门成功 count=%s active_only=%s", len(departments), active_only)
return departments
def create_department(self, **values) -> DepartmentORM:
payload = self._validated_department_values(values)
if self.repository.get_department_by_code(payload["department_code"]) is not None:
raise ValueError("部门编码已存在")
if self.repository.get_department_by_name(payload["name"]) is not None:
raise ValueError("部门名称已存在")
department = self.repository.create_department(**payload)
logger.info("新增部门成功 department_id=%s name=%s", department.id, department.name)
return department
def update_department(self, department_id: int, **values) -> DepartmentORM:
payload = self._validated_department_values(values, department_id=department_id)
by_code = self.repository.get_department_by_code(payload["department_code"])
if by_code is not None and by_code.id != department_id:
raise ValueError("部门编码已存在")
by_name = self.repository.get_department_by_name(payload["name"])
if by_name is not None and by_name.id != department_id:
raise ValueError("部门名称已存在")
department = self.repository.update_department(department_id, **payload)
logger.info("更新部门成功 department_id=%s name=%s", department.id, department.name)
return department
def list_positions(
self,
*,
department_id: int | None = None,
active_only: bool = False,
keyword: str | None = None,
) -> list[PositionORM]:
if department_id:
self.repository.require_department(department_id)
positions = self.repository.list_positions(
department_id=department_id,
active_only=active_only,
keyword=_clean_optional(keyword),
)
logger.info("查询岗位成功 count=%s department_id=%s", len(positions), department_id)
return positions
def create_position(self, **values) -> PositionORM:
payload = self._validated_position_values(values)
if self.repository.get_position_by_code(payload["position_code"]) is not None:
raise ValueError("岗位编码已存在")
self._ensure_position_name_available(payload["department_id"], payload["name"])
position = self.repository.create_position(**payload)
logger.info("新增岗位成功 position_id=%s name=%s", position.id, position.name)
return position
def update_position(self, position_id: int, **values) -> PositionORM:
current = self.repository.require_position(position_id)
payload = self._validated_position_values(values, current=current)
by_code = self.repository.get_position_by_code(payload["position_code"])
if by_code is not None and by_code.id != position_id:
raise ValueError("岗位编码已存在")
self._ensure_position_name_available(payload["department_id"], payload["name"], position_id=position_id)
position = self.repository.update_position(position_id, **payload)
logger.info("更新岗位成功 position_id=%s name=%s", position.id, position.name)
return position
def _validated_department_values(self, values: dict, department_id: int | None = None) -> dict:
parent_id = values.get("parent_id")
parent_id = int(parent_id) if parent_id not in (None, "", 0) else None
if parent_id:
if parent_id == department_id:
raise ValueError("父级部门不能选择自己")
self.repository.require_department(parent_id)
department_code = str(values.get("department_code") or "").strip()
name = str(values.get("name") or "").strip()
if not department_code:
raise ValueError("部门编码不能为空")
if not name:
raise ValueError("部门名称不能为空")
return {
"parent_id": parent_id,
"department_code": department_code,
"name": name,
"sort_order": int(values.get("sort_order") or 0),
"is_active": bool(values.get("is_active", True)),
"remark": str(values.get("remark") or "").strip(),
}
def _validated_position_values(self, values: dict, current: PositionORM | None = None) -> dict:
department_id = int(values.get("department_id") or 0)
self.repository.require_department(department_id)
position_code = str(values.get("position_code") or "").strip()
name = str(values.get("name") or "").strip()
if not position_code:
position_code = current.position_code if current else self._next_position_code()
if not name:
raise ValueError("岗位名称不能为空")
return {
"department_id": department_id,
"position_code": position_code,
"name": name,
"sort_order": int(values.get("sort_order") or 0),
"is_active": bool(values.get("is_active", True)),
"remark": str(values.get("remark") or "").strip(),
}
def _ensure_position_name_available(
self,
department_id: int,
name: str,
*,
position_id: int | None = None,
) -> None:
by_name = self.repository.get_position_by_department_and_name(department_id, name)
if by_name is not None and by_name.id != position_id:
raise ValueError("该部门下岗位名称已存在")
def _usable_department_code(self, preferred_code: str, existing: DepartmentORM | None = None) -> str:
owner = self.repository.get_department_by_code(preferred_code)
if owner is None or (existing is not None and owner.id == existing.id):
return preferred_code
if existing is not None:
return existing.department_code
return self._next_department_code()
def _usable_position_code(self, preferred_code: str, existing: PositionORM | None = None) -> str:
owner = self.repository.get_position_by_code(preferred_code)
if owner is None or (existing is not None and owner.id == existing.id):
return preferred_code
if existing is not None:
return existing.position_code
return self._next_position_code()
def _next_department_code(self) -> str:
return _next_code(
[department.department_code for department in self.repository.list_departments(active_only=False)],
"ORG",
)
def _next_position_code(self) -> str:
return _next_code(
[position.position_code for position in self.repository.list_positions(active_only=False)],
"POS",
)
def _clean_optional(value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
def _next_code(existing_codes: list[str], prefix: str) -> str:
pattern = re.compile(rf"^{re.escape(prefix)}(\d+)$")
used_numbers = {
int(match.group(1))
for code in existing_codes
if (match := pattern.match(code))
}
number = 1
while number in used_numbers:
number += 1
return f"{prefix}{number:03d}"
def _department_changed(department: DepartmentORM, payload: dict) -> bool:
return any(
[
department.parent_id != payload["parent_id"],
department.department_code != payload["department_code"],
department.name != payload["name"],
department.sort_order != payload["sort_order"],
department.is_active != payload["is_active"],
department.remark != payload["remark"],
]
)
def _position_changed(position: PositionORM, payload: dict) -> bool:
return any(
[
position.department_id != payload["department_id"],
position.position_code != payload["position_code"],
position.name != payload["name"],
position.sort_order != payload["sort_order"],
position.is_active != payload["is_active"],
position.remark != payload["remark"],
]
)

View File

@ -0,0 +1,238 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time
from pathlib import Path
from typing import BinaryIO
from ..core.config import AppConfig, PayrollRules, load_config
from ..core.logger import AppLogger
from ..core.settings import AppSettings
from ..domain.calculator import PayrollCalculator
from ..domain.models import EmployeeAttendance, EmployeePayConfig, PayrollResult
from ..integrations.dingtalk import DingTalkAttendanceAdapter, DingTalkClient, DingTalkSettings
from ..io.exporter import export_payroll_results
from ..io.parser import MonthlySummaryParser
from ..io.storage import FileStorage
from ..repositories import CommissionRepository, PayrollRepository, SalaryProfileRepository
from .salary_config_service import SalaryConfigService
logger = AppLogger.get_logger(__name__)
class PayrollService:
"""纯工资服务:只负责把考勤输入转换成工资计算结果。"""
def __init__(self, config: AppConfig):
self.config = config
self.calculator = PayrollCalculator(config)
def calculate_from_excel(self, excel_path: str | Path) -> list[PayrollResult]:
logger.info("Excel 工资计算开始 excel_path=%s", excel_path)
parser = MonthlySummaryParser(self.config.attendance)
employees = parser.parse(excel_path)
results = self.calculator.calculate(employees)
logger.info("Excel 工资计算完成 excel_path=%s employee_count=%s", excel_path, len(results))
return results
@dataclass(frozen=True)
class PayrollComputation:
"""一次工资计算的返回值,供 API schema 和持久化流程复用。"""
job_id: str
results: list[PayrollResult]
output_file: str | None = None
@property
def employee_count(self) -> int:
return len(self.results)
@property
def output_filename(self) -> str | None:
return Path(self.output_file).name if self.output_file else None
class PayrollApplicationService:
"""应用服务层:编排上传、外部接口、计算、导出和落库。"""
def __init__(
self,
repository: PayrollRepository,
settings: AppSettings,
salary_repository: SalaryProfileRepository | None = None,
commission_repository: CommissionRepository | None = None,
config_service: SalaryConfigService | None = None,
):
self.repository = repository
self.settings = settings
self.salary_repository = salary_repository
self.commission_repository = commission_repository
self.config_service = config_service
self.storage = FileStorage(settings)
def calculate_from_excel_upload(
self,
filename: str,
stream: BinaryIO,
config_path: str | None = None,
export_excel: bool = True,
) -> PayrollComputation:
logger.info("Excel 上传计算请求开始 filename=%s export_excel=%s", filename, export_excel)
upload_path = self.storage.save_upload(filename, stream)
job = self.repository.create_job("excel", input_file=str(upload_path))
logger.info("Excel 工资计算任务已创建 job_id=%s upload_path=%s", job.id, upload_path)
try:
config = self._load_runtime_config(config_path)
employees = MonthlySummaryParser(config.attendance).parse(upload_path)
salary_month = self._salary_month_from_employees(employees)
config = self._load_runtime_config(config_path, salary_month=salary_month)
results = PayrollCalculator(config).calculate(employees)
output_file = self._export_if_needed(config, results, "excel", export_excel)
self.repository.save_results(job.id, results)
self.repository.mark_completed(job.id, len(results), output_file)
logger.info(
"Excel 工资计算任务完成 job_id=%s employee_count=%s output_file=%s",
job.id,
len(results),
output_file,
)
return PayrollComputation(job_id=job.id, results=results, output_file=output_file)
except Exception as exc:
self.repository.mark_failed(job.id, str(exc))
logger.exception("Excel 工资计算任务失败 job_id=%s upload_path=%s", job.id, upload_path)
raise
async def calculate_from_dingtalk_realtime(
self,
user_ids: list[str],
start_date: date,
end_date: date,
config_path: str | None = None,
export_excel: bool = True,
) -> PayrollComputation:
logger.info(
"钉钉实时工资计算请求开始 user_count=%s start_date=%s end_date=%s export_excel=%s",
len(user_ids),
start_date,
end_date,
export_excel,
)
config = self._load_runtime_config(config_path, salary_month=start_date.strftime("%Y-%m"))
settings = self._dingtalk_settings()
job = self.repository.create_job("dingtalk")
logger.info("钉钉工资计算任务已创建 job_id=%s", job.id)
try:
client = DingTalkClient(settings)
start_dt = datetime.combine(start_date, time.min)
end_dt = datetime.combine(end_date, time(23, 59, 59))
records = await client.list_attendance_records(user_ids, start_dt, end_dt)
adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone)
employees = adapter.to_employee_attendance(records)
results = PayrollCalculator(config).calculate(employees)
output_file = self._export_if_needed(config, results, "dingtalk", export_excel)
self.repository.save_results(job.id, results)
self.repository.mark_completed(job.id, len(results), output_file)
logger.info(
"钉钉工资计算任务完成 job_id=%s record_count=%s employee_count=%s output_file=%s",
job.id,
len(records),
len(results),
output_file,
)
return PayrollComputation(job_id=job.id, results=results, output_file=output_file)
except Exception as exc:
self.repository.mark_failed(job.id, str(exc))
logger.exception("钉钉工资计算任务失败 job_id=%s", job.id)
raise
def get_job(self, job_id: str):
job = self.repository.get_job(job_id)
logger.info("查询工资计算任务 job_id=%s found=%s", job_id, job is not None)
return job
def resolve_output_file(self, filename: str) -> Path:
logger.info("解析工资结果下载文件 filename=%s", filename)
return self.storage.resolve_output_file(filename)
def _export_if_needed(
self,
config: AppConfig,
results: list[PayrollResult],
source_type: str,
export_excel: bool,
) -> str | None:
if not export_excel:
logger.info("跳过工资结果导出 source_type=%s", source_type)
return None
output_path = self.storage.payroll_output_path(source_type)
export_payroll_results(results, config, output_path)
logger.info("工资结果导出完成 source_type=%s output_path=%s", source_type, output_path)
return str(output_path)
def _load_runtime_config(self, config_path: str | None, salary_month: str | None = None) -> AppConfig:
config = load_config(config_path)
if self.config_service is not None:
self.config_service.ensure_defaults()
config = self.config_service.resolve_app_config(config)
if self.salary_repository is None:
return config
pay_configs = dict(config.payroll.employees)
profiles = self.salary_repository.list_active_profiles()
month_commissions = (
self.commission_repository.aggregate_by_month(salary_month)
if self.commission_repository is not None and salary_month
else {}
)
for profile in profiles:
employee = profile.employee
if not employee.name:
continue
pay_configs[employee.name] = EmployeePayConfig(
salary_mode=profile.salary_mode,
base_salary=profile.base_salary,
hourly_rate=profile.hourly_rate,
piece_quantity=profile.piece_quantity,
piece_unit_price=profile.piece_unit_price,
probation_type=profile.probation_type,
probation_ratio=profile.probation_ratio,
probation_salary=profile.probation_salary or None,
commission_amount=profile.commission_amount + month_commissions.get(employee.id, 0),
overtime_rate=profile.overtime_rate,
weekend_overtime_rate=profile.weekend_overtime_rate,
holiday_overtime_rate=profile.holiday_overtime_rate,
)
if profiles:
logger.info(
"已合并数据库薪资档案到工资计算配置 count=%s salary_month=%s commission_employee_count=%s",
len(profiles),
salary_month,
len(month_commissions),
)
return AppConfig(
attendance=config.attendance,
payroll=PayrollRules(
default_overtime_rate=config.payroll.default_overtime_rate,
default_weekend_overtime_rate=config.payroll.default_weekend_overtime_rate,
default_holiday_overtime_rate=config.payroll.default_holiday_overtime_rate,
employees=pay_configs,
),
)
def _salary_month_from_employees(self, employees: list[EmployeeAttendance]) -> str | None:
for employee in employees:
if employee.daily_records:
return employee.daily_records[0].work_date.strftime("%Y-%m")
return None
def _dingtalk_settings(self) -> DingTalkSettings:
if not self.settings.dingtalk_app_key or not self.settings.dingtalk_app_secret:
logger.error("钉钉配置缺失 config_path=%s", self.settings.config_path)
raise ValueError("请先在 config/app_settings.json 中配置 dingtalk.app_key 和 dingtalk.app_secret")
return DingTalkSettings(
app_key=self.settings.dingtalk_app_key,
app_secret=self.settings.dingtalk_app_secret,
base_url=self.settings.dingtalk_base_url,
timezone=self.settings.dingtalk_timezone,
)

View File

@ -0,0 +1,48 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from ..core.logger import AppLogger
from ..database.orm import SalaryRecordORM
from ..repositories import ReportRepository
logger = AppLogger.get_logger(__name__)
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
@dataclass(frozen=True)
class PayrollReport:
salary_month: str | None
totals: dict[str, float]
attendance: dict[str, float]
departments: list[dict[str, float | str]]
recent_records: list[SalaryRecordORM]
class ReportService:
"""报表服务:提供薪资、考勤和部门维度的聚合结果。"""
def __init__(self, repository: ReportRepository):
self.repository = repository
def payroll_summary(self, *, salary_month: str | None = None) -> PayrollReport:
month = _clean_optional(salary_month)
if month and not MONTH_RE.match(month):
raise ValueError("工资月份格式必须为YYYY-MM")
report = PayrollReport(
salary_month=month,
totals=self.repository.payroll_totals(salary_month=month),
attendance=self.repository.attendance_totals(salary_month=month),
departments=self.repository.department_totals(salary_month=month),
recent_records=self.repository.recent_salary_records(salary_month=month),
)
logger.info("查询工资统计报表成功 salary_month=%s employee_count=%s", month, report.totals["employee_count"])
return report
def _clean_optional(value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None

View File

@ -0,0 +1,228 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from ..core.config import AppConfig, AttendanceRules, PayrollRules
from ..core.logger import AppLogger
from ..database.orm import SalaryConfigORM
from ..repositories import SalaryConfigRepository
logger = AppLogger.get_logger(__name__)
DEFAULT_CONFIGS = [
{
"config_key": "attendance.on_work_time",
"config_name": "上班时间",
"config_group": "attendance",
"config_value": "08:30",
"value_type": "string",
"remark": "用于迟到识别格式HH:MM",
},
{
"config_key": "attendance.off_work_time",
"config_name": "下班时间",
"config_group": "attendance",
"config_value": "17:00",
"value_type": "string",
"remark": "加班按下班打卡超过该时间后向下取整",
},
{
"config_key": "attendance.overtime_round_minutes",
"config_name": "加班取整分钟",
"config_group": "attendance",
"config_value": "60",
"value_type": "integer",
"remark": "例如60表示20:30按3小时计算",
},
{
"config_key": "attendance.standard_daily_hours",
"config_name": "标准日工时",
"config_group": "attendance",
"config_value": "8",
"value_type": "number",
"remark": "计时工资按出勤天数折算工时",
},
{
"config_key": "attendance.minor_late_minutes",
"config_name": "轻微迟到分钟",
"config_group": "attendance",
"config_value": "5",
"value_type": "integer",
"remark": "小于等于该分钟数按轻微迟到统计",
},
{
"config_key": "attendance.minor_late_free_times",
"config_name": "轻微迟到免扣次数",
"config_group": "attendance",
"config_value": "3",
"value_type": "integer",
"remark": "5分钟内迟到3次内合格超过后按次扣款",
},
{
"config_key": "attendance.late_penalty",
"config_name": "迟到扣款",
"config_group": "attendance",
"config_value": "30",
"value_type": "number",
"remark": "每次计扣迟到扣款金额",
},
{
"config_key": "attendance.missing_card_penalty",
"config_name": "缺卡扣款",
"config_group": "attendance",
"config_value": "20",
"value_type": "number",
"remark": "每次缺卡扣款金额",
},
{
"config_key": "attendance.weekend_days",
"config_name": "周末星期",
"config_group": "attendance",
"config_value": "[5, 6]",
"value_type": "json",
"remark": "Python weekday周一0周六5周日6",
},
{
"config_key": "attendance.legal_holidays",
"config_name": "法定节假日",
"config_group": "attendance",
"config_value": "[]",
"value_type": "json",
"remark": "日期数组,例如[\"2026-10-01\"]",
},
{
"config_key": "attendance.leave_keywords",
"config_name": "请假关键字",
"config_group": "attendance",
"config_value": json.dumps(list(AttendanceRules.leave_keywords), ensure_ascii=False),
"value_type": "json",
"remark": "从钉钉单元格识别请假/调休类型",
},
{
"config_key": "payroll.default_overtime_rate",
"config_name": "工作日加班单价",
"config_group": "payroll",
"config_value": "0",
"value_type": "number",
"remark": "未维护员工单价时使用",
},
{
"config_key": "payroll.default_weekend_overtime_rate",
"config_name": "周末加班单价",
"config_group": "payroll",
"config_value": "0",
"value_type": "number",
"remark": "未维护员工周末单价时使用",
},
{
"config_key": "payroll.default_holiday_overtime_rate",
"config_name": "节假日加班单价",
"config_group": "payroll",
"config_value": "0",
"value_type": "number",
"remark": "未维护员工节假日单价时使用",
},
]
@dataclass(frozen=True)
class SalaryConfigUpdate:
config_name: str
config_group: str
config_value: str
value_type: str
is_enabled: bool
remark: str
class SalaryConfigService:
"""系统配置服务:维护规则配置,并把数据库配置合成为 AppConfig。"""
def __init__(self, repository: SalaryConfigRepository):
self.repository = repository
def ensure_defaults(self) -> int:
created = self.repository.create_defaults(DEFAULT_CONFIGS)
logger.info("系统默认配置初始化完成 created=%s", created)
return created
def list_configs(self, *, config_group: str | None = None) -> list[SalaryConfigORM]:
configs = self.repository.list_configs(config_group=_clean_optional(config_group))
logger.info("查询系统配置成功 count=%s group=%s", len(configs), config_group)
return configs
def update_config(self, config_key: str, values: SalaryConfigUpdate) -> SalaryConfigORM:
self._parse_value(values.config_value, values.value_type)
config = self.repository.upsert_config(
config_key=config_key,
config_name=values.config_name.strip(),
config_group=values.config_group.strip(),
config_value=values.config_value.strip(),
value_type=values.value_type.strip(),
is_enabled=values.is_enabled,
remark=values.remark.strip(),
)
logger.info("更新系统配置成功 config_key=%s", config_key)
return config
def resolve_app_config(self, base_config: AppConfig) -> AppConfig:
values = {
config.config_key: self._parse_value(config.config_value, config.value_type)
for config in self.repository.list_configs()
if config.is_enabled
}
attendance = AttendanceRules(
on_work_time=str(values.get("attendance.on_work_time", base_config.attendance.on_work_time)),
off_work_time=str(values.get("attendance.off_work_time", base_config.attendance.off_work_time)),
overtime_round_minutes=int(
values.get("attendance.overtime_round_minutes", base_config.attendance.overtime_round_minutes)
),
standard_daily_hours=float(
values.get("attendance.standard_daily_hours", base_config.attendance.standard_daily_hours)
),
minor_late_minutes=int(values.get("attendance.minor_late_minutes", base_config.attendance.minor_late_minutes)),
minor_late_free_times=int(
values.get("attendance.minor_late_free_times", base_config.attendance.minor_late_free_times)
),
late_penalty=float(values.get("attendance.late_penalty", base_config.attendance.late_penalty)),
missing_card_penalty=float(
values.get("attendance.missing_card_penalty", base_config.attendance.missing_card_penalty)
),
weekend_days=tuple(int(item) for item in values.get("attendance.weekend_days", base_config.attendance.weekend_days)),
legal_holidays=tuple(str(item) for item in values.get("attendance.legal_holidays", base_config.attendance.legal_holidays)),
leave_keywords=tuple(str(item) for item in values.get("attendance.leave_keywords", base_config.attendance.leave_keywords)),
)
payroll = PayrollRules(
default_overtime_rate=float(
values.get("payroll.default_overtime_rate", base_config.payroll.default_overtime_rate)
),
default_weekend_overtime_rate=float(
values.get("payroll.default_weekend_overtime_rate", base_config.payroll.default_weekend_overtime_rate)
),
default_holiday_overtime_rate=float(
values.get("payroll.default_holiday_overtime_rate", base_config.payroll.default_holiday_overtime_rate)
),
employees=base_config.payroll.employees,
)
logger.info("已合并数据库规则配置到运行时计算配置")
return AppConfig(attendance=attendance, payroll=payroll)
def _parse_value(self, raw_value: str, value_type: str):
if value_type == "integer":
return int(raw_value)
if value_type == "number":
return float(raw_value)
if value_type == "boolean":
return str(raw_value).lower() in {"1", "true", "yes", "on"}
if value_type == "json":
return json.loads(raw_value or "null")
return raw_value
def _clean_optional(value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None

View File

@ -0,0 +1,117 @@
from __future__ import annotations
from ..core.logger import AppLogger
from ..database.orm import SalaryProfileORM
from ..domain.models import EmployeePayConfig
from ..repositories import EmployeeRepository, SalaryProfileRepository
logger = AppLogger.get_logger(__name__)
VALID_SALARY_MODES = {"monthly", "hourly", "piecework", "probation"}
VALID_PROBATION_TYPES = {"ratio", "fixed"}
class SalaryProfileService:
"""薪资档案应用服务:维护不同薪资模式和独立加班费单价。"""
def __init__(self, repository: SalaryProfileRepository, employee_repository: EmployeeRepository):
self.repository = repository
self.employee_repository = employee_repository
def upsert_profile(
self,
*,
employee_id: int,
salary_mode: str,
base_salary: float,
hourly_rate: float,
piece_quantity: float,
piece_unit_price: float,
probation_type: str,
probation_ratio: float,
probation_salary: float,
commission_amount: float,
overtime_rate: float,
weekend_overtime_rate: float,
holiday_overtime_rate: float,
effective_month: str = "",
remark: str = "",
) -> SalaryProfileORM:
self.employee_repository.require_by_id(employee_id)
normalized_salary_mode = salary_mode.strip() or "monthly"
if normalized_salary_mode not in VALID_SALARY_MODES:
raise ValueError("薪资模式不合法")
normalized_probation_type = probation_type.strip() or "ratio"
if normalized_probation_type not in VALID_PROBATION_TYPES:
raise ValueError("试用期计算方式不合法")
profile = self.repository.upsert_profile(
employee_id=employee_id,
salary_mode=normalized_salary_mode,
base_salary=_non_negative(base_salary, "基础工资"),
hourly_rate=_non_negative(hourly_rate, "计时小时单价"),
piece_quantity=_non_negative(piece_quantity, "计件数量"),
piece_unit_price=_non_negative(piece_unit_price, "计件单价"),
probation_type=normalized_probation_type,
probation_ratio=_non_negative(probation_ratio, "试用期比例") or 1,
probation_salary=_non_negative(probation_salary, "试用期固定工资"),
commission_amount=_non_negative(commission_amount, "提成费用"),
overtime_rate=_non_negative(overtime_rate, "工作日加班费小时单价"),
weekend_overtime_rate=_non_negative(weekend_overtime_rate, "周末加班费小时单价"),
holiday_overtime_rate=_non_negative(holiday_overtime_rate, "节假日加班费小时单价"),
effective_month=effective_month.strip(),
remark=remark.strip(),
)
logger.info(
"薪资档案保存成功 employee_id=%s salary_mode=%s base_salary=%s commission=%s overtime_rate=%s",
employee_id,
profile.salary_mode,
profile.base_salary,
profile.commission_amount,
profile.overtime_rate,
)
return profile
def list_profiles(self, *, keyword: str | None = None) -> list[SalaryProfileORM]:
profiles = self.repository.list_profiles(keyword=_clean_optional(keyword))
logger.info("查询薪资档案成功 count=%s", len(profiles))
return profiles
def active_pay_config_by_employee_name(self) -> dict[str, EmployeePayConfig]:
"""生成工资计算器使用的员工薪资配置,按员工姓名匹配考勤表。"""
profiles = self.repository.list_active_profiles()
result: dict[str, EmployeePayConfig] = {}
for profile in profiles:
employee = profile.employee
if not employee.name:
continue
result[employee.name] = EmployeePayConfig(
salary_mode=profile.salary_mode,
base_salary=profile.base_salary,
hourly_rate=profile.hourly_rate,
piece_quantity=profile.piece_quantity,
piece_unit_price=profile.piece_unit_price,
probation_type=profile.probation_type,
probation_ratio=profile.probation_ratio,
probation_salary=profile.probation_salary,
commission_amount=profile.commission_amount,
overtime_rate=profile.overtime_rate,
weekend_overtime_rate=profile.weekend_overtime_rate,
holiday_overtime_rate=profile.holiday_overtime_rate,
)
logger.info("薪资档案转换为工资计算配置 count=%s", len(result))
return result
def _non_negative(value: float, label: str) -> float:
number = float(value or 0)
if number < 0:
raise ValueError(f"{label}不能小于0")
return number
def _clean_optional(value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None

2
frontend/.env.example Normal file
View File

@ -0,0 +1,2 @@
VITE_API_BASE_URL=
VITE_DEV_PROXY_TARGET=http://127.0.0.1:8000

141
frontend/README.md Normal file
View File

@ -0,0 +1,141 @@
# 企业薪酬管理系统前端
这是 `Financial_System` 项目的前端子工程,使用 Vue3、Vite、TypeScript、Pinia 和 Vue Router 开发,用于对接后端 FastAPI 工资计算服务。
## 技术栈
- Vue3页面和组件开发。
- Vite本地开发服务和生产构建。
- TypeScript接口类型、状态类型和页面逻辑类型约束。
- Pinia登录用户、菜单和权限状态管理。
- Vue Router页面路由和权限守卫。
- @lucide/vue:按钮、菜单和状态图标。
## 目录结构
| 目录 | 说明 |
| --- | --- |
| `src/api/` | 后端接口封装、请求工具、接口类型、文件下载逻辑。 |
| `src/stores/` | Pinia 状态管理,目前主要是登录态、菜单、权限和主题色。 |
| `src/router/` | 路由配置和登录/权限守卫。 |
| `src/components/` | 通用布局、侧边栏、顶部栏、统计卡片和工资结果表格。 |
| `src/views/` | 登录、工作台、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 |
| `src/assets/` | 全局样式和 UI 设计变量。 |
## 本地启动
先启动后端服务:
```bash
cd /Users/jiaolongyan/PycharmProjects/牛牛小屋/Financial_System
/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python main.py
```
再启动前端服务:
```bash
cd /Users/jiaolongyan/PycharmProjects/牛牛小屋/Financial_System/frontend
npm install
npm run dev
```
默认访问地址:
```text
http://127.0.0.1:5173/
```
## 后端接口代理
开发环境下,`vite.config.ts` 已将以下路径代理到后端:
```text
/api -> http://127.0.0.1:8000
/health -> http://127.0.0.1:8000
/static -> http://127.0.0.1:8000
```
因此前端代码中可以直接请求 `/api/auth/login`、`/api/payroll/excel` 等路径,头像静态资源也可以直接访问 `/static/uploads/avatars/...`
如果部署时前端和后端不在同一个域名,可以复制 `.env.example``.env`,并设置:
```env
VITE_API_BASE_URL=http://127.0.0.1:8000
VITE_DEV_PROXY_TARGET=http://127.0.0.1:8000
```
如果后端 `8000` 端口被旧进程占用,新后端自动启动到了 `8001`,需要把开发代理改成:
```env
VITE_DEV_PROXY_TARGET=http://127.0.0.1:8001
```
修改 `.env` 后需要重启前端开发服务。
## 默认账号
后端首次启动会初始化超级管理员:
```text
用户名admin
密码Admin@123456
角色superuser
```
登录成功后,前端会根据后端返回的 `menus` 渲染左侧菜单,根据 `permissions` 控制按钮和操作权限。
## 页面说明
| 页面 | 路由 | 说明 |
| --- | --- | --- |
| 登录 | `/login` | 用户登录入口。 |
| 工作台 | `/dashboard` | 展示最近计算任务、统计卡片和趋势面板。 |
| 工资计算 | `/payroll` | 支持 Excel 导入和钉钉实时计算。 |
| 计算记录 | `/payroll/jobs` | 支持按任务编号查询已落库工资结果。 |
| 提成管理 | `/payroll/commissions` | 支持手工维护提成和 Excel 批量导入。 |
| 统计报表 | `/reports` | 查看工资汇总、部门工资成本和考勤工时统计。 |
| 组织架构 | `/system/organization` | 维护部门上下级和部门下的岗位。 |
| 员工维护 | `/system/employees` | 维护员工编号、姓名、钉钉用户ID、部门、岗位和状态。 |
| 薪资维护 | `/system/salary-profiles` | 维护包月、计时、计件、试用期工资规则和三类加班费单价。 |
| 规则配置 | `/system/configs` | 维护考勤扣款、加班取整、请假关键字和默认加班单价。 |
| 用户管理 | `/system/users` | 超级用户创建账号、查看账号和角色权限。 |
| 操作日志 | `/system/operation-logs` | 支持按操作人、模块、动作、状态和关键字查看系统操作记录。 |
组织架构页面中的岗位编码由后端自动生成,新增或编辑岗位时不需要手工输入;员工维护页面会按所选部门联动展示该部门下的岗位,员工编号由后端按 `ZA0001`、`ZA0002` 规则自动生成。
## 个性化与个人资料
顶部栏提供两个常用入口:
- 调色盘按钮:切换主题色,支持深海蓝、专业蓝、翡翠绿、勃艮第,选择结果保存在浏览器本地。
- 头像/姓名区域:打开个人资料弹窗,可修改显示名称、邮箱、手机号、部门、职位,并上传头像。
头像上传接口为 `/api/auth/me/avatar`,后端会保存到 `storage.avatar_dir`,默认是:
```text
static/uploads/avatars
```
## 权限说明
| 角色 | 可见菜单和操作 |
| --- | --- |
| `superuser` | 拥有全部菜单和全部操作权限。 |
| `manager` | 可进行工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 |
| `viewer` | 可查看计算记录、下载结果和统计报表。 |
## 构建
```bash
cd /Users/jiaolongyan/PycharmProjects/牛牛小屋/Financial_System/frontend
npm run build
```
构建产物生成在 `dist/`,该目录已加入项目 `.gitignore`
## 常见问题
1. 登录失败:确认后端已启动,并且 `/health` 返回 `{"status":"ok"}`
2. 前端请求 401重新登录或确认当前用户拥有对应权限。
3. 下载结果失败:确认任务已生成 `output_file`,且后端 `outputs/` 中文件未被删除。
4. 端口被占用:可临时执行 `npm run dev -- --port 5174` 更换端口。

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>企业薪酬管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1570
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "financial-system-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"@lucide/vue": "1.18.0",
"pinia": "2.1.7",
"vue": "3.4.15",
"vue-router": "4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "4.6.2",
"typescript": "5.3.3",
"vite": "4.5.14",
"vue-tsc": "1.8.27"
},
"engines": {
"node": ">=16.14.0"
}
}

3
frontend/src/App.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<RouterView />
</template>

55
frontend/src/api/auth.ts Normal file
View File

@ -0,0 +1,55 @@
import { apiFetch } from "./http";
import type {
ChangePasswordRequest,
ChangePasswordResponse,
CreateUserRequest,
LoginResponse,
UpdateProfileRequest,
UserProfile,
UserRecord,
} from "./types";
export function login(username: string, password: string): Promise<LoginResponse> {
return apiFetch<LoginResponse>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export function getCurrentUser(): Promise<UserProfile> {
return apiFetch<UserProfile>("/api/auth/me");
}
export function listUsers(): Promise<UserRecord[]> {
return apiFetch<UserRecord[]>("/api/auth/users");
}
export function createUser(payload: CreateUserRequest): Promise<UserRecord> {
return apiFetch<UserRecord>("/api/auth/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateProfile(payload: UpdateProfileRequest): Promise<UserProfile> {
return apiFetch<UserProfile>("/api/auth/me", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export function changePassword(payload: ChangePasswordRequest): Promise<ChangePasswordResponse> {
return apiFetch<ChangePasswordResponse>("/api/auth/me/password", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export function uploadAvatar(file: File): Promise<UserProfile> {
const formData = new FormData();
formData.append("file", file);
return apiFetch<UserProfile>("/api/auth/me/avatar", {
method: "POST",
body: formData,
});
}

View File

@ -0,0 +1,53 @@
import { apiFetch } from "./http";
import type {
CommissionImportResponse,
CommissionListResponse,
CommissionRecord,
CommissionRecordRequest,
} from "./types";
export interface CommissionQuery {
page?: number;
page_size?: number;
commission_month?: string;
keyword?: string;
}
export function listCommissions(query: CommissionQuery): Promise<CommissionListResponse> {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== "") {
params.set(key, String(value));
}
});
return apiFetch<CommissionListResponse>(`/api/commissions?${params.toString()}`);
}
export function createCommission(request: CommissionRecordRequest): Promise<CommissionRecord> {
return apiFetch<CommissionRecord>("/api/commissions", {
method: "POST",
body: JSON.stringify(request),
});
}
export function updateCommission(id: number, request: CommissionRecordRequest): Promise<CommissionRecord> {
return apiFetch<CommissionRecord>(`/api/commissions/${id}`, {
method: "PUT",
body: JSON.stringify(request),
});
}
export function deleteCommission(id: number): Promise<{ message: string }> {
return apiFetch<{ message: string }>(`/api/commissions/${id}`, {
method: "DELETE",
});
}
export function importCommissions(file: File): Promise<CommissionImportResponse> {
const form = new FormData();
form.append("file", file);
return apiFetch<CommissionImportResponse>("/api/commissions/import", {
method: "POST",
body: form,
});
}

View File

@ -0,0 +1,24 @@
import { apiFetch } from "./http";
import type { SalaryConfigRecord, SalaryConfigRequest } from "./types";
export function listSalaryConfigs(configGroup = ""): Promise<SalaryConfigRecord[]> {
const params = new URLSearchParams();
if (configGroup) {
params.set("config_group", configGroup);
}
const query = params.toString();
return apiFetch<SalaryConfigRecord[]>(`/api/configs${query ? `?${query}` : ""}`);
}
export function updateSalaryConfig(configKey: string, request: SalaryConfigRequest): Promise<SalaryConfigRecord> {
return apiFetch<SalaryConfigRecord>(`/api/configs/${encodeURIComponent(configKey)}`, {
method: "PUT",
body: JSON.stringify(request),
});
}
export function ensureDefaultConfigs(): Promise<{ created_count: number; message: string }> {
return apiFetch<{ created_count: number; message: string }>("/api/configs/defaults", {
method: "POST",
});
}

View File

@ -0,0 +1,48 @@
import { apiFetch } from "./http";
import type { EmployeeListResponse, EmployeeNoResponse, EmployeeRecord, EmployeeRequest } from "./types";
export function listEmployees(params: { keyword?: string; employment_status?: string } = {}): Promise<EmployeeRecord[]> {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== "") {
search.set(key, String(value));
}
});
const query = search.toString();
return apiFetch<EmployeeRecord[]>(`/api/employees${query ? `?${query}` : ""}`);
}
export function listEmployeePage(
params: { page?: number; page_size?: number; keyword?: string; employment_status?: string } = {},
): Promise<EmployeeListResponse> {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== "") {
search.set(key, String(value));
}
});
const query = search.toString();
return apiFetch<EmployeeListResponse>(`/api/employees/page${query ? `?${query}` : ""}`);
}
export function getEmployee(employeeId: number): Promise<EmployeeRecord> {
return apiFetch<EmployeeRecord>(`/api/employees/${employeeId}`);
}
export function previewNextEmployeeNo(): Promise<EmployeeNoResponse> {
return apiFetch<EmployeeNoResponse>("/api/employees/next-no");
}
export function createEmployee(payload: EmployeeRequest): Promise<EmployeeRecord> {
return apiFetch<EmployeeRecord>("/api/employees", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateEmployee(employeeId: number, payload: EmployeeRequest): Promise<EmployeeRecord> {
return apiFetch<EmployeeRecord>(`/api/employees/${employeeId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}

68
frontend/src/api/http.ts Normal file
View File

@ -0,0 +1,68 @@
const TOKEN_KEY = "financial-system-token";
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = "ApiError";
}
}
export function getStoredToken(): string {
return localStorage.getItem(TOKEN_KEY) || "";
}
export function setStoredToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearStoredToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export function buildApiUrl(path: string): string {
const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/$/, "");
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
const token = getStoredToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(buildApiUrl(path), {
...init,
headers,
});
if (!response.ok) {
throw new ApiError(await readErrorMessage(response), response.status);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
async function readErrorMessage(response: Response): Promise<string> {
try {
const payload = await response.json();
if (typeof payload.detail === "string") {
return payload.detail;
}
return JSON.stringify(payload.detail || payload);
} catch {
return `请求失败,状态码 ${response.status}`;
}
}

View File

@ -0,0 +1,15 @@
import { apiFetch } from "./http";
import type { OperationLogListResponse, OperationLogQuery } from "./types";
export function listOperationLogs(query: OperationLogQuery = {}): Promise<OperationLogListResponse> {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== "") {
params.set(key, String(value));
}
});
const search = params.toString();
return apiFetch<OperationLogListResponse>(`/api/operation-logs${search ? `?${search}` : ""}`);
}

View File

@ -0,0 +1,55 @@
import { apiFetch } from "./http";
import type { DepartmentRecord, DepartmentRequest, PositionRecord, PositionRequest } from "./types";
export function listDepartments(activeOnly = true, keyword = ""): Promise<DepartmentRecord[]> {
const params = new URLSearchParams();
params.set("active_only", String(activeOnly));
if (keyword.trim()) {
params.set("keyword", keyword.trim());
}
return apiFetch<DepartmentRecord[]>(`/api/organization/departments?${params.toString()}`);
}
export function createDepartment(request: DepartmentRequest): Promise<DepartmentRecord> {
return apiFetch<DepartmentRecord>("/api/organization/departments", {
method: "POST",
body: JSON.stringify(request),
});
}
export function updateDepartment(id: number, request: DepartmentRequest): Promise<DepartmentRecord> {
return apiFetch<DepartmentRecord>(`/api/organization/departments/${id}`, {
method: "PUT",
body: JSON.stringify(request),
});
}
export function listPositions(
departmentId?: number,
activeOnly = true,
keyword = "",
): Promise<PositionRecord[]> {
const params = new URLSearchParams();
params.set("active_only", String(activeOnly));
if (departmentId) {
params.set("department_id", String(departmentId));
}
if (keyword.trim()) {
params.set("keyword", keyword.trim());
}
return apiFetch<PositionRecord[]>(`/api/organization/positions?${params.toString()}`);
}
export function createPosition(request: PositionRequest): Promise<PositionRecord> {
return apiFetch<PositionRecord>("/api/organization/positions", {
method: "POST",
body: JSON.stringify(request),
});
}
export function updatePosition(id: number, request: PositionRequest): Promise<PositionRecord> {
return apiFetch<PositionRecord>(`/api/organization/positions/${id}`, {
method: "PUT",
body: JSON.stringify(request),
});
}

View File

@ -0,0 +1,98 @@
import { ApiError, apiFetch, buildApiUrl, getStoredToken } from "./http";
import type {
DingTalkPayrollRequest,
PayrollJobResponse,
PayrollResponse,
RecentPayrollJob,
} from "./types";
const RECENT_JOBS_KEY = "financial-system-recent-payroll-jobs";
export function calculateFromExcel(
file: File,
exportExcel: boolean,
configPath?: string,
): Promise<PayrollResponse> {
const formData = new FormData();
formData.append("file", file);
formData.append("export_excel", String(exportExcel));
if (configPath) {
formData.append("config_path", configPath);
}
return apiFetch<PayrollResponse>("/api/payroll/excel", {
method: "POST",
body: formData,
});
}
export function calculateFromDingTalk(payload: DingTalkPayrollRequest): Promise<PayrollResponse> {
return apiFetch<PayrollResponse>("/api/payroll/dingtalk/realtime", {
method: "POST",
body: JSON.stringify(payload),
});
}
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 {
if (downloadUrl) {
return buildApiUrl(downloadUrl);
}
const filename = outputFile?.split(/[\\/]/).pop();
return filename ? buildApiUrl(`/api/payroll/download/${encodeURIComponent(filename)}`) : "";
}
export async function downloadPayrollFile(downloadUrl: string | null, outputFile?: string | null): Promise<void> {
const url = getDownloadUrl(downloadUrl, outputFile);
if (!url) {
throw new Error("没有可下载的结果文件");
}
const headers = new Headers();
const token = getStoredToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(url, { headers });
if (!response.ok) {
throw new ApiError("下载失败,请确认文件仍然存在", response.status);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const filename = outputFile?.split(/[\\/]/).pop() || "工资计算结果.xlsx";
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
export function readRecentJobs(): RecentPayrollJob[] {
try {
const raw = localStorage.getItem(RECENT_JOBS_KEY);
return raw ? (JSON.parse(raw) as RecentPayrollJob[]) : [];
} catch {
return [];
}
}
export function saveRecentJob(sourceType: string, response: PayrollResponse): void {
const nextJob: RecentPayrollJob = {
job_id: response.job_id,
source_type: sourceType,
employee_count: response.employee_count,
output_file: response.output_file,
download_url: response.download_url,
created_at: new Date().toISOString(),
};
const jobs = [nextJob, ...readRecentJobs().filter((item) => item.job_id !== response.job_id)].slice(0, 12);
localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(jobs));
}

View File

@ -0,0 +1,11 @@
import { apiFetch } from "./http";
import type { PayrollReportResponse } from "./types";
export function getPayrollReport(salaryMonth = ""): Promise<PayrollReportResponse> {
const params = new URLSearchParams();
if (salaryMonth) {
params.set("salary_month", salaryMonth);
}
const query = params.toString();
return apiFetch<PayrollReportResponse>(`/api/reports/payroll-summary${query ? `?${query}` : ""}`);
}

Some files were not shown because too many files have changed in this diff Show More