Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c319a6a428 | ||
|
|
b86eaf2b00 | ||
|
|
71df9a16ed | ||
|
|
980628bb8f | ||
|
|
c8131588bf | ||
|
|
b6d84033ce | ||
|
|
698df6d31a | ||
|
|
39a3fa7796 | ||
|
|
ebb3a92f25 | ||
|
|
294d99a0d3 | ||
|
|
6ff202d637 | ||
|
|
b7cfdfa50a | ||
|
|
04a1a0d307 | ||
|
|
2a1939d2e7 | ||
|
|
cfd2a196f2 | ||
|
|
cc4c51359c | ||
|
|
036834a1d0 | ||
|
|
8903a194c9 | ||
|
|
a2bd3960e1 | ||
|
|
fb398c3835 | ||
|
|
c5f219423b | ||
|
|
a536608b53 | ||
|
|
ccfd20651b | ||
|
|
48c0a86279 | ||
|
|
e48cfb752d | ||
|
|
028a94a6eb | ||
|
|
a89d540e3f | ||
|
|
7a99994d17 | ||
|
|
45c268b146 | ||
|
|
828b57350a | ||
|
|
76ded6d5ec | ||
|
|
7a460cb682 | ||
|
|
cb4f38dfd9 |
No files matched your search
@@ -64,3 +64,9 @@ CLAUDE.md
|
||||
backend/uploads/
|
||||
# 数据库备份导出目录(⑨ 备份脚本产物)
|
||||
db/backups/
|
||||
|
||||
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
|
||||
docs/**/*.rar
|
||||
docs/T_12.3数据字典/
|
||||
# macOS 压缩包残留元数据
|
||||
__MACOSX/
|
||||
@@ -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)")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""V9:business_secondary_allocations 唯一键补齐(存量库自愈)。
|
||||
|
||||
背景:uk(business_id, personnel_id) 是随「就地修改已发布的 V6 迁移」加入的
|
||||
(V6 建表语句里已含、硬编码名);**旧 V6 已应用的存量库 upgrade 到当前
|
||||
head 时不会重放 V6**,拿不到该约束,V8 也未补加——两条升级路径的库结构
|
||||
漂移(test_schema_sync 只比对表/列集合,约束级漂移拦不住)。
|
||||
|
||||
本迁移是**幂等条件式**:约束已存在(新库 / init-dev 重置库 / 已重建的
|
||||
开发库)时跳过,不存在(旧 V6 升级库)时补加,避免重复约束名 errno 1826。
|
||||
语句写成裸 SQL 而非 op.create_unique_constraint——后者在 --sql 离线导出
|
||||
时会无条件输出 DDL,无法携带存在性判断。
|
||||
|
||||
注意:补键时若表内已存在重复 (business_id, personnel_id) 数据,ADD
|
||||
CONSTRAINT 会失败(有意如此:先由人工清理重复数据再升级,不做静默去重)。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "ba0217f5091b"
|
||||
down_revision: str | None = "c7d2e5a81f30"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_UK_NAME = "uk_business_secondary_allocations_business_personnel"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""条件式补唯一键(见模块 docstring 的背景与幂等说明)。"""
|
||||
op.execute(
|
||||
"SET @uk_exists := ("
|
||||
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
|
||||
"WHERE CONSTRAINT_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = 'business_secondary_allocations' "
|
||||
f"AND CONSTRAINT_NAME = '{_UK_NAME}'"
|
||||
")"
|
||||
)
|
||||
op.execute(
|
||||
"SET @ddl := IF(@uk_exists = 0, "
|
||||
f"'ALTER TABLE business_secondary_allocations ADD CONSTRAINT {_UK_NAME} "
|
||||
"UNIQUE (business_id, personnel_id)', "
|
||||
"'DO 0')"
|
||||
)
|
||||
op.execute("PREPARE stmt FROM @ddl")
|
||||
op.execute("EXECUTE stmt")
|
||||
op.execute("DEALLOCATE PREPARE stmt")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""有意不撤键:新库由 V6 建表即带该约束、并非本迁移首创,撤掉会让
|
||||
降级路径产生新的漂移;确需撤销请人工评估后执行 DROP INDEX。
|
||||
"""
|
||||
@@ -25,8 +25,8 @@ from datetime import date
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c7d2e5a81f30"
|
||||
down_revision = "3bca400421c6"
|
||||
revision: str = "c7d2e5a81f30"
|
||||
down_revision: str | None = "3bca400421c6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
@@ -27,11 +27,12 @@ _WIDTH, _HEIGHT = 120, 40
|
||||
_FONT_SIZE = 25
|
||||
_PADDING = 8
|
||||
|
||||
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
|
||||
_INK = (15, 54, 70)
|
||||
_INTERFERENCE = (126, 156, 170)
|
||||
_SPECKLE = (168, 183, 192)
|
||||
_PAPER = (247, 246, 242)
|
||||
# 与前端 v3 配色呼应(--ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper):
|
||||
# 墨色字符、次级墨干扰、辅助墨噪点、纸白底
|
||||
_INK = (28, 35, 51) # #1c2333
|
||||
_INTERFERENCE = (91, 101, 119) # #5b6577
|
||||
_SPECKLE = (154, 163, 178) # #9aa3b2
|
||||
_PAPER = (255, 255, 255) # #ffffff
|
||||
|
||||
_SPECKLE_COUNT = 46
|
||||
_INTERFERENCE_LINES = 2
|
||||
|
||||
@@ -87,6 +87,7 @@ MODULE_SYSTEM_SETTINGS = "系统设置"
|
||||
MODULE_APPROVAL = "审批管理"
|
||||
MODULE_CALC = "绩效核算"
|
||||
MODULE_SETTLEMENT = "月度结算"
|
||||
MODULE_REPORT = "报表统计"
|
||||
|
||||
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||
@@ -105,3 +106,4 @@ ACTION_ENABLE = "启用"
|
||||
ACTION_DISABLE = "禁用"
|
||||
ACTION_APPROVE = "审批通过"
|
||||
ACTION_REJECT = "审批驳回"
|
||||
ACTION_EXPORT = "导出"
|
||||
@@ -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])
|
||||
@@ -0,0 +1,79 @@
|
||||
"""报表统计元数据(区间桶、中文标签、导出上限)——叶子模块。
|
||||
|
||||
刻意不导入 app 内任何模块:report_service 与 report_export 都要消费这里的
|
||||
定义,若它反向依赖 service 层会形成循环导入;也不走参数体系——FR-031
|
||||
「区间可配置」一期以代码常量落地(参数化需注册表 + 迁移 + init-dev +
|
||||
registry 测试四处同改,业务未提诉求前不做),配置缝收在
|
||||
get_payment_day_buckets 单函数,未来在线可配时只改该函数。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DayBucket:
|
||||
"""回款天数区间档位:min/max 均为 None 表示「无回款天数」兜底桶。"""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
min_days: int | None
|
||||
max_days: int | None
|
||||
|
||||
|
||||
# 8 档区间(催办清单 R2 默认口径)。两个兜底桶必须保留:payment_days 可空
|
||||
# (回款未指定发货批次)且可为负(先款后货照实负值),缺了它们这些行会
|
||||
# 静默消失、金额对不上。
|
||||
PAYMENT_DAY_BUCKETS: tuple[DayBucket, ...] = (
|
||||
DayBucket("le0", "≤0 天(先款后货)", None, 0),
|
||||
DayBucket("1-30", "1-30 天", 1, 30),
|
||||
DayBucket("31-60", "31-60 天", 31, 60),
|
||||
DayBucket("61-90", "61-90 天", 61, 90),
|
||||
DayBucket("91-180", "91-180 天", 91, 180),
|
||||
DayBucket("181-360", "181-360 天", 181, 360),
|
||||
DayBucket("361+", "361 天以上", 361, None),
|
||||
DayBucket("none", "无回款天数", None, None),
|
||||
)
|
||||
|
||||
CUSTOMER_TYPE_LABELS: dict[str, str] = {
|
||||
"personal": "个人户",
|
||||
"manager_personal": "经理个人户",
|
||||
"enterprise": "企业户",
|
||||
}
|
||||
|
||||
# mixed 不在 core.constants.PRODUCT_CATEGORIES(那只是可维护的档案类别),
|
||||
# 但台账行可以是 mixed(混合业务行级明细入 snapshot_json),报表必须能标它
|
||||
PRODUCT_CATEGORY_LABELS: dict[str, str] = {
|
||||
"municipal_water": "市政水利",
|
||||
"rail_transit": "轨道交通",
|
||||
"mixed": "混合业务",
|
||||
}
|
||||
|
||||
# 业务状态中文标签:导出列、筛选页眉与前端标签共用同一份映射
|
||||
BUSINESS_STATUS_LABELS: dict[int, str] = {1: "进行中", 2: "已完成", 3: "已终止"}
|
||||
|
||||
REPORT_TEMPLATE_VERSION = "V1.2"
|
||||
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
|
||||
# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出
|
||||
REPORT_EXPORT_MAX_ROWS = 20000
|
||||
|
||||
|
||||
def get_payment_day_buckets() -> tuple[DayBucket, ...]:
|
||||
"""区间档位唯一出口:前端下拉与分桶都以本函数返回为准,禁止硬编码。
|
||||
|
||||
未来「区间在线可配」时改此处读参数即可,调用方与前端零改动。
|
||||
"""
|
||||
return PAYMENT_DAY_BUCKETS
|
||||
|
||||
|
||||
def bucket_of(payment_days: int | None) -> DayBucket:
|
||||
"""把单笔回款天数落入对应区间桶;未匹配任何区间按「无回款天数」兜底。"""
|
||||
if payment_days is None:
|
||||
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")
|
||||
for bucket in PAYMENT_DAY_BUCKETS:
|
||||
if bucket.code == "none":
|
||||
continue
|
||||
lower_ok = bucket.min_days is None or payment_days >= bucket.min_days
|
||||
upper_ok = bucket.max_days is None or payment_days <= bucket.max_days
|
||||
if lower_ok and upper_ok:
|
||||
return bucket
|
||||
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")
|
||||
@@ -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()
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import PerformanceLedger
|
||||
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
|
||||
from app.schemas.calc import LedgerQuery
|
||||
|
||||
|
||||
@@ -15,7 +16,12 @@ def get_by_id(session: Session, ledger_id: int) -> PerformanceLedger | None:
|
||||
def get_active_by_receipt(
|
||||
session: Session, receipt_id: int, *, calc_type: int = 1
|
||||
) -> PerformanceLedger | None:
|
||||
"""取某回款的当前生效核算行(is_active=1;无则 None)。"""
|
||||
"""取某回款的当前生效核算行(is_active=1;无则 None)。
|
||||
|
||||
二次分摊拆行后同回款可有多行(负责人+参与人)——重算作废用
|
||||
``list_active_by_receipt``;本函数仅限确知单行的调用方(重算前的
|
||||
存在性判断按业务维度走 has_active_for_business)。
|
||||
"""
|
||||
return session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.receipt_id == receipt_id,
|
||||
@@ -25,6 +31,21 @@ def get_active_by_receipt(
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_active_by_receipt(
|
||||
session: Session, receipt_id: int, *, calc_type: int = 1
|
||||
) -> list[PerformanceLedger]:
|
||||
"""取某回款当前生效的**全部**核算行(分摊拆行后负责人+参与人各一行)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.receipt_id == receipt_id,
|
||||
PerformanceLedger.calc_type == calc_type,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def has_active_for_business(session: Session, business_id: int, *, calc_type: int = 1) -> bool:
|
||||
"""该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。"""
|
||||
return (
|
||||
@@ -73,6 +94,27 @@ def mark_inactive(session: Session, row: PerformanceLedger) -> None:
|
||||
session.flush()
|
||||
|
||||
|
||||
def deactivate_month_aggregates(session: Session, month: str) -> None:
|
||||
"""月结重生成时作废该月全部经理聚合行(calc_type=2)。
|
||||
|
||||
聚合行每次生成重建,旧行不翻 0 会与新行重复计入(按人聚合按 is_active
|
||||
过滤)。与 mark_inactive 同属 is_active 翻 0 的受控 UPDATE 例外;set 批量
|
||||
版本而非逐行——聚合行一个月至多几行,且无逐行回读必要。
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
session.execute(
|
||||
update(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
PerformanceLedger.calc_type == CALC_TYPE_MANAGER_AGGREGATE,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
.values(is_active=0)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def next_version(session: Session, receipt_id: int) -> int:
|
||||
"""某回款的下一个核算版本号(max+1;无行时 1)。"""
|
||||
current = session.execute(
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"""报表统计仓储:只读聚合查询;不 commit、不抛业务异常。
|
||||
|
||||
报表与台账分页查询刻意分开而非复用 ``performance_ledger_repository``:
|
||||
- 台账查询允许聚合行(calc_type=2),报表明细必须显式排除——经理聚合
|
||||
行 receipt_id 为 NULL,混入明细会让合同编号/天数列失真、业务统计的
|
||||
绩效被整团队聚合额污染;
|
||||
- 月结的 ``_confirm_scope`` 带 ``settlement_id IS NULL``(可入结口径),
|
||||
报表要看的是同月**全量**生效单笔行,口径不同;
|
||||
- 聚合需求(SUM/GROUP BY)台账仓储完全没有,报表在此统一实现。
|
||||
|
||||
发货额/回款额直接读 businesses 的汇总冗余列(服务层事务内单点维护),
|
||||
不逐业务子查询发货/回款表;绩效额 businesses 无冗余列,用子查询联台账。
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.report_meta import DayBucket
|
||||
from app.models import (
|
||||
AdjustmentOrder,
|
||||
Business,
|
||||
Customer,
|
||||
MonthlySettlement,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
)
|
||||
from app.schemas.report import (
|
||||
BusinessStatsQuery,
|
||||
PaymentDetailQuery,
|
||||
PaymentStatsQuery,
|
||||
PersonalDetailQuery,
|
||||
)
|
||||
|
||||
# 单笔生效行基础条件:报表四表共用(聚合行与已替代版本一律排除)
|
||||
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
|
||||
|
||||
|
||||
# ==== 个人绩效明细(FR-029)====
|
||||
|
||||
|
||||
def _personal_filters(query: PersonalDetailQuery) -> tuple:
|
||||
filters = [*_ACTIVE_SINGLE]
|
||||
if query.settlement_month:
|
||||
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
|
||||
if query.personnel_id is not None:
|
||||
filters.append(PerformanceLedger.personnel_id == query.personnel_id)
|
||||
if query.customer_type:
|
||||
filters.append(PerformanceLedger.customer_type == query.customer_type)
|
||||
if query.product_category:
|
||||
filters.append(PerformanceLedger.product_category == query.product_category)
|
||||
if query.role_code:
|
||||
filters.append(PerformanceLedger.role_code == query.role_code)
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def list_personal_detail(
|
||||
session: Session, query: PersonalDetailQuery, *, offset: int, limit: int
|
||||
) -> list[PerformanceLedger]:
|
||||
"""个人绩效明细分页(回款日倒序,同人按版本可追溯)。"""
|
||||
stmt = select(PerformanceLedger).where(*_personal_filters(query))
|
||||
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_personal_detail(session: Session, query: PersonalDetailQuery) -> int:
|
||||
"""个人绩效明细计数(与 list_personal_detail 同筛选)。"""
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count()).select_from(PerformanceLedger).where(*_personal_filters(query))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def sum_personal_detail(session: Session, query: PersonalDetailQuery) -> dict:
|
||||
"""全筛选口径合计(分页与合计同条件,避免「页脚合计≠全部结果」)。"""
|
||||
return (
|
||||
session.execute(
|
||||
select(
|
||||
func.count().label("row_count"),
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label(
|
||||
"converted_profit_total"
|
||||
),
|
||||
).where(*_personal_filters(query))
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
# ==== 部门汇总取数(FR-030)====
|
||||
|
||||
|
||||
def list_month_receipt_rows(session: Session, month: str) -> list[PerformanceLedger]:
|
||||
"""同月全量生效单笔台账行(部门汇总基数与成员明细的取数)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == month, *_ACTIVE_SINGLE
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_month_manager_aggregates(session: Session, month: str) -> list[PerformanceLedger]:
|
||||
"""同月经理聚合行(calc_type=2):已生成月结时的冻结对照值。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
PerformanceLedger.calc_type == 2,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_month_adjustments(session: Session, month: str) -> list[AdjustmentOrder]:
|
||||
"""同月已生效调整单(status=2)——报表汇总并入的口径。
|
||||
|
||||
与 list_adjustments(调整单管理页)不同:必须按月筛选且只取生效行,
|
||||
待审批/已驳回/已撤回的金额绝不能混进报表。
|
||||
"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(AdjustmentOrder).where(
|
||||
AdjustmentOrder.settlement_month == month,
|
||||
AdjustmentOrder.status == 2,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
|
||||
"""同月月结头(部门汇总展示锁定状态用;无则 None)。"""
|
||||
return session.execute(
|
||||
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
# ==== 业务统计(FR-031 之一)====
|
||||
|
||||
|
||||
def _business_perf_subquery():
|
||||
"""业务维度绩效子查询(排除聚合行,防 12.5%/5% 聚合额污染单业务绩效)。"""
|
||||
return (
|
||||
select(
|
||||
PerformanceLedger.business_id.label("business_id"),
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("perf"),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable"),
|
||||
)
|
||||
.where(*_ACTIVE_SINGLE)
|
||||
.group_by(PerformanceLedger.business_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
def _business_filters(query: BusinessStatsQuery) -> tuple:
|
||||
filters = []
|
||||
if query.keyword:
|
||||
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 转义通配符,
|
||||
# 与 business_repository._apply_filters 同口径(双列 OR 用法为该处既定)
|
||||
filters.append(
|
||||
Business.business_no.contains(query.keyword, autoescape=True)
|
||||
| Customer.company_name.contains(query.keyword, autoescape=True)
|
||||
)
|
||||
if query.customer_id is not None:
|
||||
filters.append(Business.customer_id == query.customer_id)
|
||||
if query.owner_personnel_id is not None:
|
||||
filters.append(Business.owner_personnel_id == query.owner_personnel_id)
|
||||
if query.status is not None:
|
||||
filters.append(Business.status == query.status)
|
||||
if query.date_from is not None:
|
||||
filters.append(Business.business_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
filters.append(Business.business_date <= query.date_to)
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def _business_select(query: BusinessStatsQuery):
|
||||
perf_sq = _business_perf_subquery()
|
||||
return (
|
||||
select(
|
||||
Business,
|
||||
Customer.company_name.label("customer_name"),
|
||||
Personnel.personnel_name.label("owner_personnel_name"),
|
||||
perf_sq.c.perf.label("performance_amount"),
|
||||
perf_sq.c.payable.label("payable_amount"),
|
||||
)
|
||||
.outerjoin(Customer, Customer.id == Business.customer_id)
|
||||
.outerjoin(Personnel, Personnel.id == Business.owner_personnel_id)
|
||||
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
|
||||
.where(*_business_filters(query))
|
||||
)
|
||||
|
||||
|
||||
def list_business_stats(
|
||||
session: Session, query: BusinessStatsQuery, *, offset: int, limit: int
|
||||
) -> list:
|
||||
"""业务统计分页(业务日期倒序)。"""
|
||||
stmt = _business_select(query).order_by(Business.id.desc()).offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
|
||||
"""业务统计计数(联客户表因关键字筛选涉及客户名)。"""
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Business)
|
||||
.outerjoin(Customer, Customer.id == Business.customer_id)
|
||||
.where(*_business_filters(query))
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
|
||||
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
|
||||
perf_sq = _business_perf_subquery()
|
||||
return (
|
||||
session.execute(
|
||||
select(
|
||||
func.count().label("row_count"),
|
||||
func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"),
|
||||
func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"),
|
||||
func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"),
|
||||
func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"),
|
||||
func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"),
|
||||
)
|
||||
.outerjoin(Customer, Customer.id == Business.customer_id)
|
||||
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
|
||||
.where(*_business_filters(query))
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
# ==== 回款统计(FR-031 之二)====
|
||||
|
||||
|
||||
def _payment_filters(query: PaymentStatsQuery | PaymentDetailQuery) -> tuple:
|
||||
filters = [*_ACTIVE_SINGLE]
|
||||
if query.settlement_month:
|
||||
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
|
||||
if query.date_from is not None:
|
||||
filters.append(PerformanceLedger.receipt_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
filters.append(PerformanceLedger.receipt_date <= query.date_to)
|
||||
if query.customer_type:
|
||||
filters.append(PerformanceLedger.customer_type == query.customer_type)
|
||||
if query.product_category:
|
||||
filters.append(PerformanceLedger.product_category == query.product_category)
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def group_payment_by_days(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按(回款天数精确值, 客户类型, 产品类别)聚合绩效与应付。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
PerformanceLedger.payment_days,
|
||||
PerformanceLedger.customer_type,
|
||||
PerformanceLedger.product_category,
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.count().label("ledger_count"),
|
||||
)
|
||||
.where(*_payment_filters(query))
|
||||
.group_by(
|
||||
PerformanceLedger.payment_days,
|
||||
PerformanceLedger.customer_type,
|
||||
PerformanceLedger.product_category,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def group_payment_receipt_by_days(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按维度聚合回款金额与笔数——金额侧必须按 receipt_id 去重。
|
||||
|
||||
二次分摊拆行使同一回款在台账出现多行,直接 SUM(receipts.amount) 会
|
||||
按拆行数翻倍;台账拆行只改归属人,不改维度值(payment_days/客户类型/
|
||||
产品类别),因此先对台账行 DISTINCT 出回款维度,再联回款表求和。
|
||||
"""
|
||||
distinct_receipts = (
|
||||
select(
|
||||
PerformanceLedger.receipt_id.label("receipt_id"),
|
||||
PerformanceLedger.payment_days.label("payment_days"),
|
||||
PerformanceLedger.customer_type.label("customer_type"),
|
||||
PerformanceLedger.product_category.label("product_category"),
|
||||
)
|
||||
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
from app.models import Receipt
|
||||
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
distinct_receipts.c.payment_days,
|
||||
distinct_receipts.c.customer_type,
|
||||
distinct_receipts.c.product_category,
|
||||
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
|
||||
func.count(distinct_receipts.c.receipt_id).label("receipt_count"),
|
||||
)
|
||||
.outerjoin(Receipt, Receipt.id == distinct_receipts.c.receipt_id)
|
||||
.group_by(
|
||||
distinct_receipts.c.payment_days,
|
||||
distinct_receipts.c.customer_type,
|
||||
distinct_receipts.c.product_category,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def group_payment_by_customer_type(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按客户类型聚合(绩效 + 回款金额)。"""
|
||||
return _group_by_dimension(session, query, PerformanceLedger.customer_type)
|
||||
|
||||
|
||||
def group_payment_by_product_category(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按产品类别聚合(绩效 + 回款金额)。"""
|
||||
return _group_by_dimension(session, query, PerformanceLedger.product_category)
|
||||
|
||||
|
||||
def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column) -> list:
|
||||
"""维度聚合共用实现(绩效侧 GROUP BY + 金额侧 DISTINCT 去重联回款)。"""
|
||||
from app.models import Receipt
|
||||
|
||||
dim_perf = (
|
||||
select(
|
||||
dim_column.label("dim"),
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.count().label("ledger_count"),
|
||||
)
|
||||
.where(*_payment_filters(query))
|
||||
.group_by(dim_column)
|
||||
)
|
||||
dim_receipts = (
|
||||
select(
|
||||
dim_column.label("dim"),
|
||||
PerformanceLedger.receipt_id.label("receipt_id"),
|
||||
)
|
||||
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
perf_rows = {row.dim: row for row in session.execute(dim_perf).all()}
|
||||
receipt_rows = session.execute(
|
||||
select(
|
||||
dim_receipts.c.dim,
|
||||
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
|
||||
)
|
||||
.outerjoin(Receipt, Receipt.id == dim_receipts.c.receipt_id)
|
||||
.group_by(dim_receipts.c.dim)
|
||||
).all()
|
||||
receipt_amounts = {row.dim: row.receipt_amount for row in receipt_rows}
|
||||
return [
|
||||
(
|
||||
row.dim,
|
||||
receipt_amounts.get(row.dim),
|
||||
row.performance_total,
|
||||
row.payable_total,
|
||||
row.ledger_count,
|
||||
)
|
||||
for row in perf_rows.values()
|
||||
]
|
||||
|
||||
|
||||
def _bucket_condition(bucket: DayBucket) -> tuple:
|
||||
"""区间桶的 days 过滤条件:none 桶 IS NULL,其余按 min/max 闭区间。"""
|
||||
if bucket.code == "none":
|
||||
return (PerformanceLedger.payment_days.is_(None),)
|
||||
conds = []
|
||||
if bucket.min_days is not None:
|
||||
conds.append(PerformanceLedger.payment_days >= bucket.min_days)
|
||||
if bucket.max_days is not None:
|
||||
conds.append(PerformanceLedger.payment_days <= bucket.max_days)
|
||||
return tuple(conds)
|
||||
|
||||
|
||||
def list_payment_detail(
|
||||
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
|
||||
) -> list:
|
||||
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
|
||||
|
||||
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
|
||||
"""
|
||||
from app.models import Receipt
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
PerformanceLedger,
|
||||
Receipt.amount.label("receipt_amount"),
|
||||
Receipt.receipt_batch_no.label("receipt_batch_no"),
|
||||
)
|
||||
.outerjoin(Receipt, Receipt.id == PerformanceLedger.receipt_id)
|
||||
.where(*_payment_filters(query), *_bucket_condition(bucket))
|
||||
)
|
||||
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> int:
|
||||
"""区间下钻计数(与 list_payment_detail 同筛选)。"""
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(PerformanceLedger)
|
||||
.where(*_payment_filters(query), *_bucket_condition(bucket))
|
||||
).scalar_one()
|
||||
)
|
||||
@@ -92,3 +92,21 @@ def update_ledger_settlement_id(
|
||||
.values(settlement_id=settlement_id)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def clear_ledger_settlement_id(session: Session, settlement_id: int) -> None:
|
||||
"""重生成前解绑:释放本结算名下台账行的 settlement_id(「未入结」判据复位)。
|
||||
|
||||
草稿/驳回态重新生成时,上一轮回填的行必须重新进入候选集——否则
|
||||
``settlement_id IS NULL`` 会把它们永久排除,重生成漏算整月工资。
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
session.execute(
|
||||
update(PerformanceLedger)
|
||||
.where(PerformanceLedger.settlement_id == settlement_id)
|
||||
.values(settlement_id=None)
|
||||
)
|
||||
session.flush()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,369 @@
|
||||
"""报表统计的请求/响应模型(M4 ⑤)。
|
||||
|
||||
金额与比例一律 `Decimal`(pydantic v2 序列化为字符串,零精度损失);
|
||||
比例原样输出 `rate_text`(如 88/9000),禁止转 float 或截断展示。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.month import validate_month
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
def _required_month(value: str) -> str:
|
||||
"""必填月份:非法直接抛 ValueError,由 FastAPI 压成 400。"""
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
def _optional_month(value: str | None) -> str | None:
|
||||
"""可选月份:空串规范为 None(axios 未剔除时也不会误报格式错误)。"""
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
# ==== 查询入参 ====
|
||||
|
||||
|
||||
class PersonalDetailQuery(PageQuery):
|
||||
"""个人绩效明细查询条件(FR-029:人员/月份/客户类型/产品类别/岗位)。"""
|
||||
|
||||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||||
personnel_id: int | None = Field(default=None, ge=1, description="人员筛选")
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
role_code: str | None = Field(default=None, max_length=32, description="岗位筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
class DepartmentSummaryQuery(BaseModel):
|
||||
"""部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。"""
|
||||
|
||||
settlement_month: str = Field(min_length=7, max_length=7, description="归属月 YYYY-MM")
|
||||
manager_id: int | None = Field(default=None, ge=1, description="仅看某经理团队")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str) -> str:
|
||||
return _required_month(value)
|
||||
|
||||
|
||||
class BusinessStatsQuery(PageQuery):
|
||||
"""业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。"""
|
||||
|
||||
keyword: str | None = Field(default=None, max_length=64, description="业务编号/客户名称")
|
||||
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
|
||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人筛选")
|
||||
status: int | None = Field(default=None, description="业务状态筛选")
|
||||
date_from: date | None = Field(default=None, description="业务日期起")
|
||||
date_to: date | None = Field(default=None, description="业务日期止")
|
||||
|
||||
|
||||
class PaymentStatsQuery(BaseModel):
|
||||
"""回款统计查询条件(按回款天数区间/客户类型/产品类别统计,FR-031)。"""
|
||||
|
||||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||||
date_from: date | None = Field(default=None, description="回款日期起")
|
||||
date_to: date | None = Field(default=None, description="回款日期止")
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
class PaymentDetailQuery(PageQuery):
|
||||
"""回款区间下钻(桶编码必填,入参由 payment-stats 页面展开行携带)。"""
|
||||
|
||||
bucket: str = Field(min_length=1, max_length=16, description="区间桶编码")
|
||||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||||
date_from: date | None = Field(default=None, description="回款日期起")
|
||||
date_to: date | None = Field(default=None, description="回款日期止")
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
# ==== 响应出参 ====
|
||||
|
||||
|
||||
class ReportSummary(BaseModel):
|
||||
"""全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。
|
||||
|
||||
字段按报表取用:个人明细用绩效/应付/折算毛利;业务统计用发货/回款/
|
||||
销售额;不涉及的留 None。
|
||||
"""
|
||||
|
||||
row_count: int = Field(default=0, description="筛选结果行数")
|
||||
performance_total: Decimal | None = Field(default=None, description="绩效金额合计")
|
||||
payable_total: Decimal | None = Field(default=None, description="应付绩效合计")
|
||||
converted_profit_total: Decimal | None = Field(default=None, description="折算毛利润合计")
|
||||
ship_total: Decimal | None = Field(default=None, description="发货额合计")
|
||||
received_total: Decimal | None = Field(default=None, description="回款额合计")
|
||||
sale_total: Decimal | None = Field(default=None, description="销售额合计")
|
||||
|
||||
|
||||
class ReportPage[T](BaseModel):
|
||||
"""报表分页:标准分页四字段 + 全筛选合计。
|
||||
|
||||
序列化字段与 PageResult 一致(list/total/pageNum/pageSize),前端
|
||||
usePagedList 可直接消费;追加的 summary 由各页页脚单独读取。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
records: list[T] = Field(
|
||||
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
|
||||
)
|
||||
total: int = Field(default=0, description="总记录数")
|
||||
page_num: int = Field(
|
||||
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
|
||||
)
|
||||
page_size: int = Field(
|
||||
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
|
||||
)
|
||||
summary: ReportSummary = Field(default_factory=ReportSummary, description="全筛选合计")
|
||||
|
||||
|
||||
class PersonalDetailRow(BaseModel):
|
||||
"""个人绩效明细行(需规 2.4.1 列全覆盖)。
|
||||
|
||||
二次分摊拆行后同一回款按人多行(负责人余额行 + 参与人份额行),行内
|
||||
不放「关联回款金额」列——同回款金额重复展示会让合计无法自洽,金额
|
||||
下沉到快照下钻。
|
||||
"""
|
||||
|
||||
ledger_id: int
|
||||
business_id: int
|
||||
business_no: str
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
role_code: str
|
||||
role_name: str
|
||||
customer_type: str
|
||||
product_category: str
|
||||
ship_date: date | None = None
|
||||
receipt_date: date
|
||||
payment_days: int | None = None
|
||||
rate_text: str
|
||||
weight_r: int | None = None
|
||||
payment_weight: Decimal | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
converted_profit: Decimal | None = None
|
||||
is_probation: int
|
||||
missing_cost: int
|
||||
calc_version: int
|
||||
settlement_id: int | None = None
|
||||
|
||||
|
||||
class DepartmentMemberRow(BaseModel):
|
||||
"""部门汇总 · 团队成员明细(内联于团队行)。"""
|
||||
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
role_code: str
|
||||
is_probation: int
|
||||
member_kind: str = Field(
|
||||
default="regular",
|
||||
description="行来源:regular 普通台账 / manager_personal 经理个人户 / adjustment 仅调整单",
|
||||
)
|
||||
unassigned_reason: str | None = Field(
|
||||
default=None, description="未纳入团队基数的原因(仅未分配块)"
|
||||
)
|
||||
performance_sum: Decimal | None = None
|
||||
payable_sum: Decimal | None = None
|
||||
adjust_extra: Decimal = Field(default=Decimal("0"), description="调整单生效补发合计")
|
||||
adjust_deduct: Decimal = Field(default=Decimal("0"), description="调整单生效扣减合计")
|
||||
adjust_net: Decimal = Field(default=Decimal("0"), description="调整净额(补发为正)")
|
||||
net_total: Decimal | None = None
|
||||
ledger_ids: list[int] = Field(default_factory=list, description="台账行 id 列表(下钻锚点)")
|
||||
|
||||
|
||||
class DepartmentTeamRow(BaseModel):
|
||||
"""部门汇总 · 经理团队行。"""
|
||||
|
||||
manager_id: int
|
||||
manager_name: str
|
||||
manager_status: int = Field(description="经理在职状态(1 在职 0 离职)")
|
||||
member_count: int
|
||||
team_base: Decimal = Field(description="团队基数(12.5% 的底,等于成员绩效合计)")
|
||||
team_share_amount: Decimal = Field(description="12.5% 团队分成(分列展示)")
|
||||
company_share_amount: Decimal = Field(
|
||||
description="单经理 5% 名义值;该团队无月结聚合行时为 0(不发放)"
|
||||
)
|
||||
has_aggregate: bool = Field(
|
||||
default=False, description="本月是否有月结聚合行(决定是否发放 5%)"
|
||||
)
|
||||
aggregate_amount: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="月结聚合行应发(12.5%+5% 一次舍入);无聚合行时 0",
|
||||
)
|
||||
team_subtotal: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="团队应发小计 = 聚合行金额 + 调整净额",
|
||||
)
|
||||
company_base_ratio: Decimal = Field(description="团队基数占全公司基数比例(除零为 0)")
|
||||
adjust_extra: Decimal = Decimal("0")
|
||||
adjust_deduct: Decimal = Decimal("0")
|
||||
adjust_net: Decimal = Decimal("0")
|
||||
net_total: Decimal = Field(description="团队净额 = 团队应发小计")
|
||||
frozen_amount: Decimal | None = Field(default=None, description="月结聚合行冻结额(对照)")
|
||||
aggregate_ledger_id: int | None = None
|
||||
members: list[DepartmentMemberRow] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DepartmentCompanyBlock(BaseModel):
|
||||
"""部门汇总 · 全公司口径块(FR-030「全公司 5% 部分单列」)。"""
|
||||
|
||||
team_base_total: Decimal = Field(description="各团队基数之和(= 全部单笔绩效合计)")
|
||||
company_base: Decimal = Field(description="全公司基数(5% 的底)")
|
||||
company_share_pct: str = Field(description="分成比例参数原文")
|
||||
company_share_amount: Decimal = Field(description="单经理 5% 名义值")
|
||||
company_share_count: int = Field(
|
||||
default=0, description="计入应发的经理数(展示团队中有月结聚合行的数量)"
|
||||
)
|
||||
company_share_total: Decimal = Field(
|
||||
default=Decimal("0"), description="5% 合计 = 单份 × 计入次数"
|
||||
)
|
||||
|
||||
|
||||
class DepartmentReconciliation(BaseModel):
|
||||
"""部门汇总 · 对账块(导出/打印签字稿的合计口径,前端不自行求和)。
|
||||
|
||||
应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
|
||||
聚合行金额与月结 ``total_payable`` 同口径(12.5%+5% 一次舍入);
|
||||
``team_share_total`` 与 ``company_share_total`` 仅作分列展示,两者
|
||||
之和与聚合额的差记入 ``aggregate_rounding_diff``。
|
||||
"""
|
||||
|
||||
team_member_payable_total: Decimal = Field(description="展示团队成员的应付绩效合计")
|
||||
unassigned_payable_total: Decimal = Field(description="展示未分配人员的应付绩效合计")
|
||||
team_share_total: Decimal = Field(description="展示团队的 12.5% 分成合计(分列)")
|
||||
company_share_total: Decimal = Field(
|
||||
description="展示团队的全公司分成合计(每位有聚合行的经理各计一次,分列)"
|
||||
)
|
||||
aggregate_payable_total: Decimal = Field(
|
||||
description="展示团队的经理聚合行应发合计(12.5%+5% 一次舍入)"
|
||||
)
|
||||
aggregate_rounding_diff: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
|
||||
)
|
||||
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
|
||||
payable_total: Decimal = Field(description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额")
|
||||
team_base_total: Decimal = Field(description="展示团队基数合计")
|
||||
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
|
||||
other_team_base: Decimal = Field(
|
||||
default=Decimal("0"), description="未展示的基数(其他团队 + 全公司未分配)"
|
||||
)
|
||||
other_team_member_payable_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_share_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_company_share_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_aggregate_total: Decimal = Field(default=Decimal("0"))
|
||||
other_adjust_net_total: Decimal = Field(default=Decimal("0"))
|
||||
company_base: Decimal = Field(description="全公司基数(5% 的底)")
|
||||
|
||||
|
||||
class DepartmentSummaryOut(BaseModel):
|
||||
"""部门绩效工资汇总响应(FR-030)。"""
|
||||
|
||||
settlement_month: str
|
||||
params: dict | None = Field(default=None, description="分成参数原文与取值时点(对账用)")
|
||||
teams: list[DepartmentTeamRow] = Field(default_factory=list)
|
||||
unassigned: list[DepartmentMemberRow] = Field(
|
||||
default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)"
|
||||
)
|
||||
company: DepartmentCompanyBlock | None = None
|
||||
reconciliation: DepartmentReconciliation | None = Field(
|
||||
default=None, description="签字稿对账块(应发公式各项)"
|
||||
)
|
||||
totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)")
|
||||
|
||||
|
||||
class BusinessStatsRow(BaseModel):
|
||||
"""业务统计行(发货/回款/绩效为该业务累计值)。"""
|
||||
|
||||
business_id: int
|
||||
business_no: str
|
||||
business_date: date | None = None
|
||||
status: int
|
||||
customer_name: str | None = None
|
||||
owner_personnel_name: str | None = None
|
||||
total_sale_amount: Decimal | None = None
|
||||
total_ship_amount: Decimal | None = None
|
||||
total_received_amount: Decimal | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
|
||||
|
||||
class BucketRow(BaseModel):
|
||||
"""回款统计 · 区间桶行(空桶恒在且为 0,防金额静默消失)。"""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
min_days: int | None = None
|
||||
max_days: int | None = None
|
||||
receipt_amount: Decimal | None = None
|
||||
receipt_count: int = 0
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
ledger_count: int = 0
|
||||
|
||||
|
||||
class GroupRow(BaseModel):
|
||||
"""回款统计 · 维度对比行(客户类型 / 产品类别)。"""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
receipt_amount: Decimal | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
ledger_count: int = 0
|
||||
|
||||
|
||||
class PaymentStatsOut(BaseModel):
|
||||
"""回款情况统计响应(FR-031)。
|
||||
|
||||
口径:已计提回款(有台账行)的金额与绩效;进度登记回款无台账行,
|
||||
不在本表(页面需注明)。
|
||||
"""
|
||||
|
||||
settlement_month: str | None = None
|
||||
buckets: list[BucketRow] = Field(default_factory=list)
|
||||
by_customer_type: list[GroupRow] = Field(default_factory=list)
|
||||
by_product_category: list[GroupRow] = Field(default_factory=list)
|
||||
totals: dict = Field(default_factory=dict, description="全表合计")
|
||||
|
||||
|
||||
class PaymentDetailRow(BaseModel):
|
||||
"""回款区间下钻行(行级展示回款金额与批次号)。"""
|
||||
|
||||
ledger_id: int
|
||||
receipt_id: int | None = None
|
||||
receipt_batch_no: str | None = None
|
||||
amount: Decimal | None = Field(default=None, description="回款金额(同回款拆行重复展示)")
|
||||
business_id: int
|
||||
business_no: str
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
customer_type: str
|
||||
product_category: str
|
||||
ship_date: date | None = None
|
||||
receipt_date: date
|
||||
payment_days: int | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
@@ -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):
|
||||
@@ -86,5 +82,15 @@ class AdjustmentCreateIn(BaseModel):
|
||||
settlement_month: str = Field(description="调整月份 YYYY-MM")
|
||||
personnel_id: int = Field(ge=1, description="调整人员ID")
|
||||
direction: int = Field(ge=1, le=2, description="1补发 2扣减")
|
||||
amount: str = Field(description="金额(元,两位小数)")
|
||||
# 正则先拦格式(非负两位小数、≤12 位整数防 NUMERIC(14,2) 溢出);
|
||||
# 「> 0」由服务层判定(schema 只管形状,语义校验留在 service)
|
||||
amount: str = Field(
|
||||
pattern=r"^\d{1,12}(\.\d{1,2})?$",
|
||||
description="金额(元,两位小数,恒正)",
|
||||
)
|
||||
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str) -> str:
|
||||
return _check_month(value)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -64,10 +66,12 @@ from app.services.no_generator import next_no
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 开关参数编码(与 param_registry 种子同源)
|
||||
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
|
||||
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
|
||||
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
||||
1: "approval.secondary_allocation.enabled",
|
||||
2: "approval.monthly_settlement.enabled",
|
||||
3: "approval.adjustment.enabled",
|
||||
}
|
||||
|
||||
# 在途状态集合(同一引用对象至多一张在途单)
|
||||
@@ -82,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)
|
||||
@@ -219,6 +235,10 @@ def act(
|
||||
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
||||
else APPROVAL_STATUS_APPROVED
|
||||
)
|
||||
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
|
||||
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
|
||||
# 驳回在任何节点都是终态,即时联动。
|
||||
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
|
||||
|
||||
session.add(
|
||||
ApprovalStep(
|
||||
@@ -259,12 +279,15 @@ def act(
|
||||
if target is not None:
|
||||
if rejected:
|
||||
settlement_service.mark_rejected(session, target, operator=operator)
|
||||
else:
|
||||
elif final_approved:
|
||||
settlement_service.mark_locked(session, target, operator=operator)
|
||||
else:
|
||||
adj = session.get(AdjustmentOrder, order.ref_id)
|
||||
if adj is not None:
|
||||
adj.status = ADJ_REJECTED if rejected else ADJUSTMENT_STATUS_EFFECTIVE
|
||||
if rejected:
|
||||
adj.status = ADJ_REJECTED
|
||||
elif final_approved:
|
||||
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
|
||||
|
||||
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
|
||||
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
|
||||
|
||||
@@ -298,8 +298,24 @@ def _build_allocation_rows(
|
||||
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
|
||||
**新加入**的参与人做在职校验。
|
||||
|
||||
:raises BadRequestError: 新增参与人员不存在或已离职
|
||||
:raises BadRequestError: 新增参与人员不存在或已离职 / 比例合计超 100%
|
||||
/ 参与人含合同负责人(口径见澄清 #1/#2 答复 2026-10-19:分配绩效、
|
||||
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
|
||||
自分份额则双重计入)
|
||||
"""
|
||||
business = business_repository.get_by_id(session, business_id)
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
total = sum((Decimal(item.ratio) for item in items), Decimal("0"))
|
||||
if total > Decimal("100"):
|
||||
raise BadRequestError(
|
||||
f"二次分配比例合计 {total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}%"
|
||||
" 超过 100%(余额归合同负责营销人员,比例不可超满额)"
|
||||
)
|
||||
owner_id = business.owner_personnel_id
|
||||
if owner_id is not None and any(item.personnel_id == owner_id for item in items):
|
||||
raise BadRequestError("分摊参与人不可包含合同负责营销人员(负责人得余额份额)")
|
||||
|
||||
rows: list[SecondaryAllocation] = []
|
||||
for item in items:
|
||||
if item.personnel_id in old_ids:
|
||||
@@ -320,7 +336,7 @@ def _build_allocation_rows(
|
||||
|
||||
|
||||
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
||||
"""方案合计/余额提示(不强制合计满额是需求原文;余额归属待澄清 #2)。
|
||||
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
|
||||
|
||||
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
|
||||
"""
|
||||
@@ -331,7 +347,7 @@ def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
||||
if total == Decimal("100"):
|
||||
return f"二次分配合计 {total_text}%"
|
||||
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
return f"二次分配合计 {total_text}%,余额 {rest}%(归属待业务确认)"
|
||||
return f"二次分配合计 {total_text}%,余额 {rest}%(归合同负责营销人员)"
|
||||
|
||||
|
||||
def create_business(
|
||||
|
||||
@@ -6,16 +6,20 @@
|
||||
按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、
|
||||
核算才是算钱的法定固化点。
|
||||
|
||||
策略点(待业务答复,全部集中为单函数,答复后只改函数 + 按回款批量重算):
|
||||
- ``_personal_rate``:#12 经理个人户单笔落台账 rate=0/绩效 0(12.5%+5%
|
||||
聚合口径在月结时由 manager_team_share 计算);#20 在 ``_row_profit``
|
||||
策略点(业务答复 2026-10-19 已回填,后续调整仍集中在对应函数 + 按回款批量重算):
|
||||
- ``_personal_rate``:#12 经理个人户单笔由参数 manager.personal.included
|
||||
分流——0=单笔落 0(聚合口径在月结时由 manager_team_share 计算)、
|
||||
1=按业务员递减公式计提;#20 在 ``_row_profit``(暂按不含税原样相减)
|
||||
- 二次分摊(澄清 #1 答复「分配绩效」,算后分):``_load_allocations``
|
||||
读生效方案,``_persist_ledger`` 按比例拆行(参与人份额 + 负责人余额,
|
||||
行合计守恒);#2 余额归负责人、#3 二次分配即协助场景的核算载体
|
||||
- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发,
|
||||
后续回款仅置 settlement_status=1(repeated=True),不重复计提
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
from typing import Any
|
||||
|
||||
@@ -29,6 +33,7 @@ from app.models import (
|
||||
BusinessItem,
|
||||
Customer,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
Receipt,
|
||||
Shipment,
|
||||
User,
|
||||
@@ -54,8 +59,8 @@ from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# snapshot_json 里的策略点注记(答复回填时的检索锚点)
|
||||
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(#12 待答复):聚合口径在月结时由团队聚合计算"
|
||||
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
|
||||
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -78,14 +83,17 @@ def calc_receipt(
|
||||
) -> CalcReceiptOut:
|
||||
"""单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。
|
||||
|
||||
防重前两层在本函数(FOR UPDATE 锁行 + settlement_status 校验),第三层
|
||||
是落台账 uk(receipt_id, calc_version)。全程一个 audit 事务,出参在
|
||||
commit 前固化。
|
||||
防重前两层在 ``_load_and_guard``(FOR UPDATE 锁行 + settlement_status
|
||||
与月结锁定校验),第三层是落台账 uk(receipt_id, calc_version)。全程
|
||||
一个 audit 事务,出参在 commit 前固化。
|
||||
|
||||
首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时
|
||||
仅登记进度(repeated=True)。
|
||||
仅登记进度(repeated=True,``_prepare_mode`` 内提前返回)。
|
||||
重算模式:回款必须已核算过;作废旧版本行(is_active=0),按当前参数
|
||||
落 calc_version+1 新行,calc_reason 留痕。
|
||||
|
||||
本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 →
|
||||
计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。
|
||||
"""
|
||||
is_recalc = recalc_reason is not None
|
||||
action = "重算" if is_recalc else "核算"
|
||||
@@ -103,189 +111,419 @@ def calc_receipt(
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
receipt = session.get(Receipt, receipt_id, with_for_update=True)
|
||||
if receipt is None:
|
||||
raise BadRequestError("回款不存在")
|
||||
if is_recalc:
|
||||
if receipt.settlement_status == SETTLEMENT_PENDING:
|
||||
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
|
||||
elif receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
|
||||
receipt, business = _load_and_guard(session, receipt_id, is_recalc=is_recalc)
|
||||
repeated_out = _prepare_mode(session, receipt, business, is_recalc=is_recalc)
|
||||
if repeated_out is not None:
|
||||
return repeated_out
|
||||
|
||||
business = session.get(Business, receipt.business_id, with_for_update=True)
|
||||
if business is None:
|
||||
raise BadRequestError("回款关联的业务不存在")
|
||||
if business.owner_personnel_id is None:
|
||||
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
|
||||
shipment = _load_shipment(session, receipt)
|
||||
person = _load_person(session, business)
|
||||
snapshot = resolve_snapshot(session, receipt.receipt_date)
|
||||
items = _load_items(session, business)
|
||||
allocations = _load_allocations(session, business, person.id)
|
||||
|
||||
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算
|
||||
if is_recalc:
|
||||
old = performance_ledger_repository.get_active_by_receipt(session, receipt_id)
|
||||
if old is not None:
|
||||
performance_ledger_repository.mark_inactive(session, old)
|
||||
|
||||
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
|
||||
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
|
||||
repeated = False
|
||||
if not is_recalc:
|
||||
repeated = performance_ledger_repository.has_active_for_business(
|
||||
session, business.id, calc_type=CALC_TYPE_RECEIPT
|
||||
)
|
||||
if repeated:
|
||||
_mark_settled(session, receipt)
|
||||
row = performance_ledger_repository.get_active_by_receipt_of_business(
|
||||
session, business.id
|
||||
)
|
||||
session.flush()
|
||||
out = CalcReceiptOut(ledger=_to_out(row), repeated=True)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"回款登记进度(业务已核算)| receipt=%s | business=%s", receipt_id, business.id
|
||||
)
|
||||
return out
|
||||
|
||||
if receipt.shipment_id is None:
|
||||
raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)")
|
||||
shipment = session.get(Shipment, receipt.shipment_id)
|
||||
if shipment is None or shipment.ship_date is None:
|
||||
raise BadRequestError("发货批次缺少发货日期,无法核算")
|
||||
|
||||
person = personnel_service.ensure_active(
|
||||
session, business.owner_personnel_id, label="核算归属"
|
||||
conversion = _convert_items(snapshot, person, items)
|
||||
amounts = _compose_amounts(
|
||||
session, snapshot, person, business, receipt, shipment, conversion
|
||||
)
|
||||
if person.business_role_id is None:
|
||||
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
|
||||
rows = _persist_ledger(
|
||||
session,
|
||||
receipt=receipt,
|
||||
business=business,
|
||||
person=person,
|
||||
shipment=shipment,
|
||||
items=items,
|
||||
allocations=allocations,
|
||||
conversion=conversion,
|
||||
amounts=amounts,
|
||||
recalc_reason=recalc_reason,
|
||||
is_recalc=is_recalc,
|
||||
)
|
||||
session.flush()
|
||||
out = CalcReceiptOut(ledger=_to_out(rows.owner_row), repeated=False)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"核算完成 | receipt=%s | ledger=%s | payable=%s | 分摊行=%s",
|
||||
receipt_id,
|
||||
rows.owner_row.id,
|
||||
amounts.payable,
|
||||
len(rows.share_rows),
|
||||
)
|
||||
return out
|
||||
|
||||
receipt_date = receipt.receipt_date
|
||||
snapshot = resolve_snapshot(session, receipt_date)
|
||||
items = business_repository.list_items(session, business.id)
|
||||
if not items:
|
||||
raise BadRequestError("业务无产品明细行,无法核算")
|
||||
|
||||
# 逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)
|
||||
total_revenue = Decimal("0")
|
||||
total_profit = Decimal("0")
|
||||
converted_total = Decimal("0")
|
||||
missing_cost = False
|
||||
line_details: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
row_revenue = item.sale_amount
|
||||
if item.cost_amount is None:
|
||||
missing_cost = True
|
||||
row_profit = Decimal("0")
|
||||
else:
|
||||
row_profit = _row_profit(row_revenue, item.cost_amount)
|
||||
total_revenue += row_revenue
|
||||
total_profit += row_profit
|
||||
threshold = parse_fraction(
|
||||
snapshot.get(
|
||||
"product.threshold",
|
||||
role_code=role_code_of(person),
|
||||
product_category=item.product_category,
|
||||
)
|
||||
)
|
||||
row_converted = converted_profit(
|
||||
row_revenue, profit_rate(row_profit, row_revenue), threshold
|
||||
)
|
||||
converted_total += row_converted
|
||||
line_details.append(
|
||||
{
|
||||
"item_id": item.id,
|
||||
"product_category": item.product_category,
|
||||
"revenue": str(row_revenue),
|
||||
"profit": str(row_profit),
|
||||
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
|
||||
# 快照/台账口径需与列一致)
|
||||
"converted": str(row_converted.quantize(Decimal("0.01"))),
|
||||
}
|
||||
)
|
||||
def _load_and_guard(
|
||||
session: Session, receipt_id: int, *, is_recalc: bool
|
||||
) -> tuple[Receipt, Business]:
|
||||
"""锁行 + 模式校验 + 月结锁定拦截 + 业务归属校验(核算/重算共用)。"""
|
||||
receipt = session.get(Receipt, receipt_id, with_for_update=True)
|
||||
if receipt is None:
|
||||
raise BadRequestError("回款不存在")
|
||||
if is_recalc:
|
||||
if receipt.settlement_status == SETTLEMENT_PENDING:
|
||||
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
|
||||
elif receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
|
||||
|
||||
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
|
||||
days = payment_days(shipment.ship_date, receipt_date)
|
||||
decision = _personal_rate(session, snapshot, person, business, days)
|
||||
# 月结锁定拦截:已审定月份的回款不得再核算/重算——金额已固化,
|
||||
# 变动唯一通道是调整单(否则重算会绕开审批改写已锁定月份的应付)
|
||||
from app.services import settlement_service
|
||||
|
||||
if decision.base_override is not None:
|
||||
converted = decision.base_override # 信息员:基数=本人销售额
|
||||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||
|
||||
business = session.get(Business, receipt.business_id, with_for_update=True)
|
||||
if business is None:
|
||||
raise BadRequestError("回款关联的业务不存在")
|
||||
if business.owner_personnel_id is None:
|
||||
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
|
||||
return receipt, business
|
||||
|
||||
|
||||
def _prepare_mode(
|
||||
session: Session, receipt: Receipt, business: Business, *, is_recalc: bool
|
||||
) -> CalcReceiptOut | None:
|
||||
"""模式预处理:重算作废旧版本;首算判业务级一次性计提。
|
||||
|
||||
返回非 None = 本笔仅登记进度(repeated=True 的出参,已 commit),
|
||||
调用方直接返回。
|
||||
"""
|
||||
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算。
|
||||
# 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及
|
||||
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
|
||||
# 两行生效行导致双重计提 → 一律 400
|
||||
if is_recalc:
|
||||
# 分摊拆行后旧版本可能多行(负责人+参与人)——全部作废再落新版本
|
||||
olds = performance_ledger_repository.list_active_by_receipt(session, receipt.id)
|
||||
if not olds:
|
||||
raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
|
||||
for old in olds:
|
||||
performance_ledger_repository.mark_inactive(session, old)
|
||||
return None
|
||||
|
||||
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
|
||||
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
|
||||
if not performance_ledger_repository.has_active_for_business(
|
||||
session, business.id, calc_type=CALC_TYPE_RECEIPT
|
||||
):
|
||||
return None
|
||||
_mark_settled(session, receipt)
|
||||
row = performance_ledger_repository.get_active_by_receipt_of_business(session, business.id)
|
||||
session.flush()
|
||||
out = CalcReceiptOut(ledger=_to_out(row), repeated=True)
|
||||
session.commit()
|
||||
logger.info("回款登记进度(业务已核算)| receipt=%s | business=%s", receipt.id, business.id)
|
||||
return out
|
||||
|
||||
|
||||
def _load_shipment(session: Session, receipt: Receipt) -> Shipment:
|
||||
"""核销基准:发货批次存在且带发货日(回款天数公式的输入)。"""
|
||||
if receipt.shipment_id is None:
|
||||
raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)")
|
||||
shipment = session.get(Shipment, receipt.shipment_id)
|
||||
if shipment is None or shipment.ship_date is None:
|
||||
raise BadRequestError("发货批次缺少发货日期,无法核算")
|
||||
return shipment
|
||||
|
||||
|
||||
def _load_person(session: Session, business: Business):
|
||||
"""核算归属:在职 + 绑定岗位角色(计提公式锚点)。"""
|
||||
person = personnel_service.ensure_active(session, business.owner_personnel_id, label="核算归属")
|
||||
if person.business_role_id is None:
|
||||
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
|
||||
return person
|
||||
|
||||
|
||||
def _load_items(session: Session, business: Business) -> list[BusinessItem]:
|
||||
"""业务产品明细行(逐行折算的输入;空明细无法核算)。"""
|
||||
items = business_repository.list_items(session, business.id)
|
||||
if not items:
|
||||
raise BadRequestError("业务无产品明细行,无法核算")
|
||||
return items
|
||||
|
||||
|
||||
def _load_allocations(session: Session, business: Business, owner_id: int) -> list:
|
||||
"""业务生效的二次分配方案(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
|
||||
|
||||
校验双保险之一(保存侧在 business_service):比例合计 >100% 会把负责人
|
||||
份额算成负数(多算参与人绩效);合同负责人不可出现在参与人列表(自分
|
||||
份额会双重计入)。无方案返回空列表 = 全额归负责人。
|
||||
"""
|
||||
allocations = business_repository.list_allocations(session, business.id)
|
||||
if not allocations:
|
||||
return []
|
||||
total = sum((a.ratio for a in allocations), Decimal("0"))
|
||||
if total > Decimal("100"):
|
||||
raise BadRequestError(
|
||||
f"二次分配比例合计 {total.quantize(Decimal('0.01'))}% 超过 100%,"
|
||||
"无法核算(先调整方案比例)"
|
||||
)
|
||||
if any(a.personnel_id == owner_id for a in allocations):
|
||||
raise BadRequestError("二次分配参与人含合同负责营销人员,无法核算(先调整方案)")
|
||||
return list(allocations)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Conversion:
|
||||
"""逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。"""
|
||||
|
||||
total_revenue: Decimal
|
||||
total_profit: Decimal
|
||||
converted_total: Decimal
|
||||
missing_cost: bool
|
||||
line_details: list[dict[str, Any]]
|
||||
|
||||
|
||||
def _convert_items(snapshot, person, items: list[BusinessItem]) -> _Conversion:
|
||||
"""逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)。"""
|
||||
total_revenue = Decimal("0")
|
||||
total_profit = Decimal("0")
|
||||
converted_total = Decimal("0")
|
||||
missing_cost = False
|
||||
line_details: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
row_revenue = item.sale_amount
|
||||
if item.cost_amount is None:
|
||||
missing_cost = True
|
||||
row_profit = Decimal("0")
|
||||
else:
|
||||
converted = converted_total
|
||||
|
||||
performance = (
|
||||
Decimal("0")
|
||||
if decision.manager_pending
|
||||
else performance_amount(converted, decision.rate)
|
||||
)
|
||||
barter_applied = receipt.is_barter == 1
|
||||
base_amount = performance
|
||||
if barter_applied:
|
||||
discount = parse_fraction(
|
||||
snapshot.get("barter.discount", role_code=role_code_of(person))
|
||||
row_profit = _row_profit(row_revenue, item.cost_amount)
|
||||
total_revenue += row_revenue
|
||||
total_profit += row_profit
|
||||
threshold = parse_fraction(
|
||||
snapshot.get(
|
||||
"product.threshold",
|
||||
role_code=role_code_of(person),
|
||||
product_category=item.product_category,
|
||||
)
|
||||
base_amount = apply_barter(performance, discount)
|
||||
|
||||
received_total = business.total_received_amount or Decimal("0")
|
||||
start_ratio = parse_fraction(
|
||||
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
|
||||
)
|
||||
start_ok = start_payment_ok(received_total, total_revenue, start_ratio)
|
||||
weight_r = clamp_weight_r(received_total, total_revenue)
|
||||
weight = payment_weight(weight_r)
|
||||
payable = param_service.to_decimal(Fraction(base_amount) * weight)
|
||||
row_converted = converted_profit(
|
||||
row_revenue, profit_rate(row_profit, row_revenue), threshold
|
||||
)
|
||||
converted_total += row_converted
|
||||
line_details.append(
|
||||
{
|
||||
"item_id": item.id,
|
||||
"product_category": item.product_category,
|
||||
"revenue": str(row_revenue),
|
||||
"profit": str(row_profit),
|
||||
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
|
||||
# 快照/台账口径需与列一致)
|
||||
"converted": str(_q2(row_converted)),
|
||||
}
|
||||
)
|
||||
return _Conversion(total_revenue, total_profit, converted_total, missing_cost, line_details)
|
||||
|
||||
settlement_month = receipt_date.strftime("%Y-%m")
|
||||
snapshot_json: dict[str, Any] = {
|
||||
"params_as_of": str(receipt_date),
|
||||
"line_details": line_details,
|
||||
"start_payment_ok": start_ok,
|
||||
"rate_note": decision.rate_note,
|
||||
"revenue_total": str(total_revenue),
|
||||
"profit_total": str(total_profit),
|
||||
}
|
||||
if decision.manager_pending:
|
||||
snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING
|
||||
|
||||
def q2(value: Decimal | None) -> Decimal | None:
|
||||
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。"""
|
||||
return value.quantize(Decimal("0.01")) if value is not None else None
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Amounts:
|
||||
"""计提组成结果(比例分派 → 绩效 → 抵顶 → 支付权重 → 应付)。"""
|
||||
|
||||
days: int
|
||||
decision: _RateDecision
|
||||
converted: Decimal
|
||||
performance: Decimal
|
||||
barter_applied: bool
|
||||
weight_r: int
|
||||
weight: Fraction
|
||||
payable: Decimal
|
||||
start_ok: bool
|
||||
|
||||
|
||||
def _compose_amounts(
|
||||
session: Session,
|
||||
snapshot,
|
||||
person,
|
||||
business: Business,
|
||||
receipt: Receipt,
|
||||
shipment: Shipment,
|
||||
conversion: _Conversion,
|
||||
) -> _Amounts:
|
||||
"""按比例分派与支付权重组成应付(与 param_service.preview 同公式路径)。"""
|
||||
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
|
||||
days = payment_days(shipment.ship_date, receipt.receipt_date)
|
||||
decision = _personal_rate(session, snapshot, person, business, days)
|
||||
|
||||
if decision.base_override is not None:
|
||||
converted = decision.base_override # 信息员:基数=本人销售额
|
||||
else:
|
||||
converted = conversion.converted_total
|
||||
|
||||
performance = (
|
||||
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
|
||||
)
|
||||
barter_applied = receipt.is_barter == 1
|
||||
base_amount = performance
|
||||
if barter_applied:
|
||||
discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person)))
|
||||
base_amount = apply_barter(performance, discount)
|
||||
|
||||
received_total = business.total_received_amount or Decimal("0")
|
||||
start_ratio = parse_fraction(
|
||||
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
|
||||
)
|
||||
start_ok = start_payment_ok(received_total, conversion.total_revenue, start_ratio)
|
||||
weight_r = clamp_weight_r(received_total, conversion.total_revenue)
|
||||
weight = payment_weight(weight_r)
|
||||
payable = param_service.to_decimal(Fraction(base_amount) * weight)
|
||||
return _Amounts(
|
||||
days=days,
|
||||
decision=decision,
|
||||
converted=converted,
|
||||
performance=performance,
|
||||
barter_applied=barter_applied,
|
||||
weight_r=weight_r,
|
||||
weight=weight,
|
||||
payable=payable,
|
||||
start_ok=start_ok,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LedgerRows:
|
||||
"""拆行落库结果:负责人行恒存在(出参与 CAS 锚点),参与人份额行可空。"""
|
||||
|
||||
owner_row: PerformanceLedger
|
||||
share_rows: list[PerformanceLedger]
|
||||
|
||||
|
||||
def _persist_ledger(
|
||||
session: Session,
|
||||
*,
|
||||
receipt: Receipt,
|
||||
business: Business,
|
||||
person,
|
||||
shipment: Shipment,
|
||||
items: list[BusinessItem],
|
||||
allocations: list,
|
||||
conversion: _Conversion,
|
||||
amounts: _Amounts,
|
||||
recalc_reason: str | None,
|
||||
is_recalc: bool,
|
||||
) -> _LedgerRows:
|
||||
"""落台账行(分摊拆行;版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。
|
||||
|
||||
分摊(澄清 #1 答复 2026-10-19「分配绩效」,算后分):业务级全额(含
|
||||
递减比例、抵顶、支付权重全环节)算完后按方案比例拆给参与人,余额归
|
||||
合同负责人。行合计与业务级全额**守恒**(负责人行取 q2 全额减参与人
|
||||
q2 份额的差额,防分漂);每行快照带完整方案快照,行级可独立回溯。
|
||||
参与人离职照分(历史方案事实,方案调整是业务侧责任——入职校验只在
|
||||
保存侧,核算侧只要求人员存在,FK RESTRICT 已保证)。
|
||||
"""
|
||||
receipt_date = receipt.receipt_date
|
||||
base_snapshot: dict[str, Any] = {
|
||||
"params_as_of": str(receipt_date),
|
||||
"line_details": conversion.line_details,
|
||||
"start_payment_ok": amounts.start_ok,
|
||||
"rate_note": amounts.decision.rate_note,
|
||||
"revenue_total": str(conversion.total_revenue),
|
||||
"profit_total": str(conversion.total_profit),
|
||||
}
|
||||
if amounts.decision.manager_pending:
|
||||
base_snapshot["strategy_note"] = _NOTE_MANAGER_PENDING
|
||||
if allocations:
|
||||
base_snapshot["alloc_plan"] = [
|
||||
{
|
||||
"personnel_id": a.personnel_id,
|
||||
"personnel_name": a.personnel_name,
|
||||
"ratio": str(a.ratio),
|
||||
}
|
||||
for a in allocations
|
||||
]
|
||||
|
||||
version = performance_ledger_repository.next_version(session, receipt.id)
|
||||
customer_type = _customer_type(session, business)
|
||||
category = _category_label(items)
|
||||
|
||||
# 份额清单:(person, ratio, tag)——ratio 为 None 时走余额公式(q2 全额
|
||||
# 减已分累计,守恒);负责人余额的百分数(100−∑参与人)仅作快照展示,
|
||||
# 不参与计算——按它乘全额会丢 q2 尾差(39.1111×30% ≠ 39.11−27.37)
|
||||
shares: list[tuple[Any, Decimal | None, str]] = []
|
||||
owner_ratio_text: str | None = None
|
||||
if not allocations:
|
||||
shares.append((person, None, "owner"))
|
||||
owner_ratio_text = "100"
|
||||
else:
|
||||
total_ratio = Decimal("0")
|
||||
for a in allocations:
|
||||
shares.append((session.get(Personnel, a.personnel_id), a.ratio, "participant"))
|
||||
total_ratio += a.ratio
|
||||
owner_ratio_text = str((Decimal("100") - total_ratio).quantize(Decimal("0.01")))
|
||||
shares.append((person, None, "owner"))
|
||||
|
||||
def _share(full: Decimal, ratio: Decimal | None, *, added: Decimal = Decimal("0")) -> Decimal:
|
||||
"""单份额金额:参与人 = 全额×比例(q2);负责人 = q2 全额 − 已分累计。"""
|
||||
if ratio is None:
|
||||
return _q2(full - added) or Decimal("0")
|
||||
# Fraction 乘积先 to_decimal 再 q2(Fraction 无 quantize,直接喂 _q2 会炸)
|
||||
return _q2(param_service.to_decimal(Fraction(full) * Fraction(ratio) / 100)) or Decimal("0")
|
||||
|
||||
added_perf = Decimal("0")
|
||||
added_conv = Decimal("0")
|
||||
added_pay = Decimal("0")
|
||||
owner_row: PerformanceLedger | None = None
|
||||
share_rows: list[PerformanceLedger] = []
|
||||
for target, ratio, tag in shares:
|
||||
perf = _share(amounts.performance, ratio, added=added_perf)
|
||||
conv = _share(amounts.converted, ratio, added=added_conv)
|
||||
pay = _share(amounts.payable, ratio, added=added_pay)
|
||||
added_perf += perf
|
||||
added_conv += conv
|
||||
added_pay += pay
|
||||
row = performance_ledger_repository.add(
|
||||
session,
|
||||
PerformanceLedger(
|
||||
receipt_id=receipt.id,
|
||||
calc_version=performance_ledger_repository.next_version(session, receipt.id),
|
||||
calc_version=version,
|
||||
calc_type=CALC_TYPE_RECEIPT,
|
||||
business_id=business.id,
|
||||
business_no=business.business_no,
|
||||
personnel_id=person.id,
|
||||
personnel_name=person.personnel_name,
|
||||
role_code=role_code_of(person),
|
||||
customer_type=_customer_type(session, business),
|
||||
product_category=_category_label(items),
|
||||
personnel_id=target.id,
|
||||
personnel_name=target.personnel_name,
|
||||
role_code=role_code_of(target),
|
||||
customer_type=customer_type,
|
||||
product_category=category,
|
||||
ship_date=shipment.ship_date,
|
||||
receipt_date=receipt_date,
|
||||
payment_days=days,
|
||||
rate_text=str(decision.rate),
|
||||
converted_profit=q2(converted),
|
||||
performance_amount=q2(performance),
|
||||
barter_applied=1 if barter_applied else 0,
|
||||
weight_r=weight_r,
|
||||
payment_weight=param_service.to_decimal(weight),
|
||||
payable_amount=q2(payable),
|
||||
settlement_month=settlement_month,
|
||||
is_probation=1 if person.is_probation == 1 else 0,
|
||||
missing_cost=1 if missing_cost else 0,
|
||||
snapshot_json=snapshot_json,
|
||||
payment_days=amounts.days,
|
||||
rate_text=str(amounts.decision.rate),
|
||||
converted_profit=conv,
|
||||
performance_amount=perf,
|
||||
barter_applied=1 if amounts.barter_applied else 0,
|
||||
weight_r=amounts.weight_r,
|
||||
payment_weight=param_service.to_decimal(amounts.weight),
|
||||
payable_amount=pay,
|
||||
settlement_month=receipt_date.strftime("%Y-%m"),
|
||||
is_probation=1 if target.is_probation == 1 else 0,
|
||||
missing_cost=1 if conversion.missing_cost else 0,
|
||||
snapshot_json={
|
||||
**base_snapshot,
|
||||
"alloc_role": tag,
|
||||
"alloc_ratio": owner_ratio_text if tag == "owner" else str(ratio),
|
||||
},
|
||||
calc_reason=recalc_reason,
|
||||
),
|
||||
)
|
||||
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
|
||||
# 会 rowcount=0 误报并发撞车)
|
||||
if not is_recalc:
|
||||
_mark_settled(session, receipt)
|
||||
session.flush()
|
||||
out = CalcReceiptOut(ledger=_to_out(row), repeated=False)
|
||||
session.commit()
|
||||
logger.info("核算完成 | receipt=%s | ledger=%s | payable=%s", receipt_id, row.id, payable)
|
||||
return out
|
||||
if tag == "owner":
|
||||
owner_row = row
|
||||
else:
|
||||
share_rows.append(row)
|
||||
|
||||
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
|
||||
# 会 rowcount=0 误报并发撞车)
|
||||
if not is_recalc:
|
||||
_mark_settled(session, receipt)
|
||||
return _LedgerRows(owner_row=owner_row, share_rows=share_rows)
|
||||
|
||||
|
||||
def _q2(value: Decimal | None) -> Decimal | None:
|
||||
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。
|
||||
|
||||
显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家舍入),
|
||||
x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _personal_rate(
|
||||
@@ -310,12 +548,23 @@ def _personal_rate(
|
||||
|
||||
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
|
||||
if template == "TPL_MANAGER_SHARE":
|
||||
return _RateDecision(
|
||||
Decimal("0"),
|
||||
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
|
||||
None,
|
||||
True,
|
||||
)
|
||||
# #12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户
|
||||
# 单按业务员递减公式单笔计提(个人户单本质是正常业务,有发货/回款/
|
||||
# 毛利;聚合基数开关在 settlement_service 同参数消费)——0 时单笔落 0
|
||||
if snapshot.get("manager.personal.included") != "1":
|
||||
return _RateDecision(
|
||||
Decimal("0"),
|
||||
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
|
||||
None,
|
||||
True,
|
||||
)
|
||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||
note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天"
|
||||
return _RateDecision(rate, note, None, False)
|
||||
if template == "TPL_INFO_SALES_FIXED":
|
||||
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
||||
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
||||
@@ -324,7 +573,9 @@ def _personal_rate(
|
||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||
boundary = int(snapshot.get("salesperson.days.boundary"))
|
||||
# int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500;
|
||||
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
|
||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
|
||||
return _RateDecision(rate, note, None, False)
|
||||
@@ -393,7 +644,7 @@ def recalc_receipt(
|
||||
"""重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。
|
||||
|
||||
薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定
|
||||
的拦截在月结(T5)接入后补入本函数。
|
||||
的拦截在 calc_receipt 内(核算与重算同口径)。
|
||||
"""
|
||||
if not reason or not reason.strip():
|
||||
raise BadRequestError("重算必须填写原因")
|
||||
|
||||
@@ -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))
|
||||
@@ -41,6 +41,10 @@ def salesperson_rate(
|
||||
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
|
||||
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
|
||||
|
||||
首日取整(澄清 #14 业务答复 2026-10-19):第 1 天**不递减**、直接取
|
||||
起始值(需规图示「第 1 天 10‰」覆盖公式的 9.9889‰)——第 2 天起按公式
|
||||
递减,360 天=6‰ 锚点与 361 天=4‰ 分界均不受影响。
|
||||
|
||||
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
|
||||
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
|
||||
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
|
||||
@@ -48,6 +52,8 @@ def salesperson_rate(
|
||||
days = max(days, 0)
|
||||
if days > boundary_days:
|
||||
return floor / 1000
|
||||
if days <= 1:
|
||||
return start / 1000
|
||||
return (start - days * slope) / 1000
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -291,6 +291,12 @@ def update_receipt(
|
||||
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("已核算回款禁止修改")
|
||||
business = _ensure_business_writable(session, receipt.business_id, "修改")
|
||||
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
|
||||
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||
settlement_service.ensure_month_not_locked(session, payload.receipt_date.strftime("%Y-%m"))
|
||||
# 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
|
||||
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
|
||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||
@@ -342,6 +348,10 @@ def delete_receipt(
|
||||
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("已核算回款禁止删除")
|
||||
business = _ensure_business_writable(session, receipt.business_id, "删除")
|
||||
# 月结锁定拦截:已锁定月份的回款是月结快照的源数据,不得删除
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||
|
||||
batch_no = receipt.receipt_batch_no
|
||||
receipt_repository.delete(session, receipt)
|
||||
|
||||
@@ -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
@@ -9,7 +9,8 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -25,20 +26,23 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT
|
||||
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
|
||||
from app.models.performance_ledger import (
|
||||
CALC_TYPE_MANAGER_AGGREGATE,
|
||||
CALC_TYPE_RECEIPT,
|
||||
)
|
||||
from app.models.settlement import (
|
||||
SETTLEMENT_STATUS_APPROVED,
|
||||
SETTLEMENT_STATUS_DRAFT,
|
||||
SETTLEMENT_STATUS_PENDING,
|
||||
SETTLEMENT_STATUS_REJECTED,
|
||||
)
|
||||
from app.repositories import settlement_repository
|
||||
from app.repositories import performance_ledger_repository, settlement_repository
|
||||
from app.schemas.settlement import (
|
||||
SettlementDetailOut,
|
||||
SettlementOut,
|
||||
SettlementQuery,
|
||||
)
|
||||
from app.services import approval_service, audit_service
|
||||
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
|
||||
|
||||
@@ -59,8 +63,11 @@ def generate(
|
||||
"""生成月度结算快照(草稿):按确认回款口径取有效台账行,按人汇总。
|
||||
|
||||
- 同月已有月结行(任意状态)时 400——一行一月,重生成走「驳回/草稿态
|
||||
重新生成」(先清 items 重算头表)
|
||||
- 经理聚合(calc_type=2)在生成时计算(#12 暂口径:基数不含经理个人户)
|
||||
重新生成」(_release_previous_round 解绑回填后清明细重算)
|
||||
- 经理聚合(calc_type=2)在生成时计算并落库(#12 参数化见
|
||||
``_build_manager_aggregates``)
|
||||
|
||||
本函数只做编排,各段独立私有函数(口径修改只动对应段)。
|
||||
"""
|
||||
with audit_service.audit(
|
||||
session,
|
||||
@@ -74,101 +81,152 @@ def generate(
|
||||
meta=meta,
|
||||
):
|
||||
existing = settlement_repository.get_by_month(session, month)
|
||||
if existing is not None:
|
||||
if existing.status == SETTLEMENT_STATUS_APPROVED:
|
||||
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
|
||||
if existing.status == SETTLEMENT_STATUS_PENDING:
|
||||
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
|
||||
# 草稿/已驳回:允许重新生成(清明细重算)
|
||||
_guard_regeneration(existing, month)
|
||||
_release_previous_round(session, existing, month)
|
||||
|
||||
ledger_rows = _confirm_scope(session, month)
|
||||
if not ledger_rows:
|
||||
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
|
||||
|
||||
# 经理团队聚合(#12 策略点:基数不含经理个人户业绩)
|
||||
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
|
||||
|
||||
settlement = settlement_repository.get_by_month(session, month)
|
||||
is_new = settlement is None
|
||||
if is_new:
|
||||
settlement = MonthlySettlement(
|
||||
settlement_month=month,
|
||||
submitter_id=operator.id,
|
||||
submitter_name=operator.username,
|
||||
status=SETTLEMENT_STATUS_DRAFT,
|
||||
)
|
||||
settlement_repository.add(session, settlement)
|
||||
else:
|
||||
settlement.submitter_id = operator.id
|
||||
settlement.submitter_name = operator.username
|
||||
settlement.status = SETTLEMENT_STATUS_DRAFT
|
||||
|
||||
# 按人汇总(台账行:personnel+calc_type 维度)
|
||||
by_person: dict[tuple[int, int], dict] = {}
|
||||
for row in ledger_rows + aggregate_rows:
|
||||
key = (row.personnel_id, row.calc_type)
|
||||
bucket = by_person.setdefault(
|
||||
key,
|
||||
{
|
||||
"personnel_id": row.personnel_id,
|
||||
"personnel_name": row.personnel_name,
|
||||
"role_code": row.role_code,
|
||||
"calc_type": row.calc_type,
|
||||
"performance_sum": Decimal("0"),
|
||||
"payable_sum": Decimal("0"),
|
||||
"ledger_ids": [],
|
||||
"is_probation": row.is_probation,
|
||||
},
|
||||
)
|
||||
bucket["performance_sum"] += row.performance_amount or Decimal("0")
|
||||
bucket["payable_sum"] += row.payable_amount or Decimal("0")
|
||||
bucket["ledger_ids"].append(row.id)
|
||||
|
||||
items: list[SettlementItem] = []
|
||||
for (_, _), bucket in sorted(by_person.items()):
|
||||
person = session.get(Personnel, bucket["personnel_id"])
|
||||
basic = person.salary_standard if person else None
|
||||
mode = _salary_mode_of(session, bucket["role_code"])
|
||||
net = (
|
||||
monthly_salary(bucket["payable_sum"], basic, mode)
|
||||
if person and person.is_probation != 1
|
||||
else None
|
||||
)
|
||||
note = None
|
||||
if person and person.is_probation == 1:
|
||||
note = (
|
||||
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session)} 元部分"
|
||||
"年底一次性结算"
|
||||
)
|
||||
items.append(
|
||||
SettlementItem(
|
||||
personnel_id=bucket["personnel_id"],
|
||||
personnel_name=bucket["personnel_name"],
|
||||
role_code=bucket["role_code"],
|
||||
calc_type=bucket["calc_type"],
|
||||
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01")),
|
||||
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01")),
|
||||
basic_amount=basic,
|
||||
salary_mode=mode,
|
||||
net_amount=net.quantize(Decimal("0.01")) if net is not None else None,
|
||||
is_probation=bucket["is_probation"],
|
||||
probation_note=note,
|
||||
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
|
||||
)
|
||||
)
|
||||
settlement = _upsert_draft(session, existing, operator, month)
|
||||
items, ledger_ids = _build_items(session, ledger_rows, aggregate_rows, month)
|
||||
|
||||
settlement_repository.replace_items(session, settlement.id, items)
|
||||
settlement.total_performance = sum(
|
||||
(i.performance_sum for i in items), Decimal("0")
|
||||
).quantize(Decimal("0.01"))
|
||||
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
|
||||
Decimal("0.01")
|
||||
)
|
||||
settlement.person_count = len({i.personnel_id for i in items})
|
||||
_apply_totals(settlement, items)
|
||||
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
|
||||
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
|
||||
# 不回填会让重生成把已固化行反复纳入;聚合行此刻已落库、id 可用
|
||||
settlement_repository.update_ledger_settlement_id(session, ledger_ids, settlement.id)
|
||||
|
||||
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
|
||||
# 记成失败审计,与库中事实相反)
|
||||
session.flush()
|
||||
detail = _detail_of(session, settlement)
|
||||
session.commit()
|
||||
logger.info("月结生成 | month=%s | items=%s", month, len(items))
|
||||
return _detail_of(session, settlement)
|
||||
return detail
|
||||
|
||||
|
||||
def _guard_regeneration(existing: MonthlySettlement | None, month: str) -> None:
|
||||
"""重生成准入:已审定 / 待审批不可重生成(草稿、已驳回放行)。"""
|
||||
if existing is None:
|
||||
return
|
||||
if existing.status == SETTLEMENT_STATUS_APPROVED:
|
||||
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
|
||||
if existing.status == SETTLEMENT_STATUS_PENDING:
|
||||
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
|
||||
|
||||
|
||||
def _release_previous_round(
|
||||
session: Session, existing: MonthlySettlement | None, month: str
|
||||
) -> None:
|
||||
"""释放上一轮固化标记(必须早于 _confirm_scope)。
|
||||
|
||||
台账行 settlement_id 回填后,「未入结」过滤会把它们永久排除在候选集外;
|
||||
旧聚合行也要作废,否则本轮重建的聚合行与旧行重复计入。
|
||||
"""
|
||||
if existing is None:
|
||||
return
|
||||
settlement_repository.clear_ledger_settlement_id(session, existing.id)
|
||||
performance_ledger_repository.deactivate_month_aggregates(session, month)
|
||||
|
||||
|
||||
def _upsert_draft(
|
||||
session: Session, existing: MonthlySettlement | None, operator: User, month: str
|
||||
) -> MonthlySettlement:
|
||||
"""新建或重置草稿头表(重生成时刷新提交人与状态)。"""
|
||||
if existing is None:
|
||||
settlement = MonthlySettlement(
|
||||
settlement_month=month,
|
||||
submitter_id=operator.id,
|
||||
submitter_name=operator.username,
|
||||
status=SETTLEMENT_STATUS_DRAFT,
|
||||
)
|
||||
return settlement_repository.add(session, settlement)
|
||||
existing.submitter_id = operator.id
|
||||
existing.submitter_name = operator.username
|
||||
existing.status = SETTLEMENT_STATUS_DRAFT
|
||||
return existing
|
||||
|
||||
|
||||
def _build_items(
|
||||
session: Session, ledger_rows: list, aggregate_rows: list, month: str
|
||||
) -> tuple[list[SettlementItem], list[int]]:
|
||||
"""按人汇总生成明细行,返回 (明细, 本次入结的台账行 id 列表)。"""
|
||||
by_person = _bucket_rows(ledger_rows + aggregate_rows)
|
||||
items = [_item_of(session, bucket, month) for _, bucket in sorted(by_person.items())]
|
||||
ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]]
|
||||
return items, ledger_ids
|
||||
|
||||
|
||||
def _bucket_rows(rows: list) -> dict[tuple[int, int], dict]:
|
||||
"""台账行按 (人员, 核算类型) 维度分桶(冗余人读字段取首行)。"""
|
||||
by_person: dict[tuple[int, int], dict] = {}
|
||||
for row in rows:
|
||||
key = (row.personnel_id, row.calc_type)
|
||||
bucket = by_person.setdefault(
|
||||
key,
|
||||
{
|
||||
"personnel_id": row.personnel_id,
|
||||
"personnel_name": row.personnel_name,
|
||||
"role_code": row.role_code,
|
||||
"calc_type": row.calc_type,
|
||||
"performance_sum": Decimal("0"),
|
||||
"payable_sum": Decimal("0"),
|
||||
"ledger_ids": [],
|
||||
"is_probation": row.is_probation,
|
||||
},
|
||||
)
|
||||
bucket["performance_sum"] += row.performance_amount or Decimal("0")
|
||||
bucket["payable_sum"] += row.payable_amount or Decimal("0")
|
||||
bucket["ledger_ids"].append(row.id)
|
||||
return by_person
|
||||
|
||||
|
||||
def _item_of(session: Session, bucket: dict, month: str) -> SettlementItem:
|
||||
"""单条结算明细:月工资三口径净额、试用期说明与台账行引用列表。"""
|
||||
person = session.get(Personnel, bucket["personnel_id"])
|
||||
basic = person.salary_standard if person else None
|
||||
mode = _salary_mode_of(session, bucket["role_code"], month)
|
||||
net = (
|
||||
monthly_salary(bucket["payable_sum"], basic, mode)
|
||||
if person and person.is_probation != 1
|
||||
else None
|
||||
)
|
||||
note = None
|
||||
if person and person.is_probation == 1:
|
||||
note = (
|
||||
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session, month)} 元部分"
|
||||
"年底一次性结算"
|
||||
)
|
||||
return SettlementItem(
|
||||
personnel_id=bucket["personnel_id"],
|
||||
personnel_name=bucket["personnel_name"],
|
||||
role_code=bucket["role_code"],
|
||||
calc_type=bucket["calc_type"],
|
||||
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
basic_amount=basic,
|
||||
salary_mode=mode,
|
||||
net_amount=(
|
||||
net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if net is not None else None
|
||||
),
|
||||
is_probation=bucket["is_probation"],
|
||||
probation_note=note,
|
||||
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
|
||||
)
|
||||
|
||||
|
||||
def _apply_totals(settlement: MonthlySettlement, items: list[SettlementItem]) -> None:
|
||||
"""头表三合计固化(明细已两位小数,合计再 HALF_UP 收口)。"""
|
||||
settlement.total_performance = sum((i.performance_sum for i in items), Decimal("0")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
settlement.person_count = len({i.personnel_id for i in items})
|
||||
|
||||
|
||||
def submit(
|
||||
@@ -206,13 +264,16 @@ def submit(
|
||||
settlement.approval_order_no = order.order_no
|
||||
if order.status == SETTLEMENT_STATUS_APPROVED:
|
||||
mark_locked(session, settlement, operator=operator)
|
||||
session.commit()
|
||||
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month)
|
||||
return _detail_of(session, settlement)
|
||||
else:
|
||||
settlement.status = SETTLEMENT_STATUS_PENDING
|
||||
|
||||
settlement.status = SETTLEMENT_STATUS_PENDING
|
||||
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已提交的
|
||||
# 月结记成失败审计,与库中事实相反)
|
||||
session.flush()
|
||||
detail = _detail_of(session, settlement)
|
||||
session.commit()
|
||||
return _detail_of(session, settlement)
|
||||
return detail
|
||||
|
||||
|
||||
def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
|
||||
@@ -225,8 +286,14 @@ def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: Us
|
||||
|
||||
|
||||
def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
|
||||
"""审批驳回钩子:回草稿(可修改后重提)。"""
|
||||
"""审批驳回钩子:回草稿(可修改后重提)。
|
||||
|
||||
锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示
|
||||
「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。
|
||||
"""
|
||||
settlement.status = SETTLEMENT_STATUS_DRAFT
|
||||
settlement.locked_at = None
|
||||
settlement.locked_by = None
|
||||
|
||||
|
||||
def ensure_month_not_locked(session: Session, month: str) -> None:
|
||||
@@ -261,7 +328,11 @@ def list_page(session: Session, query: SettlementQuery):
|
||||
|
||||
|
||||
def _confirm_scope(session: Session, month: str) -> list:
|
||||
"""确认回款口径(#8 策略点):本月有效台账行(未入结)。"""
|
||||
"""确认回款口径(#8 策略点):本月**单笔回款**的有效台账行(未入结)。
|
||||
|
||||
calc_type 必须限定单笔(1):聚合行(2)也落同月台账、也会被回填
|
||||
settlement_id,不排除会在重生成时与新聚合行重复计入。
|
||||
"""
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
return list(
|
||||
@@ -269,6 +340,7 @@ def _confirm_scope(session: Session, month: str) -> list:
|
||||
select(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
PerformanceLedger.calc_type == CALC_TYPE_RECEIPT,
|
||||
PerformanceLedger.is_active == 1,
|
||||
PerformanceLedger.settlement_id.is_(None),
|
||||
)
|
||||
@@ -277,46 +349,90 @@ def _confirm_scope(session: Session, month: str) -> list:
|
||||
)
|
||||
|
||||
|
||||
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
|
||||
"""按经理生成团队聚合行(calc_type=2)。
|
||||
def _manager_share_params(session: Session, month: str) -> manager_share.ManagerShareParams:
|
||||
"""薄委托 manager_share 共享模块:月结聚合与报表部门汇总同源取参。
|
||||
|
||||
#12 策略点:聚合基数**不含**经理个人户业绩(customer_type=
|
||||
manager_personal 的行按经理个人户单笔处理,已落台账但此处不入基数)。
|
||||
经理 = 本月台账行里 role_code=sales_manager 且 customer_type=personal
|
||||
的人员(本人带领的业务员经 manager_id 关联)。
|
||||
迁移说明(2026-10-19):实现原样迁至 manager_share,行为零变化;
|
||||
参数按百分数存储(12.5 表示 12.5%),调用方不得再乘 100。
|
||||
"""
|
||||
return manager_share.manager_share_params(session, month)
|
||||
|
||||
|
||||
def _collect_team_buckets(
|
||||
session: Session, ledger_rows: list, include_manager_personal: bool
|
||||
) -> dict[int, dict]:
|
||||
"""薄委托 manager_share 共享模块:按经理采集团队业绩(行为零变化)。"""
|
||||
return manager_share.collect_team_buckets(session, ledger_rows, include_manager_personal)
|
||||
|
||||
|
||||
def _aggregate_row(
|
||||
session: Session,
|
||||
manager,
|
||||
bucket: dict,
|
||||
company_base: Decimal,
|
||||
share: manager_share.ManagerShareParams,
|
||||
):
|
||||
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
managers: dict[int, dict] = {}
|
||||
for row in ledger_rows:
|
||||
if row.customer_type == "manager_personal":
|
||||
continue # 经理个人户业绩不入聚合基数(#12 暂口径)
|
||||
# 找到该行业务归属人员的经理(manager_id)
|
||||
person = session.get(Personnel, row.personnel_id)
|
||||
manager_id = person.manager_id if person else None
|
||||
if manager_id is None:
|
||||
continue
|
||||
bucket = managers.setdefault(
|
||||
manager_id,
|
||||
{
|
||||
"performance": Decimal("0"),
|
||||
"business_id": row.business_id,
|
||||
"business_no": row.business_no,
|
||||
"names": set(),
|
||||
amount = manager_team_share(
|
||||
bucket["performance"],
|
||||
company_base,
|
||||
team_pct=share.team_pct,
|
||||
company_pct=share.company_pct,
|
||||
)
|
||||
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
|
||||
return performance_ledger_repository.add(
|
||||
session,
|
||||
PerformanceLedger(
|
||||
receipt_id=None,
|
||||
calc_version=1, # 聚合行无回款维度,版本恒 1(uk 含 NULL 不去重)
|
||||
calc_type=CALC_TYPE_MANAGER_AGGREGATE,
|
||||
business_id=bucket["business_id"],
|
||||
business_no=bucket["business_no"],
|
||||
personnel_id=manager.id,
|
||||
personnel_name=manager.personnel_name,
|
||||
role_code="sales_manager",
|
||||
customer_type="personal",
|
||||
product_category="mixed",
|
||||
receipt_date=share.param_date,
|
||||
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
|
||||
rate_text=f"聚合:{share.team_text}%+{share.company_text}%",
|
||||
converted_profit=None,
|
||||
performance_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
barter_applied=0,
|
||||
payable_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
settlement_month=share.month,
|
||||
is_probation=1 if manager.is_probation == 1 else 0,
|
||||
snapshot_json={
|
||||
"team_base": str(bucket["performance"]),
|
||||
"company_base": str(company_base),
|
||||
"members": member_names,
|
||||
"params_as_of": str(share.param_date),
|
||||
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
||||
"note": (
|
||||
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
|
||||
"经理个人户业绩;9.8 方案公式"
|
||||
),
|
||||
},
|
||||
)
|
||||
bucket["performance"] += row.performance_amount or Decimal("0")
|
||||
bucket["names"].add(row.personnel_name)
|
||||
),
|
||||
)
|
||||
|
||||
from datetime import date
|
||||
|
||||
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
|
||||
"""按经理生成团队聚合行(calc_type=2)并落库。
|
||||
|
||||
#12 答复(2026-10-19)参数化:经理个人户业绩是否计入聚合基数由参数
|
||||
``manager.personal.included`` 决定(0=不计入默认,1=计入);
|
||||
经理 = 台账行归属人员经 manager_id 关联的 sales_manager 岗人员。
|
||||
"""
|
||||
from app.repositories import business_role_repository
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
share = _manager_share_params(session, month)
|
||||
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
|
||||
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);共享口径单点
|
||||
company_base = manager_share.company_base_of(ledger_rows, share.include_manager_personal)
|
||||
aggregates: list = []
|
||||
snapshot = resolve_snapshot(session, date.today())
|
||||
team_pct = parse_fraction(snapshot.get("manager.share.team"))
|
||||
company_pct = parse_fraction(snapshot.get("manager.share.company"))
|
||||
for manager_id, bucket in managers.items():
|
||||
manager = session.get(Personnel, manager_id)
|
||||
if manager is None:
|
||||
@@ -328,67 +444,30 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
|
||||
)
|
||||
if role is None or role.role_code != "sales_manager":
|
||||
continue # 非营销经理的上级不参与聚合
|
||||
# 全公司基数:全部非经理个人户的有效行
|
||||
company_base = sum(
|
||||
(
|
||||
r.performance_amount or Decimal("0")
|
||||
for r in ledger_rows
|
||||
if r.customer_type != "manager_personal"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
share = manager_team_share(
|
||||
bucket["performance"],
|
||||
company_base,
|
||||
team_pct=team_pct * 100,
|
||||
company_pct=company_pct * 100,
|
||||
)
|
||||
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
|
||||
aggregates.append(
|
||||
PerformanceLedger(
|
||||
receipt_id=None,
|
||||
calc_type=CALC_TYPE_MANAGER_AGGREGATE,
|
||||
business_id=bucket["business_id"],
|
||||
business_no=bucket["business_no"],
|
||||
personnel_id=manager_id,
|
||||
personnel_name=manager.personnel_name,
|
||||
role_code="sales_manager",
|
||||
customer_type="personal",
|
||||
product_category="mixed",
|
||||
receipt_date=date.today(),
|
||||
rate_text="聚合:12.5%+5%",
|
||||
converted_profit=None,
|
||||
performance_amount=share.quantize(Decimal("0.01")),
|
||||
barter_applied=0,
|
||||
payable_amount=share.quantize(Decimal("0.01")),
|
||||
settlement_month=month,
|
||||
is_probation=1 if manager.is_probation == 1 else 0,
|
||||
snapshot_json={
|
||||
"team_base": str(bucket["performance"]),
|
||||
"company_base": str(company_base),
|
||||
"members": member_names,
|
||||
"note": "#12 暂口径:基数不含经理个人户业绩;9.8 方案公式",
|
||||
},
|
||||
)
|
||||
)
|
||||
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
|
||||
return aggregates
|
||||
|
||||
|
||||
def _salary_mode_of(session: Session, role_code: str) -> str | None:
|
||||
from datetime import date
|
||||
def _month_end(month: str) -> date:
|
||||
"""薄委托 manager_share 共享模块:YYYY-MM → 所属月月末(取参锚点)。
|
||||
|
||||
保留包装:tests/unit/test_settlement_helpers.py 直接 import 本名字;
|
||||
「补生成历史月份结果不随操作日漂移」的原因说明见 manager_share.month_end。
|
||||
"""
|
||||
return manager_share.month_end(month)
|
||||
|
||||
|
||||
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
|
||||
from app.services.param_service import resolve_snapshot
|
||||
|
||||
snapshot = resolve_snapshot(session, date.today())
|
||||
snapshot = resolve_snapshot(session, _month_end(month))
|
||||
return snapshot.get("salary.basic.mode", role_code=role_code) or None
|
||||
|
||||
|
||||
def _probation_threshold(session: Session) -> Decimal:
|
||||
from datetime import date
|
||||
|
||||
def _probation_threshold(session: Session, month: str) -> Decimal:
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
snapshot = resolve_snapshot(session, date.today())
|
||||
snapshot = resolve_snapshot(session, _month_end(month))
|
||||
from app.services.formula import to_decimal
|
||||
|
||||
return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold")))
|
||||
@@ -412,19 +491,11 @@ def create_adjustment(
|
||||
):
|
||||
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
|
||||
from app.core.constants import NO_PREFIX_ADJUSTMENT
|
||||
from app.models import AdjustmentOrder, ApprovalStep
|
||||
from app.models.approval import (
|
||||
APPROVAL_ACTION_AUTO_APPROVE,
|
||||
APPROVAL_NODE_FINANCE,
|
||||
APPROVAL_NODE_LEADER,
|
||||
APPROVAL_TYPE_ADJUSTMENT,
|
||||
)
|
||||
from app.models.settlement import (
|
||||
ADJUSTMENT_STATUS_EFFECTIVE,
|
||||
ADJUSTMENT_STATUS_PENDING,
|
||||
)
|
||||
from app.models import AdjustmentOrder
|
||||
from app.services.no_generator import next_no
|
||||
|
||||
amount = _adjustment_amount(payload)
|
||||
|
||||
person = session.get(Personnel, payload.personnel_id)
|
||||
if person is None:
|
||||
raise BadRequestError("调整人员不存在")
|
||||
@@ -451,7 +522,7 @@ def create_adjustment(
|
||||
personnel_id=payload.personnel_id,
|
||||
personnel_name=person.personnel_name,
|
||||
direction=payload.direction,
|
||||
amount=payload.amount,
|
||||
amount=amount,
|
||||
reason=payload.reason.strip(),
|
||||
submitter_id=operator.id,
|
||||
submitter_name=operator.username,
|
||||
@@ -459,40 +530,68 @@ def create_adjustment(
|
||||
session.add(order)
|
||||
session.flush()
|
||||
|
||||
if approval_service.is_enabled(session, APPROVAL_TYPE_ADJUSTMENT):
|
||||
order.status = ADJUSTMENT_STATUS_PENDING
|
||||
session.flush()
|
||||
approval_service.submit_order(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_ADJUSTMENT,
|
||||
ref_id=order.id,
|
||||
refer_no=order.order_no,
|
||||
submitter=operator,
|
||||
)
|
||||
else:
|
||||
order.status = ADJUSTMENT_STATUS_EFFECTIVE
|
||||
session.add_all(
|
||||
[
|
||||
ApprovalStep(
|
||||
order_id=order.id,
|
||||
node=node,
|
||||
action=APPROVAL_ACTION_AUTO_APPROVE,
|
||||
operator_name="系统",
|
||||
)
|
||||
for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER)
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
_wire_adjustment_approval(session, order, operator)
|
||||
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
|
||||
session.commit()
|
||||
logger.info("调整单创建 | order=%s | status=%s", order.order_no, order.status)
|
||||
return {
|
||||
"id": order.id,
|
||||
"order_no": order.order_no,
|
||||
"status": order.status,
|
||||
"personnel_name": order.personnel_name,
|
||||
"amount": str(order.amount),
|
||||
"direction": order.direction,
|
||||
}
|
||||
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])
|
||||
return out
|
||||
|
||||
|
||||
def _adjustment_amount(payload) -> Decimal:
|
||||
"""金额恒正校验(方向表达补发/扣减)。
|
||||
|
||||
列注释即「恒正」约定,负数会让方向语义反转、审批/报表按 direction
|
||||
汇总出错;格式已由 schema 正则拦截,此处兜底转换 + 语义判定。
|
||||
"""
|
||||
from decimal import InvalidOperation
|
||||
|
||||
try:
|
||||
amount = Decimal(payload.amount)
|
||||
except InvalidOperation as exc:
|
||||
raise BadRequestError("调整金额格式非法(非负两位小数)") from exc
|
||||
if amount <= 0:
|
||||
raise BadRequestError("调整金额须大于 0(方向表达补发/扣减)")
|
||||
return amount
|
||||
|
||||
|
||||
def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
|
||||
"""开关分流与留痕统一交给 submit_order(关=自动通过留痕;开=待财务复核)。
|
||||
|
||||
**不得手工插 ApprovalStep**——``approval_steps.order_id`` 外键指向
|
||||
approval_orders(不是 adjustment_orders),用调整单 id 落步必然外键
|
||||
失败(历史缺陷:关开关时新建调整单不可用)。
|
||||
"""
|
||||
from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT
|
||||
from app.models.settlement import (
|
||||
ADJUSTMENT_STATUS_EFFECTIVE,
|
||||
ADJUSTMENT_STATUS_PENDING,
|
||||
)
|
||||
|
||||
approval_order = approval_service.submit_order(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_ADJUSTMENT,
|
||||
ref_id=order.id,
|
||||
refer_no=order.order_no,
|
||||
submitter=operator,
|
||||
)
|
||||
order.status = (
|
||||
ADJUSTMENT_STATUS_EFFECTIVE
|
||||
if approval_order.status == APPROVAL_STATUS_APPROVED
|
||||
else ADJUSTMENT_STATUS_PENDING
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def _adjustment_out(order) -> dict:
|
||||
"""调整单出参(commit 前固化;expire_on_commit 关闭下也不依赖后置读取)。"""
|
||||
return {
|
||||
"id": order.id,
|
||||
"order_no": order.order_no,
|
||||
"status": order.status,
|
||||
"personnel_name": order.personnel_name,
|
||||
"amount": str(order.amount),
|
||||
"direction": order.direction,
|
||||
}
|
||||
|
||||
|
||||
def list_adjustments(session: Session) -> list[dict]:
|
||||
|
||||
@@ -237,6 +237,12 @@ def create_shipment(
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止新发货")
|
||||
# 月结锁定拦截:发货日所在月已审定 → 源数据不可改(调整走调整单)
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(
|
||||
session, payload.ship_date.strftime("%Y-%m")
|
||||
)
|
||||
|
||||
shipment = Shipment(
|
||||
shipment_no=shipment_no,
|
||||
@@ -309,6 +315,11 @@ def update_shipment(
|
||||
if shipment is None:
|
||||
raise NotFoundError("发货单不存在")
|
||||
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
|
||||
# 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数)
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
|
||||
settlement_service.ensure_month_not_locked(session, payload.ship_date.strftime("%Y-%m"))
|
||||
|
||||
shipment.ship_date = payload.ship_date
|
||||
shipment.freight_amount = _amount_or_zero(payload.freight_amount)
|
||||
@@ -357,6 +368,10 @@ def delete_shipment(
|
||||
if shipment is None:
|
||||
raise NotFoundError("发货单不存在")
|
||||
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
|
||||
# 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
|
||||
if shipment_repository.count_receipt_refs(session, shipment_id):
|
||||
raise BadRequestError("已被回款批次指定,请先解除回款的批次指定")
|
||||
|
||||
|
||||
@@ -137,7 +137,12 @@ def save_logo(
|
||||
|
||||
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||
if old_ext and old_ext != ext:
|
||||
_cleanup_old_logo(old_ext)
|
||||
try:
|
||||
_cleanup_old_logo(old_ext)
|
||||
except OSError:
|
||||
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
|
||||
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反
|
||||
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True)
|
||||
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
|
||||
return to_out(setting)
|
||||
|
||||
|
||||
+58
-2
@@ -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)。
|
||||
|
||||
## 三、运行记录
|
||||
|
||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||
@@ -153,11 +176,31 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-17 | M3-core 定点 | 32 | ✅ 通过 | **④ 核算引擎+月结完成**(calc 13 / settlements 6 / formula 扩 12 / schemas 等)。金标准复算到分;重算版本链;月结锁定与审批联动 |
|
||||
| 2026-10-17 | 全量回归 | 701+N | ✅ 全部通过 | **M3-core 收尾回归**(T1-T6:V7 四表+核算+月结+调整单;前端台账/月结两页 order17/18)。e2e 补 calc_settlement_flow 待下批 |
|
||||
| 2026-10-16 | 全量回归 + 7 个 e2e | 697 | ✅ 697 passed(5:10);e2e 七脚本全过 | **M3 增量批次完成**(+72 例:settings 11 / 分配 schema 13+集成 9 / 审批 11 / registry 与 schema_sync 断言扩充等)。e2e:登录 8 + 系统管理 13 + 基础数据 12 + 业务 16 + 参数 7 步回归全过;**新增 settings_flow 4 步 / secondary_allocation_flow 6 步**(首跑即抓出 public 缓存与 logo 清理两处缺陷,见下表) |
|
||||
| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) |
|
||||
| 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 |
|
||||
| 2026-10-19 | 拆行收口批次 | 全量 788 | ✅ 788 passed(9:20) | **2026-10-19 业务答复落实完成**:#14 首日取整 10‰(公式段/锚点不变,用例反转);#12 单笔侧开关消费(1=业务员递减计提);**二次分摊拆行**(参与人份额+负责人余额守恒,uk 三列版);权限分层(params 查看放开/写仅 admin);V8 迁移(审批开关默认开/撤试用期参数/增基数开关)。定点:calc 20 / settlements 13(含 #12 开关两档金标准 6.84→24.34)/ businesses 30(保存侧+3)/ formula 35。e2e calc_settlement_flow 交接并行会话 |
|
||||
| 2026-10-19 | **报表统计修复回归** | 全量 847 | ✅ 847 passed(10:13) | **M4 报表 code-review 15 项修复后全量回归**(后端 8 + 前端 7):归属三集合/调整单全量并入/经理个人户拆分/对账出参、分块签字稿导出、导出审计覆盖取数、筛选页眉 ID→名称、月份校验 400、岗位只读选项。报表集成 24 / 报表单元 16 / 月份校验 17。type-check + build 绿;e2e `report_flow.py` 扩至 12 步,待 `E2E_ADMIN_PASSWORD` 实跑 |
|
||||
| 2026-10-19 | 全部 | 852 | ✅ 852 passed(9:17) | **M4 ⑧ 工作台完成**(四区块实时聚合 + ECharts 5.6 前端 + V10 预警参数三项;新增 5 例;参数注册表 19→22;同步模块显式降级)。期间修复:alembic `--sql` 导出 GBK 导致 schema 同步误报 |
|
||||
| 2026-10-19 | **code-review 修复回归** | 全量 862 | ✅ 862 passed(9:36) | **M4 工作台 + 报表 3 必须 + 8 建议修复**:普通用户落地页/守卫(报表集成 27、工作台集成 11、报表单元 17,新增 10 例);部门汇总 5% 按计入次数累计并与月结 `total_payable` 对拍、聚合行舍入差;工作台待办按角色过滤、逾期命中与阈值生效、终止业务排除、`warnings.total` 截断、趋势跨月归属。ruff 绿;type-check + build 绿 |
|
||||
| 2026-10-20 | e2e 全链路 | — | ✅ login 8 / system 13 / role_home 4 / report 12 / calc_settlement 8 | 重启后端加载新代码后实跑:普通用户落地页与受限页回退、报表四页/导出/打印、核算与月结链路全过。`report_flow` 回款统计改为展开目标桶后按本次业务编号定位明细,不依赖桶累计值,可重复运行 |
|
||||
| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 |
|
||||
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
|
||||
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
|
||||
|
||||
## 四、测试发现并修复的缺陷
|
||||
|
||||
| 日期 | 缺陷 | 影响 | 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-10-19 | 部门汇总导出/打印只展平成员行 | 团队分成、全公司 5% 块、未分配人员、零成员团队全部缺失——**签字稿合计小于应发**,财务按签字稿复核会漏发经理分成与未分配绩效 | 导出改为单 sheet 分块签字稿(团队/公司/对账/成员/未分配)+ 打印五块;应发公式与对账块由后端 Decimal 计算 |
|
||||
| 2026-10-19 | `_member_rows` 直接 `member_map[pid]` 索引 | 无台账行的营销经理自身挂上级时 KeyError → **部门汇总接口与导出 500**(当月完全无台账也会触发) | 归属只从 member_map/adjustments 生成;`manager_role` 覆盖所有成员引用的上级 |
|
||||
| 2026-10-19 | 生效调整单只并入有台账人员 | 只有调整单、当月无回款的人被静默丢弃——补发/扣减金额不出现,**按报表发放会少发** | 调整单人员补零值成员后参与团队/未分配归属;每人调整净额只计一次 |
|
||||
| 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 |
|
||||
| 2026-10-19 | 导出取数与超限校验在审计上下文之外 | 超限 400 与取数异常**不留失败日志**,与 M4 计划 T12「导出 400/失败审计」不符 | `_export_payload` 移入 `with audit`;静态标题映射(AuditRecord frozen) |
|
||||
| 2026-10-19 | 月份只校验长度 | `2026-13` 传到 `calendar.monthrange` 抛 ValueError → **500**;导出手工构造模型抛裸 ValidationError 同样 500 | 新增 `core/month.py`(格式+01-12+年份);四类 query、月结、调整单接入;导出 ValidationError 转 400 |
|
||||
| 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 |
|
||||
| 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 |
|
||||
| 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 |
|
||||
| 2026-10-19 | 工作台待办未按角色过滤 | 财务看得到「待领导审核」、领导看得到「待财务复核」,与 FR-034「仅看到与自己职责相关」不符 | `pending_statuses_for_roles` 按角色映射在途节点;repository 参数化 statuses;admin 保留全部在途视角 |
|
||||
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
|
||||
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
|
||||
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
|
||||
@@ -190,6 +233,19 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-16 | **logo 上传同扩展名覆盖时先写后删** | `save_logo` 在写入新文件**之后**清理旧扩展名——同扩展名重传时把刚落盘的新文件一并删掉,回读 404(首次单跑通过、集成连跑即暴露,依赖「库里已有同扩展名旧记录」的前置状态) | 清理旧扩展名移到写新文件**之前**且仅异扩展名执行;集成连跑两遍幂等锁定 |
|
||||
| 2026-10-16 | **public 端点 300s 响应缓存让登录页拿旧公司名** | 公司名保存后退出登录,登录页命中浏览器缓存副本仍显示旧品牌名(设置页「保存后生效」承诺失效)——**由 settings_flow e2e 首跑暴露**(新标签页直接打开正常、退出回跳的登录页异常) | 登录页改用带时间戳的原生 `fetch` + `cache: no-store` 绕过磁盘缓存(axios 对响应 max-age 无能为力);手动解包统一信封 |
|
||||
| 2026-10-16 | V6 迁移 downgrade 保留 autogenerate 的逐个 `drop_index` 且漏删行级种子 | 外键唯一支撑索引单独删除报 MySQL 1553 回滚中断;roles/param_definitions/param_values 的行级种子(表由 V1/V3 建)drop_table 不会带走——重放 upgrade 时唯一键冲突 | downgrade 只保留 `drop_table`(V1/V3 先例)+ 三条 DELETE 反向清理行级种子;downgrade→upgrade 往返实测通过 |
|
||||
| 2026-10-19 | **经理团队聚合分成放大 100 倍**(settlement_service) | 参数按百分数存储(12.5=12.5%),`parse_fraction` 后调用方又 ×100、`manager_team_share` 内部再 ÷100——净效应「绩效×12.5」;单元测试直传 Fraction 未覆盖调用方换算使缺陷存活 | 去掉调用方 ×100;`rate_text` 改用实际参数原文(原硬编码默认值失真);集成用例两档金标准(6.84/9.78 vs 错误值 684.43)锁定 |
|
||||
| 2026-10-19 | **审批联动不判终审**(approval_service.act) | 财务节点通过(1→2)即 mark_locked 月结/调整单置生效——领导驳回后 locked_at/locked_by 残留、两级审批形同虚设(锁定后源数据写路径立即被拦) | 生效类联动收敛到 `final_approved`(终态才触发);驳回任意节点即时;mark_rejected 补清锁定字段;用例断言财务通过后仍 status=2 且无锁定信息 |
|
||||
| 2026-10-19 | **调整单创建完全不可用**(两处叠加) | ① `ENABLE_CODE_BY_TYPE` 漏登记 type=3 → is_enabled 恒 400「未知的审批对象类型」;② 修复①后暴露:自动生效分支手工插 `ApprovalStep(order_id=调整单id)`,而该外键指向 approval_orders,必然 errno 1452 | 补登记 `approval.adjustment.enabled`;自动通过留痕统一交 `submit_order`(不再手工插步);新建调整单集成用例锁定 |
|
||||
| 2026-10-19 | **重算绕过业务级一次性计提防线** | 「进度登记」回款(#25 第二笔及以后)无独立台账行,重算会凭空落一行生效行——同业务两行生效行、绩效按两倍计入工资 | 无旧版本行一律 400「进度登记无可重算」;用例锁定「重算后同业务仍只有一行生效行」 |
|
||||
| 2026-10-19 | **前端重算传错 ID**(台账页) | 把台账行主键当 receipt_id 传给重算接口——会作废并重算**无关回款**的台账(若目标回款已核算),或报错无法重算目标行 | 改传 `row.receipt_id`(聚合行 receipt_id 空值防御);`LedgerRow` 本含该字段 |
|
||||
| 2026-10-19 | **quantize 缺省银行家舍入**(9 处金额固化) | docstring 声称 HALF_UP,实际未传 rounding 走 ROUND_HALF_EVEN——x.xx5 舍成偶数尾数,与业务四舍五入口径差 1 分(台账固化金额比应发少 1 分) | 9 处显式 `rounding=ROUND_HALF_UP`;`_q2` 提为模块级并单测锁定(2.345→2.35) |
|
||||
| 2026-10-19 | **月结锁定拦截只覆盖 1/4 写路径** | 仅新增回款被拦;回款编辑(可跨月搬数)/删除、发货增/改/删、核算与重算全部放行——锁定月份快照与源数据脱钩且无留痕 | 五处写路径补齐 `ensure_month_not_locked`(编辑按原月+新月双查);拦截先于「被引用/已核算」判定;三用例锁定 |
|
||||
| 2026-10-19 | **台账 settlement_id 回填从未发生**(死代码) | `update_ledger_settlement_id` 全库零调用——「未入结」判据恒真,重生成会把已固化行反复纳入(或补上回填却不先解绑导致漏算整月) | generate 回填 + 重生成前解绑(新增 `clear_ledger_settlement_id`);用例断言回填与重生成金额不翻倍 |
|
||||
| 2026-10-19 | **经理聚合行从不落库** | `_build_manager_aggregates` 只构造内存对象参与 items 汇总——台账表永远无 calc_type=2 行,报表下钻(business_id 锚点)落空 | 聚合行经 repository 落库并回填 settlement_id;重生成先作废旧聚合行(`deactivate_month_aggregates`);`_confirm_scope` 限定 calc_type=1 防聚合行混入单笔池 |
|
||||
| 2026-10-19 | **月结取参用生成日**(date.today) | 补生成历史月份时经理分成/月工资口径/试用期阈值按生成日参数计算——同一月份两次生成金额不同,违背「参数按生效日期时点化」红线 | 新增 `_month_end`(所属月月末)为聚合与月工资口径取参锚点;单测 5 例 + 集成用例(10-25 预约变更影响 10 月聚合) |
|
||||
| 2026-10-19 | **迁移双轨三处漂移** | ① V6 就地修改加入 uk 致旧库补不上(V8 也未补)→ V9 条件式补齐(幂等裸 SQL,临时库验证两分支);② V7 留存 SQL 约束/索引名单数(`fk_performance_ledger_*`)与 alembic op.f/init-dev 漂移;③ V8 迁移 revision 缺类型注解,`test_schema_sync` 的 head 推断**静默漏检 V8**、init-dev 版本号落后未被发现 | V9 迁移 + V7 SQL 名修正 + V8 注解修正 + init-dev 版本推进;schema 同步测试恢复全量断言 |
|
||||
| 2026-10-19 | 运维脚本两处(restore_drill/health_check) | ① 行数核对把查询失败回显 `'?'` 当数据比较(`'?'=='?'` 判 ✓)且 TABLES 缺 V7 四表等 16 张——备份漏表时演练全绿误报「可恢复」;② `redis-cli -a "$REDIS_PASSWORD"` 密码进宿主进程命令行 | TABLES 补全 32 张业务表 + `'?'` 一律判 ✗;Redis 探活改容器内环境变量展开(compose 补 REDIS_PASSWORD),实测 PONG |
|
||||
| 2026-10-19 | 调整单金额无校验 / INT 参数原文 `int()` / logo 清理裸抛 | ① amount 负数反转补发-扣减语义、'abc' 直落库 500;② `int('360.0')` 使整月核算 500(INT 参数合法入库原文);③ 旧 logo 清理 OSError 裸抛把成功更新记成失败审计并 500 | schema 正则 + 服务层 Decimal>0;`int(parse_fraction(...))`;清理包 try 仅 warning;各补回归用例 |
|
||||
|
||||
## 五、待补用例
|
||||
|
||||
@@ -198,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)随 ④ 月结子块接入时补测 |
|
||||
| 绩效工资核算 | 未开始 | **被业务澄清阻塞**(经理个人户基数、含税/退货冲减等 5 项口径待答复);答复后按同粒度另出计划 |
|
||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||
| 绩效工资核算(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 完成并补齐用例,见上表。
|
||||
@@ -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 一并删
|
||||
|
||||
@@ -129,11 +129,12 @@ def switched_on(client: TestClient, admin: dict) -> None:
|
||||
|
||||
|
||||
class TestSwitchOff:
|
||||
"""开关关闭(种子默认):提单即自动通过。"""
|
||||
"""开关关闭(用例显式设置,不依赖种子默认值):提单即自动通过。"""
|
||||
|
||||
def test_创建带方案_自动通过全节点留痕(
|
||||
self, client: TestClient, admin: dict, finance: dict
|
||||
) -> None:
|
||||
_set_switch(client, admin["headers"], "0") # V8 起种子默认开启
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -412,6 +412,71 @@ class TestEdit:
|
||||
assert len(detail["items"]) == 1
|
||||
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
|
||||
|
||||
def test_二次分配方案合计超100_保存400(self, client: TestClient, normal: dict) -> None:
|
||||
"""口径(澄清 #1/#2 答复 2026-10-19):分配绩效、余额归合同负责人——
|
||||
超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。"""
|
||||
headers = normal["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
part = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
|
||||
resp = client.put(
|
||||
f"{_BUSINESSES}/{data['id']}",
|
||||
json=_payload(
|
||||
customer["id"],
|
||||
secondary_allocations=[
|
||||
{"personnel_id": part["id"], "ratio": "60"},
|
||||
{"personnel_id": _personnel(client, headers)["id"], "ratio": "50"},
|
||||
],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "超过 100%" in resp.json()["message"]
|
||||
|
||||
def test_二次分配参与人含负责人_保存400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
|
||||
resp = client.put(
|
||||
f"{_BUSINESSES}/{data['id']}",
|
||||
json=_payload(
|
||||
customer["id"],
|
||||
secondary_allocations=[{"personnel_id": owner["id"], "ratio": "30"}],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "含合同负责营销人员" in resp.json()["message"]
|
||||
|
||||
def test_二次分配方案合法比例_保存成功且回显(
|
||||
self, client: TestClient, normal: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
part = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
|
||||
resp = client.put(
|
||||
f"{_BUSINESSES}/{data['id']}",
|
||||
json=_payload(
|
||||
customer["id"],
|
||||
secondary_allocations=[{"personnel_id": part["id"], "ratio": "40"}],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# 方案在详情回包(更新回包为简版 Out,不含方案区)
|
||||
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
|
||||
assert len(detail["secondary_allocations"]) == 1
|
||||
assert Decimal(detail["secondary_allocations"][0]["ratio"]) == Decimal("40")
|
||||
# 余额 60% 归合同负责人(hint 文案 = #2 答复口径)
|
||||
assert detail["allocation_hint"] is not None and "60" in detail["allocation_hint"]
|
||||
|
||||
|
||||
class TestStatus:
|
||||
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||
|
||||
@@ -22,7 +22,7 @@ from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import PerformanceLedger, Receipt
|
||||
from app.models import PerformanceLedger, Personnel, Receipt, SecondaryAllocation
|
||||
from app.models.receipt import SETTLEMENT_DONE
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
@@ -357,6 +357,38 @@ class TestGuards:
|
||||
resp = _calc(client, ops["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_分界天数参数小数原文_核算不500(self, client: TestClient, admin: dict) -> None:
|
||||
"""守 int(参数原文) 缺陷:INT 参数入库允许 '360.0'(Fraction 分母为 1),
|
||||
直接 int(str) 抛 ValueError 会让整月核算 500。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": "用例:INT 参数小数原文",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "salesperson.days.boundary",
|
||||
"dimension_value": "salesperson",
|
||||
"value": "360.0",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
# 分界取整无损:20 天 < 360 天 → 同一递减公式
|
||||
assert resp.json()["data"]["ledger"]["rate_text"] == "11/1125"
|
||||
|
||||
def test_台账列表与筛选(self, client: TestClient, admin: dict) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
client,
|
||||
@@ -459,6 +491,53 @@ class TestRecalc:
|
||||
assert resp.status_code == 400
|
||||
assert "尚未核算" in resp.json()["message"]
|
||||
|
||||
def test_进度登记回款重算400_不得凭空落行(self, client: TestClient, admin: dict) -> None:
|
||||
"""业务级一次性计提的第二笔回款无台账行;重算若落新行会双重计提。"""
|
||||
biz, first = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
assert _calc(client, admin["headers"], first["id"]).status_code == 200
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-25",
|
||||
"amount": "100.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
second = resp.json()["data"]
|
||||
assert _calc(client, admin["headers"], second["id"]).json()["data"]["repeated"] is True
|
||||
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{second['id']}/recalc",
|
||||
json={"reason": "尝试重算进度登记"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "进度登记" in resp.json()["message"]
|
||||
|
||||
# 同业务仍只有一行生效台账(未凭空新增)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.business_id == biz["id"],
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 1
|
||||
|
||||
def test_重算_普通用户与营销后勤403(
|
||||
self, client: TestClient, normal: dict, make_user, auth_headers
|
||||
) -> None:
|
||||
@@ -505,3 +584,207 @@ class TestBatch:
|
||||
assert by_id[r1["id"]]["ok"] is True and by_id[r1["id"]]["repeated"] is False
|
||||
assert by_id[r2["id"]]["ok"] is True and by_id[r2["id"]]["repeated"] is True
|
||||
assert by_id[999999]["ok"] is False
|
||||
|
||||
|
||||
class TestSecondaryAllocation:
|
||||
"""二次分摊拆行(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
|
||||
|
||||
方案行直插入库(跳过保存侧校验,聚焦核算引擎;保存侧校验用例在
|
||||
test_businesses)。金标准:双类别混合业务绩效 39.11 / 折算 4000.00,
|
||||
拆 30%+40% 后参与人 11.73/15.64、负责人余额 11.74,行合计守恒。
|
||||
"""
|
||||
|
||||
def _add_plan(self, business_id: int, rows: list[tuple[int, str]]) -> None:
|
||||
"""直插方案行(测试隔离造数;核算引擎按「当前落库方案」消费)。"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
for pid, ratio in rows:
|
||||
person = session.get(Personnel, pid)
|
||||
session.add(
|
||||
SecondaryAllocation(
|
||||
business_id=business_id,
|
||||
personnel_id=pid,
|
||||
personnel_name=person.personnel_name,
|
||||
ratio=Decimal(ratio),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def test_分摊拆行_参与人比例与负责人余额守恒(self, client: TestClient, admin: dict) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
parts = [_person(client, admin["headers"]) for _ in range(2)]
|
||||
self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")])
|
||||
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.receipt_id == receipt["id"],
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 3, "负责人+两参与人各一行"
|
||||
by_pid = {r.personnel_id: r for r in rows}
|
||||
a, b, o = (
|
||||
by_pid[parts[0]["id"]],
|
||||
by_pid[parts[1]["id"]],
|
||||
by_pid[biz["owner_personnel_id"]],
|
||||
)
|
||||
# 绩效份额:A=39.11×30%=11.73、B=39.11×40%=15.64、负责人=守恒余额 11.74
|
||||
assert (a.performance_amount, b.performance_amount, o.performance_amount) == (
|
||||
Decimal("11.73"),
|
||||
Decimal("15.64"),
|
||||
Decimal("11.74"),
|
||||
)
|
||||
# 行合计 == 业务级全额(守恒是分摊红线:多一分少一分都是错账)
|
||||
assert a.payable_amount + b.payable_amount + o.payable_amount == Decimal("39.11")
|
||||
# 折算额同比例拆分:4000 的 30%/40%/余额
|
||||
assert (a.converted_profit, b.converted_profit, o.converted_profit) == (
|
||||
Decimal("1200.00"),
|
||||
Decimal("1600.00"),
|
||||
Decimal("1200.00"),
|
||||
)
|
||||
# 行级身份固化:参与人各自岗位;快照带方案与角色标签
|
||||
# (ratio 为 NUMERIC(5,2) 读回两位 scale,断言用数值比较)
|
||||
assert a.role_code == b.role_code == "salesperson"
|
||||
assert a.snapshot_json["alloc_role"] == "participant"
|
||||
assert Decimal(a.snapshot_json["alloc_ratio"]) == Decimal("30")
|
||||
assert o.snapshot_json["alloc_role"] == "owner"
|
||||
assert Decimal(o.snapshot_json["alloc_plan"][0]["ratio"]) == Decimal("30")
|
||||
|
||||
def test_分摊后第二笔回款仅登记进度(self, client: TestClient, admin: dict) -> None:
|
||||
"""一次性计提(#25)与分摊叠加:第二笔不新增行、不重复拆分。"""
|
||||
biz, first = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="10000.00",
|
||||
)
|
||||
part = _person(client, admin["headers"])
|
||||
self._add_plan(biz["id"], [(part["id"], "50")])
|
||||
assert _calc(client, admin["headers"], first["id"]).status_code == 200
|
||||
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-25",
|
||||
"amount": "5000.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
second = resp.json()["data"]
|
||||
out = _calc(client, admin["headers"], second["id"]).json()["data"]
|
||||
assert out["repeated"] is True
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.business_id == biz["id"],
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 2, "第二笔回款不得新增拆分行(一次性计提)"
|
||||
|
||||
def test_重算作废全部拆分行并落新版本(
|
||||
self, client: TestClient, admin: dict, finance: dict
|
||||
) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
parts = [_person(client, admin["headers"]) for _ in range(2)]
|
||||
self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")])
|
||||
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
|
||||
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{receipt['id']}/recalc",
|
||||
json={"reason": "用例:分摊重算"},
|
||||
headers=finance["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(PerformanceLedger.receipt_id == receipt["id"])
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
active = [r for r in rows if r.is_active == 1]
|
||||
inactive = [r for r in rows if r.is_active == 0]
|
||||
assert len(active) == 3 and len(inactive) == 3
|
||||
assert {r.calc_version for r in active} == {2}, "新版本三行"
|
||||
assert {r.calc_version for r in inactive} == {1}, "旧版本三行全部作废"
|
||||
|
||||
def test_合计超100核算侧拦截(self, client: TestClient, admin: dict) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="100.00",
|
||||
)
|
||||
part = _person(client, admin["headers"])
|
||||
self._add_plan(biz["id"], [(part["id"], "60")])
|
||||
# 再直插另一行绕过保存校验(合计 110),核算侧防御必须拦
|
||||
other = _person(client, admin["headers"])
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
person = session.get(Personnel, other["id"])
|
||||
session.add(
|
||||
SecondaryAllocation(
|
||||
business_id=biz["id"],
|
||||
personnel_id=other["id"],
|
||||
personnel_name=person.personnel_name,
|
||||
ratio=Decimal("50"),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 400
|
||||
assert "超过 100%" in resp.json()["message"]
|
||||
|
||||
def test_参与人含负责人核算侧拦截(self, client: TestClient, admin: dict) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="100.00",
|
||||
)
|
||||
self._add_plan(biz["id"], [(biz["owner_personnel_id"], "30")])
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 400
|
||||
assert "含合同负责营销人员" in resp.json()["message"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -69,6 +69,26 @@ def _create_with_allocations(client: TestClient, headers: dict, allocations: lis
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _set_switch(client: TestClient, headers: dict, value: str) -> None:
|
||||
"""显式设置二次分配审批开关(V8 起默认开启,用例不得依赖种子默认值)。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": f"用例设置二次分配审批开关={value}",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "approval.secondary_allocation.enabled",
|
||||
"dimension_value": "",
|
||||
"value": value,
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def _detail(client: TestClient, headers: dict, business_id: int) -> dict:
|
||||
resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
@@ -239,6 +259,8 @@ class TestDeleteProtection:
|
||||
assert detail["secondary_allocations"][0]["personnel_name"] == p["personnel_name"]
|
||||
|
||||
def test_删除业务后可删人员(self, client: TestClient, admin: dict) -> None:
|
||||
# 显式关开关:开启时审批单在途、删业务被 400 拦截(产品行为,另有用例覆盖)
|
||||
_set_switch(client, admin["headers"], "0")
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
|
||||
|
||||
@@ -10,13 +10,15 @@
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import MonthlySettlement
|
||||
from app.models import MonthlySettlement, PerformanceLedger
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
@@ -39,30 +41,38 @@ _GOLD_ITEMS = [
|
||||
]
|
||||
|
||||
|
||||
def _person(client: TestClient, headers: dict) -> dict:
|
||||
def _role_id(role_code: str) -> int:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.constants import BUSINESS_ROLE_SALESPERSON
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import BusinessRole
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
role_id = int(
|
||||
return int(
|
||||
session.execute(
|
||||
select(BusinessRole.id).where(
|
||||
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||
BusinessRole.role_code == role_code,
|
||||
BusinessRole.status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _person(
|
||||
client: TestClient,
|
||||
headers: dict,
|
||||
*,
|
||||
role_code: str = "salesperson",
|
||||
manager_id: int | None = None,
|
||||
) -> dict:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json={
|
||||
"personnel_name": f"t_月结_{uuid.uuid4().hex[:8]}",
|
||||
"business_role_id": role_id,
|
||||
"business_role_id": _role_id(role_code),
|
||||
"salary_standard": "3000.00",
|
||||
**({"manager_id": manager_id} if manager_id is not None else {}),
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
@@ -70,6 +80,20 @@ def _person(client: TestClient, headers: dict) -> dict:
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _set_switch(client: TestClient, headers: dict, code: str, value: str) -> None:
|
||||
"""显式设置审批开关(V8 起审批开关默认开启,用例不得依赖种子默认值)。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": f"用例设置 {code}={value}",
|
||||
"items": [{"param_code": code, "dimension_value": "", "value": value}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def _calc_one(client: TestClient, headers: dict) -> dict:
|
||||
"""完整造一笔已核算回款(回款日 2026-10-20 → 归月 2026-10)。"""
|
||||
cust = customer(client, headers)
|
||||
@@ -145,6 +169,15 @@ class TestGenerate:
|
||||
select(MonthlySettlement).where(MonthlySettlement.settlement_month == "2026-10")
|
||||
).scalar_one()
|
||||
assert settled.person_count >= 1
|
||||
backfilled = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(PerformanceLedger.settlement_id == settled.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert backfilled, "台账行 settlement_id 未回填(「未入结」判据失效)"
|
||||
assert all(r.settlement_month == "2026-10" for r in backfilled)
|
||||
|
||||
def test_无核算记录生成400(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.post(
|
||||
@@ -161,7 +194,8 @@ class TestGenerate:
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
# 提交(开关默认关)→ 自动通过锁定
|
||||
# 显式关开关 → 提交即自动通过锁定(用例不依赖种子默认值)
|
||||
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
|
||||
sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][
|
||||
"id"
|
||||
]
|
||||
@@ -172,8 +206,7 @@ class TestGenerate:
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# 已核算禁改(M2 防线)先于月结锁定拦截生效——两者均 400 拒改
|
||||
assert ("已核算" in resp.json()["message"]) or ("锁定" in resp.json()["message"])
|
||||
assert "已审定锁定" in resp.json()["message"]
|
||||
|
||||
|
||||
class TestSubmitAndLock:
|
||||
@@ -183,6 +216,8 @@ class TestSubmitAndLock:
|
||||
self, client: TestClient, admin: dict, finance: dict
|
||||
) -> None:
|
||||
_calc_one(client, admin["headers"])
|
||||
# 显式关开关(V8 起种子默认开启,用例不得依赖默认值)
|
||||
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
@@ -322,6 +357,13 @@ class TestSwitchOn:
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# 财务节点通过只是流转到待领导——不得提前锁定月结(否则领导驳回后
|
||||
# 锁定字段残留、两级审批形同虚设)
|
||||
mid = client.get(f"{_SETTLEMENTS}/{sid}", headers=admin["headers"]).json()["data"]
|
||||
assert mid["status"] == 2, "财务通过后应仍为待审批,不得提前锁定"
|
||||
assert mid["locked_at"] is None
|
||||
assert mid["locked_by"] is None
|
||||
|
||||
# 领导通过 → 锁定
|
||||
resp = client.post(
|
||||
f"/api/v1/system/approvals/{order['id']}/action",
|
||||
@@ -333,3 +375,398 @@ class TestSwitchOn:
|
||||
assert final["status"] == 3
|
||||
assert final["locked_at"] is not None
|
||||
assert final["locked_by"] == leader["username"] # 审批通过人=领导账号
|
||||
|
||||
|
||||
class TestManagerAggregate:
|
||||
"""经理团队聚合:比例按参数实际值计算(守「百分数再乘 100」放大缺陷)。"""
|
||||
|
||||
def _scene(self, client: TestClient, headers: dict) -> tuple[dict, dict]:
|
||||
"""经理(带一名业务员)→ 业务 → 发货 → 已核算回款,返回 (经理, 回款)。"""
|
||||
manager = _person(client, headers, role_code="sales_manager")
|
||||
sales = _person(client, headers, manager_id=manager["id"])
|
||||
|
||||
cust = customer(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=sales["id"],
|
||||
items=[dict(_GOLD_ITEMS[0], product_name=f"t_聚合_{uuid.uuid4().hex[:6]}")],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
biz = resp.json()["data"]
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"ship_date": "2026-10-01",
|
||||
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-20",
|
||||
"amount": "20000.00",
|
||||
"shipment_id": resp.json()["data"]["id"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
receipt = resp.json()["data"]
|
||||
assert (
|
||||
client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers).status_code
|
||||
== 200
|
||||
)
|
||||
return manager, receipt
|
||||
|
||||
def test_聚合金额按实际比例计入且落库回填(self, client: TestClient, admin: dict) -> None:
|
||||
manager, _receipt = self._scene(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
items = out["items"]
|
||||
manager_items = [i for i in items if i["calc_type"] == 2]
|
||||
assert len(manager_items) == 1
|
||||
# 业务员绩效 39.11;经理 = 39.11×12.5% + 39.11×5% = 6.84425 → 6.84
|
||||
# (若调用方把百分数再 ×100,此处会是 684.43)
|
||||
assert manager_items[0]["personnel_id"] == manager["id"]
|
||||
assert manager_items[0]["performance_sum"] == "6.84"
|
||||
assert manager_items[0]["payable_sum"] == "6.84"
|
||||
|
||||
# 聚合行必须落台账(报告下钻的数据源)且被回填 settlement_id
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == "2026-10",
|
||||
PerformanceLedger.calc_type == 2,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 1, "聚合行必须落库(修复前只在内存参与汇总)"
|
||||
assert rows[0].is_active == 1
|
||||
assert rows[0].receipt_id is None
|
||||
assert rows[0].settlement_id == out["id"], "聚合行未回填 settlement_id"
|
||||
|
||||
def test_草稿态重生成_聚合不重复计入(self, client: TestClient, admin: dict) -> None:
|
||||
self._scene(client, admin["headers"])
|
||||
first = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
).json()["data"]
|
||||
second = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
out = second.json()["data"]
|
||||
# 重生成金额不翻倍(聚合行不做废旧行会 45.95→52.79)也不漏行
|
||||
assert out["total_payable"] == first["total_payable"] == "45.95"
|
||||
assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1
|
||||
assert len([i for i in out["items"] if i["calc_type"] == 1]) == 1
|
||||
|
||||
def test_月末取参_预约变更影响本月聚合(self, client: TestClient, admin: dict) -> None:
|
||||
"""取参锚点=所属月月末:10-25 生效的预约变更应影响 2026-10 月结。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 2,
|
||||
"effective_date": "2026-10-25",
|
||||
"remark": "用例:月末取参锚点",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "manager.share.team",
|
||||
"dimension_value": "",
|
||||
"value": "20",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
self._scene(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
manager_items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2]
|
||||
# 39.11×20% + 39.11×5% = 9.7775 → 9.78(按生成日取参会得 6.84)
|
||||
assert manager_items[0]["performance_sum"] == "9.78"
|
||||
|
||||
# 台账快照固化月末取参日与实际比例原文
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
row = session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == "2026-10",
|
||||
PerformanceLedger.calc_type == 2,
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.snapshot_json["params_as_of"] == "2026-10-31"
|
||||
assert row.rate_text == "聚合:20%+5%"
|
||||
assert str(row.receipt_date) == "2026-10-31"
|
||||
|
||||
def test_聚合基数开关_经理个人户计入可配置(self, client: TestClient, admin: dict) -> None:
|
||||
"""#12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户业绩并入基数。
|
||||
|
||||
直插一行经理个人户台账(绩效 100,模拟该类业务经单笔侧计提后的
|
||||
结果)——默认 0 时聚合 6.84 不变;开关 1 后基数 39.11+100=139.11。
|
||||
"""
|
||||
manager, _receipt = self._scene(client, admin["headers"])
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.add(
|
||||
PerformanceLedger(
|
||||
receipt_id=None,
|
||||
calc_version=1,
|
||||
calc_type=1,
|
||||
business_id=0,
|
||||
business_no="t_个人户",
|
||||
personnel_id=manager["id"],
|
||||
personnel_name=manager["personnel_name"],
|
||||
role_code="sales_manager",
|
||||
customer_type="manager_personal",
|
||||
product_category="mixed",
|
||||
receipt_date=date(2026, 10, 15),
|
||||
rate_text="0",
|
||||
performance_amount=Decimal("100.00"),
|
||||
barter_applied=0,
|
||||
payable_amount=Decimal("0"),
|
||||
settlement_month="2026-10",
|
||||
is_probation=0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def _manager_share() -> str:
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2]
|
||||
assert len(items) == 1
|
||||
return items[0]["performance_sum"]
|
||||
|
||||
# 默认(开关 0=不计入):基数 39.11 → 6.84(与既有金标准一致)
|
||||
assert _manager_share() == "6.84"
|
||||
|
||||
# 开关 1(立即生效,月末 2026-10-31 取参命中):139.11×12.5% + ×5% = 24.34425
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": "用例:#12 开关计入",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "manager.personal.included",
|
||||
"dimension_value": "",
|
||||
"value": "1",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _manager_share() == "24.34"
|
||||
|
||||
|
||||
class TestAdjustment:
|
||||
"""调整单:已锁定月份补发/扣减的唯一通道。"""
|
||||
|
||||
def test_新建调整单_开关关闭即生效(self, client: TestClient, admin: dict) -> None:
|
||||
# 显式关开关(用例不依赖种子默认值)
|
||||
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
|
||||
person = _person(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/adjustments",
|
||||
json={
|
||||
"settlement_month": "2026-10",
|
||||
"personnel_id": person["id"],
|
||||
"direction": 1,
|
||||
"amount": "500.00",
|
||||
"reason": "补发差额",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
# 守 ENABLE_CODE_BY_TYPE 漏登记 type=3:修复前此处恒 400「未知的审批对象类型」
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["status"] == 2 # 已生效
|
||||
|
||||
def test_金额恒正校验(self, client: TestClient, admin: dict) -> None:
|
||||
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
|
||||
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
|
||||
person = _person(client, admin["headers"])
|
||||
base = {
|
||||
"settlement_month": "2026-10",
|
||||
"personnel_id": person["id"],
|
||||
"direction": 1,
|
||||
"reason": "金额校验用例",
|
||||
}
|
||||
for bad in ("-500.00", "0", "0.00", "abc", "1e3"):
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/adjustments",
|
||||
json={**base, "amount": bad},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, f"amount={bad!r} 应 400:{resp.text}"
|
||||
|
||||
|
||||
class TestLockInterception:
|
||||
"""锁定后源数据写路径全量拦截(回款编辑/删除、发货增改删、核算/重算)。"""
|
||||
|
||||
def _lock_scene(self, client: TestClient, admin: dict) -> tuple[dict, dict, dict, dict]:
|
||||
"""已核算+未核算各一笔回款、两张 10 月发货单,然后锁定 2026-10。
|
||||
|
||||
返回 (月结, 业务, 未核算回款, 无回款引用的发货单)。
|
||||
"""
|
||||
biz = _calc_one(client, admin["headers"])
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={"business_id": biz["id"], "receipt_date": "2026-10-22", "amount": "100.00"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
unsettled = resp.json()["data"]
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"ship_date": "2026-10-05",
|
||||
"items": [{"product_name": "t_发2", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
free_shipment = resp.json()["data"]
|
||||
|
||||
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
settlement = resp.json()["data"]
|
||||
resp = client.post(f"{_SETTLEMENTS}/{settlement['id']}/submit", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["data"]["status"] == 3
|
||||
return settlement, biz, unsettled, free_shipment
|
||||
|
||||
def test_锁定后_未核算回款禁改禁删(self, client: TestClient, admin: dict) -> None:
|
||||
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
|
||||
resp = client.put(
|
||||
f"{_RECEIPTS}/{unsettled['id']}",
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-23",
|
||||
"amount": "88.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
resp = client.delete(f"{_RECEIPTS}/{unsettled['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
def test_锁定后_发货增改删400_非锁定月放行(self, client: TestClient, admin: dict) -> None:
|
||||
_settlement, biz, _unsettled, free_shipment = self._lock_scene(client, admin)
|
||||
items = [{"product_name": "t_发x", "quantity": "1", "sale_price": "1.00"}]
|
||||
|
||||
# 新增:落锁定月 400
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={"business_id": biz["id"], "ship_date": "2026-10-08", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
# 新增:非锁定月放行(拦截不误伤其它月份)
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={"business_id": biz["id"], "ship_date": "2026-11-02", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
nov = resp.json()["data"]
|
||||
|
||||
# 编辑:把非锁定月的发货单改入锁定月 400
|
||||
resp = client.put(
|
||||
f"{_SHIPMENTS}/{nov['id']}",
|
||||
json={"ship_date": "2026-10-15", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
# 编辑:原月即锁定月 400
|
||||
resp = client.put(
|
||||
f"{_SHIPMENTS}/{free_shipment['id']}",
|
||||
json={"ship_date": "2026-10-06", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
# 删除:锁定月的发货单 400(拦截先于「被回款引用」判定)
|
||||
resp = client.delete(f"{_SHIPMENTS}/{free_shipment['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
def test_锁定后_首算与重算400(self, client: TestClient, admin: dict) -> None:
|
||||
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
|
||||
from app.models import Receipt
|
||||
|
||||
with get_session_factory()() as session:
|
||||
settled_id = int(
|
||||
session.execute(
|
||||
select(Receipt.id).where(
|
||||
Receipt.business_id == biz["id"],
|
||||
Receipt.settlement_status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{settled_id}/recalc",
|
||||
json={"reason": "锁定月重算"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
|
||||
def test_非法月份_月结生成与调整单均返回400(client: TestClient, admin: dict) -> None:
|
||||
"""月份范围必须在 schema 拦住:`2026-13` 不能传到 monthrange 变成 500。"""
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-13"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/adjustments",
|
||||
json={
|
||||
"settlement_month": "2026-13",
|
||||
"personnel_id": 1,
|
||||
"direction": 1,
|
||||
"amount": "100.00",
|
||||
"reason": "t_非法月份",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
@@ -0,0 +1,28 @@
|
||||
"""核算落库辅助纯函数测试。
|
||||
|
||||
守的是:金额两位固化的舍入模式 —— quantize 缺省是上下文 ROUND_HALF_EVEN
|
||||
(银行家舍入),x.xx5 会舍成偶数尾数,与业务四舍五入口径差 1 分
|
||||
(docstring 声称 HALF_UP,必须用测试锁死实际行为)。
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from app.services.calc_service import _q2
|
||||
|
||||
|
||||
class TestQ2:
|
||||
"""金额两位小数(HALF_UP)。"""
|
||||
|
||||
def test_半进位一律入(self) -> None:
|
||||
# 缺省 ROUND_HALF_EVEN 会舍成 2.34(4 是偶数)
|
||||
assert _q2(Decimal("2.345")) == Decimal("2.35")
|
||||
|
||||
def test_负数半进位远离零(self) -> None:
|
||||
assert _q2(Decimal("-2.345")) == Decimal("-2.35")
|
||||
|
||||
def test_普通进位不变(self) -> None:
|
||||
assert _q2(Decimal("39.111111")) == Decimal("39.11")
|
||||
assert _q2(Decimal("39.115000")) == Decimal("39.12")
|
||||
|
||||
def test_None直通(self) -> None:
|
||||
assert _q2(None) is None
|
||||
@@ -61,9 +61,14 @@ class TestSalespersonRate:
|
||||
# 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」
|
||||
assert _sp_rate(361) == Fraction(4, 1000)
|
||||
|
||||
def test_第1天按公式为899比90000_非取整为10(self) -> None:
|
||||
# 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000
|
||||
assert _sp_rate(1) == Fraction(899, 90000)
|
||||
def test_首日取整10千分_图示口径裁决(self) -> None:
|
||||
# 需规图示「第 1 天 10‰」2026-10-19 裁决覆盖公式(公式值 9.9889‰ 作废):
|
||||
# 首日不递减、直接取起始值;第 2 天起才进入公式段
|
||||
assert _sp_rate(1) == Fraction(10, 1000)
|
||||
|
||||
def test_第2天起进入公式递减段(self) -> None:
|
||||
# 10‰ − 2×4/360‰ = 9.9778‰(首日取整裁决只影响第 1 天,第 2 天起不变)
|
||||
assert _sp_rate(2) == Fraction(10, 1000) - Fraction(2 * 4, 360 * 1000)
|
||||
|
||||
def test_第180天线性段中点千分之8(self) -> None:
|
||||
assert _sp_rate(180) == Fraction(8, 1000)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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%", "")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""月结取参锚点测试。
|
||||
|
||||
守的是:月结参数快照按**所属月月末**解析,而不是生成日 —— 用 date.today()
|
||||
取参会让补生成/重新生成历史月份的经理分成与月工资随操作日期漂移,
|
||||
同一月份两次生成金额不同(违背「参数按生效日期时点化」红线)。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from app.services.settlement_service import _month_end
|
||||
|
||||
|
||||
class TestMonthEnd:
|
||||
"""YYYY-MM → 所属月最后一天(含大小月与闰年)。"""
|
||||
|
||||
def test_大月31日(self) -> None:
|
||||
assert _month_end("2026-10") == date(2026, 10, 31)
|
||||
|
||||
def test_小月30日(self) -> None:
|
||||
assert _month_end("2026-09") == date(2026, 9, 30)
|
||||
|
||||
def test_二月平年28日(self) -> None:
|
||||
assert _month_end("2026-02") == date(2026, 2, 28)
|
||||
|
||||
def test_二月闰年29日(self) -> None:
|
||||
assert _month_end("2028-02") == date(2028, 2, 29)
|
||||
|
||||
def test_跨年十二月(self) -> None:
|
||||
assert _month_end("2026-12") == date(2026, 12, 31)
|
||||
+11
-3
@@ -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 ('3bca400421c6');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('90653814a421');
|
||||
@@ -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';
|
||||
|
||||
@@ -93,19 +93,19 @@ CREATE TABLE performance_ledgers (
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_performance_ledgers PRIMARY KEY (id),
|
||||
CONSTRAINT fk_performance_ledger_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledger_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledger_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT uk_performance_ledger_receipt_id_calc_version UNIQUE (receipt_id, calc_version)
|
||||
CONSTRAINT fk_performance_ledgers_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledgers_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledgers_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT uk_performance_ledgers_receipt_id_calc_version UNIQUE (receipt_id, calc_version)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_performance_ledger_business_id ON performance_ledgers (business_id);
|
||||
CREATE INDEX idx_performance_ledgers_business_id ON performance_ledgers (business_id);
|
||||
|
||||
CREATE INDEX idx_performance_ledger_month_active ON performance_ledgers (settlement_month, is_active);
|
||||
CREATE INDEX idx_performance_ledgers_month_active ON performance_ledgers (settlement_month, is_active);
|
||||
|
||||
CREATE INDEX idx_performance_ledger_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type);
|
||||
CREATE INDEX idx_performance_ledgers_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type);
|
||||
|
||||
CREATE INDEX idx_performance_ledger_personnel_id ON performance_ledgers (personnel_id);
|
||||
CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (personnel_id);
|
||||
|
||||
ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单';
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Running upgrade c7d2e5a81f30 -> ba0217f5091b
|
||||
|
||||
SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'business_secondary_allocations' AND CONSTRAINT_NAME = 'uk_business_secondary_allocations_business_personnel');
|
||||
|
||||
SET @ddl := IF(@uk_exists = 0, 'ALTER TABLE business_secondary_allocations ADD CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id)', 'DO 0');
|
||||
|
||||
PREPARE stmt FROM @ddl;
|
||||
|
||||
EXECUTE stmt;
|
||||
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE alembic_version SET version_num='ba0217f5091b' WHERE alembic_version.version_num = 'c7d2e5a81f30';
|
||||
@@ -38,6 +38,8 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
# 健康检查脚本在容器内展开(密码不进宿主进程命令行,模式同 mysql)
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
# appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
|
||||
Binary file not shown.
@@ -35,7 +35,7 @@
|
||||
|
||||
--ys-radius: 10px;
|
||||
--ys-radius-sm: 7px;
|
||||
--ys-font: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', -apple-system,
|
||||
--ys-font: 'Noto Sans SC', 'PingFang SC', -apple-system,
|
||||
'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
--ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
/* 极微噪点:去除大色块的塑料感(内联 SVG,无外部依赖) */
|
||||
--ys-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
|
||||
/* 品牌字:思源黑体(现代几何黑体;实施时自托管 4 字子集,内网可用) */
|
||||
--ys-font-brand: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
/* 品牌字:思源黑体(现代几何黑体;实施时自托管 4 字子集,内网可用);栈内禁用微软雅黑等需授权字体 */
|
||||
--ys-font-brand: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC', sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -556,8 +556,8 @@
|
||||
<div class="bc-cap">B · 思源黑体 300 细体 · 0.42em</div>
|
||||
</div>
|
||||
<div class="bc">
|
||||
<div style="font-family:'Microsoft YaHei',sans-serif;font-weight:600;letter-spacing:.25em;font-size:16px">衍水集团</div>
|
||||
<div class="bc-cap">C · 微软雅黑 600 · 0.25em</div>
|
||||
<div style="font-weight:600;letter-spacing:.25em;font-size:16px">衍水集团</div>
|
||||
<div class="bc-cap">C · 系统默认 600(不指定字体,展示回退效果)· 0.25em</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -936,7 +936,7 @@
|
||||
<script src="echarts.min.js"></script>
|
||||
<script>
|
||||
/* 仪表盘图表:ECharts 定制主题(圆角柱 / 无网格线 / 浅灰轴标签)——实施时前端引入 echarts 依赖 */
|
||||
const chartFont = "'PingFang SC','Microsoft YaHei','Noto Sans SC',sans-serif";
|
||||
const chartFont = "'Noto Sans SC','PingFang SC',sans-serif";
|
||||
const rev = echarts.init(document.getElementById('chartRev'), null, { renderer: 'svg' });
|
||||
rev.setOption({
|
||||
grid: { left: 0, right: 0, top: 6, bottom: 20, containLabel: true },
|
||||
|
||||
@@ -45,12 +45,12 @@
|
||||
② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游)
|
||||
③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户
|
||||
│ 增量(2026-10-15):二次分配方案(合同刚建立时录入)
|
||||
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
||||
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清)
|
||||
⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
|
||||
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约
|
||||
⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层
|
||||
⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早
|
||||
④ 绩效工资核算 ✅ ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
||||
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1)
|
||||
⑦ 复核审批流(新)✅ ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
|
||||
⑤ 报表统计 ✅(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约(待 IT 18 项答复)
|
||||
⑧ 工作台(新)✅ ← 消费 ④⑤⑥⑦ 数据的聚合展示层
|
||||
⑨ 备份恢复与运维检查(新)✅ ← 独立于业务块,可与任一批次并行,建议尽早
|
||||
```
|
||||
|
||||
### 1.2 顺序论证(要点)
|
||||
@@ -68,12 +68,12 @@
|
||||
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 |
|
||||
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
||||
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 |
|
||||
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(语义待澄清,见 3.3) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
|
||||
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
|
||||
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
||||
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions) | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
|
||||
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态 | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
|
||||
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本 | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
|
||||
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
|
||||
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计。**✅ 已完成(2026-10-19)** | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
|
||||
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发。**⏳ 阻塞(IT 18 项未答复)** | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
||||
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions)。**✅ 已完成(2026-10-19;权限细目待业务清单)** | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
|
||||
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态。**✅ 已完成(2026-10-19)** | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
|
||||
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本。**✅ 已完成(2026-10-16)** | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
|
||||
|
||||
### 1.4 里程碑(交付批次)
|
||||
|
||||
@@ -81,9 +81,9 @@
|
||||
| --- | --- | --- |
|
||||
| **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) |
|
||||
| **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) |
|
||||
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流 | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」;开工前必须澄清:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7);内部建议顺序 ①增量 → ③增量 → ⑦ 审批框架 → ④ 核算 → 月结与审批串联 |
|
||||
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台 | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
|
||||
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付) | 数据持续积累,备份先行;不依赖业务模块进度 |
|
||||
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流。**✅ 已完成(2026-10-19,feat/m3-increments)** | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」已交付;余 3.3-1 答复与审批权限细目。开工前澄清项:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7,已参数化) |
|
||||
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台。**✅ ⑤⑧ 已完成(2026-10-19);⑥ 待 IT 18 项答复** | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
|
||||
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付)。**✅ 已完成(2026-10-16)** | 数据持续积累,备份先行;不依赖业务模块进度 |
|
||||
|
||||
### 1.5 需求依据对照
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24;增量见 3.1(一)(四) | 协助 25%;经理 8‰;26 天示例;含运费公式 |
|
||||
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R);增量见 3.1(四) | — |
|
||||
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
|
||||
| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——尚未回填需规与对照表,引用以第三章原文为准 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制,关系待业务澄清(3.3-1) |
|
||||
| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——已随 SRS V2.1(2026-10-19)回填;《问题与解决方案对照表》是否回填由用户决定 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制;#1–#3 答复仍空白,开发侧暂定口径已实现(3.3.1) |
|
||||
|
||||
---
|
||||
|
||||
@@ -610,11 +610,13 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
|
||||
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
|
||||
|
||||
> **2026-10-20 回填**:上述开放问题均已有结论,原表保留为 2026-10-12 设计时快照。#1 → 澄清 #14(首日整 10‰,A 口径);#2 → #18(按 2‰ 回溯,双种子保留);#3 → #15(一期仅 FULL,BALANCE 留参数位);#4 → #16(撤 `probation.salary.basic`,读 `personnel.salary_standard`);#5 → #17(查看放开登录用户、修改仅管理员);#6 → 催办清单三、R3;#7 → #12(`manager.personal.included` 参数,默认 0=不计入)。
|
||||
|
||||
---
|
||||
|
||||
## 第三章 2026-10-15 功能清单核查补充(新增需求)
|
||||
|
||||
> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容尚未回填需规文档与《问题与解决方案对照表》,是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。
|
||||
> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容已随《需求规格说明书》V2.1(2026-10-19)回填;《问题与解决方案对照表》是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。
|
||||
|
||||
### 3.1 需求原文(照录)
|
||||
|
||||
@@ -656,6 +658,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
#### (六)系统设置
|
||||
|
||||
- 公司信息配置:公司名称、logo 等基本信息
|
||||
- **2026-10-20 回填**:参数注册表已扩至 22 项(V8/V10 增补审批开关、试用期迁移、预警阈值等)
|
||||
|
||||
#### (七)参数配置强调
|
||||
|
||||
@@ -666,7 +669,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
#### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接
|
||||
|
||||
- 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `businesses` 创建同事务或紧随其后);合同建立后是否允许补录/修改未明说,方案修改的留痕方式待设计;
|
||||
- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1),存储上只存各参与人比例、不强制校验合计;
|
||||
- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1);存储上只存各参与人比例、不强制校验合计。**2026-10-20 回填**:#1–#3 答复仍空白,开发侧已按暂定口径实现分摊拆行(余额归负责人、行合计守恒,见 3.3.1),答复后如有变更走口径调整;
|
||||
- 「修改不影响已结算月份」→ 月结快照天然隔离(快照固化当月结果),方案表需支持编辑 + 修改留痕;
|
||||
- 「已结算月份调整只能通过调整单或后续月份扣补」→ 调整单为统一承载机制,见 (四)。
|
||||
|
||||
@@ -676,7 +679,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
| --- | --- |
|
||||
| 经营绩效总览 | ④ 台账/月结(按人/团队/月份汇总) |
|
||||
| 待办事项 | ⑦ 待审批单、⑥ 同步失败、回款未指定批次(M2 已有该状态可复用) |
|
||||
| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器 |
|
||||
| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器。**2026-10-20 回填**:方向已答复参数化(#10),首批三条按默认落 V10 参数(逾期 90 天/审批超时 3 天/同步失败待 ⑥) |
|
||||
| 最近同步/计算/审批动态 | ⑥ 同步日志 + ④ 核算/月结记录 + ⑦ 审批记录 |
|
||||
|
||||
#### (三) 复核审批 → ⑦ 新模块
|
||||
@@ -687,9 +690,11 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
- 「审批意见与版本」→ 审批单带版本号:重算/改单产生新版本,旧版本与意见留痕不覆盖;
|
||||
- **登录角色体系扩展**:营销后勤/财务/领导是系统新登录角色(`roles` 表基础设施已有;`permissions`/`role_permissions` 自 M1 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。
|
||||
|
||||
> **2026-10-20 回填**:审批框架与月结审批对象已交付(2026-10-19);权限点清单仍待业务方给出(澄清 #6,催办 B-6),当前按 role_code 粗粒度运行,营销后勤暂不可见审批列表。
|
||||
|
||||
#### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板
|
||||
|
||||
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」定义待澄清(3.3-3);
|
||||
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」已答复为人工确认、可手动关闭(3.3-3),月结按已确认回款取数;
|
||||
- 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径;
|
||||
- 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态);
|
||||
- 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据;
|
||||
@@ -708,6 +713,8 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
|
||||
### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清)
|
||||
|
||||
> **回填状态(2026-10-20):** 下表保留 2026-10-15 原始问题与建议默认(历史快照);业务答复与落实情况见 3.3.1。3.3-1 仍无业务答复,开发侧已按暂定口径实现分摊拆行,答复后如有变更按口径调整。
|
||||
|
||||
| # | 事项 | 影响模块 | 建议默认处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 |
|
||||
@@ -718,13 +725,41 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 |
|
||||
| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 |
|
||||
|
||||
### 3.3.1 答复回填(2026-10-19,2026-10-20 修订)
|
||||
|
||||
> **修订说明(2026-10-20):** 2026-10-19 版本把 24 项统一写成「全部落实并实现」,与 3.3-1 答复空白、开发侧暂定分摊的事实不符。本次按「业务答复 / 落实状态 / 关联项」重写;3.3-1 明确标注为开发侧暂定口径,不视为业务确认。
|
||||
>
|
||||
> 业务方 24 项答复于 2026-10-16 到达(回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)。分组:确认按建议默认 13 项(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#22/#23/#24,其中 #22 为模糊确认按不计入);与默认不同、已按答复落实 4 项(#5/#14/#16/#19);部分答复 3 项(#4/#6/#12);仍开放 4 项(#1/#2/#3/#20)。未答复项对外跟催见 `docs/待答复催办清单_2026-10-19.md`。
|
||||
|
||||
| 3.3# | 事项 | 业务答复(2026-10-16) | 落实状态(2026-10-20) | 关联项 / 证据 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 的关系) | **未答复**(澄清 #1–#3 空白) | **开发侧暂定口径已实现**(非业务答复):分配标的=绩效金额份额;参与人=份额×比例,负责人=全额−已分累计(余额归负责人);行合计守恒;合计>100% 与负责人自分 400 拦截;重算作废全部拆行。协助 25% 维持仅记录不核算。**答复后校准** | 澄清 #1/#2/#3;催办一、A-1 至 A-3;实现 ccfd206 |
|
||||
| 2 | 二次分配审批开关默认值 | 默认开启,可手动关闭 | 已落实:V8 参数种子默认开启;关闭时提交即生效(自动通过留痕) | 澄清 #5;`approval.secondary_allocation.enabled` |
|
||||
| 3 | 「确认回款」定义 | 需人工确认动作,可手动关闭 | 已落实:月结按已确认回款取数;回款确认状态可手动关闭 | 澄清 #8 |
|
||||
| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认 | 按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读;营销后勤暂不可见审批列表;permissions/role_permissions 细目待清单 | 澄清 #6;催办一、B-6 |
|
||||
| 5 | 风险预警规则 | 方向确认参数化;首批三条未点名 | 已按默认三条实现:`warning.enabled`、`warning.receipt.overdue.days=90`、`warning.approval.timeout.days=3`(V10);同步失败类待 ⑥ | 澄清 #10 |
|
||||
| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 同一审批链;跨月需管理员授权并留痕 | 已落实:调整单走同一审批链;跨月调整需管理员授权并留痕 | 澄清 #9 |
|
||||
| 7 | 待办事项范围 | 待审批 + 同步失败 + 回款未指定批次起步 | 已落实:工作台待办三项;⑥ 未建时同步项显式降级为「模块未启用」;待办按角色过滤 | 澄清 #11 |
|
||||
|
||||
#### 关联项表(与《待确认事项汇总_2026-10-16》及催办清单的对应)
|
||||
|
||||
| 3.3# | 澄清清单编号 | 催办清单位置 | 实现落点 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | #1/#2/#3(#15 为作废条款关系) | 一、A-1 至 A-3 | 二次分摊拆行(ccfd206) |
|
||||
| 2 | #5 | 已按答复执行(供核阅) | V8 迁移参数种子 |
|
||||
| 3 | #8 | 已按答复执行(供核阅) | 月结取数(settlement_service) |
|
||||
| 4 | #6 | 一、B-6 | permissions/role_permissions 待清单;当前按 role_code 粗粒度 |
|
||||
| 5 | #10 | 已按答复执行(供核阅) | V10 参数 + dashboard_service |
|
||||
| 6 | #9 | 已按答复执行(供核阅) | 调整单审批链与跨月授权 |
|
||||
| 7 | #11 | 已按答复执行(供核阅) | dashboard_service 待办三源 |
|
||||
|
||||
### 3.4 对路线图的影响(本次已修订)
|
||||
|
||||
1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容;第三章待 3.3 各项澄清后,按第二章同粒度补齐 M3 各增量块的任务级实施计划。
|
||||
1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容。M3 各增量块已于 2026-10-19 交付;3.3-1 仍无业务答复,二次分摊当前按开发侧暂定口径运行,答复后补口径变更计划;3.3-4 的权限细目与 3.3-5 的预警规则仍待业务确认。⑥ T+ 同步在 IT 答复前不排任务级计划。
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
||||
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
|
||||
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
|
||||
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量 301+ 用例与 e2e 全绿。
|
||||
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量后端用例与 e2e 全绿(以 `backend/tests/README.md` 最新运行记录为准)。
|
||||
@@ -0,0 +1,160 @@
|
||||
# M4 ⑤ 报表统计 · 任务级实施计划
|
||||
|
||||
> 立项日期 2026-10-19;分支 feat/m3-increments。依据:需规 2.4 /《需求规格说明书》FR-029~FR-032 / 开发计划第五章 1.3 验收行 / 2026-10-15 补充(四)「导出模板围绕财务复核和领导签字设计」。
|
||||
> 前置已满足:④ 台账/月结/调整单全部交付(台账即「报表 2.4.1 的数据源」,`performance_ledger.py` 注释锚点)。
|
||||
> **纯只读模块,无数据库迁移**。
|
||||
|
||||
## 〇、代码核实结论(实施时按此避坑)
|
||||
|
||||
| # | 结论 | 影响 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `require_roles` 403 文案硬编码「无权访问审批管理」(`api/deps.py:135`) | 给 `require_roles` 增可选 `message` 参数(默认值不变),报表传「无权访问报表统计」 |
|
||||
| 2 | `PageQuery.page_size` 上限 200(`core/response.py:57`) | 打印视图循环翻页取全量;导出走专用无分页查询 |
|
||||
| 3 | `settlement_service._confirm_scope` 带 `settlement_id IS NULL`(可入结口径) | 报表取数**不复用**,新增同月全量生效单笔行查询 |
|
||||
| 4 | `list_adjustments` 无月份筛选、limit 200、含全部状态 | 调整单并入需新写仓储查询(月份 + status=2) |
|
||||
| 5 | `GET /system/settings` 是 AdminUser,`/public` 才无角色限制 | 打印视图页眉必须走 `getPublicSettings()` |
|
||||
| 6 | 台账 `payment_days` 可空可负(先款后货);进度登记回款无台账行(#25) | 区间桶含「≤0 天」「无天数」兜底;回款统计注明「不含进度登记回款」 |
|
||||
| 7 | 二次分摊拆行使同一回款多行(负责人+参与人,business_id 相同) | 回款金额按 receipt_id 去重;个人明细不放「关联回款金额」列 |
|
||||
| 8 | 经理聚合行 calc_type=2 的 business_id 是锚点、receipt_id=NULL、payment_days=NULL | 业务统计/个人明细/回款统计一律 `calc_type=1 AND is_active=1`,防聚合额污染 |
|
||||
| 9 | openpyxl `NUMERIC_TYPES` 含 Decimal,原样落数值不转 float | 精度红线导出侧成立;`number_format='#,##0.00'` 控显示 |
|
||||
| 10 | `test_param_registry.py` 锁 registry 19 项 | 新增参数需四处同改(本计划不新增参数,规避) |
|
||||
| 11 | `PRODUCT_CATEGORIES` 常量不含 `mixed`,台账行有 `mixed` | 标签映射额外覆盖 mixed,单测防漂移 |
|
||||
| 12 | `audit_service.audit` 可用于只读导出留痕(独立 commit) | 直接复用;上下文内 bytes 在 commit 前固化 |
|
||||
| 13 | `test_settlement_helpers.py` 直接 import `settlement_service._month_end` | 抽共享模块时保留薄委托包装 |
|
||||
| 14 | 前端无任何打印/PDF/xlsx 代码 | 打印从零建:独立顶层路由 + `@media print` |
|
||||
| 15 | Starlette 响应头 latin-1,中文文件名会 UnicodeEncodeError | 导出文件名服务端 ASCII、中文名前端 `link.download` 赋 |
|
||||
|
||||
## 一、全局口径(写进 report_service docstring)
|
||||
|
||||
1. 数据源 `performance_ledgers` 固定 `is_active=1 AND calc_type=1`;聚合行只在部门汇总作冻结对照单读;月份维度 `settlement_month`。
|
||||
2. 金额全程 `Decimal` 出参字符串;比例原样 `rate_text`,禁 float/截断。
|
||||
3. 调整单并入:仅 `status=2`,按 `(settlement_month, personnel_id)` 并入,`direction=1` 记 +、`2` 记 −;归属按实时 `manager_id`。
|
||||
4. 离职人员不按 `status` 过滤(历史业绩留在原团队);零行经理枚举仅在职经理。
|
||||
5. 汇总 = 明细合计(团队/公司基数 == 成员明细绩效合计,测试逐项断言)。
|
||||
|
||||
### 已定设计决策
|
||||
|
||||
- 部门口径 = `personnel.manager_id` 实时上溯(无独立部门表,人员模型 docstring 已声明;已入催办清单 R1 向业务确认)
|
||||
- 导出 = 后端 openpyxl + StreamingResponse(ASCII 文件名);打印 = 前端独立打印视图 + `@media print`,PDF 走浏览器另存为;不引前端 PDF/xlsx 依赖
|
||||
- 不做图表(需求无要求,不引 ECharts)
|
||||
- 区间桶 = 代码常量 + 服务端下发元数据(前端零硬编码);不进参数体系避免迁移;配置缝收在 `get_payment_day_buckets()` 单函数,升级路径:`report.payment_days.buckets` 参数 → V10 迁移种子 → init-dev → registry 测试 19→20
|
||||
- 权限 `require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER)` 粗粒度,前后端双层(admin 显式列入)
|
||||
|
||||
## 二、后端任务
|
||||
|
||||
### T1 常量与元数据(`core/constants.py` 改 + `core/report_meta.py` 新)
|
||||
|
||||
> 实施修订(2026-10-19):`report_meta` 放在 `core/` 而非 `services/`——仓储层
|
||||
> 的区间下钻需要 `DayBucket` 类型,放 services 会让 repository 反向依赖
|
||||
> service 层(违反六层红线)。
|
||||
|
||||
- `MODULE_REPORT = "报表统计"`、`ACTION_EXPORT = "导出"`
|
||||
- `report_meta.py`(叶子模块,禁 app 内导入防循环依赖):`DayBucket` frozen dataclass + `PAYMENT_DAY_BUCKETS` 8 档(≤0 天(先款后货)/1-30/31-60/61-90/91-180/181-360/361+/无天数)、`CUSTOMER_TYPE_LABELS`、`PRODUCT_CATEGORY_LABELS`(含 mixed)、`REPORT_TEMPLATE_VERSION = "V1.0"`、`REPORT_EXPORT_MAX_ROWS = 20000`
|
||||
|
||||
### T2 仓储 `repositories/report_repository.py`(新)
|
||||
|
||||
| 函数 | 说明 |
|
||||
| --- | --- |
|
||||
| `list_personal_detail / count_personal_detail` | 单笔生效行分页(month/personnel/customer_type/product_category/role_code 筛选,id desc) |
|
||||
| `sum_personal_detail` | 全筛选口径 SUM(分页合计与页内合计分开,禁内存当前页求和) |
|
||||
| `list_month_receipt_rows` | 同月全量生效单笔行(部门汇总取数,不复用 _confirm_scope) |
|
||||
| `list_month_manager_aggregates` | 同月 calc_type=2 生效行(冻结对照) |
|
||||
| `list_month_adjustments` | status=2 + 月份 |
|
||||
| `list_business_stats / count / sum` | Business×Customer×Personnel 左联 + 三个 scalar_subquery(发货额 SUM(shipments.total_amount)、回款额 SUM(receipts.amount)、绩效 SUM(台账 performance_amount WHERE calc_type=1 AND is_active=1));keyword 双列 OR contains(autoescape=True) |
|
||||
| `list_payment_group_by_days` | GROUP BY (payment_days, customer_type, product_category) 出 SUM 绩效/应付/COUNT |
|
||||
| `list_payment_receipt_group_by_days` | 先 DISTINCT receipt_id 再 JOIN receipts 求和(拆行防翻倍) |
|
||||
| `list_payment_detail / count` | 区间下钻分页(闭区间 between、开区间 >=、none 桶 IS NULL),JOIN receipts 取金额与批次号 |
|
||||
|
||||
### T3 经理分成共享口径(`services/manager_share.py` 新 + `settlement_service.py` 改薄委托)
|
||||
|
||||
- 迁出(复制实现 + 薄委托,行为零变化):`month_end`、`ManagerShareParams`(含 `manager_share_params(session, month)`,锚定月末 resolve_snapshot)、`collect_team_buckets`、`company_base_of`
|
||||
- 报表侧分成额调用 `formula.manager_team_share(...)`——月结聚合行与报表汇总同源,杜绝口径漂移
|
||||
- **协调点**:84 会话已拆 settlement_service(028a94a);本任务在其基础上做,验证 `test_settlement_helpers.py` 全绿
|
||||
|
||||
### T4 服务与 schemas(`services/report_service.py` + `schemas/report.py` 新)
|
||||
|
||||
1. **个人明细**:`ReportPage[T]` = 标准分页四字段 + `summary`(全筛选合计);行含 ledger_id/business_id/business_no/人员/岗位/客户类型/产品类别/ship_date/receipt_date/payment_days/rate_text/weight_r/payment_weight/performance_amount/payable_amount;**不含回款金额列**
|
||||
2. **部门汇总**(月份必填):`collect_team_buckets` 分团队 → 团队基数/成员内联明细(personnel_id 聚合 + 台账 id 列表 + 调整 extra/deduct/net);`company` 块 5% 单列(team_base_total/company_base/company_share_amount;各团队 5% 之和 ≠ 全局 5%,UI 注明);零行经理并集枚举(在职 sales_manager JOIN business_roles + 数据中出现的 manager_id);对比 `company_base_ratio` 除零→0;冻结对照 `aggregate_ledger_id + frozen_amount`;出参 `params`(team_pct/company_pct/manager_personal_included/params_as_of)
|
||||
3. **业务统计**:分页 + summary;回款/绩效为该业务累计值(不随日期筛选变,页面 hint)
|
||||
4. **回款统计**:SQL 精确值聚合 → Python 按 `PAYMENT_DAY_BUCKETS` 分桶(恒含全桶、空桶 0);正负绩效直接求和不 ABS;出参 buckets/by_customer_type/by_product_category/totals
|
||||
5. **区间下钻**:`GET /reports/payment-detail`(bucket 必填,分页)
|
||||
6. **导出编排** `export_report(session, code, q, *, operator, meta)`:先 count 判上限(超限 400 中文提示按月缩小);`audit_service.audit(MODULE_REPORT, ACTION_EXPORT, ...)` 包取数+构建;出参 `(bytes, filename)` 在 commit 语义闭合前固化
|
||||
|
||||
### T5 Excel 模板(`services/report_export.py` 新,纯函数)
|
||||
|
||||
- 普通 `Workbook`(上限封顶 2 万行内存可承受);版式:公司页眉行(logo `read_logo` try/except 降级纯文本)→ 标题(合并居中)→ 筛选条件行 → 导出时间/人/模板版本 → 表头(加粗底纹边框 + freeze_panes)→ 数据行 → 合计行(service 侧 Decimal SUM quantize HALF_UP,**不写 Excel 公式**)→ 空两行 → 财务复核栏 / 领导签字栏 / 日期栏
|
||||
- 精度:金额 Decimal 直接写 + `#,##0.00`;比例 `'0.00"%"'`;`rate_text` 原样文本;日期 `date` + `YYYY-MM-DD`
|
||||
- 防公式注入:文本以 `= + - @ \t` 开头前置 `'`;CJK 列宽;部门汇总/回款统计多 sheet(各带页眉与签字栏);空数据允许(表头 + 提示行 + 签字栏)
|
||||
|
||||
### T6 API 与路由(`api/v1/reports.py` 新 + `router.py` 改 + `deps.py` 改)
|
||||
|
||||
| 方法 | 路径 | 入参(Query 蛇形) |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/reports/personal-detail` | pageNum/pageSize + settlement_month, personnel_id, customer_type, product_category, role_code |
|
||||
| GET | `/api/v1/reports/department-summary` | settlement_month(必填), manager_id |
|
||||
| GET | `/api/v1/reports/business-stats` | pageNum/pageSize + keyword, customer_id, owner_personnel_id, status, date_from, date_to |
|
||||
| GET | `/api/v1/reports/payment-stats` | settlement_month, date_from, date_to, customer_type, product_category |
|
||||
| GET | `/api/v1/reports/payment-detail` | pageNum/pageSize + bucket(必填) + 同上 |
|
||||
| GET | `/api/v1/reports/{code}/export` | code ∈ 四表,参数同对应页 → StreamingResponse xlsx |
|
||||
|
||||
- 全部 `def` 同步路由 + `Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计"))]`
|
||||
- Content-Disposition 文件名 ASCII(`personal_detail_20261019.xlsx`),沿用 `system/params.py` 导出先例
|
||||
|
||||
### T7 后端文档(`backend/README.md` 改)
|
||||
|
||||
报表接口表 + 「报表口径」小节(生效行/调整单并入/回款去重/不含进度登记回款/区间桶常量与升级路径)。
|
||||
|
||||
## 三、前端任务
|
||||
|
||||
### T8 API 与常量(`api/reports.ts` 新 + `utils/constants.ts` 改)
|
||||
|
||||
类型逐字段对齐(金额 string);`exportReport(code, params)` 裸 axios `responseType:'blob'`;MODULE_OPTIONS 与后端 `core/constants.py` 全量对齐
|
||||
|
||||
### T9 四个页面(`views/reports/{personal-detail,department-summary,business-stats,payment-stats}/index.vue` 新)
|
||||
|
||||
统一骨架:`filters(reactive)` + `usePagedList`(非分页表 ref+手动 load)+ `el-table stripe` + `AppPagination` + 工具条「导出 Excel」「打印」+ `handledError`;日期区间 `el-date-picker value-format="YYYY-MM-DD"`(必写);下拉复用 `useDropdownOptions`
|
||||
|
||||
| 页面 | 路由 order | 关键列与下钻 |
|
||||
| --- | --- | --- |
|
||||
| 个人绩效明细表 | `/reports/personal-detail` 19 | 需规 10 列 + 页脚全筛选合计(注明非当前页);expand 懒加载 `getLedger(ledger_id)` 快照下钻 |
|
||||
| 部门绩效汇总表 | `/reports/department-summary` 20 | 团队表 + 全公司 5% 卡片 + 参数取值提示;展开成员 → 跨页下钻个人明细(router.push query)→ 台账下钻复用 `listLedger` |
|
||||
| 业务统计表 | `/reports/business-stats` 21 | 发货额/回款额/绩效三列;expand 两小表(复用 `listReceipts`/`listLedger`);hint「累计值」 |
|
||||
| 回款统计表 | `/reports/payment-stats` 22 | buckets 元数据渲染(空桶 0)+ 桶展开下钻 `payment-detail` + 客户类型/产品类别对比小表 |
|
||||
|
||||
路由 meta:`{ title, group: '报表统计', order: 19..22, allowedRoles: [ROLE_ADMIN, 'finance', 'leader'] }`(admin 显式列入)
|
||||
|
||||
### T10 打印视图(`views/reports/print/index.vue` 新 + `router/index.ts` 改)
|
||||
|
||||
- 顶层路由 `/reports/print/:code`(不挂 BasicLayout),`menu:false` + allowedRoles 同上
|
||||
- 循环翻页取全量(pageSize 200、上限 2000 截断提示);页眉 `getPublicSettings()`(非管理员拿不到 `/system/settings`)
|
||||
- `window.print()`(按钮区 `@media print { display:none }`);`@page A4 landscape`;`thead { display: table-header-group }` 每页带表头;打印态白底黑字沿用 `--ys-*`
|
||||
|
||||
### T11 前端文档(`frontend/README.md` 改)
|
||||
|
||||
报表页与打印视图小节 + 权限矩阵
|
||||
|
||||
## 四、测试(模块整体完成时统一编写)
|
||||
|
||||
### T12 后端(`tests/unit/test_report_helpers.py` + `tests/integration/test_reports.py` 新)
|
||||
|
||||
- 单元:分桶边界(0/1/30/31/60/61/180/181/360/361/负值/NULL);标签键集合 ⊇ CUSTOMER_TYPES ∪ PRODUCT_CATEGORIES ∪ {"mixed"};Excel 结构断言(load_workbook 读回:页眉/合计/签字栏/number_format/注入前缀/空数据);占比除零→0
|
||||
- 集成(factories 造数):① 个人明细对拍 `/calc/ledger`、summary==全量和;② 部门汇总成员合计==团队基数==公司基数、`aggregate_amount==manager_team_share` 预期、生成月结后 computed==frozen;③ 无团队成员经理 0 行、离职成员计入原团队;④ 调整单生效行并入(status≠2 不计);⑤ 回款统计空桶恒在、负绩效计入、**分摊拆行金额只计一次**;⑥ 业务统计排除 calc_type=2;⑦ 导出 200/400/审计留痕(成功与失败);⑧ 普通 user 403(文案「无权访问报表统计」)
|
||||
|
||||
### T13 e2e(`frontend/e2e/report_flow.py` 新)
|
||||
|
||||
菜单可见 → 四页筛选与数字 → `page.expect_download()` 断言导出 → 打印视图注入 `window.print` 桩断言 → console 无错;造数用 API + 登录 token(远未来月份隔离,参照 calc_settlement_flow.py)
|
||||
|
||||
### T14 收尾
|
||||
|
||||
回归(pytest 全量 + ruff + type-check + build + e2e report_flow 与复跑两条既有脚本);三 README 更新;提交拆分 `feat(report): 报表统计后端取数与导出` → `feat(report): 报表统计前端四页与打印视图` → `test(report): 报表模块用例与测试记录`
|
||||
|
||||
## 五、开放项(不阻塞,随催办确认)
|
||||
|
||||
| 事项 | 默认 |
|
||||
| --- | --- |
|
||||
| 部门口径(R1) | manager_id 实时上溯 |
|
||||
| 区间档位(R2) | 8 档代码常量 |
|
||||
| 签字栏要素(R3) | 页眉/标题/筛选/时间人/版本/复核/签字/日期 |
|
||||
| 导出上限 | 20000 行超限 400 |
|
||||
| 打印上限 | 2000 行截断提示 |
|
||||
| Excel logo | 有则嵌、无则纯文本 |
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
-19
@@ -3,59 +3,62 @@
|
||||
> 汇总当前所有「待执行 / 待答复 / 待恢复」事项,供项目例会与对外跟催使用。
|
||||
> 来源:《待澄清问题清单_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 答复仍空白)。
|
||||
|
||||
## 一、立即待办(需项目内处理)
|
||||
|
||||
| # | 事项 | 状态 | 下一步动作 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | **推送 feat/m3-increments(27 个提交)** | ⛔ 被远端阻塞 | 43.140.215.244:7040 的 Git 服务应用层无响应(ping 通、TCP 通、HTTP 不回包),需**服务器管理员重启 Git 服务**;恢复后执行 `git push -u origin feat/m3-increments`。本地提交完整无丢失 |
|
||||
| 2 | **M2 分支合并 main** | 未开始 | feat/base-business-data(M2 全部 + code-review 32 条修复)尚未合入 main;远端恢复后与 #1 一并处理(PR 评审或本地合并) |
|
||||
| 1 | **推送 feat/m3-increments** | ✅ 已完成(2026-10-19) | 远端 Git 服务恢复后已推送,远端现为 e48cfb7(含 code-review 修复 4 提交 + #14 首日口径 + V8 系列 + 测试/文档);并行会话(88:calc_service 分摊拆行)收口后继续推送其后提交 |
|
||||
| 2 | **M2 分支合并 main** | 未开始 | feat/base-business-data(M2 全部 + code-review 32 条修复)尚未合入 main;与 #1 一并处理(PR 评审或本地合并) |
|
||||
|
||||
## 二、业务方澄清(24 项,**2026-10-16 答复已到**,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)
|
||||
|
||||
> 答复汇总:**12 项确认按默认执行**(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#23/#24);**4 项答复与默认不同,以答复为准**——#5 二次分配审批开关**默认开启**、#14 **首日整 10‰**、#16 试用期基本工资**按人员档案逐人维护**、#19 备份**加增量补充**;仍未落定见下表「答复状态」列。答复后待回填开发计划 3.3 与需求记录。
|
||||
> 答复汇总:**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 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定 |
|
||||
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认 |
|
||||
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊已于 2026-10-19 实现(ccfd206) |
|
||||
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** |
|
||||
| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 |
|
||||
| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 |
|
||||
| 12 | **经理个人户业绩是否计入其 12.5% 基数?(无默认,必须答复)** | —— | ④ 台账表结构设计 | ⚠️ 模糊:「做参数可调」,初始值未给 |
|
||||
| 12 | **经理个人户业绩是否计入其 12.5% 基数?(无默认,必须答复)** | —— | ④ 台账表结构设计 | ✅ **已落实为参数(2026-10-19)**:答复「做参数可调、初始值未给」→ 按默认 0=不计入落地(`manager.personal.included`),可手工调 1=计入 |
|
||||
|
||||
### 中优先级(模块实现期内答复即可,9 项)
|
||||
|
||||
| 清单编号 | 问题 | 答复状态(2026-10-16) |
|
||||
| --- | --- | --- |
|
||||
| 5 | 二次分配审批开关默认值? | 🔁 **与默认不同:默认开启**,业务可关闭 |
|
||||
| 7 | 月度结算审批链默认开还是关? | ✅ 默认开启,可手动关闭 |
|
||||
| 5 | 二次分配审批开关默认值? | ✅ 已落实(2026-10-19,V8):默认开启 + 当日生效行;调整单/二次分配/月结三类审批开关均已可读可调 |
|
||||
| 7 | 月度结算审批链默认开还是关? | ✅ 已落实(2026-10-19,V8):默认开启;财务节点通过仅流转,终审(领导)通过才锁定月结 |
|
||||
| 10 | 「风险预警」包含哪些情形与阈值? | ✅ 方向确认:参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步 |
|
||||
| 11 | 「待办事项」范围? | ✅ 确认:待审批+同步失败+回款未指定批次起步 |
|
||||
| 13 | 支付环节 5 项默认值顺手确认(起付 60%/R×R/预付 10‰/抵顶 80%/月工资口径) | ✅ 确认按默认执行 |
|
||||
| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL |
|
||||
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | 🔁 **与默认不同:按人员档案逐人维护**(用 `personnel.salary_standard`,撤 `probation.salary.basic` 参数) |
|
||||
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` |
|
||||
| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 |
|
||||
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ❌ 未答复:「当前未提到预收款情况」,暂按不计入 |
|
||||
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ⚠️ 模糊答复:「当前未提到预收款情况」,暂按不计入 |
|
||||
|
||||
### 低优先级(已有默认口径,顺手确认,7 项)
|
||||
|
||||
| 清单编号 | 问题 | 答复状态(2026-10-16) |
|
||||
| --- | --- | --- |
|
||||
| 14 | 需规图示「第 1 天 10‰」与公式不一致,是否认可按公式? | 🔁 **与默认不同:首日整 10‰**。实现口径待追问:A 仅首日覆盖(保「360 天=6‰」锚点,推荐)/ B 整体平移(破坏锚点) |
|
||||
| 17 | 计提参数等敏感配置普通用户可否查看? | ✅ 查看可、修改仅管理员 |
|
||||
| 14 | 需规图示「第 1 天 10‰」与公式不一致,是否认可按公式? | ✅ 已落实(2026-10-19,按 A 口径):首日整 10‰ 特判、次日起按公式(保「360 天=6‰」锚点);B 口径(整体平移)追问保留 |
|
||||
| 17 | 计提参数等敏感配置普通用户可否查看? | ✅ 已落实(2026-10-19):查看放开登录用户、修改仅管理员 |
|
||||
| 18 | 企业户 02-17 之前的业务是否按 2‰ 回溯? | ✅ 确认按回溯 |
|
||||
| 19 | 备份保留周期与恢复目标? | 🔁 **新增要求:每日全量 + 有更新时增量补充**(现三脚本仅全量) |
|
||||
| 19 | 备份保留周期与恢复目标? | ✅ 已落实(2026-10-19):全量 + binlog 增量归档补位 |
|
||||
| 21 | 退货/红字是否冲减绩效? | ✅ 当期冲减(未结算月),已结算走调整单 |
|
||||
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
|
||||
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
|
||||
|
||||
## 三、待 IT / T+ 侧答复(18 项,见《T+对接数据需求清单》v1.3「待IT确认事项」表)
|
||||
|
||||
> **2026-10-19 清单已升 v1.4**(`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`,七节:人员/产品/业务单据/发货单/回款单/客户档案/同步完整性与补充字段)——本节口径待收编到 v1.4 后再对外;待 IT 逐项确认的状态以最新版文件为准。
|
||||
> **2026-10-16 业务方已对「口径与用途说明」修订,已收编清单升 v1.3**:#18 回款↔发货批次定稿**人工指定**(T+ 核销关系仅作辅助,`shipment_id` 保持「同步不覆盖」,v1.2.1「T+ 主源」修订作废,M2 回款页现状不动);「确认回款」两处 #8 开放引用收敛;特殊业务标记待确认降为低优。
|
||||
> **新增追问 2 条**(随下一轮回执发业务方):① 「负责营销人员」口径被改为「人员编码**以姓名**为唯一识别」,与「编码是业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误;② 数据清单回款「关联订单号」仍写「由 T+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。
|
||||
|
||||
@@ -81,14 +84,15 @@
|
||||
|
||||
| 里程碑 | 内容 | 前置条件 |
|
||||
| --- | --- | --- |
|
||||
| M3 剩余 | ④ 核算引擎 + 月结/调整单/负数工资 + ⑦ 月结审批对象接入 | #12(基数参数初始值)、#20(含税口径)、#1–#3(二次分摊)落定后按同粒度另出计划;#14 首日 10‰ 口径(A/B)确认后随本批实现 |
|
||||
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 交付后 |
|
||||
| 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 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
|
||||
| 杂项收口(低优) | 超长函数 17 处收敛、散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
|
||||
| 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
|
||||
|
||||
## 五、文档待办(低优)
|
||||
|
||||
- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准
|
||||
- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.3**(收编业务方口径修订,生成脚本 build_tplus_xlsx.py 同步对齐)
|
||||
- 新口径待落实(业务答复与默认不同的 4 项):#5 审批开关默认开启(核对 M3 参数种子)、#14 首日整 10‰(④ 实现,A/B 口径待追问)、#16 试用期工资迁人员档案、#19 备份补增量能力
|
||||
- 澄清答复已到(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` 单点即可
|
||||
@@ -0,0 +1,82 @@
|
||||
# 待答复事项催办清单(2026-10-19)
|
||||
|
||||
> **用途**:对外跟催材料,分三块可直接拆分发送——一、业务方;二、IT/T+ 侧;三、报表口径补充确认(本轮新增)。
|
||||
> **基线**:《待澄清问题清单_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 项已裁决口径)。
|
||||
> **原则**:已答复项不重复询问;未答复项按清单内「建议默认」执行中,答复后以答复为准并回填文档。
|
||||
|
||||
## 一、业务方(建议回复期限:____)
|
||||
|
||||
### A. 仍未答复 —— 阻塞对应模块开工(4 项)
|
||||
|
||||
| # | 问题(原文口径) | 当前执行默认 | 阻塞 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 方案录入已交付;**分摊已按开发侧暂定口径实现**(非业务答复,答复后校准) | ④ 分摊计算 |
|
||||
| 2 | 分配比例合计不足 100% 时,剩余部分归属谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 |
|
||||
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ 分摊 |
|
||||
| 20 | 含税口径:销售/成本/毛利润/运费各项是否含税?价税合计与折扣折让如何处理? | 按不含税口径实现中 | ④ 核算口径终验、⑥ 同步建列(是否建税率/税额列) |
|
||||
|
||||
> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批;开发侧已按暂定口径把分摊拆行计入绩效(参与人份额×比例、负责人余额),**标的与余额归属仍待业务答复,答复后可能调整**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。
|
||||
|
||||
### B. 已有答复但需补具体值 / 勾选(4 项)
|
||||
|
||||
| # | 事项 | 需补充 |
|
||||
| --- | --- | --- |
|
||||
| 12 | 经理个人户业绩是否计入其 12.5% 团队基数 | 答复为「做参数可调」,**请给出初始值**:计入 / 不计入(系统已实现为开关参数,当前默认不计入) |
|
||||
| 14 | 「第 1 天 10‰」实现口径 | 请勾选:**A 仅首日覆盖**(推荐,保「360 天 = 6‰」锚点)或 B 整体平移(破坏锚点)。系统当前按 A 实现(首日 10‰、其余按公式 10 − 天数 × 4/360) |
|
||||
| 4 | 二次分配方案修改窗口与权限 | 答复「暂定允许修改,走调整单」——请定:首次月结前可改(留痕)?月结后只读、仅走调整单?谁有权改(管理员 / 营销后勤)? |
|
||||
| 6 | 营销后勤/财务/领导三类角色的账号归属、可见菜单、可操作点 | 答复「先预设角色,需与客户确认」——请给权限清单(三类角色各能看什么、能操作什么)。系统已按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读,营销后勤暂不可见审批列表 |
|
||||
|
||||
### C. 请确认两处勘误(已按勘误实施,需业务回执存档)(2 项)
|
||||
|
||||
| 项 | 原表述 | 现行勘误(v1.4) |
|
||||
| --- | --- | --- |
|
||||
| 人员识别 | 「负责营销人员以姓名为唯一识别」 | 改为「以人员编码(工号)为唯一识别、姓名辅助核对」——原表述与「编码为业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误,请确认 |
|
||||
| 回款↔发货批次 | 「由 T+ 收款分配关系指定,人工辅助」 | 改为「**系统人工指定**(对照表 #18 定稿),T+ 收款分配关系仅作辅助」——主次相反疑漏改,请确认 |
|
||||
|
||||
### 已按默认执行、无需回复的项(供核阅)
|
||||
|
||||
\#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。
|
||||
|
||||
## 二、IT / T+ 侧(建议回复期限:____)
|
||||
|
||||
> 2026-10-15 回稿收到后,v1.3「待IT确认事项」18 项**未逐项答复**(回稿仅做了必要性升级与第七节补充),机制性内容(同步频率/幂等/格式要求)未包含,「同步不覆盖字段」表被引用但未附。现有 v1.4 清单中「系统侧目标表.字段」列**全表留空待回填**。
|
||||
|
||||
### A. 高优先(9 项,阻塞核算口径与同步程序开工)
|
||||
|
||||
| # | 事项 | 为什么高 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **成本额取数口径**:成本类型/毛利计算方式/更新时间/精度 | 毛利润 = 销售额 − 成本额,是绩效金额全部源头;缺此则同步无法自动跑,只能人工录入 |
|
||||
| 2 | 发货日期口径:出库日 / 签收日 / 开票日 | 回款天数基准日(发货日首日计第 1 天),直接决定计提比例 |
|
||||
| 3 | 到账日期口径:收款日 / 银行到账日 / 核销日 | 计提参数版本匹配日,直接决定适用哪一版比例 |
|
||||
| 4 | 幂等唯一键规则:作废重开是否换号 | 重复同步不重复入库的前提 |
|
||||
| 5 | 三单关联键:订单 / 发货 / 回款如何互相关联 | 一笔业务可分批发货;回款↔批次系统内人工指定,但订单↔发货关联仍必须由 T+ 提供 |
|
||||
| 6 | **人员唯一编码(工号)** + 业务员↔营销经理隶属关系 | 业绩归属唯一锚点(重名即错钱);经理 12.5% 团队聚合依隶属关系展开 |
|
||||
| 7 | 对接方式与安全:REST / 数据库视图 / 中间表 / 文件交换 | 未定则同步程序无法开工(一期人工录入可闭环) |
|
||||
| 8 | 负数 / 红字 / 退货 / 冲销处理规则与原单关联 | 决定第七节补充字段如何建列;如不提供则只能靠人工补录 |
|
||||
| 16 | 回款确认 / 核销状态字段与枚举 | 月度结算按「确认回款」生成,需 T+ 核销/确认类字段作人工确认依据 |
|
||||
|
||||
### B. 回稿三缺口(请补)
|
||||
|
||||
1. v1.3 第七节曾漏「人员唯一编码(工号)」——v1.4 数据清单第一节已补★项并注明「待IT确认 #19:T+ 档案编码是否即工号,若为自动内部编码请指明工号字段」;
|
||||
2. 「客户档案是否同步」(待IT确认 #10)未点名答复——v1.4 第六节已列客户档案 5 项,请确认可提供;
|
||||
3. 「系统侧目标表.字段(T+ 确认映射)」列全表留空——请回填,这是同步程序的字段契约。
|
||||
|
||||
### C. 中低优先(9 项,模块实现期内答复即可)
|
||||
|
||||
存量数据起始日期与增量口径(影响能出多早的报表)、客户匹配与唯一键、收款方式完整枚举清单、产品「数量」语义(库存量/在途/累计销量)、发货单价口径(订单价/发货时价)、特殊业务标记字段、同步窗口时间、市场拓展计划数据、待IT确认 #18(口径裁决 5 项已转业务方,见第一部分)。
|
||||
|
||||
## 三、报表口径补充确认(2026-10-19 新增,随本轮开发一并排)
|
||||
|
||||
> 报表统计模块(需规 2.4 / FR-029~FR-032)开工在即,以下三项请确认;未答复按「建议默认」实现,后续可调。
|
||||
|
||||
| # | 事项 | 建议默认(将按此实现) |
|
||||
| --- | --- | --- |
|
||||
| R1 | 部门绩效汇总表的「部门」口径 | 部门 = 经理团队(经理 + 其直属业务员),按人员档案的隶属关系展开;离职人员历史业绩留在原团队;经理的 5% 全公司部分单独一列 |
|
||||
| R2 | 回款统计的「回款天数区间」档位 | ≤0 天(先款后货)|1-30|31-60|61-90|91-180|181-360|361 以上|无回款天数,共 8 档;未命中区间显示 0 |
|
||||
| R3 | 导出/打印模板要素 | 公司信息页眉(名称+logo)、报表标题、筛选条件、导出时间与导出人、模板版本号、财务复核栏、领导签字栏、日期栏 |
|
||||
|
||||
另:报表查看权限(FR-029 参与者含「营销后勤(授权范围内)」)——请随第一部分 B-6 权限清单一并明确营销后勤是否需要查看报表、可见哪些列。
|
||||
|
||||
---
|
||||
|
||||
**发送建议**:第一部分发业务方(含 C 两项勘误确认);第二部分发 IT/T+(v1.4 附表一并);第三部分随开发进度与业务方顺带确认。
|
||||
Binary file not shown.
@@ -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 链路),剩余工作以外部输入与审计性技术债为主,可按计划推进。
|
||||
+66
-14
@@ -79,38 +79,42 @@ pnpm build # 类型检查 + 生产构建
|
||||
|
||||
## 视觉设计
|
||||
|
||||
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:深水面板 + 流动的层理线,即公司名的意象,也是业务的隐喻。
|
||||
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」)。
|
||||
|
||||
**配色**(金色分两档是有意的:亮金只用于深色底、深金只用于浅色底,两处对比度都达标)
|
||||
**配色**(靛蓝只作重点点缀——主按钮 / 链接 / 焦点 / 当前菜单,大面积用中性灰白)
|
||||
|
||||
| 名 | 值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 深渊 | `#061A22` | 品牌面板底色 |
|
||||
| 沉碧 | `#0F3646` | 面板渐变、水纹线 |
|
||||
| 青金 | `#C9A227` | **深色底上**的细线与边框 |
|
||||
| 赭金 | `#8F6520` | **浅色底上**的主按钮(配白字约 6.2:1) |
|
||||
| 素纸 | `#FAFAF7` | 表单侧底色 |
|
||||
| 墨 | `#16262D` | 正文 |
|
||||
| 靛蓝 | `#2e4ba8` | 主按钮、链接、焦点、当前菜单(配白字约 7.5:1) |
|
||||
| 深靛 | `#223a85` | 主色深阶(按压态) |
|
||||
| 淡靛 | `#edf1fa` | 选中底色、浅色强调底 |
|
||||
| 纸白 | `#ffffff` | 卡片与面板底色 |
|
||||
| 雾灰 | `#f4f5f8` | 页面底色 |
|
||||
| 墨 | `#1c2333` | 正文 |
|
||||
| 墨灰 | `#5b6577` / `#9aa3b2` | 次级文本 / 辅助文本 |
|
||||
|
||||
**字体**:品牌名用**宋体**(衬线,拟正式、拟账册),正文用系统无衬线,数字与验证码用等宽。
|
||||
**字体**:品牌名用**思源黑体**(`Noto Sans SC` / `Source Han Sans SC`,开源可商用),正文用同一无衬线栈,数字与验证码用等宽。**禁止引入微软雅黑、宋体等需商业授权的字体**——CSS 字体栈、设计稿、导出物一律只用开源或免费商用字体(如阿里巴巴普惠体、HarmonyOS Sans)。
|
||||
**界面不引入网络字体**——内网环境字体加载不可靠,靠字重、字距与留白做层次更稳妥。新页面请沿用这套字号节奏。
|
||||
|
||||
**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页与布局、系统管理页面共用;全局还把 `--el-color-primary` 指向赭金,Element Plus 组件默认走品牌色。登录页 scoped 里的同名局部变量只是全局 token 的别名。
|
||||
**质感来源**(而非装饰):表面 1–2% 明度差的微渐变、半透明描边、近距 + 远距分层阴影、内高光 / 内阴影、极微噪点(内联 SVG)、精确排版(字距 / 等宽数字 / 行高节律)。
|
||||
|
||||
**签名元素**:品牌面板的水纹层理线,越往下越密(如潜向深处),载入时用 `stroke-dashoffset` 逐条画出——**一个统一的入场动作,而不是散落的特效**。
|
||||
**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页、布局与各业务页面共用;全局把 `--el-color-primary` 指向靛蓝,并按 Element Plus 公式预计算了 light-3/5/7/8/9 与 dark-2 色阶(不用 `color-mix()`,避免依赖较新浏览器特性)。旧 token 名(深水青 / 金色系列)在 `:root` 中保留映射,存量页面迁移完成后移除。
|
||||
|
||||
**构图**:品牌内容(名称 / 金线 / 系统名 / 主标语 / 环节行)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
|
||||
**签名元素**:**渐隐靛蓝水线**——仪表盘等页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
|
||||
|
||||
**构图**:登录页品牌内容(名称 / 标语 / 水线)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
|
||||
早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。
|
||||
|
||||
### 写代码时的三条约定
|
||||
### 写代码时的四条约定
|
||||
|
||||
- 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定
|
||||
- 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 `<span>`),交给浏览器自动换行会出现单字独占一行的难看结果
|
||||
- 新页面需响应式到移动端、保留键盘焦点可见、并尊重 `prefers-reduced-motion`
|
||||
- **确认框统一加 `customClass: 'ys-confirm-box'`,不要再传 `type: 'warning'`** —— Element Plus 的默认 warning 图标会渲染在正文左侧独立成列(多行正文时图标悬在文字块中间),与 v3 靛蓝体系也冲突。全局样式(`src/assets/styles/index.css`)已把图标改为 18px 描边样式并放在标题行左侧,正文与标题文字对齐
|
||||
|
||||
## 验证码图片
|
||||
|
||||
后端自绘渲染(`backend/app/core/captcha_image.py`),**不再使用 `captcha` 库的默认输出**——那是随机彩色字符 + 重噪点,与界面视觉体系冲突。自绘后字符为墨蓝、底为素纸、干扰线与噪点取自同一套配色。
|
||||
后端自绘渲染(`backend/app/core/captcha_image.py`),**不再使用 `captcha` 库的默认输出**——那是随机彩色字符 + 重噪点,与界面视觉体系冲突。自绘后字符为深青色、底为暖白、干扰线与噪点取自同一套配色。
|
||||
|
||||
识别难度由**逐字随机旋转(±14°)+ 垂直抖动(±2px)+ 正弦干扰曲线 + 双层噪点**共同保证,与原实现相当;笔画加了 1px 描边——本系统用户是营销人员,屏幕与年龄跨度大,验证码过难会直接变成支持负担。
|
||||
|
||||
@@ -178,3 +182,51 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||
|
||||
运行前置同上(需 `E2E_ADMIN_PASSWORD`)。**期间修复的缺陷**:public 端点 300s 响应缓存让登录页拿旧公司名(改 `fetch cache: no-store` + 时间戳绕过磁盘缓存);业务弹窗分配表与产品明细共用 `items-table` 类导致选择器错位(分配表加专属 `alloc-table` 类)。
|
||||
**回归**:既有五脚本(登录 8 / 系统管理 13 / 基础数据 12 / 业务 16 / 参数 7 步)在 allowedRoles 路由扩展与品牌名动态化后全部复跑通过。
|
||||
|
||||
## 前端页面(2026-10-19 追加 · M4 ⑤ 报表统计)
|
||||
|
||||
| 页面 | 说明 |
|
||||
| --- | --- |
|
||||
| `/reports/personal-detail` 个人绩效明细(新组「报表统计」order19) | 人员/月份/客户类型/产品类别/岗位筛选;需规 2.4.1 全部列;行展开懒加载台账快照;页脚**全筛选合计**(注明非当前页);导出/打印 |
|
||||
| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司分成独立卡片(单经理 5% × 计入人数 = 合计,每位有聚合行的经理各计一次);团队表(基数/团队分成/全公司分成/占比/调整/净合计/月结冻结额),净合计与月结聚合行同口径,比例列头随在线参数变化;展开成员 → 跨页下钻个人明细(人员与月份带入筛选);未分配桶(按经理筛选时为空) |
|
||||
| `/reports/business-stats` 业务统计(order21) | 关键字/客户/归属人/状态/业务日期;发货额/回款额/绩效(累计口径提示);展开下钻回款与台账明细 |
|
||||
| `/reports/payment-stats` 回款统计(order22) | 月份/回款日期/客户类型/产品类别;8 档区间表(空桶 0,档位由后端下发);展开桶下钻分页明细;客户类型/产品类别对比表 |
|
||||
| `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(以接口 total 判定超过 2000 行并提示截断);部门汇总按「团队分成 → 全公司 5% → 对账 → 成员明细 → 未分配」五块渲染;`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 |
|
||||
|
||||
- 路由组「报表统计」四页 `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` 已补 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 台账与月结)
|
||||
|
||||
| 脚本 | 覆盖 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| `e2e/calc_settlement_flow.py` | 绩效核算与月度结算 8 步:登录进台账页 → API 预置(客户/人员/业务/发货/回款/核算,随机远未来月份防碰撞)→ 按归属月筛选见 v1 行 → 重算对话框(原因必填)→ v2 生效 → 版本三态筛选(仅历史 / 仅当前 / 清空=全部)→ 生成月结草稿 → 提交审批(开关显式关闭=自动通过锁定)→ 详情抽屉(按人明细/净工资/锁定状态) | ✓ 全通过(2026-10-19 首跑,修复下述缺陷后) |
|
||||
|
||||
运行前置同上(需 `E2E_ADMIN_PASSWORD`);数据 `e2e_` 前缀 + 随机远未来月份(209x),核算/月结按设计不可删,行数据留档开发库。
|
||||
**首跑即抓出三处真实前端缺陷**(M3 台账/月结两页自 2026-10-17 交付起一直「暂无数据」):
|
||||
① `src/api/{calc,settlements,approvals}.ts` 误用默认导出 `request`(axios 实例,返回 AxiosResponse 未解包),列表 `list` 恒为 undefined → 三页永远「暂无数据」;改为具名导入 `{ request }`(信封解包版)。
|
||||
② 三页均无 `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`。
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+23
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
import request from './request'
|
||||
import { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 审批单状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import request from './request'
|
||||
import { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 绩效台账行(金额/比例一律 str) */
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import request from './request'
|
||||
import { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 月结状态:1草稿 2待审批 3已审定(=已锁定)4已驳回 */
|
||||
|
||||
@@ -30,8 +30,12 @@
|
||||
--ys-text: #1c2333;
|
||||
--ys-muted: #5b6577;
|
||||
|
||||
/* 品牌字:思源黑体(实施时自托管 4 字子集,内网可用) */
|
||||
--ys-font-brand: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
/*
|
||||
* 品牌字:思源黑体(实施时自托管 4 字子集,内网可用)。
|
||||
* 栈内只用开源/免费商用字体 —— 微软雅黑、宋体等需商业授权的系统字体不得入栈。
|
||||
*/
|
||||
--ys-font-brand: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC',
|
||||
sans-serif;
|
||||
|
||||
/*
|
||||
* Element Plus 主色 = 靛蓝(白字对比约 7.5:1)。
|
||||
@@ -60,8 +64,9 @@ body,
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', Arial, sans-serif;
|
||||
/* 与品牌栈同源:开源字体优先;Helvetica/Arial 仅为西文回退(系统自带,不涉字体文件分发) */
|
||||
font-family: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC',
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--ys-ink);
|
||||
background-color: var(--ys-mist);
|
||||
@@ -76,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>
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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: '取消',
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import * as calcApi from '@/api/calc'
|
||||
@@ -21,13 +21,15 @@ const filters = reactive({
|
||||
is_active: 1 as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search } = usePagedList<
|
||||
LedgerRow,
|
||||
{ settlement_month?: string; is_active?: number }
|
||||
>(calcApi.listLedger, () => ({
|
||||
settlement_month: filters.settlement_month.trim() || undefined,
|
||||
is_active: filters.is_active,
|
||||
}))
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
||||
usePagedList<LedgerRow, { settlement_month?: string; is_active?: number }>(calcApi.listLedger, () => ({
|
||||
settlement_month: filters.settlement_month.trim() || undefined,
|
||||
is_active: filters.is_active,
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.settlement_month = ''
|
||||
@@ -40,19 +42,23 @@ const recalcTarget = ref<LedgerRow | null>(null)
|
||||
const recalcReason = ref('')
|
||||
|
||||
function openRecalc(row: LedgerRow): void {
|
||||
// 聚合行(calc_type=2)receipt_id 为 NULL,无回款可重算(按钮已限量,防御)
|
||||
if (row.receipt_id == null) return
|
||||
recalcTarget.value = row
|
||||
recalcReason.value = ''
|
||||
recalcVisible.value = true
|
||||
}
|
||||
|
||||
async function submitRecalc(): Promise<void> {
|
||||
if (!recalcTarget.value) return
|
||||
const row = recalcTarget.value
|
||||
if (!row || row.receipt_id == null) return
|
||||
if (!recalcReason.value.trim()) {
|
||||
ElMessage.warning('重算必须填写原因')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await calcApi.recalcReceipt(recalcTarget.value.id, recalcReason.value.trim())
|
||||
// 重算接口按回款 ID 定位(台账行主键 ≠ 回款 ID:传 row.id 会重算到错误回款)
|
||||
await calcApi.recalcReceipt(row.receipt_id, recalcReason.value.trim())
|
||||
ElMessage.success('重算完成(新版本已生效)')
|
||||
recalcVisible.value = false
|
||||
await load()
|
||||
@@ -75,9 +81,16 @@ async function submitRecalc(): Promise<void> {
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="版本">
|
||||
<el-select v-model="filters.is_active" style="width: 130px">
|
||||
<!-- 可清空:清空=不加 is_active 过滤(axios 自动剔除 undefined),
|
||||
即「全部版本」;0 精确匹配历史行,不能用来表达「含历史」 -->
|
||||
<el-select
|
||||
v-model="filters.is_active"
|
||||
placeholder="全部版本"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
>
|
||||
<el-option label="仅当前生效" :value="1" />
|
||||
<el-option label="含历史版本" :value="0" />
|
||||
<el-option label="仅历史版本" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
@@ -110,7 +123,7 @@ async function submitRecalc(): Promise<void> {
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<el-table-column label="操作" width="90" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.is_active === 1 && row.calc_type === 1"
|
||||
@@ -125,10 +138,11 @@ async function submitRecalc(): Promise<void> {
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
v-model:page-num="pageNum"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
@change="load"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="recalcVisible" title="重算确认" width="460px">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 消费验证码,无论何种失败它都已失效。
|
||||
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user