初步提交
This commit is contained in:
commit
99c7ba776f
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
||||
APP_CONFIG_FILE=config/app_settings.json
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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
10
.idea/.gitignore
vendored
Normal 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/
|
||||
10
.idea/Financial_System.iml
Normal file
10
.idea/Financial_System.iml
Normal 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>
|
||||
25
.idea/inspectionProfiles/Project_Default.xml
Normal file
25
.idea/inspectionProfiles/Project_Default.xml
Normal 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>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
Normal 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
7
.idea/misc.xml
Normal 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
8
.idea/modules.xml
Normal 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
6
.idea/vcs.xml
Normal 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
316
README.md
Normal 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
47
config/app_settings.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
36
config/salary_rules.example.json
Normal file
36
config/salary_rules.example.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
financial_system/__init__.py
Normal file
5
financial_system/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Attendance-driven payroll calculator for monthly DingTalk-style summaries."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
3
financial_system/api/__init__.py
Normal file
3
financial_system/api/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .app import app, create_app
|
||||
|
||||
__all__ = ["app", "create_app"]
|
||||
138
financial_system/api/app.py
Normal file
138
financial_system/api/app.py
Normal 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()
|
||||
209
financial_system/api/dependencies.py
Normal file
209
financial_system/api/dependencies.py
Normal 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
|
||||
24
financial_system/api/request_meta.py
Normal file
24
financial_system/api/request_meta.py
Normal 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),
|
||||
}
|
||||
1
financial_system/api/routers/__init__.py
Normal file
1
financial_system/api/routers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
__all__ = ["auth", "employees", "health", "operation_logs", "payroll", "salary_profiles"]
|
||||
266
financial_system/api/routers/auth.py
Normal file
266
financial_system/api/routers/auth.py
Normal 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]
|
||||
191
financial_system/api/routers/commissions.py
Normal file
191
financial_system/api/routers/commissions.py
Normal 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="导入成功")
|
||||
101
financial_system/api/routers/configs.py
Normal file
101
financial_system/api/routers/configs.py
Normal 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="默认配置已检查")
|
||||
199
financial_system/api/routers/employees.py
Normal file
199
financial_system/api/routers/employees.py
Normal 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)
|
||||
10
financial_system/api/routers/health.py
Normal file
10
financial_system/api/routers/health.py
Normal 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"}
|
||||
57
financial_system/api/routers/operation_logs.py
Normal file
57
financial_system/api/routers/operation_logs.py
Normal 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)
|
||||
176
financial_system/api/routers/organization.py
Normal file
176
financial_system/api/routers/organization.py
Normal 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)
|
||||
239
financial_system/api/routers/payroll.py
Normal file
239
financial_system/api/routers/payroll.py
Normal 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",
|
||||
)
|
||||
41
financial_system/api/routers/reports.py
Normal file
41
financial_system/api/routers/reports.py
Normal 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)
|
||||
80
financial_system/api/routers/salary_profiles.py
Normal file
80
financial_system/api/routers/salary_profiles.py
Normal 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)
|
||||
43
financial_system/api/schemas/__init__.py
Normal file
43
financial_system/api/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
168
financial_system/api/schemas/auth.py
Normal file
168
financial_system/api/schemas/auth.py
Normal 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),
|
||||
)
|
||||
78
financial_system/api/schemas/commission.py
Normal file
78
financial_system/api/schemas/commission.py
Normal 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
|
||||
90
financial_system/api/schemas/employee.py
Normal file
90
financial_system/api/schemas/employee.py
Normal 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,
|
||||
)
|
||||
62
financial_system/api/schemas/operation_log.py
Normal file
62
financial_system/api/schemas/operation_log.py
Normal 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,
|
||||
)
|
||||
87
financial_system/api/schemas/organization.py
Normal file
87
financial_system/api/schemas/organization.py
Normal 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,
|
||||
)
|
||||
190
financial_system/api/schemas/payroll.py
Normal file
190
financial_system/api/schemas/payroll.py
Normal 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],
|
||||
)
|
||||
75
financial_system/api/schemas/report.py
Normal file
75
financial_system/api/schemas/report.py
Normal 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],
|
||||
)
|
||||
66
financial_system/api/schemas/salary_config.py
Normal file
66
financial_system/api/schemas/salary_config.py
Normal 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
|
||||
76
financial_system/api/schemas/salary_profile.py
Normal file
76
financial_system/api/schemas/salary_profile.py
Normal 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,
|
||||
)
|
||||
5
financial_system/cli/__init__.py
Normal file
5
financial_system/cli/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Command line entry points."""
|
||||
|
||||
from .payroll import main
|
||||
|
||||
__all__ = ["main"]
|
||||
61
financial_system/cli/payroll.py
Normal file
61
financial_system/cli/payroll.py
Normal 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)}")
|
||||
1
financial_system/core/__init__.py
Normal file
1
financial_system/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Core configuration, security, settings, and permissions."""
|
||||
115
financial_system/core/config.py
Normal file
115
financial_system/core/config.py
Normal 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)
|
||||
101
financial_system/core/logger.py
Normal file
101
financial_system/core/logger.py
Normal 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)
|
||||
139
financial_system/core/permissions.py
Normal file
139
financial_system/core/permissions.py
Normal 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, ())
|
||||
98
financial_system/core/security.py
Normal file
98
financial_system/core/security.py
Normal 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 token,payload 明文可读但不可被客户端篡改。"""
|
||||
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)
|
||||
147
financial_system/core/settings.py
Normal file
147
financial_system/core/settings.py
Normal 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"}
|
||||
3
financial_system/database/__init__.py
Normal file
3
financial_system/database/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .session import SessionLocal, init_database
|
||||
|
||||
__all__ = ["SessionLocal", "init_database"]
|
||||
871
financial_system/database/orm.py
Normal file
871
financial_system/database/orm.py
Normal 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="任务ID,UUID十六进制字符串")
|
||||
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="更新时间",
|
||||
)
|
||||
239
financial_system/database/session.py
Normal file
239
financial_system/database/session.py
Normal 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***"
|
||||
613
financial_system/database/sql/init_mysql.sql
Normal file
613
financial_system/database/sql/init_mysql.sql
Normal 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 '任务ID,UUID十六进制字符串',
|
||||
`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();
|
||||
489
financial_system/database/sql/schema.sql
Normal file
489
financial_system/database/sql/schema.sql
Normal 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 '任务ID,UUID十六进制字符串',
|
||||
`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`);
|
||||
41
financial_system/database/sql/seed.sql
Normal file
41
financial_system/database/sql/seed.sql
Normal 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();
|
||||
@ -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;
|
||||
@ -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='系统操作日志表,记录用户登录、资料维护、工资计算、下载和系统管理动作';
|
||||
@ -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';
|
||||
@ -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`);
|
||||
1
financial_system/domain/__init__.py
Normal file
1
financial_system/domain/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Domain models and payroll calculation rules."""
|
||||
227
financial_system/domain/calculator.py
Normal file
227
financial_system/domain/calculator.py
Normal 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)
|
||||
115
financial_system/domain/models.py
Normal file
115
financial_system/domain/models.py
Normal 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 = ""
|
||||
1
financial_system/integrations/__init__.py
Normal file
1
financial_system/integrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""External system integrations."""
|
||||
193
financial_system/integrations/dingtalk.py
Normal file
193
financial_system/integrations/dingtalk.py
Normal 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
|
||||
1
financial_system/io/__init__.py
Normal file
1
financial_system/io/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Input/output adapters for Excel and local files."""
|
||||
194
financial_system/io/exporter.py
Normal file
194
financial_system/io/exporter.py
Normal 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")
|
||||
168
financial_system/io/parser.py
Normal file
168
financial_system/io/parser.py
Normal 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}小时"
|
||||
61
financial_system/io/storage.py
Normal file
61
financial_system/io/storage.py
Normal 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}"
|
||||
21
financial_system/repositories/__init__.py
Normal file
21
financial_system/repositories/__init__.py
Normal 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",
|
||||
]
|
||||
121
financial_system/repositories/commission_repository.py
Normal file
121
financial_system/repositories/commission_repository.py
Normal 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)
|
||||
132
financial_system/repositories/employee_repository.py
Normal file
132
financial_system/repositories/employee_repository.py
Normal 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
|
||||
100
financial_system/repositories/operation_log_repository.py
Normal file
100
financial_system/repositories/operation_log_repository.py
Normal 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()
|
||||
185
financial_system/repositories/organization_repository.py
Normal file
185
financial_system/repositories/organization_repository.py
Normal 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()
|
||||
)
|
||||
300
financial_system/repositories/payroll_repository.py
Normal file
300
financial_system/repositories/payroll_repository.py
Normal 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"
|
||||
79
financial_system/repositories/report_repository.py
Normal file
79
financial_system/repositories/report_repository.py
Normal 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()
|
||||
63
financial_system/repositories/salary_config_repository.py
Normal file
63
financial_system/repositories/salary_config_repository.py
Normal 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
|
||||
93
financial_system/repositories/salary_profile_repository.py
Normal file
93
financial_system/repositories/salary_profile_repository.py
Normal 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()
|
||||
)
|
||||
96
financial_system/repositories/user_repository.py
Normal file
96
financial_system/repositories/user_repository.py
Normal 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
|
||||
28
financial_system/services/__init__.py
Normal file
28
financial_system/services/__init__.py
Normal 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",
|
||||
]
|
||||
156
financial_system/services/auth_service.py
Normal file
156
financial_system/services/auth_service.py
Normal 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)
|
||||
182
financial_system/services/commission_service.py
Normal file
182
financial_system/services/commission_service.py
Normal 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()
|
||||
183
financial_system/services/employee_service.py
Normal file
183
financial_system/services/employee_service.py
Normal 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
|
||||
114
financial_system/services/operation_log_service.py
Normal file
114
financial_system/services/operation_log_service.py
Normal 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]
|
||||
392
financial_system/services/organization_service.py
Normal file
392
financial_system/services/organization_service.py
Normal 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"],
|
||||
]
|
||||
)
|
||||
238
financial_system/services/payroll_service.py
Normal file
238
financial_system/services/payroll_service.py
Normal 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,
|
||||
)
|
||||
48
financial_system/services/report_service.py
Normal file
48
financial_system/services/report_service.py
Normal 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
|
||||
228
financial_system/services/salary_config_service.py
Normal file
228
financial_system/services/salary_config_service.py
Normal 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
|
||||
117
financial_system/services/salary_profile_service.py
Normal file
117
financial_system/services/salary_profile_service.py
Normal 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
2
frontend/.env.example
Normal 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
141
frontend/README.md
Normal 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
12
frontend/index.html
Normal 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
1570
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal 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
3
frontend/src/App.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
55
frontend/src/api/auth.ts
Normal file
55
frontend/src/api/auth.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
53
frontend/src/api/commissions.ts
Normal file
53
frontend/src/api/commissions.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
24
frontend/src/api/configs.ts
Normal file
24
frontend/src/api/configs.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
48
frontend/src/api/employees.ts
Normal file
48
frontend/src/api/employees.ts
Normal 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
68
frontend/src/api/http.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
15
frontend/src/api/operationLogs.ts
Normal file
15
frontend/src/api/operationLogs.ts
Normal 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}` : ""}`);
|
||||
}
|
||||
55
frontend/src/api/organization.ts
Normal file
55
frontend/src/api/organization.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
98
frontend/src/api/payroll.ts
Normal file
98
frontend/src/api/payroll.ts
Normal 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));
|
||||
}
|
||||
11
frontend/src/api/reports.ts
Normal file
11
frontend/src/api/reports.ts
Normal 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
Loading…
Reference in New Issue
Block a user