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

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

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

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

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

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

验证:pnpm type-check 绿

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 17:25:23 +08:00
148 changed files with 19105 additions and 510 deletions

No files matched your search

+5
View File
@@ -59,3 +59,8 @@ frontend/e2e/*.png
backup/ backup/
.claude .claude
CLAUDE.md CLAUDE.md
# 运行期上传物(公司 logo 等):与构建产物隔离,不入版本库
backend/uploads/
# 数据库备份导出目录(⑨ 备份脚本产物)
db/backups/
+46
View File
@@ -114,6 +114,31 @@ app/
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」, **登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。 若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
### 报表统计接口(M4 ⑤,限管理员/财务/领导)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/reports/personal-detail` | 个人绩效工资明细表(FR-029:人员/月份/客户类型/产品类别/岗位筛选;响应带 `summary` 全筛选合计) |
| GET | `/api/v1/reports/department-summary` | 部门绩效工资汇总表(FR-030:月份必填;团队基数与月结聚合行同源;12.5% 与 5% 分列;含成员内联明细、未分配桶、月结冻结对照额) |
| GET | `/api/v1/reports/business-stats` | 业务统计表(FR-031:发货/回款/绩效为业务累计值,不随业务日期筛选变化) |
| GET | `/api/v1/reports/payment-stats` | 回款情况统计表(FR-031:8 档天数区间恒在,空桶为 0) |
| GET | `/api/v1/reports/payment-detail` | 回款区间下钻(`bucket` 必填,未注册编码 400) |
| GET | `/api/v1/reports/payment-day-buckets` | 区间档位元数据(前端下拉唯一来源,禁止前端硬编码) |
| GET | `/api/v1/reports/{code}/export` | Excel 导出(文件流非统一信封;含公司页眉/财务复核栏/领导签字栏;超 2 万行 400;行为入操作日志) |
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
### 工作台接口(M4 ⑧,限管理员/财务/领导)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/dashboard?month=YYYY-MM` | 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志) |
待办与预警**实时派生**(不落表、不引调度器);预警阈值走参数体系
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
## 常用命令 ## 常用命令
```bash ```bash
@@ -196,6 +221,27 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。 - **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。 - **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
### 工作台口径(M4 ⑧,改动前先读)
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定;回款未指定批次 = `receipts.shipment_id IS NULL`。
- **同步降级**:⑥ 未建时 `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` 共享模块),**12.5% 与 5% 分列**——两项之和才是聚合行金额。
- **人员归属按行类型**:普通台账行跟随 `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% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);全公司分成只出现一次,调整净额只在明细层求和,应发合计 = 团队成员应付 + 未分配应付 + 团队分成合计 + 全公司分成 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
## 常见问题 ## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。 **起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
@@ -0,0 +1,632 @@
"""V7 核算台账/月结/调整单四表与调整单开关种子
Revision ID: 3bca400421c6
Revises: 79bcc5aa98d4
Create Date: 2026-10-16 15:54:53.000890
"""
from collections.abc import Sequence
from datetime import date
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "3bca400421c6"
down_revision: str | Sequence[str] | None = "79bcc5aa98d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"monthly_settlements",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="结算月份(YYYY-MM;全表唯一)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
),
sa.Column(
"total_performance",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="绩效金额合计(可负)",
),
sa.Column(
"total_payable",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="应付绩效合计(可负)",
),
sa.Column(
"person_count", mysql.TINYINT(), server_default="0", nullable=False, comment="结算人数"
),
sa.Column(
"snapshot_json",
sa.JSON(),
nullable=True,
comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)",
),
sa.Column(
"approval_order_no",
sa.String(length=32),
nullable=True,
comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)",
),
sa.Column(
"submitter_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="提交人ID(账号删除后置空)",
),
sa.Column(
"submitter_name",
sa.String(length=64),
nullable=False,
comment="提交人用户名(冗余固化)",
),
sa.Column("locked_at", sa.DateTime(), nullable=True, comment="锁定时间(status=3 时回填)"),
sa.Column(
"locked_by", sa.String(length=64), nullable=True, comment="锁定操作人(冗余固化)"
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["submitter_id"],
["users.id"],
name=op.f("fk_monthly_settlements_submitter_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_monthly_settlements")),
sa.UniqueConstraint(
"settlement_month", name=op.f("uk_monthly_settlements_settlement_month")
),
comment="月度结算表(按月一行;通过即锁定,锁定后走调整单)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"adjustment_orders",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"order_no",
sa.String(length=32),
nullable=False,
comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="调整人员ID(personnel.id)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="调整人员姓名(冗余固化)",
),
sa.Column("direction", mysql.TINYINT(), nullable=False, comment="方向:1补发 2扣减"),
sa.Column(
"amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="调整金额(恒正,方向表达语义)",
),
sa.Column(
"reason",
sa.String(length=255),
nullable=False,
comment="扣补说明(必填,审批与追溯锚点)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
),
sa.Column(
"submitter_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="提交人ID(账号删除后置空)",
),
sa.Column(
"submitter_name",
sa.String(length=64),
nullable=False,
comment="提交人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_adjustment_orders_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["submitter_id"],
["users.id"],
name=op.f("fk_adjustment_orders_submitter_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_adjustment_orders")),
sa.UniqueConstraint("order_no", name=op.f("uk_adjustment_orders_order_no")),
comment="调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"settlement_items",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"settlement_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="月结ID(monthly_settlements.id;头删明细随删)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="结算人员ID(personnel.id)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="结算人员姓名(冗余固化)",
),
sa.Column(
"role_code", sa.String(length=32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
),
sa.Column(
"calc_type",
mysql.TINYINT(),
nullable=False,
comment="核算类型:1单笔回款 2经理团队聚合",
),
sa.Column(
"performance_sum",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
),
sa.Column(
"payable_sum",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="应付绩效合计(可负)",
),
sa.Column(
"basic_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="月工资标准(personnel.salary_standard 当月快照)",
),
sa.Column(
"salary_mode",
sa.String(length=16),
nullable=True,
comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE",
),
sa.Column(
"net_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="净工资 = 绩效±基本工资(按口径;试用期 0)",
),
sa.Column(
"is_probation",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
),
sa.Column(
"probation_note",
sa.String(length=255),
nullable=True,
comment="试用期说明(年累计超阈值部分年底一次性结算)",
),
sa.Column(
"snapshot_json", sa.JSON(), nullable=True, comment="个人明细快照(台账行引用列表)"
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_settlement_items_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["settlement_id"],
["monthly_settlements.id"],
name=op.f("fk_settlement_items_settlement_id"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_settlement_items")),
comment="月结明细表(按人固化;支持负数工资;锁定后只读)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"performance_ledgers",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"receipt_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="回款ID(receipts.id;聚合行可为 NULL)",
),
sa.Column(
"calc_version",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="核算版本(重算+1,旧行 is_active=0)",
),
sa.Column(
"is_active",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
),
sa.Column(
"calc_type",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
),
sa.Column(
"business_no",
sa.String(length=32),
nullable=False,
comment="业务编号(冗余固化,列表免联查)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="归属营销人员ID(personnel.id;核算归属锚点)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="归属营销人员姓名(冗余固化)",
),
sa.Column(
"role_code",
sa.String(length=32),
nullable=False,
comment="岗位编码(business_roles.role_code,计提公式锚点)",
),
sa.Column(
"customer_type",
sa.String(length=32),
nullable=False,
comment="客户分类:personal/manager_personal/enterprise",
),
sa.Column(
"product_category",
sa.String(length=32),
nullable=False,
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
),
sa.Column("ship_date", sa.Date(), nullable=True, comment="发货日期(回款天数基准)"),
sa.Column(
"receipt_date",
sa.Date(),
nullable=False,
comment="回款日期(参数版本匹配日,裁决 #11)",
),
sa.Column(
"payment_days",
sa.Integer(),
nullable=True,
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)",
),
sa.Column(
"rate_text",
sa.String(length=32),
nullable=False,
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
),
sa.Column(
"converted_profit",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="折算毛利润额(可负;混合业务为各行汇总)",
),
sa.Column(
"performance_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="绩效金额(可负,裁决:负绩效照算不归零)",
),
sa.Column(
"barter_applied",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="以物抵顶折算:1已按 80% 折算 0否",
),
sa.Column(
"weight_r",
sa.Integer(),
nullable=True,
comment="支付权重 R(回款占销售额百分比向下取整)",
),
sa.Column(
"payment_weight",
sa.Numeric(precision=7, scale=6),
nullable=True,
comment="支付权重系数 R×R(展示用)",
),
sa.Column(
"payable_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)",
),
sa.Column(
"settlement_month",
sa.String(length=7),
nullable=False,
comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)",
),
sa.Column(
"is_probation",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="试用期:1照算不发放 0否",
),
sa.Column(
"missing_cost",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
),
sa.Column(
"snapshot_json",
sa.JSON(),
nullable=True,
comment="计算快照(所用参数版本与行级折算明细,重算可回溯)",
),
sa.Column(
"settlement_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="所属月结ID(月结生成时回填;NULL=未入结)",
),
sa.Column(
"calc_reason",
sa.String(length=255),
nullable=True,
comment="核算/重算原因(重算必填留痕)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_performance_ledgers_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["receipt_id"],
["receipts.id"],
name=op.f("fk_performance_ledgers_receipt_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["settlement_id"],
["monthly_settlements.id"],
name=op.f("fk_performance_ledgers_settlement_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_performance_ledgers")),
sa.UniqueConstraint(
"receipt_id", "calc_version", name="uk_performance_ledgers_receipt_id_calc_version"
),
comment="绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_performance_ledgers_business_id", "performance_ledgers", ["business_id"], unique=False
)
op.create_index(
"idx_performance_ledgers_month_active",
"performance_ledgers",
["settlement_month", "is_active"],
unique=False,
)
op.create_index(
"idx_performance_ledgers_month_personnel_type",
"performance_ledgers",
["settlement_month", "personnel_id", "calc_type"],
unique=False,
)
op.create_index(
"idx_performance_ledgers_personnel_id",
"performance_ledgers",
["personnel_id"],
unique=False,
)
op.alter_column(
"approval_orders",
"order_type",
existing_type=mysql.TINYINT(),
comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
existing_comment="审批对象类型:1二次分配方案 2月度结算(预留)",
existing_nullable=False,
)
# ### end Alembic commands ###
# ---- 种子:调整单审批开关(V6 写法先例;definitions id 19 / values id 22)----
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 19,
"param_code": "approval.adjustment.enabled",
"param_name": "调整单审批开关",
"param_group": "审批开关",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
"调整单是已锁定月份补发/扣减的唯一通道"
),
"sort_order": 19,
"status": 1,
}
],
)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 22,
"param_id": 19,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
}
],
)
def downgrade() -> None:
"""回滚迁移。
整表回滚只保留 `op.drop_table`(V1/V3/V6 先例:外键唯一支撑索引单独
drop 会报 MySQL 1553)。行级种子(param_definitions 19 / param_values 22)
的宿主表由 V3 建、本迁移只插行,须显式 DELETE 否则重放 upgrade 撞唯一键。
删除顺序:先删引用方(ledger/items),再删被引用方(头表/调整单)。
"""
op.drop_table("performance_ledgers")
op.drop_table("settlement_items")
op.drop_table("adjustment_orders")
op.drop_table("monthly_settlements")
op.alter_column(
"approval_orders",
"order_type",
existing_type=mysql.TINYINT(),
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
existing_comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
existing_nullable=False,
)
op.execute("DELETE FROM param_values WHERE id = 22")
op.execute("DELETE FROM param_definitions WHERE id = 19")
@@ -0,0 +1,441 @@
"""V6 系统设置/二次分配/审批表与角色参数种子
Revision ID: 79bcc5aa98d4
Revises: 8efc189b131f
Create Date: 2026-10-15 15:42:13.467447
"""
from collections.abc import Sequence
from datetime import date
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "79bcc5aa98d4"
down_revision: str | Sequence[str] | None = "8efc189b131f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"system_settings",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"company_name",
sa.String(length=64),
nullable=False,
comment="公司名称(登录页与导出页眉展示)",
),
sa.Column(
"logo_path",
sa.String(length=255),
nullable=True,
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_system_settings")),
comment="系统设置表(公司信息,全系统单行)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"approval_orders",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"order_no",
sa.String(length=32),
nullable=False,
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
),
sa.Column(
"order_type",
mysql.TINYINT(),
nullable=False,
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
),
sa.Column(
"ref_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
),
sa.Column(
"refer_no",
sa.String(length=32),
nullable=False,
comment="引用对象单号(冗余固化,列表页免联查)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
),
sa.Column(
"submitter_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="提交人ID(账号删除后置空)",
),
sa.Column(
"submitter_name",
sa.String(length=64),
nullable=False,
comment="提交人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["submitter_id"],
["users.id"],
name=op.f("fk_approval_orders_submitter_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_orders")),
sa.UniqueConstraint("order_no", name=op.f("uk_approval_orders_order_no")),
comment="审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_approval_orders_type_status", "approval_orders", ["order_type", "status"], unique=False
)
op.create_table(
"approval_steps",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"order_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="审批单ID(approval_orders.id;单删记录随删)",
),
sa.Column(
"node",
mysql.TINYINT(),
nullable=False,
comment="审批节点:0撤回留痕 1财务复核 2领导审核",
),
sa.Column(
"action", mysql.TINYINT(), nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
),
sa.Column(
"opinion", sa.String(length=255), nullable=True, comment="审批意见(驳回时填写)"
),
sa.Column(
"operator_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(自动通过/系统动作置空)",
),
sa.Column(
"operator_name",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余固化;系统动作记「系统」)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["operator_id"],
["users.id"],
name=op.f("fk_approval_steps_operator_id"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["order_id"],
["approval_orders.id"],
name=op.f("fk_approval_steps_order_id"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_steps")),
comment="审批节点记录表(append-only;自动通过/撤回均有留痕)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_approval_steps_order_id", "approval_steps", ["order_id"], unique=False)
op.create_table(
"business_secondary_allocations",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id;业务删除随删)",
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="参与业务员ID(personnel.id;被方案引用禁删)",
),
sa.Column(
"personnel_name",
sa.String(length=64),
nullable=False,
comment="参与业务员姓名(冗余固化,离职后回显有名)",
),
sa.Column(
"ratio",
sa.Numeric(precision=5, scale=2),
nullable=False,
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_business_secondary_allocations_business_id"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_business_secondary_allocations_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_secondary_allocations")),
sa.UniqueConstraint(
"business_id",
"personnel_id",
name="uk_business_secondary_allocations_business_personnel",
),
comment="业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_business_secondary_allocations_business_id",
"business_secondary_allocations",
["business_id"],
unique=False,
)
op.create_index(
"idx_business_secondary_allocations_personnel_id",
"business_secondary_allocations",
["personnel_id"],
unique=False,
)
# ### end Alembic commands ###
# ---- 种子数据(生产与开发同源;写法沿用 V1/V3 迁移先例)----
# 三个审批职能登录角色(2026-10-15 复核审批补充需求;roles 表无 is_builtin
# 列,内置保护由 role_service 的 BUILTIN_ROLE_CODES 白名单承担)
op.bulk_insert(
sa.table(
"roles",
sa.column("role_code", sa.String),
sa.column("role_name", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
),
[
{
"role_code": "marketing_ops",
"role_name": "营销后勤",
"description": "提交与录入(审批链第一节点:月结/二次分配提交)",
"sort_order": 3,
},
{
"role_code": "finance",
"role_name": "财务",
"description": "复核(审批链第二节点)",
"sort_order": 4,
},
{
"role_code": "leader",
"role_name": "领导",
"description": "审核(审批链第三节点;全览只读)",
"sort_order": 5,
},
],
)
# 审批开关参数定义(param_definitions id 沿用 V3 种子的显式 id 方案接续 17/18)
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 17,
"param_code": "approval.secondary_allocation.enabled",
"param_name": "二次分配审批开关",
"param_group": "审批开关",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
"1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)"
),
"sort_order": 17,
"status": 1,
},
{
"id": 18,
"param_code": "approval.monthly_settlement.enabled",
"param_name": "月度结算审批开关",
"param_group": "审批开关",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);"
"0=结算生成即生效,1=走财务复核→领导审核审批流"
),
"sort_order": 18,
"status": 1,
},
],
)
# 审批开关联动批次条目(batch_id=1 即 V3 的「初始版本」批次;BOOL 无维度行,
# 生效日期 2026-01-01 对齐其他支付环节种子)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 20,
"param_id": 17,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
},
{
"id": 21,
"param_id": 18,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
},
],
)
# 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
op.bulk_insert(
sa.table(
"system_settings",
sa.column("id", sa.BigInteger),
sa.column("company_name", sa.String),
sa.column("logo_path", sa.String),
),
[
{"id": 1, "company_name": "衍水集团", "logo_path": None},
],
)
def downgrade() -> None:
"""回滚迁移。
整表回滚只保留 `op.drop_table` —— DROP TABLE 会连带删除索引与外键。
autogenerate 原样输出的逐个 `op.drop_index` 会失败:idx_approval_steps_order_id
与 idx_business_secondary_allocations_personnel_id 分别是外键的唯一支撑索引,
单独删除被 MySQL 拒绝(errno 1553),回滚会中断在半途。
删除顺序:先删引用方(approval_steps/二次分配),再删被引用方。
行级种子反向清理:roles/param_definitions/param_values 三表由 V1/V3 建、
本迁移只插行(roles 三角色、参数 17/18 及其版本条目),drop_table
不会带走,须显式 DELETE,否则重放 upgrade 时唯一键冲突。
"""
op.drop_table("business_secondary_allocations")
op.drop_table("approval_steps")
op.drop_table("approval_orders")
op.drop_table("system_settings")
op.execute("DELETE FROM param_values WHERE id IN (20, 21)")
op.execute("DELETE FROM param_definitions WHERE id IN (17, 18)")
op.execute("DELETE FROM roles WHERE role_code IN ('marketing_ops', 'finance', 'leader')")
@@ -0,0 +1,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)")
@@ -0,0 +1,51 @@
"""V9:business_secondary_allocations 唯一键补齐(存量库自愈)。
背景:uk(business_id, personnel_id) 是随「就地修改已发布的 V6 迁移」加入的
(V6 建表语句里已含、硬编码名);**旧 V6 已应用的存量库 upgrade 到当前
head 时不会重放 V6**,拿不到该约束,V8 也未补加——两条升级路径的库结构
漂移(test_schema_sync 只比对表/列集合,约束级漂移拦不住)。
本迁移是**幂等条件式**:约束已存在(新库 / init-dev 重置库 / 已重建的
开发库)时跳过,不存在(旧 V6 升级库)时补加,避免重复约束名 errno 1826。
语句写成裸 SQL 而非 op.create_unique_constraint——后者在 --sql 离线导出
时会无条件输出 DDL,无法携带存在性判断。
注意:补键时若表内已存在重复 (business_id, personnel_id) 数据,ADD
CONSTRAINT 会失败(有意如此:先由人工清理重复数据再升级,不做静默去重)。
"""
from alembic import op
revision: str = "ba0217f5091b"
down_revision: str | None = "c7d2e5a81f30"
branch_labels = None
depends_on = None
_UK_NAME = "uk_business_secondary_allocations_business_personnel"
def upgrade() -> None:
"""条件式补唯一键(见模块 docstring 的背景与幂等说明)。"""
op.execute(
"SET @uk_exists := ("
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
"WHERE CONSTRAINT_SCHEMA = DATABASE() "
"AND TABLE_NAME = 'business_secondary_allocations' "
f"AND CONSTRAINT_NAME = '{_UK_NAME}'"
")"
)
op.execute(
"SET @ddl := IF(@uk_exists = 0, "
f"'ALTER TABLE business_secondary_allocations ADD CONSTRAINT {_UK_NAME} "
"UNIQUE (business_id, personnel_id)', "
"'DO 0')"
)
op.execute("PREPARE stmt FROM @ddl")
op.execute("EXECUTE stmt")
op.execute("DEALLOCATE PREPARE stmt")
def downgrade() -> None:
"""有意不撤键:新库由 V6 建表即带该约束、并非本迁移首创,撤掉会让
降级路径产生新的漂移;确需撤销请人工评估后执行 DROP INDEX。
"""
@@ -0,0 +1,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)"
)
+26
View File
@@ -113,3 +113,29 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
AdminUser = Annotated[User, Depends(require_admin)] AdminUser = Annotated[User, Depends(require_admin)]
# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅
# 管理员」——参数目录/历史/预览等只读端点用此依赖)
LoginUser = Annotated[User, Depends(get_current_user)]
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
"""要求当前用户具备任一指定角色(依赖工厂)。
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
避免既有调用与测试变动)。
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
"""
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
codes = set(user_repository.get_role_codes(current_user))
if not codes & set(role_codes):
raise ForbiddenError(message)
return current_user
return _checker
+19
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter
from app.api import health from app.api import health
from app.api.v1 import auth from app.api.v1 import auth
from app.api.v1.base import business_roles as base_business_roles
from app.api.v1.base import customers as base_customers from app.api.v1.base import customers as base_customers
from app.api.v1.base import personnel as base_personnel from app.api.v1.base import personnel as base_personnel
from app.api.v1.base import products as base_products from app.api.v1.base import products as base_products
@@ -15,10 +16,16 @@ 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 ratings as business_ratings
from app.api.v1.business import receipts as business_receipts from app.api.v1.business import receipts as business_receipts
from app.api.v1.business import shipments as business_shipments 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 business_roles as system_business_roles
from app.api.v1.system import logs as system_logs from app.api.v1.system import logs as system_logs
from app.api.v1.system import params as system_params from app.api.v1.system import params as system_params
from app.api.v1.system import roles as system_roles from app.api.v1.system import roles as system_roles
from app.api.v1.system import settings as system_settings
from app.api.v1.system import users as system_users from app.api.v1.system import users as system_users
api_router = APIRouter() api_router = APIRouter()
@@ -36,6 +43,8 @@ system_router.include_router(system_users.router)
system_router.include_router(system_roles.router) system_router.include_router(system_roles.router)
system_router.include_router(system_business_roles.router) system_router.include_router(system_business_roles.router)
system_router.include_router(system_params.router) system_router.include_router(system_params.router)
system_router.include_router(system_settings.router)
system_router.include_router(system_approvals.router)
system_router.include_router(system_logs.operation_router) system_router.include_router(system_logs.operation_router)
system_router.include_router(system_logs.login_router) system_router.include_router(system_logs.login_router)
v1_router.include_router(system_router) v1_router.include_router(system_router)
@@ -45,6 +54,7 @@ base_router = APIRouter(prefix="/base")
base_router.include_router(base_personnel.router) base_router.include_router(base_personnel.router)
base_router.include_router(base_products.router) base_router.include_router(base_products.router)
base_router.include_router(base_customers.router) base_router.include_router(base_customers.router)
base_router.include_router(base_business_roles.router)
v1_router.include_router(base_router) v1_router.include_router(base_router)
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问 # 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
@@ -55,4 +65,13 @@ business_router.include_router(business_receipts.router)
business_router.include_router(business_ratings.router) business_router.include_router(business_ratings.router)
v1_router.include_router(business_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) api_router.include_router(v1_router)
+40
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
]
)
+107
View File
@@ -0,0 +1,107 @@
"""绩效核算接口(M3 ④):台账查询与单笔核算触发。
核算动作 = 把回款固化为台账行(每笔回款仅一次;重算走独立入口并授权)。
准入:读=登录即可;写(核算/重算/批量)=管理员与营销后勤
(权限清单待澄清 #6,答复后细化)。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
from app.services import calc_service
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
# 重算是授权动作:管理员/财务(营销后勤不可重算)
_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))]
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
def list_ledger(
session: DbSession,
query: Annotated[LedgerQuery, Query()],
) -> ApiResponse[PageResult[LedgerOut]]:
"""分页查询绩效核算台账(默认仅生效行,可筛选历史版本)。"""
return ok(calc_service.list_ledger_page(session, query))
@router.get("/ledger/{ledger_id}", response_model=ApiResponse[LedgerOut], summary="台账详情")
def get_ledger(
session: DbSession,
ledger_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[LedgerOut]:
"""台账行详情(含计算快照)。"""
return ok(calc_service.get_ledger(session, ledger_id))
@router.post(
"/receipts/{receipt_id}/calc",
response_model=ApiResponse[CalcReceiptOut],
summary="核算回款",
)
def calc_receipt(
receipt_id: Annotated[int, Path(ge=1)],
session: DbSession,
current_user: _CalcWriter,
meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]:
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
return ok(
calc_service.calc_receipt(session, receipt_id, operator=current_user, meta=meta),
message="核算完成",
)
class RecalcIn(BaseModel):
"""重算入参:原因必填(重算授权留痕)。"""
reason: str = Field(min_length=1, max_length=255, description="重算原因")
class BatchIn(BaseModel):
"""批量核算入参。"""
receipt_ids: list[int] = Field(min_length=1, description="回款 ID 列表")
@router.post(
"/receipts/{receipt_id}/recalc",
response_model=ApiResponse[CalcReceiptOut],
summary="重算回款",
)
def recalc_receipt(
receipt_id: Annotated[int, Path(ge=1)],
payload: RecalcIn,
session: DbSession,
current_user: _RecalcUser,
meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]:
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
return ok(
calc_service.recalc_receipt(
session, receipt_id, payload.reason, operator=current_user, meta=meta
),
message="重算完成",
)
@router.post("/batch", response_model=ApiResponse[list[dict]], summary="批量核算")
def batch_calc(
payload: BatchIn,
session: DbSession,
current_user: _CalcWriter,
meta: RequestMetaDep,
) -> ApiResponse[list[dict]]:
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
return ok(
calc_service.batch_calc(session, payload.receipt_ids, operator=current_user, meta=meta),
message="批量核算完成",
)
+39
View File
@@ -0,0 +1,39 @@
"""工作台接口(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,
_: _DashboardUser,
month: Annotated[
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None,
) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。"""
return ok(dashboard_service.get_dashboard(session, month))
+178
View File
@@ -0,0 +1,178 @@
"""报表统计接口(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
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, 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 月份必填由模型校验)。"""
data = query.model_dump()
if code == "personal-detail":
return PersonalDetailQuery(**data)
if code == "department-summary":
return DepartmentSummaryQuery(**data)
if code == "business-stats":
return BusinessStatsQuery(**data)
return PaymentStatsQuery(**data)
+99
View File
@@ -0,0 +1,99 @@
"""月度结算接口(M3 ④):生成/提交审批/锁定与调整单。
准入:读=登录即可;写(生成/提交/调整单)=管理员与营销后勤
(权限清单待澄清 #6,答复后细化)。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.settlement import (
AdjustmentCreateIn,
SettlementDetailOut,
SettlementGenerateIn,
SettlementOut,
SettlementQuery,
)
from app.services import settlement_service
router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"])
_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
def generate(
payload: SettlementGenerateIn,
session: DbSession,
current_user: _SettlementWriter,
meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]:
"""按确认回款口径生成指定月份的结算快照(草稿)。"""
return ok(
settlement_service.generate(session, payload.month, operator=current_user, meta=meta),
message="月结已生成",
)
@router.get("", response_model=ApiResponse[PageResult[SettlementOut]], summary="月结列表")
def list_settlements(
session: DbSession,
query: Annotated[SettlementQuery, Query()],
) -> ApiResponse[PageResult[SettlementOut]]:
"""分页查询月结(按月份倒序)。"""
return ok(settlement_service.list_page(session, query))
@router.get("/{settlement_id}", response_model=ApiResponse[SettlementDetailOut], summary="月结详情")
def get_settlement(
session: DbSession,
settlement_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[SettlementDetailOut]:
"""月结详情(含按人明细)。"""
return ok(settlement_service.get_detail(session, settlement_id))
@router.post(
"/{settlement_id}/submit",
response_model=ApiResponse[SettlementDetailOut],
summary="提交审批",
)
def submit(
settlement_id: Annotated[int, Path(ge=1)],
session: DbSession,
current_user: _SettlementWriter,
meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]:
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
return ok(
settlement_service.submit(session, settlement_id, operator=current_user, meta=meta),
message="已提交",
)
# ==================== 调整单(已锁定月份的补发/扣减) ====================
@router.post("/adjustments", response_model=ApiResponse[dict], summary="新建调整单")
def create_adjustment(
payload: AdjustmentCreateIn,
session: DbSession,
current_user: _SettlementWriter,
meta: RequestMetaDep,
) -> ApiResponse[dict]:
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
return ok(
settlement_service.create_adjustment(session, payload, operator=current_user, meta=meta),
message="调整单已创建",
)
@router.get("/adjustments", response_model=ApiResponse[list[dict]], summary="调整单列表")
def list_adjustments(session: DbSession) -> ApiResponse[list[dict]]:
"""调整单列表(一期不分页,内部系统量级有限)。"""
return ok(settlement_service.list_adjustments(session))
+67
View File
@@ -0,0 +1,67 @@
"""审批管理接口(2026-10-15 复核审批补充需求)。
准入:admin/财务/领导可见(营销后勤暂不可见——权限清单待澄清 #6,
答复后放开为「提交人可见本人单据」)。节点级准入由 service 按单据
状态二次校验(status=1 仅财务、status=2 仅领导,admin 不代办)。
提单不经本模块:由业务写路径(创建/编辑带二次分配方案)自动触发。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.approval import (
ApprovalActionIn,
ApprovalOrderDetailOut,
ApprovalOrderOut,
ApprovalQuery,
)
from app.services import approval_service
router = APIRouter(prefix="/approvals", tags=["系统管理 · 审批管理"])
# 模块准入:可见集合(节点准入在 service 层按状态二次判定)
_ApprovalUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER))]
@router.get("", response_model=ApiResponse[PageResult[ApprovalOrderOut]], summary="审批单列表")
def list_orders(
session: DbSession,
_: _ApprovalUser,
query: Annotated[ApprovalQuery, Query()],
) -> ApiResponse[PageResult[ApprovalOrderOut]]:
"""分页查询审批单。"""
return ok(approval_service.list_orders(session, query))
@router.get("/{order_id}", response_model=ApiResponse[ApprovalOrderDetailOut], summary="审批详情")
def get_order(
session: DbSession,
_: _ApprovalUser,
order_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ApprovalOrderDetailOut]:
"""审批单详情(含节点时间线)。"""
return ok(approval_service.get_order(session, order_id))
@router.post(
"/{order_id}/action",
response_model=ApiResponse[ApprovalOrderDetailOut],
summary="节点动作(通过/驳回)",
)
def act_on_order(
payload: ApprovalActionIn,
session: DbSession,
current_user: _ApprovalUser,
meta: RequestMetaDep,
order_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ApprovalOrderDetailOut]:
"""财务复核 / 领导审核;一驳全驳,驳回须填意见。"""
return ok(
approval_service.act(session, order_id, payload, operator=current_user, meta=meta),
message="已提交",
)
+12 -11
View File
@@ -1,8 +1,9 @@
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。 """计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读); 权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读
写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志, 端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作
变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。 (提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作
日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
导出为文件流(非统一信封),前端以 blob 接收。 导出为文件流(非统一信封),前端以 blob 接收。
""" """
@@ -12,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Path, Query from fastapi import APIRouter, Path, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.api.deps import AdminUser, DbSession, RequestMetaDep from app.api.deps import AdminUser, DbSession, LoginUser, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok from app.core.response import ApiResponse, PageResult, ok
from app.schemas.param import ( from app.schemas.param import (
ParamBatchDetailOut, ParamBatchDetailOut,
@@ -37,7 +38,7 @@ router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
) )
def get_catalog( def get_catalog(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None, as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
) -> ApiResponse[list[ParamCatalogItemOut]]: ) -> ApiResponse[list[ParamCatalogItemOut]]:
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。""" """全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
@@ -63,7 +64,7 @@ def submit_change(
) )
def list_changes( def list_changes(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
query: Annotated[ParamBatchQuery, Query()], query: Annotated[ParamBatchQuery, Query()],
) -> ApiResponse[PageResult[ParamBatchOut]]: ) -> ApiResponse[PageResult[ParamBatchOut]]:
"""按生效日期区间分页查询变更批次(创建时间倒序)。""" """按生效日期区间分页查询变更批次(创建时间倒序)。"""
@@ -75,7 +76,7 @@ def list_changes(
) )
def get_batch( def get_batch(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
batch_id: Annotated[int, Path(ge=1)], batch_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ParamBatchDetailOut]: ) -> ApiResponse[ParamBatchDetailOut]:
"""批次头 + 逐项旧值 → 新值。""" """批次头 + 逐项旧值 → 新值。"""
@@ -97,7 +98,7 @@ def withdraw_batch(
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比") @router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
def compare( def compare(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
from_date: Annotated[date, Query(description="起始业务日期(含)")], from_date: Annotated[date, Query(description="起始业务日期(含)")],
to_date: Annotated[date, Query(description="截止业务日期(含)")], to_date: Annotated[date, Query(description="截止业务日期(含)")],
) -> ApiResponse[list[ParamDiffOut]]: ) -> ApiResponse[list[ParamDiffOut]]:
@@ -108,7 +109,7 @@ def compare(
@router.get("/export", summary="导出变更记录 Excel") @router.get("/export", summary="导出变更记录 Excel")
def export_changes( def export_changes(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None, start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None, end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
) -> StreamingResponse: ) -> StreamingResponse:
@@ -125,7 +126,7 @@ def export_changes(
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览") @router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
def preview( def preview(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
payload: ParamPreviewIn, payload: ParamPreviewIn,
) -> ApiResponse[ParamPreviewOut]: ) -> ApiResponse[ParamPreviewOut]:
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。""" """输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
@@ -139,7 +140,7 @@ def preview(
) )
def get_history( def get_history(
session: DbSession, session: DbSession,
_: AdminUser, _: LoginUser,
param_code: Annotated[str, Path(max_length=64)], param_code: Annotated[str, Path(max_length=64)],
query: Annotated[ParamHistoryQuery, Query()], query: Annotated[ParamHistoryQuery, Query()],
) -> ApiResponse[PageResult[ParamHistoryOut]]: ) -> ApiResponse[PageResult[ParamHistoryOut]]:
+83
View File
@@ -0,0 +1,83 @@
"""系统设置接口(公司信息)。
`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),
仅暴露两个非敏感展示字段。
`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径
(二进制响应套 JSON 信封无意义)。
"""
from fastapi import APIRouter, Response, UploadFile
from fastapi.responses import FileResponse
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.exceptions import BadRequestError
from app.core.response import ApiResponse, ok
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
from app.services import system_setting_service as service
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
"""取公司信息(管理端)。"""
return ok(service.to_out(service.get_setting(session)))
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称")
def update_settings(
payload: SettingUpdateIn,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""更新公司名称(审计留痕)。"""
return ok(
service.update_company(session, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.post("/logo", response_model=ApiResponse[SettingOut], summary="上传 logo")
def upload_logo(
file: UploadFile,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。
同步 `def`:读文件、磁盘写与审计提交都是阻塞操作,async 路由里会让
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
"""
if file.size is not None and file.size > service.LOGO_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
content = file.file.read()
ext = (file.filename or "").rsplit(".", 1)[-1]
return ok(
service.save_logo(session, content, ext, operator=current_user, meta=meta),
message="logo 已更新",
)
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
response.headers["Cache-Control"] = "public, max-age=300"
return ok(service.to_public_out(service.get_setting(session)))
@router.get("/logo/public", summary="logo 图片(匿名)")
def get_logo_public(session: DbSession) -> FileResponse:
"""logo 文件流(无鉴权)。no-cache:图片替换后由版本参数破缓存兜底。"""
path, ext = service.read_logo(session)
return FileResponse(
path,
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
headers={"Cache-Control": "no-cache"},
)
+6 -5
View File
@@ -27,11 +27,12 @@ _WIDTH, _HEIGHT = 120, 40
_FONT_SIZE = 25 _FONT_SIZE = 25
_PADDING = 8 _PADDING = 8
# 与前端配色呼应:墨色字符、青灰干扰、素纸底 # 与前端 v3 配色呼应(--ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper):
_INK = (15, 54, 70) # 墨色字符、次级墨干扰、辅助墨噪点、纸白底
_INTERFERENCE = (126, 156, 170) _INK = (28, 35, 51) # #1c2333
_SPECKLE = (168, 183, 192) _INTERFERENCE = (91, 101, 119) # #5b6577
_PAPER = (247, 246, 242) _SPECKLE = (154, 163, 178) # #9aa3b2
_PAPER = (255, 255, 255) # #ffffff
_SPECKLE_COUNT = 46 _SPECKLE_COUNT = 46
_INTERFERENCE_LINES = 2 _INTERFERENCE_LINES = 2
+9
View File
@@ -92,6 +92,15 @@ class Settings(BaseSettings):
auth = f":{self.redis_password}@" if self.redis_password else "" auth = f":{self.redis_password}@" if self.redis_password else ""
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}" return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
@property
def upload_dir(self) -> Path:
"""上传文件根目录(公司 logo 等):backend/uploads/。
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
"""
return BACKEND_DIR / "uploads"
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+23
View File
@@ -25,6 +25,19 @@ TOKEN_TYPE_BEARER = "Bearer"
# ===== 角色编码(与 db/migration 初始化数据保持一致)===== # ===== 角色编码(与 db/migration 初始化数据保持一致)=====
ROLE_ADMIN = "admin" ROLE_ADMIN = "admin"
ROLE_USER = "user" ROLE_USER = "user"
# 2026-10-15 复核审批补充需求新增职能登录角色:营销后勤(提交+录入)、
# 财务(复核)、领导(审核)。roles 表无 is_builtin 列,内置保护用代码
# 白名单判定(role_service 两处内置校验改查此集合);
# permissions/role_permissions 细粒度激活待业务权限清单(待澄清 #6)。
ROLE_MARKETING_OPS = "marketing_ops"
ROLE_FINANCE = "finance"
ROLE_LEADER = "leader"
BUILTIN_ROLE_CODES = {
ROLE_ADMIN,
ROLE_MARKETING_OPS,
ROLE_FINANCE,
ROLE_LEADER,
}
# ===== 密码策略 ===== # ===== 密码策略 =====
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」, # bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
@@ -70,6 +83,11 @@ MODULE_BUSINESS = "业务管理"
MODULE_SHIPMENT = "发货管理" MODULE_SHIPMENT = "发货管理"
MODULE_RECEIPT = "回款管理" MODULE_RECEIPT = "回款管理"
MODULE_RATING = "客户评级" MODULE_RATING = "客户评级"
MODULE_SYSTEM_SETTINGS = "系统设置"
MODULE_APPROVAL = "审批管理"
MODULE_CALC = "绩效核算"
MODULE_SETTLEMENT = "月度结算"
MODULE_REPORT = "报表统计"
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)===== # ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组 # 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
@@ -77,6 +95,8 @@ NO_PREFIX_BUSINESS = "YW"
NO_PREFIX_SHIPMENT = "SH" NO_PREFIX_SHIPMENT = "SH"
NO_PREFIX_RECEIPT = "HK" NO_PREFIX_RECEIPT = "HK"
NO_PREFIX_PARAM = "P" NO_PREFIX_PARAM = "P"
NO_PREFIX_APPROVAL = "AP"
NO_PREFIX_ADJUSTMENT = "TZ"
ACTION_CREATE = "新增" ACTION_CREATE = "新增"
ACTION_UPDATE = "修改" ACTION_UPDATE = "修改"
@@ -84,3 +104,6 @@ ACTION_DELETE = "删除"
ACTION_RESET_PASSWORD = "重置密码" ACTION_RESET_PASSWORD = "重置密码"
ACTION_ENABLE = "启用" ACTION_ENABLE = "启用"
ACTION_DISABLE = "禁用" ACTION_DISABLE = "禁用"
ACTION_APPROVE = "审批通过"
ACTION_REJECT = "审批驳回"
ACTION_EXPORT = "导出"
+26
View File
@@ -0,0 +1,26 @@
"""月份参数校验(YYYY-MM)——叶子模块。
报表、月结与调整单共用同一套校验:只校验长度会放过 `2026-13` 这类
非法月份,一路传到 `calendar.monthrange` 才抛 ValueError,最终被全局
处理器当成服务端异常(500)。月份范围必须在入口处拦住,才能返回 400。
"""
import re
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
def validate_month(value: str) -> str:
"""校验 YYYY-MM 且月份 01-12;非法抛 ValueError(Pydantic 友好)。
年份 0000 也拒绝:`date(0, ...)` 同样会抛 ValueError,且业务上不存在。
"""
if not _MONTH_RE.match(value) or int(value[:4]) == 0:
raise ValueError("月份格式须为 YYYY-MM(01-12)")
return value
def parse_month(value: str) -> tuple[int, int]:
"""校验并返回 (year, month),供服务层计算月末等使用。"""
validate_month(value)
return int(value[:4]), int(value[5:7])
+76
View File
@@ -0,0 +1,76 @@
"""报表统计元数据(区间桶、中文标签、导出上限)——叶子模块。
刻意不导入 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": "混合业务",
}
REPORT_TEMPLATE_VERSION = "V1.0"
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
# 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")
+13 -1
View File
@@ -9,22 +9,29 @@
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。 `Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
""" """
from app.models.approval import ApprovalOrder, ApprovalStep
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole from app.models.business_role import BusinessRole
from app.models.customer import Customer, OwnershipChangeLog from app.models.customer import Customer, OwnershipChangeLog
from app.models.login_log import LoginLog from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
from app.models.performance_ledger import PerformanceLedger
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
from app.models.product import Product from app.models.product import Product
from app.models.rating import CustomerRatingGrade from app.models.rating import CustomerRatingGrade
from app.models.receipt import Receipt from app.models.receipt import Receipt
from app.models.role import Permission, Role, RolePermission from app.models.role import Permission, Role, RolePermission
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
from app.models.shipment import Shipment, ShipmentItem from app.models.shipment import Shipment, ShipmentItem
from app.models.system_setting import SystemSetting
from app.models.user import User, UserRole from app.models.user import User, UserRole
__all__ = [ __all__ = [
"AdjustmentOrder",
"ApprovalOrder",
"ApprovalStep",
"Base", "Base",
"Business", "Business",
"BusinessItem", "BusinessItem",
@@ -33,11 +40,13 @@ __all__ = [
"Customer", "Customer",
"CustomerRatingGrade", "CustomerRatingGrade",
"LoginLog", "LoginLog",
"MonthlySettlement",
"OperationLog", "OperationLog",
"OwnershipChangeLog", "OwnershipChangeLog",
"ParamChangeBatch", "ParamChangeBatch",
"ParamDefinition", "ParamDefinition",
"ParamValue", "ParamValue",
"PerformanceLedger",
"Permission", "Permission",
"Personnel", "Personnel",
"PersonnelPlan", "PersonnelPlan",
@@ -46,8 +55,11 @@ __all__ = [
"Receipt", "Receipt",
"Role", "Role",
"RolePermission", "RolePermission",
"SecondaryAllocation",
"SettlementItem",
"Shipment", "Shipment",
"ShipmentItem", "ShipmentItem",
"SystemSetting",
"TimestampMixin", "TimestampMixin",
"User", "User",
"UserRole", "UserRole",
+133
View File
@@ -0,0 +1,133 @@
"""审批模型:审批单与节点记录(2026-10-15 复核审批补充需求)。
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数体系开关驱动
(`approval.*.enabled` BOOL):关闭=提交即生成「自动通过」节点留痕,
留痕不断链(开发计划 3.2(三));开启=按节点人工审核,一驳全驳。
依赖说明:节点准入按登录角色编码判定(ROLE_FINANCE/ROLE_LEADER),
不动 `permissions`/`role_permissions` 闲置表 —— 细粒度权限清单待业务方
答复(待澄清 #6)后再激活。
ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=2
(月结预留)将指向 ④ 尚未落地的表,外键无法统一指向;单据删除保护由
服务层承担(详见 approval_service)。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
# 审批单状态(approval_orders.status;流转机见 approval_service)
APPROVAL_STATUS_PENDING_FINANCE = 1 # 待财务复核
APPROVAL_STATUS_PENDING_LEADER = 2 # 待领导审核
APPROVAL_STATUS_APPROVED = 3 # 已通过(终态)
APPROVAL_STATUS_REJECTED = 4 # 已驳回(终态)
APPROVAL_STATUS_WITHDRAWN = 5 # 已撤回(被引用方重新提交时旧在途单注销)
# 节点(approval_steps.node;0 仅用于撤回留痕)
APPROVAL_NODE_FINANCE = 1
APPROVAL_NODE_LEADER = 2
APPROVAL_NODE_WITHDRAW = 0
APPROVAL_NODES = {
APPROVAL_STATUS_PENDING_FINANCE: APPROVAL_NODE_FINANCE,
APPROVAL_STATUS_PENDING_LEADER: APPROVAL_NODE_LEADER,
}
# 动作(approval_steps.action)
APPROVAL_ACTION_APPROVE = 1 # 人工通过
APPROVAL_ACTION_REJECT = 2 # 人工驳回
APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕)
APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销)
# 审批对象类型(approval_orders.order_type)
APPROVAL_TYPE_SECONDARY_ALLOCATION = 1
APPROVAL_TYPE_MONTHLY_SETTLEMENT = 2
APPROVAL_TYPE_ADJUSTMENT = 3
class ApprovalOrder(Base, TimestampMixin):
"""审批单:一张待审对象一条审批流(状态可流转,含 update_time)。"""
__tablename__ = "approval_orders"
__table_args__ = mysql_table_args(
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
Index("idx_approval_orders_type_status", "order_type", "status"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
order_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
)
order_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算 3调整单"
)
ref_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
)
refer_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)
class ApprovalStep(Base):
"""审批节点记录表(append-only):每次动作只增不改,构成时间线。
不继承 TimestampMixin(留痕无更新语义,同 business_status_logs 先例)。
"""
__tablename__ = "approval_steps"
__table_args__ = mysql_table_args(
"审批节点记录表(append-only;自动通过/撤回均有留痕)",
Index("idx_approval_steps_order_id", "order_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
order_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("approval_orders.id", ondelete="CASCADE"),
nullable=False,
comment="审批单ID(approval_orders.id;单删记录随删)",
)
node: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="审批节点:0撤回留痕 1财务复核 2领导审核"
)
action: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
)
opinion: Mapped[str | None] = mapped_column(String(255), comment="审批意见(驳回时填写)")
operator_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(自动通过/系统动作置空)",
)
operator_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="操作人用户名(冗余固化;系统动作记「系统」)"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
+58 -1
View File
@@ -15,7 +15,7 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal 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 sqlalchemy.orm import Mapped, mapped_column
from app.models.base import ( from app.models.base import (
@@ -33,6 +33,63 @@ BUSINESS_STATUS_DONE = 2
BUSINESS_STATUS_TERMINATED = 3 BUSINESS_STATUS_TERMINATED = 3
class SecondaryAllocation(Base, TimestampMixin):
"""二次分配方案明细表(2026-10-15 补充需求「二次分配方案」)。
口径(AI_CONTEXT 待澄清 #1/#2 未答复前的边界):
- 只出现在合同刚建立时;修改在首次月结前留痕放行,月结后只读(月结在 ④,
终态门槛先行承担「修改不影响已结算月份」)
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
与比例,响应以 hint 提示合计/余额,不做强制校验
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
同一合同的同一参与人仅一行,重复提交在服务层聚合。
"""
__tablename__ = "business_secondary_allocations"
__table_args__ = mysql_table_args(
# 表/列注释避开「%」字符: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"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="CASCADE"),
nullable=False,
comment="业务ID(businesses.id;业务删除随删)",
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="参与业务员ID(personnel.id;被方案引用禁删)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="参与业务员姓名(冗余固化,离职后回显有名)"
)
ratio: Mapped[Decimal] = mapped_column(
Numeric(5, 2),
nullable=False,
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
)
class Business(Base, TimestampMixin, TPlusSyncMixin): class Business(Base, TimestampMixin, TPlusSyncMixin):
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。""" """业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
+181
View File
@@ -0,0 +1,181 @@
"""绩效核算台账模型(M3 ④:每笔回款仅核算一次的固化点)。
**append-only 版本行**:重算 = 插入 calc_version+1 的新行,旧行仅把
is_active 翻 0 —— 这是全表唯一的受控 UPDATE(模型注释与 test_models
均已声明例外),行内容永不改写;历史版本可回溯(重算授权留痕的需求口径)。
防重三层:服务层 CAS(UPDATE ... WHERE settlement_status=0,rowcount=0
即 400)+ FOR UPDATE 行锁 + 库级 uk(receipt_id, calc_version, personnel_id)
兜底。uk 含 personnel_id:二次分配分摊(澄清 #1 答复 2026-10-19,分配
绩效——按方案比例拆给参与人、余额归合同负责人)后同一回款同版本按人
各落一行,拆分仍是「一次核算」的组成部分;聚合行 receipt_id 为 NULL,
MySQL 唯一索引对 NULL 不去重,天然不约束。
经理聚合行(calc_type=2,receipt_id 为 NULL):月度批处理时按「本团队
业务员绩效 ×12.5% + 全公司 ×5%」落一行;其唯一性由月结生成的
settlement_month 串行化 + 服务层保证(MySQL 无部分唯一索引,docstring
声明即可)。
#12 答复(2026-10-19):经理个人户业绩是否计入聚合基数改为参数
``manager.personal.included``(默认 0=不计入)——消费点在 settlement_service
的聚合函数。
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import (
JSON,
Date,
ForeignKey,
Index,
Numeric,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
# 核算类型(performance_ledgers.calc_type)
CALC_TYPE_RECEIPT = 1 # 单笔回款核算
CALC_TYPE_MANAGER_AGGREGATE = 2 # 经理团队聚合(月度批处理)
class PerformanceLedger(Base, TimestampMixin):
"""绩效核算台账表:核算事实的固化点,报表 2.4.1 的数据源。"""
__tablename__ = "performance_ledgers"
__table_args__ = mysql_table_args(
"绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
# 库级防重兜底:同一回款同版本按人仅一行(分摊拆行后负责人+参与人
# 各占一行;服务层 CAS+行锁之外的第二道闸)。聚合行 receipt_id 为
# NULL,MySQL 唯一索引对 NULL 不去重,天然不约束聚合行
UniqueConstraint(
"receipt_id",
"calc_version",
"personnel_id",
name="uk_performance_ledgers_receipt_id_calc_version_personnel",
),
Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"),
Index(
"idx_performance_ledgers_month_personnel_type",
"settlement_month",
"personnel_id",
"calc_type",
),
Index("idx_performance_ledgers_personnel_id", "personnel_id"),
Index("idx_performance_ledgers_business_id", "business_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
receipt_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("receipts.id", ondelete="RESTRICT"),
comment="回款ID(receipts.id;聚合行可为 NULL)",
)
calc_version: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="核算版本(重算+1,旧行 is_active=0)",
)
is_active: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
)
calc_type: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
)
business_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="业务编号(冗余固化,列表免联查)"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="归属营销人员ID(personnel.id;核算归属锚点)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="归属营销人员姓名(冗余固化)"
)
role_code: Mapped[str] = mapped_column(
String(32), nullable=False, comment="岗位编码(business_roles.role_code,计提公式锚点)"
)
customer_type: Mapped[str] = mapped_column(
String(32), nullable=False, comment="客户分类:personal/manager_personal/enterprise"
)
product_category: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
)
ship_date: Mapped[date | None] = mapped_column(Date, comment="发货日期(回款天数基准)")
receipt_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="回款日期(参数版本匹配日,裁决 #11)"
)
payment_days: Mapped[int | None] = mapped_column(
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)"
)
rate_text: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
)
converted_profit: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="折算毛利润额(可负;混合业务为各行汇总)"
)
performance_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="绩效金额(可负,裁决:负绩效照算不归零)"
)
barter_applied: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="以物抵顶折算:1已按 80% 折算 0否",
)
weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)")
payment_weight: Mapped[Decimal | None] = mapped_column(
Numeric(7, 6), comment="支付权重系数 R×R(展示用)"
)
payable_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)"
)
settlement_month: Mapped[str] = mapped_column(
String(7), nullable=False, comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)"
)
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="试用期:1照算不发放 0否",
)
missing_cost: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="计算快照(所用参数版本与行级折算明细,重算可回溯)"
)
settlement_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("monthly_settlements.id", ondelete="RESTRICT"),
comment="所属月结ID(月结生成时回填;NULL=未入结)",
)
calc_reason: Mapped[str | None] = mapped_column(
String(255), comment="核算/重算原因(重算必填留痕)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
+197
View File
@@ -0,0 +1,197 @@
"""月度结算与调整单模型(M3 ④:月结审批报表补充需求)。
- 月度结算按「确认回款」和计算快照生成;**通过即锁定**(status=3 已审定
即终态,锁定后源数据改动只产生提示与后续调整)
- settlement_items 明细按人固化(可负=负数工资),锁定后无 UPDATE 路径
(重生成 = 草稿/驳回态删旧插新)
- 调整单是已锁定月份的唯一调整通道(补发/扣减,走审批;生效后不回写
已锁定月结——append-only,报表汇总并入)
"""
from datetime import datetime
from decimal import Decimal
from sqlalchemy import JSON, DateTime, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
# 月结状态(monthly_settlements.status;通过即锁定——两态合一)
SETTLEMENT_STATUS_DRAFT = 1 # 草稿(可重新生成/提交)
SETTLEMENT_STATUS_PENDING = 2 # 待审批(提交后)
SETTLEMENT_STATUS_APPROVED = 3 # 已审定(终态=已锁定)
SETTLEMENT_STATUS_REJECTED = 4 # 已驳回(回草稿可重提)
# 调整单状态(adjustment_orders.status)
ADJUSTMENT_STATUS_PENDING = 1 # 待审批
ADJUSTMENT_STATUS_EFFECTIVE = 2 # 已生效
ADJUSTMENT_STATUS_REJECTED = 3 # 已驳回
ADJUSTMENT_STATUS_WITHDRAWN = 4 # 已撤回
# 调整方向(adjustment_orders.direction)
ADJUSTMENT_DIRECTION_EXTRA = 1 # 补发
ADJUSTMENT_DIRECTION_DEDUCT = 2 # 扣减
class MonthlySettlement(Base, TimestampMixin):
"""月度结算表:按月一行(settlement_month 唯一),快照 + 审批 + 锁定。"""
__tablename__ = "monthly_settlements"
__table_args__ = mysql_table_args("月度结算表(按月一行;通过即锁定,锁定后走调整单)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
settlement_month: Mapped[str] = mapped_column(
String(7), nullable=False, unique=True, comment="结算月份(YYYY-MM;全表唯一)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
)
total_performance: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="绩效金额合计(可负)"
)
total_payable: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="应付绩效合计(可负)"
)
person_count: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="0", comment="结算人数"
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)"
)
approval_order_no: Mapped[str | None] = mapped_column(
String(32), comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)"
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)
locked_at: Mapped[datetime | None] = mapped_column(
DateTime, comment="锁定时间(status=3 时回填)"
)
locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)")
class SettlementItem(Base):
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
__tablename__ = "settlement_items"
__table_args__ = mysql_table_args(
"月结明细表(按人固化;支持负数工资;锁定后只读)",
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
settlement_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("monthly_settlements.id", ondelete="CASCADE"),
nullable=False,
comment="月结ID(monthly_settlements.id;头删明细随删)",
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="结算人员ID(personnel.id)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="结算人员姓名(冗余固化)"
)
role_code: Mapped[str] = mapped_column(
String(32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
)
calc_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="核算类型:1单笔回款 2经理团队聚合"
)
performance_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
)
payable_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="应付绩效合计(可负)"
)
basic_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="月工资标准(personnel.salary_standard 当月快照)"
)
salary_mode: Mapped[str | None] = mapped_column(
String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE"
)
net_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="净工资 = 绩效±基本工资(按口径;试用期 0)"
)
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
)
probation_note: Mapped[str | None] = mapped_column(
String(255), comment="试用期说明(年累计超阈值部分年底一次性结算)"
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="个人明细快照(台账行引用列表)"
)
class AdjustmentOrder(Base, TimestampMixin):
"""调整单:已锁定月份的唯一调整通道(补发/扣减,走审批;不回写月结)。"""
__tablename__ = "adjustment_orders"
__table_args__ = mysql_table_args(
"调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
order_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
)
settlement_month: Mapped[str] = mapped_column(
String(7), nullable=False, comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="调整人员ID(personnel.id)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="调整人员姓名(冗余固化)"
)
direction: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="方向:1补发 2扣减"
)
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="调整金额(恒正,方向表达语义)"
)
reason: Mapped[str] = mapped_column(
String(255), nullable=False, comment="扣补说明(必填,审批与追溯锚点)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)
+31
View File
@@ -0,0 +1,31 @@
"""系统设置模型:公司信息单例行。
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在
`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见
system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。
"""
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class SystemSetting(Base, TimestampMixin):
"""系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。"""
__tablename__ = "system_settings"
__table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
company_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)"
)
logo_path: Mapped[str | None] = mapped_column(
String(255),
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
)
@@ -18,6 +18,7 @@ from app.models import (
OwnershipChangeLog, OwnershipChangeLog,
Personnel, Personnel,
Receipt, Receipt,
SecondaryAllocation,
Shipment, Shipment,
) )
from app.models.customer import OWNER_TYPE_BUSINESS from app.models.customer import OWNER_TYPE_BUSINESS
@@ -198,6 +199,47 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
session.flush() session.flush()
def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
"""二次分配方案(详情回显;空列表=未设置方案)。"""
return list(
session.execute(
select(SecondaryAllocation)
.where(SecondaryAllocation.business_id == business_id)
.order_by(SecondaryAllocation.id)
).scalars()
)
def replace_allocations(
session: Session, business_id: int, rows: list[SecondaryAllocation]
) -> None:
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
"""
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
)
session.flush()
for row in rows:
row.business_id = business_id
session.add(row)
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: def add_status_log(session: Session, log: BusinessStatusLog) -> None:
"""追加状态留痕(不 commit)。""" """追加状态留痕(不 commit)。"""
session.add(log) session.add(log)
@@ -0,0 +1,209 @@
"""工作台仓储:四区块的只读聚合查询;不 commit、不抛业务异常。
总览只做 SQL 聚合(SUM/COUNT DISTINCT),不复用报表的
``department_summary``——后者会加载整月台账并逐团队查人员,适合报表页,
不适合每次打开工作台。
"""
from datetime import date, datetime
from sqlalchemy import exists, func, 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, months: list[str]) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。"""
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
rows = session.execute(
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
.where(month_expr.in_(months))
.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, *, limit: int) -> list[ApprovalOrder]:
"""待处理审批单(按提交时间升序——最久的排前面)。"""
return list(
session.execute(
select(ApprovalOrder)
.where(ApprovalOrder.status.in_(_PENDING_STATUSES))
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
.limit(limit)
).scalars()
)
def count_pending_approvals(session: Session) -> int:
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(ApprovalOrder.status.in_(_PENDING_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:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。"""
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)
.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)
.where(Shipment.ship_date <= cutoff, ~has_receipt)
).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()
)
@@ -40,9 +40,7 @@ def list_by_ref(
return list(session.execute(stmt).all()) return list(session.execute(stmt).all())
def count_by_ref( def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
session: Session, *, owner_type: int, ref_column, ref_id: int
) -> int:
"""统计某引用对象的归属留痕总数,供分页使用。""" """统计某引用对象的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where( stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == owner_type, OwnershipChangeLog.owner_type == owner_type,
@@ -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)
@@ -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()
)
@@ -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()
+66
View File
@@ -0,0 +1,66 @@
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.core.response import PageQuery
class ApprovalStepOut(BaseModel):
"""节点记录(时间线;action=3 自动通过 operator 记「系统」)。"""
model_config = ConfigDict(from_attributes=True)
id: int
node: int
action: int
opinion: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime
class ApprovalOrderOut(BaseModel):
"""审批单列表行。"""
model_config = ConfigDict(from_attributes=True)
id: int
order_no: str
order_type: int
ref_id: int
refer_no: str
status: int
submitter_id: int | None = None
submitter_name: str
create_time: datetime
class ApprovalOrderDetailOut(ApprovalOrderOut):
"""审批单详情:附节点时间线。"""
steps: list[ApprovalStepOut] = Field(default_factory=list)
class ApprovalQuery(PageQuery):
"""审批单列表查询条件。"""
keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊")
status: int | None = Field(default=None, ge=1, le=5, description="状态筛选")
order_type: int | None = Field(default=None, ge=1, le=2, description="对象类型筛选")
class ApprovalActionIn(BaseModel):
"""节点动作:1 通过 / 2 驳回(驳回须填意见——空驳无从追溯)。"""
model_config = ConfigDict(str_strip_whitespace=True)
action: int = Field(ge=1, le=2, description="1通过 2驳回")
opinion: str | None = Field(default=None, max_length=255, description="审批意见")
@model_validator(mode="after")
def _check_opinion(self) -> "ApprovalActionIn":
if self.action == 2 and not self.opinion:
raise ValueError("驳回必须填写审批意见")
return self
+61 -2
View File
@@ -151,10 +151,53 @@ class BusinessOut(BaseModel):
remark: str | None = None remark: str | None = None
class SecondaryAllocationIn(BaseModel):
"""二次分配参与人入参(2026-10-15 补充需求「二次分配方案」)。
比例**不要求合计 100%**(需求原文):余额归属待业务澄清(待澄清 #2),
服务层仅以 hint 展示合计/余额,不做强制校验。ratio 沿金额红线口径:
字符串入参、两位小数、超限拒绝不静默舍入。
"""
model_config = ConfigDict(str_strip_whitespace=True)
personnel_id: int = Field(ge=1, description="参与业务员ID(personnel.id)")
ratio: str = Field(description="分摊比例(0-100,两位小数)")
@field_validator("ratio")
@classmethod
def _check_ratio(cls, value: str) -> str:
return _check_amount(value, "分摊比例", Decimal("100"), allow_positive_only=True) or value
class SecondaryAllocationOut(BaseModel):
"""二次分配参与人响应(personnel_name 冗余固化,离职后回显有名)。"""
model_config = ConfigDict(from_attributes=True)
personnel_id: int
personnel_name: str
ratio: str
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
seen: set[int] = set()
for item in items:
if item.personnel_id in seen:
raise ValueError(f"二次分配参与人重复:人员ID {item.personnel_id}")
seen.add(item.personnel_id)
class BusinessDetailOut(BusinessOut): class BusinessDetailOut(BusinessOut):
"""业务详情:附产品明细行。""" """业务详情:附产品明细行与二次分配方案。"""
items: list[BusinessItemOut] = Field(default_factory=list) items: list[BusinessItemOut] = Field(default_factory=list)
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
allocation_hint: str | None = Field(
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
)
class BusinessCreateIn(BaseModel): class BusinessCreateIn(BaseModel):
@@ -175,6 +218,9 @@ class BusinessCreateIn(BaseModel):
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否") is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明") special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行") items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行")
secondary_allocations: list[SecondaryAllocationIn] = Field(
default_factory=list, description="二次分配方案(只出现在合同刚建立时;可空)"
)
remark: str | None = Field(default=None, max_length=255) remark: str | None = Field(default=None, max_length=255)
@model_validator(mode="after") @model_validator(mode="after")
@@ -184,9 +230,14 @@ class BusinessCreateIn(BaseModel):
raise ValueError("特殊业务必须填写说明") raise ValueError("特殊业务必须填写说明")
return self return self
@model_validator(mode="after")
def _check_allocations(self) -> "BusinessCreateIn":
_ensure_allocations_unique(self.secondary_allocations)
return self
class BusinessUpdateIn(BaseModel): class BusinessUpdateIn(BaseModel):
"""编辑业务(全量替换明细;归属不在此端点调整,走 /ownership)。""" """编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
model_config = ConfigDict(str_strip_whitespace=True) model_config = ConfigDict(str_strip_whitespace=True)
@@ -207,6 +258,9 @@ class BusinessUpdateIn(BaseModel):
items: list[BusinessItemIn] = Field( items: list[BusinessItemIn] = Field(
min_length=1, description="产品明细行,至少 1 行(全量替换)" min_length=1, description="产品明细行,至少 1 行(全量替换)"
) )
secondary_allocations: list[SecondaryAllocationIn] = Field(
default_factory=list, description="二次分配方案(全量替换;空数组=清空方案)"
)
remark: str | None = Field(default=None, max_length=255) remark: str | None = Field(default=None, max_length=255)
@model_validator(mode="after") @model_validator(mode="after")
@@ -215,6 +269,11 @@ class BusinessUpdateIn(BaseModel):
raise ValueError("特殊业务必须填写说明") raise ValueError("特殊业务必须填写说明")
return self return self
@model_validator(mode="after")
def _check_allocations(self) -> "BusinessUpdateIn":
_ensure_allocations_unique(self.secondary_allocations)
return self
class BusinessStatusIn(BaseModel): class BusinessStatusIn(BaseModel):
"""状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。""" """状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。"""
+62
View File
@@ -0,0 +1,62 @@
"""绩效核算的请求/响应模型(M3 ④)。"""
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class LedgerQuery(PageQuery):
"""台账列表查询条件。"""
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
personnel_id: int | None = Field(default=None, ge=1, description="归属人员筛选")
business_id: int | None = Field(default=None, ge=1, description="业务筛选")
is_active: int | None = Field(default=None, ge=0, le=1, description="生效标记筛选")
calc_type: int | None = Field(default=None, ge=1, le=2, description="核算类型筛选")
class LedgerOut(BaseModel):
"""台账行响应(金额/比例一律 str)。"""
model_config = ConfigDict(from_attributes=True)
id: int
receipt_id: int | None = None
calc_version: int
is_active: int
calc_type: int
business_id: int
business_no: str
personnel_id: int
personnel_name: str
role_code: str
customer_type: str
product_category: str
ship_date: date | None = None
receipt_date: date
payment_days: int | None = None
rate_text: str
converted_profit: Decimal | None = None
performance_amount: Decimal | None = None
barter_applied: int
weight_r: int | None = None
payment_weight: Decimal | None = None
payable_amount: Decimal | None = None
settlement_month: str
is_probation: int
missing_cost: int
snapshot_json: dict | None = None
settlement_id: int | None = None
calc_reason: str | None = None
create_time: datetime
class CalcReceiptOut(BaseModel):
"""单笔核算结果:台账行 + 重复计提标记(核算粒度 #25 一期口径)。"""
ledger: LedgerOut
#: True=该业务此前已核算过,本笔回款仅登记进度、未重复计提(#25 一期口径)
repeated: bool = False
+125
View File
@@ -0,0 +1,125 @@
"""工作台的请求/响应模型(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)
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)
+269
View File
@@ -0,0 +1,269 @@
"""报表统计的请求/响应模型(M4 ⑤)。
金额与比例一律 `Decimal`(pydantic v2 序列化为字符串,零精度损失);
比例原样输出 `rate_text`(如 88/9000),禁止转 float 或截断展示。
"""
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
# ==== 查询入参 ====
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="岗位筛选")
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="仅看某经理团队")
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="产品类别筛选")
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="产品类别筛选")
# ==== 响应出参 ====
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
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% 全公司分成(同一基数,各团队不互斥)")
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% 全公司分成")
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
totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)")
class BusinessStatsRow(BaseModel):
"""业务统计行(发货/回款/绩效为该业务累计值)。"""
business_id: int
business_no: str
business_date: date | None = None
status: int
customer_name: str | None = None
owner_personnel_name: str | None = None
total_sale_amount: Decimal | None = None
total_ship_amount: Decimal | None = None
total_received_amount: Decimal | None = None
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
class BucketRow(BaseModel):
"""回款统计 · 区间桶行(空桶恒在且为 0,防金额静默消失)。"""
code: str
label: str
min_days: int | None = None
max_days: int | None = None
receipt_amount: Decimal | None = None
receipt_count: int = 0
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
ledger_count: int = 0
class GroupRow(BaseModel):
"""回款统计 · 维度对比行(客户类型 / 产品类别)。"""
code: str
label: str
receipt_amount: Decimal | None = None
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
ledger_count: int = 0
class PaymentStatsOut(BaseModel):
"""回款情况统计响应(FR-031)。
口径:已计提回款(有台账行)的金额与绩效;进度登记回款无台账行,
不在本表(页面需注明)。
"""
settlement_month: str | None = None
buckets: list[BucketRow] = Field(default_factory=list)
by_customer_type: list[GroupRow] = Field(default_factory=list)
by_product_category: list[GroupRow] = Field(default_factory=list)
totals: dict = Field(default_factory=dict, description="全表合计")
class PaymentDetailRow(BaseModel):
"""回款区间下钻行(行级展示回款金额与批次号)。"""
ledger_id: int
receipt_id: int | None = None
receipt_batch_no: str | None = None
amount: Decimal | None = Field(default=None, description="回款金额(同回款拆行重复展示)")
business_id: int
business_no: str
personnel_id: int
personnel_name: str
customer_type: str
product_category: str
ship_date: date | None = None
receipt_date: date
payment_days: int | None = None
performance_amount: Decimal | None = None
payable_amount: Decimal | None = None
+34
View File
@@ -0,0 +1,34 @@
"""系统设置(公司信息)请求/响应模型。
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
顺着复用链泄漏到匿名端点。
"""
from pydantic import BaseModel, ConfigDict, Field
class SettingOut(BaseModel):
"""公司信息(管理端)。"""
company_name: str
logo_url: str | None = None
class SettingPublicOut(BaseModel):
"""公司信息(匿名端点白名单):登录页展示所需的两个字段。"""
company_name: str
logo_url: str | None = None
class SettingUpdateIn(BaseModel):
"""公司信息更新。
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
+95
View File
@@ -0,0 +1,95 @@
"""月度结算的请求/响应模型(M3 ④)。"""
import re
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
_MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
def _check_month(value: str) -> str:
"""月份格式 YYYY-MM(settlement_month 全表唯一的维度)。"""
if not _MONTH_RE.match(value):
raise ValueError("月份格式须为 YYYY-MM")
return 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="扣补说明")
+377
View File
@@ -0,0 +1,377 @@
"""审批流业务编排(2026-10-15 复核审批补充需求)。
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数开关驱动
(`approval.secondary_allocation.enabled` / `approval.monthly_settlement.enabled`):
- **关闭(默认)**:提交即生成审批单并「自动通过」全节点留痕(action=3,
operator 记「系统」)——留痕不断链,与「关闭=直接生效」的需求口径一致;
- **开启**:status=1 待财务复核 → 2 待领导审核 → 3 已通过;任一节点驳回
即整体驳回(status=4),一期不做退回重走(简单状态机)。
红线:
- `submit_order` **不 commit、不嵌套 audit** —— 由业务写路径(business_service
的 create/update)既有 audit 上下文统一收口,违反嵌套禁令会让失败路径
回滚不完整(见 audit_service 模块 docstring);
- 节点准入**严格按角色编码**:status=1 仅财务、status=2 仅领导,admin 不
代办 —— 放行 admin 会让双人复核流退化成管理员单点审批;
- 同一引用对象的在途单(status 1/2)在重新提交前先撤回(status=5 +
node=0/action=4 留痕),保证「一个对象至多一张在途单」。
"""
import logging
from datetime import date
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_APPROVE,
ACTION_REJECT,
MODULE_APPROVAL,
NO_PREFIX_APPROVAL,
ROLE_FINANCE,
ROLE_LEADER,
)
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,
APPROVAL_NODE_WITHDRAW,
APPROVAL_NODES,
APPROVAL_STATUS_APPROVED,
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
APPROVAL_STATUS_REJECTED,
APPROVAL_STATUS_WITHDRAWN,
)
from app.repositories import user_repository
from app.schemas.approval import (
ApprovalActionIn,
ApprovalOrderDetailOut,
ApprovalOrderOut,
ApprovalQuery,
ApprovalStepOut,
)
from app.services import audit_service, param_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
ENABLE_CODE_BY_TYPE: dict[int, str] = {
1: "approval.secondary_allocation.enabled",
2: "approval.monthly_settlement.enabled",
3: "approval.adjustment.enabled",
}
# 在途状态集合(同一引用对象至多一张在途单)
_PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
# 在途状态 → 当前节点应操作的角色(admin 不代办:单人审批绕过双人复核)
_NODE_ROLE_BY_STATUS = {
APPROVAL_STATUS_PENDING_FINANCE: ROLE_FINANCE,
APPROVAL_STATUS_PENDING_LEADER: ROLE_LEADER,
}
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
def is_enabled(session: Session, order_type: int) -> bool:
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
code = ENABLE_CODE_BY_TYPE.get(order_type)
if code is None:
raise BadRequestError("未知的审批对象类型")
return param_service.resolve_snapshot(session, date.today()).boolean(code)
def submit_order(
session: Session,
*,
order_type: int,
ref_id: int,
refer_no: str,
submitter: User,
) -> ApprovalOrder:
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
"""
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
order = ApprovalOrder(
order_no=next_no(
session,
model=ApprovalOrder,
column_name="order_no",
prefix=NO_PREFIX_APPROVAL,
),
order_type=order_type,
ref_id=ref_id,
refer_no=refer_no,
submitter_id=submitter.id,
submitter_name=submitter.username,
)
session.add(order)
if is_enabled(session, order_type):
order.status = APPROVAL_STATUS_PENDING_FINANCE
session.flush()
logger.info("审批单待审 | order=%s", order.order_no)
return order
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
order.status = APPROVAL_STATUS_APPROVED
session.flush()
session.add_all(
[
ApprovalStep(
order_id=order.id,
node=node,
action=APPROVAL_ACTION_AUTO_APPROVE,
operator_name="系统",
)
for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER)
]
)
session.flush()
logger.info("审批单自动通过 | order=%s(开关关闭)", order.order_no)
return order
def withdraw_pending_orders(
session: Session, *, order_type: int, ref_id: int, operator: User
) -> int:
"""撤回引用对象的全部在途单(供业务清空方案等场景显式调用;不 commit)。
返回撤回张数(0 = 本就无在途单)。
"""
before = len(
session.execute(
select(ApprovalOrder.id).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
if before:
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=operator)
return before
def act(
session: Session,
order_id: int,
payload: ApprovalActionIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ApprovalOrderDetailOut:
"""节点动作(财务复核/领导审核):状态机流转 + 节点留痕(审计上下文)。
通过:status=1 → 2(下一步领导);status=2 → 3(终态已通过)。
驳回:任一在途状态 → 4(整体驳回,一期不退回重走)。
节点留痕的 node 取**动作发生时的当前节点**(通过/驳回都记在操作节点上)。
"""
codes = set(user_repository.get_role_codes(operator))
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_APPROVAL,
action=verb,
summary=f"审批单 #{order_id} {verb}",
request_data=payload.model_dump(),
),
meta=meta,
):
order = session.get(ApprovalOrder, order_id, with_for_update=True)
if order is None:
raise NotFoundError("审批单不存在")
if order.status not in _PENDING_STATUSES:
raise BadRequestError("审批单已结束,不可重复操作")
required_role = _NODE_ROLE_BY_STATUS[order.status]
if required_role not in codes:
raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作")
node_now = APPROVAL_NODES[order.status]
rejected = payload.action == APPROVAL_ACTION_REJECT
if rejected:
order.status = APPROVAL_STATUS_REJECTED
else:
order.status = (
APPROVAL_STATUS_PENDING_LEADER
if order.status == APPROVAL_STATUS_PENDING_FINANCE
else APPROVAL_STATUS_APPROVED
)
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
# 驳回在任何节点都是终态,即时联动。
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
session.add(
ApprovalStep(
order_id=order.id,
node=node_now,
action=payload.action,
opinion=payload.opinion,
operator_id=operator.id,
operator_name=operator.username,
)
)
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
# 「当前生效行」身份留在表里、被 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
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
"""分页查询审批单(准入由 api 层依赖判定)。"""
filters = _order_filters(query)
rows = (
session.execute(
select(ApprovalOrder)
.where(*filters)
.order_by(ApprovalOrder.id.desc())
.offset(query.offset)
.limit(query.page_size)
)
.scalars()
.all()
)
total = session.execute(
select(func.count()).select_from(ApprovalOrder).where(*filters)
).scalar_one()
return PageResult[ApprovalOrderOut](
records=[ApprovalOrderOut.model_validate(r) for r in rows],
total=int(total),
page_num=query.page_num,
page_size=query.page_size,
)
def get_order(session: Session, order_id: int) -> ApprovalOrderDetailOut:
"""审批单详情(附节点时间线)。"""
order = session.get(ApprovalOrder, order_id)
if order is None:
raise NotFoundError("审批单不存在")
return _detail_of(session, order)
def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operator: User) -> None:
"""撤回引用对象的在途单(重新提交前调用;不 commit)。"""
pending = (
session.execute(
select(ApprovalOrder).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
for order in pending:
order.status = APPROVAL_STATUS_WITHDRAWN
session.add(
ApprovalStep(
order_id=order.id,
node=APPROVAL_NODE_WITHDRAW,
action=APPROVAL_ACTION_WITHDRAW,
operator_id=operator.id,
operator_name=operator.username,
)
)
if pending:
session.flush()
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
steps = (
session.execute(
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
)
.scalars()
.all()
)
detail = ApprovalOrderDetailOut.model_validate(order)
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
return detail
def _order_filters(query: ApprovalQuery) -> tuple:
filters = []
if query.keyword:
# autoescape:用户输入里的 %/_ 按 URL 片段字面匹配(全库统一口径,
# 见 customer_repository.contains 用法)——否则 "AP2026_10" 会把
# 下划线当单字符通配,搜出无关单据
filters.append(
ApprovalOrder.order_no.contains(query.keyword, autoescape=True)
| ApprovalOrder.refer_no.contains(query.keyword, autoescape=True)
)
if query.status is not None:
filters.append(ApprovalOrder.status == query.status)
if query.order_type is not None:
filters.append(ApprovalOrder.order_type == query.order_type)
return tuple(filters)
+172 -5
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.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult from app.core.response import PageQuery, PageResult
from app.models import ( from app.models import (
ApprovalOrder,
Business, Business,
BusinessItem, BusinessItem,
BusinessStatusLog, BusinessStatusLog,
@@ -41,15 +42,17 @@ from app.models import (
OwnershipChangeLog, OwnershipChangeLog,
Personnel, Personnel,
Receipt, Receipt,
SecondaryAllocation,
Shipment, Shipment,
User, User,
) )
from app.models.approval import APPROVAL_TYPE_SECONDARY_ALLOCATION
from app.models.business import ( from app.models.business import (
BUSINESS_STATUS_ACTIVE, BUSINESS_STATUS_ACTIVE,
BUSINESS_STATUS_DONE, BUSINESS_STATUS_DONE,
) )
from app.models.customer import OWNER_TYPE_BUSINESS 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 ( from app.schemas.business import (
BusinessCreateIn, BusinessCreateIn,
BusinessDetailOut, BusinessDetailOut,
@@ -65,8 +68,10 @@ from app.schemas.business import (
GrossProfitOut, GrossProfitOut,
GrossProfitRowOut, GrossProfitRowOut,
OwnershipChangeLogOut, OwnershipChangeLogOut,
SecondaryAllocationIn,
SecondaryAllocationOut,
) )
from app.services import audit_service, param_service, personnel_service from app.services import approval_service, audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import converted_profit, profit_rate from app.services.formula import converted_profit, profit_rate
from app.services.no_generator import next_no from app.services.no_generator import next_no
@@ -257,7 +262,7 @@ def list_businesses(session: Session, query: BusinessQuery) -> PageResult[Busine
def get_detail(session: Session, business_id: int) -> BusinessDetailOut: def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
"""业务详情(附明细行,供编辑抽屉回显)。 """业务详情(附明细行与二次分配方案,供编辑抽屉回显)。
:raises NotFoundError: 业务不存在 :raises NotFoundError: 业务不存在
""" """
@@ -268,9 +273,83 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
out.items = [ out.items = [
_item_to_out(item) for item in business_repository.list_items(session, business_id) _item_to_out(item) for item in business_repository.list_items(session, business_id)
] ]
allocations = business_repository.list_allocations(session, business_id)
out.secondary_allocations = [
SecondaryAllocationOut(
personnel_id=a.personnel_id, personnel_name=a.personnel_name, ratio=str(a.ratio)
)
for a in allocations
]
out.allocation_hint = _allocation_hint(allocations)
return out return out
def _build_allocation_rows(
session: Session,
business_id: int,
items: list[SecondaryAllocationIn],
*,
old_ids: set[int],
) -> list[SecondaryAllocation]:
"""构造二次分配明细行:人员校验 + 姓名冗余固化 + 比例转 Decimal。
:param old_ids: 编辑前已参与的人员 ID 集合(create 传空集)。这些人的
行是**历史事实**——personnel_name 冗余固化的设计本就允许离职后回显,
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
**新加入**的参与人做在职校验。
:raises BadRequestError: 新增参与人员不存在或已离职 / 比例合计超 100%
/ 参与人含合同负责人(口径见澄清 #1/#2 答复 2026-10-19:分配绩效、
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
自分份额则双重计入)
"""
business = business_repository.get_by_id(session, business_id)
if business is None:
raise NotFoundError("业务不存在")
total = sum((Decimal(item.ratio) for item in items), Decimal("0"))
if total > Decimal("100"):
raise BadRequestError(
f"二次分配比例合计 {total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}%"
" 超过 100%(余额归合同负责营销人员,比例不可超满额)"
)
owner_id = business.owner_personnel_id
if owner_id is not None and any(item.personnel_id == owner_id for item in items):
raise BadRequestError("分摊参与人不可包含合同负责营销人员(负责人得余额份额)")
rows: list[SecondaryAllocation] = []
for item in items:
if item.personnel_id in old_ids:
person = personnel_repository.get_by_id(session, item.personnel_id)
if person is None:
raise BadRequestError("分摊参与人员不存在")
else:
person = _ensure_personnel_active(session, item.personnel_id, "分摊参与")
rows.append(
SecondaryAllocation(
business_id=business_id,
personnel_id=item.personnel_id,
personnel_name=person.personnel_name,
ratio=Decimal(item.ratio),
)
)
return rows
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
"""
if not allocations:
return None
total = sum((a.ratio for a in allocations), Decimal("0"))
total_text = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if total == Decimal("100"):
return f"二次分配合计 {total_text}%"
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"二次分配合计 {total_text}%,余额 {rest}%(归合同负责营销人员)"
def create_business( def create_business(
session: Session, session: Session,
payload: BusinessCreateIn, payload: BusinessCreateIn,
@@ -335,6 +414,26 @@ def create_business(
operator_name=operator.username, operator_name=operator.username,
), ),
) )
# 二次分配方案与业务同事务(需求:只出现在合同刚建立时);
# 放在重试循环内——rollback 后方案行随业务一并重来
business_repository.replace_allocations(
session,
business.id,
_build_allocation_rows(
session, business.id, payload.secondary_allocations, old_ids=set()
),
)
# 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。
# AP 编号撞键时 IntegrityError 穿透 → 外层编号重试循环把
# 业务/方案/审批单整体重来
if payload.secondary_allocations:
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business.id,
refer_no=business.business_no,
submitter=operator,
)
customer = customer_repository.get_by_id(session, business.customer_id) customer = customer_repository.get_by_id(session, business.customer_id)
out = _to_out((business, customer.company_name if customer else None, None, None)) out = _to_out((business, customer.company_name if customer else None, None, None))
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
@@ -408,6 +507,35 @@ def update_business(
business_repository.replace_items( business_repository.replace_items(
session, business_id, _build_item_rows(business_id, payload.items) session, business_id, _build_item_rows(business_id, payload.items)
) )
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
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, old_ids=old_ids
),
)
# 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
# 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
if payload.secondary_allocations:
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
refer_no=business.business_no,
submitter=operator,
)
else:
approval_service.withdraw_pending_orders(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
operator=operator,
)
_recalc_totals(session, business) _recalc_totals(session, business)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段 # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名) # 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
@@ -430,7 +558,7 @@ def delete_business(
与业务维度的归属留痕(无外键固化表,须手动清)。 与业务维度的归属留痕(无外键固化表,须手动清)。
:raises NotFoundError: 业务不存在 :raises NotFoundError: 业务不存在
:raises BadRequestError: 存在发货或回款记录 :raises BadRequestError: 存在发货或回款记录 / 存在在途审批单
""" """
business = business_repository.get_by_id(session, business_id, for_update=True) business = business_repository.get_by_id(session, business_id, for_update=True)
with audit_service.audit( with audit_service.audit(
@@ -447,6 +575,10 @@ def delete_business(
raise NotFoundError("业务不存在") raise NotFoundError("业务不存在")
if business_repository.has_docs(session, business_id): if business_repository.has_docs(session, business_id):
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除") raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
# 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单
if _count_pending_approvals(session, business_id):
raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除")
business_no = business.business_no business_no = business.business_no
_hard_delete(session, business_id) _hard_delete(session, business_id)
@@ -454,11 +586,46 @@ def delete_business(
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no) 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: def _hard_delete(session: Session, business_id: int) -> None:
"""按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。""" """按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→
状态留痕 → 业务维度归属留痕 → 主表。"""
from sqlalchemy import delete from sqlalchemy import delete
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id)) 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(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
session.execute( session.execute(
delete(OwnershipChangeLog).where( delete(OwnershipChangeLog).where(
+719
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)
+245
View File
@@ -0,0 +1,245 @@
"""工作台服务(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
from app.repositories.query_utils import LogFilters
# 工作台只展示摘要,完整列表跳转对应管理页
_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) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。"""
resolved = _normalize_month(month)
warning_params = _warning_params(session)
return {
"month": resolved,
"overview": _overview(session, resolved),
"todos": _todos(session),
"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 _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, months)
return {
"month": month,
"performance_total": _dec(sums["performance_total"]),
"payable_total": _dec(sums["payable_total"]),
"person_count": int(sums["person_count"]),
"team_count": dashboard_repository.count_active_managers(session),
"settlement": {
"exists": settlement is not None,
"status": settlement.status if settlement else None,
"total_performance": settlement.total_performance if settlement else None,
"total_payable": settlement.total_payable if settlement else None,
"locked_at": settlement.locked_at if settlement else None,
},
"trend": [
{
"month": item,
"performance_total": _dec(performance.get(item)),
"receipt_total": _dec(receipts.get(item)),
}
for item in months
],
"note": "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月(含未核算回款);不含调整单",
}
def _todos(session: Session) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。"""
orders = dashboard_repository.list_pending_approvals(session, limit=_TODO_LIMIT)
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
return {
"pending_approval_total": dashboard_repository.count_pending_approvals(session),
"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": [],
"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
]
return {
"enabled": True,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": items,
"receipt_overdue_total": dashboard_repository.count_overdue_shipments(
session, cutoff=receipt_cutoff
),
"approval_timeout_total": dashboard_repository.count_overdue_approvals(
session, cutoff=approval_cutoff
),
"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))
+66
View File
@@ -41,6 +41,10 @@ def salesperson_rate(
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限 ≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
(对照表 #4:360 天=6‰,361 天起固定 4‰)。 (对照表 #4:360 天=6‰,361 天起固定 4‰)。
首日取整(澄清 #14 业务答复 2026-10-19):第 1 天**不递减**、直接取
起始值(需规图示「第 1 天 10‰」覆盖公式的 9.9889‰)——第 2 天起按公式
递减,360 天=6‰ 锚点与 361 天=4‰ 分界均不受影响。
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出 先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days 高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。 的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
@@ -48,6 +52,8 @@ def salesperson_rate(
days = max(days, 0) days = max(days, 0)
if days > boundary_days: if days > boundary_days:
return floor / 1000 return floor / 1000
if days <= 1:
return start / 1000
return (start - days * slope) / 1000 return (start - days * slope) / 1000
@@ -95,3 +101,63 @@ def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bo
if revenue == 0: if revenue == 0:
return False return False
return Fraction(received) / Fraction(revenue) >= ratio / 100 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
)
+118
View File
@@ -0,0 +1,118 @@
"""经理分成共享口径(月结聚合与报表部门汇总的唯一实现)。
从 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.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);聚合与月工资口径没有
更细的业务日期,取该月最后可确定的生效时点。
"""
year, mon = int(month[:4]), int(month[5:7])
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"),
)
+115 -9
View File
@@ -243,18 +243,124 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
sort_order=15, sort_order=15,
), ),
ParamSpec( ParamSpec(
code="probation.salary.basic", code="approval.secondary_allocation.enabled",
name="试用期基本工资标准", name="二次分配审批开关",
group="月工资口径", group="审批开关",
value_type="DECIMAL", value_type="BOOL",
unit="元", unit=None,
dimension_type="NONE", dimension_type="NONE",
options=None, options=None,
default_value="3000", default_value="1",
value_min="0", value_min=None,
value_max=None, value_max=None,
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)", description=(
sort_order=16, "0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
"1=提交后进入财务复核→领导审核审批流。"
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
),
sort_order=17,
),
ParamSpec(
code="approval.monthly_settlement.enabled",
name="月度结算审批开关",
group="审批开关",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="1",
value_min=None,
value_max=None,
description=(
"月度结算审批链开关(④ 月结提交时消费):"
"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=(
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
"默认值业务未明确,暂按直接生效,业务可开启)"
),
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,
), ),
] ]
} }
+2 -5
View File
@@ -614,11 +614,8 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
start_ratio = take("payment.start.ratio") start_ratio = take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, start_ratio) start_ok = formula.start_payment_ok(received, revenue, start_ratio)
# R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int() # R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
# 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线 weight_r = formula.clamp_weight_r(received, revenue)
weight_r = (
max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0
)
weight = formula.payment_weight(weight_r) weight = formula.payment_weight(weight_r)
base_amount = after_barter if barter_applied else performance base_amount = after_barter if barter_applied else performance
payable = to_decimal(Fraction(base_amount) * weight) payable = to_decimal(Fraction(base_amount) * weight)
+4 -1
View File
@@ -217,7 +217,10 @@ def update_grade(
raise NotFoundError("评级等级不存在") raise NotFoundError("评级等级不存在")
_ensure_no_overlap( _ensure_no_overlap(
rating_repository.list_all(session, for_update=True), new_min, new_max, exclude_id=grade_id rating_repository.list_all(session, for_update=True),
new_min,
new_max,
exclude_id=grade_id,
) )
existed = rating_repository.get_by_name(session, payload.grade_name) existed = rating_repository.get_by_name(session, payload.grade_name)
if existed is not None and existed.id != grade_id: if existed is not None and existed.id != grade_id:
+17
View File
@@ -23,6 +23,7 @@
""" """
import logging import logging
from datetime import date
from decimal import Decimal from decimal import Decimal
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -209,6 +210,12 @@ def create_receipt(
raise NotFoundError("业务不存在") raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE: if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新增回款") 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( shipment_no, ship_date = _ensure_shipment_of_business(
session, payload.shipment_id, payload.business_id session, payload.shipment_id, payload.business_id
) )
@@ -284,6 +291,12 @@ def update_receipt(
if receipt.settlement_status == SETTLEMENT_DONE: if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止修改") raise BadRequestError("已核算回款禁止修改")
business = _ensure_business_writable(session, receipt.business_id, "修改") 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 # 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join # 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
shipment_no, ship_date = _ensure_shipment_of_business( shipment_no, ship_date = _ensure_shipment_of_business(
@@ -335,6 +348,10 @@ def delete_receipt(
if receipt.settlement_status == SETTLEMENT_DONE: if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止删除") raise BadRequestError("已核算回款禁止删除")
business = _ensure_business_writable(session, receipt.business_id, "删除") 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 batch_no = receipt.receipt_batch_no
receipt_repository.delete(session, receipt) receipt_repository.delete(session, receipt)
+309
View File
@@ -0,0 +1,309 @@
"""报表 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 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")
BUSINESS_STATUS_LABELS = {1: "进行中", 2: "已完成", 3: "已终止"}
@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
_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,
) -> bytes:
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。"""
workbook = Workbook()
sheet = workbook.active
sheet.title = _sheet_name(code)
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,
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 即数据行起点。"""
sheet.append([company_name])
_merge_row(sheet, len(columns))
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, len(columns))
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, len(columns))
sheet.append(
[
f"导出时间:{datetime.now():%Y-%m-%d %H:%M:%S} | 导出人:{operator_name} | "
f"模板版本:{template_version}"
]
)
_merge_row(sheet, len(columns))
sheet.append([])
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 _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,
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, "全部", 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)
+698
View File
@@ -0,0 +1,698 @@
"""报表统计服务(M4 ⑤):四张报表取数编排 + Excel 导出编排。
全局口径(四表共用,改动须同步 backend/README「报表口径」小节):
1. 数据源 ``performance_ledgers`` 固定 **is_active=1 AND calc_type=1**(单笔
生效行);经理聚合行(calc_type=2)只在部门汇总作冻结对照单读——把
聚合额混进明细会让「汇总 = 明细合计」失效;
2. 金额全程 Decimal(两位收口 ROUND_HALF_UP);比例原样输出 ``rate_text``
(如 88/9000),禁止转 float 或截断展示;
3. 调整单并入:仅 status=2(生效);direction=1 补发记 +、2 扣减记 −
(金额恒正,方向表达语义);
4. 离职人员不按 ``personnel.status`` 过滤——历史业绩留在原团队(FR-030
「变更不回溯」原则);无成员团队仍出 0 值行;
5. 团队基数直接取 ``manager_share.collect_team_buckets`` 的分桶结果,与
月结聚合行同源(不是报表侧另算一遍)。
"""
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from sqlalchemy import select
from app.core import report_meta
from app.core.constants import ACTION_EXPORT, MODULE_REPORT
from app.core.exceptions import BadRequestError
from app.core.report_meta import (
REPORT_EXPORT_MAX_ROWS,
REPORT_TEMPLATE_VERSION,
DayBucket,
)
from app.models import BusinessRole, PerformanceLedger, Personnel, User
from app.models.settlement import ADJUSTMENT_DIRECTION_EXTRA
from app.repositories import report_repository
from app.services import audit_service, manager_share
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import manager_team_share
# ==== 元数据 ====
def get_payment_day_buckets() -> list[dict]:
"""回款天数区间元数据(前端下拉唯一数据源,禁止前端硬编码档位)。"""
return [
{"code": b.code, "label": b.label, "min_days": b.min_days, "max_days": b.max_days}
for b in report_meta.get_payment_day_buckets()
]
# ==== 个人绩效明细(FR-029)====
def list_personal_detail(session, query) -> dict:
"""明细分页 + 全筛选合计(summary 与分页同条件,避免页脚合计≠全部结果)。"""
rows = report_repository.list_personal_detail(
session, query, offset=query.offset, limit=query.page_size
)
role_names = _role_names(session)
sums = report_repository.sum_personal_detail(session, query)
return {
"list": [_person_row(r, role_names) for r in rows],
"total": report_repository.count_personal_detail(session, query),
"pageNum": query.page_num,
"pageSize": query.page_size,
"summary": {
"row_count": sums["row_count"],
"performance_total": _q2(_dec(sums["performance_total"])),
"payable_total": _q2(_dec(sums["payable_total"])),
"converted_profit_total": _q2(_dec(sums["converted_profit_total"])),
},
}
def _person_row(row: PerformanceLedger, role_names: dict[str, str]) -> dict:
"""台账行 → 明细行出参(金额保持 Decimal,序列化交由信封层)。"""
return {
"ledger_id": row.id,
"business_id": row.business_id,
"business_no": row.business_no,
"personnel_id": row.personnel_id,
"personnel_name": row.personnel_name,
"role_code": row.role_code,
"role_name": role_names.get(row.role_code, row.role_code),
"customer_type": row.customer_type,
"product_category": row.product_category,
"ship_date": row.ship_date,
"receipt_date": row.receipt_date,
"payment_days": row.payment_days,
"rate_text": row.rate_text,
"weight_r": row.weight_r,
"payment_weight": row.payment_weight,
"performance_amount": row.performance_amount,
"payable_amount": row.payable_amount,
"converted_profit": row.converted_profit,
"is_probation": row.is_probation,
"missing_cost": row.missing_cost,
"calc_version": row.calc_version,
"settlement_id": row.settlement_id,
}
def _role_names(session) -> dict[str, str]:
"""岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。"""
return {
r.role_code: r.role_name
for r in session.execute(select(BusinessRole)).scalars()
}
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))
def _q2(value: Decimal | None) -> Decimal | None:
"""两位小数收口(HALF_UP,与核算/月结舍入口径一致)。"""
if value is None:
return None
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# ==== 部门绩效汇总(FR-030)====
def department_summary(session, query) -> dict:
"""按经理团队/月份汇总绩效工资(团队基数与月结聚合行同源)。"""
month = query.settlement_month
rows = report_repository.list_month_receipt_rows(session, month)
share = manager_share.manager_share_params(session, month)
buckets = manager_share.collect_team_buckets(session, rows, share.include_manager_personal)
company_base = manager_share.company_base_of(rows, share.include_manager_personal)
aggregates = {
row.personnel_id: row
for row in report_repository.list_month_manager_aggregates(session, month)
}
adjustments = _adjustments_by_person(report_repository.list_month_adjustments(session, month))
member_map = _member_map(rows)
manager_ids = _manager_ids(session, buckets, query.manager_id)
# 岗位判定范围必须覆盖「无成员的经理」——只按 member_map 取会让零行团队被跳过
manager_of, manager_role = _team_relations(session, member_map, manager_ids)
teams: list[dict] = []
for manager_id in manager_ids:
manager = session.get(Personnel, manager_id)
if manager is None or manager_role.get(manager_id) != "sales_manager":
continue # 非营销经理的上级不展示团队行(与月结聚合同口径)
aggregate = aggregates.get(manager_id)
member_rows = _member_rows(
manager_id, member_map, manager_of, adjustments, share.include_manager_personal
)
team_extra = sum((m["adjust_extra"] for m in member_rows), Decimal("0"))
team_deduct = sum((m["adjust_deduct"] for m in member_rows), Decimal("0"))
team_base = _dec(buckets.get(manager_id, {}).get("performance", Decimal("0")))
# 12.5% 与 5% 分列(FR-030「全公司 5% 部分单列」);两项之和才是月结
# 聚合行的金额(frozen_amount 对照),重复计入会双倍展示
team_share = _team_share(team_base, share.team_pct)
company_share = _company_share(company_base, share.company_pct)
teams.append(
{
"manager_id": manager_id,
"manager_name": manager.personnel_name,
"manager_status": manager.status,
"member_count": len(member_rows),
"team_base": _q2(team_base),
"team_share_amount": _q2(team_share),
"company_share_amount": _q2(company_share),
"company_base_ratio": _ratio(team_base, company_base),
"adjust_extra": _q2(team_extra),
"adjust_deduct": _q2(team_deduct),
"adjust_net": _q2(team_extra - team_deduct),
"net_total": _q2(team_share + company_share + team_extra - team_deduct),
"frozen_amount": aggregate.performance_amount if aggregate else None,
"aggregate_ledger_id": aggregate.id if aggregate else None,
"members": member_rows,
}
)
unassigned = _unassigned_rows(member_map, manager_of, manager_role, adjustments)
return {
"settlement_month": month,
"params": {
"team_text": share.team_text,
"company_text": share.company_text,
"manager_personal_included": 1 if share.include_manager_personal else 0,
"params_as_of": str(share.param_date),
},
"teams": teams,
"unassigned": unassigned,
"company": {
"team_base_total": _q2(sum((t["team_base"] for t in teams), Decimal("0"))),
"company_base": _q2(company_base),
"company_share_pct": share.company_text,
"company_share_amount": _q2(_company_share(company_base, share.company_pct)),
},
"totals": {
"performance_total": _q2(sum((t["team_base"] for t in teams), Decimal("0"))),
"payable_total": _q2(sum((t["net_total"] for t in teams), Decimal("0"))),
},
}
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
return manager_team_share(
team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0)
)
def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal:
"""5% 全公司分成:团队部分传 0,仅取 company_pct 项。"""
return manager_team_share(
Decimal("0"), company_base, team_pct=Fraction(0), company_pct=company_pct
)
def _ratio(part: Decimal, whole: Decimal) -> Decimal:
"""占比(除零返回 0,不抛 ZeroDivisionError)。"""
if not whole:
return Decimal("0")
return (part / whole).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
def _adjustments_by_person(orders) -> dict[int, dict]:
"""生效调整单按人汇总(补发为正、扣减为负;金额本身恒正)。"""
by_person: dict[int, dict] = {}
for order in orders:
acc = by_person.setdefault(
order.personnel_id, {"extra": Decimal("0"), "deduct": Decimal("0")}
)
if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
acc["extra"] += order.amount
else:
acc["deduct"] += order.amount
return by_person
def _member_map(rows) -> dict[int, dict]:
"""台账行按人求和(成员明细的唯一数据源),经理个人户金额单独记账。"""
members: dict[int, dict] = {}
for row in rows:
acc = members.setdefault(
row.personnel_id,
{
"personnel_name": row.personnel_name,
"role_code": row.role_code,
"is_probation": row.is_probation,
"performance": Decimal("0"),
"payable": Decimal("0"),
"manager_personal_performance": Decimal("0"),
"manager_personal_payable": Decimal("0"),
"ledger_ids": [],
"has_manager_personal": False,
},
)
performance = row.performance_amount or Decimal("0")
payable = row.payable_amount or Decimal("0")
acc["performance"] += performance
acc["payable"] += payable
acc["ledger_ids"].append(row.id)
if row.customer_type == "manager_personal":
acc["has_manager_personal"] = True
acc["manager_personal_performance"] += performance
acc["manager_personal_payable"] += payable
return members
def _team_relations(
session, member_map: dict[int, dict], manager_ids: list[int]
) -> tuple[dict[int, int | None], dict[int, str]]:
"""人员 → 直属经理与人员 → 岗位编码(各一次查询,避免逐人 get 的 N+1)。
查询范围 = 成员 ∪ 经理枚举:无成员的经理也要有岗位判定,否则「零行团队」
会被岗位过滤静默跳过(FR-030 要求无团队成员出 0 行)。
经理判定与 ``manager_share.collect_team_buckets`` 的实时上溯同口径:
离职人员的 manager_id 保留,历史业绩因此留在原团队。
"""
pids = list(set(member_map) | set(manager_ids))
if not pids:
return {}, {}
people = list(session.execute(select(Personnel).where(Personnel.id.in_(pids))).scalars())
manager_of = {p.id: p.manager_id for p in people}
role_by_id = {
r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()
}
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
return manager_of, manager_role
def _manager_ids(session, buckets: dict, only: int | None) -> list[int]:
"""经理枚举:数据中出现的经理 ∪ 在职营销经理(无成员也要出 0 值行)。"""
if only is not None:
return [only]
found = set(buckets)
role = session.execute(
select(BusinessRole).where(BusinessRole.role_code == "sales_manager")
).scalar_one_or_none()
if role is not None:
active = session.execute(
select(Personnel.id).where(Personnel.business_role_id == role.id, Personnel.status == 1)
).scalars()
found.update(active)
return sorted(found)
def _member_rows(
manager_id: int,
member_map: dict[int, dict],
manager_of: dict[int, int | None],
adjustments: dict[int, dict],
include_manager_personal: bool,
) -> list[dict]:
"""团队内成员行:业务员按 manager_id 归属;经理个人户按开关归属本人。"""
pids = [pid for pid, mid in manager_of.items() if mid == manager_id and pid != manager_id]
# 经理本人(经理个人户行):#12 开关开启时计入本团队基数
if include_manager_personal and manager_id in member_map:
pids.append(manager_id)
return [_member_row(pid, member_map[pid], adjustments) for pid in pids]
def _member_row(pid: int, member: dict, adjustments: dict[int, dict]) -> dict:
"""单条成员行(绩效/应付求和 + 调整净额 + 台账 id 列表)。"""
adj = adjustments.get(pid, {"extra": Decimal("0"), "deduct": Decimal("0")})
net = member["payable"] + adj["extra"] - adj["deduct"]
return {
"personnel_id": pid,
"personnel_name": member["personnel_name"],
"role_code": member["role_code"],
"is_probation": member["is_probation"],
"performance_sum": _q2(member["performance"]),
"payable_sum": _q2(member["payable"]),
"adjust_extra": _q2(adj["extra"]),
"adjust_deduct": _q2(adj["deduct"]),
"adjust_net": _q2(adj["extra"] - adj["deduct"]),
"net_total": _q2(net),
"ledger_ids": member["ledger_ids"],
}
def _unassigned_rows(
member_map: dict[int, dict],
manager_of: dict[int, int | None],
manager_role: dict[int, str],
adjustments: dict[int, dict],
) -> list[dict]:
"""未进入团队基数的成员:无经理业务员 + 非营销经理上级的下属。
经理个人户业绩默认不计入任何基数(#12 开关关闭时),也不在此展示——
其金额仍可在个人明细报表中查询,避免与「团队基数 + 未分配」口径混淆。
"""
rows = []
for pid, member in member_map.items():
mid = manager_of.get(pid)
if mid is not None and manager_role.get(mid) == "sales_manager":
continue
regular = member["performance"] - member["manager_personal_performance"]
if regular == 0 and member["manager_personal_performance"] != 0:
continue
row = _member_row(pid, member, adjustments)
row["performance_sum"] = _q2(regular)
row["payable_sum"] = _q2(member["payable"] - member["manager_personal_payable"])
rows.append(row)
return rows
# ==== 业务统计(FR-031 之一)====
def business_stats(session, query) -> dict:
"""业务统计分页 + 全筛选合计;回款/绩效为业务累计值。"""
rows = report_repository.list_business_stats(
session, query, offset=query.offset, limit=query.page_size
)
sums = report_repository.sum_business_stats(session, query)
return {
"list": [_business_row(r) for r in rows],
"total": report_repository.count_business_stats(session, query),
"pageNum": query.page_num,
"pageSize": query.page_size,
"summary": {
"row_count": sums["row_count"],
"ship_total": _q2(_dec(sums["ship_total"])),
"received_total": _q2(_dec(sums["received_total"])),
"sale_total": _q2(_dec(sums["sale_total"])),
"performance_total": _q2(_dec(sums["performance_total"])),
"payable_total": _q2(_dec(sums["payable_total"])),
},
}
def _business_row(row) -> dict:
"""业务统计行(ORM 行与三个联查列)。"""
business = row[0]
return {
"business_id": business.id,
"business_no": business.business_no,
"business_date": business.business_date,
"status": business.status,
"customer_name": row.customer_name,
"owner_personnel_name": row.owner_personnel_name,
"total_sale_amount": business.total_sale_amount,
"total_ship_amount": business.total_ship_amount,
"total_received_amount": business.total_received_amount,
"performance_amount": row.performance_amount,
"payable_amount": row.payable_amount,
}
# ==== 回款统计(FR-031 之二)====
def payment_stats(session, query) -> dict:
"""区间/客户类型/产品类别三维度统计;空桶恒在且为 0。"""
perf_rows = report_repository.group_payment_by_days(session, query)
receipt_rows = report_repository.group_payment_receipt_by_days(session, query)
buckets = _bucket_rows(perf_rows, receipt_rows)
by_customer = _group_rows(report_repository.group_payment_by_customer_type(session, query))
by_category = _group_rows(report_repository.group_payment_by_product_category(session, query))
return {
"settlement_month": query.settlement_month,
"buckets": buckets,
"by_customer_type": by_customer,
"by_product_category": by_category,
"totals": {
"receipt_amount": _q2(
sum((b["receipt_amount"] or Decimal("0") for b in buckets), Decimal("0"))
),
"receipt_count": sum(b["receipt_count"] for b in buckets),
"performance_amount": _q2(
sum((b["performance_amount"] or Decimal("0") for b in buckets), Decimal("0"))
),
"payable_amount": _q2(
sum((b["payable_amount"] or Decimal("0") for b in buckets), Decimal("0"))
),
},
}
def _bucket_rows(perf_rows, receipt_rows) -> list[dict]:
"""把(天数, 类型, 类别)精确值聚合结果按 8 档区间分桶(Python 侧分桶单点)。"""
perf_acc: dict[str, dict] = {}
for row in perf_rows:
bucket = report_meta.bucket_of(row.payment_days)
acc = perf_acc.setdefault(
bucket.code, {"performance": Decimal("0"), "payable": Decimal("0"), "count": 0}
)
acc["performance"] += _dec(row.performance_total)
acc["payable"] += _dec(row.payable_total)
acc["count"] += int(row.ledger_count)
receipt_acc: dict[str, dict] = {}
for row in receipt_rows:
bucket = report_meta.bucket_of(row.payment_days)
acc = receipt_acc.setdefault(bucket.code, {"amount": Decimal("0"), "count": 0})
acc["amount"] += _dec(row.receipt_amount)
acc["count"] += int(row.receipt_count)
zero = Decimal("0")
return [
{
"code": bucket.code,
"label": bucket.label,
"min_days": bucket.min_days,
"max_days": bucket.max_days,
# 空桶显示 0 而非 null(FR-031「空区间正常显示」)
"receipt_amount": _q2(receipt_acc.get(bucket.code, {}).get("amount", zero)),
"receipt_count": receipt_acc.get(bucket.code, {}).get("count", 0),
"performance_amount": _q2(perf_acc.get(bucket.code, {}).get("performance", zero)),
"payable_amount": _q2(perf_acc.get(bucket.code, {}).get("payable", zero)),
"ledger_count": perf_acc.get(bucket.code, {}).get("count", 0),
}
for bucket in report_meta.get_payment_day_buckets()
]
def _group_rows(rows) -> list[dict]:
"""维度对比行(客户类型/产品类别),标签来自 report_meta。
仓储返回元组 (dim, 回款额, 绩效, 应付, 台账行数),按位置解包。
"""
labels = report_meta.CUSTOMER_TYPE_LABELS | report_meta.PRODUCT_CATEGORY_LABELS
result = []
for dim, receipt_amount, performance_amount, payable_amount, ledger_count in rows:
result.append(
{
"code": dim,
"label": labels.get(dim, dim or "未分类"),
"receipt_amount": _q2(_dec(receipt_amount)),
"performance_amount": _q2(_dec(performance_amount)),
"payable_amount": _q2(_dec(payable_amount)),
"ledger_count": int(ledger_count),
}
)
return result
def payment_detail(session, query) -> dict:
"""区间下钻分页(bucket 未注册 → 400)。"""
bucket = _bucket_by_code(query.bucket)
rows = report_repository.list_payment_detail(
session, query, bucket, offset=query.offset, limit=query.page_size
)
return {
"list": [_payment_row(r) for r in rows],
"total": report_repository.count_payment_detail(session, query, bucket),
"pageNum": query.page_num,
"pageSize": query.page_size,
"summary": {"row_count": len(rows)},
}
def _payment_row(row) -> dict:
"""下钻行(台账行 + 回款金额/批次号)。"""
ledger = row[0]
return {
"ledger_id": ledger.id,
"receipt_id": ledger.receipt_id,
"receipt_batch_no": row.receipt_batch_no,
"amount": row.receipt_amount,
"business_id": ledger.business_id,
"business_no": ledger.business_no,
"personnel_id": ledger.personnel_id,
"personnel_name": ledger.personnel_name,
"customer_type": ledger.customer_type,
"product_category": ledger.product_category,
"ship_date": ledger.ship_date,
"receipt_date": ledger.receipt_date,
"payment_days": ledger.payment_days,
"performance_amount": ledger.performance_amount,
"payable_amount": ledger.payable_amount,
}
def _bucket_by_code(code: str) -> DayBucket:
"""按编码取区间桶;未注册编码返回 400(防前端传参与后端档位漂移)。"""
for bucket in report_meta.get_payment_day_buckets():
if bucket.code == code:
return bucket
raise BadRequestError(f"未知的回款天数区间:{code}")
# ==== 导出编排(FR-032)====
EXPORT_FILENAMES = {
"personal-detail": "personal_detail",
"department-summary": "department_summary",
"business-stats": "business_stats",
"payment-stats": "payment_stats",
}
def export_report(session, code: str, query, *, operator: User, meta: RequestMeta | None = None):
"""导出 Excel:上限校验 → 取数 → 构建 → 审计留痕(独立 commit)。
返回 (bytes, 文件名);文件名由 API 层拼日期(ASCII,响应头 latin-1)。
"""
from app.services import report_export
rows, context = _export_payload(session, code, query)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_REPORT,
action=ACTION_EXPORT,
summary=f"导出{context['title']}({context['row_count']} 行)",
request_data=query.model_dump(exclude_none=True),
),
meta=meta,
):
content = report_export.build_workbook(
code=code,
title=context["title"],
filters=context["filters"],
rows=rows,
summary=context.get("summary"),
buckets=context.get("buckets"),
template_version=REPORT_TEMPLATE_VERSION,
operator_name=getattr(operator, "username", ""),
company_name=context["company_name"],
)
return content, EXPORT_FILENAMES[code]
def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
"""按报表编码取全量数据并做上限校验(复用查询函数,不走分页)。"""
if code == "personal-detail":
total = report_repository.count_personal_detail(session, query)
_check_export_limit(total)
rows = report_repository.list_personal_detail(
session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS
)
role_names = _role_names(session)
sums = report_repository.sum_personal_detail(session, query)
return [_person_row(r, role_names) for r in rows], {
"title": "个人绩效工资明细表",
"row_count": total,
"filters": _filter_text(query),
"summary": {
"performance_total": _q2(_dec(sums["performance_total"])),
"payable_total": _q2(_dec(sums["payable_total"])),
},
"company_name": _company_name(session),
}
if code == "department-summary":
data = department_summary(session, query)
member_total = sum(len(t["members"]) for t in data["teams"])
_check_export_limit(member_total)
rows = _department_export_rows(data)
return rows, {
"title": "部门绩效工资汇总表",
"row_count": member_total,
"filters": f"统计月份 {query.settlement_month}",
"summary": data["company"],
"company_name": _company_name(session),
}
if code == "business-stats":
total = report_repository.count_business_stats(session, query)
_check_export_limit(total)
rows = report_repository.list_business_stats(
session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS
)
sums = report_repository.sum_business_stats(session, query)
return [_business_row(r) for r in rows], {
"title": "业务统计表",
"row_count": total,
"filters": _filter_text(query),
"summary": {
"ship_total": _q2(_dec(sums["ship_total"])),
"received_total": _q2(_dec(sums["received_total"])),
"performance_total": _q2(_dec(sums["performance_total"])),
},
"company_name": _company_name(session),
}
if code == "payment-stats":
data = payment_stats(session, query)
rows = data["buckets"]
return rows, {
"title": "回款情况统计表",
"row_count": len(rows),
"filters": _filter_text(query),
"summary": data["totals"],
"buckets": data,
"company_name": _company_name(session),
}
raise BadRequestError(f"未知的报表编码:{code}")
def _check_export_limit(row_count: int) -> None:
"""导出上限(需求「超限提示」口径):超过即 400 引导缩小筛选。"""
if row_count > REPORT_EXPORT_MAX_ROWS:
raise BadRequestError(
f"导出数据量 {row_count} 行超过上限 {REPORT_EXPORT_MAX_ROWS} 行,"
"请缩小筛选范围(如按月导出)"
)
def _department_export_rows(data: dict) -> list[dict]:
"""部门汇总导出展平:每个成员一行,团队列冗余(便于签字稿核对)。"""
rows = []
for team in data["teams"]:
for member in team["members"]:
rows.append(
{
"manager_name": team["manager_name"],
"member_name": member["personnel_name"],
"role_code": member["role_code"],
"performance_sum": member["performance_sum"],
"payable_sum": member["payable_sum"],
"adjust_net": member["adjust_net"],
"net_total": member["net_total"],
}
)
return rows
def _filter_text(query) -> str:
"""筛选条件摘要(导出页眉展示;未设置项不展示)。"""
parts = []
for field, label in (
("settlement_month", "统计月份"),
("customer_type", "客户类型"),
("product_category", "产品类别"),
("role_code", "岗位"),
("keyword", "关键字"),
("date_from", "日期起"),
("date_to", "日期止"),
):
value = getattr(query, field, None)
if value:
parts.append(f"{label} {value}")
return " | ".join(parts) or "全部"
def _company_name(session) -> str:
"""导出页眉公司名(未配置时 system_setting_service 返回默认「衍水集团」)。"""
from app.services import system_setting_service
return system_setting_service.get_setting(session).company_name
+18 -15
View File
@@ -1,17 +1,20 @@
"""角色管理业务编排。 """角色管理业务编排。
需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善** 需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求
(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置, 新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批
待业务方给出权限清单后再启用)。 节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` /
`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验): 保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖 - **内置角色禁删禁停**(`BUILTIN_ROLE_CODES`:admin / marketing_ops /
该编码存在,删除会使系统无法自举 finance / leader)—— `admin` 被删除会使系统无法自举(cli 建管理员与
`require_admin` 判定依赖);财务/领导是审批节点准入锚点,被删会让
在途审批流失去可操作人
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默 - **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE` 剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
锁行,堵住「校验与删除之间并发绑定」的窗口 锁行,堵住「校验与删除之间并发绑定」的窗口
- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点 - `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态), - 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
但**不可再分配给用户**(`user_service._resolve_roles` 拦截) 但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
""" """
@@ -27,8 +30,8 @@ from app.core.constants import (
ACTION_DISABLE, ACTION_DISABLE,
ACTION_ENABLE, ACTION_ENABLE,
ACTION_UPDATE, ACTION_UPDATE,
BUILTIN_ROLE_CODES,
MODULE_ROLE, MODULE_ROLE,
ROLE_ADMIN,
) )
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult from app.core.response import PageResult
@@ -177,10 +180,10 @@ def set_status(
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态), 停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截); 但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
内置管理员角色不可停用。 内置角色不可停用。
:raises NotFoundError: 角色不存在 :raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图停用内置管理员角色 :raises ForbiddenError: 试图停用内置角色
""" """
role = role_repository.get_by_id(session, role_id) role = role_repository.get_by_id(session, role_id)
verb = "启用" if status == 1 else "禁用" verb = "启用" if status == 1 else "禁用"
@@ -198,8 +201,8 @@ def set_status(
if role is None: if role is None:
raise NotFoundError("角色不存在") raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN and status == 0: if role.role_code in BUILTIN_ROLE_CODES and status == 0:
raise ForbiddenError("内置管理员角色不可停用") raise ForbiddenError("内置角色不可停用")
role.status = status role.status = status
out = _to_out(role, _user_count(session, role_id)) out = _to_out(role, _user_count(session, role_id))
@@ -217,12 +220,12 @@ def delete_role(
) -> None: ) -> None:
"""删除角色。 """删除角色。
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。 内置角色不可删除(admin 与三个审批职能角色,见 BUILTIN_ROLE_CODES);仍有关联用户的角色不可删除(提示先解除绑定)。
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把 查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。 新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
:raises NotFoundError: 角色不存在 :raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图删除内置管理员角色 :raises ForbiddenError: 试图删除内置角色
:raises BadRequestError: 角色下仍有绑定的用户 :raises BadRequestError: 角色下仍有绑定的用户
""" """
role = role_repository.get_by_id(session, role_id, for_update=True) role = role_repository.get_by_id(session, role_id, for_update=True)
@@ -239,8 +242,8 @@ def delete_role(
if role is None: if role is None:
raise NotFoundError("角色不存在") raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN: if role.role_code in BUILTIN_ROLE_CODES:
raise ForbiddenError("内置管理员角色不可删除") raise ForbiddenError("内置角色不可删除")
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限 # user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
user_count = _user_count(session, role_id) user_count = _user_count(session, role_id)
+622
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
]
+15
View File
@@ -237,6 +237,12 @@ def create_shipment(
raise NotFoundError("业务不存在") raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE: if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新发货") raise BadRequestError("业务已完成或已终止,禁止新发货")
# 月结锁定拦截:发货日所在月已审定 → 源数据不可改(调整走调整单)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(
session, payload.ship_date.strftime("%Y-%m")
)
shipment = Shipment( shipment = Shipment(
shipment_no=shipment_no, shipment_no=shipment_no,
@@ -309,6 +315,11 @@ def update_shipment(
if shipment is None: if shipment is None:
raise NotFoundError("发货单不存在") raise NotFoundError("发货单不存在")
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单") 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.ship_date = payload.ship_date
shipment.freight_amount = _amount_or_zero(payload.freight_amount) shipment.freight_amount = _amount_or_zero(payload.freight_amount)
@@ -357,6 +368,10 @@ def delete_shipment(
if shipment is None: if shipment is None:
raise NotFoundError("发货单不存在") raise NotFoundError("发货单不存在")
business = _ensure_business_writable(session, shipment.business_id, "删除发货单") 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): if shipment_repository.count_receipt_refs(session, shipment_id):
raise BadRequestError("已被回款批次指定,请先解除回款的批次指定") raise BadRequestError("已被回款批次指定,请先解除回款的批次指定")
@@ -0,0 +1,171 @@
"""系统设置(公司信息)业务编排。
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径**
(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次
读取与备份放大传输,且无法独立替换;DB 只持有一行指针。
logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
覆盖任意文件。
"""
import logging
from pathlib import Path
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.constants import ACTION_UPDATE, MODULE_SYSTEM_SETTINGS
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import SystemSetting, User
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
# magic bytes:PNG 固定 8 字节签名;JPEG 以 SOI + APP0/JFIF 起始
_MAGIC = {
"png": b"\x89PNG\r\n\x1a\n",
"jpg": b"\xff\xd8\xff",
}
LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用
SETTINGS_ID = 1 # 全系统单行主键
def get_setting(session: Session) -> SystemSetting:
"""取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。"""
setting = session.get(SystemSetting, SETTINGS_ID)
if setting is None:
setting = SystemSetting(id=SETTINGS_ID, company_name="衍水集团")
session.add(setting)
session.flush()
return setting
def to_out(setting: SystemSetting) -> SettingOut:
"""管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。"""
return SettingOut(
company_name=setting.company_name,
logo_url=_logo_url(setting),
)
def to_public_out(setting: SystemSetting) -> SettingPublicOut:
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
return SettingPublicOut(
company_name=setting.company_name,
logo_url=_logo_url(setting),
)
def _logo_url(setting: SystemSetting) -> str | None:
if not setting.logo_path:
return None
version = int(setting.update_time.timestamp()) if setting.update_time else 0
return f"/api/v1/system/settings/logo/public?v={version}"
def update_company(
session: Session,
payload: SettingUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""更新公司名称(审计留痕)。"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE,
summary=f"修改公司名称为「{payload.company_name}」",
request_data=payload.model_dump(),
),
meta=meta,
):
setting = get_setting(session)
setting.company_name = payload.company_name.strip()
session.commit()
return to_out(setting)
def save_logo(
session: Session,
content: bytes,
ext: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
"""
ext = ext.lower()
if ext not in _MAGIC:
raise BadRequestError("logo 仅支持 png/jpg 格式")
if len(content) > LOGO_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
if not content.startswith(_MAGIC[ext]):
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE,
summary="更新公司 logo",
request_data={"ext": ext, "size": len(content)},
),
meta=meta,
):
setting = get_setting(session)
old_path = setting.logo_path
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
# 由写入覆盖,无需也不会误删新文件
target = _logo_dir() / f"logo.{ext}"
target.write_bytes(content)
setting.logo_path = f"logo/logo.{ext}"
session.commit()
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext:
try:
_cleanup_old_logo(old_ext)
except OSError:
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True)
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
return to_out(setting)
def read_logo(session: Session) -> tuple[Path, str]:
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
setting = get_setting(session)
if not setting.logo_path:
raise NotFoundError("尚未上传 logo")
path = get_settings().upload_dir / setting.logo_path
if not path.is_file():
raise NotFoundError("logo 文件不存在,请重新上传")
return path, path.suffix.lstrip(".")
def _logo_dir() -> Path:
directory = get_settings().upload_dir / "logo"
directory.mkdir(parents=True, exist_ok=True)
return directory
def _cleanup_old_logo(old_ext: str) -> None:
if old_ext in _MAGIC:
old_file = _logo_dir() / f"logo.{old_ext}"
if old_file.exists():
old_file.unlink(missing_ok=True)
+1
View File
@@ -35,6 +35,7 @@ dependencies = [
"captcha>=0.6", "captcha>=0.6",
"pillow>=11.0", "pillow>=11.0",
"openpyxl>=3.1.5", "openpyxl>=3.1.5",
"python-multipart>=0.0.32",
] ]
[dependency-groups] [dependency-groups]
+83 -3
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)。 运行:`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、仅有调整单人员并入团队/未分配、经理个人户开关两侧口径、按经理筛选不误判其他团队下属、对账恒等式**;导出页眉 ID→名称/中文标签、回款维度 sheet 透传筛选、**导出超限与取数异常的失败审计**、非法月份查询/导出均 400;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**;业务统计排除经理聚合行;普通用户 403 | 24 |
| `tests/unit/test_report_helpers.py` | 区间分桶边界与 8 档编码唯一;标签覆盖全部枚举;Excel 模板页眉/签字栏/空数据/金额格式/合计/公式注入/维度 sheet/状态中文;**部门汇总签字稿五块顺序、零成员团队与未分配行、全公司分成只出现一次、应发合计取对账口径、比例标题参数化** | 16 |
| `tests/unit/test_month_validation.py` | 非法月份(`2026-13`/`2026-00`/`0000-01` 等)在报表、月结、调整单 schema 入口即拒绝;可选月份空串规范为 None | 17 |
运行:`uv run pytest tests/integration/test_reports.py -q` → **24 passed**(2026-10-19 修复回归);`uv run pytest tests/unit/test_report_helpers.py tests/unit/test_month_validation.py -q` → **33 passed**;全量 **847 passed**(2026-10-19)。
**e2e**:`frontend/e2e/report_flow.py` 已补回款多桶同时展开、部门下钻筛选生效、打印截断提示、部门汇总打印五块结构;需 `E2E_ADMIN_PASSWORD` 与前后端在跑。
### 工作台(M4 ⑧,2026-10-19 完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志 | 5 |
| `tests/unit/test_param_registry.py`(改) | 注册表 19→**22 项**(V10 预警规则三项);编码集合与迁移种子一致 | 改 |
运行:`uv run pytest tests/integration/test_dashboard.py -q` → **5 passed**(2026-10-19)。
**实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。
## 三、运行记录 ## 三、运行记录
| 日期 | 范围 | 用例数 | 结果 | 备注 | | 日期 | 范围 | 用例数 | 结果 | 备注 |
@@ -127,11 +173,28 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 | | 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-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-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 | 单元全量 + 报表定点 | 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-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 | | 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 | `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")` | | 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
@@ -161,6 +224,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 | 先款后货(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 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 | | 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 +247,8 @@ uv run pytest tests/integration -q # 仅集成
| --- | --- | --- | | --- | --- | --- |
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 | | 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 | | 业务数据管理 | ✅ 后端已完成 | 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 例。**待补**:e2e `calc_settlement_flow`(台账/月结两页真实浏览器链路);业务答复回填批次(88 会话:首日 10‰/聚合基数开关/二次分摊拆行)落地后按同粒度补测 |
| 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;e2e 用例已写好,需完整环境用 `E2E_ADMIN_PASSWORD` 实跑 |
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。 > 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
+42
View File
@@ -21,6 +21,7 @@
import uuid import uuid
from decimal import Decimal from decimal import Decimal
from pathlib import Path
import pytest import pytest
from sqlalchemy import delete, select, update from sqlalchemy import delete, select, update
@@ -29,22 +30,30 @@ from app.core.constants import ROLE_ADMIN, ROLE_USER
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.core.security import create_access_token, hash_password from app.core.security import create_access_token, hash_password
from app.models import ( from app.models import (
AdjustmentOrder,
ApprovalOrder,
Business, Business,
BusinessItem, BusinessItem,
BusinessRole, BusinessRole,
BusinessStatusLog, BusinessStatusLog,
Customer, Customer,
CustomerRatingGrade, CustomerRatingGrade,
MonthlySettlement,
OperationLog, OperationLog,
OwnershipChangeLog, OwnershipChangeLog,
ParamChangeBatch,
ParamValue,
PerformanceLedger,
Personnel, Personnel,
PersonnelPlan, PersonnelPlan,
PersonnelRegion, PersonnelRegion,
Product, Product,
Receipt, Receipt,
Role, Role,
SettlementItem,
Shipment, Shipment,
ShipmentItem, ShipmentItem,
SystemSetting,
User, User,
UserRole, UserRole,
) )
@@ -92,6 +101,14 @@ def _purge_test_data() -> None:
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids))) session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
session.execute(delete(Role).where(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):业务/发货/回款三类单据 ---- # ---- M2 业务数据(T11):业务/发货/回款三类单据 ----
# 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前 # 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前
# 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货 # 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货
@@ -168,7 +185,32 @@ def _purge_test_data() -> None:
session.execute(delete(Product).where(Product.product_name.like("t\\_%"))) session.execute(delete(Product).where(Product.product_name.like("t\\_%")))
# 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删 # 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删
session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%"))) session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%")))
# ---- M3:审批单 / 二次分配 / 参数开关 / 系统设置基线 ----
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
# 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数
# id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars())
if stray_values:
stray_batches = {v.batch_id for v in stray_values}
session.execute(delete(ParamValue).where(ParamValue.id > 28))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
session.execute(
update(SystemSetting)
.where(SystemSetting.id == 1)
.values(company_name="衍水集团", logo_path=None)
)
session.commit() session.commit()
logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo"
if logo_dir.is_dir():
for f in logo_dir.iterdir():
f.unlink(missing_ok=True)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -0,0 +1,390 @@
"""审批流集成测试(二次分配方案为首个接入对象)。
守的是(计划 T4 验收矩阵):
- 开关关闭(种子默认 0):提单即「自动通过」——status=3 且两级节点
action=3 留痕(留痕不断链,关闭=直接生效的需求口径)
- 开关开启:status=1 → 财务通过 → status=2 → 领导通过 → status=3
- 驳回:整体驳回 status=4,意见留痕;驳回必填意见(空驳 400)
- 节点准入:status=1 仅财务、status=2 仅领导;**admin 不代办**(403);
普通用户/营销后勤进不了模块(403)
- 再编辑撤旧建新:在途单 status=5 + node=0/action=4 撤回留痕,新单新建
- 方案为空不提单
- 开关读一次:同一提交路径内参数变化不影响判定(种子级默认行为锁定)
角色用户经 make_user 造(finance/leader/marketing_ops 三新角色种子在库)。
开关联动走参数变更批次(PUT /system/params 端到端,不直改库)。
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from tests.integration.factories import business_payload, customer
pytestmark = pytest.mark.integration
_BUSINESSES = "/api/v1/business/businesses"
_APPROVALS = "/api/v1/system/approvals"
_PARAMS = "/api/v1/system/params"
_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": _any_role_id(),
"salary_standard": "3000.00",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _any_role_id() -> 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:
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
BusinessRole.status == 1,
)
).scalar_one()
)
def _create_with_plan(client: TestClient, headers: dict, personnel_id: int) -> dict:
cust = customer(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
secondary_allocations=[{"personnel_id": personnel_id, "ratio": "50.00"}],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _latest_order(client: TestClient, headers: dict, refer_no: str) -> dict:
resp = client.get(_APPROVALS, params={"keyword": refer_no, "pageSize": 1}, headers=headers)
assert resp.status_code == 200, resp.text
rows = resp.json()["data"]["list"]
assert rows, f"未找到审批单:{refer_no}"
detail = client.get(f"{_APPROVALS}/{rows[0]['id']}", headers=headers)
return detail.json()["data"]
def _set_switch(client: TestClient, admin_headers: dict, value: str) -> None:
"""经参数变更批次把二次分配审批开关置值(立即生效)。"""
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"remark": f"测试切换审批开关={value}",
"items": [
{
"param_code": "approval.secondary_allocation.enabled",
"dimension_value": "",
"value": value,
}
],
},
headers=admin_headers,
)
assert resp.status_code == 200, resp.text
@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
@pytest.fixture
def switched_on(client: TestClient, admin: dict) -> None:
"""把二次分配审批开关置 1(立即生效;用例结束由 conftest 恢复种子基线)。"""
_set_switch(client, admin["headers"], "1")
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"])
order = _latest_order(client, finance["headers"], data["business_no"])
assert order["status"] == 3
assert order["order_no"].startswith("AP")
auto_steps = [s for s in order["steps"] if s["action"] == 3]
assert len(auto_steps) == 2
assert {s["node"] for s in auto_steps} == {1, 2}
assert all(s["operator_name"] == "系统" for s in auto_steps)
def test_方案为空不提单(self, client: TestClient, admin: dict, finance: dict) -> None:
cust = customer(client, admin["headers"])
resp = client.post(_BUSINESSES, json=business_payload(cust["id"]), headers=admin["headers"])
assert resp.status_code == 200
rows = client.get(
_APPROVALS,
params={"keyword": resp.json()["data"]["business_no"]},
headers=finance["headers"],
).json()["data"]["list"]
assert rows == []
class TestSwitchOn:
"""开关开启:两节点人工流转。"""
@pytest.fixture
def switched_on(self, client: TestClient, admin: dict) -> None:
_set_switch(client, admin["headers"], "1")
def test_两节点流转至通过(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
assert order["status"] == 1
# 财务通过 → 待领导
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 1, "opinion": "同意"},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["status"] == 2
# 领导通过 → 已通过
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 1},
headers=leader["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["status"] == 3
manual = [s for s in resp.json()["data"]["steps"] if s["action"] == 1]
assert {s["node"] for s in manual} == {1, 2}
def test_财务驳回_整体驳回且意见留痕(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
_ = leader
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, resp.text
out = resp.json()["data"]
assert out["status"] == 4
rejected = [s for s in out["steps"] if s["action"] == 2]
assert rejected and rejected[0]["opinion"] == "比例分配不合理"
def test_驳回缺意见被拒(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
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},
headers=finance["headers"],
)
assert resp.status_code == 400
def test_节点准入_领导不能审财务节点_admin不代办(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, leader["headers"], data["business_no"])
# status=1 是财务节点:领导不行,admin 也不代办
for headers in (leader["headers"], admin["headers"]):
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 1},
headers=headers,
)
assert resp.status_code == 403
def test_重复操作已通过单被拒(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
)
client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=leader["headers"]
)
resp = client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
)
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
) -> 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=[{"personnel_id": p["id"], "ratio": "80.00"}],
)
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 5 in statuses and 1 in statuses, f"未见撤回+新建:{statuses}"
old_detail = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()
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)。"""
def test_普通用户403(self, client: TestClient, normal: dict) -> None:
assert client.get(_APPROVALS, headers=normal["headers"]).status_code == 403
def test_营销后勤403_暂不可见(self, client: TestClient, make_user, auth_headers) -> None:
ops = make_user(["marketing_ops"])
ops["headers"] = auth_headers(ops)
assert client.get(_APPROVALS, headers=ops["headers"]).status_code == 403
def test_财务与领导可见(self, client: TestClient, make_user, auth_headers) -> None:
for code in ("finance", "leader"):
user = make_user([code])
user["headers"] = auth_headers(user)
assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200
@@ -412,6 +412,71 @@ class TestEdit:
assert len(detail["items"]) == 1 assert len(detail["items"]) == 1
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100") 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: class TestStatus:
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。""" """状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
+790
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"]
+232
View File
@@ -0,0 +1,232 @@
"""工作台集成测试(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 select, 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])
@@ -86,9 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
class TestCatalog: class TestCatalog:
"""参数目录:种子完整性与时点化回溯。""" """参数目录:种子完整性与时点化回溯。"""
def test_目录共16项且覆盖七个分组(self, client: TestClient, admin: dict) -> None: def test_目录共22项且覆盖九个分组(self, client: TestClient, admin: dict) -> None:
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"] items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
assert len(items) == 16 assert len(items) == 22
groups = {item["param_group"] for item in items} groups = {item["param_group"] for item in items}
assert groups == { assert groups == {
"业务员个人户", "业务员个人户",
@@ -98,6 +99,8 @@ class TestCatalog:
"支付环节", "支付环节",
"产品阈值", "产品阈值",
"月工资口径", "月工资口径",
"审批开关",
"预警规则",
} }
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None: def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
@@ -435,12 +438,23 @@ class TestPreview:
class TestPermission: 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]) user = make_user([ROLE_USER])
headers = auth_headers(user) headers = auth_headers(user)
assert client.get(_PARAMS, headers=headers).status_code == 403
resp = client.post( resp = client.post(
f"{_PARAMS}/changes", f"{_PARAMS}/changes",
json={ json={
@@ -450,3 +464,8 @@ class TestPermission:
headers=headers, headers=headers,
) )
assert resp.status_code == 403 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
+308
View File
@@ -0,0 +1,308 @@
"""报表统计集成测试(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"] == "无权访问报表统计"
@@ -61,7 +61,10 @@ def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]:
for table, columns in _parse_tables(sql).items(): for table, columns in _parse_tables(sql).items():
merged.setdefault(table, set()).update(columns) merged.setdefault(table, set()).update(columns)
for table, column in _ALTER_ADD.findall(sql): 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): for table, column in _ALTER_DROP.findall(sql):
merged.setdefault(table, set()).discard(column) merged.setdefault(table, set()).discard(column)
return merged return merged
@@ -129,7 +132,7 @@ class TestSchemaSync:
class TestSeedData: class TestSeedData:
"""字典数据与版本标记。""" """字典数据与版本标记。"""
@pytest.mark.parametrize("role_code", ["admin", "user"]) @pytest.mark.parametrize("role_code", ["admin", "user", "marketing_ops", "finance", "leader"])
def test_角色初始化数据在两处都存在(self, role_code: str) -> None: def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
migration_sql = "\n".join( migration_sql = "\n".join(
path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql")) path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql"))
@@ -0,0 +1,270 @@
"""二次分配方案录入(业务创建/编辑同事务)集成测试。
守的是(计划 T3 验收):
- 创建带方案 → 详情回显含 personnel_name 冗余与比例
- 编辑全量替换(空数组=清空)
- 离职/不存在人员 → 400「分摊参与人员不存在或已离职」
- 终态业务禁改方案(与编辑终态门槛同一口径)
- 合计不强制 100%:hint 展示合计与余额(余额归属待澄清 #2)
- 删除保护:被方案引用的人员 RESTRICT 禁删(400 而非静默剥离)
- 人员删除后方案回显仍有名(personnel_name 固化的意义)
人员造数走 /api/v1/base/personnel(种子营销员岗位);离职用状态端点。
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from tests.integration.factories import business_payload, customer
pytestmark = pytest.mark.integration
_BUSINESSES = "/api/v1/business/businesses"
_PERSONNEL = "/api/v1/base/personnel"
def _person(client: TestClient, headers: dict, **overrides: object) -> dict:
role_id = overrides.pop("business_role_id", None)
body = {
"personnel_name": f"t_分摊_{uuid.uuid4().hex[:8]}",
"business_role_id": role_id or _any_role_id(),
"salary_standard": "3000.00",
**overrides,
}
resp = client.post(_PERSONNEL, json=body, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _any_role_id() -> int:
"""种子营销员岗位 ID(内置启用)。"""
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:
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
BusinessRole.status == 1,
)
).scalar_one()
)
def _create_with_allocations(client: TestClient, headers: dict, allocations: list[dict]) -> dict:
cust = customer(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(cust["id"], secondary_allocations=allocations),
headers=headers,
)
assert resp.status_code == 200, resp.text
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
return resp.json()["data"]
class TestCreateAndEcho:
"""创建带方案与详情回显。"""
def test_带方案创建_详情回显姓名与比例(self, client: TestClient, admin: dict) -> None:
p1 = _person(client, admin["headers"])
p2 = _person(client, admin["headers"])
data = _create_with_allocations(
client,
admin["headers"],
[
{"personnel_id": p1["id"], "ratio": "30.00"},
{"personnel_id": p2["id"], "ratio": "20.00"},
],
)
detail = _detail(client, admin["headers"], data["id"])
assert len(detail["secondary_allocations"]) == 2
names = {a["personnel_name"] for a in detail["secondary_allocations"]}
assert p1["personnel_name"] in names and p2["personnel_name"] in names
ratios = sorted(a["ratio"] for a in detail["secondary_allocations"])
assert ratios == ["20.00", "30.00"]
# 合计 50%,余额 50% —— 只提示不落库(待澄清 #2)
assert "50.00" in (detail["allocation_hint"] or "")
assert "余额" in (detail["allocation_hint"] or "")
def test_不建方案_详情为空且无hint(self, client: TestClient, admin: dict) -> None:
data = _create_with_allocations(client, admin["headers"], [])
detail = _detail(client, admin["headers"], data["id"])
assert detail["secondary_allocations"] == []
assert detail["allocation_hint"] is None
def test_合计满额_hint只报合计(self, client: TestClient, admin: dict) -> None:
p1 = _person(client, admin["headers"])
p2 = _person(client, admin["headers"])
data = _create_with_allocations(
client,
admin["headers"],
[
{"personnel_id": p1["id"], "ratio": "60"},
{"personnel_id": p2["id"], "ratio": "40.00"},
],
)
hint = _detail(client, admin["headers"], data["id"])["allocation_hint"]
assert hint is not None and "余额" not in hint
class TestGuards:
"""校验链:在职/终态/去重已由 schema 层覆盖,此处打服务层口径。"""
def test_离职人员参与被拒(self, client: TestClient, admin: dict) -> None:
p = _person(client, admin["headers"])
resp = client.put(
f"{_PERSONNEL}/{p['id']}/status", json={"status": 0}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
cust = customer(client, admin["headers"])
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "50.00"}],
),
headers=admin["headers"],
)
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(
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "100"}]
)
resp = client.put(
f"{_BUSINESSES}/{data['id']}/status",
json={"status": 2},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
detail = _detail(client, admin["headers"], data["id"])
payload = business_payload(detail["customer_id"], secondary_allocations=[])
for key in ("business_no", "status", "owner_personnel_id", "total_sale_amount"):
payload.pop(key, None)
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:
p1 = _person(client, admin["headers"])
p2 = _person(client, admin["headers"])
data = _create_with_allocations(
client, admin["headers"], [{"personnel_id": p1["id"], "ratio": "40.00"}]
)
detail = _detail(client, admin["headers"], data["id"])
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p2["id"], "ratio": "70.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
replaced = _detail(client, admin["headers"], data["id"])
assert len(replaced["secondary_allocations"]) == 1
assert replaced["secondary_allocations"][0]["personnel_id"] == p2["id"]
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
assert _detail(client, admin["headers"], data["id"])["secondary_allocations"] == []
class TestDeleteProtection:
"""RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。"""
def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None:
p = _person(client, admin["headers"])
data = _create_with_allocations(
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
)
resp = client.delete(f"{_PERSONNEL}/{p['id']}", headers=admin["headers"])
assert resp.status_code == 400
# 方案仍在,回显有名(RESTRICT 保证未静默剥离)
detail = _detail(client, admin["headers"], data["id"])
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"}]
)
resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"])
assert resp.status_code == 200, resp.text # 方案 CASCADE 随业务删除
assert client.delete(f"{_PERSONNEL}/{p['id']}", headers=admin["headers"]).status_code == 200
+137
View File
@@ -0,0 +1,137 @@
"""系统设置(公司信息)接口测试。
守的是:
- 单行语义:全系统恒一行,PUT 只改名称不增行
- public 端点无鉴权可用但**白名单字段**——多一个敏感字段都是匿名泄漏
- logo 上传三重校验(后缀/magic bytes/大小)——仅看后缀可被伪造
- 审计留痕:系统设置属敏感配置,改必须有日志
真图片构造:PNG 用 8 字节签名 + 最小 IHDR;JPG 用 SOI 头 + 填充字节。
仅校验 magic bytes 前缀,无需完整合法图片(解析由前端 img 完成)。
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from app.main import app
pytestmark = pytest.mark.integration
_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
_TXT_BYTES = b"not an image at all"
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
class TestCompanyInfo:
"""公司名称读取与更新。"""
def test_初始值与更新生效(self, client: TestClient, admin: dict) -> None:
got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"]
assert got["company_name"]
# logo_url 允许为 None(未上传)或历史遗留的 URL——单行库可复用,
# 断言不依赖「干净库」前提;上传行为由 TestLogoUpload 专类覆盖
assert got["logo_url"] is None or "/logo/public" in got["logo_url"]
new_name = f"测试公司_{uuid.uuid4().hex[:6]}"
put = client.put(_SETTINGS, json={"company_name": new_name}, headers=admin["headers"])
assert put.status_code == 200, put.text
assert put.json()["data"]["company_name"] == new_name
assert (
client.get(_SETTINGS, headers=admin["headers"]).json()["data"]["company_name"]
== new_name
)
def test_名称为空被拒(self, client: TestClient, admin: dict) -> None:
assert (
client.put(_SETTINGS, json={"company_name": ""}, headers=admin["headers"]).status_code
== 400
)
def test_非管理员改名称被拒(self, client: TestClient, normal: dict) -> None:
resp = client.put(_SETTINGS, json={"company_name": "越权公司"}, headers=normal["headers"])
assert resp.status_code == 403
def test_修改留审计日志(self, client: TestClient, admin: dict) -> None:
client.put(
_SETTINGS,
json={"company_name": f"审计公司_{uuid.uuid4().hex[:6]}"},
headers=admin["headers"],
)
logs = client.get(
"/api/v1/system/operation-logs",
params={"module": "系统设置"},
headers=admin["headers"],
).json()["data"]["list"]
assert logs, "修改公司名称未产生操作日志"
class TestPublicEndpoint:
"""匿名端点:白名单字段 + 可用性。"""
def test_无令牌可读且仅白名单字段(self, client: TestClient, admin: dict) -> None:
name = f"公开公司_{uuid.uuid4().hex[:6]}"
client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"])
resp = client.get(f"{_SETTINGS}/public")
assert resp.status_code == 200
data = resp.json()["data"]
assert set(data) == {"company_name", "logo_url"}, "public 端点字段越界"
assert data["company_name"] == name
assert resp.headers.get("cache-control") is not None
def test_写操作匿名不可用(self, client: TestClient) -> None:
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
401,
405,
}
class TestLogoUpload:
"""logo 上传校验链。"""
def _upload(self, client: TestClient, admin: dict, filename: str, content: bytes):
return client.post(
f"{_SETTINGS}/logo",
files={"file": (filename, content, "application/octet-stream")},
headers=admin["headers"],
)
def test_合法png上传成功且回读(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "logo.png", _PNG_BYTES)
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["logo_url"] and "/logo/public" in out["logo_url"]
img = client.get(f"{_SETTINGS}/logo/public")
assert img.status_code == 200
assert img.headers["content-type"] == "image/png"
def test_伪造扩展名被magic_bytes拦截(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "fake.png", _TXT_BYTES)
assert resp.status_code == 400
assert "不符" in resp.json()["message"]
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}/logo",
files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")},
)
assert resp.status_code == 401
@@ -0,0 +1,751 @@
"""月度结算集成测试(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"]
+28
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
+77 -3
View File
@@ -11,10 +11,14 @@ from fractions import Fraction
from app.services.formula import ( from app.services.formula import (
apply_barter, apply_barter,
clamp_weight_r,
converted_profit, converted_profit,
manager_team_share,
monthly_salary,
payment_days, payment_days,
payment_weight, payment_weight,
performance_amount, performance_amount,
probation_annual_note,
profit_rate, profit_rate,
salesperson_rate, salesperson_rate,
start_payment_ok, start_payment_ok,
@@ -57,9 +61,14 @@ class TestSalespersonRate:
# 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」 # 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」
assert _sp_rate(361) == Fraction(4, 1000) assert _sp_rate(361) == Fraction(4, 1000)
def test_第1天按公式为899比90000_非取整为10(self) -> None: def test_首日取整10千分_图示口径裁决(self) -> None:
# 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000 # 需规图示「第 1 天 10‰」2026-10-19 裁决覆盖公式(公式值 9.9889‰ 作废):
assert _sp_rate(1) == Fraction(899, 90000) # 首日不递减、直接取起始值;第 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: def test_第180天线性段中点千分之8(self) -> None:
assert _sp_rate(180) == Fraction(8, 1000) assert _sp_rate(180) == Fraction(8, 1000)
@@ -137,3 +146,68 @@ class TestModifiers:
def test_起付判定_未达到60点(self) -> None: def test_起付判定_未达到60点(self) -> None:
assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False 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")
+8
View File
@@ -20,6 +20,10 @@ EXPECTED_TABLES = {
"login_logs", "login_logs",
"operation_logs", "operation_logs",
"business_roles", "business_roles",
"system_settings",
"approval_orders",
"approval_steps",
"business_secondary_allocations",
"param_definitions", "param_definitions",
"param_change_batches", "param_change_batches",
"param_values", "param_values",
@@ -36,6 +40,10 @@ EXPECTED_TABLES = {
"shipment_items", "shipment_items",
"receipts", "receipts",
"customer_rating_grades", "customer_rating_grades",
"performance_ledgers",
"monthly_settlements",
"settlement_items",
"adjustment_orders",
} }
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束 # 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
@@ -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
+30 -4
View File
@@ -18,7 +18,9 @@ from app.services.param_registry import (
validate_and_normalize, validate_and_normalize,
) )
# 与 db/migration/V3 迁移种子保持一致的 16 项编码 # 与 db/migration 迁移种子保持一致的 22 项编码(V3 十六项 + V6 审批开关两项
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
# V10 增工作台预警规则三项 #10)
EXPECTED_CODES = { EXPECTED_CODES = {
"salesperson.rate.start", "salesperson.rate.start",
"salesperson.rate.slope", "salesperson.rate.slope",
@@ -35,15 +37,21 @@ EXPECTED_CODES = {
"product.threshold", "product.threshold",
"salary.basic.mode", "salary.basic.mode",
"probation.annual.threshold", "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: class TestRegistry:
"""注册表元数据完整性(与迁移种子同源)。""" """注册表元数据完整性(与迁移种子同源)。"""
def test_注册表共16项(self) -> None: def test_注册表共22项(self) -> None:
assert len(PARAM_REGISTRY) == 16 assert len(PARAM_REGISTRY) == 22
def test_编码集合与迁移种子一致(self) -> None: def test_编码集合与迁移种子一致(self) -> None:
assert set(PARAM_REGISTRY) == EXPECTED_CODES assert set(PARAM_REGISTRY) == EXPECTED_CODES
@@ -61,10 +69,28 @@ class TestRegistry:
continue continue
if spec.value_type in {"DECIMAL", "INT"}: if spec.value_type in {"DECIMAL", "INT"}:
parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError
elif spec.value_type == "BOOL":
assert spec.default_value in {"0", "1"}, f"{spec.code} 默认值非 0/1"
elif spec.value_type == "ENUM": elif spec.value_type == "ENUM":
assert spec.options, f"{spec.code} 为 ENUM 但无选项" assert spec.options, f"{spec.code} 为 ENUM 但无选项"
assert spec.default_value in spec.options assert spec.default_value in spec.options
def test_BOOL开关_仅接受0与1(self) -> None:
"""审批开关 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"
assert validate_and_normalize(spec, "0") == "0"
assert validate_and_normalize(spec, "1") == "1"
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "true")
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "2")
def test_取值域上下限可解析(self) -> None: def test_取值域上下限可解析(self) -> None:
for spec in PARAM_REGISTRY.values(): for spec in PARAM_REGISTRY.values():
if spec.value_min is not None: if spec.value_min is not None:
+211
View File
@@ -0,0 +1,211 @@
"""报表辅助纯函数与 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
@@ -0,0 +1,111 @@
"""二次分配方案入参校验(schema 层,秒级)。
守的是:比例红线(字符串入参、两位小数、超限拒绝不静默舍入)与
参与人去重——uk(business_id, personnel_id) 的友好前置校验。
明细行同批创建的正常路径由集成测试覆盖,这里集中打边界。
"""
import pytest
from pydantic import ValidationError
from app.schemas.business import SecondaryAllocationIn
def _make(personnel_id: int = 1, ratio: str = "50.00") -> dict:
return {"personnel_id": personnel_id, "ratio": ratio}
class TestRatioBoundary:
"""比例边界:0 / 满额 / 超限 / 精度 / 非数字。"""
@pytest.mark.parametrize(
("ratio", "ok"),
[
("0", False), # 必须大于 0
("0.01", True),
("33.33", True),
("100", True),
("100.00", True),
("100.01", False), # 超上限
("-5", False), # 负数
("33.333", False), # 三位小数:拒绝不静默舍入
("abc", False), # 非数字
("", False), # 空串
],
)
def test_比例边界(self, ratio: str, *, ok: bool) -> None:
if ok:
assert SecondaryAllocationIn(**_make(ratio=ratio)).ratio == ratio
else:
with pytest.raises(ValidationError):
SecondaryAllocationIn(**_make(ratio=ratio))
def test_人员id必须为正整数(self) -> None:
with pytest.raises(ValidationError):
SecondaryAllocationIn(**_make(personnel_id=0))
with pytest.raises(ValidationError):
SecondaryAllocationIn(**_make(personnel_id=-1))
class TestDuplicate:
"""参与人去重(Create/Update 两个入参模型同款校验)。"""
def test_创建入参_重复参与人被拒(self) -> None:
from app.schemas.business import BusinessCreateIn
payload = {
"customer_id": 1,
"items": [
{
"product_name": "t_产品",
"product_category": "municipal_water",
"quantity": "1.00",
"sale_price": "10.00",
}
],
"secondary_allocations": [
_make(personnel_id=5, ratio="30.00"),
_make(personnel_id=5, ratio="20.00"),
],
}
with pytest.raises(ValidationError, match="重复"):
BusinessCreateIn(**payload)
def test_编辑入参_重复参与人被拒(self) -> None:
from app.schemas.business import BusinessUpdateIn
payload = {
"customer_id": 1,
"items": [
{
"product_name": "t_产品",
"product_category": "municipal_water",
"quantity": "1.00",
"sale_price": "10.00",
}
],
"secondary_allocations": [_make(personnel_id=5), _make(personnel_id=5)],
}
with pytest.raises(ValidationError, match="重复"):
BusinessUpdateIn(**payload)
def test_不同参与人合法(self) -> None:
from app.schemas.business import BusinessCreateIn
payload = {
"customer_id": 1,
"items": [
{
"product_name": "t_产品",
"product_category": "municipal_water",
"quantity": "1.00",
"sale_price": "10.00",
}
],
"secondary_allocations": [
_make(personnel_id=5, ratio="30.00"),
_make(personnel_id=6, ratio="20.00"),
],
}
model = BusinessCreateIn(**payload)
assert len(model.secondary_allocations) == 2
@@ -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)
+11
View File
@@ -435,6 +435,7 @@ dependencies = [
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "pymysql", extra = ["rsa"] }, { name = "pymysql", extra = ["rsa"] },
{ name = "python-multipart" },
{ name = "redis", extra = ["hiredis"] }, { name = "redis", extra = ["hiredis"] },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "starlette" }, { name = "starlette" },
@@ -462,6 +463,7 @@ requires-dist = [
{ name = "pydantic-settings", specifier = ">=2.6" }, { name = "pydantic-settings", specifier = ">=2.6" },
{ name = "pyjwt", specifier = ">=2.10" }, { name = "pyjwt", specifier = ">=2.10" },
{ name = "pymysql", extras = ["rsa"], specifier = ">=1.2.3" }, { name = "pymysql", extras = ["rsa"], specifier = ">=1.2.3" },
{ name = "python-multipart", specifier = ">=0.0.32" },
{ name = "redis", extras = ["hiredis"], specifier = ">=5.2" }, { name = "redis", extras = ["hiredis"], specifier = ">=5.2" },
{ name = "sqlalchemy", specifier = ">=2.0.36" }, { name = "sqlalchemy", specifier = ">=2.0.36" },
{ name = "starlette", specifier = ">=1.7,<2" }, { name = "starlette", specifier = ">=1.7,<2" },
@@ -656,6 +658,15 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
] ]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
+215 -4
View File
@@ -16,6 +16,14 @@
SET NAMES utf8mb4; 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 `system_settings`;
DROP TABLE IF EXISTS `param_values`; DROP TABLE IF EXISTS `param_values`;
DROP TABLE IF EXISTS `param_change_batches`; DROP TABLE IF EXISTS `param_change_batches`;
DROP TABLE IF EXISTS `param_definitions`; DROP TABLE IF EXISTS `param_definitions`;
@@ -408,6 +416,14 @@ INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2); INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2);
-- 2026-10-15 复核审批补充需求:三个职能登录角色(与 V6 迁移种子一致;
-- 内置保护由 role_service 的 BUILTIN_ROLE_CODES 白名单承担)
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('marketing_ops', '营销后勤', '提交与录入(审批链第一节点:月结/二次分配提交)', 3);
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('finance', '财务', '复核(审批链第二节点)', 4);
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('leader', '领导', '审核(审批链第三节点;全览只读)', 5);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决) -- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决)
-- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致 -- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致
@@ -418,7 +434,7 @@ INSERT INTO business_roles (id, role_code, role_name, calc_template, description
(3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1); (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 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 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), (1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
@@ -436,7 +452,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), (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), (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), (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, '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 INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
(1, 1, 'salesperson', '2026-02-17', '10', 1), (1, 1, 'salesperson', '2026-02-17', '10', 1),
@@ -457,7 +480,16 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1), (16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1),
(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1), (17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1),
(18, 15, '', '2026-01-01', '18000', 1), (18, 15, '', '2026-01-01', '18000', 1),
(19, 16, '', '2026-01-01', '3000', 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 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护) -- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
@@ -663,6 +695,185 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
('C', 60, 74, 3, '合格'), ('C', 60, 74, 3, '合格'),
('D', 0, 59, 4, '待改进'); ('D', 0, 59, 4, '待改进');
-- ---------------------------------------------------------------------------
-- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批)
-- ---------------------------------------------------------------------------
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=未上传使用默认)',
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;
CREATE TABLE approval_orders (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}',
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)',
status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' 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_approval_orders PRIMARY KEY (id),
CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no),
CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status);
CREATE TABLE approval_steps (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
node TINYINT NOT NULL COMMENT '审批节点:0撤回留痕 1财务复核 2领导审核',
action TINYINT NOT NULL COMMENT '动作:1通过 2驳回 3自动通过 4撤回',
opinion VARCHAR(255) COMMENT '审批意见(驳回时填写)',
operator_id BIGINT UNSIGNED COMMENT '操作人ID(自动通过/系统动作置空)',
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化;系统动作记「系统」)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_approval_steps PRIMARY KEY (id),
CONSTRAINT fk_approval_steps_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT fk_approval_steps_order_id FOREIGN KEY(order_id) REFERENCES approval_orders (id) ON DELETE CASCADE
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批节点记录表(append-only;自动通过/撤回均有留痕)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id);
CREATE TABLE business_secondary_allocations (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)',
personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
ratio DECIMAL(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)',
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;
CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id);
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);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作 -- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -671,4 +882,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('8efc189b131f'); INSERT INTO alembic_version (version_num) VALUES ('90653814a421');
+16
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';
@@ -0,0 +1,81 @@
-- Running upgrade 8efc189b131f -> 79bcc5aa98d4
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=未上传使用默认)',
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;
CREATE TABLE approval_orders (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}',
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)',
status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' 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_approval_orders PRIMARY KEY (id),
CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status);
CREATE TABLE approval_steps (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
node TINYINT NOT NULL COMMENT '审批节点:0撤回留痕 1财务复核 2领导审核',
action TINYINT NOT NULL COMMENT '动作:1通过 2驳回 3自动通过 4撤回',
opinion VARCHAR(255) COMMENT '审批意见(驳回时填写)',
operator_id BIGINT UNSIGNED COMMENT '操作人ID(自动通过/系统动作置空)',
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化;系统动作记「系统」)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_approval_steps PRIMARY KEY (id),
CONSTRAINT fk_approval_steps_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT fk_approval_steps_order_id FOREIGN KEY(order_id) REFERENCES approval_orders (id) ON DELETE CASCADE
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批节点记录表(append-only;自动通过/撤回均有留痕)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id);
CREATE TABLE business_secondary_allocations (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)',
personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
ratio NUMERIC(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)',
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 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 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);
CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id);
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('marketing_ops', '营销后勤', '提交与录入(审批链第一节点:月结/二次分配提交)', 3);
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('finance', '财务', '复核(审批链第二节点)', 4);
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('leader', '领导', '审核(审批链第三节点;全览只读)', 5);
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 (17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)', 17, 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 (18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);0=结算生成即生效,1=走财务复核→领导审核审批流', 18, 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (20, 17, '', '2026-01-01', '0', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (21, 18, '', '2026-01-01', '0', 1);
INSERT INTO system_settings (id, company_name, logo_path) VALUES (1, '衍水集团', NULL);
UPDATE alembic_version SET version_num='79bcc5aa98d4' WHERE alembic_version.version_num = '8efc189b131f';
@@ -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';
@@ -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';
@@ -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';
+2
View File
@@ -38,6 +38,8 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
# 健康检查脚本在容器内展开(密码不进宿主进程命令行,模式同 mysql)
REDIS_PASSWORD: ${REDIS_PASSWORD}
# appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢 # appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes: volumes:
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -35,7 +35,7 @@
--ys-radius: 10px; --ys-radius: 10px;
--ys-radius-sm: 7px; --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; 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
--ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1); --ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
@@ -46,8 +46,8 @@
/* 极微噪点:去除大色块的塑料感(内联 SVG,无外部依赖) */ /* 极微噪点:去除大色块的塑料感(内联 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"); --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 字子集,内网可用) */ /* 品牌字:思源黑体(现代几何黑体;实施时自托管 4 字子集,内网可用);栈内禁用微软雅黑等需授权字体 */
--ys-font-brand: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; --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; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -556,8 +556,8 @@
<div class="bc-cap">B · 思源黑体 300 细体 · 0.42em</div> <div class="bc-cap">B · 思源黑体 300 细体 · 0.42em</div>
</div> </div>
<div class="bc"> <div class="bc">
<div style="font-family:'Microsoft YaHei',sans-serif;font-weight:600;letter-spacing:.25em;font-size:16px">衍水集团</div> <div style="font-weight:600;letter-spacing:.25em;font-size:16px">衍水集团</div>
<div class="bc-cap">C · 微软雅黑 600 · 0.25em</div> <div class="bc-cap">C · 系统默认 600(不指定字体,展示回退效果)· 0.25em</div>
</div> </div>
</div> </div>
</section> </section>
@@ -936,7 +936,7 @@
<script src="echarts.min.js"></script> <script src="echarts.min.js"></script>
<script> <script>
/* 仪表盘图表:ECharts 定制主题(圆角柱 / 无网格线 / 浅灰轴标签)——实施时前端引入 echarts 依赖 */ /* 仪表盘图表: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' }); const rev = echarts.init(document.getElementById('chartRev'), null, { renderer: 'svg' });
rev.setOption({ rev.setOption({
grid: { left: 0, right: 0, top: 6, bottom: 20, containLabel: true }, grid: { left: 0, right: 0, top: 6, bottom: 20, containLabel: true },
@@ -2,7 +2,7 @@
> **面向 AI 代理的工作者:** 首工作块(M1)实施时使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现本计划第二章;步骤使用复选框(`- [ ]`)语法跟踪进度。第一章路线图供全局导航。 > **面向 AI 代理的工作者:** 首工作块(M1)实施时使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现本计划第二章;步骤使用复选框(`- [ ]`)语法跟踪进度。第一章路线图供全局导航。
**目标:** 在需求裁决全部落定(2026-10-12)后,为剩余 5 大模块与 T+ 同步给出推进路线;并交付首个工作块「系统设置与参数体系(#28)」的可执行实施计划(任务级,含测试与验证)。 **目标:** 在需求裁决全部落定(2026-10-12)后,为剩余 5 大模块与 T+ 同步给出推进路线;并交付首个工作块「系统设置与参数体系(#28)」的可执行实施计划(任务级,含测试与验证)。2026-10-15 功能清单核查补充(二次分配方案、工作台、复核审批、月结与调整单、备份恢复运维、公司信息配置)已并入路线图(1.1/1.3/1.4)并收录为第三章。
**架构:** 沿用现有六层(api → services → repositories → models,schemas 承载请求/响应,core 横切);新增「公式模板注册表(代码内有限集合)+ 参数按生效日期时点化存储 + 计算预览」作为核算模块的输入契约,不引入规则引擎。 **架构:** 沿用现有六层(api → services → repositories → models,schemas 承载请求/响应,core 横切);新增「公式模板注册表(代码内有限集合)+ 参数按生效日期时点化存储 + 计算预览」作为核算模块的输入契约,不引入规则引擎。
@@ -40,11 +40,17 @@
### 1.1 推荐顺序与依赖 ### 1.1 推荐顺序与依赖
```text ```text
① 系统设置与参数体系(#28) ← 地基:岗位角色 + 参数 + 公式模板 ① 系统设置与参数体系(#28)✅ ← 地基:岗位角色 + 参数 + 公式模板
② 基础数据管理 ← 人员引用 ① 岗位角色(外键上游) │ 增量(2026-10-15):公司信息配置;审批开关走参数体系
③ 业务数据管理 ← 引用 ② 人员/产品/客户 ② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游)
③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户
│ 增量(2026-10-15):二次分配方案(合同刚建立时录入)
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据 ④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;⑥ 写入 ②③ 目标表,受 IT 字段映射制约 │ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清)
⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约
⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层
⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早
``` ```
### 1.2 顺序论证(要点) ### 1.2 顺序论证(要点)
@@ -59,32 +65,37 @@
| 块 | 范围界定 | 验收标准(一句话) | | 块 | 范围界定 | 验收标准(一句话) |
| --- | --- | --- | | --- | --- | --- |
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览 | 管理员可新增岗位并绑定模板;任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录;任意业务日期可解析当时快照;预览精确复现 360 天=6‰、361 天=4‰;301 基线不回归 | | ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 |
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更 | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 | | ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器 | 一笔业务可多产品多批次发货、多笔回款;回款天数按「发货日首日=1」自动算;折算走精确分数;评级只出等级不影响金额 | | ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 |
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕 | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成 | | ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(语义待澄清,见 3.3) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致 | | ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 | | ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions) | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态 | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本 | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
### 1.4 里程碑(交付批次) ### 1.4 里程碑(交付批次)
| 批次 | 内容 | 说明 | | 批次 | 内容 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| **M1** | ① 系统设置与参数体系 | 独立可交付;预览工具可作业务对账入口先行验收 | | **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) |
| **M2** | ② + ③ | 合并交付:一次表结构评审把 T+ 预留列与报表字段清单同时定稿 | | **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) |
| **M3** | ④ 绩效工资核算 | 核心钱袋子;测试重点在精度与参数回溯 | | **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流 | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」;开工前必须澄清:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7);内部建议顺序 ①增量 → ③增量 → ⑦ 审批框架 → ④ 核算 → 月结与审批串联 |
| **M4** | ⑤ 报表 + ⑥ T+ 同步(并行) | ⑥ 受 IT 制约,谁先就绪谁先交 | | **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台 | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付) | 数据持续积累,备份先行;不依赖业务模块进度 |
### 1.5 需求依据对照 ### 1.5 需求依据对照
| 块 | 依据 | 关联作废条款(不引用) | | 块 | 依据 | 关联作废条款(不引用) |
| --- | --- | --- | | --- | --- | --- |
| ① | 需规 2.3.1(四)/2.5.2/2.1.2;对照表 #28/#5/#8/#20–#24 | 经理个人户 8‰(不设参数);含运费口径;协助 25%(不设参数) | | ① | 需规 2.3.1(四)/2.5.2/2.1.2;对照表 #28/#5/#8/#20–#24;增量见 3.1(五)(六) | 经理个人户 8‰(不设参数);含运费口径;协助 25%(不设参数) |
| ② | 需规 2.1.1–2.1.3;对照表 #9/#19/#26 | 信息部负责人条款(信息员独立角色) | | ② | 需规 2.1.1–2.1.3;对照表 #9/#19/#26 | 信息部负责人条款(信息员独立角色) |
| ③ | 需规 2.2.1–2.2.5;对照表 #1/#2/#12/#13/#14/#18/#25 | 26 天示例;含运费公式(2.2.4/2.3.3 整段) | | ③ | 需规 2.2.1–2.2.5;对照表 #1/#2/#12/#13/#14/#18/#25;增量见 3.1(一) | 26 天示例;含运费公式(2.2.4/2.3.3 整段) |
| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24 | 协助 25%;经理 8‰;26 天示例;含运费公式 | | ④ | 需规 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) | — | | ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R);增量见 3.1(四) | — |
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — | | ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——尚未回填需规与对照表,引用以第三章原文为准 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制,关系待业务澄清(3.3-1) |
--- ---
@@ -599,6 +610,132 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 | | 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) | | 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
---
## 第三章 2026-10-15 功能清单核查补充(新增需求)
> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容尚未回填需规文档与《问题与解决方案对照表》,是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。
### 3.1 需求原文(照录)
#### (一)二次分配方案
- 二次分配只出现在合同刚建立时。
- 合同建立时可手动选择参与二次分配的业务员。
- 分配比例不要求合计 100%。
- 审批流程可增加开关配置;关闭时直接生效,开启时走审批。
- 修改二次分配方案不影响已经结算的月份。
- 已结算月份如需调整,只能通过调整单或后续月份扣补处理。
#### (二)工作台
- 经营绩效总览
- 待办事项
- 风险预警
- 最近同步/计算/审批动态
#### (三)复核审批
- 营销后勤提交
- 财务复核
- 领导审核
- 审批开关配置
- 审批意见与版本
#### (四)月结、审批、报表
- 月度结算按确认回款和计算快照生成。
- 支持负数工资、扣补说明和调整单。
- 结算后锁定,修改源数据不影响已结算结果,只产生提示、历史记录和后续调整。
- 导出模板围绕财务复核和领导签字设计。
#### (五)备份恢复与运维检查
- (标题级条目,无细则,用户强调「这个备份恢复很重要」)
#### (六)系统设置
- 公司信息配置:公司名称、logo 等基本信息
#### (七)参数配置强调
- (用户原话):「业务强调参数配置尽量还是灵活一点,能省去以后很多麻烦」
### 3.2 模块归属与设计要点(开发侧推演,非需求原文)
#### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接
- 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `businesses` 创建同事务或紧随其后);合同建立后是否允许补录/修改未明说,方案修改的留痕方式待设计;
- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1),存储上只存各参与人比例、不强制校验合计;
- 「修改不影响已结算月份」→ 月结快照天然隔离(快照固化当月结果),方案表需支持编辑 + 修改留痕;
- 「已结算月份调整只能通过调整单或后续月份扣补」→ 调整单为统一承载机制,见 (四)。
#### (二) 工作台 → ⑧ 新页面(升级现有 Bento 仪表盘占位)
| 区块 | 数据源(推演) |
| --- | --- |
| 经营绩效总览 | ④ 台账/月结(按人/团队/月份汇总) |
| 待办事项 | ⑦ 待审批单、⑥ 同步失败、回款未指定批次(M2 已有该状态可复用) |
| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器 |
| 最近同步/计算/审批动态 | ⑥ 同步日志 + ④ 核算/月结记录 + ⑦ 审批记录 |
#### (三) 复核审批 → ⑦ 新模块
- 三级节点:营销后勤提交 → 财务复核 → 领导审核;
- 审批对象:月度结算((四) 联动);二次分配方案审批((一) 开关联动);
- 「审批开关配置」→ 进 ① 参数体系(BOOL 参数,按审批对象各一);「关闭时直接生效」= 提交即生成自动通过的审批记录(留痕不断链);
- 「审批意见与版本」→ 审批单带版本号:重算/改单产生新版本,旧版本与意见留痕不覆盖;
- **登录角色体系扩展**:营销后勤/财务/领导是系统新登录角色(`roles` 表基础设施已有;`permissions`/`role_permissions` 自 M1 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。
#### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」定义待澄清(3.3-3);
- 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径;
- 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态);
- 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据;
- 导出模板含财务复核栏、领导签字栏、公司信息页眉(依赖 (六) 公司信息配置)。
#### (五) 备份恢复与运维检查 → ⑨(原文无细则,范围由开发定义后交用户确认)
- 备份:MySQL 定时全量(mysqldump,命名卷外置)+ 保留策略 + **恢复演练文档与脚本**(备份不演练 = 没有备份);
- 运维检查:容器状态、磁盘空间、备份新鲜度、Redis 键 TTL 抽查、同步日志异常、登录日志异常 → 脚本化健康报告;
- 用户强调重要 → 建议 M3 期间并行尽早交付(不依赖业务模块)。
#### (六) 系统设置-公司信息 + 参数灵活性 → ① 增量
- 公司信息配置:公司名称、logo(登录页与 ⑤ 导出页眉消费);
- 参数灵活性(用户重申方向):新增审批开关、二次分配、月结相关配置**一律进参数注册表**(16 项清单将扩充),沿用「公式模板(代码内有限集合)+ 参数绑定」既定路线;新增公式形态仍需加模板(一期接受,与「不引入规则引擎」一致)。
### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清)
| # | 事项 | 影响模块 | 建议默认处理 |
| --- | --- | --- | --- |
| 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 |
| 2 | 二次分配审批开关默认值(开/关) | ⑦ 参数种子 | 按「支付环节按需规默认值参数化」先例:默认关闭(直接生效),可开 —— 待业务确认 |
| 3 | 「确认回款」的定义:登记即确认 / T+ 同步即确认 / 需人工确认动作? | ④ 月结取数口径 | 建议需人工确认动作(可在回款确认状态上扩展),待确认 |
| 4 | 审批角色权限清单:营销后勤/财务/领导的账号归属、可见菜单、操作权限点 | ⑦ 与系统管理 | 开工前业务方给出清单;技术侧激活 permissions/role_permissions |
| 5 | 风险预警规则:哪些情形预警、阈值多少 | ⑧ | ④⑤ 落地后与业务确认首批规则,先落参数化容器 |
| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 |
| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 |
### 3.3.1 答复回填(2026-10-19)
> 业务方 24 项答复已到(2026-10-16,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列),2026-10-19 全部落实并实现;本节为 3.3 各项的落定状态,供后续引用。答复与默认不同处以答复为准;仍未答复项见 `docs/待答复催办清单_2026-10-19.md`。
| 3.3# | 事项 | 落定状态 |
| --- | --- | --- |
| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 关系) | **答复仍空白;2026-10-19 已按建议默认实现分摊拆行**——分配标的=绩效金额份额(参与人份额×比例、负责人拿余额、行合计守恒、合计>100% 与负责人自分 400 拦截);协助 25% 维持仅记录不核算;业务答复后如有变更走口径调整 |
| 2 | 二次分配审批开关默认值 | 已答复:**默认开启**,可手动关闭(已落实 V8 参数种子) |
| 3 | 「确认回款」定义 | 已答复:需人工确认动作,可手动关闭(系统已按此预留并实现) |
| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认;现按最小授权运行(后勤=提交录入,财务=复核+导出,领导=审核只读),营销后勤暂不可见审批列表 |
| 5 | 风险预警规则 | 已答复:方向确认参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步——⑧ 工作台实现 |
| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 已答复:同一审批链;跨月调整需管理员授权并留痕(已实现) |
| 7 | 待办事项范围 | 已答复:待审批 + 同步失败 + 回款未指定批次起步——⑧ 工作台实现 |
### 3.4 对路线图的影响(本次已修订)
1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容;第三章待 3.3 各项澄清后,按第二章同粒度补齐 M3 各增量块的任务级实施计划。
## 验证方式 ## 验证方式
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款); 1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
@@ -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 | 有则嵌、无则纯文本 |
Binary file not shown.
+101
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+97
View File
@@ -0,0 +1,97 @@
# 待确认事项汇总(2026-10-16)
> 汇总当前所有「待执行 / 待答复 / 待恢复」事项,供项目例会与对外跟催使用。
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。
## 一、立即待办(需项目内处理)
| # | 事项 | 状态 | 下一步动作 |
| --- | --- | --- | --- |
| 1 | **推送 feat/m3-increments** | ✅ 已完成(2026-10-19) | 远端 Git 服务恢复后已推送,远端现为 e48cfb7(含 code-review 修复 4 提交 + #14 首日口径 + V8 系列 + 测试/文档);并行会话(88:calc_service 分摊拆行)收口后继续推送其后提交 |
| 2 | **M2 分支合并 main** | 未开始 | feat/base-business-data(M2 全部 + code-review 32 条修复)尚未合入 main;与 #1 一并处理(PR 评审或本地合并) |
## 二、业务方澄清(24 项,**2026-10-16 答复已到**,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)
> 答复汇总:**12 项确认按默认执行**(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#23/#24);**4 项答复与默认不同,以答复为准**——#5 二次分配审批开关**默认开启**、#14 **首日整 10‰**、#16 试用期基本工资**按人员档案逐人维护**、#19 备份**加增量补充**;仍未落定见下表「答复状态」列。答复后待回填开发计划 3.3 与需求记录。
### 高优先级(阻塞对应模块开工,8 项)
| 清单编号 | 问题 | 建议默认(未答复时执行) | 阻塞模块 | 答复状态 |
| --- | --- | --- | --- | --- |
| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 未答复前只做方案录入,不实现分摊(已按此交付) | ④ 分摊计算 | ❌ 未答复 |
| 2 | 分配比例合计不足 100% 时余额归谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | ❌ 未答复 |
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ | ❌ 未答复 |
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊实现(88 批次)进行中 |
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** |
| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 |
| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 |
| 12 | **经理个人户业绩是否计入其 12.5% 基数?(无默认,必须答复)** | —— | ④ 台账表结构设计 | ✅ **已落实为参数(2026-10-19)**:答复「做参数可调、初始值未给」→ 按默认 0=不计入落地(`manager.personal.included`),可手工调 1=计入 |
### 中优先级(模块实现期内答复即可,9 项)
| 清单编号 | 问题 | 答复状态(2026-10-16) |
| --- | --- | --- |
| 5 | 二次分配审批开关默认值? | ✅ 已落实(2026-10-19,V8):默认开启 + 当日生效行;调整单/二次分配/月结三类审批开关均已可读可调 |
| 7 | 月度结算审批链默认开还是关? | ✅ 已落实(2026-10-19,V8):默认开启;财务节点通过仅流转,终审(领导)通过才锁定月结 |
| 10 | 「风险预警」包含哪些情形与阈值? | ✅ 方向确认:参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步 |
| 11 | 「待办事项」范围? | ✅ 确认:待审批+同步失败+回款未指定批次起步 |
| 13 | 支付环节 5 项默认值顺手确认(起付 60%/R×R/预付 10‰/抵顶 80%/月工资口径) | ✅ 确认按默认执行 |
| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL |
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` |
| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 |
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ❌ 未答复:「当前未提到预收款情况」,暂按不计入 |
### 低优先级(已有默认口径,顺手确认,7 项)
| 清单编号 | 问题 | 答复状态(2026-10-16) |
| --- | --- | --- |
| 14 | 需规图示「第 1 天 10‰」与公式不一致,是否认可按公式? | ✅ 已落实(2026-10-19,按 A 口径):首日整 10‰ 特判、次日起按公式(保「360 天=6‰」锚点);B 口径(整体平移)追问保留 |
| 17 | 计提参数等敏感配置普通用户可否查看? | ✅ 已落实(2026-10-19):查看放开登录用户、修改仅管理员 |
| 18 | 企业户 02-17 之前的业务是否按 2‰ 回溯? | ✅ 确认按回溯 |
| 19 | 备份保留周期与恢复目标? | ✅ 已落实(2026-10-19):全量 + binlog 增量归档补位 |
| 21 | 退货/红字是否冲减绩效? | ✅ 当期冲减(未结算月),已结算走调整单 |
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
## 三、待 IT / T+ 侧答复(18 项,见《T+对接数据需求清单》v1.3「待IT确认事项」表)
> **2026-10-19 清单已升 v1.4**(`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`,七节:人员/产品/业务单据/发货单/回款单/客户档案/同步完整性与补充字段)——本节口径待收编到 v1.4 后再对外;待 IT 逐项确认的状态以最新版文件为准。
> **2026-10-16 业务方已对「口径与用途说明」修订,已收编清单升 v1.3**:#18 回款↔发货批次定稿**人工指定**(T+ 核销关系仅作辅助,`shipment_id` 保持「同步不覆盖」,v1.2.1「T+ 主源」修订作废,M2 回款页现状不动);「确认回款」两处 #8 开放引用收敛;特殊业务标记待确认降为低优。
> **新增追问 2 条**(随下一轮回执发业务方):① 「负责营销人员」口径被改为「人员编码**以姓名**为唯一识别」,与「编码是业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误;② 数据清单回款「关联订单号」仍写「由 T+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。
### 高优先级(8 项)
| # | 事项 |
| --- | --- |
| 1 | **成本额取数口径**(阻塞核算:成本类型/毛利计算方式/更新时间/精度) |
| 2 | 发货日期口径(出库日/签收日) |
| 3 | 到账日期口径(银行到账日/核销日) |
| 4 | 幂等唯一键规则(作废重开是否换号) |
| 5 | 三单关联键(订单/发货/回款如何关联) |
| 6 | **人员唯一编码(工号)+ 业务员↔经理隶属**(隶属已列必须;识别口径疑笔误见上方追问①) |
| 7 | 对接方式与安全(REST/视图/中间表/文件) |
| 8 | 负数/红字/退货/冲销处理规则与原单关联 |
| 16 | 回款确认/核销状态字段与枚举 |
### 中低优先级(9 项)
存量起始日期与增量口径、客户档案匹配与唯一键、收款方式枚举清单、产品「数量」语义、发货单价口径、特殊业务标记字段、同步窗口时间、市场拓展计划数据、口径裁决 5 项(#20–#24 已转业务方)。
## 四、开发里程碑待办
| 里程碑 | 内容 | 前置条件 |
| --- | --- | --- |
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;**余**:二次分摊实现(#1–#3 答复空白)、业务答复回填批次收口(88 会话) | #20(含税口径)、#1–#3 答复后按同粒度推进 |
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 |
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
| 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
## 五、文档待办(低优)
- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准
- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.4**(2026-10-19,本节口径待收编;生成脚本 build_tplus_xlsx.py 已对齐 v1.3、待对齐 v1.4)
- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)**
- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可
+82
View File
@@ -0,0 +1,82 @@
# 待答复事项催办清单(2026-10-19)
> **用途**:对外跟催材料,分三块可直接拆分发送——一、业务方;二、IT/T+ 侧;三、报表口径补充确认(本轮新增)。
> **基线**:《待澄清问题清单_2026-10-15(1)(2).xlsx》(业务 24 项,2026-10-16 已回 15 项)、《T+对接数据需求清单》v1.3「待IT确认事项」18 项 + v1.4(2026-10-19 勘误)、《问题与解决方案对照表_v2》(28 项已裁决口径)。
> **原则**:已答复项不重复询问;未答复项按清单内「建议默认」执行中,答复后以答复为准并回填文档。
## 一、业务方(建议回复期限:____)
### A. 仍未答复 —— 阻塞对应模块开工(4 项)
| # | 问题(原文口径) | 当前执行默认 | 阻塞 |
| --- | --- | --- | --- |
| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 未答复前只做方案录入,**不实现分摊计算**(已按此交付) | ④ 分摊计算 |
| 2 | 分配比例合计不足 100% 时,剩余部分归属谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 |
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ 分摊 |
| 20 | 含税口径:销售/成本/毛利润/运费各项是否含税?价税合计与折扣折让如何处理? | 按不含税口径实现中 | ④ 核算口径终验、⑥ 同步建列(是否建税率/税额列) |
> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批,但**分摊金额不会出现在绩效中**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。
### B. 已有答复但需补具体值 / 勾选(4 项)
| # | 事项 | 需补充 |
| --- | --- | --- |
| 12 | 经理个人户业绩是否计入其 12.5% 团队基数 | 答复为「做参数可调」,**请给出初始值**:计入 / 不计入(系统已实现为开关参数,当前默认不计入) |
| 14 | 「第 1 天 10‰」实现口径 | 请勾选:**A 仅首日覆盖**(推荐,保「360 天 = 6‰」锚点)或 B 整体平移(破坏锚点)。系统当前按 A 实现(首日 10‰、其余按公式 10 − 天数 × 4/360) |
| 4 | 二次分配方案修改窗口与权限 | 答复「暂定允许修改,走调整单」——请定:首次月结前可改(留痕)?月结后只读、仅走调整单?谁有权改(管理员 / 营销后勤)? |
| 6 | 营销后勤/财务/领导三类角色的账号归属、可见菜单、可操作点 | 答复「先预设角色,需与客户确认」——请给权限清单(三类角色各能看什么、能操作什么)。系统已按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读,营销后勤暂不可见审批列表 |
### C. 请确认两处勘误(已按勘误实施,需业务回执存档)(2 项)
| 项 | 原表述 | 现行勘误(v1.4) |
| --- | --- | --- |
| 人员识别 | 「负责营销人员以姓名为唯一识别」 | 改为「以人员编码(工号)为唯一识别、姓名辅助核对」——原表述与「编码为业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误,请确认 |
| 回款↔发货批次 | 「由 T+ 收款分配关系指定,人工辅助」 | 改为「**系统人工指定**(对照表 #18 定稿),T+ 收款分配关系仅作辅助」——主次相反疑漏改,请确认 |
### 已按默认执行、无需回复的项(供核阅)
#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。
## 二、IT / T+ 侧(建议回复期限:____)
> 2026-10-15 回稿收到后,v1.3「待IT确认事项」18 项**未逐项答复**(回稿仅做了必要性升级与第七节补充),机制性内容(同步频率/幂等/格式要求)未包含,「同步不覆盖字段」表被引用但未附。现有 v1.4 清单中「系统侧目标表.字段」列**全表留空待回填**。
### A. 高优先(9 项,阻塞核算口径与同步程序开工)
| # | 事项 | 为什么高 |
| --- | --- | --- |
| 1 | **成本额取数口径**:成本类型/毛利计算方式/更新时间/精度 | 毛利润 = 销售额 − 成本额,是绩效金额全部源头;缺此则同步无法自动跑,只能人工录入 |
| 2 | 发货日期口径:出库日 / 签收日 / 开票日 | 回款天数基准日(发货日首日计第 1 天),直接决定计提比例 |
| 3 | 到账日期口径:收款日 / 银行到账日 / 核销日 | 计提参数版本匹配日,直接决定适用哪一版比例 |
| 4 | 幂等唯一键规则:作废重开是否换号 | 重复同步不重复入库的前提 |
| 5 | 三单关联键:订单 / 发货 / 回款如何互相关联 | 一笔业务可分批发货;回款↔批次系统内人工指定,但订单↔发货关联仍必须由 T+ 提供 |
| 6 | **人员唯一编码(工号)** + 业务员↔营销经理隶属关系 | 业绩归属唯一锚点(重名即错钱);经理 12.5% 团队聚合依隶属关系展开 |
| 7 | 对接方式与安全:REST / 数据库视图 / 中间表 / 文件交换 | 未定则同步程序无法开工(一期人工录入可闭环) |
| 8 | 负数 / 红字 / 退货 / 冲销处理规则与原单关联 | 决定第七节补充字段如何建列;如不提供则只能靠人工补录 |
| 16 | 回款确认 / 核销状态字段与枚举 | 月度结算按「确认回款」生成,需 T+ 核销/确认类字段作人工确认依据 |
### B. 回稿三缺口(请补)
1. v1.3 第七节曾漏「人员唯一编码(工号)」——v1.4 数据清单第一节已补★项并注明「待IT确认 #19:T+ 档案编码是否即工号,若为自动内部编码请指明工号字段」;
2. 「客户档案是否同步」(待IT确认 #10)未点名答复——v1.4 第六节已列客户档案 5 项,请确认可提供;
3. 「系统侧目标表.字段(T+ 确认映射)」列全表留空——请回填,这是同步程序的字段契约。
### C. 中低优先(9 项,模块实现期内答复即可)
存量数据起始日期与增量口径(影响能出多早的报表)、客户匹配与唯一键、收款方式完整枚举清单、产品「数量」语义(库存量/在途/累计销量)、发货单价口径(订单价/发货时价)、特殊业务标记字段、同步窗口时间、市场拓展计划数据、待IT确认 #18(口径裁决 5 项已转业务方,见第一部分)。
## 三、报表口径补充确认(2026-10-19 新增,随本轮开发一并排)
> 报表统计模块(需规 2.4 / FR-029~FR-032)开工在即,以下三项请确认;未答复按「建议默认」实现,后续可调。
| # | 事项 | 建议默认(将按此实现) |
| --- | --- | --- |
| R1 | 部门绩效汇总表的「部门」口径 | 部门 = 经理团队(经理 + 其直属业务员),按人员档案的隶属关系展开;离职人员历史业绩留在原团队;经理的 5% 全公司部分单独一列 |
| R2 | 回款统计的「回款天数区间」档位 | ≤0 天(先款后货)|1-30|31-60|61-90|91-180|181-360|361 以上|无回款天数,共 8 档;未命中区间显示 0 |
| R3 | 导出/打印模板要素 | 公司信息页眉(名称+logo)、报表标题、筛选条件、导出时间与导出人、模板版本号、财务复核栏、领导签字栏、日期栏 |
另:报表查看权限(FR-029 参与者含「营销后勤(授权范围内)」)——请随第一部分 B-6 权限清单一并明确营销后勤是否需要查看报表、可见哪些列。
---
**发送建议**:第一部分发业务方(含 C 两项勘误确认);第二部分发 IT/T+(v1.4 附表一并);第三部分随开发进度与业务方顺带确认。
Binary file not shown.
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more