Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3de35c2ec | ||
|
|
35fea3802f | ||
|
|
a4794489b1 | ||
|
|
eb4e06d1e8 | ||
|
|
ce0961a498 | ||
|
|
28d01dcd75 | ||
|
|
560b6878d6 | ||
|
|
d6cac0a914 | ||
|
|
65873bc894 | ||
|
|
a8adf064d2 | ||
|
|
b21dff12f4 | ||
|
|
fc4428abb5 | ||
|
|
b8920c0d28 | ||
|
|
68e70fd6e2 | ||
|
|
515b83b660 | ||
|
|
4a888e9974 | ||
|
|
73dbf08d4b | ||
|
|
1e53792ad6 | ||
|
|
3efe72c344 | ||
|
|
ae12403c0b | ||
|
|
b552f1e567 | ||
|
|
6bac9586bf | ||
|
|
bf404c3b68 | ||
|
|
2ac4c71106 | ||
|
|
2c003fc4a7 | ||
|
|
5e5304eece | ||
|
|
908851a704 | ||
|
|
c684a44753 | ||
|
|
67adecbf1b | ||
|
|
22605f4106 | ||
|
|
a2f9a58de5 | ||
|
|
1461a8d113 | ||
|
|
02fb06e0af | ||
|
|
71b7846a79 | ||
|
|
019817d6dc | ||
|
|
108bf87225 | ||
|
|
1b074576fe | ||
|
|
013d6e7a74 | ||
|
|
0ca1734523 | ||
|
|
b0c599e513 | ||
|
|
2fda2d118b | ||
|
|
37eb51513f | ||
|
|
ee801652e3 | ||
|
|
8a171a572c | ||
|
|
651341b36c | ||
|
|
0d63359acb | ||
|
|
ea41dadb64 | ||
|
|
8242aae454 | ||
|
|
87a0884953 | ||
|
|
ea989ce9ad | ||
|
|
4fe5916c2c | ||
|
|
78bef43c06 | ||
|
|
823d9f02ec | ||
|
|
678db0499d | ||
|
|
a00fca942d | ||
|
|
61965e0b60 | ||
|
|
111ac5425a | ||
|
|
61fe5cca98 | ||
|
|
32b7940067 | ||
|
|
b9781f7d3f | ||
|
|
8e8bcaa5fb | ||
|
|
1f65913002 | ||
|
|
e61800dcdc | ||
|
|
f88857e026 | ||
|
|
5ece9f6dab | ||
|
|
c48d24ab6d | ||
|
|
d0726487e9 | ||
|
|
a84fe9b316 | ||
|
|
94e2957359 | ||
|
|
ebc9595de2 | ||
|
|
9964281c14 | ||
|
|
694d21d1fd | ||
|
|
4c32a556de | ||
|
|
4c10475228 | ||
|
|
2e0c64849b | ||
|
|
33b9b00bb0 | ||
|
|
d03ddadd1a | ||
|
|
c319a6a428 | ||
|
|
36114745db | ||
|
|
b86eaf2b00 | ||
|
|
71df9a16ed | ||
|
|
980628bb8f | ||
|
|
c8131588bf | ||
|
|
b6d84033ce | ||
|
|
698df6d31a | ||
|
|
39a3fa7796 | ||
|
|
ebb3a92f25 | ||
|
|
c91d1db78a | ||
|
|
294d99a0d3 | ||
|
|
6ff202d637 | ||
|
|
4e54d09ccb | ||
|
|
b7cfdfa50a | ||
|
|
04a1a0d307 | ||
|
|
2a1939d2e7 | ||
|
|
cfd2a196f2 | ||
|
|
cc4c51359c | ||
|
|
036834a1d0 | ||
|
|
8903a194c9 | ||
|
|
a2bd3960e1 | ||
|
|
fb398c3835 | ||
|
|
c5f219423b | ||
|
|
a536608b53 | ||
|
|
ccfd20651b | ||
|
|
48c0a86279 | ||
|
|
e48cfb752d | ||
|
|
028a94a6eb | ||
|
|
a89d540e3f | ||
|
|
7a99994d17 | ||
|
|
45c268b146 | ||
|
|
828b57350a | ||
|
|
76ded6d5ec | ||
|
|
7a460cb682 | ||
|
|
cb4f38dfd9 | ||
|
|
35fe37fb0c | ||
|
|
7008de0c69 | ||
|
|
2652397720 | ||
|
|
0e8c5713fa | ||
|
|
238a38c356 | ||
|
|
a1217f4482 | ||
|
|
e7e47d88bc | ||
|
|
22a2872100 | ||
|
|
df1a3839a1 | ||
|
|
a5c86640e2 | ||
|
|
30a179fc4c | ||
|
|
1c93ea0a4b | ||
|
|
fc8a42bcd9 | ||
|
|
b5e27810fd | ||
|
|
927592b472 | ||
|
|
74f1ef1cf9 | ||
|
|
8ef26e22fb | ||
|
|
680661a80b | ||
|
|
efed29647c | ||
|
|
e95dc4b2de | ||
|
|
acf87fdfdc | ||
|
|
94c49138bf | ||
|
|
f75a9d534e | ||
|
|
0ef3adec21 | ||
|
|
60f6af6494 | ||
|
|
409e26614f | ||
|
|
5ef48abf47 | ||
|
|
7f8f88c148 | ||
|
|
c2b462df3b | ||
|
|
e6c2c71d1b | ||
|
|
91fc6f6293 | ||
|
|
9a70264b51 | ||
|
|
6d612daf30 | ||
|
|
a0086554b1 | ||
|
|
5a3778a424 | ||
|
|
cb977979da | ||
|
|
64c57e9141 | ||
|
|
d8dba27fb2 | ||
|
|
d77728265b | ||
|
|
8c4acb82fe | ||
|
|
fba8cfc215 | ||
|
|
299b72483c | ||
|
|
c512cb77fb | ||
|
|
ca4cc72eea | ||
|
|
77e578085a | ||
|
|
06cdf4141f | ||
|
|
5207d14daa | ||
|
|
09831dbb7f | ||
|
|
a02b6c3ec8 | ||
|
|
4aea2be103 | ||
|
|
0cdc6246c4 | ||
|
|
1625907380 | ||
|
|
899bf3afa5 | ||
|
|
20fcf06f51 | ||
|
|
ca88ee7a0b | ||
|
|
15de81dd2c | ||
|
|
b8ba9e4626 | ||
|
|
7eeed85c96 | ||
|
|
a694570dd1 | ||
|
|
f22d14e439 | ||
|
|
4552f2e703 | ||
|
|
e568ebde61 | ||
|
|
8668e58c30 | ||
|
|
fbf044f240 | ||
|
|
6219698a39 | ||
|
|
d7fbec1177 | ||
|
|
4c442b8cf2 | ||
|
|
fd801455f0 | ||
|
|
8ad3650dd3 | ||
|
|
7a0f3f4b98 | ||
|
|
1b3509b20f | ||
|
|
5ed5665a54 | ||
|
|
bc019cadf5 | ||
|
|
c7212cd93f | ||
|
|
8355e0cc7d | ||
|
|
c83287819a | ||
|
|
e22c4bcdc9 | ||
|
|
ad5fe4a411 |
No files matched your search
@@ -0,0 +1,35 @@
|
|||||||
|
# 构建上下文白名单,防止本地配置、客户资料和虚拟环境被发送到构建器。
|
||||||
|
**
|
||||||
|
!backend/
|
||||||
|
!backend/Dockerfile
|
||||||
|
!backend/pyproject.toml
|
||||||
|
!backend/uv.lock
|
||||||
|
!backend/.python-version
|
||||||
|
!backend/alembic.ini
|
||||||
|
!backend/app/
|
||||||
|
!backend/app/**
|
||||||
|
!backend/alembic/
|
||||||
|
!backend/alembic/**
|
||||||
|
!db/
|
||||||
|
!db/migration/
|
||||||
|
!db/migration/**
|
||||||
|
!db/queries/
|
||||||
|
!db/queries/tplus/
|
||||||
|
!db/queries/tplus/**
|
||||||
|
!frontend/
|
||||||
|
!frontend/Dockerfile
|
||||||
|
!frontend/package.json
|
||||||
|
!frontend/pnpm-lock.yaml
|
||||||
|
!frontend/index.html
|
||||||
|
!frontend/tsconfig*.json
|
||||||
|
!frontend/vite.config.ts
|
||||||
|
!frontend/.env.production
|
||||||
|
!frontend/src/
|
||||||
|
!frontend/src/**
|
||||||
|
!frontend/public/
|
||||||
|
!frontend/public/**
|
||||||
|
!docker/
|
||||||
|
!docker/nginx.prod.conf
|
||||||
|
!docker/tplus-maintenance.sh
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
+42
-4
@@ -1,5 +1,7 @@
|
|||||||
# AI辅助目录(整个目录忽略)
|
# AI辅助目录(整个目录忽略)
|
||||||
ai-aux/
|
.ai/
|
||||||
|
.agents/
|
||||||
|
/AGENTS.md
|
||||||
|
|
||||||
# 依赖
|
# 依赖
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -26,7 +28,10 @@ out/
|
|||||||
|
|
||||||
# 日志
|
# 日志
|
||||||
*.log
|
*.log
|
||||||
logs/
|
# 后端运行日志目录(core/logging.py 创建)。
|
||||||
|
# 不要写成裸 `logs/` —— 那会匹配任意层级的同名目录,曾把
|
||||||
|
# frontend/src/views/system/logs 整个吞掉(文件进不了版本库)
|
||||||
|
/backend/logs/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
@@ -42,15 +47,48 @@ htmlcov/
|
|||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.tox/
|
.tox/
|
||||||
# 端到端测试产出的截图
|
# 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD
|
||||||
frontend/e2e/*.png
|
# 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖)
|
||||||
|
frontend/e2e/**/*.png
|
||||||
|
|
||||||
# 临时文件
|
# 临时文件
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
*.cache
|
*.cache
|
||||||
|
.tmp-processes*.txt
|
||||||
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
|
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
|
||||||
~$*
|
~$*
|
||||||
|
|
||||||
# 备份
|
# 备份
|
||||||
backup/
|
backup/
|
||||||
|
.claude
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# 运行期上传物(公司 logo 等):与构建产物隔离,不入版本库
|
||||||
|
backend/uploads/
|
||||||
|
# 数据库备份导出目录(⑨ 备份脚本产物)
|
||||||
|
db/backups/
|
||||||
|
# 客户发来的原始账套备份压缩包。
|
||||||
|
/db/*.zip
|
||||||
|
# 本地恢复与连通性排查脚本/结果,不作为业务交付物。
|
||||||
|
/db/restore_*.ps1
|
||||||
|
/db/restore_*.sql
|
||||||
|
/db/restore_result.txt
|
||||||
|
/db/probe*.ps1
|
||||||
|
/backend/probe_*_status.py
|
||||||
|
|
||||||
|
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
|
||||||
|
docs/**/*.rar
|
||||||
|
docs/T_12.3数据字典/
|
||||||
|
# macOS 压缩包残留元数据
|
||||||
|
__MACOSX/
|
||||||
|
|
||||||
|
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
|
||||||
|
.tmpdocx/
|
||||||
|
# 本轮原开发环境损坏后的独立恢复运行环境。
|
||||||
|
backend/.venv-tplus/
|
||||||
|
backend/.venv-workbench/
|
||||||
|
|
||||||
|
# 本地浏览器验收自动生成的快照
|
||||||
|
/.playwright-mcp/
|
||||||
|
/phase2-evidence-*/
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
|
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
|
||||||
|
|
||||||
> 当前进度:登录鉴权链路已跑通(验证码 / JWT / 限流 / 登出黑名单 / 登录日志)。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
|
> 当前进度:正式工资闭环及同步运维自动化已完成本地开发验证,支持每周原始采集、有限重试、独立心跳和站内提示,并提供 Windows 托管脚本。辽宁兴晟达真实样本已在隔离库完成正式导入、核算、月结、报表与导出验收,整月实际工资仍须另行核定,见[真实样本业务闭环验收](docs/真实样本业务闭环验收_2026-11-03.md)。生产自动化默认关闭,尚未安装启用。审核能力保留但默认关闭,多原单更正仍单独跟踪。相关说明见 [T+ 本地恢复与同步开发手册](docs/T+本地恢复与同步开发手册.md) 和 [Windows 操作说明](scripts/windows/README.md)。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ pnpm dev
|
|||||||
# 后端测试与代码检查
|
# 后端测试与代码检查
|
||||||
cd backend
|
cd backend
|
||||||
uv run pytest tests/unit -q # 纯单元,无需容器,秒级
|
uv run pytest tests/unit -q # 纯单元,无需容器,秒级
|
||||||
uv run pytest -q # 全部(集成部分需容器在跑)
|
python ../scripts/run_isolated_tests.py # 创建独立测试库运行集成,禁止指向业务库
|
||||||
uv run ruff check . # 代码检查
|
uv run ruff check . # 代码检查
|
||||||
uv run ruff format . # 代码格式化
|
uv run ruff format . # 代码格式化
|
||||||
|
|
||||||
@@ -81,7 +81,8 @@ pnpm type-check # 类型检查
|
|||||||
pnpm build # 类型检查 + 生产构建
|
pnpm build # 类型检查 + 生产构建
|
||||||
|
|
||||||
# 浏览器端到端(需容器、后端、前端都在运行)
|
# 浏览器端到端(需容器、后端、前端都在运行)
|
||||||
python e2e/login_flow.py
|
python e2e/login_flow.py # 登录链路 8 项
|
||||||
|
python e2e/system_management.py # 系统管理 13 项
|
||||||
```
|
```
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
@@ -127,9 +128,28 @@ python e2e/login_flow.py
|
|||||||
|
|
||||||
## 相关文档
|
## 相关文档
|
||||||
|
|
||||||
|
客户现场部署见 [客户现场部署与 T+ 连接说明](docs/客户现场部署与T+连接说明.md):按 Linux 容器、Windows Server 或 Linux 原生选择托管方式,连接客户既有 T+ SQL Server。生产 Compose 为 `docker/docker-compose.prod.yml`,T+ 恢复库编排仅用于本地开发。
|
||||||
|
|
||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `backend/README.md` | 后端启动、分层约定、接口清单 |
|
| `backend/README.md` | 后端启动、分层约定、接口清单 |
|
||||||
| `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 |
|
| `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 |
|
||||||
| `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 |
|
| `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 |
|
||||||
| `docs/` | 需求文档与工资算法方案 |
|
| `docs/` | 需求文档与工资算法方案 |
|
||||||
|
|
||||||
|
## 正式工资闭环
|
||||||
|
|
||||||
|
- 在“角色管理”配置工资操作权限,在“用户管理”分配角色;账号自动继承权限,无需单独绑定工资制作人。每次实际经办人继续留痕。
|
||||||
|
- 应用新增迁移后,T+ 页面提供正式业务导入预览/执行;业务列表“正式核算”准备人工业务资料或核算已导入资料,台账可查看逐段依据。
|
||||||
|
- “生成月结快照”首次成功即锁定该月分配;审批退回、重新生成不解锁。历史已生成工资保留,差额计调整单生成当月。
|
||||||
|
- 后端接口说明、验证状态和已知边界见 `docs/正式工资闭环开发记录_2026-10-30.md`。旧引擎仅通过 `PAYROLL_ALLOW_LEGACY_CALCULATION` 显式启用作历史兼容,默认关闭,不用于最新规则验收。
|
||||||
|
- 集成回归使用 `scripts/run_isolated_tests.py`;原 `.venv` 不可用时,本机使用 `backend/.venv-workbench/Scripts/python.exe`。运行器默认清理本次新建的隔离库和账号,绝不删除原开发数据;需复查失败现场时加 `--keep`。
|
||||||
|
|
||||||
|
审核默认关闭:`PAYROLL_APPROVAL_ENABLED=false`,工资生成直接完成且不产生审批痕迹;如确需系统内复核,显式开启后沿用财务/领导审核和各类审批参数。此配置与旧核算引擎兼容开关独立。
|
||||||
|
|
||||||
|
## 权限管理
|
||||||
|
|
||||||
|
- “用户管理 → 分配角色”:选择角色并预览继承权限,支持多个角色叠加。
|
||||||
|
- “角色管理 → 配置权限”:按模块配置模板;停用角色即时撤销其授权。
|
||||||
|
- 管理员拥有全部功能权限;月份锁定、审核节点及源读取开关继续独立检查。
|
||||||
|
- 授权变更无需重新登录,后端逐请求校验;详情、只读提示、菜单与按钮使用统一规则。操作与接口说明见 [权限管理使用与开发记录](docs/权限管理使用与开发记录_2026-11-02.md)。
|
||||||
+50
-3
@@ -17,6 +17,15 @@ REDIS_DB=0
|
|||||||
# ===== 应用 =====
|
# ===== 应用 =====
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
APP_SECRET_KEY=
|
APP_SECRET_KEY=
|
||||||
|
# 运行监控:每 15 秒采集,分钟聚合保留 7 天;部署限单个应用进程
|
||||||
|
MONITOR_ENABLED=true
|
||||||
|
MONITOR_CPU_THRESHOLD=85
|
||||||
|
MONITOR_MEMORY_THRESHOLD=85
|
||||||
|
MONITOR_DISK_THRESHOLD=90
|
||||||
|
MONITOR_RESOURCE_DURATION_SECONDS=300
|
||||||
|
MONITOR_SLOW_REQUEST_MS=2000
|
||||||
|
MONITOR_ERROR_RATE_THRESHOLD=5
|
||||||
|
MONITOR_ERROR_MIN_REQUESTS=20
|
||||||
# JWT 有效期(分钟),规范要求 ≤120
|
# JWT 有效期(分钟),规范要求 ≤120
|
||||||
JWT_EXPIRE_MINUTES=120
|
JWT_EXPIRE_MINUTES=120
|
||||||
# 验证码有效期(秒),规范要求 300
|
# 验证码有效期(秒),规范要求 300
|
||||||
@@ -26,6 +35,44 @@ LOGIN_FAIL_LIMIT=5
|
|||||||
LOGIN_LOCK_MINUTES=15
|
LOGIN_LOCK_MINUTES=15
|
||||||
|
|
||||||
# ===== T+ 对接(待畅捷通确认后填写)=====
|
# ===== T+ 对接(待畅捷通确认后填写)=====
|
||||||
T_PLUS_BASE_URL=
|
T_PLUS_ENABLED=false
|
||||||
T_PLUS_APP_KEY=
|
T_PLUS_HOST=127.0.0.1
|
||||||
T_PLUS_APP_SECRET=
|
T_PLUS_PORT=1433
|
||||||
|
T_PLUS_DATABASE=
|
||||||
|
T_PLUS_USER=
|
||||||
|
T_PLUS_PASSWORD=
|
||||||
|
# 仅用于已确认的开发测试库,默认禁止使用 sa。
|
||||||
|
T_PLUS_ALLOW_DEV_SA=false
|
||||||
|
# 仅在客户确认只能使用管理员账号时显式开启。
|
||||||
|
T_PLUS_ALLOW_ADMIN_ACCOUNT=false
|
||||||
|
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
|
||||||
|
T_PLUS_CONNECTION_FILE=
|
||||||
|
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
|
||||||
|
# 本地 PoC 可复用已安装的 SQL Server 驱动;不自动安装或升级客户驱动。
|
||||||
|
T_PLUS_ODBC_DRIVER=
|
||||||
|
# 默认启用传输加密。旧实例 TLS 不兼容时,仅隔离开发环境可显式设为 false,
|
||||||
|
# 记录原因并重新验收;程序不会自动降级。真实环境值后续按验收结果填写。
|
||||||
|
T_PLUS_ENCRYPT=true
|
||||||
|
# 默认校验服务器证书。仅隔离开发环境可显式设为 true,并记录原因和验收范围。
|
||||||
|
T_PLUS_TRUST_SERVER_CERTIFICATE=false
|
||||||
|
T_PLUS_CONNECT_TIMEOUT=10
|
||||||
|
T_PLUS_QUERY_TIMEOUT=120
|
||||||
|
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
|
||||||
|
T_PLUS_QUERY_DIR=
|
||||||
|
|
||||||
|
# 工资操作通过角色权限控制,无需配置全局制作人账号。
|
||||||
|
|
||||||
|
# 审核能力保留但默认关闭;开启后仍按各类审批参数及财务/领导节点执行
|
||||||
|
PAYROLL_APPROVAL_ENABLED=false
|
||||||
|
|
||||||
|
# ===== T+ 周期采集(默认关闭,仅采集原始数据,不自动生成工资)=====
|
||||||
|
T_PLUS_AUTOMATION_ENABLED=false
|
||||||
|
# 0=周一,6=周日;时段均为北京时间
|
||||||
|
T_PLUS_SCHEDULE_WEEKDAY=0
|
||||||
|
T_PLUS_SCHEDULE_TIME=02:00
|
||||||
|
# 托管启动器自动设置;发现标记后停止排队和领取新任务
|
||||||
|
T_PLUS_STOP_FILE=
|
||||||
|
# 仅允许本机 HTTP 健康检查地址;API 更换监听端口时同步修改。
|
||||||
|
T_PLUS_SUPERVISOR_HEALTH_URL=http://127.0.0.1:8000/api/health
|
||||||
|
# 可选上传文件根目录;留空使用 backend/uploads,隔离测试由运行器独立指定。
|
||||||
|
UPLOAD_DIR=
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 从仓库根目录构建:docker build -f backend/Dockerfile -t ys-payroll-backend:release .
|
||||||
|
FROM ghcr.io/astral-sh/uv:0.12.13 AS uv
|
||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_PYTHON_DOWNLOADS=never \
|
||||||
|
PATH="/app/backend/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
# 这里只安装客户端驱动,连接客户现场 SQL Server;镜像内没有 T+ 数据库。
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates curl gnupg unixodbc tzdata \
|
||||||
|
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
|
||||||
|
&& echo "deb [signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" \
|
||||||
|
> /etc/apt/sources.list.d/mssql-release.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& ACCEPT_EULA=Y DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends msodbcsql18 \
|
||||||
|
&& apt-get purge -y curl gnupg \
|
||||||
|
&& apt-get autoremove -y \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=uv /uv /usr/local/bin/uv
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY backend/pyproject.toml backend/uv.lock backend/.python-version ./
|
||||||
|
RUN uv sync --frozen --no-dev --extra tplus --no-cache
|
||||||
|
|
||||||
|
COPY backend/alembic.ini ./
|
||||||
|
COPY backend/alembic ./alembic
|
||||||
|
COPY backend/app ./app
|
||||||
|
# 保留仓库相对布局,覆盖读取、总量、关联核验和样本核对 SQL。
|
||||||
|
COPY db/queries/tplus /app/db/queries/tplus
|
||||||
|
# V28 迁移从仓库路径读取留存 SQL,镜像必须携带该运行时依赖。
|
||||||
|
COPY db/migration /app/db/migration
|
||||||
|
COPY docker/tplus-maintenance.sh /app/tplus-maintenance.sh
|
||||||
|
|
||||||
|
RUN mkdir -p /app/backend/uploads /app/backend/logs \
|
||||||
|
&& useradd --uid 10001 --create-home appuser \
|
||||||
|
&& chown -R appuser:appuser /app/backend/uploads /app/backend/logs
|
||||||
|
USER appuser
|
||||||
|
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
+181
-1
@@ -1,11 +1,13 @@
|
|||||||
# 后端
|
# 后端
|
||||||
|
|
||||||
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与健康检查,业务模块待需求澄清后开发。
|
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。已实现登录鉴权、系统管理(用户/角色/操作日志/日志查询)与 M1 参数体系(岗位角色管理、计提参数版本化/留痕/对比/预览/导出,2026-10-13);业务模块(基础/业务数据、核算、报表)按路线图推进。
|
||||||
|
|
||||||
## 职责
|
## 职责
|
||||||
|
|
||||||
- 提供 REST 接口(统一前缀 `/api`)
|
- 提供 REST 接口(统一前缀 `/api`)
|
||||||
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
|
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
|
||||||
|
- 系统管理(需求 2.5.1):用户管理、角色管理、岗位角色(#28 可扩展+公式模板绑定)、操作日志(自动审计)、操作与登录日志查询
|
||||||
|
- 计提参数体系(需求 #28):16 项参数目录、按生效日期版本化(回款到账日取值)、变更批次留痕/对比/导出、计算预览
|
||||||
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
|
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
|
||||||
|
|
||||||
## 技术栈与版本
|
## 技术栈与版本
|
||||||
@@ -53,12 +55,104 @@ app/
|
|||||||
| POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 |
|
| POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 |
|
||||||
| GET | `/api/v1/auth/me` | 是 | 当前用户与角色 |
|
| GET | `/api/v1/auth/me` | 是 | 当前用户与角色 |
|
||||||
|
|
||||||
|
### 系统管理接口(需求 2.5.1,全部要求管理员权限)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET / POST | `/api/v1/system/users` | 用户列表(分页/筛选)/ 新增用户 |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/system/users/{id}` | 用户详情 / 编辑(含角色分配)/ 删除(物理删除) |
|
||||||
|
| PUT | `/api/v1/system/users/{id}/password` | 重置密码(提升令牌版本,旧令牌立即失效) |
|
||||||
|
| PUT | `/api/v1/system/users/{id}/status` | 启用 / 禁用(禁用同样提升令牌版本) |
|
||||||
|
| GET | `/api/v1/system/users/meta/roles` | 角色下拉选项(仅启用角色) |
|
||||||
|
| GET / POST | `/api/v1/system/roles` | 角色列表(含停用与绑定用户数)/ 新增角色 |
|
||||||
|
| PUT / DELETE | `/api/v1/system/roles/{id}` | 编辑(不含编码)/ 删除(内置与有绑定的角色受保护) |
|
||||||
|
| PUT | `/api/v1/system/roles/{id}/status` | 启用 / 禁用(内置 admin 禁停) |
|
||||||
|
| GET | `/api/v1/system/operation-logs` | 操作日志查询(操作人/模块/操作/结果/时间范围;**不含 request_data** 大字段) |
|
||||||
|
| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) |
|
||||||
|
| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) |
|
||||||
|
|
||||||
|
### 基础数据接口(需求 2.1,登录即可 —— 业务操作不要求管理员)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET / POST | `/api/v1/base/personnel` | 人员列表(姓名模糊/状态/岗位筛选,附岗位与经理名)/ 新增(岗位须启用;经理非自己且在职) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/base/personnel/{id}` | 详情(附区域与计划)/ 编辑 / 删除(被团队成员或客户归属引用 → 400 引导改离职) |
|
||||||
|
| PUT | `/api/v1/base/personnel/{id}/status` | 在职 / 离职切换 |
|
||||||
|
| PUT | `/api/v1/base/personnel/{id}/regions` | 辐射区域全量替换(空列表即清空) |
|
||||||
|
| PUT | `/api/v1/base/personnel/{id}/plans` | 市场拓展计划全量替换 |
|
||||||
|
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
||||||
|
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
||||||
|
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
|
||||||
|
| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) |
|
||||||
|
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
|
||||||
|
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
||||||
|
|
||||||
|
### 业务数据接口(需求 2.2,登录即可)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET / POST | `/api/v1/business/businesses` | 业务列表(编号/公司名模糊、状态/客户/归属/日期筛选)/ 创建(编号 `YW{日期}-{序号}` 自动生成;明细 ≥1 行;销售额服务层固化) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/business/businesses/{id}` | 详情(附明细)/ 编辑(明细全量替换 + 五项汇总重算;**归属不在此端点**)/ 删除(有发货或回款 → 400 引导改已终止) |
|
||||||
|
| PUT | `/api/v1/business/businesses/{id}/status` | 状态流转(1→2/3;终态不可再变更;终止必填原因;写状态留痕) |
|
||||||
|
| PUT | `/api/v1/business/businesses/{id}/ownership` | 负责/协助归属变更(同事务写留痕,owner_type=2) |
|
||||||
|
| GET | `/api/v1/business/businesses/{id}/ownership-logs` | 业务归属变更留痕分页 |
|
||||||
|
| GET | `/api/v1/business/businesses/{id}/status-logs` | 状态流转时间线(创建写首条 old=new=1) |
|
||||||
|
| GET | `/api/v1/business/businesses/{id}/gross-profit` | **毛利润数据视图**(只读实时计算,不落库:行级率/折算额 + 类别汇总;不含运费 #2;阈值按 `as_of` 取参数快照;成本缺失行标注无法计算) |
|
||||||
|
| GET / POST | `/api/v1/business/shipments` | 发货单列表 / 创建(编号 `SH…`;业务须执行中;行金额服务层固化;业务级发货/运费汇总回写) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/business/shipments/{id}` | 详情 / 编辑(终态业务禁改)/ 删除(被回款批次指定 → 400) |
|
||||||
|
| GET / POST | `/api/v1/business/receipts` | 回款列表(含 **payment_days 派生**:发货日首日=第 1 天 #1)/ 新增(编号 `HK…`;**shipment_id 同业务强校验**;业务须执行中) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/business/receipts/{id}` | 详情 / 编辑 / 删除(**已核算回款禁改禁删**——M3 防重锚点) |
|
||||||
|
| GET / POST | `/api/v1/business/rating-grades` | 评级等级全量列表(sort_order 升序)/ 新增(区间值域 + **与既有等级重叠 → 400**) |
|
||||||
|
| PUT / DELETE | `/api/v1/business/rating-grades/{id}` | 编辑(重叠判定)/ 删除(无下游引用,允许清空) |
|
||||||
|
|
||||||
|
全部写操作自动写入操作日志(成功与失败都记,机制见下)。
|
||||||
|
|
||||||
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
|
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
|
||||||
状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。
|
状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。
|
||||||
|
|
||||||
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
|
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
|
||||||
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
|
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
|
||||||
|
|
||||||
|
### 报表统计接口(M4 ⑤,限管理员/财务/领导)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/reports/personal-detail` | 个人绩效工资明细表(FR-029:人员/月份/客户类型/产品类别/岗位筛选;响应带 `summary` 全筛选合计) |
|
||||||
|
| GET | `/api/v1/reports/department-summary` | 部门绩效工资汇总表(FR-030:月份必填;团队基数与月结聚合行同源;12.5% 与 5% 分列;含成员内联明细、未分配桶、月结冻结对照额) |
|
||||||
|
| GET | `/api/v1/reports/business-stats` | 业务统计表(FR-031:发货/回款/绩效为业务累计值,不随业务日期筛选变化) |
|
||||||
|
| GET | `/api/v1/reports/payment-stats` | 回款情况统计表(FR-031:8 档天数区间恒在,空桶为 0) |
|
||||||
|
| GET | `/api/v1/reports/payment-detail` | 回款区间下钻(`bucket` 必填,未注册编码 400) |
|
||||||
|
| GET | `/api/v1/reports/payment-day-buckets` | 区间档位元数据(前端下拉唯一来源,禁止前端硬编码) |
|
||||||
|
| GET | `/api/v1/reports/{code}/export` | Excel 导出(文件流非统一信封;含公司页眉/财务复核栏/领导签字栏;超 2 万行 400;行为入操作日志) |
|
||||||
|
|
||||||
|
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
|
||||||
|
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
|
||||||
|
|
||||||
|
### 工作台接口(M4 ⑧,限管理员/财务/领导)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/dashboard?month=YYYY-MM` | 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志) |
|
||||||
|
|
||||||
|
待办与预警**实时派生**(不落表、不引调度器);预警阈值走参数体系
|
||||||
|
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
|
||||||
|
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
|
||||||
|
|
||||||
|
### T+ 本地只读同步与管理
|
||||||
|
|
||||||
|
T+ 数据库是 SQL Server 只读源库,业务库仍为 MySQL。Docker 恢复 PoC、备份检查和 D 盘目录约定见 `docs/T+本地恢复与同步开发手册.md`。
|
||||||
|
|
||||||
|
- 连接配置在 `backend/.env` 的 `T_PLUS_*` 变量中,默认关闭,不配置时不影响主业务启动。
|
||||||
|
- `uv run python -m app.cli tplus-stage --entity personnel` 按主键分页,将源行写入 `tplus_sync_records` staging,并记录批次和失败信息。水位表已预留,时间增量未启用,`--watermark` 会明确拒绝。
|
||||||
|
- T+ 驱动通过 `uv sync --extra tplus` 安装到应用环境,ODBC 驱动名称必须显式配置;交付兼容目标为 SQL Server 2008 R2,2022 副本不能代替同版本验收。
|
||||||
|
- 查询目录 `db/queries/tplus/` 随部署交付,或使用 `T_PLUS_QUERY_DIR` 指定受控位置;支持六类主表和三类明细。
|
||||||
|
- T+ 字典中的候选字段在实际备份核验和客户确认前,不得直接覆盖岗位、工资标准、归属、业务状态或核算状态。
|
||||||
|
- 管理 API `/api/v1/system/tplus` 全部限管理员:概况、批次/异常分页、按需报告、创建与失败重试。HTTP 只创建 QUEUED 任务;`uv run python -m app.cli tplus-worker --watch --connection-file <项目外JSON路径>` 独立处理。
|
||||||
|
- 核心文件:`services/tplus/task_service.py`(幂等/重试)、`worker.py`(执行)、`relationship_audit.py`(字典与关联)、`query_service.py`(安全输出)、`core/tplus_lock.py`(跨提交执行锁)。原 CLI 读取与 worker 共用执行锁。
|
||||||
|
- V15 / Alembic `e5f607a8b9c0` 增加范围、跳过数、请求键、父批次、请求人和核验结果。未启用时间增量与工资业务导入;完整运行步骤和异常处理见本地恢复手册。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -118,6 +212,82 @@ Redis 黑名单只负责「用户主动登出」。
|
|||||||
bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。
|
bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。
|
||||||
中文一个字符占 3 字节,24 个汉字即到顶——`max_length=20` 这类字符数校验拦不住。
|
中文一个字符占 3 字节,24 个汉字即到顶——`max_length=20` 这类字符数校验拦不住。
|
||||||
|
|
||||||
|
### 操作日志:审计上下文 + 独立提交
|
||||||
|
|
||||||
|
写操作统一经 `services/audit_service.py` 的 `audit()` 上下文管理器留痕(用法见 `api/v1/system/users.py`):
|
||||||
|
|
||||||
|
- 调用形态为 DTO:`audit(session, operator=..., record=AuditRecord(module, action, summary, request_data), meta=...)`;落库条目为 `OperationLogEntry`
|
||||||
|
- 正常退出记成功;抛异常**先 `session.rollback()` 再**记失败(success=0 + 中文错误原因),随后原异常继续传播
|
||||||
|
- 日志**独立 commit**,整体兜底不外抛 —— 方向是「审计可丢,业务不可断」;反向理解:业务失败回滚不会带走失败记录,而那正是审计最需要的
|
||||||
|
- **操作人标量在进入上下文时固化**:失败路径先 rollback 会使 ORM 对象过期,之后再读 id/username 会触发额外查询(账号并发被删时 ObjectDeletedError 还会被兜底吞掉)
|
||||||
|
- 请求元信息(ip / user-agent / method / path)经路由层的 `RequestMetaDep` 传入 service
|
||||||
|
- **commit 之后的任何查询/ORM 读取都要前置**(提到 commit 前或用已固化的局部变量):业务已提交却因后置查询抛错会被记成「失败」,产生与事实相反的审计
|
||||||
|
- 文本列按 `models/operation_log.py` 的长度常量截断;`request_data` 先经**结构化脱敏**(递归按键名整值抹除 password/token 等,不依赖文本模式匹配)再经 `core.logging.scrub` 文本兜底,最后按 **UTF-8 字节边界**截断(encode 带 `errors="replace"` 兜底孤立代理字符)—— 超长/编码异常都会在 MySQL 或 encode 处抛出,被兜底吞掉后表现为「日志莫名消失」
|
||||||
|
- **日志列表与详情两段式**:`operation-logs` 列表排除 `request_data`(TEXT 大字段,随列表返回会让翻页负担随表增长),展开详情走 `GET /operation-logs/{id}` 懒加载
|
||||||
|
- 模块/操作词表在 `core/constants.py`(`MODULE_*` / `ACTION_*`),前端 `utils/constants.ts` 与其对齐
|
||||||
|
|
||||||
|
**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。
|
||||||
|
|
||||||
|
### M2 业务数据的四个关键约定(2026-10-13 定稿,改动前先读)
|
||||||
|
|
||||||
|
- **单据编号**(`services/no_generator.py`):`{前缀}{YYYYMMDD}-{当日4位序号}`,业务 YW / 发货 SH / 回款 HK(`core/constants.py` 的 `NO_PREFIX_*`)。`SELECT MAX ... FOR UPDATE` 锁当日区间防并发重号;残余竞态以唯一键 1062 冲突**重试一次**兜底(无限重试会掩盖死锁)。序号超 4 位按实际位数输出(VARCHAR(32) 余量足够)。
|
||||||
|
- **五项汇总冗余**(`businesses.total_*`):明细/发货/回款的任何写路径统一收口到 `business_service._recalc_totals` 单点重算,禁止各处手写 SUM 回写;发货与回款服务写操作前**必须 `get_by_id(for_update=True)` 锁业务行**(防两模块并发写同一业务的丢失更新)。毛利润率/折算额**不落库**——它们是明细金额 + 时点化阈值的派生值,落库会在成本调整或参数改版后漂移;视图接口(`/gross-profit`)实时算,M3 核算台账才是法定固化点。
|
||||||
|
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
|
||||||
|
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
|
||||||
|
|
||||||
|
### 审批快照与方案版本化(2026-10-20 定稿,改动前先读)
|
||||||
|
|
||||||
|
- **审批单带版本号与内容快照**:`approval_orders.version_no` 按 `(order_type, ref_id)` 从 1 递增,重提先撤回旧在途单、但**旧单快照不覆盖**;`snapshot_json` 统一信封 `{schema_version, order_type, captured_at, data}`,`data` 由 `approval_service.{allocation,settlement,adjustment}_content` 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。
|
||||||
|
- **两个 `version_no` 语义不同,别混用**:`business_secondary_allocations.version_no` = 方案内容第几版,**仅方案内容(参与人+比例)变化时 +1**;`approval_orders.version_no` = 同一对象第几次送审,**仅送审内容变化时 +1**。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。
|
||||||
|
- **送审内容未变不重提**:编辑业务时若**存在在途审批单**且其快照 `data` 与本次送审内容逐键相等(`approval_service.pending_matches_snapshot`,只比 `data` 不比 `captured_at`),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时**不新增方案版本行**(`business_service._plan_changed` 按「人员+比例」集合比较,与行序无关)。
|
||||||
|
- **快照的覆盖范围是「方案 + 钱 + 归属」**(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),**不含产品明细行**。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。
|
||||||
|
- **列表不加载快照列**:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 `defer(ApprovalOrder.snapshot_json)`(与操作日志列表排除 `request_data` 同一理由,参考 `operation_log_repository.list_logs`);详情走 `get_order` 按需读取。
|
||||||
|
- **详情内容来源三态**:`content_source=snapshot`(送审快照)/ `live`(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ `missing`(引用对象不存在且无快照)。**禁止回填历史单快照**——回填等于伪造提交时内容。
|
||||||
|
- **二次分配方案 append-only**:`business_secondary_allocations` 行不物理删除,编辑全量替换与驳回清空只把旧行 `is_active=0`;唯一键为 `(business_id, version_no, personnel_id)`,同一版本内参与人唯一、跨版本可重复。`business_repository.list_allocations(active_only=True)` 是核算与详情回显的唯一取数口径;业务详情 `allocation_history` 返回全部版本供回溯。
|
||||||
|
- **月结提交要锁行**:`settlement_service.submit` 取月结行必须 `for_update=True`——并发提交会让两次 `submit_order` 算出同一个审批单 `version_no`,送审次序失真。
|
||||||
|
- **审批单 `version_no` 有唯一键兜底(V12)**:`uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`——同一对象的第 N 次送审在库里有唯一一行。ORM 侧约束名必须与迁移 DDL 严格一致,否则 autogenerate 误判。
|
||||||
|
- **取版本号必须是「当前读」(`approval_service._next_version`)**,两个禁忌:(a) 不能用普通 `MAX` 查询——请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻,父行锁只串行化写入时刻、**不刷新快照**,MAX 会读到旧值;(b) 不能写成 `select(func.max(...)).with_for_update()`——MySQL 官方明确「Locking clauses are not permitted in queries with aggregate functions or GROUP BY」,聚合上的 FOR UPDATE 语义无保证。正解:锁读该 ref 的现有版本行(`select(version_no)...with_for_update()`),最大值 Python 侧算(同 `no_generator` 套路)。
|
||||||
|
- **版本号冲突的收敛口径**:`submit_order` 内不 catch、不 rollback(红线)。create 路径由外层编号重试循环整体重来(`business_service._NO_RETRY`);编辑/月结提交路径由审计上下文统一回滚,再把 1062 翻译为 400「提交冲突(版本号生成重复),请重试」(同 `param_service` 版本号冲突模板)。唯一键是最后一道防线,不是取号正确性的前提。
|
||||||
|
- **迁移前先修存量**:V11 把存量行 `version_no` 一律置 1,而 V11 前每次重提都新建行——同一 `(order_type, ref_id)` 存在多行 version_no=1,直接加唯一键必报 1062。V12 先重排「组内有重复」的分组(按 id 升序 1..n),其余分组不动(全量重排会改掉前端已展示过的历史版本号)。
|
||||||
|
- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。
|
||||||
|
|
||||||
|
### 工作台口径(M4 ⑧,改动前先读)
|
||||||
|
|
||||||
|
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
|
||||||
|
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定,**已终止业务(status=3)排除**;回款未指定批次 = `receipts.shipment_id IS NULL`。
|
||||||
|
- **待办按角色过滤**:`pending_statuses_for_roles`(approval_service)把当前用户角色映射到在途节点——财务只看待财务复核(status=1)、领导只看待领导审核(status=2)、admin 可看全部;多角色取并集。风险预警仍是全局经营视角,不按人裁剪。
|
||||||
|
- **预警展示上限**:`items` 最多 20 条,`total` 是全部命中数(前端徽标用它,不能用 `items.length`)。
|
||||||
|
- **趋势窗口**:回款趋势先按 `receipt_date` 范围过滤再按 `DATE_FORMAT` 分组,函数列不参与 WHERE,保证索引可用。
|
||||||
|
- **同步降级**:⑥ 未建时 `sync_available=false`,前端显示「模块未启用」——不要返回 0 条冒充「无失败」。
|
||||||
|
- **参数快照一次解析**:`warning.*` 三个阈值在每次请求只 `resolve_snapshot` 一次,多条规则复用。
|
||||||
|
- **导出 SQL 的编码坑**:Windows 下 `alembic upgrade --sql > V*.sql` 默认按控制台 GBK 输出,`test_schema_sync` 按 UTF-8 读取会报 `UnicodeDecodeError`——必须 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1` 前缀。
|
||||||
|
|
||||||
|
### 报表口径(M4 ⑤,改动前先读)
|
||||||
|
|
||||||
|
- **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。
|
||||||
|
- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入,**不要求该人当月有台账行**(只有调整单也要出零绩效成员行);`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。调整单归属跟随该人普通行的实时 `manager_id`;只有经理个人户的经理跟随本人团队。
|
||||||
|
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
|
||||||
|
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块)。
|
||||||
|
- **全公司 5% 的发放口径**:月结只为有台账 bucket 的营销经理各生成一条聚合行(12.5%+5%),**每位经理各计一次 5%**;零成员团队无聚合行、不发 5%。报表的 `aggregate_amount` 优先取冻结聚合行、未生成月结时才用 `manager_team_share` 一次舍入预览;`team_subtotal`/`net_total` = 聚合额 + 调整净额。`team_share_total` 与 `company_share_total` 仅分列展示,两者之和与聚合额的差记入 `aggregate_rounding_diff`(分列各自 q2 会有 0.01~0.02 尾差)。
|
||||||
|
- **对拍边界**:报表应发 = 月结 `total_payable` + 调整单净额(调整单不进月结 items);筛选 `manager_id` 时只汇总展示团队,隐藏金额进 `other_*`,不能与全公司月结总额对拍。
|
||||||
|
- **人员归属按行类型**:普通台账行跟随 `manager_id`;经理个人户行在 `manager.personal.included=1` 时归经理本人团队,且经理成员行**只取个人户金额**(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 `manager_id` 筛选时 `unassigned` 为空(语义是「仅看该经理团队」)。
|
||||||
|
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
|
||||||
|
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);第二块展示单经理 5%、计入次数与合计,对账块展示团队分成/全公司分成(分列)、聚合行舍入差、调整净额,应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
|
||||||
|
- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。
|
||||||
|
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
|
||||||
|
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
|
||||||
|
|
||||||
|
### 面向业务人员的文案(2026-10-23 定稿,改文案前先读)
|
||||||
|
|
||||||
|
系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:
|
||||||
|
|
||||||
|
- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
|
||||||
|
- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。
|
||||||
|
- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
|
||||||
|
- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。
|
||||||
|
- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。
|
||||||
|
- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
||||||
@@ -127,3 +297,13 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
|||||||
**`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。
|
**`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。
|
||||||
|
|
||||||
**改了代码不生效** —— 启动时没加 `--reload`。
|
**改了代码不生效** —— 启动时没加 `--reload`。
|
||||||
|
|
||||||
|
## 系统监控(2026-11-02)
|
||||||
|
|
||||||
|
新增管理员只读接口 `/api/v1/system/monitor/{overview,trends,interfaces,events,diagnostic}`,事件详情为 `/events/{id}`。后台独立线程每 15 秒采集,分钟聚合保留 7 天;配置见 `.env.example` 的 `MONITOR_*`,新增依赖 `psutil`。部署限单个后端应用进程,按项目流程应用 V23 迁移后启动。异常通过 `X-Request-ID` 与脱敏服务端日志关联,`/api/health` 原契约保持不变。
|
||||||
|
|
||||||
|
详细采集口径、故障边界、排查步骤和接口见 [系统监控运维说明](../docs/系统监控运维说明.md)。
|
||||||
|
|
||||||
|
## 角色授权(2026-11-02)
|
||||||
|
|
||||||
|
系统权限统一为“角色配置权限 → 用户分配角色 → 用户继承权限”。V26(`26e7f8a9b0c1`)停用旧个人覆盖和全局制作人入口;工资核算、生成、调整分别检查角色权限,月份实际经办人、生成锁和历史留痕继续保留。接口与迁移边界见 [角色授权使用说明](../docs/角色授权使用说明.md)。
|
||||||
@@ -5,7 +5,7 @@ Revises: ${down_revision | comma,n}
|
|||||||
Create Date: ${create_date}
|
Create Date: ${create_date}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -13,16 +13,22 @@ ${imports if imports else ""}
|
|||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = ${repr(up_revision)}
|
revision: str = ${repr(up_revision)}
|
||||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
down_revision: str | Sequence[str] | None = ${repr(down_revision)}
|
||||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""应用迁移。"""
|
||||||
${upgrades if upgrades else "pass"}
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""回滚迁移。
|
||||||
|
|
||||||
|
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
|
||||||
|
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
|
||||||
|
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
|
||||||
|
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
|
||||||
|
"""
|
||||||
${downgrades if downgrades else "pass"}
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""V17 对齐 V16 已应用工作区约束的完整表名约定。"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "07b8c9d0e1f2"
|
||||||
|
down_revision: str | None = "f6a708b9c0d1"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
RENAMES = (
|
||||||
|
("tplus_source_heads", "uk_tplus_heads_source_entity_id", "uk_tplus_source_heads_source_entity_id"),
|
||||||
|
("tplus_source_heads", "idx_tplus_heads_batch_entity", "idx_tplus_source_heads_batch_entity"),
|
||||||
|
("tplus_source_links", "uk_tplus_links_source_entity_id", "uk_tplus_source_links_source_entity_id"),
|
||||||
|
("tplus_source_links", "uk_tplus_links_entity_target", "uk_tplus_source_links_entity_target"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
"""只重命名固定索引,不改变任何数据。"""
|
||||||
|
for table, old, new in RENAMES:
|
||||||
|
op.execute(f"ALTER TABLE {table} RENAME INDEX {old} TO {new}")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
"""恢复 V16 固定索引名。"""
|
||||||
|
for table, old, new in reversed(RENAMES):
|
||||||
|
op.execute(f"ALTER TABLE {table} RENAME INDEX {new} TO {old}")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""正式工资输入、FIFO明细和月份生成锁。"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "18c9d0e1f2a3"
|
||||||
|
down_revision: str | None = "07b8c9d0e1f2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute('CREATE INDEX idx_performance_ledgers_receipt_id ON performance_ledgers (receipt_id)')
|
||||||
|
op.execute("ALTER TABLE performance_ledgers ADD COLUMN allocation_key VARCHAR(64) NOT NULL DEFAULT '' COMMENT '正式FIFO事件键;历史单笔核算为空串'")
|
||||||
|
op.execute('ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version_personnel')
|
||||||
|
op.execute('ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id, allocation_key)')
|
||||||
|
|
||||||
|
op.execute("CREATE TABLE payroll_months (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n month VARCHAR(7) NOT NULL COMMENT '工资月份', \n maker_id BIGINT UNSIGNED NOT NULL COMMENT '实际工资制作人账号ID', \n maker_name VARCHAR(64) NOT NULL COMMENT '制作人用户名快照', \n generated_at DATETIME COMMENT '首次成功生成时间(不可解锁)', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_months PRIMARY KEY (id), \n CONSTRAINT uk_payroll_months_month UNIQUE (month)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='工资月份控制表(首次生成即锁定分配,审批不解锁)' COLLATE utf8mb4_general_ci")
|
||||||
|
op.execute("CREATE TABLE payroll_inputs (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', \n version INTEGER NOT NULL COMMENT '输入版本', \n source_database VARCHAR(128) NOT NULL COMMENT '源账套或人工来源', \n group_key VARCHAR(128) NOT NULL COMMENT '独立业务平账组', \n fingerprint VARCHAR(64) NOT NULL COMMENT '来源及事实指纹', \n data_json JSON NOT NULL COMMENT '经核实的源数据及人工事实快照', \n requested_by BIGINT UNSIGNED NOT NULL COMMENT '录入人账号ID', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_inputs PRIMARY KEY (id), \n CONSTRAINT uk_payroll_inputs_business_version UNIQUE (business_id, version)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式工资输入版本表' COLLATE utf8mb4_general_ci")
|
||||||
|
op.execute('CREATE INDEX idx_payroll_inputs_source_group ON payroll_inputs (source_database, group_key)')
|
||||||
|
op.execute("CREATE TABLE payroll_allocations (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n ledger_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式台账ID', \n business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', \n line_no INTEGER NOT NULL COMMENT '本台账稳定明细序号', \n input_id BIGINT UNSIGNED NOT NULL COMMENT '所用输入版本ID', \n amount NUMERIC(14, 2) NOT NULL COMMENT '分配回款金额', \n data_json JSON NOT NULL COMMENT '单据、成本、归属、参数和逐段计算依据', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_allocations PRIMARY KEY (id), \n CONSTRAINT uk_payroll_allocations_ledger_line UNIQUE (ledger_id, line_no)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式FIFO分配明细表' COLLATE utf8mb4_general_ci")
|
||||||
|
op.execute('CREATE INDEX idx_payroll_allocations_business_id ON payroll_allocations (business_id)')
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if bind.exec_driver_sql("SELECT COUNT(*) FROM payroll_inputs").scalar() or bind.exec_driver_sql("SELECT COUNT(*) FROM payroll_allocations").scalar():
|
||||||
|
raise RuntimeError("已有正式核算输入或分配记录,拒绝破坏性回退;请先备份并使用应用回退方案")
|
||||||
|
op.execute('ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version_personnel')
|
||||||
|
op.execute("ALTER TABLE performance_ledgers DROP COLUMN allocation_key")
|
||||||
|
op.execute("ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id)")
|
||||||
|
op.execute("DROP INDEX idx_performance_ledgers_receipt_id ON performance_ledgers")
|
||||||
|
op.drop_table('payroll_allocations')
|
||||||
|
op.drop_table('payroll_inputs')
|
||||||
|
op.drop_table('payroll_months')
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""分配调整的原台账与业务分管归属。"""
|
||||||
|
from alembic import op
|
||||||
|
revision: str = "19d0e1f2a3b4"
|
||||||
|
down_revision: str | None = "18c9d0e1f2a3"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute("ALTER TABLE adjustment_orders ADD COLUMN adjustment_type VARCHAR(32) NOT NULL DEFAULT 'GENERAL' COMMENT '调整类型:GENERAL普通 ALLOCATION分配差额'")
|
||||||
|
op.execute("ALTER TABLE adjustment_orders ADD COLUMN source_ledger_id BIGINT UNSIGNED NULL COMMENT '分配差额对应的原台账ID'")
|
||||||
|
op.execute("ALTER TABLE adjustment_orders ADD COLUMN attribution_json JSON NULL COMMENT '原台账业务与分管归属快照'")
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
if op.get_bind().exec_driver_sql("SELECT COUNT(*) FROM adjustment_orders WHERE source_ledger_id IS NOT NULL").scalar():
|
||||||
|
raise RuntimeError("已有分配调整依据,拒绝删除归属信息;请使用应用版本回退")
|
||||||
|
op.drop_column("adjustment_orders", "attribution_json")
|
||||||
|
op.drop_column("adjustment_orders", "source_ledger_id")
|
||||||
|
op.drop_column("adjustment_orders", "adjustment_type")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""自动采集周期、重试与心跳。"""
|
||||||
|
from alembic import op
|
||||||
|
revision: str = "20e1f2a3b4c5"
|
||||||
|
down_revision: str | None = "19d0e1f2a3b4"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute("CREATE TABLE tplus_automation_states (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n source_key VARCHAR(64) NOT NULL COMMENT '源连接身份摘要(不含凭据)', \n source_database VARCHAR(128) NOT NULL COMMENT '源账套', \n scheduled_period VARCHAR(16) COMMENT '最近已排队ISO周', \n root_batch_no VARCHAR(32) COMMENT '本周期原始采集批次', \n current_batch_no VARCHAR(32) COMMENT '当前原始或重试批次', \n retry_attempts INTEGER NOT NULL COMMENT '本周期自动重试次数' DEFAULT '0', \n next_retry_at DATETIME COMMENT '下一次自动重试时间', \n last_success_at DATETIME COMMENT '最近成功采集时间', \n maintenance_at DATETIME COMMENT '最近周期检查时间', \n heartbeat_at DATETIME COMMENT '独立进程心跳时间', \n worker_owner VARCHAR(36) COMMENT '当前持锁进程身份', \n worker_phase VARCHAR(16) NOT NULL COMMENT '进程状态:IDLE/RUNNING/STOPPED' DEFAULT '''STOPPED''', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_tplus_automation_states PRIMARY KEY (id), \n CONSTRAINT uk_tplus_automation_states_source_key UNIQUE (source_key)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+自动采集周期与进程状态(不自动导入正式业务)' COLLATE utf8mb4_general_ci")
|
||||||
|
op.execute("ALTER TABLE tplus_sync_batches ADD COLUMN trigger_type VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '触发来源:MANUAL/SCHEDULED/AUTO_RETRY'")
|
||||||
|
op.execute("ALTER TABLE tplus_sync_batches ADD COLUMN automatic_attempt INTEGER NOT NULL DEFAULT 0 COMMENT '自动重试序号(0为原始任务)'")
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
if op.get_bind().exec_driver_sql("SELECT COUNT(*) FROM tplus_automation_states").scalar():
|
||||||
|
raise RuntimeError("存在调度状态,请先停用并备份后采用应用回退,不删除运行依据")
|
||||||
|
op.drop_column("tplus_sync_batches", "automatic_attempt")
|
||||||
|
op.drop_column("tplus_sync_batches", "trigger_type")
|
||||||
|
op.drop_table("tplus_automation_states")
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""修正进程状态默认值的多余引号,保留已应用V20。"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "21f2a3b4c5d6"
|
||||||
|
down_revision: str | None = "20e1f2a3b4c5"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute("ALTER TABLE tplus_automation_states ALTER COLUMN worker_phase SET DEFAULT 'STOPPED'")
|
||||||
|
op.execute("UPDATE tplus_automation_states SET worker_phase='STOPPED' WHERE worker_phase='''STOPPED'''")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.execute("ALTER TABLE tplus_automation_states ALTER COLUMN worker_phase SET DEFAULT '''STOPPED'''")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""V22 完整角色及个人授权;目录与模板固化于此版本。"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "22a3b4c5d6e7"
|
||||||
|
down_revision: str | None = "21f2a3b4c5d6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
DDL = ["CREATE TABLE authorization_settings (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '单例主键ID' AUTO_INCREMENT, \n\tmaker_user_id BIGINT UNSIGNED COMMENT '现任工资制作人账号ID', \n\trevision INTEGER NOT NULL COMMENT '全局授权版本' DEFAULT '1', \n\tupdated_by BIGINT UNSIGNED COMMENT '最近修改人ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_authorization_settings PRIMARY KEY (id), \n\tCONSTRAINT fk_authorization_settings_maker_user_id FOREIGN KEY(maker_user_id) REFERENCES users (id) ON DELETE RESTRICT, \n\tCONSTRAINT fk_authorization_settings_updated_by FOREIGN KEY(updated_by) REFERENCES users (id) ON DELETE SET NULL\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='授权设置表(单例与并发修改锁)' COLLATE utf8mb4_general_ci;", "CREATE TABLE user_permission_overrides (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tuser_id BIGINT UNSIGNED NOT NULL COMMENT '系统用户ID', \n\tpermission_id BIGINT UNSIGNED NOT NULL COMMENT '权限ID', \n\teffect VARCHAR(8) NOT NULL COMMENT '个人覆盖:ALLOW允许 DENY禁止', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_user_permission_overrides PRIMARY KEY (id), \n\tCONSTRAINT uk_user_permission_overrides_user_permission UNIQUE (user_id, permission_id), \n\tCONSTRAINT fk_user_permission_overrides_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE, \n\tCONSTRAINT fk_user_permission_overrides_permission_id FOREIGN KEY(permission_id) REFERENCES permissions (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='用户权限覆盖表(个人禁止优先)' COLLATE utf8mb4_general_ci;", 'CREATE INDEX idx_user_permission_overrides_permission_id ON user_permission_overrides (permission_id);']
|
||||||
|
PERMISSIONS = [('dashboard:view', '首页:查看', 1, None, 1), ('system:users:view', '用户管理:查看', 1, None, 2), ('system:users:create', '用户管理:新增', 2, 'system:users:view', 3), ('system:users:update', '用户管理:编辑', 2, 'system:users:view', 4), ('system:users:delete', '用户管理:删除', 2, 'system:users:view', 5), ('system:users:status', '用户管理:变更状态', 2, 'system:users:view', 6), ('system:users:password', '用户管理:重置密码', 2, 'system:users:view', 7), ('system:roles:view', '角色管理:查看', 1, None, 8), ('system:roles:create', '角色管理:新增', 2, 'system:roles:view', 9), ('system:roles:update', '角色管理:编辑', 2, 'system:roles:view', 10), ('system:roles:delete', '角色管理:删除', 2, 'system:roles:view', 11), ('system:roles:status', '角色管理:变更状态', 2, 'system:roles:view', 12), ('system:authorization:view', '权限与工资制作人:查看', 1, None, 13), ('system:authorization:update', '权限与工资制作人:编辑', 2, 'system:authorization:view', 14), ('system:business_roles:view', '岗位角色:查看', 1, None, 15), ('system:business_roles:create', '岗位角色:新增', 2, 'system:business_roles:view', 16), ('system:business_roles:update', '岗位角色:编辑', 2, 'system:business_roles:view', 17), ('system:business_roles:delete', '岗位角色:删除', 2, 'system:business_roles:view', 18), ('system:business_roles:status', '岗位角色:变更状态', 2, 'system:business_roles:view', 19), ('system:params:view', '计提参数:查看', 1, None, 20), ('system:params:update', '计提参数:编辑', 2, 'system:params:view', 21), ('system:params:withdraw', '计提参数:撤回参数', 2, 'system:params:view', 22), ('system:params:export', '计提参数:导出', 2, 'system:params:view', 23), ('system:params:preview', '计提参数:预览', 2, 'system:params:view', 24), ('system:settings:view', '公司信息:查看', 1, None, 25), ('system:settings:update', '公司信息:编辑', 2, 'system:settings:view', 26), ('system:logs:view', '日志查询:查看', 1, None, 27), ('base:personnel:view', '人员管理:查看', 1, None, 28), ('base:personnel:create', '人员管理:新增', 2, 'base:personnel:view', 29), ('base:personnel:update', '人员管理:编辑', 2, 'base:personnel:view', 30), ('base:personnel:delete', '人员管理:删除', 2, 'base:personnel:view', 31), ('base:personnel:status', '人员管理:变更状态', 2, 'base:personnel:view', 32), ('base:personnel:regions', '人员管理:维护区域', 2, 'base:personnel:view', 33), ('base:personnel:plans', '人员管理:维护计划', 2, 'base:personnel:view', 34), ('base:products:view', '产品管理:查看', 1, None, 35), ('base:products:create', '产品管理:新增', 2, 'base:products:view', 36), ('base:products:update', '产品管理:编辑', 2, 'base:products:view', 37), ('base:products:delete', '产品管理:删除', 2, 'base:products:view', 38), ('base:products:status', '产品管理:变更状态', 2, 'base:products:view', 39), ('base:customers:view', '客户管理:查看', 1, None, 40), ('base:customers:create', '客户管理:新增', 2, 'base:customers:view', 41), ('base:customers:update', '客户管理:编辑', 2, 'base:customers:view', 42), ('base:customers:delete', '客户管理:删除', 2, 'base:customers:view', 43), ('base:customers:rating', '客户管理:客户评级', 2, 'base:customers:view', 44), ('base:customers:ownership', '客户管理:变更归属', 2, 'base:customers:view', 45), ('business:businesses:view', '业务管理:查看', 1, None, 46), ('business:businesses:create', '业务管理:新增', 2, 'business:businesses:view', 47), ('business:businesses:update', '业务管理:编辑', 2, 'business:businesses:view', 48), ('business:businesses:delete', '业务管理:删除', 2, 'business:businesses:view', 49), ('business:businesses:status', '业务管理:变更状态', 2, 'business:businesses:view', 50), ('business:businesses:ownership', '业务管理:变更归属', 2, 'business:businesses:view', 51), ('business:shipments:view', '发货管理:查看', 1, None, 52), ('business:shipments:create', '发货管理:新增', 2, 'business:shipments:view', 53), ('business:shipments:update', '发货管理:编辑', 2, 'business:shipments:view', 54), ('business:shipments:delete', '发货管理:删除', 2, 'business:shipments:view', 55), ('business:receipts:view', '回款管理:查看', 1, None, 56), ('business:receipts:create', '回款管理:新增', 2, 'business:receipts:view', 57), ('business:receipts:update', '回款管理:编辑', 2, 'business:receipts:view', 58), ('business:receipts:delete', '回款管理:删除', 2, 'business:receipts:view', 59), ('business:ratings:view', '评级标准:查看', 1, None, 60), ('business:ratings:create', '评级标准:新增', 2, 'business:ratings:view', 61), ('business:ratings:update', '评级标准Line truncated
|
||||||
|
DEFAULTS = {'admin': ['adjustments:create', 'adjustments:view', 'approvals:finance', 'approvals:leader', 'approvals:view', 'base:customers:create', 'base:customers:delete', 'base:customers:ownership', 'base:customers:rating', 'base:customers:update', 'base:customers:view', 'base:personnel:create', 'base:personnel:delete', 'base:personnel:plans', 'base:personnel:regions', 'base:personnel:status', 'base:personnel:update', 'base:personnel:view', 'base:products:create', 'base:products:delete', 'base:products:status', 'base:products:update', 'base:products:view', 'business:businesses:create', 'business:businesses:delete', 'business:businesses:ownership', 'business:businesses:status', 'business:businesses:update', 'business:businesses:view', 'business:ratings:create', 'business:ratings:delete', 'business:ratings:update', 'business:ratings:view', 'business:receipts:create', 'business:receipts:delete', 'business:receipts:update', 'business:receipts:view', 'business:shipments:create', 'business:shipments:delete', 'business:shipments:update', 'business:shipments:view', 'dashboard:view', 'payroll:allocation', 'payroll:calculate', 'payroll:prepare', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:generate', 'settlements:submit', 'settlements:view', 'system:authorization:update', 'system:authorization:view', 'system:business_roles:create', 'system:business_roles:delete', 'system:business_roles:status', 'system:business_roles:update', 'system:business_roles:view', 'system:logs:view', 'system:params:export', 'system:params:preview', 'system:params:update', 'system:params:view', 'system:params:withdraw', 'system:roles:create', 'system:roles:delete', 'system:roles:status', 'system:roles:update', 'system:roles:view', 'system:settings:update', 'system:settings:view', 'system:users:create', 'system:users:delete', 'system:users:password', 'system:users:status', 'system:users:update', 'system:users:view', 'tplus:collect', 'tplus:export', 'tplus:facts', 'tplus:import', 'tplus:preview', 'tplus:retry', 'tplus:view'], 'user': ['base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view'], 'marketing_ops': ['base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view'], 'finance': ['adjustments:view', 'approvals:finance', 'approvals:view', 'base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view', 'dashboard:view', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:view', 'system:params:preview', 'system:params:view'], 'leader': ['adjustments:view', 'approvals:leader', 'approvals:view', 'base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view', 'dashboard:view', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:view', 'system:params:preview', 'system:params:view'], 'payroll_maker': ['adjustments:create', 'adjustments:view', 'approvals:view', 'base:customers:create', 'base:customers:delete', 'base:customers:ownership', 'base:customers:rating', 'base:customers:update', 'base:customers:view', 'base:personnel:create', 'base:personnel:delete', 'base:personnel:plans', 'base:personnel:regions', 'base:personnel:status', 'base:personnel:update', 'base:personnel:view', 'base:products:create', 'base:products:delete', 'base:products:status', 'base:products:update', 'base:products:view', 'business:businesses:create', 'business:businesses:delete', 'business:businesses:ownership', 'business:businesses:status', 'business:businesses:update', 'business:businesses:view', 'business:ratings:create', 'business:ratings:delete', 'business:ratings:update', 'business:ratings:view', 'business:receipts:create', 'business:receipts:delete', 'business:receipts:update', 'business:receipts:view', 'business:shipments:create', 'business:shipments:delete', 'business:shipments:update', 'business:shipments:view', 'payroll:allocation', 'payroll:calculate', 'payroll:prepare', 'payroll:view', 'reports:business:view', 'reports:department:view', 'reports:payments:view', 'reports:personal:view', 'settlements:generate', 'settlements:submit', 'settlements:view', 'tplus:cLine truncated
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind=op.get_bind()
|
||||||
|
for sql in DDL:
|
||||||
|
op.execute(sql)
|
||||||
|
for code,name,kind,_parent,order in PERMISSIONS:
|
||||||
|
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES (:code,:name,:kind,:order,1)"),{"code": code,"name": name,"kind": kind,"order": order})
|
||||||
|
for code,_,_,parent,_ in PERMISSIONS:
|
||||||
|
if parent:
|
||||||
|
bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code=:parent SET p.parent_id=parent.id WHERE p.perm_code=:code"),{"parent": parent,"code": code})
|
||||||
|
bind.execute(sa.text("INSERT INTO roles (role_code,role_name,description,sort_order,status) VALUES ('payroll_maker','工资制作人','指定制作人还需在权限设置中绑定,角色本身不能绕过月份锁',5,1)"))
|
||||||
|
for role,codes in DEFAULTS.items():
|
||||||
|
for code in codes:
|
||||||
|
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code=:role"),{"code": code,"role": role})
|
||||||
|
from app.core.config import get_settings
|
||||||
|
maker=get_settings().payroll_maker_user_id
|
||||||
|
if maker is not None and not bind.execute(sa.text("SELECT id FROM users WHERE id=:id AND status=1"),{"id": maker}).scalar():
|
||||||
|
raise RuntimeError("旧工资制作人账号无效,请修正配置后重试迁移")
|
||||||
|
bind.execute(sa.text("INSERT INTO authorization_settings (id,maker_user_id,revision) VALUES (1,:maker,1)"),{"maker": maker})
|
||||||
|
if maker is not None:
|
||||||
|
bind.execute(sa.text("INSERT INTO user_roles (user_id,role_id) SELECT :maker,id FROM roles WHERE role_code='payroll_maker'"),{"maker": maker})
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind=op.get_bind()
|
||||||
|
if bind.execute(sa.text("SELECT revision FROM authorization_settings WHERE id=1")).scalar()!=1 or bind.execute(sa.text("SELECT COUNT(*) FROM user_permission_overrides")).scalar():
|
||||||
|
raise RuntimeError("已发生授权修改,拒绝删除授权依据;请保留数据并回退应用版本")
|
||||||
|
op.drop_table('user_permission_overrides')
|
||||||
|
op.drop_table('authorization_settings')
|
||||||
|
codes=[row[0] for row in PERMISSIONS]
|
||||||
|
for code in codes:
|
||||||
|
bind.execute(sa.text("DELETE rp FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE p.perm_code=:code"),{"code": code})
|
||||||
|
bind.execute(sa.text("DELETE ur FROM user_roles ur JOIN roles r ON r.id=ur.role_id WHERE r.role_code='payroll_maker'"))
|
||||||
|
bind.execute(sa.text("DELETE FROM roles WHERE role_code='payroll_maker'"))
|
||||||
|
for code in reversed(codes):
|
||||||
|
bind.execute(sa.text("DELETE FROM permissions WHERE perm_code=:code"),{"code": code})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""新增运行监控快照、接口聚合与诊断事件。"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision: str = "23b4c5d6e7f8"
|
||||||
|
down_revision: str | None = "22a3b4c5d6e7"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
DDL = [
|
||||||
|
"CREATE TABLE monitor_snapshots (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tminute DATETIME NOT NULL COMMENT '采集分钟,北京时间', \n\tsample_count INTEGER NOT NULL COMMENT '实际采样次数', \n\tresources JSON NOT NULL COMMENT '资源均值与采集范围,不含配置', \n\trequests JSON NOT NULL COMMENT '请求汇总与固定耗时直方图', \n\tCONSTRAINT pk_monitor_snapshots PRIMARY KEY (id), \n\tCONSTRAINT uk_monitor_snapshots_instance_minute UNIQUE (instance_id, minute)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='分钟级运行监控快照' COLLATE utf8mb4_general_ci",
|
||||||
|
"CREATE INDEX idx_monitor_snapshots_minute ON monitor_snapshots (minute)",
|
||||||
|
"CREATE TABLE monitor_request_minutes (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tminute DATETIME NOT NULL COMMENT '请求分钟,北京时间', \n\tmethod VARCHAR(8) NOT NULL COMMENT 'HTTP方法,固定集合', \n\troute VARCHAR(256) NOT NULL COMMENT '路由模板,不含原始地址及参数', \n\tmetrics JSON NOT NULL COMMENT '请求数、状态分类与固定直方图', \n\tCONSTRAINT pk_monitor_request_minutes PRIMARY KEY (id), \n\tCONSTRAINT uk_monitor_request_minutes_endpoint UNIQUE (instance_id, minute, method, route)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='接口分钟级运行统计' COLLATE utf8mb4_general_ci",
|
||||||
|
"CREATE INDEX idx_monitor_request_minutes_minute ON monitor_request_minutes (minute)",
|
||||||
|
"CREATE TABLE monitor_events (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tkind VARCHAR(24) NOT NULL COMMENT '事件类型,固定集合', \n\tcode VARCHAR(64) NOT NULL COMMENT '稳定错误码', \n\tcomponent VARCHAR(128) NOT NULL COMMENT '所属组件或日志模块', \n\tlevel VARCHAR(16) NOT NULL COMMENT '异常级别', \n\tmessage VARCHAR(256) NOT NULL COMMENT '通用说明,不保存内部异常文本', \n\tfingerprint VARCHAR(64) COMMENT '同类健康异常合并键', \n\trequest_id VARCHAR(32) COMMENT '请求关联编号', \n\tmethod VARCHAR(8) COMMENT 'HTTP方法', \n\troute VARCHAR(256) COMMENT '路由模板', \n\tstatus_code INTEGER COMMENT 'HTTP状态码', \n\tduration_ms FLOAT COMMENT '请求耗时,毫秒', \n\tbatch_no VARCHAR(32) COMMENT '相关同步批次编号', \n\tfirst_seen DATETIME NOT NULL COMMENT '首次发生时间', \n\tlast_seen DATETIME NOT NULL COMMENT '最近发生时间', \n\toccurrences INTEGER NOT NULL COMMENT '发生或持续采样次数', \n\tresolved_at DATETIME COMMENT '恢复时间,仅用于健康异常', \n\tCONSTRAINT pk_monitor_events PRIMARY KEY (id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='脱敏诊断事件与异常恢复记录' COLLATE utf8mb4_general_ci",
|
||||||
|
"CREATE INDEX idx_monitor_events_fingerprint ON monitor_events (fingerprint, resolved_at)",
|
||||||
|
"CREATE INDEX idx_monitor_events_last_seen ON monitor_events (last_seen)",
|
||||||
|
"CREATE INDEX idx_monitor_events_request_id ON monitor_events (request_id)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
for statement in DDL:
|
||||||
|
op.execute(text(statement))
|
||||||
|
op.execute(text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:monitor:view','系统监控:查看',1,100,1)"))
|
||||||
|
op.execute(text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:monitor:view' WHERE r.role_code='admin'"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# 应用回滚可保留新增监控表;有历史数据时拒绝隐式删除。
|
||||||
|
bind = op.get_bind()
|
||||||
|
for table in ("monitor_events", "monitor_request_minutes", "monitor_snapshots"):
|
||||||
|
if bind.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar():
|
||||||
|
raise RuntimeError("存在监控历史,拒绝自动删除;请回退应用并保留新增表")
|
||||||
|
for table in ("monitor_events", "monitor_request_minutes", "monitor_snapshots"):
|
||||||
|
op.drop_table(table)
|
||||||
|
bind.execute(text("DELETE rp FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE p.perm_code='system:monitor:view'"))
|
||||||
|
bind.execute(text("DELETE FROM permissions WHERE perm_code='system:monitor:view'"))
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""V24 需求补齐:试用期台账、产品来源及分产品系数参数。"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "24c5d6e7f8a9"
|
||||||
|
down_revision = "23b4c5d6e7f8"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
PROBATION_DDL = "CREATE TABLE probation_deferrals (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n ledger_id BIGINT UNSIGNED NOT NULL COMMENT '原核算台账版本ID', \n personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', \n year VARCHAR(4) NOT NULL COMMENT '原计提年度', \n month VARCHAR(7) NOT NULL COMMENT '本次计提或应付变化月份', \n accrued NUMERIC(14, 2) NOT NULL COMMENT '本次试用期计提额,可负', \n eligible NUMERIC(14, 2) NOT NULL COMMENT '满足起付及权重的本次应付差额,可负', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n CONSTRAINT pk_probation_deferrals PRIMARY KEY (id), \n CONSTRAINT uk_probation_deferrals_ledger_id UNIQUE (ledger_id), \n CONSTRAINT fk_probation_deferrals_ledger_id FOREIGN KEY(ledger_id) REFERENCES performance_ledgers (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期缓发台账(每个正式台账版本一条)' COLLATE utf8mb4_general_ci;\n\nCREATE INDEX idx_probation_deferrals_personnel_id_year ON probation_deferrals (personnel_id, year);\n\nCREATE TABLE probation_payments (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n settlement_id BIGINT UNSIGNED COMMENT '月结ID(当月调整单结清时为空)', \n adjustment_order_id BIGINT UNSIGNED COMMENT '已生成工资后的结清调整单ID', \n personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', \n year VARCHAR(4) NOT NULL COMMENT '原累计年度', \n month VARCHAR(7) NOT NULL COMMENT '本次实际支付月份', \n amount NUMERIC(14, 2) NOT NULL COMMENT '本次补发或扣回金额', \n snapshot_json JSON NOT NULL COMMENT '计提、起付权重、门槛及历史补发依据', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n CONSTRAINT pk_probation_payments PRIMARY KEY (id), \n CONSTRAINT uk_probation_payments_settlement_person_year UNIQUE (settlement_id, personnel_id, year), \n CONSTRAINT uk_probation_payments_adjustment_order_id UNIQUE (adjustment_order_id), \n CONSTRAINT fk_probation_payments_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE, \n CONSTRAINT fk_probation_payments_adjustment_order_id FOREIGN KEY(adjustment_order_id) REFERENCES adjustment_orders (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期年终及后续结清记录(工资生成事务内固化)' COLLATE utf8mb4_general_ci;\n\nCREATE INDEX idx_probation_payments_personnel_id_year ON probation_payments (personnel_id, year);"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"personnel",
|
||||||
|
sa.Column(
|
||||||
|
"probation_start_date", sa.Date(), nullable=True, comment="试用期开始日(含当日)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"personnel",
|
||||||
|
sa.Column(
|
||||||
|
"regularization_date",
|
||||||
|
sa.Date(),
|
||||||
|
nullable=True,
|
||||||
|
comment="转正生效日(当日不再属于试用期)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"personnel",
|
||||||
|
sa.Column(
|
||||||
|
"resignation_date", sa.Date(), nullable=True, comment="离职生效日(当日起不计基本工资)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"personnel",
|
||||||
|
sa.Column(
|
||||||
|
"salary_history_json",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="工资标准、岗位及任职资料生效版本",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"products",
|
||||||
|
sa.Column(
|
||||||
|
"source_trade_json",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="最近有效发货数量、含税成交价及源单据快照",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"products",
|
||||||
|
sa.Column(
|
||||||
|
"quantity_price_manual",
|
||||||
|
sa.SmallInteger(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="数量单价人工保护:1人工维护 0随源更新",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, title in (
|
||||||
|
("normal_payable_sum", "当月正常实付绩效"),
|
||||||
|
("deferred_sum", "当月试用期缓发变化"),
|
||||||
|
("probation_payment_sum", "本月年度补发或扣回"),
|
||||||
|
):
|
||||||
|
op.add_column(
|
||||||
|
"settlement_items",
|
||||||
|
sa.Column(name, sa.Numeric(14, 2), server_default="0", nullable=False, comment=title),
|
||||||
|
)
|
||||||
|
for statement in PROBATION_DDL.split(";"):
|
||||||
|
if statement.strip():
|
||||||
|
op.execute(sa.text(statement))
|
||||||
|
# 仅建立新参数定义,不写任何历史生效值;未覆盖项继续回溯原通用参数。
|
||||||
|
fields = (
|
||||||
|
("first", "个人户首日比例", "DECIMAL", "‰"),
|
||||||
|
("start", "个人户线性阶段起始比例", "DECIMAL", "‰"),
|
||||||
|
("slope", "个人户线性阶段日斜率", "DECIMAL", "‰/天"),
|
||||||
|
("floor", "个人户超期比例", "DECIMAL", "‰"),
|
||||||
|
("boundary", "个人户线性阶段截止天数", "INT", "天"),
|
||||||
|
)
|
||||||
|
definitions = sa.table(
|
||||||
|
"param_definitions",
|
||||||
|
*[
|
||||||
|
sa.column(name, sa.String())
|
||||||
|
for name in (
|
||||||
|
"param_code",
|
||||||
|
"param_name",
|
||||||
|
"param_group",
|
||||||
|
"value_type",
|
||||||
|
"unit",
|
||||||
|
"dimension_type",
|
||||||
|
"value_min",
|
||||||
|
"value_max",
|
||||||
|
"description",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
sa.column("sort_order", sa.Integer()),
|
||||||
|
sa.column("status", sa.Integer()),
|
||||||
|
)
|
||||||
|
op.bulk_insert(
|
||||||
|
definitions,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"param_code": f"coefficient.personal.{field}",
|
||||||
|
"param_name": title,
|
||||||
|
"param_group": "产品系数表",
|
||||||
|
"value_type": kind,
|
||||||
|
"unit": unit,
|
||||||
|
"dimension_type": "PRODUCT_CATEGORY",
|
||||||
|
"value_min": "1" if kind == "INT" else "0",
|
||||||
|
"value_max": "3660" if kind == "INT" else None,
|
||||||
|
"description": "按产品类别维护;未配置时沿用同日期的通用业务员参数。",
|
||||||
|
"sort_order": 52 + index,
|
||||||
|
"status": 1,
|
||||||
|
}
|
||||||
|
for index, (field, title, kind, unit) in enumerate(fields)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
for table, condition in (("personnel", "salary_history_json IS NOT NULL"), ("products", "source_trade_json IS NOT NULL OR quantity_price_manual<>0")):
|
||||||
|
if bind.execute(sa.text(f"SELECT COUNT(*) FROM {table} WHERE {condition}")).scalar():
|
||||||
|
raise RuntimeError("存在人员或产品新增资料,请回退应用并保留业务结构")
|
||||||
|
for table in ("probation_payments", "probation_deferrals"):
|
||||||
|
if bind.execute(sa.text(f"SELECT COUNT(*) FROM {table}")).scalar():
|
||||||
|
raise RuntimeError("存在试用期结算记录,请回退应用并保留业务数据")
|
||||||
|
if bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT COUNT(*) FROM param_values v JOIN param_definitions d ON d.id=v.param_id WHERE d.param_code LIKE 'coefficient.personal.%'"
|
||||||
|
)
|
||||||
|
).scalar():
|
||||||
|
raise RuntimeError("存在已维护的产品系数,请保留新结构")
|
||||||
|
bind.execute(
|
||||||
|
sa.text("DELETE FROM param_definitions WHERE param_code LIKE 'coefficient.personal.%'")
|
||||||
|
)
|
||||||
|
op.drop_table("probation_payments")
|
||||||
|
op.drop_table("probation_deferrals")
|
||||||
|
for name in ("normal_payable_sum", "deferred_sum", "probation_payment_sum"):
|
||||||
|
op.drop_column("settlement_items", name)
|
||||||
|
op.drop_column("products", "quantity_price_manual")
|
||||||
|
op.drop_column("products", "source_trade_json")
|
||||||
|
op.drop_column("personnel", "regularization_date")
|
||||||
|
op.drop_column("personnel", "salary_history_json")
|
||||||
|
op.drop_column("personnel", "resignation_date")
|
||||||
|
op.drop_column("personnel", "probation_start_date")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""V25 将新增系统监控接入统一功能权限,不修改已应用的 V22。"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "25d6e7f8a9b0"
|
||||||
|
down_revision: str | None = "24c5d6e7f8a9"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
for code, name, kind in (
|
||||||
|
("system:monitor:view", "系统监控:查看", 1),
|
||||||
|
("system:monitor:export", "系统监控:导出", 2),
|
||||||
|
):
|
||||||
|
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT :code,:name,:kind,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code=:code)"), {"code": code,"name": name,"kind": kind})
|
||||||
|
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code='admin' AND NOT EXISTS (SELECT 1 FROM role_permissions old WHERE old.role_id=r.id AND old.permission_id=p.id)"), {"code": code})
|
||||||
|
bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:monitor:view' SET p.parent_id=parent.id WHERE p.perm_code='system:monitor:export'"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# V23 也可能已登记目录;回退此兼容版本不删除任何既有授权。
|
||||||
|
return
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""授权归一到角色,停用全局制作人入口,保留旧结构与历史工资依据。"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision: str = "26e7f8a9b0c1"
|
||||||
|
down_revision: str | None = "25d6e7f8a9b0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
STATEMENTS = (
|
||||||
|
"UPDATE authorization_settings SET maker_user_id=NULL, revision=revision+1 WHERE id=1",
|
||||||
|
"UPDATE permissions SET status=0 WHERE perm_code IN ('system:authorization:view','system:authorization:update')",
|
||||||
|
"UPDATE roles SET role_name='工资经办', description='按角色授权维护核算资料、计算工资及生成工资;月份锁定和实际经办人留痕继续生效' WHERE role_code='payroll_maker' AND role_name='工资制作人'",
|
||||||
|
"UPDATE roles SET description='管理业务基础资料,具体功能由角色权限配置决定' WHERE role_code='marketing_ops' AND description LIKE '%审批链%'",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if bind.execute(text("SELECT COUNT(*) FROM user_permission_overrides")).scalar():
|
||||||
|
raise RuntimeError("存在个人权限覆盖,请先整理为等效角色后再升级,避免隐式改变用户权限")
|
||||||
|
for statement in STATEMENTS:
|
||||||
|
op.execute(text(statement))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# 只恢复旧入口,不猜测重新绑定哪个账号。旧版本需由管理员重新指定。
|
||||||
|
op.execute(text("UPDATE permissions SET status=1 WHERE perm_code IN ('system:authorization:view','system:authorization:update')"))
|
||||||
|
op.execute(text("UPDATE roles SET role_name='工资制作人' WHERE role_code='payroll_maker' AND role_name='工资经办'"))
|
||||||
|
op.execute(text("UPDATE authorization_settings SET revision=revision+1 WHERE id=1"))
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""增加源级自动采集开关,不修改活动采集表。"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
revision = "27f8a9b0c1d2"
|
||||||
|
down_revision = "26e7f8a9b0c1"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
if not inspect(op.get_bind()).has_table("tplus_automation_configs"):
|
||||||
|
sql = Path(__file__).resolve().parents[3] / "db/migration/V27__tplus_automation_config.sql"
|
||||||
|
op.get_bind().execute(text(sql.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("tplus_automation_configs")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""V28 计提公式模板定义:模板入库,页面可维护;公式骨架仍受控。"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
revision = "28a9b0c1d2e3"
|
||||||
|
down_revision = "27f8a9b0c1d2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
if not inspect(op.get_bind()).has_table("calc_template_definitions"):
|
||||||
|
sql = Path(__file__).resolve().parents[3] / "db/migration/V28__calc_template_definitions.sql"
|
||||||
|
raw = sql.read_text(encoding="utf-8")
|
||||||
|
# MySQL 不支持 multi-statement;按分号+换行拆分成逐条执行。
|
||||||
|
# JSON_OBJECT 内不含分号+换行,可安全拆分。
|
||||||
|
for statement in raw.split(";\n"):
|
||||||
|
stripped = "\n".join(
|
||||||
|
line for line in statement.splitlines() if not line.lstrip().startswith("--")
|
||||||
|
).strip()
|
||||||
|
if stripped:
|
||||||
|
op.get_bind().execute(text(stripped))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# 模板可能被岗位引用,外键不存在但编码被业务表保存;回退仅删定义表,
|
||||||
|
# 已绑定岗位的编码会回退到代码注册表解析,不猜新数据归属。
|
||||||
|
op.drop_table("calc_template_definitions")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""V29 回款独立人工确认及默认开启开关;不自动确认历史回款。"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
|
||||||
|
revision = "29b0c1d2e3f4"
|
||||||
|
down_revision = "28a9b0c1d2e3"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
sql = Path(__file__).resolve().parents[3] / "db/migration/V29__receipt_manual_confirmation.sql"
|
||||||
|
columns = {column["name"]: column for column in inspect(op.get_bind()).get_columns("receipts")}
|
||||||
|
existing = {name for name in ("confirmation_status", "confirmed_by", "confirmed_at", "confirmed_revision") if name in columns}
|
||||||
|
if existing and len(existing) != 4:
|
||||||
|
raise RuntimeError("回款确认字段不完整,请先核对数据库结构")
|
||||||
|
if existing:
|
||||||
|
expected = {"confirmation_status": ("TINYINT", None, False), "confirmed_by": ("VARCHAR", 64, True),
|
||||||
|
"confirmed_at": ("DATETIME", None, True), "confirmed_revision": ("VARCHAR", 64, True)}
|
||||||
|
for name, (kind, length, nullable) in expected.items():
|
||||||
|
column = columns[name]
|
||||||
|
actual_type = column["type"]
|
||||||
|
if (actual_type.__visit_name__.upper() != kind or getattr(actual_type, "length", None) != length
|
||||||
|
or column["nullable"] != nullable):
|
||||||
|
raise RuntimeError(f"回款确认字段 {name} 与迁移定义不一致,请先核对数据库结构")
|
||||||
|
if str(columns["confirmation_status"]["default"]).strip("'\"") != "0":
|
||||||
|
raise RuntimeError("回款确认状态默认值不是待确认,请先核对数据库结构")
|
||||||
|
for statement in sql.read_text(encoding="utf-8").split(";\n"):
|
||||||
|
clean = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip()
|
||||||
|
if clean and not (existing and clean.startswith("ALTER TABLE receipts ADD COLUMN")):
|
||||||
|
op.get_bind().execute(text(clean))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if bind.scalar(text("SELECT COUNT(*) FROM receipts WHERE confirmed_at IS NOT NULL OR confirmation_status<>0")):
|
||||||
|
raise RuntimeError("已有回款人工确认记录,禁止删除确认依据;请仅回退应用并保留结构")
|
||||||
|
if bind.scalar(text("SELECT COUNT(*) FROM param_values v JOIN param_definitions d ON d.id=v.param_id WHERE d.param_code='receipt.confirmation.enabled'")):
|
||||||
|
raise RuntimeError("已维护人工确认开关版本,禁止删除历史参数")
|
||||||
|
bind.execute(text("DELETE FROM param_definitions WHERE param_code='receipt.confirmation.enabled'"))
|
||||||
|
bind.execute(text("UPDATE permissions SET status=0 WHERE perm_code='business:receipts:confirm'"))
|
||||||
|
for column in ("confirmed_revision", "confirmed_at", "confirmed_by", "confirmation_status"):
|
||||||
|
op.drop_column("receipts", column)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""V30 业务接入与核算准备解耦;无默认回填。"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "30c1d2e3f4a5"
|
||||||
|
down_revision = "29b0c1d2e3f4"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
script = Path(__file__).resolve().parents[3] / "db/migration/V30__tplus_business_sync.sql"
|
||||||
|
for statement in script.read_text(encoding="utf-8").split(";\n"):
|
||||||
|
sql = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip()
|
||||||
|
if sql:
|
||||||
|
op.get_bind().execute(text(sql))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
connection = op.get_bind()
|
||||||
|
for table, condition in (("tplus_business_documents", "1=1"), ("products", "product_category IS NULL"),
|
||||||
|
("customers", "customer_type IS NULL"), ("business_items", "product_category IS NULL"),
|
||||||
|
("shipments", "freight_amount IS NULL")):
|
||||||
|
if connection.scalar(text(f"SELECT COUNT(*) FROM {table} WHERE {condition}")):
|
||||||
|
raise RuntimeError("已有接入或待补资料,请关闭接入并回退兼容版本;不删除业务数据以降级结构")
|
||||||
|
op.drop_table("tplus_business_documents")
|
||||||
|
op.drop_table("tplus_business_sync_configs")
|
||||||
|
for index in ("idx_tplus_source_heads_parent", "idx_tplus_source_heads_customer_date", "idx_tplus_source_heads_date"):
|
||||||
|
op.drop_index(index, "tplus_source_heads")
|
||||||
|
for column in ("source_parent_id", "source_customer_id", "source_date"):
|
||||||
|
op.drop_column("tplus_source_heads", column)
|
||||||
|
op.drop_column("businesses", "integration_json")
|
||||||
|
for table, column, sql_type, default in (("products", "product_category", "VARCHAR(32)", ""),
|
||||||
|
("customers", "customer_type", "VARCHAR(32)", " DEFAULT 'personal'"),
|
||||||
|
("business_items", "product_category", "VARCHAR(32)", ""),
|
||||||
|
("shipments", "freight_amount", "DECIMAL(14,2)", " DEFAULT 0")):
|
||||||
|
connection.execute(text(f"ALTER TABLE {table} MODIFY {column} {sql_type} NOT NULL{default}"))
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""合并业务接入和回款确认迁移分支。"""
|
||||||
|
|
||||||
|
revision = "31d2e3f4a5b6"
|
||||||
|
down_revision = ("29b0c1d2e3f4", "30c1d2e3f4a5")
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""增加默认停止的 T+ 读取服务控制。"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "32e3f4a5b6c7"
|
||||||
|
down_revision = "31d2e3f4a5b6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
sql = Path(__file__).resolve().parents[3] / "db/migration/V32__tplus_runtime_control.sql"
|
||||||
|
for statement in sql.read_text(encoding="utf-8").split(";"):
|
||||||
|
if statement.strip():
|
||||||
|
op.get_bind().execute(text(statement))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
raise RuntimeError("运行控制包含用户设置,请停止服务并备份后采用应用回退")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""将 T+ 运行控制表名统一为复数。"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "33e4f5a6b7c8"
|
||||||
|
down_revision = "32e3f4a5b6c7"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
sql = Path(__file__).resolve().parents[3] / "db/migration/V33__pluralize_tplus_runtime_control.sql"
|
||||||
|
for statement in sql.read_text(encoding="utf-8").split(";"):
|
||||||
|
if statement.strip():
|
||||||
|
op.get_bind().execute(text(statement))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
raise RuntimeError("运行控制表名迁移不可直接回退,请停止服务并按兼容版本处理")
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
"""V7 核算台账/月结/调整单四表与调整单开关种子
|
||||||
|
|
||||||
|
Revision ID: 3bca400421c6
|
||||||
|
Revises: 79bcc5aa98d4
|
||||||
|
Create Date: 2026-10-16 15:54:53.000890
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "3bca400421c6"
|
||||||
|
down_revision: str | Sequence[str] | None = "79bcc5aa98d4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移。"""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"monthly_settlements",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_month",
|
||||||
|
sa.String(length=7),
|
||||||
|
nullable=False,
|
||||||
|
comment="结算月份(YYYY-MM;全表唯一)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_performance",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="绩效金额合计(可负)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_payable",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="应付绩效合计(可负)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"person_count", mysql.TINYINT(), server_default="0", nullable=False, comment="结算人数"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"snapshot_json",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"approval_order_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="提交人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="提交人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column("locked_at", sa.DateTime(), nullable=True, comment="锁定时间(status=3 时回填)"),
|
||||||
|
sa.Column(
|
||||||
|
"locked_by", sa.String(length=64), nullable=True, comment="锁定操作人(冗余固化)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["submitter_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_monthly_settlements_submitter_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_monthly_settlements")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"settlement_month", name=op.f("uk_monthly_settlements_settlement_month")
|
||||||
|
),
|
||||||
|
comment="月度结算表(按月一行;通过即锁定,锁定后走调整单)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"adjustment_orders",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"order_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_month",
|
||||||
|
sa.String(length=7),
|
||||||
|
nullable=False,
|
||||||
|
comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="调整人员ID(personnel.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="调整人员姓名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column("direction", mysql.TINYINT(), nullable=False, comment="方向:1补发 2扣减"),
|
||||||
|
sa.Column(
|
||||||
|
"amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="调整金额(恒正,方向表达语义)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"reason",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=False,
|
||||||
|
comment="扣补说明(必填,审批与追溯锚点)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="提交人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="提交人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_adjustment_orders_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["submitter_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_adjustment_orders_submitter_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_adjustment_orders")),
|
||||||
|
sa.UniqueConstraint("order_no", name=op.f("uk_adjustment_orders_order_no")),
|
||||||
|
comment="调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"settlement_items",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="月结ID(monthly_settlements.id;头删明细随删)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="结算人员ID(personnel.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="结算人员姓名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"role_code", sa.String(length=32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"calc_type",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
nullable=False,
|
||||||
|
comment="核算类型:1单笔回款 2经理团队聚合",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"performance_sum",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"payable_sum",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="应付绩效合计(可负)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"basic_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="月工资标准(personnel.salary_standard 当月快照)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"salary_mode",
|
||||||
|
sa.String(length=16),
|
||||||
|
nullable=True,
|
||||||
|
comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"net_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="净工资 = 绩效±基本工资(按口径;试用期 0)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_probation",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"probation_note",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
comment="试用期说明(年累计超阈值部分年底一次性结算)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"snapshot_json", sa.JSON(), nullable=True, comment="个人明细快照(台账行引用列表)"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_settlement_items_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["settlement_id"],
|
||||||
|
["monthly_settlements.id"],
|
||||||
|
name=op.f("fk_settlement_items_settlement_id"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_settlement_items")),
|
||||||
|
comment="月结明细表(按人固化;支持负数工资;锁定后只读)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"performance_ledgers",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"receipt_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="回款ID(receipts.id;聚合行可为 NULL)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"calc_version",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="核算版本(重算+1,旧行 is_active=0)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_active",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"calc_type",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务编号(冗余固化,列表免联查)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="归属营销人员ID(personnel.id;核算归属锚点)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="归属营销人员姓名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"role_code",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="岗位编码(business_roles.role_code,计提公式锚点)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"customer_type",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户分类:personal/manager_personal/enterprise",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_category",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
|
||||||
|
),
|
||||||
|
sa.Column("ship_date", sa.Date(), nullable=True, comment="发货日期(回款天数基准)"),
|
||||||
|
sa.Column(
|
||||||
|
"receipt_date",
|
||||||
|
sa.Date(),
|
||||||
|
nullable=False,
|
||||||
|
comment="回款日期(参数版本匹配日,裁决 #11)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"payment_days",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"rate_text",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"converted_profit",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="折算毛利润额(可负;混合业务为各行汇总)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"performance_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="绩效金额(可负,裁决:负绩效照算不归零)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"barter_applied",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="以物抵顶折算:1已按 80% 折算 0否",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"weight_r",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
comment="支付权重 R(回款占销售额百分比向下取整)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"payment_weight",
|
||||||
|
sa.Numeric(precision=7, scale=6),
|
||||||
|
nullable=True,
|
||||||
|
comment="支付权重系数 R×R(展示用)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"payable_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_month",
|
||||||
|
sa.String(length=7),
|
||||||
|
nullable=False,
|
||||||
|
comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_probation",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="试用期:1照算不发放 0否",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"missing_cost",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"snapshot_json",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="计算快照(所用参数版本与行级折算明细,重算可回溯)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="所属月结ID(月结生成时回填;NULL=未入结)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"calc_reason",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
comment="核算/重算原因(重算必填留痕)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_performance_ledgers_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["receipt_id"],
|
||||||
|
["receipts.id"],
|
||||||
|
name=op.f("fk_performance_ledgers_receipt_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["settlement_id"],
|
||||||
|
["monthly_settlements.id"],
|
||||||
|
name=op.f("fk_performance_ledgers_settlement_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_performance_ledgers")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"receipt_id", "calc_version", name="uk_performance_ledgers_receipt_id_calc_version"
|
||||||
|
),
|
||||||
|
comment="绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_performance_ledgers_business_id", "performance_ledgers", ["business_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_performance_ledgers_month_active",
|
||||||
|
"performance_ledgers",
|
||||||
|
["settlement_month", "is_active"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_performance_ledgers_month_personnel_type",
|
||||||
|
"performance_ledgers",
|
||||||
|
["settlement_month", "personnel_id", "calc_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_performance_ledgers_personnel_id",
|
||||||
|
"performance_ledgers",
|
||||||
|
["personnel_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"approval_orders",
|
||||||
|
"order_type",
|
||||||
|
existing_type=mysql.TINYINT(),
|
||||||
|
comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
|
||||||
|
existing_comment="审批对象类型:1二次分配方案 2月度结算(预留)",
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
# ---- 种子:调整单审批开关(V6 写法先例;definitions id 19 / values id 22)----
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_definitions",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("param_code", sa.String),
|
||||||
|
sa.column("param_name", sa.String),
|
||||||
|
sa.column("param_group", sa.String),
|
||||||
|
sa.column("value_type", sa.String),
|
||||||
|
sa.column("unit", sa.String),
|
||||||
|
sa.column("dimension_type", sa.String),
|
||||||
|
sa.column("options_text", sa.String),
|
||||||
|
sa.column("default_value", sa.String),
|
||||||
|
sa.column("value_min", sa.String),
|
||||||
|
sa.column("value_max", sa.String),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
sa.column("status", sa.Integer),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"param_code": "approval.adjustment.enabled",
|
||||||
|
"param_name": "调整单审批开关",
|
||||||
|
"param_group": "审批开关",
|
||||||
|
"value_type": "BOOL",
|
||||||
|
"unit": None,
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "0",
|
||||||
|
"value_min": None,
|
||||||
|
"value_max": None,
|
||||||
|
"description": (
|
||||||
|
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
|
||||||
|
"调整单是已锁定月份补发/扣减的唯一通道"
|
||||||
|
),
|
||||||
|
"sort_order": 19,
|
||||||
|
"status": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_values",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("param_id", sa.BigInteger),
|
||||||
|
sa.column("dimension_value", sa.String),
|
||||||
|
sa.column("effective_date", sa.Date),
|
||||||
|
sa.column("value_text", sa.String),
|
||||||
|
sa.column("batch_id", sa.BigInteger),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"param_id": 19,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "0",
|
||||||
|
"batch_id": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移。
|
||||||
|
|
||||||
|
整表回滚只保留 `op.drop_table`(V1/V3/V6 先例:外键唯一支撑索引单独
|
||||||
|
drop 会报 MySQL 1553)。行级种子(param_definitions 19 / param_values 22)
|
||||||
|
的宿主表由 V3 建、本迁移只插行,须显式 DELETE 否则重放 upgrade 撞唯一键。
|
||||||
|
删除顺序:先删引用方(ledger/items),再删被引用方(头表/调整单)。
|
||||||
|
"""
|
||||||
|
op.drop_table("performance_ledgers")
|
||||||
|
op.drop_table("settlement_items")
|
||||||
|
op.drop_table("adjustment_orders")
|
||||||
|
op.drop_table("monthly_settlements")
|
||||||
|
op.alter_column(
|
||||||
|
"approval_orders",
|
||||||
|
"order_type",
|
||||||
|
existing_type=mysql.TINYINT(),
|
||||||
|
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
|
||||||
|
existing_comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
op.execute("DELETE FROM param_values WHERE id = 22")
|
||||||
|
op.execute("DELETE FROM param_definitions WHERE id = 19")
|
||||||
@@ -337,17 +337,19 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""回滚迁移。
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_index("idx_user_roles_role_id", table_name="user_roles")
|
只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
|
||||||
|
原 autogenerate 输出里逐个 drop_index 会失败:idx_user_roles_role_id 与
|
||||||
|
idx_role_permissions_permission_id 分别是外键的唯一支撑索引,
|
||||||
|
MySQL 拒绝单独删除(errno 1553),回滚会中断在半途。
|
||||||
|
|
||||||
|
删除顺序遵循外键依赖:先子表(user_roles / role_permissions / login_logs),
|
||||||
|
再父表(users / roles / permissions)。
|
||||||
|
"""
|
||||||
op.drop_table("user_roles")
|
op.drop_table("user_roles")
|
||||||
op.drop_index("idx_role_permissions_permission_id", table_name="role_permissions")
|
|
||||||
op.drop_table("role_permissions")
|
op.drop_table("role_permissions")
|
||||||
op.drop_index("idx_login_logs_username", table_name="login_logs")
|
|
||||||
op.drop_index("idx_login_logs_create_time", table_name="login_logs")
|
|
||||||
op.drop_table("login_logs")
|
op.drop_table("login_logs")
|
||||||
op.drop_index(op.f("idx_users_phone"), table_name="users")
|
|
||||||
op.drop_table("users")
|
op.drop_table("users")
|
||||||
op.drop_table("roles")
|
op.drop_table("roles")
|
||||||
op.drop_table("permissions")
|
op.drop_table("permissions")
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
"""V6 系统设置/二次分配/审批表与角色参数种子
|
||||||
|
|
||||||
|
Revision ID: 79bcc5aa98d4
|
||||||
|
Revises: 8efc189b131f
|
||||||
|
Create Date: 2026-10-15 15:42:13.467447
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "79bcc5aa98d4"
|
||||||
|
down_revision: str | Sequence[str] | None = "8efc189b131f"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移。"""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"company_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="公司名称(登录页与导出页眉展示)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"logo_path",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_system_settings")),
|
||||||
|
comment="系统设置表(公司信息,全系统单行)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"approval_orders",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"order_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"order_type",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
nullable=False,
|
||||||
|
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"ref_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"refer_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="引用对象单号(冗余固化,列表页免联查)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="提交人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="提交人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["submitter_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_approval_orders_submitter_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_orders")),
|
||||||
|
sa.UniqueConstraint("order_no", name=op.f("uk_approval_orders_order_no")),
|
||||||
|
comment="审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_approval_orders_type_status", "approval_orders", ["order_type", "status"], unique=False
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"approval_steps",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"order_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="审批单ID(approval_orders.id;单删记录随删)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"node",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
nullable=False,
|
||||||
|
comment="审批节点:0撤回留痕 1财务复核 2领导审核",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"action", mysql.TINYINT(), nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"opinion", sa.String(length=255), nullable=True, comment="审批意见(驳回时填写)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="操作人ID(自动通过/系统动作置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="操作人用户名(冗余固化;系统动作记「系统」)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["operator_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_approval_steps_operator_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["order_id"],
|
||||||
|
["approval_orders.id"],
|
||||||
|
name=op.f("fk_approval_steps_order_id"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_steps")),
|
||||||
|
comment="审批节点记录表(append-only;自动通过/撤回均有留痕)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_approval_steps_order_id", "approval_steps", ["order_id"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"business_secondary_allocations",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id;业务删除随删)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="参与业务员ID(personnel.id;被方案引用禁删)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="参与业务员姓名(冗余固化,离职后回显有名)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"ratio",
|
||||||
|
sa.Numeric(precision=5, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_id"],
|
||||||
|
["businesses.id"],
|
||||||
|
name=op.f("fk_business_secondary_allocations_business_id"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_business_secondary_allocations_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_secondary_allocations")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"business_id",
|
||||||
|
"personnel_id",
|
||||||
|
name="uk_business_secondary_allocations_business_personnel",
|
||||||
|
),
|
||||||
|
comment="业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_secondary_allocations_business_id",
|
||||||
|
"business_secondary_allocations",
|
||||||
|
["business_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_secondary_allocations_personnel_id",
|
||||||
|
"business_secondary_allocations",
|
||||||
|
["personnel_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
# ---- 种子数据(生产与开发同源;写法沿用 V1/V3 迁移先例)----
|
||||||
|
|
||||||
|
# 三个审批职能登录角色(2026-10-15 复核审批补充需求;roles 表无 is_builtin
|
||||||
|
# 列,内置保护由 role_service 的 BUILTIN_ROLE_CODES 白名单承担)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"roles",
|
||||||
|
sa.column("role_code", sa.String),
|
||||||
|
sa.column("role_name", sa.String),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role_code": "marketing_ops",
|
||||||
|
"role_name": "营销后勤",
|
||||||
|
"description": "提交与录入(审批链第一节点:月结/二次分配提交)",
|
||||||
|
"sort_order": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_code": "finance",
|
||||||
|
"role_name": "财务",
|
||||||
|
"description": "复核(审批链第二节点)",
|
||||||
|
"sort_order": 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_code": "leader",
|
||||||
|
"role_name": "领导",
|
||||||
|
"description": "审核(审批链第三节点;全览只读)",
|
||||||
|
"sort_order": 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 审批开关参数定义(param_definitions id 沿用 V3 种子的显式 id 方案接续 17/18)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_definitions",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("param_code", sa.String),
|
||||||
|
sa.column("param_name", sa.String),
|
||||||
|
sa.column("param_group", sa.String),
|
||||||
|
sa.column("value_type", sa.String),
|
||||||
|
sa.column("unit", sa.String),
|
||||||
|
sa.column("dimension_type", sa.String),
|
||||||
|
sa.column("options_text", sa.String),
|
||||||
|
sa.column("default_value", sa.String),
|
||||||
|
sa.column("value_min", sa.String),
|
||||||
|
sa.column("value_max", sa.String),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
sa.column("status", sa.Integer),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"param_code": "approval.secondary_allocation.enabled",
|
||||||
|
"param_name": "二次分配审批开关",
|
||||||
|
"param_group": "审批开关",
|
||||||
|
"value_type": "BOOL",
|
||||||
|
"unit": None,
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "0",
|
||||||
|
"value_min": None,
|
||||||
|
"value_max": None,
|
||||||
|
"description": (
|
||||||
|
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
||||||
|
"1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)"
|
||||||
|
),
|
||||||
|
"sort_order": 17,
|
||||||
|
"status": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"param_code": "approval.monthly_settlement.enabled",
|
||||||
|
"param_name": "月度结算审批开关",
|
||||||
|
"param_group": "审批开关",
|
||||||
|
"value_type": "BOOL",
|
||||||
|
"unit": None,
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "0",
|
||||||
|
"value_min": None,
|
||||||
|
"value_max": None,
|
||||||
|
"description": (
|
||||||
|
"预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);"
|
||||||
|
"0=结算生成即生效,1=走财务复核→领导审核审批流"
|
||||||
|
),
|
||||||
|
"sort_order": 18,
|
||||||
|
"status": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 审批开关联动批次条目(batch_id=1 即 V3 的「初始版本」批次;BOOL 无维度行,
|
||||||
|
# 生效日期 2026-01-01 对齐其他支付环节种子)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_values",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("param_id", sa.BigInteger),
|
||||||
|
sa.column("dimension_value", sa.String),
|
||||||
|
sa.column("effective_date", sa.Date),
|
||||||
|
sa.column("value_text", sa.String),
|
||||||
|
sa.column("batch_id", sa.BigInteger),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"param_id": 17,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "0",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"param_id": 18,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "0",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"system_settings",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("company_name", sa.String),
|
||||||
|
sa.column("logo_path", sa.String),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{"id": 1, "company_name": "衍水集团", "logo_path": None},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移。
|
||||||
|
|
||||||
|
整表回滚只保留 `op.drop_table` —— DROP TABLE 会连带删除索引与外键。
|
||||||
|
autogenerate 原样输出的逐个 `op.drop_index` 会失败:idx_approval_steps_order_id
|
||||||
|
与 idx_business_secondary_allocations_personnel_id 分别是外键的唯一支撑索引,
|
||||||
|
单独删除被 MySQL 拒绝(errno 1553),回滚会中断在半途。
|
||||||
|
删除顺序:先删引用方(approval_steps/二次分配),再删被引用方。
|
||||||
|
行级种子反向清理:roles/param_definitions/param_values 三表由 V1/V3 建、
|
||||||
|
本迁移只插行(roles 三角色、参数 17/18 及其版本条目),drop_table
|
||||||
|
不会带走,须显式 DELETE,否则重放 upgrade 时唯一键冲突。
|
||||||
|
"""
|
||||||
|
op.drop_table("business_secondary_allocations")
|
||||||
|
op.drop_table("approval_steps")
|
||||||
|
op.drop_table("approval_orders")
|
||||||
|
op.drop_table("system_settings")
|
||||||
|
op.execute("DELETE FROM param_values WHERE id IN (20, 21)")
|
||||||
|
op.execute("DELETE FROM param_definitions WHERE id IN (17, 18)")
|
||||||
|
op.execute("DELETE FROM roles WHERE role_code IN ('marketing_ops', 'finance', 'leader')")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""v12 审批单版本号唯一键
|
||||||
|
|
||||||
|
Revision ID: 7f4f7b918bc4
|
||||||
|
Revises: f3a8c2d61b47
|
||||||
|
Create Date: 2026-10-21 10:15:00.000000
|
||||||
|
|
||||||
|
给 ``approval_orders`` 加 ``(order_type, ref_id, version_no)`` 唯一键,兜住
|
||||||
|
「同一引用对象的第几次送审」在并发下算重。
|
||||||
|
|
||||||
|
**加键前必须先重排存量**:V11 迁移用 ``ADD COLUMN version_no INT NOT NULL
|
||||||
|
DEFAULT 1`` 把存量行一律置 1,而 V11 之前的每次重提都是**新建行**(撤回
|
||||||
|
只改旧单 status,行不删)。于是任何有 V11 前审批历史的库里,同一
|
||||||
|
``(order_type, ref_id)`` 存在多行且 ``version_no`` 全为 1——直接
|
||||||
|
``ADD CONSTRAINT`` 必报 1062,迁移会停在半途。
|
||||||
|
|
||||||
|
重排只针对「组内有重复」的分组(``COUNT(*) > COUNT(DISTINCT version_no)``),
|
||||||
|
按 id 升序重排为 1..n;其余分组一个字节都不动——全量重排会把前端已展示
|
||||||
|
过的历史版本号(``V{{ version_no }}``)改掉,属于用户可见的历史数据篡改。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "7f4f7b918bc4"
|
||||||
|
down_revision: str | Sequence[str] | None = "f3a8c2d61b47"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_UK = "uk_approval_orders_order_type_ref_version"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""先重排存量重复的版本号,再加唯一键。"""
|
||||||
|
# 重排:只动「组内有重复版本号」的分组,按 id 升序编 1..n。
|
||||||
|
# 用派生表内联而非临时表——同一张临时表被 JOIN 两侧引用会报 1137
|
||||||
|
# "Can't reopen table"。
|
||||||
|
op.execute(
|
||||||
|
"UPDATE approval_orders ao "
|
||||||
|
"JOIN (SELECT id, ROW_NUMBER() OVER ("
|
||||||
|
" PARTITION BY order_type, ref_id ORDER BY id) AS rn "
|
||||||
|
" FROM approval_orders "
|
||||||
|
" WHERE (order_type, ref_id) IN ("
|
||||||
|
" SELECT order_type, ref_id FROM approval_orders "
|
||||||
|
" GROUP BY order_type, ref_id "
|
||||||
|
" HAVING COUNT(*) > COUNT(DISTINCT version_no))) t "
|
||||||
|
"ON ao.id = t.id "
|
||||||
|
"SET ao.version_no = t.rn"
|
||||||
|
)
|
||||||
|
# 幂等 guard:MySQL DDL 隐式提交,若加键成功而版本号未落盘,
|
||||||
|
# 重跑会报 1091(键已存在)。写法沿用 V9 先例。
|
||||||
|
op.execute(
|
||||||
|
"SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
|
||||||
|
"WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' "
|
||||||
|
f"AND CONSTRAINT_NAME = '{_UK}')"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"SET @ddl := IF(@uk_exists = 0, "
|
||||||
|
f"'ALTER TABLE approval_orders ADD CONSTRAINT {_UK} "
|
||||||
|
"UNIQUE (order_type, ref_id, version_no)', 'DO 0')"
|
||||||
|
)
|
||||||
|
op.execute("PREPARE stmt FROM @ddl")
|
||||||
|
op.execute("EXECUTE stmt")
|
||||||
|
op.execute("DEALLOCATE PREPARE stmt")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""删唯一键。唯一键本质是索引,DROP INDEX 不校验数据,存量是否已有
|
||||||
|
重复 version_no 与删索引无关;重排结果也不回滚(版本号保持 1..n 连续)。"""
|
||||||
|
op.execute(f"ALTER TABLE approval_orders DROP INDEX {_UK}")
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
"""业务数据管理七表:业务/业务明细/状态留痕/发货单/发货明细/回款/评级等级配置(M2)
|
||||||
|
|
||||||
|
- businesses:业务编号自动生成(报表 2.4.1「合同编号」),五项汇总冗余列
|
||||||
|
由服务层事务内维护;毛利润相关值不落库(派生值,M3 台账才是固化点)
|
||||||
|
- business_items:行级 product_category 为折算锚点(对照表 #14),指导价
|
||||||
|
人工录入、T+ 同步不覆盖
|
||||||
|
- business_status_logs:append-only,创建时写首条(old=new=1)
|
||||||
|
- shipments/shipment_items:发货日为回款天数基准日(对照表 #1)
|
||||||
|
- receipts:手动指定发货批次(#18);settlement_status 为 M3 核算防重锚点
|
||||||
|
- customer_rating_grades:等级映射可配置,种子四条随迁移初始化
|
||||||
|
|
||||||
|
Revision ID: 8efc189b131f
|
||||||
|
Revises: e1133a7c670a
|
||||||
|
Create Date: 2026-10-13 11:14:17.545320
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "8efc189b131f"
|
||||||
|
down_revision: str | Sequence[str] | None = "e1133a7c670a"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
# 评级等级种子(与 init-dev.sql 双写一致;口径见模型 docstring)
|
||||||
|
_GRADE_SEED = [
|
||||||
|
{"grade_name": "A", "min_score": 90, "max_score": 100, "sort_order": 1, "remark": "优秀"},
|
||||||
|
{"grade_name": "B", "min_score": 75, "max_score": 89, "sort_order": 2, "remark": "良好"},
|
||||||
|
{"grade_name": "C", "min_score": 60, "max_score": 74, "sort_order": 3, "remark": "合格"},
|
||||||
|
{"grade_name": "D", "min_score": 0, "max_score": 59, "sort_order": 4, "remark": "待改进"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移。"""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"customer_rating_grades",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"grade_name", sa.String(length=32), nullable=False, comment="等级名称(如 A/优秀)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"min_score",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
nullable=False,
|
||||||
|
comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"max_score",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
nullable=False,
|
||||||
|
comment="总分上限(展示用)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sort_order",
|
||||||
|
sa.Integer(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="排序(判定顺序,升序)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="说明"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_rating_grades")),
|
||||||
|
sa.UniqueConstraint("grade_name", name=op.f("uk_customer_rating_grades_grade_name")),
|
||||||
|
comment="客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"business_status_logs",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(固化,无外键;删除保护由服务层承担)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"old_status", mysql.TINYINT(), nullable=False, comment="变更前状态(创建首条为 1)"
|
||||||
|
),
|
||||||
|
sa.Column("new_status", mysql.TINYINT(), nullable=False, comment="变更后状态"),
|
||||||
|
sa.Column(
|
||||||
|
"change_reason", sa.String(length=255), nullable=True, comment="变更原因(终止时必填)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="操作人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["operator_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_business_status_logs_operator_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_status_logs")),
|
||||||
|
comment="业务状态变更记录表(append-only)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_status_logs_business_id",
|
||||||
|
"business_status_logs",
|
||||||
|
["business_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_status_logs_create_time",
|
||||||
|
"business_status_logs",
|
||||||
|
["create_time"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"businesses",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"customer_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户ID(customers.id)",
|
||||||
|
),
|
||||||
|
sa.Column("business_date", sa.Date(), nullable=True, comment="业务日期(T+ 同步口径)"),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"owner_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"assistant_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_special",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="特殊业务标记:1是 0否(公司参与等)",
|
||||||
|
),
|
||||||
|
sa.Column("special_note", sa.String(length=255), nullable=True, comment="特殊业务说明"),
|
||||||
|
sa.Column(
|
||||||
|
"total_sale_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_cost_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_ship_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_received_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_freight_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["assistant_personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_businesses_assistant_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["customer_id"],
|
||||||
|
["customers.id"],
|
||||||
|
name=op.f("fk_businesses_customer_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["owner_personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_businesses_owner_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_businesses")),
|
||||||
|
sa.UniqueConstraint("business_no", name=op.f("uk_businesses_business_no")),
|
||||||
|
comment="业务表(业务编号自动生成;T+同步不覆盖业务状态)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_businesses_business_date", "businesses", ["business_date"], unique=False)
|
||||||
|
op.create_index("idx_businesses_customer_id", "businesses", ["customer_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"idx_businesses_owner_personnel_id", "businesses", ["owner_personnel_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(op.f("idx_businesses_source_no"), "businesses", ["source_no"], unique=False)
|
||||||
|
op.create_index("idx_businesses_status", "businesses", ["status"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"business_items",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_name",
|
||||||
|
sa.String(length=128),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品名称(引用产品时冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"spec_model",
|
||||||
|
sa.String(length=128),
|
||||||
|
server_default="",
|
||||||
|
nullable=False,
|
||||||
|
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||||
|
),
|
||||||
|
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
|
||||||
|
sa.Column(
|
||||||
|
"product_category",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="数量(>0)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sale_price",
|
||||||
|
sa.Numeric(precision=14, scale=4),
|
||||||
|
nullable=True,
|
||||||
|
comment="销售单价(元;T+ 同步或人工)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sale_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"cost_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"guided_price",
|
||||||
|
sa.Numeric(precision=14, scale=4),
|
||||||
|
nullable=True,
|
||||||
|
comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_id"],
|
||||||
|
["businesses.id"],
|
||||||
|
name=op.f("fk_business_items_business_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["product_id"],
|
||||||
|
["products.id"],
|
||||||
|
name=op.f("fk_business_items_product_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_items")),
|
||||||
|
comment="业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_items_business_id", "business_items", ["business_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_items_product_category", "business_items", ["product_category"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index("idx_business_items_product_id", "business_items", ["product_id"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"shipments",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"shipment_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id;终态业务禁止新发货)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"ship_date",
|
||||||
|
sa.Date(),
|
||||||
|
nullable=False,
|
||||||
|
comment="发货日期(回款天数基准日,发货日首日=第 1 天)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"freight_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_id"],
|
||||||
|
["businesses.id"],
|
||||||
|
name=op.f("fk_shipments_business_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipments")),
|
||||||
|
sa.UniqueConstraint("shipment_no", name=op.f("uk_shipments_shipment_no")),
|
||||||
|
comment="发货单表(发货日为回款天数基准日,发货日首日计第 1 天)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_shipments_business_id", "shipments", ["business_id"], unique=False)
|
||||||
|
op.create_index("idx_shipments_ship_date", "shipments", ["ship_date"], unique=False)
|
||||||
|
op.create_index(op.f("idx_shipments_source_no"), "shipments", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"receipts",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"receipt_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"shipment_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"receipt_date",
|
||||||
|
sa.Date(),
|
||||||
|
nullable=False,
|
||||||
|
comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="回款金额(≥0;一期禁负数/红字)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_barter",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_id"],
|
||||||
|
["businesses.id"],
|
||||||
|
name=op.f("fk_receipts_business_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["shipment_id"],
|
||||||
|
["shipments.id"],
|
||||||
|
name=op.f("fk_receipts_shipment_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_receipts")),
|
||||||
|
sa.UniqueConstraint("receipt_batch_no", name=op.f("uk_receipts_receipt_batch_no")),
|
||||||
|
comment="回款表(手动指定发货批次;已核算回款禁改禁删)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_receipts_business_id", "receipts", ["business_id"], unique=False)
|
||||||
|
op.create_index("idx_receipts_receipt_date", "receipts", ["receipt_date"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"idx_receipts_settlement_status", "receipts", ["settlement_status"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index("idx_receipts_shipment_id", "receipts", ["shipment_id"], unique=False)
|
||||||
|
op.create_index(op.f("idx_receipts_source_no"), "receipts", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"shipment_items",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"shipment_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="发货单ID(shipments.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="产品ID(products.id;NULL=自由录入)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_name",
|
||||||
|
sa.String(length=128),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品名称(引用产品时冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"spec_model",
|
||||||
|
sa.String(length=128),
|
||||||
|
server_default="",
|
||||||
|
nullable=False,
|
||||||
|
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||||
|
),
|
||||||
|
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
|
||||||
|
sa.Column(
|
||||||
|
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="发货数量(>0)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sale_price",
|
||||||
|
sa.Numeric(precision=14, scale=4),
|
||||||
|
nullable=True,
|
||||||
|
comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["product_id"],
|
||||||
|
["products.id"],
|
||||||
|
name=op.f("fk_shipment_items_product_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["shipment_id"],
|
||||||
|
["shipments.id"],
|
||||||
|
name=op.f("fk_shipment_items_shipment_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipment_items")),
|
||||||
|
comment="发货产品明细表",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_shipment_items_product_id", "shipment_items", ["product_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"idx_shipment_items_shipment_id", "shipment_items", ["shipment_id"], unique=False
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
# 评级等级种子(等级映射可配置;双轨:init-dev.sql 同步写入)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"customer_rating_grades",
|
||||||
|
sa.column("grade_name", sa.String),
|
||||||
|
sa.column("min_score", sa.Numeric),
|
||||||
|
sa.column("max_score", sa.Numeric),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
sa.column("remark", sa.String),
|
||||||
|
),
|
||||||
|
_GRADE_SEED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移。
|
||||||
|
|
||||||
|
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
|
||||||
|
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
|
||||||
|
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
|
||||||
|
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
|
||||||
|
"""
|
||||||
|
# 整表回滚只保留 drop_table(DROP TABLE 连带删除索引与外键);
|
||||||
|
# 逐条 drop_index 会先于 drop_table 摘掉外键支撑索引而报 MySQL 1553
|
||||||
|
op.drop_table("shipment_items")
|
||||||
|
op.drop_table("receipts")
|
||||||
|
op.drop_table("shipments")
|
||||||
|
op.drop_table("business_items")
|
||||||
|
op.drop_table("businesses")
|
||||||
|
op.drop_table("business_status_logs")
|
||||||
|
op.drop_table("customer_rating_grades")
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""v10 工作台预警规则参数
|
||||||
|
|
||||||
|
Revision ID: 90653814a421
|
||||||
|
Revises: ba0217f5091b
|
||||||
|
Create Date: 2026-10-19 17:55:48.892998
|
||||||
|
|
||||||
|
新增风险预警三条参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」):
|
||||||
|
- warning.enabled:总开关(默认 1)
|
||||||
|
- warning.receipt.overdue.days:发货后逾期天数(默认 90)
|
||||||
|
- warning.approval.timeout.days:审批超时天数(默认 3)
|
||||||
|
|
||||||
|
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "90653814a421"
|
||||||
|
down_revision: str | Sequence[str] | None = "ba0217f5091b"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_EFFECTIVE_DATE = date(2026, 10, 19)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""种入三个预警参数定义与生效值(append-only 时点化)。"""
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_definitions",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("param_code", sa.String),
|
||||||
|
sa.column("param_name", sa.String),
|
||||||
|
sa.column("param_group", sa.String),
|
||||||
|
sa.column("value_type", sa.String),
|
||||||
|
sa.column("unit", sa.String),
|
||||||
|
sa.column("dimension_type", sa.String),
|
||||||
|
sa.column("options_text", sa.String),
|
||||||
|
sa.column("default_value", sa.String),
|
||||||
|
sa.column("value_min", sa.String),
|
||||||
|
sa.column("value_max", sa.String),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
sa.column("status", sa.Integer),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"param_code": "warning.enabled",
|
||||||
|
"param_name": "风险预警总开关",
|
||||||
|
"param_group": "预警规则",
|
||||||
|
"value_type": "BOOL",
|
||||||
|
"unit": None,
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "1",
|
||||||
|
"value_min": None,
|
||||||
|
"value_max": None,
|
||||||
|
"description": "工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
|
||||||
|
"sort_order": 21,
|
||||||
|
"status": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"param_code": "warning.receipt.overdue.days",
|
||||||
|
"param_name": "回款逾期天数阈值",
|
||||||
|
"param_group": "预警规则",
|
||||||
|
"value_type": "INT",
|
||||||
|
"unit": "天",
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "90",
|
||||||
|
"value_min": "1",
|
||||||
|
"value_max": None,
|
||||||
|
"description": "发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)",
|
||||||
|
"sort_order": 22,
|
||||||
|
"status": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"param_code": "warning.approval.timeout.days",
|
||||||
|
"param_name": "审批超时天数阈值",
|
||||||
|
"param_group": "预警规则",
|
||||||
|
"value_type": "INT",
|
||||||
|
"unit": "天",
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "3",
|
||||||
|
"value_min": "1",
|
||||||
|
"value_max": None,
|
||||||
|
"description": "待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)",
|
||||||
|
"sort_order": 23,
|
||||||
|
"status": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_values",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("param_id", sa.BigInteger),
|
||||||
|
sa.column("dimension_value", sa.String),
|
||||||
|
sa.column("effective_date", sa.Date),
|
||||||
|
sa.column("value_text", sa.String),
|
||||||
|
sa.column("batch_id", sa.BigInteger),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 26,
|
||||||
|
"param_id": 21,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": _EFFECTIVE_DATE,
|
||||||
|
"value_text": "1",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 27,
|
||||||
|
"param_id": 22,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": _EFFECTIVE_DATE,
|
||||||
|
"value_text": "90",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 28,
|
||||||
|
"param_id": 23,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": _EFFECTIVE_DATE,
|
||||||
|
"value_text": "3",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚种子行(行级种子不会被 drop_table 带走,必须显式删除)。"""
|
||||||
|
op.execute("DELETE FROM param_values WHERE id IN (26, 27, 28)")
|
||||||
|
op.execute("DELETE FROM param_definitions WHERE id IN (21, 22, 23)")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""新增操作日志表
|
||||||
|
|
||||||
|
Revision ID: a45a8087831b
|
||||||
|
Revises: 6165691326d4
|
||||||
|
Create Date: 2026-10-10 10:06:17.435697
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "a45a8087831b"
|
||||||
|
down_revision: str | Sequence[str] | None = "6165691326d4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移。"""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"operation_logs",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"username",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="操作人用户名(冗余存储便于追溯)",
|
||||||
|
),
|
||||||
|
sa.Column("module", sa.String(length=64), nullable=False, comment="所属模块,如 用户管理"),
|
||||||
|
sa.Column(
|
||||||
|
"action", sa.String(length=32), nullable=False, comment="操作类型:新增/修改/删除/导出"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"summary", sa.String(length=255), nullable=False, comment="操作摘要(中文,供人阅读)"
|
||||||
|
),
|
||||||
|
sa.Column("method", sa.String(length=8), nullable=True, comment="HTTP 方法"),
|
||||||
|
sa.Column("path", sa.String(length=255), nullable=True, comment="请求路径"),
|
||||||
|
sa.Column("ip", sa.String(length=45), nullable=True, comment="客户端IP(45位兼容IPv6)"),
|
||||||
|
sa.Column(
|
||||||
|
"user_agent",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
comment="客户端User-Agent(截断存储)",
|
||||||
|
),
|
||||||
|
sa.Column("request_data", sa.Text(), nullable=True, comment="请求参数(已脱敏)"),
|
||||||
|
sa.Column(
|
||||||
|
"success",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="是否成功:1成功 0失败",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"error_message",
|
||||||
|
sa.String(length=500),
|
||||||
|
nullable=True,
|
||||||
|
comment="失败原因(写入方须截断)",
|
||||||
|
),
|
||||||
|
sa.Column("duration_ms", sa.Integer(), nullable=True, comment="处理耗时(毫秒)"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"], ["users.id"], name=op.f("fk_operation_logs_user_id"), ondelete="SET NULL"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_operation_logs")),
|
||||||
|
comment="操作日志表",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_operation_logs_create_time", "operation_logs", ["create_time"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index("idx_operation_logs_module", "operation_logs", ["module"], unique=False)
|
||||||
|
op.create_index("idx_operation_logs_user_id", "operation_logs", ["user_id"], unique=False)
|
||||||
|
op.create_index("idx_operation_logs_username", "operation_logs", ["username"], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移。
|
||||||
|
|
||||||
|
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
|
||||||
|
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
|
||||||
|
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
|
||||||
|
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
|
||||||
|
"""
|
||||||
|
# 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
|
||||||
|
# 逐个 drop_index 会失败:idx_operation_logs_user_id 是外键
|
||||||
|
# fk_operation_logs_user_id 的唯一支撑索引,MySQL 拒绝删除(errno 1553)。
|
||||||
|
op.drop_table("operation_logs")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""V9:business_secondary_allocations 唯一键补齐(存量库自愈)。
|
||||||
|
|
||||||
|
背景:uk(business_id, personnel_id) 是随「就地修改已发布的 V6 迁移」加入的
|
||||||
|
(V6 建表语句里已含、硬编码名);**旧 V6 已应用的存量库 upgrade 到当前
|
||||||
|
head 时不会重放 V6**,拿不到该约束,V8 也未补加——两条升级路径的库结构
|
||||||
|
漂移(test_schema_sync 只比对表/列集合,约束级漂移拦不住)。
|
||||||
|
|
||||||
|
本迁移是**幂等条件式**:约束已存在(新库 / init-dev 重置库 / 已重建的
|
||||||
|
开发库)时跳过,不存在(旧 V6 升级库)时补加,避免重复约束名 errno 1826。
|
||||||
|
语句写成裸 SQL 而非 op.create_unique_constraint——后者在 --sql 离线导出
|
||||||
|
时会无条件输出 DDL,无法携带存在性判断。
|
||||||
|
|
||||||
|
注意:补键时若表内已存在重复 (business_id, personnel_id) 数据,ADD
|
||||||
|
CONSTRAINT 会失败(有意如此:先由人工清理重复数据再升级,不做静默去重)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "ba0217f5091b"
|
||||||
|
down_revision: str | None = "c7d2e5a81f30"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_UK_NAME = "uk_business_secondary_allocations_business_personnel"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""条件式补唯一键(见模块 docstring 的背景与幂等说明)。"""
|
||||||
|
op.execute(
|
||||||
|
"SET @uk_exists := ("
|
||||||
|
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
|
||||||
|
"WHERE CONSTRAINT_SCHEMA = DATABASE() "
|
||||||
|
"AND TABLE_NAME = 'business_secondary_allocations' "
|
||||||
|
f"AND CONSTRAINT_NAME = '{_UK_NAME}'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"SET @ddl := IF(@uk_exists = 0, "
|
||||||
|
f"'ALTER TABLE business_secondary_allocations ADD CONSTRAINT {_UK_NAME} "
|
||||||
|
"UNIQUE (business_id, personnel_id)', "
|
||||||
|
"'DO 0')"
|
||||||
|
)
|
||||||
|
op.execute("PREPARE stmt FROM @ddl")
|
||||||
|
op.execute("EXECUTE stmt")
|
||||||
|
op.execute("DEALLOCATE PREPARE stmt")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""有意不撤键:新库由 V6 建表即带该约束、并非本迁移首创,撤掉会让
|
||||||
|
降级路径产生新的漂移;确需撤销请人工评估后执行 DROP INDEX。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
"""新增岗位角色与计提参数四表
|
||||||
|
|
||||||
|
Revision ID: bfbda9c3191d
|
||||||
|
Revises: a45a8087831b
|
||||||
|
Create Date: 2026-10-12 16:01:48.760620
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "bfbda9c3191d"
|
||||||
|
down_revision: str | Sequence[str] | None = "a45a8087831b"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移。"""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"business_roles",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"role_code",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改",
|
||||||
|
),
|
||||||
|
sa.Column("role_name", sa.String(length=64), nullable=False, comment="岗位名称"),
|
||||||
|
sa.Column(
|
||||||
|
"calc_template",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="计提公式模板编码(以代码内模板注册表为准)",
|
||||||
|
),
|
||||||
|
sa.Column("description", sa.String(length=255), nullable=True, comment="岗位说明"),
|
||||||
|
sa.Column(
|
||||||
|
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:1启用 0禁用",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_builtin",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="是否内置岗位:1内置(禁删禁停) 0自定义",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_roles")),
|
||||||
|
sa.UniqueConstraint("role_code", name=op.f("uk_business_roles_role_code")),
|
||||||
|
comment="业务岗位角色表(与登录角色roles区分,决定计提公式与参数)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"param_definitions",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"param_code",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="参数编码,如 salesperson.rate.slope",
|
||||||
|
),
|
||||||
|
sa.Column("param_name", sa.String(length=64), nullable=False, comment="参数名称"),
|
||||||
|
sa.Column(
|
||||||
|
"param_group",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"value_type",
|
||||||
|
sa.String(length=16),
|
||||||
|
nullable=False,
|
||||||
|
comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"unit", sa.String(length=16), nullable=True, comment="展示单位:‰/%/元/天,无单位为空"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"dimension_type",
|
||||||
|
sa.String(length=32),
|
||||||
|
server_default="NONE",
|
||||||
|
nullable=False,
|
||||||
|
comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"options_text",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
comment="枚举选项(竖线分隔),仅 ENUM 使用",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"default_value",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="系统默认值(Fraction 可解析字符串)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"value_min", sa.String(length=64), nullable=True, comment="取值下限(含),校验用"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"value_max", sa.String(length=64), nullable=True, comment="取值上限(含),校验用"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"description", sa.String(length=255), nullable=True, comment="口径说明(配置页展示)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:1启用 0停用",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_definitions")),
|
||||||
|
sa.UniqueConstraint("param_code", name=op.f("uk_param_definitions_param_code")),
|
||||||
|
comment="计提参数定义表",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"param_change_batches",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"version_no", sa.String(length=32), nullable=False, comment="版本号,如 P20261012-01"
|
||||||
|
),
|
||||||
|
sa.Column("effective_date", sa.Date(), nullable=False, comment="生效日期(业务日期口径)"),
|
||||||
|
sa.Column(
|
||||||
|
"change_type",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
nullable=False,
|
||||||
|
comment="生效方式:1立即生效 2指定日期生效",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="变更说明"),
|
||||||
|
sa.Column(
|
||||||
|
"operator_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="操作人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"item_count", sa.Integer(), server_default="0", nullable=False, comment="变更参数项数"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["operator_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_param_change_batches_operator_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_change_batches")),
|
||||||
|
sa.UniqueConstraint("version_no", name=op.f("uk_param_change_batches_version_no")),
|
||||||
|
comment="参数变更批次表",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"param_values",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"param_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="参数定义ID(param_definitions.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"dimension_value",
|
||||||
|
sa.String(length=32),
|
||||||
|
server_default=sa.text("''"),
|
||||||
|
nullable=False,
|
||||||
|
comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"effective_date", sa.Date(), nullable=False, comment="生效日期(按业务日期时点化取值)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"value_text",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="参数值(Fraction 可解析字符串/枚举码/开关)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"old_value_text", sa.String(length=64), nullable=True, comment="变更前值(留痕)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"batch_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="变更批次ID(param_change_batches.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["batch_id"],
|
||||||
|
["param_change_batches.id"],
|
||||||
|
name=op.f("fk_param_values_batch_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["param_id"],
|
||||||
|
["param_definitions.id"],
|
||||||
|
name=op.f("fk_param_values_param_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_values")),
|
||||||
|
comment="参数版本条目表(append-only,按生效日期时点化取值)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_param_values_batch_id", "param_values", ["batch_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"idx_param_values_effective_date", "param_values", ["effective_date"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_param_values_param_id_dimension_value_effective_date",
|
||||||
|
"param_values",
|
||||||
|
["param_id", "dimension_value", "effective_date"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
# ---- 种子数据(生产与开发同源;口径依据 2026-10-12 业务裁决对照表)----
|
||||||
|
# 显式指定 id:四张表均为本迁移新建、应用时必为空表,id 稳定可控;
|
||||||
|
# param_values 按 param_id 引用定义,无法依赖自增(bulk_insert 取不回自增值)。
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"business_roles",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("role_code", sa.String),
|
||||||
|
sa.column("role_name", sa.String),
|
||||||
|
sa.column("calc_template", sa.String),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
sa.column("is_builtin", sa.Integer),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"role_code": "salesperson",
|
||||||
|
"role_name": "营销员",
|
||||||
|
"calc_template": "TPL_SALESPERSON_DECAY",
|
||||||
|
"description": "业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)",
|
||||||
|
"sort_order": 1,
|
||||||
|
"is_builtin": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"role_code": "sales_manager",
|
||||||
|
"role_name": "营销经理",
|
||||||
|
"calc_template": "TPL_MANAGER_SHARE",
|
||||||
|
"description": "经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
|
||||||
|
"sort_order": 2,
|
||||||
|
"is_builtin": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"role_code": "info_officer",
|
||||||
|
"role_name": "信息员",
|
||||||
|
"calc_template": "TPL_INFO_SALES_FIXED",
|
||||||
|
"description": "信息员绩效:本人销售额×1‰",
|
||||||
|
"sort_order": 3,
|
||||||
|
"is_builtin": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_change_batches",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("version_no", sa.String),
|
||||||
|
sa.column("effective_date", sa.Date),
|
||||||
|
sa.column("change_type", sa.Integer),
|
||||||
|
sa.column("remark", sa.String),
|
||||||
|
sa.column("operator_name", sa.String),
|
||||||
|
sa.column("item_count", sa.Integer),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"version_no": "P20260101-00",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"change_type": 1,
|
||||||
|
"remark": "初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)",
|
||||||
|
"operator_name": "system",
|
||||||
|
"item_count": 19,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_definitions",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("param_code", sa.String),
|
||||||
|
sa.column("param_name", sa.String),
|
||||||
|
sa.column("param_group", sa.String),
|
||||||
|
sa.column("value_type", sa.String),
|
||||||
|
sa.column("unit", sa.String),
|
||||||
|
sa.column("dimension_type", sa.String),
|
||||||
|
sa.column("options_text", sa.String),
|
||||||
|
sa.column("default_value", sa.String),
|
||||||
|
sa.column("value_min", sa.String),
|
||||||
|
sa.column("value_max", sa.String),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"param_code": "salesperson.rate.start",
|
||||||
|
"param_name": "业务员起始计提比例",
|
||||||
|
"param_group": "业务员个人户",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "‰",
|
||||||
|
"dimension_type": "BUSINESS_ROLE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "10",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": None,
|
||||||
|
"description": "线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
|
||||||
|
"sort_order": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"param_code": "salesperson.rate.slope",
|
||||||
|
"param_name": "业务员日递减斜率",
|
||||||
|
"param_group": "业务员个人户",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "‰/天",
|
||||||
|
"dimension_type": "BUSINESS_ROLE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "4/360",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": None,
|
||||||
|
"description": "精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
|
||||||
|
"sort_order": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"param_code": "salesperson.rate.floor",
|
||||||
|
"param_name": "业务员计提下限",
|
||||||
|
"param_group": "业务员个人户",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "‰",
|
||||||
|
"dimension_type": "BUSINESS_ROLE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "4",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": None,
|
||||||
|
"description": "回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
|
||||||
|
"sort_order": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"param_code": "salesperson.days.boundary",
|
||||||
|
"param_name": "线性递减分界天数",
|
||||||
|
"param_group": "业务员个人户",
|
||||||
|
"value_type": "INT",
|
||||||
|
"unit": "天",
|
||||||
|
"dimension_type": "BUSINESS_ROLE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "360",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": None,
|
||||||
|
"description": "≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
|
||||||
|
"sort_order": 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"param_code": "manager.share.team",
|
||||||
|
"param_name": "经理本团队分成比例",
|
||||||
|
"param_group": "经理分成",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "%",
|
||||||
|
"dimension_type": "BUSINESS_ROLE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "12.5",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": "100",
|
||||||
|
"description": "∑本人带领业务员绩效 × 此比例(限本团队)",
|
||||||
|
"sort_order": 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"param_code": "manager.share.company",
|
||||||
|
"param_name": "经理全公司分成比例",
|
||||||
|
"param_group": "经理分成",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "%",
|
||||||
|
"dimension_type": "BUSINESS_ROLE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "5",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": "100",
|
||||||
|
"description": "∑全部业务员绩效 × 此比例(全公司)",
|
||||||
|
"sort_order": 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"param_code": "enterprise.rate",
|
||||||
|
"param_name": "企业户计提比例",
|
||||||
|
"param_group": "企业户",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "‰",
|
||||||
|
"dimension_type": "PRODUCT_CATEGORY",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "3",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": None,
|
||||||
|
"description": "所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
|
||||||
|
"sort_order": 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"param_code": "info.rate",
|
||||||
|
"param_name": "信息员计提比例",
|
||||||
|
"param_group": "信息员",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "‰",
|
||||||
|
"dimension_type": "BUSINESS_ROLE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "1",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": None,
|
||||||
|
"description": "信息员绩效 = 本人销售额 × 此比例",
|
||||||
|
"sort_order": 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"param_code": "prepay.rate",
|
||||||
|
"param_name": "预付款计提比例",
|
||||||
|
"param_group": "支付环节",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "‰",
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "10",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": None,
|
||||||
|
"description": "预付款统一按此比例计提",
|
||||||
|
"sort_order": 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"param_code": "barter.discount",
|
||||||
|
"param_name": "以物抵顶折算比例",
|
||||||
|
"param_group": "支付环节",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "%",
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "80",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": "100",
|
||||||
|
"description": "以物抵顶回款绩效按应发绩效的此比例折算",
|
||||||
|
"sort_order": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"param_code": "payment.start.ratio",
|
||||||
|
"param_name": "支付起付比例",
|
||||||
|
"param_group": "支付环节",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "%",
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "60",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": "100",
|
||||||
|
"description": "单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
|
||||||
|
"sort_order": 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"param_code": "payment.weight.mode",
|
||||||
|
"param_name": "支付权重应用方式",
|
||||||
|
"param_group": "支付环节",
|
||||||
|
"value_type": "ENUM",
|
||||||
|
"unit": None,
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": "FULL|BALANCE",
|
||||||
|
"default_value": "FULL",
|
||||||
|
"value_min": None,
|
||||||
|
"value_max": None,
|
||||||
|
"description": "FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
|
||||||
|
"sort_order": 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"param_code": "product.threshold",
|
||||||
|
"param_name": "折算毛利润额阈值",
|
||||||
|
"param_group": "产品阈值",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "%",
|
||||||
|
"dimension_type": "PRODUCT_CATEGORY",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "30",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": "100",
|
||||||
|
"description": "折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
|
||||||
|
"sort_order": 13,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"param_code": "salary.basic.mode",
|
||||||
|
"param_name": "月工资基本工资口径",
|
||||||
|
"param_group": "月工资口径",
|
||||||
|
"value_type": "ENUM",
|
||||||
|
"unit": None,
|
||||||
|
"dimension_type": "BUSINESS_ROLE",
|
||||||
|
"options_text": "INCLUDE|DEDUCT|EXCLUDE",
|
||||||
|
"default_value": "INCLUDE",
|
||||||
|
"value_min": None,
|
||||||
|
"value_max": None,
|
||||||
|
"description": "INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
|
||||||
|
"sort_order": 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"param_code": "probation.annual.threshold",
|
||||||
|
"param_name": "试用期年累计结算阈值",
|
||||||
|
"param_group": "月工资口径",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "元",
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "18000",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": None,
|
||||||
|
"description": "试用期年累计绩效超过此值部分年底一次性结算",
|
||||||
|
"sort_order": 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"param_code": "probation.salary.basic",
|
||||||
|
"param_name": "试用期基本工资标准",
|
||||||
|
"param_group": "月工资口径",
|
||||||
|
"value_type": "DECIMAL",
|
||||||
|
"unit": "元",
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "3000",
|
||||||
|
"value_min": "0",
|
||||||
|
"value_max": None,
|
||||||
|
"description": "试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
|
||||||
|
"sort_order": 16,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_values",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("param_id", sa.BigInteger),
|
||||||
|
sa.column("dimension_value", sa.String),
|
||||||
|
sa.column("effective_date", sa.Date),
|
||||||
|
sa.column("value_text", sa.String),
|
||||||
|
sa.column("batch_id", sa.BigInteger),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
# 业务员个人户(2026-02-17 折算公式执行日起)
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"param_id": 1,
|
||||||
|
"dimension_value": "salesperson",
|
||||||
|
"effective_date": date(2026, 2, 17),
|
||||||
|
"value_text": "10",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"param_id": 2,
|
||||||
|
"dimension_value": "salesperson",
|
||||||
|
"effective_date": date(2026, 2, 17),
|
||||||
|
"value_text": "4/360",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"param_id": 3,
|
||||||
|
"dimension_value": "salesperson",
|
||||||
|
"effective_date": date(2026, 2, 17),
|
||||||
|
"value_text": "4",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"param_id": 4,
|
||||||
|
"dimension_value": "salesperson",
|
||||||
|
"effective_date": date(2026, 2, 17),
|
||||||
|
"value_text": "360",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
# 经理与信息员(2026-08-13 执行日起)
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"param_id": 5,
|
||||||
|
"dimension_value": "sales_manager",
|
||||||
|
"effective_date": date(2026, 8, 13),
|
||||||
|
"value_text": "12.5",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"param_id": 6,
|
||||||
|
"dimension_value": "sales_manager",
|
||||||
|
"effective_date": date(2026, 8, 13),
|
||||||
|
"value_text": "5",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"param_id": 7,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 2, 17),
|
||||||
|
"value_text": "2",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"param_id": 7,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 8, 13),
|
||||||
|
"value_text": "3",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"param_id": 8,
|
||||||
|
"dimension_value": "info_officer",
|
||||||
|
"effective_date": date(2026, 8, 13),
|
||||||
|
"value_text": "1",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
# 支付环节与通用口径(需规默认值,无历史冲突)
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"param_id": 9,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "10",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"param_id": 10,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "80",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"param_id": 11,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "60",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"param_id": 12,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "FULL",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
# 产品阈值(按产品类别维度)
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"param_id": 13,
|
||||||
|
"dimension_value": "municipal_water",
|
||||||
|
"effective_date": date(2026, 2, 17),
|
||||||
|
"value_text": "30",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"param_id": 13,
|
||||||
|
"dimension_value": "rail_transit",
|
||||||
|
"effective_date": date(2026, 2, 17),
|
||||||
|
"value_text": "20",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
# 月工资口径(按岗位维度)
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"param_id": 14,
|
||||||
|
"dimension_value": "salesperson",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "INCLUDE",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"param_id": 14,
|
||||||
|
"dimension_value": "sales_manager",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "DEDUCT",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"param_id": 15,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "18000",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"param_id": 16,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "3000",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移。
|
||||||
|
|
||||||
|
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
|
||||||
|
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
|
||||||
|
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
|
||||||
|
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
|
||||||
|
"""
|
||||||
|
# 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
|
||||||
|
# 原 autogenerate 输出的逐个 drop_index 会失败:组合索引最左列 param_id 与
|
||||||
|
# idx_param_values_batch_id 分别是两个外键的唯一支撑索引(MySQL errno 1553)。
|
||||||
|
# 删除顺序:先删引用方 param_values,再删被引用方。
|
||||||
|
op.drop_table("param_values")
|
||||||
|
op.drop_table("param_change_batches")
|
||||||
|
op.drop_table("param_definitions")
|
||||||
|
op.drop_table("business_roles")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""v13 客户评级落库
|
||||||
|
|
||||||
|
Revision ID: c3a9e1d7b2f4
|
||||||
|
Revises: 7f4f7b918bc4
|
||||||
|
Create Date: 2026-10-23 10:30:00.000000
|
||||||
|
|
||||||
|
评级计算器从独立工具页并入客户管理(行内弹窗),评分由「不落库」改为
|
||||||
|
保存为客户档案属性:``rating_score`` 总分 + ``rating_time`` 评级时间。
|
||||||
|
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
|
||||||
|
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
|
||||||
|
|
||||||
|
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
|
||||||
|
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
|
||||||
|
的等级随之更新),因此本迁移只落总分与评级时间两列。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c3a9e1d7b2f4"
|
||||||
|
down_revision: str | Sequence[str] | None = "7f4f7b918bc4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""customers 加评级两列,紧跟 remark 之后保持与 ORM 声明同序。
|
||||||
|
|
||||||
|
用 execute 手写而非 op.add_column:后者不带 AFTER 子句,两列会落到
|
||||||
|
表尾(T+ 预留列之后),与 ORM/init-dev 的声明顺序不一致。
|
||||||
|
"""
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE customers "
|
||||||
|
"ADD COLUMN rating_score TINYINT COMMENT "
|
||||||
|
"'评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' "
|
||||||
|
"AFTER remark"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE customers "
|
||||||
|
"ADD COLUMN rating_time DATETIME COMMENT "
|
||||||
|
"'最近评级时间(评级计算器保存评分时刷新;人工维护字段)' "
|
||||||
|
"AFTER rating_score"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""删除评级两列。评分数据随列删除,评级无下游引用、无悬空风险。"""
|
||||||
|
op.drop_column("customers", "rating_time")
|
||||||
|
op.drop_column("customers", "rating_score")
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""V8:业务答复口径落实(2026-10-19 回执)。
|
||||||
|
|
||||||
|
四件事,全部源于《待澄清问题清单》2026-10-19 答复:
|
||||||
|
|
||||||
|
1. 审批开关默认值翻转(#5/#7 答复「默认开启」):param_definitions 的
|
||||||
|
default_value 更新为 '1';param_values **插入生效日 2026-10-19 的新行**
|
||||||
|
而非改历史行——参数体系时点化(按业务日期回溯),直接改种子行会篡改
|
||||||
|
10-19 之前的口径历史。
|
||||||
|
2. 撤销试用期统一工资参数(#16 答复「按人员档案逐人维护」):删除
|
||||||
|
probation.salary.basic 的定义与版本行;月结基本工资已直接读
|
||||||
|
personnel.salary_standard(该参数自 M3 ④ 实现起即无业务消费点)。
|
||||||
|
3. 新增经理个人户聚合基数开关(#12 答复「作为参数配置,手动调整是否
|
||||||
|
纳入」):manager.personal.included,默认 0=不计入(维持暂口径)。
|
||||||
|
4. 绩效台账唯一键加 personnel_id(二次分配分摊铺路,澄清 #1 答复
|
||||||
|
「分配绩效」):分摊后同一回款同版本按人各落一行(负责人余额行 +
|
||||||
|
参与人比例行),uk(receipt_id, calc_version) 会撞;扩为三列后聚合行
|
||||||
|
(receipt_id NULL)依旧不受 MySQL 唯一索引约束。
|
||||||
|
|
||||||
|
迁移 downgrade 只保留 drop_table/行级种子反向 DELETE(V6 教训:逐个
|
||||||
|
drop_index 会撞外键支撑索引 errno 1553)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "c7d2e5a81f30"
|
||||||
|
down_revision: str | None = "3bca400421c6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
|
||||||
|
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
|
||||||
|
op.execute("UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18)")
|
||||||
|
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_values",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("param_id", sa.BigInteger),
|
||||||
|
sa.column("dimension_value", sa.String),
|
||||||
|
sa.column("effective_date", sa.Date),
|
||||||
|
sa.column("value_text", sa.String),
|
||||||
|
sa.column("batch_id", sa.BigInteger),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"param_id": 17,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 10, 19),
|
||||||
|
"value_text": "1",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"param_id": 18,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 10, 19),
|
||||||
|
"value_text": "1",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 撤销试用期统一工资参数(#16:按人员档案 personnel.salary_standard 逐人维护)
|
||||||
|
op.execute("DELETE FROM param_values WHERE id = 19")
|
||||||
|
op.execute("DELETE FROM param_definitions WHERE id = 16")
|
||||||
|
|
||||||
|
# 3. 经理个人户聚合基数开关(#12:参数化,默认不计入)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_definitions",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("param_code", sa.String),
|
||||||
|
sa.column("param_name", sa.String),
|
||||||
|
sa.column("param_group", sa.String),
|
||||||
|
sa.column("value_type", sa.String),
|
||||||
|
sa.column("unit", sa.String),
|
||||||
|
sa.column("dimension_type", sa.String),
|
||||||
|
sa.column("options_text", sa.String),
|
||||||
|
sa.column("default_value", sa.String),
|
||||||
|
sa.column("value_min", sa.String),
|
||||||
|
sa.column("value_max", sa.String),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
sa.column("status", sa.Integer),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"param_code": "manager.personal.included",
|
||||||
|
"param_name": "经理个人户计入聚合基数",
|
||||||
|
"param_group": "经理分成",
|
||||||
|
"value_type": "BOOL",
|
||||||
|
"unit": None,
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "0",
|
||||||
|
"value_min": None,
|
||||||
|
"value_max": None,
|
||||||
|
"description": (
|
||||||
|
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
|
||||||
|
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
|
||||||
|
"0=不计入(默认,暂口径) 1=计入"
|
||||||
|
),
|
||||||
|
"sort_order": 20,
|
||||||
|
"status": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"param_values",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("param_id", sa.BigInteger),
|
||||||
|
sa.column("dimension_value", sa.String),
|
||||||
|
sa.column("effective_date", sa.Date),
|
||||||
|
sa.column("value_text", sa.String),
|
||||||
|
sa.column("batch_id", sa.BigInteger),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 25,
|
||||||
|
"param_id": 20,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "0",
|
||||||
|
"batch_id": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 台账唯一键扩为三列(分摊拆行:同回款同版本按人一行)
|
||||||
|
# 顺序要紧:旧 uk 是 fk_performance_ledgers_receipt_id 的唯一支撑索引,
|
||||||
|
# 先删会撞 errno 1553(外键无支撑索引);先建新键(receipt_id 仍居最左)
|
||||||
|
# 再做替换,两键短暂并存无冲突。
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uk_performance_ledgers_receipt_id_calc_version_personnel",
|
||||||
|
"performance_ledgers",
|
||||||
|
["receipt_id", "calc_version", "personnel_id"],
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"uk_performance_ledgers_receipt_id_calc_version",
|
||||||
|
"performance_ledgers",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""反向:唯一键复原 + 种子行级回退(顺序与 upgrade 相反)。
|
||||||
|
|
||||||
|
试用期参数(definitions 16 / values 19)的原始种子在 V3,按 V6 downgrade
|
||||||
|
的行级反向惯例显式重插,保证可重放。
|
||||||
|
"""
|
||||||
|
# 与 upgrade 同因的顺序:先建回旧键再删新键(旧键支撑 receipts 外键)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uk_performance_ledgers_receipt_id_calc_version",
|
||||||
|
"performance_ledgers",
|
||||||
|
["receipt_id", "calc_version"],
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"uk_performance_ledgers_receipt_id_calc_version_personnel",
|
||||||
|
"performance_ledgers",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("DELETE FROM param_values WHERE id = 25")
|
||||||
|
op.execute("DELETE FROM param_definitions WHERE id = 20")
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, "
|
||||||
|
"unit, dimension_type, options_text, default_value, value_min, value_max, "
|
||||||
|
"description, sort_order, status) VALUES "
|
||||||
|
"(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', "
|
||||||
|
"'元', 'NONE', NULL, '3000', '0', NULL, "
|
||||||
|
"'试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16, 1)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"INSERT INTO param_values (id, param_id, dimension_value, effective_date, "
|
||||||
|
"value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1)"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
|
||||||
|
op.execute("UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)")
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""T+ 只读同步批次、错误、staging 和增量水位。
|
||||||
|
|
||||||
|
Revision ID: d4e5f6a7b8c9
|
||||||
|
Revises: c3a9e1d7b2f4
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.mysql import BIGINT
|
||||||
|
|
||||||
|
revision: str = "d4e5f6a7b8c9"
|
||||||
|
down_revision: str | Sequence[str] | None = "c3a9e1d7b2f4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""增加 T+ 源更新时间和同步审计/staging 表。"""
|
||||||
|
for table in ("personnel", "products", "customers", "businesses", "shipments", "receipts"):
|
||||||
|
op.execute(
|
||||||
|
f"ALTER TABLE {table} ADD COLUMN source_updated_at DATETIME "
|
||||||
|
"COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||||
|
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||||
|
sa.Column("sync_mode", sa.String(length=16), nullable=False, comment="同步模式:FULL全量 INCREMENTAL增量"),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False, comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED"),
|
||||||
|
sa.Column("source_database", sa.String(length=128), nullable=True, comment="T+ 源数据库名"),
|
||||||
|
sa.Column("started_at", sa.DateTime(), nullable=False, comment="开始读取时间"),
|
||||||
|
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="结束时间"),
|
||||||
|
sa.Column("watermark_from", sa.DateTime(), nullable=True, comment="增量起始水位"),
|
||||||
|
sa.Column("watermark_to", sa.DateTime(), nullable=True, comment="本批次结束水位"),
|
||||||
|
sa.Column("source_record_count", sa.Integer(), nullable=False, server_default="0", comment="源记录数"),
|
||||||
|
sa.Column("staged_record_count", sa.Integer(), nullable=False, server_default="0", comment="写入 staging 的记录数"),
|
||||||
|
sa.Column("failed_record_count", sa.Integer(), nullable=False, server_default="0", comment="失败记录数"),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=True, comment="批次错误摘要"),
|
||||||
|
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||||
|
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_batches"),
|
||||||
|
sa.UniqueConstraint("batch_no", name="uk_tplus_sync_batches_batch_no"),
|
||||||
|
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步批次表(记录读取范围、结果和对账统计)",
|
||||||
|
)
|
||||||
|
op.create_index("idx_tplus_sync_batches_status", "tplus_sync_batches", ["status"])
|
||||||
|
op.create_index("idx_tplus_sync_batches_started_at", "tplus_sync_batches", ["started_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"tplus_sync_errors",
|
||||||
|
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||||
|
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||||
|
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||||
|
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
|
||||||
|
sa.Column("source_id", sa.String(length=64), nullable=True, comment="T+ 源主键"),
|
||||||
|
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
|
||||||
|
sa.Column("error_code", sa.String(length=32), nullable=False, comment="错误编码"),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=False, comment="错误说明"),
|
||||||
|
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0", comment="已重试次数"),
|
||||||
|
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||||
|
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_errors"),
|
||||||
|
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步错误表(记录失败行和重试信息)",
|
||||||
|
)
|
||||||
|
op.create_index("idx_tplus_sync_errors_batch_no", "tplus_sync_errors", ["batch_no"])
|
||||||
|
op.create_index("idx_tplus_sync_errors_entity_type", "tplus_sync_errors", ["entity_type"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"tplus_sync_records",
|
||||||
|
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||||
|
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||||
|
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||||
|
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
|
||||||
|
sa.Column("source_id", sa.String(length=64), nullable=False, comment="T+ 源主键"),
|
||||||
|
sa.Column("source_no", sa.String(length=64), nullable=True, comment="T+ 原始编号"),
|
||||||
|
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
|
||||||
|
sa.Column("source_updated_at", sa.DateTime(), nullable=True, comment="T+ 源数据更新时间"),
|
||||||
|
sa.Column("row_hash", sa.String(length=64), nullable=False, comment="原始行 SHA-256"),
|
||||||
|
sa.Column("state", sa.String(length=16), nullable=False, server_default="PENDING", comment="状态:PENDING/APPLIED/SKIPPED/ERROR"),
|
||||||
|
sa.Column("payload_json", sa.JSON(), nullable=False, comment="原始行 JSON"),
|
||||||
|
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||||
|
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_records"),
|
||||||
|
sa.UniqueConstraint("entity_type", "source_id", "row_hash", name="uk_tplus_sync_records_entity_source_hash"),
|
||||||
|
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)",
|
||||||
|
)
|
||||||
|
op.create_index("idx_tplus_sync_records_batch_no", "tplus_sync_records", ["batch_no"])
|
||||||
|
op.create_index("idx_tplus_sync_records_source_no", "tplus_sync_records", ["source_no"])
|
||||||
|
op.create_index("idx_tplus_sync_records_state", "tplus_sync_records", ["state"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"tplus_sync_watermarks",
|
||||||
|
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||||
|
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||||
|
sa.Column("last_source_updated_at", sa.DateTime(), nullable=True, comment="最近源更新时间"),
|
||||||
|
sa.Column("last_source_id", sa.String(length=64), nullable=True, comment="同一时间点的最后源主键"),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False, server_default="READY", comment="状态:READY/BLOCKED"),
|
||||||
|
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||||
|
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_watermarks"),
|
||||||
|
sa.UniqueConstraint("entity_type", name="uk_tplus_sync_watermarks_entity_type"),
|
||||||
|
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 增量同步水位表",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""删除 T+ 同步支撑表和源更新时间列。"""
|
||||||
|
op.drop_table("tplus_sync_watermarks")
|
||||||
|
op.drop_index("idx_tplus_sync_records_state", table_name="tplus_sync_records")
|
||||||
|
op.drop_index("idx_tplus_sync_records_source_no", table_name="tplus_sync_records")
|
||||||
|
op.drop_index("idx_tplus_sync_records_batch_no", table_name="tplus_sync_records")
|
||||||
|
op.drop_table("tplus_sync_records")
|
||||||
|
op.drop_index("idx_tplus_sync_errors_entity_type", table_name="tplus_sync_errors")
|
||||||
|
op.drop_index("idx_tplus_sync_errors_batch_no", table_name="tplus_sync_errors")
|
||||||
|
op.drop_table("tplus_sync_errors")
|
||||||
|
op.drop_index("idx_tplus_sync_batches_started_at", table_name="tplus_sync_batches")
|
||||||
|
op.drop_index("idx_tplus_sync_batches_status", table_name="tplus_sync_batches")
|
||||||
|
op.drop_table("tplus_sync_batches")
|
||||||
|
for table in ("receipts", "shipments", "businesses", "customers", "products", "personnel"):
|
||||||
|
op.drop_column(table, "source_updated_at")
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
"""基础数据管理六表:人员/辐射区域/拓展计划/产品/客户/归属变更留痕(M2)
|
||||||
|
|
||||||
|
- personnel / personnel_regions / personnel_plans:人员档案及其 1:N 子表
|
||||||
|
- products:产品主数据(类别锚定折算阈值参数)
|
||||||
|
- customers:客户档案(公司名称+负责人为业务唯一标识)
|
||||||
|
- ownership_change_logs:归属变更留痕(append-only,不回溯历史)
|
||||||
|
- 六张表的 T+ 同步预留列(source_no 等)全部可空、不设唯一键(对照表 #26)
|
||||||
|
|
||||||
|
Revision ID: e1133a7c670a
|
||||||
|
Revises: bfbda9c3191d
|
||||||
|
Create Date: 2026-10-13 09:36:02.219248
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "e1133a7c670a"
|
||||||
|
down_revision: str | Sequence[str] | None = "bfbda9c3191d"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移。"""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"products",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column("product_name", sa.String(length=128), nullable=False, comment="产品名称"),
|
||||||
|
sa.Column(
|
||||||
|
"spec_model",
|
||||||
|
sa.String(length=128),
|
||||||
|
server_default="",
|
||||||
|
nullable=False,
|
||||||
|
comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
|
||||||
|
),
|
||||||
|
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
|
||||||
|
sa.Column(
|
||||||
|
"stock_quantity",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="数量(T+ 同步原样存储,语义待 IT 确认)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sale_price",
|
||||||
|
sa.Numeric(precision=14, scale=4),
|
||||||
|
nullable=True,
|
||||||
|
comment="销售单价(元,T+ 同步)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_category",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:1启用 0停用",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_products")),
|
||||||
|
sa.UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"),
|
||||||
|
comment="产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(op.f("idx_products_source_no"), "products", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"ownership_change_logs",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"owner_type", mysql.TINYINT(), nullable=False, comment="归属类型:1客户归属 2业务归属"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"customer_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="客户ID(固化,owner_type=1 时非空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="业务ID(固化,owner_type=2 时非空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"old_owner_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="变更前负责营销人员ID(固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"new_owner_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="变更后负责营销人员ID(固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"old_assistant_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="变更前协助营销人员ID(固化,仅业务归属使用)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"new_assistant_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="变更后协助营销人员ID(固化,仅业务归属使用)",
|
||||||
|
),
|
||||||
|
sa.Column("change_reason", sa.String(length=255), nullable=True, comment="变更原因"),
|
||||||
|
sa.Column(
|
||||||
|
"operator_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="操作人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["operator_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_ownership_change_logs_operator_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_ownership_change_logs")),
|
||||||
|
comment="归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_ownership_change_logs_customer_id"),
|
||||||
|
"ownership_change_logs",
|
||||||
|
["customer_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_ownership_change_logs_business_id"),
|
||||||
|
"ownership_change_logs",
|
||||||
|
["business_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_ownership_change_logs_create_time"),
|
||||||
|
"ownership_change_logs",
|
||||||
|
["create_time"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"personnel",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column("personnel_name", sa.String(length=64), nullable=False, comment="姓名"),
|
||||||
|
sa.Column(
|
||||||
|
"business_role_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"manager_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)",
|
||||||
|
),
|
||||||
|
sa.Column("phone", sa.String(length=20), nullable=True, comment="联系电话"),
|
||||||
|
sa.Column("hire_date", sa.Date(), nullable=True, comment="入职日期"),
|
||||||
|
sa.Column(
|
||||||
|
"is_probation",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"salary_standard",
|
||||||
|
sa.Numeric(precision=12, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="月工资标准(元;T+同步不覆盖)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_role_id"],
|
||||||
|
["business_roles.id"],
|
||||||
|
name=op.f("fk_personnel_business_role_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["manager_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_personnel_manager_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel")),
|
||||||
|
comment="营销人员表(T+同步不覆盖岗位与工资标准)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_personnel_business_role_id"), "personnel", ["business_role_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(op.f("idx_personnel_manager_id"), "personnel", ["manager_id"], unique=False)
|
||||||
|
op.create_index(op.f("idx_personnel_source_no"), "personnel", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"customers",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column("company_name", sa.String(length=128), nullable=False, comment="公司名称"),
|
||||||
|
sa.Column(
|
||||||
|
"owner_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户负责人姓名(客户公司负责人的人名,非系统人员)",
|
||||||
|
),
|
||||||
|
sa.Column("contact_person", sa.String(length=64), nullable=True, comment="联系人"),
|
||||||
|
sa.Column("contact_phone", sa.String(length=20), nullable=True, comment="联系电话"),
|
||||||
|
sa.Column("address", sa.String(length=255), nullable=True, comment="地址"),
|
||||||
|
sa.Column("industry", sa.String(length=64), nullable=True, comment="所属行业(自由文本)"),
|
||||||
|
sa.Column(
|
||||||
|
"customer_type",
|
||||||
|
sa.String(length=32),
|
||||||
|
server_default="personal",
|
||||||
|
nullable=False,
|
||||||
|
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"owner_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["owner_personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_customers_owner_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_customers")),
|
||||||
|
sa.UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
|
||||||
|
comment="客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_customers_customer_type"), "customers", ["customer_type"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_customers_owner_personnel_id"), "customers", ["owner_personnel_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(op.f("idx_customers_source_no"), "customers", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"personnel_plans",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="人员ID(personnel.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"plan_period",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="计划期间(如 2026Q1、2026-05)",
|
||||||
|
),
|
||||||
|
sa.Column("plan_content", sa.String(length=500), nullable=False, comment="计划内容"),
|
||||||
|
sa.Column(
|
||||||
|
"target_desc", sa.String(length=255), nullable=True, comment="指标说明(文字描述)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no", sa.String(length=64), nullable=True, comment="T+ 原始条目号(预留)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_personnel_plans_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_plans")),
|
||||||
|
comment="市场拓展计划表",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_personnel_plans_personnel_id"), "personnel_plans", ["personnel_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"personnel_regions",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="人员ID(personnel.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"region_name",
|
||||||
|
sa.String(length=128),
|
||||||
|
nullable=False,
|
||||||
|
comment="区域名称(产品辐射地理范围)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_personnel_regions_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_regions")),
|
||||||
|
comment="产品辐射区域表",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_personnel_regions_personnel_id"),
|
||||||
|
"personnel_regions",
|
||||||
|
["personnel_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移。
|
||||||
|
|
||||||
|
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
|
||||||
|
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
|
||||||
|
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
|
||||||
|
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
|
||||||
|
"""
|
||||||
|
op.drop_table("personnel_regions")
|
||||||
|
op.drop_table("personnel_plans")
|
||||||
|
op.drop_table("customers")
|
||||||
|
op.drop_table("personnel")
|
||||||
|
op.drop_table("ownership_change_logs")
|
||||||
|
op.drop_table("products")
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""V15 T+ 持久化任务、幂等和重试元数据。"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.mysql import BIGINT
|
||||||
|
|
||||||
|
revision: str = "e5f607a8b9c0"
|
||||||
|
down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""仅扩展管理元数据,不改原始暂存行或工资业务表。"""
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column("entity_types_json", sa.JSON(), comment="请求读取的实体类型;历史批次可为空"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column(
|
||||||
|
"skipped_record_count",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="重复快照跳过条数",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column("parent_batch_no", sa.String(32), comment="重试来源批次号,保留原批次记录"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column("request_key", sa.String(36), comment="请求幂等键,空值用于历史和直接CLI任务"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column(
|
||||||
|
"requested_by", BIGINT(unsigned=True), comment="请求用户ID,CLI任务为空,无物理外键"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column("audit_result_json", sa.JSON(), comment="关联核验结果,只含系统字典和汇总"),
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"started_at",
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
nullable=True,
|
||||||
|
comment="开始执行时间(排队时为空)",
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"sync_mode",
|
||||||
|
existing_type=sa.String(16),
|
||||||
|
nullable=False,
|
||||||
|
comment="任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留",
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"status",
|
||||||
|
existing_type=sa.String(16),
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED",
|
||||||
|
)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uk_tplus_sync_batches_request_key", "tplus_sync_batches", ["request_key"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_tplus_sync_batches_parent_status", "tplus_sync_batches", ["parent_batch_no", "status"]
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE tplus_sync_batches SET skipped_record_count=GREATEST(0, source_record_count-staged_record_count-failed_record_count) WHERE status='SUCCEEDED' AND sync_mode='FULL'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚前关闭 worker;原始暂存数据和历史批次仍保留。"""
|
||||||
|
op.execute(
|
||||||
|
"UPDATE tplus_sync_batches SET started_at=create_time, status='FAILED', finished_at=CURRENT_TIMESTAMP, error_message='任务管理版本已回滚' WHERE status='QUEUED'"
|
||||||
|
)
|
||||||
|
op.execute("UPDATE tplus_sync_batches SET started_at=create_time WHERE started_at IS NULL")
|
||||||
|
op.drop_index("idx_tplus_sync_batches_parent_status", table_name="tplus_sync_batches")
|
||||||
|
op.drop_constraint("uk_tplus_sync_batches_request_key", "tplus_sync_batches", type_="unique")
|
||||||
|
for name in (
|
||||||
|
"audit_result_json",
|
||||||
|
"requested_by",
|
||||||
|
"request_key",
|
||||||
|
"parent_batch_no",
|
||||||
|
"skipped_record_count",
|
||||||
|
"entity_types_json",
|
||||||
|
):
|
||||||
|
op.drop_column("tplus_sync_batches", name)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"started_at",
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
nullable=False,
|
||||||
|
comment="开始读取时间",
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"sync_mode",
|
||||||
|
existing_type=sa.String(16),
|
||||||
|
nullable=False,
|
||||||
|
comment="同步模式:FULL全量 INCREMENTAL增量",
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"status",
|
||||||
|
existing_type=sa.String(16),
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED",
|
||||||
|
)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""v11 审批快照与方案版本化
|
||||||
|
|
||||||
|
Revision ID: f3a8c2d61b47
|
||||||
|
Revises: 90653814a421
|
||||||
|
Create Date: 2026-10-20 10:30:00.000000
|
||||||
|
|
||||||
|
补齐 code-review 遗留 2 项:
|
||||||
|
|
||||||
|
- 审批单加 ``version_no`` 与 ``snapshot_json``:重提产生新版本,送审内容
|
||||||
|
在提交时固化,审批期间业务再改不影响复核依据;
|
||||||
|
- 二次分配方案行改 append-only 版本化:加 ``version_no`` / ``is_active``,
|
||||||
|
唯一键由 (business_id, personnel_id) 扩为
|
||||||
|
(business_id, version_no, personnel_id),编辑替换与驳回清空只置历史、
|
||||||
|
不再物理删除。
|
||||||
|
|
||||||
|
存量数据均落 ``version_no=1`` / ``is_active=1``:升级前的行就是当前生效
|
||||||
|
版本,语义不变。审批单存量快照为空,详情接口按「当前内容回退 + 显式
|
||||||
|
标注」处理,不做历史回填(回填会伪造提交时内容)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "f3a8c2d61b47"
|
||||||
|
down_revision: str | Sequence[str] | None = "90653814a421"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""加审批快照列/索引与方案版本列,替换唯一键。"""
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE approval_orders "
|
||||||
|
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
|
||||||
|
"COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' "
|
||||||
|
"AFTER refer_no"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE approval_orders "
|
||||||
|
"ADD COLUMN snapshot_json JSON NULL "
|
||||||
|
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
|
||||||
|
"AFTER version_no"
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)")
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE business_secondary_allocations "
|
||||||
|
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
|
||||||
|
"COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' "
|
||||||
|
"AFTER business_id"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE business_secondary_allocations "
|
||||||
|
"ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 "
|
||||||
|
"COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' "
|
||||||
|
"AFTER ratio"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE business_secondary_allocations "
|
||||||
|
"DROP INDEX uk_business_secondary_allocations_business_personnel, "
|
||||||
|
"ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel "
|
||||||
|
"UNIQUE (business_id, version_no, personnel_id)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX idx_business_secondary_allocations_business_active "
|
||||||
|
"ON business_secondary_allocations (business_id, is_active)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE business_secondary_allocations COMMENT = "
|
||||||
|
"'业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回退结构(降级前须确认同业务同一参与人只存在于一个版本)。"""
|
||||||
|
op.execute(
|
||||||
|
"DROP INDEX idx_business_secondary_allocations_business_active "
|
||||||
|
"ON business_secondary_allocations"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE business_secondary_allocations "
|
||||||
|
"DROP INDEX uk_business_secondary_allocations_business_version_personnel, "
|
||||||
|
"ADD CONSTRAINT uk_business_secondary_allocations_business_personnel "
|
||||||
|
"UNIQUE (business_id, personnel_id)"
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN is_active")
|
||||||
|
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN version_no")
|
||||||
|
|
||||||
|
op.execute("DROP INDEX idx_approval_orders_type_ref ON approval_orders")
|
||||||
|
op.execute("ALTER TABLE approval_orders DROP COLUMN snapshot_json")
|
||||||
|
op.execute("ALTER TABLE approval_orders DROP COLUMN version_no")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""V16 基础导入和核算预演工作区;结构固定,不引用演进中的 ORM。"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "f6a708b9c0d1"
|
||||||
|
down_revision: str | None = "e5f607a8b9c0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
STATEMENTS = [
|
||||||
|
"CREATE TABLE tplus_source_heads (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体类型', \n\tsource_id VARCHAR(64) NOT NULL COMMENT '源内部ID', \n\trecord_id BIGINT UNSIGNED NOT NULL COMMENT '原始快照ID', \n\tbatch_no VARCHAR(32) NOT NULL COMMENT '最后观察批次', \n\tCONSTRAINT pk_tplus_source_heads PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_heads_source_entity_id UNIQUE (source_database, entity_type, source_id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 源当前快照指针(按成功全量批次读取,删除不自动回写业务表)' COLLATE utf8mb4_general_ci",
|
||||||
|
"CREATE INDEX idx_tplus_heads_batch_entity ON tplus_source_heads (batch_no, entity_type)",
|
||||||
|
"CREATE TABLE tplus_facts (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tkind VARCHAR(32) NOT NULL COMMENT '事实类别', \n\tfact_key VARCHAR(128) NOT NULL COMMENT '事实业务键', \n\tversion INTEGER NOT NULL COMMENT '版本号', \n\tdata_json JSON NOT NULL COMMENT '经校验的事实内容', \n\tevidence VARCHAR(500) NOT NULL COMMENT '依据和修改原因', \n\trequested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_facts PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_facts_source_kind_key_version UNIQUE (source_database, kind, fact_key, version)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 人工补充事实版本表' COLLATE utf8mb4_general_ci",
|
||||||
|
"CREATE TABLE tplus_source_links (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体类型', \n\tsource_id VARCHAR(64) NOT NULL COMMENT '源内部ID', \n\ttarget_id BIGINT UNSIGNED NOT NULL COMMENT '业务目标ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_source_links PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_links_source_entity_id UNIQUE (source_database, entity_type, source_id), \n\tCONSTRAINT uk_tplus_links_entity_target UNIQUE (entity_type, target_id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 基础数据源目标关联' COLLATE utf8mb4_general_ci",
|
||||||
|
"CREATE TABLE tplus_work_runs (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\trun_no VARCHAR(32) NOT NULL COMMENT '运行编号', \n\trequest_key VARCHAR(36) NOT NULL COMMENT '请求幂等键', \n\tkind VARCHAR(32) NOT NULL COMMENT 'MASTER_PREVIEW/MASTER_APPLY/CALC_PREVIEW', \n\tstatus VARCHAR(16) NOT NULL COMMENT '运行状态', \n\tsource_database VARCHAR(128) NOT NULL COMMENT '固定源账套', \n\trequested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', \n\tinput_json JSON NOT NULL COMMENT '请求输入', \n\tsnapshot_json JSON COMMENT '规则和补充资料的固定快照', \n\tsnapshot_hash VARCHAR(64) COMMENT '输入指纹', \n\tsummary_json JSON COMMENT '结果汇总', \n\terror_code VARCHAR(64) COMMENT '固定错误编码', \n\tstarted_at DATETIME COMMENT '开始时间', \n\tfinished_at DATETIME COMMENT '结束时间', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_work_runs PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_work_runs_run_no UNIQUE (run_no), \n\tCONSTRAINT uk_tplus_work_runs_request_key UNIQUE (request_key)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入与预演运行表' COLLATE utf8mb4_general_ci",
|
||||||
|
"CREATE INDEX idx_tplus_work_runs_status_id ON tplus_work_runs (status, id)",
|
||||||
|
"CREATE TABLE tplus_work_lines (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\trun_no VARCHAR(32) NOT NULL COMMENT '运行编号', \n\tline_no INTEGER NOT NULL COMMENT '稳定行序号', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体或分组类型', \n\tsource_id VARCHAR(128) NOT NULL COMMENT '源ID或平账组', \n\taction VARCHAR(32) NOT NULL COMMENT '新增/更新/跳过/待补/冲突/分配', \n\treason VARCHAR(128) COMMENT '固定原因编码', \n\tdata_json JSON NOT NULL COMMENT '最小必要的业务明细', \n\tCONSTRAINT pk_tplus_work_lines PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_work_lines_run_line UNIQUE (run_no, line_no)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入预览与核算预演明细' COLLATE utf8mb4_general_ci"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
"""只新增隔离工作区表,不写已有业务或工资记录。"""
|
||||||
|
for statement in STATEMENTS:
|
||||||
|
op.execute(sa.text(statement))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
"""只移除本次工作区表,执行前应保留资料和预演备份。"""
|
||||||
|
for name in ("tplus_work_lines", "tplus_work_runs", "tplus_source_links", "tplus_facts", "tplus_source_heads"):
|
||||||
|
op.drop_table(name)
|
||||||
+123
-2
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import ROLE_ADMIN
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.exceptions import AuthError
|
from app.core.exceptions import AuthError, ForbiddenError
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.repositories import user_repository
|
from app.repositories import user_repository
|
||||||
from app.services import token_blacklist
|
from app.services import token_blacklist
|
||||||
|
from app.services.audit_service import RequestMeta
|
||||||
|
|
||||||
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
|
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
|
||||||
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
|
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
|
||||||
@@ -21,6 +23,29 @@ BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bear
|
|||||||
DbSession = Annotated[Session, Depends(get_db)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str | None:
|
||||||
|
"""取客户端 IP。
|
||||||
|
|
||||||
|
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
|
||||||
|
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
|
||||||
|
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
|
||||||
|
"""
|
||||||
|
return request.client.host if request.client else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_meta(request: Request) -> RequestMeta:
|
||||||
|
"""提取 HTTP 请求元信息,供操作日志审计使用。"""
|
||||||
|
return RequestMeta(
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
RequestMetaDep = Annotated[RequestMeta, Depends(get_request_meta)]
|
||||||
|
|
||||||
|
|
||||||
def get_raw_token(credentials: BearerCredentials) -> str:
|
def get_raw_token(credentials: BearerCredentials) -> str:
|
||||||
"""取原始令牌字符串。登出时需要它来解析 jti。"""
|
"""取原始令牌字符串。登出时需要它来解析 jti。"""
|
||||||
if credentials is None or not credentials.credentials:
|
if credentials is None or not credentials.credentials:
|
||||||
@@ -71,3 +96,99 @@ def _is_digits(value: object) -> bool:
|
|||||||
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
|
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
|
||||||
"""
|
"""
|
||||||
return isinstance(value, str) and value.isdigit()
|
return isinstance(value, str) and value.isdigit()
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
|
||||||
|
"""要求当前用户具备管理员角色。
|
||||||
|
|
||||||
|
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
|
||||||
|
角色,故权限判定用**角色粗粒度**即可,不必引入权限码表 —— `permissions`
|
||||||
|
与 `role_permissions` 表已建好,等业务方给出权限清单后再启用。
|
||||||
|
|
||||||
|
:raises ForbiddenError: 已登录但不具备管理员角色(403)
|
||||||
|
"""
|
||||||
|
# 权限码与「仅管理员」是两套语义:部分系统权限(如 system:params:update)
|
||||||
|
# 可授予普通角色,统一入口通过不代表本端点放行,这里不依赖
|
||||||
|
# authorization_checked 短路,每次按数据库角色重新判定。
|
||||||
|
if ROLE_ADMIN not in user_repository.get_role_codes(current_user):
|
||||||
|
raise ForbiddenError("需要管理员权限")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
AdminUser = Annotated[User, Depends(require_admin)]
|
||||||
|
|
||||||
|
# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅
|
||||||
|
# 管理员」——参数目录/历史/预览等只读端点用此依赖)
|
||||||
|
LoginUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_payroll_writer(current_user: LoginUser) -> User:
|
||||||
|
"""兼容原写接口注解;操作权限由统一入口校验,制作人限制仅用于工资操作。"""
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
PayrollWriter = Annotated[User, Depends(require_payroll_writer)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_workbench_reader(current_user: LoginUser) -> User:
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
WorkbenchReader = Annotated[User, Depends(require_workbench_reader)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
|
||||||
|
"""要求当前用户具备任一指定角色(依赖工厂)。
|
||||||
|
|
||||||
|
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
|
||||||
|
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
|
||||||
|
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
|
||||||
|
|
||||||
|
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
|
||||||
|
避免既有调用与测试变动)。
|
||||||
|
|
||||||
|
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _checker(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
|
||||||
|
# 与 require_admin 同理:不依赖统一入口的短路标记,按数据库角色判定。
|
||||||
|
held = set(user_repository.get_role_codes(current_user))
|
||||||
|
if not held & set(role_codes):
|
||||||
|
raise ForbiddenError(message)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return _checker
|
||||||
|
|
||||||
|
|
||||||
|
async def authorize_request(request: Request, session: DbSession, credentials: BearerCredentials):
|
||||||
|
"""统一覆盖查询、导出和写入;公开端点以显式白名单处理。"""
|
||||||
|
from app.services import authorization_policy as policy
|
||||||
|
from app.services.authorization_service import require_permission
|
||||||
|
|
||||||
|
endpoint = request.scope["route"].endpoint
|
||||||
|
module = endpoint.__module__.removeprefix("app.api.v1.")
|
||||||
|
name = endpoint.__name__
|
||||||
|
if (module, name) in policy.PUBLIC_ENDPOINTS:
|
||||||
|
return
|
||||||
|
user = get_current_user(credentials, session)
|
||||||
|
body = {}
|
||||||
|
if request.method in ("POST", "PUT", "PATCH") and request.headers.get("content-type", "").split(";", 1)[0] == "application/json":
|
||||||
|
try:
|
||||||
|
value = await request.json()
|
||||||
|
body = value if isinstance(value, dict) else {}
|
||||||
|
except ValueError:
|
||||||
|
body = {}
|
||||||
|
code = policy.permission_for(module, name, request.method, params=request.path_params, body=body)
|
||||||
|
if code is None:
|
||||||
|
raise ForbiddenError("此接口尚未登记权限,操作已拒绝")
|
||||||
|
if code:
|
||||||
|
try:
|
||||||
|
require_permission(session, user, code)
|
||||||
|
except ForbiddenError:
|
||||||
|
from app.services.audit_service import AuditRecord, audit
|
||||||
|
|
||||||
|
with audit(session, operator=user, meta=get_request_meta(request), record=AuditRecord(
|
||||||
|
"权限管理", "权限拒绝", "操作因未授权被拒绝", {"permission_code": code}
|
||||||
|
)):
|
||||||
|
raise
|
||||||
|
request.state.authorization_checked = True
|
||||||
@@ -4,10 +4,37 @@
|
|||||||
健康检查作为运维探针不带版本号,直接位于 `/api/health`。
|
健康检查作为运维探针不带版本号,直接位于 `/api/health`。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app.api import health
|
from app.api import health
|
||||||
|
from app.api.deps import authorize_request
|
||||||
from app.api.v1 import auth
|
from app.api.v1 import auth
|
||||||
|
from app.api.v1.base import business_roles as base_business_roles
|
||||||
|
from app.api.v1.base import customers as base_customers
|
||||||
|
from app.api.v1.base import personnel as base_personnel
|
||||||
|
from app.api.v1.base import products as base_products
|
||||||
|
from app.api.v1.business import businesses as business_businesses
|
||||||
|
from app.api.v1.business import ratings as business_ratings
|
||||||
|
from app.api.v1.business import receipts as business_receipts
|
||||||
|
from app.api.v1.business import shipments as business_shipments
|
||||||
|
from app.api.v1.calc import router as calc_router
|
||||||
|
from app.api.v1.dashboard import router as dashboard_router
|
||||||
|
from app.api.v1.metadata import router as metadata_router
|
||||||
|
from app.api.v1.payroll import router as payroll_router
|
||||||
|
from app.api.v1.reports import router as reports_router
|
||||||
|
from app.api.v1.settlements import router as settlements_router
|
||||||
|
from app.api.v1.system import approvals as system_approvals
|
||||||
|
from app.api.v1.system import business_roles as system_business_roles
|
||||||
|
from app.api.v1.system import calc_templates as system_calc_templates
|
||||||
|
from app.api.v1.system import logs as system_logs
|
||||||
|
from app.api.v1.system import monitor as system_monitor
|
||||||
|
from app.api.v1.system import params as system_params
|
||||||
|
from app.api.v1.system import roles as system_roles
|
||||||
|
from app.api.v1.system import settings as system_settings
|
||||||
|
from app.api.v1.system import tplus as system_tplus
|
||||||
|
from app.api.v1.system import users as system_users
|
||||||
|
from app.api.v1.system.authorization import router as authorization_router
|
||||||
|
from app.api.v1.system.coefficients import router as coefficient_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -15,6 +42,52 @@ api_router = APIRouter()
|
|||||||
api_router.include_router(health.router)
|
api_router.include_router(health.router)
|
||||||
|
|
||||||
# 业务接口:/api/v1/*
|
# 业务接口:/api/v1/*
|
||||||
v1_router = APIRouter(prefix="/v1")
|
v1_router = APIRouter(prefix="/v1", dependencies=[Depends(authorize_request)])
|
||||||
|
v1_router.include_router(authorization_router)
|
||||||
|
v1_router.include_router(metadata_router)
|
||||||
v1_router.include_router(auth.router)
|
v1_router.include_router(auth.router)
|
||||||
|
|
||||||
|
# 系统管理(需求 2.5.1):/api/v1/system/*
|
||||||
|
system_router = APIRouter(prefix="/system")
|
||||||
|
system_router.include_router(system_users.router)
|
||||||
|
system_router.include_router(system_monitor.router)
|
||||||
|
system_router.include_router(system_roles.router)
|
||||||
|
system_router.include_router(system_business_roles.router)
|
||||||
|
system_router.include_router(system_calc_templates.router)
|
||||||
|
system_router.include_router(system_params.router)
|
||||||
|
|
||||||
|
system_router.include_router(coefficient_router)
|
||||||
|
system_router.include_router(system_settings.router)
|
||||||
|
system_router.include_router(system_approvals.router)
|
||||||
|
system_router.include_router(system_logs.operation_router)
|
||||||
|
system_router.include_router(system_logs.login_router)
|
||||||
|
system_router.include_router(system_tplus.router)
|
||||||
|
v1_router.include_router(system_router)
|
||||||
|
|
||||||
|
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
|
||||||
|
base_router = APIRouter(prefix="/base")
|
||||||
|
base_router.include_router(base_personnel.router)
|
||||||
|
base_router.include_router(base_products.router)
|
||||||
|
base_router.include_router(base_customers.router)
|
||||||
|
base_router.include_router(base_business_roles.router)
|
||||||
|
v1_router.include_router(base_router)
|
||||||
|
|
||||||
|
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
|
||||||
|
business_router = APIRouter(prefix="/business")
|
||||||
|
business_router.include_router(business_businesses.router)
|
||||||
|
business_router.include_router(business_shipments.router)
|
||||||
|
business_router.include_router(business_receipts.router)
|
||||||
|
business_router.include_router(business_ratings.router)
|
||||||
|
v1_router.include_router(business_router)
|
||||||
|
|
||||||
|
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
|
||||||
|
v1_router.include_router(calc_router)
|
||||||
|
v1_router.include_router(payroll_router)
|
||||||
|
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
|
||||||
|
v1_router.include_router(settlements_router)
|
||||||
|
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
|
||||||
|
v1_router.include_router(reports_router)
|
||||||
|
# 工作台(M4 ⑧):/api/v1/dashboard,四区块只读聚合,限管理员/财务/领导
|
||||||
|
v1_router.include_router(dashboard_router)
|
||||||
|
|
||||||
api_router.include_router(v1_router)
|
api_router.include_router(v1_router)
|
||||||
@@ -4,26 +4,16 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
from app.api.deps import DbSession, get_current_user, get_raw_token
|
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.core.response import ApiResponse, ok
|
from app.core.response import ApiResponse, ok
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.repositories.user_repository import get_role_codes
|
|
||||||
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
|
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
|
||||||
from app.services import auth_service, captcha_service
|
from app.services import auth_service, captcha_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str | None:
|
|
||||||
"""取客户端 IP。
|
|
||||||
|
|
||||||
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
|
|
||||||
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
|
|
||||||
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
|
|
||||||
"""
|
|
||||||
return request.client.host if request.client else None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
|
@router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
|
||||||
def get_captcha() -> ApiResponse[CaptchaOut]:
|
def get_captcha() -> ApiResponse[CaptchaOut]:
|
||||||
"""签发图形验证码。无需认证。
|
"""签发图形验证码。无需认证。
|
||||||
@@ -52,7 +42,7 @@ def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse
|
|||||||
password=payload.password,
|
password=payload.password,
|
||||||
captcha_id=payload.captcha_id,
|
captcha_id=payload.captcha_id,
|
||||||
captcha_code=payload.captcha_code,
|
captcha_code=payload.captcha_code,
|
||||||
ip=_client_ip(request),
|
ip=client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
return ok(result, message="登录成功")
|
return ok(result, message="登录成功")
|
||||||
@@ -75,12 +65,16 @@ def logout(
|
|||||||
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
|
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
|
||||||
def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]:
|
def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]:
|
||||||
"""返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。"""
|
"""返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。"""
|
||||||
|
from app.services.authorization_service import session_info, user_session
|
||||||
|
|
||||||
|
authorization = session_info(user_session(current_user), current_user)
|
||||||
return ok(
|
return ok(
|
||||||
CurrentUserOut(
|
CurrentUserOut(
|
||||||
id=current_user.id,
|
id=current_user.id,
|
||||||
username=current_user.username,
|
username=current_user.username,
|
||||||
real_name=current_user.real_name,
|
real_name=current_user.real_name,
|
||||||
roles=get_role_codes(current_user),
|
**authorization,
|
||||||
|
payroll_approval_enabled=get_settings().payroll_approval_enabled,
|
||||||
last_login_time=current_user.last_login_time,
|
last_login_time=current_user.last_login_time,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""基础数据管理接口(需求 2.1)。
|
||||||
|
|
||||||
|
挂载于 `/api/v1/base/*`,含人员管理、辐射区域、拓展计划等基础数据的维护入口。
|
||||||
|
|
||||||
|
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
|
||||||
|
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
|
||||||
|
由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""岗位下拉选项(基础数据与报表筛选共用)。
|
||||||
|
|
||||||
|
岗位管理本身仅管理员可用(FR-006),但报表的「岗位」筛选对财务/领导
|
||||||
|
开放(FR-029)。为避免把管理接口整体放开,这里提供只读、登录即可的
|
||||||
|
启用岗位选项;写操作仍走 `/system/business-roles` 的管理员端点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, get_current_user
|
||||||
|
from app.core.response import ApiResponse, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.business_role import BusinessRoleOptionOut
|
||||||
|
from app.services.authorization_metadata import options
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/options",
|
||||||
|
response_model=ApiResponse[list[BusinessRoleOptionOut]],
|
||||||
|
summary="岗位下拉选项",
|
||||||
|
)
|
||||||
|
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[BusinessRoleOptionOut]]:
|
||||||
|
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
|
||||||
|
|
||||||
|
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
|
||||||
|
"""
|
||||||
|
return ok([BusinessRoleOptionOut.model_validate(row) for row in options(session, _, "business_roles")])
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""客户管理接口(需求 2.1.3)。
|
||||||
|
|
||||||
|
归属调整有专属端点 ``PUT /{id}/ownership``:它是唯一写归属留痕的入口,
|
||||||
|
编辑端点的 schema 不含归属字段 —— 防止出现「改了归属却无留痕」的旁路
|
||||||
|
(原因详见 `schemas.customer.CustomerUpdateIn`)。
|
||||||
|
|
||||||
|
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
|
||||||
|
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
|
||||||
|
由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||||
|
from app.models import Customer, User
|
||||||
|
from app.schemas.category_batch import CustomerCategoryBatch, CustomerOwnershipBatch
|
||||||
|
from app.schemas.customer import (
|
||||||
|
CustomerCreateIn,
|
||||||
|
CustomerOut,
|
||||||
|
CustomerOwnershipIn,
|
||||||
|
CustomerQuery,
|
||||||
|
CustomerRatingIn,
|
||||||
|
CustomerUpdateIn,
|
||||||
|
OwnershipChangeLogOut,
|
||||||
|
)
|
||||||
|
from app.services import customer_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/customers", tags=["客户管理"])
|
||||||
|
|
||||||
|
# 基础数据不要求管理员(见模块 docstring),登录用户即可操作
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/batch-category")
|
||||||
|
def batch_category(payload: CustomerCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
|
||||||
|
from app.services.category_batch import fill_missing
|
||||||
|
|
||||||
|
return ok(fill_missing(session, Customer, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/batch-ownership")
|
||||||
|
def batch_ownership(payload: CustomerOwnershipBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
|
||||||
|
return ok(customer_service.batch_change_ownership(session, payload, operator=actor, meta=meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表")
|
||||||
|
def list_customers(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[CustomerQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[CustomerOut]]:
|
||||||
|
"""分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名)。"""
|
||||||
|
return ok(customer_service.list_customers(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
|
||||||
|
def create_customer(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
payload: CustomerCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""新增客户。客户分类须在注册表内;「公司名称+负责人」组合唯一。"""
|
||||||
|
return ok(
|
||||||
|
customer_service.create_customer(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="客户已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="客户详情")
|
||||||
|
def get_customer(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""按主键查询客户详情。"""
|
||||||
|
return ok(customer_service.get_customer(session, customer_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
|
||||||
|
def update_customer(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: CustomerUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""编辑客户档案。
|
||||||
|
|
||||||
|
**不含归属** —— 归属调整统一走 ``PUT /{id}/ownership``;编辑表单即使
|
||||||
|
提交 ownerPersonnelId 也会被忽略,归属以归属端点为准。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
customer_service.update_customer(
|
||||||
|
session, customer_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
|
||||||
|
def delete_customer(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除客户(物理删除)。
|
||||||
|
|
||||||
|
已存在关联业务时返回 400(先处理相关业务单据)。
|
||||||
|
"""
|
||||||
|
customer_service.delete_customer(session, customer_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="客户已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{customer_id}/rating",
|
||||||
|
response_model=ApiResponse[CustomerOut],
|
||||||
|
summary="保存客户评级",
|
||||||
|
)
|
||||||
|
def rate_customer(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: CustomerRatingIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""保存评级总分(评级计算器「保存评分」)。
|
||||||
|
|
||||||
|
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
|
||||||
|
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
customer_service.rate_customer(
|
||||||
|
session, customer_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="评级已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{customer_id}/ownership",
|
||||||
|
response_model=ApiResponse[CustomerOut],
|
||||||
|
summary="归属变更",
|
||||||
|
)
|
||||||
|
def change_ownership(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: CustomerOwnershipIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""调整客户归属(唯一写归属留痕的入口)。
|
||||||
|
|
||||||
|
只影响变更后核算,不回溯历史(对照表 #19);新旧归属人员ID任一为 null
|
||||||
|
表示清空归属。新旧归属相同报「归属未变化」。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
customer_service.change_ownership(
|
||||||
|
session, customer_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="归属已调整",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{customer_id}/ownership-logs",
|
||||||
|
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
|
||||||
|
summary="归属变更留痕",
|
||||||
|
)
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[PageQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
|
||||||
|
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
|
||||||
|
return ok(customer_service.list_ownership_logs(session, customer_id, query))
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""人员管理接口(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
|
||||||
|
|
||||||
|
基础数据属业务操作,**登录即可访问**(不要求管理员,对照系统管理模块)。
|
||||||
|
校验规则(岗位/经理合法性、删除保护、子表全量替换的防御写法)见
|
||||||
|
`personnel_service` 模块 docstring —— 前端提示之外,后端校验是唯一防线。
|
||||||
|
|
||||||
|
写操作经 `RequestMetaDep` 带入请求元信息由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.personnel import (
|
||||||
|
PersonnelCreateIn,
|
||||||
|
PersonnelDetailOut,
|
||||||
|
PersonnelOut,
|
||||||
|
PersonnelQuery,
|
||||||
|
PersonnelStatusIn,
|
||||||
|
PlanReplaceIn,
|
||||||
|
RegionReplaceIn,
|
||||||
|
)
|
||||||
|
from app.services import personnel_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/personnel", tags=["人员管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[PersonnelOut]], summary="人员列表")
|
||||||
|
def list_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[PersonnelQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[PersonnelOut]]:
|
||||||
|
"""分页查询人员(含离职)。姓名模糊匹配,可按状态与岗位筛选。"""
|
||||||
|
return ok(personnel_service.list_personnel(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
|
||||||
|
def create_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
payload: PersonnelCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[PersonnelOut]:
|
||||||
|
"""新增人员,默认在职。岗位须存在且启用;直属经理不能是自己且须在职。"""
|
||||||
|
return ok(
|
||||||
|
personnel_service.create_personnel(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="人员已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{personnel_id}", response_model=ApiResponse[PersonnelDetailOut], summary="人员详情")
|
||||||
|
def get_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[PersonnelDetailOut]:
|
||||||
|
"""人员详情,附辐射区域与拓展计划,供编辑抽屉一次回显。"""
|
||||||
|
return ok(personnel_service.get_detail(session, personnel_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
|
||||||
|
def update_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: PersonnelCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[PersonnelOut]:
|
||||||
|
"""编辑人员(全量提交,不含状态)。校验规则同新增。"""
|
||||||
|
return ok(
|
||||||
|
personnel_service.update_personnel(
|
||||||
|
session, personnel_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
|
||||||
|
def delete_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除人员。被团队成员或客户归属引用时禁删,应改为离职。"""
|
||||||
|
personnel_service.delete_personnel(session, personnel_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="人员已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{personnel_id}/status",
|
||||||
|
response_model=ApiResponse[PersonnelOut],
|
||||||
|
summary="设为在职/离职",
|
||||||
|
)
|
||||||
|
def set_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: PersonnelStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[PersonnelOut]:
|
||||||
|
"""在职/离职切换。离职人员不可再被业务与客户归属引用。"""
|
||||||
|
result = personnel_service.set_status(
|
||||||
|
session, personnel_id, payload.status, effective_date=payload.effective_date, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
|
||||||
|
def replace_regions(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: RegionReplaceIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""全量替换辐射区域,空列表即清空。"""
|
||||||
|
personnel_service.replace_regions(
|
||||||
|
session, personnel_id, payload, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(message="已保存")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
|
||||||
|
def replace_plans(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: PlanReplaceIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""全量替换拓展计划,空列表即清空。"""
|
||||||
|
personnel_service.replace_plans(
|
||||||
|
session, personnel_id, payload, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(message="已保存")
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""产品管理接口(需求 2.1.2:产品主数据维护)。
|
||||||
|
|
||||||
|
产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值),
|
||||||
|
类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。
|
||||||
|
|
||||||
|
数据提取使用预览指纹校验、名称规格去重和单位分类冲突保护,
|
||||||
|
仅建立产品档案关联,不改写历史业务金额。
|
||||||
|
|
||||||
|
基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
|
||||||
|
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import Product, User
|
||||||
|
from app.schemas.category_batch import ProductCategoryBatch
|
||||||
|
from app.schemas.product import (
|
||||||
|
ProductCreateIn,
|
||||||
|
ProductExtractIn,
|
||||||
|
ProductOut,
|
||||||
|
ProductQuery,
|
||||||
|
ProductStatusIn,
|
||||||
|
ProductUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import product_service
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/products", tags=["产品管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/batch-category")
|
||||||
|
def batch_category(payload: ProductCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
|
||||||
|
from app.services.category_batch import fill_missing
|
||||||
|
|
||||||
|
return ok(fill_missing(session, Product, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/extract/candidates")
|
||||||
|
def extraction_candidates(session: DbSession, _: CurrentUser, keyword: str = "", page_num: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)):
|
||||||
|
from app.services.product_completion import candidates
|
||||||
|
|
||||||
|
return ok(candidates(session, keyword, page_num, page_size))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/extract/preview")
|
||||||
|
def extraction_preview(payload: ProductExtractIn, session: DbSession, _: CurrentUser):
|
||||||
|
from app.services.product_completion import extraction_plan
|
||||||
|
|
||||||
|
return ok(extraction_plan(session, payload.item_ids))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/extract")
|
||||||
|
def extraction_apply(payload: ProductExtractIn, session: DbSession, actor: CurrentUser, meta: RequestMetaDep):
|
||||||
|
from app.services.product_completion import extract
|
||||||
|
|
||||||
|
return ok(extract(session, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{product_id}/restore-source")
|
||||||
|
def restore_source(product_id: int, session: DbSession, actor: CurrentUser, meta: RequestMetaDep):
|
||||||
|
from app.services.product_completion import restore_source as restore
|
||||||
|
|
||||||
|
return ok(restore(session, product_id, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表")
|
||||||
|
def list_products(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[ProductQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ProductOut]]:
|
||||||
|
"""分页查询产品(含停用)。keyword 对名称或规格模糊匹配。"""
|
||||||
|
return ok(product_service.list_products(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
|
||||||
|
def create_product(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
payload: ProductCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""新增产品。类别须在合法值域内;新产品默认启用。"""
|
||||||
|
return ok(
|
||||||
|
product_service.create_product(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="产品已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{product_id}", response_model=ApiResponse[ProductOut], summary="产品详情")
|
||||||
|
def get_product(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""按主键查询产品详情。"""
|
||||||
|
return ok(product_service.get_product(session, product_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
|
||||||
|
def update_product(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ProductUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""编辑产品基本信息(全量替换,不含状态;启用/停用走独立端点)。"""
|
||||||
|
return ok(
|
||||||
|
product_service.update_product(
|
||||||
|
session, product_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
|
||||||
|
def delete_product(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""物理删除产品(仅限误录数据,常规下架用停用)。
|
||||||
|
|
||||||
|
被业务明细/发货明细引用时返回 400(引导改走停用)。
|
||||||
|
"""
|
||||||
|
product_service.delete_product(session, product_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="产品已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
|
||||||
|
def set_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ProductStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""启用或停用产品(1启用 0停用)。"""
|
||||||
|
result = product_service.set_status(
|
||||||
|
session, product_id, payload.status, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(result, message="已启用" if payload.status == 1 else "已停用")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""业务数据管理接口(需求 2.2)。
|
||||||
|
|
||||||
|
挂载于 `/api/v1/business/*`,含业务单据维护、客户评级等级配置等业务数据入口。
|
||||||
|
|
||||||
|
与 `api/v1/system` 的权限分工:业务数据是**业务操作**,登录即可访问
|
||||||
|
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
|
||||||
|
由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""业务管理接口(需求 2.2.1/2.2.4)。
|
||||||
|
|
||||||
|
校验规则(编号生成、状态机、归属留痕、汇总重算)见 `business_service`
|
||||||
|
模块 docstring;基础数据属业务操作,登录即可访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.business import (
|
||||||
|
BusinessCreateIn,
|
||||||
|
BusinessDetailOut,
|
||||||
|
BusinessOut,
|
||||||
|
BusinessOwnershipIn,
|
||||||
|
BusinessQuery,
|
||||||
|
BusinessStatusIn,
|
||||||
|
BusinessStatusLogOut,
|
||||||
|
BusinessUpdateIn,
|
||||||
|
GrossProfitOut,
|
||||||
|
OwnershipChangeLogOut,
|
||||||
|
)
|
||||||
|
from app.schemas.business_source import (
|
||||||
|
GuidanceConfirmIn,
|
||||||
|
SalesRelationConfirmIn,
|
||||||
|
SalesRelationQuery,
|
||||||
|
SalesRelationReleaseIn,
|
||||||
|
SourceDocumentsQuery,
|
||||||
|
SourceMaterialsIn,
|
||||||
|
SourcePriceConfirmIn,
|
||||||
|
)
|
||||||
|
from app.services import business_service, business_source
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/businesses", tags=["业务管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{business_id}/readiness")
|
||||||
|
def source_readiness(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)]):
|
||||||
|
return ok(business_source.readiness(session, business_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{business_id}/source-documents")
|
||||||
|
def source_documents(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[SourceDocumentsQuery, Query()]):
|
||||||
|
return ok(business_source.documents(session, business_id, query, query.entity))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{business_id}/source-quantities")
|
||||||
|
def source_quantities(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[PageQuery, Query()]):
|
||||||
|
return ok(business_source.source_quantities(session, business_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{business_id}/source-documents/{document_id}/details")
|
||||||
|
def source_document_details(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
|
||||||
|
document_id: Annotated[int, Path(ge=1)], query: Annotated[PageQuery, Query()]):
|
||||||
|
return ok(business_source.document_details(session, business_id, document_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/source-documents/{document_id}/missing-price")
|
||||||
|
def confirm_source_price(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
|
||||||
|
document_id: Annotated[int, Path(ge=1)], payload: SourcePriceConfirmIn, meta: RequestMetaDep):
|
||||||
|
from app.services.authorization_service import require_permission
|
||||||
|
|
||||||
|
require_permission(session, actor, "payroll:prepare")
|
||||||
|
return ok(business_source.confirm_missing_source_price(session, business_id, document_id, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/guidance")
|
||||||
|
def confirm_guidance(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: GuidanceConfirmIn, meta: RequestMetaDep):
|
||||||
|
"""从本地发货明细登记来源产品的发货月份指导成本。"""
|
||||||
|
from app.services.authorization_service import require_permission
|
||||||
|
|
||||||
|
require_permission(session, actor, "payroll:prepare")
|
||||||
|
require_permission(session, actor, "tplus:facts")
|
||||||
|
return ok(business_source.confirm_guidance(session, business_id, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{business_id}/sales-relation-candidates")
|
||||||
|
def sales_relation_candidates(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[SalesRelationQuery, Query()]):
|
||||||
|
return ok(business_source.sales_relation_candidates(session, business_id, query, query.shipment_page, query.order_page))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/sales-relation")
|
||||||
|
def confirm_sales_relation(session: DbSession, actor: PayrollWriter,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: SalesRelationConfirmIn, meta: RequestMetaDep):
|
||||||
|
from app.services.authorization_service import require_permission
|
||||||
|
from app.services.tplus import sales_relations
|
||||||
|
|
||||||
|
require_permission(session, actor, "payroll:prepare")
|
||||||
|
return ok(sales_relations.confirm(session, business_id, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/sales-relation/release")
|
||||||
|
def release_sales_relation(session: DbSession, actor: PayrollWriter,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: SalesRelationReleaseIn, meta: RequestMetaDep):
|
||||||
|
from app.services.authorization_service import require_permission
|
||||||
|
from app.services.tplus import sales_relations
|
||||||
|
|
||||||
|
require_permission(session, actor, "payroll:prepare")
|
||||||
|
return ok(sales_relations.release(session, business_id, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/source-materials")
|
||||||
|
def source_materials(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: SourceMaterialsIn, meta: RequestMetaDep):
|
||||||
|
return ok(business_source.save_materials(session, business_id, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表")
|
||||||
|
def list_businesses(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[BusinessQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[BusinessOut]]:
|
||||||
|
"""分页查询业务。编号/公司名称模糊,可按状态/客户/归属人员/日期范围筛选。"""
|
||||||
|
return ok(business_service.list_businesses(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
|
||||||
|
def create_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
payload: BusinessCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""创建业务 + 明细行(≥1)。业务编号自动生成;创建写首条状态留痕。"""
|
||||||
|
return ok(
|
||||||
|
business_service.create_business(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="业务已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{business_id}", response_model=ApiResponse[BusinessDetailOut], summary="业务详情")
|
||||||
|
def get_business(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[BusinessDetailOut]:
|
||||||
|
"""业务详情,附产品明细行,供编辑抽屉一次回显。"""
|
||||||
|
return ok(business_service.get_detail(session, business_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
|
||||||
|
def update_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""编辑业务(明细全量替换并重算汇总)。归属不在此端点调整,走 /ownership。"""
|
||||||
|
return ok(
|
||||||
|
business_service.update_business(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
|
||||||
|
def delete_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除业务。有发货或回款记录时禁删,应改为已终止。"""
|
||||||
|
business_service.delete_business(session, business_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="业务已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
|
||||||
|
def change_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""状态流转:执行中→已完成/已终止;终态不可再变更;终止必填原因。"""
|
||||||
|
return ok(
|
||||||
|
business_service.change_status(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="状态已变更",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
|
||||||
|
def change_ownership(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessOwnershipIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""调整负责/协助营销人员,同事务写归属变更留痕(不回溯历史)。"""
|
||||||
|
return ok(
|
||||||
|
business_service.change_ownership(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="归属已调整",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/ownership-logs",
|
||||||
|
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
|
||||||
|
summary="归属变更记录",
|
||||||
|
)
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[PageQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
|
||||||
|
"""某业务的归属变更留痕(分页,按时间倒序)。"""
|
||||||
|
return ok(business_service.list_ownership_logs(session, business_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/status-logs",
|
||||||
|
response_model=ApiResponse[PageResult[BusinessStatusLogOut]],
|
||||||
|
summary="状态流转记录",
|
||||||
|
)
|
||||||
|
def list_status_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[PageQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[BusinessStatusLogOut]]:
|
||||||
|
"""某业务的状态流转时间线(分页,按时间正序,从创建起展示)。"""
|
||||||
|
return ok(business_service.list_status_logs(session, business_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/gross-profit",
|
||||||
|
response_model=ApiResponse[GrossProfitOut],
|
||||||
|
summary="毛利润数据视图",
|
||||||
|
)
|
||||||
|
def gross_profit(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
as_of: Annotated[date | None, Query()] = None,
|
||||||
|
) -> ApiResponse[GrossProfitOut]:
|
||||||
|
"""按产品逐行实时计算毛利润与折算额(不含运费口径,对照表 #2)。
|
||||||
|
|
||||||
|
阈值按 asOf(默认今日)取参数快照;M3 核算实际按回款到账日取参。
|
||||||
|
"""
|
||||||
|
return ok(business_service.gross_profit(session, business_id, as_of))
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""客户评级等级配置接口(需求 2.2.5)。
|
||||||
|
|
||||||
|
评级计算器的等级映射判定表:判定按 sort_order 升序取首个 min_score ≤ 总分
|
||||||
|
的等级,max_score 仅展示。区间合法(0≤min≤max≤100)与互不重叠由后端校验
|
||||||
|
—— 前端表单只是展示约束,后端校验是唯一防线。
|
||||||
|
|
||||||
|
列表不分页全量返回:等级映射是判定表,数量个位数,分页无意义。
|
||||||
|
业务数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
|
||||||
|
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
|
||||||
|
from app.services import rating_service
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rating-grades", tags=["客户评级"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[list[RatingGradeOut]], summary="评级等级列表")
|
||||||
|
def list_grades(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
) -> ApiResponse[list[RatingGradeOut]]:
|
||||||
|
"""全量等级列表,按判定顺序(sort_order 升序)返回,不分页。"""
|
||||||
|
return ok(rating_service.list_grades(session))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
|
||||||
|
def create_grade(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
payload: RatingGradeCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[RatingGradeOut]:
|
||||||
|
"""新增评级等级。区间须 0≤min≤max≤100 且与既有等级不重叠。"""
|
||||||
|
return ok(
|
||||||
|
rating_service.create_grade(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="评级等级已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
|
||||||
|
def update_grade(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
grade_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: RatingGradeUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[RatingGradeOut]:
|
||||||
|
"""编辑评级等级(全量替换)。区间须合法且与其他等级不重叠。"""
|
||||||
|
return ok(
|
||||||
|
rating_service.update_grade(session, grade_id, payload, operator=current_user, meta=meta),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
|
||||||
|
def delete_grade(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
grade_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""物理删除评级等级(等级表无下游引用,允许清空,理由见 service 说明)。"""
|
||||||
|
rating_service.delete_grade(session, grade_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="评级等级已删除")
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""回款管理接口(需求 2.2.3)。
|
||||||
|
|
||||||
|
回款按业务登记到账款项,批次号自动生成(HK 前缀)。校验规则(发货
|
||||||
|
批次同业务强校验、已核算禁改禁删、汇总重算)见 `receipt_service` 模块
|
||||||
|
docstring;基础数据属业务操作,登录即可访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.receipt import (
|
||||||
|
ReceiptConfirmIn,
|
||||||
|
ReceiptCreateIn,
|
||||||
|
ReceiptOut,
|
||||||
|
ReceiptQuery,
|
||||||
|
ReceiptUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import receipt_confirmation, receipt_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/receipts", tags=["回款管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/confirmation-policy", summary="回款人工确认开关状态")
|
||||||
|
def confirmation_policy(session: DbSession, _: CurrentUser):
|
||||||
|
return ok({"enabled": receipt_confirmation.enabled(session)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{receipt_id}/confirm", response_model=ApiResponse[ReceiptOut], summary="独立人工确认回款")
|
||||||
|
def confirm_receipt(
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)], payload: ReceiptConfirmIn,
|
||||||
|
session: DbSession, current_user: CurrentUser, meta: RequestMetaDep,
|
||||||
|
):
|
||||||
|
return ok(receipt_service.confirm_receipt(
|
||||||
|
session, receipt_id, payload, operator=current_user, meta=meta,
|
||||||
|
), message="回款已人工确认")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表")
|
||||||
|
def list_receipts(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[ReceiptQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ReceiptOut]]:
|
||||||
|
"""分页查询回款。附业务号/发货单号与回款天数派生值,可按业务/日期/抵顶/核算状态筛选。"""
|
||||||
|
return ok(receipt_service.list_receipts(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
|
||||||
|
def create_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
payload: ReceiptCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ReceiptOut]:
|
||||||
|
"""新增回款。批次号自动生成;发货批次须与本回款同属一个业务;业务须执行中。"""
|
||||||
|
return ok(
|
||||||
|
receipt_service.create_receipt(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="回款已登记",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="回款详情")
|
||||||
|
def get_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ReceiptOut]:
|
||||||
|
"""回款详情(含回款天数派生值,未指定发货批次时为空)。"""
|
||||||
|
return ok(receipt_service.get_detail(session, receipt_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
|
||||||
|
def update_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ReceiptUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ReceiptOut]:
|
||||||
|
"""编辑回款(全量替换并重算合计)。已核算回款禁止修改;发货批次重新校验同业务。"""
|
||||||
|
return ok(
|
||||||
|
receipt_service.update_receipt(
|
||||||
|
session, receipt_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
|
||||||
|
def delete_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除回款。已核算回款禁止删除;业务已完成或已终止时禁删。"""
|
||||||
|
receipt_service.delete_receipt(session, receipt_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="回款已删除")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""发货管理接口(需求 2.2.2)。
|
||||||
|
|
||||||
|
校验规则(状态门槛、行金额固化、双重汇总回写、删除保护)见
|
||||||
|
`shipment_service` 模块 docstring;业务数据属业务操作,登录即可访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.shipment import (
|
||||||
|
ShipmentCreateIn,
|
||||||
|
ShipmentDetailOut,
|
||||||
|
ShipmentOut,
|
||||||
|
ShipmentQuery,
|
||||||
|
ShipmentUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import shipment_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/shipments", tags=["发货管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ShipmentOut]], summary="发货单列表")
|
||||||
|
def list_shipments(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[ShipmentQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ShipmentOut]]:
|
||||||
|
"""分页查询发货单(附业务编号),可按业务/发货日期范围筛选。"""
|
||||||
|
return ok(shipment_service.list_shipments(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
|
||||||
|
def create_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
payload: ShipmentCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ShipmentOut]:
|
||||||
|
"""创建发货单(明细 ≥1)。业务须处于执行中;锁业务行后生成 SH 编号。"""
|
||||||
|
return ok(
|
||||||
|
shipment_service.create_shipment(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="发货单已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shipment_id}", response_model=ApiResponse[ShipmentDetailOut], summary="发货单详情")
|
||||||
|
def get_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
shipment_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ShipmentDetailOut]:
|
||||||
|
"""发货单详情,附产品明细行,供编辑抽屉一次回显。"""
|
||||||
|
return ok(shipment_service.get_detail(session, shipment_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
|
||||||
|
def update_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
shipment_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ShipmentUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ShipmentOut]:
|
||||||
|
"""编辑发货单(明细全量替换并重算汇总)。终态业务的发货单禁改。"""
|
||||||
|
return ok(
|
||||||
|
shipment_service.update_shipment(
|
||||||
|
session, shipment_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
|
||||||
|
def delete_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
shipment_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除发货单(连带明细行并回写业务汇总)。被回款批次指定时禁删。"""
|
||||||
|
shipment_service.delete_shipment(session, shipment_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="发货单已删除")
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""绩效核算接口(M3 ④):台账查询与单笔核算触发。
|
||||||
|
|
||||||
|
核算动作 = 把回款固化为台账行(每笔回款仅一次;重算走独立入口并授权)。
|
||||||
|
准入:读=登录即可;写(核算/重算/批量)=管理员与营销后勤
|
||||||
|
(权限清单待澄清 #6,答复后细化)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
|
||||||
|
from app.services import calc_service
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/calc", tags=["绩效工资 · 绩效核算"])
|
||||||
|
|
||||||
|
# 重算是授权动作:管理员/财务(营销后勤不可重算)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
|
||||||
|
def list_ledger(
|
||||||
|
session: DbSession,
|
||||||
|
query: Annotated[LedgerQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[LedgerOut]]:
|
||||||
|
"""分页查询绩效核算台账(默认仅生效行,可筛选历史版本)。"""
|
||||||
|
return ok(calc_service.list_ledger_page(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ledger/{ledger_id}", response_model=ApiResponse[LedgerOut], summary="台账详情")
|
||||||
|
def get_ledger(
|
||||||
|
session: DbSession,
|
||||||
|
ledger_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[LedgerOut]:
|
||||||
|
"""台账行详情(含计算快照)。"""
|
||||||
|
return ok(calc_service.get_ledger(session, ledger_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/receipts/{receipt_id}/calc",
|
||||||
|
response_model=ApiResponse[CalcReceiptOut],
|
||||||
|
summary="核算回款",
|
||||||
|
)
|
||||||
|
def calc_receipt(
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CalcReceiptOut]:
|
||||||
|
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
|
||||||
|
return ok(
|
||||||
|
calc_service.calc_receipt(session, receipt_id, operator=current_user, meta=meta),
|
||||||
|
message="核算完成",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecalcIn(BaseModel):
|
||||||
|
"""重算入参:原因必填(重算授权留痕)。"""
|
||||||
|
|
||||||
|
reason: str = Field(min_length=1, max_length=255, description="重算原因")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchIn(BaseModel):
|
||||||
|
"""批量核算入参。"""
|
||||||
|
|
||||||
|
receipt_ids: list[int] = Field(min_length=1, description="回款 ID 列表")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/receipts/{receipt_id}/recalc",
|
||||||
|
response_model=ApiResponse[CalcReceiptOut],
|
||||||
|
summary="重算回款",
|
||||||
|
)
|
||||||
|
def recalc_receipt(
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: RecalcIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CalcReceiptOut]:
|
||||||
|
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
|
||||||
|
return ok(
|
||||||
|
calc_service.recalc_receipt(
|
||||||
|
session, receipt_id, payload.reason, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="重算完成",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch", response_model=ApiResponse[list[dict]], summary="批量核算")
|
||||||
|
def batch_calc(
|
||||||
|
payload: BatchIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[list[dict]]:
|
||||||
|
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
|
||||||
|
return ok(
|
||||||
|
calc_service.batch_calc(session, payload.receipt_ids, operator=current_user, meta=meta),
|
||||||
|
message="批量核算完成",
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
|
||||||
|
|
||||||
|
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
|
||||||
|
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, require_roles
|
||||||
|
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||||
|
from app.core.response import ApiResponse, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut
|
||||||
|
from app.services import dashboard_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["工作台"])
|
||||||
|
|
||||||
|
_DashboardUser = Annotated[
|
||||||
|
User,
|
||||||
|
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块")
|
||||||
|
def get_dashboard(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _DashboardUser,
|
||||||
|
month: Annotated[
|
||||||
|
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
|
||||||
|
] = None,
|
||||||
|
) -> ApiResponse[DashboardOut]:
|
||||||
|
"""一次返回经营总览/待办/风险预警(待办与预警实时派生)。
|
||||||
|
|
||||||
|
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
|
||||||
|
最近动态是分页列表,走 `GET /dashboard/activities`。
|
||||||
|
"""
|
||||||
|
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/activities", response_model=ApiResponse[ActivityPageOut], summary="最近动态(分页)")
|
||||||
|
def list_dashboard_activities(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _DashboardUser,
|
||||||
|
query: Annotated[ActivityQuery, Query()],
|
||||||
|
) -> ApiResponse[ActivityPageOut]:
|
||||||
|
"""分页查询最近动态,可按模块筛选。
|
||||||
|
|
||||||
|
与聚合端点拆开:翻页只重取这张列表,不必重算总览/待办/预警。
|
||||||
|
"""
|
||||||
|
return ok(dashboard_service.list_activities(session, query))
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""表单参照选项,不能由此读取薪酬、联系方式或完整业务对象。"""
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, LoginUser
|
||||||
|
from app.core.response import ok
|
||||||
|
from app.services.authorization_metadata import options, payroll_options
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/meta", tags=["业务参照"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/payroll-input/{business_id}")
|
||||||
|
def payroll_metadata(business_id: int, session: DbSession, actor: LoginUser,
|
||||||
|
page_num: Annotated[int, Query(alias="pageNum", ge=1)] = 1,
|
||||||
|
page_size: Annotated[int, Query(alias="pageSize", ge=1, le=200)] = 200):
|
||||||
|
return ok(payroll_options(session, actor, business_id, page_num=page_num, page_size=page_size))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/options")
|
||||||
|
def metadata_options(session: DbSession, actor: LoginUser, resource: str,
|
||||||
|
business_id: Annotated[int | None, Query(ge=1)] = None,
|
||||||
|
search: Annotated[str | None, Query(max_length=64)] = None):
|
||||||
|
return ok(options(session, actor, resource, business_id=business_id, search=search))
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""正式工资资料、FIFO核算和逐段明细入口。"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, LoginUser, PayrollWriter, RequestMetaDep
|
||||||
|
from app.core.response import PageQuery, ok
|
||||||
|
from app.schemas.payroll import AllocationAdjustmentIn, PayrollMonthIn, PayrollSetupIn
|
||||||
|
from app.services import payroll_calculation, payroll_inputs, payroll_months, settlement_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/payroll", tags=["正式工资核算"])
|
||||||
|
|
||||||
|
|
||||||
|
class ProbationAdjustmentIn(BaseModel):
|
||||||
|
personnel_id: int = Field(ge=1)
|
||||||
|
year: str = Field(pattern=r"^20\d{2}$")
|
||||||
|
fingerprint: str = Field(min_length=64, max_length=64)
|
||||||
|
reason: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/probation/adjustments")
|
||||||
|
def probation_adjustment(payload: ProbationAdjustmentIn, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
|
||||||
|
from app.services.probation_service import adjustment as apply
|
||||||
|
|
||||||
|
return ok(apply(session, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/probation")
|
||||||
|
def probation_preview(session: DbSession, _: LoginUser, month: str, personnel_id: int | None = None):
|
||||||
|
from app.services import probation_service
|
||||||
|
|
||||||
|
return ok(probation_service.preview(session, month, personnel_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/probation/export")
|
||||||
|
def probation_export(session: DbSession, _: LoginUser, month: str):
|
||||||
|
from app.services import probation_service
|
||||||
|
|
||||||
|
return Response(probation_service.export(session, month),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="probation_balances.xlsx"'})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/salary-export/{month}")
|
||||||
|
def salary_export(month: str, session: DbSession, _: LoginUser):
|
||||||
|
from app.core.month import validate_month
|
||||||
|
from app.services.salary_completion import export_settlement
|
||||||
|
|
||||||
|
validate_month(month)
|
||||||
|
return Response(export_settlement(session, month),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="monthly_salary.xlsx"'})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/months/{month}")
|
||||||
|
def month_state(
|
||||||
|
month: Annotated[str, Path(pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$")],
|
||||||
|
session: DbSession,
|
||||||
|
_: LoginUser,
|
||||||
|
):
|
||||||
|
return ok(payroll_months.month_state(session, month))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/businesses/{business_id}/input")
|
||||||
|
def input_detail(business_id: int, session: DbSession, _: LoginUser):
|
||||||
|
return ok(payroll_inputs.detail(session, business_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/businesses/{business_id}/input")
|
||||||
|
def save_input(
|
||||||
|
business_id: int,
|
||||||
|
payload: PayrollSetupIn,
|
||||||
|
session: DbSession,
|
||||||
|
actor: PayrollWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
):
|
||||||
|
return ok(payroll_inputs.setup(session, business_id, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/businesses/{business_id}/calculate")
|
||||||
|
def calculate(
|
||||||
|
business_id: int,
|
||||||
|
payload: PayrollMonthIn,
|
||||||
|
session: DbSession,
|
||||||
|
actor: PayrollWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
):
|
||||||
|
return ok(
|
||||||
|
payroll_calculation.calculate(
|
||||||
|
session, business_id, payload.month, operator=actor, meta=meta, reason=payload.reason
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ledger/{ledger_id}/allocations")
|
||||||
|
def allocations(
|
||||||
|
ledger_id: int, query: Annotated[PageQuery, Query()], session: DbSession, _: LoginUser
|
||||||
|
):
|
||||||
|
return ok(payroll_calculation.allocation_page(session, ledger_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/allocation-adjustments")
|
||||||
|
def adjustment(
|
||||||
|
payload: AllocationAdjustmentIn, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep
|
||||||
|
):
|
||||||
|
return ok(
|
||||||
|
settlement_service.create_allocation_adjustment(session, payload, operator=actor, meta=meta)
|
||||||
|
)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""报表统计接口(M4 ⑤):四张报表只读查询 + Excel 导出。
|
||||||
|
|
||||||
|
准入:管理员/财务/领导(FR-030/031 明确;FR-029 的营销后勤「授权范围内」
|
||||||
|
待权限清单 #6,暂不开放)。前后端双层:前端 allowedRoles 控菜单,本层
|
||||||
|
require_roles 兜底。
|
||||||
|
|
||||||
|
导出返回文件流(非统一信封),沿用参数导出先例:服务端文件名为 ASCII
|
||||||
|
(HTTP 头按 latin-1 编码,中文名会 UnicodeEncodeError),中文名由前端
|
||||||
|
``link.download`` 指定。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||||
|
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||||
|
from app.core.exceptions import BadRequestError
|
||||||
|
from app.core.response import ApiResponse, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.report import (
|
||||||
|
BusinessStatsQuery,
|
||||||
|
BusinessStatsRow,
|
||||||
|
DepartmentSummaryOut,
|
||||||
|
DepartmentSummaryQuery,
|
||||||
|
PaymentDetailQuery,
|
||||||
|
PaymentDetailRow,
|
||||||
|
PaymentStatsOut,
|
||||||
|
PaymentStatsQuery,
|
||||||
|
PersonalDetailQuery,
|
||||||
|
PersonalDetailRow,
|
||||||
|
ReportPage,
|
||||||
|
)
|
||||||
|
from app.services import report_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/reports", tags=["绩效工资 · 报表统计"])
|
||||||
|
|
||||||
|
_ReportViewer = Annotated[
|
||||||
|
User,
|
||||||
|
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/personal-detail",
|
||||||
|
response_model=ApiResponse[ReportPage[PersonalDetailRow]],
|
||||||
|
summary="个人绩效工资明细表",
|
||||||
|
)
|
||||||
|
def personal_detail(
|
||||||
|
session: DbSession,
|
||||||
|
_: _ReportViewer,
|
||||||
|
query: Annotated[PersonalDetailQuery, Query()],
|
||||||
|
) -> ApiResponse[ReportPage[PersonalDetailRow]]:
|
||||||
|
"""按人员/月份/客户类型/产品类别/岗位查明细(口径与台账一致)。"""
|
||||||
|
return ok(report_service.list_personal_detail(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/department-summary",
|
||||||
|
response_model=ApiResponse[DepartmentSummaryOut],
|
||||||
|
summary="部门绩效工资汇总表",
|
||||||
|
)
|
||||||
|
def department_summary(
|
||||||
|
session: DbSession,
|
||||||
|
_: _ReportViewer,
|
||||||
|
query: Annotated[DepartmentSummaryQuery, Query()],
|
||||||
|
) -> ApiResponse[DepartmentSummaryOut]:
|
||||||
|
"""按经理团队/月份汇总(团队基数与月结聚合行同源)。"""
|
||||||
|
return ok(report_service.department_summary(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/business-stats",
|
||||||
|
response_model=ApiResponse[ReportPage[BusinessStatsRow]],
|
||||||
|
summary="业务统计表",
|
||||||
|
)
|
||||||
|
def business_stats(
|
||||||
|
session: DbSession,
|
||||||
|
_: _ReportViewer,
|
||||||
|
query: Annotated[BusinessStatsQuery, Query()],
|
||||||
|
) -> ApiResponse[ReportPage[BusinessStatsRow]]:
|
||||||
|
"""按业务查询发货、回款、绩效(回款/绩效为业务累计值)。"""
|
||||||
|
return ok(report_service.business_stats(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/payment-stats",
|
||||||
|
response_model=ApiResponse[PaymentStatsOut],
|
||||||
|
summary="回款情况统计表",
|
||||||
|
)
|
||||||
|
def payment_stats(
|
||||||
|
session: DbSession,
|
||||||
|
_: _ReportViewer,
|
||||||
|
query: Annotated[PaymentStatsQuery, Query()],
|
||||||
|
) -> ApiResponse[PaymentStatsOut]:
|
||||||
|
"""按回款天数区间/客户类型/产品类别统计(空桶恒在且为 0)。"""
|
||||||
|
return ok(report_service.payment_stats(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/payment-detail",
|
||||||
|
response_model=ApiResponse[ReportPage[PaymentDetailRow]],
|
||||||
|
summary="回款区间下钻",
|
||||||
|
)
|
||||||
|
def payment_detail(
|
||||||
|
session: DbSession,
|
||||||
|
_: _ReportViewer,
|
||||||
|
query: Annotated[PaymentDetailQuery, Query()],
|
||||||
|
) -> ApiResponse[ReportPage[PaymentDetailRow]]:
|
||||||
|
"""区间桶下钻到单笔台账行(含回款金额与批次号)。"""
|
||||||
|
return ok(report_service.payment_detail(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/payment-day-buckets", response_model=ApiResponse[list[dict]], summary="回款天数区间档位"
|
||||||
|
)
|
||||||
|
def payment_day_buckets(_: _ReportViewer) -> ApiResponse[list[dict]]:
|
||||||
|
"""区间档位元数据(前端渲染下拉/表头,禁止前端硬编码档位)。"""
|
||||||
|
return ok(report_service.get_payment_day_buckets())
|
||||||
|
|
||||||
|
|
||||||
|
class ExportQuery(BaseModel):
|
||||||
|
"""导出通用筛选参数(按报表编码取用相关字段,未用字段忽略)。"""
|
||||||
|
|
||||||
|
settlement_month: str | None = None
|
||||||
|
personnel_id: int | None = None
|
||||||
|
customer_type: str | None = None
|
||||||
|
product_category: str | None = None
|
||||||
|
role_code: str | None = None
|
||||||
|
manager_id: int | None = None
|
||||||
|
keyword: str | None = None
|
||||||
|
customer_id: int | None = None
|
||||||
|
owner_personnel_id: int | None = None
|
||||||
|
status: int | None = None
|
||||||
|
date_from: date | None = None
|
||||||
|
date_to: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{code}/export", summary="导出报表 Excel")
|
||||||
|
def export_report(
|
||||||
|
code: Annotated[
|
||||||
|
Literal["personal-detail", "department-summary", "business-stats", "payment-stats"],
|
||||||
|
Path(description="报表编码"),
|
||||||
|
],
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _ReportViewer,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
query: Annotated[ExportQuery, Query()],
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""导出 xlsx(含公司页眉/财务复核栏/领导签字栏),行为入操作日志。"""
|
||||||
|
content, filename = report_service.export_report(
|
||||||
|
session,
|
||||||
|
code,
|
||||||
|
_build_export_query(code, query),
|
||||||
|
operator=current_user,
|
||||||
|
meta=meta,
|
||||||
|
)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([content]),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": (f'attachment; filename="{filename}_{date.today():%Y%m%d}.xlsx"')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_export_query(code: str, query: ExportQuery):
|
||||||
|
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。
|
||||||
|
|
||||||
|
手工构造模型抛出的是 `pydantic.ValidationError`,不是 FastAPI 的
|
||||||
|
`RequestValidationError`——不在这里转换就会落到全局 500。导出参数
|
||||||
|
非法与查询接口一样应当是 400。
|
||||||
|
"""
|
||||||
|
data = query.model_dump()
|
||||||
|
model = {
|
||||||
|
"personal-detail": PersonalDetailQuery,
|
||||||
|
"department-summary": DepartmentSummaryQuery,
|
||||||
|
"business-stats": BusinessStatsQuery,
|
||||||
|
"payment-stats": PaymentStatsQuery,
|
||||||
|
}[code]
|
||||||
|
try:
|
||||||
|
return model(**data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
first = exc.errors()[0]
|
||||||
|
location = ".".join(str(part) for part in first.get("loc", ()))
|
||||||
|
message = first.get("msg", "参数错误")
|
||||||
|
detail = f"{location} {message}" if location else message
|
||||||
|
raise BadRequestError(f"参数错误:{detail}") from exc
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""月度结算接口(M3 ④):生成/提交审批/锁定与调整单。
|
||||||
|
|
||||||
|
准入:读=登录即可;写(生成/提交/调整单)=管理员与营销后勤
|
||||||
|
(权限清单待澄清 #6,答复后细化)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.schemas.settlement import (
|
||||||
|
AdjustmentCreateIn,
|
||||||
|
SettlementDetailOut,
|
||||||
|
SettlementGenerateIn,
|
||||||
|
SettlementOut,
|
||||||
|
SettlementQuery,
|
||||||
|
)
|
||||||
|
from app.services import settlement_service
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/settlements", tags=["绩效工资 · 月度结算"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
|
||||||
|
def generate(
|
||||||
|
payload: SettlementGenerateIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[SettlementDetailOut]:
|
||||||
|
"""按确认回款口径生成指定月份的结算快照(草稿)。"""
|
||||||
|
return ok(
|
||||||
|
settlement_service.generate(session, payload.month, operator=current_user, meta=meta),
|
||||||
|
message="月结已生成",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[SettlementOut]], summary="月结列表")
|
||||||
|
def list_settlements(
|
||||||
|
session: DbSession,
|
||||||
|
query: Annotated[SettlementQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[SettlementOut]]:
|
||||||
|
"""分页查询月结(按月份倒序)。"""
|
||||||
|
return ok(settlement_service.list_page(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{settlement_id}", response_model=ApiResponse[SettlementDetailOut], summary="月结详情")
|
||||||
|
def get_settlement(
|
||||||
|
session: DbSession,
|
||||||
|
settlement_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[SettlementDetailOut]:
|
||||||
|
"""月结详情(含按人明细)。"""
|
||||||
|
return ok(settlement_service.get_detail(session, settlement_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{settlement_id}/submit",
|
||||||
|
response_model=ApiResponse[SettlementDetailOut],
|
||||||
|
summary="提交审批",
|
||||||
|
)
|
||||||
|
def submit(
|
||||||
|
settlement_id: Annotated[int, Path(ge=1)],
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[SettlementDetailOut]:
|
||||||
|
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
|
||||||
|
return ok(
|
||||||
|
settlement_service.submit(session, settlement_id, operator=current_user, meta=meta),
|
||||||
|
message="已提交",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 调整单(已锁定月份的补发/扣减) ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/adjustments", response_model=ApiResponse[dict], summary="新建调整单")
|
||||||
|
def create_adjustment(
|
||||||
|
payload: AdjustmentCreateIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: PayrollWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[dict]:
|
||||||
|
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
|
||||||
|
return ok(
|
||||||
|
settlement_service.create_adjustment(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="调整单已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/adjustments", response_model=ApiResponse[list[dict]], summary="调整单列表")
|
||||||
|
def list_adjustments(session: DbSession) -> ApiResponse[list[dict]]:
|
||||||
|
"""调整单列表(一期不分页,内部系统量级有限)。"""
|
||||||
|
return ok(settlement_service.list_adjustments(session))
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""系统管理路由(需求 2.5.1)。
|
||||||
|
|
||||||
|
含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。
|
||||||
|
|
||||||
|
与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录,
|
||||||
|
以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""审批管理接口(2026-10-15 复核审批补充需求)。
|
||||||
|
|
||||||
|
准入:admin/财务/领导可见(营销后勤暂不可见——权限清单待澄清 #6,
|
||||||
|
答复后放开为「提交人可见本人单据」)。节点级准入由 service 按单据
|
||||||
|
状态二次校验(status=1 仅财务、status=2 仅领导,admin 不代办)。
|
||||||
|
提单不经本模块:由业务写路径(创建/编辑带二次分配方案)自动触发。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||||
|
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.approval import (
|
||||||
|
ApprovalActionIn,
|
||||||
|
ApprovalOrderDetailOut,
|
||||||
|
ApprovalOrderOut,
|
||||||
|
ApprovalQuery,
|
||||||
|
)
|
||||||
|
from app.services import approval_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/approvals", tags=["系统管理 · 审批管理"])
|
||||||
|
|
||||||
|
# 模块准入:可见集合(节点准入在 service 层按状态二次判定)
|
||||||
|
_ApprovalUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ApprovalOrderOut]], summary="审批单列表")
|
||||||
|
def list_orders(
|
||||||
|
session: DbSession,
|
||||||
|
_: _ApprovalUser,
|
||||||
|
query: Annotated[ApprovalQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ApprovalOrderOut]]:
|
||||||
|
"""分页查询审批单。"""
|
||||||
|
return ok(approval_service.list_orders(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{order_id}", response_model=ApiResponse[ApprovalOrderDetailOut], summary="审批详情")
|
||||||
|
def get_order(
|
||||||
|
session: DbSession,
|
||||||
|
_: _ApprovalUser,
|
||||||
|
order_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ApprovalOrderDetailOut]:
|
||||||
|
"""审批单详情(含节点时间线)。"""
|
||||||
|
return ok(approval_service.get_order(session, order_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{order_id}/action",
|
||||||
|
response_model=ApiResponse[ApprovalOrderDetailOut],
|
||||||
|
summary="节点动作(通过/驳回)",
|
||||||
|
)
|
||||||
|
def act_on_order(
|
||||||
|
payload: ApprovalActionIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _ApprovalUser,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
order_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ApprovalOrderDetailOut]:
|
||||||
|
"""财务复核 / 领导审核;一驳全驳,驳回须填意见。"""
|
||||||
|
return ok(
|
||||||
|
approval_service.act(session, order_id, payload, operator=current_user, meta=meta),
|
||||||
|
message="已提交",
|
||||||
|
)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""角色权限配置与用户角色分配;旧制作人设置只保留兼容提示。"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||||
|
from app.core.response import ok
|
||||||
|
from app.schemas.authorization import (
|
||||||
|
AuthorizationPreviewIn,
|
||||||
|
AuthorizationSettingsIn,
|
||||||
|
RolePermissionsIn,
|
||||||
|
UserAuthorizationIn,
|
||||||
|
)
|
||||||
|
from app.services import authorization_management as service
|
||||||
|
from app.services.permission_catalog import CATALOG
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/system", tags=["系统管理 · 权限"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permissions/catalog")
|
||||||
|
def catalog(session: DbSession, actor: AdminUser):
|
||||||
|
service.require_admin(session, actor)
|
||||||
|
return ok(list(CATALOG.values()))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roles/{role_id}/permissions")
|
||||||
|
def role_permissions(role_id: int, session: DbSession, actor: AdminUser):
|
||||||
|
return ok(service.role_permissions(session, role_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/roles/{role_id}/permissions")
|
||||||
|
def save_role(role_id: int, payload: RolePermissionsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
|
||||||
|
return ok(service.save_role(session, role_id, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/authorization")
|
||||||
|
def user_authorization(user_id: int, session: DbSession, actor: AdminUser):
|
||||||
|
return ok(service.user_authorization(session, user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/authorization/preview")
|
||||||
|
def preview(user_id: int, payload: AuthorizationPreviewIn, session: DbSession, actor: AdminUser):
|
||||||
|
return ok(service.preview(session, user_id, payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/authorization")
|
||||||
|
def save_user(user_id: int, payload: UserAuthorizationIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
|
||||||
|
return ok(service.save_user(session, user_id, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authorization-settings")
|
||||||
|
def settings(session: DbSession, actor: AdminUser):
|
||||||
|
return ok(service.settings_out(session))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/authorization-settings")
|
||||||
|
def save_settings(payload: AuthorizationSettingsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
|
||||||
|
return ok(service.save_settings(session, payload, actor, meta))
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""业务岗位角色管理接口(需求 #28:岗位可扩展 + 绑定公式模板)。
|
||||||
|
|
||||||
|
保护规则(内置岗位禁删禁停、编码不可改、模板须在注册表内)见
|
||||||
|
`business_role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。
|
||||||
|
|
||||||
|
全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service
|
||||||
|
层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.schemas.business_role import (
|
||||||
|
BusinessRoleCreateIn,
|
||||||
|
BusinessRoleOut,
|
||||||
|
BusinessRoleQuery,
|
||||||
|
BusinessRoleStatusIn,
|
||||||
|
BusinessRoleUpdateIn,
|
||||||
|
TemplateOptionOut,
|
||||||
|
)
|
||||||
|
from app.services import business_role_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/business-roles", tags=["系统管理 · 岗位角色"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[BusinessRoleOut]], summary="岗位列表")
|
||||||
|
def list_roles(
|
||||||
|
session: DbSession,
|
||||||
|
_: AdminUser,
|
||||||
|
query: Annotated[BusinessRoleQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[BusinessRoleOut]]:
|
||||||
|
"""分页查询业务岗位角色(含停用)。"""
|
||||||
|
return ok(business_role_service.list_roles(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[BusinessRoleOut], summary="新增岗位")
|
||||||
|
def create_role(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
payload: BusinessRoleCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessRoleOut]:
|
||||||
|
"""新增岗位。编码全局唯一、创建后不可修改;模板须在注册表内;新岗位默认启用。"""
|
||||||
|
return ok(
|
||||||
|
business_role_service.create_role(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="岗位已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/meta/templates",
|
||||||
|
response_model=ApiResponse[list[TemplateOptionOut]],
|
||||||
|
summary="公式模板下拉",
|
||||||
|
)
|
||||||
|
def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[TemplateOptionOut]]:
|
||||||
|
"""取公式模板下拉(含声明参数,供岗位表单展示)。
|
||||||
|
|
||||||
|
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。
|
||||||
|
"""
|
||||||
|
return ok(business_role_service.list_template_options(session))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")
|
||||||
|
def get_role(
|
||||||
|
session: DbSession,
|
||||||
|
_: AdminUser,
|
||||||
|
role_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[BusinessRoleOut]:
|
||||||
|
"""按主键查询岗位详情。"""
|
||||||
|
return ok(business_role_service.get_role(session, role_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="编辑岗位")
|
||||||
|
def update_role(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
role_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessRoleUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessRoleOut]:
|
||||||
|
"""编辑岗位名称/模板/说明/排序。
|
||||||
|
|
||||||
|
**不含 role_code** —— 编码是参数维度值与模板绑定的锚点。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
business_role_service.update_role(
|
||||||
|
session, role_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{role_id}/status", response_model=ApiResponse[BusinessRoleOut], summary="启用/禁用岗位"
|
||||||
|
)
|
||||||
|
def set_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
role_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessRoleStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessRoleOut]:
|
||||||
|
"""启用或禁用岗位。内置岗位不可停用。"""
|
||||||
|
result = business_role_service.set_status(
|
||||||
|
session, role_id, payload.status, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除岗位")
|
||||||
|
def delete_role(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
role_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。"""
|
||||||
|
business_role_service.delete_role(session, role_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="岗位已删除")
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""计提公式模板管理接口(管理员)。"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path
|
||||||
|
|
||||||
|
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||||
|
from app.core.response import ApiResponse, ok
|
||||||
|
from app.schemas.calc_template import (
|
||||||
|
CalcTemplateCreateIn,
|
||||||
|
CalcTemplateOut,
|
||||||
|
CalcTemplateUpdateIn,
|
||||||
|
FormulaOptionOut,
|
||||||
|
)
|
||||||
|
from app.services import calc_template_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/calc-templates", tags=["系统管理 · 公式模板"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[list[CalcTemplateOut]], summary="模板列表")
|
||||||
|
def list_templates(session: DbSession, _: AdminUser) -> ApiResponse[list[CalcTemplateOut]]:
|
||||||
|
"""取全部模板(含停用与岗位引用计数)。"""
|
||||||
|
return ok(calc_template_service.list_templates(session).records)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meta/formulas", response_model=ApiResponse[list[FormulaOptionOut]], summary="公式骨架下拉")
|
||||||
|
def list_formula_options(_: AdminUser) -> ApiResponse[list[FormulaOptionOut]]:
|
||||||
|
"""取受控公式骨架集合(新增模板时选择)。"""
|
||||||
|
return ok([FormulaOptionOut.model_validate(row) for row in calc_template_service.list_formula_options()])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[CalcTemplateOut], summary="新增模板")
|
||||||
|
def create_template(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
payload: CalcTemplateCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CalcTemplateOut]:
|
||||||
|
"""新增公式模板。编码唯一、骨架受控、参数绑定按骨架角色校验。"""
|
||||||
|
return ok(
|
||||||
|
calc_template_service.create_template(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="模板已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{template_id}", response_model=ApiResponse[CalcTemplateOut], summary="编辑模板")
|
||||||
|
def update_template(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
template_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: CalcTemplateUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CalcTemplateOut]:
|
||||||
|
"""编辑模板名称/说明/排序;自定义模板可调整参数绑定。"""
|
||||||
|
return ok(
|
||||||
|
calc_template_service.update_template(session, template_id, payload, operator=current_user, meta=meta),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{template_id}", response_model=ApiResponse[None], summary="删除模板")
|
||||||
|
def delete_template(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
template_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除模板。系统模板禁删;被岗位引用禁删。"""
|
||||||
|
calc_template_service.delete_template(session, template_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="模板已删除")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""系数表查询、维护与工作簿导入导出,复用计提参数权限。"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, UploadFile
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, LoginUser, RequestMetaDep
|
||||||
|
from app.core.response import ok
|
||||||
|
from app.schemas.coefficient import CoefficientChangeIn
|
||||||
|
from app.services import coefficient_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/coefficients", tags=["计提系数表"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def catalog(session: DbSession, _: LoginUser, as_of: date | None = None):
|
||||||
|
return ok(coefficient_service.catalog(session, as_of))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("")
|
||||||
|
def save(payload: CoefficientChangeIn, session: DbSession, actor: LoginUser, meta: RequestMetaDep):
|
||||||
|
return ok(coefficient_service.save(session, payload, actor, meta))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
def export(session: DbSession, _: LoginUser, as_of: date | None = None):
|
||||||
|
return Response(
|
||||||
|
coefficient_service.workbook(session, as_of),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="coefficients.xlsx"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-preview")
|
||||||
|
def import_preview(session: DbSession, _: LoginUser, file: UploadFile = File(...)):
|
||||||
|
return ok(coefficient_service.import_preview(session, file.file.read(2 * 1024 * 1024 + 1)))
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""日志查询接口(需求 2.5.1:支持日志查询)。
|
||||||
|
|
||||||
|
操作日志与登录日志是两个独立端点:两表字段与筛选项不同(前者含模块/操作/
|
||||||
|
请求详情,后者含失败原因),合并会让 `action` 等参数产生二义;前端正好是
|
||||||
|
两个 tab。操作日志另有单条详情(列表为性能排除 `request_data`)。
|
||||||
|
|
||||||
|
均要求管理员权限;只读查询不写审计(读操作记日志只会淹没审计表)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import AdminUser, DbSession
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.schemas.log import (
|
||||||
|
LoginLogOut,
|
||||||
|
LoginLogQuery,
|
||||||
|
OperationLogDetailOut,
|
||||||
|
OperationLogOut,
|
||||||
|
OperationLogQuery,
|
||||||
|
)
|
||||||
|
from app.services import log_service
|
||||||
|
|
||||||
|
operation_router = APIRouter(prefix="/operation-logs", tags=["系统管理 · 日志"])
|
||||||
|
login_router = APIRouter(prefix="/login-logs", tags=["系统管理 · 日志"])
|
||||||
|
|
||||||
|
|
||||||
|
@operation_router.get(
|
||||||
|
"", response_model=ApiResponse[PageResult[OperationLogOut]], summary="操作日志"
|
||||||
|
)
|
||||||
|
def list_operation_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: AdminUser,
|
||||||
|
query: Annotated[OperationLogQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[OperationLogOut]]:
|
||||||
|
"""分页查询操作日志,支持按操作人/模块/操作类型/结果/时间范围筛选。
|
||||||
|
|
||||||
|
按 id 倒序(时间倒序的稳定实现)。列表**不含** `request_data`(大字段),
|
||||||
|
展开详情请调 `GET /operation-logs/{log_id}`。
|
||||||
|
"""
|
||||||
|
return ok(log_service.list_operation_logs(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@operation_router.get(
|
||||||
|
"/{log_id}", response_model=ApiResponse[OperationLogDetailOut], summary="操作日志详情"
|
||||||
|
)
|
||||||
|
def get_operation_log(
|
||||||
|
session: DbSession,
|
||||||
|
_: AdminUser,
|
||||||
|
log_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[OperationLogDetailOut]:
|
||||||
|
"""查询单条操作日志详情(含已脱敏的请求参数)。
|
||||||
|
|
||||||
|
前端展开日志行时按 id 按需加载 —— 避免列表翻页携带大字段。
|
||||||
|
"""
|
||||||
|
return ok(log_service.get_operation_log(session, log_id))
|
||||||
|
|
||||||
|
|
||||||
|
@login_router.get("", response_model=ApiResponse[PageResult[LoginLogOut]], summary="登录日志")
|
||||||
|
def list_login_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: AdminUser,
|
||||||
|
query: Annotated[LoginLogQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[LoginLogOut]]:
|
||||||
|
"""分页查询登录日志,支持按用户名/IP/结果/时间范围筛选。"""
|
||||||
|
return ok(log_service.list_login_logs(session, query))
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""系统监控只读接口;全部复用管理员依赖,不提供运维写操作。"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path, Query, Request
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.api.deps import AdminUser, DbSession
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.schemas.monitor import EventOut, EventQuery, MonitorWindow, OverviewOut
|
||||||
|
from app.services import monitor_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/monitor", tags=["系统管理 · 系统监控"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overview", response_model=ApiResponse[OverviewOut], summary="健康总览")
|
||||||
|
def overview(request: Request, session: DbSession, _: AdminUser):
|
||||||
|
return ok(monitor_service.overview(session, request.app.state.monitor))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trends", response_model=ApiResponse[dict], summary="分钟级监控趋势")
|
||||||
|
def trends(
|
||||||
|
request: Request, session: DbSession, _: AdminUser, query: Annotated[MonitorWindow, Query()]
|
||||||
|
):
|
||||||
|
return ok(monitor_service.trends(session, request.app.state.monitor, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/interfaces", response_model=ApiResponse[dict], summary="接口性能排行")
|
||||||
|
def interfaces(
|
||||||
|
request: Request, session: DbSession, _: AdminUser, query: Annotated[MonitorWindow, Query()]
|
||||||
|
):
|
||||||
|
return ok(monitor_service.interfaces(session, request.app.state.monitor, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events", response_model=ApiResponse[PageResult[EventOut]], summary="诊断事件")
|
||||||
|
def events(session: DbSession, _: AdminUser, query: Annotated[EventQuery, Query()]):
|
||||||
|
return ok(monitor_service.events(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events/{event_id}", response_model=ApiResponse[EventOut], summary="诊断事件详情")
|
||||||
|
def event(session: DbSession, _: AdminUser, event_id: Annotated[int, Path(ge=1)]):
|
||||||
|
return ok(monitor_service.event(session, event_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diagnostic", summary="下载脱敏诊断摘要")
|
||||||
|
def diagnostic(
|
||||||
|
request: Request, session: DbSession, _: AdminUser, query: Annotated[EventQuery, Query()]
|
||||||
|
):
|
||||||
|
return JSONResponse(
|
||||||
|
jsonable_encoder(monitor_service.diagnostic(session, request.app.state.monitor, query)),
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": 'attachment; filename="monitor-diagnostic.json"',
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
|
||||||
|
|
||||||
|
权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读
|
||||||
|
端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作
|
||||||
|
(提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作
|
||||||
|
日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
|
||||||
|
导出为文件流(非统一信封),前端以 blob 接收。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.api.deps import AdminUser, DbSession, LoginUser, RequestMetaDep
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.schemas.param import (
|
||||||
|
ParamBatchDetailOut,
|
||||||
|
ParamBatchOut,
|
||||||
|
ParamBatchQuery,
|
||||||
|
ParamCatalogItemOut,
|
||||||
|
ParamChangeBatchOut,
|
||||||
|
ParamChangeIn,
|
||||||
|
ParamDiffOut,
|
||||||
|
ParamHistoryOut,
|
||||||
|
ParamHistoryQuery,
|
||||||
|
ParamPreviewIn,
|
||||||
|
ParamPreviewOut,
|
||||||
|
)
|
||||||
|
from app.services import param_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"", response_model=ApiResponse[list[ParamCatalogItemOut]], summary="参数目录(含当前生效值)"
|
||||||
|
)
|
||||||
|
def get_catalog(
|
||||||
|
session: DbSession,
|
||||||
|
_: LoginUser,
|
||||||
|
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
|
||||||
|
) -> ApiResponse[list[ParamCatalogItemOut]]:
|
||||||
|
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
|
||||||
|
return ok(param_service.get_catalog(session, as_of=as_of))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/changes", response_model=ApiResponse[ParamChangeBatchOut], summary="提交变更批次")
|
||||||
|
def submit_change(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
payload: ParamChangeIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ParamChangeBatchOut]:
|
||||||
|
"""提交一批参数变更(1~50 项),返回版本号;立即生效或指定日期生效。"""
|
||||||
|
return ok(
|
||||||
|
param_service.submit_change(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="变更已提交",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/changes", response_model=ApiResponse[PageResult[ParamBatchOut]], summary="变更记录分页"
|
||||||
|
)
|
||||||
|
def list_changes(
|
||||||
|
session: DbSession,
|
||||||
|
_: LoginUser,
|
||||||
|
query: Annotated[ParamBatchQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ParamBatchOut]]:
|
||||||
|
"""按生效日期区间分页查询变更批次(创建时间倒序)。"""
|
||||||
|
return ok(param_service.list_batches(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/changes/{batch_id}", response_model=ApiResponse[ParamBatchDetailOut], summary="批次明细"
|
||||||
|
)
|
||||||
|
def get_batch(
|
||||||
|
session: DbSession,
|
||||||
|
_: LoginUser,
|
||||||
|
batch_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ParamBatchDetailOut]:
|
||||||
|
"""批次头 + 逐项旧值 → 新值。"""
|
||||||
|
return ok(param_service.get_batch_detail(session, batch_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/changes/{batch_id}", response_model=ApiResponse[None], summary="撤回未生效批次")
|
||||||
|
def withdraw_batch(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
batch_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""撤回尚未生效的批次;已生效批次不可撤回。"""
|
||||||
|
param_service.withdraw_batch(session, batch_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="批次已撤回")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
|
||||||
|
def compare(
|
||||||
|
session: DbSession,
|
||||||
|
_: LoginUser,
|
||||||
|
from_date: Annotated[date, Query(description="起始业务日期(含)")],
|
||||||
|
to_date: Annotated[date, Query(description="截止业务日期(含)")],
|
||||||
|
) -> ApiResponse[list[ParamDiffOut]]:
|
||||||
|
"""两个业务日期的全量参数快照逐项对比,changed 标记是否变化。"""
|
||||||
|
return ok(param_service.compare(session, from_date=from_date, to_date=to_date))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export", summary="导出变更记录 Excel")
|
||||||
|
def export_changes(
|
||||||
|
session: DbSession,
|
||||||
|
_: LoginUser,
|
||||||
|
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
|
||||||
|
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""变更记录导出为 Excel 文件流(非统一信封,前端以 blob 接收)。"""
|
||||||
|
content = param_service.export_changes(session, start_date=start_date, end_date=end_date)
|
||||||
|
filename = f"param_changes_{date.today():%Y%m%d}.xlsx"
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([content]),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
|
||||||
|
def preview(
|
||||||
|
session: DbSession,
|
||||||
|
_: LoginUser,
|
||||||
|
payload: ParamPreviewIn,
|
||||||
|
) -> ApiResponse[ParamPreviewOut]:
|
||||||
|
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
|
||||||
|
return ok(param_service.preview(session, payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{param_code}/history",
|
||||||
|
response_model=ApiResponse[PageResult[ParamHistoryOut]],
|
||||||
|
summary="参数版本历史",
|
||||||
|
)
|
||||||
|
def get_history(
|
||||||
|
session: DbSession,
|
||||||
|
_: LoginUser,
|
||||||
|
param_code: Annotated[str, Path(max_length=64)],
|
||||||
|
query: Annotated[ParamHistoryQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ParamHistoryOut]]:
|
||||||
|
"""单参数的版本历史(含版本号、变更人与旧值 → 新值),最新在前。"""
|
||||||
|
return ok(param_service.get_history(session, param_code, query))
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""角色管理接口(自主完善模块:需求 2.5.1 仅定义 admin / user 两个角色)。
|
||||||
|
|
||||||
|
保护规则(admin 禁删禁停、有绑定的角色禁删、role_code 不可改)见
|
||||||
|
`role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。
|
||||||
|
|
||||||
|
全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service
|
||||||
|
层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleStatusIn, RoleUpdateIn
|
||||||
|
from app.services import role_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/roles", tags=["系统管理 · 角色"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[RoleManageOut]], summary="角色列表")
|
||||||
|
def list_roles(
|
||||||
|
session: DbSession,
|
||||||
|
_: AdminUser,
|
||||||
|
query: Annotated[RoleQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[RoleManageOut]]:
|
||||||
|
"""分页查询角色。
|
||||||
|
|
||||||
|
与用户表单的角色下拉口径不同:**本列表含停用角色**(下拉只给启用项)。
|
||||||
|
每行附带 `user_count`(绑定用户数),供前端提示删除保护。
|
||||||
|
"""
|
||||||
|
return ok(role_service.list_roles(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[RoleManageOut], summary="新增角色")
|
||||||
|
def create_role(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
payload: RoleCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[RoleManageOut]:
|
||||||
|
"""新增角色。编码全局唯一、创建后不可修改;新角色默认启用。"""
|
||||||
|
return ok(
|
||||||
|
role_service.create_role(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="角色已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{role_id}", response_model=ApiResponse[RoleManageOut], summary="编辑角色")
|
||||||
|
def update_role(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
role_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: RoleUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[RoleManageOut]:
|
||||||
|
"""编辑角色名称/说明/排序。
|
||||||
|
|
||||||
|
**不含 role_code** —— 编码是授权判定(`ROLE_ADMIN` 常量)的锚点。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
role_service.update_role(session, role_id, payload, operator=current_user, meta=meta),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{role_id}/status", response_model=ApiResponse[RoleManageOut], summary="启用/禁用角色")
|
||||||
|
def set_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
role_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: RoleStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[RoleManageOut]:
|
||||||
|
"""启用或禁用角色。
|
||||||
|
|
||||||
|
停用**不解除**用户绑定、也不影响既有授权(授权判定只看编码、不看状态);
|
||||||
|
内置管理员角色不可停用。
|
||||||
|
"""
|
||||||
|
result = role_service.set_status(
|
||||||
|
session, role_id, payload.status, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除角色")
|
||||||
|
def delete_role(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
role_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除角色。
|
||||||
|
|
||||||
|
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)——
|
||||||
|
`user_roles` 外键是 CASCADE,后端拦截是防止静默剥离权限的唯一防线。
|
||||||
|
"""
|
||||||
|
role_service.delete_role(session, role_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="角色已删除")
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""系统设置接口(公司信息)。
|
||||||
|
|
||||||
|
`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时
|
||||||
|
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),
|
||||||
|
仅暴露两个非敏感展示字段。
|
||||||
|
|
||||||
|
`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径
|
||||||
|
(二进制响应套 JSON 信封无意义)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Response, UploadFile
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||||
|
from app.core.exceptions import BadRequestError
|
||||||
|
from app.core.response import ApiResponse, ok
|
||||||
|
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
|
||||||
|
from app.services import system_setting_service as service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
|
||||||
|
|
||||||
|
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
|
||||||
|
def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
|
||||||
|
"""取公司信息(管理端)。"""
|
||||||
|
return ok(service.to_out(service.get_setting(session)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称")
|
||||||
|
def update_settings(
|
||||||
|
payload: SettingUpdateIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[SettingOut]:
|
||||||
|
"""更新公司名称(审计留痕)。"""
|
||||||
|
return ok(
|
||||||
|
service.update_company(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logo", response_model=ApiResponse[SettingOut], summary="上传 logo")
|
||||||
|
def upload_logo(
|
||||||
|
file: UploadFile,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[SettingOut]:
|
||||||
|
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。
|
||||||
|
|
||||||
|
同步 `def`:读文件、磁盘写与审计提交都是阻塞操作,async 路由里会让
|
||||||
|
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
|
||||||
|
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
|
||||||
|
"""
|
||||||
|
if file.size is not None and file.size > service.LOGO_MAX_BYTES:
|
||||||
|
raise BadRequestError("logo 文件不能超过 2MB")
|
||||||
|
content = file.file.read()
|
||||||
|
ext = (file.filename or "").rsplit(".", 1)[-1]
|
||||||
|
return ok(
|
||||||
|
service.save_logo(session, content, ext, operator=current_user, meta=meta),
|
||||||
|
message="logo 已更新",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
|
||||||
|
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
|
||||||
|
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=300"
|
||||||
|
return ok(service.to_public_out(service.get_setting(session)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logo/public", summary="logo 图片(匿名)")
|
||||||
|
def get_logo_public(session: DbSession) -> FileResponse:
|
||||||
|
"""logo 文件流(无鉴权)。no-cache:图片替换后由版本参数破缓存兜底。"""
|
||||||
|
path, ext = service.read_logo(session)
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
|
||||||
|
headers={"Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""管理员管理 T+ 只读任务;长任务交给独立 worker 执行。"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, WorkbenchReader
|
||||||
|
from app.api.v1.system.tplus_workbench import router as workbench_router
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.schemas.tplus import (
|
||||||
|
TPlusAutomationOverview,
|
||||||
|
TPlusAutomationUpdate,
|
||||||
|
TPlusBatchDetailOut,
|
||||||
|
TPlusBatchOut,
|
||||||
|
TPlusBatchQuery,
|
||||||
|
TPlusCreateIn,
|
||||||
|
TPlusErrorOut,
|
||||||
|
TPlusErrorQuery,
|
||||||
|
TPlusOverviewOut,
|
||||||
|
TPlusRetryIn,
|
||||||
|
TPlusRuntimeOut,
|
||||||
|
TPlusRuntimeUpdate,
|
||||||
|
)
|
||||||
|
from app.services.tplus import automation_config, query_service, runtime_control, task_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"])
|
||||||
|
router.include_router(workbench_router)
|
||||||
|
BatchNumber = Annotated[str, Path(min_length=1, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overview", response_model=ApiResponse[TPlusOverviewOut])
|
||||||
|
def overview(session: DbSession, _: WorkbenchReader):
|
||||||
|
"""查询配置状态、任务统计和最近核验。"""
|
||||||
|
return ok(query_service.overview(session))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/automation", response_model=ApiResponse[TPlusAutomationOverview])
|
||||||
|
def update_automation(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep,
|
||||||
|
payload: TPlusAutomationUpdate):
|
||||||
|
"""保存自动排队开关;既有活动任务继续执行。"""
|
||||||
|
return ok(automation_config.save(session, payload, task_service.TaskActor(admin, meta)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runtime", response_model=ApiResponse[TPlusRuntimeOut])
|
||||||
|
def runtime_status(session: DbSession, _: WorkbenchReader):
|
||||||
|
return ok(runtime_control.overview(session))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/runtime", response_model=ApiResponse[TPlusRuntimeOut])
|
||||||
|
def update_runtime(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep,
|
||||||
|
payload: TPlusRuntimeUpdate):
|
||||||
|
return ok(runtime_control.save(session, payload, task_service.TaskActor(admin, meta)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]])
|
||||||
|
def batches(session: DbSession, _: WorkbenchReader, query: Annotated[TPlusBatchQuery, Query()]):
|
||||||
|
"""分页查询批次元数据。"""
|
||||||
|
return ok(query_service.list_batches(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batches/{batch_no}", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||||
|
def detail(session: DbSession, _: WorkbenchReader, batch_no: BatchNumber):
|
||||||
|
"""查询批次详情与受控的核验报告。"""
|
||||||
|
return ok(query_service.get_batch(session, batch_no))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batches/{batch_no}/errors", response_model=ApiResponse[PageResult[TPlusErrorOut]])
|
||||||
|
def errors(
|
||||||
|
session: DbSession,
|
||||||
|
_: WorkbenchReader,
|
||||||
|
batch_no: BatchNumber,
|
||||||
|
query: Annotated[TPlusErrorQuery, Query()],
|
||||||
|
):
|
||||||
|
"""分页查询异常定位和固定错误说明。"""
|
||||||
|
return ok(query_service.list_errors(session, batch_no, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batches", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||||
|
def create(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep, payload: TPlusCreateIn):
|
||||||
|
"""将读取或核验任务持久化排队。"""
|
||||||
|
batch = task_service.create_task(session, payload, task_service.TaskActor(admin, meta))
|
||||||
|
return ok(query_service.get_batch(session, batch.batch_no))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batches/{batch_no}/retry", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||||
|
def retry(
|
||||||
|
session: DbSession,
|
||||||
|
admin: PayrollWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
batch_no: BatchNumber,
|
||||||
|
payload: TPlusRetryIn,
|
||||||
|
):
|
||||||
|
"""新建重试批次,保留原批次与异常历史。"""
|
||||||
|
batch = task_service.retry_task(session, batch_no, payload, task_service.TaskActor(admin, meta))
|
||||||
|
return ok(query_service.get_batch(session, batch.batch_no))
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""T+ 基础导入和预演的管理员入口;HTTP 不执行长查询或核算。"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path, Query
|
||||||
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, PayrollWriter, WorkbenchReader
|
||||||
|
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||||
|
from app.schemas.tplus_business_sync import BusinessSyncRun, BusinessSyncUpdate, SyncRange
|
||||||
|
from app.schemas.tplus_workbench import (
|
||||||
|
CorrectionPairIn,
|
||||||
|
FactIn,
|
||||||
|
SourceChoiceQuery,
|
||||||
|
WorkCreateIn,
|
||||||
|
WorkQuery,
|
||||||
|
)
|
||||||
|
from app.services.tplus import business_sync_control, workbench
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
RunNo = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]+$", max_length=32)]
|
||||||
|
|
||||||
|
@router.get("/business-sync")
|
||||||
|
def business_sync_config(session: DbSession, _: WorkbenchReader):
|
||||||
|
return ok(business_sync_control.configuration(session))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/business-sync")
|
||||||
|
def business_sync_save(session: DbSession, actor: PayrollWriter, payload: BusinessSyncUpdate):
|
||||||
|
return ok(business_sync_control.save(session, payload, actor.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/business-sync/preview")
|
||||||
|
def business_sync_preview(session: DbSession, _: PayrollWriter, payload: SyncRange):
|
||||||
|
return ok(business_sync_control.readonly_preview(session, payload.start_date))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/business-sync/runs")
|
||||||
|
def business_sync_run(session: DbSession, actor: PayrollWriter, payload: BusinessSyncRun):
|
||||||
|
return ok(business_sync_control.queue(session, actor.id, payload.start_date, payload.request_key))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/correction-pairs")
|
||||||
|
def save_correction_pair(session: DbSession, actor: PayrollWriter, payload: CorrectionPairIn):
|
||||||
|
"""成对排除转客户更正单,将实际回款关联原业务,保留实际到账日。"""
|
||||||
|
from app.services.tplus.corrections import save_pair
|
||||||
|
|
||||||
|
return ok(save_pair(session, payload, actor.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/facts", response_model=ApiResponse[PageResult[dict]])
|
||||||
|
def facts(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):
|
||||||
|
"""有界查询每项补充资料的当前版本。"""
|
||||||
|
return ok(workbench.list_facts(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/facts", response_model=ApiResponse[dict])
|
||||||
|
def save_fact(session: DbSession, admin: PayrollWriter, payload: FactIn):
|
||||||
|
"""追加有依据的人工事实;旧版本不覆盖。"""
|
||||||
|
return ok(workbench.save_fact(session, payload, admin.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/facts/{fact_id}/history", response_model=ApiResponse[PageResult[dict]])
|
||||||
|
def fact_history(
|
||||||
|
session: DbSession, _: WorkbenchReader, fact_id: int, query: Annotated[PageQuery, Query()]
|
||||||
|
):
|
||||||
|
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
|
||||||
|
return ok(workbench.fact_history(session, fact_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/work-runs", response_model=ApiResponse[dict])
|
||||||
|
def create_work(session: DbSession, admin: PayrollWriter, payload: WorkCreateIn):
|
||||||
|
"""创建导入预览、执行导入或核算预演任务。"""
|
||||||
|
return ok(workbench.queue_work(session, payload, admin.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/work-runs", response_model=ApiResponse[PageResult[dict]])
|
||||||
|
def work_runs(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):
|
||||||
|
"""分页查询任务,所有金额结果仅为预演。"""
|
||||||
|
return ok(workbench.list_work(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/work-runs/{run_no}", response_model=ApiResponse[dict])
|
||||||
|
def work_detail(session: DbSession, _: WorkbenchReader, run_no: RunNo):
|
||||||
|
"""查询规则版本、输入指纹及结果。"""
|
||||||
|
return ok(workbench.work_out(workbench.get_run(session, run_no)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/work-runs/{run_no}/lines", response_model=ApiResponse[PageResult[dict]])
|
||||||
|
def work_lines(
|
||||||
|
session: DbSession, _: WorkbenchReader, run_no: RunNo, query: Annotated[WorkQuery, Query()]
|
||||||
|
):
|
||||||
|
"""分页查看新增、待补及分配明细,不公开原始快照 JSON。"""
|
||||||
|
return ok(workbench.list_lines(session, run_no, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/source-records", response_model=ApiResponse[PageResult[dict]])
|
||||||
|
def source_choices(
|
||||||
|
session: DbSession,
|
||||||
|
_: WorkbenchReader,
|
||||||
|
query: Annotated[SourceChoiceQuery, Query()],
|
||||||
|
):
|
||||||
|
"""仅提供挑选资料所需的源编号和名称,不返回电话、地址和原始载荷。"""
|
||||||
|
return ok(workbench.source_choices(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/work-runs/{run_no}/report")
|
||||||
|
def work_report(session: DbSession, _: WorkbenchReader, run_no: RunNo):
|
||||||
|
"""导出全部预演明细,防止源单号被电子表格当作公式执行。"""
|
||||||
|
return StreamingResponse(
|
||||||
|
workbench.report_csv(session, run_no),
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{run_no}-preview.csv"'},
|
||||||
|
)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""用户管理接口(需求 2.5.1:用户账号管理)。
|
||||||
|
|
||||||
|
全部要求管理员权限 —— 按需求「系统管理员(全部权限)、普通用户(业务操作)」,
|
||||||
|
用户管理属管理员专属。
|
||||||
|
|
||||||
|
删除、禁用、改角色三处都有**自我保护**:不允许对当前登录账号执行,
|
||||||
|
否则管理员误操作会立刻失去全部管理权限且无法自行恢复。
|
||||||
|
|
||||||
|
全部写操作经 `RequestMetaDep` 带入请求元信息后由 service 层写入操作日志
|
||||||
|
(成功与失败都记),操作人为当前登录管理员。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.schemas.role import RoleOut
|
||||||
|
from app.schemas.user import (
|
||||||
|
PasswordResetIn,
|
||||||
|
UserCreateIn,
|
||||||
|
UserOut,
|
||||||
|
UserQuery,
|
||||||
|
UserStatusIn,
|
||||||
|
UserUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import user_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["系统管理 · 用户"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[UserOut]], summary="用户列表")
|
||||||
|
def list_users(
|
||||||
|
session: DbSession,
|
||||||
|
_: AdminUser,
|
||||||
|
query: Annotated[UserQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[UserOut]]:
|
||||||
|
"""分页查询用户,支持按用户名/姓名模糊、状态与角色筛选。"""
|
||||||
|
return ok(user_service.list_users(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=ApiResponse[UserOut], summary="用户详情")
|
||||||
|
def get_user(
|
||||||
|
session: DbSession,
|
||||||
|
_: AdminUser,
|
||||||
|
user_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[UserOut]:
|
||||||
|
"""查询用户详情。"""
|
||||||
|
return ok(user_service.get_user(session, user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[UserOut], summary="新增用户")
|
||||||
|
def create_user(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
payload: UserCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[UserOut]:
|
||||||
|
"""新增用户。用户名须唯一,至少指定一个角色。"""
|
||||||
|
return ok(
|
||||||
|
user_service.create_user(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="用户已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}", response_model=ApiResponse[UserOut], summary="编辑用户")
|
||||||
|
def update_user(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
user_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: UserUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[UserOut]:
|
||||||
|
"""编辑用户的基本信息与角色。
|
||||||
|
|
||||||
|
**不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
user_service.update_user(session, user_id, payload, operator=current_user, meta=meta),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", response_model=ApiResponse[None], summary="删除用户")
|
||||||
|
def delete_user(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
user_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除用户。
|
||||||
|
|
||||||
|
物理删除。历史操作日志通过 `user_id` 置空 + 冗余用户名保留可追溯性。
|
||||||
|
业务上更推荐用「禁用」代替删除 —— 禁用保留完整审计线索。
|
||||||
|
"""
|
||||||
|
user_service.delete_user(session, user_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="用户已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/password", response_model=ApiResponse[None], summary="重置密码")
|
||||||
|
def reset_password(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
user_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: PasswordResetIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""重置用户密码。
|
||||||
|
|
||||||
|
会同时提升该用户的令牌版本,使其**已签发的令牌立即失效** ——
|
||||||
|
否则改密后旧令牌在有效期内仍可用。
|
||||||
|
"""
|
||||||
|
user_service.reset_password(
|
||||||
|
session, user_id, payload.new_password, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(message="密码已重置,该用户需重新登录")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/status", response_model=ApiResponse[UserOut], summary="启用/禁用用户")
|
||||||
|
def set_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
user_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: UserStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[UserOut]:
|
||||||
|
"""启用或禁用用户。
|
||||||
|
|
||||||
|
禁用会提升令牌版本使已签发令牌立即失效,否则被禁用者仍可凭旧令牌
|
||||||
|
继续访问直到过期(最长 2 小时)。
|
||||||
|
"""
|
||||||
|
result = user_service.set_status(
|
||||||
|
session, user_id, payload.status, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meta/roles", response_model=ApiResponse[list[RoleOut]], summary="角色下拉选项")
|
||||||
|
def list_roles(session: DbSession, _: AdminUser) -> ApiResponse[list[RoleOut]]:
|
||||||
|
"""取全部启用角色,供用户表单的角色选择使用。
|
||||||
|
|
||||||
|
路径放在 `/users/meta/roles` 而非 `/roles`,是为了避免与
|
||||||
|
`/{user_id}` 的路由匹配冲突(`roles` 会被当成 user_id 解析而报 422)。
|
||||||
|
"""
|
||||||
|
return ok(user_service.list_roles(session))
|
||||||
+180
-1
@@ -10,14 +10,25 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import getpass
|
import getpass
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
|
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
|
||||||
from app.core.db import dispose_engine, get_session_factory
|
from app.core.db import dispose_engine, get_session_factory
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
|
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
|
||||||
from app.models import Role, User, UserRole
|
from app.models import Role, User, UserRole
|
||||||
|
from app.repositories.tplus_repository import get_batch
|
||||||
|
from app.services.tplus.sync_service import TPlusSyncService
|
||||||
|
from app.services.tplus.worker import run_once
|
||||||
|
|
||||||
|
|
||||||
def _prompt_password() -> str:
|
def _prompt_password() -> str:
|
||||||
@@ -80,6 +91,115 @@ def _create_admin(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_tplus(args: argparse.Namespace) -> int:
|
||||||
|
"""读取 T+ 源库并写入 MySQL staging。"""
|
||||||
|
target_factory = get_session_factory()
|
||||||
|
source_factory = get_tplus_session_factory()
|
||||||
|
watermark = datetime.fromisoformat(args.watermark) if args.watermark else None
|
||||||
|
with source_factory() as source_session, target_factory() as target_session:
|
||||||
|
batch = TPlusSyncService(source_session, target_session).run(
|
||||||
|
entity_types=args.entity,
|
||||||
|
watermark=watermark,
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"T+ staging 完成:批次={batch.batch_no} 状态={batch.status} "
|
||||||
|
f"源记录={batch.source_record_count} 新增={batch.staged_record_count} "
|
||||||
|
f"失败={batch.failed_record_count}"
|
||||||
|
)
|
||||||
|
return 0 if batch.status == "SUCCEEDED" else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _tplus_worker(args: argparse.Namespace) -> int:
|
||||||
|
"""独立消费持久化任务;HTTP 进程不承担长时间读取。"""
|
||||||
|
if args.connection_file:
|
||||||
|
settings = get_settings()
|
||||||
|
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
||||||
|
if not 2 <= args.poll_seconds <= 60:
|
||||||
|
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
factory = get_session_factory()
|
||||||
|
settings = get_settings()
|
||||||
|
stop_file = getattr(args, "stop_file", None) or settings.t_plus_stop_file
|
||||||
|
stopped = threading.Event()
|
||||||
|
owner = str(uuid4())
|
||||||
|
previous_handlers = {}
|
||||||
|
if threading.current_thread() is threading.main_thread():
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
previous_handlers[sig] = signal.signal(sig, lambda *_: stopped.set())
|
||||||
|
try:
|
||||||
|
while not stopped.is_set() and not (stop_file and Path(stop_file).exists()):
|
||||||
|
with factory() as session:
|
||||||
|
batch_no = run_once(session, worker_owner=owner)
|
||||||
|
batch = get_batch(session, batch_no) if batch_no else None
|
||||||
|
if batch_no and batch is None:
|
||||||
|
from app.models import TPlusWorkRun
|
||||||
|
batch = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == batch_no))
|
||||||
|
status = batch.status if batch else None
|
||||||
|
if batch_no:
|
||||||
|
print(f"任务完成:{batch_no} 状态={status}", flush=True)
|
||||||
|
if not args.watch:
|
||||||
|
return 0 if status in (None, "SUCCEEDED") else 1
|
||||||
|
if batch_no is None or status != "SUCCEEDED":
|
||||||
|
stopped.wait(args.poll_seconds)
|
||||||
|
print("T+读取服务已停止领取新任务", flush=True)
|
||||||
|
return 0
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("T+ 读取服务已停止")
|
||||||
|
return 130
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
for sig, handler in previous_handlers.items():
|
||||||
|
signal.signal(sig, handler)
|
||||||
|
try:
|
||||||
|
from app.services.tplus.automation import heartbeat
|
||||||
|
|
||||||
|
heartbeat(factory, settings, owner, "STOPPED")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"T+停止状态未能写入({type(exc).__name__}),请检查迁移与运行配置", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _maintenance(args):
|
||||||
|
from app.services.tplus.automation import tick
|
||||||
|
|
||||||
|
if args.connection_file:
|
||||||
|
settings = get_settings()
|
||||||
|
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
||||||
|
if args.stop_file:
|
||||||
|
get_settings().t_plus_stop_file = args.stop_file
|
||||||
|
with get_session_factory()() as session:
|
||||||
|
result = tick(session)
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _tplus_supervisor(args):
|
||||||
|
from app.services.tplus.supervisor import run
|
||||||
|
|
||||||
|
return run(get_session_factory(), get_settings(), interval=args.poll_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _materials(args):
|
||||||
|
from app.services.tplus.materials import collect_materials, render_markdown
|
||||||
|
|
||||||
|
if args.connection_file:
|
||||||
|
settings = get_settings()
|
||||||
|
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
||||||
|
output = Path(args.output_dir).resolve()
|
||||||
|
targets = [output / f"materials-{args.customer_id}-{args.month}.{ext}" for ext in ("json", "md")]
|
||||||
|
if any(p.exists() for p in targets):
|
||||||
|
raise RuntimeError("核对包已存在,请指定新的输出目录,避免覆盖既有验收依据")
|
||||||
|
with get_tplus_session_factory()() as source:
|
||||||
|
report = collect_materials(source, args.customer_id, args.month)
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
targets[0].write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
targets[1].write_text(render_markdown(report), encoding="utf-8")
|
||||||
|
print(f"样本核对包已生成;待补{len(report['missing'])}项;真实金额尚未验收:{output}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""命令行入口。"""
|
"""命令行入口。"""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -96,12 +216,71 @@ def main() -> int:
|
|||||||
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
|
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
p_tplus = sub.add_parser("tplus-stage", help="读取 T+ 源库并写入原始 staging")
|
||||||
|
p_tplus.add_argument(
|
||||||
|
"--entity",
|
||||||
|
action="append",
|
||||||
|
choices=[
|
||||||
|
"personnel",
|
||||||
|
"products",
|
||||||
|
"customers",
|
||||||
|
"businesses",
|
||||||
|
"shipments",
|
||||||
|
"receipts",
|
||||||
|
"business_items",
|
||||||
|
"shipment_items",
|
||||||
|
"receipt_items",
|
||||||
|
],
|
||||||
|
required=True,
|
||||||
|
help="实体类型,可重复指定",
|
||||||
|
)
|
||||||
|
p_tplus.add_argument(
|
||||||
|
"--watermark",
|
||||||
|
default=None,
|
||||||
|
help="增量起始时间,ISO 8601 格式;不传表示全量读取",
|
||||||
|
)
|
||||||
|
p_tplus.add_argument("--batch-size", type=int, default=500, help="每次从源库读取的行数")
|
||||||
|
|
||||||
|
p_worker = sub.add_parser("tplus-worker", help="执行已排队的 T+ 读取或关联核验")
|
||||||
|
modes = p_worker.add_mutually_exclusive_group()
|
||||||
|
modes.add_argument("--once", action="store_true", help="执行一个任务后退出(默认)")
|
||||||
|
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
|
||||||
|
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
|
||||||
|
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
|
||||||
|
p_worker.add_argument("--stop-file", default=None, help="检测到停止标记后不再领取新任务")
|
||||||
|
|
||||||
|
p_maintenance = sub.add_parser("tplus-maintain", help="检查每周原始采集与有限重试,不生成工资")
|
||||||
|
p_maintenance.add_argument("--once", action="store_true", help="执行一次检查(默认)")
|
||||||
|
p_maintenance.add_argument("--connection-file", default=None)
|
||||||
|
p_maintenance.add_argument("--stop-file", default=None)
|
||||||
|
p_supervisor = sub.add_parser("tplus-supervisor", help="按页面设置托管 T+ 读取服务")
|
||||||
|
p_supervisor.add_argument("--poll-seconds", type=int, choices=range(2, 61), default=5)
|
||||||
|
p_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资")
|
||||||
|
p_materials.add_argument("--customer-id", type=int, required=True)
|
||||||
|
p_materials.add_argument("--month", required=True)
|
||||||
|
p_materials.add_argument("--connection-file", default=None)
|
||||||
|
p_materials.add_argument("--output-dir", required=True)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return _create_admin(args)
|
if args.command == "create-admin":
|
||||||
|
return _create_admin(args)
|
||||||
|
if args.command == "tplus-worker":
|
||||||
|
return _tplus_worker(args)
|
||||||
|
if args.command == "tplus-maintain":
|
||||||
|
return _maintenance(args)
|
||||||
|
if args.command == "tplus-supervisor":
|
||||||
|
return _tplus_supervisor(args)
|
||||||
|
if args.command == "tplus-materials":
|
||||||
|
return _materials(args)
|
||||||
|
return _stage_tplus(args)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"命令执行失败({type(exc).__name__}),请检查参数和只读连接配置", file=sys.stderr)
|
||||||
|
return 1
|
||||||
finally:
|
finally:
|
||||||
dispose_engine()
|
dispose_engine()
|
||||||
|
dispose_tplus_engine()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ _WIDTH, _HEIGHT = 120, 40
|
|||||||
_FONT_SIZE = 25
|
_FONT_SIZE = 25
|
||||||
_PADDING = 8
|
_PADDING = 8
|
||||||
|
|
||||||
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
|
# 与前端 v3 配色呼应(--ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper):
|
||||||
_INK = (15, 54, 70)
|
# 墨色字符、次级墨干扰、辅助墨噪点、纸白底
|
||||||
_INTERFERENCE = (126, 156, 170)
|
_INK = (28, 35, 51) # #1c2333
|
||||||
_SPECKLE = (168, 183, 192)
|
_INTERFERENCE = (91, 101, 119) # #5b6577
|
||||||
_PAPER = (247, 246, 242)
|
_SPECKLE = (154, 163, 178) # #9aa3b2
|
||||||
|
_PAPER = (255, 255, 255) # #ffffff
|
||||||
|
|
||||||
_SPECKLE_COUNT = 46
|
_SPECKLE_COUNT = 46
|
||||||
_INTERFERENCE_LINES = 2
|
_INTERFERENCE_LINES = 2
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
@@ -35,7 +36,28 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# ===== 应用 =====
|
# ===== 应用 =====
|
||||||
app_env: str = "dev"
|
app_env: str = "dev"
|
||||||
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符")
|
upload_root: Path | None = Field(default=None, validation_alias="UPLOAD_DIR")
|
||||||
|
|
||||||
|
@field_validator("upload_root", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _empty_upload_root(cls, value):
|
||||||
|
return None if value in (None, "") else value
|
||||||
|
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符", repr=False)
|
||||||
|
payroll_maker_user_id: int | None = Field(
|
||||||
|
default=None, ge=1, description="指定工资制作人的现有账号ID;未设置则业务只读"
|
||||||
|
)
|
||||||
|
payroll_allow_legacy_calculation: bool = False
|
||||||
|
payroll_approval_enabled: bool = False
|
||||||
|
|
||||||
|
# ===== 单进程运行监控;历史只清理监控新表 =====
|
||||||
|
monitor_enabled: bool = True
|
||||||
|
monitor_cpu_threshold: float = Field(default=85, ge=1, le=100)
|
||||||
|
monitor_memory_threshold: float = Field(default=85, ge=1, le=100)
|
||||||
|
monitor_disk_threshold: float = Field(default=90, ge=1, le=100)
|
||||||
|
monitor_resource_duration_seconds: int = Field(default=300, ge=15)
|
||||||
|
monitor_slow_request_ms: int = Field(default=2000, ge=1)
|
||||||
|
monitor_error_rate_threshold: float = Field(default=5, ge=0.1, le=100)
|
||||||
|
monitor_error_min_requests: int = Field(default=20, ge=1)
|
||||||
|
|
||||||
# ===== 鉴权 =====
|
# ===== 鉴权 =====
|
||||||
# 规范第 13 节:Token 有效期 ≤ 2 小时
|
# 规范第 13 节:Token 有效期 ≤ 2 小时
|
||||||
@@ -50,14 +72,39 @@ class Settings(BaseSettings):
|
|||||||
mysql_port: int = 3306
|
mysql_port: int = 3306
|
||||||
mysql_database: str = "payroll"
|
mysql_database: str = "payroll"
|
||||||
mysql_user: str = "payroll"
|
mysql_user: str = "payroll"
|
||||||
mysql_password: str = ""
|
mysql_password: str = Field(default="", repr=False)
|
||||||
|
|
||||||
# ===== Redis =====
|
# ===== Redis =====
|
||||||
redis_host: str = "127.0.0.1"
|
redis_host: str = "127.0.0.1"
|
||||||
redis_port: int = 6379
|
redis_port: int = 6379
|
||||||
redis_password: str = ""
|
redis_password: str = Field(default="", repr=False)
|
||||||
redis_db: int = 0
|
redis_db: int = 0
|
||||||
|
|
||||||
|
# ===== T+ 只读 SQL Server =====
|
||||||
|
# T+ 数据库默认关闭,避免开发者未配置驱动时影响主业务启动。
|
||||||
|
t_plus_enabled: bool = False
|
||||||
|
t_plus_host: str = "127.0.0.1"
|
||||||
|
t_plus_port: int = 1433
|
||||||
|
t_plus_database: str = ""
|
||||||
|
t_plus_user: str = ""
|
||||||
|
t_plus_password: str = Field(default="", repr=False)
|
||||||
|
t_plus_allow_dev_sa: bool = False
|
||||||
|
t_plus_allow_admin_account: bool = False
|
||||||
|
t_plus_connection_file: str = ""
|
||||||
|
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
|
||||||
|
t_plus_odbc_driver: str = ""
|
||||||
|
t_plus_encrypt: bool = True
|
||||||
|
# 默认校验服务器证书;自签回退只允许隔离开发显式开启,避免中间人替换证书。
|
||||||
|
t_plus_trust_server_certificate: bool = False
|
||||||
|
t_plus_connect_timeout: int = 10
|
||||||
|
t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
|
||||||
|
t_plus_query_dir: str = ""
|
||||||
|
t_plus_automation_enabled: bool = False
|
||||||
|
t_plus_schedule_weekday: int = Field(default=0, ge=0, le=6)
|
||||||
|
t_plus_schedule_time: str = Field(default="02:00", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
|
||||||
|
t_plus_stop_file: str = ""
|
||||||
|
t_plus_supervisor_health_url: str = "http://127.0.0.1:8000/api/health"
|
||||||
|
|
||||||
@field_validator("app_secret_key")
|
@field_validator("app_secret_key")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _check_secret_key(cls, v: str) -> str:
|
def _check_secret_key(cls, v: str) -> str:
|
||||||
@@ -89,9 +136,42 @@ class Settings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def redis_url(self) -> str:
|
def redis_url(self) -> str:
|
||||||
"""Redis 连接 URL。密码为空时不拼接认证段。"""
|
"""Redis 连接 URL。密码为空时不拼接认证段。"""
|
||||||
auth = f":{self.redis_password}@" if self.redis_password else ""
|
auth = f":{quote(self.redis_password, safe='')}@" if self.redis_password else ""
|
||||||
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def t_plus_database_url(self) -> URL:
|
||||||
|
"""构造 T+ 只读 SQL Server 连接 URL。
|
||||||
|
|
||||||
|
连接 URL 只在启用 T+ 适配器时使用;密码由 SQLAlchemy URL 对象保存,
|
||||||
|
日志中调用 ``render_as_string()`` 时仍会默认隐藏密码。
|
||||||
|
ODBC 的分号和花括号由方言统一转义,调用方不得再手工拼接连接串。
|
||||||
|
"""
|
||||||
|
query = {
|
||||||
|
"driver": self.t_plus_odbc_driver,
|
||||||
|
"Encrypt": "yes" if self.t_plus_encrypt else "no",
|
||||||
|
"TrustServerCertificate": "yes" if self.t_plus_trust_server_certificate else "no",
|
||||||
|
"Connection Timeout": str(self.t_plus_connect_timeout),
|
||||||
|
}
|
||||||
|
return URL.create(
|
||||||
|
drivername="mssql+pyodbc",
|
||||||
|
username=self.t_plus_user,
|
||||||
|
password=self.t_plus_password,
|
||||||
|
host=self.t_plus_host,
|
||||||
|
port=self.t_plus_port,
|
||||||
|
database=self.t_plus_database,
|
||||||
|
query=query,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def upload_dir(self) -> Path:
|
||||||
|
"""上传文件根目录(公司 logo 等):backend/uploads/。
|
||||||
|
|
||||||
|
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
|
||||||
|
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
|
||||||
|
"""
|
||||||
|
return self.upload_root.resolve() if self.upload_root else BACKEND_DIR / "uploads"
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ TOKEN_TYPE_BEARER = "Bearer"
|
|||||||
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
|
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
|
||||||
ROLE_ADMIN = "admin"
|
ROLE_ADMIN = "admin"
|
||||||
ROLE_USER = "user"
|
ROLE_USER = "user"
|
||||||
|
# 2026-10-15 复核审批补充需求新增职能登录角色:营销后勤(提交+录入)、
|
||||||
|
# 财务(复核)、领导(审核)。roles 表无 is_builtin 列,内置保护用代码
|
||||||
|
# 白名单判定(role_service 两处内置校验改查此集合);
|
||||||
|
# permissions/role_permissions 细粒度激活待业务权限清单(待澄清 #6)。
|
||||||
|
ROLE_MARKETING_OPS = "marketing_ops"
|
||||||
|
ROLE_FINANCE = "finance"
|
||||||
|
ROLE_LEADER = "leader"
|
||||||
|
BUILTIN_ROLE_CODES = {
|
||||||
|
ROLE_ADMIN,
|
||||||
|
ROLE_MARKETING_OPS,
|
||||||
|
ROLE_FINANCE,
|
||||||
|
ROLE_LEADER,
|
||||||
|
}
|
||||||
|
|
||||||
# ===== 密码策略 =====
|
# ===== 密码策略 =====
|
||||||
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
|
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
|
||||||
@@ -36,3 +49,61 @@ PASSWORD_MAX_BYTES = 72
|
|||||||
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
|
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
|
||||||
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
CAPTCHA_LENGTH = 4
|
CAPTCHA_LENGTH = 4
|
||||||
|
|
||||||
|
# ===== 业务岗位编码(与 db/migration 种子一致,business_roles.role_code)=====
|
||||||
|
BUSINESS_ROLE_SALESPERSON = "salesperson"
|
||||||
|
BUSINESS_ROLE_SALES_MANAGER = "sales_manager"
|
||||||
|
BUSINESS_ROLE_INFO_OFFICER = "info_officer"
|
||||||
|
|
||||||
|
# ===== 产品类别码(与 param_values.dimension_value 口径一致)=====
|
||||||
|
PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
|
||||||
|
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
|
||||||
|
PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT}
|
||||||
|
|
||||||
|
# ===== 客户分类码(customers.customer_type,计提规则匹配锚点)=====
|
||||||
|
CUSTOMER_TYPE_PERSONAL = "personal"
|
||||||
|
CUSTOMER_TYPE_MANAGER_PERSONAL = "manager_personal"
|
||||||
|
CUSTOMER_TYPE_ENTERPRISE = "enterprise"
|
||||||
|
CUSTOMER_TYPES = {
|
||||||
|
CUSTOMER_TYPE_PERSONAL,
|
||||||
|
CUSTOMER_TYPE_MANAGER_PERSONAL,
|
||||||
|
CUSTOMER_TYPE_ENTERPRISE,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
|
||||||
|
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
|
||||||
|
MODULE_USER = "用户管理"
|
||||||
|
MODULE_ROLE = "角色管理"
|
||||||
|
MODULE_BUSINESS_ROLE = "岗位角色"
|
||||||
|
MODULE_PARAM = "计提参数"
|
||||||
|
MODULE_PERSONNEL = "人员管理"
|
||||||
|
MODULE_PRODUCT = "产品管理"
|
||||||
|
MODULE_CUSTOMER = "客户管理"
|
||||||
|
MODULE_BUSINESS = "业务管理"
|
||||||
|
MODULE_SHIPMENT = "发货管理"
|
||||||
|
MODULE_RECEIPT = "回款管理"
|
||||||
|
MODULE_RATING = "客户评级"
|
||||||
|
MODULE_SYSTEM_SETTINGS = "系统设置"
|
||||||
|
MODULE_APPROVAL = "审批管理"
|
||||||
|
MODULE_CALC = "绩效核算"
|
||||||
|
MODULE_SETTLEMENT = "月度结算"
|
||||||
|
MODULE_REPORT = "报表统计"
|
||||||
|
|
||||||
|
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||||
|
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||||
|
NO_PREFIX_BUSINESS = "YW"
|
||||||
|
NO_PREFIX_SHIPMENT = "SH"
|
||||||
|
NO_PREFIX_RECEIPT = "HK"
|
||||||
|
NO_PREFIX_PARAM = "P"
|
||||||
|
NO_PREFIX_APPROVAL = "AP"
|
||||||
|
NO_PREFIX_ADJUSTMENT = "TZ"
|
||||||
|
|
||||||
|
ACTION_CREATE = "新增"
|
||||||
|
ACTION_UPDATE = "修改"
|
||||||
|
ACTION_DELETE = "删除"
|
||||||
|
ACTION_RESET_PASSWORD = "重置密码"
|
||||||
|
ACTION_ENABLE = "启用"
|
||||||
|
ACTION_DISABLE = "禁用"
|
||||||
|
ACTION_APPROVE = "审批通过"
|
||||||
|
ACTION_REJECT = "审批驳回"
|
||||||
|
ACTION_EXPORT = "导出"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""数据库完整性错误的统一识别与翻译口径。
|
||||||
|
|
||||||
|
判断依据:pymysql 原生异常 ``orig.args`` 首位是 MySQL 错误码 —— 只有
|
||||||
|
1062(ER_DUP_ENTRY,唯一键冲突)是「已存在」语义,可安全转 400 业务
|
||||||
|
提示;其余完整性错误(外键 RESTRICT、数据截断等)原样上抛,由全局
|
||||||
|
异常处理器收敛 500(程序缺陷或数据异常,均不应伪装成用户错误)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
# MySQL 唯一键冲突错误码(pymysql 原生异常 args 首位)
|
||||||
|
ERR_DUP_ENTRY = 1062
|
||||||
|
|
||||||
|
|
||||||
|
def is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。"""
|
||||||
|
args = getattr(exc.orig, "args", ())
|
||||||
|
return bool(args) and args[0] == ERR_DUP_ENTRY
|
||||||
@@ -12,6 +12,7 @@ import logging
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
|
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
|
||||||
@@ -60,13 +61,29 @@ async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSON
|
|||||||
return _build_response(exc.status_code, message)
|
return _build_response(exc.status_code, message)
|
||||||
|
|
||||||
|
|
||||||
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
|
async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONResponse:
|
||||||
|
"""完整性错误全局兜底为 400。
|
||||||
|
|
||||||
|
service 层已判别的 1062/引用冲突会自行翻译为业务提示,不会走到这里;
|
||||||
|
本 handler 拦截的是「新增父表后某 service 漏补引用保护」一类的遗漏
|
||||||
|
(e2e 撞见一个补一个的模式无法保证全覆盖):FK RESTRICT 等数据完整性
|
||||||
|
冲突对用户表现为「这行数据被别处引用/冲突」,转 400 比 500 更贴近事实。
|
||||||
|
完整异常记录日志供排查;不在此处 rollback —— 会话由请求级依赖统一关闭。
|
||||||
|
"""
|
||||||
|
logger.error("数据库完整性冲突(未在 service 层翻译)", exc_info=exc)
|
||||||
|
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse:
|
||||||
"""兜底处理未预期异常。
|
"""兜底处理未预期异常。
|
||||||
|
|
||||||
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
|
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
|
||||||
"""
|
"""
|
||||||
logger.exception("未预期的服务端异常")
|
request_id = getattr(request.state, "request_id", "-")
|
||||||
return _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
|
logger.exception("未预期的服务端异常", extra={"request_id": request_id})
|
||||||
|
response = _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def register_exception_handlers(app: FastAPI) -> None:
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
@@ -77,4 +94,5 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
|
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
|
||||||
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
||||||
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
|
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
|
||||||
|
app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type]
|
||||||
app.add_exception_handler(Exception, handle_unexpected_exception)
|
app.add_exception_handler(Exception, handle_unexpected_exception)
|
||||||
@@ -52,3 +52,10 @@ class NotFoundError(AppException):
|
|||||||
|
|
||||||
code = CODE_NOT_FOUND
|
code = CODE_NOT_FOUND
|
||||||
message = "资源不存在"
|
message = "资源不存在"
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(AppException):
|
||||||
|
"""授权版本已变化,客户端应刷新后重新核对。"""
|
||||||
|
|
||||||
|
code = 409
|
||||||
|
message = "配置已被其他人修改,请刷新后重新核对"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""日志配置。
|
"""日志配置。
|
||||||
|
|
||||||
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天+大小切分、
|
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天轮转(当前未做大小上限,磁盘容量需配合监控)、
|
||||||
保留 30 天、禁止输出密码/手机号/身份证/Token。
|
保留 30 天、禁止输出密码/手机号/身份证/Token。
|
||||||
|
|
||||||
Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8,
|
Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8,
|
||||||
@@ -13,6 +13,7 @@ import sys
|
|||||||
from logging.handlers import TimedRotatingFileHandler
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
|
||||||
from app.core.config import BACKEND_DIR
|
from app.core.config import BACKEND_DIR
|
||||||
|
from app.core.monitoring import REQUEST_ID
|
||||||
|
|
||||||
LOG_DIR = BACKEND_DIR / "logs"
|
LOG_DIR = BACKEND_DIR / "logs"
|
||||||
|
|
||||||
@@ -29,8 +30,8 @@ _SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
|
|||||||
),
|
),
|
||||||
# Authorization: Bearer xxx
|
# Authorization: Bearer xxx
|
||||||
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
|
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
|
||||||
# 手机号:138****8888
|
# 手机号:138****8888(兼容带空格/横线分隔的写法,如 138 0013 8000)
|
||||||
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"),
|
(re.compile(r"\b(1[3-9]\d)[-\s]?(\d{4})[-\s]?(\d{4})\b"), r"\1****\3"),
|
||||||
# 18 位身份证:保留前 6 位与后 4 位
|
# 18 位身份证:保留前 6 位与后 4 位
|
||||||
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
|
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
|
||||||
)
|
)
|
||||||
@@ -53,6 +54,8 @@ class SensitiveFormatter(logging.Formatter):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
if not hasattr(record, "request_id"):
|
||||||
|
record.request_id = REQUEST_ID.get()
|
||||||
return scrub(super().format(record))
|
return scrub(super().format(record))
|
||||||
|
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@ def setup_logging(level: str = "INFO", log_to_file: bool = True) -> None:
|
|||||||
root.removeHandler(handler)
|
root.removeHandler(handler)
|
||||||
|
|
||||||
formatter = SensitiveFormatter(
|
formatter = SensitiveFormatter(
|
||||||
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
fmt="%(asctime)s | %(levelname)-8s | %(name)s | request_id=%(request_id)s | %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""有界、线程安全的请求遥测;请求线程只写内存,绝不等待监控数据库。"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
from collections import OrderedDict, deque
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from time import monotonic, perf_counter
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
|
REQUEST_ID: ContextVar[str] = ContextVar("request_id", default="-")
|
||||||
|
SHANGHAI = timezone(timedelta(hours=8))
|
||||||
|
# 固定上界直方图,可相加后求近似 P95;最后一档使用实际最大耗时。
|
||||||
|
BOUNDS = (
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
25,
|
||||||
|
50,
|
||||||
|
100,
|
||||||
|
200,
|
||||||
|
300,
|
||||||
|
500,
|
||||||
|
750,
|
||||||
|
1000,
|
||||||
|
1500,
|
||||||
|
2000,
|
||||||
|
3000,
|
||||||
|
5000,
|
||||||
|
10000,
|
||||||
|
30000,
|
||||||
|
60000,
|
||||||
|
120000,
|
||||||
|
300000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def now_local() -> datetime:
|
||||||
|
return datetime.now(SHANGHAI).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def minute_of(value: datetime) -> datetime:
|
||||||
|
return value.replace(second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def empty_metric() -> dict:
|
||||||
|
return {
|
||||||
|
"count": 0,
|
||||||
|
"errors_5xx": 0,
|
||||||
|
"errors_4xx": 0,
|
||||||
|
"max_ms": 0.0,
|
||||||
|
"histogram": [0] * (len(BOUNDS) + 1),
|
||||||
|
"last_error_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_metrics(items) -> dict:
|
||||||
|
result = empty_metric()
|
||||||
|
for item in items:
|
||||||
|
for key in ("count", "errors_5xx", "errors_4xx"):
|
||||||
|
result[key] += item[key]
|
||||||
|
result["max_ms"] = max(result["max_ms"], item["max_ms"])
|
||||||
|
for index, count in enumerate(item["histogram"]):
|
||||||
|
result["histogram"][index] += count
|
||||||
|
last = item.get("last_error_at")
|
||||||
|
if last and (not result["last_error_at"] or last > result["last_error_at"]):
|
||||||
|
result["last_error_at"] = last
|
||||||
|
result["error_rate"] = (
|
||||||
|
round(result["errors_5xx"] / result["count"] * 100, 2) if result["count"] else 0
|
||||||
|
)
|
||||||
|
result["p95_ms"] = percentile(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def percentile(metric: dict) -> float | None:
|
||||||
|
if not metric["count"]:
|
||||||
|
return None
|
||||||
|
target, count = math.ceil(metric["count"] * 0.95), 0
|
||||||
|
for index, value in enumerate(metric["histogram"]):
|
||||||
|
count += value
|
||||||
|
if count >= target:
|
||||||
|
return min(BOUNDS[index], metric["max_ms"]) if index < len(BOUNDS) else metric["max_ms"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(code: str, component: str = "") -> str:
|
||||||
|
return hashlib.sha256(f"{code}:{component}".encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class Recorder:
|
||||||
|
"""最多保留十分钟、每分钟 200 个路由、2000 条待写事件。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.minutes = OrderedDict()
|
||||||
|
self.events = deque(maxlen=2000)
|
||||||
|
self.dropped_events = 0
|
||||||
|
self.started = monotonic()
|
||||||
|
self.instance_id = uuid4().hex[:16]
|
||||||
|
self.slow_ms = 2000
|
||||||
|
|
||||||
|
def record(self, method, route, status, elapsed, request_id, *, at=None):
|
||||||
|
at = at or now_local()
|
||||||
|
key = minute_of(at)
|
||||||
|
with self.lock:
|
||||||
|
endpoints = self.minutes.setdefault(key, {})
|
||||||
|
endpoint = (method, route)
|
||||||
|
if endpoint not in endpoints and len(endpoints) >= 200:
|
||||||
|
endpoint = ("OTHER", "/__other__")
|
||||||
|
metric = endpoints.setdefault(endpoint, empty_metric())
|
||||||
|
metric["count"] += 1
|
||||||
|
metric["errors_5xx"] += int(status >= 500)
|
||||||
|
metric["errors_4xx"] += int(400 <= status < 500)
|
||||||
|
metric["max_ms"] = max(metric["max_ms"], round(elapsed, 2))
|
||||||
|
bucket = next((i for i, bound in enumerate(BOUNDS) if elapsed <= bound), len(BOUNDS))
|
||||||
|
metric["histogram"][bucket] += 1
|
||||||
|
if status >= 500:
|
||||||
|
metric["last_error_at"] = at.isoformat()
|
||||||
|
while len(self.minutes) > 10:
|
||||||
|
self.minutes.popitem(last=False)
|
||||||
|
if status >= 500 or elapsed >= self.slow_ms:
|
||||||
|
self._event(
|
||||||
|
{
|
||||||
|
"kind": "REQUEST_ERROR" if status >= 500 else "SLOW_REQUEST",
|
||||||
|
"code": "HTTP_5XX" if status >= 500 else "REQUEST_SLOW",
|
||||||
|
"component": "http",
|
||||||
|
"message": "接口处理异常,请按请求编号检索服务端日志。"
|
||||||
|
if status >= 500
|
||||||
|
else "接口响应较慢,请核对同一时段资源和任务。",
|
||||||
|
"request_id": request_id,
|
||||||
|
"method": method,
|
||||||
|
"route": route,
|
||||||
|
"status_code": status,
|
||||||
|
"duration_ms": round(elapsed, 2),
|
||||||
|
"first_seen": at,
|
||||||
|
"last_seen": at,
|
||||||
|
"occurrences": 1,
|
||||||
|
"level": "error" if status >= 500 else "warning",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _event(self, event):
|
||||||
|
if len(self.events) == self.events.maxlen:
|
||||||
|
self.dropped_events += 1
|
||||||
|
self.events.append(event)
|
||||||
|
|
||||||
|
def runtime_error(self, component):
|
||||||
|
with self.lock:
|
||||||
|
at = now_local()
|
||||||
|
self._event(
|
||||||
|
{
|
||||||
|
"kind": "RUNTIME_ERROR",
|
||||||
|
"code": "BACKGROUND_ERROR",
|
||||||
|
"component": component[:128],
|
||||||
|
"message": "后台运行出现错误,请检索服务端日志。",
|
||||||
|
"first_seen": at,
|
||||||
|
"last_seen": at,
|
||||||
|
"occurrences": 1,
|
||||||
|
"level": "error",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def metrics(self, start=None, end=None):
|
||||||
|
with self.lock:
|
||||||
|
return deepcopy(
|
||||||
|
{
|
||||||
|
key: value
|
||||||
|
for key, value in self.minutes.items()
|
||||||
|
if (start is None or key >= start) and (end is None or key < end)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def take_events(self):
|
||||||
|
with self.lock:
|
||||||
|
values = list(self.events)
|
||||||
|
self.events.clear()
|
||||||
|
return values
|
||||||
|
|
||||||
|
def restore_events(self, values):
|
||||||
|
with self.lock:
|
||||||
|
for value in reversed(values):
|
||||||
|
if len(self.events) < self.events.maxlen:
|
||||||
|
self.events.appendleft(value)
|
||||||
|
else:
|
||||||
|
self.dropped_events += 1
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorMiddleware:
|
||||||
|
"""纯 ASGI 中间件:包含异常、流式响应与取消路径,避免读取请求正文。"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp, recorder: Recorder):
|
||||||
|
self.app, self.recorder = app, recorder
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
request_id = uuid4().hex
|
||||||
|
scope.setdefault("state", {})["request_id"] = request_id
|
||||||
|
token = REQUEST_ID.set(request_id)
|
||||||
|
started, status = perf_counter(), 500
|
||||||
|
|
||||||
|
async def with_id(message: Message):
|
||||||
|
nonlocal status
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
status = message["status"]
|
||||||
|
message["headers"] = [
|
||||||
|
(k, v) for k, v in message.get("headers", []) if k.lower() != b"x-request-id"
|
||||||
|
]
|
||||||
|
message["headers"].append((b"x-request-id", request_id.encode()))
|
||||||
|
if scope.get("path", "").startswith("/api/v1/system/monitor"):
|
||||||
|
message["headers"].append((b"cache-control", b"no-store"))
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.app(scope, receive, with_id)
|
||||||
|
except Exception:
|
||||||
|
status = 500
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
route = getattr(scope.get("route"), "path", "/__unmatched__")
|
||||||
|
excluded = route.startswith(
|
||||||
|
("/api/v1/system/monitor", "/api/health", "/api/docs", "/api/openapi")
|
||||||
|
)
|
||||||
|
if not excluded:
|
||||||
|
method = scope.get("method", "OTHER")
|
||||||
|
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}:
|
||||||
|
method = "OTHER"
|
||||||
|
self.recorder.record(
|
||||||
|
method, route, status, (perf_counter() - started) * 1000, request_id
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning("请求遥测写入失败", exc_info=True)
|
||||||
|
finally:
|
||||||
|
REQUEST_ID.reset(token)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""月份参数校验(YYYY-MM)——叶子模块。
|
||||||
|
|
||||||
|
报表、月结与调整单共用同一套校验:只校验长度会放过 `2026-13` 这类
|
||||||
|
非法月份,一路传到 `calendar.monthrange` 才抛 ValueError,最终被全局
|
||||||
|
处理器当成服务端异常(500)。月份范围必须在入口处拦住,才能返回 400。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_month(value: str) -> str:
|
||||||
|
"""校验 YYYY-MM 且月份 01-12;非法抛 ValueError(Pydantic 友好)。
|
||||||
|
|
||||||
|
年份 0000 也拒绝:`date(0, ...)` 同样会抛 ValueError,且业务上不存在。
|
||||||
|
"""
|
||||||
|
if not _MONTH_RE.match(value) or int(value[:4]) == 0:
|
||||||
|
raise ValueError("月份格式须为 YYYY-MM(01-12)")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_month(value: str) -> tuple[int, int]:
|
||||||
|
"""校验并返回 (year, month),供服务层计算月末等使用。"""
|
||||||
|
validate_month(value)
|
||||||
|
return int(value[:4]), int(value[5:7])
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""报表统计元数据(区间桶、中文标签、导出上限)——叶子模块。
|
||||||
|
|
||||||
|
刻意不导入 app 内任何模块:report_service 与 report_export 都要消费这里的
|
||||||
|
定义,若它反向依赖 service 层会形成循环导入;也不走参数体系——FR-031
|
||||||
|
「区间可配置」一期以代码常量落地(参数化需注册表 + 迁移 + init-dev +
|
||||||
|
registry 测试四处同改,业务未提诉求前不做),配置缝收在
|
||||||
|
get_payment_day_buckets 单函数,未来在线可配时只改该函数。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DayBucket:
|
||||||
|
"""回款天数区间档位:min/max 均为 None 表示「无回款天数」兜底桶。"""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
min_days: int | None
|
||||||
|
max_days: int | None
|
||||||
|
|
||||||
|
|
||||||
|
# 8 档区间(催办清单 R2 默认口径)。两个兜底桶必须保留:payment_days 可空
|
||||||
|
# (回款未指定发货批次)且可为负(先款后货照实负值),缺了它们这些行会
|
||||||
|
# 静默消失、金额对不上。
|
||||||
|
PAYMENT_DAY_BUCKETS: tuple[DayBucket, ...] = (
|
||||||
|
DayBucket("le0", "≤0 天(先款后货)", None, 0),
|
||||||
|
DayBucket("1-30", "1-30 天", 1, 30),
|
||||||
|
DayBucket("31-60", "31-60 天", 31, 60),
|
||||||
|
DayBucket("61-90", "61-90 天", 61, 90),
|
||||||
|
DayBucket("91-180", "91-180 天", 91, 180),
|
||||||
|
DayBucket("181-360", "181-360 天", 181, 360),
|
||||||
|
DayBucket("361+", "361 天以上", 361, None),
|
||||||
|
DayBucket("none", "无回款天数", None, None),
|
||||||
|
)
|
||||||
|
|
||||||
|
CUSTOMER_TYPE_LABELS: dict[str, str] = {
|
||||||
|
"personal": "个人户",
|
||||||
|
"manager_personal": "经理个人户",
|
||||||
|
"enterprise": "企业户",
|
||||||
|
}
|
||||||
|
|
||||||
|
# mixed 不在 core.constants.PRODUCT_CATEGORIES(那只是可维护的档案类别),
|
||||||
|
# 但台账行可以是 mixed(混合业务行级明细入 snapshot_json),报表必须能标它
|
||||||
|
PRODUCT_CATEGORY_LABELS: dict[str, str] = {
|
||||||
|
"municipal_water": "市政水利",
|
||||||
|
"rail_transit": "轨道交通",
|
||||||
|
"mixed": "混合业务",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 业务状态中文标签:导出列、筛选页眉与前端标签共用同一份映射
|
||||||
|
BUSINESS_STATUS_LABELS: dict[int, str] = {1: "进行中", 2: "已完成", 3: "已终止"}
|
||||||
|
|
||||||
|
REPORT_TEMPLATE_VERSION = "V1.2"
|
||||||
|
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
|
||||||
|
# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出
|
||||||
|
REPORT_EXPORT_MAX_ROWS = 20000
|
||||||
|
|
||||||
|
|
||||||
|
def get_payment_day_buckets() -> tuple[DayBucket, ...]:
|
||||||
|
"""区间档位唯一出口:前端下拉与分桶都以本函数返回为准,禁止硬编码。
|
||||||
|
|
||||||
|
未来「区间在线可配」时改此处读参数即可,调用方与前端零改动。
|
||||||
|
"""
|
||||||
|
return PAYMENT_DAY_BUCKETS
|
||||||
|
|
||||||
|
|
||||||
|
def bucket_of(payment_days: int | None) -> DayBucket:
|
||||||
|
"""把单笔回款天数落入对应区间桶;未匹配任何区间按「无回款天数」兜底。"""
|
||||||
|
if payment_days is None:
|
||||||
|
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")
|
||||||
|
for bucket in PAYMENT_DAY_BUCKETS:
|
||||||
|
if bucket.code == "none":
|
||||||
|
continue
|
||||||
|
lower_ok = bucket.min_days is None or payment_days >= bucket.min_days
|
||||||
|
upper_ok = bucket.max_days is None or payment_days <= bucket.max_days
|
||||||
|
if lower_ok and upper_ok:
|
||||||
|
return bucket
|
||||||
|
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""T+ 只读 SQL Server 连接。
|
||||||
|
|
||||||
|
T+ 是外部源库,业务接口不直接依赖它。连接采用延迟初始化,未配置或未启用
|
||||||
|
时不会影响主业务服务启动;适配器执行查询时才建立连接池。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Generator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
||||||
|
from sqlalchemy import Engine, create_engine, event
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
|
||||||
|
_engine: Engine | None = None
|
||||||
|
_session_factory: sessionmaker[Session] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class _ConnectionFile(BaseModel):
|
||||||
|
"""连接文件仅允许源库字段,密码不会出现在模型表示中。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", strict=True)
|
||||||
|
host: str = Field(min_length=1, max_length=255)
|
||||||
|
port: int = Field(ge=1, le=65535)
|
||||||
|
database: str = Field(min_length=1, max_length=128)
|
||||||
|
user: str = Field(min_length=1, max_length=128)
|
||||||
|
password: SecretStr
|
||||||
|
driver: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tplus_settings(settings: Settings) -> Settings:
|
||||||
|
"""解析显式配置的只读连接,返回副本,不修改应用全局配置。"""
|
||||||
|
if not settings.t_plus_enabled:
|
||||||
|
raise RuntimeError("T+ 数据源未启用,请设置 T_PLUS_ENABLED=true")
|
||||||
|
resolved = settings
|
||||||
|
if settings.t_plus_connection_file:
|
||||||
|
try:
|
||||||
|
path = Path(settings.t_plus_connection_file)
|
||||||
|
if path.stat().st_size > 16384:
|
||||||
|
raise ValueError("连接文件过大")
|
||||||
|
config = _ConnectionFile.model_validate(
|
||||||
|
json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
# 消息只区分故障类别,不回显路径内容;路径本身可在应用配置中核对。
|
||||||
|
raise RuntimeError("T+ 只读连接文件不存在或不可读") from None
|
||||||
|
except (ValueError, ValidationError):
|
||||||
|
raise RuntimeError("T+ 只读连接文件字段不正确") from None
|
||||||
|
values = {
|
||||||
|
"t_plus_" + key: getattr(config, key) for key in ("host", "port", "database", "user")
|
||||||
|
}
|
||||||
|
values.update(
|
||||||
|
t_plus_password=config.password.get_secret_value(), t_plus_odbc_driver=config.driver
|
||||||
|
)
|
||||||
|
resolved = settings.model_copy(update=values)
|
||||||
|
allow_dev_sa = settings.app_env.strip().casefold() == "dev" and settings.t_plus_allow_dev_sa
|
||||||
|
if resolved.t_plus_user.strip().casefold() == "sa" and not (
|
||||||
|
allow_dev_sa or settings.t_plus_allow_admin_account
|
||||||
|
):
|
||||||
|
raise RuntimeError("T+ 管理员账号未获授权,请检查连接配置")
|
||||||
|
if not resolved.t_plus_database or not resolved.t_plus_user or not resolved.t_plus_odbc_driver:
|
||||||
|
raise RuntimeError("T+ 只读连接缺少数据库、账号或已验证的 ODBC 驱动")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def get_tplus_engine() -> Engine:
|
||||||
|
"""获取 T+ 只读引擎;未启用或缺少 ODBC 驱动时给出可读错误。"""
|
||||||
|
global _engine, _session_factory
|
||||||
|
if _engine is not None:
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
settings = resolve_tplus_settings(get_settings())
|
||||||
|
|
||||||
|
try:
|
||||||
|
_engine = create_engine(
|
||||||
|
settings.t_plus_database_url,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=1800,
|
||||||
|
pool_timeout=settings.t_plus_connect_timeout,
|
||||||
|
echo=False,
|
||||||
|
hide_parameters=True,
|
||||||
|
)
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"T+ 连接需要 pyodbc(uv sync --extra tplus)和已验证的 SQL Server ODBC 驱动"
|
||||||
|
) from exc
|
||||||
|
_session_factory = sessionmaker(_engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
def configure_timeout(connection, _record):
|
||||||
|
connection.timeout = settings.t_plus_query_timeout
|
||||||
|
|
||||||
|
event.listen(_engine, "connect", configure_timeout)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_tplus_session_factory() -> sessionmaker[Session]:
|
||||||
|
"""获取 T+ SQLAlchemy 会话工厂。"""
|
||||||
|
get_tplus_engine()
|
||||||
|
assert _session_factory is not None
|
||||||
|
return _session_factory
|
||||||
|
|
||||||
|
|
||||||
|
def get_tplus_db() -> Generator[Session, None, None]:
|
||||||
|
"""提供只读 T+ 会话;调用方不得 commit 或执行写操作。"""
|
||||||
|
factory = get_tplus_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
def dispose_tplus_engine() -> None:
|
||||||
|
"""释放 T+ 连接池。"""
|
||||||
|
global _engine, _session_factory
|
||||||
|
if _engine is not None:
|
||||||
|
_engine.dispose()
|
||||||
|
_engine = None
|
||||||
|
_session_factory = None
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""同账套任务串行锁;独立持有 MySQL 连接,跨分页 commit 仍保持有效。"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
class TPlusBusyError(RuntimeError):
|
||||||
|
"""已有任务持有执行锁。"""
|
||||||
|
|
||||||
|
|
||||||
|
def lock_name(session: Session) -> str:
|
||||||
|
"""生成不含地址、账号或密码的固定长度锁名。"""
|
||||||
|
database = session.get_bind().url.database or "default"
|
||||||
|
return "ys:tplus:read:" + hashlib.sha256(database.encode()).hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def sync_lock(session: Session):
|
||||||
|
"""只在取得锁后执行;释放失败时使连接失效,防止带锁回池。"""
|
||||||
|
engine = session.get_bind()
|
||||||
|
if engine.dialect.name != "mysql":
|
||||||
|
raise RuntimeError("T+ 执行协调需要 MySQL 命名锁")
|
||||||
|
connection = engine.connect()
|
||||||
|
acquired = False
|
||||||
|
try:
|
||||||
|
acquired = (
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT GET_LOCK(:name, 0)"), {"name": lock_name(session)}
|
||||||
|
).scalar_one()
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
if not acquired:
|
||||||
|
raise TPlusBusyError("已有 T+ 任务正在执行,请稍后重试")
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if acquired:
|
||||||
|
try:
|
||||||
|
released = connection.execute(
|
||||||
|
text("SELECT RELEASE_LOCK(:name)"), {"name": lock_name(session)}
|
||||||
|
).scalar_one()
|
||||||
|
if released != 1:
|
||||||
|
connection.invalidate()
|
||||||
|
except Exception:
|
||||||
|
connection.invalidate()
|
||||||
|
connection.close()
|
||||||
+13
-2
@@ -14,13 +14,16 @@ from app.core.config import get_settings
|
|||||||
from app.core.db import dispose_engine
|
from app.core.db import dispose_engine
|
||||||
from app.core.exception_handlers import register_exception_handlers
|
from app.core.exception_handlers import register_exception_handlers
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
|
from app.core.monitoring import MonitorMiddleware, Recorder
|
||||||
from app.core.redis_client import close_redis
|
from app.core.redis_client import close_redis
|
||||||
|
from app.core.tplus_db import dispose_tplus_engine
|
||||||
|
from app.services.monitor_collector import MonitorCollector
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""应用生命周期管理。
|
"""应用生命周期管理。
|
||||||
|
|
||||||
启动阶段不建立数据库/Redis 连接(延迟到首次使用),
|
启动阶段不建立数据库/Redis 连接(延迟到首次使用),
|
||||||
@@ -33,8 +36,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
settings.mysql_host,
|
settings.mysql_host,
|
||||||
settings.mysql_database,
|
settings.mysql_database,
|
||||||
)
|
)
|
||||||
yield
|
app.state.monitor.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
app.state.monitor.stop()
|
||||||
dispose_engine()
|
dispose_engine()
|
||||||
|
dispose_tplus_engine()
|
||||||
close_redis()
|
close_redis()
|
||||||
logger.info("应用已停止,连接池已释放")
|
logger.info("应用已停止,连接池已释放")
|
||||||
|
|
||||||
@@ -60,6 +68,9 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
app.include_router(api_router, prefix="/api")
|
app.include_router(api_router, prefix="/api")
|
||||||
|
recorder = Recorder()
|
||||||
|
app.state.monitor = MonitorCollector(settings, recorder)
|
||||||
|
app.add_middleware(MonitorMiddleware, recorder=recorder)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,18 +9,107 @@
|
|||||||
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
|
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from app.models.approval import ApprovalOrder, ApprovalStep
|
||||||
|
from app.models.authorization import AuthorizationSetting, UserPermissionOverride
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
|
||||||
|
from app.models.business_role import BusinessRole
|
||||||
|
from app.models.calc_template import CalcTemplateDefinition
|
||||||
|
from app.models.customer import Customer, OwnershipChangeLog
|
||||||
from app.models.login_log import LoginLog
|
from app.models.login_log import LoginLog
|
||||||
|
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
|
||||||
|
from app.models.operation_log import OperationLog
|
||||||
|
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
||||||
|
from app.models.payroll import PayrollAllocation, PayrollInput, PayrollMonth
|
||||||
|
from app.models.performance_ledger import PerformanceLedger
|
||||||
|
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
|
||||||
|
from app.models.probation import ProbationDeferral, ProbationPayment
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.models.rating import CustomerRatingGrade
|
||||||
|
from app.models.receipt import Receipt
|
||||||
from app.models.role import Permission, Role, RolePermission
|
from app.models.role import Permission, Role, RolePermission
|
||||||
|
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
|
||||||
|
from app.models.shipment import Shipment, ShipmentItem
|
||||||
|
from app.models.system_setting import SystemSetting
|
||||||
|
from app.models.tplus_automation import (
|
||||||
|
TPlusAutomationConfig,
|
||||||
|
TPlusAutomationState,
|
||||||
|
TPlusRuntimeControl,
|
||||||
|
)
|
||||||
|
from app.models.tplus_business_sync import TPlusBusinessDocument, TPlusBusinessSyncConfig
|
||||||
|
from app.models.tplus_sync import (
|
||||||
|
TPlusSyncBatch,
|
||||||
|
TPlusSyncError,
|
||||||
|
TPlusSyncRecord,
|
||||||
|
TPlusSyncWatermark,
|
||||||
|
)
|
||||||
|
from app.models.tplus_workbench import (
|
||||||
|
TPlusFact,
|
||||||
|
TPlusSourceHead,
|
||||||
|
TPlusSourceLink,
|
||||||
|
TPlusWorkLine,
|
||||||
|
TPlusWorkRun,
|
||||||
|
)
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AdjustmentOrder",
|
||||||
|
"ApprovalOrder",
|
||||||
|
"ApprovalStep",
|
||||||
|
"AuthorizationSetting",
|
||||||
"Base",
|
"Base",
|
||||||
|
"Business",
|
||||||
|
"BusinessItem",
|
||||||
|
"BusinessRole",
|
||||||
|
"BusinessStatusLog",
|
||||||
|
"CalcTemplateDefinition",
|
||||||
|
"Customer",
|
||||||
|
"CustomerRatingGrade",
|
||||||
"LoginLog",
|
"LoginLog",
|
||||||
|
"MonitorEvent",
|
||||||
|
"MonitorRequestMinute",
|
||||||
|
"MonitorSnapshot",
|
||||||
|
"MonthlySettlement",
|
||||||
|
"OperationLog",
|
||||||
|
"OwnershipChangeLog",
|
||||||
|
"ParamChangeBatch",
|
||||||
|
"ParamDefinition",
|
||||||
|
"ParamValue",
|
||||||
|
"PayrollAllocation",
|
||||||
|
"PayrollInput",
|
||||||
|
"PayrollMonth",
|
||||||
|
"PerformanceLedger",
|
||||||
"Permission",
|
"Permission",
|
||||||
|
"Personnel",
|
||||||
|
"PersonnelPlan",
|
||||||
|
"PersonnelRegion",
|
||||||
|
"ProbationDeferral",
|
||||||
|
"ProbationPayment",
|
||||||
|
"Product",
|
||||||
|
"Receipt",
|
||||||
"Role",
|
"Role",
|
||||||
"RolePermission",
|
"RolePermission",
|
||||||
|
"SecondaryAllocation",
|
||||||
|
"SettlementItem",
|
||||||
|
"Shipment",
|
||||||
|
"ShipmentItem",
|
||||||
|
"SystemSetting",
|
||||||
|
"TPlusAutomationConfig",
|
||||||
|
"TPlusAutomationState",
|
||||||
|
"TPlusBusinessDocument",
|
||||||
|
"TPlusBusinessSyncConfig",
|
||||||
|
"TPlusFact",
|
||||||
|
"TPlusRuntimeControl",
|
||||||
|
"TPlusSourceHead",
|
||||||
|
"TPlusSourceLink",
|
||||||
|
"TPlusSyncBatch",
|
||||||
|
"TPlusSyncError",
|
||||||
|
"TPlusSyncRecord",
|
||||||
|
"TPlusSyncWatermark",
|
||||||
|
"TPlusWorkLine",
|
||||||
|
"TPlusWorkRun",
|
||||||
"TimestampMixin",
|
"TimestampMixin",
|
||||||
"User",
|
"User",
|
||||||
|
"UserPermissionOverride",
|
||||||
"UserRole",
|
"UserRole",
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""审批模型:审批单与节点记录(2026-10-15 复核审批补充需求)。
|
||||||
|
|
||||||
|
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数体系开关驱动
|
||||||
|
(`approval.*.enabled` BOOL):关闭=提交即生成「自动通过」节点留痕,
|
||||||
|
留痕不断链(开发计划 3.2(三));开启=按节点人工审核,一驳全驳。
|
||||||
|
|
||||||
|
依赖说明:节点准入按登录角色编码判定(ROLE_FINANCE/ROLE_LEADER),
|
||||||
|
不动 `permissions`/`role_permissions` 闲置表 —— 细粒度权限清单待业务方
|
||||||
|
答复(待澄清 #6)后再激活。
|
||||||
|
|
||||||
|
ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=2
|
||||||
|
(月结预留)将指向 ④ 尚未落地的表,外键无法统一指向;单据删除保护由
|
||||||
|
服务层承担(详见 approval_service)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
# 审批单状态(approval_orders.status;流转机见 approval_service)
|
||||||
|
APPROVAL_STATUS_PENDING_FINANCE = 1 # 待财务复核
|
||||||
|
APPROVAL_STATUS_PENDING_LEADER = 2 # 待领导审核
|
||||||
|
APPROVAL_STATUS_APPROVED = 3 # 已通过(终态)
|
||||||
|
APPROVAL_STATUS_REJECTED = 4 # 已驳回(终态)
|
||||||
|
APPROVAL_STATUS_WITHDRAWN = 5 # 已撤回(被引用方重新提交时旧在途单注销)
|
||||||
|
|
||||||
|
# 节点(approval_steps.node;0 仅用于撤回留痕)
|
||||||
|
APPROVAL_NODE_FINANCE = 1
|
||||||
|
APPROVAL_NODE_LEADER = 2
|
||||||
|
APPROVAL_NODE_WITHDRAW = 0
|
||||||
|
APPROVAL_NODES = {
|
||||||
|
APPROVAL_STATUS_PENDING_FINANCE: APPROVAL_NODE_FINANCE,
|
||||||
|
APPROVAL_STATUS_PENDING_LEADER: APPROVAL_NODE_LEADER,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 动作(approval_steps.action)
|
||||||
|
APPROVAL_ACTION_APPROVE = 1 # 人工通过
|
||||||
|
APPROVAL_ACTION_REJECT = 2 # 人工驳回
|
||||||
|
APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕)
|
||||||
|
APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销)
|
||||||
|
|
||||||
|
# 审批对象类型(approval_orders.order_type)
|
||||||
|
APPROVAL_TYPE_SECONDARY_ALLOCATION = 1
|
||||||
|
APPROVAL_TYPE_MONTHLY_SETTLEMENT = 2
|
||||||
|
APPROVAL_TYPE_ADJUSTMENT = 3
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalOrder(Base, TimestampMixin):
|
||||||
|
"""审批单:一张待审对象一条审批流(状态可流转,含 update_time)。"""
|
||||||
|
|
||||||
|
__tablename__ = "approval_orders"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
|
||||||
|
# 同一引用对象的第 N 次送审在库里有唯一一行:并发提单算重版本号时
|
||||||
|
# 由 DB 兜住(应用层取号走锁读,见 approval_service._next_version)
|
||||||
|
UniqueConstraint(
|
||||||
|
"order_type",
|
||||||
|
"ref_id",
|
||||||
|
"version_no",
|
||||||
|
name="uk_approval_orders_order_type_ref_version",
|
||||||
|
),
|
||||||
|
Index("idx_approval_orders_type_status", "order_type", "status"),
|
||||||
|
# 版本号按 (order_type, ref_id) 递增,须有对应索引避免全表 max 扫描
|
||||||
|
Index("idx_approval_orders_type_ref", "order_type", "ref_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
order_no: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
|
||||||
|
)
|
||||||
|
order_type: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算 3调整单"
|
||||||
|
)
|
||||||
|
ref_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
|
||||||
|
)
|
||||||
|
refer_no: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
|
||||||
|
)
|
||||||
|
version_no: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖;"
|
||||||
|
"同一对象内唯一,由uk_approval_orders_order_type_ref_version强制)",
|
||||||
|
)
|
||||||
|
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
comment="提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)",
|
||||||
|
)
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
|
||||||
|
)
|
||||||
|
submitter_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="提交人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
submitter_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="提交人用户名(冗余固化)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStep(Base):
|
||||||
|
"""审批节点记录表(append-only):每次动作只增不改,构成时间线。
|
||||||
|
|
||||||
|
不继承 TimestampMixin(留痕无更新语义,同 business_status_logs 先例)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "approval_steps"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"审批节点记录表(append-only;自动通过/撤回均有留痕)",
|
||||||
|
Index("idx_approval_steps_order_id", "order_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
order_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("approval_orders.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="审批单ID(approval_orders.id;单删记录随删)",
|
||||||
|
)
|
||||||
|
node: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="审批节点:0撤回留痕 1财务复核 2领导审核"
|
||||||
|
)
|
||||||
|
action: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
|
||||||
|
)
|
||||||
|
opinion: Mapped[str | None] = mapped_column(String(255), comment="审批意见(驳回时填写)")
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="操作人ID(自动通过/系统动作置空)",
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="操作人用户名(冗余固化;系统动作记「系统」)"
|
||||||
|
)
|
||||||
|
create_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""授权版本锁及旧版授权结构;V26 后不再使用个人覆盖或全局制作人绑定。"""
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Index, Integer, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
|
||||||
|
class UserPermissionOverride(Base, TimestampMixin):
|
||||||
|
__tablename__ = "user_permission_overrides"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"用户权限覆盖表(个人禁止优先)",
|
||||||
|
UniqueConstraint("user_id", "permission_id", name="uk_user_permission_overrides_user_permission"),
|
||||||
|
Index("idx_user_permission_overrides_permission_id", "permission_id"),
|
||||||
|
)
|
||||||
|
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
|
||||||
|
user_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="CASCADE"), comment="系统用户ID")
|
||||||
|
permission_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, ForeignKey("permissions.id", ondelete="RESTRICT"), comment="权限ID")
|
||||||
|
effect: Mapped[str] = mapped_column(String(8), comment="个人覆盖:ALLOW允许 DENY禁止")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationSetting(Base, TimestampMixin):
|
||||||
|
__tablename__ = "authorization_settings"
|
||||||
|
__table_args__ = mysql_table_args("授权设置表(单例与并发修改锁)")
|
||||||
|
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="单例主键ID")
|
||||||
|
maker_user_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="RESTRICT"), comment="现任工资制作人账号ID")
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1", comment="全局授权版本")
|
||||||
|
updated_by: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="SET NULL"), comment="最近修改人ID")
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, MetaData, text
|
from sqlalchemy import DateTime, MetaData, String, text
|
||||||
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
|
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
@@ -28,7 +28,14 @@ BIGINT_UNSIGNED = BIGINT(unsigned=True)
|
|||||||
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
|
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
|
||||||
TINYINT_TYPE = TINYINT
|
TINYINT_TYPE = TINYINT
|
||||||
|
|
||||||
__all__ = ["BIGINT_UNSIGNED", "TINYINT_TYPE", "Base", "TimestampMixin", "mysql_table_args"]
|
__all__ = [
|
||||||
|
"BIGINT_UNSIGNED",
|
||||||
|
"TINYINT_TYPE",
|
||||||
|
"Base",
|
||||||
|
"TPlusSyncMixin",
|
||||||
|
"TimestampMixin",
|
||||||
|
"mysql_table_args",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
@@ -62,6 +69,33 @@ class TimestampMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TPlusSyncMixin:
|
||||||
|
"""T+(用友)数据同步预留列,六张业务主表共用。
|
||||||
|
|
||||||
|
对照表 #26:T+ 对接的字段映射与幂等键语义未定(IT 牵头),故四列全部
|
||||||
|
可空、不设唯一键 —— 键语义未定就锁死唯一约束,对接时大概率返工;
|
||||||
|
source_no 先建普通索引承担查询与去重,IT 定键后以小迁移升级唯一约束。
|
||||||
|
|
||||||
|
同步守则(M4 同步模块必须遵守):同步只写 T+ 侧字段与本组列,
|
||||||
|
**不覆盖人工维护字段**(岗位/工资标准/归属/客户分类/业务状态等,
|
||||||
|
各表注释已标注)。同步频率:一周一次定时 + 手动触发(对照表 #10)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_no: Mapped[str | None] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
index=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
)
|
||||||
|
t_plus_id: Mapped[str | None] = mapped_column(String(64), comment="T+ 系统内部标识")
|
||||||
|
sync_batch_no: Mapped[str | None] = mapped_column(
|
||||||
|
String(32), comment="同步批次号(同步模块写入)"
|
||||||
|
)
|
||||||
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
|
||||||
|
source_updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime, comment="T+ 源数据更新时间(未确认时可为空)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
|
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
|
||||||
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
|
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""业务数据模型:业务单、产品明细与状态流转留痕。
|
||||||
|
|
||||||
|
- 汇总冗余列(total_* 五列)由服务层在明细/发货/回款增删改时**事务内单点
|
||||||
|
重算**,禁手工直改 —— 列表页与 M3 起付判定直接读冗余列,避免每次 JOIN
|
||||||
|
聚合;代价是所有写路径必须重算(business_service 统一收口)
|
||||||
|
- 毛利润相关值(毛利润额/毛利润率/折算毛利润额)**不落库**:它们是
|
||||||
|
sale_amount/cost_amount + 时点化阈值参数的派生值,落库会在成本调整或
|
||||||
|
参数改版后漂移出「假快照」;M2 以只读毛利润视图实时计算(复用
|
||||||
|
formula.profit_rate/converted_profit),M3 核算台账才是法定固化点
|
||||||
|
- product_category 存在明细行级:混合业务按产品逐条计算(对照表 #14)
|
||||||
|
- 运费仅记录展示,不计入毛利润口径(对照表 #2);协助营销人员一期核算
|
||||||
|
不使用(对照表 #15 剔除协助 25% 分成),字段保留仅记录事实(需规 2.2.1)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
TINYINT_TYPE,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 业务状态(businesses.status,流转规则见 business_service:终态不可变更)
|
||||||
|
BUSINESS_STATUS_ACTIVE = 1
|
||||||
|
BUSINESS_STATUS_DONE = 2
|
||||||
|
BUSINESS_STATUS_TERMINATED = 3
|
||||||
|
|
||||||
|
|
||||||
|
class SecondaryAllocation(Base, TimestampMixin):
|
||||||
|
"""二次分配方案明细表(2026-10-15 补充需求「二次分配方案」)。
|
||||||
|
|
||||||
|
口径(AI_CONTEXT 待澄清 #1/#2 未答复前的边界):
|
||||||
|
- 只出现在合同刚建立时;修改在首次月结前留痕放行,月结后只读(月结在 ④,
|
||||||
|
终态门槛先行承担「修改不影响已结算月份」)
|
||||||
|
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
|
||||||
|
与比例,响应以 hint 提示合计/余额,不做强制校验
|
||||||
|
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
|
||||||
|
- **版本行 append-only**:编辑全量替换与驳回清空都把旧行置
|
||||||
|
``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由
|
||||||
|
``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照
|
||||||
|
(``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只
|
||||||
|
补齐「方案事实留存」,不改变核算口径
|
||||||
|
|
||||||
|
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
|
||||||
|
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
|
||||||
|
冗余固化,人离职后方案回显仍有名。uk 锁
|
||||||
|
(business_id, version_no, personnel_id),同一版本内同一参与人仅一行,
|
||||||
|
重复提交在服务层聚合;跨版本允许同一人再次出现。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "business_secondary_allocations"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
|
||||||
|
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
|
||||||
|
"业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)",
|
||||||
|
UniqueConstraint(
|
||||||
|
"business_id",
|
||||||
|
"version_no",
|
||||||
|
"personnel_id",
|
||||||
|
name="uk_business_secondary_allocations_business_version_personnel",
|
||||||
|
),
|
||||||
|
Index("idx_business_secondary_allocations_business_id", "business_id"),
|
||||||
|
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
|
||||||
|
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
|
||||||
|
Index("idx_business_secondary_allocations_business_active", "business_id", "is_active"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("businesses.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id;业务删除随删)",
|
||||||
|
)
|
||||||
|
version_no: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)",
|
||||||
|
)
|
||||||
|
personnel_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="参与业务员ID(personnel.id;被方案引用禁删)",
|
||||||
|
)
|
||||||
|
personnel_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="参与业务员姓名(冗余固化,离职后回显有名)"
|
||||||
|
)
|
||||||
|
ratio: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 2),
|
||||||
|
nullable=False,
|
||||||
|
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
|
||||||
|
)
|
||||||
|
is_active: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Business(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
|
||||||
|
|
||||||
|
__tablename__ = "businesses"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"业务表(业务编号自动生成;T+同步不覆盖业务状态)",
|
||||||
|
Index("idx_businesses_customer_id", "customer_id"),
|
||||||
|
Index("idx_businesses_owner_personnel_id", "owner_personnel_id"),
|
||||||
|
Index("idx_businesses_status", "status"),
|
||||||
|
Index("idx_businesses_business_date", "business_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_no: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
|
||||||
|
)
|
||||||
|
customer_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("customers.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户ID(customers.id)",
|
||||||
|
)
|
||||||
|
business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)")
|
||||||
|
integration_json: Mapped[dict | None] = mapped_column(JSON, comment="T+业务接入范围及来源人员摘要,非核算快照")
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
|
||||||
|
)
|
||||||
|
owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
|
||||||
|
)
|
||||||
|
assistant_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
|
||||||
|
)
|
||||||
|
is_special: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="特殊业务标记:1是 0否(公司参与等)",
|
||||||
|
)
|
||||||
|
special_note: Mapped[str | None] = mapped_column(String(255), comment="特殊业务说明")
|
||||||
|
total_sale_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
|
||||||
|
)
|
||||||
|
total_cost_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
)
|
||||||
|
total_ship_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
)
|
||||||
|
total_received_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
|
||||||
|
)
|
||||||
|
total_freight_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessItem(Base, TimestampMixin):
|
||||||
|
"""业务产品明细表(需求 2.2.1/2.2.4):毛利润的行级源数据。
|
||||||
|
|
||||||
|
product_id 可空=自由录入产品(配合「数据提取」一键入产品库);
|
||||||
|
产品名称/规格冗余固化,防产品改名影响历史业务。
|
||||||
|
sale_amount 由服务层按 数量×单价 计算固化,不信任前端传值。
|
||||||
|
cost_amount 来源待 T+ 对接确认(可空,人工可录先行)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "business_items"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
|
||||||
|
Index("idx_business_items_business_id", "business_id"),
|
||||||
|
Index("idx_business_items_product_id", "product_id"),
|
||||||
|
Index("idx_business_items_product_category", "product_category"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("businesses.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id)",
|
||||||
|
)
|
||||||
|
product_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("products.id", ondelete="RESTRICT"),
|
||||||
|
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
|
||||||
|
)
|
||||||
|
product_name: Mapped[str] = mapped_column(
|
||||||
|
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
|
||||||
|
)
|
||||||
|
spec_model: Mapped[str] = mapped_column(
|
||||||
|
String(128),
|
||||||
|
nullable=False,
|
||||||
|
server_default="''",
|
||||||
|
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||||
|
)
|
||||||
|
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
|
||||||
|
product_category: Mapped[str | None] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=True,
|
||||||
|
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
|
||||||
|
)
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)")
|
||||||
|
sale_price: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 4), comment="销售单价(元;T+ 同步或人工)"
|
||||||
|
)
|
||||||
|
sale_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
nullable=False,
|
||||||
|
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
|
||||||
|
)
|
||||||
|
cost_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
|
||||||
|
)
|
||||||
|
guided_price: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 4), comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)"
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessStatusLog(Base):
|
||||||
|
"""业务状态变更记录表(append-only,需求 2.2.1「支持状态变更记录」)。
|
||||||
|
|
||||||
|
不继承 TimestampMixin(留痕无更新语义);business_id 为无外键的固化 ID
|
||||||
|
(businesses 建表在本表之后的迁移批次内 —— 实为同批,但留痕统一口径:
|
||||||
|
被引用方删除后记录仍完整可读,删除保护由服务层检查承担)。
|
||||||
|
首条记录在业务创建时写入(old=new=1 执行中),详情页时间线从创建起展示。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "business_status_logs"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"业务状态变更记录表(append-only)",
|
||||||
|
Index("idx_business_status_logs_business_id", "business_id"),
|
||||||
|
Index("idx_business_status_logs_create_time", "create_time"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="业务ID(固化,无外键;删除保护由服务层承担)",
|
||||||
|
)
|
||||||
|
old_status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="变更前状态(创建首条为 1)"
|
||||||
|
)
|
||||||
|
new_status: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, comment="变更后状态")
|
||||||
|
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因(终止时必填)")
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="操作人用户名(冗余固化)"
|
||||||
|
)
|
||||||
|
create_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""业务岗位角色模型。
|
||||||
|
|
||||||
|
与系统登录角色(`roles`,权限维度)严格区分:本表是**业务岗位**维度,
|
||||||
|
决定人员适用的计提公式模板与参数。需求 #28 要求岗位角色可增删、
|
||||||
|
新增岗位时在系统设置中绑定公式与参数(无需改代码),故模板编码
|
||||||
|
存于本表、公式实现留在代码内注册表(一期不引入规则引擎)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessRole(Base, TimestampMixin):
|
||||||
|
"""业务岗位角色表。内置三个岗位(营销员/营销经理/信息员)随迁移初始化。"""
|
||||||
|
|
||||||
|
__tablename__ = "business_roles"
|
||||||
|
__table_args__ = mysql_table_args("业务岗位角色表(与登录角色roles区分,决定计提公式与参数)")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
role_code: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改",
|
||||||
|
)
|
||||||
|
role_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位名称")
|
||||||
|
calc_template: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="计提公式模板编码(以代码内模板注册表为准)"
|
||||||
|
)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), comment="岗位说明")
|
||||||
|
sort_order: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, server_default="0", comment="显示排序"
|
||||||
|
)
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
|
||||||
|
)
|
||||||
|
is_builtin: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="是否内置岗位:1内置(禁删禁停) 0自定义",
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""计提公式模板定义模型。
|
||||||
|
|
||||||
|
把一期代码内注册表升级为数据库配置:模板名称、说明、参数绑定可在页面维护,
|
||||||
|
公式执行骨架仍是受控枚举(formula_type),不允许自由表达式,避免注入与算错。
|
||||||
|
岗位角色 `business_roles.calc_template` 绑定模板编码,编码创建后不可修改。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
|
||||||
|
class CalcTemplateDefinition(Base, TimestampMixin):
|
||||||
|
"""计提公式模板定义表。内置模板随迁移初始化,页面可新增自定义模板。"""
|
||||||
|
|
||||||
|
__tablename__ = "calc_template_definitions"
|
||||||
|
__table_args__ = mysql_table_args("计提公式模板定义表(页面可维护,骨架受控)")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
template_code: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="模板编码,创建后不可修改;岗位表 business_roles.calc_template 引用",
|
||||||
|
)
|
||||||
|
template_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="模板名称")
|
||||||
|
formula_type: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
comment="公式骨架类型:linear_decay/fixed_profit/fixed_sales/manager_share/prepay_fixed,创建后不可修改",
|
||||||
|
)
|
||||||
|
params_json: Mapped[dict] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
nullable=False,
|
||||||
|
comment="骨架参数角色到参数编码的 JSON 映射,如 {start:..., slope:...}",
|
||||||
|
)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), comment="模板说明")
|
||||||
|
sort_order: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, server_default="0", comment="显示排序"
|
||||||
|
)
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
|
||||||
|
)
|
||||||
|
is_system: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="是否系统模板:1内置(禁删、骨架与编码锁定) 0自定义",
|
||||||
|
)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""客户模型:客户档案与归属变更留痕。
|
||||||
|
|
||||||
|
- 「公司名称 + 负责人」是业务唯一标识(需规 2.1.3);负责人是客户公司的
|
||||||
|
负责人**人名**(字符串),不是系统内的营销人员 —— 系统内归属另用
|
||||||
|
owner_personnel_id 外键表达,两者语义不同
|
||||||
|
- 归属变更只改主表当前值 + 插一条留痕,**不回溯历史**(对照表 #19):
|
||||||
|
历史核算结果在 M3 台账已固化,无需 effective_date 时点化设计
|
||||||
|
- 留痕表(OwnershipChangeLog)对客户/业务/人员 ID 均为**无外键的冗余固化**:
|
||||||
|
留痕是历史记录,被引用方被删除后记录仍须完整可读;RESTRICT 外键反而会
|
||||||
|
把「删除客户」逼成 500。业务实体删除保护由服务层检查承担
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
TINYINT_TYPE,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 归属类型(ownership_change_logs.owner_type)
|
||||||
|
OWNER_TYPE_CUSTOMER = 1
|
||||||
|
OWNER_TYPE_BUSINESS = 2
|
||||||
|
|
||||||
|
|
||||||
|
class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""客户表(需求 2.1.3)。customer_type 是计提规则匹配锚点(M3 消费)。"""
|
||||||
|
|
||||||
|
__tablename__ = "customers"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
|
||||||
|
UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
company_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="公司名称")
|
||||||
|
owner_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="客户负责人姓名(客户公司负责人的人名,非系统人员)"
|
||||||
|
)
|
||||||
|
contact_person: Mapped[str | None] = mapped_column(String(64), comment="联系人")
|
||||||
|
contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
|
||||||
|
address: Mapped[str | None] = mapped_column(String(255), comment="地址")
|
||||||
|
industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)")
|
||||||
|
customer_type: Mapped[str | None] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
|
||||||
|
)
|
||||||
|
owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
index=True,
|
||||||
|
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
rating_score: Mapped[int | None] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
|
||||||
|
)
|
||||||
|
rating_time: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipChangeLog(Base):
|
||||||
|
"""归属变更记录表(append-only,需求 2.1.3 + 对照表 #19)。
|
||||||
|
|
||||||
|
owner_type 区分客户归属/业务归属:1 → customer_id 非空,2 → business_id
|
||||||
|
非空,由应用层校验(无外键,见模块 docstring 的固化论证)。
|
||||||
|
前后值冗余固化(无外键):人员被删除后留痕仍可读。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "ownership_change_logs"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
|
||||||
|
Index("idx_ownership_change_logs_customer_id", "customer_id"),
|
||||||
|
Index("idx_ownership_change_logs_business_id", "business_id"),
|
||||||
|
Index("idx_ownership_change_logs_create_time", "create_time"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
owner_type: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="归属类型:1客户归属 2业务归属"
|
||||||
|
)
|
||||||
|
customer_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="客户ID(固化,owner_type=1 时非空)",
|
||||||
|
)
|
||||||
|
business_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="业务ID(固化,owner_type=2 时非空)",
|
||||||
|
)
|
||||||
|
old_owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更前负责营销人员ID(固化)"
|
||||||
|
)
|
||||||
|
new_owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更后负责营销人员ID(固化)"
|
||||||
|
)
|
||||||
|
old_assistant_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更前协助营销人员ID(固化,仅业务归属使用)"
|
||||||
|
)
|
||||||
|
new_assistant_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更后协助营销人员ID(固化,仅业务归属使用)"
|
||||||
|
)
|
||||||
|
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因")
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="操作人用户名(冗余固化)"
|
||||||
|
)
|
||||||
|
create_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||||
|
)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""监控数据独立保存,不关联工资或审计表;仅这些新表按七天保留。"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, Float, Index, Integer, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, Base, mysql_table_args
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorSnapshot(Base):
|
||||||
|
__tablename__ = "monitor_snapshots"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"分钟级运行监控快照",
|
||||||
|
UniqueConstraint("instance_id", "minute", name="uk_monitor_snapshots_instance_minute"),
|
||||||
|
Index("idx_monitor_snapshots_minute", "minute"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
|
||||||
|
minute: Mapped[datetime] = mapped_column(DateTime, comment="采集分钟,北京时间")
|
||||||
|
sample_count: Mapped[int] = mapped_column(Integer, comment="实际采样次数")
|
||||||
|
resources: Mapped[dict] = mapped_column(JSON, comment="资源均值与采集范围,不含配置")
|
||||||
|
requests: Mapped[dict] = mapped_column(JSON, comment="请求汇总与固定耗时直方图")
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorRequestMinute(Base):
|
||||||
|
__tablename__ = "monitor_request_minutes"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"接口分钟级运行统计",
|
||||||
|
UniqueConstraint(
|
||||||
|
"instance_id", "minute", "method", "route", name="uk_monitor_request_minutes_endpoint"
|
||||||
|
),
|
||||||
|
Index("idx_monitor_request_minutes_minute", "minute"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
|
||||||
|
minute: Mapped[datetime] = mapped_column(DateTime, comment="请求分钟,北京时间")
|
||||||
|
method: Mapped[str] = mapped_column(String(8), comment="HTTP方法,固定集合")
|
||||||
|
route: Mapped[str] = mapped_column(String(256), comment="路由模板,不含原始地址及参数")
|
||||||
|
metrics: Mapped[dict] = mapped_column(JSON, comment="请求数、状态分类与固定直方图")
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorEvent(Base):
|
||||||
|
__tablename__ = "monitor_events"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"脱敏诊断事件与异常恢复记录",
|
||||||
|
Index("idx_monitor_events_last_seen", "last_seen"),
|
||||||
|
Index("idx_monitor_events_request_id", "request_id"),
|
||||||
|
Index("idx_monitor_events_fingerprint", "fingerprint", "resolved_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
|
||||||
|
kind: Mapped[str] = mapped_column(String(24), comment="事件类型,固定集合")
|
||||||
|
code: Mapped[str] = mapped_column(String(64), comment="稳定错误码")
|
||||||
|
component: Mapped[str] = mapped_column(String(128), comment="所属组件或日志模块")
|
||||||
|
level: Mapped[str] = mapped_column(String(16), comment="异常级别")
|
||||||
|
message: Mapped[str] = mapped_column(String(256), comment="通用说明,不保存内部异常文本")
|
||||||
|
fingerprint: Mapped[str | None] = mapped_column(String(64), comment="同类健康异常合并键")
|
||||||
|
request_id: Mapped[str | None] = mapped_column(String(32), comment="请求关联编号")
|
||||||
|
method: Mapped[str | None] = mapped_column(String(8), comment="HTTP方法")
|
||||||
|
route: Mapped[str | None] = mapped_column(String(256), comment="路由模板")
|
||||||
|
status_code: Mapped[int | None] = mapped_column(Integer, comment="HTTP状态码")
|
||||||
|
duration_ms: Mapped[float | None] = mapped_column(Float, comment="请求耗时,毫秒")
|
||||||
|
batch_no: Mapped[str | None] = mapped_column(String(32), comment="相关同步批次编号")
|
||||||
|
first_seen: Mapped[datetime] = mapped_column(DateTime, comment="首次发生时间")
|
||||||
|
last_seen: Mapped[datetime] = mapped_column(DateTime, comment="最近发生时间")
|
||||||
|
occurrences: Mapped[int] = mapped_column(Integer, comment="发生或持续采样次数")
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime, comment="恢复时间,仅用于健康异常"
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""操作日志模型。
|
||||||
|
|
||||||
|
需求 2.5.1 要求「记录用户登录、操作行为及计提参数/系数表变更,支持日志查询」。
|
||||||
|
登录行为已在 `login_logs` 中单独记录(登录失败也要留痕,且不依赖认证上下文);
|
||||||
|
本表记录**已认证用户的操作行为**,两者分工明确、互不重叠。
|
||||||
|
|
||||||
|
不继承 TimestampMixin:日志是追加写、不可变的,没有"更新时间"语义。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, mysql_table_args
|
||||||
|
|
||||||
|
# 各文本列的上限。**写入方必须按此截断**,否则 MySQL 严格模式抛 1406
|
||||||
|
# (Data too long),而按"审计失败不得影响业务"的约定该异常会被吞掉 ——
|
||||||
|
# 结果是整条日志静默丢失。截断值集中定义于此,供 repository 层引用。
|
||||||
|
USERNAME_MAX_LEN = 64
|
||||||
|
MODULE_MAX_LEN = 64
|
||||||
|
ACTION_MAX_LEN = 32
|
||||||
|
SUMMARY_MAX_LEN = 255
|
||||||
|
METHOD_MAX_LEN = 8
|
||||||
|
PATH_MAX_LEN = 255
|
||||||
|
IP_MAX_LEN = 45
|
||||||
|
USER_AGENT_MAX_LEN = 255
|
||||||
|
ERROR_MESSAGE_MAX_LEN = 500
|
||||||
|
# request_data 是 MySQL TEXT(上限 65535 字节)。脱敏后的 JSON 可能超限
|
||||||
|
# (如批量操作提交数千个 ID),超限同样会抛 1406,故写入方需按字节截断。
|
||||||
|
REQUEST_DATA_MAX_BYTES = 60000
|
||||||
|
|
||||||
|
|
||||||
|
class OperationLog(Base):
|
||||||
|
"""操作日志表。
|
||||||
|
|
||||||
|
记录原则与 `login_logs` 一致:
|
||||||
|
- **请求参数必须脱敏后入库**(复用 `core.logging.scrub`),日志是最容易泄漏
|
||||||
|
密码与令牌的地方
|
||||||
|
- 用户名冗余存储而非只存 user_id —— 账号被删除后仍能追溯操作者,
|
||||||
|
故 `username` 单独建索引(与 `login_logs` 保持一致)
|
||||||
|
- 写入失败不得影响业务(审计可丢,业务不可断)
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "operation_logs"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"操作日志表",
|
||||||
|
Index("idx_operation_logs_user_id", "user_id"),
|
||||||
|
# 账号删除后 user_id 被置空,只能按用户名追溯,故该列必须有索引,
|
||||||
|
# 否则日志表增长后查询退化为全表扫描
|
||||||
|
Index("idx_operation_logs_username", "username"),
|
||||||
|
Index("idx_operation_logs_module", "module"),
|
||||||
|
Index("idx_operation_logs_create_time", "create_time"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
username: Mapped[str] = mapped_column(
|
||||||
|
String(USERNAME_MAX_LEN), nullable=False, comment="操作人用户名(冗余存储便于追溯)"
|
||||||
|
)
|
||||||
|
module: Mapped[str] = mapped_column(
|
||||||
|
String(MODULE_MAX_LEN), nullable=False, comment="所属模块,如 用户管理"
|
||||||
|
)
|
||||||
|
action: Mapped[str] = mapped_column(
|
||||||
|
String(ACTION_MAX_LEN), nullable=False, comment="操作类型:新增/修改/删除/导出"
|
||||||
|
)
|
||||||
|
summary: Mapped[str] = mapped_column(
|
||||||
|
String(SUMMARY_MAX_LEN), nullable=False, comment="操作摘要(中文,供人阅读)"
|
||||||
|
)
|
||||||
|
|
||||||
|
method: Mapped[str | None] = mapped_column(String(METHOD_MAX_LEN), comment="HTTP 方法")
|
||||||
|
path: Mapped[str | None] = mapped_column(String(PATH_MAX_LEN), comment="请求路径")
|
||||||
|
ip: Mapped[str | None] = mapped_column(String(IP_MAX_LEN), comment="客户端IP(45位兼容IPv6)")
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(
|
||||||
|
String(USER_AGENT_MAX_LEN), comment="客户端User-Agent(截断存储)"
|
||||||
|
)
|
||||||
|
# 请求参数,**入库前已脱敏**;长文本故用 TEXT
|
||||||
|
request_data: Mapped[str | None] = mapped_column(Text, comment="请求参数(已脱敏)")
|
||||||
|
|
||||||
|
# 默认 0 而非 1:与 login_logs 保持一致,且方向更保守 ——
|
||||||
|
# 写入方漏传时记为「失败」,管理员会去核查;反之漏传记为「成功」则是
|
||||||
|
# 审计漏报,且无声无息。写入方应显式传值(见 repository 层)。
|
||||||
|
success: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, server_default="0", comment="是否成功:1成功 0失败"
|
||||||
|
)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(
|
||||||
|
String(ERROR_MESSAGE_MAX_LEN), comment="失败原因(写入方须截断)"
|
||||||
|
)
|
||||||
|
duration_ms: Mapped[int | None] = mapped_column(Integer, comment="处理耗时(毫秒)")
|
||||||
|
|
||||||
|
create_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||||
|
)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""计提参数模型:定义、变更批次与版本条目。
|
||||||
|
|
||||||
|
存储设计(M1 评审定稿):
|
||||||
|
- 参数值按**生效日期时点化**存储(逐项版本条目,append-only),核算按业务
|
||||||
|
日期回溯取值(对照表 #11:按回款到账日匹配参数版本),禁读「当前值」算历史
|
||||||
|
- 「版本」由变更批次承载:一次保存 = 一个批次 = 一条变更记录;条目逐项留痕
|
||||||
|
(旧值/新值),满足 #28 版本管理与留痕(需规 2.3.1 四)
|
||||||
|
- 值一律为 Fraction 可解析字符串(如 "4/360",禁 0.011111),解析不经 float
|
||||||
|
- **不设 (param_id, dimension_value, effective_date) 唯一键**:同日多次修正
|
||||||
|
是合法留痕,解析取 (effective_date, id) 最新一条;唯一键会把同日二次变更
|
||||||
|
逼成覆盖写、丢失中间值
|
||||||
|
- 批次与条目均为 append-only(仅 create_time、无 update_time,同 login_logs
|
||||||
|
先例):带 ON UPDATE 的列会让「未改也被刷新」造成留痕假象
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
|
||||||
|
class ParamDefinition(Base, TimestampMixin):
|
||||||
|
"""计提参数定义表。目录元数据(类型/单位/维度/取值域),随迁移种子初始化。"""
|
||||||
|
|
||||||
|
__tablename__ = "param_definitions"
|
||||||
|
__table_args__ = mysql_table_args("计提参数定义表")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
param_code: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, unique=True, comment="参数编码,如 salesperson.rate.slope"
|
||||||
|
)
|
||||||
|
param_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="参数名称")
|
||||||
|
param_group: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径",
|
||||||
|
)
|
||||||
|
value_type: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT"
|
||||||
|
)
|
||||||
|
unit: Mapped[str | None] = mapped_column(String(16), comment="展示单位:‰/%/元/天,无单位为空")
|
||||||
|
dimension_type: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
server_default="NONE",
|
||||||
|
comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE",
|
||||||
|
)
|
||||||
|
options_text: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), comment="枚举选项(竖线分隔),仅 ENUM 使用"
|
||||||
|
)
|
||||||
|
default_value: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), comment="系统默认值(Fraction 可解析字符串)"
|
||||||
|
)
|
||||||
|
value_min: Mapped[str | None] = mapped_column(String(64), comment="取值下限(含),校验用")
|
||||||
|
value_max: Mapped[str | None] = mapped_column(String(64), comment="取值上限(含),校验用")
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), comment="口径说明(配置页展示)")
|
||||||
|
sort_order: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, server_default="0", comment="显示排序"
|
||||||
|
)
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParamChangeBatch(Base):
|
||||||
|
"""参数变更批次表(append-only)。
|
||||||
|
|
||||||
|
一次提交 = 一个批次;版本号(如 P20261012-01)供对比与追溯引用。
|
||||||
|
未生效批次可整批撤回(删批次与条目);已生效条目禁改禁删。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "param_change_batches"
|
||||||
|
__table_args__ = mysql_table_args("参数变更批次表")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
version_no: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, unique=True, comment="版本号,如 P20261012-01"
|
||||||
|
)
|
||||||
|
effective_date: Mapped[date] = mapped_column(
|
||||||
|
Date, nullable=False, comment="生效日期(业务日期口径)"
|
||||||
|
)
|
||||||
|
change_type: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="生效方式:1立即生效 2指定日期生效"
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="变更说明")
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="操作人用户名(冗余固化)"
|
||||||
|
)
|
||||||
|
item_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, server_default="0", comment="变更参数项数"
|
||||||
|
)
|
||||||
|
create_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParamValue(Base):
|
||||||
|
"""参数版本条目表(append-only)。
|
||||||
|
|
||||||
|
解析规则:按 (param_id, dimension_value) 取 effective_date <= 业务日期 的
|
||||||
|
ORDER BY effective_date DESC, id DESC 首条(同日多次变更取最新)。
|
||||||
|
dimension_value 用空串而非 NULL:MySQL 组合索引对 NULL 不去重,空串语义更稳。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "param_values"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"参数版本条目表(append-only,按生效日期时点化取值)",
|
||||||
|
Index(
|
||||||
|
"idx_param_values_param_id_dimension_value_effective_date",
|
||||||
|
"param_id",
|
||||||
|
"dimension_value",
|
||||||
|
"effective_date",
|
||||||
|
),
|
||||||
|
Index("idx_param_values_effective_date", "effective_date"),
|
||||||
|
Index("idx_param_values_batch_id", "batch_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
param_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("param_definitions.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="参数定义ID(param_definitions.id)",
|
||||||
|
)
|
||||||
|
dimension_value: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("''"),
|
||||||
|
comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值",
|
||||||
|
)
|
||||||
|
effective_date: Mapped[date] = mapped_column(
|
||||||
|
Date, nullable=False, comment="生效日期(按业务日期时点化取值)"
|
||||||
|
)
|
||||||
|
value_text: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="参数值(Fraction 可解析字符串/枚举码/开关)"
|
||||||
|
)
|
||||||
|
old_value_text: Mapped[str | None] = mapped_column(String(64), comment="变更前值(留痕)")
|
||||||
|
batch_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("param_change_batches.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="变更批次ID(param_change_batches.id)",
|
||||||
|
)
|
||||||
|
create_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""正式工资输入、分配过程与月度生成锁,和隔离预演分别留存。"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, Index, Integer, Numeric, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollMonth(Base, TimestampMixin):
|
||||||
|
"""先锁月份行再修改方案或生成工资;生成标记不随审批退回清除。"""
|
||||||
|
|
||||||
|
__tablename__ = "payroll_months"
|
||||||
|
__table_args__ = mysql_table_args("工资月份控制表(首次生成即锁定分配,审批不解锁)")
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
month: Mapped[str] = mapped_column(String(7), unique=True, comment="工资月份")
|
||||||
|
maker_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="实际工资制作人账号ID")
|
||||||
|
maker_name: Mapped[str] = mapped_column(String(64), comment="制作人用户名快照")
|
||||||
|
generated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime, comment="首次成功生成时间(不可解锁)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollInput(Base, TimestampMixin):
|
||||||
|
"""正式核算输入的版本:来源、成本、归属及期初均有依据,旧版本不覆盖。"""
|
||||||
|
|
||||||
|
__tablename__ = "payroll_inputs"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"正式工资输入版本表",
|
||||||
|
UniqueConstraint("business_id", "version", name="uk_payroll_inputs_business_version"),
|
||||||
|
Index("idx_payroll_inputs_source_group", "source_database", "group_key"),
|
||||||
|
)
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="正式业务ID")
|
||||||
|
version: Mapped[int] = mapped_column(Integer, comment="输入版本")
|
||||||
|
source_database: Mapped[str] = mapped_column(String(128), comment="源账套或人工来源")
|
||||||
|
group_key: Mapped[str] = mapped_column(String(128), comment="独立业务平账组")
|
||||||
|
fingerprint: Mapped[str] = mapped_column(String(64), comment="来源及事实指纹")
|
||||||
|
data_json: Mapped[dict] = mapped_column(JSON, comment="经核实的源数据及人工事实快照")
|
||||||
|
requested_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="录入人账号ID")
|
||||||
|
|
||||||
|
|
||||||
|
class PayrollAllocation(Base, TimestampMixin):
|
||||||
|
"""一次正式计算的逐段过程;版本不可变,生效性由所属台账控制。"""
|
||||||
|
|
||||||
|
__tablename__ = "payroll_allocations"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"正式FIFO分配明细表",
|
||||||
|
UniqueConstraint("ledger_id", "line_no", name="uk_payroll_allocations_ledger_line"),
|
||||||
|
Index("idx_payroll_allocations_business_id", "business_id"),
|
||||||
|
)
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
ledger_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="所属正式台账ID")
|
||||||
|
business_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="正式业务ID")
|
||||||
|
line_no: Mapped[int] = mapped_column(Integer, comment="本台账稳定明细序号")
|
||||||
|
input_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="所用输入版本ID")
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), comment="分配回款金额")
|
||||||
|
data_json: Mapped[dict] = mapped_column(JSON, comment="单据、成本、归属、参数和逐段计算依据")
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""绩效核算台账模型(M3 ④:每笔回款仅核算一次的固化点)。
|
||||||
|
|
||||||
|
**append-only 版本行**:重算 = 插入 calc_version+1 的新行,旧行仅把
|
||||||
|
is_active 翻 0 —— 这是全表唯一的受控 UPDATE(模型注释与 test_models
|
||||||
|
均已声明例外),行内容永不改写;历史版本可回溯(重算授权留痕的需求口径)。
|
||||||
|
|
||||||
|
防重三层:服务层 CAS(UPDATE ... WHERE settlement_status=0,rowcount=0
|
||||||
|
即 400)+ FOR UPDATE 行锁 + 库级 uk(receipt_id, calc_version, personnel_id)
|
||||||
|
兜底。uk 含 personnel_id:二次分配分摊(澄清 #1 答复 2026-10-19,分配
|
||||||
|
绩效——按方案比例拆给参与人、余额归合同负责人)后同一回款同版本按人
|
||||||
|
各落一行,拆分仍是「一次核算」的组成部分;聚合行 receipt_id 为 NULL,
|
||||||
|
MySQL 唯一索引对 NULL 不去重,天然不约束。
|
||||||
|
|
||||||
|
经理聚合行(calc_type=2,receipt_id 为 NULL):月度批处理时按「本团队
|
||||||
|
业务员绩效 ×12.5% + 全公司 ×5%」落一行;其唯一性由月结生成的
|
||||||
|
settlement_month 串行化 + 服务层保证(MySQL 无部分唯一索引,docstring
|
||||||
|
声明即可)。
|
||||||
|
|
||||||
|
#12 答复(2026-10-19):经理个人户业绩是否计入聚合基数改为参数
|
||||||
|
``manager.personal.included``(默认 0=不计入)——消费点在 settlement_service
|
||||||
|
的聚合函数。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Date,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
# 核算类型(performance_ledgers.calc_type)
|
||||||
|
CALC_TYPE_RECEIPT = 1 # 单笔回款核算
|
||||||
|
CALC_TYPE_MANAGER_AGGREGATE = 2 # 经理团队聚合(月度批处理)
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceLedger(Base, TimestampMixin):
|
||||||
|
"""绩效核算台账表:核算事实的固化点,报表 2.4.1 的数据源。"""
|
||||||
|
|
||||||
|
__tablename__ = "performance_ledgers"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
|
||||||
|
# 库级防重兜底:同一回款同版本按人仅一行(分摊拆行后负责人+参与人
|
||||||
|
# 各占一行;服务层 CAS+行锁之外的第二道闸)。聚合行 receipt_id 为
|
||||||
|
# NULL,MySQL 唯一索引对 NULL 不去重,天然不约束聚合行
|
||||||
|
UniqueConstraint(
|
||||||
|
"receipt_id",
|
||||||
|
"calc_version",
|
||||||
|
"personnel_id",
|
||||||
|
"allocation_key",
|
||||||
|
name="uk_performance_ledgers_receipt_id_calc_version_personnel",
|
||||||
|
),
|
||||||
|
Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"),
|
||||||
|
Index(
|
||||||
|
"idx_performance_ledgers_month_personnel_type",
|
||||||
|
"settlement_month",
|
||||||
|
"personnel_id",
|
||||||
|
"calc_type",
|
||||||
|
),
|
||||||
|
Index("idx_performance_ledgers_personnel_id", "personnel_id"),
|
||||||
|
Index("idx_performance_ledgers_receipt_id", "receipt_id"),
|
||||||
|
Index("idx_performance_ledgers_business_id", "business_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
receipt_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("receipts.id", ondelete="RESTRICT"),
|
||||||
|
comment="回款ID(receipts.id;聚合行可为 NULL)",
|
||||||
|
)
|
||||||
|
calc_version: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="核算版本(重算+1,旧行 is_active=0)",
|
||||||
|
)
|
||||||
|
allocation_key: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, server_default="''", comment="正式FIFO事件键;历史单笔核算为空串",
|
||||||
|
)
|
||||||
|
is_active: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
|
||||||
|
)
|
||||||
|
calc_type: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
|
||||||
|
)
|
||||||
|
business_no: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="业务编号(冗余固化,列表免联查)"
|
||||||
|
)
|
||||||
|
personnel_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="归属营销人员ID(personnel.id;核算归属锚点)",
|
||||||
|
)
|
||||||
|
personnel_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="归属营销人员姓名(冗余固化)"
|
||||||
|
)
|
||||||
|
role_code: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="岗位编码(business_roles.role_code,计提公式锚点)"
|
||||||
|
)
|
||||||
|
customer_type: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="客户分类:personal/manager_personal/enterprise"
|
||||||
|
)
|
||||||
|
product_category: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
|
||||||
|
)
|
||||||
|
ship_date: Mapped[date | None] = mapped_column(Date, comment="发货日期(回款天数基准)")
|
||||||
|
receipt_date: Mapped[date] = mapped_column(
|
||||||
|
Date, nullable=False, comment="回款日期(参数版本匹配日,裁决 #11)"
|
||||||
|
)
|
||||||
|
payment_days: Mapped[int | None] = mapped_column(
|
||||||
|
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)"
|
||||||
|
)
|
||||||
|
rate_text: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
|
||||||
|
)
|
||||||
|
converted_profit: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="折算毛利润额(可负;混合业务为各行汇总)"
|
||||||
|
)
|
||||||
|
performance_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="绩效金额(可负,裁决:负绩效照算不归零)"
|
||||||
|
)
|
||||||
|
barter_applied: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="以物抵顶处理标记:当前系统不自动折算,1表示由线下规则处理 0表示未标记",
|
||||||
|
)
|
||||||
|
weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)")
|
||||||
|
payment_weight: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(7, 6), comment="支付权重系数 R×R(展示用)"
|
||||||
|
)
|
||||||
|
payable_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)"
|
||||||
|
)
|
||||||
|
settlement_month: Mapped[str] = mapped_column(
|
||||||
|
String(7), nullable=False, comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)"
|
||||||
|
)
|
||||||
|
is_probation: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="试用期:1照算不发放 0否",
|
||||||
|
)
|
||||||
|
missing_cost: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
|
||||||
|
)
|
||||||
|
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, comment="计算快照(所用参数版本与行级折算明细,重算可回溯)"
|
||||||
|
)
|
||||||
|
settlement_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("monthly_settlements.id", ondelete="RESTRICT"),
|
||||||
|
comment="所属月结ID(月结生成时回填;NULL=未入结)",
|
||||||
|
)
|
||||||
|
calc_reason: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), comment="核算/重算原因(重算必填留痕)"
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
Loaded 100 of 611 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user