Compare commits

..

56 Commits

Author SHA1 Message Date
郑博文
ac4ba7514b merge: 集成客户评级弹窗、公司信息精简与参数中文化
将 feat/settings-rating-brand(基于 927592b)合并到最新 main(d03ddad):
- 冲突收口:系统设置服务/契约、集成测试夹具、布局/路由/登录页、客户列表
- 迁移重编号:本分支未发布的三个迁移压成 V11(客户评级 + 站点信息 + 版本号),
  down_revision 接到 main 的 90653814a421,db/migration 相应改为 V11
- 站点图标上传与 logo 上传统一为同步 def,超限先按 file.size 拦截
2026-10-20 11:42:49 +08:00
郑博文
27533eb465 wip: 客户评级弹窗化/公司信息精简/参数中文化/品牌站点同步(基于 927592b) 2026-10-20 11:06:01 +08:00
liuwenxin
4c10475228 merge: 合并 T+ 字段映射草案与清单 v1.5 至 main 2026-10-20 10:57:09 +08:00
liuwenxin
2e0c64849b docs(tplus): 收敛 IT 待确认事项并更新催办清单
原 18 项收敛为 13 项 IT 待确认 + 1 组业务裁决,每项附字典候选字段与来源;同步更新待确认汇总第三节与文档待办。字段级证据见映射草案 v0.1。

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:55:57 +08:00
liuwenxin
d03ddadd1a merge: 合并 M4 阶段报告至 main 2026-10-20 10:19:22 +08:00
liuwenxin
c319a6a428 docs: M4 阶段报告(2026-10-20)
汇总报表统计与工作台交付范围、关键口径、测试证据与遗留事项。

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

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

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

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

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

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

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

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

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

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

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

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

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

验证:pnpm type-check 绿

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 09:44:50 +08:00
166 changed files with 21103 additions and 1035 deletions

6
.gitignore vendored
View File

@ -64,3 +64,9 @@ CLAUDE.md
backend/uploads/
# 数据库备份导出目录(⑨ 备份脚本产物)
db/backups/
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
docs/**/*.rar
docs/T_12.3数据字典/
# macOS 压缩包残留元数据
__MACOSX/

View File

@ -114,6 +114,31 @@ app/
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
### 报表统计接口(M4 ⑤,限管理员/财务/领导)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/reports/personal-detail` | 个人绩效工资明细表(FR-029:人员/月份/客户类型/产品类别/岗位筛选;响应带 `summary` 全筛选合计) |
| GET | `/api/v1/reports/department-summary` | 部门绩效工资汇总表(FR-030:月份必填;团队基数与月结聚合行同源;12.5% 与 5% 分列;含成员内联明细、未分配桶、月结冻结对照额) |
| GET | `/api/v1/reports/business-stats` | 业务统计表(FR-031:发货/回款/绩效为业务累计值,不随业务日期筛选变化) |
| GET | `/api/v1/reports/payment-stats` | 回款情况统计表(FR-031:8 档天数区间恒在,空桶为 0) |
| GET | `/api/v1/reports/payment-detail` | 回款区间下钻(`bucket` 必填,未注册编码 400) |
| GET | `/api/v1/reports/payment-day-buckets` | 区间档位元数据(前端下拉唯一来源,禁止前端硬编码) |
| GET | `/api/v1/reports/{code}/export` | Excel 导出(文件流非统一信封;含公司页眉/财务复核栏/领导签字栏;超 2 万行 400;行为入操作日志) |
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
### 工作台接口(M4 ⑧,限管理员/财务/领导)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/dashboard?month=YYYY-MM` | 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志) |
待办与预警**实时派生**(不落表、不引调度器);预警阈值走参数体系
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
## 常用命令
```bash
@ -196,6 +221,32 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
### 工作台口径(M4 ⑧,改动前先读)
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定,**已终止业务(status=3)排除**;回款未指定批次 = `receipts.shipment_id IS NULL`。
- **待办按角色过滤**:`pending_statuses_for_roles`(approval_service)把当前用户角色映射到在途节点——财务只看待财务复核(status=1)、领导只看待领导审核(status=2)、admin 可看全部;多角色取并集。风险预警仍是全局经营视角,不按人裁剪。
- **预警展示上限**:`items` 最多 20 条,`total` 是全部命中数(前端徽标用它,不能用 `items.length`)。
- **趋势窗口**:回款趋势先按 `receipt_date` 范围过滤再按 `DATE_FORMAT` 分组,函数列不参与 WHERE,保证索引可用。
- **同步降级**:⑥ 未建时 `sync_available=false`,前端显示「模块未启用」——不要返回 0 条冒充「无失败」。
- **参数快照一次解析**:`warning.*` 三个阈值在每次请求只 `resolve_snapshot` 一次,多条规则复用。
- **导出 SQL 的编码坑**:Windows 下 `alembic upgrade --sql > V*.sql` 默认按控制台 GBK 输出,`test_schema_sync` 按 UTF-8 读取会报 `UnicodeDecodeError`——必须 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1` 前缀。
### 报表口径(M4 ⑤,改动前先读)
- **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。
- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入,**不要求该人当月有台账行**(只有调整单也要出零绩效成员行);`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。调整单归属跟随该人普通行的实时 `manager_id`;只有经理个人户的经理跟随本人团队。
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块)。
- **全公司 5% 的发放口径**:月结只为有台账 bucket 的营销经理各生成一条聚合行(12.5%+5%),**每位经理各计一次 5%**;零成员团队无聚合行、不发 5%。报表的 `aggregate_amount` 优先取冻结聚合行、未生成月结时才用 `manager_team_share` 一次舍入预览;`team_subtotal`/`net_total` = 聚合额 + 调整净额。`team_share_total` 与 `company_share_total` 仅分列展示,两者之和与聚合额的差记入 `aggregate_rounding_diff`(分列各自 q2 会有 0.01~0.02 尾差)。
- **对拍边界**:报表应发 = 月结 `total_payable` + 调整单净额(调整单不进月结 items);筛选 `manager_id` 时只汇总展示团队,隐藏金额进 `other_*`,不能与全公司月结总额对拍。
- **人员归属按行类型**:普通台账行跟随 `manager_id`;经理个人户行在 `manager.personal.included=1` 时归经理本人团队,且经理成员行**只取个人户金额**(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 `manager_id` 筛选时 `unassigned` 为空(语义是「仅看该经理团队」)。
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);第二块展示单经理 5%、计入次数与合计,对账块展示团队分成/全公司分成(分列)、聚合行舍入差、调整净额,应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。

View File

@ -0,0 +1,632 @@
"""V7 核算台账/月结/调整单四表与调整单开关种子
Revision ID: 3bca400421c6
Revises: 79bcc5aa98d4
Create Date: 2026-10-16 15:54:53.000890
"""
from collections.abc import Sequence
from datetime import date
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "3bca400421c6"
down_revision: str | Sequence[str] | None = "79bcc5aa98d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"monthly_settlements",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="结算月份(YYYY-MM;全表唯一)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
),
sa.Column(
"total_performance",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="绩效金额合计(可负)",
),
sa.Column(
"total_payable",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="应付绩效合计(可负)",
),
sa.Column(
"person_count", mysql.TINYINT(), server_default="0", nullable=False, comment="结算人数"
),
sa.Column(
"snapshot_json",
sa.JSON(),
nullable=True,
comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)",
),
sa.Column(
"approval_order_no",
sa.String(length=32),
nullable=True,
comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)",
),
sa.Column(
"submitter_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="提交人ID(账号删除后置空)",
),
sa.Column(
"submitter_name",
sa.String(length=64),
nullable=False,
comment="提交人用户名(冗余固化)",
),
sa.Column("locked_at", sa.DateTime(), nullable=True, comment="锁定时间(status=3 时回填)"),
sa.Column(
"locked_by", sa.String(length=64), nullable=True, comment="锁定操作人(冗余固化)"
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["submitter_id"],
["users.id"],
name=op.f("fk_monthly_settlements_submitter_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_monthly_settlements")),
sa.UniqueConstraint(
"settlement_month", name=op.f("uk_monthly_settlements_settlement_month")
),
comment="月度结算表(按月一行;通过即锁定,锁定后走调整单)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"adjustment_orders",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"order_no",
sa.String(length=32),
nullable=False,
comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="调整人员ID(personnel.id)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="调整人员姓名(冗余固化)",
),
sa.Column("direction", mysql.TINYINT(), nullable=False, comment="方向:1补发 2扣减"),
sa.Column(
"amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="调整金额(恒正,方向表达语义)",
),
sa.Column(
"reason",
sa.String(length=255),
nullable=False,
comment="扣补说明(必填,审批与追溯锚点)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
),
sa.Column(
"submitter_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="提交人ID(账号删除后置空)",
),
sa.Column(
"submitter_name",
sa.String(length=64),
nullable=False,
comment="提交人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_adjustment_orders_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["submitter_id"],
["users.id"],
name=op.f("fk_adjustment_orders_submitter_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_adjustment_orders")),
sa.UniqueConstraint("order_no", name=op.f("uk_adjustment_orders_order_no")),
comment="调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"settlement_items",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"settlement_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="月结ID(monthly_settlements.id;头删明细随删)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="结算人员ID(personnel.id)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="结算人员姓名(冗余固化)",
),
sa.Column(
"role_code", sa.String(length=32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
),
sa.Column(
"calc_type",
mysql.TINYINT(),
nullable=False,
comment="核算类型:1单笔回款 2经理团队聚合",
),
sa.Column(
"performance_sum",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
),
sa.Column(
"payable_sum",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="应付绩效合计(可负)",
),
sa.Column(
"basic_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="月工资标准(personnel.salary_standard 当月快照)",
),
sa.Column(
"salary_mode",
sa.String(length=16),
nullable=True,
comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE",
),
sa.Column(
"net_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="净工资 = 绩效±基本工资(按口径;试用期 0)",
),
sa.Column(
"is_probation",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
),
sa.Column(
"probation_note",
sa.String(length=255),
nullable=True,
comment="试用期说明(年累计超阈值部分年底一次性结算)",
),
sa.Column(
"snapshot_json", sa.JSON(), nullable=True, comment="个人明细快照(台账行引用列表)"
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_settlement_items_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["settlement_id"],
["monthly_settlements.id"],
name=op.f("fk_settlement_items_settlement_id"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_settlement_items")),
comment="月结明细表(按人固化;支持负数工资;锁定后只读)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"performance_ledgers",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"receipt_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="回款ID(receipts.id;聚合行可为 NULL)",
),
sa.Column(
"calc_version",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="核算版本(重算+1,旧行 is_active=0)",
),
sa.Column(
"is_active",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
),
sa.Column(
"calc_type",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
),
sa.Column(
"business_no",
sa.String(length=32),
nullable=False,
comment="业务编号(冗余固化,列表免联查)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="归属营销人员ID(personnel.id;核算归属锚点)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="归属营销人员姓名(冗余固化)",
),
sa.Column(
"role_code",
sa.String(length=32),
nullable=False,
comment="岗位编码(business_roles.role_code,计提公式锚点)",
),
sa.Column(
"customer_type",
sa.String(length=32),
nullable=False,
comment="客户分类:personal/manager_personal/enterprise",
),
sa.Column(
"product_category",
sa.String(length=32),
nullable=False,
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
),
sa.Column("ship_date", sa.Date(), nullable=True, comment="发货日期(回款天数基准)"),
sa.Column(
"receipt_date",
sa.Date(),
nullable=False,
comment="回款日期(参数版本匹配日,裁决 #11)",
),
sa.Column(
"payment_days",
sa.Integer(),
nullable=True,
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)",
),
sa.Column(
"rate_text",
sa.String(length=32),
nullable=False,
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
),
sa.Column(
"converted_profit",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="折算毛利润额(可负;混合业务为各行汇总)",
),
sa.Column(
"performance_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="绩效金额(可负,裁决:负绩效照算不归零)",
),
sa.Column(
"barter_applied",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="以物抵顶折算:1已按 80% 折算 0否",
),
sa.Column(
"weight_r",
sa.Integer(),
nullable=True,
comment="支付权重 R(回款占销售额百分比向下取整)",
),
sa.Column(
"payment_weight",
sa.Numeric(precision=7, scale=6),
nullable=True,
comment="支付权重系数 R×R(展示用)",
),
sa.Column(
"payable_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)",
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)",
),
sa.Column(
"is_probation",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="试用期:1照算不发放 0否",
),
sa.Column(
"missing_cost",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
),
sa.Column(
"snapshot_json",
sa.JSON(),
nullable=True,
comment="计算快照(所用参数版本与行级折算明细,重算可回溯)",
),
sa.Column(
"settlement_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="所属月结ID(月结生成时回填;NULL=未入结)",
),
sa.Column(
"calc_reason",
sa.String(length=255),
nullable=True,
comment="核算/重算原因(重算必填留痕)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_performance_ledgers_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["receipt_id"],
["receipts.id"],
name=op.f("fk_performance_ledgers_receipt_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["settlement_id"],
["monthly_settlements.id"],
name=op.f("fk_performance_ledgers_settlement_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_performance_ledgers")),
sa.UniqueConstraint(
"receipt_id", "calc_version", name="uk_performance_ledgers_receipt_id_calc_version"
),
comment="绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_performance_ledgers_business_id", "performance_ledgers", ["business_id"], unique=False
)
op.create_index(
"idx_performance_ledgers_month_active",
"performance_ledgers",
["settlement_month", "is_active"],
unique=False,
)
op.create_index(
"idx_performance_ledgers_month_personnel_type",
"performance_ledgers",
["settlement_month", "personnel_id", "calc_type"],
unique=False,
)
op.create_index(
"idx_performance_ledgers_personnel_id",
"performance_ledgers",
["personnel_id"],
unique=False,
)
op.alter_column(
"approval_orders",
"order_type",
existing_type=mysql.TINYINT(),
comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
existing_comment="审批对象类型:1二次分配方案 2月度结算(预留)",
existing_nullable=False,
)
# ### end Alembic commands ###
# ---- 种子:调整单审批开关(V6 写法先例;definitions id 19 / values id 22)----
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 19,
"param_code": "approval.adjustment.enabled",
"param_name": "调整单审批开关",
"param_group": "审批开关",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
"调整单是已锁定月份补发/扣减的唯一通道"
),
"sort_order": 19,
"status": 1,
}
],
)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 22,
"param_id": 19,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
}
],
)
def downgrade() -> None:
"""回滚迁移。
整表回滚只保留 `op.drop_table`(V1/V3/V6 先例:外键唯一支撑索引单独
drop 会报 MySQL 1553)。行级种子(param_definitions 19 / param_values 22)
的宿主表由 V3 建、本迁移只插行,须显式 DELETE 否则重放 upgrade 撞唯一键。
删除顺序:先删引用方(ledger/items),再删被引用方(头表/调整单)。
"""
op.drop_table("performance_ledgers")
op.drop_table("settlement_items")
op.drop_table("adjustment_orders")
op.drop_table("monthly_settlements")
op.alter_column(
"approval_orders",
"order_type",
existing_type=mysql.TINYINT(),
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
existing_comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
existing_nullable=False,
)
op.execute("DELETE FROM param_values WHERE id = 22")
op.execute("DELETE FROM param_definitions WHERE id = 19")

View File

@ -255,6 +255,11 @@ def upgrade() -> None:
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_secondary_allocations")),
sa.UniqueConstraint(
"business_id",
"personnel_id",
name="uk_business_secondary_allocations_business_personnel",
),
comment="业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",

View File

@ -0,0 +1,143 @@
"""v10 工作台预警规则参数
Revision ID: 90653814a421
Revises: ba0217f5091b
Create Date: 2026-10-19 17:55:48.892998
新增风险预警三条参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」):
- warning.enabled:总开关(默认 1)
- warning.receipt.overdue.days:发货后逾期天数(默认 90)
- warning.approval.timeout.days:审批超时天数(默认 3)
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
"""
from collections.abc import Sequence
from datetime import date
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '90653814a421'
down_revision: str | Sequence[str] | None = 'ba0217f5091b'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_EFFECTIVE_DATE = date(2026, 10, 19)
def upgrade() -> None:
"""种入三个预警参数定义与生效值(append-only 时点化)。"""
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 21,
"param_code": "warning.enabled",
"param_name": "风险预警总开关",
"param_group": "预警规则",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "1",
"value_min": None,
"value_max": None,
"description": "工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
"sort_order": 21,
"status": 1,
},
{
"id": 22,
"param_code": "warning.receipt.overdue.days",
"param_name": "回款逾期天数阈值",
"param_group": "预警规则",
"value_type": "INT",
"unit": "天",
"dimension_type": "NONE",
"options_text": None,
"default_value": "90",
"value_min": "1",
"value_max": None,
"description": "发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)",
"sort_order": 22,
"status": 1,
},
{
"id": 23,
"param_code": "warning.approval.timeout.days",
"param_name": "审批超时天数阈值",
"param_group": "预警规则",
"value_type": "INT",
"unit": "天",
"dimension_type": "NONE",
"options_text": None,
"default_value": "3",
"value_min": "1",
"value_max": None,
"description": "待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)",
"sort_order": 23,
"status": 1,
},
],
)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 26,
"param_id": 21,
"dimension_value": "",
"effective_date": _EFFECTIVE_DATE,
"value_text": "1",
"batch_id": 1,
},
{
"id": 27,
"param_id": 22,
"dimension_value": "",
"effective_date": _EFFECTIVE_DATE,
"value_text": "90",
"batch_id": 1,
},
{
"id": 28,
"param_id": 23,
"dimension_value": "",
"effective_date": _EFFECTIVE_DATE,
"value_text": "3",
"batch_id": 1,
},
],
)
def downgrade() -> None:
"""回滚种子行(行级种子不会被 drop_table 带走,必须显式删除)。"""
op.execute("DELETE FROM param_values WHERE id IN (26, 27, 28)")
op.execute("DELETE FROM param_definitions WHERE id IN (21, 22, 23)")

View File

@ -0,0 +1,51 @@
"""V9:business_secondary_allocations 唯一键补齐(存量库自愈)。
背景:uk(business_id, personnel_id) 是随「就地修改已发布的 V6 迁移」加入的
(V6 建表语句里已含、硬编码名);**旧 V6 已应用的存量库 upgrade 到当前
head 时不会重放 V6**,拿不到该约束,V8 也未补加——两条升级路径的库结构
漂移(test_schema_sync 只比对表/列集合,约束级漂移拦不住)。
本迁移是**幂等条件式**:约束已存在(新库 / init-dev 重置库 / 已重建的
开发库)时跳过,不存在(旧 V6 升级库)时补加,避免重复约束名 errno 1826。
语句写成裸 SQL 而非 op.create_unique_constraint——后者在 --sql 离线导出
时会无条件输出 DDL,无法携带存在性判断。
注意:补键时若表内已存在重复 (business_id, personnel_id) 数据,ADD
CONSTRAINT 会失败(有意如此:先由人工清理重复数据再升级,不做静默去重)。
"""
from alembic import op
revision: str = "ba0217f5091b"
down_revision: str | None = "c7d2e5a81f30"
branch_labels = None
depends_on = None
_UK_NAME = "uk_business_secondary_allocations_business_personnel"
def upgrade() -> None:
"""条件式补唯一键(见模块 docstring 的背景与幂等说明)。"""
op.execute(
"SET @uk_exists := ("
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
"WHERE CONSTRAINT_SCHEMA = DATABASE() "
"AND TABLE_NAME = 'business_secondary_allocations' "
f"AND CONSTRAINT_NAME = '{_UK_NAME}'"
")"
)
op.execute(
"SET @ddl := IF(@uk_exists = 0, "
f"'ALTER TABLE business_secondary_allocations ADD CONSTRAINT {_UK_NAME} "
"UNIQUE (business_id, personnel_id)', "
"'DO 0')"
)
op.execute("PREPARE stmt FROM @ddl")
op.execute("EXECUTE stmt")
op.execute("DEALLOCATE PREPARE stmt")
def downgrade() -> None:
"""有意不撤键:新库由 V6 建表即带该约束、并非本迁移首创,撤掉会让
降级路径产生新的漂移;确需撤销请人工评估后执行 DROP INDEX。
"""

View File

@ -0,0 +1,212 @@
"""V11 客户评级落库与站点信息
Revision ID: c4e2a86f9b13
Revises: 90653814a421
Create Date: 2026-10-19 10:00:00.000000
与上游 V7–V10(台账/月结/调整单/口径/唯一键/预警参数)合并时重编号为 V11:
本分支三个迁移(原 V7 客户评级 + V8 公司档案精简 + V9 图片版本号)从未发布,
压成单条,最终结构 = 评级表 + 最终版站点/公司信息字段(不含中途增删的
副标题/信用代码/地址/电话/邮箱)。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "c4e2a86f9b13"
down_revision: str | Sequence[str] | None = "90653814a421"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移(双主题一次交付)。
- 客户评级弹窗化:评分每客户落库(customer_ratings,一行一客户快照)
- 公司信息补充与精简:标语/页脚/备案号/站点标题/站点图标 + 图片版本号
"""
op.create_table(
"customer_ratings",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"customer_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="客户ID(唯一:一客户一份当前评级)",
),
sa.Column(
"score_ship_amount",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="发货额评分(0-20)",
),
sa.Column(
"score_receipt_rate",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="回款率评分(0-20)",
),
sa.Column(
"score_profit_rate",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="毛利率评分(0-20)",
),
sa.Column(
"score_contract_cooperation",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="合同履约配合能力评分(0-20)",
),
sa.Column(
"score_site_construction",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="现场规范施工能力评分(0-20)",
),
sa.Column(
"total_score",
sa.Numeric(precision=5, scale=1),
nullable=False,
comment="总分(服务端重算,五项之和 0-100)",
),
sa.Column(
"grade_name",
sa.String(length=32),
nullable=True,
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"scorer_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="评级操作人ID(账号删除后置空)",
),
sa.Column(
"scorer_name",
sa.String(length=64),
nullable=False,
comment="评级操作人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["customer_id"],
["customers.id"],
name=op.f("fk_customer_ratings_customer_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["scorer_id"],
["users.id"],
name=op.f("fk_customer_ratings_scorer_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_ratings")),
sa.UniqueConstraint("customer_id", name=op.f("uk_customer_ratings_customer_id")),
comment="客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.add_column(
"system_settings",
sa.Column(
"site_title",
sa.String(length=64),
nullable=True,
comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)",
),
)
op.add_column(
"system_settings",
sa.Column(
"brand_slogan",
sa.String(length=64),
nullable=True,
comment="品牌标语(登录页与品牌预览;null=内置默认)",
),
)
op.add_column(
"system_settings",
sa.Column(
"footer_text",
sa.String(length=64),
nullable=True,
comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)",
),
)
op.add_column(
"system_settings",
sa.Column(
"icp_no",
sa.String(length=32),
nullable=True,
comment="ICP 备案号(登录页页脚,空=不显示)",
),
)
op.add_column(
"system_settings",
sa.Column(
"favicon_path",
sa.String(length=255),
nullable=True,
comment="站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)",
),
)
op.add_column(
"system_settings",
sa.Column(
"logo_version",
sa.Integer(),
server_default="0",
nullable=False,
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
),
)
op.add_column(
"system_settings",
sa.Column(
"favicon_version",
sa.Integer(),
server_default="0",
nullable=False,
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
),
)
def downgrade() -> None:
"""回滚迁移:先删评级表(引用 customers/users),再逆序移除站点信息列。"""
op.drop_table("customer_ratings")
op.drop_column("system_settings", "favicon_version")
op.drop_column("system_settings", "logo_version")
op.drop_column("system_settings", "favicon_path")
op.drop_column("system_settings", "icp_no")
op.drop_column("system_settings", "footer_text")
op.drop_column("system_settings", "brand_slogan")
op.drop_column("system_settings", "site_title")

View File

@ -0,0 +1,193 @@
"""V8:业务答复口径落实(2026-10-19 回执)。
四件事,全部源于《待澄清问题清单》2026-10-19 答复:
1. 审批开关默认值翻转(#5/#7 答复「默认开启」):param_definitions 的
default_value 更新为 '1';param_values **插入生效日 2026-10-19 的新行**
而非改历史行——参数体系时点化(按业务日期回溯),直接改种子行会篡改
10-19 之前的口径历史。
2. 撤销试用期统一工资参数(#16 答复「按人员档案逐人维护」):删除
probation.salary.basic 的定义与版本行;月结基本工资已直接读
personnel.salary_standard(该参数自 M3 ④ 实现起即无业务消费点)。
3. 新增经理个人户聚合基数开关(#12 答复「作为参数配置,手动调整是否
纳入」):manager.personal.included,默认 0=不计入(维持暂口径)。
4. 绩效台账唯一键加 personnel_id(二次分配分摊铺路,澄清 #1 答复
「分配绩效」):分摊后同一回款同版本按人各落一行(负责人余额行 +
参与人比例行),uk(receipt_id, calc_version) 会撞;扩为三列后聚合行
(receipt_id NULL)依旧不受 MySQL 唯一索引约束。
迁移 downgrade 只保留 drop_table/行级种子反向 DELETE(V6 教训:逐个
drop_index 会撞外键支撑索引 errno 1553)。
"""
from datetime import date
import sqlalchemy as sa
from alembic import op
revision: str = "c7d2e5a81f30"
down_revision: str | None = "3bca400421c6"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
op.execute(
"UPDATE param_definitions SET default_value = '1' "
"WHERE id IN (17, 18)"
)
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 23,
"param_id": 17,
"dimension_value": "",
"effective_date": date(2026, 10, 19),
"value_text": "1",
"batch_id": 1,
},
{
"id": 24,
"param_id": 18,
"dimension_value": "",
"effective_date": date(2026, 10, 19),
"value_text": "1",
"batch_id": 1,
},
],
)
# 2. 撤销试用期统一工资参数(#16:按人员档案 personnel.salary_standard 逐人维护)
op.execute("DELETE FROM param_values WHERE id = 19")
op.execute("DELETE FROM param_definitions WHERE id = 16")
# 3. 经理个人户聚合基数开关(#12:参数化,默认不计入)
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 20,
"param_code": "manager.personal.included",
"param_name": "经理个人户计入聚合基数",
"param_group": "经理分成",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
"0=不计入(默认,暂口径) 1=计入"
),
"sort_order": 20,
"status": 1,
}
],
)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 25,
"param_id": 20,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
}
],
)
# 4. 台账唯一键扩为三列(分摊拆行:同回款同版本按人一行)
# 顺序要紧:旧 uk 是 fk_performance_ledgers_receipt_id 的唯一支撑索引,
# 先删会撞 errno 1553(外键无支撑索引);先建新键(receipt_id 仍居最左)
# 再做替换,两键短暂并存无冲突。
op.create_unique_constraint(
"uk_performance_ledgers_receipt_id_calc_version_personnel",
"performance_ledgers",
["receipt_id", "calc_version", "personnel_id"],
)
op.drop_constraint(
"uk_performance_ledgers_receipt_id_calc_version",
"performance_ledgers",
type_="unique",
)
def downgrade() -> None:
"""反向:唯一键复原 + 种子行级回退(顺序与 upgrade 相反)。
试用期参数(definitions 16 / values 19)的原始种子在 V3,按 V6 downgrade
的行级反向惯例显式重插,保证可重放。
"""
# 与 upgrade 同因的顺序:先建回旧键再删新键(旧键支撑 receipts 外键)
op.create_unique_constraint(
"uk_performance_ledgers_receipt_id_calc_version",
"performance_ledgers",
["receipt_id", "calc_version"],
)
op.drop_constraint(
"uk_performance_ledgers_receipt_id_calc_version_personnel",
"performance_ledgers",
type_="unique",
)
op.execute("DELETE FROM param_values WHERE id = 25")
op.execute("DELETE FROM param_definitions WHERE id = 20")
op.execute(
"INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, "
"unit, dimension_type, options_text, default_value, value_min, value_max, "
"description, sort_order, status) VALUES "
"(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', "
"'元', 'NONE', NULL, '3000', '0', NULL, "
"'试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16, 1)"
)
op.execute(
"INSERT INTO param_values (id, param_id, dimension_value, effective_date, "
"value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1)"
)
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
op.execute(
"UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)"
)

View File

@ -114,21 +114,28 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
AdminUser = Annotated[User, Depends(require_admin)]
# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅
# 管理员」——参数目录/历史/预览等只读端点用此依赖)
LoginUser = Annotated[User, Depends(get_current_user)]
def require_roles(*role_codes: str):
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
"""要求当前用户具备任一指定角色(依赖工厂)。
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
避免既有调用与测试变动)。
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
"""
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
codes = set(user_repository.get_role_codes(current_user))
if not codes & set(role_codes):
raise ForbiddenError("无权访问审批管理")
raise ForbiddenError(message)
return current_user
return _checker

View File

@ -8,6 +8,7 @@ from fastapi import APIRouter
from app.api import health
from app.api.v1 import auth
from app.api.v1.base import business_roles as base_business_roles
from app.api.v1.base import customers as base_customers
from app.api.v1.base import personnel as base_personnel
from app.api.v1.base import products as base_products
@ -15,6 +16,10 @@ from app.api.v1.business import businesses as business_businesses
from app.api.v1.business import ratings as business_ratings
from app.api.v1.business import receipts as business_receipts
from app.api.v1.business import shipments as business_shipments
from app.api.v1.calc import router as calc_router
from app.api.v1.dashboard import router as dashboard_router
from app.api.v1.reports import router as reports_router
from app.api.v1.settlements import router as settlements_router
from app.api.v1.system import approvals as system_approvals
from app.api.v1.system import business_roles as system_business_roles
from app.api.v1.system import logs as system_logs
@ -49,6 +54,7 @@ base_router = APIRouter(prefix="/base")
base_router.include_router(base_personnel.router)
base_router.include_router(base_products.router)
base_router.include_router(base_customers.router)
base_router.include_router(base_business_roles.router)
v1_router.include_router(base_router)
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
@ -59,4 +65,13 @@ business_router.include_router(business_receipts.router)
business_router.include_router(business_ratings.router)
v1_router.include_router(business_router)
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(calc_router)
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(settlements_router)
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
v1_router.include_router(reports_router)
# 工作台(M4 ⑧):/api/v1/dashboard,四区块只读聚合,限管理员/财务/领导
v1_router.include_router(dashboard_router)
api_router.include_router(v1_router)

View File

@ -0,0 +1,40 @@
"""岗位下拉选项(基础数据与报表筛选共用)。
岗位管理本身仅管理员可用(FR-006),但报表的「岗位」筛选对财务/领导
开放(FR-029)。为避免把管理接口整体放开,这里提供只读、登录即可的
启用岗位选项;写操作仍走 `/system/business-roles` 的管理员端点。
"""
from typing import Annotated
from fastapi import APIRouter, Depends
from app.api.deps import DbSession, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories import business_role_repository
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
# 基础数据是业务操作:登录即可,不要求管理员
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("/options", response_model=ApiResponse[list[dict]], summary="岗位下拉选项")
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[dict]]:
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
岗位规模有限(内置 + 自定义),一次取足够上限,不做分页。
"""
roles = business_role_repository.list_roles(session, offset=0, limit=1000)
return ok(
[
{
"id": role.id,
"role_code": role.role_code,
"role_name": role.role_name,
"status": role.status,
}
for role in roles
]
)

View File

@ -24,7 +24,8 @@ from app.schemas.customer import (
CustomerUpdateIn,
OwnershipChangeLogOut,
)
from app.services import customer_service
from app.schemas.rating import CustomerRatingOut, CustomerRatingSaveIn
from app.services import customer_service, rating_service
router = APIRouter(prefix="/customers", tags=["客户管理"])
@ -140,3 +141,45 @@ def list_ownership_logs(
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
return ok(customer_service.list_ownership_logs(session, customer_id, query))
@router.get(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerRatingOut],
summary="客户当前评级",
)
def get_customer_rating(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[CustomerRatingOut]:
"""查某客户当前评级。
未评级返回 ``data: null``(这是正常态而非错误);客户不存在才 404。
"""
return ok(rating_service.get_customer_rating(session, customer_id))
@router.put(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerRatingOut],
summary="保存客户评级",
)
def save_customer_rating(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingSaveIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerRatingOut]:
"""保存客户评级(首次创建、再次保存整体覆盖)。
五项分值 0-20 由服务层在审计上下文内校验,总分与等级由服务端重算;
等级按保存时点的映射快照固化,等级表后续增删改不影响该客户结论。
"""
return ok(
rating_service.save_customer_rating(
session, customer_id, payload, operator=current_user, meta=meta
),
message="评级已保存",
)

107
backend/app/api/v1/calc.py Normal file
View File

@ -0,0 +1,107 @@
"""绩效核算接口(M3 ④):台账查询与单笔核算触发。
核算动作 = 把回款固化为台账行(每笔回款仅一次;重算走独立入口并授权)。
准入:读=登录即可;写(核算/重算/批量)=管理员与营销后勤
(权限清单待澄清 #6,答复后细化)。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
from app.services import calc_service
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
# 重算是授权动作:管理员/财务(营销后勤不可重算)
_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))]
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
def list_ledger(
session: DbSession,
query: Annotated[LedgerQuery, Query()],
) -> ApiResponse[PageResult[LedgerOut]]:
"""分页查询绩效核算台账(默认仅生效行,可筛选历史版本)。"""
return ok(calc_service.list_ledger_page(session, query))
@router.get("/ledger/{ledger_id}", response_model=ApiResponse[LedgerOut], summary="台账详情")
def get_ledger(
session: DbSession,
ledger_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[LedgerOut]:
"""台账行详情(含计算快照)。"""
return ok(calc_service.get_ledger(session, ledger_id))
@router.post(
"/receipts/{receipt_id}/calc",
response_model=ApiResponse[CalcReceiptOut],
summary="核算回款",
)
def calc_receipt(
receipt_id: Annotated[int, Path(ge=1)],
session: DbSession,
current_user: _CalcWriter,
meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]:
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
return ok(
calc_service.calc_receipt(session, receipt_id, operator=current_user, meta=meta),
message="核算完成",
)
class RecalcIn(BaseModel):
"""重算入参:原因必填(重算授权留痕)。"""
reason: str = Field(min_length=1, max_length=255, description="重算原因")
class BatchIn(BaseModel):
"""批量核算入参。"""
receipt_ids: list[int] = Field(min_length=1, description="回款 ID 列表")
@router.post(
"/receipts/{receipt_id}/recalc",
response_model=ApiResponse[CalcReceiptOut],
summary="重算回款",
)
def recalc_receipt(
receipt_id: Annotated[int, Path(ge=1)],
payload: RecalcIn,
session: DbSession,
current_user: _RecalcUser,
meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]:
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
return ok(
calc_service.recalc_receipt(
session, receipt_id, payload.reason, operator=current_user, meta=meta
),
message="重算完成",
)
@router.post("/batch", response_model=ApiResponse[list[dict]], summary="批量核算")
def batch_calc(
payload: BatchIn,
session: DbSession,
current_user: _CalcWriter,
meta: RequestMetaDep,
) -> ApiResponse[list[dict]]:
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
return ok(
calc_service.batch_calc(session, payload.receipt_ids, operator=current_user, meta=meta),
message="批量核算完成",
)

View File

@ -0,0 +1,44 @@
"""工作台接口(M4 ⑧):四区块一次性聚合。
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.api.deps import DbSession, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.dashboard import DashboardOut
from app.services import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["工作台"])
_DashboardUser = Annotated[
User,
Depends(
require_roles(
ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台"
)
),
]
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
def get_dashboard(
session: DbSession,
current_user: _DashboardUser,
month: Annotated[
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None,
) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
"""
return ok(
dashboard_service.get_dashboard(session, month, current_user=current_user)
)

View File

@ -0,0 +1,191 @@
"""报表统计接口(M4 ⑤):四张报表只读查询 + Excel 导出。
准入:管理员/财务/领导(FR-030/031 明确;FR-029 的营销后勤「授权范围内」
待权限清单 #6,暂不开放)。前后端双层:前端 allowedRoles 控菜单,本层
require_roles 兜底。
导出返回文件流(非统一信封),沿用参数导出先例:服务端文件名为 ASCII
(HTTP 头按 latin-1 编码,中文名会 UnicodeEncodeError),中文名由前端
``link.download`` 指定。
"""
from datetime import date
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ValidationError
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.exceptions import BadRequestError
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.report import (
BusinessStatsQuery,
BusinessStatsRow,
DepartmentSummaryOut,
DepartmentSummaryQuery,
PaymentDetailQuery,
PaymentDetailRow,
PaymentStatsOut,
PaymentStatsQuery,
PersonalDetailQuery,
PersonalDetailRow,
ReportPage,
)
from app.services import report_service
router = APIRouter(prefix="/reports", tags=["绩效工资 · 报表统计"])
_ReportViewer = Annotated[
User,
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计")),
]
@router.get(
"/personal-detail",
response_model=ApiResponse[ReportPage[PersonalDetailRow]],
summary="个人绩效工资明细表",
)
def personal_detail(
session: DbSession,
_: _ReportViewer,
query: Annotated[PersonalDetailQuery, Query()],
) -> ApiResponse[ReportPage[PersonalDetailRow]]:
"""按人员/月份/客户类型/产品类别/岗位查明细(口径与台账一致)。"""
return ok(report_service.list_personal_detail(session, query))
@router.get(
"/department-summary",
response_model=ApiResponse[DepartmentSummaryOut],
summary="部门绩效工资汇总表",
)
def department_summary(
session: DbSession,
_: _ReportViewer,
query: Annotated[DepartmentSummaryQuery, Query()],
) -> ApiResponse[DepartmentSummaryOut]:
"""按经理团队/月份汇总(团队基数与月结聚合行同源)。"""
return ok(report_service.department_summary(session, query))
@router.get(
"/business-stats",
response_model=ApiResponse[ReportPage[BusinessStatsRow]],
summary="业务统计表",
)
def business_stats(
session: DbSession,
_: _ReportViewer,
query: Annotated[BusinessStatsQuery, Query()],
) -> ApiResponse[ReportPage[BusinessStatsRow]]:
"""按业务查询发货、回款、绩效(回款/绩效为业务累计值)。"""
return ok(report_service.business_stats(session, query))
@router.get(
"/payment-stats",
response_model=ApiResponse[PaymentStatsOut],
summary="回款情况统计表",
)
def payment_stats(
session: DbSession,
_: _ReportViewer,
query: Annotated[PaymentStatsQuery, Query()],
) -> ApiResponse[PaymentStatsOut]:
"""按回款天数区间/客户类型/产品类别统计(空桶恒在且为 0)。"""
return ok(report_service.payment_stats(session, query))
@router.get(
"/payment-detail",
response_model=ApiResponse[ReportPage[PaymentDetailRow]],
summary="回款区间下钻",
)
def payment_detail(
session: DbSession,
_: _ReportViewer,
query: Annotated[PaymentDetailQuery, Query()],
) -> ApiResponse[ReportPage[PaymentDetailRow]]:
"""区间桶下钻到单笔台账行(含回款金额与批次号)。"""
return ok(report_service.payment_detail(session, query))
@router.get(
"/payment-day-buckets", response_model=ApiResponse[list[dict]], summary="回款天数区间档位"
)
def payment_day_buckets(_: _ReportViewer) -> ApiResponse[list[dict]]:
"""区间档位元数据(前端渲染下拉/表头,禁止前端硬编码档位)。"""
return ok(report_service.get_payment_day_buckets())
class ExportQuery(BaseModel):
"""导出通用筛选参数(按报表编码取用相关字段,未用字段忽略)。"""
settlement_month: str | None = None
personnel_id: int | None = None
customer_type: str | None = None
product_category: str | None = None
role_code: str | None = None
manager_id: int | None = None
keyword: str | None = None
customer_id: int | None = None
owner_personnel_id: int | None = None
status: int | None = None
date_from: date | None = None
date_to: date | None = None
@router.get("/{code}/export", summary="导出报表 Excel")
def export_report(
code: Annotated[
Literal["personal-detail", "department-summary", "business-stats", "payment-stats"],
Path(description="报表编码"),
],
session: DbSession,
current_user: _ReportViewer,
meta: RequestMetaDep,
query: Annotated[ExportQuery, Query()],
) -> StreamingResponse:
"""导出 xlsx(含公司页眉/财务复核栏/领导签字栏),行为入操作日志。"""
content, filename = report_service.export_report(
session,
code,
_build_export_query(code, query),
operator=current_user,
meta=meta,
)
return StreamingResponse(
iter([content]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": (f'attachment; filename="{filename}_{date.today():%Y%m%d}.xlsx"')
},
)
def _build_export_query(code: str, query: ExportQuery):
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。
手工构造模型抛出的是 `pydantic.ValidationError`,不是 FastAPI 的
`RequestValidationError`——不在这里转换就会落到全局 500。导出参数
非法与查询接口一样应当是 400。
"""
data = query.model_dump()
model = {
"personal-detail": PersonalDetailQuery,
"department-summary": DepartmentSummaryQuery,
"business-stats": BusinessStatsQuery,
"payment-stats": PaymentStatsQuery,
}[code]
try:
return model(**data)
except ValidationError as exc:
first = exc.errors()[0]
location = ".".join(str(part) for part in first.get("loc", ()))
message = first.get("msg", "参数错误")
detail = f"{location} {message}" if location else message
raise BadRequestError(f"参数错误:{detail}") from exc

View File

@ -0,0 +1,99 @@
"""月度结算接口(M3 ④):生成/提交审批/锁定与调整单。
准入:读=登录即可;写(生成/提交/调整单)=管理员与营销后勤
(权限清单待澄清 #6,答复后细化)。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.settlement import (
AdjustmentCreateIn,
SettlementDetailOut,
SettlementGenerateIn,
SettlementOut,
SettlementQuery,
)
from app.services import settlement_service
router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"])
_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
def generate(
payload: SettlementGenerateIn,
session: DbSession,
current_user: _SettlementWriter,
meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]:
"""按确认回款口径生成指定月份的结算快照(草稿)。"""
return ok(
settlement_service.generate(session, payload.month, operator=current_user, meta=meta),
message="月结已生成",
)
@router.get("", response_model=ApiResponse[PageResult[SettlementOut]], summary="月结列表")
def list_settlements(
session: DbSession,
query: Annotated[SettlementQuery, Query()],
) -> ApiResponse[PageResult[SettlementOut]]:
"""分页查询月结(按月份倒序)。"""
return ok(settlement_service.list_page(session, query))
@router.get("/{settlement_id}", response_model=ApiResponse[SettlementDetailOut], summary="月结详情")
def get_settlement(
session: DbSession,
settlement_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[SettlementDetailOut]:
"""月结详情(含按人明细)。"""
return ok(settlement_service.get_detail(session, settlement_id))
@router.post(
"/{settlement_id}/submit",
response_model=ApiResponse[SettlementDetailOut],
summary="提交审批",
)
def submit(
settlement_id: Annotated[int, Path(ge=1)],
session: DbSession,
current_user: _SettlementWriter,
meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]:
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
return ok(
settlement_service.submit(session, settlement_id, operator=current_user, meta=meta),
message="已提交",
)
# ==================== 调整单(已锁定月份的补发/扣减) ====================
@router.post("/adjustments", response_model=ApiResponse[dict], summary="新建调整单")
def create_adjustment(
payload: AdjustmentCreateIn,
session: DbSession,
current_user: _SettlementWriter,
meta: RequestMetaDep,
) -> ApiResponse[dict]:
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
return ok(
settlement_service.create_adjustment(session, payload, operator=current_user, meta=meta),
message="调整单已创建",
)
@router.get("/adjustments", response_model=ApiResponse[list[dict]], summary="调整单列表")
def list_adjustments(session: DbSession) -> ApiResponse[list[dict]]:
"""调整单列表(一期不分页,内部系统量级有限)。"""
return ok(settlement_service.list_adjustments(session))

View File

@ -1,8 +1,9 @@
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读);
写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志,
变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读
端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作
(提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作
日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
导出为文件流(非统一信封),前端以 blob 接收。
"""
@ -12,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Path, Query
from fastapi.responses import StreamingResponse
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.api.deps import AdminUser, DbSession, LoginUser, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.param import (
ParamBatchDetailOut,
@ -37,7 +38,7 @@ router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
)
def get_catalog(
session: DbSession,
_: AdminUser,
_: LoginUser,
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
) -> ApiResponse[list[ParamCatalogItemOut]]:
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
@ -63,7 +64,7 @@ def submit_change(
)
def list_changes(
session: DbSession,
_: AdminUser,
_: LoginUser,
query: Annotated[ParamBatchQuery, Query()],
) -> ApiResponse[PageResult[ParamBatchOut]]:
"""按生效日期区间分页查询变更批次(创建时间倒序)。"""
@ -75,7 +76,7 @@ def list_changes(
)
def get_batch(
session: DbSession,
_: AdminUser,
_: LoginUser,
batch_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ParamBatchDetailOut]:
"""批次头 + 逐项旧值 → 新值。"""
@ -97,7 +98,7 @@ def withdraw_batch(
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
def compare(
session: DbSession,
_: AdminUser,
_: LoginUser,
from_date: Annotated[date, Query(description="起始业务日期(含)")],
to_date: Annotated[date, Query(description="截止业务日期(含)")],
) -> ApiResponse[list[ParamDiffOut]]:
@ -108,7 +109,7 @@ def compare(
@router.get("/export", summary="导出变更记录 Excel")
def export_changes(
session: DbSession,
_: AdminUser,
_: LoginUser,
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
) -> StreamingResponse:
@ -125,7 +126,7 @@ def export_changes(
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
def preview(
session: DbSession,
_: AdminUser,
_: LoginUser,
payload: ParamPreviewIn,
) -> ApiResponse[ParamPreviewOut]:
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
@ -139,7 +140,7 @@ def preview(
)
def get_history(
session: DbSession,
_: AdminUser,
_: LoginUser,
param_code: Annotated[str, Path(max_length=64)],
query: Annotated[ParamHistoryQuery, Query()],
) -> ApiResponse[PageResult[ParamHistoryOut]]:

View File

@ -1,17 +1,18 @@
"""系统设置接口(公司信息)。
"""系统设置接口(公司信息 + 站点信息)。
`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),
仅暴露两个非敏感展示字段。
`GET /public`、`GET /logo/public`、`GET /favicon/public` **无鉴权**:登录页
渲染公司名/logo、浏览器加载标签页图标时尚无令牌。响应经
`SettingPublicOut` 白名单收敛(见 schemas/setting.py),仅暴露展示必需字段。
`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径
(二进制响应套 JSON 信封无意义)。
`/logo/public` 与 `/favicon/public` 返回文件流而非统一信封——与参数导出
同一例外口径(二进制响应套 JSON 信封无意义)。
"""
from fastapi import APIRouter, Response, UploadFile
from fastapi.responses import FileResponse
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.exceptions import BadRequestError
from app.core.response import ApiResponse, ok
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
from app.services import system_setting_service as service
@ -19,6 +20,7 @@ from app.services import system_setting_service as service
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
_FAVICON_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg", "ico": "image/x-icon"}
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
@ -27,14 +29,14 @@ def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
return ok(service.to_out(service.get_setting(session)))
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称")
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司信息")
def update_settings(
payload: SettingUpdateIn,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""更新公司名称(审计留痕)。"""
"""更新公司信息(部分更新;审计留痕)。"""
return ok(
service.update_company(session, payload, operator=current_user, meta=meta),
message="已保存",
@ -42,14 +44,21 @@ def update_settings(
@router.post("/logo", response_model=ApiResponse[SettingOut], summary="上传 logo")
async def upload_logo(
def upload_logo(
file: UploadFile,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。"""
content = await file.read()
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。
同步 `def`:读文件、磁盘写与审计提交都是阻塞操作,async 路由里会让
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
"""
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
content = file.file.read()
ext = (file.filename or "").rsplit(".", 1)[-1]
return ok(
service.save_logo(session, content, ext, operator=current_user, meta=meta),
@ -57,6 +66,27 @@ async def upload_logo(
)
@router.post("/favicon", response_model=ApiResponse[SettingOut], summary="上传站点图标")
def upload_favicon(
file: UploadFile,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""上传站点图标(png/jpg/ico ≤2MB;固定名覆盖,旧扩展名文件清理)。
同步 `def` 与 logo 上传同口径:磁盘写与审计提交均为阻塞操作。
"""
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
raise BadRequestError("站点图标文件不能超过 2MB")
content = file.file.read()
ext = (file.filename or "").rsplit(".", 1)[-1]
return ok(
service.save_favicon(session, content, ext, operator=current_user, meta=meta),
message="站点图标已更新",
)
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
@ -73,3 +103,14 @@ def get_logo_public(session: DbSession) -> FileResponse:
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
headers={"Cache-Control": "no-cache"},
)
@router.get("/favicon/public", summary="站点图标(匿名)")
def get_favicon_public(session: DbSession) -> FileResponse:
"""站点图标文件流(无鉴权)。no-cache:图标替换后由版本参数破缓存兜底。"""
path, ext = service.read_favicon(session)
return FileResponse(
path,
media_type=_FAVICON_MEDIA_TYPE.get(ext, "application/octet-stream"),
headers={"Cache-Control": "no-cache"},
)

View File

@ -27,11 +27,12 @@ _WIDTH, _HEIGHT = 120, 40
_FONT_SIZE = 25
_PADDING = 8
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
_INK = (15, 54, 70)
_INTERFERENCE = (126, 156, 170)
_SPECKLE = (168, 183, 192)
_PAPER = (247, 246, 242)
# 与前端 v3 配色呼应(--ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper):
# 墨色字符、次级墨干扰、辅助墨噪点、纸白底
_INK = (28, 35, 51) # #1c2333
_INTERFERENCE = (91, 101, 119) # #5b6577
_SPECKLE = (154, 163, 178) # #9aa3b2
_PAPER = (255, 255, 255) # #ffffff
_SPECKLE_COUNT = 46
_INTERFERENCE_LINES = 2

View File

@ -85,6 +85,9 @@ MODULE_RECEIPT = "回款管理"
MODULE_RATING = "客户评级"
MODULE_SYSTEM_SETTINGS = "系统设置"
MODULE_APPROVAL = "审批管理"
MODULE_CALC = "绩效核算"
MODULE_SETTLEMENT = "月度结算"
MODULE_REPORT = "报表统计"
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
@ -93,6 +96,7 @@ NO_PREFIX_SHIPMENT = "SH"
NO_PREFIX_RECEIPT = "HK"
NO_PREFIX_PARAM = "P"
NO_PREFIX_APPROVAL = "AP"
NO_PREFIX_ADJUSTMENT = "TZ"
ACTION_CREATE = "新增"
ACTION_UPDATE = "修改"
@ -102,3 +106,4 @@ ACTION_ENABLE = "启用"
ACTION_DISABLE = "禁用"
ACTION_APPROVE = "审批通过"
ACTION_REJECT = "审批驳回"
ACTION_EXPORT = "导出"

26
backend/app/core/month.py Normal file
View File

@ -0,0 +1,26 @@
"""月份参数校验(YYYY-MM)——叶子模块。
报表、月结与调整单共用同一套校验:只校验长度会放过 `2026-13` 这类
非法月份,一路传到 `calendar.monthrange` 才抛 ValueError,最终被全局
处理器当成服务端异常(500)。月份范围必须在入口处拦住,才能返回 400。
"""
import re
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
def validate_month(value: str) -> str:
"""校验 YYYY-MM 且月份 01-12;非法抛 ValueError(Pydantic 友好)。
年份 0000 也拒绝:`date(0, ...)` 同样会抛 ValueError,且业务上不存在。
"""
if not _MONTH_RE.match(value) or int(value[:4]) == 0:
raise ValueError("月份格式须为 YYYY-MM(01-12)")
return value
def parse_month(value: str) -> tuple[int, int]:
"""校验并返回 (year, month),供服务层计算月末等使用。"""
validate_month(value)
return int(value[:4]), int(value[5:7])

View File

@ -0,0 +1,79 @@
"""报表统计元数据(区间桶、中文标签、导出上限)——叶子模块。
刻意不导入 app 内任何模块:report_service 与 report_export 都要消费这里的
定义,若它反向依赖 service 层会形成循环导入;也不走参数体系——FR-031
「区间可配置」一期以代码常量落地(参数化需注册表 + 迁移 + init-dev +
registry 测试四处同改,业务未提诉求前不做),配置缝收在
get_payment_day_buckets 单函数,未来在线可配时只改该函数。
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class DayBucket:
"""回款天数区间档位:min/max 均为 None 表示「无回款天数」兜底桶。"""
code: str
label: str
min_days: int | None
max_days: int | None
# 8 档区间(催办清单 R2 默认口径)。两个兜底桶必须保留:payment_days 可空
# (回款未指定发货批次)且可为负(先款后货照实负值),缺了它们这些行会
# 静默消失、金额对不上。
PAYMENT_DAY_BUCKETS: tuple[DayBucket, ...] = (
DayBucket("le0", "≤0 天(先款后货)", None, 0),
DayBucket("1-30", "1-30 天", 1, 30),
DayBucket("31-60", "31-60 天", 31, 60),
DayBucket("61-90", "61-90 天", 61, 90),
DayBucket("91-180", "91-180 天", 91, 180),
DayBucket("181-360", "181-360 天", 181, 360),
DayBucket("361+", "361 天以上", 361, None),
DayBucket("none", "无回款天数", None, None),
)
CUSTOMER_TYPE_LABELS: dict[str, str] = {
"personal": "个人户",
"manager_personal": "经理个人户",
"enterprise": "企业户",
}
# mixed 不在 core.constants.PRODUCT_CATEGORIES(那只是可维护的档案类别),
# 但台账行可以是 mixed(混合业务行级明细入 snapshot_json),报表必须能标它
PRODUCT_CATEGORY_LABELS: dict[str, str] = {
"municipal_water": "市政水利",
"rail_transit": "轨道交通",
"mixed": "混合业务",
}
# 业务状态中文标签:导出列、筛选页眉与前端标签共用同一份映射
BUSINESS_STATUS_LABELS: dict[int, str] = {1: "进行中", 2: "已完成", 3: "已终止"}
REPORT_TEMPLATE_VERSION = "V1.2"
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出
REPORT_EXPORT_MAX_ROWS = 20000
def get_payment_day_buckets() -> tuple[DayBucket, ...]:
"""区间档位唯一出口:前端下拉与分桶都以本函数返回为准,禁止硬编码。
未来「区间在线可配」时改此处读参数即可,调用方与前端零改动。
"""
return PAYMENT_DAY_BUCKETS
def bucket_of(payment_days: int | None) -> DayBucket:
"""把单笔回款天数落入对应区间桶;未匹配任何区间按「无回款天数」兜底。"""
if payment_days is None:
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")
for bucket in PAYMENT_DAY_BUCKETS:
if bucket.code == "none":
continue
lower_ok = bucket.min_days is None or payment_days >= bucket.min_days
upper_ok = bucket.max_days is None or payment_days <= bucket.max_days
if lower_ok and upper_ok:
return bucket
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")

View File

@ -14,19 +14,23 @@ from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole
from app.models.customer import Customer, OwnershipChangeLog
from app.models.customer_rating import CustomerRating
from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
from app.models.performance_ledger import PerformanceLedger
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
from app.models.product import Product
from app.models.rating import CustomerRatingGrade
from app.models.receipt import Receipt
from app.models.role import Permission, Role, RolePermission
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
from app.models.shipment import Shipment, ShipmentItem
from app.models.system_setting import SystemSetting
from app.models.user import User, UserRole
__all__ = [
"AdjustmentOrder",
"ApprovalOrder",
"ApprovalStep",
"Base",
@ -35,13 +39,16 @@ __all__ = [
"BusinessRole",
"BusinessStatusLog",
"Customer",
"CustomerRating",
"CustomerRatingGrade",
"LoginLog",
"MonthlySettlement",
"OperationLog",
"OwnershipChangeLog",
"ParamChangeBatch",
"ParamDefinition",
"ParamValue",
"PerformanceLedger",
"Permission",
"Personnel",
"PersonnelPlan",
@ -51,6 +58,7 @@ __all__ = [
"Role",
"RolePermission",
"SecondaryAllocation",
"SettlementItem",
"Shipment",
"ShipmentItem",
"SystemSetting",

View File

@ -42,9 +42,10 @@ APPROVAL_ACTION_REJECT = 2 # 人工驳回
APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕)
APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销)
# 审批对象类型(approval_orders.order_type;2 为 ④ 月结预留)
# 审批对象类型(approval_orders.order_type)
APPROVAL_TYPE_SECONDARY_ALLOCATION = 1
APPROVAL_TYPE_MONTHLY_SETTLEMENT = 2
APPROVAL_TYPE_ADJUSTMENT = 3
class ApprovalOrder(Base, TimestampMixin):
@ -66,7 +67,7 @@ class ApprovalOrder(Base, TimestampMixin):
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
)
order_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算(预留)"
TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算 3调整单"
)
ref_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,

View File

@ -15,7 +15,7 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, text
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
@ -56,6 +56,11 @@ class SecondaryAllocation(Base, TimestampMixin):
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
UniqueConstraint(
"business_id",
"personnel_id",
name="uk_business_secondary_allocations_business_personnel",
),
Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
)

View File

@ -0,0 +1,77 @@
"""客户评级模型(需求 2.2.5)。
评级从「纯计算器不落库」升级为**每客户一行落库**(2026-10-19 确认):
客户管理页要罗列评级列并按其排序,评分必须可回溯。设计要点:
- ``customer_id`` 唯一键 + RESTRICT 外键:一客户一份当前评级(快照语义,
非流水),删除客户由服务层先删评级行再删主行(RESTRICT 兜底并发窗口)
- ``grade_name`` 为**快照固化、无外键**:等级映射表仍允许自由增删清空
(rating_service 的既有前提),删等级不能伤及已评级客户的历史结论 ——
与归属留痕「ID/人名固化、不回溯」同一论证口径;等级未命中时为 NULL
- ``scorer_id`` 外键 SET NULL + ``scorer_name`` 冗余固化:账号删除后
记录仍可读(同 ownership_change_logs 的操作人口径)
- 分值列 DECIMAL(5,1):UI 整数 0-20,DECIMAL 留十进制余地;总分由
**服务端重算**,不采信前端回传
"""
from decimal import Decimal
from sqlalchemy import ForeignKey, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class CustomerRating(Base, TimestampMixin):
"""客户评级表:每客户一行(uk_customer_ratings_customer_id)。
五项各 0-20 分、总分 = 五项之和(0-100),判定规则同等级映射表
(按 sort_order 升序首个 min_score ≤ 总分,见 rating_service.resolve_grade)。
"""
__tablename__ = "customer_ratings"
__table_args__ = mysql_table_args(
"客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
UniqueConstraint("customer_id", name="uk_customer_ratings_customer_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
customer_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("customers.id", ondelete="RESTRICT"),
nullable=False,
comment="客户ID(唯一:一客户一份当前评级)",
)
score_ship_amount: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="发货额评分(0-20)"
)
score_receipt_rate: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="回款率评分(0-20)"
)
score_profit_rate: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="毛利率评分(0-20)"
)
score_contract_cooperation: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="合同履约配合能力评分(0-20)"
)
score_site_construction: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="现场规范施工能力评分(0-20)"
)
total_score: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, comment="总分(服务端重算,五项之和 0-100)"
)
grade_name: Mapped[str | None] = mapped_column(
String(32),
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
scorer_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="评级操作人ID(账号删除后置空)",
)
scorer_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="评级操作人用户名(冗余固化)"
)

View File

@ -0,0 +1,181 @@
"""绩效核算台账模型(M3 ④:每笔回款仅核算一次的固化点)。
**append-only 版本行**:重算 = 插入 calc_version+1 的新行,旧行仅把
is_active 翻 0 —— 这是全表唯一的受控 UPDATE(模型注释与 test_models
均已声明例外),行内容永不改写;历史版本可回溯(重算授权留痕的需求口径)。
防重三层:服务层 CAS(UPDATE ... WHERE settlement_status=0,rowcount=0
即 400)+ FOR UPDATE 行锁 + 库级 uk(receipt_id, calc_version, personnel_id)
兜底。uk 含 personnel_id:二次分配分摊(澄清 #1 答复 2026-10-19,分配
绩效——按方案比例拆给参与人、余额归合同负责人)后同一回款同版本按人
各落一行,拆分仍是「一次核算」的组成部分;聚合行 receipt_id 为 NULL,
MySQL 唯一索引对 NULL 不去重,天然不约束。
经理聚合行(calc_type=2,receipt_id 为 NULL):月度批处理时按「本团队
业务员绩效 ×12.5% + 全公司 ×5%」落一行;其唯一性由月结生成的
settlement_month 串行化 + 服务层保证(MySQL 无部分唯一索引,docstring
声明即可)。
#12 答复(2026-10-19):经理个人户业绩是否计入聚合基数改为参数
``manager.personal.included``(默认 0=不计入)——消费点在 settlement_service
的聚合函数。
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import (
JSON,
Date,
ForeignKey,
Index,
Numeric,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
# 核算类型(performance_ledgers.calc_type)
CALC_TYPE_RECEIPT = 1 # 单笔回款核算
CALC_TYPE_MANAGER_AGGREGATE = 2 # 经理团队聚合(月度批处理)
class PerformanceLedger(Base, TimestampMixin):
"""绩效核算台账表:核算事实的固化点,报表 2.4.1 的数据源。"""
__tablename__ = "performance_ledgers"
__table_args__ = mysql_table_args(
"绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
# 库级防重兜底:同一回款同版本按人仅一行(分摊拆行后负责人+参与人
# 各占一行;服务层 CAS+行锁之外的第二道闸)。聚合行 receipt_id 为
# NULL,MySQL 唯一索引对 NULL 不去重,天然不约束聚合行
UniqueConstraint(
"receipt_id",
"calc_version",
"personnel_id",
name="uk_performance_ledgers_receipt_id_calc_version_personnel",
),
Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"),
Index(
"idx_performance_ledgers_month_personnel_type",
"settlement_month",
"personnel_id",
"calc_type",
),
Index("idx_performance_ledgers_personnel_id", "personnel_id"),
Index("idx_performance_ledgers_business_id", "business_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
receipt_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("receipts.id", ondelete="RESTRICT"),
comment="回款ID(receipts.id;聚合行可为 NULL)",
)
calc_version: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="核算版本(重算+1,旧行 is_active=0)",
)
is_active: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
)
calc_type: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
)
business_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="业务编号(冗余固化,列表免联查)"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="归属营销人员ID(personnel.id;核算归属锚点)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="归属营销人员姓名(冗余固化)"
)
role_code: Mapped[str] = mapped_column(
String(32), nullable=False, comment="岗位编码(business_roles.role_code,计提公式锚点)"
)
customer_type: Mapped[str] = mapped_column(
String(32), nullable=False, comment="客户分类:personal/manager_personal/enterprise"
)
product_category: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
)
ship_date: Mapped[date | None] = mapped_column(Date, comment="发货日期(回款天数基准)")
receipt_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="回款日期(参数版本匹配日,裁决 #11)"
)
payment_days: Mapped[int | None] = mapped_column(
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)"
)
rate_text: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
)
converted_profit: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="折算毛利润额(可负;混合业务为各行汇总)"
)
performance_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="绩效金额(可负,裁决:负绩效照算不归零)"
)
barter_applied: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="以物抵顶折算:1已按 80% 折算 0否",
)
weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)")
payment_weight: Mapped[Decimal | None] = mapped_column(
Numeric(7, 6), comment="支付权重系数 R×R(展示用)"
)
payable_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)"
)
settlement_month: Mapped[str] = mapped_column(
String(7), nullable=False, comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)"
)
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="试用期:1照算不发放 0否",
)
missing_cost: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="计算快照(所用参数版本与行级折算明细,重算可回溯)"
)
settlement_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("monthly_settlements.id", ondelete="RESTRICT"),
comment="所属月结ID(月结生成时回填;NULL=未入结)",
)
calc_reason: Mapped[str | None] = mapped_column(
String(255), comment="核算/重算原因(重算必填留痕)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")

View File

@ -1,9 +1,10 @@
"""客户评级等级配置模型(需求 2.2.5)。
评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录
不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取
第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在
「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。
评级定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分自 2026-10-19
起每客户落库于 customer_ratings(快照固化等级名),本表承载可配置的等级
**映射关系**。判定规则:按 sort_order 升序取第一个 min_score ≤ 总分的
等级 —— 单边判定避免区间边界(如 75 分)在「≥75 且 ≤89」与「>74 且 <75」
两套写法间的重叠/遗漏歧义;max_score 仅展示。
"""
from decimal import Decimal

View File

@ -0,0 +1,197 @@
"""月度结算与调整单模型(M3 ④:月结审批报表补充需求)。
- 月度结算按「确认回款」和计算快照生成;**通过即锁定**(status=3 已审定
即终态,锁定后源数据改动只产生提示与后续调整)
- settlement_items 明细按人固化(可负=负数工资),锁定后无 UPDATE 路径
(重生成 = 草稿/驳回态删旧插新)
- 调整单是已锁定月份的唯一调整通道(补发/扣减,走审批;生效后不回写
已锁定月结——append-only,报表汇总并入)
"""
from datetime import datetime
from decimal import Decimal
from sqlalchemy import JSON, DateTime, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
# 月结状态(monthly_settlements.status;通过即锁定——两态合一)
SETTLEMENT_STATUS_DRAFT = 1 # 草稿(可重新生成/提交)
SETTLEMENT_STATUS_PENDING = 2 # 待审批(提交后)
SETTLEMENT_STATUS_APPROVED = 3 # 已审定(终态=已锁定)
SETTLEMENT_STATUS_REJECTED = 4 # 已驳回(回草稿可重提)
# 调整单状态(adjustment_orders.status)
ADJUSTMENT_STATUS_PENDING = 1 # 待审批
ADJUSTMENT_STATUS_EFFECTIVE = 2 # 已生效
ADJUSTMENT_STATUS_REJECTED = 3 # 已驳回
ADJUSTMENT_STATUS_WITHDRAWN = 4 # 已撤回
# 调整方向(adjustment_orders.direction)
ADJUSTMENT_DIRECTION_EXTRA = 1 # 补发
ADJUSTMENT_DIRECTION_DEDUCT = 2 # 扣减
class MonthlySettlement(Base, TimestampMixin):
"""月度结算表:按月一行(settlement_month 唯一),快照 + 审批 + 锁定。"""
__tablename__ = "monthly_settlements"
__table_args__ = mysql_table_args("月度结算表(按月一行;通过即锁定,锁定后走调整单)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
settlement_month: Mapped[str] = mapped_column(
String(7), nullable=False, unique=True, comment="结算月份(YYYY-MM;全表唯一)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
)
total_performance: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="绩效金额合计(可负)"
)
total_payable: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="应付绩效合计(可负)"
)
person_count: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="0", comment="结算人数"
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)"
)
approval_order_no: Mapped[str | None] = mapped_column(
String(32), comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)"
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)
locked_at: Mapped[datetime | None] = mapped_column(
DateTime, comment="锁定时间(status=3 时回填)"
)
locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)")
class SettlementItem(Base):
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
__tablename__ = "settlement_items"
__table_args__ = mysql_table_args(
"月结明细表(按人固化;支持负数工资;锁定后只读)",
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
settlement_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("monthly_settlements.id", ondelete="CASCADE"),
nullable=False,
comment="月结ID(monthly_settlements.id;头删明细随删)",
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="结算人员ID(personnel.id)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="结算人员姓名(冗余固化)"
)
role_code: Mapped[str] = mapped_column(
String(32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
)
calc_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="核算类型:1单笔回款 2经理团队聚合"
)
performance_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
)
payable_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="应付绩效合计(可负)"
)
basic_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="月工资标准(personnel.salary_standard 当月快照)"
)
salary_mode: Mapped[str | None] = mapped_column(
String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE"
)
net_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="净工资 = 绩效±基本工资(按口径;试用期 0)"
)
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
)
probation_note: Mapped[str | None] = mapped_column(
String(255), comment="试用期说明(年累计超阈值部分年底一次性结算)"
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="个人明细快照(台账行引用列表)"
)
class AdjustmentOrder(Base, TimestampMixin):
"""调整单:已锁定月份的唯一调整通道(补发/扣减,走审批;不回写月结)。"""
__tablename__ = "adjustment_orders"
__table_args__ = mysql_table_args(
"调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
order_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
)
settlement_month: Mapped[str] = mapped_column(
String(7), nullable=False, comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="调整人员ID(personnel.id)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="调整人员姓名(冗余固化)"
)
direction: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="方向:1补发 2扣减"
)
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="调整金额(恒正,方向表达语义)"
)
reason: Mapped[str] = mapped_column(
String(255), nullable=False, comment="扣补说明(必填,审批与追溯锚点)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)

View File

@ -1,31 +1,67 @@
"""系统设置模型:公司信息单例行。
"""系统设置模型:公司信息 + 站点信息单例行。
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在
`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见
system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。
value_text 为 String(64) 装不下;故独立单行表。logo/favicon 只存相对路径——
文件在 `backend/uploads/{logo,favicon}/`,独立于数据库备份体积且可单独替换
(见 system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底)。
2026-10-20 精简(用户确认):企业档案字段(信用代码/地址/电话/邮箱)与
系统副标题删除,新增**站点标题**(浏览器标签页/侧边栏副标题)与**站点图标**
(浏览器标签页 favicon,独立于登录页 logo)。
"""
from sqlalchemy import String
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class SystemSetting(Base, TimestampMixin):
"""系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。"""
"""系统设置表:公司信息 + 站点信息,全系统单行 id=1。
brand_slogan/footer_text/icp_no/site_title 均可空:null 表示前端回退
内置默认值,存量行为零变化。
"""
__tablename__ = "system_settings"
__table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)")
__table_args__ = mysql_table_args("系统设置表(公司信息与站点信息,全系统单行)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
company_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)"
String(64), nullable=False, comment="公司名称(登录页/侧边栏/浏览器标题展示)"
)
logo_path: Mapped[str | None] = mapped_column(
String(255),
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
comment="公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
)
logo_version: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="0",
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
)
site_title: Mapped[str | None] = mapped_column(
String(64), comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)"
)
brand_slogan: Mapped[str | None] = mapped_column(
String(64), comment="品牌标语(登录页与品牌预览;null=内置默认)"
)
footer_text: Mapped[str | None] = mapped_column(
String(64), comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)"
)
icp_no: Mapped[str | None] = mapped_column(
String(32), comment="ICP 备案号(登录页页脚,空=不显示)"
)
favicon_path: Mapped[str | None] = mapped_column(
String(255),
comment="站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)",
)
favicon_version: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="0",
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
)

View File

@ -228,6 +228,18 @@ def replace_allocations(
session.flush()
def delete_allocations(session: Session, business_id: int) -> None:
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
方案事实由审批单留痕与操作日志 request_data 承载。
"""
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
)
session.flush()
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
"""追加状态留痕(不 commit)。"""
session.add(log)

View File

@ -0,0 +1,33 @@
"""客户评级数据访问(每客户一行)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import CustomerRating
# ==================== 读取 ====================
def get_by_customer_id(session: Session, customer_id: int) -> CustomerRating | None:
"""查某客户的当前评级(uk_customer_ratings_customer_id 保证至多一行)。"""
stmt = select(CustomerRating).where(CustomerRating.customer_id == customer_id)
return session.execute(stmt).scalar_one_or_none()
# ==================== 写入 ====================
def create(session: Session, rating: CustomerRating) -> CustomerRating:
"""新增评级行。只 flush 以取得自增 id。"""
session.add(rating)
session.flush()
return rating
def delete(session: Session, rating: CustomerRating) -> None:
"""删除评级行(客户物理删除前的联动清理,调用方先校验业务引用)。"""
session.delete(rating)
session.flush()

View File

@ -6,7 +6,7 @@
from sqlalchemy import ColumnElement, Row, Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Customer, OwnershipChangeLog, Personnel
from app.models import Customer, CustomerRating, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import ownership_log_repository
from app.repositories.query_utils import fuzzy
@ -31,7 +31,7 @@ def count_business_refs(session: Session, customer_id: int) -> int:
def get_with_owner_name(session: Session, customer_id: int) -> Row | None:
"""查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。"""
"""查单个客户并带出归属人员姓名与评级快照(详情/编辑回显/归属变更共用)。"""
stmt = _base_select().where(Customer.id == customer_id)
return session.execute(stmt).first()
@ -53,12 +53,14 @@ def list_customers(
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
order_field: str | None = None,
order_type: str | None = None,
) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
"""分页查询客户,行内容为 (客户, 归属人员姓名, 评级总分, 评级等级)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
归属人员/评级行均用 outerjoin:关联行不可考(人员被物理删除、客户
未评级)不应让客户行整个查不出来(同 personnel_repository 韧性口径)。
默认排序按 id 倒序:新增的靠前符合列表页习惯;评级排序见 ``_apply_order``。
"""
stmt = _apply_filters(
_base_select(),
@ -66,7 +68,8 @@ def list_customers(
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
stmt = _apply_order(stmt, order_field=order_field, order_type=order_type)
stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).all())
@ -120,12 +123,38 @@ def count_ownership_logs(session: Session, *, customer_id: int) -> int:
def _base_select() -> Select:
"""客户主表外联归属人员姓名。"""
return select(Customer, Personnel.personnel_name).outerjoin(
Personnel, Customer.owner_personnel_id == Personnel.id
"""客户主表外联归属人员姓名与评级快照(等级名/总分,列表评级列免 N+1)。"""
return (
select(
Customer,
Personnel.personnel_name,
CustomerRating.total_score,
CustomerRating.grade_name,
)
.outerjoin(Personnel, Customer.owner_personnel_id == Personnel.id)
.outerjoin(CustomerRating, CustomerRating.customer_id == Customer.id)
)
def _apply_order(stmt: Select, *, order_field: str | None, order_type: str | None) -> Select:
"""应用列表排序。默认 id 倒序;order_field=rating 按评级总分排序。
等级由总分判定、与总分同向,故按总分即等价按等级排序。MySQL 没有
``NULLS LAST`` 语法,用布尔键 ``total_score IS NULL`` 升序模拟 ——
未评级行恒在末尾,正序/倒序一致;同分以 id 倒序兜底保证次序稳定。
非法字段/方向不落到 SQL(schema Literal 已在入口拦 400),此处静默
回退默认排序作为纵深防御。
"""
if order_field == "rating" and order_type in ("asc", "desc"):
score_order = (
CustomerRating.total_score.asc()
if order_type == "asc"
else CustomerRating.total_score.desc()
)
return stmt.order_by(CustomerRating.total_score.is_(None), score_order, Customer.id.desc())
return stmt.order_by(Customer.id.desc())
def _apply_filters(
stmt: Select,
*,

View File

@ -0,0 +1,237 @@
"""工作台仓储:四区块的只读聚合查询;不 commit、不抛业务异常。
总览只做 SQL 聚合(SUM/COUNT DISTINCT),不复用报表的
``department_summary``——后者会加载整月台账并逐团队查人员,适合报表页,
不适合每次打开工作台。
"""
from datetime import date, datetime
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session
from app.models import (
ApprovalOrder,
Business,
BusinessRole,
MonthlySettlement,
PerformanceLedger,
Personnel,
Receipt,
Shipment,
)
from app.models.approval import (
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
)
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
_PENDING_STATUSES = (
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
)
# ==== 经营绩效总览 ====
def sum_month_performance(session: Session, month: str) -> dict:
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
return session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
).where(
PerformanceLedger.settlement_month == month,
*_ACTIVE_SINGLE,
)
).mappings().one()
def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
"""近 N 个月绩效合计(按台账归属月)。"""
rows = session.execute(
select(
PerformanceLedger.settlement_month,
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0),
)
.where(
PerformanceLedger.settlement_month.in_(months),
*_ACTIVE_SINGLE,
)
.group_by(PerformanceLedger.settlement_month)
).all()
return dict(rows)
def receipt_trend(
session: Session, *, start_date: date, end_date: date
) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
分组,不参与 WHERE,避免函数列导致全表扫描。
"""
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
rows = session.execute(
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
.where(Receipt.receipt_date >= start_date, Receipt.receipt_date < end_date)
.group_by(month_expr)
).all()
return dict(rows)
def count_active_managers(session: Session) -> int:
"""在职营销经理数(团队数口径,与部门汇总的团队枚举一致)。"""
return int(
session.execute(
select(func.count())
.select_from(Personnel)
.join(BusinessRole, Personnel.business_role_id == BusinessRole.id)
.where(
BusinessRole.role_code == "sales_manager",
Personnel.status == 1,
)
).scalar_one()
)
def get_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
"""当月月结头(状态与锁定时间角标)。"""
return session.execute(
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
).scalar_one_or_none()
# ==== 待办事项 ====
def list_pending_approvals(
session: Session, *, statuses: tuple[int, ...], limit: int
) -> list[ApprovalOrder]:
"""待处理审批单(按提交时间升序——最久的排前面)。
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
"""
if not statuses:
return []
return list(
session.execute(
select(ApprovalOrder)
.where(ApprovalOrder.status.in_(statuses))
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
.limit(limit)
).scalars()
)
def count_pending_approvals(session: Session, *, statuses: tuple[int, ...]) -> int:
if not statuses:
return 0
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(ApprovalOrder.status.in_(statuses))
).scalar_one()
)
def list_unassigned_receipts(session: Session, *, limit: int) -> list:
"""回款未指定发货批次(shipment_id IS NULL,核算前置缺失)。"""
return list(
session.execute(
select(
Receipt,
Business.business_no.label("business_no"),
)
.outerjoin(Business, Business.id == Receipt.business_id)
.where(Receipt.shipment_id.is_(None))
.order_by(Receipt.receipt_date.asc(), Receipt.id.asc())
.limit(limit)
).all()
)
def count_unassigned_receipts(session: Session) -> int:
return int(
session.execute(
select(func.count())
.select_from(Receipt)
.where(Receipt.shipment_id.is_(None))
).scalar_one()
)
# ==== 风险预警 ====
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
已终止业务(status=3)不再催收,排除在预警之外;业务行缺失时仍保留,
避免历史脏数据让发货单从预警中静默消失。
"""
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
return list(
session.execute(
select(
Shipment,
Business.business_no.label("business_no"),
)
.outerjoin(Business, Business.id == Shipment.business_id)
.where(
Shipment.ship_date <= cutoff,
~has_receipt,
or_(Business.status.is_(None), Business.status != 3),
)
.order_by(Shipment.ship_date.asc())
.limit(limit)
).all()
)
def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
return int(
session.execute(
select(func.count())
.select_from(Shipment)
.outerjoin(Business, Business.id == Shipment.business_id)
.where(
Shipment.ship_date <= cutoff,
~has_receipt,
or_(Business.status.is_(None), Business.status != 3),
)
).scalar_one()
)
def list_overdue_approvals(
session: Session, *, cutoff: datetime, limit: int
) -> list[ApprovalOrder]:
"""提交后超过阈值仍未处理的审批单(审批超时规则)。"""
return list(
session.execute(
select(ApprovalOrder)
.where(
ApprovalOrder.status.in_(_PENDING_STATUSES),
ApprovalOrder.create_time < cutoff,
)
.order_by(ApprovalOrder.create_time.asc())
.limit(limit)
).scalars()
)
def count_overdue_approvals(session: Session, *, cutoff: datetime) -> int:
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(
ApprovalOrder.status.in_(_PENDING_STATUSES),
ApprovalOrder.create_time < cutoff,
)
).scalar_one()
)

View File

@ -0,0 +1,156 @@
"""绩效核算台账仓储:只读写数据并 flush;commit/rollback 在服务层。"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import PerformanceLedger
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
from app.schemas.calc import LedgerQuery
def get_by_id(session: Session, ledger_id: int) -> PerformanceLedger | None:
"""按主键取台账行。"""
return session.get(PerformanceLedger, ledger_id)
def get_active_by_receipt(
session: Session, receipt_id: int, *, calc_type: int = 1
) -> PerformanceLedger | None:
"""取某回款的当前生效核算行(is_active=1;无则 None)。
二次分摊拆行后同回款可有多行(负责人+参与人)——重算作废用
``list_active_by_receipt``;本函数仅限确知单行的调用方(重算前的
存在性判断按业务维度走 has_active_for_business)。
"""
return session.execute(
select(PerformanceLedger).where(
PerformanceLedger.receipt_id == receipt_id,
PerformanceLedger.calc_type == calc_type,
PerformanceLedger.is_active == 1,
)
).scalar_one_or_none()
def list_active_by_receipt(
session: Session, receipt_id: int, *, calc_type: int = 1
) -> list[PerformanceLedger]:
"""取某回款当前生效的**全部**核算行(分摊拆行后负责人+参与人各一行)。"""
return list(
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.receipt_id == receipt_id,
PerformanceLedger.calc_type == calc_type,
PerformanceLedger.is_active == 1,
)
).scalars()
)
def has_active_for_business(session: Session, business_id: int, *, calc_type: int = 1) -> bool:
"""该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。"""
return (
session.execute(
select(func.count())
.select_from(PerformanceLedger)
.where(
PerformanceLedger.business_id == business_id,
PerformanceLedger.calc_type == calc_type,
PerformanceLedger.is_active == 1,
)
).scalar_one()
> 0
)
def get_active_by_receipt_of_business(
session: Session, business_id: int, *, calc_type: int = 1
) -> PerformanceLedger:
"""取该业务当前生效的核算行(重复回款登记进度时回显既有台账)。"""
return (
session.execute(
select(PerformanceLedger)
.where(
PerformanceLedger.business_id == business_id,
PerformanceLedger.calc_type == calc_type,
PerformanceLedger.is_active == 1,
)
.order_by(PerformanceLedger.id.desc())
)
.scalars()
.first()
)
def add(session: Session, row: PerformanceLedger) -> PerformanceLedger:
"""落台账行(flush 取 id;commit 在服务层)。"""
session.add(row)
session.flush()
return row
def mark_inactive(session: Session, row: PerformanceLedger) -> None:
"""重算时把旧版本行翻为已替代(全表唯一的受控 UPDATE 例外;不 commit)。"""
row.is_active = 0
session.flush()
def deactivate_month_aggregates(session: Session, month: str) -> None:
"""月结重生成时作废该月全部经理聚合行(calc_type=2)。
聚合行每次生成重建,旧行不翻 0 会与新行重复计入(按人聚合按 is_active
过滤)。与 mark_inactive 同属 is_active 翻 0 的受控 UPDATE 例外;set 批量
版本而非逐行——聚合行一个月至多几行,且无逐行回读必要。
"""
from sqlalchemy import update
session.execute(
update(PerformanceLedger)
.where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == CALC_TYPE_MANAGER_AGGREGATE,
PerformanceLedger.is_active == 1,
)
.values(is_active=0)
)
session.flush()
def next_version(session: Session, receipt_id: int) -> int:
"""某回款的下一个核算版本号(max+1;无行时 1)。"""
current = session.execute(
select(func.max(PerformanceLedger.calc_version)).where(
PerformanceLedger.receipt_id == receipt_id
)
).scalar_one()
return int(current or 0) + 1
def list_ledger(session: Session, query: LedgerQuery) -> list[PerformanceLedger]:
"""分页查询台账(筛选:月/人员/业务/生效/类型)。"""
stmt = select(PerformanceLedger).where(*_filters(query))
stmt = stmt.order_by(PerformanceLedger.id.desc()).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars())
def count_ledger(session: Session, query: LedgerQuery) -> int:
"""台账计数(与 list_ledger 同筛选)。"""
return int(
session.execute(
select(func.count()).select_from(PerformanceLedger).where(*_filters(query))
).scalar_one()
)
def _filters(query: LedgerQuery) -> tuple:
filters = []
if query.settlement_month:
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
if query.personnel_id is not None:
filters.append(PerformanceLedger.personnel_id == query.personnel_id)
if query.business_id is not None:
filters.append(PerformanceLedger.business_id == query.business_id)
if query.is_active is not None:
filters.append(PerformanceLedger.is_active == query.is_active)
if query.calc_type is not None:
filters.append(PerformanceLedger.calc_type == query.calc_type)
return tuple(filters)

View File

@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
"""按主键查询。
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
等级表对客户评级无**外键**引用(评级行快照固化等级名),无需
``for_update`` 锁行 —— product_repository 锁行防的是并发引用悬空,
此处不存在该风险。
"""
return session.get(CustomerRatingGrade, grade_id)
@ -60,6 +61,6 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
"""物理删除等级。等级表对客户评级无外键引用,调用方无需引用保护校验。"""
session.delete(grade)
session.flush()

View File

@ -0,0 +1,423 @@
"""报表统计仓储:只读聚合查询;不 commit、不抛业务异常。
报表与台账分页查询刻意分开而非复用 ``performance_ledger_repository``:
- 台账查询允许聚合行(calc_type=2),报表明细必须显式排除——经理聚合
行 receipt_id 为 NULL,混入明细会让合同编号/天数列失真、业务统计的
绩效被整团队聚合额污染;
- 月结的 ``_confirm_scope`` 带 ``settlement_id IS NULL``(可入结口径),
报表要看的是同月**全量**生效单笔行,口径不同;
- 聚合需求(SUM/GROUP BY)台账仓储完全没有,报表在此统一实现。
发货额/回款额直接读 businesses 的汇总冗余列(服务层事务内单点维护),
不逐业务子查询发货/回款表;绩效额 businesses 无冗余列,用子查询联台账。
"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.report_meta import DayBucket
from app.models import (
AdjustmentOrder,
Business,
Customer,
MonthlySettlement,
PerformanceLedger,
Personnel,
)
from app.schemas.report import (
BusinessStatsQuery,
PaymentDetailQuery,
PaymentStatsQuery,
PersonalDetailQuery,
)
# 单笔生效行基础条件:报表四表共用(聚合行与已替代版本一律排除)
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
# ==== 个人绩效明细(FR-029)====
def _personal_filters(query: PersonalDetailQuery) -> tuple:
filters = [*_ACTIVE_SINGLE]
if query.settlement_month:
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
if query.personnel_id is not None:
filters.append(PerformanceLedger.personnel_id == query.personnel_id)
if query.customer_type:
filters.append(PerformanceLedger.customer_type == query.customer_type)
if query.product_category:
filters.append(PerformanceLedger.product_category == query.product_category)
if query.role_code:
filters.append(PerformanceLedger.role_code == query.role_code)
return tuple(filters)
def list_personal_detail(
session: Session, query: PersonalDetailQuery, *, offset: int, limit: int
) -> list[PerformanceLedger]:
"""个人绩效明细分页(回款日倒序,同人按版本可追溯)。"""
stmt = select(PerformanceLedger).where(*_personal_filters(query))
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_personal_detail(session: Session, query: PersonalDetailQuery) -> int:
"""个人绩效明细计数(与 list_personal_detail 同筛选)。"""
return int(
session.execute(
select(func.count()).select_from(PerformanceLedger).where(*_personal_filters(query))
).scalar_one()
)
def sum_personal_detail(session: Session, query: PersonalDetailQuery) -> dict:
"""全筛选口径合计(分页与合计同条件,避免「页脚合计≠全部结果」)。"""
return (
session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label(
"converted_profit_total"
),
).where(*_personal_filters(query))
)
.mappings()
.one()
)
# ==== 部门汇总取数(FR-030)====
def list_month_receipt_rows(session: Session, month: str) -> list[PerformanceLedger]:
"""同月全量生效单笔台账行(部门汇总基数与成员明细的取数)。"""
return list(
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == month, *_ACTIVE_SINGLE
)
).scalars()
)
def list_month_manager_aggregates(session: Session, month: str) -> list[PerformanceLedger]:
"""同月经理聚合行(calc_type=2):已生成月结时的冻结对照值。"""
return list(
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == 2,
PerformanceLedger.is_active == 1,
)
).scalars()
)
def list_month_adjustments(session: Session, month: str) -> list[AdjustmentOrder]:
"""同月已生效调整单(status=2)——报表汇总并入的口径。
与 list_adjustments(调整单管理页)不同:必须按月筛选且只取生效行,
待审批/已驳回/已撤回的金额绝不能混进报表。
"""
return list(
session.execute(
select(AdjustmentOrder).where(
AdjustmentOrder.settlement_month == month,
AdjustmentOrder.status == 2,
)
).scalars()
)
def list_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
"""同月月结头(部门汇总展示锁定状态用;无则 None)。"""
return session.execute(
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
).scalar_one_or_none()
# ==== 业务统计(FR-031 之一)====
def _business_perf_subquery():
"""业务维度绩效子查询(排除聚合行,防 12.5%/5% 聚合额污染单业务绩效)。"""
return (
select(
PerformanceLedger.business_id.label("business_id"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("perf"),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable"),
)
.where(*_ACTIVE_SINGLE)
.group_by(PerformanceLedger.business_id)
.subquery()
)
def _business_filters(query: BusinessStatsQuery) -> tuple:
filters = []
if query.keyword:
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 转义通配符,
# 与 business_repository._apply_filters 同口径(双列 OR 用法为该处既定)
filters.append(
Business.business_no.contains(query.keyword, autoescape=True)
| Customer.company_name.contains(query.keyword, autoescape=True)
)
if query.customer_id is not None:
filters.append(Business.customer_id == query.customer_id)
if query.owner_personnel_id is not None:
filters.append(Business.owner_personnel_id == query.owner_personnel_id)
if query.status is not None:
filters.append(Business.status == query.status)
if query.date_from is not None:
filters.append(Business.business_date >= query.date_from)
if query.date_to is not None:
filters.append(Business.business_date <= query.date_to)
return tuple(filters)
def _business_select(query: BusinessStatsQuery):
perf_sq = _business_perf_subquery()
return (
select(
Business,
Customer.company_name.label("customer_name"),
Personnel.personnel_name.label("owner_personnel_name"),
perf_sq.c.perf.label("performance_amount"),
perf_sq.c.payable.label("payable_amount"),
)
.outerjoin(Customer, Customer.id == Business.customer_id)
.outerjoin(Personnel, Personnel.id == Business.owner_personnel_id)
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
.where(*_business_filters(query))
)
def list_business_stats(
session: Session, query: BusinessStatsQuery, *, offset: int, limit: int
) -> list:
"""业务统计分页(业务日期倒序)。"""
stmt = _business_select(query).order_by(Business.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
"""业务统计计数(联客户表因关键字筛选涉及客户名)。"""
stmt = (
select(func.count())
.select_from(Business)
.outerjoin(Customer, Customer.id == Business.customer_id)
.where(*_business_filters(query))
)
return int(session.execute(stmt).scalar_one())
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
perf_sq = _business_perf_subquery()
return (
session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"),
func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"),
func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"),
func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"),
func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"),
)
.outerjoin(Customer, Customer.id == Business.customer_id)
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
.where(*_business_filters(query))
)
.mappings()
.one()
)
# ==== 回款统计(FR-031 之二)====
def _payment_filters(query: PaymentStatsQuery | PaymentDetailQuery) -> tuple:
filters = [*_ACTIVE_SINGLE]
if query.settlement_month:
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
if query.date_from is not None:
filters.append(PerformanceLedger.receipt_date >= query.date_from)
if query.date_to is not None:
filters.append(PerformanceLedger.receipt_date <= query.date_to)
if query.customer_type:
filters.append(PerformanceLedger.customer_type == query.customer_type)
if query.product_category:
filters.append(PerformanceLedger.product_category == query.product_category)
return tuple(filters)
def group_payment_by_days(session: Session, query: PaymentStatsQuery) -> list:
"""按(回款天数精确值, 客户类型, 产品类别)聚合绩效与应付。"""
return list(
session.execute(
select(
PerformanceLedger.payment_days,
PerformanceLedger.customer_type,
PerformanceLedger.product_category,
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count().label("ledger_count"),
)
.where(*_payment_filters(query))
.group_by(
PerformanceLedger.payment_days,
PerformanceLedger.customer_type,
PerformanceLedger.product_category,
)
).all()
)
def group_payment_receipt_by_days(session: Session, query: PaymentStatsQuery) -> list:
"""按维度聚合回款金额与笔数——金额侧必须按 receipt_id 去重。
二次分摊拆行使同一回款在台账出现多行,直接 SUM(receipts.amount) 会
按拆行数翻倍;台账拆行只改归属人,不改维度值(payment_days/客户类型/
产品类别),因此先对台账行 DISTINCT 出回款维度,再联回款表求和。
"""
distinct_receipts = (
select(
PerformanceLedger.receipt_id.label("receipt_id"),
PerformanceLedger.payment_days.label("payment_days"),
PerformanceLedger.customer_type.label("customer_type"),
PerformanceLedger.product_category.label("product_category"),
)
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
.distinct()
.subquery()
)
from app.models import Receipt
return list(
session.execute(
select(
distinct_receipts.c.payment_days,
distinct_receipts.c.customer_type,
distinct_receipts.c.product_category,
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
func.count(distinct_receipts.c.receipt_id).label("receipt_count"),
)
.outerjoin(Receipt, Receipt.id == distinct_receipts.c.receipt_id)
.group_by(
distinct_receipts.c.payment_days,
distinct_receipts.c.customer_type,
distinct_receipts.c.product_category,
)
).all()
)
def group_payment_by_customer_type(session: Session, query: PaymentStatsQuery) -> list:
"""按客户类型聚合(绩效 + 回款金额)。"""
return _group_by_dimension(session, query, PerformanceLedger.customer_type)
def group_payment_by_product_category(session: Session, query: PaymentStatsQuery) -> list:
"""按产品类别聚合(绩效 + 回款金额)。"""
return _group_by_dimension(session, query, PerformanceLedger.product_category)
def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column) -> list:
"""维度聚合共用实现(绩效侧 GROUP BY + 金额侧 DISTINCT 去重联回款)。"""
from app.models import Receipt
dim_perf = (
select(
dim_column.label("dim"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count().label("ledger_count"),
)
.where(*_payment_filters(query))
.group_by(dim_column)
)
dim_receipts = (
select(
dim_column.label("dim"),
PerformanceLedger.receipt_id.label("receipt_id"),
)
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
.distinct()
.subquery()
)
perf_rows = {row.dim: row for row in session.execute(dim_perf).all()}
receipt_rows = session.execute(
select(
dim_receipts.c.dim,
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
)
.outerjoin(Receipt, Receipt.id == dim_receipts.c.receipt_id)
.group_by(dim_receipts.c.dim)
).all()
receipt_amounts = {row.dim: row.receipt_amount for row in receipt_rows}
return [
(
row.dim,
receipt_amounts.get(row.dim),
row.performance_total,
row.payable_total,
row.ledger_count,
)
for row in perf_rows.values()
]
def _bucket_condition(bucket: DayBucket) -> tuple:
"""区间桶的 days 过滤条件:none 桶 IS NULL,其余按 min/max 闭区间。"""
if bucket.code == "none":
return (PerformanceLedger.payment_days.is_(None),)
conds = []
if bucket.min_days is not None:
conds.append(PerformanceLedger.payment_days >= bucket.min_days)
if bucket.max_days is not None:
conds.append(PerformanceLedger.payment_days <= bucket.max_days)
return tuple(conds)
def list_payment_detail(
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
) -> list:
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
"""
from app.models import Receipt
stmt = (
select(
PerformanceLedger,
Receipt.amount.label("receipt_amount"),
Receipt.receipt_batch_no.label("receipt_batch_no"),
)
.outerjoin(Receipt, Receipt.id == PerformanceLedger.receipt_id)
.where(*_payment_filters(query), *_bucket_condition(bucket))
)
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> int:
"""区间下钻计数(与 list_payment_detail 同筛选)。"""
return int(
session.execute(
select(func.count())
.select_from(PerformanceLedger)
.where(*_payment_filters(query), *_bucket_condition(bucket))
).scalar_one()
)

View File

@ -0,0 +1,112 @@
"""月度结算仓储:只读写数据并 flush;commit/rollback 在服务层。"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import MonthlySettlement, SettlementItem
from app.schemas.settlement import SettlementQuery
def get_by_id(session: Session, settlement_id: int) -> MonthlySettlement | None:
return session.get(MonthlySettlement, settlement_id)
def get_by_month(session: Session, month: str) -> MonthlySettlement | None:
"""按月份取月结行(settlement_month 全表唯一)。"""
return session.execute(
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
).scalar_one_or_none()
def add(session: Session, settlement: MonthlySettlement) -> MonthlySettlement:
session.add(settlement)
session.flush()
return settlement
def list_settlements(session: Session, query: SettlementQuery) -> list[MonthlySettlement]:
return list(
session.execute(
select(MonthlySettlement)
.where(*_filters(query))
.order_by(MonthlySettlement.settlement_month.desc())
.offset(query.offset)
.limit(query.page_size)
).scalars()
)
def count_settlements(session: Session, query: SettlementQuery) -> int:
return int(
session.execute(
select(func.count()).select_from(MonthlySettlement).where(*_filters(query))
).scalar_one()
)
def _filters(query: SettlementQuery) -> tuple:
filters = []
if query.status is not None:
filters.append(MonthlySettlement.status == query.status)
return tuple(filters)
def list_items(session: Session, settlement_id: int) -> list[SettlementItem]:
return list(
session.execute(
select(SettlementItem)
.where(SettlementItem.settlement_id == settlement_id)
.order_by(SettlementItem.id)
).scalars()
)
def replace_items(session: Session, settlement_id: int, rows: list[SettlementItem]) -> None:
"""全量替换明细(先删后 flush 再插,防御写法;不 commit)。"""
session.execute(delete_items_stmt(settlement_id))
session.flush()
for row in rows:
row.settlement_id = settlement_id
session.add(row)
session.flush()
def delete_items_stmt(settlement_id: int):
from sqlalchemy import delete
return delete(SettlementItem).where(SettlementItem.settlement_id == settlement_id)
def update_ledger_settlement_id(
session: Session, ledger_ids: list[int], settlement_id: int
) -> None:
"""月结生成时把纳入的台账行回填 settlement_id(append-only 的受控回填)。"""
from sqlalchemy import update
from app.models import PerformanceLedger
if ledger_ids:
session.execute(
update(PerformanceLedger)
.where(PerformanceLedger.id.in_(ledger_ids))
.values(settlement_id=settlement_id)
)
session.flush()
def clear_ledger_settlement_id(session: Session, settlement_id: int) -> None:
"""重生成前解绑:释放本结算名下台账行的 settlement_id(「未入结」判据复位)。
草稿/驳回态重新生成时,上一轮回填的行必须重新进入候选集——否则
``settlement_id IS NULL`` 会把它们永久排除,重生成漏算整月工资。
"""
from sqlalchemy import update
from app.models import PerformanceLedger
session.execute(
update(PerformanceLedger)
.where(PerformanceLedger.settlement_id == settlement_id)
.values(settlement_id=None)
)
session.flush()

View File

@ -0,0 +1,62 @@
"""绩效核算的请求/响应模型(M3 ④)。"""
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class LedgerQuery(PageQuery):
"""台账列表查询条件。"""
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
personnel_id: int | None = Field(default=None, ge=1, description="归属人员筛选")
business_id: int | None = Field(default=None, ge=1, description="业务筛选")
is_active: int | None = Field(default=None, ge=0, le=1, description="生效标记筛选")
calc_type: int | None = Field(default=None, ge=1, le=2, description="核算类型筛选")
class LedgerOut(BaseModel):
"""台账行响应(金额/比例一律 str)。"""
model_config = ConfigDict(from_attributes=True)
id: int
receipt_id: int | None = None
calc_version: int
is_active: int
calc_type: int
business_id: int
business_no: str
personnel_id: int
personnel_name: str
role_code: str
customer_type: str
product_category: str
ship_date: date | None = None
receipt_date: date
payment_days: int | None = None
rate_text: str
converted_profit: Decimal | None = None
performance_amount: Decimal | None = None
barter_applied: int
weight_r: int | None = None
payment_weight: Decimal | None = None
payable_amount: Decimal | None = None
settlement_month: str
is_probation: int
missing_cost: int
snapshot_json: dict | None = None
settlement_id: int | None = None
calc_reason: str | None = None
create_time: datetime
class CalcReceiptOut(BaseModel):
"""单笔核算结果:台账行 + 重复计提标记(核算粒度 #25 一期口径)。"""
ledger: LedgerOut
#: True=该业务此前已核算过,本笔回款仅登记进度、未重复计提(#25 一期口径)
repeated: bool = False

View File

@ -1,6 +1,7 @@
"""客户管理的请求/响应模型(需求 2.1.3)。"""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@ -13,6 +14,10 @@ class CustomerOut(BaseModel):
owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的
脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕
「ID 固化、姓名随联表」同一口径。
rating_total / rating_grade_name 由 LEFT JOIN customer_ratings 带出
(列表评级列免 N+1):未评级时均为 null;总分按分值红线以字符串出参,
等级为保存时点的快照固化值(见 models/customer_rating.py)。
"""
model_config = ConfigDict(from_attributes=True)
@ -28,6 +33,8 @@ class CustomerOut(BaseModel):
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
remark: str | None = None
rating_total: str | None = None
rating_grade_name: str | None = None
class CustomerCreateIn(BaseModel):
@ -97,6 +104,14 @@ class CustomerQuery(PageQuery):
)
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
# 排序白名单(全项目首个列表排序参数):非法值由 Literal 拦下、经全局
# 异常处理器转 400 —— 不把排序字段透传给 SQL,避免注入面
order_field: Literal["rating"] | None = Field(
default=None, description="排序字段白名单:rating=客户评级总分(等级由总分判定、同向)"
)
order_type: Literal["asc", "desc"] | None = Field(
default=None, description="排序方向:asc 正序 / desc 倒序(未评级恒在末尾)"
)
class CustomerOwnershipIn(BaseModel):

View File

@ -0,0 +1,126 @@
"""工作台的请求/响应模型(M4 ⑧)。
四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返):
经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal
(序列化为字符串),前端不做数值运算。
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
不返回伪造的 0 条。
"""
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, Field
class TrendPoint(BaseModel):
"""近 6 个月趋势点(绩效按台账归属月,回款按到账月)。"""
month: str
performance_total: Decimal | None = None
receipt_total: Decimal | None = None
class SettlementBrief(BaseModel):
"""当月月结状态(工作台总览角标)。"""
exists: bool = False
status: int | None = Field(default=None, description="1草稿 2待审批 3已审定 4已驳回")
total_performance: Decimal | None = None
total_payable: Decimal | None = None
locked_at: datetime | None = None
class OverviewBlock(BaseModel):
"""经营绩效总览(FR-033):口径与台账/月结一致。"""
month: str
performance_total: Decimal | None = Field(default=None, description="当月单笔绩效合计")
payable_total: Decimal | None = Field(default=None, description="当月应付绩效合计")
person_count: int = Field(default=0, description="当月有台账的人员数(拆行去重)")
team_count: int = Field(default=0, description="在职营销经理数(团队数)")
settlement: SettlementBrief
trend: list[TrendPoint] = Field(default_factory=list, description="近 6 个月")
note: str = Field(default="", description="口径说明(页面展示)")
class ApprovalTodoItem(BaseModel):
"""待审批条目。"""
order_id: int
order_no: str
order_type: int
submitter_name: str | None = None
create_time: datetime
class ReceiptTodoItem(BaseModel):
"""回款未指定发货批次条目。"""
receipt_id: int
receipt_batch_no: str
business_id: int
business_no: str | None = None
amount: Decimal
receipt_date: date
class TodoBlock(BaseModel):
"""待办事项(FR-034,澄清 #11 起步范围)。"""
pending_approval_total: int = 0
pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list)
unassigned_receipt_total: int = 0
unassigned_receipts: list[ReceiptTodoItem] = Field(default_factory=list)
sync_available: bool = Field(default=False, description="⑥ 同步模块是否已启用")
sync_failure_total: int = 0
class WarningItem(BaseModel):
"""风险预警条目(实时派生,不改变业务数据)。"""
code: str = Field(description="规则编码:receipt_overdue / approval_timeout")
level: str = Field(description="级别:warning / danger")
object_type: str
object_id: int
title: str
detail: str
suggest: str
link: str | None = Field(default=None, description="前端跳转路径")
class WarningBlock(BaseModel):
"""风险预警(FR-035,首批两条可算规则;同步规则待 ⑥)。"""
enabled: bool = True
receipt_overdue_days: int = 90
approval_timeout_days: int = 3
items: list[WarningItem] = Field(default_factory=list)
total: int = Field(default=0, description="全部规则命中总数(items 仅为展示上限内的子集)")
receipt_overdue_total: int = 0
approval_timeout_total: int = 0
sync_available: bool = False
class ActivityItem(BaseModel):
"""最近动态条目(来源 operation_logs,只读聚合)。"""
id: int
module: str
action: str
summary: str
username: str | None = None
success: int
create_time: datetime
class DashboardOut(BaseModel):
"""工作台四区块一次性响应。"""
month: str
overview: OverviewBlock
todos: TodoBlock
warnings: WarningBlock
activities: list[ActivityItem] = Field(default_factory=list)

View File

@ -77,3 +77,55 @@ class RatingGradeUpdateIn(RatingGradeCreateIn):
前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。
"""
class CustomerRatingOut(BaseModel):
"""客户评级响应(每客户一行;总分与分值按红线以字符串出参)。
grade_name 是保存时点按等级映射**快照固化**的值(无外键):等级表后续
增删改不影响已保存的评级结论;为 NULL 表示保存时未命中任何等级
(总分低于全部下限或等级表被清空)。
"""
customer_id: int
score_ship_amount: str
score_receipt_rate: str
score_profit_rate: str
score_contract_cooperation: str
score_site_construction: str
total_score: str
grade_name: str | None = None
remark: str | None = None
scorer_name: str
create_time: datetime
update_time: datetime
class CustomerRatingSaveIn(BaseModel):
"""保存客户评级(每客户一行,整体替换语义)。
五项分值为 0-20 的数字字符串(schema 只拦格式;0≤分≤20 与总分等级
判定在 service 层审计上下文内完成 —— 失败的 400 同样留痕)。总分由
服务端按五项重算,**不采信前端回传的总分**。
"""
model_config = ConfigDict(str_strip_whitespace=True)
score_ship_amount: str = Field(description="发货额评分(0-20 数字字符串)")
score_receipt_rate: str = Field(description="回款率评分(0-20 数字字符串)")
score_profit_rate: str = Field(description="毛利率评分(0-20 数字字符串)")
score_contract_cooperation: str = Field(description="合同履约配合能力评分(0-20 数字字符串)")
score_site_construction: str = Field(description="现场规范施工能力评分(0-20 数字字符串)")
remark: str | None = Field(default=None, max_length=255, description="备注")
@field_validator(
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
@classmethod
def _check_score(cls, value: str) -> str:
"""单项分值须为有限数字(值域 0-20 由 service 层判定)。"""
return _parse_score(value, label="评分")

View File

@ -0,0 +1,369 @@
"""报表统计的请求/响应模型(M4 ⑤)。
金额与比例一律 `Decimal`(pydantic v2 序列化为字符串,零精度损失);
比例原样输出 `rate_text`(如 88/9000),禁止转 float 或截断展示。
"""
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.month import validate_month
from app.core.response import PageQuery
def _required_month(value: str) -> str:
"""必填月份:非法直接抛 ValueError,由 FastAPI 压成 400。"""
return validate_month(value)
def _optional_month(value: str | None) -> str | None:
"""可选月份:空串规范为 None(axios 未剔除时也不会误报格式错误)。"""
if value in (None, ""):
return None
return validate_month(value)
# ==== 查询入参 ====
class PersonalDetailQuery(PageQuery):
"""个人绩效明细查询条件(FR-029:人员/月份/客户类型/产品类别/岗位)。"""
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
personnel_id: int | None = Field(default=None, ge=1, description="人员筛选")
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
role_code: str | None = Field(default=None, max_length=32, description="岗位筛选")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str | None) -> str | None:
return _optional_month(value)
class DepartmentSummaryQuery(BaseModel):
"""部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。"""
settlement_month: str = Field(min_length=7, max_length=7, description="归属月 YYYY-MM")
manager_id: int | None = Field(default=None, ge=1, description="仅看某经理团队")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str) -> str:
return _required_month(value)
class BusinessStatsQuery(PageQuery):
"""业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。"""
keyword: str | None = Field(default=None, max_length=64, description="业务编号/客户名称")
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人筛选")
status: int | None = Field(default=None, description="业务状态筛选")
date_from: date | None = Field(default=None, description="业务日期起")
date_to: date | None = Field(default=None, description="业务日期止")
class PaymentStatsQuery(BaseModel):
"""回款统计查询条件(按回款天数区间/客户类型/产品类别统计,FR-031)。"""
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
date_from: date | None = Field(default=None, description="回款日期起")
date_to: date | None = Field(default=None, description="回款日期止")
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str | None) -> str | None:
return _optional_month(value)
class PaymentDetailQuery(PageQuery):
"""回款区间下钻(桶编码必填,入参由 payment-stats 页面展开行携带)。"""
bucket: str = Field(min_length=1, max_length=16, description="区间桶编码")
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
date_from: date | None = Field(default=None, description="回款日期起")
date_to: date | None = Field(default=None, description="回款日期止")
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str | None) -> str | None:
return _optional_month(value)
# ==== 响应出参 ====
class ReportSummary(BaseModel):
"""全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。
字段按报表取用:个人明细用绩效/应付/折算毛利;业务统计用发货/回款/
销售额;不涉及的留 None。
"""
row_count: int = Field(default=0, description="筛选结果行数")
performance_total: Decimal | None = Field(default=None, description="绩效金额合计")
payable_total: Decimal | None = Field(default=None, description="应付绩效合计")
converted_profit_total: Decimal | None = Field(default=None, description="折算毛利润合计")
ship_total: Decimal | None = Field(default=None, description="发货额合计")
received_total: Decimal | None = Field(default=None, description="回款额合计")
sale_total: Decimal | None = Field(default=None, description="销售额合计")
class ReportPage[T](BaseModel):
"""报表分页:标准分页四字段 + 全筛选合计。
序列化字段与 PageResult 一致(list/total/pageNum/pageSize),前端
usePagedList 可直接消费;追加的 summary 由各页页脚单独读取。
"""
model_config = ConfigDict(populate_by_name=True)
records: list[T] = Field(
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
)
total: int = Field(default=0, description="总记录数")
page_num: int = Field(
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
)
page_size: int = Field(
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
)
summary: ReportSummary = Field(default_factory=ReportSummary, description="全筛选合计")
class PersonalDetailRow(BaseModel):
"""个人绩效明细行(需规 2.4.1 列全覆盖)。
二次分摊拆行后同一回款按人多行(负责人余额行 + 参与人份额行),行内
不放「关联回款金额」列——同回款金额重复展示会让合计无法自洽,金额
下沉到快照下钻。
"""
ledger_id: int
business_id: int
business_no: str
personnel_id: int
personnel_name: str
role_code: str
role_name: str
customer_type: str
product_category: str
ship_date: date | None = None
receipt_date: date
payment_days: int | None = None
rate_text: str
weight_r: int | None = None
payment_weight: Decimal | None = None
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
converted_profit: Decimal | None = None
is_probation: int
missing_cost: int
calc_version: int
settlement_id: int | None = None
class DepartmentMemberRow(BaseModel):
"""部门汇总 · 团队成员明细(内联于团队行)。"""
personnel_id: int
personnel_name: str
role_code: str
is_probation: int
member_kind: str = Field(
default="regular",
description="行来源:regular 普通台账 / manager_personal 经理个人户 / adjustment 仅调整单",
)
unassigned_reason: str | None = Field(
default=None, description="未纳入团队基数的原因(仅未分配块)"
)
performance_sum: Decimal | None = None
payable_sum: Decimal | None = None
adjust_extra: Decimal = Field(default=Decimal("0"), description="调整单生效补发合计")
adjust_deduct: Decimal = Field(default=Decimal("0"), description="调整单生效扣减合计")
adjust_net: Decimal = Field(default=Decimal("0"), description="调整净额(补发为正)")
net_total: Decimal | None = None
ledger_ids: list[int] = Field(default_factory=list, description="台账行 id 列表(下钻锚点)")
class DepartmentTeamRow(BaseModel):
"""部门汇总 · 经理团队行。"""
manager_id: int
manager_name: str
manager_status: int = Field(description="经理在职状态(1 在职 0 离职)")
member_count: int
team_base: Decimal = Field(description="团队基数(12.5% 的底,等于成员绩效合计)")
team_share_amount: Decimal = Field(description="12.5% 团队分成(分列展示)")
company_share_amount: Decimal = Field(
description="单经理 5% 名义值;该团队无月结聚合行时为 0(不发放)"
)
has_aggregate: bool = Field(
default=False, description="本月是否有月结聚合行(决定是否发放 5%)"
)
aggregate_amount: Decimal = Field(
default=Decimal("0"),
description="月结聚合行应发(12.5%+5% 一次舍入);无聚合行时 0",
)
team_subtotal: Decimal = Field(
default=Decimal("0"),
description="团队应发小计 = 聚合行金额 + 调整净额",
)
company_base_ratio: Decimal = Field(description="团队基数占全公司基数比例(除零为 0)")
adjust_extra: Decimal = Decimal("0")
adjust_deduct: Decimal = Decimal("0")
adjust_net: Decimal = Decimal("0")
net_total: Decimal = Field(description="团队净额 = 团队应发小计")
frozen_amount: Decimal | None = Field(default=None, description="月结聚合行冻结额(对照)")
aggregate_ledger_id: int | None = None
members: list[DepartmentMemberRow] = Field(default_factory=list)
class DepartmentCompanyBlock(BaseModel):
"""部门汇总 · 全公司口径块(FR-030「全公司 5% 部分单列」)。"""
team_base_total: Decimal = Field(description="各团队基数之和(= 全部单笔绩效合计)")
company_base: Decimal = Field(description="全公司基数(5% 的底)")
company_share_pct: str = Field(description="分成比例参数原文")
company_share_amount: Decimal = Field(description="单经理 5% 名义值")
company_share_count: int = Field(
default=0, description="计入应发的经理数(展示团队中有月结聚合行的数量)"
)
company_share_total: Decimal = Field(
default=Decimal("0"), description="5% 合计 = 单份 × 计入次数"
)
class DepartmentReconciliation(BaseModel):
"""部门汇总 · 对账块(导出/打印签字稿的合计口径,前端不自行求和)。
应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
聚合行金额与月结 ``total_payable`` 同口径(12.5%+5% 一次舍入);
``team_share_total`` 与 ``company_share_total`` 仅作分列展示,两者
之和与聚合额的差记入 ``aggregate_rounding_diff``。
"""
team_member_payable_total: Decimal = Field(description="展示团队成员的应付绩效合计")
unassigned_payable_total: Decimal = Field(description="展示未分配人员的应付绩效合计")
team_share_total: Decimal = Field(description="展示团队的 12.5% 分成合计(分列)")
company_share_total: Decimal = Field(
description="展示团队的全公司分成合计(每位有聚合行的经理各计一次,分列)"
)
aggregate_payable_total: Decimal = Field(
description="展示团队的经理聚合行应发合计(12.5%+5% 一次舍入)"
)
aggregate_rounding_diff: Decimal = Field(
default=Decimal("0"),
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
)
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
payable_total: Decimal = Field(description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额")
team_base_total: Decimal = Field(description="展示团队基数合计")
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
other_team_base: Decimal = Field(
default=Decimal("0"), description="未展示的基数(其他团队 + 全公司未分配)"
)
other_team_member_payable_total: Decimal = Field(default=Decimal("0"))
other_team_share_total: Decimal = Field(default=Decimal("0"))
other_team_company_share_total: Decimal = Field(default=Decimal("0"))
other_team_aggregate_total: Decimal = Field(default=Decimal("0"))
other_adjust_net_total: Decimal = Field(default=Decimal("0"))
company_base: Decimal = Field(description="全公司基数(5% 的底)")
class DepartmentSummaryOut(BaseModel):
"""部门绩效工资汇总响应(FR-030)。"""
settlement_month: str
params: dict | None = Field(default=None, description="分成参数原文与取值时点(对账用)")
teams: list[DepartmentTeamRow] = Field(default_factory=list)
unassigned: list[DepartmentMemberRow] = Field(
default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)"
)
company: DepartmentCompanyBlock | None = None
reconciliation: DepartmentReconciliation | None = Field(
default=None, description="签字稿对账块(应发公式各项)"
)
totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)")
class BusinessStatsRow(BaseModel):
"""业务统计行(发货/回款/绩效为该业务累计值)。"""
business_id: int
business_no: str
business_date: date | None = None
status: int
customer_name: str | None = None
owner_personnel_name: str | None = None
total_sale_amount: Decimal | None = None
total_ship_amount: Decimal | None = None
total_received_amount: Decimal | None = None
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
class BucketRow(BaseModel):
"""回款统计 · 区间桶行(空桶恒在且为 0,防金额静默消失)。"""
code: str
label: str
min_days: int | None = None
max_days: int | None = None
receipt_amount: Decimal | None = None
receipt_count: int = 0
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
ledger_count: int = 0
class GroupRow(BaseModel):
"""回款统计 · 维度对比行(客户类型 / 产品类别)。"""
code: str
label: str
receipt_amount: Decimal | None = None
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
ledger_count: int = 0
class PaymentStatsOut(BaseModel):
"""回款情况统计响应(FR-031)。
口径:已计提回款(有台账行)的金额与绩效;进度登记回款无台账行,
不在本表(页面需注明)。
"""
settlement_month: str | None = None
buckets: list[BucketRow] = Field(default_factory=list)
by_customer_type: list[GroupRow] = Field(default_factory=list)
by_product_category: list[GroupRow] = Field(default_factory=list)
totals: dict = Field(default_factory=dict, description="全表合计")
class PaymentDetailRow(BaseModel):
"""回款区间下钻行(行级展示回款金额与批次号)。"""
ledger_id: int
receipt_id: int | None = None
receipt_batch_no: str | None = None
amount: Decimal | None = Field(default=None, description="回款金额(同回款拆行重复展示)")
business_id: int
business_no: str
personnel_id: int
personnel_name: str
customer_type: str
product_category: str
ship_date: date | None = None
receipt_date: date
payment_days: int | None = None
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None

View File

@ -1,28 +1,59 @@
"""系统设置(公司信息)请求/响应模型。
"""系统设置(公司信息 + 站点信息)请求/响应模型。
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
顺着复用链泄漏到匿名端点。
2026-10-20 精简(用户确认):删除系统副标题与企业档案字段(信用代码/
地址/电话/邮箱);新增站点标题(浏览器标签页/侧边栏副标题)与站点图标
(浏览器标签页 favicon)。public 端点只需登录页/标签页展示字段,两者
恰好同集合,但仍保留独立 schema 作为白名单防线。
"""
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
class SettingOut(BaseModel):
"""公司信息(管理端)。"""
"""公司信息与站点信息(管理端)。"""
company_name: str
logo_url: str | None = None
site_title: str | None = None
brand_slogan: str | None = None
footer_text: str | None = None
icp_no: str | None = None
favicon_url: str | None = None
class SettingPublicOut(BaseModel):
"""公司信息(匿名端点白名单):登录页展示所需的两个字段。"""
"""公司信息与站点信息(匿名端点白名单):登录页/浏览器标签页展示所需字段。"""
company_name: str
logo_url: str | None = None
site_title: str | None = None
brand_slogan: str | None = None
footer_text: str | None = None
icp_no: str | None = None
favicon_url: str | None = None
class SettingUpdateIn(BaseModel):
"""公司信息更新。"""
"""公司信息与站点信息更新(**部分更新**语义)。
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
仅提交的字段生效(``exclude_unset``),未提交或显式 null 的字段保持
不变;可选文本传空串等价清空(落库为 null、前端回退内置默认)。
company_name 若提交必须非空(DB 非空约束)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
company_name: str | None = Field(
default=None, min_length=1, max_length=64, description="公司名称(提交则须非空)"
)
site_title: str | None = Field(default=None, max_length=64, description="站点标题")
brand_slogan: str | None = Field(default=None, max_length=64, description="品牌标语")
footer_text: str | None = Field(default=None, max_length=64, description="页脚版权文本")
icp_no: str | None = Field(default=None, max_length=32, description="ICP 备案号")

View File

@ -0,0 +1,96 @@
"""月度结算的请求/响应模型(M3 ④)。"""
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.month import validate_month
from app.core.response import PageQuery
def _check_month(value: str) -> str:
"""月份格式 YYYY-MM 且月份 01-12(settlement_month 全表唯一的维度)。"""
return validate_month(value)
class SettlementGenerateIn(BaseModel):
"""月结生成入参。"""
month: str = Field(description="结算月份 YYYY-MM")
@field_validator("month")
@classmethod
def _v_month(cls, value: str) -> str:
return _check_month(value)
class SettlementQuery(PageQuery):
"""月结列表查询。"""
status: int | None = Field(default=None, ge=1, le=4, description="状态筛选")
class SettlementItemOut(BaseModel):
"""月结明细行。"""
model_config = ConfigDict(from_attributes=True)
id: int
personnel_id: int
personnel_name: str
role_code: str
calc_type: int
performance_sum: Decimal
payable_sum: Decimal
basic_amount: Decimal | None = None
salary_mode: str | None = None
net_amount: Decimal | None = None
is_probation: int
probation_note: str | None = None
class SettlementOut(BaseModel):
"""月结头表响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
settlement_month: str
status: int
total_performance: Decimal | None = None
total_payable: Decimal | None = None
person_count: int
approval_order_no: str | None = None
submitter_name: str
locked_at: datetime | datetime | None = None
locked_by: str | None = None
create_time: datetime
class SettlementDetailOut(SettlementOut):
"""月结详情:附明细。"""
items: list[SettlementItemOut] = Field(default_factory=list)
class AdjustmentCreateIn(BaseModel):
"""调整单创建入参(金额恒正,方向表达补发/扣减)。"""
model_config = ConfigDict(str_strip_whitespace=True)
settlement_month: str = Field(description="调整月份 YYYY-MM")
personnel_id: int = Field(ge=1, description="调整人员ID")
direction: int = Field(ge=1, le=2, description="1补发 2扣减")
# 正则先拦格式(非负两位小数、≤12 位整数防 NUMERIC(14,2) 溢出);
# 「> 0」由服务层判定(schema 只管形状,语义校验留在 service)
amount: str = Field(
pattern=r"^\d{1,12}(\.\d{1,2})?$",
description="金额(元,两位小数,恒正)",
)
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str) -> str:
return _check_month(value)

View File

@ -19,10 +19,10 @@
"""
import logging
from collections.abc import Iterable
from datetime import date
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
@ -30,16 +30,17 @@ from app.core.constants import (
ACTION_REJECT,
MODULE_APPROVAL,
NO_PREFIX_APPROVAL,
ROLE_ADMIN,
ROLE_FINANCE,
ROLE_LEADER,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import ApprovalOrder, ApprovalStep, User
from app.models.approval import (
APPROVAL_ACTION_APPROVE,
APPROVAL_ACTION_AUTO_APPROVE,
APPROVAL_ACTION_REJECT,
APPROVAL_ACTION_WITHDRAW,
APPROVAL_NODE_FINANCE,
APPROVAL_NODE_LEADER,
@ -65,14 +66,14 @@ from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 开关参数编码(与 param_registry 种子同源)
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
ENABLE_CODE_BY_TYPE: dict[int, str] = {
1: "approval.secondary_allocation.enabled",
2: "approval.monthly_settlement.enabled",
3: "approval.adjustment.enabled",
}
_NO_RETRY = 1 # AP 单号唯一键冲突重试(next_no 的 MAX+FOR UPDATE 之外的兜底)
# 在途状态集合(同一引用对象至多一张在途单)
_PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
@ -85,6 +86,18 @@ _NODE_ROLE_BY_STATUS = {
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
"""工作台「待我审批」:按当前用户角色映射在途审批状态。
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
的可见性一致);财务/领导只看到本节点可操作的条目。
"""
codes = set(role_codes)
if ROLE_ADMIN in codes:
return _PENDING_STATUSES
return tuple(status for status, role in _NODE_ROLE_BY_STATUS.items() if role in codes)
def is_enabled(session: Session, order_type: int) -> bool:
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
code = ENABLE_CODE_BY_TYPE.get(order_type)
@ -100,19 +113,15 @@ def submit_order(
ref_id: int,
refer_no: str,
submitter: User,
retry_on_conflict: bool,
) -> ApprovalOrder:
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
(全节点 action=3 留痕)。
:param retry_on_conflict: AP 编号撞唯一键时是否允许本函数内部
rollback 后重取。**仅 create 传 True**——它外层有编号重试循环,
rollback 后整体重来;update 路径必须传 False:内部 rollback 会把
同事务的业务行修改一并回滚,静默继续会「返回 200 库里没改」。
False 时 IntegrityError 直接抛出 → 审计上下文回滚并记失败 →
全局 handler 收敛 400,用户重试即可。
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
"""
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
@ -133,13 +142,13 @@ def submit_order(
if is_enabled(session, order_type):
order.status = APPROVAL_STATUS_PENDING_FINANCE
_flush_order(session, order, retry_on_conflict=retry_on_conflict)
session.flush()
logger.info("审批单待审 | order=%s", order.order_no)
return order
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
order.status = APPROVAL_STATUS_APPROVED
_flush_order(session, order, retry_on_conflict=retry_on_conflict)
session.flush()
session.add_all(
[
ApprovalStep(
@ -156,6 +165,29 @@ def submit_order(
return order
def withdraw_pending_orders(
session: Session, *, order_type: int, ref_id: int, operator: User
) -> int:
"""撤回引用对象的全部在途单(供业务清空方案等场景显式调用;不 commit)。
返回撤回张数(0 = 本就无在途单)。
"""
before = len(
session.execute(
select(ApprovalOrder.id).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
if before:
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=operator)
return before
def act(
session: Session,
order_id: int,
@ -194,14 +226,19 @@ def act(
raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作")
node_now = APPROVAL_NODES[order.status]
if payload.action == APPROVAL_ACTION_APPROVE:
rejected = payload.action == APPROVAL_ACTION_REJECT
if rejected:
order.status = APPROVAL_STATUS_REJECTED
else:
order.status = (
APPROVAL_STATUS_PENDING_LEADER
if order.status == APPROVAL_STATUS_PENDING_FINANCE
else APPROVAL_STATUS_APPROVED
)
else:
order.status = APPROVAL_STATUS_REJECTED
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
# 驳回在任何节点都是终态,即时联动。
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
session.add(
ApprovalStep(
@ -213,8 +250,52 @@ def act(
operator_name=operator.username,
)
)
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
# 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
# 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
# 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
# 委托 business_repository 完成 —— service 层互调、不触 commit。
if rejected and order.order_type == 1:
from app.repositories import business_repository
business_repository.delete_allocations(session, order.ref_id)
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
if order.order_type == 2 or order.order_type == 3:
from app.models import AdjustmentOrder
from app.models.settlement import (
ADJUSTMENT_STATUS_EFFECTIVE,
)
from app.models.settlement import (
ADJUSTMENT_STATUS_REJECTED as ADJ_REJECTED,
)
from app.services import settlement_service
if order.order_type == 2:
from app.models.settlement import MonthlySettlement
target = session.get(MonthlySettlement, order.ref_id)
if target is not None:
if rejected:
settlement_service.mark_rejected(session, target, operator=operator)
elif final_approved:
settlement_service.mark_locked(session, target, operator=operator)
else:
adj = session.get(AdjustmentOrder, order.ref_id)
if adj is not None:
if rejected:
adj.status = ADJ_REJECTED
elif final_approved:
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
# 成失败审计,与库中事实相反(全 services 层唯一例外不许再犯)
session.flush()
detail = _detail_of(session, order)
session.commit()
return _detail_of(session, order)
return detail
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
@ -279,33 +360,8 @@ def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operato
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
def _flush_order(session: Session, order: ApprovalOrder, *, retry_on_conflict: bool) -> None:
"""flush 提单;撞 AP 单号唯一键时按 retry_on_conflict 决定重取或抛出。
rollback 会连业务写路径的未提交变更一并回滚 —— 见 submit_order 的
retry_on_conflict 说明(create 才允许内部重取)。
"""
for attempt in range(_NO_RETRY + 1):
try:
session.flush()
return
except IntegrityError as exc:
session.rollback()
if is_duplicate_entry(exc) and retry_on_conflict and attempt < _NO_RETRY:
order.id = None # rollback 后实例回 transient,主键重新分配
order.order_no = next_no(
session,
model=ApprovalOrder,
column_name="order_no",
prefix=NO_PREFIX_APPROVAL,
)
session.add(order)
continue
raise
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
"""出参组装(含 steps 时间线)。"""
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
steps = (
session.execute(
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
@ -321,8 +377,13 @@ def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut
def _order_filters(query: ApprovalQuery) -> tuple:
filters = []
if query.keyword:
like = f"%{query.keyword}%"
filters.append(ApprovalOrder.order_no.like(like) | ApprovalOrder.refer_no.like(like))
# autoescape:用户输入里的 %/_ 按 URL 片段字面匹配(全库统一口径,
# 见 customer_repository.contains 用法)——否则 "AP2026_10" 会把
# 下划线当单字符通配,搜出无关单据
filters.append(
ApprovalOrder.order_no.contains(query.keyword, autoescape=True)
| ApprovalOrder.refer_no.contains(query.keyword, autoescape=True)
)
if query.status is not None:
filters.append(ApprovalOrder.status == query.status)
if query.order_type is not None:

View File

@ -34,6 +34,7 @@ from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult
from app.models import (
ApprovalOrder,
Business,
BusinessItem,
BusinessStatusLog,
@ -51,7 +52,7 @@ from app.models.business import (
BUSINESS_STATUS_DONE,
)
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import business_repository, customer_repository
from app.repositories import business_repository, customer_repository, personnel_repository
from app.schemas.business import (
BusinessCreateIn,
BusinessDetailOut,
@ -284,15 +285,45 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
def _build_allocation_rows(
session: Session, business_id: int, items: list[SecondaryAllocationIn]
session: Session,
business_id: int,
items: list[SecondaryAllocationIn],
*,
old_ids: set[int],
) -> list[SecondaryAllocation]:
"""构造二次分配明细行:人员在职校验 + 姓名冗余固化 + 比例转 Decimal。
"""构造二次分配明细行:人员校验 + 姓名冗余固化 + 比例转 Decimal。
:raises BadRequestError: 参与人员不存在或已离职
:param old_ids: 编辑前已参与的人员 ID 集合(create 传空集)。这些人的
行是**历史事实**——personnel_name 冗余固化的设计本就允许离职后回显,
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
**新加入**的参与人做在职校验。
:raises BadRequestError: 新增参与人员不存在或已离职 / 比例合计超 100%
/ 参与人含合同负责人(口径见澄清 #1/#2 答复 2026-10-19:分配绩效、
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
自分份额则双重计入)
"""
business = business_repository.get_by_id(session, business_id)
if business is None:
raise NotFoundError("业务不存在")
total = sum((Decimal(item.ratio) for item in items), Decimal("0"))
if total > Decimal("100"):
raise BadRequestError(
f"二次分配比例合计 {total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}%"
" 超过 100%(余额归合同负责营销人员,比例不可超满额)"
)
owner_id = business.owner_personnel_id
if owner_id is not None and any(item.personnel_id == owner_id for item in items):
raise BadRequestError("分摊参与人不可包含合同负责营销人员(负责人得余额份额)")
rows: list[SecondaryAllocation] = []
for item in items:
person = _ensure_personnel_active(session, item.personnel_id, "分摊参与")
if item.personnel_id in old_ids:
person = personnel_repository.get_by_id(session, item.personnel_id)
if person is None:
raise BadRequestError("分摊参与人员不存在")
else:
person = _ensure_personnel_active(session, item.personnel_id, "分摊参与")
rows.append(
SecondaryAllocation(
business_id=business_id,
@ -305,7 +336,7 @@ def _build_allocation_rows(
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
"""方案合计/余额提示(不强制合计满额是需求原文;余额归属待澄清 #2)。
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
"""
@ -316,7 +347,7 @@ def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
if total == Decimal("100"):
return f"二次分配合计 {total_text}%"
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"二次分配合计 {total_text}%,余额 {rest}%(归属待业务确认)"
return f"二次分配合计 {total_text}%,余额 {rest}%(归合同负责营销人员)"
def create_business(
@ -388,11 +419,13 @@ def create_business(
business_repository.replace_allocations(
session,
business.id,
_build_allocation_rows(session, business.id, payload.secondary_allocations),
_build_allocation_rows(
session, business.id, payload.secondary_allocations, old_ids=set()
),
)
# 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。
# retry_on_conflict=True:AP 编号撞键可内部重取(外层编号
# 重试循环兜底整体重来)
# AP 编号撞键时 IntegrityError 穿透 → 外层编号重试循环把
# 业务/方案/审批单整体重来
if payload.secondary_allocations:
approval_service.submit_order(
session,
@ -400,7 +433,6 @@ def create_business(
ref_id=business.id,
refer_no=business.business_no,
submitter=operator,
retry_on_conflict=True,
)
customer = customer_repository.get_by_id(session, business.customer_id)
out = _to_out((business, customer.company_name if customer else None, None, None))
@ -477,13 +509,18 @@ def update_business(
)
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
old_ids = {
a.personnel_id for a in business_repository.list_allocations(session, business_id)
}
business_repository.replace_allocations(
session,
business_id,
_build_allocation_rows(session, business_id, payload.secondary_allocations),
_build_allocation_rows(
session, business_id, payload.secondary_allocations, old_ids=old_ids
),
)
# 方案非空提单(自动撤回旧在途单);retry_on_conflict=False:
# 本路径无外层重试,内部 rollback 会回掉业务行修改造成静默丢失
# 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
# 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
if payload.secondary_allocations:
approval_service.submit_order(
session,
@ -491,7 +528,13 @@ def update_business(
ref_id=business_id,
refer_no=business.business_no,
submitter=operator,
retry_on_conflict=False,
)
else:
approval_service.withdraw_pending_orders(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
operator=operator,
)
_recalc_totals(session, business)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
@ -515,7 +558,7 @@ def delete_business(
与业务维度的归属留痕(无外键固化表,须手动清)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 存在发货或回款记录
:raises BadRequestError: 存在发货或回款记录 / 存在在途审批单
"""
business = business_repository.get_by_id(session, business_id, for_update=True)
with audit_service.audit(
@ -532,6 +575,10 @@ def delete_business(
raise NotFoundError("业务不存在")
if business_repository.has_docs(session, business_id):
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
# 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单
if _count_pending_approvals(session, business_id):
raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除")
business_no = business.business_no
_hard_delete(session, business_id)
@ -539,11 +586,46 @@ def delete_business(
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no)
def _count_pending_approvals(session: Session, business_id: int) -> int:
"""该业务的在途审批单数(status 1/2;终态单随业务删除一并清理)。"""
from sqlalchemy import func, select
from app.models import ApprovalOrder
from app.models.approval import (
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
)
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
ApprovalOrder.ref_id == business_id,
ApprovalOrder.status.in_(
(APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
),
)
).scalar_one()
)
def _hard_delete(session: Session, business_id: int) -> None:
"""按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。"""
"""按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→
状态留痕 → 业务维度归属留痕 → 主表。"""
from sqlalchemy import delete
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
)
session.execute(
delete(ApprovalOrder).where(
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
ApprovalOrder.ref_id == business_id,
)
)
session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
session.execute(
delete(OwnershipChangeLog).where(

View File

@ -0,0 +1,719 @@
"""绩效核算服务(M3 ④):回款 → 台账的固化流水线。
单笔核算(calc_receipt)与计算预览(param_service.preview)**共用同一
公式路径与取值防线**:resolve_snapshot 按回款到账日取参(裁决 #11)、按
岗位模板分派计提比例(‰ 存储口径 ÷1000 转纯比例)、行级折算(混合业务
按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、
核算才是算钱的法定固化点。
策略点(业务答复 2026-10-19 已回填,后续调整仍集中在对应函数 + 按回款批量重算):
- ``_personal_rate``:#12 经理个人户单笔由参数 manager.personal.included
分流——0=单笔落 0(聚合口径在月结时由 manager_team_share 计算)、
1=按业务员递减公式计提;#20 在 ``_row_profit``(暂按不含税原样相减)
- 二次分摊(澄清 #1 答复「分配绩效」,算后分):``_load_allocations``
读生效方案,``_persist_ledger`` 按比例拆行(参与人份额 + 负责人余额,
行合计守恒);#2 余额归负责人、#3 二次分配即协助场景的核算载体
- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发,
后续回款仅置 settlement_status=1(repeated=True),不重复计提
"""
import logging
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from typing import Any
from sqlalchemy import update
from sqlalchemy.orm import Session
from app.core.constants import MODULE_CALC
from app.core.exceptions import BadRequestError
from app.models import (
Business,
BusinessItem,
Customer,
PerformanceLedger,
Personnel,
Receipt,
Shipment,
User,
)
from app.models.performance_ledger import CALC_TYPE_RECEIPT
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
from app.repositories import business_repository, performance_ledger_repository
from app.schemas.calc import CalcReceiptOut
from app.services import audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import (
apply_barter,
clamp_weight_r,
converted_profit,
payment_days,
payment_weight,
performance_amount,
profit_rate,
salesperson_rate,
start_payment_ok,
)
from app.services.param_service import parse_fraction, resolve_snapshot
logger = logging.getLogger(__name__)
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
@dataclass(frozen=True, slots=True)
class _RateDecision:
"""单笔计提比例的分派结果。"""
rate: Fraction # 纯比例(str() 产出分数原文如 88/9000,直接落台账 rate_text)
rate_note: str # 人读说明(入 snapshot)
base_override: Decimal | None # 非 None 时绩效基数用它(信息员=销售额)
manager_pending: bool # True=经理聚合口径,单笔落 0(#12 策略点)
def calc_receipt(
session: Session,
receipt_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
recalc_reason: str | None = None,
) -> CalcReceiptOut:
"""单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。
防重前两层在 ``_load_and_guard``(FOR UPDATE 锁行 + settlement_status
与月结锁定校验),第三层是落台账 uk(receipt_id, calc_version)。全程
一个 audit 事务,出参在 commit 前固化。
首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时
仅登记进度(repeated=True,``_prepare_mode`` 内提前返回)。
重算模式:回款必须已核算过;作废旧版本行(is_active=0),按当前参数
落 calc_version+1 新行,calc_reason 留痕。
本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 →
计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。
"""
is_recalc = recalc_reason is not None
action = "重算" if is_recalc else "核算"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CALC,
action=action,
summary=f"{action}回款 #{receipt_id}",
request_data={
"receipt_id": receipt_id,
**({"reason": recalc_reason} if is_recalc else {}),
},
),
meta=meta,
):
receipt, business = _load_and_guard(session, receipt_id, is_recalc=is_recalc)
repeated_out = _prepare_mode(session, receipt, business, is_recalc=is_recalc)
if repeated_out is not None:
return repeated_out
shipment = _load_shipment(session, receipt)
person = _load_person(session, business)
snapshot = resolve_snapshot(session, receipt.receipt_date)
items = _load_items(session, business)
allocations = _load_allocations(session, business, person.id)
conversion = _convert_items(snapshot, person, items)
amounts = _compose_amounts(
session, snapshot, person, business, receipt, shipment, conversion
)
rows = _persist_ledger(
session,
receipt=receipt,
business=business,
person=person,
shipment=shipment,
items=items,
allocations=allocations,
conversion=conversion,
amounts=amounts,
recalc_reason=recalc_reason,
is_recalc=is_recalc,
)
session.flush()
out = CalcReceiptOut(ledger=_to_out(rows.owner_row), repeated=False)
session.commit()
logger.info(
"核算完成 | receipt=%s | ledger=%s | payable=%s | 分摊行=%s",
receipt_id,
rows.owner_row.id,
amounts.payable,
len(rows.share_rows),
)
return out
def _load_and_guard(
session: Session, receipt_id: int, *, is_recalc: bool
) -> tuple[Receipt, Business]:
"""锁行 + 模式校验 + 月结锁定拦截 + 业务归属校验(核算/重算共用)。"""
receipt = session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None:
raise BadRequestError("回款不存在")
if is_recalc:
if receipt.settlement_status == SETTLEMENT_PENDING:
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
elif receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
# 月结锁定拦截:已审定月份的回款不得再核算/重算——金额已固化,
# 变动唯一通道是调整单(否则重算会绕开审批改写已锁定月份的应付)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
business = session.get(Business, receipt.business_id, with_for_update=True)
if business is None:
raise BadRequestError("回款关联的业务不存在")
if business.owner_personnel_id is None:
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
return receipt, business
def _prepare_mode(
session: Session, receipt: Receipt, business: Business, *, is_recalc: bool
) -> CalcReceiptOut | None:
"""模式预处理:重算作废旧版本;首算判业务级一次性计提。
返回非 None = 本笔仅登记进度(repeated=True 的出参,已 commit),
调用方直接返回。
"""
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算。
# 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
# 两行生效行导致双重计提 → 一律 400
if is_recalc:
# 分摊拆行后旧版本可能多行(负责人+参与人)——全部作废再落新版本
olds = performance_ledger_repository.list_active_by_receipt(session, receipt.id)
if not olds:
raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
for old in olds:
performance_ledger_repository.mark_inactive(session, old)
return None
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
if not performance_ledger_repository.has_active_for_business(
session, business.id, calc_type=CALC_TYPE_RECEIPT
):
return None
_mark_settled(session, receipt)
row = performance_ledger_repository.get_active_by_receipt_of_business(session, business.id)
session.flush()
out = CalcReceiptOut(ledger=_to_out(row), repeated=True)
session.commit()
logger.info("回款登记进度(业务已核算)| receipt=%s | business=%s", receipt.id, business.id)
return out
def _load_shipment(session: Session, receipt: Receipt) -> Shipment:
"""核销基准:发货批次存在且带发货日(回款天数公式的输入)。"""
if receipt.shipment_id is None:
raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)")
shipment = session.get(Shipment, receipt.shipment_id)
if shipment is None or shipment.ship_date is None:
raise BadRequestError("发货批次缺少发货日期,无法核算")
return shipment
def _load_person(session: Session, business: Business):
"""核算归属:在职 + 绑定岗位角色(计提公式锚点)。"""
person = personnel_service.ensure_active(session, business.owner_personnel_id, label="核算归属")
if person.business_role_id is None:
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
return person
def _load_items(session: Session, business: Business) -> list[BusinessItem]:
"""业务产品明细行(逐行折算的输入;空明细无法核算)。"""
items = business_repository.list_items(session, business.id)
if not items:
raise BadRequestError("业务无产品明细行,无法核算")
return items
def _load_allocations(session: Session, business: Business, owner_id: int) -> list:
"""业务生效的二次分配方案(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
校验双保险之一(保存侧在 business_service):比例合计 >100% 会把负责人
份额算成负数(多算参与人绩效);合同负责人不可出现在参与人列表(自分
份额会双重计入)。无方案返回空列表 = 全额归负责人。
"""
allocations = business_repository.list_allocations(session, business.id)
if not allocations:
return []
total = sum((a.ratio for a in allocations), Decimal("0"))
if total > Decimal("100"):
raise BadRequestError(
f"二次分配比例合计 {total.quantize(Decimal('0.01'))}% 超过 100%,"
"无法核算(先调整方案比例)"
)
if any(a.personnel_id == owner_id for a in allocations):
raise BadRequestError("二次分配参与人含合同负责营销人员,无法核算(先调整方案)")
return list(allocations)
@dataclass(frozen=True, slots=True)
class _Conversion:
"""逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。"""
total_revenue: Decimal
total_profit: Decimal
converted_total: Decimal
missing_cost: bool
line_details: list[dict[str, Any]]
def _convert_items(snapshot, person, items: list[BusinessItem]) -> _Conversion:
"""逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)。"""
total_revenue = Decimal("0")
total_profit = Decimal("0")
converted_total = Decimal("0")
missing_cost = False
line_details: list[dict[str, Any]] = []
for item in items:
row_revenue = item.sale_amount
if item.cost_amount is None:
missing_cost = True
row_profit = Decimal("0")
else:
row_profit = _row_profit(row_revenue, item.cost_amount)
total_revenue += row_revenue
total_profit += row_profit
threshold = parse_fraction(
snapshot.get(
"product.threshold",
role_code=role_code_of(person),
product_category=item.product_category,
)
)
row_converted = converted_profit(
row_revenue, profit_rate(row_profit, row_revenue), threshold
)
converted_total += row_converted
line_details.append(
{
"item_id": item.id,
"product_category": item.product_category,
"revenue": str(row_revenue),
"profit": str(row_profit),
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
# 快照/台账口径需与列一致)
"converted": str(_q2(row_converted)),
}
)
return _Conversion(total_revenue, total_profit, converted_total, missing_cost, line_details)
@dataclass(frozen=True, slots=True)
class _Amounts:
"""计提组成结果(比例分派 → 绩效 → 抵顶 → 支付权重 → 应付)。"""
days: int
decision: _RateDecision
converted: Decimal
performance: Decimal
barter_applied: bool
weight_r: int
weight: Fraction
payable: Decimal
start_ok: bool
def _compose_amounts(
session: Session,
snapshot,
person,
business: Business,
receipt: Receipt,
shipment: Shipment,
conversion: _Conversion,
) -> _Amounts:
"""按比例分派与支付权重组成应付(与 param_service.preview 同公式路径)。"""
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
days = payment_days(shipment.ship_date, receipt.receipt_date)
decision = _personal_rate(session, snapshot, person, business, days)
if decision.base_override is not None:
converted = decision.base_override # 信息员:基数=本人销售额
else:
converted = conversion.converted_total
performance = (
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
)
barter_applied = receipt.is_barter == 1
base_amount = performance
if barter_applied:
discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person)))
base_amount = apply_barter(performance, discount)
received_total = business.total_received_amount or Decimal("0")
start_ratio = parse_fraction(
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
)
start_ok = start_payment_ok(received_total, conversion.total_revenue, start_ratio)
weight_r = clamp_weight_r(received_total, conversion.total_revenue)
weight = payment_weight(weight_r)
payable = param_service.to_decimal(Fraction(base_amount) * weight)
return _Amounts(
days=days,
decision=decision,
converted=converted,
performance=performance,
barter_applied=barter_applied,
weight_r=weight_r,
weight=weight,
payable=payable,
start_ok=start_ok,
)
@dataclass(frozen=True, slots=True)
class _LedgerRows:
"""拆行落库结果:负责人行恒存在(出参与 CAS 锚点),参与人份额行可空。"""
owner_row: PerformanceLedger
share_rows: list[PerformanceLedger]
def _persist_ledger(
session: Session,
*,
receipt: Receipt,
business: Business,
person,
shipment: Shipment,
items: list[BusinessItem],
allocations: list,
conversion: _Conversion,
amounts: _Amounts,
recalc_reason: str | None,
is_recalc: bool,
) -> _LedgerRows:
"""落台账行(分摊拆行;版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。
分摊(澄清 #1 答复 2026-10-19「分配绩效」,算后分):业务级全额(含
递减比例、抵顶、支付权重全环节)算完后按方案比例拆给参与人,余额归
合同负责人。行合计与业务级全额**守恒**(负责人行取 q2 全额减参与人
q2 份额的差额,防分漂);每行快照带完整方案快照,行级可独立回溯。
参与人离职照分(历史方案事实,方案调整是业务侧责任——入职校验只在
保存侧,核算侧只要求人员存在,FK RESTRICT 已保证)。
"""
receipt_date = receipt.receipt_date
base_snapshot: dict[str, Any] = {
"params_as_of": str(receipt_date),
"line_details": conversion.line_details,
"start_payment_ok": amounts.start_ok,
"rate_note": amounts.decision.rate_note,
"revenue_total": str(conversion.total_revenue),
"profit_total": str(conversion.total_profit),
}
if amounts.decision.manager_pending:
base_snapshot["strategy_note"] = _NOTE_MANAGER_PENDING
if allocations:
base_snapshot["alloc_plan"] = [
{
"personnel_id": a.personnel_id,
"personnel_name": a.personnel_name,
"ratio": str(a.ratio),
}
for a in allocations
]
version = performance_ledger_repository.next_version(session, receipt.id)
customer_type = _customer_type(session, business)
category = _category_label(items)
# 份额清单:(person, ratio, tag)——ratio 为 None 时走余额公式(q2 全额
# 减已分累计,守恒);负责人余额的百分数(100−∑参与人)仅作快照展示,
# 不参与计算——按它乘全额会丢 q2 尾差(39.1111×30% ≠ 39.11−27.37)
shares: list[tuple[Any, Decimal | None, str]] = []
owner_ratio_text: str | None = None
if not allocations:
shares.append((person, None, "owner"))
owner_ratio_text = "100"
else:
total_ratio = Decimal("0")
for a in allocations:
shares.append((session.get(Personnel, a.personnel_id), a.ratio, "participant"))
total_ratio += a.ratio
owner_ratio_text = str((Decimal("100") - total_ratio).quantize(Decimal("0.01")))
shares.append((person, None, "owner"))
def _share(full: Decimal, ratio: Decimal | None, *, added: Decimal = Decimal("0")) -> Decimal:
"""单份额金额:参与人 = 全额×比例(q2);负责人 = q2 全额 − 已分累计。"""
if ratio is None:
return _q2(full - added) or Decimal("0")
# Fraction 乘积先 to_decimal 再 q2(Fraction 无 quantize,直接喂 _q2 会炸)
return _q2(param_service.to_decimal(Fraction(full) * Fraction(ratio) / 100)) or Decimal("0")
added_perf = Decimal("0")
added_conv = Decimal("0")
added_pay = Decimal("0")
owner_row: PerformanceLedger | None = None
share_rows: list[PerformanceLedger] = []
for target, ratio, tag in shares:
perf = _share(amounts.performance, ratio, added=added_perf)
conv = _share(amounts.converted, ratio, added=added_conv)
pay = _share(amounts.payable, ratio, added=added_pay)
added_perf += perf
added_conv += conv
added_pay += pay
row = performance_ledger_repository.add(
session,
PerformanceLedger(
receipt_id=receipt.id,
calc_version=version,
calc_type=CALC_TYPE_RECEIPT,
business_id=business.id,
business_no=business.business_no,
personnel_id=target.id,
personnel_name=target.personnel_name,
role_code=role_code_of(target),
customer_type=customer_type,
product_category=category,
ship_date=shipment.ship_date,
receipt_date=receipt_date,
payment_days=amounts.days,
rate_text=str(amounts.decision.rate),
converted_profit=conv,
performance_amount=perf,
barter_applied=1 if amounts.barter_applied else 0,
weight_r=amounts.weight_r,
payment_weight=param_service.to_decimal(amounts.weight),
payable_amount=pay,
settlement_month=receipt_date.strftime("%Y-%m"),
is_probation=1 if target.is_probation == 1 else 0,
missing_cost=1 if conversion.missing_cost else 0,
snapshot_json={
**base_snapshot,
"alloc_role": tag,
"alloc_ratio": owner_ratio_text if tag == "owner" else str(ratio),
},
calc_reason=recalc_reason,
),
)
if tag == "owner":
owner_row = row
else:
share_rows.append(row)
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
# 会 rowcount=0 误报并发撞车)
if not is_recalc:
_mark_settled(session, receipt)
return _LedgerRows(owner_row=owner_row, share_rows=share_rows)
def _q2(value: Decimal | None) -> Decimal | None:
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。
显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家舍入),
x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。
"""
if value is None:
return None
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _personal_rate(
session: Session,
snapshot,
person,
business: Business,
days: int,
) -> _RateDecision:
"""按岗位模板分派单笔计提比例(与 param_service._preview_rate 同构)。
#12 策略点:经理个人户(owner 岗位绑 TPL_MANAGER_SHARE)单笔不计提
—— 12.5%+5% 是月度团队聚合口径,单笔落 rate=0/绩效 0 的台账行保事实。
"""
role = _role_of(session, person)
customer_type = _customer_type(session, business)
if customer_type == "enterprise":
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
return _RateDecision(rate, "企业户固定计提比例", None, False)
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
if template == "TPL_MANAGER_SHARE":
# #12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户
# 单按业务员递减公式单笔计提(个人户单本质是正常业务,有发货/回款/
# 毛利;聚合基数开关在 settlement_service 同参数消费)——0 时单笔落 0
if snapshot.get("manager.personal.included") != "1":
return _RateDecision(
Decimal("0"),
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
None,
True,
)
start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天"
return _RateDecision(rate, note, None, False)
if template == "TPL_INFO_SALES_FIXED":
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
rate = parse_fraction(snapshot.get("info.rate")) / 1000
return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False)
if template == "TPL_SALESPERSON_DECAY":
start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
# int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500;
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
return _RateDecision(rate, note, None, False)
raise BadRequestError(f"公式模板 {template} 暂不支持核算")
def _role_of(session: Session, person):
from app.repositories import business_role_repository
if person.business_role_id is None:
return None
return business_role_repository.get_by_id(session, person.business_role_id)
def role_code_of(person) -> str:
"""人员岗位编码(台账冗余固化用;无岗位为空串——前置校验已拦)。"""
from app.repositories import business_role_repository
if person.business_role_id is None:
return ""
session = Session.object_session(person)
role = business_role_repository.get_by_id(session, person.business_role_id)
return role.role_code if role else ""
def _customer_type(session: Session, business: Business) -> str:
customer = session.get(Customer, business.customer_id)
return customer.customer_type if customer else "personal"
def _category_label(items: list[BusinessItem]) -> str:
"""单类别直出;混合业务记 mixed(行级明细在 snapshot_json)。"""
categories = {item.product_category for item in items}
return categories.pop() if len(categories) == 1 else "mixed"
def _mark_settled(session: Session, receipt: Receipt) -> None:
"""CAS 置已核算:并发下另一事务先行置 1 时 rowcount=0 → 400。"""
result = session.execute(
update(Receipt)
.where(Receipt.id == receipt.id, Receipt.settlement_status == SETTLEMENT_PENDING)
.values(settlement_status=SETTLEMENT_DONE)
)
if result.rowcount == 0:
raise BadRequestError("该回款已被并发核算,请刷新后查看台账")
def _row_profit(revenue: Decimal, cost: Decimal) -> Decimal:
"""行毛利 = 行销售额 − 行成本。
#20 策略点(待业务答复,2026-10-16 暂定口径):金额暂按**不含税**原样
相减;答复若为含税口径,只改本函数(含税换算/取不含税金额列)+ 按回款
批量重算回填。
"""
return revenue - cost
def recalc_receipt(
session: Session,
receipt_id: int,
reason: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CalcReceiptOut:
"""重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。
薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定
的拦截在 calc_receipt 内(核算与重算同口径)。
"""
if not reason or not reason.strip():
raise BadRequestError("重算必须填写原因")
return calc_receipt(
session, receipt_id, operator=operator, meta=meta, recalc_reason=reason.strip()
)
def batch_calc(
session: Session,
receipt_ids: list[int],
*,
operator: User,
meta: RequestMeta | None = None,
) -> list[dict]:
"""批量核算:逐笔独立事务(各笔自带 audit),返回逐笔成败清单。
**不开外层 audit**——外层包 audit 会违反嵌套禁令,且单笔失败不应
回滚其它笔的核算结果。
"""
results: list[dict] = []
for rid in receipt_ids:
try:
out = calc_receipt(session, rid, operator=operator, meta=meta)
results.append(
{
"receipt_id": rid,
"ok": True,
"repeated": out.repeated,
"ledger_id": out.ledger.id,
}
)
except Exception as exc:
results.append(
{"receipt_id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
)
ok_count = sum(1 for r in results if r["ok"])
logger.info("批量核算 | 总数=%s 成功=%s", len(results), ok_count)
return results
def list_ledger_page(session: Session, query):
"""分页查询台账(api 层转发)。"""
rows = performance_ledger_repository.list_ledger(session, query)
total = performance_ledger_repository.count_ledger(session, query)
from app.core.response import PageResult
from app.schemas.calc import LedgerOut
return PageResult[LedgerOut](
records=[LedgerOut.model_validate(r) for r in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_ledger(session: Session, ledger_id: int):
"""台账详情(api 层转发)。"""
row = performance_ledger_repository.get_by_id(session, ledger_id)
if row is None:
from app.core.exceptions import NotFoundError
raise NotFoundError("台账记录不存在")
from app.schemas.calc import LedgerOut
return LedgerOut.model_validate(row)
def _to_out(row: PerformanceLedger):
from app.schemas.calc import LedgerOut
return LedgerOut.model_validate(row)

View File

@ -31,7 +31,7 @@ from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult
from app.models import Customer, OwnershipChangeLog, Personnel, User
from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import customer_repository, personnel_repository
from app.repositories import customer_rating_repository, customer_repository, personnel_repository
from app.schemas.customer import (
CustomerCreateIn,
CustomerOut,
@ -46,8 +46,17 @@ from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
def _to_out(
customer: Customer,
owner_name: str | None,
rating_total: object | None = None,
rating_grade: str | None = None,
) -> CustomerOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。
rating_total 由 LEFT JOIN 带出(Decimal 或 None),按分值红线转字符串
出参;新建客户/未评级客户不传,保持 null。
"""
return CustomerOut(
id=customer.id,
company_name=customer.company_name,
@ -60,6 +69,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
owner_personnel_id=customer.owner_personnel_id,
owner_personnel_name=owner_name,
remark=customer.remark,
rating_total=str(rating_total) if rating_total is not None else None,
rating_grade_name=rating_grade,
)
@ -106,7 +117,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
"""分页查询客户(附归属人员姓名)。"""
"""分页查询客户(附归属人员姓名与评级快照;支持评级列排序)。"""
rows = customer_repository.list_customers(
session,
offset=query.offset,
@ -114,6 +125,8 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome
keyword=query.keyword,
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
order_field=query.order_field,
order_type=query.order_type,
)
total = customer_repository.count_customers(
session,
@ -121,8 +134,12 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
)
records = [
_to_out(customer, owner_name, rating_total, rating_grade)
for customer, owner_name, rating_total, rating_grade in rows
]
return PageResult[CustomerOut](
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
records=records,
total=total,
page_num=query.page_num,
page_size=query.page_size,
@ -137,8 +154,8 @@ def get_customer(session: Session, customer_id: int) -> CustomerOut:
row = customer_repository.get_with_owner_name(session, customer_id)
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name = row
return _to_out(customer, owner_name)
customer, owner_name, rating_total, rating_grade = row
return _to_out(customer, owner_name, rating_total, rating_grade)
def create_customer(
@ -241,7 +258,7 @@ def update_customer(
_ensure_customer_type(payload.customer_type)
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name = row
customer, owner_name, rating_total, rating_grade = row
duplicate = customer_repository.get_by_company_owner(
session, payload.company_name, payload.owner_name
@ -260,7 +277,7 @@ def update_customer(
try:
# 响应在 commit 之前构造(同 user_service 顶部说明)
out = _to_out(customer, owner_name)
out = _to_out(customer, owner_name, rating_total, rating_grade)
session.commit()
except IntegrityError as exc:
# 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写
@ -308,6 +325,11 @@ def delete_customer(
company_name = customer.company_name # 固化:对象随删除失效
try:
# 评级行 FK RESTRICT:先删评级行再删客户主行(同事务一次 commit;
# 评级是附属快照,不构成「有关联业务」的删除阻碍)
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
if rating is not None:
customer_rating_repository.delete(session, rating)
customer_repository.delete(session, customer)
session.commit()
except IntegrityError as exc:
@ -362,7 +384,7 @@ def change_ownership(
):
if row is None:
raise NotFoundError("客户不存在")
customer, _ = row
customer, _, rating_total, rating_grade = row
if payload.new_owner_personnel_id is not None:
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
@ -385,7 +407,12 @@ def change_ownership(
),
)
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
out = _to_out(customer, new_person.personnel_name if new_person else None)
out = _to_out(
customer,
new_person.personnel_name if new_person else None,
rating_total,
rating_grade,
)
session.commit()
logger.info(
"调整客户归属 | customer_id=%s | old=%s | new=%s",

View File

@ -0,0 +1,278 @@
"""工作台服务(M4 ⑧):四区块实时聚合编排。
设计决策(2026-10-19):
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
不复用报表的部门汇总(后者会加载整月台账,过重)
- **待办与预警实时派生**:不建待办/预警表、不引调度器;处置动作本身
已有操作日志可追溯。与 FR-035「预警产生/消除留痕」的差距记录在
计划开放项,待业务确认是否物化
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
``sync_available=false`` 显式降级,不返回伪造的 0 条
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
"""
from datetime import date, datetime, timedelta
from decimal import Decimal
from sqlalchemy.orm import Session
from app.core.exceptions import BadRequestError
from app.core.month import validate_month
from app.repositories import dashboard_repository, operation_log_repository, user_repository
from app.repositories.query_utils import LogFilters
from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5
_WARNING_LIMIT = 20
_ACTIVITY_LIMIT = 20
_TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。
"""
resolved = _normalize_month(month)
warning_params = _warning_params(session)
pending_statuses = approval_service.pending_statuses_for_roles(
user_repository.get_role_codes(current_user)
)
return {
"month": resolved,
"overview": _overview(session, resolved),
"todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params),
"activities": _activities(session),
}
def _normalize_month(month: str | None) -> str:
"""未传月份按当月;非法月份在入口拦成 400(避免 500)。"""
if not month:
today = date.today()
return f"{today.year:04d}-{today.month:02d}"
try:
return validate_month(month)
except ValueError as exc:
raise BadRequestError(str(exc)) from exc
def _shift_month(month: str, delta: int) -> str:
"""YYYY-MM 平移 delta 个月(用于近 N 个月窗口)。"""
year, mon = int(month[:4]), int(month[5:7])
index = year * 12 + (mon - 1) + delta
return f"{index // 12:04d}-{index % 12 + 1:02d}"
def _month_start(month: str) -> date:
"""YYYY-MM → 该月首日(范围查询边界,避免函数列过滤)。"""
return date(int(month[:4]), int(month[5:7]), 1)
def _trend_months(month: str) -> list[str]:
"""窗口为「当月及之前共 6 个月」,升序返回便于前端画图。"""
return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)]
def _overview(session: Session, month: str) -> dict:
"""经营绩效总览(FR-033):口径与台账/月结一致。"""
sums = dashboard_repository.sum_month_performance(session, month)
settlement = dashboard_repository.get_month_settlement(session, month)
months = _trend_months(month)
performance = dashboard_repository.performance_trend(session, months)
receipts = dashboard_repository.receipt_trend(
session,
start_date=_month_start(months[0]),
end_date=_month_start(_shift_month(months[-1], 1)),
)
return {
"month": month,
"performance_total": _dec(sums["performance_total"]),
"payable_total": _dec(sums["payable_total"]),
"person_count": int(sums["person_count"]),
"team_count": dashboard_repository.count_active_managers(session),
"settlement": {
"exists": settlement is not None,
"status": settlement.status if settlement else None,
"total_performance": settlement.total_performance if settlement else None,
"total_payable": settlement.total_payable if settlement else None,
"locked_at": settlement.locked_at if settlement else None,
},
"trend": [
{
"month": item,
"performance_total": _dec(performance.get(item)),
"receipt_total": _dec(receipts.get(item)),
}
for item in months
],
"note": (
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
),
}
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
领导只看待领导审核;admin 查看全部在途单。
"""
orders = dashboard_repository.list_pending_approvals(
session, statuses=pending_statuses, limit=_TODO_LIMIT
)
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
return {
"pending_approval_total": dashboard_repository.count_pending_approvals(
session, statuses=pending_statuses
),
"pending_approvals": [
{
"order_id": order.id,
"order_no": order.order_no,
"order_type": order.order_type,
"submitter_name": order.submitter_name,
"create_time": order.create_time,
}
for order in orders
],
"unassigned_receipt_total": dashboard_repository.count_unassigned_receipts(session),
"unassigned_receipts": [
{
"receipt_id": row[0].id,
"receipt_batch_no": row[0].receipt_batch_no,
"business_id": row[0].business_id,
"business_no": row.business_no,
"amount": row[0].amount,
"receipt_date": row[0].receipt_date,
}
for row in receipts
],
# ⑥ T+ 同步未建:显式降级,不伪造「无失败」
"sync_available": False,
"sync_failure_total": 0,
}
def _warning_params(session: Session) -> dict:
"""预警参数一次解析(阈值参数化,澄清 #10)。"""
from app.services.param_service import parse_fraction, resolve_snapshot
snapshot = resolve_snapshot(session, date.today())
return {
"enabled": snapshot.get("warning.enabled") == "1",
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
"approval_timeout_days": int(
parse_fraction(snapshot.get("warning.approval.timeout.days"))
),
}
def _warnings(session: Session, params: dict) -> dict:
"""风险预警(FR-035):首批两条可算规则;同步规则待 ⑥。"""
if not params["enabled"]:
return {
"enabled": False,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": [],
"total": 0,
"receipt_overdue_total": 0,
"approval_timeout_total": 0,
"sync_available": False,
}
receipt_cutoff = date.today() - timedelta(days=params["receipt_overdue_days"])
approval_cutoff = datetime.now() - timedelta(days=params["approval_timeout_days"])
shipments = dashboard_repository.list_overdue_shipments(
session, cutoff=receipt_cutoff, limit=_WARNING_LIMIT
)
orders = dashboard_repository.list_overdue_approvals(
session, cutoff=approval_cutoff, limit=_WARNING_LIMIT
)
items = [
{
"code": "receipt_overdue",
"level": "danger",
"object_type": "shipment",
"object_id": row[0].id,
"title": f"发货批次 {row[0].shipment_no} 逾期未回款",
"detail": (
f"业务 {row.business_no or row[0].business_id} 发货日 {row[0].ship_date},"
f"超过 {params['receipt_overdue_days']} 天无回款记录"
),
"suggest": "联系客户催收,或核对回款批次指定关系",
"link": "/business/shipments",
}
for row in shipments
]
items += [
{
"code": "approval_timeout",
"level": "warning",
"object_type": "approval_order",
"object_id": order.id,
"title": f"审批单 {order.order_no} 超时未处理",
"detail": (
f"{_ORDER_TYPE_LABELS.get(order.order_type, '审批')}由 "
f"{order.submitter_name or '—'} 提交于 {order.create_time:%Y-%m-%d %H:%M},"
f"已超过 {params['approval_timeout_days']} 天"
),
"suggest": "在审批管理中复核或退回",
"link": "/system/approvals",
}
for order in orders
]
receipt_overdue_total = dashboard_repository.count_overdue_shipments(
session, cutoff=receipt_cutoff
)
approval_timeout_total = dashboard_repository.count_overdue_approvals(
session, cutoff=approval_cutoff
)
return {
"enabled": True,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": items,
# items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数
"total": receipt_overdue_total + approval_timeout_total,
"receipt_overdue_total": receipt_overdue_total,
"approval_timeout_total": approval_timeout_total,
"sync_available": False,
}
def _activities(session: Session) -> list[dict]:
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
logs = operation_log_repository.list_logs(
session,
offset=0,
limit=_ACTIVITY_LIMIT,
filters=LogFilters(),
)
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
]
def _dec(value) -> Decimal | None:
"""SQL 聚合结果转 Decimal(SUM 可能返回 int,None 原样返回)。"""
if value is None:
return None
return value if isinstance(value, Decimal) else Decimal(str(value))

View File

@ -41,6 +41,10 @@ def salesperson_rate(
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
首日取整(澄清 #14 业务答复 2026-10-19):第 1 天**不递减**、直接取
起始值(需规图示「第 1 天 10‰」覆盖公式的 9.9889‰)——第 2 天起按公式
递减,360 天=6‰ 锚点与 361 天=4‰ 分界均不受影响。
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
@ -48,6 +52,8 @@ def salesperson_rate(
days = max(days, 0)
if days > boundary_days:
return floor / 1000
if days <= 1:
return start / 1000
return (start - days * slope) / 1000
@ -95,3 +101,63 @@ def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bo
if revenue == 0:
return False
return Fraction(received) / Fraction(revenue) >= ratio / 100
def clamp_weight_r(received: Decimal, revenue: Decimal) -> int:
"""支付权重 R:回款占销售额百分比,封顶封底 [0, 100](对照表 #22)。
received 非负时 int() 即向下取整;min/max 同时是红字冲销(负数回款)
接入时的方向防线。从计算预览的内联实现提取 —— ④ 核算与预览共用。
"""
if revenue <= 0:
return 0
return max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100)))
def monthly_salary(performance: Decimal, basic: Decimal | None, mode: str) -> Decimal | None:
"""月工资分岗口径(对照表 #8,salary.basic.mode 按角色维度取值)。
- INCLUDE:绩效 + 基本工资(业务员默认——绩效之外照发基本工资)
- DEDUCT:绩效 − 基本工资(经理默认——基本工资从绩效分成中扣除)
- EXCLUDE:仅绩效,基本工资不参与
basic 缺失时按 0 参与(INCLUDE/EXCLUDE 等价于纯绩效)。
"""
basic_value = basic if basic is not None else Decimal("0")
if mode == "INCLUDE":
return performance + basic_value
if mode == "DEDUCT":
return performance - basic_value
if mode == "EXCLUDE":
return performance
raise ValueError(f"未知的月工资口径:{mode}")
def probation_annual_note(year_total: Decimal, threshold: Decimal) -> str | None:
"""试用期年累计说明:超阈值部分年底一次性结算(对照表 #17)。
未超阈值返回 None(无需说明);阈值按元展示(Decimal 直出)。
"""
if year_total <= threshold:
return None
excess = (year_total - threshold).quantize(Decimal("0.01"), rounding="ROUND_HALF_UP")
return f"试用期绩效照算不发放;年累计超 {threshold} 元部分年底一次性结算(当前超 {excess} 元)"
def manager_team_share(
team_performance: Decimal,
company_performance: Decimal,
*,
team_pct: Fraction,
company_pct: Fraction,
) -> Decimal:
"""经理绩效 = 本团队业务员绩效 × 12.5% + 全公司业务员绩效 × 5%(裁决 #6/#15/#16)。
**#12 策略点(待业务答复,2026-10-16 暂定口径)**:基数**不含**经理本人
名下经理个人户业务的业绩——该部分按经理个人户规则另行处理(单笔落台账
rate=0/绩效 0,待答复后若需并入,只改调用方传入的基数集合)。
团队口径:manager_id=该经理且岗位非「营销经理」的业务员绩效行之和;
全公司口径:全部业务员(非经理岗)绩效行之和。
"""
return _to_decimal(Fraction(team_performance) * team_pct / 100) + _to_decimal(
Fraction(company_performance) * company_pct / 100
)

View File

@ -0,0 +1,124 @@
"""经理分成共享口径(月结聚合与报表部门汇总的唯一实现)。
从 settlement_service 迁出(行为零变化):经理 12.5%/5% 的取参、团队
分桶、公司基数计算,月结生成与部门汇总报表都要用——抽为单一实现后
「报表汇总 == 月结聚合行」不靠两处代码各自对齐,而是同源函数的直接
结果。分成金额仍走 formula.manager_team_share 纯函数。
"""
from calendar import monthrange
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from fractions import Fraction
from sqlalchemy.orm import Session
from app.core.exceptions import BadRequestError
from app.core.month import parse_month
from app.models import Personnel
@dataclass(frozen=True, slots=True)
class ManagerShareParams:
"""经理分成取参与参数原文(同一次计算共用一份,随月月末时点化)。"""
month: str
param_date: date
team_text: str
company_text: str
team_pct: Fraction
company_pct: Fraction
include_manager_personal: bool
def month_end(month: str) -> date:
"""YYYY-MM → 所属月月末(月结与报表取参锚点)。
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
更细的业务日期,取该月最后可确定的生效时点。
"""
try:
year, mon = parse_month(month)
except ValueError as exc:
# schema 已拦住非法月份;内部调用绕过 schema 时也不能变成 500
raise BadRequestError(str(exc)) from exc
return date(year, mon, monthrange(year, mon)[1])
def manager_share_params(session: Session, month: str) -> ManagerShareParams:
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
"""
from app.services.param_service import parse_fraction, resolve_snapshot
param_date = month_end(month)
snapshot = resolve_snapshot(session, param_date)
team_text = snapshot.get("manager.share.team")
company_text = snapshot.get("manager.share.company")
return ManagerShareParams(
month=month,
param_date=param_date,
team_text=team_text,
company_text=company_text,
team_pct=parse_fraction(team_text),
company_pct=parse_fraction(company_text),
include_manager_personal=snapshot.get("manager.personal.included") == "1",
)
def add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
bucket = managers.setdefault(
manager_id,
{
"performance": Decimal("0"),
"business_id": row.business_id,
"business_no": row.business_no,
"names": set(),
},
)
bucket["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name)
def collect_team_buckets(
session: Session, ledger_rows: list, include_manager_personal: bool
) -> dict[int, dict]:
"""按经理采集团队业绩。
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
按上溯会被跳过漏计)。
"""
managers: dict[int, dict] = {}
for row in ledger_rows:
if row.customer_type == "manager_personal":
if include_manager_personal:
add_team_performance(managers, row.personnel_id, row)
continue # 默认不计入聚合基数(#12 参数化)
person = session.get(Personnel, row.personnel_id)
manager_id = person.manager_id if person else None
if manager_id is not None:
add_team_performance(managers, manager_id, row)
return managers
def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decimal:
"""全公司基数(5% 的底):默认排除经理个人户行(#12 开关计入时不过滤)。
口径逐行固定,调用方在循环外算一次。
"""
return sum(
(
r.performance_amount or Decimal("0")
for r in ledger_rows
if include_manager_personal or r.customer_type != "manager_personal"
),
Decimal("0"),
)

View File

@ -242,20 +242,6 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
description="试用期年累计绩效超过此值部分年底一次性结算",
sort_order=15,
),
ParamSpec(
code="probation.salary.basic",
name="试用期基本工资标准",
group="月工资口径",
value_type="DECIMAL",
unit="元",
dimension_type="NONE",
options=None,
default_value="3000",
value_min="0",
value_max=None,
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
sort_order=16,
),
ParamSpec(
code="approval.secondary_allocation.enabled",
name="二次分配审批开关",
@ -264,12 +250,13 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
unit=None,
dimension_type="NONE",
options=None,
default_value="0",
default_value="1",
value_min=None,
value_max=None,
description=(
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
"1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)"
"1=提交后进入财务复核→领导审核审批流。"
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
),
sort_order=17,
),
@ -281,14 +268,99 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
unit=None,
dimension_type="NONE",
options=None,
default_value="1",
value_min=None,
value_max=None,
description=(
"月度结算审批链开关(④ 月结提交时消费):"
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
),
sort_order=18,
),
ParamSpec(
code="approval.adjustment.enabled",
name="调整单审批开关",
group="审批开关",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="0",
value_min=None,
value_max=None,
description=(
"预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);"
"0=结算生成即生效,1=走财务复核→领导审核审批流"
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
"默认值业务未明确,暂按直接生效,业务可开启)"
),
sort_order=18,
sort_order=19,
),
ParamSpec(
code="manager.personal.included",
name="经理个人户计入聚合基数",
group="经理分成",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="0",
value_min=None,
value_max=None,
description=(
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
"0=不计入(默认,暂口径) 1=计入"
),
sort_order=20,
),
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
# 首批三条中「同步连续失败」依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目
ParamSpec(
code="warning.enabled",
name="风险预警总开关",
group="预警规则",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="1",
value_min=None,
value_max=None,
description="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
sort_order=21,
),
ParamSpec(
code="warning.receipt.overdue.days",
name="回款逾期天数阈值",
group="预警规则",
value_type="INT",
unit="天",
dimension_type="NONE",
options=None,
default_value="90",
value_min="1",
value_max=None,
description=(
"发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"
),
sort_order=22,
),
ParamSpec(
code="warning.approval.timeout.days",
name="审批超时天数阈值",
group="预警规则",
value_type="INT",
unit="天",
dimension_type="NONE",
options=None,
default_value="3",
value_min="1",
value_max=None,
description=(
"待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"
),
sort_order=23,
),
]
}

View File

@ -614,11 +614,8 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
start_ratio = take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
# R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int()
# 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线
weight_r = (
max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0
)
# 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)

View File

@ -1,12 +1,14 @@
"""客户评级等级配置业务编排。
"""客户评级业务编排(等级映射配置 + 每客户评级落库)。
评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库
——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个
min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级):
评级定位(对照表 #25):**不影响绩效金额**。自 2026-10-19 起评分每客户
落库(customer_ratings,一行一客户快照),等级映射仍是本服务承载的
**判定表**,判定规则为按 sort_order 升序取首个 min_score ≤ 总分的等级,
max_score 仅展示。两条硬约束(违反会算错等级):
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内**
完成,失败的 400 同样留痕(2026-10-13 定稿口径)
到 Decimal 的转换与落库;值域校验(等级区间 0≤min≤max≤100、单项评分
0≤分≤20)在**审计上下文内**完成,失败的 400 同样留痕(2026-10-13 定稿
口径)
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
失效。判定为**闭区间两两相交**:任一其他等级满足
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
@ -14,9 +16,9 @@ min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
靠前的等级,相邻无歧义
删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不
回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出
等级(前端已对应提示),故不强制保留任何一条。
删除允许清空:等级表对客户评级**无外键引用**(评级快照固化等级名、不
回指本表),物理删除无悬空风险,也不伤及已评级客户的历史结论;全部删空
后新评级只记总分、等级为 NULL(前端已对应提示),故不强制保留任何一条。
"""
import logging
@ -33,9 +35,15 @@ from app.core.constants import (
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import CustomerRatingGrade, User
from app.repositories import rating_repository
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
from app.models import CustomerRating, CustomerRatingGrade, User
from app.repositories import customer_rating_repository, customer_repository, rating_repository
from app.schemas.rating import (
CustomerRatingOut,
CustomerRatingSaveIn,
RatingGradeCreateIn,
RatingGradeOut,
RatingGradeUpdateIn,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
@ -44,6 +52,8 @@ logger = logging.getLogger(__name__)
# 分值域上下界(Decimal 常量,禁用 float 参与比较)
_SCORE_FLOOR = Decimal("0")
_SCORE_CEIL = Decimal("100")
# 单项评分上界(五项各 0-20,总分 0-100)
_ITEM_SCORE_CEIL = Decimal("20")
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
@ -252,10 +262,11 @@ def delete_grade(
) -> None:
"""物理删除评级等级。
等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定
只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。
等级表对客户评级**无外键引用** —— 评级行快照固化等级名(见
``models/customer_rating.py`` 的论证),判定只读本表现值,故可物理删除
且无需锁行防并发引用;删除等级不改变已评级客户的历史结论。
**允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示),
**允许清空**:全部删空后新评级只记总分、等级为 NULL(前端已对应提示),
不强制保留任何一条。
:raises NotFoundError: 评级等级不存在
@ -278,3 +289,164 @@ def delete_grade(
rating_repository.delete(session, grade)
session.commit()
logger.info("删除评级等级 | grade_name=%s", grade_name)
# ==================== 客户评级(每客户一行,落库) ====================
def resolve_grade(
grades: list[CustomerRatingGrade], total_score: Decimal
) -> CustomerRatingGrade | None:
"""等级判定纯函数:按 sort_order 升序取首个 min_score ≤ 总分的等级。
与前端打分卡的实时判定同规则同序(前端按数值比较,此处 Decimal 比较
——同一规则的两端实现,测试锁定边界行为一致)。无命中(总分低于全部
下限或等级表为空)返回 None,由调用方决定展示与落库(快照记 NULL)。
"""
# id 兜底让同序等级次序稳定(纯函数也接受未持久化对象:id 可能为 None)
for grade in sorted(grades, key=lambda g: (g.sort_order, g.id or 0)):
if grade.min_score <= total_score:
return grade
return None
def _to_rating_out(rating: CustomerRating) -> CustomerRatingOut:
"""客户评级行转响应模型。分值 Decimal → str(分值红线出参口径)。"""
return CustomerRatingOut(
customer_id=rating.customer_id,
score_ship_amount=str(rating.score_ship_amount),
score_receipt_rate=str(rating.score_receipt_rate),
score_profit_rate=str(rating.score_profit_rate),
score_contract_cooperation=str(rating.score_contract_cooperation),
score_site_construction=str(rating.score_site_construction),
total_score=str(rating.total_score),
grade_name=rating.grade_name,
remark=rating.remark,
scorer_name=rating.scorer_name,
create_time=rating.create_time,
update_time=rating.update_time,
)
def _ensure_item_scores(scores: list[Decimal]) -> None:
"""单项评分值域:0 ≤ 分 ≤ 20(总分由五项求和自然落在 0-100)。
须在审计上下文内调用,失败也要留痕(同 _ensure_score_range 口径)。
"""
for score in scores:
if not (_SCORE_FLOOR <= score <= _ITEM_SCORE_CEIL):
raise BadRequestError("单项评分须在 0~20 之间")
def get_customer_rating(session: Session, customer_id: int) -> CustomerRatingOut | None:
"""查某客户当前评级;未评级返回 None(接口信封 data 为 null)。
「未评级」是正常态而非错误,不用 404 表达;客户不存在才是 NotFound。
:raises NotFoundError: 客户不存在
"""
customer = customer_repository.get_by_id(session, customer_id)
if customer is None:
raise NotFoundError("客户不存在")
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
return _to_rating_out(rating) if rating is not None else None
def save_customer_rating(
session: Session,
customer_id: int,
payload: CustomerRatingSaveIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CustomerRatingOut:
"""保存客户评级(upsert:首次创建,再次保存整体覆盖)。
总分与等级**服务端重算**:总分按五项求和(不采信前端回传),等级按
保存时点的等级映射判定后**快照固化**进评级行 —— 等级表后续增删改
不影响该行(见 ``models/customer_rating.py``)。
:raises NotFoundError: 客户不存在
:raises BadRequestError: 单项评分须在 0~20 之间(审计上下文内校验,留痕)
"""
# 只读预取在审计上下文之前:客户不存在(NotFound 失败)也能留下可读记录,
# 摘要需要客户名
customer = customer_repository.get_by_id(session, customer_id)
existing = customer_rating_repository.get_by_customer_id(session, customer_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_RATING,
action=ACTION_CREATE if existing is None else ACTION_UPDATE,
summary=f"评定客户「{customer.company_name if customer else f'#{customer_id}'}」评级",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域校验在审计上下文内:分值非法的 400 也要留痕(同等级配置口径)
item_scores = [
_to_decimal(payload.score_ship_amount),
_to_decimal(payload.score_receipt_rate),
_to_decimal(payload.score_profit_rate),
_to_decimal(payload.score_contract_cooperation),
_to_decimal(payload.score_site_construction),
]
_ensure_item_scores(item_scores)
if customer is None:
raise NotFoundError("客户不存在")
total_score = sum(item_scores, _SCORE_FLOOR)
grade = resolve_grade(rating_repository.list_all(session), total_score)
def _apply(row: CustomerRating) -> None:
"""把五项分值/总分/等级快照写入评级行(创建与并发兜底共用)。"""
(
row.score_ship_amount,
row.score_receipt_rate,
row.score_profit_rate,
row.score_contract_cooperation,
row.score_site_construction,
) = item_scores
row.total_score = total_score
row.grade_name = grade.grade_name if grade is not None else None
row.remark = payload.remark
row.scorer_id = operator.id
row.scorer_name = operator.username
try:
if existing is None:
rating = CustomerRating(
customer_id=customer_id,
scorer_id=operator.id,
scorer_name=operator.username,
)
_apply(rating)
customer_rating_repository.create(session, rating)
else:
_apply(existing)
rating = existing
out = _to_rating_out(rating)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发为同一客户首次评级会撞
# uk_customer_ratings_customer_id,回滚后转为更新兜底
session.rollback()
if not is_duplicate_entry(exc):
raise
raced = customer_rating_repository.get_by_customer_id(session, customer_id)
if raced is None:
# 1062 但行不在:数据态异常,交全局 IntegrityError 兜底
raise
_apply(raced)
rating = raced
out = _to_rating_out(rating)
session.commit()
logger.info(
"保存客户评级 | customer_id=%s | total=%s | grade=%s",
customer_id,
out.total_score,
out.grade_name,
)
return out

View File

@ -23,6 +23,7 @@
"""
import logging
from datetime import date
from decimal import Decimal
from sqlalchemy.exc import IntegrityError
@ -209,6 +210,12 @@ def create_receipt(
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
)
@ -284,6 +291,12 @@ def update_receipt(
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止修改")
business = _ensure_business_writable(session, receipt.business_id, "修改")
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
settlement_service.ensure_month_not_locked(session, payload.receipt_date.strftime("%Y-%m"))
# 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
shipment_no, ship_date = _ensure_shipment_of_business(
@ -335,6 +348,10 @@ def delete_receipt(
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止删除")
business = _ensure_business_writable(session, receipt.business_id, "删除")
# 月结锁定拦截:已锁定月份的回款是月结快照的源数据,不得删除
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
batch_no = receipt.receipt_batch_no
receipt_repository.delete(session, receipt)

View File

@ -0,0 +1,657 @@
"""报表 Excel 模板构建(纯函数,可单测)。
版式(FR-032「导出模板围绕财务复核和领导签字设计」):
公司信息页眉 → 报表标题 → 筛选条件 → 导出时间/人/模板版本 → 表头 → 数据行
→ 合计行 → 财务复核栏 / 领导签字栏 / 日期栏。
精度红线:金额写 ``Decimal``(openpyxl 原生支持,不经 float)+
``number_format='#,##0.00'``;合计在 Python 侧求和后两位收口,**不写
Excel 公式**(避免打开时重算漂移与公式注入面)。文本列以 ``= + - @``
开头时前置单引号,防公式注入。
"""
from dataclasses import dataclass
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from app.core.report_meta import (
BUSINESS_STATUS_LABELS,
CUSTOMER_TYPE_LABELS,
PRODUCT_CATEGORY_LABELS,
)
_HEADER_FILL = PatternFill("solid", fgColor="DDE3F0")
_THIN = Side(style="thin", color="B0B7C3")
_BORDER = Border(left=_THIN, right=_THIN, top=_THIN, bottom=_THIN)
_TITLE_FONT = Font(size=14, bold=True)
_COMPANY_FONT = Font(size=16, bold=True)
_HEADER_FONT = Font(bold=True)
_AMOUNT_FORMAT = "#,##0.00"
_DATE_FORMAT = "YYYY-MM-DD"
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
@dataclass(frozen=True, slots=True)
class _Column:
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
key: str
title: str
kind: str # text / amount / int / date / customer / category / status
width: float
@dataclass(frozen=True, slots=True)
class _Block:
"""签字稿中的一个区块:独立表头、数据行与可选合计行。"""
title: str
columns: tuple[_Column, ...]
rows: list[dict]
total: dict | None = None
empty_text: str = "(无)"
_COLUMNS: dict[str, tuple[_Column, ...]] = {
"personal-detail": (
_Column("business_no", "合同编号", "text", 20),
_Column("ship_date", "发货日", "date", 12),
_Column("receipt_date", "回款日", "date", 12),
_Column("payment_days", "回款天数", "int", 10),
_Column("product_category", "产品类别", "category", 14),
_Column("customer_type", "客户类型", "customer", 14),
_Column("role_name", "岗位", "text", 12),
_Column("rate_text", "计提比例", "text", 14),
_Column("weight_r", "支付权重R", "int", 10),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
),
"department-summary": (
_Column("manager_name", "经理团队", "text", 16),
_Column("member_name", "成员", "text", 14),
_Column("role_code", "岗位编码", "text", 12),
_Column("performance_sum", "绩效金额", "amount", 14),
_Column("payable_sum", "应付绩效", "amount", 14),
_Column("adjust_net", "调整净额", "amount", 14),
_Column("net_total", "净合计", "amount", 14),
),
"business-stats": (
_Column("business_no", "业务编号", "text", 20),
_Column("business_date", "业务日期", "date", 12),
_Column("customer_name", "客户", "text", 22),
_Column("owner_personnel_name", "归属人", "text", 12),
_Column("status", "状态", "status", 10),
_Column("total_sale_amount", "销售额", "amount", 14),
_Column("total_ship_amount", "发货额", "amount", 14),
_Column("total_received_amount", "回款额", "amount", 14),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
),
"payment-stats": (
_Column("label", "回款天数区间", "text", 20),
_Column("receipt_amount", "回款金额", "amount", 16),
_Column("receipt_count", "回款笔数", "int", 10),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
_Column("ledger_count", "台账行数", "int", 10),
),
}
def build_workbook(
*,
code: str,
title: str,
filters: str,
rows: list[dict],
summary: dict | None,
buckets: dict | None,
template_version: str,
operator_name: str,
company_name: str,
department: dict | None = None,
) -> bytes:
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。
部门汇总是签字对账文件,走单 sheet 分块版式;其余报表仍是单表 +
合计行。`department` 为 `department_summary` 的完整出参。
"""
workbook = Workbook()
sheet = workbook.active
sheet.title = _sheet_name(code)
if code == "department-summary" and department is not None:
_write_department_document(
sheet,
department,
company_name=company_name,
title=title,
filters=filters,
template_version=template_version,
operator_name=operator_name,
)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
columns = _COLUMNS[code]
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
if rows:
for row in rows:
_write_row(sheet, columns, row)
else:
sheet.append(["本筛选条件无数据"])
_merge_row(sheet, len(columns))
_write_total(sheet, columns, rows)
_write_signature(sheet, len(columns))
_autofit(sheet, columns)
# 回款统计附维度对比 sheet(客户类型/产品类别),与区间表共用页眉格式
if code == "payment-stats" and buckets:
for dim_key, dim_title in (
("by_customer_type", "客户类型对比"),
("by_product_category", "产品类别对比"),
):
extra = workbook.create_sheet(dim_title)
_write_dimension_sheet(
extra,
dim_title,
company_name,
filters,
buckets.get(dim_key) or [],
template_version,
operator_name,
)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def _sheet_name(code: str) -> str:
return {
"personal-detail": "个人绩效明细",
"department-summary": "部门绩效汇总",
"business-stats": "业务统计",
"payment-stats": "回款统计",
}[code]
def _write_header(
sheet,
columns: tuple[_Column, ...],
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""页眉四行 + 表头行;返回后 sheet.max_row 即数据行起点。"""
_write_document_header(
sheet,
len(columns),
company_name,
title,
filters,
template_version,
operator_name,
)
sheet.append([column.title for column in columns])
header_row = sheet.max_row
for cell in sheet[header_row]:
cell.font = _HEADER_FONT
cell.fill = _HEADER_FILL
cell.border = _BORDER
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = sheet.cell(row=header_row + 1, column=1)
def _write_document_header(
sheet,
column_count: int,
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""公司名/标题/筛选条件/导出信息四行(分块文档与单表共用)。"""
sheet.append([company_name])
_merge_row(sheet, column_count)
sheet.cell(row=1, column=1).font = _COMPANY_FONT
sheet.cell(row=1, column=1).alignment = Alignment(horizontal="left", vertical="center")
sheet.append([title])
_merge_row(sheet, column_count)
sheet.cell(row=2, column=1).font = _TITLE_FONT
sheet.cell(row=2, column=1).alignment = Alignment(horizontal="center", vertical="center")
sheet.append([f"筛选条件:{filters}"])
_merge_row(sheet, column_count)
sheet.append(
[
f"导出时间:{datetime.now():%Y-%m-%d %H:%M:%S} | 导出人:{operator_name} | "
f"模板版本:{template_version}"
]
)
_merge_row(sheet, column_count)
sheet.append([])
def _merge_row(sheet, column_count: int) -> None:
"""合并当前行(公司名/标题/筛选行跨全表宽)。"""
row = sheet.max_row
if column_count > 1:
sheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=column_count)
def _write_row(sheet, columns: tuple[_Column, ...], row: dict) -> None:
"""数据行:按列类型写单元格(金额 Decimal、日期 date、文本防注入)。"""
values = []
for column in columns:
raw = row.get(column.key)
values.append(_cell_value(column, raw))
sheet.append(values)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
elif column.kind == "date":
cell.number_format = _DATE_FORMAT
def _cell_value(column: _Column, raw):
"""单值转换:标签列转中文,文本列防公式注入。"""
if raw is None:
return None
if column.kind == "amount":
return raw if isinstance(raw, Decimal) else Decimal(str(raw))
if column.kind == "date":
return raw
if column.kind == "int":
return int(raw)
if column.kind == "customer":
return CUSTOMER_TYPE_LABELS.get(str(raw), str(raw))
if column.kind == "category":
return PRODUCT_CATEGORY_LABELS.get(str(raw), str(raw))
if column.kind == "status":
return BUSINESS_STATUS_LABELS.get(int(raw), str(raw))
return _safe_text(raw)
def _safe_text(value) -> str:
"""文本防公式注入:以 = + - @ 或制表符开头时前置单引号。"""
text = str(value)
return "'" + text if text.startswith(_FORMULA_PREFIXES) else text
def _write_total(sheet, columns: tuple[_Column, ...], rows: list[dict]) -> None:
"""合计行:金额列按行求和后两位收口(不写 Excel 公式)。
「合计」标签只写在第一个非金额列;其余非金额列留空——否则每遇到
一个文本列都会重复写一次标签,合计行会满屏「合计」。
"""
if not rows:
return
totals = []
label_written = False
for column in columns:
if column.kind != "amount":
totals.append("合计" if not label_written else None)
label_written = True
continue
total = sum(
(
row.get(column.key)
for row in rows
if isinstance(row.get(column.key), (Decimal, int))
),
Decimal("0"),
)
totals.append(total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
sheet.append(totals)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.font = _HEADER_FONT
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
def _write_signature(sheet, column_count: int) -> None:
"""财务复核栏 / 领导签字栏 / 日期栏(FR-032 模板要素)。"""
sheet.append([])
sheet.append(["财务复核:________________", "", "日期:______年__月__日"])
sheet.append(["领导签字:________________", "", "日期:______年__月__日"])
for row_index in (sheet.max_row - 1, sheet.max_row):
sheet.cell(row=row_index, column=1).alignment = Alignment(horizontal="left")
def _autofit(sheet, columns: tuple[_Column, ...]) -> None:
"""列宽按定义表设置(CJK 按 2 字符宽,不用默认 8.43)。"""
for index, column in enumerate(columns, start=1):
sheet.column_dimensions[get_column_letter(index)].width = column.width
def _write_dimension_sheet(
sheet,
title: str,
company_name: str,
filters: str,
rows: list[dict],
template_version: str,
operator_name: str,
) -> None:
"""维度对比 sheet(客户类型/产品类别),页眉筛选条件与主表一致。"""
columns = (
_Column("label", "维度", "text", 16),
_Column("receipt_amount", "回款金额", "amount", 16),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
_Column("ledger_count", "台账行数", "int", 10),
)
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
for row in rows:
_write_row(sheet, columns, row)
_write_total(sheet, columns, rows)
_write_signature(sheet, len(columns))
_autofit(sheet, columns)
# ==== 部门汇总签字稿(FR-030 / FR-032)====
_MEMBER_KIND_LABELS = {
"regular": "普通绩效",
"manager_personal": "经理个人户",
"adjustment": "仅调整单",
}
# 各块列数不同,统一按列号取最大宽度(签字稿打印时不挤在一起)
_DEPARTMENT_WIDTHS = (30, 16, 22, 13, 13, 15, 18, 14, 14, 13)
def _write_department_document(
sheet,
data: dict,
*,
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""部门汇总签字稿:团队分成 → 全公司 5% → 对账 → 成员 → 未分配 → 签字栏。
全公司 5% 每位有月结聚合行的经理各计一次,合计在第二块展示;
团队块的调整净额仅供成员汇总核对。应发合计由后端 `reconciliation`
按聚合行口径算好(含分列舍入差),本函数只渲染。
"""
params = data.get("params") or {}
team_text = params.get("team_text", "")
company_text = params.get("company_text", "")
teams = data.get("teams") or []
unassigned = data.get("unassigned") or []
company = data.get("company") or {}
reconciliation = data.get("reconciliation") or {}
company_share_count = int(company.get("company_share_count") or 0)
_write_document_header(
sheet, len(_DEPARTMENT_WIDTHS), company_name, title, filters, template_version, operator_name
)
_write_block(
sheet,
_Block(
"一、团队分成汇总",
(
_Column("seq", "序号", "int", 6),
_Column("manager_name", "经理团队", "text", 16),
_Column("manager_status_text", "状态", "text", 8),
_Column("member_count", "团队人数", "int", 9),
_Column("team_base", "团队基数", "amount", 13),
_Column("team_share_amount", f"团队分成({team_text}%)", "amount", 15),
_Column("adjust_net", "调整净额(仅核对)", "amount", 16),
_Column("team_subtotal", "团队应发小计(含聚合分成)", "amount", 18),
_Column("frozen_amount", "月结冻结额", "amount", 14),
),
[
{
"seq": index,
"manager_name": team["manager_name"],
"manager_status_text": "在职" if team["manager_status"] == 1 else "离职",
"member_count": team["member_count"],
"team_base": team["team_base"],
"team_share_amount": team["team_share_amount"],
"adjust_net": team["adjust_net"],
"team_subtotal": team["team_subtotal"],
"frozen_amount": team["frozen_amount"],
}
for index, team in enumerate(teams, start=1)
],
{
"manager_name": "合计",
"member_count": sum(team["member_count"] for team in teams),
"team_base": _sum_amount(team["team_base"] for team in teams),
"team_share_amount": _sum_amount(
team["team_share_amount"] for team in teams
),
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
"frozen_amount": (
_sum_amount(
team["frozen_amount"]
for team in teams
if team["frozen_amount"] is not None
)
if any(team["frozen_amount"] is not None for team in teams)
else None
),
},
"(无团队)",
),
)
_write_block(
sheet,
_Block(
f"二、全公司分成(每位有聚合行的经理各计一次,共 {company_share_count} 次)",
(
_Column("company_base", "全公司基数", "amount", 16),
_Column("company_share_pct", "分成比例", "text", 12),
_Column("company_share_amount", "单经理分成", "amount", 13),
_Column("company_share_count", "计入次数", "int", 10),
_Column("company_share_total", "分成合计", "amount", 14),
),
[
{
"company_base": company.get("company_base"),
"company_share_pct": f"{company_text}%",
"company_share_amount": company.get("company_share_amount"),
"company_share_count": company_share_count,
"company_share_total": company.get("company_share_total"),
}
],
),
)
_write_block(
sheet,
_Block(
"三、对账汇总",
(
_Column("item", "项目", "text", 30),
_Column("amount", "金额", "amount", 16),
_Column("note", "说明", "text", 40),
),
[
{
"item": "团队成员应付合计",
"amount": reconciliation.get("team_member_payable_total"),
},
{
"item": "未分配人员应付合计",
"amount": reconciliation.get("unassigned_payable_total"),
},
{"item": "团队分成合计(分列)", "amount": reconciliation.get("team_share_total")},
{
"item": f"全公司分成合计({company_share_count} 次,分列)",
"amount": reconciliation.get("company_share_total"),
"note": "每位有聚合行的经理各计一次",
},
{
"item": "聚合行舍入差",
"amount": reconciliation.get("aggregate_rounding_diff"),
"note": "分列舍入与聚合行一次舍入的差",
},
{
"item": "调整净额合计(明细层)",
"amount": reconciliation.get("adjust_net_total"),
},
{
"item": "应发合计",
"amount": reconciliation.get("payable_total"),
"note": "上列各项之和",
},
{"item": "团队基数合计", "amount": reconciliation.get("team_base_total")},
{
"item": "未分配绩效合计",
"amount": reconciliation.get("unassigned_performance_total"),
},
{
"item": "其他未展示基数",
"amount": reconciliation.get("other_team_base"),
"note": "筛选时含其他团队与全公司未分配",
},
{"item": "全公司基数", "amount": reconciliation.get("company_base")},
],
),
)
member_columns = (
_Column("manager_name", "经理团队", "text", 15),
_Column("personnel_name", "成员", "text", 12),
_Column("role_code", "岗位", "text", 10),
_Column("member_kind_text", "来源", "text", 13),
_Column("performance_sum", "绩效金额", "amount", 13),
_Column("payable_sum", "应付绩效", "amount", 13),
_Column("adjust_extra", "调整补发", "amount", 12),
_Column("adjust_deduct", "调整扣减", "amount", 12),
_Column("adjust_net", "调整净额", "amount", 12),
_Column("net_total", "净合计", "amount", 13),
)
member_rows = [
{
"manager_name": team["manager_name"],
"personnel_name": member["personnel_name"],
"role_code": member["role_code"],
"member_kind_text": _MEMBER_KIND_LABELS.get(
member["member_kind"], member["member_kind"]
),
"performance_sum": member["performance_sum"],
"payable_sum": member["payable_sum"],
"adjust_extra": member["adjust_extra"],
"adjust_deduct": member["adjust_deduct"],
"adjust_net": member["adjust_net"],
"net_total": member["net_total"],
}
for team in teams
for member in team["members"]
]
_write_block(
sheet,
_Block("四、团队成员明细", member_columns, member_rows, empty_text="(无成员)"),
sum_amounts=True,
)
unassigned_columns = (
_Column("personnel_name", "人员", "text", 14),
_Column("role_code", "岗位", "text", 10),
_Column("unassigned_reason", "未纳入原因", "text", 24),
_Column("performance_sum", "绩效金额", "amount", 13),
_Column("payable_sum", "应付绩效", "amount", 13),
_Column("adjust_extra", "调整补发", "amount", 12),
_Column("adjust_deduct", "调整扣减", "amount", 12),
_Column("adjust_net", "调整净额", "amount", 12),
_Column("net_total", "净合计", "amount", 13),
)
unassigned_rows = [
{
"personnel_name": row["personnel_name"],
"role_code": row["role_code"],
"unassigned_reason": row.get("unassigned_reason") or "",
"performance_sum": row["performance_sum"],
"payable_sum": row["payable_sum"],
"adjust_extra": row["adjust_extra"],
"adjust_deduct": row["adjust_deduct"],
"adjust_net": row["adjust_net"],
"net_total": row["net_total"],
}
for row in unassigned
]
_write_block(
sheet,
_Block("五、未分配人员明细", unassigned_columns, unassigned_rows),
sum_amounts=True,
)
_write_signature(sheet, len(_DEPARTMENT_WIDTHS))
for index, width in enumerate(_DEPARTMENT_WIDTHS, start=1):
sheet.column_dimensions[get_column_letter(index)].width = width
def _write_block(sheet, block: _Block, *, sum_amounts: bool = False) -> None:
"""写一个区块:标题、表头、数据/空提示,以及自定义或自动合计。"""
sheet.append([block.title])
title_row = sheet.max_row
_merge_row(sheet, len(block.columns))
sheet.cell(row=title_row, column=1).font = _HEADER_FONT
sheet.append([column.title for column in block.columns])
header_row = sheet.max_row
for cell in sheet[header_row]:
cell.font = _HEADER_FONT
cell.fill = _HEADER_FILL
cell.border = _BORDER
cell.alignment = Alignment(horizontal="center", vertical="center")
if block.rows:
for row in block.rows:
_write_row(sheet, block.columns, row)
else:
sheet.append([block.empty_text])
_merge_row(sheet, len(block.columns))
if block.total is not None:
_write_custom_total(sheet, block.columns, block.total)
elif sum_amounts:
_write_total(sheet, block.columns, block.rows)
sheet.append([])
def _write_custom_total(sheet, columns: tuple[_Column, ...], total: dict) -> None:
"""写后端预计算的合计行(不自动求和,避免重复计入共享基数)。"""
values = []
for column in columns:
raw = total.get(column.key)
values.append(_cell_value(column, raw) if raw is not None else None)
sheet.append(values)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.font = _HEADER_FONT
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
def _sum_amount(values) -> Decimal:
"""金额列求和后两位收口(None 跳过)。"""
return sum((value for value in values if value is not None), Decimal("0")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,622 @@
"""月度结算服务(M3 ④):生成快照 → 提交审批 → 通过即锁定。
确认回款口径(澄清 #8 策略点,2026-10-16 暂定):settlement_status=1 的
**有效台账行**(is_active=1、settlement_id IS NULL=未被任何月结固化),
按 settlement_month(回款日所在月)归集。答复后只改 ``_confirm_scope``。
通过即锁定(status=3 终态):锁定后源数据(回款/发货)写路径由
``ensure_month_not_locked`` 拦截;调整走调整单(T6)。
"""
import logging
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.constants import (
MODULE_SETTLEMENT,
)
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import (
MonthlySettlement,
Personnel,
SettlementItem,
User,
)
from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT
from app.models.performance_ledger import (
CALC_TYPE_MANAGER_AGGREGATE,
CALC_TYPE_RECEIPT,
)
from app.models.settlement import (
SETTLEMENT_STATUS_APPROVED,
SETTLEMENT_STATUS_DRAFT,
SETTLEMENT_STATUS_PENDING,
SETTLEMENT_STATUS_REJECTED,
)
from app.repositories import performance_ledger_repository, settlement_repository
from app.schemas.settlement import (
SettlementDetailOut,
SettlementOut,
SettlementQuery,
)
from app.services import approval_service, audit_service, manager_share
from app.services.approval_service import AuditRecord, RequestMeta
from app.services.formula import manager_team_share, monthly_salary
logger = logging.getLogger(__name__)
# 审批对象类型引用(order_type=2 月结、3 调整单)
_SETTLEMENT_ORDER_TYPE = APPROVAL_TYPE_MONTHLY_SETTLEMENT
_SETTLEMENT_NOTE = "确认回款口径(#8 策略点):settlement_status=1 的有效台账行按回款日归月"
def generate(
session: Session,
month: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettlementOut:
"""生成月度结算快照(草稿):按确认回款口径取有效台账行,按人汇总。
- 同月已有月结行(任意状态)时 400——一行一月,重生成走「驳回/草稿态
重新生成」(_release_previous_round 解绑回填后清明细重算)
- 经理聚合(calc_type=2)在生成时计算并落库(#12 参数化见
``_build_manager_aggregates``)
本函数只做编排,各段独立私有函数(口径修改只动对应段)。
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="生成月结",
summary=f"生成 {month} 月度结算快照",
request_data={"month": month},
),
meta=meta,
):
existing = settlement_repository.get_by_month(session, month)
_guard_regeneration(existing, month)
_release_previous_round(session, existing, month)
ledger_rows = _confirm_scope(session, month)
if not ledger_rows:
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
settlement = _upsert_draft(session, existing, operator, month)
items, ledger_ids = _build_items(session, ledger_rows, aggregate_rows, month)
settlement_repository.replace_items(session, settlement.id, items)
_apply_totals(settlement, items)
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
# 不回填会让重生成把已固化行反复纳入;聚合行此刻已落库、id 可用
settlement_repository.update_ledger_settlement_id(session, ledger_ids, settlement.id)
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
# 记成失败审计,与库中事实相反)
session.flush()
detail = _detail_of(session, settlement)
session.commit()
logger.info("月结生成 | month=%s | items=%s", month, len(items))
return detail
def _guard_regeneration(existing: MonthlySettlement | None, month: str) -> None:
"""重生成准入:已审定 / 待审批不可重生成(草稿、已驳回放行)。"""
if existing is None:
return
if existing.status == SETTLEMENT_STATUS_APPROVED:
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
if existing.status == SETTLEMENT_STATUS_PENDING:
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
def _release_previous_round(
session: Session, existing: MonthlySettlement | None, month: str
) -> None:
"""释放上一轮固化标记(必须早于 _confirm_scope)。
台账行 settlement_id 回填后,「未入结」过滤会把它们永久排除在候选集外;
旧聚合行也要作废,否则本轮重建的聚合行与旧行重复计入。
"""
if existing is None:
return
settlement_repository.clear_ledger_settlement_id(session, existing.id)
performance_ledger_repository.deactivate_month_aggregates(session, month)
def _upsert_draft(
session: Session, existing: MonthlySettlement | None, operator: User, month: str
) -> MonthlySettlement:
"""新建或重置草稿头表(重生成时刷新提交人与状态)。"""
if existing is None:
settlement = MonthlySettlement(
settlement_month=month,
submitter_id=operator.id,
submitter_name=operator.username,
status=SETTLEMENT_STATUS_DRAFT,
)
return settlement_repository.add(session, settlement)
existing.submitter_id = operator.id
existing.submitter_name = operator.username
existing.status = SETTLEMENT_STATUS_DRAFT
return existing
def _build_items(
session: Session, ledger_rows: list, aggregate_rows: list, month: str
) -> tuple[list[SettlementItem], list[int]]:
"""按人汇总生成明细行,返回 (明细, 本次入结的台账行 id 列表)。"""
by_person = _bucket_rows(ledger_rows + aggregate_rows)
items = [_item_of(session, bucket, month) for _, bucket in sorted(by_person.items())]
ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]]
return items, ledger_ids
def _bucket_rows(rows: list) -> dict[tuple[int, int], dict]:
"""台账行按 (人员, 核算类型) 维度分桶(冗余人读字段取首行)。"""
by_person: dict[tuple[int, int], dict] = {}
for row in rows:
key = (row.personnel_id, row.calc_type)
bucket = by_person.setdefault(
key,
{
"personnel_id": row.personnel_id,
"personnel_name": row.personnel_name,
"role_code": row.role_code,
"calc_type": row.calc_type,
"performance_sum": Decimal("0"),
"payable_sum": Decimal("0"),
"ledger_ids": [],
"is_probation": row.is_probation,
},
)
bucket["performance_sum"] += row.performance_amount or Decimal("0")
bucket["payable_sum"] += row.payable_amount or Decimal("0")
bucket["ledger_ids"].append(row.id)
return by_person
def _item_of(session: Session, bucket: dict, month: str) -> SettlementItem:
"""单条结算明细:月工资三口径净额、试用期说明与台账行引用列表。"""
person = session.get(Personnel, bucket["personnel_id"])
basic = person.salary_standard if person else None
mode = _salary_mode_of(session, bucket["role_code"], month)
net = (
monthly_salary(bucket["payable_sum"], basic, mode)
if person and person.is_probation != 1
else None
)
note = None
if person and person.is_probation == 1:
note = (
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session, month)} 元部分"
"年底一次性结算"
)
return SettlementItem(
personnel_id=bucket["personnel_id"],
personnel_name=bucket["personnel_name"],
role_code=bucket["role_code"],
calc_type=bucket["calc_type"],
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
basic_amount=basic,
salary_mode=mode,
net_amount=(
net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if net is not None else None
),
is_probation=bucket["is_probation"],
probation_note=note,
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
)
def _apply_totals(settlement: MonthlySettlement, items: list[SettlementItem]) -> None:
"""头表三合计固化(明细已两位小数,合计再 HALF_UP 收口)。"""
settlement.total_performance = sum((i.performance_sum for i in items), Decimal("0")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
settlement.person_count = len({i.personnel_id for i in items})
def submit(
session: Session,
settlement_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettlementDetailOut:
"""提交审批:开关分流(关=自动通过并锁定;开=待财务复核)。"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="提交月结",
summary=f"提交月结 #{settlement_id}",
request_data={"settlement_id": settlement_id},
),
meta=meta,
):
settlement = settlement_repository.get_by_id(session, settlement_id)
if settlement is None:
raise NotFoundError("月结不存在")
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
raise BadRequestError("仅草稿或已驳回状态可提交")
order = approval_service.submit_order(
session,
order_type=_SETTLEMENT_ORDER_TYPE,
ref_id=settlement.id,
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
submitter=operator,
)
settlement.approval_order_no = order.order_no
if order.status == SETTLEMENT_STATUS_APPROVED:
mark_locked(session, settlement, operator=operator)
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month)
else:
settlement.status = SETTLEMENT_STATUS_PENDING
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已提交的
# 月结记成失败审计,与库中事实相反)
session.flush()
detail = _detail_of(session, settlement)
session.commit()
return detail
def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
"""审批通过钩子(由 approval_service.act 回调):置已审定+锁定信息。"""
from datetime import datetime
settlement.status = SETTLEMENT_STATUS_APPROVED
settlement.locked_at = datetime.now()
settlement.locked_by = operator.username
def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
"""审批驳回钩子:回草稿(可修改后重提)。
锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示
「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。
"""
settlement.status = SETTLEMENT_STATUS_DRAFT
settlement.locked_at = None
settlement.locked_by = None
def ensure_month_not_locked(session: Session, month: str) -> None:
"""锁定拦截(回款/发货写路径调用):单据日期所在月已审定 → 400。"""
settlement = settlement_repository.get_by_month(session, month)
if settlement is not None and settlement.status == SETTLEMENT_STATUS_APPROVED:
raise BadRequestError(f"{month} 已月结锁定,源数据不可修改(调整走调整单)")
def get_detail(session: Session, settlement_id: int) -> SettlementDetailOut:
settlement = settlement_repository.get_by_id(session, settlement_id)
if settlement is None:
raise NotFoundError("月结不存在")
return _detail_of(session, settlement)
def list_page(session: Session, query: SettlementQuery):
from app.core.response import PageResult
from app.schemas.settlement import SettlementOut
rows = settlement_repository.list_settlements(session, query)
total = settlement_repository.count_settlements(session, query)
return PageResult[SettlementOut](
records=[SettlementOut.model_validate(r) for r in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
# ---------- 内部 ----------
def _confirm_scope(session: Session, month: str) -> list:
"""确认回款口径(#8 策略点):本月**单笔回款**的有效台账行(未入结)。
calc_type 必须限定单笔(1):聚合行(2)也落同月台账、也会被回填
settlement_id,不排除会在重生成时与新聚合行重复计入。
"""
from app.models import PerformanceLedger
return list(
session.execute(
select(PerformanceLedger)
.where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == CALC_TYPE_RECEIPT,
PerformanceLedger.is_active == 1,
PerformanceLedger.settlement_id.is_(None),
)
.order_by(PerformanceLedger.id)
).scalars()
)
def _manager_share_params(session: Session, month: str) -> manager_share.ManagerShareParams:
"""薄委托 manager_share 共享模块:月结聚合与报表部门汇总同源取参。
迁移说明(2026-10-19):实现原样迁至 manager_share,行为零变化;
参数按百分数存储(12.5 表示 12.5%),调用方不得再乘 100。
"""
return manager_share.manager_share_params(session, month)
def _collect_team_buckets(
session: Session, ledger_rows: list, include_manager_personal: bool
) -> dict[int, dict]:
"""薄委托 manager_share 共享模块:按经理采集团队业绩(行为零变化)。"""
return manager_share.collect_team_buckets(session, ledger_rows, include_manager_personal)
def _aggregate_row(
session: Session,
manager,
bucket: dict,
company_base: Decimal,
share: manager_share.ManagerShareParams,
):
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
from app.models import PerformanceLedger
amount = manager_team_share(
bucket["performance"],
company_base,
team_pct=share.team_pct,
company_pct=share.company_pct,
)
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
return performance_ledger_repository.add(
session,
PerformanceLedger(
receipt_id=None,
calc_version=1, # 聚合行无回款维度,版本恒 1(uk 含 NULL 不去重)
calc_type=CALC_TYPE_MANAGER_AGGREGATE,
business_id=bucket["business_id"],
business_no=bucket["business_no"],
personnel_id=manager.id,
personnel_name=manager.personnel_name,
role_code="sales_manager",
customer_type="personal",
product_category="mixed",
receipt_date=share.param_date,
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
rate_text=f"聚合:{share.team_text}%+{share.company_text}%",
converted_profit=None,
performance_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
barter_applied=0,
payable_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
settlement_month=share.month,
is_probation=1 if manager.is_probation == 1 else 0,
snapshot_json={
"team_base": str(bucket["performance"]),
"company_base": str(company_base),
"members": member_names,
"params_as_of": str(share.param_date),
"manager_personal_included": 1 if share.include_manager_personal else 0,
"note": (
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
"经理个人户业绩;9.8 方案公式"
),
},
),
)
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
"""按经理生成团队聚合行(calc_type=2)并落库。
#12 答复(2026-10-19)参数化:经理个人户业绩是否计入聚合基数由参数
``manager.personal.included`` 决定(0=不计入默认,1=计入);
经理 = 台账行归属人员经 manager_id 关联的 sales_manager 岗人员。
"""
from app.repositories import business_role_repository
share = _manager_share_params(session, month)
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);共享口径单点
company_base = manager_share.company_base_of(ledger_rows, share.include_manager_personal)
aggregates: list = []
for manager_id, bucket in managers.items():
manager = session.get(Personnel, manager_id)
if manager is None:
continue
role = (
business_role_repository.get_by_id(session, manager.business_role_id)
if manager.business_role_id
else None
)
if role is None or role.role_code != "sales_manager":
continue # 非营销经理的上级不参与聚合
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
return aggregates
def _month_end(month: str) -> date:
"""薄委托 manager_share 共享模块:YYYY-MM → 所属月月末(取参锚点)。
保留包装:tests/unit/test_settlement_helpers.py 直接 import 本名字;
「补生成历史月份结果不随操作日漂移」的原因说明见 manager_share.month_end。
"""
return manager_share.month_end(month)
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
from app.services.param_service import resolve_snapshot
snapshot = resolve_snapshot(session, _month_end(month))
return snapshot.get("salary.basic.mode", role_code=role_code) or None
def _probation_threshold(session: Session, month: str) -> Decimal:
from app.services.param_service import parse_fraction, resolve_snapshot
snapshot = resolve_snapshot(session, _month_end(month))
from app.services.formula import to_decimal
return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold")))
def _detail_of(session: Session, settlement: MonthlySettlement) -> SettlementDetailOut:
items = settlement_repository.list_items(session, settlement.id)
detail = SettlementDetailOut.model_validate(settlement)
from app.schemas.settlement import SettlementItemOut
detail.items = [SettlementItemOut.model_validate(i) for i in items]
return detail
def create_adjustment(
session: Session,
payload,
*,
operator: User,
meta: RequestMeta | None = None,
):
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
from app.core.constants import NO_PREFIX_ADJUSTMENT
from app.models import AdjustmentOrder
from app.services.no_generator import next_no
amount = _adjustment_amount(payload)
person = session.get(Personnel, payload.personnel_id)
if person is None:
raise BadRequestError("调整人员不存在")
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="新建调整单",
summary=f"调整单 {payload.direction} {payload.amount} 元({person.personnel_name})",
request_data=payload.model_dump(),
),
meta=meta,
):
order = AdjustmentOrder(
order_no=next_no(
session,
model=AdjustmentOrder,
column_name="order_no",
prefix=NO_PREFIX_ADJUSTMENT,
),
settlement_month=payload.settlement_month,
personnel_id=payload.personnel_id,
personnel_name=person.personnel_name,
direction=payload.direction,
amount=amount,
reason=payload.reason.strip(),
submitter_id=operator.id,
submitter_name=operator.username,
)
session.add(order)
session.flush()
_wire_adjustment_approval(session, order, operator)
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
session.commit()
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])
return out
def _adjustment_amount(payload) -> Decimal:
"""金额恒正校验(方向表达补发/扣减)。
列注释即「恒正」约定,负数会让方向语义反转、审批/报表按 direction
汇总出错;格式已由 schema 正则拦截,此处兜底转换 + 语义判定。
"""
from decimal import InvalidOperation
try:
amount = Decimal(payload.amount)
except InvalidOperation as exc:
raise BadRequestError("调整金额格式非法(非负两位小数)") from exc
if amount <= 0:
raise BadRequestError("调整金额须大于 0(方向表达补发/扣减)")
return amount
def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
"""开关分流与留痕统一交给 submit_order(关=自动通过留痕;开=待财务复核)。
**不得手工插 ApprovalStep**——``approval_steps.order_id`` 外键指向
approval_orders(不是 adjustment_orders),用调整单 id 落步必然外键
失败(历史缺陷:关开关时新建调整单不可用)。
"""
from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT
from app.models.settlement import (
ADJUSTMENT_STATUS_EFFECTIVE,
ADJUSTMENT_STATUS_PENDING,
)
approval_order = approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_ADJUSTMENT,
ref_id=order.id,
refer_no=order.order_no,
submitter=operator,
)
order.status = (
ADJUSTMENT_STATUS_EFFECTIVE
if approval_order.status == APPROVAL_STATUS_APPROVED
else ADJUSTMENT_STATUS_PENDING
)
session.flush()
def _adjustment_out(order) -> dict:
"""调整单出参(commit 前固化;expire_on_commit 关闭下也不依赖后置读取)。"""
return {
"id": order.id,
"order_no": order.order_no,
"status": order.status,
"personnel_name": order.personnel_name,
"amount": str(order.amount),
"direction": order.direction,
}
def list_adjustments(session: Session) -> list[dict]:
"""调整单列表(月份倒序)。"""
from sqlalchemy import select
from app.models import AdjustmentOrder
rows = (
session.execute(select(AdjustmentOrder).order_by(AdjustmentOrder.id.desc()).limit(200))
.scalars()
.all()
)
from app.models.settlement import adjustment_label # noqa: F401
return [
{
"id": r.id,
"order_no": r.order_no,
"settlement_month": r.settlement_month,
"personnel_name": r.personnel_name,
"direction": r.direction,
"amount": str(r.amount),
"reason": r.reason,
"status": r.status,
}
for r in rows
]

View File

@ -237,6 +237,12 @@ def create_shipment(
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新发货")
# 月结锁定拦截:发货日所在月已审定 → 源数据不可改(调整走调整单)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(
session, payload.ship_date.strftime("%Y-%m")
)
shipment = Shipment(
shipment_no=shipment_no,
@ -309,6 +315,11 @@ def update_shipment(
if shipment is None:
raise NotFoundError("发货单不存在")
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
# 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
settlement_service.ensure_month_not_locked(session, payload.ship_date.strftime("%Y-%m"))
shipment.ship_date = payload.ship_date
shipment.freight_amount = _amount_or_zero(payload.freight_amount)
@ -357,6 +368,10 @@ def delete_shipment(
if shipment is None:
raise NotFoundError("发货单不存在")
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
# 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
if shipment_repository.count_receipt_refs(session, shipment_id):
raise BadRequestError("已被回款批次指定,请先解除回款的批次指定")

View File

@ -1,12 +1,17 @@
"""系统设置(公司信息)业务编排。
"""系统设置(公司信息 + 站点信息)业务编排。
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径**
(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次
读取与备份放大传输,且无法独立替换;DB 只持有一行指针。
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo/favicon 只存**相对
路径**(uploads/logo、uploads/favicon 下固定名覆盖),文件不入库的理由:
图片进数据库会让每次读取与备份放大传输,且无法独立替换;DB 只持有指针。
logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
图像安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
覆盖任意文件。
logo 与 favicon 相互独立(2026-10-20):
- logo 用于登录页品牌位;
- favicon 用于浏览器标签页图标,未上传时前端使用内置 favicon.svg
(不再跟随 logo——公司 logo 是横版,作标签页图标常被裁切)。
"""
import logging
@ -24,15 +29,26 @@ from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
# magic bytes:PNG 固定 8 字节签名;JPEG 以 SOI + APP0/JFIF 起始
# magic bytes:PNG 8 字节签名;JPEG 以 SOI 起始;ICO 以 00 00 01 00 起始
_MAGIC = {
"png": b"\x89PNG\r\n\x1a\n",
"jpg": b"\xff\xd8\xff",
"ico": b"\x00\x00\x01\x00",
}
LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用
# 允许的后缀集合:logo 沿用 png/jpg(页眉/登录页展示);favicon 额外接受 ico
LOGO_EXTS = ("png", "jpg")
FAVICON_EXTS = ("png", "jpg", "ico")
ASSET_MAX_BYTES = 2 * 1024 * 1024 # 2MB:展示尺寸上限,防上传滥用
SETTINGS_ID = 1 # 全系统单行主键
# 公司档案中的可选文本字段:提交空串等价清空(落库 null,前端回退内置默认)
_OPTIONAL_TEXT_FIELDS = ("site_title", "brand_slogan", "footer_text", "icp_no")
# 资源种类 → 中文名(错误提示与审计摘要用)
_ASSET_LABELS = {"logo": "logo", "favicon": "站点图标"}
_ASSET_EXTS = {"logo": LOGO_EXTS, "favicon": FAVICON_EXTS}
def get_setting(session: Session) -> SystemSetting:
"""取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。"""
@ -45,10 +61,15 @@ def get_setting(session: Session) -> SystemSetting:
def to_out(setting: SystemSetting) -> SettingOut:
"""管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。"""
"""管理端出参(图片地址带更新时间版本参数,替换后浏览器不再用旧缓存)。"""
return SettingOut(
company_name=setting.company_name,
logo_url=_logo_url(setting),
logo_url=_asset_url(setting, "logo"),
site_title=setting.site_title,
brand_slogan=setting.brand_slogan,
footer_text=setting.footer_text,
icp_no=setting.icp_no,
favicon_url=_asset_url(setting, "favicon"),
)
@ -56,15 +77,26 @@ def to_public_out(setting: SystemSetting) -> SettingPublicOut:
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
return SettingPublicOut(
company_name=setting.company_name,
logo_url=_logo_url(setting),
logo_url=_asset_url(setting, "logo"),
site_title=setting.site_title,
brand_slogan=setting.brand_slogan,
footer_text=setting.footer_text,
icp_no=setting.icp_no,
favicon_url=_asset_url(setting, "favicon"),
)
def _logo_url(setting: SystemSetting) -> str | None:
if not setting.logo_path:
def _asset_url(setting: SystemSetting, kind: str) -> str | None:
"""资源 URL(kind=logo/favicon);未上传返回 None。
版本号取**上传计数器**而非 update_time:同扩展名替换时固定文件名不变
(logo_path 值不变),MySQL 的 ON UPDATE 不会刷新 update_time,用它做
?v= 会导致浏览器一直用旧图(2026-10-20 用户反馈的换图不刷新问题)。
"""
if not getattr(setting, f"{kind}_path"):
return None
version = int(setting.update_time.timestamp()) if setting.update_time else 0
return f"/api/v1/system/settings/logo/public?v={version}"
version = getattr(setting, f"{kind}_version") or 0
return f"/api/v1/system/settings/{kind}/public?v={version}"
def update_company(
@ -74,20 +106,34 @@ def update_company(
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""更新公司名称(审计留痕)。"""
"""更新公司信息与站点信息(部分更新 + 审计留痕)。
仅提交的字段生效(``exclude_unset``:未提交/显式 null 保持原值),
便于「只改名称」「只改站点标题」等场景不覆盖其他字段;可选文本传
空串等价清空(落库 null)。
"""
changes = payload.model_dump(exclude_unset=True, exclude_none=True)
summary = "修改公司信息"
if "company_name" in changes:
summary = f"修改公司信息(公司名称「{changes['company_name']}」)"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE,
summary=f"修改公司名称为「{payload.company_name}」",
request_data=payload.model_dump(),
summary=summary,
request_data=changes or None,
),
meta=meta,
):
setting = get_setting(session)
setting.company_name = payload.company_name.strip()
if "company_name" in changes:
# schema 已保证提交值非空(min_length=1)
setting.company_name = changes["company_name"]
for field in _OPTIONAL_TEXT_FIELDS:
if field in changes:
setattr(setting, field, changes[field].strip() or None)
session.commit()
return to_out(setting)
@ -100,17 +146,39 @@ def save_logo(
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
"""上传公司 logo(登录页品牌位;png/jpg ≤2MB)。"""
return _save_asset(session, content, ext, kind="logo", operator=operator, meta=meta)
def save_favicon(
session: Session,
content: bytes,
ext: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""上传站点图标(浏览器标签页;png/jpg/ico ≤2MB)。"""
return _save_asset(session, content, ext, kind="favicon", operator=operator, meta=meta)
def _save_asset(
session: Session,
content: bytes,
ext: str,
*,
kind: str,
operator: User,
meta: RequestMeta | None,
) -> SettingOut:
"""校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
"""
label = _ASSET_LABELS[kind]
allowed = _ASSET_EXTS[kind]
ext = ext.lower()
if ext not in _MAGIC:
raise BadRequestError("logo 仅支持 png/jpg 格式")
if len(content) > LOGO_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
if not content.startswith(_MAGIC[ext]):
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
_validate_upload(content, ext, allowed=allowed, label=label)
with audit_service.audit(
session,
@ -118,48 +186,79 @@ def save_logo(
record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE,
summary="更新公司 logo",
summary=f"更新{label}",
request_data={"ext": ext, "size": len(content)},
),
meta=meta,
):
setting = get_setting(session)
old_path = setting.logo_path
old_path = getattr(setting, f"{kind}_path")
# 旧扩展名清理必须在写新文件**之前**:同扩展名覆盖时先写后删会把
# 刚落盘的新文件一并删掉(回读 404)
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext:
_cleanup_old_logo(old_ext)
target = _logo_dir() / f"logo.{ext}"
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
# 由写入覆盖,无需也不会误删新文件
target = _asset_dir(kind) / f"{kind}.{ext}"
target.write_bytes(content)
setting.logo_path = f"logo/logo.{ext}"
setattr(setting, f"{kind}_path", f"{kind}/{kind}.{ext}")
# 版本号每次上传必增:即使同扩展名覆盖(路径不变)也能破浏览器缓存
setattr(setting, f"{kind}_version", (getattr(setting, f"{kind}_version") or 0) + 1)
session.commit()
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext and old_ext in allowed:
try:
_cleanup_old_asset(kind, old_ext)
except OSError:
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
# 成功的更新会被记成失败审计并 500 —— 审计与事实相反
logger.warning(
"旧%s清理失败(不影响本次更新)| ext=%s", label, old_ext, exc_info=True
)
logger.info("更新%s | size=%s ext=%s", label, len(content), ext)
return to_out(setting)
def read_logo(session: Session) -> tuple[Path, str]:
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
return _read_asset(session, "logo")
def read_favicon(session: Session) -> tuple[Path, str]:
"""按库中记录定位站点图标文件,返回 (路径, 扩展名)。未上传抛 404。"""
return _read_asset(session, "favicon")
def _read_asset(session: Session, kind: str) -> tuple[Path, str]:
label = _ASSET_LABELS[kind]
setting = get_setting(session)
if not setting.logo_path:
raise NotFoundError("尚未上传 logo")
path = get_settings().upload_dir / setting.logo_path
path_str = getattr(setting, f"{kind}_path")
if not path_str:
raise NotFoundError(f"尚未上传{label}")
path = get_settings().upload_dir / path_str
if not path.is_file():
raise NotFoundError("logo 文件不存在,请重新上传")
raise NotFoundError(f"{label}文件不存在,请重新上传")
return path, path.suffix.lstrip(".")
def _logo_dir() -> Path:
directory = get_settings().upload_dir / "logo"
def _validate_upload(content: bytes, ext: str, *, allowed: tuple[str, ...], label: str) -> None:
"""后缀白名单 + magic bytes + 大小三重校验(同口径供 logo/favicon 复用)。"""
if ext not in allowed:
raise BadRequestError(f"{label}仅支持 {'/'.join(allowed)} 格式")
if len(content) > ASSET_MAX_BYTES:
raise BadRequestError(f"{label}文件不能超过 2MB")
if not content.startswith(_MAGIC[ext]):
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
def _asset_dir(kind: str) -> Path:
directory = get_settings().upload_dir / kind
directory.mkdir(parents=True, exist_ok=True)
return directory
def _cleanup_old_logo(old_ext: str) -> None:
if old_ext in _MAGIC:
old_file = _logo_dir() / f"logo.{old_ext}"
def _cleanup_old_asset(kind: str, old_ext: str) -> None:
if old_ext in _ASSET_EXTS[kind]:
old_file = _asset_dir(kind) / f"{kind}.{old_ext}"
if old_file.exists():
old_file.unlink(missing_ok=True)

View File

@ -1,6 +1,6 @@
# 后端测试
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 603 个用例全绿(单元 381 / 集成 222)。
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 697 个用例全绿(2026-10-16,M3 增量批次后)。
## 一、测试约定
@ -113,6 +113,52 @@ uv run pytest tests/integration -q # 仅集成
运行:`uv run pytest tests/integration/test_{businesses,shipments,receipts,ratings}.py -q` → **70 passed**(2026-10-13,连跑两次幂等);全量回归 `uv run pytest -q` → **603 passed**(单元 381 / 集成 222)。
### M3-core ④ 核算引擎 + 月度结算(2026-10-16 完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/unit/test_formula.py`(扩) | R 钳制(向下取整/[0,100] 封顶封底/销售额 0 防御)、月工资三口径(INCLUDE 加/DEDUCT 减可负/EXCLUDE 仅绩效/基本缺失按 0/未知口径报错)、试用期年累计说明(未超无 note/超额精确差额)、经理 12.5%+5% 聚合(#12 暂口径) | +12 |
| `tests/integration/test_calc.py` | **金标准双类别复算到分**(市政 4000 + 轨交 0 = 折算 4000;88/9000 约分原文落 rate_text;绩效 39.11;R=100 应付 39.11,DB 锚定);**业务级一次性计提 #25**(同业务第二笔回款 repeated=True 仅登记进度不新增行);已核算再核算 400;未指定批次 400;**成本缺失行毛利按 0 计**(折算保守 −3000、missing_cost=1);负绩效照算不归零(折算 −2000);准入(normal 403/marketing_ops 可用);列表筛选;重算(finance 授权、v2 生效 v1 保留、reason 留痕、原因缺失 400、未核算重算 400、越权 403);批量逐笔成败清单 | 13 |
| `tests/integration/test_settlements.py` | 生成按人汇总+台账回填 settlement_id;无核算记录 400;**已审定月份重生成 400**;开关关闭提交即自动通过锁定(AP 单号/locked_at 回填);**锁定后回款禁改 400**(M2 已核算禁改防线先于锁定拦截);开关开启两节点流转(finance→leader)至锁定;#12 暂口径聚合不实现(items 按人汇总不聚合验证) | 6 |
运行:`uv run pytest tests/integration/test_{calc,settlements}.py tests/unit/test_formula.py -q` → **32 passed**(2026-10-16);全量回归见运行记录。
### M3 增量批次(2026-10-16 完成:公司信息 / 二次分配录入 / 审批框架)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_settings.py` | 公司信息单行语义(PUT 只改名称不增行);**public 端点无令牌 200 且字段白名单仅 2 项**(复用 admin schema 即泄漏面);名称空 400;非 admin 403;logo 三重校验(伪造扩展名被 magic bytes 拦 / gif 拒 / 超 2MB 拒);合法 png 上传回读 content-type;匿名上传 401;修改留审计日志 | 11 |
| `tests/unit/test_secondary_allocation_schema.py` | 比例边界矩阵(0 拒 / 0.01-100 过 / 100.01 拒 / 负数拒 / **三位小数拒绝不静默舍入** / 非数字拒);人员 ID 非正拒;**创建/编辑两入参的参与人去重**(uk 前置友好提示);不同参与人合法 | 13 |
| `tests/integration/test_secondary_allocation.py` | 带方案创建详情回显(**personnel_name 冗余固化**:离职后回显有名);不建方案详情空且无 hint;合计满额 hint 只报合计无「余额」噪音;**离职人员参与 400**「分摊参与人员不存在或已离职」;终态业务禁改方案 400;编辑全量替换与空数组清空;**被方案引用人员禁删(RESTRICT→400)且方案回显有名**;删除业务后方案 CASCADE 随删、人员可删 | 9 |
| `tests/integration/test_approval_flow.py` | **开关关闭(种子默认 0):提单即两级「自动通过」留痕**(status=3、steps 两条 action=3、operator 记「系统」——留痕不断链);方案为空不提单;开关开启两节点流转(1→财务通过→2→领导通过→3);财务驳回整体 status=4 且意见留痕;驳回缺意见 400;**节点准入:领导/admin 不可代审财务节点(403)**;终态单重复操作 400;再编辑撤旧建新(旧单 status=5 + node=0/action=4 撤回留痕);模块准入(normal/marketing_ops 403,finance/leader 200)。开关切换经参数变更批次端到端,不直改库;用后 conftest 恢复种子基线 | 11 |
| `tests/unit/test_param_registry.py`(改) | 注册表 16→**18 项**(V6 首例 BOOL 两个审批开关);**BOOL 校验边界专项**(0/1 过、true/2 拒) | +1 |
| `tests/integration/test_schema_sync.py`(改) | 角色种子断言 2→**5 角色**(marketing_ops/finance/leader 入列) | 参数化 |
运行:`uv run pytest tests/integration/test_{settings,secondary_allocation,approval_flow}.py tests/unit/test_secondary_allocation_schema.py -q` → **44 passed**(2026-10-16);全量回归 `uv run pytest -q` → **697 passed**(基线 625 + 新增 72,5:10)。
### 报表统计(M4 ⑤,2026-10-19 后端完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、分成按参数、无成员经理出 0 值行;**无台账经理挂上级不 500、仅有调整单人员并入团队/未分配、经理个人户开关两侧口径、按经理筛选不误判其他团队下属、对账恒等式**;**应发与月结 `total_payable` 对拍(两位经理各计一次 5%)、有调整单时等于月结+调整净额、筛选态只汇总展示团队**;导出页眉 ID→名称/中文标签、回款维度 sheet 透传筛选、**导出超限与取数异常的失败审计**、非法月份查询/导出均 400;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**;业务统计排除经理聚合行;普通用户 403 | 27 |
| `tests/unit/test_report_helpers.py` | 区间分桶边界与 8 档编码唯一;标签覆盖全部枚举;Excel 模板页眉/签字栏/空数据/金额格式/合计/公式注入/维度 sheet/状态中文;**部门汇总签字稿五块顺序、零成员团队与未分配行、5% 按计入次数累计、应发合计取对账口径、聚合行舍入差单列、比例标题参数化** | 17 |
| `tests/unit/test_month_validation.py` | 非法月份(`2026-13`/`2026-00`/`0000-01` 等)在报表、月结、调整单 schema 入口即拒绝;可选月份空串规范为 None | 17 |
运行:`uv run pytest tests/integration/test_reports.py -q` → **27 passed**(2026-10-20 复核);`uv run pytest tests/unit/test_report_helpers.py tests/unit/test_month_validation.py -q` → **34 passed**;全量 **862 passed**(2026-10-20,11:32)。
**e2e 实跑(2026-10-20)**:`frontend/e2e/report_flow.py` 12/12 通过。回款统计改为展开目标桶后按**本次业务编号**定位下钻行(不依赖桶累计值,可重复运行);另覆盖多桶同时展开、部门下钻筛选、打印截断提示、部门汇总打印五块结构。
### 工作台(M4 ⑧,2026-10-19 完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志;**待办按角色过滤(财务/领导/多角色/admin)+ 真实流转;回款逾期命中与阈值参数生效;终止业务排除;`warnings.total` 不受展示上限截断;回款趋势跨月归属** | 11 |
| `tests/unit/test_param_registry.py`(改) | 注册表 19→**22 项**(V10 预警规则三项);编码集合与迁移种子一致 | 改 |
运行:`uv run pytest tests/integration/test_dashboard.py -q` → **11 passed**(2026-10-19 code-review 修复回归)。
**实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。
## 三、运行记录
| 日期 | 范围 | 用例数 | 结果 | 备注 |
@ -127,11 +173,34 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 |
| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 |
| 2026-10-15 | 全量回归 + e2e | 639 | ✅ 全部通过 | **code-review 32 条修复后全量回归**(新增 34 例:跨位宽 3 / 参数预览与维度 3 / 审计重试 2 / 终态编辑与 quantize 2 / 产品保护 2+6 / 评级锁 1 / formula 钳制 1 等)。e2e 冒烟:登录 8 项 + 系统管理 13 项 + 基础数据 13 步 + 业务全链路 16 步全过(拦截器/正则收口/params 分页改造后复跑) |
| 2026-10-17 | M3-core 定点 | 32 | ✅ 通过 | **④ 核算引擎+月结完成**(calc 13 / settlements 6 / formula 扩 12 / schemas 等)。金标准复算到分;重算版本链;月结锁定与审批联动 |
| 2026-10-17 | 全量回归 | 701+N | ✅ 全部通过 | **M3-core 收尾回归**(T1-T6:V7 四表+核算+月结+调整单;前端台账/月结两页 order17/18)。e2e 补 calc_settlement_flow 待下批 |
| 2026-10-16 | 全量回归 + 7 个 e2e | 697 | ✅ 697 passed(5:10);e2e 七脚本全过 | **M3 增量批次完成**(+72 例:settings 11 / 分配 schema 13+集成 9 / 审批 11 / registry 与 schema_sync 断言扩充等)。e2e:登录 8 + 系统管理 13 + 基础数据 12 + 业务 16 + 参数 7 步回归全过;**新增 settings_flow 4 步 / secondary_allocation_flow 6 步**(首跑即抓出 public 缓存与 logo 清理两处缺陷,见下表) |
| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) |
| 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 |
| 2026-10-19 | 拆行收口批次 | 全量 788 | ✅ 788 passed(9:20) | **2026-10-19 业务答复落实完成**:#14 首日取整 10‰(公式段/锚点不变,用例反转);#12 单笔侧开关消费(1=业务员递减计提);**二次分摊拆行**(参与人份额+负责人余额守恒,uk 三列版);权限分层(params 查看放开/写仅 admin);V8 迁移(审批开关默认开/撤试用期参数/增基数开关)。定点:calc 20 / settlements 13(含 #12 开关两档金标准 6.84→24.34)/ businesses 30(保存侧+3)/ formula 35。e2e calc_settlement_flow 交接并行会话 |
| 2026-10-19 | **报表统计修复回归** | 全量 847 | ✅ 847 passed(10:13) | **M4 报表 code-review 15 项修复后全量回归**(后端 8 + 前端 7):归属三集合/调整单全量并入/经理个人户拆分/对账出参、分块签字稿导出、导出审计覆盖取数、筛选页眉 ID→名称、月份校验 400、岗位只读选项。报表集成 24 / 报表单元 16 / 月份校验 17。type-check + build 绿;e2e `report_flow.py` 扩至 12 步,待 `E2E_ADMIN_PASSWORD` 实跑 |
| 2026-10-19 | 全部 | 852 | ✅ 852 passed(9:17) | **M4 ⑧ 工作台完成**(四区块实时聚合 + ECharts 5.6 前端 + V10 预警参数三项;新增 5 例;参数注册表 19→22;同步模块显式降级)。期间修复:alembic `--sql` 导出 GBK 导致 schema 同步误报 |
| 2026-10-19 | **code-review 修复回归** | 全量 862 | ✅ 862 passed(9:36) | **M4 工作台 + 报表 3 必须 + 8 建议修复**:普通用户落地页/守卫(报表集成 27、工作台集成 11、报表单元 17,新增 10 例);部门汇总 5% 按计入次数累计并与月结 `total_payable` 对拍、聚合行舍入差;工作台待办按角色过滤、逾期命中与阈值生效、终止业务排除、`warnings.total` 截断、趋势跨月归属。ruff 绿;type-check + build 绿 |
| 2026-10-20 | e2e 全链路 | — | ✅ login 8 / system 13 / role_home 4 / report 12 / calc_settlement 8 | 重启后端加载新代码后实跑:普通用户落地页与受限页回退、报表四页/导出/打印、核算与月结链路全过。`report_flow` 回款统计改为展开目标桶后按本次业务编号定位明细,不依赖桶累计值,可重复运行 |
| 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-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 | 部门汇总导出/打印只展平成员行 | 团队分成、全公司 5% 块、未分配人员、零成员团队全部缺失——**签字稿合计小于应发**,财务按签字稿复核会漏发经理分成与未分配绩效 | 导出改为单 sheet 分块签字稿(团队/公司/对账/成员/未分配)+ 打印五块;应发公式与对账块由后端 Decimal 计算 |
| 2026-10-19 | `_member_rows` 直接 `member_map[pid]` 索引 | 无台账行的营销经理自身挂上级时 KeyError → **部门汇总接口与导出 500**(当月完全无台账也会触发) | 归属只从 member_map/adjustments 生成;`manager_role` 覆盖所有成员引用的上级 |
| 2026-10-19 | 生效调整单只并入有台账人员 | 只有调整单、当月无回款的人被静默丢弃——补发/扣减金额不出现,**按报表发放会少发** | 调整单人员补零值成员后参与团队/未分配归属;每人调整净额只计一次 |
| 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 |
| 2026-10-19 | 导出取数与超限校验在审计上下文之外 | 超限 400 与取数异常**不留失败日志**,与 M4 计划 T12「导出 400/失败审计」不符 | `_export_payload` 移入 `with audit`;静态标题映射(AuditRecord frozen) |
| 2026-10-19 | 月份只校验长度 | `2026-13` 传到 `calendar.monthrange` 抛 ValueError → **500**;导出手工构造模型抛裸 ValidationError 同样 500 | 新增 `core/month.py`(格式+01-12+年份);四类 query、月结、调整单接入;导出 ValidationError 转 400 |
| 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 |
| 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 |
| 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 |
| 2026-10-19 | 工作台待办未按角色过滤 | 财务看得到「待领导审核」、领导看得到「待财务复核」,与 FR-034「仅看到与自己职责相关」不符 | `pending_statuses_for_roles` 按角色映射在途节点;repository 参数化 statuses;admin 保留全部在途视角 |
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
@ -161,6 +230,22 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-15 | 先款后货(payment_days 为负)计提比例线性外插 | 比例 = 起始 + \|天数\|×斜率,**高于起始值甚至超 100%**——M3 接入真实回款后直接多算绩效工资(预览被 schema 拦住、回款登记不校验日期先后,缺陷在 M3 前不可见) | `salesperson_rate` 钳制 days≥0(按起始比例计提;payment_days 出参仍照实输出负值,口径经用户 2026-10-15 裁决) |
| 2026-10-15 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 |
| 2026-10-16 | **logo 上传同扩展名覆盖时先写后删** | `save_logo` 在写入新文件**之后**清理旧扩展名——同扩展名重传时把刚落盘的新文件一并删掉,回读 404(首次单跑通过、集成连跑即暴露,依赖「库里已有同扩展名旧记录」的前置状态) | 清理旧扩展名移到写新文件**之前**且仅异扩展名执行;集成连跑两遍幂等锁定 |
| 2026-10-16 | **public 端点 300s 响应缓存让登录页拿旧公司名** | 公司名保存后退出登录,登录页命中浏览器缓存副本仍显示旧品牌名(设置页「保存后生效」承诺失效)——**由 settings_flow e2e 首跑暴露**(新标签页直接打开正常、退出回跳的登录页异常) | 登录页改用带时间戳的原生 `fetch` + `cache: no-store` 绕过磁盘缓存(axios 对响应 max-age 无能为力);手动解包统一信封 |
| 2026-10-16 | V6 迁移 downgrade 保留 autogenerate 的逐个 `drop_index` 且漏删行级种子 | 外键唯一支撑索引单独删除报 MySQL 1553 回滚中断;roles/param_definitions/param_values 的行级种子(表由 V1/V3 建)drop_table 不会带走——重放 upgrade 时唯一键冲突 | downgrade 只保留 `drop_table`(V1/V3 先例)+ 三条 DELETE 反向清理行级种子;downgrade→upgrade 往返实测通过 |
| 2026-10-19 | **经理团队聚合分成放大 100 倍**(settlement_service) | 参数按百分数存储(12.5=12.5%),`parse_fraction` 后调用方又 ×100、`manager_team_share` 内部再 ÷100——净效应「绩效×12.5」;单元测试直传 Fraction 未覆盖调用方换算使缺陷存活 | 去掉调用方 ×100;`rate_text` 改用实际参数原文(原硬编码默认值失真);集成用例两档金标准(6.84/9.78 vs 错误值 684.43)锁定 |
| 2026-10-19 | **审批联动不判终审**(approval_service.act) | 财务节点通过(1→2)即 mark_locked 月结/调整单置生效——领导驳回后 locked_at/locked_by 残留、两级审批形同虚设(锁定后源数据写路径立即被拦) | 生效类联动收敛到 `final_approved`(终态才触发);驳回任意节点即时;mark_rejected 补清锁定字段;用例断言财务通过后仍 status=2 且无锁定信息 |
| 2026-10-19 | **调整单创建完全不可用**(两处叠加) | ① `ENABLE_CODE_BY_TYPE` 漏登记 type=3 → is_enabled 恒 400「未知的审批对象类型」;② 修复①后暴露:自动生效分支手工插 `ApprovalStep(order_id=调整单id)`,而该外键指向 approval_orders,必然 errno 1452 | 补登记 `approval.adjustment.enabled`;自动通过留痕统一交 `submit_order`(不再手工插步);新建调整单集成用例锁定 |
| 2026-10-19 | **重算绕过业务级一次性计提防线** | 「进度登记」回款(#25 第二笔及以后)无独立台账行,重算会凭空落一行生效行——同业务两行生效行、绩效按两倍计入工资 | 无旧版本行一律 400「进度登记无可重算」;用例锁定「重算后同业务仍只有一行生效行」 |
| 2026-10-19 | **前端重算传错 ID**(台账页) | 把台账行主键当 receipt_id 传给重算接口——会作废并重算**无关回款**的台账(若目标回款已核算),或报错无法重算目标行 | 改传 `row.receipt_id`(聚合行 receipt_id 空值防御);`LedgerRow` 本含该字段 |
| 2026-10-19 | **quantize 缺省银行家舍入**(9 处金额固化) | docstring 声称 HALF_UP,实际未传 rounding 走 ROUND_HALF_EVEN——x.xx5 舍成偶数尾数,与业务四舍五入口径差 1 分(台账固化金额比应发少 1 分) | 9 处显式 `rounding=ROUND_HALF_UP`;`_q2` 提为模块级并单测锁定(2.345→2.35) |
| 2026-10-19 | **月结锁定拦截只覆盖 1/4 写路径** | 仅新增回款被拦;回款编辑(可跨月搬数)/删除、发货增/改/删、核算与重算全部放行——锁定月份快照与源数据脱钩且无留痕 | 五处写路径补齐 `ensure_month_not_locked`(编辑按原月+新月双查);拦截先于「被引用/已核算」判定;三用例锁定 |
| 2026-10-19 | **台账 settlement_id 回填从未发生**(死代码) | `update_ledger_settlement_id` 全库零调用——「未入结」判据恒真,重生成会把已固化行反复纳入(或补上回填却不先解绑导致漏算整月) | generate 回填 + 重生成前解绑(新增 `clear_ledger_settlement_id`);用例断言回填与重生成金额不翻倍 |
| 2026-10-19 | **经理聚合行从不落库** | `_build_manager_aggregates` 只构造内存对象参与 items 汇总——台账表永远无 calc_type=2 行,报表下钻(business_id 锚点)落空 | 聚合行经 repository 落库并回填 settlement_id;重生成先作废旧聚合行(`deactivate_month_aggregates`);`_confirm_scope` 限定 calc_type=1 防聚合行混入单笔池 |
| 2026-10-19 | **月结取参用生成日**(date.today) | 补生成历史月份时经理分成/月工资口径/试用期阈值按生成日参数计算——同一月份两次生成金额不同,违背「参数按生效日期时点化」红线 | 新增 `_month_end`(所属月月末)为聚合与月工资口径取参锚点;单测 5 例 + 集成用例(10-25 预约变更影响 10 月聚合) |
| 2026-10-19 | **迁移双轨三处漂移** | ① V6 就地修改加入 uk 致旧库补不上(V8 也未补)→ V9 条件式补齐(幂等裸 SQL,临时库验证两分支);② V7 留存 SQL 约束/索引名单数(`fk_performance_ledger_*`)与 alembic op.f/init-dev 漂移;③ V8 迁移 revision 缺类型注解,`test_schema_sync` 的 head 推断**静默漏检 V8**、init-dev 版本号落后未被发现 | V9 迁移 + V7 SQL 名修正 + V8 注解修正 + init-dev 版本推进;schema 同步测试恢复全量断言 |
| 2026-10-19 | 运维脚本两处(restore_drill/health_check) | ① 行数核对把查询失败回显 `'?'` 当数据比较(`'?'=='?'` 判 ✓)且 TABLES 缺 V7 四表等 16 张——备份漏表时演练全绿误报「可恢复」;② `redis-cli -a "$REDIS_PASSWORD"` 密码进宿主进程命令行 | TABLES 补全 32 张业务表 + `'?'` 一律判 ✗;Redis 探活改容器内环境变量展开(compose 补 REDIS_PASSWORD),实测 PONG |
| 2026-10-19 | 调整单金额无校验 / INT 参数原文 `int()` / logo 清理裸抛 | ① amount 负数反转补发-扣减语义、'abc' 直落库 500;② `int('360.0')` 使整月核算 500(INT 参数合法入库原文);③ 旧 logo 清理 OSError 裸抛把成功更新记成失败审计并 500 | schema 正则 + 服务层 Decimal>0;`int(parse_fraction(...))`;清理包 try 仅 warning;各补回归用例 |
## 五、待补用例
@ -168,7 +253,8 @@ uv run pytest tests/integration -q # 仅集成
| --- | --- | --- |
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
| 报表统计 | 未开始 | 依赖报表口径确认 |
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例;2026-10-20 e2e `calc_settlement_flow` 8/8 实跑通过(台账/月结真实浏览器链路) |
| 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;2026-10-20 e2e `report_flow` 12/12 实跑通过,全量 862 passed |
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。

View File

@ -29,25 +29,31 @@ from app.core.constants import ROLE_ADMIN, ROLE_USER
from app.core.db import get_session_factory
from app.core.security import create_access_token, hash_password
from app.models import (
AdjustmentOrder,
ApprovalOrder,
Business,
BusinessItem,
BusinessRole,
BusinessStatusLog,
Customer,
CustomerRating,
CustomerRatingGrade,
MonthlySettlement,
OperationLog,
OwnershipChangeLog,
ParamChangeBatch,
ParamValue,
PerformanceLedger,
Personnel,
PersonnelPlan,
PersonnelRegion,
Product,
Receipt,
Role,
SettlementItem,
Shipment,
ShipmentItem,
SystemSetting,
User,
UserRole,
)
@ -95,6 +101,14 @@ def _purge_test_data() -> None:
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
session.execute(delete(Role).where(Role.id.in_(role_ids)))
# ---- M3-core:核算台账 / 月结 / 调整单(必须先于回款/业务/人员删除——
# ledger.receipt_id/personnel_id/settlement_id 外键 RESTRICT 会挡住后面
# 的单据与人员清理;仅测试写入,全量清)----
session.execute(delete(PerformanceLedger))
session.execute(delete(SettlementItem))
session.execute(delete(MonthlySettlement))
session.execute(delete(AdjustmentOrder).where(AdjustmentOrder.order_no.like("TZ%")))
# ---- M2 业务数据(T11):业务/发货/回款三类单据 ----
# 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前
# 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货
@ -150,6 +164,10 @@ def _purge_test_data() -> None:
session.execute(
delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids))
)
# 评级行 FK RESTRICT:必须先于客户删除(V7 起客户评级每客户一行)
session.execute(
delete(CustomerRating).where(CustomerRating.customer_id.in_(customer_ids))
)
session.execute(delete(Customer).where(Customer.id.in_(customer_ids)))
# 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条
session.execute(
@ -172,23 +190,107 @@ def _purge_test_data() -> None:
# 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删
session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%")))
# ---- M3:审批单 / 二次分配 / 参数开关基线 ----
# ---- M3:审批单 / 二次分配 / 参数开关 / 系统设置基线 ----
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 21
# (V3 十九条 + V6 两条),删新条目与新批次后解析回退到种子值(开关=0)
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 21)).scalars())
if stray_values:
stray_batches = {v.batch_id for v in stray_values}
session.execute(delete(ParamValue).where(ParamValue.id > 21))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
# 开关参数恢复种子基线:测试提交的变更批次由 t_ 前缀测试账号创建,
# 按**批次操作人**删除本轮的参数条目与批次,解析后回退到种子值(开关=0)。
# 不用 id 阈值:种子条目的主键在不同环境的漂移库中并不相同(历史库
# 可能补过种子行),按操作人识别测试产物与环境数据分布无关。
test_batch_ids = list(
session.execute(
select(ParamChangeBatch.id).where(ParamChangeBatch.operator_name.like("t\\_%"))
).scalars()
)
if test_batch_ids:
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(test_batch_ids)))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(test_batch_ids)))
session.commit()
# ==================== 系统设置与上传文件的快照还原 ====================
#
# 系统设置是**真实单行数据**,logo/favicon 是真实上传文件,没有 t_ 前缀可供
# 兜底清理。测试(尤其 logo 上传用例)会改公司名、覆盖/删除真实图片 ——
# 必须在用例结束后还原,否则跑一次测试就会破坏开发环境的品牌与 logo。
_ASSET_KINDS = ("logo", "favicon")
def _snapshot_company_info() -> dict | None:
"""快照系统设置单行(无行时返回 None)。"""
factory = get_session_factory()
with factory() as session:
row = session.get(SystemSetting, 1)
if row is None:
return None
return {column.name: getattr(row, column.name) for column in SystemSetting.__table__.columns}
def _restore_company_info(snapshot: dict | None) -> None:
if snapshot is None:
return
factory = get_session_factory()
with factory() as session:
row = session.get(SystemSetting, 1)
if row is None:
return
for key, value in snapshot.items():
if key != "update_time": # 更新时间由数据库 ON UPDATE 维护
setattr(row, key, value)
session.commit()
def _snapshot_assets() -> dict[str, dict[str, bytes]]:
"""快照上传目录里的真实图片(logo/favicon)。"""
from app.core.config import get_settings
root = get_settings().upload_dir
snapshot: dict[str, dict[str, bytes]] = {}
for kind in _ASSET_KINDS:
directory = root / kind
snapshot[kind] = (
{path.name: path.read_bytes() for path in directory.glob("*") if path.is_file()}
if directory.is_dir()
else {}
)
return snapshot
def _restore_assets(snapshot: dict[str, dict[str, bytes]]) -> None:
"""还原上传目录:删除测试新增文件、回写被测试覆盖的原文件。"""
from app.core.config import get_settings
root = get_settings().upload_dir
for kind, files in snapshot.items():
directory = root / kind
directory.mkdir(parents=True, exist_ok=True)
for path in directory.glob("*"):
if path.is_file() and path.name not in files:
path.unlink(missing_ok=True)
for name, content in files.items():
(directory / name).write_bytes(content)
@pytest.fixture(autouse=True)
def _purge_after_each_test():
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。"""
def _preserve_company_info():
"""每个用例前快照系统设置与上传图片,结束后还原(见上方说明)。"""
info = _snapshot_company_info()
assets = _snapshot_assets()
yield
# 先还原文件:数据库路径随后回指同一批文件
_restore_assets(assets)
_restore_company_info(info)
@pytest.fixture(autouse=True)
def _purge_after_each_test(_preserve_company_info):
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。
依赖 ``_preserve_company_info``:保证快照在该夹具之前建立、在该夹具
清理**之后**还原(夹具 teardown 为逆序)。
"""
yield
_purge_test_data()

View File

@ -129,11 +129,12 @@ def switched_on(client: TestClient, admin: dict) -> None:
class TestSwitchOff:
"""开关关闭(种子默认):提单即自动通过。"""
"""开关关闭(用例显式设置,不依赖种子默认值):提单即自动通过。"""
def test_创建带方案_自动通过全节点留痕(
self, client: TestClient, admin: dict, finance: dict
) -> None:
_set_switch(client, admin["headers"], "0") # V8 起种子默认开启
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
@ -261,9 +262,28 @@ class TestSwitchOn:
)
assert resp.status_code == 400
def test_驳回后方案行清空_不留在生效表(
self, client: TestClient, admin: dict, finance, switched_on
) -> None:
"""被否决的方案不得以「当前生效行」身份留在表里(M3 快照会取到)。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 2, "opinion": "方案不合理"},
headers=finance["headers"],
)
assert resp.status_code == 200
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert detail["secondary_allocations"] == [], "被驳回的方案应从生效表清空"
assert detail["allocation_hint"] is None
class TestResubmit:
"""再编辑撤旧建新。"""
"""再编辑撤旧建新 / 清空方案撤在途单。"""
def test_在途单撤回并新建(
self, client: TestClient, admin: dict, finance, leader, switched_on
@ -293,6 +313,64 @@ class TestResubmit:
withdraw_steps = [s for s in old_detail["data"]["steps"] if s["action"] == 4]
assert withdraw_steps and withdraw_steps[0]["node"] == 0
def test_编辑清空方案_在途单撤回不再悬空(
self, client: TestClient, admin: dict, finance, switched_on
) -> None:
"""空数组=清空方案:旧在途单必须撤回,否则挂着不存在的方案走审批。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
assert first["status"] == 1
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = business_payload(detail["customer_id"], secondary_allocations=[])
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"], "pageSize": 10},
headers=finance["headers"],
).json()["data"]["list"]
statuses = {row["status"] for row in rows}
assert statuses == {5}, f"清空方案后旧在途单应撤回且无新单:{statuses}"
assert (
client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"][
"status"
]
== 5
)
def test_删除业务在途单禁删_终态单随删(
self, client: TestClient, admin: dict, finance, switched_on
) -> None:
"""ref_id 固化无外键:在途单禁删(防指向已删业务的活单继续被审批),
终态单随业务删除一并清理(已通过/已驳回的历史事实由留痕日志承载)。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
# 在途:禁删
resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"])
assert resp.status_code == 400
assert "在途审批单" in resp.json()["message"]
# 驳回(终态)后:可删,且审批单随之清理
order = _latest_order(client, finance["headers"], data["business_no"])
client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 2, "opinion": "驳回后可删"},
headers=finance["headers"],
)
resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"])
assert resp.status_code == 200, resp.text
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"]},
headers=finance["headers"],
).json()["data"]["list"]
assert rows == [], "终态审批单应随业务删除一并清理"
class TestModuleAccess:
"""模块准入:普通用户与营销后勤不可见(权限清单待澄清 #6)。"""

View File

@ -147,9 +147,18 @@ class TestBuiltinProtection:
class TestPermission:
"""权限:普通用户全部 403(当前默认管理员专用)。"""
"""权限:管理接口普通用户 403;只读选项登录即可(报表筛选依赖)。"""
def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
assert client.get(_ROLES, headers=headers).status_code == 403
def test_普通用户可读取岗位选项(self, client: TestClient, make_user, auth_headers) -> None:
"""财务/领导做报表岗位筛选时不能因管理员接口 403 而失去筛选能力。"""
user = make_user([ROLE_USER])
headers = auth_headers(user)
resp = client.get("/api/v1/base/business-roles/options", headers=headers)
assert resp.status_code == 200, resp.text
codes = {row["role_code"] for row in resp.json()["data"]}
assert {"salesperson", "sales_manager", "info_officer"} <= codes

View File

@ -412,6 +412,71 @@ class TestEdit:
assert len(detail["items"]) == 1
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
def test_二次分配方案合计超100_保存400(self, client: TestClient, normal: dict) -> None:
"""口径(澄清 #1/#2 答复 2026-10-19):分配绩效、余额归合同负责人——
超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。"""
headers = normal["headers"]
customer = _customer(client, headers)
owner = _personnel(client, headers)
part = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
resp = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(
customer["id"],
secondary_allocations=[
{"personnel_id": part["id"], "ratio": "60"},
{"personnel_id": _personnel(client, headers)["id"], "ratio": "50"},
],
),
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "超过 100%" in resp.json()["message"]
def test_二次分配参与人含负责人_保存400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
owner = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
resp = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(
customer["id"],
secondary_allocations=[{"personnel_id": owner["id"], "ratio": "30"}],
),
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "含合同负责营销人员" in resp.json()["message"]
def test_二次分配方案合法比例_保存成功且回显(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
owner = _personnel(client, headers)
part = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
resp = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(
customer["id"],
secondary_allocations=[{"personnel_id": part["id"], "ratio": "40"}],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
# 方案在详情回包(更新回包为简版 Out,不含方案区)
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
assert len(detail["secondary_allocations"]) == 1
assert Decimal(detail["secondary_allocations"][0]["ratio"]) == Decimal("40")
# 余额 60% 归合同负责人(hint 文案 = #2 答复口径)
assert detail["allocation_hint"] is not None and "60" in detail["allocation_hint"]
class TestStatus:
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""

View File

@ -0,0 +1,790 @@
"""单笔回款核算集成测试(M3 ④:金标准复算到分)。
金标准场景(双类别混合业务,覆盖行级折算与汇总):
- 行1 市政水利:100×100.00 = 销售 10000,成本 3000 → 毛利率 0.7,阈值 30%
→ 折算 10000×(0.7−0.3) = 4000
- 行2 轨道交通:50×200.00 = 销售 10000,成本 8000 → 毛利率 0.2,阈值 20%
→ 折算 10000×(0.2−0.2) = 0
- 折算合计 4000;发货 10-01 → 回款 10-20,days=20
- 率 = (10 − 20×4/360)‰ = 88/9000;绩效 = 4000×88/9000 ≈ 39.11
- 回款 20000(=销售额 100%)→ R=100、权重 1 → 应付 = 39.11
守的还有:核算粒度 #25(业务级一次性计提——第二笔回款仅登记进度)、
已核算再核算 400、未指定批次 400、成本缺失行毛利按 0 计、负绩效照算、
准入(normal 403 / marketing_ops 可用)。
"""
import uuid
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import PerformanceLedger, Personnel, Receipt, SecondaryAllocation
from app.models.receipt import SETTLEMENT_DONE
from tests.integration.factories import business_payload, customer
pytestmark = pytest.mark.integration
_CALC = "/api/v1/calc"
_BUSINESSES = "/api/v1/business/businesses"
_SHIPMENTS = "/api/v1/business/shipments"
_RECEIPTS = "/api/v1/business/receipts"
_PERSONNEL = "/api/v1/base/personnel"
def _person(client: TestClient, headers: dict) -> dict:
resp = client.post(
_PERSONNEL,
json={
"personnel_name": f"t_核算_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id("salesperson"),
"salary_standard": "3000.00",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _role_id(code: str) -> int:
from sqlalchemy import select
from app.core.constants import BUSINESS_ROLE_SALESPERSON
from app.core.db import get_session_factory
from app.models import BusinessRole
factory = get_session_factory()
with factory() as session:
role_code = BUSINESS_ROLE_SALESPERSON if code == "salesperson" else code
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == role_code, BusinessRole.status == 1
)
).scalar_one()
)
def _create_full_chain(
client: TestClient,
headers: dict,
*,
items: list[dict],
ship_date: str,
receipt_date: str,
amount: str,
) -> tuple[dict, dict]:
"""客户 → 人员 → 业务(双行明细+归属)→ 发货 → 回款(指定批次)。"""
cust = customer(client, headers)
person = _person(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(cust["id"], owner_personnel_id=person["id"], items=items),
headers=headers,
)
assert resp.status_code == 200, resp.text
biz = resp.json()["data"]
resp = client.post(
_SHIPMENTS,
json={
"business_id": biz["id"],
"ship_date": ship_date,
"items": [{"product_name": "t_发货物", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
shipment = resp.json()["data"]
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": receipt_date,
"amount": amount,
"shipment_id": shipment["id"],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
receipt = resp.json()["data"]
return biz, receipt
def _calc(client: TestClient, headers: dict, receipt_id: int):
return client.post(f"{_CALC}/receipts/{receipt_id}/calc", headers=headers)
@pytest.fixture
def finance(make_user, auth_headers) -> dict:
"""财务角色账号(重算授权角色之一)。"""
user = make_user(["finance"])
user["headers"] = auth_headers(user)
return user
_GOLD_ITEMS = [
{
"product_name": "t_市政_金标",
"product_category": "municipal_water",
"quantity": "100",
"sale_price": "100.00",
"cost_amount": "3000.00",
},
{
"product_name": "t_轨交_金标",
"product_category": "rail_transit",
"quantity": "50",
"sale_price": "200.00",
"cost_amount": "8000.00",
},
]
class TestGoldenStandard:
"""金标准:双类别混合业务复算到分(Decimal 精确断言)。"""
def test_复算到分_全字段固化(self, client: TestClient, admin: dict) -> None:
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["repeated"] is False
ledger = out["ledger"]
assert ledger["payment_days"] == 20
assert ledger["settlement_month"] == "2026-10"
# 率 = (10 − 20×4/360)‰ = 88/9000,Fraction 约分原文 11/1125
assert ledger["rate_text"] == "11/1125"
assert ledger["weight_r"] == 100
assert ledger["missing_cost"] == 0
assert ledger["snapshot_json"]["line_details"][0]["converted"] == "4000.00"
# 金额锚定 DB 事实源(NUMERIC(14,2) 读回恒两位;响应 JSON 的 Decimal
# 序列化 scale 不作断言——归前端展示问题)
factory = get_session_factory()
with factory() as session:
row = session.execute(
select(PerformanceLedger).where(
PerformanceLedger.receipt_id == receipt["id"],
PerformanceLedger.is_active == 1,
)
).scalar_one()
assert row.converted_profit == Decimal("4000.00")
# 绩效 = 4000 × 88/9000 = 39.111… → 落库两位 39.11
assert row.performance_amount == Decimal("39.11")
# 应付 = 绩效 × 权重 1 = 39.11
assert row.payable_amount == Decimal("39.11")
# 回款状态被 CAS 置已核算
detail = client.get(f"{_RECEIPTS}/{receipt['id']}", headers=admin["headers"]).json()["data"]
assert detail["settlement_status"] == 1
def test_已核算回款再核算400(self, client: TestClient, admin: dict) -> None:
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="100.00",
)
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 400
assert "已核算" in resp.json()["message"]
def test_业务级一次性计提_第二笔仅登记进度(self, client: TestClient, admin: dict) -> None:
"""核算粒度 #25 一期口径:同业务第二笔回款不重复计提。"""
biz, first = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="10000.00",
)
assert _calc(client, admin["headers"], first["id"]).status_code == 200
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-10-25",
"amount": "5000.00",
"shipment_id": None,
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
second = resp.json()["data"]
resp = _calc(client, admin["headers"], second["id"])
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["repeated"] is True
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.business_id == biz["id"],
PerformanceLedger.is_active == 1,
)
)
.scalars()
.all()
)
assert len(rows) == 1, "第二笔回款不得新增台账行(一次性计提)"
receipts = (
session.execute(select(Receipt).where(Receipt.business_id == biz["id"]))
.scalars()
.all()
)
assert all(r.settlement_status == SETTLEMENT_DONE for r in receipts)
class TestGuards:
"""校验链与策略点。"""
def test_未指定批次400(self, client: TestClient, admin: dict) -> None:
cust = customer(client, admin["headers"])
person = _person(client, admin["headers"])
resp = client.post(
_BUSINESSES,
json=business_payload(cust["id"], owner_personnel_id=person["id"]),
headers=admin["headers"],
)
assert resp.status_code == 200
biz = resp.json()["data"]
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-10-20",
"amount": "100.00",
},
headers=admin["headers"],
)
assert resp.status_code == 200
receipt = resp.json()["data"]
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 400
assert "发货批次" in resp.json()["message"]
def test_成本缺失行_毛利按0计并标记(self, client: TestClient, admin: dict) -> None:
items = [
{
"product_name": "t_无成本_市政",
"product_category": "municipal_water",
"quantity": "100",
"sale_price": "100.00",
"cost_amount": None,
}
]
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=items,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="10000.00",
)
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 200, resp.text
ledger = resp.json()["data"]["ledger"]
# 成本缺失 → 毛利按 0 → 折算 = 销售额×(0−30%) = **−3000**(保守少算
# 方向:缺成本的行宁少算不多发),missing_cost=1
assert ledger["missing_cost"] == 1
assert float(ledger["converted_profit"]) == -3000.0
assert float(ledger["performance_amount"]) == pytest.approx(-29.33, abs=0.01)
def test_负绩效照算不归零(self, client: TestClient, admin: dict) -> None:
items = [
{
"product_name": "t_亏损_市政",
"product_category": "municipal_water",
"quantity": "100",
"sale_price": "100.00",
"cost_amount": "9000.00",
}
]
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=items,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="10000.00",
)
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 200, resp.text
ledger = resp.json()["data"]["ledger"]
# 毛利 1000、率 0.1 < 阈值 30% → 折算 10000×(0.1−0.3) = −2000 → 负绩效
assert float(ledger["converted_profit"]) == -2000.0
assert float(ledger["performance_amount"]) < 0
def test_准入_normal403_marketing_ops可用(
self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers
) -> None:
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="100.00",
)
resp = _calc(client, normal["headers"], receipt["id"])
assert resp.status_code == 403
ops = make_user(["marketing_ops"])
ops["headers"] = auth_headers(ops)
resp = _calc(client, ops["headers"], receipt["id"])
assert resp.status_code == 200, resp.text
def test_分界天数参数小数原文_核算不500(self, client: TestClient, admin: dict) -> None:
"""守 int(参数原文) 缺陷:INT 参数入库允许 '360.0'(Fraction 分母为 1),
直接 int(str) 抛 ValueError 会让整月核算 500。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": "用例:INT 参数小数原文",
"items": [
{
"param_code": "salesperson.days.boundary",
"dimension_value": "salesperson",
"value": "360.0",
}
],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 200, resp.text
# 分界取整无损:20 天 < 360 天 → 同一递减公式
assert resp.json()["data"]["ledger"]["rate_text"] == "11/1125"
def test_台账列表与筛选(self, client: TestClient, admin: dict) -> None:
biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="100.00",
)
_calc(client, admin["headers"], receipt["id"])
resp = client.get(
f"{_CALC}/ledger",
params={"settlement_month": "2026-10", "is_active": 1},
headers=admin["headers"],
)
assert resp.status_code == 200
rows = resp.json()["data"]["list"]
assert rows and rows[0]["business_no"] == biz["business_no"]
class TestRecalc:
"""重算:授权动作,作废旧版本、落新版本并留痕。"""
def test_重算_新版本生效_旧版本保留(
self, client: TestClient, admin: dict, finance: dict
) -> None:
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
fin = finance
resp = client.post(
f"{_CALC}/receipts/{receipt['id']}/recalc",
json={"reason": "成本录入修正"},
headers=fin["headers"],
)
assert resp.status_code == 200, resp.text
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger)
.where(PerformanceLedger.receipt_id == receipt["id"])
.order_by(PerformanceLedger.calc_version)
)
.scalars()
.all()
)
assert [(r.calc_version, r.is_active) for r in rows] == [(1, 0), (2, 1)]
assert rows[1].calc_reason == "成本录入修正"
# 详情回显的是新版本
assert (
client.get(
f"{_CALC}/ledger",
params={"receipt_id": receipt["id"], "is_active": 1},
headers=admin["headers"],
).json()["data"]["list"][0]["calc_version"]
== 2
)
def test_重算_原因缺失400(self, client: TestClient, admin: dict) -> None:
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="100.00",
)
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
resp = client.post(
f"{_CALC}/receipts/{receipt['id']}/recalc",
json={"reason": " "},
headers=admin["headers"],
)
assert resp.status_code == 400
def test_未核算过_重算400(self, client: TestClient, admin: dict) -> None:
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="100.00",
)
resp = client.post(
f"{_CALC}/receipts/{receipt['id']}/recalc",
json={"reason": "无旧版本"},
headers=admin["headers"],
)
assert resp.status_code == 400
assert "尚未核算" in resp.json()["message"]
def test_进度登记回款重算400_不得凭空落行(self, client: TestClient, admin: dict) -> None:
"""业务级一次性计提的第二笔回款无台账行;重算若落新行会双重计提。"""
biz, first = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
assert _calc(client, admin["headers"], first["id"]).status_code == 200
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-10-25",
"amount": "100.00",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
second = resp.json()["data"]
assert _calc(client, admin["headers"], second["id"]).json()["data"]["repeated"] is True
resp = client.post(
f"{_CALC}/receipts/{second['id']}/recalc",
json={"reason": "尝试重算进度登记"},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "进度登记" in resp.json()["message"]
# 同业务仍只有一行生效台账(未凭空新增)
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.business_id == biz["id"],
PerformanceLedger.is_active == 1,
)
)
.scalars()
.all()
)
assert len(rows) == 1
def test_重算_普通用户与营销后勤403(
self, client: TestClient, normal: dict, make_user, auth_headers
) -> None:
ops = make_user(["marketing_ops"])
ops["headers"] = auth_headers(ops)
for headers in (normal["headers"], ops["headers"]):
resp = client.post(f"{_CALC}/receipts/1/recalc", json={"reason": "x"}, headers=headers)
assert resp.status_code == 403
class TestBatch:
"""批量核算:逐笔独立事务,失败不中断。"""
def test_批量_逐笔成败清单(self, client: TestClient, admin: dict) -> None:
biz, r1 = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
# 同业务第二笔回款:核算走 #25 重复分支(ok=True、repeated=True)
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-10-25",
"amount": "100.00",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
r2 = resp.json()["data"]
resp = client.post(
f"{_CALC}/batch",
json={"receipt_ids": [r1["id"], r2["id"], 999999]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
results = resp.json()["data"]
by_id = {r["receipt_id"]: r for r in results}
assert by_id[r1["id"]]["ok"] is True and by_id[r1["id"]]["repeated"] is False
assert by_id[r2["id"]]["ok"] is True and by_id[r2["id"]]["repeated"] is True
assert by_id[999999]["ok"] is False
class TestSecondaryAllocation:
"""二次分摊拆行(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
方案行直插入库(跳过保存侧校验,聚焦核算引擎;保存侧校验用例在
test_businesses)。金标准:双类别混合业务绩效 39.11 / 折算 4000.00,
拆 30%+40% 后参与人 11.73/15.64、负责人余额 11.74,行合计守恒。
"""
def _add_plan(self, business_id: int, rows: list[tuple[int, str]]) -> None:
"""直插方案行(测试隔离造数;核算引擎按「当前落库方案」消费)。"""
factory = get_session_factory()
with factory() as session:
for pid, ratio in rows:
person = session.get(Personnel, pid)
session.add(
SecondaryAllocation(
business_id=business_id,
personnel_id=pid,
personnel_name=person.personnel_name,
ratio=Decimal(ratio),
)
)
session.commit()
def test_分摊拆行_参与人比例与负责人余额守恒(self, client: TestClient, admin: dict) -> None:
biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
parts = [_person(client, admin["headers"]) for _ in range(2)]
self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")])
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 200, resp.text
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.receipt_id == receipt["id"],
PerformanceLedger.is_active == 1,
)
)
.scalars()
.all()
)
assert len(rows) == 3, "负责人+两参与人各一行"
by_pid = {r.personnel_id: r for r in rows}
a, b, o = (
by_pid[parts[0]["id"]],
by_pid[parts[1]["id"]],
by_pid[biz["owner_personnel_id"]],
)
# 绩效份额:A=39.11×30%=11.73、B=39.11×40%=15.64、负责人=守恒余额 11.74
assert (a.performance_amount, b.performance_amount, o.performance_amount) == (
Decimal("11.73"),
Decimal("15.64"),
Decimal("11.74"),
)
# 行合计 == 业务级全额(守恒是分摊红线:多一分少一分都是错账)
assert a.payable_amount + b.payable_amount + o.payable_amount == Decimal("39.11")
# 折算额同比例拆分:4000 的 30%/40%/余额
assert (a.converted_profit, b.converted_profit, o.converted_profit) == (
Decimal("1200.00"),
Decimal("1600.00"),
Decimal("1200.00"),
)
# 行级身份固化:参与人各自岗位;快照带方案与角色标签
# (ratio 为 NUMERIC(5,2) 读回两位 scale,断言用数值比较)
assert a.role_code == b.role_code == "salesperson"
assert a.snapshot_json["alloc_role"] == "participant"
assert Decimal(a.snapshot_json["alloc_ratio"]) == Decimal("30")
assert o.snapshot_json["alloc_role"] == "owner"
assert Decimal(o.snapshot_json["alloc_plan"][0]["ratio"]) == Decimal("30")
def test_分摊后第二笔回款仅登记进度(self, client: TestClient, admin: dict) -> None:
"""一次性计提(#25)与分摊叠加:第二笔不新增行、不重复拆分。"""
biz, first = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="10000.00",
)
part = _person(client, admin["headers"])
self._add_plan(biz["id"], [(part["id"], "50")])
assert _calc(client, admin["headers"], first["id"]).status_code == 200
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-10-25",
"amount": "5000.00",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
second = resp.json()["data"]
out = _calc(client, admin["headers"], second["id"]).json()["data"]
assert out["repeated"] is True
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.business_id == biz["id"],
PerformanceLedger.is_active == 1,
)
)
.scalars()
.all()
)
assert len(rows) == 2, "第二笔回款不得新增拆分行(一次性计提)"
def test_重算作废全部拆分行并落新版本(
self, client: TestClient, admin: dict, finance: dict
) -> None:
biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
parts = [_person(client, admin["headers"]) for _ in range(2)]
self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")])
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
resp = client.post(
f"{_CALC}/receipts/{receipt['id']}/recalc",
json={"reason": "用例:分摊重算"},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(PerformanceLedger.receipt_id == receipt["id"])
)
.scalars()
.all()
)
active = [r for r in rows if r.is_active == 1]
inactive = [r for r in rows if r.is_active == 0]
assert len(active) == 3 and len(inactive) == 3
assert {r.calc_version for r in active} == {2}, "新版本三行"
assert {r.calc_version for r in inactive} == {1}, "旧版本三行全部作废"
def test_合计超100核算侧拦截(self, client: TestClient, admin: dict) -> None:
biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="100.00",
)
part = _person(client, admin["headers"])
self._add_plan(biz["id"], [(part["id"], "60")])
# 再直插另一行绕过保存校验(合计 110),核算侧防御必须拦
other = _person(client, admin["headers"])
factory = get_session_factory()
with factory() as session:
person = session.get(Personnel, other["id"])
session.add(
SecondaryAllocation(
business_id=biz["id"],
personnel_id=other["id"],
personnel_name=person.personnel_name,
ratio=Decimal("50"),
)
)
session.commit()
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 400
assert "超过 100%" in resp.json()["message"]
def test_参与人含负责人核算侧拦截(self, client: TestClient, admin: dict) -> None:
biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="100.00",
)
self._add_plan(biz["id"], [(biz["owner_personnel_id"], "30")])
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 400
assert "含合同负责营销人员" in resp.json()["message"]

View File

@ -11,6 +11,7 @@ append-only 完整性;「公司名称+负责人」组合唯一缺失会放行
"""
import uuid
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
@ -376,3 +377,104 @@ class TestAudit:
latest = logs[0]
assert customer["company_name"] in latest.summary, "摘要应含客户名"
assert "→" in latest.summary, "摘要应体现新旧归属走向"
# ==================== 客户评级列(V11) ====================
_RATING_KEYS = (
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
def _rate(client: TestClient, headers: dict, customer_id: int, scores: list[str]) -> dict:
"""保存客户评级(五项 0-20),返回响应 data。"""
resp = client.put(
f"{_CUSTOMERS}/{customer_id}/rating",
json=dict(zip(_RATING_KEYS, scores, strict=True)),
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _list_by_keyword(client: TestClient, headers: dict, keyword: str, **params) -> list[dict]:
query: dict = {"keyword": keyword, "pageNum": 1, "pageSize": 50, **params}
resp = client.get(_CUSTOMERS, params=query, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]["list"]
class TestRatingColumn:
"""客户评级列:列表带出评级快照,列头排序走服务端(未评级恒在末尾)。
列表是服务端分页,排序必须在 SQL 层完成 —— 前端只传
``order_field=rating`` + ``order_type``;MySQL 无 NULLS LAST,
未评级行用布尔键模拟,正序/倒序都在末尾。
"""
def _seed_three(self, client: TestClient, headers: dict) -> tuple[str, dict, dict, dict]:
"""建三名同批次客户:90 分(A)、0 分(D)、未评级。"""
token = uuid.uuid4().hex[:8]
keyword = f"t_公司_{token}"
high = _create_customer(client, headers, company_name=f"{keyword}_甲")
low = _create_customer(client, headers, company_name=f"{keyword}_乙")
none = _create_customer(client, headers, company_name=f"{keyword}_丙")
_rate(client, headers, high["id"], ["18"] * 5)
_rate(client, headers, low["id"], ["0"] * 5)
return keyword, high, low, none
def test_列表带出评级字段(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
keyword, high, low, none = self._seed_three(client, headers)
rows = _list_by_keyword(client, headers, keyword)
by_id = {row["id"]: row for row in rows}
assert Decimal(by_id[high["id"]]["rating_total"]) == Decimal("90")
assert by_id[high["id"]]["rating_grade_name"] == "A"
assert Decimal(by_id[low["id"]]["rating_total"]) == Decimal("0")
assert by_id[low["id"]]["rating_grade_name"] == "D"
assert by_id[none["id"]]["rating_total"] is None
assert by_id[none["id"]]["rating_grade_name"] is None
def test_评级列正序倒序_未评级恒末尾(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
keyword, high, low, none = self._seed_three(client, headers)
desc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="desc")
assert [row["id"] for row in desc] == [high["id"], low["id"], none["id"]]
asc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="asc")
assert [row["id"] for row in asc] == [low["id"], high["id"], none["id"]]
def test_非法排序字段_400(self, client: TestClient, normal: dict) -> None:
"""排序字段是白名单(Literal):不透传给 SQL,非法值 400。"""
resp = client.get(
_CUSTOMERS,
params={"order_field": "company_name", "order_type": "asc"},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_非法排序方向_400(self, client: TestClient, normal: dict) -> None:
resp = client.get(
_CUSTOMERS,
params={"order_field": "rating", "order_type": "sideways"},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_删除客户联动删评级行(self, client: TestClient, normal: dict) -> None:
"""评级行 FK RESTRICT:删除客户须先清评级行,否则 500。"""
headers = normal["headers"]
customer = _create_customer(client, headers)
_rate(client, headers, customer["id"], ["20"] * 5)
resp = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
assert resp.status_code == 200, resp.text
assert (
client.get(f"{_CUSTOMERS}/{customer['id']}/rating", headers=headers).status_code == 404
)

View File

@ -0,0 +1,453 @@
"""工作台集成测试(M4 ⑧)。
守的口径:
- 权限:普通用户 403 且文案「无权访问工作台」
- 总览:绩效合计/人数与台账一致;月份参数非法 400
- 待办:月结提交后出现待审批;回款未指定批次可命中;同步未建时显式降级
- 风险预警:审批超时命中、参数关闭后不产生条目、阈值参数化生效
- 最近动态:来源操作日志(写操作可见)
待办与预警为实时派生(不落表),测试只验证查询结果,不清理派生数据。
"""
import uuid
from datetime import date, datetime, timedelta
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
pytestmark = pytest.mark.integration
_DASHBOARD = "/api/v1/dashboard"
_PERSONNEL = "/api/v1/base/personnel"
_BUSINESSES = "/api/v1/business/businesses"
_SHIPMENTS = "/api/v1/business/shipments"
_RECEIPTS = "/api/v1/business/receipts"
_CALC = "/api/v1/calc"
_SETTLEMENTS = "/api/v1/settlements"
_PARAMS = "/api/v1/system/params"
def _role_id(code: str) -> int:
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import BusinessRole
factory = get_session_factory()
with factory() as session:
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == code, BusinessRole.status == 1
)
).scalar_one()
)
def _person(client: TestClient, headers: dict) -> dict:
resp = client.post(
_PERSONNEL,
json={
"personnel_name": f"t_工作台_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id("salesperson"),
"salary_standard": "3000.00",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _chain(client: TestClient, headers: dict, *, assign_shipment: bool = True) -> dict:
"""客户/业务(100×100,成本 3000)→ 发货(60 天前)→ 回款 → 核算。"""
from tests.integration.factories import business_item, business_payload, customer
cust = customer(client, headers)
person = _person(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=person["id"],
items=[business_item(cost_amount="3000.00")],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
business = resp.json()["data"]
ship_date = (date.today() - timedelta(days=60)).isoformat()
resp = client.post(
_SHIPMENTS,
json={
"business_id": business["id"],
"ship_date": ship_date,
"items": [{"product_name": "t_工作台发货", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
shipment = resp.json()["data"]
payload = {
"business_id": business["id"],
"receipt_date": date.today().isoformat(),
"amount": "10000.00",
}
if assign_shipment:
payload["shipment_id"] = shipment["id"]
resp = client.post(_RECEIPTS, json=payload, headers=headers)
assert resp.status_code == 200, resp.text
receipt = resp.json()["data"]
if assign_shipment:
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
assert resp.status_code == 200, resp.text
return {"business": business, "person": person, "receipt": receipt}
def _month() -> str:
return date.today().strftime("%Y-%m")
def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> dict:
params = {"month": month} if month else {}
resp = client.get(_DASHBOARD, params=params, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def test_权限_普通用户403文案(client: TestClient, normal: dict):
resp = client.get(_DASHBOARD, headers=normal["headers"])
assert resp.status_code == 403
assert resp.json()["message"] == "无权访问工作台"
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
_chain(client, admin["headers"])
data = _dashboard(client, admin["headers"], _month())
from sqlalchemy import func, select
from app.core.db import get_session_factory
from app.models import PerformanceLedger
factory = get_session_factory()
with factory() as session:
expected = Decimal(
str(
session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)
).where(
PerformanceLedger.settlement_month == _month(),
PerformanceLedger.calc_type == 1,
PerformanceLedger.is_active == 1,
)
).scalar_one()
)
)
assert Decimal(str(data["overview"]["performance_total"])) == expected
assert data["overview"]["person_count"] >= 1
assert len(data["overview"]["trend"]) == 6
resp = client.get(_DASHBOARD, params={"month": "2026-13"}, headers=admin["headers"])
assert resp.status_code == 400
def test_待办_待审批与未指定批次与同步降级(client: TestClient, admin: dict):
# 先造一笔已核算业务(月结生成要求有可结算记录),再补一笔未指定批次的回款
_chain(client, admin["headers"], assign_shipment=True)
ctx = _chain(client, admin["headers"], assign_shipment=False)
# 月结提交 → 产生待审批单
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"]
resp = client.post(
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
data = _dashboard(client, admin["headers"])
assert data["todos"]["pending_approval_total"] >= 1
assert any(
item["order_type"] == 2 for item in data["todos"]["pending_approvals"]
)
assert data["todos"]["unassigned_receipt_total"] >= 1
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
# ⑥ 未建:显式降级
assert data["todos"]["sync_available"] is False
def test_预警_审批超时命中且参数关闭后不产生(client: TestClient, admin: dict):
_chain(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
settlement_id = resp.json()["data"]["id"]
client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
# 把待审批单提交时间改到 4 天前(阈值默认 3 天)
from sqlalchemy import update
from app.core.db import get_session_factory
from app.models import ApprovalOrder
factory = get_session_factory()
with factory() as session:
session.execute(
update(ApprovalOrder)
.where(ApprovalOrder.status.in_((1, 2)))
.values(create_time=datetime.now() - timedelta(days=4))
)
session.commit()
data = _dashboard(client, admin["headers"])
assert data["warnings"]["enabled"] is True
assert any(item["code"] == "approval_timeout" for item in data["warnings"]["items"])
# 关闭预警开关 → 不产生条目
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"remark": "e2e 工作台测试:关闭风险预警",
"items": [{"param_code": "warning.enabled", "dimension_value": "", "value": "0"}],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = _dashboard(client, admin["headers"])
assert data["warnings"]["enabled"] is False
assert data["warnings"]["items"] == []
def test_动态_来源操作日志(client: TestClient, admin: dict):
_chain(client, admin["headers"])
data = _dashboard(client, admin["headers"])
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
@pytest.fixture
def finance(make_user, auth_headers) -> dict:
user = make_user(["finance"])
user["headers"] = auth_headers(user)
return user
@pytest.fixture
def leader(make_user, auth_headers) -> dict:
user = make_user(["leader"])
user["headers"] = auth_headers(user)
return user
def _insert_approval(status: int) -> None:
"""直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。"""
from app.core.db import get_session_factory
from app.models import ApprovalOrder
factory = get_session_factory()
with factory() as session:
session.add(
ApprovalOrder(
order_no=f"AP{uuid.uuid4().hex[:12]}",
order_type=2,
ref_id=0,
refer_no="t_工作台",
status=status,
submitter_name="t_admin",
)
)
session.commit()
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"remark": f"t_工作台:{code}",
"items": [{"param_code": code, "dimension_value": "", "value": value}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _shipment_scene(client: TestClient, headers: dict, *, days_ago: int) -> tuple[dict, dict]:
"""造一笔无回款的发货(用于回款逾期预警)。"""
from tests.integration.factories import business_item, business_payload, customer
person = _person(client, headers)
cust = customer(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=person["id"],
items=[business_item(cost_amount="3000.00")],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
business = resp.json()["data"]
ship_date = (date.today() - timedelta(days=days_ago)).isoformat()
resp = client.post(
_SHIPMENTS,
json={
"business_id": business["id"],
"ship_date": ship_date,
"items": [{"product_name": "t_工作台逾期", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return business, resp.json()["data"]
def test_待办_按角色仅显示可操作节点(
client: TestClient, admin: dict, finance: dict, leader: dict, make_user, auth_headers
):
"""财务只看到待财务复核、领导只看到待领导审核;admin 与多角色看并集。"""
_insert_approval(1)
_insert_approval(2)
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
assert _dashboard(client, admin["headers"])["todos"]["pending_approval_total"] == 2
both = make_user(["finance", "leader"])
both["headers"] = auth_headers(both)
assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2
finance_orders = {
item["order_no"] for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
}
leader_orders = {
item["order_no"] for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
}
assert finance_orders.isdisjoint(leader_orders)
def test_待办_财务通过后领导出现待办(
client: TestClient, admin: dict, finance: dict, leader: dict
):
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
_chain(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"]
resp = client.post(
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0]
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 0
resp = client.post(
f"/api/v1/system/approvals/{order['order_id']}/action",
json={"action": 1},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 0
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
def test_预警_回款逾期命中且阈值参数生效(client: TestClient, admin: dict):
"""发货超阈值且无任何回款 → 命中;调大阈值后不再命中。"""
_, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
data = _dashboard(client, admin["headers"])
assert any(
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
for item in data["warnings"]["items"]
), "120 天未回款的发货批次未命中预警"
_set_param(client, admin["headers"], "warning.receipt.overdue.days", "200")
data = _dashboard(client, admin["headers"])
assert not any(
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
for item in data["warnings"]["items"]
), "阈值调大后仍命中,说明参数未生效"
def test_预警_终止业务不再产生回款逾期(client: TestClient, admin: dict):
"""已终止业务不再催收,其未回款发货单不得留在预警列表。"""
business, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
resp = client.put(
f"{_BUSINESSES}/{business['id']}/status",
json={"status": 3, "reason": "t_工作台终止"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = _dashboard(client, admin["headers"])
assert not any(item["object_id"] == shipment["id"] for item in data["warnings"]["items"])
assert data["warnings"]["receipt_overdue_total"] == 0
def test_预警_总数不受展示上限截断(client: TestClient, admin: dict, monkeypatch):
"""items 只展示前 N 条,徽标 total 必须仍是全量命中数。"""
from app.services import dashboard_service
_shipment_scene(client, admin["headers"], days_ago=120)
_shipment_scene(client, admin["headers"], days_ago=120)
monkeypatch.setattr(dashboard_service, "_WARNING_LIMIT", 1)
data = _dashboard(client, admin["headers"])
assert data["warnings"]["total"] == 2
assert len(data["warnings"]["items"]) == 1
assert data["warnings"]["total"] == data["warnings"]["receipt_overdue_total"]
def test_总览_回款趋势按到账月跨月归属(client: TestClient, admin: dict):
"""趋势窗口按 receipt_date 范围过滤,月初/月末不得串月。"""
from tests.integration.factories import business_item, business_payload, customer
person = _person(client, admin["headers"])
cust = customer(client, admin["headers"])
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=person["id"],
items=[business_item(cost_amount="3000.00")],
),
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
business = resp.json()["data"]
first_of_month = date.today().replace(day=1)
previous_last = first_of_month - timedelta(days=1)
for receipt_date in (previous_last, first_of_month):
resp = client.post(
_RECEIPTS,
json={
"business_id": business["id"],
"receipt_date": receipt_date.isoformat(),
"amount": "1000.00",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
trend = {
item["month"]: Decimal(str(item["receipt_total"] or 0))
for item in _dashboard(client, admin["headers"])["overview"]["trend"]
}
assert trend[previous_last.strftime("%Y-%m")] == Decimal("1000.00")
assert trend[first_of_month.strftime("%Y-%m")] == Decimal("1000.00")

View File

@ -86,10 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
class TestCatalog:
"""参数目录:种子完整性与时点化回溯。"""
def test_目录共18项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
"""V6 起含审批开关组(16 计提项 + 2 BOOL 开关)。"""
def test_目录共22项且覆盖九个分组(self, client: TestClient, admin: dict) -> None:
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
assert len(items) == 18
assert len(items) == 22
groups = {item["param_group"] for item in items}
assert groups == {
"业务员个人户",
@ -100,6 +100,7 @@ class TestCatalog:
"产品阈值",
"月工资口径",
"审批开关",
"预警规则",
}
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
@ -437,12 +438,23 @@ class TestPreview:
class TestPermission:
"""权限:普通用户全部 403。"""
"""权限分层(澄清 #17 答复 2026-10-19):查看可(登录即可)、修改仅管理员。"""
def test_普通用户访问目录与提交_403(self, client: TestClient, make_user, auth_headers) -> None:
def test_普通用户查看目录_200(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
resp = client.get(_PARAMS, headers=headers)
assert resp.status_code == 200
assert len(resp.json()["data"]) == 22
def test_未登录访问目录_403(self, client: TestClient) -> None:
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
# 均属拦截;显式断言不泄露数据即可)
assert client.get(_PARAMS).status_code in (401, 403)
def test_普通用户提交变更_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
assert client.get(_PARAMS, headers=headers).status_code == 403
resp = client.post(
f"{_PARAMS}/changes",
json={
@ -452,3 +464,8 @@ class TestPermission:
headers=headers,
)
assert resp.status_code == 403
def test_普通用户撤回批次_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
assert client.delete(f"{_PARAMS}/changes/1", headers=headers).status_code == 403

View File

@ -21,15 +21,17 @@ from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy import func, select
from app.core.constants import ACTION_CREATE, MODULE_RATING
from app.core.db import get_session_factory
from app.models import OperationLog
from app.models import CustomerRating, CustomerRatingGrade, OperationLog
from app.services.rating_service import resolve_grade
pytestmark = pytest.mark.integration
_GRADES = "/api/v1/business/rating-grades"
_CUSTOMERS = "/api/v1/base/customers"
# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1))
_SEED = [
@ -77,6 +79,41 @@ def _names(client: TestClient, headers: dict) -> list[str]:
return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]]
def _create_customer(client: TestClient, headers: dict) -> dict:
"""建一名 t_ 前缀测试客户(评级落库的被引用方,conftest 兜底清理)。"""
resp = client.post(
_CUSTOMERS,
json={
"company_name": f"t_评级客户_{uuid.uuid4().hex[:8]}",
"owner_name": "t_老板",
"customer_type": "personal",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
_RATING_KEYS = (
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
def _rating_url(customer_id: int) -> str:
return f"{_CUSTOMERS}/{customer_id}/rating"
def _rating_payload(scores: list[str], remark: str | None = None) -> dict:
body: dict = dict(zip(_RATING_KEYS, scores, strict=True))
if remark is not None:
body["remark"] = remark
return body
def _logs_of(username: str) -> list[OperationLog]:
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
factory = get_session_factory()
@ -258,8 +295,8 @@ class TestEdit:
class TestDelete:
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表
立即反映,二次删除 404。"""
"""删除允许清空(客户评级快照固化等级名、无外键引用)—— 删除后判定表
立即反映,二次删除 404,已评级客户的历史等级快照不受影响。"""
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
@ -313,3 +350,146 @@ class TestAudit:
]
assert failed, "失败操作未留痕"
assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0]
class TestResolveGrade:
"""判定纯函数边界:与前端打分卡同规则(sort_order 升序首个 min_score ≤ 总分)。
单边判定让边界分归 sort_order 靠前的等级:90→A、75→B、60→C、59.9→D;
等级表为空或总分低于全部下限时返回 None(落库等级为 NULL)。
"""
@staticmethod
def _seed_grades() -> list[CustomerRatingGrade]:
return [
CustomerRatingGrade(
grade_name=name,
min_score=Decimal(min_score),
max_score=Decimal(max_score),
sort_order=order,
)
for name, min_score, max_score, order in _SEED
]
def test_边界分归高档(self) -> None:
grades = self._seed_grades()
assert resolve_grade(grades, Decimal("90")).grade_name == "A"
assert resolve_grade(grades, Decimal("75")).grade_name == "B"
assert resolve_grade(grades, Decimal("60")).grade_name == "C"
def test_下限下方归最低档(self) -> None:
assert resolve_grade(self._seed_grades(), Decimal("59.9")).grade_name == "D"
def test_等级表为空_未命中(self) -> None:
assert resolve_grade([], Decimal("80")) is None
def test_低于全部下限_未命中(self) -> None:
grades = [
CustomerRatingGrade(
grade_name="A",
min_score=Decimal("90"),
max_score=Decimal("100"),
sort_order=1,
)
]
assert resolve_grade(grades, Decimal("50")) is None
class TestCustomerRating:
"""客户评级落库(V11):每客户一行、服务端重算总分与等级、等级快照固化。
五项各 0-20 → 总分 0-100;等级按保存时点判定并写入 grade_name(无外键)。
未评级返回 data=null(正常态);分数越界 400 且审计留痕。
"""
def test_未评级返回null(self, client: TestClient, normal: dict) -> None:
customer = _create_customer(client, normal["headers"])
resp = client.get(_rating_url(customer["id"]), headers=normal["headers"])
assert resp.status_code == 200, resp.text
assert resp.json()["data"] is None
def test_保存评级_服务端重算总分等级并留审计(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
put = client.put(
_rating_url(customer["id"]),
json=_rating_payload(["18", "20", "19", "17", "16"], remark="t_评级依据"),
headers=headers,
)
assert put.status_code == 200, put.text
data = put.json()["data"]
assert Decimal(data["total_score"]) == Decimal("90")
assert data["grade_name"] == "A"
assert data["remark"] == "t_评级依据"
assert data["scorer_name"] == normal["username"]
# 回读一致(列表评级列与弹窗回显同源)
got = client.get(_rating_url(customer["id"]), headers=headers).json()["data"]
assert Decimal(got["total_score"]) == Decimal("90")
assert got["grade_name"] == "A"
assert Decimal(got["score_ship_amount"]) == Decimal("18")
logs = [log for log in _logs_of(normal["username"]) if log.module == MODULE_RATING]
assert logs, "保存客户评级未留操作日志"
assert logs[0].success == 1
assert customer["company_name"] in logs[0].summary
assert "评级" in logs[0].summary
def test_重复保存覆盖同一客户仅一行(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
first = client.put(
_rating_url(customer["id"]), json=_rating_payload(["0"] * 5), headers=headers
)
assert first.status_code == 200, first.text
assert first.json()["data"]["grade_name"] == "D"
second = client.put(
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
)
assert second.status_code == 200, second.text
assert Decimal(second.json()["data"]["total_score"]) == Decimal("100")
assert second.json()["data"]["grade_name"] == "A"
factory = get_session_factory()
with factory() as session:
count = session.execute(
select(func.count())
.select_from(CustomerRating)
.where(CustomerRating.customer_id == customer["id"])
).scalar_one()
assert count == 1, "同一客户出现多行评级(upsert 失效)"
def test_单项分数越界_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
for bad in (["21", "0", "0", "0", "0"], ["0", "-1", "0", "0", "0"]):
resp = client.put(
_rating_url(customer["id"]), json=_rating_payload(bad), headers=headers
)
assert resp.status_code == 400, resp.text
assert "0~20" in resp.json()["message"], resp.text
def test_客户不存在_404(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
assert client.get(_rating_url(999_999_999), headers=headers).status_code == 404
put = client.put(_rating_url(999_999_999), json=_rating_payload(["0"] * 5), headers=headers)
assert put.status_code == 404, put.text
def test_等级表清空后保存_等级为null(self, client: TestClient, normal: dict) -> None:
"""等级映射允许清空(快照设计前提):清空后评分照常保存,仅等级为 NULL。"""
headers = normal["headers"]
for name in ("A", "B", "C", "D"):
assert (
client.delete(
f"{_GRADES}/{_seed_id(client, headers, name)}", headers=headers
).status_code
== 200
)
customer = _create_customer(client, headers)
data = client.put(
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
).json()["data"]
assert Decimal(data["total_score"]) == Decimal("100")
assert data["grade_name"] is None

View File

@ -0,0 +1,803 @@
"""报表统计集成测试(M4 ⑤)。
守的口径(破坏了就是报表数字与台账对不上):
- 个人明细与台账逐笔一致;summary 为**全筛选**合计而非当前页合计
- 部门汇总团队基数 = 成员绩效合计;12.5%/5% 分成按参数;无成员经理出 0 行
- 回款统计 8 个区间桶恒在(空桶为 0);**二次分摊拆行时回款金额只计一次**
- 业务统计排除经理聚合行(calc_type=2),绩效不被整团队聚合额污染
- 导出 xlsx 可解析且含签字栏;导出行为入操作日志
- 权限:普通用户 403 且文案为「无权访问报表统计」
"""
import uuid
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
pytestmark = pytest.mark.integration
_REPORTS = "/api/v1/reports"
_PERSONNEL = "/api/v1/base/personnel"
_BUSINESSES = "/api/v1/business/businesses"
_SHIPMENTS = "/api/v1/business/shipments"
_RECEIPTS = "/api/v1/business/receipts"
_CALC = "/api/v1/calc"
_SETTLEMENTS = "/api/v1/settlements"
def _role_id(code: str) -> int:
"""取内置业务岗位 id(salesperson / sales_manager)。"""
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import BusinessRole
factory = get_session_factory()
with factory() as session:
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == code, BusinessRole.status == 1
)
).scalar_one()
)
def _person(client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None) -> dict:
body = {
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id(role),
"salary_standard": "3000.00",
}
if manager_id is not None:
body["manager_id"] = manager_id
resp = client.post(_PERSONNEL, json=body, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _full_chain(
client: TestClient,
headers: dict,
*,
owner_id: int,
customer_id: int | None = None,
allocations: list[dict] | None = None,
) -> tuple[dict, dict]:
"""业务(单行 100×100,成本 3000)→ 发货(60 天前)→ 回款 → 核算。"""
from tests.integration.factories import business_item, business_payload, customer
cust = customer(client, headers) if customer_id is None else {"id": customer_id}
payload = business_payload(
cust["id"],
owner_personnel_id=owner_id,
items=[business_item(cost_amount="3000.00")],
)
if allocations:
payload["secondary_allocations"] = allocations
resp = client.post(_BUSINESSES, json=payload, headers=headers)
assert resp.status_code == 200, resp.text
business = resp.json()["data"]
from datetime import date, timedelta
ship_date = (date.today() - timedelta(days=60)).isoformat()
resp = client.post(
_SHIPMENTS,
json={
"business_id": business["id"],
"ship_date": ship_date,
"items": [{"product_name": "t_发货物", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
shipment = resp.json()["data"]
resp = client.post(
_RECEIPTS,
json={
"business_id": business["id"],
"receipt_date": date.today().isoformat(),
"amount": "10000.00",
"shipment_id": shipment["id"],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
receipt = resp.json()["data"]
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
assert resp.status_code == 200, resp.text
return business, receipt
def _month() -> str:
from datetime import date
return date.today().strftime("%Y-%m")
def _ledger_sum(headers: dict, month: str) -> Decimal:
"""台账(单笔生效行)同月绩效合计——报表 summary 的对照基准。"""
from sqlalchemy import func, select
from app.core.db import get_session_factory
from app.models import PerformanceLedger
factory = get_session_factory()
with factory() as session:
return Decimal(
str(
session.execute(
select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == 1,
PerformanceLedger.is_active == 1,
)
).scalar_one()
)
)
def test_个人明细_与台账一致且summary为全量合计(client: TestClient, admin: dict):
"""明细行金额必须等于台账;pageSize=1 时 summary 仍是全部筛选结果之和。"""
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
month = _month()
resp = client.get(
f"{_REPORTS}/personal-detail",
params={"settlement_month": month, "pageSize": 1},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert data["total"] == 1
expected = _ledger_sum(admin["headers"], month)
assert Decimal(str(data["list"][0]["performance_amount"])) == expected
assert Decimal(str(data["summary"]["performance_total"])) == expected
def test_部门汇总_团队基数等于成员绩效且分成按参数(admin, client: TestClient):
"""团队基数=成员绩效合计;12.5% 团队分成与 5% 全公司分成分别可核。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
_full_chain(client, admin["headers"], owner_id=salesperson["id"])
month = _month()
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": month},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
ledger_total = _ledger_sum(admin["headers"], month)
assert Decimal(str(team["team_base"])) == ledger_total
expected_share = (ledger_total * Decimal("0.125")).quantize(Decimal("0.01"))
assert Decimal(str(team["team_share_amount"])) == expected_share
assert team["member_count"] == 1
assert data["company"]["company_base"] == team["team_base"]
def test_部门汇总_无成员经理出零值行(admin, client: TestClient):
"""无团队成员 → 0 行展示(FR-030 异常处理)。"""
manager = _person(client, admin["headers"], role="sales_manager")
month = _month()
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": month},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
team = next(
(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None
)
assert team is not None
assert Decimal(str(team["team_base"])) == Decimal("0")
assert team["member_count"] == 0
def test_回款统计_空桶恒在且分摊拆行金额只计一次(admin, client: TestClient):
"""8 个区间桶恒在;二次分配拆行使台账多行但回款金额不得翻倍。"""
owner = _person(client, admin["headers"])
participant = _person(client, admin["headers"])
_full_chain(
client,
admin["headers"],
owner_id=owner["id"],
allocations=[{"personnel_id": participant["id"], "ratio": "30.00"}],
)
month = _month()
resp = client.get(
f"{_REPORTS}/payment-stats",
params={"settlement_month": month},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert len(data["buckets"]) == 8
assert [b["code"] for b in data["buckets"]] == [
"le0", "1-30", "31-60", "61-90", "91-180", "181-360", "361+", "none",
]
# 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天)
bucket = next(b for b in data["buckets"] if b["code"] == "61-90")
assert Decimal(str(bucket["receipt_amount"])) == Decimal("10000.00")
assert bucket["receipt_count"] == 1
# 拆行后台账两行,但绩效合计仍等于台账总和
assert bucket["ledger_count"] == 2
empty = next(b for b in data["buckets"] if b["code"] == "361+")
assert Decimal(str(empty["performance_amount"])) == Decimal("0")
def test_业务统计_绩效排除经理聚合行(admin, client: TestClient):
"""生成月结后出现 calc_type=2 聚合行,业务统计不得把聚合额算进单业务。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
business, _ = _full_chain(client, admin["headers"], owner_id=salesperson["id"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
resp = client.get(
f"{_REPORTS}/business-stats",
params={"keyword": business["business_no"]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
row = resp.json()["data"]["list"][0]
assert Decimal(str(row["performance_amount"])) == _ledger_sum(admin["headers"], _month())
def test_导出_excel含签字栏且入操作日志(admin, client: TestClient):
"""导出是文件流(非统一信封);模板含复核/签字栏;行为入操作日志。"""
from io import BytesIO
from openpyxl import load_workbook
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
resp = client.get(
f"{_REPORTS}/personal-detail/export",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
workbook = load_workbook(BytesIO(resp.content))
text = "\n".join(
str(cell.value)
for row in workbook.active.iter_rows()
for cell in row
if cell.value is not None
)
assert "个人绩效工资明细表" in text
assert "财务复核" in text
assert "领导签字" in text
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import OperationLog
factory = get_session_factory()
with factory() as session:
log = session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
).scalars().first()
assert log is not None
def test_权限_普通用户403文案(admin, normal, client: TestClient):
"""报表限管理员/财务/领导;普通用户被后端拦截且文案正确。"""
resp = client.get(f"{_REPORTS}/personal-detail", headers=normal["headers"])
assert resp.status_code == 403
assert resp.json()["message"] == "无权访问报表统计"
# ==== 审查回归:归属、调整单、经理个人户、导出审计 ====
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
"""立即生效的参数变更(用例不依赖种子默认值,结束显式恢复)。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": f"用例设置 {code}={value}",
"items": [{"param_code": code, "dimension_value": "", "value": value}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _insert_adjustment(person: dict, month: str, amount: str, direction: int = 1) -> None:
"""直插生效调整单(status=2):绕开审批链,聚焦报表归属口径。"""
from app.core.db import get_session_factory
from app.models import AdjustmentOrder
factory = get_session_factory()
with factory() as session:
session.add(
AdjustmentOrder(
order_no=f"TZ{uuid.uuid4().hex[:12]}",
settlement_month=month,
personnel_id=person["id"],
personnel_name=person["personnel_name"],
direction=direction,
amount=Decimal(amount),
reason="t_报表用例",
status=2,
submitter_name="t_admin",
)
)
session.commit()
def _insert_manager_personal_ledger(manager: dict, month: str, amount: str) -> None:
"""直插一条经理个人户台账行(模拟单笔侧已计提的结果)。"""
from datetime import date
from app.core.db import get_session_factory
from app.models import PerformanceLedger
factory = get_session_factory()
with factory() as session:
session.add(
PerformanceLedger(
receipt_id=None,
calc_version=1,
calc_type=1,
business_id=0,
business_no="t_个人户",
personnel_id=manager["id"],
personnel_name=manager["personnel_name"],
role_code="sales_manager",
customer_type="manager_personal",
product_category="mixed",
receipt_date=date.today(),
rate_text="0",
performance_amount=Decimal(amount),
barter_applied=0,
payable_amount=Decimal(amount),
settlement_month=month,
is_probation=0,
)
)
session.commit()
def _latest_export_log():
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import OperationLog
factory = get_session_factory()
with factory() as session:
return session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
).scalars().first()
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
manager_b = _person(client, admin["headers"], role="sales_manager")
manager_a = _person(
client, admin["headers"], role="sales_manager", manager_id=manager_b["id"]
)
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team_a = next(t for t in data["teams"] if t["manager_id"] == manager_a["id"])
assert team_a["member_count"] == 0
team_b = next(t for t in data["teams"] if t["manager_id"] == manager_b["id"])
assert all(m["personnel_id"] != manager_a["id"] for m in team_b["members"])
def test_部门汇总_仅有调整单人员并入对应团队(admin, client: TestClient):
"""只有调整单、当月无台账行的人也要出现在团队明细中。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
_insert_adjustment(salesperson, _month(), "500.00")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
member = next(m for m in team["members"] if m["personnel_id"] == salesperson["id"])
assert member["member_kind"] == "adjustment"
assert Decimal(str(member["performance_sum"])) == Decimal("0")
assert Decimal(str(member["adjust_net"])) == Decimal("500.00")
assert Decimal(str(team["adjust_net"])) == Decimal("500.00")
assert Decimal(str(data["reconciliation"]["adjust_net_total"])) == Decimal("500.00")
def test_部门汇总_仅调整单无经理人员进入未分配(admin, client: TestClient):
"""无经理人员的调整单不能被静默丢弃。"""
salesperson = _person(client, admin["headers"])
_insert_adjustment(salesperson, _month(), "300.00")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
row = next(u for u in data["unassigned"] if u["personnel_id"] == salesperson["id"])
assert row["member_kind"] == "adjustment"
assert Decimal(str(row["adjust_net"])) == Decimal("300.00")
def test_部门汇总_经理个人户计入时基数等于成员合计(admin, client: TestClient):
"""开关 1:经理成员行只取个人户金额,团队基数 == 成员绩效合计。"""
manager = _person(client, admin["headers"], role="sales_manager")
_insert_manager_personal_ledger(manager, _month(), "100.00")
_set_param(client, admin["headers"], "manager.personal.included", "1")
try:
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
team = next(
t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]
)
assert Decimal(str(team["team_base"])) == Decimal("100.00")
assert sum(
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
) == Decimal("100.00")
assert all(m["member_kind"] == "manager_personal" for m in team["members"])
finally:
_set_param(client, admin["headers"], "manager.personal.included", "0")
def test_部门汇总_经理个人户关闭时不展示个人户金额(admin, client: TestClient):
"""开关 0:个人户金额既不计入团队基数,也不进未分配(与全公司口径一致)。"""
manager = _person(client, admin["headers"], role="sales_manager")
_insert_manager_personal_ledger(manager, _month(), "100.00")
_set_param(client, admin["headers"], "manager.personal.included", "0")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
assert team["member_count"] == 0
assert all(u["personnel_id"] != manager["id"] for u in data["unassigned"])
def test_部门汇总_按经理筛选不误判其他团队下属(admin, client: TestClient):
"""筛选只影响展示的团队;其他经理的下属不能进入未分配。"""
manager_1 = _person(client, admin["headers"], role="sales_manager")
manager_2 = _person(client, admin["headers"], role="sales_manager")
salesperson_1 = _person(client, admin["headers"], manager_id=manager_1["id"])
salesperson_2 = _person(client, admin["headers"], manager_id=manager_2["id"])
_full_chain(client, admin["headers"], owner_id=salesperson_1["id"])
_full_chain(client, admin["headers"], owner_id=salesperson_2["id"])
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
assert data["unassigned"] == []
assert all(
m["personnel_id"] != salesperson_2["id"]
for team in data["teams"]
for m in team["members"]
)
assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0
def test_部门汇总_对账恒等式(admin, client: TestClient):
"""未筛选:团队基数 + 未分配绩效 = 全公司基数;应发公式成立。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
unassigned = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=salesperson["id"])
_full_chain(client, admin["headers"], owner_id=unassigned["id"])
_insert_adjustment(salesperson, _month(), "200.00")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
reconciliation = resp.json()["data"]["reconciliation"]
assert Decimal(str(reconciliation["team_base_total"])) + Decimal(
str(reconciliation["unassigned_performance_total"])
) == Decimal(str(reconciliation["company_base"]))
assert Decimal(str(reconciliation["other_team_base"])) == Decimal("0")
assert Decimal(str(reconciliation["payable_total"])) == (
Decimal(str(reconciliation["team_member_payable_total"]))
+ Decimal(str(reconciliation["unassigned_payable_total"]))
+ Decimal(str(reconciliation["aggregate_payable_total"]))
+ Decimal(str(reconciliation["adjust_net_total"]))
)
# 分列合计 + 舍入差 = 聚合额(月结一次舍入口径)
assert Decimal(str(reconciliation["aggregate_rounding_diff"])) == (
Decimal(str(reconciliation["aggregate_payable_total"]))
- Decimal(str(reconciliation["team_share_total"]))
- Decimal(str(reconciliation["company_share_total"]))
)
def _two_manager_scene(
client: TestClient, headers: dict
) -> tuple[dict, dict, dict, dict, dict]:
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
manager_1 = _person(client, headers, role="sales_manager")
manager_2 = _person(client, headers, role="sales_manager")
empty_manager = _person(client, headers, role="sales_manager")
salesperson_1 = _person(client, headers, manager_id=manager_1["id"])
salesperson_2 = _person(client, headers, manager_id=manager_2["id"])
_full_chain(client, headers, owner_id=salesperson_1["id"])
_full_chain(client, headers, owner_id=salesperson_2["id"])
return manager_1, manager_2, empty_manager, salesperson_1, salesperson_2
def test_部门汇总_应发合计与月结对拍_两位经理(admin, client: TestClient):
"""月结每位有聚合行的经理各计一次 5%;签字稿应发必须等于 total_payable。"""
_two_manager_scene(client, admin["headers"])
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
before = resp.json()["data"]
assert before["company"]["company_share_count"] == 2
assert Decimal(str(before["company"]["company_share_total"])) == (
Decimal(str(before["company"]["company_share_amount"])) * 2
)
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement = resp.json()["data"]
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
teams = {team["manager_id"]: team for team in data["teams"]}
frozen_total = sum(
(
Decimal(str(team["frozen_amount"]))
for team in teams.values()
if team["frozen_amount"] is not None
),
Decimal("0"),
)
reconciliation = data["reconciliation"]
assert Decimal(str(reconciliation["aggregate_payable_total"])) == frozen_total
assert Decimal(str(reconciliation["payable_total"])) == Decimal(
str(settlement["total_payable"])
)
def test_部门汇总_有调整单时应发等于月结加调整(admin, client: TestClient):
"""调整单不进月结 items;报表应发 = 月结总额 + 调整净额。"""
_, _, _, salesperson, _ = _two_manager_scene(client, admin["headers"])
_insert_adjustment(salesperson, _month(), "500.00")
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement = resp.json()["data"]
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
reconciliation = resp.json()["data"]["reconciliation"]
assert Decimal(str(reconciliation["adjust_net_total"])) == Decimal("500.00")
assert Decimal(str(reconciliation["payable_total"])) == (
Decimal(str(settlement["total_payable"])) + Decimal("500.00")
)
def test_部门汇总_按经理筛选时5百分比只计该团队(admin, client: TestClient):
"""筛选态只汇总展示团队;隐藏团队的金额进 other_*,不参与应发。"""
manager_1, _, _, _, _ = _two_manager_scene(client, admin["headers"])
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert data["company"]["company_share_count"] == 1
reconciliation = data["reconciliation"]
assert Decimal(str(reconciliation["other_team_aggregate_total"])) > 0
assert Decimal(str(reconciliation["other_team_company_share_total"])) > 0
def test_导出_筛选页眉ID转名称与中文标签(admin, client: TestClient):
"""页眉是签字稿追溯筛选范围的唯一凭据,ID 必须转名称。"""
from io import BytesIO
from openpyxl import load_workbook
from app.core.db import get_session_factory
from app.models import BusinessRole
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
factory = get_session_factory()
with factory() as session:
role_name = (
session.query(BusinessRole.role_name)
.filter(BusinessRole.role_code == "salesperson")
.scalar()
)
resp = client.get(
f"{_REPORTS}/personal-detail/export",
params={
"settlement_month": _month(),
"personnel_id": person["id"],
"role_code": "salesperson",
"customer_type": "personal",
"product_category": "municipal_water",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
workbook = load_workbook(BytesIO(resp.content))
text = "\n".join(
str(cell.value)
for row in workbook.active.iter_rows()
for cell in row
if cell.value is not None
)
assert person["personnel_name"] in text
assert f"人员 {person['personnel_name']}(#{person['id']})" in text
assert "个人户" in text
assert "市政水利" in text
assert role_name in text
def test_导出_回款维度sheet透传筛选条件(admin, client: TestClient):
"""两个维度 sheet 的页眉不能固定写「全部」。"""
from io import BytesIO
from openpyxl import load_workbook
resp = client.get(
f"{_REPORTS}/payment-stats/export",
params={"customer_type": "personal"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
workbook = load_workbook(BytesIO(resp.content))
for sheet_name in ("客户类型对比", "产品类别对比"):
text = "\n".join(
str(cell.value)
for row in workbook[sheet_name].iter_rows()
for cell in row
if cell.value is not None
)
assert "客户类型 个人户" in text
assert "筛选条件:全部" not in text
def test_导出超限_失败审计留痕(admin, client: TestClient, monkeypatch):
"""超限 400 发生在审计上下文内,必须留下失败记录。"""
from app.services import report_service
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
monkeypatch.setattr(report_service, "REPORT_EXPORT_MAX_ROWS", 0)
resp = client.get(
f"{_REPORTS}/personal-detail/export",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
log = _latest_export_log()
assert log is not None
assert log.success == 0
assert "超过上限" in (log.error_message or "")
def test_导出取数异常_失败审计留痕(admin, client: TestClient, monkeypatch):
"""取数阶段的异常同样要留下失败记录。
TestClient 默认重抛服务端异常,这里直接调 service 层,聚焦审计落库。
"""
from app.core.db import get_session_factory
from app.models import User
from app.schemas.report import PersonalDetailQuery
from app.services import report_service
def _boom(*args, **kwargs):
raise RuntimeError("t_取数异常")
monkeypatch.setattr(report_service, "_export_payload", _boom)
factory = get_session_factory()
with factory() as session:
operator = session.get(User, admin["id"])
with pytest.raises(RuntimeError, match="t_取数异常"):
report_service.export_report(
session,
"personal-detail",
PersonalDetailQuery(settlement_month=_month()),
operator=operator,
)
log = _latest_export_log()
assert log is not None
assert log.success == 0
# 未预期异常按安全口径收敛为通用文案,摘要仍能定位到报表
assert "导出个人绩效工资明细表" in (log.summary or "")
@pytest.mark.parametrize(
"path", ["/personal-detail", "/department-summary", "/payment-stats"]
)
def test_非法月份_查询返回400(admin, client: TestClient, path: str):
"""非法月份必须是参数错误 400,不能落 500。"""
resp = client.get(
f"{_REPORTS}{path}",
params={"settlement_month": "2026-13"},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
@pytest.mark.parametrize(
"code", ["personal-detail", "department-summary", "payment-stats"]
)
def test_非法月份_导出返回400(admin, client: TestClient, code: str):
"""导出手工构造查询模型同样要转 400。"""
resp = client.get(
f"{_REPORTS}/{code}/export",
params={"settlement_month": "2026-13"},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text

View File

@ -61,7 +61,10 @@ def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]:
for table, columns in _parse_tables(sql).items():
merged.setdefault(table, set()).update(columns)
for table, column in _ALTER_ADD.findall(sql):
merged.setdefault(table, set()).add(column)
# ADD CONSTRAINT ... UNIQUE 的「CONSTRAINT」不是列(与 _parse_tables
# 的约束关键词同一份清单过滤),V8 台账唯一键三列扩展即此形态
if column.upper() not in _NON_COLUMN_KEYWORDS:
merged.setdefault(table, set()).add(column)
for table, column in _ALTER_DROP.findall(sql):
merged.setdefault(table, set()).discard(column)
return merged

View File

@ -69,6 +69,26 @@ def _create_with_allocations(client: TestClient, headers: dict, allocations: lis
return resp.json()["data"]
def _set_switch(client: TestClient, headers: dict, value: str) -> None:
"""显式设置二次分配审批开关(V8 起默认开启,用例不得依赖种子默认值)。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": f"用例设置二次分配审批开关={value}",
"items": [
{
"param_code": "approval.secondary_allocation.enabled",
"dimension_value": "",
"value": value,
}
],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _detail(client: TestClient, headers: dict, business_id: int) -> dict:
resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers)
assert resp.status_code == 200, resp.text
@ -142,6 +162,43 @@ class TestGuards:
assert resp.status_code == 400
assert "分摊参与" in resp.json()["message"]
def test_离职历史参与人_编辑其余字段放行(self, client: TestClient, admin: dict) -> None:
"""历史参与人离职后:编辑业务(保留该行)不应被 400 卡死——
personnel_name 冗余固化的设计本就允许离职后回显;仅新增参与人
才做在职校验。"""
p = _person(client, admin["headers"])
data = _create_with_allocations(
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
)
resp = client.put(
f"{_PERSONNEL}/{p['id']}/status", json={"status": 0}, headers=admin["headers"]
)
assert resp.status_code == 200
detail = _detail(client, admin["headers"], data["id"])
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "50.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
after = _detail(client, admin["headers"], data["id"])
assert after["secondary_allocations"][0]["personnel_name"] == p["personnel_name"]
# 新增一名离职人员仍要拦(新增才做在职校验)
p2 = _person(client, admin["headers"])
client.put(f"{_PERSONNEL}/{p2['id']}/status", json={"status": 0}, headers=admin["headers"])
payload = business_payload(
detail["customer_id"],
secondary_allocations=[
{"personnel_id": p["id"], "ratio": "30.00"},
{"personnel_id": p2["id"], "ratio": "20.00"},
],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 400
assert "分摊参与" in resp.json()["message"]
def test_终态业务禁改方案(self, client: TestClient, admin: dict) -> None:
p = _person(client, admin["headers"])
data = _create_with_allocations(
@ -202,6 +259,8 @@ class TestDeleteProtection:
assert detail["secondary_allocations"][0]["personnel_name"] == p["personnel_name"]
def test_删除业务后可删人员(self, client: TestClient, admin: dict) -> None:
# 显式关开关:开启时审批单在途、删业务被 400 拦截(产品行为,另有用例覆盖)
_set_switch(client, admin["headers"], "0")
p = _person(client, admin["headers"])
data = _create_with_allocations(
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]

View File

@ -23,6 +23,7 @@ _SETTINGS = "/api/v1/system/settings"
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
_JPG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32
_ICO_BYTES = b"\x00\x00\x01\x00" + b"\x00" * 32
_TXT_BYTES = b"not an image at all"
@ -31,6 +32,10 @@ def client() -> TestClient:
return TestClient(app)
# 系统设置单行与上传文件的快照/还原已上移到 integration/conftest.py,
# 对所有集成用例生效(不再只保护本模块)。
class TestCompanyInfo:
"""公司名称读取与更新。"""
@ -84,10 +89,30 @@ class TestPublicEndpoint:
resp = client.get(f"{_SETTINGS}/public")
assert resp.status_code == 200
data = resp.json()["data"]
assert set(data) == {"company_name", "logo_url"}, "public 端点字段越界"
assert set(data) == {
"company_name",
"logo_url",
"site_title",
"brand_slogan",
"footer_text",
"icp_no",
"favicon_url",
}, "public 端点字段越界"
assert data["company_name"] == name
assert resp.headers.get("cache-control") is not None
def test_已删除的档案字段不再返回(self, client: TestClient) -> None:
"""V11 精简:副标题/信用代码/地址/电话/邮箱已从模型与契约中删除。"""
data = client.get(f"{_SETTINGS}/public").json()["data"]
for field in (
"system_subtitle",
"credit_code",
"company_address",
"contact_phone",
"contact_email",
):
assert field not in data, f"已删除字段仍出现在匿名端点:{field}"
def test_写操作匿名不可用(self, client: TestClient) -> None:
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
401,
@ -95,6 +120,41 @@ class TestPublicEndpoint:
}
class TestCompanyProfile:
"""站点标题/品牌信息(V11):部分更新语义与空串清空。"""
def test_部分更新_未提交字段保持不变(self, client: TestClient, admin: dict) -> None:
name = f"站点公司_{uuid.uuid4().hex[:6]}"
client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"])
put = client.put(_SETTINGS, json={"site_title": "测试站点标题"}, headers=admin["headers"])
assert put.status_code == 200, put.text
assert put.json()["data"]["site_title"] == "测试站点标题"
# 未提交的 company_name 保持不变(部分更新语义)
assert put.json()["data"]["company_name"] == name
got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"]
assert got["company_name"] == name
assert got["site_title"] == "测试站点标题"
def test_可选文本传空串_清空为null(self, client: TestClient, admin: dict) -> None:
client.put(
_SETTINGS,
json={"brand_slogan": "t_临时标语", "icp_no": "t_备案号"},
headers=admin["headers"],
)
put = client.put(
_SETTINGS, json={"brand_slogan": "", "icp_no": ""}, headers=admin["headers"]
)
assert put.status_code == 200, put.text
assert put.json()["data"]["brand_slogan"] is None
assert put.json()["data"]["icp_no"] is None
def test_超长字段被拒(self, client: TestClient, admin: dict) -> None:
resp = client.put(_SETTINGS, json={"site_title": "x" * 65}, headers=admin["headers"])
assert resp.status_code == 400, resp.text
class TestLogoUpload:
"""logo 上传校验链。"""
@ -135,3 +195,65 @@ class TestLogoUpload:
files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")},
)
assert resp.status_code == 401
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
"""同扩展名覆盖时路径与 update_time 都不变 —— 版本号必须递增,否则浏览器用旧图。"""
first = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
second = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
assert first and second
assert first != second, "同扩展名替换后图片 URL 未变化,浏览器会继续使用旧缓存"
class TestFaviconUpload:
"""站点图标上传校验链(独立于 logo;favicon 额外接受 ico)。"""
def _upload(self, client: TestClient, admin: dict, filename: str, content: bytes):
return client.post(
f"{_SETTINGS}/favicon",
files={"file": (filename, content, "application/octet-stream")},
headers=admin["headers"],
)
def test_合法ico上传成功且回读(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "favicon.ico", _ICO_BYTES)
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["favicon_url"] and "/favicon/public" in out["favicon_url"]
img = client.get(f"{_SETTINGS}/favicon/public")
assert img.status_code == 200
assert img.headers["content-type"] == "image/x-icon"
def test_合法png上传成功(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "favicon.png", _PNG_BYTES)
assert resp.status_code == 200, resp.text
assert "/favicon/public" in resp.json()["data"]["favicon_url"]
def test_伪造扩展名被magic_bytes拦截(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "fake.ico", _TXT_BYTES)
assert resp.status_code == 400, resp.text
assert "不符" in resp.json()["message"]
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
first = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"]["favicon_url"]
second = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"][
"favicon_url"
]
assert first and second
assert first != second, "同扩展名替换后站点图标 URL 未变化"
def test_非法格式被拒(self, client: TestClient, admin: dict) -> None:
assert self._upload(client, admin, "x.gif", _PNG_BYTES).status_code == 400
def test_超限被拒(self, client: TestClient, admin: dict) -> None:
big = _PNG_BYTES + b"\x00" * (2 * 1024 * 1024)
resp = self._upload(client, admin, "big.png", big)
assert resp.status_code == 400
assert "2MB" in resp.json()["message"]
def test_匿名上传被拒(self, client: TestClient) -> None:
resp = client.post(
f"{_SETTINGS}/favicon",
files={"file": ("favicon.png", _PNG_BYTES, "application/octet-stream")},
)
assert resp.status_code == 401

View File

@ -0,0 +1,772 @@
"""月度结算集成测试(M3 ④:生成快照 → 审批 → 锁定)。
守的还有:
- 确认回款口径(#8 策略点):仅 is_active=1 且未入结的台账行入结;
入结后重生成同月不再重复计入
- 通过即锁定(status=3 终态);锁定后回款写路径 400(走调整单)
- 驳回回草稿可重提;开关两路(关=自动通过锁定;开=审批流转)
- #12 暂口径:经理个人户业绩不入聚合基数(金标准仅业务员个人户,
聚合用例以「无经理台账」形态验证 items 按人汇总不聚合)
"""
import uuid
from datetime import date
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import MonthlySettlement, PerformanceLedger
from tests.integration.factories import business_payload, customer
pytestmark = pytest.mark.integration
_SETTLEMENTS = "/api/v1/settlements"
_CALC = "/api/v1/calc"
_BUSINESSES = "/api/v1/business/businesses"
_SHIPMENTS = "/api/v1/business/shipments"
_RECEIPTS = "/api/v1/business/receipts"
_PERSONNEL = "/api/v1/base/personnel"
_GOLD_ITEMS = [
{
"product_name": "t_市政_月结",
"product_category": "municipal_water",
"quantity": "100",
"sale_price": "100.00",
"cost_amount": "3000.00",
}
]
def _role_id(role_code: str) -> int:
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import BusinessRole
factory = get_session_factory()
with factory() as session:
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == role_code,
BusinessRole.status == 1,
)
).scalar_one()
)
def _person(
client: TestClient,
headers: dict,
*,
role_code: str = "salesperson",
manager_id: int | None = None,
) -> dict:
resp = client.post(
_PERSONNEL,
json={
"personnel_name": f"t_月结_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id(role_code),
"salary_standard": "3000.00",
**({"manager_id": manager_id} if manager_id is not None else {}),
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _set_switch(client: TestClient, headers: dict, code: str, value: str) -> None:
"""显式设置审批开关(V8 起审批开关默认开启,用例不得依赖种子默认值)。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": f"用例设置 {code}={value}",
"items": [{"param_code": code, "dimension_value": "", "value": value}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _calc_one(client: TestClient, headers: dict) -> dict:
"""完整造一笔已核算回款(回款日 2026-10-20 → 归月 2026-10)。"""
cust = customer(client, headers)
person = _person(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=person["id"],
items=[dict(_GOLD_ITEMS[0], product_name=f"t_月结产品_{uuid.uuid4().hex[:6]}")],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
biz = resp.json()["data"]
resp = client.post(
_SHIPMENTS,
json={
"business_id": biz["id"],
"ship_date": "2026-10-01",
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-10-20",
"amount": "20000.00",
"shipment_id": resp.json()["data"]["id"],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
receipt = resp.json()["data"]
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
assert resp.status_code == 200, resp.text
return biz
@pytest.fixture
def finance(make_user, auth_headers) -> dict:
user = make_user(["finance"])
user["headers"] = auth_headers(user)
return user
class TestGenerate:
"""生成快照:确认回款口径与按人汇总。"""
def test_生成_按人汇总并回填台账(self, client: TestClient, admin: dict) -> None:
_calc_one(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["settlement_month"] == "2026-10"
assert out["status"] == 1 # 草稿
assert len(out["items"]) >= 1
item = out["items"][0]
assert item["is_probation"] in (0, 1)
# 台账行已回填 settlement_id(入结标记,防重复计入)
factory = get_session_factory()
with factory() as session:
settled = session.execute(
select(MonthlySettlement).where(MonthlySettlement.settlement_month == "2026-10")
).scalar_one()
assert settled.person_count >= 1
backfilled = (
session.execute(
select(PerformanceLedger).where(PerformanceLedger.settlement_id == settled.id)
)
.scalars()
.all()
)
assert backfilled, "台账行 settlement_id 未回填(「未入结」判据失效)"
assert all(r.settlement_month == "2026-10" for r in backfilled)
def test_无核算记录生成400(self, client: TestClient, admin: dict) -> None:
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2099-01"}, headers=admin["headers"]
)
assert resp.status_code == 400
assert "无可结算" in resp.json()["message"]
def test_已审定月份重生成400(self, client: TestClient, admin: dict) -> None:
_calc_one(client, admin["headers"])
assert (
client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
).status_code
== 200
)
# 显式关开关 → 提交即自动通过锁定(用例不依赖种子默认值)
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][
"id"
]
assert (
client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"]).status_code == 200
)
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 400
assert "已审定锁定" in resp.json()["message"]
class TestSubmitAndLock:
"""提交审批与锁定(开关两路)。"""
def test_开关关闭_提交即自动通过锁定(
self, client: TestClient, admin: dict, finance: dict
) -> None:
_calc_one(client, admin["headers"])
# 显式关开关(V8 起种子默认开启,用例不得依赖默认值)
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
sid = resp.json()["data"]["id"]
resp = client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"])
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["status"] == 3 # 自动通过=已审定(锁定)
assert out["approval_order_no"] and out["approval_order_no"].startswith("AP")
assert out["locked_at"] is not None
def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None:
from sqlalchemy import select
biz = _calc_one(client, admin["headers"])
client.post(f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"])
sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][
"id"
]
client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"])
# 找该月回款并尝试编辑(400:月已锁定)
factory = get_session_factory()
with factory() as session:
from app.models import Receipt
receipt_id = int(
session.execute(
select(Receipt.id).where(Receipt.business_id == biz["id"])
).scalar_one()
)
resp = client.put(
f"{_RECEIPTS}/{receipt_id}",
json={
"business_id": biz["id"],
"receipt_date": "2026-10-21",
"amount": "999.00",
},
headers=admin["headers"],
)
assert resp.status_code == 400
# 已核算禁改(M2 防线)先于月结锁定拦截生效——两者均 400 拒改
assert ("已核算" in resp.json()["message"]) or ("锁定" in resp.json()["message"])
class TestSwitchOn:
"""开关开启:审批流转后锁定。"""
def test_提交待审_通过后锁定(
self, client: TestClient, admin: dict, finance: dict, make_user, auth_headers
) -> None:
# 开月结审批开关(立即生效)
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": "开月结审批",
"items": [
{
"param_code": "approval.monthly_settlement.enabled",
"dimension_value": "",
"value": "1",
}
],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
leader = make_user(["leader"])
leader["headers"] = auth_headers(leader)
# 造 11 月的台账(避开 10 月已入结月份)
cust = customer(client, admin["headers"])
person = _person(client, admin["headers"])
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=person["id"],
items=[dict(_GOLD_ITEMS[0], product_name=f"t_11月_{uuid.uuid4().hex[:6]}")],
),
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
biz = resp.json()["data"]
resp = client.post(
_SHIPMENTS,
json={
"business_id": biz["id"],
"ship_date": "2026-11-01",
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
shipment = resp.json()["data"]
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-11-20",
"amount": "20000.00",
"shipment_id": shipment["id"],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
receipt = resp.json()["data"]
assert (
client.post(
f"{_CALC}/receipts/{receipt['id']}/calc", headers=admin["headers"]
).status_code
== 200
)
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-11"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
sid = resp.json()["data"]["id"]
# 提交 → 待财务复核
resp = client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"])
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["status"] == 2
# 财务通过 → 待领导(approval_order_no 关联的审批单流转)
order_no = resp.json()["data"]["approval_order_no"]
order = client.get(
"/api/v1/system/approvals", params={"keyword": order_no}, headers=admin["headers"]
).json()["data"]["list"][0]
resp = client.post(
f"/api/v1/system/approvals/{order['id']}/action",
json={"action": 1},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
# 财务节点通过只是流转到待领导——不得提前锁定月结(否则领导驳回后
# 锁定字段残留、两级审批形同虚设)
mid = client.get(f"{_SETTLEMENTS}/{sid}", headers=admin["headers"]).json()["data"]
assert mid["status"] == 2, "财务通过后应仍为待审批,不得提前锁定"
assert mid["locked_at"] is None
assert mid["locked_by"] is None
# 领导通过 → 锁定
resp = client.post(
f"/api/v1/system/approvals/{order['id']}/action",
json={"action": 1},
headers=leader["headers"],
)
assert resp.status_code == 200, resp.text
final = client.get(f"{_SETTLEMENTS}/{sid}", headers=admin["headers"]).json()["data"]
assert final["status"] == 3
assert final["locked_at"] is not None
assert final["locked_by"] == leader["username"] # 审批通过人=领导账号
class TestManagerAggregate:
"""经理团队聚合:比例按参数实际值计算(守「百分数再乘 100」放大缺陷)。"""
def _scene(self, client: TestClient, headers: dict) -> tuple[dict, dict]:
"""经理(带一名业务员)→ 业务 → 发货 → 已核算回款,返回 (经理, 回款)。"""
manager = _person(client, headers, role_code="sales_manager")
sales = _person(client, headers, manager_id=manager["id"])
cust = customer(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=sales["id"],
items=[dict(_GOLD_ITEMS[0], product_name=f"t_聚合_{uuid.uuid4().hex[:6]}")],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
biz = resp.json()["data"]
resp = client.post(
_SHIPMENTS,
json={
"business_id": biz["id"],
"ship_date": "2026-10-01",
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-10-20",
"amount": "20000.00",
"shipment_id": resp.json()["data"]["id"],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
receipt = resp.json()["data"]
assert (
client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers).status_code
== 200
)
return manager, receipt
def test_聚合金额按实际比例计入且落库回填(self, client: TestClient, admin: dict) -> None:
manager, _receipt = self._scene(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
items = out["items"]
manager_items = [i for i in items if i["calc_type"] == 2]
assert len(manager_items) == 1
# 业务员绩效 39.11;经理 = 39.11×12.5% + 39.11×5% = 6.84425 → 6.84
# (若调用方把百分数再 ×100,此处会是 684.43)
assert manager_items[0]["personnel_id"] == manager["id"]
assert manager_items[0]["performance_sum"] == "6.84"
assert manager_items[0]["payable_sum"] == "6.84"
# 聚合行必须落台账(报告下钻的数据源)且被回填 settlement_id
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == "2026-10",
PerformanceLedger.calc_type == 2,
)
)
.scalars()
.all()
)
assert len(rows) == 1, "聚合行必须落库(修复前只在内存参与汇总)"
assert rows[0].is_active == 1
assert rows[0].receipt_id is None
assert rows[0].settlement_id == out["id"], "聚合行未回填 settlement_id"
def test_草稿态重生成_聚合不重复计入(self, client: TestClient, admin: dict) -> None:
self._scene(client, admin["headers"])
first = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
).json()["data"]
second = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert second.status_code == 200, second.text
out = second.json()["data"]
# 重生成金额不翻倍(聚合行不做废旧行会 45.95→52.79)也不漏行
assert out["total_payable"] == first["total_payable"] == "45.95"
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
def test_月末取参_预约变更影响本月聚合(self, client: TestClient, admin: dict) -> None:
"""取参锚点=所属月月末:10-25 生效的预约变更应影响 2026-10 月结。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 2,
"effective_date": "2026-10-25",
"remark": "用例:月末取参锚点",
"items": [
{
"param_code": "manager.share.team",
"dimension_value": "",
"value": "20",
}
],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
self._scene(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
manager_items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2]
# 39.11×20% + 39.11×5% = 9.7775 → 9.78(按生成日取参会得 6.84)
assert manager_items[0]["performance_sum"] == "9.78"
# 台账快照固化月末取参日与实际比例原文
factory = get_session_factory()
with factory() as session:
row = session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == "2026-10",
PerformanceLedger.calc_type == 2,
)
).scalar_one()
assert row.snapshot_json["params_as_of"] == "2026-10-31"
assert row.rate_text == "聚合:20%+5%"
assert str(row.receipt_date) == "2026-10-31"
def test_聚合基数开关_经理个人户计入可配置(self, client: TestClient, admin: dict) -> None:
"""#12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户业绩并入基数。
直插一行经理个人户台账(绩效 100,模拟该类业务经单笔侧计提后的
结果)——默认 0 时聚合 6.84 不变;开关 1 后基数 39.11+100=139.11。
"""
manager, _receipt = self._scene(client, admin["headers"])
factory = get_session_factory()
with factory() as session:
session.add(
PerformanceLedger(
receipt_id=None,
calc_version=1,
calc_type=1,
business_id=0,
business_no="t_个人户",
personnel_id=manager["id"],
personnel_name=manager["personnel_name"],
role_code="sales_manager",
customer_type="manager_personal",
product_category="mixed",
receipt_date=date(2026, 10, 15),
rate_text="0",
performance_amount=Decimal("100.00"),
barter_applied=0,
payable_amount=Decimal("0"),
settlement_month="2026-10",
is_probation=0,
)
)
session.commit()
def _manager_share() -> str:
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2]
assert len(items) == 1
return items[0]["performance_sum"]
# 默认(开关 0=不计入):基数 39.11 → 6.84(与既有金标准一致)
assert _manager_share() == "6.84"
# 开关 1(立即生效,月末 2026-10-31 取参命中):139.11×12.5% + ×5% = 24.34425
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": "用例:#12 开关计入",
"items": [
{
"param_code": "manager.personal.included",
"dimension_value": "",
"value": "1",
}
],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
assert _manager_share() == "24.34"
class TestAdjustment:
"""调整单:已锁定月份补发/扣减的唯一通道。"""
def test_新建调整单_开关关闭即生效(self, client: TestClient, admin: dict) -> None:
# 显式关开关(用例不依赖种子默认值)
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
person = _person(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/adjustments",
json={
"settlement_month": "2026-10",
"personnel_id": person["id"],
"direction": 1,
"amount": "500.00",
"reason": "补发差额",
},
headers=admin["headers"],
)
# 守 ENABLE_CODE_BY_TYPE 漏登记 type=3:修复前此处恒 400「未知的审批对象类型」
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["status"] == 2 # 已生效
def test_金额恒正校验(self, client: TestClient, admin: dict) -> None:
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
person = _person(client, admin["headers"])
base = {
"settlement_month": "2026-10",
"personnel_id": person["id"],
"direction": 1,
"reason": "金额校验用例",
}
for bad in ("-500.00", "0", "0.00", "abc", "1e3"):
resp = client.post(
f"{_SETTLEMENTS}/adjustments",
json={**base, "amount": bad},
headers=admin["headers"],
)
assert resp.status_code == 400, f"amount={bad!r} 应 400:{resp.text}"
class TestLockInterception:
"""锁定后源数据写路径全量拦截(回款编辑/删除、发货增改删、核算/重算)。"""
def _lock_scene(self, client: TestClient, admin: dict) -> tuple[dict, dict, dict, dict]:
"""已核算+未核算各一笔回款、两张 10 月发货单,然后锁定 2026-10。
返回 (月结, 业务, 未核算回款, 无回款引用的发货单)。
"""
biz = _calc_one(client, admin["headers"])
resp = client.post(
_RECEIPTS,
json={"business_id": biz["id"], "receipt_date": "2026-10-22", "amount": "100.00"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
unsettled = resp.json()["data"]
resp = client.post(
_SHIPMENTS,
json={
"business_id": biz["id"],
"ship_date": "2026-10-05",
"items": [{"product_name": "t_发2", "quantity": "1", "sale_price": "1.00"}],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
free_shipment = resp.json()["data"]
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement = resp.json()["data"]
resp = client.post(f"{_SETTLEMENTS}/{settlement['id']}/submit", headers=admin["headers"])
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["status"] == 3
return settlement, biz, unsettled, free_shipment
def test_锁定后_未核算回款禁改禁删(self, client: TestClient, admin: dict) -> None:
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
resp = client.put(
f"{_RECEIPTS}/{unsettled['id']}",
json={
"business_id": biz["id"],
"receipt_date": "2026-10-23",
"amount": "88.00",
},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
resp = client.delete(f"{_RECEIPTS}/{unsettled['id']}", headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
def test_锁定后_发货增改删400_非锁定月放行(self, client: TestClient, admin: dict) -> None:
_settlement, biz, _unsettled, free_shipment = self._lock_scene(client, admin)
items = [{"product_name": "t_发x", "quantity": "1", "sale_price": "1.00"}]
# 新增:落锁定月 400
resp = client.post(
_SHIPMENTS,
json={"business_id": biz["id"], "ship_date": "2026-10-08", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
# 新增:非锁定月放行(拦截不误伤其它月份)
resp = client.post(
_SHIPMENTS,
json={"business_id": biz["id"], "ship_date": "2026-11-02", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
nov = resp.json()["data"]
# 编辑:把非锁定月的发货单改入锁定月 400
resp = client.put(
f"{_SHIPMENTS}/{nov['id']}",
json={"ship_date": "2026-10-15", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
# 编辑:原月即锁定月 400
resp = client.put(
f"{_SHIPMENTS}/{free_shipment['id']}",
json={"ship_date": "2026-10-06", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
# 删除:锁定月的发货单 400(拦截先于「被回款引用」判定)
resp = client.delete(f"{_SHIPMENTS}/{free_shipment['id']}", headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
def test_锁定后_首算与重算400(self, client: TestClient, admin: dict) -> None:
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
from app.models import Receipt
with get_session_factory()() as session:
settled_id = int(
session.execute(
select(Receipt.id).where(
Receipt.business_id == biz["id"],
Receipt.settlement_status == 1,
)
).scalar_one()
)
resp = client.post(
f"{_CALC}/receipts/{settled_id}/recalc",
json={"reason": "锁定月重算"},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
def test_非法月份_月结生成与调整单均返回400(client: TestClient, admin: dict) -> None:
"""月份范围必须在 schema 拦住:`2026-13` 不能传到 monthrange 变成 500。"""
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-13"}, headers=admin["headers"]
)
assert resp.status_code == 400, resp.text
resp = client.post(
f"{_SETTLEMENTS}/adjustments",
json={
"settlement_month": "2026-13",
"personnel_id": 1,
"direction": 1,
"amount": "100.00",
"reason": "t_非法月份",
},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text

View File

@ -0,0 +1,28 @@
"""核算落库辅助纯函数测试。
守的是:金额两位固化的舍入模式 —— quantize 缺省是上下文 ROUND_HALF_EVEN
(银行家舍入),x.xx5 会舍成偶数尾数,与业务四舍五入口径差 1 分
(docstring 声称 HALF_UP,必须用测试锁死实际行为)。
"""
from decimal import Decimal
from app.services.calc_service import _q2
class TestQ2:
"""金额两位小数(HALF_UP)。"""
def test_半进位一律入(self) -> None:
# 缺省 ROUND_HALF_EVEN 会舍成 2.34(4 是偶数)
assert _q2(Decimal("2.345")) == Decimal("2.35")
def test_负数半进位远离零(self) -> None:
assert _q2(Decimal("-2.345")) == Decimal("-2.35")
def test_普通进位不变(self) -> None:
assert _q2(Decimal("39.111111")) == Decimal("39.11")
assert _q2(Decimal("39.115000")) == Decimal("39.12")
def test_None直通(self) -> None:
assert _q2(None) is None

View File

@ -11,10 +11,14 @@ from fractions import Fraction
from app.services.formula import (
apply_barter,
clamp_weight_r,
converted_profit,
manager_team_share,
monthly_salary,
payment_days,
payment_weight,
performance_amount,
probation_annual_note,
profit_rate,
salesperson_rate,
start_payment_ok,
@ -57,9 +61,14 @@ class TestSalespersonRate:
# 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」
assert _sp_rate(361) == Fraction(4, 1000)
def test_第1天按公式为899比90000_非取整为10(self) -> None:
# 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000
assert _sp_rate(1) == Fraction(899, 90000)
def test_首日取整10千分_图示口径裁决(self) -> None:
# 需规图示「第 1 天 10‰」2026-10-19 裁决覆盖公式(公式值 9.9889‰ 作废):
# 首日不递减、直接取起始值;第 2 天起才进入公式段
assert _sp_rate(1) == Fraction(10, 1000)
def test_第2天起进入公式递减段(self) -> None:
# 10‰ − 2×4/360‰ = 9.9778‰(首日取整裁决只影响第 1 天,第 2 天起不变)
assert _sp_rate(2) == Fraction(10, 1000) - Fraction(2 * 4, 360 * 1000)
def test_第180天线性段中点千分之8(self) -> None:
assert _sp_rate(180) == Fraction(8, 1000)
@ -137,3 +146,68 @@ class TestModifiers:
def test_起付判定_未达到60点(self) -> None:
assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False
class TestClampWeightR:
"""R 钳制:向下取整 + [0,100] 封顶封底(从预览内联提取的共用实现)。"""
def test_向下取整(self) -> None:
assert clamp_weight_r(Decimal("5999.99"), Decimal("10000")) == 59
def test_封顶100_封底0(self) -> None:
assert clamp_weight_r(Decimal("20000"), Decimal("10000")) == 100
assert clamp_weight_r(Decimal("0"), Decimal("10000")) == 0
def test_销售额0_防御0(self) -> None:
assert clamp_weight_r(Decimal("100"), Decimal("0")) == 0
class TestMonthlySalary:
"""月工资分岗口径三档(对照表 #8)。"""
def test_INCLUDE_绩效加基本(self) -> None:
assert monthly_salary(Decimal("1000"), Decimal("3000"), "INCLUDE") == Decimal("4000")
def test_DEDUCT_绩效减基本_可为负(self) -> None:
assert monthly_salary(Decimal("1000"), Decimal("3000"), "DEDUCT") == Decimal("-2000")
def test_EXCLUDE_仅绩效(self) -> None:
assert monthly_salary(Decimal("1000"), Decimal("3000"), "EXCLUDE") == Decimal("1000")
def test_基本缺失按0(self) -> None:
assert monthly_salary(Decimal("1000"), None, "INCLUDE") == Decimal("1000")
def test_未知口径报错(self) -> None:
import pytest
with pytest.raises(ValueError):
monthly_salary(Decimal("1"), Decimal("1"), "UNKNOWN")
class TestProbationAnnualNote:
"""试用期年累计说明:超阈值才有 note,金额精确到分。"""
def test_未超阈值_无说明(self) -> None:
assert probation_annual_note(Decimal("18000"), Decimal("18000")) is None
assert probation_annual_note(Decimal("100"), Decimal("18000")) is None
def test_超额_给出精确差额(self) -> None:
note = probation_annual_note(Decimal("20000.5"), Decimal("18000"))
assert note is not None and "2000.50" in note
class TestManagerTeamShare:
"""经理 12.5%+5% 聚合(#12 暂口径:基数不含经理个人户业绩——由调用方
传入的基数集合保证,本函数只做乘法与求和)。"""
def test_两段乘加_精确分数(self) -> None:
# 入参为百分数(与种子一致:12.5 表示 12.5%):10000×12.5%=1250、50000×5%=2500
got = manager_team_share(
Decimal("10000"), Decimal("50000"), team_pct=Fraction(25, 2), company_pct=Fraction(5)
)
assert got == Decimal("3750")
def test_零基数_零结果(self) -> None:
assert manager_team_share(
Decimal("0"), Decimal("0"), team_pct=Fraction(0), company_pct=Fraction(0)
) == Decimal("0")

View File

@ -40,6 +40,11 @@ EXPECTED_TABLES = {
"shipment_items",
"receipts",
"customer_rating_grades",
"customer_ratings",
"performance_ledgers",
"monthly_settlements",
"settlement_items",
"adjustment_orders",
}
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束

View File

@ -0,0 +1,55 @@
"""月份校验单元测试。
守的口径:报表、月结、调整单入口都必须拦住 `2026-13`、`0000-01` 这类
非法月份。只校验长度会让 ValueError 一路传到 `calendar.monthrange`,
最终变成 500 而不是参数错误 400。
"""
import pytest
from pydantic import ValidationError
from app.core.month import parse_month, validate_month
from app.schemas.report import DepartmentSummaryQuery, PersonalDetailQuery
from app.schemas.settlement import AdjustmentCreateIn, SettlementGenerateIn
class Test月份校验:
"""合法月份通过,非法月份在入口即拒绝。"""
@pytest.mark.parametrize("month", ["2026-01", "2026-12", "2000-06"])
def test_合法月份_通过(self, month: str):
assert validate_month(month) == month
year, mon = parse_month(month)
assert (year, mon) == (int(month[:4]), int(month[5:7]))
@pytest.mark.parametrize(
"month",
["2026-13", "2026-00", "2026-1", "2026-1a", "0000-01", "abc", "2026/10"],
)
def test_非法月份_抛ValueError(self, month: str):
with pytest.raises(ValueError):
validate_month(month)
@pytest.mark.parametrize("month", ["2026-13", "2026-00", "0000-01"])
def test_月结与调整单_schema拒绝非法月份(self, month: str):
with pytest.raises(ValidationError):
SettlementGenerateIn(month=month)
with pytest.raises(ValidationError):
AdjustmentCreateIn(
settlement_month=month,
personnel_id=1,
direction=1,
amount="100.00",
reason="测试",
)
@pytest.mark.parametrize("month", ["2026-13", "2026-00", "0000-01"])
def test_报表查询_schema拒绝非法月份(self, month: str):
with pytest.raises(ValidationError):
DepartmentSummaryQuery(settlement_month=month)
with pytest.raises(ValidationError):
PersonalDetailQuery(settlement_month=month)
def test_报表可选月份_空串规范为None(self):
"""axios 未剔除空串时不能误报格式错误。"""
assert PersonalDetailQuery(settlement_month="").settlement_month is None

View File

@ -18,7 +18,9 @@ from app.services.param_registry import (
validate_and_normalize,
)
# 与 db/migration 迁移种子保持一致的 18 项编码(V3 十六项 + V6 审批开关两项)
# 与 db/migration 迁移种子保持一致的 22 项编码(V3 十六项 + V6 审批开关两项
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
# V10 增工作台预警规则三项 #10)
EXPECTED_CODES = {
"salesperson.rate.start",
"salesperson.rate.slope",
@ -35,17 +37,21 @@ EXPECTED_CODES = {
"product.threshold",
"salary.basic.mode",
"probation.annual.threshold",
"probation.salary.basic",
"approval.secondary_allocation.enabled",
"approval.monthly_settlement.enabled",
"approval.adjustment.enabled",
"manager.personal.included",
"warning.enabled",
"warning.receipt.overdue.days",
"warning.approval.timeout.days",
}
class TestRegistry:
"""注册表元数据完整性(与迁移种子同源)。"""
def test_注册表共18项(self) -> None:
assert len(PARAM_REGISTRY) == 18
def test_注册表共22项(self) -> None:
assert len(PARAM_REGISTRY) == 22
def test_编码集合与迁移种子一致(self) -> None:
assert set(PARAM_REGISTRY) == EXPECTED_CODES
@ -70,10 +76,11 @@ class TestRegistry:
assert spec.default_value in spec.options
def test_BOOL开关_仅接受0与1(self) -> None:
"""V6 首例 BOOL 注册的两个审批开关:合法值与非法值边界。"""
"""审批开关 BOOL 组(V6 两个 + V7 调整单):合法值与非法值边界。"""
for code in (
"approval.secondary_allocation.enabled",
"approval.monthly_settlement.enabled",
"approval.adjustment.enabled",
):
spec = PARAM_REGISTRY[code]
assert spec.value_type == "BOOL"

View File

@ -0,0 +1,422 @@
"""报表辅助纯函数与 Excel 模板构建单元测试(M4 ⑤)。
这组用例守的是「不测会静默出错」的三类点:
- 区间分桶边界错一档 → 金额进错桶,但报表仍然「能打开」,没人会发现
- 标签映射漏一个枚举值 → 报表少一类数据或显示英文编码
- 导出模板缺签字栏 / 金额被转 float / 文本公式注入 → 交给财务的签字稿失真
"""
import io
from datetime import date
from decimal import Decimal
from openpyxl import load_workbook
from app.core import report_meta
from app.core.constants import CUSTOMER_TYPES, PRODUCT_CATEGORIES
from app.services import report_export
class Test区间分桶:
"""回款天数 → 8 档区间(含 ≤0 与无天数两个兜底桶)。"""
def test_边界值全部落入正确区间(self):
cases = [
(None, "none"),
(-5, "le0"),
(0, "le0"),
(1, "1-30"),
(30, "1-30"),
(31, "31-60"),
(60, "31-60"),
(61, "61-90"),
(90, "61-90"),
(91, "91-180"),
(180, "91-180"),
(181, "181-360"),
(360, "181-360"),
(361, "361+"),
(1000, "361+"),
]
for days, code in cases:
assert report_meta.bucket_of(days).code == code, f"days={days}"
def test_八档且编码唯一(self):
buckets = report_meta.get_payment_day_buckets()
assert len(buckets) == 8
assert len({b.code for b in buckets}) == 8
class Test标签覆盖:
"""标签键集合必须覆盖业务常量——防常量新增后报表静默少算/显示编码。"""
def test_客户类型标签覆盖全部类型(self):
assert set(report_meta.CUSTOMER_TYPE_LABELS) >= CUSTOMER_TYPES
def test_产品类别标签覆盖常量与混合业务(self):
assert set(report_meta.PRODUCT_CATEGORY_LABELS) >= PRODUCT_CATEGORIES
assert "mixed" in report_meta.PRODUCT_CATEGORY_LABELS
def _build(code: str, rows: list[dict], buckets: dict | None = None) -> bytes:
return report_export.build_workbook(
code=code,
title="测试报表",
filters="统计月份 2026-10",
rows=rows,
summary=None,
buckets=buckets,
template_version="V1.0",
operator_name="admin",
company_name="衍水集团",
)
def _sheet_text(content: bytes) -> str:
workbook = load_workbook(io.BytesIO(content))
return "\n".join(
str(cell.value)
for row in workbook.active.iter_rows()
for cell in row
if cell.value is not None
)
def _personal_row(**overrides) -> dict:
row = {
"business_no": "YW001",
"ship_date": date(2026, 10, 1),
"receipt_date": date(2026, 10, 20),
"payment_days": 20,
"product_category": "municipal_water",
"customer_type": "personal",
"role_name": "营销员",
"rate_text": "88/9000",
"weight_r": 100,
"performance_amount": Decimal("39.11"),
"payable_amount": Decimal("39.11"),
}
row.update(overrides)
return row
class TestExcel模板:
"""导出稿是交给财务复核与领导签字的,结构缺失等同于没有模板。"""
def test_页眉标题签字栏齐全(self):
text = _sheet_text(_build("personal-detail", [_personal_row()]))
assert "衍水集团" in text
assert "测试报表" in text
assert "统计月份 2026-10" in text
assert "模板版本:V1.0" in text
assert "财务复核" in text
assert "领导签字" in text
def test_空数据不炸且给提示行(self):
text = _sheet_text(_build("personal-detail", []))
assert "本筛选条件无数据" in text
assert "财务复核" in text
def test_金额为数值单元格且两位格式(self):
# openpyxl 写入 Decimal 后读回为 int/float(Excel 单元格本身是数值),
# 这里断言「是数值而非文本」+ 显示格式;计算精度在服务层 Decimal 侧保证
content = _build("personal-detail", [_personal_row()])
workbook = load_workbook(io.BytesIO(content))
sheet = workbook.active
amount_cells = [
cell
for row in sheet.iter_rows()
for cell in row
if isinstance(cell.value, (int, float))
and Decimal(str(cell.value)) == Decimal("39.11")
]
assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)"
assert amount_cells[0].number_format == "#,##0.00"
def test_合计行等于明细之和且标签只出现一次(self):
rows = [
_personal_row(performance_amount=Decimal("10.00"), payable_amount=Decimal("10.00")),
_personal_row(performance_amount=Decimal("5.50"), payable_amount=Decimal("5.50")),
]
content = _build("personal-detail", rows)
workbook = load_workbook(io.BytesIO(content))
cells = [cell for row in workbook.active.iter_rows() for cell in row]
values = [
Decimal(str(cell.value))
for cell in cells
if isinstance(cell.value, (int, float))
]
assert Decimal("15.50") in values
# 「合计」标签只写一次(写满每个文本列是回归缺陷)
assert sum(1 for cell in cells if cell.value == "合计") == 1
def test_文本公式注入被前置单引号(self):
evil = "=HYPERLINK(\"http://evil\")"
content = _build("personal-detail", [_personal_row(business_no=evil)])
workbook = load_workbook(io.BytesIO(content))
values = [
cell.value
for row in workbook.active.iter_rows()
for cell in row
if isinstance(cell.value, str)
]
assert any(value.startswith("'=") for value in values), "公式注入未转义"
def test_回款统计附两个维度对比sheet(self):
buckets = {
"by_customer_type": [
{
"label": "个人户",
"receipt_amount": Decimal("100.00"),
"performance_amount": Decimal("1.00"),
"payable_amount": Decimal("1.00"),
"ledger_count": 1,
}
],
"by_product_category": [],
}
content = _build(
"payment-stats",
[
{
"label": "1-30 天",
"receipt_amount": Decimal("100.00"),
"receipt_count": 1,
"performance_amount": Decimal("1.00"),
"payable_amount": Decimal("1.00"),
"ledger_count": 1,
}
],
buckets=buckets,
)
workbook = load_workbook(io.BytesIO(content))
assert workbook.sheetnames == ["回款统计", "客户类型对比", "产品类别对比"]
def test_业务统计状态转中文(self):
rows = [
{
"business_no": "YW001",
"business_date": date(2026, 10, 1),
"customer_name": "测试客户",
"owner_personnel_name": "张三",
"status": 2,
"total_sale_amount": Decimal("100.00"),
"total_ship_amount": Decimal("100.00"),
"total_received_amount": Decimal("100.00"),
"performance_amount": Decimal("1.00"),
"payable_amount": Decimal("1.00"),
}
]
text = _sheet_text(_build("business-stats", rows))
assert "已完成" in text
def _member(
personnel_id: int,
name: str,
*,
kind: str = "regular",
performance: str = "0.00",
payable: str = "0.00",
adjust: str = "0.00",
reason: str | None = None,
) -> dict:
return {
"personnel_id": personnel_id,
"personnel_name": name,
"role_code": "salesperson",
"is_probation": 0,
"member_kind": kind,
"unassigned_reason": reason,
"performance_sum": Decimal(performance),
"payable_sum": Decimal(payable),
"adjust_extra": Decimal(adjust),
"adjust_deduct": Decimal("0"),
"adjust_net": Decimal(adjust),
"net_total": Decimal(payable) + Decimal(adjust),
"ledger_ids": [],
}
def _department_data(
team_text: str = "12.5",
company_text: str = "5",
*,
second_has_aggregate: bool = False,
) -> dict:
"""一份含成员、零成员团队与未分配人员的部门汇总出参。
``second_has_aggregate`` 打开后两位经理都有月结聚合行,用于验证
全公司 5% 按人数累计(零成员团队默认不发 5%)。
"""
second_aggregate = Decimal("600.00") if second_has_aggregate else Decimal("0")
share_count = 2 if second_has_aggregate else 1
aggregate_total = Decimal("1850.00") + second_aggregate
payable_total = Decimal("10000.00") + Decimal("2000.00") + aggregate_total
return {
"settlement_month": "2026-10",
"params": {
"team_text": team_text,
"company_text": company_text,
"manager_personal_included": 0,
"params_as_of": "2026-10-31",
},
"teams": [
{
"manager_id": 1,
"manager_name": "张经理",
"manager_status": 1,
"member_count": 1,
"team_base": Decimal("10000.00"),
"team_share_amount": Decimal("1250.00"),
"company_share_amount": Decimal("600.00"),
"has_aggregate": True,
"aggregate_amount": Decimal("1850.00"),
"team_subtotal": Decimal("1850.00"),
"company_base_ratio": Decimal("0.8333"),
"adjust_extra": Decimal("0"),
"adjust_deduct": Decimal("0"),
"adjust_net": Decimal("0"),
"net_total": Decimal("1850.00"),
"frozen_amount": None,
"aggregate_ledger_id": None,
"members": [
_member(
2,
"李业务",
performance="10000.00",
payable="10000.00",
)
],
},
{
"manager_id": 3,
"manager_name": "王经理",
"manager_status": 1,
"member_count": 0,
"team_base": Decimal("0"),
"team_share_amount": Decimal("0"),
"company_share_amount": second_aggregate,
"has_aggregate": second_has_aggregate,
"aggregate_amount": second_aggregate,
"team_subtotal": second_aggregate,
"company_base_ratio": Decimal("0"),
"adjust_extra": Decimal("0"),
"adjust_deduct": Decimal("0"),
"adjust_net": Decimal("0"),
"net_total": second_aggregate,
"frozen_amount": None,
"aggregate_ledger_id": None,
"members": [],
},
],
"unassigned": [
_member(
4,
"赵未分配",
performance="2000.00",
payable="2000.00",
reason="无经理或上级非营销经理",
)
],
"company": {
"team_base_total": Decimal("10000.00"),
"company_base": Decimal("12000.00"),
"company_share_pct": company_text,
"company_share_amount": Decimal("600.00"),
"company_share_count": share_count,
"company_share_total": Decimal("600.00") * share_count,
},
"reconciliation": {
"team_member_payable_total": Decimal("10000.00"),
"unassigned_payable_total": Decimal("2000.00"),
"team_share_total": Decimal("1250.00"),
"company_share_total": Decimal("600.00") * share_count,
"aggregate_payable_total": aggregate_total,
"aggregate_rounding_diff": Decimal("0"),
"adjust_net_total": Decimal("0"),
"payable_total": payable_total,
"team_base_total": Decimal("10000.00"),
"unassigned_performance_total": Decimal("2000.00"),
"other_team_base": Decimal("0"),
"other_team_member_payable_total": Decimal("0"),
"other_team_share_total": Decimal("0"),
"other_team_company_share_total": Decimal("0"),
"other_team_aggregate_total": Decimal("0"),
"other_adjust_net_total": Decimal("0"),
"company_base": Decimal("12000.00"),
},
"totals": {},
}
def _build_department(data: dict) -> bytes:
return report_export.build_workbook(
code="department-summary",
title="部门绩效工资汇总表",
filters="统计月份 2026-10",
rows=[],
summary=None,
buckets=None,
department=data,
template_version="V1.2",
operator_name="admin",
company_name="衍水集团",
)
class Test部门汇总签字稿:
"""签字稿少了团队分成/5% 块/未分配/零成员团队,合计就会小于应发。"""
def test_五块顺序完整(self):
text = _sheet_text(_build_department(_department_data()))
titles = [
"一、团队分成汇总",
"二、全公司分成(每位有聚合行",
"三、对账汇总",
"四、团队成员明细",
"五、未分配人员明细",
]
positions = [text.index(title) for title in titles]
assert positions == sorted(positions), "签字稿块顺序错乱"
assert "财务复核" in text and "领导签字" in text
def test_零成员团队与未分配行存在(self):
text = _sheet_text(_build_department(_department_data()))
assert "王经理" in text
assert "赵未分配" in text
assert "无经理或上级非营销经理" in text
def test_全公司分成按计入次数累计(self):
"""两位有聚合行的经理各计一次 5%;零成员团队不产生分成。"""
one = _department_data()
text = _sheet_text(_build_department(one))
assert text.count("全公司分成(每位有聚合行") == 1
assert "共 1 次" in text
assert "13850" in text
two = _department_data(second_has_aggregate=True)
text = _sheet_text(_build_department(two))
assert "共 2 次" in text
# 团队成员应付 10000 + 未分配 2000 + 聚合 2450
assert "14450" in text
def test_应发合计取对账口径(self):
text = _sheet_text(_build_department(_department_data()))
assert "应发合计" in text
# Excel 数值单元格读回为 int/float,金额断言只看数值本身
assert "13850" in text
def test_聚合行舍入差单列(self):
data = _department_data()
data["reconciliation"]["aggregate_rounding_diff"] = Decimal("-0.02")
text = _sheet_text(_build_department(data))
assert "聚合行舍入差" in text
assert "-0.02" in text
def test_比例标题参数化(self):
text = _sheet_text(_build_department(_department_data("20", "6")))
assert "团队分成(20%)" in text
assert "6%" in text
assert "12.5%" not in text
assert "5%" not in text.replace("6%", "")

View File

@ -0,0 +1,29 @@
"""月结取参锚点测试。
守的是:月结参数快照按**所属月月末**解析,而不是生成日 —— 用 date.today()
取参会让补生成/重新生成历史月份的经理分成与月工资随操作日期漂移,
同一月份两次生成金额不同(违背「参数按生效日期时点化」红线)。
"""
from datetime import date
from app.services.settlement_service import _month_end
class TestMonthEnd:
"""YYYY-MM → 所属月最后一天(含大小月与闰年)。"""
def test_大月31日(self) -> None:
assert _month_end("2026-10") == date(2026, 10, 31)
def test_小月30日(self) -> None:
assert _month_end("2026-09") == date(2026, 9, 30)
def test_二月平年28日(self) -> None:
assert _month_end("2026-02") == date(2026, 2, 28)
def test_二月闰年29日(self) -> None:
assert _month_end("2028-02") == date(2028, 2, 29)
def test_跨年十二月(self) -> None:
assert _month_end("2026-12") == date(2026, 12, 31)

View File

@ -16,9 +16,14 @@
SET NAMES utf8mb4;
-- 删除顺序遵循外键依赖:先删子表
DROP TABLE IF EXISTS `performance_ledgers`;
DROP TABLE IF EXISTS `settlement_items`;
DROP TABLE IF EXISTS `adjustment_orders`;
DROP TABLE IF EXISTS `monthly_settlements`;
DROP TABLE IF EXISTS `approval_steps`;
DROP TABLE IF EXISTS `approval_orders`;
DROP TABLE IF EXISTS `business_secondary_allocations`;
DROP TABLE IF EXISTS `customer_ratings`;
DROP TABLE IF EXISTS `system_settings`;
DROP TABLE IF EXISTS `param_values`;
DROP TABLE IF EXISTS `param_change_batches`;
@ -430,7 +435,7 @@ INSERT INTO business_roles (id, role_code, role_name, calc_template, description
(3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1);
INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19);
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
@ -448,9 +453,14 @@ INSERT INTO param_definitions (id, param_code, param_name, param_group, value_ty
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13),
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14),
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16),
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)', 17),
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);0=结算生成即生效,1=走财务复核→领导审核审批流', 18);
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17),
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18),
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19),
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20),
-- 预警规则参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21),
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22),
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
(1, 1, 'salesperson', '2026-02-17', '10', 1),
@ -471,9 +481,16 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1),
(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1),
(18, 15, '', '2026-01-01', '18000', 1),
(19, 16, '', '2026-01-01', '3000', 1),
(20, 17, '', '2026-01-01', '0', 1),
(21, 18, '', '2026-01-01', '0', 1);
-- 审批开关默认开启(澄清 #5/#7 答复 2026-10-19);试用期统一工资参数已撤
-- (#16 答复:按人员档案 personnel.salary_standard 逐人维护),本脚本直达终态
(20, 17, '', '2026-01-01', '1', 1),
(21, 18, '', '2026-01-01', '1', 1),
(22, 19, '', '2026-01-01', '0', 1),
(25, 20, '', '2026-01-01', '0', 1),
-- 预警规则参数生效值(M4 ⑧)
(26, 21, '', '2026-10-19', '1', 1),
(27, 22, '', '2026-10-19', '90', 1),
(28, 23, '', '2026-10-19', '3', 1);
-- ---------------------------------------------------------------------------
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
@ -679,17 +696,50 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
('C', 60, 74, 3, '合格'),
('D', 0, 59, 4, '待改进');
-- ---------------------------------------------------------------------------
-- 客户评级落库(V11 迁移;每客户一行,等级名为快照固化无外键)
-- ---------------------------------------------------------------------------
CREATE TABLE customer_ratings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
remark VARCHAR(255) COMMENT '备注',
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
scorer_name VARCHAR(64) NOT NULL COMMENT '评级操作人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_customer_ratings PRIMARY KEY (id),
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
-- ---------------------------------------------------------------------------
-- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批)
-- V11 迁移:增加品牌标语/页脚/备案号/站点标题/站点图标与图片版本号
-- (公司名称/logo/标语/页脚/备案号 + 站点信息,企业档案字段方案已废弃未发布)
-- ---------------------------------------------------------------------------
CREATE TABLE system_settings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页与导出页眉展示)',
logo_path VARCHAR(255) COMMENT 'logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页/侧边栏/浏览器标题展示)',
logo_path VARCHAR(255) COMMENT '公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0',
site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)',
brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)',
footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)',
icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)',
favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)',
favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_system_settings PRIMARY KEY (id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息,全系统单行)' COLLATE utf8mb4_general_ci;
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息与站点信息,全系统单行)' COLLATE utf8mb4_general_ci;
CREATE TABLE approval_orders (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
@ -734,6 +784,7 @@ CREATE TABLE business_secondary_allocations (
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id),
CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id),
CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
@ -742,6 +793,118 @@ CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondar
CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id);
-- ---------------------------------------------------------------------------
-- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单)
-- ---------------------------------------------------------------------------
CREATE TABLE monthly_settlements (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
settlement_month VARCHAR(7) NOT NULL COMMENT '结算月份(YYYY-MM;全表唯一)',
status TINYINT NOT NULL COMMENT '状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)' DEFAULT '1',
total_performance NUMERIC(14, 2) COMMENT '绩效金额合计(可负)',
total_payable NUMERIC(14, 2) COMMENT '应付绩效合计(可负)',
person_count TINYINT NOT NULL COMMENT '结算人数' DEFAULT '0',
snapshot_json JSON COMMENT '月度总览快照(生成时刻的确认回款口径说明与汇总参数)',
approval_order_no VARCHAR(32) COMMENT '关联审批单号(冗余固化;开关关闭自动通过时也有单)',
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
locked_at DATETIME COMMENT '锁定时间(status=3 时回填)',
locked_by VARCHAR(64) COMMENT '锁定操作人(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_monthly_settlements PRIMARY KEY (id),
CONSTRAINT uk_monthly_settlements_settlement_month UNIQUE (settlement_month),
CONSTRAINT fk_monthly_settlements_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月度结算表(按月一行;通过即锁定,锁定后走调整单)' COLLATE utf8mb4_general_ci;
CREATE TABLE settlement_items (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '月结ID(monthly_settlements.id;头删明细随删)',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '结算人员ID(personnel.id)',
personnel_name VARCHAR(64) NOT NULL COMMENT '结算人员姓名(冗余固化)',
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(主岗,聚合锚点)',
calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合',
performance_sum NUMERIC(14, 2) NOT NULL COMMENT '绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)',
payable_sum NUMERIC(14, 2) NOT NULL COMMENT '应付绩效合计(可负)',
basic_amount NUMERIC(14, 2) COMMENT '月工资标准(personnel.salary_standard 当月快照)',
salary_mode VARCHAR(16) COMMENT '月工资口径快照:INCLUDE/DEDUCT/EXCLUDE',
net_amount NUMERIC(14, 2) COMMENT '净工资 = 绩效±基本工资(按口径;试用期 0)',
is_probation TINYINT NOT NULL COMMENT '试用期:1绩效照算不发放(net=0,超年累计记 note)0否' DEFAULT '0',
probation_note VARCHAR(255) COMMENT '试用期说明(年累计超阈值部分年底一次性结算)',
snapshot_json JSON COMMENT '个人明细快照(台账行引用列表)',
CONSTRAINT pk_settlement_items PRIMARY KEY (id),
CONSTRAINT fk_settlement_items_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE,
CONSTRAINT fk_settlement_items_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月结明细表(按人固化;支持负数工资;锁定后只读)' COLLATE utf8mb4_general_ci;
CREATE TABLE adjustment_orders (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}',
settlement_month VARCHAR(7) NOT NULL COMMENT '调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '调整人员ID(personnel.id)',
personnel_name VARCHAR(64) NOT NULL COMMENT '调整人员姓名(冗余固化)',
direction TINYINT NOT NULL COMMENT '方向:1补发 2扣减',
amount NUMERIC(14, 2) NOT NULL COMMENT '调整金额(恒正,方向表达语义)',
reason VARCHAR(255) NOT NULL COMMENT '扣补说明(必填,审批与追溯锚点)',
status TINYINT NOT NULL COMMENT '状态:1待审批 2已生效 3已驳回 4已撤回' DEFAULT '1',
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_adjustment_orders PRIMARY KEY (id),
CONSTRAINT uk_adjustment_orders_order_no UNIQUE (order_no),
CONSTRAINT fk_adjustment_orders_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT fk_adjustment_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)' COLLATE utf8mb4_general_ci;
CREATE TABLE performance_ledgers (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
receipt_id BIGINT UNSIGNED COMMENT '回款ID(receipts.id;聚合行可为 NULL)',
calc_version TINYINT NOT NULL COMMENT '核算版本(重算+1,旧行 is_active=0)' DEFAULT '1',
is_active TINYINT NOT NULL COMMENT '生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)' DEFAULT '1',
calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合(月度)' DEFAULT '1',
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)',
business_no VARCHAR(32) NOT NULL COMMENT '业务编号(冗余固化,列表免联查)',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属营销人员ID(personnel.id;核算归属锚点)',
personnel_name VARCHAR(64) NOT NULL COMMENT '归属营销人员姓名(冗余固化)',
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(business_roles.role_code,计提公式锚点)',
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal/manager_personal/enterprise',
product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)',
ship_date DATE COMMENT '发货日期(回款天数基准)',
receipt_date DATE NOT NULL COMMENT '回款日期(参数版本匹配日,裁决 #11)',
payment_days INTEGER COMMENT '回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)',
rate_text VARCHAR(32) NOT NULL COMMENT '计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)',
converted_profit NUMERIC(14, 2) COMMENT '折算毛利润额(可负;混合业务为各行汇总)',
performance_amount NUMERIC(14, 2) COMMENT '绩效金额(可负,裁决:负绩效照算不归零)',
barter_applied TINYINT NOT NULL COMMENT '以物抵顶折算:1已按 80% 折算 0否' DEFAULT '0',
weight_r INTEGER COMMENT '支付权重 R(回款占销售额百分比向下取整)',
payment_weight NUMERIC(7, 6) COMMENT '支付权重系数 R×R(展示用)',
payable_amount NUMERIC(14, 2) COMMENT '当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)',
settlement_month VARCHAR(7) NOT NULL COMMENT '归属月(回款日期所在月,YYYY-MM;月结与报表维度)',
is_probation TINYINT NOT NULL COMMENT '试用期:1照算不发放 0否' DEFAULT '0',
missing_cost TINYINT NOT NULL COMMENT '成本缺失:1有明细行缺成本(该行毛利按 0 计)0否' DEFAULT '0',
snapshot_json JSON COMMENT '计算快照(所用参数版本与行级折算明细,重算可回溯)',
settlement_id BIGINT UNSIGNED COMMENT '所属月结ID(月结生成时回填;NULL=未入结)',
calc_reason VARCHAR(255) COMMENT '核算/重算原因(重算必填留痕)',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_performance_ledgers PRIMARY KEY (id),
CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id),
CONSTRAINT fk_performance_ledgers_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT,
CONSTRAINT fk_performance_ledgers_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT fk_performance_ledgers_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_performance_ledgers_business_id ON performance_ledgers (business_id);
CREATE INDEX idx_performance_ledgers_month_active ON performance_ledgers (settlement_month, is_active);
CREATE INDEX idx_performance_ledgers_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type);
CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (personnel_id);
ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单';
-- 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
INSERT INTO system_settings (id, company_name, logo_path) VALUES (1, '衍水集团', NULL);
@ -753,4 +916,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('79bcc5aa98d4');
INSERT INTO alembic_version (version_num) VALUES ('c4e2a86f9b13');

View File

@ -0,0 +1,16 @@
-- Running upgrade ba0217f5091b -> 90653814a421
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21, 1);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22, 1);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23, 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (26, 21, '', '2026-10-19', '1', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (27, 22, '', '2026-10-19', '90', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (28, 23, '', '2026-10-19', '3', 1);
UPDATE alembic_version SET version_num='90653814a421' WHERE alembic_version.version_num = 'ba0217f5091b';

View File

@ -0,0 +1,38 @@
-- Running upgrade 90653814a421 -> c4e2a86f9b13
CREATE TABLE customer_ratings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
remark VARCHAR(255) COMMENT '备注',
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
scorer_name VARCHAR(64) NOT NULL COMMENT '评级操作人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_customer_ratings PRIMARY KEY (id),
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
ALTER TABLE system_settings ADD COLUMN site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)';
ALTER TABLE system_settings ADD COLUMN brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)';
ALTER TABLE system_settings ADD COLUMN footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)';
ALTER TABLE system_settings ADD COLUMN icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)';
ALTER TABLE system_settings ADD COLUMN favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)';
ALTER TABLE system_settings ADD COLUMN logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0';
ALTER TABLE system_settings ADD COLUMN favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0';
UPDATE alembic_version SET version_num='c4e2a86f9b13' WHERE alembic_version.version_num = '90653814a421';

View File

@ -53,7 +53,8 @@ CREATE TABLE business_secondary_allocations (
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id),
CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id);

View File

@ -0,0 +1,117 @@
-- Running upgrade 79bcc5aa98d4 -> 3bca400421c6
CREATE TABLE monthly_settlements (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
settlement_month VARCHAR(7) NOT NULL COMMENT '结算月份(YYYY-MM;全表唯一)',
status TINYINT NOT NULL COMMENT '状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)' DEFAULT '1',
total_performance NUMERIC(14, 2) COMMENT '绩效金额合计(可负)',
total_payable NUMERIC(14, 2) COMMENT '应付绩效合计(可负)',
person_count TINYINT NOT NULL COMMENT '结算人数' DEFAULT '0',
snapshot_json JSON COMMENT '月度总览快照(生成时刻的确认回款口径说明与汇总参数)',
approval_order_no VARCHAR(32) COMMENT '关联审批单号(冗余固化;开关关闭自动通过时也有单)',
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
locked_at DATETIME COMMENT '锁定时间(status=3 时回填)',
locked_by VARCHAR(64) COMMENT '锁定操作人(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_monthly_settlements PRIMARY KEY (id),
CONSTRAINT fk_monthly_settlements_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_monthly_settlements_settlement_month UNIQUE (settlement_month)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月度结算表(按月一行;通过即锁定,锁定后走调整单)' COLLATE utf8mb4_general_ci;
CREATE TABLE adjustment_orders (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}',
settlement_month VARCHAR(7) NOT NULL COMMENT '调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '调整人员ID(personnel.id)',
personnel_name VARCHAR(64) NOT NULL COMMENT '调整人员姓名(冗余固化)',
direction TINYINT NOT NULL COMMENT '方向:1补发 2扣减',
amount NUMERIC(14, 2) NOT NULL COMMENT '调整金额(恒正,方向表达语义)',
reason VARCHAR(255) NOT NULL COMMENT '扣补说明(必填,审批与追溯锚点)',
status TINYINT NOT NULL COMMENT '状态:1待审批 2已生效 3已驳回 4已撤回' DEFAULT '1',
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_adjustment_orders PRIMARY KEY (id),
CONSTRAINT fk_adjustment_orders_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT fk_adjustment_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_adjustment_orders_order_no UNIQUE (order_no)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)' COLLATE utf8mb4_general_ci;
CREATE TABLE settlement_items (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '月结ID(monthly_settlements.id;头删明细随删)',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '结算人员ID(personnel.id)',
personnel_name VARCHAR(64) NOT NULL COMMENT '结算人员姓名(冗余固化)',
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(主岗,聚合锚点)',
calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合',
performance_sum NUMERIC(14, 2) NOT NULL COMMENT '绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)',
payable_sum NUMERIC(14, 2) NOT NULL COMMENT '应付绩效合计(可负)',
basic_amount NUMERIC(14, 2) COMMENT '月工资标准(personnel.salary_standard 当月快照)',
salary_mode VARCHAR(16) COMMENT '月工资口径快照:INCLUDE/DEDUCT/EXCLUDE',
net_amount NUMERIC(14, 2) COMMENT '净工资 = 绩效±基本工资(按口径;试用期 0)',
is_probation TINYINT NOT NULL COMMENT '试用期:1绩效照算不发放(net=0,超年累计记 note)0否' DEFAULT '0',
probation_note VARCHAR(255) COMMENT '试用期说明(年累计超阈值部分年底一次性结算)',
snapshot_json JSON COMMENT '个人明细快照(台账行引用列表)',
CONSTRAINT pk_settlement_items PRIMARY KEY (id),
CONSTRAINT fk_settlement_items_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT fk_settlement_items_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月结明细表(按人固化;支持负数工资;锁定后只读)' COLLATE utf8mb4_general_ci;
CREATE TABLE performance_ledgers (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
receipt_id BIGINT UNSIGNED COMMENT '回款ID(receipts.id;聚合行可为 NULL)',
calc_version TINYINT NOT NULL COMMENT '核算版本(重算+1,旧行 is_active=0)' DEFAULT '1',
is_active TINYINT NOT NULL COMMENT '生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)' DEFAULT '1',
calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合(月度)' DEFAULT '1',
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)',
business_no VARCHAR(32) NOT NULL COMMENT '业务编号(冗余固化,列表免联查)',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属营销人员ID(personnel.id;核算归属锚点)',
personnel_name VARCHAR(64) NOT NULL COMMENT '归属营销人员姓名(冗余固化)',
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(business_roles.role_code,计提公式锚点)',
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal/manager_personal/enterprise',
product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)',
ship_date DATE COMMENT '发货日期(回款天数基准)',
receipt_date DATE NOT NULL COMMENT '回款日期(参数版本匹配日,裁决 #11)',
payment_days INTEGER COMMENT '回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)',
rate_text VARCHAR(32) NOT NULL COMMENT '计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)',
converted_profit NUMERIC(14, 2) COMMENT '折算毛利润额(可负;混合业务为各行汇总)',
performance_amount NUMERIC(14, 2) COMMENT '绩效金额(可负,裁决:负绩效照算不归零)',
barter_applied TINYINT NOT NULL COMMENT '以物抵顶折算:1已按 80% 折算 0否' DEFAULT '0',
weight_r INTEGER COMMENT '支付权重 R(回款占销售额百分比向下取整)',
payment_weight NUMERIC(7, 6) COMMENT '支付权重系数 R×R(展示用)',
payable_amount NUMERIC(14, 2) COMMENT '当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)',
settlement_month VARCHAR(7) NOT NULL COMMENT '归属月(回款日期所在月,YYYY-MM;月结与报表维度)',
is_probation TINYINT NOT NULL COMMENT '试用期:1照算不发放 0否' DEFAULT '0',
missing_cost TINYINT NOT NULL COMMENT '成本缺失:1有明细行缺成本(该行毛利按 0 计)0否' DEFAULT '0',
snapshot_json JSON COMMENT '计算快照(所用参数版本与行级折算明细,重算可回溯)',
settlement_id BIGINT UNSIGNED COMMENT '所属月结ID(月结生成时回填;NULL=未入结)',
calc_reason VARCHAR(255) COMMENT '核算/重算原因(重算必填留痕)',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_performance_ledgers PRIMARY KEY (id),
CONSTRAINT fk_performance_ledgers_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT fk_performance_ledgers_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT,
CONSTRAINT fk_performance_ledgers_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT,
CONSTRAINT uk_performance_ledgers_receipt_id_calc_version UNIQUE (receipt_id, calc_version)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_performance_ledgers_business_id ON performance_ledgers (business_id);
CREATE INDEX idx_performance_ledgers_month_active ON performance_ledgers (settlement_month, is_active);
CREATE INDEX idx_performance_ledgers_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type);
CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (personnel_id);
ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单';
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道', 19, 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (22, 19, '', '2026-01-01', '0', 1);
UPDATE alembic_version SET version_num='3bca400421c6' WHERE alembic_version.version_num = '79bcc5aa98d4';

View File

@ -0,0 +1,22 @@
-- Running upgrade 3bca400421c6 -> c7d2e5a81f30
UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (23, 17, '', '2026-10-19', '1', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (24, 18, '', '2026-10-19', '1', 1);
DELETE FROM param_values WHERE id = 19;
DELETE FROM param_definitions WHERE id = 16;
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20, 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (25, 20, '', '2026-01-01', '0', 1);
ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id);
ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version;
UPDATE alembic_version SET version_num='c7d2e5a81f30' WHERE alembic_version.version_num = '3bca400421c6';

View File

@ -0,0 +1,13 @@
-- Running upgrade c7d2e5a81f30 -> ba0217f5091b
SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'business_secondary_allocations' AND CONSTRAINT_NAME = 'uk_business_secondary_allocations_business_personnel');
SET @ddl := IF(@uk_exists = 0, 'ALTER TABLE business_secondary_allocations ADD CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id)', 'DO 0');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE alembic_version SET version_num='ba0217f5091b' WHERE alembic_version.version_num = 'c7d2e5a81f30';

View File

@ -38,6 +38,8 @@ services:
restart: unless-stopped
environment:
TZ: Asia/Shanghai
# 健康检查脚本在容器内展开(密码不进宿主进程命令行,模式同 mysql)
REDIS_PASSWORD: ${REDIS_PASSWORD}
# appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:

View File

@ -0,0 +1,289 @@
# T+ 12.3 字段映射草案(v0.1,2026-10-20)
> 本文仅记录字段级事实与页面索引,不复制厂商文档正文。
> 基线:`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`(75 项)+ v1.3 IT 18 项 + 《T+12.3数据字典》。
## 0. 结论摘要
- 覆盖:清单 **75/75** 项均已给出 T+ 侧候选字段与系统目标列;
- 状态分布:候选待确认 42 / 字典明确 23 / 未检出 9 / 需 IT 1;
- 数据字典无法让 IT 18 项中的任何一项完全闭环;但 9 项可改写为「候选字段选择题」,6 项部分可答,2 项纯 IT,1 项转业务。
- 收敛后:**13 项 IT 待确认 + 1 组业务裁决**(见第 12 节)。
- 关键缺口:人员工号/入职日期/试用期无字段、客户 status 缺失、回款 payment_method/confirm_status 待补、明细行无显式行号、无源端更新时间与负向事件模型。
## 1. 编制说明与证据等级
| 标记 | 含义 |
| --- | --- |
| `D` | 字典明确 |
| `D?` | 候选待确认 |
| `IT` | 需 IT |
| `B` | 需业务 |
| `S` | 系统自维护 |
| `N` | 未检出 |
- `D` 仅用于字段存在、类型明确且语义直接匹配的项;涉及「取哪个字段 / 业务口径」一律标 `D?` 或 `IT`。
- 字典来源为 T+ 12.3 SchemaDoc;同一字段可能标注 `T+12.1/12.2/12.3` 版本,映射按 12.3 可用为准。
## 2. 公共字段与同步元数据
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | T+ 原始单据号 / 档案编码 | 各表 code(人员/存货/客户/订单/出库/收付款单均有) | nvarchar(30~64) | — | personnel/products/customers/businesses/shipments/receipts.source_no | TRIM;作废重开是否换号待 IT(#4) | 必须 | D? | AA_Person.code、AA_Inventory.code、AA_Partner.code、SA_SaleOrder.code、SA_SaleDelivery.code、ARAP_ReceivePayment.code |
| 2 | T+ 系统内部标识 | 各表 id(整型主键) | int(4) | — | 六表 t_plus_id | int → 字符串存储;辅助对账/改名追溯 | 建议 | D | 各表 id(主键) |
系统侧六张主表已预留 `source_no / t_plus_id / sync_batch_no / synced_at`;`source_no` 当前为普通索引,IT 确认幂等键后升级唯一约束。
## 3. 人员档案
人员主档;隶属关系需经部门树/部门负责人推断,T+ 无直属上级字段。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 人员唯一编码(工号)★ | AA_Person.code | nvarchar(32) | — | personnel.source_no(建议补 personnel_code) | TRIM;编码优先、姓名辅助核对;是否即工号待 IT(#19) | 必须 | D? | AA_Person.code(编号) |
| 2 | 姓名 | AA_Person.name | nvarchar(200) | — | personnel.personnel_name | TRIM;仅辅助核对,不作唯一键 | 必须 | D | AA_Person.name(名称) |
| 3 | 在职状态 | AA_Person.disabled | tinyint(1) | 0 启用 / 1 停用(候选) | personnel.status(1 在职 / 0 离职) | disabled=1 → 0;无离职日期字段,离职时间需 IT 确认 | 必须 | D? | AA_Person.disabled(停用) |
| 4 | 是否试用期 | 未检出 | — | — | personnel.is_probation | T+ 人员档案无试用期字段;系统内人工维护或按入职日期规则推导 | 必须 | N | AA_Person 全表检索无 probation/试用 字段 |
| 5 | 入职日期 | 未检出(仅 madeDate 制单日期、birthday 出生日期) | — | — | personnel.hire_date | T+ 无入职日期字段;确认是否有自定义项承载,否则人工维护 | 必须 | N | AA_Person 全表检索无 hire/entry/入职 字段 |
| 6 | 联系电话 | AA_Person.mobilePhoneNo / officePhoneNo | nvarchar(20) / nvarchar(100) | — | personnel.phone | 优先 mobilePhoneNo,空则 officePhoneNo;日志脱敏 | 建议 | D | AA_Person.mobilePhoneNo(手机)、officePhoneNo(办公电话) |
| 7 | 业务员 ↔ 营销经理隶属关系 | AA_Person.iddepartment → AA_Department.idparent / iddirector | int(4) | — | personnel.manager_id | T+ 人员档案无直属上级字段;可经部门树/部门负责人推断,规则待确认;否则系统人工指定 | 必须 | D? | AA_Person.iddepartment(部门id);AA_Department.idparent(上级id)、iddirector(部门负责人) |
| 8 | 人员负责区域 / 产品辐射范围 | 未检出(可用 priuserdefnvc* 自定义项承载,需 IT 确认) | — | — | personnel_regions.region_name | 无专门字段则系统内人工维护 | 建议 | N | AA_Person 无区域/辐射范围字段 |
| 9 | 银行账户名 / 账号 / 开户行 | 未检出(AA_Partner 有银行账号字段,人员档案无) | — | — | 暂不落库 | 一期不采集;如后续代发工资再扩展 | 可选 | N | AA_Person 无银行账户字段 |
## 4. 产品档案
产品主档不含库存量与售价,数量取现存量表、价格取价目表/订单明细。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 产品名称 | AA_Inventory.name | nvarchar(200) | — | products.product_name | TRIM;与规格型号组成系统组合唯一键 | 必须 | D | AA_Inventory.name(名称) |
| 2 | 规格型号 | AA_Inventory.specification | nvarchar(200) | — | products.spec_model | 空值归一为空串 | 必须 | D | AA_Inventory.specification(规格型号) |
| 3 | 计量单位 | AA_Inventory.idunit → 计量单位表 | int(4) | — | products.unit | 按 idunit join 单位表取名称;单位表名待索引确认 | 建议 | D? | AA_Inventory.idunit(计量单位id)、idunitgroup |
| 4 | 数量 | ST_CurrentStock.baseQuantity(现存量) | decimal(13) | — | products.stock_quantity | 按存货+仓库汇总;语义(现存量/在途/累计销量)待 IT(#12) | 必须 | D? | ST_CurrentStock.baseQuantity(主计量数量);AA_Inventory 无库存量字段 |
| 5 | 销售单价 | AA_InventoryPrice(零售价/批发价)、AA_InventoryUnitPrice.retailPrice;订单明细实际成交价 | decimal(13~17) / nvarchar(1000) 公式 | — | products.sale_price | 产品档案参考价取价目表;绩效以订单/发货明细实际单价为准 | 必须 | D? | AA_InventoryPrice、AA_InventoryUnitPrice;AA_Inventory 无销售单价字段 |
| 6 | 产品分类(类目) | AA_Inventory.idinventoryclass → AA_InventoryClass.name | int(4) | — | products.product_category | T+ 分类仅辅助判定市政水利/轨交;系统分类人工维护不覆盖 | 必须 | D? | AA_Inventory.idinventoryclass(存货分类id)、AA_InventoryClass.name |
| 7 | 计量单位换算关系(如存在) | AA_Inventory_MultiUnit / AA_InventoryUnitPrice.rateOfExchange | decimal(13) | — | 暂不实现(系统单单位存储) | 如业务有多单位换算需求,另行设计 | 可选 | D? | AA_Inventory_MultiUnit、AA_InventoryUnitPrice.rateOfExchange(换算率) |
## 5. 业务单据(销售订单)
成本额在订单明细仅有最新成本,出库明细才有成本金额;销售额需按数量×单价计算。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 订单单号 | SA_SaleOrder.code | nvarchar(30) | — | businesses.source_no | TRIM;不得写入内部 business_no | 必须 | D | SA_SaleOrder.code(编号) |
| 2 | 业务(订单)日期 | SA_SaleOrder.voucherdate | datetime(8) | — | businesses.business_date | 截断为 DATE | 必须 | D | SA_SaleOrder.voucherdate(单据日期) |
| 3 | 客户标识 | SA_SaleOrder.idcustomer → AA_Partner.code | int(4) | — | businesses.customer_id | 按客户编码解析;回退公司名称+负责人 | 必须 | D? | SA_SaleOrder.idcustomer(客户id)、idsettlecustomer(结算客户id) |
| 4 | 负责营销人员 | SA_SaleOrder.idclerk → AA_Person.code | int(4) | — | businesses.owner_personnel_id | 按人员编码解析;新插入写 T+ 负责人、更新是否覆盖人工归属待裁决 | 必须 | D? | SA_SaleOrder.idclerk(业务员id) |
| 5 | 协助营销人员 | 未检出独立协助人字段 | — | — | businesses.assistant_personnel_id | 业务已回馈 T+ 合同只挂单一负责人;协助人系统内记录 | 建议 | N | SA_SaleOrder 无 assist/协助 字段 |
| 6 | 特殊业务标记 | SA_SaleOrder.idbusinesstype → 业务类型表 | int(4) | 业务类型枚举待字典/IT 提供 | businesses.is_special | 业务类型是否等同特殊业务待确认;无则人工标记 | 必须 | D? | SA_SaleOrder.idbusinesstype(业务类型id) |
| 7 | 明细行 · 产品名称 + 规格 | SA_SaleOrder_b.idinventory → AA_Inventory.name/specification | int(4) | — | business_items.product_name / spec_model | 冗余固化名称与规格;未建档产品允许自由录入 | 必须 | D | SA_SaleOrder_b.idinventory(存货id) |
| 8 | 明细行 · 数量 | SA_SaleOrder_b.quantity | decimal(13) | — | business_items.quantity | 主计量数量;需 >0 校验 | 必须 | D | SA_SaleOrder_b.quantity(数量)、baseQuantity(主计量数量) |
| 9 | 明细行 · 销售单价 | SA_SaleOrder_b.price / taxPrice | decimal(13) | — | business_items.sale_price | price 为不含税单价、taxPrice 为含税单价;取哪个待确认 | 必须 | D? | SA_SaleOrder_b.price(单价)、taxPrice(含税单价) |
| 10 | 明细行 · 销售额 | SA_SaleOrder_b 无「销售额」字段;由数量×单价计算,或 taxAmount−tax | decimal(13) | — | business_items.sale_amount | 系统按数量×单价固化;T+ 用于对账 | 必须 | D? | SA_SaleOrder_b.quantity、price、taxAmount、tax |
| 11 | 明细行 · 成本额 ★ | SA_SaleOrder_b.LatestCost(最新成本);SA_SaleDelivery_b.costPrice/costAmount | decimal(13) | — | business_items.cost_amount | 取订单最新成本还是出库成本、成本类型/时点待 IT(#1) | 必须 | D? | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType |
| 12 | 项目 / 工程编号或名称(如存在) | SA_SaleOrder.idproject → 项目表 | int(4) | — | 建议预留 project_code(当前不落库) | 按项目查询与报表归集;是否建列待业务 | 可选 | D? | SA_SaleOrder.idproject(项目id)、SA_SaleOrder_b.idproject |
| 13 | 服务类 / 维修类及其他收入单据(如存在) | 未检索到服务/维修类独立单据表 | — | — | 暂人工录入 | 业务规则未定前不纳入自动同步 | 可选 | N | SA_* 表索引未发现服务/维修类主表 |
## 6. 发货单据(销售出库/发货)
发货日期有四个候选字段;运费未在发货单据检出。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 发货单号 | SA_SaleDelivery.code | nvarchar(30) | — | shipments.source_no | TRIM;不得写入内部 shipment_no | 必须 | D | SA_SaleDelivery.code(编号) |
| 2 | 发货日期 ★ | SA_SaleDelivery.voucherdate / SendDate / deliveryDate / auditeddate | datetime(8) | — | shipments.ship_date | 四个日期候选:单据日期/送货日期(T+12.3)/交货日期/审核日期;取哪个待 IT(#2) | 必须 | D? | SA_SaleDelivery.voucherdate、SendDate、deliveryDate、auditeddate |
| 3 | 关联订单号 | SA_SaleDelivery.SourceVoucherCode / idsourcevouchertype;明细 saleOrderCode/saleOrderId | nvarchar(50) / int(4) | — | shipments.business_id | 按订单号解析;主关联路径待 IT(#5) | 必须 | D? | SA_SaleDelivery.SourceVoucherCode、SA_SaleDelivery_b.saleOrderCode/saleOrderId/saleOrderDetailId |
| 4 | 运费金额 | 未检出运费字段(明细有 origExpenseAmount 费用分摊金额) | decimal(13) | — | shipments.freight_amount | T+ 运费可能在费用单/其他单据,需 IT 确认;系统暂人工 | 必须 | N | SA_SaleDelivery/SA_SaleDelivery_b 无 freight/运费字段 |
| 5 | 发货总金额 | SA_SaleDelivery.amount / taxAmount | decimal(13) | — | shipments.total_amount | 系统按明细汇总,不直写;T+ 金额用于对账 | 必须 | D | SA_SaleDelivery.amount(金额)、taxAmount(含税金额) |
| 6 | 明细行 · 产品/规格/数量/单价/行金额 | SA_SaleDelivery_b.idinventory / quantity / price / taxPrice / taxAmount | decimal(13) | — | shipment_items.product_name/spec_model/quantity/sale_price/amount | 行金额由系统按数量×单价固化;T+ 用于对账 | 必须 | D | SA_SaleDelivery_b.quantity、price、taxPrice、taxAmount |
| 7 | 发货单状态 | SA_SaleDelivery.voucherState / isCancel / auditeddate | int(4) / tinyint(1) | 单据状态枚举待 IT 提供 | 不落库(v1.4 统一) | 仅推已生效单据;状态随行供同步校验 | 必须 | D | SA_SaleDelivery.voucherState(单据状态)、isCancel(状态) |
| 8 | 承运商 / 物流单号 | SA_SaleDelivery.deliveryMode(运输方式 id) | int(4) | 运输方式枚举表待确认 | 暂不落库 | 承运商/物流单号无独立字段;如需建列再扩展 | 可选 | D? | SA_SaleDelivery.deliveryMode(运输方式) |
## 7. 回款单据(收款/核销)
到账日期无银行到账日字段;收款方式取结算方式表;确认/核销状态字段存在但枚举待 IT。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 回款单号 | ARAP_ReceivePayment.code | nvarchar(30) | — | receipts.source_no | TRIM;不得写入内部 receipt_batch_no | 必须 | D | ARAP_ReceivePayment.code(编号) |
| 2 | 到账日期 ★ | ARAP_ReceivePayment.voucherDate / auditedDate / createdTime | datetime(8) | — | receipts.receipt_date | 无银行到账日字段;取单据日期/审核日期/创建时间哪个待 IT(#3) | 必须 | D? | ARAP_ReceivePayment.voucherDate、auditedDate、createdTime |
| 3 | 回款金额 | ARAP_ReceivePayment.amount(本币)/ origAmount(原币) | decimal(13) | — | receipts.amount | 取本币金额;一期禁负数,红字规则待 #8 | 必须 | D | ARAP_ReceivePayment.amount、origAmount |
| 4 | 关联订单号 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;明细 saleordercode/saleorderid/sourceordercode | nvarchar(50) / int(4) | — | receipts.business_id | 回款↔订单按订单号解析;回款↔发货批次系统人工指定 | 必须 | D? | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b.saleordercode/sourceordercode |
| 5 | 收款方式 | ARAP_ReceivePayment.IdSettleStyle → AA_SettleStyle.code/name | int(4) | 结算方式枚举表(AA_SettleStyle:code/name/停用) | receipts.payment_method(待补列) | 枚举映射;完整枚举清单需 IT 提供 | 必须 | D? | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle |
| 6 | 以物抵顶物说明 | 收款方式(以物抵顶枚举值)+ ARAP_ReceivePayment.memo | int(4) / nvarchar(200) | 以物抵顶对应结算方式代码待 IT 提供 | receipts.is_barter / remark | 按结算方式识别;抵顶物说明暂入备注 | 建议 | D? | ARAP_ReceivePayment.IdSettleStyle、memo |
| 7 | 回款确认 / 核销状态 | ARAP_ReceivePayment.cancelState / voucherstate / auditedDate | int(4) / datetime(8) | 核销/单据状态枚举待 IT 提供 | receipts.confirm_status(待补列) | 字段存在;哪个可作为人工确认依据待 IT(#16) | 必须 | D? | ARAP_ReceivePayment.cancelState(核销状态)、voucherstate(单据状态) |
| 8 | 银行流水号 / 回单参考号 | 未检出银行流水号/回单号字段(MultiSettle.documentCode 为票据号) | — | — | 建议预留 bank_serial_no | 需 IT 确认是否有对应字段 | 建议 | N | ARAP_ReceivePayment/ARAP_ReceivePayment_MultiSettle 仅票据号 |
| 9 | 票据信息(票号 / 到期日 / 贴现说明) | ARAP_ReceivePayment_MultiSettle.documentCode / amount / idsettlestyle | nvarchar(30) / decimal(13) | 票据类型枚举待 IT | 系统暂以备注承载 | 无票据到期日字段;承兑到期核销信息需 IT 确认 | 建议 | D? | ARAP_ReceivePayment_MultiSettle.documentCode(票据号) |
| 10 | 币种 / 汇率(如存在外币业务) | ARAP_ReceivePayment.idcurrency / exchangeRate | int(4) / decimal(13) | 币种表待索引 | 一期不落库(人民币记账) | 如存在外币业务再扩展 | 可选 | D | ARAP_ReceivePayment.idcurrency、exchangeRate |
## 8. 客户档案
采用往来单位表 AA_Partner;客户编码/停用字段存在,status 列需系统补。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 公司名称 | AA_Partner.name | nvarchar(200) | — | customers.company_name | TRIM;与负责人组成系统唯一键 | 必须 | D | AA_Partner.name(名称) |
| 2 | 负责人姓名 | AA_Partner.Contact(联系人)/ representative(法人代表) | nvarchar(50) | — | customers.owner_name | 「负责人」对应 Contact 还是 representative 待 IT/业务确认 | 必须 | D? | AA_Partner.Contact、representative |
| 3 | 客户编码 / 状态 / 启用停用时间 | AA_Partner.code / disabled / madeDate / updated | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用(候选) | customers.source_no / status(待补列) | 启停时间无独立列,可用 updated 近似(需 IT 确认) | 必须 | D? | AA_Partner.code、disabled、madeDate、updated |
| 4 | 联系人 / 电话 / 地址 / 行业 | AA_Partner.Contact / TelephoneNo / MobilePhone / CustomerAddress / iddistrict | nvarchar(30~200) | — | customers.contact_person / contact_phone / address / industry | 行业无直接字段;地区按 iddistrict 关联 | 可选 | D? | AA_Partner.Contact、TelephoneNo、MobilePhone、CustomerAddress、iddistrict |
| 5 | 开票信息(税号 / 开票名称 / 开票地址电话) | AA_Partner.taxRegcode / name / CustomerAddress | nvarchar(50~200) | — | 系统暂仅供展示,不落库 | 税号存在;开票地址电话需 IT 确认字段 | 可选 | D? | AA_Partner.taxRegcode(纳税号)、CustomerAddress |
## 9. 同步完整性与补充字段
时间戳与负向字段部分存在;无统一事件表、无明细行号、无手续费字段。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 数据最后更新时间 / 单据创建时间 / 修改时间 | 各表 createdTime/createdtime、updated、madeDate、auditedDate、ts | datetime(8) / timestamp(8) | — | 建议新增 source_created_at / source_updated_at(当前无) | 本地 update_time 不可作为源端水位;增量水位字段待 IT(#9) | 必须 | D? | 各表 updated/createdTime;ts(分布时间戳) |
| 2 | 变更类型 / 单据事件:新增、修改、删除、作废、红冲、退货、冲销 | 各表 isCancel(状态)、isCounteractDelivery(冲抵销货)、returnQuntity(退货数量) | int(4) / tinyint(1) / decimal(13) | 状态/事件枚举待 IT 提供 | 建议事件表(当前无) | 无统一变更事件表;负向事实需事件模型,规则待 #8 | 必须 | D? | SA_SaleOrder.isCancel、SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity |
| 3 | 单据状态 / 财务审核状态 / 状态变更时间 | voucherState / auditedDate / auditor / auditorid | int(4) / datetime(8) / nvarchar(50) | 单据状态枚举待 IT | 不落库(仅同步校验) | 仅推已生效单据;状态随行校验 | 必须 | D | SA_SaleOrder/SA_SaleDelivery/ARAP_ReceivePayment 的 voucherState、auditedDate |
| 4 | 明细行行号 / 行状态 / 行级变更事件 | 明细表主键 ID、code(明细编号);无显式行号字段 | int(4) / nvarchar(30) | — | 建议补 source_line_no / source_item_id | 确认 code 是否即行号;否则用 T+ 明细 ID 作行级幂等键(待 IT) | 必须 | D? | SA_SaleOrder_b.id/code、SA_SaleDelivery_b.ID/code、ARAP_ReceivePayment_b.id/detailCode |
| 5 | 税率 / 税额 / 价税合计 / 折扣或折让 | 明细表 taxRate、tax、taxAmount、origTaxAmount、discount、discountRate、discountAmount | decimal(13) | — | 待业务 #20 答复后决定是否建列 | 字段齐全;金额是否含税、折扣关联方式待业务 | 必须 | D? | SA_SaleOrder_b/SA_SaleDelivery_b 税额与折扣字段 |
| 6 | 发货客户 / 收货人 / 收货地址 / 联系电话 | SA_SaleDelivery.idcustomer / address / contactPhone / idSendPerson / idConsignor | int(4) / nvarchar(100~200) | — | 建议预留(当前无列) | 用于核对发货对象与代发情形 | 必须 | D | SA_SaleDelivery.idcustomer、address、contactPhone、idSendPerson、idConsignor |
| 7 | 发货业务员 / 发货组织 / 仓库 | SA_SaleDelivery.idclerk / iddepartment / idwarehouse | int(4) | — | 建议预留(当前无列) | 识别代发、调拨与多组织差异 | 必须 | D | SA_SaleDelivery.idclerk、iddepartment、idwarehouse |
| 8 | 发货退回 / 红字发货 / 退回数量 / 退回金额 / 原发货单关联 | SA_SaleDelivery.isCounteractDelivery、_b.returnQuntity/returnQuntity2/backSentQuantity、sourceVoucher* | tinyint(1) / decimal(13) / nvarchar(50) | 冲抵/退货枚举待 IT | 待业务 #21 答复后决定是否建列 | 原单关联字段存在;是否冲减绩效待业务 | 必须 | D? | SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity、backSentQuantity |
| 9 | 收款客户 / 经手人 / 财务审核状态 | ARAP_ReceivePayment.idpartner / idHandler / auditor / voucherstate | int(4) / nvarchar(50) / int(4) | — | 建议预留(当前无列) | 核对实际付款主体与经办责任 | 必须 | D | ARAP_ReceivePayment.idpartner、idHandler、auditor、voucherstate |
| 10 | 收款分配明细 / 分配金额 / 未分配金额 / 分配对象 | ARAP_ReceivePayment_b(源单/订单/金额/未结算)、ARAP_StrikeBalance(核销) | decimal(13) / int(4) | — | 人工指定发货批次的辅助参考 | 分配关系仅参考;系统仍人工指定(对照表 #18) | 必须 | D? | ARAP_ReceivePayment_b.saleordercode/amount/origNoSettleAmount;ARAP_StrikeBalance |
| 11 | 预收款 / 保证金 / 代收款 / 折让 / 抵扣 | ARAP_ReceivePayment.paymentAmount/origPaymentAmount(预收/预付)、overPayment(超收转预收)、allowances(折让) | decimal(13) | — | 待业务 #22 答复后决定是否建列 | 字段存在;是否计入绩效回款待业务 | 必须 | D? | ARAP_ReceivePayment.paymentAmount、overPayment、allowances |
| 12 | 手续费 / 交易费用 / 收款净额 | 未检出手续费/交易费用字段 | — | — | 待业务 #23 答复后决定 | 需 IT 确认手续费是否在其他单据/字段 | 必须 | N | ARAP_ReceivePayment 无 fee/charge 字段 |
| 13 | 红字收款 / 收款作废 / 收款退回 / 冲销及原单关联 | ARAP_ReceivePayment.voucherstate / isReceiveFlag;ARAP_StrikeBalance 冲销 | int(4) / tinyint(1) | 作废/红字状态枚举待 IT | 待业务 #24 答复后决定 | 无红字收款专用字段;负向事实需事件模型 | 必须 | D? | ARAP_ReceivePayment.voucherstate、isReceiveFlag;ARAP_StrikeBalance |
| 14 | 客户编码 / 客户状态 / 启用停用时间 | AA_Partner.code / disabled | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | customers.source_no / status(待补列) | 同六-3;客户编码唯一性待 IT | 必须 | D | AA_Partner.code、disabled |
| 15 | 客户分类 / 渠道 / 区域 / 所属组织 | AA_Partner.idpartnerclass / customerType / iddistrict / idMarketingOrgan | int(4) | 分类/客商类型枚举待 IT | customers.customer_type(不覆盖);渠道无列 | 系统客户分类人工维护;渠道/区域作备考 | 必须 | D? | AA_Partner.idpartnerclass、customerType、iddistrict、idMarketingOrgan |
| 16 | 客户名称变更 / 编码停用合并前后的对应关系 | AA_Partner.code(终身不变)+ updated;无变更历史表(待确认) | nvarchar(32) | — | 靠 source_no 追溯同一主体 | 改名/换负责人/合并靠 code 串同一主体;变更历史需 IT 确认 | 必须 | D? | AA_Partner.code、updated、HasEverChanged |
| 17 | 人员部门 / 岗位 / 调岗时间 / 离职和组织变更时间 | AA_Person.iddepartment / position / updated;调岗历史表待确认 | int(4) / datetime(8) | — | 不落库(备考/排查) | 字段部分存在;调岗/离职时间需 IT 确认 | 必须 | D? | AA_Person.iddepartment、position、updated |
| 18 | 产品编码 / 产品状态 / 启用停用时间 | AA_Inventory.code / disabled / madeDate | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | products.source_no / status | 稳定匹配;停用后历史明细仍可回溯 | 必须 | D | AA_Inventory.code、disabled、MadeRecordDate |
| 19 | 字典和枚举取值范围 / 编码与名称对照说明 | 枚举表:AA_SettleStyle、业务类型表、状态字典(需随数据导出) | — | 枚举字典随数据提供 | 同步映射表 | 不能只给代码;需编码与名称对照 | 必须 | D? | AA_SettleStyle、AA_BusinessType 等枚举表 |
| 20 | 对账汇总信息 / 数据记录数 / 数量合计 / 金额合计 / 抽取时间 | 由 IT 提供抽取汇总(接口/视图层) | — | — | 同步日志/对账表 | 记录明细条数、金额合计、抽取时间 | 必须 | IT | 字典不提供;需对接方式确定 |
| 21 | 单据历史版本 / 修改留痕(如 T+ 提供) | 未检索到统一单据历史版本表(待 IT 确认) | — | — | 系统同步快照可比对 | 无此数据不阻塞 | 可选 | D? | 表索引未见 history 类表 |
## 10. 跨表关联与键
| 关系 | T+ 字段(字典) | 系统目标 | 状态 |
| --- | --- | --- | --- |
| 人员唯一键 | AA_Person.code | personnel.source_no(建议补 personnel_code) | D? |
| 客户唯一键 | AA_Partner.code;名称+联系人 | customers.source_no;company_name+owner_name | D? |
| 订单→发货 | SA_SaleDelivery.SourceVoucherCode / _b.saleOrderCode/saleOrderId | shipments.business_id | D? |
| 订单/发货→回款 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;_b.saleordercode | receipts.business_id | D? |
| 回款→发货批次 | 系统人工指定(对照表 #18) | receipts.shipment_id | S |
| 明细行幂等 | 明细表 ID/code;无显式行号 | 建议补 source_line_no | D? |
## 11. 枚举与状态
| 枚举 | T+ 来源 | 备注 |
| --- | --- | --- |
| 收款/结算方式 | AA_SettleStyle(code/name/disabled) | 完整枚举需随数据导出 |
| 单据状态 | 各单据 voucherState / isCancel | 仅同步校验,不落库 |
| 核销状态 | ARAP_ReceivePayment.cancelState | 含义待 IT |
| 业务类型 | SA_SaleOrder.idbusinesstype → 业务类型表 | 是否等同特殊业务待确认 |
| 产品分类 | AA_InventoryClass | 仅辅助系统分类人工维护 |
## 12. 系统侧目标表缺口
| 缺口 | 说明 | 建议 |
| --- | --- | --- |
| `personnel.personnel_code` | T+ 工号/编码是否即 `source_no` 未定 | 若不等则补唯一列 |
| `customers.status` / 启停时间 | T+ 有 disabled,系统无列 | 补 status 与停用时间 |
| `receipts.payment_method` / `confirm_status` | T+ 有结算方式与核销状态 | 补两列并接枚举 |
| `source_created_at` / `source_updated_at` | 本地 update_time 不可作源端水位 | 补列或建 staging 表 |
| `source_line_no` / `source_item_id` | 明细表无行号 | 补列支持行级幂等 |
| 负向事件模型 | 无删除/作废/红冲/退货事件表 | 设计事件表或冲销单 |
| `sync_log` / 失败明细表 | 完全缺失 | 同步模块前置 |
| T+ 配置项 | `.env` 有 `T_PLUS_*`,`Settings` 未定义 | 补配置字段与校验 |
## 13. IT 待确认事项收敛(18 → 13 + 1 组业务)
| 新# | 原# | 事项 | 字典证据 | 字典可答 | 仍需确认 | 优先级 |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType;AA_Inventory.invSCost/latestCost/avagCost | 字段存在性、类型精度、挂在订单还是出库单 | 请确认绩效成本取哪个字段(订单最新成本 / 出库成本 / 其他),取数时点与精度 | 高(阻塞核算) |
| 2 | 2+3 | 发货日与到账日口径 | 发货:SA_SaleDelivery.voucherdate/SendDate/deliveryDate/auditeddate;到账:ARAP_ReceivePayment.voucherDate/auditedDate/createdTime | 各日期字段存在性与类型 | 请分别确认发货日、到账日取哪个字段;签收日/银行到账日能否从 T+ 获取 | 高 |
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键;SA_SaleOrder.ismodifiedcode、HasEverChanged | 编码字段与主键关系 | 各单据 code 是否唯一终身不变;作废重开是否换号;建议以 code 作 source_no | 高 |
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode/idsourcevouchertype;SA_SaleDelivery_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b;ARAP_StrikeBalance | 关联字段与核销表存在性 | 请确认订单→发货→回款的主关联路径(按单号还是 ID),以及分批发货/部分核销如何取 | 高 |
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment/isSalesman;AA_Department.idparent/iddirector | 编码/部门/负责人字段存在;无直属上级字段 | AA_Person.code 是否即工号;业务员→营销经理隶属在 T+ 如何维护(部门负责人?自定义项?) | 高 |
| 6 | 7 | 对接方式与安全 | 字典不涉及 | 无 | REST/视图/中间表/文件;只读账号与凭据、接口文档、样例数据 | 高 |
| 7 | 8 | 负数/红字/退货/冲销规则 | SA_SaleDelivery.isCounteractDelivery;SA_SaleDelivery_b.returnQuntity/backSentQuantity;ARAP_ReceivePayment.voucherstate;ARAP_StrikeBalance | 负向字段/单据形式存在性 | 负向单据的实际使用形式、原单关联字段、是否推送;绩效影响转业务 | 高 |
| 8 | 9 | 存量起始与增量水位 | 各表 updated/createdTime/madeDate/auditedDate/ts | 时间戳字段存在性 | 存量起始日期;增量用哪个时间戳(updated? ts?);时区 | 中 |
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 编码/名称/负责人/停用字段 | 客户唯一键;改名/换负责人/合并如何映射同一主体;负责人取 Contact 还是 representative | 中 |
| 10 | 11+16 | 收款方式与确认/核销枚举 | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle.code/name;ARAP_ReceivePayment.cancelState/voucherstate | 枚举表与状态字段存在性 | 收款方式完整枚举与以物抵顶对应代码;核销/确认状态枚举含义及可否作为人工确认依据 | 高 |
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量/价格字段存在性 | 产品「数量」取现存量/在途/累计销量;发货单价取订单价还是发货时价、含税与否 | 中 |
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 字段/表存在性 | 特殊业务是否有可识别字段;市场拓展计划 T+ 是否有表可提供;无则人工维护 | 低 |
| 13 | 15 | 同步窗口 | 字典不涉及 | 无 | 每周同步时间点,避开结账/备份;失败重试策略 | 低 |
| B1 | 18 | 业务裁决 5 项 | 税额/折扣/退货/预收/手续费字段已定位(待答复后决定是否建列) | 字段存在性 | 含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述 | 业务裁决 |
### 原 18 项对照
| 原# | 事项 | 原优先级 | 收敛去向 |
| --- | --- | --- | --- |
| 1 | 成本额取数口径 | 高(阻塞核算) | 1 |
| 2 | 发货日期口径 | 高 | 2 |
| 3 | 到账日期口径 | 高 | 2 |
| 4 | 幂等唯一键 | 高 | 3 |
| 5 | 三单关联键 | 高 | 4 |
| 6 | 人员匹配键与经理隶属(v1.2 更新) | 高 | 5 |
| 7 | 对接方式与安全 | 高 | 6 |
| 8 | 负数 / 红字 / 退货 / 冲销处理规则 | 高 | 7 |
| 9 | 存量范围与增量口径 | 中 | 8 |
| 10 | 客户档案匹配与唯一键 | 中 | 9 |
| 11 | 收款方式枚举 | 中 | 10 |
| 12 | 产品「数量」语义 | 中 | 11 |
| 13 | 发货单价口径 | 中 | 11 |
| 14 | 特殊业务标记 | 低 | 12 |
| 15 | 同步窗口 | 低 | 13 |
| 16 | 回款确认 / 核销状态 | 高 | 10 |
| 17 | 市场拓展计划数据 | 低 | 12 |
| 18 | 口径裁决 5 项(移交业务方) | 业务裁决 | B1 |
## 14. 待确认清单
**IT(13 项)**:见第 13 节新# 1–13。
**业务(1 组)**:含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18 / 待澄清 #20–#24)。
**系统侧(不阻塞 IT)**:补 `personnel_code`、`customers.status`、`receipts.payment_method/confirm_status`、`source_updated_at`、`source_line_no`、`sync_log` 与 T+ 配置项。
## 15. 验证记录
- 解包:1,457 个 HTML、`.hhc/.hhk` 齐全、零字节 0;
- 全量解析:1,457 页、938 个 Table 类型页、**931 张表、21,019 个字段、0 失败页**;
- 编码:逐页检测,GB18030(无替换字符);
- 抽样核验:六类主表结构一致(6 列字段表),关键字段逐项比对通过;
- 输入 SHA-256:`3e8cd3146e22bdebe8bacda56b5dafbbd8be744c4279d7b2bbf3d7ecdc7e2726`;
## 附录 A:六类主表索引
| 类别 | T+ 表 | 字典页 GUID |
| --- | --- | --- |
| 人员 | AA_Person | `fcd00c17-6778-4c6a-90ae-3f0dbaf2c102` |
| 产品 | AA_Inventory | `93b57dfa-4ca4-411d-a883-6d26c8e01787` |
| 客户 | AA_Partner | `871e93dd-c2cf-4bd2-af91-cbeb5951ac5e` |
| 业务 | SA_SaleOrder | `9a51f8a9-658a-4f7c-9372-8af2257511ee` |
| 发货 | SA_SaleDelivery | `bef98b52-c9f0-4d80-8e62-8968cced5b52` |
| 回款 | ARAP_ReceivePayment | `709de6e4-3b3d-4cf8-b5ed-5f657d42599a` |
## 附录 B:75 项覆盖矩阵
| 节 | 项数 | 已映射 |
| --- | --- | --- |
| 公共要求 | 2 | 2 |
| 人员 | 9 | 9 |
| 产品 | 7 | 7 |
| 业务 | 13 | 13 |
| 发货 | 8 | 8 |
| 回款 | 10 | 10 |
| 客户 | 5 | 5 |
| 同步完整性 | 21 | 21 |
## 附录 C:失败页
无(`failures.csv` 为空)。
## 附录 D:复现方法
```text
1. C:/Windows/hh.exe -decompile <out> <TPlus123.chm>
2. py -3.14 -I 03_extract_fields.py --extracted <out> --out <out>
3. py -3.14 -I 08_build_md.py --out <out> --target <md>
```
---
*本文档由数据字典解析与人工核验生成,字段候选未经 IT 确认前不得视为同步契约。*

Binary file not shown.

Binary file not shown.

View File

@ -35,7 +35,7 @@
--ys-radius: 10px;
--ys-radius-sm: 7px;
--ys-font: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', -apple-system,
--ys-font: 'Noto Sans SC', 'PingFang SC', -apple-system,
'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
--ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
@ -46,8 +46,8 @@
/* 极微噪点:去除大色块的塑料感(内联 SVG,无外部依赖) */
--ys-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
/* 品牌字:思源黑体(现代几何黑体;实施时自托管 4 字子集,内网可用) */
--ys-font-brand: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
/* 品牌字:思源黑体(现代几何黑体;实施时自托管 4 字子集,内网可用);栈内禁用微软雅黑等需授权字体 */
--ys-font-brand: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@ -556,8 +556,8 @@
<div class="bc-cap">B · 思源黑体 300 细体 · 0.42em</div>
</div>
<div class="bc">
<div style="font-family:'Microsoft YaHei',sans-serif;font-weight:600;letter-spacing:.25em;font-size:16px">衍水集团</div>
<div class="bc-cap">C · 微软雅黑 600 · 0.25em</div>
<div style="font-weight:600;letter-spacing:.25em;font-size:16px">衍水集团</div>
<div class="bc-cap">C · 系统默认 600(不指定字体,展示回退效果)· 0.25em</div>
</div>
</div>
</section>
@ -936,7 +936,7 @@
<script src="echarts.min.js"></script>
<script>
/* 仪表盘图表:ECharts 定制主题(圆角柱 / 无网格线 / 浅灰轴标签)——实施时前端引入 echarts 依赖 */
const chartFont = "'PingFang SC','Microsoft YaHei','Noto Sans SC',sans-serif";
const chartFont = "'Noto Sans SC','PingFang SC',sans-serif";
const rev = echarts.init(document.getElementById('chartRev'), null, { renderer: 'svg' });
rev.setOption({
grid: { left: 0, right: 0, top: 6, bottom: 20, containLabel: true },

View File

@ -45,12 +45,12 @@
② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游)
③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户
│ 增量(2026-10-15):二次分配方案(合同刚建立时录入)
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清)
⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约
⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层
⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早
④ 绩效工资核算 ✅ ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1)
⑦ 复核审批流(新)✅ ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
⑤ 报表统计 ✅(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约(待 IT 18 项答复)
⑧ 工作台(新)✅ ← 消费 ④⑤⑥⑦ 数据的聚合展示层
⑨ 备份恢复与运维检查(新)✅ ← 独立于业务块,可与任一批次并行,建议尽早
```
### 1.2 顺序论证(要点)
@ -68,12 +68,12 @@
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 |
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 |
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(语义待澄清,见 3.3) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions) | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态 | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本 | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计。**✅ 已完成(2026-10-19)** | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发。**⏳ 阻塞(IT 18 项未答复)** | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions)。**✅ 已完成(2026-10-19;权限细目待业务清单)** | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态。**✅ 已完成(2026-10-19)** | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本。**✅ 已完成(2026-10-16)** | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
### 1.4 里程碑(交付批次)
@ -81,9 +81,9 @@
| --- | --- | --- |
| **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) |
| **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) |
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流 | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」;开工前必须澄清:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7);内部建议顺序 ①增量 → ③增量 → ⑦ 审批框架 → ④ 核算 → 月结与审批串联 |
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台 | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付) | 数据持续积累,备份先行;不依赖业务模块进度 |
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流。**✅ 已完成(2026-10-19,feat/m3-increments)** | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」已交付;余 3.3-1 答复与审批权限细目。开工前澄清项:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7,已参数化) |
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台。**✅ ⑤⑧ 已完成(2026-10-19);⑥ 待 IT 18 项答复** | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付)。**✅ 已完成(2026-10-16)** | 数据持续积累,备份先行;不依赖业务模块进度 |
### 1.5 需求依据对照
@ -95,7 +95,7 @@
| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24;增量见 3.1(一)(四) | 协助 25%;经理 8‰;26 天示例;含运费公式 |
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R);增量见 3.1(四) | — |
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——尚未回填需规与对照表,引用以第三章原文为准 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制,关系待业务澄清(3.3-1) |
| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——已随 SRS V2.1(2026-10-19)回填;《问题与解决方案对照表》是否回填由用户决定 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制;#1–#3 答复仍空白,开发侧暂定口径已实现(3.3.1) |
---
@ -610,11 +610,13 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
> **2026-10-20 回填**:上述开放问题均已有结论,原表保留为 2026-10-12 设计时快照。#1 → 澄清 #14(首日整 10‰,A 口径);#2 → #18(按 2‰ 回溯,双种子保留);#3 → #15(一期仅 FULL,BALANCE 留参数位);#4 → #16(撤 `probation.salary.basic`,读 `personnel.salary_standard`);#5 → #17(查看放开登录用户、修改仅管理员);#6 → 催办清单三、R3;#7 → #12(`manager.personal.included` 参数,默认 0=不计入)。
---
## 第三章 2026-10-15 功能清单核查补充(新增需求)
> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容尚未回填需规文档与《问题与解决方案对照表》,是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。
> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容已随《需求规格说明书》V2.1(2026-10-19)回填;《问题与解决方案对照表》是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。
### 3.1 需求原文(照录)
@ -656,6 +658,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
#### (六)系统设置
- 公司信息配置:公司名称、logo 等基本信息
- **2026-10-20 回填**:参数注册表已扩至 22 项(V8/V10 增补审批开关、试用期迁移、预警阈值等)
#### (七)参数配置强调
@ -666,7 +669,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
#### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接
- 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `businesses` 创建同事务或紧随其后);合同建立后是否允许补录/修改未明说,方案修改的留痕方式待设计;
- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1),存储上只存各参与人比例、不强制校验合计;
- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1);存储上只存各参与人比例、不强制校验合计。**2026-10-20 回填**:#1–#3 答复仍空白,开发侧已按暂定口径实现分摊拆行(余额归负责人、行合计守恒,见 3.3.1),答复后如有变更走口径调整;
- 「修改不影响已结算月份」→ 月结快照天然隔离(快照固化当月结果),方案表需支持编辑 + 修改留痕;
- 「已结算月份调整只能通过调整单或后续月份扣补」→ 调整单为统一承载机制,见 (四)。
@ -676,7 +679,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
| --- | --- |
| 经营绩效总览 | ④ 台账/月结(按人/团队/月份汇总) |
| 待办事项 | ⑦ 待审批单、⑥ 同步失败、回款未指定批次(M2 已有该状态可复用) |
| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器 |
| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器。**2026-10-20 回填**:方向已答复参数化(#10),首批三条按默认落 V10 参数(逾期 90 天/审批超时 3 天/同步失败待 ⑥) |
| 最近同步/计算/审批动态 | ⑥ 同步日志 + ④ 核算/月结记录 + ⑦ 审批记录 |
#### (三) 复核审批 → ⑦ 新模块
@ -687,9 +690,11 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
- 「审批意见与版本」→ 审批单带版本号:重算/改单产生新版本,旧版本与意见留痕不覆盖;
- **登录角色体系扩展**:营销后勤/财务/领导是系统新登录角色(`roles` 表基础设施已有;`permissions`/`role_permissions` 自 M1 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。
> **2026-10-20 回填**:审批框架与月结审批对象已交付(2026-10-19);权限点清单仍待业务方给出(澄清 #6,催办 B-6),当前按 role_code 粗粒度运行,营销后勤暂不可见审批列表。
#### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」定义待澄清(3.3-3);
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」已答复为人工确认、可手动关闭(3.3-3),月结按已确认回款取数;
- 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径;
- 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态);
- 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据;
@ -708,6 +713,8 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清)
> **回填状态(2026-10-20):** 下表保留 2026-10-15 原始问题与建议默认(历史快照);业务答复与落实情况见 3.3.1。3.3-1 仍无业务答复,开发侧已按暂定口径实现分摊拆行,答复后如有变更按口径调整。
| # | 事项 | 影响模块 | 建议默认处理 |
| --- | --- | --- | --- |
| 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 |
@ -718,13 +725,41 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 |
| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 |
### 3.3.1 答复回填(2026-10-19,2026-10-20 修订)
> **修订说明(2026-10-20):** 2026-10-19 版本把 24 项统一写成「全部落实并实现」,与 3.3-1 答复空白、开发侧暂定分摊的事实不符。本次按「业务答复 / 落实状态 / 关联项」重写;3.3-1 明确标注为开发侧暂定口径,不视为业务确认。
>
> 业务方 24 项答复于 2026-10-16 到达(回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)。分组:确认按建议默认 13 项(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#22/#23/#24,其中 #22 为模糊确认按不计入);与默认不同、已按答复落实 4 项(#5/#14/#16/#19);部分答复 3 项(#4/#6/#12);仍开放 4 项(#1/#2/#3/#20)。未答复项对外跟催见 `docs/待答复催办清单_2026-10-19.md`。
| 3.3# | 事项 | 业务答复(2026-10-16) | 落实状态(2026-10-20) | 关联项 / 证据 |
| --- | --- | --- | --- | --- |
| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 的关系) | **未答复**(澄清 #1–#3 空白) | **开发侧暂定口径已实现**(非业务答复):分配标的=绩效金额份额;参与人=份额×比例,负责人=全额−已分累计(余额归负责人);行合计守恒;合计>100% 与负责人自分 400 拦截;重算作废全部拆行。协助 25% 维持仅记录不核算。**答复后校准** | 澄清 #1/#2/#3;催办一、A-1 至 A-3;实现 ccfd206 |
| 2 | 二次分配审批开关默认值 | 默认开启,可手动关闭 | 已落实:V8 参数种子默认开启;关闭时提交即生效(自动通过留痕) | 澄清 #5;`approval.secondary_allocation.enabled` |
| 3 | 「确认回款」定义 | 需人工确认动作,可手动关闭 | 已落实:月结按已确认回款取数;回款确认状态可手动关闭 | 澄清 #8 |
| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认 | 按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读;营销后勤暂不可见审批列表;permissions/role_permissions 细目待清单 | 澄清 #6;催办一、B-6 |
| 5 | 风险预警规则 | 方向确认参数化;首批三条未点名 | 已按默认三条实现:`warning.enabled`、`warning.receipt.overdue.days=90`、`warning.approval.timeout.days=3`(V10);同步失败类待 ⑥ | 澄清 #10 |
| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 同一审批链;跨月需管理员授权并留痕 | 已落实:调整单走同一审批链;跨月调整需管理员授权并留痕 | 澄清 #9 |
| 7 | 待办事项范围 | 待审批 + 同步失败 + 回款未指定批次起步 | 已落实:工作台待办三项;⑥ 未建时同步项显式降级为「模块未启用」;待办按角色过滤 | 澄清 #11 |
#### 关联项表(与《待确认事项汇总_2026-10-16》及催办清单的对应)
| 3.3# | 澄清清单编号 | 催办清单位置 | 实现落点 |
| --- | --- | --- | --- |
| 1 | #1/#2/#3(#15 为作废条款关系) | 一、A-1 至 A-3 | 二次分摊拆行(ccfd206) |
| 2 | #5 | 已按答复执行(供核阅) | V8 迁移参数种子 |
| 3 | #8 | 已按答复执行(供核阅) | 月结取数(settlement_service) |
| 4 | #6 | 一、B-6 | permissions/role_permissions 待清单;当前按 role_code 粗粒度 |
| 5 | #10 | 已按答复执行(供核阅) | V10 参数 + dashboard_service |
| 6 | #9 | 已按答复执行(供核阅) | 调整单审批链与跨月授权 |
| 7 | #11 | 已按答复执行(供核阅) | dashboard_service 待办三源 |
### 3.4 对路线图的影响(本次已修订)
1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容;第三章待 3.3 各项澄清后,按第二章同粒度补齐 M3 各增量块的任务级实施计划。
1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容。M3 各增量块已于 2026-10-19 交付;3.3-1 仍无业务答复,二次分摊当前按开发侧暂定口径运行,答复后补口径变更计划;3.3-4 的权限细目与 3.3-5 的预警规则仍待业务确认。⑥ T+ 同步在 IT 答复前不排任务级计划。
## 验证方式
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量 301+ 用例与 e2e 全绿。
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量后端用例与 e2e 全绿(以 `backend/tests/README.md` 最新运行记录为准)。

View File

@ -0,0 +1,160 @@
# M4 ⑤ 报表统计 · 任务级实施计划
> 立项日期 2026-10-19;分支 feat/m3-increments。依据:需规 2.4 /《需求规格说明书》FR-029~FR-032 / 开发计划第五章 1.3 验收行 / 2026-10-15 补充(四)「导出模板围绕财务复核和领导签字设计」。
> 前置已满足:④ 台账/月结/调整单全部交付(台账即「报表 2.4.1 的数据源」,`performance_ledger.py` 注释锚点)。
> **纯只读模块,无数据库迁移**。
## 〇、代码核实结论(实施时按此避坑)
| # | 结论 | 影响 |
| --- | --- | --- |
| 1 | `require_roles` 403 文案硬编码「无权访问审批管理」(`api/deps.py:135`) | 给 `require_roles` 增可选 `message` 参数(默认值不变),报表传「无权访问报表统计」 |
| 2 | `PageQuery.page_size` 上限 200(`core/response.py:57`) | 打印视图循环翻页取全量;导出走专用无分页查询 |
| 3 | `settlement_service._confirm_scope` 带 `settlement_id IS NULL`(可入结口径) | 报表取数**不复用**,新增同月全量生效单笔行查询 |
| 4 | `list_adjustments` 无月份筛选、limit 200、含全部状态 | 调整单并入需新写仓储查询(月份 + status=2) |
| 5 | `GET /system/settings` 是 AdminUser,`/public` 才无角色限制 | 打印视图页眉必须走 `getPublicSettings()` |
| 6 | 台账 `payment_days` 可空可负(先款后货);进度登记回款无台账行(#25) | 区间桶含「≤0 天」「无天数」兜底;回款统计注明「不含进度登记回款」 |
| 7 | 二次分摊拆行使同一回款多行(负责人+参与人,business_id 相同) | 回款金额按 receipt_id 去重;个人明细不放「关联回款金额」列 |
| 8 | 经理聚合行 calc_type=2 的 business_id 是锚点、receipt_id=NULL、payment_days=NULL | 业务统计/个人明细/回款统计一律 `calc_type=1 AND is_active=1`,防聚合额污染 |
| 9 | openpyxl `NUMERIC_TYPES` 含 Decimal,原样落数值不转 float | 精度红线导出侧成立;`number_format='#,##0.00'` 控显示 |
| 10 | `test_param_registry.py` 锁 registry 19 项 | 新增参数需四处同改(本计划不新增参数,规避) |
| 11 | `PRODUCT_CATEGORIES` 常量不含 `mixed`,台账行有 `mixed` | 标签映射额外覆盖 mixed,单测防漂移 |
| 12 | `audit_service.audit` 可用于只读导出留痕(独立 commit) | 直接复用;上下文内 bytes 在 commit 前固化 |
| 13 | `test_settlement_helpers.py` 直接 import `settlement_service._month_end` | 抽共享模块时保留薄委托包装 |
| 14 | 前端无任何打印/PDF/xlsx 代码 | 打印从零建:独立顶层路由 + `@media print` |
| 15 | Starlette 响应头 latin-1,中文文件名会 UnicodeEncodeError | 导出文件名服务端 ASCII、中文名前端 `link.download` 赋 |
## 一、全局口径(写进 report_service docstring)
1. 数据源 `performance_ledgers` 固定 `is_active=1 AND calc_type=1`;聚合行只在部门汇总作冻结对照单读;月份维度 `settlement_month`。
2. 金额全程 `Decimal` 出参字符串;比例原样 `rate_text`,禁 float/截断。
3. 调整单并入:仅 `status=2`,按 `(settlement_month, personnel_id)` 并入,`direction=1` 记 +、`2` 记 −;归属按实时 `manager_id`。
4. 离职人员不按 `status` 过滤(历史业绩留在原团队);零行经理枚举仅在职经理。
5. 汇总 = 明细合计(团队/公司基数 == 成员明细绩效合计,测试逐项断言)。
### 已定设计决策
- 部门口径 = `personnel.manager_id` 实时上溯(无独立部门表,人员模型 docstring 已声明;已入催办清单 R1 向业务确认)
- 导出 = 后端 openpyxl + StreamingResponse(ASCII 文件名);打印 = 前端独立打印视图 + `@media print`,PDF 走浏览器另存为;不引前端 PDF/xlsx 依赖
- 不做图表(需求无要求,不引 ECharts)
- 区间桶 = 代码常量 + 服务端下发元数据(前端零硬编码);不进参数体系避免迁移;配置缝收在 `get_payment_day_buckets()` 单函数,升级路径:`report.payment_days.buckets` 参数 → V10 迁移种子 → init-dev → registry 测试 19→20
- 权限 `require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER)` 粗粒度,前后端双层(admin 显式列入)
## 二、后端任务
### T1 常量与元数据(`core/constants.py` 改 + `core/report_meta.py` 新)
> 实施修订(2026-10-19):`report_meta` 放在 `core/` 而非 `services/`——仓储层
> 的区间下钻需要 `DayBucket` 类型,放 services 会让 repository 反向依赖
> service 层(违反六层红线)。
- `MODULE_REPORT = "报表统计"`、`ACTION_EXPORT = "导出"`
- `report_meta.py`(叶子模块,禁 app 内导入防循环依赖):`DayBucket` frozen dataclass + `PAYMENT_DAY_BUCKETS` 8 档(≤0 天(先款后货)/1-30/31-60/61-90/91-180/181-360/361+/无天数)、`CUSTOMER_TYPE_LABELS`、`PRODUCT_CATEGORY_LABELS`(含 mixed)、`REPORT_TEMPLATE_VERSION = "V1.0"`、`REPORT_EXPORT_MAX_ROWS = 20000`
### T2 仓储 `repositories/report_repository.py`(新)
| 函数 | 说明 |
| --- | --- |
| `list_personal_detail / count_personal_detail` | 单笔生效行分页(month/personnel/customer_type/product_category/role_code 筛选,id desc) |
| `sum_personal_detail` | 全筛选口径 SUM(分页合计与页内合计分开,禁内存当前页求和) |
| `list_month_receipt_rows` | 同月全量生效单笔行(部门汇总取数,不复用 _confirm_scope) |
| `list_month_manager_aggregates` | 同月 calc_type=2 生效行(冻结对照) |
| `list_month_adjustments` | status=2 + 月份 |
| `list_business_stats / count / sum` | Business×Customer×Personnel 左联 + 三个 scalar_subquery(发货额 SUM(shipments.total_amount)、回款额 SUM(receipts.amount)、绩效 SUM(台账 performance_amount WHERE calc_type=1 AND is_active=1));keyword 双列 OR contains(autoescape=True) |
| `list_payment_group_by_days` | GROUP BY (payment_days, customer_type, product_category) 出 SUM 绩效/应付/COUNT |
| `list_payment_receipt_group_by_days` | 先 DISTINCT receipt_id 再 JOIN receipts 求和(拆行防翻倍) |
| `list_payment_detail / count` | 区间下钻分页(闭区间 between、开区间 >=、none 桶 IS NULL),JOIN receipts 取金额与批次号 |
### T3 经理分成共享口径(`services/manager_share.py` 新 + `settlement_service.py` 改薄委托)
- 迁出(复制实现 + 薄委托,行为零变化):`month_end`、`ManagerShareParams`(含 `manager_share_params(session, month)`,锚定月末 resolve_snapshot)、`collect_team_buckets`、`company_base_of`
- 报表侧分成额调用 `formula.manager_team_share(...)`——月结聚合行与报表汇总同源,杜绝口径漂移
- **协调点**:84 会话已拆 settlement_service(028a94a);本任务在其基础上做,验证 `test_settlement_helpers.py` 全绿
### T4 服务与 schemas(`services/report_service.py` + `schemas/report.py` 新)
1. **个人明细**:`ReportPage[T]` = 标准分页四字段 + `summary`(全筛选合计);行含 ledger_id/business_id/business_no/人员/岗位/客户类型/产品类别/ship_date/receipt_date/payment_days/rate_text/weight_r/payment_weight/performance_amount/payable_amount;**不含回款金额列**
2. **部门汇总**(月份必填):`collect_team_buckets` 分团队 → 团队基数/成员内联明细(personnel_id 聚合 + 台账 id 列表 + 调整 extra/deduct/net);`company` 块 5% 单列(team_base_total/company_base/company_share_amount;各团队 5% 之和 ≠ 全局 5%,UI 注明);零行经理并集枚举(在职 sales_manager JOIN business_roles + 数据中出现的 manager_id);对比 `company_base_ratio` 除零→0;冻结对照 `aggregate_ledger_id + frozen_amount`;出参 `params`(team_pct/company_pct/manager_personal_included/params_as_of)
3. **业务统计**:分页 + summary;回款/绩效为该业务累计值(不随日期筛选变,页面 hint)
4. **回款统计**:SQL 精确值聚合 → Python 按 `PAYMENT_DAY_BUCKETS` 分桶(恒含全桶、空桶 0);正负绩效直接求和不 ABS;出参 buckets/by_customer_type/by_product_category/totals
5. **区间下钻**:`GET /reports/payment-detail`(bucket 必填,分页)
6. **导出编排** `export_report(session, code, q, *, operator, meta)`:先 count 判上限(超限 400 中文提示按月缩小);`audit_service.audit(MODULE_REPORT, ACTION_EXPORT, ...)` 包取数+构建;出参 `(bytes, filename)` 在 commit 语义闭合前固化
### T5 Excel 模板(`services/report_export.py` 新,纯函数)
- 普通 `Workbook`(上限封顶 2 万行内存可承受);版式:公司页眉行(logo `read_logo` try/except 降级纯文本)→ 标题(合并居中)→ 筛选条件行 → 导出时间/人/模板版本 → 表头(加粗底纹边框 + freeze_panes)→ 数据行 → 合计行(service 侧 Decimal SUM quantize HALF_UP,**不写 Excel 公式**)→ 空两行 → 财务复核栏 / 领导签字栏 / 日期栏
- 精度:金额 Decimal 直接写 + `#,##0.00`;比例 `'0.00"%"'`;`rate_text` 原样文本;日期 `date` + `YYYY-MM-DD`
- 防公式注入:文本以 `= + - @ \t` 开头前置 `'`;CJK 列宽;部门汇总/回款统计多 sheet(各带页眉与签字栏);空数据允许(表头 + 提示行 + 签字栏)
### T6 API 与路由(`api/v1/reports.py` 新 + `router.py` 改 + `deps.py` 改)
| 方法 | 路径 | 入参(Query 蛇形) |
| --- | --- | --- |
| GET | `/api/v1/reports/personal-detail` | pageNum/pageSize + settlement_month, personnel_id, customer_type, product_category, role_code |
| GET | `/api/v1/reports/department-summary` | settlement_month(必填), manager_id |
| GET | `/api/v1/reports/business-stats` | pageNum/pageSize + keyword, customer_id, owner_personnel_id, status, date_from, date_to |
| GET | `/api/v1/reports/payment-stats` | settlement_month, date_from, date_to, customer_type, product_category |
| GET | `/api/v1/reports/payment-detail` | pageNum/pageSize + bucket(必填) + 同上 |
| GET | `/api/v1/reports/{code}/export` | code ∈ 四表,参数同对应页 → StreamingResponse xlsx |
- 全部 `def` 同步路由 + `Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计"))]`
- Content-Disposition 文件名 ASCII(`personal_detail_20261019.xlsx`),沿用 `system/params.py` 导出先例
### T7 后端文档(`backend/README.md` 改)
报表接口表 + 「报表口径」小节(生效行/调整单并入/回款去重/不含进度登记回款/区间桶常量与升级路径)。
## 三、前端任务
### T8 API 与常量(`api/reports.ts` 新 + `utils/constants.ts` 改)
类型逐字段对齐(金额 string);`exportReport(code, params)` 裸 axios `responseType:'blob'`;MODULE_OPTIONS 与后端 `core/constants.py` 全量对齐
### T9 四个页面(`views/reports/{personal-detail,department-summary,business-stats,payment-stats}/index.vue` 新)
统一骨架:`filters(reactive)` + `usePagedList`(非分页表 ref+手动 load)+ `el-table stripe` + `AppPagination` + 工具条「导出 Excel」「打印」+ `handledError`;日期区间 `el-date-picker value-format="YYYY-MM-DD"`(必写);下拉复用 `useDropdownOptions`
| 页面 | 路由 order | 关键列与下钻 |
| --- | --- | --- |
| 个人绩效明细表 | `/reports/personal-detail` 19 | 需规 10 列 + 页脚全筛选合计(注明非当前页);expand 懒加载 `getLedger(ledger_id)` 快照下钻 |
| 部门绩效汇总表 | `/reports/department-summary` 20 | 团队表 + 全公司 5% 卡片 + 参数取值提示;展开成员 → 跨页下钻个人明细(router.push query)→ 台账下钻复用 `listLedger` |
| 业务统计表 | `/reports/business-stats` 21 | 发货额/回款额/绩效三列;expand 两小表(复用 `listReceipts`/`listLedger`);hint「累计值」 |
| 回款统计表 | `/reports/payment-stats` 22 | buckets 元数据渲染(空桶 0)+ 桶展开下钻 `payment-detail` + 客户类型/产品类别对比小表 |
路由 meta:`{ title, group: '报表统计', order: 19..22, allowedRoles: [ROLE_ADMIN, 'finance', 'leader'] }`(admin 显式列入)
### T10 打印视图(`views/reports/print/index.vue` 新 + `router/index.ts` 改)
- 顶层路由 `/reports/print/:code`(不挂 BasicLayout),`menu:false` + allowedRoles 同上
- 循环翻页取全量(pageSize 200、上限 2000 截断提示);页眉 `getPublicSettings()`(非管理员拿不到 `/system/settings`)
- `window.print()`(按钮区 `@media print { display:none }`);`@page A4 landscape`;`thead { display: table-header-group }` 每页带表头;打印态白底黑字沿用 `--ys-*`
### T11 前端文档(`frontend/README.md` 改)
报表页与打印视图小节 + 权限矩阵
## 四、测试(模块整体完成时统一编写)
### T12 后端(`tests/unit/test_report_helpers.py` + `tests/integration/test_reports.py` 新)
- 单元:分桶边界(0/1/30/31/60/61/180/181/360/361/负值/NULL);标签键集合 ⊇ CUSTOMER_TYPES ∪ PRODUCT_CATEGORIES ∪ {"mixed"};Excel 结构断言(load_workbook 读回:页眉/合计/签字栏/number_format/注入前缀/空数据);占比除零→0
- 集成(factories 造数):① 个人明细对拍 `/calc/ledger`、summary==全量和;② 部门汇总成员合计==团队基数==公司基数、`aggregate_amount==manager_team_share` 预期、生成月结后 computed==frozen;③ 无团队成员经理 0 行、离职成员计入原团队;④ 调整单生效行并入(status≠2 不计);⑤ 回款统计空桶恒在、负绩效计入、**分摊拆行金额只计一次**;⑥ 业务统计排除 calc_type=2;⑦ 导出 200/400/审计留痕(成功与失败);⑧ 普通 user 403(文案「无权访问报表统计」)
### T13 e2e(`frontend/e2e/report_flow.py` 新)
菜单可见 → 四页筛选与数字 → `page.expect_download()` 断言导出 → 打印视图注入 `window.print` 桩断言 → console 无错;造数用 API + 登录 token(远未来月份隔离,参照 calc_settlement_flow.py)
### T14 收尾
回归(pytest 全量 + ruff + type-check + build + e2e report_flow 与复跑两条既有脚本);三 README 更新;提交拆分 `feat(report): 报表统计后端取数与导出` → `feat(report): 报表统计前端四页与打印视图` → `test(report): 报表模块用例与测试记录`
## 五、开放项(不阻塞,随催办确认)
| 事项 | 默认 |
| --- | --- |
| 部门口径(R1) | manager_id 实时上溯 |
| 区间档位(R2) | 8 档代码常量 |
| 签字栏要素(R3) | 页眉/标题/筛选/时间人/版本/复核/签字/日期 |
| 导出上限 | 20000 行超限 400 |
| 打印上限 | 2000 行截断提示 |
| Excel logo | 有则嵌、无则纯文本 |

101
docs/备份恢复手册.md Normal file
View File

@ -0,0 +1,101 @@
# 备份恢复手册(⑨ 备份恢复与运维检查)
> 用户强调「这个备份恢复很重要」。**备份不演练 = 没有备份**——本手册的一页纸目标:任何运维/开发拿到都能完成「备份 → 恢复到临时库核对 → 删临时库」全链路。
## 一、备份(每日一次,建议凌晨低峰)
```bash
# 项目根目录执行
bash scripts/backup_full.sh
```
- 产物:`db/backups/payroll_YYYYMMDD_HHMMSS.sql.gz`(gzip 压缩)
- 保留策略:自动清理 30 天前的旧备份
- 凭据:从 `docker/.env` 读取(不入脚本、不入版本库)
**Windows 定时任务**(任务计划程序 / `schtasks`):
```cmd
:: 每日 02:30 备份(经 Git Bash)
schtasks /create /tn "ys-payroll-backup" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/backup_full.sh\"" /sc daily /st 02:30 /ru SYSTEM
```
## 二、恢复演练(备份后顺手跑一次,验证备份真能用)
```bash
bash scripts/restore_drill.sh # 恢复到临时库 + 行数核对,临时库默认保留
bash scripts/restore_drill.sh --drop-exec # 核对通过后再加此参数删除临时库
```
- **绝不直接覆盖生产库**——演练恢复到独立临时库 `payroll_drill_<时间戳>`
- 行数对比覆盖 16 张关键表(users/roles/param_*/business*/personnel/products/customers/shipments*/receipts/approval_*/system_settings)
- 全部 ✓ 即备份可恢复;出现 ✗ 时临时库保留供排查
## 二·五、增量备份(澄清 #19 答复:每日全量 + 有更新时增量补充)
全量之间的数据变更由 **binlog 归档**覆盖(MySQL 8 默认开启 binlog,ROW 格式、服务端保留 30 天):
```bash
bash scripts/backup_incremental.sh
```
- 产物:`db/backups/binlog.NNNNNN.gz`(原始 binlog 文件逐个压缩)
- **有更新时才产出**:状态文件 `db/backups/.incremental_state` 记录上次归档到的文件,无新变更时直接退出
- 只归档「已写完」的 binlog(正在写的最后一个留到下次收取)
- 保留策略同全量:自动清理 30 天前的增量包
**配套**:`backup_full.sh` 每次全量会同时产出位点文件 `payroll_*.sql.gz.pos`(binlog 文件名 + 位点),标记「该全量覆盖到此」——恢复重放从位点之后开始。
**恢复顺序**(真出事时):最新全量 → 依次 `gunzip` 其后的增量包 → 用 `mysqlbinlog` 重放(镜像内无此客户端,宿主机或临时容器执行):
```bash
# 解出增量包后重放(示例:从全量位点之后开始)
gunzip -c db/backups/binlog.000003.gz > /tmp/binlog.000003
mysqlbinlog --start-position=<全量位点> /tmp/binlog.000003 | \
docker exec -i ys-mysql-dev sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" payroll'
```
**建议定时**:增量与全量同节奏(每日 02:30 全量后接一次增量;变更频繁的日子可在中午加跑一次):
```cmd
schtasks /create /tn "ys-payroll-backup-incr" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/backup_incremental.sh\"" /sc daily /st 02:50 /ru SYSTEM
```
## 三、生产恢复(真出事了,七步)
> ⚠️ 生产恢复是**高危操作**:先把容器停了再恢复,避免写冲突。下面假设恢复到**全新空数据卷**(最干净路径)。
1. **停容器保卷**:`docker compose -f docker/docker-compose.dev.yml down`
2. **删旧卷(谨慎!确认要全量重来才执行)**:`docker volume rm ys-payroll_mysql-dev-data`
- 若只想恢复到某个时间点**且不丢其它数据**,改用「恢复到临时库 → mysqldump 单表导出 → 手工回灌」的细粒度路径,勿整体覆盖。
3. **起空库**:`docker compose -f docker/docker-compose.dev.yml up -d`(首启自动跑 `db/init/init-dev.sql` 建表 + 种子)
4. **选一份备份**:`ls -t db/backups/payroll_*.sql.gz | head`
5. **导入该备份**:
```bash
gunzip -c db/backups/payroll_XXXXXXXX_XXXXXX.sql.gz | \
docker exec -i ys-mysql-dev sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" payroll'
```
6. **核对行数**:再跑一次 `bash scripts/restore_drill.sh`(此时源库=刚恢复的库,应全 ✓)
7. **重启后端**:`cd backend && uv run uvicorn app.main:app --reload --port 8000`,登录抽查几个页面确认数据回来
## 四、运维健康检查(每日一次)
```bash
bash scripts/health_check.sh
```
六项检查:容器健康 ×2 / 磁盘水位(80% 阈值)/ 备份新鲜度(24h 内)/ Redis 探活 / 24h 登录失败次数(暴力破解预警)。任一项 WARN/FAIL 时退出码非零,方便监控采集。
**Windows 定时**:
```cmd
:: 每日 08:50 检查(上班前出报告)
schtasks /create /tn "ys-payroll-health" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/health_check.sh >> db/backups/health.log 2>&1\"" /sc daily /st 08:50 /ru SYSTEM
```
## 五、注意事项
- 备份含**全部业务数据与参数种子**,属敏感文件——`db/backups/` 已加入 `.gitignore`,**不要**把它提交到版本库
- 恢复演练产生的 `payroll_drill_*` 库会占磁盘,核对完及时 `--drop-exec` 清理
- 备份脚本与恢复脚本都只依赖 Docker(无需宿主机装 MySQL 客户端)
- 增量重放的 `mysqlbinlog` 是唯一需要宿主机(或临时容器)具备的客户端——官方 mysql:8.0 镜像内不含;重放前务必确认 binlog 序号连续(中间断号说明有未归档文件,先跑一次增量归档)

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More