Compare commits
52 Commits
main
...
chore/clea
| 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 |
35
.dockerignore
Normal file
35
.dockerignore
Normal file
@ -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
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@ -1,5 +1,7 @@
|
||||
# AI辅助目录(整个目录忽略)
|
||||
ai-aux/
|
||||
.ai/
|
||||
.agents/
|
||||
/AGENTS.md
|
||||
|
||||
# 依赖
|
||||
node_modules/
|
||||
@ -53,6 +55,7 @@ frontend/e2e/**/*.png
|
||||
*.tmp
|
||||
*.bak
|
||||
*.cache
|
||||
.tmp-processes*.txt
|
||||
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
|
||||
~$*
|
||||
|
||||
@ -65,6 +68,14 @@ 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
|
||||
@ -74,3 +85,10 @@ __MACOSX/
|
||||
|
||||
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
|
||||
.tmpdocx/
|
||||
# 本轮原开发环境损坏后的独立恢复运行环境。
|
||||
backend/.venv-tplus/
|
||||
backend/.venv-workbench/
|
||||
|
||||
# 本地浏览器验收自动生成的快照
|
||||
/.playwright-mcp/
|
||||
/phase2-evidence-*/
|
||||
|
||||
23
README.md
23
README.md
@ -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)。
|
||||
|
||||
@ -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=
|
||||
|
||||
43
backend/Dockerfile
Normal file
43
backend/Dockerfile
Normal file
@ -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"]
|
||||
@ -140,6 +140,19 @@ app/
|
||||
(`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
|
||||
@ -284,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)。
|
||||
|
||||
27
backend/alembic/versions/07b8c9d0e1f2_tplus_index_names.py
Normal file
27
backend/alembic/versions/07b8c9d0e1f2_tplus_index_names.py
Normal file
@ -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}")
|
||||
31
backend/alembic/versions/18c9d0e1f2a3_formal_payroll.py
Normal file
31
backend/alembic/versions/18c9d0e1f2a3_formal_payroll.py
Normal file
@ -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")
|
||||
18
backend/alembic/versions/20e1f2a3b4c5_tplus_automation.py
Normal file
18
backend/alembic/versions/20e1f2a3b4c5_tplus_automation.py
Normal file
@ -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'''")
|
||||
47
backend/alembic/versions/22a3b4c5d6e7_authorization.py
Normal file
47
backend/alembic/versions/22a3b4c5d6e7_authorization.py
Normal file
File diff suppressed because one or more lines are too long
39
backend/alembic/versions/23b4c5d6e7f8_system_monitor.py
Normal file
39
backend/alembic/versions/23b4c5d6e7f8_system_monitor.py
Normal file
@ -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'"))
|
||||
152
backend/alembic/versions/24c5d6e7f8a9_requirement_completion.py
Normal file
152
backend/alembic/versions/24c5d6e7f8a9_requirement_completion.py
Normal file
@ -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")
|
||||
24
backend/alembic/versions/25d6e7f8a9b0_monitor_permissions.py
Normal file
24
backend/alembic/versions/25d6e7f8a9b0_monitor_permissions.py
Normal file
@ -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
|
||||
31
backend/alembic/versions/26e7f8a9b0c1_role_authorization.py
Normal file
31
backend/alembic/versions/26e7f8a9b0c1_role_authorization.py
Normal file
@ -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)
|
||||
40
backend/alembic/versions/30c1d2e3f4a5_tplus_business_sync.py
Normal file
40
backend/alembic/versions/30c1d2e3f4a5_tplus_business_sync.py
Normal file
@ -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("运行控制表名迁移不可直接回退,请停止服务并按兼容版本处理")
|
||||
122
backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py
Normal file
122
backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py
Normal file
@ -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")
|
||||
118
backend/alembic/versions/e5f607a8b9c0_tplus_task_management.py
Normal file
118
backend/alembic/versions/e5f607a8b9c0_tplus_task_management.py
Normal file
@ -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",
|
||||
)
|
||||
31
backend/alembic/versions/f6a708b9c0d1_tplus_workbench.py
Normal file
31
backend/alembic/versions/f6a708b9c0d1_tplus_workbench.py
Normal file
@ -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)
|
||||
@ -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,6 +122,21 @@ AdminUser = Annotated[User, Depends(require_admin)]
|
||||
LoginUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
def require_payroll_writer(current_user: LoginUser) -> User:
|
||||
"""兼容原写接口注解;操作权限由统一入口校验,制作人限制仅用于工资操作。"""
|
||||
return current_user
|
||||
|
||||
|
||||
PayrollWriter = Annotated[User, Depends(require_payroll_writer)]
|
||||
|
||||
|
||||
def require_workbench_reader(current_user: LoginUser) -> User:
|
||||
return current_user
|
||||
|
||||
|
||||
WorkbenchReader = Annotated[User, Depends(require_workbench_reader)]
|
||||
|
||||
|
||||
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
|
||||
"""要求当前用户具备任一指定角色(依赖工厂)。
|
||||
|
||||
@ -132,10 +150,45 @@ def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
|
||||
: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):
|
||||
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,9 +4,10 @@
|
||||
健康检查作为运维探针不带版本号,直接位于 `/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
|
||||
@ -18,15 +19,22 @@ 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()
|
||||
|
||||
@ -34,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/*,业务操作登录即可访问
|
||||
@ -67,6 +82,7 @@ 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/*,只读,限管理员/财务/领导
|
||||
|
||||
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@ -13,7 +13,7 @@ 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 import business_role_service
|
||||
from app.services.authorization_metadata import options
|
||||
|
||||
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
|
||||
|
||||
@ -31,4 +31,4 @@ def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[Busines
|
||||
|
||||
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
|
||||
"""
|
||||
return ok(business_role_service.list_options(session))
|
||||
return ok([BusinessRoleOptionOut.model_validate(row) for row in options(session, _, "business_roles")])
|
||||
|
||||
@ -13,9 +13,10 @@ 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,
|
||||
@ -33,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,
|
||||
@ -46,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]:
|
||||
@ -70,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,
|
||||
@ -91,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]:
|
||||
@ -110,7 +123,7 @@ def delete_customer(
|
||||
)
|
||||
def rate_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerRatingIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -135,7 +148,7 @@ def rate_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]]:
|
||||
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
|
||||
|
||||
24
backend/app/api/v1/metadata.py
Normal file
24
backend/app/api/v1/metadata.py
Normal file
@ -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))
|
||||
111
backend/app/api/v1/payroll.py
Normal file
111
backend/app/api/v1/payroll.py
Normal file
@ -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)
|
||||
)
|
||||
@ -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]:
|
||||
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
|
||||
|
||||
56
backend/app/api/v1/system/authorization.py
Normal file
56
backend/app/api/v1/system/authorization.py
Normal file
@ -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="岗位详情")
|
||||
|
||||
70
backend/app/api/v1/system/calc_templates.py
Normal file
70
backend/app/api/v1/system/calc_templates.py
Normal file
@ -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="模板已删除")
|
||||
37
backend/app/api/v1/system/coefficients.py
Normal file
37
backend/app/api/v1/system/coefficients.py
Normal file
@ -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)))
|
||||
56
backend/app/api/v1/system/monitor.py
Normal file
56
backend/app/api/v1/system/monitor.py
Normal file
@ -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",
|
||||
},
|
||||
)
|
||||
95
backend/app/api/v1/system/tplus.py
Normal file
95
backend/app/api/v1/system/tplus.py
Normal file
@ -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))
|
||||
114
backend/app/api/v1/system/tplus_workbench.py
Normal file
114
backend/app/api/v1/system/tplus_workbench.py
Normal file
@ -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"'},
|
||||
)
|
||||
@ -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__":
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
240
backend/app/core/monitoring.py
Normal file
240
backend/app/core/monitoring.py
Normal file
@ -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)
|
||||
118
backend/app/core/tplus_db.py
Normal file
118
backend/app/core/tplus_db.py
Normal file
@ -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
|
||||
48
backend/app/core/tplus_lock.py
Normal file
48
backend/app/core/tplus_lock.py
Normal file
@ -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()
|
||||
@ -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",
|
||||
]
|
||||
|
||||
28
backend/app/models/authorization.py
Normal file
28
backend/app/models/authorization.py
Normal file
@ -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, ...]:
|
||||
|
||||
@ -16,6 +16,7 @@ from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
@ -147,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,
|
||||
@ -239,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)")
|
||||
|
||||
52
backend/app/models/calc_template.py
Normal file
52
backend/app/models/calc_template.py
Normal file
@ -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+同步不覆盖)",
|
||||
)
|
||||
|
||||
79
backend/app/models/monitor.py
Normal file
79
backend/app/models/monitor.py
Normal file
@ -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="恢复时间,仅用于健康异常"
|
||||
)
|
||||
66
backend/app/models/payroll.py
Normal file
66
backend/app/models/payroll.py
Normal file
@ -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,
|
||||
|
||||
81
backend/app/models/probation.py
Normal file
81
backend/app/models/probation.py
Normal file
@ -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(
|
||||
|
||||
60
backend/app/models/tplus_automation.py
Normal file
60
backend/app/models/tplus_automation.py
Normal file
@ -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")
|
||||
51
backend/app/models/tplus_business_sync.py
Normal file
51
backend/app/models/tplus_business_sync.py
Normal file
@ -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="最小必要来源与差异说明")
|
||||
151
backend/app/models/tplus_sync.py
Normal file
151
backend/app/models/tplus_sync.py
Normal file
@ -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"
|
||||
)
|
||||
140
backend/app/models/tplus_workbench.py
Normal file
140
backend/app/models/tplus_workbench.py
Normal file
@ -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)
|
||||
@ -15,15 +15,21 @@ 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:
|
||||
"""按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。"""
|
||||
@ -98,9 +104,40 @@ def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
|
||||
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, query: BusinessQuery
|
||||
) -> list[tuple[Business, str, str | None, str | None]]:
|
||||
|
||||
66
backend/app/repositories/calc_template_repository.py
Normal file
66
backend/app/repositories/calc_template_repository.py
Normal file
@ -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()
|
||||
@ -114,8 +114,12 @@ def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
|
||||
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
|
||||
|
||||
|
||||
|
||||
59
backend/app/repositories/monitor_repository.py
Normal file
59
backend/app/repositories/monitor_repository.py
Normal file
@ -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)
|
||||
@ -66,6 +66,8 @@ def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
|
||||
)
|
||||
if query.product_category:
|
||||
stmt = stmt.where(Product.product_category == query.product_category)
|
||||
if query.category_missing:
|
||||
stmt = stmt.where(Product.product_category.is_(None))
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Product.status == query.status)
|
||||
return stmt
|
||||
|
||||
@ -57,6 +57,8 @@ def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
|
||||
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
|
||||
if query.settlement_status is not None:
|
||||
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
|
||||
if query.confirmation_status is not None:
|
||||
stmt = stmt.where(Receipt.confirmation_status == query.confirmation_status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@ -217,6 +217,16 @@ def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def list_source_business_sales(session: Session, query: BusinessStatsQuery, *, after_id: int, limit: int):
|
||||
"""键集分页读取筛选范围内的来源业务,不加载完整业务或用户数据。"""
|
||||
stmt = (select(Business.id, Business.total_sale_amount).select_from(Business)
|
||||
.outerjoin(Customer, Customer.id == Business.customer_id)
|
||||
.where(*_business_filters(query),
|
||||
Business.integration_json["source_database"].as_string().is_not(None),
|
||||
Business.id > after_id).order_by(Business.id).limit(limit))
|
||||
return list(session.execute(stmt))
|
||||
|
||||
|
||||
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
|
||||
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
|
||||
perf_sq = _business_perf_subquery()
|
||||
|
||||
@ -9,8 +9,9 @@
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Role, UserRole
|
||||
from app.models import Permission, Role, RolePermission, UserRole
|
||||
from app.schemas.role import RoleQuery
|
||||
from app.services.permission_catalog import CATALOG
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@ -97,6 +98,19 @@ def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]
|
||||
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
|
||||
|
||||
|
||||
def count_permissions_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
|
||||
"""批量统计角色已配置的权限,避免列表逐行查询。"""
|
||||
if not role_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(RolePermission.role_id, func.count())
|
||||
.join(Permission, Permission.id == RolePermission.permission_id)
|
||||
.where(RolePermission.role_id.in_(role_ids), Permission.perm_code.in_(CATALOG))
|
||||
.group_by(RolePermission.role_id)
|
||||
)
|
||||
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
|
||||
167
backend/app/repositories/tplus_repository.py
Normal file
167
backend/app/repositories/tplus_repository.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""T+ 管理库访问;只 flush,事务由 service 管理。"""
|
||||
|
||||
from sqlalchemy import case, func, select, update
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||
|
||||
|
||||
def get_batch(
|
||||
session: Session, batch_no: str, *, for_update: bool = False, with_result: bool = False
|
||||
):
|
||||
"""按批次号查询;普通查询不加载核验大字段。"""
|
||||
statement = select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == batch_no)
|
||||
if not with_result:
|
||||
statement = statement.options(defer(TPlusSyncBatch.audit_result_json))
|
||||
if for_update:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
return session.scalar(statement)
|
||||
|
||||
|
||||
def get_request(session: Session, key: str, *, for_update: bool = False):
|
||||
"""锁读用于唯一约束竞争后的当前读,避免旧事务快照看不到新任务。"""
|
||||
statement = select(TPlusSyncBatch).where(TPlusSyncBatch.request_key == key)
|
||||
if for_update:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
return session.scalar(statement)
|
||||
|
||||
|
||||
def active_retry(session: Session, parent_no: str):
|
||||
"""调用方先锁父批次,再用当前读判断是否已有未完成重试。"""
|
||||
return session.scalar(
|
||||
select(TPlusSyncBatch)
|
||||
.where(
|
||||
TPlusSyncBatch.parent_batch_no == parent_no,
|
||||
TPlusSyncBatch.status.in_(("QUEUED", "RUNNING")),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def increment_retry_errors(session: Session, parent_no: str) -> None:
|
||||
"""创建新重试时对原异常累计重试次数。"""
|
||||
session.execute(
|
||||
update(TPlusSyncError)
|
||||
.where(TPlusSyncError.batch_no == parent_no)
|
||||
.values(retry_count=TPlusSyncError.retry_count + 1)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def next_queued(session: Session):
|
||||
"""调用方持有账套执行锁后领取最早排队任务。"""
|
||||
return session.scalar(
|
||||
select(TPlusSyncBatch)
|
||||
.where(TPlusSyncBatch.status == "QUEUED")
|
||||
.order_by(TPlusSyncBatch.id)
|
||||
.limit(1)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def running_batches(session: Session) -> list[TPlusSyncBatch]:
|
||||
"""只在持有执行锁时使用,不能依据时间戳判断任务已经停止。"""
|
||||
return list(
|
||||
session.scalars(
|
||||
select(TPlusSyncBatch).where(TPlusSyncBatch.status == "RUNNING").with_for_update()
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def list_batches(session: Session, query):
|
||||
"""分页只读元数据,核验报告和原异常不随列表加载。"""
|
||||
filters = []
|
||||
if query.status:
|
||||
filters.append(TPlusSyncBatch.status == query.status)
|
||||
if query.task_type:
|
||||
filters.append(
|
||||
TPlusSyncBatch.sync_mode == ("AUDIT" if query.task_type == "AUDIT" else "FULL")
|
||||
)
|
||||
total = session.scalar(select(func.count()).select_from(TPlusSyncBatch).where(*filters))
|
||||
rows = session.scalars(
|
||||
select(TPlusSyncBatch)
|
||||
.where(*filters)
|
||||
.options(defer(TPlusSyncBatch.audit_result_json), defer(TPlusSyncBatch.error_message))
|
||||
.order_by(TPlusSyncBatch.id.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).all()
|
||||
return rows, total or 0
|
||||
|
||||
|
||||
def active_retry_parents(session: Session, numbers: list[str]) -> set[str]:
|
||||
"""整页一次获取未完成的重试,避免逐行查询。"""
|
||||
if not numbers:
|
||||
return set()
|
||||
return set(
|
||||
session.scalars(
|
||||
select(TPlusSyncBatch.parent_batch_no).where(
|
||||
TPlusSyncBatch.parent_batch_no.in_(numbers),
|
||||
TPlusSyncBatch.status.in_(("QUEUED", "RUNNING")),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def list_errors(session: Session, number: str, query):
|
||||
"""只读取当前批次的异常定位元数据。"""
|
||||
filters = [TPlusSyncError.batch_no == number]
|
||||
if query.entity_type:
|
||||
filters.append(TPlusSyncError.entity_type == query.entity_type)
|
||||
total = session.scalar(select(func.count()).select_from(TPlusSyncError).where(*filters))
|
||||
rows = session.scalars(
|
||||
select(TPlusSyncError)
|
||||
.where(*filters)
|
||||
.options(defer(TPlusSyncError.error_message))
|
||||
.order_by(TPlusSyncError.id.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).all()
|
||||
return rows, total or 0
|
||||
|
||||
|
||||
def status_counts(session: Session) -> dict[str, int]:
|
||||
"""汇总任务表,不扫描原始快照。"""
|
||||
return dict(
|
||||
session.execute(
|
||||
select(TPlusSyncBatch.status, func.count()).group_by(TPlusSyncBatch.status)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def progress_reports(session: Session, numbers):
|
||||
if not numbers:
|
||||
return {}
|
||||
rows = session.execute(select(TPlusSyncBatch.batch_no,
|
||||
TPlusSyncBatch.audit_result_json["progress"],
|
||||
TPlusSyncBatch.audit_result_json["observed_entities"],
|
||||
).where(TPlusSyncBatch.batch_no.in_(numbers)))
|
||||
return {number: {"progress": progress, "observed_entities": observed or []}
|
||||
for number, progress, observed in rows}
|
||||
|
||||
|
||||
def current_batch(session: Session):
|
||||
return session.scalar(select(TPlusSyncBatch).order_by(
|
||||
case((TPlusSyncBatch.status == "RUNNING", 0), (TPlusSyncBatch.status == "QUEUED", 1), else_=2),
|
||||
TPlusSyncBatch.id.desc(),
|
||||
).limit(1))
|
||||
|
||||
|
||||
def latest_audit(session: Session):
|
||||
"""最近完成的核验;返回批次供查询层过滤报告。"""
|
||||
return session.scalar(
|
||||
select(TPlusSyncBatch)
|
||||
.where(
|
||||
TPlusSyncBatch.sync_mode == "AUDIT",
|
||||
TPlusSyncBatch.finished_at.is_not(None),
|
||||
)
|
||||
.order_by(TPlusSyncBatch.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
|
||||
def total_staged_records(session: Session) -> int:
|
||||
"""从批次元数据汇总累计新增数,避免扫描原始快照。"""
|
||||
return (
|
||||
session.scalar(select(func.coalesce(func.sum(TPlusSyncBatch.staged_record_count), 0))) or 0
|
||||
)
|
||||
@ -37,7 +37,7 @@ def get_role_codes(user: User) -> list[str]:
|
||||
依赖 `User.user_roles` 的 `lazy="selectin"` 预加载(见模型定义),
|
||||
故此处遍历不会触发 N+1 查询。
|
||||
"""
|
||||
return [link.role.role_code for link in user.user_roles if link.role is not None]
|
||||
return [link.role.role_code for link in user.user_roles if link.role is not None and link.role.status == 1]
|
||||
|
||||
|
||||
def touch_last_login(session: Session, user: User, moment: datetime) -> None:
|
||||
|
||||
@ -56,6 +56,12 @@ class UserBrief(BaseModel):
|
||||
username: str
|
||||
real_name: str | None = None
|
||||
roles: list[str] = Field(default_factory=list, description="角色编码列表")
|
||||
can_write_payroll: bool = False
|
||||
payroll_approval_enabled: bool = False
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
authorization_revision: int = 0
|
||||
is_payroll_maker: bool = False
|
||||
maker_required_permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
|
||||
45
backend/app/schemas/authorization.py
Normal file
45
backend/app/schemas/authorization.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""授权请求版本化,禁止匿名覆盖他人的修改。"""
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class PermissionOverrideIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
permission_code: str = Field(min_length=1, max_length=128)
|
||||
effect: Literal["ALLOW", "DENY"]
|
||||
|
||||
|
||||
class AuthorizationPreviewIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
role_codes: list[str] = Field(default_factory=list, max_length=100)
|
||||
# 保留空字段兼容旧客户端;不再允许个人允许/禁止覆盖角色授权。
|
||||
overrides: list[PermissionOverrideIn] = Field(default_factory=list, max_length=0)
|
||||
|
||||
|
||||
class UserAuthorizationIn(AuthorizationPreviewIn):
|
||||
expected_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def reason_required(cls, value):
|
||||
if not value.strip():
|
||||
raise ValueError("修改原因不能为空")
|
||||
return value.strip()
|
||||
|
||||
|
||||
class RolePermissionsIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
permission_codes: list[str] = Field(max_length=300)
|
||||
expected_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
_reason = field_validator("reason")(UserAuthorizationIn.reason_required.__func__)
|
||||
|
||||
|
||||
class AuthorizationSettingsIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
maker_user_id: int | None = Field(default=None, ge=1)
|
||||
expected_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
_reason = field_validator("reason")(UserAuthorizationIn.reason_required.__func__)
|
||||
@ -107,6 +107,15 @@ class BusinessItemIn(BaseModel):
|
||||
return _check_amount(value, "金额", AMOUNT_MAX)
|
||||
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _derive_guided_cost(self):
|
||||
from app.services.cost_rules import guided_total
|
||||
|
||||
total = guided_total(self.quantity, self.guided_price, self.cost_amount)
|
||||
self.cost_amount = str(total) if total is not None else None
|
||||
return self
|
||||
|
||||
|
||||
class BusinessItemOut(BaseModel):
|
||||
"""业务产品明细行响应。金额/数量一律 str。"""
|
||||
|
||||
@ -117,7 +126,7 @@ class BusinessItemOut(BaseModel):
|
||||
product_name: str
|
||||
spec_model: str | None = None
|
||||
unit: str | None = None
|
||||
product_category: str
|
||||
product_category: str | None = None
|
||||
quantity: str
|
||||
sale_price: str | None = None
|
||||
sale_amount: str
|
||||
@ -149,6 +158,12 @@ class BusinessOut(BaseModel):
|
||||
total_received_amount: str | None = None
|
||||
total_freight_amount: str | None = None
|
||||
remark: str | None = None
|
||||
source_type: str = "MANUAL"
|
||||
source_no: str | None = None
|
||||
source_personnel: list[str] = Field(default_factory=list)
|
||||
integration_status: str | None = None
|
||||
readiness_status: str | None = None
|
||||
readiness_reasons: list[dict[str, str]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SecondaryAllocationIn(BaseModel):
|
||||
@ -250,6 +265,8 @@ class BusinessCreateIn(BaseModel):
|
||||
class BusinessUpdateIn(BaseModel):
|
||||
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
|
||||
|
||||
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
customer_id: int = Field(ge=1, description="客户ID")
|
||||
@ -320,13 +337,18 @@ class BusinessQuery(PageQuery):
|
||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选")
|
||||
date_from: date | None = Field(default=None, description="业务日期起(含)")
|
||||
date_to: date | None = Field(default=None, description="业务日期止(含)")
|
||||
source_type: str | None = Field(default=None, pattern=r"^(MANUAL|TPLUS)$")
|
||||
source_personnel: str | None = Field(default=None, max_length=64)
|
||||
pending_only: bool = False
|
||||
readiness_status: str | None = Field(default=None, pattern=r"^(READY|PENDING)$")
|
||||
readiness_reason: str | None = Field(default=None, max_length=64, pattern=r"^[A-Z_]+$")
|
||||
|
||||
|
||||
class GrossProfitRowOut(BaseModel):
|
||||
"""毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。"""
|
||||
|
||||
item_id: int
|
||||
product_category: str
|
||||
product_category: str | None = None
|
||||
product_name: str
|
||||
spec_model: str | None = None
|
||||
quantity: str
|
||||
@ -335,7 +357,7 @@ class GrossProfitRowOut(BaseModel):
|
||||
"""成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason"""
|
||||
profit_amount: str | None = None
|
||||
profit_rate: str | None = None
|
||||
threshold: str
|
||||
threshold: str | None = None
|
||||
converted_profit: str | None = None
|
||||
missing_reason: str | None = None
|
||||
|
||||
|
||||
109
backend/app/schemas/business_source.py
Normal file
109
backend/app/schemas/business_source.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""来源业务的人工分类资料。"""
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
class SalesRelationQuery(PageQuery):
|
||||
shipment_page: int = Field(default=1, ge=1, le=10000)
|
||||
order_page: int = Field(default=1, ge=1, le=10000)
|
||||
|
||||
|
||||
class SourceDocumentsQuery(PageQuery):
|
||||
entity: str | None = Field(default=None, max_length=32)
|
||||
|
||||
|
||||
class SourcePriceConfirmIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
revision: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
evidence: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_evidence(self):
|
||||
self.evidence = self.evidence.strip()
|
||||
if not self.evidence:
|
||||
raise ValueError("核对依据不能为空")
|
||||
return self
|
||||
|
||||
|
||||
class GuidanceConfirmIn(BaseModel):
|
||||
"""从业务发货明细登记对应发货月份的单位指导成本。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
shipment_item_id: int = Field(ge=1)
|
||||
amount: str = Field(min_length=1, max_length=32)
|
||||
evidence: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_values(self):
|
||||
self.evidence = self.evidence.strip()
|
||||
if not self.evidence:
|
||||
raise ValueError("核对依据不能为空")
|
||||
try:
|
||||
value = Decimal(self.amount)
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
raise ValueError("指导成本必须为有效数字") from None
|
||||
if not value.is_finite() or value < 0 or value.as_tuple().exponent < -4:
|
||||
raise ValueError("指导成本最多保留4位小数且不能为负数")
|
||||
return self
|
||||
|
||||
|
||||
class SourceProductCategory(BaseModel):
|
||||
product_id: int = Field(ge=1)
|
||||
category: Literal["municipal_water", "rail_transit"]
|
||||
|
||||
|
||||
class SourceMaterialsIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
customer_type: Literal["personal", "manager_personal", "enterprise"] | None = None
|
||||
products: list[SourceProductCategory] = Field(default_factory=list, max_length=200)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_products(self):
|
||||
if len({p.product_id for p in self.products}) != len(self.products):
|
||||
raise ValueError("产品不能重复")
|
||||
if self.customer_type is None and not self.products:
|
||||
raise ValueError("请选择要补充的分类")
|
||||
return self
|
||||
|
||||
|
||||
class SalesRelationConfirmIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
item_id: int = Field(ge=1)
|
||||
shipment_document_id: int = Field(ge=1)
|
||||
order_document_id: int | None = Field(default=None, ge=1)
|
||||
disposition: Literal["ORDER", "INDEPENDENT"]
|
||||
item_revision: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
shipment_revision: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
order_revision: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
|
||||
expected_version: int = Field(default=0, ge=0)
|
||||
evidence: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_relation(self):
|
||||
self.evidence = self.evidence.strip()
|
||||
if not self.evidence:
|
||||
raise ValueError("确认依据不能为空")
|
||||
if self.disposition == "ORDER" and (self.order_document_id is None or self.order_revision is None):
|
||||
raise ValueError("请选择并核对订单明细")
|
||||
if self.disposition == "INDEPENDENT" and (self.order_document_id is not None or self.order_revision is not None):
|
||||
raise ValueError("独立发货不应指定订单明细")
|
||||
return self
|
||||
|
||||
|
||||
class SalesRelationReleaseIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
item_id: int = Field(ge=1)
|
||||
expected_version: int = Field(ge=1)
|
||||
evidence: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_evidence(self):
|
||||
self.evidence = self.evidence.strip()
|
||||
if not self.evidence:
|
||||
raise ValueError("撤销依据不能为空")
|
||||
return self
|
||||
88
backend/app/schemas/calc_template.py
Normal file
88
backend/app/schemas/calc_template.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""计提公式模板的请求/响应模型。"""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# 模板编码与历史 TPL_ 编码一致:大写字母开头,大写字母/数字/下划线,3–32 位。
|
||||
TEMPLATE_CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,31}$")
|
||||
|
||||
|
||||
class TemplateParamOut(BaseModel):
|
||||
"""模板骨架参数角色映射(角色 → 参数编码)。"""
|
||||
|
||||
role: str
|
||||
param_code: str
|
||||
|
||||
|
||||
class CalcTemplateOut(BaseModel):
|
||||
"""模板列表/详情响应。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
template_code: str
|
||||
template_name: str
|
||||
formula_type: str
|
||||
params: dict[str, str]
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
status: int = 1
|
||||
is_system: int = 0
|
||||
role_count: int = 0
|
||||
|
||||
|
||||
class CalcTemplateOptionOut(BaseModel):
|
||||
"""岗位表单下拉项(仅启用模板,含骨架与参数绑定说明)。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
formula_type: str
|
||||
description: str
|
||||
params: list[TemplateParamOut]
|
||||
|
||||
|
||||
class FormulaOptionOut(BaseModel):
|
||||
"""公式骨架下拉项(新增模板时选择,受控集合)。
|
||||
|
||||
role_labels / role_hints 把英文角色键翻译成业务可读的中文标签与提示,
|
||||
模板表单据此渲染每个参数槽位的标签与 placeholder。
|
||||
"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str
|
||||
roles: list[str]
|
||||
role_labels: dict[str, str] = Field(default_factory=dict)
|
||||
role_hints: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalcTemplateCreateIn(BaseModel):
|
||||
"""新增模板。编码与骨架创建后不可修改。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
template_code: str = Field(min_length=3, max_length=32, description="模板编码,创建后不可修改")
|
||||
template_name: str = Field(min_length=1, max_length=64, description="模板名称")
|
||||
formula_type: str = Field(min_length=1, max_length=32, description="公式骨架类型")
|
||||
params: dict[str, str] = Field(default_factory=dict, description="骨架参数角色到参数编码映射")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
|
||||
@field_validator("template_code")
|
||||
@classmethod
|
||||
def _check_code(cls, value: str) -> str:
|
||||
if not TEMPLATE_CODE_PATTERN.match(value):
|
||||
raise ValueError("模板编码须以大写字母开头,仅可含大写字母、数字与下划线")
|
||||
return value
|
||||
|
||||
|
||||
class CalcTemplateUpdateIn(BaseModel):
|
||||
"""编辑模板。编码与骨架锁定;参数绑定仍按骨架角色校验。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
template_name: str = Field(min_length=1, max_length=64)
|
||||
params: dict[str, str] = Field(default_factory=dict)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
30
backend/app/schemas/category_batch.py
Normal file
30
backend/app/schemas/category_batch.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""基础档案待确认分类的批量补充。"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class CategoryBatch(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
ids: list[int] = Field(min_length=1, max_length=200)
|
||||
|
||||
@field_validator("ids")
|
||||
@classmethod
|
||||
def validate_ids(cls, value):
|
||||
if any(item < 1 for item in value) or len(value) != len(set(value)):
|
||||
raise ValueError("档案 ID 必须为不重复的正整数")
|
||||
return value
|
||||
|
||||
|
||||
class CustomerCategoryBatch(CategoryBatch):
|
||||
category: Literal["personal", "manager_personal", "enterprise"]
|
||||
|
||||
|
||||
class ProductCategoryBatch(CategoryBatch):
|
||||
category: Literal["municipal_water", "rail_transit"]
|
||||
|
||||
|
||||
class CustomerOwnershipBatch(CategoryBatch):
|
||||
new_owner_personnel_id: int = Field(ge=1)
|
||||
reason: str | None = Field(default=None, max_length=255)
|
||||
23
backend/app/schemas/coefficient.py
Normal file
23
backend/app/schemas/coefficient.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""系数表与 Excel 导入共用的边界模型。"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CoefficientRowIn(BaseModel):
|
||||
product_category: Literal["municipal_water", "rail_transit"]
|
||||
customer_type: Literal["personal", "enterprise"]
|
||||
stage: Literal["first", "linear", "after", "fixed"]
|
||||
value: str = Field(min_length=1, max_length=64)
|
||||
slope: str | None = Field(default=None, max_length=64)
|
||||
boundary: int | None = Field(default=None, ge=1, le=3660)
|
||||
|
||||
|
||||
class CoefficientChangeIn(BaseModel):
|
||||
effective_date: date
|
||||
base_date: date | None = None
|
||||
remark: str = Field(default="系数表维护", max_length=255)
|
||||
fingerprint: str = Field(min_length=64, max_length=64)
|
||||
rows: list[CoefficientRowIn] = Field(min_length=1, max_length=16)
|
||||
@ -25,7 +25,7 @@ class CustomerOut(BaseModel):
|
||||
contact_phone: str | None = None
|
||||
address: str | None = None
|
||||
industry: str | None = None
|
||||
customer_type: str
|
||||
customer_type: str | None = None
|
||||
owner_personnel_id: int | None = None
|
||||
owner_personnel_name: str | None = None
|
||||
remark: str | None = None
|
||||
@ -104,7 +104,9 @@ class CustomerQuery(PageQuery):
|
||||
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
||||
)
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
||||
category_missing: bool = Field(default=False, description="仅显示客户分类待确认")
|
||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
||||
owner_missing: bool = Field(default=False, description="仅显示归属待确认")
|
||||
order_by: Literal["id", "rating_score"] = Field(
|
||||
default="id", description="排序键:id 建档序 / rating_score 评级总分"
|
||||
)
|
||||
|
||||
@ -9,8 +9,7 @@
|
||||
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
|
||||
|
||||
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
|
||||
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
|
||||
不返回伪造的 0 条。
|
||||
同步记录、读取开关和自动采集开关分别返回,不用当前开关覆盖历史采集结果。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
@ -80,7 +79,7 @@ class TodoBlock(BaseModel):
|
||||
pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list)
|
||||
unassigned_receipt_total: int = 0
|
||||
unassigned_receipts: list[ReceiptTodoItem] = Field(default_factory=list)
|
||||
sync_available: bool = Field(default=False, description="⑥ 同步模块是否已启用")
|
||||
sync_available: bool = Field(default=False, description="同步记录是否可查询")
|
||||
sync_failure_total: int = 0
|
||||
|
||||
|
||||
@ -154,6 +153,17 @@ class ActivityPageOut(BaseModel):
|
||||
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)")
|
||||
|
||||
|
||||
class SyncBlock(BaseModel):
|
||||
"""源采集历史与当前运行配置,仅公开汇总状态。"""
|
||||
|
||||
available: bool = False
|
||||
enabled: bool = False
|
||||
automation_enabled: bool = False
|
||||
latest_status: str | None = None
|
||||
last_success_at: datetime | None = None
|
||||
historical_failure_total: int = 0
|
||||
|
||||
|
||||
class DashboardOut(BaseModel):
|
||||
"""工作台三区块一次性响应(最近动态走独立分页端点)。"""
|
||||
|
||||
@ -161,3 +171,4 @@ class DashboardOut(BaseModel):
|
||||
overview: OverviewBlock
|
||||
todos: TodoBlock
|
||||
warnings: WarningBlock
|
||||
sync: SyncBlock = Field(default_factory=SyncBlock)
|
||||
|
||||
78
backend/app/schemas/monitor.py
Normal file
78
backend/app/schemas/monitor.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""监控接口只暴露固定白名单,不传输内部异常或连接配置。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
Status = Literal["normal", "warning", "error", "unknown"]
|
||||
Hours = Annotated[
|
||||
Literal[1, 24, 168],
|
||||
BeforeValidator(lambda value: int(value) if value in ("1", "24", "168") else value),
|
||||
]
|
||||
|
||||
|
||||
class MonitorWindow(BaseModel):
|
||||
hours: Hours = 1
|
||||
end: datetime | None = None
|
||||
route: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class EventQuery(PageQuery):
|
||||
hours: Hours = 24
|
||||
end: datetime | None = None
|
||||
route: str | None = Field(default=None, max_length=256)
|
||||
kind: Literal["HEALTH", "REQUEST_ERROR", "SLOW_REQUEST", "RUNTIME_ERROR"] | None = None
|
||||
request_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
|
||||
active_only: bool = False
|
||||
|
||||
|
||||
class EventOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
instance_id: str
|
||||
kind: str
|
||||
code: str
|
||||
component: str
|
||||
level: str
|
||||
message: str
|
||||
request_id: str | None = None
|
||||
method: str | None = None
|
||||
route: str | None = None
|
||||
status_code: int | None = None
|
||||
duration_ms: float | None = None
|
||||
batch_no: str | None = None
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
occurrences: int
|
||||
resolved_at: datetime | None = None
|
||||
|
||||
|
||||
class DependencyOut(BaseModel):
|
||||
name: str
|
||||
status: Status
|
||||
message: str
|
||||
checked_at: datetime | None = None
|
||||
duration_ms: float | None = None
|
||||
|
||||
|
||||
class OverviewOut(BaseModel):
|
||||
status: Status
|
||||
environment: str
|
||||
version: str
|
||||
instance_id: str
|
||||
uptime_seconds: int
|
||||
collected_at: datetime | None
|
||||
last_persisted_at: datetime | None
|
||||
collection_status: Status
|
||||
collection_message: str
|
||||
resources: dict
|
||||
requests: dict
|
||||
dependencies: list[DependencyOut]
|
||||
alerts: list[EventOut]
|
||||
tasks: dict
|
||||
dropped_events: int
|
||||
retention_days: int = 7
|
||||
@ -191,8 +191,6 @@ class ParamPreviewIn(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_dates(self) -> "ParamPreviewIn":
|
||||
if self.receipt_date < self.ship_date:
|
||||
raise ValueError("回款到账日不得早于发货日")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
67
backend/app/schemas/payroll.py
Normal file
67
backend/app/schemas/payroll.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""正式工资核算的最小输入契约;金额使用字符串,资料版本显式校验。"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.month import validate_month
|
||||
from app.schemas.tplus_workbench import HandoverFact, _decimal
|
||||
|
||||
|
||||
class PayrollMonthIn(BaseModel):
|
||||
month: str
|
||||
reason: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
|
||||
@field_validator("month")
|
||||
@classmethod
|
||||
def check_month(cls, value):
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
class ShipmentOwnershipIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
shipment_item_id: int = Field(ge=1)
|
||||
personnel_id: int = Field(ge=1)
|
||||
manager_id: int | None = Field(default=None, ge=1)
|
||||
unit_cost: str
|
||||
freight_amount: str | None = None
|
||||
|
||||
@field_validator("freight_amount")
|
||||
@classmethod
|
||||
def check_freight(cls, value):
|
||||
if value is not None:
|
||||
_decimal(value, 2)
|
||||
return value
|
||||
|
||||
@field_validator("unit_cost")
|
||||
@classmethod
|
||||
def check_cost(cls, value):
|
||||
_decimal(value, 4)
|
||||
return value
|
||||
|
||||
|
||||
class PayrollSetupIn(PayrollMonthIn):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
expected_version: int = Field(default=0, ge=0)
|
||||
evidence: str = Field(min_length=1, max_length=500)
|
||||
opening_balance: str
|
||||
history_confirmed: Literal[True]
|
||||
guidance_finalized: Literal[True]
|
||||
shipment_ownerships: list[ShipmentOwnershipIn] = Field(min_length=1, max_length=10000)
|
||||
handovers: list[HandoverFact] = Field(default_factory=list, max_length=1000)
|
||||
|
||||
@field_validator("opening_balance")
|
||||
@classmethod
|
||||
def check_opening(cls, value):
|
||||
_decimal(value, 2)
|
||||
return value
|
||||
|
||||
|
||||
class AllocationAdjustmentIn(BaseModel):
|
||||
"""分配差额仅允许归生成当月;关联已结算原台账留痕。"""
|
||||
|
||||
ledger_id: int = Field(ge=1)
|
||||
personnel_id: int = Field(ge=1)
|
||||
direction: Literal[1, 2]
|
||||
amount: str = Field(pattern=r"^\d{1,12}(\.\d{1,2})?$")
|
||||
reason: str = Field(min_length=1, max_length=255)
|
||||
@ -3,7 +3,7 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
@ -46,6 +46,9 @@ class PersonnelOut(BaseModel):
|
||||
manager_name: str | None = None
|
||||
phone: str | None = None
|
||||
hire_date: date | None = None
|
||||
probation_start_date: date | None = None
|
||||
regularization_date: date | None = None
|
||||
resignation_date: date | None = None
|
||||
is_probation: int = 0
|
||||
salary_standard: str | None = None
|
||||
status: int = 1
|
||||
@ -53,6 +56,7 @@ class PersonnelOut(BaseModel):
|
||||
|
||||
|
||||
class PersonnelDetailOut(PersonnelOut):
|
||||
salary_history_json: list[dict] | None = None
|
||||
"""人员详情:附区域与拓展计划,供编辑抽屉一次回显。"""
|
||||
|
||||
regions: list[PersonnelRegionOut] = Field(default_factory=list)
|
||||
@ -60,6 +64,7 @@ class PersonnelDetailOut(PersonnelOut):
|
||||
|
||||
|
||||
class PersonnelCreateIn(BaseModel):
|
||||
salary_effective_date: date | None = None
|
||||
"""新增人员。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
@ -69,6 +74,9 @@ class PersonnelCreateIn(BaseModel):
|
||||
manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无")
|
||||
phone: str | None = Field(default=None, max_length=20, description="联系电话")
|
||||
hire_date: date | None = Field(default=None, description="入职日期,ISO 格式")
|
||||
probation_start_date: date | None = None
|
||||
regularization_date: date | None = None
|
||||
resignation_date: date | None = None
|
||||
is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否")
|
||||
salary_standard: str | None = Field(
|
||||
default=None, description="月工资标准(元),字符串形式的数字"
|
||||
@ -100,6 +108,13 @@ class PersonnelCreateIn(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_probation_dates(self):
|
||||
if self.probation_start_date and self.regularization_date and self.regularization_date <= self.probation_start_date:
|
||||
raise ValueError("转正生效日必须晚于试用期开始日")
|
||||
return self
|
||||
|
||||
|
||||
class PersonnelUpdateIn(PersonnelCreateIn):
|
||||
"""编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。"""
|
||||
|
||||
@ -108,6 +123,7 @@ class PersonnelStatusIn(BaseModel):
|
||||
"""在职/离职切换。"""
|
||||
|
||||
status: int = Field(ge=0, le=1, description="1在职 0离职")
|
||||
effective_date: date | None = None
|
||||
|
||||
|
||||
class RegionItemIn(BaseModel):
|
||||
|
||||
@ -23,11 +23,13 @@ class ProductOut(BaseModel):
|
||||
unit: str | None = None
|
||||
stock_quantity: str | None = None
|
||||
sale_price: str | None = None
|
||||
product_category: str
|
||||
product_category: str | None = None
|
||||
status: int = 1
|
||||
remark: str | None = None
|
||||
create_time: datetime
|
||||
update_time: datetime
|
||||
source_trade_json: dict | None = None
|
||||
quantity_price_manual: int = 0
|
||||
|
||||
|
||||
class ProductCreateIn(BaseModel):
|
||||
@ -94,4 +96,17 @@ class ProductQuery(PageQuery):
|
||||
|
||||
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="按类别筛选")
|
||||
category_missing: bool = Field(default=False, description="仅显示产品分类待确认")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|
||||
|
||||
|
||||
class ProductExtractIn(BaseModel):
|
||||
item_ids: list[int] = Field(min_length=1, max_length=200)
|
||||
fingerprint: str | None = Field(default=None, min_length=64, max_length=64)
|
||||
|
||||
@field_validator("item_ids")
|
||||
@classmethod
|
||||
def _ids(cls, value):
|
||||
if any(v <= 0 for v in value) or len(value) != len(set(value)):
|
||||
raise ValueError("明细ID必须为不重复的正整数")
|
||||
return value
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user