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
liuwenxinandClaude Code 1b074576fe fix(后端): 区分 T+ 连接文件的缺失与字段错误
原先 OSError、ValueError、ValidationError 统一归并为「不可用
或字段不正确」,文件不存在和字段写错无法区分,排障困难。
拆分为两条消息分别指示故障类别,仍不回显文件内容与路径。

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:58:27 +08:00
liuwenxinandClaude Code 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
liuwenxinandClaude Code 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
liuwenxinandClaude Code 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
liuwenxinandClaude Code 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
liuwenxinandClaude Code 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
liuwenxinandClaude Code 823d9f02ec docs: 补充文案约定与侧栏层级说明
- backend/README 新增「面向业务人员的文案」约定:param_registry 的
  description 是唯一显示源、枚举值在页面上必须翻译、报错要给下一步、
  init-dev.sql 种子必须与 registry 同源、模板层禁止出现开发者语汇
- frontend/README 增补侧栏层级口径、最近动态独立分页端点,以及分页的
  两个坑(v-loading 不要罩到分页条上、翻页失败必须退回实际显示页)
- backend/tests/README 记录本轮运行结果

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:23:14 +08:00
liuwenxinandClaude Code 678db0499d fix(frontend): 分页失败回滚到实际显示页
records 只在请求成功时更新,而分页器高亮绑的是 pageNum,失败不回滚就会
出现「高亮第 1 页、列表是第 2 页的数据」的永久错位,用户来回点才恢复。
- 用 shownPageNum / shownPageSize 记录实际显示的页,失败时 restoreShownPage
  退回该页
- 回滚本身也要防竞态:只有本次请求仍是最新请求(seq === requestSeq)才回滚,
  否则连点时旧请求失败会把用户刚点出的新页码覆盖掉,表现为页码自己跳回去
- 页码与条数在 await 之前取快照,避免等待期间被别的点击改动后记错「显示的页」

对外接口不变(load / search / onPageChange / onSizeChange / reloadAfterDelete),
现有调用方无需改动。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:22:19 +08:00
liuwenxinandClaude Code a00fca942d refactor(frontend): 侧栏层级与表单页版式调整
侧栏层级口径改版(视觉稿 v4.2):组标题与菜单项同字号 13px,层级改由
「折叠箭头 + 组间留白 + 子项缩进」表达,不靠字号/字重压过父级。
- 去掉组标题的靛蓝竖标与激活项左侧 2px 竖条(竖条只能落在侧栏内边缘、
  与白底悬空,是重复的第二个信号),激活只留白底浮起 + 靛蓝字
- 子项缩进 30px 收到 22px,图标父子统一 16px
- AppMenu 的 .app-menu 提供横向 10px 留白,BasicLayout 的 .app-aside__menu
  只写上下内距——两处都用 padding 简写会同权重靠注入顺序决胜,横向留白
  会被整条抹掉、条目贴到侧栏边缘

公司信息页字段自适应分列:卡片够宽时两字段并排,窄时折回单列;说明文字
紧贴控件下方,不再顶到卡片最右缘(宽屏下与输入框隔上千像素,视线关联断掉)。

设计稿同步升 v4.2。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:21:19 +08:00
liuwenxinandClaude Code 61965e0b60 feat(ui): 面向客户的文案中文化
系统由财务与营销人员日常使用,不是给开发者看的。本批把会渲染到页面上的
文本统一改成业务语言:
- param_registry 的 22 条参数说明重写为「管什么、钱怎么变、何时该调它」,
  去掉英文枚举名、需求编号、内部阶段号与精度约束
- 前端补 formatByCode:变更记录、版本历史、快照对比只给裸 value_text,
  需回目录按编码补 value_type 与 unit 才能翻译枚举,否则这些位置仍显示
  INCLUDE/FULL/1;编辑弹窗 BOOL 改为开启/关闭下拉
- 后端错误文案:模板报错用中文模板名并给出下一步,产品类别不再甩
  municipal_water / rail_transit
- 台账与预览落库的计提说明口语化(rate_note / strategy_note / 聚合行 note),
  去掉「基数开关=1」「#12 参数化」「9.8 方案公式」

分数原文 11/1125 与「聚合:20%+5%」不动——那是金额口径的可复核原文,
不是说明文案。init-dev.sql 种子 description 与 registry 同源。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:20:05 +08:00
liuwenxinandClaude Code 111ac5425a feat(customer): 客户列表展示等级徽标并统一等级不落库口径
评级计算器并入客户管理后,列表只有分数看不出档位,本批补上等级徽标:
- 抽出 utils/rating.ts 的 resolveGrade(按 sort_order 升序取首个
  min_score ≤ 总分的等级,单边判定避免区间边界歧义),列表页与弹窗共用
- 等级映射按需拉取并缓存在内存,等级配置改动后回调父页重拉,避免
  「同一分数在弹窗是 B、在列表是 C」的两处口径分歧
- 徽标色调按等级名首字母映射,自定义等级名落回中性灰(等级名可配置,
  不能靠写死枚举判语义色)
- 悬浮提示给出等级区间与评级时间

同时订正三处与实现不符的注释:rate_customer 实际只写 rating_score 与
rating_time,等级由分数按当前映射实时推导,三处却写成「后端判定并固化等级
名快照」,v13 迁移 docstring 还引用了并不存在的 v14(d5b8f2a46c71)。
纯注释改动,无逻辑变更。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:18:47 +08:00
liuwenxinandClaude Code 61fe5cca98 feat(dashboard): 最近动态改服务端分页
工作台「最近动态」原为聚合端点内嵌、固定截断前 20 条,操作日志变长后
既撑大响应又只能看到头部。改为独立分页端点:
- GET /api/v1/dashboard/activities,支持 pageNum/pageSize/module 筛选
- 模块筛选项与列表同源返回(全量模块名,不随翻页变化),前端不必再取一次全量
- 聚合端点去掉内嵌 activities,避免同一份数据两处查询

前端改用 usePagedList + 统一 AppPagination(每页 10/20/50),模块筛选由
本地过滤改为服务端筛选。集成测试 13 例(含翻页不重不漏 / 模块筛选 / 越权 403)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:15:09 +08:00
liuwenxin 32b7940067 feat(customer): 客户评级并入客户管理并落库
- customers 表新增 rating_score/rating_time(V13 迁移 + V10 SQL + init-dev 同步)
- 新端点 PUT /customers/{id}/rating:评分是档案属性,只存总分与评级时间(五项明细不落库)
- 列表排序 order_by=rating_score,未评分客户升序/降序都垫底,id desc 兜底稳定分页
- 前端 RatingCalculatorDialog 并入客户管理(评分列 + 评级计算按钮),删除独立页与路由
- 测试:评级保存/越界/排序垫底 3 例,test_customers.py 18 passed;schema 同步 216 passed
2026-10-23 10:59:33 +08:00
liuwenxinandClaude Code b9781f7d3f docs(design): 视觉稿升 v4.1 并新增表单页章节
承接 10-23 浏览器实测验收,把三处实测偏差与新增的表单页口径写回设计稿(代码为视觉权威,稿为描述):

- §03 新增「表格 · 行高」卡:单元格行高定 1.5(组件库默认 23px 会把单行数据行撑到 51px)
- §05 工作台:卡片内空态改为在剩余高度内垂直居中
- §09 月结:生命周期条四段圆点改为按阶段着色(草稿中性 / 待审批琥珀 / 已审定靛蓝 / 已驳回砖红,与选中态解耦),并补条右侧操作提示
- §10 新增「表单页(公司信息)」:两列版式(左 1fr + 右 480px 实景预览,≤1200px 折单列)、控件与说明同行(≥1600px 说明靠卡片右缘右对齐)、实景预览按真实登录页 1:1 复刻、独立的「生效范围」卡
- 封面与文件头注释同步 v4.1 变更说明

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 10:24:41 +08:00
liuwenxin 8e8bcaa5fb feat(frontend): 视觉规范 v4 全站落地与实测修复
token 补齐与 EP 形体基准、全站版式工具类(.page-head/.card 系/.filter-bar/.ys-pill 等)、布局与登录页、工作台 Bento 与图表主题、打印稿纸、全部业务页统一到全局类;StatusTag 改 pill、AppPagination 加 background。

浏览器实测修复:表格行高收到 1.5;月结生命周期条圆点按阶段着色并加操作提示;工作台空态垂直居中;状态/结果列宽按胶囊内容给足(96/108/120),消除 Element Plus 省略号伪『小点』。设计稿反向同步圆点着色与流程条提示。
2026-10-23 09:58:48 +08:00
liuwenxinandClaude Code 1f65913002 docs: 更新视觉方向预览页与部署组件需求说明
- 设计/视觉方向预览.html 同步当前视觉体系(品牌色、语义三色、菜单
  图标),作为前端视觉落地的对照稿
- 部署组件需求说明.docx 精简冗余章节

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:47:19 +08:00
liuwenxinandClaude Code e61800dcdc feat(frontend): 菜单图标与视觉设计系统落地
- 引入 @element-plus/icons-vue 官方图标库,菜单项按模块配图标,消除
  纯文字导航的辨识成本(新增依赖已评估:Element Plus 官方同源维护)
- AppMenu/BasicLayout/router 同步:图标渲染、菜单结构与路由 meta 对齐
- index.css 视觉系统化:品牌色、语义三色(ok/warn/danger 及其 wash 层次)、
  形体与材质(圆角、缓动、阴影、等宽字体)统一为 CSS 变量,并覆盖
  Element Plus 主色
- 登录页、业务页、结算页、工作台、审批页等视图随新变量适配

验证:pnpm type-check 与 pnpm build 均通过;后端接口契约未变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:45:54 +08:00
liuwenxinandClaude Code f88857e026 style(backend): ruff format 统一迁移脚本格式
存量迁移脚本未纳入格式化,与项目代码风格不一致(引号不统一、行宽未
收敛)。仅格式调整,无逻辑变更,不影响任何库的升级路径。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:44:29 +08:00
liuwenxinandClaude Code 5ece9f6dab refactor(backend): 仓储层散参数收敛为查询 DTO
仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数
传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的
约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery),
服务层与测试调用同步调整,行为不变。

- repositories 层查询方法签名改为 (session, query),分页与筛选条件
  由 Query 承载,_apply_filters 同步收口
- schemas 层补齐各模块 Query 模型(approval/business_role/report 等)
- services 层调用点同步改为传 Query
- tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、
  参数预览顺序稳定

验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:43:20 +08:00
liuwenxin c48d24ab6d merge: 合并 V12 审批版本号唯一键与 T+ 清单 v1.7 至 main
- V12:审批单 (order_type, ref_id, version_no) 唯一键 + 取号改锁读
- 审批详情展示快照采集时间
- T+ 对接清单升至 v1.7,清理过期版本文件;补充部署硬件需求
- 全量回归 897 passed
2026-10-22 13:42:25 +08:00
liuwenxin d0726487e9 merge: 合并 main 至 docs/tplus-field-mapping(V12 审批版本号批次收口前同步) 2026-10-22 13:32:30 +08:00
liuwenxinandClaude Code a84fe9b316 chore: 忽略嵌套目录下的 e2e 失败截图与临时文档目录
- `frontend/e2e/*.png` 改为 `frontend/e2e/**/*.png`:e2e 脚本按相对路径
  写 failure.png,在不同 CWD 下会落到 frontend/e2e/e2e/
- 忽略 .tmpdocx/(AI 生成文档的脚本与预览截图)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:57 +08:00
liuwenxinandClaude Code 94e2957359 docs: T+ 清单升至 v1.7 并清理过期版本文件
- 新增 docs/T+对接数据需求清单_v1.7_2026-10-20.xlsx(v1.6 可读性改造
  的进一步结构重构:结论总览首屏、业务列前置、技术列后置分区)
- 保留 v1.6 供对照;删除已被替代的 T+ 清单 2026-10-15/v1.4/v1.5、
  待澄清问题清单 2026-10-15(两份)、待确认事项排序表 2026-10-16
  (两份)与 2026-10-13 阶段报告(均有 10-19/10-20 后继文档)
- 新增部署硬件需求(txt 源文与 docx 说明)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:57 +08:00
liuwenxinandClaude Code ebc9595de2 feat(approval): 审批详情展示内容采集时间
- 快照单显示「内容采集时间(送审版本)」;live 单显示「打开详情时实时
  读取(非送审版本)」,让复核者明确看到的是哪个时点的内容
- live 分支不展示 new Date() 生成的时刻:渲染函数里的时间非响应式,
  且 toISOString 为 UTC 会差 8 小时

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:19 +08:00
liuwenxinandClaude Code 9964281c14 fix(approval): 版本号唯一键兜底 + 取号改当前读防并发重复
- V12 迁移:先重排存量重复 (order_type, ref_id) 分组的 version_no
  (V11 把存量一律置 1,而 V11 前重提是新建行),再加
  uk_approval_orders_order_type_ref_version;只重排真有重复的分组,
  避免改动前端已展示过的历史版本号
- _next_version 改锁读(select version_no ... FOR UPDATE,最大值在
  Python 侧算):请求级鉴权的普通 SELECT 已把 REPEATABLE READ 快照
  锚定在请求开始时刻,原 MAX 快照读在并发下会算出同一版本号;
  聚合 + FOR UPDATE 为 MySQL 明确禁止,故不锁聚合结果
- 编辑与月结提交路径把 1062 收敛为 400「提交冲突(版本号生成重复),
  请重试」,其余完整性错误原样上抛;submit_order 内不 catch/rollback
- 驳回清空方案的过时注释改为 V11 起的 append-only 语义(置历史不删行)
- init-dev.sql 同步唯一键与 alembic_version 7f4f7b918bc4,导出 V12 SQL
- 测试:集成 +5(锁读 SQL 形态、两会话快照回归、唯一键 1062、冲突 400、
  正常递增)+ 单元 +1;工作台假审批单改用不同 ref_id 适配唯一键
- 全量回归 897 passed;ruff 与前端 type-check 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:01 +08:00
liuwenxinandClaude Code 694d21d1fd test(e2e): 二次分配流程断言改为取实际方案并补审批内容校验
审批详情的送审内容断言不再硬编码参与人姓名(下拉取人员列表前两项,
未必是脚本刚建的 t_e2e 员):改为经业务详情接口取回实际保存的方案,
逐参与人断言姓名与比例出现在审批抽屉中。顺带修掉原脚本清理环节的
隐性缺陷——开关开启时业务有在途审批单,界面删除被拦截而脚本无断言,
数据从未真正清理;现脚本内显式关闭审批开关并保证结束还原为 1。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 14:25:39 +08:00
liuwenxinandClaude Code 4c32a556de feat(approval): 审批单内容快照与二次分配方案版本化
补齐 code-review 遗留 2 项:审批单此前只固化 ref_id/refer_no 指针,复核者看到
的是业务当前内容;二次分配方案在编辑替换与驳回清空时一律物理删除,方案事实
无处回溯。

- 审批单加 version_no 与 snapshot_json(信封 schema_version/order_type/
  captured_at/data);二次分配、月结、调整单三类提交均固化送审内容,开关关闭
  的自动通过路径同样写快照;详情按 snapshot/live/missing 三态回显,历史单
  只读回退当前内容并显式告警,不回填历史快照
- 二次分配方案行改 append-only 版本化(version_no/is_active,唯一键扩为
  business+version+personnel):编辑替换与驳回清空只置历史,核算与详情默认
  只取生效行,业务详情新增 allocation_history
- 送审内容未变不重提、方案未变不新增版本:判据统一为送审内容,两个
  version_no 语义解耦(方案版本=内容第几版,审批单版本=第几次送审)
- 审批单列表、工作台待办、审批超时预警三处 defer 快照列,避免整列拉取
- 月结提交取行加 for_update,防并发提交算出重复的审批单版本号
- 修复审批类型筛选上界 le=2(调整单无法筛选)、快照金额把空值写成 0.00
- V11 迁移三件套同步(Alembic / db/migration / init-dev.sql)

测试:单元 +16(方案内容比较 10、审批入参 6)、集成 +12;全量 891 passed。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 18:30:51 +08:00
liuwenxin 4c10475228 merge: 合并 T+ 字段映射草案与清单 v1.5 至 main 2026-10-20 10:57:09 +08:00
liuwenxinandClaude Code 2e0c64849b docs(tplus): 收敛 IT 待确认事项并更新催办清单
原 18 项收敛为 13 项 IT 待确认 + 1 组业务裁决,每项附字典候选字段与来源;同步更新待确认汇总第三节与文档待办。字段级证据见映射草案 v0.1。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:56:07 +08:00
liuwenxinandClaude Code 33b9b00bb0 docs(tplus): 新增 T+12.3 六类字段映射草案与需求清单 v1.5
全量解析 T+12.3 数据字典(1,457 页 / 931 表 / 21,019 字段,0 失败),按 v1.4 的 75 项逐项回填 T+ 候选字段、类型、枚举与系统目标列;必须项候选覆盖率 91.4%。v1.5 补齐 IT 收敛表(13 项 + 1 组业务裁决)与不覆盖字段 12 条,并修复 v1.4 单 sheet 与 21 项/#20 笔误。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:55:57 +08:00
533 changed files with 49725 additions and 6539 deletions

No files matched your search

