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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:56:13 +08:00
liuwenxin 8a171a572c docs(tplus): 完成交付设计并统一客户确认表与同版本基准 2026-10-28 16:31:55 +08:00
liuwenxin 651341b36c fix(tplus): 统一兼容检查连接策略并默认启用加密 2026-10-28 16:01:38 +08:00
liuwenxin 0d63359acb docs(tplus): 导入设计补充字典交叉验证的字段核验备注 2026-10-28 14:41:50 +08:00
liuwenxin ea41dadb64 docs(tplus): 修正验收手册只读表述并补 B5 预留参数 2026-10-28 13:11:15 +08:00
liuwenxin 8242aae454 docs(tplus): 新增 2008 R2 验收手册与数据导入设计稿 2026-10-28 11:52:39 +08:00
liuwenxin 87a0884953 docs(tplus): 更新客户口径文档与开发计划 2026-10-28 11:43:13 +08:00
liuwenxin ea989ce9ad feat(tplus): 新增 T+ 同步管理页面与菜单入口 2026-10-28 11:42:51 +08:00
liuwenxin 4fe5916c2c feat(tplus): 新增 T+ 只读同步服务与同步管理 2026-10-28 11:42:15 +08:00
liuwenxin 78bef43c06 feat(tplus): 新增同步暂存与任务管理迁移和环境配置 2026-10-28 11:41:38 +08:00
liuwenxinandClaude Code 823d9f02ec docs: 补充文案约定与侧栏层级说明
- backend/README 新增「面向业务人员的文案」约定:param_registry 的
  description 是唯一显示源、枚举值在页面上必须翻译、报错要给下一步、
  init-dev.sql 种子必须与 registry 同源、模板层禁止出现开发者语汇
- frontend/README 增补侧栏层级口径、最近动态独立分页端点,以及分页的
  两个坑(v-loading 不要罩到分页条上、翻页失败必须退回实际显示页)
- backend/tests/README 记录本轮运行结果

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

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

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

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

设计稿同步升 v4.2。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:43:20 +08:00
liuwenxin c48d24ab6d merge: 合并 V12 审批版本号唯一键与 T+ 清单 v1.7 至 main
- V12:审批单 (order_type, ref_id, version_no) 唯一键 + 取号改锁读
- 审批详情展示快照采集时间
- T+ 对接清单升至 v1.7,清理过期版本文件;补充部署硬件需求
- 全量回归 897 passed
2026-10-22 13:42:25 +08:00
202 changed files with 13081 additions and 4134 deletions

No files matched your search

