Author SHA1 Message Date
liuwenxinandClaude Code c319a6a428 docs: M4 阶段报告(2026-10-20)
汇总报表统计与工作台交付范围、关键口径、测试证据与遗留事项。

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

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

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

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

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

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

验证:pnpm type-check 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 16:33:06 +08:00
77 changed files with 9220 additions and 608 deletions

No files matched your search

+6
View File
@@ -64,3 +64,9 @@ CLAUDE.md
backend/uploads/
# 数据库备份导出目录(⑨ 备份脚本产物)
db/backups/
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
docs/**/*.rar
docs/T_12.3数据字典/
# macOS 压缩包残留元数据
__MACOSX/
+51
View File
@@ -114,6 +114,31 @@ app/
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
### 报表统计接口(M4 ⑤,限管理员/财务/领导)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/reports/personal-detail` | 个人绩效工资明细表(FR-029:人员/月份/客户类型/产品类别/岗位筛选;响应带 `summary` 全筛选合计) |
| GET | `/api/v1/reports/department-summary` | 部门绩效工资汇总表(FR-030:月份必填;团队基数与月结聚合行同源;12.5% 与 5% 分列;含成员内联明细、未分配桶、月结冻结对照额) |
| GET | `/api/v1/reports/business-stats` | 业务统计表(FR-031:发货/回款/绩效为业务累计值,不随业务日期筛选变化) |
| GET | `/api/v1/reports/payment-stats` | 回款情况统计表(FR-031:8 档天数区间恒在,空桶为 0) |
| GET | `/api/v1/reports/payment-detail` | 回款区间下钻(`bucket` 必填,未注册编码 400) |
| GET | `/api/v1/reports/payment-day-buckets` | 区间档位元数据(前端下拉唯一来源,禁止前端硬编码) |
| GET | `/api/v1/reports/{code}/export` | Excel 导出(文件流非统一信封;含公司页眉/财务复核栏/领导签字栏;超 2 万行 400;行为入操作日志) |
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
### 工作台接口(M4 ⑧,限管理员/财务/领导)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/dashboard?month=YYYY-MM` | 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志) |
待办与预警**实时派生**(不落表、不引调度器);预警阈值走参数体系
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
## 常用命令
```bash
@@ -196,6 +221,32 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
### 工作台口径(M4 ⑧,改动前先读)
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定,**已终止业务(status=3)排除**;回款未指定批次 = `receipts.shipment_id IS NULL`。
- **待办按角色过滤**:`pending_statuses_for_roles`(approval_service)把当前用户角色映射到在途节点——财务只看待财务复核(status=1)、领导只看待领导审核(status=2)、admin 可看全部;多角色取并集。风险预警仍是全局经营视角,不按人裁剪。
- **预警展示上限**:`items` 最多 20 条,`total` 是全部命中数(前端徽标用它,不能用 `items.length`)。
- **趋势窗口**:回款趋势先按 `receipt_date` 范围过滤再按 `DATE_FORMAT` 分组,函数列不参与 WHERE,保证索引可用。
- **同步降级**:⑥ 未建时 `sync_available=false`,前端显示「模块未启用」——不要返回 0 条冒充「无失败」。
- **参数快照一次解析**:`warning.*` 三个阈值在每次请求只 `resolve_snapshot` 一次,多条规则复用。
- **导出 SQL 的编码坑**:Windows 下 `alembic upgrade --sql > V*.sql` 默认按控制台 GBK 输出,`test_schema_sync` 按 UTF-8 读取会报 `UnicodeDecodeError`——必须 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1` 前缀。
### 报表口径(M4 ⑤,改动前先读)
- **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。
- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入,**不要求该人当月有台账行**(只有调整单也要出零绩效成员行);`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。调整单归属跟随该人普通行的实时 `manager_id`;只有经理个人户的经理跟随本人团队。
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块)。
- **全公司 5% 的发放口径**:月结只为有台账 bucket 的营销经理各生成一条聚合行(12.5%+5%),**每位经理各计一次 5%**;零成员团队无聚合行、不发 5%。报表的 `aggregate_amount` 优先取冻结聚合行、未生成月结时才用 `manager_team_share` 一次舍入预览;`team_subtotal`/`net_total` = 聚合额 + 调整净额。`team_share_total` 与 `company_share_total` 仅分列展示,两者之和与聚合额的差记入 `aggregate_rounding_diff`(分列各自 q2 会有 0.01~0.02 尾差)。
- **对拍边界**:报表应发 = 月结 `total_payable` + 调整单净额(调整单不进月结 items);筛选 `manager_id` 时只汇总展示团队,隐藏金额进 `other_*`,不能与全公司月结总额对拍。
- **人员归属按行类型**:普通台账行跟随 `manager_id`;经理个人户行在 `manager.personal.included=1` 时归经理本人团队,且经理成员行**只取个人户金额**(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 `manager_id` 筛选时 `unassigned` 为空(语义是「仅看该经理团队」)。
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);第二块展示单经理 5%、计入次数与合计,对账块展示团队分成/全公司分成(分列)、聚合行舍入差、调整净额,应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
@@ -0,0 +1,143 @@
"""v10 工作台预警规则参数
Revision ID: 90653814a421
Revises: ba0217f5091b
Create Date: 2026-10-19 17:55:48.892998
新增风险预警三条参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」):
- warning.enabled:总开关(默认 1)
- warning.receipt.overdue.days:发货后逾期天数(默认 90)
- warning.approval.timeout.days:审批超时天数(默认 3)
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
"""
from collections.abc import Sequence
from datetime import date
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '90653814a421'
down_revision: str | Sequence[str] | None = 'ba0217f5091b'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_EFFECTIVE_DATE = date(2026, 10, 19)
def upgrade() -> None:
"""种入三个预警参数定义与生效值(append-only 时点化)。"""
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 21,
"param_code": "warning.enabled",
"param_name": "风险预警总开关",
"param_group": "预警规则",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "1",
"value_min": None,
"value_max": None,
"description": "工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
"sort_order": 21,
"status": 1,
},
{
"id": 22,
"param_code": "warning.receipt.overdue.days",
"param_name": "回款逾期天数阈值",
"param_group": "预警规则",
"value_type": "INT",
"unit": "天",
"dimension_type": "NONE",
"options_text": None,
"default_value": "90",
"value_min": "1",
"value_max": None,
"description": "发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)",
"sort_order": 22,
"status": 1,
},
{
"id": 23,
"param_code": "warning.approval.timeout.days",
"param_name": "审批超时天数阈值",
"param_group": "预警规则",
"value_type": "INT",
"unit": "天",
"dimension_type": "NONE",
"options_text": None,
"default_value": "3",
"value_min": "1",
"value_max": None,
"description": "待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)",
"sort_order": 23,
"status": 1,
},
],
)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 26,
"param_id": 21,
"dimension_value": "",
"effective_date": _EFFECTIVE_DATE,
"value_text": "1",
"batch_id": 1,
},
{
"id": 27,
"param_id": 22,
"dimension_value": "",
"effective_date": _EFFECTIVE_DATE,
"value_text": "90",
"batch_id": 1,
},
{
"id": 28,
"param_id": 23,
"dimension_value": "",
"effective_date": _EFFECTIVE_DATE,
"value_text": "3",
"batch_id": 1,
},
],
)
def downgrade() -> None:
"""回滚种子行(行级种子不会被 drop_table 带走,必须显式删除)。"""
op.execute("DELETE FROM param_values WHERE id IN (26, 27, 28)")
op.execute("DELETE FROM param_definitions WHERE id IN (21, 22, 23)")
+5 -2
View File
@@ -119,20 +119,23 @@ AdminUser = Annotated[User, Depends(require_admin)]
LoginUser = Annotated[User, Depends(get_current_user)]
def require_roles(*role_codes: str):
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
"""要求当前用户具备任一指定角色(依赖工厂)。
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
避免既有调用与测试变动)。
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
"""
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
codes = set(user_repository.get_role_codes(current_user))
if not codes & set(role_codes):
raise ForbiddenError("无权访问审批管理")
raise ForbiddenError(message)
return current_user
return _checker
+8
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter
from app.api import health
from app.api.v1 import auth
from app.api.v1.base import business_roles as base_business_roles
from app.api.v1.base import customers as base_customers
from app.api.v1.base import personnel as base_personnel
from app.api.v1.base import products as base_products
@@ -16,6 +17,8 @@ from app.api.v1.business import ratings as business_ratings
from app.api.v1.business import receipts as business_receipts
from app.api.v1.business import shipments as business_shipments
from app.api.v1.calc import router as calc_router
from app.api.v1.dashboard import router as dashboard_router
from app.api.v1.reports import router as reports_router
from app.api.v1.settlements import router as settlements_router
from app.api.v1.system import approvals as system_approvals
from app.api.v1.system import business_roles as system_business_roles
@@ -51,6 +54,7 @@ base_router = APIRouter(prefix="/base")
base_router.include_router(base_personnel.router)
base_router.include_router(base_products.router)
base_router.include_router(base_customers.router)
base_router.include_router(base_business_roles.router)
v1_router.include_router(base_router)
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
@@ -65,5 +69,9 @@ v1_router.include_router(business_router)
v1_router.include_router(calc_router)
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(settlements_router)
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
v1_router.include_router(reports_router)
# 工作台(M4 ⑧):/api/v1/dashboard,四区块只读聚合,限管理员/财务/领导
v1_router.include_router(dashboard_router)
api_router.include_router(v1_router)
+40
View File
@@ -0,0 +1,40 @@
"""岗位下拉选项(基础数据与报表筛选共用)。
岗位管理本身仅管理员可用(FR-006),但报表的「岗位」筛选对财务/领导
开放(FR-029)。为避免把管理接口整体放开,这里提供只读、登录即可的
启用岗位选项;写操作仍走 `/system/business-roles` 的管理员端点。
"""
from typing import Annotated
from fastapi import APIRouter, Depends
from app.api.deps import DbSession, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories import business_role_repository
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
# 基础数据是业务操作:登录即可,不要求管理员
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("/options", response_model=ApiResponse[list[dict]], summary="岗位下拉选项")
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[dict]]:
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
岗位规模有限(内置 + 自定义),一次取足够上限,不做分页。
"""
roles = business_role_repository.list_roles(session, offset=0, limit=1000)
return ok(
[
{
"id": role.id,
"role_code": role.role_code,
"role_name": role.role_name,
"status": role.status,
}
for role in roles
]
)
+44
View File
@@ -0,0 +1,44 @@
"""工作台接口(M4 ⑧):四区块一次性聚合。
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.api.deps import DbSession, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.dashboard import DashboardOut
from app.services import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["工作台"])
_DashboardUser = Annotated[
User,
Depends(
require_roles(
ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台"
)
),
]
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
def get_dashboard(
session: DbSession,
current_user: _DashboardUser,
month: Annotated[
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None,
) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
"""
return ok(
dashboard_service.get_dashboard(session, month, current_user=current_user)
)
+191
View File
@@ -0,0 +1,191 @@
"""报表统计接口(M4 ⑤):四张报表只读查询 + Excel 导出。
准入:管理员/财务/领导(FR-030/031 明确;FR-029 的营销后勤「授权范围内」
待权限清单 #6,暂不开放)。前后端双层:前端 allowedRoles 控菜单,本层
require_roles 兜底。
导出返回文件流(非统一信封),沿用参数导出先例:服务端文件名为 ASCII
(HTTP 头按 latin-1 编码,中文名会 UnicodeEncodeError),中文名由前端
``link.download`` 指定。
"""
from datetime import date
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ValidationError
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.exceptions import BadRequestError
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.report import (
BusinessStatsQuery,
BusinessStatsRow,
DepartmentSummaryOut,
DepartmentSummaryQuery,
PaymentDetailQuery,
PaymentDetailRow,
PaymentStatsOut,
PaymentStatsQuery,
PersonalDetailQuery,
PersonalDetailRow,
ReportPage,
)
from app.services import report_service
router = APIRouter(prefix="/reports", tags=["绩效工资 · 报表统计"])
_ReportViewer = Annotated[
User,
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计")),
]
@router.get(
"/personal-detail",
response_model=ApiResponse[ReportPage[PersonalDetailRow]],
summary="个人绩效工资明细表",
)
def personal_detail(
session: DbSession,
_: _ReportViewer,
query: Annotated[PersonalDetailQuery, Query()],
) -> ApiResponse[ReportPage[PersonalDetailRow]]:
"""按人员/月份/客户类型/产品类别/岗位查明细(口径与台账一致)。"""
return ok(report_service.list_personal_detail(session, query))
@router.get(
"/department-summary",
response_model=ApiResponse[DepartmentSummaryOut],
summary="部门绩效工资汇总表",
)
def department_summary(
session: DbSession,
_: _ReportViewer,
query: Annotated[DepartmentSummaryQuery, Query()],
) -> ApiResponse[DepartmentSummaryOut]:
"""按经理团队/月份汇总(团队基数与月结聚合行同源)。"""
return ok(report_service.department_summary(session, query))
@router.get(
"/business-stats",
response_model=ApiResponse[ReportPage[BusinessStatsRow]],
summary="业务统计表",
)
def business_stats(
session: DbSession,
_: _ReportViewer,
query: Annotated[BusinessStatsQuery, Query()],
) -> ApiResponse[ReportPage[BusinessStatsRow]]:
"""按业务查询发货、回款、绩效(回款/绩效为业务累计值)。"""
return ok(report_service.business_stats(session, query))
@router.get(
"/payment-stats",
response_model=ApiResponse[PaymentStatsOut],
summary="回款情况统计表",
)
def payment_stats(
session: DbSession,
_: _ReportViewer,
query: Annotated[PaymentStatsQuery, Query()],
) -> ApiResponse[PaymentStatsOut]:
"""按回款天数区间/客户类型/产品类别统计(空桶恒在且为 0)。"""
return ok(report_service.payment_stats(session, query))
@router.get(
"/payment-detail",
response_model=ApiResponse[ReportPage[PaymentDetailRow]],
summary="回款区间下钻",
)
def payment_detail(
session: DbSession,
_: _ReportViewer,
query: Annotated[PaymentDetailQuery, Query()],
) -> ApiResponse[ReportPage[PaymentDetailRow]]:
"""区间桶下钻到单笔台账行(含回款金额与批次号)。"""
return ok(report_service.payment_detail(session, query))
@router.get(
"/payment-day-buckets", response_model=ApiResponse[list[dict]], summary="回款天数区间档位"
)
def payment_day_buckets(_: _ReportViewer) -> ApiResponse[list[dict]]:
"""区间档位元数据(前端渲染下拉/表头,禁止前端硬编码档位)。"""
return ok(report_service.get_payment_day_buckets())
class ExportQuery(BaseModel):
"""导出通用筛选参数(按报表编码取用相关字段,未用字段忽略)。"""
settlement_month: str | None = None
personnel_id: int | None = None
customer_type: str | None = None
product_category: str | None = None
role_code: str | None = None
manager_id: int | None = None
keyword: str | None = None
customer_id: int | None = None
owner_personnel_id: int | None = None
status: int | None = None
date_from: date | None = None
date_to: date | None = None
@router.get("/{code}/export", summary="导出报表 Excel")
def export_report(
code: Annotated[
Literal["personal-detail", "department-summary", "business-stats", "payment-stats"],
Path(description="报表编码"),
],
session: DbSession,
current_user: _ReportViewer,
meta: RequestMetaDep,
query: Annotated[ExportQuery, Query()],
) -> StreamingResponse:
"""导出 xlsx(含公司页眉/财务复核栏/领导签字栏),行为入操作日志。"""
content, filename = report_service.export_report(
session,
code,
_build_export_query(code, query),
operator=current_user,
meta=meta,
)
return StreamingResponse(
iter([content]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": (f'attachment; filename="{filename}_{date.today():%Y%m%d}.xlsx"')
},
)
def _build_export_query(code: str, query: ExportQuery):
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。
手工构造模型抛出的是 `pydantic.ValidationError`,不是 FastAPI 的
`RequestValidationError`——不在这里转换就会落到全局 500。导出参数
非法与查询接口一样应当是 400。
"""
data = query.model_dump()
model = {
"personal-detail": PersonalDetailQuery,
"department-summary": DepartmentSummaryQuery,
"business-stats": BusinessStatsQuery,
"payment-stats": PaymentStatsQuery,
}[code]
try:
return model(**data)
except ValidationError as exc:
first = exc.errors()[0]
location = ".".join(str(part) for part in first.get("loc", ()))
message = first.get("msg", "参数错误")
detail = f"{location} {message}" if location else message
raise BadRequestError(f"参数错误:{detail}") from exc
+26
View File
@@ -0,0 +1,26 @@
"""月份参数校验(YYYY-MM)——叶子模块。
报表、月结与调整单共用同一套校验:只校验长度会放过 `2026-13` 这类
非法月份,一路传到 `calendar.monthrange` 才抛 ValueError,最终被全局
处理器当成服务端异常(500)。月份范围必须在入口处拦住,才能返回 400。
"""
import re
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
def validate_month(value: str) -> str:
"""校验 YYYY-MM 且月份 01-12;非法抛 ValueError(Pydantic 友好)。
年份 0000 也拒绝:`date(0, ...)` 同样会抛 ValueError,且业务上不存在。
"""
if not _MONTH_RE.match(value) or int(value[:4]) == 0:
raise ValueError("月份格式须为 YYYY-MM(01-12)")
return value
def parse_month(value: str) -> tuple[int, int]:
"""校验并返回 (year, month),供服务层计算月末等使用。"""
validate_month(value)
return int(value[:4]), int(value[5:7])
@@ -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
@@ -0,0 +1,237 @@
"""工作台仓储:四区块的只读聚合查询;不 commit、不抛业务异常。
总览只做 SQL 聚合(SUM/COUNT DISTINCT),不复用报表的
``department_summary``——后者会加载整月台账并逐团队查人员,适合报表页,
不适合每次打开工作台。
"""
from datetime import date, datetime
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session
from app.models import (
ApprovalOrder,
Business,
BusinessRole,
MonthlySettlement,
PerformanceLedger,
Personnel,
Receipt,
Shipment,
)
from app.models.approval import (
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
)
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
_PENDING_STATUSES = (
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
)
# ==== 经营绩效总览 ====
def sum_month_performance(session: Session, month: str) -> dict:
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
return session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
).where(
PerformanceLedger.settlement_month == month,
*_ACTIVE_SINGLE,
)
).mappings().one()
def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
"""近 N 个月绩效合计(按台账归属月)。"""
rows = session.execute(
select(
PerformanceLedger.settlement_month,
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0),
)
.where(
PerformanceLedger.settlement_month.in_(months),
*_ACTIVE_SINGLE,
)
.group_by(PerformanceLedger.settlement_month)
).all()
return dict(rows)
def receipt_trend(
session: Session, *, start_date: date, end_date: date
) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
分组,不参与 WHERE,避免函数列导致全表扫描。
"""
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
rows = session.execute(
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
.where(Receipt.receipt_date >= start_date, Receipt.receipt_date < end_date)
.group_by(month_expr)
).all()
return dict(rows)
def count_active_managers(session: Session) -> int:
"""在职营销经理数(团队数口径,与部门汇总的团队枚举一致)。"""
return int(
session.execute(
select(func.count())
.select_from(Personnel)
.join(BusinessRole, Personnel.business_role_id == BusinessRole.id)
.where(
BusinessRole.role_code == "sales_manager",
Personnel.status == 1,
)
).scalar_one()
)
def get_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
"""当月月结头(状态与锁定时间角标)。"""
return session.execute(
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
).scalar_one_or_none()
# ==== 待办事项 ====
def list_pending_approvals(
session: Session, *, statuses: tuple[int, ...], limit: int
) -> list[ApprovalOrder]:
"""待处理审批单(按提交时间升序——最久的排前面)。
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
"""
if not statuses:
return []
return list(
session.execute(
select(ApprovalOrder)
.where(ApprovalOrder.status.in_(statuses))
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
.limit(limit)
).scalars()
)
def count_pending_approvals(session: Session, *, statuses: tuple[int, ...]) -> int:
if not statuses:
return 0
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(ApprovalOrder.status.in_(statuses))
).scalar_one()
)
def list_unassigned_receipts(session: Session, *, limit: int) -> list:
"""回款未指定发货批次(shipment_id IS NULL,核算前置缺失)。"""
return list(
session.execute(
select(
Receipt,
Business.business_no.label("business_no"),
)
.outerjoin(Business, Business.id == Receipt.business_id)
.where(Receipt.shipment_id.is_(None))
.order_by(Receipt.receipt_date.asc(), Receipt.id.asc())
.limit(limit)
).all()
)
def count_unassigned_receipts(session: Session) -> int:
return int(
session.execute(
select(func.count())
.select_from(Receipt)
.where(Receipt.shipment_id.is_(None))
).scalar_one()
)
# ==== 风险预警 ====
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
已终止业务(status=3)不再催收,排除在预警之外;业务行缺失时仍保留,
避免历史脏数据让发货单从预警中静默消失。
"""
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
return list(
session.execute(
select(
Shipment,
Business.business_no.label("business_no"),
)
.outerjoin(Business, Business.id == Shipment.business_id)
.where(
Shipment.ship_date <= cutoff,
~has_receipt,
or_(Business.status.is_(None), Business.status != 3),
)
.order_by(Shipment.ship_date.asc())
.limit(limit)
).all()
)
def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
return int(
session.execute(
select(func.count())
.select_from(Shipment)
.outerjoin(Business, Business.id == Shipment.business_id)
.where(
Shipment.ship_date <= cutoff,
~has_receipt,
or_(Business.status.is_(None), Business.status != 3),
)
).scalar_one()
)
def list_overdue_approvals(
session: Session, *, cutoff: datetime, limit: int
) -> list[ApprovalOrder]:
"""提交后超过阈值仍未处理的审批单(审批超时规则)。"""
return list(
session.execute(
select(ApprovalOrder)
.where(
ApprovalOrder.status.in_(_PENDING_STATUSES),
ApprovalOrder.create_time < cutoff,
)
.order_by(ApprovalOrder.create_time.asc())
.limit(limit)
).scalars()
)
def count_overdue_approvals(session: Session, *, cutoff: datetime) -> int:
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(
ApprovalOrder.status.in_(_PENDING_STATUSES),
ApprovalOrder.create_time < cutoff,
)
).scalar_one()
)
+70 -39
View File
@@ -15,6 +15,7 @@
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.report_meta import DayBucket
from app.models import (
AdjustmentOrder,
Business,
@@ -36,6 +37,7 @@ _ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active
# ==== 个人绩效明细(FR-029)====
def _personal_filters(query: PersonalDetailQuery) -> tuple:
filters = [*_ACTIVE_SINGLE]
if query.settlement_month:
@@ -65,29 +67,34 @@ def count_personal_detail(session: Session, query: PersonalDetailQuery) -> int:
"""个人绩效明细计数(与 list_personal_detail 同筛选)。"""
return int(
session.execute(
select(func.count())
.select_from(PerformanceLedger)
.where(*_personal_filters(query))
select(func.count()).select_from(PerformanceLedger).where(*_personal_filters(query))
).scalar_one()
)
def sum_personal_detail(session: Session, query: PersonalDetailQuery) -> dict:
"""全筛选口径合计(分页与合计同条件,避免「页脚合计≠全部结果」)。"""
return session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("performance_total"),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label(
"converted_profit_total"
),
).where(*_personal_filters(query))
).mappings().one()
return (
session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label(
"converted_profit_total"
),
).where(*_personal_filters(query))
)
.mappings()
.one()
)
# ==== 部门汇总取数(FR-030)====
def list_month_receipt_rows(session: Session, month: str) -> list[PerformanceLedger]:
"""同月全量生效单笔台账行(部门汇总基数与成员明细的取数)。"""
return list(
@@ -137,6 +144,7 @@ def list_month_settlement(session: Session, month: str) -> MonthlySettlement | N
# ==== 业务统计(FR-031 之一)====
def _business_perf_subquery():
"""业务维度绩效子查询(排除聚合行,防 12.5%/5% 聚合额污染单业务绩效)。"""
return (
@@ -212,23 +220,28 @@ def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
perf_sq = _business_perf_subquery()
return session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"),
func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"),
func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"),
func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"),
func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"),
return (
session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"),
func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"),
func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"),
func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"),
func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"),
)
.outerjoin(Customer, Customer.id == Business.customer_id)
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
.where(*_business_filters(query))
)
.outerjoin(Customer, Customer.id == Business.customer_id)
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
.where(*_business_filters(query))
).mappings().one()
.mappings()
.one()
)
# ==== 回款统计(FR-031 之二)====
def _payment_filters(query: PaymentStatsQuery | PaymentDetailQuery) -> tuple:
filters = [*_ACTIVE_SINGLE]
if query.settlement_month:
@@ -255,9 +268,7 @@ def group_payment_by_days(session: Session, query: PaymentStatsQuery) -> list:
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label(
"payable_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count().label("ledger_count"),
)
.where(*_payment_filters(query))
@@ -326,7 +337,9 @@ def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column)
dim_perf = (
select(
dim_column.label("dim"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("performance_total"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count().label("ledger_count"),
)
@@ -342,10 +355,7 @@ def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column)
.distinct()
.subquery()
)
perf_rows = {
row.dim: row
for row in session.execute(dim_perf).all()
}
perf_rows = {row.dim: row for row in session.execute(dim_perf).all()}
receipt_rows = session.execute(
select(
dim_receipts.c.dim,
@@ -356,15 +366,36 @@ def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column)
).all()
receipt_amounts = {row.dim: row.receipt_amount for row in receipt_rows}
return [
(row.dim, receipt_amounts.get(row.dim), row.performance_total, row.payable_total, row.ledger_count)
(
row.dim,
receipt_amounts.get(row.dim),
row.performance_total,
row.payable_total,
row.ledger_count,
)
for row in perf_rows.values()
]
def _bucket_condition(bucket: DayBucket) -> tuple:
"""区间桶的 days 过滤条件:none 桶 IS NULL,其余按 min/max 闭区间。"""
if bucket.code == "none":
return (PerformanceLedger.payment_days.is_(None),)
conds = []
if bucket.min_days is not None:
conds.append(PerformanceLedger.payment_days >= bucket.min_days)
if bucket.max_days is not None:
conds.append(PerformanceLedger.payment_days <= bucket.max_days)
return tuple(conds)
def list_payment_detail(
session: Session, query: PaymentDetailQuery, *, offset: int, limit: int
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
) -> list:
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。"""
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
"""
from app.models import Receipt
stmt = (
@@ -374,19 +405,19 @@ def list_payment_detail(
Receipt.receipt_batch_no.label("receipt_batch_no"),
)
.outerjoin(Receipt, Receipt.id == PerformanceLedger.receipt_id)
.where(*_payment_filters(query))
.where(*_payment_filters(query), *_bucket_condition(bucket))
)
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_payment_detail(session: Session, query: PaymentDetailQuery) -> int:
def count_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> int:
"""区间下钻计数(与 list_payment_detail 同筛选)。"""
return int(
session.execute(
select(func.count())
.select_from(PerformanceLedger)
.where(*_payment_filters(query))
.where(*_payment_filters(query), *_bucket_condition(bucket))
).scalar_one()
)
+126
View File
@@ -0,0 +1,126 @@
"""工作台的请求/响应模型(M4 ⑧)。
四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返):
经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal
(序列化为字符串),前端不做数值运算。
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
不返回伪造的 0 条。
"""
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, Field
class TrendPoint(BaseModel):
"""近 6 个月趋势点(绩效按台账归属月,回款按到账月)。"""
month: str
performance_total: Decimal | None = None
receipt_total: Decimal | None = None
class SettlementBrief(BaseModel):
"""当月月结状态(工作台总览角标)。"""
exists: bool = False
status: int | None = Field(default=None, description="1草稿 2待审批 3已审定 4已驳回")
total_performance: Decimal | None = None
total_payable: Decimal | None = None
locked_at: datetime | None = None
class OverviewBlock(BaseModel):
"""经营绩效总览(FR-033):口径与台账/月结一致。"""
month: str
performance_total: Decimal | None = Field(default=None, description="当月单笔绩效合计")
payable_total: Decimal | None = Field(default=None, description="当月应付绩效合计")
person_count: int = Field(default=0, description="当月有台账的人员数(拆行去重)")
team_count: int = Field(default=0, description="在职营销经理数(团队数)")
settlement: SettlementBrief
trend: list[TrendPoint] = Field(default_factory=list, description="近 6 个月")
note: str = Field(default="", description="口径说明(页面展示)")
class ApprovalTodoItem(BaseModel):
"""待审批条目。"""
order_id: int
order_no: str
order_type: int
submitter_name: str | None = None
create_time: datetime
class ReceiptTodoItem(BaseModel):
"""回款未指定发货批次条目。"""
receipt_id: int
receipt_batch_no: str
business_id: int
business_no: str | None = None
amount: Decimal
receipt_date: date
class TodoBlock(BaseModel):
"""待办事项(FR-034,澄清 #11 起步范围)。"""
pending_approval_total: int = 0
pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list)
unassigned_receipt_total: int = 0
unassigned_receipts: list[ReceiptTodoItem] = Field(default_factory=list)
sync_available: bool = Field(default=False, description="⑥ 同步模块是否已启用")
sync_failure_total: int = 0
class WarningItem(BaseModel):
"""风险预警条目(实时派生,不改变业务数据)。"""
code: str = Field(description="规则编码:receipt_overdue / approval_timeout")
level: str = Field(description="级别:warning / danger")
object_type: str
object_id: int
title: str
detail: str
suggest: str
link: str | None = Field(default=None, description="前端跳转路径")
class WarningBlock(BaseModel):
"""风险预警(FR-035,首批两条可算规则;同步规则待 ⑥)。"""
enabled: bool = True
receipt_overdue_days: int = 90
approval_timeout_days: int = 3
items: list[WarningItem] = Field(default_factory=list)
total: int = Field(default=0, description="全部规则命中总数(items 仅为展示上限内的子集)")
receipt_overdue_total: int = 0
approval_timeout_total: int = 0
sync_available: bool = False
class ActivityItem(BaseModel):
"""最近动态条目(来源 operation_logs,只读聚合)。"""
id: int
module: str
action: str
summary: str
username: str | None = None
success: int
create_time: datetime
class DashboardOut(BaseModel):
"""工作台四区块一次性响应。"""
month: str
overview: OverviewBlock
todos: TodoBlock
warnings: WarningBlock
activities: list[ActivityItem] = Field(default_factory=list)
+115 -6
View File
@@ -7,12 +7,27 @@
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)
# ==== 查询入参 ====
class PersonalDetailQuery(PageQuery):
"""个人绩效明细查询条件(FR-029:人员/月份/客户类型/产品类别/岗位)。"""
@@ -22,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):
"""部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。"""
@@ -29,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):
"""业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。"""
@@ -50,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 页面展开行携带)。"""
@@ -61,16 +91,29 @@ 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)
# ==== 响应出参 ====
class ReportSummary(BaseModel):
"""全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。"""
"""全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。
字段按报表取用:个人明细用绩效/应付/折算毛利;业务统计用发货/回款/
销售额;不涉及的留 None。
"""
row_count: int = Field(default=0, description="筛选结果行数")
performance_total: Decimal | None = Field(default=None, description="绩效金额合计")
payable_total: Decimal | None = Field(default=None, description="应付绩效合计")
converted_profit_total: Decimal | None = Field(default=None, description="折算毛利润合计")
ship_total: Decimal | None = Field(default=None, description="发货额合计")
received_total: Decimal | None = Field(default=None, description="回款额合计")
sale_total: Decimal | None = Field(default=None, description="销售额合计")
class ReportPage[T](BaseModel):
@@ -134,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="调整单生效补发合计")
@@ -151,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)
@@ -169,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):
@@ -182,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)
+14
View File
@@ -19,6 +19,7 @@
"""
import logging
from collections.abc import Iterable
from datetime import date
from sqlalchemy import func, select
@@ -29,6 +30,7 @@ from app.core.constants import (
ACTION_REJECT,
MODULE_APPROVAL,
NO_PREFIX_APPROVAL,
ROLE_ADMIN,
ROLE_FINANCE,
ROLE_LEADER,
)
@@ -84,6 +86,18 @@ _NODE_ROLE_BY_STATUS = {
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
"""工作台「待我审批」:按当前用户角色映射在途审批状态。
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
的可见性一致);财务/领导只看到本节点可操作的条目。
"""
codes = set(role_codes)
if ROLE_ADMIN in codes:
return _PENDING_STATUSES
return tuple(status for status, role in _NODE_ROLE_BY_STATUS.items() if role in codes)
def is_enabled(session: Session, order_type: int) -> bool:
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
code = ENABLE_CODE_BY_TYPE.get(order_type)
+278
View File
@@ -0,0 +1,278 @@
"""工作台服务(M4 ⑧):四区块实时聚合编排。
设计决策(2026-10-19):
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
不复用报表的部门汇总(后者会加载整月台账,过重)
- **待办与预警实时派生**:不建待办/预警表、不引调度器;处置动作本身
已有操作日志可追溯。与 FR-035「预警产生/消除留痕」的差距记录在
计划开放项,待业务确认是否物化
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
``sync_available=false`` 显式降级,不返回伪造的 0 条
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
"""
from datetime import date, datetime, timedelta
from decimal import Decimal
from sqlalchemy.orm import Session
from app.core.exceptions import BadRequestError
from app.core.month import validate_month
from app.repositories import dashboard_repository, operation_log_repository, user_repository
from app.repositories.query_utils import LogFilters
from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5
_WARNING_LIMIT = 20
_ACTIVITY_LIMIT = 20
_TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。
"""
resolved = _normalize_month(month)
warning_params = _warning_params(session)
pending_statuses = approval_service.pending_statuses_for_roles(
user_repository.get_role_codes(current_user)
)
return {
"month": resolved,
"overview": _overview(session, resolved),
"todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params),
"activities": _activities(session),
}
def _normalize_month(month: str | None) -> str:
"""未传月份按当月;非法月份在入口拦成 400(避免 500)。"""
if not month:
today = date.today()
return f"{today.year:04d}-{today.month:02d}"
try:
return validate_month(month)
except ValueError as exc:
raise BadRequestError(str(exc)) from exc
def _shift_month(month: str, delta: int) -> str:
"""YYYY-MM 平移 delta 个月(用于近 N 个月窗口)。"""
year, mon = int(month[:4]), int(month[5:7])
index = year * 12 + (mon - 1) + delta
return f"{index // 12:04d}-{index % 12 + 1:02d}"
def _month_start(month: str) -> date:
"""YYYY-MM → 该月首日(范围查询边界,避免函数列过滤)。"""
return date(int(month[:4]), int(month[5:7]), 1)
def _trend_months(month: str) -> list[str]:
"""窗口为「当月及之前共 6 个月」,升序返回便于前端画图。"""
return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)]
def _overview(session: Session, month: str) -> dict:
"""经营绩效总览(FR-033):口径与台账/月结一致。"""
sums = dashboard_repository.sum_month_performance(session, month)
settlement = dashboard_repository.get_month_settlement(session, month)
months = _trend_months(month)
performance = dashboard_repository.performance_trend(session, months)
receipts = dashboard_repository.receipt_trend(
session,
start_date=_month_start(months[0]),
end_date=_month_start(_shift_month(months[-1], 1)),
)
return {
"month": month,
"performance_total": _dec(sums["performance_total"]),
"payable_total": _dec(sums["payable_total"]),
"person_count": int(sums["person_count"]),
"team_count": dashboard_repository.count_active_managers(session),
"settlement": {
"exists": settlement is not None,
"status": settlement.status if settlement else None,
"total_performance": settlement.total_performance if settlement else None,
"total_payable": settlement.total_payable if settlement else None,
"locked_at": settlement.locked_at if settlement else None,
},
"trend": [
{
"month": item,
"performance_total": _dec(performance.get(item)),
"receipt_total": _dec(receipts.get(item)),
}
for item in months
],
"note": (
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
),
}
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
领导只看待领导审核;admin 查看全部在途单。
"""
orders = dashboard_repository.list_pending_approvals(
session, statuses=pending_statuses, limit=_TODO_LIMIT
)
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
return {
"pending_approval_total": dashboard_repository.count_pending_approvals(
session, statuses=pending_statuses
),
"pending_approvals": [
{
"order_id": order.id,
"order_no": order.order_no,
"order_type": order.order_type,
"submitter_name": order.submitter_name,
"create_time": order.create_time,
}
for order in orders
],
"unassigned_receipt_total": dashboard_repository.count_unassigned_receipts(session),
"unassigned_receipts": [
{
"receipt_id": row[0].id,
"receipt_batch_no": row[0].receipt_batch_no,
"business_id": row[0].business_id,
"business_no": row.business_no,
"amount": row[0].amount,
"receipt_date": row[0].receipt_date,
}
for row in receipts
],
# ⑥ T+ 同步未建:显式降级,不伪造「无失败」
"sync_available": False,
"sync_failure_total": 0,
}
def _warning_params(session: Session) -> dict:
"""预警参数一次解析(阈值参数化,澄清 #10)。"""
from app.services.param_service import parse_fraction, resolve_snapshot
snapshot = resolve_snapshot(session, date.today())
return {
"enabled": snapshot.get("warning.enabled") == "1",
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
"approval_timeout_days": int(
parse_fraction(snapshot.get("warning.approval.timeout.days"))
),
}
def _warnings(session: Session, params: dict) -> dict:
"""风险预警(FR-035):首批两条可算规则;同步规则待 ⑥。"""
if not params["enabled"]:
return {
"enabled": False,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": [],
"total": 0,
"receipt_overdue_total": 0,
"approval_timeout_total": 0,
"sync_available": False,
}
receipt_cutoff = date.today() - timedelta(days=params["receipt_overdue_days"])
approval_cutoff = datetime.now() - timedelta(days=params["approval_timeout_days"])
shipments = dashboard_repository.list_overdue_shipments(
session, cutoff=receipt_cutoff, limit=_WARNING_LIMIT
)
orders = dashboard_repository.list_overdue_approvals(
session, cutoff=approval_cutoff, limit=_WARNING_LIMIT
)
items = [
{
"code": "receipt_overdue",
"level": "danger",
"object_type": "shipment",
"object_id": row[0].id,
"title": f"发货批次 {row[0].shipment_no} 逾期未回款",
"detail": (
f"业务 {row.business_no or row[0].business_id} 发货日 {row[0].ship_date},"
f"超过 {params['receipt_overdue_days']} 天无回款记录"
),
"suggest": "联系客户催收,或核对回款批次指定关系",
"link": "/business/shipments",
}
for row in shipments
]
items += [
{
"code": "approval_timeout",
"level": "warning",
"object_type": "approval_order",
"object_id": order.id,
"title": f"审批单 {order.order_no} 超时未处理",
"detail": (
f"{_ORDER_TYPE_LABELS.get(order.order_type, '审批')}由 "
f"{order.submitter_name or '—'} 提交于 {order.create_time:%Y-%m-%d %H:%M},"
f"已超过 {params['approval_timeout_days']} 天"
),
"suggest": "在审批管理中复核或退回",
"link": "/system/approvals",
}
for order in orders
]
receipt_overdue_total = dashboard_repository.count_overdue_shipments(
session, cutoff=receipt_cutoff
)
approval_timeout_total = dashboard_repository.count_overdue_approvals(
session, cutoff=approval_cutoff
)
return {
"enabled": True,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": items,
# items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数
"total": receipt_overdue_total + approval_timeout_total,
"receipt_overdue_total": receipt_overdue_total,
"approval_timeout_total": approval_timeout_total,
"sync_available": False,
}
def _activities(session: Session) -> list[dict]:
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
logs = operation_log_repository.list_logs(
session,
offset=0,
limit=_ACTIVITY_LIMIT,
filters=LogFilters(),
)
return [
{
"id": log.id,
"module": log.module,
"action": log.action,
"summary": log.summary,
"username": log.username,
"success": log.success,
"create_time": log.create_time,
}
for log in logs
]
def _dec(value) -> Decimal | None:
"""SQL 聚合结果转 Decimal(SUM 可能返回 int,None 原样返回)。"""
if value is None:
return None
return value if isinstance(value, Decimal) else Decimal(str(value))
+124
View File
@@ -0,0 +1,124 @@
"""经理分成共享口径(月结聚合与报表部门汇总的唯一实现)。
从 settlement_service 迁出(行为零变化):经理 12.5%/5% 的取参、团队
分桶、公司基数计算,月结生成与部门汇总报表都要用——抽为单一实现后
「报表汇总 == 月结聚合行」不靠两处代码各自对齐,而是同源函数的直接
结果。分成金额仍走 formula.manager_team_share 纯函数。
"""
from calendar import monthrange
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from fractions import Fraction
from sqlalchemy.orm import Session
from app.core.exceptions import BadRequestError
from app.core.month import parse_month
from app.models import Personnel
@dataclass(frozen=True, slots=True)
class ManagerShareParams:
"""经理分成取参与参数原文(同一次计算共用一份,随月月末时点化)。"""
month: str
param_date: date
team_text: str
company_text: str
team_pct: Fraction
company_pct: Fraction
include_manager_personal: bool
def month_end(month: str) -> date:
"""YYYY-MM → 所属月月末(月结与报表取参锚点)。
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
更细的业务日期,取该月最后可确定的生效时点。
"""
try:
year, mon = parse_month(month)
except ValueError as exc:
# schema 已拦住非法月份;内部调用绕过 schema 时也不能变成 500
raise BadRequestError(str(exc)) from exc
return date(year, mon, monthrange(year, mon)[1])
def manager_share_params(session: Session, month: str) -> ManagerShareParams:
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
"""
from app.services.param_service import parse_fraction, resolve_snapshot
param_date = month_end(month)
snapshot = resolve_snapshot(session, param_date)
team_text = snapshot.get("manager.share.team")
company_text = snapshot.get("manager.share.company")
return ManagerShareParams(
month=month,
param_date=param_date,
team_text=team_text,
company_text=company_text,
team_pct=parse_fraction(team_text),
company_pct=parse_fraction(company_text),
include_manager_personal=snapshot.get("manager.personal.included") == "1",
)
def add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
bucket = managers.setdefault(
manager_id,
{
"performance": Decimal("0"),
"business_id": row.business_id,
"business_no": row.business_no,
"names": set(),
},
)
bucket["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name)
def collect_team_buckets(
session: Session, ledger_rows: list, include_manager_personal: bool
) -> dict[int, dict]:
"""按经理采集团队业绩。
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
按上溯会被跳过漏计)。
"""
managers: dict[int, dict] = {}
for row in ledger_rows:
if row.customer_type == "manager_personal":
if include_manager_personal:
add_team_performance(managers, row.personnel_id, row)
continue # 默认不计入聚合基数(#12 参数化)
person = session.get(Personnel, row.personnel_id)
manager_id = person.manager_id if person else None
if manager_id is not None:
add_team_performance(managers, manager_id, row)
return managers
def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decimal:
"""全公司基数(5% 的底):默认排除经理个人户行(#12 开关计入时不过滤)。
口径逐行固定,调用方在循环外算一次。
"""
return sum(
(
r.performance_amount or Decimal("0")
for r in ledger_rows
if include_manager_personal or r.customer_type != "manager_personal"
),
Decimal("0"),
)
+48
View File
@@ -314,6 +314,54 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
),
sort_order=20,
),
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
# 首批三条中「同步连续失败」依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目
ParamSpec(
code="warning.enabled",
name="风险预警总开关",
group="预警规则",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="1",
value_min=None,
value_max=None,
description="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
sort_order=21,
),
ParamSpec(
code="warning.receipt.overdue.days",
name="回款逾期天数阈值",
group="预警规则",
value_type="INT",
unit="天",
dimension_type="NONE",
options=None,
default_value="90",
value_min="1",
value_max=None,
description=(
"发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"
),
sort_order=22,
),
ParamSpec(
code="warning.approval.timeout.days",
name="审批超时天数阈值",
group="预警规则",
value_type="INT",
unit="天",
dimension_type="NONE",
options=None,
default_value="3",
value_min="1",
value_max=None,
description=(
"待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"
),
sort_order=23,
),
]
}
+657
View File
@@ -0,0 +1,657 @@
"""报表 Excel 模板构建(纯函数,可单测)。
版式(FR-032「导出模板围绕财务复核和领导签字设计」):
公司信息页眉 → 报表标题 → 筛选条件 → 导出时间/人/模板版本 → 表头 → 数据行
→ 合计行 → 财务复核栏 / 领导签字栏 / 日期栏。
精度红线:金额写 ``Decimal``(openpyxl 原生支持,不经 float)+
``number_format='#,##0.00'``;合计在 Python 侧求和后两位收口,**不写
Excel 公式**(避免打开时重算漂移与公式注入面)。文本列以 ``= + - @``
开头时前置单引号,防公式注入。
"""
from dataclasses import dataclass
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from app.core.report_meta import (
BUSINESS_STATUS_LABELS,
CUSTOMER_TYPE_LABELS,
PRODUCT_CATEGORY_LABELS,
)
_HEADER_FILL = PatternFill("solid", fgColor="DDE3F0")
_THIN = Side(style="thin", color="B0B7C3")
_BORDER = Border(left=_THIN, right=_THIN, top=_THIN, bottom=_THIN)
_TITLE_FONT = Font(size=14, bold=True)
_COMPANY_FONT = Font(size=16, bold=True)
_HEADER_FONT = Font(bold=True)
_AMOUNT_FORMAT = "#,##0.00"
_DATE_FORMAT = "YYYY-MM-DD"
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
@dataclass(frozen=True, slots=True)
class _Column:
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
key: str
title: str
kind: str # text / amount / int / date / customer / category / status
width: float
@dataclass(frozen=True, slots=True)
class _Block:
"""签字稿中的一个区块:独立表头、数据行与可选合计行。"""
title: str
columns: tuple[_Column, ...]
rows: list[dict]
total: dict | None = None
empty_text: str = "(无)"
_COLUMNS: dict[str, tuple[_Column, ...]] = {
"personal-detail": (
_Column("business_no", "合同编号", "text", 20),
_Column("ship_date", "发货日", "date", 12),
_Column("receipt_date", "回款日", "date", 12),
_Column("payment_days", "回款天数", "int", 10),
_Column("product_category", "产品类别", "category", 14),
_Column("customer_type", "客户类型", "customer", 14),
_Column("role_name", "岗位", "text", 12),
_Column("rate_text", "计提比例", "text", 14),
_Column("weight_r", "支付权重R", "int", 10),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
),
"department-summary": (
_Column("manager_name", "经理团队", "text", 16),
_Column("member_name", "成员", "text", 14),
_Column("role_code", "岗位编码", "text", 12),
_Column("performance_sum", "绩效金额", "amount", 14),
_Column("payable_sum", "应付绩效", "amount", 14),
_Column("adjust_net", "调整净额", "amount", 14),
_Column("net_total", "净合计", "amount", 14),
),
"business-stats": (
_Column("business_no", "业务编号", "text", 20),
_Column("business_date", "业务日期", "date", 12),
_Column("customer_name", "客户", "text", 22),
_Column("owner_personnel_name", "归属人", "text", 12),
_Column("status", "状态", "status", 10),
_Column("total_sale_amount", "销售额", "amount", 14),
_Column("total_ship_amount", "发货额", "amount", 14),
_Column("total_received_amount", "回款额", "amount", 14),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
),
"payment-stats": (
_Column("label", "回款天数区间", "text", 20),
_Column("receipt_amount", "回款金额", "amount", 16),
_Column("receipt_count", "回款笔数", "int", 10),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
_Column("ledger_count", "台账行数", "int", 10),
),
}
def build_workbook(
*,
code: str,
title: str,
filters: str,
rows: list[dict],
summary: dict | None,
buckets: dict | None,
template_version: str,
operator_name: str,
company_name: str,
department: dict | None = None,
) -> bytes:
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。
部门汇总是签字对账文件,走单 sheet 分块版式;其余报表仍是单表 +
合计行。`department` 为 `department_summary` 的完整出参。
"""
workbook = Workbook()
sheet = workbook.active
sheet.title = _sheet_name(code)
if code == "department-summary" and department is not None:
_write_department_document(
sheet,
department,
company_name=company_name,
title=title,
filters=filters,
template_version=template_version,
operator_name=operator_name,
)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
columns = _COLUMNS[code]
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
if rows:
for row in rows:
_write_row(sheet, columns, row)
else:
sheet.append(["本筛选条件无数据"])
_merge_row(sheet, len(columns))
_write_total(sheet, columns, rows)
_write_signature(sheet, len(columns))
_autofit(sheet, columns)
# 回款统计附维度对比 sheet(客户类型/产品类别),与区间表共用页眉格式
if code == "payment-stats" and buckets:
for dim_key, dim_title in (
("by_customer_type", "客户类型对比"),
("by_product_category", "产品类别对比"),
):
extra = workbook.create_sheet(dim_title)
_write_dimension_sheet(
extra,
dim_title,
company_name,
filters,
buckets.get(dim_key) or [],
template_version,
operator_name,
)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def _sheet_name(code: str) -> str:
return {
"personal-detail": "个人绩效明细",
"department-summary": "部门绩效汇总",
"business-stats": "业务统计",
"payment-stats": "回款统计",
}[code]
def _write_header(
sheet,
columns: tuple[_Column, ...],
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""页眉四行 + 表头行;返回后 sheet.max_row 即数据行起点。"""
_write_document_header(
sheet,
len(columns),
company_name,
title,
filters,
template_version,
operator_name,
)
sheet.append([column.title for column in columns])
header_row = sheet.max_row
for cell in sheet[header_row]:
cell.font = _HEADER_FONT
cell.fill = _HEADER_FILL
cell.border = _BORDER
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = sheet.cell(row=header_row + 1, column=1)
def _write_document_header(
sheet,
column_count: int,
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""公司名/标题/筛选条件/导出信息四行(分块文档与单表共用)。"""
sheet.append([company_name])
_merge_row(sheet, column_count)
sheet.cell(row=1, column=1).font = _COMPANY_FONT
sheet.cell(row=1, column=1).alignment = Alignment(horizontal="left", vertical="center")
sheet.append([title])
_merge_row(sheet, column_count)
sheet.cell(row=2, column=1).font = _TITLE_FONT
sheet.cell(row=2, column=1).alignment = Alignment(horizontal="center", vertical="center")
sheet.append([f"筛选条件:{filters}"])
_merge_row(sheet, column_count)
sheet.append(
[
f"导出时间:{datetime.now():%Y-%m-%d %H:%M:%S} | 导出人:{operator_name} | "
f"模板版本:{template_version}"
]
)
_merge_row(sheet, column_count)
sheet.append([])
def _merge_row(sheet, column_count: int) -> None:
"""合并当前行(公司名/标题/筛选行跨全表宽)。"""
row = sheet.max_row
if column_count > 1:
sheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=column_count)
def _write_row(sheet, columns: tuple[_Column, ...], row: dict) -> None:
"""数据行:按列类型写单元格(金额 Decimal、日期 date、文本防注入)。"""
values = []
for column in columns:
raw = row.get(column.key)
values.append(_cell_value(column, raw))
sheet.append(values)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
elif column.kind == "date":
cell.number_format = _DATE_FORMAT
def _cell_value(column: _Column, raw):
"""单值转换:标签列转中文,文本列防公式注入。"""
if raw is None:
return None
if column.kind == "amount":
return raw if isinstance(raw, Decimal) else Decimal(str(raw))
if column.kind == "date":
return raw
if column.kind == "int":
return int(raw)
if column.kind == "customer":
return CUSTOMER_TYPE_LABELS.get(str(raw), str(raw))
if column.kind == "category":
return PRODUCT_CATEGORY_LABELS.get(str(raw), str(raw))
if column.kind == "status":
return BUSINESS_STATUS_LABELS.get(int(raw), str(raw))
return _safe_text(raw)
def _safe_text(value) -> str:
"""文本防公式注入:以 = + - @ 或制表符开头时前置单引号。"""
text = str(value)
return "'" + text if text.startswith(_FORMULA_PREFIXES) else text
def _write_total(sheet, columns: tuple[_Column, ...], rows: list[dict]) -> None:
"""合计行:金额列按行求和后两位收口(不写 Excel 公式)。
「合计」标签只写在第一个非金额列;其余非金额列留空——否则每遇到
一个文本列都会重复写一次标签,合计行会满屏「合计」。
"""
if not rows:
return
totals = []
label_written = False
for column in columns:
if column.kind != "amount":
totals.append("合计" if not label_written else None)
label_written = True
continue
total = sum(
(
row.get(column.key)
for row in rows
if isinstance(row.get(column.key), (Decimal, int))
),
Decimal("0"),
)
totals.append(total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
sheet.append(totals)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.font = _HEADER_FONT
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
def _write_signature(sheet, column_count: int) -> None:
"""财务复核栏 / 领导签字栏 / 日期栏(FR-032 模板要素)。"""
sheet.append([])
sheet.append(["财务复核:________________", "", "日期:______年__月__日"])
sheet.append(["领导签字:________________", "", "日期:______年__月__日"])
for row_index in (sheet.max_row - 1, sheet.max_row):
sheet.cell(row=row_index, column=1).alignment = Alignment(horizontal="left")
def _autofit(sheet, columns: tuple[_Column, ...]) -> None:
"""列宽按定义表设置(CJK 按 2 字符宽,不用默认 8.43)。"""
for index, column in enumerate(columns, start=1):
sheet.column_dimensions[get_column_letter(index)].width = column.width
def _write_dimension_sheet(
sheet,
title: str,
company_name: str,
filters: str,
rows: list[dict],
template_version: str,
operator_name: str,
) -> None:
"""维度对比 sheet(客户类型/产品类别),页眉筛选条件与主表一致。"""
columns = (
_Column("label", "维度", "text", 16),
_Column("receipt_amount", "回款金额", "amount", 16),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
_Column("ledger_count", "台账行数", "int", 10),
)
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
for row in rows:
_write_row(sheet, columns, row)
_write_total(sheet, columns, rows)
_write_signature(sheet, len(columns))
_autofit(sheet, columns)
# ==== 部门汇总签字稿(FR-030 / FR-032)====
_MEMBER_KIND_LABELS = {
"regular": "普通绩效",
"manager_personal": "经理个人户",
"adjustment": "仅调整单",
}
# 各块列数不同,统一按列号取最大宽度(签字稿打印时不挤在一起)
_DEPARTMENT_WIDTHS = (30, 16, 22, 13, 13, 15, 18, 14, 14, 13)
def _write_department_document(
sheet,
data: dict,
*,
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""部门汇总签字稿:团队分成 → 全公司 5% → 对账 → 成员 → 未分配 → 签字栏。
全公司 5% 每位有月结聚合行的经理各计一次,合计在第二块展示;
团队块的调整净额仅供成员汇总核对。应发合计由后端 `reconciliation`
按聚合行口径算好(含分列舍入差),本函数只渲染。
"""
params = data.get("params") or {}
team_text = params.get("team_text", "")
company_text = params.get("company_text", "")
teams = data.get("teams") or []
unassigned = data.get("unassigned") or []
company = data.get("company") or {}
reconciliation = data.get("reconciliation") or {}
company_share_count = int(company.get("company_share_count") or 0)
_write_document_header(
sheet, len(_DEPARTMENT_WIDTHS), company_name, title, filters, template_version, operator_name
)
_write_block(
sheet,
_Block(
"一、团队分成汇总",
(
_Column("seq", "序号", "int", 6),
_Column("manager_name", "经理团队", "text", 16),
_Column("manager_status_text", "状态", "text", 8),
_Column("member_count", "团队人数", "int", 9),
_Column("team_base", "团队基数", "amount", 13),
_Column("team_share_amount", f"团队分成({team_text}%)", "amount", 15),
_Column("adjust_net", "调整净额(仅核对)", "amount", 16),
_Column("team_subtotal", "团队应发小计(含聚合分成)", "amount", 18),
_Column("frozen_amount", "月结冻结额", "amount", 14),
),
[
{
"seq": index,
"manager_name": team["manager_name"],
"manager_status_text": "在职" if team["manager_status"] == 1 else "离职",
"member_count": team["member_count"],
"team_base": team["team_base"],
"team_share_amount": team["team_share_amount"],
"adjust_net": team["adjust_net"],
"team_subtotal": team["team_subtotal"],
"frozen_amount": team["frozen_amount"],
}
for index, team in enumerate(teams, start=1)
],
{
"manager_name": "合计",
"member_count": sum(team["member_count"] for team in teams),
"team_base": _sum_amount(team["team_base"] for team in teams),
"team_share_amount": _sum_amount(
team["team_share_amount"] for team in teams
),
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
"frozen_amount": (
_sum_amount(
team["frozen_amount"]
for team in teams
if team["frozen_amount"] is not None
)
if any(team["frozen_amount"] is not None for team in teams)
else None
),
},
"(无团队)",
),
)
_write_block(
sheet,
_Block(
f"二、全公司分成(每位有聚合行的经理各计一次,共 {company_share_count} 次)",
(
_Column("company_base", "全公司基数", "amount", 16),
_Column("company_share_pct", "分成比例", "text", 12),
_Column("company_share_amount", "单经理分成", "amount", 13),
_Column("company_share_count", "计入次数", "int", 10),
_Column("company_share_total", "分成合计", "amount", 14),
),
[
{
"company_base": company.get("company_base"),
"company_share_pct": f"{company_text}%",
"company_share_amount": company.get("company_share_amount"),
"company_share_count": company_share_count,
"company_share_total": company.get("company_share_total"),
}
],
),
)
_write_block(
sheet,
_Block(
"三、对账汇总",
(
_Column("item", "项目", "text", 30),
_Column("amount", "金额", "amount", 16),
_Column("note", "说明", "text", 40),
),
[
{
"item": "团队成员应付合计",
"amount": reconciliation.get("team_member_payable_total"),
},
{
"item": "未分配人员应付合计",
"amount": reconciliation.get("unassigned_payable_total"),
},
{"item": "团队分成合计(分列)", "amount": reconciliation.get("team_share_total")},
{
"item": f"全公司分成合计({company_share_count} 次,分列)",
"amount": reconciliation.get("company_share_total"),
"note": "每位有聚合行的经理各计一次",
},
{
"item": "聚合行舍入差",
"amount": reconciliation.get("aggregate_rounding_diff"),
"note": "分列舍入与聚合行一次舍入的差",
},
{
"item": "调整净额合计(明细层)",
"amount": reconciliation.get("adjust_net_total"),
},
{
"item": "应发合计",
"amount": reconciliation.get("payable_total"),
"note": "上列各项之和",
},
{"item": "团队基数合计", "amount": reconciliation.get("team_base_total")},
{
"item": "未分配绩效合计",
"amount": reconciliation.get("unassigned_performance_total"),
},
{
"item": "其他未展示基数",
"amount": reconciliation.get("other_team_base"),
"note": "筛选时含其他团队与全公司未分配",
},
{"item": "全公司基数", "amount": reconciliation.get("company_base")},
],
),
)
member_columns = (
_Column("manager_name", "经理团队", "text", 15),
_Column("personnel_name", "成员", "text", 12),
_Column("role_code", "岗位", "text", 10),
_Column("member_kind_text", "来源", "text", 13),
_Column("performance_sum", "绩效金额", "amount", 13),
_Column("payable_sum", "应付绩效", "amount", 13),
_Column("adjust_extra", "调整补发", "amount", 12),
_Column("adjust_deduct", "调整扣减", "amount", 12),
_Column("adjust_net", "调整净额", "amount", 12),
_Column("net_total", "净合计", "amount", 13),
)
member_rows = [
{
"manager_name": team["manager_name"],
"personnel_name": member["personnel_name"],
"role_code": member["role_code"],
"member_kind_text": _MEMBER_KIND_LABELS.get(
member["member_kind"], member["member_kind"]
),
"performance_sum": member["performance_sum"],
"payable_sum": member["payable_sum"],
"adjust_extra": member["adjust_extra"],
"adjust_deduct": member["adjust_deduct"],
"adjust_net": member["adjust_net"],
"net_total": member["net_total"],
}
for team in teams
for member in team["members"]
]
_write_block(
sheet,
_Block("四、团队成员明细", member_columns, member_rows, empty_text="(无成员)"),
sum_amounts=True,
)
unassigned_columns = (
_Column("personnel_name", "人员", "text", 14),
_Column("role_code", "岗位", "text", 10),
_Column("unassigned_reason", "未纳入原因", "text", 24),
_Column("performance_sum", "绩效金额", "amount", 13),
_Column("payable_sum", "应付绩效", "amount", 13),
_Column("adjust_extra", "调整补发", "amount", 12),
_Column("adjust_deduct", "调整扣减", "amount", 12),
_Column("adjust_net", "调整净额", "amount", 12),
_Column("net_total", "净合计", "amount", 13),
)
unassigned_rows = [
{
"personnel_name": row["personnel_name"],
"role_code": row["role_code"],
"unassigned_reason": row.get("unassigned_reason") or "",
"performance_sum": row["performance_sum"],
"payable_sum": row["payable_sum"],
"adjust_extra": row["adjust_extra"],
"adjust_deduct": row["adjust_deduct"],
"adjust_net": row["adjust_net"],
"net_total": row["net_total"],
}
for row in unassigned
]
_write_block(
sheet,
_Block("五、未分配人员明细", unassigned_columns, unassigned_rows),
sum_amounts=True,
)
_write_signature(sheet, len(_DEPARTMENT_WIDTHS))
for index, width in enumerate(_DEPARTMENT_WIDTHS, start=1):
sheet.column_dimensions[get_column_letter(index)].width = width
def _write_block(sheet, block: _Block, *, sum_amounts: bool = False) -> None:
"""写一个区块:标题、表头、数据/空提示,以及自定义或自动合计。"""
sheet.append([block.title])
title_row = sheet.max_row
_merge_row(sheet, len(block.columns))
sheet.cell(row=title_row, column=1).font = _HEADER_FONT
sheet.append([column.title for column in block.columns])
header_row = sheet.max_row
for cell in sheet[header_row]:
cell.font = _HEADER_FONT
cell.fill = _HEADER_FILL
cell.border = _BORDER
cell.alignment = Alignment(horizontal="center", vertical="center")
if block.rows:
for row in block.rows:
_write_row(sheet, block.columns, row)
else:
sheet.append([block.empty_text])
_merge_row(sheet, len(block.columns))
if block.total is not None:
_write_custom_total(sheet, block.columns, block.total)
elif sum_amounts:
_write_total(sheet, block.columns, block.rows)
sheet.append([])
def _write_custom_total(sheet, columns: tuple[_Column, ...], total: dict) -> None:
"""写后端预计算的合计行(不自动求和,避免重复计入共享基数)。"""
values = []
for column in columns:
raw = total.get(column.key)
values.append(_cell_value(column, raw) if raw is not None else None)
sheet.append(values)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.font = _HEADER_FONT
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
def _sum_amount(values) -> Decimal:
"""金额列求和后两位收口(None 跳过)。"""
return sum((value for value in values if value is not None), Decimal("0")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
File diff suppressed because it is too large. Load diff
+19 -87
View File
@@ -9,11 +9,8 @@
"""
import logging
from calendar import monthrange
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -45,7 +42,7 @@ from app.schemas.settlement import (
SettlementOut,
SettlementQuery,
)
from app.services import approval_service, audit_service
from app.services import approval_service, audit_service, manager_share
from app.services.approval_service import AuditRecord, RequestMeta
from app.services.formula import manager_team_share, monthly_salary
@@ -352,82 +349,28 @@ def _confirm_scope(session: Session, month: str) -> list:
)
@dataclass(frozen=True, slots=True)
class _ShareParams:
"""经理分成取参与参数原文(同一次生成共用一份,随月月末时点化)。"""
def _manager_share_params(session: Session, month: str) -> manager_share.ManagerShareParams:
"""薄委托 manager_share 共享模块:月结聚合与报表部门汇总同源取参。
month: str
param_date: date
team_text: str
company_text: str
team_pct: Fraction
company_pct: Fraction
include_manager_personal: bool
def _manager_share_params(session: Session, month: str) -> _ShareParams:
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
迁移说明(2026-10-19):实现原样迁至 manager_share,行为零变化;
参数按百分数存储(12.5 表示 12.5%),调用方不得再乘 100。
"""
from app.services.param_service import parse_fraction, resolve_snapshot
param_date = _month_end(month)
snapshot = resolve_snapshot(session, param_date)
team_text = snapshot.get("manager.share.team")
company_text = snapshot.get("manager.share.company")
return _ShareParams(
month=month,
param_date=param_date,
team_text=team_text,
company_text=company_text,
team_pct=parse_fraction(team_text),
company_pct=parse_fraction(company_text),
include_manager_personal=snapshot.get("manager.personal.included") == "1",
)
def _add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
bucket = managers.setdefault(
manager_id,
{
"performance": Decimal("0"),
"business_id": row.business_id,
"business_no": row.business_no,
"names": set(),
},
)
bucket["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name)
return manager_share.manager_share_params(session, month)
def _collect_team_buckets(
session: Session, ledger_rows: list, include_manager_personal: bool
) -> dict[int, dict]:
"""按经理采集团队业绩。
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
按上溯会被跳过漏计)。
"""
managers: dict[int, dict] = {}
for row in ledger_rows:
if row.customer_type == "manager_personal":
if include_manager_personal:
_add_team_performance(managers, row.personnel_id, row)
continue # 默认不计入聚合基数(#12 参数化)
person = session.get(Personnel, row.personnel_id)
manager_id = person.manager_id if person else None
if manager_id is not None:
_add_team_performance(managers, manager_id, row)
return managers
"""薄委托 manager_share 共享模块:按经理采集团队业绩(行为零变化)。"""
return manager_share.collect_team_buckets(session, ledger_rows, include_manager_personal)
def _aggregate_row(
session: Session, manager, bucket: dict, company_base: Decimal, share: _ShareParams
session: Session,
manager,
bucket: dict,
company_base: Decimal,
share: manager_share.ManagerShareParams,
):
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
from app.models import PerformanceLedger
@@ -487,16 +430,8 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
share = _manager_share_params(session, month)
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);
# 口径逐行固定,循环外算一次
company_base = sum(
(
r.performance_amount or Decimal("0")
for r in ledger_rows
if share.include_manager_personal or r.customer_type != "manager_personal"
),
Decimal("0"),
)
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);共享口径单点
company_base = manager_share.company_base_of(ledger_rows, share.include_manager_personal)
aggregates: list = []
for manager_id, bucket in managers.items():
manager = session.get(Personnel, manager_id)
@@ -514,15 +449,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
def _month_end(month: str) -> date:
"""YYYY-MM → 所属月月末(月结取参锚点)。
"""薄委托 manager_share 共享模块:YYYY-MM → 所属月月末(取参锚点)。
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
更细的业务日期,取该月最后可确定的生效时点。
保留包装:tests/unit/test_settlement_helpers.py 直接 import 本名字;
「补生成历史月份结果不随操作日漂移」的原因说明见 manager_share.month_end。
"""
year, mon = int(month[:4]), int(month[5:7])
return date(year, mon, monthrange(year, mon)[1])
return manager_share.month_end(month)
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
+42 -3
View File
@@ -136,6 +136,29 @@ uv run pytest tests/integration -q # 仅集成
运行:`uv run pytest tests/integration/test_{settings,secondary_allocation,approval_flow}.py tests/unit/test_secondary_allocation_schema.py -q` → **44 passed**(2026-10-16);全量回归 `uv run pytest -q` → **697 passed**(基线 625 + 新增 72,5:10)。
### 报表统计(M4 ⑤,2026-10-19 后端完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、分成按参数、无成员经理出 0 值行;**无台账经理挂上级不 500、仅有调整单人员并入团队/未分配、经理个人户开关两侧口径、按经理筛选不误判其他团队下属、对账恒等式**;**应发与月结 `total_payable` 对拍(两位经理各计一次 5%)、有调整单时等于月结+调整净额、筛选态只汇总展示团队**;导出页眉 ID→名称/中文标签、回款维度 sheet 透传筛选、**导出超限与取数异常的失败审计**、非法月份查询/导出均 400;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**;业务统计排除经理聚合行;普通用户 403 | 27 |
| `tests/unit/test_report_helpers.py` | 区间分桶边界与 8 档编码唯一;标签覆盖全部枚举;Excel 模板页眉/签字栏/空数据/金额格式/合计/公式注入/维度 sheet/状态中文;**部门汇总签字稿五块顺序、零成员团队与未分配行、5% 按计入次数累计、应发合计取对账口径、聚合行舍入差单列、比例标题参数化** | 17 |
| `tests/unit/test_month_validation.py` | 非法月份(`2026-13`/`2026-00`/`0000-01` 等)在报表、月结、调整单 schema 入口即拒绝;可选月份空串规范为 None | 17 |
运行:`uv run pytest tests/integration/test_reports.py -q` → **27 passed**(2026-10-20 复核);`uv run pytest tests/unit/test_report_helpers.py tests/unit/test_month_validation.py -q` → **34 passed**;全量 **862 passed**(2026-10-20,11:32)。
**e2e 实跑(2026-10-20)**:`frontend/e2e/report_flow.py` 12/12 通过。回款统计改为展开目标桶后按**本次业务编号**定位下钻行(不依赖桶累计值,可重复运行);另覆盖多桶同时展开、部门下钻筛选、打印截断提示、部门汇总打印五块结构。
### 工作台(M4 ⑧,2026-10-19 完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志;**待办按角色过滤(财务/领导/多角色/admin)+ 真实流转;回款逾期命中与阈值参数生效;终止业务排除;`warnings.total` 不受展示上限截断;回款趋势跨月归属** | 11 |
| `tests/unit/test_param_registry.py`(改) | 注册表 19→**22 项**(V10 预警规则三项);编码集合与迁移种子一致 | 改 |
运行:`uv run pytest tests/integration/test_dashboard.py -q` → **11 passed**(2026-10-19 code-review 修复回归)。
**实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。
## 三、运行记录
| 日期 | 范围 | 用例数 | 结果 | 备注 |
@@ -156,12 +179,28 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) |
| 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 |
| 2026-10-19 | 拆行收口批次 | 全量 788 | ✅ 788 passed(9:20) | **2026-10-19 业务答复落实完成**:#14 首日取整 10‰(公式段/锚点不变,用例反转);#12 单笔侧开关消费(1=业务员递减计提);**二次分摊拆行**(参与人份额+负责人余额守恒,uk 三列版);权限分层(params 查看放开/写仅 admin);V8 迁移(审批开关默认开/撤试用期参数/增基数开关)。定点:calc 20 / settlements 13(含 #12 开关两档金标准 6.84→24.34)/ businesses 30(保存侧+3)/ formula 35。e2e calc_settlement_flow 交接并行会话 |
| 2026-10-19 | **报表统计修复回归** | 全量 847 | ✅ 847 passed(10:13) | **M4 报表 code-review 15 项修复后全量回归**(后端 8 + 前端 7):归属三集合/调整单全量并入/经理个人户拆分/对账出参、分块签字稿导出、导出审计覆盖取数、筛选页眉 ID→名称、月份校验 400、岗位只读选项。报表集成 24 / 报表单元 16 / 月份校验 17。type-check + build 绿;e2e `report_flow.py` 扩至 12 步,待 `E2E_ADMIN_PASSWORD` 实跑 |
| 2026-10-19 | 全部 | 852 | ✅ 852 passed(9:17) | **M4 ⑧ 工作台完成**(四区块实时聚合 + ECharts 5.6 前端 + V10 预警参数三项;新增 5 例;参数注册表 19→22;同步模块显式降级)。期间修复:alembic `--sql` 导出 GBK 导致 schema 同步误报 |
| 2026-10-19 | **code-review 修复回归** | 全量 862 | ✅ 862 passed(9:36) | **M4 工作台 + 报表 3 必须 + 8 建议修复**:普通用户落地页/守卫(报表集成 27、工作台集成 11、报表单元 17,新增 10 例);部门汇总 5% 按计入次数累计并与月结 `total_payable` 对拍、聚合行舍入差;工作台待办按角色过滤、逾期命中与阈值生效、终止业务排除、`warnings.total` 截断、趋势跨月归属。ruff 绿;type-check + build 绿 |
| 2026-10-20 | e2e 全链路 | — | ✅ login 8 / system 13 / role_home 4 / report 12 / calc_settlement 8 | 重启后端加载新代码后实跑:普通用户落地页与受限页回退、报表四页/导出/打印、核算与月结链路全过。`report_flow` 回款统计改为展开目标桶后按本次业务编号定位明细,不依赖桶累计值,可重复运行 |
| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 |
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
## 四、测试发现并修复的缺陷
| 日期 | 缺陷 | 影响 | 处理 |
| --- | --- | --- | --- |
| 2026-10-19 | 分摊负责人份额按余额百分数乘全额导致行合计丢 q2 尾差 | 拆 30%+40% 后负责人 100−70=30% 再乘未 q2 全额(39.1111×0.30=11.7333),行合计 39.10 ≠ 39.11——**每笔分摊账丢 1 分**,守恒破坏(金标准用例 `行合计==业务级全额` 抓住) | 负责人行恒走余额公式(q2 全额 −∑参与人 q2 份额);余额百分数仅作快照展示不参与计算 |
| 2026-10-19 | 部门汇总导出/打印只展平成员行 | 团队分成、全公司 5% 块、未分配人员、零成员团队全部缺失——**签字稿合计小于应发**,财务按签字稿复核会漏发经理分成与未分配绩效 | 导出改为单 sheet 分块签字稿(团队/公司/对账/成员/未分配)+ 打印五块;应发公式与对账块由后端 Decimal 计算 |
| 2026-10-19 | `_member_rows` 直接 `member_map[pid]` 索引 | 无台账行的营销经理自身挂上级时 KeyError → **部门汇总接口与导出 500**(当月完全无台账也会触发) | 归属只从 member_map/adjustments 生成;`manager_role` 覆盖所有成员引用的上级 |
| 2026-10-19 | 生效调整单只并入有台账人员 | 只有调整单、当月无回款的人被静默丢弃——补发/扣减金额不出现,**按报表发放会少发** | 调整单人员补零值成员后参与团队/未分配归属;每人调整净额只计一次 |
| 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 |
| 2026-10-19 | 导出取数与超限校验在审计上下文之外 | 超限 400 与取数异常**不留失败日志**,与 M4 计划 T12「导出 400/失败审计」不符 | `_export_payload` 移入 `with audit`;静态标题映射(AuditRecord frozen) |
| 2026-10-19 | 月份只校验长度 | `2026-13` 传到 `calendar.monthrange` 抛 ValueError → **500**;导出手工构造模型抛裸 ValidationError 同样 500 | 新增 `core/month.py`(格式+01-12+年份);四类 query、月结、调整单接入;导出 ValidationError 转 400 |
| 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 |
| 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 |
| 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 |
| 2026-10-19 | 工作台待办未按角色过滤 | 财务看得到「待领导审核」、领导看得到「待财务复核」,与 FR-034「仅看到与自己职责相关」不符 | `pending_statuses_for_roles` 按角色映射在途节点;repository 参数化 statuses;admin 保留全部在途视角 |
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
@@ -215,7 +254,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‰/聚合基数开关/二次分摊拆行)落地后按同粒度补测 |
| 报表统计 | 未开始 | 依赖报表口径确认 |
| 绩效工资核算(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 完成并补齐用例,见上表。
+5 -5
View File
@@ -190,14 +190,14 @@ def _purge_test_data() -> None:
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 25
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
# 插入两个审批开关的 10-19 生效行 id 23/24,均属种子),删新条目与
# 新批次后解析回退到种子值
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 25)).scalars())
# 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数
# id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars())
if stray_values:
stray_batches = {v.batch_id for v in stray_values}
session.execute(delete(ParamValue).where(ParamValue.id > 25))
session.execute(delete(ParamValue).where(ParamValue.id > 28))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
@@ -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
+453
View File
@@ -0,0 +1,453 @@
"""工作台集成测试(M4 ⑧)。
守的口径:
- 权限:普通用户 403 且文案「无权访问工作台」
- 总览:绩效合计/人数与台账一致;月份参数非法 400
- 待办:月结提交后出现待审批;回款未指定批次可命中;同步未建时显式降级
- 风险预警:审批超时命中、参数关闭后不产生条目、阈值参数化生效
- 最近动态:来源操作日志(写操作可见)
待办与预警为实时派生(不落表),测试只验证查询结果,不清理派生数据。
"""
import uuid
from datetime import date, datetime, timedelta
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
pytestmark = pytest.mark.integration
_DASHBOARD = "/api/v1/dashboard"
_PERSONNEL = "/api/v1/base/personnel"
_BUSINESSES = "/api/v1/business/businesses"
_SHIPMENTS = "/api/v1/business/shipments"
_RECEIPTS = "/api/v1/business/receipts"
_CALC = "/api/v1/calc"
_SETTLEMENTS = "/api/v1/settlements"
_PARAMS = "/api/v1/system/params"
def _role_id(code: str) -> int:
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import BusinessRole
factory = get_session_factory()
with factory() as session:
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == code, BusinessRole.status == 1
)
).scalar_one()
)
def _person(client: TestClient, headers: dict) -> dict:
resp = client.post(
_PERSONNEL,
json={
"personnel_name": f"t_工作台_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id("salesperson"),
"salary_standard": "3000.00",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _chain(client: TestClient, headers: dict, *, assign_shipment: bool = True) -> dict:
"""客户/业务(100×100,成本 3000)→ 发货(60 天前)→ 回款 → 核算。"""
from tests.integration.factories import business_item, business_payload, customer
cust = customer(client, headers)
person = _person(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=person["id"],
items=[business_item(cost_amount="3000.00")],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
business = resp.json()["data"]
ship_date = (date.today() - timedelta(days=60)).isoformat()
resp = client.post(
_SHIPMENTS,
json={
"business_id": business["id"],
"ship_date": ship_date,
"items": [{"product_name": "t_工作台发货", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
shipment = resp.json()["data"]
payload = {
"business_id": business["id"],
"receipt_date": date.today().isoformat(),
"amount": "10000.00",
}
if assign_shipment:
payload["shipment_id"] = shipment["id"]
resp = client.post(_RECEIPTS, json=payload, headers=headers)
assert resp.status_code == 200, resp.text
receipt = resp.json()["data"]
if assign_shipment:
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
assert resp.status_code == 200, resp.text
return {"business": business, "person": person, "receipt": receipt}
def _month() -> str:
return date.today().strftime("%Y-%m")
def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> dict:
params = {"month": month} if month else {}
resp = client.get(_DASHBOARD, params=params, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def test_权限_普通用户403文案(client: TestClient, normal: dict):
resp = client.get(_DASHBOARD, headers=normal["headers"])
assert resp.status_code == 403
assert resp.json()["message"] == "无权访问工作台"
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
_chain(client, admin["headers"])
data = _dashboard(client, admin["headers"], _month())
from sqlalchemy import func, select
from app.core.db import get_session_factory
from app.models import PerformanceLedger
factory = get_session_factory()
with factory() as session:
expected = Decimal(
str(
session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)
).where(
PerformanceLedger.settlement_month == _month(),
PerformanceLedger.calc_type == 1,
PerformanceLedger.is_active == 1,
)
).scalar_one()
)
)
assert Decimal(str(data["overview"]["performance_total"])) == expected
assert data["overview"]["person_count"] >= 1
assert len(data["overview"]["trend"]) == 6
resp = client.get(_DASHBOARD, params={"month": "2026-13"}, headers=admin["headers"])
assert resp.status_code == 400
def test_待办_待审批与未指定批次与同步降级(client: TestClient, admin: dict):
# 先造一笔已核算业务(月结生成要求有可结算记录),再补一笔未指定批次的回款
_chain(client, admin["headers"], assign_shipment=True)
ctx = _chain(client, admin["headers"], assign_shipment=False)
# 月结提交 → 产生待审批单
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"]
resp = client.post(
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
data = _dashboard(client, admin["headers"])
assert data["todos"]["pending_approval_total"] >= 1
assert any(
item["order_type"] == 2 for item in data["todos"]["pending_approvals"]
)
assert data["todos"]["unassigned_receipt_total"] >= 1
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
# ⑥ 未建:显式降级
assert data["todos"]["sync_available"] is False
def test_预警_审批超时命中且参数关闭后不产生(client: TestClient, admin: dict):
_chain(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
settlement_id = resp.json()["data"]["id"]
client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
# 把待审批单提交时间改到 4 天前(阈值默认 3 天)
from sqlalchemy import update
from app.core.db import get_session_factory
from app.models import ApprovalOrder
factory = get_session_factory()
with factory() as session:
session.execute(
update(ApprovalOrder)
.where(ApprovalOrder.status.in_((1, 2)))
.values(create_time=datetime.now() - timedelta(days=4))
)
session.commit()
data = _dashboard(client, admin["headers"])
assert data["warnings"]["enabled"] is True
assert any(item["code"] == "approval_timeout" for item in data["warnings"]["items"])
# 关闭预警开关 → 不产生条目
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"remark": "e2e 工作台测试:关闭风险预警",
"items": [{"param_code": "warning.enabled", "dimension_value": "", "value": "0"}],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = _dashboard(client, admin["headers"])
assert data["warnings"]["enabled"] is False
assert data["warnings"]["items"] == []
def test_动态_来源操作日志(client: TestClient, admin: dict):
_chain(client, admin["headers"])
data = _dashboard(client, admin["headers"])
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
@pytest.fixture
def finance(make_user, auth_headers) -> dict:
user = make_user(["finance"])
user["headers"] = auth_headers(user)
return user
@pytest.fixture
def leader(make_user, auth_headers) -> dict:
user = make_user(["leader"])
user["headers"] = auth_headers(user)
return user
def _insert_approval(status: int) -> None:
"""直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。"""
from app.core.db import get_session_factory
from app.models import ApprovalOrder
factory = get_session_factory()
with factory() as session:
session.add(
ApprovalOrder(
order_no=f"AP{uuid.uuid4().hex[:12]}",
order_type=2,
ref_id=0,
refer_no="t_工作台",
status=status,
submitter_name="t_admin",
)
)
session.commit()
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"remark": f"t_工作台:{code}",
"items": [{"param_code": code, "dimension_value": "", "value": value}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _shipment_scene(client: TestClient, headers: dict, *, days_ago: int) -> tuple[dict, dict]:
"""造一笔无回款的发货(用于回款逾期预警)。"""
from tests.integration.factories import business_item, business_payload, customer
person = _person(client, headers)
cust = customer(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=person["id"],
items=[business_item(cost_amount="3000.00")],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
business = resp.json()["data"]
ship_date = (date.today() - timedelta(days=days_ago)).isoformat()
resp = client.post(
_SHIPMENTS,
json={
"business_id": business["id"],
"ship_date": ship_date,
"items": [{"product_name": "t_工作台逾期", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return business, resp.json()["data"]
def test_待办_按角色仅显示可操作节点(
client: TestClient, admin: dict, finance: dict, leader: dict, make_user, auth_headers
):
"""财务只看到待财务复核、领导只看到待领导审核;admin 与多角色看并集。"""
_insert_approval(1)
_insert_approval(2)
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
assert _dashboard(client, admin["headers"])["todos"]["pending_approval_total"] == 2
both = make_user(["finance", "leader"])
both["headers"] = auth_headers(both)
assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2
finance_orders = {
item["order_no"] for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
}
leader_orders = {
item["order_no"] for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
}
assert finance_orders.isdisjoint(leader_orders)
def test_待办_财务通过后领导出现待办(
client: TestClient, admin: dict, finance: dict, leader: dict
):
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
_chain(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"]
resp = client.post(
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0]
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 0
resp = client.post(
f"/api/v1/system/approvals/{order['order_id']}/action",
json={"action": 1},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 0
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
def test_预警_回款逾期命中且阈值参数生效(client: TestClient, admin: dict):
"""发货超阈值且无任何回款 → 命中;调大阈值后不再命中。"""
_, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
data = _dashboard(client, admin["headers"])
assert any(
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
for item in data["warnings"]["items"]
), "120 天未回款的发货批次未命中预警"
_set_param(client, admin["headers"], "warning.receipt.overdue.days", "200")
data = _dashboard(client, admin["headers"])
assert not any(
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
for item in data["warnings"]["items"]
), "阈值调大后仍命中,说明参数未生效"
def test_预警_终止业务不再产生回款逾期(client: TestClient, admin: dict):
"""已终止业务不再催收,其未回款发货单不得留在预警列表。"""
business, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
resp = client.put(
f"{_BUSINESSES}/{business['id']}/status",
json={"status": 3, "reason": "t_工作台终止"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = _dashboard(client, admin["headers"])
assert not any(item["object_id"] == shipment["id"] for item in data["warnings"]["items"])
assert data["warnings"]["receipt_overdue_total"] == 0
def test_预警_总数不受展示上限截断(client: TestClient, admin: dict, monkeypatch):
"""items 只展示前 N 条,徽标 total 必须仍是全量命中数。"""
from app.services import dashboard_service
_shipment_scene(client, admin["headers"], days_ago=120)
_shipment_scene(client, admin["headers"], days_ago=120)
monkeypatch.setattr(dashboard_service, "_WARNING_LIMIT", 1)
data = _dashboard(client, admin["headers"])
assert data["warnings"]["total"] == 2
assert len(data["warnings"]["items"]) == 1
assert data["warnings"]["total"] == data["warnings"]["receipt_overdue_total"]
def test_总览_回款趋势按到账月跨月归属(client: TestClient, admin: dict):
"""趋势窗口按 receipt_date 范围过滤,月初/月末不得串月。"""
from tests.integration.factories import business_item, business_payload, customer
person = _person(client, admin["headers"])
cust = customer(client, admin["headers"])
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=person["id"],
items=[business_item(cost_amount="3000.00")],
),
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
business = resp.json()["data"]
first_of_month = date.today().replace(day=1)
previous_last = first_of_month - timedelta(days=1)
for receipt_date in (previous_last, first_of_month):
resp = client.post(
_RECEIPTS,
json={
"business_id": business["id"],
"receipt_date": receipt_date.isoformat(),
"amount": "1000.00",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
trend = {
item["month"]: Decimal(str(item["receipt_total"] or 0))
for item in _dashboard(client, admin["headers"])["overview"]["trend"]
}
assert trend[previous_last.strftime("%Y-%m")] == Decimal("1000.00")
assert trend[first_of_month.strftime("%Y-%m")] == Decimal("1000.00")
@@ -86,10 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
class TestCatalog:
"""参数目录:种子完整性与时点化回溯。"""
def test_目录共19项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
"""V7 起含调整单审批开关(16 计提项 + 3 BOOL 开关)。"""
def test_目录共22项且覆盖九个分组(self, client: TestClient, admin: dict) -> None:
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
assert len(items) == 19
assert len(items) == 22
groups = {item["param_group"] for item in items}
assert groups == {
"业务员个人户",
@@ -100,6 +100,7 @@ class TestCatalog:
"产品阈值",
"月工资口径",
"审批开关",
"预警规则",
}
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
@@ -444,7 +445,7 @@ class TestPermission:
headers = auth_headers(user)
resp = client.get(_PARAMS, headers=headers)
assert resp.status_code == 200
assert len(resp.json()["data"]) == 19
assert len(resp.json()["data"]) == 22
def test_未登录访问目录_403(self, client: TestClient) -> None:
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
+803
View File
@@ -0,0 +1,803 @@
"""报表统计集成测试(M4 ⑤)。
守的口径(破坏了就是报表数字与台账对不上):
- 个人明细与台账逐笔一致;summary 为**全筛选**合计而非当前页合计
- 部门汇总团队基数 = 成员绩效合计;12.5%/5% 分成按参数;无成员经理出 0 行
- 回款统计 8 个区间桶恒在(空桶为 0);**二次分摊拆行时回款金额只计一次**
- 业务统计排除经理聚合行(calc_type=2),绩效不被整团队聚合额污染
- 导出 xlsx 可解析且含签字栏;导出行为入操作日志
- 权限:普通用户 403 且文案为「无权访问报表统计」
"""
import uuid
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
pytestmark = pytest.mark.integration
_REPORTS = "/api/v1/reports"
_PERSONNEL = "/api/v1/base/personnel"
_BUSINESSES = "/api/v1/business/businesses"
_SHIPMENTS = "/api/v1/business/shipments"
_RECEIPTS = "/api/v1/business/receipts"
_CALC = "/api/v1/calc"
_SETTLEMENTS = "/api/v1/settlements"
def _role_id(code: str) -> int:
"""取内置业务岗位 id(salesperson / sales_manager)。"""
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import BusinessRole
factory = get_session_factory()
with factory() as session:
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == code, BusinessRole.status == 1
)
).scalar_one()
)
def _person(client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None) -> dict:
body = {
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id(role),
"salary_standard": "3000.00",
}
if manager_id is not None:
body["manager_id"] = manager_id
resp = client.post(_PERSONNEL, json=body, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _full_chain(
client: TestClient,
headers: dict,
*,
owner_id: int,
customer_id: int | None = None,
allocations: list[dict] | None = None,
) -> tuple[dict, dict]:
"""业务(单行 100×100,成本 3000)→ 发货(60 天前)→ 回款 → 核算。"""
from tests.integration.factories import business_item, business_payload, customer
cust = customer(client, headers) if customer_id is None else {"id": customer_id}
payload = business_payload(
cust["id"],
owner_personnel_id=owner_id,
items=[business_item(cost_amount="3000.00")],
)
if allocations:
payload["secondary_allocations"] = allocations
resp = client.post(_BUSINESSES, json=payload, headers=headers)
assert resp.status_code == 200, resp.text
business = resp.json()["data"]
from datetime import date, timedelta
ship_date = (date.today() - timedelta(days=60)).isoformat()
resp = client.post(
_SHIPMENTS,
json={
"business_id": business["id"],
"ship_date": ship_date,
"items": [{"product_name": "t_发货物", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
shipment = resp.json()["data"]
resp = client.post(
_RECEIPTS,
json={
"business_id": business["id"],
"receipt_date": date.today().isoformat(),
"amount": "10000.00",
"shipment_id": shipment["id"],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
receipt = resp.json()["data"]
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
assert resp.status_code == 200, resp.text
return business, receipt
def _month() -> str:
from datetime import date
return date.today().strftime("%Y-%m")
def _ledger_sum(headers: dict, month: str) -> Decimal:
"""台账(单笔生效行)同月绩效合计——报表 summary 的对照基准。"""
from sqlalchemy import func, select
from app.core.db import get_session_factory
from app.models import PerformanceLedger
factory = get_session_factory()
with factory() as session:
return Decimal(
str(
session.execute(
select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == 1,
PerformanceLedger.is_active == 1,
)
).scalar_one()
)
)
def test_个人明细_与台账一致且summary为全量合计(client: TestClient, admin: dict):
"""明细行金额必须等于台账;pageSize=1 时 summary 仍是全部筛选结果之和。"""
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
month = _month()
resp = client.get(
f"{_REPORTS}/personal-detail",
params={"settlement_month": month, "pageSize": 1},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert data["total"] == 1
expected = _ledger_sum(admin["headers"], month)
assert Decimal(str(data["list"][0]["performance_amount"])) == expected
assert Decimal(str(data["summary"]["performance_total"])) == expected
def test_部门汇总_团队基数等于成员绩效且分成按参数(admin, client: TestClient):
"""团队基数=成员绩效合计;12.5% 团队分成与 5% 全公司分成分别可核。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
_full_chain(client, admin["headers"], owner_id=salesperson["id"])
month = _month()
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": month},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
ledger_total = _ledger_sum(admin["headers"], month)
assert Decimal(str(team["team_base"])) == ledger_total
expected_share = (ledger_total * Decimal("0.125")).quantize(Decimal("0.01"))
assert Decimal(str(team["team_share_amount"])) == expected_share
assert team["member_count"] == 1
assert data["company"]["company_base"] == team["team_base"]
def test_部门汇总_无成员经理出零值行(admin, client: TestClient):
"""无团队成员 → 0 行展示(FR-030 异常处理)。"""
manager = _person(client, admin["headers"], role="sales_manager")
month = _month()
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": month},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
team = next(
(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None
)
assert team is not None
assert Decimal(str(team["team_base"])) == Decimal("0")
assert team["member_count"] == 0
def test_回款统计_空桶恒在且分摊拆行金额只计一次(admin, client: TestClient):
"""8 个区间桶恒在;二次分配拆行使台账多行但回款金额不得翻倍。"""
owner = _person(client, admin["headers"])
participant = _person(client, admin["headers"])
_full_chain(
client,
admin["headers"],
owner_id=owner["id"],
allocations=[{"personnel_id": participant["id"], "ratio": "30.00"}],
)
month = _month()
resp = client.get(
f"{_REPORTS}/payment-stats",
params={"settlement_month": month},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert len(data["buckets"]) == 8
assert [b["code"] for b in data["buckets"]] == [
"le0", "1-30", "31-60", "61-90", "91-180", "181-360", "361+", "none",
]
# 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天)
bucket = next(b for b in data["buckets"] if b["code"] == "61-90")
assert Decimal(str(bucket["receipt_amount"])) == Decimal("10000.00")
assert bucket["receipt_count"] == 1
# 拆行后台账两行,但绩效合计仍等于台账总和
assert bucket["ledger_count"] == 2
empty = next(b for b in data["buckets"] if b["code"] == "361+")
assert Decimal(str(empty["performance_amount"])) == Decimal("0")
def test_业务统计_绩效排除经理聚合行(admin, client: TestClient):
"""生成月结后出现 calc_type=2 聚合行,业务统计不得把聚合额算进单业务。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
business, _ = _full_chain(client, admin["headers"], owner_id=salesperson["id"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
resp = client.get(
f"{_REPORTS}/business-stats",
params={"keyword": business["business_no"]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
row = resp.json()["data"]["list"][0]
assert Decimal(str(row["performance_amount"])) == _ledger_sum(admin["headers"], _month())
def test_导出_excel含签字栏且入操作日志(admin, client: TestClient):
"""导出是文件流(非统一信封);模板含复核/签字栏;行为入操作日志。"""
from io import BytesIO
from openpyxl import load_workbook
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
resp = client.get(
f"{_REPORTS}/personal-detail/export",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
workbook = load_workbook(BytesIO(resp.content))
text = "\n".join(
str(cell.value)
for row in workbook.active.iter_rows()
for cell in row
if cell.value is not None
)
assert "个人绩效工资明细表" in text
assert "财务复核" in text
assert "领导签字" in text
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import OperationLog
factory = get_session_factory()
with factory() as session:
log = session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
).scalars().first()
assert log is not None
def test_权限_普通用户403文案(admin, normal, client: TestClient):
"""报表限管理员/财务/领导;普通用户被后端拦截且文案正确。"""
resp = client.get(f"{_REPORTS}/personal-detail", headers=normal["headers"])
assert resp.status_code == 403
assert resp.json()["message"] == "无权访问报表统计"
# ==== 审查回归:归属、调整单、经理个人户、导出审计 ====
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
"""立即生效的参数变更(用例不依赖种子默认值,结束显式恢复)。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": f"用例设置 {code}={value}",
"items": [{"param_code": code, "dimension_value": "", "value": value}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _insert_adjustment(person: dict, month: str, amount: str, direction: int = 1) -> None:
"""直插生效调整单(status=2):绕开审批链,聚焦报表归属口径。"""
from app.core.db import get_session_factory
from app.models import AdjustmentOrder
factory = get_session_factory()
with factory() as session:
session.add(
AdjustmentOrder(
order_no=f"TZ{uuid.uuid4().hex[:12]}",
settlement_month=month,
personnel_id=person["id"],
personnel_name=person["personnel_name"],
direction=direction,
amount=Decimal(amount),
reason="t_报表用例",
status=2,
submitter_name="t_admin",
)
)
session.commit()
def _insert_manager_personal_ledger(manager: dict, month: str, amount: str) -> None:
"""直插一条经理个人户台账行(模拟单笔侧已计提的结果)。"""
from datetime import date
from app.core.db import get_session_factory
from app.models import PerformanceLedger
factory = get_session_factory()
with factory() as session:
session.add(
PerformanceLedger(
receipt_id=None,
calc_version=1,
calc_type=1,
business_id=0,
business_no="t_个人户",
personnel_id=manager["id"],
personnel_name=manager["personnel_name"],
role_code="sales_manager",
customer_type="manager_personal",
product_category="mixed",
receipt_date=date.today(),
rate_text="0",
performance_amount=Decimal(amount),
barter_applied=0,
payable_amount=Decimal(amount),
settlement_month=month,
is_probation=0,
)
)
session.commit()
def _latest_export_log():
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import OperationLog
factory = get_session_factory()
with factory() as session:
return session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
).scalars().first()
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
manager_b = _person(client, admin["headers"], role="sales_manager")
manager_a = _person(
client, admin["headers"], role="sales_manager", manager_id=manager_b["id"]
)
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team_a = next(t for t in data["teams"] if t["manager_id"] == manager_a["id"])
assert team_a["member_count"] == 0
team_b = next(t for t in data["teams"] if t["manager_id"] == manager_b["id"])
assert all(m["personnel_id"] != manager_a["id"] for m in team_b["members"])
def test_部门汇总_仅有调整单人员并入对应团队(admin, client: TestClient):
"""只有调整单、当月无台账行的人也要出现在团队明细中。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
_insert_adjustment(salesperson, _month(), "500.00")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
member = next(m for m in team["members"] if m["personnel_id"] == salesperson["id"])
assert member["member_kind"] == "adjustment"
assert Decimal(str(member["performance_sum"])) == Decimal("0")
assert Decimal(str(member["adjust_net"])) == Decimal("500.00")
assert Decimal(str(team["adjust_net"])) == Decimal("500.00")
assert Decimal(str(data["reconciliation"]["adjust_net_total"])) == Decimal("500.00")
def test_部门汇总_仅调整单无经理人员进入未分配(admin, client: TestClient):
"""无经理人员的调整单不能被静默丢弃。"""
salesperson = _person(client, admin["headers"])
_insert_adjustment(salesperson, _month(), "300.00")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
row = next(u for u in data["unassigned"] if u["personnel_id"] == salesperson["id"])
assert row["member_kind"] == "adjustment"
assert Decimal(str(row["adjust_net"])) == Decimal("300.00")
def test_部门汇总_经理个人户计入时基数等于成员合计(admin, client: TestClient):
"""开关 1:经理成员行只取个人户金额,团队基数 == 成员绩效合计。"""
manager = _person(client, admin["headers"], role="sales_manager")
_insert_manager_personal_ledger(manager, _month(), "100.00")
_set_param(client, admin["headers"], "manager.personal.included", "1")
try:
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
team = next(
t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]
)
assert Decimal(str(team["team_base"])) == Decimal("100.00")
assert sum(
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
) == Decimal("100.00")
assert all(m["member_kind"] == "manager_personal" for m in team["members"])
finally:
_set_param(client, admin["headers"], "manager.personal.included", "0")
def test_部门汇总_经理个人户关闭时不展示个人户金额(admin, client: TestClient):
"""开关 0:个人户金额既不计入团队基数,也不进未分配(与全公司口径一致)。"""
manager = _person(client, admin["headers"], role="sales_manager")
_insert_manager_personal_ledger(manager, _month(), "100.00")
_set_param(client, admin["headers"], "manager.personal.included", "0")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
assert team["member_count"] == 0
assert all(u["personnel_id"] != manager["id"] for u in data["unassigned"])
def test_部门汇总_按经理筛选不误判其他团队下属(admin, client: TestClient):
"""筛选只影响展示的团队;其他经理的下属不能进入未分配。"""
manager_1 = _person(client, admin["headers"], role="sales_manager")
manager_2 = _person(client, admin["headers"], role="sales_manager")
salesperson_1 = _person(client, admin["headers"], manager_id=manager_1["id"])
salesperson_2 = _person(client, admin["headers"], manager_id=manager_2["id"])
_full_chain(client, admin["headers"], owner_id=salesperson_1["id"])
_full_chain(client, admin["headers"], owner_id=salesperson_2["id"])
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
assert data["unassigned"] == []
assert all(
m["personnel_id"] != salesperson_2["id"]
for team in data["teams"]
for m in team["members"]
)
assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0
def test_部门汇总_对账恒等式(admin, client: TestClient):
"""未筛选:团队基数 + 未分配绩效 = 全公司基数;应发公式成立。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
unassigned = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=salesperson["id"])
_full_chain(client, admin["headers"], owner_id=unassigned["id"])
_insert_adjustment(salesperson, _month(), "200.00")
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
reconciliation = resp.json()["data"]["reconciliation"]
assert Decimal(str(reconciliation["team_base_total"])) + Decimal(
str(reconciliation["unassigned_performance_total"])
) == Decimal(str(reconciliation["company_base"]))
assert Decimal(str(reconciliation["other_team_base"])) == Decimal("0")
assert Decimal(str(reconciliation["payable_total"])) == (
Decimal(str(reconciliation["team_member_payable_total"]))
+ Decimal(str(reconciliation["unassigned_payable_total"]))
+ Decimal(str(reconciliation["aggregate_payable_total"]))
+ Decimal(str(reconciliation["adjust_net_total"]))
)
# 分列合计 + 舍入差 = 聚合额(月结一次舍入口径)
assert Decimal(str(reconciliation["aggregate_rounding_diff"])) == (
Decimal(str(reconciliation["aggregate_payable_total"]))
- Decimal(str(reconciliation["team_share_total"]))
- Decimal(str(reconciliation["company_share_total"]))
)
def _two_manager_scene(
client: TestClient, headers: dict
) -> tuple[dict, dict, dict, dict, dict]:
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
manager_1 = _person(client, headers, role="sales_manager")
manager_2 = _person(client, headers, role="sales_manager")
empty_manager = _person(client, headers, role="sales_manager")
salesperson_1 = _person(client, headers, manager_id=manager_1["id"])
salesperson_2 = _person(client, headers, manager_id=manager_2["id"])
_full_chain(client, headers, owner_id=salesperson_1["id"])
_full_chain(client, headers, owner_id=salesperson_2["id"])
return manager_1, manager_2, empty_manager, salesperson_1, salesperson_2
def test_部门汇总_应发合计与月结对拍_两位经理(admin, client: TestClient):
"""月结每位有聚合行的经理各计一次 5%;签字稿应发必须等于 total_payable。"""
_two_manager_scene(client, admin["headers"])
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
before = resp.json()["data"]
assert before["company"]["company_share_count"] == 2
assert Decimal(str(before["company"]["company_share_total"])) == (
Decimal(str(before["company"]["company_share_amount"])) * 2
)
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement = resp.json()["data"]
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
teams = {team["manager_id"]: team for team in data["teams"]}
frozen_total = sum(
(
Decimal(str(team["frozen_amount"]))
for team in teams.values()
if team["frozen_amount"] is not None
),
Decimal("0"),
)
reconciliation = data["reconciliation"]
assert Decimal(str(reconciliation["aggregate_payable_total"])) == frozen_total
assert Decimal(str(reconciliation["payable_total"])) == Decimal(
str(settlement["total_payable"])
)
def test_部门汇总_有调整单时应发等于月结加调整(admin, client: TestClient):
"""调整单不进月结 items;报表应发 = 月结总额 + 调整净额。"""
_, _, _, salesperson, _ = _two_manager_scene(client, admin["headers"])
_insert_adjustment(salesperson, _month(), "500.00")
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement = resp.json()["data"]
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
reconciliation = resp.json()["data"]["reconciliation"]
assert Decimal(str(reconciliation["adjust_net_total"])) == Decimal("500.00")
assert Decimal(str(reconciliation["payable_total"])) == (
Decimal(str(settlement["total_payable"])) + Decimal("500.00")
)
def test_部门汇总_按经理筛选时5百分比只计该团队(admin, client: TestClient):
"""筛选态只汇总展示团队;隐藏团队的金额进 other_*,不参与应发。"""
manager_1, _, _, _, _ = _two_manager_scene(client, admin["headers"])
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert data["company"]["company_share_count"] == 1
reconciliation = data["reconciliation"]
assert Decimal(str(reconciliation["other_team_aggregate_total"])) > 0
assert Decimal(str(reconciliation["other_team_company_share_total"])) > 0
def test_导出_筛选页眉ID转名称与中文标签(admin, client: TestClient):
"""页眉是签字稿追溯筛选范围的唯一凭据,ID 必须转名称。"""
from io import BytesIO
from openpyxl import load_workbook
from app.core.db import get_session_factory
from app.models import BusinessRole
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
factory = get_session_factory()
with factory() as session:
role_name = (
session.query(BusinessRole.role_name)
.filter(BusinessRole.role_code == "salesperson")
.scalar()
)
resp = client.get(
f"{_REPORTS}/personal-detail/export",
params={
"settlement_month": _month(),
"personnel_id": person["id"],
"role_code": "salesperson",
"customer_type": "personal",
"product_category": "municipal_water",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
workbook = load_workbook(BytesIO(resp.content))
text = "\n".join(
str(cell.value)
for row in workbook.active.iter_rows()
for cell in row
if cell.value is not None
)
assert person["personnel_name"] in text
assert f"人员 {person['personnel_name']}(#{person['id']})" in text
assert "个人户" in text
assert "市政水利" in text
assert role_name in text
def test_导出_回款维度sheet透传筛选条件(admin, client: TestClient):
"""两个维度 sheet 的页眉不能固定写「全部」。"""
from io import BytesIO
from openpyxl import load_workbook
resp = client.get(
f"{_REPORTS}/payment-stats/export",
params={"customer_type": "personal"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
workbook = load_workbook(BytesIO(resp.content))
for sheet_name in ("客户类型对比", "产品类别对比"):
text = "\n".join(
str(cell.value)
for row in workbook[sheet_name].iter_rows()
for cell in row
if cell.value is not None
)
assert "客户类型 个人户" in text
assert "筛选条件:全部" not in text
def test_导出超限_失败审计留痕(admin, client: TestClient, monkeypatch):
"""超限 400 发生在审计上下文内,必须留下失败记录。"""
from app.services import report_service
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
monkeypatch.setattr(report_service, "REPORT_EXPORT_MAX_ROWS", 0)
resp = client.get(
f"{_REPORTS}/personal-detail/export",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
log = _latest_export_log()
assert log is not None
assert log.success == 0
assert "超过上限" in (log.error_message or "")
def test_导出取数异常_失败审计留痕(admin, client: TestClient, monkeypatch):
"""取数阶段的异常同样要留下失败记录。
TestClient 默认重抛服务端异常,这里直接调 service 层,聚焦审计落库。
"""
from app.core.db import get_session_factory
from app.models import User
from app.schemas.report import PersonalDetailQuery
from app.services import report_service
def _boom(*args, **kwargs):
raise RuntimeError("t_取数异常")
monkeypatch.setattr(report_service, "_export_payload", _boom)
factory = get_session_factory()
with factory() as session:
operator = session.get(User, admin["id"])
with pytest.raises(RuntimeError, match="t_取数异常"):
report_service.export_report(
session,
"personal-detail",
PersonalDetailQuery(settlement_month=_month()),
operator=operator,
)
log = _latest_export_log()
assert log is not None
assert log.success == 0
# 未预期异常按安全口径收敛为通用文案,摘要仍能定位到报表
assert "导出个人绩效工资明细表" in (log.summary or "")
@pytest.mark.parametrize(
"path", ["/personal-detail", "/department-summary", "/payment-stats"]
)
def test_非法月份_查询返回400(admin, client: TestClient, path: str):
"""非法月份必须是参数错误 400,不能落 500。"""
resp = client.get(
f"{_REPORTS}{path}",
params={"settlement_month": "2026-13"},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
@pytest.mark.parametrize(
"code", ["personal-detail", "department-summary", "payment-stats"]
)
def test_非法月份_导出返回400(admin, client: TestClient, code: str):
"""导出手工构造查询模型同样要转 400。"""
resp = client.get(
f"{_REPORTS}/{code}/export",
params={"settlement_month": "2026-13"},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
@@ -749,3 +749,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,55 @@
"""月份校验单元测试。
守的口径:报表、月结、调整单入口都必须拦住 `2026-13`、`0000-01` 这类
非法月份。只校验长度会让 ValueError 一路传到 `calendar.monthrange`,
最终变成 500 而不是参数错误 400。
"""
import pytest
from pydantic import ValidationError
from app.core.month import parse_month, validate_month
from app.schemas.report import DepartmentSummaryQuery, PersonalDetailQuery
from app.schemas.settlement import AdjustmentCreateIn, SettlementGenerateIn
class Test月份校验:
"""合法月份通过,非法月份在入口即拒绝。"""
@pytest.mark.parametrize("month", ["2026-01", "2026-12", "2000-06"])
def test_合法月份_通过(self, month: str):
assert validate_month(month) == month
year, mon = parse_month(month)
assert (year, mon) == (int(month[:4]), int(month[5:7]))
@pytest.mark.parametrize(
"month",
["2026-13", "2026-00", "2026-1", "2026-1a", "0000-01", "abc", "2026/10"],
)
def test_非法月份_抛ValueError(self, month: str):
with pytest.raises(ValueError):
validate_month(month)
@pytest.mark.parametrize("month", ["2026-13", "2026-00", "0000-01"])
def test_月结与调整单_schema拒绝非法月份(self, month: str):
with pytest.raises(ValidationError):
SettlementGenerateIn(month=month)
with pytest.raises(ValidationError):
AdjustmentCreateIn(
settlement_month=month,
personnel_id=1,
direction=1,
amount="100.00",
reason="测试",
)
@pytest.mark.parametrize("month", ["2026-13", "2026-00", "0000-01"])
def test_报表查询_schema拒绝非法月份(self, month: str):
with pytest.raises(ValidationError):
DepartmentSummaryQuery(settlement_month=month)
with pytest.raises(ValidationError):
PersonalDetailQuery(settlement_month=month)
def test_报表可选月份_空串规范为None(self):
"""axios 未剔除空串时不能误报格式错误。"""
assert PersonalDetailQuery(settlement_month="").settlement_month is None
+8 -4
View File
@@ -18,8 +18,9 @@ from app.services.param_registry import (
validate_and_normalize,
)
# 与 db/migration 迁移种子保持一致的 19 项编码(V3 十六项 + V6 审批开关两项
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12)
# 与 db/migration 迁移种子保持一致的 22 项编码(V3 十六项 + V6 审批开关两项
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
# V10 增工作台预警规则三项 #10)
EXPECTED_CODES = {
"salesperson.rate.start",
"salesperson.rate.slope",
@@ -40,14 +41,17 @@ EXPECTED_CODES = {
"approval.monthly_settlement.enabled",
"approval.adjustment.enabled",
"manager.personal.included",
"warning.enabled",
"warning.receipt.overdue.days",
"warning.approval.timeout.days",
}
class TestRegistry:
"""注册表元数据完整性(与迁移种子同源)。"""
def test_注册表共19项(self) -> None:
assert len(PARAM_REGISTRY) == 19
def test_注册表共22项(self) -> None:
assert len(PARAM_REGISTRY) == 22
def test_编码集合与迁移种子一致(self) -> None:
assert set(PARAM_REGISTRY) == EXPECTED_CODES
+422
View File
@@ -0,0 +1,422 @@
"""报表辅助纯函数与 Excel 模板构建单元测试(M4 ⑤)。
这组用例守的是「不测会静默出错」的三类点:
- 区间分桶边界错一档 → 金额进错桶,但报表仍然「能打开」,没人会发现
- 标签映射漏一个枚举值 → 报表少一类数据或显示英文编码
- 导出模板缺签字栏 / 金额被转 float / 文本公式注入 → 交给财务的签字稿失真
"""
import io
from datetime import date
from decimal import Decimal
from openpyxl import load_workbook
from app.core import report_meta
from app.core.constants import CUSTOMER_TYPES, PRODUCT_CATEGORIES
from app.services import report_export
class Test区间分桶:
"""回款天数 → 8 档区间(含 ≤0 与无天数两个兜底桶)。"""
def test_边界值全部落入正确区间(self):
cases = [
(None, "none"),
(-5, "le0"),
(0, "le0"),
(1, "1-30"),
(30, "1-30"),
(31, "31-60"),
(60, "31-60"),
(61, "61-90"),
(90, "61-90"),
(91, "91-180"),
(180, "91-180"),
(181, "181-360"),
(360, "181-360"),
(361, "361+"),
(1000, "361+"),
]
for days, code in cases:
assert report_meta.bucket_of(days).code == code, f"days={days}"
def test_八档且编码唯一(self):
buckets = report_meta.get_payment_day_buckets()
assert len(buckets) == 8
assert len({b.code for b in buckets}) == 8
class Test标签覆盖:
"""标签键集合必须覆盖业务常量——防常量新增后报表静默少算/显示编码。"""
def test_客户类型标签覆盖全部类型(self):
assert set(report_meta.CUSTOMER_TYPE_LABELS) >= CUSTOMER_TYPES
def test_产品类别标签覆盖常量与混合业务(self):
assert set(report_meta.PRODUCT_CATEGORY_LABELS) >= PRODUCT_CATEGORIES
assert "mixed" in report_meta.PRODUCT_CATEGORY_LABELS
def _build(code: str, rows: list[dict], buckets: dict | None = None) -> bytes:
return report_export.build_workbook(
code=code,
title="测试报表",
filters="统计月份 2026-10",
rows=rows,
summary=None,
buckets=buckets,
template_version="V1.0",
operator_name="admin",
company_name="衍水集团",
)
def _sheet_text(content: bytes) -> str:
workbook = load_workbook(io.BytesIO(content))
return "\n".join(
str(cell.value)
for row in workbook.active.iter_rows()
for cell in row
if cell.value is not None
)
def _personal_row(**overrides) -> dict:
row = {
"business_no": "YW001",
"ship_date": date(2026, 10, 1),
"receipt_date": date(2026, 10, 20),
"payment_days": 20,
"product_category": "municipal_water",
"customer_type": "personal",
"role_name": "营销员",
"rate_text": "88/9000",
"weight_r": 100,
"performance_amount": Decimal("39.11"),
"payable_amount": Decimal("39.11"),
}
row.update(overrides)
return row
class TestExcel模板:
"""导出稿是交给财务复核与领导签字的,结构缺失等同于没有模板。"""
def test_页眉标题签字栏齐全(self):
text = _sheet_text(_build("personal-detail", [_personal_row()]))
assert "衍水集团" in text
assert "测试报表" in text
assert "统计月份 2026-10" in text
assert "模板版本:V1.0" in text
assert "财务复核" in text
assert "领导签字" in text
def test_空数据不炸且给提示行(self):
text = _sheet_text(_build("personal-detail", []))
assert "本筛选条件无数据" in text
assert "财务复核" in text
def test_金额为数值单元格且两位格式(self):
# openpyxl 写入 Decimal 后读回为 int/float(Excel 单元格本身是数值),
# 这里断言「是数值而非文本」+ 显示格式;计算精度在服务层 Decimal 侧保证
content = _build("personal-detail", [_personal_row()])
workbook = load_workbook(io.BytesIO(content))
sheet = workbook.active
amount_cells = [
cell
for row in sheet.iter_rows()
for cell in row
if isinstance(cell.value, (int, float))
and Decimal(str(cell.value)) == Decimal("39.11")
]
assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)"
assert amount_cells[0].number_format == "#,##0.00"
def test_合计行等于明细之和且标签只出现一次(self):
rows = [
_personal_row(performance_amount=Decimal("10.00"), payable_amount=Decimal("10.00")),
_personal_row(performance_amount=Decimal("5.50"), payable_amount=Decimal("5.50")),
]
content = _build("personal-detail", rows)
workbook = load_workbook(io.BytesIO(content))
cells = [cell for row in workbook.active.iter_rows() for cell in row]
values = [
Decimal(str(cell.value))
for cell in cells
if isinstance(cell.value, (int, float))
]
assert Decimal("15.50") in values
# 「合计」标签只写一次(写满每个文本列是回归缺陷)
assert sum(1 for cell in cells if cell.value == "合计") == 1
def test_文本公式注入被前置单引号(self):
evil = "=HYPERLINK(\"http://evil\")"
content = _build("personal-detail", [_personal_row(business_no=evil)])
workbook = load_workbook(io.BytesIO(content))
values = [
cell.value
for row in workbook.active.iter_rows()
for cell in row
if isinstance(cell.value, str)
]
assert any(value.startswith("'=") for value in values), "公式注入未转义"
def test_回款统计附两个维度对比sheet(self):
buckets = {
"by_customer_type": [
{
"label": "个人户",
"receipt_amount": Decimal("100.00"),
"performance_amount": Decimal("1.00"),
"payable_amount": Decimal("1.00"),
"ledger_count": 1,
}
],
"by_product_category": [],
}
content = _build(
"payment-stats",
[
{
"label": "1-30 天",
"receipt_amount": Decimal("100.00"),
"receipt_count": 1,
"performance_amount": Decimal("1.00"),
"payable_amount": Decimal("1.00"),
"ledger_count": 1,
}
],
buckets=buckets,
)
workbook = load_workbook(io.BytesIO(content))
assert workbook.sheetnames == ["回款统计", "客户类型对比", "产品类别对比"]
def test_业务统计状态转中文(self):
rows = [
{
"business_no": "YW001",
"business_date": date(2026, 10, 1),
"customer_name": "测试客户",
"owner_personnel_name": "张三",
"status": 2,
"total_sale_amount": Decimal("100.00"),
"total_ship_amount": Decimal("100.00"),
"total_received_amount": Decimal("100.00"),
"performance_amount": Decimal("1.00"),
"payable_amount": Decimal("1.00"),
}
]
text = _sheet_text(_build("business-stats", rows))
assert "已完成" in text
def _member(
personnel_id: int,
name: str,
*,
kind: str = "regular",
performance: str = "0.00",
payable: str = "0.00",
adjust: str = "0.00",
reason: str | None = None,
) -> dict:
return {
"personnel_id": personnel_id,
"personnel_name": name,
"role_code": "salesperson",
"is_probation": 0,
"member_kind": kind,
"unassigned_reason": reason,
"performance_sum": Decimal(performance),
"payable_sum": Decimal(payable),
"adjust_extra": Decimal(adjust),
"adjust_deduct": Decimal("0"),
"adjust_net": Decimal(adjust),
"net_total": Decimal(payable) + Decimal(adjust),
"ledger_ids": [],
}
def _department_data(
team_text: str = "12.5",
company_text: str = "5",
*,
second_has_aggregate: bool = False,
) -> dict:
"""一份含成员、零成员团队与未分配人员的部门汇总出参。
``second_has_aggregate`` 打开后两位经理都有月结聚合行,用于验证
全公司 5% 按人数累计(零成员团队默认不发 5%)。
"""
second_aggregate = Decimal("600.00") if second_has_aggregate else Decimal("0")
share_count = 2 if second_has_aggregate else 1
aggregate_total = Decimal("1850.00") + second_aggregate
payable_total = Decimal("10000.00") + Decimal("2000.00") + aggregate_total
return {
"settlement_month": "2026-10",
"params": {
"team_text": team_text,
"company_text": company_text,
"manager_personal_included": 0,
"params_as_of": "2026-10-31",
},
"teams": [
{
"manager_id": 1,
"manager_name": "张经理",
"manager_status": 1,
"member_count": 1,
"team_base": Decimal("10000.00"),
"team_share_amount": Decimal("1250.00"),
"company_share_amount": Decimal("600.00"),
"has_aggregate": True,
"aggregate_amount": Decimal("1850.00"),
"team_subtotal": Decimal("1850.00"),
"company_base_ratio": Decimal("0.8333"),
"adjust_extra": Decimal("0"),
"adjust_deduct": Decimal("0"),
"adjust_net": Decimal("0"),
"net_total": Decimal("1850.00"),
"frozen_amount": None,
"aggregate_ledger_id": None,
"members": [
_member(
2,
"李业务",
performance="10000.00",
payable="10000.00",
)
],
},
{
"manager_id": 3,
"manager_name": "王经理",
"manager_status": 1,
"member_count": 0,
"team_base": Decimal("0"),
"team_share_amount": Decimal("0"),
"company_share_amount": second_aggregate,
"has_aggregate": second_has_aggregate,
"aggregate_amount": second_aggregate,
"team_subtotal": second_aggregate,
"company_base_ratio": Decimal("0"),
"adjust_extra": Decimal("0"),
"adjust_deduct": Decimal("0"),
"adjust_net": Decimal("0"),
"net_total": second_aggregate,
"frozen_amount": None,
"aggregate_ledger_id": None,
"members": [],
},
],
"unassigned": [
_member(
4,
"赵未分配",
performance="2000.00",
payable="2000.00",
reason="无经理或上级非营销经理",
)
],
"company": {
"team_base_total": Decimal("10000.00"),
"company_base": Decimal("12000.00"),
"company_share_pct": company_text,
"company_share_amount": Decimal("600.00"),
"company_share_count": share_count,
"company_share_total": Decimal("600.00") * share_count,
},
"reconciliation": {
"team_member_payable_total": Decimal("10000.00"),
"unassigned_payable_total": Decimal("2000.00"),
"team_share_total": Decimal("1250.00"),
"company_share_total": Decimal("600.00") * share_count,
"aggregate_payable_total": aggregate_total,
"aggregate_rounding_diff": Decimal("0"),
"adjust_net_total": Decimal("0"),
"payable_total": payable_total,
"team_base_total": Decimal("10000.00"),
"unassigned_performance_total": Decimal("2000.00"),
"other_team_base": Decimal("0"),
"other_team_member_payable_total": Decimal("0"),
"other_team_share_total": Decimal("0"),
"other_team_company_share_total": Decimal("0"),
"other_team_aggregate_total": Decimal("0"),
"other_adjust_net_total": Decimal("0"),
"company_base": Decimal("12000.00"),
},
"totals": {},
}
def _build_department(data: dict) -> bytes:
return report_export.build_workbook(
code="department-summary",
title="部门绩效工资汇总表",
filters="统计月份 2026-10",
rows=[],
summary=None,
buckets=None,
department=data,
template_version="V1.2",
operator_name="admin",
company_name="衍水集团",
)
class Test部门汇总签字稿:
"""签字稿少了团队分成/5% 块/未分配/零成员团队,合计就会小于应发。"""
def test_五块顺序完整(self):
text = _sheet_text(_build_department(_department_data()))
titles = [
"一、团队分成汇总",
"二、全公司分成(每位有聚合行",
"三、对账汇总",
"四、团队成员明细",
"五、未分配人员明细",
]
positions = [text.index(title) for title in titles]
assert positions == sorted(positions), "签字稿块顺序错乱"
assert "财务复核" in text and "领导签字" in text
def test_零成员团队与未分配行存在(self):
text = _sheet_text(_build_department(_department_data()))
assert "王经理" in text
assert "赵未分配" in text
assert "无经理或上级非营销经理" in text
def test_全公司分成按计入次数累计(self):
"""两位有聚合行的经理各计一次 5%;零成员团队不产生分成。"""
one = _department_data()
text = _sheet_text(_build_department(one))
assert text.count("全公司分成(每位有聚合行") == 1
assert "共 1 次" in text
assert "13850" in text
two = _department_data(second_has_aggregate=True)
text = _sheet_text(_build_department(two))
assert "共 2 次" in text
# 团队成员应付 10000 + 未分配 2000 + 聚合 2450
assert "14450" in text
def test_应发合计取对账口径(self):
text = _sheet_text(_build_department(_department_data()))
assert "应发合计" in text
# Excel 数值单元格读回为 int/float,金额断言只看数值本身
assert "13850" in text
def test_聚合行舍入差单列(self):
data = _department_data()
data["reconciliation"]["aggregate_rounding_diff"] = Decimal("-0.02")
text = _sheet_text(_build_department(data))
assert "聚合行舍入差" in text
assert "-0.02" in text
def test_比例标题参数化(self):
text = _sheet_text(_build_department(_department_data("20", "6")))
assert "团队分成(20%)" in text
assert "6%" in text
assert "12.5%" not in text
assert "5%" not in text.replace("6%", "")
+11 -3
View File
@@ -455,7 +455,11 @@ INSERT INTO param_definitions (id, param_code, param_name, param_group, value_ty
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17),
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18),
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19),
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20);
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20),
-- 预警规则参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21),
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22),
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
(1, 1, 'salesperson', '2026-02-17', '10', 1),
@@ -481,7 +485,11 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
(20, 17, '', '2026-01-01', '1', 1),
(21, 18, '', '2026-01-01', '1', 1),
(22, 19, '', '2026-01-01', '0', 1),
(25, 20, '', '2026-01-01', '0', 1);
(25, 20, '', '2026-01-01', '0', 1),
-- 预警规则参数生效值(M4 ⑧)
(26, 21, '', '2026-10-19', '1', 1),
(27, 22, '', '2026-10-19', '90', 1),
(28, 23, '', '2026-10-19', '3', 1);
-- ---------------------------------------------------------------------------
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
@@ -874,4 +882,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 ('ba0217f5091b');
INSERT INTO alembic_version (version_num) VALUES ('90653814a421');
+16
View File
@@ -0,0 +1,16 @@
-- Running upgrade ba0217f5091b -> 90653814a421
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21, 1);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22, 1);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23, 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (26, 21, '', '2026-10-19', '1', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (27, 22, '', '2026-10-19', '90', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (28, 23, '', '2026-10-19', '3', 1);
UPDATE alembic_version SET version_num='90653814a421' WHERE alembic_version.version_num = 'ba0217f5091b';
@@ -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` 最新运行记录为准)。
@@ -42,7 +42,11 @@
## 二、后端任务
### T1 常量与元数据(`core/constants.py` 改 + `services/report_meta.py` 新)
### T1 常量与元数据(`core/constants.py` 改 + `core/report_meta.py` 新)
> 实施修订(2026-10-19):`report_meta` 放在 `core/` 而非 `services/`——仓储层
> 的区间下钻需要 `DayBucket` 类型,放 services 会让 repository 反向依赖
> service 层(违反六层红线)。
- `MODULE_REPORT = "报表统计"`、`ACTION_EXPORT = "导出"`
- `report_meta.py`(叶子模块,禁 app 内导入防循环依赖):`DayBucket` frozen dataclass + `PAYMENT_DAY_BUCKETS` 8 档(≤0 天(先款后货)/1-30/31-60/61-90/91-180/181-360/361+/无天数)、`CUSTOMER_TYPE_LABELS`、`PRODUCT_CATEGORY_LABELS`(含 mixed)、`REPORT_TEMPLATE_VERSION = "V1.0"`、`REPORT_EXPORT_MAX_ROWS = 20000`
+7 -6
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 答复仍空白)。
## 一、立即待办(需项目内处理)
@@ -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 项)
@@ -83,7 +84,7 @@
| 里程碑 | 内容 | 前置条件 |
| --- | --- | --- |
| 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 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
@@ -92,6 +93,6 @@
## 五、文档待办(低优)
- 需规过时条款未修订(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.4**(2026-10-19,本节口径待收编;生成脚本 build_tplus_xlsx.py 已对齐 v1.3、待对齐 v1.4)
- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)**
- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可
+4 -4
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 项)
@@ -35,7 +35,7 @@
### 已按默认执行、无需回复的项(供核阅)
#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+ 侧(建议回复期限:____)
Binary file not shown.
+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 遗留的方案版本承载与审批单快照;需规 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 快照列 + 前端详情展示)。
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 链路),剩余工作以外部输入与审计性技术债为主,可按计划推进。
+23 -3
View File
@@ -188,15 +188,16 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
| 页面 | 说明 |
| --- | --- |
| `/reports/personal-detail` 个人绩效明细(新组「报表统计」order19) | 人员/月份/客户类型/产品类别/岗位筛选;需规 2.4.1 全部列;行展开懒加载台账快照;页脚**全筛选合计**(注明非当前页);导出/打印 |
| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司 5% 独立卡片;团队表(基数/12.5%/5%/占比/调整/净合计/月结冻结额);展开成员 → 跨页下钻个人明细;未分配桶 |
| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司分成独立卡片(单经理 5% × 计入人数 = 合计,每位有聚合行的经理各计一次);团队表(基数/团队分成/全公司分成/占比/调整/净合计/月结冻结额),净合计与月结聚合行同口径,比例列头随在线参数变化;展开成员 → 跨页下钻个人明细(人员与月份带入筛选);未分配桶(按经理筛选时为空) |
| `/reports/business-stats` 业务统计(order21) | 关键字/客户/归属人/状态/业务日期;发货额/回款额/绩效(累计口径提示);展开下钻回款与台账明细 |
| `/reports/payment-stats` 回款统计(order22) | 月份/回款日期/客户类型/产品类别;8 档区间表(空桶 0,档位由后端下发);展开桶下钻分页明细;客户类型/产品类别对比表 |
| `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(上限 2000 行截断提示);`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 |
| `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(以接口 total 判定超过 2000 行并提示截断);部门汇总按「团队分成 → 全公司 5% → 对账 → 成员明细 → 未分配」五块渲染;`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 |
- 路由组「报表统计」四页 `allowedRoles: [admin, finance, leader]`(前端菜单 + 后端 `require_roles` 双层;admin 须显式列入)。
- api 封装 `src/api/reports.ts`(类型 + 查询 + `exportReport` 裸 axios blob;产品类别标签额外覆盖 `mixed`)。
- 权限:营销后勤「授权范围内」待业务权限清单 #6,当前不开放报表。
- type-check 与 build 通过(2026-10-19)。e2e `report_flow.py` 待补(需 `E2E_ADMIN_PASSWORD` 环境)。
- 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 台账与月结)
@@ -210,3 +211,22 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
② 三页均无 `onMounted` 初始加载 → 进页不取数;补 `onMounted(() => void load())`。
③ 三页 `AppPagination` 用 `v-model:page-num` + `@change`(组件无该事件、也未实现 `update:*`)→ 翻页失效;统一为 `:page-num/:page-size` + `@page-change/@size-change`(usePagedList 的 onPageChange/onSizeChange)。
**注意**:e2e 与 `pytest tests/integration` **不可并发**——conftest 每用例整体清空开发库,会清掉 e2e 造的数据造成假失败(本次排查耗时即源于此,两次假失败的报错均为「台账行消失 / 401 登录失效」)。
## 前端页面(2026-10-19 追加 · M4 ⑧ 工作台)
`/dashboard` 由 Bento 占位升级为工作台四区块(**一次聚合请求** `/api/v1/dashboard`):
| 区块 | 展示 |
| --- | --- |
| 经营绩效总览 | KPI 四卡(本月绩效/应付/核算人数/团队数 + 月结状态);近 6 个月绩效折线 + 回款柱状(ECharts) |
| 待办事项 | 待审批(跳审批管理)、回款未指定批次(跳回款管理)、T+ 同步失败(未启用显式占位) |
| 风险预警 | 回款逾期(danger)/ 审批超时(warning),点击跳处理页;总开关关闭时显示参数提示 |
| 最近动态 | 操作日志时间线,按模块本地筛选 |
- **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)
+432
View File
@@ -0,0 +1,432 @@
"""报表统计端到端验证(M4 ⑤:四张报表 + 打印视图)。
覆盖真实浏览器链路(接口测试守不住的部分):
- 「报表统计」组四页菜单可见、可进入
- 个人明细:API 预置的已核算业务出现在列表;导出触发真实下载
- 部门汇总:经理团队行出现且团队基数非 0;全公司 5% 卡片可见
- 业务统计:关键字筛选命中;三列统计非空
- 回款统计:8 档区间表;目标桶(61-90 天)有金额;空桶显示 0
- 打印视图:独立路由带出报表与签字栏;window.print 被调用
前置(缺一不可):
docker compose up -d
cd backend && uv run uvicorn app.main:app --port 8000
cd frontend && pnpm dev
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/report_flow.py
数据说明:核算后数据不可删(业务规则),按 `e2e_` 前缀留档开发库;
每次运行用随机后缀隔离,断言以本次业务编号定位。
"""
import json
import os
import time
import urllib.error
import urllib.request
from playwright.sync_api import Page, sync_playwright
from common import (
BASE_URL,
E2EFailure,
collect_console_errors,
current_path,
fail,
login,
)
ADMIN = "admin"
SUFFIX = f"{int(time.time())}"
console_errors: list[str] = []
MONTH = time.strftime("%Y-%m")
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 step_login(page: Page, admin_password: str) -> None:
login(page, ADMIN, admin_password)
print("✓ 1. 登录")
def step_setup_via_api(page: Page) -> dict:
"""预置:经理 + 业务员 → 客户/业务 → 发货(60 天前)→ 回款 → 核算。"""
token = page.evaluate("localStorage.getItem('ys_token')")
if not token:
fail("API 预置", "localStorage 中未取到 ys_token")
roles = _api("GET", "/api/v1/system/business-roles?pageNum=1&pageSize=50", token)["list"]
salesperson_role = next((r["id"] for r in roles if r["role_code"] == "salesperson"), None)
manager_role = next((r["id"] for r in roles if r["role_code"] == "sales_manager"), None)
if salesperson_role is None or manager_role is None:
fail("API 预置", "未找到 salesperson / sales_manager 岗位种子")
manager = _api(
"POST",
"/api/v1/base/personnel",
token,
{
"personnel_name": f"e2e_报表现经理_{SUFFIX}",
"business_role_id": manager_role,
"salary_standard": "3000.00",
},
)
person = _api(
"POST",
"/api/v1/base/personnel",
token,
{
"personnel_name": f"e2e_报表现业务员_{SUFFIX}",
"business_role_id": salesperson_role,
"salary_standard": "3000.00",
"manager_id": manager["id"],
},
)
customer = _api(
"POST",
"/api/v1/base/customers",
token,
{
"company_name": f"e2e_报表客户_{SUFFIX}",
"owner_name": "张老板",
"customer_type": "personal",
},
)
business = _api(
"POST",
"/api/v1/business/businesses",
token,
{
"customer_id": customer["id"],
"business_date": f"{MONTH}-01",
"owner_personnel_id": person["id"],
"items": [
{
"product_name": f"e2e_报表产品_{SUFFIX}",
"product_category": "municipal_water",
"quantity": "100",
"sale_price": "100.00",
"cost_amount": "3000.00",
}
],
},
)
from datetime import date, timedelta
ship_date = (date.today() - timedelta(days=60)).isoformat()
shipment = _api(
"POST",
"/api/v1/business/shipments",
token,
{
"business_id": business["id"],
"ship_date": ship_date,
"items": [{"product_name": "e2e_报表发货", "quantity": "1", "sale_price": "1.00"}],
},
)
receipt = _api(
"POST",
"/api/v1/business/receipts",
token,
{
"business_id": business["id"],
"receipt_date": date.today().isoformat(),
"amount": "10000.00",
"shipment_id": shipment["id"],
},
)
_api("POST", f"/api/v1/calc/receipts/{receipt['id']}/calc", token)
print(f"✓ 2. API 预置 业务 {business['business_no']} 已核算")
return {
"month": MONTH,
"business_no": business["business_no"],
"personnel_id": person["id"],
"personnel_name": person["personnel_name"],
"manager_name": manager["personnel_name"],
}
def step_personal_detail(page: Page, ctx: dict) -> None:
page.goto(f"{BASE_URL}/reports/personal-detail", wait_until="networkidle")
page.wait_for_timeout(800)
if current_path(page) != "/reports/personal-detail":
fail("导航", f"未进入个人绩效明细页,当前 {page.url}")
# 月份筛选:Element Plus 月份选择器可直接键入 YYYY-MM
month_input = page.locator(".filters input").first
month_input.fill(ctx["month"])
month_input.press("Enter")
page.locator(".filters button:has-text('查询')").first.click()
page.wait_for_timeout(900)
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
if row.count() == 0:
fail("个人明细", f"未找到业务 {ctx['business_no']} 的明细行")
print("✓ 3. 个人明细 列表命中本次核算业务")
with page.expect_download(timeout=20000) as download_info:
page.locator("button:has-text('导出 Excel')").first.click()
download = download_info.value
if not download.suggested_filename.endswith(".xlsx"):
fail("导出", f"下载文件名非 xlsx:{download.suggested_filename}")
print(f"✓ 4. 导出下载 {download.suggested_filename}")
def step_department_summary(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
if row.count() == 0:
fail("部门汇总", f"未找到经理 {ctx['manager_name']} 的团队行")
if page.locator(".company-card").count() == 0:
fail("部门汇总", "全公司 5% 卡片未渲染")
print("✓ 5. 部门汇总 团队行与全公司卡片可见")
def step_business_stats(page: Page, ctx: dict) -> None:
page.goto(f"{BASE_URL}/reports/business-stats", wait_until="networkidle")
page.wait_for_timeout(800)
keyword = page.locator(".filters input").first
keyword.fill(ctx["business_no"])
keyword.press("Enter")
page.locator(".filters button:has-text('查询')").first.click()
page.wait_for_timeout(800)
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
if row.count() == 0:
fail("业务统计", f"未找到业务 {ctx['business_no']}")
print("✓ 7. 业务统计 关键字筛选命中")
def step_payment_stats(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)
# 页面同时有区间表与两个维度对比表,必须限定主区间表
rows = page.locator('[data-testid="payment-bucket-table"] .el-table__row')
if rows.count() != 8:
fail("回款统计", f"区间表应为 8 行,实际 {rows.count()}")
bucket = page.locator(
'[data-testid="payment-bucket-table"] .el-table__row', has_text="61-90 天"
).first
if bucket.count() == 0:
fail("回款统计", "未找到 61-90 天区间行")
# 按本次业务编号在下钻明细中定位,不依赖桶聚合值(历史 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:
page.add_init_script("window.print = () => { window.__printed = true }")
page.goto(
f"{BASE_URL}/reports/print/personal-detail?settlement_month={ctx['month']}",
wait_until="networkidle",
)
page.wait_for_timeout(1200)
if page.locator(".sheet-table").count() == 0:
fail("打印视图", "报表表格未渲染")
text = page.locator(".print-page").inner_text()
if "财务复核" not in text or "领导签字" not in text:
fail("打印视图", "签字栏缺失")
page.locator("button:has-text('打印')").first.click()
page.wait_for_timeout(400)
if page.evaluate("window.__printed") is not True:
fail("打印视图", "window.print 未被调用")
print("✓ 10. 打印视图 表格/签字栏/打印调用均正常")
def run(page: Page) -> None:
collect_console_errors(page, console_errors)
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
if not admin_password:
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
step_login(page, admin_password)
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]})")
def main() -> int:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 1000})
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())
+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())
+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)
+1
View File
@@ -13,6 +13,7 @@
"dependencies": {
"@vue/devtools-api": "^8.1.5",
"axios": "^1.20.0",
"echarts": "^5.6.0",
"element-plus": "^2.14.7",
"pinia": "^4.0.3",
"vue": "^3.5.43",
+23
View File
@@ -14,6 +14,9 @@ importers:
axios:
specifier: ^1.20.0
version: 1.20.0(debug@4.4.3)
echarts:
specifier: ^5.6.0
version: 5.6.0
element-plus:
specifier: ^2.14.7
version: 2.14.7(vue@3.5.43(typescript@5.9.3))
@@ -399,6 +402,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
echarts@5.6.0:
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
element-plus@2.14.7:
resolution: {integrity: sha512-lrG7pZyCS+mpCNVpzxxyu5r35+T6V0hzkAeIwn7HA6to772XSTHKuYCZG51OfzlqaxGbNegLxs650zAF1j43gA==}
peerDependencies:
@@ -693,6 +699,9 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -828,6 +837,9 @@ packages:
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
zrender@5.6.1:
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
snapshots:
'@babel/helper-string-parser@7.29.7': {}
@@ -1155,6 +1167,11 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
echarts@5.6.0:
dependencies:
tslib: 2.3.0
zrender: 5.6.1
element-plus@2.14.7(vue@3.5.43(typescript@5.9.3)):
dependencies:
'@ctrl/tinycolor': 4.2.1
@@ -1435,6 +1452,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
tslib@2.3.0: {}
typescript@5.9.3: {}
ufo@1.6.4: {}
@@ -1521,3 +1540,7 @@ snapshots:
typescript: 5.9.3
webpack-virtual-modules@0.6.2: {}
zrender@5.6.1:
dependencies:
tslib: 2.3.0
+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,
+99
View File
@@ -0,0 +1,99 @@
import { request } from './request'
/** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */
export interface TrendPoint {
month: string
performance_total: string | null
receipt_total: string | null
}
export interface SettlementBrief {
exists: boolean
status: number | null
total_performance: string | null
total_payable: string | null
locked_at: string | null
}
export interface OverviewBlock {
month: string
performance_total: string | null
payable_total: string | null
person_count: number
team_count: number
settlement: SettlementBrief
trend: TrendPoint[]
note: string
}
export interface ApprovalTodoItem {
order_id: number
order_no: string
order_type: number
submitter_name: string | null
create_time: string
}
export interface ReceiptTodoItem {
receipt_id: number
receipt_batch_no: string
business_id: number
business_no: string | null
amount: string
receipt_date: string
}
export interface TodoBlock {
pending_approval_total: number
pending_approvals: ApprovalTodoItem[]
unassigned_receipt_total: number
unassigned_receipts: ReceiptTodoItem[]
sync_available: boolean
sync_failure_total: number
}
export interface WarningItem {
code: string
level: 'warning' | 'danger'
object_type: string
object_id: number
title: string
detail: string
suggest: string
link: string | null
}
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
}
export interface ActivityItem {
id: number
module: string
action: string
summary: string
username: string | null
success: number
create_time: string
}
export interface DashboardData {
month: string
overview: OverviewBlock
todos: TodoBlock
warnings: WarningBlock
activities: ActivityItem[]
}
export function getDashboard(params: { month?: string }): Promise<DashboardData> {
return request({ url: '/v1/dashboard', method: 'get', params })
}
+265
View File
@@ -0,0 +1,265 @@
import service, { request } from './request'
import type { PageData } from './types'
/** 报表分页:标准分页四字段 + 全筛选合计(summary 非当前页合计) */
export interface ReportSummary {
row_count: number
performance_total?: string | null
payable_total?: string | null
converted_profit_total?: string | null
ship_total?: string | null
received_total?: string | null
sale_total?: string | null
}
export interface ReportPage<T> extends PageData<T> {
summary: ReportSummary
}
/** 个人绩效明细行(金额/比例一律字符串,前端不做数值运算) */
export interface PersonalDetailRow {
ledger_id: number
business_id: number
business_no: string
personnel_id: number
personnel_name: string
role_code: string
role_name: string
customer_type: string
product_category: string
ship_date: string | null
receipt_date: string
payment_days: number | null
rate_text: string
weight_r: number | null
payment_weight: string | null
performance_amount: string | null
payable_amount: string | null
converted_profit: string | null
is_probation: number
missing_cost: number
calc_version: number
settlement_id: number | null
}
export interface DepartmentMemberRow {
personnel_id: number
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
adjust_deduct: string
adjust_net: string
net_total: string | null
ledger_ids: number[]
}
export interface DepartmentTeamRow {
manager_id: number
manager_name: string
manager_status: number
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
adjust_net: string
net_total: string
frozen_amount: string | null
aggregate_ledger_id: number | null
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: {
team_text: string
company_text: string
manager_personal_included: number
params_as_of: string
} | null
teams: DepartmentTeamRow[]
unassigned: DepartmentMemberRow[]
company: {
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>
}
export interface BusinessStatsRow {
business_id: number
business_no: string
business_date: string | null
status: number
customer_name: string | null
owner_personnel_name: string | null
total_sale_amount: string | null
total_ship_amount: string | null
total_received_amount: string | null
performance_amount: string | null
payable_amount: string | null
}
export interface BucketRow {
code: string
label: string
min_days: number | null
max_days: number | null
receipt_amount: string | null
receipt_count: number
performance_amount: string | null
payable_amount: string | null
ledger_count: number
}
export interface GroupRow {
code: string
label: string
receipt_amount: string | null
performance_amount: string | null
payable_amount: string | null
ledger_count: number
}
export interface PaymentStats {
settlement_month: string | null
buckets: BucketRow[]
by_customer_type: GroupRow[]
by_product_category: GroupRow[]
totals: Record<string, string | number>
}
export interface PaymentDetailRow {
ledger_id: number
receipt_id: number | null
receipt_batch_no: string | null
amount: string | null
business_id: number
business_no: string
personnel_id: number
personnel_name: string
customer_type: string
product_category: string
ship_date: string | null
receipt_date: string
payment_days: number | null
performance_amount: string | null
payable_amount: string | null
}
/** 报表查询通用筛选(各页按需取用) */
export interface ReportFilterParams {
pageNum?: number
pageSize?: number
settlement_month?: string
personnel_id?: number
customer_type?: string
product_category?: string
role_code?: string
manager_id?: number
keyword?: string
customer_id?: number
owner_personnel_id?: number
status?: number
date_from?: string
date_to?: string
bucket?: string
}
export function listPersonalDetail(
params: ReportFilterParams,
): Promise<ReportPage<PersonalDetailRow>> {
return request({ url: '/v1/reports/personal-detail', method: 'get', params })
}
export function getDepartmentSummary(params: ReportFilterParams): Promise<DepartmentSummary> {
return request({ url: '/v1/reports/department-summary', method: 'get', params })
}
export function listBusinessStats(
params: ReportFilterParams,
): Promise<ReportPage<BusinessStatsRow>> {
return request({ url: '/v1/reports/business-stats', method: 'get', params })
}
export function getPaymentStats(params: ReportFilterParams): Promise<PaymentStats> {
return request({ url: '/v1/reports/payment-stats', method: 'get', params })
}
export function listPaymentDetail(
params: ReportFilterParams,
): Promise<ReportPage<PaymentDetailRow>> {
return request({ url: '/v1/reports/payment-detail', method: 'get', params })
}
export function getPaymentDayBuckets(): Promise<
{ code: string; label: string; min_days: number | null; max_days: number | null }[]
> {
return request({ url: '/v1/reports/payment-day-buckets', method: 'get' })
}
/**
* 导出报表(文件流)。走 axios 实例(带令牌)而非 request 封装:响应是
* xlsx 文件流,不是统一信封,拦截器已对 responseType=blob 直通。
* 服务端文件名是 ASCII(HTTP 头 latin-1 限制),中文名由调用方 link.download 指定。
*/
export async function exportReport(
code: 'personal-detail' | 'department-summary' | 'business-stats' | 'payment-stats',
params: ReportFilterParams,
): Promise<Blob> {
const response = await service.get(`/v1/reports/${code}/export`, {
params,
responseType: 'blob',
})
return response.data as Blob
}
/** 产品类别展示名:报表需额外覆盖「混合业务」(台账行可为 mixed) */
export function reportCategoryLabel(code: string): string {
if (code === 'mixed') return '混合业务'
return { municipal_water: '市政水利', rail_transit: '轨道交通' }[code] ?? code
}
+35
View File
@@ -81,3 +81,38 @@ body {
.toolbar {
margin-bottom: 12px;
}
/*
* 确认框(ElMessageBox)统一视觉
*
* Element Plus 默认把 warning 图标渲染在正文左侧、与正文并排成两列:
* 多行正文时图标悬在文字块中间,左侧留出一列空白,标题行反而是空的;
* 实心黄圆也与本站「中性灰白 + 靛蓝点缀」体系冲突。
* 这里去掉调用处的 type,把图标改画在标题行左侧:18px 描边样式,
* 深琥珀 #b7791f + 浅琥珀底 #fbf3e4(警示语义,饱和度压到与靛蓝同级);
* 正文与标题文字左对齐,图标单独悬挂在左侧。图标为 CSS 伪元素,天然不进无障碍树,
* 属于装饰元素,语义由标题文字承载。
* MessageBox 挂载在 body 下,scoped 样式够不到,因此放在全局文件。
*/
.ys-confirm-box .el-message-box__title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--ys-ink);
}
.ys-confirm-box .el-message-box__title::before {
content: '';
flex: none;
width: 18px;
height: 18px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 18 18' fill='none'%3E%3Ccircle cx='9' cy='9' r='8.25' fill='%23FBF3E4'/%3E%3Ccircle cx='9' cy='9' r='7.4' stroke='%23B7791F' stroke-width='1.3'/%3E%3Cpath d='M9 5.3V9.8' stroke='%23B7791F' stroke-width='1.5' stroke-linecap='round'/%3E%3Ccircle cx='9' cy='12.4' r='0.95' fill='%23B7791F'/%3E%3C/svg%3E")
center / 18px 18px no-repeat;
}
.ys-confirm-box .el-message-box__message {
/* 与标题文字对齐,让图标单独悬挂在左侧:18px 图标 + 8px gap */
padding-left: 26px;
color: var(--ys-ink-2);
}
@@ -0,0 +1,57 @@
/*
* 表格操作列统一样式。
*
* 用法:用 <div class="table-actions"> 包住该行的操作按钮;破坏性动作
* (删除/禁用等)前插入 <span class="table-actions__divider"></span> 分组。
* 列宽须容纳整行动作的自然宽度,否则 Element Plus 会把末尾按钮折到第二行。
*/
.table-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
white-space: nowrap;
}
/* 间距统一交给 gap,抵消相邻按钮默认的 12px 外边距 */
.table-actions .el-button + .el-button {
margin-left: 0;
}
/*
* 带 title 提示或 tooltip 的按钮常包在 span 里。行内盒的基线留白会让包装
* 按钮比直接子按钮低约 1.7px,改为 flex 后与整行严格垂直对齐。
*/
.table-actions > span:not(.table-actions__divider) {
display: flex;
align-items: center;
}
/* 悬停加深并加下划线:Element 默认悬停变浅,浅靛蓝在表格底色上对比度不足 */
.table-actions .el-button.is-link:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.table-actions .el-button.is-link.el-button--primary:hover {
color: var(--el-color-primary-dark-2);
}
.table-actions .el-button.is-link.el-button--success:hover {
color: var(--el-color-success-dark-2);
}
.table-actions .el-button.is-link.el-button--warning:hover {
color: var(--el-color-warning-dark-2);
}
.table-actions .el-button.is-link.el-button--danger:hover {
color: var(--el-color-danger-dark-2);
}
.table-actions__divider {
flex: none;
width: 1px;
height: 12px;
background-color: var(--ys-line);
}
@@ -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 ?? '',
+3 -1
View File
@@ -29,7 +29,9 @@ export function useConfirmAction() {
}): Promise<boolean> {
try {
await ElMessageBox.confirm(options.confirmText, options.title ?? '操作确认', {
type: 'warning',
// 不传 type:Element Plus 的 warning 图标会渲染在正文左侧独立成列,
// 标题行反被留空。图标改由全局样式画在标题行内(.ys-confirm-box)。
customClass: 'ys-confirm-box',
confirmButtonText: options.confirmButtonText ?? '确定',
cancelButtonText: '取消',
})
+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 {
+1
View File
@@ -8,5 +8,6 @@ import pinia from './store'
import 'element-plus/dist/index.css'
import './assets/styles/index.css'
import './assets/styles/table-actions.css'
createApp(App).use(pinia).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
+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))
}
+73 -5
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'
@@ -38,10 +39,16 @@ const routes: RouteRecordRaw[] = [
redirect: '/dashboard',
children: [
{
// 工作台(M4 ⑧):与报表/审批接口同权限(admin/finance/leader),
// 前后端双层;营销后勤可见性待权限清单 #6 答复
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', order: 1 },
meta: {
title: '首页',
order: 1,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
},
},
{
path: '/system/users',
@@ -158,8 +165,66 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/settlements/index.vue'),
meta: { title: '月度结算', group: '绩效工资', order: 18 },
},
// 报表统计组(M4 ⑤):FR-030/031 限领导/财务/管理员;admin 须显式
// 列入 allowedRoles(项目既定规则,admin 不天然放行)
{
path: '/reports/personal-detail',
name: 'reports-personal-detail',
component: () => import('@/views/reports/personal-detail/index.vue'),
meta: {
title: '个人绩效明细',
group: '报表统计',
order: 19,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
},
},
{
path: '/reports/department-summary',
name: 'reports-department-summary',
component: () => import('@/views/reports/department-summary/index.vue'),
meta: {
title: '部门绩效汇总',
group: '报表统计',
order: 20,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
},
},
{
path: '/reports/business-stats',
name: 'reports-business-stats',
component: () => import('@/views/reports/business-stats/index.vue'),
meta: {
title: '业务统计',
group: '报表统计',
order: 21,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
},
},
{
path: '/reports/payment-stats',
name: 'reports-payment-stats',
component: () => import('@/views/reports/payment-stats/index.vue'),
meta: {
title: '回款统计',
group: '报表统计',
order: 22,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
},
},
],
},
{
// 打印视图独立于 BasicLayout(不挂侧栏/顶栏,浏览器打印只出报表本身);
// menu:false 不进菜单,但 allowedRoles 守卫照常生效
path: '/reports/print/:code',
name: 'report-print',
component: () => import('@/views/reports/print/index.vue'),
meta: {
title: '报表打印',
menu: false,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
},
},
// 未匹配路由回首页;若未登录会被守卫拦到登录页
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
]
@@ -201,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
+10 -5
View File
@@ -398,12 +398,17 @@ onMounted(() => {
<el-table-column label="归属人员" min-width="110">
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<el-table-column label="操作" width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openOwnership(row)">归属变更</el-button>
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openOwnership(row)">
归属变更
</el-button>
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
+15 -10
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) // 下拉为空不阻断列表使用
}
@@ -452,14 +452,19 @@ onMounted(() => {
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<el-table-column label="操作" width="270" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openSubTables(row)">区域与计划</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '设为离职' : '设为在职' }}
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openSubTables(row)">
区域与计划
</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '设为离职' : '设为在职' }}
</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
+9 -6
View File
@@ -269,13 +269,16 @@ onMounted(() => {
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '停用' : '启用' }}
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '停用' : '启用' }}
</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
@@ -690,37 +690,46 @@ onMounted(() => {
<span class="cell-amount">{{ row.total_received_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<el-table-column label="操作" width="300" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDrawer(row)">详情</el-button>
<el-tooltip content="已完成或已终止的业务禁止编辑" :disabled="row.status === 1" placement="top">
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.status !== 1"
@click="openEdit(row)"
>
编辑
</el-button>
</span>
</el-tooltip>
<el-tooltip content="终态不可再变更" :disabled="row.status === 1" placement="top">
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.status !== 1"
@click="openStatus(row)"
>
状态
</el-button>
</span>
</el-tooltip>
<el-button link type="primary" size="small" @click="openOwnership(row)">归属</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openDrawer(row)">详情</el-button>
<el-tooltip
content="已完成或已终止的业务禁止编辑"
:disabled="row.status === 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.status !== 1"
@click="openEdit(row)"
>
编辑
</el-button>
</span>
</el-tooltip>
<el-tooltip content="终态不可再变更" :disabled="row.status === 1" placement="top">
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.status !== 1"
@click="openStatus(row)"
>
状态
</el-button>
</span>
</el-tooltip>
<el-button link type="primary" size="small" @click="openOwnership(row)">
归属
</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
+5 -2
View File
@@ -282,8 +282,11 @@ onMounted(() => {
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
+38 -35
View File
@@ -346,42 +346,45 @@ onMounted(() => {
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<el-table-column label="操作" width="130" fixed="right" align="center">
<template #default="{ row }">
<el-tooltip
content="已核算回款不可变更"
:disabled="row.settlement_status !== 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.settlement_status === 1"
@click="openEdit(row)"
>
编辑
</el-button>
</span>
</el-tooltip>
<el-tooltip
content="已核算回款不可变更"
:disabled="row.settlement_status !== 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="danger"
size="small"
:disabled="row.settlement_status === 1"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</el-tooltip>
<div class="table-actions">
<el-tooltip
content="已核算回款不可变更"
:disabled="row.settlement_status !== 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.settlement_status === 1"
@click="openEdit(row)"
>
编辑
</el-button>
</span>
</el-tooltip>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-tooltip
content="已核算回款不可变更"
:disabled="row.settlement_status !== 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="danger"
size="small"
:disabled="row.settlement_status === 1"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
@@ -337,10 +337,13 @@ onMounted(() => {
<span class="cell-amount">{{ row.total_amount }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<el-table-column label="操作" width="130" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
+506 -205
View File
@@ -1,133 +1,383 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as echarts from 'echarts/core'
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'
import type { ECharts, EChartsCoreOption } from 'echarts/core'
import * as businessRoleApi from '@/api/businessRoles'
import * as paramsApi from '@/api/params'
import * as dashboardApi from '@/api/dashboard'
import type { DashboardData, TrendPoint } from '@/api/dashboard'
import { handledError } from '@/utils/error'
echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, SVGRenderer])
/**
* 工作台(M4 ⑧):经营绩效总览 / 待办事项 / 风险预警 / 最近动态。
*
* - 四区块由一次聚合请求返回(后端实时派生,见 dashboard_service 注释)
* - 总览图表严格按设计预览:SVG 渲染、无网格线、靛蓝渐变;
* **必须显式设置 fontFamily**——ECharts 在 Windows 默认微软雅黑(字体合规红线)
* - ⑥ T+ 同步未建:同步失败待办/同步预警显示「模块未启用」,不显示 0 条
*/
const router = useRouter()
/**
* 首页仪表盘(Bento Grid,2026-10-12 视觉方向)。
*
* M1 阶段:计提参数与岗位配置已就绪(真实数据);绩效/回款/待办等业务卡
* 展示空态 —— 业务数据管理上线后接入真实统计(图表用 ECharts 渲染)。
* 空态文案遵循全局规范:空态是邀请,说明「这里将出现什么、怎么让它出现」。
*/
const roleCount = ref<number | null>(null)
const paramCount = ref<number | null>(null)
const latestVersion = ref<string | null>(null)
const CHART_FONT =
"'Noto Sans SC','Source Han Sans SC','HarmonyOS Sans SC','PingFang SC',sans-serif"
const SETTLEMENT_STATUS_LABELS: Record<number, string> = {
1: '草稿',
2: '待审批',
3: '已锁定',
4: '已驳回',
}
const ORDER_TYPE_LABELS: Record<number, string> = {
1: '二次分配方案',
2: '月度结算',
3: '调整单',
}
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)
let perfChart: ECharts | null = null
let receiptChart: ECharts | null = null
function currentMonth(): string {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
async function load(): Promise<void> {
const seq = ++loadSeq
loading.value = true
try {
const result = await dashboardApi.getDashboard({ month: month.value })
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
data.value = result
await nextTick()
renderCharts()
} catch (error) {
if (seq === loadSeq) handledError(error)
} finally {
if (seq === loadSeq) loading.value = false
}
}
function renderCharts(): void {
const trend = data.value?.overview.trend ?? []
if (perfChartRef.value) {
perfChart ??= echarts.init(perfChartRef.value, undefined, { renderer: 'svg' })
perfChart.setOption(performanceOption(trend))
}
if (receiptChartRef.value) {
receiptChart ??= echarts.init(receiptChartRef.value, undefined, { renderer: 'svg' })
receiptChart.setOption(receiptOption(trend))
}
}
function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
return {
textStyle: { fontFamily: CHART_FONT },
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
xAxis: {
type: 'category',
data: trend.map((item) => item.month.slice(5)),
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
axisTick: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
yAxis: {
type: 'value',
splitLine: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
series: [
{
name: '绩效',
type: 'line',
smooth: 0.4,
symbol: 'none',
data: trend.map((item) => Number(item.performance_total ?? 0)),
lineStyle: { color: '#2e4ba8', width: 2 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(46,75,168,.22)' },
{ offset: 1, color: 'rgba(46,75,168,0)' },
],
},
},
},
],
}
}
function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
return {
textStyle: { fontFamily: CHART_FONT },
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
xAxis: {
type: 'category',
data: trend.map((item) => item.month.slice(5)),
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
axisTick: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
yAxis: {
type: 'value',
splitLine: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
series: [
{
name: '回款',
type: 'bar',
data: trend.map((item) => Number(item.receipt_total ?? 0)),
barMaxWidth: 22,
itemStyle: { color: '#2e4ba8', borderRadius: [4, 4, 0, 0] },
},
],
}
}
function handleResize(): void {
perfChart?.resize()
receiptChart?.resize()
}
onMounted(() => {
// 三项统计各自容错:一张卡失败不影响其余卡片
void (async () => {
try {
const page = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 1 })
roleCount.value = page.total
} catch (error) {
handledError(error)
}
})()
void (async () => {
try {
const catalog = await paramsApi.getCatalog()
paramCount.value = catalog.length
} catch (error) {
handledError(error)
}
})()
void (async () => {
try {
const page = await paramsApi.listBatches({ pageNum: 1, pageSize: 1 })
latestVersion.value = page.list[0]?.version_no ?? null
} catch (error) {
handledError(error)
}
})()
void load()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
perfChart?.dispose()
receiptChart?.dispose()
})
const activityModule = ref('')
const activityModules = computed(() => {
const modules = new Set((data.value?.activities ?? []).map((item) => item.module))
return [...modules]
})
const filteredActivities = computed(() =>
(data.value?.activities ?? []).filter(
(item) => !activityModule.value || item.module === activityModule.value,
),
)
function go(path: string | null): void {
if (path) void router.push(path)
}
function formatTime(value: string): string {
return value.replace('T', ' ').slice(0, 16)
}
</script>
<template>
<div>
<div v-loading="loading">
<div class="page-head">
<h1 class="page-title">仪表盘</h1>
<p class="page-desc">系统概览 · 计提参数与岗位配置就绪,业务数据管理上线后接入核算统计</p>
<h1 class="page-title">工作台</h1>
<p class="page-desc">经营绩效总览 · 待办与风险集中处置 · 最近动态</p>
<span class="waterline"></span>
</div>
<div class="toolbar">
<el-date-picker
v-model="month"
type="month"
value-format="YYYY-MM"
:clearable="false"
style="width: 140px"
@change="load"
/>
<el-button type="primary" :loading="loading" @click="load">刷新</el-button>
<span class="toolbar-note">{{ data?.overview.note ?? '' }}</span>
</div>
<div class="bento">
<!-- 绩效总额:业务上线前的空态 -->
<div class="bento-card b-2x2">
<!-- 经营绩效总览:KPI -->
<div class="bento-card">
<div class="b-eyebrow">本月绩效总额</div>
<div class="b-empty">
<div class="b-empty-title">暂无核算数据</div>
<div class="b-empty-desc">回款录入并核算后,这里将显示本月绩效总额与趋势</div>
<div class="b-num">{{ data?.overview.performance_total ?? '—' }}</div>
</div>
<div class="bento-card">
<div class="b-eyebrow">本月应付绩效</div>
<div class="b-num">{{ data?.overview.payable_total ?? '—' }}</div>
</div>
<div class="bento-card">
<div class="b-eyebrow">核算人数</div>
<div class="b-num">{{ data?.overview.person_count ?? '—' }}<small>人</small></div>
</div>
<div class="bento-card">
<div class="b-eyebrow">营销团队</div>
<div class="b-num">{{ data?.overview.team_count ?? '—' }}<small>个</small></div>
<div class="b-sub">
月结:
{{
data?.overview.settlement.exists
? SETTLEMENT_STATUS_LABELS[data.overview.settlement.status ?? 0] ?? '—'
: '未生成'
}}
</div>
</div>
<!-- 回款趋势:业务上线前的空态 -->
<!-- 经营绩效总览:图表 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">近 6 个月绩效</div>
<div ref="perfChartRef" class="chart"></div>
</div>
<div class="bento-card b-2x2">
<div class="b-eyebrow">近 6 个月回款</div>
<div class="b-empty">
<div class="b-empty-title">暂无回款数据</div>
<div class="b-empty-desc">业务数据管理上线后,此处展示按月回款趋势</div>
<div ref="receiptChartRef" class="chart"></div>
</div>
<!-- 待办事项 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">
待办事项
<span class="b-count">{{ data?.todos.pending_approval_total ?? 0 }}</span>
</div>
<div v-if="data?.todos.pending_approvals.length" class="todo-list">
<button
v-for="item in data.todos.pending_approvals"
:key="item.order_id"
class="todo-row"
type="button"
@click="go('/system/approvals')"
>
<span class="todo-title">
{{ ORDER_TYPE_LABELS[item.order_type] ?? '审批' }} · {{ item.order_no }}
</span>
<span class="todo-meta">{{ item.submitter_name ?? '—' }} · {{ formatTime(item.create_time) }}</span>
</button>
</div>
<div v-else class="b-empty-mini">无待审批事项</div>
<div class="b-eyebrow b-eyebrow-gap">
回款未指定批次
<span class="b-count">{{ data?.todos.unassigned_receipt_total ?? 0 }}</span>
</div>
<div v-if="data?.todos.unassigned_receipts.length" class="todo-list">
<button
v-for="item in data.todos.unassigned_receipts"
:key="item.receipt_id"
class="todo-row"
type="button"
@click="go('/business/receipts')"
>
<span class="todo-title">{{ item.receipt_batch_no }} · {{ item.amount }}</span>
<span class="todo-meta">业务 {{ item.business_no ?? item.business_id }} · {{ item.receipt_date }}</span>
</button>
</div>
<div v-else class="b-empty-mini">无未指定批次的回款</div>
<div class="b-eyebrow b-eyebrow-gap">T+ 同步失败</div>
<div class="b-empty-mini">
{{ data?.todos.sync_available ? `${data.todos.sync_failure_total} 条待处置` : '同步模块未启用(待 T+ 对接)' }}
</div>
</div>
<!-- 待办 -->
<div class="bento-card b-2x1">
<div class="b-eyebrow">待办</div>
<div class="b-empty-mini">业务数据管理上线后,待核算回款会出现在这里</div>
<!-- 风险预警 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">
风险预警
<span class="b-count">{{ data?.warnings.total ?? 0 }}</span>
</div>
<div v-if="data && !data.warnings.enabled" class="b-empty-mini">
预警已关闭(参数 warning.enabled=0)
</div>
<div v-else-if="data?.warnings.items.length" class="warning-list">
<button
v-for="item in data.warnings.items"
:key="`${item.code}-${item.object_id}`"
class="warning-row"
type="button"
@click="go(item.link)"
>
<el-tag
size="small"
:type="item.level === 'danger' ? 'danger' : 'warning'"
disable-transitions
>
{{ item.level === 'danger' ? '资金' : '时效' }}
</el-tag>
<span class="warning-title">{{ item.title }}</span>
<span class="warning-detail">{{ item.detail }}</span>
</button>
</div>
<div v-else class="b-empty-mini">
暂无预警(回款逾期 {{ 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+ 对接)' }}
</div>
</div>
<!-- 营销人员:人员管理上线后接入 -->
<div class="bento-card">
<div class="b-eyebrow">营销人员</div>
<div class="b-empty-mini">人员管理上线后同步</div>
</div>
<!-- 岗位角色:真实数据 -->
<div class="bento-card">
<div class="b-eyebrow">岗位角色</div>
<div class="b-num">{{ roleCount ?? '—' }}<small>个</small></div>
<button class="b-link" type="button" @click="router.push('/system/business-roles')">
管理 ›
</button>
</div>
<!-- 计提参数:真实数据 -->
<div class="bento-card">
<div class="b-eyebrow">计提参数</div>
<div class="b-num">{{ paramCount ?? '—' }}<small>项</small></div>
<button class="b-link" type="button" @click="router.push('/system/params')">
维护 ›
</button>
<!-- 最近动态 -->
<div class="bento-card b-full">
<div class="b-eyebrow">
最近动态
<el-select
v-model="activityModule"
size="small"
clearable
placeholder="全部模块"
style="width: 140px; margin-left: 12px"
>
<el-option v-for="item in activityModules" :key="item" :label="item" :value="item" />
</el-select>
</div>
<div v-if="filteredActivities.length" class="activity-list">
<div v-for="item in filteredActivities" :key="item.id" class="activity-row">
<span class="activity-time">{{ formatTime(item.create_time) }}</span>
<el-tag size="small" :type="item.success === 1 ? 'info' : 'danger'" disable-transitions>
{{ item.module }} · {{ item.action }}
</el-tag>
<span class="activity-summary">{{ item.summary }}</span>
<span class="activity-user">{{ item.username ?? '—' }}</span>
</div>
</div>
<div v-else class="b-empty-mini">暂无动态</div>
</div>
<!-- 常用操作 -->
<div class="bento-card b-2x1">
<div class="bento-card b-full">
<div class="b-eyebrow">常用操作</div>
<div class="b-links">
<button class="b-op" type="button" @click="router.push('/system/params')">
计提参数
<button class="b-op" type="button" @click="go('/business/receipts')">录入回款</button>
<button class="b-op" type="button" @click="go('/calc/ledger')">绩效台账</button>
<button class="b-op" type="button" @click="go('/settlements')">月度结算</button>
<button class="b-op" type="button" @click="go('/reports/personal-detail')">
报表统计
</button>
<button class="b-op" type="button" @click="router.push('/system/business-roles')">
岗位角色
</button>
<button class="b-op" type="button" @click="router.push('/system/logs')">
操作日志
</button>
<span class="b-op b-op-disabled" title="业务数据管理上线后开放">录入回款</span>
</div>
</div>
<!-- T+ 同步 -->
<div class="bento-card">
<div class="b-eyebrow">T+ 同步</div>
<div class="b-state">待接入</div>
<div class="b-sub">每周一次 · 手动触发</div>
</div>
</div>
</div>
</template>
@@ -139,56 +389,62 @@ onMounted(() => {
.page-title {
margin: 0;
font-size: 18px;
font-size: 22px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.page-desc {
margin: 4px 0 0;
font-size: 12px;
margin: 6px 0 0;
font-size: 13px;
color: var(--ys-ink-3);
}
.waterline {
display: block;
width: 56px;
width: 46px;
height: 2px;
margin-top: 10px;
border-radius: 1px;
margin-top: 12px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
/* Bento Grid:4 列基格,卡片按重要性混排大小 */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.toolbar-note {
font-size: 12px;
color: var(--ys-ink-3);
}
.bento {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: 102px;
gap: 14px;
}
.bento-card {
display: flex;
flex-direction: column;
padding: 17px 20px;
overflow: hidden;
position: relative;
padding: 16px 18px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: 10px;
box-shadow:
0 1px 2px rgba(28, 35, 51, 0.04),
0 3px 10px rgba(28, 35, 51, 0.03);
transition:
transform 180ms cubic-bezier(0.25, 0.1, 0.25, 1),
box-shadow 180ms cubic-bezier(0.25, 0.1, 0.25, 1);
0 10px 24px -18px rgba(28, 35, 51, 0.18);
transition: transform 0.18s ease, box-shadow 0.18s ease;
overflow: hidden;
}
.bento-card:hover {
transform: translateY(-2px);
box-shadow:
0 1px 2px rgba(28, 35, 51, 0.05),
0 6px 18px rgba(28, 35, 51, 0.07);
0 2px 4px rgba(28, 35, 51, 0.05),
0 16px 30px -18px rgba(28, 35, 51, 0.22);
}
.b-2x2 {
@@ -196,149 +452,190 @@ onMounted(() => {
grid-row: span 2;
}
.b-2x1 {
grid-column: span 2;
.b-full {
grid-column: span 4;
}
.b-eyebrow {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--ys-ink-2);
color: var(--ys-ink-3);
}
.b-eyebrow-gap {
margin-top: 12px;
}
.b-count {
min-width: 20px;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
text-align: center;
color: var(--ys-indigo);
background: var(--ys-indigo-wash);
border-radius: 9px;
}
.b-num {
margin-top: auto;
font-size: 24px;
margin-top: 10px;
font-size: 26px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--ys-ink);
font-variant-numeric: tabular-nums;
}
.b-num small {
margin-left: 3px;
margin-left: 4px;
font-size: 12px;
font-weight: 400;
color: var(--ys-ink-3);
}
.b-sub {
margin-top: 3px;
font-size: 11px;
color: var(--ys-ink-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b-state {
margin-top: auto;
font-size: 15px;
font-weight: 600;
color: var(--ys-ink-2);
}
.b-empty {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.b-empty-title {
font-size: 14px;
font-weight: 500;
color: var(--ys-ink-2);
}
.b-empty-desc {
max-width: 260px;
margin-top: 6px;
margin-top: 4px;
font-size: 12px;
color: var(--ys-ink-3);
line-height: 1.7;
}
.b-empty-mini {
margin-top: auto;
margin-bottom: auto;
margin-top: 8px;
font-size: 12px;
color: var(--ys-ink-3);
line-height: 1.7;
}
.chart {
height: 150px;
margin-top: 6px;
}
.todo-list,
.warning-list {
max-height: 178px;
margin-top: 8px;
overflow-y: auto;
}
.todo-row,
.warning-row {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 6px 4px;
border: none;
border-bottom: 1px dashed var(--ys-line);
background: none;
text-align: left;
cursor: pointer;
}
.todo-row:hover,
.warning-row:hover {
background: rgba(46, 75, 168, 0.04);
}
.todo-title {
font-size: 13px;
color: var(--ys-ink);
}
.todo-meta {
font-size: 11px;
color: var(--ys-ink-3);
}
.warning-row {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: 8px;
align-items: center;
}
.warning-title {
font-size: 13px;
color: var(--ys-ink);
}
.warning-detail {
grid-column: 2;
font-size: 11px;
color: var(--ys-ink-3);
}
.activity-list {
max-height: 150px;
margin-top: 8px;
overflow-y: auto;
}
.activity-row {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 0;
border-bottom: 1px dashed var(--ys-line);
font-size: 12px;
}
.activity-time {
width: 118px;
color: var(--ys-ink-3);
font-variant-numeric: tabular-nums;
}
.activity-summary {
flex: 1;
color: var(--ys-ink-2);
}
.activity-user {
color: var(--ys-ink-3);
}
.b-links {
display: flex;
gap: 8px;
align-items: center;
margin-top: auto;
}
.b-link {
padding: 0;
margin-top: 6px;
font-size: 12px;
color: var(--ys-indigo);
text-align: left;
cursor: pointer;
background: none;
border: none;
}
.b-link:hover {
color: var(--ys-indigo-deep);
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.b-op {
height: 30px;
padding: 0 13px;
font-size: 12px;
font-family: inherit;
color: var(--ys-ink);
cursor: pointer;
background: linear-gradient(180deg, #ffffff, #fbfcfe);
border: 1px solid var(--ys-line);
border-radius: 7px;
transition:
color 150ms cubic-bezier(0.25, 0.1, 0.25, 1),
border-color 150ms cubic-bezier(0.25, 0.1, 0.25, 1);
}
button.b-op:hover {
padding: 6px 14px;
font-size: 13px;
color: var(--ys-indigo);
border-color: rgba(46, 75, 168, 0.45);
background: var(--ys-indigo-wash);
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
}
.b-op-disabled {
color: var(--ys-ink-3);
cursor: not-allowed;
.b-op:hover {
border-color: var(--ys-indigo);
}
@media (max-width: 1024px) {
.bento {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.b-full {
grid-column: span 2;
}
}
@media (max-width: 640px) {
.bento {
grid-template-columns: 1fr;
grid-auto-rows: auto;
}
.bento-card {
min-height: 90px;
}
.b-2x2,
.b-2x1 {
.b-full {
grid-column: span 1;
grid-row: span 1;
min-height: 130px;
}
}
@@ -346,5 +643,9 @@ button.b-op:hover {
.bento-card {
transition: none;
}
.bento-card:hover {
transform: none;
}
}
</style>
+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 消费验证码,无论何种失败它都已失效。
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
@@ -0,0 +1,320 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { BUSINESS_STATUS_OPTIONS, businessStatusLabel } from '@/api/businesses'
import * as calcApi from '@/api/calc'
import type { LedgerRow } from '@/api/calc'
import { listCustomers } from '@/api/customers'
import { listPersonnel } from '@/api/personnel'
import * as receiptApi from '@/api/receipts'
import * as reportApi from '@/api/reports'
import type { BusinessStatsRow, ReportFilterParams, ReportSummary } from '@/api/reports'
import AppPagination from '@/components/table/AppPagination.vue'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { handledError } from '@/utils/error'
/**
* 业务统计表(FR-031 之一):按业务查发货、回款、绩效情况。
*
* 回款额/绩效是**业务累计值**,不随业务日期筛选变化(页面已注明);
* 行展开下钻回款明细与台账明细(复用既有查询)。
*/
const router = useRouter()
const filters = reactive({
keyword: '',
customer_id: undefined as number | undefined,
owner_personnel_id: undefined as number | undefined,
status: undefined as number | undefined,
})
const dateRange = ref<[string, string] | null>(null)
const { options: customerOptions, load: loadCustomers } = useDropdownOptions(() =>
listCustomers({ pageNum: 1, pageSize: 200 }).then((data) =>
data.list.map((item) => ({ label: item.company_name, value: item.id })),
),
)
const { options: personnelOptions, load: loadPersonnelOptions } = useDropdownOptions(() =>
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
),
)
void loadCustomers()
void loadPersonnelOptions()
const summary = ref<ReportSummary | null>(null)
function getFilters(): ReportFilterParams {
return {
keyword: filters.keyword.trim() || undefined,
customer_id: filters.customer_id,
owner_personnel_id: filters.owner_personnel_id,
status: filters.status,
// el-date-picker 必须指定 value-format,否则绑定值是 Date 对象
date_from: dateRange.value?.[0],
date_to: dateRange.value?.[1],
}
}
function fetchPage(params: ReportFilterParams) {
return reportApi.listBusinessStats(params)
}
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
usePagedList(fetchPage, getFilters, (data) => {
summary.value = data.summary
})
// 进页即加载(报表页默认展示全部,筛选由用户主动收窄)
onMounted(search)
function resetFilters(): void {
filters.keyword = ''
filters.customer_id = undefined
filters.owner_personnel_id = undefined
filters.status = undefined
dateRange.value = null
search()
}
/** 展开行下钻:回款明细与台账明细各自懒加载并缓存 */
const receiptsMap = ref<Record<number, receiptApi.ReceiptItem[]>>({})
const ledgersMap = ref<Record<number, LedgerRow[]>>({})
async function loadDrill(row: BusinessStatsRow): Promise<void> {
const id = row.business_id
try {
if (!receiptsMap.value[id]) {
const data = await receiptApi.listReceipts({ business_id: id, pageNum: 1, pageSize: 200 })
receiptsMap.value = { ...receiptsMap.value, [id]: data.list }
}
if (!ledgersMap.value[id]) {
const data = await calcApi.listLedger({
business_id: id,
is_active: 1,
calc_type: 1,
pageNum: 1,
pageSize: 200,
})
ledgersMap.value = { ...ledgersMap.value, [id]: data.list }
}
} catch (error) {
handledError(error)
}
}
const exporting = ref(false)
async function doExport(): Promise<void> {
if (total.value === 0) {
ElMessage.warning('当前筛选无数据,无法导出')
return
}
exporting.value = true
try {
const blob = await reportApi.exportReport('business-stats', getFilters())
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `业务统计表_${today()}.xlsx`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('已导出')
} catch (error) {
handledError(error)
} finally {
exporting.value = false
}
}
function doPrint(): void {
const query: Record<string, string> = {}
for (const [key, value] of Object.entries(getFilters())) {
if (value !== undefined && value !== null && value !== '') query[key] = String(value)
}
const route = router.resolve({
name: 'report-print',
params: { code: 'business-stats' },
query,
})
window.open(route.href, '_blank')
}
function today(): string {
const now = new Date()
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
now.getDate(),
).padStart(2, '0')}`
}
</script>
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<el-form-item label="关键字">
<el-input
v-model="filters.keyword"
placeholder="业务编号/客户名称"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="客户">
<el-select
v-model="filters.customer_id"
placeholder="全部客户"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="option in customerOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="归属人">
<el-select
v-model="filters.owner_personnel_id"
placeholder="全部人员"
clearable
filterable
style="width: 150px"
>
<el-option
v-for="option in personnelOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 120px">
<el-option
v-for="option in BUSINESS_STATUS_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="业务日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始"
end-placeholder="结束"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</el-form>
<div class="hint">回款额与绩效为业务累计值,不随业务日期筛选变化</div>
<el-table v-loading="loading" :data="records" stripe @expand-change="loadDrill">
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<h4>回款明细</h4>
<el-table :data="receiptsMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="settlement_status" label="入结状态" width="100" />
<el-table-column prop="remark" label="备注" min-width="140" />
</el-table>
<h4>台账明细</h4>
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="personnel_name" label="归属人员" width="120" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="business_no" label="业务编号" min-width="160" />
<el-table-column prop="business_date" label="业务日期" width="110" />
<el-table-column prop="customer_name" label="客户" min-width="170" />
<el-table-column prop="owner_personnel_name" label="归属人" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">{{ businessStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right" />
<el-table-column prop="total_ship_amount" label="发货额" width="120" align="right" />
<el-table-column prop="total_received_amount" label="回款额" width="120" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
</el-table>
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计 {{ summary.ship_total ?? '0.00' }}
| 回款合计 {{ summary.received_total ?? '0.00' }} | 绩效合计
{{ summary.performance_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum) -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
}
.hint {
margin-bottom: 8px;
font-size: 12px;
color: var(--ys-ink-3);
}
.drill-wrap {
padding: 8px 16px;
}
.drill-wrap h4 {
margin: 8px 0;
font-size: 13px;
font-weight: 600;
}
.summary-bar {
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
}
.summary-note {
color: var(--ys-ink-3);
font-size: 12px;
}
</style>
@@ -0,0 +1,315 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { listPersonnel } from '@/api/personnel'
import * as reportApi from '@/api/reports'
import type { DepartmentSummary } from '@/api/reports'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { handledError } from '@/utils/error'
/**
* 部门绩效工资汇总表(FR-030):按经理团队/月份汇总。
*
* 部门口径 = personnel.manager_id 实时上溯的经理团队(无独立部门表);
* 团队基数与月结聚合行同源(manager_share 共享模块),因此可与月结
* 冻结额(frozen_amount)对账。全公司分成每位有聚合行的经理各计一次,
* 团队应发小计与月结聚合行同口径;列头比例随在线参数变化。
*/
const router = useRouter()
const filters = reactive({
settlement_month: currentMonth(),
manager_id: undefined as number | undefined,
})
const { options: managerOptions, load: loadManagers } = useDropdownOptions(() =>
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
),
)
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()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
async function load(): Promise<void> {
if (!filters.settlement_month) {
ElMessage.warning('请选择统计月份(部门汇总按单月聚合)')
return
}
const seq = ++loadSeq
loading.value = true
try {
const result = await reportApi.getDepartmentSummary({
settlement_month: filters.settlement_month,
manager_id: filters.manager_id,
})
if (seq === loadSeq) data.value = result
} catch (error) {
if (seq === loadSeq) handledError(error)
} finally {
if (seq === loadSeq) loading.value = false
}
}
void load()
async function doExport(): Promise<void> {
if (!data.value) {
ElMessage.warning('请先查询')
return
}
exporting.value = true
try {
const blob = await reportApi.exportReport('department-summary', {
settlement_month: filters.settlement_month,
manager_id: filters.manager_id,
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `部门绩效工资汇总表_${today()}.xlsx`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('已导出')
} catch (error) {
handledError(error)
} finally {
exporting.value = false
}
}
function doPrint(): void {
const query: Record<string, string> = { settlement_month: filters.settlement_month }
if (filters.manager_id) query.manager_id = String(filters.manager_id)
const route = router.resolve({
name: 'report-print',
params: { code: 'department-summary' },
query,
})
window.open(route.href, '_blank')
}
function openPersonalDetail(member: { personnel_id: number }): void {
void router.push({
name: 'reports-personal-detail',
query: {
personnel_id: String(member.personnel_id),
settlement_month: filters.settlement_month,
},
})
}
function today(): string {
const now = new Date()
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
now.getDate(),
).padStart(2, '0')}`
}
function pct(value: string): string {
return `${(Number(value) * 100).toFixed(2)}%`
}
</script>
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<el-form-item label="统计月份" required>
<el-date-picker
v-model="filters.settlement_month"
type="month"
value-format="YYYY-MM"
placeholder="选择月份"
:clearable="false"
style="width: 140px"
@change="load"
/>
</el-form-item>
<el-form-item label="经理">
<el-select
v-model="filters.manager_id"
placeholder="全部经理"
clearable
filterable
style="width: 160px"
@change="load"
>
<el-option
v-for="option in managerOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</el-form>
<div v-if="data?.params" class="params-hint">
分成参数:团队 {{ data.params.team_text }}% | 全公司 {{ data.params.company_text }}% |
经理个人户{{ data.params.manager_personal_included === 1 ? '计入' : '不计入' }}基数 |
取值时点 {{ data.params.params_as_of }}
</div>
<div v-loading="loading" class="company-card">
<span class="company-title">{{ companyTitle }}</span>
<span>全公司基数:{{ data?.company?.company_base ?? '—' }}</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">
每位有月结聚合行的经理各计一次;零成员团队无聚合行,不产生分成
</span>
</div>
<el-table v-loading="loading" :data="data?.teams ?? []" row-key="manager_id" stripe>
<el-table-column type="expand">
<template #default="{ row }">
<div class="member-wrap">
<el-table :data="row.members" size="small" stripe>
<el-table-column prop="personnel_name" label="成员" width="120" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right" />
<el-table-column prop="payable_sum" label="应付绩效" width="120" align="right" />
<el-table-column label="调整补发" width="110" align="right">
<template #default="{ row: member }">{{ member.adjust_extra }}</template>
</el-table-column>
<el-table-column label="调整扣减" width="110" align="right">
<template #default="{ row: member }">{{ member.adjust_deduct }}</template>
</el-table-column>
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row: member }">
<el-button link type="primary" @click="openPersonalDetail(member)">
查看明细
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="row.members.length === 0" description="该经理本月无团队成员业绩" />
</div>
</template>
</el-table-column>
<el-table-column prop="manager_name" label="经理团队" width="130">
<template #default="{ row }">
{{ row.manager_name }}
<el-tag v-if="row.manager_status === 0" size="small" type="info" disable-transitions>
离职
</el-tag>
</template>
</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="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>
<el-table-column prop="adjust_net" label="调整净额" width="110" align="right" />
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
<el-table-column label="月结冻结额" width="130" align="right">
<template #default="{ row }">
<span v-if="row.frozen_amount !== null" class="frozen">{{ row.frozen_amount }}</span>
<span v-else class="frozen-none">未生成月结</span>
</template>
</el-table-column>
</el-table>
<div v-if="data && data.unassigned.length" class="unassigned">
<h4>未纳入团队基数(无经理 / 非营销经理上级的下属)</h4>
<el-table :data="data.unassigned" size="small" stripe>
<el-table-column prop="personnel_name" label="人员" width="130" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right" />
<el-table-column prop="net_total" label="净合计" width="130" align="right" />
</el-table>
</div>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
}
.params-hint {
margin-bottom: 10px;
padding: 8px 12px;
font-size: 12px;
color: var(--ys-ink-2);
background: var(--ys-mist);
border-radius: 6px;
}
.company-card {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 12px;
padding: 12px 16px;
background: var(--ys-indigo-wash);
border-radius: 8px;
font-size: 13px;
}
.company-title {
font-weight: 600;
}
.company-note {
color: var(--ys-ink-3);
font-size: 12px;
}
.member-wrap {
padding: 8px 16px;
}
.frozen {
color: var(--ys-ink-2);
}
.frozen-none {
color: var(--ys-ink-3);
font-size: 12px;
}
.unassigned {
margin-top: 20px;
}
.unassigned h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
</style>
@@ -0,0 +1,337 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { CUSTOMER_TYPE_OPTIONS, customerTypeLabel } from '@/api/customers'
import * as reportApi from '@/api/reports'
import type { BucketRow, PaymentDetailRow, PaymentStats } from '@/api/reports'
import AppPagination from '@/components/table/AppPagination.vue'
import { handledError } from '@/utils/error'
/**
* 回款情况统计表(FR-031 之二):按回款天数区间/客户类型/产品类别统计。
*
* 区间档位由后端下发(禁止前端硬编码);空桶恒在且显示 0。回款金额侧
* 按回款单去重(二次分摊拆行不会让金额翻倍)。进度登记回款无台账行,
* 不在本表口径内(页面已注明)。
*/
const router = useRouter()
const filters = reactive({
settlement_month: '',
customer_type: '',
product_category: '',
})
const dateRange = ref<[string, string] | null>(null)
const data = ref<PaymentStats | null>(null)
const loading = ref(false)
const exporting = ref(false)
function getFilters() {
return {
settlement_month: filters.settlement_month || undefined,
customer_type: filters.customer_type || undefined,
product_category: filters.product_category || undefined,
date_from: dateRange.value?.[0],
date_to: dateRange.value?.[1],
}
}
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 {
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) {
if (seq === loadSeq) handledError(error)
} finally {
if (seq === loadSeq) loading.value = false
}
}
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(code: string): Promise<void> {
const state = drillOf(code)
const seq = ++state.seq
state.loading = true
try {
const page = await reportApi.listPaymentDetail({
...getFilters(),
bucket: code,
pageNum: state.pageNum,
pageSize: state.pageSize,
})
if (seq !== state.seq) return
state.rows = page.list
state.total = page.total
} catch (error) {
if (seq === state.seq) handledError(error)
} finally {
if (seq === state.seq) state.loading = false
}
}
function onDrillPage(code: string, page: number): void {
drillOf(code).pageNum = page
void fetchDrill(code)
}
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 {
const blob = await reportApi.exportReport('payment-stats', getFilters())
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `回款情况统计表_${today()}.xlsx`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('已导出')
} catch (error) {
handledError(error)
} finally {
exporting.value = false
}
}
function doPrint(): void {
const query: Record<string, string> = {}
for (const [key, value] of Object.entries(getFilters())) {
if (value !== undefined && value !== null && value !== '') query[key] = String(value)
}
const route = router.resolve({
name: 'report-print',
params: { code: 'payment-stats' },
query,
})
window.open(route.href, '_blank')
}
function today(): string {
const now = new Date()
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
now.getDate(),
).padStart(2, '0')}`
}
</script>
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<el-form-item label="月份">
<el-date-picker
v-model="filters.settlement_month"
type="month"
value-format="YYYY-MM"
placeholder="全部月份"
clearable
style="width: 140px"
/>
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始"
end-placeholder="结束"
style="width: 240px"
/>
</el-form-item>
<el-form-item label="客户类型">
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="option in CUSTOMER_TYPE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="产品类别">
<el-select
v-model="filters.product_category"
placeholder="全部"
clearable
style="width: 130px"
>
<el-option label="市政水利" value="municipal_water" />
<el-option label="轨道交通" value="rail_transit" />
<el-option label="混合业务" value="mixed" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</el-form>
<div class="hint">
统计口径:已计提回款(有台账行)的金额与绩效;进度登记回款不计入。空区间显示 0。
</div>
<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="{ row }">
<div class="drill-wrap">
<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" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="personnel_name" label="归属人员" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column label="客户类型" width="100">
<template #default="{ row }">
{{ customerTypeLabel(row.customer_type) }}
</template>
</el-table-column>
<el-table-column label="产品类别" width="100">
<template #default="{ row }">
{{ reportApi.reportCategoryLabel(row.product_category) }}
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
</el-table>
<AppPagination
: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>
</el-table-column>
<el-table-column prop="label" label="回款天数区间" min-width="150" />
<el-table-column prop="receipt_amount" label="回款金额" width="140" align="right" />
<el-table-column prop="receipt_count" label="回款笔数" width="100" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="payable_amount" label="应付绩效" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
</el-table>
<div v-if="data" class="summary-bar">
合计:回款 {{ data.totals.receipt_amount ?? '0.00' }} | 笔数
{{ data.totals.receipt_count ?? 0 }} | 绩效 {{ data.totals.performance_amount ?? '0.00' }}
| 应付 {{ data.totals.payable_amount ?? '0.00' }}
</div>
<div v-if="data" class="dimension-grid">
<div>
<h4>客户类型对比</h4>
<el-table :data="data.by_customer_type" size="small" stripe>
<el-table-column prop="label" label="客户类型" width="120" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
</el-table>
</div>
<div>
<h4>产品类别对比</h4>
<el-table :data="data.by_product_category" size="small" stripe>
<el-table-column prop="label" label="产品类别" width="120" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
</el-table>
</div>
</div>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
}
.hint {
margin-bottom: 8px;
font-size: 12px;
color: var(--ys-ink-3);
}
.drill-wrap {
padding: 8px 16px;
}
.summary-bar {
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
}
.dimension-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: 20px;
margin-top: 20px;
}
.dimension-grid h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
</style>
@@ -0,0 +1,331 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import * as calcApi from '@/api/calc'
import type { LedgerRow } from '@/api/calc'
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 { listBusinessRoleOptions } from '@/api/businessRoles'
import AppPagination from '@/components/table/AppPagination.vue'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { handledError } from '@/utils/error'
/**
* 个人绩效工资明细表(FR-029):按人员/月份/客户类型/产品类别/岗位查明细。
*
* 口径与台账一致(后端同源查询);页脚合计是**全筛选结果**之和,不是
* 当前页之和(页面已注明)。行展开看计算快照(按台账 id 懒加载)。
*/
const router = useRouter()
const route = useRoute()
const filters = reactive({
settlement_month: '',
personnel_id: undefined as number | undefined,
customer_type: '',
product_category: '',
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(() =>
listBusinessRoleOptions().then((data) =>
data.map((item) => ({ label: item.role_name, value: item.role_code })),
),
)
void loadPersonnel()
void loadRoles()
const summary = ref<ReportSummary | null>(null)
function getFilters(): ReportFilterParams {
return {
// axios 自动剔除 undefined;空串需显式转换,否则后端收到空字符串会精确匹配空值
settlement_month: filters.settlement_month || undefined,
personnel_id: filters.personnel_id,
customer_type: filters.customer_type || undefined,
product_category: filters.product_category || undefined,
role_code: filters.role_code || undefined,
}
}
function fetchPage(params: ReportFilterParams) {
return reportApi.listPersonalDetail(params)
}
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
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
filters.customer_type = ''
filters.product_category = ''
filters.role_code = ''
search()
}
/** 快照懒加载缓存:同一台账行只取一次 */
const snapshots = ref<Record<number, LedgerRow>>({})
async function loadSnapshot(row: PersonalDetailRow): Promise<void> {
if (snapshots.value[row.ledger_id]) return
try {
const ledger = await calcApi.getLedger(row.ledger_id)
snapshots.value = { ...snapshots.value, [row.ledger_id]: ledger }
} catch (error) {
handledError(error)
}
}
const exporting = ref(false)
async function doExport(): Promise<void> {
if (total.value === 0) {
ElMessage.warning('当前筛选无数据,无法导出')
return
}
exporting.value = true
try {
const blob = await reportApi.exportReport('personal-detail', getFilters())
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `个人绩效工资明细表_${today()}.xlsx`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('已导出')
} catch (error) {
handledError(error)
} finally {
exporting.value = false
}
}
function doPrint(): void {
const route = router.resolve({
name: 'report-print',
params: { code: 'personal-detail' },
query: cleanQuery(getFilters()),
})
window.open(route.href, '_blank')
}
function cleanQuery(params: ReportFilterParams): Record<string, string> {
const query: Record<string, string> = {}
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') query[key] = String(value)
}
return query
}
function today(): string {
const now = new Date()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${now.getFullYear()}${month}${day}`
}
</script>
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<el-form-item label="月份">
<el-date-picker
v-model="filters.settlement_month"
type="month"
value-format="YYYY-MM"
placeholder="全部月份"
clearable
style="width: 140px"
/>
</el-form-item>
<el-form-item label="人员">
<el-select
v-model="filters.personnel_id"
placeholder="全部人员"
clearable
filterable
style="width: 150px"
>
<el-option
v-for="option in personnelOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="客户类型">
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="option in CUSTOMER_TYPE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="产品类别">
<el-select
v-model="filters.product_category"
placeholder="全部"
clearable
style="width: 130px"
>
<el-option label="市政水利" value="municipal_water" />
<el-option label="轨道交通" value="rail_transit" />
<el-option label="混合业务" value="mixed" />
</el-select>
</el-form-item>
<el-form-item label="岗位">
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="option in roleOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="records" stripe @expand-change="loadSnapshot">
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{
JSON.stringify(snapshots[row.ledger_id]?.snapshot_json ?? '加载中…', null, 2)
}}</pre>
</template>
</el-table-column>
<el-table-column prop="business_no" label="合同编号" min-width="160" />
<el-table-column prop="ship_date" label="发货日" width="105" />
<el-table-column prop="receipt_date" label="回款日" width="105" />
<el-table-column prop="payment_days" label="回款天数" width="90" align="right" />
<el-table-column label="产品类别" width="100">
<template #default="{ row }">{{ reportApi.reportCategoryLabel(row.product_category) }}</template>
</el-table-column>
<el-table-column label="客户类型" width="100">
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位" width="100" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column prop="weight_r" label="支付权重R" width="95" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="110" align="right">
<template #default="{ row }">
<span :class="{ negative: Number(row.performance_amount) < 0 }">
{{ row.performance_amount ?? '—' }}
</span>
</template>
</el-table-column>
<el-table-column prop="payable_amount" label="应付绩效" width="110" align="right" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
试用期
</el-tag>
<el-tag v-if="row.missing_cost === 1" size="small" type="info" disable-transitions>
缺成本
</el-tag>
</template>
</el-table-column>
</el-table>
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计 {{ summary.performance_total ?? '0.00' }}
| 应付合计 {{ summary.payable_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum),
不能用 v-model 或 @change——翻了页不会加载 -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
}
.snapshot {
margin: 0;
padding: 10px;
font-size: 12px;
line-height: 1.6;
background: #f4f5f8;
border-radius: 6px;
white-space: pre-wrap;
word-break: break-all;
}
.negative {
color: #c45656;
}
.summary-bar {
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
}
.summary-note {
color: var(--ys-ink-3);
font-size: 12px;
}
</style>
+579
View File
@@ -0,0 +1,579 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { businessStatusLabel } from '@/api/businesses'
import { customerTypeLabel } from '@/api/customers'
import * as reportApi from '@/api/reports'
import type {
BusinessStatsRow,
DepartmentSummary,
PersonalDetailRow,
ReportFilterParams,
} from '@/api/reports'
import { getPublicSettings } from '@/api/settings'
import { handledError } from '@/utils/error'
/**
* 报表打印视图(FR-032):独立顶层路由,不挂 BasicLayout——浏览器直接
* 打印不会带出侧栏/顶栏。PDF 由打印对话框「另存为 PDF」覆盖,不引入
* 前端 PDF 依赖。
*
* 页眉公司信息走 public 端点(财务/领导非管理员,拿不到 /system/settings)。
* 分页接口循环翻页取全量(pageSize 上限 200),超过打印上限截断并提示。
*/
const route = useRoute()
const code = computed(() => String(route.params.code))
const query = computed(() => route.query as Record<string, string>)
/** 路由 query 转为报表查询参数(值均为字符串,数字字段由后端强转) */
const queryParams = computed(() => query.value as unknown as ReportFilterParams)
const PRINT_MAX_ROWS = 2000
const PAGE_SIZE = 200
const companyName = ref('衍水集团')
const logoUrl = ref<string | null>(null)
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: '统计月份',
personnel_id: '人员ID',
customer_type: '客户类型',
product_category: '产品类别',
role_code: '岗位',
manager_id: '经理ID',
keyword: '关键字',
customer_id: '客户ID',
owner_personnel_id: '归属人ID',
status: '状态',
date_from: '日期起',
date_to: '日期止',
}
const parts = Object.entries(query.value)
.filter(([, value]) => value !== undefined && value !== '')
.map(([key, value]) => `${labels[key] ?? key}:${value}`)
return parts.join(' | ') || '全部'
})
async function fetchAll<T>(
fetcher: (params: ReportFilterParams) => Promise<{ list: T[]; total: number }>,
params: ReportFilterParams,
): 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
}
// 以接口 total 判定:collected 最多正好等于上限,用长度比较永远为 false
return {
list: collected.slice(0, PRINT_MAX_ROWS),
truncated: total > PRINT_MAX_ROWS,
}
}
function personalRow(row: PersonalDetailRow): Record<string, any> {
return {
...row,
product_category: reportApi.reportCategoryLabel(row.product_category),
customer_type: customerTypeLabel(row.customer_type),
}
}
function businessRow(row: BusinessStatsRow): Record<string, any> {
return { ...row, status: businessStatusLabel(row.status) }
}
async function load(): Promise<void> {
loading.value = true
try {
const settings = await getPublicSettings()
companyName.value = settings.company_name || '衍水集团'
logoUrl.value = settings.logo_url || null
if (code.value === 'personal-detail') {
title.value = '个人绩效工资明细表'
const data = await fetchAll(
(params) => reportApi.listPersonalDetail(params),
queryParams.value,
)
rows.value = (data.list as PersonalDetailRow[]).map(personalRow)
truncated.value = data.truncated
} else if (code.value === 'department-summary') {
title.value = '部门绩效工资汇总表'
department.value = await reportApi.getDepartmentSummary(queryParams.value)
} else if (code.value === 'business-stats') {
title.value = '业务统计表'
const data = await fetchAll(
(params) => reportApi.listBusinessStats(params),
queryParams.value,
)
rows.value = (data.list as BusinessStatsRow[]).map(businessRow)
truncated.value = data.truncated
} else {
title.value = '回款情况统计表'
const data = await reportApi.getPaymentStats(queryParams.value)
buckets.value = data.buckets as unknown as Record<string, unknown>[]
groups.value = [
{ title: '客户类型对比', rows: data.by_customer_type as unknown as Record<string, unknown>[] },
{
title: '产品类别对比',
rows: data.by_product_category as unknown as Record<string, unknown>[],
},
]
}
} catch (error) {
handledError(error)
} finally {
loading.value = false
printedAt.value = new Date()
}
}
function doPrint(): void {
window.print()
}
onMounted(load)
</script>
<template>
<div class="print-page" v-loading="loading">
<div class="no-print toolbar">
<el-button type="primary" @click="doPrint">打印 / 另存为 PDF</el-button>
<span class="toolbar-hint">在打印对话框选择「另存为 PDF」即可导出 PDF</span>
</div>
<header class="sheet-header">
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
<div class="company">{{ companyName }}</div>
<h1 class="title">{{ title }}</h1>
<div class="meta">
<span>筛选条件:{{ filterText }}</span>
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2</span>
</div>
</header>
<div v-if="truncated" class="truncated">
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),仅打印前 {{ PRINT_MAX_ROWS }}
行;请缩小筛选范围后重新打印。
</div>
<template v-if="code === 'personal-detail'">
<table class="sheet-table">
<thead>
<tr>
<th>合同编号</th><th>发货日</th><th>回款日</th><th>回款天数</th>
<th>产品类别</th><th>客户类型</th><th>岗位</th><th>计提比例</th>
<th>支付权重R</th><th>绩效金额</th><th>应付绩效</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row.ledger_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.ship_date }}</td>
<td>{{ row.receipt_date }}</td>
<td class="num">{{ row.payment_days ?? '—' }}</td>
<td>{{ row.product_category }}</td>
<td>{{ row.customer_type }}</td>
<td>{{ row.role_name }}</td>
<td>{{ row.rate_text }}</td>
<td class="num">{{ row.weight_r ?? '—' }}</td>
<td class="num">{{ row.performance_amount ?? '—' }}</td>
<td class="num">{{ row.payable_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
</template>
<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>团队分成({{ department.params?.team_text ?? '—' }}%)</th>
<th>调整净额(仅核对)</th><th>团队应发小计(含聚合分成)</th><th>月结冻结额</th>
</tr>
</thead>
<tbody>
<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>
</template>
<template v-else-if="code === 'business-stats'">
<table class="sheet-table">
<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="row in rows" :key="String(row.business_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.business_date }}</td>
<td>{{ row.customer_name }}</td>
<td>{{ row.owner_personnel_name }}</td>
<td>{{ row.status }}</td>
<td class="num">{{ row.total_sale_amount }}</td>
<td class="num">{{ row.total_ship_amount }}</td>
<td class="num">{{ row.total_received_amount }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else>
<table class="sheet-table">
<thead>
<tr>
<th>回款天数区间</th><th>回款金额</th><th>回款笔数</th>
<th>绩效金额</th><th>应付绩效</th><th>台账行数</th>
</tr>
</thead>
<tbody>
<tr v-for="row in buckets" :key="String(row.code)">
<td>{{ row.label }}</td>
<td class="num">{{ row.receipt_amount }}</td>
<td class="num">{{ row.receipt_count }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
<td class="num">{{ row.ledger_count }}</td>
</tr>
</tbody>
</table>
<div v-for="group in groups" :key="group.title" class="group-block">
<h3>{{ group.title }}</h3>
<table class="sheet-table">
<thead>
<tr><th>维度</th><th>回款金额</th><th>绩效金额</th><th>台账行数</th></tr>
</thead>
<tbody>
<tr v-for="row in group.rows" :key="String(row.code)">
<td>{{ row.label }}</td>
<td class="num">{{ row.receipt_amount }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.ledger_count }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<footer class="signature">
<div>财务复核:____________________</div>
<div>领导签字:____________________</div>
<div>日期:______年__月__日</div>
</footer>
</div>
</template>
<style scoped>
.print-page {
min-height: 100vh;
padding: 24px;
background: #fff;
color: #1c2333;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.toolbar-hint {
color: var(--ys-ink-3);
font-size: 12px;
}
.sheet-header {
margin-bottom: 16px;
text-align: center;
}
.logo {
max-height: 48px;
margin-bottom: 6px;
}
.company {
font-size: 18px;
font-weight: 700;
}
.title {
margin: 6px 0;
font-size: 20px;
}
.meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #5b6577;
}
.truncated {
margin-bottom: 10px;
padding: 8px 12px;
font-size: 12px;
color: #8a5a00;
background: #fdf6e3;
border-radius: 4px;
}
.sheet-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.sheet-table th,
.sheet-table td {
padding: 6px 8px;
border: 1px solid #b0b7c3;
}
.sheet-table th {
background: #dde3f0;
font-weight: 600;
}
.sheet-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.block-title {
margin: 18px 0 6px;
font-size: 14px;
font-weight: 600;
}
.group-block {
margin-top: 18px;
}
.signature {
display: flex;
justify-content: space-between;
margin-top: 40px;
padding-top: 16px;
font-size: 13px;
}
@media print {
.no-print {
display: none !important;
}
.print-page {
padding: 0;
}
@page {
size: A4 landscape;
margin: 12mm;
}
.sheet-table thead {
display: table-header-group;
}
.sheet-table tr {
page-break-inside: avoid;
}
}
</style>
@@ -258,31 +258,34 @@ onMounted(() => {
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<el-table-column label="操作" width="190" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="builtinHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span :title="builtinHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="builtinHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span class="table-actions__divider" aria-hidden="true"></span>
<span :title="builtinHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</div>
</template>
</el-table-column>
</el-table>
+1 -1
View File
@@ -193,7 +193,7 @@ async function submitAll(): Promise<void> {
await ElMessageBox.confirm(
`共 ${pendingList.value.length} 项变更,${effectiveText.value}。提交后逐项留痕,未生效前可整批撤回。`,
'提交确认',
{ confirmButtonText: '提交', cancelButtonText: '再想想', type: 'warning' },
{ confirmButtonText: '提交', cancelButtonText: '再想想', customClass: 'ys-confirm-box' },
)
} catch {
return
+27 -24
View File
@@ -224,31 +224,34 @@ onMounted(load)
<el-table-column label="用户数" width="90" align="center">
<template #default="{ row }">{{ row.user_count }}</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="isBuiltin(row) ? '内置管理员角色不可禁用' : ''">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span :title="deleteHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row) || row.user_count > 0"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="isBuiltin(row) ? '内置管理员角色不可禁用' : ''">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span class="table-actions__divider" aria-hidden="true"></span>
<span :title="deleteHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row) || row.user_count > 0"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</div>
</template>
</el-table-column>
</el-table>
+29 -26
View File
@@ -331,34 +331,37 @@ onMounted(() => {
<el-table-column label="最后登录" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.last_login_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<el-table-column label="操作" width="250" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openReset(row)">
重置密码
</el-button>
<span :title="selfHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isSelf(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openReset(row)">
重置密码
</el-button>
</span>
<span :title="selfHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isSelf(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
<span :title="selfHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isSelf(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span class="table-actions__divider" aria-hidden="true"></span>
<span :title="selfHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isSelf(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</div>
</template>
</el-table-column>
</el-table>