Compare commits

..

52 Commits

Author SHA1 Message Date
liuwenxin
b3de35c2ec fix(核算): 对齐抵顶口径并完善界面细节
回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。
2026-11-06 16:27:45 +08:00
liuwenxin
35fea3802f fix(备份): 防止恢复脚本覆盖数据库并返回失败码
修复根目录 README 的失效文档链接,改为现有 T+ 本地恢复手册。\n恢复脚本移除 RESTORE WITH REPLACE,默认不覆盖已有数据库;成功和失败均写入结果文件,失败显式返回非零退出码,并统一释放数据库连接。\n\n影响范围:项目导航文档与两个本地 SQL Server 恢复辅助脚本。\n\nCo-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-06 15:36:39 +08:00
liuwenxin
a4794489b1 fix(T+与核算): 对齐抵顶口径和托管说明
根据审查结果统一以物抵顶的线下核实口径,移除当前模型、初始化脚本和前端接口中的自动折算误导文案。\n补充业务接入必须持有共享 T+ 命名锁的执行契约,明确来源快照稳定性依赖。\nWindows 部署说明区分新 supervisor 与旧任务迁移检查,避免现场并行启动旧 worker。\n\n影响范围:T+ 托管文档、业务接入契约、回款/台账模型注释和参数试算说明。\n\nCo-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-06 14:49:06 +08:00
liuwenxin
eb4e06d1e8 docs(计划): 记录待核对原因汇总
记录业务接入终态中的未生效单据、来源值异常和目标冲突数量,明确未进入工资输入。
2026-11-06 14:18:32 +08:00
liuwenxin
ce0961a498 docs(计划): 记录业务接入最终终态
记录重试任务的最终处理量、待核对数量、业务总数和零工资输入结果。
2026-11-06 14:10:26 +08:00
liuwenxin
28d01dcd75 docs(计划): 记录 Docker 运行时根因
记录 Docker backend 异常退出、MySQL 连接中断和业务接入暂停原因,区分环境故障与应用数据问题。
2026-11-06 12:35:53 +08:00
liuwenxin
560b6878d6 docs(计划): 记录业务接入基础设施中断
记录重试任务的进度、Docker Desktop 再次停止和未写工资边界,保留后续恢复依据。
2026-11-06 12:27:41 +08:00
liuwenxin
d6cac0a914 docs(计划): 记录业务接入恢复进度
记录 Docker 恢复、业务接入任务续跑和当前新增、待核对数量,明确未写工资边界。
2026-11-06 11:52:16 +08:00
liuwenxin
65873bc894 docs(计划): 记录业务自动接入实测结果
记录采集完成、只读预览、业务自动接入启动以及真实业务列表增长结果。
2026-11-06 11:37:08 +08:00
liuwenxin
a8adf064d2 docs(计划): 记录真实 T+ 服务恢复验收
记录当前库 V33 迁移、读取服务恢复、真实登录页面同步进度和未写工资边界。
2026-11-06 10:32:08 +08:00
liuwenxin
b21dff12f4 fix(T+托管): 限制健康检查重定向范围
supervisor 的健康探测只允许访问本机地址,避免本机端点重定向到外部网络。\n增加重定向处理器和回归测试,保留现有依赖健康及错误脱敏行为。\n\n影响范围:T+ supervisor 健康检查与运行控制单元测试。\n\nCo-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-06 09:42:07 +08:00
liuwenxin
fc4428abb5 docs(计划): 记录 T+ 保护修复验收
记录指导成本、月份锁、读取服务页面和权限保护修复后的 89 项隔离定向测试结果。
2026-11-05 19:34:39 +08:00
liuwenxin
b8920c0d28 fix(T+): 补齐指导成本与读取服务保护
补充业务详情指导成本入口、来源版本和历史封存保护,接入月份锁并完善权限复核。
同步接入读取服务运行状态到页面,补充前端控制、回归测试和服务端只读连接授权校验。

定向隔离测试:89 项通过,1 条既有 Starlette 弃用警告。
2026-11-05 19:32:20 +08:00
liuwenxin
68e70fd6e2 fix(权限): 修复业务接入预览权限映射
业务接入预览属于只读预演,不应要求执行导入权限。\n将预览接口映射到 tplus:preview,并增加路由权限回归,避免只读角色被错误拒绝。\n\n影响范围:T+ 工作台业务接入预览接口与权限策略。\n\nCo-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-05 19:31:01 +08:00
liuwenxin
515b83b660 docs(计划): 记录真实浏览器冒烟结果
记录最新前端登录页在真实浏览器中的 200 响应、表单渲染和验证码入口检查,未提交登录或业务写入。
2026-11-05 19:19:21 +08:00
liuwenxin
4a888e9974 docs(计划): 记录最新 T+ 运行验收
记录当前提交在新端口的只读健康检查、接口发现和前端加载结果,明确验证码和真实写入尚未执行。
2026-11-05 19:13:34 +08:00
liuwenxin
73dbf08d4b fix(T+): 补齐读取服务数据库迁移
补充运行控制表及复数表名迁移,确保提交后的 Alembic 链与已验证的 V33 数据库结构一致。
2026-11-05 18:48:54 +08:00
liuwenxin
1e53792ad6 feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
2026-11-05 18:47:13 +08:00
liuwenxin
3efe72c344 docs(审查): 记录体检修复与备份恢复验证结果
记录审查问题、修复范围、验收证据及仍待目标环境验证的边界。
2026-11-03 17:18:53 +08:00
liuwenxin
ae12403c0b fix(备份): 修复快照位点与轮转期间的增量遗漏
统一跨平台备份入口,校验快照位点、完整性及增量链后隔离恢复。
末个 binlog 按读取位点归档,避免轮转后并发事务被续接跳过。
修复 Windows 安装器的编码、默认路径和花括号路径参数。
2026-11-03 17:18:52 +08:00
liuwenxin
b552f1e567 fix(部署): 将模板迁移 SQL 打包到后端镜像
V28 迁移运行时需要读取 db/migration,补齐镜像复制与构建白名单。
同时保留后端镜像使用的周期检查入口。
2026-11-03 17:18:49 +08:00
liuwenxin
6bac9586bf fix(托管): 轮转角色日志并验证失败退出码
每个角色固定保留当前与上次的输出日志,避免周期任务持续新增文件。
扩展 PowerShell 5 隔离检查,覆盖连续运行与日志轮转。
2026-11-03 17:18:48 +08:00
liuwenxin
bf404c3b68 fix(前端): 更新图表与请求依赖并同步锁文件
更新 ECharts、axios 及传递依赖,修复体检发现的依赖问题。
前端类型检查、生产构建及生产依赖安全审计通过。
2026-11-03 17:18:46 +08:00
liuwenxin
2ac4c71106 fix(迁移): 恢复计提模板建表脚本及内置种子
清除误写入 SQL 文件的缓存内容与 NUL 字节。
恢复与 V28 对齐的表结构及五个内置模板。
2026-11-03 17:18:45 +08:00
liuwenxin
2c003fc4a7 style(迁移): 修正迁移脚本的 Ruff 检查问题
将等价字典构造改为字面量并标记未使用字段,保持迁移 SQL 语义。
2026-11-03 17:18:44 +08:00
liuwenxin
5e5304eece fix(连接): 默认校验 T+ 证书并限制开发端口
T+ 连接默认校验服务器证书,隔离开发回退须显式配置。
MySQL 与 Redis 的开发端口仅绑定本机回环地址。
2026-11-03 17:18:43 +08:00
liuwenxin
908851a704 fix(测试): 清理本次创建的隔离资源并保留失败码
记录数据库与账号的创建结果,只清理本次成功创建的对象。
支持显式保留资源,拒绝误删复用库或重名对象,补充退出码回归。
2026-11-03 17:18:41 +08:00
liuwenxin
c684a44753 fix(设置): 恢复上传提交失败前的公司标志文件
数据库提交失败时恢复被覆盖的文件,或移除本次新文件。
文件恢复失败保留原始异常,并补充三种扩展名场景的回归测试。
2026-11-03 17:18:39 +08:00
liuwenxin
67adecbf1b fix(认证): 原子更新登录限流并统一凭据校验耗时
将失败计数与过期时间放入同一 Redis 事务,避免永久锁定。
不存在的账号执行同成本哈希校验,登录测试强制读取环境口令。
2026-11-03 17:18:36 +08:00
liuwenxin
22605f4106 docs(项目): 更新需求文档与开发记录
- 收录 2026-11-02 需求补齐计划、验收记录与业务口径时间线
- 新增授权使用、监控运维与产品提取操作说明
- 归档 T+ 前置计划与待确认事项,清理过期临时文档
- README 同步当前进度与权限改造说明

影响范围:仅文档,无代码变更
2026-11-03 11:21:46 +08:00
liuwenxin
a2f9a58de5 feat(前端): 配套授权、监控与同步进度页面
- 角色授权:权限矩阵抽屉与角色创建,按模块分组勾选
- 系统监控:总览、趋势、接口与事件页,15 秒自动刷新,
  请求可中断、窗口切换暂停,详情抽屉支持导出
- T+ 同步:同步进度条组件与工作台状态拆分
- 工资:试用期面板、产品提取弹窗与无权限占位页
- 路由权限:按权限码控制菜单与页面可达性

影响范围:前端全部页面
验证:随本次后端联调本地人工核对,e2e 脚本入库待跑
2026-11-03 11:21:19 +08:00
liuwenxin
1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00
liuwenxin
02fb06e0af fix(前端): 修复续算金额文案和长弹窗越界
跨月续算时将历史匹配回款与指导成本标为计算依据,避免页面误称为本次金额,并明确本期应付为续算差额。\n\n统一弹窗顶部间距与最大高度计算,保留正文滚动和底部操作区;新增隔离浏览器回归覆盖首次计提、续算状态及多种视口。\n\n验证:pnpm build;node frontend/e2e/ledger-dialog-regression.mjs。
2026-11-02 14:24:08 +08:00
liuwenxin
71b7846a79 refactor(后端): 将工资与工作区业务逻辑下沉服务层
将月份状态、资料输出、调整单编排及工作区查询、
CSV 生成移至现有服务层,路由仅绑定参数并返回响应。