+35
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
+25 -3
View File
@@ -1,5 +1,7 @@
# AI辅助目录(整个目录忽略)
ai-aux/
.ai/
.agents/
/AGENTS.md
# 依赖
node_modules/
@@ -45,13 +47,15 @@ htmlcov/
.mypy_cache/
.ruff_cache/
.tox/
# 端到端测试产出的截图
frontend/e2e/*.png
# 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD
# 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖)
frontend/e2e/**/*.png
# 临时文件
*.tmp
*.bak
*.cache
.tmp-processes*.txt
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
~$*
@@ -64,9 +68,27 @@ CLAUDE.md
backend/uploads/
# 数据库备份导出目录(⑨ 备份脚本产物)
db/backups/
# 客户发来的原始账套备份压缩包。
/db/*.zip
# 本地恢复与连通性排查脚本/结果,不作为业务交付物。
/db/restore_*.ps1
/db/restore_*.sql
/db/restore_result.txt
/db/probe*.ps1
/backend/probe_*_status.py
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
docs/**/*.rar
docs/T_12.3数据字典/
# macOS 压缩包残留元数据
__MACOSX/
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
.tmpdocx/
# 本轮原开发环境损坏后的独立恢复运行环境。
backend/.venv-tplus/
backend/.venv-workbench/
# 本地浏览器验收自动生成的快照
/.playwright-mcp/
/phase2-evidence-*/
+21 -2
View File
@@ -2,7 +2,7 @@
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
> 当前进度:登录鉴权与系统管理(用户/角色/操作日志/日志查询)已完成,前端具备应用布局框架。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
> 当前进度:正式工资闭环及同步运维自动化已完成本地开发验证,支持每周原始采集、有限重试、独立心跳和站内提示,并提供 Windows 托管脚本。辽宁兴晟达真实样本已在隔离库完成正式导入、核算、月结、报表与导出验收,整月实际工资仍须另行核定,见[真实样本业务闭环验收](docs/真实样本业务闭环验收_2026-11-03.md)。生产自动化默认关闭,尚未安装启用。审核能力保留但默认关闭,多原单更正仍单独跟踪。相关说明见 [T+ 本地恢复与同步开发手册](docs/T+本地恢复与同步开发手册.md) 和 [Windows 操作说明](scripts/windows/README.md)。
## 快速开始
@@ -70,7 +70,7 @@ pnpm dev
# 后端测试与代码检查
cd backend
uv run pytest tests/unit -q # 纯单元,无需容器,秒级
uv run pytest -q # 全部(集成部分需容器在跑)
python ../scripts/run_isolated_tests.py # 创建独立测试库运行集成,禁止指向业务库
uv run ruff check . # 代码检查
uv run ruff format . # 代码格式化
@@ -128,9 +128,28 @@ python e2e/system_management.py # 系统管理 13 项
## 相关文档
客户现场部署见 [客户现场部署与 T+ 连接说明](docs/客户现场部署与T+连接说明.md):按 Linux 容器、Windows Server 或 Linux 原生选择托管方式,连接客户既有 T+ SQL Server。生产 Compose 为 `docker/docker-compose.prod.yml`,T+ 恢复库编排仅用于本地开发。
| 文档 | 内容 |
| --- | --- |
| `backend/README.md` | 后端启动、分层约定、接口清单 |
| `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 |
| `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 |
| `docs/` | 需求文档与工资算法方案 |
## 正式工资闭环
- 在“角色管理”配置工资操作权限,在“用户管理”分配角色;账号自动继承权限,无需单独绑定工资制作人。每次实际经办人继续留痕。
- 应用新增迁移后,T+ 页面提供正式业务导入预览/执行;业务列表“正式核算”准备人工业务资料或核算已导入资料,台账可查看逐段依据。
- “生成月结快照”首次成功即锁定该月分配;审批退回、重新生成不解锁。历史已生成工资保留,差额计调整单生成当月。
- 后端接口说明、验证状态和已知边界见 `docs/正式工资闭环开发记录_2026-10-30.md`。旧引擎仅通过 `PAYROLL_ALLOW_LEGACY_CALCULATION` 显式启用作历史兼容,默认关闭,不用于最新规则验收。
- 集成回归使用 `scripts/run_isolated_tests.py`;原 `.venv` 不可用时,本机使用 `backend/.venv-workbench/Scripts/python.exe`。运行器默认清理本次新建的隔离库和账号,绝不删除原开发数据;需复查失败现场时加 `--keep`。
审核默认关闭:`PAYROLL_APPROVAL_ENABLED=false`,工资生成直接完成且不产生审批痕迹;如确需系统内复核,显式开启后沿用财务/领导审核和各类审批参数。此配置与旧核算引擎兼容开关独立。
## 权限管理
- “用户管理 → 分配角色”:选择角色并预览继承权限,支持多个角色叠加。
- “角色管理 → 配置权限”:按模块配置模板;停用角色即时撤销其授权。
- 管理员拥有全部功能权限;月份锁定、审核节点及源读取开关继续独立检查。
- 授权变更无需重新登录,后端逐请求校验;详情、只读提示、菜单与按钮使用统一规则。操作与接口说明见 [权限管理使用与开发记录](docs/权限管理使用与开发记录_2026-11-02.md)。
+50 -3
View File
@@ -17,6 +17,15 @@ REDIS_DB=0
# ===== 应用 =====
APP_ENV=dev
APP_SECRET_KEY=
# 运行监控:每 15 秒采集,分钟聚合保留 7 天;部署限单个应用进程
MONITOR_ENABLED=true
MONITOR_CPU_THRESHOLD=85
MONITOR_MEMORY_THRESHOLD=85
MONITOR_DISK_THRESHOLD=90
MONITOR_RESOURCE_DURATION_SECONDS=300
MONITOR_SLOW_REQUEST_MS=2000
MONITOR_ERROR_RATE_THRESHOLD=5
MONITOR_ERROR_MIN_REQUESTS=20
# JWT 有效期(分钟),规范要求 ≤120
JWT_EXPIRE_MINUTES=120
# 验证码有效期(秒),规范要求 300
@@ -26,6 +35,44 @@ LOGIN_FAIL_LIMIT=5
LOGIN_LOCK_MINUTES=15
# ===== T+ 对接(待畅捷通确认后填写)=====
T_PLUS_BASE_URL=
T_PLUS_APP_KEY=
T_PLUS_APP_SECRET=
T_PLUS_ENABLED=false
T_PLUS_HOST=127.0.0.1
T_PLUS_PORT=1433
T_PLUS_DATABASE=
T_PLUS_USER=
T_PLUS_PASSWORD=
# 仅用于已确认的开发测试库,默认禁止使用 sa。
T_PLUS_ALLOW_DEV_SA=false
# 仅在客户确认只能使用管理员账号时显式开启。
T_PLUS_ALLOW_ADMIN_ACCOUNT=false
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
T_PLUS_CONNECTION_FILE=
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
# 本地 PoC 可复用已安装的 SQL Server 驱动;不自动安装或升级客户驱动。
T_PLUS_ODBC_DRIVER=
# 默认启用传输加密。旧实例 TLS 不兼容时,仅隔离开发环境可显式设为 false,
# 记录原因并重新验收;程序不会自动降级。真实环境值后续按验收结果填写。
T_PLUS_ENCRYPT=true
# 默认校验服务器证书。仅隔离开发环境可显式设为 true,并记录原因和验收范围。
T_PLUS_TRUST_SERVER_CERTIFICATE=false
T_PLUS_CONNECT_TIMEOUT=10
T_PLUS_QUERY_TIMEOUT=120
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
T_PLUS_QUERY_DIR=
# 工资操作通过角色权限控制,无需配置全局制作人账号。
# 审核能力保留但默认关闭;开启后仍按各类审批参数及财务/领导节点执行
PAYROLL_APPROVAL_ENABLED=false
# ===== T+ 周期采集(默认关闭,仅采集原始数据,不自动生成工资)=====
T_PLUS_AUTOMATION_ENABLED=false
# 0=周一,6=周日;时段均为北京时间
T_PLUS_SCHEDULE_WEEKDAY=0
T_PLUS_SCHEDULE_TIME=02:00
# 托管启动器自动设置;发现标记后停止排队和领取新任务
T_PLUS_STOP_FILE=
# 仅允许本机 HTTP 健康检查地址;API 更换监听端口时同步修改。
T_PLUS_SUPERVISOR_HEALTH_URL=http://127.0.0.1:8000/api/health
# 可选上传文件根目录;留空使用 backend/uploads,隔离测试由运行器独立指定。
UPLOAD_DIR=
+43
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"]
+52 -1
View File
@@ -83,8 +83,9 @@ app/
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) |
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) |
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
@@ -139,6 +140,19 @@ app/
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
### T+ 本地只读同步与管理
T+ 数据库是 SQL Server 只读源库,业务库仍为 MySQL。Docker 恢复 PoC、备份检查和 D 盘目录约定见 `docs/T+本地恢复与同步开发手册.md`。
- 连接配置在 `backend/.env` 的 `T_PLUS_*` 变量中,默认关闭,不配置时不影响主业务启动。
- `uv run python -m app.cli tplus-stage --entity personnel` 按主键分页,将源行写入 `tplus_sync_records` staging,并记录批次和失败信息。水位表已预留,时间增量未启用,`--watermark` 会明确拒绝。
- T+ 驱动通过 `uv sync --extra tplus` 安装到应用环境,ODBC 驱动名称必须显式配置;交付兼容目标为 SQL Server 2008 R2,2022 副本不能代替同版本验收。
- 查询目录 `db/queries/tplus/` 随部署交付,或使用 `T_PLUS_QUERY_DIR` 指定受控位置;支持六类主表和三类明细。
- T+ 字典中的候选字段在实际备份核验和客户确认前,不得直接覆盖岗位、工资标准、归属、业务状态或核算状态。
- 管理 API `/api/v1/system/tplus` 全部限管理员:概况、批次/异常分页、按需报告、创建与失败重试。HTTP 只创建 QUEUED 任务;`uv run python -m app.cli tplus-worker --watch --connection-file <项目外JSON路径>` 独立处理。
- 核心文件:`services/tplus/task_service.py`(幂等/重试)、`worker.py`(执行)、`relationship_audit.py`(字典与关联)、`query_service.py`(安全输出)、`core/tplus_lock.py`(跨提交执行锁)。原 CLI 读取与 worker 共用执行锁。
- V15 / Alembic `e5f607a8b9c0` 增加范围、跳过数、请求键、父批次、请求人和核验结果。未启用时间增量与工资业务导入;完整运行步骤和异常处理见本地恢复手册。
## 常用命令
```bash
@@ -221,6 +235,22 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
### 审批快照与方案版本化(2026-10-20 定稿,改动前先读)
- **审批单带版本号与内容快照**:`approval_orders.version_no` 按 `(order_type, ref_id)` 从 1 递增,重提先撤回旧在途单、但**旧单快照不覆盖**;`snapshot_json` 统一信封 `{schema_version, order_type, captured_at, data}`,`data` 由 `approval_service.{allocation,settlement,adjustment}_content` 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。
- **两个 `version_no` 语义不同,别混用**:`business_secondary_allocations.version_no` = 方案内容第几版,**仅方案内容(参与人+比例)变化时 +1**;`approval_orders.version_no` = 同一对象第几次送审,**仅送审内容变化时 +1**。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。
- **送审内容未变不重提**:编辑业务时若**存在在途审批单**且其快照 `data` 与本次送审内容逐键相等(`approval_service.pending_matches_snapshot`,只比 `data` 不比 `captured_at`),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时**不新增方案版本行**(`business_service._plan_changed` 按「人员+比例」集合比较,与行序无关)。
- **快照的覆盖范围是「方案 + 钱 + 归属」**(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),**不含产品明细行**。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。
- **列表不加载快照列**:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 `defer(ApprovalOrder.snapshot_json)`(与操作日志列表排除 `request_data` 同一理由,参考 `operation_log_repository.list_logs`);详情走 `get_order` 按需读取。
- **详情内容来源三态**:`content_source=snapshot`(送审快照)/ `live`(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ `missing`(引用对象不存在且无快照)。**禁止回填历史单快照**——回填等于伪造提交时内容。
- **二次分配方案 append-only**:`business_secondary_allocations` 行不物理删除,编辑全量替换与驳回清空只把旧行 `is_active=0`;唯一键为 `(business_id, version_no, personnel_id)`,同一版本内参与人唯一、跨版本可重复。`business_repository.list_allocations(active_only=True)` 是核算与详情回显的唯一取数口径;业务详情 `allocation_history` 返回全部版本供回溯。
- **月结提交要锁行**:`settlement_service.submit` 取月结行必须 `for_update=True`——并发提交会让两次 `submit_order` 算出同一个审批单 `version_no`,送审次序失真。
- **审批单 `version_no` 有唯一键兜底(V12)**:`uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`——同一对象的第 N 次送审在库里有唯一一行。ORM 侧约束名必须与迁移 DDL 严格一致,否则 autogenerate 误判。
- **取版本号必须是「当前读」(`approval_service._next_version`)**,两个禁忌:(a) 不能用普通 `MAX` 查询——请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻,父行锁只串行化写入时刻、**不刷新快照**,MAX 会读到旧值;(b) 不能写成 `select(func.max(...)).with_for_update()`——MySQL 官方明确「Locking clauses are not permitted in queries with aggregate functions or GROUP BY」,聚合上的 FOR UPDATE 语义无保证。正解:锁读该 ref 的现有版本行(`select(version_no)...with_for_update()`),最大值 Python 侧算(同 `no_generator` 套路)。
- **版本号冲突的收敛口径**:`submit_order` 内不 catch、不 rollback(红线)。create 路径由外层编号重试循环整体重来(`business_service._NO_RETRY`);编辑/月结提交路径由审计上下文统一回滚,再把 1062 翻译为 400「提交冲突(版本号生成重复),请重试」(同 `param_service` 版本号冲突模板)。唯一键是最后一道防线,不是取号正确性的前提。
- **迁移前先修存量**:V11 把存量行 `version_no` 一律置 1,而 V11 前每次重提都新建行——同一 `(order_type, ref_id)` 存在多行 version_no=1,直接加唯一键必报 1062。V12 先重排「组内有重复」的分组(按 id 升序 1..n),其余分组不动(全量重排会改掉前端已展示过的历史版本号)。
- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。
### 工作台口径(M4 ⑧,改动前先读)
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
@@ -247,6 +277,17 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
### 面向业务人员的文案(2026-10-23 定稿,改文案前先读)
系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:
- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。
- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。
- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。
- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
@@ -256,3 +297,13 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
**`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。
**改了代码不生效** —— 启动时没加 `--reload`。
## 系统监控(2026-11-02)
新增管理员只读接口 `/api/v1/system/monitor/{overview,trends,interfaces,events,diagnostic}`,事件详情为 `/events/{id}`。后台独立线程每 15 秒采集,分钟聚合保留 7 天;配置见 `.env.example` 的 `MONITOR_*`,新增依赖 `psutil`。部署限单个后端应用进程,按项目流程应用 V23 迁移后启动。异常通过 `X-Request-ID` 与脱敏服务端日志关联,`/api/health` 原契约保持不变。
详细采集口径、故障边界、排查步骤和接口见 [系统监控运维说明](../docs/系统监控运维说明.md)。
## 角色授权(2026-11-02)
系统权限统一为“角色配置权限 → 用户分配角色 → 用户继承权限”。V26(`26e7f8a9b0c1`)停用旧个人覆盖和全局制作人入口;工资核算、生成、调整分别检查角色权限,月份实际经办人、生成锁和历史留痕继续保留。接口与迁移边界见 [角色授权使用说明](../docs/角色授权使用说明.md)。
@@ -0,0 +1,27 @@
"""V17 对齐 V16 已应用工作区约束的完整表名约定。"""
from alembic import op
revision: str = "07b8c9d0e1f2"
down_revision: str | None = "f6a708b9c0d1"
branch_labels = None
depends_on = None
RENAMES = (
("tplus_source_heads", "uk_tplus_heads_source_entity_id", "uk_tplus_source_heads_source_entity_id"),
("tplus_source_heads", "idx_tplus_heads_batch_entity", "idx_tplus_source_heads_batch_entity"),
("tplus_source_links", "uk_tplus_links_source_entity_id", "uk_tplus_source_links_source_entity_id"),
("tplus_source_links", "uk_tplus_links_entity_target", "uk_tplus_source_links_entity_target"),
)
def upgrade():
"""只重命名固定索引,不改变任何数据。"""
for table, old, new in RENAMES:
op.execute(f"ALTER TABLE {table} RENAME INDEX {old} TO {new}")
def downgrade():
"""恢复 V16 固定索引名。"""
for table, old, new in reversed(RENAMES):
op.execute(f"ALTER TABLE {table} RENAME INDEX {new} TO {old}")
@@ -0,0 +1,31 @@
"""正式工资输入、FIFO明细和月份生成锁。"""
from alembic import op
revision: str = "18c9d0e1f2a3"
down_revision: str | None = "07b8c9d0e1f2"
branch_labels = None
depends_on = None
def upgrade():
op.execute('CREATE INDEX idx_performance_ledgers_receipt_id ON performance_ledgers (receipt_id)')
op.execute("ALTER TABLE performance_ledgers ADD COLUMN allocation_key VARCHAR(64) NOT NULL DEFAULT '' COMMENT '正式FIFO事件键;历史单笔核算为空串'")
op.execute('ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version_personnel')
op.execute('ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id, allocation_key)')
op.execute("CREATE TABLE payroll_months (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n month VARCHAR(7) NOT NULL COMMENT '工资月份', \n maker_id BIGINT UNSIGNED NOT NULL COMMENT '实际工资制作人账号ID', \n maker_name VARCHAR(64) NOT NULL COMMENT '制作人用户名快照', \n generated_at DATETIME COMMENT '首次成功生成时间(不可解锁)', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_months PRIMARY KEY (id), \n CONSTRAINT uk_payroll_months_month UNIQUE (month)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='工资月份控制表(首次生成即锁定分配,审批不解锁)' COLLATE utf8mb4_general_ci")
op.execute("CREATE TABLE payroll_inputs (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', \n version INTEGER NOT NULL COMMENT '输入版本', \n source_database VARCHAR(128) NOT NULL COMMENT '源账套或人工来源', \n group_key VARCHAR(128) NOT NULL COMMENT '独立业务平账组', \n fingerprint VARCHAR(64) NOT NULL COMMENT '来源及事实指纹', \n data_json JSON NOT NULL COMMENT '经核实的源数据及人工事实快照', \n requested_by BIGINT UNSIGNED NOT NULL COMMENT '录入人账号ID', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_inputs PRIMARY KEY (id), \n CONSTRAINT uk_payroll_inputs_business_version UNIQUE (business_id, version)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式工资输入版本表' COLLATE utf8mb4_general_ci")
op.execute('CREATE INDEX idx_payroll_inputs_source_group ON payroll_inputs (source_database, group_key)')
op.execute("CREATE TABLE payroll_allocations (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n ledger_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式台账ID', \n business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', \n line_no INTEGER NOT NULL COMMENT '本台账稳定明细序号', \n input_id BIGINT UNSIGNED NOT NULL COMMENT '所用输入版本ID', \n amount NUMERIC(14, 2) NOT NULL COMMENT '分配回款金额', \n data_json JSON NOT NULL COMMENT '单据、成本、归属、参数和逐段计算依据', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_allocations PRIMARY KEY (id), \n CONSTRAINT uk_payroll_allocations_ledger_line UNIQUE (ledger_id, line_no)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式FIFO分配明细表' COLLATE utf8mb4_general_ci")
op.execute('CREATE INDEX idx_payroll_allocations_business_id ON payroll_allocations (business_id)')
def downgrade():
bind = op.get_bind()
if bind.exec_driver_sql("SELECT COUNT(*) FROM payroll_inputs").scalar() or bind.exec_driver_sql("SELECT COUNT(*) FROM payroll_allocations").scalar():
raise RuntimeError("已有正式核算输入或分配记录,拒绝破坏性回退;请先备份并使用应用回退方案")
op.execute('ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version_personnel')
op.execute("ALTER TABLE performance_ledgers DROP COLUMN allocation_key")
op.execute("ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id)")
op.execute("DROP INDEX idx_performance_ledgers_receipt_id ON performance_ledgers")
op.drop_table('payroll_allocations')
op.drop_table('payroll_inputs')
op.drop_table('payroll_months')
@@ -0,0 +1,18 @@
"""分配调整的原台账与业务分管归属。"""
from alembic import op
revision: str = "19d0e1f2a3b4"
down_revision: str | None = "18c9d0e1f2a3"
branch_labels = None
depends_on = None
def upgrade():
op.execute("ALTER TABLE adjustment_orders ADD COLUMN adjustment_type VARCHAR(32) NOT NULL DEFAULT 'GENERAL' COMMENT '调整类型:GENERAL普通 ALLOCATION分配差额'")
op.execute("ALTER TABLE adjustment_orders ADD COLUMN source_ledger_id BIGINT UNSIGNED NULL COMMENT '分配差额对应的原台账ID'")
op.execute("ALTER TABLE adjustment_orders ADD COLUMN attribution_json JSON NULL COMMENT '原台账业务与分管归属快照'")
def downgrade():
if op.get_bind().exec_driver_sql("SELECT COUNT(*) FROM adjustment_orders WHERE source_ledger_id IS NOT NULL").scalar():
raise RuntimeError("已有分配调整依据,拒绝删除归属信息;请使用应用版本回退")
op.drop_column("adjustment_orders", "attribution_json")
op.drop_column("adjustment_orders", "source_ledger_id")
op.drop_column("adjustment_orders", "adjustment_type")
@@ -0,0 +1,18 @@
"""自动采集周期、重试与心跳。"""
from alembic import op
revision: str = "20e1f2a3b4c5"
down_revision: str | None = "19d0e1f2a3b4"
branch_labels = None
depends_on = None
def upgrade():
op.execute("CREATE TABLE tplus_automation_states (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n source_key VARCHAR(64) NOT NULL COMMENT '源连接身份摘要(不含凭据)', \n source_database VARCHAR(128) NOT NULL COMMENT '源账套', \n scheduled_period VARCHAR(16) COMMENT '最近已排队ISO周', \n root_batch_no VARCHAR(32) COMMENT '本周期原始采集批次', \n current_batch_no VARCHAR(32) COMMENT '当前原始或重试批次', \n retry_attempts INTEGER NOT NULL COMMENT '本周期自动重试次数' DEFAULT '0', \n next_retry_at DATETIME COMMENT '下一次自动重试时间', \n last_success_at DATETIME COMMENT '最近成功采集时间', \n maintenance_at DATETIME COMMENT '最近周期检查时间', \n heartbeat_at DATETIME COMMENT '独立进程心跳时间', \n worker_owner VARCHAR(36) COMMENT '当前持锁进程身份', \n worker_phase VARCHAR(16) NOT NULL COMMENT '进程状态:IDLE/RUNNING/STOPPED' DEFAULT '''STOPPED''', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_tplus_automation_states PRIMARY KEY (id), \n CONSTRAINT uk_tplus_automation_states_source_key UNIQUE (source_key)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+自动采集周期与进程状态(不自动导入正式业务)' COLLATE utf8mb4_general_ci")
op.execute("ALTER TABLE tplus_sync_batches ADD COLUMN trigger_type VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '触发来源:MANUAL/SCHEDULED/AUTO_RETRY'")
op.execute("ALTER TABLE tplus_sync_batches ADD COLUMN automatic_attempt INTEGER NOT NULL DEFAULT 0 COMMENT '自动重试序号(0为原始任务)'")
def downgrade():
if op.get_bind().exec_driver_sql("SELECT COUNT(*) FROM tplus_automation_states").scalar():
raise RuntimeError("存在调度状态,请先停用并备份后采用应用回退,不删除运行依据")
op.drop_column("tplus_sync_batches", "automatic_attempt")
op.drop_column("tplus_sync_batches", "trigger_type")
op.drop_table("tplus_automation_states")
@@ -0,0 +1,17 @@
"""修正进程状态默认值的多余引号,保留已应用V20。"""
from alembic import op
revision: str = "21f2a3b4c5d6"
down_revision: str | None = "20e1f2a3b4c5"
branch_labels = None
depends_on = None
def upgrade():
op.execute("ALTER TABLE tplus_automation_states ALTER COLUMN worker_phase SET DEFAULT 'STOPPED'")
op.execute("UPDATE tplus_automation_states SET worker_phase='STOPPED' WHERE worker_phase='''STOPPED'''")
def downgrade():
op.execute("ALTER TABLE tplus_automation_states ALTER COLUMN worker_phase SET DEFAULT '''STOPPED'''")
@@ -0,0 +1,47 @@
"""V22 完整角色及个人授权;目录与模板固化于此版本。"""
from alembic import op
import sqlalchemy as sa
revision: str = "22a3b4c5d6e7"
down_revision: str | None = "21f2a3b4c5d6"
branch_labels = None
depends_on = None
DDL = ["CREATE TABLE authorization_settings (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '单例主键ID' AUTO_INCREMENT, \n\tmaker_user_id BIGINT UNSIGNED COMMENT '现任工资制作人账号ID', \n\trevision INTEGER NOT NULL COMMENT '全局授权版本' DEFAULT '1', \n\tupdated_by BIGINT UNSIGNED COMMENT '最近修改人ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_authorization_settings PRIMARY KEY (id), \n\tCONSTRAINT fk_authorization_settings_maker_user_id FOREIGN KEY(maker_user_id) REFERENCES users (id) ON DELETE RESTRICT, \n\tCONSTRAINT fk_authorization_settings_updated_by FOREIGN KEY(updated_by) REFERENCES users (id) ON DELETE SET NULL\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='授权设置表(单例与并发修改锁)' COLLATE utf8mb4_general_ci;", "CREATE TABLE user_permission_overrides (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tuser_id BIGINT UNSIGNED NOT NULL COMMENT '系统用户ID', \n\tpermission_id BIGINT UNSIGNED NOT NULL COMMENT '权限ID', \n\teffect VARCHAR(8) NOT NULL COMMENT '个人覆盖:ALLOW允许 DENY禁止', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_user_permission_overrides PRIMARY KEY (id), \n\tCONSTRAINT uk_user_permission_overrides_user_permission UNIQUE (user_id, permission_id), \n\tCONSTRAINT fk_user_permission_overrides_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE, \n\tCONSTRAINT fk_user_permission_overrides_permission_id FOREIGN KEY(permission_id) REFERENCES permissions (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='用户权限覆盖表(个人禁止优先)' COLLATE utf8mb4_general_ci;", 'CREATE INDEX idx_user_permission_overrides_permission_id ON user_permission_overrides (permission_id);']
PERMISSIONS = [('dashboard:view', '首页:查看', 1, None, 1), ('system:users:view', '用户管理:查看', 1, None, 2), ('system:users:create', '用户管理:新增', 2, 'system:users:view', 3), ('system:users:update', '用户管理:编辑', 2, 'system:users:view', 4), ('system:users:delete', '用户管理:删除', 2, 'system:users:view', 5), ('system:users:status', '用户管理:变更状态', 2, 'system:users:view', 6), ('system:users:password', '用户管理:重置密码', 2, 'system:users:view', 7), ('system:roles:view', '角色管理:查看', 1, None, 8), ('system:roles:create', '角色管理:新增', 2, 'system:roles:view', 9), ('system:roles:update', '角色管理:编辑', 2, 'system:roles:view', 10), ('system:roles:delete', '角色管理:删除', 2, 'system:roles:view', 11), ('system:roles:status', '角色管理:变更状态', 2, 'system:roles:view', 12), ('system:authorization:view', '权限与工资制作人:查看', 1, None, 13), ('system:authorization:update', '权限与工资制作人:编辑', 2, 'system:authorization:view', 14), ('system:business_roles:view', '岗位角色:查看', 1, None, 15), ('system:business_roles:create', '岗位角色:新增', 2, 'system:business_roles:view', 16), ('system:business_roles:update', '岗位角色:编辑', 2, 'system:business_roles:view', 17), ('system:business_roles:delete', '岗位角色:删除', 2, 'system:business_roles:view', 18), ('system:business_roles:status', '岗位角色:变更状态', 2, 'system:business_roles:view', 19), ('system:params:view', '计提参数:查看', 1, None, 20), ('system:params:update', '计提参数:编辑', 2, 'system:params:view', 21), ('system:params:withdraw', '计提参数:撤回参数', 2, 'system:params:view', 22), ('system:params:export', '计提参数:导出', 2, 'system:params:view', 23), ('system:params:preview', '计提参数:预览', 2, 'system:params:view', 24), ('system:settings:view', '公司信息:查看', 1, None, 25), ('system:settings:update', '公司信息:编辑', 2, 'system:settings:view', 26), ('system:logs:view', '日志查询:查看', 1, None, 27), ('base:personnel:view', '人员管理:查看', 1, None, 28), ('base:personnel:create', '人员管理:新增', 2, 'base:personnel:view', 29), ('base:personnel:update', '人员管理:编辑', 2, 'base:personnel:view', 30), ('base:personnel:delete', '人员管理:删除', 2, 'base:personnel:view', 31), ('base:personnel:status', '人员管理:变更状态', 2, 'base:personnel:view', 32), ('base:personnel:regions', '人员管理:维护区域', 2, 'base:personnel:view', 33), ('base:personnel:plans', '人员管理:维护计划', 2, 'base:personnel:view', 34), ('base:products:view', '产品管理:查看', 1, None, 35), ('base:products:create', '产品管理:新增', 2, 'base:products:view', 36), ('base:products:update', '产品管理:编辑', 2, 'base:products:view', 37), ('base:products:delete', '产品管理:删除', 2, 'base:products:view', 38), ('base:products:status', '产品管理:变更状态', 2, 'base:products:view', 39), ('base:customers:view', '客户管理:查看', 1, None, 40), ('base:customers:create', '客户管理:新增', 2, 'base:customers:view', 41), ('base:customers:update', '客户管理:编辑', 2, 'base:customers:view', 42), ('base:customers:delete', '客户管理:删除', 2, 'base:customers:view', 43), ('base:customers:rating', '客户管理:客户评级', 2, 'base:customers:view', 44), ('base:customers:ownership', '客户管理:变更归属', 2, 'base:customers:view', 45), ('business:businesses:view', '业务管理:查看', 1, None, 46), ('business:businesses:create', '业务管理:新增', 2, 'business:businesses:view', 47), ('business:businesses:update', '业务管理:编辑', 2, 'business:businesses:view', 48), ('business:businesses:delete', '业务管理:删除', 2, 'business:businesses:view', 49), ('business:businesses:status', '业务管理:变更状态', 2, 'business:businesses:view', 50), ('business:businesses:ownership', '业务管理:变更归属', 2, 'business:businesses:view', 51), ('business:shipments:view', '发货管理:查看', 1, None, 52), ('business:shipments:create', '发货管理:新增', 2, 'business:shipments:view', 53), ('business:shipments:update', '发货管理:编辑', 2, 'business:shipments:view', 54), ('business:shipments:delete', '发货管理:删除', 2, 'business:shipments:view', 55), ('business:receipts:view', '回款管理:查看', 1, None, 56), ('business:receipts:create', '回款管理:新增', 2, 'business:receipts:view', 57), ('business:receipts:update', '回款管理:编辑', 2, 'business:receipts:view', 58), ('business:receipts:delete', '回款管理:删除', 2, 'business:receipts:view', 59), ('business:ratings:view', '评级标准:查看', 1, None, 60), ('business:ratings:create', '评级标准:新增', 2, 'business:ratings:view', 61), ('business:ratings:update', '评级标准Line truncated
DEFAULTS = {'admin': ['adjustments:create', 'adjustments:view', 'approvals:finance', 'approvals:leader', 'approvals:view', 'base:customers:create', 'base:customers:delete', 'base:customers:ownership', 'base:customers:rating', 'base:customers:update', 'base:customers:view', 'base:personnel:create', 'base:personnel:delete', 'base:personnel:plans', 'base:personnel:regions', 'base:personnel:status', 'base:personnel:update', 'base:personnel:view', 'base:products:create', 'base:products:delete', 'base:products:status', 'base:products:update', 'base:products:view', 'business:businesses:create', 'business:businesses:delete', 'business:businesses:ownership', 'business:businesses:status', 'business:businesses:update', 'business:businesses:view', 'business:ratings:create', 'business:ratings:delete', 'business:ratings:update', 'business:ratings:view', 'business:receipts:create', 'business:receipts:delete', 'business:receipts:update', 'business:receipts:view', 'business:shipments:create', 'business:shipments:delete', 'business:shipments:update', 'business:shipments:view', 'dashboard:view', 'payroll:allocation', 'payroll:calculate', 'payroll:prepare', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:generate', 'settlements:submit', 'settlements:view', 'system:authorization:update', 'system:authorization:view', 'system:business_roles:create', 'system:business_roles:delete', 'system:business_roles:status', 'system:business_roles:update', 'system:business_roles:view', 'system:logs:view', 'system:params:export', 'system:params:preview', 'system:params:update', 'system:params:view', 'system:params:withdraw', 'system:roles:create', 'system:roles:delete', 'system:roles:status', 'system:roles:update', 'system:roles:view', 'system:settings:update', 'system:settings:view', 'system:users:create', 'system:users:delete', 'system:users:password', 'system:users:status', 'system:users:update', 'system:users:view', 'tplus:collect', 'tplus:export', 'tplus:facts', 'tplus:import', 'tplus:preview', 'tplus:retry', 'tplus:view'], 'user': ['base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view'], 'marketing_ops': ['base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view'], 'finance': ['adjustments:view', 'approvals:finance', 'approvals:view', 'base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view', 'dashboard:view', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:view', 'system:params:preview', 'system:params:view'], 'leader': ['adjustments:view', 'approvals:leader', 'approvals:view', 'base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view', 'dashboard:view', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:view', 'system:params:preview', 'system:params:view'], 'payroll_maker': ['adjustments:create', 'adjustments:view', 'approvals:view', 'base:customers:create', 'base:customers:delete', 'base:customers:ownership', 'base:customers:rating', 'base:customers:update', 'base:customers:view', 'base:personnel:create', 'base:personnel:delete', 'base:personnel:plans', 'base:personnel:regions', 'base:personnel:status', 'base:personnel:update', 'base:personnel:view', 'base:products:create', 'base:products:delete', 'base:products:status', 'base:products:update', 'base:products:view', 'business:businesses:create', 'business:businesses:delete', 'business:businesses:ownership', 'business:businesses:status', 'business:businesses:update', 'business:businesses:view', 'business:ratings:create', 'business:ratings:delete', 'business:ratings:update', 'business:ratings:view', 'business:receipts:create', 'business:receipts:delete', 'business:receipts:update', 'business:receipts:view', 'business:shipments:create', 'business:shipments:delete', 'business:shipments:update', 'business:shipments:view', 'payroll:allocation', 'payroll:calculate', 'payroll:prepare', 'payroll:view', 'reports:business:view', 'reports:department:view', 'reports:payments:view', 'reports:personal:view', 'settlements:generate', 'settlements:submit', 'settlements:view', 'tplus:cLine truncated
def upgrade():
bind=op.get_bind()
for sql in DDL:
op.execute(sql)
for code,name,kind,_parent,order in PERMISSIONS:
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES (:code,:name,:kind,:order,1)"),{"code": code,"name": name,"kind": kind,"order": order})
for code,_,_,parent,_ in PERMISSIONS:
if parent:
bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code=:parent SET p.parent_id=parent.id WHERE p.perm_code=:code"),{"parent": parent,"code": code})
bind.execute(sa.text("INSERT INTO roles (role_code,role_name,description,sort_order,status) VALUES ('payroll_maker','工资制作人','指定制作人还需在权限设置中绑定,角色本身不能绕过月份锁',5,1)"))
for role,codes in DEFAULTS.items():
for code in codes:
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code=:role"),{"code": code,"role": role})
from app.core.config import get_settings
maker=get_settings().payroll_maker_user_id
if maker is not None and not bind.execute(sa.text("SELECT id FROM users WHERE id=:id AND status=1"),{"id": maker}).scalar():
raise RuntimeError("旧工资制作人账号无效,请修正配置后重试迁移")
bind.execute(sa.text("INSERT INTO authorization_settings (id,maker_user_id,revision) VALUES (1,:maker,1)"),{"maker": maker})
if maker is not None:
bind.execute(sa.text("INSERT INTO user_roles (user_id,role_id) SELECT :maker,id FROM roles WHERE role_code='payroll_maker'"),{"maker": maker})
def downgrade():
bind=op.get_bind()
if bind.execute(sa.text("SELECT revision FROM authorization_settings WHERE id=1")).scalar()!=1 or bind.execute(sa.text("SELECT COUNT(*) FROM user_permission_overrides")).scalar():
raise RuntimeError("已发生授权修改,拒绝删除授权依据;请保留数据并回退应用版本")
op.drop_table('user_permission_overrides')
op.drop_table('authorization_settings')
codes=[row[0] for row in PERMISSIONS]
for code in codes:
bind.execute(sa.text("DELETE rp FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE p.perm_code=:code"),{"code": code})
bind.execute(sa.text("DELETE ur FROM user_roles ur JOIN roles r ON r.id=ur.role_id WHERE r.role_code='payroll_maker'"))
bind.execute(sa.text("DELETE FROM roles WHERE role_code='payroll_maker'"))
for code in reversed(codes):
bind.execute(sa.text("DELETE FROM permissions WHERE perm_code=:code"),{"code": code})
@@ -0,0 +1,39 @@
"""新增运行监控快照、接口聚合与诊断事件。"""
from alembic import op
from sqlalchemy import text
revision: str = "23b4c5d6e7f8"
down_revision: str | None = "22a3b4c5d6e7"
branch_labels = None
depends_on = None
DDL = [
"CREATE TABLE monitor_snapshots (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tminute DATETIME NOT NULL COMMENT '采集分钟,北京时间', \n\tsample_count INTEGER NOT NULL COMMENT '实际采样次数', \n\tresources JSON NOT NULL COMMENT '资源均值与采集范围,不含配置', \n\trequests JSON NOT NULL COMMENT '请求汇总与固定耗时直方图', \n\tCONSTRAINT pk_monitor_snapshots PRIMARY KEY (id), \n\tCONSTRAINT uk_monitor_snapshots_instance_minute UNIQUE (instance_id, minute)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='分钟级运行监控快照' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_monitor_snapshots_minute ON monitor_snapshots (minute)",
"CREATE TABLE monitor_request_minutes (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tminute DATETIME NOT NULL COMMENT '请求分钟,北京时间', \n\tmethod VARCHAR(8) NOT NULL COMMENT 'HTTP方法,固定集合', \n\troute VARCHAR(256) NOT NULL COMMENT '路由模板,不含原始地址及参数', \n\tmetrics JSON NOT NULL COMMENT '请求数、状态分类与固定直方图', \n\tCONSTRAINT pk_monitor_request_minutes PRIMARY KEY (id), \n\tCONSTRAINT uk_monitor_request_minutes_endpoint UNIQUE (instance_id, minute, method, route)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='接口分钟级运行统计' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_monitor_request_minutes_minute ON monitor_request_minutes (minute)",
"CREATE TABLE monitor_events (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tkind VARCHAR(24) NOT NULL COMMENT '事件类型,固定集合', \n\tcode VARCHAR(64) NOT NULL COMMENT '稳定错误码', \n\tcomponent VARCHAR(128) NOT NULL COMMENT '所属组件或日志模块', \n\tlevel VARCHAR(16) NOT NULL COMMENT '异常级别', \n\tmessage VARCHAR(256) NOT NULL COMMENT '通用说明,不保存内部异常文本', \n\tfingerprint VARCHAR(64) COMMENT '同类健康异常合并键', \n\trequest_id VARCHAR(32) COMMENT '请求关联编号', \n\tmethod VARCHAR(8) COMMENT 'HTTP方法', \n\troute VARCHAR(256) COMMENT '路由模板', \n\tstatus_code INTEGER COMMENT 'HTTP状态码', \n\tduration_ms FLOAT COMMENT '请求耗时,毫秒', \n\tbatch_no VARCHAR(32) COMMENT '相关同步批次编号', \n\tfirst_seen DATETIME NOT NULL COMMENT '首次发生时间', \n\tlast_seen DATETIME NOT NULL COMMENT '最近发生时间', \n\toccurrences INTEGER NOT NULL COMMENT '发生或持续采样次数', \n\tresolved_at DATETIME COMMENT '恢复时间,仅用于健康异常', \n\tCONSTRAINT pk_monitor_events PRIMARY KEY (id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='脱敏诊断事件与异常恢复记录' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_monitor_events_fingerprint ON monitor_events (fingerprint, resolved_at)",
"CREATE INDEX idx_monitor_events_last_seen ON monitor_events (last_seen)",
"CREATE INDEX idx_monitor_events_request_id ON monitor_events (request_id)",
]
def upgrade():
for statement in DDL:
op.execute(text(statement))
op.execute(text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:monitor:view','系统监控:查看',1,100,1)"))
op.execute(text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:monitor:view' WHERE r.role_code='admin'"))
def downgrade():
# 应用回滚可保留新增监控表;有历史数据时拒绝隐式删除。
bind = op.get_bind()
for table in ("monitor_events", "monitor_request_minutes", "monitor_snapshots"):
if bind.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar():
raise RuntimeError("存在监控历史,拒绝自动删除;请回退应用并保留新增表")
for table in ("monitor_events", "monitor_request_minutes", "monitor_snapshots"):
op.drop_table(table)
bind.execute(text("DELETE rp FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE p.perm_code='system:monitor:view'"))
bind.execute(text("DELETE FROM permissions WHERE perm_code='system:monitor:view'"))
@@ -0,0 +1,152 @@
"""V24 需求补齐:试用期台账、产品来源及分产品系数参数。"""
import sqlalchemy as sa
from alembic import op
revision = "24c5d6e7f8a9"
down_revision = "23b4c5d6e7f8"
branch_labels = None
depends_on = None
PROBATION_DDL = "CREATE TABLE probation_deferrals (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n ledger_id BIGINT UNSIGNED NOT NULL COMMENT '原核算台账版本ID', \n personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', \n year VARCHAR(4) NOT NULL COMMENT '原计提年度', \n month VARCHAR(7) NOT NULL COMMENT '本次计提或应付变化月份', \n accrued NUMERIC(14, 2) NOT NULL COMMENT '本次试用期计提额,可负', \n eligible NUMERIC(14, 2) NOT NULL COMMENT '满足起付及权重的本次应付差额,可负', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n CONSTRAINT pk_probation_deferrals PRIMARY KEY (id), \n CONSTRAINT uk_probation_deferrals_ledger_id UNIQUE (ledger_id), \n CONSTRAINT fk_probation_deferrals_ledger_id FOREIGN KEY(ledger_id) REFERENCES performance_ledgers (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期缓发台账(每个正式台账版本一条)' COLLATE utf8mb4_general_ci;\n\nCREATE INDEX idx_probation_deferrals_personnel_id_year ON probation_deferrals (personnel_id, year);\n\nCREATE TABLE probation_payments (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n settlement_id BIGINT UNSIGNED COMMENT '月结ID(当月调整单结清时为空)', \n adjustment_order_id BIGINT UNSIGNED COMMENT '已生成工资后的结清调整单ID', \n personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', \n year VARCHAR(4) NOT NULL COMMENT '原累计年度', \n month VARCHAR(7) NOT NULL COMMENT '本次实际支付月份', \n amount NUMERIC(14, 2) NOT NULL COMMENT '本次补发或扣回金额', \n snapshot_json JSON NOT NULL COMMENT '计提、起付权重、门槛及历史补发依据', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n CONSTRAINT pk_probation_payments PRIMARY KEY (id), \n CONSTRAINT uk_probation_payments_settlement_person_year UNIQUE (settlement_id, personnel_id, year), \n CONSTRAINT uk_probation_payments_adjustment_order_id UNIQUE (adjustment_order_id), \n CONSTRAINT fk_probation_payments_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE, \n CONSTRAINT fk_probation_payments_adjustment_order_id FOREIGN KEY(adjustment_order_id) REFERENCES adjustment_orders (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期年终及后续结清记录(工资生成事务内固化)' COLLATE utf8mb4_general_ci;\n\nCREATE INDEX idx_probation_payments_personnel_id_year ON probation_payments (personnel_id, year);"
def upgrade():
op.add_column(
"personnel",
sa.Column(
"probation_start_date", sa.Date(), nullable=True, comment="试用期开始日(含当日)"
),
)
op.add_column(
"personnel",
sa.Column(
"regularization_date",
sa.Date(),
nullable=True,
comment="转正生效日(当日不再属于试用期)",
),
)
op.add_column(
"personnel",
sa.Column(
"resignation_date", sa.Date(), nullable=True, comment="离职生效日(当日起不计基本工资)"
),
)
op.add_column(
"personnel",
sa.Column(
"salary_history_json",
sa.JSON(),
nullable=True,
comment="工资标准、岗位及任职资料生效版本",
),
)
op.add_column(
"products",
sa.Column(
"source_trade_json",
sa.JSON(),
nullable=True,
comment="最近有效发货数量、含税成交价及源单据快照",
),
)
op.add_column(
"products",
sa.Column(
"quantity_price_manual",
sa.SmallInteger(),
server_default="0",
nullable=False,
comment="数量单价人工保护:1人工维护 0随源更新",
),
)
for name, title in (
("normal_payable_sum", "当月正常实付绩效"),
("deferred_sum", "当月试用期缓发变化"),
("probation_payment_sum", "本月年度补发或扣回"),
):
op.add_column(
"settlement_items",
sa.Column(name, sa.Numeric(14, 2), server_default="0", nullable=False, comment=title),
)
for statement in PROBATION_DDL.split(";"):
if statement.strip():
op.execute(sa.text(statement))
# 仅建立新参数定义,不写任何历史生效值;未覆盖项继续回溯原通用参数。
fields = (
("first", "个人户首日比例", "DECIMAL", "‰"),
("start", "个人户线性阶段起始比例", "DECIMAL", "‰"),
("slope", "个人户线性阶段日斜率", "DECIMAL", "‰/天"),
("floor", "个人户超期比例", "DECIMAL", "‰"),
("boundary", "个人户线性阶段截止天数", "INT", "天"),
)
definitions = sa.table(
"param_definitions",
*[
sa.column(name, sa.String())
for name in (
"param_code",
"param_name",
"param_group",
"value_type",
"unit",
"dimension_type",
"value_min",
"value_max",
"description",
)
],
sa.column("sort_order", sa.Integer()),
sa.column("status", sa.Integer()),
)
op.bulk_insert(
definitions,
[
{
"param_code": f"coefficient.personal.{field}",
"param_name": title,
"param_group": "产品系数表",
"value_type": kind,
"unit": unit,
"dimension_type": "PRODUCT_CATEGORY",
"value_min": "1" if kind == "INT" else "0",
"value_max": "3660" if kind == "INT" else None,
"description": "按产品类别维护;未配置时沿用同日期的通用业务员参数。",
"sort_order": 52 + index,
"status": 1,
}
for index, (field, title, kind, unit) in enumerate(fields)
],
)
def downgrade():
bind = op.get_bind()
for table, condition in (("personnel", "salary_history_json IS NOT NULL"), ("products", "source_trade_json IS NOT NULL OR quantity_price_manual<>0")):
if bind.execute(sa.text(f"SELECT COUNT(*) FROM {table} WHERE {condition}")).scalar():
raise RuntimeError("存在人员或产品新增资料,请回退应用并保留业务结构")
for table in ("probation_payments", "probation_deferrals"):
if bind.execute(sa.text(f"SELECT COUNT(*) FROM {table}")).scalar():
raise RuntimeError("存在试用期结算记录,请回退应用并保留业务数据")
if bind.execute(
sa.text(
"SELECT COUNT(*) FROM param_values v JOIN param_definitions d ON d.id=v.param_id WHERE d.param_code LIKE 'coefficient.personal.%'"
)
).scalar():
raise RuntimeError("存在已维护的产品系数,请保留新结构")
bind.execute(
sa.text("DELETE FROM param_definitions WHERE param_code LIKE 'coefficient.personal.%'")
)
op.drop_table("probation_payments")
op.drop_table("probation_deferrals")
for name in ("normal_payable_sum", "deferred_sum", "probation_payment_sum"):
op.drop_column("settlement_items", name)
op.drop_column("products", "quantity_price_manual")
op.drop_column("products", "source_trade_json")
op.drop_column("personnel", "regularization_date")
op.drop_column("personnel", "salary_history_json")
op.drop_column("personnel", "resignation_date")
op.drop_column("personnel", "probation_start_date")
@@ -0,0 +1,24 @@
"""V25 将新增系统监控接入统一功能权限,不修改已应用的 V22。"""
from alembic import op
import sqlalchemy as sa
revision: str = "25d6e7f8a9b0"
down_revision: str | None = "24c5d6e7f8a9"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
for code, name, kind in (
("system:monitor:view", "系统监控:查看", 1),
("system:monitor:export", "系统监控:导出", 2),
):
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT :code,:name,:kind,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code=:code)"), {"code": code,"name": name,"kind": kind})
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code='admin' AND NOT EXISTS (SELECT 1 FROM role_permissions old WHERE old.role_id=r.id AND old.permission_id=p.id)"), {"code": code})
bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:monitor:view' SET p.parent_id=parent.id WHERE p.perm_code='system:monitor:export'"))
def downgrade():
# V23 也可能已登记目录;回退此兼容版本不删除任何既有授权。
return
@@ -0,0 +1,31 @@
"""授权归一到角色,停用全局制作人入口,保留旧结构与历史工资依据。"""
from alembic import op
from sqlalchemy import text
revision: str = "26e7f8a9b0c1"
down_revision: str | None = "25d6e7f8a9b0"
branch_labels = None
depends_on = None
STATEMENTS = (
"UPDATE authorization_settings SET maker_user_id=NULL, revision=revision+1 WHERE id=1",
"UPDATE permissions SET status=0 WHERE perm_code IN ('system:authorization:view','system:authorization:update')",
"UPDATE roles SET role_name='工资经办', description='按角色授权维护核算资料、计算工资及生成工资;月份锁定和实际经办人留痕继续生效' WHERE role_code='payroll_maker' AND role_name='工资制作人'",
"UPDATE roles SET description='管理业务基础资料,具体功能由角色权限配置决定' WHERE role_code='marketing_ops' AND description LIKE '%审批链%'",
)
def upgrade():
bind = op.get_bind()
if bind.execute(text("SELECT COUNT(*) FROM user_permission_overrides")).scalar():
raise RuntimeError("存在个人权限覆盖,请先整理为等效角色后再升级,避免隐式改变用户权限")
for statement in STATEMENTS:
op.execute(text(statement))
def downgrade():
# 只恢复旧入口,不猜测重新绑定哪个账号。旧版本需由管理员重新指定。
op.execute(text("UPDATE permissions SET status=1 WHERE perm_code IN ('system:authorization:view','system:authorization:update')"))
op.execute(text("UPDATE roles SET role_name='工资制作人' WHERE role_code='payroll_maker' AND role_name='工资经办'"))
op.execute(text("UPDATE authorization_settings SET revision=revision+1 WHERE id=1"))
@@ -0,0 +1,21 @@
"""增加源级自动采集开关,不修改活动采集表。"""
from pathlib import Path
from alembic import op
from sqlalchemy import inspect, text
revision = "27f8a9b0c1d2"
down_revision = "26e7f8a9b0c1"
branch_labels = None
depends_on = None
def upgrade():
if not inspect(op.get_bind()).has_table("tplus_automation_configs"):
sql = Path(__file__).resolve().parents[3] / "db/migration/V27__tplus_automation_config.sql"
op.get_bind().execute(text(sql.read_text(encoding="utf-8")))
def downgrade():
op.drop_table("tplus_automation_configs")
@@ -0,0 +1,31 @@
"""V28 计提公式模板定义:模板入库,页面可维护;公式骨架仍受控。"""
from pathlib import Path
from alembic import op
from sqlalchemy import inspect, text
revision = "28a9b0c1d2e3"
down_revision = "27f8a9b0c1d2"
branch_labels = None
depends_on = None
def upgrade():
if not inspect(op.get_bind()).has_table("calc_template_definitions"):
sql = Path(__file__).resolve().parents[3] / "db/migration/V28__calc_template_definitions.sql"
raw = sql.read_text(encoding="utf-8")
# MySQL 不支持 multi-statement;按分号+换行拆分成逐条执行。
# JSON_OBJECT 内不含分号+换行,可安全拆分。
for statement in raw.split(";\n"):
stripped = "\n".join(
line for line in statement.splitlines() if not line.lstrip().startswith("--")
).strip()
if stripped:
op.get_bind().execute(text(stripped))
def downgrade():
# 模板可能被岗位引用,外键不存在但编码被业务表保存;回退仅删定义表,
# 已绑定岗位的编码会回退到代码注册表解析,不猜新数据归属。
op.drop_table("calc_template_definitions")
@@ -0,0 +1,47 @@
"""V29 回款独立人工确认及默认开启开关;不自动确认历史回款。"""
from pathlib import Path
from alembic import op
from sqlalchemy import inspect, text
revision = "29b0c1d2e3f4"
down_revision = "28a9b0c1d2e3"
branch_labels = None
depends_on = None
def upgrade():
sql = Path(__file__).resolve().parents[3] / "db/migration/V29__receipt_manual_confirmation.sql"
columns = {column["name"]: column for column in inspect(op.get_bind()).get_columns("receipts")}
existing = {name for name in ("confirmation_status", "confirmed_by", "confirmed_at", "confirmed_revision") if name in columns}
if existing and len(existing) != 4:
raise RuntimeError("回款确认字段不完整,请先核对数据库结构")
if existing:
expected = {"confirmation_status": ("TINYINT", None, False), "confirmed_by": ("VARCHAR", 64, True),
"confirmed_at": ("DATETIME", None, True), "confirmed_revision": ("VARCHAR", 64, True)}
for name, (kind, length, nullable) in expected.items():
column = columns[name]
actual_type = column["type"]
if (actual_type.__visit_name__.upper() != kind or getattr(actual_type, "length", None) != length
or column["nullable"] != nullable):
raise RuntimeError(f"回款确认字段 {name} 与迁移定义不一致,请先核对数据库结构")
if str(columns["confirmation_status"]["default"]).strip("'\"") != "0":
raise RuntimeError("回款确认状态默认值不是待确认,请先核对数据库结构")
for statement in sql.read_text(encoding="utf-8").split(";\n"):
clean = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip()
if clean and not (existing and clean.startswith("ALTER TABLE receipts ADD COLUMN")):
op.get_bind().execute(text(clean))
def downgrade():
bind = op.get_bind()
if bind.scalar(text("SELECT COUNT(*) FROM receipts WHERE confirmed_at IS NOT NULL OR confirmation_status<>0")):
raise RuntimeError("已有回款人工确认记录,禁止删除确认依据;请仅回退应用并保留结构")
if bind.scalar(text("SELECT COUNT(*) FROM param_values v JOIN param_definitions d ON d.id=v.param_id WHERE d.param_code='receipt.confirmation.enabled'")):
raise RuntimeError("已维护人工确认开关版本,禁止删除历史参数")
bind.execute(text("DELETE FROM param_definitions WHERE param_code='receipt.confirmation.enabled'"))
bind.execute(text("UPDATE permissions SET status=0 WHERE perm_code='business:receipts:confirm'"))
for column in ("confirmed_revision", "confirmed_at", "confirmed_by", "confirmation_status"):
op.drop_column("receipts", column)
@@ -0,0 +1,40 @@
"""V30 业务接入与核算准备解耦;无默认回填。"""
from pathlib import Path
from alembic import op
from sqlalchemy import text
revision = "30c1d2e3f4a5"
down_revision = "29b0c1d2e3f4"
branch_labels = None
depends_on = None
def upgrade():
script = Path(__file__).resolve().parents[3] / "db/migration/V30__tplus_business_sync.sql"
for statement in script.read_text(encoding="utf-8").split(";\n"):
sql = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip()
if sql:
op.get_bind().execute(text(sql))
def downgrade():
connection = op.get_bind()
for table, condition in (("tplus_business_documents", "1=1"), ("products", "product_category IS NULL"),
("customers", "customer_type IS NULL"), ("business_items", "product_category IS NULL"),
("shipments", "freight_amount IS NULL")):
if connection.scalar(text(f"SELECT COUNT(*) FROM {table} WHERE {condition}")):
raise RuntimeError("已有接入或待补资料,请关闭接入并回退兼容版本;不删除业务数据以降级结构")
op.drop_table("tplus_business_documents")
op.drop_table("tplus_business_sync_configs")
for index in ("idx_tplus_source_heads_parent", "idx_tplus_source_heads_customer_date", "idx_tplus_source_heads_date"):
op.drop_index(index, "tplus_source_heads")
for column in ("source_parent_id", "source_customer_id", "source_date"):
op.drop_column("tplus_source_heads", column)
op.drop_column("businesses", "integration_json")
for table, column, sql_type, default in (("products", "product_category", "VARCHAR(32)", ""),
("customers", "customer_type", "VARCHAR(32)", " DEFAULT 'personal'"),
("business_items", "product_category", "VARCHAR(32)", ""),
("shipments", "freight_amount", "DECIMAL(14,2)", " DEFAULT 0")):
connection.execute(text(f"ALTER TABLE {table} MODIFY {column} {sql_type} NOT NULL{default}"))
@@ -0,0 +1,14 @@
"""合并业务接入和回款确认迁移分支。"""
revision = "31d2e3f4a5b6"
down_revision = ("29b0c1d2e3f4", "30c1d2e3f4a5")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
@@ -0,0 +1,22 @@
"""增加默认停止的 T+ 读取服务控制。"""
from pathlib import Path
from alembic import op
from sqlalchemy import text
revision = "32e3f4a5b6c7"
down_revision = "31d2e3f4a5b6"
branch_labels = None
depends_on = None
def upgrade():
sql = Path(__file__).resolve().parents[3] / "db/migration/V32__tplus_runtime_control.sql"
for statement in sql.read_text(encoding="utf-8").split(";"):
if statement.strip():
op.get_bind().execute(text(statement))
def downgrade():
raise RuntimeError("运行控制包含用户设置,请停止服务并备份后采用应用回退")
@@ -0,0 +1,22 @@
"""将 T+ 运行控制表名统一为复数。"""
from pathlib import Path
from alembic import op
from sqlalchemy import text
revision = "33e4f5a6b7c8"
down_revision = "32e3f4a5b6c7"
branch_labels = None
depends_on = None
def upgrade():
sql = Path(__file__).resolve().parents[3] / "db/migration/V33__pluralize_tplus_runtime_control.sql"
for statement in sql.read_text(encoding="utf-8").split(";"):
if statement.strip():
op.get_bind().execute(text(statement))
def downgrade():
raise RuntimeError("运行控制表名迁移不可直接回退,请停止服务并按兼容版本处理")
@@ -0,0 +1,71 @@
"""v12 审批单版本号唯一键
Revision ID: 7f4f7b918bc4
Revises: f3a8c2d61b47
Create Date: 2026-10-21 10:15:00.000000
给 ``approval_orders`` 加 ``(order_type, ref_id, version_no)`` 唯一键,兜住
「同一引用对象的第几次送审」在并发下算重。
**加键前必须先重排存量**:V11 迁移用 ``ADD COLUMN version_no INT NOT NULL
DEFAULT 1`` 把存量行一律置 1,而 V11 之前的每次重提都是**新建行**(撤回
只改旧单 status,行不删)。于是任何有 V11 前审批历史的库里,同一
``(order_type, ref_id)`` 存在多行且 ``version_no`` 全为 1——直接
``ADD CONSTRAINT`` 必报 1062,迁移会停在半途。
重排只针对「组内有重复」的分组(``COUNT(*) > COUNT(DISTINCT version_no)``),
按 id 升序重排为 1..n;其余分组一个字节都不动——全量重排会把前端已展示
过的历史版本号(``V{{ version_no }}``)改掉,属于用户可见的历史数据篡改。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7f4f7b918bc4"
down_revision: str | Sequence[str] | None = "f3a8c2d61b47"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_UK = "uk_approval_orders_order_type_ref_version"
def upgrade() -> None:
"""先重排存量重复的版本号,再加唯一键。"""
# 重排:只动「组内有重复版本号」的分组,按 id 升序编 1..n。
# 用派生表内联而非临时表——同一张临时表被 JOIN 两侧引用会报 1137
# "Can't reopen table"。
op.execute(
"UPDATE approval_orders ao "
"JOIN (SELECT id, ROW_NUMBER() OVER ("
" PARTITION BY order_type, ref_id ORDER BY id) AS rn "
" FROM approval_orders "
" WHERE (order_type, ref_id) IN ("
" SELECT order_type, ref_id FROM approval_orders "
" GROUP BY order_type, ref_id "
" HAVING COUNT(*) > COUNT(DISTINCT version_no))) t "
"ON ao.id = t.id "
"SET ao.version_no = t.rn"
)
# 幂等 guard:MySQL DDL 隐式提交,若加键成功而版本号未落盘,
# 重跑会报 1091(键已存在)。写法沿用 V9 先例。
op.execute(
"SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
"WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' "
f"AND CONSTRAINT_NAME = '{_UK}')"
)
op.execute(
"SET @ddl := IF(@uk_exists = 0, "
f"'ALTER TABLE approval_orders ADD CONSTRAINT {_UK} "
"UNIQUE (order_type, ref_id, version_no)', 'DO 0')"
)
op.execute("PREPARE stmt FROM @ddl")
op.execute("EXECUTE stmt")
op.execute("DEALLOCATE PREPARE stmt")
def downgrade() -> None:
"""删唯一键。唯一键本质是索引,DROP INDEX 不校验数据,存量是否已有
重复 version_no 与删索引无关;重排结果也不回滚(版本号保持 1..n 连续)。"""
op.execute(f"ALTER TABLE approval_orders DROP INDEX {_UK}")
@@ -11,6 +11,7 @@ Create Date: 2026-10-19 17:55:48.892998
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
"""
from collections.abc import Sequence
from datetime import date
@@ -19,8 +20,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '90653814a421'
down_revision: str | Sequence[str] | None = 'ba0217f5091b'
revision: str = "90653814a421"
down_revision: str | Sequence[str] | None = "ba0217f5091b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
@@ -0,0 +1,51 @@
"""v13 客户评级落库
Revision ID: c3a9e1d7b2f4
Revises: 7f4f7b918bc4
Create Date: 2026-10-23 10:30:00.000000
评级计算器从独立工具页并入客户管理(行内弹窗),评分由「不落库」改为
保存为客户档案属性:``rating_score`` 总分 + ``rating_time`` 评级时间。
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
的等级随之更新),因此本迁移只落总分与评级时间两列。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c3a9e1d7b2f4"
down_revision: str | Sequence[str] | None = "7f4f7b918bc4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""customers 加评级两列,紧跟 remark 之后保持与 ORM 声明同序。
用 execute 手写而非 op.add_column:后者不带 AFTER 子句,两列会落到
表尾(T+ 预留列之后),与 ORM/init-dev 的声明顺序不一致。
"""
op.execute(
"ALTER TABLE customers "
"ADD COLUMN rating_score TINYINT COMMENT "
"'评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' "
"AFTER remark"
)
op.execute(
"ALTER TABLE customers "
"ADD COLUMN rating_time DATETIME COMMENT "
"'最近评级时间(评级计算器保存评分时刷新;人工维护字段)' "
"AFTER rating_score"
)
def downgrade() -> None:
"""删除评级两列。评分数据随列删除,评级无下游引用、无悬空风险。"""
op.drop_column("customers", "rating_time")
op.drop_column("customers", "rating_score")
@@ -34,10 +34,7 @@ depends_on = None
def upgrade() -> None:
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
op.execute(
"UPDATE param_definitions SET default_value = '1' "
"WHERE id IN (17, 18)"
)
op.execute("UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18)")
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
op.bulk_insert(
sa.table(
@@ -188,6 +185,4 @@ def downgrade() -> None:
)
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
op.execute(
"UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)"
)
op.execute("UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)")
@@ -0,0 +1,122 @@
"""T+ 只读同步批次、错误、staging 和增量水位。
Revision ID: d4e5f6a7b8c9
Revises: c3a9e1d7b2f4
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.mysql import BIGINT
revision: str = "d4e5f6a7b8c9"
down_revision: str | Sequence[str] | None = "c3a9e1d7b2f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""增加 T+ 源更新时间和同步审计/staging 表。"""
for table in ("personnel", "products", "customers", "businesses", "shipments", "receipts"):
op.execute(
f"ALTER TABLE {table} ADD COLUMN source_updated_at DATETIME "
"COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at"
)
op.create_table(
"tplus_sync_batches",
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
sa.Column("sync_mode", sa.String(length=16), nullable=False, comment="同步模式:FULL全量 INCREMENTAL增量"),
sa.Column("status", sa.String(length=16), nullable=False, comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED"),
sa.Column("source_database", sa.String(length=128), nullable=True, comment="T+ 源数据库名"),
sa.Column("started_at", sa.DateTime(), nullable=False, comment="开始读取时间"),
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="结束时间"),
sa.Column("watermark_from", sa.DateTime(), nullable=True, comment="增量起始水位"),
sa.Column("watermark_to", sa.DateTime(), nullable=True, comment="本批次结束水位"),
sa.Column("source_record_count", sa.Integer(), nullable=False, server_default="0", comment="源记录数"),
sa.Column("staged_record_count", sa.Integer(), nullable=False, server_default="0", comment="写入 staging 的记录数"),
sa.Column("failed_record_count", sa.Integer(), nullable=False, server_default="0", comment="失败记录数"),
sa.Column("error_message", sa.Text(), nullable=True, comment="批次错误摘要"),
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_batches"),
sa.UniqueConstraint("batch_no", name="uk_tplus_sync_batches_batch_no"),
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步批次表(记录读取范围、结果和对账统计)",
)
op.create_index("idx_tplus_sync_batches_status", "tplus_sync_batches", ["status"])
op.create_index("idx_tplus_sync_batches_started_at", "tplus_sync_batches", ["started_at"])
op.create_table(
"tplus_sync_errors",
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
sa.Column("source_id", sa.String(length=64), nullable=True, comment="T+ 源主键"),
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
sa.Column("error_code", sa.String(length=32), nullable=False, comment="错误编码"),
sa.Column("error_message", sa.Text(), nullable=False, comment="错误说明"),
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0", comment="已重试次数"),
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_errors"),
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步错误表(记录失败行和重试信息)",
)
op.create_index("idx_tplus_sync_errors_batch_no", "tplus_sync_errors", ["batch_no"])
op.create_index("idx_tplus_sync_errors_entity_type", "tplus_sync_errors", ["entity_type"])
op.create_table(
"tplus_sync_records",
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
sa.Column("source_id", sa.String(length=64), nullable=False, comment="T+ 源主键"),
sa.Column("source_no", sa.String(length=64), nullable=True, comment="T+ 原始编号"),
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
sa.Column("source_updated_at", sa.DateTime(), nullable=True, comment="T+ 源数据更新时间"),
sa.Column("row_hash", sa.String(length=64), nullable=False, comment="原始行 SHA-256"),
sa.Column("state", sa.String(length=16), nullable=False, server_default="PENDING", comment="状态:PENDING/APPLIED/SKIPPED/ERROR"),
sa.Column("payload_json", sa.JSON(), nullable=False, comment="原始行 JSON"),
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_records"),
sa.UniqueConstraint("entity_type", "source_id", "row_hash", name="uk_tplus_sync_records_entity_source_hash"),
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)",
)
op.create_index("idx_tplus_sync_records_batch_no", "tplus_sync_records", ["batch_no"])
op.create_index("idx_tplus_sync_records_source_no", "tplus_sync_records", ["source_no"])
op.create_index("idx_tplus_sync_records_state", "tplus_sync_records", ["state"])
op.create_table(
"tplus_sync_watermarks",
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
sa.Column("last_source_updated_at", sa.DateTime(), nullable=True, comment="最近源更新时间"),
sa.Column("last_source_id", sa.String(length=64), nullable=True, comment="同一时间点的最后源主键"),
sa.Column("status", sa.String(length=16), nullable=False, server_default="READY", comment="状态:READY/BLOCKED"),
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_watermarks"),
sa.UniqueConstraint("entity_type", name="uk_tplus_sync_watermarks_entity_type"),
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 增量同步水位表",
)
def downgrade() -> None:
"""删除 T+ 同步支撑表和源更新时间列。"""
op.drop_table("tplus_sync_watermarks")
op.drop_index("idx_tplus_sync_records_state", table_name="tplus_sync_records")
op.drop_index("idx_tplus_sync_records_source_no", table_name="tplus_sync_records")
op.drop_index("idx_tplus_sync_records_batch_no", table_name="tplus_sync_records")
op.drop_table("tplus_sync_records")
op.drop_index("idx_tplus_sync_errors_entity_type", table_name="tplus_sync_errors")
op.drop_index("idx_tplus_sync_errors_batch_no", table_name="tplus_sync_errors")
op.drop_table("tplus_sync_errors")
op.drop_index("idx_tplus_sync_batches_started_at", table_name="tplus_sync_batches")
op.drop_index("idx_tplus_sync_batches_status", table_name="tplus_sync_batches")
op.drop_table("tplus_sync_batches")
for table in ("receipts", "shipments", "businesses", "customers", "products", "personnel"):
op.drop_column(table, "source_updated_at")
@@ -0,0 +1,118 @@
"""V15 T+ 持久化任务、幂等和重试元数据。"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.mysql import BIGINT
revision: str = "e5f607a8b9c0"
down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""仅扩展管理元数据,不改原始暂存行或工资业务表。"""
op.add_column(
"tplus_sync_batches",
sa.Column("entity_types_json", sa.JSON(), comment="请求读取的实体类型;历史批次可为空"),
)
op.add_column(
"tplus_sync_batches",
sa.Column(
"skipped_record_count",
sa.Integer(),
nullable=False,
server_default="0",
comment="重复快照跳过条数",
),
)
op.add_column(
"tplus_sync_batches",
sa.Column("parent_batch_no", sa.String(32), comment="重试来源批次号,保留原批次记录"),
)
op.add_column(
"tplus_sync_batches",
sa.Column("request_key", sa.String(36), comment="请求幂等键,空值用于历史和直接CLI任务"),
)
op.add_column(
"tplus_sync_batches",
sa.Column(
"requested_by", BIGINT(unsigned=True), comment="请求用户ID,CLI任务为空,无物理外键"
),
)
op.add_column(
"tplus_sync_batches",
sa.Column("audit_result_json", sa.JSON(), comment="关联核验结果,只含系统字典和汇总"),
)
op.alter_column(
"tplus_sync_batches",
"started_at",
existing_type=sa.DateTime(),
nullable=True,
comment="开始执行时间(排队时为空)",
)
op.alter_column(
"tplus_sync_batches",
"sync_mode",
existing_type=sa.String(16),
nullable=False,
comment="任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留",
)
op.alter_column(
"tplus_sync_batches",
"status",
existing_type=sa.String(16),
nullable=False,
comment="状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED",
)
op.create_unique_constraint(
"uk_tplus_sync_batches_request_key", "tplus_sync_batches", ["request_key"]
)
op.create_index(
"idx_tplus_sync_batches_parent_status", "tplus_sync_batches", ["parent_batch_no", "status"]
)
op.execute(
"UPDATE tplus_sync_batches SET skipped_record_count=GREATEST(0, source_record_count-staged_record_count-failed_record_count) WHERE status='SUCCEEDED' AND sync_mode='FULL'"
)
def downgrade() -> None:
"""回滚前关闭 worker;原始暂存数据和历史批次仍保留。"""
op.execute(
"UPDATE tplus_sync_batches SET started_at=create_time, status='FAILED', finished_at=CURRENT_TIMESTAMP, error_message='任务管理版本已回滚' WHERE status='QUEUED'"
)
op.execute("UPDATE tplus_sync_batches SET started_at=create_time WHERE started_at IS NULL")
op.drop_index("idx_tplus_sync_batches_parent_status", table_name="tplus_sync_batches")
op.drop_constraint("uk_tplus_sync_batches_request_key", "tplus_sync_batches", type_="unique")
for name in (
"audit_result_json",
"requested_by",
"request_key",
"parent_batch_no",
"skipped_record_count",
"entity_types_json",
):
op.drop_column("tplus_sync_batches", name)
op.alter_column(
"tplus_sync_batches",
"started_at",
existing_type=sa.DateTime(),
nullable=False,
comment="开始读取时间",
)
op.alter_column(
"tplus_sync_batches",
"sync_mode",
existing_type=sa.String(16),
nullable=False,
comment="同步模式:FULL全量 INCREMENTAL增量",
)
op.alter_column(
"tplus_sync_batches",
"status",
existing_type=sa.String(16),
nullable=False,
comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED",
)
@@ -0,0 +1,93 @@
"""v11 审批快照与方案版本化
Revision ID: f3a8c2d61b47
Revises: 90653814a421
Create Date: 2026-10-20 10:30:00.000000
补齐 code-review 遗留 2 项:
- 审批单加 ``version_no`` 与 ``snapshot_json``:重提产生新版本,送审内容
在提交时固化,审批期间业务再改不影响复核依据;
- 二次分配方案行改 append-only 版本化:加 ``version_no`` / ``is_active``,
唯一键由 (business_id, personnel_id) 扩为
(business_id, version_no, personnel_id),编辑替换与驳回清空只置历史、
不再物理删除。
存量数据均落 ``version_no=1`` / ``is_active=1``:升级前的行就是当前生效
版本,语义不变。审批单存量快照为空,详情接口按「当前内容回退 + 显式
标注」处理,不做历史回填(回填会伪造提交时内容)。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f3a8c2d61b47"
down_revision: str | Sequence[str] | None = "90653814a421"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""加审批快照列/索引与方案版本列,替换唯一键。"""
op.execute(
"ALTER TABLE approval_orders "
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
"COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' "
"AFTER refer_no"
)
op.execute(
"ALTER TABLE approval_orders "
"ADD COLUMN snapshot_json JSON NULL "
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
"AFTER version_no"
)
op.execute("CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)")
op.execute(
"ALTER TABLE business_secondary_allocations "
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
"COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' "
"AFTER business_id"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 "
"COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' "
"AFTER ratio"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"DROP INDEX uk_business_secondary_allocations_business_personnel, "
"ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel "
"UNIQUE (business_id, version_no, personnel_id)"
)
op.execute(
"CREATE INDEX idx_business_secondary_allocations_business_active "
"ON business_secondary_allocations (business_id, is_active)"
)
op.execute(
"ALTER TABLE business_secondary_allocations COMMENT = "
"'业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)'"
)
def downgrade() -> None:
"""回退结构(降级前须确认同业务同一参与人只存在于一个版本)。"""
op.execute(
"DROP INDEX idx_business_secondary_allocations_business_active "
"ON business_secondary_allocations"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"DROP INDEX uk_business_secondary_allocations_business_version_personnel, "
"ADD CONSTRAINT uk_business_secondary_allocations_business_personnel "
"UNIQUE (business_id, personnel_id)"
)
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN is_active")
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN version_no")
op.execute("DROP INDEX idx_approval_orders_type_ref ON approval_orders")
op.execute("ALTER TABLE approval_orders DROP COLUMN snapshot_json")
op.execute("ALTER TABLE approval_orders DROP COLUMN version_no")
@@ -0,0 +1,31 @@
"""V16 基础导入和核算预演工作区;结构固定,不引用演进中的 ORM。"""
from alembic import op
import sqlalchemy as sa
revision: str = "f6a708b9c0d1"
down_revision: str | None = "e5f607a8b9c0"
branch_labels = None
depends_on = None
STATEMENTS = [
"CREATE TABLE tplus_source_heads (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体类型', \n\tsource_id VARCHAR(64) NOT NULL COMMENT '源内部ID', \n\trecord_id BIGINT UNSIGNED NOT NULL COMMENT '原始快照ID', \n\tbatch_no VARCHAR(32) NOT NULL COMMENT '最后观察批次', \n\tCONSTRAINT pk_tplus_source_heads PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_heads_source_entity_id UNIQUE (source_database, entity_type, source_id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 源当前快照指针(按成功全量批次读取,删除不自动回写业务表)' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_tplus_heads_batch_entity ON tplus_source_heads (batch_no, entity_type)",
"CREATE TABLE tplus_facts (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tkind VARCHAR(32) NOT NULL COMMENT '事实类别', \n\tfact_key VARCHAR(128) NOT NULL COMMENT '事实业务键', \n\tversion INTEGER NOT NULL COMMENT '版本号', \n\tdata_json JSON NOT NULL COMMENT '经校验的事实内容', \n\tevidence VARCHAR(500) NOT NULL COMMENT '依据和修改原因', \n\trequested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_facts PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_facts_source_kind_key_version UNIQUE (source_database, kind, fact_key, version)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 人工补充事实版本表' COLLATE utf8mb4_general_ci",
"CREATE TABLE tplus_source_links (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体类型', \n\tsource_id VARCHAR(64) NOT NULL COMMENT '源内部ID', \n\ttarget_id BIGINT UNSIGNED NOT NULL COMMENT '业务目标ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_source_links PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_links_source_entity_id UNIQUE (source_database, entity_type, source_id), \n\tCONSTRAINT uk_tplus_links_entity_target UNIQUE (entity_type, target_id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 基础数据源目标关联' COLLATE utf8mb4_general_ci",
"CREATE TABLE tplus_work_runs (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\trun_no VARCHAR(32) NOT NULL COMMENT '运行编号', \n\trequest_key VARCHAR(36) NOT NULL COMMENT '请求幂等键', \n\tkind VARCHAR(32) NOT NULL COMMENT 'MASTER_PREVIEW/MASTER_APPLY/CALC_PREVIEW', \n\tstatus VARCHAR(16) NOT NULL COMMENT '运行状态', \n\tsource_database VARCHAR(128) NOT NULL COMMENT '固定源账套', \n\trequested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', \n\tinput_json JSON NOT NULL COMMENT '请求输入', \n\tsnapshot_json JSON COMMENT '规则和补充资料的固定快照', \n\tsnapshot_hash VARCHAR(64) COMMENT '输入指纹', \n\tsummary_json JSON COMMENT '结果汇总', \n\terror_code VARCHAR(64) COMMENT '固定错误编码', \n\tstarted_at DATETIME COMMENT '开始时间', \n\tfinished_at DATETIME COMMENT '结束时间', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_work_runs PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_work_runs_run_no UNIQUE (run_no), \n\tCONSTRAINT uk_tplus_work_runs_request_key UNIQUE (request_key)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入与预演运行表' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_tplus_work_runs_status_id ON tplus_work_runs (status, id)",
"CREATE TABLE tplus_work_lines (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\trun_no VARCHAR(32) NOT NULL COMMENT '运行编号', \n\tline_no INTEGER NOT NULL COMMENT '稳定行序号', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体或分组类型', \n\tsource_id VARCHAR(128) NOT NULL COMMENT '源ID或平账组', \n\taction VARCHAR(32) NOT NULL COMMENT '新增/更新/跳过/待补/冲突/分配', \n\treason VARCHAR(128) COMMENT '固定原因编码', \n\tdata_json JSON NOT NULL COMMENT '最小必要的业务明细', \n\tCONSTRAINT pk_tplus_work_lines PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_work_lines_run_line UNIQUE (run_no, line_no)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入预览与核算预演明细' COLLATE utf8mb4_general_ci"
]
def upgrade():
"""只新增隔离工作区表,不写已有业务或工资记录。"""
for statement in STATEMENTS:
op.execute(sa.text(statement))
def downgrade():
"""只移除本次工作区表,执行前应保留资料和预演备份。"""
for name in ("tplus_work_lines", "tplus_work_runs", "tplus_source_links", "tplus_facts", "tplus_source_heads"):
op.drop_table(name)
+57 -4
View File
@@ -98,7 +98,7 @@ def _is_digits(value: object) -> bool:
return isinstance(value, str) and value.isdigit()
def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User:
def require_admin(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
"""要求当前用户具备管理员角色。
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
@@ -107,6 +107,9 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
:raises ForbiddenError: 已登录但不具备管理员角色(403)
"""
# 权限码与「仅管理员」是两套语义:部分系统权限(如 system:params:update)
# 可授予普通角色,统一入口通过不代表本端点放行,这里不依赖
# authorization_checked 短路,每次按数据库角色重新判定。
if ROLE_ADMIN not in user_repository.get_role_codes(current_user):
raise ForbiddenError("需要管理员权限")
return current_user
@@ -119,6 +122,21 @@ AdminUser = Annotated[User, Depends(require_admin)]
LoginUser = Annotated[User, Depends(get_current_user)]
def require_payroll_writer(current_user: LoginUser) -> User:
"""兼容原写接口注解;操作权限由统一入口校验,制作人限制仅用于工资操作。"""
return current_user
PayrollWriter = Annotated[User, Depends(require_payroll_writer)]
def require_workbench_reader(current_user: LoginUser) -> User:
return current_user
WorkbenchReader = Annotated[User, Depends(require_workbench_reader)]
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
"""要求当前用户具备任一指定角色(依赖工厂)。
@@ -132,10 +150,45 @@ def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
"""
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
codes = set(user_repository.get_role_codes(current_user))
if not codes & set(role_codes):
def _checker(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
# 与 require_admin 同理:不依赖统一入口的短路标记,按数据库角色判定。
held = set(user_repository.get_role_codes(current_user))
if not held & set(role_codes):
raise ForbiddenError(message)
return current_user
return _checker
async def authorize_request(request: Request, session: DbSession, credentials: BearerCredentials):
"""统一覆盖查询、导出和写入;公开端点以显式白名单处理。"""
from app.services import authorization_policy as policy
from app.services.authorization_service import require_permission
endpoint = request.scope["route"].endpoint
module = endpoint.__module__.removeprefix("app.api.v1.")
name = endpoint.__name__
if (module, name) in policy.PUBLIC_ENDPOINTS:
return
user = get_current_user(credentials, session)
body = {}
if request.method in ("POST", "PUT", "PATCH") and request.headers.get("content-type", "").split(";", 1)[0] == "application/json":
try:
value = await request.json()
body = value if isinstance(value, dict) else {}
except ValueError:
body = {}
code = policy.permission_for(module, name, request.method, params=request.path_params, body=body)
if code is None:
raise ForbiddenError("此接口尚未登记权限,操作已拒绝")
if code:
try:
require_permission(session, user, code)
except ForbiddenError:
from app.services.audit_service import AuditRecord, audit
with audit(session, operator=user, meta=get_request_meta(request), record=AuditRecord(
"权限管理", "权限拒绝", "操作因未授权被拒绝", {"permission_code": code}
)):
raise
request.state.authorization_checked = True
+18 -2
View File
@@ -4,9 +4,10 @@
健康检查作为运维探针不带版本号,直接位于 `/api/health`。
"""
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from app.api import health
from app.api.deps import authorize_request
from app.api.v1 import auth
from app.api.v1.base import business_roles as base_business_roles
from app.api.v1.base import customers as base_customers
@@ -18,15 +19,22 @@ from app.api.v1.business import receipts as business_receipts
from app.api.v1.business import shipments as business_shipments
from app.api.v1.calc import router as calc_router
from app.api.v1.dashboard import router as dashboard_router
from app.api.v1.metadata import router as metadata_router
from app.api.v1.payroll import router as payroll_router
from app.api.v1.reports import router as reports_router
from app.api.v1.settlements import router as settlements_router
from app.api.v1.system import approvals as system_approvals
from app.api.v1.system import business_roles as system_business_roles
from app.api.v1.system import calc_templates as system_calc_templates
from app.api.v1.system import logs as system_logs
from app.api.v1.system import monitor as system_monitor
from app.api.v1.system import params as system_params
from app.api.v1.system import roles as system_roles
from app.api.v1.system import settings as system_settings
from app.api.v1.system import tplus as system_tplus
from app.api.v1.system import users as system_users
from app.api.v1.system.authorization import router as authorization_router
from app.api.v1.system.coefficients import router as coefficient_router
api_router = APIRouter()
@@ -34,19 +42,26 @@ api_router = APIRouter()
api_router.include_router(health.router)
# 业务接口:/api/v1/*
v1_router = APIRouter(prefix="/v1")
v1_router = APIRouter(prefix="/v1", dependencies=[Depends(authorize_request)])
v1_router.include_router(authorization_router)
v1_router.include_router(metadata_router)
v1_router.include_router(auth.router)
# 系统管理(需求 2.5.1):/api/v1/system/*
system_router = APIRouter(prefix="/system")
system_router.include_router(system_users.router)
system_router.include_router(system_monitor.router)
system_router.include_router(system_roles.router)
system_router.include_router(system_business_roles.router)
system_router.include_router(system_calc_templates.router)
system_router.include_router(system_params.router)
system_router.include_router(coefficient_router)
system_router.include_router(system_settings.router)
system_router.include_router(system_approvals.router)
system_router.include_router(system_logs.operation_router)
system_router.include_router(system_logs.login_router)
system_router.include_router(system_tplus.router)
v1_router.include_router(system_router)
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
@@ -67,6 +82,7 @@ v1_router.include_router(business_router)
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(calc_router)
v1_router.include_router(payroll_router)
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(settlements_router)
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
+6 -2
View File
@@ -5,9 +5,9 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
from app.core.config import get_settings
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories.user_repository import get_role_codes
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
from app.services import auth_service, captcha_service
@@ -65,12 +65,16 @@ def logout(
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]:
"""返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。"""
from app.services.authorization_service import session_info, user_session
authorization = session_info(user_session(current_user), current_user)
return ok(
CurrentUserOut(
id=current_user.id,
username=current_user.username,
real_name=current_user.real_name,
roles=get_role_codes(current_user),
**authorization,
payroll_approval_enabled=get_settings().payroll_approval_enabled,
last_login_time=current_user.last_login_time,
)
)
+10 -16
View File
@@ -12,7 +12,8 @@ from fastapi import APIRouter, Depends
from app.api.deps import DbSession, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories import business_role_repository
from app.schemas.business_role import BusinessRoleOptionOut
from app.services.authorization_metadata import options
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
@@ -20,21 +21,14 @@ router = APIRouter(prefix="/business-roles", tags=["人员管理"])
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("/options", response_model=ApiResponse[list[dict]], summary="岗位下拉选项")
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[dict]]:
@router.get(
"/options",
response_model=ApiResponse[list[BusinessRoleOptionOut]],
summary="岗位下拉选项",
)
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[BusinessRoleOptionOut]]:
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
岗位规模有限(内置 + 自定义),一次取足够上限,不做分页。
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
"""
roles = business_role_repository.list_roles(session, offset=0, limit=1000)
return ok(
[
{
"id": role.id,
"role_code": role.role_code,
"role_name": role.role_name,
"status": role.status,
}
for role in roles
]
)
return ok([BusinessRoleOptionOut.model_validate(row) for row in options(session, _, "business_roles")])
+45 -6
View File
@@ -13,14 +13,16 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User
from app.models import Customer, User
from app.schemas.category_batch import CustomerCategoryBatch, CustomerOwnershipBatch
from app.schemas.customer import (
CustomerCreateIn,
CustomerOut,
CustomerOwnershipIn,
CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn,
OwnershipChangeLogOut,
)
@@ -32,6 +34,18 @@ router = APIRouter(prefix="/customers", tags=["客户管理"])
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.put("/batch-category")
def batch_category(payload: CustomerCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
from app.services.category_batch import fill_missing
return ok(fill_missing(session, Customer, payload, actor, meta))
@router.put("/batch-ownership")
def batch_ownership(payload: CustomerOwnershipBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
return ok(customer_service.batch_change_ownership(session, payload, operator=actor, meta=meta))
@router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表")
def list_customers(
session: DbSession,
@@ -45,7 +59,7 @@ def list_customers(
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
def create_customer(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: CustomerCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
@@ -69,7 +83,7 @@ def get_customer(
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
def update_customer(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerUpdateIn,
meta: RequestMetaDep,
@@ -90,7 +104,7 @@ def update_customer(
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
def delete_customer(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
@@ -102,6 +116,31 @@ def delete_customer(
return ok(message="客户已删除")
@router.put(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerOut],
summary="保存客户评级",
)
def rate_customer(
session: DbSession,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""保存评级总分(评级计算器「保存评分」)。
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
"""
return ok(
customer_service.rate_customer(
session, customer_id, payload, operator=current_user, meta=meta
),
message="评级已保存",
)
@router.put(
"/{customer_id}/ownership",
response_model=ApiResponse[CustomerOut],
@@ -109,7 +148,7 @@ def delete_customer(
)
def change_ownership(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerOwnershipIn,
meta: RequestMetaDep,
+8 -8
View File
@@ -11,7 +11,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.personnel import (
@@ -44,7 +44,7 @@ def list_personnel(
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
def create_personnel(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: PersonnelCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
@@ -68,7 +68,7 @@ def get_personnel(
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
def update_personnel(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelCreateIn,
meta: RequestMetaDep,
@@ -85,7 +85,7 @@ def update_personnel(
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
def delete_personnel(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
@@ -101,14 +101,14 @@ def delete_personnel(
)
def set_status(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
"""在职/离职切换。离职人员不可再被业务与客户归属引用。"""
result = personnel_service.set_status(
session, personnel_id, payload.status, operator=current_user, meta=meta
session, personnel_id, payload.status, effective_date=payload.effective_date, operator=current_user, meta=meta
)
return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职")
@@ -116,7 +116,7 @@ def set_status(
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
def replace_regions(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: RegionReplaceIn,
meta: RequestMetaDep,
@@ -131,7 +131,7 @@ def replace_regions(
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
def replace_plans(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: PlanReplaceIn,
meta: RequestMetaDep,
+45 -8
View File
@@ -3,8 +3,8 @@
产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值),
类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。
**数据提取(POST /products/extract)暂不提供**:它依赖业务明细表
(V5 迁移),待业务管理任务完成后补充。
数据提取使用预览指纹校验、名称规格去重和单位分类冲突保护,
仅建立产品档案关联,不改写历史业务金额。
基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
@@ -14,11 +14,13 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.models import Product, User
from app.schemas.category_batch import ProductCategoryBatch
from app.schemas.product import (
ProductCreateIn,
ProductExtractIn,
ProductOut,
ProductQuery,
ProductStatusIn,
@@ -31,6 +33,41 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/products", tags=["产品管理"])
@router.put("/batch-category")
def batch_category(payload: ProductCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
from app.services.category_batch import fill_missing
return ok(fill_missing(session, Product, payload, actor, meta))
@router.get("/extract/candidates")
def extraction_candidates(session: DbSession, _: CurrentUser, keyword: str = "", page_num: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)):
from app.services.product_completion import candidates
return ok(candidates(session, keyword, page_num, page_size))
@router.post("/extract/preview")
def extraction_preview(payload: ProductExtractIn, session: DbSession, _: CurrentUser):
from app.services.product_completion import extraction_plan
return ok(extraction_plan(session, payload.item_ids))
@router.post("/extract")
def extraction_apply(payload: ProductExtractIn, session: DbSession, actor: CurrentUser, meta: RequestMetaDep):
from app.services.product_completion import extract
return ok(extract(session, payload, actor, meta))
@router.post("/{product_id}/restore-source")
def restore_source(product_id: int, session: DbSession, actor: CurrentUser, meta: RequestMetaDep):
from app.services.product_completion import restore_source as restore
return ok(restore(session, product_id, actor, meta))
@router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表")
def list_products(
session: DbSession,
@@ -44,7 +81,7 @@ def list_products(
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
def create_product(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: ProductCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ProductOut]:
@@ -68,7 +105,7 @@ def get_product(
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
def update_product(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)],
payload: ProductUpdateIn,
meta: RequestMetaDep,
@@ -85,7 +122,7 @@ def update_product(
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
def delete_product(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
@@ -100,7 +137,7 @@ def delete_product(
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
def set_status(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)],
payload: ProductStatusIn,
meta: RequestMetaDep,
+93 -7
View File
@@ -9,7 +9,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User
from app.schemas.business import (
@@ -24,7 +24,16 @@ from app.schemas.business import (
GrossProfitOut,
OwnershipChangeLogOut,
)
from app.services import business_service
from app.schemas.business_source import (
GuidanceConfirmIn,
SalesRelationConfirmIn,
SalesRelationQuery,
SalesRelationReleaseIn,
SourceDocumentsQuery,
SourceMaterialsIn,
SourcePriceConfirmIn,
)
from app.services import business_service, business_source
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
@@ -32,6 +41,83 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/businesses", tags=["业务管理"])
@router.get("/{business_id}/readiness")
def source_readiness(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)]):
return ok(business_source.readiness(session, business_id))
@router.get("/{business_id}/source-documents")
def source_documents(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
query: Annotated[SourceDocumentsQuery, Query()]):
return ok(business_source.documents(session, business_id, query, query.entity))
@router.get("/{business_id}/source-quantities")
def source_quantities(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()]):
return ok(business_source.source_quantities(session, business_id, query))
@router.get("/{business_id}/source-documents/{document_id}/details")
def source_document_details(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
document_id: Annotated[int, Path(ge=1)], query: Annotated[PageQuery, Query()]):
return ok(business_source.document_details(session, business_id, document_id, query))
@router.put("/{business_id}/source-documents/{document_id}/missing-price")
def confirm_source_price(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
document_id: Annotated[int, Path(ge=1)], payload: SourcePriceConfirmIn, meta: RequestMetaDep):
from app.services.authorization_service import require_permission
require_permission(session, actor, "payroll:prepare")
return ok(business_source.confirm_missing_source_price(session, business_id, document_id, payload, actor, meta))
@router.put("/{business_id}/guidance")
def confirm_guidance(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
payload: GuidanceConfirmIn, meta: RequestMetaDep):
"""从本地发货明细登记来源产品的发货月份指导成本。"""
from app.services.authorization_service import require_permission
require_permission(session, actor, "payroll:prepare")
require_permission(session, actor, "tplus:facts")
return ok(business_source.confirm_guidance(session, business_id, payload, actor, meta))
@router.get("/{business_id}/sales-relation-candidates")
def sales_relation_candidates(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
query: Annotated[SalesRelationQuery, Query()]):
return ok(business_source.sales_relation_candidates(session, business_id, query, query.shipment_page, query.order_page))
@router.put("/{business_id}/sales-relation")
def confirm_sales_relation(session: DbSession, actor: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: SalesRelationConfirmIn, meta: RequestMetaDep):
from app.services.authorization_service import require_permission
from app.services.tplus import sales_relations
require_permission(session, actor, "payroll:prepare")
return ok(sales_relations.confirm(session, business_id, payload, actor, meta))
@router.put("/{business_id}/sales-relation/release")
def release_sales_relation(session: DbSession, actor: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: SalesRelationReleaseIn, meta: RequestMetaDep):
from app.services.authorization_service import require_permission
from app.services.tplus import sales_relations
require_permission(session, actor, "payroll:prepare")
return ok(sales_relations.release(session, business_id, payload, actor, meta))
@router.put("/{business_id}/source-materials")
def source_materials(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
payload: SourceMaterialsIn, meta: RequestMetaDep):
return ok(business_source.save_materials(session, business_id, payload, actor, meta))
@router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表")
def list_businesses(
session: DbSession,
@@ -45,7 +131,7 @@ def list_businesses(
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
def create_business(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: BusinessCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
@@ -69,7 +155,7 @@ def get_business(
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
def update_business(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessUpdateIn,
meta: RequestMetaDep,
@@ -86,7 +172,7 @@ def update_business(
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
def delete_business(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
@@ -98,7 +184,7 @@ def delete_business(
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
def change_status(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessStatusIn,
meta: RequestMetaDep,
@@ -115,7 +201,7 @@ def change_status(
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
def change_ownership(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessOwnershipIn,
meta: RequestMetaDep,
+4 -4
View File
@@ -13,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
@@ -36,7 +36,7 @@ def list_grades(
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
def create_grade(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: RatingGradeCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[RatingGradeOut]:
@@ -50,7 +50,7 @@ def create_grade(
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
def update_grade(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
grade_id: Annotated[int, Path(ge=1)],
payload: RatingGradeUpdateIn,
meta: RequestMetaDep,
@@ -65,7 +65,7 @@ def update_grade(
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
def delete_grade(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
grade_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
+21 -5
View File
@@ -9,16 +9,17 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.receipt import (
ReceiptConfirmIn,
ReceiptCreateIn,
ReceiptOut,
ReceiptQuery,
ReceiptUpdateIn,
)
from app.services import receipt_service
from app.services import receipt_confirmation, receipt_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
@@ -26,6 +27,21 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/receipts", tags=["回款管理"])
@router.get("/confirmation-policy", summary="回款人工确认开关状态")
def confirmation_policy(session: DbSession, _: CurrentUser):
return ok({"enabled": receipt_confirmation.enabled(session)})
@router.post("/{receipt_id}/confirm", response_model=ApiResponse[ReceiptOut], summary="独立人工确认回款")
def confirm_receipt(
receipt_id: Annotated[int, Path(ge=1)], payload: ReceiptConfirmIn,
session: DbSession, current_user: CurrentUser, meta: RequestMetaDep,
):
return ok(receipt_service.confirm_receipt(
session, receipt_id, payload, operator=current_user, meta=meta,
), message="回款已人工确认")
@router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表")
def list_receipts(
session: DbSession,
@@ -39,7 +55,7 @@ def list_receipts(
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
def create_receipt(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: ReceiptCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ReceiptOut]:
@@ -63,7 +79,7 @@ def get_receipt(
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
def update_receipt(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
receipt_id: Annotated[int, Path(ge=1)],
payload: ReceiptUpdateIn,
meta: RequestMetaDep,
@@ -80,7 +96,7 @@ def update_receipt(
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
def delete_receipt(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
receipt_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
+4 -4
View File
@@ -8,7 +8,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.shipment import (
@@ -39,7 +39,7 @@ def list_shipments(
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
def create_shipment(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: ShipmentCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ShipmentOut]:
@@ -63,7 +63,7 @@ def get_shipment(
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
def update_shipment(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
shipment_id: Annotated[int, Path(ge=1)],
payload: ShipmentUpdateIn,
meta: RequestMetaDep,
@@ -80,7 +80,7 @@ def update_shipment(
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
def delete_shipment(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
shipment_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
+5 -9
View File
@@ -10,18 +10,14 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
from app.services import calc_service
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/calc", tags=["绩效工资 · 绩效核算"])
_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
# 重算是授权动作:管理员/财务(营销后勤不可重算)
_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))]
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
@@ -50,7 +46,7 @@ def get_ledger(
def calc_receipt(
receipt_id: Annotated[int, Path(ge=1)],
session: DbSession,
current_user: _CalcWriter,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]:
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
@@ -81,7 +77,7 @@ def recalc_receipt(
receipt_id: Annotated[int, Path(ge=1)],
payload: RecalcIn,
session: DbSession,
current_user: _RecalcUser,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]:
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
@@ -97,7 +93,7 @@ def recalc_receipt(
def batch_calc(
payload: BatchIn,
session: DbSession,
current_user: _CalcWriter,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[list[dict]]:
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
+20 -12
View File
@@ -1,4 +1,4 @@
"""工作台接口(M4 ⑧):四区块一次性聚合。
"""工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
@@ -12,22 +12,18 @@ from app.api.deps import DbSession, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.dashboard import DashboardOut
from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut
from app.services import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["工作台"])
_DashboardUser = Annotated[
User,
Depends(
require_roles(
ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台"
)
),
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台")),
]
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块")
def get_dashboard(
session: DbSession,
current_user: _DashboardUser,
@@ -35,10 +31,22 @@ def get_dashboard(
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None,
) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
"""一次返回经营总览/待办/风险预警(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
最近动态是分页列表,走 `GET /dashboard/activities`。
"""
return ok(
dashboard_service.get_dashboard(session, month, current_user=current_user)
)
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user))
@router.get("/activities", response_model=ApiResponse[ActivityPageOut], summary="最近动态(分页)")
def list_dashboard_activities(
session: DbSession,
current_user: _DashboardUser,
query: Annotated[ActivityQuery, Query()],
) -> ApiResponse[ActivityPageOut]:
"""分页查询最近动态,可按模块筛选。
与聚合端点拆开:翻页只重取这张列表,不必重算总览/待办/预警。
"""
return ok(dashboard_service.list_activities(session, query))
+24
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))
+111
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)
)
+5 -8
View File
@@ -8,10 +8,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.settlement import (
AdjustmentCreateIn,
SettlementDetailOut,
@@ -21,16 +19,15 @@ from app.schemas.settlement import (
)
from app.services import settlement_service
router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"])
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/settlements", tags=["绩效工资 · 月度结算"])
_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
def generate(
payload: SettlementGenerateIn,
session: DbSession,
current_user: _SettlementWriter,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]:
"""按确认回款口径生成指定月份的结算快照(草稿)。"""
@@ -66,7 +63,7 @@ def get_settlement(
def submit(
settlement_id: Annotated[int, Path(ge=1)],
session: DbSession,
current_user: _SettlementWriter,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]:
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
@@ -83,7 +80,7 @@ def submit(
def create_adjustment(
payload: AdjustmentCreateIn,
session: DbSession,
current_user: _SettlementWriter,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[dict]:
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
@@ -0,0 +1,56 @@
"""角色权限配置与用户角色分配;旧制作人设置只保留兼容提示。"""
from fastapi import APIRouter
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ok
from app.schemas.authorization import (
AuthorizationPreviewIn,
AuthorizationSettingsIn,
RolePermissionsIn,
UserAuthorizationIn,
)
from app.services import authorization_management as service
from app.services.permission_catalog import CATALOG
router = APIRouter(prefix="/system", tags=["系统管理 · 权限"])
@router.get("/permissions/catalog")
def catalog(session: DbSession, actor: AdminUser):
service.require_admin(session, actor)
return ok(list(CATALOG.values()))
@router.get("/roles/{role_id}/permissions")
def role_permissions(role_id: int, session: DbSession, actor: AdminUser):
return ok(service.role_permissions(session, role_id))
@router.put("/roles/{role_id}/permissions")
def save_role(role_id: int, payload: RolePermissionsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_role(session, role_id, payload, actor, meta))
@router.get("/users/{user_id}/authorization")
def user_authorization(user_id: int, session: DbSession, actor: AdminUser):
return ok(service.user_authorization(session, user_id))
@router.post("/users/{user_id}/authorization/preview")
def preview(user_id: int, payload: AuthorizationPreviewIn, session: DbSession, actor: AdminUser):
return ok(service.preview(session, user_id, payload))
@router.put("/users/{user_id}/authorization")
def save_user(user_id: int, payload: UserAuthorizationIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_user(session, user_id, payload, actor, meta))
@router.get("/authorization-settings")
def settings(session: DbSession, actor: AdminUser):
return ok(service.settings_out(session))
@router.put("/authorization-settings")
def save_settings(payload: AuthorizationSettingsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_settings(session, payload, actor, meta))
+1 -1
View File
@@ -60,7 +60,7 @@ def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。
"""
return ok(business_role_service.list_template_options())
return ok(business_role_service.list_template_options(session))
@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")
@@ -0,0 +1,70 @@
"""计提公式模板管理接口(管理员)。"""
from typing import Annotated
from fastapi import APIRouter, Path
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, ok
from app.schemas.calc_template import (
CalcTemplateCreateIn,
CalcTemplateOut,
CalcTemplateUpdateIn,
FormulaOptionOut,
)
from app.services import calc_template_service
router = APIRouter(prefix="/calc-templates", tags=["系统管理 · 公式模板"])
@router.get("", response_model=ApiResponse[list[CalcTemplateOut]], summary="模板列表")
def list_templates(session: DbSession, _: AdminUser) -> ApiResponse[list[CalcTemplateOut]]:
"""取全部模板(含停用与岗位引用计数)。"""
return ok(calc_template_service.list_templates(session).records)
@router.get("/meta/formulas", response_model=ApiResponse[list[FormulaOptionOut]], summary="公式骨架下拉")
def list_formula_options(_: AdminUser) -> ApiResponse[list[FormulaOptionOut]]:
"""取受控公式骨架集合(新增模板时选择)。"""
return ok([FormulaOptionOut.model_validate(row) for row in calc_template_service.list_formula_options()])
@router.post("", response_model=ApiResponse[CalcTemplateOut], summary="新增模板")
def create_template(
session: DbSession,
current_user: AdminUser,
payload: CalcTemplateCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[CalcTemplateOut]:
"""新增公式模板。编码唯一、骨架受控、参数绑定按骨架角色校验。"""
return ok(
calc_template_service.create_template(session, payload, operator=current_user, meta=meta),
message="模板已创建",
)
@router.put("/{template_id}", response_model=ApiResponse[CalcTemplateOut], summary="编辑模板")
def update_template(
session: DbSession,
current_user: AdminUser,
template_id: Annotated[int, Path(ge=1)],
payload: CalcTemplateUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[CalcTemplateOut]:
"""编辑模板名称/说明/排序;自定义模板可调整参数绑定。"""
return ok(
calc_template_service.update_template(session, template_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.delete("/{template_id}", response_model=ApiResponse[None], summary="删除模板")
def delete_template(
session: DbSession,
current_user: AdminUser,
template_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除模板。系统模板禁删;被岗位引用禁删。"""
calc_template_service.delete_template(session, template_id, operator=current_user, meta=meta)
return ok(message="模板已删除")
+37
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)))
+56
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",
},
)
+95
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))
@@ -0,0 +1,114 @@
"""T+ 基础导入和预演的管理员入口;HTTP 不执行长查询或核算。"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from starlette.responses import StreamingResponse
from app.api.deps import DbSession, PayrollWriter, WorkbenchReader
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.schemas.tplus_business_sync import BusinessSyncRun, BusinessSyncUpdate, SyncRange
from app.schemas.tplus_workbench import (
CorrectionPairIn,
FactIn,
SourceChoiceQuery,
WorkCreateIn,
WorkQuery,
)
from app.services.tplus import business_sync_control, workbench
router = APIRouter()
RunNo = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]+$", max_length=32)]
@router.get("/business-sync")
def business_sync_config(session: DbSession, _: WorkbenchReader):
return ok(business_sync_control.configuration(session))
@router.put("/business-sync")
def business_sync_save(session: DbSession, actor: PayrollWriter, payload: BusinessSyncUpdate):
return ok(business_sync_control.save(session, payload, actor.id))
@router.post("/business-sync/preview")
def business_sync_preview(session: DbSession, _: PayrollWriter, payload: SyncRange):
return ok(business_sync_control.readonly_preview(session, payload.start_date))
@router.post("/business-sync/runs")
def business_sync_run(session: DbSession, actor: PayrollWriter, payload: BusinessSyncRun):
return ok(business_sync_control.queue(session, actor.id, payload.start_date, payload.request_key))
@router.post("/correction-pairs")
def save_correction_pair(session: DbSession, actor: PayrollWriter, payload: CorrectionPairIn):
"""成对排除转客户更正单,将实际回款关联原业务,保留实际到账日。"""
from app.services.tplus.corrections import save_pair
return ok(save_pair(session, payload, actor.id))
@router.get("/facts", response_model=ApiResponse[PageResult[dict]])
def facts(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):
"""有界查询每项补充资料的当前版本。"""
return ok(workbench.list_facts(session, query))
@router.post("/facts", response_model=ApiResponse[dict])
def save_fact(session: DbSession, admin: PayrollWriter, payload: FactIn):
"""追加有依据的人工事实;旧版本不覆盖。"""
return ok(workbench.save_fact(session, payload, admin.id))
@router.get("/facts/{fact_id}/history", response_model=ApiResponse[PageResult[dict]])
def fact_history(
session: DbSession, _: WorkbenchReader, fact_id: int, query: Annotated[PageQuery, Query()]
):
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
return ok(workbench.fact_history(session, fact_id, query))
@router.post("/work-runs", response_model=ApiResponse[dict])
def create_work(session: DbSession, admin: PayrollWriter, payload: WorkCreateIn):
"""创建导入预览、执行导入或核算预演任务。"""
return ok(workbench.queue_work(session, payload, admin.id))
@router.get("/work-runs", response_model=ApiResponse[PageResult[dict]])
def work_runs(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):
"""分页查询任务,所有金额结果仅为预演。"""
return ok(workbench.list_work(session, query))
@router.get("/work-runs/{run_no}", response_model=ApiResponse[dict])
def work_detail(session: DbSession, _: WorkbenchReader, run_no: RunNo):
"""查询规则版本、输入指纹及结果。"""
return ok(workbench.work_out(workbench.get_run(session, run_no)))
@router.get("/work-runs/{run_no}/lines", response_model=ApiResponse[PageResult[dict]])
def work_lines(
session: DbSession, _: WorkbenchReader, run_no: RunNo, query: Annotated[WorkQuery, Query()]
):
"""分页查看新增、待补及分配明细,不公开原始快照 JSON。"""
return ok(workbench.list_lines(session, run_no, query))
@router.get("/source-records", response_model=ApiResponse[PageResult[dict]])
def source_choices(
session: DbSession,
_: WorkbenchReader,
query: Annotated[SourceChoiceQuery, Query()],
):
"""仅提供挑选资料所需的源编号和名称,不返回电话、地址和原始载荷。"""
return ok(workbench.source_choices(session, query))
@router.get("/work-runs/{run_no}/report")
def work_report(session: DbSession, _: WorkbenchReader, run_no: RunNo):
"""导出全部预演明细,防止源单号被电子表格当作公式执行。"""
return StreamingResponse(
workbench.report_csv(session, run_no),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{run_no}-preview.csv"'},
)
+180 -1
View File
@@ -10,14 +10,25 @@
import argparse
import getpass
import json
import signal
import sys
import threading
from datetime import datetime
from pathlib import Path
from uuid import uuid4
from sqlalchemy import select
from app.core.config import get_settings
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
from app.core.db import dispose_engine, get_session_factory
from app.core.security import hash_password
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
from app.models import Role, User, UserRole
from app.repositories.tplus_repository import get_batch
from app.services.tplus.sync_service import TPlusSyncService
from app.services.tplus.worker import run_once
def _prompt_password() -> str:
@@ -80,6 +91,115 @@ def _create_admin(args: argparse.Namespace) -> int:
return 0
def _stage_tplus(args: argparse.Namespace) -> int:
"""读取 T+ 源库并写入 MySQL staging。"""
target_factory = get_session_factory()
source_factory = get_tplus_session_factory()
watermark = datetime.fromisoformat(args.watermark) if args.watermark else None
with source_factory() as source_session, target_factory() as target_session:
batch = TPlusSyncService(source_session, target_session).run(
entity_types=args.entity,
watermark=watermark,
batch_size=args.batch_size,
)
print(
f"T+ staging 完成:批次={batch.batch_no} 状态={batch.status} "
f"源记录={batch.source_record_count} 新增={batch.staged_record_count} "
f"失败={batch.failed_record_count}"
)
return 0 if batch.status == "SUCCEEDED" else 1
def _tplus_worker(args: argparse.Namespace) -> int:
"""独立消费持久化任务;HTTP 进程不承担长时间读取。"""
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
if not 2 <= args.poll_seconds <= 60:
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
return 1
factory = get_session_factory()
settings = get_settings()
stop_file = getattr(args, "stop_file", None) or settings.t_plus_stop_file
stopped = threading.Event()
owner = str(uuid4())
previous_handlers = {}
if threading.current_thread() is threading.main_thread():
for sig in (signal.SIGINT, signal.SIGTERM):
previous_handlers[sig] = signal.signal(sig, lambda *_: stopped.set())
try:
while not stopped.is_set() and not (stop_file and Path(stop_file).exists()):
with factory() as session:
batch_no = run_once(session, worker_owner=owner)
batch = get_batch(session, batch_no) if batch_no else None
if batch_no and batch is None:
from app.models import TPlusWorkRun
batch = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == batch_no))
status = batch.status if batch else None
if batch_no:
print(f"任务完成:{batch_no} 状态={status}", flush=True)
if not args.watch:
return 0 if status in (None, "SUCCEEDED") else 1
if batch_no is None or status != "SUCCEEDED":
stopped.wait(args.poll_seconds)
print("T+读取服务已停止领取新任务", flush=True)
return 0
except KeyboardInterrupt:
print("T+ 读取服务已停止")
return 130
except Exception as exc:
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
return 1
finally:
for sig, handler in previous_handlers.items():
signal.signal(sig, handler)
try:
from app.services.tplus.automation import heartbeat
heartbeat(factory, settings, owner, "STOPPED")
except Exception as exc:
print(f"T+停止状态未能写入({type(exc).__name__}),请检查迁移与运行配置", file=sys.stderr)
def _maintenance(args):
from app.services.tplus.automation import tick
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
if args.stop_file:
get_settings().t_plus_stop_file = args.stop_file
with get_session_factory()() as session:
result = tick(session)
print(json.dumps(result, ensure_ascii=False))
return 0
def _tplus_supervisor(args):
from app.services.tplus.supervisor import run
return run(get_session_factory(), get_settings(), interval=args.poll_seconds)
def _materials(args):
from app.services.tplus.materials import collect_materials, render_markdown
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
output = Path(args.output_dir).resolve()
targets = [output / f"materials-{args.customer_id}-{args.month}.{ext}" for ext in ("json", "md")]
if any(p.exists() for p in targets):
raise RuntimeError("核对包已存在,请指定新的输出目录,避免覆盖既有验收依据")
with get_tplus_session_factory()() as source:
report = collect_materials(source, args.customer_id, args.month)
output.mkdir(parents=True, exist_ok=True)
targets[0].write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
targets[1].write_text(render_markdown(report), encoding="utf-8")
print(f"样本核对包已生成;待补{len(report['missing'])}项;真实金额尚未验收:{output}")
return 0
def main() -> int:
"""命令行入口。"""
parser = argparse.ArgumentParser(
@@ -96,12 +216,71 @@ def main() -> int:
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
)
p_tplus = sub.add_parser("tplus-stage", help="读取 T+ 源库并写入原始 staging")
p_tplus.add_argument(
"--entity",
action="append",
choices=[
"personnel",
"products",
"customers",
"businesses",
"shipments",
"receipts",
"business_items",
"shipment_items",
"receipt_items",
],
required=True,
help="实体类型,可重复指定",
)
p_tplus.add_argument(
"--watermark",
default=None,
help="增量起始时间,ISO 8601 格式;不传表示全量读取",
)
p_tplus.add_argument("--batch-size", type=int, default=500, help="每次从源库读取的行数")
p_worker = sub.add_parser("tplus-worker", help="执行已排队的 T+ 读取或关联核验")
modes = p_worker.add_mutually_exclusive_group()
modes.add_argument("--once", action="store_true", help="执行一个任务后退出(默认)")
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
p_worker.add_argument("--stop-file", default=None, help="检测到停止标记后不再领取新任务")
p_maintenance = sub.add_parser("tplus-maintain", help="检查每周原始采集与有限重试,不生成工资")
p_maintenance.add_argument("--once", action="store_true", help="执行一次检查(默认)")
p_maintenance.add_argument("--connection-file", default=None)
p_maintenance.add_argument("--stop-file", default=None)
p_supervisor = sub.add_parser("tplus-supervisor", help="按页面设置托管 T+ 读取服务")
p_supervisor.add_argument("--poll-seconds", type=int, choices=range(2, 61), default=5)
p_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资")
p_materials.add_argument("--customer-id", type=int, required=True)
p_materials.add_argument("--month", required=True)
p_materials.add_argument("--connection-file", default=None)
p_materials.add_argument("--output-dir", required=True)
args = parser.parse_args()
try:
return _create_admin(args)
if args.command == "create-admin":
return _create_admin(args)
if args.command == "tplus-worker":
return _tplus_worker(args)
if args.command == "tplus-maintain":
return _maintenance(args)
if args.command == "tplus-supervisor":
return _tplus_supervisor(args)
if args.command == "tplus-materials":
return _materials(args)
return _stage_tplus(args)
except Exception as exc:
print(f"命令执行失败({type(exc).__name__}),请检查参数和只读连接配置", file=sys.stderr)
return 1
finally:
dispose_engine()
dispose_tplus_engine()
if __name__ == "__main__":
+76 -5
View File
@@ -11,6 +11,7 @@
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -35,7 +36,28 @@ class Settings(BaseSettings):
# ===== 应用 =====
app_env: str = "dev"
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符")
upload_root: Path | None = Field(default=None, validation_alias="UPLOAD_DIR")
@field_validator("upload_root", mode="before")
@classmethod
def _empty_upload_root(cls, value):
return None if value in (None, "") else value
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符", repr=False)
payroll_maker_user_id: int | None = Field(
default=None, ge=1, description="指定工资制作人的现有账号ID;未设置则业务只读"
)
payroll_allow_legacy_calculation: bool = False
payroll_approval_enabled: bool = False
# ===== 单进程运行监控;历史只清理监控新表 =====
monitor_enabled: bool = True
monitor_cpu_threshold: float = Field(default=85, ge=1, le=100)
monitor_memory_threshold: float = Field(default=85, ge=1, le=100)
monitor_disk_threshold: float = Field(default=90, ge=1, le=100)
monitor_resource_duration_seconds: int = Field(default=300, ge=15)
monitor_slow_request_ms: int = Field(default=2000, ge=1)
monitor_error_rate_threshold: float = Field(default=5, ge=0.1, le=100)
monitor_error_min_requests: int = Field(default=20, ge=1)
# ===== 鉴权 =====
# 规范第 13 节:Token 有效期 ≤ 2 小时
@@ -50,14 +72,39 @@ class Settings(BaseSettings):
mysql_port: int = 3306
mysql_database: str = "payroll"
mysql_user: str = "payroll"
mysql_password: str = ""
mysql_password: str = Field(default="", repr=False)
# ===== Redis =====
redis_host: str = "127.0.0.1"
redis_port: int = 6379
redis_password: str = ""
redis_password: str = Field(default="", repr=False)
redis_db: int = 0
# ===== T+ 只读 SQL Server =====
# T+ 数据库默认关闭,避免开发者未配置驱动时影响主业务启动。
t_plus_enabled: bool = False
t_plus_host: str = "127.0.0.1"
t_plus_port: int = 1433
t_plus_database: str = ""
t_plus_user: str = ""
t_plus_password: str = Field(default="", repr=False)
t_plus_allow_dev_sa: bool = False
t_plus_allow_admin_account: bool = False
t_plus_connection_file: str = ""
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
t_plus_odbc_driver: str = ""
t_plus_encrypt: bool = True
# 默认校验服务器证书;自签回退只允许隔离开发显式开启,避免中间人替换证书。
t_plus_trust_server_certificate: bool = False
t_plus_connect_timeout: int = 10
t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
t_plus_query_dir: str = ""
t_plus_automation_enabled: bool = False
t_plus_schedule_weekday: int = Field(default=0, ge=0, le=6)
t_plus_schedule_time: str = Field(default="02:00", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
t_plus_stop_file: str = ""
t_plus_supervisor_health_url: str = "http://127.0.0.1:8000/api/health"
@field_validator("app_secret_key")
@classmethod
def _check_secret_key(cls, v: str) -> str:
@@ -89,9 +136,33 @@ class Settings(BaseSettings):
@property
def redis_url(self) -> str:
"""Redis 连接 URL。密码为空时不拼接认证段。"""
auth = f":{self.redis_password}@" if self.redis_password else ""
auth = f":{quote(self.redis_password, safe='')}@" if self.redis_password else ""
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
@property
def t_plus_database_url(self) -> URL:
"""构造 T+ 只读 SQL Server 连接 URL。
连接 URL 只在启用 T+ 适配器时使用;密码由 SQLAlchemy URL 对象保存,
日志中调用 ``render_as_string()`` 时仍会默认隐藏密码。
ODBC 的分号和花括号由方言统一转义,调用方不得再手工拼接连接串。
"""
query = {
"driver": self.t_plus_odbc_driver,
"Encrypt": "yes" if self.t_plus_encrypt else "no",
"TrustServerCertificate": "yes" if self.t_plus_trust_server_certificate else "no",
"Connection Timeout": str(self.t_plus_connect_timeout),
}
return URL.create(
drivername="mssql+pyodbc",
username=self.t_plus_user,
password=self.t_plus_password,
host=self.t_plus_host,
port=self.t_plus_port,
database=self.t_plus_database,
query=query,
)
@property
def upload_dir(self) -> Path:
"""上传文件根目录(公司 logo 等):backend/uploads/。
@@ -99,7 +170,7 @@ class Settings(BaseSettings):
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
"""
return BACKEND_DIR / "uploads"
return self.upload_root.resolve() if self.upload_root else BACKEND_DIR / "uploads"
@lru_cache
+6 -3
View File
@@ -74,13 +74,16 @@ async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONRespons
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse:
"""兜底处理未预期异常。
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
"""
logger.exception("未预期的服务端异常")
return _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
request_id = getattr(request.state, "request_id", "-")
logger.exception("未预期的服务端异常", extra={"request_id": request_id})
response = _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
response.headers["X-Request-ID"] = request_id
return response
def register_exception_handlers(app: FastAPI) -> None:
+7
View File
@@ -52,3 +52,10 @@ class NotFoundError(AppException):
code = CODE_NOT_FOUND
message = "资源不存在"
class ConflictError(AppException):
"""授权版本已变化,客户端应刷新后重新核对。"""
code = 409
message = "配置已被其他人修改,请刷新后重新核对"
+5 -2
View File
@@ -1,6 +1,6 @@
"""日志配置。
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天+大小切分、
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天轮转(当前未做大小上限,磁盘容量需配合监控)、
保留 30 天、禁止输出密码/手机号/身份证/Token。
Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8,
@@ -13,6 +13,7 @@ import sys
from logging.handlers import TimedRotatingFileHandler
from app.core.config import BACKEND_DIR
from app.core.monitoring import REQUEST_ID
LOG_DIR = BACKEND_DIR / "logs"
@@ -53,6 +54,8 @@ class SensitiveFormatter(logging.Formatter):
"""
def format(self, record: logging.LogRecord) -> str:
if not hasattr(record, "request_id"):
record.request_id = REQUEST_ID.get()
return scrub(super().format(record))
@@ -71,7 +74,7 @@ def setup_logging(level: str = "INFO", log_to_file: bool = True) -> None:
root.removeHandler(handler)
formatter = SensitiveFormatter(
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
fmt="%(asctime)s | %(levelname)-8s | %(name)s | request_id=%(request_id)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
+240
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)
+118
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
+48
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()
+13 -2
View File
@@ -14,13 +14,16 @@ from app.core.config import get_settings
from app.core.db import dispose_engine
from app.core.exception_handlers import register_exception_handlers
from app.core.logging import setup_logging
from app.core.monitoring import MonitorMiddleware, Recorder
from app.core.redis_client import close_redis
from app.core.tplus_db import dispose_tplus_engine
from app.services.monitor_collector import MonitorCollector
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期管理。
启动阶段不建立数据库/Redis 连接(延迟到首次使用),
@@ -33,8 +36,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
settings.mysql_host,
settings.mysql_database,
)
yield
app.state.monitor.start()
try:
yield
finally:
app.state.monitor.stop()
dispose_engine()
dispose_tplus_engine()
close_redis()
logger.info("应用已停止,连接池已释放")
@@ -60,6 +68,9 @@ def create_app() -> FastAPI:
register_exception_handlers(app)
app.include_router(api_router, prefix="/api")
recorder = Recorder()
app.state.monitor = MonitorCollector(settings, recorder)
app.add_middleware(MonitorMiddleware, recorder=recorder)
return app
+49
View File
@@ -10,15 +10,20 @@
"""
from app.models.approval import ApprovalOrder, ApprovalStep
from app.models.authorization import AuthorizationSetting, UserPermissionOverride
from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole
from app.models.calc_template import CalcTemplateDefinition
from app.models.customer import Customer, OwnershipChangeLog
from app.models.login_log import LoginLog
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
from app.models.payroll import PayrollAllocation, PayrollInput, PayrollMonth
from app.models.performance_ledger import PerformanceLedger
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
from app.models.probation import ProbationDeferral, ProbationPayment
from app.models.product import Product
from app.models.rating import CustomerRatingGrade
from app.models.receipt import Receipt
@@ -26,31 +31,60 @@ from app.models.role import Permission, Role, RolePermission
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
from app.models.shipment import Shipment, ShipmentItem
from app.models.system_setting import SystemSetting
from app.models.tplus_automation import (
TPlusAutomationConfig,
TPlusAutomationState,
TPlusRuntimeControl,
)
from app.models.tplus_business_sync import TPlusBusinessDocument, TPlusBusinessSyncConfig
from app.models.tplus_sync import (
TPlusSyncBatch,
TPlusSyncError,
TPlusSyncRecord,
TPlusSyncWatermark,
)
from app.models.tplus_workbench import (
TPlusFact,
TPlusSourceHead,
TPlusSourceLink,
TPlusWorkLine,
TPlusWorkRun,
)
from app.models.user import User, UserRole
__all__ = [
"AdjustmentOrder",
"ApprovalOrder",
"ApprovalStep",
"AuthorizationSetting",
"Base",
"Business",
"BusinessItem",
"BusinessRole",
"BusinessStatusLog",
"CalcTemplateDefinition",
"Customer",
"CustomerRatingGrade",
"LoginLog",
"MonitorEvent",
"MonitorRequestMinute",
"MonitorSnapshot",
"MonthlySettlement",
"OperationLog",
"OwnershipChangeLog",
"ParamChangeBatch",
"ParamDefinition",
"ParamValue",
"PayrollAllocation",
"PayrollInput",
"PayrollMonth",
"PerformanceLedger",
"Permission",
"Personnel",
"PersonnelPlan",
"PersonnelRegion",
"ProbationDeferral",
"ProbationPayment",
"Product",
"Receipt",
"Role",
@@ -60,7 +94,22 @@ __all__ = [
"Shipment",
"ShipmentItem",
"SystemSetting",
"TPlusAutomationConfig",
"TPlusAutomationState",
"TPlusBusinessDocument",
"TPlusBusinessSyncConfig",
"TPlusFact",
"TPlusRuntimeControl",
"TPlusSourceHead",
"TPlusSourceLink",
"TPlusSyncBatch",
"TPlusSyncError",
"TPlusSyncRecord",
"TPlusSyncWatermark",
"TPlusWorkLine",
"TPlusWorkRun",
"TimestampMixin",
"User",
"UserPermissionOverride",
"UserRole",
]
+31 -1
View File
@@ -15,7 +15,16 @@ ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, text
from sqlalchemy import (
JSON,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
@@ -54,7 +63,17 @@ class ApprovalOrder(Base, TimestampMixin):
__tablename__ = "approval_orders"
__table_args__ = mysql_table_args(
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
# 同一引用对象的第 N 次送审在库里有唯一一行:并发提单算重版本号时
# 由 DB 兜住(应用层取号走锁读,见 approval_service._next_version)
UniqueConstraint(
"order_type",
"ref_id",
"version_no",
name="uk_approval_orders_order_type_ref_version",
),
Index("idx_approval_orders_type_status", "order_type", "status"),
# 版本号按 (order_type, ref_id) 递增,须有对应索引避免全表 max 扫描
Index("idx_approval_orders_type_ref", "order_type", "ref_id"),
)
id: Mapped[int] = mapped_column(
@@ -76,6 +95,17 @@ class ApprovalOrder(Base, TimestampMixin):
refer_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
)
version_no: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="1",
comment="提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖;"
"同一对象内唯一,由uk_approval_orders_order_type_ref_version强制)",
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON,
comment="提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)",
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
+28
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")
+3
View File
@@ -91,6 +91,9 @@ class TPlusSyncMixin:
String(32), comment="同步批次号(同步模块写入)"
)
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
source_updated_at: Mapped[datetime | None] = mapped_column(
DateTime, comment="T+ 源数据更新时间(未确认时可为空)"
)
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
+40 -9
View File
@@ -15,7 +15,18 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
from sqlalchemy import (
JSON,
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
@@ -42,27 +53,34 @@ class SecondaryAllocation(Base, TimestampMixin):
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
与比例,响应以 hint 提示合计/余额,不做强制校验
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
- **版本行 append-only**:编辑全量替换与驳回清空都把旧行置
``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由
``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照
(``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只
补齐「方案事实留存」,不改变核算口径
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
同一合同的同一参与人仅一行,重复提交在服务层聚合。
冗余固化,人离职后方案回显仍有名。uk 锁
(business_id, version_no, personnel_id),同一版本内同一参与人仅一行,
重复提交在服务层聚合;跨版本允许同一人再次出现。
"""
__tablename__ = "business_secondary_allocations"
__table_args__ = mysql_table_args(
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
"业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)",
UniqueConstraint(
"business_id",
"version_no",
"personnel_id",
name="uk_business_secondary_allocations_business_personnel",
name="uk_business_secondary_allocations_business_version_personnel",
),
Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
Index("idx_business_secondary_allocations_business_active", "business_id", "is_active"),
)
id: Mapped[int] = mapped_column(
@@ -74,6 +92,12 @@ class SecondaryAllocation(Base, TimestampMixin):
nullable=False,
comment="业务ID(businesses.id;业务删除随删)",
)
version_no: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="1",
comment="方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)",
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
@@ -88,6 +112,12 @@ class SecondaryAllocation(Base, TimestampMixin):
nullable=False,
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
)
is_active: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)",
)
class Business(Base, TimestampMixin, TPlusSyncMixin):
@@ -118,6 +148,7 @@ class Business(Base, TimestampMixin, TPlusSyncMixin):
comment="客户ID(customers.id)",
)
business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)")
integration_json: Mapped[dict | None] = mapped_column(JSON, comment="T+业务接入范围及来源人员摘要,非核算快照")
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
@@ -210,9 +241,9 @@ class BusinessItem(Base, TimestampMixin):
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
product_category: Mapped[str] = mapped_column(
product_category: Mapped[str | None] = mapped_column(
String(32),
nullable=False,
nullable=True,
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
)
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)")
+52
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自定义",
)
+10 -3
View File
@@ -49,10 +49,9 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
address: Mapped[str | None] = mapped_column(String(255), comment="地址")
industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)")
customer_type: Mapped[str] = mapped_column(
customer_type: Mapped[str | None] = mapped_column(
String(32),
nullable=False,
server_default="personal",
nullable=True,
index=True,
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
)
@@ -63,6 +62,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
rating_score: Mapped[int | None] = mapped_column(
TINYINT_TYPE,
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
)
rating_time: Mapped[datetime | None] = mapped_column(
DateTime,
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
)
class OwnershipChangeLog(Base):
+79
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="恢复时间,仅用于健康异常"
)
+66
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="单据、成本、归属、参数和逐段计算依据")
+6 -1
View File
@@ -55,6 +55,7 @@ class PerformanceLedger(Base, TimestampMixin):
"receipt_id",
"calc_version",
"personnel_id",
"allocation_key",
name="uk_performance_ledgers_receipt_id_calc_version_personnel",
),
Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"),
@@ -65,6 +66,7 @@ class PerformanceLedger(Base, TimestampMixin):
"calc_type",
),
Index("idx_performance_ledgers_personnel_id", "personnel_id"),
Index("idx_performance_ledgers_receipt_id", "receipt_id"),
Index("idx_performance_ledgers_business_id", "business_id"),
)
@@ -82,6 +84,9 @@ class PerformanceLedger(Base, TimestampMixin):
server_default="1",
comment="核算版本(重算+1,旧行 is_active=0)",
)
allocation_key: Mapped[str] = mapped_column(
String(64), nullable=False, server_default="''", comment="正式FIFO事件键;历史单笔核算为空串",
)
is_active: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
@@ -143,7 +148,7 @@ class PerformanceLedger(Base, TimestampMixin):
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="以物抵顶折算:1已按 80% 折算 0否",
comment="以物抵顶处理标记:当前系统不自动折算,1表示由线下规则处理 0表示未标记",
)
weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)")
payment_weight: Mapped[Decimal | None] = mapped_column(
+14 -1
View File
@@ -10,7 +10,7 @@
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy import JSON, Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
@@ -62,9 +62,22 @@ class Personnel(Base, TimestampMixin, TPlusSyncMixin):
server_default="0",
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
)
probation_start_date: Mapped[date | None] = mapped_column(
Date, comment="试用期开始日(含当日)"
)
regularization_date: Mapped[date | None] = mapped_column(
Date, comment="转正生效日(当日不再属于试用期)"
)
resignation_date: Mapped[date | None] = mapped_column(
Date, comment="离职生效日(当日起不计基本工资)"
)
salary_standard: Mapped[Decimal | None] = mapped_column(
Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)"
)
salary_history_json: Mapped[list | None] = mapped_column(
JSON,
comment="工资标准、岗位及任职资料生效版本",
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
+81
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="创建时间"
)
+5 -3
View File
@@ -8,7 +8,7 @@
from decimal import Decimal
from sqlalchemy import Numeric, String, UniqueConstraint
from sqlalchemy import JSON, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
@@ -47,12 +47,14 @@ class Product(Base, TimestampMixin, TPlusSyncMixin):
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="销售单价(元,T+ 同步)"
)
product_category: Mapped[str] = mapped_column(
product_category: Mapped[str | None] = mapped_column(
String(32),
nullable=False,
nullable=True,
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
source_trade_json: Mapped[dict | None] = mapped_column(JSON, comment="最近有效发货数量、含税成交价及源单据快照")
quantity_price_manual: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, server_default="0", comment="数量单价人工保护:1人工维护 0随源更新")
+9 -3
View File
@@ -10,10 +10,10 @@
- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开
"""
from datetime import date
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
@@ -72,7 +72,7 @@ class Receipt(Base, TimestampMixin, TPlusSyncMixin):
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
comment="以物抵顶标记:1是 0否(特殊绩效线下核实,系统不自动折算)",
)
settlement_status: Mapped[int] = mapped_column(
TINYINT_TYPE,
@@ -80,4 +80,10 @@ class Receipt(Base, TimestampMixin, TPlusSyncMixin):
server_default="0",
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
)
confirmation_status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="0", comment="人工确认状态:0待确认 1已确认,独立于凭证及核算状态"
)
confirmed_by: Mapped[str | None] = mapped_column(String(64), comment="人工确认人账号快照")
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime, comment="人工确认时间")
confirmed_revision: Mapped[str | None] = mapped_column(String(64), comment="人工确认时回款关键内容指纹")
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
+19 -2
View File
@@ -79,6 +79,10 @@ class MonthlySettlement(Base, TimestampMixin):
)
locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)")
@property
def approval_required(self) -> bool:
return bool((self.snapshot_json or {}).get("approval_required", True))
class SettlementItem(Base):
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
@@ -127,17 +131,27 @@ class SettlementItem(Base):
String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE"
)
net_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="净工资 = 绩效±基本工资(按口径;试用期 0)"
Numeric(14, 2),
comment="净工资 = 绩效±基本工资(按口径;试用期为基本工资+正常应付+年度差额,缓发部分不发)"
)
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
comment="试用期:1缓发绩效不发(基本工资正常发,超年累计记 note)0否",
)
probation_note: Mapped[str | None] = mapped_column(
String(255), comment="试用期说明(年累计超阈值部分年底一次性结算)"
)
normal_payable_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, server_default="0", comment="当月正常实付绩效"
)
deferred_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, server_default="0", comment="当月试用期缓发变化"
)
probation_payment_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, server_default="0", comment="本月年度补发或扣回"
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="个人明细快照(台账行引用列表)"
)
@@ -154,6 +168,9 @@ class AdjustmentOrder(Base, TimestampMixin):
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
adjustment_type: Mapped[str] = mapped_column(String(32), nullable=False, server_default="'GENERAL'", comment="调整类型:GENERAL普通 ALLOCATION分配差额")
source_ledger_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="分配差额对应的原台账ID")
attribution_json: Mapped[dict | None] = mapped_column(JSON, comment="原台账业务与分管归属快照")
order_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
+2 -3
View File
@@ -49,10 +49,9 @@ class Shipment(Base, TimestampMixin, TPlusSyncMixin):
ship_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)"
)
freight_amount: Mapped[Decimal] = mapped_column(
freight_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
nullable=False,
server_default="0",
nullable=True,
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
)
total_amount: Mapped[Decimal] = mapped_column(
+60
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")
+51
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="最小必要来源与差异说明")
+151
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"
)
+140
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)
+115 -90
View File
@@ -5,9 +5,7 @@
models/customer.py 定义)。
"""
from datetime import date
from sqlalchemy import Row, Select, delete, exists, func, or_, select
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
from sqlalchemy.orm import Session, aliased
from app.models import (
@@ -17,12 +15,20 @@ from app.models import (
Customer,
OwnershipChangeLog,
Personnel,
Product,
Receipt,
SecondaryAllocation,
Shipment,
TPlusBusinessDocument,
)
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.schemas.business import BusinessQuery
READINESS_SQL_REASONS = frozenset({
"CUSTOMER_CATEGORY_PENDING", "PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SOURCE_CONFLICT",
})
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
@@ -77,50 +83,63 @@ def get_with_names(
return session.execute(stmt).first()
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> Select:
def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
"""业务列表的筛选条件(调用方须已 join Customer)。"""
if keyword:
if query.keyword:
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
stmt = stmt.where(
or_(
Business.business_no.contains(keyword, autoescape=True),
Customer.company_name.contains(keyword, autoescape=True),
Business.business_no.contains(query.keyword, autoescape=True),
Customer.company_name.contains(query.keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(Business.status == status)
if customer_id is not None:
stmt = stmt.where(Business.customer_id == customer_id)
if owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
if date_from is not None:
stmt = stmt.where(Business.business_date >= date_from)
if date_to is not None:
stmt = stmt.where(Business.business_date <= date_to)
if query.status is not None:
stmt = stmt.where(Business.status == query.status)
if query.customer_id is not None:
stmt = stmt.where(Business.customer_id == query.customer_id)
if query.owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id)
if query.date_from is not None:
stmt = stmt.where(Business.business_date >= query.date_from)
if query.date_to is not None:
stmt = stmt.where(Business.business_date <= query.date_to)
if query.source_type == "TPLUS":
stmt = stmt.where(Business.t_plus_id.is_not(None))
elif query.source_type == "MANUAL":
stmt = stmt.where(Business.t_plus_id.is_(None))
if query.source_personnel:
stmt = stmt.where(Business.integration_json["personnel_names"].as_string().contains(query.source_personnel, autoescape=True))
if query.readiness_reason in READINESS_SQL_REASONS:
stmt = stmt.where(Business.integration_json["source_database"].as_string().is_not(None))
if query.readiness_reason == "CUSTOMER_CATEGORY_PENDING":
stmt = stmt.where(Customer.customer_type.is_(None))
elif query.readiness_reason == "PRODUCT_CATEGORY_PENDING":
stmt = stmt.where(exists(select(BusinessItem.id).join(Product, Product.id == BusinessItem.product_id).where(
BusinessItem.business_id == Business.id, Product.product_category.is_(None))))
elif query.readiness_reason == "NO_ACTUAL_SHIPMENT":
stmt = stmt.where(~exists().where(Shipment.business_id == Business.id))
elif query.readiness_reason == "SOURCE_CONFLICT":
stmt = stmt.where(exists().where(TPlusBusinessDocument.business_id == Business.id,
TPlusBusinessDocument.status.in_(("CONFLICT", "PENDING"))))
return stmt
def scan_businesses(session: Session, query: BusinessQuery, before_id: int | None, limit: int = 100):
"""按主键倒序分批读取,供需要计算准备状态的筛选使用。"""
owner = aliased(Personnel)
assistant = aliased(Personnel)
stmt = select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name).join(
Customer, Customer.id == Business.customer_id)
stmt = _apply_filters(stmt, query).outerjoin(owner, owner.id == Business.owner_personnel_id).outerjoin(
assistant, assistant.id == Business.assistant_personnel_id)
if before_id is not None:
stmt = stmt.where(Business.id < before_id)
return list(session.execute(stmt.order_by(Business.id.desc()).limit(limit)).all())
def list_businesses(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
session: Session, query: BusinessQuery
) -> list[tuple[Business, str, str | None, str | None]]:
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
owner = aliased(Personnel)
@@ -128,49 +147,24 @@ def list_businesses(
stmt = select(
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
).join(Customer, Customer.id == Business.customer_id)
stmt = _apply_filters(
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
stmt = _apply_filters(stmt, query)
stmt = (
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
.order_by(Business.id.desc())
.offset(offset)
.limit(limit)
.offset(query.offset)
.limit(query.page_size)
)
return list(session.execute(stmt).all())
def count_businesses(
session: Session,
*,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> int:
def count_businesses(session: Session, query: BusinessQuery) -> int:
stmt = (
select(func.count())
.select_from(Business)
.join(Customer, Customer.id == Business.customer_id)
)
stmt = _apply_filters(
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
stmt = _apply_filters(stmt, query)
return int(session.execute(stmt).scalar_one())
@@ -199,43 +193,75 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
session.flush()
def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
"""二次分配方案(详情回显;空列表=未设置方案)。"""
def list_allocations(
session: Session, business_id: int, *, active_only: bool = True
) -> list[SecondaryAllocation]:
"""二次分配方案(详情回显与核算取数;默认仅当前生效版本)。"""
stmt = select(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
if active_only:
stmt = stmt.where(SecondaryAllocation.is_active == 1)
return list(
session.execute(
select(SecondaryAllocation)
.where(SecondaryAllocation.business_id == business_id)
.order_by(SecondaryAllocation.id)
stmt.order_by(SecondaryAllocation.version_no, SecondaryAllocation.id)
).scalars()
)
def list_allocation_versions(session: Session, business_id: int) -> list[SecondaryAllocation]:
"""方案全部版本行(含历史;业务详情「方案历史」用,按版本正序)。"""
return list_allocations(session, business_id, active_only=False)
def replace_allocations(
session: Session, business_id: int, rows: list[SecondaryAllocation]
) -> None:
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
"""全量替换二次分配方案:旧生效行置历史,新行按下一版本号插入。
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
不 commit(事务边界在服务层)。版本行 append-only——编辑与清空都不
物理删除,驳回后仍可回溯被否决方案的参与人与比例。空 ``rows`` 表示
只清空生效态(不产生新版本)。
"""
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
deactivate_allocations(session, business_id)
if not rows:
return
next_version = (
session.execute(
select(func.coalesce(func.max(SecondaryAllocation.version_no), 0)).where(
SecondaryAllocation.business_id == business_id
)
).scalar_one()
+ 1
)
session.flush()
for row in rows:
row.business_id = business_id
row.version_no = next_version
row.is_active = 1
session.add(row)
session.flush()
def delete_allocations(session: Session, business_id: int) -> None:
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
def deactivate_allocations(session: Session, business_id: int) -> None:
"""把业务的当前生效方案置为历史(审批驳回/编辑替换时调用;行保留)。
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
方案事实由审批单留痕与操作日志 request_data 承载。
被否决的方案不得以「当前生效行」身份继续被核算取到;``is_active=0``
后核算侧自然读不到,而历史版本仍可在业务详情回溯。不 commit。
显式声明 ``synchronize_session="fetch"``:Core UPDATE 默认走 auto 策略
(优先 evaluate,本例的条件恰好能在 Python 侧求值,故实测也会刷新在途
对象)。显式写 fetch 是为了不把「在途对象被刷新」这件事押在 evaluate
能否解析条件上——条件一旦复杂化,auto 会退化为不刷新,同一会话里已
加载的方案行就会停留在 ``is_active=1`` 的过期内存值(查询虽有
is_active 过滤,但行对象本身仍是「生效态」,被序列化或参与判断就是
错的)。
"""
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
update(SecondaryAllocation)
.where(
SecondaryAllocation.business_id == business_id,
SecondaryAllocation.is_active == 1,
)
.values(is_active=0)
.execution_options(synchronize_session="fetch")
)
session.flush()
@@ -281,18 +307,17 @@ def list_ownership_logs(
) -> list[Row]:
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session,
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,
offset=offset,
limit=limit,
session, _business_log_ref(business_id), offset=offset, limit=limit
)
def count_ownership_logs(session: Session, business_id: int) -> int:
return ownership_log_repository.count_by_ref(
session,
return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id))
def _business_log_ref(business_id: int) -> OwnershipLogRef:
"""业务维度的归属留痕锚点。"""
return OwnershipLogRef(
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessRole
from app.schemas.business_role import BusinessRoleQuery
# ==================== 读取 ====================
@@ -39,41 +40,48 @@ def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, Business
return {role.role_code: role for role in rows}
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[BusinessRole]:
def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]:
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
stmt = _apply_filters(select(BusinessRole), query)
stmt = (
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
.offset(query.offset)
.limit(query.page_size)
)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
def count_roles(session: Session, query: BusinessRoleQuery) -> int:
"""统计符合条件的岗位总数,供分页使用。"""
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
stmt = _apply_filters(select(func.count(BusinessRole.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
def list_all(session: Session) -> list[BusinessRole]:
"""全部岗位(含停用,不分页)——岗位下拉选项的取数口径。
岗位规模有限(内置 + 自定义),一次取全量,不受分页上限约束;
排序与 list_roles 一致(sort_order, id),下拉与列表相对次序相同。
"""
stmt = select(BusinessRole).order_by(BusinessRole.sort_order, BusinessRole.id)
return list(session.execute(stmt).scalars())
def _apply_filters(stmt: Select, query: BusinessRoleQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
不转义时 ``_`` 会当单字符通配执行。
"""
if keyword:
if query.keyword:
stmt = stmt.where(
or_(
BusinessRole.role_code.contains(keyword, autoescape=True),
BusinessRole.role_name.contains(keyword, autoescape=True),
BusinessRole.role_code.contains(query.keyword, autoescape=True),
BusinessRole.role_name.contains(query.keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(BusinessRole.status == status)
if query.status is not None:
stmt = stmt.where(BusinessRole.status == query.status)
return stmt
@@ -0,0 +1,66 @@
"""计提公式模板数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import BusinessRole, CalcTemplateDefinition
def get_by_id(session: Session, template_id: int, *, for_update: bool = False) -> CalcTemplateDefinition | None:
"""按主键查询;删除校验走行锁。"""
if for_update:
stmt = select(CalcTemplateDefinition).where(CalcTemplateDefinition.id == template_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(CalcTemplateDefinition, template_id)
def get_by_code(session: Session, template_code: str) -> CalcTemplateDefinition | None:
"""按编码查询模板。编码是岗位绑定与参数解析的锚点。"""
stmt = select(CalcTemplateDefinition).where(CalcTemplateDefinition.template_code == template_code)
return session.execute(stmt).scalar_one_or_none()
def list_all(session: Session) -> list[CalcTemplateDefinition]:
"""全部模板(含停用,供管理列表展示引用状态)。"""
stmt = select(CalcTemplateDefinition).order_by(
CalcTemplateDefinition.sort_order, CalcTemplateDefinition.id
)
return list(session.execute(stmt).scalars())
def list_active(session: Session) -> list[CalcTemplateDefinition]:
"""启用模板,供岗位表单下拉。"""
stmt = (
select(CalcTemplateDefinition)
.where(CalcTemplateDefinition.status == 1)
.order_by(CalcTemplateDefinition.sort_order, CalcTemplateDefinition.id)
)
return list(session.execute(stmt).scalars())
def count_roles_by_codes(session: Session, codes: list[str]) -> dict[str, int]:
"""批量统计各模板被岗位引用的数量,用于删除保护与列表展示。"""
if not codes:
return {}
rows = session.execute(
select(BusinessRole.calc_template, func.count(BusinessRole.id))
.where(BusinessRole.calc_template.in_(codes))
.group_by(BusinessRole.calc_template)
).all()
return {code: int(count) for code, count in rows}
def create(session: Session, template: CalcTemplateDefinition) -> CalcTemplateDefinition:
"""新增模板。只 flush 以取得自增 id。"""
session.add(template)
session.flush()
return template
def delete(session: Session, template: CalcTemplateDefinition) -> None:
"""删除模板。调用方须先完成保护规则校验。"""
session.delete(template)
session.flush()
+45 -48
View File
@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
from app.models import Customer, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.repositories.query_utils import fuzzy
from app.schemas.customer import CustomerQuery
# ==================== 读取 ====================
@@ -45,49 +47,27 @@ def get_by_company_owner(session: Session, company_name: str, owner_name: str) -
return session.execute(stmt).scalar_one_or_none()
def list_customers(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> list[Row]:
def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
"""
stmt = _apply_filters(
_base_select(),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
stmt = _apply_filters(_base_select(), query)
stmt = _apply_order(stmt, query)
stmt = stmt.offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).all())
def count_customers(
session: Session,
*,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> int:
def count_customers(session: Session, query: CustomerQuery) -> int:
"""统计符合条件的客户总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(
select(func.count(Customer.id)),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = _apply_filters(select(func.count(Customer.id)), query)
return int(session.execute(stmt).scalar_one())
@@ -101,9 +81,7 @@ def list_ownership_logs(
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session,
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
_customer_log_ref(customer_id),
offset=offset,
limit=limit,
)
@@ -111,8 +89,12 @@ def list_ownership_logs(
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
"""统计某客户的归属留痕总数,供分页使用。"""
return ownership_log_repository.count_by_ref(
session,
return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id))
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
"""客户维度的归属留痕锚点。"""
return OwnershipLogRef(
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
@@ -126,20 +108,18 @@ def _base_select() -> Select:
)
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
customer_type: str | None,
owner_personnel_id: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword:
stmt = stmt.where(_keyword_clause(keyword))
if customer_type:
stmt = stmt.where(Customer.customer_type == customer_type)
if owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
if query.keyword:
stmt = stmt.where(_keyword_clause(query.keyword))
if query.customer_type:
stmt = stmt.where(Customer.customer_type == query.customer_type)
if query.category_missing:
stmt = stmt.where(Customer.customer_type.is_(None))
if query.owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
if query.owner_missing:
stmt = stmt.where(Customer.owner_personnel_id.is_(None))
return stmt
@@ -157,6 +137,23 @@ def _keyword_clause(keyword: str) -> ColumnElement[bool]:
)
def _apply_order(stmt: Select, query: CustomerQuery) -> Select:
"""应用排序。排序键已由 schema 白名单收口,此处只做键 → 列的映射。
MySQL 把 NULL 视为最小值:ASC 时未评分(null)会排最前,但「没打分」
不该压在「打了低分」之上 —— ASC 分支用 ``is_(None)`` 布尔键把 null
挪到末尾;DESC 分支 null 天然在最后,无需处理。尾部再按 id 倒序兜底,
保证同分行的次序稳定(分页不跳行)。
"""
if query.order_by == "rating_score":
score = Customer.rating_score
if query.order == "asc":
stmt = stmt.order_by(score.is_(None), score.asc())
else:
stmt = stmt.order_by(score.desc())
return stmt.order_by(Customer.id.desc())
# ==================== 写入 ====================
@@ -8,7 +8,7 @@
from datetime import date, datetime
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, defer
from app.models import (
ApprovalOrder,
@@ -34,20 +34,25 @@ _PENDING_STATUSES = (
# ==== 经营绩效总览 ====
def sum_month_performance(session: Session, month: str) -> dict:
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
return session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
).where(
PerformanceLedger.settlement_month == month,
*_ACTIVE_SINGLE,
return (
session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
).where(
PerformanceLedger.settlement_month == month,
*_ACTIVE_SINGLE,
)
)
).mappings().one()
.mappings()
.one()
)
def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
@@ -66,9 +71,7 @@ def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
return dict(rows)
def receipt_trend(
session: Session, *, start_date: date, end_date: date
) -> dict[str, object]:
def receipt_trend(session: Session, *, start_date: date, end_date: date) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
@@ -107,18 +110,22 @@ def get_month_settlement(session: Session, month: str) -> MonthlySettlement | No
# ==== 待办事项 ====
def list_pending_approvals(
session: Session, *, statuses: tuple[int, ...], limit: int
) -> list[ApprovalOrder]:
"""待处理审批单(按提交时间升序——最久的排前面)。
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
排除 snapshot_json 列(快照可达上百 KB,待办只取单号/类型/提交人/时间)。
"""
if not statuses:
return []
return list(
session.execute(
select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(ApprovalOrder.status.in_(statuses))
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
.limit(limit)
@@ -157,15 +164,14 @@ def list_unassigned_receipts(session: Session, *, limit: int) -> list:
def count_unassigned_receipts(session: Session) -> int:
return int(
session.execute(
select(func.count())
.select_from(Receipt)
.where(Receipt.shipment_id.is_(None))
select(func.count()).select_from(Receipt).where(Receipt.shipment_id.is_(None))
).scalar_one()
)
# ==== 风险预警 ====
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
@@ -210,10 +216,14 @@ def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
def list_overdue_approvals(
session: Session, *, cutoff: datetime, limit: int
) -> list[ApprovalOrder]:
"""提交后超过阈值仍未处理的审批单(审批超时规则)。"""
"""提交后超过阈值仍未处理的审批单(审批超时规则)。
排除 snapshot_json 列(同待办:预警只用单号/类型/提交人/时间)。
"""
return list(
session.execute(
select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(
ApprovalOrder.status.in_(_PENDING_STATUSES),
ApprovalOrder.create_time < cutoff,
@@ -1,5 +1,7 @@
"""登录日志数据访问。"""
from dataclasses import dataclass
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
@@ -10,68 +12,73 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
_USER_AGENT_MAX_LEN = 255
def add(
session: Session,
*,
user_id: int | None,
username: str,
ip: str | None,
user_agent: str | None,
success: bool,
fail_reason: str | None = None,
) -> None:
@dataclass(frozen=True, slots=True)
class LoginLogEntry:
"""一条待写入的登录日志(全字段 DTO)。
与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传
都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。
"""
user_id: int | None
username: str
success: bool
ip: str | None = None
user_agent: str | None = None
fail_reason: str | None = None
@dataclass(frozen=True, slots=True)
class LoginLogListQuery:
"""登录日志列表的查询条件(筛选 + 分页)。"""
filters: LogFilters
offset: int = 0
limit: int = 20
ip: str | None = None
def add(session: Session, entry: LoginLogEntry) -> None:
"""写入一条登录日志。只 flush 不 commit。
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
:param user_id: 账号不存在时传 None(列本身可空)
:param username: 用户名原样记录,便于账号不存在时也能追溯
:param entry.user_id: 账号不存在时为 None(列本身可空)
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯
"""
session.add(
LoginLog(
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
success=1 if success else 0,
fail_reason=fail_reason,
user_id=entry.user_id,
username=entry.username,
ip=entry.ip,
user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None,
success=1 if entry.success else 0,
fail_reason=entry.fail_reason,
)
)
session.flush()
def list_logs(
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
ip: str | None = None,
) -> list[LoginLog]:
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
"""分页查询登录日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
不确定,翻页会重复或遗漏(与用户列表同一理由)。
"""
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
stmt = _apply_filters(select(LoginLog), query)
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
return list(session.execute(stmt).scalars())
def count_logs(
session: Session,
*,
filters: LogFilters,
ip: str | None = None,
) -> int:
def count_logs(session: Session, query: LoginLogListQuery) -> int:
"""统计符合条件的登录日志总数,供分页使用。"""
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
stmt = _apply_filters(select(func.count(LoginLog.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
def _apply_filters(stmt: Select, query: LoginLogListQuery) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
stmt = apply_log_filters(stmt, LoginLog, filters)
if ip:
stmt = stmt.where(LoginLog.ip == ip)
stmt = apply_log_filters(stmt, LoginLog, query.filters)
if query.ip:
stmt = stmt.where(LoginLog.ip == query.ip)
return stmt
@@ -0,0 +1,59 @@
"""监控查询只使用固定字段与有界窗口;事务由服务层负责。"""
from sqlalchemy import func, select
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
def snapshots(session, start, end):
return session.scalars(
select(MonitorSnapshot)
.where(MonitorSnapshot.minute >= start, MonitorSnapshot.minute < end)
.order_by(MonitorSnapshot.minute)
.limit(10082)
).all()
def request_minutes(session, start, end, route=None):
statement = select(MonitorRequestMinute).where(
MonitorRequestMinute.minute >= start, MonitorRequestMinute.minute < end
)
if route:
statement = statement.where(MonitorRequestMinute.route == route)
return session.scalars(
statement.order_by(MonitorRequestMinute.minute.desc()).limit(20001)
).all()
def events(session, query, start, end):
filters = [MonitorEvent.last_seen >= start, MonitorEvent.first_seen < end]
if query.kind:
filters.append(MonitorEvent.kind == query.kind)
if query.request_id:
filters.append(MonitorEvent.request_id == query.request_id)
if query.route:
filters.append(MonitorEvent.route == query.route)
if query.active_only:
filters.extend([MonitorEvent.kind == "HEALTH", MonitorEvent.resolved_at.is_(None)])
total = session.scalar(select(func.count()).select_from(MonitorEvent).where(*filters)) or 0
rows = session.scalars(
select(MonitorEvent)
.where(*filters)
.order_by(MonitorEvent.last_seen.desc(), MonitorEvent.id.desc())
.offset(query.offset)
.limit(query.page_size)
).all()
return rows, total
def active_events(session):
return session.scalars(
select(MonitorEvent)
.where(MonitorEvent.kind == "HEALTH", MonitorEvent.resolved_at.is_(None))
.order_by(MonitorEvent.last_seen.desc())
.limit(100)
).all()
def event(session, event_id):
return session.get(MonitorEvent, event_id)
@@ -107,15 +107,18 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
return session.get(OperationLog, log_id)
def list_logs(
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
module: str | None = None,
action: str | None = None,
) -> list[OperationLog]:
@dataclass(frozen=True, slots=True)
class OperationLogListQuery:
"""操作日志列表的查询条件(筛选 + 分页)。"""
filters: LogFilters
offset: int = 0
limit: int = 20
module: str | None = None
action: str | None = None
def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]:
"""分页查询操作日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
@@ -127,41 +130,34 @@ def list_logs(
"""
stmt = _apply_filters(
select(OperationLog).options(defer(OperationLog.request_data)),
filters=filters,
module=module,
action=action,
query,
)
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
stmt = stmt.order_by(OperationLog.id.desc()).offset(query.offset).limit(query.limit)
return list(session.execute(stmt).scalars())
def count_logs(
session: Session,
*,
filters: LogFilters,
module: str | None = None,
action: str | None = None,
) -> int:
def count_logs(session: Session, query: OperationLogListQuery) -> int:
"""统计符合条件的日志总数,供分页使用。"""
stmt = _apply_filters(
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
)
stmt = _apply_filters(select(func.count(OperationLog.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
filters: LogFilters,
module: str | None,
action: str | None,
) -> Select:
def distinct_modules(session: Session) -> list[str]:
"""全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。
交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。
"""
stmt = select(OperationLog.module).distinct().order_by(OperationLog.module)
return [module for module in session.execute(stmt).scalars() if module]
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
stmt = apply_log_filters(stmt, OperationLog, filters)
if module:
stmt = stmt.where(OperationLog.module == module)
if action:
stmt = stmt.where(OperationLog.action == action)
stmt = apply_log_filters(stmt, OperationLog, query.filters)
if query.module:
stmt = stmt.where(OperationLog.module == query.module)
if query.action:
stmt = stmt.where(OperationLog.action == query.action)
return stmt
@@ -5,18 +5,32 @@ owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
"""
from dataclasses import dataclass
from typing import Any
from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session, aliased
from app.models import OwnershipChangeLog, Personnel
@dataclass(frozen=True, slots=True)
class OwnershipLogRef:
"""归属留痕的引用锚点(维度类型 + 引用列 + 引用 ID)。
引用列是 SQLAlchemy 列表达式(如 ``OwnershipChangeLog.customer_id``),
不是可序列化值 —— 故用冻结 dataclass 而非 Pydantic 模型。
"""
owner_type: int
ref_column: Any
ref_id: int
def list_by_ref(
session: Session,
ref: OwnershipLogRef,
*,
owner_type: int,
ref_column,
ref_id: int,
offset: int,
limit: int,
) -> list[Row]:
@@ -32,7 +46,7 @@ def list_by_ref(
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
.where(OwnershipChangeLog.owner_type == ref.owner_type, ref.ref_column == ref.ref_id)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
@@ -40,11 +54,11 @@ def list_by_ref(
return list(session.execute(stmt).all())
def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
def count_by_ref(session: Session, ref: OwnershipLogRef) -> int:
"""统计某引用对象的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == owner_type,
ref_column == ref_id,
OwnershipChangeLog.owner_type == ref.owner_type,
ref.ref_column == ref.ref_id,
)
return int(session.execute(stmt).scalar_one())
+28 -38
View File
@@ -10,6 +10,7 @@ from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
from app.schemas.param import ParamBatchQuery, ParamHistoryQuery
# ==================== 读取 ====================
@@ -97,27 +98,27 @@ def list_definitions_by_codes(
def list_history(
session: Session,
param_id: int,
*,
offset: int,
limit: int,
dimension_value: str | None = None,
session: Session, param_id: int, query: ParamHistoryQuery
) -> list[tuple[ParamValue, ParamChangeBatch]]:
"""单参数的版本历史(含批次信息),最新在前。"""
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
"""单参数的版本历史(含批次信息),最新在前。
``param_id`` 是「查哪个参数」的主锚点(由路径参数 param_code 解析而来),
不属于列表筛选项,故仍以位置参数传入;筛选与分页走 ``query``。
"""
stmt = _history_stmt(param_id, query)
return [
(value, batch)
for value, batch in session.execute(stmt.offset(query.offset).limit(query.page_size))
]
def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
def count_history(session: Session, param_id: int, query: ParamHistoryQuery) -> int:
"""统计单参数的历史条目数。"""
stmt = select(func.count()).select_from(
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
)
stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery())
return int(session.execute(stmt).scalar_one())
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
stmt = (
select(ParamValue, ParamChangeBatch)
@@ -125,8 +126,8 @@ def _history_stmt(session: Session, param_id: int, *, dimension_value: str | Non
.where(ParamValue.param_id == param_id)
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
)
if dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
if query.dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == query.dimension_value)
return stmt
@@ -150,41 +151,30 @@ def get_batch_by_id(
return session.get(ParamChangeBatch, batch_id)
def list_batches(
session: Session,
*,
offset: int,
limit: int,
start_date: date | None = None,
end_date: date | None = None,
) -> list[ParamChangeBatch]:
def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]:
"""分页查询变更批次(按创建时间倒序)。"""
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
stmt = _batch_filters(select(ParamChangeBatch), query)
return list(
session.execute(
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
.offset(offset)
.limit(limit)
.offset(query.offset)
.limit(query.page_size)
).scalars()
)
def count_batches(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> int:
def count_batches(session: Session, query: ParamBatchQuery) -> int:
"""统计符合条件的批次总数。"""
stmt = _batch_filters(
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
)
stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query)
return int(session.execute(stmt).scalar_one())
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
def _batch_filters(stmt: Select, query: ParamBatchQuery) -> Select:
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
if start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
if end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
if query.start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= query.start_date)
if query.end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= query.end_date)
return stmt
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, aliased
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
from app.repositories.query_utils import fuzzy
from app.schemas.personnel import PersonnelQuery
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
_manager = aliased(Personnel)
@@ -30,44 +31,23 @@ def get_with_names(session: Session, personnel_id: int) -> Row | None:
return session.execute(stmt).first()
def list_personnel(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> list[Row]:
def list_personnel(session: Session, query: PersonnelQuery) -> list[Row]:
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
"""
stmt = _apply_filters(
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
)
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
stmt = _apply_filters(_base_select(), query)
stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).all())
def count_personnel(
session: Session,
*,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> int:
def count_personnel(session: Session, query: PersonnelQuery) -> int:
"""统计符合条件的人员总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(
select(func.count(Personnel.id)),
keyword=keyword,
status=status,
business_role_id=business_role_id,
)
stmt = _apply_filters(select(func.count(Personnel.id)), query)
return int(session.execute(stmt).scalar_one())
@@ -154,20 +134,14 @@ def _base_select() -> Select:
)
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
business_role_id: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: PersonnelQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
if status is not None:
stmt = stmt.where(Personnel.status == status)
if business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == business_role_id)
if query.keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, query.keyword)
if query.status is not None:
stmt = stmt.where(Personnel.status == query.status)
if query.business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == query.business_role_id)
return stmt
+16 -43
View File
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessItem, Product, ShipmentItem
from app.schemas.product import ProductQuery
# ==================== 读取 ====================
@@ -34,50 +35,20 @@ def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Pr
return session.execute(stmt).scalar_one_or_none()
def list_products(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> list[Product]:
def list_products(session: Session, query: ProductQuery) -> list[Product]:
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
stmt = _apply_filters(
select(Product),
keyword=keyword,
product_category=product_category,
status=status,
)
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
stmt = _apply_filters(select(Product), query)
stmt = stmt.order_by(Product.id).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars())
def count_products(
session: Session,
*,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> int:
def count_products(session: Session, query: ProductQuery) -> int:
"""统计符合条件的产品总数,供分页使用。"""
stmt = _apply_filters(
select(func.count(Product.id)),
keyword=keyword,
product_category=product_category,
status=status,
)
stmt = _apply_filters(select(func.count(Product.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
product_category: str | None,
status: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
@@ -86,17 +57,19 @@ def _apply_filters(
写 contains —— M1 business_role_repository 的双列关键词同款写法。
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
"""
if keyword:
if query.keyword:
stmt = stmt.where(
or_(
Product.product_name.contains(keyword, autoescape=True),
Product.spec_model.contains(keyword, autoescape=True),
Product.product_name.contains(query.keyword, autoescape=True),
Product.spec_model.contains(query.keyword, autoescape=True),
)
)
if product_category:
stmt = stmt.where(Product.product_category == product_category)
if status is not None:
stmt = stmt.where(Product.status == status)
if query.product_category:
stmt = stmt.where(Product.product_category == query.product_category)
if query.category_missing:
stmt = stmt.where(Product.product_category.is_(None))
if query.status is not None:
stmt = stmt.where(Product.status == query.status)
return stmt
@@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
"""按主键查询。
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
等级表无外键引用(客户档案只存总分,等级由分数按当前映射实时推导,
不存等级 id),无需 ``for_update`` 锁行 —— product_repository 锁行防的
是并发引用悬空,此处不存在该风险:删掉一个等级,只是以后不再判到它。
"""
return session.get(CustomerRatingGrade, grade_id)
@@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
"""物理删除等级。
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
"""
session.delete(grade)
session.flush()
+24 -51
View File
@@ -14,6 +14,7 @@ from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment
from app.schemas.receipt import ReceiptQuery
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
@@ -43,72 +44,44 @@ def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
return session.get(Shipment, shipment_id)
def _apply_filters(
stmt: Select,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
if business_id is not None:
stmt = stmt.where(Receipt.business_id == business_id)
if date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= date_from)
if date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= date_to)
if is_barter is not None:
stmt = stmt.where(Receipt.is_barter == is_barter)
if settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == settlement_status)
if query.business_id is not None:
stmt = stmt.where(Receipt.business_id == query.business_id)
if query.date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= query.date_from)
if query.date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= query.date_to)
if query.is_barter is not None:
stmt = stmt.where(Receipt.is_barter == query.is_barter)
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
if query.settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
if query.confirmation_status is not None:
stmt = stmt.where(Receipt.confirmation_status == query.confirmation_status)
return stmt
def list_receipts(
session: Session,
*,
offset: int,
limit: int,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
session: Session, query: ReceiptQuery
) -> list[tuple[Receipt, str, str | None, date | None]]:
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
stmt = _apply_filters(
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
.join(Business, Business.id == Receipt.business_id)
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
business_id=business_id,
date_from=date_from,
date_to=date_to,
is_barter=is_barter,
settlement_status=settlement_status,
query,
)
return list(
session.execute(
stmt.order_by(Receipt.id.desc()).offset(query.offset).limit(query.page_size)
).all()
)
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
def count_receipts(
session: Session,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> int:
def count_receipts(session: Session, query: ReceiptQuery) -> int:
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters(
select(func.count()).select_from(Receipt),
business_id=business_id,
date_from=date_from,
date_to=date_to,
is_barter=is_barter,
settlement_status=settlement_status,
)
stmt = _apply_filters(select(func.count()).select_from(Receipt), query)
return int(session.execute(stmt).scalar_one())
+12 -4
View File
@@ -217,6 +217,16 @@ def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
return int(session.execute(stmt).scalar_one())
def list_source_business_sales(session: Session, query: BusinessStatsQuery, *, after_id: int, limit: int):
"""键集分页读取筛选范围内的来源业务,不加载完整业务或用户数据。"""
stmt = (select(Business.id, Business.total_sale_amount).select_from(Business)
.outerjoin(Customer, Customer.id == Business.customer_id)
.where(*_business_filters(query),
Business.integration_json["source_database"].as_string().is_not(None),
Business.id > after_id).order_by(Business.id).limit(limit))
return list(session.execute(stmt))
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
perf_sq = _business_perf_subquery()
@@ -389,9 +399,7 @@ def _bucket_condition(bucket: DayBucket) -> tuple:
return tuple(conds)
def list_payment_detail(
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
) -> list:
def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> list:
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
@@ -408,7 +416,7 @@ def list_payment_detail(
.where(*_payment_filters(query), *_bucket_condition(bucket))
)
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(offset).limit(limit)
stmt = stmt.offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).all())
+27 -19
View File
@@ -9,7 +9,9 @@
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Role, UserRole
from app.models import Permission, Role, RolePermission, UserRole
from app.schemas.role import RoleQuery
from app.services.permission_catalog import CATALOG
# ==================== 读取 ====================
@@ -47,44 +49,37 @@ def get_all_roles(session: Session) -> list[Role]:
return list(session.execute(stmt).scalars())
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[Role]:
def list_roles(session: Session, query: RoleQuery) -> list[Role]:
"""分页查询角色(管理列表:含停用角色)。
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
"""
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
stmt = _apply_filters(select(Role), query)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
def count_roles(session: Session, query: RoleQuery) -> int:
"""统计符合条件的角色总数,供分页使用。"""
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
stmt = _apply_filters(select(func.count(Role.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
def _apply_filters(stmt: Select, query: RoleQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
"""
if keyword:
if query.keyword:
stmt = stmt.where(
or_(
Role.role_code.contains(keyword, autoescape=True),
Role.role_name.contains(keyword, autoescape=True),
Role.role_code.contains(query.keyword, autoescape=True),
Role.role_name.contains(query.keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(Role.status == status)
if query.status is not None:
stmt = stmt.where(Role.status == query.status)
return stmt
@@ -103,6 +98,19 @@ def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
def count_permissions_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
"""批量统计角色已配置的权限,避免列表逐行查询。"""
if not role_ids:
return {}
stmt = (
select(RolePermission.role_id, func.count())
.join(Permission, Permission.id == RolePermission.permission_id)
.where(RolePermission.role_id.in_(role_ids), Permission.perm_code.in_(CATALOG))
.group_by(RolePermission.role_id)
)
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
# ==================== 写入 ====================
@@ -7,7 +7,16 @@ from app.models import MonthlySettlement, SettlementItem
from app.schemas.settlement import SettlementQuery
def get_by_id(session: Session, settlement_id: int) -> MonthlySettlement | None:
def get_by_id(
session: Session, settlement_id: int, *, for_update: bool = False
) -> MonthlySettlement | None:
"""月结行;``for_update`` 供提交审批路径加行锁用。
提交要算审批单版本号(同一月结的第几次送审),并发提交会让两次
算出同一个版本号;锁住月结行使并发提交串行化。
"""
if for_update:
return session.get(MonthlySettlement, settlement_id, with_for_update=True)
return session.get(MonthlySettlement, settlement_id)
Loaded 100 of 533 files, more files were not shown because too many files have changed in this diff. Show more