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
liuwenxin d03ddadd1a merge: 合并 M4 阶段报告至 main 2026-10-20 10:19:22 +08:00
liuwenxinandClaude Code c319a6a428 docs: M4 阶段报告(2026-10-20)
汇总报表统计与工作台交付范围、关键口径、测试证据与遗留事项。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:19:15 +08:00
liuwenxin 36114745db merge: 合并 M4 工作台与报表修复至 main 2026-10-20 10:18:40 +08:00
liuwenxinandClaude Code b86eaf2b00 docs: 记录 2026-10-20 全量回归与 e2e 实跑结果
全量 862 passed(11:32);e2e role_home 4/4、report_flow 12/12(按业务编号下钻断言)、calc_settlement 8/8;审批开关已还原、无测试账号残留。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:10:32 +08:00
liuwenxinandClaude Code 71df9a16ed docs: 修复催办清单行首井号被误判为标题
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:05:28 +08:00
liuwenxinandClaude Code 980628bb8f test(e2e): 回款统计改按本次业务编号下钻断言
历史 e2e 数据会累计到同一回款区间桶,断言桶聚合值在重复运行时必然失败;改为展开目标桶后按本次业务编号定位下钻行并校验金额,脚本可无副作用重跑。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:04:42 +08:00
liuwenxinandClaude Code c8131588bf chore: 忽略 T+12.3 数据字典参考资料
- docs/**/*.rar、docs/T_12.3数据字典/ 与 __MACOSX/ 不入版本库

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:47:41 +08:00
liuwenxinandClaude Code b6d84033ce docs(plan): 回填里程碑完成状态与开放问题结论
- 开发计划:M3/M4 ⑤⑧/⑦⑨ 标记完成,依赖图与里程碑状态同步
- 开放问题表补 2026-10-20 结论映射;补充需求已随 SRS V2.1 回填
- 待确认事项汇总与催办清单同步当前状态

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:47:41 +08:00
liuwenxinandClaude Code 698df6d31a docs: 同步报表与工作台口径及测试记录
- backend/README:全公司 5% 每位有聚合行经理各计一次并与月结对拍、
  待办按角色过滤、预警排除终止业务与 total 计数、趋势范围查询
- backend/tests/README:新增修复回归与 e2e 全链路运行记录、缺陷表
- frontend/README:普通用户落地页规则、预警徽标、e2e 实跑结果

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:47:03 +08:00
liuwenxinandClaude Code 39a3fa7796 fix(dashboard): 修复普通用户落地页与待办角色过滤
- 新增 router/access.ts 统一准入与落地页(工作台→业务数据→回款),
  守卫/登录回跳/菜单/面包屑复用;普通用户不再被无限重定向
- 待办按角色映射在途审批节点(财务/领导/admin),admin 不代办但可查全部
- 预警排除已终止业务;WarningBlock.total 为全量命中数;回款趋势改日期范围
- 工作台加载请求序号防竞态;KPI 口径说明补经理分成/调整单
- 新增 role_home_flow e2e,更新 system_management 普通用户断言

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:45:03 +08:00
liuwenxinandClaude Code ebb3a92f25 fix(report): 报表统计修复批次——对账口径、导出审计与月份校验
- 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次,
  团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍
  (有调整单时 + adjust_net_total),分列舍入差单列
- 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入,
  团队与未分配不重复;按经理筛选只汇总展示团队
- 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称
- 月份校验抽 core/month,报表/月结/调整单非法月份统一 400;
  岗位只读选项供财务/领导筛选