保持接口路径、参数、响应模型和权限依赖一致,
补充九项内存库行为回归并同步测试记录。
2026-11-02 09:42:55 +08:00
liuwenxin
019817d6dc fix(工资核算): 排除起付比例中的历史发货
旧应收已经由期初余额承接,启用前发货再次进入分母,
会使新业务足额回款仍未达到起付门槛。

将销售额日期范围与 FIFO 的启用边界保持一致,
补充普通历史日期及二月末的两项回归用例。
2026-11-02 09:42:55 +08:00
liuwenxin
108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
2026-11-02 09:42:53 +08:00
liuwenxin
1b074576fe fix(后端): 区分 T+ 连接文件的缺失与字段错误
原先 OSError、ValueError、ValidationError 统一归并为「不可用
或字段不正确」,文件不存在和字段写错无法区分,排障困难。
拆分为两条消息分别指示故障类别,仍不回显文件内容与路径。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:58:47 +08:00
liuwenxin
013d6e7a74 docs(测试): 标注 T+ 前端 e2e 待补
按模块测试约定,T+ 页面验收节点应统一编写 Playwright 用例
并记录运行结果;当前仅有 type-check 验证,显式标注避免遗忘。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:58:27 +08:00
liuwenxin
0ca1734523 refactor(前端): 清理 T+ 页面展示细节
详情对话框:异常明细补充后端已支持的范围筛选,执行时间行
在批次未开始时隐藏(原先显示「— → —」);核验结果提示与
页首警告统一 show-icon;删除已声明未消费的 receipt_line_kinds
类型字段(方向信息已在 receipt_kinds 名称内)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:58:08 +08:00
liuwenxin
b0c599e513 fix(脚本): 收紧 T+ 运维脚本校验与恢复行为
login 脚本原先在结尾无条件 SET READ_ONLY:库名误指向其他
可写库时会把它锁成只读。改为开头校验库存在(不存在即中止)
并记录初始 Updateability,结尾仅当初始为 READ_ONLY 时恢复。
inventory 脚本的库名校验统一为 restore 脚本的字符白名单。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:57:30 +08:00
liuwenxin
2fda2d118b refactor(脚本): 复用应用连接校验与 URL 转义
verify_staging 原先 setattr 改写全局 Settings 单例,绕过
_ConnectionFile 严格校验与 sa 账号拒绝;profile_source 手工
拼接 ODBC 连接串且仅转义密码,database/user 含分号时可注入
连接属性。两者统一改为 model_copy 副本 + resolve_tplus_settings
完整校验 + SQLAlchemy URL 自建引擎,与 check_compatibility
同一路径,顺带消除 pyodbc connect 上下文的提交语义隐患。

真实源库连接的功能验证待本机 T+ 环境执行。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:57:07 +08:00
liuwenxin
37eb51513f fix(前端): 兼容非安全上下文的任务请求键生成
crypto.randomUUID 仅在 HTTPS 或 localhost 下存在,客户内网
以 http://<内网IP> 访问时该 API 为 undefined,而新建任务
对话框在页面挂载即调用,会直接 TypeError 导致整页白屏。

新增 newRequestKey 工具:优先 randomUUID,不可用时降级为
getRandomValues 拼 v4 格式(非安全上下文仍可用)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:56:41 +08:00
liuwenxin
ee801652e3 fix(运维): 限制 T+ 开发库端口仅本机访问
SQL Server 容器还原的是客户真实账套且带 sa 账号,1433 端口
原先绑定全部网卡会暴露到局域网。改为 127.0.0.1 回环地址,
与 MySQL 开发库的绑定先例保持一致,宿主机访问不受影响。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:56:13 +08:00
liuwenxin
8a171a572c docs(tplus): 完成交付设计并统一客户确认表与同版本基准 2026-10-28 16:31:55 +08:00
liuwenxin
651341b36c fix(tplus): 统一兼容检查连接策略并默认启用加密 2026-10-28 16:01:38 +08:00
liuwenxin
0d63359acb docs(tplus): 导入设计补充字典交叉验证的字段核验备注 2026-10-28 14:41:50 +08:00
liuwenxin
ea41dadb64 docs(tplus): 修正验收手册只读表述并补 B5 预留参数 2026-10-28 13:11:15 +08:00
liuwenxin
8242aae454 docs(tplus): 新增 2008 R2 验收手册与数据导入设计稿 2026-10-28 11:52:39 +08:00
liuwenxin
87a0884953 docs(tplus): 更新客户口径文档与开发计划 2026-10-28 11:43:13 +08:00
liuwenxin
ea989ce9ad feat(tplus): 新增 T+ 同步管理页面与菜单入口 2026-10-28 11:42:51 +08:00
liuwenxin
4fe5916c2c feat(tplus): 新增 T+ 只读同步服务与同步管理 2026-10-28 11:42:15 +08:00
liuwenxin
78bef43c06 feat(tplus): 新增同步暂存与任务管理迁移和环境配置 2026-10-28 11:41:38 +08:00
499 changed files with 39965 additions and 3230 deletions

35
.dockerignore Normal file
View 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
View File

@ -1,5 +1,7 @@
# AI辅助目录(整个目录忽略) # AI辅助目录(整个目录忽略)
ai-aux/ .ai/
.agents/
/AGENTS.md
# 依赖 # 依赖
node_modules/ node_modules/
@ -53,6 +55,7 @@ frontend/e2e/**/*.png
*.tmp *.tmp
*.bak *.bak
*.cache *.cache
.tmp-processes*.txt
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx) # Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
~$* ~$*
@ -65,6 +68,14 @@ CLAUDE.md
backend/uploads/ backend/uploads/
# 数据库备份导出目录(⑨ 备份脚本产物) # 数据库备份导出目录(⑨ 备份脚本产物)
db/backups/ 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 数据字典压缩包与解压目录不入版本库 # 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
docs/**/*.rar docs/**/*.rar
@ -74,3 +85,10 @@ __MACOSX/
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库 # AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
.tmpdocx/ .tmpdocx/
# 本轮原开发环境损坏后的独立恢复运行环境。
backend/.venv-tplus/
backend/.venv-workbench/
# 本地浏览器验收自动生成的快照
/.playwright-mcp/
/phase2-evidence-*/

View File

