Compare commits
113
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 |
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
|
||||
+31
-3
@@ -1,5 +1,7 @@
|
||||
# AI辅助目录(整个目录忽略)
|
||||
ai-aux/
|
||||
.ai/
|
||||
.agents/
|
||||
/AGENTS.md
|
||||
|
||||
# 依赖
|
||||
node_modules/
|
||||
@@ -45,13 +47,15 @@ htmlcov/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.tox/
|
||||
# 端到端测试产出的截图
|
||||
frontend/e2e/*.png
|
||||
# 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD
|
||||
# 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖)
|
||||
frontend/e2e/**/*.png
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.bak
|
||||
*.cache
|
||||
.tmp-processes*.txt
|
||||
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
|
||||
~$*
|
||||
|
||||
@@ -64,3 +68,27 @@ CLAUDE.md
|
||||
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。
|
||||
|
||||
> 当前进度:登录鉴权与系统管理(用户/角色/操作日志/日志查询)已完成,前端具备应用布局框架。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
|
||||
> 当前进度:正式工资闭环及同步运维自动化已完成本地开发验证,支持每周原始采集、有限重试、独立心跳和站内提示,并提供 Windows 托管脚本。辽宁兴晟达真实样本已在隔离库完成正式导入、核算、月结、报表与导出验收,整月实际工资仍须另行核定,见[真实样本业务闭环验收](docs/真实样本业务闭环验收_2026-11-03.md)。生产自动化默认关闭,尚未安装启用。审核能力保留但默认关闭,多原单更正仍单独跟踪。相关说明见 [T+ 本地恢复与同步开发手册](docs/T+本地恢复与同步开发手册.md) 和 [Windows 操作说明](scripts/windows/README.md)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -70,7 +70,7 @@ pnpm dev
|
||||
# 后端测试与代码检查
|
||||
cd backend
|
||||
uv run pytest tests/unit -q # 纯单元,无需容器,秒级
|
||||
uv run pytest -q # 全部(集成部分需容器在跑)
|
||||
python ../scripts/run_isolated_tests.py # 创建独立测试库运行集成,禁止指向业务库
|
||||
uv run ruff check . # 代码检查
|
||||
uv run ruff format . # 代码格式化
|
||||
|
||||
@@ -128,9 +128,28 @@ python e2e/system_management.py # 系统管理 13 项
|
||||
|
||||
## 相关文档
|
||||
|
||||
客户现场部署见 [客户现场部署与 T+ 连接说明](docs/客户现场部署与T+连接说明.md):按 Linux 容器、Windows Server 或 Linux 原生选择托管方式,连接客户既有 T+ SQL Server。生产 Compose 为 `docker/docker-compose.prod.yml`,T+ 恢复库编排仅用于本地开发。
|
||||
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| `backend/README.md` | 后端启动、分层约定、接口清单 |
|
||||
| `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 |
|
||||
| `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 |
|
||||
| `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_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_EXPIRE_MINUTES=120
|
||||
# 验证码有效期(秒),规范要求 300
|
||||
@@ -26,6 +35,44 @@ LOGIN_FAIL_LIMIT=5
|
||||
LOGIN_LOCK_MINUTES=15
|
||||
|
||||
# ===== T+ 对接(待畅捷通确认后填写)=====
|
||||
T_PLUS_BASE_URL=
|
||||
T_PLUS_APP_KEY=
|
||||
T_PLUS_APP_SECRET=
|
||||
T_PLUS_ENABLED=false
|
||||
T_PLUS_HOST=127.0.0.1
|
||||
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"]
|
||||
+103
-1
@@ -83,8 +83,9 @@ app/
|
||||
| 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` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
|
||||
| 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 人员出姓名) |
|
||||
|
||||
@@ -114,6 +115,44 @@ app/
|
||||
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
|
||||
若登录接口自身返回 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
|
||||
@@ -196,6 +235,59 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
||||
- **留痕表的固化设计**:`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 是安全问题,必须启动即失败。
|
||||
@@ -205,3 +297,13 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
||||
**`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。
|
||||
|
||||
**改了代码不生效** —— 启动时没加 `--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)。
|
||||
@@ -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,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,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,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,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")
|
||||
@@ -25,8 +25,8 @@ from datetime import date
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c7d2e5a81f30"
|
||||
down_revision = "3bca400421c6"
|
||||
revision: str = "c7d2e5a81f30"
|
||||
down_revision: str | None = "3bca400421c6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
@@ -34,10 +34,7 @@ depends_on = None
|
||||
def upgrade() -> None:
|
||||
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
|
||||
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
|
||||
op.execute(
|
||||
"UPDATE param_definitions SET default_value = '1' "
|
||||
"WHERE id IN (17, 18)"
|
||||
)
|
||||
op.execute("UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18)")
|
||||
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
@@ -188,6 +185,4 @@ def downgrade() -> None:
|
||||
)
|
||||
|
||||
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)"
|
||||
)
|
||||
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,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)
|
||||
+62
-6
@@ -98,7 +98,7 @@ def _is_digits(value: object) -> bool:
|
||||
return isinstance(value, str) and value.isdigit()
|
||||
|
||||
|
||||
def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
def require_admin(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
|
||||
"""要求当前用户具备管理员角色。
|
||||
|
||||
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
|
||||
@@ -107,6 +107,9 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
|
||||
|
||||
: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
|
||||
@@ -119,20 +122,73 @@ AdminUser = Annotated[User, Depends(require_admin)]
|
||||
LoginUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
def require_roles(*role_codes: str):
|
||||
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)]) -> User:
|
||||
codes = set(user_repository.get_role_codes(current_user))
|
||||
if not codes & set(role_codes):
|
||||
raise ForbiddenError("无权访问审批管理")
|
||||
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,12 @@
|
||||
健康检查作为运维探针不带版本号,直接位于 `/api/health`。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api import health
|
||||
from app.api.deps import authorize_request
|
||||
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
|
||||
@@ -16,14 +18,23 @@ 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()
|
||||
|
||||
@@ -31,19 +42,26 @@ api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
|
||||
# 业务接口:/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)
|
||||
|
||||
# 系统管理(需求 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/*,业务操作登录即可访问
|
||||
@@ -51,6 +69,7 @@ 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/*,业务操作登录即可访问
|
||||
@@ -63,7 +82,12 @@ 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)
|
||||
@@ -5,9 +5,9 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
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.models import User
|
||||
from app.repositories.user_repository import get_role_codes
|
||||
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
|
||||
from app.services import auth_service, captcha_service
|
||||
|
||||
@@ -65,12 +65,16 @@ def logout(
|
||||
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
|
||||
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(
|
||||
CurrentUserOut(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -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")])
|
||||
@@ -13,14 +13,16 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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.models import Customer, User
|
||||
from app.schemas.category_batch import CustomerCategoryBatch, CustomerOwnershipBatch
|
||||
from app.schemas.customer import (
|
||||
CustomerCreateIn,
|
||||
CustomerOut,
|
||||
CustomerOwnershipIn,
|
||||
CustomerQuery,
|
||||
CustomerRatingIn,
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
@@ -32,6 +34,18 @@ router = APIRouter(prefix="/customers", tags=["客户管理"])
|
||||
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,
|
||||
@@ -45,7 +59,7 @@ def list_customers(
|
||||
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
|
||||
def create_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: CustomerCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CustomerOut]:
|
||||
@@ -69,7 +83,7 @@ def get_customer(
|
||||
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
|
||||
def update_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -90,7 +104,7 @@ def update_customer(
|
||||
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
|
||||
def delete_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@@ -102,6 +116,31 @@ def delete_customer(
|
||||
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],
|
||||
@@ -109,7 +148,7 @@ def delete_customer(
|
||||
)
|
||||
def change_ownership(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerOwnershipIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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 (
|
||||
@@ -44,7 +44,7 @@ def list_personnel(
|
||||
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
|
||||
def create_personnel(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: PersonnelCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[PersonnelOut]:
|
||||
@@ -68,7 +68,7 @@ def get_personnel(
|
||||
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
|
||||
def update_personnel(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
payload: PersonnelCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -85,7 +85,7 @@ def update_personnel(
|
||||
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
|
||||
def delete_personnel(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@@ -101,14 +101,14 @@ def delete_personnel(
|
||||
)
|
||||
def set_status(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
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, operator=current_user, meta=meta
|
||||
session, personnel_id, payload.status, effective_date=payload.effective_date, operator=current_user, meta=meta
|
||||
)
|
||||
return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职")
|
||||
|
||||
@@ -116,7 +116,7 @@ def set_status(
|
||||
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
|
||||
def replace_regions(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
payload: RegionReplaceIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -131,7 +131,7 @@ def replace_regions(
|
||||
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
|
||||
def replace_plans(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
payload: PlanReplaceIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值),
|
||||
类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。
|
||||
|
||||
**数据提取(POST /products/extract)暂不提供**:它依赖业务明细表
|
||||
(V5 迁移),待业务管理任务完成后补充。
|
||||
数据提取使用预览指纹校验、名称规格去重和单位分类冲突保护,
|
||||
仅建立产品档案关联,不改写历史业务金额。
|
||||
|
||||
基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
|
||||
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
|
||||
@@ -14,11 +14,13 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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.models import Product, User
|
||||
from app.schemas.category_batch import ProductCategoryBatch
|
||||
from app.schemas.product import (
|
||||
ProductCreateIn,
|
||||
ProductExtractIn,
|
||||
ProductOut,
|
||||
ProductQuery,
|
||||
ProductStatusIn,
|
||||
@@ -31,6 +33,41 @@ 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,
|
||||
@@ -44,7 +81,7 @@ def list_products(
|
||||
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
|
||||
def create_product(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: ProductCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[ProductOut]:
|
||||
@@ -68,7 +105,7 @@ def get_product(
|
||||
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
|
||||
def update_product(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
product_id: Annotated[int, Path(ge=1)],
|
||||
payload: ProductUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -85,7 +122,7 @@ def update_product(
|
||||
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
|
||||
def delete_product(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
product_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@@ -100,7 +137,7 @@ def delete_product(
|
||||
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
|
||||
def set_status(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
product_id: Annotated[int, Path(ge=1)],
|
||||
payload: ProductStatusIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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 (
|
||||
@@ -24,7 +24,16 @@ from app.schemas.business import (
|
||||
GrossProfitOut,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
from app.services import business_service
|
||||
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)]
|
||||
@@ -32,6 +41,83 @@ 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,
|
||||
@@ -45,7 +131,7 @@ def list_businesses(
|
||||
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
|
||||
def create_business(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: BusinessCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[BusinessOut]:
|
||||
@@ -69,7 +155,7 @@ def get_business(
|
||||
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
|
||||
def update_business(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
payload: BusinessUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -86,7 +172,7 @@ def update_business(
|
||||
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
|
||||
def delete_business(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@@ -98,7 +184,7 @@ def delete_business(
|
||||
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
|
||||
def change_status(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
payload: BusinessStatusIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -115,7 +201,7 @@ def change_status(
|
||||
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
|
||||
def change_ownership(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
payload: BusinessOwnershipIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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
|
||||
@@ -36,7 +36,7 @@ def list_grades(
|
||||
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
|
||||
def create_grade(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: RatingGradeCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[RatingGradeOut]:
|
||||
@@ -50,7 +50,7 @@ def create_grade(
|
||||
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
|
||||
def update_grade(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
grade_id: Annotated[int, Path(ge=1)],
|
||||
payload: RatingGradeUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -65,7 +65,7 @@ def update_grade(
|
||||
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
|
||||
def delete_grade(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
grade_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
|
||||
@@ -9,16 +9,17 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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_service
|
||||
from app.services import receipt_confirmation, receipt_service
|
||||
|
||||
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
@@ -26,6 +27,21 @@ 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,
|
||||
@@ -39,7 +55,7 @@ def list_receipts(
|
||||
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
|
||||
def create_receipt(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: ReceiptCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[ReceiptOut]:
|
||||
@@ -63,7 +79,7 @@ def get_receipt(
|
||||
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
|
||||
def update_receipt(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
payload: ReceiptUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -80,7 +96,7 @@ def update_receipt(
|
||||
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
|
||||
def delete_receipt(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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 (
|
||||
@@ -39,7 +39,7 @@ def list_shipments(
|
||||
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
|
||||
def create_shipment(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: ShipmentCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[ShipmentOut]:
|
||||
@@ -63,7 +63,7 @@ def get_shipment(
|
||||
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
|
||||
def update_shipment(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
shipment_id: Annotated[int, Path(ge=1)],
|
||||
payload: ShipmentUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -80,7 +80,7 @@ def update_shipment(
|
||||
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
|
||||
def delete_shipment(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
shipment_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
|
||||
@@ -10,18 +10,14 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
|
||||
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.calc import CalcReceiptOut, LedgerOut, LedgerQuery
|
||||
from app.services import calc_service
|
||||
|
||||
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/calc", tags=["绩效工资 · 绩效核算"])
|
||||
|
||||
_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
|
||||
# 重算是授权动作:管理员/财务(营销后勤不可重算)
|
||||
_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))]
|
||||
|
||||
|
||||
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
|
||||
@@ -50,7 +46,7 @@ def get_ledger(
|
||||
def calc_receipt(
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
session: DbSession,
|
||||
current_user: _CalcWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CalcReceiptOut]:
|
||||
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
|
||||
@@ -81,7 +77,7 @@ def recalc_receipt(
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
payload: RecalcIn,
|
||||
session: DbSession,
|
||||
current_user: _RecalcUser,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CalcReceiptOut]:
|
||||
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
|
||||
@@ -97,7 +93,7 @@ def recalc_receipt(
|
||||
def batch_calc(
|
||||
payload: BatchIn,
|
||||
session: DbSession,
|
||||
current_user: _CalcWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[list[dict]]:
|
||||
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
|
||||
|
||||
@@ -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
|
||||
@@ -8,10 +8,8 @@ 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_MARKETING_OPS
|
||||
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.settlement import (
|
||||
AdjustmentCreateIn,
|
||||
SettlementDetailOut,
|
||||
@@ -21,16 +19,15 @@ from app.schemas.settlement import (
|
||||
)
|
||||
from app.services import settlement_service
|
||||
|
||||
router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"])
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/settlements", tags=["绩效工资 · 月度结算"])
|
||||
|
||||
_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
|
||||
|
||||
|
||||
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
|
||||
def generate(
|
||||
payload: SettlementGenerateIn,
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettlementDetailOut]:
|
||||
"""按确认回款口径生成指定月份的结算快照(草稿)。"""
|
||||
@@ -66,7 +63,7 @@ def get_settlement(
|
||||
def submit(
|
||||
settlement_id: Annotated[int, Path(ge=1)],
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettlementDetailOut]:
|
||||
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
|
||||
@@ -83,7 +80,7 @@ def submit(
|
||||
def create_adjustment(
|
||||
payload: AdjustmentCreateIn,
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[dict]:
|
||||
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
|
||||
|
||||
@@ -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))
|
||||
@@ -60,7 +60,7 @@ def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[
|
||||
|
||||
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。
|
||||
"""
|
||||
return ok(business_role_service.list_template_options())
|
||||
return ok(business_role_service.list_template_options(session))
|
||||
|
||||
|
||||
@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")
|
||||
|
||||
@@ -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,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,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"'},
|
||||
)
|
||||
+180
-1
@@ -10,14 +10,25 @@
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
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.db import dispose_engine, get_session_factory
|
||||
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.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:
|
||||
@@ -80,6 +91,115 @@ def _create_admin(args: argparse.Namespace) -> int:
|
||||
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:
|
||||
"""命令行入口。"""
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -96,12 +216,71 @@ def main() -> int:
|
||||
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()
|
||||
|
||||
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:
|
||||
dispose_engine()
|
||||
dispose_tplus_engine()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -27,11 +27,12 @@ _WIDTH, _HEIGHT = 120, 40
|
||||
_FONT_SIZE = 25
|
||||
_PADDING = 8
|
||||
|
||||
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
|
||||
_INK = (15, 54, 70)
|
||||
_INTERFERENCE = (126, 156, 170)
|
||||
_SPECKLE = (168, 183, 192)
|
||||
_PAPER = (247, 246, 242)
|
||||
# 与前端 v3 配色呼应(--ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper):
|
||||
# 墨色字符、次级墨干扰、辅助墨噪点、纸白底
|
||||
_INK = (28, 35, 51) # #1c2333
|
||||
_INTERFERENCE = (91, 101, 119) # #5b6577
|
||||
_SPECKLE = (154, 163, 178) # #9aa3b2
|
||||
_PAPER = (255, 255, 255) # #ffffff
|
||||
|
||||
_SPECKLE_COUNT = 46
|
||||
_INTERFERENCE_LINES = 2
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
@@ -35,7 +36,28 @@ class Settings(BaseSettings):
|
||||
|
||||
# ===== 应用 =====
|
||||
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 小时
|
||||
@@ -50,14 +72,39 @@ class Settings(BaseSettings):
|
||||
mysql_port: int = 3306
|
||||
mysql_database: str = "payroll"
|
||||
mysql_user: str = "payroll"
|
||||
mysql_password: str = ""
|
||||
mysql_password: str = Field(default="", repr=False)
|
||||
|
||||
# ===== Redis =====
|
||||
redis_host: str = "127.0.0.1"
|
||||
redis_port: int = 6379
|
||||
redis_password: str = ""
|
||||
redis_password: str = Field(default="", repr=False)
|
||||
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")
|
||||
@classmethod
|
||||
def _check_secret_key(cls, v: str) -> str:
|
||||
@@ -89,9 +136,33 @@ class Settings(BaseSettings):
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
"""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}"
|
||||
|
||||
@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/。
|
||||
@@ -99,7 +170,7 @@ class Settings(BaseSettings):
|
||||
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
|
||||
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
|
||||
"""
|
||||
return BACKEND_DIR / "uploads"
|
||||
return self.upload_root.resolve() if self.upload_root else BACKEND_DIR / "uploads"
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -87,6 +87,7 @@ MODULE_SYSTEM_SETTINGS = "系统设置"
|
||||
MODULE_APPROVAL = "审批管理"
|
||||
MODULE_CALC = "绩效核算"
|
||||
MODULE_SETTLEMENT = "月度结算"
|
||||
MODULE_REPORT = "报表统计"
|
||||
|
||||
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||
@@ -105,3 +106,4 @@ ACTION_ENABLE = "启用"
|
||||
ACTION_DISABLE = "禁用"
|
||||
ACTION_APPROVE = "审批通过"
|
||||
ACTION_REJECT = "审批驳回"
|
||||
ACTION_EXPORT = "导出"
|
||||
@@ -74,13 +74,16 @@ async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONRespons
|
||||
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
|
||||
|
||||
|
||||
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
|
||||
async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""兜底处理未预期异常。
|
||||
|
||||
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
|
||||
"""
|
||||
logger.exception("未预期的服务端异常")
|
||||
return _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
|
||||
request_id = getattr(request.state, "request_id", "-")
|
||||
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:
|
||||
|
||||
@@ -52,3 +52,10 @@ class NotFoundError(AppException):
|
||||
|
||||
code = CODE_NOT_FOUND
|
||||
message = "资源不存在"
|
||||
|
||||
|
||||
class ConflictError(AppException):
|
||||
"""授权版本已变化,客户端应刷新后重新核对。"""
|
||||
|
||||
code = 409
|
||||
message = "配置已被其他人修改,请刷新后重新核对"
|
||||
@@ -1,6 +1,6 @@
|
||||
"""日志配置。
|
||||
|
||||
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天+大小切分、
|
||||
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天轮转(当前未做大小上限,磁盘容量需配合监控)、
|
||||
保留 30 天、禁止输出密码/手机号/身份证/Token。
|
||||
|
||||
Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8,
|
||||
@@ -13,6 +13,7 @@ import sys
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
|
||||
from app.core.config import BACKEND_DIR
|
||||
from app.core.monitoring import REQUEST_ID
|
||||
|
||||
LOG_DIR = BACKEND_DIR / "logs"
|
||||
|
||||
@@ -53,6 +54,8 @@ class SensitiveFormatter(logging.Formatter):
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if not hasattr(record, "request_id"):
|
||||
record.request_id = REQUEST_ID.get()
|
||||
return scrub(super().format(record))
|
||||
|
||||
|
||||
@@ -71,7 +74,7 @@ def setup_logging(level: str = "INFO", log_to_file: bool = True) -> None:
|
||||
root.removeHandler(handler)
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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.exception_handlers import register_exception_handlers
|
||||
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.tplus_db import dispose_tplus_engine
|
||||
from app.services.monitor_collector import MonitorCollector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""应用生命周期管理。
|
||||
|
||||
启动阶段不建立数据库/Redis 连接(延迟到首次使用),
|
||||
@@ -33,8 +36,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
settings.mysql_host,
|
||||
settings.mysql_database,
|
||||
)
|
||||
yield
|
||||
app.state.monitor.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
app.state.monitor.stop()
|
||||
dispose_engine()
|
||||
dispose_tplus_engine()
|
||||
close_redis()
|
||||
logger.info("应用已停止,连接池已释放")
|
||||
|
||||
@@ -60,6 +68,9 @@ def create_app() -> FastAPI:
|
||||
|
||||
register_exception_handlers(app)
|
||||
app.include_router(api_router, prefix="/api")
|
||||
recorder = Recorder()
|
||||
app.state.monitor = MonitorCollector(settings, recorder)
|
||||
app.add_middleware(MonitorMiddleware, recorder=recorder)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -10,15 +10,20 @@
|
||||
"""
|
||||
|
||||
from app.models.approval import ApprovalOrder, ApprovalStep
|
||||
from app.models.authorization import AuthorizationSetting, UserPermissionOverride
|
||||
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.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
|
||||
@@ -26,31 +31,60 @@ 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
|
||||
|
||||
__all__ = [
|
||||
"AdjustmentOrder",
|
||||
"ApprovalOrder",
|
||||
"ApprovalStep",
|
||||
"AuthorizationSetting",
|
||||
"Base",
|
||||
"Business",
|
||||
"BusinessItem",
|
||||
"BusinessRole",
|
||||
"BusinessStatusLog",
|
||||
"CalcTemplateDefinition",
|
||||
"Customer",
|
||||
"CustomerRatingGrade",
|
||||
"LoginLog",
|
||||
"MonitorEvent",
|
||||
"MonitorRequestMinute",
|
||||
"MonitorSnapshot",
|
||||
"MonthlySettlement",
|
||||
"OperationLog",
|
||||
"OwnershipChangeLog",
|
||||
"ParamChangeBatch",
|
||||
"ParamDefinition",
|
||||
"ParamValue",
|
||||
"PayrollAllocation",
|
||||
"PayrollInput",
|
||||
"PayrollMonth",
|
||||
"PerformanceLedger",
|
||||
"Permission",
|
||||
"Personnel",
|
||||
"PersonnelPlan",
|
||||
"PersonnelRegion",
|
||||
"ProbationDeferral",
|
||||
"ProbationPayment",
|
||||
"Product",
|
||||
"Receipt",
|
||||
"Role",
|
||||
@@ -60,7 +94,22 @@ __all__ = [
|
||||
"Shipment",
|
||||
"ShipmentItem",
|
||||
"SystemSetting",
|
||||
"TPlusAutomationConfig",
|
||||
"TPlusAutomationState",
|
||||
"TPlusBusinessDocument",
|
||||
"TPlusBusinessSyncConfig",
|
||||
"TPlusFact",
|
||||
"TPlusRuntimeControl",
|
||||
"TPlusSourceHead",
|
||||
"TPlusSourceLink",
|
||||
"TPlusSyncBatch",
|
||||
"TPlusSyncError",
|
||||
"TPlusSyncRecord",
|
||||
"TPlusSyncWatermark",
|
||||
"TPlusWorkLine",
|
||||
"TPlusWorkRun",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserPermissionOverride",
|
||||
"UserRole",
|
||||
]
|
||||
@@ -15,7 +15,16 @@ ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, text
|
||||
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
|
||||
@@ -54,7 +63,17 @@ class ApprovalOrder(Base, TimestampMixin):
|
||||
__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(
|
||||
@@ -76,6 +95,17 @@ class ApprovalOrder(Base, TimestampMixin):
|
||||
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,
|
||||
|
||||
@@ -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")
|
||||
@@ -91,6 +91,9 @@ class TPlusSyncMixin:
|
||||
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, ...]:
|
||||
|
||||
@@ -15,7 +15,18 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
|
||||
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 (
|
||||
@@ -42,27 +53,34 @@ class SecondaryAllocation(Base, TimestampMixin):
|
||||
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
|
||||
与比例,响应以 hint 提示合计/余额,不做强制校验
|
||||
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
|
||||
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
|
||||
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
|
||||
- **版本行 append-only**:编辑全量替换与驳回清空都把旧行置
|
||||
``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由
|
||||
``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照
|
||||
(``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只
|
||||
补齐「方案事实留存」,不改变核算口径
|
||||
|
||||
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
|
||||
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
|
||||
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
|
||||
同一合同的同一参与人仅一行,重复提交在服务层聚合。
|
||||
冗余固化,人离职后方案回显仍有名。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_personnel",
|
||||
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(
|
||||
@@ -74,6 +92,12 @@ class SecondaryAllocation(Base, TimestampMixin):
|
||||
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"),
|
||||
@@ -88,6 +112,12 @@ class SecondaryAllocation(Base, TimestampMixin):
|
||||
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):
|
||||
@@ -118,6 +148,7 @@ class Business(Base, TimestampMixin, TPlusSyncMixin):
|
||||
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,
|
||||
@@ -210,9 +241,9 @@ class BusinessItem(Base, TimestampMixin):
|
||||
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||
)
|
||||
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
|
||||
product_category: Mapped[str] = mapped_column(
|
||||
product_category: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
|
||||
)
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>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自定义",
|
||||
)
|
||||
@@ -49,10 +49,9 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
||||
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] = mapped_column(
|
||||
customer_type: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
server_default="personal",
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
|
||||
)
|
||||
@@ -63,6 +62,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
||||
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):
|
||||
|
||||
@@ -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,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="单据、成本、归属、参数和逐段计算依据")
|
||||
@@ -55,6 +55,7 @@ class PerformanceLedger(Base, TimestampMixin):
|
||||
"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"),
|
||||
@@ -65,6 +66,7 @@ class PerformanceLedger(Base, TimestampMixin):
|
||||
"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"),
|
||||
)
|
||||
|
||||
@@ -82,6 +84,9 @@ class PerformanceLedger(Base, TimestampMixin):
|
||||
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,
|
||||
@@ -143,7 +148,7 @@ class PerformanceLedger(Base, TimestampMixin):
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="以物抵顶折算:1已按 80% 折算 0否",
|
||||
comment="以物抵顶处理标记:当前系统不自动折算,1表示由线下规则处理 0表示未标记",
|
||||
)
|
||||
weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)")
|
||||
payment_weight: Mapped[Decimal | None] = mapped_column(
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Numeric, String
|
||||
from sqlalchemy import JSON, Date, ForeignKey, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import (
|
||||
@@ -62,9 +62,22 @@ class Personnel(Base, TimestampMixin, TPlusSyncMixin):
|
||||
server_default="0",
|
||||
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
|
||||
)
|
||||
probation_start_date: Mapped[date | None] = mapped_column(
|
||||
Date, comment="试用期开始日(含当日)"
|
||||
)
|
||||
regularization_date: Mapped[date | None] = mapped_column(
|
||||
Date, comment="转正生效日(当日不再属于试用期)"
|
||||
)
|
||||
resignation_date: Mapped[date | None] = mapped_column(
|
||||
Date, comment="离职生效日(当日起不计基本工资)"
|
||||
)
|
||||
salary_standard: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)"
|
||||
)
|
||||
salary_history_json: Mapped[list | None] = mapped_column(
|
||||
JSON,
|
||||
comment="工资标准、岗位及任职资料生效版本",
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""试用期缓发与结清记录,引用固化台账,不使用人员当前开关重写历史。"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, mysql_table_args
|
||||
|
||||
|
||||
class ProbationDeferral(Base):
|
||||
__tablename__ = "probation_deferrals"
|
||||
__table_args__ = mysql_table_args(
|
||||
"试用期缓发台账(每个正式台账版本一条)",
|
||||
UniqueConstraint("ledger_id", name="uk_probation_deferrals_ledger_id"),
|
||||
Index("idx_probation_deferrals_personnel_id_year", "personnel_id", "year"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
ledger_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("performance_ledgers.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
comment="原核算台账版本ID",
|
||||
)
|
||||
personnel_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, nullable=False, comment="归属人员ID")
|
||||
year: Mapped[str] = mapped_column(String(4), nullable=False, comment="原计提年度")
|
||||
month: Mapped[str] = mapped_column(String(7), nullable=False, comment="本次计提或应付变化月份")
|
||||
accrued: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, comment="本次试用期计提额,可负"
|
||||
)
|
||||
eligible: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, comment="满足起付及权重的本次应付差额,可负"
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False, comment="创建时间"
|
||||
)
|
||||
|
||||
|
||||
class ProbationPayment(Base):
|
||||
__tablename__ = "probation_payments"
|
||||
__table_args__ = mysql_table_args(
|
||||
"试用期年终及后续结清记录(工资生成事务内固化)",
|
||||
UniqueConstraint(
|
||||
"settlement_id",
|
||||
"personnel_id",
|
||||
"year",
|
||||
name="uk_probation_payments_settlement_person_year",
|
||||
),
|
||||
UniqueConstraint("adjustment_order_id", name="uk_probation_payments_adjustment_order_id"),
|
||||
Index("idx_probation_payments_personnel_id_year", "personnel_id", "year"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
settlement_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("monthly_settlements.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
comment="月结ID(当月调整单结清时为空)",
|
||||
)
|
||||
adjustment_order_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("adjustment_orders.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
comment="已生成工资后的结清调整单ID",
|
||||
)
|
||||
personnel_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, nullable=False, comment="归属人员ID")
|
||||
year: Mapped[str] = mapped_column(String(4), nullable=False, comment="原累计年度")
|
||||
month: Mapped[str] = mapped_column(String(7), nullable=False, comment="本次实际支付月份")
|
||||
amount: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, comment="本次补发或扣回金额"
|
||||
)
|
||||
snapshot_json: Mapped[dict] = mapped_column(
|
||||
JSON, nullable=False, comment="计提、起付权重、门槛及历史补发依据"
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False, comment="创建时间"
|
||||
)
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Numeric, String, UniqueConstraint
|
||||
from sqlalchemy import JSON, Numeric, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import (
|
||||
@@ -47,12 +47,14 @@ class Product(Base, TimestampMixin, TPlusSyncMixin):
|
||||
sale_price: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 4), comment="销售单价(元,T+ 同步)"
|
||||
)
|
||||
product_category: Mapped[str] = mapped_column(
|
||||
product_category: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||
source_trade_json: Mapped[dict | None] = mapped_column(JSON, comment="最近有效发货数量、含税成交价及源单据快照")
|
||||
quantity_price_manual: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, server_default="0", comment="数量单价人工保护:1人工维护 0随源更新")
|
||||
@@ -10,10 +10,10 @@
|
||||
- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import (
|
||||
@@ -72,7 +72,7 @@ class Receipt(Base, TimestampMixin, TPlusSyncMixin):
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
|
||||
comment="以物抵顶标记:1是 0否(特殊绩效线下核实,系统不自动折算)",
|
||||
)
|
||||
settlement_status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
@@ -80,4 +80,10 @@ class Receipt(Base, TimestampMixin, TPlusSyncMixin):
|
||||
server_default="0",
|
||||
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
|
||||
)
|
||||
confirmation_status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="0", comment="人工确认状态:0待确认 1已确认,独立于凭证及核算状态"
|
||||
)
|
||||
confirmed_by: Mapped[str | None] = mapped_column(String(64), comment="人工确认人账号快照")
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime, comment="人工确认时间")
|
||||
confirmed_revision: Mapped[str | None] = mapped_column(String(64), comment="人工确认时回款关键内容指纹")
|
||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||
@@ -79,6 +79,10 @@ class MonthlySettlement(Base, TimestampMixin):
|
||||
)
|
||||
locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)")
|
||||
|
||||
@property
|
||||
def approval_required(self) -> bool:
|
||||
return bool((self.snapshot_json or {}).get("approval_required", True))
|
||||
|
||||
|
||||
class SettlementItem(Base):
|
||||
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
|
||||
@@ -127,17 +131,27 @@ class SettlementItem(Base):
|
||||
String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE"
|
||||
)
|
||||
net_amount: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 2), comment="净工资 = 绩效±基本工资(按口径;试用期 0)"
|
||||
Numeric(14, 2),
|
||||
comment="净工资 = 绩效±基本工资(按口径;试用期为基本工资+正常应付+年度差额,缓发部分不发)"
|
||||
)
|
||||
is_probation: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
|
||||
comment="试用期:1缓发绩效不发(基本工资正常发,超年累计记 note)0否",
|
||||
)
|
||||
probation_note: Mapped[str | None] = mapped_column(
|
||||
String(255), comment="试用期说明(年累计超阈值部分年底一次性结算)"
|
||||
)
|
||||
normal_payable_sum: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, server_default="0", comment="当月正常实付绩效"
|
||||
)
|
||||
deferred_sum: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, server_default="0", comment="当月试用期缓发变化"
|
||||
)
|
||||
probation_payment_sum: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, server_default="0", comment="本月年度补发或扣回"
|
||||
)
|
||||
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||
JSON, comment="个人明细快照(台账行引用列表)"
|
||||
)
|
||||
@@ -154,6 +168,9 @@ class AdjustmentOrder(Base, TimestampMixin):
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
adjustment_type: Mapped[str] = mapped_column(String(32), nullable=False, server_default="'GENERAL'", comment="调整类型:GENERAL普通 ALLOCATION分配差额")
|
||||
source_ledger_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="分配差额对应的原台账ID")
|
||||
attribution_json: Mapped[dict | None] = mapped_column(JSON, comment="原台账业务与分管归属快照")
|
||||
order_no: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
|
||||
@@ -49,10 +49,9 @@ class Shipment(Base, TimestampMixin, TPlusSyncMixin):
|
||||
ship_date: Mapped[date] = mapped_column(
|
||||
Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)"
|
||||
)
|
||||
freight_amount: Mapped[Decimal] = mapped_column(
|
||||
freight_amount: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 2),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
nullable=True,
|
||||
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||
)
|
||||
total_amount: Mapped[Decimal] = mapped_column(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""自动采集的周期、重试和进程心跳;不承载业务核算状态。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class TPlusAutomationConfig(Base, TimestampMixin):
|
||||
"""页面保存的源级开关,与长任务心跳表分开,切换不争用采集状态行。"""
|
||||
|
||||
__tablename__ = "tplus_automation_configs"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+自动采集设置(不含连接凭据)",
|
||||
UniqueConstraint("source_key", name="uk_tplus_automation_configs_source_key"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
source_key: Mapped[str] = mapped_column(String(64), comment="源连接身份摘要")
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
enabled: Mapped[int] = mapped_column(TINYINT_TYPE, comment="自动采集是否开启:1开启0关闭")
|
||||
version: Mapped[int] = mapped_column(Integer, comment="设置版本号,防止并发覆盖")
|
||||
updated_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="最近修改账号ID")
|
||||
|
||||
|
||||
class TPlusRuntimeControl(Base, TimestampMixin):
|
||||
"""单部署的服务期望状态与托管进程报告,默认不启动。"""
|
||||
|
||||
__tablename__ = "tplus_runtime_controls"
|
||||
__table_args__ = mysql_table_args("T+读取服务运行控制")
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=False, comment="主键ID")
|
||||
desired_state: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="期望状态:RUNNING/STOPPED")
|
||||
actual_state: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="实际状态:RUNNING/STOPPED/STOPPING/DEGRADED/OFFLINE")
|
||||
worker_phase: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="读取服务阶段")
|
||||
heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近心跳时间")
|
||||
last_error: Mapped[str | None] = mapped_column(String(255), comment="最近受控错误")
|
||||
host: Mapped[str | None] = mapped_column(String(128), comment="运行主机")
|
||||
platform: Mapped[str | None] = mapped_column(String(32), comment="运行平台")
|
||||
owner: Mapped[str | None] = mapped_column(String(36), comment="当前托管进程身份")
|
||||
version: Mapped[int] = mapped_column(Integer, server_default="0", comment="状态版本号")
|
||||
updated_by: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="最近修改账号ID")
|
||||
|
||||
|
||||
class TPlusAutomationState(Base, TimestampMixin):
|
||||
__tablename__ = "tplus_automation_states"
|
||||
__table_args__ = mysql_table_args("T+自动采集周期与进程状态(不自动导入正式业务)")
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
source_key: Mapped[str] = mapped_column(String(64), unique=True, comment="源连接身份摘要(不含凭据)")
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
scheduled_period: Mapped[str | None] = mapped_column(String(16), comment="最近已排队ISO周")
|
||||
root_batch_no: Mapped[str | None] = mapped_column(String(32), comment="本周期原始采集批次")
|
||||
current_batch_no: Mapped[str | None] = mapped_column(String(32), comment="当前原始或重试批次")
|
||||
retry_attempts: Mapped[int] = mapped_column(Integer, server_default="0", comment="本周期自动重试次数")
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime, comment="下一次自动重试时间")
|
||||
last_success_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近成功采集时间")
|
||||
maintenance_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近周期检查时间")
|
||||
heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, comment="独立进程心跳时间")
|
||||
worker_owner: Mapped[str | None] = mapped_column(String(36), comment="当前持锁进程身份")
|
||||
worker_phase: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="进程状态:IDLE/RUNNING/STOPPED")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""T+ 业务自动接入:源级开关与单据差异账本,不承载核算结果。"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import JSON, Date, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class TPlusBusinessSyncConfig(Base, TimestampMixin):
|
||||
"""每个源账套一条设置;启用后才按起点自动排队接入。"""
|
||||
|
||||
__tablename__ = "tplus_business_sync_configs"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+业务自动接入设置",
|
||||
UniqueConstraint("source_database", name="uk_tplus_business_sync_configs_source"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
enabled: Mapped[int] = mapped_column(TINYINT_TYPE, server_default="0", comment="自动接入是否启用:1启用0关闭")
|
||||
start_date: Mapped[date] = mapped_column(Date, comment="业务接入起点")
|
||||
version: Mapped[int] = mapped_column(Integer, server_default="0", comment="配置乐观版本")
|
||||
updated_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="授权启用账号")
|
||||
last_error: Mapped[str | None] = mapped_column(String(64), comment="后台接入暂停原因")
|
||||
|
||||
|
||||
class TPlusBusinessDocument(Base, TimestampMixin):
|
||||
"""源单线与正式单据的对应关系与差异结论;重复同步只更新本表。"""
|
||||
|
||||
__tablename__ = "tplus_business_documents"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+业务单据接入与差异账本",
|
||||
UniqueConstraint(
|
||||
"source_database", "entity_type", "source_id",
|
||||
name="uk_tplus_business_documents_source_entity_id",
|
||||
),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), comment="源实体")
|
||||
source_id: Mapped[str] = mapped_column(String(64), comment="源单据或明细ID")
|
||||
source_no: Mapped[str | None] = mapped_column(String(128), comment="原始单号")
|
||||
record_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="当前观察到的源快照ID")
|
||||
applied_record_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="已接入的源快照ID")
|
||||
fingerprint: Mapped[str | None] = mapped_column(String(64), comment="已应用的业务字段指纹")
|
||||
business_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="关联业务ID")
|
||||
target_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="正式单据或明细ID")
|
||||
status: Mapped[str] = mapped_column(String(24), default="PENDING", comment="APPLIED/PENDING/CONFLICT/EXCLUDED")
|
||||
reason: Mapped[str | None] = mapped_column(String(128), comment="固定待补或差异编码")
|
||||
data_json: Mapped[dict] = mapped_column(JSON, comment="最小必要来源与差异说明")
|
||||
@@ -0,0 +1,151 @@
|
||||
"""T+ 同步批次、错误和原始 staging 记录。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class TPlusSyncBatch(Base, TimestampMixin):
|
||||
"""一次 T+ 全量或增量同步的审计记录。"""
|
||||
|
||||
__tablename__ = "tplus_sync_batches"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 同步批次表(记录读取范围、结果和对账统计)",
|
||||
UniqueConstraint("batch_no", name="uk_tplus_sync_batches_batch_no"),
|
||||
UniqueConstraint("request_key", name="uk_tplus_sync_batches_request_key"),
|
||||
Index("idx_tplus_sync_batches_status", "status"),
|
||||
Index("idx_tplus_sync_batches_started_at", "started_at"),
|
||||
Index("idx_tplus_sync_batches_parent_status", "parent_batch_no", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
batch_no: Mapped[str] = mapped_column(String(32), nullable=False, comment="同步批次号")
|
||||
sync_mode: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED"
|
||||
)
|
||||
source_database: Mapped[str | None] = mapped_column(String(128), comment="T+ 源数据库名")
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, comment="开始执行时间(排队时为空)"
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, comment="结束时间")
|
||||
watermark_from: Mapped[datetime | None] = mapped_column(DateTime, comment="增量起始水位")
|
||||
watermark_to: Mapped[datetime | None] = mapped_column(DateTime, comment="本批次结束水位")
|
||||
source_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="源记录数"
|
||||
)
|
||||
staged_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="写入 staging 的记录数"
|
||||
)
|
||||
failed_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="失败记录数"
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, comment="批次错误摘要")
|
||||
entity_types_json: Mapped[list[str] | None] = mapped_column(
|
||||
JSON, comment="请求读取的实体类型;历史批次可为空"
|
||||
)
|
||||
skipped_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="重复快照跳过条数"
|
||||
)
|
||||
parent_batch_no: Mapped[str | None] = mapped_column(
|
||||
String(32), comment="重试来源批次号,保留原批次记录"
|
||||
)
|
||||
request_key: Mapped[str | None] = mapped_column(
|
||||
String(36), comment="请求幂等键,空值用于历史和直接CLI任务"
|
||||
)
|
||||
requested_by: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED, comment="请求用户ID,CLI任务为空,无物理外键"
|
||||
)
|
||||
trigger_type: Mapped[str] = mapped_column(String(16), server_default="'MANUAL'", comment="触发来源:MANUAL/SCHEDULED/AUTO_RETRY")
|
||||
automatic_attempt: Mapped[int] = mapped_column(Integer, server_default="0", comment="自动重试序号(0为原始任务)")
|
||||
audit_result_json: Mapped[dict | None] = mapped_column(
|
||||
JSON, comment="关联核验结果,只含系统字典和汇总"
|
||||
)
|
||||
|
||||
|
||||
class TPlusSyncError(Base, TimestampMixin):
|
||||
"""单条 T+ 数据读取、清洗或 staging 失败记录。"""
|
||||
|
||||
__tablename__ = "tplus_sync_errors"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 同步错误表(记录失败行和重试信息)",
|
||||
Index("idx_tplus_sync_errors_batch_no", "batch_no"),
|
||||
Index("idx_tplus_sync_errors_entity_type", "entity_type"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
batch_no: Mapped[str] = mapped_column(String(32), nullable=False, comment="同步批次号")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务实体类型")
|
||||
source_table: Mapped[str] = mapped_column(String(128), nullable=False, comment="T+ 源表")
|
||||
source_id: Mapped[str | None] = mapped_column(String(64), comment="T+ 源主键")
|
||||
source_line_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 明细行号或明细主键")
|
||||
error_code: Mapped[str] = mapped_column(String(32), nullable=False, comment="错误编码")
|
||||
error_message: Mapped[str] = mapped_column(Text, nullable=False, comment="错误说明")
|
||||
retry_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="已重试次数"
|
||||
)
|
||||
|
||||
|
||||
class TPlusSyncRecord(Base, TimestampMixin):
|
||||
"""T+ 原始行 staging,保存映射未定前的可追溯数据。"""
|
||||
|
||||
__tablename__ = "tplus_sync_records"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)",
|
||||
UniqueConstraint(
|
||||
"entity_type", "source_id", "row_hash", name="uk_tplus_sync_records_entity_source_hash"
|
||||
),
|
||||
Index("idx_tplus_sync_records_batch_no", "batch_no"),
|
||||
Index("idx_tplus_sync_records_source_no", "source_no"),
|
||||
Index("idx_tplus_sync_records_state", "state"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
batch_no: Mapped[str] = mapped_column(String(32), nullable=False, comment="同步批次号")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务实体类型")
|
||||
source_table: Mapped[str] = mapped_column(String(128), nullable=False, comment="T+ 源表")
|
||||
source_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="T+ 源主键")
|
||||
source_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 原始编号")
|
||||
source_line_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 明细行号或明细主键")
|
||||
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime, comment="T+ 源更新时间")
|
||||
row_hash: Mapped[str] = mapped_column(String(64), nullable=False, comment="原始行 SHA-256")
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
server_default="PENDING",
|
||||
comment="状态:PENDING/APPLIED/SKIPPED/ERROR",
|
||||
)
|
||||
payload_json: Mapped[dict] = mapped_column(JSON, nullable=False, comment="原始行 JSON")
|
||||
|
||||
|
||||
class TPlusSyncWatermark(Base, TimestampMixin):
|
||||
"""按实体保存增量同步水位。"""
|
||||
|
||||
__tablename__ = "tplus_sync_watermarks"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 增量同步水位表",
|
||||
UniqueConstraint("entity_type", name="uk_tplus_sync_watermarks_entity_type"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
entity_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务实体类型")
|
||||
last_source_updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, comment="最近源更新时间"
|
||||
)
|
||||
last_source_id: Mapped[str | None] = mapped_column(String(64), comment="同一时间点的最后源主键")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default="READY", comment="状态:READY/BLOCKED"
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""T+ 基础导入与试算的隔离工作区,任何表均不关联正式工资台账。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class TPlusSourceHead(Base):
|
||||
"""一次完整读取实际观察到的源版本;解决 A→B→A 去重后的旧版本误选。"""
|
||||
|
||||
__tablename__ = "tplus_source_heads"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 源当前快照指针(按成功全量批次读取,删除不自动回写业务表)",
|
||||
UniqueConstraint(
|
||||
"source_database",
|
||||
"entity_type",
|
||||
"source_id",
|
||||
name="uk_tplus_source_heads_source_entity_id",
|
||||
),
|
||||
Index("idx_tplus_source_heads_batch_entity", "batch_no", "entity_type"),
|
||||
Index("idx_tplus_source_heads_parent", "source_database", "entity_type", "source_parent_id"),
|
||||
Index("idx_tplus_source_heads_customer_date", "source_database", "entity_type", "source_customer_id", "source_date"),
|
||||
Index("idx_tplus_source_heads_date", "source_database", "entity_type", "source_date", "id"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), comment="实体类型")
|
||||
source_id: Mapped[str] = mapped_column(String(64), comment="源内部ID")
|
||||
record_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="原始快照ID")
|
||||
source_parent_id: Mapped[str | None] = mapped_column(String(64), comment="源父单据ID查询投影")
|
||||
source_customer_id: Mapped[str | None] = mapped_column(String(64), comment="源客户ID查询投影")
|
||||
source_date: Mapped[str | None] = mapped_column(String(10), comment="源业务日期查询投影")
|
||||
batch_no: Mapped[str] = mapped_column(String(32), comment="最后观察批次")
|
||||
|
||||
|
||||
class TPlusFact(Base, TimestampMixin):
|
||||
"""补充资料追加版本,旧版本永久保留,乐观锁防止静默覆盖。"""
|
||||
|
||||
__tablename__ = "tplus_facts"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 人工补充事实版本表",
|
||||
UniqueConstraint(
|
||||
"source_database",
|
||||
"kind",
|
||||
"fact_key",
|
||||
"version",
|
||||
name="uk_tplus_facts_source_kind_key_version",
|
||||
),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
kind: Mapped[str] = mapped_column(String(32), comment="事实类别")
|
||||
fact_key: Mapped[str] = mapped_column(String(128), comment="事实业务键")
|
||||
version: Mapped[int] = mapped_column(Integer, comment="版本号")
|
||||
data_json: Mapped[dict] = mapped_column(JSON, comment="经校验的事实内容")
|
||||
evidence: Mapped[str] = mapped_column(String(500), comment="依据和修改原因")
|
||||
requested_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="操作者ID")
|
||||
|
||||
|
||||
class TPlusSourceLink(Base, TimestampMixin):
|
||||
"""源稳定身份与业务目标的唯一关联;不按名称猜测合并。"""
|
||||
|
||||
__tablename__ = "tplus_source_links"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 基础数据源目标关联",
|
||||
UniqueConstraint(
|
||||
"source_database",
|
||||
"entity_type",
|
||||
"source_id",
|
||||
name="uk_tplus_source_links_source_entity_id",
|
||||
),
|
||||
UniqueConstraint("entity_type", "target_id", name="uk_tplus_source_links_entity_target"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), comment="实体类型")
|
||||
source_id: Mapped[str] = mapped_column(String(64), comment="源内部ID")
|
||||
target_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="业务目标ID")
|
||||
|
||||
|
||||
class TPlusWorkRun(Base, TimestampMixin):
|
||||
"""后台导入、预览和试算运行记录,固定输入,保留终态。"""
|
||||
|
||||
__tablename__ = "tplus_work_runs"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 导入与预演运行表",
|
||||
UniqueConstraint("run_no", name="uk_tplus_work_runs_run_no"),
|
||||
UniqueConstraint("request_key", name="uk_tplus_work_runs_request_key"),
|
||||
Index("idx_tplus_work_runs_status_id", "status", "id"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
run_no: Mapped[str] = mapped_column(String(32), comment="运行编号")
|
||||
request_key: Mapped[str] = mapped_column(String(36), comment="请求幂等键")
|
||||
kind: Mapped[str] = mapped_column(
|
||||
String(32), comment="MASTER_PREVIEW/MASTER_APPLY/CALC_PREVIEW"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), default="QUEUED", comment="运行状态")
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="固定源账套")
|
||||
requested_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="操作者ID")
|
||||
input_json: Mapped[dict] = mapped_column(JSON, comment="请求输入")
|
||||
snapshot_json: Mapped[dict | None] = mapped_column(JSON, comment="规则和补充资料的固定快照")
|
||||
snapshot_hash: Mapped[str | None] = mapped_column(String(64), comment="输入指纹")
|
||||
summary_json: Mapped[dict | None] = mapped_column(JSON, comment="结果汇总")
|
||||
error_code: Mapped[str | None] = mapped_column(String(64), comment="固定错误编码")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, comment="开始时间")
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, comment="结束时间")
|
||||
|
||||
|
||||
class TPlusWorkLine(Base):
|
||||
"""固定输入及结果逐行留存,仅经授权的工作区接口可访问。"""
|
||||
|
||||
__tablename__ = "tplus_work_lines"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 导入预览与核算预演明细",
|
||||
UniqueConstraint("run_no", "line_no", name="uk_tplus_work_lines_run_line"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
run_no: Mapped[str] = mapped_column(String(32), comment="运行编号")
|
||||
line_no: Mapped[int] = mapped_column(Integer, comment="稳定行序号")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), comment="实体或分组类型")
|
||||
source_id: Mapped[str] = mapped_column(String(128), comment="源ID或平账组")
|
||||
action: Mapped[str] = mapped_column(String(32), comment="新增/更新/跳过/待补/冲突/分配")
|
||||
reason: Mapped[str | None] = mapped_column(String(128), comment="固定原因编码")
|
||||
data_json: Mapped[dict] = mapped_column(JSON, comment="最小必要的业务明细")
|
||||
|
||||
|
||||
WORKBENCH_MODELS = (TPlusSourceHead, TPlusFact, TPlusSourceLink, TPlusWorkRun, TPlusWorkLine)
|
||||
@@ -5,9 +5,7 @@
|
||||
models/customer.py 定义)。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Row, Select, delete, exists, func, or_, select
|
||||
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import (
|
||||
@@ -17,12 +15,20 @@ from app.models import (
|
||||
Customer,
|
||||
OwnershipChangeLog,
|
||||
Personnel,
|
||||
Product,
|
||||
Receipt,
|
||||
SecondaryAllocation,
|
||||
Shipment,
|
||||
TPlusBusinessDocument,
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
from app.repositories import ownership_log_repository
|
||||
from app.repositories.ownership_log_repository import OwnershipLogRef
|
||||
from app.schemas.business import BusinessQuery
|
||||
|
||||
READINESS_SQL_REASONS = frozenset({
|
||||
"CUSTOMER_CATEGORY_PENDING", "PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SOURCE_CONFLICT",
|
||||
})
|
||||
|
||||
|
||||
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
|
||||
@@ -77,50 +83,63 @@ def get_with_names(
|
||||
return session.execute(stmt).first()
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
customer_id: int | None,
|
||||
owner_personnel_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
|
||||
"""业务列表的筛选条件(调用方须已 join Customer)。"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
|
||||
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Business.business_no.contains(keyword, autoescape=True),
|
||||
Customer.company_name.contains(keyword, autoescape=True),
|
||||
Business.business_no.contains(query.keyword, autoescape=True),
|
||||
Customer.company_name.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Business.status == status)
|
||||
if customer_id is not None:
|
||||
stmt = stmt.where(Business.customer_id == customer_id)
|
||||
if owner_personnel_id is not None:
|
||||
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(Business.business_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(Business.business_date <= date_to)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Business.status == query.status)
|
||||
if query.customer_id is not None:
|
||||
stmt = stmt.where(Business.customer_id == query.customer_id)
|
||||
if query.owner_personnel_id is not None:
|
||||
stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id)
|
||||
if query.date_from is not None:
|
||||
stmt = stmt.where(Business.business_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
stmt = stmt.where(Business.business_date <= query.date_to)
|
||||
if query.source_type == "TPLUS":
|
||||
stmt = stmt.where(Business.t_plus_id.is_not(None))
|
||||
elif query.source_type == "MANUAL":
|
||||
stmt = stmt.where(Business.t_plus_id.is_(None))
|
||||
if query.source_personnel:
|
||||
stmt = stmt.where(Business.integration_json["personnel_names"].as_string().contains(query.source_personnel, autoescape=True))
|
||||
if query.readiness_reason in READINESS_SQL_REASONS:
|
||||
stmt = stmt.where(Business.integration_json["source_database"].as_string().is_not(None))
|
||||
if query.readiness_reason == "CUSTOMER_CATEGORY_PENDING":
|
||||
stmt = stmt.where(Customer.customer_type.is_(None))
|
||||
elif query.readiness_reason == "PRODUCT_CATEGORY_PENDING":
|
||||
stmt = stmt.where(exists(select(BusinessItem.id).join(Product, Product.id == BusinessItem.product_id).where(
|
||||
BusinessItem.business_id == Business.id, Product.product_category.is_(None))))
|
||||
elif query.readiness_reason == "NO_ACTUAL_SHIPMENT":
|
||||
stmt = stmt.where(~exists().where(Shipment.business_id == Business.id))
|
||||
elif query.readiness_reason == "SOURCE_CONFLICT":
|
||||
stmt = stmt.where(exists().where(TPlusBusinessDocument.business_id == Business.id,
|
||||
TPlusBusinessDocument.status.in_(("CONFLICT", "PENDING"))))
|
||||
return stmt
|
||||
|
||||
|
||||
def scan_businesses(session: Session, query: BusinessQuery, before_id: int | None, limit: int = 100):
|
||||
"""按主键倒序分批读取,供需要计算准备状态的筛选使用。"""
|
||||
owner = aliased(Personnel)
|
||||
assistant = aliased(Personnel)
|
||||
stmt = select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name).join(
|
||||
Customer, Customer.id == Business.customer_id)
|
||||
stmt = _apply_filters(stmt, query).outerjoin(owner, owner.id == Business.owner_personnel_id).outerjoin(
|
||||
assistant, assistant.id == Business.assistant_personnel_id)
|
||||
if before_id is not None:
|
||||
stmt = stmt.where(Business.id < before_id)
|
||||
return list(session.execute(stmt.order_by(Business.id.desc()).limit(limit)).all())
|
||||
|
||||
|
||||
def list_businesses(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
customer_id: int | None,
|
||||
owner_personnel_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
session: Session, query: BusinessQuery
|
||||
) -> list[tuple[Business, str, str | None, str | None]]:
|
||||
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
|
||||
owner = aliased(Personnel)
|
||||
@@ -128,49 +147,24 @@ def list_businesses(
|
||||
stmt = select(
|
||||
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
|
||||
).join(Customer, Customer.id == Business.customer_id)
|
||||
stmt = _apply_filters(
|
||||
stmt,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
customer_id=customer_id,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
stmt = _apply_filters(stmt, query)
|
||||
stmt = (
|
||||
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||||
.order_by(Business.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_businesses(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
customer_id: int | None,
|
||||
owner_personnel_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
) -> int:
|
||||
def count_businesses(session: Session, query: BusinessQuery) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Business)
|
||||
.join(Customer, Customer.id == Business.customer_id)
|
||||
)
|
||||
stmt = _apply_filters(
|
||||
stmt,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
customer_id=customer_id,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
stmt = _apply_filters(stmt, query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -199,43 +193,75 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
|
||||
session.flush()
|
||||
|
||||
|
||||
def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
|
||||
"""二次分配方案(详情回显;空列表=未设置方案)。"""
|
||||
def list_allocations(
|
||||
session: Session, business_id: int, *, active_only: bool = True
|
||||
) -> list[SecondaryAllocation]:
|
||||
"""二次分配方案(详情回显与核算取数;默认仅当前生效版本)。"""
|
||||
stmt = select(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
if active_only:
|
||||
stmt = stmt.where(SecondaryAllocation.is_active == 1)
|
||||
return list(
|
||||
session.execute(
|
||||
select(SecondaryAllocation)
|
||||
.where(SecondaryAllocation.business_id == business_id)
|
||||
.order_by(SecondaryAllocation.id)
|
||||
stmt.order_by(SecondaryAllocation.version_no, SecondaryAllocation.id)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_allocation_versions(session: Session, business_id: int) -> list[SecondaryAllocation]:
|
||||
"""方案全部版本行(含历史;业务详情「方案历史」用,按版本正序)。"""
|
||||
return list_allocations(session, business_id, active_only=False)
|
||||
|
||||
|
||||
def replace_allocations(
|
||||
session: Session, business_id: int, rows: list[SecondaryAllocation]
|
||||
) -> None:
|
||||
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
|
||||
"""全量替换二次分配方案:旧生效行置历史,新行按下一版本号插入。
|
||||
|
||||
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
|
||||
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
|
||||
不 commit(事务边界在服务层)。版本行 append-only——编辑与清空都不
|
||||
物理删除,驳回后仍可回溯被否决方案的参与人与比例。空 ``rows`` 表示
|
||||
只清空生效态(不产生新版本)。
|
||||
"""
|
||||
session.execute(
|
||||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
deactivate_allocations(session, business_id)
|
||||
if not rows:
|
||||
return
|
||||
next_version = (
|
||||
session.execute(
|
||||
select(func.coalesce(func.max(SecondaryAllocation.version_no), 0)).where(
|
||||
SecondaryAllocation.business_id == business_id
|
||||
)
|
||||
).scalar_one()
|
||||
+ 1
|
||||
)
|
||||
session.flush()
|
||||
for row in rows:
|
||||
row.business_id = business_id
|
||||
row.version_no = next_version
|
||||
row.is_active = 1
|
||||
session.add(row)
|
||||
session.flush()
|
||||
|
||||
|
||||
def delete_allocations(session: Session, business_id: int) -> None:
|
||||
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
|
||||
def deactivate_allocations(session: Session, business_id: int) -> None:
|
||||
"""把业务的当前生效方案置为历史(审批驳回/编辑替换时调用;行保留)。
|
||||
|
||||
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
|
||||
方案事实由审批单留痕与操作日志 request_data 承载。
|
||||
被否决的方案不得以「当前生效行」身份继续被核算取到;``is_active=0``
|
||||
后核算侧自然读不到,而历史版本仍可在业务详情回溯。不 commit。
|
||||
|
||||
显式声明 ``synchronize_session="fetch"``:Core UPDATE 默认走 auto 策略
|
||||
(优先 evaluate,本例的条件恰好能在 Python 侧求值,故实测也会刷新在途
|
||||
对象)。显式写 fetch 是为了不把「在途对象被刷新」这件事押在 evaluate
|
||||
能否解析条件上——条件一旦复杂化,auto 会退化为不刷新,同一会话里已
|
||||
加载的方案行就会停留在 ``is_active=1`` 的过期内存值(查询虽有
|
||||
is_active 过滤,但行对象本身仍是「生效态」,被序列化或参与判断就是
|
||||
错的)。
|
||||
"""
|
||||
session.execute(
|
||||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
update(SecondaryAllocation)
|
||||
.where(
|
||||
SecondaryAllocation.business_id == business_id,
|
||||
SecondaryAllocation.is_active == 1,
|
||||
)
|
||||
.values(is_active=0)
|
||||
.execution_options(synchronize_session="fetch")
|
||||
)
|
||||
session.flush()
|
||||
|
||||
@@ -281,18 +307,17 @@ def list_ownership_logs(
|
||||
) -> list[Row]:
|
||||
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
session, _business_log_ref(business_id), offset=offset, limit=limit
|
||||
)
|
||||
|
||||
|
||||
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id))
|
||||
|
||||
|
||||
def _business_log_ref(business_id: int) -> OwnershipLogRef:
|
||||
"""业务维度的归属留痕锚点。"""
|
||||
return OwnershipLogRef(
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BusinessRole
|
||||
from app.schemas.business_role import BusinessRoleQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -39,41 +40,48 @@ def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, Business
|
||||
return {role.role_code: role for role in rows}
|
||||
|
||||
|
||||
def list_roles(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[BusinessRole]:
|
||||
def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]:
|
||||
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
|
||||
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
|
||||
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(BusinessRole), query)
|
||||
stmt = (
|
||||
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
||||
def count_roles(session: Session, query: BusinessRoleQuery) -> int:
|
||||
"""统计符合条件的岗位总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
|
||||
stmt = _apply_filters(select(func.count(BusinessRole.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
||||
def list_all(session: Session) -> list[BusinessRole]:
|
||||
"""全部岗位(含停用,不分页)——岗位下拉选项的取数口径。
|
||||
|
||||
岗位规模有限(内置 + 自定义),一次取全量,不受分页上限约束;
|
||||
排序与 list_roles 一致(sort_order, id),下拉与列表相对次序相同。
|
||||
"""
|
||||
stmt = select(BusinessRole).order_by(BusinessRole.sort_order, BusinessRole.id)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, query: BusinessRoleQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
|
||||
不转义时 ``_`` 会当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
BusinessRole.role_code.contains(keyword, autoescape=True),
|
||||
BusinessRole.role_name.contains(keyword, autoescape=True),
|
||||
BusinessRole.role_code.contains(query.keyword, autoescape=True),
|
||||
BusinessRole.role_name.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(BusinessRole.status == status)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(BusinessRole.status == query.status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""计提公式模板数据访问。
|
||||
|
||||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BusinessRole, CalcTemplateDefinition
|
||||
|
||||
|
||||
def get_by_id(session: Session, template_id: int, *, for_update: bool = False) -> CalcTemplateDefinition | None:
|
||||
"""按主键查询;删除校验走行锁。"""
|
||||
if for_update:
|
||||
stmt = select(CalcTemplateDefinition).where(CalcTemplateDefinition.id == template_id).with_for_update()
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
return session.get(CalcTemplateDefinition, template_id)
|
||||
|
||||
|
||||
def get_by_code(session: Session, template_code: str) -> CalcTemplateDefinition | None:
|
||||
"""按编码查询模板。编码是岗位绑定与参数解析的锚点。"""
|
||||
stmt = select(CalcTemplateDefinition).where(CalcTemplateDefinition.template_code == template_code)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_all(session: Session) -> list[CalcTemplateDefinition]:
|
||||
"""全部模板(含停用,供管理列表展示引用状态)。"""
|
||||
stmt = select(CalcTemplateDefinition).order_by(
|
||||
CalcTemplateDefinition.sort_order, CalcTemplateDefinition.id
|
||||
)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def list_active(session: Session) -> list[CalcTemplateDefinition]:
|
||||
"""启用模板,供岗位表单下拉。"""
|
||||
stmt = (
|
||||
select(CalcTemplateDefinition)
|
||||
.where(CalcTemplateDefinition.status == 1)
|
||||
.order_by(CalcTemplateDefinition.sort_order, CalcTemplateDefinition.id)
|
||||
)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles_by_codes(session: Session, codes: list[str]) -> dict[str, int]:
|
||||
"""批量统计各模板被岗位引用的数量,用于删除保护与列表展示。"""
|
||||
if not codes:
|
||||
return {}
|
||||
rows = session.execute(
|
||||
select(BusinessRole.calc_template, func.count(BusinessRole.id))
|
||||
.where(BusinessRole.calc_template.in_(codes))
|
||||
.group_by(BusinessRole.calc_template)
|
||||
).all()
|
||||
return {code: int(count) for code, count in rows}
|
||||
|
||||
|
||||
def create(session: Session, template: CalcTemplateDefinition) -> CalcTemplateDefinition:
|
||||
"""新增模板。只 flush 以取得自增 id。"""
|
||||
session.add(template)
|
||||
session.flush()
|
||||
return template
|
||||
|
||||
|
||||
def delete(session: Session, template: CalcTemplateDefinition) -> None:
|
||||
"""删除模板。调用方须先完成保护规则校验。"""
|
||||
session.delete(template)
|
||||
session.flush()
|
||||
@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
|
||||
from app.models import Customer, OwnershipChangeLog, Personnel
|
||||
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||
from app.repositories import ownership_log_repository
|
||||
from app.repositories.ownership_log_repository import OwnershipLogRef
|
||||
from app.repositories.query_utils import fuzzy
|
||||
from app.schemas.customer import CustomerQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -45,49 +47,27 @@ def get_by_company_owner(session: Session, company_name: str, owner_name: str) -
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_customers(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
customer_type: str | None = None,
|
||||
owner_personnel_id: int | None = None,
|
||||
) -> list[Row]:
|
||||
def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
|
||||
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
|
||||
|
||||
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
||||
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
||||
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
|
||||
排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
|
||||
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
_base_select(),
|
||||
keyword=keyword,
|
||||
customer_type=customer_type,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
)
|
||||
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(_base_select(), query)
|
||||
stmt = _apply_order(stmt, query)
|
||||
stmt = stmt.offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_customers(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
customer_type: str | None = None,
|
||||
owner_personnel_id: int | None = None,
|
||||
) -> int:
|
||||
def count_customers(session: Session, query: CustomerQuery) -> int:
|
||||
"""统计符合条件的客户总数,供分页使用。
|
||||
|
||||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||
``_apply_filters`` 单点保证)。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(Customer.id)),
|
||||
keyword=keyword,
|
||||
customer_type=customer_type,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(Customer.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -101,9 +81,7 @@ def list_ownership_logs(
|
||||
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
_customer_log_ref(customer_id),
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -111,8 +89,12 @@ def list_ownership_logs(
|
||||
|
||||
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
||||
"""统计某客户的归属留痕总数,供分页使用。"""
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id))
|
||||
|
||||
|
||||
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
|
||||
"""客户维度的归属留痕锚点。"""
|
||||
return OwnershipLogRef(
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
@@ -126,20 +108,18 @@ def _base_select() -> Select:
|
||||
)
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
customer_type: str | None,
|
||||
owner_personnel_id: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||
if keyword:
|
||||
stmt = stmt.where(_keyword_clause(keyword))
|
||||
if customer_type:
|
||||
stmt = stmt.where(Customer.customer_type == customer_type)
|
||||
if owner_personnel_id is not None:
|
||||
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
|
||||
if query.keyword:
|
||||
stmt = stmt.where(_keyword_clause(query.keyword))
|
||||
if query.customer_type:
|
||||
stmt = stmt.where(Customer.customer_type == query.customer_type)
|
||||
if query.category_missing:
|
||||
stmt = stmt.where(Customer.customer_type.is_(None))
|
||||
if query.owner_personnel_id is not None:
|
||||
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
|
||||
if query.owner_missing:
|
||||
stmt = stmt.where(Customer.owner_personnel_id.is_(None))
|
||||
return stmt
|
||||
|
||||
|
||||
@@ -157,6 +137,23 @@ def _keyword_clause(keyword: str) -> ColumnElement[bool]:
|
||||
)
|
||||
|
||||
|
||||
def _apply_order(stmt: Select, query: CustomerQuery) -> Select:
|
||||
"""应用排序。排序键已由 schema 白名单收口,此处只做键 → 列的映射。
|
||||
|
||||
MySQL 把 NULL 视为最小值:ASC 时未评分(null)会排最前,但「没打分」
|
||||
不该压在「打了低分」之上 —— ASC 分支用 ``is_(None)`` 布尔键把 null
|
||||
挪到末尾;DESC 分支 null 天然在最后,无需处理。尾部再按 id 倒序兜底,
|
||||
保证同分行的次序稳定(分页不跳行)。
|
||||
"""
|
||||
if query.order_by == "rating_score":
|
||||
score = Customer.rating_score
|
||||
if query.order == "asc":
|
||||
stmt = stmt.order_by(score.is_(None), score.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(score.desc())
|
||||
return stmt.order_by(Customer.id.desc())
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""工作台仓储:四区块的只读聚合查询;不 commit、不抛业务异常。
|
||||
|
||||
总览只做 SQL 聚合(SUM/COUNT DISTINCT),不复用报表的
|
||||
``department_summary``——后者会加载整月台账并逐团队查人员,适合报表页,
|
||||
不适合每次打开工作台。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.models import (
|
||||
ApprovalOrder,
|
||||
Business,
|
||||
BusinessRole,
|
||||
MonthlySettlement,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
Receipt,
|
||||
Shipment,
|
||||
)
|
||||
from app.models.approval import (
|
||||
APPROVAL_STATUS_PENDING_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER,
|
||||
)
|
||||
|
||||
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
|
||||
_PENDING_STATUSES = (
|
||||
APPROVAL_STATUS_PENDING_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER,
|
||||
)
|
||||
|
||||
|
||||
# ==== 经营绩效总览 ====
|
||||
|
||||
|
||||
def sum_month_performance(session: Session, month: str) -> dict:
|
||||
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
|
||||
return (
|
||||
session.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
|
||||
).where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
*_ACTIVE_SINGLE,
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
|
||||
"""近 N 个月绩效合计(按台账归属月)。"""
|
||||
rows = session.execute(
|
||||
select(
|
||||
PerformanceLedger.settlement_month,
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0),
|
||||
)
|
||||
.where(
|
||||
PerformanceLedger.settlement_month.in_(months),
|
||||
*_ACTIVE_SINGLE,
|
||||
)
|
||||
.group_by(PerformanceLedger.settlement_month)
|
||||
).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
def receipt_trend(session: Session, *, start_date: date, end_date: date) -> dict[str, object]:
|
||||
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
|
||||
|
||||
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
|
||||
分组,不参与 WHERE,避免函数列导致全表扫描。
|
||||
"""
|
||||
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
|
||||
rows = session.execute(
|
||||
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
|
||||
.where(Receipt.receipt_date >= start_date, Receipt.receipt_date < end_date)
|
||||
.group_by(month_expr)
|
||||
).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
def count_active_managers(session: Session) -> int:
|
||||
"""在职营销经理数(团队数口径,与部门汇总的团队枚举一致)。"""
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Personnel)
|
||||
.join(BusinessRole, Personnel.business_role_id == BusinessRole.id)
|
||||
.where(
|
||||
BusinessRole.role_code == "sales_manager",
|
||||
Personnel.status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def get_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
|
||||
"""当月月结头(状态与锁定时间角标)。"""
|
||||
return session.execute(
|
||||
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
# ==== 待办事项 ====
|
||||
|
||||
|
||||
def list_pending_approvals(
|
||||
session: Session, *, statuses: tuple[int, ...], limit: int
|
||||
) -> list[ApprovalOrder]:
|
||||
"""待处理审批单(按提交时间升序——最久的排前面)。
|
||||
|
||||
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
|
||||
|
||||
排除 snapshot_json 列(快照可达上百 KB,待办只取单号/类型/提交人/时间)。
|
||||
"""
|
||||
if not statuses:
|
||||
return []
|
||||
return list(
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.options(defer(ApprovalOrder.snapshot_json))
|
||||
.where(ApprovalOrder.status.in_(statuses))
|
||||
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
|
||||
.limit(limit)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_pending_approvals(session: Session, *, statuses: tuple[int, ...]) -> int:
|
||||
if not statuses:
|
||||
return 0
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(ApprovalOrder)
|
||||
.where(ApprovalOrder.status.in_(statuses))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def list_unassigned_receipts(session: Session, *, limit: int) -> list:
|
||||
"""回款未指定发货批次(shipment_id IS NULL,核算前置缺失)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
Receipt,
|
||||
Business.business_no.label("business_no"),
|
||||
)
|
||||
.outerjoin(Business, Business.id == Receipt.business_id)
|
||||
.where(Receipt.shipment_id.is_(None))
|
||||
.order_by(Receipt.receipt_date.asc(), Receipt.id.asc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def count_unassigned_receipts(session: Session) -> int:
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count()).select_from(Receipt).where(Receipt.shipment_id.is_(None))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
# ==== 风险预警 ====
|
||||
|
||||
|
||||
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
|
||||
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
|
||||
|
||||
已终止业务(status=3)不再催收,排除在预警之外;业务行缺失时仍保留,
|
||||
避免历史脏数据让发货单从预警中静默消失。
|
||||
"""
|
||||
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
Shipment,
|
||||
Business.business_no.label("business_no"),
|
||||
)
|
||||
.outerjoin(Business, Business.id == Shipment.business_id)
|
||||
.where(
|
||||
Shipment.ship_date <= cutoff,
|
||||
~has_receipt,
|
||||
or_(Business.status.is_(None), Business.status != 3),
|
||||
)
|
||||
.order_by(Shipment.ship_date.asc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
|
||||
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Shipment)
|
||||
.outerjoin(Business, Business.id == Shipment.business_id)
|
||||
.where(
|
||||
Shipment.ship_date <= cutoff,
|
||||
~has_receipt,
|
||||
or_(Business.status.is_(None), Business.status != 3),
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def list_overdue_approvals(
|
||||
session: Session, *, cutoff: datetime, limit: int
|
||||
) -> list[ApprovalOrder]:
|
||||
"""提交后超过阈值仍未处理的审批单(审批超时规则)。
|
||||
|
||||
排除 snapshot_json 列(同待办:预警只用单号/类型/提交人/时间)。
|
||||
"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.options(defer(ApprovalOrder.snapshot_json))
|
||||
.where(
|
||||
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||
ApprovalOrder.create_time < cutoff,
|
||||
)
|
||||
.order_by(ApprovalOrder.create_time.asc())
|
||||
.limit(limit)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_overdue_approvals(session: Session, *, cutoff: datetime) -> int:
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(ApprovalOrder)
|
||||
.where(
|
||||
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||
ApprovalOrder.create_time < cutoff,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
"""登录日志数据访问。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -10,68 +12,73 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
|
||||
_USER_AGENT_MAX_LEN = 255
|
||||
|
||||
|
||||
def add(
|
||||
session: Session,
|
||||
*,
|
||||
user_id: int | None,
|
||||
username: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
success: bool,
|
||||
fail_reason: str | None = None,
|
||||
) -> None:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoginLogEntry:
|
||||
"""一条待写入的登录日志(全字段 DTO)。
|
||||
|
||||
与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传
|
||||
都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。
|
||||
"""
|
||||
|
||||
user_id: int | None
|
||||
username: str
|
||||
success: bool
|
||||
ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
fail_reason: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoginLogListQuery:
|
||||
"""登录日志列表的查询条件(筛选 + 分页)。"""
|
||||
|
||||
filters: LogFilters
|
||||
offset: int = 0
|
||||
limit: int = 20
|
||||
ip: str | None = None
|
||||
|
||||
|
||||
def add(session: Session, entry: LoginLogEntry) -> None:
|
||||
"""写入一条登录日志。只 flush 不 commit。
|
||||
|
||||
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
||||
|
||||
:param user_id: 账号不存在时传 None(列本身可空)
|
||||
:param username: 用户名原样记录,便于账号不存在时也能追溯
|
||||
:param entry.user_id: 账号不存在时为 None(列本身可空)
|
||||
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯
|
||||
"""
|
||||
session.add(
|
||||
LoginLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
|
||||
success=1 if success else 0,
|
||||
fail_reason=fail_reason,
|
||||
user_id=entry.user_id,
|
||||
username=entry.username,
|
||||
ip=entry.ip,
|
||||
user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None,
|
||||
success=1 if entry.success else 0,
|
||||
fail_reason=entry.fail_reason,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def list_logs(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> list[LoginLog]:
|
||||
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
|
||||
"""分页查询登录日志。
|
||||
|
||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||||
"""
|
||||
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
|
||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(LoginLog), query)
|
||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_logs(
|
||||
session: Session,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
def count_logs(session: Session, query: LoginLogListQuery) -> int:
|
||||
"""统计符合条件的登录日志总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
|
||||
stmt = _apply_filters(select(func.count(LoginLog.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
|
||||
def _apply_filters(stmt: Select, query: LoginLogListQuery) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, LoginLog, filters)
|
||||
if ip:
|
||||
stmt = stmt.where(LoginLog.ip == ip)
|
||||
stmt = apply_log_filters(stmt, LoginLog, query.filters)
|
||||
if query.ip:
|
||||
stmt = stmt.where(LoginLog.ip == query.ip)
|
||||
return stmt
|
||||
@@ -0,0 +1,59 @@
|
||||
"""监控查询只使用固定字段与有界窗口;事务由服务层负责。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
|
||||
|
||||
|
||||
def snapshots(session, start, end):
|
||||
return session.scalars(
|
||||
select(MonitorSnapshot)
|
||||
.where(MonitorSnapshot.minute >= start, MonitorSnapshot.minute < end)
|
||||
.order_by(MonitorSnapshot.minute)
|
||||
.limit(10082)
|
||||
).all()
|
||||
|
||||
|
||||
def request_minutes(session, start, end, route=None):
|
||||
statement = select(MonitorRequestMinute).where(
|
||||
MonitorRequestMinute.minute >= start, MonitorRequestMinute.minute < end
|
||||
)
|
||||
if route:
|
||||
statement = statement.where(MonitorRequestMinute.route == route)
|
||||
return session.scalars(
|
||||
statement.order_by(MonitorRequestMinute.minute.desc()).limit(20001)
|
||||
).all()
|
||||
|
||||
|
||||
def events(session, query, start, end):
|
||||
filters = [MonitorEvent.last_seen >= start, MonitorEvent.first_seen < end]
|
||||
if query.kind:
|
||||
filters.append(MonitorEvent.kind == query.kind)
|
||||
if query.request_id:
|
||||
filters.append(MonitorEvent.request_id == query.request_id)
|
||||
if query.route:
|
||||
filters.append(MonitorEvent.route == query.route)
|
||||
if query.active_only:
|
||||
filters.extend([MonitorEvent.kind == "HEALTH", MonitorEvent.resolved_at.is_(None)])
|
||||
total = session.scalar(select(func.count()).select_from(MonitorEvent).where(*filters)) or 0
|
||||
rows = session.scalars(
|
||||
select(MonitorEvent)
|
||||
.where(*filters)
|
||||
.order_by(MonitorEvent.last_seen.desc(), MonitorEvent.id.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).all()
|
||||
return rows, total
|
||||
|
||||
|
||||
def active_events(session):
|
||||
return session.scalars(
|
||||
select(MonitorEvent)
|
||||
.where(MonitorEvent.kind == "HEALTH", MonitorEvent.resolved_at.is_(None))
|
||||
.order_by(MonitorEvent.last_seen.desc())
|
||||
.limit(100)
|
||||
).all()
|
||||
|
||||
|
||||
def event(session, event_id):
|
||||
return session.get(MonitorEvent, event_id)
|
||||
@@ -107,15 +107,18 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
|
||||
return session.get(OperationLog, log_id)
|
||||
|
||||
|
||||
def list_logs(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
filters: LogFilters,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
) -> list[OperationLog]:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationLogListQuery:
|
||||
"""操作日志列表的查询条件(筛选 + 分页)。"""
|
||||
|
||||
filters: LogFilters
|
||||
offset: int = 0
|
||||
limit: int = 20
|
||||
module: str | None = None
|
||||
action: str | None = None
|
||||
|
||||
|
||||
def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]:
|
||||
"""分页查询操作日志。
|
||||
|
||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||
@@ -127,41 +130,34 @@ def list_logs(
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(OperationLog).options(defer(OperationLog.request_data)),
|
||||
filters=filters,
|
||||
module=module,
|
||||
action=action,
|
||||
query,
|
||||
)
|
||||
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
|
||||
stmt = stmt.order_by(OperationLog.id.desc()).offset(query.offset).limit(query.limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_logs(
|
||||
session: Session,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
) -> int:
|
||||
def count_logs(session: Session, query: OperationLogListQuery) -> int:
|
||||
"""统计符合条件的日志总数,供分页使用。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(OperationLog.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
module: str | None,
|
||||
action: str | None,
|
||||
) -> Select:
|
||||
def distinct_modules(session: Session) -> list[str]:
|
||||
"""全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。
|
||||
|
||||
交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。
|
||||
"""
|
||||
stmt = select(OperationLog.module).distinct().order_by(OperationLog.module)
|
||||
return [module for module in session.execute(stmt).scalars() if module]
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, OperationLog, filters)
|
||||
if module:
|
||||
stmt = stmt.where(OperationLog.module == module)
|
||||
if action:
|
||||
stmt = stmt.where(OperationLog.action == action)
|
||||
stmt = apply_log_filters(stmt, OperationLog, query.filters)
|
||||
if query.module:
|
||||
stmt = stmt.where(OperationLog.module == query.module)
|
||||
if query.action:
|
||||
stmt = stmt.where(OperationLog.action == query.action)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -5,18 +5,32 @@ owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧
|
||||
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Row, func, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import OwnershipChangeLog, Personnel
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnershipLogRef:
|
||||
"""归属留痕的引用锚点(维度类型 + 引用列 + 引用 ID)。
|
||||
|
||||
引用列是 SQLAlchemy 列表达式(如 ``OwnershipChangeLog.customer_id``),
|
||||
不是可序列化值 —— 故用冻结 dataclass 而非 Pydantic 模型。
|
||||
"""
|
||||
|
||||
owner_type: int
|
||||
ref_column: Any
|
||||
ref_id: int
|
||||
|
||||
|
||||
def list_by_ref(
|
||||
session: Session,
|
||||
ref: OwnershipLogRef,
|
||||
*,
|
||||
owner_type: int,
|
||||
ref_column,
|
||||
ref_id: int,
|
||||
offset: int,
|
||||
limit: int,
|
||||
) -> list[Row]:
|
||||
@@ -32,7 +46,7 @@ def list_by_ref(
|
||||
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
|
||||
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
|
||||
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
|
||||
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
|
||||
.where(OwnershipChangeLog.owner_type == ref.owner_type, ref.ref_column == ref.ref_id)
|
||||
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -40,11 +54,11 @@ def list_by_ref(
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
|
||||
def count_by_ref(session: Session, ref: OwnershipLogRef) -> int:
|
||||
"""统计某引用对象的归属留痕总数,供分页使用。"""
|
||||
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||
OwnershipChangeLog.owner_type == owner_type,
|
||||
ref_column == ref_id,
|
||||
OwnershipChangeLog.owner_type == ref.owner_type,
|
||||
ref.ref_column == ref.ref_id,
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import Select, delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
|
||||
from app.schemas.param import ParamBatchQuery, ParamHistoryQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -97,27 +98,27 @@ def list_definitions_by_codes(
|
||||
|
||||
|
||||
def list_history(
|
||||
session: Session,
|
||||
param_id: int,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
dimension_value: str | None = None,
|
||||
session: Session, param_id: int, query: ParamHistoryQuery
|
||||
) -> list[tuple[ParamValue, ParamChangeBatch]]:
|
||||
"""单参数的版本历史(含批次信息),最新在前。"""
|
||||
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
|
||||
return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
|
||||
"""单参数的版本历史(含批次信息),最新在前。
|
||||
|
||||
``param_id`` 是「查哪个参数」的主锚点(由路径参数 param_code 解析而来),
|
||||
不属于列表筛选项,故仍以位置参数传入;筛选与分页走 ``query``。
|
||||
"""
|
||||
stmt = _history_stmt(param_id, query)
|
||||
return [
|
||||
(value, batch)
|
||||
for value, batch in session.execute(stmt.offset(query.offset).limit(query.page_size))
|
||||
]
|
||||
|
||||
|
||||
def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
|
||||
def count_history(session: Session, param_id: int, query: ParamHistoryQuery) -> int:
|
||||
"""统计单参数的历史条目数。"""
|
||||
stmt = select(func.count()).select_from(
|
||||
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
|
||||
)
|
||||
stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery())
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
|
||||
def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
|
||||
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
|
||||
stmt = (
|
||||
select(ParamValue, ParamChangeBatch)
|
||||
@@ -125,8 +126,8 @@ def _history_stmt(session: Session, param_id: int, *, dimension_value: str | Non
|
||||
.where(ParamValue.param_id == param_id)
|
||||
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
|
||||
)
|
||||
if dimension_value is not None:
|
||||
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
|
||||
if query.dimension_value is not None:
|
||||
stmt = stmt.where(ParamValue.dimension_value == query.dimension_value)
|
||||
return stmt
|
||||
|
||||
|
||||
@@ -150,41 +151,30 @@ def get_batch_by_id(
|
||||
return session.get(ParamChangeBatch, batch_id)
|
||||
|
||||
|
||||
def list_batches(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
) -> list[ParamChangeBatch]:
|
||||
def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]:
|
||||
"""分页查询变更批次(按创建时间倒序)。"""
|
||||
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
|
||||
stmt = _batch_filters(select(ParamChangeBatch), query)
|
||||
return list(
|
||||
session.execute(
|
||||
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_batches(
|
||||
session: Session, *, start_date: date | None = None, end_date: date | None = None
|
||||
) -> int:
|
||||
def count_batches(session: Session, query: ParamBatchQuery) -> int:
|
||||
"""统计符合条件的批次总数。"""
|
||||
stmt = _batch_filters(
|
||||
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
|
||||
)
|
||||
stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
|
||||
def _batch_filters(stmt: Select, query: ParamBatchQuery) -> Select:
|
||||
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
|
||||
if start_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
|
||||
if end_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
|
||||
if query.start_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date >= query.start_date)
|
||||
if query.end_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date <= query.end_date)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import PerformanceLedger
|
||||
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
|
||||
from app.schemas.calc import LedgerQuery
|
||||
|
||||
|
||||
@@ -15,7 +16,12 @@ def get_by_id(session: Session, ledger_id: int) -> PerformanceLedger | None:
|
||||
def get_active_by_receipt(
|
||||
session: Session, receipt_id: int, *, calc_type: int = 1
|
||||
) -> PerformanceLedger | None:
|
||||
"""取某回款的当前生效核算行(is_active=1;无则 None)。"""
|
||||
"""取某回款的当前生效核算行(is_active=1;无则 None)。
|
||||
|
||||
二次分摊拆行后同回款可有多行(负责人+参与人)——重算作废用
|
||||
``list_active_by_receipt``;本函数仅限确知单行的调用方(重算前的
|
||||
存在性判断按业务维度走 has_active_for_business)。
|
||||
"""
|
||||
return session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.receipt_id == receipt_id,
|
||||
@@ -25,6 +31,21 @@ def get_active_by_receipt(
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_active_by_receipt(
|
||||
session: Session, receipt_id: int, *, calc_type: int = 1
|
||||
) -> list[PerformanceLedger]:
|
||||
"""取某回款当前生效的**全部**核算行(分摊拆行后负责人+参与人各一行)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.receipt_id == receipt_id,
|
||||
PerformanceLedger.calc_type == calc_type,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def has_active_for_business(session: Session, business_id: int, *, calc_type: int = 1) -> bool:
|
||||
"""该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。"""
|
||||
return (
|
||||
@@ -73,6 +94,27 @@ def mark_inactive(session: Session, row: PerformanceLedger) -> None:
|
||||
session.flush()
|
||||
|
||||
|
||||
def deactivate_month_aggregates(session: Session, month: str) -> None:
|
||||
"""月结重生成时作废该月全部经理聚合行(calc_type=2)。
|
||||
|
||||
聚合行每次生成重建,旧行不翻 0 会与新行重复计入(按人聚合按 is_active
|
||||
过滤)。与 mark_inactive 同属 is_active 翻 0 的受控 UPDATE 例外;set 批量
|
||||
版本而非逐行——聚合行一个月至多几行,且无逐行回读必要。
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
session.execute(
|
||||
update(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
PerformanceLedger.calc_type == CALC_TYPE_MANAGER_AGGREGATE,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
.values(is_active=0)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def next_version(session: Session, receipt_id: int) -> int:
|
||||
"""某回款的下一个核算版本号(max+1;无行时 1)。"""
|
||||
current = session.execute(
|
||||
|
||||
Loaded 100 of 548 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user