+3 -1
View File
@@ -1,5 +1,5 @@
# AI辅助目录(整个目录忽略) # AI辅助目录(整个目录忽略)
ai-aux/ .ai/
# 依赖 # 依赖
node_modules/ node_modules/
@@ -65,6 +65,8 @@ CLAUDE.md
backend/uploads/ backend/uploads/
# 数据库备份导出目录(⑨ 备份脚本产物) # 数据库备份导出目录(⑨ 备份脚本产物)
db/backups/ db/backups/
# 客户发来的原始账套备份压缩包。
/db/*.zip
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库 # 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
docs/**/*.rar docs/**/*.rar
+20 -3
View File
@@ -26,6 +26,23 @@ LOGIN_FAIL_LIMIT=5
LOGIN_LOCK_MINUTES=15 LOGIN_LOCK_MINUTES=15
# ===== T+ 对接(待畅捷通确认后填写)===== # ===== T+ 对接(待畅捷通确认后填写)=====
T_PLUS_BASE_URL= T_PLUS_ENABLED=false
T_PLUS_APP_KEY= T_PLUS_HOST=127.0.0.1
T_PLUS_APP_SECRET= T_PLUS_PORT=1433
T_PLUS_DATABASE=
T_PLUS_USER=
T_PLUS_PASSWORD=
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
T_PLUS_CONNECTION_FILE=
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
# 本地 PoC 可复用已安装的 SQL Server 驱动;不自动安装或升级客户驱动。
T_PLUS_ODBC_DRIVER=
# 默认启用传输加密。旧实例 TLS 不兼容时,仅隔离开发环境可显式设为 false,
# 记录原因并重新验收;程序不会自动降级。真实环境值后续按验收结果填写。
T_PLUS_ENCRYPT=true
# 内网自签证书可暂用 true;它不校验证书身份。部署可信证书后改为 false。
T_PLUS_TRUST_SERVER_CERTIFICATE=true
T_PLUS_CONNECT_TIMEOUT=10
T_PLUS_QUERY_TIMEOUT=120
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
T_PLUS_QUERY_DIR=
+26 -1
View File
@@ -83,8 +83,9 @@ app/
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) | | GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) | | GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 | | 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}` | 详情 / 编辑(**不触归属**)/ 删除 | | 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) | | PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) | | GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
@@ -139,6 +140,19 @@ app/
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`, (`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。 V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
### T+ 本地只读同步与管理
T+ 数据库是 SQL Server 只读源库,业务库仍为 MySQL。Docker 恢复 PoC、备份检查和 D 盘目录约定见 `docs/T+本地恢复与同步开发手册.md`。
- 连接配置在 `backend/.env` 的 `T_PLUS_*` 变量中,默认关闭,不配置时不影响主业务启动。
- `uv run python -m app.cli tplus-stage --entity personnel` 按主键分页,将源行写入 `tplus_sync_records` staging,并记录批次和失败信息。水位表已预留,时间增量未启用,`--watermark` 会明确拒绝。
- T+ 驱动通过 `uv sync --extra tplus` 安装到应用环境,ODBC 驱动名称必须显式配置;交付兼容目标为 SQL Server 2008 R2,2022 副本不能代替同版本验收。
- 查询目录 `db/queries/tplus/` 随部署交付,或使用 `T_PLUS_QUERY_DIR` 指定受控位置;支持六类主表和三类明细。
- T+ 字典中的候选字段在实际备份核验和客户确认前,不得直接覆盖岗位、工资标准、归属、业务状态或核算状态。
- 管理 API `/api/v1/system/tplus` 全部限管理员:概况、批次/异常分页、按需报告、创建与失败重试。HTTP 只创建 QUEUED 任务;`uv run python -m app.cli tplus-worker --watch --connection-file <项目外JSON路径>` 独立处理。
- 核心文件:`services/tplus/task_service.py`(幂等/重试)、`worker.py`(执行)、`relationship_audit.py`(字典与关联)、`query_service.py`(安全输出)、`core/tplus_lock.py`(跨提交执行锁)。原 CLI 读取与 worker 共用执行锁。
- V15 / Alembic `e5f607a8b9c0` 增加范围、跳过数、请求键、父批次、请求人和核验结果。未启用时间增量与工资业务导入;完整运行步骤和异常处理见本地恢复手册。
## 常用命令 ## 常用命令
```bash ```bash
@@ -263,6 +277,17 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。 - **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。 - **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
### 面向业务人员的文案(2026-10-23 定稿,改文案前先读)
系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:
- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。
- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。
- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。
- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。
## 常见问题 ## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。 **起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
@@ -11,6 +11,7 @@ Create Date: 2026-10-19 17:55:48.892998
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。 「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
""" """
from collections.abc import Sequence from collections.abc import Sequence
from datetime import date from datetime import date
@@ -19,8 +20,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '90653814a421' revision: str = "90653814a421"
down_revision: str | Sequence[str] | None = 'ba0217f5091b' down_revision: str | Sequence[str] | None = "ba0217f5091b"
branch_labels: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
@@ -0,0 +1,51 @@
"""v13 客户评级落库
Revision ID: c3a9e1d7b2f4
Revises: 7f4f7b918bc4
Create Date: 2026-10-23 10:30:00.000000
评级计算器从独立工具页并入客户管理(行内弹窗),评分由「不落库」改为
保存为客户档案属性:``rating_score`` 总分 + ``rating_time`` 评级时间。
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
的等级随之更新),因此本迁移只落总分与评级时间两列。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c3a9e1d7b2f4"
down_revision: str | Sequence[str] | None = "7f4f7b918bc4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""customers 加评级两列,紧跟 remark 之后保持与 ORM 声明同序。
用 execute 手写而非 op.add_column:后者不带 AFTER 子句,两列会落到
表尾(T+ 预留列之后),与 ORM/init-dev 的声明顺序不一致。
"""
op.execute(
"ALTER TABLE customers "
"ADD COLUMN rating_score TINYINT COMMENT "
"'评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' "
"AFTER remark"
)
op.execute(
"ALTER TABLE customers "
"ADD COLUMN rating_time DATETIME COMMENT "
"'最近评级时间(评级计算器保存评分时刷新;人工维护字段)' "
"AFTER rating_score"
)
def downgrade() -> None:
"""删除评级两列。评分数据随列删除,评级无下游引用、无悬空风险。"""
op.drop_column("customers", "rating_time")
op.drop_column("customers", "rating_score")
@@ -34,10 +34,7 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。""" """种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭) # 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
op.execute( op.execute("UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18)")
"UPDATE param_definitions SET default_value = '1' "
"WHERE id IN (17, 18)"
)
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析) # 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
op.bulk_insert( op.bulk_insert(
sa.table( sa.table(
@@ -188,6 +185,4 @@ def downgrade() -> None:
) )
op.execute("DELETE FROM param_values WHERE id IN (23, 24)") op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
op.execute( op.execute("UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)")
"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",
)
@@ -43,9 +43,7 @@ def upgrade() -> None:
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' " "COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
"AFTER version_no" "AFTER version_no"
) )
op.execute( op.execute("CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)")
"CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)"
)
op.execute( op.execute(
"ALTER TABLE business_secondary_allocations " "ALTER TABLE business_secondary_allocations "
+2
View File
@@ -26,6 +26,7 @@ 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 params as system_params
from app.api.v1.system import roles as system_roles from app.api.v1.system import roles as system_roles
from app.api.v1.system import settings as system_settings from app.api.v1.system import settings as system_settings
from app.api.v1.system import tplus as system_tplus
from app.api.v1.system import users as system_users from app.api.v1.system import users as system_users
api_router = APIRouter() api_router = APIRouter()
@@ -47,6 +48,7 @@ system_router.include_router(system_settings.router)
system_router.include_router(system_approvals.router) system_router.include_router(system_approvals.router)
system_router.include_router(system_logs.operation_router) system_router.include_router(system_logs.operation_router)
system_router.include_router(system_logs.login_router) system_router.include_router(system_logs.login_router)
system_router.include_router(system_tplus.router)
v1_router.include_router(system_router) v1_router.include_router(system_router)
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问 # 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
+10 -16
View File
@@ -12,7 +12,8 @@ from fastapi import APIRouter, Depends
from app.api.deps import DbSession, get_current_user from app.api.deps import DbSession, get_current_user
from app.core.response import ApiResponse, ok from app.core.response import ApiResponse, ok
from app.models import User from app.models import User
from app.repositories import business_role_repository from app.schemas.business_role import BusinessRoleOptionOut
from app.services import business_role_service
router = APIRouter(prefix="/business-roles", tags=["人员管理"]) router = APIRouter(prefix="/business-roles", tags=["人员管理"])
@@ -20,21 +21,14 @@ router = APIRouter(prefix="/business-roles", tags=["人员管理"])
CurrentUser = Annotated[User, Depends(get_current_user)] CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("/options", response_model=ApiResponse[list[dict]], summary="岗位下拉选项") @router.get(
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[dict]]: "/options",
response_model=ApiResponse[list[BusinessRoleOptionOut]],
summary="岗位下拉选项",
)
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[BusinessRoleOptionOut]]:
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。 """岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
岗位规模有限(内置 + 自定义),一次取足够上限,不做分页。 岗位规模有限(内置 + 自定义),一次取全量,不做分页。
""" """
roles = business_role_repository.list_roles(session, offset=0, limit=1000) return ok(business_role_service.list_options(session))
return ok(
[
{
"id": role.id,
"role_code": role.role_code,
"role_name": role.role_name,
"status": role.status,
}
for role in roles
]
)
+26
View File
@@ -21,6 +21,7 @@ from app.schemas.customer import (
CustomerOut, CustomerOut,
CustomerOwnershipIn, CustomerOwnershipIn,
CustomerQuery, CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn, CustomerUpdateIn,
OwnershipChangeLogOut, OwnershipChangeLogOut,
) )
@@ -102,6 +103,31 @@ def delete_customer(
return ok(message="客户已删除") 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( @router.put(
"/{customer_id}/ownership", "/{customer_id}/ownership",
response_model=ApiResponse[CustomerOut], response_model=ApiResponse[CustomerOut],
+20 -12
View File
@@ -1,4 +1,4 @@
"""工作台接口(M4 ⑧):四区块一次性聚合。 """工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」 准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。 待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
@@ -12,22 +12,18 @@ from app.api.deps import DbSession, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, ok from app.core.response import ApiResponse, ok
from app.models import User from app.models import User
from app.schemas.dashboard import DashboardOut from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut
from app.services import dashboard_service from app.services import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["工作台"]) router = APIRouter(prefix="/dashboard", tags=["工作台"])
_DashboardUser = Annotated[ _DashboardUser = Annotated[
User, User,
Depends( Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台")),
require_roles(
ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台"
)
),
] ]
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块") @router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块")
def get_dashboard( def get_dashboard(
session: DbSession, session: DbSession,
current_user: _DashboardUser, current_user: _DashboardUser,
@@ -35,10 +31,22 @@ def get_dashboard(
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月") str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None, ] = None,
) -> ApiResponse[DashboardOut]: ) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。 """一次返回经营总览/待办/风险预警(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。 待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
最近动态是分页列表,走 `GET /dashboard/activities`。
""" """
return ok( return ok(dashboard_service.get_dashboard(session, month, current_user=current_user))
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))
+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))
+92
View File
@@ -11,13 +11,20 @@
import argparse import argparse
import getpass import getpass
import sys import sys
import time
from datetime import datetime
from sqlalchemy import select from sqlalchemy import select
from app.core.config import get_settings
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
from app.core.db import dispose_engine, get_session_factory from app.core.db import dispose_engine, get_session_factory
from app.core.security import hash_password from app.core.security import hash_password
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
from app.models import Role, User, UserRole from app.models import Role, User, UserRole
from app.repositories.tplus_repository import get_batch
from app.services.tplus.sync_service import TPlusSyncService
from app.services.tplus.worker import run_once
def _prompt_password() -> str: def _prompt_password() -> str:
@@ -80,6 +87,54 @@ def _create_admin(args: argparse.Namespace) -> int:
return 0 return 0
def _stage_tplus(args: argparse.Namespace) -> int:
"""读取 T+ 源库并写入 MySQL staging。"""
target_factory = get_session_factory()
source_factory = get_tplus_session_factory()
watermark = datetime.fromisoformat(args.watermark) if args.watermark else None
with source_factory() as source_session, target_factory() as target_session:
batch = TPlusSyncService(source_session, target_session).run(
entity_types=args.entity,
watermark=watermark,
batch_size=args.batch_size,
)
print(
f"T+ staging 完成:批次={batch.batch_no} 状态={batch.status} "
f"源记录={batch.source_record_count} 新增={batch.staged_record_count} "
f"失败={batch.failed_record_count}"
)
return 0 if batch.status == "SUCCEEDED" else 1
def _tplus_worker(args: argparse.Namespace) -> int:
"""独立消费持久化任务;HTTP 进程不承担长时间读取。"""
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
if not 2 <= args.poll_seconds <= 60:
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
return 1
factory = get_session_factory()
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: def main() -> int:
"""命令行入口。""" """命令行入口。"""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -96,12 +151,49 @@ def main() -> int:
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中", help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
) )
p_tplus = sub.add_parser("tplus-stage", help="读取 T+ 源库并写入原始 staging")
p_tplus.add_argument(
"--entity",
action="append",
choices=[
"personnel",
"products",
"customers",
"businesses",
"shipments",
"receipts",
"business_items",
"shipment_items",
"receipt_items",
],
required=True,
help="实体类型,可重复指定",
)
p_tplus.add_argument(
"--watermark",
default=None,
help="增量起始时间,ISO 8601 格式;不传表示全量读取",
)
p_tplus.add_argument("--batch-size", type=int, default=500, help="每次从源库读取的行数")
p_worker = sub.add_parser("tplus-worker", help="执行已排队的 T+ 读取或关联核验")
modes = p_worker.add_mutually_exclusive_group()
modes.add_argument("--once", action="store_true", help="执行一个任务后退出(默认)")
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
args = parser.parse_args() args = parser.parse_args()
try: try:
if args.command == "create-admin":
return _create_admin(args) return _create_admin(args)
if args.command == "tplus-worker":
return _tplus_worker(args)
return _stage_tplus(args)
finally: finally:
dispose_engine() dispose_engine()
dispose_tplus_engine()
if __name__ == "__main__": if __name__ == "__main__":
+43
View File
@@ -58,6 +58,23 @@ class Settings(BaseSettings):
redis_password: str = "" redis_password: str = ""
redis_db: int = 0 redis_db: int = 0
# ===== T+ 只读 SQL Server =====
# T+ 数据库默认关闭,避免开发者未配置驱动时影响主业务启动。
t_plus_enabled: bool = False
t_plus_host: str = "127.0.0.1"
t_plus_port: int = 1433
t_plus_database: str = ""
t_plus_user: str = ""
t_plus_password: str = Field(default="", repr=False)
t_plus_connection_file: str = ""
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
t_plus_odbc_driver: str = ""
t_plus_encrypt: bool = True
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") @field_validator("app_secret_key")
@classmethod @classmethod
def _check_secret_key(cls, v: str) -> str: def _check_secret_key(cls, v: str) -> str:
@@ -92,6 +109,32 @@ class Settings(BaseSettings):
auth = f":{self.redis_password}@" if self.redis_password else "" auth = f":{self.redis_password}@" if self.redis_password else ""
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}" return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
@property
def t_plus_database_url(self) -> URL:
"""构造 T+ 只读 SQL Server 连接 URL。
连接 URL 只在启用 T+ 适配器时使用;密码由 SQLAlchemy URL 对象保存,
日志中调用 ``render_as_string()`` 时仍会默认隐藏密码。
ODBC 的分号和花括号由方言统一转义,调用方不得再手工拼接连接串。
"""
query = {
"driver": self.t_plus_odbc_driver,
"Encrypt": "yes" if self.t_plus_encrypt else "no",
"TrustServerCertificate": "yes"
if self.t_plus_trust_server_certificate
else "no",
"Connection Timeout": str(self.t_plus_connect_timeout),
}
return URL.create(
drivername="mssql+pyodbc",
username=self.t_plus_user,
password=self.t_plus_password,
host=self.t_plus_host,
port=self.t_plus_port,
database=self.t_plus_database,
query=query,
)
@property @property
def upload_dir(self) -> Path: def upload_dir(self) -> Path:
"""上传文件根目录(公司 logo 等):backend/uploads/。 """上传文件根目录(公司 logo 等):backend/uploads/。
+115
View File
@@ -0,0 +1,115 @@
"""T+ 只读 SQL Server 连接。
T+ 是外部源库,业务接口不直接依赖它。连接采用延迟初始化,未配置或未启用
时不会影响主业务服务启动;适配器执行查询时才建立连接池。
"""
import json
from collections.abc import Generator
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import Settings, get_settings
_engine: Engine | None = None
_session_factory: sessionmaker[Session] | None = None
class _ConnectionFile(BaseModel):
"""连接文件仅允许源库字段,密码不会出现在模型表示中。"""
model_config = ConfigDict(extra="forbid", strict=True)
host: str = Field(min_length=1, max_length=255)
port: int = Field(ge=1, le=65535)
database: str = Field(min_length=1, max_length=128)
user: str = Field(min_length=1, max_length=128)
password: SecretStr
driver: str = Field(min_length=1, max_length=128)
def resolve_tplus_settings(settings: Settings) -> Settings:
"""解析显式配置的只读连接,返回副本,不修改应用全局配置。"""
if not settings.t_plus_enabled:
raise RuntimeError("T+ 数据源未启用,请设置 T_PLUS_ENABLED=true")
resolved = settings
if settings.t_plus_connection_file:
try:
path = Path(settings.t_plus_connection_file)
if path.stat().st_size > 16384:
raise ValueError("连接文件过大")
config = _ConnectionFile.model_validate(
json.loads(path.read_text(encoding="utf-8-sig"))
)
except OSError:
# 消息只区分故障类别,不回显路径内容;路径本身可在应用配置中核对。
raise RuntimeError("T+ 只读连接文件不存在或不可读") from None
except (ValueError, ValidationError):
raise RuntimeError("T+ 只读连接文件字段不正确") from None
values = {
"t_plus_" + key: getattr(config, key) for key in ("host", "port", "database", "user")
}
values.update(
t_plus_password=config.password.get_secret_value(), t_plus_odbc_driver=config.driver
)
resolved = settings.model_copy(update=values)
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.exception_handlers import register_exception_handlers
from app.core.logging import setup_logging from app.core.logging import setup_logging
from app.core.redis_client import close_redis from app.core.redis_client import close_redis
from app.core.tplus_db import dispose_tplus_engine
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,6 +36,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
) )
yield yield
dispose_engine() dispose_engine()
dispose_tplus_engine()
close_redis() close_redis()
logger.info("应用已停止,连接池已释放") logger.info("应用已停止,连接池已释放")
+10
View File
@@ -26,6 +26,12 @@ from app.models.role import Permission, Role, RolePermission
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
from app.models.shipment import Shipment, ShipmentItem from app.models.shipment import Shipment, ShipmentItem
from app.models.system_setting import SystemSetting from app.models.system_setting import SystemSetting
from app.models.tplus_sync import (
TPlusSyncBatch,
TPlusSyncError,
TPlusSyncRecord,
TPlusSyncWatermark,
)
from app.models.user import User, UserRole from app.models.user import User, UserRole
__all__ = [ __all__ = [
@@ -60,6 +66,10 @@ __all__ = [
"Shipment", "Shipment",
"ShipmentItem", "ShipmentItem",
"SystemSetting", "SystemSetting",
"TPlusSyncBatch",
"TPlusSyncError",
"TPlusSyncRecord",
"TPlusSyncWatermark",
"TimestampMixin", "TimestampMixin",
"User", "User",
"UserRole", "UserRole",
+3
View File
@@ -91,6 +91,9 @@ class TPlusSyncMixin:
String(32), comment="同步批次号(同步模块写入)" String(32), comment="同步批次号(同步模块写入)"
) )
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间") synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
source_updated_at: Mapped[datetime | None] = mapped_column(
DateTime, comment="T+ 源数据更新时间(未确认时可为空)"
)
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]: def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
+1 -3
View File
@@ -79,9 +79,7 @@ class SecondaryAllocation(Base, TimestampMixin):
Index("idx_business_secondary_allocations_business_id", "business_id"), Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"), Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1 # 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
Index( Index("idx_business_secondary_allocations_business_active", "business_id", "is_active"),
"idx_business_secondary_allocations_business_active", "business_id", "is_active"
),
) )
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
+8
View File
@@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)", comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
) )
remark: Mapped[str | None] = mapped_column(String(255), comment="备注") 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): class OwnershipChangeLog(Base):
+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"
)
+29 -73
View File
@@ -5,8 +5,6 @@
models/customer.py 定义)。 models/customer.py 定义)。
""" """
from datetime import date
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
@@ -23,6 +21,8 @@ from app.models import (
) )
from app.models.customer import OWNER_TYPE_BUSINESS from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import ownership_log_repository from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.schemas.business import BusinessQuery
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None: def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
@@ -77,50 +77,32 @@ def get_with_names(
return session.execute(stmt).first() return session.execute(stmt).first()
def _apply_filters( def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
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:
"""业务列表的筛选条件(调用方须已 join Customer)。""" """业务列表的筛选条件(调用方须已 join Customer)。"""
if keyword: if query.keyword:
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy # 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合) # 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
stmt = stmt.where( stmt = stmt.where(
or_( or_(
Business.business_no.contains(keyword, autoescape=True), Business.business_no.contains(query.keyword, autoescape=True),
Customer.company_name.contains(keyword, autoescape=True), Customer.company_name.contains(query.keyword, autoescape=True),
) )
) )
if status is not None: if query.status is not None:
stmt = stmt.where(Business.status == status) stmt = stmt.where(Business.status == query.status)
if customer_id is not None: if query.customer_id is not None:
stmt = stmt.where(Business.customer_id == customer_id) stmt = stmt.where(Business.customer_id == query.customer_id)
if owner_personnel_id is not None: if query.owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id) stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id)
if date_from is not None: if query.date_from is not None:
stmt = stmt.where(Business.business_date >= date_from) stmt = stmt.where(Business.business_date >= query.date_from)
if date_to is not None: if query.date_to is not None:
stmt = stmt.where(Business.business_date <= date_to) stmt = stmt.where(Business.business_date <= query.date_to)
return stmt return stmt
def list_businesses( def list_businesses(
session: Session, session: Session, query: BusinessQuery
*,
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,
) -> list[tuple[Business, str, str | None, str | None]]: ) -> list[tuple[Business, str, str | None, str | None]]:
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。""" """分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
owner = aliased(Personnel) owner = aliased(Personnel)
@@ -128,49 +110,24 @@ def list_businesses(
stmt = select( stmt = select(
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
).join(Customer, Customer.id == Business.customer_id) ).join(Customer, Customer.id == Business.customer_id)
stmt = _apply_filters( stmt = _apply_filters(stmt, query)
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
stmt = ( stmt = (
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id) stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id) .outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
.order_by(Business.id.desc()) .order_by(Business.id.desc())
.offset(offset) .offset(query.offset)
.limit(limit) .limit(query.page_size)
) )
return list(session.execute(stmt).all()) return list(session.execute(stmt).all())
def count_businesses( def count_businesses(session: Session, query: BusinessQuery) -> int:
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:
stmt = ( stmt = (
select(func.count()) select(func.count())
.select_from(Business) .select_from(Business)
.join(Customer, Customer.id == Business.customer_id) .join(Customer, Customer.id == Business.customer_id)
) )
stmt = _apply_filters( stmt = _apply_filters(stmt, query)
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
@@ -313,18 +270,17 @@ def list_ownership_logs(
) -> list[Row]: ) -> list[Row]:
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。""" """某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
return ownership_log_repository.list_by_ref( return ownership_log_repository.list_by_ref(
session, session, _business_log_ref(business_id), offset=offset, limit=limit
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,
offset=offset,
limit=limit,
) )
def count_ownership_logs(session: Session, business_id: int) -> int: def count_ownership_logs(session: Session, business_id: int) -> int:
return ownership_log_repository.count_by_ref( return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id))
session,
def _business_log_ref(business_id: int) -> OwnershipLogRef:
"""业务维度的归属留痕锚点。"""
return OwnershipLogRef(
owner_type=OWNER_TYPE_BUSINESS, owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id, ref_column=OwnershipChangeLog.business_id,
ref_id=business_id, ref_id=business_id,
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import BusinessRole 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} return {role.role_code: role for role in rows}
def list_roles( def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]:
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[BusinessRole]:
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。""" """分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status) stmt = _apply_filters(select(BusinessRole), query)
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit) stmt = (
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
.offset(query.offset)
.limit(query.page_size)
)
return list(session.execute(stmt).scalars()) 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()) 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 通配符)——岗位编码含下划线, 关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
不转义时 ``_`` 会当单字符通配执行。 不转义时 ``_`` 会当单字符通配执行。
""" """
if keyword: if query.keyword:
stmt = stmt.where( stmt = stmt.where(
or_( or_(
BusinessRole.role_code.contains(keyword, autoescape=True), BusinessRole.role_code.contains(query.keyword, autoescape=True),
BusinessRole.role_name.contains(keyword, autoescape=True), BusinessRole.role_name.contains(query.keyword, autoescape=True),
) )
) )
if status is not None: if query.status is not None:
stmt = stmt.where(BusinessRole.status == status) stmt = stmt.where(BusinessRole.status == query.status)
return stmt 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 import Customer, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import ownership_log_repository from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.repositories.query_utils import fuzzy 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() return session.execute(stmt).scalar_one_or_none()
def list_customers( def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名)。 """分页查询客户,行内容为 (客户, 归属人员姓名)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据) 归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。 不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。 排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
""" """
stmt = _apply_filters( stmt = _apply_filters(_base_select(), query)
_base_select(), stmt = _apply_order(stmt, query)
keyword=keyword, stmt = stmt.offset(query.offset).limit(query.page_size)
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).all()) return list(session.execute(stmt).all())
def count_customers( def count_customers(session: Session, query: CustomerQuery) -> int:
session: Session,
*,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> int:
"""统计符合条件的客户总数,供分页使用。 """统计符合条件的客户总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由 筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。 ``_apply_filters`` 单点保证)。
""" """
stmt = _apply_filters( stmt = _apply_filters(select(func.count(Customer.id)), query)
select(func.count(Customer.id)),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
@@ -101,9 +81,7 @@ def list_ownership_logs(
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。""" """查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
return ownership_log_repository.list_by_ref( return ownership_log_repository.list_by_ref(
session, session,
owner_type=OWNER_TYPE_CUSTOMER, _customer_log_ref(customer_id),
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
offset=offset, offset=offset,
limit=limit, limit=limit,
) )
@@ -111,8 +89,12 @@ def list_ownership_logs(
def count_ownership_logs(session: Session, *, customer_id: int) -> int: def count_ownership_logs(session: Session, *, customer_id: int) -> int:
"""统计某客户的归属留痕总数,供分页使用。""" """统计某客户的归属留痕总数,供分页使用。"""
return ownership_log_repository.count_by_ref( return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id))
session,
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
"""客户维度的归属留痕锚点。"""
return OwnershipLogRef(
owner_type=OWNER_TYPE_CUSTOMER, owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id, ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id, ref_id=customer_id,
@@ -126,20 +108,14 @@ def _base_select() -> Select:
) )
def _apply_filters( def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
stmt: Select,
*,
keyword: str | None,
customer_type: str | None,
owner_personnel_id: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" """应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword: if query.keyword:
stmt = stmt.where(_keyword_clause(keyword)) stmt = stmt.where(_keyword_clause(query.keyword))
if customer_type: if query.customer_type:
stmt = stmt.where(Customer.customer_type == customer_type) stmt = stmt.where(Customer.customer_type == query.customer_type)
if owner_personnel_id is not None: if query.owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id) stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
return stmt 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())
# ==================== 写入 ==================== # ==================== 写入 ====================
@@ -34,9 +34,11 @@ _PENDING_STATUSES = (
# ==== 经营绩效总览 ==== # ==== 经营绩效总览 ====
def sum_month_performance(session: Session, month: str) -> dict: def sum_month_performance(session: Session, month: str) -> dict:
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。""" """当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
return session.execute( return (
session.execute(
select( select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label( func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total" "performance_total"
@@ -47,7 +49,10 @@ def sum_month_performance(session: Session, month: str) -> dict:
PerformanceLedger.settlement_month == month, PerformanceLedger.settlement_month == month,
*_ACTIVE_SINGLE, *_ACTIVE_SINGLE,
) )
).mappings().one() )
.mappings()
.one()
)
def performance_trend(session: Session, months: list[str]) -> dict[str, object]: def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
@@ -66,9 +71,7 @@ def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
return dict(rows) return dict(rows)
def receipt_trend( def receipt_trend(session: Session, *, start_date: date, end_date: date) -> dict[str, object]:
session: Session, *, start_date: date, end_date: date
) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。 """近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于 过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
@@ -107,6 +110,7 @@ def get_month_settlement(session: Session, month: str) -> MonthlySettlement | No
# ==== 待办事项 ==== # ==== 待办事项 ====
def list_pending_approvals( def list_pending_approvals(
session: Session, *, statuses: tuple[int, ...], limit: int session: Session, *, statuses: tuple[int, ...], limit: int
) -> list[ApprovalOrder]: ) -> list[ApprovalOrder]:
@@ -160,15 +164,14 @@ def list_unassigned_receipts(session: Session, *, limit: int) -> list:
def count_unassigned_receipts(session: Session) -> int: def count_unassigned_receipts(session: Session) -> int:
return int( return int(
session.execute( session.execute(
select(func.count()) select(func.count()).select_from(Receipt).where(Receipt.shipment_id.is_(None))
.select_from(Receipt)
.where(Receipt.shipment_id.is_(None))
).scalar_one() ).scalar_one()
) )
# ==== 风险预警 ==== # ==== 风险预警 ====
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list: def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。 """发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
@@ -1,5 +1,7 @@
"""登录日志数据访问。""" """登录日志数据访问。"""
from dataclasses import dataclass
from sqlalchemy import Select, func, select from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,68 +12,73 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
_USER_AGENT_MAX_LEN = 255 _USER_AGENT_MAX_LEN = 255
def add( @dataclass(frozen=True, slots=True)
session: Session, class LoginLogEntry:
*, """一条待写入的登录日志(全字段 DTO)。
user_id: int | None,
username: str, 与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传
ip: str | None, 都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。
user_agent: str | None, """
success: bool,
fail_reason: str | None = None, user_id: int | None
) -> 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。 """写入一条登录日志。只 flush 不 commit。
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。 **绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
:param user_id: 账号不存在时传 None(列本身可空) :param entry.user_id: 账号不存在时为 None(列本身可空)
:param username: 用户名原样记录,便于账号不存在时也能追溯 :param entry.username: 用户名原样记录,便于账号不存在时也能追溯
""" """
session.add( session.add(
LoginLog( LoginLog(
user_id=user_id, user_id=entry.user_id,
username=username, username=entry.username,
ip=ip, ip=entry.ip,
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None, user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None,
success=1 if success else 0, success=1 if entry.success else 0,
fail_reason=fail_reason, fail_reason=entry.fail_reason,
) )
) )
session.flush() session.flush()
def list_logs( def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
ip: str | None = None,
) -> list[LoginLog]:
"""分页查询登录日志。 """分页查询登录日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序 **按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
不确定,翻页会重复或遗漏(与用户列表同一理由)。 不确定,翻页会重复或遗漏(与用户列表同一理由)。
""" """
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip) stmt = _apply_filters(select(LoginLog), query)
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit) stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
return list(session.execute(stmt).scalars()) return list(session.execute(stmt).scalars())
def count_logs( def count_logs(session: Session, query: LoginLogListQuery) -> int:
session: Session,
*,
filters: LogFilters,
ip: str | None = None,
) -> 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()) 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,与操作日志同口径)。""" """应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
stmt = apply_log_filters(stmt, LoginLog, filters) stmt = apply_log_filters(stmt, LoginLog, query.filters)
if ip: if query.ip:
stmt = stmt.where(LoginLog.ip == ip) stmt = stmt.where(LoginLog.ip == query.ip)
return stmt return stmt
@@ -107,15 +107,18 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
return session.get(OperationLog, log_id) return session.get(OperationLog, log_id)
def list_logs( @dataclass(frozen=True, slots=True)
session: Session, class OperationLogListQuery:
*, """操作日志列表的查询条件(筛选 + 分页)。"""
offset: int,
limit: int, filters: LogFilters
filters: LogFilters, offset: int = 0
module: str | None = None, limit: int = 20
action: str | None = None, module: str | None = None
) -> list[OperationLog]: action: str | None = None
def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]:
"""分页查询操作日志。 """分页查询操作日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序 **按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
@@ -127,41 +130,34 @@ def list_logs(
""" """
stmt = _apply_filters( stmt = _apply_filters(
select(OperationLog).options(defer(OperationLog.request_data)), select(OperationLog).options(defer(OperationLog.request_data)),
filters=filters, query,
module=module,
action=action,
) )
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()) return list(session.execute(stmt).scalars())
def count_logs( def count_logs(session: Session, query: OperationLogListQuery) -> int:
session: Session,
*,
filters: LogFilters,
module: str | None = None,
action: str | None = None,
) -> int:
"""统计符合条件的日志总数,供分页使用。""" """统计符合条件的日志总数,供分页使用。"""
stmt = _apply_filters( stmt = _apply_filters(select(func.count(OperationLog.id)), query)
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def _apply_filters( def distinct_modules(session: Session) -> list[str]:
stmt: Select, """全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。
*,
filters: LogFilters, 交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。
module: str | None, """
action: str | None, stmt = select(OperationLog.module).distinct().order_by(OperationLog.module)
) -> Select: return [module for module in session.execute(stmt).scalars() if module]
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。""" """应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
stmt = apply_log_filters(stmt, OperationLog, filters) stmt = apply_log_filters(stmt, OperationLog, query.filters)
if module: if query.module:
stmt = stmt.where(OperationLog.module == module) stmt = stmt.where(OperationLog.module == query.module)
if action: if query.action:
stmt = stmt.where(OperationLog.action == action) stmt = stmt.where(OperationLog.action == query.action)
return stmt return stmt
@@ -5,18 +5,32 @@ owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。 「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
""" """
from dataclasses import dataclass
from typing import Any
from sqlalchemy import Row, func, select from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app.models import OwnershipChangeLog, Personnel 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( def list_by_ref(
session: Session, session: Session,
ref: OwnershipLogRef,
*, *,
owner_type: int,
ref_column,
ref_id: int,
offset: int, offset: int,
limit: int, limit: int,
) -> list[Row]: ) -> list[Row]:
@@ -32,7 +46,7 @@ def list_by_ref(
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name) select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id) .outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_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()) .order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@@ -40,11 +54,11 @@ def list_by_ref(
return list(session.execute(stmt).all()) 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( stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == owner_type, OwnershipChangeLog.owner_type == ref.owner_type,
ref_column == ref_id, ref.ref_column == ref.ref_id,
) )
return int(session.execute(stmt).scalar_one()) 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 sqlalchemy.orm import Session
from app.models import ParamChangeBatch, ParamDefinition, ParamValue 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( def list_history(
session: Session, session: Session, param_id: int, query: ParamHistoryQuery
param_id: int,
*,
offset: int,
limit: int,
dimension_value: str | None = None,
) -> list[tuple[ParamValue, ParamChangeBatch]]: ) -> 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( stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery())
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
)
return int(session.execute(stmt).scalar_one()) 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 批次(版本号/操作人),最新在前。""" """历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
stmt = ( stmt = (
select(ParamValue, ParamChangeBatch) 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) .where(ParamValue.param_id == param_id)
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc()) .order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
) )
if dimension_value is not None: if query.dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == dimension_value) stmt = stmt.where(ParamValue.dimension_value == query.dimension_value)
return stmt return stmt
@@ -150,41 +151,30 @@ def get_batch_by_id(
return session.get(ParamChangeBatch, batch_id) return session.get(ParamChangeBatch, batch_id)
def list_batches( def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]:
session: Session,
*,
offset: int,
limit: int,
start_date: date | None = None,
end_date: date | None = None,
) -> list[ParamChangeBatch]:
"""分页查询变更批次(按创建时间倒序)。""" """分页查询变更批次(按创建时间倒序)。"""
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date) stmt = _batch_filters(select(ParamChangeBatch), query)
return list( return list(
session.execute( session.execute(
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc()) stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
.offset(offset) .offset(query.offset)
.limit(limit) .limit(query.page_size)
).scalars() ).scalars()
) )
def count_batches( def count_batches(session: Session, query: ParamBatchQuery) -> int:
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> int:
"""统计符合条件的批次总数。""" """统计符合条件的批次总数。"""
stmt = _batch_filters( stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query)
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
)
return int(session.execute(stmt).scalar_one()) 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: if query.start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date) stmt = stmt.where(ParamChangeBatch.effective_date >= query.start_date)
if end_date is not None: if query.end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date) stmt = stmt.where(ParamChangeBatch.effective_date <= query.end_date)
return stmt return stmt
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, aliased
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
from app.repositories.query_utils import fuzzy from app.repositories.query_utils import fuzzy
from app.schemas.personnel import PersonnelQuery
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」 # 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
_manager = aliased(Personnel) _manager = aliased(Personnel)
@@ -30,44 +31,23 @@ def get_with_names(session: Session, personnel_id: int) -> Row | None:
return session.execute(stmt).first() return session.execute(stmt).first()
def list_personnel( def list_personnel(session: Session, query: PersonnelQuery) -> list[Row]:
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> list[Row]:
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。 """分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。 排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
""" """
stmt = _apply_filters( stmt = _apply_filters(_base_select(), query)
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size)
)
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).all()) return list(session.execute(stmt).all())
def count_personnel( def count_personnel(session: Session, query: PersonnelQuery) -> int:
session: Session,
*,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> int:
"""统计符合条件的人员总数,供分页使用。 """统计符合条件的人员总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由 筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。 ``_apply_filters`` 单点保证)。
""" """
stmt = _apply_filters( stmt = _apply_filters(select(func.count(Personnel.id)), query)
select(func.count(Personnel.id)),
keyword=keyword,
status=status,
business_role_id=business_role_id,
)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
@@ -154,20 +134,14 @@ def _base_select() -> Select:
) )
def _apply_filters( def _apply_filters(stmt: Select, query: PersonnelQuery) -> Select:
stmt: Select,
*,
keyword: str | None,
status: int | None,
business_role_id: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" """应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword: if query.keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, keyword) stmt = fuzzy(stmt, Personnel.personnel_name, query.keyword)
if status is not None: if query.status is not None:
stmt = stmt.where(Personnel.status == status) stmt = stmt.where(Personnel.status == query.status)
if business_role_id is not None: if query.business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == business_role_id) stmt = stmt.where(Personnel.business_role_id == query.business_role_id)
return stmt return stmt
+14 -43
View File
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import BusinessItem, Product, ShipmentItem 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() return session.execute(stmt).scalar_one_or_none()
def list_products( def list_products(session: Session, query: ProductQuery) -> list[Product]:
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> list[Product]:
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。""" """分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
stmt = _apply_filters( stmt = _apply_filters(select(Product), query)
select(Product), stmt = stmt.order_by(Product.id).offset(query.offset).limit(query.page_size)
keyword=keyword,
product_category=product_category,
status=status,
)
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
return list(session.execute(stmt).scalars()) return list(session.execute(stmt).scalars())
def count_products( def count_products(session: Session, query: ProductQuery) -> int:
session: Session,
*,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> int:
"""统计符合条件的产品总数,供分页使用。""" """统计符合条件的产品总数,供分页使用。"""
stmt = _apply_filters( stmt = _apply_filters(select(func.count(Product.id)), query)
select(func.count(Product.id)),
keyword=keyword,
product_category=product_category,
status=status,
)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def _apply_filters( def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
stmt: Select,
*,
keyword: str | None,
product_category: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。 """应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制 关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
@@ -86,17 +57,17 @@ def _apply_filters(
写 contains —— M1 business_role_repository 的双列关键词同款写法。 写 contains —— M1 business_role_repository 的双列关键词同款写法。
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。 规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
""" """
if keyword: if query.keyword:
stmt = stmt.where( stmt = stmt.where(
or_( or_(
Product.product_name.contains(keyword, autoescape=True), Product.product_name.contains(query.keyword, autoescape=True),
Product.spec_model.contains(keyword, autoescape=True), Product.spec_model.contains(query.keyword, autoescape=True),
) )
) )
if product_category: if query.product_category:
stmt = stmt.where(Product.product_category == product_category) stmt = stmt.where(Product.product_category == query.product_category)
if status is not None: if query.status is not None:
stmt = stmt.where(Product.status == status) stmt = stmt.where(Product.status == query.status)
return stmt 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: 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) return session.get(CustomerRatingGrade, grade_id)
@@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None: def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。""" """物理删除等级。
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
"""
session.delete(grade) session.delete(grade)
session.flush() session.flush()
+22 -51
View File
@@ -14,6 +14,7 @@ from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment from app.models import Business, Receipt, Shipment
from app.schemas.receipt import ReceiptQuery
def get_by_id(session: Session, receipt_id: int) -> Receipt | None: 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) return session.get(Shipment, shipment_id)
def _apply_filters( def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
stmt: Select,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> Select:
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。""" """回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
if business_id is not None: if query.business_id is not None:
stmt = stmt.where(Receipt.business_id == business_id) stmt = stmt.where(Receipt.business_id == query.business_id)
if date_from is not None: if query.date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= date_from) stmt = stmt.where(Receipt.receipt_date >= query.date_from)
if date_to is not None: if query.date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= date_to) stmt = stmt.where(Receipt.receipt_date <= query.date_to)
if is_barter is not None: if query.is_barter is not None:
stmt = stmt.where(Receipt.is_barter == is_barter) stmt = stmt.where(Receipt.is_barter == query.is_barter)
if settlement_status is not None: # settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
stmt = stmt.where(Receipt.settlement_status == settlement_status) if query.settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
return stmt return stmt
def list_receipts( def list_receipts(
session: Session, session: Session, query: ReceiptQuery
*,
offset: int,
limit: int,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> list[tuple[Receipt, str, str | None, date | None]]: ) -> list[tuple[Receipt, str, str | None, date | None]]:
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。""" """分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
stmt = _apply_filters( stmt = _apply_filters(
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date) select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
.join(Business, Business.id == Receipt.business_id) .join(Business, Business.id == Receipt.business_id)
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id), .outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
business_id=business_id, query,
date_from=date_from, )
date_to=date_to, return list(
is_barter=is_barter, session.execute(
settlement_status=settlement_status, 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( def count_receipts(session: Session, query: ReceiptQuery) -> int:
session: Session,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> int:
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。""" """回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters( stmt = _apply_filters(select(func.count()).select_from(Receipt), query)
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,
)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
@@ -389,9 +389,7 @@ def _bucket_condition(bucket: DayBucket) -> tuple:
return tuple(conds) return tuple(conds)
def list_payment_detail( def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> list:
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
) -> list:
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。 """区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。 bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
@@ -408,7 +406,7 @@ def list_payment_detail(
.where(*_payment_filters(query), *_bucket_condition(bucket)) .where(*_payment_filters(query), *_bucket_condition(bucket))
) )
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc()) stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(offset).limit(limit) stmt = stmt.offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).all()) return list(session.execute(stmt).all())
+12 -18
View File
@@ -10,6 +10,7 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Role, UserRole from app.models import 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()) return list(session.execute(stmt).scalars())
def list_roles( def list_roles(session: Session, query: RoleQuery) -> list[Role]:
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[Role]:
"""分页查询角色(管理列表:含停用角色)。 """分页查询角色(管理列表:含停用角色)。
排序与下拉口径一致(sort_order, id),保持同一套相对次序。 排序与下拉口径一致(sort_order, id),保持同一套相对次序。
""" """
stmt = _apply_filters(select(Role), keyword=keyword, status=status) stmt = _apply_filters(select(Role), query)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit) stmt = stmt.order_by(Role.sort_order, Role.id).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars()) 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()) 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 通配符)—— 关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。 角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
""" """
if keyword: if query.keyword:
stmt = stmt.where( stmt = stmt.where(
or_( or_(
Role.role_code.contains(keyword, autoescape=True), Role.role_code.contains(query.keyword, autoescape=True),
Role.role_name.contains(keyword, autoescape=True), Role.role_name.contains(query.keyword, autoescape=True),
) )
) )
if status is not None: if query.status is not None:
stmt = stmt.where(Role.status == status) stmt = stmt.where(Role.status == query.status)
return stmt return stmt
+15 -37
View File
@@ -8,13 +8,13 @@
业务级汇总仍收口 business_service._recalc_totals 业务级汇总仍收口 business_service._recalc_totals
""" """
from datetime import date
from decimal import Decimal from decimal import Decimal
from sqlalchemy import Select, delete, func, select from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment, ShipmentItem 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: 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() ).first()
def _apply_filters( def _apply_filters(stmt: Select, query: ShipmentQuery) -> Select:
stmt: Select,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> Select:
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。""" """发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
if business_id is not None: if query.business_id is not None:
stmt = stmt.where(Shipment.business_id == business_id) stmt = stmt.where(Shipment.business_id == query.business_id)
if ship_date_from is not None: if query.ship_date_from is not None:
stmt = stmt.where(Shipment.ship_date >= ship_date_from) stmt = stmt.where(Shipment.ship_date >= query.ship_date_from)
if ship_date_to is not None: if query.ship_date_to is not None:
stmt = stmt.where(Shipment.ship_date <= ship_date_to) stmt = stmt.where(Shipment.ship_date <= query.ship_date_to)
return stmt return stmt
def list_shipments( def list_shipments(session: Session, query: ShipmentQuery) -> list[tuple[Shipment, str]]:
session: Session,
*,
offset: int,
limit: int,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> list[tuple[Shipment, str]]:
"""分页查询发货单(附业务号),按 id 倒序。""" """分页查询发货单(附业务号),按 id 倒序。"""
stmt = _apply_filters( stmt = _apply_filters(
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id), select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
business_id=business_id, query,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
) )
return list( 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( def count_shipments(session: Session, query: ShipmentQuery) -> int:
session: Session,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> int:
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。""" """发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters( stmt = _apply_filters(
select(func.count()) select(func.count())
.select_from(Shipment) .select_from(Shipment)
.join(Business, Business.id == Shipment.business_id), .join(Business, Business.id == Shipment.business_id),
business_id=business_id, query,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
) )
return int(session.execute(stmt).scalar_one()) 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.models import Role, User, UserRole
from app.repositories.query_utils import fuzzy from app.repositories.query_utils import fuzzy
from app.schemas.user import UserQuery
def _base_query() -> Select[tuple[User]]: def _base_query() -> Select[tuple[User]]:
@@ -48,16 +49,7 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
# ==================== 列表查询 ==================== # ==================== 列表查询 ====================
def list_users( def list_users(session: Session, query: UserQuery) -> list[User]:
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]:
"""分页查询用户列表。 """分页查询用户列表。
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户 **按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
@@ -65,48 +57,35 @@ def list_users(
id 是自增主键,天然唯一且与创建顺序一致。 id 是自增主键,天然唯一且与创建顺序一致。
""" """
stmt = _base_query() stmt = _base_query()
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status) stmt = _apply_filters(stmt, query)
if role_code: if query.role_code:
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略 # 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code) stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit) stmt = stmt.order_by(User.id.desc()).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars().unique()) return list(session.execute(stmt).scalars().unique())
def count_users( def count_users(session: Session, query: UserQuery) -> int:
session: Session,
*,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> int:
"""统计符合条件的用户总数,供分页使用。""" """统计符合条件的用户总数,供分页使用。"""
stmt = select(func.count(User.id)) stmt = select(func.count(User.id))
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status) stmt = _apply_filters(stmt, query)
if role_code: if query.role_code:
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code) stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def _apply_filters( def _apply_filters(stmt: Select, query: UserQuery) -> Select:
stmt: Select,
*,
username: str | None,
real_name: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。 """应用通用筛选条件。列表与计数共用,避免两处口径不一致。
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍 模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。 含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
""" """
if username: if query.username:
stmt = fuzzy(stmt, User.username, username) stmt = fuzzy(stmt, User.username, query.username)
if real_name: if query.real_name:
stmt = fuzzy(stmt, User.real_name, real_name) stmt = fuzzy(stmt, User.real_name, query.real_name)
if status is not None: if query.status is not None:
stmt = stmt.where(User.status == status) stmt = stmt.where(User.status == query.status)
return stmt return stmt
+2 -1
View File
@@ -49,7 +49,8 @@ class ApprovalOrderDetailOut(ApprovalOrderOut):
steps: list[ApprovalStepOut] = Field(default_factory=list) steps: list[ApprovalStepOut] = Field(default_factory=list)
snapshot: dict[str, Any] | None = Field( snapshot: dict[str, Any] | None = Field(
default=None, description="提交时内容快照(信封:schema_version/order_type/captured_at/data)" default=None,
description="提交时内容快照(信封:schema_version/order_type/captured_at/data)",
) )
content_source: Literal["snapshot", "live", "missing"] = Field( content_source: Literal["snapshot", "live", "missing"] = Field(
default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失" default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失"
+13
View File
@@ -27,6 +27,19 @@ class BusinessRoleOut(BaseModel):
is_builtin: int = 0 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): class TemplateParamOut(BaseModel):
"""模板声明的参数(供岗位表单展示,含当前口径说明)。""" """模板声明的参数(供岗位表单展示,含当前口径说明)。"""
+23 -1
View File
@@ -1,6 +1,7 @@
"""客户管理的请求/响应模型(需求 2.1.3)。""" """客户管理的请求/响应模型(需求 2.1.3)。"""
from datetime import datetime from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -28,6 +29,8 @@ class CustomerOut(BaseModel):
owner_personnel_id: int | None = None owner_personnel_id: int | None = None
owner_personnel_name: str | None = None owner_personnel_name: str | None = None
remark: str | None = None remark: str | None = None
rating_score: int | None = None
rating_time: datetime | None = None
class CustomerCreateIn(BaseModel): class CustomerCreateIn(BaseModel):
@@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel):
class CustomerQuery(PageQuery): class CustomerQuery(PageQuery):
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。""" """客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
有意义的排序键,白名单足够。
"""
keyword: str | None = Field( keyword: str | None = Field(
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配" default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
) )
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选") customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选") 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): class CustomerOwnershipIn(BaseModel):
+42 -5
View File
@@ -1,9 +1,13 @@
"""工作台的请求/响应模型(M4 ⑧)。 """工作台的请求/响应模型(M4 ⑧)。
四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返): 聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返):
经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal 经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal
(序列化为字符串),前端不做数值运算。 (序列化为字符串),前端不做数值运算。
**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合
响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策); 待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级, ⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
不返回伪造的 0 条。 不返回伪造的 0 条。
@@ -12,7 +16,9 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class TrendPoint(BaseModel): class TrendPoint(BaseModel):
@@ -116,11 +122,42 @@ class ActivityItem(BaseModel):
create_time: datetime 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): class DashboardOut(BaseModel):
"""工作台四区块一次性响应。""" """工作台三区块一次性响应(最近动态走独立分页端点)。"""
month: str month: str
overview: OverviewBlock overview: OverviewBlock
todos: TodoBlock todos: TodoBlock
warnings: WarningBlock warnings: WarningBlock
activities: list[ActivityItem] = Field(default_factory=list)
+3 -1
View File
@@ -264,7 +264,9 @@ class DepartmentReconciliation(BaseModel):
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)", description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
) )
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)") adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
payable_total: Decimal = Field(description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额") payable_total: Decimal = Field(
description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额"
)
team_base_total: Decimal = Field(description="展示团队基数合计") team_base_total: Decimal = Field(description="展示团队基数合计")
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计") unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
other_team_base: Decimal = Field( other_team_base: Decimal = Field(
+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
+2
View File
@@ -37,12 +37,14 @@ def _record(
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。""" """写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
login_log_repository.add( login_log_repository.add(
session, session,
login_log_repository.LoginLogEntry(
user_id=user_id, user_id=user_id,
username=username, username=username,
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
success=success, success=success,
fail_reason=fail_reason, fail_reason=fail_reason,
),
) )
session.commit() 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.repositories import business_role_repository
from app.schemas.business_role import ( from app.schemas.business_role import (
BusinessRoleCreateIn, BusinessRoleCreateIn,
BusinessRoleOptionOut,
BusinessRoleOut, BusinessRoleOut,
BusinessRoleQuery, BusinessRoleQuery,
BusinessRoleUpdateIn, BusinessRoleUpdateIn,
@@ -67,16 +68,8 @@ def _ensure_template(calc_template: str) -> None:
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]: def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
"""分页查询岗位(含停用)。""" """分页查询岗位(含停用)。"""
roles = business_role_repository.list_roles( roles = business_role_repository.list_roles(session, query)
session, total = business_role_repository.count_roles(session, query)
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
)
return PageResult[BusinessRoleOut]( return PageResult[BusinessRoleOut](
records=[_to_out(role) for role in roles], records=[_to_out(role) for role in roles],
total=total, 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: def get_role(session: Session, role_id: int) -> BusinessRoleOut:
"""岗位详情。 """岗位详情。
+166 -109
View File
@@ -15,6 +15,8 @@
""" """
import logging import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date from datetime import date
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction from fractions import Fraction
@@ -234,26 +236,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]: def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
"""分页查询业务(附客户名与归属人名)。""" """分页查询业务(附客户名与归属人名)。"""
rows = business_repository.list_businesses( rows = business_repository.list_businesses(session, query)
session, total = business_repository.count_businesses(session, query)
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,
)
return PageResult[BusinessOut]( return PageResult[BusinessOut](
records=[_to_out(row) for row in rows], records=[_to_out(row) for row in rows],
total=total, total=total,
@@ -748,6 +732,67 @@ def change_status(
return out 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( def change_ownership(
session: Session, session: Session,
business_id: int, business_id: int,
@@ -779,41 +824,12 @@ def change_ownership(
if row is None: if row is None:
raise NotFoundError("业务不存在") raise NotFoundError("业务不存在")
business, _, old_owner_name, old_assistant_name = row business, _, old_owner_name, old_assistant_name = row
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前 resolution = _resolve_business_ownership(session, business, payload)
# 固化标量,新归属人名必须按新 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
)
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.owner_personnel_id = payload.new_owner_personnel_id
business.assistant_personnel_id = payload.new_assistant_personnel_id business.assistant_personnel_id = payload.new_assistant_personnel_id
business_repository.add_ownership_log( business_repository.add_ownership_log(
session, session, _business_ownership_log(business, payload, resolution, operator)
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,
),
) )
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名 # 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照 # 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
@@ -821,17 +837,19 @@ def change_ownership(
( (
business, business,
row[1], row[1],
new_owner.personnel_name if new_owner is not None else old_owner_name, resolution.owner.personnel_name if resolution.owner is not None else old_owner_name,
new_assistant.personnel_name if new_assistant is not None else old_assistant_name, resolution.assistant.personnel_name
if resolution.assistant is not None
else old_assistant_name,
) )
) )
session.commit() session.commit()
logger.info( logger.info(
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s", "调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
business_id, business_id,
old_owner_id, resolution.old_owner_id,
payload.new_owner_personnel_id, payload.new_owner_personnel_id,
old_assistant_id, resolution.old_assistant_id,
payload.new_assistant_personnel_id, payload.new_assistant_personnel_id,
) )
return out return out
@@ -897,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: def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。 """毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
@@ -915,64 +1021,15 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
def threshold_of(category: str) -> Fraction: def threshold_of(category: str) -> Fraction:
return snapshot.fraction("product.threshold", product_category=category) return snapshot.fraction("product.threshold", product_category=category)
rows_out: list[GrossProfitRowOut] = [] scan = _scan_gross_profit_rows(session, business_id, threshold_of)
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))
categories = [ categories = [
GrossProfitCategoryOut( _gross_profit_category(category, sale, cost, threshold_of(category))
product_category=category, for category, [sale, cost] in scan.category_agg.items()
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()
] ]
return GrossProfitOut( return GrossProfitOut(
business_id=business_id, business_id=business_id,
as_of=as_of, as_of=as_of,
rows=rows_out, rows=scan.rows,
categories=categories, categories=categories,
missing_count=missing_count, missing_count=scan.missing_count,
) )
+14 -7
View File
@@ -60,7 +60,7 @@ from app.services.param_service import parse_fraction, resolve_snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记) # snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算" _NOTE_MANAGER_PENDING = "经理个人户单笔不计提(未计入分成基数):绩效在月结时按团队与全公司分成计算"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -544,7 +544,7 @@ def _personal_rate(
if customer_type == "enterprise": if customer_type == "enterprise":
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线) # 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000 rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
return _RateDecision(rate, "企业户固定计提比例", None, False) return _RateDecision(rate, "企业户:不按回款天数,统一按固定比例计提", None, False)
template = role.calc_template if role else "TPL_SALESPERSON_DECAY" template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
if template == "TPL_MANAGER_SHARE": if template == "TPL_MANAGER_SHARE":
@@ -554,7 +554,7 @@ def _personal_rate(
if snapshot.get("manager.personal.included") != "1": if snapshot.get("manager.personal.included") != "1":
return _RateDecision( return _RateDecision(
Decimal("0"), Decimal("0"),
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0", "经理个人户:绩效按团队与全公司分成在月结时计算,单笔不计提",
None, None,
True, True,
) )
@@ -563,12 +563,12 @@ def _personal_rate(
floor = parse_fraction(snapshot.get("salesperson.rate.floor")) floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary"))) boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary) rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天" note = f"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰"
return _RateDecision(rate, note, None, False) return _RateDecision(rate, note, None, False)
if template == "TPL_INFO_SALES_FIXED": if template == "TPL_INFO_SALES_FIXED":
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线 # 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
rate = parse_fraction(snapshot.get("info.rate")) / 1000 rate = parse_fraction(snapshot.get("info.rate")) / 1000
return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False) return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False)
if template == "TPL_SALESPERSON_DECAY": if template == "TPL_SALESPERSON_DECAY":
start = parse_fraction(snapshot.get("salesperson.rate.start")) start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope")) slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
@@ -577,9 +577,16 @@ def _personal_rate(
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损) # 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary"))) boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary) rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)" note = (
f"回款 {days} 天,从起始 {start}‰ 按日递减"
f"(满 {boundary} 天后固定按 {floor}‰ 计算)"
)
return _RateDecision(rate, note, None, False) return _RateDecision(rate, note, None, False)
raise BadRequestError(f"公式模板 {template} 暂不支持核算") # 提示给模板中文名而非编码:业务人员在岗位角色页看到的是「营销经理·团队与全公司分成」
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): def _role_of(session: Session, person):
+129 -47
View File
@@ -14,6 +14,8 @@
""" """
import logging import logging
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import Row from sqlalchemy import Row
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -37,6 +39,7 @@ from app.schemas.customer import (
CustomerOut, CustomerOut,
CustomerOwnershipIn, CustomerOwnershipIn,
CustomerQuery, CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn, CustomerUpdateIn,
OwnershipChangeLogOut, OwnershipChangeLogOut,
) )
@@ -60,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
owner_personnel_id=customer.owner_personnel_id, owner_personnel_id=customer.owner_personnel_id,
owner_personnel_name=owner_name, owner_personnel_name=owner_name,
remark=customer.remark, 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]: def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
"""分页查询客户(附归属人员姓名)。""" """分页查询客户(附归属人员姓名)。"""
rows = customer_repository.list_customers( rows = customer_repository.list_customers(session, query)
session, total = customer_repository.count_customers(session, query)
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,
)
return PageResult[CustomerOut]( return PageResult[CustomerOut](
records=[_to_out(customer, owner_name) for customer, owner_name in rows], records=[_to_out(customer, owner_name) for customer, owner_name in rows],
total=total, total=total,
@@ -274,6 +267,53 @@ def update_customer(
return out 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( def delete_customer(
session: Session, session: Session,
customer_id: int, customer_id: int,
@@ -317,6 +357,71 @@ def delete_customer(
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name) 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( def change_ownership(
session: Session, session: Session,
customer_id: int, customer_id: int,
@@ -335,57 +440,34 @@ def change_ownership(
""" """
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在 # 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
# 上下文内进行,失败同样要留下可追溯的记录 # 上下文内进行,失败同样要留下可追溯的记录
row = customer_repository.get_with_owner_name(session, customer_id) prefetch = _prefetch_customer_ownership(session, customer_id, payload)
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}"
with audit_service.audit( with audit_service.audit(
session, session,
operator=operator, operator=operator,
record=AuditRecord( record=AuditRecord(
module=MODULE_CUSTOMER, module=MODULE_CUSTOMER,
action=ACTION_UPDATE, 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), request_data=payload.model_dump(exclude_none=True),
), ),
meta=meta, meta=meta,
): ):
if row is None: if prefetch.row is None:
raise NotFoundError("客户不存在") raise NotFoundError("客户不存在")
customer, _ = row customer, _ = prefetch.row
if payload.new_owner_personnel_id is not None: old_owner_id = _validate_customer_ownership(session, customer, payload)
_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("归属未变化")
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring) # 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
customer.owner_personnel_id = payload.new_owner_personnel_id customer.owner_personnel_id = payload.new_owner_personnel_id
customer_repository.add_ownership_log( customer_repository.add_ownership_log(
session, session, _customer_ownership_log(customer, payload, old_owner_id, operator)
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,
),
) )
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准 # 响应在 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() session.commit()
logger.info( logger.info(
"调整客户归属 | customer_id=%s | old=%s | new=%s", "调整客户归属 | customer_id=%s | old=%s | new=%s",
+38 -26
View File
@@ -1,4 +1,4 @@
"""工作台服务(M4 ⑧):四区块实时聚合编排。 """工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。
设计决策(2026-10-19): 设计决策(2026-10-19):
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合, - **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
@@ -9,6 +9,8 @@
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以 - **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
``sync_available=false`` 显式降级,不返回伪造的 0 条 ``sync_available=false`` 显式降级,不返回伪造的 0 条
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照 - 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
""" """
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
@@ -20,22 +22,23 @@ from app.core.exceptions import BadRequestError
from app.core.month import validate_month from app.core.month import validate_month
from app.repositories import dashboard_repository, operation_log_repository, user_repository from app.repositories import dashboard_repository, operation_log_repository, user_repository
from app.repositories.query_utils import LogFilters from app.repositories.query_utils import LogFilters
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
from app.services import approval_service from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页 # 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5 _TODO_LIMIT = 5
_WARNING_LIMIT = 20 _WARNING_LIMIT = 20
_ACTIVITY_LIMIT = 20
_TREND_MONTHS = 6 _TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"} _ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict: def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。 """三区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单); 待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。 风险预警仍是全局经营视角,不按人裁剪。
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
""" """
resolved = _normalize_month(month) resolved = _normalize_month(month)
warning_params = _warning_params(session) warning_params = _warning_params(session)
@@ -47,7 +50,6 @@ def get_dashboard(session: Session, month: str | None = None, *, current_user) -
"overview": _overview(session, resolved), "overview": _overview(session, resolved),
"todos": _todos(session, pending_statuses), "todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params), "warnings": _warnings(session, warning_params),
"activities": _activities(session),
} }
@@ -112,8 +114,8 @@ def _overview(session: Session, month: str) -> dict:
for item in months for item in months
], ],
"note": ( "note": (
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月" "绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);"
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单" "回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单"
), ),
} }
@@ -168,9 +170,7 @@ def _warning_params(session: Session) -> dict:
return { return {
"enabled": snapshot.get("warning.enabled") == "1", "enabled": snapshot.get("warning.enabled") == "1",
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))), "receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
"approval_timeout_days": int( "approval_timeout_days": int(parse_fraction(snapshot.get("warning.approval.timeout.days"))),
parse_fraction(snapshot.get("warning.approval.timeout.days"))
),
} }
@@ -249,26 +249,38 @@ def _warnings(session: Session, params: dict) -> dict:
} }
def _activities(session: Session) -> list[dict]: def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。""" """最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
logs = operation_log_repository.list_logs(
session, 模块筛选项与列表同源返回(全量模块名,不随当前页变化),
offset=0, 前端模块下拉不必另取一次全量。
limit=_ACTIVITY_LIMIT, """
repo_query = operation_log_repository.OperationLogListQuery(
filters=LogFilters(), 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,
) )
return [
{
"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 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: def _dec(value) -> Decimal | None:
+8 -12
View File
@@ -22,18 +22,15 @@ from app.schemas.log import (
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]: def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。""" """分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
filters = _filters_from(query) repo_query = operation_log_repository.OperationLogListQuery(
logs = operation_log_repository.list_logs( filters=_filters_from(query),
session,
offset=query.offset, offset=query.offset,
limit=query.page_size, limit=query.page_size,
filters=filters,
module=query.module, module=query.module,
action=query.action, action=query.action,
) )
total = operation_log_repository.count_logs( logs = operation_log_repository.list_logs(session, repo_query)
session, filters=filters, module=query.module, action=query.action total = operation_log_repository.count_logs(session, repo_query)
)
return PageResult[OperationLogOut]( return PageResult[OperationLogOut](
records=[OperationLogOut.model_validate(log) for log in logs], records=[OperationLogOut.model_validate(log) for log in logs],
total=total, 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]: def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
"""分页查询登录日志。""" """分页查询登录日志。"""
filters = _filters_from(query) repo_query = login_log_repository.LoginLogListQuery(
logs = login_log_repository.list_logs( filters=_filters_from(query),
session,
offset=query.offset, offset=query.offset,
limit=query.page_size, limit=query.page_size,
filters=filters,
ip=query.ip, 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]( return PageResult[LoginLogOut](
records=[LoginLogOut.model_validate(log) for log in logs], records=[LoginLogOut.model_validate(log) for log in logs],
total=total, total=total,
+26 -42
View File
@@ -3,6 +3,10 @@
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义 注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。 一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。 值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**:
说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、
内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。
""" """
from dataclasses import dataclass from dataclasses import dataclass
@@ -43,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10", default_value="10",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)", description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。",
sort_order=1, sort_order=1,
), ),
ParamSpec( ParamSpec(
@@ -57,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4/360", default_value="4/360",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储", description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。",
sort_order=2, sort_order=2,
), ),
ParamSpec( ParamSpec(
@@ -71,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4", default_value="4",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)", description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。",
sort_order=3, sort_order=3,
), ),
ParamSpec( ParamSpec(
@@ -85,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="360", default_value="360",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限", description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。",
sort_order=4, sort_order=4,
), ),
ParamSpec( ParamSpec(
@@ -99,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="12.5", default_value="12.5",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="∑本人带领业务员绩效 × 此比例(限本团队)", description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。",
sort_order=5, sort_order=5,
), ),
ParamSpec( ParamSpec(
@@ -113,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="5", default_value="5",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="∑全部业务员绩效 × 此比例(全公司)", description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。",
sort_order=6, sort_order=6,
), ),
ParamSpec( ParamSpec(
@@ -127,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3", default_value="3",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰", description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。",
sort_order=7, sort_order=7,
), ),
ParamSpec( ParamSpec(
@@ -141,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="信息员绩效 = 本人销售额 × 此比例", description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。",
sort_order=8, sort_order=8,
), ),
ParamSpec( ParamSpec(
@@ -155,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10", default_value="10",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="预付款统一按此比例计提", description="预付款到账即按这个比例计提绩效,不必等到全额回款。",
sort_order=9, sort_order=9,
), ),
ParamSpec( ParamSpec(
@@ -169,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="80", default_value="80",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="以物抵顶回款绩效按应发绩效的此比例折算", description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。",
sort_order=10, sort_order=10,
), ),
ParamSpec( ParamSpec(
@@ -183,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="60", default_value="60",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计", description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。",
sort_order=11, sort_order=11,
), ),
ParamSpec( ParamSpec(
@@ -197,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="FULL", default_value="FULL",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)", description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。",
sort_order=12, sort_order=12,
), ),
ParamSpec( ParamSpec(
@@ -211,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="30", default_value="30",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%", description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。",
sort_order=13, sort_order=13,
), ),
ParamSpec( ParamSpec(
@@ -225,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="INCLUDE", default_value="INCLUDE",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计", description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。",
sort_order=14, sort_order=14,
), ),
ParamSpec( ParamSpec(
@@ -239,7 +243,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="18000", default_value="18000",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="试用期年累计绩效超过此值部分年底一次性结算", description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。",
sort_order=15, sort_order=15,
), ),
ParamSpec( ParamSpec(
@@ -253,11 +257,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min=None, value_min=None,
value_max=None, value_max=None,
description=( description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。",
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
"1=提交后进入财务复核→领导审核审批流。"
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
),
sort_order=17, sort_order=17,
), ),
ParamSpec( ParamSpec(
@@ -271,11 +271,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min=None, value_min=None,
value_max=None, value_max=None,
description=( description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。",
"月度结算审批链开关(④ 月结提交时消费):"
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
),
sort_order=18, sort_order=18,
), ),
ParamSpec( ParamSpec(
@@ -289,11 +285,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="0", default_value="0",
value_min=None, value_min=None,
value_max=None, value_max=None,
description=( description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。",
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
"默认值业务未明确,暂按直接生效,业务可开启)"
),
sort_order=19, sort_order=19,
), ),
ParamSpec( ParamSpec(
@@ -307,11 +299,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="0", default_value="0",
value_min=None, value_min=None,
value_max=None, value_max=None,
description=( description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。",
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
"0=不计入(默认,暂口径) 1=计入"
),
sort_order=20, sort_order=20,
), ),
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)===== # ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
@@ -327,7 +315,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)", description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。",
sort_order=21, sort_order=21,
), ),
ParamSpec( ParamSpec(
@@ -341,9 +329,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="90", default_value="90",
value_min="1", value_min="1",
value_max=None, value_max=None,
description=( description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。",
"发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"
),
sort_order=22, sort_order=22,
), ),
ParamSpec( ParamSpec(
@@ -357,9 +343,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3", default_value="3",
value_min="1", value_min="1",
value_max=None, value_max=None,
description=( description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。",
"待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"
),
sort_order=23, sort_order=23,
), ),
] ]
+160 -84
View File
@@ -6,7 +6,7 @@ ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日
""" """
import logging import logging
from collections.abc import Iterable from collections.abc import Callable, Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
@@ -44,7 +44,7 @@ from app.schemas.param import (
ParamPreviewOut, ParamPreviewOut,
ParamUsedOut, 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.audit_service import AuditRecord, RequestMeta
from app.services.formula import to_decimal from app.services.formula import to_decimal
from app.services.no_generator import next_no 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) definition = param_repository.get_definition_by_code(session, param_code)
if definition is None: if definition is None:
raise NotFoundError("参数不存在") raise NotFoundError("参数不存在")
rows = param_repository.list_history( rows = param_repository.list_history(session, definition.id, query)
session, total = param_repository.count_history(session, definition.id, query)
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
)
return PageResult[ParamHistoryOut]( return PageResult[ParamHistoryOut](
records=[ records=[
ParamHistoryOut( ParamHistoryOut(
@@ -410,16 +402,8 @@ def get_history(
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]: def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
"""变更批次分页(按创建时间倒序)。""" """变更批次分页(按创建时间倒序)。"""
batches = param_repository.list_batches( batches = param_repository.list_batches(session, query)
session, total = param_repository.count_batches(session, query)
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
)
return PageResult[ParamBatchOut]( return PageResult[ParamBatchOut](
records=[ records=[
ParamBatchOut( ParamBatchOut(
@@ -509,42 +493,89 @@ def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDi
return out return out
def _preview_rate( @dataclass(frozen=True, slots=True)
session: Session, class _PreviewContext:
payload: ParamPreviewIn, """预览上下文(会话、入参、参数取值闭包)。
snapshot: ParamSnapshot,
days: int, ``take`` 闭包同时负责取数与记录「用了哪些参数」——**调用顺序即
take, ``params_used`` 的展示顺序**,拆分时不得调换各段的先后。
) -> tuple[Fraction, str, Decimal | None]: """
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 表示该模板的绩效基数不是折算毛利润额(如信息员按 基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
「本人销售额」计提),预览层直接采用并跳过折算与阈值。 「本人销售额」计提),预览层直接采用并跳过折算与阈值。
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。 聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
""" """
payload = ctx.payload
take = ctx.take
if payload.customer_type == "enterprise": if payload.customer_type == "enterprise":
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount # 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线) # 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
rate = take("enterprise.rate") / 1000 rate = take("enterprise.rate") / 1000
return rate, "企业户固定计提比例", None return rate, "企业户:不按回款天数,统一按固定比例计提", None
role_code = payload.business_role role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY" template = "TPL_SALESPERSON_DECAY"
if role_code: 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: if role is None:
raise BadRequestError(f"岗位 {role_code!r} 不存在") raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
template = role.calc_template template = role.calc_template
if template == "TPL_MANAGER_SHARE": if template == "TPL_MANAGER_SHARE":
raise BadRequestError( raise BadRequestError(
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览" "营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
) )
if template == "TPL_INFO_SALES_FIXED": if template == "TPL_INFO_SALES_FIXED":
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍) # ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
rate = take("info.rate") / 1000 rate = take("info.rate") / 1000
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数 # 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue) return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY": if template == "TPL_SALESPERSON_DECAY":
start = take("salesperson.rate.start") start = take("salesperson.rate.start")
slope = take("salesperson.rate.slope") slope = take("salesperson.rate.slope")
@@ -553,12 +584,84 @@ def _preview_rate(
rate = formula.salesperson_rate( rate = formula.salesperson_rate(
days, start=start, slope=slope, floor=floor, boundary_days=boundary days, start=start, slope=slope, floor=floor, boundary_days=boundary
) )
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
return ( return (
rate, rate,
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)", f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
None, 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: def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
@@ -566,7 +669,6 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
# 参数按回款到账日取值(对照表 #11) # 参数按回款到账日取值(对照表 #11)
snapshot = resolve_snapshot(session, payload.receipt_date) snapshot = resolve_snapshot(session, payload.receipt_date)
revenue = Decimal(payload.revenue) revenue = Decimal(payload.revenue)
profit = Decimal(payload.profit)
received = Decimal(payload.received_amount) received = Decimal(payload.received_amount)
used: list[ParamUsedOut] = [] used: list[ParamUsedOut] = []
@@ -589,55 +691,29 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
) )
return parse_fraction(raw) return parse_fraction(raw)
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES: ctx = _PreviewContext(session=session, payload=payload, take=take)
raise BadRequestError( # 各段顺序固定 —— take 的调用顺序即 params_used 的展示顺序
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit" decision = _preview_rate_decision(ctx)
) earning = _preview_earning(ctx, decision)
barter = _preview_barter(ctx, earning.performance)
days = formula.payment_days(payload.ship_date, payload.receipt_date) payment = _preview_payment(ctx, received, revenue, barter.base_amount)
rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take) probation_note = _preview_probation_note(ctx)
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 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
weight_r = formula.clamp_weight_r(received, revenue)
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)} 元部分年底一次性结算"
return ParamPreviewOut( return ParamPreviewOut(
payment_days=days, payment_days=decision.days,
rate=str(rate), rate=str(decision.rate),
rate_text=rate_text, rate_text=decision.rate_text,
profit_rate=str(profit_rate), profit_rate=str(earning.profit_rate),
converted_profit=str(converted), converted_profit=str(earning.converted),
performance_amount=str(performance), performance_amount=str(earning.performance),
barter_applied=barter_applied, barter_applied=barter.applied,
performance_after_barter=str(after_barter) if after_barter is not None else None, performance_after_barter=(
start_payment_ok=start_ok, str(barter.after_barter) if barter.after_barter is not None else None
payment_weight_r=weight_r, ),
payment_weight=str(weight), start_payment_ok=payment.start_ok,
payable_amount=str(payable), payment_weight_r=payment.weight_r,
payment_weight=str(payment.weight),
payable_amount=str(payment.payable),
probation_note=probation_note, probation_note=probation_note,
params_used=used, 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]: def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
"""分页查询人员(含离职)。""" """分页查询人员(含离职)。"""
rows = personnel_repository.list_personnel( rows = personnel_repository.list_personnel(session, query)
session, total = personnel_repository.count_personnel(session, query)
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,
)
return PageResult[PersonnelOut]( return PageResult[PersonnelOut](
records=[ records=[
_to_out(personnel, role_name, manager_name) _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]: def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
"""分页查询产品(含停用)。""" """分页查询产品(含停用)。"""
products = product_repository.list_products( products = product_repository.list_products(session, query)
session, total = product_repository.count_products(session, query)
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,
)
return PageResult[ProductOut]( return PageResult[ProductOut](
records=[_to_out(product) for product in products], records=[_to_out(product) for product in products],
total=total, total=total,
+59 -51
View File
@@ -23,6 +23,7 @@
""" """
import logging import logging
from dataclasses import dataclass
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
@@ -39,7 +40,7 @@ from app.core.constants import (
from app.core.db_errors import is_duplicate_entry from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult 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.business import BUSINESS_STATUS_ACTIVE
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
from app.repositories import business_repository, receipt_repository from app.repositories import business_repository, receipt_repository
@@ -129,24 +130,8 @@ def _ensure_business_writable(session: Session, business_id: int, action: str):
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]: def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。""" """分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
rows = receipt_repository.list_receipts( rows = receipt_repository.list_receipts(session, query)
session, total = receipt_repository.count_receipts(session, query)
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,
)
return PageResult[ReceiptOut]( return PageResult[ReceiptOut](
records=[_to_out(row) for row in rows], records=[_to_out(row) for row in rows],
total=total, total=total,
@@ -166,6 +151,57 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
return _to_out(row) 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( def create_receipt(
session: Session, session: Session,
payload: ReceiptCreateIn, payload: ReceiptCreateIn,
@@ -206,35 +242,7 @@ def create_receipt(
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归 # 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
for attempt in range(_NO_RETRY + 1): for attempt in range(_NO_RETRY + 1):
try: try:
if business is None: created = _create_receipt_once(session, payload, business, batch_no)
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))
new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM
session.commit() session.commit()
break break
except IntegrityError as exc: except IntegrityError as exc:
@@ -249,11 +257,11 @@ def create_receipt(
logger.info( logger.info(
"新增回款 | receipt_id=%s | no=%s | business_id=%s", "新增回款 | receipt_id=%s | no=%s | business_id=%s",
new_id, created.receipt_id,
new_no, created.batch_no,
payload.business_id, payload.business_id,
) )
return out return created.out
def update_receipt( def update_receipt(
+9 -8
View File
@@ -36,7 +36,6 @@ _DATE_FORMAT = "YYYY-MM-DD"
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t") _FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _Column: class _Column:
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。""" """列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
@@ -403,7 +402,13 @@ def _write_department_document(
company_share_count = int(company.get("company_share_count") or 0) company_share_count = int(company.get("company_share_count") or 0)
_write_document_header( _write_document_header(
sheet, len(_DEPARTMENT_WIDTHS), company_name, title, filters, template_version, operator_name sheet,
len(_DEPARTMENT_WIDTHS),
company_name,
title,
filters,
template_version,
operator_name,
) )
_write_block( _write_block(
@@ -439,16 +444,12 @@ def _write_department_document(
"manager_name": "合计", "manager_name": "合计",
"member_count": sum(team["member_count"] for team in teams), "member_count": sum(team["member_count"] for team in teams),
"team_base": _sum_amount(team["team_base"] for team in teams), "team_base": _sum_amount(team["team_base"] for team in teams),
"team_share_amount": _sum_amount( "team_share_amount": _sum_amount(team["team_share_amount"] for team in teams),
team["team_share_amount"] for team in teams
),
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams), "team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导) # 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
"frozen_amount": ( "frozen_amount": (
_sum_amount( _sum_amount(
team["frozen_amount"] team["frozen_amount"] for team in teams if team["frozen_amount"] is not None
for team in teams
if team["frozen_amount"] is not None
) )
if any(team["frozen_amount"] is not None for team in teams) if any(team["frozen_amount"] is not None for team in teams)
else None else None
+9 -30
View File
@@ -103,10 +103,7 @@ def _person_row(row: PerformanceLedger, role_names: dict[str, str]) -> dict:
def _role_names(session) -> dict[str, str]: def _role_names(session) -> dict[str, str]:
"""岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。""" """岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。"""
return { return {r.role_code: r.role_name for r in session.execute(select(BusinessRole)).scalars()}
r.role_code: r.role_name
for r in session.execute(select(BusinessRole)).scalars()
}
def _dec(value) -> Decimal | None: def _dec(value) -> Decimal | None:
@@ -178,11 +175,7 @@ def department_summary(session, query) -> dict:
extra_ids = { extra_ids = {
target target
for pid in adjustments for pid in adjustments
if ( if (target := _adjustment_target(pid, member_map, manager_of, manager_role, include_mp))
target := _adjustment_target(
pid, member_map, manager_of, manager_role, include_mp
)
)
is not None is not None
} - set(manager_ids) } - set(manager_ids)
if extra_ids: if extra_ids:
@@ -193,9 +186,7 @@ def department_summary(session, query) -> dict:
for team in teams_all for team in teams_all
if query.manager_id is None or team["manager_id"] == query.manager_id if query.manager_id is None or team["manager_id"] == query.manager_id
] ]
unassigned_all = _unassigned_rows( unassigned_all = _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp)
member_map, manager_of, manager_role, adjustments, include_mp
)
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关 # 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
unassigned = [] if query.manager_id is not None else unassigned_all unassigned = [] if query.manager_id is not None else unassigned_all
company_share_amount = _company_share(company_base, share.company_pct) company_share_amount = _company_share(company_base, share.company_pct)
@@ -335,9 +326,7 @@ def _reconciliation(
(m["adjust_net"] for team in teams for m in team["members"]), Decimal("0") (m["adjust_net"] for team in teams for m in team["members"]), Decimal("0")
) + sum((row["adjust_net"] for row in unassigned), Decimal("0")) ) + sum((row["adjust_net"] for row in unassigned), Decimal("0"))
team_base_total = sum((team["team_base"] for team in teams), Decimal("0")) team_base_total = sum((team["team_base"] for team in teams), Decimal("0"))
unassigned_performance = sum( unassigned_performance = sum((row["performance_sum"] for row in unassigned), Decimal("0"))
(row["performance_sum"] for row in unassigned), Decimal("0")
)
payable_total = ( payable_total = (
team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total
) )
@@ -378,9 +367,7 @@ def _reconciliation(
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal: def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。""" """12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
return manager_team_share( return manager_team_share(team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0))
team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0)
)
def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal: def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal:
@@ -483,12 +470,8 @@ def _team_relations(
referenced = {p.manager_id for p in people if p.manager_id is not None} referenced = {p.manager_id for p in people if p.manager_id is not None}
missing = referenced - seed missing = referenced - seed
if missing: if missing:
people.extend( people.extend(session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars())
session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars() role_by_id = {r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()}
)
role_by_id = {
r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()
}
manager_of = {p.id: p.manager_id for p in people} manager_of = {p.id: p.manager_id for p in people}
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people} manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
person_info = { person_info = {
@@ -619,9 +602,7 @@ def _member_rows(
) )
) )
personal_in_team = ( personal_in_team = (
pid == manager_id pid == manager_id and include_manager_personal and member["has_manager_personal"]
and include_manager_personal
and member["has_manager_personal"]
) )
if personal_in_team: if personal_in_team:
rows.append( rows.append(
@@ -893,9 +874,7 @@ def _group_rows(rows) -> list[dict]:
def payment_detail(session, query) -> dict: def payment_detail(session, query) -> dict:
"""区间下钻分页(bucket 未注册 → 400)。""" """区间下钻分页(bucket 未注册 → 400)。"""
bucket = _bucket_by_code(query.bucket) bucket = _bucket_by_code(query.bucket)
rows = report_repository.list_payment_detail( rows = report_repository.list_payment_detail(session, query, bucket)
session, query, bucket, offset=query.offset, limit=query.page_size
)
return { return {
"list": [_payment_row(r) for r in rows], "list": [_payment_row(r) for r in rows],
"total": report_repository.count_payment_detail(session, query, bucket), "total": report_repository.count_payment_detail(session, query, bucket),
+2 -8
View File
@@ -68,14 +68,8 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**, 与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
下拉只给启用项。 下拉只给启用项。
""" """
roles = role_repository.list_roles( roles = role_repository.list_roles(session, query)
session, total = role_repository.count_roles(session, query)
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)
counts = role_repository.count_users_by_role(session, [role.id for role in roles]) counts = role_repository.count_users_by_role(session, [role.id for role in roles])
return PageResult[RoleManageOut]( return PageResult[RoleManageOut](
records=[_to_out(role, counts.get(role.id, 0)) for role in roles], records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
+3 -2
View File
@@ -423,9 +423,10 @@ def _aggregate_row(
"members": member_names, "members": member_names,
"params_as_of": str(share.param_date), "params_as_of": str(share.param_date),
"manager_personal_included": 1 if share.include_manager_personal else 0, "manager_personal_included": 1 if share.include_manager_personal else 0,
# 说明写给业务人员看:说清这一笔的基数是怎么凑出来的
"note": ( "note": (
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}" f"团队分成:基数{'含' if share.include_manager_personal else '不含'}"
"经理个人户业绩;9.8 方案公式" "经理本人名下业绩,按本团队与全公司两级比例合计计提"
), ),
}, },
), ),
+2 -14
View File
@@ -157,20 +157,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]: def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。""" """分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
rows = shipment_repository.list_shipments( rows = shipment_repository.list_shipments(session, query)
session, total = shipment_repository.count_shipments(session, query)
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,
)
return PageResult[ShipmentOut]( return PageResult[ShipmentOut](
records=[_to_out(row) for row in rows], records=[_to_out(row) for row in rows],
total=total, total=total,
+23
View File
@@ -0,0 +1,23 @@
# 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+ 账套,查询文件随部署交付。
应用与兼容性脚本共用 `Settings.t_plus_database_url` 和连接文件校验;分号、花括号由 SQLAlchemy ODBC 方言转义。默认启用加密,失败不自动降级;既有私有配置需要按运行手册核对。管理页面不返回原始数据,但暂存 JSON 的生产敏感字段保护仍待 S1 决策。
## 验收限制
当前只写 MySQL 暂存与管理表,不写工资业务表、不推进时间水位。关联、成本、日期和负向事件的业务规则待客户确认。开发验收先以备份源 10.50.1600 为基准,生产完整版本后补;2022 副本结果不能替代该基准的实测。
+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
+123
View File
@@ -0,0 +1,123 @@
"""T+ 原始行 staging 服务。"""
import hashlib
import json
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import insert, select, tuple_
from sqlalchemy.orm import Session
from app.models import TPlusSyncRecord
def _json_default(value: Any) -> str:
"""将日期、Decimal 等源字段转换为稳定 JSON 文本。"""
if isinstance(value, (date, datetime)):
return value.isoformat()
if isinstance(value, Decimal):
return str(value)
if isinstance(value, (bytes, bytearray, memoryview)):
return "0x" + bytes(value).hex()
raise TypeError("T+ 源行包含无法序列化的数据类型")
def stage_row(
session: Session,
*,
batch_no: str,
entity_type: str,
source_table: str,
row: dict[str, Any],
) -> tuple[TPlusSyncRecord, bool]:
"""幂等写入单条原始行;返回记录和是否新插入。"""
values = prepare_row(batch_no, entity_type, source_table, row)
existing = session.execute(
select(TPlusSyncRecord).where(
TPlusSyncRecord.entity_type == entity_type,
TPlusSyncRecord.source_id == values["source_id"],
TPlusSyncRecord.row_hash == values["row_hash"],
)
).scalar_one_or_none()
if existing is not None:
return existing, False
record = TPlusSyncRecord(**values)
session.add(record)
session.flush()
return record, True
def prepare_row(batch_no: str, entity_type: str, source_table: str, row: dict) -> dict:
"""规范化原始值,生成与 JSON 列序列化一致的哈希。"""
source_id = str(row["id"]) if row.get("id") is not None else ""
if not source_id:
raise ValueError("T+ 源行缺少 id,无法建立幂等键")
source_no = row.get("code")
payload = json.dumps(
row, ensure_ascii=False, sort_keys=True, default=_json_default, allow_nan=False
)
return {
"batch_no": batch_no,
"entity_type": entity_type,
"source_table": source_table,
"source_id": source_id,
"source_no": str(source_no).strip() if source_no is not None else None,
"source_line_no": str(row.get("line_no")) if row.get("line_no") is not None else None,
"source_updated_at": _source_datetime(row),
"row_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
"payload_json": json.loads(payload),
}
def stage_rows(session: Session, batch_no: str, mapping, rows: list[dict]) -> tuple[int, list]:
"""批量去重和插入;数据库拒绝整批时逐条隔离,保留有效行。"""
records, errors = {}, []
for row in rows:
try:
values = prepare_row(batch_no, mapping.entity_type, mapping.source_table, row)
records[(values["source_id"], values["row_hash"])] = values
except (ValueError, TypeError) as exc:
errors.append((row, type(exc).__name__))
if not records:
return 0, errors
existing = set(
session.execute(
select(TPlusSyncRecord.source_id, TPlusSyncRecord.row_hash).where(
TPlusSyncRecord.entity_type == mapping.entity_type,
tuple_(TPlusSyncRecord.source_id, TPlusSyncRecord.row_hash).in_(list(records)),
)
).all()
)
pending = [v for k, v in records.items() if k not in existing]
if not pending:
return 0, errors
try:
with session.begin_nested():
session.execute(insert(TPlusSyncRecord), pending)
return len(pending), errors
except Exception:
# 唯一约束竞争或坏行触发回滚后,再逐条处理,不能丢掉同批有效行。
inserted = 0
for row in rows:
try:
with session.begin_nested():
_, is_new = stage_row(
session,
batch_no=batch_no,
entity_type=mapping.entity_type,
source_table=mapping.source_table,
row=row,
)
inserted += int(is_new)
except Exception as exc:
if not any(old is row for old, _ in errors):
errors.append((row, type(exc).__name__))
return inserted, errors
def _source_datetime(row: dict[str, Any]) -> datetime | None:
"""只接收真实时间值;SQL Server rowversion 不能当作日期。"""
value = row.get("source_updated_at") or row.get("updated")
return value if isinstance(value, datetime) else None
+131
View File
@@ -0,0 +1,131 @@
"""T+ 只读快照同步:批量 staging、幂等去重和失败隔离。"""
from collections.abc import Iterable
from datetime import datetime
from itertools import batched
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.tplus_lock import sync_lock
from app.models import TPlusSyncBatch, TPlusSyncError
from app.services.tplus.mapping import TPlusMapping, get_mapping
from app.services.tplus.reader import TPlusReader
from app.services.tplus.staging import stage_rows
from app.services.tplus.task_service import fail_batch, new_batch_no, recover_running
class TPlusSyncService:
"""按分页提交原始快照,业务表始终不参与本服务。"""
def __init__(self, source_session: Session, target_session: Session) -> None:
self.source_session = source_session
self.target_session = target_session
configured = get_settings().t_plus_query_dir
self.reader = TPlusReader(source_session, Path(configured) if configured else None)
def run(
self,
*,
entity_types: Iterable[str],
watermark: datetime | None = None,
batch_no: str | None = None,
batch_size: int = 500,
) -> TPlusSyncBatch:
"""执行可重复的全量读取,提交每页进度;字段语义未定时拒绝时间增量。"""
entities = tuple(dict.fromkeys(entity_types))
if not entities or not 1 <= batch_size <= 5000:
raise ValueError("至少指定一个实体,batch_size 必须在 1 到 5000 之间")
if watermark is not None:
raise ValueError("增量水位语义尚未确认,当前只支持全量读取和哈希去重")
for entity in entities:
get_mapping(entity)
with sync_lock(self.target_session):
recover_running(self.target_session)
batch = self._start_direct(entities, batch_no)
try:
return self.execute_claimed(batch, batch_size)
except BaseException:
self.target_session.rollback()
fail_batch(self.target_session, batch, "INTERRUPTED")
raise
def _start_direct(self, entities, batch_no):
batch = TPlusSyncBatch(
batch_no=batch_no or new_batch_no(),
sync_mode="FULL",
status="RUNNING",
source_database=get_settings().t_plus_database or None,
started_at=datetime.now(),
source_record_count=0,
staged_record_count=0,
failed_record_count=0,
skipped_record_count=0,
entity_types_json=list(entities),
)
self.target_session.add(batch)
self.target_session.commit()
return batch
def execute_claimed(self, batch: TPlusSyncBatch, batch_size: int = 500):
"""处理已持锁且标记 RUNNING 的任务;仅由直接入口或 worker 调用。"""
if batch.status != "RUNNING" or not batch.entity_types_json:
raise ValueError("任务尚未领取或读取范围不完整")
mappings = [get_mapping(entity) for entity in batch.entity_types_json]
for mapping in mappings:
try:
self._read_entity(batch, mapping, batch_size)
except Exception as exc:
self.target_session.rollback()
self._record_error(batch, mapping, {}, "READ_ERROR", type(exc).__name__)
self.target_session.commit()
batch.status = "SUCCEEDED"
if batch.failed_record_count:
failed_rows = (
self.target_session.scalar(
select(func.count())
.select_from(TPlusSyncError)
.where(
TPlusSyncError.batch_no == batch.batch_no,
TPlusSyncError.error_code == "STAGE_ERROR",
)
)
or 0
)
batch.status = "PARTIAL" if batch.source_record_count > failed_rows else "FAILED"
batch.error_message = "部分数据未完成,请查询同步错误记录并重跑全量快照"
batch.finished_at = datetime.now()
batch.watermark_to = None
self.target_session.commit()
return batch
def _read_entity(self, batch: TPlusSyncBatch, mapping: TPlusMapping, size: int) -> None:
"""批量写入一类数据;每页提交后即使进程中断也可幂等重跑。"""
source = self.reader.iter_entity(mapping.entity_type, batch_size=size)
for chunk in batched(source, size):
inserted, errors = stage_rows(self.target_session, batch.batch_no, mapping, list(chunk))
batch.source_record_count += len(chunk)
batch.staged_record_count += inserted
batch.skipped_record_count += len(chunk) - inserted - len(errors)
for row, reason in errors:
self._record_error(batch, mapping, row, "STAGE_ERROR", reason)
self.target_session.commit()
def _record_error(
self, batch: TPlusSyncBatch, mapping: TPlusMapping, row: dict, code: str, reason: str
) -> None:
"""只记录错误类别和源 ID,避免数据库异常携带个人信息或凭据。"""
self.target_session.add(
TPlusSyncError(
batch_no=batch.batch_no,
entity_type=mapping.entity_type,
source_table=mapping.source_table,
source_id=str(row["id"]) if row.get("id") is not None else None,
source_line_no=str(row["line_no"]) if row.get("line_no") is not None else None,
error_code=code,
error_message=f"源数据处理失败({reason}),请核对连接和字段",
)
)
batch.failed_record_count += 1
+184
View File
@@ -0,0 +1,184 @@
"""持久化任务创建和重试,不在 HTTP 事务中读取源库。"""
import secrets
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.tplus_db import resolve_tplus_settings
from app.models import TPlusSyncBatch, TPlusSyncError, User
from app.repositories import tplus_repository as repository
from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
@dataclass(frozen=True)
class TaskActor:
"""HTTP 操作者与请求元信息,CLI 调用不传。"""
user: User
meta: RequestMeta | None = None
def new_batch_no() -> str:
"""生成最长 24 字符的可追溯批次号。"""
return f"TP{datetime.now():%Y%m%d%H%M%S}{secrets.token_hex(4)}"
def _audit(session, actor, action, data):
if actor is None:
return nullcontext()
return audit_service.audit(
session,
operator=actor.user,
meta=actor.meta,
record=AuditRecord("T+数据同步", action, "T+ 数据任务" + action, data),
)
def _source_database() -> str:
try:
return resolve_tplus_settings(get_settings()).t_plus_database
except RuntimeError as exc:
raise BadRequestError(str(exc)) from None
def _same_intent(batch, mode, entities, parent):
if (
batch.sync_mode != mode
or sorted(batch.entity_types_json or []) != sorted(entities)
or batch.parent_batch_no != parent
):
raise BadRequestError("幂等键已用于不同的任务请求")
return batch
def _insert(session, values):
try:
with session.begin_nested():
batch = TPlusSyncBatch(**values)
session.add(batch)
session.flush()
return batch, True
except IntegrityError:
batch = repository.get_request(session, values["request_key"], for_update=True)
if batch is None:
raise
return _same_intent(
batch, values["sync_mode"], values["entity_types_json"], values["parent_batch_no"]
), False
def _values(payload, database, actor, parent=None):
return {
"batch_no": new_batch_no(),
"request_key": str(payload.request_key),
"status": "QUEUED",
"sync_mode": "AUDIT" if payload.task_type == "AUDIT" else "FULL",
"entity_types_json": sorted(payload.entity_types),
"source_database": database,
"requested_by": actor.user.id if actor else None,
"parent_batch_no": parent,
"started_at": None,
"source_record_count": 0,
"staged_record_count": 0,
"failed_record_count": 0,
"skipped_record_count": 0,
}
def create_task(session: Session, payload: TPlusCreateIn, actor: TaskActor | None = None):
"""原子创建任务;重放已存在的意图不依赖此刻的源连接状态。"""
with _audit(session, actor, "创建读取任务", payload.model_dump(mode="json")):
existing = repository.get_request(session, str(payload.request_key))
mode = "AUDIT" if payload.task_type == "AUDIT" else "FULL"
if existing:
batch = _same_intent(existing, mode, payload.entity_types, None)
else:
batch, _ = _insert(session, _values(payload, _source_database(), actor))
session.commit()
return batch
def retry_task(
session: Session, parent_no: str, payload: TPlusRetryIn, actor: TaskActor | None = None
):
"""锁父批次后创建一个新任务;不覆盖原批次及异常历史。"""
with _audit(
session,
actor,
"重试读取任务",
{"parent_batch_no": parent_no, "request_key": str(payload.request_key)},
):
existing = repository.get_request(session, str(payload.request_key))
if existing:
if existing.parent_batch_no != parent_no:
raise BadRequestError("幂等键已用于不同的重试请求")
session.commit()
batch = existing
else:
parent = repository.get_batch(session, parent_no, for_update=True)
if parent is None:
raise NotFoundError("同步批次不存在")
request = _retry_request(parent, payload)
active = repository.active_retry(session, parent_no)
if active:
if active.request_key != str(payload.request_key):
raise BadRequestError("该批次已有未完成的重试任务,请刷新列表查看")
batch = active
else:
batch, inserted = _insert(
session, _values(request, _source_database(), actor, parent_no)
)
if inserted:
repository.increment_retry_errors(session, parent_no)
session.commit()
return batch
def _retry_request(parent, payload):
if parent.status not in ("FAILED", "PARTIAL"):
raise BadRequestError("只能重试失败或部分失败的批次")
if parent.sync_mode not in ("FULL", "AUDIT"):
raise BadRequestError("该同步方式尚未启用,请重新选择读取任务")
if parent.sync_mode != "AUDIT" and parent.entity_types_json is None:
raise BadRequestError("历史批次未记录范围,请重新选择读取类型")
return TPlusCreateIn(
task_type="AUDIT" if parent.sync_mode == "AUDIT" else "READ",
entity_types=parent.entity_types_json or [],
request_key=payload.request_key,
)
def fail_batch(session: Session, batch: TPlusSyncBatch, code: str) -> None:
"""只保存固定错误摘要;即使原异常含连接密码也不直接落库。"""
messages = {
"INTERRUPTED": "执行进程中断,请重新读取;已暂存数据会自动去重",
"EXECUTION_ERROR": "任务执行失败,请检查只读连接和字段映射后重试",
"TYPE_UNRESOLVED": "核验所需单据类型缺失或不唯一,请核对源字典",
}
message = messages.get(code, "任务未完成,请核对运行配置")
batch.status, batch.finished_at, batch.error_message = "FAILED", datetime.now(), message
batch.failed_record_count = (batch.failed_record_count or 0) + 1
session.add(
TPlusSyncError(
batch_no=batch.batch_no,
entity_type="system",
source_table="system",
error_code=code,
error_message=message,
)
)
session.commit()
def recover_running(session: Session) -> None:
"""仅在持有执行锁时调用;锁证明之前的执行者已经退出。"""
for batch in repository.running_batches(session):
fail_batch(session, batch, "INTERRUPTED")
+61
View File
@@ -0,0 +1,61 @@
"""持久化任务执行者;获取连接级锁后才恢复或领取任务。"""
from datetime import datetime
from pathlib import Path
from app.core.tplus_db import get_tplus_session_factory, resolve_tplus_settings
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.repositories import tplus_repository as repository
from app.services.tplus import task_service
from app.services.tplus.relationship_audit import TPlusRelationshipAudit
from app.services.tplus.sync_service import TPlusSyncService
def run_once(session, source_factory=None):
"""返回本轮处理的批次号;忙锁或队列为空时不假称已执行。"""
try:
with sync_lock(session):
task_service.recover_running(session)
batch = repository.next_queued(session)
if batch is None:
session.rollback()
return None
batch.status, batch.started_at = "RUNNING", datetime.now()
session.commit()
_execute(session, batch, source_factory)
return batch.batch_no
except TPlusBusyError:
session.rollback()
return None
def _execute(session, batch, source_factory):
try:
settings = resolve_tplus_settings(task_service.get_settings())
if batch.source_database and batch.source_database != settings.t_plus_database:
raise RuntimeError("任务排队后的源账套配置已改变")
factory = source_factory or get_tplus_session_factory()
with factory() as source:
if batch.sync_mode == "AUDIT":
root = Path(settings.t_plus_query_dir) if settings.t_plus_query_dir else None
report = TPlusRelationshipAudit(source, root).run()
batch.audit_result_json = report
batch.source_record_count = sum(row["row_count"] for row in report["receipt_kinds"])
batch.finished_at, batch.status = datetime.now(), "SUCCEEDED"
if report["relationship_status"] != "OBSERVED":
task_service.fail_batch(session, batch, "TYPE_UNRESOLVED")
else:
session.commit()
else:
TPlusSyncService(source, session).execute_claimed(batch)
except BaseException as exc:
session.rollback()
task_service.fail_batch(
session,
batch,
"INTERRUPTED"
if isinstance(exc, (KeyboardInterrupt, SystemExit))
else "EXECUTION_ERROR",
)
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
raise
+2 -16
View File
@@ -68,22 +68,8 @@ def _to_out(user: User) -> UserOut:
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]: def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
"""分页查询用户列表。""" """分页查询用户列表。"""
users = user_repository.list_users( users = user_repository.list_users(session, query)
session, total = user_repository.count_users(session, query)
offset=query.offset,
limit=query.page_size,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
total = user_repository.count_users(
session,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
return PageResult[UserOut]( return PageResult[UserOut](
records=[_to_out(user) for user in users], records=[_to_out(user) for user in users],
total=total, total=total,
+5
View File
@@ -38,6 +38,11 @@ dependencies = [
"python-multipart>=0.0.32", "python-multipart>=0.0.32",
] ]
[project.optional-dependencies]
tplus = [
"pyodbc>=5.2,<6",
]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.3", "pytest>=8.3",
+31 -3
View File
@@ -1,6 +1,30 @@
# 后端测试 # 后端测试
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 697 个用例全绿(2026-10-16,M3 增量批次后)。 > AI 摘要:2026-10-28 的 P0.5 连接修复已通过 19 项定点测试和 1001 项完整回归;使用本轮独立 MySQL/Redis 容器,未连接客户 T+ 源库。真实备份源 10.50.1600 验收仍未执行。
## 连接策略修复核验(2026-10-28)
- Ruff(含兼容性脚本)通过;定点命令 `uv run pytest tests/unit/test_tplus_config.py tests/unit/test_tplus_connection_file.py tests/unit/test_tplus_compatibility_script.py -q`:19 passed。
- 新增覆盖:数据库名/用户名/密码中的分号、花括号和中文;默认加密与显式回退;脚本复用连接文件校验;10.50.1600 与其他补丁、2022 版本区分;连接失败不自动降级且报告不带驱动原始异常。
- 完整回归:1001 passed,1 warning,869.47 秒。使用已安装的 `mysql:8.0`、`redis:7-alpine` 镜像启动独立容器、随机宿主端口和命名卷,以 `db/init/init-dev.sql` 初始化;未连接现有开发业务库或 T+ 源库。
- 完整测试入口为 `uv run python ../.ai/run_isolated_regression.py`,内部调用 `pytest.main(["-q", ...])`,不筛选或跳过用例。运行期配置指向临时隔离实例;单测的 `_env_file=None` 仍保持默认值测试语义。结束后已清理本轮容器、卷与临时凭据配置。
- 兼容性脚本、worker 的 `--help` 可运行;保留现有 Starlette TestClient 弃用提醒。本轮未改前端,未重复前端构建。
- 版本判断与失败分支是单元模拟结果,不能据此宣称真实 2008 R2 的驱动、TLS、断连和权限已验收。下方 2026-10-27 的结果保留为历史记录。
## T+ 本轮核验(2026-10-27)
- 完整回归在独立 MySQL `payroll_tplus_validation_20261027`、Redis 15 库运行:984 passed、2 failed。两项失败均为配置单测预期默认 Redis 0 库,而进程继承了集成用的 `REDIS_DB=15`;没有修改默认配置或削弱断言。
- 后续将单元测试放在不设置上述集成覆盖值的独立进程中运行:602 passed。最终管理 API、真实并发和结构一致性定点测试:18 passed,包含新增加的配置关闭及创建/重试审计。
- 真实并发:相同请求只生成一个批次;不同键同时重试同父批次只有一个成功,无死锁;执行锁跨 ORM commit 仍有效,退出后可再获取。
- V15 在隔离库 downgrade 到 V14,再 upgrade 到 head 通过;当前 head `e5f607a8b9c0`。模型、Alembic、留存 SQL 和初始化脚本的结构检查通过;此前 V1–V14 留存 SQL 已从空库执行通过。
- 人员重读 847 条,新增 0、重复跳过 847、失败 0;关联报告覆盖 103,318 条销货类收款明细,50,044 条有明确订单关联,六类业务表行数不变。独立证据位于 D 盘 reports/management_verification_2026-10-27.json。
- Chrome 真实接口检查通过:排队、受控失败、异常查看、页面重试、人员去重、关联检查、筛选、普通用户拒绝访问,以及 320/768/1024/1440 宽度。无页面运行错误;临时账号和批次已清理。
- 加载、空态、配置关闭、概况失败另外使用浏览器响应模拟验证,不能与真实源链路混记。证据位于 .ai/archive/ai-aux/preview/tplus/。
- 320 像素主内容可操作,现有全局顶栏仍有文字挤压;未扩大本轮范围修改共享布局。宽表保留横向滚动,截图和限制见同步管理验收报告。
- 前端类型检查、生产构建和 Ruff 通过;构建保留既有的大分块提醒,测试有现存 Starlette TestClient 弃用提醒。
- 源查询保持参数化与旧版语法,时间增量和业务表写入均关闭。合法 Windows/SQL Server 2008 R2 安装介质仍未取得,不能把 2022 的结果标记为客户版本兼容。
单元测试与集成测试建议分独立终端运行:单元测试不设置集成覆盖值;集成测试使用隔离 MySQL 和 Redis。通用集成夹具会清理数据,不能指向开发或客户业务库。Windows 可设置 `PYTEST_DEBUG_TEMPROOT` 到项目 D 盘的 ai-aux/pytest-tmp,避免不同权限令牌共享系统临时目录。
## 一、测试约定 ## 一、测试约定
@@ -98,9 +122,9 @@ uv run pytest tests/integration -q # 仅集成
| --- | --- | --- | | --- | --- | --- |
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 | | `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 | | `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 | | `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕、评级保存(回显总分/时间、覆盖式保存)、评分 101→400、按评分排序未评分垫底(asc/desc) | 18 |
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。 运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等);2026-10-23 评级功能后客户单文件 **18 passed**(18.5s)。
### 业务数据管理(2026-10-13 完成) ### 业务数据管理(2026-10-13 完成)
@@ -205,9 +229,13 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 | | 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 |
| 2026-10-20 | code-review 遗留 2 项 | 全量 875 | ✅ 875 passed(12:13) | **方案版本化 + 审批内容快照**(V11 迁移):审批单 `version_no`/`snapshot_json`,方案行 `is_active` 版本化;编辑/驳回不再物理删方案;历史单无快照时 live 回退并显式标注。新增集成 7 + 单元 6;定点审批/月结/核算/业务/报表全绿;ruff 与 type-check/build 绿 | | 2026-10-20 | code-review 遗留 2 项 | 全量 875 | ✅ 875 passed(12:13) | **方案版本化 + 审批内容快照**(V11 迁移):审批单 `version_no`/`snapshot_json`,方案行 `is_active` 版本化;编辑/驳回不再物理删方案;历史单无快照时 live 回退并显式标注。新增集成 7 + 单元 6;定点审批/月结/核算/业务/报表全绿;ruff 与 type-check/build 绿 |
| 2026-10-20 | 遗留项自评审收口 | 全量 891 | ✅ 891 passed(10:23) | **列表 defer 快照列**(审批列表/工作台待办/超时预警)+ **送审内容未变不重提、方案未变不新增版本**(两个 version_no 语义解耦)+ **月结提交加行锁**。新增单元 10 + 集成 5;单元 525、集成定点 96 全绿 | | 2026-10-20 | 遗留项自评审收口 | 全量 891 | ✅ 891 passed(10:23) | **列表 defer 快照列**(审批列表/工作台待办/超时预警)+ **送审内容未变不重提、方案未变不新增版本**(两个 version_no 语义解耦)+ **月结提交加行锁**。新增单元 10 + 集成 5;单元 525、集成定点 96 全绿 |
| 2026-10-22 | 清理批次(散参数 DTO 化) | 全量 900 | ✅ 900 passed(11:08) | **仓储层散参数收敛为查询 DTO**(`list_*(session, query)` 取代 keyword/status/offset/limit 四参数),同步调整服务层与测试调用;单元 526 绿、ruff 绿、前端 type-check 与 build 绿 |
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 | | 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 | | 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
| 2026-10-23 | 参数文案中文化 | 单元 526 + 参数集成 24 | ✅ 526 passed(45.15s)/ 24 passed(10.60s) | **计提参数页全面中文化**:`param_registry` 22 条 `description` 改写为业务语言(去掉「澄清 #10」「M3」「禁止按 0.011111 截断」等开发者语汇),`db/init/init-dev.sql` 种子同源同步;`param_service` 模板报错改中文模板名、产品类别报错去掉英文枚举;`dashboard_service` 概览口径说明重写。**这些文案无测试断言**(`rate_text` 断言 `11/1125`、`聚合:20%+5%` 未动),故用例数不变、全绿即口径未被破坏。ruff 绿;前端 `pnpm type-check` 绿 |
| 2026-10-23 | 台账/预览的计提说明口语化 | 单元 526 + 集成 87(calc/settlements/param/reports) | ✅ 526 passed(47.29s)/ 87 passed(7:41) | **`rate_text`/`rate_note`/聚合行 `note` 改业务话**:`企业户固定计提比例`→「企业户:不按回款天数,统一按固定比例计提」、`业务员递减:10‰ 起,20 天(分界 360 天…)`→「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」、`信息员:本人销售额 × 计提比例`→「信息员:按本人销售额乘计提比例,不随回款天数递减」、聚合行 `#12 参数化:基数不含经理个人户业绩;9.8 方案公式`→「团队分成:基数不含经理本人名下业绩,按本团队与全公司两级比例合计计提」。**用例同步**:`test_经理聚合口径_预览拒绝` 原断言消息含「聚合」,改为断言含「营销经理」+「月度结算」(守「拒绝且给下一步」,不再绑定措辞);`rate_text` 的 `11/1125`、`聚合:20%+5%` 断言未动(那是分数原文与百分比拼装,非说明文案) |
## 四、测试发现并修复的缺陷 ## 四、测试发现并修复的缺陷
| 日期 | 缺陷 | 影响 | 处理 | | 日期 | 缺陷 | 影响 | 处理 |
+30
View File
@@ -4,6 +4,36 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@pytest.fixture
def tplus_session(monkeypatch):
"""T+ 管理逻辑的真实内存事务;MySQL 并发锁另做集成验证。"""
from sqlalchemy import DefaultClause, Integer, MetaData, create_engine, text
from sqlalchemy.orm import Session
from app.core.config import Settings
from app.models import TPlusSyncBatch, TPlusSyncError, TPlusSyncRecord
engine = create_engine("sqlite:///:memory:")
metadata = MetaData()
for model in (TPlusSyncBatch, TPlusSyncError, TPlusSyncRecord):
table = model.__table__.to_metadata(metadata)
table.c.id.type = Integer()
table.c.update_time.server_default = DefaultClause(text("CURRENT_TIMESTAMP"))
metadata.create_all(engine)
settings = Settings(
_env_file=None,
app_secret_key="x" * 32,
t_plus_enabled=True,
t_plus_database="unit_tplus",
t_plus_user="reader",
t_plus_odbc_driver="unit",
)
monkeypatch.setattr("app.services.tplus.task_service.get_settings", lambda: settings)
with Session(engine, expire_on_commit=False) as session:
yield session
engine.dispose()
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def client() -> TestClient: def client() -> TestClient:
"""构造应用测试客户端(会话级复用,避免重复构建应用)。 """构造应用测试客户端(会话级复用,避免重复构建应用)。
+33
View File
@@ -324,3 +324,36 @@ def normal(make_user, auth_headers) -> dict:
user = make_user([ROLE_USER]) user = make_user([ROLE_USER])
user["headers"] = auth_headers(user) user["headers"] = auth_headers(user)
return user return user
@pytest.fixture
def tplus_tasks(monkeypatch):
"""隔离测试任务,仅清理此夹具创建的批次和异常。"""
from app.core.config import Settings
from app.models import TPlusSyncBatch, TPlusSyncError
settings = Settings(
_env_file=None,
app_secret_key="x" * 32,
t_plus_enabled=True,
t_plus_database="unit_tplus",
t_plus_user="reader",
t_plus_odbc_driver="unit",
)
monkeypatch.setattr("app.services.tplus.task_service.get_settings", lambda: settings)
keys = []
def payload():
key = str(uuid.uuid4())
keys.append(key)
return {"task_type": "READ", "entity_types": ["personnel"], "request_key": key}
yield payload
with get_session_factory()() as db:
numbers = list(
db.scalars(select(TPlusSyncBatch.batch_no).where(TPlusSyncBatch.request_key.in_(keys)))
)
if numbers:
db.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers)))
db.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.batch_no.in_(numbers)))
db.commit()
@@ -451,7 +451,9 @@ class TestContentSnapshot:
row.snapshot_json = None row.snapshot_json = None
session.commit() session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"] detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
"data"
]
assert detail["content_source"] == "live" assert detail["content_source"] == "live"
assert detail["snapshot"]["captured_at"] is None assert detail["snapshot"]["captured_at"] is None
assert detail["snapshot"]["data"]["business_no"] == data["business_no"] assert detail["snapshot"]["data"]["business_no"] == data["business_no"]
@@ -474,7 +476,9 @@ class TestContentSnapshot:
row.ref_id = 999_999_999 row.ref_id = 999_999_999
session.commit() session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"] detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
"data"
]
assert detail["content_source"] == "missing" assert detail["content_source"] == "missing"
assert detail["snapshot"] is None assert detail["snapshot"] is None
@@ -679,9 +683,7 @@ class TestAllocationVersions:
data = _create_with_plan(client, admin["headers"], p["id"]) data = _create_with_plan(client, admin["headers"], p["id"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [ assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [(1, 1)]
(1, 1)
]
payload = business_payload( payload = business_payload(
detail["customer_id"], detail["customer_id"],
@@ -691,7 +693,9 @@ class TestAllocationVersions:
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
history = [(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]] history = [
(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]
]
assert history == [(1, 0, "50.00"), (2, 1, "80.00")] assert history == [(1, 0, "50.00"), (2, 1, "80.00")]
assert [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"] assert [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"]
+96 -2
View File
@@ -13,21 +13,24 @@ audit_service.py「上下文内不得吞掉异常后正常退出」的约定相
""" """
import uuid import uuid
from datetime import date
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import func, select from sqlalchemy import func, select
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS from app.core.constants import ACTION_CREATE, MODULE_BUSINESS, MODULE_RECEIPT
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.main import app from app.main import app
from app.models import OperationLog from app.models import OperationLog
from app.services import business_service from app.services import business_service, receipt_service
from tests.integration.factories import create_business
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
_BUSINESSES = "/api/v1/business/businesses" _BUSINESSES = "/api/v1/business/businesses"
_CUSTOMERS = "/api/v1/base/customers" _CUSTOMERS = "/api/v1/base/customers"
_RECEIPTS = "/api/v1/business/receipts"
@pytest.fixture @pytest.fixture
@@ -160,3 +163,94 @@ class TestAuditRetry:
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志 # 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
assert _log_count(normal["username"], success=False) == base_fail + 1 assert _log_count(normal["username"], success=False) == base_fail + 1
assert _log_count(normal["username"], success=True) == base_ok assert _log_count(normal["username"], success=True) == base_ok
def _receipt_log_count(username: str, *, success: bool) -> int:
"""按操作人统计「新增回款」操作日志条数。"""
factory = get_session_factory()
with factory() as session:
stmt = (
select(func.count())
.select_from(OperationLog)
.where(
OperationLog.username == username,
OperationLog.module == MODULE_RECEIPT,
OperationLog.action == ACTION_CREATE,
OperationLog.summary.like("新增回款%"),
OperationLog.success == int(success),
)
)
return int(session.execute(stmt).scalar_one())
def _receipt_payload(business_id: int) -> dict:
return {
"business_id": business_id,
"receipt_date": str(date.today()),
"amount": "1200.00",
}
class TestReceiptAuditRetry:
"""回款新增的 1062 重试路径(回款与业务同构,但走独立实现)。
回款侧的重试循环本次从 create 函数体内移入 helper 调用,重试边界、
审计条数、批次号重取都必须保持原样 —— 用例会盯住这三条。
"""
def test_首次撞号重试成功_审计恰一条真成功(
self, quiet_client: TestClient, normal: dict, monkeypatch
) -> None:
client = quiet_client
business = create_business(client, normal["headers"])
first = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert first.status_code == 200, first.text
existing_no = first.json()["data"]["receipt_batch_no"]
real_generate = receipt_service._generate_no
calls = {"n": 0}
def flaky_generate(session):
calls["n"] += 1
if calls["n"] == 1:
return existing_no # 首次返回库内已存在批次号 → flush 撞 1062
return real_generate(session)
monkeypatch.setattr(receipt_service, "_generate_no", flaky_generate)
base_ok = _receipt_log_count(normal["username"], success=True)
base_fail = _receipt_log_count(normal["username"], success=False)
second = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert second.status_code == 200, second.text
assert calls["n"] == 2, "重试应恰好再取一次号"
assert _receipt_log_count(normal["username"], success=True) == base_ok + 1
assert _receipt_log_count(normal["username"], success=False) == base_fail
def test_持续冲突_最终失败且审计恰一条失败(
self, quiet_client: TestClient, normal: dict, monkeypatch
) -> None:
client = quiet_client
business = create_business(client, normal["headers"])
first = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert first.status_code == 200, first.text
existing_no = first.json()["data"]["receipt_batch_no"]
def always_conflict(session):
return existing_no
monkeypatch.setattr(receipt_service, "_generate_no", always_conflict)
base_ok = _receipt_log_count(normal["username"], success=True)
base_fail = _receipt_log_count(normal["username"], success=False)
second = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert second.status_code == 400, second.text
assert _receipt_log_count(normal["username"], success=False) == base_fail + 1
assert _receipt_log_count(normal["username"], success=True) == base_ok
+1 -3
View File
@@ -452,9 +452,7 @@ class TestEdit:
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "含合同负责营销人员" in resp.json()["message"] assert "含合同负责营销人员" in resp.json()["message"]
def test_二次分配方案合法比例_保存成功且回显( def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None:
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"] headers = normal["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
owner = _personnel(client, headers) owner = _personnel(client, headers)
@@ -376,3 +376,73 @@ class TestAudit:
latest = logs[0] latest = logs[0]
assert customer["company_name"] in latest.summary, "摘要应含客户名" assert customer["company_name"] in latest.summary, "摘要应含客户名"
assert "→" in latest.summary, "摘要应体现新旧归属走向" assert "→" in latest.summary, "摘要应体现新旧归属走向"
class TestRating:
"""评级保存与列表排序 —— 评分是档案属性,只存总分与评级时间。
守的是:评分只走专属端点(编辑端点不含评分,旁路写评分会绕开口径);
分值红线 0-100 在入口拦截;按评分排序时未评分客户必须垫底,不能因
MySQL 把 NULL 视为最小值而在升序里压过打了低分的客户。
"""
def test_保存评级_回显总分与时间_且编辑端点不含评分(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
assert customer["rating_score"] is None, "新客户应无评分"
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 88}, headers=headers
)
assert resp.status_code == 200, resp.text
body = resp.json()["data"]
assert body["rating_score"] == 88
assert body["rating_time"] is not None, "保存评分必须刷新评级时间"
# 覆盖式保存:新分数替换旧分数
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 60}, headers=headers
)
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["rating_score"] == 60
def test_评分越界_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers
)
assert resp.status_code == 400, resp.text
def test_按评分排序_未评分垫底_升序降序都不压过低分(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
high = _create_customer(client, headers)
low = _create_customer(client, headers)
unrated = _create_customer(client, headers)
for target, score in ((high, 80), (low, 60)):
resp = client.put(
f"{_CUSTOMERS}/{target['id']}/rating", json={"score": score}, headers=headers
)
assert resp.status_code == 200, resp.text
def _order_names(order: str) -> list[str]:
resp = client.get(
_CUSTOMERS,
params={"pageNum": 1, "pageSize": 50, "order_by": "rating_score", "order": order},
headers=headers,
)
assert resp.status_code == 200, resp.text
names = [row["company_name"] for row in resp.json()["data"]["list"]]
return names
# 降序:高分在前,未评分最后;升序:低分在前,未评分仍垫底
desc_names = _order_names("desc")
assert desc_names.index(high["company_name"]) < desc_names.index(low["company_name"])
assert desc_names[-1] == unrated["company_name"]
asc_names = _order_names("asc")
assert asc_names.index(low["company_name"]) < asc_names.index(high["company_name"])
assert asc_names[-1] == unrated["company_name"], "未评分必须垫底"
+53 -20
View File
@@ -118,10 +118,20 @@ def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> d
return resp.json()["data"] return resp.json()["data"]
def _activities(client: TestClient, headers: dict, **params) -> dict:
"""最近动态分页端点。"""
resp = client.get(f"{_DASHBOARD}/activities", params=params, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def test_权限_普通用户403文案(client: TestClient, normal: dict): def test_权限_普通用户403文案(client: TestClient, normal: dict):
resp = client.get(_DASHBOARD, headers=normal["headers"]) resp = client.get(_DASHBOARD, headers=normal["headers"])
assert resp.status_code == 403 assert resp.status_code == 403
assert resp.json()["message"] == "无权访问工作台" assert resp.json()["message"] == "无权访问工作台"
# 最近动态分页端点同属工作台,权限一致
resp = client.get(f"{_DASHBOARD}/activities", headers=normal["headers"])
assert resp.status_code == 403
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict): def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
@@ -138,9 +148,7 @@ def test_总览_绩效合计与台账一致且非法月份400(client: TestClient
expected = Decimal( expected = Decimal(
str( str(
session.execute( session.execute(
select( select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)
).where(
PerformanceLedger.settlement_month == _month(), PerformanceLedger.settlement_month == _month(),
PerformanceLedger.calc_type == 1, PerformanceLedger.calc_type == 1,
PerformanceLedger.is_active == 1, PerformanceLedger.is_active == 1,
@@ -166,16 +174,12 @@ def test_待办_待审批与未指定批次与同步降级(client: TestClient, a
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"] settlement_id = resp.json()["data"]["id"]
resp = client.post( resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
data = _dashboard(client, admin["headers"]) data = _dashboard(client, admin["headers"])
assert data["todos"]["pending_approval_total"] >= 1 assert data["todos"]["pending_approval_total"] >= 1
assert any( assert any(item["order_type"] == 2 for item in data["todos"]["pending_approvals"])
item["order_type"] == 2 for item in data["todos"]["pending_approvals"]
)
assert data["todos"]["unassigned_receipt_total"] >= 1 assert data["todos"]["unassigned_receipt_total"] >= 1
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"] assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
# ⑥ 未建:显式降级 # ⑥ 未建:显式降级
@@ -227,9 +231,40 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
def test_动态_来源操作日志(client: TestClient, admin: dict): def test_动态_来源操作日志(client: TestClient, admin: dict):
_chain(client, admin["headers"]) _chain(client, admin["headers"])
data = _dashboard(client, admin["headers"]) data = _activities(client, admin["headers"])
assert data["activities"], "工作台动态为空:写操作应产生操作日志" assert data["list"], "工作台动态为空:写操作应产生操作日志"
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0]) assert {"module", "action", "summary", "create_time"} <= set(data["list"][0])
def test_动态_分页翻页不重不漏(client: TestClient, admin: dict):
_chain(client, admin["headers"])
first = _activities(client, admin["headers"], pageNum=1, pageSize=2)
assert first["pageNum"] == 1
assert first["pageSize"] == 2
assert len(first["list"]) == 2
assert first["total"] >= 2
second = _activities(client, admin["headers"], pageNum=2, pageSize=2)
assert len(second["list"]) == 2
# 两页不重不漏(倒序取,按 id 倒排)
first_ids = {item["id"] for item in first["list"]}
second_ids = {item["id"] for item in second["list"]}
assert first_ids.isdisjoint(second_ids)
assert first["list"][0]["id"] > second["list"][0]["id"]
def test_动态_模块筛选与筛选项(client: TestClient, admin: dict):
_chain(client, admin["headers"])
page = _activities(client, admin["headers"], pageSize=50)
assert page["modules"], "模块筛选项不应为空"
module = page["modules"][0]
filtered = _activities(client, admin["headers"], module=module, pageSize=50)
assert filtered["total"] >= 1
assert filtered["list"], "按模块筛选后应有数据"
assert all(item["module"] == module for item in filtered["list"])
# 筛选项不随筛选条件变化(下拉里始终是全量模块)
assert filtered["modules"] == page["modules"]
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)==== # ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
@@ -335,17 +370,17 @@ def test_待办_按角色仅显示可操作节点(
assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2 assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2
finance_orders = { finance_orders = {
item["order_no"] for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"] item["order_no"]
for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
} }
leader_orders = { leader_orders = {
item["order_no"] for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"] item["order_no"]
for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
} }
assert finance_orders.isdisjoint(leader_orders) assert finance_orders.isdisjoint(leader_orders)
def test_待办_财务通过后领导出现待办( def test_待办_财务通过后领导出现待办(client: TestClient, admin: dict, finance: dict, leader: dict):
client: TestClient, admin: dict, finance: dict, leader: dict
):
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。""" """真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
_chain(client, admin["headers"]) _chain(client, admin["headers"])
resp = client.post( resp = client.post(
@@ -353,9 +388,7 @@ def test_待办_财务通过后领导出现待办(
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"] settlement_id = resp.json()["data"]["id"]
resp = client.post( resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0] order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0]
@@ -326,6 +326,38 @@ class TestPreview:
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言 # Fraction 自动约分:4/1000 存储为 1/250,按数值断言
assert Fraction(data["rate"]) == Fraction(4, 1000) assert Fraction(data["rate"]) == Fraction(4, 1000)
def test_预览参数使用顺序稳定(self, client: TestClient, admin: dict) -> None:
"""params_used 的顺序即前端「用了哪些参数」的展示顺序。
预览实现拆成多段后,各段的调用先后决定参数读取先后;顺序漂移会让
复核者看到的参数清单与核算口径对不上(不报错,但会误导判读)。
"""
payload = {
"business_role": "salesperson",
"customer_type": "personal",
"product_category": "municipal_water",
"ship_date": "2025-12-15",
"receipt_date": "2026-01-10",
"revenue": "100000",
"profit": "45000",
"received_amount": "60000",
"barter": True,
"probation": True,
}
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
"data"
]
assert [p["param_code"] for p in data["params_used"]] == [
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
"salesperson.days.boundary",
"product.threshold",
"barter.discount",
"payment.start.ratio",
"probation.annual.threshold",
]
def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None: def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None:
payload = { payload = {
"business_role": "sales_manager", "business_role": "sales_manager",
@@ -339,7 +371,11 @@ class TestPreview:
} }
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]) resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
assert resp.status_code == 400 assert resp.status_code == 400
assert "聚合" in resp.json()["message"] # 守两件事:① 聚合口径不允许单笔试算(拒绝而不是算出个错数);
# ② 提示要能指导下一步(点名是谁的口径 + 去哪儿看),不是一句「不支持」。
message = resp.json()["message"]
assert "营销经理" in message
assert "月度结算" in message
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None: def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
payload = { payload = {
+30 -27
View File
@@ -44,7 +44,9 @@ def _role_id(code: str) -> int:
) )
def _person(client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None) -> dict: def _person(
client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None
) -> dict:
body = { body = {
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}", "personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id(role), "business_role_id": _role_id(role),
@@ -194,9 +196,7 @@ def test_部门汇总_无成员经理出零值行(admin, client: TestClient):
headers=admin["headers"], headers=admin["headers"],
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
team = next( team = next((t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None)
(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None
)
assert team is not None assert team is not None
assert Decimal(str(team["team_base"])) == Decimal("0") assert Decimal(str(team["team_base"])) == Decimal("0")
assert team["member_count"] == 0 assert team["member_count"] == 0
@@ -224,7 +224,14 @@ def test_回款统计_空桶恒在且分摊拆行金额只计一次(admin, clien
assert len(data["buckets"]) == 8 assert len(data["buckets"]) == 8
assert [b["code"] for b in data["buckets"]] == [ assert [b["code"] for b in data["buckets"]] == [
"le0", "1-30", "31-60", "61-90", "91-180", "181-360", "361+", "none", "le0",
"1-30",
"31-60",
"61-90",
"91-180",
"181-360",
"361+",
"none",
] ]
# 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天) # 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天)
bucket = next(b for b in data["buckets"] if b["code"] == "61-90") bucket = next(b for b in data["buckets"] if b["code"] == "61-90")
@@ -293,11 +300,15 @@ def test_导出_excel含签字栏且入操作日志(admin, client: TestClient):
factory = get_session_factory() factory = get_session_factory()
with factory() as session: with factory() as session:
log = session.execute( log = (
session.execute(
select(OperationLog) select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出") .where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc()) .order_by(OperationLog.id.desc())
).scalars().first() )
.scalars()
.first()
)
assert log is not None assert log is not None
@@ -389,19 +400,21 @@ def _latest_export_log():
factory = get_session_factory() factory = get_session_factory()
with factory() as session: with factory() as session:
return session.execute( return (
session.execute(
select(OperationLog) select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出") .where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc()) .order_by(OperationLog.id.desc())
).scalars().first() )
.scalars()
.first()
)
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient): def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。""" """经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
manager_b = _person(client, admin["headers"], role="sales_manager") manager_b = _person(client, admin["headers"], role="sales_manager")
manager_a = _person( manager_a = _person(client, admin["headers"], role="sales_manager", manager_id=manager_b["id"])
client, admin["headers"], role="sales_manager", manager_id=manager_b["id"]
)
resp = client.get( resp = client.get(
f"{_REPORTS}/department-summary", f"{_REPORTS}/department-summary",
@@ -467,9 +480,7 @@ def test_部门汇总_经理个人户计入时基数等于成员合计(admin, cl
headers=admin["headers"], headers=admin["headers"],
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
team = next( team = next(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"])
t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]
)
assert Decimal(str(team["team_base"])) == Decimal("100.00") assert Decimal(str(team["team_base"])) == Decimal("100.00")
assert sum( assert sum(
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0") (Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
@@ -516,9 +527,7 @@ def test_部门汇总_按经理筛选不误判其他团队下属(admin, client:
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]] assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
assert data["unassigned"] == [] assert data["unassigned"] == []
assert all( assert all(
m["personnel_id"] != salesperson_2["id"] m["personnel_id"] != salesperson_2["id"] for team in data["teams"] for m in team["members"]
for team in data["teams"]
for m in team["members"]
) )
assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0 assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0
@@ -557,9 +566,7 @@ def test_部门汇总_对账恒等式(admin, client: TestClient):
) )
def _two_manager_scene( def _two_manager_scene(client: TestClient, headers: dict) -> tuple[dict, dict, dict, dict, dict]:
client: TestClient, headers: dict
) -> tuple[dict, dict, dict, dict, dict]:
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。""" """两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
manager_1 = _person(client, headers, role="sales_manager") manager_1 = _person(client, headers, role="sales_manager")
manager_2 = _person(client, headers, role="sales_manager") manager_2 = _person(client, headers, role="sales_manager")
@@ -777,9 +784,7 @@ def test_导出取数异常_失败审计留痕(admin, client: TestClient, monkey
assert "导出个人绩效工资明细表" in (log.summary or "") assert "导出个人绩效工资明细表" in (log.summary or "")
@pytest.mark.parametrize( @pytest.mark.parametrize("path", ["/personal-detail", "/department-summary", "/payment-stats"])
"path", ["/personal-detail", "/department-summary", "/payment-stats"]
)
def test_非法月份_查询返回400(admin, client: TestClient, path: str): def test_非法月份_查询返回400(admin, client: TestClient, path: str):
"""非法月份必须是参数错误 400,不能落 500。""" """非法月份必须是参数错误 400,不能落 500。"""
resp = client.get( resp = client.get(
@@ -790,9 +795,7 @@ def test_非法月份_查询返回400(admin, client: TestClient, path: str):
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
@pytest.mark.parametrize( @pytest.mark.parametrize("code", ["personal-detail", "department-summary", "payment-stats"])
"code", ["personal-detail", "department-summary", "payment-stats"]
)
def test_非法月份_导出返回400(admin, client: TestClient, code: str): def test_非法月份_导出返回400(admin, client: TestClient, code: str):
"""导出手工构造查询模型同样要转 400。""" """导出手工构造查询模型同样要转 400。"""
resp = client.get( resp = client.get(
+11 -1
View File
@@ -487,8 +487,18 @@ class TestManagerAggregate:
assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1 assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1
assert len([i for i in out["items"] if i["calc_type"] == 1]) == 1 assert len([i for i in out["items"] if i["calc_type"] == 1]) == 1
def test_月末取参_预约变更影响本月聚合(self, client: TestClient, admin: dict) -> None: def test_月末取参_预约变更影响本月聚合(
self, client: TestClient, admin: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
"""取参锚点=所属月月末:10-25 生效的预约变更应影响 2026-10 月结。""" """取参锚点=所属月月末:10-25 生效的预约变更应影响 2026-10 月结。"""
class ScenarioDate(date):
@classmethod
def today(cls):
return cls(2026, 10, 20)
# 固定预约场景的申请日,避免现实日期超过 10-25 后误测成“禁止追溯修改”。
monkeypatch.setattr("app.services.param_service.date", ScenarioDate)
resp = client.post( resp = client.post(
"/api/v1/system/params/changes", "/api/v1/system/params/changes",
json={ json={
@@ -0,0 +1,90 @@
"""在隔离 MySQL 中验证幂等竞争、父批次串行化和连接级锁。"""
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from uuid import uuid4
import pytest
from sqlalchemy import delete, func, select, text
from app.core.db import get_session_factory
from app.core.exceptions import BadRequestError
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import TPlusSyncBatch
from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn
from app.services.tplus import task_service
pytestmark = pytest.mark.integration
def _parallel(action):
barrier = Barrier(2)
def run():
with get_session_factory()() as session:
barrier.wait(timeout=10)
return action(session)
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(run) for _ in range(2)]
return [future.result(timeout=20) for future in futures]
def test_并发相同请求只创建一个批次(tplus_tasks):
payload = TPlusCreateIn(**tplus_tasks())
numbers = _parallel(lambda session: task_service.create_task(session, payload).batch_no)
assert numbers[0] == numbers[1]
with get_session_factory()() as session:
assert (
session.scalar(
select(func.count())
.select_from(TPlusSyncBatch)
.where(TPlusSyncBatch.request_key == str(payload.request_key))
)
== 1
)
def test_并发不同请求重试同父批次只允许一个(tplus_tasks):
with get_session_factory()() as session:
parent = task_service.create_task(session, TPlusCreateIn(**tplus_tasks()))
parent.status = "FAILED"
session.commit()
number = parent.batch_no
def retry(session):
try:
return task_service.retry_task(
session, number, TPlusRetryIn(request_key=uuid4())
).batch_no
except BadRequestError:
session.rollback()
return "REJECTED"
try:
numbers = _parallel(retry)
assert numbers.count("REJECTED") == 1
with get_session_factory()() as session:
assert (
session.scalar(
select(func.count())
.select_from(TPlusSyncBatch)
.where(TPlusSyncBatch.parent_batch_no == number)
)
== 1
)
finally:
with get_session_factory()() as session:
session.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.parent_batch_no == number))
session.commit()
def test_执行锁跨提交保持且释放后可获取():
with get_session_factory()() as owner, get_session_factory()() as other:
with sync_lock(owner):
owner.execute(text("SELECT 1"))
owner.commit()
with pytest.raises(TPlusBusyError), sync_lock(other):
pytest.fail("另一执行者不应取得锁")
with sync_lock(other):
assert other.scalar(text("SELECT 1")) == 1
@@ -0,0 +1,129 @@
"""管理员可管理原始读取任务;权限、幂等和异常脱敏由真实接口验证。"""
import pytest
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import TPlusSyncBatch, TPlusSyncError
pytestmark = pytest.mark.integration
BASE = "/api/v1/system/tplus"
def test_未登录和普通用户不能访问管理(client, admin, normal, tplus_tasks):
assert client.get(BASE + "/overview").status_code == 401
assert client.get(BASE + "/overview", headers=normal["headers"]).status_code == 403
assert (
client.post(BASE + "/batches", json=tplus_tasks(), headers=normal["headers"]).status_code
== 403
)
def test_创建重复请求返回同一批次并可分页查看(client, admin, tplus_tasks):
payload = tplus_tasks()
first = client.post(BASE + "/batches", json=payload, headers=admin["headers"])
assert first.status_code == 200
second = client.post(BASE + "/batches", json=payload, headers=admin["headers"])
assert second.status_code == 200
number = first.json()["data"]["batch_no"]
assert second.json()["data"]["batch_no"] == number
page = client.get(
BASE + "/batches", params={"pageSize": 1, "status": "QUEUED"}, headers=admin["headers"]
)
assert page.status_code == 200 and page.json()["data"]["pageSize"] == 1
detail = client.get(BASE + "/batches/" + number, headers=admin["headers"])
assert detail.status_code == 200
assert detail.json()["data"]["status"] == "QUEUED"
def test_异常返回固定说明不泄漏数据库参数(client, admin, tplus_tasks):
response = client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"])
assert response.status_code == 200
number = response.json()["data"]["batch_no"]
with get_session_factory()() as db:
batch = db.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
batch.status, batch.error_message = "FAILED", "password=private-value phone=13800000000"
db.add(
TPlusSyncError(
batch_no=number,
entity_type="personnel",
source_table="AA_Person",
source_id="12",
error_code="READ_ERROR",
error_message=batch.error_message,
)
)
db.commit()
for path in (f"/batches/{number}", f"/batches/{number}/errors"):
result = client.get(BASE + path, headers=admin["headers"])
assert result.status_code == 200
assert "private-value" not in result.text and "13800000000" not in result.text
def test_不支持的实体和不存在的批次有明确错误(client, admin, tplus_tasks):
payload = tplus_tasks()
payload["entity_types"] = ["users;drop"]
assert client.post(BASE + "/batches", json=payload, headers=admin["headers"]).status_code == 400
assert client.get(BASE + "/batches/TPmissing", headers=admin["headers"]).status_code == 404
def test_配置关闭时可查询历史但不能排队(client, admin, tplus_tasks, monkeypatch):
from app.services.tplus import task_service
settings = task_service.get_settings().model_copy(update={"t_plus_enabled": False})
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
response = client.get(BASE + "/overview", headers=admin["headers"])
assert response.status_code == 200
assert response.json()["data"]["is_configured"] is False
assert response.json()["data"]["business_import_enabled"] is False
assert response.json()["data"]["compatibility_status"] == "PENDING"
assert (
client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"]).status_code
== 400
)
def test_失败批次重试保留异常并返回新批次(client, admin, tplus_tasks):
from app.models import OperationLog
parent = client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"]).json()[
"data"
]
number = parent["batch_no"]
with get_session_factory()() as db:
batch = db.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
batch.status, batch.failed_record_count = "FAILED", 1
db.add(
TPlusSyncError(
batch_no=number,
entity_type="personnel",
source_table="AA_Person",
source_id="1",
error_code="READ_ERROR",
error_message="读取失败",
)
)
db.commit()
request = {"request_key": tplus_tasks()["request_key"]}
first = client.post(BASE + f"/batches/{number}/retry", json=request, headers=admin["headers"])
replay = client.post(BASE + f"/batches/{number}/retry", json=request, headers=admin["headers"])
assert first.status_code == replay.status_code == 200
child = first.json()["data"]
assert child["parent_batch_no"] == number and child["batch_no"] != number
assert replay.json()["data"]["batch_no"] == child["batch_no"]
detail = client.get(BASE + f"/batches/{number}", headers=admin["headers"]).json()["data"]
assert detail["can_retry"] is False
with get_session_factory()() as db:
assert (
db.scalar(select(TPlusSyncError.retry_count).where(TPlusSyncError.batch_no == number))
== 1
)
assert db.scalar(
select(OperationLog.id)
.where(
OperationLog.user_id == admin["id"],
OperationLog.module == "T+数据同步",
OperationLog.action == "重试读取任务",
)
.limit(1)
)
+6 -1
View File
@@ -13,7 +13,12 @@ VALID_SECRET = "x" * 32
def _settings(**overrides: object) -> Settings: def _settings(**overrides: object) -> Settings:
"""构造一个不读取 .env 的配置实例。""" """构造一个不读取 .env 的配置实例。"""
params: dict[str, object] = {"_env_file": None, "app_secret_key": VALID_SECRET} # _env_file=None 只禁用文件读取;显式值避免隔离测试库的环境变量影响默认值断言。
params: dict[str, object] = {
"_env_file": None,
"app_secret_key": VALID_SECRET,
"mysql_database": "payroll",
}
params.update(overrides) params.update(overrides)
return Settings(**params) # type: ignore[arg-type] return Settings(**params) # type: ignore[arg-type]
+4
View File
@@ -44,6 +44,10 @@ EXPECTED_TABLES = {
"monthly_settlements", "monthly_settlements",
"settlement_items", "settlement_items",
"adjustment_orders", "adjustment_orders",
"tplus_sync_batches",
"tplus_sync_errors",
"tplus_sync_records",
"tplus_sync_watermarks",
} }
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束 # 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
+3 -6
View File
@@ -127,8 +127,7 @@ class TestExcel模板:
cell cell
for row in sheet.iter_rows() for row in sheet.iter_rows()
for cell in row for cell in row
if isinstance(cell.value, (int, float)) if isinstance(cell.value, (int, float)) and Decimal(str(cell.value)) == Decimal("39.11")
and Decimal(str(cell.value)) == Decimal("39.11")
] ]
assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)" assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)"
assert amount_cells[0].number_format == "#,##0.00" assert amount_cells[0].number_format == "#,##0.00"
@@ -142,16 +141,14 @@ class TestExcel模板:
workbook = load_workbook(io.BytesIO(content)) workbook = load_workbook(io.BytesIO(content))
cells = [cell for row in workbook.active.iter_rows() for cell in row] cells = [cell for row in workbook.active.iter_rows() for cell in row]
values = [ values = [
Decimal(str(cell.value)) Decimal(str(cell.value)) for cell in cells if isinstance(cell.value, (int, float))
for cell in cells
if isinstance(cell.value, (int, float))
] ]
assert Decimal("15.50") in values assert Decimal("15.50") in values
# 「合计」标签只写一次(写满每个文本列是回归缺陷) # 「合计」标签只写一次(写满每个文本列是回归缺陷)
assert sum(1 for cell in cells if cell.value == "合计") == 1 assert sum(1 for cell in cells if cell.value == "合计") == 1
def test_文本公式注入被前置单引号(self): def test_文本公式注入被前置单引号(self):
evil = "=HYPERLINK(\"http://evil\")" evil = '=HYPERLINK("http://evil")'
content = _build("personal-detail", [_personal_row(business_no=evil)]) content = _build("personal-detail", [_personal_row(business_no=evil)])
workbook = load_workbook(io.BytesIO(content)) workbook = load_workbook(io.BytesIO(content))
values = [ values = [
@@ -0,0 +1,92 @@
"""兼容性脚本与应用使用相同连接策略,失败报告不泄露连接数据。"""
import importlib.util
import json
import sys
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from app.core.config import Settings
@pytest.fixture
def script(monkeypatch):
# 纯单元测试不需要安装 ODBC 驱动或连接源库。
monkeypatch.setitem(sys.modules, "pyodbc", MagicMock())
path = Path(__file__).resolve().parents[3] / "scripts/tplus/check_compatibility.py"
spec = importlib.util.spec_from_file_location("compatibility_script", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
settings = Settings(_env_file=None, app_secret_key="x" * 32, t_plus_encrypt=True)
monkeypatch.setattr(module, "get_settings", lambda: settings)
return module
def test_脚本复用连接文件校验与加密设置(script, tmp_path):
path = tmp_path / "reader.json"
payload = {
"host": "localhost",
"port": 1433,
"database": "data;x}",
"user": "reader;other}",
"password": "test-only;x}",
"driver": "TestDriver",
}
path.write_text(json.dumps(payload), encoding="utf-8")
resolved = script.compatibility_settings(path)
assert resolved.t_plus_database_url.database == payload["database"]
assert resolved.t_plus_database_url.username == payload["user"]
assert resolved.t_plus_database_url.query["Encrypt"] == "yes"
assert script.get_settings().t_plus_enabled is False
payload["user"] = "sa"
path.write_text(json.dumps(payload), encoding="utf-8")
with pytest.raises(RuntimeError, match="专用只读"):
script.compatibility_settings(path)
@pytest.mark.parametrize(
"version,passed",
[
("10.50.1600", True),
("10.50.1600.0", True),
("10.50.6000", False),
("16.0.4165.4", False),
],
)
def test_同主版本或2022副本不能冒充备份源同版本(script, version, passed):
connection = MagicMock()
cursor = connection.cursor.return_value
cursor.execute.return_value.fetchone.side_effect = [
(version,),
("中文核验", Decimal("12345.6789"), datetime(2008, 1, 2, 3, 4, 5)),
(0, 0, 0),
]
connection.getinfo.return_value = "test-driver"
report = script._inspect_connection(connection, "10.50.1600")
assert report["compatible"] is passed
cursor.close.assert_called_once()
def test_连接失败不降级且报告不带驱动异常原文(script, tmp_path, monkeypatch, capsys):
report_path = tmp_path / "report.json"
monkeypatch.setattr(
sys, "argv", ["check", "--connection-file", "unused.json", "--report", str(report_path)]
)
monkeypatch.setattr(script, "compatibility_settings", lambda path: script.get_settings())
engine = MagicMock()
engine.raw_connection.side_effect = RuntimeError("PWD=secret-test-only;手机号")
create = MagicMock(return_value=engine)
monkeypatch.setattr(script, "create_engine", create)
assert script.main() == 2
create.assert_called_once()
engine.raw_connection.assert_called_once()
engine.dispose.assert_called_once()
assert json.loads(report_path.read_text(encoding="utf-8")) == {
"compatible": False,
"error_type": "RuntimeError",
}
assert "secret-test-only" not in capsys.readouterr().out
+87
View File
@@ -0,0 +1,87 @@
"""T+ 配置和读取适配层的纯单元测试。"""
from pathlib import Path
import pytest
from sqlalchemy.dialects.mssql.pyodbc import MSDialect_pyodbc
from app.core.config import Settings
from app.services.tplus.mapping import MappingStatus, get_mapping
from app.services.tplus.reader import TPlusReader
def _settings(**overrides: object) -> Settings:
params: dict[str, object] = {"_env_file": None, "app_secret_key": "x" * 32}
params.update(overrides)
return Settings(**params) # type: ignore[arg-type]
def test_T加数据库URL使用mssql_pyodbc并隐藏密码() -> None:
settings = _settings(
t_plus_enabled=True,
t_plus_database="UFTData472115_000001",
t_plus_user="reader",
t_plus_password="p@ss:w/ord",
)
url = settings.t_plus_database_url
assert url.drivername == "mssql+pyodbc"
assert url.database == "UFTData472115_000001"
assert "p@ss:w/ord" not in url.render_as_string()
def test_T加未启用时读取器不建立连接() -> None:
settings = _settings(t_plus_enabled=False)
assert settings.t_plus_enabled is False
def test_T加默认加密且允许显式隔离环境回退(monkeypatch) -> None:
monkeypatch.delenv("T_PLUS_ENCRYPT", raising=False)
monkeypatch.delenv("T_PLUS_TRUST_SERVER_CERTIFICATE", raising=False)
assert _settings().t_plus_database_url.query["Encrypt"] == "yes"
assert _settings().t_plus_database_url.query["TrustServerCertificate"] == "yes"
url = _settings(t_plus_encrypt=False, t_plus_trust_server_certificate=False).t_plus_database_url
assert url.query["Encrypt"] == "no"
assert url.query["TrustServerCertificate"] == "no"
@pytest.mark.parametrize("value", ["a;b", "a}b", "{prefix", "中文;UID=other}x"])
def test_ODBC特殊字符保持字段边界且不泄露密码(value) -> None:
settings = _settings(
t_plus_odbc_driver="Driver}Name",
t_plus_database=value,
t_plus_user=value,
t_plus_password=value,
t_plus_encrypt=True,
)
url = settings.t_plus_database_url
args, _ = MSDialect_pyodbc().create_connect_args(url)
escaped = "{" + value.replace("}", "}}") + "}"
assert f"Database={escaped};UID={escaped};PWD={escaped};" in args[0]
assert "DRIVER={Driver}}Name}" in args[0]
assert ";Encrypt=yes;" in args[0]
assert url.render_as_string().split("@", 1)[0].endswith(":***")
def test_T加查询文件覆盖六类实体() -> None:
reader = TPlusReader.__new__(TPlusReader)
reader.query_dir = Path(__file__).resolve().parents[3] / "db" / "queries" / "tplus"
assert reader.inventory() == (
"personnel",
"products",
"customers",
"businesses",
"shipments",
"receipts",
)
for entity in reader.inventory():
assert (reader.query_dir / f"{entity}.sql").is_file()
def test_T加高风险映射保持待确认() -> None:
assert get_mapping("businesses").status is MappingStatus.PENDING
assert "成本口径" in get_mapping("businesses").unresolved
def test_未知T加实体被拒绝() -> None:
with pytest.raises(KeyError):
get_mapping("unknown")
@@ -0,0 +1,72 @@
"""显式连接文件不应污染应用配置,也不能启用未授权的数据源。"""
import json
import pytest
from app.core.config import Settings
from app.core.tplus_db import resolve_tplus_settings
def test_未开启连接时不读取配置文件(tmp_path):
settings = Settings(
_env_file=None,
app_secret_key="x" * 32,
t_plus_connection_file=str(tmp_path / "missing.json"),
)
with pytest.raises(RuntimeError, match="未启用"):
resolve_tplus_settings(settings)
def test_文件连接只覆盖源字段且原Settings不变(tmp_path):
path = tmp_path / "reader.json"
path.write_text(
json.dumps(
{
"host": "127.0.0.1",
"port": 1433,
"database": "UFTData",
"user": "reader",
"password": "test-only-value",
"driver": "SQL Server",
}
),
encoding="utf-8",
)
settings = Settings(
_env_file=None,
app_secret_key="x" * 32,
t_plus_enabled=True,
t_plus_connection_file=str(path),
)
resolved = resolve_tplus_settings(settings)
assert resolved.t_plus_database == "UFTData"
assert resolved.mysql_database == settings.mysql_database
assert settings.t_plus_password == ""
assert "test-only-value" not in str(resolved.t_plus_database_url)
def test_连接文件不接受sa或应用配置覆盖(tmp_path):
path = tmp_path / "bad.json"
path.write_text(
json.dumps(
{
"host": "127.0.0.1",
"port": 1433,
"database": "UFTData",
"user": "sa",
"password": "test-only-value",
"driver": "SQL Server",
"app_secret_key": "bad",
}
),
encoding="utf-8",
)
settings = Settings(
_env_file=None,
app_secret_key="x" * 32,
t_plus_enabled=True,
t_plus_connection_file=str(path),
)
with pytest.raises(RuntimeError):
resolve_tplus_settings(settings)
@@ -0,0 +1,43 @@
"""对外元数据必须过滤历史原异常和未知报告字段。"""
from uuid import uuid4
from app.models import TPlusSyncError
from app.schemas.tplus import TPlusCreateIn, TPlusErrorQuery
from app.services.tplus import query_service, task_service
from app.services.tplus.relationship_audit import public_report
def test_历史报告只开放白名单并保持兼容待验():
result = public_report(
{
"source_version": "16.0",
"compatibility_status": "PASSED",
"password": "sensitive",
"sales_links": {"row_count": 1, "payload": "secret"},
"state_enums": [{"name": "password=hidden", "extra": "secret"}],
}
)
assert result["compatibility_status"] == "PENDING"
assert "password" not in result and "payload" not in result["sales_links"]
assert "hidden" not in str(result) and "secret" not in str(result)
def test_异常不返回手机号或原始错误文本(tplus_session):
batch = task_service.create_task(
tplus_session, TPlusCreateIn(entity_types=["personnel"], request_key=uuid4())
)
tplus_session.add(
TPlusSyncError(
batch_no=batch.batch_no,
entity_type="personnel",
source_table="AA_Person",
source_id="13800000000",
error_code="READ_ERROR",
error_message="password=private",
)
)
tplus_session.commit()
page = query_service.list_errors(tplus_session, batch.batch_no, TPlusErrorQuery())
assert page.records[0].source_id is None
assert "private" not in page.model_dump_json()
@@ -0,0 +1,97 @@
"""关联核验必须使用真实类型字典,不能依靠恰好相同的跨表 ID。"""
from unittest.mock import Mock
import pytest
from app.services.tplus.relationship_audit import TPlusRelationshipAudit
def _audit(types=None):
source = Mock()
rows = {
"source_version": [{"source_version": "16.0.4165.4"}],
"voucher_types": types
if types is not None
else [
{"id": 9104, "code": "SA04", "name": "销货单"},
{"id": 9092, "code": "ARAP01", "name": "收款单"},
],
"state_enums": [{"id": 189, "code": "01", "name": "生效", "enum_name": "VoucherState"}],
"receipt_kinds": [
{
"voucher_type_code": "ARAP01",
"voucher_type_name": "收款单",
"receive_flag": 1,
"row_count": 10,
}
],
"receipt_line_kinds": [
{"voucher_type_id": 9104, "voucher_type_name": "销货单", "row_count": 12}
],
"sales_links": [
{
"row_count": 12,
"delivery_matches": 12,
"detail_matches": 7,
"direct_order_matches": 4,
"invalid_order_ids": 0,
}
],
"order_coverage": [
{"row_count": 12, "without_order": 2, "single_order": 8, "multiple_orders": 2}
],
}
audit = TPlusRelationshipAudit(source)
audit.fetch = Mock(side_effect=lambda name, **kw: rows[name])
return audit
def test_类型标识从字典读取而不是硬编码104():
audit = _audit()
result = audit.run()
args = next(call.kwargs for call in audit.fetch.call_args_list if call.args[0] == "sales_links")
assert args == {"sale_delivery_type_id": 9104, "receive_flag": 1}
assert result["sales_links"]["delivery_matches"] == 12
def test_枚举名称按源库保留且2022不通过2008验收():
result = _audit().run()
assert result["state_enums"][0]["name"] == "生效"
assert result["is_target_engine"] is False
assert result["compatibility_status"] == "PENDING"
def test_同类型字典有歧义就停止该链路统计不猜ID():
audit = _audit(
[
{"id": 1, "code": "SA04", "name": "A"},
{"id": 2, "code": "SA04", "name": "B"},
{"id": 3, "code": "ARAP01", "name": "收款单"},
]
)
result = audit.run()
assert result["relationship_status"] == "UNRESOLVED"
assert result["sales_links"] is None
assert not any(call.args[0] == "sales_links" for call in audit.fetch.call_args_list)
def test_多订单和成本日期仍需业务确认不自动选择():
result = _audit().run()
assert result["order_coverage"]["multiple_orders"] == 2
keys = {item["key"] for item in result["pending_confirmations"]}
assert {"cost_basis", "receipt_date", "ship_date", "allocation"} <= keys
def test_SQL查询有固定白名单且不接受自定义表名():
audit = TPlusRelationshipAudit(Mock())
with pytest.raises(ValueError, match="核验查询"):
audit.fetch("SELECT * FROM customers")
def test_主表来源单据类型为空不妨碍按是否收款核验():
# 字典明确 idvouchertype 是“来源单据类型”,不是当前收款单的类型。
audit = _audit([{"id": 9104, "code": "SA04", "name": "销货单"}])
assert audit.run()["relationship_status"] == "OBSERVED"
assert "p.isReceiveFlag = :receive_flag" in audit.queries["sales_links"]
assert "p.idvouchertype =" not in audit.queries["sales_links"]
@@ -0,0 +1,40 @@
"""限制 T+ 源查询语法;真实 2008 R2 的驱动/引擎实测仍是最终验收条件。"""
import re
from pathlib import Path
import pytest
QUERY_ROOT = Path(__file__).resolve().parents[3] / "db" / "queries" / "tplus"
@pytest.mark.parametrize("path", sorted(QUERY_ROOT.glob("*.sql")), ids=lambda p: p.stem)
def test_源查询只使用旧版本分页并保持只读(path):
sql = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8"))
assert not re.search(
r"\b(OFFSET|FETCH|OPENJSON|JSON_VALUE|STRING_AGG|TRY_CONVERT|TRY_CAST|"
r"INSERT|UPDATE|DELETE|MERGE|CREATE|ALTER|DROP|TRUNCATE)\b",
sql,
re.I,
)
assert "TOP (:batch_size)" in sql
assert ":after_id" in sql
assert not re.search(r"SELECT\s+\*", sql, re.I)
def test_收付款主表没有更新时间时不虚构水位():
sql = (QUERY_ROOT / "receipts.sql").read_text(encoding="utf-8")
assert "CAST(NULL AS datetime) AS source_updated_at" in sql
assert not re.search(r"h\.updated\b", sql, re.I)
def test_关联核验保持只读且不使用新版函数():
sql = re.sub(r"--[^\n]*", "", (QUERY_ROOT / "audit/queries.sql").read_text(encoding="utf-8"))
assert not re.search(
r"\b(OFFSET|FETCH|OPENJSON|JSON_VALUE|STRING_AGG|TRY_CONVERT|TRY_CAST|"
r"INSERT|UPDATE|DELETE|MERGE|CREATE|ALTER|DROP|TRUNCATE)\b",
sql,
re.I,
)
assert not re.search(r"SELECT\s+\*", sql, re.I)
assert ":sale_delivery_type_id" in sql and ":receive_flag" in sql
+126
View File
@@ -0,0 +1,126 @@
"""T+ staging 的幂等和原始行保护测试。"""
import json
from datetime import datetime
from decimal import Decimal
import pytest
from app.models import TPlusSyncRecord
from app.services.tplus.staging import stage_row
class _Result:
def __init__(self, value: TPlusSyncRecord | None) -> None:
self.value = value
def scalar_one_or_none(self) -> TPlusSyncRecord | None:
return self.value
class _Session:
def __init__(self, existing: TPlusSyncRecord | None = None) -> None:
self.existing = existing
self.added: list[object] = []
self.flushed = False
def execute(self, _statement: object) -> _Result:
return _Result(self.existing)
def add(self, value: object) -> None:
self.added.append(value)
def flush(self) -> None:
self.flushed = True
def _row() -> dict[str, object]:
return {
"id": 1001,
"code": "SO-1001",
"updated": datetime(2026, 10, 27, 10, 30),
"amount": 12.5,
}
def test_stage_row_首次写入保留源编号更新时间和哈希() -> None:
session = _Session()
record, inserted = stage_row(
session,
batch_no="TP20261027103000ABCD",
entity_type="businesses",
source_table="SA_SaleOrder",
row=_row(),
)
assert inserted is True
assert isinstance(session.added[0], TPlusSyncRecord)
assert record.source_id == "1001"
assert record.source_no == "SO-1001"
assert record.source_updated_at == datetime(2026, 10, 27, 10, 30)
assert len(record.row_hash) == 64
assert session.flushed is True
def test_stage_row_同一行重复执行返回已有记录() -> None:
existing = TPlusSyncRecord(
batch_no="TP20261027103000ABCD",
entity_type="businesses",
source_table="SA_SaleOrder",
source_id="1001",
row_hash="x" * 64,
payload_json={},
)
session = _Session(existing)
record, inserted = stage_row(
session,
batch_no="TP20261027103000EFGH",
entity_type="businesses",
source_table="SA_SaleOrder",
row=_row(),
)
assert inserted is False
assert record is existing
assert session.added == []
def test_stage_row_缺少源主键时拒绝写入() -> None:
with pytest.raises(ValueError, match="缺少 id"):
stage_row(
_Session(),
batch_no="TP20261027103000ABCD",
entity_type="businesses",
source_table="SA_SaleOrder",
row={"code": "SO-1001"},
)
def test_stage_row_日期金额和二进制行版本能够实际序列化() -> None:
row = {"id": 0, "code": "A", "updated": b"\x00\x01", "amount": Decimal("12.50")}
record, _ = stage_row(
_Session(), batch_no="TP1", entity_type="products", source_table="AA_Inventory", row=row
)
assert record.source_id == "0"
assert record.source_updated_at is None
assert record.payload_json["updated"] == "0x0001"
assert record.payload_json["amount"] == "12.50"
assert json.loads(json.dumps(record.payload_json)) == record.payload_json
def test_stage_row_键顺序不影响原始行哈希() -> None:
first, _ = stage_row(
_Session(),
batch_no="TP1",
entity_type="products",
source_table="AA_Inventory",
row={"id": 1, "code": "A"},
)
second, _ = stage_row(
_Session(),
batch_no="TP2",
entity_type="products",
source_table="AA_Inventory",
row={"code": "A", "id": 1},
)
assert first.row_hash == second.row_hash
+110
View File
@@ -0,0 +1,110 @@
"""用真实事务验证 T+ staging 重跑、失败隔离和敏感信息保护。"""
from contextlib import nullcontext
from unittest.mock import Mock
import pytest
from sqlalchemy import DefaultClause, Integer, MetaData, create_engine, func, select, text
from sqlalchemy.orm import Session
from app.core.config import Settings
from app.models import TPlusSyncBatch, TPlusSyncError, TPlusSyncRecord
from app.services.tplus.sync_service import TPlusSyncService
@pytest.fixture
def target(monkeypatch):
"""以 SQLite 执行 staging 事务,MySQL 专属 DDL 仍由迁移测试覆盖。"""
engine = create_engine("sqlite:///:memory:")
metadata = MetaData()
for model in (TPlusSyncBatch, TPlusSyncError, TPlusSyncRecord):
table = model.__table__.to_metadata(metadata)
table.c.id.type = Integer()
table.c.update_time.server_default = DefaultClause(text("CURRENT_TIMESTAMP"))
metadata.create_all(engine)
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr("app.services.tplus.sync_service.get_settings", lambda: settings)
monkeypatch.setattr("app.services.tplus.sync_service.sync_lock", lambda session: nullcontext())
with Session(engine, expire_on_commit=False) as session:
yield session
engine.dispose()
def _service(target, rows):
service = TPlusSyncService(Mock(), target)
service.reader = Mock()
service.reader.iter_entity.side_effect = lambda *a, **kw: iter(rows)
return service
def test_重复全量读取不增加记录且业务表未被引用(target):
rows = [{"id": 1, "code": "A"}, {"id": 2, "code": "B"}]
service = _service(target, rows)
first = service.run(entity_types=["products"])
second = service.run(entity_types=["products"])
assert first.status == second.status == "SUCCEEDED"
assert first.staged_record_count == 2
assert second.staged_record_count == 0
assert second.source_record_count == 2
assert target.scalar(select(func.count()).select_from(TPlusSyncRecord)) == 2
assert second.watermark_to is None
def test_单条缺失主键不会回滚其他有效行(target):
service = _service(target, [{"id": 1, "code": "A"}, {"code": "bad"}, {"id": 2}])
batch = service.run(entity_types=["products"])
assert batch.status == "PARTIAL"
assert batch.staged_record_count == 2
assert batch.failed_record_count == 1
assert target.scalar(select(func.count()).select_from(TPlusSyncRecord)) == 2
def test_数据库约束失败后仍可写入下一条(target):
service = _service(target, [{"id": 1, "code": "A"}])
# 用数据库真实触发器拒绝坏行,验证 executemany 失败后的回滚和逐条隔离。
service.reader.iter_entity.side_effect = lambda *a, **kw: iter([{"id": 1}, {"id": 2}])
target.execute(
text(
"CREATE TRIGGER reject_first BEFORE INSERT ON tplus_sync_records "
"WHEN NEW.source_id='1' BEGIN SELECT RAISE(ABORT, 'invalid row'); END"
)
)
batch = service.run(entity_types=["products"])
assert batch.status == "PARTIAL"
assert batch.staged_record_count == 1
assert target.scalar(select(TPlusSyncRecord.source_id)) == "2"
def test_读取失败有审计且异常中的敏感参数不落日志(target):
service = _service(target, [])
service.reader.iter_entity.side_effect = RuntimeError("password=secret phone=13800000000")
batch = service.run(entity_types=["products"])
assert batch.status == "FAILED"
error = target.scalar(select(TPlusSyncError))
assert error.error_code == "READ_ERROR"
assert "secret" not in error.error_message
assert "13800000000" not in error.error_message
def test_成功一类后另一类读取失败应标记部分成功(target):
service = _service(target, [])
def source(entity, **kwargs):
if entity == "products":
return iter([{"id": 1}])
raise RuntimeError("读取中断")
service.reader.iter_entity.side_effect = source
batch = service.run(entity_types=["products", "customers"])
assert batch.status == "PARTIAL"
assert batch.source_record_count == batch.staged_record_count == 1
assert batch.failed_record_count == 1
def test_未确认时间增量不能创建误导性批次(target):
from datetime import datetime
service = _service(target, [])
with pytest.raises(ValueError, match="增量水位语义尚未确认"):
service.run(entity_types=["receipts"], watermark=datetime(2026, 10, 27))
assert target.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff. Show more