- 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、
  summary 请求序号保护

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:44:43 +08:00
liuwenxin c91d1db78a Merge feat/m3-increments into main��M4 �� ����̨��
����̨������ + V10 Ԥ������
2026-10-19 21:01:17 -04:00
liuwenxinandClaude Code 294d99a0d3 feat(dashboard): 工作台前端四区块与 ECharts 图表
- /dashboard 由 Bento 占位升级:KPI 四卡(本月绩效/应付/核算人数/团队数+月结状态)、近 6 个月绩效折线与回款柱状、待办/预警列表与跳转、最近动态时间线(按模块筛选)、常用操作
- ECharts 5.6 按需引入(core + Bar/Line/Grid/Tooltip + SVGRenderer);textStyle.fontFamily 显式设为思源黑体栈(Windows 默认雅黑,字体商用合规红线)
- 路由 allowedRoles admin/finance/leader,与后端 require_roles 双层
- 新增依赖 echarts@5.6.0

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 18:45:17 +08:00
liuwenxinandClaude Code 6ff202d637 feat(dashboard): 工作台四区块实时聚合与风险预警参数
- 后端 /api/v1/dashboard 单端点返回四区块:经营总览(绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步显式降级)、风险预警(回款逾期/审批超时,阈值参数化)、最近动态(操作日志)
- dashboard_service 实时派生(不落表、不引调度器);参数快照一次解析;总览只做 SQL 聚合,不复用报表的重查询
- V10 迁移新增预警参数三项(warning.enabled / warning.receipt.overdue.days=90 / warning.approval.timeout.days=3),参数注册表 19→22,init-dev 与 V10 SQL 双轨同步
- 测试:test_dashboard 5 例(权限文案/总览对账+非法月份 400/待办三类/预警命中与参数关闭/动态来源);全量 852 passed
- 依赖闭包并入:core/month.py 月份校验、api/v1/base/business_roles.py(router 已挂载的岗位只读端点)、test_month_validation
- 修复:alembic --sql 在 Windows 按控制台 GBK 输出,V10 SQL 以 UTF-8 重新导出(README 已记)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 18:44:15 +08:00
liuwenxin 4e54d09ccb Merge feat/m3-increments into main
M2 ����/ҵ������ + M3 ����/�½�/���� + M4 ����ͳ��
2026-10-19 04:59:56 -04:00
liuwenxinandClaude Code b7cfdfa50a docs: 需求规格说明书升 V2.1(2026-10-19)
回填 2026-10-16 业务方 24 项澄清答复(12 确认默认 / 4 项与默认不同已落实 / 仍开放三态)、
AC-12 口径修正、二次分摊拆行与月末取参实现口径;FR 42 条 / AC 12 条结构校验通过,
作废口径仅出现在修订对照与作废声明语境。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 16:34:05 +08:00
liuwenxinandClaude Code 04a1a0d307 feat(report): 报表四页与打印视图
- 个人绩效明细/部门绩效汇总/业务统计/回款统计四页,路由组「报表统计」order19-22(allowedRoles admin/finance/leader)
- 明细分页页脚全筛选合计;部门汇总成员内联与跨页下钻;业务统计展开回款/台账;回款统计 8 档区间(档位由后端下发)与桶下钻
- 打印视图独立顶层路由(不挂 BasicLayout):公司页眉走 public 端点、循环翻页取全量(2000 行截断提示)、@media print A4 横版每页表头、签字栏
- AppPagination 只派发 page-change/size-change,四页按事件绑定并补初始加载(@change/v-model 不生效的同类缺陷)
- e2e report_flow.py:四页筛选与导出下载、打印视图 window.print 桩断言

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 16:33:37 +08:00
liuwenxinandClaude Code 2a1939d2e7 feat(report): 报表统计四表查询与 Excel 签字导出
- 四表接口 /api/v1/reports/*:个人明细(全筛选 summary)、部门汇总(团队基数与月结聚合同源、12.5%/5% 分列、零行经理、冻结对照)、业务统计(排除聚合行)、回款统计(8 档区间恒在、回款金额按 receipt_id 去重防分摊拆行翻倍)
- Excel 导出:公司页眉/财务复核栏/领导签字栏/日期/版本、Decimal 原值 + number_format、文本防公式注入、2 万行超限 400、导出行为入操作日志
- manager_share 共享模块:月结聚合与报表部门汇总同源取参与分桶(薄委托保留测试入口)
- 区间元数据落 core/report_meta(repository 需要类型,避免反向依赖 service 层)
- 准入 require_roles(admin/finance/leader),支持自定义 403 文案

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 16:33:31 +08:00
liuwenxinandClaude Code cfd2a196f2 style(frontend): 表格操作列统一样式与确认框视觉收敛
- 新增 table-actions.css:操作列 flex 居中、间距交给 gap、破坏性动作前
  加分组竖线、链接按钮悬停加深并加下划线(默认浅靛蓝在表格底色上对比度
  不足);包装 span 改 flex,消除行内盒基线导致的约 1.7px 垂直错位
- 11 个列表页操作列套用统一容器与分隔线,列宽按动作自然宽度调整并居中
- 确认框不再传 type:warning(Element Plus 的实心黄圆图标与正文并排成
  两列,标题行反被留空),图标改由全局样式画在标题行左侧,正文与标题
  文字左对齐;图标为 CSS 伪元素,不进无障碍树

验证:pnpm type-check 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 16:33:06 +08:00
liuwenxinandClaude Code cc4c51359c fix(frontend): M3 台账/月结/审批三页数据加载与分页——e2e 首跑抓出
calc_settlement_flow e2e 首跑即暴露三处真实缺陷(三页自 2026-10-17 交付
起一直「暂无数据」且翻页失效,此前无 e2e 覆盖):

- api/{calc,settlements,approvals}.ts 误用默认导出 `request`(axios 实例,
  返回 AxiosResponse 未解包),usePagedList 拿到的 data.list 恒 undefined
  → 三页永远「暂无数据」;改为具名导入 `{ request }`(统一信封解包版)
- 三页均无 onMounted 初始加载 → 进页不取数;补 `onMounted(() => void load())`
- 三页 AppPagination 用 `v-model:page-num` + `@change`(组件无该事件、
  也未实现 update:*)→ 翻页失效;统一为 `:page-num/:page-size` +
  `@page-change/@size-change`
- 并入并行会话的操作列 align="center" 样式与 M4 报表页 README 段落

验证:e2e calc_settlement_flow 8 步全过(API 预置→台账筛选→重算对话框
按回款 ID→版本三态筛选→月结生成→提交锁定→详情抽屉);type-check 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 16:24:18 +08:00
liuwenxinandClaude Code 036834a1d0 style(backend): 验证码配色切换至 v3 中性色体系
字符/干扰/噪点/底色改用 --ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper
对应色值,与 2026-10-12 后的前端视觉方向一致;识别难度参数未变,
tests/unit/test_captcha.py 12 项通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 15:55:02 +08:00
liuwenxinandClaude Code 8903a194c9 style(frontend): 字体商用合规——移除微软雅黑,统一思源黑体开源栈
- index.css 两处字体栈移除 Microsoft YaHei:品牌字与正文统一思源黑体(OFL)
  + HarmonyOS Sans 开源序列,Helvetica/Arial 仅作西文回退
- README 视觉章节对齐 v3 靛蓝体系:配色表/质感来源/落地方式/签名元素/构图
  更新,旧「宋体品牌字」「两档金」表述修正并写明商用字体禁用约定
- 设计预览页字体栈与品牌字候选卡同步,echarts chartFont 去雅黑

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 15:54:59 +08:00
liuwenxinandClaude Code a2bd3960e1 feat(report): M4 报表基础设施——元数据、查询模型与只读聚合仓储
- report_meta:回款天数区间桶(含可空/负值兜底桶)、客户类型标签、导出上限
- schemas/report:明细/汇总查询入参与分页响应,金额比例全程 Decimal
- report_repository:报表专用只读聚合,显式排除台账聚合行、口径与月结区分
- constants:新增报表统计模块与导出动作常量

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 13:53:34 +08:00
liuwenxinandClaude Code fb398c3835 test(e2e): 绩效核算与月度结算端到端用例——台账重算与月结审批链路
覆盖台账页按回款 ID 重算、版本筛选三态、月结生成/提交审批/明细回显,
用随机远未来月份造数,重复运行互不碰撞。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 13:50:13 +08:00
liuwenxinandClaude Code c5f219423b docs: 2026-10-19 业务答复与需求留档——T+ 清单 v1.4、排序表、催办清单与 M4 计划
- T+ 对接数据需求清单 v1.4 与业务方答复回执
- 待确认事项排序表两版、待答复催办清单
- 剩余开发计划回填 3.3.1 答复落定状态
- 新增 M4 报表统计实施计划

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 13:49:42 +08:00
liuwenxinandClaude Code a536608b53 docs(tests): 2026-10-19 业务答复落实批次记录与分摊尾差缺陷表更新
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 13:29:37 +08:00
liuwenxinandClaude Code ccfd20651b feat(calc): 二次分摊拆行与 #12 单笔侧开关——按方案比例拆给参与人、余额归负责人
- _personal_rate 的 TPL_MANAGER_SHARE 分支读 manager.personal.included:1=按业务员递减公式计提,0=单笔落 0(现状)
- _load_allocations 装载生效方案并防御拦截(合计超 100%、参与人含负责人)
- _persist_ledger 拆行落库:参与人=份额×比例(q2)、负责人=q2 全额−已分累计(行合计守恒防分漂);快照带 alloc_plan/alloc_role/alloc_ratio
- _prepare_mode 重算改 list_active_by_receipt 作废全部拆分行;uk 三列版支持同回款按人多行
- business_service 保存侧同口径校验(超 100% 400、负责人自分 400、hint 文案定稿)
- 集成用例 +5(拆行守恒/叠加进度登记/重算全作废/双拦截)+保存侧 3 条

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 13:15:59 +08:00
liuwenxinandClaude Code 48c0a86279 docs: 推送状态补正——feat/m3-increments 已推送至远端 e48cfb7
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 13:13:23 +08:00
liuwenxinandClaude Code e48cfb752d docs: 待确认事项汇总更新至 2026-10-19(答复落实/code-review/远端恢复)
- 立即待办:远端 Git 服务已恢复(ls-remote 实测连通);标注远端已到
  35fe37f、本地领先 8 提交
- 业务方澄清:#12 经理个人户基数 → 已落实为参数 manager.personal.included
  (默认 0=不计入);#5/#7 审批开关默认开启、#14 首日整 10‰(A 口径)、
  #16 试用期参数撤销、#17 参数查看放开、#19 增量备份 → 均已落实(2026-10-19)
- 里程碑:M3 剩余改为已完成主体 + 余项(二次分摊待 #1–#3、答复回填批次
  收口);杂项收口标注 calc_receipt 与 settlement_service 拆分进度
- T+ 小节:清单已升 v1.4(2026-10-19),本节口径待收编
- 新增修复批次口径记录:月结聚合/月工资按月月末取参(单笔行按回款日)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 13:01:50 +08:00
liuwenxinandClaude Code 028a94a6eb refactor(m3): settlement_service 三段收尾——生成/聚合/调整单拆分
code-review 约定合规项续(calc_receipt 之后的剩余超长函数):

- generate(~120 行)→ 编排 + 六段私有函数:_guard_regeneration(重生成
  准入)、_release_previous_round(解绑回填+旧聚合作废)、_upsert_draft
  (草稿头表新建/重置)、_build_items(=_bucket_rows + _item_of 记账)与
  _apply_totals(头表三合计)
- _build_manager_aggregates(~110 行)→ _ShareParams 冻结 dataclass +
  _manager_share_params(月末取参/百分数口径/#12 开关同源)/ _collect_team_buckets
  (含 #12「个人户行归经理本人」分支)/ _add_team_performance / _aggregate_row;
  全公司基数循环外算一次(口径逐行固定,行为等价)
- create_adjustment(~90 行)→ _adjustment_amount(恒正)/ _wire_adjustment_approval
  (submit_order 分流与留痕,含「不得手工插 ApprovalStep」外键教训注释)/
  _adjustment_out(出参前置到 commit 之前)

行为零变化;#12 参数化(include_manager_personal 过滤、个人户行按
personnel_id 直归经理、snapshot 的 manager_personal_included)语义原样保留。
验证:重构后月结集成 15 例 + 月结单测 5 例全绿(81s 内无并发干扰的一轮);
全量回归待 88 会话 calc_service 收口后复跑(其进行中的拆行改动与测试
并发互清导致临时 401,非本重构所致)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 12:52:36 +08:00
liuwenxinandClaude Code a89d540e3f docs(tests): code-review 修复批次测试记录与缺陷表更新
- 运行记录:M3 定点 34 例与全量回归 780 passed(2026-10-19)两行
- 缺陷表:补 14 条(聚合 ×100/审批提前锁定/调整单双重缺陷/重算双重计提/
  前端传错 ID/quantize 银行家舍入/锁定拦截 1-4/回填死代码/聚合不落库/
  生成日取参/迁移三处漂移/演练误报与密码明文/金额与 INT 解析与 logo 清理)
- 待补用例:绩效工资核算行更新为 M3-core 已完成(e2e calc_settlement_flow
  与答复回填批次待补)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 12:30:41 +08:00
liuwenxinandClaude Code 7a99994d17 refactor(m3): calc_receipt 五段拆分——218 行收敛为编排 + 私有函数
code-review 约定合规项(CLAUDE.md「单一职责 ≤50 行」):原 218 行单函数
把锁行校验、模式预处理、输入装载、逐行折算、计提组成、落库全部内联,
待澄清答复回填时改动回归面大。按原执行顺序拆为四段私有函数 + 两个
冻结 dataclass(_Conversion/_Amounts),主函数只做编排;行为零变化
(M3 全部用例原样通过):

- _load_and_guard:锁行 + 模式校验 + 月结锁定拦截 + 业务归属校验
- _prepare_mode:重算作废旧版本 / 首算判一次性计提(进度登记提前返回)
- _load_shipment / _load_person / _load_items:输入装载三查
- _convert_items → _Conversion:逐行折算(行级阈值/缺成本标记/行级明细)
- _compose_amounts → _Amounts:比例分派 → 绩效 → 抵顶 → 权重 → 应付
- _persist_ledger:落台账行 + 首算 CAS
- 局部 q2 提为模块级 _q2(新增单测锁定 HALF_UP:2.345→2.35,
  缺省银行家舍入会得 2.34)

验证:全量 780 passed;type-check 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 12:25:36 +08:00
liuwenxinandClaude Code 45c268b146 fix(m3): code-review P2——金额校验、参数解析与运维脚本容错
- 调整单金额恒正:schema 正则拦格式(非负两位小数、≤12 位整数防
  NUMERIC(14,2) 溢出)+ 服务层 Decimal>0 语义校验——负数会反转补发/
  扣减语义,'abc' 原样入库会以 500 暴露
- 分界天数参数解析:int(parse_fraction(...)) 替代 int(参数原文)——
  '360.0' 是 INT 参数的合法入库值(Fraction 分母为 1),直接 int(str)
  抛 ValueError 会让整月核算 500
- logo 旧文件清理容错:OSError 只记 warning——裸抛会把成功的 logo 更新
  记成失败审计并 500(库中 logo_path 却已是新值,审计与事实相反)
- health_check.sh:Redis 探活改容器内环境变量展开(密码不进宿主进程
  命令行,模式同 backup_full.sh);compose redis 补 REDIS_PASSWORD
- restore_drill.sh:行数核对扩到全部 32 张业务表(原 16 张漏 V7 四表、
  二次分配等核心表,备份漏表演练全绿);查询失败回显 '?' 一律判 ✗
  (原 '?'=='?' 判一致,表缺失时误报「备份可恢复」)
- 并入 88 会话的 #12 实现:manager.personal.included 开关消费
  (_build_manager_aggregates 参数化,测试见 828b573)
- 测试:调整单金额恒正 5 稿、INT 参数小数原文核算 200 回归;单测 469

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 12:10:29 +08:00
liuwenxinandClaude Code 828b57350a test(settlement): #12 聚合基数开关用例——经理个人户计入可配置
默认 0 时聚合 6.84 不变;开关 1 时个人户行按 personnel_id 归经理本人
(不走 manager_id 上溯)、基数 139.11 → 聚合 24.34,两档金标准锁定

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 12:06:07 +08:00
liuwenxinandClaude Code 76ded6d5ec fix(m3): code-review P1——锁定不变量、入结回填与双轨漂移
- 月结锁定拦截补全:原仅新增回款一处,现覆盖回款编辑(原月+新月,防跨月
  搬数)/删除、发货新增(按发货日)/编辑(原月+新月)/删除、核算与重算
  (已审定月份金额只走调整单);拦截先于「被引用/已核算」判定
- 台账 settlement_id 回填落地:generate 在 replace_items 后回填纳入行、
  重生成前先解绑(clear_ledger_settlement_id)——不回填/不解绑会让
  「未入结」判据失效:重生成漏算整月或把已固化行反复计入
- 经理聚合行落库:原只进内存参与 items 汇总,台账表永远无 calc_type=2
  行、报表下钻落空;重生成先作废旧聚合行(deactivate_month_aggregates),
  _confirm_scope 限定计算类型避免聚合行混入单笔池
- 月结取参锚定所属月月末(_month_end):原用 date.today(),补生成历史
  月份时分成/月工资口径随生成日漂移;聚合行 receipt_date 同步为月末
- generate/submit 出参固化前置到 commit 之前(commit 后 SELECT 失败会把
  已生成的月结记成失败审计,与库中事实相反)
- V9 迁移:business_secondary_allocations 唯一键条件式补齐(幂等裸 SQL,
  临时库验证「无键→补」「有键→跳过」两分支),修复旧 V6 升级库拿不到
  V6 就地加入的约束;V8 迁移 revision 声明补类型注解(原格式漂移使
  schema 同步测试的 head 推断静默漏检 V8);init-dev 版本号推进至 V9
- V7 留存 SQL 约束/索引名单数改复数(与 alembic op.f 与 init-dev 对齐,
  消除 SHOW CREATE TABLE 双轨漂移)
- 测试:显式控制审批开关(不依赖 V8 翻转后的种子默认值);新增回归 8 例
  (回填落库/聚合不重复/月末取参/月末纯函数 5 例/锁定拦截回款与发货与
  核算);全量 773 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:59:49 +08:00
liuwenxinandClaude Code 7a460cb682 feat(formula): 首日计提比例取整 10‰(#14 答复——图示口径覆盖公式)
- salesperson_rate 首日特判:第 1 天不递减直接取起始值(原公式 9.9889‰ 作废)
- 第 2 天起公式段不变、360 天=6‰ 锚点与 361 天=4‰ 分界不变;preview/核算共用一处
- 用例反转:首日断言改 Fraction(10,1000),补第 2 天公式段边界

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:40:07 +08:00
liuwenxinandClaude Code cb4f38dfd9 fix(m3): code-review P0 六项——算错钱与不可用
- 经理聚合分成放大 100 倍:参数按百分数存储(12.5=12.5%),parse_fraction
  后调用方又 ×100、函数内部再 ÷100,净效应「绩效×12.5」;去掉调用方
  ×100,rate_text 改用实际参数原文(原硬编码 12.5%+5% 参数调整后失真)
- 审批联动只认终审:财务节点通过(1→2)不再提前 mark_locked/调整单生效,
  否则领导驳回后 locked_at/locked_by 残留、两级审批形同虚设;驳回在任何
  节点即时联动;mark_rejected 补清锁定字段
- 调整单新建不可用:ENABLE_CODE_BY_TYPE 未登记 type=3 → is_enabled 恒 400;
  补登记 approval.adjustment.enabled。另修复叠加缺陷——开关关闭分支手工
  ApprovalStep(order_id=调整单id),而该外键指向 approval_orders,必然
  errno 1452;统一改走 submit_order(自动通过留痕由它负责)
- 重算双重计提:业务级一次性计提的「进度登记」回款无生效台账行,重算会
  凭空落新行;无旧行一律 400
- 前端重算传错 id:台账行主键当 receipt_id 传给重算接口,会作废无关回款
  的台账;改传 row.receipt_id(聚合行防御)
- quantize 显式 ROUND_HALF_UP:9 处金额 quantize 缺省银行家舍入,
  x.xx5 与四舍五入口径差 1 分(docstring 却声称 HALF_UP)

测试:显式控制审批开关(V8 起种子默认开启,用例不再依赖默认值);
新增回归 4 例(经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前
锁定),test_calc+test_settlements 22 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:25:21 +08:00
liuwenxinandClaude Code 35fe37fb0c feat(ops): 增量备份——binlog 归档脚本与全量位点记录(#19 答复)
- backup_incremental.sh:归档已写完 binlog(tar 拷出+gzip),状态文件对齐序号,有更新才产出,保留 30 天
- backup_full.sh:补 .pos 位点文件(SHOW MASTER STATUS),增量重放的起点锚
- 手册新增增量章节:恢复顺序=全量→binlog 重放,含 mysqlbinlog 缺席镜像的说明

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:22:39 +08:00
liuwenxinandClaude Code 7008de0c69 feat(api): 计提参数查看放开登录用户、修改仅管理员(#17/#6 答复)
- deps 新增 LoginUser 依赖;params 只读七端点改登录即可,写(提交/撤回)保持 AdminUser
- 前端 params 路由 requiresAdmin → allowedRoles(admin/finance/leader);页面写按钮按角色显隐
- 权限用例分层:普通用户目录 200、提交/撤回 403、未登录拦截

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:19:16 +08:00
liuwenxinandClaude Code 2652397720 chore(migration): 收敛重复 V8 SQL——保留英文名版本(与并行会话对账,内容等价)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:13:28 +08:00
liuwenxinandClaude Code 0e8c5713fa feat(migration): V8 业务答复口径落实——审批开关默认开启、撤试用期统一参数、经理基数开关、台账分摊唯一键
- #5/#7 答复默认开启:两审批开关 default 翻 1,插 10-19 生效行保时点历史
- #16 答复逐人维护:撤 probation.salary.basic(无业务消费点,月结已读 personnel.salary_standard)
- #12 答复参数化:新增 manager.personal.included(默认 0=不计入聚合基数)
- #1 答复分配绩效铺路:台账 uk 扩为 (receipt_id, calc_version, personnel_id) 支持分摊拆行
- schema_sync 解析器过滤 ADD CONSTRAINT 误捕获

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:09:05 +08:00
liuwenxinandClaude Code 238a38c356 test(m3-core): 核算/月结用例收口,目录断言 19 项,测试记录更新
- 金标准金额断言锚定 DB 事实源(NUMERIC(14,2) 读回恒两位);行级明细
  折算两位固化;全量回归 756 passed(701+核算 13/月结 6/公式 12 等)
- 目录断言 18→19(V7 调整单开关);tests/README 增 M3-core 节与运行记录;
  frontend/README 增核算与月结两页

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-17 01:08:31 +08:00
liuwenxinandClaude Code a1217f4482 feat(frontend): 绩效台账与月度结算两页接入(新组绩效工资)
- 绩效台账页(order17):月/版本筛选 + 行展开计算快照 + 重算(原因
  对话框,管理员/财务);核算入口在回款管理页(每笔回款行内触发)
- 月结页(order18):生成快照(月份输入)+ 状态筛选 + 提交审批 +
  详情抽屉(按人明细三口径/试用期说明/审批单号关联)
- 均复用 usePagedList/AppPagination/--ys-* 惯例;金额 str 直出
- type-check 零错误;build 通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-17 00:54:51 +08:00
liuwenxinandClaude Code e7e47d88bc feat(settlement): 月度结算快照生成与审批锁定,调整单(补发/扣减)与审批联动
- settlement_service:generate(确认回款口径 #8 策略点=有效台账行按回款日
  归月、经理聚合 #12 暂口径基数不含经理个人户、月结 uk 一行一月、草稿/
  驳回可重生成、已审定/待审 400)→ submit(审批开关分流,关=自动通过即
  锁定、开=待财务复核)→ approval_service.act 扩 order_type=2/3 钩子
  (通过=mark_locked/调整单生效;驳回=回草稿/作废,延迟导入防循环)
- 锁定拦截:回款新增写路径校验回款月已审定 400(比需求「提示」取硬,
  一期安全侧;M2 已核算禁改防线先于其生效)
- 调整单:新建(TZ 单号,开关分流自动通过留痕/走审批)+ 列表
- schemas settlement:金额 Decimal 声明(str 声明遇 Decimal 入参拒绝)
- 测试 6 例:生成回填/无记录 400/已审定重生成 400/关自动锁定/锁定后回款
  禁改/开两节点流转至锁定(finance→leader)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-17 00:33:31 +08:00
liuwenxinandClaude Code 22a2872100 feat(calc): 重算授权留痕与批量核算
- calc_receipt 双模式:recalc_reason 非 None=重算(audit action=重算/
  reason 留痕、作废旧版本行、calc_version 自动递增、CAS 仅首算执行——
  重算状态本就是已核算,误跑 CAS 会误报并发撞车)
- recalc_receipt 薄委托(reason 必填、未核算过 400);重算授权
  require_roles(admin, finance),营销后勤不可重算
- batch_calc:逐笔独立事务不开外层 audit(嵌套禁令),逐笔成败清单
  (同业务第二笔回款走 #25 重复分支 ok=True/repeated=True)
- 测试 +5:重算版本对/原因缺失 400/未核算重算 400/越权 403/批量清单
- 修 next_version 空表 max NULL → int(None) TypeError

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 21:49:44 +08:00
liuwenxinandClaude Code df1a3839a1 feat(calc): 单笔回款核算引擎与绩效台账
- calc_service.calc_receipt:锁回款行→settlement_status=0 校验→发货批次/
  归属前置→resolve_snapshot(回款日)→按 calc_template 分派(与 _preview_rate
  同构,‰÷1000 防线)→items 行级折算(行类别阈值,混合按行汇总)→
  barter/起付/R→台账 v1→CAS 置已核算,单 audit 事务
- 策略点集中:_personal_rate(#12 经理个人户单笔落 0 待答复)、_row_profit
  (#20 不含税原样);核算粒度 #25 一期口径=业务级一次性计提(重复回款
  repeated=True 仅登记进度)
- 台账仓储:生效行查询/业务级判定/版本号;schemas LedgerOut/Query
- api /api/v1/calc/*:台账分页/详情/核算触发(写限 admin+marketing_ops)
- 测试 8 例:金标准双类别复算到分(DB 锚定 4000.00/39.11/39.11)、
  一次性计提、已核算 400、未指定批次、成本缺失行保守 -3000、负绩效照算、
  准入、列表筛选;行级明细折算两位固化
- conftest 清理顺序修正:V7 台账/月结清理移到回款/人员删除之前
  (RESTRICT 外键不挡序)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 20:52:02 +08:00
liuwenxinandClaude Code a5c86640e2 feat(migration): V7 核算台账/月结/调整单四表与核算纯函数
- V7 四表:performance_ledgers(append-only 版本行,uk(receipt_id,calc_version)
  库级防重兜底)/monthly_settlements(月份 uk,通过即锁定终态)/
  settlement_items(按人固化可负工资,uk 三列)/adjustment_orders(TZ 单号)
- registry +1:approval.adjustment.enabled(19 项,BOOL 首组扩至三开关)
- formula 补核算纯函数:clamp_weight_r(从预览内联提取共用)/monthly_salary
  三口径/probation_annual_note/manager_team_share(#12 暂口径:基数不含
  经理个人户业绩,聚合集中一处答复后只改)
- 迁移 downgrade 只保留 drop_table+alter 回滚+种子行反向 DELETE;
  performance_ledgers 表名复数(test_models 红线断言)
- 断言同步:EXPECTED_TABLES 四新表/registry 19/BOOL 三开关/conftest
  清理四新表与 ParamValue 边界 22
- schema_sync+全量单测 460 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 16:32:32 +08:00
liuwenxinandClaude Code 30a179fc4c docs: 新增待确认事项汇总(立即待办/业务 24 项/IT 18 项/里程碑分层)
- 立即待办:推送被远端 Git 服务阻塞(服务器侧重启)、M2 分支合并 main
- 业务澄清 24 项按高/中/低分层(含建议默认),IT 18 项按优先级分层
- 里程碑待办(M3 剩余/M4/评审遗留 2 项)与文档待办

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 15:08:03 +08:00
liuwenxinandClaude Code 1c93ea0a4b fix(m3): code-review 15 项修复(13 项落地,2 项依赖分摊语义记待办)
- 审批:删 _flush_order 撞键内部重试(rollback 会回掉同事务业务行造成
  静默丢数据,异常改由 create 外层重试整体重来/update 路径审计收敛);
  act() 出参固化前置到 commit 之前(commit 后查询失败会把已通过的审批
  记成失败审计);驳回/撤回清空方案生效行(防被否决方案进 M3 快照);
  keyword 改 contains(autoescape=True) 对齐全库转义口径;撤单抽
  withdraw_pending_orders 供编辑清空方案时显式调用
- 业务:删业务遇在途审批单禁删 400、终态审批单随 _hard_delete 清理;
  编辑清空方案同步撤回在途单;历史离职参与人编辑放行(冗余姓名固化
  语义,仅新增参与人做在职校验)
- 迁移:business_secondary_allocations 补 uk(business_id, personnel_id)
  (模型/迁移 py/导出 SQL/init-dev 四方同步,开发库往返重放验证)
- 设置:upload_logo 改同步 def(阻塞操作不卡事件循环)+ file.size 先拦;
  company_name schema 剥空白防全空白入库;logo 旧文件清理移到 commit
  成功后(防库指旧路径而文件已删断链);uploadLogo 走 request() 解包
  (原直发 service.post 不解包信封,logoUrl 会被置 undefined)
- 备份:restore_drill --drop-exec 先清全部历史 drill 库再演练并即删本轮
  (原语义永远删错对象,每轮净增一份整库拷贝)
- 测试:conftest 清理补 system_settings 种子行与 uploads/logo 文件;
  新增 5 例(驳回清方案/清空撤单/在途禁删终态随删/离职放行/BOOL 边界);
  全量回归 701 passed
- 遗留 2 项记 AI_CONTEXT(方案版本承载、审批单快照,依赖 ④ 分摊语义)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 14:23:53 +08:00
liuwenxinandClaude Code fc8a42bcd9 test(m3): 集成/e2e 增补三模块用例并更新测试记录
- 新增 e2e:settings_flow(改名→登出→登录页即时生效→finally 还原防污染)、
  secondary_allocation_flow(建客户/员→录两行 30%+40%→编辑回显与合计条
  余额提示→删除清理;宽视口防弹窗遮挡、alloc-table 专属类防选择器错位)
- 修复 e2e 首跑抓出的两处缺陷:① public 端点 300s 响应缓存让登录页拿旧
  公司名(登录页改时间戳 fetch + cache:no-store,手动解包信封)② logo 同
  扩展名覆盖时先写后删(清理移到写入前且仅异扩展名执行)
- 分配表加 alloc-table 专属类(与产品明细 items-table 区分)
- 全量回归 697 passed(基线 625 + 72);e2e 七脚本全过(既有五脚本回归 +
  两个新增);pnpm type-check 零错误
- tests/README.md 增 M3 节(44 例明细)/运行记录/缺陷 3 行;frontend/
  README.md 增 M3 e2e 节

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 12:01:52 +08:00
liuwenxinandClaude Code b5e27810fd feat(backup): mysqldump 定时备份/恢复演练/健康检查脚本与手册
- scripts/backup_full.sh:容器内 mysqldump --single-transaction,gzip 落盘
  db/backups/,保留 30 天;凭据 source docker/.env 不硬编码;实测备份成功
- scripts/restore_drill.sh:恢复到临时库 payroll_drill_* 并逐表核对行数
  (16 张关键表),默认不 DROP、--drop-exec 才删——绝不覆盖生产库;
  实测 16 表全 ✓。规避嵌套 shell 吞反引号(mysqladmin create/drop、
  双引号包裹表名)
- scripts/health_check.sh:六项检查(容器健康×2/磁盘水位/备份新鲜度/
  Redis 探活/24h 登录失败阈值),PASS/WARN/FAIL 报告,退出码供监控;
  Redis 密码经宿主机 .env 验证(容器内无该环境变量),实测六项全 PASS
- docs/备份恢复手册.md:备份/演练/生产恢复七步/定时 schtasks 两则,
  db/backups/ 已入 .gitignore

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 09:44:50 +08:00
liuwenxinandClaude Code 927592b472 feat(frontend): 公司设置/二次分配/审批三页接入,参数页编码中文化
- 公司信息页(设置 order7):名称编辑 + logo 上传(png/jpg ≤2MB
  前端先拦)+ 签名元素「登录页实景预览」(缩比登录卡,品牌名/logo
  即时反映,所见即所得)
- 审批管理页(order8):列表筛选(关键词/状态/类型)+ 签名元素
  「竖排节点时间线」(财务复核→领导审核,人工/自动/驳回色区分)+
  在途单通过/驳回动作(驳回必填意见,经 useConfirmAction 确认流)
- 路由扩展 allowedRoles 元数据:菜单按任一列入角色可见 + 守卫双
  过滤(admin 须显式列入集合,不天然放行);审批页对 admin/
  finance/leader 可见(营销后勤待权限清单 #6)
- 业务表单二次分配区:参与人下拉(离职禁用)+比例输入+实时合计条
  (合计≠100% 提示余额归属待业务确认,满额高亮);编辑全量替换、
  空行自动忽略;校验(人员非重/比例两位小数)与后端 NUMERIC(5,2)
  同口径
- 登录页接 /system/settings/public:品牌名与页脚动态,logo 展示,
  失败静默回退默认(登录页必须可用)
- 参数页中文化:维度角色名映射(营销员/营销经理/信息员)、枚举值
  中文(当月全额/余额、含/扣/不计基本工资、开/关)、预览「所用参数」
  由编码直出改中文名(编码括注)
- 验证:pnpm type-check 零错误;pnpm build 通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 09:38:05 +08:00
liuwenxin 74f1ef1cf9 feat(frontend): 审批与系统设置模块页面接入及业务列表调整 2026-10-16 09:32:12 +08:00
liuwenxinandClaude Code 8ef26e22fb feat(approval): 审批单/节点状态机与二次分配接入(开关自动通过)
- approval_orders/approval_steps 服务层:submit_order(不 commit 不嵌套
  audit,业务写路径统一收口)+ act 节点动作 + 分页/详情
- 开关关闭=提单即两级「自动通过」留痕(action=3,operator 记系统);
  开启=财务→领导流转,一驳全驳,驳回必填意见
- 节点准入严格按角色(status=1 仅财务、status=2 仅领导),admin 不代办;
  模块准入 require_roles 依赖工厂(admin/finance/leader,营销后勤待
  澄清 #6 后放开)
- 业务接入:创建/编辑带方案即提单(空方案不提单);再编辑自动撤回旧
  在途单(node=0/action=4 留痕);AP 编号冲突 create 内可重取(外层
  重试兜底)、update 直接抛出防静默丢失
- conftest 兜底清理增审批单(AP 前缀)与开关参数种子基线恢复
- 集成 11 例:自动通过/两节点流转/驳回留痕/缺意见 400/节点准入 403/
  admin 不代办/重复操作 400/撤旧建新/模块准入

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 18:50:38 +08:00
liuwenxinandClaude Code 680661a80b feat(allocation): 二次分配方案录入/替换/终态门槛(分摊计算待澄清另批)
- SecondaryAllocationIn/Out:比例走金额红线口径(str 入参、两位小数、
  超 100 拒绝不静默舍入);参与人 schema 层去重(uk 前置友好提示)
- create/update 同事务写入(replace_allocations,先删后 flush 再插——
  uk 表防御写法);create 放编号重试循环内,rollback 后方案随业务重来
- 人员在职复用 ensure_active(label=分摊参与),personnel_name 冗余固化;
  FK RESTRICT:被方案引用人员禁删,方案回显不静默失联
- 合计不强制 100%(需求原文):详情 allocation_hint 只展示合计/余额,
  余额归属待澄清 #2 不落库
- 单测 13 例(比例边界/去重)+ 集成 9 例(回显/替换清空/离职 400/
  终态禁改/RESTRICT 保护)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 18:31:27 +08:00
liuwenxinandClaude Code efed29647c feat(settings): 公司信息配置与 logo 管理(public 端点供登录页)
- system_settings 单行 CRUD:GET/PUT(AdminUser)+ GET /public(无鉴权,
  SettingPublicOut 白名单仅 company_name/logo_url 两字段,短缓存 300s)
- logo 上传:后缀+magic bytes+2MB 三重校验,固定名覆盖落盘 uploads/logo/
  (不使用上传方文件名,杜绝路径拼接);旧扩展名文件清理;文件流端点
  no-cache,URL 带 update_time 版本参数破缓存
- logo 存目录不存库:图片入 DB 让每次读取与 mysqldump 放大传输且无法
  独立替换;DB 只持一行相对路径
- 新增依赖 python-multipart(FastAPI UploadFile 必备传输层);.gitignore
  增 backend/uploads/ 与 db/backups/
- 集成 11 例:单行语义/白名单字段/三重校验/越权 403/匿名 401/审计留痕

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 18:22:12 +08:00
liuwenxinandClaude Code e95dc4b2de style(backend): formatter 收口两处漏网文件(纯格式无行为变化)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 17:25:24 +08:00
liuwenxinandClaude Code acf87fdfdc feat(migration): V6 系统设置/二次分配/审批表与角色参数种子
- 新增 system_settings(公司信息单行)/ business_secondary_allocations(二次
  分配明细,比例不强制合计满额,分摊计算待澄清另批)/ approval_orders+
  approval_steps(三级审批状态机,开关关闭自动通过留痕)
- roles 种子新增营销后勤/财务/领导三职能角色;内置保护改 BUILTIN_ROLE_CODES
  白名单(roles 表无 is_builtin 列,不加列)
- PARAM_REGISTRY 首例 BOOL:两个审批开关(16→18 项,种子双写)
- 双轨同步:V6 导出 SQL + init-dev.sql(表/索引/种子/版本号 79bcc5aa98d4)
- 测试同步:EXPECTED_TABLES 四新表、角色种子断言 5 角色、目录断言 18 项
  八分组、BOOL 校验边界;迁移 downgrade 修 1553(只保留 drop_table)并补
  行级种子反向清理

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 17:25:23 +08:00
liuwenxinandClaude Code 94c49138bf refactor(e2e): 六个列表页 UI helper 收口 common.py
open_menu 时分组参数化、fill_filter :text-is 精确匹配、messagebox 带
:visible(base_data 实现为准)——两脚本此前各持一份且匹配精度漂移
(has_text 子串 vs :text-is),使用者误以为两者等价;登录/系统管理/
基础数据/业务数据四链路 e2e 复跑全过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 11:01:31 +08:00
liuwenxinandClaude Code f75a9d534e refactor(test): 集成工厂上提共用,submit_change 拆校验段,审计重试断言对齐全局兜底
- tests/integration/factories.py:回款/发货两文件逐字重复的
  _customer/_bitem/_bpayload/_create_business 上提(t_ 前缀与默认行
  口径单点维护);test_audit_retry 的持续冲突断言由 500 更新为
  400「数据完整性冲突」——全局 IntegrityError handler(批 6)收敛
  行为属预期改进,非回归
- param_service.submit_change 拆出 _normalize_items(校验段独立,
  主函数降至 50 行约定内);e2e 六个列表页 helper 收口 common.py
  (base_data 版为准:分组参数化、:text-is 精确匹配),两脚本变薄
- 全量回归 625 passed(基线 603 + 新增 22);e2e 四链路(登录 8/
  系统管理 13/基础数据 13/业务 16)全过
- 测试 README 补 2026-10-15 运行记录与 10 条评审缺陷(预览千分比/
  审计假成功/字典序 MAX/无锁编辑/删产品 500/schema 口径/quantize/
  负天数外插/评级并发/大小写不对称)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 10:58:44 +08:00
liuwenxinandClaude Code 0ef3adec21 refactor(frontend): 金额正则/分页/抽屉状态/行编辑与下拉收口
- 金额形态正则收进 utils/validate.ts 单点导出(此前四页各写,精度
  调整漏改一页即「A 页拦得住 B 页放行后端 400」或历史行无法编辑)
- params 版本/历史两抽屉的手写分页五件套换 usePagedList(白得请求
  序号防竞态——快速翻页旧响应覆盖新页的手写坑不再复现)
- 业务抽屉三份 id 状态收为 drawerRowId computed(单一状态源,消除
  「标题 A 业务、留痕表 B 业务」的失同步面)
- useEditingRow(三页逐字相同的 computed)与 useDropdownOptions
  (取数失败不阻断列表的骨架)收口,映射函数由调用方给出
- e2e 冒烟:基础数据 13 步 + 业务全链路 16 步全过

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 10:08:24 +08:00
liuwenxinandClaude Code 60f6af6494 refactor(repos): 归属留痕共享数据访问,人员在职校验收口单点
- ownership_log_repository:客户/业务两侧逐行同构的 list/count/add
  收口(owner_type 与引用列参数化),双键倒序与被删人员兜底口径
  单点维护;两侧 repository 变薄包装,service 层零改动
- PERSONNEL_STATUS_ACTIVE 单点导出于 models/personnel(对照
  BUSINESS_STATUS_* 先例);业务/客户两侧的「存在且在职」校验收口
  于 personnel_service.ensure_active——此前两份常量+校验仅靠注释
  维持同步,在职判定调整时会两入口口径分叉;校验放 service 层遵守
  「repositories 不抛业务异常」红线

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 09:49:53 +08:00
liuwenxinandClaude Code 409e26614f refactor(business): 编辑出参取校验返回值省重查,submit_change 批量预载
- update_receipt 取 _ensure_shipment_of_business 返回值组装出参
  (shipment_id 已赋为 payload 新值,与 get_with_refs 重查同源),
  省一次三表 join;update_shipment 同理取锁定行的 business_no
- 删 business_repository.count_items(无任何调用的死代码,删除保护
  实际走 has_docs)
- submit_change 校验段定义与岗位各一条 IN 批量预载(逐条查询在批次
  扩大时是写侧 N+1);岗位字典键 casefold 归一保持与单条版 ci 校对
  等价的命中语义;旧值留痕保持逐条(窗口函数优化留待 M3 一并设计)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 09:32:35 +08:00
liuwenxinandClaude Code 5ef48abf47 test(schema-sync): 迁移链按序重放 ALTER,比对基准由历史并集改为终态
早前实现把全部 V*.sql 的 CREATE 列做并集:某表日后被 ALTER DROP
COLUMN 时并集里旧列名永存,init-dev.sql 必须保留死列才能过测——
守卫会主动背书一份与生产结构漂移的开发库 DDL。按文件序重放
ADD/DROP COLUMN 后比对基准为迁移链终态;列改名(CHANGE)出现时
重放报差异属人工核对时机(改名同时牵动 init-dev 与 ORM)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 09:24:34 +08:00
liuwenxinandClaude Code 7f8f88c148 fix(frontend): 错误提示单一化/成功提示后置/日志页首次成功拉取
- request.ts blob 失败分支:先解析真实文案再统一提示——此前先弹兜底
  「导出失败」再异步补弹真实 message(双提示),401 时跳转提示与
  实际原因错位;401 语义文案在导出场景不再失真
- useConfirmAction:收尾(列表刷新)成功后才弹成功提示——此前先弹
  「已删除」再刷新,after 失败被吞时成功/失败提示同屏矛盾
- 日志页登录 tab 首次成功加载后不再重复拉取(loginLoadedOk 由
  fetcher 成功返回置位,失败切回可重拉,不复活「失败停空表」旧坑)
- login_flow.py 密码改从 E2E_ADMIN_PASSWORD 读(与 system_management
  同一约定),硬编码默认值在他机上会假绿为「凭据错误」
- e2e 冒烟:登录 8 项 + 系统管理 13 项全过(拦截器全局改动验证)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 09:22:12 +08:00
liuwenxinandClaude Code c2b462df3b fix(rating): 区间重叠判定改用锁定读取,防并发重叠双提交
先查后写无锁时,两管理员并发提交重叠区间互判不重叠而双双入库
(区间重叠无唯一键可拦,同一总分命中多等级且结果取决于插入顺序);
判重叠的 list_all 加 for_update 锁全表行串行化并发判定,只读直出
list_grades 不加锁。SQL 形态用例锁定两条口径。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 19:01:11 +08:00
liuwenxinandClaude Code e6c2c71d1b fix(business): 行金额乘积显式两位固化,计提比例对负天数钳制
- 明细行金额(数量2位×单价4位可产 6 位小数)直写 DECIMAL(14,2) 被
  MySQL 静默舍入且回显漂移(创建响应读内存值、复查读库值不一致);
  显式 quantize HALF_UP 固化,舍入方向与 MySQL 一致(业务/发货同改)
- 先款后货回款(payment_days 为负)线性外插会算出高于起始值的计提
  比例(多算绩效);钳制到 0 天按起始比例计提,payment_days 出参仍
  照实输出负值(口径:2026-10-14 用户裁决)
- 新增 formula 负天数钳制单测与 quantize 回显/库值一致性集成用例

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 18:58:30 +08:00
liuwenxinandClaude Code 91fc6f6293 fix(product): 删除引用保护/单价小数位校验,IntegrityError 全局兜底转 400
- delete_product 补 count_refs 引用检查(业务明细+发货明细 RESTRICT
  外键)与并发窗口 IntegrityError→400 兜底(customer 先例同构),
  docstring 与两个 api 层过时注释同步更新
- 产品 schema 复用 schemas/business._check_amount:sale_price 走
  (PRICE_MAX, scale=4)、stock_quantity 走 AMOUNT_MAX——此前私写
  _parse_non_negative 不查小数位与容量,5 位小数被 Numeric(14,4)
  静默截断(舍入即算错钱);新增 6 个秒级 schema 单测锁定口径
- 全局注册 IntegrityError handler:漏补引用保护的删除类操作收敛为
  400 通用文案(e2e 撞见一个补一个的模式无法保证全覆盖),完整
  异常记日志;rating/customer 的「非 1062 按 500」注释同步更正

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 18:48:32 +08:00
liuwenxinandClaude Code 9a70264b51 style(backend): ruff format 统一此前批次的排版残留
纯排版无行为变化(ruff format 重排 7 个此前批次的文件),
涉及模块集成回归全绿(67 passed)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 18:46:43 +08:00
liuwenxinandClaude Code 6d612daf30 fix(business): 编辑/流转/归属加锁与终态编辑门槛,has_docs 单点收口
- get_with_names 加 for_update 参数:update/change_status/change_ownership
  以锁定读取行,与发货/回款写路径同一锁口径——「有发货禁换客户」的
  快照读校验不再被并发新增发货旁路(此前 delete 有锁、唯独编辑无锁)
- update_business 补终态门槛(明细/汇总是 M3 核算基线,与发货/回款
  服务禁写终态同口径);前端编辑按钮按终态禁用并加 tooltip
- 新增 business_repository.has_docs(单条 OR exists):替换 update
  换客户与 delete 两处内联双查询,M3 新增挂靠单据类型时只改一处
- 出参组装复用首查 row:change_ownership 的姓名侧按「变更项取校验
  函数返回的新人名、未变项沿用首查名」处理(首查名是变更前快照)
- 新增终态编辑 400 且明细未动的集成用例

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 17:30:21 +08:00
liuwenxinandClaude Code a0086554b1 refactor(core): 1062 唯一键冲突判定收口至 core/db_errors
六个 service 各自复制的 _is_duplicate_entry(口径已漂移:rating/product
用 _ERR_DUP_ENTRY 常量、其余四处写字面量 1062)收敛为共享函数;
is_duplicate_entry 语义与文案注释各处保留,非 1062 完整性错误仍原样
上抛由全局处理器收敛。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 15:17:00 +08:00
liuwenxinandClaude Code 5a3778a424 fix(business): 唯一键冲突重试改为审计上下文内循环,单条审计无递归
三处 create(业务/发货/回款)的 return _retry_create 是吞异常后正常
退出:外层审计把已回滚的首次尝试记成 success(假成功),重入整个
create_* 违反 audit_service「不支持嵌套」约定,且 _NO_RETRY 死常量
导致持续冲突时无界递归至 RecursionError。改为 audit 内 for 循环
(上限 _NO_RETRY),rollback 后重新锁行与取号,成功/最终失败各恰好
一条审计。新增集成用例锁定两条路径的日志增量。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 15:09:05 +08:00
liuwenxinandClaude Code cb977979da fix(param): 预览千分比换算/版本号复用 next_no/维度规范值落库/冗余提交清理
- 企业户与信息员预览分支把 ‰ 存储值当纯比例直接相乘,绩效金额放大
  1000 倍;补两个岗位路径的精确数值用例(既有用例仅覆盖业务员路径)
- 批次版本号改用 no_generator.next_no(MAX FOR UPDATE 锁当日区间),
  移除无锁全拉重造的 _next_version_no(依赖前一笔的长度优先排序)
- 岗位维度值经 ci 校对命中后以库里规范 role_code 落库,防快照字典
  精确匹配静默不命中;变形大小写提交补回读断言
- 删正常路径冗余双 commit;支付权重 R 封底封顶口径注释声明

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 14:20:05 +08:00
liuwenxinandClaude Code 64c57e9141 fix(no_generator): 编号 MAX 检索改长度优先排序,破解位宽进位后字典序失效
跨位宽(-9999→-10000)后纯字典序反转,MAX 恒取旧值导致当日建单
持续撞唯一键;同前缀下长度大即数值大,同级同长时字典序与数值一致。
新增跨位宽/同宽/空表三个集成用例。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 14:09:30 +08:00
liuwenxinandClaude Code d8dba27fb2 docs(requirements): 模块页面功能清单(开发交付视角:14 页面已交付/M3+M4 待开发)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 18:00:23 +08:00
liuwenxinandClaude Code d77728265b docs(business): M2 收尾——e2e 记录、缺陷表、AI_CONTEXT 摘要更新
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:13:21 +08:00
liuwenxinandClaude Code 8c4acb82fe fix(business): e2e 链路验证与两处修复
- wait_toast 超时返回最后见到的非空提示(错误 toast 一闪而过时诊断信息全丢)
- 业务/发货明细行单价类字段放行 4 位小数(与后端 DECIMAL(14,4) 对齐)
- 清理步骤适配删除保护(被终态业务/引用拦截时留档而非失败)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:06:46 +08:00
liuwenxinandClaude Code fba8cfc215 fix(business): 删除保护补齐与单价小数位口径修复(e2e 发现)
- 客户删除保护:被业务引用 → 400(T4 占位注释在业务表就位后漏补,FK 500 暴露)
- 人员删除保护:业务归属(负责/协助)引用纳入检查(同样漏检 500)
- schema 单价类列(DECIMAL 14,4)小数位放行至 4 位:回显 '5000.0000'
  原样提交被两位校验永久拒收(编辑任何带精度单价的历史行都失败)
- 补 2 个集成用例锁定;文案同步「客户归属或业务归属引用」

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:06:46 +08:00
liuwenxinandClaude Code 299b72483c test(business): M2 基础数据与业务数据两个 e2e 链路脚本(13+16 步)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 14:17:28 +08:00
liuwenxinandClaude Code c512cb77fb docs: 后端接口清单补基础/业务数据两节与 M2 关键约定;前端 e2e 清单预登记
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 13:43:17 +08:00
liuwenxinandClaude Code ca4cc72eea test(business): 业务数据管理模块集成用例(70 例)与两处缺陷修复
- 覆盖:编号格式与序号递增/状态机终态拦截/三处汇总冗余一致性/跨业务批次
  400/已核算禁改禁删/毛利润金标准与防除零/asOf 回溯/评级重叠/审计双记录
- 缺陷修复①:change_status 留痕 old_status 取到赋值后属性(时间线失真 3→3)
- 缺陷修复②:freight_amount 漏金额校验(负数/三位小数可落库污染运费合计)
- conftest 扩展业务六表清理(编号前缀 + FK 逆序收口)与评级种子基线恢复
- 全量回归 603 passed(单元 381 / 集成 222)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 13:39:09 +08:00
liuwenxinandClaude Code 77e578085a feat(business): 操作日志模块词表补业务数据四模块(与后端 MODULE_* 同步)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:51:20 +08:00
liuwenxinandClaude Code 06cdf4141f feat(business): 前端业务/发货/回款管理三页
- 业务页:明细行编辑器、详情抽屉五分区(含状态时间线与毛利润卡片)
- 回款页:业务→发货批次级联指定(可清空)、已核算行禁改禁删
- 金额/比率字符串直出;毛利润卡片注明不含运费口径

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:50:10 +08:00
liuwenxinandClaude Code 5207d14daa feat(base): 前端基础数据三页与客户评级计算器(人员/产品/客户/评级)
- 人员页:编辑抽屉内区域/计划子表编辑;产品页:类别阈值提示条;
  客户页:归属变更二次确认含「不回溯历史」+变更记录抽屉
- 评级页:五项评分实时出等级(纯前端判定,不落库 #25)+ 等级映射维护抽屉
- 菜单两组:基础数据(8-10)/业务数据(14);金额字符串直出禁 float

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:25:45 +08:00
liuwenxinandClaude Code 09831dbb7f feat(business): 挂载发货/回款路由 /api/v1/business/*
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:25:34 +08:00
liuwenxinandClaude Code a02b6c3ec8 feat(business): 回款管理接口(批次号/手动指定批次/已核算禁改/合计回写)
- 批次 HK 生成;shipment_id 同业务强校验(统一失败面防探测)
- 已核算回款禁改禁删(M3 防重锚点);payment_days 派生(发货日首日=第 1 天)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:25:34 +08:00
liuwenxinandClaude Code 4aea2be103 feat(business): 发货管理接口(发货单/明细/运费/汇总回写/删除保护)
- FOR UPDATE 锁业务行防发货/回款并发丢失更新;终态业务禁新发货
- 行金额服务层按数量×单价固化;明细行 remark 按设计定稿移除(冒烟修复)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:25:33 +08:00
liuwenxinandClaude Code 0cdc6246c4 feat(business): 业务管理接口(编号生成/明细/状态机/归属留痕/毛利润视图)
- 编号 YW{日期}-{序号}:MAX+FOR UPDATE 并发安全 + 1062 兜底重试(no_generator 三单据共用)
- 明细全量替换重算五项汇总冗余;销售额服务层按数量×单价固化不信任前端
- 状态机终态拦截、终止必填原因、创建写首条留痕;归属变更同事务留痕防旁路
- 毛利润视图按行实时计算(不含运费 #2),阈值按 asOf 取 M1 快照,率输出
  Fraction 分数字符串同 M1 预览口径;成本缺失行标注无法计算
- 入参契约统一为蛇形直出(撤回 customer/business 的驼峰 alias,与前端
  businessRoles.ts 模式对齐)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:01:48 +08:00
liuwenxinandClaude Code 1625907380 feat(business): 客户评级等级映射配置接口(区间重叠判定/名称唯一)
- 判定=按 sort_order 升序首个 min_score≤总分,边界相邻不算重叠
- 新增同样执行重叠判定(判定表语义下插出重叠即脏配置)
- 等级配置无下游引用(评级为纯计算器,对照表 #25),允许清空

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:01:47 +08:00
liuwenxinandClaude Code 899bf3afa5 docs(test): 基础数据模块测试记录(48 例/审计盲区缺陷/待补用例更新)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 11:24:29 +08:00
liuwenxinandClaude Code 20fcf06f51 feat(business): 新增业务数据七表及 V5 迁移(业务/明细/状态留痕/发货/回款/评级配置)
- businesses 五项汇总冗余列服务层事务内维护;毛利润不落库(派生值,M3 台账固化)
- 行级 product_category 折算锚点(#14);回款 settlement_status 预置为 M3 防重锚点
- 评级等级种子四条(迁移+init-dev 双写);编号前缀常量 YW/SH/HK
- 双轨 SQL 同步(V4/V5 重导,schema_sync+test_models 164 例全绿)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 11:22:34 +08:00
liuwenxinandClaude Code ca88ee7a0b test(base): 基础数据管理模块集成用例(48 例)与审计盲区修复
- 覆盖:401/引用校验/删除保护/子表全量替换/类别与组合唯一/归属变更留痕/审计双记录
- 缺陷修复:personnel/product/customer 三 service 的前置校验移入审计上下文,
  校验失败(400)同样留痕(成功与失败都记的既定原则)
- conftest 兜底清理扩展到 M2 五表(留痕无外键,按 operator_name + 前缀双轨清理)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 11:22:34 +08:00
liuwenxinandClaude Code 15de81dd2c feat(base): 挂载基础数据路由 /api/v1/base/*
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:04 +08:00
liuwenxinandClaude Code b8ba9e4626 feat(base): 客户管理接口与归属变更留痕(不回溯历史)
- 归属变更同事务更新主表+插留痕;归属调整唯一入口防旁路

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:04 +08:00
liuwenxinandClaude Code 7eeed85c96 feat(base): 产品管理接口(类别校验/组合唯一/启停)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:03 +08:00
liuwenxinandClaude Code a694570dd1 feat(base): 人员管理接口(岗位/工资标准/团队/辐射区域/拓展计划)
- 删除保护:被团队成员/客户归属引用禁删,引导改离职
- 子表全量替换采用 clear 后立即 flush 防御写法
- 工资标准字符串进出,超两位小数拒绝而非静默舍入

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:02 +08:00
liuwenxinandClaude Code f22d14e439 feat(base): 新增基础数据六表及 V4 迁移(人员/辐射区域/拓展计划/产品/客户/归属留痕)
- TPlusSyncMixin 统一 T+ 同步预留四列(可空+普通索引,唯一键待 IT 定键后升级)
- 留痕表实体 ID 无外键冗余固化,删除保护由服务层承担
- init-dev.sql 同步六表 DDL,alembic_version 标记更新

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 09:49:05 +08:00
liuwenxinandClaude Code 4552f2e703 docs(plan): M2 基础数据与业务数据实施计划(表结构评审定稿)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 09:26:39 +08:00
578 changed files with 86152 additions and 1931 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
+36 -3
View File
@@ -1,5 +1,7 @@
# AI辅助目录(整个目录忽略) # AI辅助目录(整个目录忽略)
ai-aux/ .ai/
.agents/
/AGENTS.md
# 依赖 # 依赖
node_modules/ node_modules/
@@ -45,13 +47,15 @@ htmlcov/
.mypy_cache/ .mypy_cache/
.ruff_cache/ .ruff_cache/
.tox/ .tox/
# 端到端测试产出的截图 # 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD
frontend/e2e/*.png # 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖)
frontend/e2e/**/*.png
# 临时文件 # 临时文件
*.tmp *.tmp
*.bak *.bak
*.cache *.cache
.tmp-processes*.txt
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx) # Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
~$* ~$*
@@ -59,3 +63,32 @@ frontend/e2e/*.png
backup/ backup/
.claude .claude
CLAUDE.md CLAUDE.md
# 运行期上传物(公司 logo 等):与构建产物隔离,不入版本库
backend/uploads/
# 数据库备份导出目录(⑨ 备份脚本产物)
db/backups/
# 客户发来的原始账套备份压缩包。
/db/*.zip
# 本地恢复与连通性排查脚本/结果,不作为业务交付物。
/db/restore_*.ps1
/db/restore_*.sql
/db/restore_result.txt
/db/probe*.ps1
/backend/probe_*_status.py
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
docs/**/*.rar
docs/T_12.3数据字典/
# macOS 压缩包残留元数据
__MACOSX/
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
.tmpdocx/
# 本轮原开发环境损坏后的独立恢复运行环境。
backend/.venv-tplus/
backend/.venv-workbench/
# 本地浏览器验收自动生成的快照
/.playwright-mcp/
/phase2-evidence-*/
+21 -2
View File
@@ -2,7 +2,7 @@
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。 营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
> 当前进度:登录鉴权与系统管理(用户/角色/操作日志/日志查询)已完成,前端具备应用布局框架。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。 > 当前进度:正式工资闭环及同步运维自动化已完成本地开发验证,支持每周原始采集、有限重试、独立心跳和站内提示,并提供 Windows 托管脚本。辽宁兴晟达真实样本已在隔离库完成正式导入、核算、月结、报表与导出验收,整月实际工资仍须另行核定,见[真实样本业务闭环验收](docs/真实样本业务闭环验收_2026-11-03.md)。生产自动化默认关闭,尚未安装启用。审核能力保留但默认关闭,多原单更正仍单独跟踪。相关说明见 [T+ 本地恢复与同步开发手册](docs/T+本地恢复与同步开发手册.md) 和 [Windows 操作说明](scripts/windows/README.md)。
## 快速开始 ## 快速开始
@@ -70,7 +70,7 @@ pnpm dev
# 后端测试与代码检查 # 后端测试与代码检查
cd backend cd backend
uv run pytest tests/unit -q # 纯单元,无需容器,秒级 uv run pytest tests/unit -q # 纯单元,无需容器,秒级
uv run pytest -q # 全部(集成部分需容器在跑) python ../scripts/run_isolated_tests.py # 创建独立测试库运行集成,禁止指向业务库
uv run ruff check . # 代码检查 uv run ruff check . # 代码检查
uv run ruff format . # 代码格式化 uv run ruff format . # 代码格式化
@@ -128,9 +128,28 @@ python e2e/system_management.py # 系统管理 13 项
## 相关文档 ## 相关文档
客户现场部署见 [客户现场部署与 T+ 连接说明](docs/客户现场部署与T+连接说明.md):按 Linux 容器、Windows Server 或 Linux 原生选择托管方式,连接客户既有 T+ SQL Server。生产 Compose 为 `docker/docker-compose.prod.yml`,T+ 恢复库编排仅用于本地开发。
| 文档 | 内容 | | 文档 | 内容 |
| --- | --- | | --- | --- |
| `backend/README.md` | 后端启动、分层约定、接口清单 | | `backend/README.md` | 后端启动、分层约定、接口清单 |
| `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 | | `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 |
| `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 | | `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 |
| `docs/` | 需求文档与工资算法方案 | | `docs/` | 需求文档与工资算法方案 |
## 正式工资闭环
- 在“角色管理”配置工资操作权限,在“用户管理”分配角色;账号自动继承权限,无需单独绑定工资制作人。每次实际经办人继续留痕。
- 应用新增迁移后,T+ 页面提供正式业务导入预览/执行;业务列表“正式核算”准备人工业务资料或核算已导入资料,台账可查看逐段依据。
- “生成月结快照”首次成功即锁定该月分配;审批退回、重新生成不解锁。历史已生成工资保留,差额计调整单生成当月。
- 后端接口说明、验证状态和已知边界见 `docs/正式工资闭环开发记录_2026-10-30.md`。旧引擎仅通过 `PAYROLL_ALLOW_LEGACY_CALCULATION` 显式启用作历史兼容,默认关闭,不用于最新规则验收。
- 集成回归使用 `scripts/run_isolated_tests.py`;原 `.venv` 不可用时,本机使用 `backend/.venv-workbench/Scripts/python.exe`。运行器默认清理本次新建的隔离库和账号,绝不删除原开发数据;需复查失败现场时加 `--keep`。
审核默认关闭:`PAYROLL_APPROVAL_ENABLED=false`,工资生成直接完成且不产生审批痕迹;如确需系统内复核,显式开启后沿用财务/领导审核和各类审批参数。此配置与旧核算引擎兼容开关独立。
## 权限管理
- “用户管理 → 分配角色”:选择角色并预览继承权限,支持多个角色叠加。
- “角色管理 → 配置权限”:按模块配置模板;停用角色即时撤销其授权。
- 管理员拥有全部功能权限;月份锁定、审核节点及源读取开关继续独立检查。
- 授权变更无需重新登录,后端逐请求校验;详情、只读提示、菜单与按钮使用统一规则。操作与接口说明见 [权限管理使用与开发记录](docs/权限管理使用与开发记录_2026-11-02.md)。
+50 -3
View File
@@ -17,6 +17,15 @@ REDIS_DB=0
# ===== 应用 ===== # ===== 应用 =====
APP_ENV=dev APP_ENV=dev
APP_SECRET_KEY= APP_SECRET_KEY=
# 运行监控:每 15 秒采集,分钟聚合保留 7 天;部署限单个应用进程
MONITOR_ENABLED=true
MONITOR_CPU_THRESHOLD=85
MONITOR_MEMORY_THRESHOLD=85
MONITOR_DISK_THRESHOLD=90
MONITOR_RESOURCE_DURATION_SECONDS=300
MONITOR_SLOW_REQUEST_MS=2000
MONITOR_ERROR_RATE_THRESHOLD=5
MONITOR_ERROR_MIN_REQUESTS=20
# JWT 有效期(分钟),规范要求 ≤120 # JWT 有效期(分钟),规范要求 ≤120
JWT_EXPIRE_MINUTES=120 JWT_EXPIRE_MINUTES=120
# 验证码有效期(秒),规范要求 300 # 验证码有效期(秒),规范要求 300
@@ -26,6 +35,44 @@ LOGIN_FAIL_LIMIT=5
LOGIN_LOCK_MINUTES=15 LOGIN_LOCK_MINUTES=15
# ===== T+ 对接(待畅捷通确认后填写)===== # ===== T+ 对接(待畅捷通确认后填写)=====
T_PLUS_BASE_URL= T_PLUS_ENABLED=false
T_PLUS_APP_KEY= T_PLUS_HOST=127.0.0.1
T_PLUS_APP_SECRET= T_PLUS_PORT=1433
T_PLUS_DATABASE=
T_PLUS_USER=
T_PLUS_PASSWORD=
# 仅用于已确认的开发测试库,默认禁止使用 sa。
T_PLUS_ALLOW_DEV_SA=false
# 仅在客户确认只能使用管理员账号时显式开启。
T_PLUS_ALLOW_ADMIN_ACCOUNT=false
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
T_PLUS_CONNECTION_FILE=
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
# 本地 PoC 可复用已安装的 SQL Server 驱动;不自动安装或升级客户驱动。
T_PLUS_ODBC_DRIVER=
# 默认启用传输加密。旧实例 TLS 不兼容时,仅隔离开发环境可显式设为 false,
# 记录原因并重新验收;程序不会自动降级。真实环境值后续按验收结果填写。
T_PLUS_ENCRYPT=true
# 默认校验服务器证书。仅隔离开发环境可显式设为 true,并记录原因和验收范围。
T_PLUS_TRUST_SERVER_CERTIFICATE=false
T_PLUS_CONNECT_TIMEOUT=10
T_PLUS_QUERY_TIMEOUT=120
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
T_PLUS_QUERY_DIR=
# 工资操作通过角色权限控制,无需配置全局制作人账号。
# 审核能力保留但默认关闭;开启后仍按各类审批参数及财务/领导节点执行
PAYROLL_APPROVAL_ENABLED=false
# ===== T+ 周期采集(默认关闭,仅采集原始数据,不自动生成工资)=====
T_PLUS_AUTOMATION_ENABLED=false
# 0=周一,6=周日;时段均为北京时间
T_PLUS_SCHEDULE_WEEKDAY=0
T_PLUS_SCHEDULE_TIME=02:00
# 托管启动器自动设置;发现标记后停止排队和领取新任务
T_PLUS_STOP_FILE=
# 仅允许本机 HTTP 健康检查地址;API 更换监听端口时同步修改。
T_PLUS_SUPERVISOR_HEALTH_URL=http://127.0.0.1:8000/api/health
# 可选上传文件根目录;留空使用 backend/uploads,隔离测试由运行器独立指定。
UPLOAD_DIR=
+43
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"]
+144
View File
@@ -71,6 +71,42 @@ app/
| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) | | GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) |
| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) | | GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) |
### 基础数据接口(需求 2.1,登录即可 —— 业务操作不要求管理员)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET / POST | `/api/v1/base/personnel` | 人员列表(姓名模糊/状态/岗位筛选,附岗位与经理名)/ 新增(岗位须启用;经理非自己且在职) |
| GET / PUT / DELETE | `/api/v1/base/personnel/{id}` | 详情(附区域与计划)/ 编辑 / 删除(被团队成员或客户归属引用 → 400 引导改离职) |
| PUT | `/api/v1/base/personnel/{id}/status` | 在职 / 离职切换 |
| PUT | `/api/v1/base/personnel/{id}/regions` | 辐射区域全量替换(空列表即清空) |
| PUT | `/api/v1/base/personnel/{id}/plans` | 市场拓展计划全量替换 |
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) |
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) |
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
### 业务数据接口(需求 2.2,登录即可)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET / POST | `/api/v1/business/businesses` | 业务列表(编号/公司名模糊、状态/客户/归属/日期筛选)/ 创建(编号 `YW{日期}-{序号}` 自动生成;明细 ≥1 行;销售额服务层固化) |
| GET / PUT / DELETE | `/api/v1/business/businesses/{id}` | 详情(附明细)/ 编辑(明细全量替换 + 五项汇总重算;**归属不在此端点**)/ 删除(有发货或回款 → 400 引导改已终止) |
| PUT | `/api/v1/business/businesses/{id}/status` | 状态流转(1→2/3;终态不可再变更;终止必填原因;写状态留痕) |
| PUT | `/api/v1/business/businesses/{id}/ownership` | 负责/协助归属变更(同事务写留痕,owner_type=2) |
| GET | `/api/v1/business/businesses/{id}/ownership-logs` | 业务归属变更留痕分页 |
| GET | `/api/v1/business/businesses/{id}/status-logs` | 状态流转时间线(创建写首条 old=new=1) |
| GET | `/api/v1/business/businesses/{id}/gross-profit` | **毛利润数据视图**(只读实时计算,不落库:行级率/折算额 + 类别汇总;不含运费 #2;阈值按 `as_of` 取参数快照;成本缺失行标注无法计算) |
| GET / POST | `/api/v1/business/shipments` | 发货单列表 / 创建(编号 `SH…`;业务须执行中;行金额服务层固化;业务级发货/运费汇总回写) |
| GET / PUT / DELETE | `/api/v1/business/shipments/{id}` | 详情 / 编辑(终态业务禁改)/ 删除(被回款批次指定 → 400) |
| GET / POST | `/api/v1/business/receipts` | 回款列表(含 **payment_days 派生**:发货日首日=第 1 天 #1)/ 新增(编号 `HK…`;**shipment_id 同业务强校验**;业务须执行中) |
| GET / PUT / DELETE | `/api/v1/business/receipts/{id}` | 详情 / 编辑 / 删除(**已核算回款禁改禁删**——M3 防重锚点) |
| GET / POST | `/api/v1/business/rating-grades` | 评级等级全量列表(sort_order 升序)/ 新增(区间值域 + **与既有等级重叠 → 400**) |
| PUT / DELETE | `/api/v1/business/rating-grades/{id}` | 编辑(重叠判定)/ 删除(无下游引用,允许清空) |
全部写操作自动写入操作日志(成功与失败都记,机制见下)。 全部写操作自动写入操作日志(成功与失败都记,机制见下)。
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。 统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
@@ -79,6 +115,44 @@ app/
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」, **登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。 若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
### 报表统计接口(M4 ⑤,限管理员/财务/领导)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/reports/personal-detail` | 个人绩效工资明细表(FR-029:人员/月份/客户类型/产品类别/岗位筛选;响应带 `summary` 全筛选合计) |
| GET | `/api/v1/reports/department-summary` | 部门绩效工资汇总表(FR-030:月份必填;团队基数与月结聚合行同源;12.5% 与 5% 分列;含成员内联明细、未分配桶、月结冻结对照额) |
| GET | `/api/v1/reports/business-stats` | 业务统计表(FR-031:发货/回款/绩效为业务累计值,不随业务日期筛选变化) |
| GET | `/api/v1/reports/payment-stats` | 回款情况统计表(FR-031:8 档天数区间恒在,空桶为 0) |
| GET | `/api/v1/reports/payment-detail` | 回款区间下钻(`bucket` 必填,未注册编码 400) |
| GET | `/api/v1/reports/payment-day-buckets` | 区间档位元数据(前端下拉唯一来源,禁止前端硬编码) |
| GET | `/api/v1/reports/{code}/export` | Excel 导出(文件流非统一信封;含公司页眉/财务复核栏/领导签字栏;超 2 万行 400;行为入操作日志) |
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
### 工作台接口(M4 ⑧,限管理员/财务/领导)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/dashboard?month=YYYY-MM` | 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志) |
待办与预警**实时派生**(不落表、不引调度器);预警阈值走参数体系
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
### T+ 本地只读同步与管理
T+ 数据库是 SQL Server 只读源库,业务库仍为 MySQL。Docker 恢复 PoC、备份检查和 D 盘目录约定见 `docs/T+本地恢复与同步开发手册.md`。
- 连接配置在 `backend/.env` 的 `T_PLUS_*` 变量中,默认关闭,不配置时不影响主业务启动。
- `uv run python -m app.cli tplus-stage --entity personnel` 按主键分页,将源行写入 `tplus_sync_records` staging,并记录批次和失败信息。水位表已预留,时间增量未启用,`--watermark` 会明确拒绝。
- T+ 驱动通过 `uv sync --extra tplus` 安装到应用环境,ODBC 驱动名称必须显式配置;交付兼容目标为 SQL Server 2008 R2,2022 副本不能代替同版本验收。
- 查询目录 `db/queries/tplus/` 随部署交付,或使用 `T_PLUS_QUERY_DIR` 指定受控位置;支持六类主表和三类明细。
- T+ 字典中的候选字段在实际备份核验和客户确认前,不得直接覆盖岗位、工资标准、归属、业务状态或核算状态。
- 管理 API `/api/v1/system/tplus` 全部限管理员:概况、批次/异常分页、按需报告、创建与失败重试。HTTP 只创建 QUEUED 任务;`uv run python -m app.cli tplus-worker --watch --connection-file <项目外JSON路径>` 独立处理。
- 核心文件:`services/tplus/task_service.py`(幂等/重试)、`worker.py`(执行)、`relationship_audit.py`(字典与关联)、`query_service.py`(安全输出)、`core/tplus_lock.py`(跨提交执行锁)。原 CLI 读取与 worker 共用执行锁。
- V15 / Alembic `e5f607a8b9c0` 增加范围、跳过数、请求键、父批次、请求人和核验结果。未启用时间增量与工资业务导入;完整运行步骤和异常处理见本地恢复手册。
## 常用命令 ## 常用命令
```bash ```bash
@@ -154,6 +228,66 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。 **角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。
### M2 业务数据的四个关键约定(2026-10-13 定稿,改动前先读)
- **单据编号**(`services/no_generator.py`):`{前缀}{YYYYMMDD}-{当日4位序号}`,业务 YW / 发货 SH / 回款 HK(`core/constants.py` 的 `NO_PREFIX_*`)。`SELECT MAX ... FOR UPDATE` 锁当日区间防并发重号;残余竞态以唯一键 1062 冲突**重试一次**兜底(无限重试会掩盖死锁)。序号超 4 位按实际位数输出(VARCHAR(32) 余量足够)。
- **五项汇总冗余**(`businesses.total_*`):明细/发货/回款的任何写路径统一收口到 `business_service._recalc_totals` 单点重算,禁止各处手写 SUM 回写;发货与回款服务写操作前**必须 `get_by_id(for_update=True)` 锁业务行**(防两模块并发写同一业务的丢失更新)。毛利润率/折算额**不落库**——它们是明细金额 + 时点化阈值的派生值,落库会在成本调整或参数改版后漂移;视图接口(`/gross-profit`)实时算,M3 核算台账才是法定固化点。
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
### 审批快照与方案版本化(2026-10-20 定稿,改动前先读)
- **审批单带版本号与内容快照**:`approval_orders.version_no` 按 `(order_type, ref_id)` 从 1 递增,重提先撤回旧在途单、但**旧单快照不覆盖**;`snapshot_json` 统一信封 `{schema_version, order_type, captured_at, data}`,`data` 由 `approval_service.{allocation,settlement,adjustment}_content` 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。
- **两个 `version_no` 语义不同,别混用**:`business_secondary_allocations.version_no` = 方案内容第几版,**仅方案内容(参与人+比例)变化时 +1**;`approval_orders.version_no` = 同一对象第几次送审,**仅送审内容变化时 +1**。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。
- **送审内容未变不重提**:编辑业务时若**存在在途审批单**且其快照 `data` 与本次送审内容逐键相等(`approval_service.pending_matches_snapshot`,只比 `data` 不比 `captured_at`),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时**不新增方案版本行**(`business_service._plan_changed` 按「人员+比例」集合比较,与行序无关)。
- **快照的覆盖范围是「方案 + 钱 + 归属」**(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),**不含产品明细行**。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。
- **列表不加载快照列**:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 `defer(ApprovalOrder.snapshot_json)`(与操作日志列表排除 `request_data` 同一理由,参考 `operation_log_repository.list_logs`);详情走 `get_order` 按需读取。
- **详情内容来源三态**:`content_source=snapshot`(送审快照)/ `live`(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ `missing`(引用对象不存在且无快照)。**禁止回填历史单快照**——回填等于伪造提交时内容。
- **二次分配方案 append-only**:`business_secondary_allocations` 行不物理删除,编辑全量替换与驳回清空只把旧行 `is_active=0`;唯一键为 `(business_id, version_no, personnel_id)`,同一版本内参与人唯一、跨版本可重复。`business_repository.list_allocations(active_only=True)` 是核算与详情回显的唯一取数口径;业务详情 `allocation_history` 返回全部版本供回溯。
- **月结提交要锁行**:`settlement_service.submit` 取月结行必须 `for_update=True`——并发提交会让两次 `submit_order` 算出同一个审批单 `version_no`,送审次序失真。
- **审批单 `version_no` 有唯一键兜底(V12)**:`uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`——同一对象的第 N 次送审在库里有唯一一行。ORM 侧约束名必须与迁移 DDL 严格一致,否则 autogenerate 误判。
- **取版本号必须是「当前读」(`approval_service._next_version`)**,两个禁忌:(a) 不能用普通 `MAX` 查询——请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻,父行锁只串行化写入时刻、**不刷新快照**,MAX 会读到旧值;(b) 不能写成 `select(func.max(...)).with_for_update()`——MySQL 官方明确「Locking clauses are not permitted in queries with aggregate functions or GROUP BY」,聚合上的 FOR UPDATE 语义无保证。正解:锁读该 ref 的现有版本行(`select(version_no)...with_for_update()`),最大值 Python 侧算(同 `no_generator` 套路)。
- **版本号冲突的收敛口径**:`submit_order` 内不 catch、不 rollback(红线)。create 路径由外层编号重试循环整体重来(`business_service._NO_RETRY`);编辑/月结提交路径由审计上下文统一回滚,再把 1062 翻译为 400「提交冲突(版本号生成重复),请重试」(同 `param_service` 版本号冲突模板)。唯一键是最后一道防线,不是取号正确性的前提。
- **迁移前先修存量**:V11 把存量行 `version_no` 一律置 1,而 V11 前每次重提都新建行——同一 `(order_type, ref_id)` 存在多行 version_no=1,直接加唯一键必报 1062。V12 先重排「组内有重复」的分组(按 id 升序 1..n),其余分组不动(全量重排会改掉前端已展示过的历史版本号)。
- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。
### 工作台口径(M4 ⑧,改动前先读)
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定,**已终止业务(status=3)排除**;回款未指定批次 = `receipts.shipment_id IS NULL`。
- **待办按角色过滤**:`pending_statuses_for_roles`(approval_service)把当前用户角色映射到在途节点——财务只看待财务复核(status=1)、领导只看待领导审核(status=2)、admin 可看全部;多角色取并集。风险预警仍是全局经营视角,不按人裁剪。
- **预警展示上限**:`items` 最多 20 条,`total` 是全部命中数(前端徽标用它,不能用 `items.length`)。
- **趋势窗口**:回款趋势先按 `receipt_date` 范围过滤再按 `DATE_FORMAT` 分组,函数列不参与 WHERE,保证索引可用。
- **同步降级**:⑥ 未建时 `sync_available=false`,前端显示「模块未启用」——不要返回 0 条冒充「无失败」。
- **参数快照一次解析**:`warning.*` 三个阈值在每次请求只 `resolve_snapshot` 一次,多条规则复用。
- **导出 SQL 的编码坑**:Windows 下 `alembic upgrade --sql > V*.sql` 默认按控制台 GBK 输出,`test_schema_sync` 按 UTF-8 读取会报 `UnicodeDecodeError`——必须 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1` 前缀。
### 报表口径(M4 ⑤,改动前先读)
- **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。
- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入,**不要求该人当月有台账行**(只有调整单也要出零绩效成员行);`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。调整单归属跟随该人普通行的实时 `manager_id`;只有经理个人户的经理跟随本人团队。
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块)。
- **全公司 5% 的发放口径**:月结只为有台账 bucket 的营销经理各生成一条聚合行(12.5%+5%),**每位经理各计一次 5%**;零成员团队无聚合行、不发 5%。报表的 `aggregate_amount` 优先取冻结聚合行、未生成月结时才用 `manager_team_share` 一次舍入预览;`team_subtotal`/`net_total` = 聚合额 + 调整净额。`team_share_total` 与 `company_share_total` 仅分列展示,两者之和与聚合额的差记入 `aggregate_rounding_diff`(分列各自 q2 会有 0.01~0.02 尾差)。
- **对拍边界**:报表应发 = 月结 `total_payable` + 调整单净额(调整单不进月结 items);筛选 `manager_id` 时只汇总展示团队,隐藏金额进 `other_*`,不能与全公司月结总额对拍。
- **人员归属按行类型**:普通台账行跟随 `manager_id`;经理个人户行在 `manager.personal.included=1` 时归经理本人团队,且经理成员行**只取个人户金额**(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 `manager_id` 筛选时 `unassigned` 为空(语义是「仅看该经理团队」)。
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);第二块展示单经理 5%、计入次数与合计,对账块展示团队分成/全公司分成(分列)、聚合行舍入差、调整净额,应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
### 面向业务人员的文案(2026-10-23 定稿,改文案前先读)
系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:
- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。
- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。
- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。
- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。
## 常见问题 ## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。 **起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
@@ -163,3 +297,13 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
**`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。 **`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。
**改了代码不生效** —— 启动时没加 `--reload`。 **改了代码不生效** —— 启动时没加 `--reload`。
## 系统监控(2026-11-02)
新增管理员只读接口 `/api/v1/system/monitor/{overview,trends,interfaces,events,diagnostic}`,事件详情为 `/events/{id}`。后台独立线程每 15 秒采集,分钟聚合保留 7 天;配置见 `.env.example` 的 `MONITOR_*`,新增依赖 `psutil`。部署限单个后端应用进程,按项目流程应用 V23 迁移后启动。异常通过 `X-Request-ID` 与脱敏服务端日志关联,`/api/health` 原契约保持不变。
详细采集口径、故障边界、排查步骤和接口见 [系统监控运维说明](../docs/系统监控运维说明.md)。
## 角色授权(2026-11-02)
系统权限统一为“角色配置权限 → 用户分配角色 → 用户继承权限”。V26(`26e7f8a9b0c1`)停用旧个人覆盖和全局制作人入口;工资核算、生成、调整分别检查角色权限,月份实际经办人、生成锁和历史留痕继续保留。接口与迁移边界见 [角色授权使用说明](../docs/角色授权使用说明.md)。
@@ -0,0 +1,27 @@
"""V17 对齐 V16 已应用工作区约束的完整表名约定。"""
from alembic import op
revision: str = "07b8c9d0e1f2"
down_revision: str | None = "f6a708b9c0d1"
branch_labels = None
depends_on = None
RENAMES = (
("tplus_source_heads", "uk_tplus_heads_source_entity_id", "uk_tplus_source_heads_source_entity_id"),
("tplus_source_heads", "idx_tplus_heads_batch_entity", "idx_tplus_source_heads_batch_entity"),
("tplus_source_links", "uk_tplus_links_source_entity_id", "uk_tplus_source_links_source_entity_id"),
("tplus_source_links", "uk_tplus_links_entity_target", "uk_tplus_source_links_entity_target"),
)
def upgrade():
"""只重命名固定索引,不改变任何数据。"""
for table, old, new in RENAMES:
op.execute(f"ALTER TABLE {table} RENAME INDEX {old} TO {new}")
def downgrade():
"""恢复 V16 固定索引名。"""
for table, old, new in reversed(RENAMES):
op.execute(f"ALTER TABLE {table} RENAME INDEX {new} TO {old}")
@@ -0,0 +1,31 @@
"""正式工资输入、FIFO明细和月份生成锁。"""
from alembic import op
revision: str = "18c9d0e1f2a3"
down_revision: str | None = "07b8c9d0e1f2"
branch_labels = None
depends_on = None
def upgrade():
op.execute('CREATE INDEX idx_performance_ledgers_receipt_id ON performance_ledgers (receipt_id)')
op.execute("ALTER TABLE performance_ledgers ADD COLUMN allocation_key VARCHAR(64) NOT NULL DEFAULT '' COMMENT '正式FIFO事件键;历史单笔核算为空串'")
op.execute('ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version_personnel')
op.execute('ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id, allocation_key)')
op.execute("CREATE TABLE payroll_months (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n month VARCHAR(7) NOT NULL COMMENT '工资月份', \n maker_id BIGINT UNSIGNED NOT NULL COMMENT '实际工资制作人账号ID', \n maker_name VARCHAR(64) NOT NULL COMMENT '制作人用户名快照', \n generated_at DATETIME COMMENT '首次成功生成时间(不可解锁)', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_months PRIMARY KEY (id), \n CONSTRAINT uk_payroll_months_month UNIQUE (month)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='工资月份控制表(首次生成即锁定分配,审批不解锁)' COLLATE utf8mb4_general_ci")
op.execute("CREATE TABLE payroll_inputs (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', \n version INTEGER NOT NULL COMMENT '输入版本', \n source_database VARCHAR(128) NOT NULL COMMENT '源账套或人工来源', \n group_key VARCHAR(128) NOT NULL COMMENT '独立业务平账组', \n fingerprint VARCHAR(64) NOT NULL COMMENT '来源及事实指纹', \n data_json JSON NOT NULL COMMENT '经核实的源数据及人工事实快照', \n requested_by BIGINT UNSIGNED NOT NULL COMMENT '录入人账号ID', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_inputs PRIMARY KEY (id), \n CONSTRAINT uk_payroll_inputs_business_version UNIQUE (business_id, version)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式工资输入版本表' COLLATE utf8mb4_general_ci")
op.execute('CREATE INDEX idx_payroll_inputs_source_group ON payroll_inputs (source_database, group_key)')
op.execute("CREATE TABLE payroll_allocations (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n ledger_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式台账ID', \n business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', \n line_no INTEGER NOT NULL COMMENT '本台账稳定明细序号', \n input_id BIGINT UNSIGNED NOT NULL COMMENT '所用输入版本ID', \n amount NUMERIC(14, 2) NOT NULL COMMENT '分配回款金额', \n data_json JSON NOT NULL COMMENT '单据、成本、归属、参数和逐段计算依据', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_allocations PRIMARY KEY (id), \n CONSTRAINT uk_payroll_allocations_ledger_line UNIQUE (ledger_id, line_no)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式FIFO分配明细表' COLLATE utf8mb4_general_ci")
op.execute('CREATE INDEX idx_payroll_allocations_business_id ON payroll_allocations (business_id)')
def downgrade():
bind = op.get_bind()
if bind.exec_driver_sql("SELECT COUNT(*) FROM payroll_inputs").scalar() or bind.exec_driver_sql("SELECT COUNT(*) FROM payroll_allocations").scalar():
raise RuntimeError("已有正式核算输入或分配记录,拒绝破坏性回退;请先备份并使用应用回退方案")
op.execute('ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version_personnel')
op.execute("ALTER TABLE performance_ledgers DROP COLUMN allocation_key")
op.execute("ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id)")
op.execute("DROP INDEX idx_performance_ledgers_receipt_id ON performance_ledgers")
op.drop_table('payroll_allocations')
op.drop_table('payroll_inputs')
op.drop_table('payroll_months')
@@ -0,0 +1,18 @@
"""分配调整的原台账与业务分管归属。"""
from alembic import op
revision: str = "19d0e1f2a3b4"
down_revision: str | None = "18c9d0e1f2a3"
branch_labels = None
depends_on = None
def upgrade():
op.execute("ALTER TABLE adjustment_orders ADD COLUMN adjustment_type VARCHAR(32) NOT NULL DEFAULT 'GENERAL' COMMENT '调整类型:GENERAL普通 ALLOCATION分配差额'")
op.execute("ALTER TABLE adjustment_orders ADD COLUMN source_ledger_id BIGINT UNSIGNED NULL COMMENT '分配差额对应的原台账ID'")
op.execute("ALTER TABLE adjustment_orders ADD COLUMN attribution_json JSON NULL COMMENT '原台账业务与分管归属快照'")
def downgrade():
if op.get_bind().exec_driver_sql("SELECT COUNT(*) FROM adjustment_orders WHERE source_ledger_id IS NOT NULL").scalar():
raise RuntimeError("已有分配调整依据,拒绝删除归属信息;请使用应用版本回退")
op.drop_column("adjustment_orders", "attribution_json")
op.drop_column("adjustment_orders", "source_ledger_id")
op.drop_column("adjustment_orders", "adjustment_type")
@@ -0,0 +1,18 @@
"""自动采集周期、重试与心跳。"""
from alembic import op
revision: str = "20e1f2a3b4c5"
down_revision: str | None = "19d0e1f2a3b4"
branch_labels = None
depends_on = None
def upgrade():
op.execute("CREATE TABLE tplus_automation_states (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n source_key VARCHAR(64) NOT NULL COMMENT '源连接身份摘要(不含凭据)', \n source_database VARCHAR(128) NOT NULL COMMENT '源账套', \n scheduled_period VARCHAR(16) COMMENT '最近已排队ISO周', \n root_batch_no VARCHAR(32) COMMENT '本周期原始采集批次', \n current_batch_no VARCHAR(32) COMMENT '当前原始或重试批次', \n retry_attempts INTEGER NOT NULL COMMENT '本周期自动重试次数' DEFAULT '0', \n next_retry_at DATETIME COMMENT '下一次自动重试时间', \n last_success_at DATETIME COMMENT '最近成功采集时间', \n maintenance_at DATETIME COMMENT '最近周期检查时间', \n heartbeat_at DATETIME COMMENT '独立进程心跳时间', \n worker_owner VARCHAR(36) COMMENT '当前持锁进程身份', \n worker_phase VARCHAR(16) NOT NULL COMMENT '进程状态:IDLE/RUNNING/STOPPED' DEFAULT '''STOPPED''', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_tplus_automation_states PRIMARY KEY (id), \n CONSTRAINT uk_tplus_automation_states_source_key UNIQUE (source_key)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+自动采集周期与进程状态(不自动导入正式业务)' COLLATE utf8mb4_general_ci")
op.execute("ALTER TABLE tplus_sync_batches ADD COLUMN trigger_type VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '触发来源:MANUAL/SCHEDULED/AUTO_RETRY'")
op.execute("ALTER TABLE tplus_sync_batches ADD COLUMN automatic_attempt INTEGER NOT NULL DEFAULT 0 COMMENT '自动重试序号(0为原始任务)'")
def downgrade():
if op.get_bind().exec_driver_sql("SELECT COUNT(*) FROM tplus_automation_states").scalar():
raise RuntimeError("存在调度状态,请先停用并备份后采用应用回退,不删除运行依据")
op.drop_column("tplus_sync_batches", "automatic_attempt")
op.drop_column("tplus_sync_batches", "trigger_type")
op.drop_table("tplus_automation_states")
@@ -0,0 +1,17 @@
"""修正进程状态默认值的多余引号,保留已应用V20。"""
from alembic import op
revision: str = "21f2a3b4c5d6"
down_revision: str | None = "20e1f2a3b4c5"
branch_labels = None
depends_on = None
def upgrade():
op.execute("ALTER TABLE tplus_automation_states ALTER COLUMN worker_phase SET DEFAULT 'STOPPED'")
op.execute("UPDATE tplus_automation_states SET worker_phase='STOPPED' WHERE worker_phase='''STOPPED'''")
def downgrade():
op.execute("ALTER TABLE tplus_automation_states ALTER COLUMN worker_phase SET DEFAULT '''STOPPED'''")
@@ -0,0 +1,47 @@
"""V22 完整角色及个人授权;目录与模板固化于此版本。"""
from alembic import op
import sqlalchemy as sa
revision: str = "22a3b4c5d6e7"
down_revision: str | None = "21f2a3b4c5d6"
branch_labels = None
depends_on = None
DDL = ["CREATE TABLE authorization_settings (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '单例主键ID' AUTO_INCREMENT, \n\tmaker_user_id BIGINT UNSIGNED COMMENT '现任工资制作人账号ID', \n\trevision INTEGER NOT NULL COMMENT '全局授权版本' DEFAULT '1', \n\tupdated_by BIGINT UNSIGNED COMMENT '最近修改人ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_authorization_settings PRIMARY KEY (id), \n\tCONSTRAINT fk_authorization_settings_maker_user_id FOREIGN KEY(maker_user_id) REFERENCES users (id) ON DELETE RESTRICT, \n\tCONSTRAINT fk_authorization_settings_updated_by FOREIGN KEY(updated_by) REFERENCES users (id) ON DELETE SET NULL\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='授权设置表(单例与并发修改锁)' COLLATE utf8mb4_general_ci;", "CREATE TABLE user_permission_overrides (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tuser_id BIGINT UNSIGNED NOT NULL COMMENT '系统用户ID', \n\tpermission_id BIGINT UNSIGNED NOT NULL COMMENT '权限ID', \n\teffect VARCHAR(8) NOT NULL COMMENT '个人覆盖:ALLOW允许 DENY禁止', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_user_permission_overrides PRIMARY KEY (id), \n\tCONSTRAINT uk_user_permission_overrides_user_permission UNIQUE (user_id, permission_id), \n\tCONSTRAINT fk_user_permission_overrides_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE, \n\tCONSTRAINT fk_user_permission_overrides_permission_id FOREIGN KEY(permission_id) REFERENCES permissions (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='用户权限覆盖表(个人禁止优先)' COLLATE utf8mb4_general_ci;", 'CREATE INDEX idx_user_permission_overrides_permission_id ON user_permission_overrides (permission_id);']
PERMISSIONS = [('dashboard:view', '首页:查看', 1, None, 1), ('system:users:view', '用户管理:查看', 1, None, 2), ('system:users:create', '用户管理:新增', 2, 'system:users:view', 3), ('system:users:update', '用户管理:编辑', 2, 'system:users:view', 4), ('system:users:delete', '用户管理:删除', 2, 'system:users:view', 5), ('system:users:status', '用户管理:变更状态', 2, 'system:users:view', 6), ('system:users:password', '用户管理:重置密码', 2, 'system:users:view', 7), ('system:roles:view', '角色管理:查看', 1, None, 8), ('system:roles:create', '角色管理:新增', 2, 'system:roles:view', 9), ('system:roles:update', '角色管理:编辑', 2, 'system:roles:view', 10), ('system:roles:delete', '角色管理:删除', 2, 'system:roles:view', 11), ('system:roles:status', '角色管理:变更状态', 2, 'system:roles:view', 12), ('system:authorization:view', '权限与工资制作人:查看', 1, None, 13), ('system:authorization:update', '权限与工资制作人:编辑', 2, 'system:authorization:view', 14), ('system:business_roles:view', '岗位角色:查看', 1, None, 15), ('system:business_roles:create', '岗位角色:新增', 2, 'system:business_roles:view', 16), ('system:business_roles:update', '岗位角色:编辑', 2, 'system:business_roles:view', 17), ('system:business_roles:delete', '岗位角色:删除', 2, 'system:business_roles:view', 18), ('system:business_roles:status', '岗位角色:变更状态', 2, 'system:business_roles:view', 19), ('system:params:view', '计提参数:查看', 1, None, 20), ('system:params:update', '计提参数:编辑', 2, 'system:params:view', 21), ('system:params:withdraw', '计提参数:撤回参数', 2, 'system:params:view', 22), ('system:params:export', '计提参数:导出', 2, 'system:params:view', 23), ('system:params:preview', '计提参数:预览', 2, 'system:params:view', 24), ('system:settings:view', '公司信息:查看', 1, None, 25), ('system:settings:update', '公司信息:编辑', 2, 'system:settings:view', 26), ('system:logs:view', '日志查询:查看', 1, None, 27), ('base:personnel:view', '人员管理:查看', 1, None, 28), ('base:personnel:create', '人员管理:新增', 2, 'base:personnel:view', 29), ('base:personnel:update', '人员管理:编辑', 2, 'base:personnel:view', 30), ('base:personnel:delete', '人员管理:删除', 2, 'base:personnel:view', 31), ('base:personnel:status', '人员管理:变更状态', 2, 'base:personnel:view', 32), ('base:personnel:regions', '人员管理:维护区域', 2, 'base:personnel:view', 33), ('base:personnel:plans', '人员管理:维护计划', 2, 'base:personnel:view', 34), ('base:products:view', '产品管理:查看', 1, None, 35), ('base:products:create', '产品管理:新增', 2, 'base:products:view', 36), ('base:products:update', '产品管理:编辑', 2, 'base:products:view', 37), ('base:products:delete', '产品管理:删除', 2, 'base:products:view', 38), ('base:products:status', '产品管理:变更状态', 2, 'base:products:view', 39), ('base:customers:view', '客户管理:查看', 1, None, 40), ('base:customers:create', '客户管理:新增', 2, 'base:customers:view', 41), ('base:customers:update', '客户管理:编辑', 2, 'base:customers:view', 42), ('base:customers:delete', '客户管理:删除', 2, 'base:customers:view', 43), ('base:customers:rating', '客户管理:客户评级', 2, 'base:customers:view', 44), ('base:customers:ownership', '客户管理:变更归属', 2, 'base:customers:view', 45), ('business:businesses:view', '业务管理:查看', 1, None, 46), ('business:businesses:create', '业务管理:新增', 2, 'business:businesses:view', 47), ('business:businesses:update', '业务管理:编辑', 2, 'business:businesses:view', 48), ('business:businesses:delete', '业务管理:删除', 2, 'business:businesses:view', 49), ('business:businesses:status', '业务管理:变更状态', 2, 'business:businesses:view', 50), ('business:businesses:ownership', '业务管理:变更归属', 2, 'business:businesses:view', 51), ('business:shipments:view', '发货管理:查看', 1, None, 52), ('business:shipments:create', '发货管理:新增', 2, 'business:shipments:view', 53), ('business:shipments:update', '发货管理:编辑', 2, 'business:shipments:view', 54), ('business:shipments:delete', '发货管理:删除', 2, 'business:shipments:view', 55), ('business:receipts:view', '回款管理:查看', 1, None, 56), ('business:receipts:create', '回款管理:新增', 2, 'business:receipts:view', 57), ('business:receipts:update', '回款管理:编辑', 2, 'business:receipts:view', 58), ('business:receipts:delete', '回款管理:删除', 2, 'business:receipts:view', 59), ('business:ratings:view', '评级标准:查看', 1, None, 60), ('business:ratings:create', '评级标准:新增', 2, 'business:ratings:view', 61), ('business:ratings:update', '评级标准Line truncated
DEFAULTS = {'admin': ['adjustments:create', 'adjustments:view', 'approvals:finance', 'approvals:leader', 'approvals:view', 'base:customers:create', 'base:customers:delete', 'base:customers:ownership', 'base:customers:rating', 'base:customers:update', 'base:customers:view', 'base:personnel:create', 'base:personnel:delete', 'base:personnel:plans', 'base:personnel:regions', 'base:personnel:status', 'base:personnel:update', 'base:personnel:view', 'base:products:create', 'base:products:delete', 'base:products:status', 'base:products:update', 'base:products:view', 'business:businesses:create', 'business:businesses:delete', 'business:businesses:ownership', 'business:businesses:status', 'business:businesses:update', 'business:businesses:view', 'business:ratings:create', 'business:ratings:delete', 'business:ratings:update', 'business:ratings:view', 'business:receipts:create', 'business:receipts:delete', 'business:receipts:update', 'business:receipts:view', 'business:shipments:create', 'business:shipments:delete', 'business:shipments:update', 'business:shipments:view', 'dashboard:view', 'payroll:allocation', 'payroll:calculate', 'payroll:prepare', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:generate', 'settlements:submit', 'settlements:view', 'system:authorization:update', 'system:authorization:view', 'system:business_roles:create', 'system:business_roles:delete', 'system:business_roles:status', 'system:business_roles:update', 'system:business_roles:view', 'system:logs:view', 'system:params:export', 'system:params:preview', 'system:params:update', 'system:params:view', 'system:params:withdraw', 'system:roles:create', 'system:roles:delete', 'system:roles:status', 'system:roles:update', 'system:roles:view', 'system:settings:update', 'system:settings:view', 'system:users:create', 'system:users:delete', 'system:users:password', 'system:users:status', 'system:users:update', 'system:users:view', 'tplus:collect', 'tplus:export', 'tplus:facts', 'tplus:import', 'tplus:preview', 'tplus:retry', 'tplus:view'], 'user': ['base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view'], 'marketing_ops': ['base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view'], 'finance': ['adjustments:view', 'approvals:finance', 'approvals:view', 'base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view', 'dashboard:view', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:view', 'system:params:preview', 'system:params:view'], 'leader': ['adjustments:view', 'approvals:leader', 'approvals:view', 'base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view', 'dashboard:view', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:view', 'system:params:preview', 'system:params:view'], 'payroll_maker': ['adjustments:create', 'adjustments:view', 'approvals:view', 'base:customers:create', 'base:customers:delete', 'base:customers:ownership', 'base:customers:rating', 'base:customers:update', 'base:customers:view', 'base:personnel:create', 'base:personnel:delete', 'base:personnel:plans', 'base:personnel:regions', 'base:personnel:status', 'base:personnel:update', 'base:personnel:view', 'base:products:create', 'base:products:delete', 'base:products:status', 'base:products:update', 'base:products:view', 'business:businesses:create', 'business:businesses:delete', 'business:businesses:ownership', 'business:businesses:status', 'business:businesses:update', 'business:businesses:view', 'business:ratings:create', 'business:ratings:delete', 'business:ratings:update', 'business:ratings:view', 'business:receipts:create', 'business:receipts:delete', 'business:receipts:update', 'business:receipts:view', 'business:shipments:create', 'business:shipments:delete', 'business:shipments:update', 'business:shipments:view', 'payroll:allocation', 'payroll:calculate', 'payroll:prepare', 'payroll:view', 'reports:business:view', 'reports:department:view', 'reports:payments:view', 'reports:personal:view', 'settlements:generate', 'settlements:submit', 'settlements:view', 'tplus:cLine truncated
def upgrade():
bind=op.get_bind()
for sql in DDL:
op.execute(sql)
for code,name,kind,_parent,order in PERMISSIONS:
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES (:code,:name,:kind,:order,1)"),{"code": code,"name": name,"kind": kind,"order": order})
for code,_,_,parent,_ in PERMISSIONS:
if parent:
bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code=:parent SET p.parent_id=parent.id WHERE p.perm_code=:code"),{"parent": parent,"code": code})
bind.execute(sa.text("INSERT INTO roles (role_code,role_name,description,sort_order,status) VALUES ('payroll_maker','工资制作人','指定制作人还需在权限设置中绑定,角色本身不能绕过月份锁',5,1)"))
for role,codes in DEFAULTS.items():
for code in codes:
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code=:role"),{"code": code,"role": role})
from app.core.config import get_settings
maker=get_settings().payroll_maker_user_id
if maker is not None and not bind.execute(sa.text("SELECT id FROM users WHERE id=:id AND status=1"),{"id": maker}).scalar():
raise RuntimeError("旧工资制作人账号无效,请修正配置后重试迁移")
bind.execute(sa.text("INSERT INTO authorization_settings (id,maker_user_id,revision) VALUES (1,:maker,1)"),{"maker": maker})
if maker is not None:
bind.execute(sa.text("INSERT INTO user_roles (user_id,role_id) SELECT :maker,id FROM roles WHERE role_code='payroll_maker'"),{"maker": maker})
def downgrade():
bind=op.get_bind()
if bind.execute(sa.text("SELECT revision FROM authorization_settings WHERE id=1")).scalar()!=1 or bind.execute(sa.text("SELECT COUNT(*) FROM user_permission_overrides")).scalar():
raise RuntimeError("已发生授权修改,拒绝删除授权依据;请保留数据并回退应用版本")
op.drop_table('user_permission_overrides')
op.drop_table('authorization_settings')
codes=[row[0] for row in PERMISSIONS]
for code in codes:
bind.execute(sa.text("DELETE rp FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE p.perm_code=:code"),{"code": code})
bind.execute(sa.text("DELETE ur FROM user_roles ur JOIN roles r ON r.id=ur.role_id WHERE r.role_code='payroll_maker'"))
bind.execute(sa.text("DELETE FROM roles WHERE role_code='payroll_maker'"))
for code in reversed(codes):
bind.execute(sa.text("DELETE FROM permissions WHERE perm_code=:code"),{"code": code})
@@ -0,0 +1,39 @@
"""新增运行监控快照、接口聚合与诊断事件。"""
from alembic import op
from sqlalchemy import text
revision: str = "23b4c5d6e7f8"
down_revision: str | None = "22a3b4c5d6e7"
branch_labels = None
depends_on = None
DDL = [
"CREATE TABLE monitor_snapshots (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tminute DATETIME NOT NULL COMMENT '采集分钟,北京时间', \n\tsample_count INTEGER NOT NULL COMMENT '实际采样次数', \n\tresources JSON NOT NULL COMMENT '资源均值与采集范围,不含配置', \n\trequests JSON NOT NULL COMMENT '请求汇总与固定耗时直方图', \n\tCONSTRAINT pk_monitor_snapshots PRIMARY KEY (id), \n\tCONSTRAINT uk_monitor_snapshots_instance_minute UNIQUE (instance_id, minute)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='分钟级运行监控快照' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_monitor_snapshots_minute ON monitor_snapshots (minute)",
"CREATE TABLE monitor_request_minutes (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tminute DATETIME NOT NULL COMMENT '请求分钟,北京时间', \n\tmethod VARCHAR(8) NOT NULL COMMENT 'HTTP方法,固定集合', \n\troute VARCHAR(256) NOT NULL COMMENT '路由模板,不含原始地址及参数', \n\tmetrics JSON NOT NULL COMMENT '请求数、状态分类与固定直方图', \n\tCONSTRAINT pk_monitor_request_minutes PRIMARY KEY (id), \n\tCONSTRAINT uk_monitor_request_minutes_endpoint UNIQUE (instance_id, minute, method, route)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='接口分钟级运行统计' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_monitor_request_minutes_minute ON monitor_request_minutes (minute)",
"CREATE TABLE monitor_events (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tkind VARCHAR(24) NOT NULL COMMENT '事件类型,固定集合', \n\tcode VARCHAR(64) NOT NULL COMMENT '稳定错误码', \n\tcomponent VARCHAR(128) NOT NULL COMMENT '所属组件或日志模块', \n\tlevel VARCHAR(16) NOT NULL COMMENT '异常级别', \n\tmessage VARCHAR(256) NOT NULL COMMENT '通用说明,不保存内部异常文本', \n\tfingerprint VARCHAR(64) COMMENT '同类健康异常合并键', \n\trequest_id VARCHAR(32) COMMENT '请求关联编号', \n\tmethod VARCHAR(8) COMMENT 'HTTP方法', \n\troute VARCHAR(256) COMMENT '路由模板', \n\tstatus_code INTEGER COMMENT 'HTTP状态码', \n\tduration_ms FLOAT COMMENT '请求耗时,毫秒', \n\tbatch_no VARCHAR(32) COMMENT '相关同步批次编号', \n\tfirst_seen DATETIME NOT NULL COMMENT '首次发生时间', \n\tlast_seen DATETIME NOT NULL COMMENT '最近发生时间', \n\toccurrences INTEGER NOT NULL COMMENT '发生或持续采样次数', \n\tresolved_at DATETIME COMMENT '恢复时间,仅用于健康异常', \n\tCONSTRAINT pk_monitor_events PRIMARY KEY (id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='脱敏诊断事件与异常恢复记录' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_monitor_events_fingerprint ON monitor_events (fingerprint, resolved_at)",
"CREATE INDEX idx_monitor_events_last_seen ON monitor_events (last_seen)",
"CREATE INDEX idx_monitor_events_request_id ON monitor_events (request_id)",
]
def upgrade():
for statement in DDL:
op.execute(text(statement))
op.execute(text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:monitor:view','系统监控:查看',1,100,1)"))
op.execute(text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:monitor:view' WHERE r.role_code='admin'"))
def downgrade():
# 应用回滚可保留新增监控表;有历史数据时拒绝隐式删除。
bind = op.get_bind()
for table in ("monitor_events", "monitor_request_minutes", "monitor_snapshots"):
if bind.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar():
raise RuntimeError("存在监控历史,拒绝自动删除;请回退应用并保留新增表")
for table in ("monitor_events", "monitor_request_minutes", "monitor_snapshots"):
op.drop_table(table)
bind.execute(text("DELETE rp FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE p.perm_code='system:monitor:view'"))
bind.execute(text("DELETE FROM permissions WHERE perm_code='system:monitor:view'"))
@@ -0,0 +1,152 @@
"""V24 需求补齐:试用期台账、产品来源及分产品系数参数。"""
import sqlalchemy as sa
from alembic import op
revision = "24c5d6e7f8a9"
down_revision = "23b4c5d6e7f8"
branch_labels = None
depends_on = None
PROBATION_DDL = "CREATE TABLE probation_deferrals (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n ledger_id BIGINT UNSIGNED NOT NULL COMMENT '原核算台账版本ID', \n personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', \n year VARCHAR(4) NOT NULL COMMENT '原计提年度', \n month VARCHAR(7) NOT NULL COMMENT '本次计提或应付变化月份', \n accrued NUMERIC(14, 2) NOT NULL COMMENT '本次试用期计提额,可负', \n eligible NUMERIC(14, 2) NOT NULL COMMENT '满足起付及权重的本次应付差额,可负', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n CONSTRAINT pk_probation_deferrals PRIMARY KEY (id), \n CONSTRAINT uk_probation_deferrals_ledger_id UNIQUE (ledger_id), \n CONSTRAINT fk_probation_deferrals_ledger_id FOREIGN KEY(ledger_id) REFERENCES performance_ledgers (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期缓发台账(每个正式台账版本一条)' COLLATE utf8mb4_general_ci;\n\nCREATE INDEX idx_probation_deferrals_personnel_id_year ON probation_deferrals (personnel_id, year);\n\nCREATE TABLE probation_payments (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n settlement_id BIGINT UNSIGNED COMMENT '月结ID(当月调整单结清时为空)', \n adjustment_order_id BIGINT UNSIGNED COMMENT '已生成工资后的结清调整单ID', \n personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', \n year VARCHAR(4) NOT NULL COMMENT '原累计年度', \n month VARCHAR(7) NOT NULL COMMENT '本次实际支付月份', \n amount NUMERIC(14, 2) NOT NULL COMMENT '本次补发或扣回金额', \n snapshot_json JSON NOT NULL COMMENT '计提、起付权重、门槛及历史补发依据', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n CONSTRAINT pk_probation_payments PRIMARY KEY (id), \n CONSTRAINT uk_probation_payments_settlement_person_year UNIQUE (settlement_id, personnel_id, year), \n CONSTRAINT uk_probation_payments_adjustment_order_id UNIQUE (adjustment_order_id), \n CONSTRAINT fk_probation_payments_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE, \n CONSTRAINT fk_probation_payments_adjustment_order_id FOREIGN KEY(adjustment_order_id) REFERENCES adjustment_orders (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期年终及后续结清记录(工资生成事务内固化)' COLLATE utf8mb4_general_ci;\n\nCREATE INDEX idx_probation_payments_personnel_id_year ON probation_payments (personnel_id, year);"
def upgrade():
op.add_column(
"personnel",
sa.Column(
"probation_start_date", sa.Date(), nullable=True, comment="试用期开始日(含当日)"
),
)
op.add_column(
"personnel",
sa.Column(
"regularization_date",
sa.Date(),
nullable=True,
comment="转正生效日(当日不再属于试用期)",
),
)
op.add_column(
"personnel",
sa.Column(
"resignation_date", sa.Date(), nullable=True, comment="离职生效日(当日起不计基本工资)"
),
)
op.add_column(
"personnel",
sa.Column(
"salary_history_json",
sa.JSON(),
nullable=True,
comment="工资标准、岗位及任职资料生效版本",
),
)
op.add_column(
"products",
sa.Column(
"source_trade_json",
sa.JSON(),
nullable=True,
comment="最近有效发货数量、含税成交价及源单据快照",
),
)
op.add_column(
"products",
sa.Column(
"quantity_price_manual",
sa.SmallInteger(),
server_default="0",
nullable=False,
comment="数量单价人工保护:1人工维护 0随源更新",
),
)
for name, title in (
("normal_payable_sum", "当月正常实付绩效"),
("deferred_sum", "当月试用期缓发变化"),
("probation_payment_sum", "本月年度补发或扣回"),
):
op.add_column(
"settlement_items",
sa.Column(name, sa.Numeric(14, 2), server_default="0", nullable=False, comment=title),
)
for statement in PROBATION_DDL.split(";"):
if statement.strip():
op.execute(sa.text(statement))
# 仅建立新参数定义,不写任何历史生效值;未覆盖项继续回溯原通用参数。
fields = (
("first", "个人户首日比例", "DECIMAL", "‰"),
("start", "个人户线性阶段起始比例", "DECIMAL", "‰"),
("slope", "个人户线性阶段日斜率", "DECIMAL", "‰/天"),
("floor", "个人户超期比例", "DECIMAL", "‰"),
("boundary", "个人户线性阶段截止天数", "INT", "天"),
)
definitions = sa.table(
"param_definitions",
*[
sa.column(name, sa.String())
for name in (
"param_code",
"param_name",
"param_group",
"value_type",
"unit",
"dimension_type",
"value_min",
"value_max",
"description",
)
],
sa.column("sort_order", sa.Integer()),
sa.column("status", sa.Integer()),
)
op.bulk_insert(
definitions,
[
{
"param_code": f"coefficient.personal.{field}",
"param_name": title,
"param_group": "产品系数表",
"value_type": kind,
"unit": unit,
"dimension_type": "PRODUCT_CATEGORY",
"value_min": "1" if kind == "INT" else "0",
"value_max": "3660" if kind == "INT" else None,
"description": "按产品类别维护;未配置时沿用同日期的通用业务员参数。",
"sort_order": 52 + index,
"status": 1,
}
for index, (field, title, kind, unit) in enumerate(fields)
],
)
def downgrade():
bind = op.get_bind()
for table, condition in (("personnel", "salary_history_json IS NOT NULL"), ("products", "source_trade_json IS NOT NULL OR quantity_price_manual<>0")):
if bind.execute(sa.text(f"SELECT COUNT(*) FROM {table} WHERE {condition}")).scalar():
raise RuntimeError("存在人员或产品新增资料,请回退应用并保留业务结构")
for table in ("probation_payments", "probation_deferrals"):
if bind.execute(sa.text(f"SELECT COUNT(*) FROM {table}")).scalar():
raise RuntimeError("存在试用期结算记录,请回退应用并保留业务数据")
if bind.execute(
sa.text(
"SELECT COUNT(*) FROM param_values v JOIN param_definitions d ON d.id=v.param_id WHERE d.param_code LIKE 'coefficient.personal.%'"
)
).scalar():
raise RuntimeError("存在已维护的产品系数,请保留新结构")
bind.execute(
sa.text("DELETE FROM param_definitions WHERE param_code LIKE 'coefficient.personal.%'")
)
op.drop_table("probation_payments")
op.drop_table("probation_deferrals")
for name in ("normal_payable_sum", "deferred_sum", "probation_payment_sum"):
op.drop_column("settlement_items", name)
op.drop_column("products", "quantity_price_manual")
op.drop_column("products", "source_trade_json")
op.drop_column("personnel", "regularization_date")
op.drop_column("personnel", "salary_history_json")
op.drop_column("personnel", "resignation_date")
op.drop_column("personnel", "probation_start_date")
@@ -0,0 +1,24 @@
"""V25 将新增系统监控接入统一功能权限,不修改已应用的 V22。"""
from alembic import op
import sqlalchemy as sa
revision: str = "25d6e7f8a9b0"
down_revision: str | None = "24c5d6e7f8a9"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
for code, name, kind in (
("system:monitor:view", "系统监控:查看", 1),
("system:monitor:export", "系统监控:导出", 2),
):
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT :code,:name,:kind,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code=:code)"), {"code": code,"name": name,"kind": kind})
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code='admin' AND NOT EXISTS (SELECT 1 FROM role_permissions old WHERE old.role_id=r.id AND old.permission_id=p.id)"), {"code": code})
bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:monitor:view' SET p.parent_id=parent.id WHERE p.perm_code='system:monitor:export'"))
def downgrade():
# V23 也可能已登记目录;回退此兼容版本不删除任何既有授权。
return
@@ -0,0 +1,31 @@
"""授权归一到角色,停用全局制作人入口,保留旧结构与历史工资依据。"""
from alembic import op
from sqlalchemy import text
revision: str = "26e7f8a9b0c1"
down_revision: str | None = "25d6e7f8a9b0"
branch_labels = None
depends_on = None
STATEMENTS = (
"UPDATE authorization_settings SET maker_user_id=NULL, revision=revision+1 WHERE id=1",
"UPDATE permissions SET status=0 WHERE perm_code IN ('system:authorization:view','system:authorization:update')",
"UPDATE roles SET role_name='工资经办', description='按角色授权维护核算资料、计算工资及生成工资;月份锁定和实际经办人留痕继续生效' WHERE role_code='payroll_maker' AND role_name='工资制作人'",
"UPDATE roles SET description='管理业务基础资料,具体功能由角色权限配置决定' WHERE role_code='marketing_ops' AND description LIKE '%审批链%'",
)
def upgrade():
bind = op.get_bind()
if bind.execute(text("SELECT COUNT(*) FROM user_permission_overrides")).scalar():
raise RuntimeError("存在个人权限覆盖,请先整理为等效角色后再升级,避免隐式改变用户权限")
for statement in STATEMENTS:
op.execute(text(statement))
def downgrade():
# 只恢复旧入口,不猜测重新绑定哪个账号。旧版本需由管理员重新指定。
op.execute(text("UPDATE permissions SET status=1 WHERE perm_code IN ('system:authorization:view','system:authorization:update')"))
op.execute(text("UPDATE roles SET role_name='工资制作人' WHERE role_code='payroll_maker' AND role_name='工资经办'"))
op.execute(text("UPDATE authorization_settings SET revision=revision+1 WHERE id=1"))
@@ -0,0 +1,21 @@
"""增加源级自动采集开关,不修改活动采集表。"""
from pathlib import Path
from alembic import op
from sqlalchemy import inspect, text
revision = "27f8a9b0c1d2"
down_revision = "26e7f8a9b0c1"
branch_labels = None
depends_on = None
def upgrade():
if not inspect(op.get_bind()).has_table("tplus_automation_configs"):
sql = Path(__file__).resolve().parents[3] / "db/migration/V27__tplus_automation_config.sql"
op.get_bind().execute(text(sql.read_text(encoding="utf-8")))
def downgrade():
op.drop_table("tplus_automation_configs")
@@ -0,0 +1,31 @@
"""V28 计提公式模板定义:模板入库,页面可维护;公式骨架仍受控。"""
from pathlib import Path
from alembic import op
from sqlalchemy import inspect, text
revision = "28a9b0c1d2e3"
down_revision = "27f8a9b0c1d2"
branch_labels = None
depends_on = None
def upgrade():
if not inspect(op.get_bind()).has_table("calc_template_definitions"):
sql = Path(__file__).resolve().parents[3] / "db/migration/V28__calc_template_definitions.sql"
raw = sql.read_text(encoding="utf-8")
# MySQL 不支持 multi-statement;按分号+换行拆分成逐条执行。
# JSON_OBJECT 内不含分号+换行,可安全拆分。
for statement in raw.split(";\n"):
stripped = "\n".join(
line for line in statement.splitlines() if not line.lstrip().startswith("--")
).strip()
if stripped:
op.get_bind().execute(text(stripped))
def downgrade():
# 模板可能被岗位引用,外键不存在但编码被业务表保存;回退仅删定义表,
# 已绑定岗位的编码会回退到代码注册表解析,不猜新数据归属。
op.drop_table("calc_template_definitions")
@@ -0,0 +1,47 @@
"""V29 回款独立人工确认及默认开启开关;不自动确认历史回款。"""
from pathlib import Path
from alembic import op
from sqlalchemy import inspect, text
revision = "29b0c1d2e3f4"
down_revision = "28a9b0c1d2e3"
branch_labels = None
depends_on = None
def upgrade():
sql = Path(__file__).resolve().parents[3] / "db/migration/V29__receipt_manual_confirmation.sql"
columns = {column["name"]: column for column in inspect(op.get_bind()).get_columns("receipts")}
existing = {name for name in ("confirmation_status", "confirmed_by", "confirmed_at", "confirmed_revision") if name in columns}
if existing and len(existing) != 4:
raise RuntimeError("回款确认字段不完整,请先核对数据库结构")
if existing:
expected = {"confirmation_status": ("TINYINT", None, False), "confirmed_by": ("VARCHAR", 64, True),
"confirmed_at": ("DATETIME", None, True), "confirmed_revision": ("VARCHAR", 64, True)}
for name, (kind, length, nullable) in expected.items():
column = columns[name]
actual_type = column["type"]
if (actual_type.__visit_name__.upper() != kind or getattr(actual_type, "length", None) != length
or column["nullable"] != nullable):
raise RuntimeError(f"回款确认字段 {name} 与迁移定义不一致,请先核对数据库结构")
if str(columns["confirmation_status"]["default"]).strip("'\"") != "0":
raise RuntimeError("回款确认状态默认值不是待确认,请先核对数据库结构")
for statement in sql.read_text(encoding="utf-8").split(";\n"):
clean = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip()
if clean and not (existing and clean.startswith("ALTER TABLE receipts ADD COLUMN")):
op.get_bind().execute(text(clean))
def downgrade():
bind = op.get_bind()
if bind.scalar(text("SELECT COUNT(*) FROM receipts WHERE confirmed_at IS NOT NULL OR confirmation_status<>0")):
raise RuntimeError("已有回款人工确认记录,禁止删除确认依据;请仅回退应用并保留结构")
if bind.scalar(text("SELECT COUNT(*) FROM param_values v JOIN param_definitions d ON d.id=v.param_id WHERE d.param_code='receipt.confirmation.enabled'")):
raise RuntimeError("已维护人工确认开关版本,禁止删除历史参数")
bind.execute(text("DELETE FROM param_definitions WHERE param_code='receipt.confirmation.enabled'"))
bind.execute(text("UPDATE permissions SET status=0 WHERE perm_code='business:receipts:confirm'"))
for column in ("confirmed_revision", "confirmed_at", "confirmed_by", "confirmation_status"):
op.drop_column("receipts", column)
@@ -0,0 +1,40 @@
"""V30 业务接入与核算准备解耦;无默认回填。"""
from pathlib import Path
from alembic import op
from sqlalchemy import text
revision = "30c1d2e3f4a5"
down_revision = "29b0c1d2e3f4"
branch_labels = None
depends_on = None
def upgrade():
script = Path(__file__).resolve().parents[3] / "db/migration/V30__tplus_business_sync.sql"
for statement in script.read_text(encoding="utf-8").split(";\n"):
sql = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip()
if sql:
op.get_bind().execute(text(sql))
def downgrade():
connection = op.get_bind()
for table, condition in (("tplus_business_documents", "1=1"), ("products", "product_category IS NULL"),
("customers", "customer_type IS NULL"), ("business_items", "product_category IS NULL"),
("shipments", "freight_amount IS NULL")):
if connection.scalar(text(f"SELECT COUNT(*) FROM {table} WHERE {condition}")):
raise RuntimeError("已有接入或待补资料,请关闭接入并回退兼容版本;不删除业务数据以降级结构")
op.drop_table("tplus_business_documents")
op.drop_table("tplus_business_sync_configs")
for index in ("idx_tplus_source_heads_parent", "idx_tplus_source_heads_customer_date", "idx_tplus_source_heads_date"):
op.drop_index(index, "tplus_source_heads")
for column in ("source_parent_id", "source_customer_id", "source_date"):
op.drop_column("tplus_source_heads", column)
op.drop_column("businesses", "integration_json")
for table, column, sql_type, default in (("products", "product_category", "VARCHAR(32)", ""),
("customers", "customer_type", "VARCHAR(32)", " DEFAULT 'personal'"),
("business_items", "product_category", "VARCHAR(32)", ""),
("shipments", "freight_amount", "DECIMAL(14,2)", " DEFAULT 0")):
connection.execute(text(f"ALTER TABLE {table} MODIFY {column} {sql_type} NOT NULL{default}"))
@@ -0,0 +1,14 @@
"""合并业务接入和回款确认迁移分支。"""
revision = "31d2e3f4a5b6"
down_revision = ("29b0c1d2e3f4", "30c1d2e3f4a5")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
@@ -0,0 +1,22 @@
"""增加默认停止的 T+ 读取服务控制。"""
from pathlib import Path
from alembic import op
from sqlalchemy import text
revision = "32e3f4a5b6c7"
down_revision = "31d2e3f4a5b6"
branch_labels = None
depends_on = None
def upgrade():
sql = Path(__file__).resolve().parents[3] / "db/migration/V32__tplus_runtime_control.sql"
for statement in sql.read_text(encoding="utf-8").split(";"):
if statement.strip():
op.get_bind().execute(text(statement))
def downgrade():
raise RuntimeError("运行控制包含用户设置,请停止服务并备份后采用应用回退")
@@ -0,0 +1,22 @@
"""将 T+ 运行控制表名统一为复数。"""
from pathlib import Path
from alembic import op
from sqlalchemy import text
revision = "33e4f5a6b7c8"
down_revision = "32e3f4a5b6c7"
branch_labels = None
depends_on = None
def upgrade():
sql = Path(__file__).resolve().parents[3] / "db/migration/V33__pluralize_tplus_runtime_control.sql"
for statement in sql.read_text(encoding="utf-8").split(";"):
if statement.strip():
op.get_bind().execute(text(statement))
def downgrade():
raise RuntimeError("运行控制表名迁移不可直接回退,请停止服务并按兼容版本处理")
@@ -0,0 +1,632 @@
"""V7 核算台账/月结/调整单四表与调整单开关种子
Revision ID: 3bca400421c6
Revises: 79bcc5aa98d4
Create Date: 2026-10-16 15:54:53.000890
"""
from collections.abc import Sequence
from datetime import date
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "3bca400421c6"
down_revision: str | Sequence[str] | None = "79bcc5aa98d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"monthly_settlements",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="结算月份(YYYY-MM;全表唯一)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
),
sa.Column(
"total_performance",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="绩效金额合计(可负)",
),
sa.Column(
"total_payable",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="应付绩效合计(可负)",
),
sa.Column(
"person_count", mysql.TINYINT(), server_default="0", nullable=False, comment="结算人数"
),
sa.Column(
"snapshot_json",
sa.JSON(),
nullable=True,
comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)",
),
sa.Column(
"approval_order_no",
sa.String(length=32),
nullable=True,
comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)",
),
sa.Column(
"submitter_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="提交人ID(账号删除后置空)",
),
sa.Column(
"submitter_name",
sa.String(length=64),
nullable=False,
comment="提交人用户名(冗余固化)",
),
sa.Column("locked_at", sa.DateTime(), nullable=True, comment="锁定时间(status=3 时回填)"),
sa.Column(
"locked_by", sa.String(length=64), nullable=True, comment="锁定操作人(冗余固化)"
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["submitter_id"],
["users.id"],
name=op.f("fk_monthly_settlements_submitter_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_monthly_settlements")),
sa.UniqueConstraint(
"settlement_month", name=op.f("uk_monthly_settlements_settlement_month")
),
comment="月度结算表(按月一行;通过即锁定,锁定后走调整单)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"adjustment_orders",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"order_no",
sa.String(length=32),
nullable=False,
comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="调整人员ID(personnel.id)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="调整人员姓名(冗余固化)",
),
sa.Column("direction", mysql.TINYINT(), nullable=False, comment="方向:1补发 2扣减"),
sa.Column(
"amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="调整金额(恒正,方向表达语义)",
),
sa.Column(
"reason",
sa.String(length=255),
nullable=False,
comment="扣补说明(必填,审批与追溯锚点)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
),
sa.Column(
"submitter_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="提交人ID(账号删除后置空)",
),
sa.Column(
"submitter_name",
sa.String(length=64),
nullable=False,
comment="提交人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_adjustment_orders_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["submitter_id"],
["users.id"],
name=op.f("fk_adjustment_orders_submitter_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_adjustment_orders")),
sa.UniqueConstraint("order_no", name=op.f("uk_adjustment_orders_order_no")),
comment="调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"settlement_items",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"settlement_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="月结ID(monthly_settlements.id;头删明细随删)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="结算人员ID(personnel.id)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="结算人员姓名(冗余固化)",
),
sa.Column(
"role_code", sa.String(length=32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
),
sa.Column(
"calc_type",
mysql.TINYINT(),
nullable=False,
comment="核算类型:1单笔回款 2经理团队聚合",
),
sa.Column(
"performance_sum",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
),
sa.Column(
"payable_sum",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="应付绩效合计(可负)",
),
sa.Column(
"basic_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="月工资标准(personnel.salary_standard 当月快照)",
),
sa.Column(
"salary_mode",
sa.String(length=16),
nullable=True,
comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE",
),
sa.Column(
"net_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="净工资 = 绩效±基本工资(按口径;试用期 0)",
),
sa.Column(
"is_probation",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
),
sa.Column(
"probation_note",
sa.String(length=255),
nullable=True,
comment="试用期说明(年累计超阈值部分年底一次性结算)",
),
sa.Column(
"snapshot_json", sa.JSON(), nullable=True, comment="个人明细快照(台账行引用列表)"
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_settlement_items_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["settlement_id"],
["monthly_settlements.id"],
name=op.f("fk_settlement_items_settlement_id"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_settlement_items")),
comment="月结明细表(按人固化;支持负数工资;锁定后只读)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"performance_ledgers",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"receipt_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="回款ID(receipts.id;聚合行可为 NULL)",
),
sa.Column(
"calc_version",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="核算版本(重算+1,旧行 is_active=0)",
),
sa.Column(
"is_active",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
),
sa.Column(
"calc_type",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
),
sa.Column(
"business_no",
sa.String(length=32),
nullable=False,
comment="业务编号(冗余固化,列表免联查)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="归属营销人员ID(personnel.id;核算归属锚点)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="归属营销人员姓名(冗余固化)",
),
sa.Column(
"role_code",
sa.String(length=32),
nullable=False,
comment="岗位编码(business_roles.role_code,计提公式锚点)",
),
sa.Column(
"customer_type",
sa.String(length=32),
nullable=False,
comment="客户分类:personal/manager_personal/enterprise",
),
sa.Column(
"product_category",
sa.String(length=32),
nullable=False,
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
),
sa.Column("ship_date", sa.Date(), nullable=True, comment="发货日期(回款天数基准)"),
sa.Column(
"receipt_date",
sa.Date(),
nullable=False,
comment="回款日期(参数版本匹配日,裁决 #11)",
),
sa.Column(
"payment_days",
sa.Integer(),
nullable=True,
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)",
),
sa.Column(
"rate_text",
sa.String(length=32),
nullable=False,
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
),
sa.Column(
"converted_profit",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="折算毛利润额(可负;混合业务为各行汇总)",
),
sa.Column(
"performance_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="绩效金额(可负,裁决:负绩效照算不归零)",
),
sa.Column(
"barter_applied",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="以物抵顶折算:1已按 80% 折算 0否",
),
sa.Column(
"weight_r",
sa.Integer(),
nullable=True,
comment="支付权重 R(回款占销售额百分比向下取整)",
),
sa.Column(
"payment_weight",
sa.Numeric(precision=7, scale=6),
nullable=True,
comment="支付权重系数 R×R(展示用)",
),
sa.Column(
"payable_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)",
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)",
),
sa.Column(
"is_probation",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="试用期:1照算不发放 0否",
),
sa.Column(
"missing_cost",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
),
sa.Column(
"snapshot_json",
sa.JSON(),
nullable=True,
comment="计算快照(所用参数版本与行级折算明细,重算可回溯)",
),
sa.Column(
"settlement_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="所属月结ID(月结生成时回填;NULL=未入结)",
),
sa.Column(
"calc_reason",
sa.String(length=255),
nullable=True,
comment="核算/重算原因(重算必填留痕)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_performance_ledgers_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["receipt_id"],
["receipts.id"],
name=op.f("fk_performance_ledgers_receipt_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["settlement_id"],
["monthly_settlements.id"],
name=op.f("fk_performance_ledgers_settlement_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_performance_ledgers")),
sa.UniqueConstraint(
"receipt_id", "calc_version", name="uk_performance_ledgers_receipt_id_calc_version"
),
comment="绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_performance_ledgers_business_id", "performance_ledgers", ["business_id"], unique=False
)
op.create_index(
"idx_performance_ledgers_month_active",
"performance_ledgers",
["settlement_month", "is_active"],
unique=False,
)
op.create_index(
"idx_performance_ledgers_month_personnel_type",
"performance_ledgers",
["settlement_month", "personnel_id", "calc_type"],
unique=False,
)
op.create_index(
"idx_performance_ledgers_personnel_id",
"performance_ledgers",
["personnel_id"],
unique=False,
)
op.alter_column(
"approval_orders",
"order_type",
existing_type=mysql.TINYINT(),
comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
existing_comment="审批对象类型:1二次分配方案 2月度结算(预留)",
existing_nullable=False,
)
# ### end Alembic commands ###
# ---- 种子:调整单审批开关(V6 写法先例;definitions id 19 / values id 22)----
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 19,
"param_code": "approval.adjustment.enabled",
"param_name": "调整单审批开关",
"param_group": "审批开关",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
"调整单是已锁定月份补发/扣减的唯一通道"
),
"sort_order": 19,
"status": 1,
}
],
)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 22,
"param_id": 19,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
}
],
)
def downgrade() -> None:
"""回滚迁移。
整表回滚只保留 `op.drop_table`(V1/V3/V6 先例:外键唯一支撑索引单独
drop 会报 MySQL 1553)。行级种子(param_definitions 19 / param_values 22)
的宿主表由 V3 建、本迁移只插行,须显式 DELETE 否则重放 upgrade 撞唯一键。
删除顺序:先删引用方(ledger/items),再删被引用方(头表/调整单)。
"""
op.drop_table("performance_ledgers")
op.drop_table("settlement_items")
op.drop_table("adjustment_orders")
op.drop_table("monthly_settlements")
op.alter_column(
"approval_orders",
"order_type",
existing_type=mysql.TINYINT(),
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
existing_comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
existing_nullable=False,
)
op.execute("DELETE FROM param_values WHERE id = 22")
op.execute("DELETE FROM param_definitions WHERE id = 19")
@@ -0,0 +1,441 @@
"""V6 系统设置/二次分配/审批表与角色参数种子
Revision ID: 79bcc5aa98d4
Revises: 8efc189b131f
Create Date: 2026-10-15 15:42:13.467447
"""
from collections.abc import Sequence
from datetime import date
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "79bcc5aa98d4"
down_revision: str | Sequence[str] | None = "8efc189b131f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"system_settings",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"company_name",
sa.String(length=64),
nullable=False,
comment="公司名称(登录页与导出页眉展示)",
),
sa.Column(
"logo_path",
sa.String(length=255),
nullable=True,
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_system_settings")),
comment="系统设置表(公司信息,全系统单行)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"approval_orders",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"order_no",
sa.String(length=32),
nullable=False,
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
),
sa.Column(
"order_type",
mysql.TINYINT(),
nullable=False,
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
),
sa.Column(
"ref_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
),
sa.Column(
"refer_no",
sa.String(length=32),
nullable=False,
comment="引用对象单号(冗余固化,列表页免联查)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
),
sa.Column(
"submitter_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="提交人ID(账号删除后置空)",
),
sa.Column(
"submitter_name",
sa.String(length=64),
nullable=False,
comment="提交人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["submitter_id"],
["users.id"],
name=op.f("fk_approval_orders_submitter_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_orders")),
sa.UniqueConstraint("order_no", name=op.f("uk_approval_orders_order_no")),
comment="审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_approval_orders_type_status", "approval_orders", ["order_type", "status"], unique=False
)
op.create_table(
"approval_steps",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"order_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="审批单ID(approval_orders.id;单删记录随删)",
),
sa.Column(
"node",
mysql.TINYINT(),
nullable=False,
comment="审批节点:0撤回留痕 1财务复核 2领导审核",
),
sa.Column(
"action", mysql.TINYINT(), nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
),
sa.Column(
"opinion", sa.String(length=255), nullable=True, comment="审批意见(驳回时填写)"
),
sa.Column(
"operator_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(自动通过/系统动作置空)",
),
sa.Column(
"operator_name",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余固化;系统动作记「系统」)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["operator_id"],
["users.id"],
name=op.f("fk_approval_steps_operator_id"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["order_id"],
["approval_orders.id"],
name=op.f("fk_approval_steps_order_id"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_steps")),
comment="审批节点记录表(append-only;自动通过/撤回均有留痕)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_approval_steps_order_id", "approval_steps", ["order_id"], unique=False)
op.create_table(
"business_secondary_allocations",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id;业务删除随删)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="参与业务员ID(personnel.id;被方案引用禁删)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="参与业务员姓名(冗余固化,离职后回显有名)",
),
sa.Column(
"ratio",
sa.Numeric(precision=5, scale=2),
nullable=False,
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_business_secondary_allocations_business_id"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_business_secondary_allocations_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_secondary_allocations")),
sa.UniqueConstraint(
"business_id",
"personnel_id",
name="uk_business_secondary_allocations_business_personnel",
),
comment="业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_business_secondary_allocations_business_id",
"business_secondary_allocations",
["business_id"],
unique=False,
)
op.create_index(
"idx_business_secondary_allocations_personnel_id",
"business_secondary_allocations",
["personnel_id"],
unique=False,
)
# ### end Alembic commands ###
# ---- 种子数据(生产与开发同源;写法沿用 V1/V3 迁移先例)----
# 三个审批职能登录角色(2026-10-15 复核审批补充需求;roles 表无 is_builtin
# 列,内置保护由 role_service 的 BUILTIN_ROLE_CODES 白名单承担)
op.bulk_insert(
sa.table(
"roles",
sa.column("role_code", sa.String),
sa.column("role_name", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
),
[
{
"role_code": "marketing_ops",
"role_name": "营销后勤",
"description": "提交与录入(审批链第一节点:月结/二次分配提交)",
"sort_order": 3,
},
{
"role_code": "finance",
"role_name": "财务",
"description": "复核(审批链第二节点)",
"sort_order": 4,
},
{
"role_code": "leader",
"role_name": "领导",
"description": "审核(审批链第三节点;全览只读)",
"sort_order": 5,
},
],
)
# 审批开关参数定义(param_definitions id 沿用 V3 种子的显式 id 方案接续 17/18)
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 17,
"param_code": "approval.secondary_allocation.enabled",
"param_name": "二次分配审批开关",
"param_group": "审批开关",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
"1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)"
),
"sort_order": 17,
"status": 1,
},
{
"id": 18,
"param_code": "approval.monthly_settlement.enabled",
"param_name": "月度结算审批开关",
"param_group": "审批开关",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);"
"0=结算生成即生效,1=走财务复核→领导审核审批流"
),
"sort_order": 18,
"status": 1,
},
],
)
# 审批开关联动批次条目(batch_id=1 即 V3 的「初始版本」批次;BOOL 无维度行,
# 生效日期 2026-01-01 对齐其他支付环节种子)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 20,
"param_id": 17,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
},
{
"id": 21,
"param_id": 18,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
},
],
)
# 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
op.bulk_insert(
sa.table(
"system_settings",
sa.column("id", sa.BigInteger),
sa.column("company_name", sa.String),
sa.column("logo_path", sa.String),
),
[
{"id": 1, "company_name": "衍水集团", "logo_path": None},
],
)
def downgrade() -> None:
"""回滚迁移。
整表回滚只保留 `op.drop_table` —— DROP TABLE 会连带删除索引与外键。
autogenerate 原样输出的逐个 `op.drop_index` 会失败:idx_approval_steps_order_id
与 idx_business_secondary_allocations_personnel_id 分别是外键的唯一支撑索引,
单独删除被 MySQL 拒绝(errno 1553),回滚会中断在半途。
删除顺序:先删引用方(approval_steps/二次分配),再删被引用方。
行级种子反向清理:roles/param_definitions/param_values 三表由 V1/V3 建、
本迁移只插行(roles 三角色、参数 17/18 及其版本条目),drop_table
不会带走,须显式 DELETE,否则重放 upgrade 时唯一键冲突。
"""
op.drop_table("business_secondary_allocations")
op.drop_table("approval_steps")
op.drop_table("approval_orders")
op.drop_table("system_settings")
op.execute("DELETE FROM param_values WHERE id IN (20, 21)")
op.execute("DELETE FROM param_definitions WHERE id IN (17, 18)")
op.execute("DELETE FROM roles WHERE role_code IN ('marketing_ops', 'finance', 'leader')")
@@ -0,0 +1,71 @@
"""v12 审批单版本号唯一键
Revision ID: 7f4f7b918bc4
Revises: f3a8c2d61b47
Create Date: 2026-10-21 10:15:00.000000
给 ``approval_orders`` 加 ``(order_type, ref_id, version_no)`` 唯一键,兜住
「同一引用对象的第几次送审」在并发下算重。
**加键前必须先重排存量**:V11 迁移用 ``ADD COLUMN version_no INT NOT NULL
DEFAULT 1`` 把存量行一律置 1,而 V11 之前的每次重提都是**新建行**(撤回
只改旧单 status,行不删)。于是任何有 V11 前审批历史的库里,同一
``(order_type, ref_id)`` 存在多行且 ``version_no`` 全为 1——直接
``ADD CONSTRAINT`` 必报 1062,迁移会停在半途。
重排只针对「组内有重复」的分组(``COUNT(*) > COUNT(DISTINCT version_no)``),
按 id 升序重排为 1..n;其余分组一个字节都不动——全量重排会把前端已展示
过的历史版本号(``V{{ version_no }}``)改掉,属于用户可见的历史数据篡改。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7f4f7b918bc4"
down_revision: str | Sequence[str] | None = "f3a8c2d61b47"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_UK = "uk_approval_orders_order_type_ref_version"
def upgrade() -> None:
"""先重排存量重复的版本号,再加唯一键。"""
# 重排:只动「组内有重复版本号」的分组,按 id 升序编 1..n。
# 用派生表内联而非临时表——同一张临时表被 JOIN 两侧引用会报 1137
# "Can't reopen table"。
op.execute(
"UPDATE approval_orders ao "
"JOIN (SELECT id, ROW_NUMBER() OVER ("
" PARTITION BY order_type, ref_id ORDER BY id) AS rn "
" FROM approval_orders "
" WHERE (order_type, ref_id) IN ("
" SELECT order_type, ref_id FROM approval_orders "
" GROUP BY order_type, ref_id "
" HAVING COUNT(*) > COUNT(DISTINCT version_no))) t "
"ON ao.id = t.id "
"SET ao.version_no = t.rn"
)
# 幂等 guard:MySQL DDL 隐式提交,若加键成功而版本号未落盘,
# 重跑会报 1091(键已存在)。写法沿用 V9 先例。
op.execute(
"SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
"WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' "
f"AND CONSTRAINT_NAME = '{_UK}')"
)
op.execute(
"SET @ddl := IF(@uk_exists = 0, "
f"'ALTER TABLE approval_orders ADD CONSTRAINT {_UK} "
"UNIQUE (order_type, ref_id, version_no)', 'DO 0')"
)
op.execute("PREPARE stmt FROM @ddl")
op.execute("EXECUTE stmt")
op.execute("DEALLOCATE PREPARE stmt")
def downgrade() -> None:
"""删唯一键。唯一键本质是索引,DROP INDEX 不校验数据,存量是否已有
重复 version_no 与删索引无关;重排结果也不回滚(版本号保持 1..n 连续)。"""
op.execute(f"ALTER TABLE approval_orders DROP INDEX {_UK}")
@@ -0,0 +1,696 @@
"""业务数据管理七表:业务/业务明细/状态留痕/发货单/发货明细/回款/评级等级配置(M2)
- businesses:业务编号自动生成(报表 2.4.1「合同编号」),五项汇总冗余列
由服务层事务内维护;毛利润相关值不落库(派生值,M3 台账才是固化点)
- business_items:行级 product_category 为折算锚点(对照表 #14),指导价
人工录入、T+ 同步不覆盖
- business_status_logs:append-only,创建时写首条(old=new=1)
- shipments/shipment_items:发货日为回款天数基准日(对照表 #1)
- receipts:手动指定发货批次(#18);settlement_status 为 M3 核算防重锚点
- customer_rating_grades:等级映射可配置,种子四条随迁移初始化
Revision ID: 8efc189b131f
Revises: e1133a7c670a
Create Date: 2026-10-13 11:14:17.545320
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "8efc189b131f"
down_revision: str | Sequence[str] | None = "e1133a7c670a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# 评级等级种子(与 init-dev.sql 双写一致;口径见模型 docstring)
_GRADE_SEED = [
{"grade_name": "A", "min_score": 90, "max_score": 100, "sort_order": 1, "remark": "优秀"},
{"grade_name": "B", "min_score": 75, "max_score": 89, "sort_order": 2, "remark": "良好"},
{"grade_name": "C", "min_score": 60, "max_score": 74, "sort_order": 3, "remark": "合格"},
{"grade_name": "D", "min_score": 0, "max_score": 59, "sort_order": 4, "remark": "待改进"},
]
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"customer_rating_grades",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"grade_name", sa.String(length=32), nullable=False, comment="等级名称(如 A/优秀)"
),
sa.Column(
"min_score",
sa.Numeric(precision=5, scale=1),
nullable=False,
comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分",
),
sa.Column(
"max_score",
sa.Numeric(precision=5, scale=1),
nullable=False,
comment="总分上限(展示用)",
),
sa.Column(
"sort_order",
sa.Integer(),
server_default="0",
nullable=False,
comment="排序(判定顺序,升序)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="说明"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_rating_grades")),
sa.UniqueConstraint("grade_name", name=op.f("uk_customer_rating_grades_grade_name")),
comment="客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"business_status_logs",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(固化,无外键;删除保护由服务层承担)",
),
sa.Column(
"old_status", mysql.TINYINT(), nullable=False, comment="变更前状态(创建首条为 1)"
),
sa.Column("new_status", mysql.TINYINT(), nullable=False, comment="变更后状态"),
sa.Column(
"change_reason", sa.String(length=255), nullable=True, comment="变更原因(终止时必填)"
),
sa.Column(
"operator_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(账号删除后置空)",
),
sa.Column(
"operator_name",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["operator_id"],
["users.id"],
name=op.f("fk_business_status_logs_operator_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_status_logs")),
comment="业务状态变更记录表(append-only)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_business_status_logs_business_id",
"business_status_logs",
["business_id"],
unique=False,
)
op.create_index(
"idx_business_status_logs_create_time",
"business_status_logs",
["create_time"],
unique=False,
)
op.create_table(
"businesses",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_no",
sa.String(length=32),
nullable=False,
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
),
sa.Column(
"customer_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="客户ID(customers.id)",
),
sa.Column("business_date", sa.Date(), nullable=True, comment="业务日期(T+ 同步口径)"),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
),
sa.Column(
"owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
),
sa.Column(
"assistant_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
),
sa.Column(
"is_special",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="特殊业务标记:1是 0否(公司参与等)",
),
sa.Column("special_note", sa.String(length=255), nullable=True, comment="特殊业务说明"),
sa.Column(
"total_sale_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
),
sa.Column(
"total_cost_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
),
sa.Column(
"total_ship_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
),
sa.Column(
"total_received_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
),
sa.Column(
"total_freight_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["assistant_personnel_id"],
["personnel.id"],
name=op.f("fk_businesses_assistant_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["customer_id"],
["customers.id"],
name=op.f("fk_businesses_customer_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["owner_personnel_id"],
["personnel.id"],
name=op.f("fk_businesses_owner_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_businesses")),
sa.UniqueConstraint("business_no", name=op.f("uk_businesses_business_no")),
comment="业务表(业务编号自动生成;T+同步不覆盖业务状态)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_businesses_business_date", "businesses", ["business_date"], unique=False)
op.create_index("idx_businesses_customer_id", "businesses", ["customer_id"], unique=False)
op.create_index(
"idx_businesses_owner_personnel_id", "businesses", ["owner_personnel_id"], unique=False
)
op.create_index(op.f("idx_businesses_source_no"), "businesses", ["source_no"], unique=False)
op.create_index("idx_businesses_status", "businesses", ["status"], unique=False)
op.create_table(
"business_items",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id)",
),
sa.Column(
"product_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
),
sa.Column(
"product_name",
sa.String(length=128),
nullable=False,
comment="产品名称(引用产品时冗余固化)",
),
sa.Column(
"spec_model",
sa.String(length=128),
server_default="",
nullable=False,
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
),
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
sa.Column(
"product_category",
sa.String(length=32),
nullable=False,
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
),
sa.Column(
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="数量(>0)"
),
sa.Column(
"sale_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="销售单价(元;T+ 同步或人工)",
),
sa.Column(
"sale_amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
),
sa.Column(
"cost_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
),
sa.Column(
"guided_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_business_items_business_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
name=op.f("fk_business_items_product_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_items")),
comment="业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_business_items_business_id", "business_items", ["business_id"], unique=False
)
op.create_index(
"idx_business_items_product_category", "business_items", ["product_category"], unique=False
)
op.create_index("idx_business_items_product_id", "business_items", ["product_id"], unique=False)
op.create_table(
"shipments",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"shipment_no",
sa.String(length=32),
nullable=False,
comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id;终态业务禁止新发货)",
),
sa.Column(
"ship_date",
sa.Date(),
nullable=False,
comment="发货日期(回款天数基准日,发货日首日=第 1 天)",
),
sa.Column(
"freight_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=False,
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
),
sa.Column(
"total_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=False,
comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_shipments_business_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipments")),
sa.UniqueConstraint("shipment_no", name=op.f("uk_shipments_shipment_no")),
comment="发货单表(发货日为回款天数基准日,发货日首日计第 1 天)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_shipments_business_id", "shipments", ["business_id"], unique=False)
op.create_index("idx_shipments_ship_date", "shipments", ["ship_date"], unique=False)
op.create_index(op.f("idx_shipments_source_no"), "shipments", ["source_no"], unique=False)
op.create_table(
"receipts",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"receipt_batch_no",
sa.String(length=32),
nullable=False,
comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id)",
),
sa.Column(
"shipment_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)",
),
sa.Column(
"receipt_date",
sa.Date(),
nullable=False,
comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)",
),
sa.Column(
"amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="回款金额(≥0;一期禁负数/红字)",
),
sa.Column(
"is_barter",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
),
sa.Column(
"settlement_status",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_receipts_business_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["shipment_id"],
["shipments.id"],
name=op.f("fk_receipts_shipment_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_receipts")),
sa.UniqueConstraint("receipt_batch_no", name=op.f("uk_receipts_receipt_batch_no")),
comment="回款表(手动指定发货批次;已核算回款禁改禁删)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_receipts_business_id", "receipts", ["business_id"], unique=False)
op.create_index("idx_receipts_receipt_date", "receipts", ["receipt_date"], unique=False)
op.create_index(
"idx_receipts_settlement_status", "receipts", ["settlement_status"], unique=False
)
op.create_index("idx_receipts_shipment_id", "receipts", ["shipment_id"], unique=False)
op.create_index(op.f("idx_receipts_source_no"), "receipts", ["source_no"], unique=False)
op.create_table(
"shipment_items",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"shipment_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="发货单ID(shipments.id)",
),
sa.Column(
"product_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="产品ID(products.id;NULL=自由录入)",
),
sa.Column(
"product_name",
sa.String(length=128),
nullable=False,
comment="产品名称(引用产品时冗余固化)",
),
sa.Column(
"spec_model",
sa.String(length=128),
server_default="",
nullable=False,
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
),
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
sa.Column(
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="发货数量(>0)"
),
sa.Column(
"sale_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)",
),
sa.Column(
"amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
name=op.f("fk_shipment_items_product_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["shipment_id"],
["shipments.id"],
name=op.f("fk_shipment_items_shipment_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipment_items")),
comment="发货产品明细表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_shipment_items_product_id", "shipment_items", ["product_id"], unique=False)
op.create_index(
"idx_shipment_items_shipment_id", "shipment_items", ["shipment_id"], unique=False
)
# ### end Alembic commands ###
# 评级等级种子(等级映射可配置;双轨:init-dev.sql 同步写入)
op.bulk_insert(
sa.table(
"customer_rating_grades",
sa.column("grade_name", sa.String),
sa.column("min_score", sa.Numeric),
sa.column("max_score", sa.Numeric),
sa.column("sort_order", sa.Integer),
sa.column("remark", sa.String),
),
_GRADE_SEED,
)
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
# 整表回滚只保留 drop_table(DROP TABLE 连带删除索引与外键);
# 逐条 drop_index 会先于 drop_table 摘掉外键支撑索引而报 MySQL 1553
op.drop_table("shipment_items")
op.drop_table("receipts")
op.drop_table("shipments")
op.drop_table("business_items")
op.drop_table("businesses")
op.drop_table("business_status_logs")
op.drop_table("customer_rating_grades")
@@ -0,0 +1,144 @@
"""v10 工作台预警规则参数
Revision ID: 90653814a421
Revises: ba0217f5091b
Create Date: 2026-10-19 17:55:48.892998
新增风险预警三条参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」):
- warning.enabled:总开关(默认 1)
- warning.receipt.overdue.days:发货后逾期天数(默认 90)
- warning.approval.timeout.days:审批超时天数(默认 3)
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
"""
from collections.abc import Sequence
from datetime import date
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "90653814a421"
down_revision: str | Sequence[str] | None = "ba0217f5091b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_EFFECTIVE_DATE = date(2026, 10, 19)
def upgrade() -> None:
"""种入三个预警参数定义与生效值(append-only 时点化)。"""
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 21,
"param_code": "warning.enabled",
"param_name": "风险预警总开关",
"param_group": "预警规则",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "1",
"value_min": None,
"value_max": None,
"description": "工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
"sort_order": 21,
"status": 1,
},
{
"id": 22,
"param_code": "warning.receipt.overdue.days",
"param_name": "回款逾期天数阈值",
"param_group": "预警规则",
"value_type": "INT",
"unit": "天",
"dimension_type": "NONE",
"options_text": None,
"default_value": "90",
"value_min": "1",
"value_max": None,
"description": "发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)",
"sort_order": 22,
"status": 1,
},
{
"id": 23,
"param_code": "warning.approval.timeout.days",
"param_name": "审批超时天数阈值",
"param_group": "预警规则",
"value_type": "INT",
"unit": "天",
"dimension_type": "NONE",
"options_text": None,
"default_value": "3",
"value_min": "1",
"value_max": None,
"description": "待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)",
"sort_order": 23,
"status": 1,
},
],
)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 26,
"param_id": 21,
"dimension_value": "",
"effective_date": _EFFECTIVE_DATE,
"value_text": "1",
"batch_id": 1,
},
{
"id": 27,
"param_id": 22,
"dimension_value": "",
"effective_date": _EFFECTIVE_DATE,
"value_text": "90",
"batch_id": 1,
},
{
"id": 28,
"param_id": 23,
"dimension_value": "",
"effective_date": _EFFECTIVE_DATE,
"value_text": "3",
"batch_id": 1,
},
],
)
def downgrade() -> None:
"""回滚种子行(行级种子不会被 drop_table 带走,必须显式删除)。"""
op.execute("DELETE FROM param_values WHERE id IN (26, 27, 28)")
op.execute("DELETE FROM param_definitions WHERE id IN (21, 22, 23)")
@@ -0,0 +1,51 @@
"""V9:business_secondary_allocations 唯一键补齐(存量库自愈)。
背景:uk(business_id, personnel_id) 是随「就地修改已发布的 V6 迁移」加入的
(V6 建表语句里已含、硬编码名);**旧 V6 已应用的存量库 upgrade 到当前
head 时不会重放 V6**,拿不到该约束,V8 也未补加——两条升级路径的库结构
漂移(test_schema_sync 只比对表/列集合,约束级漂移拦不住)。
本迁移是**幂等条件式**:约束已存在(新库 / init-dev 重置库 / 已重建的
开发库)时跳过,不存在(旧 V6 升级库)时补加,避免重复约束名 errno 1826。
语句写成裸 SQL 而非 op.create_unique_constraint——后者在 --sql 离线导出
时会无条件输出 DDL,无法携带存在性判断。
注意:补键时若表内已存在重复 (business_id, personnel_id) 数据,ADD
CONSTRAINT 会失败(有意如此:先由人工清理重复数据再升级,不做静默去重)。
"""
from alembic import op
revision: str = "ba0217f5091b"
down_revision: str | None = "c7d2e5a81f30"
branch_labels = None
depends_on = None
_UK_NAME = "uk_business_secondary_allocations_business_personnel"
def upgrade() -> None:
"""条件式补唯一键(见模块 docstring 的背景与幂等说明)。"""
op.execute(
"SET @uk_exists := ("
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
"WHERE CONSTRAINT_SCHEMA = DATABASE() "
"AND TABLE_NAME = 'business_secondary_allocations' "
f"AND CONSTRAINT_NAME = '{_UK_NAME}'"
")"
)
op.execute(
"SET @ddl := IF(@uk_exists = 0, "
f"'ALTER TABLE business_secondary_allocations ADD CONSTRAINT {_UK_NAME} "
"UNIQUE (business_id, personnel_id)', "
"'DO 0')"
)
op.execute("PREPARE stmt FROM @ddl")
op.execute("EXECUTE stmt")
op.execute("DEALLOCATE PREPARE stmt")
def downgrade() -> None:
"""有意不撤键:新库由 V6 建表即带该约束、并非本迁移首创,撤掉会让
降级路径产生新的漂移;确需撤销请人工评估后执行 DROP INDEX。
"""
@@ -0,0 +1,51 @@
"""v13 客户评级落库
Revision ID: c3a9e1d7b2f4
Revises: 7f4f7b918bc4
Create Date: 2026-10-23 10:30:00.000000
评级计算器从独立工具页并入客户管理(行内弹窗),评分由「不落库」改为
保存为客户档案属性:``rating_score`` 总分 + ``rating_time`` 评级时间。
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
的等级随之更新),因此本迁移只落总分与评级时间两列。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c3a9e1d7b2f4"
down_revision: str | Sequence[str] | None = "7f4f7b918bc4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""customers 加评级两列,紧跟 remark 之后保持与 ORM 声明同序。
用 execute 手写而非 op.add_column:后者不带 AFTER 子句,两列会落到
表尾(T+ 预留列之后),与 ORM/init-dev 的声明顺序不一致。
"""
op.execute(
"ALTER TABLE customers "
"ADD COLUMN rating_score TINYINT COMMENT "
"'评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' "
"AFTER remark"
)
op.execute(
"ALTER TABLE customers "
"ADD COLUMN rating_time DATETIME COMMENT "
"'最近评级时间(评级计算器保存评分时刷新;人工维护字段)' "
"AFTER rating_score"
)
def downgrade() -> None:
"""删除评级两列。评分数据随列删除,评级无下游引用、无悬空风险。"""
op.drop_column("customers", "rating_time")
op.drop_column("customers", "rating_score")
@@ -0,0 +1,188 @@
"""V8:业务答复口径落实(2026-10-19 回执)。
四件事,全部源于《待澄清问题清单》2026-10-19 答复:
1. 审批开关默认值翻转(#5/#7 答复「默认开启」):param_definitions 的
default_value 更新为 '1';param_values **插入生效日 2026-10-19 的新行**
而非改历史行——参数体系时点化(按业务日期回溯),直接改种子行会篡改
10-19 之前的口径历史。
2. 撤销试用期统一工资参数(#16 答复「按人员档案逐人维护」):删除
probation.salary.basic 的定义与版本行;月结基本工资已直接读
personnel.salary_standard(该参数自 M3 ④ 实现起即无业务消费点)。
3. 新增经理个人户聚合基数开关(#12 答复「作为参数配置,手动调整是否
纳入」):manager.personal.included,默认 0=不计入(维持暂口径)。
4. 绩效台账唯一键加 personnel_id(二次分配分摊铺路,澄清 #1 答复
「分配绩效」):分摊后同一回款同版本按人各落一行(负责人余额行 +
参与人比例行),uk(receipt_id, calc_version) 会撞;扩为三列后聚合行
(receipt_id NULL)依旧不受 MySQL 唯一索引约束。
迁移 downgrade 只保留 drop_table/行级种子反向 DELETE(V6 教训:逐个
drop_index 会撞外键支撑索引 errno 1553)。
"""
from datetime import date
import sqlalchemy as sa
from alembic import op
revision: str = "c7d2e5a81f30"
down_revision: str | None = "3bca400421c6"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
op.execute("UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18)")
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 23,
"param_id": 17,
"dimension_value": "",
"effective_date": date(2026, 10, 19),
"value_text": "1",
"batch_id": 1,
},
{
"id": 24,
"param_id": 18,
"dimension_value": "",
"effective_date": date(2026, 10, 19),
"value_text": "1",
"batch_id": 1,
},
],
)
# 2. 撤销试用期统一工资参数(#16:按人员档案 personnel.salary_standard 逐人维护)
op.execute("DELETE FROM param_values WHERE id = 19")
op.execute("DELETE FROM param_definitions WHERE id = 16")
# 3. 经理个人户聚合基数开关(#12:参数化,默认不计入)
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 20,
"param_code": "manager.personal.included",
"param_name": "经理个人户计入聚合基数",
"param_group": "经理分成",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
"0=不计入(默认,暂口径) 1=计入"
),
"sort_order": 20,
"status": 1,
}
],
)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 25,
"param_id": 20,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
}
],
)
# 4. 台账唯一键扩为三列(分摊拆行:同回款同版本按人一行)
# 顺序要紧:旧 uk 是 fk_performance_ledgers_receipt_id 的唯一支撑索引,
# 先删会撞 errno 1553(外键无支撑索引);先建新键(receipt_id 仍居最左)
# 再做替换,两键短暂并存无冲突。
op.create_unique_constraint(
"uk_performance_ledgers_receipt_id_calc_version_personnel",
"performance_ledgers",
["receipt_id", "calc_version", "personnel_id"],
)
op.drop_constraint(
"uk_performance_ledgers_receipt_id_calc_version",
"performance_ledgers",
type_="unique",
)
def downgrade() -> None:
"""反向:唯一键复原 + 种子行级回退(顺序与 upgrade 相反)。
试用期参数(definitions 16 / values 19)的原始种子在 V3,按 V6 downgrade
的行级反向惯例显式重插,保证可重放。
"""
# 与 upgrade 同因的顺序:先建回旧键再删新键(旧键支撑 receipts 外键)
op.create_unique_constraint(
"uk_performance_ledgers_receipt_id_calc_version",
"performance_ledgers",
["receipt_id", "calc_version"],
)
op.drop_constraint(
"uk_performance_ledgers_receipt_id_calc_version_personnel",
"performance_ledgers",
type_="unique",
)
op.execute("DELETE FROM param_values WHERE id = 25")
op.execute("DELETE FROM param_definitions WHERE id = 20")
op.execute(
"INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, "
"unit, dimension_type, options_text, default_value, value_min, value_max, "
"description, sort_order, status) VALUES "
"(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', "
"'元', 'NONE', NULL, '3000', '0', NULL, "
"'试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16, 1)"
)
op.execute(
"INSERT INTO param_values (id, param_id, dimension_value, effective_date, "
"value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1)"
)
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
op.execute("UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)")
@@ -0,0 +1,122 @@
"""T+ 只读同步批次、错误、staging 和增量水位。
Revision ID: d4e5f6a7b8c9
Revises: c3a9e1d7b2f4
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.mysql import BIGINT
revision: str = "d4e5f6a7b8c9"
down_revision: str | Sequence[str] | None = "c3a9e1d7b2f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""增加 T+ 源更新时间和同步审计/staging 表。"""
for table in ("personnel", "products", "customers", "businesses", "shipments", "receipts"):
op.execute(
f"ALTER TABLE {table} ADD COLUMN source_updated_at DATETIME "
"COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at"
)
op.create_table(
"tplus_sync_batches",
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
sa.Column("sync_mode", sa.String(length=16), nullable=False, comment="同步模式:FULL全量 INCREMENTAL增量"),
sa.Column("status", sa.String(length=16), nullable=False, comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED"),
sa.Column("source_database", sa.String(length=128), nullable=True, comment="T+ 源数据库名"),
sa.Column("started_at", sa.DateTime(), nullable=False, comment="开始读取时间"),
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="结束时间"),
sa.Column("watermark_from", sa.DateTime(), nullable=True, comment="增量起始水位"),
sa.Column("watermark_to", sa.DateTime(), nullable=True, comment="本批次结束水位"),
sa.Column("source_record_count", sa.Integer(), nullable=False, server_default="0", comment="源记录数"),
sa.Column("staged_record_count", sa.Integer(), nullable=False, server_default="0", comment="写入 staging 的记录数"),
sa.Column("failed_record_count", sa.Integer(), nullable=False, server_default="0", comment="失败记录数"),
sa.Column("error_message", sa.Text(), nullable=True, comment="批次错误摘要"),
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_batches"),
sa.UniqueConstraint("batch_no", name="uk_tplus_sync_batches_batch_no"),
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步批次表(记录读取范围、结果和对账统计)",
)
op.create_index("idx_tplus_sync_batches_status", "tplus_sync_batches", ["status"])
op.create_index("idx_tplus_sync_batches_started_at", "tplus_sync_batches", ["started_at"])
op.create_table(
"tplus_sync_errors",
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
sa.Column("source_id", sa.String(length=64), nullable=True, comment="T+ 源主键"),
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
sa.Column("error_code", sa.String(length=32), nullable=False, comment="错误编码"),
sa.Column("error_message", sa.Text(), nullable=False, comment="错误说明"),
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0", comment="已重试次数"),
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_errors"),
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步错误表(记录失败行和重试信息)",
)
op.create_index("idx_tplus_sync_errors_batch_no", "tplus_sync_errors", ["batch_no"])
op.create_index("idx_tplus_sync_errors_entity_type", "tplus_sync_errors", ["entity_type"])
op.create_table(
"tplus_sync_records",
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
sa.Column("source_id", sa.String(length=64), nullable=False, comment="T+ 源主键"),
sa.Column("source_no", sa.String(length=64), nullable=True, comment="T+ 原始编号"),
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
sa.Column("source_updated_at", sa.DateTime(), nullable=True, comment="T+ 源数据更新时间"),
sa.Column("row_hash", sa.String(length=64), nullable=False, comment="原始行 SHA-256"),
sa.Column("state", sa.String(length=16), nullable=False, server_default="PENDING", comment="状态:PENDING/APPLIED/SKIPPED/ERROR"),
sa.Column("payload_json", sa.JSON(), nullable=False, comment="原始行 JSON"),
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_records"),
sa.UniqueConstraint("entity_type", "source_id", "row_hash", name="uk_tplus_sync_records_entity_source_hash"),
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)",
)
op.create_index("idx_tplus_sync_records_batch_no", "tplus_sync_records", ["batch_no"])
op.create_index("idx_tplus_sync_records_source_no", "tplus_sync_records", ["source_no"])
op.create_index("idx_tplus_sync_records_state", "tplus_sync_records", ["state"])
op.create_table(
"tplus_sync_watermarks",
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
sa.Column("last_source_updated_at", sa.DateTime(), nullable=True, comment="最近源更新时间"),
sa.Column("last_source_id", sa.String(length=64), nullable=True, comment="同一时间点的最后源主键"),
sa.Column("status", sa.String(length=16), nullable=False, server_default="READY", comment="状态:READY/BLOCKED"),
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_watermarks"),
sa.UniqueConstraint("entity_type", name="uk_tplus_sync_watermarks_entity_type"),
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 增量同步水位表",
)
def downgrade() -> None:
"""删除 T+ 同步支撑表和源更新时间列。"""
op.drop_table("tplus_sync_watermarks")
op.drop_index("idx_tplus_sync_records_state", table_name="tplus_sync_records")
op.drop_index("idx_tplus_sync_records_source_no", table_name="tplus_sync_records")
op.drop_index("idx_tplus_sync_records_batch_no", table_name="tplus_sync_records")
op.drop_table("tplus_sync_records")
op.drop_index("idx_tplus_sync_errors_entity_type", table_name="tplus_sync_errors")
op.drop_index("idx_tplus_sync_errors_batch_no", table_name="tplus_sync_errors")
op.drop_table("tplus_sync_errors")
op.drop_index("idx_tplus_sync_batches_started_at", table_name="tplus_sync_batches")
op.drop_index("idx_tplus_sync_batches_status", table_name="tplus_sync_batches")
op.drop_table("tplus_sync_batches")
for table in ("receipts", "shipments", "businesses", "customers", "products", "personnel"):
op.drop_column(table, "source_updated_at")
@@ -0,0 +1,490 @@
"""基础数据管理六表:人员/辐射区域/拓展计划/产品/客户/归属变更留痕(M2)
- personnel / personnel_regions / personnel_plans:人员档案及其 1:N 子表
- products:产品主数据(类别锚定折算阈值参数)
- customers:客户档案(公司名称+负责人为业务唯一标识)
- ownership_change_logs:归属变更留痕(append-only,不回溯历史)
- 六张表的 T+ 同步预留列(source_no 等)全部可空、不设唯一键(对照表 #26)
Revision ID: e1133a7c670a
Revises: bfbda9c3191d
Create Date: 2026-10-13 09:36:02.219248
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "e1133a7c670a"
down_revision: str | Sequence[str] | None = "bfbda9c3191d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"products",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column("product_name", sa.String(length=128), nullable=False, comment="产品名称"),
sa.Column(
"spec_model",
sa.String(length=128),
server_default="",
nullable=False,
comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
),
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
sa.Column(
"stock_quantity",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="数量(T+ 同步原样存储,语义待 IT 确认)",
),
sa.Column(
"sale_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="销售单价(元,T+ 同步)",
),
sa.Column(
"product_category",
sa.String(length=32),
nullable=False,
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0停用",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_products")),
sa.UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"),
comment="产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(op.f("idx_products_source_no"), "products", ["source_no"], unique=False)
op.create_table(
"ownership_change_logs",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"owner_type", mysql.TINYINT(), nullable=False, comment="归属类型:1客户归属 2业务归属"
),
sa.Column(
"customer_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="客户ID(固化,owner_type=1 时非空)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="业务ID(固化,owner_type=2 时非空)",
),
sa.Column(
"old_owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更前负责营销人员ID(固化)",
),
sa.Column(
"new_owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更后负责营销人员ID(固化)",
),
sa.Column(
"old_assistant_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更前协助营销人员ID(固化,仅业务归属使用)",
),
sa.Column(
"new_assistant_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更后协助营销人员ID(固化,仅业务归属使用)",
),
sa.Column("change_reason", sa.String(length=255), nullable=True, comment="变更原因"),
sa.Column(
"operator_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(账号删除后置空)",
),
sa.Column(
"operator_name",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["operator_id"],
["users.id"],
name=op.f("fk_ownership_change_logs_operator_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_ownership_change_logs")),
comment="归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_ownership_change_logs_customer_id"),
"ownership_change_logs",
["customer_id"],
unique=False,
)
op.create_index(
op.f("idx_ownership_change_logs_business_id"),
"ownership_change_logs",
["business_id"],
unique=False,
)
op.create_index(
op.f("idx_ownership_change_logs_create_time"),
"ownership_change_logs",
["create_time"],
unique=False,
)
op.create_table(
"personnel",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column("personnel_name", sa.String(length=64), nullable=False, comment="姓名"),
sa.Column(
"business_role_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
),
sa.Column(
"manager_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)",
),
sa.Column("phone", sa.String(length=20), nullable=True, comment="联系电话"),
sa.Column("hire_date", sa.Date(), nullable=True, comment="入职日期"),
sa.Column(
"is_probation",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
),
sa.Column(
"salary_standard",
sa.Numeric(precision=12, scale=2),
nullable=True,
comment="月工资标准(元;T+同步不覆盖)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["business_role_id"],
["business_roles.id"],
name=op.f("fk_personnel_business_role_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["manager_id"],
["personnel.id"],
name=op.f("fk_personnel_manager_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel")),
comment="营销人员表(T+同步不覆盖岗位与工资标准)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_personnel_business_role_id"), "personnel", ["business_role_id"], unique=False
)
op.create_index(op.f("idx_personnel_manager_id"), "personnel", ["manager_id"], unique=False)
op.create_index(op.f("idx_personnel_source_no"), "personnel", ["source_no"], unique=False)
op.create_table(
"customers",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column("company_name", sa.String(length=128), nullable=False, comment="公司名称"),
sa.Column(
"owner_name",
sa.String(length=64),
nullable=False,
comment="客户负责人姓名(客户公司负责人的人名,非系统人员)",
),
sa.Column("contact_person", sa.String(length=64), nullable=True, comment="联系人"),
sa.Column("contact_phone", sa.String(length=20), nullable=True, comment="联系电话"),
sa.Column("address", sa.String(length=255), nullable=True, comment="地址"),
sa.Column("industry", sa.String(length=64), nullable=True, comment="所属行业(自由文本)"),
sa.Column(
"customer_type",
sa.String(length=32),
server_default="personal",
nullable=False,
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
),
sa.Column(
"owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["owner_personnel_id"],
["personnel.id"],
name=op.f("fk_customers_owner_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_customers")),
sa.UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
comment="客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_customers_customer_type"), "customers", ["customer_type"], unique=False
)
op.create_index(
op.f("idx_customers_owner_personnel_id"), "customers", ["owner_personnel_id"], unique=False
)
op.create_index(op.f("idx_customers_source_no"), "customers", ["source_no"], unique=False)
op.create_table(
"personnel_plans",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="人员ID(personnel.id)",
),
sa.Column(
"plan_period",
sa.String(length=32),
nullable=False,
comment="计划期间(如 2026Q1、2026-05)",
),
sa.Column("plan_content", sa.String(length=500), nullable=False, comment="计划内容"),
sa.Column(
"target_desc", sa.String(length=255), nullable=True, comment="指标说明(文字描述)"
),
sa.Column(
"source_no", sa.String(length=64), nullable=True, comment="T+ 原始条目号(预留)"
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_personnel_plans_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_plans")),
comment="市场拓展计划表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_personnel_plans_personnel_id"), "personnel_plans", ["personnel_id"], unique=False
)
op.create_table(
"personnel_regions",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="人员ID(personnel.id)",
),
sa.Column(
"region_name",
sa.String(length=128),
nullable=False,
comment="区域名称(产品辐射地理范围)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_personnel_regions_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_regions")),
comment="产品辐射区域表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_personnel_regions_personnel_id"),
"personnel_regions",
["personnel_id"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
op.drop_table("personnel_regions")
op.drop_table("personnel_plans")
op.drop_table("customers")
op.drop_table("personnel")
op.drop_table("ownership_change_logs")
op.drop_table("products")
@@ -0,0 +1,118 @@
"""V15 T+ 持久化任务、幂等和重试元数据。"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.mysql import BIGINT
revision: str = "e5f607a8b9c0"
down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""仅扩展管理元数据,不改原始暂存行或工资业务表。"""
op.add_column(
"tplus_sync_batches",
sa.Column("entity_types_json", sa.JSON(), comment="请求读取的实体类型;历史批次可为空"),
)
op.add_column(
"tplus_sync_batches",
sa.Column(
"skipped_record_count",
sa.Integer(),
nullable=False,
server_default="0",
comment="重复快照跳过条数",
),
)
op.add_column(
"tplus_sync_batches",
sa.Column("parent_batch_no", sa.String(32), comment="重试来源批次号,保留原批次记录"),
)
op.add_column(
"tplus_sync_batches",
sa.Column("request_key", sa.String(36), comment="请求幂等键,空值用于历史和直接CLI任务"),
)
op.add_column(
"tplus_sync_batches",
sa.Column(
"requested_by", BIGINT(unsigned=True), comment="请求用户ID,CLI任务为空,无物理外键"
),
)
op.add_column(
"tplus_sync_batches",
sa.Column("audit_result_json", sa.JSON(), comment="关联核验结果,只含系统字典和汇总"),
)
op.alter_column(
"tplus_sync_batches",
"started_at",
existing_type=sa.DateTime(),
nullable=True,
comment="开始执行时间(排队时为空)",
)
op.alter_column(
"tplus_sync_batches",
"sync_mode",
existing_type=sa.String(16),
nullable=False,
comment="任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留",
)
op.alter_column(
"tplus_sync_batches",
"status",
existing_type=sa.String(16),
nullable=False,
comment="状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED",
)
op.create_unique_constraint(
"uk_tplus_sync_batches_request_key", "tplus_sync_batches", ["request_key"]
)
op.create_index(
"idx_tplus_sync_batches_parent_status", "tplus_sync_batches", ["parent_batch_no", "status"]
)
op.execute(
"UPDATE tplus_sync_batches SET skipped_record_count=GREATEST(0, source_record_count-staged_record_count-failed_record_count) WHERE status='SUCCEEDED' AND sync_mode='FULL'"
)
def downgrade() -> None:
"""回滚前关闭 worker;原始暂存数据和历史批次仍保留。"""
op.execute(
"UPDATE tplus_sync_batches SET started_at=create_time, status='FAILED', finished_at=CURRENT_TIMESTAMP, error_message='任务管理版本已回滚' WHERE status='QUEUED'"
)
op.execute("UPDATE tplus_sync_batches SET started_at=create_time WHERE started_at IS NULL")
op.drop_index("idx_tplus_sync_batches_parent_status", table_name="tplus_sync_batches")
op.drop_constraint("uk_tplus_sync_batches_request_key", "tplus_sync_batches", type_="unique")
for name in (
"audit_result_json",
"requested_by",
"request_key",
"parent_batch_no",
"skipped_record_count",
"entity_types_json",
):
op.drop_column("tplus_sync_batches", name)
op.alter_column(
"tplus_sync_batches",
"started_at",
existing_type=sa.DateTime(),
nullable=False,
comment="开始读取时间",
)
op.alter_column(
"tplus_sync_batches",
"sync_mode",
existing_type=sa.String(16),
nullable=False,
comment="同步模式:FULL全量 INCREMENTAL增量",
)
op.alter_column(
"tplus_sync_batches",
"status",
existing_type=sa.String(16),
nullable=False,
comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED",
)
@@ -0,0 +1,93 @@
"""v11 审批快照与方案版本化
Revision ID: f3a8c2d61b47
Revises: 90653814a421
Create Date: 2026-10-20 10:30:00.000000
补齐 code-review 遗留 2 项:
- 审批单加 ``version_no`` 与 ``snapshot_json``:重提产生新版本,送审内容
在提交时固化,审批期间业务再改不影响复核依据;
- 二次分配方案行改 append-only 版本化:加 ``version_no`` / ``is_active``,
唯一键由 (business_id, personnel_id) 扩为
(business_id, version_no, personnel_id),编辑替换与驳回清空只置历史、
不再物理删除。
存量数据均落 ``version_no=1`` / ``is_active=1``:升级前的行就是当前生效
版本,语义不变。审批单存量快照为空,详情接口按「当前内容回退 + 显式
标注」处理,不做历史回填(回填会伪造提交时内容)。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f3a8c2d61b47"
down_revision: str | Sequence[str] | None = "90653814a421"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""加审批快照列/索引与方案版本列,替换唯一键。"""
op.execute(
"ALTER TABLE approval_orders "
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
"COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' "
"AFTER refer_no"
)
op.execute(
"ALTER TABLE approval_orders "
"ADD COLUMN snapshot_json JSON NULL "
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
"AFTER version_no"
)
op.execute("CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)")
op.execute(
"ALTER TABLE business_secondary_allocations "
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
"COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' "
"AFTER business_id"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 "
"COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' "
"AFTER ratio"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"DROP INDEX uk_business_secondary_allocations_business_personnel, "
"ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel "
"UNIQUE (business_id, version_no, personnel_id)"
)
op.execute(
"CREATE INDEX idx_business_secondary_allocations_business_active "
"ON business_secondary_allocations (business_id, is_active)"
)
op.execute(
"ALTER TABLE business_secondary_allocations COMMENT = "
"'业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)'"
)
def downgrade() -> None:
"""回退结构(降级前须确认同业务同一参与人只存在于一个版本)。"""
op.execute(
"DROP INDEX idx_business_secondary_allocations_business_active "
"ON business_secondary_allocations"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"DROP INDEX uk_business_secondary_allocations_business_version_personnel, "
"ADD CONSTRAINT uk_business_secondary_allocations_business_personnel "
"UNIQUE (business_id, personnel_id)"
)
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN is_active")
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN version_no")
op.execute("DROP INDEX idx_approval_orders_type_ref ON approval_orders")
op.execute("ALTER TABLE approval_orders DROP COLUMN snapshot_json")
op.execute("ALTER TABLE approval_orders DROP COLUMN version_no")
@@ -0,0 +1,31 @@
"""V16 基础导入和核算预演工作区;结构固定,不引用演进中的 ORM。"""
from alembic import op
import sqlalchemy as sa
revision: str = "f6a708b9c0d1"
down_revision: str | None = "e5f607a8b9c0"
branch_labels = None
depends_on = None
STATEMENTS = [
"CREATE TABLE tplus_source_heads (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体类型', \n\tsource_id VARCHAR(64) NOT NULL COMMENT '源内部ID', \n\trecord_id BIGINT UNSIGNED NOT NULL COMMENT '原始快照ID', \n\tbatch_no VARCHAR(32) NOT NULL COMMENT '最后观察批次', \n\tCONSTRAINT pk_tplus_source_heads PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_heads_source_entity_id UNIQUE (source_database, entity_type, source_id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 源当前快照指针(按成功全量批次读取,删除不自动回写业务表)' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_tplus_heads_batch_entity ON tplus_source_heads (batch_no, entity_type)",
"CREATE TABLE tplus_facts (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tkind VARCHAR(32) NOT NULL COMMENT '事实类别', \n\tfact_key VARCHAR(128) NOT NULL COMMENT '事实业务键', \n\tversion INTEGER NOT NULL COMMENT '版本号', \n\tdata_json JSON NOT NULL COMMENT '经校验的事实内容', \n\tevidence VARCHAR(500) NOT NULL COMMENT '依据和修改原因', \n\trequested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_facts PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_facts_source_kind_key_version UNIQUE (source_database, kind, fact_key, version)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 人工补充事实版本表' COLLATE utf8mb4_general_ci",
"CREATE TABLE tplus_source_links (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体类型', \n\tsource_id VARCHAR(64) NOT NULL COMMENT '源内部ID', \n\ttarget_id BIGINT UNSIGNED NOT NULL COMMENT '业务目标ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_source_links PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_links_source_entity_id UNIQUE (source_database, entity_type, source_id), \n\tCONSTRAINT uk_tplus_links_entity_target UNIQUE (entity_type, target_id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 基础数据源目标关联' COLLATE utf8mb4_general_ci",
"CREATE TABLE tplus_work_runs (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\trun_no VARCHAR(32) NOT NULL COMMENT '运行编号', \n\trequest_key VARCHAR(36) NOT NULL COMMENT '请求幂等键', \n\tkind VARCHAR(32) NOT NULL COMMENT 'MASTER_PREVIEW/MASTER_APPLY/CALC_PREVIEW', \n\tstatus VARCHAR(16) NOT NULL COMMENT '运行状态', \n\tsource_database VARCHAR(128) NOT NULL COMMENT '固定源账套', \n\trequested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', \n\tinput_json JSON NOT NULL COMMENT '请求输入', \n\tsnapshot_json JSON COMMENT '规则和补充资料的固定快照', \n\tsnapshot_hash VARCHAR(64) COMMENT '输入指纹', \n\tsummary_json JSON COMMENT '结果汇总', \n\terror_code VARCHAR(64) COMMENT '固定错误编码', \n\tstarted_at DATETIME COMMENT '开始时间', \n\tfinished_at DATETIME COMMENT '结束时间', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_work_runs PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_work_runs_run_no UNIQUE (run_no), \n\tCONSTRAINT uk_tplus_work_runs_request_key UNIQUE (request_key)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入与预演运行表' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_tplus_work_runs_status_id ON tplus_work_runs (status, id)",
"CREATE TABLE tplus_work_lines (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\trun_no VARCHAR(32) NOT NULL COMMENT '运行编号', \n\tline_no INTEGER NOT NULL COMMENT '稳定行序号', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体或分组类型', \n\tsource_id VARCHAR(128) NOT NULL COMMENT '源ID或平账组', \n\taction VARCHAR(32) NOT NULL COMMENT '新增/更新/跳过/待补/冲突/分配', \n\treason VARCHAR(128) COMMENT '固定原因编码', \n\tdata_json JSON NOT NULL COMMENT '最小必要的业务明细', \n\tCONSTRAINT pk_tplus_work_lines PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_work_lines_run_line UNIQUE (run_no, line_no)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入预览与核算预演明细' COLLATE utf8mb4_general_ci"
]
def upgrade():
"""只新增隔离工作区表,不写已有业务或工资记录。"""
for statement in STATEMENTS:
op.execute(sa.text(statement))
def downgrade():
"""只移除本次工作区表,执行前应保留资料和预演备份。"""
for name in ("tplus_work_lines", "tplus_work_runs", "tplus_source_links", "tplus_facts", "tplus_source_heads"):
op.drop_table(name)
+80 -1
View File
@@ -98,7 +98,7 @@ def _is_digits(value: object) -> bool:
return isinstance(value, str) and value.isdigit() return isinstance(value, str) and value.isdigit()
def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User: def require_admin(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
"""要求当前用户具备管理员角色。 """要求当前用户具备管理员角色。
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个 需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
@@ -107,9 +107,88 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
:raises ForbiddenError: 已登录但不具备管理员角色(403) :raises ForbiddenError: 已登录但不具备管理员角色(403)
""" """
# 权限码与「仅管理员」是两套语义:部分系统权限(如 system:params:update)
# 可授予普通角色,统一入口通过不代表本端点放行,这里不依赖
# authorization_checked 短路,每次按数据库角色重新判定。
if ROLE_ADMIN not in user_repository.get_role_codes(current_user): if ROLE_ADMIN not in user_repository.get_role_codes(current_user):
raise ForbiddenError("需要管理员权限") raise ForbiddenError("需要管理员权限")
return current_user return current_user
AdminUser = Annotated[User, Depends(require_admin)] AdminUser = Annotated[User, Depends(require_admin)]
# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅
# 管理员」——参数目录/历史/预览等只读端点用此依赖)
LoginUser = Annotated[User, Depends(get_current_user)]
def require_payroll_writer(current_user: LoginUser) -> User:
"""兼容原写接口注解;操作权限由统一入口校验,制作人限制仅用于工资操作。"""
return current_user
PayrollWriter = Annotated[User, Depends(require_payroll_writer)]
def require_workbench_reader(current_user: LoginUser) -> User:
return current_user
WorkbenchReader = Annotated[User, Depends(require_workbench_reader)]
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
"""要求当前用户具备任一指定角色(依赖工厂)。
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
避免既有调用与测试变动)。
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
"""
def _checker(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
# 与 require_admin 同理:不依赖统一入口的短路标记,按数据库角色判定。
held = set(user_repository.get_role_codes(current_user))
if not held & set(role_codes):
raise ForbiddenError(message)
return current_user
return _checker
async def authorize_request(request: Request, session: DbSession, credentials: BearerCredentials):
"""统一覆盖查询、导出和写入;公开端点以显式白名单处理。"""
from app.services import authorization_policy as policy
from app.services.authorization_service import require_permission
endpoint = request.scope["route"].endpoint
module = endpoint.__module__.removeprefix("app.api.v1.")
name = endpoint.__name__
if (module, name) in policy.PUBLIC_ENDPOINTS:
return
user = get_current_user(credentials, session)
body = {}
if request.method in ("POST", "PUT", "PATCH") and request.headers.get("content-type", "").split(";", 1)[0] == "application/json":
try:
value = await request.json()
body = value if isinstance(value, dict) else {}
except ValueError:
body = {}
code = policy.permission_for(module, name, request.method, params=request.path_params, body=body)
if code is None:
raise ForbiddenError("此接口尚未登记权限,操作已拒绝")
if code:
try:
require_permission(session, user, code)
except ForbiddenError:
from app.services.audit_service import AuditRecord, audit
with audit(session, operator=user, meta=get_request_meta(request), record=AuditRecord(
"权限管理", "权限拒绝", "操作因未授权被拒绝", {"permission_code": code}
)):
raise
request.state.authorization_checked = True
+59 -2
View File
@@ -4,15 +4,37 @@
健康检查作为运维探针不带版本号,直接位于 `/api/health`。 健康检查作为运维探针不带版本号,直接位于 `/api/health`。
""" """
from fastapi import APIRouter from fastapi import APIRouter, Depends
from app.api import health from app.api import health
from app.api.deps import authorize_request
from app.api.v1 import auth from app.api.v1 import auth
from app.api.v1.base import business_roles as base_business_roles
from app.api.v1.base import customers as base_customers
from app.api.v1.base import personnel as base_personnel
from app.api.v1.base import products as base_products
from app.api.v1.business import businesses as business_businesses
from app.api.v1.business import ratings as business_ratings
from app.api.v1.business import receipts as business_receipts
from app.api.v1.business import shipments as business_shipments
from app.api.v1.calc import router as calc_router
from app.api.v1.dashboard import router as dashboard_router
from app.api.v1.metadata import router as metadata_router
from app.api.v1.payroll import router as payroll_router
from app.api.v1.reports import router as reports_router
from app.api.v1.settlements import router as settlements_router
from app.api.v1.system import approvals as system_approvals
from app.api.v1.system import business_roles as system_business_roles from app.api.v1.system import business_roles as system_business_roles
from app.api.v1.system import calc_templates as system_calc_templates
from app.api.v1.system import logs as system_logs from app.api.v1.system import logs as system_logs
from app.api.v1.system import monitor as system_monitor
from app.api.v1.system import params as system_params from app.api.v1.system import params as system_params
from app.api.v1.system import roles as system_roles from app.api.v1.system import roles as system_roles
from app.api.v1.system import settings as system_settings
from app.api.v1.system import tplus as system_tplus
from app.api.v1.system import users as system_users from app.api.v1.system import users as system_users
from app.api.v1.system.authorization import router as authorization_router
from app.api.v1.system.coefficients import router as coefficient_router
api_router = APIRouter() api_router = APIRouter()
@@ -20,17 +42,52 @@ api_router = APIRouter()
api_router.include_router(health.router) api_router.include_router(health.router)
# 业务接口:/api/v1/* # 业务接口:/api/v1/*
v1_router = APIRouter(prefix="/v1") v1_router = APIRouter(prefix="/v1", dependencies=[Depends(authorize_request)])
v1_router.include_router(authorization_router)
v1_router.include_router(metadata_router)
v1_router.include_router(auth.router) v1_router.include_router(auth.router)
# 系统管理(需求 2.5.1):/api/v1/system/* # 系统管理(需求 2.5.1):/api/v1/system/*
system_router = APIRouter(prefix="/system") system_router = APIRouter(prefix="/system")
system_router.include_router(system_users.router) system_router.include_router(system_users.router)
system_router.include_router(system_monitor.router)
system_router.include_router(system_roles.router) system_router.include_router(system_roles.router)
system_router.include_router(system_business_roles.router) system_router.include_router(system_business_roles.router)
system_router.include_router(system_calc_templates.router)
system_router.include_router(system_params.router) system_router.include_router(system_params.router)
system_router.include_router(coefficient_router)
system_router.include_router(system_settings.router)
system_router.include_router(system_approvals.router)
system_router.include_router(system_logs.operation_router) system_router.include_router(system_logs.operation_router)
system_router.include_router(system_logs.login_router) system_router.include_router(system_logs.login_router)
system_router.include_router(system_tplus.router)
v1_router.include_router(system_router) v1_router.include_router(system_router)
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
base_router = APIRouter(prefix="/base")
base_router.include_router(base_personnel.router)
base_router.include_router(base_products.router)
base_router.include_router(base_customers.router)
base_router.include_router(base_business_roles.router)
v1_router.include_router(base_router)
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
business_router = APIRouter(prefix="/business")
business_router.include_router(business_businesses.router)
business_router.include_router(business_shipments.router)
business_router.include_router(business_receipts.router)
business_router.include_router(business_ratings.router)
v1_router.include_router(business_router)
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(calc_router)
v1_router.include_router(payroll_router)
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(settlements_router)
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
v1_router.include_router(reports_router)
# 工作台(M4 ⑧):/api/v1/dashboard,四区块只读聚合,限管理员/财务/领导
v1_router.include_router(dashboard_router)
api_router.include_router(v1_router) api_router.include_router(v1_router)
+6 -2
View File
@@ -5,9 +5,9 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
from app.core.config import get_settings
from app.core.response import ApiResponse, ok from app.core.response import ApiResponse, ok
from app.models import User from app.models import User
from app.repositories.user_repository import get_role_codes
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
from app.services import auth_service, captcha_service from app.services import auth_service, captcha_service
@@ -65,12 +65,16 @@ def logout(
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户") @router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]: def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]:
"""返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。""" """返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。"""
from app.services.authorization_service import session_info, user_session
authorization = session_info(user_session(current_user), current_user)
return ok( return ok(
CurrentUserOut( CurrentUserOut(
id=current_user.id, id=current_user.id,
username=current_user.username, username=current_user.username,
real_name=current_user.real_name, real_name=current_user.real_name,
roles=get_role_codes(current_user), **authorization,
payroll_approval_enabled=get_settings().payroll_approval_enabled,
last_login_time=current_user.last_login_time, last_login_time=current_user.last_login_time,
) )
) )
+8
View File
@@ -0,0 +1,8 @@
"""基础数据管理接口(需求 2.1)。
挂载于 `/api/v1/base/*`,含人员管理、辐射区域、拓展计划等基础数据的维护入口。
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
由 service 层写入操作日志。
"""
+34
View File
@@ -0,0 +1,34 @@
"""岗位下拉选项(基础数据与报表筛选共用)。
岗位管理本身仅管理员可用(FR-006),但报表的「岗位」筛选对财务/领导
开放(FR-029)。为避免把管理接口整体放开,这里提供只读、登录即可的
启用岗位选项;写操作仍走 `/system/business-roles` 的管理员端点。
"""
from typing import Annotated
from fastapi import APIRouter, Depends
from app.api.deps import DbSession, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.business_role import BusinessRoleOptionOut
from app.services.authorization_metadata import options
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
# 基础数据是业务操作:登录即可,不要求管理员
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get(
"/options",
response_model=ApiResponse[list[BusinessRoleOptionOut]],
summary="岗位下拉选项",
)
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[BusinessRoleOptionOut]]:
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
"""
return ok([BusinessRoleOptionOut.model_validate(row) for row in options(session, _, "business_roles")])
+181
View File
@@ -0,0 +1,181 @@
"""客户管理接口(需求 2.1.3)。
归属调整有专属端点 ``PUT /{id}/ownership``:它是唯一写归属留痕的入口,
编辑端点的 schema 不含归属字段 —— 防止出现「改了归属却无留痕」的旁路
(原因详见 `schemas.customer.CustomerUpdateIn`)。
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import Customer, User
from app.schemas.category_batch import CustomerCategoryBatch, CustomerOwnershipBatch
from app.schemas.customer import (
CustomerCreateIn,
CustomerOut,
CustomerOwnershipIn,
CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn,
OwnershipChangeLogOut,
)
from app.services import customer_service
router = APIRouter(prefix="/customers", tags=["客户管理"])
# 基础数据不要求管理员(见模块 docstring),登录用户即可操作
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.put("/batch-category")
def batch_category(payload: CustomerCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
from app.services.category_batch import fill_missing
return ok(fill_missing(session, Customer, payload, actor, meta))
@router.put("/batch-ownership")
def batch_ownership(payload: CustomerOwnershipBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
return ok(customer_service.batch_change_ownership(session, payload, operator=actor, meta=meta))
@router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表")
def list_customers(
session: DbSession,
_: CurrentUser,
query: Annotated[CustomerQuery, Query()],
) -> ApiResponse[PageResult[CustomerOut]]:
"""分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名)。"""
return ok(customer_service.list_customers(session, query))
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
def create_customer(
session: DbSession,
current_user: PayrollWriter,
payload: CustomerCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""新增客户。客户分类须在注册表内;「公司名称+负责人」组合唯一。"""
return ok(
customer_service.create_customer(session, payload, operator=current_user, meta=meta),
message="客户已创建",
)
@router.get("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="客户详情")
def get_customer(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[CustomerOut]:
"""按主键查询客户详情。"""
return ok(customer_service.get_customer(session, customer_id))
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
def update_customer(
session: DbSession,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""编辑客户档案。
**不含归属** —— 归属调整统一走 ``PUT /{id}/ownership``;编辑表单即使
提交 ownerPersonnelId 也会被忽略,归属以归属端点为准。
"""
return ok(
customer_service.update_customer(
session, customer_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
def delete_customer(
session: DbSession,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除客户(物理删除)。
已存在关联业务时返回 400(先处理相关业务单据)。
"""
customer_service.delete_customer(session, customer_id, operator=current_user, meta=meta)
return ok(message="客户已删除")
@router.put(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerOut],
summary="保存客户评级",
)
def rate_customer(
session: DbSession,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""保存评级总分(评级计算器「保存评分」)。
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
"""
return ok(
customer_service.rate_customer(
session, customer_id, payload, operator=current_user, meta=meta
),
message="评级已保存",
)
@router.put(
"/{customer_id}/ownership",
response_model=ApiResponse[CustomerOut],
summary="归属变更",
)
def change_ownership(
session: DbSession,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerOwnershipIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""调整客户归属(唯一写归属留痕的入口)。
只影响变更后核算,不回溯历史(对照表 #19);新旧归属人员ID任一为 null
表示清空归属。新旧归属相同报「归属未变化」。
"""
return ok(
customer_service.change_ownership(
session, customer_id, payload, operator=current_user, meta=meta
),
message="归属已调整",
)
@router.get(
"/{customer_id}/ownership-logs",
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
summary="归属变更留痕",
)
def list_ownership_logs(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()],
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
return ok(customer_service.list_ownership_logs(session, customer_id, query))
+143
View File
@@ -0,0 +1,143 @@
"""人员管理接口(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
基础数据属业务操作,**登录即可访问**(不要求管理员,对照系统管理模块)。
校验规则(岗位/经理合法性、删除保护、子表全量替换的防御写法)见
`personnel_service` 模块 docstring —— 前端提示之外,后端校验是唯一防线。
写操作经 `RequestMetaDep` 带入请求元信息由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.personnel import (
PersonnelCreateIn,
PersonnelDetailOut,
PersonnelOut,
PersonnelQuery,
PersonnelStatusIn,
PlanReplaceIn,
RegionReplaceIn,
)
from app.services import personnel_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/personnel", tags=["人员管理"])
@router.get("", response_model=ApiResponse[PageResult[PersonnelOut]], summary="人员列表")
def list_personnel(
session: DbSession,
_: CurrentUser,
query: Annotated[PersonnelQuery, Query()],
) -> ApiResponse[PageResult[PersonnelOut]]:
"""分页查询人员(含离职)。姓名模糊匹配,可按状态与岗位筛选。"""
return ok(personnel_service.list_personnel(session, query))
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
def create_personnel(
session: DbSession,
current_user: PayrollWriter,
payload: PersonnelCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
"""新增人员,默认在职。岗位须存在且启用;直属经理不能是自己且须在职。"""
return ok(
personnel_service.create_personnel(session, payload, operator=current_user, meta=meta),
message="人员已创建",
)
@router.get("/{personnel_id}", response_model=ApiResponse[PersonnelDetailOut], summary="人员详情")
def get_personnel(
session: DbSession,
_: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[PersonnelDetailOut]:
"""人员详情,附辐射区域与拓展计划,供编辑抽屉一次回显。"""
return ok(personnel_service.get_detail(session, personnel_id))
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
def update_personnel(
session: DbSession,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
"""编辑人员(全量提交,不含状态)。校验规则同新增。"""
return ok(
personnel_service.update_personnel(
session, personnel_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
def delete_personnel(
session: DbSession,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除人员。被团队成员或客户归属引用时禁删,应改为离职。"""
personnel_service.delete_personnel(session, personnel_id, operator=current_user, meta=meta)
return ok(message="人员已删除")
@router.put(
"/{personnel_id}/status",
response_model=ApiResponse[PersonnelOut],
summary="设为在职/离职",
)
def set_status(
session: DbSession,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
"""在职/离职切换。离职人员不可再被业务与客户归属引用。"""
result = personnel_service.set_status(
session, personnel_id, payload.status, effective_date=payload.effective_date, operator=current_user, meta=meta
)
return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职")
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
def replace_regions(
session: DbSession,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: RegionReplaceIn,
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""全量替换辐射区域,空列表即清空。"""
personnel_service.replace_regions(
session, personnel_id, payload, operator=current_user, meta=meta
)
return ok(message="已保存")
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
def replace_plans(
session: DbSession,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: PlanReplaceIn,
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""全量替换拓展计划,空列表即清空。"""
personnel_service.replace_plans(
session, personnel_id, payload, operator=current_user, meta=meta
)
return ok(message="已保存")
+149
View File
@@ -0,0 +1,149 @@
"""产品管理接口(需求 2.1.2:产品主数据维护)。
产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值),
类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。
数据提取使用预览指纹校验、名称规格去重和单位分类冲突保护,
仅建立产品档案关联,不改写历史业务金额。
基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import Product, User
from app.schemas.category_batch import ProductCategoryBatch
from app.schemas.product import (
ProductCreateIn,
ProductExtractIn,
ProductOut,
ProductQuery,
ProductStatusIn,
ProductUpdateIn,
)
from app.services import product_service
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/products", tags=["产品管理"])
@router.put("/batch-category")
def batch_category(payload: ProductCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
from app.services.category_batch import fill_missing
return ok(fill_missing(session, Product, payload, actor, meta))
@router.get("/extract/candidates")
def extraction_candidates(session: DbSession, _: CurrentUser, keyword: str = "", page_num: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)):
from app.services.product_completion import candidates
return ok(candidates(session, keyword, page_num, page_size))
@router.post("/extract/preview")
def extraction_preview(payload: ProductExtractIn, session: DbSession, _: CurrentUser):
from app.services.product_completion import extraction_plan
return ok(extraction_plan(session, payload.item_ids))
@router.post("/extract")
def extraction_apply(payload: ProductExtractIn, session: DbSession, actor: CurrentUser, meta: RequestMetaDep):
from app.services.product_completion import extract
return ok(extract(session, payload, actor, meta))
@router.post("/{product_id}/restore-source")
def restore_source(product_id: int, session: DbSession, actor: CurrentUser, meta: RequestMetaDep):
from app.services.product_completion import restore_source as restore
return ok(restore(session, product_id, actor, meta))
@router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表")
def list_products(
session: DbSession,
_: CurrentUser,
query: Annotated[ProductQuery, Query()],
) -> ApiResponse[PageResult[ProductOut]]:
"""分页查询产品(含停用)。keyword 对名称或规格模糊匹配。"""
return ok(product_service.list_products(session, query))
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
def create_product(
session: DbSession,
current_user: PayrollWriter,
payload: ProductCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ProductOut]:
"""新增产品。类别须在合法值域内;新产品默认启用。"""
return ok(
product_service.create_product(session, payload, operator=current_user, meta=meta),
message="产品已创建",
)
@router.get("/{product_id}", response_model=ApiResponse[ProductOut], summary="产品详情")
def get_product(
session: DbSession,
_: CurrentUser,
product_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ProductOut]:
"""按主键查询产品详情。"""
return ok(product_service.get_product(session, product_id))
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
def update_product(
session: DbSession,
current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)],
payload: ProductUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[ProductOut]:
"""编辑产品基本信息(全量替换,不含状态;启用/停用走独立端点)。"""
return ok(
product_service.update_product(
session, product_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
def delete_product(
session: DbSession,
current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""物理删除产品(仅限误录数据,常规下架用停用)。
被业务明细/发货明细引用时返回 400(引导改走停用)。
"""
product_service.delete_product(session, product_id, operator=current_user, meta=meta)
return ok(message="产品已删除")
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
def set_status(
session: DbSession,
current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)],
payload: ProductStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[ProductOut]:
"""启用或停用产品(1启用 0停用)。"""
result = product_service.set_status(
session, product_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已停用")
+8
View File
@@ -0,0 +1,8 @@
"""业务数据管理接口(需求 2.2)。
挂载于 `/api/v1/business/*`,含业务单据维护、客户评级等级配置等业务数据入口。
与 `api/v1/system` 的权限分工:业务数据是**业务操作**,登录即可访问
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
由 service 层写入操作日志。
"""
+263
View File
@@ -0,0 +1,263 @@
"""业务管理接口(需求 2.2.1/2.2.4)。
校验规则(编号生成、状态机、归属留痕、汇总重算)见 `business_service`
模块 docstring;基础数据属业务操作,登录即可访问。
"""
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User
from app.schemas.business import (
BusinessCreateIn,
BusinessDetailOut,
BusinessOut,
BusinessOwnershipIn,
BusinessQuery,
BusinessStatusIn,
BusinessStatusLogOut,
BusinessUpdateIn,
GrossProfitOut,
OwnershipChangeLogOut,
)
from app.schemas.business_source import (
GuidanceConfirmIn,
SalesRelationConfirmIn,
SalesRelationQuery,
SalesRelationReleaseIn,
SourceDocumentsQuery,
SourceMaterialsIn,
SourcePriceConfirmIn,
)
from app.services import business_service, business_source
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/businesses", tags=["业务管理"])
@router.get("/{business_id}/readiness")
def source_readiness(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)]):
return ok(business_source.readiness(session, business_id))
@router.get("/{business_id}/source-documents")
def source_documents(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
query: Annotated[SourceDocumentsQuery, Query()]):
return ok(business_source.documents(session, business_id, query, query.entity))
@router.get("/{business_id}/source-quantities")
def source_quantities(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()]):
return ok(business_source.source_quantities(session, business_id, query))
@router.get("/{business_id}/source-documents/{document_id}/details")
def source_document_details(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
document_id: Annotated[int, Path(ge=1)], query: Annotated[PageQuery, Query()]):
return ok(business_source.document_details(session, business_id, document_id, query))
@router.put("/{business_id}/source-documents/{document_id}/missing-price")
def confirm_source_price(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
document_id: Annotated[int, Path(ge=1)], payload: SourcePriceConfirmIn, meta: RequestMetaDep):
from app.services.authorization_service import require_permission
require_permission(session, actor, "payroll:prepare")
return ok(business_source.confirm_missing_source_price(session, business_id, document_id, payload, actor, meta))
@router.put("/{business_id}/guidance")
def confirm_guidance(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
payload: GuidanceConfirmIn, meta: RequestMetaDep):
"""从本地发货明细登记来源产品的发货月份指导成本。"""
from app.services.authorization_service import require_permission
require_permission(session, actor, "payroll:prepare")
require_permission(session, actor, "tplus:facts")
return ok(business_source.confirm_guidance(session, business_id, payload, actor, meta))
@router.get("/{business_id}/sales-relation-candidates")
def sales_relation_candidates(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
query: Annotated[SalesRelationQuery, Query()]):
return ok(business_source.sales_relation_candidates(session, business_id, query, query.shipment_page, query.order_page))
@router.put("/{business_id}/sales-relation")
def confirm_sales_relation(session: DbSession, actor: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: SalesRelationConfirmIn, meta: RequestMetaDep):
from app.services.authorization_service import require_permission
from app.services.tplus import sales_relations
require_permission(session, actor, "payroll:prepare")
return ok(sales_relations.confirm(session, business_id, payload, actor, meta))
@router.put("/{business_id}/sales-relation/release")
def release_sales_relation(session: DbSession, actor: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: SalesRelationReleaseIn, meta: RequestMetaDep):
from app.services.authorization_service import require_permission
from app.services.tplus import sales_relations
require_permission(session, actor, "payroll:prepare")
return ok(sales_relations.release(session, business_id, payload, actor, meta))
@router.put("/{business_id}/source-materials")
def source_materials(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
payload: SourceMaterialsIn, meta: RequestMetaDep):
return ok(business_source.save_materials(session, business_id, payload, actor, meta))
@router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表")
def list_businesses(
session: DbSession,
_: CurrentUser,
query: Annotated[BusinessQuery, Query()],
) -> ApiResponse[PageResult[BusinessOut]]:
"""分页查询业务。编号/公司名称模糊,可按状态/客户/归属人员/日期范围筛选。"""
return ok(business_service.list_businesses(session, query))
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
def create_business(
session: DbSession,
current_user: PayrollWriter,
payload: BusinessCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""创建业务 + 明细行(≥1)。业务编号自动生成;创建写首条状态留痕。"""
return ok(
business_service.create_business(session, payload, operator=current_user, meta=meta),
message="业务已创建",
)
@router.get("/{business_id}", response_model=ApiResponse[BusinessDetailOut], summary="业务详情")
def get_business(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[BusinessDetailOut]:
"""业务详情,附产品明细行,供编辑抽屉一次回显。"""
return ok(business_service.get_detail(session, business_id))
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
def update_business(
session: DbSession,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""编辑业务(明细全量替换并重算汇总)。归属不在此端点调整,走 /ownership。"""
return ok(
business_service.update_business(
session, business_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
def delete_business(
session: DbSession,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除业务。有发货或回款记录时禁删,应改为已终止。"""
business_service.delete_business(session, business_id, operator=current_user, meta=meta)
return ok(message="业务已删除")
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
def change_status(
session: DbSession,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""状态流转:执行中→已完成/已终止;终态不可再变更;终止必填原因。"""
return ok(
business_service.change_status(
session, business_id, payload, operator=current_user, meta=meta
),
message="状态已变更",
)
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
def change_ownership(
session: DbSession,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessOwnershipIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""调整负责/协助营销人员,同事务写归属变更留痕(不回溯历史)。"""
return ok(
business_service.change_ownership(
session, business_id, payload, operator=current_user, meta=meta
),
message="归属已调整",
)
@router.get(
"/{business_id}/ownership-logs",
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
summary="归属变更记录",
)
def list_ownership_logs(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()],
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""某业务的归属变更留痕(分页,按时间倒序)。"""
return ok(business_service.list_ownership_logs(session, business_id, query))
@router.get(
"/{business_id}/status-logs",
response_model=ApiResponse[PageResult[BusinessStatusLogOut]],
summary="状态流转记录",
)
def list_status_logs(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()],
) -> ApiResponse[PageResult[BusinessStatusLogOut]]:
"""某业务的状态流转时间线(分页,按时间正序,从创建起展示)。"""
return ok(business_service.list_status_logs(session, business_id, query))
@router.get(
"/{business_id}/gross-profit",
response_model=ApiResponse[GrossProfitOut],
summary="毛利润数据视图",
)
def gross_profit(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
as_of: Annotated[date | None, Query()] = None,
) -> ApiResponse[GrossProfitOut]:
"""按产品逐行实时计算毛利润与折算额(不含运费口径,对照表 #2)。
阈值按 asOf(默认今日)取参数快照;M3 核算实际按回款到账日取参。
"""
return ok(business_service.gross_profit(session, business_id, as_of))
+74
View File
@@ -0,0 +1,74 @@
"""客户评级等级配置接口(需求 2.2.5)。
评级计算器的等级映射判定表:判定按 sort_order 升序取首个 min_score ≤ 总分
的等级,max_score 仅展示。区间合法(0≤min≤max≤100)与互不重叠由后端校验
—— 前端表单只是展示约束,后端校验是唯一防线。
列表不分页全量返回:等级映射是判定表,数量个位数,分页无意义。
业务数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
from app.services import rating_service
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/rating-grades", tags=["客户评级"])
@router.get("", response_model=ApiResponse[list[RatingGradeOut]], summary="评级等级列表")
def list_grades(
session: DbSession,
_: CurrentUser,
) -> ApiResponse[list[RatingGradeOut]]:
"""全量等级列表,按判定顺序(sort_order 升序)返回,不分页。"""
return ok(rating_service.list_grades(session))
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
def create_grade(
session: DbSession,
current_user: PayrollWriter,
payload: RatingGradeCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[RatingGradeOut]:
"""新增评级等级。区间须 0≤min≤max≤100 且与既有等级不重叠。"""
return ok(
rating_service.create_grade(session, payload, operator=current_user, meta=meta),
message="评级等级已创建",
)
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
def update_grade(
session: DbSession,
current_user: PayrollWriter,
grade_id: Annotated[int, Path(ge=1)],
payload: RatingGradeUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[RatingGradeOut]:
"""编辑评级等级(全量替换)。区间须合法且与其他等级不重叠。"""
return ok(
rating_service.update_grade(session, grade_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
def delete_grade(
session: DbSession,
current_user: PayrollWriter,
grade_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""物理删除评级等级(等级表无下游引用,允许清空,理由见 service 说明)。"""
rating_service.delete_grade(session, grade_id, operator=current_user, meta=meta)
return ok(message="评级等级已删除")
+105
View File
@@ -0,0 +1,105 @@
"""回款管理接口(需求 2.2.3)。
回款按业务登记到账款项,批次号自动生成(HK 前缀)。校验规则(发货
批次同业务强校验、已核算禁改禁删、汇总重算)见 `receipt_service` 模块
docstring;基础数据属业务操作,登录即可访问。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.receipt import (
ReceiptConfirmIn,
ReceiptCreateIn,
ReceiptOut,
ReceiptQuery,
ReceiptUpdateIn,
)
from app.services import receipt_confirmation, receipt_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/receipts", tags=["回款管理"])
@router.get("/confirmation-policy", summary="回款人工确认开关状态")
def confirmation_policy(session: DbSession, _: CurrentUser):
return ok({"enabled": receipt_confirmation.enabled(session)})
@router.post("/{receipt_id}/confirm", response_model=ApiResponse[ReceiptOut], summary="独立人工确认回款")
def confirm_receipt(
receipt_id: Annotated[int, Path(ge=1)], payload: ReceiptConfirmIn,
session: DbSession, current_user: CurrentUser, meta: RequestMetaDep,
):
return ok(receipt_service.confirm_receipt(
session, receipt_id, payload, operator=current_user, meta=meta,
), message="回款已人工确认")
@router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表")
def list_receipts(
session: DbSession,
_: CurrentUser,
query: Annotated[ReceiptQuery, Query()],
) -> ApiResponse[PageResult[ReceiptOut]]:
"""分页查询回款。附业务号/发货单号与回款天数派生值,可按业务/日期/抵顶/核算状态筛选。"""
return ok(receipt_service.list_receipts(session, query))
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
def create_receipt(
session: DbSession,
current_user: PayrollWriter,
payload: ReceiptCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ReceiptOut]:
"""新增回款。批次号自动生成;发货批次须与本回款同属一个业务;业务须执行中。"""
return ok(
receipt_service.create_receipt(session, payload, operator=current_user, meta=meta),
message="回款已登记",
)
@router.get("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="回款详情")
def get_receipt(
session: DbSession,
_: CurrentUser,
receipt_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ReceiptOut]:
"""回款详情(含回款天数派生值,未指定发货批次时为空)。"""
return ok(receipt_service.get_detail(session, receipt_id))
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
def update_receipt(
session: DbSession,
current_user: PayrollWriter,
receipt_id: Annotated[int, Path(ge=1)],
payload: ReceiptUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[ReceiptOut]:
"""编辑回款(全量替换并重算合计)。已核算回款禁止修改;发货批次重新校验同业务。"""
return ok(
receipt_service.update_receipt(
session, receipt_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
def delete_receipt(
session: DbSession,
current_user: PayrollWriter,
receipt_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除回款。已核算回款禁止删除;业务已完成或已终止时禁删。"""
receipt_service.delete_receipt(session, receipt_id, operator=current_user, meta=meta)
return ok(message="回款已删除")
+89
View File
@@ -0,0 +1,89 @@
"""发货管理接口(需求 2.2.2)。
校验规则(状态门槛、行金额固化、双重汇总回写、删除保护)见
`shipment_service` 模块 docstring;业务数据属业务操作,登录即可访问。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.shipment import (
ShipmentCreateIn,
ShipmentDetailOut,
ShipmentOut,
ShipmentQuery,
ShipmentUpdateIn,
)
from app.services import shipment_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/shipments", tags=["发货管理"])
@router.get("", response_model=ApiResponse[PageResult[ShipmentOut]], summary="发货单列表")
def list_shipments(
session: DbSession,
_: CurrentUser,
query: Annotated[ShipmentQuery, Query()],
) -> ApiResponse[PageResult[ShipmentOut]]:
"""分页查询发货单(附业务编号),可按业务/发货日期范围筛选。"""
return ok(shipment_service.list_shipments(session, query))
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
def create_shipment(
session: DbSession,
current_user: PayrollWriter,
payload: ShipmentCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ShipmentOut]:
"""创建发货单(明细 ≥1)。业务须处于执行中;锁业务行后生成 SH 编号。"""
return ok(
shipment_service.create_shipment(session, payload, operator=current_user, meta=meta),
message="发货单已创建",
)
@router.get("/{shipment_id}", response_model=ApiResponse[ShipmentDetailOut], summary="发货单详情")
def get_shipment(
session: DbSession,
_: CurrentUser,
shipment_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ShipmentDetailOut]:
"""发货单详情,附产品明细行,供编辑抽屉一次回显。"""
return ok(shipment_service.get_detail(session, shipment_id))
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
def update_shipment(
session: DbSession,
current_user: PayrollWriter,
shipment_id: Annotated[int, Path(ge=1)],
payload: ShipmentUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[ShipmentOut]:
"""编辑发货单(明细全量替换并重算汇总)。终态业务的发货单禁改。"""
return ok(
shipment_service.update_shipment(
session, shipment_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
def delete_shipment(
session: DbSession,
current_user: PayrollWriter,
shipment_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除发货单(连带明细行并回写业务汇总)。被回款批次指定时禁删。"""
shipment_service.delete_shipment(session, shipment_id, operator=current_user, meta=meta)
return ok(message="发货单已删除")
+103
View File
@@ -0,0 +1,103 @@
"""绩效核算接口(M3 ④):台账查询与单笔核算触发。
核算动作 = 把回款固化为台账行(每笔回款仅一次;重算走独立入口并授权)。
准入:读=登录即可;写(核算/重算/批量)=管理员与营销后勤
(权限清单待澄清 #6,答复后细化)。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
from app.services import calc_service
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/calc", tags=["绩效工资 · 绩效核算"])
# 重算是授权动作:管理员/财务(营销后勤不可重算)
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
def list_ledger(
session: DbSession,
query: Annotated[LedgerQuery, Query()],
) -> ApiResponse[PageResult[LedgerOut]]:
"""分页查询绩效核算台账(默认仅生效行,可筛选历史版本)。"""
return ok(calc_service.list_ledger_page(session, query))
@router.get("/ledger/{ledger_id}", response_model=ApiResponse[LedgerOut], summary="台账详情")
def get_ledger(
session: DbSession,
ledger_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[LedgerOut]:
"""台账行详情(含计算快照)。"""
return ok(calc_service.get_ledger(session, ledger_id))
@router.post(
"/receipts/{receipt_id}/calc",
response_model=ApiResponse[CalcReceiptOut],
summary="核算回款",
)
def calc_receipt(
receipt_id: Annotated[int, Path(ge=1)],
session: DbSession,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]:
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
return ok(
calc_service.calc_receipt(session, receipt_id, operator=current_user, meta=meta),
message="核算完成",
)
class RecalcIn(BaseModel):
"""重算入参:原因必填(重算授权留痕)。"""
reason: str = Field(min_length=1, max_length=255, description="重算原因")
class BatchIn(BaseModel):
"""批量核算入参。"""
receipt_ids: list[int] = Field(min_length=1, description="回款 ID 列表")
@router.post(
"/receipts/{receipt_id}/recalc",
response_model=ApiResponse[CalcReceiptOut],
summary="重算回款",
)
def recalc_receipt(
receipt_id: Annotated[int, Path(ge=1)],
payload: RecalcIn,
session: DbSession,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]:
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
return ok(
calc_service.recalc_receipt(
session, receipt_id, payload.reason, operator=current_user, meta=meta
),
message="重算完成",
)
@router.post("/batch", response_model=ApiResponse[list[dict]], summary="批量核算")
def batch_calc(
payload: BatchIn,
session: DbSession,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[list[dict]]:
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
return ok(
calc_service.batch_calc(session, payload.receipt_ids, operator=current_user, meta=meta),
message="批量核算完成",
)
+52
View File
@@ -0,0 +1,52 @@
"""工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.api.deps import DbSession, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut
from app.services import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["工作台"])
_DashboardUser = Annotated[
User,
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台")),
]
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块")
def get_dashboard(
session: DbSession,
current_user: _DashboardUser,
month: Annotated[
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None,
) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
最近动态是分页列表,走 `GET /dashboard/activities`。
"""
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user))
@router.get("/activities", response_model=ApiResponse[ActivityPageOut], summary="最近动态(分页)")
def list_dashboard_activities(
session: DbSession,
current_user: _DashboardUser,
query: Annotated[ActivityQuery, Query()],
) -> ApiResponse[ActivityPageOut]:
"""分页查询最近动态,可按模块筛选。
与聚合端点拆开:翻页只重取这张列表,不必重算总览/待办/预警。
"""
return ok(dashboard_service.list_activities(session, query))
+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)
)
+191
View File
@@ -0,0 +1,191 @@
"""报表统计接口(M4 ⑤):四张报表只读查询 + Excel 导出。
准入:管理员/财务/领导(FR-030/031 明确;FR-029 的营销后勤「授权范围内」
待权限清单 #6,暂不开放)。前后端双层:前端 allowedRoles 控菜单,本层
require_roles 兜底。
导出返回文件流(非统一信封),沿用参数导出先例:服务端文件名为 ASCII
(HTTP 头按 latin-1 编码,中文名会 UnicodeEncodeError),中文名由前端
``link.download`` 指定。
"""
from datetime import date
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ValidationError
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.exceptions import BadRequestError
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.report import (
BusinessStatsQuery,
BusinessStatsRow,
DepartmentSummaryOut,
DepartmentSummaryQuery,
PaymentDetailQuery,
PaymentDetailRow,
PaymentStatsOut,
PaymentStatsQuery,
PersonalDetailQuery,
PersonalDetailRow,
ReportPage,
)
from app.services import report_service
router = APIRouter(prefix="/reports", tags=["绩效工资 · 报表统计"])
_ReportViewer = Annotated[
User,
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计")),
]
@router.get(
"/personal-detail",
response_model=ApiResponse[ReportPage[PersonalDetailRow]],
summary="个人绩效工资明细表",
)
def personal_detail(
session: DbSession,
_: _ReportViewer,
query: Annotated[PersonalDetailQuery, Query()],
) -> ApiResponse[ReportPage[PersonalDetailRow]]:
"""按人员/月份/客户类型/产品类别/岗位查明细(口径与台账一致)。"""
return ok(report_service.list_personal_detail(session, query))
@router.get(
"/department-summary",
response_model=ApiResponse[DepartmentSummaryOut],
summary="部门绩效工资汇总表",
)
def department_summary(
session: DbSession,
_: _ReportViewer,
query: Annotated[DepartmentSummaryQuery, Query()],
) -> ApiResponse[DepartmentSummaryOut]:
"""按经理团队/月份汇总(团队基数与月结聚合行同源)。"""
return ok(report_service.department_summary(session, query))
@router.get(
"/business-stats",
response_model=ApiResponse[ReportPage[BusinessStatsRow]],
summary="业务统计表",
)
def business_stats(
session: DbSession,
_: _ReportViewer,
query: Annotated[BusinessStatsQuery, Query()],
) -> ApiResponse[ReportPage[BusinessStatsRow]]:
"""按业务查询发货、回款、绩效(回款/绩效为业务累计值)。"""
return ok(report_service.business_stats(session, query))
@router.get(
"/payment-stats",
response_model=ApiResponse[PaymentStatsOut],
summary="回款情况统计表",
)
def payment_stats(
session: DbSession,
_: _ReportViewer,
query: Annotated[PaymentStatsQuery, Query()],
) -> ApiResponse[PaymentStatsOut]:
"""按回款天数区间/客户类型/产品类别统计(空桶恒在且为 0)。"""
return ok(report_service.payment_stats(session, query))
@router.get(
"/payment-detail",
response_model=ApiResponse[ReportPage[PaymentDetailRow]],
summary="回款区间下钻",
)
def payment_detail(
session: DbSession,
_: _ReportViewer,
query: Annotated[PaymentDetailQuery, Query()],
) -> ApiResponse[ReportPage[PaymentDetailRow]]:
"""区间桶下钻到单笔台账行(含回款金额与批次号)。"""
return ok(report_service.payment_detail(session, query))
@router.get(
"/payment-day-buckets", response_model=ApiResponse[list[dict]], summary="回款天数区间档位"
)
def payment_day_buckets(_: _ReportViewer) -> ApiResponse[list[dict]]:
"""区间档位元数据(前端渲染下拉/表头,禁止前端硬编码档位)。"""
return ok(report_service.get_payment_day_buckets())
class ExportQuery(BaseModel):
"""导出通用筛选参数(按报表编码取用相关字段,未用字段忽略)。"""
settlement_month: str | None = None
personnel_id: int | None = None
customer_type: str | None = None
product_category: str | None = None
role_code: str | None = None
manager_id: int | None = None
keyword: str | None = None
customer_id: int | None = None
owner_personnel_id: int | None = None
status: int | None = None
date_from: date | None = None
date_to: date | None = None
@router.get("/{code}/export", summary="导出报表 Excel")
def export_report(
code: Annotated[
Literal["personal-detail", "department-summary", "business-stats", "payment-stats"],
Path(description="报表编码"),
],
session: DbSession,
current_user: _ReportViewer,
meta: RequestMetaDep,
query: Annotated[ExportQuery, Query()],
) -> StreamingResponse:
"""导出 xlsx(含公司页眉/财务复核栏/领导签字栏),行为入操作日志。"""
content, filename = report_service.export_report(
session,
code,
_build_export_query(code, query),
operator=current_user,
meta=meta,
)
return StreamingResponse(
iter([content]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": (f'attachment; filename="{filename}_{date.today():%Y%m%d}.xlsx"')
},
)
def _build_export_query(code: str, query: ExportQuery):
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。
手工构造模型抛出的是 `pydantic.ValidationError`,不是 FastAPI 的
`RequestValidationError`——不在这里转换就会落到全局 500。导出参数
非法与查询接口一样应当是 400。
"""
data = query.model_dump()
model = {
"personal-detail": PersonalDetailQuery,
"department-summary": DepartmentSummaryQuery,
"business-stats": BusinessStatsQuery,
"payment-stats": PaymentStatsQuery,
}[code]
try:
return model(**data)
except ValidationError as exc:
first = exc.errors()[0]
location = ".".join(str(part) for part in first.get("loc", ()))
message = first.get("msg", "参数错误")
detail = f"{location} {message}" if location else message
raise BadRequestError(f"参数错误:{detail}") from exc
+96
View File
@@ -0,0 +1,96 @@
"""月度结算接口(M3 ④):生成/提交审批/锁定与调整单。
准入:读=登录即可;写(生成/提交/调整单)=管理员与营销后勤
(权限清单待澄清 #6,答复后细化)。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.settlement import (
AdjustmentCreateIn,
SettlementDetailOut,
SettlementGenerateIn,
SettlementOut,
SettlementQuery,
)
from app.services import settlement_service
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/settlements", tags=["绩效工资 · 月度结算"])
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
def generate(
payload: SettlementGenerateIn,
session: DbSession,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]:
"""按确认回款口径生成指定月份的结算快照(草稿)。"""
return ok(
settlement_service.generate(session, payload.month, operator=current_user, meta=meta),
message="月结已生成",
)
@router.get("", response_model=ApiResponse[PageResult[SettlementOut]], summary="月结列表")
def list_settlements(
session: DbSession,
query: Annotated[SettlementQuery, Query()],
) -> ApiResponse[PageResult[SettlementOut]]:
"""分页查询月结(按月份倒序)。"""
return ok(settlement_service.list_page(session, query))
@router.get("/{settlement_id}", response_model=ApiResponse[SettlementDetailOut], summary="月结详情")
def get_settlement(
session: DbSession,
settlement_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[SettlementDetailOut]:
"""月结详情(含按人明细)。"""
return ok(settlement_service.get_detail(session, settlement_id))
@router.post(
"/{settlement_id}/submit",
response_model=ApiResponse[SettlementDetailOut],
summary="提交审批",
)
def submit(
settlement_id: Annotated[int, Path(ge=1)],
session: DbSession,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]:
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
return ok(
settlement_service.submit(session, settlement_id, operator=current_user, meta=meta),
message="已提交",
)
# ==================== 调整单(已锁定月份的补发/扣减) ====================
@router.post("/adjustments", response_model=ApiResponse[dict], summary="新建调整单")
def create_adjustment(
payload: AdjustmentCreateIn,
session: DbSession,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[dict]:
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
return ok(
settlement_service.create_adjustment(session, payload, operator=current_user, meta=meta),
message="调整单已创建",
)
@router.get("/adjustments", response_model=ApiResponse[list[dict]], summary="调整单列表")
def list_adjustments(session: DbSession) -> ApiResponse[list[dict]]:
"""调整单列表(一期不分页,内部系统量级有限)。"""
return ok(settlement_service.list_adjustments(session))
+67
View File
@@ -0,0 +1,67 @@
"""审批管理接口(2026-10-15 复核审批补充需求)。
准入:admin/财务/领导可见(营销后勤暂不可见——权限清单待澄清 #6,
答复后放开为「提交人可见本人单据」)。节点级准入由 service 按单据
状态二次校验(status=1 仅财务、status=2 仅领导,admin 不代办)。
提单不经本模块:由业务写路径(创建/编辑带二次分配方案)自动触发。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.approval import (
ApprovalActionIn,
ApprovalOrderDetailOut,
ApprovalOrderOut,
ApprovalQuery,
)
from app.services import approval_service
router = APIRouter(prefix="/approvals", tags=["系统管理 · 审批管理"])
# 模块准入:可见集合(节点准入在 service 层按状态二次判定)
_ApprovalUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER))]
@router.get("", response_model=ApiResponse[PageResult[ApprovalOrderOut]], summary="审批单列表")
def list_orders(
session: DbSession,
_: _ApprovalUser,
query: Annotated[ApprovalQuery, Query()],
) -> ApiResponse[PageResult[ApprovalOrderOut]]:
"""分页查询审批单。"""
return ok(approval_service.list_orders(session, query))
@router.get("/{order_id}", response_model=ApiResponse[ApprovalOrderDetailOut], summary="审批详情")
def get_order(
session: DbSession,
_: _ApprovalUser,
order_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ApprovalOrderDetailOut]:
"""审批单详情(含节点时间线)。"""
return ok(approval_service.get_order(session, order_id))
@router.post(
"/{order_id}/action",
response_model=ApiResponse[ApprovalOrderDetailOut],
summary="节点动作(通过/驳回)",
)
def act_on_order(
payload: ApprovalActionIn,
session: DbSession,
current_user: _ApprovalUser,
meta: RequestMetaDep,
order_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ApprovalOrderDetailOut]:
"""财务复核 / 领导审核;一驳全驳,驳回须填意见。"""
return ok(
approval_service.act(session, order_id, payload, operator=current_user, meta=meta),
message="已提交",
)
@@ -0,0 +1,56 @@
"""角色权限配置与用户角色分配;旧制作人设置只保留兼容提示。"""
from fastapi import APIRouter
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ok
from app.schemas.authorization import (
AuthorizationPreviewIn,
AuthorizationSettingsIn,
RolePermissionsIn,
UserAuthorizationIn,
)
from app.services import authorization_management as service
from app.services.permission_catalog import CATALOG
router = APIRouter(prefix="/system", tags=["系统管理 · 权限"])
@router.get("/permissions/catalog")
def catalog(session: DbSession, actor: AdminUser):
service.require_admin(session, actor)
return ok(list(CATALOG.values()))
@router.get("/roles/{role_id}/permissions")
def role_permissions(role_id: int, session: DbSession, actor: AdminUser):
return ok(service.role_permissions(session, role_id))
@router.put("/roles/{role_id}/permissions")
def save_role(role_id: int, payload: RolePermissionsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_role(session, role_id, payload, actor, meta))
@router.get("/users/{user_id}/authorization")
def user_authorization(user_id: int, session: DbSession, actor: AdminUser):
return ok(service.user_authorization(session, user_id))
@router.post("/users/{user_id}/authorization/preview")
def preview(user_id: int, payload: AuthorizationPreviewIn, session: DbSession, actor: AdminUser):
return ok(service.preview(session, user_id, payload))
@router.put("/users/{user_id}/authorization")
def save_user(user_id: int, payload: UserAuthorizationIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_user(session, user_id, payload, actor, meta))
@router.get("/authorization-settings")
def settings(session: DbSession, actor: AdminUser):
return ok(service.settings_out(session))
@router.put("/authorization-settings")
def save_settings(payload: AuthorizationSettingsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_settings(session, payload, actor, meta))
+1 -1
View File
@@ -60,7 +60,7 @@ def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。 定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。
""" """
return ok(business_role_service.list_template_options()) return ok(business_role_service.list_template_options(session))
@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情") @router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")
@@ -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",
},
)
+12 -11
View File
@@ -1,8 +1,9 @@
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。 """计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读); 权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读
写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志, 端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作
变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。 (提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作
日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
导出为文件流(非统一信封),前端以 blob 接收。 导出为文件流(非统一信封),前端以 blob 接收。
""" """
@@ -12,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Path, Query from fastapi import APIRouter, Path, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.api.deps import AdminUser, DbSession, RequestMetaDep from app.api.deps import AdminUser, DbSession, LoginUser, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok from app.core.response import ApiResponse, PageResult, ok
from app.schemas.param import ( from app.schemas.param import (
ParamBatchDetailOut, ParamBatchDetailOut,
@@ -37,7 +38,7 @@ router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
) )
def get_catalog( def get_catalog(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None, as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
) -> ApiResponse[list[ParamCatalogItemOut]]: ) -> ApiResponse[list[ParamCatalogItemOut]]:
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。""" """全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
@@ -63,7 +64,7 @@ def submit_change(
) )
def list_changes( def list_changes(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
query: Annotated[ParamBatchQuery, Query()], query: Annotated[ParamBatchQuery, Query()],
) -> ApiResponse[PageResult[ParamBatchOut]]: ) -> ApiResponse[PageResult[ParamBatchOut]]:
"""按生效日期区间分页查询变更批次(创建时间倒序)。""" """按生效日期区间分页查询变更批次(创建时间倒序)。"""
@@ -75,7 +76,7 @@ def list_changes(
) )
def get_batch( def get_batch(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
batch_id: Annotated[int, Path(ge=1)], batch_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ParamBatchDetailOut]: ) -> ApiResponse[ParamBatchDetailOut]:
"""批次头 + 逐项旧值 → 新值。""" """批次头 + 逐项旧值 → 新值。"""
@@ -97,7 +98,7 @@ def withdraw_batch(
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比") @router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
def compare( def compare(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
from_date: Annotated[date, Query(description="起始业务日期(含)")], from_date: Annotated[date, Query(description="起始业务日期(含)")],
to_date: Annotated[date, Query(description="截止业务日期(含)")], to_date: Annotated[date, Query(description="截止业务日期(含)")],
) -> ApiResponse[list[ParamDiffOut]]: ) -> ApiResponse[list[ParamDiffOut]]:
@@ -108,7 +109,7 @@ def compare(
@router.get("/export", summary="导出变更记录 Excel") @router.get("/export", summary="导出变更记录 Excel")
def export_changes( def export_changes(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None, start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None, end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
) -> StreamingResponse: ) -> StreamingResponse:
@@ -125,7 +126,7 @@ def export_changes(
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览") @router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
def preview( def preview(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
payload: ParamPreviewIn, payload: ParamPreviewIn,
) -> ApiResponse[ParamPreviewOut]: ) -> ApiResponse[ParamPreviewOut]:
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。""" """输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
@@ -139,7 +140,7 @@ def preview(
) )
def get_history( def get_history(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
param_code: Annotated[str, Path(max_length=64)], param_code: Annotated[str, Path(max_length=64)],
query: Annotated[ParamHistoryQuery, Query()], query: Annotated[ParamHistoryQuery, Query()],
) -> ApiResponse[PageResult[ParamHistoryOut]]: ) -> ApiResponse[PageResult[ParamHistoryOut]]:
+83
View File
@@ -0,0 +1,83 @@
"""系统设置接口(公司信息)。
`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),
仅暴露两个非敏感展示字段。
`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径
(二进制响应套 JSON 信封无意义)。
"""
from fastapi import APIRouter, Response, UploadFile
from fastapi.responses import FileResponse
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.exceptions import BadRequestError
from app.core.response import ApiResponse, ok
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
from app.services import system_setting_service as service
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
"""取公司信息(管理端)。"""
return ok(service.to_out(service.get_setting(session)))
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称")
def update_settings(
payload: SettingUpdateIn,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""更新公司名称(审计留痕)。"""
return ok(
service.update_company(session, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.post("/logo", response_model=ApiResponse[SettingOut], summary="上传 logo")
def upload_logo(
file: UploadFile,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。
同步 `def`:读文件、磁盘写与审计提交都是阻塞操作,async 路由里会让
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
"""
if file.size is not None and file.size > service.LOGO_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
content = file.file.read()
ext = (file.filename or "").rsplit(".", 1)[-1]
return ok(
service.save_logo(session, content, ext, operator=current_user, meta=meta),
message="logo 已更新",
)
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
response.headers["Cache-Control"] = "public, max-age=300"
return ok(service.to_public_out(service.get_setting(session)))
@router.get("/logo/public", summary="logo 图片(匿名)")
def get_logo_public(session: DbSession) -> FileResponse:
"""logo 文件流(无鉴权)。no-cache:图片替换后由版本参数破缓存兜底。"""
path, ext = service.read_logo(session)
return FileResponse(
path,
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
headers={"Cache-Control": "no-cache"},
)
+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"'},
)
+179
View File
@@ -10,14 +10,25 @@
import argparse import argparse
import getpass import getpass
import json
import signal
import sys import sys
import threading
from datetime import datetime
from pathlib import Path
from uuid import uuid4
from sqlalchemy import select from sqlalchemy import select
from app.core.config import get_settings
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
from app.core.db import dispose_engine, get_session_factory from app.core.db import dispose_engine, get_session_factory
from app.core.security import hash_password from app.core.security import hash_password
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
from app.models import Role, User, UserRole from app.models import Role, User, UserRole
from app.repositories.tplus_repository import get_batch
from app.services.tplus.sync_service import TPlusSyncService
from app.services.tplus.worker import run_once
def _prompt_password() -> str: def _prompt_password() -> str:
@@ -80,6 +91,115 @@ def _create_admin(args: argparse.Namespace) -> int:
return 0 return 0
def _stage_tplus(args: argparse.Namespace) -> int:
"""读取 T+ 源库并写入 MySQL staging。"""
target_factory = get_session_factory()
source_factory = get_tplus_session_factory()
watermark = datetime.fromisoformat(args.watermark) if args.watermark else None
with source_factory() as source_session, target_factory() as target_session:
batch = TPlusSyncService(source_session, target_session).run(
entity_types=args.entity,
watermark=watermark,
batch_size=args.batch_size,
)
print(
f"T+ staging 完成:批次={batch.batch_no} 状态={batch.status} "
f"源记录={batch.source_record_count} 新增={batch.staged_record_count} "
f"失败={batch.failed_record_count}"
)
return 0 if batch.status == "SUCCEEDED" else 1
def _tplus_worker(args: argparse.Namespace) -> int:
"""独立消费持久化任务;HTTP 进程不承担长时间读取。"""
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
if not 2 <= args.poll_seconds <= 60:
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
return 1
factory = get_session_factory()
settings = get_settings()
stop_file = getattr(args, "stop_file", None) or settings.t_plus_stop_file
stopped = threading.Event()
owner = str(uuid4())
previous_handlers = {}
if threading.current_thread() is threading.main_thread():
for sig in (signal.SIGINT, signal.SIGTERM):
previous_handlers[sig] = signal.signal(sig, lambda *_: stopped.set())
try:
while not stopped.is_set() and not (stop_file and Path(stop_file).exists()):
with factory() as session:
batch_no = run_once(session, worker_owner=owner)
batch = get_batch(session, batch_no) if batch_no else None
if batch_no and batch is None:
from app.models import TPlusWorkRun
batch = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == batch_no))
status = batch.status if batch else None
if batch_no:
print(f"任务完成:{batch_no} 状态={status}", flush=True)
if not args.watch:
return 0 if status in (None, "SUCCEEDED") else 1
if batch_no is None or status != "SUCCEEDED":
stopped.wait(args.poll_seconds)
print("T+读取服务已停止领取新任务", flush=True)
return 0
except KeyboardInterrupt:
print("T+ 读取服务已停止")
return 130
except Exception as exc:
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
return 1
finally:
for sig, handler in previous_handlers.items():
signal.signal(sig, handler)
try:
from app.services.tplus.automation import heartbeat
heartbeat(factory, settings, owner, "STOPPED")
except Exception as exc:
print(f"T+停止状态未能写入({type(exc).__name__}),请检查迁移与运行配置", file=sys.stderr)
def _maintenance(args):
from app.services.tplus.automation import tick
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
if args.stop_file:
get_settings().t_plus_stop_file = args.stop_file
with get_session_factory()() as session:
result = tick(session)
print(json.dumps(result, ensure_ascii=False))
return 0
def _tplus_supervisor(args):
from app.services.tplus.supervisor import run
return run(get_session_factory(), get_settings(), interval=args.poll_seconds)
def _materials(args):
from app.services.tplus.materials import collect_materials, render_markdown
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
output = Path(args.output_dir).resolve()
targets = [output / f"materials-{args.customer_id}-{args.month}.{ext}" for ext in ("json", "md")]
if any(p.exists() for p in targets):
raise RuntimeError("核对包已存在,请指定新的输出目录,避免覆盖既有验收依据")
with get_tplus_session_factory()() as source:
report = collect_materials(source, args.customer_id, args.month)
output.mkdir(parents=True, exist_ok=True)
targets[0].write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
targets[1].write_text(render_markdown(report), encoding="utf-8")
print(f"样本核对包已生成;待补{len(report['missing'])}项;真实金额尚未验收:{output}")
return 0
def main() -> int: def main() -> int:
"""命令行入口。""" """命令行入口。"""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -96,12 +216,71 @@ def main() -> int:
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中", help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
) )
p_tplus = sub.add_parser("tplus-stage", help="读取 T+ 源库并写入原始 staging")
p_tplus.add_argument(
"--entity",
action="append",
choices=[
"personnel",
"products",
"customers",
"businesses",
"shipments",
"receipts",
"business_items",
"shipment_items",
"receipt_items",
],
required=True,
help="实体类型,可重复指定",
)
p_tplus.add_argument(
"--watermark",
default=None,
help="增量起始时间,ISO 8601 格式;不传表示全量读取",
)
p_tplus.add_argument("--batch-size", type=int, default=500, help="每次从源库读取的行数")
p_worker = sub.add_parser("tplus-worker", help="执行已排队的 T+ 读取或关联核验")
modes = p_worker.add_mutually_exclusive_group()
modes.add_argument("--once", action="store_true", help="执行一个任务后退出(默认)")
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
p_worker.add_argument("--stop-file", default=None, help="检测到停止标记后不再领取新任务")
p_maintenance = sub.add_parser("tplus-maintain", help="检查每周原始采集与有限重试,不生成工资")
p_maintenance.add_argument("--once", action="store_true", help="执行一次检查(默认)")
p_maintenance.add_argument("--connection-file", default=None)
p_maintenance.add_argument("--stop-file", default=None)
p_supervisor = sub.add_parser("tplus-supervisor", help="按页面设置托管 T+ 读取服务")
p_supervisor.add_argument("--poll-seconds", type=int, choices=range(2, 61), default=5)
p_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资")
p_materials.add_argument("--customer-id", type=int, required=True)
p_materials.add_argument("--month", required=True)
p_materials.add_argument("--connection-file", default=None)
p_materials.add_argument("--output-dir", required=True)
args = parser.parse_args() args = parser.parse_args()
try: try:
if args.command == "create-admin":
return _create_admin(args) return _create_admin(args)
if args.command == "tplus-worker":
return _tplus_worker(args)
if args.command == "tplus-maintain":
return _maintenance(args)
if args.command == "tplus-supervisor":
return _tplus_supervisor(args)
if args.command == "tplus-materials":
return _materials(args)
return _stage_tplus(args)
except Exception as exc:
print(f"命令执行失败({type(exc).__name__}),请检查参数和只读连接配置", file=sys.stderr)
return 1
finally: finally:
dispose_engine() dispose_engine()
dispose_tplus_engine()
if __name__ == "__main__": if __name__ == "__main__":
+6 -5
View File
@@ -27,11 +27,12 @@ _WIDTH, _HEIGHT = 120, 40
_FONT_SIZE = 25 _FONT_SIZE = 25
_PADDING = 8 _PADDING = 8
# 与前端配色呼应:墨色字符、青灰干扰、素纸底 # 与前端 v3 配色呼应(--ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper):
_INK = (15, 54, 70) # 墨色字符、次级墨干扰、辅助墨噪点、纸白底
_INTERFERENCE = (126, 156, 170) _INK = (28, 35, 51) # #1c2333
_SPECKLE = (168, 183, 192) _INTERFERENCE = (91, 101, 119) # #5b6577
_PAPER = (247, 246, 242) _SPECKLE = (154, 163, 178) # #9aa3b2
_PAPER = (255, 255, 255) # #ffffff
_SPECKLE_COUNT = 46 _SPECKLE_COUNT = 46
_INTERFERENCE_LINES = 2 _INTERFERENCE_LINES = 2
+84 -4
View File
@@ -11,6 +11,7 @@
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from urllib.parse import quote
from pydantic import Field, field_validator from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -35,7 +36,28 @@ class Settings(BaseSettings):
# ===== 应用 ===== # ===== 应用 =====
app_env: str = "dev" app_env: str = "dev"
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符") upload_root: Path | None = Field(default=None, validation_alias="UPLOAD_DIR")
@field_validator("upload_root", mode="before")
@classmethod
def _empty_upload_root(cls, value):
return None if value in (None, "") else value
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符", repr=False)
payroll_maker_user_id: int | None = Field(
default=None, ge=1, description="指定工资制作人的现有账号ID;未设置则业务只读"
)
payroll_allow_legacy_calculation: bool = False
payroll_approval_enabled: bool = False
# ===== 单进程运行监控;历史只清理监控新表 =====
monitor_enabled: bool = True
monitor_cpu_threshold: float = Field(default=85, ge=1, le=100)
monitor_memory_threshold: float = Field(default=85, ge=1, le=100)
monitor_disk_threshold: float = Field(default=90, ge=1, le=100)
monitor_resource_duration_seconds: int = Field(default=300, ge=15)
monitor_slow_request_ms: int = Field(default=2000, ge=1)
monitor_error_rate_threshold: float = Field(default=5, ge=0.1, le=100)
monitor_error_min_requests: int = Field(default=20, ge=1)
# ===== 鉴权 ===== # ===== 鉴权 =====
# 规范第 13 节:Token 有效期 ≤ 2 小时 # 规范第 13 节:Token 有效期 ≤ 2 小时
@@ -50,14 +72,39 @@ class Settings(BaseSettings):
mysql_port: int = 3306 mysql_port: int = 3306
mysql_database: str = "payroll" mysql_database: str = "payroll"
mysql_user: str = "payroll" mysql_user: str = "payroll"
mysql_password: str = "" mysql_password: str = Field(default="", repr=False)
# ===== Redis ===== # ===== Redis =====
redis_host: str = "127.0.0.1" redis_host: str = "127.0.0.1"
redis_port: int = 6379 redis_port: int = 6379
redis_password: str = "" redis_password: str = Field(default="", repr=False)
redis_db: int = 0 redis_db: int = 0
# ===== T+ 只读 SQL Server =====
# T+ 数据库默认关闭,避免开发者未配置驱动时影响主业务启动。
t_plus_enabled: bool = False
t_plus_host: str = "127.0.0.1"
t_plus_port: int = 1433
t_plus_database: str = ""
t_plus_user: str = ""
t_plus_password: str = Field(default="", repr=False)
t_plus_allow_dev_sa: bool = False
t_plus_allow_admin_account: bool = False
t_plus_connection_file: str = ""
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
t_plus_odbc_driver: str = ""
t_plus_encrypt: bool = True
# 默认校验服务器证书;自签回退只允许隔离开发显式开启,避免中间人替换证书。
t_plus_trust_server_certificate: bool = False
t_plus_connect_timeout: int = 10
t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
t_plus_query_dir: str = ""
t_plus_automation_enabled: bool = False
t_plus_schedule_weekday: int = Field(default=0, ge=0, le=6)
t_plus_schedule_time: str = Field(default="02:00", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
t_plus_stop_file: str = ""
t_plus_supervisor_health_url: str = "http://127.0.0.1:8000/api/health"
@field_validator("app_secret_key") @field_validator("app_secret_key")
@classmethod @classmethod
def _check_secret_key(cls, v: str) -> str: def _check_secret_key(cls, v: str) -> str:
@@ -89,9 +136,42 @@ class Settings(BaseSettings):
@property @property
def redis_url(self) -> str: def redis_url(self) -> str:
"""Redis 连接 URL。密码为空时不拼接认证段。""" """Redis 连接 URL。密码为空时不拼接认证段。"""
auth = f":{self.redis_password}@" if self.redis_password else "" auth = f":{quote(self.redis_password, safe='')}@" if self.redis_password else ""
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}" return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
@property
def t_plus_database_url(self) -> URL:
"""构造 T+ 只读 SQL Server 连接 URL。
连接 URL 只在启用 T+ 适配器时使用;密码由 SQLAlchemy URL 对象保存,
日志中调用 ``render_as_string()`` 时仍会默认隐藏密码。
ODBC 的分号和花括号由方言统一转义,调用方不得再手工拼接连接串。
"""
query = {
"driver": self.t_plus_odbc_driver,
"Encrypt": "yes" if self.t_plus_encrypt else "no",
"TrustServerCertificate": "yes" if self.t_plus_trust_server_certificate else "no",
"Connection Timeout": str(self.t_plus_connect_timeout),
}
return URL.create(
drivername="mssql+pyodbc",
username=self.t_plus_user,
password=self.t_plus_password,
host=self.t_plus_host,
port=self.t_plus_port,
database=self.t_plus_database,
query=query,
)
@property
def upload_dir(self) -> Path:
"""上传文件根目录(公司 logo 等):backend/uploads/。
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
"""
return self.upload_root.resolve() if self.upload_root else BACKEND_DIR / "uploads"
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+47
View File
@@ -25,6 +25,19 @@ TOKEN_TYPE_BEARER = "Bearer"
# ===== 角色编码(与 db/migration 初始化数据保持一致)===== # ===== 角色编码(与 db/migration 初始化数据保持一致)=====
ROLE_ADMIN = "admin" ROLE_ADMIN = "admin"
ROLE_USER = "user" ROLE_USER = "user"
# 2026-10-15 复核审批补充需求新增职能登录角色:营销后勤(提交+录入)、
# 财务(复核)、领导(审核)。roles 表无 is_builtin 列,内置保护用代码
# 白名单判定(role_service 两处内置校验改查此集合);
# permissions/role_permissions 细粒度激活待业务权限清单(待澄清 #6)。
ROLE_MARKETING_OPS = "marketing_ops"
ROLE_FINANCE = "finance"
ROLE_LEADER = "leader"
BUILTIN_ROLE_CODES = {
ROLE_ADMIN,
ROLE_MARKETING_OPS,
ROLE_FINANCE,
ROLE_LEADER,
}
# ===== 密码策略 ===== # ===== 密码策略 =====
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」, # bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
@@ -47,12 +60,43 @@ PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit" PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT} PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT}
# ===== 客户分类码(customers.customer_type,计提规则匹配锚点)=====
CUSTOMER_TYPE_PERSONAL = "personal"
CUSTOMER_TYPE_MANAGER_PERSONAL = "manager_personal"
CUSTOMER_TYPE_ENTERPRISE = "enterprise"
CUSTOMER_TYPES = {
CUSTOMER_TYPE_PERSONAL,
CUSTOMER_TYPE_MANAGER_PERSONAL,
CUSTOMER_TYPE_ENTERPRISE,
}
# ===== 操作日志词表(operation_logs 的 module / action 取值)===== # ===== 操作日志词表(operation_logs 的 module / action 取值)=====
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致 # 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
MODULE_USER = "用户管理" MODULE_USER = "用户管理"
MODULE_ROLE = "角色管理" MODULE_ROLE = "角色管理"
MODULE_BUSINESS_ROLE = "岗位角色" MODULE_BUSINESS_ROLE = "岗位角色"
MODULE_PARAM = "计提参数" MODULE_PARAM = "计提参数"
MODULE_PERSONNEL = "人员管理"
MODULE_PRODUCT = "产品管理"
MODULE_CUSTOMER = "客户管理"
MODULE_BUSINESS = "业务管理"
MODULE_SHIPMENT = "发货管理"
MODULE_RECEIPT = "回款管理"
MODULE_RATING = "客户评级"
MODULE_SYSTEM_SETTINGS = "系统设置"
MODULE_APPROVAL = "审批管理"
MODULE_CALC = "绩效核算"
MODULE_SETTLEMENT = "月度结算"
MODULE_REPORT = "报表统计"
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
NO_PREFIX_BUSINESS = "YW"
NO_PREFIX_SHIPMENT = "SH"
NO_PREFIX_RECEIPT = "HK"
NO_PREFIX_PARAM = "P"
NO_PREFIX_APPROVAL = "AP"
NO_PREFIX_ADJUSTMENT = "TZ"
ACTION_CREATE = "新增" ACTION_CREATE = "新增"
ACTION_UPDATE = "修改" ACTION_UPDATE = "修改"
@@ -60,3 +104,6 @@ ACTION_DELETE = "删除"
ACTION_RESET_PASSWORD = "重置密码" ACTION_RESET_PASSWORD = "重置密码"
ACTION_ENABLE = "启用" ACTION_ENABLE = "启用"
ACTION_DISABLE = "禁用" ACTION_DISABLE = "禁用"
ACTION_APPROVE = "审批通过"
ACTION_REJECT = "审批驳回"
ACTION_EXPORT = "导出"
+18
View File
@@ -0,0 +1,18 @@
"""数据库完整性错误的统一识别与翻译口径。
判断依据:pymysql 原生异常 ``orig.args`` 首位是 MySQL 错误码 —— 只有
1062(ER_DUP_ENTRY,唯一键冲突)是「已存在」语义,可安全转 400 业务
提示;其余完整性错误(外键 RESTRICT、数据截断等)原样上抛,由全局
异常处理器收敛 500(程序缺陷或数据异常,均不应伪装成用户错误)。
"""
from sqlalchemy.exc import IntegrityError
# MySQL 唯一键冲突错误码(pymysql 原生异常 args 首位)
ERR_DUP_ENTRY = 1062
def is_duplicate_entry(exc: IntegrityError) -> bool:
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。"""
args = getattr(exc.orig, "args", ())
return bool(args) and args[0] == ERR_DUP_ENTRY
+21 -3
View File
@@ -12,6 +12,7 @@ import logging
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
@@ -60,13 +61,29 @@ async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSON
return _build_response(exc.status_code, message) return _build_response(exc.status_code, message)
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse: async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONResponse:
"""完整性错误全局兜底为 400。
service 层已判别的 1062/引用冲突会自行翻译为业务提示,不会走到这里;
本 handler 拦截的是「新增父表后某 service 漏补引用保护」一类的遗漏
(e2e 撞见一个补一个的模式无法保证全覆盖):FK RESTRICT 等数据完整性
冲突对用户表现为「这行数据被别处引用/冲突」,转 400 比 500 更贴近事实。
完整异常记录日志供排查;不在此处 rollback —— 会话由请求级依赖统一关闭。
"""
logger.error("数据库完整性冲突(未在 service 层翻译)", exc_info=exc)
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse:
"""兜底处理未预期异常。 """兜底处理未预期异常。
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。 对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
""" """
logger.exception("未预期的服务端异常") request_id = getattr(request.state, "request_id", "-")
return _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试") logger.exception("未预期的服务端异常", extra={"request_id": request_id})
response = _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
response.headers["X-Request-ID"] = request_id
return response
def register_exception_handlers(app: FastAPI) -> None: def register_exception_handlers(app: FastAPI) -> None:
@@ -77,4 +94,5 @@ def register_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type] app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type] app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type] app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type]
app.add_exception_handler(Exception, handle_unexpected_exception) app.add_exception_handler(Exception, handle_unexpected_exception)
+7
View File
@@ -52,3 +52,10 @@ class NotFoundError(AppException):
code = CODE_NOT_FOUND code = CODE_NOT_FOUND
message = "资源不存在" message = "资源不存在"
class ConflictError(AppException):
"""授权版本已变化,客户端应刷新后重新核对。"""
code = 409
message = "配置已被其他人修改,请刷新后重新核对"
+5 -2
View File
@@ -1,6 +1,6 @@
"""日志配置。 """日志配置。
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天+大小切分、 规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天轮转(当前未做大小上限,磁盘容量需配合监控)、
保留 30 天、禁止输出密码/手机号/身份证/Token。 保留 30 天、禁止输出密码/手机号/身份证/Token。
Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8, Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8,
@@ -13,6 +13,7 @@ import sys
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from app.core.config import BACKEND_DIR from app.core.config import BACKEND_DIR
from app.core.monitoring import REQUEST_ID
LOG_DIR = BACKEND_DIR / "logs" LOG_DIR = BACKEND_DIR / "logs"
@@ -53,6 +54,8 @@ class SensitiveFormatter(logging.Formatter):
""" """
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
if not hasattr(record, "request_id"):
record.request_id = REQUEST_ID.get()
return scrub(super().format(record)) return scrub(super().format(record))
@@ -71,7 +74,7 @@ def setup_logging(level: str = "INFO", log_to_file: bool = True) -> None:
root.removeHandler(handler) root.removeHandler(handler)
formatter = SensitiveFormatter( formatter = SensitiveFormatter(
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", fmt="%(asctime)s | %(levelname)-8s | %(name)s | request_id=%(request_id)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S", datefmt="%Y-%m-%d %H:%M:%S",
) )
+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)
+26
View File
@@ -0,0 +1,26 @@
"""月份参数校验(YYYY-MM)——叶子模块。
报表、月结与调整单共用同一套校验:只校验长度会放过 `2026-13` 这类
非法月份,一路传到 `calendar.monthrange` 才抛 ValueError,最终被全局
处理器当成服务端异常(500)。月份范围必须在入口处拦住,才能返回 400。
"""
import re
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
def validate_month(value: str) -> str:
"""校验 YYYY-MM 且月份 01-12;非法抛 ValueError(Pydantic 友好)。
年份 0000 也拒绝:`date(0, ...)` 同样会抛 ValueError,且业务上不存在。
"""
if not _MONTH_RE.match(value) or int(value[:4]) == 0:
raise ValueError("月份格式须为 YYYY-MM(01-12)")
return value
def parse_month(value: str) -> tuple[int, int]:
"""校验并返回 (year, month),供服务层计算月末等使用。"""
validate_month(value)
return int(value[:4]), int(value[5:7])
+79
View File
@@ -0,0 +1,79 @@
"""报表统计元数据(区间桶、中文标签、导出上限)——叶子模块。
刻意不导入 app 内任何模块:report_service 与 report_export 都要消费这里的
定义,若它反向依赖 service 层会形成循环导入;也不走参数体系——FR-031
「区间可配置」一期以代码常量落地(参数化需注册表 + 迁移 + init-dev +
registry 测试四处同改,业务未提诉求前不做),配置缝收在
get_payment_day_buckets 单函数,未来在线可配时只改该函数。
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class DayBucket:
"""回款天数区间档位:min/max 均为 None 表示「无回款天数」兜底桶。"""
code: str
label: str
min_days: int | None
max_days: int | None
# 8 档区间(催办清单 R2 默认口径)。两个兜底桶必须保留:payment_days 可空
# (回款未指定发货批次)且可为负(先款后货照实负值),缺了它们这些行会
# 静默消失、金额对不上。
PAYMENT_DAY_BUCKETS: tuple[DayBucket, ...] = (
DayBucket("le0", "≤0 天(先款后货)", None, 0),
DayBucket("1-30", "1-30 天", 1, 30),
DayBucket("31-60", "31-60 天", 31, 60),
DayBucket("61-90", "61-90 天", 61, 90),
DayBucket("91-180", "91-180 天", 91, 180),
DayBucket("181-360", "181-360 天", 181, 360),
DayBucket("361+", "361 天以上", 361, None),
DayBucket("none", "无回款天数", None, None),
)
CUSTOMER_TYPE_LABELS: dict[str, str] = {
"personal": "个人户",
"manager_personal": "经理个人户",
"enterprise": "企业户",
}
# mixed 不在 core.constants.PRODUCT_CATEGORIES(那只是可维护的档案类别),
# 但台账行可以是 mixed(混合业务行级明细入 snapshot_json),报表必须能标它
PRODUCT_CATEGORY_LABELS: dict[str, str] = {
"municipal_water": "市政水利",
"rail_transit": "轨道交通",
"mixed": "混合业务",
}
# 业务状态中文标签:导出列、筛选页眉与前端标签共用同一份映射
BUSINESS_STATUS_LABELS: dict[int, str] = {1: "进行中", 2: "已完成", 3: "已终止"}
REPORT_TEMPLATE_VERSION = "V1.2"
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出
REPORT_EXPORT_MAX_ROWS = 20000
def get_payment_day_buckets() -> tuple[DayBucket, ...]:
"""区间档位唯一出口:前端下拉与分桶都以本函数返回为准,禁止硬编码。
未来「区间在线可配」时改此处读参数即可,调用方与前端零改动。
"""
return PAYMENT_DAY_BUCKETS
def bucket_of(payment_days: int | None) -> DayBucket:
"""把单笔回款天数落入对应区间桶;未匹配任何区间按「无回款天数」兜底。"""
if payment_days is None:
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")
for bucket in PAYMENT_DAY_BUCKETS:
if bucket.code == "none":
continue
lower_ok = bucket.min_days is None or payment_days >= bucket.min_days
upper_ok = bucket.max_days is None or payment_days <= bucket.max_days
if lower_ok and upper_ok:
return bucket
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")
+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()
+12 -1
View File
@@ -14,13 +14,16 @@ from app.core.config import get_settings
from app.core.db import dispose_engine from app.core.db import dispose_engine
from app.core.exception_handlers import register_exception_handlers from app.core.exception_handlers import register_exception_handlers
from app.core.logging import setup_logging from app.core.logging import setup_logging
from app.core.monitoring import MonitorMiddleware, Recorder
from app.core.redis_client import close_redis from app.core.redis_client import close_redis
from app.core.tplus_db import dispose_tplus_engine
from app.services.monitor_collector import MonitorCollector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期管理。 """应用生命周期管理。
启动阶段不建立数据库/Redis 连接(延迟到首次使用), 启动阶段不建立数据库/Redis 连接(延迟到首次使用),
@@ -33,8 +36,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
settings.mysql_host, settings.mysql_host,
settings.mysql_database, settings.mysql_database,
) )
app.state.monitor.start()
try:
yield yield
finally:
app.state.monitor.stop()
dispose_engine() dispose_engine()
dispose_tplus_engine()
close_redis() close_redis()
logger.info("应用已停止,连接池已释放") logger.info("应用已停止,连接池已释放")
@@ -60,6 +68,9 @@ def create_app() -> FastAPI:
register_exception_handlers(app) register_exception_handlers(app)
app.include_router(api_router, prefix="/api") app.include_router(api_router, prefix="/api")
recorder = Recorder()
app.state.monitor = MonitorCollector(settings, recorder)
app.add_middleware(MonitorMiddleware, recorder=recorder)
return app return app
+81
View File
@@ -9,26 +9,107 @@
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。 `Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
""" """
from app.models.approval import ApprovalOrder, ApprovalStep
from app.models.authorization import AuthorizationSetting, UserPermissionOverride
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole from app.models.business_role import BusinessRole
from app.models.calc_template import CalcTemplateDefinition
from app.models.customer import Customer, OwnershipChangeLog
from app.models.login_log import LoginLog from app.models.login_log import LoginLog
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
from app.models.operation_log import OperationLog from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
from app.models.payroll import PayrollAllocation, PayrollInput, PayrollMonth
from app.models.performance_ledger import PerformanceLedger
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
from app.models.probation import ProbationDeferral, ProbationPayment
from app.models.product import Product
from app.models.rating import CustomerRatingGrade
from app.models.receipt import Receipt
from app.models.role import Permission, Role, RolePermission from app.models.role import Permission, Role, RolePermission
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
from app.models.shipment import Shipment, ShipmentItem
from app.models.system_setting import SystemSetting
from app.models.tplus_automation import (
TPlusAutomationConfig,
TPlusAutomationState,
TPlusRuntimeControl,
)
from app.models.tplus_business_sync import TPlusBusinessDocument, TPlusBusinessSyncConfig
from app.models.tplus_sync import (
TPlusSyncBatch,
TPlusSyncError,
TPlusSyncRecord,
TPlusSyncWatermark,
)
from app.models.tplus_workbench import (
TPlusFact,
TPlusSourceHead,
TPlusSourceLink,
TPlusWorkLine,
TPlusWorkRun,
)
from app.models.user import User, UserRole from app.models.user import User, UserRole
__all__ = [ __all__ = [
"AdjustmentOrder",
"ApprovalOrder",
"ApprovalStep",
"AuthorizationSetting",
"Base", "Base",
"Business",
"BusinessItem",
"BusinessRole", "BusinessRole",
"BusinessStatusLog",
"CalcTemplateDefinition",
"Customer",
"CustomerRatingGrade",
"LoginLog", "LoginLog",
"MonitorEvent",
"MonitorRequestMinute",
"MonitorSnapshot",
"MonthlySettlement",
"OperationLog", "OperationLog",
"OwnershipChangeLog",
"ParamChangeBatch", "ParamChangeBatch",
"ParamDefinition", "ParamDefinition",
"ParamValue", "ParamValue",
"PayrollAllocation",
"PayrollInput",
"PayrollMonth",
"PerformanceLedger",
"Permission", "Permission",
"Personnel",
"PersonnelPlan",
"PersonnelRegion",
"ProbationDeferral",
"ProbationPayment",
"Product",
"Receipt",
"Role", "Role",
"RolePermission", "RolePermission",
"SecondaryAllocation",
"SettlementItem",
"Shipment",
"ShipmentItem",
"SystemSetting",
"TPlusAutomationConfig",
"TPlusAutomationState",
"TPlusBusinessDocument",
"TPlusBusinessSyncConfig",
"TPlusFact",
"TPlusRuntimeControl",
"TPlusSourceHead",
"TPlusSourceLink",
"TPlusSyncBatch",
"TPlusSyncError",
"TPlusSyncRecord",
"TPlusSyncWatermark",
"TPlusWorkLine",
"TPlusWorkRun",
"TimestampMixin", "TimestampMixin",
"User", "User",
"UserPermissionOverride",
"UserRole", "UserRole",
] ]
+163
View File
@@ -0,0 +1,163 @@
"""审批模型:审批单与节点记录(2026-10-15 复核审批补充需求)。
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数体系开关驱动
(`approval.*.enabled` BOOL):关闭=提交即生成「自动通过」节点留痕,
留痕不断链(开发计划 3.2(三));开启=按节点人工审核,一驳全驳。
依赖说明:节点准入按登录角色编码判定(ROLE_FINANCE/ROLE_LEADER),
不动 `permissions`/`role_permissions` 闲置表 —— 细粒度权限清单待业务方
答复(待澄清 #6)后再激活。
ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=2
(月结预留)将指向 ④ 尚未落地的表,外键无法统一指向;单据删除保护由
服务层承担(详见 approval_service)。
"""
from datetime import datetime
from sqlalchemy import (
JSON,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
# 审批单状态(approval_orders.status;流转机见 approval_service)
APPROVAL_STATUS_PENDING_FINANCE = 1 # 待财务复核
APPROVAL_STATUS_PENDING_LEADER = 2 # 待领导审核
APPROVAL_STATUS_APPROVED = 3 # 已通过(终态)
APPROVAL_STATUS_REJECTED = 4 # 已驳回(终态)
APPROVAL_STATUS_WITHDRAWN = 5 # 已撤回(被引用方重新提交时旧在途单注销)
# 节点(approval_steps.node;0 仅用于撤回留痕)
APPROVAL_NODE_FINANCE = 1
APPROVAL_NODE_LEADER = 2
APPROVAL_NODE_WITHDRAW = 0
APPROVAL_NODES = {
APPROVAL_STATUS_PENDING_FINANCE: APPROVAL_NODE_FINANCE,
APPROVAL_STATUS_PENDING_LEADER: APPROVAL_NODE_LEADER,
}
# 动作(approval_steps.action)
APPROVAL_ACTION_APPROVE = 1 # 人工通过
APPROVAL_ACTION_REJECT = 2 # 人工驳回
APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕)
APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销)
# 审批对象类型(approval_orders.order_type)
APPROVAL_TYPE_SECONDARY_ALLOCATION = 1
APPROVAL_TYPE_MONTHLY_SETTLEMENT = 2
APPROVAL_TYPE_ADJUSTMENT = 3
class ApprovalOrder(Base, TimestampMixin):
"""审批单:一张待审对象一条审批流(状态可流转,含 update_time)。"""
__tablename__ = "approval_orders"
__table_args__ = mysql_table_args(
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
# 同一引用对象的第 N 次送审在库里有唯一一行:并发提单算重版本号时
# 由 DB 兜住(应用层取号走锁读,见 approval_service._next_version)
UniqueConstraint(
"order_type",
"ref_id",
"version_no",
name="uk_approval_orders_order_type_ref_version",
),
Index("idx_approval_orders_type_status", "order_type", "status"),
# 版本号按 (order_type, ref_id) 递增,须有对应索引避免全表 max 扫描
Index("idx_approval_orders_type_ref", "order_type", "ref_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
order_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
)
order_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算 3调整单"
)
ref_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
)
refer_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
)
version_no: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="1",
comment="提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖;"
"同一对象内唯一,由uk_approval_orders_order_type_ref_version强制)",
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON,
comment="提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)",
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)
class ApprovalStep(Base):
"""审批节点记录表(append-only):每次动作只增不改,构成时间线。
不继承 TimestampMixin(留痕无更新语义,同 business_status_logs 先例)。
"""
__tablename__ = "approval_steps"
__table_args__ = mysql_table_args(
"审批节点记录表(append-only;自动通过/撤回均有留痕)",
Index("idx_approval_steps_order_id", "order_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
order_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("approval_orders.id", ondelete="CASCADE"),
nullable=False,
comment="审批单ID(approval_orders.id;单删记录随删)",
)
node: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="审批节点:0撤回留痕 1财务复核 2领导审核"
)
action: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
)
opinion: Mapped[str | None] = mapped_column(String(255), comment="审批意见(驳回时填写)")
operator_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(自动通过/系统动作置空)",
)
operator_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="操作人用户名(冗余固化;系统动作记「系统」)"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
+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")
+36 -2
View File
@@ -7,7 +7,7 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, MetaData, text from sqlalchemy import DateTime, MetaData, String, text
from sqlalchemy.dialects.mysql import BIGINT, TINYINT from sqlalchemy.dialects.mysql import BIGINT, TINYINT
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -28,7 +28,14 @@ BIGINT_UNSIGNED = BIGINT(unsigned=True)
# 状态类字段统一用 TINYINT(0/1 或少量枚举值) # 状态类字段统一用 TINYINT(0/1 或少量枚举值)
TINYINT_TYPE = TINYINT TINYINT_TYPE = TINYINT
__all__ = ["BIGINT_UNSIGNED", "TINYINT_TYPE", "Base", "TimestampMixin", "mysql_table_args"] __all__ = [
"BIGINT_UNSIGNED",
"TINYINT_TYPE",
"Base",
"TPlusSyncMixin",
"TimestampMixin",
"mysql_table_args",
]
class Base(DeclarativeBase): class Base(DeclarativeBase):
@@ -62,6 +69,33 @@ class TimestampMixin:
) )
class TPlusSyncMixin:
"""T+(用友)数据同步预留列,六张业务主表共用。
对照表 #26:T+ 对接的字段映射与幂等键语义未定(IT 牵头),故四列全部
可空、不设唯一键 —— 键语义未定就锁死唯一约束,对接时大概率返工;
source_no 先建普通索引承担查询与去重,IT 定键后以小迁移升级唯一约束。
同步守则(M4 同步模块必须遵守):同步只写 T+ 侧字段与本组列,
**不覆盖人工维护字段**(岗位/工资标准/归属/客户分类/业务状态等,
各表注释已标注)。同步频率:一周一次定时 + 手动触发(对照表 #10)。
"""
source_no: Mapped[str | None] = mapped_column(
String(64),
index=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
)
t_plus_id: Mapped[str | None] = mapped_column(String(64), comment="T+ 系统内部标识")
sync_batch_no: Mapped[str | None] = mapped_column(
String(32), comment="同步批次号(同步模块写入)"
)
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
source_updated_at: Mapped[datetime | None] = mapped_column(
DateTime, comment="T+ 源数据更新时间(未确认时可为空)"
)
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]: def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。 """构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
+306
View File
@@ -0,0 +1,306 @@
"""业务数据模型:业务单、产品明细与状态流转留痕。
- 汇总冗余列(total_* 五列)由服务层在明细/发货/回款增删改时**事务内单点
重算**,禁手工直改 —— 列表页与 M3 起付判定直接读冗余列,避免每次 JOIN
聚合;代价是所有写路径必须重算(business_service 统一收口)
- 毛利润相关值(毛利润额/毛利润率/折算毛利润额)**不落库**:它们是
sale_amount/cost_amount + 时点化阈值参数的派生值,落库会在成本调整或
参数改版后漂移出「假快照」;M2 以只读毛利润视图实时计算(复用
formula.profit_rate/converted_profit),M3 核算台账才是法定固化点
- product_category 存在明细行级:混合业务按产品逐条计算(对照表 #14)
- 运费仅记录展示,不计入毛利润口径(对照表 #2);协助营销人员一期核算
不使用(对照表 #15 剔除协助 25% 分成),字段保留仅记录事实(需规 2.2.1)
"""
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
JSON,
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# 业务状态(businesses.status,流转规则见 business_service:终态不可变更)
BUSINESS_STATUS_ACTIVE = 1
BUSINESS_STATUS_DONE = 2
BUSINESS_STATUS_TERMINATED = 3
class SecondaryAllocation(Base, TimestampMixin):
"""二次分配方案明细表(2026-10-15 补充需求「二次分配方案」)。
口径(AI_CONTEXT 待澄清 #1/#2 未答复前的边界):
- 只出现在合同刚建立时;修改在首次月结前留痕放行,月结后只读(月结在 ④,
终态门槛先行承担「修改不影响已结算月份」)
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
与比例,响应以 hint 提示合计/余额,不做强制校验
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
- **版本行 append-only**:编辑全量替换与驳回清空都把旧行置
``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由
``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照
(``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只
补齐「方案事实留存」,不改变核算口径
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
冗余固化,人离职后方案回显仍有名。uk 锁
(business_id, version_no, personnel_id),同一版本内同一参与人仅一行,
重复提交在服务层聚合;跨版本允许同一人再次出现。
"""
__tablename__ = "business_secondary_allocations"
__table_args__ = mysql_table_args(
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
"业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)",
UniqueConstraint(
"business_id",
"version_no",
"personnel_id",
name="uk_business_secondary_allocations_business_version_personnel",
),
Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
Index("idx_business_secondary_allocations_business_active", "business_id", "is_active"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="CASCADE"),
nullable=False,
comment="业务ID(businesses.id;业务删除随删)",
)
version_no: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="1",
comment="方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)",
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="参与业务员ID(personnel.id;被方案引用禁删)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="参与业务员姓名(冗余固化,离职后回显有名)"
)
ratio: Mapped[Decimal] = mapped_column(
Numeric(5, 2),
nullable=False,
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
)
is_active: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)",
)
class Business(Base, TimestampMixin, TPlusSyncMixin):
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
__tablename__ = "businesses"
__table_args__ = mysql_table_args(
"业务表(业务编号自动生成;T+同步不覆盖业务状态)",
Index("idx_businesses_customer_id", "customer_id"),
Index("idx_businesses_owner_personnel_id", "owner_personnel_id"),
Index("idx_businesses_status", "status"),
Index("idx_businesses_business_date", "business_date"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
)
customer_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("customers.id", ondelete="RESTRICT"),
nullable=False,
comment="客户ID(customers.id)",
)
business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)")
integration_json: Mapped[dict | None] = mapped_column(JSON, comment="T+业务接入范围及来源人员摘要,非核算快照")
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
)
owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
)
assistant_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
)
is_special: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="特殊业务标记:1是 0否(公司参与等)",
)
special_note: Mapped[str | None] = mapped_column(String(255), comment="特殊业务说明")
total_sale_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
)
total_cost_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
)
total_ship_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
)
total_received_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
)
total_freight_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class BusinessItem(Base, TimestampMixin):
"""业务产品明细表(需求 2.2.1/2.2.4):毛利润的行级源数据。
product_id 可空=自由录入产品(配合「数据提取」一键入产品库);
产品名称/规格冗余固化,防产品改名影响历史业务。
sale_amount 由服务层按 数量×单价 计算固化,不信任前端传值。
cost_amount 来源待 T+ 对接确认(可空,人工可录先行)。
"""
__tablename__ = "business_items"
__table_args__ = mysql_table_args(
"业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
Index("idx_business_items_business_id", "business_id"),
Index("idx_business_items_product_id", "product_id"),
Index("idx_business_items_product_category", "product_category"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="RESTRICT"),
nullable=False,
comment="业务ID(businesses.id)",
)
product_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("products.id", ondelete="RESTRICT"),
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
)
product_name: Mapped[str] = mapped_column(
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
)
spec_model: Mapped[str] = mapped_column(
String(128),
nullable=False,
server_default="''",
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
product_category: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
)
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)")
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="销售单价(元;T+ 同步或人工)"
)
sale_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
)
cost_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
)
guided_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class BusinessStatusLog(Base):
"""业务状态变更记录表(append-only,需求 2.2.1「支持状态变更记录」)。
不继承 TimestampMixin(留痕无更新语义);business_id 为无外键的固化 ID
(businesses 建表在本表之后的迁移批次内 —— 实为同批,但留痕统一口径:
被引用方删除后记录仍完整可读,删除保护由服务层检查承担)。
首条记录在业务创建时写入(old=new=1 执行中),详情页时间线从创建起展示。
"""
__tablename__ = "business_status_logs"
__table_args__ = mysql_table_args(
"业务状态变更记录表(append-only)",
Index("idx_business_status_logs_business_id", "business_id"),
Index("idx_business_status_logs_create_time", "create_time"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
comment="业务ID(固化,无外键;删除保护由服务层承担)",
)
old_status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="变更前状态(创建首条为 1)"
)
new_status: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, comment="变更后状态")
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因(终止时必填)")
operator_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(账号删除后置空)",
)
operator_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="操作人用户名(冗余固化)"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
+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自定义",
)
+128
View File
@@ -0,0 +1,128 @@
"""客户模型:客户档案与归属变更留痕。
- 「公司名称 + 负责人」是业务唯一标识(需规 2.1.3);负责人是客户公司的
负责人**人名**(字符串),不是系统内的营销人员 —— 系统内归属另用
owner_personnel_id 外键表达,两者语义不同
- 归属变更只改主表当前值 + 插一条留痕,**不回溯历史**(对照表 #19):
历史核算结果在 M3 台账已固化,无需 effective_date 时点化设计
- 留痕表(OwnershipChangeLog)对客户/业务/人员 ID 均为**无外键的冗余固化**:
留痕是历史记录,被引用方被删除后记录仍须完整可读;RESTRICT 外键反而会
把「删除客户」逼成 500。业务实体删除保护由服务层检查承担
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# 归属类型(ownership_change_logs.owner_type)
OWNER_TYPE_CUSTOMER = 1
OWNER_TYPE_BUSINESS = 2
class Customer(Base, TimestampMixin, TPlusSyncMixin):
"""客户表(需求 2.1.3)。customer_type 是计提规则匹配锚点(M3 消费)。"""
__tablename__ = "customers"
__table_args__ = mysql_table_args(
"客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
company_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="公司名称")
owner_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="客户负责人姓名(客户公司负责人的人名,非系统人员)"
)
contact_person: Mapped[str | None] = mapped_column(String(64), comment="联系人")
contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
address: Mapped[str | None] = mapped_column(String(255), comment="地址")
industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)")
customer_type: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
index=True,
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
)
owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
index=True,
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
rating_score: Mapped[int | None] = mapped_column(
TINYINT_TYPE,
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
)
rating_time: Mapped[datetime | None] = mapped_column(
DateTime,
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
)
class OwnershipChangeLog(Base):
"""归属变更记录表(append-only,需求 2.1.3 + 对照表 #19)。
owner_type 区分客户归属/业务归属:1 → customer_id 非空,2 → business_id
非空,由应用层校验(无外键,见模块 docstring 的固化论证)。
前后值冗余固化(无外键):人员被删除后留痕仍可读。
"""
__tablename__ = "ownership_change_logs"
__table_args__ = mysql_table_args(
"归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
Index("idx_ownership_change_logs_customer_id", "customer_id"),
Index("idx_ownership_change_logs_business_id", "business_id"),
Index("idx_ownership_change_logs_create_time", "create_time"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
owner_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="归属类型:1客户归属 2业务归属"
)
customer_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
comment="客户ID(固化,owner_type=1 时非空)",
)
business_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
comment="业务ID(固化,owner_type=2 时非空)",
)
old_owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更前负责营销人员ID(固化)"
)
new_owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更后负责营销人员ID(固化)"
)
old_assistant_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更前协助营销人员ID(固化,仅业务归属使用)"
)
new_assistant_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更后协助营销人员ID(固化,仅业务归属使用)"
)
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因")
operator_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(账号删除后置空)",
)
operator_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="操作人用户名(冗余固化)"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
+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="单据、成本、归属、参数和逐段计算依据")
+186
View File
@@ -0,0 +1,186 @@
"""绩效核算台账模型(M3 ④:每笔回款仅核算一次的固化点)。
**append-only 版本行**:重算 = 插入 calc_version+1 的新行,旧行仅把
is_active 翻 0 —— 这是全表唯一的受控 UPDATE(模型注释与 test_models
均已声明例外),行内容永不改写;历史版本可回溯(重算授权留痕的需求口径)。
防重三层:服务层 CAS(UPDATE ... WHERE settlement_status=0,rowcount=0
即 400)+ FOR UPDATE 行锁 + 库级 uk(receipt_id, calc_version, personnel_id)
兜底。uk 含 personnel_id:二次分配分摊(澄清 #1 答复 2026-10-19,分配
绩效——按方案比例拆给参与人、余额归合同负责人)后同一回款同版本按人
各落一行,拆分仍是「一次核算」的组成部分;聚合行 receipt_id 为 NULL,
MySQL 唯一索引对 NULL 不去重,天然不约束。
经理聚合行(calc_type=2,receipt_id 为 NULL):月度批处理时按「本团队
业务员绩效 ×12.5% + 全公司 ×5%」落一行;其唯一性由月结生成的
settlement_month 串行化 + 服务层保证(MySQL 无部分唯一索引,docstring
声明即可)。
#12 答复(2026-10-19):经理个人户业绩是否计入聚合基数改为参数
``manager.personal.included``(默认 0=不计入)——消费点在 settlement_service
的聚合函数。
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import (
JSON,
Date,
ForeignKey,
Index,
Numeric,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
# 核算类型(performance_ledgers.calc_type)
CALC_TYPE_RECEIPT = 1 # 单笔回款核算
CALC_TYPE_MANAGER_AGGREGATE = 2 # 经理团队聚合(月度批处理)
class PerformanceLedger(Base, TimestampMixin):
"""绩效核算台账表:核算事实的固化点,报表 2.4.1 的数据源。"""
__tablename__ = "performance_ledgers"
__table_args__ = mysql_table_args(
"绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
# 库级防重兜底:同一回款同版本按人仅一行(分摊拆行后负责人+参与人
# 各占一行;服务层 CAS+行锁之外的第二道闸)。聚合行 receipt_id 为
# NULL,MySQL 唯一索引对 NULL 不去重,天然不约束聚合行
UniqueConstraint(
"receipt_id",
"calc_version",
"personnel_id",
"allocation_key",
name="uk_performance_ledgers_receipt_id_calc_version_personnel",
),
Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"),
Index(
"idx_performance_ledgers_month_personnel_type",
"settlement_month",
"personnel_id",
"calc_type",
),
Index("idx_performance_ledgers_personnel_id", "personnel_id"),
Index("idx_performance_ledgers_receipt_id", "receipt_id"),
Index("idx_performance_ledgers_business_id", "business_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
receipt_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("receipts.id", ondelete="RESTRICT"),
comment="回款ID(receipts.id;聚合行可为 NULL)",
)
calc_version: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="核算版本(重算+1,旧行 is_active=0)",
)
allocation_key: Mapped[str] = mapped_column(
String(64), nullable=False, server_default="''", comment="正式FIFO事件键;历史单笔核算为空串",
)
is_active: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
)
calc_type: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
)
business_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="业务编号(冗余固化,列表免联查)"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="归属营销人员ID(personnel.id;核算归属锚点)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="归属营销人员姓名(冗余固化)"
)
role_code: Mapped[str] = mapped_column(
String(32), nullable=False, comment="岗位编码(business_roles.role_code,计提公式锚点)"
)
customer_type: Mapped[str] = mapped_column(
String(32), nullable=False, comment="客户分类:personal/manager_personal/enterprise"
)
product_category: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
)
ship_date: Mapped[date | None] = mapped_column(Date, comment="发货日期(回款天数基准)")
receipt_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="回款日期(参数版本匹配日,裁决 #11)"
)
payment_days: Mapped[int | None] = mapped_column(
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)"
)
rate_text: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
)
converted_profit: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="折算毛利润额(可负;混合业务为各行汇总)"
)
performance_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="绩效金额(可负,裁决:负绩效照算不归零)"
)
barter_applied: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="以物抵顶处理标记:当前系统不自动折算,1表示由线下规则处理 0表示未标记",
)
weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)")
payment_weight: Mapped[Decimal | None] = mapped_column(
Numeric(7, 6), comment="支付权重系数 R×R(展示用)"
)
payable_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)"
)
settlement_month: Mapped[str] = mapped_column(
String(7), nullable=False, comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)"
)
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="试用期:1照算不发放 0否",
)
missing_cost: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="计算快照(所用参数版本与行级折算明细,重算可回溯)"
)
settlement_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("monthly_settlements.id", ondelete="RESTRICT"),
comment="所属月结ID(月结生成时回填;NULL=未入结)",
)
calc_reason: Mapped[str | None] = mapped_column(
String(255), comment="核算/重算原因(重算必填留痕)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
+140
View File
@@ -0,0 +1,140 @@
"""营销人员模型:人员档案、产品辐射区域与市场拓展计划。
- 岗位(business_role_id)决定计提公式模板,与工资标准同属「同步不覆盖」的
人工维护字段(对照表 #26 落地口径,表注释标注供 M4 同步模块遵守)
- manager_id 自引用表达「经理带团队」:NULL=经理本人或未分配;M3 经理
12.5% 聚合、M4 部门汇总均按该关系展开,无需独立团队表
- 一期营销人员不登录系统,不与 users 关联;若后续开放自助查工资再加列
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import JSON, Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py
# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移)
PERSONNEL_STATUS_ACTIVE = 1
class Personnel(Base, TimestampMixin, TPlusSyncMixin):
"""营销人员表(需求 2.1.1)。
表名 personnel 为集合名词(单复数同形),本身即复数语义。
试用期人员绩效照算不发放(对照表 #17),is_probation 供 M3 区分。
"""
__tablename__ = "personnel"
__table_args__ = mysql_table_args("营销人员表(T+同步不覆盖岗位与工资标准)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="姓名")
business_role_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("business_roles.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
)
manager_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
index=True,
comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)",
)
phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
hire_date: Mapped[date | None] = mapped_column(Date, comment="入职日期")
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
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,
server_default="1",
comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class PersonnelRegion(Base, TimestampMixin):
"""产品辐射区域表(需求 2.1.1):人员负责的产品辐射地理范围,1:N 子表。
随人员编辑整表替换维护,无独立管理诉求,故不加 T+ 预留列。
"""
__tablename__ = "personnel_regions"
__table_args__ = mysql_table_args("产品辐射区域表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="人员ID(personnel.id)",
)
region_name: Mapped[str] = mapped_column(
String(128), nullable=False, comment="区域名称(产品辐射地理范围)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class PersonnelPlan(Base, TimestampMixin):
"""市场拓展计划表(需求 2.1.1):计划期间 + 计划内容 + 指标说明的自由条目。
结构化指标粒度待 T+ 字段映射确认,先以自由条目容器落地;条目最终由
T+ 同步确定,故预留 source_no 单列(无索引:查询按 personnel_id 走)。
"""
__tablename__ = "personnel_plans"
__table_args__ = mysql_table_args("市场拓展计划表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="人员ID(personnel.id)",
)
plan_period: Mapped[str] = mapped_column(
String(32), nullable=False, comment="计划期间(如 2026Q1、2026-05)"
)
plan_content: Mapped[str] = mapped_column(String(500), nullable=False, comment="计划内容")
target_desc: Mapped[str | None] = mapped_column(String(255), comment="指标说明(文字描述)")
source_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 原始条目号(预留)")
+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="创建时间"
)
+60
View File
@@ -0,0 +1,60 @@
"""产品模型:产品主数据。
产品类别(product_category)是**折算阈值的行级锚点**:混合业务按产品逐条
计算(对照表 #14),阈值本身由 M1 参数 `product.threshold` 按 PRODUCT_CATEGORY
维度承担(#28「全部阈值统一在系统设置维护」),本表不建阈值列 —— 两处维护
必然漂移。类别值域与 M1 参数维度共用 PRODUCT_CATEGORIES 常量。
"""
from decimal import Decimal
from sqlalchemy import JSON, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
class Product(Base, TimestampMixin, TPlusSyncMixin):
"""产品表(需求 2.1.2)。停用(status=0)为主,不物理删除。"""
__tablename__ = "products"
__table_args__ = mysql_table_args(
"产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)",
UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
product_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="产品名称")
spec_model: Mapped[str] = mapped_column(
String(128),
nullable=False,
server_default="''",
comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
stock_quantity: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="数量(T+ 同步原样存储,语义待 IT 确认)"
)
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="销售单价(元,T+ 同步)"
)
product_category: Mapped[str | None] = mapped_column(
String(32),
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随源更新")
+46
View File
@@ -0,0 +1,46 @@
"""客户评级等级配置模型(需求 2.2.5)。
评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录
不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取
第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在
「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。
"""
from decimal import Decimal
from sqlalchemy import Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class CustomerRatingGrade(Base, TimestampMixin):
"""客户评级等级配置表(需求 2.2.5「等级对应关系可配置」)。
种子四条随迁移初始化:A 优秀 90-100 / B 良好 75-89 / C 合格 60-74 /
D 待改进 0-59。区间不重叠与 0≤min≤max≤100 由应用层校验。
"""
__tablename__ = "customer_rating_grades"
__table_args__ = mysql_table_args(
"客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)"
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
grade_name: Mapped[str] = mapped_column(
String(32), nullable=False, unique=True, comment="等级名称(如 A/优秀)"
)
min_score: Mapped[Decimal] = mapped_column(
Numeric(5, 1),
nullable=False,
comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分",
)
max_score: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, comment="总分上限(展示用)"
)
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="排序(判定顺序,升序)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="说明")
+89
View File
@@ -0,0 +1,89 @@
"""回款模型:回款记录与批次追溯。
- shipment_id 为**手动指定**的发货批次(对照表 #18):可空=暂未指定;
必须与 business_id 同属一个业务(服务层强校验,无数据库级约束可表达
跨表同属关系);指定后回款天数方可由 formula.payment_days 派生
- settlement_status 是 **M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算一次,
台账记录核销状态」):预置于本表避免 M3 反向迁移改 M2 表;M3 台账建立后
由核算服务写入,M2 仅维护 0(未核算)
- receipt_date 是 M3 参数版本匹配日(对照表 #11:按回款到账日取时点参数)
- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开
"""
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# 绩效核算状态(receipts.settlement_status)
SETTLEMENT_PENDING = 0
SETTLEMENT_DONE = 1
class Receipt(Base, TimestampMixin, TPlusSyncMixin):
"""回款表(需求 2.2.3):按业务登记到账款项,批次号系统内唯一。"""
__tablename__ = "receipts"
__table_args__ = mysql_table_args(
"回款表(手动指定发货批次;已核算回款禁改禁删)",
Index("idx_receipts_business_id", "business_id"),
Index("idx_receipts_receipt_date", "receipt_date"),
Index("idx_receipts_settlement_status", "settlement_status"),
Index("idx_receipts_shipment_id", "shipment_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
receipt_batch_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)",
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="RESTRICT"),
nullable=False,
comment="业务ID(businesses.id)",
)
shipment_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("shipments.id", ondelete="RESTRICT"),
comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)",
)
receipt_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)"
)
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="回款金额(≥0;一期禁负数/红字)"
)
is_barter: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="以物抵顶标记:1是 0否(特殊绩效线下核实,系统不自动折算)",
)
settlement_status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
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="备注")
+214
View File
@@ -0,0 +1,214 @@
"""月度结算与调整单模型(M3 ④:月结审批报表补充需求)。
- 月度结算按「确认回款」和计算快照生成;**通过即锁定**(status=3 已审定
即终态,锁定后源数据改动只产生提示与后续调整)
- settlement_items 明细按人固化(可负=负数工资),锁定后无 UPDATE 路径
(重生成 = 草稿/驳回态删旧插新)
- 调整单是已锁定月份的唯一调整通道(补发/扣减,走审批;生效后不回写
已锁定月结——append-only,报表汇总并入)
"""
from datetime import datetime
from decimal import Decimal
from sqlalchemy import JSON, DateTime, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
# 月结状态(monthly_settlements.status;通过即锁定——两态合一)
SETTLEMENT_STATUS_DRAFT = 1 # 草稿(可重新生成/提交)
SETTLEMENT_STATUS_PENDING = 2 # 待审批(提交后)
SETTLEMENT_STATUS_APPROVED = 3 # 已审定(终态=已锁定)
SETTLEMENT_STATUS_REJECTED = 4 # 已驳回(回草稿可重提)
# 调整单状态(adjustment_orders.status)
ADJUSTMENT_STATUS_PENDING = 1 # 待审批
ADJUSTMENT_STATUS_EFFECTIVE = 2 # 已生效
ADJUSTMENT_STATUS_REJECTED = 3 # 已驳回
ADJUSTMENT_STATUS_WITHDRAWN = 4 # 已撤回
# 调整方向(adjustment_orders.direction)
ADJUSTMENT_DIRECTION_EXTRA = 1 # 补发
ADJUSTMENT_DIRECTION_DEDUCT = 2 # 扣减
class MonthlySettlement(Base, TimestampMixin):
"""月度结算表:按月一行(settlement_month 唯一),快照 + 审批 + 锁定。"""
__tablename__ = "monthly_settlements"
__table_args__ = mysql_table_args("月度结算表(按月一行;通过即锁定,锁定后走调整单)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
settlement_month: Mapped[str] = mapped_column(
String(7), nullable=False, unique=True, comment="结算月份(YYYY-MM;全表唯一)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
)
total_performance: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="绩效金额合计(可负)"
)
total_payable: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="应付绩效合计(可负)"
)
person_count: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="0", comment="结算人数"
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)"
)
approval_order_no: Mapped[str | None] = mapped_column(
String(32), comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)"
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)
locked_at: Mapped[datetime | None] = mapped_column(
DateTime, comment="锁定时间(status=3 时回填)"
)
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):
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
__tablename__ = "settlement_items"
__table_args__ = mysql_table_args(
"月结明细表(按人固化;支持负数工资;锁定后只读)",
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
settlement_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("monthly_settlements.id", ondelete="CASCADE"),
nullable=False,
comment="月结ID(monthly_settlements.id;头删明细随删)",
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="结算人员ID(personnel.id)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="结算人员姓名(冗余固化)"
)
role_code: Mapped[str] = mapped_column(
String(32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
)
calc_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="核算类型:1单笔回款 2经理团队聚合"
)
performance_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
)
payable_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="应付绩效合计(可负)"
)
basic_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="月工资标准(personnel.salary_standard 当月快照)"
)
salary_mode: Mapped[str | None] = mapped_column(
String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE"
)
net_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
comment="净工资 = 绩效±基本工资(按口径;试用期为基本工资+正常应付+年度差额,缓发部分不发)"
)
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="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="个人明细快照(台账行引用列表)"
)
class AdjustmentOrder(Base, TimestampMixin):
"""调整单:已锁定月份的唯一调整通道(补发/扣减,走审批;不回写月结)。"""
__tablename__ = "adjustment_orders"
__table_args__ = mysql_table_args(
"调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
)
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,
unique=True,
comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
)
settlement_month: Mapped[str] = mapped_column(
String(7), nullable=False, comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="调整人员ID(personnel.id)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="调整人员姓名(冗余固化)"
)
direction: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="方向:1补发 2扣减"
)
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="调整金额(恒正,方向表达语义)"
)
reason: Mapped[str] = mapped_column(
String(255), nullable=False, comment="扣补说明(必填,审批与追溯锚点)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)
+114
View File
@@ -0,0 +1,114 @@
"""发货模型:发货单与发货产品明细。
- ship_date 是**回款天数计算基准日**(对照表 #1:发货日首日计第 1 天),
回款指定本发货单后由 formula.payment_days 派生回款天数,不在本表落库
- 运费仅记录展示,不计入毛利润口径(对照表 #2)
- total_amount 为明细行汇总冗余,服务层事务内维护
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
class Shipment(Base, TimestampMixin, TPlusSyncMixin):
"""发货单表(需求 2.2.2):每笔发货对应一个业务,支持分批发货。"""
__tablename__ = "shipments"
__table_args__ = mysql_table_args(
"发货单表(发货日为回款天数基准日,发货日首日计第 1 天)",
Index("idx_shipments_business_id", "business_id"),
Index("idx_shipments_ship_date", "ship_date"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
shipment_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)",
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="RESTRICT"),
nullable=False,
comment="业务ID(businesses.id;终态业务禁止新发货)",
)
ship_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)"
)
freight_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
nullable=True,
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
)
total_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
server_default="0",
comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class ShipmentItem(Base, TimestampMixin):
"""发货产品明细表(需求 2.2.2):记录发货的产品名称、规格、数量等。
价格口径两可(T+ 供价或人工录入,待 IT 确认):sale_price 可空人工补,
amount 由服务层按 数量×单价 计算固化。
"""
__tablename__ = "shipment_items"
__table_args__ = mysql_table_args(
"发货产品明细表",
Index("idx_shipment_items_shipment_id", "shipment_id"),
Index("idx_shipment_items_product_id", "product_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
shipment_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("shipments.id", ondelete="RESTRICT"),
nullable=False,
comment="发货单ID(shipments.id)",
)
product_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("products.id", ondelete="RESTRICT"),
comment="产品ID(products.id;NULL=自由录入)",
)
product_name: Mapped[str] = mapped_column(
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
)
spec_model: Mapped[str] = mapped_column(
String(128),
nullable=False,
server_default="''",
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
quantity: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="发货数量(>0)"
)
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)"
)
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
)
+31
View File
@@ -0,0 +1,31 @@
"""系统设置模型:公司信息单例行。
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在
`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见
system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。
"""
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class SystemSetting(Base, TimestampMixin):
"""系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。"""
__tablename__ = "system_settings"
__table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
company_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)"
)
logo_path: Mapped[str | None] = mapped_column(
String(255),
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
)
+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"
)
Loaded 100 of 578 files, more files were not shown because too many files have changed in this diff. Show more