Author SHA1 Message Date
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
272 changed files with 35566 additions and 3570 deletions

No files matched your search

+20 -3
View File
@@ -1,5 +1,5 @@
# AI辅助目录(整个目录忽略)
ai-aux/
.ai/
# 依赖
node_modules/
@@ -45,8 +45,9 @@ htmlcov/
.mypy_cache/
.ruff_cache/
.tox/
# 端到端测试产出的截图
frontend/e2e/*.png
# 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD
# 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖)
frontend/e2e/**/*.png
# 临时文件
*.tmp
@@ -59,3 +60,19 @@ frontend/e2e/*.png
backup/
.claude
CLAUDE.md
# 运行期上传物(公司 logo 等):与构建产物隔离,不入版本库
backend/uploads/
# 数据库备份导出目录(⑨ 备份脚本产物)
db/backups/
# 客户发来的原始账套备份压缩包。
/db/*.zip
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
docs/**/*.rar
docs/T_12.3数据字典/
# macOS 压缩包残留元数据
__MACOSX/
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
.tmpdocx/
+17 -3
View File
@@ -26,6 +26,20 @@ LOGIN_FAIL_LIMIT=5
LOGIN_LOCK_MINUTES=15
# ===== T+ 对接(待畅捷通确认后填写)=====
T_PLUS_BASE_URL=
T_PLUS_APP_KEY=
T_PLUS_APP_SECRET=
T_PLUS_ENABLED=false
T_PLUS_HOST=127.0.0.1
T_PLUS_PORT=1433
T_PLUS_DATABASE=
T_PLUS_USER=
T_PLUS_PASSWORD=
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
T_PLUS_CONNECTION_FILE=
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
# 本地 PoC 可复用已安装的 SQL Server 驱动;不自动安装或升级客户驱动。
T_PLUS_ODBC_DRIVER=
T_PLUS_ENCRYPT=false
T_PLUS_TRUST_SERVER_CERTIFICATE=true
T_PLUS_CONNECT_TIMEOUT=10
T_PLUS_QUERY_TIMEOUT=120
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
T_PLUS_QUERY_DIR=
+93 -1
View File
@@ -83,8 +83,9 @@ app/
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) |
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) |
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
@@ -114,6 +115,44 @@ app/
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
### 报表统计接口(M4 ⑤,限管理员/财务/领导)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/reports/personal-detail` | 个人绩效工资明细表(FR-029:人员/月份/客户类型/产品类别/岗位筛选;响应带 `summary` 全筛选合计) |
| GET | `/api/v1/reports/department-summary` | 部门绩效工资汇总表(FR-030:月份必填;团队基数与月结聚合行同源;12.5% 与 5% 分列;含成员内联明细、未分配桶、月结冻结对照额) |
| GET | `/api/v1/reports/business-stats` | 业务统计表(FR-031:发货/回款/绩效为业务累计值,不随业务日期筛选变化) |
| GET | `/api/v1/reports/payment-stats` | 回款情况统计表(FR-031:8 档天数区间恒在,空桶为 0) |
| GET | `/api/v1/reports/payment-detail` | 回款区间下钻(`bucket` 必填,未注册编码 400) |
| GET | `/api/v1/reports/payment-day-buckets` | 区间档位元数据(前端下拉唯一来源,禁止前端硬编码) |
| GET | `/api/v1/reports/{code}/export` | Excel 导出(文件流非统一信封;含公司页眉/财务复核栏/领导签字栏;超 2 万行 400;行为入操作日志) |
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
### 工作台接口(M4 ⑧,限管理员/财务/领导)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/dashboard?month=YYYY-MM` | 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志) |
待办与预警**实时派生**(不落表、不引调度器);预警阈值走参数体系
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
### T+ 本地只读同步与管理
T+ 数据库是 SQL Server 只读源库,业务库仍为 MySQL。Docker 恢复 PoC、备份检查和 D 盘目录约定见 `docs/T+本地恢复与同步开发手册.md`。
- 连接配置在 `backend/.env` 的 `T_PLUS_*` 变量中,默认关闭,不配置时不影响主业务启动。
- `uv run python -m app.cli tplus-stage --entity personnel` 按主键分页,将源行写入 `tplus_sync_records` staging,并记录批次和失败信息。水位表已预留,时间增量未启用,`--watermark` 会明确拒绝。
- T+ 驱动通过 `uv sync --extra tplus` 安装到应用环境,ODBC 驱动名称必须显式配置;交付兼容目标为 SQL Server 2008 R2,2022 副本不能代替同版本验收。
- 查询目录 `db/queries/tplus/` 随部署交付,或使用 `T_PLUS_QUERY_DIR` 指定受控位置;支持六类主表和三类明细。
- T+ 字典中的候选字段在实际备份核验和客户确认前,不得直接覆盖岗位、工资标准、归属、业务状态或核算状态。
- 管理 API `/api/v1/system/tplus` 全部限管理员:概况、批次/异常分页、按需报告、创建与失败重试。HTTP 只创建 QUEUED 任务;`uv run python -m app.cli tplus-worker --watch --connection-file <项目外JSON路径>` 独立处理。
- 核心文件:`services/tplus/task_service.py`(幂等/重试)、`worker.py`(执行)、`relationship_audit.py`(字典与关联)、`query_service.py`(安全输出)、`core/tplus_lock.py`(跨提交执行锁)。原 CLI 读取与 worker 共用执行锁。
- V15 / Alembic `e5f607a8b9c0` 增加范围、跳过数、请求键、父批次、请求人和核验结果。未启用时间增量与工资业务导入;完整运行步骤和异常处理见本地恢复手册。
## 常用命令
```bash
@@ -196,6 +235,59 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
### 审批快照与方案版本化(2026-10-20 定稿,改动前先读)
- **审批单带版本号与内容快照**:`approval_orders.version_no` 按 `(order_type, ref_id)` 从 1 递增,重提先撤回旧在途单、但**旧单快照不覆盖**;`snapshot_json` 统一信封 `{schema_version, order_type, captured_at, data}`,`data` 由 `approval_service.{allocation,settlement,adjustment}_content` 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。
- **两个 `version_no` 语义不同,别混用**:`business_secondary_allocations.version_no` = 方案内容第几版,**仅方案内容(参与人+比例)变化时 +1**;`approval_orders.version_no` = 同一对象第几次送审,**仅送审内容变化时 +1**。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。
- **送审内容未变不重提**:编辑业务时若**存在在途审批单**且其快照 `data` 与本次送审内容逐键相等(`approval_service.pending_matches_snapshot`,只比 `data` 不比 `captured_at`),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时**不新增方案版本行**(`business_service._plan_changed` 按「人员+比例」集合比较,与行序无关)。
- **快照的覆盖范围是「方案 + 钱 + 归属」**(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),**不含产品明细行**。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。
- **列表不加载快照列**:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 `defer(ApprovalOrder.snapshot_json)`(与操作日志列表排除 `request_data` 同一理由,参考 `operation_log_repository.list_logs`);详情走 `get_order` 按需读取。
- **详情内容来源三态**:`content_source=snapshot`(送审快照)/ `live`(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ `missing`(引用对象不存在且无快照)。**禁止回填历史单快照**——回填等于伪造提交时内容。
- **二次分配方案 append-only**:`business_secondary_allocations` 行不物理删除,编辑全量替换与驳回清空只把旧行 `is_active=0`;唯一键为 `(business_id, version_no, personnel_id)`,同一版本内参与人唯一、跨版本可重复。`business_repository.list_allocations(active_only=True)` 是核算与详情回显的唯一取数口径;业务详情 `allocation_history` 返回全部版本供回溯。
- **月结提交要锁行**:`settlement_service.submit` 取月结行必须 `for_update=True`——并发提交会让两次 `submit_order` 算出同一个审批单 `version_no`,送审次序失真。
- **审批单 `version_no` 有唯一键兜底(V12)**:`uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`——同一对象的第 N 次送审在库里有唯一一行。ORM 侧约束名必须与迁移 DDL 严格一致,否则 autogenerate 误判。
- **取版本号必须是「当前读」(`approval_service._next_version`)**,两个禁忌:(a) 不能用普通 `MAX` 查询——请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻,父行锁只串行化写入时刻、**不刷新快照**,MAX 会读到旧值;(b) 不能写成 `select(func.max(...)).with_for_update()`——MySQL 官方明确「Locking clauses are not permitted in queries with aggregate functions or GROUP BY」,聚合上的 FOR UPDATE 语义无保证。正解:锁读该 ref 的现有版本行(`select(version_no)...with_for_update()`),最大值 Python 侧算(同 `no_generator` 套路)。
- **版本号冲突的收敛口径**:`submit_order` 内不 catch、不 rollback(红线)。create 路径由外层编号重试循环整体重来(`business_service._NO_RETRY`);编辑/月结提交路径由审计上下文统一回滚,再把 1062 翻译为 400「提交冲突(版本号生成重复),请重试」(同 `param_service` 版本号冲突模板)。唯一键是最后一道防线,不是取号正确性的前提。
- **迁移前先修存量**:V11 把存量行 `version_no` 一律置 1,而 V11 前每次重提都新建行——同一 `(order_type, ref_id)` 存在多行 version_no=1,直接加唯一键必报 1062。V12 先重排「组内有重复」的分组(按 id 升序 1..n),其余分组不动(全量重排会改掉前端已展示过的历史版本号)。
- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。
### 工作台口径(M4 ⑧,改动前先读)
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定,**已终止业务(status=3)排除**;回款未指定批次 = `receipts.shipment_id IS NULL`。
- **待办按角色过滤**:`pending_statuses_for_roles`(approval_service)把当前用户角色映射到在途节点——财务只看待财务复核(status=1)、领导只看待领导审核(status=2)、admin 可看全部;多角色取并集。风险预警仍是全局经营视角,不按人裁剪。
- **预警展示上限**:`items` 最多 20 条,`total` 是全部命中数(前端徽标用它,不能用 `items.length`)。
- **趋势窗口**:回款趋势先按 `receipt_date` 范围过滤再按 `DATE_FORMAT` 分组,函数列不参与 WHERE,保证索引可用。
- **同步降级**:⑥ 未建时 `sync_available=false`,前端显示「模块未启用」——不要返回 0 条冒充「无失败」。
- **参数快照一次解析**:`warning.*` 三个阈值在每次请求只 `resolve_snapshot` 一次,多条规则复用。
- **导出 SQL 的编码坑**:Windows 下 `alembic upgrade --sql > V*.sql` 默认按控制台 GBK 输出,`test_schema_sync` 按 UTF-8 读取会报 `UnicodeDecodeError`——必须 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1` 前缀。
### 报表口径(M4 ⑤,改动前先读)
- **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。
- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入,**不要求该人当月有台账行**(只有调整单也要出零绩效成员行);`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。调整单归属跟随该人普通行的实时 `manager_id`;只有经理个人户的经理跟随本人团队。
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块)。
- **全公司 5% 的发放口径**:月结只为有台账 bucket 的营销经理各生成一条聚合行(12.5%+5%),**每位经理各计一次 5%**;零成员团队无聚合行、不发 5%。报表的 `aggregate_amount` 优先取冻结聚合行、未生成月结时才用 `manager_team_share` 一次舍入预览;`team_subtotal`/`net_total` = 聚合额 + 调整净额。`team_share_total` 与 `company_share_total` 仅分列展示,两者之和与聚合额的差记入 `aggregate_rounding_diff`(分列各自 q2 会有 0.01~0.02 尾差)。
- **对拍边界**:报表应发 = 月结 `total_payable` + 调整单净额(调整单不进月结 items);筛选 `manager_id` 时只汇总展示团队,隐藏金额进 `other_*`,不能与全公司月结总额对拍。
- **人员归属按行类型**:普通台账行跟随 `manager_id`;经理个人户行在 `manager.personal.included=1` 时归经理本人团队,且经理成员行**只取个人户金额**(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 `manager_id` 筛选时 `unassigned` 为空(语义是「仅看该经理团队」)。
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);第二块展示单经理 5%、计入次数与合计,对账块展示团队分成/全公司分成(分列)、聚合行舍入差、调整净额,应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
### 面向业务人员的文案(2026-10-23 定稿,改文案前先读)
系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:
- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。
- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。
- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。
- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
@@ -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,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,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")
+26
View File
@@ -113,3 +113,29 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
AdminUser = Annotated[User, Depends(require_admin)]
# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅
# 管理员」——参数目录/历史/预览等只读端点用此依赖)
LoginUser = Annotated[User, Depends(get_current_user)]
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
"""要求当前用户具备任一指定角色(依赖工厂)。
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
避免既有调用与测试变动)。
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
"""
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
codes = set(user_repository.get_role_codes(current_user))
if not codes & set(role_codes):
raise ForbiddenError(message)
return current_user
return _checker
+21
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter
from app.api import health
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
@@ -15,10 +16,17 @@ 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.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 logs as system_logs
from app.api.v1.system import params as system_params
from app.api.v1.system import roles as system_roles
from app.api.v1.system import settings as system_settings
from app.api.v1.system import tplus as system_tplus
from app.api.v1.system import users as system_users
api_router = APIRouter()
@@ -36,8 +44,11 @@ system_router.include_router(system_users.router)
system_router.include_router(system_roles.router)
system_router.include_router(system_business_roles.router)
system_router.include_router(system_params.router)
system_router.include_router(system_settings.router)
system_router.include_router(system_approvals.router)
system_router.include_router(system_logs.operation_router)
system_router.include_router(system_logs.login_router)
system_router.include_router(system_tplus.router)
v1_router.include_router(system_router)
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
@@ -45,6 +56,7 @@ base_router = APIRouter(prefix="/base")
base_router.include_router(base_personnel.router)
base_router.include_router(base_products.router)
base_router.include_router(base_customers.router)
base_router.include_router(base_business_roles.router)
v1_router.include_router(base_router)
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
@@ -55,4 +67,13 @@ 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)
# 月度结算(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)
+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 import business_role_service
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(business_role_service.list_options(session))
+26
View File
@@ -21,6 +21,7 @@ from app.schemas.customer import (
CustomerOut,
CustomerOwnershipIn,
CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn,
OwnershipChangeLogOut,
)
@@ -102,6 +103,31 @@ def delete_customer(
return ok(message="客户已删除")
@router.put(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerOut],
summary="保存客户评级",
)
def rate_customer(
session: DbSession,
current_user: CurrentUser,
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],
+107
View File
@@ -0,0 +1,107 @@
"""绩效核算接口(M3 ④):台账查询与单笔核算触发。
核算动作 = 把回款固化为台账行(每笔回款仅一次;重算走独立入口并授权)。
准入:读=登录即可;写(核算/重算/批量)=管理员与营销后勤
(权限清单待澄清 #6,答复后细化)。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
from app.services import calc_service
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
# 重算是授权动作:管理员/财务(营销后勤不可重算)
_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))]
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
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: _CalcWriter,
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: _RecalcUser,
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: _CalcWriter,
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))
+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
+99
View File
@@ -0,0 +1,99 @@
"""月度结算接口(M3 ④):生成/提交审批/锁定与调整单。
准入:读=登录即可;写(生成/提交/调整单)=管理员与营销后勤
(权限清单待澄清 #6,答复后细化)。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.settlement import (
AdjustmentCreateIn,
SettlementDetailOut,
SettlementGenerateIn,
SettlementOut,
SettlementQuery,
)
from app.services import settlement_service
router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"])
_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
def generate(
payload: SettlementGenerateIn,
session: DbSession,
current_user: _SettlementWriter,
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: _SettlementWriter,
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: _SettlementWriter,
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="已提交",
)
+12 -11
View File
@@ -1,8 +1,9 @@
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读);
写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志,
变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读
端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作
(提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作
日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
导出为文件流(非统一信封),前端以 blob 接收。
"""
@@ -12,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Path, Query
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.schemas.param import (
ParamBatchDetailOut,
@@ -37,7 +38,7 @@ router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
)
def get_catalog(
session: DbSession,
_: AdminUser,
_: LoginUser,
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
) -> ApiResponse[list[ParamCatalogItemOut]]:
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
@@ -63,7 +64,7 @@ def submit_change(
)
def list_changes(
session: DbSession,
_: AdminUser,
_: LoginUser,
query: Annotated[ParamBatchQuery, Query()],
) -> ApiResponse[PageResult[ParamBatchOut]]:
"""按生效日期区间分页查询变更批次(创建时间倒序)。"""
@@ -75,7 +76,7 @@ def list_changes(
)
def get_batch(
session: DbSession,
_: AdminUser,
_: LoginUser,
batch_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ParamBatchDetailOut]:
"""批次头 + 逐项旧值 → 新值。"""
@@ -97,7 +98,7 @@ def withdraw_batch(
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
def compare(
session: DbSession,
_: AdminUser,
_: LoginUser,
from_date: Annotated[date, Query(description="起始业务日期(含)")],
to_date: Annotated[date, Query(description="截止业务日期(含)")],
) -> ApiResponse[list[ParamDiffOut]]:
@@ -108,7 +109,7 @@ def compare(
@router.get("/export", summary="导出变更记录 Excel")
def export_changes(
session: DbSession,
_: AdminUser,
_: LoginUser,
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
) -> StreamingResponse:
@@ -125,7 +126,7 @@ def export_changes(
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
def preview(
session: DbSession,
_: AdminUser,
_: LoginUser,
payload: ParamPreviewIn,
) -> ApiResponse[ParamPreviewOut]:
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
@@ -139,7 +140,7 @@ def preview(
)
def get_history(
session: DbSession,
_: AdminUser,
_: LoginUser,
param_code: Annotated[str, Path(max_length=64)],
query: Annotated[ParamHistoryQuery, Query()],
) -> 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"},
)
+71
View File
@@ -0,0 +1,71 @@
"""管理员管理 T+ 只读任务;长任务交给独立 worker 执行。"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.tplus import (
TPlusBatchDetailOut,
TPlusBatchOut,
TPlusBatchQuery,
TPlusCreateIn,
TPlusErrorOut,
TPlusErrorQuery,
TPlusOverviewOut,
TPlusRetryIn,
)
from app.services.tplus import query_service, task_service
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"])
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, _: AdminUser):
"""查询配置状态、任务统计和最近核验。"""
return ok(query_service.overview(session))
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]])
def batches(session: DbSession, _: AdminUser, 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, _: AdminUser, 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,
_: AdminUser,
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: AdminUser, 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: AdminUser,
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))
+93 -1
View File
@@ -11,13 +11,20 @@
import argparse
import getpass
import sys
import time
from datetime import datetime
from sqlalchemy import select
from app.core.config import get_settings
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
from app.core.db import dispose_engine, get_session_factory
from app.core.security import hash_password
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
from app.models import Role, User, UserRole
from app.repositories.tplus_repository import get_batch
from app.services.tplus.sync_service import TPlusSyncService
from app.services.tplus.worker import run_once
def _prompt_password() -> str:
@@ -80,6 +87,54 @@ def _create_admin(args: argparse.Namespace) -> int:
return 0
def _stage_tplus(args: argparse.Namespace) -> int:
"""读取 T+ 源库并写入 MySQL staging。"""
target_factory = get_session_factory()
source_factory = get_tplus_session_factory()
watermark = datetime.fromisoformat(args.watermark) if args.watermark else None
with source_factory() as source_session, target_factory() as target_session:
batch = TPlusSyncService(source_session, target_session).run(
entity_types=args.entity,
watermark=watermark,
batch_size=args.batch_size,
)
print(
f"T+ staging 完成:批次={batch.batch_no} 状态={batch.status} "
f"源记录={batch.source_record_count} 新增={batch.staged_record_count} "
f"失败={batch.failed_record_count}"
)
return 0 if batch.status == "SUCCEEDED" else 1
def _tplus_worker(args: argparse.Namespace) -> int:
"""独立消费持久化任务;HTTP 进程不承担长时间读取。"""
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
if not 2 <= args.poll_seconds <= 60:
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
return 1
factory = get_session_factory()
try:
while True:
with factory() as session:
batch_no = run_once(session)
batch = get_batch(session, batch_no) if batch_no else None
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":
time.sleep(args.poll_seconds)
except KeyboardInterrupt:
print("T+ 读取服务已停止")
return 130
except Exception as exc:
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
return 1
def main() -> int:
"""命令行入口。"""
parser = argparse.ArgumentParser(
@@ -96,12 +151,49 @@ def main() -> int:
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
)
p_tplus = sub.add_parser("tplus-stage", help="读取 T+ 源库并写入原始 staging")
p_tplus.add_argument(
"--entity",
action="append",
choices=[
"personnel",
"products",
"customers",
"businesses",
"shipments",
"receipts",
"business_items",
"shipment_items",
"receipt_items",
],
required=True,
help="实体类型,可重复指定",
)
p_tplus.add_argument(
"--watermark",
default=None,
help="增量起始时间,ISO 8601 格式;不传表示全量读取",
)
p_tplus.add_argument("--batch-size", type=int, default=500, help="每次从源库读取的行数")
p_worker = sub.add_parser("tplus-worker", help="执行已排队的 T+ 读取或关联核验")
modes = p_worker.add_mutually_exclusive_group()
modes.add_argument("--once", action="store_true", help="执行一个任务后退出(默认)")
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
args = parser.parse_args()
try:
return _create_admin(args)
if args.command == "create-admin":
return _create_admin(args)
if args.command == "tplus-worker":
return _tplus_worker(args)
return _stage_tplus(args)
finally:
dispose_engine()
dispose_tplus_engine()
if __name__ == "__main__":
+6 -5
View File
@@ -27,11 +27,12 @@ _WIDTH, _HEIGHT = 120, 40
_FONT_SIZE = 25
_PADDING = 8
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
_INK = (15, 54, 70)
_INTERFERENCE = (126, 156, 170)
_SPECKLE = (168, 183, 192)
_PAPER = (247, 246, 242)
# 与前端 v3 配色呼应(--ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper):
# 墨色字符、次级墨干扰、辅助墨噪点、纸白底
_INK = (28, 35, 51) # #1c2333
_INTERFERENCE = (91, 101, 119) # #5b6577
_SPECKLE = (154, 163, 178) # #9aa3b2
_PAPER = (255, 255, 255) # #ffffff
_SPECKLE_COUNT = 46
_INTERFERENCE_LINES = 2
+51
View File
@@ -58,6 +58,23 @@ class Settings(BaseSettings):
redis_password: str = ""
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_connection_file: str = ""
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
t_plus_odbc_driver: str = ""
t_plus_encrypt: bool = False
t_plus_trust_server_certificate: bool = True
t_plus_connect_timeout: int = 10
t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
t_plus_query_dir: str = ""
@field_validator("app_secret_key")
@classmethod
def _check_secret_key(cls, v: str) -> str:
@@ -92,6 +109,40 @@ class Settings(BaseSettings):
auth = f":{self.redis_password}@" if self.redis_password else ""
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
@property
def t_plus_database_url(self) -> URL:
"""构造 T+ 只读 SQL Server 连接 URL。
连接 URL 只在启用 T+ 适配器时使用;密码由 SQLAlchemy URL 对象保存,
日志中调用 ``render_as_string()`` 时仍会默认隐藏密码。
"""
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 BACKEND_DIR / "uploads"
@lru_cache
def get_settings() -> Settings:
+23
View File
@@ -25,6 +25,19 @@ TOKEN_TYPE_BEARER = "Bearer"
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
ROLE_ADMIN = "admin"
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」,
@@ -70,6 +83,11 @@ MODULE_BUSINESS = "业务管理"
MODULE_SHIPMENT = "发货管理"
MODULE_RECEIPT = "回款管理"
MODULE_RATING = "客户评级"
MODULE_SYSTEM_SETTINGS = "系统设置"
MODULE_APPROVAL = "审批管理"
MODULE_CALC = "绩效核算"
MODULE_SETTLEMENT = "月度结算"
MODULE_REPORT = "报表统计"
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
@@ -77,6 +95,8 @@ 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_UPDATE = "修改"
@@ -84,3 +104,6 @@ ACTION_DELETE = "删除"
ACTION_RESET_PASSWORD = "重置密码"
ACTION_ENABLE = "启用"
ACTION_DISABLE = "禁用"
ACTION_APPROVE = "审批通过"
ACTION_REJECT = "审批驳回"
ACTION_EXPORT = "导出"
+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")
+112
View File
@@ -0,0 +1,112 @@
"""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, 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)
if resolved.t_plus_user.strip().casefold() == "sa":
raise RuntimeError("应用必须使用 T+ 专用只读账号,不能使用 sa")
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()
+2
View File
@@ -15,6 +15,7 @@ from app.core.db import dispose_engine
from app.core.exception_handlers import register_exception_handlers
from app.core.logging import setup_logging
from app.core.redis_client import close_redis
from app.core.tplus_db import dispose_tplus_engine
logger = logging.getLogger(__name__)
@@ -35,6 +36,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
)
yield
dispose_engine()
dispose_tplus_engine()
close_redis()
logger.info("应用已停止,连接池已释放")
+23 -1
View File
@@ -9,22 +9,35 @@
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
"""
from app.models.approval import ApprovalOrder, ApprovalStep
from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole
from app.models.customer import Customer, OwnershipChangeLog
from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
from app.models.performance_ledger import PerformanceLedger
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
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.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
from app.models.shipment import Shipment, ShipmentItem
from app.models.system_setting import SystemSetting
from app.models.tplus_sync import (
TPlusSyncBatch,
TPlusSyncError,
TPlusSyncRecord,
TPlusSyncWatermark,
)
from app.models.user import User, UserRole
__all__ = [
"AdjustmentOrder",
"ApprovalOrder",
"ApprovalStep",
"Base",
"Business",
"BusinessItem",
@@ -33,11 +46,13 @@ __all__ = [
"Customer",
"CustomerRatingGrade",
"LoginLog",
"MonthlySettlement",
"OperationLog",
"OwnershipChangeLog",
"ParamChangeBatch",
"ParamDefinition",
"ParamValue",
"PerformanceLedger",
"Permission",
"Personnel",
"PersonnelPlan",
@@ -46,8 +61,15 @@ __all__ = [
"Receipt",
"Role",
"RolePermission",
"SecondaryAllocation",
"SettlementItem",
"Shipment",
"ShipmentItem",
"SystemSetting",
"TPlusSyncBatch",
"TPlusSyncError",
"TPlusSyncRecord",
"TPlusSyncWatermark",
"TimestampMixin",
"User",
"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="创建时间"
)
+3
View File
@@ -91,6 +91,9 @@ class TPlusSyncMixin:
String(32), comment="同步批次号(同步模块写入)"
)
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
source_updated_at: Mapped[datetime | None] = mapped_column(
DateTime, comment="T+ 源数据更新时间(未确认时可为空)"
)
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
+87 -1
View File
@@ -15,7 +15,17 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, text
from sqlalchemy import (
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
@@ -33,6 +43,82 @@ 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 的「合同编号」。"""
+8
View File
@@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
rating_score: Mapped[int | None] = mapped_column(
TINYINT_TYPE,
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
)
rating_time: Mapped[datetime | None] = mapped_column(
DateTime,
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
)
class OwnershipChangeLog(Base):
+181
View File
@@ -0,0 +1,181 @@
"""绩效核算台账模型(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",
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_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)",
)
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已按 80% 折算 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="备注")
+197
View File
@@ -0,0 +1,197 @@
"""月度结算与调整单模型(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="锁定操作人(冗余固化)")
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="净工资 = 绩效±基本工资(按口径;试用期 0)"
)
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
)
probation_note: Mapped[str | None] = mapped_column(
String(255), 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"
)
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="提交人用户名(冗余固化)"
)
+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=未上传使用默认)",
)
+149
View File
@@ -0,0 +1,149 @@
"""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任务为空,无物理外键"
)
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"
)
+104 -74
View File
@@ -5,9 +5,7 @@
models/customer.py 定义)。
"""
from datetime import date
from sqlalchemy import Row, Select, delete, exists, func, or_, select
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
from sqlalchemy.orm import Session, aliased
from app.models import (
@@ -18,10 +16,13 @@ from app.models import (
OwnershipChangeLog,
Personnel,
Receipt,
SecondaryAllocation,
Shipment,
)
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.schemas.business import BusinessQuery
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
@@ -76,50 +77,32 @@ def get_with_names(
return session.execute(stmt).first()
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> Select:
def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
"""业务列表的筛选条件(调用方须已 join Customer)。"""
if keyword:
if query.keyword:
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
stmt = stmt.where(
or_(
Business.business_no.contains(keyword, autoescape=True),
Customer.company_name.contains(keyword, autoescape=True),
Business.business_no.contains(query.keyword, autoescape=True),
Customer.company_name.contains(query.keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(Business.status == status)
if customer_id is not None:
stmt = stmt.where(Business.customer_id == customer_id)
if owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
if date_from is not None:
stmt = stmt.where(Business.business_date >= date_from)
if date_to is not None:
stmt = stmt.where(Business.business_date <= date_to)
if query.status is not None:
stmt = stmt.where(Business.status == query.status)
if query.customer_id is not None:
stmt = stmt.where(Business.customer_id == query.customer_id)
if query.owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id)
if query.date_from is not None:
stmt = stmt.where(Business.business_date >= query.date_from)
if query.date_to is not None:
stmt = stmt.where(Business.business_date <= query.date_to)
return stmt
def list_businesses(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
session: Session, query: BusinessQuery
) -> list[tuple[Business, str, str | None, str | None]]:
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
owner = aliased(Personnel)
@@ -127,49 +110,24 @@ def list_businesses(
stmt = select(
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
).join(Customer, Customer.id == Business.customer_id)
stmt = _apply_filters(
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
stmt = _apply_filters(stmt, query)
stmt = (
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
.order_by(Business.id.desc())
.offset(offset)
.limit(limit)
.offset(query.offset)
.limit(query.page_size)
)
return list(session.execute(stmt).all())
def count_businesses(
session: Session,
*,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> int:
def count_businesses(session: Session, query: BusinessQuery) -> int:
stmt = (
select(func.count())
.select_from(Business)
.join(Customer, Customer.id == Business.customer_id)
)
stmt = _apply_filters(
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
stmt = _apply_filters(stmt, query)
return int(session.execute(stmt).scalar_one())
@@ -198,6 +156,79 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
session.flush()
def list_allocations(
session: Session, business_id: int, *, active_only: bool = True
) -> list[SecondaryAllocation]:
"""二次分配方案(详情回显与核算取数;默认仅当前生效版本)。"""
stmt = select(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
if active_only:
stmt = stmt.where(SecondaryAllocation.is_active == 1)
return list(
session.execute(
stmt.order_by(SecondaryAllocation.version_no, SecondaryAllocation.id)
).scalars()
)
def list_allocation_versions(session: Session, business_id: int) -> list[SecondaryAllocation]:
"""方案全部版本行(含历史;业务详情「方案历史」用,按版本正序)。"""
return list_allocations(session, business_id, active_only=False)
def replace_allocations(
session: Session, business_id: int, rows: list[SecondaryAllocation]
) -> None:
"""全量替换二次分配方案:旧生效行置历史,新行按下一版本号插入。
不 commit(事务边界在服务层)。版本行 append-only——编辑与清空都不
物理删除,驳回后仍可回溯被否决方案的参与人与比例。空 ``rows`` 表示
只清空生效态(不产生新版本)。
"""
deactivate_allocations(session, business_id)
if not rows:
return
next_version = (
session.execute(
select(func.coalesce(func.max(SecondaryAllocation.version_no), 0)).where(
SecondaryAllocation.business_id == business_id
)
).scalar_one()
+ 1
)
for row in rows:
row.business_id = business_id
row.version_no = next_version
row.is_active = 1
session.add(row)
session.flush()
def deactivate_allocations(session: Session, business_id: int) -> None:
"""把业务的当前生效方案置为历史(审批驳回/编辑替换时调用;行保留)。
被否决的方案不得以「当前生效行」身份继续被核算取到;``is_active=0``
后核算侧自然读不到,而历史版本仍可在业务详情回溯。不 commit。
显式声明 ``synchronize_session="fetch"``:Core UPDATE 默认走 auto 策略
(优先 evaluate,本例的条件恰好能在 Python 侧求值,故实测也会刷新在途
对象)。显式写 fetch 是为了不把「在途对象被刷新」这件事押在 evaluate
能否解析条件上——条件一旦复杂化,auto 会退化为不刷新,同一会话里已
加载的方案行就会停留在 ``is_active=1`` 的过期内存值(查询虽有
is_active 过滤,但行对象本身仍是「生效态」,被序列化或参与判断就是
错的)。
"""
session.execute(
update(SecondaryAllocation)
.where(
SecondaryAllocation.business_id == business_id,
SecondaryAllocation.is_active == 1,
)
.values(is_active=0)
.execution_options(synchronize_session="fetch")
)
session.flush()
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
"""追加状态留痕(不 commit)。"""
session.add(log)
@@ -239,18 +270,17 @@ def list_ownership_logs(
) -> list[Row]:
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session,
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,
offset=offset,
limit=limit,
session, _business_log_ref(business_id), offset=offset, limit=limit
)
def count_ownership_logs(session: Session, business_id: int) -> int:
return ownership_log_repository.count_by_ref(
session,
return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id))
def _business_log_ref(business_id: int) -> OwnershipLogRef:
"""业务维度的归属留痕锚点。"""
return OwnershipLogRef(
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessRole
from app.schemas.business_role import BusinessRoleQuery
# ==================== 读取 ====================
@@ -39,41 +40,48 @@ def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, Business
return {role.role_code: role for role in rows}
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[BusinessRole]:
def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]:
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
stmt = _apply_filters(select(BusinessRole), query)
stmt = (
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
.offset(query.offset)
.limit(query.page_size)
)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
def count_roles(session: Session, query: BusinessRoleQuery) -> int:
"""统计符合条件的岗位总数,供分页使用。"""
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
stmt = _apply_filters(select(func.count(BusinessRole.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
def list_all(session: Session) -> list[BusinessRole]:
"""全部岗位(含停用,不分页)——岗位下拉选项的取数口径。
岗位规模有限(内置 + 自定义),一次取全量,不受分页上限约束;
排序与 list_roles 一致(sort_order, id),下拉与列表相对次序相同。
"""
stmt = select(BusinessRole).order_by(BusinessRole.sort_order, BusinessRole.id)
return list(session.execute(stmt).scalars())
def _apply_filters(stmt: Select, query: BusinessRoleQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
不转义时 ``_`` 会当单字符通配执行。
"""
if keyword:
if query.keyword:
stmt = stmt.where(
or_(
BusinessRole.role_code.contains(keyword, autoescape=True),
BusinessRole.role_name.contains(keyword, autoescape=True),
BusinessRole.role_code.contains(query.keyword, autoescape=True),
BusinessRole.role_name.contains(query.keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(BusinessRole.status == status)
if query.status is not None:
stmt = stmt.where(BusinessRole.status == query.status)
return stmt
+41 -48
View File
@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
from app.models import Customer, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.repositories.query_utils import fuzzy
from app.schemas.customer import CustomerQuery
# ==================== 读取 ====================
@@ -45,49 +47,27 @@ def get_by_company_owner(session: Session, company_name: str, owner_name: str) -
return session.execute(stmt).scalar_one_or_none()
def list_customers(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> list[Row]:
def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
"""
stmt = _apply_filters(
_base_select(),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
stmt = _apply_filters(_base_select(), query)
stmt = _apply_order(stmt, query)
stmt = stmt.offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).all())
def count_customers(
session: Session,
*,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> int:
def count_customers(session: Session, query: CustomerQuery) -> int:
"""统计符合条件的客户总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(
select(func.count(Customer.id)),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = _apply_filters(select(func.count(Customer.id)), query)
return int(session.execute(stmt).scalar_one())
@@ -101,9 +81,7 @@ def list_ownership_logs(
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session,
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
_customer_log_ref(customer_id),
offset=offset,
limit=limit,
)
@@ -111,8 +89,12 @@ def list_ownership_logs(
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
"""统计某客户的归属留痕总数,供分页使用。"""
return ownership_log_repository.count_by_ref(
session,
return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id))
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
"""客户维度的归属留痕锚点。"""
return OwnershipLogRef(
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
@@ -126,20 +108,14 @@ def _base_select() -> Select:
)
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
customer_type: str | None,
owner_personnel_id: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword:
stmt = stmt.where(_keyword_clause(keyword))
if customer_type:
stmt = stmt.where(Customer.customer_type == customer_type)
if owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
if query.keyword:
stmt = stmt.where(_keyword_clause(query.keyword))
if query.customer_type:
stmt = stmt.where(Customer.customer_type == query.customer_type)
if query.owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
return stmt
@@ -157,6 +133,23 @@ def _keyword_clause(keyword: str) -> ColumnElement[bool]:
)
def _apply_order(stmt: Select, query: CustomerQuery) -> Select:
"""应用排序。排序键已由 schema 白名单收口,此处只做键 → 列的映射。
MySQL 把 NULL 视为最小值:ASC 时未评分(null)会排最前,但「没打分」
不该压在「打了低分」之上 —— ASC 分支用 ``is_(None)`` 布尔键把 null
挪到末尾;DESC 分支 null 天然在最后,无需处理。尾部再按 id 倒序兜底,
保证同分行的次序稳定(分页不跳行)。
"""
if query.order_by == "rating_score":
score = Customer.rating_score
if query.order == "asc":
stmt = stmt.order_by(score.is_(None), score.asc())
else:
stmt = stmt.order_by(score.desc())
return stmt.order_by(Customer.id.desc())
# ==================== 写入 ====================
@@ -0,0 +1,247 @@
"""工作台仓储:四区块的只读聚合查询;不 commit、不抛业务异常。
总览只做 SQL 聚合(SUM/COUNT DISTINCT),不复用报表的
``department_summary``——后者会加载整月台账并逐团队查人员,适合报表页,
不适合每次打开工作台。
"""
from datetime import date, datetime
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session, defer
from app.models import (
ApprovalOrder,
Business,
BusinessRole,
MonthlySettlement,
PerformanceLedger,
Personnel,
Receipt,
Shipment,
)
from app.models.approval import (
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
)
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
_PENDING_STATUSES = (
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
)
# ==== 经营绩效总览 ====
def sum_month_performance(session: Session, month: str) -> dict:
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
return (
session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
).where(
PerformanceLedger.settlement_month == month,
*_ACTIVE_SINGLE,
)
)
.mappings()
.one()
)
def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
"""近 N 个月绩效合计(按台账归属月)。"""
rows = session.execute(
select(
PerformanceLedger.settlement_month,
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0),
)
.where(
PerformanceLedger.settlement_month.in_(months),
*_ACTIVE_SINGLE,
)
.group_by(PerformanceLedger.settlement_month)
).all()
return dict(rows)
def receipt_trend(session: Session, *, start_date: date, end_date: date) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
分组,不参与 WHERE,避免函数列导致全表扫描。
"""
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
rows = session.execute(
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
.where(Receipt.receipt_date >= start_date, Receipt.receipt_date < end_date)
.group_by(month_expr)
).all()
return dict(rows)
def count_active_managers(session: Session) -> int:
"""在职营销经理数(团队数口径,与部门汇总的团队枚举一致)。"""
return int(
session.execute(
select(func.count())
.select_from(Personnel)
.join(BusinessRole, Personnel.business_role_id == BusinessRole.id)
.where(
BusinessRole.role_code == "sales_manager",
Personnel.status == 1,
)
).scalar_one()
)
def get_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
"""当月月结头(状态与锁定时间角标)。"""
return session.execute(
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
).scalar_one_or_none()
# ==== 待办事项 ====
def list_pending_approvals(
session: Session, *, statuses: tuple[int, ...], limit: int
) -> list[ApprovalOrder]:
"""待处理审批单(按提交时间升序——最久的排前面)。
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
排除 snapshot_json 列(快照可达上百 KB,待办只取单号/类型/提交人/时间)。
"""
if not statuses:
return []
return list(
session.execute(
select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(ApprovalOrder.status.in_(statuses))
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
.limit(limit)
).scalars()
)
def count_pending_approvals(session: Session, *, statuses: tuple[int, ...]) -> int:
if not statuses:
return 0
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(ApprovalOrder.status.in_(statuses))
).scalar_one()
)
def list_unassigned_receipts(session: Session, *, limit: int) -> list:
"""回款未指定发货批次(shipment_id IS NULL,核算前置缺失)。"""
return list(
session.execute(
select(
Receipt,
Business.business_no.label("business_no"),
)
.outerjoin(Business, Business.id == Receipt.business_id)
.where(Receipt.shipment_id.is_(None))
.order_by(Receipt.receipt_date.asc(), Receipt.id.asc())
.limit(limit)
).all()
)
def count_unassigned_receipts(session: Session) -> int:
return int(
session.execute(
select(func.count()).select_from(Receipt).where(Receipt.shipment_id.is_(None))
).scalar_one()
)
# ==== 风险预警 ====
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
已终止业务(status=3)不再催收,排除在预警之外;业务行缺失时仍保留,
避免历史脏数据让发货单从预警中静默消失。
"""
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
return list(
session.execute(
select(
Shipment,
Business.business_no.label("business_no"),
)
.outerjoin(Business, Business.id == Shipment.business_id)
.where(
Shipment.ship_date <= cutoff,
~has_receipt,
or_(Business.status.is_(None), Business.status != 3),
)
.order_by(Shipment.ship_date.asc())
.limit(limit)
).all()
)
def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
return int(
session.execute(
select(func.count())
.select_from(Shipment)
.outerjoin(Business, Business.id == Shipment.business_id)
.where(
Shipment.ship_date <= cutoff,
~has_receipt,
or_(Business.status.is_(None), Business.status != 3),
)
).scalar_one()
)
def list_overdue_approvals(
session: Session, *, cutoff: datetime, limit: int
) -> list[ApprovalOrder]:
"""提交后超过阈值仍未处理的审批单(审批超时规则)。
排除 snapshot_json 列(同待办:预警只用单号/类型/提交人/时间)。
"""
return list(
session.execute(
select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(
ApprovalOrder.status.in_(_PENDING_STATUSES),
ApprovalOrder.create_time < cutoff,
)
.order_by(ApprovalOrder.create_time.asc())
.limit(limit)
).scalars()
)
def count_overdue_approvals(session: Session, *, cutoff: datetime) -> int:
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(
ApprovalOrder.status.in_(_PENDING_STATUSES),
ApprovalOrder.create_time < cutoff,
)
).scalar_one()
)
@@ -1,5 +1,7 @@
"""登录日志数据访问。"""
from dataclasses import dataclass
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
@@ -10,68 +12,73 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
_USER_AGENT_MAX_LEN = 255
def add(
session: Session,
*,
user_id: int | None,
username: str,
ip: str | None,
user_agent: str | None,
success: bool,
fail_reason: str | None = None,
) -> None:
@dataclass(frozen=True, slots=True)
class LoginLogEntry:
"""一条待写入的登录日志(全字段 DTO)。
与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传
都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。
"""
user_id: int | None
username: str
success: bool
ip: str | None = None
user_agent: str | None = None
fail_reason: str | None = None
@dataclass(frozen=True, slots=True)
class LoginLogListQuery:
"""登录日志列表的查询条件(筛选 + 分页)。"""
filters: LogFilters
offset: int = 0
limit: int = 20
ip: str | None = None
def add(session: Session, entry: LoginLogEntry) -> None:
"""写入一条登录日志。只 flush 不 commit。
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
:param user_id: 账号不存在时传 None(列本身可空)
:param username: 用户名原样记录,便于账号不存在时也能追溯
:param entry.user_id: 账号不存在时为 None(列本身可空)
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯
"""
session.add(
LoginLog(
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
success=1 if success else 0,
fail_reason=fail_reason,
user_id=entry.user_id,
username=entry.username,
ip=entry.ip,
user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None,
success=1 if entry.success else 0,
fail_reason=entry.fail_reason,
)
)
session.flush()
def list_logs(
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
ip: str | None = None,
) -> list[LoginLog]:
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
"""分页查询登录日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
不确定,翻页会重复或遗漏(与用户列表同一理由)。
"""
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
stmt = _apply_filters(select(LoginLog), query)
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
return list(session.execute(stmt).scalars())
def count_logs(
session: Session,
*,
filters: LogFilters,
ip: str | None = None,
) -> int:
def count_logs(session: Session, query: LoginLogListQuery) -> int:
"""统计符合条件的登录日志总数,供分页使用。"""
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
stmt = _apply_filters(select(func.count(LoginLog.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
def _apply_filters(stmt: Select, query: LoginLogListQuery) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
stmt = apply_log_filters(stmt, LoginLog, filters)
if ip:
stmt = stmt.where(LoginLog.ip == ip)
stmt = apply_log_filters(stmt, LoginLog, query.filters)
if query.ip:
stmt = stmt.where(LoginLog.ip == query.ip)
return stmt
@@ -107,15 +107,18 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
return session.get(OperationLog, log_id)
def list_logs(
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
module: str | None = None,
action: str | None = None,
) -> list[OperationLog]:
@dataclass(frozen=True, slots=True)
class OperationLogListQuery:
"""操作日志列表的查询条件(筛选 + 分页)。"""
filters: LogFilters
offset: int = 0
limit: int = 20
module: str | None = None
action: str | None = None
def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]:
"""分页查询操作日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
@@ -127,41 +130,34 @@ def list_logs(
"""
stmt = _apply_filters(
select(OperationLog).options(defer(OperationLog.request_data)),
filters=filters,
module=module,
action=action,
query,
)
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
stmt = stmt.order_by(OperationLog.id.desc()).offset(query.offset).limit(query.limit)
return list(session.execute(stmt).scalars())
def count_logs(
session: Session,
*,
filters: LogFilters,
module: str | None = None,
action: str | None = None,
) -> int:
def count_logs(session: Session, query: OperationLogListQuery) -> int:
"""统计符合条件的日志总数,供分页使用。"""
stmt = _apply_filters(
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
)
stmt = _apply_filters(select(func.count(OperationLog.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
filters: LogFilters,
module: str | None,
action: str | None,
) -> Select:
def distinct_modules(session: Session) -> list[str]:
"""全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。
交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。
"""
stmt = select(OperationLog.module).distinct().order_by(OperationLog.module)
return [module for module in session.execute(stmt).scalars() if module]
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
stmt = apply_log_filters(stmt, OperationLog, filters)
if module:
stmt = stmt.where(OperationLog.module == module)
if action:
stmt = stmt.where(OperationLog.action == action)
stmt = apply_log_filters(stmt, OperationLog, query.filters)
if query.module:
stmt = stmt.where(OperationLog.module == query.module)
if query.action:
stmt = stmt.where(OperationLog.action == query.action)
return stmt
@@ -5,18 +5,32 @@ owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
"""
from dataclasses import dataclass
from typing import Any
from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session, aliased
from app.models import OwnershipChangeLog, Personnel
@dataclass(frozen=True, slots=True)
class OwnershipLogRef:
"""归属留痕的引用锚点(维度类型 + 引用列 + 引用 ID)。
引用列是 SQLAlchemy 列表达式(如 ``OwnershipChangeLog.customer_id``),
不是可序列化值 —— 故用冻结 dataclass 而非 Pydantic 模型。
"""
owner_type: int
ref_column: Any
ref_id: int
def list_by_ref(
session: Session,
ref: OwnershipLogRef,
*,
owner_type: int,
ref_column,
ref_id: int,
offset: int,
limit: int,
) -> list[Row]:
@@ -32,7 +46,7 @@ def list_by_ref(
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
.where(OwnershipChangeLog.owner_type == ref.owner_type, ref.ref_column == ref.ref_id)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
@@ -40,13 +54,11 @@ def list_by_ref(
return list(session.execute(stmt).all())
def count_by_ref(
session: Session, *, owner_type: int, ref_column, ref_id: int
) -> int:
def count_by_ref(session: Session, ref: OwnershipLogRef) -> int:
"""统计某引用对象的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == owner_type,
ref_column == ref_id,
OwnershipChangeLog.owner_type == ref.owner_type,
ref.ref_column == ref.ref_id,
)
return int(session.execute(stmt).scalar_one())
+28 -38
View File
@@ -10,6 +10,7 @@ from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
from app.schemas.param import ParamBatchQuery, ParamHistoryQuery
# ==================== 读取 ====================
@@ -97,27 +98,27 @@ def list_definitions_by_codes(
def list_history(
session: Session,
param_id: int,
*,
offset: int,
limit: int,
dimension_value: str | None = None,
session: Session, param_id: int, query: ParamHistoryQuery
) -> list[tuple[ParamValue, ParamChangeBatch]]:
"""单参数的版本历史(含批次信息),最新在前。"""
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
"""单参数的版本历史(含批次信息),最新在前。
``param_id`` 是「查哪个参数」的主锚点(由路径参数 param_code 解析而来),
不属于列表筛选项,故仍以位置参数传入;筛选与分页走 ``query``。
"""
stmt = _history_stmt(param_id, query)
return [
(value, batch)
for value, batch in session.execute(stmt.offset(query.offset).limit(query.page_size))
]
def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
def count_history(session: Session, param_id: int, query: ParamHistoryQuery) -> int:
"""统计单参数的历史条目数。"""
stmt = select(func.count()).select_from(
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
)
stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery())
return int(session.execute(stmt).scalar_one())
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
stmt = (
select(ParamValue, ParamChangeBatch)
@@ -125,8 +126,8 @@ def _history_stmt(session: Session, param_id: int, *, dimension_value: str | Non
.where(ParamValue.param_id == param_id)
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
)
if dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
if query.dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == query.dimension_value)
return stmt
@@ -150,41 +151,30 @@ def get_batch_by_id(
return session.get(ParamChangeBatch, batch_id)
def list_batches(
session: Session,
*,
offset: int,
limit: int,
start_date: date | None = None,
end_date: date | None = None,
) -> list[ParamChangeBatch]:
def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]:
"""分页查询变更批次(按创建时间倒序)。"""
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
stmt = _batch_filters(select(ParamChangeBatch), query)
return list(
session.execute(
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
.offset(offset)
.limit(limit)
.offset(query.offset)
.limit(query.page_size)
).scalars()
)
def count_batches(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> int:
def count_batches(session: Session, query: ParamBatchQuery) -> int:
"""统计符合条件的批次总数。"""
stmt = _batch_filters(
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
)
stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query)
return int(session.execute(stmt).scalar_one())
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
def _batch_filters(stmt: Select, query: ParamBatchQuery) -> Select:
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
if start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
if end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
if query.start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= query.start_date)
if query.end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= query.end_date)
return stmt
@@ -0,0 +1,156 @@
"""绩效核算台账仓储:只读写数据并 flush;commit/rollback 在服务层。"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import PerformanceLedger
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
from app.schemas.calc import LedgerQuery
def get_by_id(session: Session, ledger_id: int) -> PerformanceLedger | None:
"""按主键取台账行。"""
return session.get(PerformanceLedger, ledger_id)
def get_active_by_receipt(
session: Session, receipt_id: int, *, calc_type: int = 1
) -> PerformanceLedger | None:
"""取某回款的当前生效核算行(is_active=1;无则 None)。
二次分摊拆行后同回款可有多行(负责人+参与人)——重算作废用
``list_active_by_receipt``;本函数仅限确知单行的调用方(重算前的
存在性判断按业务维度走 has_active_for_business)。
"""
return session.execute(
select(PerformanceLedger).where(
PerformanceLedger.receipt_id == receipt_id,
PerformanceLedger.calc_type == calc_type,
PerformanceLedger.is_active == 1,
)
).scalar_one_or_none()
def list_active_by_receipt(
session: Session, receipt_id: int, *, calc_type: int = 1
) -> list[PerformanceLedger]:
"""取某回款当前生效的**全部**核算行(分摊拆行后负责人+参与人各一行)。"""
return list(
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.receipt_id == receipt_id,
PerformanceLedger.calc_type == calc_type,
PerformanceLedger.is_active == 1,
)
).scalars()
)
def has_active_for_business(session: Session, business_id: int, *, calc_type: int = 1) -> bool:
"""该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。"""
return (
session.execute(
select(func.count())
.select_from(PerformanceLedger)
.where(
PerformanceLedger.business_id == business_id,
PerformanceLedger.calc_type == calc_type,
PerformanceLedger.is_active == 1,
)
).scalar_one()
> 0
)
def get_active_by_receipt_of_business(
session: Session, business_id: int, *, calc_type: int = 1
) -> PerformanceLedger:
"""取该业务当前生效的核算行(重复回款登记进度时回显既有台账)。"""
return (
session.execute(
select(PerformanceLedger)
.where(
PerformanceLedger.business_id == business_id,
PerformanceLedger.calc_type == calc_type,
PerformanceLedger.is_active == 1,
)
.order_by(PerformanceLedger.id.desc())
)
.scalars()
.first()
)
def add(session: Session, row: PerformanceLedger) -> PerformanceLedger:
"""落台账行(flush 取 id;commit 在服务层)。"""
session.add(row)
session.flush()
return row
def mark_inactive(session: Session, row: PerformanceLedger) -> None:
"""重算时把旧版本行翻为已替代(全表唯一的受控 UPDATE 例外;不 commit)。"""
row.is_active = 0
session.flush()
def deactivate_month_aggregates(session: Session, month: str) -> None:
"""月结重生成时作废该月全部经理聚合行(calc_type=2)。
聚合行每次生成重建,旧行不翻 0 会与新行重复计入(按人聚合按 is_active
过滤)。与 mark_inactive 同属 is_active 翻 0 的受控 UPDATE 例外;set 批量
版本而非逐行——聚合行一个月至多几行,且无逐行回读必要。
"""
from sqlalchemy import update
session.execute(
update(PerformanceLedger)
.where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == CALC_TYPE_MANAGER_AGGREGATE,
PerformanceLedger.is_active == 1,
)
.values(is_active=0)
)
session.flush()
def next_version(session: Session, receipt_id: int) -> int:
"""某回款的下一个核算版本号(max+1;无行时 1)。"""
current = session.execute(
select(func.max(PerformanceLedger.calc_version)).where(
PerformanceLedger.receipt_id == receipt_id
)
).scalar_one()
return int(current or 0) + 1
def list_ledger(session: Session, query: LedgerQuery) -> list[PerformanceLedger]:
"""分页查询台账(筛选:月/人员/业务/生效/类型)。"""
stmt = select(PerformanceLedger).where(*_filters(query))
stmt = stmt.order_by(PerformanceLedger.id.desc()).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars())
def count_ledger(session: Session, query: LedgerQuery) -> int:
"""台账计数(与 list_ledger 同筛选)。"""
return int(
session.execute(
select(func.count()).select_from(PerformanceLedger).where(*_filters(query))
).scalar_one()
)
def _filters(query: LedgerQuery) -> tuple:
filters = []
if query.settlement_month:
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
if query.personnel_id is not None:
filters.append(PerformanceLedger.personnel_id == query.personnel_id)
if query.business_id is not None:
filters.append(PerformanceLedger.business_id == query.business_id)
if query.is_active is not None:
filters.append(PerformanceLedger.is_active == query.is_active)
if query.calc_type is not None:
filters.append(PerformanceLedger.calc_type == query.calc_type)
return tuple(filters)
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, aliased
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
from app.repositories.query_utils import fuzzy
from app.schemas.personnel import PersonnelQuery
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
_manager = aliased(Personnel)
@@ -30,44 +31,23 @@ def get_with_names(session: Session, personnel_id: int) -> Row | None:
return session.execute(stmt).first()
def list_personnel(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> list[Row]:
def list_personnel(session: Session, query: PersonnelQuery) -> list[Row]:
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
"""
stmt = _apply_filters(
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
)
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
stmt = _apply_filters(_base_select(), query)
stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).all())
def count_personnel(
session: Session,
*,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> int:
def count_personnel(session: Session, query: PersonnelQuery) -> int:
"""统计符合条件的人员总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(
select(func.count(Personnel.id)),
keyword=keyword,
status=status,
business_role_id=business_role_id,
)
stmt = _apply_filters(select(func.count(Personnel.id)), query)
return int(session.execute(stmt).scalar_one())
@@ -154,20 +134,14 @@ def _base_select() -> Select:
)
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
business_role_id: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: PersonnelQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
if status is not None:
stmt = stmt.where(Personnel.status == status)
if business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == business_role_id)
if query.keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, query.keyword)
if query.status is not None:
stmt = stmt.where(Personnel.status == query.status)
if query.business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == query.business_role_id)
return stmt
+14 -43
View File
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessItem, Product, ShipmentItem
from app.schemas.product import ProductQuery
# ==================== 读取 ====================
@@ -34,50 +35,20 @@ def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Pr
return session.execute(stmt).scalar_one_or_none()
def list_products(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> list[Product]:
def list_products(session: Session, query: ProductQuery) -> list[Product]:
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
stmt = _apply_filters(
select(Product),
keyword=keyword,
product_category=product_category,
status=status,
)
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
stmt = _apply_filters(select(Product), query)
stmt = stmt.order_by(Product.id).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars())
def count_products(
session: Session,
*,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> int:
def count_products(session: Session, query: ProductQuery) -> int:
"""统计符合条件的产品总数,供分页使用。"""
stmt = _apply_filters(
select(func.count(Product.id)),
keyword=keyword,
product_category=product_category,
status=status,
)
stmt = _apply_filters(select(func.count(Product.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
product_category: str | None,
status: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
@@ -86,17 +57,17 @@ def _apply_filters(
写 contains —— M1 business_role_repository 的双列关键词同款写法。
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
"""
if keyword:
if query.keyword:
stmt = stmt.where(
or_(
Product.product_name.contains(keyword, autoescape=True),
Product.spec_model.contains(keyword, autoescape=True),
Product.product_name.contains(query.keyword, autoescape=True),
Product.spec_model.contains(query.keyword, autoescape=True),
)
)
if product_category:
stmt = stmt.where(Product.product_category == product_category)
if status is not None:
stmt = stmt.where(Product.status == status)
if query.product_category:
stmt = stmt.where(Product.product_category == query.product_category)
if query.status is not None:
stmt = stmt.where(Product.status == query.status)
return stmt
@@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
"""按主键查询。
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
等级表无外键引用(客户档案只存总分,等级由分数按当前映射实时推导,
不存等级 id),无需 ``for_update`` 锁行 —— product_repository 锁行防的
是并发引用悬空,此处不存在该风险:删掉一个等级,只是以后不再判到它。
"""
return session.get(CustomerRatingGrade, grade_id)
@@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
"""物理删除等级。
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
"""
session.delete(grade)
session.flush()
+22 -51
View File
@@ -14,6 +14,7 @@ from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment
from app.schemas.receipt import ReceiptQuery
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
@@ -43,72 +44,42 @@ def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
return session.get(Shipment, shipment_id)
def _apply_filters(
stmt: Select,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
if business_id is not None:
stmt = stmt.where(Receipt.business_id == business_id)
if date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= date_from)
if date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= date_to)
if is_barter is not None:
stmt = stmt.where(Receipt.is_barter == is_barter)
if settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == settlement_status)
if query.business_id is not None:
stmt = stmt.where(Receipt.business_id == query.business_id)
if query.date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= query.date_from)
if query.date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= query.date_to)
if query.is_barter is not None:
stmt = stmt.where(Receipt.is_barter == query.is_barter)
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
if query.settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
return stmt
def list_receipts(
session: Session,
*,
offset: int,
limit: int,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
session: Session, query: ReceiptQuery
) -> list[tuple[Receipt, str, str | None, date | None]]:
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
stmt = _apply_filters(
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
.join(Business, Business.id == Receipt.business_id)
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
business_id=business_id,
date_from=date_from,
date_to=date_to,
is_barter=is_barter,
settlement_status=settlement_status,
query,
)
return list(
session.execute(
stmt.order_by(Receipt.id.desc()).offset(query.offset).limit(query.page_size)
).all()
)
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
def count_receipts(
session: Session,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> int:
def count_receipts(session: Session, query: ReceiptQuery) -> int:
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters(
select(func.count()).select_from(Receipt),
business_id=business_id,
date_from=date_from,
date_to=date_to,
is_barter=is_barter,
settlement_status=settlement_status,
)
stmt = _apply_filters(select(func.count()).select_from(Receipt), query)
return int(session.execute(stmt).scalar_one())
@@ -0,0 +1,421 @@
"""报表统计仓储:只读聚合查询;不 commit、不抛业务异常。
报表与台账分页查询刻意分开而非复用 ``performance_ledger_repository``:
- 台账查询允许聚合行(calc_type=2),报表明细必须显式排除——经理聚合
行 receipt_id 为 NULL,混入明细会让合同编号/天数列失真、业务统计的
绩效被整团队聚合额污染;
- 月结的 ``_confirm_scope`` 带 ``settlement_id IS NULL``(可入结口径),
报表要看的是同月**全量**生效单笔行,口径不同;
- 聚合需求(SUM/GROUP BY)台账仓储完全没有,报表在此统一实现。
发货额/回款额直接读 businesses 的汇总冗余列(服务层事务内单点维护),
不逐业务子查询发货/回款表;绩效额 businesses 无冗余列,用子查询联台账。
"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.report_meta import DayBucket
from app.models import (
AdjustmentOrder,
Business,
Customer,
MonthlySettlement,
PerformanceLedger,
Personnel,
)
from app.schemas.report import (
BusinessStatsQuery,
PaymentDetailQuery,
PaymentStatsQuery,
PersonalDetailQuery,
)
# 单笔生效行基础条件:报表四表共用(聚合行与已替代版本一律排除)
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
# ==== 个人绩效明细(FR-029)====
def _personal_filters(query: PersonalDetailQuery) -> tuple:
filters = [*_ACTIVE_SINGLE]
if query.settlement_month:
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
if query.personnel_id is not None:
filters.append(PerformanceLedger.personnel_id == query.personnel_id)
if query.customer_type:
filters.append(PerformanceLedger.customer_type == query.customer_type)
if query.product_category:
filters.append(PerformanceLedger.product_category == query.product_category)
if query.role_code:
filters.append(PerformanceLedger.role_code == query.role_code)
return tuple(filters)
def list_personal_detail(
session: Session, query: PersonalDetailQuery, *, offset: int, limit: int
) -> list[PerformanceLedger]:
"""个人绩效明细分页(回款日倒序,同人按版本可追溯)。"""
stmt = select(PerformanceLedger).where(*_personal_filters(query))
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_personal_detail(session: Session, query: PersonalDetailQuery) -> int:
"""个人绩效明细计数(与 list_personal_detail 同筛选)。"""
return int(
session.execute(
select(func.count()).select_from(PerformanceLedger).where(*_personal_filters(query))
).scalar_one()
)
def sum_personal_detail(session: Session, query: PersonalDetailQuery) -> dict:
"""全筛选口径合计(分页与合计同条件,避免「页脚合计≠全部结果」)。"""
return (
session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label(
"converted_profit_total"
),
).where(*_personal_filters(query))
)
.mappings()
.one()
)
# ==== 部门汇总取数(FR-030)====
def list_month_receipt_rows(session: Session, month: str) -> list[PerformanceLedger]:
"""同月全量生效单笔台账行(部门汇总基数与成员明细的取数)。"""
return list(
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == month, *_ACTIVE_SINGLE
)
).scalars()
)
def list_month_manager_aggregates(session: Session, month: str) -> list[PerformanceLedger]:
"""同月经理聚合行(calc_type=2):已生成月结时的冻结对照值。"""
return list(
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == 2,
PerformanceLedger.is_active == 1,
)
).scalars()
)
def list_month_adjustments(session: Session, month: str) -> list[AdjustmentOrder]:
"""同月已生效调整单(status=2)——报表汇总并入的口径。
与 list_adjustments(调整单管理页)不同:必须按月筛选且只取生效行,
待审批/已驳回/已撤回的金额绝不能混进报表。
"""
return list(
session.execute(
select(AdjustmentOrder).where(
AdjustmentOrder.settlement_month == month,
AdjustmentOrder.status == 2,
)
).scalars()
)
def list_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
"""同月月结头(部门汇总展示锁定状态用;无则 None)。"""
return session.execute(
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
).scalar_one_or_none()
# ==== 业务统计(FR-031 之一)====
def _business_perf_subquery():
"""业务维度绩效子查询(排除聚合行,防 12.5%/5% 聚合额污染单业务绩效)。"""
return (
select(
PerformanceLedger.business_id.label("business_id"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("perf"),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable"),
)
.where(*_ACTIVE_SINGLE)
.group_by(PerformanceLedger.business_id)
.subquery()
)
def _business_filters(query: BusinessStatsQuery) -> tuple:
filters = []
if query.keyword:
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 转义通配符,
# 与 business_repository._apply_filters 同口径(双列 OR 用法为该处既定)
filters.append(
Business.business_no.contains(query.keyword, autoescape=True)
| Customer.company_name.contains(query.keyword, autoescape=True)
)
if query.customer_id is not None:
filters.append(Business.customer_id == query.customer_id)
if query.owner_personnel_id is not None:
filters.append(Business.owner_personnel_id == query.owner_personnel_id)
if query.status is not None:
filters.append(Business.status == query.status)
if query.date_from is not None:
filters.append(Business.business_date >= query.date_from)
if query.date_to is not None:
filters.append(Business.business_date <= query.date_to)
return tuple(filters)
def _business_select(query: BusinessStatsQuery):
perf_sq = _business_perf_subquery()
return (
select(
Business,
Customer.company_name.label("customer_name"),
Personnel.personnel_name.label("owner_personnel_name"),
perf_sq.c.perf.label("performance_amount"),
perf_sq.c.payable.label("payable_amount"),
)
.outerjoin(Customer, Customer.id == Business.customer_id)
.outerjoin(Personnel, Personnel.id == Business.owner_personnel_id)
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
.where(*_business_filters(query))
)
def list_business_stats(
session: Session, query: BusinessStatsQuery, *, offset: int, limit: int
) -> list:
"""业务统计分页(业务日期倒序)。"""
stmt = _business_select(query).order_by(Business.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
"""业务统计计数(联客户表因关键字筛选涉及客户名)。"""
stmt = (
select(func.count())
.select_from(Business)
.outerjoin(Customer, Customer.id == Business.customer_id)
.where(*_business_filters(query))
)
return int(session.execute(stmt).scalar_one())
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
perf_sq = _business_perf_subquery()
return (
session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"),
func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"),
func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"),
func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"),
func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"),
)
.outerjoin(Customer, Customer.id == Business.customer_id)
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
.where(*_business_filters(query))
)
.mappings()
.one()
)
# ==== 回款统计(FR-031 之二)====
def _payment_filters(query: PaymentStatsQuery | PaymentDetailQuery) -> tuple:
filters = [*_ACTIVE_SINGLE]
if query.settlement_month:
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
if query.date_from is not None:
filters.append(PerformanceLedger.receipt_date >= query.date_from)
if query.date_to is not None:
filters.append(PerformanceLedger.receipt_date <= query.date_to)
if query.customer_type:
filters.append(PerformanceLedger.customer_type == query.customer_type)
if query.product_category:
filters.append(PerformanceLedger.product_category == query.product_category)
return tuple(filters)
def group_payment_by_days(session: Session, query: PaymentStatsQuery) -> list:
"""按(回款天数精确值, 客户类型, 产品类别)聚合绩效与应付。"""
return list(
session.execute(
select(
PerformanceLedger.payment_days,
PerformanceLedger.customer_type,
PerformanceLedger.product_category,
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count().label("ledger_count"),
)
.where(*_payment_filters(query))
.group_by(
PerformanceLedger.payment_days,
PerformanceLedger.customer_type,
PerformanceLedger.product_category,
)
).all()
)
def group_payment_receipt_by_days(session: Session, query: PaymentStatsQuery) -> list:
"""按维度聚合回款金额与笔数——金额侧必须按 receipt_id 去重。
二次分摊拆行使同一回款在台账出现多行,直接 SUM(receipts.amount) 会
按拆行数翻倍;台账拆行只改归属人,不改维度值(payment_days/客户类型/
产品类别),因此先对台账行 DISTINCT 出回款维度,再联回款表求和。
"""
distinct_receipts = (
select(
PerformanceLedger.receipt_id.label("receipt_id"),
PerformanceLedger.payment_days.label("payment_days"),
PerformanceLedger.customer_type.label("customer_type"),
PerformanceLedger.product_category.label("product_category"),
)
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
.distinct()
.subquery()
)
from app.models import Receipt
return list(
session.execute(
select(
distinct_receipts.c.payment_days,
distinct_receipts.c.customer_type,
distinct_receipts.c.product_category,
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
func.count(distinct_receipts.c.receipt_id).label("receipt_count"),
)
.outerjoin(Receipt, Receipt.id == distinct_receipts.c.receipt_id)
.group_by(
distinct_receipts.c.payment_days,
distinct_receipts.c.customer_type,
distinct_receipts.c.product_category,
)
).all()
)
def group_payment_by_customer_type(session: Session, query: PaymentStatsQuery) -> list:
"""按客户类型聚合(绩效 + 回款金额)。"""
return _group_by_dimension(session, query, PerformanceLedger.customer_type)
def group_payment_by_product_category(session: Session, query: PaymentStatsQuery) -> list:
"""按产品类别聚合(绩效 + 回款金额)。"""
return _group_by_dimension(session, query, PerformanceLedger.product_category)
def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column) -> list:
"""维度聚合共用实现(绩效侧 GROUP BY + 金额侧 DISTINCT 去重联回款)。"""
from app.models import Receipt
dim_perf = (
select(
dim_column.label("dim"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count().label("ledger_count"),
)
.where(*_payment_filters(query))
.group_by(dim_column)
)
dim_receipts = (
select(
dim_column.label("dim"),
PerformanceLedger.receipt_id.label("receipt_id"),
)
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
.distinct()
.subquery()
)
perf_rows = {row.dim: row for row in session.execute(dim_perf).all()}
receipt_rows = session.execute(
select(
dim_receipts.c.dim,
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
)
.outerjoin(Receipt, Receipt.id == dim_receipts.c.receipt_id)
.group_by(dim_receipts.c.dim)
).all()
receipt_amounts = {row.dim: row.receipt_amount for row in receipt_rows}
return [
(
row.dim,
receipt_amounts.get(row.dim),
row.performance_total,
row.payable_total,
row.ledger_count,
)
for row in perf_rows.values()
]
def _bucket_condition(bucket: DayBucket) -> tuple:
"""区间桶的 days 过滤条件:none 桶 IS NULL,其余按 min/max 闭区间。"""
if bucket.code == "none":
return (PerformanceLedger.payment_days.is_(None),)
conds = []
if bucket.min_days is not None:
conds.append(PerformanceLedger.payment_days >= bucket.min_days)
if bucket.max_days is not None:
conds.append(PerformanceLedger.payment_days <= bucket.max_days)
return tuple(conds)
def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> list:
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
"""
from app.models import Receipt
stmt = (
select(
PerformanceLedger,
Receipt.amount.label("receipt_amount"),
Receipt.receipt_batch_no.label("receipt_batch_no"),
)
.outerjoin(Receipt, Receipt.id == PerformanceLedger.receipt_id)
.where(*_payment_filters(query), *_bucket_condition(bucket))
)
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).all())
def count_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> int:
"""区间下钻计数(与 list_payment_detail 同筛选)。"""
return int(
session.execute(
select(func.count())
.select_from(PerformanceLedger)
.where(*_payment_filters(query), *_bucket_condition(bucket))
).scalar_one()
)
+12 -18
View File
@@ -10,6 +10,7 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Role, UserRole
from app.schemas.role import RoleQuery
# ==================== 读取 ====================
@@ -47,44 +48,37 @@ def get_all_roles(session: Session) -> list[Role]:
return list(session.execute(stmt).scalars())
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[Role]:
def list_roles(session: Session, query: RoleQuery) -> list[Role]:
"""分页查询角色(管理列表:含停用角色)。
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
"""
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
stmt = _apply_filters(select(Role), query)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
def count_roles(session: Session, query: RoleQuery) -> int:
"""统计符合条件的角色总数,供分页使用。"""
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
stmt = _apply_filters(select(func.count(Role.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
def _apply_filters(stmt: Select, query: RoleQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
"""
if keyword:
if query.keyword:
stmt = stmt.where(
or_(
Role.role_code.contains(keyword, autoescape=True),
Role.role_name.contains(keyword, autoescape=True),
Role.role_code.contains(query.keyword, autoescape=True),
Role.role_name.contains(query.keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(Role.status == status)
if query.status is not None:
stmt = stmt.where(Role.status == query.status)
return stmt
@@ -0,0 +1,121 @@
"""月度结算仓储:只读写数据并 flush;commit/rollback 在服务层。"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import MonthlySettlement, SettlementItem
from app.schemas.settlement import SettlementQuery
def get_by_id(
session: Session, settlement_id: int, *, for_update: bool = False
) -> MonthlySettlement | None:
"""月结行;``for_update`` 供提交审批路径加行锁用。
提交要算审批单版本号(同一月结的第几次送审),并发提交会让两次
算出同一个版本号;锁住月结行使并发提交串行化。
"""
if for_update:
return session.get(MonthlySettlement, settlement_id, with_for_update=True)
return session.get(MonthlySettlement, settlement_id)
def get_by_month(session: Session, month: str) -> MonthlySettlement | None:
"""按月份取月结行(settlement_month 全表唯一)。"""
return session.execute(
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
).scalar_one_or_none()
def add(session: Session, settlement: MonthlySettlement) -> MonthlySettlement:
session.add(settlement)
session.flush()
return settlement
def list_settlements(session: Session, query: SettlementQuery) -> list[MonthlySettlement]:
return list(
session.execute(
select(MonthlySettlement)
.where(*_filters(query))
.order_by(MonthlySettlement.settlement_month.desc())
.offset(query.offset)
.limit(query.page_size)
).scalars()
)
def count_settlements(session: Session, query: SettlementQuery) -> int:
return int(
session.execute(
select(func.count()).select_from(MonthlySettlement).where(*_filters(query))
).scalar_one()
)
def _filters(query: SettlementQuery) -> tuple:
filters = []
if query.status is not None:
filters.append(MonthlySettlement.status == query.status)
return tuple(filters)
def list_items(session: Session, settlement_id: int) -> list[SettlementItem]:
return list(
session.execute(
select(SettlementItem)
.where(SettlementItem.settlement_id == settlement_id)
.order_by(SettlementItem.id)
).scalars()
)
def replace_items(session: Session, settlement_id: int, rows: list[SettlementItem]) -> None:
"""全量替换明细(先删后 flush 再插,防御写法;不 commit)。"""
session.execute(delete_items_stmt(settlement_id))
session.flush()
for row in rows:
row.settlement_id = settlement_id
session.add(row)
session.flush()
def delete_items_stmt(settlement_id: int):
from sqlalchemy import delete
return delete(SettlementItem).where(SettlementItem.settlement_id == settlement_id)
def update_ledger_settlement_id(
session: Session, ledger_ids: list[int], settlement_id: int
) -> None:
"""月结生成时把纳入的台账行回填 settlement_id(append-only 的受控回填)。"""
from sqlalchemy import update
from app.models import PerformanceLedger
if ledger_ids:
session.execute(
update(PerformanceLedger)
.where(PerformanceLedger.id.in_(ledger_ids))
.values(settlement_id=settlement_id)
)
session.flush()
def clear_ledger_settlement_id(session: Session, settlement_id: int) -> None:
"""重生成前解绑:释放本结算名下台账行的 settlement_id(「未入结」判据复位)。
草稿/驳回态重新生成时,上一轮回填的行必须重新进入候选集——否则
``settlement_id IS NULL`` 会把它们永久排除,重生成漏算整月工资。
"""
from sqlalchemy import update
from app.models import PerformanceLedger
session.execute(
update(PerformanceLedger)
.where(PerformanceLedger.settlement_id == settlement_id)
.values(settlement_id=None)
)
session.flush()
+15 -37
View File
@@ -8,13 +8,13 @@
业务级汇总仍收口 business_service._recalc_totals
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment, ShipmentItem
from app.schemas.shipment import ShipmentQuery
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
@@ -31,59 +31,37 @@ def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment,
).first()
def _apply_filters(
stmt: Select,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> Select:
def _apply_filters(stmt: Select, query: ShipmentQuery) -> Select:
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
if business_id is not None:
stmt = stmt.where(Shipment.business_id == business_id)
if ship_date_from is not None:
stmt = stmt.where(Shipment.ship_date >= ship_date_from)
if ship_date_to is not None:
stmt = stmt.where(Shipment.ship_date <= ship_date_to)
if query.business_id is not None:
stmt = stmt.where(Shipment.business_id == query.business_id)
if query.ship_date_from is not None:
stmt = stmt.where(Shipment.ship_date >= query.ship_date_from)
if query.ship_date_to is not None:
stmt = stmt.where(Shipment.ship_date <= query.ship_date_to)
return stmt
def list_shipments(
session: Session,
*,
offset: int,
limit: int,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> list[tuple[Shipment, str]]:
def list_shipments(session: Session, query: ShipmentQuery) -> list[tuple[Shipment, str]]:
"""分页查询发货单(附业务号),按 id 倒序。"""
stmt = _apply_filters(
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
query,
)
return list(
session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
session.execute(
stmt.order_by(Shipment.id.desc()).offset(query.offset).limit(query.page_size)
).all()
)
def count_shipments(
session: Session,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> int:
def count_shipments(session: Session, query: ShipmentQuery) -> int:
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters(
select(func.count())
.select_from(Shipment)
.join(Business, Business.id == Shipment.business_id),
business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
query,
)
return int(session.execute(stmt).scalar_one())
@@ -0,0 +1,149 @@
"""T+ 管理库访问;只 flush,事务由 service 管理。"""
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session, defer
from app.models import TPlusSyncBatch, TPlusSyncError
def get_batch(
session: Session, batch_no: str, *, for_update: bool = False, with_result: bool = False
):
"""按批次号查询;普通查询不加载核验大字段。"""
statement = select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == batch_no)
if not with_result:
statement = statement.options(defer(TPlusSyncBatch.audit_result_json))
if for_update:
statement = statement.with_for_update().execution_options(populate_existing=True)
return session.scalar(statement)
def get_request(session: Session, key: str, *, for_update: bool = False):
"""锁读用于唯一约束竞争后的当前读,避免旧事务快照看不到新任务。"""
statement = select(TPlusSyncBatch).where(TPlusSyncBatch.request_key == key)
if for_update:
statement = statement.with_for_update().execution_options(populate_existing=True)
return session.scalar(statement)
def active_retry(session: Session, parent_no: str):
"""调用方先锁父批次,再用当前读判断是否已有未完成重试。"""
return session.scalar(
select(TPlusSyncBatch)
.where(
TPlusSyncBatch.parent_batch_no == parent_no,
TPlusSyncBatch.status.in_(("QUEUED", "RUNNING")),
)
.with_for_update()
)
def increment_retry_errors(session: Session, parent_no: str) -> None:
"""创建新重试时对原异常累计重试次数。"""
session.execute(
update(TPlusSyncError)
.where(TPlusSyncError.batch_no == parent_no)
.values(retry_count=TPlusSyncError.retry_count + 1)
)
session.flush()
def next_queued(session: Session):
"""调用方持有账套执行锁后领取最早排队任务。"""
return session.scalar(
select(TPlusSyncBatch)
.where(TPlusSyncBatch.status == "QUEUED")
.order_by(TPlusSyncBatch.id)
.limit(1)
.with_for_update()
)
def running_batches(session: Session) -> list[TPlusSyncBatch]:
"""只在持有执行锁时使用,不能依据时间戳判断任务已经停止。"""
return list(
session.scalars(
select(TPlusSyncBatch).where(TPlusSyncBatch.status == "RUNNING").with_for_update()
).all()
)
def list_batches(session: Session, query):
"""分页只读元数据,核验报告和原异常不随列表加载。"""
filters = []
if query.status:
filters.append(TPlusSyncBatch.status == query.status)
if query.task_type:
filters.append(
TPlusSyncBatch.sync_mode == ("AUDIT" if query.task_type == "AUDIT" else "FULL")
)
total = session.scalar(select(func.count()).select_from(TPlusSyncBatch).where(*filters))
rows = session.scalars(
select(TPlusSyncBatch)
.where(*filters)
.options(defer(TPlusSyncBatch.audit_result_json), defer(TPlusSyncBatch.error_message))
.order_by(TPlusSyncBatch.id.desc())
.offset(query.offset)
.limit(query.page_size)
).all()
return rows, total or 0
def active_retry_parents(session: Session, numbers: list[str]) -> set[str]:
"""整页一次获取未完成的重试,避免逐行查询。"""
if not numbers:
return set()
return set(
session.scalars(
select(TPlusSyncBatch.parent_batch_no).where(
TPlusSyncBatch.parent_batch_no.in_(numbers),
TPlusSyncBatch.status.in_(("QUEUED", "RUNNING")),
)
).all()
)
def list_errors(session: Session, number: str, query):
"""只读取当前批次的异常定位元数据。"""
filters = [TPlusSyncError.batch_no == number]
if query.entity_type:
filters.append(TPlusSyncError.entity_type == query.entity_type)
total = session.scalar(select(func.count()).select_from(TPlusSyncError).where(*filters))
rows = session.scalars(
select(TPlusSyncError)
.where(*filters)
.options(defer(TPlusSyncError.error_message))
.order_by(TPlusSyncError.id.desc())
.offset(query.offset)
.limit(query.page_size)
).all()
return rows, total or 0
def status_counts(session: Session) -> dict[str, int]:
"""汇总任务表,不扫描原始快照。"""
return dict(
session.execute(
select(TPlusSyncBatch.status, func.count()).group_by(TPlusSyncBatch.status)
).all()
)
def latest_audit(session: Session):
"""最近完成的核验;返回批次供查询层过滤报告。"""
return session.scalar(
select(TPlusSyncBatch)
.where(
TPlusSyncBatch.sync_mode == "AUDIT",
TPlusSyncBatch.finished_at.is_not(None),
)
.order_by(TPlusSyncBatch.id.desc())
.limit(1)
)
def total_staged_records(session: Session) -> int:
"""从批次元数据汇总累计新增数,避免扫描原始快照。"""
return (
session.scalar(select(func.coalesce(func.sum(TPlusSyncBatch.staged_record_count), 0))) or 0
)
+17 -38
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, selectinload
from app.models import Role, User, UserRole
from app.repositories.query_utils import fuzzy
from app.schemas.user import UserQuery
def _base_query() -> Select[tuple[User]]:
@@ -48,16 +49,7 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
# ==================== 列表查询 ====================
def list_users(
session: Session,
*,
offset: int,
limit: int,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> list[User]:
def list_users(session: Session, query: UserQuery) -> list[User]:
"""分页查询用户列表。
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
@@ -65,48 +57,35 @@ def list_users(
id 是自增主键,天然唯一且与创建顺序一致。
"""
stmt = _base_query()
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
stmt = _apply_filters(stmt, query)
if query.role_code:
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
stmt = stmt.order_by(User.id.desc()).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars().unique())
def count_users(
session: Session,
*,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> int:
def count_users(session: Session, query: UserQuery) -> int:
"""统计符合条件的用户总数,供分页使用。"""
stmt = select(func.count(User.id))
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
stmt = _apply_filters(stmt, query)
if query.role_code:
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
username: str | None,
real_name: str | None,
status: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: UserQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
"""
if username:
stmt = fuzzy(stmt, User.username, username)
if real_name:
stmt = fuzzy(stmt, User.real_name, real_name)
if status is not None:
stmt = stmt.where(User.status == status)
if query.username:
stmt = fuzzy(stmt, User.username, query.username)
if query.real_name:
stmt = fuzzy(stmt, User.real_name, query.real_name)
if query.status is not None:
stmt = stmt.where(User.status == query.status)
return stmt
+80
View File
@@ -0,0 +1,80 @@
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.core.response import PageQuery
class ApprovalStepOut(BaseModel):
"""节点记录(时间线;action=3 自动通过 operator 记「系统」)。"""
model_config = ConfigDict(from_attributes=True)
id: int
node: int
action: int
opinion: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime
class ApprovalOrderOut(BaseModel):
"""审批单列表行。"""
model_config = ConfigDict(from_attributes=True)
id: int
order_no: str
order_type: int
ref_id: int
refer_no: str
version_no: int
status: int
submitter_id: int | None = None
submitter_name: str
create_time: datetime
class ApprovalOrderDetailOut(ApprovalOrderOut):
"""审批单详情:附节点时间线与审批内容。
``content_source`` 语义:``snapshot``=提交时快照(可信);
``live``=历史单无快照、回退读当前业务内容(仅供参考);
``missing``=引用对象已不存在且无快照。
"""
steps: list[ApprovalStepOut] = Field(default_factory=list)
snapshot: dict[str, Any] | None = Field(
default=None,
description="提交时内容快照(信封:schema_version/order_type/captured_at/data)",
)
content_source: Literal["snapshot", "live", "missing"] = Field(
default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失"
)
class ApprovalQuery(PageQuery):
"""审批单列表查询条件。"""
keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊")
status: int | None = Field(default=None, ge=1, le=5, description="状态筛选")
order_type: int | None = Field(default=None, ge=1, le=3, description="对象类型筛选")
class ApprovalActionIn(BaseModel):
"""节点动作:1 通过 / 2 驳回(驳回须填意见——空驳无从追溯)。"""
model_config = ConfigDict(str_strip_whitespace=True)
action: int = Field(ge=1, le=2, description="1通过 2驳回")
opinion: str | None = Field(default=None, max_length=255, description="审批意见")
@model_validator(mode="after")
def _check_opinion(self) -> "ApprovalActionIn":
if self.action == 2 and not self.opinion:
raise ValueError("驳回必须填写审批意见")
return self
+72 -2
View File
@@ -151,10 +151,64 @@ class BusinessOut(BaseModel):
remark: str | None = None
class SecondaryAllocationIn(BaseModel):
"""二次分配参与人入参(2026-10-15 补充需求「二次分配方案」)。
比例**不要求合计 100%**(需求原文):余额归属待业务澄清(待澄清 #2),
服务层仅以 hint 展示合计/余额,不做强制校验。ratio 沿金额红线口径:
字符串入参、两位小数、超限拒绝不静默舍入。
"""
model_config = ConfigDict(str_strip_whitespace=True)
personnel_id: int = Field(ge=1, description="参与业务员ID(personnel.id)")
ratio: str = Field(description="分摊比例(0-100,两位小数)")
@field_validator("ratio")
@classmethod
def _check_ratio(cls, value: str) -> str:
return _check_amount(value, "分摊比例", Decimal("100"), allow_positive_only=True) or value
class SecondaryAllocationOut(BaseModel):
"""二次分配参与人响应(personnel_name 冗余固化,离职后回显有名)。"""
model_config = ConfigDict(from_attributes=True)
personnel_id: int
personnel_name: str
ratio: str
class AllocationVersionOut(SecondaryAllocationOut):
"""方案版本行(含历史;业务详情抽屉回溯用)。"""
version_no: int
is_active: int = Field(description="1当前生效 0历史版本")
create_time: datetime
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
seen: set[int] = set()
for item in items:
if item.personnel_id in seen:
raise ValueError(f"二次分配参与人重复:人员ID {item.personnel_id}")
seen.add(item.personnel_id)
class BusinessDetailOut(BusinessOut):
"""业务详情:附产品明细行。"""
"""业务详情:附产品明细行、当前方案与方案版本历史。"""
items: list[BusinessItemOut] = Field(default_factory=list)
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
allocation_hint: str | None = Field(
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
)
allocation_history: list[AllocationVersionOut] = Field(
default_factory=list, description="方案全部版本(含历史;被驳回方案亦保留)"
)
class BusinessCreateIn(BaseModel):
@@ -175,6 +229,9 @@ class BusinessCreateIn(BaseModel):
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行")
secondary_allocations: list[SecondaryAllocationIn] = Field(
default_factory=list, description="二次分配方案(只出现在合同刚建立时;可空)"
)
remark: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
@@ -184,9 +241,14 @@ class BusinessCreateIn(BaseModel):
raise ValueError("特殊业务必须填写说明")
return self
@model_validator(mode="after")
def _check_allocations(self) -> "BusinessCreateIn":
_ensure_allocations_unique(self.secondary_allocations)
return self
class BusinessUpdateIn(BaseModel):
"""编辑业务(全量替换明细;归属不在此端点调整,走 /ownership)。"""
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
model_config = ConfigDict(str_strip_whitespace=True)
@@ -207,6 +269,9 @@ class BusinessUpdateIn(BaseModel):
items: list[BusinessItemIn] = Field(
min_length=1, description="产品明细行,至少 1 行(全量替换)"
)
secondary_allocations: list[SecondaryAllocationIn] = Field(
default_factory=list, description="二次分配方案(全量替换;空数组=清空方案)"
)
remark: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
@@ -215,6 +280,11 @@ class BusinessUpdateIn(BaseModel):
raise ValueError("特殊业务必须填写说明")
return self
@model_validator(mode="after")
def _check_allocations(self) -> "BusinessUpdateIn":
_ensure_allocations_unique(self.secondary_allocations)
return self
class BusinessStatusIn(BaseModel):
"""状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。"""
+13
View File
@@ -27,6 +27,19 @@ class BusinessRoleOut(BaseModel):
is_builtin: int = 0
class BusinessRoleOptionOut(BaseModel):
"""岗位下拉选项(人员档案与报表筛选共用)。
与 ``BusinessRoleOut`` 分开:下拉只需渲染用的最小字段集,不必顺带
暴露计提模板等管理字段。字段名直出蛇形(前端按同名取值)。
"""
id: int = Field(description="岗位ID")
role_code: str = Field(description="岗位编码")
role_name: str = Field(description="岗位名称")
status: int = Field(description="状态:1启用 0禁用")
class TemplateParamOut(BaseModel):
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
+62
View File
@@ -0,0 +1,62 @@
"""绩效核算的请求/响应模型(M3 ④)。"""
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class LedgerQuery(PageQuery):
"""台账列表查询条件。"""
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
personnel_id: int | None = Field(default=None, ge=1, description="归属人员筛选")
business_id: int | None = Field(default=None, ge=1, description="业务筛选")
is_active: int | None = Field(default=None, ge=0, le=1, description="生效标记筛选")
calc_type: int | None = Field(default=None, ge=1, le=2, description="核算类型筛选")
class LedgerOut(BaseModel):
"""台账行响应(金额/比例一律 str)。"""
model_config = ConfigDict(from_attributes=True)
id: int
receipt_id: int | None = None
calc_version: int
is_active: int
calc_type: int
business_id: int
business_no: str
personnel_id: int
personnel_name: str
role_code: str
customer_type: str
product_category: str
ship_date: date | None = None
receipt_date: date
payment_days: int | None = None
rate_text: str
converted_profit: Decimal | None = None
performance_amount: Decimal | None = None
barter_applied: int
weight_r: int | None = None
payment_weight: Decimal | None = None
payable_amount: Decimal | None = None
settlement_month: str
is_probation: int
missing_cost: int
snapshot_json: dict | None = None
settlement_id: int | None = None
calc_reason: str | None = None
create_time: datetime
class CalcReceiptOut(BaseModel):
"""单笔核算结果:台账行 + 重复计提标记(核算粒度 #25 一期口径)。"""
ledger: LedgerOut
#: True=该业务此前已核算过,本笔回款仅登记进度、未重复计提(#25 一期口径)
repeated: bool = False
+23 -1
View File
@@ -1,6 +1,7 @@
"""客户管理的请求/响应模型(需求 2.1.3)。"""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -28,6 +29,8 @@ class CustomerOut(BaseModel):
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
remark: str | None = None
rating_score: int | None = None
rating_time: datetime | None = None
class CustomerCreateIn(BaseModel):
@@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel):
class CustomerQuery(PageQuery):
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
有意义的排序键,白名单足够。
"""
keyword: str | None = Field(
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
)
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
order_by: Literal["id", "rating_score"] = Field(
default="id", description="排序键:id 建档序 / rating_score 评级总分"
)
order: Literal["asc", "desc"] = Field(default="desc", description="排序方向")
class CustomerRatingIn(BaseModel):
"""保存客户评级入参(评级计算器「保存评分」)。
score 是五项之和的整数(0-100);五项明细不落库(评级记录只存总分,
见对照表 #25 的定位:评分是档案属性而非审计事件)。
"""
score: int = Field(ge=0, le=100, description="评级总分(0-100 整数)")
class CustomerOwnershipIn(BaseModel):
+163
View File
@@ -0,0 +1,163 @@
"""工作台的请求/响应模型(M4 ⑧)。
聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返):
经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal
(序列化为字符串),前端不做数值运算。
**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合
响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
不返回伪造的 0 条。
"""
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class TrendPoint(BaseModel):
"""近 6 个月趋势点(绩效按台账归属月,回款按到账月)。"""
month: str
performance_total: Decimal | None = None
receipt_total: Decimal | None = None
class SettlementBrief(BaseModel):
"""当月月结状态(工作台总览角标)。"""
exists: bool = False
status: int | None = Field(default=None, description="1草稿 2待审批 3已审定 4已驳回")
total_performance: Decimal | None = None
total_payable: Decimal | None = None
locked_at: datetime | None = None
class OverviewBlock(BaseModel):
"""经营绩效总览(FR-033):口径与台账/月结一致。"""
month: str
performance_total: Decimal | None = Field(default=None, description="当月单笔绩效合计")
payable_total: Decimal | None = Field(default=None, description="当月应付绩效合计")
person_count: int = Field(default=0, description="当月有台账的人员数(拆行去重)")
team_count: int = Field(default=0, description="在职营销经理数(团队数)")
settlement: SettlementBrief
trend: list[TrendPoint] = Field(default_factory=list, description="近 6 个月")
note: str = Field(default="", description="口径说明(页面展示)")
class ApprovalTodoItem(BaseModel):
"""待审批条目。"""
order_id: int
order_no: str
order_type: int
submitter_name: str | None = None
create_time: datetime
class ReceiptTodoItem(BaseModel):
"""回款未指定发货批次条目。"""
receipt_id: int
receipt_batch_no: str
business_id: int
business_no: str | None = None
amount: Decimal
receipt_date: date
class TodoBlock(BaseModel):
"""待办事项(FR-034,澄清 #11 起步范围)。"""
pending_approval_total: int = 0
pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list)
unassigned_receipt_total: int = 0
unassigned_receipts: list[ReceiptTodoItem] = Field(default_factory=list)
sync_available: bool = Field(default=False, description="⑥ 同步模块是否已启用")
sync_failure_total: int = 0
class WarningItem(BaseModel):
"""风险预警条目(实时派生,不改变业务数据)。"""
code: str = Field(description="规则编码:receipt_overdue / approval_timeout")
level: str = Field(description="级别:warning / danger")
object_type: str
object_id: int
title: str
detail: str
suggest: str
link: str | None = Field(default=None, description="前端跳转路径")
class WarningBlock(BaseModel):
"""风险预警(FR-035,首批两条可算规则;同步规则待 ⑥)。"""
enabled: bool = True
receipt_overdue_days: int = 90
approval_timeout_days: int = 3
items: list[WarningItem] = Field(default_factory=list)
total: int = Field(default=0, description="全部规则命中总数(items 仅为展示上限内的子集)")
receipt_overdue_total: int = 0
approval_timeout_total: int = 0
sync_available: bool = False
class ActivityItem(BaseModel):
"""最近动态条目(来源 operation_logs,只读聚合)。"""
id: int
module: str
action: str
summary: str
username: str | None = None
success: int
create_time: datetime
class ActivityQuery(PageQuery):
"""最近动态查询条件:分页 + 模块筛选。
只支持模块筛选(工作台只需要这一个维度);其余筛选项见日志查询页。
"""
module: str | None = Field(default=None, max_length=64, description="模块,精确匹配")
class ActivityPageOut(BaseModel):
"""最近动态分页结果:当前页 + 总数 + 模块筛选项。
分页字段名沿用全站规范(list/total/pageNum/pageSize),与
`PageResult` 同形;额外带 `modules`(全量模块名),
供前端模块下拉使用 —— 不随当前页变化,避免翻页后选项增减。
"""
model_config = ConfigDict(populate_by_name=True)
records: list[ActivityItem] = Field(
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
)
total: int = Field(default=0, description="总记录数")
page_num: int = Field(
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
)
page_size: int = Field(
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
)
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)")
class DashboardOut(BaseModel):
"""工作台三区块一次性响应(最近动态走独立分页端点)。"""
month: str
overview: OverviewBlock
todos: TodoBlock
warnings: WarningBlock
+371
View File
@@ -0,0 +1,371 @@
"""报表统计的请求/响应模型(M4 ⑤)。
金额与比例一律 `Decimal`(pydantic v2 序列化为字符串,零精度损失);
比例原样输出 `rate_text`(如 88/9000),禁止转 float 或截断展示。
"""
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.month import validate_month
from app.core.response import PageQuery
def _required_month(value: str) -> str:
"""必填月份:非法直接抛 ValueError,由 FastAPI 压成 400。"""
return validate_month(value)
def _optional_month(value: str | None) -> str | None:
"""可选月份:空串规范为 None(axios 未剔除时也不会误报格式错误)。"""
if value in (None, ""):
return None
return validate_month(value)
# ==== 查询入参 ====
class PersonalDetailQuery(PageQuery):
"""个人绩效明细查询条件(FR-029:人员/月份/客户类型/产品类别/岗位)。"""
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
personnel_id: int | None = Field(default=None, ge=1, description="人员筛选")
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
role_code: str | None = Field(default=None, max_length=32, description="岗位筛选")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str | None) -> str | None:
return _optional_month(value)
class DepartmentSummaryQuery(BaseModel):
"""部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。"""
settlement_month: str = Field(min_length=7, max_length=7, description="归属月 YYYY-MM")
manager_id: int | None = Field(default=None, ge=1, description="仅看某经理团队")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str) -> str:
return _required_month(value)
class BusinessStatsQuery(PageQuery):
"""业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。"""
keyword: str | None = Field(default=None, max_length=64, description="业务编号/客户名称")
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人筛选")
status: int | None = Field(default=None, description="业务状态筛选")
date_from: date | None = Field(default=None, description="业务日期起")
date_to: date | None = Field(default=None, description="业务日期止")
class PaymentStatsQuery(BaseModel):
"""回款统计查询条件(按回款天数区间/客户类型/产品类别统计,FR-031)。"""
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
date_from: date | None = Field(default=None, description="回款日期起")
date_to: date | None = Field(default=None, description="回款日期止")
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str | None) -> str | None:
return _optional_month(value)
class PaymentDetailQuery(PageQuery):
"""回款区间下钻(桶编码必填,入参由 payment-stats 页面展开行携带)。"""
bucket: str = Field(min_length=1, max_length=16, description="区间桶编码")
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
date_from: date | None = Field(default=None, description="回款日期起")
date_to: date | None = Field(default=None, description="回款日期止")
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str | None) -> str | None:
return _optional_month(value)
# ==== 响应出参 ====
class ReportSummary(BaseModel):
"""全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。
字段按报表取用:个人明细用绩效/应付/折算毛利;业务统计用发货/回款/
销售额;不涉及的留 None。
"""
row_count: int = Field(default=0, description="筛选结果行数")
performance_total: Decimal | None = Field(default=None, description="绩效金额合计")
payable_total: Decimal | None = Field(default=None, description="应付绩效合计")
converted_profit_total: Decimal | None = Field(default=None, description="折算毛利润合计")
ship_total: Decimal | None = Field(default=None, description="发货额合计")
received_total: Decimal | None = Field(default=None, description="回款额合计")
sale_total: Decimal | None = Field(default=None, description="销售额合计")
class ReportPage[T](BaseModel):
"""报表分页:标准分页四字段 + 全筛选合计。
序列化字段与 PageResult 一致(list/total/pageNum/pageSize),前端
usePagedList 可直接消费;追加的 summary 由各页页脚单独读取。
"""
model_config = ConfigDict(populate_by_name=True)
records: list[T] = Field(
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
)
total: int = Field(default=0, description="总记录数")
page_num: int = Field(
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
)
page_size: int = Field(
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
)
summary: ReportSummary = Field(default_factory=ReportSummary, description="全筛选合计")
class PersonalDetailRow(BaseModel):
"""个人绩效明细行(需规 2.4.1 列全覆盖)。
二次分摊拆行后同一回款按人多行(负责人余额行 + 参与人份额行),行内
不放「关联回款金额」列——同回款金额重复展示会让合计无法自洽,金额
下沉到快照下钻。
"""
ledger_id: int
business_id: int
business_no: str
personnel_id: int
personnel_name: str
role_code: str
role_name: str
customer_type: str
product_category: str
ship_date: date | None = None
receipt_date: date
payment_days: int | None = None
rate_text: str
weight_r: int | None = None
payment_weight: Decimal | None = None
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
converted_profit: Decimal | None = None
is_probation: int
missing_cost: int
calc_version: int
settlement_id: int | None = None
class DepartmentMemberRow(BaseModel):
"""部门汇总 · 团队成员明细(内联于团队行)。"""
personnel_id: int
personnel_name: str
role_code: str
is_probation: int
member_kind: str = Field(
default="regular",
description="行来源:regular 普通台账 / manager_personal 经理个人户 / adjustment 仅调整单",
)
unassigned_reason: str | None = Field(
default=None, description="未纳入团队基数的原因(仅未分配块)"
)
performance_sum: Decimal | None = None
payable_sum: Decimal | None = None
adjust_extra: Decimal = Field(default=Decimal("0"), description="调整单生效补发合计")
adjust_deduct: Decimal = Field(default=Decimal("0"), description="调整单生效扣减合计")
adjust_net: Decimal = Field(default=Decimal("0"), description="调整净额(补发为正)")
net_total: Decimal | None = None
ledger_ids: list[int] = Field(default_factory=list, description="台账行 id 列表(下钻锚点)")
class DepartmentTeamRow(BaseModel):
"""部门汇总 · 经理团队行。"""
manager_id: int
manager_name: str
manager_status: int = Field(description="经理在职状态(1 在职 0 离职)")
member_count: int
team_base: Decimal = Field(description="团队基数(12.5% 的底,等于成员绩效合计)")
team_share_amount: Decimal = Field(description="12.5% 团队分成(分列展示)")
company_share_amount: Decimal = Field(
description="单经理 5% 名义值;该团队无月结聚合行时为 0(不发放)"
)
has_aggregate: bool = Field(
default=False, description="本月是否有月结聚合行(决定是否发放 5%)"
)
aggregate_amount: Decimal = Field(
default=Decimal("0"),
description="月结聚合行应发(12.5%+5% 一次舍入);无聚合行时 0",
)
team_subtotal: Decimal = Field(
default=Decimal("0"),
description="团队应发小计 = 聚合行金额 + 调整净额",
)
company_base_ratio: Decimal = Field(description="团队基数占全公司基数比例(除零为 0)")
adjust_extra: Decimal = Decimal("0")
adjust_deduct: Decimal = Decimal("0")
adjust_net: Decimal = Decimal("0")
net_total: Decimal = Field(description="团队净额 = 团队应发小计")
frozen_amount: Decimal | None = Field(default=None, description="月结聚合行冻结额(对照)")
aggregate_ledger_id: int | None = None
members: list[DepartmentMemberRow] = Field(default_factory=list)
class DepartmentCompanyBlock(BaseModel):
"""部门汇总 · 全公司口径块(FR-030「全公司 5% 部分单列」)。"""
team_base_total: Decimal = Field(description="各团队基数之和(= 全部单笔绩效合计)")
company_base: Decimal = Field(description="全公司基数(5% 的底)")
company_share_pct: str = Field(description="分成比例参数原文")
company_share_amount: Decimal = Field(description="单经理 5% 名义值")
company_share_count: int = Field(
default=0, description="计入应发的经理数(展示团队中有月结聚合行的数量)"
)
company_share_total: Decimal = Field(
default=Decimal("0"), description="5% 合计 = 单份 × 计入次数"
)
class DepartmentReconciliation(BaseModel):
"""部门汇总 · 对账块(导出/打印签字稿的合计口径,前端不自行求和)。
应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
聚合行金额与月结 ``total_payable`` 同口径(12.5%+5% 一次舍入);
``team_share_total`` 与 ``company_share_total`` 仅作分列展示,两者
之和与聚合额的差记入 ``aggregate_rounding_diff``。
"""
team_member_payable_total: Decimal = Field(description="展示团队成员的应付绩效合计")
unassigned_payable_total: Decimal = Field(description="展示未分配人员的应付绩效合计")
team_share_total: Decimal = Field(description="展示团队的 12.5% 分成合计(分列)")
company_share_total: Decimal = Field(
description="展示团队的全公司分成合计(每位有聚合行的经理各计一次,分列)"
)
aggregate_payable_total: Decimal = Field(
description="展示团队的经理聚合行应发合计(12.5%+5% 一次舍入)"
)
aggregate_rounding_diff: Decimal = Field(
default=Decimal("0"),
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
)
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
payable_total: Decimal = Field(
description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额"
)
team_base_total: Decimal = Field(description="展示团队基数合计")
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
other_team_base: Decimal = Field(
default=Decimal("0"), description="未展示的基数(其他团队 + 全公司未分配)"
)
other_team_member_payable_total: Decimal = Field(default=Decimal("0"))
other_team_share_total: Decimal = Field(default=Decimal("0"))
other_team_company_share_total: Decimal = Field(default=Decimal("0"))
other_team_aggregate_total: Decimal = Field(default=Decimal("0"))
other_adjust_net_total: Decimal = Field(default=Decimal("0"))
company_base: Decimal = Field(description="全公司基数(5% 的底)")
class DepartmentSummaryOut(BaseModel):
"""部门绩效工资汇总响应(FR-030)。"""
settlement_month: str
params: dict | None = Field(default=None, description="分成参数原文与取值时点(对账用)")
teams: list[DepartmentTeamRow] = Field(default_factory=list)
unassigned: list[DepartmentMemberRow] = Field(
default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)"
)
company: DepartmentCompanyBlock | None = None
reconciliation: DepartmentReconciliation | None = Field(
default=None, description="签字稿对账块(应发公式各项)"
)
totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)")
class BusinessStatsRow(BaseModel):
"""业务统计行(发货/回款/绩效为该业务累计值)。"""
business_id: int
business_no: str
business_date: date | None = None
status: int
customer_name: str | None = None
owner_personnel_name: str | None = None
total_sale_amount: Decimal | None = None
total_ship_amount: Decimal | None = None
total_received_amount: Decimal | None = None
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
class BucketRow(BaseModel):
"""回款统计 · 区间桶行(空桶恒在且为 0,防金额静默消失)。"""
code: str
label: str
min_days: int | None = None
max_days: int | None = None
receipt_amount: Decimal | None = None
receipt_count: int = 0
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
ledger_count: int = 0
class GroupRow(BaseModel):
"""回款统计 · 维度对比行(客户类型 / 产品类别)。"""
code: str
label: str
receipt_amount: Decimal | None = None
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
ledger_count: int = 0
class PaymentStatsOut(BaseModel):
"""回款情况统计响应(FR-031)。
口径:已计提回款(有台账行)的金额与绩效;进度登记回款无台账行,
不在本表(页面需注明)。
"""
settlement_month: str | None = None
buckets: list[BucketRow] = Field(default_factory=list)
by_customer_type: list[GroupRow] = Field(default_factory=list)
by_product_category: list[GroupRow] = Field(default_factory=list)
totals: dict = Field(default_factory=dict, description="全表合计")
class PaymentDetailRow(BaseModel):
"""回款区间下钻行(行级展示回款金额与批次号)。"""
ledger_id: int
receipt_id: int | None = None
receipt_batch_no: str | None = None
amount: Decimal | None = Field(default=None, description="回款金额(同回款拆行重复展示)")
business_id: int
business_no: str
personnel_id: int
personnel_name: str
customer_type: str
product_category: str
ship_date: date | None = None
receipt_date: date
payment_days: int | None = None
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
+34
View File
@@ -0,0 +1,34 @@
"""系统设置(公司信息)请求/响应模型。
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
顺着复用链泄漏到匿名端点。
"""
from pydantic import BaseModel, ConfigDict, Field
class SettingOut(BaseModel):
"""公司信息(管理端)。"""
company_name: str
logo_url: str | None = None
class SettingPublicOut(BaseModel):
"""公司信息(匿名端点白名单):登录页展示所需的两个字段。"""
company_name: str
logo_url: str | None = None
class SettingUpdateIn(BaseModel):
"""公司信息更新。
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
+96
View File
@@ -0,0 +1,96 @@
"""月度结算的请求/响应模型(M3 ④)。"""
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.month import validate_month
from app.core.response import PageQuery
def _check_month(value: str) -> str:
"""月份格式 YYYY-MM 且月份 01-12(settlement_month 全表唯一的维度)。"""
return validate_month(value)
class SettlementGenerateIn(BaseModel):
"""月结生成入参。"""
month: str = Field(description="结算月份 YYYY-MM")
@field_validator("month")
@classmethod
def _v_month(cls, value: str) -> str:
return _check_month(value)
class SettlementQuery(PageQuery):
"""月结列表查询。"""
status: int | None = Field(default=None, ge=1, le=4, description="状态筛选")
class SettlementItemOut(BaseModel):
"""月结明细行。"""
model_config = ConfigDict(from_attributes=True)
id: int
personnel_id: int
personnel_name: str
role_code: str
calc_type: int
performance_sum: Decimal
payable_sum: Decimal
basic_amount: Decimal | None = None
salary_mode: str | None = None
net_amount: Decimal | None = None
is_probation: int
probation_note: str | None = None
class SettlementOut(BaseModel):
"""月结头表响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
settlement_month: str
status: int
total_performance: Decimal | None = None
total_payable: Decimal | None = None
person_count: int
approval_order_no: str | None = None
submitter_name: str
locked_at: datetime | datetime | None = None
locked_by: str | None = None
create_time: datetime
class SettlementDetailOut(SettlementOut):
"""月结详情:附明细。"""
items: list[SettlementItemOut] = Field(default_factory=list)
class AdjustmentCreateIn(BaseModel):
"""调整单创建入参(金额恒正,方向表达补发/扣减)。"""
model_config = ConfigDict(str_strip_whitespace=True)
settlement_month: str = Field(description="调整月份 YYYY-MM")
personnel_id: int = Field(ge=1, description="调整人员ID")
direction: int = Field(ge=1, le=2, description="1补发 2扣减")
# 正则先拦格式(非负两位小数、≤12 位整数防 NUMERIC(14,2) 溢出);
# 「> 0」由服务层判定(schema 只管形状,语义校验留在 service)
amount: str = Field(
pattern=r"^\d{1,12}(\.\d{1,2})?$",
description="金额(元,两位小数,恒正)",
)
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str) -> str:
return _check_month(value)
+109
View File
@@ -0,0 +1,109 @@
"""T+ 管理接口合同;只暴露范围、数量和受控核验结果。"""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field, model_validator
from app.core.response import PageQuery
EntityType = Literal[
"personnel",
"products",
"customers",
"businesses",
"shipments",
"receipts",
"business_items",
"shipment_items",
"receipt_items",
]
TaskType = Literal["READ", "AUDIT"]
TaskStatus = Literal["QUEUED", "RUNNING", "SUCCEEDED", "PARTIAL", "FAILED"]
class TPlusCreateIn(BaseModel):
"""创建长任务;幂等键是调用意图的一部分,必须由调用方保留。"""
task_type: TaskType = "READ"
entity_types: list[EntityType] = Field(default_factory=list, max_length=9)
request_key: UUID
@model_validator(mode="after")
def check_scope(self):
if self.task_type == "READ" and not self.entity_types:
raise ValueError("请选择至少一种读取数据")
if self.task_type == "AUDIT" and self.entity_types:
raise ValueError("关联核验不接受自定义读取范围")
if len(set(self.entity_types)) != len(self.entity_types):
raise ValueError("读取类型不能重复")
return self
class TPlusRetryIn(BaseModel):
"""失败批次重试不接受任意新范围。"""
request_key: UUID
class TPlusBatchQuery(PageQuery):
"""批次筛选,与读取状态和任务类型保持一致。"""
status: TaskStatus | None = None
task_type: TaskType | None = None
class TPlusErrorQuery(PageQuery):
"""异常只能在指定批次内查询。"""
entity_type: EntityType | None = None
class TPlusBatchOut(BaseModel):
"""批次列表不包含原始数据、源配置或数据库异常。"""
batch_no: str
task_type: TaskType
status: str
entity_types: list[str] | None
source_record_count: int
staged_record_count: int
skipped_record_count: int
failed_record_count: int
parent_batch_no: str | None
create_time: datetime
started_at: datetime | None
finished_at: datetime | None
can_retry: bool
error_summary: str | None = None
class TPlusBatchDetailOut(TPlusBatchOut):
"""详情按需携带受控的关联核验结果。"""
audit_result: dict | None = None
class TPlusErrorOut(BaseModel):
"""异常使用固定说明,源定位仅开放整数 ID。"""
id: int
entity_type: str
source_id: str | None
error_code: str
error_message: str
retry_count: int
create_time: datetime
class TPlusOverviewOut(BaseModel):
"""配置就绪不等同于源库连通或兼容验收通过。"""
is_configured: bool
configuration_message: str
status_counts: dict[str, int]
total_staged_records: int = 0
compatibility_status: str = "PENDING"
business_import_enabled: bool = False
latest_audit: dict | None = None
+589
View File
@@ -0,0 +1,589 @@
"""审批流业务编排(2026-10-15 复核审批补充需求)。
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数开关驱动
(`approval.secondary_allocation.enabled` / `approval.monthly_settlement.enabled`):
- **关闭(默认)**:提交即生成审批单并「自动通过」全节点留痕(action=3,
operator 记「系统」)——留痕不断链,与「关闭=直接生效」的需求口径一致;
- **开启**:status=1 待财务复核 → 2 待领导审核 → 3 已通过;任一节点驳回
即整体驳回(status=4),一期不做退回重走(简单状态机)。
红线:
- `submit_order` **不 commit、不嵌套 audit** —— 由业务写路径(business_service
的 create/update)既有 audit 上下文统一收口,违反嵌套禁令会让失败路径
回滚不完整(见 audit_service 模块 docstring);
- 节点准入**严格按角色编码**:status=1 仅财务、status=2 仅领导,admin 不
代办 —— 放行 admin 会让双人复核流退化成管理员单点审批;
- 同一引用对象的在途单(status 1/2)在重新提交前先撤回(status=5 +
node=0/action=4 留痕),保证「一个对象至多一张在途单」。
"""
import logging
from collections.abc import Iterable
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.orm import Session, defer
from app.core.constants import (
ACTION_APPROVE,
ACTION_REJECT,
MODULE_APPROVAL,
NO_PREFIX_APPROVAL,
ROLE_ADMIN,
ROLE_FINANCE,
ROLE_LEADER,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import ApprovalOrder, ApprovalStep, Business, User
from app.models.approval import (
APPROVAL_ACTION_APPROVE,
APPROVAL_ACTION_AUTO_APPROVE,
APPROVAL_ACTION_REJECT,
APPROVAL_ACTION_WITHDRAW,
APPROVAL_NODE_FINANCE,
APPROVAL_NODE_LEADER,
APPROVAL_NODE_WITHDRAW,
APPROVAL_NODES,
APPROVAL_STATUS_APPROVED,
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
APPROVAL_STATUS_REJECTED,
APPROVAL_STATUS_WITHDRAWN,
)
from app.repositories import user_repository
from app.schemas.approval import (
ApprovalActionIn,
ApprovalOrderDetailOut,
ApprovalOrderOut,
ApprovalQuery,
ApprovalStepOut,
)
from app.services import audit_service, param_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
ENABLE_CODE_BY_TYPE: dict[int, str] = {
1: "approval.secondary_allocation.enabled",
2: "approval.monthly_settlement.enabled",
3: "approval.adjustment.enabled",
}
# 在途状态集合(同一引用对象至多一张在途单)
_PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
# 在途状态 → 当前节点应操作的角色(admin 不代办:单人审批绕过双人复核)
_NODE_ROLE_BY_STATUS = {
APPROVAL_STATUS_PENDING_FINANCE: ROLE_FINANCE,
APPROVAL_STATUS_PENDING_LEADER: ROLE_LEADER,
}
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
"""工作台「待我审批」:按当前用户角色映射在途审批状态。
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
的可见性一致);财务/领导只看到本节点可操作的条目。
"""
codes = set(role_codes)
if ROLE_ADMIN in codes:
return _PENDING_STATUSES
return tuple(status for status, role in _NODE_ROLE_BY_STATUS.items() if role in codes)
def is_enabled(session: Session, order_type: int) -> bool:
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
code = ENABLE_CODE_BY_TYPE.get(order_type)
if code is None:
raise BadRequestError("未知的审批对象类型")
return param_service.resolve_snapshot(session, date.today()).boolean(code)
def submit_order(
session: Session,
*,
order_type: int,
ref_id: int,
refer_no: str,
submitter: User,
snapshot: dict | None = None,
) -> ApprovalOrder:
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
``snapshot`` 是「提交时」的业务内容(由调用方组装,Decimal 已转 str);
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
"""
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
order = ApprovalOrder(
order_no=next_no(
session,
model=ApprovalOrder,
column_name="order_no",
prefix=NO_PREFIX_APPROVAL,
),
order_type=order_type,
ref_id=ref_id,
refer_no=refer_no,
version_no=_next_version(session, order_type, ref_id),
snapshot_json=(
{
"schema_version": 1,
"order_type": order_type,
"captured_at": datetime.now().isoformat(timespec="seconds"),
"data": snapshot,
}
if snapshot is not None
else None
),
submitter_id=submitter.id,
submitter_name=submitter.username,
)
session.add(order)
if is_enabled(session, order_type):
order.status = APPROVAL_STATUS_PENDING_FINANCE
session.flush()
logger.info("审批单待审 | order=%s", order.order_no)
return order
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
order.status = APPROVAL_STATUS_APPROVED
session.flush()
session.add_all(
[
ApprovalStep(
order_id=order.id,
node=node,
action=APPROVAL_ACTION_AUTO_APPROVE,
operator_name="系统",
)
for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER)
]
)
session.flush()
logger.info("审批单自动通过 | order=%s(开关关闭)", order.order_no)
return order
def pending_matches_snapshot(
session: Session, *, order_type: int, ref_id: int, snapshot: dict | None
) -> bool:
"""在途审批单的送审内容是否与给定快照一致(用于「内容未变不重提」)。
只比较快照信封里的 ``data`` 部分——``captured_at`` 每次采集都不同,
参与比较会让判断恒为不一致。V11 之前的历史单没有快照,一律视为不一致
(保守重提:宁可多提一次,不能漏审)。
"""
if snapshot is None:
return False
pending = (
session.execute(
select(ApprovalOrder).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
for order in pending:
stored = order.snapshot_json or {}
if stored.get("data") == snapshot:
return True
return False
def withdraw_pending_orders(
session: Session, *, order_type: int, ref_id: int, operator: User
) -> int:
"""撤回引用对象的全部在途单(供业务清空方案等场景显式调用;不 commit)。
返回撤回张数(0 = 本就无在途单)。
"""
before = len(
session.execute(
select(ApprovalOrder.id).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
if before:
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=operator)
return before
def act(
session: Session,
order_id: int,
payload: ApprovalActionIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ApprovalOrderDetailOut:
"""节点动作(财务复核/领导审核):状态机流转 + 节点留痕(审计上下文)。
通过:status=1 → 2(下一步领导);status=2 → 3(终态已通过)。
驳回:任一在途状态 → 4(整体驳回,一期不退回重走)。
节点留痕的 node 取**动作发生时的当前节点**(通过/驳回都记在操作节点上)。
"""
codes = set(user_repository.get_role_codes(operator))
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_APPROVAL,
action=verb,
summary=f"审批单 #{order_id} {verb}",
request_data=payload.model_dump(),
),
meta=meta,
):
order = session.get(ApprovalOrder, order_id, with_for_update=True)
if order is None:
raise NotFoundError("审批单不存在")
if order.status not in _PENDING_STATUSES:
raise BadRequestError("审批单已结束,不可重复操作")
required_role = _NODE_ROLE_BY_STATUS[order.status]
if required_role not in codes:
raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作")
node_now = APPROVAL_NODES[order.status]
rejected = payload.action == APPROVAL_ACTION_REJECT
if rejected:
order.status = APPROVAL_STATUS_REJECTED
else:
order.status = (
APPROVAL_STATUS_PENDING_LEADER
if order.status == APPROVAL_STATUS_PENDING_FINANCE
else APPROVAL_STATUS_APPROVED
)
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
# 驳回在任何节点都是终态,即时联动。
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
session.add(
ApprovalStep(
order_id=order.id,
node=node_now,
action=payload.action,
opinion=payload.opinion,
operator_id=operator.id,
operator_name=operator.username,
)
)
# 驳回=方案未获批准:把该业务的生效方案行置历史(is_active=0,
# **不删行**——V11 起 append-only 版本化),防止被否决的方案以
# 「当前生效行」身份被 M3 核算快照算进工资;历史方案仍可在业务
# 详情 allocation_history 回溯。委托 business_repository 完成
# —— service 层互调、不触 commit。
if rejected and order.order_type == 1:
from app.repositories import business_repository
business_repository.deactivate_allocations(session, order.ref_id)
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
if order.order_type == 2 or order.order_type == 3:
from app.models import AdjustmentOrder
from app.models.settlement import (
ADJUSTMENT_STATUS_EFFECTIVE,
)
from app.models.settlement import (
ADJUSTMENT_STATUS_REJECTED as ADJ_REJECTED,
)
from app.services import settlement_service
if order.order_type == 2:
from app.models.settlement import MonthlySettlement
target = session.get(MonthlySettlement, order.ref_id)
if target is not None:
if rejected:
settlement_service.mark_rejected(session, target, operator=operator)
elif final_approved:
settlement_service.mark_locked(session, target, operator=operator)
else:
adj = session.get(AdjustmentOrder, order.ref_id)
if adj is not None:
if rejected:
adj.status = ADJ_REJECTED
elif final_approved:
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
# 成失败审计,与库中事实相反(全 services 层唯一例外不许再犯)
session.flush()
detail = _detail_of(session, order)
session.commit()
return detail
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
"""分页查询审批单(准入由 api 层依赖判定)。
排除 snapshot_json 列(月结快照含按人明细,可达数十~上百 KB):列表出参
本就不含快照,整列加载会让翻页负担随快照体积线性放大;详情走
`get_order` 按需读取(与操作日志列表排除 request_data 同一理由,
见 operation_log_repository.list_logs)。
"""
filters = _order_filters(query)
rows = (
session.execute(
select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(*filters)
.order_by(ApprovalOrder.id.desc())
.offset(query.offset)
.limit(query.page_size)
)
.scalars()
.all()
)
total = session.execute(
select(func.count()).select_from(ApprovalOrder).where(*filters)
).scalar_one()
return PageResult[ApprovalOrderOut](
records=[ApprovalOrderOut.model_validate(r) for r in rows],
total=int(total),
page_num=query.page_num,
page_size=query.page_size,
)
def get_order(session: Session, order_id: int) -> ApprovalOrderDetailOut:
"""审批单详情(附节点时间线)。"""
order = session.get(ApprovalOrder, order_id)
if order is None:
raise NotFoundError("审批单不存在")
return _detail_of(session, order)
def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operator: User) -> None:
"""撤回引用对象的在途单(重新提交前调用;不 commit)。"""
pending = (
session.execute(
select(ApprovalOrder).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
for order in pending:
order.status = APPROVAL_STATUS_WITHDRAWN
session.add(
ApprovalStep(
order_id=order.id,
node=APPROVAL_NODE_WITHDRAW,
action=APPROVAL_ACTION_WITHDRAW,
operator_id=operator.id,
operator_name=operator.username,
)
)
if pending:
session.flush()
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
def _next_version(session: Session, order_type: int, ref_id: int) -> int:
"""同一对象类型+引用对象的下一个提交版本号(从 1 递增)。
**必须用锁读(当前读),不能用普通 MAX 查询**:普通 SELECT 在
REPEATABLE READ 下是快照读,读到的是本事务首次一致读的快照;请求级
鉴权(deps.get_current_user 与业务共用同一 DbSession)那次 SELECT 已把
快照锚定在请求开始时刻,此后即使父行锁等到并发事务提交,MAX 仍停在
旧快照上——两个请求会算出同一个 version_no。锁读是当前读,永远读最新
已提交数据。
**不能写成 select(func.max(...)).with_for_update()**:MySQL 官方明确
「Locking clauses are not permitted in queries with aggregate functions
or GROUP BY」,聚合结果上的 FOR UPDATE 语义无保证。改锁「该 ref 的现有
版本行」,最大值在 Python 侧算——同 no_generator 的既有套路。只取
version_no 单列,不把 snapshot_json 拉进内存。
"""
versions = (
session.execute(
select(ApprovalOrder.version_no)
.where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
)
.with_for_update()
)
.scalars()
.all()
)
return (max(versions) if versions else 0) + 1
def allocation_content(session: Session, business: Business) -> dict:
"""二次分配方案的审批内容(提单快照与历史单回退共用同一结构)。
业务主行已存在但客户联查落空(客户被删等异常)时返回空字典——调用方
以 ``or None`` 归一,避免生成「有快照但内容为空」的信封。
"""
from app.repositories import business_repository
row = business_repository.get_with_names(session, business.id)
if row is None:
return {}
_, customer_name, owner_name, assistant_name = row
allocations = business_repository.list_allocations(session, business.id)
total = sum((a.ratio for a in allocations), Decimal("0"))
return {
"business_no": business.business_no,
"business_date": business.business_date.isoformat() if business.business_date else None,
"customer_name": customer_name,
"owner_personnel_name": owner_name,
"assistant_personnel_name": assistant_name,
"sale_amount": _amount_text(business.total_sale_amount),
"cost_amount": _amount_text(business.total_cost_amount),
"allocations": [
{
"personnel_id": a.personnel_id,
"personnel_name": a.personnel_name,
"ratio": str(a.ratio),
}
for a in allocations
],
"allocation_total": str(total.quantize(Decimal("0.01"))),
"allocation_balance": str((Decimal("100") - total).quantize(Decimal("0.01"))),
}
def _amount_text(value: Decimal | None) -> str | None:
"""金额文本化(Decimal 全程不转 float;None 保留为空,不伪造 0.00)。"""
return str(value) if value is not None else None
def settlement_content(session: Session, settlement_id: int) -> dict | None:
"""月结审批内容(汇总 + 按人明细;提单快照与历史单回退共用)。"""
from app.models import MonthlySettlement
from app.repositories import settlement_repository
settlement = session.get(MonthlySettlement, settlement_id)
if settlement is None:
return None
items = settlement_repository.list_items(session, settlement_id)
return {
"settlement_month": settlement.settlement_month,
"total_performance": _amount_text(settlement.total_performance),
"total_payable": _amount_text(settlement.total_payable),
"person_count": settlement.person_count,
"items": [
{
"personnel_name": item.personnel_name,
"role_code": item.role_code,
"calc_type": item.calc_type,
"performance_sum": str(item.performance_sum),
"payable_sum": str(item.payable_sum),
"basic_amount": _amount_text(item.basic_amount),
"salary_mode": item.salary_mode,
"net_amount": _amount_text(item.net_amount),
"is_probation": item.is_probation,
}
for item in items
],
}
def adjustment_content(session: Session, adjustment_id: int) -> dict | None:
"""调整单审批内容(提单快照与历史单回退共用)。"""
from app.models import AdjustmentOrder
adjustment = session.get(AdjustmentOrder, adjustment_id)
if adjustment is None:
return None
return {
"order_no": adjustment.order_no,
"settlement_month": adjustment.settlement_month,
"personnel_name": adjustment.personnel_name,
"direction": adjustment.direction,
"amount": str(adjustment.amount),
"reason": adjustment.reason,
}
def _content_of(session: Session, order: ApprovalOrder) -> tuple[dict | None, str]:
"""审批内容出参:优先提交时快照,历史单回退当前内容并显式标注来源。"""
if order.snapshot_json:
return order.snapshot_json, "snapshot"
if order.order_type == 1:
business = session.get(Business, order.ref_id)
data = allocation_content(session, business) if business is not None else None
elif order.order_type == 2:
data = settlement_content(session, order.ref_id)
elif order.order_type == 3:
data = adjustment_content(session, order.ref_id)
else:
data = None
if not data:
return None, "missing"
return {
"schema_version": 1,
"order_type": order.order_type,
"captured_at": None,
"data": data,
}, "live"
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
"""出参组装(含 steps 时间线与审批内容)。调用方须保证处于 commit 之前。"""
steps = (
session.execute(
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
)
.scalars()
.all()
)
detail = ApprovalOrderDetailOut.model_validate(order)
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
detail.snapshot, detail.content_source = _content_of(session, order)
return detail
def _order_filters(query: ApprovalQuery) -> tuple:
filters = []
if query.keyword:
# autoescape:用户输入里的 %/_ 按 URL 片段字面匹配(全库统一口径,
# 见 customer_repository.contains 用法)——否则 "AP2026_10" 会把
# 下划线当单字符通配,搜出无关单据
filters.append(
ApprovalOrder.order_no.contains(query.keyword, autoescape=True)
| ApprovalOrder.refer_no.contains(query.keyword, autoescape=True)
)
if query.status is not None:
filters.append(ApprovalOrder.status == query.status)
if query.order_type is not None:
filters.append(ApprovalOrder.order_type == query.order_type)
return tuple(filters)
+8 -6
View File
@@ -37,12 +37,14 @@ def _record(
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
login_log_repository.add(
session,
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent,
success=success,
fail_reason=fail_reason,
login_log_repository.LoginLogEntry(
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent,
success=success,
fail_reason=fail_reason,
),
)
session.commit()
+20 -10
View File
@@ -31,6 +31,7 @@ from app.models import BusinessRole, User
from app.repositories import business_role_repository
from app.schemas.business_role import (
BusinessRoleCreateIn,
BusinessRoleOptionOut,
BusinessRoleOut,
BusinessRoleQuery,
BusinessRoleUpdateIn,
@@ -67,16 +68,8 @@ def _ensure_template(calc_template: str) -> None:
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
"""分页查询岗位(含停用)。"""
roles = business_role_repository.list_roles(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = business_role_repository.count_roles(
session, keyword=query.keyword, status=query.status
)
roles = business_role_repository.list_roles(session, query)
total = business_role_repository.count_roles(session, query)
return PageResult[BusinessRoleOut](
records=[_to_out(role) for role in roles],
total=total,
@@ -85,6 +78,23 @@ def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[Busines
)
def list_options(session: Session) -> list[BusinessRoleOptionOut]:
"""岗位下拉选项(含停用,不分页)。
含停用是因为人员档案可能仍引用停用岗位(停用只是禁止新增引用,
不改历史归属);岗位规模有限(内置 + 自定义),一次取全量。
"""
return [
BusinessRoleOptionOut(
id=role.id,
role_code=role.role_code,
role_name=role.role_name,
status=role.status,
)
for role in business_role_repository.list_all(session)
]
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
"""岗位详情。
+387 -114
View File
@@ -15,6 +15,8 @@
"""
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
@@ -34,6 +36,7 @@ from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult
from app.models import (
ApprovalOrder,
Business,
BusinessItem,
BusinessStatusLog,
@@ -41,16 +44,19 @@ from app.models import (
OwnershipChangeLog,
Personnel,
Receipt,
SecondaryAllocation,
Shipment,
User,
)
from app.models.approval import APPROVAL_TYPE_SECONDARY_ALLOCATION
from app.models.business import (
BUSINESS_STATUS_ACTIVE,
BUSINESS_STATUS_DONE,
)
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import business_repository, customer_repository
from app.repositories import business_repository, customer_repository, personnel_repository
from app.schemas.business import (
AllocationVersionOut,
BusinessCreateIn,
BusinessDetailOut,
BusinessItemIn,
@@ -65,8 +71,10 @@ from app.schemas.business import (
GrossProfitOut,
GrossProfitRowOut,
OwnershipChangeLogOut,
SecondaryAllocationIn,
SecondaryAllocationOut,
)
from app.services import audit_service, param_service, personnel_service
from app.services import approval_service, audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import converted_profit, profit_rate
from app.services.no_generator import next_no
@@ -228,26 +236,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
"""分页查询业务(附客户名与归属人名)。"""
rows = business_repository.list_businesses(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
customer_id=query.customer_id,
owner_personnel_id=query.owner_personnel_id,
date_from=query.date_from,
date_to=query.date_to,
)
total = business_repository.count_businesses(
session,
keyword=query.keyword,
status=query.status,
customer_id=query.customer_id,
owner_personnel_id=query.owner_personnel_id,
date_from=query.date_from,
date_to=query.date_to,
)
rows = business_repository.list_businesses(session, query)
total = business_repository.count_businesses(session, query)
return PageResult[BusinessOut](
records=[_to_out(row) for row in rows],
total=total,
@@ -257,7 +247,7 @@ def list_businesses(session: Session, query: BusinessQuery) -> PageResult[Busine
def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
"""业务详情(附明细行,供编辑抽屉回显)。
"""业务详情(附明细行与二次分配方案,供编辑抽屉回显)。
:raises NotFoundError: 业务不存在
"""
@@ -268,9 +258,108 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
out.items = [
_item_to_out(item) for item in business_repository.list_items(session, business_id)
]
allocations = business_repository.list_allocations(session, business_id)
out.secondary_allocations = [
SecondaryAllocationOut(
personnel_id=a.personnel_id, personnel_name=a.personnel_name, ratio=str(a.ratio)
)
for a in allocations
]
out.allocation_hint = _allocation_hint(allocations)
out.allocation_history = [
AllocationVersionOut(
personnel_id=a.personnel_id,
personnel_name=a.personnel_name,
ratio=str(a.ratio),
version_no=a.version_no,
is_active=a.is_active,
create_time=a.create_time,
)
for a in business_repository.list_allocation_versions(session, business_id)
]
return out
def _build_allocation_rows(
session: Session,
business_id: int,
items: list[SecondaryAllocationIn],
*,
old_ids: set[int],
) -> list[SecondaryAllocation]:
"""构造二次分配明细行:人员校验 + 姓名冗余固化 + 比例转 Decimal。
:param old_ids: 编辑前已参与的人员 ID 集合(create 传空集)。这些人的
行是**历史事实**——personnel_name 冗余固化的设计本就允许离职后回显,
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
**新加入**的参与人做在职校验。
:raises BadRequestError: 新增参与人员不存在或已离职 / 比例合计超 100%
/ 参与人含合同负责人(口径见澄清 #1/#2 答复 2026-10-19:分配绩效、
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
自分份额则双重计入)
"""
business = business_repository.get_by_id(session, business_id)
if business is None:
raise NotFoundError("业务不存在")
total = sum((Decimal(item.ratio) for item in items), Decimal("0"))
if total > Decimal("100"):
raise BadRequestError(
f"二次分配比例合计 {total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}%"
" 超过 100%(余额归合同负责营销人员,比例不可超满额)"
)
owner_id = business.owner_personnel_id
if owner_id is not None and any(item.personnel_id == owner_id for item in items):
raise BadRequestError("分摊参与人不可包含合同负责营销人员(负责人得余额份额)")
rows: list[SecondaryAllocation] = []
for item in items:
if item.personnel_id in old_ids:
person = personnel_repository.get_by_id(session, item.personnel_id)
if person is None:
raise BadRequestError("分摊参与人员不存在")
else:
person = _ensure_personnel_active(session, item.personnel_id, "分摊参与")
rows.append(
SecondaryAllocation(
business_id=business_id,
personnel_id=item.personnel_id,
personnel_name=person.personnel_name,
ratio=Decimal(item.ratio),
)
)
return rows
def _plan_changed(old_rows: list[SecondaryAllocation], new_rows: list[SecondaryAllocation]) -> bool:
"""方案内容(参与人 + 比例)是否真的变了。
比较口径是**集合**(人员 ID 与其比例的组合),与提交顺序无关——送审
快照同样按库内生效行的顺序生成,若按顺序比较,用户只是把行拖了个
序就会被判成「变了」,进而无谓地新增版本与重提审批单。
"""
def _key(row: SecondaryAllocation) -> tuple[int, Decimal]:
return (row.personnel_id, Decimal(row.ratio).quantize(Decimal("0.01")))
return {_key(r) for r in old_rows} != {_key(r) for r in new_rows}
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
"""
if not allocations:
return None
total = sum((a.ratio for a in allocations), Decimal("0"))
total_text = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if total == Decimal("100"):
return f"二次分配合计 {total_text}%"
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"二次分配合计 {total_text}%,余额 {rest}%(归合同负责营销人员)"
def create_business(
session: Session,
payload: BusinessCreateIn,
@@ -335,6 +424,27 @@ def create_business(
operator_name=operator.username,
),
)
# 二次分配方案与业务同事务(需求:只出现在合同刚建立时);
# 放在重试循环内——rollback 后方案行随业务一并重来
business_repository.replace_allocations(
session,
business.id,
_build_allocation_rows(
session, business.id, payload.secondary_allocations, old_ids=set()
),
)
# 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。
# AP 编号撞键时 IntegrityError 穿透 → 外层编号重试循环把
# 业务/方案/审批单整体重来
if payload.secondary_allocations:
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business.id,
refer_no=business.business_no,
submitter=operator,
snapshot=approval_service.allocation_content(session, business) or None,
)
customer = customer_repository.get_by_id(session, business.customer_id)
out = _to_out((business, customer.company_name if customer else None, None, None))
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
@@ -408,7 +518,58 @@ def update_business(
business_repository.replace_items(
session, business_id, _build_item_rows(business_id, payload.items)
)
# 方案:内容真的变了才动版本行(内容未变不新增版本,避免「只改备注」
# 也堆出一个方案版本);空数组=清空=把生效行置历史
old_allocations = business_repository.list_allocations(session, business_id)
new_rows = _build_allocation_rows(
session,
business_id,
payload.secondary_allocations,
old_ids={a.personnel_id for a in old_allocations},
)
if not payload.secondary_allocations:
business_repository.replace_allocations(session, business_id, [])
elif _plan_changed(old_allocations, new_rows):
business_repository.replace_allocations(session, business_id, new_rows)
# 汇总先重算:送审快照里的销售额/成本额必须反映本次编辑后的值,
# 放到提单之后会固化成上一版本的金额
_recalc_totals(session, business)
# 提单/撤单与方案同步生命周期:
# - 方案为空 → 仅撤回在途单(旧在途单挂着已不存在的方案继续走审批)
# - 送审内容与在途单一致 → 保留原单,不撤回不重建(避免反复保存
# 刷出「撤回+新建」的审批留痕噪音)
# - 其余 → 提新单(内含撤旧建新)
if payload.secondary_allocations:
snapshot = approval_service.allocation_content(session, business) or None
if not approval_service.pending_matches_snapshot(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
snapshot=snapshot,
):
try:
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
refer_no=business.business_no,
submitter=operator,
snapshot=snapshot,
)
except IntegrityError as exc:
# version_no 唯一键的并发冲突(取号是锁读后此项理论不可达,
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062
# 翻译成可重试的业务提示,其余完整性错误原样上抛
if _is_duplicate_entry(exc):
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
raise
else:
approval_service.withdraw_pending_orders(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
operator=operator,
)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
out = _to_out((business, customer.company_name, owner_name, assistant_name))
@@ -430,7 +591,7 @@ def delete_business(
与业务维度的归属留痕(无外键固化表,须手动清)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 存在发货或回款记录
:raises BadRequestError: 存在发货或回款记录 / 存在在途审批单
"""
business = business_repository.get_by_id(session, business_id, for_update=True)
with audit_service.audit(
@@ -447,6 +608,10 @@ def delete_business(
raise NotFoundError("业务不存在")
if business_repository.has_docs(session, business_id):
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
# 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单
if _count_pending_approvals(session, business_id):
raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除")
business_no = business.business_no
_hard_delete(session, business_id)
@@ -454,11 +619,46 @@ def delete_business(
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no)
def _count_pending_approvals(session: Session, business_id: int) -> int:
"""该业务的在途审批单数(status 1/2;终态单随业务删除一并清理)。"""
from sqlalchemy import func, select
from app.models import ApprovalOrder
from app.models.approval import (
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
)
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
ApprovalOrder.ref_id == business_id,
ApprovalOrder.status.in_(
(APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
),
)
).scalar_one()
)
def _hard_delete(session: Session, business_id: int) -> None:
"""按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。"""
"""按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→
状态留痕 → 业务维度归属留痕 → 主表。"""
from sqlalchemy import delete
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
)
session.execute(
delete(ApprovalOrder).where(
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
ApprovalOrder.ref_id == business_id,
)
)
session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
session.execute(
delete(OwnershipChangeLog).where(
@@ -532,6 +732,67 @@ def change_status(
return out
@dataclass(frozen=True, slots=True)
class _BusinessOwnershipResolution:
"""业务归属变更的解析结果(新归属人行 + 变更前固化 ID)。"""
owner: Personnel | None
assistant: Personnel | None
old_owner_id: int | None
old_assistant_id: int | None
def _resolve_business_ownership(
session: Session, business: Business, payload: BusinessOwnershipIn
) -> _BusinessOwnershipResolution:
"""校验新归属人(须在职)并判定归属是否真的变化。
校验在审计上下文内执行 —— 失败也留痕。两者均未变化 → 400「归属未变化」。
"""
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
# 固化标量,新归属人名必须按新 ID 重取
owner = (
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
if payload.new_owner_personnel_id is not None
else None
)
assistant = (
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
if payload.new_assistant_personnel_id is not None
else None
)
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
if not owner_changed and not assistant_changed:
raise BadRequestError("归属未变化")
return _BusinessOwnershipResolution(
owner=owner,
assistant=assistant,
old_owner_id=business.owner_personnel_id,
old_assistant_id=business.assistant_personnel_id,
)
def _business_ownership_log(
business: Business,
payload: BusinessOwnershipIn,
resolution: _BusinessOwnershipResolution,
operator: User,
) -> OwnershipChangeLog:
"""归属变更留痕行(负责/协助两个字段的前后值都记)。"""
return OwnershipChangeLog(
owner_type=OWNER_TYPE_BUSINESS,
business_id=business.id,
old_owner_personnel_id=resolution.old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
old_assistant_personnel_id=resolution.old_assistant_id,
new_assistant_personnel_id=payload.new_assistant_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
)
def change_ownership(
session: Session,
business_id: int,
@@ -563,41 +824,12 @@ def change_ownership(
if row is None:
raise NotFoundError("业务不存在")
business, _, old_owner_name, old_assistant_name = row
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
# 固化标量,新归属人名必须按新 ID 重取
new_owner = (
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
if payload.new_owner_personnel_id is not None
else None
)
new_assistant = (
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
if payload.new_assistant_personnel_id is not None
else None
)
resolution = _resolve_business_ownership(session, business, payload)
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
if not owner_changed and not assistant_changed:
raise BadRequestError("归属未变化")
old_owner_id = business.owner_personnel_id
old_assistant_id = business.assistant_personnel_id
business.owner_personnel_id = payload.new_owner_personnel_id
business.assistant_personnel_id = payload.new_assistant_personnel_id
business_repository.add_ownership_log(
session,
OwnershipChangeLog(
owner_type=OWNER_TYPE_BUSINESS,
business_id=business_id,
old_owner_personnel_id=old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
old_assistant_personnel_id=old_assistant_id,
new_assistant_personnel_id=payload.new_assistant_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
),
session, _business_ownership_log(business, payload, resolution, operator)
)
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
@@ -605,17 +837,19 @@ def change_ownership(
(
business,
row[1],
new_owner.personnel_name if new_owner is not None else old_owner_name,
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
resolution.owner.personnel_name if resolution.owner is not None else old_owner_name,
resolution.assistant.personnel_name
if resolution.assistant is not None
else old_assistant_name,
)
)
session.commit()
logger.info(
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
business_id,
old_owner_id,
resolution.old_owner_id,
payload.new_owner_personnel_id,
old_assistant_id,
resolution.old_assistant_id,
payload.new_assistant_personnel_id,
)
return out
@@ -681,6 +915,94 @@ def list_status_logs(
)
@dataclass(frozen=True, slots=True)
class _GrossProfitScan:
"""毛利润逐行扫描结果(行 DTO、类别聚合桶、成本缺失计数)。"""
rows: list[GrossProfitRowOut]
category_agg: dict[str, list[Decimal]]
missing_count: int
def _gross_profit_row(
item: BusinessItem, threshold: Fraction
) -> tuple[GrossProfitRowOut, tuple[Decimal, Decimal] | None]:
"""单行毛利润派生值(行 DTO + 类别聚合桶)。
聚合桶为 None 表示成本缺失行 —— 这类行不计入类别汇总,但仍要输出
派生列为 None 的行与缺失原因。阈值对两类行都要取(成本缺失也展示阈值)。
"""
row_kwargs: dict = {
"item_id": item.id,
"product_category": item.product_category,
"product_name": item.product_name,
"spec_model": item.spec_model,
"quantity": str(item.quantity),
"sale_amount": str(item.sale_amount),
"threshold": str(threshold),
}
if item.cost_amount is None:
row_kwargs.update(
cost_amount=None,
profit_amount=None,
profit_rate=None,
converted_profit=None,
missing_reason="成本缺失,无法计算毛利润",
)
return GrossProfitRowOut(**row_kwargs), None
profit = item.sale_amount - item.cost_amount
rate = profit_rate(profit, item.sale_amount)
conv = converted_profit(item.sale_amount, rate, threshold)
row_kwargs.update(
cost_amount=str(item.cost_amount),
profit_amount=str(profit),
profit_rate=str(rate),
converted_profit=str(conv),
missing_reason=None,
)
return GrossProfitRowOut(**row_kwargs), (item.sale_amount, item.cost_amount)
def _scan_gross_profit_rows(
session: Session, business_id: int, threshold_of: Callable[[str], Fraction]
) -> _GrossProfitScan:
"""逐行扫描业务明细:出行 DTO、类别聚合桶与成本缺失计数。
类别顺序沿用明细行的首次出现顺序(不排序)—— 前端汇总展示依赖该顺序。
"""
rows: list[GrossProfitRowOut] = []
agg: dict[str, list[Decimal]] = {}
missing_count = 0
for item in business_repository.list_items(session, business_id):
row, bucket = _gross_profit_row(item, threshold_of(item.product_category))
rows.append(row)
if bucket is None:
missing_count += 1
continue
current = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
current[0] += bucket[0]
current[1] += bucket[1]
return _GrossProfitScan(rows=rows, category_agg=agg, missing_count=missing_count)
def _gross_profit_category(
category: str, sale: Decimal, cost: Decimal, threshold: Fraction
) -> GrossProfitCategoryOut:
"""类别汇总行(派生口径与行级一致:利润率/折算复用 formula 纯函数)。"""
profit = sale - cost
rate = profit_rate(profit, sale)
return GrossProfitCategoryOut(
product_category=category,
sale_amount=str(sale),
cost_amount=str(cost),
profit_amount=str(profit),
profit_rate=str(rate),
threshold=str(threshold),
converted_profit=str(converted_profit(sale, rate, threshold)),
)
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
@@ -699,64 +1021,15 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
def threshold_of(category: str) -> Fraction:
return snapshot.fraction("product.threshold", product_category=category)
rows_out: list[GrossProfitRowOut] = []
missing_count = 0
# 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合
agg: dict[str, list[Decimal]] = {}
for item in business_repository.list_items(session, business_id):
threshold = threshold_of(item.product_category)
row_kwargs: dict = {
"item_id": item.id,
"product_category": item.product_category,
"product_name": item.product_name,
"spec_model": item.spec_model,
"quantity": str(item.quantity),
"sale_amount": str(item.sale_amount),
"threshold": str(threshold),
}
if item.cost_amount is None:
missing_count += 1
row_kwargs.update(
cost_amount=None,
profit_amount=None,
profit_rate=None,
converted_profit=None,
missing_reason="成本缺失,无法计算毛利润",
)
else:
profit = item.sale_amount - item.cost_amount
rate = profit_rate(profit, item.sale_amount)
conv = converted_profit(item.sale_amount, rate, threshold)
row_kwargs.update(
cost_amount=str(item.cost_amount),
profit_amount=str(profit),
profit_rate=str(rate),
converted_profit=str(conv),
missing_reason=None,
)
bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
bucket[0] += item.sale_amount
bucket[1] += item.cost_amount
rows_out.append(GrossProfitRowOut(**row_kwargs))
scan = _scan_gross_profit_rows(session, business_id, threshold_of)
categories = [
GrossProfitCategoryOut(
product_category=category,
sale_amount=str(sale),
cost_amount=str(cost),
profit_amount=str(sale - cost),
profit_rate=str(profit_rate(sale - cost, sale)),
threshold=str(threshold_of(category)),
converted_profit=str(
converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category))
),
)
for category, [sale, cost] in agg.items()
_gross_profit_category(category, sale, cost, threshold_of(category))
for category, [sale, cost] in scan.category_agg.items()
]
return GrossProfitOut(
business_id=business_id,
as_of=as_of,
rows=rows_out,
rows=scan.rows,
categories=categories,
missing_count=missing_count,
missing_count=scan.missing_count,
)
+726
View File
@@ -0,0 +1,726 @@
"""绩效核算服务(M3 ④):回款 → 台账的固化流水线。
单笔核算(calc_receipt)与计算预览(param_service.preview)**共用同一
公式路径与取值防线**:resolve_snapshot 按回款到账日取参(裁决 #11)、按
岗位模板分派计提比例(‰ 存储口径 ÷1000 转纯比例)、行级折算(混合业务
按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、
核算才是算钱的法定固化点。
策略点(业务答复 2026-10-19 已回填,后续调整仍集中在对应函数 + 按回款批量重算):
- ``_personal_rate``:#12 经理个人户单笔由参数 manager.personal.included
分流——0=单笔落 0(聚合口径在月结时由 manager_team_share 计算)、
1=按业务员递减公式计提;#20 在 ``_row_profit``(暂按不含税原样相减)
- 二次分摊(澄清 #1 答复「分配绩效」,算后分):``_load_allocations``
读生效方案,``_persist_ledger`` 按比例拆行(参与人份额 + 负责人余额,
行合计守恒);#2 余额归负责人、#3 二次分配即协助场景的核算载体
- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发,
后续回款仅置 settlement_status=1(repeated=True),不重复计提
"""
import logging
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from typing import Any
from sqlalchemy import update
from sqlalchemy.orm import Session
from app.core.constants import MODULE_CALC
from app.core.exceptions import BadRequestError
from app.models import (
Business,
BusinessItem,
Customer,
PerformanceLedger,
Personnel,
Receipt,
Shipment,
User,
)
from app.models.performance_ledger import CALC_TYPE_RECEIPT
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
from app.repositories import business_repository, performance_ledger_repository
from app.schemas.calc import CalcReceiptOut
from app.services import audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import (
apply_barter,
clamp_weight_r,
converted_profit,
payment_days,
payment_weight,
performance_amount,
profit_rate,
salesperson_rate,
start_payment_ok,
)
from app.services.param_service import parse_fraction, resolve_snapshot
logger = logging.getLogger(__name__)
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(未计入分成基数):绩效在月结时按团队与全公司分成计算"
@dataclass(frozen=True, slots=True)
class _RateDecision:
"""单笔计提比例的分派结果。"""
rate: Fraction # 纯比例(str() 产出分数原文如 88/9000,直接落台账 rate_text)
rate_note: str # 人读说明(入 snapshot)
base_override: Decimal | None # 非 None 时绩效基数用它(信息员=销售额)
manager_pending: bool # True=经理聚合口径,单笔落 0(#12 策略点)
def calc_receipt(
session: Session,
receipt_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
recalc_reason: str | None = None,
) -> CalcReceiptOut:
"""单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。
防重前两层在 ``_load_and_guard``(FOR UPDATE 锁行 + settlement_status
与月结锁定校验),第三层是落台账 uk(receipt_id, calc_version)。全程
一个 audit 事务,出参在 commit 前固化。
首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时
仅登记进度(repeated=True,``_prepare_mode`` 内提前返回)。
重算模式:回款必须已核算过;作废旧版本行(is_active=0),按当前参数
落 calc_version+1 新行,calc_reason 留痕。
本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 →
计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。
"""
is_recalc = recalc_reason is not None
action = "重算" if is_recalc else "核算"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CALC,
action=action,
summary=f"{action}回款 #{receipt_id}",
request_data={
"receipt_id": receipt_id,
**({"reason": recalc_reason} if is_recalc else {}),
},
),
meta=meta,
):
receipt, business = _load_and_guard(session, receipt_id, is_recalc=is_recalc)
repeated_out = _prepare_mode(session, receipt, business, is_recalc=is_recalc)
if repeated_out is not None:
return repeated_out
shipment = _load_shipment(session, receipt)
person = _load_person(session, business)
snapshot = resolve_snapshot(session, receipt.receipt_date)
items = _load_items(session, business)
allocations = _load_allocations(session, business, person.id)
conversion = _convert_items(snapshot, person, items)
amounts = _compose_amounts(
session, snapshot, person, business, receipt, shipment, conversion
)
rows = _persist_ledger(
session,
receipt=receipt,
business=business,
person=person,
shipment=shipment,
items=items,
allocations=allocations,
conversion=conversion,
amounts=amounts,
recalc_reason=recalc_reason,
is_recalc=is_recalc,
)
session.flush()
out = CalcReceiptOut(ledger=_to_out(rows.owner_row), repeated=False)
session.commit()
logger.info(
"核算完成 | receipt=%s | ledger=%s | payable=%s | 分摊行=%s",
receipt_id,
rows.owner_row.id,
amounts.payable,
len(rows.share_rows),
)
return out
def _load_and_guard(
session: Session, receipt_id: int, *, is_recalc: bool
) -> tuple[Receipt, Business]:
"""锁行 + 模式校验 + 月结锁定拦截 + 业务归属校验(核算/重算共用)。"""
receipt = session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None:
raise BadRequestError("回款不存在")
if is_recalc:
if receipt.settlement_status == SETTLEMENT_PENDING:
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
elif receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
# 月结锁定拦截:已审定月份的回款不得再核算/重算——金额已固化,
# 变动唯一通道是调整单(否则重算会绕开审批改写已锁定月份的应付)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
business = session.get(Business, receipt.business_id, with_for_update=True)
if business is None:
raise BadRequestError("回款关联的业务不存在")
if business.owner_personnel_id is None:
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
return receipt, business
def _prepare_mode(
session: Session, receipt: Receipt, business: Business, *, is_recalc: bool
) -> CalcReceiptOut | None:
"""模式预处理:重算作废旧版本;首算判业务级一次性计提。
返回非 None = 本笔仅登记进度(repeated=True 的出参,已 commit),
调用方直接返回。
"""
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算。
# 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
# 两行生效行导致双重计提 → 一律 400
if is_recalc:
# 分摊拆行后旧版本可能多行(负责人+参与人)——全部作废再落新版本
olds = performance_ledger_repository.list_active_by_receipt(session, receipt.id)
if not olds:
raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
for old in olds:
performance_ledger_repository.mark_inactive(session, old)
return None
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
if not performance_ledger_repository.has_active_for_business(
session, business.id, calc_type=CALC_TYPE_RECEIPT
):
return None
_mark_settled(session, receipt)
row = performance_ledger_repository.get_active_by_receipt_of_business(session, business.id)
session.flush()
out = CalcReceiptOut(ledger=_to_out(row), repeated=True)
session.commit()
logger.info("回款登记进度(业务已核算)| receipt=%s | business=%s", receipt.id, business.id)
return out
def _load_shipment(session: Session, receipt: Receipt) -> Shipment:
"""核销基准:发货批次存在且带发货日(回款天数公式的输入)。"""
if receipt.shipment_id is None:
raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)")
shipment = session.get(Shipment, receipt.shipment_id)
if shipment is None or shipment.ship_date is None:
raise BadRequestError("发货批次缺少发货日期,无法核算")
return shipment
def _load_person(session: Session, business: Business):
"""核算归属:在职 + 绑定岗位角色(计提公式锚点)。"""
person = personnel_service.ensure_active(session, business.owner_personnel_id, label="核算归属")
if person.business_role_id is None:
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
return person
def _load_items(session: Session, business: Business) -> list[BusinessItem]:
"""业务产品明细行(逐行折算的输入;空明细无法核算)。"""
items = business_repository.list_items(session, business.id)
if not items:
raise BadRequestError("业务无产品明细行,无法核算")
return items
def _load_allocations(session: Session, business: Business, owner_id: int) -> list:
"""业务生效的二次分配方案(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
校验双保险之一(保存侧在 business_service):比例合计 >100% 会把负责人
份额算成负数(多算参与人绩效);合同负责人不可出现在参与人列表(自分
份额会双重计入)。无方案返回空列表 = 全额归负责人。
"""
allocations = business_repository.list_allocations(session, business.id)
if not allocations:
return []
total = sum((a.ratio for a in allocations), Decimal("0"))
if total > Decimal("100"):
raise BadRequestError(
f"二次分配比例合计 {total.quantize(Decimal('0.01'))}% 超过 100%,"
"无法核算(先调整方案比例)"
)
if any(a.personnel_id == owner_id for a in allocations):
raise BadRequestError("二次分配参与人含合同负责营销人员,无法核算(先调整方案)")
return list(allocations)
@dataclass(frozen=True, slots=True)
class _Conversion:
"""逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。"""
total_revenue: Decimal
total_profit: Decimal
converted_total: Decimal
missing_cost: bool
line_details: list[dict[str, Any]]
def _convert_items(snapshot, person, items: list[BusinessItem]) -> _Conversion:
"""逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)。"""
total_revenue = Decimal("0")
total_profit = Decimal("0")
converted_total = Decimal("0")
missing_cost = False
line_details: list[dict[str, Any]] = []
for item in items:
row_revenue = item.sale_amount
if item.cost_amount is None:
missing_cost = True
row_profit = Decimal("0")
else:
row_profit = _row_profit(row_revenue, item.cost_amount)
total_revenue += row_revenue
total_profit += row_profit
threshold = parse_fraction(
snapshot.get(
"product.threshold",
role_code=role_code_of(person),
product_category=item.product_category,
)
)
row_converted = converted_profit(
row_revenue, profit_rate(row_profit, row_revenue), threshold
)
converted_total += row_converted
line_details.append(
{
"item_id": item.id,
"product_category": item.product_category,
"revenue": str(row_revenue),
"profit": str(row_profit),
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
# 快照/台账口径需与列一致)
"converted": str(_q2(row_converted)),
}
)
return _Conversion(total_revenue, total_profit, converted_total, missing_cost, line_details)
@dataclass(frozen=True, slots=True)
class _Amounts:
"""计提组成结果(比例分派 → 绩效 → 抵顶 → 支付权重 → 应付)。"""
days: int
decision: _RateDecision
converted: Decimal
performance: Decimal
barter_applied: bool
weight_r: int
weight: Fraction
payable: Decimal
start_ok: bool
def _compose_amounts(
session: Session,
snapshot,
person,
business: Business,
receipt: Receipt,
shipment: Shipment,
conversion: _Conversion,
) -> _Amounts:
"""按比例分派与支付权重组成应付(与 param_service.preview 同公式路径)。"""
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
days = payment_days(shipment.ship_date, receipt.receipt_date)
decision = _personal_rate(session, snapshot, person, business, days)
if decision.base_override is not None:
converted = decision.base_override # 信息员:基数=本人销售额
else:
converted = conversion.converted_total
performance = (
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
)
barter_applied = receipt.is_barter == 1
base_amount = performance
if barter_applied:
discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person)))
base_amount = apply_barter(performance, discount)
received_total = business.total_received_amount or Decimal("0")
start_ratio = parse_fraction(
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
)
start_ok = start_payment_ok(received_total, conversion.total_revenue, start_ratio)
weight_r = clamp_weight_r(received_total, conversion.total_revenue)
weight = payment_weight(weight_r)
payable = param_service.to_decimal(Fraction(base_amount) * weight)
return _Amounts(
days=days,
decision=decision,
converted=converted,
performance=performance,
barter_applied=barter_applied,
weight_r=weight_r,
weight=weight,
payable=payable,
start_ok=start_ok,
)
@dataclass(frozen=True, slots=True)
class _LedgerRows:
"""拆行落库结果:负责人行恒存在(出参与 CAS 锚点),参与人份额行可空。"""
owner_row: PerformanceLedger
share_rows: list[PerformanceLedger]
def _persist_ledger(
session: Session,
*,
receipt: Receipt,
business: Business,
person,
shipment: Shipment,
items: list[BusinessItem],
allocations: list,
conversion: _Conversion,
amounts: _Amounts,
recalc_reason: str | None,
is_recalc: bool,
) -> _LedgerRows:
"""落台账行(分摊拆行;版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。
分摊(澄清 #1 答复 2026-10-19「分配绩效」,算后分):业务级全额(含
递减比例、抵顶、支付权重全环节)算完后按方案比例拆给参与人,余额归
合同负责人。行合计与业务级全额**守恒**(负责人行取 q2 全额减参与人
q2 份额的差额,防分漂);每行快照带完整方案快照,行级可独立回溯。
参与人离职照分(历史方案事实,方案调整是业务侧责任——入职校验只在
保存侧,核算侧只要求人员存在,FK RESTRICT 已保证)。
"""
receipt_date = receipt.receipt_date
base_snapshot: dict[str, Any] = {
"params_as_of": str(receipt_date),
"line_details": conversion.line_details,
"start_payment_ok": amounts.start_ok,
"rate_note": amounts.decision.rate_note,
"revenue_total": str(conversion.total_revenue),
"profit_total": str(conversion.total_profit),
}
if amounts.decision.manager_pending:
base_snapshot["strategy_note"] = _NOTE_MANAGER_PENDING
if allocations:
base_snapshot["alloc_plan"] = [
{
"personnel_id": a.personnel_id,
"personnel_name": a.personnel_name,
"ratio": str(a.ratio),
}
for a in allocations
]
version = performance_ledger_repository.next_version(session, receipt.id)
customer_type = _customer_type(session, business)
category = _category_label(items)
# 份额清单:(person, ratio, tag)——ratio 为 None 时走余额公式(q2 全额
# 减已分累计,守恒);负责人余额的百分数(100−∑参与人)仅作快照展示,
# 不参与计算——按它乘全额会丢 q2 尾差(39.1111×30% ≠ 39.11−27.37)
shares: list[tuple[Any, Decimal | None, str]] = []
owner_ratio_text: str | None = None
if not allocations:
shares.append((person, None, "owner"))
owner_ratio_text = "100"
else:
total_ratio = Decimal("0")
for a in allocations:
shares.append((session.get(Personnel, a.personnel_id), a.ratio, "participant"))
total_ratio += a.ratio
owner_ratio_text = str((Decimal("100") - total_ratio).quantize(Decimal("0.01")))
shares.append((person, None, "owner"))
def _share(full: Decimal, ratio: Decimal | None, *, added: Decimal = Decimal("0")) -> Decimal:
"""单份额金额:参与人 = 全额×比例(q2);负责人 = q2 全额 − 已分累计。"""
if ratio is None:
return _q2(full - added) or Decimal("0")
# Fraction 乘积先 to_decimal 再 q2(Fraction 无 quantize,直接喂 _q2 会炸)
return _q2(param_service.to_decimal(Fraction(full) * Fraction(ratio) / 100)) or Decimal("0")
added_perf = Decimal("0")
added_conv = Decimal("0")
added_pay = Decimal("0")
owner_row: PerformanceLedger | None = None
share_rows: list[PerformanceLedger] = []
for target, ratio, tag in shares:
perf = _share(amounts.performance, ratio, added=added_perf)
conv = _share(amounts.converted, ratio, added=added_conv)
pay = _share(amounts.payable, ratio, added=added_pay)
added_perf += perf
added_conv += conv
added_pay += pay
row = performance_ledger_repository.add(
session,
PerformanceLedger(
receipt_id=receipt.id,
calc_version=version,
calc_type=CALC_TYPE_RECEIPT,
business_id=business.id,
business_no=business.business_no,
personnel_id=target.id,
personnel_name=target.personnel_name,
role_code=role_code_of(target),
customer_type=customer_type,
product_category=category,
ship_date=shipment.ship_date,
receipt_date=receipt_date,
payment_days=amounts.days,
rate_text=str(amounts.decision.rate),
converted_profit=conv,
performance_amount=perf,
barter_applied=1 if amounts.barter_applied else 0,
weight_r=amounts.weight_r,
payment_weight=param_service.to_decimal(amounts.weight),
payable_amount=pay,
settlement_month=receipt_date.strftime("%Y-%m"),
is_probation=1 if target.is_probation == 1 else 0,
missing_cost=1 if conversion.missing_cost else 0,
snapshot_json={
**base_snapshot,
"alloc_role": tag,
"alloc_ratio": owner_ratio_text if tag == "owner" else str(ratio),
},
calc_reason=recalc_reason,
),
)
if tag == "owner":
owner_row = row
else:
share_rows.append(row)
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
# 会 rowcount=0 误报并发撞车)
if not is_recalc:
_mark_settled(session, receipt)
return _LedgerRows(owner_row=owner_row, share_rows=share_rows)
def _q2(value: Decimal | None) -> Decimal | None:
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。
显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家舍入),
x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。
"""
if value is None:
return None
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _personal_rate(
session: Session,
snapshot,
person,
business: Business,
days: int,
) -> _RateDecision:
"""按岗位模板分派单笔计提比例(与 param_service._preview_rate 同构)。
#12 策略点:经理个人户(owner 岗位绑 TPL_MANAGER_SHARE)单笔不计提
—— 12.5%+5% 是月度团队聚合口径,单笔落 rate=0/绩效 0 的台账行保事实。
"""
role = _role_of(session, person)
customer_type = _customer_type(session, business)
if customer_type == "enterprise":
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
return _RateDecision(rate, "企业户:不按回款天数,统一按固定比例计提", None, False)
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
if template == "TPL_MANAGER_SHARE":
# #12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户
# 单按业务员递减公式单笔计提(个人户单本质是正常业务,有发货/回款/
# 毛利;聚合基数开关在 settlement_service 同参数消费)——0 时单笔落 0
if snapshot.get("manager.personal.included") != "1":
return _RateDecision(
Decimal("0"),
"经理个人户:绩效按团队与全公司分成在月结时计算,单笔不计提",
None,
True,
)
start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰"
return _RateDecision(rate, note, None, False)
if template == "TPL_INFO_SALES_FIXED":
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
rate = parse_fraction(snapshot.get("info.rate")) / 1000
return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False)
if template == "TPL_SALESPERSON_DECAY":
start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
# int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500;
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = (
f"回款 {days} 天,从起始 {start}‰ 按日递减"
f"(满 {boundary} 天后固定按 {floor}‰ 计算)"
)
return _RateDecision(rate, note, None, False)
# 提示给模板中文名而非编码:业务人员在岗位角色页看到的是「营销经理·团队与全公司分成」
from app.services.calc_templates import TEMPLATES
tpl_name = TEMPLATES[template].name if template in TEMPLATES else template
raise BadRequestError(f"「{tpl_name}」不支持单笔核算,请检查该人员的岗位设置")
def _role_of(session: Session, person):
from app.repositories import business_role_repository
if person.business_role_id is None:
return None
return business_role_repository.get_by_id(session, person.business_role_id)
def role_code_of(person) -> str:
"""人员岗位编码(台账冗余固化用;无岗位为空串——前置校验已拦)。"""
from app.repositories import business_role_repository
if person.business_role_id is None:
return ""
session = Session.object_session(person)
role = business_role_repository.get_by_id(session, person.business_role_id)
return role.role_code if role else ""
def _customer_type(session: Session, business: Business) -> str:
customer = session.get(Customer, business.customer_id)
return customer.customer_type if customer else "personal"
def _category_label(items: list[BusinessItem]) -> str:
"""单类别直出;混合业务记 mixed(行级明细在 snapshot_json)。"""
categories = {item.product_category for item in items}
return categories.pop() if len(categories) == 1 else "mixed"
def _mark_settled(session: Session, receipt: Receipt) -> None:
"""CAS 置已核算:并发下另一事务先行置 1 时 rowcount=0 → 400。"""
result = session.execute(
update(Receipt)
.where(Receipt.id == receipt.id, Receipt.settlement_status == SETTLEMENT_PENDING)
.values(settlement_status=SETTLEMENT_DONE)
)
if result.rowcount == 0:
raise BadRequestError("该回款已被并发核算,请刷新后查看台账")
def _row_profit(revenue: Decimal, cost: Decimal) -> Decimal:
"""行毛利 = 行销售额 − 行成本。
#20 策略点(待业务答复,2026-10-16 暂定口径):金额暂按**不含税**原样
相减;答复若为含税口径,只改本函数(含税换算/取不含税金额列)+ 按回款
批量重算回填。
"""
return revenue - cost
def recalc_receipt(
session: Session,
receipt_id: int,
reason: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CalcReceiptOut:
"""重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。
薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定
的拦截在 calc_receipt 内(核算与重算同口径)。
"""
if not reason or not reason.strip():
raise BadRequestError("重算必须填写原因")
return calc_receipt(
session, receipt_id, operator=operator, meta=meta, recalc_reason=reason.strip()
)
def batch_calc(
session: Session,
receipt_ids: list[int],
*,
operator: User,
meta: RequestMeta | None = None,
) -> list[dict]:
"""批量核算:逐笔独立事务(各笔自带 audit),返回逐笔成败清单。
**不开外层 audit**——外层包 audit 会违反嵌套禁令,且单笔失败不应
回滚其它笔的核算结果。
"""
results: list[dict] = []
for rid in receipt_ids:
try:
out = calc_receipt(session, rid, operator=operator, meta=meta)
results.append(
{
"receipt_id": rid,
"ok": True,
"repeated": out.repeated,
"ledger_id": out.ledger.id,
}
)
except Exception as exc:
results.append(
{"receipt_id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
)
ok_count = sum(1 for r in results if r["ok"])
logger.info("批量核算 | 总数=%s 成功=%s", len(results), ok_count)
return results
def list_ledger_page(session: Session, query):
"""分页查询台账(api 层转发)。"""
rows = performance_ledger_repository.list_ledger(session, query)
total = performance_ledger_repository.count_ledger(session, query)
from app.core.response import PageResult
from app.schemas.calc import LedgerOut
return PageResult[LedgerOut](
records=[LedgerOut.model_validate(r) for r in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_ledger(session: Session, ledger_id: int):
"""台账详情(api 层转发)。"""
row = performance_ledger_repository.get_by_id(session, ledger_id)
if row is None:
from app.core.exceptions import NotFoundError
raise NotFoundError("台账记录不存在")
from app.schemas.calc import LedgerOut
return LedgerOut.model_validate(row)
def _to_out(row: PerformanceLedger):
from app.schemas.calc import LedgerOut
return LedgerOut.model_validate(row)
+129 -47
View File
@@ -14,6 +14,8 @@
"""
import logging
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import Row
from sqlalchemy.exc import IntegrityError
@@ -37,6 +39,7 @@ from app.schemas.customer import (
CustomerOut,
CustomerOwnershipIn,
CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn,
OwnershipChangeLogOut,
)
@@ -60,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
owner_personnel_id=customer.owner_personnel_id,
owner_personnel_name=owner_name,
remark=customer.remark,
rating_score=customer.rating_score,
rating_time=customer.rating_time,
)
@@ -107,20 +112,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
"""分页查询客户(附归属人员姓名)。"""
rows = customer_repository.list_customers(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
)
total = customer_repository.count_customers(
session,
keyword=query.keyword,
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
)
rows = customer_repository.list_customers(session, query)
total = customer_repository.count_customers(session, query)
return PageResult[CustomerOut](
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
total=total,
@@ -274,6 +267,53 @@ def update_customer(
return out
def rate_customer(
session: Session,
customer_id: int,
payload: CustomerRatingIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CustomerOut:
"""保存客户评级(评级计算器「保存评分」)。
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。
:raises NotFoundError: 客户不存在
"""
# 客户名进审计摘要,预取放在审计上下文之前(同 change_ownership 口径)
row = customer_repository.get_with_owner_name(session, customer_id)
customer_name = row[0].company_name if row else f"#{customer_id}"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CUSTOMER,
action=ACTION_UPDATE,
summary=f"保存客户评级 {customer_name}:{payload.score} 分",
request_data=payload.model_dump(),
),
meta=meta,
):
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name = row
customer.rating_score = payload.score
# 评级时间取应用层本地时间(库列 TZ=Asia/Shanghai,与 TimestampMixin
# 的服务器默认值同一时区口径),不用数据库 NOW():行已在本会话
customer.rating_time = datetime.now()
# 响应在 commit 之前构造(同 user_service 顶部说明)
out = _to_out(customer, owner_name)
session.commit()
logger.info("保存客户评级 | customer_id=%s | score=%s", customer_id, payload.score)
return out
def delete_customer(
session: Session,
customer_id: int,
@@ -317,6 +357,71 @@ def delete_customer(
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
@dataclass(frozen=True, slots=True)
class _CustomerOwnershipPrefetch:
"""客户归属变更的审计前只读预取(摘要要客户名与新旧归属人名)。"""
row: Row | None
new_person: Personnel | None
old_label: str
new_label: str
customer_name: str
def _prefetch_customer_ownership(
session: Session, customer_id: int, payload: CustomerOwnershipIn
) -> _CustomerOwnershipPrefetch:
"""审计上下文之前的只读预取(预取不写库,失败路径也要有可读摘要)。"""
row = customer_repository.get_with_owner_name(session, customer_id)
old_person = (
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
if row is not None and row[0].owner_personnel_id is not None
else None
)
new_person = (
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
if payload.new_owner_personnel_id is not None
else None
)
return _CustomerOwnershipPrefetch(
row=row,
new_person=new_person,
old_label=old_person.personnel_name if old_person else "无",
new_label=new_person.personnel_name if new_person else "无",
customer_name=row[0].company_name if row else f"#{customer_id}",
)
def _validate_customer_ownership(
session: Session, customer: Customer, payload: CustomerOwnershipIn
) -> int | None:
"""校验新归属人(须在职)并判定归属是否变化,返回变更前归属人 ID。"""
if payload.new_owner_personnel_id is not None:
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
old_owner_id = customer.owner_personnel_id
if old_owner_id == payload.new_owner_personnel_id:
raise BadRequestError("归属未变化")
return old_owner_id
def _customer_ownership_log(
customer: Customer,
payload: CustomerOwnershipIn,
old_owner_id: int | None,
operator: User,
) -> OwnershipChangeLog:
"""客户归属变更留痕行。"""
return OwnershipChangeLog(
owner_type=OWNER_TYPE_CUSTOMER,
customer_id=customer.id,
old_owner_personnel_id=old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
)
def change_ownership(
session: Session,
customer_id: int,
@@ -335,57 +440,34 @@ def change_ownership(
"""
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
# 上下文内进行,失败同样要留下可追溯的记录
row = customer_repository.get_with_owner_name(session, customer_id)
old_person = (
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
if row is not None and row[0].owner_personnel_id is not None
else None
)
new_person = (
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
if payload.new_owner_personnel_id is not None
else None
)
old_label = old_person.personnel_name if old_person else "无"
new_label = new_person.personnel_name if new_person else "无"
customer_name = row[0].company_name if row else f"#{customer_id}"
prefetch = _prefetch_customer_ownership(session, customer_id, payload)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CUSTOMER,
action=ACTION_UPDATE,
summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}",
summary=(
f"调整客户归属 {prefetch.customer_name}:"
f"{prefetch.old_label} → {prefetch.new_label}"
),
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
if prefetch.row is None:
raise NotFoundError("客户不存在")
customer, _ = row
customer, _ = prefetch.row
if payload.new_owner_personnel_id is not None:
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
old_owner_id = customer.owner_personnel_id
if old_owner_id == payload.new_owner_personnel_id:
raise BadRequestError("归属未变化")
old_owner_id = _validate_customer_ownership(session, customer, payload)
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
customer.owner_personnel_id = payload.new_owner_personnel_id
customer_repository.add_ownership_log(
session,
OwnershipChangeLog(
owner_type=OWNER_TYPE_CUSTOMER,
customer_id=customer.id,
old_owner_personnel_id=old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
),
session, _customer_ownership_log(customer, payload, old_owner_id, operator)
)
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
out = _to_out(customer, new_person.personnel_name if new_person else None)
out = _to_out(customer, prefetch.new_person.personnel_name if prefetch.new_person else None)
session.commit()
logger.info(
"调整客户归属 | customer_id=%s | old=%s | new=%s",
+290
View File
@@ -0,0 +1,290 @@
"""工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。
设计决策(2026-10-19):
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
不复用报表的部门汇总(后者会加载整月台账,过重)
- **待办与预警实时派生**:不建待办/预警表、不引调度器;处置动作本身
已有操作日志可追溯。与 FR-035「预警产生/消除留痕」的差距记录在
计划开放项,待业务确认是否物化
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
``sync_available=false`` 显式降级,不返回伪造的 0 条
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
"""
from datetime import date, datetime, timedelta
from decimal import Decimal
from sqlalchemy.orm import Session
from app.core.exceptions import BadRequestError
from app.core.month import validate_month
from app.repositories import dashboard_repository, operation_log_repository, user_repository
from app.repositories.query_utils import LogFilters
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5
_WARNING_LIMIT = 20
_TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""三区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
"""
resolved = _normalize_month(month)
warning_params = _warning_params(session)
pending_statuses = approval_service.pending_statuses_for_roles(
user_repository.get_role_codes(current_user)
)
return {
"month": resolved,
"overview": _overview(session, resolved),
"todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params),
}
def _normalize_month(month: str | None) -> str:
"""未传月份按当月;非法月份在入口拦成 400(避免 500)。"""
if not month:
today = date.today()
return f"{today.year:04d}-{today.month:02d}"
try:
return validate_month(month)
except ValueError as exc:
raise BadRequestError(str(exc)) from exc
def _shift_month(month: str, delta: int) -> str:
"""YYYY-MM 平移 delta 个月(用于近 N 个月窗口)。"""
year, mon = int(month[:4]), int(month[5:7])
index = year * 12 + (mon - 1) + delta
return f"{index // 12:04d}-{index % 12 + 1:02d}"
def _month_start(month: str) -> date:
"""YYYY-MM → 该月首日(范围查询边界,避免函数列过滤)。"""
return date(int(month[:4]), int(month[5:7]), 1)
def _trend_months(month: str) -> list[str]:
"""窗口为「当月及之前共 6 个月」,升序返回便于前端画图。"""
return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)]
def _overview(session: Session, month: str) -> dict:
"""经营绩效总览(FR-033):口径与台账/月结一致。"""
sums = dashboard_repository.sum_month_performance(session, month)
settlement = dashboard_repository.get_month_settlement(session, month)
months = _trend_months(month)
performance = dashboard_repository.performance_trend(session, months)
receipts = dashboard_repository.receipt_trend(
session,
start_date=_month_start(months[0]),
end_date=_month_start(_shift_month(months[-1], 1)),
)
return {
"month": month,
"performance_total": _dec(sums["performance_total"]),
"payable_total": _dec(sums["payable_total"]),
"person_count": int(sums["person_count"]),
"team_count": dashboard_repository.count_active_managers(session),
"settlement": {
"exists": settlement is not None,
"status": settlement.status if settlement else None,
"total_performance": settlement.total_performance if settlement else None,
"total_payable": settlement.total_payable if settlement else None,
"locked_at": settlement.locked_at if settlement else None,
},
"trend": [
{
"month": item,
"performance_total": _dec(performance.get(item)),
"receipt_total": _dec(receipts.get(item)),
}
for item in months
],
"note": (
"绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);"
"回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单"
),
}
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
领导只看待领导审核;admin 查看全部在途单。
"""
orders = dashboard_repository.list_pending_approvals(
session, statuses=pending_statuses, limit=_TODO_LIMIT
)
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
return {
"pending_approval_total": dashboard_repository.count_pending_approvals(
session, statuses=pending_statuses
),
"pending_approvals": [
{
"order_id": order.id,
"order_no": order.order_no,
"order_type": order.order_type,
"submitter_name": order.submitter_name,
"create_time": order.create_time,
}
for order in orders
],
"unassigned_receipt_total": dashboard_repository.count_unassigned_receipts(session),
"unassigned_receipts": [
{
"receipt_id": row[0].id,
"receipt_batch_no": row[0].receipt_batch_no,
"business_id": row[0].business_id,
"business_no": row.business_no,
"amount": row[0].amount,
"receipt_date": row[0].receipt_date,
}
for row in receipts
],
# ⑥ T+ 同步未建:显式降级,不伪造「无失败」
"sync_available": False,
"sync_failure_total": 0,
}
def _warning_params(session: Session) -> dict:
"""预警参数一次解析(阈值参数化,澄清 #10)。"""
from app.services.param_service import parse_fraction, resolve_snapshot
snapshot = resolve_snapshot(session, date.today())
return {
"enabled": snapshot.get("warning.enabled") == "1",
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
"approval_timeout_days": int(parse_fraction(snapshot.get("warning.approval.timeout.days"))),
}
def _warnings(session: Session, params: dict) -> dict:
"""风险预警(FR-035):首批两条可算规则;同步规则待 ⑥。"""
if not params["enabled"]:
return {
"enabled": False,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": [],
"total": 0,
"receipt_overdue_total": 0,
"approval_timeout_total": 0,
"sync_available": False,
}
receipt_cutoff = date.today() - timedelta(days=params["receipt_overdue_days"])
approval_cutoff = datetime.now() - timedelta(days=params["approval_timeout_days"])
shipments = dashboard_repository.list_overdue_shipments(
session, cutoff=receipt_cutoff, limit=_WARNING_LIMIT
)
orders = dashboard_repository.list_overdue_approvals(
session, cutoff=approval_cutoff, limit=_WARNING_LIMIT
)
items = [
{
"code": "receipt_overdue",
"level": "danger",
"object_type": "shipment",
"object_id": row[0].id,
"title": f"发货批次 {row[0].shipment_no} 逾期未回款",
"detail": (
f"业务 {row.business_no or row[0].business_id} 发货日 {row[0].ship_date},"
f"超过 {params['receipt_overdue_days']} 天无回款记录"
),
"suggest": "联系客户催收,或核对回款批次指定关系",
"link": "/business/shipments",
}
for row in shipments
]
items += [
{
"code": "approval_timeout",
"level": "warning",
"object_type": "approval_order",
"object_id": order.id,
"title": f"审批单 {order.order_no} 超时未处理",
"detail": (
f"{_ORDER_TYPE_LABELS.get(order.order_type, '审批')}由 "
f"{order.submitter_name or '—'} 提交于 {order.create_time:%Y-%m-%d %H:%M},"
f"已超过 {params['approval_timeout_days']} 天"
),
"suggest": "在审批管理中复核或退回",
"link": "/system/approvals",
}
for order in orders
]
receipt_overdue_total = dashboard_repository.count_overdue_shipments(
session, cutoff=receipt_cutoff
)
approval_timeout_total = dashboard_repository.count_overdue_approvals(
session, cutoff=approval_cutoff
)
return {
"enabled": True,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": items,
# items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数
"total": receipt_overdue_total + approval_timeout_total,
"receipt_overdue_total": receipt_overdue_total,
"approval_timeout_total": approval_timeout_total,
"sync_available": False,
}
def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
"""最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
模块筛选项与列表同源返回(全量模块名,不随当前页变化),
前端模块下拉不必另取一次全量。
"""
repo_query = operation_log_repository.OperationLogListQuery(
filters=LogFilters(),
offset=query.offset,
limit=query.page_size,
module=query.module,
)
logs = operation_log_repository.list_logs(session, repo_query)
total = operation_log_repository.count_logs(session, repo_query)
return ActivityPageOut(
records=[
ActivityItem(
id=log.id,
module=log.module,
action=log.action,
summary=log.summary,
username=log.username,
success=log.success,
create_time=log.create_time,
)
for log in logs
],
total=total,
page_num=query.page_num,
page_size=query.page_size,
modules=operation_log_repository.distinct_modules(session),
)
def _dec(value) -> Decimal | None:
"""SQL 聚合结果转 Decimal(SUM 可能返回 int,None 原样返回)。"""
if value is None:
return None
return value if isinstance(value, Decimal) else Decimal(str(value))
+66
View File
@@ -41,6 +41,10 @@ def salesperson_rate(
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
首日取整(澄清 #14 业务答复 2026-10-19):第 1 天**不递减**、直接取
起始值(需规图示「第 1 天 10‰」覆盖公式的 9.9889‰)——第 2 天起按公式
递减,360 天=6‰ 锚点与 361 天=4‰ 分界均不受影响。
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
@@ -48,6 +52,8 @@ def salesperson_rate(
days = max(days, 0)
if days > boundary_days:
return floor / 1000
if days <= 1:
return start / 1000
return (start - days * slope) / 1000
@@ -95,3 +101,63 @@ def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bo
if revenue == 0:
return False
return Fraction(received) / Fraction(revenue) >= ratio / 100
def clamp_weight_r(received: Decimal, revenue: Decimal) -> int:
"""支付权重 R:回款占销售额百分比,封顶封底 [0, 100](对照表 #22)。
received 非负时 int() 即向下取整;min/max 同时是红字冲销(负数回款)
接入时的方向防线。从计算预览的内联实现提取 —— ④ 核算与预览共用。
"""
if revenue <= 0:
return 0
return max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100)))
def monthly_salary(performance: Decimal, basic: Decimal | None, mode: str) -> Decimal | None:
"""月工资分岗口径(对照表 #8,salary.basic.mode 按角色维度取值)。
- INCLUDE:绩效 + 基本工资(业务员默认——绩效之外照发基本工资)
- DEDUCT:绩效 − 基本工资(经理默认——基本工资从绩效分成中扣除)
- EXCLUDE:仅绩效,基本工资不参与
basic 缺失时按 0 参与(INCLUDE/EXCLUDE 等价于纯绩效)。
"""
basic_value = basic if basic is not None else Decimal("0")
if mode == "INCLUDE":
return performance + basic_value
if mode == "DEDUCT":
return performance - basic_value
if mode == "EXCLUDE":
return performance
raise ValueError(f"未知的月工资口径:{mode}")
def probation_annual_note(year_total: Decimal, threshold: Decimal) -> str | None:
"""试用期年累计说明:超阈值部分年底一次性结算(对照表 #17)。
未超阈值返回 None(无需说明);阈值按元展示(Decimal 直出)。
"""
if year_total <= threshold:
return None
excess = (year_total - threshold).quantize(Decimal("0.01"), rounding="ROUND_HALF_UP")
return f"试用期绩效照算不发放;年累计超 {threshold} 元部分年底一次性结算(当前超 {excess} 元)"
def manager_team_share(
team_performance: Decimal,
company_performance: Decimal,
*,
team_pct: Fraction,
company_pct: Fraction,
) -> Decimal:
"""经理绩效 = 本团队业务员绩效 × 12.5% + 全公司业务员绩效 × 5%(裁决 #6/#15/#16)。
**#12 策略点(待业务答复,2026-10-16 暂定口径)**:基数**不含**经理本人
名下经理个人户业务的业绩——该部分按经理个人户规则另行处理(单笔落台账
rate=0/绩效 0,待答复后若需并入,只改调用方传入的基数集合)。
团队口径:manager_id=该经理且岗位非「营销经理」的业务员绩效行之和;
全公司口径:全部业务员(非经理岗)绩效行之和。
"""
return _to_decimal(Fraction(team_performance) * team_pct / 100) + _to_decimal(
Fraction(company_performance) * company_pct / 100
)
+8 -12
View File
@@ -22,18 +22,15 @@ from app.schemas.log import (
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
filters = _filters_from(query)
logs = operation_log_repository.list_logs(
session,
repo_query = operation_log_repository.OperationLogListQuery(
filters=_filters_from(query),
offset=query.offset,
limit=query.page_size,
filters=filters,
module=query.module,
action=query.action,
)
total = operation_log_repository.count_logs(
session, filters=filters, module=query.module, action=query.action
)
logs = operation_log_repository.list_logs(session, repo_query)
total = operation_log_repository.count_logs(session, repo_query)
return PageResult[OperationLogOut](
records=[OperationLogOut.model_validate(log) for log in logs],
total=total,
@@ -55,15 +52,14 @@ def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
"""分页查询登录日志。"""
filters = _filters_from(query)
logs = login_log_repository.list_logs(
session,
repo_query = login_log_repository.LoginLogListQuery(
filters=_filters_from(query),
offset=query.offset,
limit=query.page_size,
filters=filters,
ip=query.ip,
)
total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
logs = login_log_repository.list_logs(session, repo_query)
total = login_log_repository.count_logs(session, repo_query)
return PageResult[LoginLogOut](
records=[LoginLogOut.model_validate(log) for log in logs],
total=total,
+124
View File
@@ -0,0 +1,124 @@
"""经理分成共享口径(月结聚合与报表部门汇总的唯一实现)。
从 settlement_service 迁出(行为零变化):经理 12.5%/5% 的取参、团队
分桶、公司基数计算,月结生成与部门汇总报表都要用——抽为单一实现后
「报表汇总 == 月结聚合行」不靠两处代码各自对齐,而是同源函数的直接
结果。分成金额仍走 formula.manager_team_share 纯函数。
"""
from calendar import monthrange
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from fractions import Fraction
from sqlalchemy.orm import Session
from app.core.exceptions import BadRequestError
from app.core.month import parse_month
from app.models import Personnel
@dataclass(frozen=True, slots=True)
class ManagerShareParams:
"""经理分成取参与参数原文(同一次计算共用一份,随月月末时点化)。"""
month: str
param_date: date
team_text: str
company_text: str
team_pct: Fraction
company_pct: Fraction
include_manager_personal: bool
def month_end(month: str) -> date:
"""YYYY-MM → 所属月月末(月结与报表取参锚点)。
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
更细的业务日期,取该月最后可确定的生效时点。
"""
try:
year, mon = parse_month(month)
except ValueError as exc:
# schema 已拦住非法月份;内部调用绕过 schema 时也不能变成 500
raise BadRequestError(str(exc)) from exc
return date(year, mon, monthrange(year, mon)[1])
def manager_share_params(session: Session, month: str) -> ManagerShareParams:
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
"""
from app.services.param_service import parse_fraction, resolve_snapshot
param_date = month_end(month)
snapshot = resolve_snapshot(session, param_date)
team_text = snapshot.get("manager.share.team")
company_text = snapshot.get("manager.share.company")
return ManagerShareParams(
month=month,
param_date=param_date,
team_text=team_text,
company_text=company_text,
team_pct=parse_fraction(team_text),
company_pct=parse_fraction(company_text),
include_manager_personal=snapshot.get("manager.personal.included") == "1",
)
def add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
bucket = managers.setdefault(
manager_id,
{
"performance": Decimal("0"),
"business_id": row.business_id,
"business_no": row.business_no,
"names": set(),
},
)
bucket["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name)
def collect_team_buckets(
session: Session, ledger_rows: list, include_manager_personal: bool
) -> dict[int, dict]:
"""按经理采集团队业绩。
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
按上溯会被跳过漏计)。
"""
managers: dict[int, dict] = {}
for row in ledger_rows:
if row.customer_type == "manager_personal":
if include_manager_personal:
add_team_performance(managers, row.personnel_id, row)
continue # 默认不计入聚合基数(#12 参数化)
person = session.get(Personnel, row.personnel_id)
manager_id = person.manager_id if person else None
if manager_id is not None:
add_team_performance(managers, manager_id, row)
return managers
def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decimal:
"""全公司基数(5% 的底):默认排除经理个人户行(#12 开关计入时不过滤)。
口径逐行固定,调用方在循环外算一次。
"""
return sum(
(
r.performance_amount or Decimal("0")
for r in ledger_rows
if include_manager_personal or r.customer_type != "manager_personal"
),
Decimal("0"),
)
+114 -24
View File
@@ -3,6 +3,10 @@
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**:
说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、
内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。
"""
from dataclasses import dataclass
@@ -43,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10",
value_min="0",
value_max=None,
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。",
sort_order=1,
),
ParamSpec(
@@ -57,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4/360",
value_min="0",
value_max=None,
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。",
sort_order=2,
),
ParamSpec(
@@ -71,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4",
value_min="0",
value_max=None,
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。",
sort_order=3,
),
ParamSpec(
@@ -85,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="360",
value_min="0",
value_max=None,
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。",
sort_order=4,
),
ParamSpec(
@@ -99,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="12.5",
value_min="0",
value_max="100",
description="∑本人带领业务员绩效 × 此比例(限本团队)",
description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。",
sort_order=5,
),
ParamSpec(
@@ -113,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="5",
value_min="0",
value_max="100",
description="∑全部业务员绩效 × 此比例(全公司)",
description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。",
sort_order=6,
),
ParamSpec(
@@ -127,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3",
value_min="0",
value_max=None,
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。",
sort_order=7,
),
ParamSpec(
@@ -141,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1",
value_min="0",
value_max=None,
description="信息员绩效 = 本人销售额 × 此比例",
description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。",
sort_order=8,
),
ParamSpec(
@@ -155,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10",
value_min="0",
value_max=None,
description="预付款统一按此比例计提",
description="预付款到账即按这个比例计提绩效,不必等到全额回款。",
sort_order=9,
),
ParamSpec(
@@ -169,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="80",
value_min="0",
value_max="100",
description="以物抵顶回款绩效按应发绩效的此比例折算",
description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。",
sort_order=10,
),
ParamSpec(
@@ -183,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="60",
value_min="0",
value_max="100",
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。",
sort_order=11,
),
ParamSpec(
@@ -197,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="FULL",
value_min=None,
value_max=None,
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。",
sort_order=12,
),
ParamSpec(
@@ -211,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="30",
value_min="0",
value_max="100",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。",
sort_order=13,
),
ParamSpec(
@@ -225,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="INCLUDE",
value_min=None,
value_max=None,
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。",
sort_order=14,
),
ParamSpec(
@@ -239,22 +243,108 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="18000",
value_min="0",
value_max=None,
description="试用期年累计绩效超过此值部分年底一次性结算",
description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。",
sort_order=15,
),
ParamSpec(
code="probation.salary.basic",
name="试用期基本工资标准",
group="月工资口径",
value_type="DECIMAL",
unit="元",
code="approval.secondary_allocation.enabled",
name="二次分配审批开关",
group="审批开关",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="3000",
value_min="0",
default_value="1",
value_min=None,
value_max=None,
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
sort_order=16,
description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。",
sort_order=17,
),
ParamSpec(
code="approval.monthly_settlement.enabled",
name="月度结算审批开关",
group="审批开关",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="1",
value_min=None,
value_max=None,
description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。",
sort_order=18,
),
ParamSpec(
code="approval.adjustment.enabled",
name="调整单审批开关",
group="审批开关",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="0",
value_min=None,
value_max=None,
description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。",
sort_order=19,
),
ParamSpec(
code="manager.personal.included",
name="经理个人户计入聚合基数",
group="经理分成",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="0",
value_min=None,
value_max=None,
description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。",
sort_order=20,
),
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
# 首批三条中「同步连续失败」依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目
ParamSpec(
code="warning.enabled",
name="风险预警总开关",
group="预警规则",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="1",
value_min=None,
value_max=None,
description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。",
sort_order=21,
),
ParamSpec(
code="warning.receipt.overdue.days",
name="回款逾期天数阈值",
group="预警规则",
value_type="INT",
unit="天",
dimension_type="NONE",
options=None,
default_value="90",
value_min="1",
value_max=None,
description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。",
sort_order=22,
),
ParamSpec(
code="warning.approval.timeout.days",
name="审批超时天数阈值",
group="预警规则",
value_type="INT",
unit="天",
dimension_type="NONE",
options=None,
default_value="3",
value_min="1",
value_max=None,
description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。",
sort_order=23,
),
]
}
+160 -87
View File
@@ -6,7 +6,7 @@ ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日
"""
import logging
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
@@ -44,7 +44,7 @@ from app.schemas.param import (
ParamPreviewOut,
ParamUsedOut,
)
from app.services import audit_service, formula
from app.services import audit_service, calc_templates, formula
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import to_decimal
from app.services.no_generator import next_no
@@ -379,16 +379,8 @@ def get_history(
definition = param_repository.get_definition_by_code(session, param_code)
if definition is None:
raise NotFoundError("参数不存在")
rows = param_repository.list_history(
session,
definition.id,
offset=query.offset,
limit=query.page_size,
dimension_value=query.dimension_value,
)
total = param_repository.count_history(
session, definition.id, dimension_value=query.dimension_value
)
rows = param_repository.list_history(session, definition.id, query)
total = param_repository.count_history(session, definition.id, query)
return PageResult[ParamHistoryOut](
records=[
ParamHistoryOut(
@@ -410,16 +402,8 @@ def get_history(
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
"""变更批次分页(按创建时间倒序)。"""
batches = param_repository.list_batches(
session,
offset=query.offset,
limit=query.page_size,
start_date=query.start_date,
end_date=query.end_date,
)
total = param_repository.count_batches(
session, start_date=query.start_date, end_date=query.end_date
)
batches = param_repository.list_batches(session, query)
total = param_repository.count_batches(session, query)
return PageResult[ParamBatchOut](
records=[
ParamBatchOut(
@@ -509,42 +493,89 @@ def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDi
return out
def _preview_rate(
session: Session,
payload: ParamPreviewIn,
snapshot: ParamSnapshot,
days: int,
take,
) -> tuple[Fraction, str, Decimal | None]:
@dataclass(frozen=True, slots=True)
class _PreviewContext:
"""预览上下文(会话、入参、参数取值闭包)。
``take`` 闭包同时负责取数与记录「用了哪些参数」——**调用顺序即
``params_used`` 的展示顺序**,拆分时不得调换各段的先后。
"""
session: Session
payload: ParamPreviewIn
take: Callable[[str], Fraction]
@dataclass(frozen=True, slots=True)
class _PreviewRateDecision:
"""计提比例判定(回款天数、纯比例、人读说明、绩效基数覆盖值)。"""
days: int
rate: Fraction
rate_text: str
base_override: Decimal | None
@dataclass(frozen=True, slots=True)
class _PreviewEarning:
"""折算与绩效(毛利润率、折算额、绩效额)。"""
profit_rate: Fraction
converted: Decimal
performance: Decimal
@dataclass(frozen=True, slots=True)
class _PreviewBarter:
"""以物抵顶(是否适用、折算后绩效、计发基数)。"""
applied: bool
after_barter: Decimal | None
base_amount: Decimal
@dataclass(frozen=True, slots=True)
class _PreviewPayment:
"""支付环节(起付判定、R 权重、权重、应付金额)。"""
start_ok: bool
weight_r: int
weight: Fraction
payable: Decimal
def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decimal | None]:
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
"""
payload = ctx.payload
take = ctx.take
if payload.customer_type == "enterprise":
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
rate = take("enterprise.rate") / 1000
return rate, "企业户固定计提比例", None
return rate, "企业户:不按回款天数,统一按固定比例计提", None
role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY"
if role_code:
role = business_role_repository.get_by_code(session, role_code)
role = business_role_repository.get_by_code(ctx.session, role_code)
if role is None:
raise BadRequestError(f"岗位 {role_code!r} 不存在")
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
template = role.calc_template
if template == "TPL_MANAGER_SHARE":
raise BadRequestError(
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
"营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
)
if template == "TPL_INFO_SALES_FIXED":
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
rate = take("info.rate") / 1000
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY":
start = take("salesperson.rate.start")
slope = take("salesperson.rate.slope")
@@ -553,12 +584,84 @@ def _preview_rate(
rate = formula.salesperson_rate(
days, start=start, slope=slope, floor=floor, boundary_days=boundary
)
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
return (
rate,
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
None,
)
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
# 提示里给模板中文名而不是编码:编码是内部标识,业务人员在岗位角色页看不到它
tpl_name = calc_templates.TEMPLATES[template].name if template in calc_templates.TEMPLATES else template
raise BadRequestError(
f"「{tpl_name}」不支持单笔试算,请在岗位角色里改用业务员递减或信息员固定计提"
)
def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision:
"""产品类别校验 + 回款天数 + 计提比例判定。"""
payload = ctx.payload
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
# 提示里用中文类别名:前端下拉显示的是「市政水利」,报错却回英文编码会让人对不上
raise BadRequestError(
f"产品类别「{payload.product_category}」不存在,可选:市政水利、轨道交通"
)
days = formula.payment_days(payload.ship_date, payload.receipt_date)
rate, rate_text, base_override = _preview_rate(ctx, days)
return _PreviewRateDecision(
days=days, rate=rate, rate_text=rate_text, base_override=base_override
)
def _preview_earning(ctx: _PreviewContext, decision: _PreviewRateDecision) -> _PreviewEarning:
"""毛利润率 → 折算额(阈值)→ 绩效额(比例)。"""
payload = ctx.payload
revenue = Decimal(payload.revenue)
profit = Decimal(payload.profit)
profit_rate = formula.profit_rate(profit, revenue)
if decision.base_override is not None:
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
converted = decision.base_override
else:
threshold = ctx.take("product.threshold")
converted = formula.converted_profit(revenue, profit_rate, threshold)
performance = formula.performance_amount(converted, decision.rate)
return _PreviewEarning(profit_rate=profit_rate, converted=converted, performance=performance)
def _preview_barter(ctx: _PreviewContext, performance: Decimal) -> _PreviewBarter:
"""以物抵顶折算(按应发绩效的固定比例折算,比例来自参数)。"""
applied = ctx.payload.barter
after_barter: Decimal | None = None
if applied:
discount = ctx.take("barter.discount")
after_barter = formula.apply_barter(performance, discount)
base_amount = after_barter if applied else performance
return _PreviewBarter(applied=applied, after_barter=after_barter, base_amount=base_amount)
def _preview_payment(
ctx: _PreviewContext, received: Decimal, revenue: Decimal, base_amount: Decimal
) -> _PreviewPayment:
"""起付比例判定 + R 权重 + 应付金额。"""
start_ratio = ctx.take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
weight_r = formula.clamp_weight_r(received, revenue)
weight = formula.payment_weight(weight_r)
payable = to_decimal(Fraction(base_amount) * weight)
return _PreviewPayment(start_ok=start_ok, weight_r=weight_r, weight=weight, payable=payable)
def _preview_probation_note(ctx: _PreviewContext) -> str | None:
"""试用期说明(绩效照算不发放,年累计超阈值的部分年底一次性结算)。"""
if not ctx.payload.probation:
return None
annual = ctx.take("probation.annual.threshold")
return (
f"试用期人员:绩效照常计算但不发放;年内累计超过 {to_decimal(annual)} 元的部分,"
"年底一次性结清。"
)
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
@@ -566,7 +669,6 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
# 参数按回款到账日取值(对照表 #11)
snapshot = resolve_snapshot(session, payload.receipt_date)
revenue = Decimal(payload.revenue)
profit = Decimal(payload.profit)
received = Decimal(payload.received_amount)
used: list[ParamUsedOut] = []
@@ -589,58 +691,29 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
)
return parse_fraction(raw)
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
raise BadRequestError(
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
)
days = formula.payment_days(payload.ship_date, payload.receipt_date)
rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take)
profit_rate = formula.profit_rate(profit, revenue)
if base_override is not None:
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
converted = base_override
else:
threshold = take("product.threshold")
converted = formula.converted_profit(revenue, profit_rate, threshold)
performance = formula.performance_amount(converted, rate)
barter_applied = payload.barter
after_barter: Decimal | None = None
if barter_applied:
discount = take("barter.discount")
after_barter = formula.apply_barter(performance, discount)
start_ratio = take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
# R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int()
# 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线
weight_r = (
max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0
)
weight = formula.payment_weight(weight_r)
base_amount = after_barter if barter_applied else performance
payable = to_decimal(Fraction(base_amount) * weight)
probation_note: str | None = None
if payload.probation:
annual = take("probation.annual.threshold")
probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
ctx = _PreviewContext(session=session, payload=payload, take=take)
# 各段顺序固定 —— take 的调用顺序即 params_used 的展示顺序
decision = _preview_rate_decision(ctx)
earning = _preview_earning(ctx, decision)
barter = _preview_barter(ctx, earning.performance)
payment = _preview_payment(ctx, received, revenue, barter.base_amount)
probation_note = _preview_probation_note(ctx)
return ParamPreviewOut(
payment_days=days,
rate=str(rate),
rate_text=rate_text,
profit_rate=str(profit_rate),
converted_profit=str(converted),
performance_amount=str(performance),
barter_applied=barter_applied,
performance_after_barter=str(after_barter) if after_barter is not None else None,
start_payment_ok=start_ok,
payment_weight_r=weight_r,
payment_weight=str(weight),
payable_amount=str(payable),
payment_days=decision.days,
rate=str(decision.rate),
rate_text=decision.rate_text,
profit_rate=str(earning.profit_rate),
converted_profit=str(earning.converted),
performance_amount=str(earning.performance),
barter_applied=barter.applied,
performance_after_barter=(
str(barter.after_barter) if barter.after_barter is not None else None
),
start_payment_ok=payment.start_ok,
payment_weight_r=payment.weight_r,
payment_weight=str(payment.weight),
payable_amount=str(payment.payable),
probation_note=probation_note,
params_used=used,
)
+2 -14
View File
@@ -70,20 +70,8 @@ def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personn
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
"""分页查询人员(含离职)。"""
rows = personnel_repository.list_personnel(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
business_role_id=query.business_role_id,
)
total = personnel_repository.count_personnel(
session,
keyword=query.keyword,
status=query.status,
business_role_id=query.business_role_id,
)
rows = personnel_repository.list_personnel(session, query)
total = personnel_repository.count_personnel(session, query)
return PageResult[PersonnelOut](
records=[
_to_out(personnel, role_name, manager_name)
+2 -14
View File
@@ -99,20 +99,8 @@ def _raise_if_duplicate(exc: IntegrityError) -> None:
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
"""分页查询产品(含停用)。"""
products = product_repository.list_products(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
product_category=query.product_category,
status=query.status,
)
total = product_repository.count_products(
session,
keyword=query.keyword,
product_category=query.product_category,
status=query.status,
)
products = product_repository.list_products(session, query)
total = product_repository.count_products(session, query)
return PageResult[ProductOut](
records=[_to_out(product) for product in products],
total=total,
+4 -1
View File
@@ -217,7 +217,10 @@ def update_grade(
raise NotFoundError("评级等级不存在")
_ensure_no_overlap(
rating_repository.list_all(session, for_update=True), new_min, new_max, exclude_id=grade_id
rating_repository.list_all(session, for_update=True),
new_min,
new_max,
exclude_id=grade_id,
)
existed = rating_repository.get_by_name(session, payload.grade_name)
if existed is not None and existed.id != grade_id:
+70 -45
View File
@@ -23,6 +23,8 @@
"""
import logging
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from sqlalchemy.exc import IntegrityError
@@ -38,7 +40,7 @@ from app.core.constants import (
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import Receipt
from app.models import Business, Receipt
from app.models.business import BUSINESS_STATUS_ACTIVE
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
from app.repositories import business_repository, receipt_repository
@@ -128,24 +130,8 @@ def _ensure_business_writable(session: Session, business_id: int, action: str):
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
rows = receipt_repository.list_receipts(
session,
offset=query.offset,
limit=query.page_size,
business_id=query.business_id,
date_from=query.date_from,
date_to=query.date_to,
is_barter=query.is_barter,
settlement_status=query.settlement_status,
)
total = receipt_repository.count_receipts(
session,
business_id=query.business_id,
date_from=query.date_from,
date_to=query.date_to,
is_barter=query.is_barter,
settlement_status=query.settlement_status,
)
rows = receipt_repository.list_receipts(session, query)
total = receipt_repository.count_receipts(session, query)
return PageResult[ReceiptOut](
records=[_to_out(row) for row in rows],
total=total,
@@ -165,6 +151,57 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
return _to_out(row)
@dataclass(frozen=True, slots=True)
class _CreatedReceipt:
"""建行结果(出参 + 固化的 ID/批次号,commit 后不再触碰 ORM 属性)。"""
out: ReceiptOut
receipt_id: int
batch_no: str
def _create_receipt_once(
session: Session,
payload: ReceiptCreateIn,
business: Business | None,
batch_no: str,
) -> _CreatedReceipt:
"""一次建行尝试:准入校验 → 建行 → 重算合计 → 出参。
**不 commit**:提交与回滚由调用方(重试循环)统一控制,保证「一次尝试
= 一个事务边界」。
"""
if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新增回款")
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
# 一期安全侧(策略点注释);未指定日期时以当天月近似
from app.services import settlement_service
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
settlement_service.ensure_month_not_locked(session, _lock_month)
shipment_no, ship_date = _ensure_shipment_of_business(
session, payload.shipment_id, payload.business_id
)
receipt = receipt_repository.create(
session,
Receipt(
receipt_batch_no=batch_no,
business_id=payload.business_id,
shipment_id=payload.shipment_id,
receipt_date=payload.receipt_date,
amount=Decimal(payload.amount),
is_barter=payload.is_barter,
settlement_status=SETTLEMENT_PENDING,
remark=payload.remark,
),
)
_recalc_totals(session, business)
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
return _CreatedReceipt(out=out, receipt_id=receipt.id, batch_no=batch_no)
def create_receipt(
session: Session,
payload: ReceiptCreateIn,
@@ -205,29 +242,7 @@ def create_receipt(
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
for attempt in range(_NO_RETRY + 1):
try:
if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新增回款")
shipment_no, ship_date = _ensure_shipment_of_business(
session, payload.shipment_id, payload.business_id
)
receipt = receipt_repository.create(
session,
Receipt(
receipt_batch_no=batch_no,
business_id=payload.business_id,
shipment_id=payload.shipment_id,
receipt_date=payload.receipt_date,
amount=Decimal(payload.amount),
is_barter=payload.is_barter,
settlement_status=SETTLEMENT_PENDING,
remark=payload.remark,
),
)
_recalc_totals(session, business)
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM
created = _create_receipt_once(session, payload, business, batch_no)
session.commit()
break
except IntegrityError as exc:
@@ -242,11 +257,11 @@ def create_receipt(
logger.info(
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
new_id,
new_no,
created.receipt_id,
created.batch_no,
payload.business_id,
)
return out
return created.out
def update_receipt(
@@ -284,6 +299,12 @@ def update_receipt(
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止修改")
business = _ensure_business_writable(session, receipt.business_id, "修改")
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
settlement_service.ensure_month_not_locked(session, payload.receipt_date.strftime("%Y-%m"))
# 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
shipment_no, ship_date = _ensure_shipment_of_business(
@@ -335,6 +356,10 @@ def delete_receipt(
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止删除")
business = _ensure_business_writable(session, receipt.business_id, "删除")
# 月结锁定拦截:已锁定月份的回款是月结快照的源数据,不得删除
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
batch_no = receipt.receipt_batch_no
receipt_repository.delete(session, receipt)
+658
View File
@@ -0,0 +1,658 @@
"""报表 Excel 模板构建(纯函数,可单测)。
版式(FR-032「导出模板围绕财务复核和领导签字设计」):
公司信息页眉 → 报表标题 → 筛选条件 → 导出时间/人/模板版本 → 表头 → 数据行
→ 合计行 → 财务复核栏 / 领导签字栏 / 日期栏。
精度红线:金额写 ``Decimal``(openpyxl 原生支持,不经 float)+
``number_format='#,##0.00'``;合计在 Python 侧求和后两位收口,**不写
Excel 公式**(避免打开时重算漂移与公式注入面)。文本列以 ``= + - @``
开头时前置单引号,防公式注入。
"""
from dataclasses import dataclass
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from app.core.report_meta import (
BUSINESS_STATUS_LABELS,
CUSTOMER_TYPE_LABELS,
PRODUCT_CATEGORY_LABELS,
)
_HEADER_FILL = PatternFill("solid", fgColor="DDE3F0")
_THIN = Side(style="thin", color="B0B7C3")
_BORDER = Border(left=_THIN, right=_THIN, top=_THIN, bottom=_THIN)
_TITLE_FONT = Font(size=14, bold=True)
_COMPANY_FONT = Font(size=16, bold=True)
_HEADER_FONT = Font(bold=True)
_AMOUNT_FORMAT = "#,##0.00"
_DATE_FORMAT = "YYYY-MM-DD"
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
@dataclass(frozen=True, slots=True)
class _Column:
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
key: str
title: str
kind: str # text / amount / int / date / customer / category / status
width: float
@dataclass(frozen=True, slots=True)
class _Block:
"""签字稿中的一个区块:独立表头、数据行与可选合计行。"""
title: str
columns: tuple[_Column, ...]
rows: list[dict]
total: dict | None = None
empty_text: str = "(无)"
_COLUMNS: dict[str, tuple[_Column, ...]] = {
"personal-detail": (
_Column("business_no", "合同编号", "text", 20),
_Column("ship_date", "发货日", "date", 12),
_Column("receipt_date", "回款日", "date", 12),
_Column("payment_days", "回款天数", "int", 10),
_Column("product_category", "产品类别", "category", 14),
_Column("customer_type", "客户类型", "customer", 14),
_Column("role_name", "岗位", "text", 12),
_Column("rate_text", "计提比例", "text", 14),
_Column("weight_r", "支付权重R", "int", 10),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
),
"department-summary": (
_Column("manager_name", "经理团队", "text", 16),
_Column("member_name", "成员", "text", 14),
_Column("role_code", "岗位编码", "text", 12),
_Column("performance_sum", "绩效金额", "amount", 14),
_Column("payable_sum", "应付绩效", "amount", 14),
_Column("adjust_net", "调整净额", "amount", 14),
_Column("net_total", "净合计", "amount", 14),
),
"business-stats": (
_Column("business_no", "业务编号", "text", 20),
_Column("business_date", "业务日期", "date", 12),
_Column("customer_name", "客户", "text", 22),
_Column("owner_personnel_name", "归属人", "text", 12),
_Column("status", "状态", "status", 10),
_Column("total_sale_amount", "销售额", "amount", 14),
_Column("total_ship_amount", "发货额", "amount", 14),
_Column("total_received_amount", "回款额", "amount", 14),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
),
"payment-stats": (
_Column("label", "回款天数区间", "text", 20),
_Column("receipt_amount", "回款金额", "amount", 16),
_Column("receipt_count", "回款笔数", "int", 10),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
_Column("ledger_count", "台账行数", "int", 10),
),
}
def build_workbook(
*,
code: str,
title: str,
filters: str,
rows: list[dict],
summary: dict | None,
buckets: dict | None,
template_version: str,
operator_name: str,
company_name: str,
department: dict | None = None,
) -> bytes:
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。
部门汇总是签字对账文件,走单 sheet 分块版式;其余报表仍是单表 +
合计行。`department` 为 `department_summary` 的完整出参。
"""
workbook = Workbook()
sheet = workbook.active
sheet.title = _sheet_name(code)
if code == "department-summary" and department is not None:
_write_department_document(
sheet,
department,
company_name=company_name,
title=title,
filters=filters,
template_version=template_version,
operator_name=operator_name,
)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
columns = _COLUMNS[code]
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
if rows:
for row in rows:
_write_row(sheet, columns, row)
else:
sheet.append(["本筛选条件无数据"])
_merge_row(sheet, len(columns))
_write_total(sheet, columns, rows)
_write_signature(sheet, len(columns))
_autofit(sheet, columns)
# 回款统计附维度对比 sheet(客户类型/产品类别),与区间表共用页眉格式
if code == "payment-stats" and buckets:
for dim_key, dim_title in (
("by_customer_type", "客户类型对比"),
("by_product_category", "产品类别对比"),
):
extra = workbook.create_sheet(dim_title)
_write_dimension_sheet(
extra,
dim_title,
company_name,
filters,
buckets.get(dim_key) or [],
template_version,
operator_name,
)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def _sheet_name(code: str) -> str:
return {
"personal-detail": "个人绩效明细",
"department-summary": "部门绩效汇总",
"business-stats": "业务统计",
"payment-stats": "回款统计",
}[code]
def _write_header(
sheet,
columns: tuple[_Column, ...],
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""页眉四行 + 表头行;返回后 sheet.max_row 即数据行起点。"""
_write_document_header(
sheet,
len(columns),
company_name,
title,
filters,
template_version,
operator_name,
)
sheet.append([column.title for column in columns])
header_row = sheet.max_row
for cell in sheet[header_row]:
cell.font = _HEADER_FONT
cell.fill = _HEADER_FILL
cell.border = _BORDER
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = sheet.cell(row=header_row + 1, column=1)
def _write_document_header(
sheet,
column_count: int,
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""公司名/标题/筛选条件/导出信息四行(分块文档与单表共用)。"""
sheet.append([company_name])
_merge_row(sheet, column_count)
sheet.cell(row=1, column=1).font = _COMPANY_FONT
sheet.cell(row=1, column=1).alignment = Alignment(horizontal="left", vertical="center")
sheet.append([title])
_merge_row(sheet, column_count)
sheet.cell(row=2, column=1).font = _TITLE_FONT
sheet.cell(row=2, column=1).alignment = Alignment(horizontal="center", vertical="center")
sheet.append([f"筛选条件:{filters}"])
_merge_row(sheet, column_count)
sheet.append(
[
f"导出时间:{datetime.now():%Y-%m-%d %H:%M:%S} | 导出人:{operator_name} | "
f"模板版本:{template_version}"
]
)
_merge_row(sheet, column_count)
sheet.append([])
def _merge_row(sheet, column_count: int) -> None:
"""合并当前行(公司名/标题/筛选行跨全表宽)。"""
row = sheet.max_row
if column_count > 1:
sheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=column_count)
def _write_row(sheet, columns: tuple[_Column, ...], row: dict) -> None:
"""数据行:按列类型写单元格(金额 Decimal、日期 date、文本防注入)。"""
values = []
for column in columns:
raw = row.get(column.key)
values.append(_cell_value(column, raw))
sheet.append(values)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
elif column.kind == "date":
cell.number_format = _DATE_FORMAT
def _cell_value(column: _Column, raw):
"""单值转换:标签列转中文,文本列防公式注入。"""
if raw is None:
return None
if column.kind == "amount":
return raw if isinstance(raw, Decimal) else Decimal(str(raw))
if column.kind == "date":
return raw
if column.kind == "int":
return int(raw)
if column.kind == "customer":
return CUSTOMER_TYPE_LABELS.get(str(raw), str(raw))
if column.kind == "category":
return PRODUCT_CATEGORY_LABELS.get(str(raw), str(raw))
if column.kind == "status":
return BUSINESS_STATUS_LABELS.get(int(raw), str(raw))
return _safe_text(raw)
def _safe_text(value) -> str:
"""文本防公式注入:以 = + - @ 或制表符开头时前置单引号。"""
text = str(value)
return "'" + text if text.startswith(_FORMULA_PREFIXES) else text
def _write_total(sheet, columns: tuple[_Column, ...], rows: list[dict]) -> None:
"""合计行:金额列按行求和后两位收口(不写 Excel 公式)。
「合计」标签只写在第一个非金额列;其余非金额列留空——否则每遇到
一个文本列都会重复写一次标签,合计行会满屏「合计」。
"""
if not rows:
return
totals = []
label_written = False
for column in columns:
if column.kind != "amount":
totals.append("合计" if not label_written else None)
label_written = True
continue
total = sum(
(
row.get(column.key)
for row in rows
if isinstance(row.get(column.key), (Decimal, int))
),
Decimal("0"),
)
totals.append(total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
sheet.append(totals)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.font = _HEADER_FONT
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
def _write_signature(sheet, column_count: int) -> None:
"""财务复核栏 / 领导签字栏 / 日期栏(FR-032 模板要素)。"""
sheet.append([])
sheet.append(["财务复核:________________", "", "日期:______年__月__日"])
sheet.append(["领导签字:________________", "", "日期:______年__月__日"])
for row_index in (sheet.max_row - 1, sheet.max_row):
sheet.cell(row=row_index, column=1).alignment = Alignment(horizontal="left")
def _autofit(sheet, columns: tuple[_Column, ...]) -> None:
"""列宽按定义表设置(CJK 按 2 字符宽,不用默认 8.43)。"""
for index, column in enumerate(columns, start=1):
sheet.column_dimensions[get_column_letter(index)].width = column.width
def _write_dimension_sheet(
sheet,
title: str,
company_name: str,
filters: str,
rows: list[dict],
template_version: str,
operator_name: str,
) -> None:
"""维度对比 sheet(客户类型/产品类别),页眉筛选条件与主表一致。"""
columns = (
_Column("label", "维度", "text", 16),
_Column("receipt_amount", "回款金额", "amount", 16),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
_Column("ledger_count", "台账行数", "int", 10),
)
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
for row in rows:
_write_row(sheet, columns, row)
_write_total(sheet, columns, rows)
_write_signature(sheet, len(columns))
_autofit(sheet, columns)
# ==== 部门汇总签字稿(FR-030 / FR-032)====
_MEMBER_KIND_LABELS = {
"regular": "普通绩效",
"manager_personal": "经理个人户",
"adjustment": "仅调整单",
}
# 各块列数不同,统一按列号取最大宽度(签字稿打印时不挤在一起)
_DEPARTMENT_WIDTHS = (30, 16, 22, 13, 13, 15, 18, 14, 14, 13)
def _write_department_document(
sheet,
data: dict,
*,
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""部门汇总签字稿:团队分成 → 全公司 5% → 对账 → 成员 → 未分配 → 签字栏。
全公司 5% 每位有月结聚合行的经理各计一次,合计在第二块展示;
团队块的调整净额仅供成员汇总核对。应发合计由后端 `reconciliation`
按聚合行口径算好(含分列舍入差),本函数只渲染。
"""
params = data.get("params") or {}
team_text = params.get("team_text", "")
company_text = params.get("company_text", "")
teams = data.get("teams") or []
unassigned = data.get("unassigned") or []
company = data.get("company") or {}
reconciliation = data.get("reconciliation") or {}
company_share_count = int(company.get("company_share_count") or 0)
_write_document_header(
sheet,
len(_DEPARTMENT_WIDTHS),
company_name,
title,
filters,
template_version,
operator_name,
)
_write_block(
sheet,
_Block(
"一、团队分成汇总",
(
_Column("seq", "序号", "int", 6),
_Column("manager_name", "经理团队", "text", 16),
_Column("manager_status_text", "状态", "text", 8),
_Column("member_count", "团队人数", "int", 9),
_Column("team_base", "团队基数", "amount", 13),
_Column("team_share_amount", f"团队分成({team_text}%)", "amount", 15),
_Column("adjust_net", "调整净额(仅核对)", "amount", 16),
_Column("team_subtotal", "团队应发小计(含聚合分成)", "amount", 18),
_Column("frozen_amount", "月结冻结额", "amount", 14),
),
[
{
"seq": index,
"manager_name": team["manager_name"],
"manager_status_text": "在职" if team["manager_status"] == 1 else "离职",
"member_count": team["member_count"],
"team_base": team["team_base"],
"team_share_amount": team["team_share_amount"],
"adjust_net": team["adjust_net"],
"team_subtotal": team["team_subtotal"],
"frozen_amount": team["frozen_amount"],
}
for index, team in enumerate(teams, start=1)
],
{
"manager_name": "合计",
"member_count": sum(team["member_count"] for team in teams),
"team_base": _sum_amount(team["team_base"] for team in teams),
"team_share_amount": _sum_amount(team["team_share_amount"] for team in teams),
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
"frozen_amount": (
_sum_amount(
team["frozen_amount"] for team in teams if team["frozen_amount"] is not None
)
if any(team["frozen_amount"] is not None for team in teams)
else None
),
},
"(无团队)",
),
)
_write_block(
sheet,
_Block(
f"二、全公司分成(每位有聚合行的经理各计一次,共 {company_share_count} 次)",
(
_Column("company_base", "全公司基数", "amount", 16),
_Column("company_share_pct", "分成比例", "text", 12),
_Column("company_share_amount", "单经理分成", "amount", 13),
_Column("company_share_count", "计入次数", "int", 10),
_Column("company_share_total", "分成合计", "amount", 14),
),
[
{
"company_base": company.get("company_base"),
"company_share_pct": f"{company_text}%",
"company_share_amount": company.get("company_share_amount"),
"company_share_count": company_share_count,
"company_share_total": company.get("company_share_total"),
}
],
),
)
_write_block(
sheet,
_Block(
"三、对账汇总",
(
_Column("item", "项目", "text", 30),
_Column("amount", "金额", "amount", 16),
_Column("note", "说明", "text", 40),
),
[
{
"item": "团队成员应付合计",
"amount": reconciliation.get("team_member_payable_total"),
},
{
"item": "未分配人员应付合计",
"amount": reconciliation.get("unassigned_payable_total"),
},
{"item": "团队分成合计(分列)", "amount": reconciliation.get("team_share_total")},
{
"item": f"全公司分成合计({company_share_count} 次,分列)",
"amount": reconciliation.get("company_share_total"),
"note": "每位有聚合行的经理各计一次",
},
{
"item": "聚合行舍入差",
"amount": reconciliation.get("aggregate_rounding_diff"),
"note": "分列舍入与聚合行一次舍入的差",
},
{
"item": "调整净额合计(明细层)",
"amount": reconciliation.get("adjust_net_total"),
},
{
"item": "应发合计",
"amount": reconciliation.get("payable_total"),
"note": "上列各项之和",
},
{"item": "团队基数合计", "amount": reconciliation.get("team_base_total")},
{
"item": "未分配绩效合计",
"amount": reconciliation.get("unassigned_performance_total"),
},
{
"item": "其他未展示基数",
"amount": reconciliation.get("other_team_base"),
"note": "筛选时含其他团队与全公司未分配",
},
{"item": "全公司基数", "amount": reconciliation.get("company_base")},
],
),
)
member_columns = (
_Column("manager_name", "经理团队", "text", 15),
_Column("personnel_name", "成员", "text", 12),
_Column("role_code", "岗位", "text", 10),
_Column("member_kind_text", "来源", "text", 13),
_Column("performance_sum", "绩效金额", "amount", 13),
_Column("payable_sum", "应付绩效", "amount", 13),
_Column("adjust_extra", "调整补发", "amount", 12),
_Column("adjust_deduct", "调整扣减", "amount", 12),
_Column("adjust_net", "调整净额", "amount", 12),
_Column("net_total", "净合计", "amount", 13),
)
member_rows = [
{
"manager_name": team["manager_name"],
"personnel_name": member["personnel_name"],
"role_code": member["role_code"],
"member_kind_text": _MEMBER_KIND_LABELS.get(
member["member_kind"], member["member_kind"]
),
"performance_sum": member["performance_sum"],
"payable_sum": member["payable_sum"],
"adjust_extra": member["adjust_extra"],
"adjust_deduct": member["adjust_deduct"],
"adjust_net": member["adjust_net"],
"net_total": member["net_total"],
}
for team in teams
for member in team["members"]
]
_write_block(
sheet,
_Block("四、团队成员明细", member_columns, member_rows, empty_text="(无成员)"),
sum_amounts=True,
)
unassigned_columns = (
_Column("personnel_name", "人员", "text", 14),
_Column("role_code", "岗位", "text", 10),
_Column("unassigned_reason", "未纳入原因", "text", 24),
_Column("performance_sum", "绩效金额", "amount", 13),
_Column("payable_sum", "应付绩效", "amount", 13),
_Column("adjust_extra", "调整补发", "amount", 12),
_Column("adjust_deduct", "调整扣减", "amount", 12),
_Column("adjust_net", "调整净额", "amount", 12),
_Column("net_total", "净合计", "amount", 13),
)
unassigned_rows = [
{
"personnel_name": row["personnel_name"],
"role_code": row["role_code"],
"unassigned_reason": row.get("unassigned_reason") or "",
"performance_sum": row["performance_sum"],
"payable_sum": row["payable_sum"],
"adjust_extra": row["adjust_extra"],
"adjust_deduct": row["adjust_deduct"],
"adjust_net": row["adjust_net"],
"net_total": row["net_total"],
}
for row in unassigned
]
_write_block(
sheet,
_Block("五、未分配人员明细", unassigned_columns, unassigned_rows),
sum_amounts=True,
)
_write_signature(sheet, len(_DEPARTMENT_WIDTHS))
for index, width in enumerate(_DEPARTMENT_WIDTHS, start=1):
sheet.column_dimensions[get_column_letter(index)].width = width
def _write_block(sheet, block: _Block, *, sum_amounts: bool = False) -> None:
"""写一个区块:标题、表头、数据/空提示,以及自定义或自动合计。"""
sheet.append([block.title])
title_row = sheet.max_row
_merge_row(sheet, len(block.columns))
sheet.cell(row=title_row, column=1).font = _HEADER_FONT
sheet.append([column.title for column in block.columns])
header_row = sheet.max_row
for cell in sheet[header_row]:
cell.font = _HEADER_FONT
cell.fill = _HEADER_FILL
cell.border = _BORDER
cell.alignment = Alignment(horizontal="center", vertical="center")
if block.rows:
for row in block.rows:
_write_row(sheet, block.columns, row)
else:
sheet.append([block.empty_text])
_merge_row(sheet, len(block.columns))
if block.total is not None:
_write_custom_total(sheet, block.columns, block.total)
elif sum_amounts:
_write_total(sheet, block.columns, block.rows)
sheet.append([])
def _write_custom_total(sheet, columns: tuple[_Column, ...], total: dict) -> None:
"""写后端预计算的合计行(不自动求和,避免重复计入共享基数)。"""
values = []
for column in columns:
raw = total.get(column.key)
values.append(_cell_value(column, raw) if raw is not None else None)
sheet.append(values)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.font = _HEADER_FONT
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
def _sum_amount(values) -> Decimal:
"""金额列求和后两位收口(None 跳过)。"""
return sum((value for value in values if value is not None), Decimal("0")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
File diff suppressed because it is too large. Load diff
+20 -23
View File
@@ -1,17 +1,20 @@
"""角色管理业务编排。
需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善**
(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置,
待业务方给出权限清单后再启用)。
需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求
新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批
节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` /
`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖
该编码存在,删除会使系统无法自举
- **内置角色禁删禁停**(`BUILTIN_ROLE_CODES`:admin / marketing_ops /
finance / leader)—— `admin` 被删除会使系统无法自举(cli 建管理员与
`require_admin` 判定依赖);财务/领导是审批节点准入锚点,被删会让
在途审批流失去可操作人
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
锁行,堵住「校验与删除之间并发绑定」的窗口
- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点
- `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
"""
@@ -27,8 +30,8 @@ from app.core.constants import (
ACTION_DISABLE,
ACTION_ENABLE,
ACTION_UPDATE,
BUILTIN_ROLE_CODES,
MODULE_ROLE,
ROLE_ADMIN,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
@@ -65,14 +68,8 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
下拉只给启用项。
"""
roles = role_repository.list_roles(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = role_repository.count_roles(session, keyword=query.keyword, status=query.status)
roles = role_repository.list_roles(session, query)
total = role_repository.count_roles(session, query)
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
return PageResult[RoleManageOut](
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
@@ -177,10 +174,10 @@ def set_status(
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
内置管理员角色不可停用。
内置角色不可停用。
:raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图停用内置管理员角色
:raises ForbiddenError: 试图停用内置角色
"""
role = role_repository.get_by_id(session, role_id)
verb = "启用" if status == 1 else "禁用"
@@ -198,8 +195,8 @@ def set_status(
if role is None:
raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN and status == 0:
raise ForbiddenError("内置管理员角色不可停用")
if role.role_code in BUILTIN_ROLE_CODES and status == 0:
raise ForbiddenError("内置角色不可停用")
role.status = status
out = _to_out(role, _user_count(session, role_id))
@@ -217,12 +214,12 @@ def delete_role(
) -> None:
"""删除角色。
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。
内置角色不可删除(admin 与三个审批职能角色,见 BUILTIN_ROLE_CODES);仍有关联用户的角色不可删除(提示先解除绑定)。
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
:raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图删除内置管理员角色
:raises ForbiddenError: 试图删除内置角色
:raises BadRequestError: 角色下仍有绑定的用户
"""
role = role_repository.get_by_id(session, role_id, for_update=True)
@@ -239,8 +236,8 @@ def delete_role(
if role is None:
raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN:
raise ForbiddenError("内置管理员角色不可删除")
if role.role_code in BUILTIN_ROLE_CODES:
raise ForbiddenError("内置角色不可删除")
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
user_count = _user_count(session, role_id)
+637
View File
@@ -0,0 +1,637 @@
"""月度结算服务(M3 ④):生成快照 → 提交审批 → 通过即锁定。
确认回款口径(澄清 #8 策略点,2026-10-16 暂定):settlement_status=1 的
**有效台账行**(is_active=1、settlement_id IS NULL=未被任何月结固化),
按 settlement_month(回款日所在月)归集。答复后只改 ``_confirm_scope``。
通过即锁定(status=3 终态):锁定后源数据(回款/发货)写路径由
``ensure_month_not_locked`` 拦截;调整走调整单(T6)。
"""
import logging
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
MODULE_SETTLEMENT,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import (
MonthlySettlement,
Personnel,
SettlementItem,
User,
)
from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT
from app.models.performance_ledger import (
CALC_TYPE_MANAGER_AGGREGATE,
CALC_TYPE_RECEIPT,
)
from app.models.settlement import (
SETTLEMENT_STATUS_APPROVED,
SETTLEMENT_STATUS_DRAFT,
SETTLEMENT_STATUS_PENDING,
SETTLEMENT_STATUS_REJECTED,
)
from app.repositories import performance_ledger_repository, settlement_repository
from app.schemas.settlement import (
SettlementDetailOut,
SettlementOut,
SettlementQuery,
)
from app.services import approval_service, audit_service, manager_share
from app.services.approval_service import AuditRecord, RequestMeta
from app.services.formula import manager_team_share, monthly_salary
logger = logging.getLogger(__name__)
# 审批对象类型引用(order_type=2 月结、3 调整单)
_SETTLEMENT_ORDER_TYPE = APPROVAL_TYPE_MONTHLY_SETTLEMENT
_SETTLEMENT_NOTE = "确认回款口径(#8 策略点):settlement_status=1 的有效台账行按回款日归月"
def generate(
session: Session,
month: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettlementOut:
"""生成月度结算快照(草稿):按确认回款口径取有效台账行,按人汇总。
- 同月已有月结行(任意状态)时 400——一行一月,重生成走「驳回/草稿态
重新生成」(_release_previous_round 解绑回填后清明细重算)
- 经理聚合(calc_type=2)在生成时计算并落库(#12 参数化见
``_build_manager_aggregates``)
本函数只做编排,各段独立私有函数(口径修改只动对应段)。
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="生成月结",
summary=f"生成 {month} 月度结算快照",
request_data={"month": month},
),
meta=meta,
):
existing = settlement_repository.get_by_month(session, month)
_guard_regeneration(existing, month)
_release_previous_round(session, existing, month)
ledger_rows = _confirm_scope(session, month)
if not ledger_rows:
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
settlement = _upsert_draft(session, existing, operator, month)
items, ledger_ids = _build_items(session, ledger_rows, aggregate_rows, month)
settlement_repository.replace_items(session, settlement.id, items)
_apply_totals(settlement, items)
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
# 不回填会让重生成把已固化行反复纳入;聚合行此刻已落库、id 可用
settlement_repository.update_ledger_settlement_id(session, ledger_ids, settlement.id)
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
# 记成失败审计,与库中事实相反)
session.flush()
detail = _detail_of(session, settlement)
session.commit()
logger.info("月结生成 | month=%s | items=%s", month, len(items))
return detail
def _guard_regeneration(existing: MonthlySettlement | None, month: str) -> None:
"""重生成准入:已审定 / 待审批不可重生成(草稿、已驳回放行)。"""
if existing is None:
return
if existing.status == SETTLEMENT_STATUS_APPROVED:
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
if existing.status == SETTLEMENT_STATUS_PENDING:
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
def _release_previous_round(
session: Session, existing: MonthlySettlement | None, month: str
) -> None:
"""释放上一轮固化标记(必须早于 _confirm_scope)。
台账行 settlement_id 回填后,「未入结」过滤会把它们永久排除在候选集外;
旧聚合行也要作废,否则本轮重建的聚合行与旧行重复计入。
"""
if existing is None:
return
settlement_repository.clear_ledger_settlement_id(session, existing.id)
performance_ledger_repository.deactivate_month_aggregates(session, month)
def _upsert_draft(
session: Session, existing: MonthlySettlement | None, operator: User, month: str
) -> MonthlySettlement:
"""新建或重置草稿头表(重生成时刷新提交人与状态)。"""
if existing is None:
settlement = MonthlySettlement(
settlement_month=month,
submitter_id=operator.id,
submitter_name=operator.username,
status=SETTLEMENT_STATUS_DRAFT,
)
return settlement_repository.add(session, settlement)
existing.submitter_id = operator.id
existing.submitter_name = operator.username
existing.status = SETTLEMENT_STATUS_DRAFT
return existing
def _build_items(
session: Session, ledger_rows: list, aggregate_rows: list, month: str
) -> tuple[list[SettlementItem], list[int]]:
"""按人汇总生成明细行,返回 (明细, 本次入结的台账行 id 列表)。"""
by_person = _bucket_rows(ledger_rows + aggregate_rows)
items = [_item_of(session, bucket, month) for _, bucket in sorted(by_person.items())]
ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]]
return items, ledger_ids
def _bucket_rows(rows: list) -> dict[tuple[int, int], dict]:
"""台账行按 (人员, 核算类型) 维度分桶(冗余人读字段取首行)。"""
by_person: dict[tuple[int, int], dict] = {}
for row in rows:
key = (row.personnel_id, row.calc_type)
bucket = by_person.setdefault(
key,
{
"personnel_id": row.personnel_id,
"personnel_name": row.personnel_name,
"role_code": row.role_code,
"calc_type": row.calc_type,
"performance_sum": Decimal("0"),
"payable_sum": Decimal("0"),
"ledger_ids": [],
"is_probation": row.is_probation,
},
)
bucket["performance_sum"] += row.performance_amount or Decimal("0")
bucket["payable_sum"] += row.payable_amount or Decimal("0")
bucket["ledger_ids"].append(row.id)
return by_person
def _item_of(session: Session, bucket: dict, month: str) -> SettlementItem:
"""单条结算明细:月工资三口径净额、试用期说明与台账行引用列表。"""
person = session.get(Personnel, bucket["personnel_id"])
basic = person.salary_standard if person else None
mode = _salary_mode_of(session, bucket["role_code"], month)
net = (
monthly_salary(bucket["payable_sum"], basic, mode)
if person and person.is_probation != 1
else None
)
note = None
if person and person.is_probation == 1:
note = (
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session, month)} 元部分"
"年底一次性结算"
)
return SettlementItem(
personnel_id=bucket["personnel_id"],
personnel_name=bucket["personnel_name"],
role_code=bucket["role_code"],
calc_type=bucket["calc_type"],
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
basic_amount=basic,
salary_mode=mode,
net_amount=(
net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if net is not None else None
),
is_probation=bucket["is_probation"],
probation_note=note,
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
)
def _apply_totals(settlement: MonthlySettlement, items: list[SettlementItem]) -> None:
"""头表三合计固化(明细已两位小数,合计再 HALF_UP 收口)。"""
settlement.total_performance = sum((i.performance_sum for i in items), Decimal("0")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
settlement.person_count = len({i.personnel_id for i in items})
def submit(
session: Session,
settlement_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettlementDetailOut:
"""提交审批:开关分流(关=自动通过并锁定;开=待财务复核)。"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="提交月结",
summary=f"提交月结 #{settlement_id}",
request_data={"settlement_id": settlement_id},
),
meta=meta,
):
settlement = settlement_repository.get_by_id(session, settlement_id, for_update=True)
if settlement is None:
raise NotFoundError("月结不存在")
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
raise BadRequestError("仅草稿或已驳回状态可提交")
try:
order = approval_service.submit_order(
session,
order_type=_SETTLEMENT_ORDER_TYPE,
ref_id=settlement.id,
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
submitter=operator,
# 月结明细在生成时已固化;提交时快照让复核者看到的是送审版本,
# 而不是审批期间若发生重生成后的内容
snapshot=approval_service.settlement_content(session, settlement.id),
)
except IntegrityError as exc:
# version_no 唯一键的并发冲突(提交前已锁月结行,此项理论不可达,
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062 翻译成
# 可重试的业务提示,其余完整性错误原样上抛
if is_duplicate_entry(exc):
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
raise
settlement.approval_order_no = order.order_no
if order.status == SETTLEMENT_STATUS_APPROVED:
mark_locked(session, settlement, operator=operator)
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month)
else:
settlement.status = SETTLEMENT_STATUS_PENDING
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已提交的
# 月结记成失败审计,与库中事实相反)
session.flush()
detail = _detail_of(session, settlement)
session.commit()
return detail
def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
"""审批通过钩子(由 approval_service.act 回调):置已审定+锁定信息。"""
from datetime import datetime
settlement.status = SETTLEMENT_STATUS_APPROVED
settlement.locked_at = datetime.now()
settlement.locked_by = operator.username
def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
"""审批驳回钩子:回草稿(可修改后重提)。
锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示
「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。
"""
settlement.status = SETTLEMENT_STATUS_DRAFT
settlement.locked_at = None
settlement.locked_by = None
def ensure_month_not_locked(session: Session, month: str) -> None:
"""锁定拦截(回款/发货写路径调用):单据日期所在月已审定 → 400。"""
settlement = settlement_repository.get_by_month(session, month)
if settlement is not None and settlement.status == SETTLEMENT_STATUS_APPROVED:
raise BadRequestError(f"{month} 已月结锁定,源数据不可修改(调整走调整单)")
def get_detail(session: Session, settlement_id: int) -> SettlementDetailOut:
settlement = settlement_repository.get_by_id(session, settlement_id)
if settlement is None:
raise NotFoundError("月结不存在")
return _detail_of(session, settlement)
def list_page(session: Session, query: SettlementQuery):
from app.core.response import PageResult
from app.schemas.settlement import SettlementOut
rows = settlement_repository.list_settlements(session, query)
total = settlement_repository.count_settlements(session, query)
return PageResult[SettlementOut](
records=[SettlementOut.model_validate(r) for r in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
# ---------- 内部 ----------
def _confirm_scope(session: Session, month: str) -> list:
"""确认回款口径(#8 策略点):本月**单笔回款**的有效台账行(未入结)。
calc_type 必须限定单笔(1):聚合行(2)也落同月台账、也会被回填
settlement_id,不排除会在重生成时与新聚合行重复计入。
"""
from app.models import PerformanceLedger
return list(
session.execute(
select(PerformanceLedger)
.where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == CALC_TYPE_RECEIPT,
PerformanceLedger.is_active == 1,
PerformanceLedger.settlement_id.is_(None),
)
.order_by(PerformanceLedger.id)
).scalars()
)
def _manager_share_params(session: Session, month: str) -> manager_share.ManagerShareParams:
"""薄委托 manager_share 共享模块:月结聚合与报表部门汇总同源取参。
迁移说明(2026-10-19):实现原样迁至 manager_share,行为零变化;
参数按百分数存储(12.5 表示 12.5%),调用方不得再乘 100。
"""
return manager_share.manager_share_params(session, month)
def _collect_team_buckets(
session: Session, ledger_rows: list, include_manager_personal: bool
) -> dict[int, dict]:
"""薄委托 manager_share 共享模块:按经理采集团队业绩(行为零变化)。"""
return manager_share.collect_team_buckets(session, ledger_rows, include_manager_personal)
def _aggregate_row(
session: Session,
manager,
bucket: dict,
company_base: Decimal,
share: manager_share.ManagerShareParams,
):
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
from app.models import PerformanceLedger
amount = manager_team_share(
bucket["performance"],
company_base,
team_pct=share.team_pct,
company_pct=share.company_pct,
)
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
return performance_ledger_repository.add(
session,
PerformanceLedger(
receipt_id=None,
calc_version=1, # 聚合行无回款维度,版本恒 1(uk 含 NULL 不去重)
calc_type=CALC_TYPE_MANAGER_AGGREGATE,
business_id=bucket["business_id"],
business_no=bucket["business_no"],
personnel_id=manager.id,
personnel_name=manager.personnel_name,
role_code="sales_manager",
customer_type="personal",
product_category="mixed",
receipt_date=share.param_date,
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
rate_text=f"聚合:{share.team_text}%+{share.company_text}%",
converted_profit=None,
performance_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
barter_applied=0,
payable_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
settlement_month=share.month,
is_probation=1 if manager.is_probation == 1 else 0,
snapshot_json={
"team_base": str(bucket["performance"]),
"company_base": str(company_base),
"members": member_names,
"params_as_of": str(share.param_date),
"manager_personal_included": 1 if share.include_manager_personal else 0,
# 说明写给业务人员看:说清这一笔的基数是怎么凑出来的
"note": (
f"团队分成:基数{'含' if share.include_manager_personal else '不含'}"
"经理本人名下业绩,按本团队与全公司两级比例合计计提"
),
},
),
)
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
"""按经理生成团队聚合行(calc_type=2)并落库。
#12 答复(2026-10-19)参数化:经理个人户业绩是否计入聚合基数由参数
``manager.personal.included`` 决定(0=不计入默认,1=计入);
经理 = 台账行归属人员经 manager_id 关联的 sales_manager 岗人员。
"""
from app.repositories import business_role_repository
share = _manager_share_params(session, month)
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);共享口径单点
company_base = manager_share.company_base_of(ledger_rows, share.include_manager_personal)
aggregates: list = []
for manager_id, bucket in managers.items():
manager = session.get(Personnel, manager_id)
if manager is None:
continue
role = (
business_role_repository.get_by_id(session, manager.business_role_id)
if manager.business_role_id
else None
)
if role is None or role.role_code != "sales_manager":
continue # 非营销经理的上级不参与聚合
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
return aggregates
def _month_end(month: str) -> date:
"""薄委托 manager_share 共享模块:YYYY-MM → 所属月月末(取参锚点)。
保留包装:tests/unit/test_settlement_helpers.py 直接 import 本名字;
「补生成历史月份结果不随操作日漂移」的原因说明见 manager_share.month_end。
"""
return manager_share.month_end(month)
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
from app.services.param_service import resolve_snapshot
snapshot = resolve_snapshot(session, _month_end(month))
return snapshot.get("salary.basic.mode", role_code=role_code) or None
def _probation_threshold(session: Session, month: str) -> Decimal:
from app.services.param_service import parse_fraction, resolve_snapshot
snapshot = resolve_snapshot(session, _month_end(month))
from app.services.formula import to_decimal
return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold")))
def _detail_of(session: Session, settlement: MonthlySettlement) -> SettlementDetailOut:
items = settlement_repository.list_items(session, settlement.id)
detail = SettlementDetailOut.model_validate(settlement)
from app.schemas.settlement import SettlementItemOut
detail.items = [SettlementItemOut.model_validate(i) for i in items]
return detail
def create_adjustment(
session: Session,
payload,
*,
operator: User,
meta: RequestMeta | None = None,
):
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
from app.core.constants import NO_PREFIX_ADJUSTMENT
from app.models import AdjustmentOrder
from app.services.no_generator import next_no
amount = _adjustment_amount(payload)
person = session.get(Personnel, payload.personnel_id)
if person is None:
raise BadRequestError("调整人员不存在")
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="新建调整单",
summary=f"调整单 {payload.direction} {payload.amount} 元({person.personnel_name})",
request_data=payload.model_dump(),
),
meta=meta,
):
order = AdjustmentOrder(
order_no=next_no(
session,
model=AdjustmentOrder,
column_name="order_no",
prefix=NO_PREFIX_ADJUSTMENT,
),
settlement_month=payload.settlement_month,
personnel_id=payload.personnel_id,
personnel_name=person.personnel_name,
direction=payload.direction,
amount=amount,
reason=payload.reason.strip(),
submitter_id=operator.id,
submitter_name=operator.username,
)
session.add(order)
session.flush()
_wire_adjustment_approval(session, order, operator)
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
session.commit()
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])
return out
def _adjustment_amount(payload) -> Decimal:
"""金额恒正校验(方向表达补发/扣减)。
列注释即「恒正」约定,负数会让方向语义反转、审批/报表按 direction
汇总出错;格式已由 schema 正则拦截,此处兜底转换 + 语义判定。
"""
from decimal import InvalidOperation
try:
amount = Decimal(payload.amount)
except InvalidOperation as exc:
raise BadRequestError("调整金额格式非法(非负两位小数)") from exc
if amount <= 0:
raise BadRequestError("调整金额须大于 0(方向表达补发/扣减)")
return amount
def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
"""开关分流与留痕统一交给 submit_order(关=自动通过留痕;开=待财务复核)。
**不得手工插 ApprovalStep**——``approval_steps.order_id`` 外键指向
approval_orders(不是 adjustment_orders),用调整单 id 落步必然外键
失败(历史缺陷:关开关时新建调整单不可用)。
"""
from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT
from app.models.settlement import (
ADJUSTMENT_STATUS_EFFECTIVE,
ADJUSTMENT_STATUS_PENDING,
)
approval_order = approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_ADJUSTMENT,
ref_id=order.id,
refer_no=order.order_no,
submitter=operator,
snapshot=approval_service.adjustment_content(session, order.id),
)
order.status = (
ADJUSTMENT_STATUS_EFFECTIVE
if approval_order.status == APPROVAL_STATUS_APPROVED
else ADJUSTMENT_STATUS_PENDING
)
session.flush()
def _adjustment_out(order) -> dict:
"""调整单出参(commit 前固化;expire_on_commit 关闭下也不依赖后置读取)。"""
return {
"id": order.id,
"order_no": order.order_no,
"status": order.status,
"personnel_name": order.personnel_name,
"amount": str(order.amount),
"direction": order.direction,
}
def list_adjustments(session: Session) -> list[dict]:
"""调整单列表(月份倒序)。"""
from sqlalchemy import select
from app.models import AdjustmentOrder
rows = (
session.execute(select(AdjustmentOrder).order_by(AdjustmentOrder.id.desc()).limit(200))
.scalars()
.all()
)
from app.models.settlement import adjustment_label # noqa: F401
return [
{
"id": r.id,
"order_no": r.order_no,
"settlement_month": r.settlement_month,
"personnel_name": r.personnel_name,
"direction": r.direction,
"amount": str(r.amount),
"reason": r.reason,
"status": r.status,
}
for r in rows
]
+17 -14
View File
@@ -157,20 +157,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
rows = shipment_repository.list_shipments(
session,
offset=query.offset,
limit=query.page_size,
business_id=query.business_id,
ship_date_from=query.ship_date_from,
ship_date_to=query.ship_date_to,
)
total = shipment_repository.count_shipments(
session,
business_id=query.business_id,
ship_date_from=query.ship_date_from,
ship_date_to=query.ship_date_to,
)
rows = shipment_repository.list_shipments(session, query)
total = shipment_repository.count_shipments(session, query)
return PageResult[ShipmentOut](
records=[_to_out(row) for row in rows],
total=total,
@@ -237,6 +225,12 @@ def create_shipment(
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新发货")
# 月结锁定拦截:发货日所在月已审定 → 源数据不可改(调整走调整单)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(
session, payload.ship_date.strftime("%Y-%m")
)
shipment = Shipment(
shipment_no=shipment_no,
@@ -309,6 +303,11 @@ def update_shipment(
if shipment is None:
raise NotFoundError("发货单不存在")
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
# 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
settlement_service.ensure_month_not_locked(session, payload.ship_date.strftime("%Y-%m"))
shipment.ship_date = payload.ship_date
shipment.freight_amount = _amount_or_zero(payload.freight_amount)
@@ -357,6 +356,10 @@ def delete_shipment(
if shipment is None:
raise NotFoundError("发货单不存在")
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
# 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
if shipment_repository.count_receipt_refs(session, shipment_id):
raise BadRequestError("已被回款批次指定,请先解除回款的批次指定")
@@ -0,0 +1,171 @@
"""系统设置(公司信息)业务编排。
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径**
(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次
读取与备份放大传输,且无法独立替换;DB 只持有一行指针。
logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
覆盖任意文件。
"""
import logging
from pathlib import Path
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.constants import ACTION_UPDATE, MODULE_SYSTEM_SETTINGS
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import SystemSetting, User
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
# magic bytes:PNG 固定 8 字节签名;JPEG 以 SOI + APP0/JFIF 起始
_MAGIC = {
"png": b"\x89PNG\r\n\x1a\n",
"jpg": b"\xff\xd8\xff",
}
LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用
SETTINGS_ID = 1 # 全系统单行主键
def get_setting(session: Session) -> SystemSetting:
"""取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。"""
setting = session.get(SystemSetting, SETTINGS_ID)
if setting is None:
setting = SystemSetting(id=SETTINGS_ID, company_name="衍水集团")
session.add(setting)
session.flush()
return setting
def to_out(setting: SystemSetting) -> SettingOut:
"""管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。"""
return SettingOut(
company_name=setting.company_name,
logo_url=_logo_url(setting),
)
def to_public_out(setting: SystemSetting) -> SettingPublicOut:
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
return SettingPublicOut(
company_name=setting.company_name,
logo_url=_logo_url(setting),
)
def _logo_url(setting: SystemSetting) -> str | None:
if not setting.logo_path:
return None
version = int(setting.update_time.timestamp()) if setting.update_time else 0
return f"/api/v1/system/settings/logo/public?v={version}"
def update_company(
session: Session,
payload: SettingUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""更新公司名称(审计留痕)。"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE,
summary=f"修改公司名称为「{payload.company_name}」",
request_data=payload.model_dump(),
),
meta=meta,
):
setting = get_setting(session)
setting.company_name = payload.company_name.strip()
session.commit()
return to_out(setting)
def save_logo(
session: Session,
content: bytes,
ext: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
"""
ext = ext.lower()
if ext not in _MAGIC:
raise BadRequestError("logo 仅支持 png/jpg 格式")
if len(content) > LOGO_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
if not content.startswith(_MAGIC[ext]):
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE,
summary="更新公司 logo",
request_data={"ext": ext, "size": len(content)},
),
meta=meta,
):
setting = get_setting(session)
old_path = setting.logo_path
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
# 由写入覆盖,无需也不会误删新文件
target = _logo_dir() / f"logo.{ext}"
target.write_bytes(content)
setting.logo_path = f"logo/logo.{ext}"
session.commit()
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext:
try:
_cleanup_old_logo(old_ext)
except OSError:
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True)
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
return to_out(setting)
def read_logo(session: Session) -> tuple[Path, str]:
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
setting = get_setting(session)
if not setting.logo_path:
raise NotFoundError("尚未上传 logo")
path = get_settings().upload_dir / setting.logo_path
if not path.is_file():
raise NotFoundError("logo 文件不存在,请重新上传")
return path, path.suffix.lstrip(".")
def _logo_dir() -> Path:
directory = get_settings().upload_dir / "logo"
directory.mkdir(parents=True, exist_ok=True)
return directory
def _cleanup_old_logo(old_ext: str) -> None:
if old_ext in _MAGIC:
old_file = _logo_dir() / f"logo.{old_ext}"
if old_file.exists():
old_file.unlink(missing_ok=True)
+21
View File
@@ -0,0 +1,21 @@
# T+ 只读同步
> AI 摘要:读取外部 SQL Server 的原始快照,并管理持久化任务。关键文件为 task_service、worker、sync_service、relationship_audit 和 query_service;管理员 API 为 `/api/v1/system/tplus`。
## 职责与边界
- `task_service` 创建/重试任务,控制请求幂等和事务,保留原批次与异常;HTTP 不执行源库长查询。
- `worker.run_once` 取得 MySQL 执行锁后恢复中断任务、领取一个任务;读取和关联核验共用锁。
- `sync_service` 主键分页读取白名单实体,每页保存原始快照,相同源 ID 和行哈希去重。
- `relationship_audit` 执行 `db/queries/tplus/audit/queries.sql`,只返回系统字典和数量;按收款方向与单据类型核对关联。
- `query_service` 分页元数据,按需加载白名单报告;不返回原始 payload、驱动异常或源配置。
## 主要入口
管理员 API 提供概况、批次/异常分页、详情、创建与失败重试。CLI 为 `python -m app.cli tplus-worker --once/--watch`;原 `tplus-stage --entity personnel` 可继续直接读取。
连接配置、启动命令和异常处理参见项目 `docs/T+本地恢复与同步开发手册.md`。后端与 worker 必须指向同一 MySQL 业务库和 T+ 账套,查询文件随部署交付。
## 验收限制
当前只写 MySQL 暂存与管理表,不写工资业务表、不推进时间水位。关联、成本、日期和负向事件的业务规则待客户确认;SQL Server 2022 的实测不能替代客户 2008 R2(10.50.x)的兼容验收。
+6
View File
@@ -0,0 +1,6 @@
"""T+ 只读读取、字段映射和同步 staging。"""
from app.services.tplus.reader import TPlusReader
from app.services.tplus.sync_service import TPlusSyncService
__all__ = ["TPlusReader", "TPlusSyncService"]
+136
View File
@@ -0,0 +1,136 @@
"""T+ 字段映射元数据。
这里记录候选字段和确认状态,不把字典中的候选直接当成业务契约。
高风险口径在客户确认前保持 ``PENDING``,同步器只允许写入原始 staging。
"""
from dataclasses import dataclass
from enum import StrEnum
class MappingStatus(StrEnum):
"""字段映射的证据状态。"""
CONFIRMED = "CONFIRMED"
CANDIDATE = "CANDIDATE"
MISSING = "MISSING"
PENDING = "PENDING"
@dataclass(frozen=True, slots=True)
class TPlusMapping:
"""一个实体的源表、候选字段和目标表说明。"""
entity_type: str
source_table: str
source_fields: tuple[str, ...]
target_table: str
status: MappingStatus
unresolved: tuple[str, ...] = ()
version: str = "0.2"
schema_evidence: str = "ceshi20261026.bak,2026-10-27 恢复副本结构核验"
TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
TPlusMapping(
"personnel",
"AA_Person",
("id", "code", "name", "disabled", "mobilePhoneNo", "officePhoneNo", "iddepartment"),
"personnel",
MappingStatus.CANDIDATE,
("工号是否等于 code", "经理隶属关系", "入职日期", "试用期"),
),
TPlusMapping(
"products",
"AA_Inventory",
("id", "code", "name", "specification", "idunit", "idinventoryclass"),
"products",
MappingStatus.CANDIDATE,
("库存数量语义", "参考价来源", "系统产品分类"),
),
TPlusMapping(
"customers",
"AA_Partner",
("id", "code", "name", "Contact", "representative", "disabled", "updated"),
"customers",
MappingStatus.CANDIDATE,
("客户唯一键", "改名/合并规则", "系统客户分类"),
),
TPlusMapping(
"businesses",
"SA_SaleOrder",
("id", "code", "voucherdate", "idcustomer", "idclerk", "idbusinesstype", "updated"),
"businesses",
MappingStatus.PENDING,
("成本口径", "业务负责人覆盖规则", "含税口径"),
),
TPlusMapping(
"shipments",
"SA_SaleDelivery",
(
"id",
"code",
"voucherdate",
"SendDate",
"deliveryDate",
"auditeddate",
"SourceVoucherCode",
"updated",
),
"shipments",
MappingStatus.PENDING,
("发货日期", "订单关联路径", "退货/红冲规则", "运费来源"),
),
TPlusMapping(
"receipts",
"ARAP_ReceivePayment",
(
"id",
"code",
"voucherDate",
"auditedDate",
"createdTime",
"SaleOrderCode",
"SaleOrderID",
"ts",
),
"receipts",
MappingStatus.PENDING,
("到账日期", "收款方式", "核销状态", "回款与发货关联"),
),
)
DETAIL_MAPPINGS: tuple[TPlusMapping, ...] = (
TPlusMapping(
"business_items",
"SA_SaleOrder_b",
("id", "idSaleOrderDTO", "LatestCost"),
"business_items",
MappingStatus.PENDING,
("成本口径", "含税口径"),
),
TPlusMapping(
"shipment_items",
"SA_SaleDelivery_b",
("ID", "idSaleDeliveryDTO", "saleOrderId"),
"shipment_items",
MappingStatus.PENDING,
("发货归属", "红冲和退货"),
),
TPlusMapping(
"receipt_items",
"ARAP_ReceivePayment_b",
("id", "idArapReceivePaymentDTO", "saleorderid"),
"receipts",
MappingStatus.PENDING,
("回款拆分", "核销语义", "增量水位"),
),
)
def get_mapping(entity_type: str) -> TPlusMapping:
"""按实体类型取得映射;未知实体直接拒绝。"""
for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS):
if mapping.entity_type == entity_type:
return mapping
raise KeyError(f"未知 T+ 实体类型:{entity_type}")
+122
View File
@@ -0,0 +1,122 @@
"""同步管理只读查询;只公开受控元数据,不公开源数据和原异常。"""
from app.core.exceptions import NotFoundError
from app.core.response import PageResult
from app.core.tplus_db import resolve_tplus_settings
from app.repositories import tplus_repository as repository
from app.schemas.tplus import (
TPlusBatchDetailOut,
TPlusBatchOut,
TPlusErrorOut,
TPlusOverviewOut,
)
from app.services.tplus import task_service
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
from app.services.tplus.relationship_audit import public_report
ERROR_MESSAGES = {
"READ_ERROR": "源数据读取失败,请检查只读连接和字段映射",
"STAGE_ERROR": "原始数据保存失败,请核对字段类型后重试",
"INTERRUPTED": "执行进程中断,可重新读取,已保存数据会自动去重",
"EXECUTION_ERROR": "任务执行失败,请检查只读连接和字段映射",
"TYPE_UNRESOLVED": "关联核验所需的单据类型缺失或不唯一",
}
_ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)}
def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
"""只使用元数据字段组装输出,历史批次范围保持未知。"""
entities = batch.entity_types_json
scope_valid = (
isinstance(entities, list)
and bool(entities)
and all(isinstance(item, str) and item in _ENTITY_TYPES for item in entities)
)
return TPlusBatchOut(
batch_no=batch.batch_no,
task_type="AUDIT" if batch.sync_mode == "AUDIT" else "READ",
status=batch.status,
entity_types=entities if scope_valid else None,
source_record_count=batch.source_record_count,
staged_record_count=batch.staged_record_count,
skipped_record_count=batch.skipped_record_count,
failed_record_count=batch.failed_record_count,
parent_batch_no=batch.parent_batch_no,
create_time=batch.create_time,
started_at=batch.started_at,
finished_at=batch.finished_at,
can_retry=batch.status in ("FAILED", "PARTIAL")
and (batch.sync_mode == "AUDIT" or (batch.sync_mode == "FULL" and scope_valid))
and batch.batch_no not in active_parents,
error_summary="任务有异常,请查看异常明细"
if batch.status in ("FAILED", "PARTIAL")
else None,
)
def get_batch(session, number: str) -> TPlusBatchDetailOut:
"""按需加载报告,未找到时统一返回 404。"""
batch = repository.get_batch(session, number, with_result=True)
if batch is None:
raise NotFoundError("同步批次不存在")
out = batch_out(batch, repository.active_retry_parents(session, [number]))
return TPlusBatchDetailOut(
**out.model_dump(), audit_result=public_report(batch.audit_result_json)
)
def list_batches(session, query):
"""分页批次并批量查询重试状态。"""
rows, total = repository.list_batches(session, query)
active = repository.active_retry_parents(session, [row.batch_no for row in rows])
return PageResult[TPlusBatchOut](
records=[batch_out(row, active) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def _safe_id(value) -> str | None:
if isinstance(value, str) and value.isascii() and value.isdigit() and len(value) <= 10:
return value if int(value) <= 2147483647 else None
return None
def list_errors(session, number, query):
"""错误按编码生成固定说明,避免驱动异常泄漏连接参数。"""
if repository.get_batch(session, number) is None:
raise NotFoundError("同步批次不存在")
rows, total = repository.list_errors(session, number, query)
records = [
TPlusErrorOut(
id=row.id,
entity_type=row.entity_type if row.entity_type in _ENTITY_TYPES else "system",
source_id=_safe_id(row.source_id),
error_code=row.error_code if row.error_code in ERROR_MESSAGES else "UNKNOWN",
error_message=ERROR_MESSAGES.get(row.error_code, "任务未完成,请核对配置后重试"),
retry_count=row.retry_count,
create_time=row.create_time,
)
for row in rows
]
return PageResult[TPlusErrorOut](
records=records, total=total, page_num=query.page_num, page_size=query.page_size
)
def overview(session) -> TPlusOverviewOut:
"""只校验配置,不在页面查询期间建立 T+ 连接。"""
try:
resolve_tplus_settings(task_service.get_settings())
configured, message = True, "只读配置已就绪,实际连接结果以任务执行为准"
except RuntimeError:
configured, message = False, "T+ 只读配置未就绪,请联系管理员配置"
latest = repository.latest_audit(session)
return TPlusOverviewOut(
is_configured=configured,
configuration_message=message,
status_counts=repository.status_counts(session),
total_staged_records=repository.total_staged_records(session),
latest_audit=public_report(latest.audit_result_json) if latest else None,
)
+95
View File
@@ -0,0 +1,95 @@
"""T+ 六类源表的只读读取器。"""
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from typing import Any
from sqlalchemy import BigInteger, Integer, bindparam, text
from sqlalchemy.orm import Session
from app.services.tplus.mapping import TPLUS_MAPPINGS, get_mapping
_ENTITY_QUERY_FILES = {
"personnel": "personnel.sql",
"products": "products.sql",
"customers": "customers.sql",
"businesses": "businesses.sql",
"shipments": "shipments.sql",
"receipts": "receipts.sql",
"business_items": "business_items.sql",
"shipment_items": "shipment_items.sql",
"receipt_items": "receipt_items.sql",
}
class TPlusReader:
"""执行白名单 SQL 文件,禁止调用方传入任意表名或 SQL。"""
def __init__(self, session: Session, query_dir: Path | None = None) -> None:
self.session = session
self.query_dir = query_dir or self._default_query_dir()
@staticmethod
def _default_query_dir() -> Path:
return Path(__file__).resolve().parents[4] / "db" / "queries" / "tplus"
def iter_entity(
self,
entity_type: str,
*,
watermark: datetime | None = None,
batch_size: int = 500,
) -> Iterator[dict[str, Any]]:
"""按实体流式读取,水位只通过绑定参数传入。"""
if entity_type not in _ENTITY_QUERY_FILES:
raise KeyError(f"不支持的 T+ 实体类型:{entity_type}")
if batch_size < 1 or batch_size > 5000:
raise ValueError("batch_size 必须在 1 到 5000 之间")
if watermark is not None:
raise ValueError("增量水位语义尚未确认,当前只支持全量读取和哈希去重")
get_mapping(entity_type)
sql_path = self.query_dir / _ENTITY_QUERY_FILES[entity_type]
if not sql_path.is_file():
raise FileNotFoundError(f"缺少 T+ 查询文件:{sql_path}")
statement = text(sql_path.read_text(encoding="utf-8")).bindparams(
bindparam("batch_size", type_=Integer()), bindparam("after_id", type_=BigInteger())
)
after_id = -2147483649 # 所有源表主键均已核验为 SQL Server int,包含其最小值。
while True:
result = self.session.execute(
statement, {"batch_size": batch_size, "after_id": after_id}
)
try:
rows = [dict(row) for row in result.mappings().all()]
finally:
result.close()
if not rows:
return
next_id = int(rows[-1]["id"])
if next_id <= after_id:
raise ValueError("T+ 源主键未递增,停止读取以避免重复循环")
yield from rows
after_id = next_id
def fetch_entity(
self,
entity_type: str,
*,
watermark: datetime | None = None,
max_rows: int = 5000,
) -> list[dict[str, Any]]:
"""读取有限数量的实体行,供核验命令和单元测试使用。"""
if max_rows < 1:
raise ValueError("max_rows 必须大于 0")
rows: list[dict[str, Any]] = []
for row in self.iter_entity(entity_type, watermark=watermark):
rows.append(row)
if len(rows) >= max_rows:
break
return rows
def inventory(self) -> tuple[str, ...]:
"""返回当前适配器支持的实体类型。"""
return tuple(mapping.entity_type for mapping in TPLUS_MAPPINGS)
@@ -0,0 +1,135 @@
"""读取实际系统字典并统计带单据类型的收款关联,不推断计提口径。"""
import re
from datetime import datetime
from pathlib import Path
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.logging import scrub
from app.services.tplus.reader import TPlusReader
_COLUMNS = {
"source_version": ("source_version",),
"voucher_types": ("id", "code", "name"),
"state_enums": ("id", "code", "name", "enum_name"),
"receipt_kinds": ("voucher_type_code", "voucher_type_name", "receive_flag", "row_count"),
"receipt_line_kinds": ("voucher_type_id", "voucher_type_name", "receive_flag", "row_count"),
"sales_links": (
"row_count",
"delivery_matches",
"detail_matches",
"direct_order_matches",
"detail_order_matches",
"unambiguous_order_matches",
"invalid_order_ids",
"conflicting_order_ids",
),
"order_coverage": ("row_count", "without_order", "single_order", "multiple_orders"),
}
PENDING_CONFIRMATIONS = (
("cost_basis", "绩效成本取值", "订单最新成本或出库成本的类型、时点和含税口径需业务确认"),
("receipt_date", "到账日", "单据日、审核日和明细 arrivalDate 的业务含义需确认"),
("ship_date", "发货日", "回款天数应使用哪个发货日期需确认"),
("allocation", "多单分摊", "一笔收款涉及多张销货单或订单时,不能自动把整笔金额记给一单"),
("negative_events", "退货与红冲", "负数、冲销及已结算业务的调整方式需确认"),
)
def public_report(raw: dict | None) -> dict | None:
"""历史报告也按当前字段白名单输出,禁止透传未知嵌套数据。"""
if not isinstance(raw, dict):
return None
result = {
key: TPlusRelationshipAudit._safe_value(raw.get(key))
for key in (
"schema_version",
"captured_at",
"source_version",
"is_target_engine",
"relationship_status",
)
}
result["compatibility_status"] = "PENDING"
for section, columns in _COLUMNS.items():
if section in ("source_version", "voucher_types"):
continue
value = raw.get(section)
if isinstance(value, list):
result[section] = [
{key: TPlusRelationshipAudit._safe_value(row.get(key)) for key in columns}
for row in value[:200]
if isinstance(row, dict)
]
elif isinstance(value, dict):
result[section] = {
key: TPlusRelationshipAudit._safe_value(value.get(key)) for key in columns
}
else:
result[section] = None
result["pending_confirmations"] = [
{"key": k, "title": t, "reason": r} for k, t, r in PENDING_CONFIRMATIONS
]
return result
class TPlusRelationshipAudit:
"""执行固定核验查询;结果只包含系统字典和数量,不包含客户记录。"""
def __init__(self, session: Session, query_dir: Path | None = None) -> None:
self.session = session
root = query_dir or TPlusReader._default_query_dir()
source = (root / "audit" / "queries.sql").read_text(encoding="utf-8")
sections = re.split(r"(?m)^-- @query (\w+)\s*$", source)
self.queries = dict(zip(sections[1::2], sections[2::2], strict=True))
if set(self.queries) != set(_COLUMNS):
raise ValueError("关联核验查询清单不完整")
def fetch(self, name: str, **params) -> list[dict]:
"""只接受登记的查询名与绑定参数,过滤非合同字段。"""
if name not in self.queries:
raise ValueError("不支持的核验查询")
result = self.session.execute(text(self.queries[name]), params)
try:
return [
{key: self._safe_value(row.get(key)) for key in _COLUMNS[name]}
for row in result.mappings().all()
]
finally:
result.close()
@staticmethod
def _safe_value(value):
if value is None or isinstance(value, (int, bool)):
return value
return scrub(re.sub(r"[\x00-\x1f]", " ", str(value)))[:200]
def run(self) -> dict:
"""生成事实报告;类型缺失或有歧义时不执行该关联链路。"""
version = self.fetch("source_version")[0]["source_version"]
types = self.fetch("voucher_types")
report = {
"schema_version": 1,
"captured_at": datetime.now().isoformat(),
"source_version": version,
"is_target_engine": str(version).startswith("10.50."),
"compatibility_status": "PENDING",
"state_enums": self.fetch("state_enums"),
"receipt_kinds": self.fetch("receipt_kinds"),
"receipt_line_kinds": self.fetch("receipt_line_kinds"),
"sales_links": None,
"order_coverage": None,
"relationship_status": "UNRESOLVED",
"relationship_basis": "主表按 isReceiveFlag=1(是否收款)筛选;明细按 SM_VoucherType 的 SA04 类型关联。主表 idvouchertype 是来源类型,不能据此筛本单类型。",
"pending_confirmations": [
{"key": k, "title": t, "reason": r} for k, t, r in PENDING_CONFIRMATIONS
],
}
sale = [r["id"] for r in types if r["code"] == "SA04"]
if len(sale) == 1:
params = {"sale_delivery_type_id": sale[0], "receive_flag": 1}
report["sales_links"] = self.fetch("sales_links", **params)[0]
report["order_coverage"] = self.fetch("order_coverage", **params)[0]
report["relationship_status"] = "OBSERVED"
return report
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff. Show more