Compare commits
2 Commits
a536608b53
...
fb398c3835
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb398c3835 | ||
|
|
c5f219423b |
BIN
docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx
Normal file
BIN
docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx
Normal file
Binary file not shown.
@ -718,6 +718,20 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 |
|
||||
| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 |
|
||||
|
||||
### 3.3.1 答复回填(2026-10-19)
|
||||
|
||||
> 业务方 24 项答复已到(2026-10-16,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列),2026-10-19 全部落实并实现;本节为 3.3 各项的落定状态,供后续引用。答复与默认不同处以答复为准;仍未答复项见 `docs/待答复催办清单_2026-10-19.md`。
|
||||
|
||||
| 3.3# | 事项 | 落定状态 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 关系) | **答复仍空白;2026-10-19 已按建议默认实现分摊拆行**——分配标的=绩效金额份额(参与人份额×比例、负责人拿余额、行合计守恒、合计>100% 与负责人自分 400 拦截);协助 25% 维持仅记录不核算;业务答复后如有变更走口径调整 |
|
||||
| 2 | 二次分配审批开关默认值 | 已答复:**默认开启**,可手动关闭(已落实 V8 参数种子) |
|
||||
| 3 | 「确认回款」定义 | 已答复:需人工确认动作,可手动关闭(系统已按此预留并实现) |
|
||||
| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认;现按最小授权运行(后勤=提交录入,财务=复核+导出,领导=审核只读),营销后勤暂不可见审批列表 |
|
||||
| 5 | 风险预警规则 | 已答复:方向确认参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步——⑧ 工作台实现 |
|
||||
| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 已答复:同一审批链;跨月调整需管理员授权并留痕(已实现) |
|
||||
| 7 | 待办事项范围 | 已答复:待审批 + 同步失败 + 回款未指定批次起步——⑧ 工作台实现 |
|
||||
|
||||
### 3.4 对路线图的影响(本次已修订)
|
||||
|
||||
1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容;第三章待 3.3 各项澄清后,按第二章同粒度补齐 M3 各增量块的任务级实施计划。
|
||||
|
||||
156
docs/superpowers/plans/2026-10-19-M4-报表统计.md
Normal file
156
docs/superpowers/plans/2026-10-19-M4-报表统计.md
Normal file
@ -0,0 +1,156 @@
|
||||
# M4 ⑤ 报表统计 · 任务级实施计划
|
||||
|
||||
> 立项日期 2026-10-19;分支 feat/m3-increments。依据:需规 2.4 /《需求规格说明书》FR-029~FR-032 / 开发计划第五章 1.3 验收行 / 2026-10-15 补充(四)「导出模板围绕财务复核和领导签字设计」。
|
||||
> 前置已满足:④ 台账/月结/调整单全部交付(台账即「报表 2.4.1 的数据源」,`performance_ledger.py` 注释锚点)。
|
||||
> **纯只读模块,无数据库迁移**。
|
||||
|
||||
## 〇、代码核实结论(实施时按此避坑)
|
||||
|
||||
| # | 结论 | 影响 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `require_roles` 403 文案硬编码「无权访问审批管理」(`api/deps.py:135`) | 给 `require_roles` 增可选 `message` 参数(默认值不变),报表传「无权访问报表统计」 |
|
||||
| 2 | `PageQuery.page_size` 上限 200(`core/response.py:57`) | 打印视图循环翻页取全量;导出走专用无分页查询 |
|
||||
| 3 | `settlement_service._confirm_scope` 带 `settlement_id IS NULL`(可入结口径) | 报表取数**不复用**,新增同月全量生效单笔行查询 |
|
||||
| 4 | `list_adjustments` 无月份筛选、limit 200、含全部状态 | 调整单并入需新写仓储查询(月份 + status=2) |
|
||||
| 5 | `GET /system/settings` 是 AdminUser,`/public` 才无角色限制 | 打印视图页眉必须走 `getPublicSettings()` |
|
||||
| 6 | 台账 `payment_days` 可空可负(先款后货);进度登记回款无台账行(#25) | 区间桶含「≤0 天」「无天数」兜底;回款统计注明「不含进度登记回款」 |
|
||||
| 7 | 二次分摊拆行使同一回款多行(负责人+参与人,business_id 相同) | 回款金额按 receipt_id 去重;个人明细不放「关联回款金额」列 |
|
||||
| 8 | 经理聚合行 calc_type=2 的 business_id 是锚点、receipt_id=NULL、payment_days=NULL | 业务统计/个人明细/回款统计一律 `calc_type=1 AND is_active=1`,防聚合额污染 |
|
||||
| 9 | openpyxl `NUMERIC_TYPES` 含 Decimal,原样落数值不转 float | 精度红线导出侧成立;`number_format='#,##0.00'` 控显示 |
|
||||
| 10 | `test_param_registry.py` 锁 registry 19 项 | 新增参数需四处同改(本计划不新增参数,规避) |
|
||||
| 11 | `PRODUCT_CATEGORIES` 常量不含 `mixed`,台账行有 `mixed` | 标签映射额外覆盖 mixed,单测防漂移 |
|
||||
| 12 | `audit_service.audit` 可用于只读导出留痕(独立 commit) | 直接复用;上下文内 bytes 在 commit 前固化 |
|
||||
| 13 | `test_settlement_helpers.py` 直接 import `settlement_service._month_end` | 抽共享模块时保留薄委托包装 |
|
||||
| 14 | 前端无任何打印/PDF/xlsx 代码 | 打印从零建:独立顶层路由 + `@media print` |
|
||||
| 15 | Starlette 响应头 latin-1,中文文件名会 UnicodeEncodeError | 导出文件名服务端 ASCII、中文名前端 `link.download` 赋 |
|
||||
|
||||
## 一、全局口径(写进 report_service docstring)
|
||||
|
||||
1. 数据源 `performance_ledgers` 固定 `is_active=1 AND calc_type=1`;聚合行只在部门汇总作冻结对照单读;月份维度 `settlement_month`。
|
||||
2. 金额全程 `Decimal` 出参字符串;比例原样 `rate_text`,禁 float/截断。
|
||||
3. 调整单并入:仅 `status=2`,按 `(settlement_month, personnel_id)` 并入,`direction=1` 记 +、`2` 记 −;归属按实时 `manager_id`。
|
||||
4. 离职人员不按 `status` 过滤(历史业绩留在原团队);零行经理枚举仅在职经理。
|
||||
5. 汇总 = 明细合计(团队/公司基数 == 成员明细绩效合计,测试逐项断言)。
|
||||
|
||||
### 已定设计决策
|
||||
|
||||
- 部门口径 = `personnel.manager_id` 实时上溯(无独立部门表,人员模型 docstring 已声明;已入催办清单 R1 向业务确认)
|
||||
- 导出 = 后端 openpyxl + StreamingResponse(ASCII 文件名);打印 = 前端独立打印视图 + `@media print`,PDF 走浏览器另存为;不引前端 PDF/xlsx 依赖
|
||||
- 不做图表(需求无要求,不引 ECharts)
|
||||
- 区间桶 = 代码常量 + 服务端下发元数据(前端零硬编码);不进参数体系避免迁移;配置缝收在 `get_payment_day_buckets()` 单函数,升级路径:`report.payment_days.buckets` 参数 → V10 迁移种子 → init-dev → registry 测试 19→20
|
||||
- 权限 `require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER)` 粗粒度,前后端双层(admin 显式列入)
|
||||
|
||||
## 二、后端任务
|
||||
|
||||
### T1 常量与元数据(`core/constants.py` 改 + `services/report_meta.py` 新)
|
||||
|
||||
- `MODULE_REPORT = "报表统计"`、`ACTION_EXPORT = "导出"`
|
||||
- `report_meta.py`(叶子模块,禁 app 内导入防循环依赖):`DayBucket` frozen dataclass + `PAYMENT_DAY_BUCKETS` 8 档(≤0 天(先款后货)/1-30/31-60/61-90/91-180/181-360/361+/无天数)、`CUSTOMER_TYPE_LABELS`、`PRODUCT_CATEGORY_LABELS`(含 mixed)、`REPORT_TEMPLATE_VERSION = "V1.0"`、`REPORT_EXPORT_MAX_ROWS = 20000`
|
||||
|
||||
### T2 仓储 `repositories/report_repository.py`(新)
|
||||
|
||||
| 函数 | 说明 |
|
||||
| --- | --- |
|
||||
| `list_personal_detail / count_personal_detail` | 单笔生效行分页(month/personnel/customer_type/product_category/role_code 筛选,id desc) |
|
||||
| `sum_personal_detail` | 全筛选口径 SUM(分页合计与页内合计分开,禁内存当前页求和) |
|
||||
| `list_month_receipt_rows` | 同月全量生效单笔行(部门汇总取数,不复用 _confirm_scope) |
|
||||
| `list_month_manager_aggregates` | 同月 calc_type=2 生效行(冻结对照) |
|
||||
| `list_month_adjustments` | status=2 + 月份 |
|
||||
| `list_business_stats / count / sum` | Business×Customer×Personnel 左联 + 三个 scalar_subquery(发货额 SUM(shipments.total_amount)、回款额 SUM(receipts.amount)、绩效 SUM(台账 performance_amount WHERE calc_type=1 AND is_active=1));keyword 双列 OR contains(autoescape=True) |
|
||||
| `list_payment_group_by_days` | GROUP BY (payment_days, customer_type, product_category) 出 SUM 绩效/应付/COUNT |
|
||||
| `list_payment_receipt_group_by_days` | 先 DISTINCT receipt_id 再 JOIN receipts 求和(拆行防翻倍) |
|
||||
| `list_payment_detail / count` | 区间下钻分页(闭区间 between、开区间 >=、none 桶 IS NULL),JOIN receipts 取金额与批次号 |
|
||||
|
||||
### T3 经理分成共享口径(`services/manager_share.py` 新 + `settlement_service.py` 改薄委托)
|
||||
|
||||
- 迁出(复制实现 + 薄委托,行为零变化):`month_end`、`ManagerShareParams`(含 `manager_share_params(session, month)`,锚定月末 resolve_snapshot)、`collect_team_buckets`、`company_base_of`
|
||||
- 报表侧分成额调用 `formula.manager_team_share(...)`——月结聚合行与报表汇总同源,杜绝口径漂移
|
||||
- **协调点**:84 会话已拆 settlement_service(028a94a);本任务在其基础上做,验证 `test_settlement_helpers.py` 全绿
|
||||
|
||||
### T4 服务与 schemas(`services/report_service.py` + `schemas/report.py` 新)
|
||||
|
||||
1. **个人明细**:`ReportPage[T]` = 标准分页四字段 + `summary`(全筛选合计);行含 ledger_id/business_id/business_no/人员/岗位/客户类型/产品类别/ship_date/receipt_date/payment_days/rate_text/weight_r/payment_weight/performance_amount/payable_amount;**不含回款金额列**
|
||||
2. **部门汇总**(月份必填):`collect_team_buckets` 分团队 → 团队基数/成员内联明细(personnel_id 聚合 + 台账 id 列表 + 调整 extra/deduct/net);`company` 块 5% 单列(team_base_total/company_base/company_share_amount;各团队 5% 之和 ≠ 全局 5%,UI 注明);零行经理并集枚举(在职 sales_manager JOIN business_roles + 数据中出现的 manager_id);对比 `company_base_ratio` 除零→0;冻结对照 `aggregate_ledger_id + frozen_amount`;出参 `params`(team_pct/company_pct/manager_personal_included/params_as_of)
|
||||
3. **业务统计**:分页 + summary;回款/绩效为该业务累计值(不随日期筛选变,页面 hint)
|
||||
4. **回款统计**:SQL 精确值聚合 → Python 按 `PAYMENT_DAY_BUCKETS` 分桶(恒含全桶、空桶 0);正负绩效直接求和不 ABS;出参 buckets/by_customer_type/by_product_category/totals
|
||||
5. **区间下钻**:`GET /reports/payment-detail`(bucket 必填,分页)
|
||||
6. **导出编排** `export_report(session, code, q, *, operator, meta)`:先 count 判上限(超限 400 中文提示按月缩小);`audit_service.audit(MODULE_REPORT, ACTION_EXPORT, ...)` 包取数+构建;出参 `(bytes, filename)` 在 commit 语义闭合前固化
|
||||
|
||||
### T5 Excel 模板(`services/report_export.py` 新,纯函数)
|
||||
|
||||
- 普通 `Workbook`(上限封顶 2 万行内存可承受);版式:公司页眉行(logo `read_logo` try/except 降级纯文本)→ 标题(合并居中)→ 筛选条件行 → 导出时间/人/模板版本 → 表头(加粗底纹边框 + freeze_panes)→ 数据行 → 合计行(service 侧 Decimal SUM quantize HALF_UP,**不写 Excel 公式**)→ 空两行 → 财务复核栏 / 领导签字栏 / 日期栏
|
||||
- 精度:金额 Decimal 直接写 + `#,##0.00`;比例 `'0.00"%"'`;`rate_text` 原样文本;日期 `date` + `YYYY-MM-DD`
|
||||
- 防公式注入:文本以 `= + - @ \t` 开头前置 `'`;CJK 列宽;部门汇总/回款统计多 sheet(各带页眉与签字栏);空数据允许(表头 + 提示行 + 签字栏)
|
||||
|
||||
### T6 API 与路由(`api/v1/reports.py` 新 + `router.py` 改 + `deps.py` 改)
|
||||
|
||||
| 方法 | 路径 | 入参(Query 蛇形) |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/reports/personal-detail` | pageNum/pageSize + settlement_month, personnel_id, customer_type, product_category, role_code |
|
||||
| GET | `/api/v1/reports/department-summary` | settlement_month(必填), manager_id |
|
||||
| GET | `/api/v1/reports/business-stats` | pageNum/pageSize + keyword, customer_id, owner_personnel_id, status, date_from, date_to |
|
||||
| GET | `/api/v1/reports/payment-stats` | settlement_month, date_from, date_to, customer_type, product_category |
|
||||
| GET | `/api/v1/reports/payment-detail` | pageNum/pageSize + bucket(必填) + 同上 |
|
||||
| GET | `/api/v1/reports/{code}/export` | code ∈ 四表,参数同对应页 → StreamingResponse xlsx |
|
||||
|
||||
- 全部 `def` 同步路由 + `Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计"))]`
|
||||
- Content-Disposition 文件名 ASCII(`personal_detail_20261019.xlsx`),沿用 `system/params.py` 导出先例
|
||||
|
||||
### T7 后端文档(`backend/README.md` 改)
|
||||
|
||||
报表接口表 + 「报表口径」小节(生效行/调整单并入/回款去重/不含进度登记回款/区间桶常量与升级路径)。
|
||||
|
||||
## 三、前端任务
|
||||
|
||||
### T8 API 与常量(`api/reports.ts` 新 + `utils/constants.ts` 改)
|
||||
|
||||
类型逐字段对齐(金额 string);`exportReport(code, params)` 裸 axios `responseType:'blob'`;MODULE_OPTIONS 与后端 `core/constants.py` 全量对齐
|
||||
|
||||
### T9 四个页面(`views/reports/{personal-detail,department-summary,business-stats,payment-stats}/index.vue` 新)
|
||||
|
||||
统一骨架:`filters(reactive)` + `usePagedList`(非分页表 ref+手动 load)+ `el-table stripe` + `AppPagination` + 工具条「导出 Excel」「打印」+ `handledError`;日期区间 `el-date-picker value-format="YYYY-MM-DD"`(必写);下拉复用 `useDropdownOptions`
|
||||
|
||||
| 页面 | 路由 order | 关键列与下钻 |
|
||||
| --- | --- | --- |
|
||||
| 个人绩效明细表 | `/reports/personal-detail` 19 | 需规 10 列 + 页脚全筛选合计(注明非当前页);expand 懒加载 `getLedger(ledger_id)` 快照下钻 |
|
||||
| 部门绩效汇总表 | `/reports/department-summary` 20 | 团队表 + 全公司 5% 卡片 + 参数取值提示;展开成员 → 跨页下钻个人明细(router.push query)→ 台账下钻复用 `listLedger` |
|
||||
| 业务统计表 | `/reports/business-stats` 21 | 发货额/回款额/绩效三列;expand 两小表(复用 `listReceipts`/`listLedger`);hint「累计值」 |
|
||||
| 回款统计表 | `/reports/payment-stats` 22 | buckets 元数据渲染(空桶 0)+ 桶展开下钻 `payment-detail` + 客户类型/产品类别对比小表 |
|
||||
|
||||
路由 meta:`{ title, group: '报表统计', order: 19..22, allowedRoles: [ROLE_ADMIN, 'finance', 'leader'] }`(admin 显式列入)
|
||||
|
||||
### T10 打印视图(`views/reports/print/index.vue` 新 + `router/index.ts` 改)
|
||||
|
||||
- 顶层路由 `/reports/print/:code`(不挂 BasicLayout),`menu:false` + allowedRoles 同上
|
||||
- 循环翻页取全量(pageSize 200、上限 2000 截断提示);页眉 `getPublicSettings()`(非管理员拿不到 `/system/settings`)
|
||||
- `window.print()`(按钮区 `@media print { display:none }`);`@page A4 landscape`;`thead { display: table-header-group }` 每页带表头;打印态白底黑字沿用 `--ys-*`
|
||||
|
||||
### T11 前端文档(`frontend/README.md` 改)
|
||||
|
||||
报表页与打印视图小节 + 权限矩阵
|
||||
|
||||
## 四、测试(模块整体完成时统一编写)
|
||||
|
||||
### T12 后端(`tests/unit/test_report_helpers.py` + `tests/integration/test_reports.py` 新)
|
||||
|
||||
- 单元:分桶边界(0/1/30/31/60/61/180/181/360/361/负值/NULL);标签键集合 ⊇ CUSTOMER_TYPES ∪ PRODUCT_CATEGORIES ∪ {"mixed"};Excel 结构断言(load_workbook 读回:页眉/合计/签字栏/number_format/注入前缀/空数据);占比除零→0
|
||||
- 集成(factories 造数):① 个人明细对拍 `/calc/ledger`、summary==全量和;② 部门汇总成员合计==团队基数==公司基数、`aggregate_amount==manager_team_share` 预期、生成月结后 computed==frozen;③ 无团队成员经理 0 行、离职成员计入原团队;④ 调整单生效行并入(status≠2 不计);⑤ 回款统计空桶恒在、负绩效计入、**分摊拆行金额只计一次**;⑥ 业务统计排除 calc_type=2;⑦ 导出 200/400/审计留痕(成功与失败);⑧ 普通 user 403(文案「无权访问报表统计」)
|
||||
|
||||
### T13 e2e(`frontend/e2e/report_flow.py` 新)
|
||||
|
||||
菜单可见 → 四页筛选与数字 → `page.expect_download()` 断言导出 → 打印视图注入 `window.print` 桩断言 → console 无错;造数用 API + 登录 token(远未来月份隔离,参照 calc_settlement_flow.py)
|
||||
|
||||
### T14 收尾
|
||||
|
||||
回归(pytest 全量 + ruff + type-check + build + e2e report_flow 与复跑两条既有脚本);三 README 更新;提交拆分 `feat(report): 报表统计后端取数与导出` → `feat(report): 报表统计前端四页与打印视图` → `test(report): 报表模块用例与测试记录`
|
||||
|
||||
## 五、开放项(不阻塞,随催办确认)
|
||||
|
||||
| 事项 | 默认 |
|
||||
| --- | --- |
|
||||
| 部门口径(R1) | manager_id 实时上溯 |
|
||||
| 区间档位(R2) | 8 档代码常量 |
|
||||
| 签字栏要素(R3) | 页眉/标题/筛选/时间人/版本/复核/签字/日期 |
|
||||
| 导出上限 | 20000 行超限 400 |
|
||||
| 打印上限 | 2000 行截断提示 |
|
||||
| Excel logo | 有则嵌、无则纯文本 |
|
||||
BIN
docs/待澄清问题清单_2026-10-15(1)(2).xlsx
Normal file
BIN
docs/待澄清问题清单_2026-10-15(1)(2).xlsx
Normal file
Binary file not shown.
BIN
docs/待确认事项排序表_2026-10-16.xlsx
Normal file
BIN
docs/待确认事项排序表_2026-10-16.xlsx
Normal file
Binary file not shown.
BIN
docs/待确认事项排序表_2026-10-16_new.xlsx
Normal file
BIN
docs/待确认事项排序表_2026-10-16_new.xlsx
Normal file
Binary file not shown.
82
docs/待答复催办清单_2026-10-19.md
Normal file
82
docs/待答复催办清单_2026-10-19.md
Normal file
@ -0,0 +1,82 @@
|
||||
# 待答复事项催办清单(2026-10-19)
|
||||
|
||||
> **用途**:对外跟催材料,分三块可直接拆分发送——一、业务方;二、IT/T+ 侧;三、报表口径补充确认(本轮新增)。
|
||||
> **基线**:《待澄清问题清单_2026-10-15(1)(2).xlsx》(业务 24 项,2026-10-16 已回 15 项)、《T+对接数据需求清单》v1.3「待IT确认事项」18 项 + v1.4(2026-10-19 勘误)、《问题与解决方案对照表_v2》(28 项已裁决口径)。
|
||||
> **原则**:已答复项不重复询问;未答复项按清单内「建议默认」执行中,答复后以答复为准并回填文档。
|
||||
|
||||
## 一、业务方(建议回复期限:____)
|
||||
|
||||
### A. 仍未答复 —— 阻塞对应模块开工(4 项)
|
||||
|
||||
| # | 问题(原文口径) | 当前执行默认 | 阻塞 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 未答复前只做方案录入,**不实现分摊计算**(已按此交付) | ④ 分摊计算 |
|
||||
| 2 | 分配比例合计不足 100% 时,剩余部分归属谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 |
|
||||
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ 分摊 |
|
||||
| 20 | 含税口径:销售/成本/毛利润/运费各项是否含税?价税合计与折扣折让如何处理? | 按不含税口径实现中 | ④ 核算口径终验、⑥ 同步建列(是否建税率/税额列) |
|
||||
|
||||
> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批,但**分摊金额不会出现在绩效中**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。
|
||||
|
||||
### B. 已有答复但需补具体值 / 勾选(4 项)
|
||||
|
||||
| # | 事项 | 需补充 |
|
||||
| --- | --- | --- |
|
||||
| 12 | 经理个人户业绩是否计入其 12.5% 团队基数 | 答复为「做参数可调」,**请给出初始值**:计入 / 不计入(系统已实现为开关参数,当前默认不计入) |
|
||||
| 14 | 「第 1 天 10‰」实现口径 | 请勾选:**A 仅首日覆盖**(推荐,保「360 天 = 6‰」锚点)或 B 整体平移(破坏锚点)。系统当前按 A 实现(首日 10‰、其余按公式 10 − 天数 × 4/360) |
|
||||
| 4 | 二次分配方案修改窗口与权限 | 答复「暂定允许修改,走调整单」——请定:首次月结前可改(留痕)?月结后只读、仅走调整单?谁有权改(管理员 / 营销后勤)? |
|
||||
| 6 | 营销后勤/财务/领导三类角色的账号归属、可见菜单、可操作点 | 答复「先预设角色,需与客户确认」——请给权限清单(三类角色各能看什么、能操作什么)。系统已按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读,营销后勤暂不可见审批列表 |
|
||||
|
||||
### C. 请确认两处勘误(已按勘误实施,需业务回执存档)(2 项)
|
||||
|
||||
| 项 | 原表述 | 现行勘误(v1.4) |
|
||||
| --- | --- | --- |
|
||||
| 人员识别 | 「负责营销人员以姓名为唯一识别」 | 改为「以人员编码(工号)为唯一识别、姓名辅助核对」——原表述与「编码为业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误,请确认 |
|
||||
| 回款↔发货批次 | 「由 T+ 收款分配关系指定,人工辅助」 | 改为「**系统人工指定**(对照表 #18 定稿),T+ 收款分配关系仅作辅助」——主次相反疑漏改,请确认 |
|
||||
|
||||
### 已按默认执行、无需回复的项(供核阅)
|
||||
|
||||
#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。
|
||||
|
||||
## 二、IT / T+ 侧(建议回复期限:____)
|
||||
|
||||
> 2026-10-15 回稿收到后,v1.3「待IT确认事项」18 项**未逐项答复**(回稿仅做了必要性升级与第七节补充),机制性内容(同步频率/幂等/格式要求)未包含,「同步不覆盖字段」表被引用但未附。现有 v1.4 清单中「系统侧目标表.字段」列**全表留空待回填**。
|
||||
|
||||
### A. 高优先(9 项,阻塞核算口径与同步程序开工)
|
||||
|
||||
| # | 事项 | 为什么高 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **成本额取数口径**:成本类型/毛利计算方式/更新时间/精度 | 毛利润 = 销售额 − 成本额,是绩效金额全部源头;缺此则同步无法自动跑,只能人工录入 |
|
||||
| 2 | 发货日期口径:出库日 / 签收日 / 开票日 | 回款天数基准日(发货日首日计第 1 天),直接决定计提比例 |
|
||||
| 3 | 到账日期口径:收款日 / 银行到账日 / 核销日 | 计提参数版本匹配日,直接决定适用哪一版比例 |
|
||||
| 4 | 幂等唯一键规则:作废重开是否换号 | 重复同步不重复入库的前提 |
|
||||
| 5 | 三单关联键:订单 / 发货 / 回款如何互相关联 | 一笔业务可分批发货;回款↔批次系统内人工指定,但订单↔发货关联仍必须由 T+ 提供 |
|
||||
| 6 | **人员唯一编码(工号)** + 业务员↔营销经理隶属关系 | 业绩归属唯一锚点(重名即错钱);经理 12.5% 团队聚合依隶属关系展开 |
|
||||
| 7 | 对接方式与安全:REST / 数据库视图 / 中间表 / 文件交换 | 未定则同步程序无法开工(一期人工录入可闭环) |
|
||||
| 8 | 负数 / 红字 / 退货 / 冲销处理规则与原单关联 | 决定第七节补充字段如何建列;如不提供则只能靠人工补录 |
|
||||
| 16 | 回款确认 / 核销状态字段与枚举 | 月度结算按「确认回款」生成,需 T+ 核销/确认类字段作人工确认依据 |
|
||||
|
||||
### B. 回稿三缺口(请补)
|
||||
|
||||
1. v1.3 第七节曾漏「人员唯一编码(工号)」——v1.4 数据清单第一节已补★项并注明「待IT确认 #19:T+ 档案编码是否即工号,若为自动内部编码请指明工号字段」;
|
||||
2. 「客户档案是否同步」(待IT确认 #10)未点名答复——v1.4 第六节已列客户档案 5 项,请确认可提供;
|
||||
3. 「系统侧目标表.字段(T+ 确认映射)」列全表留空——请回填,这是同步程序的字段契约。
|
||||
|
||||
### C. 中低优先(9 项,模块实现期内答复即可)
|
||||
|
||||
存量数据起始日期与增量口径(影响能出多早的报表)、客户匹配与唯一键、收款方式完整枚举清单、产品「数量」语义(库存量/在途/累计销量)、发货单价口径(订单价/发货时价)、特殊业务标记字段、同步窗口时间、市场拓展计划数据、待IT确认 #18(口径裁决 5 项已转业务方,见第一部分)。
|
||||
|
||||
## 三、报表口径补充确认(2026-10-19 新增,随本轮开发一并排)
|
||||
|
||||
> 报表统计模块(需规 2.4 / FR-029~FR-032)开工在即,以下三项请确认;未答复按「建议默认」实现,后续可调。
|
||||
|
||||
| # | 事项 | 建议默认(将按此实现) |
|
||||
| --- | --- | --- |
|
||||
| R1 | 部门绩效汇总表的「部门」口径 | 部门 = 经理团队(经理 + 其直属业务员),按人员档案的隶属关系展开;离职人员历史业绩留在原团队;经理的 5% 全公司部分单独一列 |
|
||||
| R2 | 回款统计的「回款天数区间」档位 | ≤0 天(先款后货)|1-30|31-60|61-90|91-180|181-360|361 以上|无回款天数,共 8 档;未命中区间显示 0 |
|
||||
| R3 | 导出/打印模板要素 | 公司信息页眉(名称+logo)、报表标题、筛选条件、导出时间与导出人、模板版本号、财务复核栏、领导签字栏、日期栏 |
|
||||
|
||||
另:报表查看权限(FR-029 参与者含「营销后勤(授权范围内)」)——请随第一部分 B-6 权限清单一并明确营销后勤是否需要查看报表、可见哪些列。
|
||||
|
||||
---
|
||||
|
||||
**发送建议**:第一部分发业务方(含 C 两项勘误确认);第二部分发 IT/T+(v1.4 附表一并);第三部分随开发进度与业务方顺带确认。
|
||||
353
frontend/e2e/calc_settlement_flow.py
Normal file
353
frontend/e2e/calc_settlement_flow.py
Normal file
@ -0,0 +1,353 @@
|
||||
"""绩效核算与月度结算端到端验证(M3 ④:台账/月结两页)。
|
||||
|
||||
覆盖真实浏览器链路(重点守 code-review 修复过的两处前端缺陷):
|
||||
- 台账页「重算」对话框按**回款 ID** 发起重算(修复前传台账行主键,会
|
||||
作废并重算无关回款的台账);重算后新版本生效、旧版本翻「已替代」
|
||||
- 版本筛选三态:仅当前生效(1)/ 仅历史版本(0)/ 清空=全部(不过滤)
|
||||
(修复前「含历史版本」传 0 只回历史行)
|
||||
- 月结页:生成快照(草稿)→ 提交审批(自动通过并锁定)→ 详情抽屉展示
|
||||
按人明细(净工资/试用期),锁定人回显
|
||||
|
||||
前置(API 预置;前端无「核算」入口——核算为批量动作走接口):
|
||||
客户/人员/业务/发货/回款经 HTTP API 直建(与集成测试同形入参),核算经
|
||||
API 触发;浏览器只负责台账/月结两页的真实交互。
|
||||
|
||||
数据说明:核算后的回款与已生成月结按设计不可删(变更只走调整单),
|
||||
本流程按 `e2e_` 前缀 + **随机远未来月份(209x)**造数——每次运行独占
|
||||
一个月份,重复运行互不碰撞;行数据留档开发库(与 business_data.py 同)。
|
||||
|
||||
运行前置(缺一不可):
|
||||
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/calc_settlement_flow.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
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,
|
||||
expect_toast,
|
||||
fail,
|
||||
login,
|
||||
)
|
||||
|
||||
ADMIN = "admin"
|
||||
API_BASE = "http://127.0.0.1:8000"
|
||||
SUFFIX = f"{int(time.time())}"
|
||||
console_errors: list[str] = []
|
||||
|
||||
_SETTLEMENT_SWITCH = "approval.monthly_settlement.enabled"
|
||||
|
||||
|
||||
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
|
||||
"""直连后端接口(仅用于 API 预置;浏览器交互一律走页面)。"""
|
||||
request = urllib.request.Request(
|
||||
f"{API_BASE}{path}",
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
payload = json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode()[:200]
|
||||
raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc
|
||||
if payload.get("code") != 200:
|
||||
raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}")
|
||||
return payload["data"]
|
||||
|
||||
|
||||
def _set_settlement_switch(token: str, value: str) -> None:
|
||||
"""显式控制月结审批开关(用例不依赖种子默认值;结束还原默认 1)。"""
|
||||
_api(
|
||||
"POST",
|
||||
"/api/v1/system/params/changes",
|
||||
token,
|
||||
{
|
||||
"change_type": 1,
|
||||
"remark": f"e2e calc_settlement_flow 设置月结审批开关={value}",
|
||||
"items": [
|
||||
{"param_code": _SETTLEMENT_SWITCH, "dimension_value": "", "value": value}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _filter_item(page: Page, label: str):
|
||||
"""可见筛选表单中指定标签的表单项(select 带 :visible 避开隐藏实例)。"""
|
||||
return page.locator(
|
||||
f'.filters:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
).first
|
||||
|
||||
|
||||
def _query(page: Page) -> None:
|
||||
page.locator(".filters:visible button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def _select_filter(page: Page, label: str, option: str) -> None:
|
||||
item = _filter_item(page, label)
|
||||
item.locator(".el-select").click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator(
|
||||
".el-select-dropdown:visible .el-select-dropdown__item", has_text=option
|
||||
).first.click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def step_login_and_open_ledger(page: Page, admin_password: str) -> None:
|
||||
login(page, ADMIN, admin_password)
|
||||
page.goto(f"{BASE_URL}/calc/ledger", wait_until="networkidle")
|
||||
page.wait_for_timeout(600)
|
||||
if current_path(page) != "/calc/ledger":
|
||||
fail("导航", f"未进入绩效台账页,当前 {page.url}")
|
||||
print("✓ 1. 登录与导航 进入绩效台账页")
|
||||
|
||||
|
||||
def step_setup_via_api(page: Page, month: str) -> dict:
|
||||
"""API 预置完整链路:角色 → 客户/人员 → 业务 → 发货 → 回款 → 核算。"""
|
||||
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"]
|
||||
role_id = next((r["id"] for r in roles if r["role_code"] == "salesperson"), None)
|
||||
if role_id is None:
|
||||
fail("API 预置", "未找到 salesperson 岗位种子")
|
||||
|
||||
customer = _api(
|
||||
"POST",
|
||||
"/api/v1/base/customers",
|
||||
token,
|
||||
{
|
||||
"company_name": f"e2e_月结客户_{SUFFIX}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
)
|
||||
person = _api(
|
||||
"POST",
|
||||
"/api/v1/base/personnel",
|
||||
token,
|
||||
{
|
||||
"personnel_name": f"e2e_月结人员_{SUFFIX}",
|
||||
"business_role_id": role_id,
|
||||
"salary_standard": "3000.00",
|
||||
},
|
||||
)
|
||||
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",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
shipment = _api(
|
||||
"POST",
|
||||
"/api/v1/business/shipments",
|
||||
token,
|
||||
{
|
||||
"business_id": business["id"],
|
||||
"ship_date": f"{month}-02",
|
||||
"items": [{"product_name": "e2e_月结发货", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
)
|
||||
receipt = _api(
|
||||
"POST",
|
||||
"/api/v1/business/receipts",
|
||||
token,
|
||||
{
|
||||
"business_id": business["id"],
|
||||
"receipt_date": f"{month}-15",
|
||||
"amount": "20000.00",
|
||||
"shipment_id": shipment["id"],
|
||||
},
|
||||
)
|
||||
calc = _api("POST", f"/api/v1/calc/receipts/{receipt['id']}/calc", token)
|
||||
if calc.get("repeated") is not False:
|
||||
fail("API 预置", f"核算应为首算,实际 repeated={calc.get('repeated')}")
|
||||
print(f"✓ 2. API 预置 业务 {business['business_no']} 已核算({month})")
|
||||
return {
|
||||
"token": token,
|
||||
"month": month,
|
||||
"business_no": business["business_no"],
|
||||
"personnel_name": person["personnel_name"],
|
||||
}
|
||||
|
||||
|
||||
def step_ledger_row_visible(page: Page, ctx: dict) -> None:
|
||||
_filter_item(page, "归属月").locator("input").first.fill(ctx["month"])
|
||||
_query(page)
|
||||
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
|
||||
if row.count() == 0:
|
||||
fail("台账列表", f"筛选 {ctx['month']} 未见业务 {ctx['business_no']} 的台账行")
|
||||
text = row.text_content()
|
||||
if "v1" not in text or "已替代" in text:
|
||||
fail("台账列表", f"首算行应为当前生效 v1,实际:「{text.strip()[:80]}」")
|
||||
print("✓ 3. 台账筛选 首算 v1 行可见且为当前生效")
|
||||
|
||||
|
||||
def step_recalc_via_dialog(page: Page, ctx: dict) -> None:
|
||||
"""重算对话框:修复前按台账行主键发起,会作废无关回款的台账。"""
|
||||
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
|
||||
row.locator("button:has-text('重算')").click()
|
||||
dialog = page.locator(".el-dialog:visible").last
|
||||
dialog.wait_for(state="visible")
|
||||
dialog.locator("textarea").fill(f"e2e 重算验证 {SUFFIX}")
|
||||
dialog.locator("button:has-text('确认重算')").click()
|
||||
expect_toast(page, "重算完成", "重算")
|
||||
page.wait_for_timeout(600)
|
||||
|
||||
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
|
||||
text = row.text_content()
|
||||
if "v2" not in text:
|
||||
fail("重算", f"重算后应显示 v2,实际:「{text.strip()[:80]}」")
|
||||
print("✓ 4. 重算对话框 按回款 ID 重算成功,v2 生效")
|
||||
|
||||
|
||||
def step_version_filter(page: Page, ctx: dict) -> None:
|
||||
# 仅历史版本:0 必须精确命中历史行(修复前「含历史版本」即传 0,语义相反)
|
||||
_select_filter(page, "版本", "仅历史版本")
|
||||
_query(page)
|
||||
rows = page.locator(".el-table__row", has_text=ctx["business_no"])
|
||||
if rows.count() != 1 or "已替代" not in rows.first.text_content():
|
||||
fail("版本筛选", "「仅历史版本」应只剩一条带「已替代」的 v1 行")
|
||||
|
||||
# 清空 = 不过滤(全部版本):v1+v2 双版本可见
|
||||
item = _filter_item(page, "版本")
|
||||
item.hover()
|
||||
clear = item.locator(".el-select__clear").first
|
||||
if clear.count() == 0:
|
||||
fail("版本筛选", "未找到下拉清空按钮")
|
||||
clear.click()
|
||||
page.wait_for_timeout(200)
|
||||
_query(page)
|
||||
rows = page.locator(".el-table__row", has_text=ctx["business_no"])
|
||||
texts = [rows.nth(i).text_content() for i in range(rows.count())]
|
||||
if rows.count() != 2 or not any("已替代" in t for t in texts):
|
||||
fail("版本筛选", f"清空后应见 v1+v2 两行,实际 {rows.count()} 行:{texts}")
|
||||
print("✓ 5. 版本筛选 仅历史/全部三态正确(v1 已替代 + v2 生效)")
|
||||
|
||||
|
||||
def step_generate_settlement(page: Page, ctx: dict) -> None:
|
||||
page.goto(f"{BASE_URL}/settlements", wait_until="networkidle")
|
||||
page.wait_for_timeout(600)
|
||||
page.locator('input[placeholder="结算月份 YYYY-MM"]').fill(ctx["month"])
|
||||
page.click("button:has-text('生成月结快照')")
|
||||
expect_toast(page, "月结已生成", "生成月结")
|
||||
|
||||
row = page.locator(".el-table__row", has_text=ctx["month"]).first
|
||||
row.wait_for(state="visible")
|
||||
text = row.text_content()
|
||||
if "草稿" not in text:
|
||||
fail("月结生成", f"生成后应为草稿状态,实际:「{text.strip()[:80]}」")
|
||||
print("✓ 6. 月结生成 快照生成,草稿状态")
|
||||
|
||||
|
||||
def step_submit_and_locked(page: Page, ctx: dict) -> None:
|
||||
row = page.locator(".el-table__row", has_text=ctx["month"]).first
|
||||
row.locator("button:has-text('提交审批')").click()
|
||||
# 开关已显式关闭(见 run 的 finally 还原)→ 自动通过并锁定
|
||||
expect_toast(page, "月结已锁定", "提交审批")
|
||||
page.wait_for_timeout(600)
|
||||
row = page.locator(".el-table__row", has_text=ctx["month"]).first
|
||||
if "已审定" not in row.text_content():
|
||||
fail("提交审批", "提交后应显示已审定(锁定)")
|
||||
print("✓ 7. 提交审批 自动通过并锁定(已审定)")
|
||||
|
||||
|
||||
def step_detail_drawer(page: Page, ctx: dict) -> None:
|
||||
row = page.locator(".el-table__row", has_text=ctx["month"]).first
|
||||
row.locator("button:has-text('详情')").click()
|
||||
drawer = page.locator(".el-drawer:visible").last
|
||||
drawer.wait_for(state="visible")
|
||||
page.wait_for_timeout(500)
|
||||
text = drawer.text_content()
|
||||
if ctx["month"] not in text or "已审定" not in text:
|
||||
fail("月结详情", f"抽屉应含月份与已审定状态,实际:「{text.strip()[:120]}」")
|
||||
if ctx["personnel_name"] not in text:
|
||||
fail("月结详情", f"明细应含本次人员 {ctx['personnel_name']}")
|
||||
if "净工资" not in text:
|
||||
fail("月结详情", "明细应含净工资列")
|
||||
print("✓ 8. 月结详情 抽屉明细含人员/净工资/锁定状态")
|
||||
|
||||
|
||||
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 密码)")
|
||||
|
||||
# 随机远未来月份:每次运行独占一月,重复运行互不碰撞(数据留档)
|
||||
month = f"20{random.randint(90, 99)}-{random.randint(1, 12):02d}"
|
||||
|
||||
step_login_and_open_ledger(page, admin_password)
|
||||
ctx = step_setup_via_api(page, month)
|
||||
_set_settlement_switch(ctx["token"], "0")
|
||||
try:
|
||||
step_ledger_row_visible(page, ctx)
|
||||
step_recalc_via_dialog(page, ctx)
|
||||
step_version_filter(page, ctx)
|
||||
step_generate_settlement(page, ctx)
|
||||
step_submit_and_locked(page, ctx)
|
||||
step_detail_drawer(page, ctx)
|
||||
finally:
|
||||
# 还原审批开关默认(V8 起默认开启),中断也不遗留
|
||||
_set_settlement_switch(ctx["token"], "1")
|
||||
|
||||
print(f" (本次数据留档:{month} 已锁月结 + 客户/人员/业务 e2e_ 前缀,不可删)")
|
||||
if console_errors:
|
||||
fail("控制台", f"存在 {len(console_errors)} 条 JS 错误:{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())
|
||||
Loading…
Reference in New Issue
Block a user