@ -2,7 +2,7 @@
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。 营销人员绩效工资核算系统。后端 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 cd backend
uv run pytest tests/unit -q # 纯单元,无需容器,秒级 uv run pytest tests/unit -q # 纯单元,无需容器,秒级
uv run pytest -q # 全部(集成部分需容器在跑) python ../scripts/run_isolated_tests.py # 创建独立测试库运行集成,禁止指向业务库
uv run ruff check . # 代码检查 uv run ruff check . # 代码检查
uv run ruff format . # 代码格式化 uv run ruff format . # 代码格式化
@ -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/README.md` | 后端启动、分层约定、接口清单 |
| `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 | | `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 |
| `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 | | `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 |
| `docs/` | 需求文档与工资算法方案 | | `docs/` | 需求文档与工资算法方案 |
## 正式工资闭环
- 在“角色管理”配置工资操作权限,在“用户管理”分配角色;账号自动继承权限,无需单独绑定工资制作人。每次实际经办人继续留痕。
- 应用新增迁移后,T+ 页面提供正式业务导入预览/执行;业务列表“正式核算”准备人工业务资料或核算已导入资料,台账可查看逐段依据。
- “生成月结快照”首次成功即锁定该月分配;审批退回、重新生成不解锁。历史已生成工资保留,差额计调整单生成当月。
- 后端接口说明、验证状态和已知边界见 `docs/正式工资闭环开发记录_2026-10-30.md`。旧引擎仅通过 `PAYROLL_ALLOW_LEGACY_CALCULATION` 显式启用作历史兼容,默认关闭,不用于最新规则验收。
- 集成回归使用 `scripts/run_isolated_tests.py`;原 `.venv` 不可用时,本机使用 `backend/.venv-workbench/Scripts/python.exe`。运行器默认清理本次新建的隔离库和账号,绝不删除原开发数据;需复查失败现场时加 `--keep`。
审核默认关闭:`PAYROLL_APPROVAL_ENABLED=false`,工资生成直接完成且不产生审批痕迹;如确需系统内复核,显式开启后沿用财务/领导审核和各类审批参数。此配置与旧核算引擎兼容开关独立。
## 权限管理
- “用户管理 → 分配角色”:选择角色并预览继承权限,支持多个角色叠加。
- “角色管理 → 配置权限”:按模块配置模板;停用角色即时撤销其授权。
- 管理员拥有全部功能权限;月份锁定、审核节点及源读取开关继续独立检查。
- 授权变更无需重新登录,后端逐请求校验;详情、只读提示、菜单与按钮使用统一规则。操作与接口说明见 [权限管理使用与开发记录](docs/权限管理使用与开发记录_2026-11-02.md)。

View File

@ -17,6 +17,15 @@ REDIS_DB=0
# ===== 应用 ===== # ===== 应用 =====
APP_ENV=dev APP_ENV=dev
APP_SECRET_KEY= APP_SECRET_KEY=
# 运行监控:每 15 秒采集,分钟聚合保留 7 天;部署限单个应用进程
MONITOR_ENABLED=true
MONITOR_CPU_THRESHOLD=85
MONITOR_MEMORY_THRESHOLD=85
MONITOR_DISK_THRESHOLD=90
MONITOR_RESOURCE_DURATION_SECONDS=300
MONITOR_SLOW_REQUEST_MS=2000
MONITOR_ERROR_RATE_THRESHOLD=5
MONITOR_ERROR_MIN_REQUESTS=20
# JWT 有效期(分钟),规范要求 ≤120 # JWT 有效期(分钟),规范要求 ≤120
JWT_EXPIRE_MINUTES=120 JWT_EXPIRE_MINUTES=120
# 验证码有效期(秒),规范要求 300 # 验证码有效期(秒),规范要求 300
@ -26,6 +35,44 @@ LOGIN_FAIL_LIMIT=5
LOGIN_LOCK_MINUTES=15 LOGIN_LOCK_MINUTES=15
# ===== T+ 对接(待畅捷通确认后填写)===== # ===== T+ 对接(待畅捷通确认后填写)=====
T_PLUS_BASE_URL= T_PLUS_ENABLED=false
T_PLUS_APP_KEY= T_PLUS_HOST=127.0.0.1
T_PLUS_APP_SECRET= T_PLUS_PORT=1433
T_PLUS_DATABASE=
T_PLUS_USER=
T_PLUS_PASSWORD=
# 仅用于已确认的开发测试库,默认禁止使用 sa。
T_PLUS_ALLOW_DEV_SA=false
# 仅在客户确认只能使用管理员账号时显式开启。
T_PLUS_ALLOW_ADMIN_ACCOUNT=false
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
T_PLUS_CONNECTION_FILE=
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
# 本地 PoC 可复用已安装的 SQL Server 驱动;不自动安装或升级客户驱动。
T_PLUS_ODBC_DRIVER=
# 默认启用传输加密。旧实例 TLS 不兼容时,仅隔离开发环境可显式设为 false,
# 记录原因并重新验收;程序不会自动降级。真实环境值后续按验收结果填写。
T_PLUS_ENCRYPT=true
# 默认校验服务器证书。仅隔离开发环境可显式设为 true,并记录原因和验收范围。
T_PLUS_TRUST_SERVER_CERTIFICATE=false
T_PLUS_CONNECT_TIMEOUT=10
T_PLUS_QUERY_TIMEOUT=120
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
T_PLUS_QUERY_DIR=
# 工资操作通过角色权限控制,无需配置全局制作人账号。
# 审核能力保留但默认关闭;开启后仍按各类审批参数及财务/领导节点执行
PAYROLL_APPROVAL_ENABLED=false
# ===== T+ 周期采集(默认关闭,仅采集原始数据,不自动生成工资)=====
T_PLUS_AUTOMATION_ENABLED=false
# 0=周一,6=周日;时段均为北京时间
T_PLUS_SCHEDULE_WEEKDAY=0
T_PLUS_SCHEDULE_TIME=02:00
# 托管启动器自动设置;发现标记后停止排队和领取新任务
T_PLUS_STOP_FILE=
# 仅允许本机 HTTP 健康检查地址;API 更换监听端口时同步修改。
T_PLUS_SUPERVISOR_HEALTH_URL=http://127.0.0.1:8000/api/health
# 可选上传文件根目录;留空使用 backend/uploads,隔离测试由运行器独立指定。
UPLOAD_DIR=

43
backend/Dockerfile Normal file
View 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"]

View File

@ -140,6 +140,19 @@ app/
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`, (`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。 V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
### T+ 本地只读同步与管理
T+ 数据库是 SQL Server 只读源库,业务库仍为 MySQL。Docker 恢复 PoC、备份检查和 D 盘目录约定见 `docs/T+本地恢复与同步开发手册.md`。
- 连接配置在 `backend/.env` 的 `T_PLUS_*` 变量中,默认关闭,不配置时不影响主业务启动。
- `uv run python -m app.cli tplus-stage --entity personnel` 按主键分页,将源行写入 `tplus_sync_records` staging,并记录批次和失败信息。水位表已预留,时间增量未启用,`--watermark` 会明确拒绝。
- T+ 驱动通过 `uv sync --extra tplus` 安装到应用环境,ODBC 驱动名称必须显式配置;交付兼容目标为 SQL Server 2008 R2,2022 副本不能代替同版本验收。
- 查询目录 `db/queries/tplus/` 随部署交付,或使用 `T_PLUS_QUERY_DIR` 指定受控位置;支持六类主表和三类明细。
- T+ 字典中的候选字段在实际备份核验和客户确认前,不得直接覆盖岗位、工资标准、归属、业务状态或核算状态。
- 管理 API `/api/v1/system/tplus` 全部限管理员:概况、批次/异常分页、按需报告、创建与失败重试。HTTP 只创建 QUEUED 任务;`uv run python -m app.cli tplus-worker --watch --connection-file <项目外JSON路径>` 独立处理。
- 核心文件:`services/tplus/task_service.py`(幂等/重试)、`worker.py`(执行)、`relationship_audit.py`(字典与关联)、`query_service.py`(安全输出)、`core/tplus_lock.py`(跨提交执行锁)。原 CLI 读取与 worker 共用执行锁。
- V15 / Alembic `e5f607a8b9c0` 增加范围、跳过数、请求键、父批次、请求人和核验结果。未启用时间增量与工资业务导入;完整运行步骤和异常处理见本地恢复手册。
## 常用命令 ## 常用命令
```bash ```bash
@ -284,3 +297,13 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
**`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。 **`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。
**改了代码不生效** —— 启动时没加 `--reload`。 **改了代码不生效** —— 启动时没加 `--reload`。
## 系统监控(2026-11-02)
新增管理员只读接口 `/api/v1/system/monitor/{overview,trends,interfaces,events,diagnostic}`,事件详情为 `/events/{id}`。后台独立线程每 15 秒采集,分钟聚合保留 7 天;配置见 `.env.example` 的 `MONITOR_*`,新增依赖 `psutil`。部署限单个后端应用进程,按项目流程应用 V23 迁移后启动。异常通过 `X-Request-ID` 与脱敏服务端日志关联,`/api/health` 原契约保持不变。
详细采集口径、故障边界、排查步骤和接口见 [系统监控运维说明](../docs/系统监控运维说明.md)。
## 角色授权(2026-11-02)
系统权限统一为“角色配置权限 → 用户分配角色 → 用户继承权限”。V26(`26e7f8a9b0c1`)停用旧个人覆盖和全局制作人入口;工资核算、生成、调整分别检查角色权限,月份实际经办人、生成锁和历史留痕继续保留。接口与迁移边界见 [角色授权使用说明](../docs/角色授权使用说明.md)。

View 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}")

View 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')

View File

@ -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")

View 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")

View File

@ -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'''")

File diff suppressed because one or more lines are too long

View 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'"))

View 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")

View 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

View 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"))

View File

@ -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")

View File

@ -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")

View File

@ -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)

View 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}"))

View File

@ -0,0 +1,14 @@
"""合并业务接入和回款确认迁移分支。"""
revision = "31d2e3f4a5b6"
down_revision = ("29b0c1d2e3f4", "30c1d2e3f4a5")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@ -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("运行控制包含用户设置,请停止服务并备份后采用应用回退")

View File

@ -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("运行控制表名迁移不可直接回退,请停止服务并按兼容版本处理")

View 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")

View 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",
)

View 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)

View File

@ -98,7 +98,7 @@ def _is_digits(value: object) -> bool:
return isinstance(value, str) and value.isdigit() return isinstance(value, str) and value.isdigit()
def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User: def require_admin(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
"""要求当前用户具备管理员角色。 """要求当前用户具备管理员角色。
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个 需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
@ -107,6 +107,9 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
:raises ForbiddenError: 已登录但不具备管理员角色(403) :raises ForbiddenError: 已登录但不具备管理员角色(403)
""" """
# 权限码与「仅管理员」是两套语义:部分系统权限(如 system:params:update)
# 可授予普通角色,统一入口通过不代表本端点放行,这里不依赖
# authorization_checked 短路,每次按数据库角色重新判定。
if ROLE_ADMIN not in user_repository.get_role_codes(current_user): if ROLE_ADMIN not in user_repository.get_role_codes(current_user):
raise ForbiddenError("需要管理员权限") raise ForbiddenError("需要管理员权限")
return current_user return current_user
@ -119,6 +122,21 @@ AdminUser = Annotated[User, Depends(require_admin)]
LoginUser = Annotated[User, Depends(get_current_user)] 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 = "无权访问审批管理"): def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
"""要求当前用户具备任一指定角色(依赖工厂)。 """要求当前用户具备任一指定角色(依赖工厂)。
@ -132,10 +150,45 @@ def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
:raises ForbiddenError: 已登录但不具备任一指定角色(403) :raises ForbiddenError: 已登录但不具备任一指定角色(403)
""" """
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User: def _checker(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
codes = set(user_repository.get_role_codes(current_user)) # 与 require_admin 同理:不依赖统一入口的短路标记,按数据库角色判定。
if not codes & set(role_codes): held = set(user_repository.get_role_codes(current_user))
if not held & set(role_codes):
raise ForbiddenError(message) raise ForbiddenError(message)
return current_user return current_user
return _checker 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

View File

@ -4,9 +4,10 @@
健康检查作为运维探针不带版本号,直接位于 `/api/health`。 健康检查作为运维探针不带版本号,直接位于 `/api/health`。
""" """
from fastapi import APIRouter from fastapi import APIRouter, Depends
from app.api import health from app.api import health
from app.api.deps import authorize_request
from app.api.v1 import auth from app.api.v1 import auth
from app.api.v1.base import business_roles as base_business_roles from app.api.v1.base import business_roles as base_business_roles
from app.api.v1.base import customers as base_customers 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.business import shipments as business_shipments
from app.api.v1.calc import router as calc_router from app.api.v1.calc import router as calc_router
from app.api.v1.dashboard import router as dashboard_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.reports import router as reports_router
from app.api.v1.settlements import router as settlements_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 approvals as system_approvals
from app.api.v1.system import business_roles as system_business_roles 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 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 params as system_params
from app.api.v1.system import roles as system_roles 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 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 import users as system_users
from app.api.v1.system.authorization import router as authorization_router
from app.api.v1.system.coefficients import router as coefficient_router
api_router = APIRouter() api_router = APIRouter()
@ -34,19 +42,26 @@ api_router = APIRouter()
api_router.include_router(health.router) api_router.include_router(health.router)
# 业务接口:/api/v1/* # 业务接口:/api/v1/*
v1_router = APIRouter(prefix="/v1") v1_router = APIRouter(prefix="/v1", dependencies=[Depends(authorize_request)])
v1_router.include_router(authorization_router)
v1_router.include_router(metadata_router)
v1_router.include_router(auth.router) v1_router.include_router(auth.router)
# 系统管理(需求 2.5.1):/api/v1/system/* # 系统管理(需求 2.5.1):/api/v1/system/*
system_router = APIRouter(prefix="/system") system_router = APIRouter(prefix="/system")
system_router.include_router(system_users.router) 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_roles.router)
system_router.include_router(system_business_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(system_params.router)
system_router.include_router(coefficient_router)
system_router.include_router(system_settings.router) system_router.include_router(system_settings.router)
system_router.include_router(system_approvals.router) system_router.include_router(system_approvals.router)
system_router.include_router(system_logs.operation_router) system_router.include_router(system_logs.operation_router)
system_router.include_router(system_logs.login_router) system_router.include_router(system_logs.login_router)
system_router.include_router(system_tplus.router)
v1_router.include_router(system_router) v1_router.include_router(system_router)
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问 # 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
@ -67,6 +82,7 @@ v1_router.include_router(business_router)
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤 # 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(calc_router) v1_router.include_router(calc_router)
v1_router.include_router(payroll_router)
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤 # 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(settlements_router) v1_router.include_router(settlements_router)
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导 # 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导

View File

@ -5,9 +5,9 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
from app.core.config import get_settings
from app.core.response import ApiResponse, ok from app.core.response import ApiResponse, ok
from app.models import User from app.models import User
from app.repositories.user_repository import get_role_codes
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
from app.services import auth_service, captcha_service from app.services import auth_service, captcha_service
@ -65,12 +65,16 @@ def logout(
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户") @router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]: def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]:
"""返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。""" """返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。"""
from app.services.authorization_service import session_info, user_session
authorization = session_info(user_session(current_user), current_user)
return ok( return ok(
CurrentUserOut( CurrentUserOut(
id=current_user.id, id=current_user.id,
username=current_user.username, username=current_user.username,
real_name=current_user.real_name, real_name=current_user.real_name,
roles=get_role_codes(current_user), **authorization,
payroll_approval_enabled=get_settings().payroll_approval_enabled,
last_login_time=current_user.last_login_time, last_login_time=current_user.last_login_time,
) )
) )

View File

@ -13,7 +13,7 @@ from app.api.deps import DbSession, get_current_user
from app.core.response import ApiResponse, ok from app.core.response import ApiResponse, ok
from app.models import User from app.models import User
from app.schemas.business_role import BusinessRoleOptionOut 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=["人员管理"]) 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")])

View File

@ -13,9 +13,10 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.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 ( from app.schemas.customer import (
CustomerCreateIn, CustomerCreateIn,
CustomerOut, CustomerOut,
@ -33,6 +34,18 @@ router = APIRouter(prefix="/customers", tags=["客户管理"])
CurrentUser = Annotated[User, Depends(get_current_user)] 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="客户列表") @router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表")
def list_customers( def list_customers(
session: DbSession, session: DbSession,
@ -46,7 +59,7 @@ def list_customers(
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户") @router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
def create_customer( def create_customer(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: CustomerCreateIn, payload: CustomerCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]: ) -> ApiResponse[CustomerOut]:
@ -70,7 +83,7 @@ def get_customer(
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户") @router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
def update_customer( def update_customer(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)], customer_id: Annotated[int, Path(ge=1)],
payload: CustomerUpdateIn, payload: CustomerUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -91,7 +104,7 @@ def update_customer(
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户") @router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
def delete_customer( def delete_customer(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)], customer_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:
@ -110,7 +123,7 @@ def delete_customer(
) )
def rate_customer( def rate_customer(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)], customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingIn, payload: CustomerRatingIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -135,7 +148,7 @@ def rate_customer(
) )
def change_ownership( def change_ownership(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)], customer_id: Annotated[int, Path(ge=1)],
payload: CustomerOwnershipIn, payload: CustomerOwnershipIn,
meta: RequestMetaDep, meta: RequestMetaDep,

View File

@ -11,7 +11,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.core.response import ApiResponse, PageResult, ok
from app.models import User from app.models import User
from app.schemas.personnel import ( from app.schemas.personnel import (
@ -44,7 +44,7 @@ def list_personnel(
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员") @router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
def create_personnel( def create_personnel(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: PersonnelCreateIn, payload: PersonnelCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]: ) -> ApiResponse[PersonnelOut]:
@ -68,7 +68,7 @@ def get_personnel(
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员") @router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
def update_personnel( def update_personnel(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)], personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelCreateIn, payload: PersonnelCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -85,7 +85,7 @@ def update_personnel(
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员") @router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
def delete_personnel( def delete_personnel(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)], personnel_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:
@ -101,14 +101,14 @@ def delete_personnel(
) )
def set_status( def set_status(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)], personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelStatusIn, payload: PersonnelStatusIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]: ) -> ApiResponse[PersonnelOut]:
"""在职/离职切换。离职人员不可再被业务与客户归属引用。""" """在职/离职切换。离职人员不可再被业务与客户归属引用。"""
result = personnel_service.set_status( 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 "已设为离职") 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="维护辐射区域") @router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
def replace_regions( def replace_regions(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)], personnel_id: Annotated[int, Path(ge=1)],
payload: RegionReplaceIn, payload: RegionReplaceIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -131,7 +131,7 @@ def replace_regions(
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划") @router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
def replace_plans( def replace_plans(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)], personnel_id: Annotated[int, Path(ge=1)],
payload: PlanReplaceIn, payload: PlanReplaceIn,
meta: RequestMetaDep, meta: RequestMetaDep,

View File

@ -3,8 +3,8 @@
产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值), 产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值),
类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。 类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。
**数据提取(POST /products/extract)暂不提供**:它依赖业务明细表 数据提取使用预览指纹校验、名称规格去重和单位分类冲突保护,
(V5 迁移),待业务管理任务完成后补充。 仅建立产品档案关联,不改写历史业务金额。
基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser); 基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。 写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
@ -14,11 +14,13 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.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 ( from app.schemas.product import (
ProductCreateIn, ProductCreateIn,
ProductExtractIn,
ProductOut, ProductOut,
ProductQuery, ProductQuery,
ProductStatusIn, ProductStatusIn,
@ -31,6 +33,41 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/products", tags=["产品管理"]) 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="产品列表") @router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表")
def list_products( def list_products(
session: DbSession, session: DbSession,
@ -44,7 +81,7 @@ def list_products(
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品") @router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
def create_product( def create_product(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: ProductCreateIn, payload: ProductCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[ProductOut]: ) -> ApiResponse[ProductOut]:
@ -68,7 +105,7 @@ def get_product(
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品") @router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
def update_product( def update_product(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)], product_id: Annotated[int, Path(ge=1)],
payload: ProductUpdateIn, payload: ProductUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -85,7 +122,7 @@ def update_product(
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品") @router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
def delete_product( def delete_product(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)], product_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:
@ -100,7 +137,7 @@ def delete_product(
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品") @router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
def set_status( def set_status(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)], product_id: Annotated[int, Path(ge=1)],
payload: ProductStatusIn, payload: ProductStatusIn,
meta: RequestMetaDep, meta: RequestMetaDep,

View File

@ -9,7 +9,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User from app.models import User
from app.schemas.business import ( from app.schemas.business import (
@ -24,7 +24,16 @@ from app.schemas.business import (
GrossProfitOut, GrossProfitOut,
OwnershipChangeLogOut, 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) # 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)] CurrentUser = Annotated[User, Depends(get_current_user)]
@ -32,6 +41,83 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/businesses", tags=["业务管理"]) 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="业务列表") @router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表")
def list_businesses( def list_businesses(
session: DbSession, session: DbSession,
@ -45,7 +131,7 @@ def list_businesses(
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务") @router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
def create_business( def create_business(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: BusinessCreateIn, payload: BusinessCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]: ) -> ApiResponse[BusinessOut]:
@ -69,7 +155,7 @@ def get_business(
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务") @router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
def update_business( def update_business(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)], business_id: Annotated[int, Path(ge=1)],
payload: BusinessUpdateIn, payload: BusinessUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -86,7 +172,7 @@ def update_business(
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务") @router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
def delete_business( def delete_business(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)], business_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:
@ -98,7 +184,7 @@ def delete_business(
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转") @router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
def change_status( def change_status(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)], business_id: Annotated[int, Path(ge=1)],
payload: BusinessStatusIn, payload: BusinessStatusIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -115,7 +201,7 @@ def change_status(
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属") @router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
def change_ownership( def change_ownership(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)], business_id: Annotated[int, Path(ge=1)],
payload: BusinessOwnershipIn, payload: BusinessOwnershipIn,
meta: RequestMetaDep, meta: RequestMetaDep,

View File

@ -13,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path 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.core.response import ApiResponse, ok
from app.models import User from app.models import User
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
@ -36,7 +36,7 @@ def list_grades(
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级") @router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
def create_grade( def create_grade(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: RatingGradeCreateIn, payload: RatingGradeCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[RatingGradeOut]: ) -> ApiResponse[RatingGradeOut]:
@ -50,7 +50,7 @@ def create_grade(
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级") @router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
def update_grade( def update_grade(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
grade_id: Annotated[int, Path(ge=1)], grade_id: Annotated[int, Path(ge=1)],
payload: RatingGradeUpdateIn, payload: RatingGradeUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -65,7 +65,7 @@ def update_grade(
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级") @router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
def delete_grade( def delete_grade(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
grade_id: Annotated[int, Path(ge=1)], grade_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:

View File

@ -9,16 +9,17 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.core.response import ApiResponse, PageResult, ok
from app.models import User from app.models import User
from app.schemas.receipt import ( from app.schemas.receipt import (
ReceiptConfirmIn,
ReceiptCreateIn, ReceiptCreateIn,
ReceiptOut, ReceiptOut,
ReceiptQuery, ReceiptQuery,
ReceiptUpdateIn, ReceiptUpdateIn,
) )
from app.services import receipt_service from app.services import receipt_confirmation, receipt_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) # 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)] CurrentUser = Annotated[User, Depends(get_current_user)]
@ -26,6 +27,21 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/receipts", tags=["回款管理"]) 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="回款列表") @router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表")
def list_receipts( def list_receipts(
session: DbSession, session: DbSession,
@ -39,7 +55,7 @@ def list_receipts(
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款") @router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
def create_receipt( def create_receipt(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: ReceiptCreateIn, payload: ReceiptCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[ReceiptOut]: ) -> ApiResponse[ReceiptOut]:
@ -63,7 +79,7 @@ def get_receipt(
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款") @router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
def update_receipt( def update_receipt(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
receipt_id: Annotated[int, Path(ge=1)], receipt_id: Annotated[int, Path(ge=1)],
payload: ReceiptUpdateIn, payload: ReceiptUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -80,7 +96,7 @@ def update_receipt(
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款") @router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
def delete_receipt( def delete_receipt(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
receipt_id: Annotated[int, Path(ge=1)], receipt_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:

View File

@ -8,7 +8,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.core.response import ApiResponse, PageResult, ok
from app.models import User from app.models import User
from app.schemas.shipment import ( from app.schemas.shipment import (
@ -39,7 +39,7 @@ def list_shipments(
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单") @router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
def create_shipment( def create_shipment(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: ShipmentCreateIn, payload: ShipmentCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[ShipmentOut]: ) -> ApiResponse[ShipmentOut]:
@ -63,7 +63,7 @@ def get_shipment(
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单") @router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
def update_shipment( def update_shipment(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
shipment_id: Annotated[int, Path(ge=1)], shipment_id: Annotated[int, Path(ge=1)],
payload: ShipmentUpdateIn, payload: ShipmentUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -80,7 +80,7 @@ def update_shipment(
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单") @router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
def delete_shipment( def delete_shipment(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
shipment_id: Annotated[int, Path(ge=1)], shipment_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:

View File

@ -10,18 +10,14 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.api.deps import DbSession, RequestMetaDep, require_roles from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
from app.core.response import ApiResponse, PageResult, ok from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
from app.services import calc_service 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="绩效台账") @router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
@ -50,7 +46,7 @@ def get_ledger(
def calc_receipt( def calc_receipt(
receipt_id: Annotated[int, Path(ge=1)], receipt_id: Annotated[int, Path(ge=1)],
session: DbSession, session: DbSession,
current_user: _CalcWriter, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]: ) -> ApiResponse[CalcReceiptOut]:
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。""" """把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
@ -81,7 +77,7 @@ def recalc_receipt(
receipt_id: Annotated[int, Path(ge=1)], receipt_id: Annotated[int, Path(ge=1)],
payload: RecalcIn, payload: RecalcIn,
session: DbSession, session: DbSession,
current_user: _RecalcUser, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]: ) -> ApiResponse[CalcReceiptOut]:
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。""" """重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
@ -97,7 +93,7 @@ def recalc_receipt(
def batch_calc( def batch_calc(
payload: BatchIn, payload: BatchIn,
session: DbSession, session: DbSession,
current_user: _CalcWriter, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[list[dict]]: ) -> ApiResponse[list[dict]]:
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。""" """批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""

View 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))

View 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)
)

View File

@ -8,10 +8,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, require_roles from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS
from app.core.response import ApiResponse, PageResult, ok from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.settlement import ( from app.schemas.settlement import (
AdjustmentCreateIn, AdjustmentCreateIn,
SettlementDetailOut, SettlementDetailOut,
@ -21,16 +19,15 @@ from app.schemas.settlement import (
) )
from app.services import settlement_service 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="生成月结快照") @router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
def generate( def generate(
payload: SettlementGenerateIn, payload: SettlementGenerateIn,
session: DbSession, session: DbSession,
current_user: _SettlementWriter, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]: ) -> ApiResponse[SettlementDetailOut]:
"""按确认回款口径生成指定月份的结算快照(草稿)。""" """按确认回款口径生成指定月份的结算快照(草稿)。"""
@ -66,7 +63,7 @@ def get_settlement(
def submit( def submit(
settlement_id: Annotated[int, Path(ge=1)], settlement_id: Annotated[int, Path(ge=1)],
session: DbSession, session: DbSession,
current_user: _SettlementWriter, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]: ) -> ApiResponse[SettlementDetailOut]:
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。""" """提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
@ -83,7 +80,7 @@ def submit(
def create_adjustment( def create_adjustment(
payload: AdjustmentCreateIn, payload: AdjustmentCreateIn,
session: DbSession, session: DbSession,
current_user: _SettlementWriter, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[dict]: ) -> ApiResponse[dict]:
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。""" """新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""

View 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))

View File

@ -60,7 +60,7 @@ def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。 定义在 `/{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="岗位详情") @router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")

View 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="模板已删除")

