Author SHA1 Message Date
liuwenxin d0726487e9 merge: 合并 main 至 docs/tplus-field-mapping(V12 审批版本号批次收口前同步) 2026-10-22 13:32:30 +08:00
liuwenxinandClaude Code a84fe9b316 chore: 忽略嵌套目录下的 e2e 失败截图与临时文档目录
- `frontend/e2e/*.png` 改为 `frontend/e2e/**/*.png`:e2e 脚本按相对路径
  写 failure.png,在不同 CWD 下会落到 frontend/e2e/e2e/
- 忽略 .tmpdocx/(AI 生成文档的脚本与预览截图)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:57 +08:00
liuwenxinandClaude Code 94e2957359 docs: T+ 清单升至 v1.7 并清理过期版本文件
- 新增 docs/T+对接数据需求清单_v1.7_2026-10-20.xlsx(v1.6 可读性改造
  的进一步结构重构:结论总览首屏、业务列前置、技术列后置分区)
- 保留 v1.6 供对照;删除已被替代的 T+ 清单 2026-10-15/v1.4/v1.5、
  待澄清问题清单 2026-10-15(两份)、待确认事项排序表 2026-10-16
  (两份)与 2026-10-13 阶段报告(均有 10-19/10-20 后继文档)
- 新增部署硬件需求(txt 源文与 docx 说明)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:57 +08:00
liuwenxinandClaude Code ebc9595de2 feat(approval): 审批详情展示内容采集时间
- 快照单显示「内容采集时间(送审版本)」;live 单显示「打开详情时实时
  读取(非送审版本)」,让复核者明确看到的是哪个时点的内容
- live 分支不展示 new Date() 生成的时刻:渲染函数里的时间非响应式,
  且 toISOString 为 UTC 会差 8 小时

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:19 +08:00
liuwenxinandClaude Code 9964281c14 fix(approval): 版本号唯一键兜底 + 取号改当前读防并发重复
- V12 迁移:先重排存量重复 (order_type, ref_id) 分组的 version_no
  (V11 把存量一律置 1,而 V11 前重提是新建行),再加
  uk_approval_orders_order_type_ref_version;只重排真有重复的分组,
  避免改动前端已展示过的历史版本号
- _next_version 改锁读(select version_no ... FOR UPDATE,最大值在
  Python 侧算):请求级鉴权的普通 SELECT 已把 REPEATABLE READ 快照
  锚定在请求开始时刻,原 MAX 快照读在并发下会算出同一版本号;
  聚合 + FOR UPDATE 为 MySQL 明确禁止,故不锁聚合结果
- 编辑与月结提交路径把 1062 收敛为 400「提交冲突(版本号生成重复),
  请重试」,其余完整性错误原样上抛;submit_order 内不 catch/rollback
- 驳回清空方案的过时注释改为 V11 起的 append-only 语义(置历史不删行)
- init-dev.sql 同步唯一键与 alembic_version 7f4f7b918bc4,导出 V12 SQL
- 测试:集成 +5(锁读 SQL 形态、两会话快照回归、唯一键 1062、冲突 400、
  正常递增)+ 单元 +1;工作台假审批单改用不同 ref_id 适配唯一键
- 全量回归 897 passed;ruff 与前端 type-check 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:01 +08:00
liuwenxinandClaude Code 694d21d1fd test(e2e): 二次分配流程断言改为取实际方案并补审批内容校验
审批详情的送审内容断言不再硬编码参与人姓名(下拉取人员列表前两项,
未必是脚本刚建的 t_e2e 员):改为经业务详情接口取回实际保存的方案,
逐参与人断言姓名与比例出现在审批抽屉中。顺带修掉原脚本清理环节的
隐性缺陷——开关开启时业务有在途审批单,界面删除被拦截而脚本无断言,
数据从未真正清理;现脚本内显式关闭审批开关并保证结束还原为 1。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 14:25:39 +08:00
liuwenxinandClaude Code 4c32a556de feat(approval): 审批单内容快照与二次分配方案版本化
补齐 code-review 遗留 2 项:审批单此前只固化 ref_id/refer_no 指针,复核者看到
的是业务当前内容;二次分配方案在编辑替换与驳回清空时一律物理删除,方案事实
无处回溯。

- 审批单加 version_no 与 snapshot_json(信封 schema_version/order_type/
  captured_at/data);二次分配、月结、调整单三类提交均固化送审内容,开关关闭
  的自动通过路径同样写快照;详情按 snapshot/live/missing 三态回显,历史单
  只读回退当前内容并显式告警,不回填历史快照
- 二次分配方案行改 append-only 版本化(version_no/is_active,唯一键扩为
  business+version+personnel):编辑替换与驳回清空只置历史,核算与详情默认
  只取生效行,业务详情新增 allocation_history
- 送审内容未变不重提、方案未变不新增版本:判据统一为送审内容,两个
  version_no 语义解耦(方案版本=内容第几版,审批单版本=第几次送审)
- 审批单列表、工作台待办、审批超时预警三处 defer 快照列,避免整列拉取
- 月结提交取行加 for_update,防并发提交算出重复的审批单版本号
- 修复审批类型筛选上界 le=2(调整单无法筛选)、快照金额把空值写成 0.00
- V11 迁移三件套同步(Alembic / db/migration / init-dev.sql)

测试:单元 +16(方案内容比较 10、审批入参 6)、集成 +12;全量 891 passed。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 18:30:51 +08:00
liuwenxin 4c10475228 merge: 合并 T+ 字段映射草案与清单 v1.5 至 main 2026-10-20 10:57:09 +08:00
liuwenxinandClaude Code 2e0c64849b docs(tplus): 收敛 IT 待确认事项并更新催办清单
原 18 项收敛为 13 项 IT 待确认 + 1 组业务裁决,每项附字典候选字段与来源;同步更新待确认汇总第三节与文档待办。字段级证据见映射草案 v0.1。

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:44:43 +08:00
81 changed files with 5550 additions and 663 deletions

No files matched your search

+12 -2
View File
@@ -45,8 +45,9 @@ htmlcov/
.mypy_cache/
.ruff_cache/
.tox/
# 端到端测试产出的截图
frontend/e2e/*.png
# 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD
# 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖)
frontend/e2e/**/*.png
# 临时文件
*.tmp
@@ -64,3 +65,12 @@ CLAUDE.md
backend/uploads/
# 数据库备份导出目录(⑨ 备份脚本产物)
db/backups/
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
docs/**/*.rar
docs/T_12.3数据字典/
# macOS 压缩包残留元数据
__MACOSX/
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
.tmpdocx/
+24 -3
View File
@@ -221,10 +221,29 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
### 审批快照与方案版本化(2026-10-20 定稿,改动前先读)
- **审批单带版本号与内容快照**:`approval_orders.version_no` 按 `(order_type, ref_id)` 从 1 递增,重提先撤回旧在途单、但**旧单快照不覆盖**;`snapshot_json` 统一信封 `{schema_version, order_type, captured_at, data}`,`data` 由 `approval_service.{allocation,settlement,adjustment}_content` 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。
- **两个 `version_no` 语义不同,别混用**:`business_secondary_allocations.version_no` = 方案内容第几版,**仅方案内容(参与人+比例)变化时 +1**;`approval_orders.version_no` = 同一对象第几次送审,**仅送审内容变化时 +1**。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。
- **送审内容未变不重提**:编辑业务时若**存在在途审批单**且其快照 `data` 与本次送审内容逐键相等(`approval_service.pending_matches_snapshot`,只比 `data` 不比 `captured_at`),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时**不新增方案版本行**(`business_service._plan_changed` 按「人员+比例」集合比较,与行序无关)。
- **快照的覆盖范围是「方案 + 钱 + 归属」**(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),**不含产品明细行**。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。
- **列表不加载快照列**:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 `defer(ApprovalOrder.snapshot_json)`(与操作日志列表排除 `request_data` 同一理由,参考 `operation_log_repository.list_logs`);详情走 `get_order` 按需读取。
- **详情内容来源三态**:`content_source=snapshot`(送审快照)/ `live`(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ `missing`(引用对象不存在且无快照)。**禁止回填历史单快照**——回填等于伪造提交时内容。
- **二次分配方案 append-only**:`business_secondary_allocations` 行不物理删除,编辑全量替换与驳回清空只把旧行 `is_active=0`;唯一键为 `(business_id, version_no, personnel_id)`,同一版本内参与人唯一、跨版本可重复。`business_repository.list_allocations(active_only=True)` 是核算与详情回显的唯一取数口径;业务详情 `allocation_history` 返回全部版本供回溯。
- **月结提交要锁行**:`settlement_service.submit` 取月结行必须 `for_update=True`——并发提交会让两次 `submit_order` 算出同一个审批单 `version_no`,送审次序失真。
- **审批单 `version_no` 有唯一键兜底(V12)**:`uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`——同一对象的第 N 次送审在库里有唯一一行。ORM 侧约束名必须与迁移 DDL 严格一致,否则 autogenerate 误判。
- **取版本号必须是「当前读」(`approval_service._next_version`)**,两个禁忌:(a) 不能用普通 `MAX` 查询——请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻,父行锁只串行化写入时刻、**不刷新快照**,MAX 会读到旧值;(b) 不能写成 `select(func.max(...)).with_for_update()`——MySQL 官方明确「Locking clauses are not permitted in queries with aggregate functions or GROUP BY」,聚合上的 FOR UPDATE 语义无保证。正解:锁读该 ref 的现有版本行(`select(version_no)...with_for_update()`),最大值 Python 侧算(同 `no_generator` 套路)。
- **版本号冲突的收敛口径**:`submit_order` 内不 catch、不 rollback(红线)。create 路径由外层编号重试循环整体重来(`business_service._NO_RETRY`);编辑/月结提交路径由审计上下文统一回滚,再把 1062 翻译为 400「提交冲突(版本号生成重复),请重试」(同 `param_service` 版本号冲突模板)。唯一键是最后一道防线,不是取号正确性的前提。
- **迁移前先修存量**:V11 把存量行 `version_no` 一律置 1,而 V11 前每次重提都新建行——同一 `(order_type, ref_id)` 存在多行 version_no=1,直接加唯一键必报 1062。V12 先重排「组内有重复」的分组(按 id 升序 1..n),其余分组不动(全量重排会改掉前端已展示过的历史版本号)。
- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。
### 工作台口径(M4 ⑧,改动前先读)
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定;回款未指定批次 = `receipts.shipment_id IS NULL`。
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定,**已终止业务(status=3)排除**;回款未指定批次 = `receipts.shipment_id IS NULL`。
- **待办按角色过滤**:`pending_statuses_for_roles`(approval_service)把当前用户角色映射到在途节点——财务只看待财务复核(status=1)、领导只看待领导审核(status=2)、admin 可看全部;多角色取并集。风险预警仍是全局经营视角,不按人裁剪。
- **预警展示上限**:`items` 最多 20 条,`total` 是全部命中数(前端徽标用它,不能用 `items.length`)。
- **趋势窗口**:回款趋势先按 `receipt_date` 范围过滤再按 `DATE_FORMAT` 分组,函数列不参与 WHERE,保证索引可用。
- **同步降级**:⑥ 未建时 `sync_available=false`,前端显示「模块未启用」——不要返回 0 条冒充「无失败」。
- **参数快照一次解析**:`warning.*` 三个阈值在每次请求只 `resolve_snapshot` 一次,多条规则复用。
- **导出 SQL 的编码坑**:Windows 下 `alembic upgrade --sql > V*.sql` 默认按控制台 GBK 输出,`test_schema_sync` 按 UTF-8 读取会报 `UnicodeDecodeError`——必须 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1` 前缀。
@@ -234,10 +253,12 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **数据源固定单笔生效行**:四张报表的明细一律 `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% 分列**——两项之和才是聚合行金额。
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块)。
- **全公司 5% 的发放口径**:月结只为有台账 bucket 的营销经理各生成一条聚合行(12.5%+5%),**每位经理各计一次 5%**;零成员团队无聚合行、不发 5%。报表的 `aggregate_amount` 优先取冻结聚合行、未生成月结时才用 `manager_team_share` 一次舍入预览;`team_subtotal`/`net_total` = 聚合额 + 调整净额。`team_share_total` 与 `company_share_total` 仅分列展示,两者之和与聚合额的差记入 `aggregate_rounding_diff`(分列各自 q2 会有 0.01~0.02 尾差)。
- **对拍边界**:报表应发 = 月结 `total_payable` + 调整单净额(调整单不进月结 items);筛选 `manager_id` 时只汇总展示团队,隐藏金额进 `other_*`,不能与全公司月结总额对拍。
- **人员归属按行类型**:普通台账行跟随 `manager_id`;经理个人户行在 `manager.personal.included=1` 时归经理本人团队,且经理成员行**只取个人户金额**(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 `manager_id` 筛选时 `unassigned` 为空(语义是「仅看该经理团队」)。
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);全公司分成只出现一次,调整净额只在明细层求和,应发合计 = 团队成员应付 + 未分配应付 + 团队分成合计 + 全公司分成 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);第二块展示单经理 5%、计入次数与合计,对账块展示团队分成/全公司分成(分列)、聚合行舍入差、调整净额,应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
@@ -0,0 +1,71 @@
"""v12 审批单版本号唯一键
Revision ID: 7f4f7b918bc4
Revises: f3a8c2d61b47
Create Date: 2026-10-21 10:15:00.000000
给 ``approval_orders`` 加 ``(order_type, ref_id, version_no)`` 唯一键,兜住
「同一引用对象的第几次送审」在并发下算重。
**加键前必须先重排存量**:V11 迁移用 ``ADD COLUMN version_no INT NOT NULL
DEFAULT 1`` 把存量行一律置 1,而 V11 之前的每次重提都是**新建行**(撤回
只改旧单 status,行不删)。于是任何有 V11 前审批历史的库里,同一
``(order_type, ref_id)`` 存在多行且 ``version_no`` 全为 1——直接
``ADD CONSTRAINT`` 必报 1062,迁移会停在半途。
重排只针对「组内有重复」的分组(``COUNT(*) > COUNT(DISTINCT version_no)``),
按 id 升序重排为 1..n;其余分组一个字节都不动——全量重排会把前端已展示
过的历史版本号(``V{{ version_no }}``)改掉,属于用户可见的历史数据篡改。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7f4f7b918bc4"
down_revision: str | Sequence[str] | None = "f3a8c2d61b47"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_UK = "uk_approval_orders_order_type_ref_version"
def upgrade() -> None:
"""先重排存量重复的版本号,再加唯一键。"""
# 重排:只动「组内有重复版本号」的分组,按 id 升序编 1..n。
# 用派生表内联而非临时表——同一张临时表被 JOIN 两侧引用会报 1137
# "Can't reopen table"。
op.execute(
"UPDATE approval_orders ao "
"JOIN (SELECT id, ROW_NUMBER() OVER ("
" PARTITION BY order_type, ref_id ORDER BY id) AS rn "
" FROM approval_orders "
" WHERE (order_type, ref_id) IN ("
" SELECT order_type, ref_id FROM approval_orders "
" GROUP BY order_type, ref_id "
" HAVING COUNT(*) > COUNT(DISTINCT version_no))) t "
"ON ao.id = t.id "
"SET ao.version_no = t.rn"
)
# 幂等 guard:MySQL DDL 隐式提交,若加键成功而版本号未落盘,
# 重跑会报 1091(键已存在)。写法沿用 V9 先例。
op.execute(
"SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
"WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' "
f"AND CONSTRAINT_NAME = '{_UK}')"
)
op.execute(
"SET @ddl := IF(@uk_exists = 0, "
f"'ALTER TABLE approval_orders ADD CONSTRAINT {_UK} "
"UNIQUE (order_type, ref_id, version_no)', 'DO 0')"
)
op.execute("PREPARE stmt FROM @ddl")
op.execute("EXECUTE stmt")
op.execute("DEALLOCATE PREPARE stmt")
def downgrade() -> None:
"""删唯一键。唯一键本质是索引,DROP INDEX 不校验数据,存量是否已有
重复 version_no 与删索引无关;重排结果也不回滚(版本号保持 1..n 连续)。"""
op.execute(f"ALTER TABLE approval_orders DROP INDEX {_UK}")
@@ -0,0 +1,95 @@
"""v11 审批快照与方案版本化
Revision ID: f3a8c2d61b47
Revises: 90653814a421
Create Date: 2026-10-20 10:30:00.000000
补齐 code-review 遗留 2 项:
- 审批单加 ``version_no`` 与 ``snapshot_json``:重提产生新版本,送审内容
在提交时固化,审批期间业务再改不影响复核依据;
- 二次分配方案行改 append-only 版本化:加 ``version_no`` / ``is_active``,
唯一键由 (business_id, personnel_id) 扩为
(business_id, version_no, personnel_id),编辑替换与驳回清空只置历史、
不再物理删除。
存量数据均落 ``version_no=1`` / ``is_active=1``:升级前的行就是当前生效
版本,语义不变。审批单存量快照为空,详情接口按「当前内容回退 + 显式
标注」处理,不做历史回填(回填会伪造提交时内容)。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f3a8c2d61b47"
down_revision: str | Sequence[str] | None = "90653814a421"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""加审批快照列/索引与方案版本列,替换唯一键。"""
op.execute(
"ALTER TABLE approval_orders "
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
"COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' "
"AFTER refer_no"
)
op.execute(
"ALTER TABLE approval_orders "
"ADD COLUMN snapshot_json JSON NULL "
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
"AFTER version_no"
)
op.execute(
"CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
"COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' "
"AFTER business_id"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 "
"COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' "
"AFTER ratio"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"DROP INDEX uk_business_secondary_allocations_business_personnel, "
"ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel "
"UNIQUE (business_id, version_no, personnel_id)"
)
op.execute(
"CREATE INDEX idx_business_secondary_allocations_business_active "
"ON business_secondary_allocations (business_id, is_active)"
)
op.execute(
"ALTER TABLE business_secondary_allocations COMMENT = "
"'业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)'"
)
def downgrade() -> None:
"""回退结构(降级前须确认同业务同一参与人只存在于一个版本)。"""
op.execute(
"DROP INDEX idx_business_secondary_allocations_business_active "
"ON business_secondary_allocations"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"DROP INDEX uk_business_secondary_allocations_business_version_personnel, "
"ADD CONSTRAINT uk_business_secondary_allocations_business_personnel "
"UNIQUE (business_id, personnel_id)"
)
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN is_active")
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN version_no")
op.execute("DROP INDEX idx_approval_orders_type_ref ON approval_orders")
op.execute("ALTER TABLE approval_orders DROP COLUMN snapshot_json")
op.execute("ALTER TABLE approval_orders DROP COLUMN version_no")
+8 -3
View File
@@ -30,10 +30,15 @@ _DashboardUser = Annotated[
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
def get_dashboard(
session: DbSession,
_: _DashboardUser,
current_user: _DashboardUser,
month: Annotated[
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None,
) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。"""
return ok(dashboard_service.get_dashboard(session, month))
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
"""
return ok(
dashboard_service.get_dashboard(session, month, current_user=current_user)
)
+22 -9
View File
@@ -14,10 +14,11 @@ from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.exceptions import BadRequestError
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.report import (
@@ -167,12 +168,24 @@ def export_report(
def _build_export_query(code: str, query: ExportQuery):
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。"""
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。
手工构造模型抛出的是 `pydantic.ValidationError`,不是 FastAPI 的
`RequestValidationError`——不在这里转换就会落到全局 500。导出参数
非法与查询接口一样应当是 400。
"""
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)
model = {
"personal-detail": PersonalDetailQuery,
"department-summary": DepartmentSummaryQuery,
"business-stats": BusinessStatsQuery,
"payment-stats": PaymentStatsQuery,
}[code]
try:
return model(**data)
except ValidationError as exc:
first = exc.errors()[0]
location = ".".join(str(part) for part in first.get("loc", ()))
message = first.get("msg", "参数错误")
detail = f"{location} {message}" if location else message
raise BadRequestError(f"参数错误:{detail}") from exc
+4 -1
View File
@@ -48,7 +48,10 @@ PRODUCT_CATEGORY_LABELS: dict[str, str] = {
"mixed": "混合业务",
}
REPORT_TEMPLATE_VERSION = "V1.0"
# 业务状态中文标签:导出列、筛选页眉与前端标签共用同一份映射
BUSINESS_STATUS_LABELS: dict[int, str] = {1: "进行中", 2: "已完成", 3: "已终止"}
REPORT_TEMPLATE_VERSION = "V1.2"
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出
REPORT_EXPORT_MAX_ROWS = 20000
+31 -1
View File
@@ -15,7 +15,16 @@ ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, text
from sqlalchemy import (
JSON,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
@@ -54,7 +63,17 @@ class ApprovalOrder(Base, TimestampMixin):
__tablename__ = "approval_orders"
__table_args__ = mysql_table_args(
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
# 同一引用对象的第 N 次送审在库里有唯一一行:并发提单算重版本号时
# 由 DB 兜住(应用层取号走锁读,见 approval_service._next_version)
UniqueConstraint(
"order_type",
"ref_id",
"version_no",
name="uk_approval_orders_order_type_ref_version",
),
Index("idx_approval_orders_type_status", "order_type", "status"),
# 版本号按 (order_type, ref_id) 递增,须有对应索引避免全表 max 扫描
Index("idx_approval_orders_type_ref", "order_type", "ref_id"),
)
id: Mapped[int] = mapped_column(
@@ -76,6 +95,17 @@ class ApprovalOrder(Base, TimestampMixin):
refer_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
)
version_no: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="1",
comment="提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖;"
"同一对象内唯一,由uk_approval_orders_order_type_ref_version强制)",
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON,
comment="提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)",
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
+38 -7
View File
@@ -15,7 +15,17 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
from sqlalchemy import (
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
@@ -42,27 +52,36 @@ class SecondaryAllocation(Base, TimestampMixin):
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
与比例,响应以 hint 提示合计/余额,不做强制校验
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
- **版本行 append-only**:编辑全量替换与驳回清空都把旧行置
``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由
``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照
(``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只
补齐「方案事实留存」,不改变核算口径
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
同一合同的同一参与人仅一行,重复提交在服务层聚合。
冗余固化,人离职后方案回显仍有名。uk 锁
(business_id, version_no, personnel_id),同一版本内同一参与人仅一行,
重复提交在服务层聚合;跨版本允许同一人再次出现。
"""
__tablename__ = "business_secondary_allocations"
__table_args__ = mysql_table_args(
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
"业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)",
UniqueConstraint(
"business_id",
"version_no",
"personnel_id",
name="uk_business_secondary_allocations_business_personnel",
name="uk_business_secondary_allocations_business_version_personnel",
),
Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
Index(
"idx_business_secondary_allocations_business_active", "business_id", "is_active"
),
)
id: Mapped[int] = mapped_column(
@@ -74,6 +93,12 @@ class SecondaryAllocation(Base, TimestampMixin):
nullable=False,
comment="业务ID(businesses.id;业务删除随删)",
)
version_no: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="1",
comment="方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)",
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
@@ -88,6 +113,12 @@ class SecondaryAllocation(Base, TimestampMixin):
nullable=False,
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
)
is_active: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)",
)
class Business(Base, TimestampMixin, TPlusSyncMixin):
+49 -17
View File
@@ -7,7 +7,7 @@ models/customer.py 定义)。
from datetime import date
from sqlalchemy import Row, Select, delete, exists, func, or_, select
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
from sqlalchemy.orm import Session, aliased
from app.models import (
@@ -199,43 +199,75 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
session.flush()
def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
"""二次分配方案(详情回显;空列表=未设置方案)。"""
def list_allocations(
session: Session, business_id: int, *, active_only: bool = True
) -> list[SecondaryAllocation]:
"""二次分配方案(详情回显与核算取数;默认仅当前生效版本)。"""
stmt = select(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
if active_only:
stmt = stmt.where(SecondaryAllocation.is_active == 1)
return list(
session.execute(
select(SecondaryAllocation)
.where(SecondaryAllocation.business_id == business_id)
.order_by(SecondaryAllocation.id)
stmt.order_by(SecondaryAllocation.version_no, SecondaryAllocation.id)
).scalars()
)
def list_allocation_versions(session: Session, business_id: int) -> list[SecondaryAllocation]:
"""方案全部版本行(含历史;业务详情「方案历史」用,按版本正序)。"""
return list_allocations(session, business_id, active_only=False)
def replace_allocations(
session: Session, business_id: int, rows: list[SecondaryAllocation]
) -> None:
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
"""全量替换二次分配方案:旧生效行置历史,新行按下一版本号插入。
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
不 commit(事务边界在服务层)。版本行 append-only——编辑与清空都不
物理删除,驳回后仍可回溯被否决方案的参与人与比例。空 ``rows`` 表示
只清空生效态(不产生新版本)。
"""
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
deactivate_allocations(session, business_id)
if not rows:
return
next_version = (
session.execute(
select(func.coalesce(func.max(SecondaryAllocation.version_no), 0)).where(
SecondaryAllocation.business_id == business_id
)
).scalar_one()
+ 1
)
session.flush()
for row in rows:
row.business_id = business_id
row.version_no = next_version
row.is_active = 1
session.add(row)
session.flush()
def delete_allocations(session: Session, business_id: int) -> None:
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
def deactivate_allocations(session: Session, business_id: int) -> None:
"""把业务的当前生效方案置为历史(审批驳回/编辑替换时调用;行保留)。
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
方案事实由审批单留痕与操作日志 request_data 承载。
被否决的方案不得以「当前生效行」身份继续被核算取到;``is_active=0``
后核算侧自然读不到,而历史版本仍可在业务详情回溯。不 commit。
显式声明 ``synchronize_session="fetch"``:Core UPDATE 默认走 auto 策略
(优先 evaluate,本例的条件恰好能在 Python 侧求值,故实测也会刷新在途
对象)。显式写 fetch 是为了不把「在途对象被刷新」这件事押在 evaluate
能否解析条件上——条件一旦复杂化,auto 会退化为不刷新,同一会话里已
加载的方案行就会停留在 ``is_active=1`` 的过期内存值(查询虽有
is_active 过滤,但行对象本身仍是「生效态」,被序列化或参与判断就是
错的)。
"""
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
update(SecondaryAllocation)
.where(
SecondaryAllocation.business_id == business_id,
SecondaryAllocation.is_active == 1,
)
.values(is_active=0)
.execution_options(synchronize_session="fetch")
)
session.flush()
@@ -7,8 +7,8 @@
from datetime import date, datetime
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session, defer
from app.models import (
ApprovalOrder,
@@ -66,12 +66,18 @@ def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
return dict(rows)
def receipt_trend(session: Session, months: list[str]) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。"""
def receipt_trend(
session: Session, *, start_date: date, end_date: date
) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
分组,不参与 WHERE,避免函数列导致全表扫描。
"""
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
rows = session.execute(
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
.where(month_expr.in_(months))
.where(Receipt.receipt_date >= start_date, Receipt.receipt_date < end_date)
.group_by(month_expr)
).all()
return dict(rows)
@@ -101,24 +107,36 @@ def get_month_settlement(session: Session, month: str) -> MonthlySettlement | No
# ==== 待办事项 ====
def list_pending_approvals(session: Session, *, limit: int) -> list[ApprovalOrder]:
"""待处理审批单(按提交时间升序——最久的排前面)。"""
def list_pending_approvals(
session: Session, *, statuses: tuple[int, ...], limit: int
) -> list[ApprovalOrder]:
"""待处理审批单(按提交时间升序——最久的排前面)。
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
排除 snapshot_json 列(快照可达上百 KB,待办只取单号/类型/提交人/时间)。
"""
if not statuses:
return []
return list(
session.execute(
select(ApprovalOrder)
.where(ApprovalOrder.status.in_(_PENDING_STATUSES))
.options(defer(ApprovalOrder.snapshot_json))
.where(ApprovalOrder.status.in_(statuses))
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
.limit(limit)
).scalars()
)
def count_pending_approvals(session: Session) -> int:
def count_pending_approvals(session: Session, *, statuses: tuple[int, ...]) -> int:
if not statuses:
return 0
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(ApprovalOrder.status.in_(_PENDING_STATUSES))
.where(ApprovalOrder.status.in_(statuses))
).scalar_one()
)
@@ -152,7 +170,11 @@ def count_unassigned_receipts(session: Session) -> int:
# ==== 风险预警 ====
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。"""
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
已终止业务(status=3)不再催收,排除在预警之外;业务行缺失时仍保留,
避免历史脏数据让发货单从预警中静默消失。
"""
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
return list(
session.execute(
@@ -161,7 +183,11 @@ def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> lis
Business.business_no.label("business_no"),
)
.outerjoin(Business, Business.id == Shipment.business_id)
.where(Shipment.ship_date <= cutoff, ~has_receipt)
.where(
Shipment.ship_date <= cutoff,
~has_receipt,
or_(Business.status.is_(None), Business.status != 3),
)
.order_by(Shipment.ship_date.asc())
.limit(limit)
).all()
@@ -174,7 +200,12 @@ def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
session.execute(
select(func.count())
.select_from(Shipment)
.where(Shipment.ship_date <= cutoff, ~has_receipt)
.outerjoin(Business, Business.id == Shipment.business_id)
.where(
Shipment.ship_date <= cutoff,
~has_receipt,
or_(Business.status.is_(None), Business.status != 3),
)
).scalar_one()
)
@@ -182,10 +213,14 @@ def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
def list_overdue_approvals(
session: Session, *, cutoff: datetime, limit: int
) -> list[ApprovalOrder]:
"""提交后超过阈值仍未处理的审批单(审批超时规则)。"""
"""提交后超过阈值仍未处理的审批单(审批超时规则)。
排除 snapshot_json 列(同待办:预警只用单号/类型/提交人/时间)。
"""
return list(
session.execute(
select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(
ApprovalOrder.status.in_(_PENDING_STATUSES),
ApprovalOrder.create_time < cutoff,
@@ -7,7 +7,16 @@ from app.models import MonthlySettlement, SettlementItem
from app.schemas.settlement import SettlementQuery
def get_by_id(session: Session, settlement_id: int) -> MonthlySettlement | None:
def get_by_id(
session: Session, settlement_id: int, *, for_update: bool = False
) -> MonthlySettlement | None:
"""月结行;``for_update`` 供提交审批路径加行锁用。
提交要算审批单版本号(同一月结的第几次送审),并发提交会让两次
算出同一个版本号;锁住月结行使并发提交串行化。
"""
if for_update:
return session.get(MonthlySettlement, settlement_id, with_for_update=True)
return session.get(MonthlySettlement, settlement_id)
+15 -2
View File
@@ -1,6 +1,7 @@
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -31,6 +32,7 @@ class ApprovalOrderOut(BaseModel):
order_type: int
ref_id: int
refer_no: str
version_no: int
status: int
submitter_id: int | None = None
submitter_name: str
@@ -38,9 +40,20 @@ class ApprovalOrderOut(BaseModel):
class ApprovalOrderDetailOut(ApprovalOrderOut):
"""审批单详情:附节点时间线。"""
"""审批单详情:附节点时间线与审批内容。
``content_source`` 语义:``snapshot``=提交时快照(可信);
``live``=历史单无快照、回退读当前业务内容(仅供参考);
``missing``=引用对象已不存在且无快照。
"""
steps: list[ApprovalStepOut] = Field(default_factory=list)
snapshot: dict[str, Any] | None = Field(
default=None, description="提交时内容快照(信封:schema_version/order_type/captured_at/data)"
)
content_source: Literal["snapshot", "live", "missing"] = Field(
default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失"
)
class ApprovalQuery(PageQuery):
@@ -48,7 +61,7 @@ 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="对象类型筛选")
order_type: int | None = Field(default=None, ge=1, le=3, description="对象类型筛选")
class ApprovalActionIn(BaseModel):
+12 -1
View File
@@ -180,6 +180,14 @@ class SecondaryAllocationOut(BaseModel):
ratio: str
class AllocationVersionOut(SecondaryAllocationOut):
"""方案版本行(含历史;业务详情抽屉回溯用)。"""
version_no: int
is_active: int = Field(description="1当前生效 0历史版本")
create_time: datetime
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
@@ -191,13 +199,16 @@ def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
class BusinessDetailOut(BusinessOut):
"""业务详情:附产品明细行与二次分配方案。"""
"""业务详情:附产品明细行、当前方案与方案版本历史。"""
items: list[BusinessItemOut] = Field(default_factory=list)
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
allocation_hint: str | None = Field(
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
)
allocation_history: list[AllocationVersionOut] = Field(
default_factory=list, description="方案全部版本(含历史;被驳回方案亦保留)"
)
class BusinessCreateIn(BaseModel):
+1
View File
@@ -98,6 +98,7 @@ class WarningBlock(BaseModel):
receipt_overdue_days: int = 90
approval_timeout_days: int = 3
items: list[WarningItem] = Field(default_factory=list)
total: int = Field(default=0, description="全部规则命中总数(items 仅为展示上限内的子集)")
receipt_overdue_total: int = 0
approval_timeout_total: int = 0
sync_available: bool = False
+105 -5
View File
@@ -7,10 +7,24 @@
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.month import validate_month
from app.core.response import PageQuery
def _required_month(value: str) -> str:
"""必填月份:非法直接抛 ValueError,由 FastAPI 压成 400。"""
return validate_month(value)
def _optional_month(value: str | None) -> str | None:
"""可选月份:空串规范为 None(axios 未剔除时也不会误报格式错误)。"""
if value in (None, ""):
return None
return validate_month(value)
# ==== 查询入参 ====
@@ -23,6 +37,11 @@ class PersonalDetailQuery(PageQuery):
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
role_code: str | None = Field(default=None, max_length=32, description="岗位筛选")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str | None) -> str | None:
return _optional_month(value)
class DepartmentSummaryQuery(BaseModel):
"""部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。"""
@@ -30,6 +49,11 @@ class DepartmentSummaryQuery(BaseModel):
settlement_month: str = Field(min_length=7, max_length=7, description="归属月 YYYY-MM")
manager_id: int | None = Field(default=None, ge=1, description="仅看某经理团队")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str) -> str:
return _required_month(value)
class BusinessStatsQuery(PageQuery):
"""业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。"""
@@ -51,6 +75,11 @@ class PaymentStatsQuery(BaseModel):
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str | None) -> str | None:
return _optional_month(value)
class PaymentDetailQuery(PageQuery):
"""回款区间下钻(桶编码必填,入参由 payment-stats 页面展开行携带)。"""
@@ -62,6 +91,11 @@ class PaymentDetailQuery(PageQuery):
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str | None) -> str | None:
return _optional_month(value)
# ==== 响应出参 ====
@@ -143,6 +177,13 @@ class DepartmentMemberRow(BaseModel):
personnel_name: str
role_code: str
is_probation: int
member_kind: str = Field(
default="regular",
description="行来源:regular 普通台账 / manager_personal 经理个人户 / adjustment 仅调整单",
)
unassigned_reason: str | None = Field(
default=None, description="未纳入团队基数的原因(仅未分配块)"
)
performance_sum: Decimal | None = None
payable_sum: Decimal | None = None
adjust_extra: Decimal = Field(default=Decimal("0"), description="调整单生效补发合计")
@@ -160,13 +201,26 @@ class DepartmentTeamRow(BaseModel):
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% 全公司分成(同一基数,各团队不互斥)")
team_share_amount: Decimal = Field(description="12.5% 团队分成(分列展示)")
company_share_amount: Decimal = Field(
description="单经理 5% 名义值;该团队无月结聚合行时为 0(不发放)"
)
has_aggregate: bool = Field(
default=False, description="本月是否有月结聚合行(决定是否发放 5%)"
)
aggregate_amount: Decimal = Field(
default=Decimal("0"),
description="月结聚合行应发(12.5%+5% 一次舍入);无聚合行时 0",
)
team_subtotal: Decimal = Field(
default=Decimal("0"),
description="团队应发小计 = 聚合行金额 + 调整净额",
)
company_base_ratio: Decimal = Field(description="团队基数占全公司基数比例(除零为 0)")
adjust_extra: Decimal = Decimal("0")
adjust_deduct: Decimal = Decimal("0")
adjust_net: Decimal = Decimal("0")
net_total: Decimal = Field(description="团队净额 = 分成 + 调整净额")
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)
@@ -178,7 +232,50 @@ class DepartmentCompanyBlock(BaseModel):
team_base_total: Decimal = Field(description="各团队基数之和(= 全部单笔绩效合计)")
company_base: Decimal = Field(description="全公司基数(5% 的底)")
company_share_pct: str = Field(description="分成比例参数原文")
company_share_amount: Decimal = Field(description="5% 全公司分成")
company_share_amount: Decimal = Field(description="单经理 5% 名义值")
company_share_count: int = Field(
default=0, description="计入应发的经理数(展示团队中有月结聚合行的数量)"
)
company_share_total: Decimal = Field(
default=Decimal("0"), description="5% 合计 = 单份 × 计入次数"
)
class DepartmentReconciliation(BaseModel):
"""部门汇总 · 对账块(导出/打印签字稿的合计口径,前端不自行求和)。
应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
聚合行金额与月结 ``total_payable`` 同口径(12.5%+5% 一次舍入);
``team_share_total`` 与 ``company_share_total`` 仅作分列展示,两者
之和与聚合额的差记入 ``aggregate_rounding_diff``。
"""
team_member_payable_total: Decimal = Field(description="展示团队成员的应付绩效合计")
unassigned_payable_total: Decimal = Field(description="展示未分配人员的应付绩效合计")
team_share_total: Decimal = Field(description="展示团队的 12.5% 分成合计(分列)")
company_share_total: Decimal = Field(
description="展示团队的全公司分成合计(每位有聚合行的经理各计一次,分列)"
)
aggregate_payable_total: Decimal = Field(
description="展示团队的经理聚合行应发合计(12.5%+5% 一次舍入)"
)
aggregate_rounding_diff: Decimal = Field(
default=Decimal("0"),
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
)
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
payable_total: Decimal = Field(description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额")
team_base_total: Decimal = Field(description="展示团队基数合计")
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
other_team_base: Decimal = Field(
default=Decimal("0"), description="未展示的基数(其他团队 + 全公司未分配)"
)
other_team_member_payable_total: Decimal = Field(default=Decimal("0"))
other_team_share_total: Decimal = Field(default=Decimal("0"))
other_team_company_share_total: Decimal = Field(default=Decimal("0"))
other_team_aggregate_total: Decimal = Field(default=Decimal("0"))
other_adjust_net_total: Decimal = Field(default=Decimal("0"))
company_base: Decimal = Field(description="全公司基数(5% 的底)")
class DepartmentSummaryOut(BaseModel):
@@ -191,6 +288,9 @@ class DepartmentSummaryOut(BaseModel):
default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)"
)
company: DepartmentCompanyBlock | None = None
reconciliation: DepartmentReconciliation | None = Field(
default=None, description="签字稿对账块(应发公式各项)"
)
totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)")
+8 -7
View File
@@ -1,21 +1,17 @@
"""月度结算的请求/响应模型(M3 ④)。"""
import re
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.month import validate_month
from app.core.response import PageQuery
_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
"""月份格式 YYYY-MM 且月份 01-12(settlement_month 全表唯一的维度)。"""
return validate_month(value)
class SettlementGenerateIn(BaseModel):
@@ -93,3 +89,8 @@ class AdjustmentCreateIn(BaseModel):
description="金额(元,两位小数,恒正)",
)
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
@field_validator("settlement_month")
@classmethod
def _v_settlement_month(cls, value: str) -> str:
return _check_month(value)
+223 -11
View File
@@ -19,22 +19,25 @@
"""
import logging
from datetime import date
from collections.abc import Iterable
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, defer
from app.core.constants import (
ACTION_APPROVE,
ACTION_REJECT,
MODULE_APPROVAL,
NO_PREFIX_APPROVAL,
ROLE_ADMIN,
ROLE_FINANCE,
ROLE_LEADER,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import ApprovalOrder, ApprovalStep, User
from app.models import ApprovalOrder, ApprovalStep, Business, User
from app.models.approval import (
APPROVAL_ACTION_APPROVE,
APPROVAL_ACTION_AUTO_APPROVE,
@@ -84,6 +87,18 @@ _NODE_ROLE_BY_STATUS = {
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
"""工作台「待我审批」:按当前用户角色映射在途审批状态。
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
的可见性一致);财务/领导只看到本节点可操作的条目。
"""
codes = set(role_codes)
if ROLE_ADMIN in codes:
return _PENDING_STATUSES
return tuple(status for status, role in _NODE_ROLE_BY_STATUS.items() if role in codes)
def is_enabled(session: Session, order_type: int) -> bool:
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
code = ENABLE_CODE_BY_TYPE.get(order_type)
@@ -99,6 +114,7 @@ def submit_order(
ref_id: int,
refer_no: str,
submitter: User,
snapshot: dict | None = None,
) -> ApprovalOrder:
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
@@ -108,6 +124,10 @@ def submit_order(
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
``snapshot`` 是「提交时」的业务内容(由调用方组装,Decimal 已转 str);
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
"""
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
@@ -121,6 +141,17 @@ def submit_order(
order_type=order_type,
ref_id=ref_id,
refer_no=refer_no,
version_no=_next_version(session, order_type, ref_id),
snapshot_json=(
{
"schema_version": 1,
"order_type": order_type,
"captured_at": datetime.now().isoformat(timespec="seconds"),
"data": snapshot,
}
if snapshot is not None
else None
),
submitter_id=submitter.id,
submitter_name=submitter.username,
)
@@ -151,6 +182,35 @@ def submit_order(
return order
def pending_matches_snapshot(
session: Session, *, order_type: int, ref_id: int, snapshot: dict | None
) -> bool:
"""在途审批单的送审内容是否与给定快照一致(用于「内容未变不重提」)。
只比较快照信封里的 ``data`` 部分——``captured_at`` 每次采集都不同,
参与比较会让判断恒为不一致。V11 之前的历史单没有快照,一律视为不一致
(保守重提:宁可多提一次,不能漏审)。
"""
if snapshot is None:
return False
pending = (
session.execute(
select(ApprovalOrder).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
for order in pending:
stored = order.snapshot_json or {}
if stored.get("data") == snapshot:
return True
return False
def withdraw_pending_orders(
session: Session, *, order_type: int, ref_id: int, operator: User
) -> int:
@@ -236,15 +296,15 @@ def act(
operator_name=operator.username,
)
)
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
# 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
# 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
# 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
# 委托 business_repository 完成 —— service 层互调、不触 commit。
# 驳回=方案未获批准:把该业务的生效方案行置历史(is_active=0,
# **不删行**——V11 起 append-only 版本化),防止被否决的方案以
# 「当前生效行」身份被 M3 核算快照算进工资;历史方案仍可在业务
# 详情 allocation_history 回溯。委托 business_repository 完成
# —— service 层互调、不触 commit。
if rejected and order.order_type == 1:
from app.repositories import business_repository
business_repository.delete_allocations(session, order.ref_id)
business_repository.deactivate_allocations(session, order.ref_id)
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
@@ -285,11 +345,18 @@ def act(
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
"""分页查询审批单(准入由 api 层依赖判定)。"""
"""分页查询审批单(准入由 api 层依赖判定)。
排除 snapshot_json 列(月结快照含按人明细,可达数十~上百 KB):列表出参
本就不含快照,整列加载会让翻页负担随快照体积线性放大;详情走
`get_order` 按需读取(与操作日志列表排除 request_data 同一理由,
见 operation_log_repository.list_logs)。
"""
filters = _order_filters(query)
rows = (
session.execute(
select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(*filters)
.order_by(ApprovalOrder.id.desc())
.offset(query.offset)
@@ -346,8 +413,152 @@ def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operato
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
def _next_version(session: Session, order_type: int, ref_id: int) -> int:
"""同一对象类型+引用对象的下一个提交版本号(从 1 递增)。
**必须用锁读(当前读),不能用普通 MAX 查询**:普通 SELECT 在
REPEATABLE READ 下是快照读,读到的是本事务首次一致读的快照;请求级
鉴权(deps.get_current_user 与业务共用同一 DbSession)那次 SELECT 已把
快照锚定在请求开始时刻,此后即使父行锁等到并发事务提交,MAX 仍停在
旧快照上——两个请求会算出同一个 version_no。锁读是当前读,永远读最新
已提交数据。
**不能写成 select(func.max(...)).with_for_update()**:MySQL 官方明确
「Locking clauses are not permitted in queries with aggregate functions
or GROUP BY」,聚合结果上的 FOR UPDATE 语义无保证。改锁「该 ref 的现有
版本行」,最大值在 Python 侧算——同 no_generator 的既有套路。只取
version_no 单列,不把 snapshot_json 拉进内存。
"""
versions = (
session.execute(
select(ApprovalOrder.version_no)
.where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
)
.with_for_update()
)
.scalars()
.all()
)
return (max(versions) if versions else 0) + 1
def allocation_content(session: Session, business: Business) -> dict:
"""二次分配方案的审批内容(提单快照与历史单回退共用同一结构)。
业务主行已存在但客户联查落空(客户被删等异常)时返回空字典——调用方
以 ``or None`` 归一,避免生成「有快照但内容为空」的信封。
"""
from app.repositories import business_repository
row = business_repository.get_with_names(session, business.id)
if row is None:
return {}
_, customer_name, owner_name, assistant_name = row
allocations = business_repository.list_allocations(session, business.id)
total = sum((a.ratio for a in allocations), Decimal("0"))
return {
"business_no": business.business_no,
"business_date": business.business_date.isoformat() if business.business_date else None,
"customer_name": customer_name,
"owner_personnel_name": owner_name,
"assistant_personnel_name": assistant_name,
"sale_amount": _amount_text(business.total_sale_amount),
"cost_amount": _amount_text(business.total_cost_amount),
"allocations": [
{
"personnel_id": a.personnel_id,
"personnel_name": a.personnel_name,
"ratio": str(a.ratio),
}
for a in allocations
],
"allocation_total": str(total.quantize(Decimal("0.01"))),
"allocation_balance": str((Decimal("100") - total).quantize(Decimal("0.01"))),
}
def _amount_text(value: Decimal | None) -> str | None:
"""金额文本化(Decimal 全程不转 float;None 保留为空,不伪造 0.00)。"""
return str(value) if value is not None else None
def settlement_content(session: Session, settlement_id: int) -> dict | None:
"""月结审批内容(汇总 + 按人明细;提单快照与历史单回退共用)。"""
from app.models import MonthlySettlement
from app.repositories import settlement_repository
settlement = session.get(MonthlySettlement, settlement_id)
if settlement is None:
return None
items = settlement_repository.list_items(session, settlement_id)
return {
"settlement_month": settlement.settlement_month,
"total_performance": _amount_text(settlement.total_performance),
"total_payable": _amount_text(settlement.total_payable),
"person_count": settlement.person_count,
"items": [
{
"personnel_name": item.personnel_name,
"role_code": item.role_code,
"calc_type": item.calc_type,
"performance_sum": str(item.performance_sum),
"payable_sum": str(item.payable_sum),
"basic_amount": _amount_text(item.basic_amount),
"salary_mode": item.salary_mode,
"net_amount": _amount_text(item.net_amount),
"is_probation": item.is_probation,
}
for item in items
],
}
def adjustment_content(session: Session, adjustment_id: int) -> dict | None:
"""调整单审批内容(提单快照与历史单回退共用)。"""
from app.models import AdjustmentOrder
adjustment = session.get(AdjustmentOrder, adjustment_id)
if adjustment is None:
return None
return {
"order_no": adjustment.order_no,
"settlement_month": adjustment.settlement_month,
"personnel_name": adjustment.personnel_name,
"direction": adjustment.direction,
"amount": str(adjustment.amount),
"reason": adjustment.reason,
}
def _content_of(session: Session, order: ApprovalOrder) -> tuple[dict | None, str]:
"""审批内容出参:优先提交时快照,历史单回退当前内容并显式标注来源。"""
if order.snapshot_json:
return order.snapshot_json, "snapshot"
if order.order_type == 1:
business = session.get(Business, order.ref_id)
data = allocation_content(session, business) if business is not None else None
elif order.order_type == 2:
data = settlement_content(session, order.ref_id)
elif order.order_type == 3:
data = adjustment_content(session, order.ref_id)
else:
data = None
if not data:
return None, "missing"
return {
"schema_version": 1,
"order_type": order.order_type,
"captured_at": None,
"data": data,
}, "live"
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
"""出参组装(含 steps 时间线与审批内容)。调用方须保证处于 commit 之前。"""
steps = (
session.execute(
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
@@ -357,6 +568,7 @@ def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut
)
detail = ApprovalOrderDetailOut.model_validate(order)
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
detail.snapshot, detail.content_source = _content_of(session, order)
return detail
+65 -16
View File
@@ -54,6 +54,7 @@ from app.models.business import (
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import business_repository, customer_repository, personnel_repository
from app.schemas.business import (
AllocationVersionOut,
BusinessCreateIn,
BusinessDetailOut,
BusinessItemIn,
@@ -281,6 +282,17 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
for a in allocations
]
out.allocation_hint = _allocation_hint(allocations)
out.allocation_history = [
AllocationVersionOut(
personnel_id=a.personnel_id,
personnel_name=a.personnel_name,
ratio=str(a.ratio),
version_no=a.version_no,
is_active=a.is_active,
create_time=a.create_time,
)
for a in business_repository.list_allocation_versions(session, business_id)
]
return out
@@ -335,6 +347,20 @@ def _build_allocation_rows(
return rows
def _plan_changed(old_rows: list[SecondaryAllocation], new_rows: list[SecondaryAllocation]) -> bool:
"""方案内容(参与人 + 比例)是否真的变了。
比较口径是**集合**(人员 ID 与其比例的组合),与提交顺序无关——送审
快照同样按库内生效行的顺序生成,若按顺序比较,用户只是把行拖了个
序就会被判成「变了」,进而无谓地新增版本与重提审批单。
"""
def _key(row: SecondaryAllocation) -> tuple[int, Decimal]:
return (row.personnel_id, Decimal(row.ratio).quantize(Decimal("0.01")))
return {_key(r) for r in old_rows} != {_key(r) for r in new_rows}
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
@@ -433,6 +459,7 @@ def create_business(
ref_id=business.id,
refer_no=business.business_no,
submitter=operator,
snapshot=approval_service.allocation_content(session, business) or None,
)
customer = customer_repository.get_by_id(session, business.customer_id)
out = _to_out((business, customer.company_name if customer else None, None, None))
@@ -507,28 +534,51 @@ def update_business(
business_repository.replace_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(
# 方案:内容真的变了才动版本行(内容未变不新增版本,避免「只改备注」
# 也堆出一个方案版本);空数组=清空=把生效行置历史
old_allocations = business_repository.list_allocations(session, business_id)
new_rows = _build_allocation_rows(
session,
business_id,
_build_allocation_rows(
session, business_id, payload.secondary_allocations, old_ids=old_ids
),
payload.secondary_allocations,
old_ids={a.personnel_id for a in old_allocations},
)
# 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
# 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
if not payload.secondary_allocations:
business_repository.replace_allocations(session, business_id, [])
elif _plan_changed(old_allocations, new_rows):
business_repository.replace_allocations(session, business_id, new_rows)
# 汇总先重算:送审快照里的销售额/成本额必须反映本次编辑后的值,
# 放到提单之后会固化成上一版本的金额
_recalc_totals(session, business)
# 提单/撤单与方案同步生命周期:
# - 方案为空 → 仅撤回在途单(旧在途单挂着已不存在的方案继续走审批)
# - 送审内容与在途单一致 → 保留原单,不撤回不重建(避免反复保存
# 刷出「撤回+新建」的审批留痕噪音)
# - 其余 → 提新单(内含撤旧建新)
if payload.secondary_allocations:
approval_service.submit_order(
snapshot = approval_service.allocation_content(session, business) or None
if not approval_service.pending_matches_snapshot(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
refer_no=business.business_no,
submitter=operator,
)
snapshot=snapshot,
):
try:
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
refer_no=business.business_no,
submitter=operator,
snapshot=snapshot,
)
except IntegrityError as exc:
# version_no 唯一键的并发冲突(取号是锁读后此项理论不可达,
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062
# 翻译成可重试的业务提示,其余完整性错误原样上抛
if _is_duplicate_entry(exc):
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
raise
else:
approval_service.withdraw_pending_orders(
session,
@@ -536,7 +586,6 @@ def update_business(
ref_id=business_id,
operator=operator,
)
_recalc_totals(session, business)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
out = _to_out((business, customer.company_name, owner_name, assistant_name))
+49 -16
View File
@@ -18,8 +18,9 @@ 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 import dashboard_repository, operation_log_repository, user_repository
from app.repositories.query_utils import LogFilters
from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5
@@ -30,14 +31,21 @@ _TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。"""
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。
"""
resolved = _normalize_month(month)
warning_params = _warning_params(session)
pending_statuses = approval_service.pending_statuses_for_roles(
user_repository.get_role_codes(current_user)
)
return {
"month": resolved,
"overview": _overview(session, resolved),
"todos": _todos(session),
"todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params),
"activities": _activities(session),
}
@@ -61,6 +69,11 @@ def _shift_month(month: str, delta: int) -> str:
return f"{index // 12:04d}-{index % 12 + 1:02d}"
def _month_start(month: str) -> date:
"""YYYY-MM → 该月首日(范围查询边界,避免函数列过滤)。"""
return date(int(month[:4]), int(month[5:7]), 1)
def _trend_months(month: str) -> list[str]:
"""窗口为「当月及之前共 6 个月」,升序返回便于前端画图。"""
return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)]
@@ -72,7 +85,11 @@ def _overview(session: Session, month: str) -> dict:
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)
receipts = dashboard_repository.receipt_trend(
session,
start_date=_month_start(months[0]),
end_date=_month_start(_shift_month(months[-1], 1)),
)
return {
"month": month,
"performance_total": _dec(sums["performance_total"]),
@@ -94,16 +111,27 @@ def _overview(session: Session, month: str) -> dict:
}
for item in months
],
"note": "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月(含未核算回款);不含调整单",
"note": (
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
),
}
def _todos(session: Session) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。"""
orders = dashboard_repository.list_pending_approvals(session, limit=_TODO_LIMIT)
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
领导只看待领导审核;admin 查看全部在途单。
"""
orders = dashboard_repository.list_pending_approvals(
session, statuses=pending_statuses, limit=_TODO_LIMIT
)
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
return {
"pending_approval_total": dashboard_repository.count_pending_approvals(session),
"pending_approval_total": dashboard_repository.count_pending_approvals(
session, statuses=pending_statuses
),
"pending_approvals": [
{
"order_id": order.id,
@@ -154,6 +182,7 @@ def _warnings(session: Session, params: dict) -> dict:
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": [],
"total": 0,
"receipt_overdue_total": 0,
"approval_timeout_total": 0,
"sync_available": False,
@@ -201,17 +230,21 @@ def _warnings(session: Session, params: dict) -> dict:
}
for order in orders
]
receipt_overdue_total = dashboard_repository.count_overdue_shipments(
session, cutoff=receipt_cutoff
)
approval_timeout_total = dashboard_repository.count_overdue_approvals(
session, cutoff=approval_cutoff
)
return {
"enabled": True,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": items,
"receipt_overdue_total": dashboard_repository.count_overdue_shipments(
session, cutoff=receipt_cutoff
),
"approval_timeout_total": dashboard_repository.count_overdue_approvals(
session, cutoff=approval_cutoff
),
# items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数
"total": receipt_overdue_total + approval_timeout_total,
"receipt_overdue_total": receipt_overdue_total,
"approval_timeout_total": approval_timeout_total,
"sync_available": False,
}
+7 -1
View File
@@ -14,6 +14,8 @@ from fractions import Fraction
from sqlalchemy.orm import Session
from app.core.exceptions import BadRequestError
from app.core.month import parse_month
from app.models import Personnel
@@ -38,7 +40,11 @@ def month_end(month: str) -> date:
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
更细的业务日期,取该月最后可确定的生效时点。
"""
year, mon = int(month[:4]), int(month[5:7])
try:
year, mon = parse_month(month)
except ValueError as exc:
# schema 已拦住非法月份;内部调用绕过 schema 时也不能变成 500
raise BadRequestError(str(exc)) from exc
return date(year, mon, monthrange(year, mon)[1])
+372 -24
View File
@@ -19,7 +19,11 @@ 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
from app.core.report_meta import (
BUSINESS_STATUS_LABELS,
CUSTOMER_TYPE_LABELS,
PRODUCT_CATEGORY_LABELS,
)
_HEADER_FILL = PatternFill("solid", fgColor="DDE3F0")
_THIN = Side(style="thin", color="B0B7C3")
@@ -31,7 +35,6 @@ _AMOUNT_FORMAT = "#,##0.00"
_DATE_FORMAT = "YYYY-MM-DD"
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
BUSINESS_STATUS_LABELS = {1: "进行中", 2: "已完成", 3: "已终止"}
@dataclass(frozen=True, slots=True)
@@ -44,6 +47,17 @@ class _Column:
width: float
@dataclass(frozen=True, slots=True)
class _Block:
"""签字稿中的一个区块:独立表头、数据行与可选合计行。"""
title: str
columns: tuple[_Column, ...]
rows: list[dict]
total: dict | None = None
empty_text: str = "(无)"
_COLUMNS: dict[str, tuple[_Column, ...]] = {
"personal-detail": (
_Column("business_no", "合同编号", "text", 20),
@@ -101,11 +115,30 @@ def build_workbook(
template_version: str,
operator_name: str,
company_name: str,
department: dict | None = None,
) -> bytes:
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。"""
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。
部门汇总是签字对账文件,走单 sheet 分块版式;其余报表仍是单表 +
合计行。`department` 为 `department_summary` 的完整出参。
"""
workbook = Workbook()
sheet = workbook.active
sheet.title = _sheet_name(code)
if code == "department-summary" and department is not None:
_write_department_document(
sheet,
department,
company_name=company_name,
title=title,
filters=filters,
template_version=template_version,
operator_name=operator_name,
)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
columns = _COLUMNS[code]
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
if rows:
@@ -129,6 +162,7 @@ def build_workbook(
extra,
dim_title,
company_name,
filters,
buckets.get(dim_key) or [],
template_version,
operator_name,
@@ -158,26 +192,15 @@ def _write_header(
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}"
]
_write_document_header(
sheet,
len(columns),
company_name,
title,
filters,
template_version,
operator_name,
)
_merge_row(sheet, len(columns))
sheet.append([])
sheet.append([column.title for column in columns])
header_row = sheet.max_row
@@ -189,6 +212,38 @@ def _write_header(
sheet.freeze_panes = sheet.cell(row=header_row + 1, column=1)
def _write_document_header(
sheet,
column_count: int,
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""公司名/标题/筛选条件/导出信息四行(分块文档与单表共用)。"""
sheet.append([company_name])
_merge_row(sheet, column_count)
sheet.cell(row=1, column=1).font = _COMPANY_FONT
sheet.cell(row=1, column=1).alignment = Alignment(horizontal="left", vertical="center")
sheet.append([title])
_merge_row(sheet, column_count)
sheet.cell(row=2, column=1).font = _TITLE_FONT
sheet.cell(row=2, column=1).alignment = Alignment(horizontal="center", vertical="center")
sheet.append([f"筛选条件:{filters}"])
_merge_row(sheet, column_count)
sheet.append(
[
f"导出时间:{datetime.now():%Y-%m-%d %H:%M:%S} | 导出人:{operator_name} | "
f"模板版本:{template_version}"
]
)
_merge_row(sheet, column_count)
sheet.append([])
def _merge_row(sheet, column_count: int) -> None:
"""合并当前行(公司名/标题/筛选行跨全表宽)。"""
row = sheet.max_row
@@ -289,11 +344,12 @@ def _write_dimension_sheet(
sheet,
title: str,
company_name: str,
filters: str,
rows: list[dict],
template_version: str,
operator_name: str,
) -> None:
"""维度对比 sheet(客户类型/产品类别)。"""
"""维度对比 sheet(客户类型/产品类别),页眉筛选条件与主表一致。"""
columns = (
_Column("label", "维度", "text", 16),
_Column("receipt_amount", "回款金额", "amount", 16),
@@ -301,9 +357,301 @@ def _write_dimension_sheet(
_Column("payable_amount", "应付绩效", "amount", 14),
_Column("ledger_count", "台账行数", "int", 10),
)
_write_header(sheet, columns, company_name, title, "全部", template_version, operator_name)
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
for row in rows:
_write_row(sheet, columns, row)
_write_total(sheet, columns, rows)
_write_signature(sheet, len(columns))
_autofit(sheet, columns)
# ==== 部门汇总签字稿(FR-030 / FR-032)====
_MEMBER_KIND_LABELS = {
"regular": "普通绩效",
"manager_personal": "经理个人户",
"adjustment": "仅调整单",
}
# 各块列数不同,统一按列号取最大宽度(签字稿打印时不挤在一起)
_DEPARTMENT_WIDTHS = (30, 16, 22, 13, 13, 15, 18, 14, 14, 13)
def _write_department_document(
sheet,
data: dict,
*,
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""部门汇总签字稿:团队分成 → 全公司 5% → 对账 → 成员 → 未分配 → 签字栏。
全公司 5% 每位有月结聚合行的经理各计一次,合计在第二块展示;
团队块的调整净额仅供成员汇总核对。应发合计由后端 `reconciliation`
按聚合行口径算好(含分列舍入差),本函数只渲染。
"""
params = data.get("params") or {}
team_text = params.get("team_text", "")
company_text = params.get("company_text", "")
teams = data.get("teams") or []
unassigned = data.get("unassigned") or []
company = data.get("company") or {}
reconciliation = data.get("reconciliation") or {}
company_share_count = int(company.get("company_share_count") or 0)
_write_document_header(
sheet, len(_DEPARTMENT_WIDTHS), company_name, title, filters, template_version, operator_name
)
_write_block(
sheet,
_Block(
"一、团队分成汇总",
(
_Column("seq", "序号", "int", 6),
_Column("manager_name", "经理团队", "text", 16),
_Column("manager_status_text", "状态", "text", 8),
_Column("member_count", "团队人数", "int", 9),
_Column("team_base", "团队基数", "amount", 13),
_Column("team_share_amount", f"团队分成({team_text}%)", "amount", 15),
_Column("adjust_net", "调整净额(仅核对)", "amount", 16),
_Column("team_subtotal", "团队应发小计(含聚合分成)", "amount", 18),
_Column("frozen_amount", "月结冻结额", "amount", 14),
),
[
{
"seq": index,
"manager_name": team["manager_name"],
"manager_status_text": "在职" if team["manager_status"] == 1 else "离职",
"member_count": team["member_count"],
"team_base": team["team_base"],
"team_share_amount": team["team_share_amount"],
"adjust_net": team["adjust_net"],
"team_subtotal": team["team_subtotal"],
"frozen_amount": team["frozen_amount"],
}
for index, team in enumerate(teams, start=1)
],
{
"manager_name": "合计",
"member_count": sum(team["member_count"] for team in teams),
"team_base": _sum_amount(team["team_base"] for team in teams),
"team_share_amount": _sum_amount(
team["team_share_amount"] for team in teams
),
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
"frozen_amount": (
_sum_amount(
team["frozen_amount"]
for team in teams
if team["frozen_amount"] is not None
)
if any(team["frozen_amount"] is not None for team in teams)
else None
),
},
"(无团队)",
),
)
_write_block(
sheet,
_Block(
f"二、全公司分成(每位有聚合行的经理各计一次,共 {company_share_count} 次)",
(
_Column("company_base", "全公司基数", "amount", 16),
_Column("company_share_pct", "分成比例", "text", 12),
_Column("company_share_amount", "单经理分成", "amount", 13),
_Column("company_share_count", "计入次数", "int", 10),
_Column("company_share_total", "分成合计", "amount", 14),
),
[
{
"company_base": company.get("company_base"),
"company_share_pct": f"{company_text}%",
"company_share_amount": company.get("company_share_amount"),
"company_share_count": company_share_count,
"company_share_total": company.get("company_share_total"),
}
],
),
)
_write_block(
sheet,
_Block(
"三、对账汇总",
(
_Column("item", "项目", "text", 30),
_Column("amount", "金额", "amount", 16),
_Column("note", "说明", "text", 40),
),
[
{
"item": "团队成员应付合计",
"amount": reconciliation.get("team_member_payable_total"),
},
{
"item": "未分配人员应付合计",
"amount": reconciliation.get("unassigned_payable_total"),
},
{"item": "团队分成合计(分列)", "amount": reconciliation.get("team_share_total")},
{
"item": f"全公司分成合计({company_share_count} 次,分列)",
"amount": reconciliation.get("company_share_total"),
"note": "每位有聚合行的经理各计一次",
},
{
"item": "聚合行舍入差",
"amount": reconciliation.get("aggregate_rounding_diff"),
"note": "分列舍入与聚合行一次舍入的差",
},
{
"item": "调整净额合计(明细层)",
"amount": reconciliation.get("adjust_net_total"),
},
{
"item": "应发合计",
"amount": reconciliation.get("payable_total"),
"note": "上列各项之和",
},
{"item": "团队基数合计", "amount": reconciliation.get("team_base_total")},
{
"item": "未分配绩效合计",
"amount": reconciliation.get("unassigned_performance_total"),
},
{
"item": "其他未展示基数",
"amount": reconciliation.get("other_team_base"),
"note": "筛选时含其他团队与全公司未分配",
},
{"item": "全公司基数", "amount": reconciliation.get("company_base")},
],
),
)
member_columns = (
_Column("manager_name", "经理团队", "text", 15),
_Column("personnel_name", "成员", "text", 12),
_Column("role_code", "岗位", "text", 10),
_Column("member_kind_text", "来源", "text", 13),
_Column("performance_sum", "绩效金额", "amount", 13),
_Column("payable_sum", "应付绩效", "amount", 13),
_Column("adjust_extra", "调整补发", "amount", 12),
_Column("adjust_deduct", "调整扣减", "amount", 12),
_Column("adjust_net", "调整净额", "amount", 12),
_Column("net_total", "净合计", "amount", 13),
)
member_rows = [
{
"manager_name": team["manager_name"],
"personnel_name": member["personnel_name"],
"role_code": member["role_code"],
"member_kind_text": _MEMBER_KIND_LABELS.get(
member["member_kind"], member["member_kind"]
),
"performance_sum": member["performance_sum"],
"payable_sum": member["payable_sum"],
"adjust_extra": member["adjust_extra"],
"adjust_deduct": member["adjust_deduct"],
"adjust_net": member["adjust_net"],
"net_total": member["net_total"],
}
for team in teams
for member in team["members"]
]
_write_block(
sheet,
_Block("四、团队成员明细", member_columns, member_rows, empty_text="(无成员)"),
sum_amounts=True,
)
unassigned_columns = (
_Column("personnel_name", "人员", "text", 14),
_Column("role_code", "岗位", "text", 10),
_Column("unassigned_reason", "未纳入原因", "text", 24),
_Column("performance_sum", "绩效金额", "amount", 13),
_Column("payable_sum", "应付绩效", "amount", 13),
_Column("adjust_extra", "调整补发", "amount", 12),
_Column("adjust_deduct", "调整扣减", "amount", 12),
_Column("adjust_net", "调整净额", "amount", 12),
_Column("net_total", "净合计", "amount", 13),
)
unassigned_rows = [
{
"personnel_name": row["personnel_name"],
"role_code": row["role_code"],
"unassigned_reason": row.get("unassigned_reason") or "",
"performance_sum": row["performance_sum"],
"payable_sum": row["payable_sum"],
"adjust_extra": row["adjust_extra"],
"adjust_deduct": row["adjust_deduct"],
"adjust_net": row["adjust_net"],
"net_total": row["net_total"],
}
for row in unassigned
]
_write_block(
sheet,
_Block("五、未分配人员明细", unassigned_columns, unassigned_rows),
sum_amounts=True,
)
_write_signature(sheet, len(_DEPARTMENT_WIDTHS))
for index, width in enumerate(_DEPARTMENT_WIDTHS, start=1):
sheet.column_dimensions[get_column_letter(index)].width = width
def _write_block(sheet, block: _Block, *, sum_amounts: bool = False) -> None:
"""写一个区块:标题、表头、数据/空提示,以及自定义或自动合计。"""
sheet.append([block.title])
title_row = sheet.max_row
_merge_row(sheet, len(block.columns))
sheet.cell(row=title_row, column=1).font = _HEADER_FONT
sheet.append([column.title for column in block.columns])
header_row = sheet.max_row
for cell in sheet[header_row]:
cell.font = _HEADER_FONT
cell.fill = _HEADER_FILL
cell.border = _BORDER
cell.alignment = Alignment(horizontal="center", vertical="center")
if block.rows:
for row in block.rows:
_write_row(sheet, block.columns, row)
else:
sheet.append([block.empty_text])
_merge_row(sheet, len(block.columns))
if block.total is not None:
_write_custom_total(sheet, block.columns, block.total)
elif sum_amounts:
_write_total(sheet, block.columns, block.rows)
sheet.append([])
def _write_custom_total(sheet, columns: tuple[_Column, ...], total: dict) -> None:
"""写后端预计算的合计行(不自动求和,避免重复计入共享基数)。"""
values = []
for column in columns:
raw = total.get(column.key)
values.append(_cell_value(column, raw) if raw is not None else None)
sheet.append(values)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.font = _HEADER_FONT
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
def _sum_amount(values) -> Decimal:
"""金额列求和后两位收口(None 跳过)。"""
return sum((value for value in values if value is not None), Decimal("0")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
+579 -127
View File
@@ -24,11 +24,14 @@ 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 (
BUSINESS_STATUS_LABELS,
CUSTOMER_TYPE_LABELS,
PRODUCT_CATEGORY_LABELS,
REPORT_EXPORT_MAX_ROWS,
REPORT_TEMPLATE_VERSION,
DayBucket,
)
from app.models import BusinessRole, PerformanceLedger, Personnel, User
from app.models import BusinessRole, Customer, 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
@@ -124,12 +127,19 @@ def _q2(value: Decimal | None) -> Decimal | None:
def department_summary(session, query) -> dict:
"""按经理团队/月份汇总绩效工资(团队基数与月结聚合行同源)。"""
"""按经理团队/月份汇总绩效工资(团队基数与月结聚合行同源)。
人员归属按「行类型」而不是「人」划分:普通台账行跟随 ``manager_id``,
经理个人户行按开关归经理本人——与 ``collect_team_buckets`` 完全同口径。
调整单是人的属性,优先跟随其普通行归属;只有经理个人户的经理跟随
本人团队。每个人/每类金额在团队与未分配之间只出现一次。
"""
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)
include_mp = share.include_manager_personal
buckets = manager_share.collect_team_buckets(session, rows, include_mp)
company_base = manager_share.company_base_of(rows, include_mp)
aggregates = {
row.personnel_id: row
@@ -137,70 +147,235 @@ def department_summary(session, query) -> dict:
}
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)
# 先构建全部团队再按筛选展示:other_*(隐藏团队金额)需要完整集合,
# 只按 query.manager_id 枚举会让筛选态的 other_* 恒为 0
manager_ids = _manager_ids(session, buckets, None)
if query.manager_id is not None and query.manager_id not in manager_ids:
manager_ids = sorted(set(manager_ids) | {query.manager_id})
# 岗位判定范围必须覆盖「无成员的经理」与调整单人员——只按 member_map 取
# 会让零行团队被跳过,也会让其他经理的下属被误判为无经理
manager_of, manager_role, person_info = _team_relations(
session, member_map, manager_ids, adjustments
)
# 只有调整单、本月无台账行的人:补成零值成员后参与归属
_merge_adjustment_only_members(member_map, adjustments, person_info)
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,
}
)
context = {
"aggregates": aggregates,
"buckets": buckets,
"member_map": member_map,
"manager_of": manager_of,
"manager_role": manager_role,
"adjustments": adjustments,
"include_mp": include_mp,
"share": share,
"company_base": company_base,
}
teams_all = _build_teams(session, manager_ids, context)
if query.manager_id is None:
# 离职经理本月无台账、但其下属只有调整单时,团队仍必须展示,
# 否则调整金额会从团队与未分配同时消失
extra_ids = {
target
for pid in adjustments
if (
target := _adjustment_target(
pid, member_map, manager_of, manager_role, include_mp
)
)
is not None
} - set(manager_ids)
if extra_ids:
teams_all.extend(_build_teams(session, sorted(extra_ids), context))
unassigned = _unassigned_rows(member_map, manager_of, manager_role, adjustments)
teams = [
team
for team in teams_all
if query.manager_id is None or team["manager_id"] == query.manager_id
]
unassigned_all = _unassigned_rows(
member_map, manager_of, manager_role, adjustments, include_mp
)
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
unassigned = [] if query.manager_id is not None else unassigned_all
company_share_amount = _company_share(company_base, share.company_pct)
company_share_count = sum(1 for team in teams if team["has_aggregate"])
reconciliation = _reconciliation(teams, teams_all, unassigned, company_base)
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,
"manager_personal_included": 1 if include_mp 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"))),
"team_base_total": reconciliation["team_base_total"],
"company_base": _q2(company_base),
"company_share_pct": share.company_text,
"company_share_amount": _q2(_company_share(company_base, share.company_pct)),
# 单经理名义值;结算总额见 company_share_total(每位有聚合行的经理各计一次)
"company_share_amount": _q2(company_share_amount),
"company_share_count": company_share_count,
"company_share_total": _q2(company_share_amount * company_share_count),
},
"reconciliation": reconciliation,
"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"))),
"performance_total": reconciliation["team_base_total"],
"payable_total": reconciliation["payable_total"],
},
}
def _build_teams(session, manager_ids: list[int], context: dict) -> list[dict]:
"""按经理枚举生成团队行(含零成员团队)。"""
teams = []
for manager_id in manager_ids:
team = _build_team(session, manager_id, context)
if team is not None:
teams.append(team)
return teams
def _build_team(session, manager_id: int, context: dict) -> dict | None:
"""单个经理团队行;非营销经理的上级不展示(与月结聚合同口径)。"""
manager = session.get(Personnel, manager_id)
if manager is None or context["manager_role"].get(manager_id) != "sales_manager":
return None
aggregate = context["aggregates"].get(manager_id)
member_rows = _member_rows(
manager_id,
context["member_map"],
context["manager_of"],
context["manager_role"],
context["adjustments"],
context["include_mp"],
)
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(context["buckets"].get(manager_id, {}).get("performance", Decimal("0")))
# 月结只为有台账 bucket 的营销经理生成聚合行;零成员团队不发 5%
has_aggregate = manager_id in context["buckets"] or aggregate is not None
team_share = _team_share(team_base, context["share"].team_pct)
# 聚合额必须与月结「一次舍入」口径一致:优先取冻结行;未生成月结时
# 才用 12.5%+5% 合并公式预览。分列各自 q2 后相加会差 0.01~0.02。
if aggregate is not None:
aggregate_amount = _dec(aggregate.performance_amount or Decimal("0"))
elif manager_id in context["buckets"]:
aggregate_amount = _q2(
manager_team_share(
team_base,
context["company_base"],
team_pct=context["share"].team_pct,
company_pct=context["share"].company_pct,
)
)
else:
aggregate_amount = Decimal("0")
company_share = (
_company_share(context["company_base"], context["share"].company_pct)
if has_aggregate
else Decimal("0")
)
team_subtotal = _q2(aggregate_amount + team_extra - team_deduct)
return {
"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),
# 单经理 5% 名义值;无聚合行时为 0(不会发放)
"company_share_amount": _q2(company_share),
"has_aggregate": has_aggregate,
# 与月结聚合行同口径(12.5% + 5% 一次舍入);无聚合行时为 0
"aggregate_amount": _q2(aggregate_amount),
# 团队应发小计 = 聚合行金额 + 调整净额;不再另加一次 5%
"team_subtotal": team_subtotal,
"company_base_ratio": _ratio(team_base, context["company_base"]),
"adjust_extra": _q2(team_extra),
"adjust_deduct": _q2(team_deduct),
"adjust_net": _q2(team_extra - team_deduct),
"net_total": team_subtotal,
"frozen_amount": aggregate.performance_amount if aggregate else None,
"aggregate_ledger_id": aggregate.id if aggregate else None,
"members": member_rows,
}
def _reconciliation(
teams: list[dict],
teams_all: list[dict],
unassigned: list[dict],
company_base: Decimal,
) -> dict:
"""展示口径的对账块。
应发 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
聚合行金额(``aggregate_amount``)含 12.5%+5%,且与月结「一次舍入」
完全一致;``team_share_total``/``company_share_total`` 只作分列展示,
两者之和与聚合额的差记入 ``aggregate_rounding_diff``(分列各自 q2
会产生 0.01~0.02 的尾差,直接相加会与月结总额不符)。
筛选态下 ``other_*`` 记录被隐藏团队的金额;它们不进入应发。
"""
displayed_ids = {team["manager_id"] for team in teams}
other_teams = [team for team in teams_all if team["manager_id"] not in displayed_ids]
team_member_payable = sum(
(m["payable_sum"] for team in teams for m in team["members"]), Decimal("0")
)
unassigned_payable = sum((row["payable_sum"] for row in unassigned), Decimal("0"))
team_share_total = sum((team["team_share_amount"] for team in teams), Decimal("0"))
company_share_total = sum((team["company_share_amount"] for team in teams), Decimal("0"))
aggregate_payable_total = sum((team["aggregate_amount"] for team in teams), Decimal("0"))
aggregate_rounding_diff = aggregate_payable_total - team_share_total - company_share_total
adjust_net_total = sum(
(m["adjust_net"] for team in teams for m in team["members"]), Decimal("0")
) + sum((row["adjust_net"] for row in unassigned), Decimal("0"))
team_base_total = sum((team["team_base"] for team in teams), Decimal("0"))
unassigned_performance = sum(
(row["performance_sum"] for row in unassigned), Decimal("0")
)
payable_total = (
team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total
)
return {
"team_member_payable_total": _q2(team_member_payable),
"unassigned_payable_total": _q2(unassigned_payable),
"team_share_total": _q2(team_share_total),
# 各位有聚合行的经理 5% 之和(单份 × 计入次数)
"company_share_total": _q2(company_share_total),
# 与月结聚合行同口径的应发合计(12.5% + 5% 一次舍入)
"aggregate_payable_total": _q2(aggregate_payable_total),
# 分列展示与聚合额之间的舍入差;导出签字稿单列一行
"aggregate_rounding_diff": _q2(aggregate_rounding_diff),
"adjust_net_total": _q2(adjust_net_total),
"payable_total": _q2(payable_total),
"team_base_total": _q2(team_base_total),
"unassigned_performance_total": _q2(unassigned_performance),
# 筛选时包含其他团队基数与全公司未分配;未筛选时应为 0
"other_team_base": _q2(company_base - team_base_total - unassigned_performance),
"other_team_member_payable_total": _q2(
sum((m["payable_sum"] for team in other_teams for m in team["members"]), Decimal("0"))
),
"other_team_share_total": _q2(
sum((team["team_share_amount"] for team in other_teams), Decimal("0"))
),
"other_team_company_share_total": _q2(
sum((team["company_share_amount"] for team in other_teams), Decimal("0"))
),
"other_team_aggregate_total": _q2(
sum((team["aggregate_amount"] for team in other_teams), Decimal("0"))
),
"other_adjust_net_total": _q2(
sum((team["adjust_net"] for team in other_teams), Decimal("0"))
),
"company_base": _q2(company_base),
}
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
return manager_team_share(
@@ -223,11 +398,19 @@ def _ratio(part: Decimal, whole: Decimal) -> Decimal:
def _adjustments_by_person(orders) -> dict[int, dict]:
"""生效调整单按人汇总(补发为正、扣减为负;金额本身恒正)。"""
"""生效调整单按人汇总(补发为正、扣减为负;金额本身恒正)。
不要求该人本月有台账行——只有调整单的人也必须进入报表(计划 §一.3)。
"""
by_person: dict[int, dict] = {}
for order in orders:
acc = by_person.setdefault(
order.personnel_id, {"extra": Decimal("0"), "deduct": Decimal("0")}
order.personnel_id,
{
"extra": Decimal("0"),
"deduct": Decimal("0"),
"personnel_name": order.personnel_name,
},
)
if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
acc["extra"] += order.amount
@@ -237,7 +420,11 @@ def _adjustments_by_person(orders) -> dict[int, dict]:
def _member_map(rows) -> dict[int, dict]:
"""台账行按人求和(成员明细的唯一数据源),经理个人户金额单独记账。"""
"""台账行按人求和(成员明细的唯一数据源)。
普通行与经理个人户行分开记账:团队基数只含后者,成员行也只能取
后者,否则经理本人的普通业绩会同时出现在成员行与未分配中。
"""
members: dict[int, dict] = {}
for row in rows:
acc = members.setdefault(
@@ -248,9 +435,13 @@ def _member_map(rows) -> dict[int, dict]:
"is_probation": row.is_probation,
"performance": Decimal("0"),
"payable": Decimal("0"),
"regular_performance": Decimal("0"),
"regular_payable": Decimal("0"),
"manager_personal_performance": Decimal("0"),
"manager_personal_payable": Decimal("0"),
"ledger_ids": [],
"regular_ledger_ids": [],
"manager_personal_ledger_ids": [],
"has_regular_rows": False,
"has_manager_personal": False,
},
)
@@ -258,34 +449,90 @@ def _member_map(rows) -> dict[int, dict]:
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
acc["manager_personal_ledger_ids"].append(row.id)
else:
acc["has_regular_rows"] = True
acc["regular_performance"] += performance
acc["regular_payable"] += payable
acc["regular_ledger_ids"].append(row.id)
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)。
session,
member_map: dict[int, dict],
manager_ids: list[int],
adjustments: dict[int, dict],
) -> tuple[dict[int, int | None], dict[int, str], dict[int, dict]]:
"""人员 → 直属经理、岗位编码与展示信息(批量查询,避免 N+1)。
查询范围 = 成员 ∪ 经理枚举:无成员的经理也要有岗位判定,否则「零行团队」
会被岗位过滤静默跳过(FR-030 要求无团队成员出 0 行)。
查询范围 = 成员 ∪ 经理枚举 ∪ 调整单人员;随后再补查这些人引用的
上级。缺少上级的岗位信息时,其他经理的下属会被误判为「无经理」,
零成员团队也会被岗位过滤静默跳过(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}
seed = set(member_map) | set(manager_ids) | set(adjustments)
if not seed:
return {}, {}, {}
people = list(session.execute(select(Personnel).where(Personnel.id.in_(seed))).scalars())
referenced = {p.manager_id for p in people if p.manager_id is not None}
missing = referenced - seed
if missing:
people.extend(
session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars()
)
role_by_id = {
r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()
}
manager_of = {p.id: p.manager_id for p in people}
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
return manager_of, manager_role
person_info = {
p.id: {
"personnel_name": p.personnel_name,
"role_code": role_by_id.get(p.business_role_id, ""),
"is_probation": p.is_probation,
}
for p in people
}
return manager_of, manager_role, person_info
def _merge_adjustment_only_members(
member_map: dict[int, dict], adjustments: dict[int, dict], person_info: dict[int, dict]
) -> None:
"""把「只有调整单、本月无台账行」的人补成零值成员。
补进 member_map 后,``_member_rows`` / ``_unassigned_rows`` 的归属
逻辑无需再为调整单单独开一条遍历分支;绩效为 0 也保证团队基数不变。
"""
for personnel_id, adjustment in adjustments.items():
if personnel_id in member_map:
continue
info = person_info.get(personnel_id) or {
"personnel_name": adjustment["personnel_name"],
"role_code": "",
"is_probation": 0,
}
member_map[personnel_id] = {
"personnel_name": info["personnel_name"],
"role_code": info["role_code"],
"is_probation": info["is_probation"],
"performance": Decimal("0"),
"payable": Decimal("0"),
"regular_performance": Decimal("0"),
"regular_payable": Decimal("0"),
"manager_personal_performance": Decimal("0"),
"manager_personal_payable": Decimal("0"),
"regular_ledger_ids": [],
"manager_personal_ledger_ids": [],
"has_regular_rows": False,
"has_manager_personal": False,
}
def _manager_ids(session, buckets: dict, only: int | None) -> list[int]:
@@ -304,37 +551,143 @@ def _manager_ids(session, buckets: dict, only: int | None) -> list[int]:
return sorted(found)
def _regular_team_of(
pid: int, manager_of: dict[int, int | None], manager_role: dict[int, str]
) -> int | None:
"""普通台账行的团队归属:直属上级是营销经理时归其团队。"""
manager_id = manager_of.get(pid)
if manager_id is not None and manager_role.get(manager_id) == "sales_manager":
return manager_id
return None
def _adjustment_target(
pid: int,
member_map: dict[int, dict],
manager_of: dict[int, int | None],
manager_role: dict[int, str],
include_manager_personal: bool,
) -> int | None:
"""调整单归属:优先跟随普通行;只有经理个人户的经理跟随本人团队。
调整单没有行类型,只能按人的实时归属落位。每人只允许一行携带调整,
因此这里返回唯一的团队键(None = 未分配)。
"""
member = member_map.get(pid)
if member is not None and member["has_regular_rows"]:
return _regular_team_of(pid, manager_of, manager_role)
if (
member is not None
and include_manager_personal
and member["has_manager_personal"]
and manager_role.get(pid) == "sales_manager"
):
return pid
return _regular_team_of(pid, manager_of, manager_role)
def _member_rows(
manager_id: int,
member_map: dict[int, dict],
manager_of: dict[int, int | None],
manager_role: dict[int, str],
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]
"""团队内成员行:普通行按 manager_id 归属;经理个人户按开关归属本人。
经理本人只以「经理个人户金额」出现在自己团队,普通行业绩留在上级
团队或未分配——同一笔业绩不会在团队与未分配中重复展示。
"""
rows = []
for pid, member in member_map.items():
target = _adjustment_target(
pid, member_map, manager_of, manager_role, include_manager_personal
)
regular_team = _regular_team_of(pid, manager_of, manager_role)
if pid != manager_id and member["has_regular_rows"] and regular_team == manager_id:
rows.append(
_member_row(
pid,
member,
adjustments,
"regular",
performance=member["regular_performance"],
payable=member["regular_payable"],
ledger_ids=member["regular_ledger_ids"],
include_adjust=target == manager_id,
)
)
personal_in_team = (
pid == manager_id
and include_manager_personal
and member["has_manager_personal"]
)
if personal_in_team:
rows.append(
_member_row(
pid,
member,
adjustments,
"manager_personal",
performance=member["manager_personal_performance"],
payable=member["manager_personal_payable"],
ledger_ids=member["manager_personal_ledger_ids"],
include_adjust=target == manager_id,
)
)
carried = (
member["has_regular_rows"] and regular_team == manager_id and target == manager_id
) or (personal_in_team and target == manager_id)
# 调整单没有被普通行/个人户行承载时单独出一行(例如只有个人户、
# 但上级是营销经理的人员:调整随实时 manager_id 归上级团队)
if pid in adjustments and target == manager_id and not carried:
rows.append(
_member_row(
pid,
member,
adjustments,
"adjustment",
performance=Decimal("0"),
payable=Decimal("0"),
ledger_ids=[],
include_adjust=True,
)
)
return rows
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"]
def _member_row(
pid: int,
member: dict,
adjustments: dict[int, dict],
member_kind: str,
*,
performance: Decimal,
payable: Decimal,
ledger_ids: list[int],
include_adjust: bool,
) -> dict:
"""单条成员行(按行类型取金额,调整净额最多计入一次)。"""
adj = (
adjustments.get(pid, {"extra": Decimal("0"), "deduct": Decimal("0")})
if include_adjust
else {"extra": Decimal("0"), "deduct": Decimal("0")}
)
net = 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"]),
"member_kind": member_kind,
"performance_sum": _q2(performance),
"payable_sum": _q2(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"],
"ledger_ids": list(ledger_ids),
}
@@ -343,24 +696,68 @@ def _unassigned_rows(
manager_of: dict[int, int | None],
manager_role: dict[int, str],
adjustments: dict[int, dict],
include_manager_personal: bool,
) -> 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)
target = _adjustment_target(
pid, member_map, manager_of, manager_role, include_manager_personal
)
regular_team = _regular_team_of(pid, manager_of, manager_role)
regular_unassigned = member["has_regular_rows"] and regular_team is None
personal_unassigned = (
include_manager_personal
and member["has_manager_personal"]
and manager_role.get(pid) != "sales_manager"
)
if regular_unassigned:
row = _member_row(
pid,
member,
adjustments,
"regular",
performance=member["regular_performance"],
payable=member["regular_payable"],
ledger_ids=member["regular_ledger_ids"],
include_adjust=target is None,
)
row["unassigned_reason"] = "无经理或上级非营销经理"
rows.append(row)
if personal_unassigned:
row = _member_row(
pid,
member,
adjustments,
"manager_personal",
performance=member["manager_personal_performance"],
payable=member["manager_personal_payable"],
ledger_ids=member["manager_personal_ledger_ids"],
include_adjust=target is None and not member["has_regular_rows"],
)
row["unassigned_reason"] = "经理个人户(本人非营销经理)"
rows.append(row)
carried = (regular_unassigned and target is None) or (
personal_unassigned and target is None and not member["has_regular_rows"]
)
if pid in adjustments and target is None and not carried:
row = _member_row(
pid,
member,
adjustments,
"adjustment",
performance=Decimal("0"),
payable=Decimal("0"),
ledger_ids=[],
include_adjust=True,
)
row["unassigned_reason"] = "仅有调整单且无团队归属"
rows.append(row)
return rows
@@ -547,26 +944,41 @@ EXPORT_FILENAMES = {
"payment-stats": "payment_stats",
}
# 标题是导出审计摘要的一部分:进入 audit 前就要确定,不能在取数后才拼
EXPORT_TITLES = {
"personal-detail": "个人绩效工资明细表",
"department-summary": "部门绩效工资汇总表",
"business-stats": "业务统计表",
"payment-stats": "回款情况统计表",
}
def export_report(session, code: str, query, *, operator: User, meta: RequestMeta | None = None):
"""导出 Excel:上限校验 → 取数 → 构建 → 审计留痕(独立 commit)。
"""导出 Excel:取数 → 构建 → 审计留痕(独立 commit)。
取数与上限校验必须在审计上下文内:超限 400 和取数异常同样要留下
失败记录(M4 计划 T12「导出 400/失败审计」)。`AuditRecord` 是 frozen
的,摘要只写静态标题,超限行数由异常文案带入 error_message。
返回 (bytes, 文件名);文件名由 API 层拼日期(ASCII,响应头 latin-1)。
"""
from app.services import report_export
rows, context = _export_payload(session, code, query)
title = EXPORT_TITLES.get(code)
if title is None:
raise BadRequestError(f"未知的报表编码:{code}")
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_REPORT,
action=ACTION_EXPORT,
summary=f"导出{context['title']}({context['row_count']} 行)",
summary=f"导出{title}",
request_data=query.model_dump(exclude_none=True),
),
meta=meta,
):
rows, context = _export_payload(session, code, query)
content = report_export.build_workbook(
code=code,
title=context["title"],
@@ -574,6 +986,7 @@ def export_report(session, code: str, query, *, operator: User, meta: RequestMet
rows=rows,
summary=context.get("summary"),
buckets=context.get("buckets"),
department=context.get("department"),
template_version=REPORT_TEMPLATE_VERSION,
operator_name=getattr(operator, "username", ""),
company_name=context["company_name"],
@@ -592,9 +1005,9 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
role_names = _role_names(session)
sums = report_repository.sum_personal_detail(session, query)
return [_person_row(r, role_names) for r in rows], {
"title": "个人绩效工资明细表",
"title": EXPORT_TITLES["personal-detail"],
"row_count": total,
"filters": _filter_text(query),
"filters": _filter_text(session, query),
"summary": {
"performance_total": _q2(_dec(sums["performance_total"])),
"payable_total": _q2(_dec(sums["payable_total"])),
@@ -603,14 +1016,17 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
}
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"],
row_count = (
len(data["teams"])
+ sum(len(team["members"]) for team in data["teams"])
+ len(data["unassigned"])
)
_check_export_limit(row_count)
return [], {
"title": EXPORT_TITLES["department-summary"],
"row_count": row_count,
"filters": _filter_text(session, query),
"department": data,
"company_name": _company_name(session),
}
if code == "business-stats":
@@ -621,9 +1037,9 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
)
sums = report_repository.sum_business_stats(session, query)
return [_business_row(r) for r in rows], {
"title": "业务统计表",
"title": EXPORT_TITLES["business-stats"],
"row_count": total,
"filters": _filter_text(query),
"filters": _filter_text(session, query),
"summary": {
"ship_total": _q2(_dec(sums["ship_total"])),
"received_total": _q2(_dec(sums["received_total"])),
@@ -635,9 +1051,9 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
data = payment_stats(session, query)
rows = data["buckets"]
return rows, {
"title": "回款情况统计表",
"title": EXPORT_TITLES["payment-stats"],
"row_count": len(rows),
"filters": _filter_text(query),
"filters": _filter_text(session, query),
"summary": data["totals"],
"buckets": data,
"company_name": _company_name(session),
@@ -654,43 +1070,79 @@ def _check_export_limit(row_count: int) -> None:
)
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(session, query) -> str:
"""筛选条件摘要(导出页眉展示;未设置项不展示;ID 转名称)。
def _filter_text(query) -> str:
"""筛选条件摘要(导出页眉展示;未设置项不展示)。"""
页眉是签字稿追溯筛选范围的唯一凭据:漏掉人员/经理/客户/归属人/
状态会让复核者把筛选结果当成全量数据。
"""
personnel_ids = {
value
for field in ("personnel_id", "manager_id", "owner_personnel_id")
if (value := getattr(query, field, None)) not in (None, "")
}
personnel_names = _personnel_names(session, personnel_ids)
parts = []
for field, label in (
("settlement_month", "统计月份"),
("personnel_id", "人员"),
("manager_id", "经理"),
("customer_id", "客户"),
("owner_personnel_id", "归属人"),
("customer_type", "客户类型"),
("product_category", "产品类别"),
("role_code", "岗位"),
("status", "状态"),
("keyword", "关键字"),
("date_from", "日期起"),
("date_to", "日期止"),
):
value = getattr(query, field, None)
if value:
if value in (None, ""):
continue
if field in ("personnel_id", "manager_id", "owner_personnel_id"):
name = personnel_names.get(value)
parts.append(f"{label} {name}(#{value})" if name else f"{label} #{value}")
elif field == "customer_id":
name = _customer_name(session, value)
parts.append(f"{label} {name}(#{value})" if name else f"{label} #{value}")
elif field == "customer_type":
parts.append(f"{label} {CUSTOMER_TYPE_LABELS.get(value, value)}")
elif field == "product_category":
parts.append(f"{label} {PRODUCT_CATEGORY_LABELS.get(value, value)}")
elif field == "role_code":
parts.append(f"{label} {_role_name_by_code(session, value) or value}")
elif field == "status":
parts.append(f"{label} {BUSINESS_STATUS_LABELS.get(value, value)}")
else:
parts.append(f"{label} {value}")
return " | ".join(parts) or "全部"
def _personnel_names(session, ids: set[int]) -> dict[int, str]:
"""批量取人员姓名;查不到时由调用方回退展示 #id。"""
if not ids:
return {}
rows = session.execute(
select(Personnel.id, Personnel.personnel_name).where(Personnel.id.in_(ids))
).all()
return dict(rows)
def _customer_name(session, customer_id: int) -> str | None:
"""客户名称(导出页眉 ID→名称)。"""
return session.execute(
select(Customer.company_name).where(Customer.id == customer_id)
).scalar_one_or_none()
def _role_name_by_code(session, role_code: str) -> str | None:
"""岗位名称(导出页眉展示中文,而不是内部编码)。"""
return session.execute(
select(BusinessRole.role_name).where(BusinessRole.role_code == role_code)
).scalar_one_or_none()
def _company_name(session) -> str:
"""导出页眉公司名(未配置时 system_setting_service 返回默认「衍水集团」)。"""
from app.services import system_setting_service
+22 -8
View File
@@ -13,11 +13,13 @@ from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
MODULE_SETTLEMENT,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import (
MonthlySettlement,
@@ -248,19 +250,30 @@ def submit(
),
meta=meta,
):
settlement = settlement_repository.get_by_id(session, settlement_id)
settlement = settlement_repository.get_by_id(session, settlement_id, for_update=True)
if settlement is None:
raise NotFoundError("月结不存在")
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
raise BadRequestError("仅草稿或已驳回状态可提交")
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,
)
try:
order = approval_service.submit_order(
session,
order_type=_SETTLEMENT_ORDER_TYPE,
ref_id=settlement.id,
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
submitter=operator,
# 月结明细在生成时已固化;提交时快照让复核者看到的是送审版本,
# 而不是审批期间若发生重生成后的内容
snapshot=approval_service.settlement_content(session, settlement.id),
)
except IntegrityError as exc:
# version_no 唯一键的并发冲突(提交前已锁月结行,此项理论不可达,
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062 翻译成
# 可重试的业务提示,其余完整性错误原样上抛
if is_duplicate_entry(exc):
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
raise
settlement.approval_order_no = order.order_no
if order.status == SETTLEMENT_STATUS_APPROVED:
mark_locked(session, settlement, operator=operator)
@@ -573,6 +586,7 @@ def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
ref_id=order.id,
refer_no=order.order_no,
submitter=operator,
snapshot=approval_service.adjustment_content(session, order.id),
)
order.status = (
ADJUSTMENT_STATUS_EFFECTIVE
+44 -8
View File
@@ -140,25 +140,44 @@ uv run pytest tests/integration -q # 仅集成
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `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/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、分成按参数、无成员经理出 0 值行;**无台账经理挂上级不 500、仅有调整单人员并入团队/未分配、经理个人户开关两侧口径、按经理筛选不误判其他团队下属、对账恒等式**;**应发与月结 `total_payable` 对拍(两位经理各计一次 5%)、有调整单时等于月结+调整净额、筛选态只汇总展示团队**;导出页眉 ID→名称/中文标签、回款维度 sheet 透传筛选、**导出超限与取数异常的失败审计**、非法月份查询/导出均 400;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**;业务统计排除经理聚合行;普通用户 403 | 27 |
| `tests/unit/test_report_helpers.py` | 区间分桶边界与 8 档编码唯一;标签覆盖全部枚举;Excel 模板页眉/签字栏/空数据/金额格式/合计/公式注入/维度 sheet/状态中文;**部门汇总签字稿五块顺序、零成员团队与未分配行、5% 按计入次数累计、应发合计取对账口径、聚合行舍入差单列、比例标题参数化** | 17 |
| `tests/unit/test_month_validation.py` | 非法月份(`2026-13`/`2026-00`/`0000-01` 等)在报表、月结、调整单 schema 入口即拒绝;可选月份空串规范为 None | 17 |
运行:`uv run pytest tests/integration/test_reports.py -q` → **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)。
运行:`uv run pytest tests/integration/test_reports.py -q` → **27 passed**(2026-10-20 复核);`uv run pytest tests/unit/test_report_helpers.py tests/unit/test_month_validation.py -q` → **34 passed**;全量 **862 passed**(2026-10-20,11:32)。
**e2e**:`frontend/e2e/report_flow.py` 已补回款多桶同时展开、部门下钻筛选生效、打印截断提示、部门汇总打印五块结构;需 `E2E_ADMIN_PASSWORD` 与前后端在跑。
**e2e 实跑(2026-10-20)**:`frontend/e2e/report_flow.py` 12/12 通过。回款统计改为展开目标桶后按**本次业务编号**定位下钻行(不依赖桶累计值,可重复运行);另覆盖多桶同时展开、部门下钻筛选、打印截断提示、部门汇总打印五块结构。
### 工作台(M4 ⑧,2026-10-19 完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志 | 5 |
| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志;**待办按角色过滤(财务/领导/多角色/admin)+ 真实流转;回款逾期命中与阈值参数生效;终止业务排除;`warnings.total` 不受展示上限截断;回款趋势跨月归属** | 11 |
| `tests/unit/test_param_registry.py`(改) | 注册表 19→**22 项**(V10 预警规则三项);编码集合与迁移种子一致 | 改 |
运行:`uv run pytest tests/integration/test_dashboard.py -q` → **5 passed**(2026-10-19)。
运行:`uv run pytest tests/integration/test_dashboard.py -q` → **11 passed**(2026-10-19 code-review 修复回归)。
**实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。
### code-review 遗留 2 项(2026-10-20 完成:方案版本化 / 审批内容快照)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_approval_flow.py`(扩) | **提交时快照固化**(version_no=1、content_source=snapshot、方案比例/合计/余额与业务编号);编辑方案为 80% 后新单 V2、旧单快照仍 50.00 且旧在途单撤回;**历史单无快照回退当前内容并标 live**(captured_at=null);引用对象缺失且无快照 → missing;**方案版本化**:编辑后 history=[(1,0,50.00),(2,1,80.00)]、生效行仅 80.00;驳回后生效行为空但历史行保留(is_active=0) | +5 |
| `tests/unit/test_approval_schema.py` | 对象类型筛选 1/2/3 均可、0/4 拒绝(修复原上界 `le=2` 导致调整单审批单无法筛选);不传类型表示全部;驳回必须填意见、通过可不填 | 6 |
| `tests/unit/test_approval_helpers.py` | **方案内容比较**(`business_service._plan_changed`):完全相同/行序不同判未变、比例变/人员增减/同比例换人判已变、比例写法 `50` 与 `50.00` 等价、空方案对照 | 10 |
| `tests/integration/test_secondary_allocation.py`(扩) | **同一会话内旧方案行随置历史同步刷新**(Core UPDATE 的会话同步策略退化为不刷新时,行对象会停留在 is_active=1 的过期内存值) | +1 |
| `tests/integration/test_approval_flow.py`(扩) | **送审内容未变不重提**:方案与金额都没动 → 方案不新增版本、审批单不重提(旧单仍待财务复核);**只改金额** → 方案版本不变但审批单 version_no+1 且新快照金额为改后值;**改方案比例** → 方案版本+1、审批单 version_no+1 且旧单撤回;**无在途单时保存仍提新单**(保守规则);`pending_matches_snapshot` 四态(一致/不一致/无在途单/快照为 None) | +5 |
### 审批单版本号并发防线(2026-10-21 完成:V12 唯一键 + 锁读取号)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_approval_version.py`(新) | **取号读最新已提交值**(两会话法:s1 锚定快照 + s2 提交 version_no=2 → s1 必须取到 3,守住「快照读 vs 当前读」根因);**取号查询带 FOR UPDATE 且不含聚合**(事件钩子抓真实 SQL——MySQL 官方不允许聚合函数携带锁子句);**唯一键生效**(重复 (order_type, ref_id, version_no) 直插撞 1062);**编辑路径冲突收敛为 400** 且业务数据不写坏(monkeypatch `_next_version` 恒返回 1);**正常路径版本号仍递增**(1→2→3) | +5 |
| `tests/unit/test_models.py`(扩) | 审批单唯一键覆盖 (order_type, ref_id, version_no) 三列且约束名与迁移 DDL 一致 | +1 |
运行(2026-10-22,容器恢复后):`tests/integration/test_approval_version.py` + `test_dashboard.py` → **16 passed**;全量 `uv run pytest -q` → **897 passed(12:02)**;`uv run ruff check .` 绿、前端 `pnpm type-check` 绿。首次全量跑出两处**测试代码**适配(非产品缺陷):① 工作台 `_insert_approval` 用同一 `(order_type=2, ref_id=0)` 连插两张单,撞 V12 新唯一键 → 每张单改给不同 `ref_id`;② 新用例跨模块引用 `switched_on` 夹具(pytest 夹具只在定义模块与 conftest 可见,报 fixture not found)→ 用 `_set_switch` 本地重建。
## 三、运行记录
| 日期 | 范围 | 用例数 | 结果 | 备注 |
@@ -181,6 +200,11 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-19 | 拆行收口批次 | 全量 788 | ✅ 788 passed(9:20) | **2026-10-19 业务答复落实完成**:#14 首日取整 10‰(公式段/锚点不变,用例反转);#12 单笔侧开关消费(1=业务员递减计提);**二次分摊拆行**(参与人份额+负责人余额守恒,uk 三列版);权限分层(params 查看放开/写仅 admin);V8 迁移(审批开关默认开/撤试用期参数/增基数开关)。定点:calc 20 / settlements 13(含 #12 开关两档金标准 6.84→24.34)/ businesses 30(保存侧+3)/ formula 35。e2e calc_settlement_flow 交接并行会话 |
| 2026-10-19 | **报表统计修复回归** | 全量 847 | ✅ 847 passed(10:13) | **M4 报表 code-review 15 项修复后全量回归**(后端 8 + 前端 7):归属三集合/调整单全量并入/经理个人户拆分/对账出参、分块签字稿导出、导出审计覆盖取数、筛选页眉 ID→名称、月份校验 400、岗位只读选项。报表集成 24 / 报表单元 16 / 月份校验 17。type-check + build 绿;e2e `report_flow.py` 扩至 12 步,待 `E2E_ADMIN_PASSWORD` 实跑 |
| 2026-10-19 | 全部 | 852 | ✅ 852 passed(9:17) | **M4 ⑧ 工作台完成**(四区块实时聚合 + ECharts 5.6 前端 + V10 预警参数三项;新增 5 例;参数注册表 19→22;同步模块显式降级)。期间修复:alembic `--sql` 导出 GBK 导致 schema 同步误报 |
| 2026-10-19 | **code-review 修复回归** | 全量 862 | ✅ 862 passed(9:36) | **M4 工作台 + 报表 3 必须 + 8 建议修复**:普通用户落地页/守卫(报表集成 27、工作台集成 11、报表单元 17,新增 10 例);部门汇总 5% 按计入次数累计并与月结 `total_payable` 对拍、聚合行舍入差;工作台待办按角色过滤、逾期命中与阈值生效、终止业务排除、`warnings.total` 截断、趋势跨月归属。ruff 绿;type-check + build 绿 |
| 2026-10-20 | e2e 全链路 | — | ✅ login 8 / system 13 / role_home 4 / report 12 / calc_settlement 8 | 重启后端加载新代码后实跑:普通用户落地页与受限页回退、报表四页/导出/打印、核算与月结链路全过。`report_flow` 回款统计改为展开目标桶后按本次业务编号定位明细,不依赖桶累计值,可重复运行 |
| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 |
| 2026-10-20 | code-review 遗留 2 项 | 全量 875 | ✅ 875 passed(12:13) | **方案版本化 + 审批内容快照**(V11 迁移):审批单 `version_no`/`snapshot_json`,方案行 `is_active` 版本化;编辑/驳回不再物理删方案;历史单无快照时 live 回退并显式标注。新增集成 7 + 单元 6;定点审批/月结/核算/业务/报表全绿;ruff 与 type-check/build 绿 |
| 2026-10-20 | 遗留项自评审收口 | 全量 891 | ✅ 891 passed(10:23) | **列表 defer 快照列**(审批列表/工作台待办/超时预警)+ **送审内容未变不重提、方案未变不新增版本**(两个 version_no 语义解耦)+ **月结提交加行锁**。新增单元 10 + 集成 5;单元 525、集成定点 96 全绿 |
| 2026-10-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` 运行 |
@@ -194,7 +218,19 @@ uv run pytest tests/integration -q # 仅集成
| 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-20 | 审批类型筛选上界写死 2 | `ApprovalQuery.order_type` 为 `le=2`:调整单(order_type=3)审批单在列表无法筛选,只能翻全量 | 上界改 3;单元用例锁定 1/2/3 与越界拒绝 |
| 2026-10-20 | 编辑业务时送审快照取旧汇总 | `update_business` 中 `_recalc_totals` 在提单之后,快照里的销售额/成本额会固化上一版本金额 | 汇总重算移到提单之前;快照用例断言送审内容与本次编辑后方案一致 |
| 2026-10-20 | 快照金额把「空」写成 0.00 | 业务尚无成本时 `total_cost_amount=None` 被文本化成 `0.00`,复核者会把「无成本」误读成「成本为零」 | `_amount_text(None)` 保留为空,前端显示「—」 |
| 2026-10-20 | 每次保存带方案的业务都刷出版本与审批留痕 | 只改备注、方案一字未动,也会「置历史+新增版本+撤回旧单+提新单」,方案历史与审批留痕被噪音淹没,两个 `version_no` 语义混同 | 判据统一为**送审内容**:方案未变不新增版本行;在途单送审内容一致则不重提(`pending_matches_snapshot`,只比 `data`) |
| 2026-10-20 | 三处列表查询整列拉快照 | 审批单列表、工作台待办、审批超时预警都用 `select(ApprovalOrder)` 取全列,快照 JSON 可达上百 KB,翻页与工作台负担随快照体积线性放大 | 三处加 `defer(ApprovalOrder.snapshot_json)`(沿用操作日志列表排除 `request_data` 的既有模式) |
| 2026-10-20 | 月结提交未锁行 | 并发/双击提交同一月结时两次 `_next_version` 可能算出同一 `version_no`,送审次序失真 | `settlement_repository.get_by_id` 加 `for_update` 参数,提交路径锁行 |
| 2026-10-21 | 审批单 `version_no` 无唯一约束 | 应用层取号即使正确,并发下 DB 层无兜底;且唯一键是「一个对象至多一张在途单」红线之外版本语义的最后防线 | V12 迁移加 `uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`;加键前先重排存量重复分组(V11 把存量一律置 1 而 V11 前重提是新建行,直接加键必报 1062) |
| 2026-10-21 | `_next_version` 是快照读 | 请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已锚定 REPEATABLE READ 快照——父行锁只串行化写入时刻、**不刷新快照**,并发提单的 MAX 停在请求开始时刻 → 版本号重复 | 取号改锁读(`select(version_no)...with_for_update()`,当前读),最大值 Python 侧算;**不能用聚合 + FOR UPDATE**——MySQL 官方不允许聚合函数携带锁子句 |
| 2026-10-21 | `act()` 驳回清空方案的注释与行为相反 | 注释写「清空该业务的二次分配行…如需保留历史版本,在 ④ 设计时演进」,而 V11 已实现 append-only(只置 `is_active=0` 行不删)——误导后来者按旧语义改代码 | 注释改为「置历史不删行,历史方案可回溯」 |
| 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 |
| 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 |
| 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 |
| 2026-10-19 | 工作台待办未按角色过滤 | 财务看得到「待领导审核」、领导看得到「待财务复核」,与 FR-034「仅看到与自己职责相关」不符 | `pending_statuses_for_roles` 按角色映射在途节点;repository 参数化 statuses;admin 保留全部在途视角 |
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
@@ -248,7 +284,7 @@ uv run pytest tests/integration -q # 仅集成
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
| 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` 实跑 |
| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例;2026-10-20 e2e `calc_settlement_flow` 8/8 实跑通过(台账/月结真实浏览器链路) |
| 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;2026-10-20 e2e `report_flow` 12/12 实跑通过,全量 862 passed |
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
@@ -388,3 +388,332 @@ class TestModuleAccess:
user = make_user([code])
user["headers"] = auth_headers(user)
assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200
class TestContentSnapshot:
"""审批单内容快照与版本号(code-review 遗留②)。
守的是:复核者看到的是**送审时**的方案,而不是审批期间被改过的当前
方案;历史单(升级前无快照)必须显式标注「当前内容回退」,不能把
当前内容冒充送审内容。
"""
def test_提交时快照固化_再改方案不回写旧单(
self, client: TestClient, admin: dict, finance: dict, 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["version_no"] == 1
assert first["content_source"] == "snapshot"
snapshot = first["snapshot"]
assert snapshot["schema_version"] == 1
assert snapshot["order_type"] == 1
assert snapshot["captured_at"], "快照须固化采集时间"
assert snapshot["data"]["business_no"] == data["business_no"]
assert snapshot["data"]["allocations"][0]["ratio"] == "50.00"
assert snapshot["data"]["allocation_total"] == "50.00"
assert snapshot["data"]["allocation_balance"] == "50.00"
# 编辑方案为 80%:旧在途单撤回,新单 V2 快照取新值;旧单快照不变
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
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2
assert second["snapshot"]["data"]["allocations"][0]["ratio"] == "80.00"
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5, "重新提交须撤回旧在途单"
assert old["snapshot"]["data"]["allocations"][0]["ratio"] == "50.00"
def test_历史单无快照_回退当前内容并标注(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
"""升级前的审批单没有快照列:回退读当前内容,但来源必须标 live。"""
from app.core.db import get_session_factory
from app.models import ApprovalOrder
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
factory = get_session_factory()
with factory() as session:
row = session.get(ApprovalOrder, order["id"])
assert row is not None
row.snapshot_json = None
session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"]
assert detail["content_source"] == "live"
assert detail["snapshot"]["captured_at"] is None
assert detail["snapshot"]["data"]["business_no"] == data["business_no"]
def test_引用对象缺失且无快照_内容缺失(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
from app.core.db import get_session_factory
from app.models import ApprovalOrder
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
factory = get_session_factory()
with factory() as session:
row = session.get(ApprovalOrder, order["id"])
assert row is not None
row.snapshot_json = None
row.ref_id = 999_999_999
session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"]
assert detail["content_source"] == "missing"
assert detail["snapshot"] is None
def _payload_from_detail(detail: dict, **overrides: object) -> dict:
"""按业务详情原样重建提交载荷(明细行照抄,用于「内容未变」的场景)。"""
items = [
{
"product_name": it["product_name"],
"product_category": it["product_category"],
"quantity": it["quantity"],
"sale_price": it["sale_price"],
}
for it in detail["items"]
]
body = business_payload(
detail["customer_id"],
items=items,
secondary_allocations=detail["secondary_allocations"],
)
body.update(overrides)
return body
class TestNoRedundantResubmit:
"""送审内容未变不重提、方案未变不新增版本(版本与留痕降噪)。
守的是:反复保存同一份业务(只改备注)不应刷出一串方案版本与
「撤回+新建」的审批留痕;而内容真变了(金额或方案)必须重提,
否则审批人看到的是过期内容。
"""
def test_方案与金额均未变_不新增版本也不重提(
self, client: TestClient, admin: dict, finance: dict, 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 = _payload_from_detail(detail)
payload["remark"] = "只改了备注"
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
# 方案版本不增
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 1)
], "方案内容未变不应新增版本行"
# 审批单不重提:仍是原来那一张、仍待财务复核
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"], "pageSize": 10},
headers=finance["headers"],
).json()["data"]["list"]
assert len(rows) == 1, f"送审内容未变不应重提,实得 {len(rows)} 张单"
assert rows[0]["id"] == first["id"]
assert rows[0]["status"] == 1, "在途单不应被撤回"
assert rows[0]["version_no"] == 1
def test_只改金额不改方案_方案版本不变但审批单重提(
self, client: TestClient, admin: dict, finance: dict, 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"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(detail)
payload["items"][0]["sale_price"] = "200.00"
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 1)
], "方案未变不应新增版本行"
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2, "金额变了必须重提"
assert second["snapshot"]["data"]["sale_amount"] == "20000.00"
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5
def test_改方案比例_方案版本与审批单版本同时递增(
self, client: TestClient, admin: dict, finance: dict, 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"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(
detail, 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
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 0),
(2, 1),
], "方案变了应新增版本并把旧行置历史"
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5
def test_无在途单时保存仍提新单(
self, client: TestClient, admin: dict, finance: dict, leader: dict, 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"]
)
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(detail)
payload["remark"] = "已通过后再保存"
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"]
assert len(rows) == 2, "无在途单时保存应重新送审"
assert max(r["version_no"] for r in rows) == 2
class TestPendingSnapshotMatch:
"""在途单送审内容一致性判定(approval_service.pending_matches_snapshot)。"""
def test_三种情形(self, client: TestClient, admin: dict, finance: dict, switched_on) -> None:
from app.core.db import get_session_factory
from app.services import approval_service
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
snapshot = order["snapshot"]["data"]
factory = get_session_factory()
with factory() as session:
# 1) 内容一致 → True
assert (
approval_service.pending_matches_snapshot(
session,
order_type=1,
ref_id=data["id"],
snapshot=snapshot,
)
is True
)
# 2) 内容不同 → False
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=data["id"], snapshot={"allocations": []}
)
is False
)
# 3) 无在途单 → False(引用对象换成别的)
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=999_999_999, snapshot=snapshot
)
is False
)
# 4) 快照为 None → False
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=data["id"], snapshot=None
)
is False
)
class TestAllocationVersions:
"""二次分配方案版本化(code-review 遗留①)。
守的是:编辑替换与驳回清空只把旧行置历史(is_active=0),不物理删除,
方案参与人与比例可回溯;生效行始终唯一,核算取数不受历史行干扰。
"""
def test_编辑方案产生新版本_旧版本保留(
self, client: TestClient, admin: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [
(1, 1)
]
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
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
history = [(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]]
assert history == [(1, 0, "50.00"), (2, 1, "80.00")]
assert [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"]
def test_驳回后生效行为空_历史行保留(
self, client: TestClient, admin: dict, finance: dict, 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, "opinion": "方案不合理"},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert detail["secondary_allocations"] == [], "被驳回方案不得留在生效行"
assert detail["allocation_hint"] is None
history = detail["allocation_history"]
assert len(history) == 1
assert history[0]["version_no"] == 1
assert history[0]["is_active"] == 0
assert history[0]["ratio"] == "50.00"
@@ -0,0 +1,183 @@
"""审批单版本号并发防线(V12 唯一键 + 锁读取号)。
守的是:
- **取号必须读最新已提交值**:请求级鉴权与业务共用同一 DbSession,鉴权
的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻;父行锁只
串行化写入时刻、不刷新快照——取号若是快照读,并发提单会算出同一个
version_no(两会话法做行为级回归锁,不依赖真多线程)
- **取号查询带 FOR UPDATE 且不用聚合**:MySQL 官方不允许聚合函数携带
锁子句(Locking clauses are not permitted in queries with aggregate
functions or GROUP BY),聚合上的 FOR UPDATE 语义无保证
- **DB 层唯一键生效**:即使应用层取号缺陷被绕过,重复
(order_type, ref_id, version_no) 也必须在库上被拒(1062)
- **冲突收敛为 400**:编辑路径撞唯一键 → 审计上下文回滚 → 业务提示
可重试,业务数据不得写坏
- **正常路径版本号仍递增**:锁读改写不破坏既有 1→2→3 行为
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import event, text
from app.core.db import get_engine, get_session_factory
from app.core.db_errors import is_duplicate_entry
from app.models import ApprovalOrder
from tests.integration.factories import business_payload
from tests.integration.test_approval_flow import (
_BUSINESSES,
_create_with_plan,
_latest_order,
_person,
_set_switch,
)
pytestmark = pytest.mark.integration
@pytest.fixture
def switched_on(client: TestClient, admin: dict) -> None:
"""把二次分配审批开关置 1(用例结束由 conftest 恢复种子基线)。
pytest 夹具只在定义它的模块与 conftest 中可见,跨模块直接引用会报
``fixture 'switched_on' not found``;这里用 ``_set_switch`` 本地重建。
"""
_set_switch(client, admin["headers"], "1")
class TestNextVersionLockRead:
"""取号查询的锁读口径(真实 SQL 形态 + 行为级快照回归)。"""
def test_取号查询带FOR_UPDATE_不含聚合(self, client: TestClient, admin: dict) -> None:
"""抓 _next_version 真实发出的 SQL(比本地重造语句再编译更贴真):
必须含 FOR UPDATE、不含 MAX( —— 聚合上的锁子句 MySQL 不保证语义。"""
captured: list[str] = []
def _spy(_conn, _cursor, statement, *_rest):
if "approval_orders" in statement and "version_no" in statement:
captured.append(statement)
engine = get_engine()
event.listen(engine, "before_cursor_execute", _spy)
try:
p = _person(client, admin["headers"])
_create_with_plan(client, admin["headers"], p["id"]) # 触发一次真实提单
finally:
event.remove(engine, "before_cursor_execute", _spy)
assert captured, "未捕获到取号查询"
assert any("FOR UPDATE" in sql.upper() for sql in captured), captured
assert not any("MAX(" in sql.upper() for sql in captured), captured
def test_版本号读最新已提交值_不受事务旧快照影响(self) -> None:
"""两会话法(无需线程):s1 先锚定快照 → s2 提交新版本 →
s1 取号必须读到 3 而非旧快照里的 2——守住「快照读 vs 当前读」根因。"""
from app.services import approval_service
factory = get_session_factory()
with factory() as s1:
# 锚定快照(模拟请求级鉴权读先于业务写发生)
s1.execute(text("SELECT order_no FROM approval_orders LIMIT 1")).all()
with factory() as s2:
order = ApprovalOrder(
order_no=f"AP-{uuid.uuid4().hex[:12]}",
order_type=1,
ref_id=987654321,
refer_no="YW-SNAPSHOT-TEST",
version_no=2,
status=5,
submitter_name="t_snapshot_probe",
)
s2.add(order)
s2.commit()
# s1 的普通读仍停在旧快照(该分组在快照里不存在)
stale = s1.execute(
text(
"SELECT COALESCE(MAX(version_no),0) FROM approval_orders "
"WHERE order_type=1 AND ref_id=987654321"
)
).scalar_one()
assert stale == 0, "前置失效:快照读应停在锚定时刻,先修隔离级别再跑本用例"
assert approval_service._next_version(s1, 1, 987654321) == 3, (
"取号必须读到最新已提交值(2+1=3),读到 2 说明退化为快照读"
)
s1.rollback()
def test_唯一键生效_重复版本号被拒(self) -> None:
"""重复 (order_type, ref_id, version_no) 直插必须撞 1062——
应用层取号即使被绕过,DB 也是最后一道防线。"""
from sqlalchemy.exc import IntegrityError
factory = get_session_factory()
with factory() as s:
common = {
"order_type": 1,
"ref_id": 987654322,
"refer_no": "YW-UK-TEST",
"status": 5,
"submitter_name": "t_uk_probe",
}
s.add(ApprovalOrder(order_no=f"AP-{uuid.uuid4().hex[:12]}", version_no=1, **common))
s.flush()
s.add(ApprovalOrder(order_no=f"AP-{uuid.uuid4().hex[:12]}", version_no=1, **common))
with pytest.raises(IntegrityError) as exc_info:
s.flush()
assert is_duplicate_entry(exc_info.value), "应为唯一键冲突(1062)而非其他完整性错误"
s.rollback()
def test_正常路径版本号仍递增(self, client: TestClient, admin: dict, switched_on) -> None:
"""常规编辑两次:送审版本号 1→2→3——锁读改写不得破坏既有行为。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, admin["headers"], data["business_no"])
assert first["version_no"] == 1
for ratio in ("60.00", "70.00"):
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": ratio}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
third = _latest_order(client, admin["headers"], data["business_no"])
assert third["version_no"] == 3
class TestConflictConvergence:
"""编辑路径撞版本号唯一键 → 收敛为可重试的 400,业务数据不写坏。"""
def test_编辑路径版本号冲突_收敛为400(
self, client: TestClient, admin: dict, switched_on, monkeypatch
) -> None:
from app.services import approval_service
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
before = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
# 模拟取号读到过期快照:恒返回 1(库中 V1 已存在)→ 撞唯一键
monkeypatch.setattr(approval_service, "_next_version", lambda *a, **k: 1)
payload = business_payload(
before["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 == 400, resp.text
assert "版本号生成重复" in resp.json()["message"]
monkeypatch.undo()
# 业务数据不得写坏:整个事务回滚,方案仍是 50.00(V1 生效行)
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [a["ratio"] for a in after["secondary_allocations"]] == ["50.00"]
assert [h["version_no"] for h in after["allocation_history"]] == [1]
@@ -147,9 +147,18 @@ class TestBuiltinProtection:
class TestPermission:
"""权限:普通用户全部 403(当前默认管理员专用)。"""
"""权限:管理接口普通用户 403;只读选项登录即可(报表筛选依赖)。"""
def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
assert client.get(_ROLES, headers=headers).status_code == 403
def test_普通用户可读取岗位选项(self, client: TestClient, make_user, auth_headers) -> None:
"""财务/领导做报表岗位筛选时不能因管理员接口 403 而失去筛选能力。"""
user = make_user([ROLE_USER])
headers = auth_headers(user)
resp = client.get("/api/v1/base/business-roles/options", headers=headers)
assert resp.status_code == 200, resp.text
codes = {row["role_code"] for row in resp.json()["data"]}
assert {"salesperson", "sales_manager", "info_officer"} <= codes
+228 -1
View File
@@ -191,7 +191,7 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
# 把待审批单提交时间改到 4 天前(阈值默认 3 天)
from sqlalchemy import select, update
from sqlalchemy import update
from app.core.db import get_session_factory
from app.models import ApprovalOrder
@@ -230,3 +230,230 @@ def test_动态_来源操作日志(client: TestClient, admin: dict):
data = _dashboard(client, admin["headers"])
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
@pytest.fixture
def finance(make_user, auth_headers) -> dict:
user = make_user(["finance"])
user["headers"] = auth_headers(user)
return user
@pytest.fixture
def leader(make_user, auth_headers) -> dict:
user = make_user(["leader"])
user["headers"] = auth_headers(user)
return user
def _insert_approval(status: int) -> None:
"""直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。
``ref_id`` 仅作对象分组锚点、本用例不校验:**每张单必须给不同的
ref_id**——V12 的 ``uk_approval_orders_order_type_ref_version`` 要求
(order_type, ref_id, version_no) 唯一,同一假对象连插两张 version_no=1
会撞 1062。
"""
from app.core.db import get_session_factory
from app.models import ApprovalOrder
factory = get_session_factory()
with factory() as session:
session.add(
ApprovalOrder(
order_no=f"AP{uuid.uuid4().hex[:12]}",
order_type=2,
ref_id=status,
refer_no="t_工作台",
status=status,
submitter_name="t_admin",
)
)
session.commit()
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"remark": f"t_工作台:{code}",
"items": [{"param_code": code, "dimension_value": "", "value": value}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _shipment_scene(client: TestClient, headers: dict, *, days_ago: int) -> tuple[dict, dict]:
"""造一笔无回款的发货(用于回款逾期预警)。"""
from tests.integration.factories import business_item, business_payload, customer
person = _person(client, headers)
cust = customer(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=person["id"],
items=[business_item(cost_amount="3000.00")],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
business = resp.json()["data"]
ship_date = (date.today() - timedelta(days=days_ago)).isoformat()
resp = client.post(
_SHIPMENTS,
json={
"business_id": business["id"],
"ship_date": ship_date,
"items": [{"product_name": "t_工作台逾期", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return business, resp.json()["data"]
def test_待办_按角色仅显示可操作节点(
client: TestClient, admin: dict, finance: dict, leader: dict, make_user, auth_headers
):
"""财务只看到待财务复核、领导只看到待领导审核;admin 与多角色看并集。"""
_insert_approval(1)
_insert_approval(2)
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
assert _dashboard(client, admin["headers"])["todos"]["pending_approval_total"] == 2
both = make_user(["finance", "leader"])
both["headers"] = auth_headers(both)
assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2
finance_orders = {
item["order_no"] for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
}
leader_orders = {
item["order_no"] for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
}
assert finance_orders.isdisjoint(leader_orders)
def test_待办_财务通过后领导出现待办(
client: TestClient, admin: dict, finance: dict, leader: dict
):
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
_chain(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"]
resp = client.post(
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0]
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 0
resp = client.post(
f"/api/v1/system/approvals/{order['order_id']}/action",
json={"action": 1},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 0
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
def test_预警_回款逾期命中且阈值参数生效(client: TestClient, admin: dict):
"""发货超阈值且无任何回款 → 命中;调大阈值后不再命中。"""
_, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
data = _dashboard(client, admin["headers"])
assert any(
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
for item in data["warnings"]["items"]
), "120 天未回款的发货批次未命中预警"
_set_param(client, admin["headers"], "warning.receipt.overdue.days", "200")
data = _dashboard(client, admin["headers"])
assert not any(
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
for item in data["warnings"]["items"]
), "阈值调大后仍命中,说明参数未生效"
def test_预警_终止业务不再产生回款逾期(client: TestClient, admin: dict):
"""已终止业务不再催收,其未回款发货单不得留在预警列表。"""
business, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
resp = client.put(
f"{_BUSINESSES}/{business['id']}/status",
json={"status": 3, "reason": "t_工作台终止"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = _dashboard(client, admin["headers"])
assert not any(item["object_id"] == shipment["id"] for item in data["warnings"]["items"])
assert data["warnings"]["receipt_overdue_total"] == 0
def test_预警_总数不受展示上限截断(client: TestClient, admin: dict, monkeypatch):
"""items 只展示前 N 条,徽标 total 必须仍是全量命中数。"""
from app.services import dashboard_service
_shipment_scene(client, admin["headers"], days_ago=120)
_shipment_scene(client, admin["headers"], days_ago=120)
monkeypatch.setattr(dashboard_service, "_WARNING_LIMIT", 1)
data = _dashboard(client, admin["headers"])
assert data["warnings"]["total"] == 2
assert len(data["warnings"]["items"]) == 1
assert data["warnings"]["total"] == data["warnings"]["receipt_overdue_total"]
def test_总览_回款趋势按到账月跨月归属(client: TestClient, admin: dict):
"""趋势窗口按 receipt_date 范围过滤,月初/月末不得串月。"""
from tests.integration.factories import business_item, business_payload, customer
person = _person(client, admin["headers"])
cust = customer(client, admin["headers"])
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=person["id"],
items=[business_item(cost_amount="3000.00")],
),
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
business = resp.json()["data"]
first_of_month = date.today().replace(day=1)
previous_last = first_of_month - timedelta(days=1)
for receipt_date in (previous_last, first_of_month):
resp = client.post(
_RECEIPTS,
json={
"business_id": business["id"],
"receipt_date": receipt_date.isoformat(),
"amount": "1000.00",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
trend = {
item["month"]: Decimal(str(item["receipt_total"] or 0))
for item in _dashboard(client, admin["headers"])["overview"]["trend"]
}
assert trend[previous_last.strftime("%Y-%m")] == Decimal("1000.00")
assert trend[first_of_month.strftime("%Y-%m")] == Decimal("1000.00")
+495
View File
@@ -306,3 +306,498 @@ def test_权限_普通用户403文案(admin, normal, client: TestClient):
resp = client.get(f"{_REPORTS}/personal-detail", headers=normal["headers"])
assert resp.status_code == 403
assert resp.json()["message"] == "无权访问报表统计"
# ==== 审查回归:归属、调整单、经理个人户、导出审计 ====
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
"""立即生效的参数变更(用例不依赖种子默认值,结束显式恢复)。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": f"用例设置 {code}={value}",
"items": [{"param_code": code, "dimension_value": "", "value": value}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _insert_adjustment(person: dict, month: str, amount: str, direction: int = 1) -> None:
"""直插生效调整单(status=2):绕开审批链,聚焦报表归属口径。"""
from app.core.db import get_session_factory
from app.models import AdjustmentOrder
factory = get_session_factory()
with factory() as session:
session.add(
AdjustmentOrder(
order_no=f"TZ{uuid.uuid4().hex[:12]}",
settlement_month=month,
personnel_id=person["id"],
personnel_name=person["personnel_name"],
direction=direction,
amount=Decimal(amount),
reason="t_报表用例",
status=2,
submitter_name="t_admin",
)
)
session.commit()
def _insert_manager_personal_ledger(manager: dict, month: str, amount: str) -> None:
"""直插一条经理个人户台账行(模拟单笔侧已计提的结果)。"""
from datetime import date
from app.core.db import get_session_factory
from app.models import PerformanceLedger
factory = get_session_factory()
with factory() as session:
session.add(
PerformanceLedger(
receipt_id=None,
calc_version=1,
calc_type=1,
business_id=0,
business_no="t_个人户",
personnel_id=manager["id"],
personnel_name=manager["personnel_name"],
role_code="sales_manager",
customer_type="manager_personal",
product_category="mixed",
receipt_date=date.today(),
rate_text="0",
performance_amount=Decimal(amount),
barter_applied=0,
payable_amount=Decimal(amount),
settlement_month=month,
is_probation=0,
)
)
session.commit()
def _latest_export_log():
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import OperationLog
factory = get_session_factory()
with factory() as session:
return session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
).scalars().first()
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
manager_b = _person(client, admin["headers"], role="sales_manager")
manager_a = _person(
client, admin["headers"], role="sales_manager", manager_id=manager_b["id"]
)
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team_a = next(t for t in data["teams"] if t["manager_id"] == manager_a["id"])
assert team_a["member_count"] == 0
team_b = next(t for t in data["teams"] if t["manager_id"] == manager_b["id"])
assert all(m["personnel_id"] != manager_a["id"] for m in team_b["members"])
def test_部门汇总_仅有调整单人员并入对应团队(admin, client: TestClient):
"""只有调整单、当月无台账行的人也要出现在团队明细中。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
_insert_adjustment(salesperson, _month(), "500.00")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
member = next(m for m in team["members"] if m["personnel_id"] == salesperson["id"])
assert member["member_kind"] == "adjustment"
assert Decimal(str(member["performance_sum"])) == Decimal("0")
assert Decimal(str(member["adjust_net"])) == Decimal("500.00")
assert Decimal(str(team["adjust_net"])) == Decimal("500.00")
assert Decimal(str(data["reconciliation"]["adjust_net_total"])) == Decimal("500.00")
def test_部门汇总_仅调整单无经理人员进入未分配(admin, client: TestClient):
"""无经理人员的调整单不能被静默丢弃。"""
salesperson = _person(client, admin["headers"])
_insert_adjustment(salesperson, _month(), "300.00")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
row = next(u for u in data["unassigned"] if u["personnel_id"] == salesperson["id"])
assert row["member_kind"] == "adjustment"
assert Decimal(str(row["adjust_net"])) == Decimal("300.00")
def test_部门汇总_经理个人户计入时基数等于成员合计(admin, client: TestClient):
"""开关 1:经理成员行只取个人户金额,团队基数 == 成员绩效合计。"""
manager = _person(client, admin["headers"], role="sales_manager")
_insert_manager_personal_ledger(manager, _month(), "100.00")
_set_param(client, admin["headers"], "manager.personal.included", "1")
try:
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
team = next(
t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]
)
assert Decimal(str(team["team_base"])) == Decimal("100.00")
assert sum(
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
) == Decimal("100.00")
assert all(m["member_kind"] == "manager_personal" for m in team["members"])
finally:
_set_param(client, admin["headers"], "manager.personal.included", "0")
def test_部门汇总_经理个人户关闭时不展示个人户金额(admin, client: TestClient):
"""开关 0:个人户金额既不计入团队基数,也不进未分配(与全公司口径一致)。"""
manager = _person(client, admin["headers"], role="sales_manager")
_insert_manager_personal_ledger(manager, _month(), "100.00")
_set_param(client, admin["headers"], "manager.personal.included", "0")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
assert team["member_count"] == 0
assert all(u["personnel_id"] != manager["id"] for u in data["unassigned"])
def test_部门汇总_按经理筛选不误判其他团队下属(admin, client: TestClient):
"""筛选只影响展示的团队;其他经理的下属不能进入未分配。"""
manager_1 = _person(client, admin["headers"], role="sales_manager")
manager_2 = _person(client, admin["headers"], role="sales_manager")
salesperson_1 = _person(client, admin["headers"], manager_id=manager_1["id"])
salesperson_2 = _person(client, admin["headers"], manager_id=manager_2["id"])
_full_chain(client, admin["headers"], owner_id=salesperson_1["id"])
_full_chain(client, admin["headers"], owner_id=salesperson_2["id"])
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
assert data["unassigned"] == []
assert all(
m["personnel_id"] != salesperson_2["id"]
for team in data["teams"]
for m in team["members"]
)
assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0
def test_部门汇总_对账恒等式(admin, client: TestClient):
"""未筛选:团队基数 + 未分配绩效 = 全公司基数;应发公式成立。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
unassigned = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=salesperson["id"])
_full_chain(client, admin["headers"], owner_id=unassigned["id"])
_insert_adjustment(salesperson, _month(), "200.00")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
reconciliation = resp.json()["data"]["reconciliation"]
assert Decimal(str(reconciliation["team_base_total"])) + Decimal(
str(reconciliation["unassigned_performance_total"])
) == Decimal(str(reconciliation["company_base"]))
assert Decimal(str(reconciliation["other_team_base"])) == Decimal("0")
assert Decimal(str(reconciliation["payable_total"])) == (
Decimal(str(reconciliation["team_member_payable_total"]))
+ Decimal(str(reconciliation["unassigned_payable_total"]))
+ Decimal(str(reconciliation["aggregate_payable_total"]))
+ Decimal(str(reconciliation["adjust_net_total"]))
)
# 分列合计 + 舍入差 = 聚合额(月结一次舍入口径)
assert Decimal(str(reconciliation["aggregate_rounding_diff"])) == (
Decimal(str(reconciliation["aggregate_payable_total"]))
- Decimal(str(reconciliation["team_share_total"]))
- Decimal(str(reconciliation["company_share_total"]))
)
def _two_manager_scene(
client: TestClient, headers: dict
) -> tuple[dict, dict, dict, dict, dict]:
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
manager_1 = _person(client, headers, role="sales_manager")
manager_2 = _person(client, headers, role="sales_manager")
empty_manager = _person(client, headers, role="sales_manager")
salesperson_1 = _person(client, headers, manager_id=manager_1["id"])
salesperson_2 = _person(client, headers, manager_id=manager_2["id"])
_full_chain(client, headers, owner_id=salesperson_1["id"])
_full_chain(client, headers, owner_id=salesperson_2["id"])
return manager_1, manager_2, empty_manager, salesperson_1, salesperson_2
def test_部门汇总_应发合计与月结对拍_两位经理(admin, client: TestClient):
"""月结每位有聚合行的经理各计一次 5%;签字稿应发必须等于 total_payable。"""
_two_manager_scene(client, admin["headers"])
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
before = resp.json()["data"]
assert before["company"]["company_share_count"] == 2
assert Decimal(str(before["company"]["company_share_total"])) == (
Decimal(str(before["company"]["company_share_amount"])) * 2
)
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement = resp.json()["data"]
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
teams = {team["manager_id"]: team for team in data["teams"]}
frozen_total = sum(
(
Decimal(str(team["frozen_amount"]))
for team in teams.values()
if team["frozen_amount"] is not None
),
Decimal("0"),
)
reconciliation = data["reconciliation"]
assert Decimal(str(reconciliation["aggregate_payable_total"])) == frozen_total
assert Decimal(str(reconciliation["payable_total"])) == Decimal(
str(settlement["total_payable"])
)
def test_部门汇总_有调整单时应发等于月结加调整(admin, client: TestClient):
"""调整单不进月结 items;报表应发 = 月结总额 + 调整净额。"""
_, _, _, salesperson, _ = _two_manager_scene(client, admin["headers"])
_insert_adjustment(salesperson, _month(), "500.00")
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement = resp.json()["data"]
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
reconciliation = resp.json()["data"]["reconciliation"]
assert Decimal(str(reconciliation["adjust_net_total"])) == Decimal("500.00")
assert Decimal(str(reconciliation["payable_total"])) == (
Decimal(str(settlement["total_payable"])) + Decimal("500.00")
)
def test_部门汇总_按经理筛选时5百分比只计该团队(admin, client: TestClient):
"""筛选态只汇总展示团队;隐藏团队的金额进 other_*,不参与应发。"""
manager_1, _, _, _, _ = _two_manager_scene(client, admin["headers"])
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert data["company"]["company_share_count"] == 1
reconciliation = data["reconciliation"]
assert Decimal(str(reconciliation["other_team_aggregate_total"])) > 0
assert Decimal(str(reconciliation["other_team_company_share_total"])) > 0
def test_导出_筛选页眉ID转名称与中文标签(admin, client: TestClient):
"""页眉是签字稿追溯筛选范围的唯一凭据,ID 必须转名称。"""
from io import BytesIO
from openpyxl import load_workbook
from app.core.db import get_session_factory
from app.models import BusinessRole
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
factory = get_session_factory()
with factory() as session:
role_name = (
session.query(BusinessRole.role_name)
.filter(BusinessRole.role_code == "salesperson")
.scalar()
)
resp = client.get(
f"{_REPORTS}/personal-detail/export",
params={
"settlement_month": _month(),
"personnel_id": person["id"],
"role_code": "salesperson",
"customer_type": "personal",
"product_category": "municipal_water",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
workbook = load_workbook(BytesIO(resp.content))
text = "\n".join(
str(cell.value)
for row in workbook.active.iter_rows()
for cell in row
if cell.value is not None
)
assert person["personnel_name"] in text
assert f"人员 {person['personnel_name']}(#{person['id']})" in text
assert "个人户" in text
assert "市政水利" in text
assert role_name in text
def test_导出_回款维度sheet透传筛选条件(admin, client: TestClient):
"""两个维度 sheet 的页眉不能固定写「全部」。"""
from io import BytesIO
from openpyxl import load_workbook
resp = client.get(
f"{_REPORTS}/payment-stats/export",
params={"customer_type": "personal"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
workbook = load_workbook(BytesIO(resp.content))
for sheet_name in ("客户类型对比", "产品类别对比"):
text = "\n".join(
str(cell.value)
for row in workbook[sheet_name].iter_rows()
for cell in row
if cell.value is not None
)
assert "客户类型 个人户" in text
assert "筛选条件:全部" not in text
def test_导出超限_失败审计留痕(admin, client: TestClient, monkeypatch):
"""超限 400 发生在审计上下文内,必须留下失败记录。"""
from app.services import report_service
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
monkeypatch.setattr(report_service, "REPORT_EXPORT_MAX_ROWS", 0)
resp = client.get(
f"{_REPORTS}/personal-detail/export",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
log = _latest_export_log()
assert log is not None
assert log.success == 0
assert "超过上限" in (log.error_message or "")
def test_导出取数异常_失败审计留痕(admin, client: TestClient, monkeypatch):
"""取数阶段的异常同样要留下失败记录。
TestClient 默认重抛服务端异常,这里直接调 service 层,聚焦审计落库。
"""
from app.core.db import get_session_factory
from app.models import User
from app.schemas.report import PersonalDetailQuery
from app.services import report_service
def _boom(*args, **kwargs):
raise RuntimeError("t_取数异常")
monkeypatch.setattr(report_service, "_export_payload", _boom)
factory = get_session_factory()
with factory() as session:
operator = session.get(User, admin["id"])
with pytest.raises(RuntimeError, match="t_取数异常"):
report_service.export_report(
session,
"personal-detail",
PersonalDetailQuery(settlement_month=_month()),
operator=operator,
)
log = _latest_export_log()
assert log is not None
assert log.success == 0
# 未预期异常按安全口径收敛为通用文案,摘要仍能定位到报表
assert "导出个人绩效工资明细表" in (log.summary or "")
@pytest.mark.parametrize(
"path", ["/personal-detail", "/department-summary", "/payment-stats"]
)
def test_非法月份_查询返回400(admin, client: TestClient, path: str):
"""非法月份必须是参数错误 400,不能落 500。"""
resp = client.get(
f"{_REPORTS}{path}",
params={"settlement_month": "2026-13"},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
@pytest.mark.parametrize(
"code", ["personal-detail", "department-summary", "payment-stats"]
)
def test_非法月份_导出返回400(admin, client: TestClient, code: str):
"""导出手工构造查询模型同样要转 400。"""
resp = client.get(
f"{_REPORTS}/{code}/export",
params={"settlement_month": "2026-13"},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
@@ -247,6 +247,36 @@ class TestGuards:
class TestDeleteProtection:
"""RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。"""
def test_同一会话内旧方案行同步为历史态(self, client: TestClient, admin: dict) -> None:
"""守「置历史后同一会话内的旧行必须同步刷新」这一行为:Core UPDATE
走的是 SQLAlchemy 的会话同步策略,条件复杂化时可能退化为不刷新,
届时行对象会停留在 is_active=1 的过期内存值(库中实际已置历史),
被序列化或参与判断就会呈现「生效态」的假象。"""
from app.core.db import get_session_factory
from app.models import SecondaryAllocation
from app.repositories import business_repository
p = _person(client, admin["headers"])
data = _create_with_allocations(
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
)
factory = get_session_factory()
with factory() as session:
loaded = business_repository.list_allocations(session, data["id"])
assert loaded and all(a.is_active == 1 for a in loaded)
business_repository.deactivate_allocations(session, data["id"])
# 同一 session 内的行对象必须已是历史态,不能停留在过期内存值
assert all(a.is_active == 0 for a in loaded), "旧行未随置历史同步刷新"
# 库中同样已置历史
rows = (
session.query(SecondaryAllocation)
.filter(SecondaryAllocation.business_id == data["id"])
.all()
)
assert rows and all(r.is_active == 0 for r in rows)
session.rollback()
def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None:
p = _person(client, admin["headers"])
data = _create_with_allocations(
@@ -229,6 +229,19 @@ class TestSubmitAndLock:
assert out["approval_order_no"] and out["approval_order_no"].startswith("AP")
assert out["locked_at"] is not None
# 自动通过路径同样固化送审快照(月结头 + 按人明细)
order = client.get(
"/api/v1/system/approvals",
params={"keyword": out["approval_order_no"], "pageSize": 1},
headers=admin["headers"],
).json()["data"]["list"][0]
detail = client.get(
f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"]
).json()["data"]
assert detail["content_source"] == "snapshot"
assert detail["snapshot"]["data"]["settlement_month"] == "2026-10"
assert detail["snapshot"]["data"]["items"], "月结快照须含按人明细"
def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None:
from sqlalchemy import select
@@ -603,6 +616,21 @@ class TestAdjustment:
out = resp.json()["data"]
assert out["status"] == 2 # 已生效
# 调整单送审快照:复核者能看到金额/方向/说明,而非只有调整单号
order = client.get(
"/api/v1/system/approvals",
params={"keyword": out["order_no"], "pageSize": 1},
headers=admin["headers"],
).json()["data"]["list"][0]
assert order["order_type"] == 3
detail = client.get(
f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"]
).json()["data"]
assert detail["content_source"] == "snapshot"
assert detail["snapshot"]["data"]["amount"] == "500.00"
assert detail["snapshot"]["data"]["direction"] == 1
assert detail["snapshot"]["data"]["reason"] == "补发差额"
def test_金额恒正校验(self, client: TestClient, admin: dict) -> None:
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
@@ -749,3 +777,24 @@ class TestLockInterception:
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
def test_非法月份_月结生成与调整单均返回400(client: TestClient, admin: dict) -> None:
"""月份范围必须在 schema 拦住:`2026-13` 不能传到 monthrange 变成 500。"""
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-13"}, headers=admin["headers"]
)
assert resp.status_code == 400, resp.text
resp = client.post(
f"{_SETTLEMENTS}/adjustments",
json={
"settlement_month": "2026-13",
"personnel_id": 1,
"direction": 1,
"amount": "100.00",
"reason": "t_非法月份",
},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
@@ -0,0 +1,72 @@
"""审批与方案版本化的纯函数单元测试(秒级,无需容器)。
守的是「方案内容是否真的变了」这一判定(``business_service._plan_changed``):
它决定要不要新增一个方案版本行、要不要重提审批单。判错的后果是双向的——
判成「变了」会刷出版本与审批留痕噪音,判成「没变」会让改过的方案
沿用旧版本号,版本语义失真。
比较口径是**集合**(人员 ID + 其比例),与提交顺序无关:用户只是把行
拖了个序,不该被算成方案变了。
"""
from decimal import Decimal
import pytest
from app.models.business import SecondaryAllocation
from app.services.business_service import _plan_changed
def _row(personnel_id: int, ratio: str) -> SecondaryAllocation:
"""构造一个未入库的方案行(ORM 实例化不触数据库)。"""
return SecondaryAllocation(
business_id=1,
version_no=1,
personnel_id=personnel_id,
personnel_name=f"t_人员{personnel_id}",
ratio=Decimal(ratio),
is_active=1,
)
class TestPlanChanged:
"""方案内容比较。"""
def test_完全相同_判未变(self) -> None:
old = [_row(1, "50.00"), _row(2, "30.00")]
new = [_row(1, "50.00"), _row(2, "30.00")]
assert _plan_changed(old, new) is False
def test_顺序不同_判未变(self) -> None:
"""用户只是把行拖了个序 —— 送审快照按库内生效行顺序生成,
按顺序比较会把这种编辑误判成「方案变了」。"""
old = [_row(1, "50.00"), _row(2, "30.00")]
new = [_row(2, "30.00"), _row(1, "50.00")]
assert _plan_changed(old, new) is False
def test_比例变化_判已变(self) -> None:
old = [_row(1, "50.00")]
new = [_row(1, "80.00")]
assert _plan_changed(old, new) is True
def test_参与人增减_判已变(self) -> None:
old = [_row(1, "50.00")]
assert _plan_changed(old, [_row(1, "50.00"), _row(2, "30.00")]) is True
assert _plan_changed([_row(1, "50.00"), _row(2, "30.00")], [_row(1, "50.00")]) is True
def test_同一人的比例从一人换成另一人_判已变(self) -> None:
"""参与人不同但比例相同:人员 ID 是比较键的一部分,不能只看比例。"""
old = [_row(1, "50.00")]
new = [_row(2, "50.00")]
assert _plan_changed(old, new) is True
@pytest.mark.parametrize("written", ["50", "50.0", "50.00"])
def test_比例写法不同_判未变(self, written: str) -> None:
"""比例入参是字符串('50' 与 '50.00' 都合法),数值相同即未变。"""
assert _plan_changed([_row(1, "50.00")], [_row(1, written)]) is False
def test_空方案与空方案_判未变(self) -> None:
assert _plan_changed([], []) is False
def test_空方案与有方案_判已变(self) -> None:
assert _plan_changed([], [_row(1, "50.00")]) is True
@@ -0,0 +1,38 @@
"""审批管理入参校验(schema 层,秒级)。
守的是:对象类型筛选必须覆盖三类审批对象(1 二次分配方案 / 2 月度结算 /
3 调整单)——早期上界写死在 2,调整单审批单在列表里无法筛选;越界值在
参数绑定阶段即拒绝。驳回必须带意见(空驳无从追溯)。
"""
import pytest
from pydantic import ValidationError
from app.schemas.approval import ApprovalActionIn, ApprovalQuery
class TestQueryOrderType:
"""对象类型筛选边界。"""
@pytest.mark.parametrize("value", [1, 2, 3])
def test_三类对象均可筛选(self, value: int) -> None:
assert ApprovalQuery(order_type=value).order_type == value
@pytest.mark.parametrize("value", [0, 4])
def test_越界类型被拒(self, value: int) -> None:
with pytest.raises(ValidationError):
ApprovalQuery(order_type=value)
def test_不传类型表示全部(self) -> None:
assert ApprovalQuery().order_type is None
class TestActionOpinion:
"""节点动作的驳回意见要求。"""
def test_驳回必须填意见(self) -> None:
with pytest.raises(ValidationError):
ApprovalActionIn(action=2)
def test_通过可不填意见(self) -> None:
assert ApprovalActionIn(action=1).opinion is None
+14
View File
@@ -96,6 +96,20 @@ class TestNamingConvention:
for fk in table.foreign_keys:
assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name
def test_审批单版本号唯一键覆盖三列(self) -> None:
"""并发提单算重版本号时由 DB 兜底(应用层取号走锁读,见
approval_service._next_version);约束名必须与迁移 DDL 严格一致,
否则 alembic autogenerate 会误判成「约束缺失/多余」。"""
table = Base.metadata.tables["approval_orders"]
uks = [c for c in table.constraints if c.__class__.__name__ == "UniqueConstraint"]
hit = [
c
for c in uks
if {col.name for col in c.columns} == {"order_type", "ref_id", "version_no"}
]
assert hit, "审批单缺 (order_type, ref_id, version_no) 唯一键"
assert hit[0].name == "uk_approval_orders_order_type_ref_version"
class TestComments:
"""规范红线要求中文 COMMENT。"""
+211
View File
@@ -209,3 +209,214 @@ class TestExcel模板:
]
text = _sheet_text(_build("business-stats", rows))
assert "已完成" in text
def _member(
personnel_id: int,
name: str,
*,
kind: str = "regular",
performance: str = "0.00",
payable: str = "0.00",
adjust: str = "0.00",
reason: str | None = None,
) -> dict:
return {
"personnel_id": personnel_id,
"personnel_name": name,
"role_code": "salesperson",
"is_probation": 0,
"member_kind": kind,
"unassigned_reason": reason,
"performance_sum": Decimal(performance),
"payable_sum": Decimal(payable),
"adjust_extra": Decimal(adjust),
"adjust_deduct": Decimal("0"),
"adjust_net": Decimal(adjust),
"net_total": Decimal(payable) + Decimal(adjust),
"ledger_ids": [],
}
def _department_data(
team_text: str = "12.5",
company_text: str = "5",
*,
second_has_aggregate: bool = False,
) -> dict:
"""一份含成员、零成员团队与未分配人员的部门汇总出参。
``second_has_aggregate`` 打开后两位经理都有月结聚合行,用于验证
全公司 5% 按人数累计(零成员团队默认不发 5%)。
"""
second_aggregate = Decimal("600.00") if second_has_aggregate else Decimal("0")
share_count = 2 if second_has_aggregate else 1
aggregate_total = Decimal("1850.00") + second_aggregate
payable_total = Decimal("10000.00") + Decimal("2000.00") + aggregate_total
return {
"settlement_month": "2026-10",
"params": {
"team_text": team_text,
"company_text": company_text,
"manager_personal_included": 0,
"params_as_of": "2026-10-31",
},
"teams": [
{
"manager_id": 1,
"manager_name": "张经理",
"manager_status": 1,
"member_count": 1,
"team_base": Decimal("10000.00"),
"team_share_amount": Decimal("1250.00"),
"company_share_amount": Decimal("600.00"),
"has_aggregate": True,
"aggregate_amount": Decimal("1850.00"),
"team_subtotal": Decimal("1850.00"),
"company_base_ratio": Decimal("0.8333"),
"adjust_extra": Decimal("0"),
"adjust_deduct": Decimal("0"),
"adjust_net": Decimal("0"),
"net_total": Decimal("1850.00"),
"frozen_amount": None,
"aggregate_ledger_id": None,
"members": [
_member(
2,
"李业务",
performance="10000.00",
payable="10000.00",
)
],
},
{
"manager_id": 3,
"manager_name": "王经理",
"manager_status": 1,
"member_count": 0,
"team_base": Decimal("0"),
"team_share_amount": Decimal("0"),
"company_share_amount": second_aggregate,
"has_aggregate": second_has_aggregate,
"aggregate_amount": second_aggregate,
"team_subtotal": second_aggregate,
"company_base_ratio": Decimal("0"),
"adjust_extra": Decimal("0"),
"adjust_deduct": Decimal("0"),
"adjust_net": Decimal("0"),
"net_total": second_aggregate,
"frozen_amount": None,
"aggregate_ledger_id": None,
"members": [],
},
],
"unassigned": [
_member(
4,
"赵未分配",
performance="2000.00",
payable="2000.00",
reason="无经理或上级非营销经理",
)
],
"company": {
"team_base_total": Decimal("10000.00"),
"company_base": Decimal("12000.00"),
"company_share_pct": company_text,
"company_share_amount": Decimal("600.00"),
"company_share_count": share_count,
"company_share_total": Decimal("600.00") * share_count,
},
"reconciliation": {
"team_member_payable_total": Decimal("10000.00"),
"unassigned_payable_total": Decimal("2000.00"),
"team_share_total": Decimal("1250.00"),
"company_share_total": Decimal("600.00") * share_count,
"aggregate_payable_total": aggregate_total,
"aggregate_rounding_diff": Decimal("0"),
"adjust_net_total": Decimal("0"),
"payable_total": payable_total,
"team_base_total": Decimal("10000.00"),
"unassigned_performance_total": Decimal("2000.00"),
"other_team_base": Decimal("0"),
"other_team_member_payable_total": Decimal("0"),
"other_team_share_total": Decimal("0"),
"other_team_company_share_total": Decimal("0"),
"other_team_aggregate_total": Decimal("0"),
"other_adjust_net_total": Decimal("0"),
"company_base": Decimal("12000.00"),
},
"totals": {},
}
def _build_department(data: dict) -> bytes:
return report_export.build_workbook(
code="department-summary",
title="部门绩效工资汇总表",
filters="统计月份 2026-10",
rows=[],
summary=None,
buckets=None,
department=data,
template_version="V1.2",
operator_name="admin",
company_name="衍水集团",
)
class Test部门汇总签字稿:
"""签字稿少了团队分成/5% 块/未分配/零成员团队,合计就会小于应发。"""
def test_五块顺序完整(self):
text = _sheet_text(_build_department(_department_data()))
titles = [
"一、团队分成汇总",
"二、全公司分成(每位有聚合行",
"三、对账汇总",
"四、团队成员明细",
"五、未分配人员明细",
]
positions = [text.index(title) for title in titles]
assert positions == sorted(positions), "签字稿块顺序错乱"
assert "财务复核" in text and "领导签字" in text
def test_零成员团队与未分配行存在(self):
text = _sheet_text(_build_department(_department_data()))
assert "王经理" in text
assert "赵未分配" in text
assert "无经理或上级非营销经理" in text
def test_全公司分成按计入次数累计(self):
"""两位有聚合行的经理各计一次 5%;零成员团队不产生分成。"""
one = _department_data()
text = _sheet_text(_build_department(one))
assert text.count("全公司分成(每位有聚合行") == 1
assert "共 1 次" in text
assert "13850" in text
two = _department_data(second_has_aggregate=True)
text = _sheet_text(_build_department(two))
assert "共 2 次" in text
# 团队成员应付 10000 + 未分配 2000 + 聚合 2450
assert "14450" in text
def test_应发合计取对账口径(self):
text = _sheet_text(_build_department(_department_data()))
assert "应发合计" in text
# Excel 数值单元格读回为 int/float,金额断言只看数值本身
assert "13850" in text
def test_聚合行舍入差单列(self):
data = _department_data()
data["reconciliation"]["aggregate_rounding_diff"] = Decimal("-0.02")
text = _sheet_text(_build_department(data))
assert "聚合行舍入差" in text
assert "-0.02" in text
def test_比例标题参数化(self):
text = _sheet_text(_build_department(_department_data("20", "6")))
assert "团队分成(20%)" in text
assert "6%" in text
assert "12.5%" not in text
assert "5%" not in text.replace("6%", "")
+12 -3
View File
@@ -713,6 +713,8 @@ CREATE TABLE approval_orders (
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 '引用对象单号(冗余固化,列表页免联查)',
version_no INT NOT NULL COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' DEFAULT '1',
snapshot_json JSON COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)',
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 '提交人用户名(冗余固化)',
@@ -720,11 +722,14 @@ CREATE TABLE approval_orders (
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 uk_approval_orders_order_type_ref_version UNIQUE (order_type, ref_id, version_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 INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id);
CREATE TABLE approval_steps (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
@@ -744,21 +749,25 @@ 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;业务删除随删)',
version_no INT NOT NULL COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' DEFAULT '1',
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)',
is_active TINYINT NOT NULL COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' DEFAULT '1',
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 uk_business_secondary_allocations_business_version_personnel UNIQUE (business_id, version_no, 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;
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)' 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);
CREATE INDEX idx_business_secondary_allocations_business_active ON business_secondary_allocations (business_id, is_active);
-- ---------------------------------------------------------------------------
-- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单)
-- ---------------------------------------------------------------------------
@@ -882,4 +891,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('90653814a421');
INSERT INTO alembic_version (version_num) VALUES ('7f4f7b918bc4');
@@ -0,0 +1,19 @@
-- Running upgrade 90653814a421 -> f3a8c2d61b47
ALTER TABLE approval_orders ADD COLUMN version_no INT NOT NULL DEFAULT 1 COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' AFTER refer_no;
ALTER TABLE approval_orders ADD COLUMN snapshot_json JSON NULL COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' AFTER version_no;
CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id);
ALTER TABLE business_secondary_allocations ADD COLUMN version_no INT NOT NULL DEFAULT 1 COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' AFTER business_id;
ALTER TABLE business_secondary_allocations ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' AFTER ratio;
ALTER TABLE business_secondary_allocations DROP INDEX uk_business_secondary_allocations_business_personnel, ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel UNIQUE (business_id, version_no, personnel_id);
CREATE INDEX idx_business_secondary_allocations_business_active ON business_secondary_allocations (business_id, is_active);
ALTER TABLE business_secondary_allocations COMMENT = '业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)';
UPDATE alembic_version SET version_num='f3a8c2d61b47' WHERE alembic_version.version_num = '90653814a421';
@@ -0,0 +1,16 @@
-- Running upgrade f3a8c2d61b47 -> 7f4f7b918bc4
UPDATE approval_orders ao JOIN (SELECT id, ROW_NUMBER() OVER ( PARTITION BY order_type, ref_id ORDER BY id) AS rn FROM approval_orders WHERE (order_type, ref_id) IN ( SELECT order_type, ref_id FROM approval_orders GROUP BY order_type, ref_id HAVING COUNT(*) > COUNT(DISTINCT version_no))) t ON ao.id = t.id SET ao.version_no = t.rn;
SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' AND CONSTRAINT_NAME = 'uk_approval_orders_order_type_ref_version');
SET @ddl := IF(@uk_exists = 0, 'ALTER TABLE approval_orders ADD CONSTRAINT uk_approval_orders_order_type_ref_version UNIQUE (order_type, ref_id, version_no)', 'DO 0');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE alembic_version SET version_num='7f4f7b918bc4' WHERE alembic_version.version_num = 'f3a8c2d61b47';
@@ -0,0 +1,289 @@
# T+ 12.3 字段映射草案(v0.1,2026-10-20)
> 本文仅记录字段级事实与页面索引,不复制厂商文档正文。
> 基线:`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`(75 项)+ v1.3 IT 18 项 + 《T+12.3数据字典》。
## 0. 结论摘要
- 覆盖:清单 **75/75** 项均已给出 T+ 侧候选字段与系统目标列;
- 状态分布:候选待确认 42 / 字典明确 23 / 未检出 9 / 需 IT 1;
- 数据字典无法让 IT 18 项中的任何一项完全闭环;但 9 项可改写为「候选字段选择题」,6 项部分可答,2 项纯 IT,1 项转业务。
- 收敛后:**13 项 IT 待确认 + 1 组业务裁决**(见第 12 节)。
- 关键缺口:人员工号/入职日期/试用期无字段、客户 status 缺失、回款 payment_method/confirm_status 待补、明细行无显式行号、无源端更新时间与负向事件模型。
## 1. 编制说明与证据等级
| 标记 | 含义 |
| --- | --- |
| `D` | 字典明确 |
| `D?` | 候选待确认 |
| `IT` | 需 IT |
| `B` | 需业务 |
| `S` | 系统自维护 |
| `N` | 未检出 |
- `D` 仅用于字段存在、类型明确且语义直接匹配的项;涉及「取哪个字段 / 业务口径」一律标 `D?` 或 `IT`。
- 字典来源为 T+ 12.3 SchemaDoc;同一字段可能标注 `T+12.1/12.2/12.3` 版本,映射按 12.3 可用为准。
## 2. 公共字段与同步元数据
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | T+ 原始单据号 / 档案编码 | 各表 code(人员/存货/客户/订单/出库/收付款单均有) | nvarchar(30~64) | — | personnel/products/customers/businesses/shipments/receipts.source_no | TRIM;作废重开是否换号待 IT(#4) | 必须 | D? | AA_Person.code、AA_Inventory.code、AA_Partner.code、SA_SaleOrder.code、SA_SaleDelivery.code、ARAP_ReceivePayment.code |
| 2 | T+ 系统内部标识 | 各表 id(整型主键) | int(4) | — | 六表 t_plus_id | int → 字符串存储;辅助对账/改名追溯 | 建议 | D | 各表 id(主键) |
系统侧六张主表已预留 `source_no / t_plus_id / sync_batch_no / synced_at`;`source_no` 当前为普通索引,IT 确认幂等键后升级唯一约束。
## 3. 人员档案
人员主档;隶属关系需经部门树/部门负责人推断,T+ 无直属上级字段。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 人员唯一编码(工号)★ | AA_Person.code | nvarchar(32) | — | personnel.source_no(建议补 personnel_code) | TRIM;编码优先、姓名辅助核对;是否即工号待 IT(#19) | 必须 | D? | AA_Person.code(编号) |
| 2 | 姓名 | AA_Person.name | nvarchar(200) | — | personnel.personnel_name | TRIM;仅辅助核对,不作唯一键 | 必须 | D | AA_Person.name(名称) |
| 3 | 在职状态 | AA_Person.disabled | tinyint(1) | 0 启用 / 1 停用(候选) | personnel.status(1 在职 / 0 离职) | disabled=1 → 0;无离职日期字段,离职时间需 IT 确认 | 必须 | D? | AA_Person.disabled(停用) |
| 4 | 是否试用期 | 未检出 | — | — | personnel.is_probation | T+ 人员档案无试用期字段;系统内人工维护或按入职日期规则推导 | 必须 | N | AA_Person 全表检索无 probation/试用 字段 |
| 5 | 入职日期 | 未检出(仅 madeDate 制单日期、birthday 出生日期) | — | — | personnel.hire_date | T+ 无入职日期字段;确认是否有自定义项承载,否则人工维护 | 必须 | N | AA_Person 全表检索无 hire/entry/入职 字段 |
| 6 | 联系电话 | AA_Person.mobilePhoneNo / officePhoneNo | nvarchar(20) / nvarchar(100) | — | personnel.phone | 优先 mobilePhoneNo,空则 officePhoneNo;日志脱敏 | 建议 | D | AA_Person.mobilePhoneNo(手机)、officePhoneNo(办公电话) |
| 7 | 业务员 ↔ 营销经理隶属关系 | AA_Person.iddepartment → AA_Department.idparent / iddirector | int(4) | — | personnel.manager_id | T+ 人员档案无直属上级字段;可经部门树/部门负责人推断,规则待确认;否则系统人工指定 | 必须 | D? | AA_Person.iddepartment(部门id);AA_Department.idparent(上级id)、iddirector(部门负责人) |
| 8 | 人员负责区域 / 产品辐射范围 | 未检出(可用 priuserdefnvc* 自定义项承载,需 IT 确认) | — | — | personnel_regions.region_name | 无专门字段则系统内人工维护 | 建议 | N | AA_Person 无区域/辐射范围字段 |
| 9 | 银行账户名 / 账号 / 开户行 | 未检出(AA_Partner 有银行账号字段,人员档案无) | — | — | 暂不落库 | 一期不采集;如后续代发工资再扩展 | 可选 | N | AA_Person 无银行账户字段 |
## 4. 产品档案
产品主档不含库存量与售价,数量取现存量表、价格取价目表/订单明细。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 产品名称 | AA_Inventory.name | nvarchar(200) | — | products.product_name | TRIM;与规格型号组成系统组合唯一键 | 必须 | D | AA_Inventory.name(名称) |
| 2 | 规格型号 | AA_Inventory.specification | nvarchar(200) | — | products.spec_model | 空值归一为空串 | 必须 | D | AA_Inventory.specification(规格型号) |
| 3 | 计量单位 | AA_Inventory.idunit → 计量单位表 | int(4) | — | products.unit | 按 idunit join 单位表取名称;单位表名待索引确认 | 建议 | D? | AA_Inventory.idunit(计量单位id)、idunitgroup |
| 4 | 数量 | ST_CurrentStock.baseQuantity(现存量) | decimal(13) | — | products.stock_quantity | 按存货+仓库汇总;语义(现存量/在途/累计销量)待 IT(#12) | 必须 | D? | ST_CurrentStock.baseQuantity(主计量数量);AA_Inventory 无库存量字段 |
| 5 | 销售单价 | AA_InventoryPrice(零售价/批发价)、AA_InventoryUnitPrice.retailPrice;订单明细实际成交价 | decimal(13~17) / nvarchar(1000) 公式 | — | products.sale_price | 产品档案参考价取价目表;绩效以订单/发货明细实际单价为准 | 必须 | D? | AA_InventoryPrice、AA_InventoryUnitPrice;AA_Inventory 无销售单价字段 |
| 6 | 产品分类(类目) | AA_Inventory.idinventoryclass → AA_InventoryClass.name | int(4) | — | products.product_category | T+ 分类仅辅助判定市政水利/轨交;系统分类人工维护不覆盖 | 必须 | D? | AA_Inventory.idinventoryclass(存货分类id)、AA_InventoryClass.name |
| 7 | 计量单位换算关系(如存在) | AA_Inventory_MultiUnit / AA_InventoryUnitPrice.rateOfExchange | decimal(13) | — | 暂不实现(系统单单位存储) | 如业务有多单位换算需求,另行设计 | 可选 | D? | AA_Inventory_MultiUnit、AA_InventoryUnitPrice.rateOfExchange(换算率) |
## 5. 业务单据(销售订单)
成本额在订单明细仅有最新成本,出库明细才有成本金额;销售额需按数量×单价计算。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 订单单号 | SA_SaleOrder.code | nvarchar(30) | — | businesses.source_no | TRIM;不得写入内部 business_no | 必须 | D | SA_SaleOrder.code(编号) |
| 2 | 业务(订单)日期 | SA_SaleOrder.voucherdate | datetime(8) | — | businesses.business_date | 截断为 DATE | 必须 | D | SA_SaleOrder.voucherdate(单据日期) |
| 3 | 客户标识 | SA_SaleOrder.idcustomer → AA_Partner.code | int(4) | — | businesses.customer_id | 按客户编码解析;回退公司名称+负责人 | 必须 | D? | SA_SaleOrder.idcustomer(客户id)、idsettlecustomer(结算客户id) |
| 4 | 负责营销人员 | SA_SaleOrder.idclerk → AA_Person.code | int(4) | — | businesses.owner_personnel_id | 按人员编码解析;新插入写 T+ 负责人、更新是否覆盖人工归属待裁决 | 必须 | D? | SA_SaleOrder.idclerk(业务员id) |
| 5 | 协助营销人员 | 未检出独立协助人字段 | — | — | businesses.assistant_personnel_id | 业务已回馈 T+ 合同只挂单一负责人;协助人系统内记录 | 建议 | N | SA_SaleOrder 无 assist/协助 字段 |
| 6 | 特殊业务标记 | SA_SaleOrder.idbusinesstype → 业务类型表 | int(4) | 业务类型枚举待字典/IT 提供 | businesses.is_special | 业务类型是否等同特殊业务待确认;无则人工标记 | 必须 | D? | SA_SaleOrder.idbusinesstype(业务类型id) |
| 7 | 明细行 · 产品名称 + 规格 | SA_SaleOrder_b.idinventory → AA_Inventory.name/specification | int(4) | — | business_items.product_name / spec_model | 冗余固化名称与规格;未建档产品允许自由录入 | 必须 | D | SA_SaleOrder_b.idinventory(存货id) |
| 8 | 明细行 · 数量 | SA_SaleOrder_b.quantity | decimal(13) | — | business_items.quantity | 主计量数量;需 >0 校验 | 必须 | D | SA_SaleOrder_b.quantity(数量)、baseQuantity(主计量数量) |
| 9 | 明细行 · 销售单价 | SA_SaleOrder_b.price / taxPrice | decimal(13) | — | business_items.sale_price | price 为不含税单价、taxPrice 为含税单价;取哪个待确认 | 必须 | D? | SA_SaleOrder_b.price(单价)、taxPrice(含税单价) |
| 10 | 明细行 · 销售额 | SA_SaleOrder_b 无「销售额」字段;由数量×单价计算,或 taxAmount−tax | decimal(13) | — | business_items.sale_amount | 系统按数量×单价固化;T+ 用于对账 | 必须 | D? | SA_SaleOrder_b.quantity、price、taxAmount、tax |
| 11 | 明细行 · 成本额 ★ | SA_SaleOrder_b.LatestCost(最新成本);SA_SaleDelivery_b.costPrice/costAmount | decimal(13) | — | business_items.cost_amount | 取订单最新成本还是出库成本、成本类型/时点待 IT(#1) | 必须 | D? | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType |
| 12 | 项目 / 工程编号或名称(如存在) | SA_SaleOrder.idproject → 项目表 | int(4) | — | 建议预留 project_code(当前不落库) | 按项目查询与报表归集;是否建列待业务 | 可选 | D? | SA_SaleOrder.idproject(项目id)、SA_SaleOrder_b.idproject |
| 13 | 服务类 / 维修类及其他收入单据(如存在) | 未检索到服务/维修类独立单据表 | — | — | 暂人工录入 | 业务规则未定前不纳入自动同步 | 可选 | N | SA_* 表索引未发现服务/维修类主表 |
## 6. 发货单据(销售出库/发货)
发货日期有四个候选字段;运费未在发货单据检出。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 发货单号 | SA_SaleDelivery.code | nvarchar(30) | — | shipments.source_no | TRIM;不得写入内部 shipment_no | 必须 | D | SA_SaleDelivery.code(编号) |
| 2 | 发货日期 ★ | SA_SaleDelivery.voucherdate / SendDate / deliveryDate / auditeddate | datetime(8) | — | shipments.ship_date | 四个日期候选:单据日期/送货日期(T+12.3)/交货日期/审核日期;取哪个待 IT(#2) | 必须 | D? | SA_SaleDelivery.voucherdate、SendDate、deliveryDate、auditeddate |
| 3 | 关联订单号 | SA_SaleDelivery.SourceVoucherCode / idsourcevouchertype;明细 saleOrderCode/saleOrderId | nvarchar(50) / int(4) | — | shipments.business_id | 按订单号解析;主关联路径待 IT(#5) | 必须 | D? | SA_SaleDelivery.SourceVoucherCode、SA_SaleDelivery_b.saleOrderCode/saleOrderId/saleOrderDetailId |
| 4 | 运费金额 | 未检出运费字段(明细有 origExpenseAmount 费用分摊金额) | decimal(13) | — | shipments.freight_amount | T+ 运费可能在费用单/其他单据,需 IT 确认;系统暂人工 | 必须 | N | SA_SaleDelivery/SA_SaleDelivery_b 无 freight/运费字段 |
| 5 | 发货总金额 | SA_SaleDelivery.amount / taxAmount | decimal(13) | — | shipments.total_amount | 系统按明细汇总,不直写;T+ 金额用于对账 | 必须 | D | SA_SaleDelivery.amount(金额)、taxAmount(含税金额) |
| 6 | 明细行 · 产品/规格/数量/单价/行金额 | SA_SaleDelivery_b.idinventory / quantity / price / taxPrice / taxAmount | decimal(13) | — | shipment_items.product_name/spec_model/quantity/sale_price/amount | 行金额由系统按数量×单价固化;T+ 用于对账 | 必须 | D | SA_SaleDelivery_b.quantity、price、taxPrice、taxAmount |
| 7 | 发货单状态 | SA_SaleDelivery.voucherState / isCancel / auditeddate | int(4) / tinyint(1) | 单据状态枚举待 IT 提供 | 不落库(v1.4 统一) | 仅推已生效单据;状态随行供同步校验 | 必须 | D | SA_SaleDelivery.voucherState(单据状态)、isCancel(状态) |
| 8 | 承运商 / 物流单号 | SA_SaleDelivery.deliveryMode(运输方式 id) | int(4) | 运输方式枚举表待确认 | 暂不落库 | 承运商/物流单号无独立字段;如需建列再扩展 | 可选 | D? | SA_SaleDelivery.deliveryMode(运输方式) |
## 7. 回款单据(收款/核销)
到账日期无银行到账日字段;收款方式取结算方式表;确认/核销状态字段存在但枚举待 IT。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 回款单号 | ARAP_ReceivePayment.code | nvarchar(30) | — | receipts.source_no | TRIM;不得写入内部 receipt_batch_no | 必须 | D | ARAP_ReceivePayment.code(编号) |
| 2 | 到账日期 ★ | ARAP_ReceivePayment.voucherDate / auditedDate / createdTime | datetime(8) | — | receipts.receipt_date | 无银行到账日字段;取单据日期/审核日期/创建时间哪个待 IT(#3) | 必须 | D? | ARAP_ReceivePayment.voucherDate、auditedDate、createdTime |
| 3 | 回款金额 | ARAP_ReceivePayment.amount(本币)/ origAmount(原币) | decimal(13) | — | receipts.amount | 取本币金额;一期禁负数,红字规则待 #8 | 必须 | D | ARAP_ReceivePayment.amount、origAmount |
| 4 | 关联订单号 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;明细 saleordercode/saleorderid/sourceordercode | nvarchar(50) / int(4) | — | receipts.business_id | 回款↔订单按订单号解析;回款↔发货批次系统人工指定 | 必须 | D? | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b.saleordercode/sourceordercode |
| 5 | 收款方式 | ARAP_ReceivePayment.IdSettleStyle → AA_SettleStyle.code/name | int(4) | 结算方式枚举表(AA_SettleStyle:code/name/停用) | receipts.payment_method(待补列) | 枚举映射;完整枚举清单需 IT 提供 | 必须 | D? | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle |
| 6 | 以物抵顶物说明 | 收款方式(以物抵顶枚举值)+ ARAP_ReceivePayment.memo | int(4) / nvarchar(200) | 以物抵顶对应结算方式代码待 IT 提供 | receipts.is_barter / remark | 按结算方式识别;抵顶物说明暂入备注 | 建议 | D? | ARAP_ReceivePayment.IdSettleStyle、memo |
| 7 | 回款确认 / 核销状态 | ARAP_ReceivePayment.cancelState / voucherstate / auditedDate | int(4) / datetime(8) | 核销/单据状态枚举待 IT 提供 | receipts.confirm_status(待补列) | 字段存在;哪个可作为人工确认依据待 IT(#16) | 必须 | D? | ARAP_ReceivePayment.cancelState(核销状态)、voucherstate(单据状态) |
| 8 | 银行流水号 / 回单参考号 | 未检出银行流水号/回单号字段(MultiSettle.documentCode 为票据号) | — | — | 建议预留 bank_serial_no | 需 IT 确认是否有对应字段 | 建议 | N | ARAP_ReceivePayment/ARAP_ReceivePayment_MultiSettle 仅票据号 |
| 9 | 票据信息(票号 / 到期日 / 贴现说明) | ARAP_ReceivePayment_MultiSettle.documentCode / amount / idsettlestyle | nvarchar(30) / decimal(13) | 票据类型枚举待 IT | 系统暂以备注承载 | 无票据到期日字段;承兑到期核销信息需 IT 确认 | 建议 | D? | ARAP_ReceivePayment_MultiSettle.documentCode(票据号) |
| 10 | 币种 / 汇率(如存在外币业务) | ARAP_ReceivePayment.idcurrency / exchangeRate | int(4) / decimal(13) | 币种表待索引 | 一期不落库(人民币记账) | 如存在外币业务再扩展 | 可选 | D | ARAP_ReceivePayment.idcurrency、exchangeRate |
## 8. 客户档案
采用往来单位表 AA_Partner;客户编码/停用字段存在,status 列需系统补。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 公司名称 | AA_Partner.name | nvarchar(200) | — | customers.company_name | TRIM;与负责人组成系统唯一键 | 必须 | D | AA_Partner.name(名称) |
| 2 | 负责人姓名 | AA_Partner.Contact(联系人)/ representative(法人代表) | nvarchar(50) | — | customers.owner_name | 「负责人」对应 Contact 还是 representative 待 IT/业务确认 | 必须 | D? | AA_Partner.Contact、representative |
| 3 | 客户编码 / 状态 / 启用停用时间 | AA_Partner.code / disabled / madeDate / updated | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用(候选) | customers.source_no / status(待补列) | 启停时间无独立列,可用 updated 近似(需 IT 确认) | 必须 | D? | AA_Partner.code、disabled、madeDate、updated |
| 4 | 联系人 / 电话 / 地址 / 行业 | AA_Partner.Contact / TelephoneNo / MobilePhone / CustomerAddress / iddistrict | nvarchar(30~200) | — | customers.contact_person / contact_phone / address / industry | 行业无直接字段;地区按 iddistrict 关联 | 可选 | D? | AA_Partner.Contact、TelephoneNo、MobilePhone、CustomerAddress、iddistrict |
| 5 | 开票信息(税号 / 开票名称 / 开票地址电话) | AA_Partner.taxRegcode / name / CustomerAddress | nvarchar(50~200) | — | 系统暂仅供展示,不落库 | 税号存在;开票地址电话需 IT 确认字段 | 可选 | D? | AA_Partner.taxRegcode(纳税号)、CustomerAddress |
## 9. 同步完整性与补充字段
时间戳与负向字段部分存在;无统一事件表、无明细行号、无手续费字段。
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 数据最后更新时间 / 单据创建时间 / 修改时间 | 各表 createdTime/createdtime、updated、madeDate、auditedDate、ts | datetime(8) / timestamp(8) | — | 建议新增 source_created_at / source_updated_at(当前无) | 本地 update_time 不可作为源端水位;增量水位字段待 IT(#9) | 必须 | D? | 各表 updated/createdTime;ts(分布时间戳) |
| 2 | 变更类型 / 单据事件:新增、修改、删除、作废、红冲、退货、冲销 | 各表 isCancel(状态)、isCounteractDelivery(冲抵销货)、returnQuntity(退货数量) | int(4) / tinyint(1) / decimal(13) | 状态/事件枚举待 IT 提供 | 建议事件表(当前无) | 无统一变更事件表;负向事实需事件模型,规则待 #8 | 必须 | D? | SA_SaleOrder.isCancel、SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity |
| 3 | 单据状态 / 财务审核状态 / 状态变更时间 | voucherState / auditedDate / auditor / auditorid | int(4) / datetime(8) / nvarchar(50) | 单据状态枚举待 IT | 不落库(仅同步校验) | 仅推已生效单据;状态随行校验 | 必须 | D | SA_SaleOrder/SA_SaleDelivery/ARAP_ReceivePayment 的 voucherState、auditedDate |
| 4 | 明细行行号 / 行状态 / 行级变更事件 | 明细表主键 ID、code(明细编号);无显式行号字段 | int(4) / nvarchar(30) | — | 建议补 source_line_no / source_item_id | 确认 code 是否即行号;否则用 T+ 明细 ID 作行级幂等键(待 IT) | 必须 | D? | SA_SaleOrder_b.id/code、SA_SaleDelivery_b.ID/code、ARAP_ReceivePayment_b.id/detailCode |
| 5 | 税率 / 税额 / 价税合计 / 折扣或折让 | 明细表 taxRate、tax、taxAmount、origTaxAmount、discount、discountRate、discountAmount | decimal(13) | — | 待业务 #20 答复后决定是否建列 | 字段齐全;金额是否含税、折扣关联方式待业务 | 必须 | D? | SA_SaleOrder_b/SA_SaleDelivery_b 税额与折扣字段 |
| 6 | 发货客户 / 收货人 / 收货地址 / 联系电话 | SA_SaleDelivery.idcustomer / address / contactPhone / idSendPerson / idConsignor | int(4) / nvarchar(100~200) | — | 建议预留(当前无列) | 用于核对发货对象与代发情形 | 必须 | D | SA_SaleDelivery.idcustomer、address、contactPhone、idSendPerson、idConsignor |
| 7 | 发货业务员 / 发货组织 / 仓库 | SA_SaleDelivery.idclerk / iddepartment / idwarehouse | int(4) | — | 建议预留(当前无列) | 识别代发、调拨与多组织差异 | 必须 | D | SA_SaleDelivery.idclerk、iddepartment、idwarehouse |
| 8 | 发货退回 / 红字发货 / 退回数量 / 退回金额 / 原发货单关联 | SA_SaleDelivery.isCounteractDelivery、_b.returnQuntity/returnQuntity2/backSentQuantity、sourceVoucher* | tinyint(1) / decimal(13) / nvarchar(50) | 冲抵/退货枚举待 IT | 待业务 #21 答复后决定是否建列 | 原单关联字段存在;是否冲减绩效待业务 | 必须 | D? | SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity、backSentQuantity |
| 9 | 收款客户 / 经手人 / 财务审核状态 | ARAP_ReceivePayment.idpartner / idHandler / auditor / voucherstate | int(4) / nvarchar(50) / int(4) | — | 建议预留(当前无列) | 核对实际付款主体与经办责任 | 必须 | D | ARAP_ReceivePayment.idpartner、idHandler、auditor、voucherstate |
| 10 | 收款分配明细 / 分配金额 / 未分配金额 / 分配对象 | ARAP_ReceivePayment_b(源单/订单/金额/未结算)、ARAP_StrikeBalance(核销) | decimal(13) / int(4) | — | 人工指定发货批次的辅助参考 | 分配关系仅参考;系统仍人工指定(对照表 #18) | 必须 | D? | ARAP_ReceivePayment_b.saleordercode/amount/origNoSettleAmount;ARAP_StrikeBalance |
| 11 | 预收款 / 保证金 / 代收款 / 折让 / 抵扣 | ARAP_ReceivePayment.paymentAmount/origPaymentAmount(预收/预付)、overPayment(超收转预收)、allowances(折让) | decimal(13) | — | 待业务 #22 答复后决定是否建列 | 字段存在;是否计入绩效回款待业务 | 必须 | D? | ARAP_ReceivePayment.paymentAmount、overPayment、allowances |
| 12 | 手续费 / 交易费用 / 收款净额 | 未检出手续费/交易费用字段 | — | — | 待业务 #23 答复后决定 | 需 IT 确认手续费是否在其他单据/字段 | 必须 | N | ARAP_ReceivePayment 无 fee/charge 字段 |
| 13 | 红字收款 / 收款作废 / 收款退回 / 冲销及原单关联 | ARAP_ReceivePayment.voucherstate / isReceiveFlag;ARAP_StrikeBalance 冲销 | int(4) / tinyint(1) | 作废/红字状态枚举待 IT | 待业务 #24 答复后决定 | 无红字收款专用字段;负向事实需事件模型 | 必须 | D? | ARAP_ReceivePayment.voucherstate、isReceiveFlag;ARAP_StrikeBalance |
| 14 | 客户编码 / 客户状态 / 启用停用时间 | AA_Partner.code / disabled | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | customers.source_no / status(待补列) | 同六-3;客户编码唯一性待 IT | 必须 | D | AA_Partner.code、disabled |
| 15 | 客户分类 / 渠道 / 区域 / 所属组织 | AA_Partner.idpartnerclass / customerType / iddistrict / idMarketingOrgan | int(4) | 分类/客商类型枚举待 IT | customers.customer_type(不覆盖);渠道无列 | 系统客户分类人工维护;渠道/区域作备考 | 必须 | D? | AA_Partner.idpartnerclass、customerType、iddistrict、idMarketingOrgan |
| 16 | 客户名称变更 / 编码停用合并前后的对应关系 | AA_Partner.code(终身不变)+ updated;无变更历史表(待确认) | nvarchar(32) | — | 靠 source_no 追溯同一主体 | 改名/换负责人/合并靠 code 串同一主体;变更历史需 IT 确认 | 必须 | D? | AA_Partner.code、updated、HasEverChanged |
| 17 | 人员部门 / 岗位 / 调岗时间 / 离职和组织变更时间 | AA_Person.iddepartment / position / updated;调岗历史表待确认 | int(4) / datetime(8) | — | 不落库(备考/排查) | 字段部分存在;调岗/离职时间需 IT 确认 | 必须 | D? | AA_Person.iddepartment、position、updated |
| 18 | 产品编码 / 产品状态 / 启用停用时间 | AA_Inventory.code / disabled / madeDate | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | products.source_no / status | 稳定匹配;停用后历史明细仍可回溯 | 必须 | D | AA_Inventory.code、disabled、MadeRecordDate |
| 19 | 字典和枚举取值范围 / 编码与名称对照说明 | 枚举表:AA_SettleStyle、业务类型表、状态字典(需随数据导出) | — | 枚举字典随数据提供 | 同步映射表 | 不能只给代码;需编码与名称对照 | 必须 | D? | AA_SettleStyle、AA_BusinessType 等枚举表 |
| 20 | 对账汇总信息 / 数据记录数 / 数量合计 / 金额合计 / 抽取时间 | 由 IT 提供抽取汇总(接口/视图层) | — | — | 同步日志/对账表 | 记录明细条数、金额合计、抽取时间 | 必须 | IT | 字典不提供;需对接方式确定 |
| 21 | 单据历史版本 / 修改留痕(如 T+ 提供) | 未检索到统一单据历史版本表(待 IT 确认) | — | — | 系统同步快照可比对 | 无此数据不阻塞 | 可选 | D? | 表索引未见 history 类表 |
## 10. 跨表关联与键
| 关系 | T+ 字段(字典) | 系统目标 | 状态 |
| --- | --- | --- | --- |
| 人员唯一键 | AA_Person.code | personnel.source_no(建议补 personnel_code) | D? |
| 客户唯一键 | AA_Partner.code;名称+联系人 | customers.source_no;company_name+owner_name | D? |
| 订单→发货 | SA_SaleDelivery.SourceVoucherCode / _b.saleOrderCode/saleOrderId | shipments.business_id | D? |
| 订单/发货→回款 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;_b.saleordercode | receipts.business_id | D? |
| 回款→发货批次 | 系统人工指定(对照表 #18) | receipts.shipment_id | S |
| 明细行幂等 | 明细表 ID/code;无显式行号 | 建议补 source_line_no | D? |
## 11. 枚举与状态
| 枚举 | T+ 来源 | 备注 |
| --- | --- | --- |
| 收款/结算方式 | AA_SettleStyle(code/name/disabled) | 完整枚举需随数据导出 |
| 单据状态 | 各单据 voucherState / isCancel | 仅同步校验,不落库 |
| 核销状态 | ARAP_ReceivePayment.cancelState | 含义待 IT |
| 业务类型 | SA_SaleOrder.idbusinesstype → 业务类型表 | 是否等同特殊业务待确认 |
| 产品分类 | AA_InventoryClass | 仅辅助系统分类人工维护 |
## 12. 系统侧目标表缺口
| 缺口 | 说明 | 建议 |
| --- | --- | --- |
| `personnel.personnel_code` | T+ 工号/编码是否即 `source_no` 未定 | 若不等则补唯一列 |
| `customers.status` / 启停时间 | T+ 有 disabled,系统无列 | 补 status 与停用时间 |
| `receipts.payment_method` / `confirm_status` | T+ 有结算方式与核销状态 | 补两列并接枚举 |
| `source_created_at` / `source_updated_at` | 本地 update_time 不可作源端水位 | 补列或建 staging 表 |
| `source_line_no` / `source_item_id` | 明细表无行号 | 补列支持行级幂等 |
| 负向事件模型 | 无删除/作废/红冲/退货事件表 | 设计事件表或冲销单 |
| `sync_log` / 失败明细表 | 完全缺失 | 同步模块前置 |
| T+ 配置项 | `.env` 有 `T_PLUS_*`,`Settings` 未定义 | 补配置字段与校验 |
## 13. IT 待确认事项收敛(18 → 13 + 1 组业务)
| 新# | 原# | 事项 | 字典证据 | 字典可答 | 仍需确认 | 优先级 |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType;AA_Inventory.invSCost/latestCost/avagCost | 字段存在性、类型精度、挂在订单还是出库单 | 请确认绩效成本取哪个字段(订单最新成本 / 出库成本 / 其他),取数时点与精度 | 高(阻塞核算) |
| 2 | 2+3 | 发货日与到账日口径 | 发货:SA_SaleDelivery.voucherdate/SendDate/deliveryDate/auditeddate;到账:ARAP_ReceivePayment.voucherDate/auditedDate/createdTime | 各日期字段存在性与类型 | 请分别确认发货日、到账日取哪个字段;签收日/银行到账日能否从 T+ 获取 | 高 |
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键;SA_SaleOrder.ismodifiedcode、HasEverChanged | 编码字段与主键关系 | 各单据 code 是否唯一终身不变;作废重开是否换号;建议以 code 作 source_no | 高 |
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode/idsourcevouchertype;SA_SaleDelivery_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b;ARAP_StrikeBalance | 关联字段与核销表存在性 | 请确认订单→发货→回款的主关联路径(按单号还是 ID),以及分批发货/部分核销如何取 | 高 |
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment/isSalesman;AA_Department.idparent/iddirector | 编码/部门/负责人字段存在;无直属上级字段 | AA_Person.code 是否即工号;业务员→营销经理隶属在 T+ 如何维护(部门负责人?自定义项?) | 高 |
| 6 | 7 | 对接方式与安全 | 字典不涉及 | 无 | REST/视图/中间表/文件;只读账号与凭据、接口文档、样例数据 | 高 |
| 7 | 8 | 负数/红字/退货/冲销规则 | SA_SaleDelivery.isCounteractDelivery;SA_SaleDelivery_b.returnQuntity/backSentQuantity;ARAP_ReceivePayment.voucherstate;ARAP_StrikeBalance | 负向字段/单据形式存在性 | 负向单据的实际使用形式、原单关联字段、是否推送;绩效影响转业务 | 高 |
| 8 | 9 | 存量起始与增量水位 | 各表 updated/createdTime/madeDate/auditedDate/ts | 时间戳字段存在性 | 存量起始日期;增量用哪个时间戳(updated? ts?);时区 | 中 |
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 编码/名称/负责人/停用字段 | 客户唯一键;改名/换负责人/合并如何映射同一主体;负责人取 Contact 还是 representative | 中 |
| 10 | 11+16 | 收款方式与确认/核销枚举 | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle.code/name;ARAP_ReceivePayment.cancelState/voucherstate | 枚举表与状态字段存在性 | 收款方式完整枚举与以物抵顶对应代码;核销/确认状态枚举含义及可否作为人工确认依据 | 高 |
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量/价格字段存在性 | 产品「数量」取现存量/在途/累计销量;发货单价取订单价还是发货时价、含税与否 | 中 |
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 字段/表存在性 | 特殊业务是否有可识别字段;市场拓展计划 T+ 是否有表可提供;无则人工维护 | 低 |
| 13 | 15 | 同步窗口 | 字典不涉及 | 无 | 每周同步时间点,避开结账/备份;失败重试策略 | 低 |
| B1 | 18 | 业务裁决 5 项 | 税额/折扣/退货/预收/手续费字段已定位(待答复后决定是否建列) | 字段存在性 | 含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述 | 业务裁决 |
### 原 18 项对照
| 原# | 事项 | 原优先级 | 收敛去向 |
| --- | --- | --- | --- |
| 1 | 成本额取数口径 | 高(阻塞核算) | 1 |
| 2 | 发货日期口径 | 高 | 2 |
| 3 | 到账日期口径 | 高 | 2 |
| 4 | 幂等唯一键 | 高 | 3 |
| 5 | 三单关联键 | 高 | 4 |
| 6 | 人员匹配键与经理隶属(v1.2 更新) | 高 | 5 |
| 7 | 对接方式与安全 | 高 | 6 |
| 8 | 负数 / 红字 / 退货 / 冲销处理规则 | 高 | 7 |
| 9 | 存量范围与增量口径 | 中 | 8 |
| 10 | 客户档案匹配与唯一键 | 中 | 9 |
| 11 | 收款方式枚举 | 中 | 10 |
| 12 | 产品「数量」语义 | 中 | 11 |
| 13 | 发货单价口径 | 中 | 11 |
| 14 | 特殊业务标记 | 低 | 12 |
| 15 | 同步窗口 | 低 | 13 |
| 16 | 回款确认 / 核销状态 | 高 | 10 |
| 17 | 市场拓展计划数据 | 低 | 12 |
| 18 | 口径裁决 5 项(移交业务方) | 业务裁决 | B1 |
## 14. 待确认清单
**IT(13 项)**:见第 13 节新# 1–13。
**业务(1 组)**:含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18 / 待澄清 #20–#24)。
**系统侧(不阻塞 IT)**:补 `personnel_code`、`customers.status`、`receipts.payment_method/confirm_status`、`source_updated_at`、`source_line_no`、`sync_log` 与 T+ 配置项。
## 15. 验证记录
- 解包:1,457 个 HTML、`.hhc/.hhk` 齐全、零字节 0;
- 全量解析:1,457 页、938 个 Table 类型页、**931 张表、21,019 个字段、0 失败页**;
- 编码:逐页检测,GB18030(无替换字符);
- 抽样核验:六类主表结构一致(6 列字段表),关键字段逐项比对通过;
- 输入 SHA-256:`3e8cd3146e22bdebe8bacda56b5dafbbd8be744c4279d7b2bbf3d7ecdc7e2726`;
## 附录 A:六类主表索引
| 类别 | T+ 表 | 字典页 GUID |
| --- | --- | --- |
| 人员 | AA_Person | `fcd00c17-6778-4c6a-90ae-3f0dbaf2c102` |
| 产品 | AA_Inventory | `93b57dfa-4ca4-411d-a883-6d26c8e01787` |
| 客户 | AA_Partner | `871e93dd-c2cf-4bd2-af91-cbeb5951ac5e` |
| 业务 | SA_SaleOrder | `9a51f8a9-658a-4f7c-9372-8af2257511ee` |
| 发货 | SA_SaleDelivery | `bef98b52-c9f0-4d80-8e62-8968cced5b52` |
| 回款 | ARAP_ReceivePayment | `709de6e4-3b3d-4cf8-b5ed-5f657d42599a` |
## 附录 B:75 项覆盖矩阵
| 节 | 项数 | 已映射 |
| --- | --- | --- |
| 公共要求 | 2 | 2 |
| 人员 | 9 | 9 |
| 产品 | 7 | 7 |
| 业务 | 13 | 13 |
| 发货 | 8 | 8 |
| 回款 | 10 | 10 |
| 客户 | 5 | 5 |
| 同步完整性 | 21 | 21 |
## 附录 C:失败页
无(`failures.csv` 为空)。
## 附录 D:复现方法
```text
1. C:/Windows/hh.exe -decompile <out> <TPlus123.chm>
2. py -3.14 -I 03_extract_fields.py --extracted <out> --out <out>
3. py -3.14 -I 08_build_md.py --out <out> --target <md>
```
---
*本文档由数据字典解析与人工核验生成,字段候选未经 IT 确认前不得视为同步契约。*
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -45,12 +45,12 @@
② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游)
③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户
│ 增量(2026-10-15):二次分配方案(合同刚建立时录入)
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清)
⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约
⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层
⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早
④ 绩效工资核算 ✅ ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1)
⑦ 复核审批流(新)✅ ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
⑤ 报表统计 ✅(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约(待 IT 18 项答复)
⑧ 工作台(新)✅ ← 消费 ④⑤⑥⑦ 数据的聚合展示层
⑨ 备份恢复与运维检查(新)✅ ← 独立于业务块,可与任一批次并行,建议尽早
```
### 1.2 顺序论证(要点)
@@ -68,12 +68,12 @@
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 |
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 |
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(语义待澄清,见 3.3) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions) | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态 | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本 | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计。**✅ 已完成(2026-10-19)** | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发。**⏳ 阻塞(IT 18 项未答复)** | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions)。**✅ 已完成(2026-10-19;权限细目待业务清单)** | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态。**✅ 已完成(2026-10-19)** | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本。**✅ 已完成(2026-10-16)** | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
### 1.4 里程碑(交付批次)
@@ -81,9 +81,9 @@
| --- | --- | --- |
| **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) |
| **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) |
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流 | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」;开工前必须澄清:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7);内部建议顺序 ①增量 → ③增量 → ⑦ 审批框架 → ④ 核算 → 月结与审批串联 |
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台 | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付) | 数据持续积累,备份先行;不依赖业务模块进度 |
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流。**✅ 已完成(2026-10-19,feat/m3-increments)** | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」已交付;余 3.3-1 答复与审批权限细目。开工前澄清项:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7,已参数化) |
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台。**✅ ⑤⑧ 已完成(2026-10-19);⑥ 待 IT 18 项答复** | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付)。**✅ 已完成(2026-10-16)** | 数据持续积累,备份先行;不依赖业务模块进度 |
### 1.5 需求依据对照
@@ -95,7 +95,7 @@
| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24;增量见 3.1(一)(四) | 协助 25%;经理 8‰;26 天示例;含运费公式 |
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R);增量见 3.1(四) | — |
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——尚未回填需规与对照表,引用以第三章原文为准 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制,关系待业务澄清(3.3-1) |
| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——已随 SRS V2.1(2026-10-19)回填;《问题与解决方案对照表》是否回填由用户决定 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制;#1–#3 答复仍空白,开发侧暂定口径已实现(3.3.1) |
---
@@ -610,11 +610,13 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
> **2026-10-20 回填**:上述开放问题均已有结论,原表保留为 2026-10-12 设计时快照。#1 → 澄清 #14(首日整 10‰,A 口径);#2 → #18(按 2‰ 回溯,双种子保留);#3 → #15(一期仅 FULL,BALANCE 留参数位);#4 → #16(撤 `probation.salary.basic`,读 `personnel.salary_standard`);#5 → #17(查看放开登录用户、修改仅管理员);#6 → 催办清单三、R3;#7 → #12(`manager.personal.included` 参数,默认 0=不计入)。
---
## 第三章 2026-10-15 功能清单核查补充(新增需求)
> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容尚未回填需规文档与《问题与解决方案对照表》,是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。
> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容已随《需求规格说明书》V2.1(2026-10-19)回填;《问题与解决方案对照表》是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。
### 3.1 需求原文(照录)
@@ -656,6 +658,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
#### (六)系统设置
- 公司信息配置:公司名称、logo 等基本信息
- **2026-10-20 回填**:参数注册表已扩至 22 项(V8/V10 增补审批开关、试用期迁移、预警阈值等)
#### (七)参数配置强调
@@ -666,7 +669,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
#### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接
- 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `businesses` 创建同事务或紧随其后);合同建立后是否允许补录/修改未明说,方案修改的留痕方式待设计;
- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1),存储上只存各参与人比例、不强制校验合计;
- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1);存储上只存各参与人比例、不强制校验合计。**2026-10-20 回填**:#1–#3 答复仍空白,开发侧已按暂定口径实现分摊拆行(余额归负责人、行合计守恒,见 3.3.1),答复后如有变更走口径调整;
- 「修改不影响已结算月份」→ 月结快照天然隔离(快照固化当月结果),方案表需支持编辑 + 修改留痕;
- 「已结算月份调整只能通过调整单或后续月份扣补」→ 调整单为统一承载机制,见 (四)。
@@ -676,7 +679,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
| --- | --- |
| 经营绩效总览 | ④ 台账/月结(按人/团队/月份汇总) |
| 待办事项 | ⑦ 待审批单、⑥ 同步失败、回款未指定批次(M2 已有该状态可复用) |
| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器 |
| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器。**2026-10-20 回填**:方向已答复参数化(#10),首批三条按默认落 V10 参数(逾期 90 天/审批超时 3 天/同步失败待 ⑥) |
| 最近同步/计算/审批动态 | ⑥ 同步日志 + ④ 核算/月结记录 + ⑦ 审批记录 |
#### (三) 复核审批 → ⑦ 新模块
@@ -687,9 +690,11 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
- 「审批意见与版本」→ 审批单带版本号:重算/改单产生新版本,旧版本与意见留痕不覆盖;
- **登录角色体系扩展**:营销后勤/财务/领导是系统新登录角色(`roles` 表基础设施已有;`permissions`/`role_permissions` 自 M1 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。
> **2026-10-20 回填**:审批框架与月结审批对象已交付(2026-10-19);权限点清单仍待业务方给出(澄清 #6,催办 B-6),当前按 role_code 粗粒度运行,营销后勤暂不可见审批列表。
#### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」定义待澄清(3.3-3);
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」已答复为人工确认、可手动关闭(3.3-3),月结按已确认回款取数;
- 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径;
- 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态);
- 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据;
@@ -708,6 +713,8 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清)
> **回填状态(2026-10-20):** 下表保留 2026-10-15 原始问题与建议默认(历史快照);业务答复与落实情况见 3.3.1。3.3-1 仍无业务答复,开发侧已按暂定口径实现分摊拆行,答复后如有变更按口径调整。
| # | 事项 | 影响模块 | 建议默认处理 |
| --- | --- | --- | --- |
| 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 |
@@ -718,27 +725,41 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 |
| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 |
### 3.3.1 答复回填(2026-10-19)
### 3.3.1 答复回填(2026-10-19,2026-10-20 修订)
> 业务方 24 项答复已到(2026-10-16,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列),2026-10-19 全部落实并实现;本节为 3.3 各项的落定状态,供后续引用。答复与默认不同处以答复为准;仍未答复项见 `docs/待答复催办清单_2026-10-19.md`。
> **修订说明(2026-10-20):** 2026-10-19 版本把 24 项统一写成「全部落实并实现」,与 3.3-1 答复空白、开发侧暂定分摊的事实不符。本次按「业务答复 / 落实状态 / 关联项」重写;3.3-1 明确标注为开发侧暂定口径,不视为业务确认。
>
> 业务方 24 项答复于 2026-10-16 到达(回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)。分组:确认按建议默认 13 项(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#22/#23/#24,其中 #22 为模糊确认按不计入);与默认不同、已按答复落实 4 项(#5/#14/#16/#19);部分答复 3 项(#4/#6/#12);仍开放 4 项(#1/#2/#3/#20)。未答复项对外跟催见 `docs/待答复催办清单_2026-10-19.md`。
| 3.3# | 事项 | 落定状态 |
| --- | --- | --- |
| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 关系) | **答复仍空白;2026-10-19 已按建议默认实现分摊拆行**——分配标的=绩效金额份额(参与人份额×比例、负责人拿余额、行合计守恒、合计>100% 与负责人自分 400 拦截);协助 25% 维持仅记录不核算;业务答复后如有变更走口径调整 |
| 2 | 二次分配审批开关默认值 | 已答复:**默认开启**,可手动关闭(已落实 V8 参数种子) |
| 3 | 「确认回款」定义 | 已答复:需人工确认动作,可手动关闭(系统已按此预留并实现) |
| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认;现按最小授权运行(后勤=提交录入,财务=复核+导出,领导=审核只读),营销后勤暂不可见审批列表 |
| 5 | 风险预警规则 | 已答复:方向确认参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步——⑧ 工作台实现 |
| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 已答复:同一审批链;跨月调整需管理员授权并留痕(已实现) |
| 7 | 待办事项范围 | 已答复:待审批 + 同步失败 + 回款未指定批次起步——⑧ 工作台实现 |
| 3.3# | 事项 | 业务答复(2026-10-16) | 落实状态(2026-10-20) | 关联项 / 证据 |
| --- | --- | --- | --- | --- |
| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 的关系) | **未答复**(澄清 #1–#3 空白) | **开发侧暂定口径已实现**(非业务答复):分配标的=绩效金额份额;参与人=份额×比例,负责人=全额−已分累计(余额归负责人);行合计守恒;合计>100% 与负责人自分 400 拦截;重算作废全部拆行。协助 25% 维持仅记录不核算。**答复后校准** | 澄清 #1/#2/#3;催办一、A-1 至 A-3;实现 ccfd206 |
| 2 | 二次分配审批开关默认值 | 默认开启,可手动关闭 | 已落实:V8 参数种子默认开启;关闭时提交即生效(自动通过留痕) | 澄清 #5;`approval.secondary_allocation.enabled` |
| 3 | 「确认回款」定义 | 需人工确认动作,可手动关闭 | 已落实:月结按已确认回款取数;回款确认状态可手动关闭 | 澄清 #8 |
| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认 | 按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读;营销后勤暂不可见审批列表;permissions/role_permissions 细目待清单 | 澄清 #6;催办一、B-6 |
| 5 | 风险预警规则 | 方向确认参数化;首批三条未点名 | 已按默认三条实现:`warning.enabled`、`warning.receipt.overdue.days=90`、`warning.approval.timeout.days=3`(V10);同步失败类待 ⑥ | 澄清 #10 |
| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 同一审批链;跨月需管理员授权并留痕 | 已落实:调整单走同一审批链;跨月调整需管理员授权并留痕 | 澄清 #9 |
| 7 | 待办事项范围 | 待审批 + 同步失败 + 回款未指定批次起步 | 已落实:工作台待办三项;⑥ 未建时同步项显式降级为「模块未启用」;待办按角色过滤 | 澄清 #11 |
#### 关联项表(与《待确认事项汇总_2026-10-16》及催办清单的对应)
| 3.3# | 澄清清单编号 | 催办清单位置 | 实现落点 |
| --- | --- | --- | --- |
| 1 | #1/#2/#3(#15 为作废条款关系) | 一、A-1 至 A-3 | 二次分摊拆行(ccfd206) |
| 2 | #5 | 已按答复执行(供核阅) | V8 迁移参数种子 |
| 3 | #8 | 已按答复执行(供核阅) | 月结取数(settlement_service) |
| 4 | #6 | 一、B-6 | permissions/role_permissions 待清单;当前按 role_code 粗粒度 |
| 5 | #10 | 已按答复执行(供核阅) | V10 参数 + dashboard_service |
| 6 | #9 | 已按答复执行(供核阅) | 调整单审批链与跨月授权 |
| 7 | #11 | 已按答复执行(供核阅) | dashboard_service 待办三源 |
### 3.4 对路线图的影响(本次已修订)
1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容;第三章待 3.3 各项澄清后,按第二章同粒度补齐 M3 各增量块的任务级实施计划。
1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容。M3 各增量块已于 2026-10-19 交付;3.3-1 仍无业务答复,二次分摊当前按开发侧暂定口径运行,答复后补口径变更计划;3.3-4 的权限细目与 3.3-5 的预警规则仍待业务确认。⑥ T+ 同步在 IT 答复前不排任务级计划。
## 验证方式
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量 301+ 用例与 e2e 全绿。
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量后端用例与 e2e 全绿(以 `backend/tests/README.md` 最新运行记录为准)。
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10 -8
View File
@@ -4,6 +4,7 @@
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。
> **2026-10-20 更新**:开发计划 3.3 已回填(3.3.1),SRS V2.1 已交付;二次分摊已按开发侧暂定口径实现(#1–#3 答复仍空白);code-review 遗留 2 项(方案版本承载、审批单内容快照)已随 V11 迁移收口。
## 一、立即待办(需项目内处理)
@@ -14,16 +15,16 @@
## 二、业务方澄清(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 与需求记录。
> 答复汇总:**13 项确认按默认执行**(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#22/#23/#24,其中 #22 为模糊确认、按不计入);**4 项答复与默认不同,以答复为准**——#5 二次分配审批开关**默认开启**、#14 **首日整 10‰**、#16 试用期基本工资**按人员档案逐人维护**、#19 备份**加增量补充**;部分答复 3 项(#4/#6/#12),仍开放 4 项(#1–#3/#20)。开发计划 3.3 已于 2026-10-20 回填(3.3.1),未答复项仍按催办清单跟进。
### 高优先级(阻塞对应模块开工,8 项)
| 清单编号 | 问题 | 建议默认(未答复时执行) | 阻塞模块 | 答复状态 |
| --- | --- | --- | --- | --- |
| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 未答复前只做方案录入,不实现分摊(已按此交付) | ④ 分摊计算 | ❌ 未答复 |
| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 方案录入已交付;2026-10-19 已按开发侧暂定口径实现分摊拆行(非业务答复,答复后校准) | ④ 分摊计算 | ❌ 未答复 |
| 2 | 分配比例合计不足 100% 时余额归谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | ❌ 未答复 |
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ | ❌ 未答复 |
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊实现(88 批次)进行中 |
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊已于 2026-10-19 实现(ccfd206) |
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** |
| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 |
| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 |
@@ -41,7 +42,7 @@
| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL |
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` |
| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 |
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ❌ 未答复:「当前未提到预收款情况」,暂按不计入 |
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ⚠️ 模糊答复:「当前未提到预收款情况」,暂按不计入 |
### 低优先级(已有默认口径,顺手确认,7 项)
@@ -55,8 +56,9 @@
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
## 三、待 IT / T+ 侧答复(18 项,见《T+对接数据需求清单》v1.3「待IT确认事项」表)
## 三、待 IT / T+ 侧答复(18 项 → 收敛为 13 项 + 1 组业务裁决)
> **2026-10-20 更新**:T+12.3 数据字典已解析(1,457 页 / 931 张表 / 21,019 个字段),字段级映射已产出:`docs/T+字段映射草案_v0.1_2026-10-20.md`、`docs/T+对接数据需求清单_v1.5_2026-10-20.xlsx`。原 18 项收敛为 13 项 IT 待确认(每项附字典候选字段)+ 1 组业务裁决,详见催办清单第二节与 v1.5「待IT确认事项」sheet。以下原表保留为 2026-10-19 基线。
> **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+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。
@@ -83,15 +85,15 @@
| 里程碑 | 内容 | 前置条件 |
| --- | --- | --- |
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;**余**:二次分摊实现(#1–#3 答复空白)、业务答复回填批次收口(88 会话) | #20(含税口径)、#1–#3 答复后按同粒度推进 |
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;二次分摊已按开发侧暂定口径实现(ccfd206),业务答复回填已完成(计划 3.3.1,2026-10-20);**余**:#1–#3/#20 答复与 #4/#6/#12 补值 | #20(含税口径)、#1–#3 答复后按同粒度推进 |
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 |
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
| code-review 遗留 2 项 | ✅ **已完成(2026-10-20,V11)**:方案行 append-only 版本化(is_active/version_no);审批单加版本号与 JSON 内容快照,详情按 snapshot/live/missing 展示 | — |
| 杂项收口(低优) | 超长函数收敛: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)
- 澄清答复已到(2026-10-16):开发计划 3.3 已回填(3.3.1,2026-10-20)、SRS V2.1 已交付(2026-10-19);《T+对接数据需求清单》已升 **v1.5**(2026-10-20,含 75 项映射 + 13 项 IT 收敛 + 不覆盖 12 条),映射草案 v0.1 已产出
- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)**
- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可
+36 -23
View File
@@ -1,7 +1,7 @@
# 待答复事项催办清单(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 项已裁决口径)。
> **基线**:《待澄清问题清单_2026-10-15(1)(2).xlsx》(业务 24 项,2026-10-16 已答复 19 项,其中部分答复 3 项;#1–#3/#20 仍空白)、《T+对接数据需求清单》v1.3「待IT确认事项」18 项 + v1.4(2026-10-19 勘误)、《问题与解决方案对照表_v2》(28 项已裁决口径)。
> **原则**:已答复项不重复询问;未答复项按清单内「建议默认」执行中,答复后以答复为准并回填文档。
## 一、业务方(建议回复期限:____)
@@ -10,12 +10,12 @@
| # | 问题(原文口径) | 当前执行默认 | 阻塞 |
| --- | --- | --- | --- |
| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 未答复前只做方案录入,**不实现分摊计算**(已按此交付) | ④ 分摊计算 |
| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 方案录入已交付;**分摊已按开发侧暂定口径实现**(非业务答复,答复后校准) | ④ 分摊计算 |
| 2 | 分配比例合计不足 100% 时,剩余部分归属谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 |
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ 分摊 |
| 20 | 含税口径:销售/成本/毛利润/运费各项是否含税?价税合计与折扣折让如何处理? | 按不含税口径实现中 | ④ 核算口径终验、⑥ 同步建列(是否建税率/税额列) |
> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批,但**分摊金额不会出现在绩效中**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。
> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批;开发侧已按暂定口径把分摊拆行计入绩效(参与人份额×比例、负责人余额),**标的与余额归属仍待业务答复,答复后可能调整**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。
### B. 已有答复但需补具体值 / 勾选(4 项)
@@ -33,37 +33,50 @@
| 人员识别 | 「负责营销人员以姓名为唯一识别」 | 改为「以人员编码(工号)为唯一识别、姓名辅助核对」——原表述与「编码为业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误,请确认 |
| 回款↔发货批次 | 「由 T+ 收款分配关系指定,人工辅助」 | 改为「**系统人工指定**(对照表 #18 定稿),T+ 收款分配关系仅作辅助」——主次相反疑漏改,请确认 |
### D. 2026-10-20 新增:需业务定口径(2 项)
> 随「审批单内容快照 + 二次分配方案版本化」收口带出,开发侧已按现行默认实现,答复后按答复调整。
| # | 事项 | 当前默认(开发侧已实现) | 需确认 |
| --- | --- | --- | --- |
| 25 | **业务硬删除时,方案版本历史是否保留?** | 随业务一并删除(与既有「终态审批单随业务删除一并清理」规则一致)——业务被删后,方案参与人与比例的版本痕迹也不复存在 | 是否需要保留(如留归档表/只留审批单快照),还是维持一并删除 |
| 26 | **离职的历史参与人重新加入方案,是否允许?** | 按新参与人处理:曾参与过但当前不在生效方案里的人,若已离职,重新加入会被「离职人员禁入」拦截 | 是否应放行(把「历史参与过」视为豁免),还是维持按现任职校验 |
### 已按默认执行、无需回复的项(供核阅)
#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。
\#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 清单中「系统侧目标表.字段」列**全表留空待回填**。
> **2026-10-20 更新**:已用 T+12.3 数据字典完成字段级核对(1,457 页 / 931 张表 / 21,019 个字段),下列问题由 18 项收敛为 **13 项 IT 待确认 + 1 组业务裁决**,每项附字典候选字段。字段级证据与来源页见《T+字段映射草案_v0.1_2026-10-20.md》,完整清单见《T+对接数据需求清单_v1.5_2026-10-20.xlsx》。
### A. 高优先(9 项,阻塞核算口径与同步程序开工)
### A. 待确认(按优先级)
| # | 事项 | 为什么高 |
| --- | --- | --- |
| 1 | **成本额取数口径**:成本类型/毛利计算方式/更新时间/精度 | 毛利润 = 销售额 − 成本额,是绩效金额全部源头;缺此则同步无法自动跑,只能人工录入 |
| 2 | 发货日期口径:出库日 / 签收日 / 开票日 | 回款天数基准日(发货日首日计第 1 天),直接决定计提比例 |
| 3 | 到账日期口径:收款日 / 银行到账日 / 核销日 | 计提参数版本匹配日,直接决定适用哪一版比例 |
| 4 | 幂等唯一键规则:作废重开是否换号 | 重复同步不重复入库的前提 |
| 5 | 三单关联键:订单 / 发货 / 回款如何互相关联 | 一笔业务可分批发货;回款↔批次系统内人工指定,但订单↔发货关联仍必须由 T+ 提供 |
| 6 | **人员唯一编码(工号)** + 业务员↔营销经理隶属关系 | 业绩归属唯一锚点(重名即错钱);经理 12.5% 团队聚合依隶属关系展开 |
| 7 | 对接方式与安全:REST / 数据库视图 / 中间表 / 文件交换 | 未定则同步程序无法开工(一期人工录入可闭环) |
| 8 | 负数 / 红字 / 退货 / 冲销处理规则与原单关联 | 决定第七节补充字段如何建列;如不提供则只能靠人工补录 |
| 16 | 回款确认 / 核销状态字段与枚举 | 月度结算按「确认回款」生成,需 T+ 核销/确认类字段作人工确认依据 |
| 新# | 原# | 事项 | 字典候选(已定位) | 仍需答复 |
| --- | --- | --- | --- | --- |
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType | 取订单最新成本还是出库成本;取数时点与精度 |
| 2 | 2+3 | 发货日与到账日口径 | 发货:voucherdate/SendDate/deliveryDate/auditeddate;到账:voucherDate/auditedDate/createdTime | 分别取哪个字段;签收日/银行到账日能否提供 |
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键 | code 是否终身唯一;作废重开是否换号 |
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode、_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_StrikeBalance | 主关联路径;分批发货/部分核销如何取 |
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment;AA_Department.idparent/iddirector(无直属上级字段) | code 是否即工号;业务员→经理隶属如何维护 |
| 6 | 7 | 对接方式与安全 | 字典不涉及 | REST/视图/中间表/文件;只读账号、接口文档、样例数据 |
| 7 | 8 | 负数/红字/退货/冲销规则 | isCounteractDelivery、returnQuntity/backSentQuantity、voucherstate、ARAP_StrikeBalance | 实际使用形式、原单关联、是否推送 |
| 8 | 9 | 存量起始与增量水位 | updated/createdTime/madeDate/auditedDate/ts | 存量起始日;增量用哪个时间戳;时区 |
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 唯一键;改名/换负责人/合并;负责人取 Contact 还是 representative |
| 10 | 11+16 | 收款方式与确认/核销枚举 | AA_SettleStyle;ARAP_ReceivePayment.IdSettleStyle/cancelState/voucherstate | 完整枚举与以物抵顶代码;核销状态含义与人工确认依据 |
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量取现存量/在途/累计销量;发货单价口径与含税 |
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 有无可识别字段/表;无则人工维护 |
| 13 | 15 | 同步窗口 | 字典不涉及 | 每周时间点,避结账/备份;失败重试策略 |
### B. 回稿三缺口(请补)
### B. 口径裁决(1 组,转业务方)
1. v1.3 第七节曾漏「人员唯一编码(工号)」——v1.4 数据清单第一节已补★项并注明「待IT确认 #19:T+ 档案编码是否即工号,若为自动内部编码请指明工号字段」;
2. 「客户档案是否同步」(待IT确认 #10)未点名答复——v1.4 第六节已列客户档案 5 项,请确认可提供;
3. 「系统侧目标表.字段(T+ 确认映射)」列全表留空——请回填,这是同步程序的字段契约。
含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18,对应《待澄清问题清单》#20–#24)。
### C. 中低优先(9 项,模块实现期内答复即可)
### C. 已由字典补齐、仅需确认可用性
存量数据起始日期与增量口径(影响能出多早的报表)、客户匹配与唯一键、收款方式完整枚举清单、产品「数量」语义(库存量/在途/累计销量)、发货单价口径(订单价/发货时价)、特殊业务标记字段、同步窗口时间、市场拓展计划数据、待IT确认 #18(口径裁决 5 项已转业务方,见第一部分)。
1. **人员编码**:字典已定位 `AA_Person.code`,请确认是否即工号;若为自动内部编码,请指明工号字段。
2. **客户档案**:字典已定位 `AA_Partner` 主表(编码/名称/联系人/停用/地区等),请确认可提供。
3. **映射列**:v1.5 清单已回填「T+ 表.字段 / 类型 / 枚举 / 系统目标列」,请按实际环境核对并修正。
## 三、报表口径补充确认(2026-10-19 新增,随本轮开发一并排)
+241
View File
@@ -0,0 +1,241 @@
衍水集团营销人员工资管理系统
部署硬件需求说明
================================================================
版本:v1.0
日期:2026-10-21
适用:内部系统部署(数十人并发,无公网访问)
依据:仓库实际运行架构(docker/docker-compose.dev.yml、backend/pyproject.toml、
backend/app/core/db.py、docs/备份恢复手册.md、ai-aux/AI_CONTEXT.md)
================================================================
一、结论摘要(先看这一节)
================================================================
推荐配置:2 核 CPU / 8 GB 内存 / 100 GB SSD(Linux 单机)
最低配置:2 核 CPU / 4 GB 内存 / 100 GB SSD
演示配置:1 核 CPU / 2 GB 内存 / 40 GB SSD(仅限试用,不适合正式使用)
一句话:内部系统的瓶颈不在 CPU,而在「内存余量」和「磁盘容量」。
内存从 4 GB 升到 8 GB 的成本极低,却能一次性消除以后所有内存不足的排查;
磁盘不要按当前数据量买,要按「备份 + 恢复演练」的双倍空间买。
================================================================
二、负载前提(为什么是这个配置)
================================================================
以下事实全部来自本仓库,不是通用模板:
1) 内部系统、数十人并发
出处:ai-aux/AI_CONTEXT.md:101「并发极低(内部系统数十人)」
含义:CPU 核心数不是瓶颈,2 核足以支撑全部并发请求。
2) 全栈同步架构 + 单 worker,重活走线程池
出处:CLAUDE.md(分层约定)、backend/app/core/db.py:29
含义:一个请求占用一个线程;绩效批量核算、Excel 导出会短暂吃满一个核,
因此要留出至少 1 个空闲核给后端,避免请求排队。
3) MySQL 8.0 + Redis 7 双容器,Redis 开启 AOF
出处:docker/docker-compose.dev.yml:11、:44
含义:MySQL 是内存消耗大户(默认配置下性能很差,必须调参);
Redis 只存验证码、Token 黑名单、登录失败计数,用量极小(<50 MB)。
4) 备份策略:每日全量 gz + binlog 增量,各自保留 30 天
出处:docs/备份恢复手册.md:11
含义:磁盘需按「业务数据 + 索引 + 30 天 binlog + 30 份全量备份」计算。
5) 恢复演练需恢复到独立临时库 payroll_drill_*
出处:docs/备份恢复手册.md:45
含义:演练瞬间需要约「2 倍数据库体积」的可用空间。
这是最容易被低估的一项,磁盘卡着下限买会在第一次演练时爆盘。
6) 报表导出用 openpyxl 全量载入内存
出处:backend/app/services/report_export.py:18
含义:导出大报表时后端进程内存峰值会翻倍,必须在内存预算里留余量。
================================================================
三、正式生产推荐配置(内部系统,数十人)
================================================================
项目 规格 说明
----------------------------------------------------------------
CPU 2 核(推荐 4 核) 2 核给容器与 MySQL,
1 核给 uvicorn(workers=2 上限),
1 核余量给系统与备份任务。
内存 8 GB(最低 4 GB) 8 GB 才有余量承受
「导出 + 核算 + 备份」同时发生。
磁盘 100 GB SSD(推荐 200 GB) 必须是 SSD。HDD 上 MySQL 的
ON UPDATE CURRENT_TIMESTAMP
高频写入会明显拖慢。
网络 内网千兆 无需公网 IP、域名、SSL 证书、CDN。
操作系统 Linux 推荐 Ubuntu 22.04 LTS / Rocky 9。
(Ubuntu 22.04 / Rocky 9) 备份、恢复、巡检脚本全部是 .sh
(scripts/ 目录),Linux 为原生路径。
若必须用 Windows Server,需 2022 +
WSL2 或 Git Bash 运行脚本。
================================================================
四、内存分配明细(8 GB 的具体切法)
================================================================
组件 建议占用 关键设置与说明
----------------------------------------------------------------
MySQL 8.0 3.5 GB innodb_buffer_pool_size = 2G
★ 默认仅 128 MB,不调会大量走磁盘,
MySQL 会成为整机最慢的一环。
后端 uvicorn 1.5 GB workers = 2
报表导出时单 worker 峰值可达 600 MB ~ 1 GB
(openpyxl 全量载入内存)。
Redis 7 256 MB maxmemory 256mb
实际用量 < 50 MB,设置上限只为兜底防失控。
Nginx + 系统 1 GB 静态资源(前端 dist)+ 反向代理 /api。
余量 1.75 GB mysqldump 备份进程、binlog 压缩、系统缓存。
----------------------------------------------------------------
4 GB 内存版(最低配置)的分配:
MySQL 1.5~2 GB(innodb_buffer_pool_size = 1G)
后端 0.8~1 GB
Redis 128 MB(maxmemory 128mb)
Docker + Nginx + 系统 0.6 GB
余量 ~0.6 GB
★ 这是「刚好够用」,一旦「导出报表」与「每日备份」撞在同一时刻,
OOM Killer 会优先杀 MySQL。这就是推荐 8 GB 的原因。
================================================================
五、磁盘空间算式(100 GB 的来历)
================================================================
项目 估算占用
----------------------------------------------------------------
业务数据(数十人,5 年) ≈ 1 GB
索引 + binlog 30 天(ROW 格式) ≈ 5 GB
全量备份 30 份(gz,约数据量 20%) ≈ 6 GB
恢复演练临时库(峰值 = 2 倍数据量) ≈ 2 GB
系统 + Docker 镜像 + 应用日志 ≈ 25 GB
----------------------------------------------------------------
合计 ≈ 39 GB
结论:100 GB 可支撑 2~3 年不需扩容。
按 docs/备份恢复手册.md:87 的 80% 磁盘水位告警,100 GB 有充足缓冲。
================================================================
六、内部部署必须调整的四项(现有配置存在隐患)
================================================================
1) 数据库端口必须收敛,不能监听所有网卡
现状:docker/docker-compose.dev.yml:27 为 "3306:3306",
:48 为 "6379:6379",等价于绑定 0.0.0.0。
风险:内网任何一台办公电脑都能直连 MySQL 试 root 密码。
改法(二选一):
A. 只允许宿主机访问
ports:
- "127.0.0.1:3306:3306"
- "127.0.0.1:6379:6379"
B. 后端与数据库在同一 compose 网络内时,直接删掉这两行 ports,
容器之间通过服务名互访,宿主机只暴露 80/443。
2) 前端必须构建成静态文件 + Nginx 反向代理
禁止把 vite dev 服务器当正式环境用:
开发服务器会暴露全部源码,且不提供任何访问控制。
正式做法:pnpm build 产出 frontend/dist,由 Nginx 托管,
/api 反向代理到后端 8000 端口。
3) 备份必须异地留一份
内部系统最常见的「数据全丢」场景就是单机故障。
在 scripts/backup_full.sh 之后追加一步 rsync 到 NAS 或公司文件服务器。
4) 提前预留反向代理层
不接公网就不需要 WAF / CDN / SSL。
但如果公司后续有统一域名单点登录(SSO)规划,
Nginx 反代层要提前留好,避免到时大改架构。
================================================================
七、三档速查表
================================================================
场景 CPU 内存 磁盘 备注
--------------------------------------------------------------------------------
试用 / 演示(< 5 人) 1 核 2 GB 40 GB SSD 仅跑小数据量;
导出大报表会 OOM
正式内部(数十人) 2 核 4 GB(建议 8 GB)100 GB SSD 推荐方案
【推荐】
内部 + 冗余 4 核 ×2 8 GB ×2 200 GB SSD ×2 含从库与监控;
(从库 + 监控) 工资系统若需
审计级可靠性
值得投入
--------------------------------------------------------------------------------
================================================================
八、可选加速项(非必须)
================================================================
1) 图表库已本地化
ECharts 静态文件已放在 docs/design/echarts.min.js,
前端自托管即可,不依赖外网 CDN,符合「内网加载不可靠」的既定决策。
2) 日志盘分离
若开启审计日志长期留存,把 backend/logs/ 挂载到独立磁盘,
避免日志写满系统盘导致 MySQL 无法写入。
3) 备份异地化
本机备份 + 定时 rsync 到 NAS 或对象存储,构成「本地 + 异地」两份。
================================================================
九、生产环境仍需完成的事项(本文件之外)
================================================================
1) 生成 docker/docker-compose.prod.yml:
- 端口收敛到 127.0.0.1
- MySQL innodb_buffer_pool_size 等参数调优
- 容器 mem_limit / cpus 资源限制
- Nginx 反向代理静态前端 + /api
2) 后端启动参数由开发模式改为生产模式:
- 去掉 --reload(生产环境必须去掉)
- 设置 workers=2
3) backend/.env 中 APP_SECRET_KEY 必须换成强随机值,
MYSQL_PASSWORD / REDIS_PASSWORD 两处(docker/.env 与 backend/.env)保持一致。
4) 首次部署执行 alembic upgrade head,并创建管理员账号:
uv run python -m app.cli create-admin --username admin
5) 配置每日定时任务:全量备份 + 增量归档(见 docs/备份恢复手册.md)
================================================================
十、附:数据来源索引
================================================================
ai-aux/AI_CONTEXT.md:101 并发规模判断(数十人)
docker/docker-compose.dev.yml:11 MySQL 8.0 容器
docker/docker-compose.dev.yml:27 MySQL 端口映射(需收敛)
docker/docker-compose.dev.yml:44 Redis 7 容器与 AOF
docker/docker-compose.dev.yml:48 Redis 端口映射(需收敛)
backend/app/core/db.py:29 同步引擎与连接池配置
backend/app/services/report_export.py:18 报表导出(内存峰值来源)
docs/备份恢复手册.md:11 备份策略
docs/备份恢复手册.md:45 恢复演练临时库
docs/备份恢复手册.md:87 磁盘水位 80% 告警阈值
scripts/ 备份 / 恢复 / 巡检脚本(.sh)
================================================================
(文件结束)
Binary file not shown.
-154
View File
@@ -1,154 +0,0 @@
# 衍水集团营销人员工资管理系统项目阶段报告
- **报告日期**:2026-10-13
- **报告范围**:需求澄清、技术底座、系统管理、M1 参数体系、M2 当前进展与后续计划
- **项目分支**:`feat/base-business-data`
- **阶段结论**:项目已从“需求与底座建设”进入 **M2 基础数据管理开发期**;M1 已形成可验收闭环,M2 数据模型与迁移初稿已落地,但接口、页面与模块级测试尚未完成。
## 一、3P 阶段摘要
### Progress(进展)
已完成需求裁决、开发环境、数据库基础设计、登录鉴权、系统管理、前端框架与 M1 系统设置参数体系。M1 覆盖岗位角色、公式模板、16 项参数种子、时点化取值、版本留痕、参数预览与 Excel 导出;最近一次全量回归为后端 406 个用例与 28 项端到端检查通过。当前 M2 已完成人员、区域、计划、产品、客户、归属留痕六类表的模型与 V4 迁移初稿。
### Plans(计划)
短期优先完成 M2:在既有六表基础上补齐仓储、服务、接口、前端页面、审计与模块测试,并完成双轨迁移一致性验证。随后进入业务数据管理,为绩效核算提供业务单据与回款数据输入。报表与 T+ 同步继续后置,待业务数据模型稳定和 IT 字段映射确认后实施。
### Problems(问题)
当前无业务需求阻塞,但 M2 代码尚未提交,需防止结构设计与后续接口实现长期分叉。T+ 字段映射、幂等唯一键与失败重试策略仍待 IT 确认;经理个人户业绩进入 12.5%+5% 的衔接口径需在核算模块开工前确认。原需求文档存在已裁决作废条款,对外引用前需修订或明确以对照表 v2 为准。
## 二、阶段定位与完成度
| 维度 | 当前状态 | 说明 |
| --- | --- | --- |
| 项目阶段 | 开发期 | M1 已交付,M2 进行中 |
| 整体完成度 | 14% | 管理口径;M2 尚未达到可交付节点,仅按结构设计进展小幅上调 |
| 交付主线 | M2 基础数据管理 | 六表结构与 T+ 预留已形成初稿 |
| 质量状态 | M1 回归通过 | 后端 406 用例、e2e 登录 8 项 / 系统管理 13 项 / 参数 7 项通过 |
| 主要风险 | 外部对接与口径追溯 | T+ 映射未定;需规含作废条款 |
> 说明:整体完成度用于管理沟通,不等同于代码行占比。M2 仅完成数据结构层,接口、页面和模块级测试未完成,因此不能标记为模块交付。
## 三、已完成成果
### 1. 需求与执行口径
- 业务方已回执《问题与解决方案对照表_v2.xlsx》,28 项争议全部落定。
- 2026-10-12 后,需求以对照表 v2 与《2026.9.8修改方案.docx》的裁决口径为准。
- 关键口径已固化:回款天数自然日累计且发货日计第 1 天;毛利润率不含运费;业务员计提按 1~360 天线性递减、361 天及以上固定 4‰;企业户 3‰;信息员按销售额 1‰;参数按回款到账日时点取值。
- 支付环节的起付比例、支付权重、预付款、以物抵顶均按可配置参数预留。
### 2. 技术与运行底座
- 后端六层结构建立:`api → services → repositories → models`,`schemas` 承载请求响应,`core` 处理横切能力。
- 统一响应、全局异常、结构化日志、审计脱敏、配置安全校验已落地。
- 本地环境基于 Docker Compose 编排 MySQL 8.0 与 Redis 7,使用命名卷持久化、业务账号最小权限、utf8mb4 字符集。
- 迁移采用 Alembic 生成并导出 `db/migration/V{n}__*.sql` 的双轨方式,兼顾开发迭代与生产脚本留存。
### 3. 登录鉴权与系统管理
- 登录链路包含图形验证码、JWT、登录失败限流、Token 黑名单与登录日志。
- 系统管理包含用户、角色、操作日志与日志查询,前后端闭环。
- 审计机制记录成功与失败操作,敏感字段按键名结构化脱敏,日志列表与详情分段加载。
### 4. M1 系统设置与参数体系
- 建立岗位角色、参数定义、变更批次、参数值四表。
- 公式模板以代码内有限集合注册,参数按生效日期时点化存储,避免用当前值重算历史。
- 提供 16 个管理端点,支持参数分组、版本管理、批次撤回、两日期对比、计算预览与 xlsx 导出。
- 前端交付岗位角色配置页、计提参数配置页、Bento 仪表盘与统一视觉体系。
- Code review 发现的 15 项问题已全部修复,并完成回归。
## 四、当前进行中工作
当前分支 `feat/base-business-data` 正在推进 M2 基础数据管理。已形成的结构设计包括:
| 数据对象 | 设计要点 | 后续消费方 |
| --- | --- | --- |
| `personnel` | 人员档案、岗位角色、直属经理、试用期、工资标准与状态 | 基础数据、业务归属、绩效核算 |
| `personnel_regions` | 人员负责的产品辐射区域,1:N 子表 | 基础数据维护 |
| `personnel_plans` | 市场拓展计划与指标说明,先做自由条目容器 | T+ 字段映射确认后细化 |
| `products` | 产品名称与规格唯一,类别锚定折算阈值参数 | 业务数据与绩效核算 |
| `customers` | 公司名称加负责人唯一,客户分类作为计提规则锚点 | 业务数据与绩效核算 |
| `ownership_change_logs` | 客户与业务归属变更留痕,append-only,不回溯历史 | 审计与核算追溯 |
设计上已为 T+ 同步预留 `source_no`、`t_plus_id`、`sync_batch_no`、`synced_at`。由于 IT 尚未确认字段映射与幂等键,这些列暂为可空并仅对 `source_no` 建普通索引,待键语义确定后再以小迁移升级唯一约束。
**当前边界**:以上属于模型、Alembic 迁移、生产 SQL 与开发初始化脚本层面的初稿;M2 的 CRUD API、服务事务、前端页面、权限审计和完整测试仍待实现。相关文件当前未提交,不能视为已交付功能。
## 五、下一步计划
| 优先级 | 工作项 | 验收标准 |
| --- | --- | --- |
| P0 | 完成 M2 六表接口与服务 | 列表、详情、创建、更新、停用 / 删除保护可用;事务与审计完整 |
| P0 | 完成 M2 前端页面 | 人员、产品、客户管理页面复用现有分页与确认交互,类型检查通过 |
| P0 | 补齐 M2 测试与迁移一致性检查 | 单元测试、集成测试、schema 同步测试通过 |
| P1 | 提交并评审 M2 | 提交信息符合规范,不直推共享主干 |
| P1 | 启动业务数据管理设计 | 明确业务、发货、回款、批次匹配与核销状态模型 |
| P1 | 确认核算前置口径 | 经理个人户业绩是否计入 12.5% 团队基数 |
| P2 | T+ 对接专项 | 字段映射、唯一键、去重、同步日志与失败重试策略由 IT 确认 |
## 六、风险与应对
| 风险 | 影响 | 当前应对 |
| --- | --- | --- |
| M2 结构初稿长期未提交 | 后续接口实现与表结构评审结论容易分叉 | 尽快完成最小可测闭环后提交,评审后再扩展 |
| T+ 字段映射未定 | 同步模块无法最终定幂等键与字段清洗规则 | 目标表预留同步字段;同步模块后置;不覆盖人工维护字段 |
| 需求文档存在作废条款 | 新成员或外部方按旧需规理解会产生口径冲突 | 以对照表 v2 与 9.8 修改方案为准;后续修订需规或加显著说明 |
| 经理个人户衔接口径待确认 | 影响绩效台账归属字段与经理绩效基数 | 在核算模块开工前作为必须裁决项跟踪 |
| 绩效金额精度风险 | 浮点误差会直接造成算错钱 | 金额使用 `Decimal`,比例斜率使用 `Fraction`;关键边界已有纯函数测试 |
## 七、技术选型理由
### 1. 后端:Python 3.12 + FastAPI
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| Python 3.12 | `Decimal` 与 `Fraction` 能精确处理金额、千分比和 `4/360` 斜率;数据处理、Excel、后续 T+ 清洗生态成熟;版本锁定 3.12 避免新版本第三方轮子覆盖不稳 | Python 3.14:生态轮子覆盖尚不完备,兼容风险高 |
| FastAPI | 自动 OpenAPI、Pydantic 参数校验、依赖注入和统一异常模型适合内部管理系统;显式路由便于把算钱逻辑限定在 service 层 | Django:Admin 对 T+ 来源的数据 CRUD 复用价值有限,隐式 ORM 行为不利于工资核算追溯 |
| 同步 SQLAlchemy 2.0 | 项目并发压力低;绩效批量核算、Excel 导出和数据清洗是同步阻塞任务,同步路由由 FastAPI 放入线程池,避免阻塞事件循环 | 异步 SQLAlchemy:增加复杂度但没有对应吞吐收益 |
### 2. 数据与缓存:MySQL 8.0 + Redis 7
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| MySQL 8.0 + InnoDB + utf8mb4 | 事务、外键、约束与审计留痕可靠;团队与运维熟悉度高;`DECIMAL` 支撑金额精度;utf8mb4 覆盖中文与特殊字符 | Excel / 文件库:无法承载并发、事务、权限与审计;NoSQL:缺少关系约束,不利薪酬追溯 |
| Alembic 双轨迁移 | 开发期可自动生成演进,生产保留按序 SQL 脚本,符合审计与发布要求 | 仅手写 SQL:模型与库结构易漂移;仅 Alembic:不满足生产脚本留存约定 |
| Redis 7 | 验证码、登录限流、Token 黑名单都需要带 TTL 的短生命周期状态;`GETDEL` 支撑验证码一次性消费 | 仅 MySQL:一次性状态表会增加清理与原子性复杂度 |
### 3. 前端:Vue 3 + TypeScript + Vite + Element Plus + Pinia
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| Vue 3 + TypeScript | 组件生态成熟,类型约束能降低表单和接口字段错误;项目已沉淀路由、状态、分页与确认交互 | 新换框架:无业务收益,徒增迁移成本 |
| Vite | 启动与热更新快,开发体验好;生产构建链路简单 | Webpack:配置与维护成本更高 |
| Element Plus | 表格、表单、抽屉、分页、日期选择等管理端组件齐全,可快速交付后台页面 | 自研组件库:一期交付效率低 |
| Pinia | 轻量状态管理,适合登录态、用户信息与模块状态 | 大型全局状态方案:当前规模过度设计 |
### 4. 工程与质量:uv + pnpm + Docker Compose + pytest + Playwright
| 选型 | 理由 |
| --- | --- |
| uv | 锁定 Python 与依赖版本,安装速度快,避免“本机能跑、别处失败” |
| pnpm | 依赖硬链接节省空间,lockfile 保证前端可重复安装 |
| Docker Compose | 一条命令拉起 MySQL 与 Redis,版本固定、环境一致,不依赖开发者本机散装数据库 |
| pytest + fakeredis + Playwright | 单元、集成、端到端分层验证;fakeredis 让验证码和限流测试无需容器,浏览器测试覆盖真实登录与管理链路 |
### 5. 业务计算架构:公式模板 + 参数版本,而非规则引擎
一期公式骨架固定,变化的是比例、阈值、生效日期和岗位绑定,因此使用“代码内有限公式模板 + 参数时点化版本”足够,并且可测试、可审计、排错直观。引入规则引擎会增加公式调试、权限、留痕与回归成本,却不能消除业务口径澄清本身。若未来业务要求公式可由管理员自由编写,再评估规则引擎或表达式引擎,并保持计算入口兼容。
## 八、报告结论
项目基础能力和 M1 参数地基已经稳定,当前最短路径是完成 M2 基础数据管理的可测闭环,再进入业务数据管理。技术上应继续坚持“关系数据库约束 + 精确数值类型 + 时点化参数 + 审计留痕”的路线,不在 T+ 映射未定时提前锁死同步唯一键。管理上需重点跟踪 M2 提交、核算前口径确认和 T+ 对接三项。
## 附录:信息来源
- 项目入口与运行说明:[README.md](../README.md)
- 剩余开发路线:[2026-10-12-剩余开发计划.md](superpowers/plans/2026-10-12-剩余开发计划.md)
- 需求裁决:`docs/问题与解决方案对照表_v2.xlsx`
- 修改方案:`docs/2026.9.8修改方案.docx`
- 后端测试记录:[backend/tests/README.md](../backend/tests/README.md)
- 前端约定与测试:[frontend/README.md](../frontend/README.md)
+80
View File
@@ -0,0 +1,80 @@
# 衍水集团营销人员工资管理系统项目阶段报告
- **报告日期**:2026-10-20
- **报告范围**:M4 ⑤ 报表统计、⑧ 工作台完成情况与 M4 收尾结论
- **项目分支**:`feat/m3-increments`(已合并 `main`,merge commit `3611474`)
- **阶段结论**:除 ⑥ T+ 数据同步受外部答复阻塞外,十大功能块全部交付;后端全量 **862 用例通过**,报表、工作台、核算月结三条 e2e 链路实跑通过。
## 一、3P 阶段摘要
### Progress(进展)
M4 两块(⑤ 报表统计、⑧ 工作台)已完成并通过 code-review 修复。报表侧交付个人绩效明细、部门汇总、业务统计、回款统计四张报表、Excel 签字导出与独立打印视图;工作台侧交付经营总览、待办事项、风险预警、最近动态四区块,待办与预警实时派生并参数化。本阶段同时完成 M4 修复批次的提交、全量回归与 e2e 实跑,开发计划 3.3 答复状态已回填。
### Plans(计划)
下一步优先解析 T+12.3 数据字典,形成六类单据字段映射草案并自答 IT 清单中可自答部分;~~其次补齐 code-review 遗留的方案版本承载与审批单快照~~(已于 2026-10-20 完成,V11);需规 V2.2 修订与对照表回填择期进行。
### Problems(问题)
⑥ T+ 数据同步仍受 IT 18 项答复阻塞,同步失败类待办/预警暂以「模块未启用」显式降级。业务方仍有 #1–#3 二次分摊、#20 含税口径、#4/#6/#12 补值与两处勘误待确认;审批角色权限细目未给出,当前按 `role_code` 粗粒度运行。需规仍含已作废条款,对外引用需注明以对照表/计划第三章为准。
## 二、阶段定位与完成度
| 维度 | 当前状态 | 说明 |
| --- | --- | --- |
| 项目阶段 | 开发收尾期 | M1–M3 与 M4 ⑤⑧ 已交付,仅 ⑥ 待外部输入 |
| 整体完成度 | 90% | 十大功能块交付;⑥ 按 0% 计入 |
| 交付主线 | M4 报表统计 + 工作台 | 前后端闭环,已合并 main |
| 质量状态 | 全绿 | 后端 862 passed(11:32);e2e role_home 4/4、report 12/12、calc_settlement 8/8 |
| 主要风险 | 外部对接与口径变更 | T+ 映射未定;含税口径未答复可能触发历史绩效重算 |
## 三、已完成成果
### 1. 报表统计(M4 ⑤)
- 四表接口 `/api/v1/reports/*`:个人绩效明细、部门绩效汇总、业务统计、回款区间统计。
- 部门归属按 `personnel.manager_id` 实时上溯;回款区间 8 档(≤0 / 1-30 / 31-60 / 61-90 / 91-180 / 181-360 / 361+ / 无天数)。
- 全公司 5% 按「有聚合行经理各计一次」计算,并与月结 `total_payable` 对拍;对账块单列聚合行舍入差。
- Excel 签字导出(openpyxl + StreamingResponse,ASCII 文件名)与独立打印视图(五块结构、每页表头、签字栏)。
- 权限 `admin/finance/leader` 前后端双层。
### 2. 工作台(M4 ⑧)
- 单端点 `/api/v1/dashboard` 一次返回四区块:经营总览、待办事项、风险预警、最近动态。
- 待办与预警**实时派生**(不落表、不引调度器);待办按角色过滤;逾期预警排除已终止业务。
- 预警阈值参数化:`warning.enabled`、`warning.receipt.overdue.days=90`、`warning.approval.timeout.days=3`(V10,参数注册表 22 项)。
- 前端 `/dashboard` 四区块 + ECharts 5.6 按需引入(SVG,显式思源黑体,规避 Windows 默认雅黑)。
- 普通用户落地页统一由 `router/access.ts` 解析(工作台 → 业务数据 → 回款),修复无限重定向。
### 3. 质量与收尾
- 后端全量 `uv run pytest -q` → **862 passed(11:32)**;`pnpm type-check` 与 `pnpm build` 通过。
- e2e 实跑:`role_home_flow` 4/4、`report_flow` 12/12、`calc_settlement_flow` 8/8;`report_flow` 回款统计改为按本次业务编号下钻断言,脚本可重复运行。
- 运行后核对:月结审批开关恢复为 1,无 `e2e_role_*` 残留账号。
- 开发计划 3.3.1 与《待确认事项汇总》已按 24 项答复分组回填(13 默认 / 4 按答复 / 3 部分 / 4 开放)。
## 四、当前进行中工作
无。M4 代码与文档已提交并合并 `main`(`3611474`)。
## 五、下一步计划
1. **T+ 同步前置**:解析 `docs/T_12.3数据字典/`,产出人员/产品/业务/发货/回款/客户六类字段映射草案,自答 IT 18 项中可从数据字典确认的部分。
2. ~~**code-review 遗留 2 项**:方案生效版本承载、审批单内容快照(orders 加方案 JSON 快照列 + 前端详情展示)。~~ ✅ 已完成(2026-10-20,V11 迁移):方案行版本化 + 审批单版本号/内容快照 + 前端展示。
3. **需规 V2.2**:修订 26 天示例、含运费公式、协助 25% 等过时条款,回填 2026-10-15 补充需求;对照表 v2 收编 24 项答复。
4. **清理类**:5 处超长函数、散参数 DTO、`useDropdownOptions` 余两页、前端菜单图标。
5. **待外部输入**:业务方催办、审批权限清单、IT 18 项答复。
## 六、风险与应对
| 风险 | 影响 | 应对 |
| --- | --- | --- |
| IT 答复持续缺失 | ⑥ 无法开工,同步类待办/预警无法真实展示 | 先做数据字典映射草案;催办清单已含高优 9 项与三缺口 |
| 含税口径(#20)改变 | 历史绩效可能需要重算 | 当前按不含税实现;答复后评估重算范围并授权留痕 |
| 二次分摊口径(#1–#3)改变 | 已生成台账/月结可能需要调整 | 暂定口径已标注;变更走口径调整,重算需授权留痕 |
| 需规含作废条款 | 对外交付物误导 | 引用以对照表/计划第三章为准;V2.2 修订前排期 |
## 七、报告结论
M4 报表统计与工作台已完成交付并合并 `main`,系统除 ⑥ T+ 数据同步外具备完整业务闭环。质量证据充分(全量测试与三条 e2e 链路),剩余工作以外部输入与审计性技术债为主,可按计划推进。
+5 -1
View File
@@ -188,7 +188,7 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
| 页面 | 说明 |
| --- | --- |
| `/reports/personal-detail` 个人绩效明细(新组「报表统计」order19) | 人员/月份/客户类型/产品类别/岗位筛选;需规 2.4.1 全部列;行展开懒加载台账快照;页脚**全筛选合计**(注明非当前页);导出/打印 |
| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司分成独立卡片;团队表(基数/团队分成/全公司分成/占比/调整/净合计/月结冻结额),比例列头随在线参数变化;展开成员 → 跨页下钻个人明细(人员与月份带入筛选);未分配桶(按经理筛选时为空) |
| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司分成独立卡片(单经理 5% × 计入人数 = 合计,每位有聚合行的经理各计一次);团队表(基数/团队分成/全公司分成/占比/调整/净合计/月结冻结额),净合计与月结聚合行同口径,比例列头随在线参数变化;展开成员 → 跨页下钻个人明细(人员与月份带入筛选);未分配桶(按经理筛选时为空) |
| `/reports/business-stats` 业务统计(order21) | 关键字/客户/归属人/状态/业务日期;发货额/回款额/绩效(累计口径提示);展开下钻回款与台账明细 |
| `/reports/payment-stats` 回款统计(order22) | 月份/回款日期/客户类型/产品类别;8 档区间表(空桶 0,档位由后端下发);展开桶下钻分页明细;客户类型/产品类别对比表 |
| `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(以接口 total 判定超过 2000 行并提示截断);部门汇总按「团队分成 → 全公司 5% → 对账 → 成员明细 → 未分配」五块渲染;`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 |
@@ -197,6 +197,7 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
- api 封装 `src/api/reports.ts`(类型 + 查询 + `exportReport` 裸 axios blob;产品类别标签额外覆盖 `mixed`)。
- 权限:营销后勤「授权范围内」待业务权限清单 #6,当前不开放报表。
- type-check 与 build 通过(2026-10-19)。e2e `report_flow.py` 已补 12 步:登录 → API 造数 → 四表筛选/导出下载 → 部门下钻筛选生效 → 回款多桶同时展开互不串行 → 打印视图 → 打印截断提示 → 部门汇总打印五块结构;需 `E2E_ADMIN_PASSWORD` 且前后端在跑。
- **e2e 实跑(2026-10-20)**:`login_flow` 8/8、`system_management` 13/13、`role_home_flow` 4/4、`report_flow` 12/12、`calc_settlement_flow` 8/8 全过。`report_flow` 的回款统计改为展开目标桶后按**本次业务编号**定位明细行,不依赖桶累计值,脚本可重复运行(历史 e2e 数据累计不再造成假失败)。
## 端到端测试(2026-10-19 追加 · M3 台账与月结)
@@ -225,4 +226,7 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
- **ECharts 5.6 按需引入**:`echarts/core` + `BarChart/LineChart/Grid/Tooltip` + `SVGRenderer`(与设计预览一致),只注册用到的模块。
- **字体红线**:图表 `textStyle.fontFamily` 必须显式设为思源黑体栈——ECharts 在 Windows 默认微软雅黑(字体商用合规要求)。
- 权限:路由 `allowedRoles: [admin, finance, leader]`,与后端 `require_roles` 双层;营销后勤待权限清单 #6。
- **普通用户落地页**:守卫不再硬编码回 `/dashboard`,统一走 `src/router/access.ts` 的 `firstAccessiblePath`(工作台 → 业务数据 → 回款),普通用户登录/刷新/直连受限页均落 `/business/businesses`,不会无限重定向;登录回跳也经 `canAccessPath` 校验。菜单与面包屑复用同一份准入判定。
- 预警徽标用后端 `warnings.total`(全部命中数),`items` 仅为展示上限内的子集。
- `package.json` 新增依赖 `echarts@5.6.0`(MIT);type-check 与 build 通过(2026-10-19)。
- e2e `e2e/role_home_flow.py`:普通用户登录落地、菜单隐藏、刷新保持、直连 `/dashboard` 与报表页回退;需 `E2E_ADMIN_PASSWORD`。
+6 -4
View File
@@ -112,10 +112,12 @@ def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -
fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}")
def login(page: Page, username: str, password: str) -> None:
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。
def login(page: Page, username: str, password: str, expected_path: str = "/dashboard") -> None:
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入落地页。
验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。
``expected_path`` 按角色传入:管理员/财务/领导默认工作台,普通用户
落业务数据页(工作台权限收紧后守卫按角色解析首页)。
"""
captcha_ids: list[str] = []
@@ -148,8 +150,8 @@ def login(page: Page, username: str, password: str) -> None:
page.fill('input[autocomplete="off"]', answer)
page.click("button:has-text('登 录')")
page.wait_for_timeout(2000)
if current_path(page) != "/dashboard":
detail = f"未跳转到首页,当前 {page.url}"
if current_path(page) != expected_path:
detail = f"未跳转到 {expected_path},当前 {page.url}"
if message := toast_text(page):
detail += f",提示:{message}"
fail("登录", detail)
+140 -8
View File
@@ -159,6 +159,7 @@ def step_setup_via_api(page: Page) -> dict:
return {
"month": MONTH,
"business_no": business["business_no"],
"personnel_id": person["id"],
"personnel_name": person["personnel_name"],
"manager_name": manager["personnel_name"],
}
@@ -216,7 +217,7 @@ def step_business_stats(page: Page, ctx: dict) -> None:
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
if row.count() == 0:
fail("业务统计", f"未找到业务 {ctx['business_no']}")
print("✓ 6. 业务统计 关键字筛选命中")
print("✓ 7. 业务统计 关键字筛选命中")
def step_payment_stats(page: Page, ctx: dict) -> None:
@@ -225,16 +226,143 @@ def step_payment_stats(page: Page, ctx: dict) -> None:
page.locator(".filters button:has-text('查询')").first.click()
page.wait_for_timeout(900)
rows = page.locator(".el-table__row")
# 页面同时有区间表与两个维度对比表,必须限定主区间表
rows = page.locator('[data-testid="payment-bucket-table"] .el-table__row')
if rows.count() != 8:
fail("回款统计", f"区间表应为 8 行,实际 {rows.count()}")
bucket = page.locator(".el-table__row", has_text="61-90 天").first
bucket = page.locator(
'[data-testid="payment-bucket-table"] .el-table__row', has_text="61-90 天"
).first
if bucket.count() == 0:
fail("回款统计", "未找到 61-90 天区间行")
bucket_text = bucket.inner_text()
if "10000.00" not in bucket_text:
fail("回款统计", f"61-90 天桶回款金额不符:{bucket_text[:120]}")
print("✓ 7. 回款统计 8 档区间与目标桶金额正确")
# 按本次业务编号在下钻明细中定位,不依赖桶聚合值(历史 e2e 数据会累计到同桶)
bucket.locator(".el-table__expand-icon").click()
page.wait_for_timeout(1000)
drill_row = page.locator(
".drill-wrap .el-table__row", has_text=ctx["business_no"]
).first
if drill_row.count() == 0:
fail("回款统计", f"61-90 天桶下钻未找到本次业务 {ctx['business_no']}")
drill_text = drill_row.inner_text()
if "10000.00" not in drill_text:
fail("回款统计", f"本次业务下钻行金额不符:{drill_text[:120]}")
print("✓ 8. 回款统计 8 档区间与本次业务下钻金额正确")
def step_payment_multi_expand(page: Page, ctx: dict) -> None:
"""多个区间同时展开时,各自的下钻面板不能串行显示同一批明细。"""
page.goto(f"{BASE_URL}/reports/payment-stats", wait_until="networkidle")
page.wait_for_timeout(900)
page.locator(".filters button:has-text('查询')").first.click()
page.wait_for_timeout(900)
for label in ("61-90 天", "1-30 天"):
row = page.locator(
'[data-testid="payment-bucket-table"] .el-table__row', has_text=label
).first
row.locator(".el-table__expand-icon").click()
page.wait_for_timeout(700)
drills = page.locator(".drill-wrap")
if drills.count() != 2:
fail("回款多桶展开", f"应展开 2 个下钻面板,实际 {drills.count()}")
texts = [drills.nth(index).inner_text() for index in range(2)]
# 面板是明细行(每笔 10000),不是桶合计;串行时两个面板文本会完全相同
if texts[0] == texts[1] or "10000.00" not in "".join(texts):
fail("回款多桶展开", "两个桶面板显示了同一批明细(drill 状态串行)")
print("✓ 9. 回款多桶展开 两个下钻面板互不串行")
def step_department_drill(page: Page, ctx: dict) -> None:
"""部门汇总「查看明细」必须把人员与月份带到个人明细页。"""
page.goto(f"{BASE_URL}/reports/department-summary", wait_until="networkidle")
page.wait_for_timeout(900)
page.locator(".filters button:has-text('查询')").first.click()
page.wait_for_timeout(900)
row = page.locator(".el-table__row", has_text=ctx["manager_name"]).first
row.locator(".el-table__expand-icon").click()
page.wait_for_timeout(500)
page.locator("button:has-text('查看明细')").first.click()
page.wait_for_timeout(900)
if f"personnel_id={ctx['personnel_id']}" not in page.url:
fail("部门下钻", f"URL 未携带人员筛选:{page.url}")
if ctx["personnel_name"] not in page.locator(".filters").inner_text():
fail("部门下钻", "个人明细页未应用人员筛选")
print("✓ 6. 部门下钻 人员与月份筛选已生效")
def step_print_truncated(page: Page, ctx: dict) -> None:
"""超过打印上限时必须给出截断提示(修复前提示永不出现)。"""
def handler(route) -> None:
rows = [
{
"ledger_id": index,
"business_no": f"E2E-{index}",
"ship_date": None,
"receipt_date": f"{ctx['month']}-01",
"payment_days": 10,
"product_category": "municipal_water",
"customer_type": "personal",
"role_name": "业务员",
"rate_text": "1",
"weight_r": 1,
"performance_amount": "1.00",
"payable_amount": "1.00",
}
for index in range(200)
]
route.fulfill(
json={
"code": 200,
"message": "ok",
"data": {
"list": rows,
"total": 2500,
"pageNum": 1,
"pageSize": 200,
"summary": {"row_count": 2500},
},
}
)
page.route("**/api/v1/reports/personal-detail*", handler)
try:
page.goto(
f"{BASE_URL}/reports/print/personal-detail?settlement_month={ctx['month']}",
wait_until="networkidle",
)
page.wait_for_timeout(1200)
if page.locator(".truncated").count() == 0:
fail("打印截断", "超过 2000 行未显示截断提示")
if "2000" not in page.locator(".truncated").inner_text():
fail("打印截断", "截断提示未说明上限行数")
finally:
page.unroute("**/api/v1/reports/personal-detail*", handler)
print("✓ 11. 打印截断 超限提示可见")
def step_print_department(page: Page, ctx: dict) -> None:
"""部门汇总打印稿必须含团队分成、5% 块、对账、成员与未分配五块。"""
page.goto(
f"{BASE_URL}/reports/print/department-summary?settlement_month={ctx['month']}",
wait_until="networkidle",
)
page.wait_for_timeout(1200)
for testid in (
"print-department-teams",
"print-company-block",
"print-reconciliation",
"print-department-members",
"print-unassigned",
):
if page.locator(f'[data-testid="{testid}"]').count() == 0:
fail("部门打印", f"缺少区块 {testid}")
if "财务复核" not in page.locator(".print-page").inner_text():
fail("部门打印", "签字栏缺失")
print("✓ 12. 部门打印 五块结构与签字栏完整")
def step_print_view(page: Page, ctx: dict) -> None:
@@ -253,7 +381,7 @@ def step_print_view(page: Page, ctx: dict) -> None:
page.wait_for_timeout(400)
if page.evaluate("window.__printed") is not True:
fail("打印视图", "window.print 未被调用")
print("✓ 8. 打印视图 表格/签字栏/打印调用均正常")
print("✓ 10. 打印视图 表格/签字栏/打印调用均正常")
def run(page: Page) -> None:
@@ -266,9 +394,13 @@ def run(page: Page) -> None:
ctx = step_setup_via_api(page)
step_personal_detail(page, ctx)
step_department_summary(page, ctx)
step_department_drill(page, ctx)
step_business_stats(page, ctx)
step_payment_stats(page, ctx)
step_payment_multi_expand(page, ctx)
step_print_view(page, ctx)
step_print_truncated(page, ctx)
step_print_department(page, ctx)
if console_errors:
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
+141
View File
@@ -0,0 +1,141 @@
"""浏览器端到端验证:普通用户落地页与路由守卫回退。
工作台收紧为 admin/finance/leader 后,登录默认页不能硬编码 `/dashboard`
(普通用户会被无限重定向)。本脚本验证:
- 普通用户登录后落业务数据页,不出现「无权访问」提示
- 侧边菜单不出现首页/系统管理/报表统计
- 刷新后仍保持登录态与落地页
- 直连 `/dashboard` 或报表页时回退业务数据页并给出权限提示
运行前置:容器、后端 8000、前端 5173 均在运行;设置 ``E2E_ADMIN_PASSWORD``。
运行::
cd frontend && E2E_ADMIN_PASSWORD=<密码> python e2e/role_home_flow.py
"""
import json
import os
import time
import urllib.error
import urllib.request
import uuid
from playwright.sync_api import Page, sync_playwright
from common import BASE_URL, E2EFailure, current_path, fail, login, toast_text
ADMIN = "admin"
USERNAME = f"e2e_role_{int(time.time())}_{uuid.uuid4().hex[:4]}"
PASSWORD = "E2eRole@2026"
HOME_PATH = "/business/businesses"
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
"""带令牌的轻量 API 调用(urllib,避免额外依赖)。"""
request = urllib.request.Request(
f"{BASE_URL}{path}",
method=method,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
data=json.dumps(body).encode() if body is not None else None,
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
payload = json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode()[:200]
raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc
if payload.get("code") != 200:
raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}")
return payload["data"]
def _token(page: Page) -> str:
token = page.evaluate("localStorage.getItem('ys_token')")
if not token:
fail("API", "未取到登录令牌")
return token
def _create_user(page: Page) -> dict:
return _api(
"POST",
"/api/v1/system/users",
_token(page),
{
"username": USERNAME,
"password": PASSWORD,
"real_name": "E2E普通用户",
"roles": ["user"],
},
)
def run(page: Page) -> None:
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
if not admin_password:
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
login(page, ADMIN, admin_password)
user = _create_user(page)
try:
page.evaluate("localStorage.clear()")
login(page, USERNAME, PASSWORD, expected_path=HOME_PATH)
page.wait_for_timeout(500)
print("✓ 1. 普通用户登录 落地业务数据页")
menu_text = page.locator(".app-menu").inner_text()
for forbidden in ("首页", "系统管理", "报表统计"):
if forbidden in menu_text:
fail("菜单权限", f"普通用户看到了「{forbidden}」菜单")
print("✓ 2. 菜单权限 首页/系统管理/报表统计均不可见")
page.reload(wait_until="networkidle")
page.wait_for_timeout(600)
if current_path(page) != HOME_PATH:
fail("刷新恢复", f"刷新后未保持落地页,当前 {page.url}")
print("✓ 3. 刷新恢复 登录态与落地页保持")
for path in ("/dashboard", "/reports/personal-detail"):
page.goto(f"{BASE_URL}{path}", wait_until="networkidle")
page.wait_for_timeout(800)
if current_path(page) != HOME_PATH:
fail("路由守卫", f"直连 {path} 未回退业务数据页,当前 {page.url}")
if "无权访问该页面" not in toast_text(page):
fail("路由守卫", f"直连 {path} 未给出权限提示")
print("✓ 4. 路由守卫 受限页均回退业务数据页并提示")
finally:
# 无论用例是否失败都清理测试账号(重新以 admin 登录取令牌)
page.evaluate("localStorage.clear()")
login(page, ADMIN, admin_password)
_api("DELETE", f"/api/v1/system/users/{user['id']}", _token(page))
def main() -> int:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.set_default_timeout(15000)
try:
run(page)
except E2EFailure as exc:
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ {exc}")
print(" 已保存截图 e2e/failure.png")
return 1
except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
return 1
finally:
browser.close()
print("\n全部通过。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+113 -6
View File
@@ -5,8 +5,12 @@
并填比例 → 提交成功
- 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义)
- 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」
- 审批内容:审批详情展示**提交时**的送审方案(参与人 + 比例)
- 数据清理:删除本次创建的业务(t_ 前缀客户/业务,走界面删除)
审批开关在脚本内显式关闭(结束还原默认 1):既不依赖种子默认值,也让本次
业务不留在途审批单——业务删除保护在途单,开关开启时删不掉。
运行前置(缺一不可):
docker compose up -d # MySQL + Redis
cd backend && uv run uvicorn app.main:app --port 8000
@@ -14,8 +18,12 @@
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/secondary_allocation_flow.py
"""
import json
import os
import time
import urllib.error
import urllib.request
from urllib.parse import quote
from playwright.sync_api import Page, sync_playwright
@@ -32,9 +40,50 @@ from common import (
)
ADMIN = "admin"
API_BASE = "http://127.0.0.1:8000"
SUFFIX = f"{int(time.time())}"
console_errors: list[str] = []
_ALLOCATION_SWITCH = "approval.secondary_allocation.enabled"
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
"""直连后端接口(仅用于开关预置;浏览器交互一律走页面)。"""
request = urllib.request.Request(
f"{API_BASE}{path}",
method=method,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
data=json.dumps(body).encode() if body is not None else None,
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
payload = json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode()[:200]
raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc
if payload.get("code") != 200:
raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}")
return payload["data"]
def _set_allocation_switch(token: str, value: str) -> None:
"""显式控制二次分配审批开关(用例不依赖种子默认值;结束还原默认 1)。"""
_api(
"POST",
"/api/v1/system/params/changes",
token,
{
"change_type": 1,
"remark": f"e2e secondary_allocation_flow 设置二次分配审批开关={value}",
"items": [
{"param_code": _ALLOCATION_SWITCH, "dimension_value": "", "value": value}
],
},
)
def step_navigate_to_business(page: Page, admin_password: str) -> None:
login(page, ADMIN, admin_password)
@@ -95,6 +144,7 @@ def step_create_personnel(page: Page) -> None:
def step_create_business_with_allocation(page: Page, customer_name: str) -> None:
"""新增带二次分配方案的业务(参与人取人员下拉前两项,比例 30% + 40%)。"""
# 客户弹窗已关(上一段已关闭);直接路由直达业务管理,避免菜单点击
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
page.wait_for_timeout(600)
@@ -170,6 +220,51 @@ def step_verify_allocation_echo(page: Page) -> None:
page.wait_for_timeout(400)
def _fetch_plan(token: str, customer_name: str) -> tuple[str, list[dict]]:
"""取回刚创建业务的编号与实际方案(接口取数,不从列表 DOM 刮编号)。
断言要以**业务实际存下的方案**为准:参与人下拉取的是人员列表的前两
项,未必是脚本刚建的 t_e2e 员,硬编码姓名会把断言写死成脆弱假设。
"""
rows = _api(
"GET",
f"/api/v1/business/businesses?keyword={quote(customer_name)}&pageSize=20",
token,
)["list"]
target = next((r for r in rows if r.get("customer_name") == customer_name), None)
if target is None:
raise E2EFailure(f"业务列表中未找到客户 {customer_name} 的业务")
detail = _api("GET", f"/api/v1/business/businesses/{target['id']}", token)
return target["business_no"], detail.get("secondary_allocations") or []
def step_verify_approval_snapshot(page: Page, business_no: str, plan: list[dict]) -> None:
"""审批详情:展示提交时的送审方案(参与人 + 比例 + 合计余额)。"""
page.goto(f"{BASE_URL}/system/approvals", wait_until="networkidle")
page.wait_for_timeout(800)
page.locator('.filters:visible input[placeholder="审批单号 / 业务编号"]').fill(business_no)
page.locator('.filters:visible button:has-text("查询")').first.click()
page.wait_for_timeout(800)
rows = page.locator(".el-table__body .el-table__row:visible")
if rows.count() == 0:
fail("审批内容", f"未找到业务 {business_no} 的审批单")
rows.first.locator("button:has-text('详情')").click()
page.wait_for_timeout(1000)
drawer = page.locator(".el-drawer:visible").last
drawer.wait_for(state="visible")
text = drawer.inner_text()
if "审批内容" not in text:
fail("审批内容", "审批详情未展示「审批内容」区块")
for row in plan:
if row["personnel_name"] not in text:
fail("审批内容", f"送审内容缺少参与人 {row['personnel_name']}")
if f"{row['ratio']}%" not in text:
fail("审批内容", f"送审内容缺少参与人 {row['personnel_name']} 的比例 {row['ratio']}%")
print(
f"✓ 5. 审批内容 审批详情展示送审方案({len(plan)} 名参与人及其比例)"
)
def step_cleanup(page: Page) -> None:
"""删除本次创建的业务与其客户(t_ 前缀,走界面删除)。"""
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
@@ -184,7 +279,7 @@ def step_cleanup(page: Page) -> None:
# 删除确认框按钮文案为「删除」(useConfirmAction confirmButtonText)
confirm_message_box(page, "删除")
page.wait_for_timeout(600)
print("✓ 5. 数据清理 已删除本次创建的 t_ 业务")
print("✓ 6. 数据清理 已删除本次创建的 t_ 业务")
def run(page: Page) -> None:
@@ -194,13 +289,25 @@ def run(page: Page) -> None:
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
step_navigate_to_business(page, admin_password)
customer_name = step_create_customer_via_ui(page)
token = page.evaluate("localStorage.getItem('ys_token')")
if not token:
fail("API 预置", "localStorage 中未取到 ys_token")
# 显式关闭二次分配审批开关(结束还原默认 1):自动通过留痕,既不依赖
# 种子默认值,也让本次业务不留“在途审批单”——业务删除保护在途单,
# 开关开启时该业务在界面上删不掉
_set_allocation_switch(token, "0")
try:
step_create_personnel(page)
step_create_business_with_allocation(page, customer_name)
step_verify_allocation_echo(page)
customer_name = step_create_customer_via_ui(page)
try:
step_create_personnel(page)
step_create_business_with_allocation(page, customer_name)
business_no, plan = _fetch_plan(token, customer_name)
step_verify_allocation_echo(page)
step_verify_approval_snapshot(page, business_no, plan)
finally:
step_cleanup(page)
finally:
step_cleanup(page)
_set_allocation_switch(token, "1")
if console_errors:
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
+3 -3
View File
@@ -249,18 +249,18 @@ def step_permission_check(page: Page, admin_password: str) -> None:
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)
login(page, VIEWER_USERNAME, E2E_PASSWORD)
login(page, VIEWER_USERNAME, E2E_PASSWORD, expected_path="/business/businesses")
if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0:
fail("菜单权限", "普通用户看到了「系统管理」菜单")
print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组")
page.goto(f"{BASE_URL}/system/users", wait_until="networkidle")
page.wait_for_timeout(800)
if current_path(page) != "/dashboard":
if current_path(page) != "/business/businesses":
fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}")
if "需要管理员权限" not in toast_text(page):
fail("路由守卫", "直连管理页未给出权限提示")
print("✓ 12. 路由守卫 直连 /system/users 被弹回首页")
print("✓ 12. 路由守卫 直连 /system/users 被弹回业务数据页")
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)
+64 -1
View File
@@ -39,6 +39,65 @@ export function stepNodeLabel(node: number): string {
return ({ 0: '撤回', 1: '财务复核', 2: '领导审核' } as const)[node] ?? `节点${node}`
}
/** 审批对象类型:1二次分配方案 2月度结算 3调整单 */
export function orderTypeLabel(orderType: number): string {
return (
({ 1: '二次分配方案', 2: '月度结算', 3: '调整单' } as const)[orderType] ??
`类型${orderType}`
)
}
/** 二次分配方案送审内容(snapshot.data 按 order_type=1 解释) */
export interface ApprovalAllocationData {
business_no: string
business_date: string | null
customer_name: string | null
owner_personnel_name: string | null
assistant_personnel_name: string | null
sale_amount: string | null
cost_amount: string | null
allocations: { personnel_id: number; personnel_name: string; ratio: string }[]
allocation_total: string
allocation_balance: string
}
/** 月结送审内容(snapshot.data 按 order_type=2 解释) */
export interface ApprovalSettlementData {
settlement_month: string
total_performance: string | null
total_payable: string | null
person_count: number
items: {
personnel_name: string
role_code: string
calc_type: number
performance_sum: string
payable_sum: string
basic_amount: string | null
salary_mode: string | null
net_amount: string | null
is_probation: number
}[]
}
/** 调整单送审内容(snapshot.data 按 order_type=3 解释) */
export interface ApprovalAdjustmentData {
order_no: string
settlement_month: string
personnel_name: string
direction: number
amount: string
reason: string
}
/** 审批内容信封:snapshot=提交时快照;live=历史单回退当前内容;missing=缺失 */
export interface ApprovalSnapshot {
schema_version: number
order_type: number
captured_at: string | null
data: Record<string, unknown>
}
export interface ApprovalStep {
id: number
node: number
@@ -52,10 +111,12 @@ export interface ApprovalStep {
export interface ApprovalOrder {
id: number
order_no: string
/** 1二次分配方案 2月度结算(预留) */
/** 1二次分配方案 2月度结算 3调整单 */
order_type: number
ref_id: number
refer_no: string
/** 同一引用对象的第几次提交(重提产生新版本) */
version_no: number
status: number
submitter_id: number | null
submitter_name: string
@@ -64,6 +125,8 @@ export interface ApprovalOrder {
export interface ApprovalOrderDetail extends ApprovalOrder {
steps: ApprovalStep[]
snapshot: ApprovalSnapshot | null
content_source: 'snapshot' | 'live' | 'missing'
}
export interface ApprovalQueryParams {
+14 -1
View File
@@ -45,13 +45,26 @@ export interface BusinessRoleUpdateParams {
sort_order?: number
}
/** 分页查询岗位角色(含停用) */
/** 岗位下拉选项(登录即可;基础数据与报表筛选用,含停用) */
export interface BusinessRoleOption {
id: number
role_code: string
role_name: string
status: number
}
/** 分页查询岗位角色(含停用,仅管理员) */
export function listBusinessRoles(
params: BusinessRoleQueryParams,
): Promise<PageData<BusinessRoleItem>> {
return request({ url: '/v1/system/business-roles', method: 'get', params })
}
/** 只读岗位选项:财务/领导做报表筛选时不走管理员接口 */
export function listBusinessRoleOptions(): Promise<BusinessRoleOption[]> {
return request({ url: '/v1/base/business-roles/options', method: 'get' })
}
/** 新增岗位 */
export function createBusinessRole(
data: BusinessRoleCreateParams,
+12 -1
View File
@@ -58,12 +58,23 @@ export interface SecondaryAllocationRow {
ratio: string
}
/** 业务详情:附产品明细行与二次分配方案(编辑弹窗一次回显) */
/** 方案版本行(append-only;编辑替换/驳回清空后旧行仍保留为历史) */
export interface AllocationVersionRow extends SecondaryAllocationRow {
/** 方案版本号(同一业务从 1 递增) */
version_no: number
/** 1 当前生效 0 历史版本 */
is_active: number
create_time: string
}
/** 业务详情:附产品明细行、当前方案与方案版本历史(编辑弹窗一次回显) */
export interface BusinessDetail extends BusinessItem {
items: BusinessItemRow[]
secondary_allocations: SecondaryAllocationRow[]
/** 方案合计/余额提示(余额归属待澄清 #2,仅展示) */
allocation_hint: string | null
/** 方案全部版本(含历史;被驳回方案亦保留) */
allocation_history: AllocationVersionRow[]
}
export interface BusinessQueryParams {
+3
View File
@@ -67,7 +67,10 @@ export interface WarningBlock {
enabled: boolean
receipt_overdue_days: number
approval_timeout_days: number
/** 当前返回的条目(受展示上限约束) */
items: WarningItem[]
/** 全部规则命中总数;徽标计数用它,不能用 items.length */
total: number
receipt_overdue_total: number
approval_timeout_total: number
sync_available: boolean
+37
View File
@@ -47,6 +47,9 @@ export interface DepartmentMemberRow {
personnel_name: string
role_code: string
is_probation: number
/** regular 普通台账 / manager_personal 经理个人户 / adjustment 仅调整单 */
member_kind: string
unassigned_reason: string | null
performance_sum: string | null
payable_sum: string | null
adjust_extra: string
@@ -63,7 +66,14 @@ export interface DepartmentTeamRow {
member_count: number
team_base: string
team_share_amount: string
/** 单经理 5% 名义值;无月结聚合行时为 0(不发放) */
company_share_amount: string
/** 本月是否有月结聚合行(决定是否发放 5%) */
has_aggregate: boolean
/** 月结聚合行应发(12.5%+5% 一次舍入);无聚合行时 0 */
aggregate_amount: string
/** 团队应发小计 = 聚合行金额 + 调整净额 */
team_subtotal: string
company_base_ratio: string
adjust_extra: string
adjust_deduct: string
@@ -74,6 +84,27 @@ export interface DepartmentTeamRow {
members: DepartmentMemberRow[]
}
/** 签字稿对账块:应发 = 成员应付 + 未分配应付 + 聚合行金额 + 调整净额 */
export interface DepartmentReconciliation {
team_member_payable_total: string
unassigned_payable_total: string
team_share_total: string
company_share_total: string
aggregate_payable_total: string
aggregate_rounding_diff: string
adjust_net_total: string
payable_total: string
team_base_total: string
unassigned_performance_total: string
other_team_base: string
other_team_member_payable_total: string
other_team_share_total: string
other_team_company_share_total: string
other_team_aggregate_total: string
other_adjust_net_total: string
company_base: string
}
export interface DepartmentSummary {
settlement_month: string
params: {
@@ -88,8 +119,14 @@ export interface DepartmentSummary {
team_base_total: string
company_base: string
company_share_pct: string
/** 单经理 5% 名义值 */
company_share_amount: string
/** 计入应发的经理数(有聚合行) */
company_share_count: number
/** 5% 合计 = 单份 × 计入次数 */
company_share_total: string
} | null
reconciliation: DepartmentReconciliation | null
totals: Record<string, string>
}
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppMenu from '@/components/layout/AppMenu.vue'
import { firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
@@ -13,11 +14,14 @@ const userStore = useUserStore()
const drawerVisible = ref(false)
/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */
const homePath = computed(() => firstAccessiblePath(router, userStore.roles))
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
const breadcrumbs = computed<string[]>(() => {
const items: string[] = []
if (route.meta.group) items.push(route.meta.group)
if (route.meta.title && route.path !== '/dashboard') items.push(route.meta.title)
if (route.meta.title && route.path !== homePath.value) items.push(route.meta.title)
return items
})
@@ -66,7 +70,7 @@ async function handleLogout(): Promise<void> {
</svg>
</button>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: homePath }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item">
{{ item }}
</el-breadcrumb-item>
+3 -9
View File
@@ -1,8 +1,8 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { canAccessRoute, isMenuRecord } from '@/router/access'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */
export interface MenuNode {
@@ -30,19 +30,13 @@ export function useAppMenu() {
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
const userRoles = computed(() => userStore.roles)
const menuNodes = computed<MenuNode[]>(() =>
router
.getRoutes()
.filter((record) => Boolean(record.meta.title) && record.meta.menu !== false)
.filter((record) => !record.meta.requiresAdmin || isAdmin.value)
// 职能页面(审批等):任一列入角色命中即可见(admin 须显式列入集合)
.filter(
(record) =>
!record.meta.allowedRoles || record.meta.allowedRoles.some((role) => userRoles.value.includes(role)),
)
// 准入规则与路由守卫、落地页解析共用,避免三处漂移
.filter((record) => isMenuRecord(record) && canAccessRoute(record, userRoles.value))
.map((record) => ({
path: record.path,
title: record.meta.title ?? '',
+5 -2
View File
@@ -13,9 +13,11 @@ import { handledError } from '@/utils/error'
* 请求的响应允许写入状态,否则旧数据会覆盖新数据、与分页器错位
* - 请求失败不外抛(提示由拦截器统一完成),loading 仅由最新请求复位
*/
export function usePagedList<T, F extends object>(
fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise<PageData<T>>,
export function usePagedList<T, F extends object, D extends PageData<T> = PageData<T>>(
fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise<D>,
getFilters: () => F,
/** 仅最新请求的响应会触发(fetcher 内的副作用不受序号保护) */
onData?: (data: D) => void,
) {
const records = ref([]) as Ref<T[]>
const total = ref(0)
@@ -39,6 +41,7 @@ export function usePagedList<T, F extends object>(
if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应
records.value = data.list
total.value = data.total
onData?.(data)
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
+62
View File
@@ -0,0 +1,62 @@
import type { RouteRecordNormalized, Router } from 'vue-router'
import { ROLE_ADMIN } from '@/utils/constants'
/**
* 路由准入与落地页解析(菜单、守卫、登录回跳共用一份规则)。
*
* 之前守卫两处兜底硬编码 `/dashboard`,工作台收紧权限后普通用户会被
* 反复重定向到同一受限页(无限循环)。集中在这里后,任何角色的落地页
* 都必须先通过同一准入判定。
*/
/** 可作为菜单项/落地页:有标题、未被 menu:false 排除、非免登录页 */
export function isMenuRecord(record: RouteRecordNormalized): boolean {
return Boolean(record.meta.title) && record.meta.menu !== false && !record.meta.public
}
/** 当前角色是否可访问该路由(与守卫、菜单过滤同源) */
export function canAccessRoute(record: RouteRecordNormalized, roles: string[]): boolean {
if (record.meta.public) return false
if (record.meta.requiresAdmin && !roles.includes(ROLE_ADMIN)) return false
if (
record.meta.allowedRoles &&
!record.meta.allowedRoles.some((role) => roles.includes(role))
) {
return false
}
return true
}
/**
* 落地页候选(按优先级):工作台 → 业务数据 → 回款。
* 普通用户没有工作台权限时落到业务数据页,而不是被菜单 order 带到
* 基础数据管理页;候选都不可访问时再按菜单顺序兜底。
*/
const HOME_CANDIDATES = ['/dashboard', '/business/businesses', '/business/receipts']
function matchedRecord(router: Router, path: string): RouteRecordNormalized | undefined {
const matched = router.resolve(path).matched
return matched[matched.length - 1]
}
/** 当前角色可访问的默认首页;无任何可访问页时回登录页(防循环) */
export function firstAccessiblePath(router: Router, roles: string[]): string {
for (const path of HOME_CANDIDATES) {
const record = matchedRecord(router, path)
if (record && isMenuRecord(record) && canAccessRoute(record, roles)) return path
}
const fallback = router
.getRoutes()
.filter(isMenuRecord)
.filter((record) => canAccessRoute(record, roles))
.sort((a, b) => (a.meta.order ?? 99) - (b.meta.order ?? 99))
return fallback[0]?.path ?? '/login'
}
/** 登录回跳目标是否可信且可访问(防开放重定向与回跳受限页) */
export function canAccessPath(router: Router, path: string, roles: string[]): boolean {
if (!path.startsWith('/') || path.startsWith('//')) return false
const record = matchedRecord(router, path)
return Boolean(record && isMenuRecord(record) && canAccessRoute(record, roles))
}
+8 -4
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import { firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
@@ -265,14 +266,17 @@ router.beforeEach(async (to) => {
}
if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) {
ElMessage.error('需要管理员权限')
return { path: '/dashboard' }
const home = firstAccessiblePath(router, userStore.roles)
// 根路径/catch-all 的重定向不提示;用户手输受限 URL 才提示
if (!to.redirectedFrom) ElMessage.error('需要管理员权限')
return home === to.path ? { path: '/login' } : { path: home }
}
// 职能页面(审批等):任一角色命中即可进;菜单隐藏之外的第二道防线
if (to.meta.allowedRoles && !to.meta.allowedRoles.some((role) => userStore.roles.includes(role))) {
ElMessage.error('无权访问该页面')
return { path: '/dashboard' }
const home = firstAccessiblePath(router, userStore.roles)
if (!to.redirectedFrom) ElMessage.error('无权访问该页面')
return home === to.path ? { path: '/login' } : { path: home }
}
return true
+3 -3
View File
@@ -69,9 +69,9 @@ const managerOptions = ref<SelectOption[]>([])
async function loadRoleOptions(): Promise<void> {
try {
// 岗位规模有限(内置 3 个 + 自定义),一页足够
const data = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 100 })
roleOptions.value = data.list.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
// 岗位规模有限(内置 3 个 + 自定义),选项接口一次取全
const data = await businessRoleApi.listBusinessRoleOptions()
roleOptions.value = data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
@@ -1141,6 +1141,43 @@ onMounted(() => {
</el-table>
</div>
<div class="sub-section">
<div class="sub-section-title">二次分配方案(含历史版本)</div>
<el-table
v-if="detail && (detail.allocation_history?.length ?? 0) > 0"
:data="detail.allocation_history"
stripe
size="small"
>
<el-table-column label="版本" width="70" align="center">
<template #default="{ row }">V{{ row.version_no }}</template>
</el-table-column>
<el-table-column prop="personnel_name" label="参与业务员" min-width="140" />
<el-table-column label="分配比例" width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.ratio }}%</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
v-if="row.is_active === 1"
type="success"
size="small"
disable-transitions
>
生效
</el-tag>
<el-tag v-else type="info" size="small" disable-transitions>历史</el-tag>
</template>
</el-table-column>
<el-table-column label="录入时间" min-width="150">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<div v-else class="cell-muted">未设置二次分配 —— 全部绩效归负责营销人员</div>
</div>
<div class="sub-section">
<div class="sub-section-title">状态流转</div>
<el-timeline v-if="statusLogs.length > 0" class="status-timeline">
+15 -4
View File
@@ -42,6 +42,8 @@ const ORDER_TYPE_LABELS: Record<number, string> = {
const month = ref(currentMonth())
const data = ref<DashboardData | null>(null)
const loading = ref(false)
/** 快速切换月份时,只允许最新请求写入数据与 loading */
let loadSeq = 0
const perfChartRef = ref<HTMLDivElement | null>(null)
const receiptChartRef = ref<HTMLDivElement | null>(null)
@@ -54,15 +56,18 @@ function currentMonth(): string {
}
async function load(): Promise<void> {
const seq = ++loadSeq
loading.value = true
try {
data.value = await dashboardApi.getDashboard({ month: month.value })
const result = await dashboardApi.getDashboard({ month: month.value })
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
data.value = result
await nextTick()
renderCharts()
} catch (error) {
handledError(error)
if (seq === loadSeq) handledError(error)
} finally {
loading.value = false
if (seq === loadSeq) loading.value = false
}
}
@@ -294,7 +299,7 @@ function formatTime(value: string): string {
<div class="bento-card b-2x2">
<div class="b-eyebrow">
风险预警
<span class="b-count">{{ data?.warnings.items.length ?? 0 }}</span>
<span class="b-count">{{ data?.warnings.total ?? 0 }}</span>
</div>
<div v-if="data && !data.warnings.enabled" class="b-empty-mini">
预警已关闭(参数 warning.enabled=0)
@@ -322,6 +327,12 @@ function formatTime(value: string): string {
暂无预警(回款逾期 {{ data?.warnings.receipt_overdue_days ?? 90 }} 天 /
审批超时 {{ data?.warnings.approval_timeout_days ?? 3 }} 天)
</div>
<div
v-if="data && data.warnings.total > data.warnings.items.length"
class="b-empty-mini"
>
仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条
</div>
<div class="b-eyebrow b-eyebrow-gap">同步异常预警</div>
<div class="b-empty-mini">
{{ data?.warnings.sync_available ? '已接入' : '同步模块未启用(待 T+ 对接)' }}
+9 -1
View File
@@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getCaptcha } from '@/api/auth'
import { canAccessPath, firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
const router = useRouter()
@@ -87,8 +88,15 @@ async function handleSubmit(): Promise<void> {
captcha_id: captchaId.value,
captcha_code: form.captcha_code.trim(),
})
// 先拉角色再解析落地页:普通用户不能回跳到受限的工作台
await userStore.fetchUserInfo()
ElMessage.success('登录成功')
await router.replace((route.query.redirect as string) || '/dashboard')
const redirect = route.query.redirect
const target =
typeof redirect === 'string' && canAccessPath(router, redirect, userStore.roles)
? redirect
: firstAccessiblePath(router, userStore.roles)
await router.replace(target)
} catch {
// **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
@@ -60,14 +60,15 @@ function getFilters(): ReportFilterParams {
}
}
async function fetchPage(params: ReportFilterParams) {
const data = await reportApi.listBusinessStats(params)
summary.value = data.summary
return data
function fetchPage(params: ReportFilterParams) {
return reportApi.listBusinessStats(params)
}
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
usePagedList<BusinessStatsRow, ReportFilterParams>(fetchPage, getFilters)
usePagedList(fetchPage, getFilters, (data) => {
summary.value = data.summary
})
// 进页即加载(报表页默认展示全部,筛选由用户主动收窄)
onMounted(search)
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
@@ -14,8 +14,8 @@ import { handledError } from '@/utils/error'
*
* 部门口径 = personnel.manager_id 实时上溯的经理团队(无独立部门表);
* 团队基数与月结聚合行同源(manager_share 共享模块),因此可与月结
* 冻结额(frozen_amount)对账。全公司 5% 部分单列——各团队行展示的
* 5% 金额是同一基数的名义值,不能相加。
* 冻结额(frozen_amount)对账。全公司分成每位有聚合行的经理各计一次,
* 团队应发小计与月结聚合行同口径;列头比例随在线参数变化。
*/
const router = useRouter()
@@ -35,6 +35,18 @@ void loadManagers()
const data = ref<DepartmentSummary | null>(null)
const loading = ref(false)
const exporting = ref(false)
let loadSeq = 0
// 比例来自在线参数,列头/卡片不能写死 12.5% / 5%
const teamShareLabel = computed(
() => `团队分成(${data.value?.params?.team_text ?? '—'}%)`,
)
const companyShareLabel = computed(
() => `全公司分成(${data.value?.params?.company_text ?? '—'}%)`,
)
const companyTitle = computed(
() => `全公司 ${data.value?.params?.company_text ?? '—'}% 部分(单列)`,
)
function currentMonth(): string {
const now = new Date()
@@ -46,16 +58,18 @@ async function load(): Promise<void> {
ElMessage.warning('请选择统计月份(部门汇总按单月聚合)')
return
}
const seq = ++loadSeq
loading.value = true
try {
data.value = await reportApi.getDepartmentSummary({
const result = await reportApi.getDepartmentSummary({
settlement_month: filters.settlement_month,
manager_id: filters.manager_id,
})
if (seq === loadSeq) data.value = result
} catch (error) {
handledError(error)
if (seq === loadSeq) handledError(error)
} finally {
loading.value = false
if (seq === loadSeq) loading.value = false
}
}
@@ -164,11 +178,16 @@ function pct(value: string): string {
</div>
<div v-loading="loading" class="company-card">
<span class="company-title">全公司 5% 部分(单列)</span>
<span class="company-title">{{ companyTitle }}</span>
<span>全公司基数:{{ data?.company?.company_base ?? '—' }}</span>
<span>5% 分成:{{ data?.company?.company_share_amount ?? '—' }}</span>
<span>
单经理 {{ data?.params?.company_text ?? '—' }}% 分成:
{{ data?.company?.company_share_amount ?? '—' }} ×
{{ data?.company?.company_share_count ?? 0 }} 人 =
{{ data?.company?.company_share_total ?? '—' }}
</span>
<span class="company-note">
各团队行的 5% 金额基于同一全公司基数,不可跨团队相加
每位有月结聚合行的经理各计一次;零成员团队无聚合行,不产生分成
</span>
</div>
@@ -210,8 +229,8 @@ function pct(value: string): string {
</el-table-column>
<el-table-column prop="member_count" label="团队人数" width="90" align="right" />
<el-table-column prop="team_base" label="团队基数" width="120" align="right" />
<el-table-column prop="team_share_amount" label="12.5% 分成" width="120" align="right" />
<el-table-column prop="company_share_amount" label="5% 分成" width="110" align="right" />
<el-table-column prop="team_share_amount" :label="teamShareLabel" width="150" align="right" />
<el-table-column prop="company_share_amount" :label="companyShareLabel" width="150" align="right" />
<el-table-column label="占全公司比" width="110" align="right">
<template #default="{ row }">{{ pct(row.company_base_ratio) }}</template>
</el-table-column>
@@ -40,66 +40,86 @@ function getFilters() {
}
}
interface DrillState {
rows: PaymentDetailRow[]
total: number
pageNum: number
pageSize: number
loading: boolean
seq: number
}
/** 每个桶独立的下钻状态:同时展开多个区间时互不覆盖 */
const drills = reactive<Record<string, DrillState>>({})
function drillOf(code: string): DrillState {
drills[code] ??= { rows: [], total: 0, pageNum: 1, pageSize: 10, loading: false, seq: 0 }
return drills[code]
}
let loadSeq = 0
async function load(): Promise<void> {
const seq = ++loadSeq
loading.value = true
try {
data.value = await reportApi.getPaymentStats(getFilters())
const result = await reportApi.getPaymentStats(getFilters())
if (seq !== loadSeq) return
data.value = result
// 筛选条件变化后旧桶明细不再对应当前口径;同时预建各桶状态,
// 避免模板渲染时才创建响应式属性
for (const code of Object.keys(drills)) delete drills[code]
for (const bucket of result.buckets) drillOf(bucket.code)
} catch (error) {
handledError(error)
if (seq === loadSeq) handledError(error)
} finally {
loading.value = false
if (seq === loadSeq) loading.value = false
}
}
void load()
/** 桶展开下钻:分页明细按 (桶编码) 缓存当前页 */
const drill = reactive({
bucket: '',
rows: [] as PaymentDetailRow[],
total: 0,
pageNum: 1,
pageSize: 10,
loading: false,
})
async function loadDrill(bucket: BucketRow, expanded: boolean): Promise<void> {
if (!expanded) return
drill.bucket = bucket.code
drill.pageNum = 1
await fetchDrill()
function loadDrill(bucket: BucketRow, expandedRows: BucketRow[]): void {
// Element Plus 2.x 的第二参是当前展开行数组,不是 boolean;
// 收起时该桶不在数组里,自然不再请求
if (!expandedRows.some((row) => row.code === bucket.code)) return
drillOf(bucket.code).pageNum = 1
void fetchDrill(bucket.code)
}
async function fetchDrill(): Promise<void> {
if (!drill.bucket) return
drill.loading = true
async function fetchDrill(code: string): Promise<void> {
const state = drillOf(code)
const seq = ++state.seq
state.loading = true
try {
const page = await reportApi.listPaymentDetail({
...getFilters(),
bucket: drill.bucket,
pageNum: drill.pageNum,
pageSize: drill.pageSize,
bucket: code,
pageNum: state.pageNum,
pageSize: state.pageSize,
})
drill.rows = page.list
drill.total = page.total
if (seq !== state.seq) return
state.rows = page.list
state.total = page.total
} catch (error) {
handledError(error)
if (seq === state.seq) handledError(error)
} finally {
drill.loading = false
if (seq === state.seq) state.loading = false
}
}
function onDrillPage(page: number): void {
drill.pageNum = page
void fetchDrill()
function onDrillPage(code: string, page: number): void {
drillOf(code).pageNum = page
void fetchDrill(code)
}
function onDrillSize(size: number): void {
drill.pageSize = size
drill.pageNum = 1 // 改每页条数回到第 1 页,否则可能停在空页
void fetchDrill()
function onDrillSize(code: string, size: number): void {
const state = drillOf(code)
state.pageSize = size
state.pageNum = 1 // 改每页条数回到第 1 页,否则可能停在空页
void fetchDrill(code)
}
void load()
async function doExport(): Promise<void> {
exporting.value = true
try {
@@ -198,15 +218,22 @@ function today(): string {
<el-table
v-loading="loading"
class="bucket-table"
data-testid="payment-bucket-table"
:data="data?.buckets ?? []"
row-key="code"
stripe
@expand-change="loadDrill"
>
<el-table-column type="expand">
<template #default>
<template #default="{ row }">
<div class="drill-wrap">
<el-table v-loading="drill.loading" :data="drill.rows" size="small" stripe>
<el-table
v-loading="drillOf(row.code).loading"
:data="drillOf(row.code).rows"
size="small"
stripe
>
<el-table-column prop="business_no" label="合同编号" min-width="150" />
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
@@ -226,11 +253,11 @@ function today(): string {
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
</el-table>
<AppPagination
:total="drill.total"
:page-num="drill.pageNum"
:page-size="drill.pageSize"
@page-change="onDrillPage"
@size-change="onDrillSize"
:total="drillOf(row.code).total"
:page-num="drillOf(row.code).pageNum"
:page-size="drillOf(row.code).pageSize"
@page-change="onDrillPage(row.code, $event)"
@size-change="onDrillSize(row.code, $event)"
/>
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import * as calcApi from '@/api/calc'
import type { LedgerRow } from '@/api/calc'
@@ -9,7 +9,7 @@ import { CUSTOMER_TYPE_OPTIONS, customerTypeLabel } from '@/api/customers'
import { listPersonnel } from '@/api/personnel'
import * as reportApi from '@/api/reports'
import type { PersonalDetailRow, ReportFilterParams, ReportSummary } from '@/api/reports'
import { listBusinessRoles } from '@/api/businessRoles'
import { listBusinessRoleOptions } from '@/api/businessRoles'
import AppPagination from '@/components/table/AppPagination.vue'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
@@ -23,6 +23,7 @@ import { handledError } from '@/utils/error'
*/
const router = useRouter()
const route = useRoute()
const filters = reactive({
settlement_month: '',
@@ -32,14 +33,30 @@ const filters = reactive({
role_code: '',
})
/**
* 部门汇总「查看明细」下钻:路由 query 必须真正落到筛选条件上。
*
* 无 query 时也要清空(不能只覆盖):从菜单再次进入同一页面时组件复用,
* 旧的 personnel_id/settlement_month 会残留,用户看到的仍是被下钻者的数据。
*/
function applyRouteQuery(): void {
const personnelId = Number(route.query.personnel_id)
filters.personnel_id =
Number.isInteger(personnelId) && personnelId > 0 ? personnelId : undefined
const month = route.query.settlement_month
filters.settlement_month =
typeof month === 'string' && /^\d{4}-\d{2}$/.test(month) ? month : ''
}
applyRouteQuery()
const { options: personnelOptions, load: loadPersonnel } = useDropdownOptions(() =>
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
),
)
const { options: roleOptions, load: loadRoles } = useDropdownOptions(() =>
listBusinessRoles({ pageNum: 1, pageSize: 200 }).then((data) =>
data.list.map((item) => ({ label: item.role_name, value: item.role_code })),
listBusinessRoleOptions().then((data) =>
data.map((item) => ({ label: item.role_name, value: item.role_code })),
),
)
void loadPersonnel()
@@ -58,18 +75,28 @@ function getFilters(): ReportFilterParams {
}
}
async function fetchPage(params: ReportFilterParams) {
const data = await reportApi.listPersonalDetail(params)
summary.value = data.summary
return data
function fetchPage(params: ReportFilterParams) {
return reportApi.listPersonalDetail(params)
}
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
usePagedList<PersonalDetailRow, ReportFilterParams>(fetchPage, getFilters)
usePagedList(fetchPage, getFilters, (data) => {
summary.value = data.summary
})
// 进页即加载(与月结/台账页修复后的行为一致;筛选条件默认为「全部」)
// 进页即加载(筛选条件可能已由部门汇总下钻带入)
onMounted(search)
// 同一路由再次下钻(例如从部门汇总换成员)时重新应用 query
watch(
() => route.query,
() => {
applyRouteQuery()
search()
},
)
function resetFilters(): void {
filters.settlement_month = ''
filters.personnel_id = undefined
+196 -28
View File
@@ -7,7 +7,7 @@ import { customerTypeLabel } from '@/api/customers'
import * as reportApi from '@/api/reports'
import type {
BusinessStatsRow,
DepartmentMemberRow,
DepartmentSummary,
PersonalDetailRow,
ReportFilterParams,
} from '@/api/reports'
@@ -38,12 +38,30 @@ const title = ref('')
// 表格行是四类报表的并集,模板按报表编码取用各自字段;用宽松记录类型
// 避免为四种行形态各写一套模板 props
const rows = ref<Record<string, any>[]>([])
const department = ref<DepartmentSummary | null>(null)
const buckets = ref<Record<string, any>[]>([])
const groups = ref<{ title: string; rows: Record<string, any>[] }[]>([])
const loading = ref(true)
const truncated = ref(false)
const printedAt = ref(new Date())
/** 部门汇总成员明细展平(团队列冗余,与签字稿其他块一致) */
const departmentMembers = computed(() =>
(department.value?.teams ?? []).flatMap((team) =>
team.members.map((member) => ({ manager_name: team.manager_name, ...member })),
),
)
const MEMBER_KIND_LABELS: Record<string, string> = {
regular: '普通绩效',
manager_personal: '经理个人户',
adjustment: '仅调整单',
}
function memberKindLabel(kind: string): string {
return MEMBER_KIND_LABELS[kind] ?? kind
}
const filterText = computed(() => {
const labels: Record<string, string> = {
settlement_month: '统计月份',
@@ -71,13 +89,19 @@ async function fetchAll<T>(
): Promise<{ list: T[]; truncated: boolean }> {
const collected: T[] = []
let pageNum = 1
let total = 0
while (collected.length < PRINT_MAX_ROWS) {
const data = await fetcher({ ...params, pageNum, pageSize: PAGE_SIZE })
total = data.total
collected.push(...data.list)
if (collected.length >= data.total || data.list.length < PAGE_SIZE) break
pageNum += 1
}
return { list: collected.slice(0, PRINT_MAX_ROWS), truncated: collected.length > PRINT_MAX_ROWS }
// 以接口 total 判定:collected 最多正好等于上限,用长度比较永远为 false
return {
list: collected.slice(0, PRINT_MAX_ROWS),
truncated: total > PRINT_MAX_ROWS,
}
}
function personalRow(row: PersonalDetailRow): Record<string, any> {
@@ -109,18 +133,7 @@ async function load(): Promise<void> {
truncated.value = data.truncated
} else if (code.value === 'department-summary') {
title.value = '部门绩效工资汇总表'
const data = await reportApi.getDepartmentSummary(queryParams.value)
rows.value = data.teams.flatMap((team) =>
team.members.map((member: DepartmentMemberRow) => ({
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,
})),
)
department.value = await reportApi.getDepartmentSummary(queryParams.value)
} else if (code.value === 'business-stats') {
title.value = '业务统计表'
const data = await fetchAll(
@@ -169,12 +182,13 @@ onMounted(load)
<h1 class="title">{{ title }}</h1>
<div class="meta">
<span>筛选条件:{{ filterText }}</span>
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.0</span>
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2</span>
</div>
</header>
<div v-if="truncated" class="truncated">
数据量超过打印上限(2000 行),仅打印前 2000 行;请缩小筛选范围后重新打印。
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),仅打印前 {{ PRINT_MAX_ROWS }}
行;请缩小筛选范围后重新打印。
</div>
<template v-if="code === 'personal-detail'">
@@ -204,23 +218,171 @@ onMounted(load)
</table>
</template>
<template v-else-if="code === 'department-summary'">
<table class="sheet-table">
<template v-else-if="code === 'department-summary' && department">
<h2 class="block-title">一、团队分成汇总</h2>
<table class="sheet-table" data-testid="print-department-teams">
<thead>
<tr>
<th>经理团队</th><th>成员</th><th>岗位</th><th>绩效金额</th>
<th>应付绩效</th><th>调整净额</th><th>净合计</th>
<th>序号</th><th>经理团队</th><th>状态</th><th>团队人数</th><th>团队基数</th>
<th>团队分成({{ department.params?.team_text ?? '—' }}%)</th>
<th>调整净额(仅核对)</th><th>团队应发小计(含聚合分成)</th><th>月结冻结额</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in rows" :key="index">
<td>{{ row.manager_name }}</td>
<td>{{ row.member_name }}</td>
<td>{{ row.role_code }}</td>
<td class="num">{{ row.performance_sum }}</td>
<td class="num">{{ row.payable_sum }}</td>
<td class="num">{{ row.adjust_net }}</td>
<td class="num">{{ row.net_total }}</td>
<tr v-for="(team, index) in department.teams" :key="team.manager_id">
<td>{{ index + 1 }}</td>
<td>{{ team.manager_name }}</td>
<td>{{ team.manager_status === 1 ? '在职' : '离职' }}</td>
<td class="num">{{ team.member_count }}</td>
<td class="num">{{ team.team_base }}</td>
<td class="num">{{ team.team_share_amount }}</td>
<td class="num">{{ team.adjust_net }}</td>
<td class="num">{{ team.team_subtotal }}</td>
<td class="num">{{ team.frozen_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">
二、全公司分成(每位有聚合行的经理各计一次,共
{{ department.company?.company_share_count ?? 0 }} 次)
</h2>
<table class="sheet-table" data-testid="print-company-block">
<thead>
<tr>
<th>全公司基数</th><th>分成比例</th><th>单经理分成</th>
<th>计入次数</th><th>分成合计</th>
</tr>
</thead>
<tbody>
<tr>
<td class="num">{{ department.company?.company_base ?? '—' }}</td>
<td class="num">{{ department.params?.company_text ?? '—' }}%</td>
<td class="num">{{ department.company?.company_share_amount ?? '—' }}</td>
<td class="num">{{ department.company?.company_share_count ?? 0 }}</td>
<td class="num">{{ department.company?.company_share_total ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">三、对账汇总</h2>
<table class="sheet-table" data-testid="print-reconciliation">
<thead>
<tr><th>项目</th><th>金额</th><th>说明</th></tr>
</thead>
<tbody>
<tr>
<td>团队成员应付合计</td>
<td class="num">{{ department.reconciliation?.team_member_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配人员应付合计</td>
<td class="num">{{ department.reconciliation?.unassigned_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>团队分成合计(分列)</td>
<td class="num">{{ department.reconciliation?.team_share_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>
全公司分成合计({{ department.company?.company_share_count ?? 0 }} 次,分列)
</td>
<td class="num">{{ department.reconciliation?.company_share_total ?? '—' }}</td>
<td>每位有聚合行的经理各计一次</td>
</tr>
<tr>
<td>聚合行舍入差</td>
<td class="num">{{ department.reconciliation?.aggregate_rounding_diff ?? '—' }}</td>
<td>分列舍入与聚合行一次舍入的差</td>
</tr>
<tr>
<td>调整净额合计(明细层)</td>
<td class="num">{{ department.reconciliation?.adjust_net_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>应发合计</td>
<td class="num">{{ department.reconciliation?.payable_total ?? '—' }}</td>
<td>上列各项之和</td>
</tr>
<tr>
<td>团队基数合计</td>
<td class="num">{{ department.reconciliation?.team_base_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配绩效合计</td>
<td class="num">{{ department.reconciliation?.unassigned_performance_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>其他未展示基数</td>
<td class="num">{{ department.reconciliation?.other_team_base ?? '—' }}</td>
<td>筛选时含其他团队与全公司未分配</td>
</tr>
<tr>
<td>全公司基数</td>
<td class="num">{{ department.reconciliation?.company_base ?? '—' }}</td>
<td />
</tr>
</tbody>
</table>
<h2 class="block-title">四、团队成员明细</h2>
<table class="sheet-table" data-testid="print-department-members">
<thead>
<tr>
<th>经理团队</th><th>成员</th><th>岗位</th><th>来源</th><th>绩效金额</th>
<th>应付绩效</th><th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in departmentMembers"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.manager_name }}</td>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ memberKindLabel(member.member_kind) }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">五、未分配人员明细</h2>
<table class="sheet-table" data-testid="print-unassigned">
<thead>
<tr>
<th>人员</th><th>岗位</th><th>未纳入原因</th><th>绩效金额</th><th>应付绩效</th>
<th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in department.unassigned"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ member.unassigned_reason ?? '—' }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
<tr v-if="department.unassigned.length === 0">
<td colspan="9">(无)</td>
</tr>
</tbody>
</table>
@@ -374,6 +536,12 @@ onMounted(load)
font-variant-numeric: tabular-nums;
}
.block-title {
margin: 18px 0 6px;
font-size: 14px;
font-weight: 600;
}
.group-block {
margin-top: 18px;
}
+192 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
@@ -8,10 +8,14 @@ import {
approvalStatusTagType,
getApproval,
listApprovals,
orderTypeLabel,
stepActionLabel,
stepNodeLabel,
type ApprovalAdjustmentData,
type ApprovalAllocationData,
type ApprovalOrder,
type ApprovalOrderDetail,
type ApprovalSettlementData,
} from '@/api/approvals'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
@@ -78,6 +82,26 @@ async function openDetail(row: ApprovalOrder): Promise<void> {
}
}
// ==================== 审批内容(按对象类型解释快照 data) ====================
const allocationData = computed<ApprovalAllocationData | null>(() =>
detail.value?.order_type === 1 && detail.value.snapshot
? (detail.value.snapshot.data as unknown as ApprovalAllocationData)
: null,
)
const settlementData = computed<ApprovalSettlementData | null>(() =>
detail.value?.order_type === 2 && detail.value.snapshot
? (detail.value.snapshot.data as unknown as ApprovalSettlementData)
: null,
)
const adjustmentData = computed<ApprovalAdjustmentData | null>(() =>
detail.value?.order_type === 3 && detail.value.snapshot
? (detail.value.snapshot.data as unknown as ApprovalAdjustmentData)
: null,
)
// ==================== 节点动作 ====================
const actingId = ref<number | null>(null)
@@ -154,6 +178,8 @@ async function submitAction(): Promise<void> {
<el-form-item label="类型">
<el-select v-model="filters.order_type" placeholder="全部" clearable style="width: 150px">
<el-option label="二次分配方案" :value="1" />
<el-option label="月度结算" :value="2" />
<el-option label="调整单" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
@@ -165,11 +191,12 @@ async function submitAction(): Promise<void> {
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="order_no" label="审批单号" min-width="150" />
<el-table-column label="类型" width="120">
<template #default="{ row }">
{{ row.order_type === 1 ? '二次分配方案' : `类型${row.order_type}` }}
</template>
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
</el-table-column>
<el-table-column prop="refer_no" label="业务编号" min-width="140" />
<el-table-column label="版本" width="70" align="center">
<template #default="{ row }">V{{ row.version_no }}</template>
</el-table-column>
<el-table-column prop="refer_no" label="引用单号" min-width="140" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="approvalStatusTagType(row.status)" size="small" disable-transitions>
@@ -196,7 +223,7 @@ async function submitAction(): Promise<void> {
@size-change="onSizeChange"
/>
<el-drawer v-model="detailVisible" title="审批详情" size="480px">
<el-drawer v-model="detailVisible" title="审批详情" size="720px">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="detail-head">
@@ -210,11 +237,129 @@ async function submitAction(): Promise<void> {
</el-tag>
</div>
<div class="detail-meta">
<span>业务编号:{{ detail.refer_no }}</span>
<span>类型:{{ orderTypeLabel(detail.order_type) }} · 版本 V{{ detail.version_no }}</span>
<span>引用单号:{{ detail.refer_no }}</span>
<span>提交人:{{ detail.submitter_name }}</span>
<span>提交时间:{{ formatDateTime(detail.create_time) }}</span>
</div>
<!-- 审批内容:优先提交时快照;历史单缺快照时读当前并显式标注 -->
<div class="content-section">
<div class="content-title">审批内容</div>
<!-- 采集时间让复核者明确「看到的是哪个时点的内容」:快照=送审时刻;
live=打开详情时的当前内容(不做时刻展示——渲染函数里的
new Date() 非响应式且 toISOString 是 UTC,会差 8 小时) -->
<div v-if="detail.content_source === 'snapshot'" class="content-captured">
内容采集时间:{{ formatDateTime(detail.snapshot?.captured_at) }}(送审版本)
</div>
<div v-else-if="detail.content_source === 'live'" class="content-captured">
内容读取时间:打开详情时实时读取(非送审版本)
</div>
<el-alert
v-if="detail.content_source === 'live'"
type="warning"
:closable="false"
show-icon
title="该单为历史单,未留提交时快照;以下为当前业务内容,仅供参考,不等同送审版本。"
/>
<el-alert
v-else-if="detail.content_source === 'missing'"
type="info"
:closable="false"
show-icon
title="送审内容缺失:引用对象已不存在且未留快照,仅可查看节点留痕。"
/>
<template v-if="allocationData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="业务编号">
{{ allocationData.business_no }}
</el-descriptions-item>
<el-descriptions-item label="业务日期">
{{ allocationData.business_date ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="客户名称">
{{ allocationData.customer_name ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="负责人员">
{{ allocationData.owner_personnel_name ?? '(待指定)' }}
</el-descriptions-item>
<el-descriptions-item label="销售额(元)">
{{ allocationData.sale_amount ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="成本(元)">
{{ allocationData.cost_amount ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<el-table :data="allocationData.allocations" stripe size="small" class="content-table">
<el-table-column prop="personnel_name" label="参与业务员" min-width="160" />
<el-table-column label="分配比例" width="120" align="right">
<template #default="{ row }">{{ row.ratio }}%</template>
</el-table-column>
<template #empty>
<span class="content-empty">未设置二次分配 —— 全部绩效归负责营销人员</span>
</template>
</el-table>
<div class="content-total">
合计 {{ allocationData.allocation_total }}%,余额
{{ allocationData.allocation_balance }}%(归合同负责营销人员)
</div>
</template>
<template v-else-if="settlementData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="结算月份">
{{ settlementData.settlement_month }}
</el-descriptions-item>
<el-descriptions-item label="结算人数">
{{ settlementData.person_count }}
</el-descriptions-item>
<el-descriptions-item label="绩效合计(元)">
{{ settlementData.total_performance ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="应付合计(元)">
{{ settlementData.total_payable ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<el-table :data="settlementData.items" stripe size="small" class="content-table">
<el-table-column prop="personnel_name" label="人员" min-width="110" />
<el-table-column prop="role_code" label="岗位" width="110" />
<el-table-column label="绩效(元)" width="110" align="right">
<template #default="{ row }">{{ row.performance_sum }}</template>
</el-table-column>
<el-table-column label="应付(元)" width="110" align="right">
<template #default="{ row }">{{ row.payable_sum }}</template>
</el-table-column>
<el-table-column label="净工资(元)" width="120" align="right">
<template #default="{ row }">{{ row.net_amount ?? '—' }}</template>
</el-table-column>
</el-table>
</template>
<template v-else-if="adjustmentData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="调整单号">
{{ adjustmentData.order_no }}
</el-descriptions-item>
<el-descriptions-item label="调整月份">
{{ adjustmentData.settlement_month }}
</el-descriptions-item>
<el-descriptions-item label="调整人员">
{{ adjustmentData.personnel_name }}
</el-descriptions-item>
<el-descriptions-item label="方向">
{{ adjustmentData.direction === 1 ? '补发' : '扣减' }}
</el-descriptions-item>
<el-descriptions-item label="金额(元)">
{{ adjustmentData.amount }}
</el-descriptions-item>
<el-descriptions-item label="扣补说明">
{{ adjustmentData.reason }}
</el-descriptions-item>
</el-descriptions>
</template>
</div>
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
<div class="timeline">
<div
@@ -315,6 +460,46 @@ async function submitAction(): Promise<void> {
color: var(--ys-ink-3);
}
.content-section {
margin-top: 12px;
padding: 12px;
background: #f7f8fa;
border: 1px solid var(--ys-line);
border-radius: 8px;
}
.content-title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: var(--ys-ink);
}
.content-captured {
margin-bottom: 10px;
font-size: 12px;
color: var(--ys-ink-2);
}
.content-desc {
margin-bottom: 10px;
}
.content-table {
margin-top: 8px;
}
.content-total {
margin-top: 8px;
font-size: 12px;
color: var(--ys-ink-2);
}
.content-empty {
font-size: 12px;
color: var(--ys-ink-3);
}
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
.timeline {
margin-top: 14px;