View 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)))

View 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",
},
)

View 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))

View 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"'},
)

View File

@ -10,14 +10,25 @@
import argparse import argparse
import getpass import getpass
import json
import signal
import sys import sys
import threading
from datetime import datetime
from pathlib import Path
from uuid import uuid4
from sqlalchemy import select from sqlalchemy import select
from app.core.config import get_settings
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
from app.core.db import dispose_engine, get_session_factory from app.core.db import dispose_engine, get_session_factory
from app.core.security import hash_password from app.core.security import hash_password
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
from app.models import Role, User, UserRole from app.models import Role, User, UserRole
from app.repositories.tplus_repository import get_batch
from app.services.tplus.sync_service import TPlusSyncService
from app.services.tplus.worker import run_once
def _prompt_password() -> str: def _prompt_password() -> str:
@ -80,6 +91,115 @@ def _create_admin(args: argparse.Namespace) -> int:
return 0 return 0
def _stage_tplus(args: argparse.Namespace) -> int:
"""读取 T+ 源库并写入 MySQL staging。"""
target_factory = get_session_factory()
source_factory = get_tplus_session_factory()
watermark = datetime.fromisoformat(args.watermark) if args.watermark else None
with source_factory() as source_session, target_factory() as target_session:
batch = TPlusSyncService(source_session, target_session).run(
entity_types=args.entity,
watermark=watermark,
batch_size=args.batch_size,
)
print(
f"T+ staging 完成:批次={batch.batch_no} 状态={batch.status} "
f"源记录={batch.source_record_count} 新增={batch.staged_record_count} "
f"失败={batch.failed_record_count}"
)
return 0 if batch.status == "SUCCEEDED" else 1
def _tplus_worker(args: argparse.Namespace) -> int:
"""独立消费持久化任务;HTTP 进程不承担长时间读取。"""
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
if not 2 <= args.poll_seconds <= 60:
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
return 1
factory = get_session_factory()
settings = get_settings()
stop_file = getattr(args, "stop_file", None) or settings.t_plus_stop_file
stopped = threading.Event()
owner = str(uuid4())
previous_handlers = {}
if threading.current_thread() is threading.main_thread():
for sig in (signal.SIGINT, signal.SIGTERM):
previous_handlers[sig] = signal.signal(sig, lambda *_: stopped.set())
try:
while not stopped.is_set() and not (stop_file and Path(stop_file).exists()):
with factory() as session:
batch_no = run_once(session, worker_owner=owner)
batch = get_batch(session, batch_no) if batch_no else None
if batch_no and batch is None:
from app.models import TPlusWorkRun
batch = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == batch_no))
status = batch.status if batch else None
if batch_no:
print(f"任务完成:{batch_no} 状态={status}", flush=True)
if not args.watch:
return 0 if status in (None, "SUCCEEDED") else 1
if batch_no is None or status != "SUCCEEDED":
stopped.wait(args.poll_seconds)
print("T+读取服务已停止领取新任务", flush=True)
return 0
except KeyboardInterrupt:
print("T+ 读取服务已停止")
return 130
except Exception as exc:
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
return 1
finally:
for sig, handler in previous_handlers.items():
signal.signal(sig, handler)
try:
from app.services.tplus.automation import heartbeat
heartbeat(factory, settings, owner, "STOPPED")
except Exception as exc:
print(f"T+停止状态未能写入({type(exc).__name__}),请检查迁移与运行配置", file=sys.stderr)
def _maintenance(args):
from app.services.tplus.automation import tick
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
if args.stop_file:
get_settings().t_plus_stop_file = args.stop_file
with get_session_factory()() as session:
result = tick(session)
print(json.dumps(result, ensure_ascii=False))
return 0
def _tplus_supervisor(args):
from app.services.tplus.supervisor import run
return run(get_session_factory(), get_settings(), interval=args.poll_seconds)
def _materials(args):
from app.services.tplus.materials import collect_materials, render_markdown
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
output = Path(args.output_dir).resolve()
targets = [output / f"materials-{args.customer_id}-{args.month}.{ext}" for ext in ("json", "md")]
if any(p.exists() for p in targets):
raise RuntimeError("核对包已存在,请指定新的输出目录,避免覆盖既有验收依据")
with get_tplus_session_factory()() as source:
report = collect_materials(source, args.customer_id, args.month)
output.mkdir(parents=True, exist_ok=True)
targets[0].write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
targets[1].write_text(render_markdown(report), encoding="utf-8")
print(f"样本核对包已生成;待补{len(report['missing'])}项;真实金额尚未验收:{output}")
return 0
def main() -> int: def main() -> int:
"""命令行入口。""" """命令行入口。"""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@ -96,12 +216,71 @@ def main() -> int:
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中", help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
) )
p_tplus = sub.add_parser("tplus-stage", help="读取 T+ 源库并写入原始 staging")
p_tplus.add_argument(
"--entity",
action="append",
choices=[
"personnel",
"products",
"customers",
"businesses",
"shipments",
"receipts",
"business_items",
"shipment_items",
"receipt_items",
],
required=True,
help="实体类型,可重复指定",
)
p_tplus.add_argument(
"--watermark",
default=None,
help="增量起始时间,ISO 8601 格式;不传表示全量读取",
)
p_tplus.add_argument("--batch-size", type=int, default=500, help="每次从源库读取的行数")
p_worker = sub.add_parser("tplus-worker", help="执行已排队的 T+ 读取或关联核验")
modes = p_worker.add_mutually_exclusive_group()
modes.add_argument("--once", action="store_true", help="执行一个任务后退出(默认)")
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
p_worker.add_argument("--stop-file", default=None, help="检测到停止标记后不再领取新任务")
p_maintenance = sub.add_parser("tplus-maintain", help="检查每周原始采集与有限重试,不生成工资")
p_maintenance.add_argument("--once", action="store_true", help="执行一次检查(默认)")
p_maintenance.add_argument("--connection-file", default=None)
p_maintenance.add_argument("--stop-file", default=None)
p_supervisor = sub.add_parser("tplus-supervisor", help="按页面设置托管 T+ 读取服务")
p_supervisor.add_argument("--poll-seconds", type=int, choices=range(2, 61), default=5)
p_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资")
p_materials.add_argument("--customer-id", type=int, required=True)
p_materials.add_argument("--month", required=True)
p_materials.add_argument("--connection-file", default=None)
p_materials.add_argument("--output-dir", required=True)
args = parser.parse_args() args = parser.parse_args()
try: try:
return _create_admin(args) if args.command == "create-admin":
return _create_admin(args)
if args.command == "tplus-worker":
return _tplus_worker(args)
if args.command == "tplus-maintain":
return _maintenance(args)
if args.command == "tplus-supervisor":
return _tplus_supervisor(args)
if args.command == "tplus-materials":
return _materials(args)
return _stage_tplus(args)
except Exception as exc:
print(f"命令执行失败({type(exc).__name__}),请检查参数和只读连接配置", file=sys.stderr)
return 1
finally: finally:
dispose_engine() dispose_engine()
dispose_tplus_engine()
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -11,6 +11,7 @@
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from urllib.parse import quote
from pydantic import Field, field_validator from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@ -35,7 +36,28 @@ class Settings(BaseSettings):
# ===== 应用 ===== # ===== 应用 =====
app_env: str = "dev" app_env: str = "dev"
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符") upload_root: Path | None = Field(default=None, validation_alias="UPLOAD_DIR")
@field_validator("upload_root", mode="before")
@classmethod
def _empty_upload_root(cls, value):
return None if value in (None, "") else value
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符", repr=False)
payroll_maker_user_id: int | None = Field(
default=None, ge=1, description="指定工资制作人的现有账号ID;未设置则业务只读"
)
payroll_allow_legacy_calculation: bool = False
payroll_approval_enabled: bool = False
# ===== 单进程运行监控;历史只清理监控新表 =====
monitor_enabled: bool = True
monitor_cpu_threshold: float = Field(default=85, ge=1, le=100)
monitor_memory_threshold: float = Field(default=85, ge=1, le=100)
monitor_disk_threshold: float = Field(default=90, ge=1, le=100)
monitor_resource_duration_seconds: int = Field(default=300, ge=15)
monitor_slow_request_ms: int = Field(default=2000, ge=1)
monitor_error_rate_threshold: float = Field(default=5, ge=0.1, le=100)
monitor_error_min_requests: int = Field(default=20, ge=1)
# ===== 鉴权 ===== # ===== 鉴权 =====
# 规范第 13 节:Token 有效期 ≤ 2 小时 # 规范第 13 节:Token 有效期 ≤ 2 小时
@ -50,14 +72,39 @@ class Settings(BaseSettings):
mysql_port: int = 3306 mysql_port: int = 3306
mysql_database: str = "payroll" mysql_database: str = "payroll"
mysql_user: str = "payroll" mysql_user: str = "payroll"
mysql_password: str = "" mysql_password: str = Field(default="", repr=False)
# ===== Redis ===== # ===== Redis =====
redis_host: str = "127.0.0.1" redis_host: str = "127.0.0.1"
redis_port: int = 6379 redis_port: int = 6379
redis_password: str = "" redis_password: str = Field(default="", repr=False)
redis_db: int = 0 redis_db: int = 0
# ===== T+ 只读 SQL Server =====
# T+ 数据库默认关闭,避免开发者未配置驱动时影响主业务启动。
t_plus_enabled: bool = False
t_plus_host: str = "127.0.0.1"
t_plus_port: int = 1433
t_plus_database: str = ""
t_plus_user: str = ""
t_plus_password: str = Field(default="", repr=False)
t_plus_allow_dev_sa: bool = False
t_plus_allow_admin_account: bool = False
t_plus_connection_file: str = ""
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
t_plus_odbc_driver: str = ""
t_plus_encrypt: bool = True
# 默认校验服务器证书;自签回退只允许隔离开发显式开启,避免中间人替换证书。
t_plus_trust_server_certificate: bool = False
t_plus_connect_timeout: int = 10
t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
t_plus_query_dir: str = ""
t_plus_automation_enabled: bool = False
t_plus_schedule_weekday: int = Field(default=0, ge=0, le=6)
t_plus_schedule_time: str = Field(default="02:00", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
t_plus_stop_file: str = ""
t_plus_supervisor_health_url: str = "http://127.0.0.1:8000/api/health"
@field_validator("app_secret_key") @field_validator("app_secret_key")
@classmethod @classmethod
def _check_secret_key(cls, v: str) -> str: def _check_secret_key(cls, v: str) -> str:
@ -89,9 +136,33 @@ class Settings(BaseSettings):
@property @property
def redis_url(self) -> str: def redis_url(self) -> str:
"""Redis 连接 URL。密码为空时不拼接认证段。""" """Redis 连接 URL。密码为空时不拼接认证段。"""
auth = f":{self.redis_password}@" if self.redis_password else "" auth = f":{quote(self.redis_password, safe='')}@" if self.redis_password else ""
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}" return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
@property
def t_plus_database_url(self) -> URL:
"""构造 T+ 只读 SQL Server 连接 URL。
连接 URL 只在启用 T+ 适配器时使用;密码由 SQLAlchemy URL 对象保存,
日志中调用 ``render_as_string()`` 时仍会默认隐藏密码。
ODBC 的分号和花括号由方言统一转义,调用方不得再手工拼接连接串。
"""
query = {
"driver": self.t_plus_odbc_driver,
"Encrypt": "yes" if self.t_plus_encrypt else "no",
"TrustServerCertificate": "yes" if self.t_plus_trust_server_certificate else "no",
"Connection Timeout": str(self.t_plus_connect_timeout),
}
return URL.create(
drivername="mssql+pyodbc",
username=self.t_plus_user,
password=self.t_plus_password,
host=self.t_plus_host,
port=self.t_plus_port,
database=self.t_plus_database,
query=query,
)
@property @property
def upload_dir(self) -> Path: def upload_dir(self) -> Path:
"""上传文件根目录(公司 logo 等):backend/uploads/。 """上传文件根目录(公司 logo 等):backend/uploads/。
@ -99,7 +170,7 @@ class Settings(BaseSettings):
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物, 不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。 运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
""" """
return BACKEND_DIR / "uploads" return self.upload_root.resolve() if self.upload_root else BACKEND_DIR / "uploads"
@lru_cache @lru_cache

View File

@ -74,13 +74,16 @@ async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONRespons
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝") 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("未预期的服务端异常") request_id = getattr(request.state, "request_id", "-")
return _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试") logger.exception("未预期的服务端异常", extra={"request_id": request_id})
response = _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
response.headers["X-Request-ID"] = request_id
return response
def register_exception_handlers(app: FastAPI) -> None: def register_exception_handlers(app: FastAPI) -> None:

View File

@ -52,3 +52,10 @@ class NotFoundError(AppException):
code = CODE_NOT_FOUND code = CODE_NOT_FOUND
message = "资源不存在" message = "资源不存在"
class ConflictError(AppException):
"""授权版本已变化,客户端应刷新后重新核对。"""
code = 409
message = "配置已被其他人修改,请刷新后重新核对"

View File

@ -1,6 +1,6 @@
"""日志配置。 """日志配置。
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天+大小切分、 规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天轮转(当前未做大小上限,磁盘容量需配合监控)、
保留 30 天、禁止输出密码/手机号/身份证/Token。 保留 30 天、禁止输出密码/手机号/身份证/Token。
Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8, Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8,
@ -13,6 +13,7 @@ import sys
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from app.core.config import BACKEND_DIR from app.core.config import BACKEND_DIR
from app.core.monitoring import REQUEST_ID
LOG_DIR = BACKEND_DIR / "logs" LOG_DIR = BACKEND_DIR / "logs"
@ -53,6 +54,8 @@ class SensitiveFormatter(logging.Formatter):
""" """
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
if not hasattr(record, "request_id"):
record.request_id = REQUEST_ID.get()
return scrub(super().format(record)) return scrub(super().format(record))
@ -71,7 +74,7 @@ def setup_logging(level: str = "INFO", log_to_file: bool = True) -> None:
root.removeHandler(handler) root.removeHandler(handler)
formatter = SensitiveFormatter( formatter = SensitiveFormatter(
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", fmt="%(asctime)s | %(levelname)-8s | %(name)s | request_id=%(request_id)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S", datefmt="%Y-%m-%d %H:%M:%S",
) )

View 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)

View 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

View 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()

View File

@ -14,13 +14,16 @@ from app.core.config import get_settings
from app.core.db import dispose_engine from app.core.db import dispose_engine
from app.core.exception_handlers import register_exception_handlers from app.core.exception_handlers import register_exception_handlers
from app.core.logging import setup_logging from app.core.logging import setup_logging
from app.core.monitoring import MonitorMiddleware, Recorder
from app.core.redis_client import close_redis from app.core.redis_client import close_redis
from app.core.tplus_db import dispose_tplus_engine
from app.services.monitor_collector import MonitorCollector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期管理。 """应用生命周期管理。
启动阶段不建立数据库/Redis 连接(延迟到首次使用), 启动阶段不建立数据库/Redis 连接(延迟到首次使用),
@ -33,8 +36,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
settings.mysql_host, settings.mysql_host,
settings.mysql_database, settings.mysql_database,
) )
yield app.state.monitor.start()
try:
yield
finally:
app.state.monitor.stop()
dispose_engine() dispose_engine()
dispose_tplus_engine()
close_redis() close_redis()
logger.info("应用已停止,连接池已释放") logger.info("应用已停止,连接池已释放")
@ -60,6 +68,9 @@ def create_app() -> FastAPI:
register_exception_handlers(app) register_exception_handlers(app)
app.include_router(api_router, prefix="/api") app.include_router(api_router, prefix="/api")
recorder = Recorder()
app.state.monitor = MonitorCollector(settings, recorder)
app.add_middleware(MonitorMiddleware, recorder=recorder)
return app return app

View File

@ -10,15 +10,20 @@
""" """
from app.models.approval import ApprovalOrder, ApprovalStep from app.models.approval import ApprovalOrder, ApprovalStep
from app.models.authorization import AuthorizationSetting, UserPermissionOverride
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole from app.models.business_role import BusinessRole
from app.models.calc_template import CalcTemplateDefinition
from app.models.customer import Customer, OwnershipChangeLog from app.models.customer import Customer, OwnershipChangeLog
from app.models.login_log import LoginLog from app.models.login_log import LoginLog
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
from app.models.operation_log import OperationLog from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue 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.performance_ledger import PerformanceLedger
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
from app.models.probation import ProbationDeferral, ProbationPayment
from app.models.product import Product from app.models.product import Product
from app.models.rating import CustomerRatingGrade from app.models.rating import CustomerRatingGrade
from app.models.receipt import Receipt 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.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
from app.models.shipment import Shipment, ShipmentItem from app.models.shipment import Shipment, ShipmentItem
from app.models.system_setting import SystemSetting from app.models.system_setting import SystemSetting
from app.models.tplus_automation import (
TPlusAutomationConfig,
TPlusAutomationState,
TPlusRuntimeControl,
)
from app.models.tplus_business_sync import TPlusBusinessDocument, TPlusBusinessSyncConfig
from app.models.tplus_sync import (
TPlusSyncBatch,
TPlusSyncError,
TPlusSyncRecord,
TPlusSyncWatermark,
)
from app.models.tplus_workbench import (
TPlusFact,
TPlusSourceHead,
TPlusSourceLink,
TPlusWorkLine,
TPlusWorkRun,
)
from app.models.user import User, UserRole from app.models.user import User, UserRole
__all__ = [ __all__ = [
"AdjustmentOrder", "AdjustmentOrder",
"ApprovalOrder", "ApprovalOrder",
"ApprovalStep", "ApprovalStep",
"AuthorizationSetting",
"Base", "Base",
"Business", "Business",
"BusinessItem", "BusinessItem",
"BusinessRole", "BusinessRole",
"BusinessStatusLog", "BusinessStatusLog",
"CalcTemplateDefinition",
"Customer", "Customer",
"CustomerRatingGrade", "CustomerRatingGrade",
"LoginLog", "LoginLog",
"MonitorEvent",
"MonitorRequestMinute",
"MonitorSnapshot",
"MonthlySettlement", "MonthlySettlement",
"OperationLog", "OperationLog",
"OwnershipChangeLog", "OwnershipChangeLog",
"ParamChangeBatch", "ParamChangeBatch",
"ParamDefinition", "ParamDefinition",
"ParamValue", "ParamValue",
"PayrollAllocation",
"PayrollInput",
"PayrollMonth",
"PerformanceLedger", "PerformanceLedger",
"Permission", "Permission",
"Personnel", "Personnel",
"PersonnelPlan", "PersonnelPlan",
"PersonnelRegion", "PersonnelRegion",
"ProbationDeferral",
"ProbationPayment",
"Product", "Product",
"Receipt", "Receipt",
"Role", "Role",
@ -60,7 +94,22 @@ __all__ = [
"Shipment", "Shipment",
"ShipmentItem", "ShipmentItem",
"SystemSetting", "SystemSetting",
"TPlusAutomationConfig",
"TPlusAutomationState",
"TPlusBusinessDocument",
"TPlusBusinessSyncConfig",
"TPlusFact",
"TPlusRuntimeControl",
"TPlusSourceHead",
"TPlusSourceLink",
"TPlusSyncBatch",
"TPlusSyncError",
"TPlusSyncRecord",
"TPlusSyncWatermark",
"TPlusWorkLine",
"TPlusWorkRun",
"TimestampMixin", "TimestampMixin",
"User", "User",
"UserPermissionOverride",
"UserRole", "UserRole",
] ]

View 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")

View File

@ -91,6 +91,9 @@ class TPlusSyncMixin:
String(32), comment="同步批次号(同步模块写入)" String(32), comment="同步批次号(同步模块写入)"
) )
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间") synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
source_updated_at: Mapped[datetime | None] = mapped_column(
DateTime, comment="T+ 源数据更新时间(未确认时可为空)"
)
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]: def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:

View File

@ -16,6 +16,7 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import ( from sqlalchemy import (
JSON,
Date, Date,
DateTime, DateTime,
ForeignKey, ForeignKey,
@ -147,6 +148,7 @@ class Business(Base, TimestampMixin, TPlusSyncMixin):
comment="客户ID(customers.id)", comment="客户ID(customers.id)",
) )
business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)") 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( status: Mapped[int] = mapped_column(
TINYINT_TYPE, TINYINT_TYPE,
nullable=False, nullable=False,
@ -239,9 +241,9 @@ class BusinessItem(Base, TimestampMixin):
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
) )
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位") unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
product_category: Mapped[str] = mapped_column( product_category: Mapped[str | None] = mapped_column(
String(32), String(32),
nullable=False, nullable=True,
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)", comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
) )
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)") quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)")

View 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自定义",
)

View File

@ -49,10 +49,9 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话") contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
address: Mapped[str | None] = mapped_column(String(255), comment="地址") address: Mapped[str | None] = mapped_column(String(255), comment="地址")
industry: Mapped[str | None] = mapped_column(String(64), 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), String(32),
nullable=False, nullable=True,
server_default="personal",
index=True, index=True,
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)", comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
) )

View 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="恢复时间,仅用于健康异常"
)

View 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="单据、成本、归属、参数和逐段计算依据")

View File

@ -55,6 +55,7 @@ class PerformanceLedger(Base, TimestampMixin):
"receipt_id", "receipt_id",
"calc_version", "calc_version",
"personnel_id", "personnel_id",
"allocation_key",
name="uk_performance_ledgers_receipt_id_calc_version_personnel", name="uk_performance_ledgers_receipt_id_calc_version_personnel",
), ),
Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"), Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"),
@ -65,6 +66,7 @@ class PerformanceLedger(Base, TimestampMixin):
"calc_type", "calc_type",
), ),
Index("idx_performance_ledgers_personnel_id", "personnel_id"), Index("idx_performance_ledgers_personnel_id", "personnel_id"),
Index("idx_performance_ledgers_receipt_id", "receipt_id"),
Index("idx_performance_ledgers_business_id", "business_id"), Index("idx_performance_ledgers_business_id", "business_id"),
) )
@ -82,6 +84,9 @@ class PerformanceLedger(Base, TimestampMixin):
server_default="1", server_default="1",
comment="核算版本(重算+1,旧行 is_active=0)", 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( is_active: Mapped[int] = mapped_column(
TINYINT_TYPE, TINYINT_TYPE,
nullable=False, nullable=False,
@ -143,7 +148,7 @@ class PerformanceLedger(Base, TimestampMixin):
TINYINT_TYPE, TINYINT_TYPE,
nullable=False, nullable=False,
server_default="0", server_default="0",
comment="以物抵顶折算:1已按 80% 折算 0否", comment="以物抵顶处理标记:当前系统不自动折算,1表示由线下规则处理 0表示未标记",
) )
weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)") weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)")
payment_weight: Mapped[Decimal | None] = mapped_column( payment_weight: Mapped[Decimal | None] = mapped_column(

View File

@ -10,7 +10,7 @@
from datetime import date from datetime import date
from decimal import Decimal 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 sqlalchemy.orm import Mapped, mapped_column
from app.models.base import ( from app.models.base import (
@ -62,9 +62,22 @@ class Personnel(Base, TimestampMixin, TPlusSyncMixin):
server_default="0", server_default="0",
comment="是否试用期:1是 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( salary_standard: Mapped[Decimal | None] = mapped_column(
Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)" Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)"
) )
salary_history_json: Mapped[list | None] = mapped_column(
JSON,
comment="工资标准、岗位及任职资料生效版本",
)
status: Mapped[int] = mapped_column( status: Mapped[int] = mapped_column(
TINYINT_TYPE, TINYINT_TYPE,
nullable=False, nullable=False,

View 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="创建时间"
)

View File

@ -8,7 +8,7 @@
from decimal import Decimal 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 sqlalchemy.orm import Mapped, mapped_column
from app.models.base import ( from app.models.base import (
@ -47,12 +47,14 @@ class Product(Base, TimestampMixin, TPlusSyncMixin):
sale_price: Mapped[Decimal | None] = mapped_column( sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="销售单价(元,T+ 同步)" Numeric(14, 4), comment="销售单价(元,T+ 同步)"
) )
product_category: Mapped[str] = mapped_column( product_category: Mapped[str | None] = mapped_column(
String(32), String(32),
nullable=False, nullable=True,
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)", comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
) )
status: Mapped[int] = mapped_column( status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用" TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
) )
remark: Mapped[str | None] = mapped_column(String(255), comment="备注") 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随源更新")

View File

@ -10,10 +10,10 @@
- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开 - 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开
""" """
from datetime import date from datetime import date, datetime
from decimal import Decimal 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 sqlalchemy.orm import Mapped, mapped_column
from app.models.base import ( from app.models.base import (
@ -72,7 +72,7 @@ class Receipt(Base, TimestampMixin, TPlusSyncMixin):
TINYINT_TYPE, TINYINT_TYPE,
nullable=False, nullable=False,
server_default="0", server_default="0",
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)", comment="以物抵顶标记:1是 0否(特殊绩效线下核实,系统不自动折算)",
) )
settlement_status: Mapped[int] = mapped_column( settlement_status: Mapped[int] = mapped_column(
TINYINT_TYPE, TINYINT_TYPE,
@ -80,4 +80,10 @@ class Receipt(Base, TimestampMixin, TPlusSyncMixin):
server_default="0", server_default="0",
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)", 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="备注") remark: Mapped[str | None] = mapped_column(String(255), comment="备注")

View File

@ -79,6 +79,10 @@ class MonthlySettlement(Base, TimestampMixin):
) )
locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)") 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): class SettlementItem(Base):
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。""" """月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
@ -127,17 +131,27 @@ class SettlementItem(Base):
String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE" String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE"
) )
net_amount: Mapped[Decimal | None] = mapped_column( net_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="净工资 = 绩效±基本工资(按口径;试用期 0)" Numeric(14, 2),
comment="净工资 = 绩效±基本工资(按口径;试用期为基本工资+正常应付+年度差额,缓发部分不发)"
) )
is_probation: Mapped[int] = mapped_column( is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE, TINYINT_TYPE,
nullable=False, nullable=False,
server_default="0", server_default="0",
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否", comment="试用期:1缓发绩效不发(基本工资正常发,超年累计记 note)0否",
) )
probation_note: Mapped[str | None] = mapped_column( probation_note: Mapped[str | None] = mapped_column(
String(255), comment="试用期说明(年累计超阈值部分年底一次性结算)" 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( snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="个人明细快照(台账行引用列表)" JSON, comment="个人明细快照(台账行引用列表)"
) )
@ -154,6 +168,9 @@ class AdjustmentOrder(Base, TimestampMixin):
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" 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( order_no: Mapped[str] = mapped_column(
String(32), String(32),
nullable=False, nullable=False,

View File

@ -49,10 +49,9 @@ class Shipment(Base, TimestampMixin, TPlusSyncMixin):
ship_date: Mapped[date] = mapped_column( ship_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)" Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)"
) )
freight_amount: Mapped[Decimal] = mapped_column( freight_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), Numeric(14, 2),
nullable=False, nullable=True,
server_default="0",
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)", comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
) )
total_amount: Mapped[Decimal] = mapped_column( total_amount: Mapped[Decimal] = mapped_column(

View 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")

View 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="最小必要来源与差异说明")

View 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"
)

View 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)

View File

@ -15,15 +15,21 @@ from app.models import (
Customer, Customer,
OwnershipChangeLog, OwnershipChangeLog,
Personnel, Personnel,
Product,
Receipt, Receipt,
SecondaryAllocation, SecondaryAllocation,
Shipment, Shipment,
TPlusBusinessDocument,
) )
from app.models.customer import OWNER_TYPE_BUSINESS from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import ownership_log_repository from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef from app.repositories.ownership_log_repository import OwnershipLogRef
from app.schemas.business import BusinessQuery 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: def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
"""按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。""" """按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。"""
@ -98,9 +104,40 @@ def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
stmt = stmt.where(Business.business_date >= query.date_from) stmt = stmt.where(Business.business_date >= query.date_from)
if query.date_to is not None: if query.date_to is not None:
stmt = stmt.where(Business.business_date <= query.date_to) 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 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( def list_businesses(
session: Session, query: BusinessQuery session: Session, query: BusinessQuery
) -> list[tuple[Business, str, str | None, str | None]]: ) -> list[tuple[Business, str, str | None, str | None]]:

View 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()

View File

@ -114,8 +114,12 @@ def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
stmt = stmt.where(_keyword_clause(query.keyword)) stmt = stmt.where(_keyword_clause(query.keyword))
if query.customer_type: if query.customer_type:
stmt = stmt.where(Customer.customer_type == 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: if query.owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id) 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 return stmt

View 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)

View File

@ -66,6 +66,8 @@ def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
) )
if query.product_category: if query.product_category:
stmt = stmt.where(Product.product_category == 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: if query.status is not None:
stmt = stmt.where(Product.status == query.status) stmt = stmt.where(Product.status == query.status)
return stmt return stmt

View File

@ -57,6 +57,8 @@ def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段 # settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
if query.settlement_status is not None: if query.settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == query.settlement_status) 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 return stmt

View File

@ -217,6 +217,16 @@ def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
return int(session.execute(stmt).scalar_one()) 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: def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。""" """业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
perf_sq = _business_perf_subquery() perf_sq = _business_perf_subquery()

View File

@ -9,8 +9,9 @@
from sqlalchemy import Select, func, or_, select from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session 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.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)} 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)}
# ==================== 写入 ==================== # ==================== 写入 ====================

View 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
)

View File

@ -37,7 +37,7 @@ def get_role_codes(user: User) -> list[str]:
依赖 `User.user_roles` 的 `lazy="selectin"` 预加载(见模型定义), 依赖 `User.user_roles` 的 `lazy="selectin"` 预加载(见模型定义),
故此处遍历不会触发 N+1 查询。 故此处遍历不会触发 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: def touch_last_login(session: Session, user: User, moment: datetime) -> None:

View File

@ -56,6 +56,12 @@ class UserBrief(BaseModel):
username: str username: str
real_name: str | None = None real_name: str | None = None
roles: list[str] = Field(default_factory=list, description="角色编码列表") 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): class LoginOut(BaseModel):

View 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__)

View File

@ -107,6 +107,15 @@ class BusinessItemIn(BaseModel):
return _check_amount(value, "金额", AMOUNT_MAX) 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): class BusinessItemOut(BaseModel):
"""业务产品明细行响应。金额/数量一律 str。""" """业务产品明细行响应。金额/数量一律 str。"""
@ -117,7 +126,7 @@ class BusinessItemOut(BaseModel):
product_name: str product_name: str
spec_model: str | None = None spec_model: str | None = None
unit: str | None = None unit: str | None = None
product_category: str product_category: str | None = None
quantity: str quantity: str
sale_price: str | None = None sale_price: str | None = None
sale_amount: str sale_amount: str
@ -149,6 +158,12 @@ class BusinessOut(BaseModel):
total_received_amount: str | None = None total_received_amount: str | None = None
total_freight_amount: str | None = None total_freight_amount: str | None = None
remark: 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): class SecondaryAllocationIn(BaseModel):
@ -250,6 +265,8 @@ class BusinessCreateIn(BaseModel):
class BusinessUpdateIn(BaseModel): class BusinessUpdateIn(BaseModel):
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。""" """编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
model_config = ConfigDict(str_strip_whitespace=True) model_config = ConfigDict(str_strip_whitespace=True)
customer_id: int = Field(ge=1, description="客户ID") 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="负责营销人员筛选") owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选")
date_from: date | None = Field(default=None, description="业务日期起(含)") date_from: date | None = Field(default=None, description="业务日期起(含)")
date_to: 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): class GrossProfitRowOut(BaseModel):
"""毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。""" """毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。"""
item_id: int item_id: int
product_category: str product_category: str | None = None
product_name: str product_name: str
spec_model: str | None = None spec_model: str | None = None
quantity: str quantity: str
@ -335,7 +357,7 @@ class GrossProfitRowOut(BaseModel):
"""成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason""" """成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason"""
profit_amount: str | None = None profit_amount: str | None = None
profit_rate: str | None = None profit_rate: str | None = None
threshold: str threshold: str | None = None
converted_profit: str | None = None converted_profit: str | None = None
missing_reason: str | None = None missing_reason: str | None = None

View 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

View 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)

View 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)

View 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)

View File

@ -25,7 +25,7 @@ class CustomerOut(BaseModel):
contact_phone: str | None = None contact_phone: str | None = None
address: str | None = None address: str | None = None
industry: str | None = None industry: str | None = None
customer_type: str customer_type: str | None = None
owner_personnel_id: int | None = None owner_personnel_id: int | None = None
owner_personnel_name: str | None = None owner_personnel_name: str | None = None
remark: str | None = None remark: str | None = None
@ -104,7 +104,9 @@ class CustomerQuery(PageQuery):
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配" default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
) )
customer_type: str | None = Field(default=None, max_length=32, 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_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( order_by: Literal["id", "rating_score"] = Field(
default="id", description="排序键:id 建档序 / rating_score 评级总分" default="id", description="排序键:id 建档序 / rating_score 评级总分"
) )

View File

@ -9,8 +9,7 @@
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。 重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策); 待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级, 同步记录、读取开关和自动采集开关分别返回,不用当前开关覆盖历史采集结果。
不返回伪造的 0 条。
""" """
from datetime import date, datetime from datetime import date, datetime
@ -80,7 +79,7 @@ class TodoBlock(BaseModel):
pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list) pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list)
unassigned_receipt_total: int = 0 unassigned_receipt_total: int = 0
unassigned_receipts: list[ReceiptTodoItem] = Field(default_factory=list) 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 sync_failure_total: int = 0
@ -154,6 +153,17 @@ class ActivityPageOut(BaseModel):
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)") 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): class DashboardOut(BaseModel):
"""工作台三区块一次性响应(最近动态走独立分页端点)。""" """工作台三区块一次性响应(最近动态走独立分页端点)。"""
@ -161,3 +171,4 @@ class DashboardOut(BaseModel):
overview: OverviewBlock overview: OverviewBlock
todos: TodoBlock todos: TodoBlock
warnings: WarningBlock warnings: WarningBlock
sync: SyncBlock = Field(default_factory=SyncBlock)

View 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

View File

@ -191,8 +191,6 @@ class ParamPreviewIn(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def _check_dates(self) -> "ParamPreviewIn": def _check_dates(self) -> "ParamPreviewIn":
if self.receipt_date < self.ship_date:
raise ValueError("回款到账日不得早于发货日")
return self return self

View 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)

View File

@ -3,7 +3,7 @@
from datetime import date from datetime import date
from decimal import Decimal, InvalidOperation 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 from app.core.response import PageQuery
@ -46,6 +46,9 @@ class PersonnelOut(BaseModel):
manager_name: str | None = None manager_name: str | None = None
phone: str | None = None phone: str | None = None
hire_date: date | 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 is_probation: int = 0
salary_standard: str | None = None salary_standard: str | None = None
status: int = 1 status: int = 1
@ -53,6 +56,7 @@ class PersonnelOut(BaseModel):
class PersonnelDetailOut(PersonnelOut): class PersonnelDetailOut(PersonnelOut):
salary_history_json: list[dict] | None = None
"""人员详情:附区域与拓展计划,供编辑抽屉一次回显。""" """人员详情:附区域与拓展计划,供编辑抽屉一次回显。"""
regions: list[PersonnelRegionOut] = Field(default_factory=list) regions: list[PersonnelRegionOut] = Field(default_factory=list)
@ -60,6 +64,7 @@ class PersonnelDetailOut(PersonnelOut):
class PersonnelCreateIn(BaseModel): class PersonnelCreateIn(BaseModel):
salary_effective_date: date | None = None
"""新增人员。""" """新增人员。"""
model_config = ConfigDict(str_strip_whitespace=True) 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,空=无") manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无")
phone: str | None = Field(default=None, max_length=20, description="联系电话") phone: str | None = Field(default=None, max_length=20, description="联系电话")
hire_date: date | None = Field(default=None, description="入职日期,ISO 格式") 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否") is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否")
salary_standard: str | None = Field( salary_standard: str | None = Field(
default=None, description="月工资标准(元),字符串形式的数字" default=None, description="月工资标准(元),字符串形式的数字"
@ -100,6 +108,13 @@ class PersonnelCreateIn(BaseModel):
return value 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): class PersonnelUpdateIn(PersonnelCreateIn):
"""编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。""" """编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。"""
@ -108,6 +123,7 @@ class PersonnelStatusIn(BaseModel):
"""在职/离职切换。""" """在职/离职切换。"""
status: int = Field(ge=0, le=1, description="1在职 0离职") status: int = Field(ge=0, le=1, description="1在职 0离职")
effective_date: date | None = None
class RegionItemIn(BaseModel): class RegionItemIn(BaseModel):

View File

@ -23,11 +23,13 @@ class ProductOut(BaseModel):
unit: str | None = None unit: str | None = None
stock_quantity: str | None = None stock_quantity: str | None = None
sale_price: str | None = None sale_price: str | None = None
product_category: str product_category: str | None = None
status: int = 1 status: int = 1
remark: str | None = None remark: str | None = None
create_time: datetime create_time: datetime
update_time: datetime update_time: datetime
source_trade_json: dict | None = None
quantity_price_manual: int = 0
class ProductCreateIn(BaseModel): class ProductCreateIn(BaseModel):
@ -94,4 +96,17 @@ class ProductQuery(PageQuery):
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配") keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
product_category: str | None = Field(default=None, max_length=32, 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="状态筛选") 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