Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac4ba7514b | ||
|
|
27533eb465 | ||
|
|
4c10475228 | ||
|
|
2e0c64849b | ||
|
|
33b9b00bb0 | ||
|
|
d03ddadd1a | ||
|
|
c319a6a428 | ||
|
|
36114745db | ||
|
|
b86eaf2b00 | ||
|
|
71df9a16ed | ||
|
|
980628bb8f | ||
|
|
c8131588bf | ||
|
|
b6d84033ce | ||
|
|
698df6d31a | ||
|
|
39a3fa7796 | ||
|
|
ebb3a92f25 | ||
|
|
c91d1db78a | ||
|
|
294d99a0d3 | ||
|
|
6ff202d637 | ||
|
|
4e54d09ccb |
No files matched your search
@@ -64,3 +64,9 @@ CLAUDE.md
|
|||||||
backend/uploads/
|
backend/uploads/
|
||||||
# 数据库备份导出目录(⑨ 备份脚本产物)
|
# 数据库备份导出目录(⑨ 备份脚本产物)
|
||||||
db/backups/
|
db/backups/
|
||||||
|
|
||||||
|
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
|
||||||
|
docs/**/*.rar
|
||||||
|
docs/T_12.3数据字典/
|
||||||
|
# macOS 压缩包残留元数据
|
||||||
|
__MACOSX/
|
||||||
+30
-4
@@ -129,6 +129,16 @@ app/
|
|||||||
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
|
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
|
||||||
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
|
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
|
||||||
|
|
||||||
|
### 工作台接口(M4 ⑧,限管理员/财务/领导)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/dashboard?month=YYYY-MM` | 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志) |
|
||||||
|
|
||||||
|
待办与预警**实时派生**(不落表、不引调度器);预警阈值走参数体系
|
||||||
|
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
|
||||||
|
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -211,15 +221,31 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
|||||||
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
|
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
|
||||||
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
|
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
|
||||||
|
|
||||||
|
### 工作台口径(M4 ⑧,改动前先读)
|
||||||
|
|
||||||
|
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
|
||||||
|
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定,**已终止业务(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 ⑤,改动前先读)
|
### 报表口径(M4 ⑤,改动前先读)
|
||||||
|
|
||||||
- **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。
|
- **数据源固定单笔生效行**:四张报表的明细一律 `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` 是两个口径。
|
- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入,**不要求该人当月有台账行**(只有调整单也要出零绩效成员行);`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。调整单归属跟随该人普通行的实时 `manager_id`;只有经理个人户的经理跟随本人团队。
|
||||||
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
|
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
|
||||||
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块),**12.5% 与 5% 分列**——两项之和才是聚合行金额。
|
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块)。
|
||||||
|
- **全公司 5% 的发放口径**:月结只为有台账 bucket 的营销经理各生成一条聚合行(12.5%+5%),**每位经理各计一次 5%**;零成员团队无聚合行、不发 5%。报表的 `aggregate_amount` 优先取冻结聚合行、未生成月结时才用 `manager_team_share` 一次舍入预览;`team_subtotal`/`net_total` = 聚合额 + 调整净额。`team_share_total` 与 `company_share_total` 仅分列展示,两者之和与聚合额的差记入 `aggregate_rounding_diff`(分列各自 q2 会有 0.01~0.02 尾差)。
|
||||||
|
- **对拍边界**:报表应发 = 月结 `total_payable` + 调整单净额(调整单不进月结 items);筛选 `manager_id` 时只汇总展示团队,隐藏金额进 `other_*`,不能与全公司月结总额对拍。
|
||||||
|
- **人员归属按行类型**:普通台账行跟随 `manager_id`;经理个人户行在 `manager.personal.included=1` 时归经理本人团队,且经理成员行**只取个人户金额**(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 `manager_id` 筛选时 `unassigned` 为空(语义是「仅看该经理团队」)。
|
||||||
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
|
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
|
||||||
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。
|
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);第二块展示单经理 5%、计入次数与合计,对账块展示团队分成/全公司分成(分列)、聚合行舍入差、调整净额,应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
|
||||||
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;集成测试逐项断言。
|
- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。
|
||||||
|
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
|
||||||
|
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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,212 @@
|
|||||||
|
"""V11 客户评级落库与站点信息
|
||||||
|
|
||||||
|
Revision ID: c4e2a86f9b13
|
||||||
|
Revises: 90653814a421
|
||||||
|
Create Date: 2026-10-19 10:00:00.000000
|
||||||
|
|
||||||
|
与上游 V7–V10(台账/月结/调整单/口径/唯一键/预警参数)合并时重编号为 V11:
|
||||||
|
本分支三个迁移(原 V7 客户评级 + V8 公司档案精简 + V9 图片版本号)从未发布,
|
||||||
|
压成单条,最终结构 = 评级表 + 最终版站点/公司信息字段(不含中途增删的
|
||||||
|
副标题/信用代码/地址/电话/邮箱)。
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c4e2a86f9b13"
|
||||||
|
down_revision: str | Sequence[str] | None = "90653814a421"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移(双主题一次交付)。
|
||||||
|
|
||||||
|
- 客户评级弹窗化:评分每客户落库(customer_ratings,一行一客户快照)
|
||||||
|
- 公司信息补充与精简:标语/页脚/备案号/站点标题/站点图标 + 图片版本号
|
||||||
|
"""
|
||||||
|
op.create_table(
|
||||||
|
"customer_ratings",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"customer_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户ID(唯一:一客户一份当前评级)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"score_ship_amount",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="发货额评分(0-20)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"score_receipt_rate",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="回款率评分(0-20)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"score_profit_rate",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="毛利率评分(0-20)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"score_contract_cooperation",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="合同履约配合能力评分(0-20)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"score_site_construction",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="现场规范施工能力评分(0-20)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_score",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
nullable=False,
|
||||||
|
comment="总分(服务端重算,五项之和 0-100)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"grade_name",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"scorer_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="评级操作人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"scorer_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="评级操作人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["customer_id"],
|
||||||
|
["customers.id"],
|
||||||
|
name=op.f("fk_customer_ratings_customer_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["scorer_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_customer_ratings_scorer_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_ratings")),
|
||||||
|
sa.UniqueConstraint("customer_id", name=op.f("uk_customer_ratings_customer_id")),
|
||||||
|
comment="客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"site_title",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"brand_slogan",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="品牌标语(登录页与品牌预览;null=内置默认)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"footer_text",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"icp_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="ICP 备案号(登录页页脚,空=不显示)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"favicon_path",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
comment="站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"logo_version",
|
||||||
|
sa.Integer(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"favicon_version",
|
||||||
|
sa.Integer(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移:先删评级表(引用 customers/users),再逆序移除站点信息列。"""
|
||||||
|
op.drop_table("customer_ratings")
|
||||||
|
op.drop_column("system_settings", "favicon_version")
|
||||||
|
op.drop_column("system_settings", "logo_version")
|
||||||
|
op.drop_column("system_settings", "favicon_path")
|
||||||
|
op.drop_column("system_settings", "icp_no")
|
||||||
|
op.drop_column("system_settings", "footer_text")
|
||||||
|
op.drop_column("system_settings", "brand_slogan")
|
||||||
|
op.drop_column("system_settings", "site_title")
|
||||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.api import health
|
from app.api import health
|
||||||
from app.api.v1 import auth
|
from app.api.v1 import auth
|
||||||
|
from app.api.v1.base import business_roles as base_business_roles
|
||||||
from app.api.v1.base import customers as base_customers
|
from app.api.v1.base import customers as base_customers
|
||||||
from app.api.v1.base import personnel as base_personnel
|
from app.api.v1.base import personnel as base_personnel
|
||||||
from app.api.v1.base import products as base_products
|
from app.api.v1.base import products as base_products
|
||||||
@@ -16,6 +17,7 @@ from app.api.v1.business import ratings as business_ratings
|
|||||||
from app.api.v1.business import receipts as business_receipts
|
from app.api.v1.business import receipts as business_receipts
|
||||||
from app.api.v1.business import shipments as business_shipments
|
from app.api.v1.business import shipments as business_shipments
|
||||||
from app.api.v1.calc import router as calc_router
|
from app.api.v1.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.reports import router as reports_router
|
||||||
from app.api.v1.settlements import router as settlements_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 approvals as system_approvals
|
||||||
@@ -52,6 +54,7 @@ base_router = APIRouter(prefix="/base")
|
|||||||
base_router.include_router(base_personnel.router)
|
base_router.include_router(base_personnel.router)
|
||||||
base_router.include_router(base_products.router)
|
base_router.include_router(base_products.router)
|
||||||
base_router.include_router(base_customers.router)
|
base_router.include_router(base_customers.router)
|
||||||
|
base_router.include_router(base_business_roles.router)
|
||||||
v1_router.include_router(base_router)
|
v1_router.include_router(base_router)
|
||||||
|
|
||||||
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
|
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
|
||||||
@@ -68,5 +71,7 @@ v1_router.include_router(calc_router)
|
|||||||
v1_router.include_router(settlements_router)
|
v1_router.include_router(settlements_router)
|
||||||
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
|
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
|
||||||
v1_router.include_router(reports_router)
|
v1_router.include_router(reports_router)
|
||||||
|
# 工作台(M4 ⑧):/api/v1/dashboard,四区块只读聚合,限管理员/财务/领导
|
||||||
|
v1_router.include_router(dashboard_router)
|
||||||
|
|
||||||
api_router.include_router(v1_router)
|
api_router.include_router(v1_router)
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -24,7 +24,8 @@ from app.schemas.customer import (
|
|||||||
CustomerUpdateIn,
|
CustomerUpdateIn,
|
||||||
OwnershipChangeLogOut,
|
OwnershipChangeLogOut,
|
||||||
)
|
)
|
||||||
from app.services import customer_service
|
from app.schemas.rating import CustomerRatingOut, CustomerRatingSaveIn
|
||||||
|
from app.services import customer_service, rating_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/customers", tags=["客户管理"])
|
router = APIRouter(prefix="/customers", tags=["客户管理"])
|
||||||
|
|
||||||
@@ -140,3 +141,45 @@ def list_ownership_logs(
|
|||||||
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
|
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
|
||||||
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
|
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
|
||||||
return ok(customer_service.list_ownership_logs(session, customer_id, query))
|
return ok(customer_service.list_ownership_logs(session, customer_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{customer_id}/rating",
|
||||||
|
response_model=ApiResponse[CustomerRatingOut],
|
||||||
|
summary="客户当前评级",
|
||||||
|
)
|
||||||
|
def get_customer_rating(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[CustomerRatingOut]:
|
||||||
|
"""查某客户当前评级。
|
||||||
|
|
||||||
|
未评级返回 ``data: null``(这是正常态而非错误);客户不存在才 404。
|
||||||
|
"""
|
||||||
|
return ok(rating_service.get_customer_rating(session, customer_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{customer_id}/rating",
|
||||||
|
response_model=ApiResponse[CustomerRatingOut],
|
||||||
|
summary="保存客户评级",
|
||||||
|
)
|
||||||
|
def save_customer_rating(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: CustomerRatingSaveIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerRatingOut]:
|
||||||
|
"""保存客户评级(首次创建、再次保存整体覆盖)。
|
||||||
|
|
||||||
|
五项分值 0-20 由服务层在审计上下文内校验,总分与等级由服务端重算;
|
||||||
|
等级按保存时点的映射快照固化,等级表后续增删改不影响该客户结论。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
rating_service.save_customer_rating(
|
||||||
|
session, customer_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="评级已保存",
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -14,10 +14,11 @@ from typing import Annotated, Literal
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Query
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
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.core.response import ApiResponse, ok
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.schemas.report import (
|
from app.schemas.report import (
|
||||||
@@ -167,12 +168,24 @@ def export_report(
|
|||||||
|
|
||||||
|
|
||||||
def _build_export_query(code: str, query: ExportQuery):
|
def _build_export_query(code: str, query: ExportQuery):
|
||||||
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。"""
|
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。
|
||||||
|
|
||||||
|
手工构造模型抛出的是 `pydantic.ValidationError`,不是 FastAPI 的
|
||||||
|
`RequestValidationError`——不在这里转换就会落到全局 500。导出参数
|
||||||
|
非法与查询接口一样应当是 400。
|
||||||
|
"""
|
||||||
data = query.model_dump()
|
data = query.model_dump()
|
||||||
if code == "personal-detail":
|
model = {
|
||||||
return PersonalDetailQuery(**data)
|
"personal-detail": PersonalDetailQuery,
|
||||||
if code == "department-summary":
|
"department-summary": DepartmentSummaryQuery,
|
||||||
return DepartmentSummaryQuery(**data)
|
"business-stats": BusinessStatsQuery,
|
||||||
if code == "business-stats":
|
"payment-stats": PaymentStatsQuery,
|
||||||
return BusinessStatsQuery(**data)
|
}[code]
|
||||||
return PaymentStatsQuery(**data)
|
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
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"""系统设置接口(公司信息)。
|
"""系统设置接口(公司信息 + 站点信息)。
|
||||||
|
|
||||||
`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时
|
`GET /public`、`GET /logo/public`、`GET /favicon/public` **无鉴权**:登录页
|
||||||
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),
|
渲染公司名/logo、浏览器加载标签页图标时尚无令牌。响应经
|
||||||
仅暴露两个非敏感展示字段。
|
`SettingPublicOut` 白名单收敛(见 schemas/setting.py),仅暴露展示必需字段。
|
||||||
|
|
||||||
`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径
|
`/logo/public` 与 `/favicon/public` 返回文件流而非统一信封——与参数导出
|
||||||
(二进制响应套 JSON 信封无意义)。
|
同一例外口径(二进制响应套 JSON 信封无意义)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Response, UploadFile
|
from fastapi import APIRouter, Response, UploadFile
|
||||||
@@ -20,6 +20,7 @@ from app.services import system_setting_service as service
|
|||||||
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
|
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
|
||||||
|
|
||||||
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
|
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
|
||||||
|
_FAVICON_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg", "ico": "image/x-icon"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
|
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
|
||||||
@@ -28,14 +29,14 @@ def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
|
|||||||
return ok(service.to_out(service.get_setting(session)))
|
return ok(service.to_out(service.get_setting(session)))
|
||||||
|
|
||||||
|
|
||||||
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称")
|
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司信息")
|
||||||
def update_settings(
|
def update_settings(
|
||||||
payload: SettingUpdateIn,
|
payload: SettingUpdateIn,
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
current_user: AdminUser,
|
current_user: AdminUser,
|
||||||
meta: RequestMetaDep,
|
meta: RequestMetaDep,
|
||||||
) -> ApiResponse[SettingOut]:
|
) -> ApiResponse[SettingOut]:
|
||||||
"""更新公司名称(审计留痕)。"""
|
"""更新公司信息(部分更新;审计留痕)。"""
|
||||||
return ok(
|
return ok(
|
||||||
service.update_company(session, payload, operator=current_user, meta=meta),
|
service.update_company(session, payload, operator=current_user, meta=meta),
|
||||||
message="已保存",
|
message="已保存",
|
||||||
@@ -55,7 +56,7 @@ def upload_logo(
|
|||||||
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
|
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
|
||||||
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
|
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
|
||||||
"""
|
"""
|
||||||
if file.size is not None and file.size > service.LOGO_MAX_BYTES:
|
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
|
||||||
raise BadRequestError("logo 文件不能超过 2MB")
|
raise BadRequestError("logo 文件不能超过 2MB")
|
||||||
content = file.file.read()
|
content = file.file.read()
|
||||||
ext = (file.filename or "").rsplit(".", 1)[-1]
|
ext = (file.filename or "").rsplit(".", 1)[-1]
|
||||||
@@ -65,6 +66,27 @@ def upload_logo(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/favicon", response_model=ApiResponse[SettingOut], summary="上传站点图标")
|
||||||
|
def upload_favicon(
|
||||||
|
file: UploadFile,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[SettingOut]:
|
||||||
|
"""上传站点图标(png/jpg/ico ≤2MB;固定名覆盖,旧扩展名文件清理)。
|
||||||
|
|
||||||
|
同步 `def` 与 logo 上传同口径:磁盘写与审计提交均为阻塞操作。
|
||||||
|
"""
|
||||||
|
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
|
||||||
|
raise BadRequestError("站点图标文件不能超过 2MB")
|
||||||
|
content = file.file.read()
|
||||||
|
ext = (file.filename or "").rsplit(".", 1)[-1]
|
||||||
|
return ok(
|
||||||
|
service.save_favicon(session, content, ext, operator=current_user, meta=meta),
|
||||||
|
message="站点图标已更新",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
|
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
|
||||||
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
|
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
|
||||||
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
|
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
|
||||||
@@ -81,3 +103,14 @@ def get_logo_public(session: DbSession) -> FileResponse:
|
|||||||
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
|
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
|
||||||
headers={"Cache-Control": "no-cache"},
|
headers={"Cache-Control": "no-cache"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/favicon/public", summary="站点图标(匿名)")
|
||||||
|
def get_favicon_public(session: DbSession) -> FileResponse:
|
||||||
|
"""站点图标文件流(无鉴权)。no-cache:图标替换后由版本参数破缓存兜底。"""
|
||||||
|
path, ext = service.read_favicon(session)
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type=_FAVICON_MEDIA_TYPE.get(ext, "application/octet-stream"),
|
||||||
|
headers={"Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""月份参数校验(YYYY-MM)——叶子模块。
|
||||||
|
|
||||||
|
报表、月结与调整单共用同一套校验:只校验长度会放过 `2026-13` 这类
|
||||||
|
非法月份,一路传到 `calendar.monthrange` 才抛 ValueError,最终被全局
|
||||||
|
处理器当成服务端异常(500)。月份范围必须在入口处拦住,才能返回 400。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_month(value: str) -> str:
|
||||||
|
"""校验 YYYY-MM 且月份 01-12;非法抛 ValueError(Pydantic 友好)。
|
||||||
|
|
||||||
|
年份 0000 也拒绝:`date(0, ...)` 同样会抛 ValueError,且业务上不存在。
|
||||||
|
"""
|
||||||
|
if not _MONTH_RE.match(value) or int(value[:4]) == 0:
|
||||||
|
raise ValueError("月份格式须为 YYYY-MM(01-12)")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_month(value: str) -> tuple[int, int]:
|
||||||
|
"""校验并返回 (year, month),供服务层计算月末等使用。"""
|
||||||
|
validate_month(value)
|
||||||
|
return int(value[:4]), int(value[5:7])
|
||||||
@@ -48,7 +48,10 @@ PRODUCT_CATEGORY_LABELS: dict[str, str] = {
|
|||||||
"mixed": "混合业务",
|
"mixed": "混合业务",
|
||||||
}
|
}
|
||||||
|
|
||||||
REPORT_TEMPLATE_VERSION = "V1.0"
|
# 业务状态中文标签:导出列、筛选页眉与前端标签共用同一份映射
|
||||||
|
BUSINESS_STATUS_LABELS: dict[int, str] = {1: "进行中", 2: "已完成", 3: "已终止"}
|
||||||
|
|
||||||
|
REPORT_TEMPLATE_VERSION = "V1.2"
|
||||||
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
|
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
|
||||||
# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出
|
# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出
|
||||||
REPORT_EXPORT_MAX_ROWS = 20000
|
REPORT_EXPORT_MAX_ROWS = 20000
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.models.base import Base, TimestampMixin
|
|||||||
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
|
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
|
||||||
from app.models.business_role import BusinessRole
|
from app.models.business_role import BusinessRole
|
||||||
from app.models.customer import Customer, OwnershipChangeLog
|
from app.models.customer import Customer, OwnershipChangeLog
|
||||||
|
from app.models.customer_rating import CustomerRating
|
||||||
from app.models.login_log import LoginLog
|
from app.models.login_log import LoginLog
|
||||||
from app.models.operation_log import OperationLog
|
from app.models.operation_log import OperationLog
|
||||||
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
||||||
@@ -38,6 +39,7 @@ __all__ = [
|
|||||||
"BusinessRole",
|
"BusinessRole",
|
||||||
"BusinessStatusLog",
|
"BusinessStatusLog",
|
||||||
"Customer",
|
"Customer",
|
||||||
|
"CustomerRating",
|
||||||
"CustomerRatingGrade",
|
"CustomerRatingGrade",
|
||||||
"LoginLog",
|
"LoginLog",
|
||||||
"MonthlySettlement",
|
"MonthlySettlement",
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""客户评级模型(需求 2.2.5)。
|
||||||
|
|
||||||
|
评级从「纯计算器不落库」升级为**每客户一行落库**(2026-10-19 确认):
|
||||||
|
客户管理页要罗列评级列并按其排序,评分必须可回溯。设计要点:
|
||||||
|
|
||||||
|
- ``customer_id`` 唯一键 + RESTRICT 外键:一客户一份当前评级(快照语义,
|
||||||
|
非流水),删除客户由服务层先删评级行再删主行(RESTRICT 兜底并发窗口)
|
||||||
|
- ``grade_name`` 为**快照固化、无外键**:等级映射表仍允许自由增删清空
|
||||||
|
(rating_service 的既有前提),删等级不能伤及已评级客户的历史结论 ——
|
||||||
|
与归属留痕「ID/人名固化、不回溯」同一论证口径;等级未命中时为 NULL
|
||||||
|
- ``scorer_id`` 外键 SET NULL + ``scorer_name`` 冗余固化:账号删除后
|
||||||
|
记录仍可读(同 ownership_change_logs 的操作人口径)
|
||||||
|
- 分值列 DECIMAL(5,1):UI 整数 0-20,DECIMAL 留十进制余地;总分由
|
||||||
|
**服务端重算**,不采信前端回传
|
||||||
|
"""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Numeric, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRating(Base, TimestampMixin):
|
||||||
|
"""客户评级表:每客户一行(uk_customer_ratings_customer_id)。
|
||||||
|
|
||||||
|
五项各 0-20 分、总分 = 五项之和(0-100),判定规则同等级映射表
|
||||||
|
(按 sort_order 升序首个 min_score ≤ 总分,见 rating_service.resolve_grade)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "customer_ratings"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
|
||||||
|
UniqueConstraint("customer_id", name="uk_customer_ratings_customer_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
customer_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("customers.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户ID(唯一:一客户一份当前评级)",
|
||||||
|
)
|
||||||
|
score_ship_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, server_default="0", comment="发货额评分(0-20)"
|
||||||
|
)
|
||||||
|
score_receipt_rate: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, server_default="0", comment="回款率评分(0-20)"
|
||||||
|
)
|
||||||
|
score_profit_rate: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, server_default="0", comment="毛利率评分(0-20)"
|
||||||
|
)
|
||||||
|
score_contract_cooperation: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, server_default="0", comment="合同履约配合能力评分(0-20)"
|
||||||
|
)
|
||||||
|
score_site_construction: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, server_default="0", comment="现场规范施工能力评分(0-20)"
|
||||||
|
)
|
||||||
|
total_score: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, comment="总分(服务端重算,五项之和 0-100)"
|
||||||
|
)
|
||||||
|
grade_name: Mapped[str | None] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
scorer_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="评级操作人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
scorer_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="评级操作人用户名(冗余固化)"
|
||||||
|
)
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"""客户评级等级配置模型(需求 2.2.5)。
|
"""客户评级等级配置模型(需求 2.2.5)。
|
||||||
|
|
||||||
评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录
|
评级定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分自 2026-10-19
|
||||||
不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取
|
起每客户落库于 customer_ratings(快照固化等级名),本表承载可配置的等级
|
||||||
第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在
|
**映射关系**。判定规则:按 sort_order 升序取第一个 min_score ≤ 总分的
|
||||||
「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。
|
等级 —— 单边判定避免区间边界(如 75 分)在「≥75 且 ≤89」与「>74 且 <75」
|
||||||
|
两套写法间的重叠/遗漏歧义;max_score 仅展示。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|||||||
@@ -1,31 +1,67 @@
|
|||||||
"""系统设置模型:公司信息单例行。
|
"""系统设置模型:公司信息 + 站点信息单例行。
|
||||||
|
|
||||||
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
|
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
|
||||||
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
|
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
|
||||||
value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在
|
value_text 为 String(64) 装不下;故独立单行表。logo/favicon 只存相对路径——
|
||||||
`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见
|
文件在 `backend/uploads/{logo,favicon}/`,独立于数据库备份体积且可单独替换
|
||||||
system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。
|
(见 system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底)。
|
||||||
|
|
||||||
|
2026-10-20 精简(用户确认):企业档案字段(信用代码/地址/电话/邮箱)与
|
||||||
|
系统副标题删除,新增**站点标题**(浏览器标签页/侧边栏副标题)与**站点图标**
|
||||||
|
(浏览器标签页 favicon,独立于登录页 logo)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import String
|
from sqlalchemy import Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
|
||||||
class SystemSetting(Base, TimestampMixin):
|
class SystemSetting(Base, TimestampMixin):
|
||||||
"""系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。"""
|
"""系统设置表:公司信息 + 站点信息,全系统单行 id=1。
|
||||||
|
|
||||||
|
brand_slogan/footer_text/icp_no/site_title 均可空:null 表示前端回退
|
||||||
|
内置默认值,存量行为零变化。
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "system_settings"
|
__tablename__ = "system_settings"
|
||||||
__table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)")
|
__table_args__ = mysql_table_args("系统设置表(公司信息与站点信息,全系统单行)")
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
)
|
)
|
||||||
company_name: Mapped[str] = mapped_column(
|
company_name: Mapped[str] = mapped_column(
|
||||||
String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)"
|
String(64), nullable=False, comment="公司名称(登录页/侧边栏/浏览器标题展示)"
|
||||||
)
|
)
|
||||||
logo_path: Mapped[str | None] = mapped_column(
|
logo_path: Mapped[str | None] = mapped_column(
|
||||||
String(255),
|
String(255),
|
||||||
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
|
comment="公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
|
||||||
|
)
|
||||||
|
logo_version: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
|
||||||
|
)
|
||||||
|
site_title: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)"
|
||||||
|
)
|
||||||
|
brand_slogan: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), comment="品牌标语(登录页与品牌预览;null=内置默认)"
|
||||||
|
)
|
||||||
|
footer_text: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)"
|
||||||
|
)
|
||||||
|
icp_no: Mapped[str | None] = mapped_column(
|
||||||
|
String(32), comment="ICP 备案号(登录页页脚,空=不显示)"
|
||||||
|
)
|
||||||
|
favicon_path: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
comment="站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)",
|
||||||
|
)
|
||||||
|
favicon_version: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""客户评级数据访问(每客户一行)。
|
||||||
|
|
||||||
|
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import CustomerRating
|
||||||
|
|
||||||
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_customer_id(session: Session, customer_id: int) -> CustomerRating | None:
|
||||||
|
"""查某客户的当前评级(uk_customer_ratings_customer_id 保证至多一行)。"""
|
||||||
|
stmt = select(CustomerRating).where(CustomerRating.customer_id == customer_id)
|
||||||
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, rating: CustomerRating) -> CustomerRating:
|
||||||
|
"""新增评级行。只 flush 以取得自增 id。"""
|
||||||
|
session.add(rating)
|
||||||
|
session.flush()
|
||||||
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
def delete(session: Session, rating: CustomerRating) -> None:
|
||||||
|
"""删除评级行(客户物理删除前的联动清理,调用方先校验业务引用)。"""
|
||||||
|
session.delete(rating)
|
||||||
|
session.flush()
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
from sqlalchemy import ColumnElement, Row, Select, func, or_, select
|
from sqlalchemy import ColumnElement, Row, Select, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Customer, OwnershipChangeLog, Personnel
|
from app.models import Customer, CustomerRating, OwnershipChangeLog, Personnel
|
||||||
from app.models.customer import OWNER_TYPE_CUSTOMER
|
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||||
from app.repositories import ownership_log_repository
|
from app.repositories import ownership_log_repository
|
||||||
from app.repositories.query_utils import fuzzy
|
from app.repositories.query_utils import fuzzy
|
||||||
@@ -31,7 +31,7 @@ def count_business_refs(session: Session, customer_id: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def get_with_owner_name(session: Session, customer_id: int) -> Row | None:
|
def get_with_owner_name(session: Session, customer_id: int) -> Row | None:
|
||||||
"""查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。"""
|
"""查单个客户并带出归属人员姓名与评级快照(详情/编辑回显/归属变更共用)。"""
|
||||||
stmt = _base_select().where(Customer.id == customer_id)
|
stmt = _base_select().where(Customer.id == customer_id)
|
||||||
return session.execute(stmt).first()
|
return session.execute(stmt).first()
|
||||||
|
|
||||||
@@ -53,12 +53,14 @@ def list_customers(
|
|||||||
keyword: str | None = None,
|
keyword: str | None = None,
|
||||||
customer_type: str | None = None,
|
customer_type: str | None = None,
|
||||||
owner_personnel_id: int | None = None,
|
owner_personnel_id: int | None = None,
|
||||||
|
order_field: str | None = None,
|
||||||
|
order_type: str | None = None,
|
||||||
) -> list[Row]:
|
) -> list[Row]:
|
||||||
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
|
"""分页查询客户,行内容为 (客户, 归属人员姓名, 评级总分, 评级等级)。
|
||||||
|
|
||||||
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
归属人员/评级行均用 outerjoin:关联行不可考(人员被物理删除、客户
|
||||||
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
未评级)不应让客户行整个查不出来(同 personnel_repository 韧性口径)。
|
||||||
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
|
默认排序按 id 倒序:新增的靠前符合列表页习惯;评级排序见 ``_apply_order``。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(
|
||||||
_base_select(),
|
_base_select(),
|
||||||
@@ -66,7 +68,8 @@ def list_customers(
|
|||||||
customer_type=customer_type,
|
customer_type=customer_type,
|
||||||
owner_personnel_id=owner_personnel_id,
|
owner_personnel_id=owner_personnel_id,
|
||||||
)
|
)
|
||||||
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
|
stmt = _apply_order(stmt, order_field=order_field, order_type=order_type)
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
return list(session.execute(stmt).all())
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
@@ -120,12 +123,38 @@ def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _base_select() -> Select:
|
def _base_select() -> Select:
|
||||||
"""客户主表外联归属人员姓名。"""
|
"""客户主表外联归属人员姓名与评级快照(等级名/总分,列表评级列免 N+1)。"""
|
||||||
return select(Customer, Personnel.personnel_name).outerjoin(
|
return (
|
||||||
Personnel, Customer.owner_personnel_id == Personnel.id
|
select(
|
||||||
|
Customer,
|
||||||
|
Personnel.personnel_name,
|
||||||
|
CustomerRating.total_score,
|
||||||
|
CustomerRating.grade_name,
|
||||||
|
)
|
||||||
|
.outerjoin(Personnel, Customer.owner_personnel_id == Personnel.id)
|
||||||
|
.outerjoin(CustomerRating, CustomerRating.customer_id == Customer.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_order(stmt: Select, *, order_field: str | None, order_type: str | None) -> Select:
|
||||||
|
"""应用列表排序。默认 id 倒序;order_field=rating 按评级总分排序。
|
||||||
|
|
||||||
|
等级由总分判定、与总分同向,故按总分即等价按等级排序。MySQL 没有
|
||||||
|
``NULLS LAST`` 语法,用布尔键 ``total_score IS NULL`` 升序模拟 ——
|
||||||
|
未评级行恒在末尾,正序/倒序一致;同分以 id 倒序兜底保证次序稳定。
|
||||||
|
非法字段/方向不落到 SQL(schema Literal 已在入口拦 400),此处静默
|
||||||
|
回退默认排序作为纵深防御。
|
||||||
|
"""
|
||||||
|
if order_field == "rating" and order_type in ("asc", "desc"):
|
||||||
|
score_order = (
|
||||||
|
CustomerRating.total_score.asc()
|
||||||
|
if order_type == "asc"
|
||||||
|
else CustomerRating.total_score.desc()
|
||||||
|
)
|
||||||
|
return stmt.order_by(CustomerRating.total_score.is_(None), score_order, Customer.id.desc())
|
||||||
|
return stmt.order_by(Customer.id.desc())
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(
|
||||||
stmt: Select,
|
stmt: Select,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
|
|||||||
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
|
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
|
||||||
"""按主键查询。
|
"""按主键查询。
|
||||||
|
|
||||||
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
|
等级表对客户评级无**外键**引用(评级行快照固化等级名),无需
|
||||||
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
|
``for_update`` 锁行 —— product_repository 锁行防的是并发引用悬空,
|
||||||
|
此处不存在该风险。
|
||||||
"""
|
"""
|
||||||
return session.get(CustomerRatingGrade, grade_id)
|
return session.get(CustomerRatingGrade, grade_id)
|
||||||
|
|
||||||
@@ -60,6 +61,6 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
|
|||||||
|
|
||||||
|
|
||||||
def delete(session: Session, grade: CustomerRatingGrade) -> None:
|
def delete(session: Session, grade: CustomerRatingGrade) -> None:
|
||||||
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
|
"""物理删除等级。等级表对客户评级无外键引用,调用方无需引用保护校验。"""
|
||||||
session.delete(grade)
|
session.delete(grade)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@@ -13,6 +14,10 @@ class CustomerOut(BaseModel):
|
|||||||
owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的
|
owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的
|
||||||
脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕
|
脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕
|
||||||
「ID 固化、姓名随联表」同一口径。
|
「ID 固化、姓名随联表」同一口径。
|
||||||
|
|
||||||
|
rating_total / rating_grade_name 由 LEFT JOIN customer_ratings 带出
|
||||||
|
(列表评级列免 N+1):未评级时均为 null;总分按分值红线以字符串出参,
|
||||||
|
等级为保存时点的快照固化值(见 models/customer_rating.py)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -28,6 +33,8 @@ class CustomerOut(BaseModel):
|
|||||||
owner_personnel_id: int | None = None
|
owner_personnel_id: int | None = None
|
||||||
owner_personnel_name: str | None = None
|
owner_personnel_name: str | None = None
|
||||||
remark: str | None = None
|
remark: str | None = None
|
||||||
|
rating_total: str | None = None
|
||||||
|
rating_grade_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CustomerCreateIn(BaseModel):
|
class CustomerCreateIn(BaseModel):
|
||||||
@@ -97,6 +104,14 @@ class CustomerQuery(PageQuery):
|
|||||||
)
|
)
|
||||||
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
||||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
||||||
|
# 排序白名单(全项目首个列表排序参数):非法值由 Literal 拦下、经全局
|
||||||
|
# 异常处理器转 400 —— 不把排序字段透传给 SQL,避免注入面
|
||||||
|
order_field: Literal["rating"] | None = Field(
|
||||||
|
default=None, description="排序字段白名单:rating=客户评级总分(等级由总分判定、同向)"
|
||||||
|
)
|
||||||
|
order_type: Literal["asc", "desc"] | None = Field(
|
||||||
|
default=None, description="排序方向:asc 正序 / desc 倒序(未评级恒在末尾)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CustomerOwnershipIn(BaseModel):
|
class CustomerOwnershipIn(BaseModel):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -77,3 +77,55 @@ class RatingGradeUpdateIn(RatingGradeCreateIn):
|
|||||||
|
|
||||||
前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。
|
前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRatingOut(BaseModel):
|
||||||
|
"""客户评级响应(每客户一行;总分与分值按红线以字符串出参)。
|
||||||
|
|
||||||
|
grade_name 是保存时点按等级映射**快照固化**的值(无外键):等级表后续
|
||||||
|
增删改不影响已保存的评级结论;为 NULL 表示保存时未命中任何等级
|
||||||
|
(总分低于全部下限或等级表被清空)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
customer_id: int
|
||||||
|
score_ship_amount: str
|
||||||
|
score_receipt_rate: str
|
||||||
|
score_profit_rate: str
|
||||||
|
score_contract_cooperation: str
|
||||||
|
score_site_construction: str
|
||||||
|
total_score: str
|
||||||
|
grade_name: str | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
scorer_name: str
|
||||||
|
create_time: datetime
|
||||||
|
update_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRatingSaveIn(BaseModel):
|
||||||
|
"""保存客户评级(每客户一行,整体替换语义)。
|
||||||
|
|
||||||
|
五项分值为 0-20 的数字字符串(schema 只拦格式;0≤分≤20 与总分等级
|
||||||
|
判定在 service 层审计上下文内完成 —— 失败的 400 同样留痕)。总分由
|
||||||
|
服务端按五项重算,**不采信前端回传的总分**。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
score_ship_amount: str = Field(description="发货额评分(0-20 数字字符串)")
|
||||||
|
score_receipt_rate: str = Field(description="回款率评分(0-20 数字字符串)")
|
||||||
|
score_profit_rate: str = Field(description="毛利率评分(0-20 数字字符串)")
|
||||||
|
score_contract_cooperation: str = Field(description="合同履约配合能力评分(0-20 数字字符串)")
|
||||||
|
score_site_construction: str = Field(description="现场规范施工能力评分(0-20 数字字符串)")
|
||||||
|
remark: str | None = Field(default=None, max_length=255, description="备注")
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"score_ship_amount",
|
||||||
|
"score_receipt_rate",
|
||||||
|
"score_profit_rate",
|
||||||
|
"score_contract_cooperation",
|
||||||
|
"score_site_construction",
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def _check_score(cls, value: str) -> str:
|
||||||
|
"""单项分值须为有限数字(值域 0-20 由 service 层判定)。"""
|
||||||
|
return _parse_score(value, label="评分")
|
||||||
@@ -7,10 +7,24 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.month import validate_month
|
||||||
from app.core.response import PageQuery
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
|
||||||
|
def _required_month(value: str) -> str:
|
||||||
|
"""必填月份:非法直接抛 ValueError,由 FastAPI 压成 400。"""
|
||||||
|
return validate_month(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_month(value: str | None) -> str | None:
|
||||||
|
"""可选月份:空串规范为 None(axios 未剔除时也不会误报格式错误)。"""
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
return validate_month(value)
|
||||||
|
|
||||||
|
|
||||||
# ==== 查询入参 ====
|
# ==== 查询入参 ====
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +37,11 @@ class PersonalDetailQuery(PageQuery):
|
|||||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||||
role_code: 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):
|
class DepartmentSummaryQuery(BaseModel):
|
||||||
"""部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。"""
|
"""部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。"""
|
||||||
@@ -30,6 +49,11 @@ class DepartmentSummaryQuery(BaseModel):
|
|||||||
settlement_month: str = Field(min_length=7, max_length=7, description="归属月 YYYY-MM")
|
settlement_month: str = Field(min_length=7, max_length=7, description="归属月 YYYY-MM")
|
||||||
manager_id: int | None = Field(default=None, ge=1, description="仅看某经理团队")
|
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):
|
class BusinessStatsQuery(PageQuery):
|
||||||
"""业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。"""
|
"""业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。"""
|
||||||
@@ -51,6 +75,11 @@ class PaymentStatsQuery(BaseModel):
|
|||||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||||
product_category: 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):
|
class PaymentDetailQuery(PageQuery):
|
||||||
"""回款区间下钻(桶编码必填,入参由 payment-stats 页面展开行携带)。"""
|
"""回款区间下钻(桶编码必填,入参由 payment-stats 页面展开行携带)。"""
|
||||||
@@ -62,6 +91,11 @@ class PaymentDetailQuery(PageQuery):
|
|||||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||||
|
|
||||||
|
@field_validator("settlement_month")
|
||||||
|
@classmethod
|
||||||
|
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||||
|
return _optional_month(value)
|
||||||
|
|
||||||
|
|
||||||
# ==== 响应出参 ====
|
# ==== 响应出参 ====
|
||||||
|
|
||||||
@@ -143,6 +177,13 @@ class DepartmentMemberRow(BaseModel):
|
|||||||
personnel_name: str
|
personnel_name: str
|
||||||
role_code: str
|
role_code: str
|
||||||
is_probation: int
|
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
|
performance_sum: Decimal | None = None
|
||||||
payable_sum: Decimal | None = None
|
payable_sum: Decimal | None = None
|
||||||
adjust_extra: Decimal = Field(default=Decimal("0"), description="调整单生效补发合计")
|
adjust_extra: Decimal = Field(default=Decimal("0"), description="调整单生效补发合计")
|
||||||
@@ -160,13 +201,26 @@ class DepartmentTeamRow(BaseModel):
|
|||||||
manager_status: int = Field(description="经理在职状态(1 在职 0 离职)")
|
manager_status: int = Field(description="经理在职状态(1 在职 0 离职)")
|
||||||
member_count: int
|
member_count: int
|
||||||
team_base: Decimal = Field(description="团队基数(12.5% 的底,等于成员绩效合计)")
|
team_base: Decimal = Field(description="团队基数(12.5% 的底,等于成员绩效合计)")
|
||||||
team_share_amount: Decimal = Field(description="12.5% 团队分成")
|
team_share_amount: Decimal = Field(description="12.5% 团队分成(分列展示)")
|
||||||
company_share_amount: Decimal = Field(description="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)")
|
company_base_ratio: Decimal = Field(description="团队基数占全公司基数比例(除零为 0)")
|
||||||
adjust_extra: Decimal = Decimal("0")
|
adjust_extra: Decimal = Decimal("0")
|
||||||
adjust_deduct: Decimal = Decimal("0")
|
adjust_deduct: Decimal = Decimal("0")
|
||||||
adjust_net: Decimal = Decimal("0")
|
adjust_net: Decimal = Decimal("0")
|
||||||
net_total: Decimal = Field(description="团队净额 = 分成 + 调整净额")
|
net_total: Decimal = Field(description="团队净额 = 团队应发小计")
|
||||||
frozen_amount: Decimal | None = Field(default=None, description="月结聚合行冻结额(对照)")
|
frozen_amount: Decimal | None = Field(default=None, description="月结聚合行冻结额(对照)")
|
||||||
aggregate_ledger_id: int | None = None
|
aggregate_ledger_id: int | None = None
|
||||||
members: list[DepartmentMemberRow] = Field(default_factory=list)
|
members: list[DepartmentMemberRow] = Field(default_factory=list)
|
||||||
@@ -178,7 +232,50 @@ class DepartmentCompanyBlock(BaseModel):
|
|||||||
team_base_total: Decimal = Field(description="各团队基数之和(= 全部单笔绩效合计)")
|
team_base_total: Decimal = Field(description="各团队基数之和(= 全部单笔绩效合计)")
|
||||||
company_base: Decimal = Field(description="全公司基数(5% 的底)")
|
company_base: Decimal = Field(description="全公司基数(5% 的底)")
|
||||||
company_share_pct: str = Field(description="分成比例参数原文")
|
company_share_pct: str = Field(description="分成比例参数原文")
|
||||||
company_share_amount: Decimal = Field(description="5% 全公司分成")
|
company_share_amount: Decimal = Field(description="单经理 5% 名义值")
|
||||||
|
company_share_count: int = Field(
|
||||||
|
default=0, description="计入应发的经理数(展示团队中有月结聚合行的数量)"
|
||||||
|
)
|
||||||
|
company_share_total: Decimal = Field(
|
||||||
|
default=Decimal("0"), description="5% 合计 = 单份 × 计入次数"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentReconciliation(BaseModel):
|
||||||
|
"""部门汇总 · 对账块(导出/打印签字稿的合计口径,前端不自行求和)。
|
||||||
|
|
||||||
|
应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
|
||||||
|
聚合行金额与月结 ``total_payable`` 同口径(12.5%+5% 一次舍入);
|
||||||
|
``team_share_total`` 与 ``company_share_total`` 仅作分列展示,两者
|
||||||
|
之和与聚合额的差记入 ``aggregate_rounding_diff``。
|
||||||
|
"""
|
||||||
|
|
||||||
|
team_member_payable_total: Decimal = Field(description="展示团队成员的应付绩效合计")
|
||||||
|
unassigned_payable_total: Decimal = Field(description="展示未分配人员的应付绩效合计")
|
||||||
|
team_share_total: Decimal = Field(description="展示团队的 12.5% 分成合计(分列)")
|
||||||
|
company_share_total: Decimal = Field(
|
||||||
|
description="展示团队的全公司分成合计(每位有聚合行的经理各计一次,分列)"
|
||||||
|
)
|
||||||
|
aggregate_payable_total: Decimal = Field(
|
||||||
|
description="展示团队的经理聚合行应发合计(12.5%+5% 一次舍入)"
|
||||||
|
)
|
||||||
|
aggregate_rounding_diff: Decimal = Field(
|
||||||
|
default=Decimal("0"),
|
||||||
|
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
|
||||||
|
)
|
||||||
|
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
|
||||||
|
payable_total: Decimal = Field(description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额")
|
||||||
|
team_base_total: Decimal = Field(description="展示团队基数合计")
|
||||||
|
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
|
||||||
|
other_team_base: Decimal = Field(
|
||||||
|
default=Decimal("0"), description="未展示的基数(其他团队 + 全公司未分配)"
|
||||||
|
)
|
||||||
|
other_team_member_payable_total: Decimal = Field(default=Decimal("0"))
|
||||||
|
other_team_share_total: Decimal = Field(default=Decimal("0"))
|
||||||
|
other_team_company_share_total: Decimal = Field(default=Decimal("0"))
|
||||||
|
other_team_aggregate_total: Decimal = Field(default=Decimal("0"))
|
||||||
|
other_adjust_net_total: Decimal = Field(default=Decimal("0"))
|
||||||
|
company_base: Decimal = Field(description="全公司基数(5% 的底)")
|
||||||
|
|
||||||
|
|
||||||
class DepartmentSummaryOut(BaseModel):
|
class DepartmentSummaryOut(BaseModel):
|
||||||
@@ -191,6 +288,9 @@ class DepartmentSummaryOut(BaseModel):
|
|||||||
default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)"
|
default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)"
|
||||||
)
|
)
|
||||||
company: DepartmentCompanyBlock | None = None
|
company: DepartmentCompanyBlock | None = None
|
||||||
|
reconciliation: DepartmentReconciliation | None = Field(
|
||||||
|
default=None, description="签字稿对账块(应发公式各项)"
|
||||||
|
)
|
||||||
totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)")
|
totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,59 @@
|
|||||||
"""系统设置(公司信息)请求/响应模型。
|
"""系统设置(公司信息 + 站点信息)请求/响应模型。
|
||||||
|
|
||||||
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
|
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
|
||||||
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
|
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
|
||||||
顺着复用链泄漏到匿名端点。
|
顺着复用链泄漏到匿名端点。
|
||||||
|
|
||||||
|
2026-10-20 精简(用户确认):删除系统副标题与企业档案字段(信用代码/
|
||||||
|
地址/电话/邮箱);新增站点标题(浏览器标签页/侧边栏副标题)与站点图标
|
||||||
|
(浏览器标签页 favicon)。public 端点只需登录页/标签页展示字段,两者
|
||||||
|
恰好同集合,但仍保留独立 schema 作为白名单防线。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class SettingOut(BaseModel):
|
class SettingOut(BaseModel):
|
||||||
"""公司信息(管理端)。"""
|
"""公司信息与站点信息(管理端)。"""
|
||||||
|
|
||||||
company_name: str
|
company_name: str
|
||||||
logo_url: str | None = None
|
logo_url: str | None = None
|
||||||
|
site_title: str | None = None
|
||||||
|
brand_slogan: str | None = None
|
||||||
|
footer_text: str | None = None
|
||||||
|
icp_no: str | None = None
|
||||||
|
favicon_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SettingPublicOut(BaseModel):
|
class SettingPublicOut(BaseModel):
|
||||||
"""公司信息(匿名端点白名单):登录页展示所需的两个字段。"""
|
"""公司信息与站点信息(匿名端点白名单):登录页/浏览器标签页展示所需字段。"""
|
||||||
|
|
||||||
company_name: str
|
company_name: str
|
||||||
logo_url: str | None = None
|
logo_url: str | None = None
|
||||||
|
site_title: str | None = None
|
||||||
|
brand_slogan: str | None = None
|
||||||
|
footer_text: str | None = None
|
||||||
|
icp_no: str | None = None
|
||||||
|
favicon_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SettingUpdateIn(BaseModel):
|
class SettingUpdateIn(BaseModel):
|
||||||
"""公司信息更新。
|
"""公司信息与站点信息更新(**部分更新**语义)。
|
||||||
|
|
||||||
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
|
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
|
||||||
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
|
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
|
||||||
|
|
||||||
|
仅提交的字段生效(``exclude_unset``),未提交或显式 null 的字段保持
|
||||||
|
不变;可选文本传空串等价清空(落库为 null、前端回退内置默认)。
|
||||||
|
company_name 若提交必须非空(DB 非空约束)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(str_strip_whitespace=True)
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
|
company_name: str | None = Field(
|
||||||
|
default=None, min_length=1, max_length=64, description="公司名称(提交则须非空)"
|
||||||
|
)
|
||||||
|
site_title: str | None = Field(default=None, max_length=64, description="站点标题")
|
||||||
|
brand_slogan: str | None = Field(default=None, max_length=64, description="品牌标语")
|
||||||
|
footer_text: str | None = Field(default=None, max_length=64, description="页脚版权文本")
|
||||||
|
icp_no: str | None = Field(default=None, max_length=32, description="ICP 备案号")
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
"""月度结算的请求/响应模型(M3 ④)。"""
|
"""月度结算的请求/响应模型(M3 ④)。"""
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.month import validate_month
|
||||||
from app.core.response import PageQuery
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
_MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
|
||||||
|
|
||||||
|
|
||||||
def _check_month(value: str) -> str:
|
def _check_month(value: str) -> str:
|
||||||
"""月份格式 YYYY-MM(settlement_month 全表唯一的维度)。"""
|
"""月份格式 YYYY-MM 且月份 01-12(settlement_month 全表唯一的维度)。"""
|
||||||
if not _MONTH_RE.match(value):
|
return validate_month(value)
|
||||||
raise ValueError("月份格式须为 YYYY-MM")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementGenerateIn(BaseModel):
|
class SettlementGenerateIn(BaseModel):
|
||||||
@@ -93,3 +89,8 @@ class AdjustmentCreateIn(BaseModel):
|
|||||||
description="金额(元,两位小数,恒正)",
|
description="金额(元,两位小数,恒正)",
|
||||||
)
|
)
|
||||||
reason: str = Field(min_length=1, max_length=255, 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
|
import logging
|
||||||
|
from collections.abc import Iterable
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
@@ -29,6 +30,7 @@ from app.core.constants import (
|
|||||||
ACTION_REJECT,
|
ACTION_REJECT,
|
||||||
MODULE_APPROVAL,
|
MODULE_APPROVAL,
|
||||||
NO_PREFIX_APPROVAL,
|
NO_PREFIX_APPROVAL,
|
||||||
|
ROLE_ADMIN,
|
||||||
ROLE_FINANCE,
|
ROLE_FINANCE,
|
||||||
ROLE_LEADER,
|
ROLE_LEADER,
|
||||||
)
|
)
|
||||||
@@ -84,6 +86,18 @@ _NODE_ROLE_BY_STATUS = {
|
|||||||
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
|
_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:
|
def is_enabled(session: Session, order_type: int) -> bool:
|
||||||
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
|
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
|
||||||
code = ENABLE_CODE_BY_TYPE.get(order_type)
|
code = ENABLE_CODE_BY_TYPE.get(order_type)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from app.core.exceptions import BadRequestError, NotFoundError
|
|||||||
from app.core.response import PageQuery, PageResult
|
from app.core.response import PageQuery, PageResult
|
||||||
from app.models import Customer, OwnershipChangeLog, Personnel, User
|
from app.models import Customer, OwnershipChangeLog, Personnel, User
|
||||||
from app.models.customer import OWNER_TYPE_CUSTOMER
|
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||||
from app.repositories import customer_repository, personnel_repository
|
from app.repositories import customer_rating_repository, customer_repository, personnel_repository
|
||||||
from app.schemas.customer import (
|
from app.schemas.customer import (
|
||||||
CustomerCreateIn,
|
CustomerCreateIn,
|
||||||
CustomerOut,
|
CustomerOut,
|
||||||
@@ -46,8 +46,17 @@ from app.services.audit_service import AuditRecord, RequestMeta
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
def _to_out(
|
||||||
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
customer: Customer,
|
||||||
|
owner_name: str | None,
|
||||||
|
rating_total: object | None = None,
|
||||||
|
rating_grade: str | None = None,
|
||||||
|
) -> CustomerOut:
|
||||||
|
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。
|
||||||
|
|
||||||
|
rating_total 由 LEFT JOIN 带出(Decimal 或 None),按分值红线转字符串
|
||||||
|
出参;新建客户/未评级客户不传,保持 null。
|
||||||
|
"""
|
||||||
return CustomerOut(
|
return CustomerOut(
|
||||||
id=customer.id,
|
id=customer.id,
|
||||||
company_name=customer.company_name,
|
company_name=customer.company_name,
|
||||||
@@ -60,6 +69,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
|||||||
owner_personnel_id=customer.owner_personnel_id,
|
owner_personnel_id=customer.owner_personnel_id,
|
||||||
owner_personnel_name=owner_name,
|
owner_personnel_name=owner_name,
|
||||||
remark=customer.remark,
|
remark=customer.remark,
|
||||||
|
rating_total=str(rating_total) if rating_total is not None else None,
|
||||||
|
rating_grade_name=rating_grade,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +117,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
||||||
"""分页查询客户(附归属人员姓名)。"""
|
"""分页查询客户(附归属人员姓名与评级快照;支持评级列排序)。"""
|
||||||
rows = customer_repository.list_customers(
|
rows = customer_repository.list_customers(
|
||||||
session,
|
session,
|
||||||
offset=query.offset,
|
offset=query.offset,
|
||||||
@@ -114,6 +125,8 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome
|
|||||||
keyword=query.keyword,
|
keyword=query.keyword,
|
||||||
customer_type=query.customer_type,
|
customer_type=query.customer_type,
|
||||||
owner_personnel_id=query.owner_personnel_id,
|
owner_personnel_id=query.owner_personnel_id,
|
||||||
|
order_field=query.order_field,
|
||||||
|
order_type=query.order_type,
|
||||||
)
|
)
|
||||||
total = customer_repository.count_customers(
|
total = customer_repository.count_customers(
|
||||||
session,
|
session,
|
||||||
@@ -121,8 +134,12 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome
|
|||||||
customer_type=query.customer_type,
|
customer_type=query.customer_type,
|
||||||
owner_personnel_id=query.owner_personnel_id,
|
owner_personnel_id=query.owner_personnel_id,
|
||||||
)
|
)
|
||||||
|
records = [
|
||||||
|
_to_out(customer, owner_name, rating_total, rating_grade)
|
||||||
|
for customer, owner_name, rating_total, rating_grade in rows
|
||||||
|
]
|
||||||
return PageResult[CustomerOut](
|
return PageResult[CustomerOut](
|
||||||
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
|
records=records,
|
||||||
total=total,
|
total=total,
|
||||||
page_num=query.page_num,
|
page_num=query.page_num,
|
||||||
page_size=query.page_size,
|
page_size=query.page_size,
|
||||||
@@ -137,8 +154,8 @@ def get_customer(session: Session, customer_id: int) -> CustomerOut:
|
|||||||
row = customer_repository.get_with_owner_name(session, customer_id)
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise NotFoundError("客户不存在")
|
raise NotFoundError("客户不存在")
|
||||||
customer, owner_name = row
|
customer, owner_name, rating_total, rating_grade = row
|
||||||
return _to_out(customer, owner_name)
|
return _to_out(customer, owner_name, rating_total, rating_grade)
|
||||||
|
|
||||||
|
|
||||||
def create_customer(
|
def create_customer(
|
||||||
@@ -241,7 +258,7 @@ def update_customer(
|
|||||||
_ensure_customer_type(payload.customer_type)
|
_ensure_customer_type(payload.customer_type)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise NotFoundError("客户不存在")
|
raise NotFoundError("客户不存在")
|
||||||
customer, owner_name = row
|
customer, owner_name, rating_total, rating_grade = row
|
||||||
|
|
||||||
duplicate = customer_repository.get_by_company_owner(
|
duplicate = customer_repository.get_by_company_owner(
|
||||||
session, payload.company_name, payload.owner_name
|
session, payload.company_name, payload.owner_name
|
||||||
@@ -260,7 +277,7 @@ def update_customer(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# 响应在 commit 之前构造(同 user_service 顶部说明)
|
# 响应在 commit 之前构造(同 user_service 顶部说明)
|
||||||
out = _to_out(customer, owner_name)
|
out = _to_out(customer, owner_name, rating_total, rating_grade)
|
||||||
session.commit()
|
session.commit()
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
# 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写
|
# 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写
|
||||||
@@ -308,6 +325,11 @@ def delete_customer(
|
|||||||
|
|
||||||
company_name = customer.company_name # 固化:对象随删除失效
|
company_name = customer.company_name # 固化:对象随删除失效
|
||||||
try:
|
try:
|
||||||
|
# 评级行 FK RESTRICT:先删评级行再删客户主行(同事务一次 commit;
|
||||||
|
# 评级是附属快照,不构成「有关联业务」的删除阻碍)
|
||||||
|
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
|
||||||
|
if rating is not None:
|
||||||
|
customer_rating_repository.delete(session, rating)
|
||||||
customer_repository.delete(session, customer)
|
customer_repository.delete(session, customer)
|
||||||
session.commit()
|
session.commit()
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
@@ -362,7 +384,7 @@ def change_ownership(
|
|||||||
):
|
):
|
||||||
if row is None:
|
if row is None:
|
||||||
raise NotFoundError("客户不存在")
|
raise NotFoundError("客户不存在")
|
||||||
customer, _ = row
|
customer, _, rating_total, rating_grade = row
|
||||||
|
|
||||||
if payload.new_owner_personnel_id is not None:
|
if payload.new_owner_personnel_id is not None:
|
||||||
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
||||||
@@ -385,7 +407,12 @@ def change_ownership(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
||||||
out = _to_out(customer, new_person.personnel_name if new_person else None)
|
out = _to_out(
|
||||||
|
customer,
|
||||||
|
new_person.personnel_name if new_person else None,
|
||||||
|
rating_total,
|
||||||
|
rating_grade,
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"调整客户归属 | customer_id=%s | old=%s | new=%s",
|
"调整客户归属 | customer_id=%s | old=%s | new=%s",
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -14,6 +14,8 @@ from fractions import Fraction
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.exceptions import BadRequestError
|
||||||
|
from app.core.month import parse_month
|
||||||
from app.models import Personnel
|
from app.models import Personnel
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +40,11 @@ def month_end(month: str) -> date:
|
|||||||
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
|
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
|
||||||
更细的业务日期,取该月最后可确定的生效时点。
|
更细的业务日期,取该月最后可确定的生效时点。
|
||||||
"""
|
"""
|
||||||
year, mon = int(month[:4]), int(month[5:7])
|
try:
|
||||||
|
year, mon = parse_month(month)
|
||||||
|
except ValueError as exc:
|
||||||
|
# schema 已拦住非法月份;内部调用绕过 schema 时也不能变成 500
|
||||||
|
raise BadRequestError(str(exc)) from exc
|
||||||
return date(year, mon, monthrange(year, mon)[1])
|
return date(year, mon, monthrange(year, mon)[1])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -314,6 +314,54 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
),
|
),
|
||||||
sort_order=20,
|
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,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""客户评级等级配置业务编排。
|
"""客户评级业务编排(等级映射配置 + 每客户评级落库)。
|
||||||
|
|
||||||
评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库
|
评级定位(对照表 #25):**不影响绩效金额**。自 2026-10-19 起评分每客户
|
||||||
——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个
|
落库(customer_ratings,一行一客户快照),等级映射仍是本服务承载的
|
||||||
min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级):
|
**判定表**,判定规则为按 sort_order 升序取首个 min_score ≤ 总分的等级,
|
||||||
|
max_score 仅展示。两条硬约束(违反会算错等级):
|
||||||
|
|
||||||
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
|
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
|
||||||
到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内**
|
到 Decimal 的转换与落库;值域校验(等级区间 0≤min≤max≤100、单项评分
|
||||||
完成,失败的 400 同样留痕(2026-10-13 定稿口径)
|
0≤分≤20)在**审计上下文内**完成,失败的 400 同样留痕(2026-10-13 定稿
|
||||||
|
口径)
|
||||||
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
|
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
|
||||||
失效。判定为**闭区间两两相交**:任一其他等级满足
|
失效。判定为**闭区间两两相交**:任一其他等级满足
|
||||||
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
|
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
|
||||||
@@ -14,9 +16,9 @@ min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反
|
|||||||
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
|
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
|
||||||
靠前的等级,相邻无歧义
|
靠前的等级,相邻无歧义
|
||||||
|
|
||||||
删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不
|
删除允许清空:等级表对客户评级**无外键引用**(评级快照固化等级名、不
|
||||||
回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出
|
回指本表),物理删除无悬空风险,也不伤及已评级客户的历史结论;全部删空
|
||||||
等级(前端已对应提示),故不强制保留任何一条。
|
后新评级只记总分、等级为 NULL(前端已对应提示),故不强制保留任何一条。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -33,9 +35,15 @@ from app.core.constants import (
|
|||||||
)
|
)
|
||||||
from app.core.db_errors import is_duplicate_entry
|
from app.core.db_errors import is_duplicate_entry
|
||||||
from app.core.exceptions import BadRequestError, NotFoundError
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
from app.models import CustomerRatingGrade, User
|
from app.models import CustomerRating, CustomerRatingGrade, User
|
||||||
from app.repositories import rating_repository
|
from app.repositories import customer_rating_repository, customer_repository, rating_repository
|
||||||
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
|
from app.schemas.rating import (
|
||||||
|
CustomerRatingOut,
|
||||||
|
CustomerRatingSaveIn,
|
||||||
|
RatingGradeCreateIn,
|
||||||
|
RatingGradeOut,
|
||||||
|
RatingGradeUpdateIn,
|
||||||
|
)
|
||||||
from app.services import audit_service
|
from app.services import audit_service
|
||||||
from app.services.audit_service import AuditRecord, RequestMeta
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
|
||||||
@@ -44,6 +52,8 @@ logger = logging.getLogger(__name__)
|
|||||||
# 分值域上下界(Decimal 常量,禁用 float 参与比较)
|
# 分值域上下界(Decimal 常量,禁用 float 参与比较)
|
||||||
_SCORE_FLOOR = Decimal("0")
|
_SCORE_FLOOR = Decimal("0")
|
||||||
_SCORE_CEIL = Decimal("100")
|
_SCORE_CEIL = Decimal("100")
|
||||||
|
# 单项评分上界(五项各 0-20,总分 0-100)
|
||||||
|
_ITEM_SCORE_CEIL = Decimal("20")
|
||||||
|
|
||||||
|
|
||||||
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
|
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
|
||||||
@@ -252,10 +262,11 @@ def delete_grade(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""物理删除评级等级。
|
"""物理删除评级等级。
|
||||||
|
|
||||||
等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定
|
等级表对客户评级**无外键引用** —— 评级行快照固化等级名(见
|
||||||
只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。
|
``models/customer_rating.py`` 的论证),判定只读本表现值,故可物理删除
|
||||||
|
且无需锁行防并发引用;删除等级不改变已评级客户的历史结论。
|
||||||
|
|
||||||
**允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示),
|
**允许清空**:全部删空后新评级只记总分、等级为 NULL(前端已对应提示),
|
||||||
不强制保留任何一条。
|
不强制保留任何一条。
|
||||||
|
|
||||||
:raises NotFoundError: 评级等级不存在
|
:raises NotFoundError: 评级等级不存在
|
||||||
@@ -278,3 +289,164 @@ def delete_grade(
|
|||||||
rating_repository.delete(session, grade)
|
rating_repository.delete(session, grade)
|
||||||
session.commit()
|
session.commit()
|
||||||
logger.info("删除评级等级 | grade_name=%s", grade_name)
|
logger.info("删除评级等级 | grade_name=%s", grade_name)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 客户评级(每客户一行,落库) ====================
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_grade(
|
||||||
|
grades: list[CustomerRatingGrade], total_score: Decimal
|
||||||
|
) -> CustomerRatingGrade | None:
|
||||||
|
"""等级判定纯函数:按 sort_order 升序取首个 min_score ≤ 总分的等级。
|
||||||
|
|
||||||
|
与前端打分卡的实时判定同规则同序(前端按数值比较,此处 Decimal 比较
|
||||||
|
——同一规则的两端实现,测试锁定边界行为一致)。无命中(总分低于全部
|
||||||
|
下限或等级表为空)返回 None,由调用方决定展示与落库(快照记 NULL)。
|
||||||
|
"""
|
||||||
|
# id 兜底让同序等级次序稳定(纯函数也接受未持久化对象:id 可能为 None)
|
||||||
|
for grade in sorted(grades, key=lambda g: (g.sort_order, g.id or 0)):
|
||||||
|
if grade.min_score <= total_score:
|
||||||
|
return grade
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_rating_out(rating: CustomerRating) -> CustomerRatingOut:
|
||||||
|
"""客户评级行转响应模型。分值 Decimal → str(分值红线出参口径)。"""
|
||||||
|
return CustomerRatingOut(
|
||||||
|
customer_id=rating.customer_id,
|
||||||
|
score_ship_amount=str(rating.score_ship_amount),
|
||||||
|
score_receipt_rate=str(rating.score_receipt_rate),
|
||||||
|
score_profit_rate=str(rating.score_profit_rate),
|
||||||
|
score_contract_cooperation=str(rating.score_contract_cooperation),
|
||||||
|
score_site_construction=str(rating.score_site_construction),
|
||||||
|
total_score=str(rating.total_score),
|
||||||
|
grade_name=rating.grade_name,
|
||||||
|
remark=rating.remark,
|
||||||
|
scorer_name=rating.scorer_name,
|
||||||
|
create_time=rating.create_time,
|
||||||
|
update_time=rating.update_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_item_scores(scores: list[Decimal]) -> None:
|
||||||
|
"""单项评分值域:0 ≤ 分 ≤ 20(总分由五项求和自然落在 0-100)。
|
||||||
|
|
||||||
|
须在审计上下文内调用,失败也要留痕(同 _ensure_score_range 口径)。
|
||||||
|
"""
|
||||||
|
for score in scores:
|
||||||
|
if not (_SCORE_FLOOR <= score <= _ITEM_SCORE_CEIL):
|
||||||
|
raise BadRequestError("单项评分须在 0~20 之间")
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer_rating(session: Session, customer_id: int) -> CustomerRatingOut | None:
|
||||||
|
"""查某客户当前评级;未评级返回 None(接口信封 data 为 null)。
|
||||||
|
|
||||||
|
「未评级」是正常态而非错误,不用 404 表达;客户不存在才是 NotFound。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
"""
|
||||||
|
customer = customer_repository.get_by_id(session, customer_id)
|
||||||
|
if customer is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
|
||||||
|
return _to_rating_out(rating) if rating is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def save_customer_rating(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
payload: CustomerRatingSaveIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CustomerRatingOut:
|
||||||
|
"""保存客户评级(upsert:首次创建,再次保存整体覆盖)。
|
||||||
|
|
||||||
|
总分与等级**服务端重算**:总分按五项求和(不采信前端回传),等级按
|
||||||
|
保存时点的等级映射判定后**快照固化**进评级行 —— 等级表后续增删改
|
||||||
|
不影响该行(见 ``models/customer_rating.py``)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
:raises BadRequestError: 单项评分须在 0~20 之间(审计上下文内校验,留痕)
|
||||||
|
"""
|
||||||
|
# 只读预取在审计上下文之前:客户不存在(NotFound 失败)也能留下可读记录,
|
||||||
|
# 摘要需要客户名
|
||||||
|
customer = customer_repository.get_by_id(session, customer_id)
|
||||||
|
existing = customer_rating_repository.get_by_customer_id(session, customer_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RATING,
|
||||||
|
action=ACTION_CREATE if existing is None else ACTION_UPDATE,
|
||||||
|
summary=f"评定客户「{customer.company_name if customer else f'#{customer_id}'}」评级",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内:分值非法的 400 也要留痕(同等级配置口径)
|
||||||
|
item_scores = [
|
||||||
|
_to_decimal(payload.score_ship_amount),
|
||||||
|
_to_decimal(payload.score_receipt_rate),
|
||||||
|
_to_decimal(payload.score_profit_rate),
|
||||||
|
_to_decimal(payload.score_contract_cooperation),
|
||||||
|
_to_decimal(payload.score_site_construction),
|
||||||
|
]
|
||||||
|
_ensure_item_scores(item_scores)
|
||||||
|
if customer is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
|
||||||
|
total_score = sum(item_scores, _SCORE_FLOOR)
|
||||||
|
grade = resolve_grade(rating_repository.list_all(session), total_score)
|
||||||
|
|
||||||
|
def _apply(row: CustomerRating) -> None:
|
||||||
|
"""把五项分值/总分/等级快照写入评级行(创建与并发兜底共用)。"""
|
||||||
|
(
|
||||||
|
row.score_ship_amount,
|
||||||
|
row.score_receipt_rate,
|
||||||
|
row.score_profit_rate,
|
||||||
|
row.score_contract_cooperation,
|
||||||
|
row.score_site_construction,
|
||||||
|
) = item_scores
|
||||||
|
row.total_score = total_score
|
||||||
|
row.grade_name = grade.grade_name if grade is not None else None
|
||||||
|
row.remark = payload.remark
|
||||||
|
row.scorer_id = operator.id
|
||||||
|
row.scorer_name = operator.username
|
||||||
|
|
||||||
|
try:
|
||||||
|
if existing is None:
|
||||||
|
rating = CustomerRating(
|
||||||
|
customer_id=customer_id,
|
||||||
|
scorer_id=operator.id,
|
||||||
|
scorer_name=operator.username,
|
||||||
|
)
|
||||||
|
_apply(rating)
|
||||||
|
customer_rating_repository.create(session, rating)
|
||||||
|
else:
|
||||||
|
_apply(existing)
|
||||||
|
rating = existing
|
||||||
|
out = _to_rating_out(rating)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后写不是原子操作:并发为同一客户首次评级会撞
|
||||||
|
# uk_customer_ratings_customer_id,回滚后转为更新兜底
|
||||||
|
session.rollback()
|
||||||
|
if not is_duplicate_entry(exc):
|
||||||
|
raise
|
||||||
|
raced = customer_rating_repository.get_by_customer_id(session, customer_id)
|
||||||
|
if raced is None:
|
||||||
|
# 1062 但行不在:数据态异常,交全局 IntegrityError 兜底
|
||||||
|
raise
|
||||||
|
_apply(raced)
|
||||||
|
rating = raced
|
||||||
|
out = _to_rating_out(rating)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"保存客户评级 | customer_id=%s | total=%s | grade=%s",
|
||||||
|
customer_id,
|
||||||
|
out.total_score,
|
||||||
|
out.grade_name,
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -19,7 +19,11 @@ from openpyxl import Workbook
|
|||||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
from app.core.report_meta import CUSTOMER_TYPE_LABELS, PRODUCT_CATEGORY_LABELS
|
from app.core.report_meta import (
|
||||||
|
BUSINESS_STATUS_LABELS,
|
||||||
|
CUSTOMER_TYPE_LABELS,
|
||||||
|
PRODUCT_CATEGORY_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
_HEADER_FILL = PatternFill("solid", fgColor="DDE3F0")
|
_HEADER_FILL = PatternFill("solid", fgColor="DDE3F0")
|
||||||
_THIN = Side(style="thin", color="B0B7C3")
|
_THIN = Side(style="thin", color="B0B7C3")
|
||||||
@@ -31,7 +35,6 @@ _AMOUNT_FORMAT = "#,##0.00"
|
|||||||
_DATE_FORMAT = "YYYY-MM-DD"
|
_DATE_FORMAT = "YYYY-MM-DD"
|
||||||
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
|
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
|
||||||
|
|
||||||
BUSINESS_STATUS_LABELS = {1: "进行中", 2: "已完成", 3: "已终止"}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -44,6 +47,17 @@ class _Column:
|
|||||||
width: float
|
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, ...]] = {
|
_COLUMNS: dict[str, tuple[_Column, ...]] = {
|
||||||
"personal-detail": (
|
"personal-detail": (
|
||||||
_Column("business_no", "合同编号", "text", 20),
|
_Column("business_no", "合同编号", "text", 20),
|
||||||
@@ -101,11 +115,30 @@ def build_workbook(
|
|||||||
template_version: str,
|
template_version: str,
|
||||||
operator_name: str,
|
operator_name: str,
|
||||||
company_name: str,
|
company_name: str,
|
||||||
|
department: dict | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。"""
|
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。
|
||||||
|
|
||||||
|
部门汇总是签字对账文件,走单 sheet 分块版式;其余报表仍是单表 +
|
||||||
|
合计行。`department` 为 `department_summary` 的完整出参。
|
||||||
|
"""
|
||||||
workbook = Workbook()
|
workbook = Workbook()
|
||||||
sheet = workbook.active
|
sheet = workbook.active
|
||||||
sheet.title = _sheet_name(code)
|
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]
|
columns = _COLUMNS[code]
|
||||||
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
|
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
|
||||||
if rows:
|
if rows:
|
||||||
@@ -129,6 +162,7 @@ def build_workbook(
|
|||||||
extra,
|
extra,
|
||||||
dim_title,
|
dim_title,
|
||||||
company_name,
|
company_name,
|
||||||
|
filters,
|
||||||
buckets.get(dim_key) or [],
|
buckets.get(dim_key) or [],
|
||||||
template_version,
|
template_version,
|
||||||
operator_name,
|
operator_name,
|
||||||
@@ -158,26 +192,15 @@ def _write_header(
|
|||||||
operator_name: str,
|
operator_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""页眉四行 + 表头行;返回后 sheet.max_row 即数据行起点。"""
|
"""页眉四行 + 表头行;返回后 sheet.max_row 即数据行起点。"""
|
||||||
sheet.append([company_name])
|
_write_document_header(
|
||||||
_merge_row(sheet, len(columns))
|
sheet,
|
||||||
sheet.cell(row=1, column=1).font = _COMPANY_FONT
|
len(columns),
|
||||||
sheet.cell(row=1, column=1).alignment = Alignment(horizontal="left", vertical="center")
|
company_name,
|
||||||
|
title,
|
||||||
sheet.append([title])
|
filters,
|
||||||
_merge_row(sheet, len(columns))
|
template_version,
|
||||||
sheet.cell(row=2, column=1).font = _TITLE_FONT
|
operator_name,
|
||||||
sheet.cell(row=2, column=1).alignment = Alignment(horizontal="center", vertical="center")
|
|
||||||
|
|
||||||
sheet.append([f"筛选条件:{filters}"])
|
|
||||||
_merge_row(sheet, len(columns))
|
|
||||||
sheet.append(
|
|
||||||
[
|
|
||||||
f"导出时间:{datetime.now():%Y-%m-%d %H:%M:%S} | 导出人:{operator_name} | "
|
|
||||||
f"模板版本:{template_version}"
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
_merge_row(sheet, len(columns))
|
|
||||||
sheet.append([])
|
|
||||||
|
|
||||||
sheet.append([column.title for column in columns])
|
sheet.append([column.title for column in columns])
|
||||||
header_row = sheet.max_row
|
header_row = sheet.max_row
|
||||||
@@ -189,6 +212,38 @@ def _write_header(
|
|||||||
sheet.freeze_panes = sheet.cell(row=header_row + 1, column=1)
|
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:
|
def _merge_row(sheet, column_count: int) -> None:
|
||||||
"""合并当前行(公司名/标题/筛选行跨全表宽)。"""
|
"""合并当前行(公司名/标题/筛选行跨全表宽)。"""
|
||||||
row = sheet.max_row
|
row = sheet.max_row
|
||||||
@@ -289,11 +344,12 @@ def _write_dimension_sheet(
|
|||||||
sheet,
|
sheet,
|
||||||
title: str,
|
title: str,
|
||||||
company_name: str,
|
company_name: str,
|
||||||
|
filters: str,
|
||||||
rows: list[dict],
|
rows: list[dict],
|
||||||
template_version: str,
|
template_version: str,
|
||||||
operator_name: str,
|
operator_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""维度对比 sheet(客户类型/产品类别)。"""
|
"""维度对比 sheet(客户类型/产品类别),页眉筛选条件与主表一致。"""
|
||||||
columns = (
|
columns = (
|
||||||
_Column("label", "维度", "text", 16),
|
_Column("label", "维度", "text", 16),
|
||||||
_Column("receipt_amount", "回款金额", "amount", 16),
|
_Column("receipt_amount", "回款金额", "amount", 16),
|
||||||
@@ -301,9 +357,301 @@ def _write_dimension_sheet(
|
|||||||
_Column("payable_amount", "应付绩效", "amount", 14),
|
_Column("payable_amount", "应付绩效", "amount", 14),
|
||||||
_Column("ledger_count", "台账行数", "int", 10),
|
_Column("ledger_count", "台账行数", "int", 10),
|
||||||
)
|
)
|
||||||
_write_header(sheet, columns, company_name, title, "全部", template_version, operator_name)
|
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
_write_row(sheet, columns, row)
|
_write_row(sheet, columns, row)
|
||||||
_write_total(sheet, columns, rows)
|
_write_total(sheet, columns, rows)
|
||||||
_write_signature(sheet, len(columns))
|
_write_signature(sheet, len(columns))
|
||||||
_autofit(sheet, 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
|
||||||
|
)
|
||||||
@@ -24,11 +24,14 @@ from app.core import report_meta
|
|||||||
from app.core.constants import ACTION_EXPORT, MODULE_REPORT
|
from app.core.constants import ACTION_EXPORT, MODULE_REPORT
|
||||||
from app.core.exceptions import BadRequestError
|
from app.core.exceptions import BadRequestError
|
||||||
from app.core.report_meta import (
|
from app.core.report_meta import (
|
||||||
|
BUSINESS_STATUS_LABELS,
|
||||||
|
CUSTOMER_TYPE_LABELS,
|
||||||
|
PRODUCT_CATEGORY_LABELS,
|
||||||
REPORT_EXPORT_MAX_ROWS,
|
REPORT_EXPORT_MAX_ROWS,
|
||||||
REPORT_TEMPLATE_VERSION,
|
REPORT_TEMPLATE_VERSION,
|
||||||
DayBucket,
|
DayBucket,
|
||||||
)
|
)
|
||||||
from app.models import BusinessRole, PerformanceLedger, Personnel, User
|
from app.models import BusinessRole, Customer, PerformanceLedger, Personnel, User
|
||||||
from app.models.settlement import ADJUSTMENT_DIRECTION_EXTRA
|
from app.models.settlement import ADJUSTMENT_DIRECTION_EXTRA
|
||||||
from app.repositories import report_repository
|
from app.repositories import report_repository
|
||||||
from app.services import audit_service, manager_share
|
from app.services import audit_service, manager_share
|
||||||
@@ -124,12 +127,19 @@ def _q2(value: Decimal | None) -> Decimal | None:
|
|||||||
|
|
||||||
|
|
||||||
def department_summary(session, query) -> dict:
|
def department_summary(session, query) -> dict:
|
||||||
"""按经理团队/月份汇总绩效工资(团队基数与月结聚合行同源)。"""
|
"""按经理团队/月份汇总绩效工资(团队基数与月结聚合行同源)。
|
||||||
|
|
||||||
|
人员归属按「行类型」而不是「人」划分:普通台账行跟随 ``manager_id``,
|
||||||
|
经理个人户行按开关归经理本人——与 ``collect_team_buckets`` 完全同口径。
|
||||||
|
调整单是人的属性,优先跟随其普通行归属;只有经理个人户的经理跟随
|
||||||
|
本人团队。每个人/每类金额在团队与未分配之间只出现一次。
|
||||||
|
"""
|
||||||
month = query.settlement_month
|
month = query.settlement_month
|
||||||
rows = report_repository.list_month_receipt_rows(session, month)
|
rows = report_repository.list_month_receipt_rows(session, month)
|
||||||
share = manager_share.manager_share_params(session, month)
|
share = manager_share.manager_share_params(session, month)
|
||||||
buckets = manager_share.collect_team_buckets(session, rows, share.include_manager_personal)
|
include_mp = share.include_manager_personal
|
||||||
company_base = manager_share.company_base_of(rows, share.include_manager_personal)
|
buckets = manager_share.collect_team_buckets(session, rows, include_mp)
|
||||||
|
company_base = manager_share.company_base_of(rows, include_mp)
|
||||||
|
|
||||||
aggregates = {
|
aggregates = {
|
||||||
row.personnel_id: row
|
row.personnel_id: row
|
||||||
@@ -137,70 +147,235 @@ def department_summary(session, query) -> dict:
|
|||||||
}
|
}
|
||||||
adjustments = _adjustments_by_person(report_repository.list_month_adjustments(session, month))
|
adjustments = _adjustments_by_person(report_repository.list_month_adjustments(session, month))
|
||||||
member_map = _member_map(rows)
|
member_map = _member_map(rows)
|
||||||
manager_ids = _manager_ids(session, buckets, query.manager_id)
|
# 先构建全部团队再按筛选展示:other_*(隐藏团队金额)需要完整集合,
|
||||||
# 岗位判定范围必须覆盖「无成员的经理」——只按 member_map 取会让零行团队被跳过
|
# 只按 query.manager_id 枚举会让筛选态的 other_* 恒为 0
|
||||||
manager_of, manager_role = _team_relations(session, member_map, manager_ids)
|
manager_ids = _manager_ids(session, buckets, None)
|
||||||
|
if query.manager_id is not None and query.manager_id not in manager_ids:
|
||||||
|
manager_ids = sorted(set(manager_ids) | {query.manager_id})
|
||||||
|
# 岗位判定范围必须覆盖「无成员的经理」与调整单人员——只按 member_map 取
|
||||||
|
# 会让零行团队被跳过,也会让其他经理的下属被误判为无经理
|
||||||
|
manager_of, manager_role, person_info = _team_relations(
|
||||||
|
session, member_map, manager_ids, adjustments
|
||||||
|
)
|
||||||
|
# 只有调整单、本月无台账行的人:补成零值成员后参与归属
|
||||||
|
_merge_adjustment_only_members(member_map, adjustments, person_info)
|
||||||
|
|
||||||
teams: list[dict] = []
|
context = {
|
||||||
for manager_id in manager_ids:
|
"aggregates": aggregates,
|
||||||
manager = session.get(Personnel, manager_id)
|
"buckets": buckets,
|
||||||
if manager is None or manager_role.get(manager_id) != "sales_manager":
|
"member_map": member_map,
|
||||||
continue # 非营销经理的上级不展示团队行(与月结聚合同口径)
|
"manager_of": manager_of,
|
||||||
aggregate = aggregates.get(manager_id)
|
"manager_role": manager_role,
|
||||||
member_rows = _member_rows(
|
"adjustments": adjustments,
|
||||||
manager_id, member_map, manager_of, adjustments, share.include_manager_personal
|
"include_mp": include_mp,
|
||||||
)
|
"share": share,
|
||||||
team_extra = sum((m["adjust_extra"] for m in member_rows), Decimal("0"))
|
"company_base": company_base,
|
||||||
team_deduct = sum((m["adjust_deduct"] for m in member_rows), Decimal("0"))
|
}
|
||||||
team_base = _dec(buckets.get(manager_id, {}).get("performance", Decimal("0")))
|
teams_all = _build_teams(session, manager_ids, context)
|
||||||
# 12.5% 与 5% 分列(FR-030「全公司 5% 部分单列」);两项之和才是月结
|
if query.manager_id is None:
|
||||||
# 聚合行的金额(frozen_amount 对照),重复计入会双倍展示
|
# 离职经理本月无台账、但其下属只有调整单时,团队仍必须展示,
|
||||||
team_share = _team_share(team_base, share.team_pct)
|
# 否则调整金额会从团队与未分配同时消失
|
||||||
company_share = _company_share(company_base, share.company_pct)
|
extra_ids = {
|
||||||
teams.append(
|
target
|
||||||
{
|
for pid in adjustments
|
||||||
"manager_id": manager_id,
|
if (
|
||||||
"manager_name": manager.personnel_name,
|
target := _adjustment_target(
|
||||||
"manager_status": manager.status,
|
pid, member_map, manager_of, manager_role, include_mp
|
||||||
"member_count": len(member_rows),
|
)
|
||||||
"team_base": _q2(team_base),
|
)
|
||||||
"team_share_amount": _q2(team_share),
|
is not None
|
||||||
"company_share_amount": _q2(company_share),
|
} - set(manager_ids)
|
||||||
"company_base_ratio": _ratio(team_base, company_base),
|
if extra_ids:
|
||||||
"adjust_extra": _q2(team_extra),
|
teams_all.extend(_build_teams(session, sorted(extra_ids), context))
|
||||||
"adjust_deduct": _q2(team_deduct),
|
|
||||||
"adjust_net": _q2(team_extra - team_deduct),
|
|
||||||
"net_total": _q2(team_share + company_share + team_extra - team_deduct),
|
|
||||||
"frozen_amount": aggregate.performance_amount if aggregate else None,
|
|
||||||
"aggregate_ledger_id": aggregate.id if aggregate else None,
|
|
||||||
"members": member_rows,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
unassigned = _unassigned_rows(member_map, manager_of, manager_role, adjustments)
|
teams = [
|
||||||
|
team
|
||||||
|
for team in teams_all
|
||||||
|
if query.manager_id is None or team["manager_id"] == query.manager_id
|
||||||
|
]
|
||||||
|
unassigned_all = _unassigned_rows(
|
||||||
|
member_map, manager_of, manager_role, adjustments, include_mp
|
||||||
|
)
|
||||||
|
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
|
||||||
|
unassigned = [] if query.manager_id is not None else unassigned_all
|
||||||
|
company_share_amount = _company_share(company_base, share.company_pct)
|
||||||
|
company_share_count = sum(1 for team in teams if team["has_aggregate"])
|
||||||
|
reconciliation = _reconciliation(teams, teams_all, unassigned, company_base)
|
||||||
return {
|
return {
|
||||||
"settlement_month": month,
|
"settlement_month": month,
|
||||||
"params": {
|
"params": {
|
||||||
"team_text": share.team_text,
|
"team_text": share.team_text,
|
||||||
"company_text": share.company_text,
|
"company_text": share.company_text,
|
||||||
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
"manager_personal_included": 1 if include_mp else 0,
|
||||||
"params_as_of": str(share.param_date),
|
"params_as_of": str(share.param_date),
|
||||||
},
|
},
|
||||||
"teams": teams,
|
"teams": teams,
|
||||||
"unassigned": unassigned,
|
"unassigned": unassigned,
|
||||||
"company": {
|
"company": {
|
||||||
"team_base_total": _q2(sum((t["team_base"] for t in teams), Decimal("0"))),
|
"team_base_total": reconciliation["team_base_total"],
|
||||||
"company_base": _q2(company_base),
|
"company_base": _q2(company_base),
|
||||||
"company_share_pct": share.company_text,
|
"company_share_pct": share.company_text,
|
||||||
"company_share_amount": _q2(_company_share(company_base, share.company_pct)),
|
# 单经理名义值;结算总额见 company_share_total(每位有聚合行的经理各计一次)
|
||||||
|
"company_share_amount": _q2(company_share_amount),
|
||||||
|
"company_share_count": company_share_count,
|
||||||
|
"company_share_total": _q2(company_share_amount * company_share_count),
|
||||||
},
|
},
|
||||||
|
"reconciliation": reconciliation,
|
||||||
"totals": {
|
"totals": {
|
||||||
"performance_total": _q2(sum((t["team_base"] for t in teams), Decimal("0"))),
|
"performance_total": reconciliation["team_base_total"],
|
||||||
"payable_total": _q2(sum((t["net_total"] for t in teams), Decimal("0"))),
|
"payable_total": reconciliation["payable_total"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_teams(session, manager_ids: list[int], context: dict) -> list[dict]:
|
||||||
|
"""按经理枚举生成团队行(含零成员团队)。"""
|
||||||
|
teams = []
|
||||||
|
for manager_id in manager_ids:
|
||||||
|
team = _build_team(session, manager_id, context)
|
||||||
|
if team is not None:
|
||||||
|
teams.append(team)
|
||||||
|
return teams
|
||||||
|
|
||||||
|
|
||||||
|
def _build_team(session, manager_id: int, context: dict) -> dict | None:
|
||||||
|
"""单个经理团队行;非营销经理的上级不展示(与月结聚合同口径)。"""
|
||||||
|
manager = session.get(Personnel, manager_id)
|
||||||
|
if manager is None or context["manager_role"].get(manager_id) != "sales_manager":
|
||||||
|
return None
|
||||||
|
aggregate = context["aggregates"].get(manager_id)
|
||||||
|
member_rows = _member_rows(
|
||||||
|
manager_id,
|
||||||
|
context["member_map"],
|
||||||
|
context["manager_of"],
|
||||||
|
context["manager_role"],
|
||||||
|
context["adjustments"],
|
||||||
|
context["include_mp"],
|
||||||
|
)
|
||||||
|
team_extra = sum((m["adjust_extra"] for m in member_rows), Decimal("0"))
|
||||||
|
team_deduct = sum((m["adjust_deduct"] for m in member_rows), Decimal("0"))
|
||||||
|
team_base = _dec(context["buckets"].get(manager_id, {}).get("performance", Decimal("0")))
|
||||||
|
# 月结只为有台账 bucket 的营销经理生成聚合行;零成员团队不发 5%
|
||||||
|
has_aggregate = manager_id in context["buckets"] or aggregate is not None
|
||||||
|
team_share = _team_share(team_base, context["share"].team_pct)
|
||||||
|
# 聚合额必须与月结「一次舍入」口径一致:优先取冻结行;未生成月结时
|
||||||
|
# 才用 12.5%+5% 合并公式预览。分列各自 q2 后相加会差 0.01~0.02。
|
||||||
|
if aggregate is not None:
|
||||||
|
aggregate_amount = _dec(aggregate.performance_amount or Decimal("0"))
|
||||||
|
elif manager_id in context["buckets"]:
|
||||||
|
aggregate_amount = _q2(
|
||||||
|
manager_team_share(
|
||||||
|
team_base,
|
||||||
|
context["company_base"],
|
||||||
|
team_pct=context["share"].team_pct,
|
||||||
|
company_pct=context["share"].company_pct,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
aggregate_amount = Decimal("0")
|
||||||
|
company_share = (
|
||||||
|
_company_share(context["company_base"], context["share"].company_pct)
|
||||||
|
if has_aggregate
|
||||||
|
else Decimal("0")
|
||||||
|
)
|
||||||
|
team_subtotal = _q2(aggregate_amount + team_extra - team_deduct)
|
||||||
|
return {
|
||||||
|
"manager_id": manager_id,
|
||||||
|
"manager_name": manager.personnel_name,
|
||||||
|
"manager_status": manager.status,
|
||||||
|
"member_count": len(member_rows),
|
||||||
|
"team_base": _q2(team_base),
|
||||||
|
"team_share_amount": _q2(team_share),
|
||||||
|
# 单经理 5% 名义值;无聚合行时为 0(不会发放)
|
||||||
|
"company_share_amount": _q2(company_share),
|
||||||
|
"has_aggregate": has_aggregate,
|
||||||
|
# 与月结聚合行同口径(12.5% + 5% 一次舍入);无聚合行时为 0
|
||||||
|
"aggregate_amount": _q2(aggregate_amount),
|
||||||
|
# 团队应发小计 = 聚合行金额 + 调整净额;不再另加一次 5%
|
||||||
|
"team_subtotal": team_subtotal,
|
||||||
|
"company_base_ratio": _ratio(team_base, context["company_base"]),
|
||||||
|
"adjust_extra": _q2(team_extra),
|
||||||
|
"adjust_deduct": _q2(team_deduct),
|
||||||
|
"adjust_net": _q2(team_extra - team_deduct),
|
||||||
|
"net_total": team_subtotal,
|
||||||
|
"frozen_amount": aggregate.performance_amount if aggregate else None,
|
||||||
|
"aggregate_ledger_id": aggregate.id if aggregate else None,
|
||||||
|
"members": member_rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reconciliation(
|
||||||
|
teams: list[dict],
|
||||||
|
teams_all: list[dict],
|
||||||
|
unassigned: list[dict],
|
||||||
|
company_base: Decimal,
|
||||||
|
) -> dict:
|
||||||
|
"""展示口径的对账块。
|
||||||
|
|
||||||
|
应发 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
|
||||||
|
聚合行金额(``aggregate_amount``)含 12.5%+5%,且与月结「一次舍入」
|
||||||
|
完全一致;``team_share_total``/``company_share_total`` 只作分列展示,
|
||||||
|
两者之和与聚合额的差记入 ``aggregate_rounding_diff``(分列各自 q2
|
||||||
|
会产生 0.01~0.02 的尾差,直接相加会与月结总额不符)。
|
||||||
|
|
||||||
|
筛选态下 ``other_*`` 记录被隐藏团队的金额;它们不进入应发。
|
||||||
|
"""
|
||||||
|
displayed_ids = {team["manager_id"] for team in teams}
|
||||||
|
other_teams = [team for team in teams_all if team["manager_id"] not in displayed_ids]
|
||||||
|
|
||||||
|
team_member_payable = sum(
|
||||||
|
(m["payable_sum"] for team in teams for m in team["members"]), Decimal("0")
|
||||||
|
)
|
||||||
|
unassigned_payable = sum((row["payable_sum"] for row in unassigned), Decimal("0"))
|
||||||
|
team_share_total = sum((team["team_share_amount"] for team in teams), Decimal("0"))
|
||||||
|
company_share_total = sum((team["company_share_amount"] for team in teams), Decimal("0"))
|
||||||
|
aggregate_payable_total = sum((team["aggregate_amount"] for team in teams), Decimal("0"))
|
||||||
|
aggregate_rounding_diff = aggregate_payable_total - team_share_total - company_share_total
|
||||||
|
adjust_net_total = sum(
|
||||||
|
(m["adjust_net"] for team in teams for m in team["members"]), Decimal("0")
|
||||||
|
) + sum((row["adjust_net"] for row in unassigned), Decimal("0"))
|
||||||
|
team_base_total = sum((team["team_base"] for team in teams), Decimal("0"))
|
||||||
|
unassigned_performance = sum(
|
||||||
|
(row["performance_sum"] for row in unassigned), Decimal("0")
|
||||||
|
)
|
||||||
|
payable_total = (
|
||||||
|
team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"team_member_payable_total": _q2(team_member_payable),
|
||||||
|
"unassigned_payable_total": _q2(unassigned_payable),
|
||||||
|
"team_share_total": _q2(team_share_total),
|
||||||
|
# 各位有聚合行的经理 5% 之和(单份 × 计入次数)
|
||||||
|
"company_share_total": _q2(company_share_total),
|
||||||
|
# 与月结聚合行同口径的应发合计(12.5% + 5% 一次舍入)
|
||||||
|
"aggregate_payable_total": _q2(aggregate_payable_total),
|
||||||
|
# 分列展示与聚合额之间的舍入差;导出签字稿单列一行
|
||||||
|
"aggregate_rounding_diff": _q2(aggregate_rounding_diff),
|
||||||
|
"adjust_net_total": _q2(adjust_net_total),
|
||||||
|
"payable_total": _q2(payable_total),
|
||||||
|
"team_base_total": _q2(team_base_total),
|
||||||
|
"unassigned_performance_total": _q2(unassigned_performance),
|
||||||
|
# 筛选时包含其他团队基数与全公司未分配;未筛选时应为 0
|
||||||
|
"other_team_base": _q2(company_base - team_base_total - unassigned_performance),
|
||||||
|
"other_team_member_payable_total": _q2(
|
||||||
|
sum((m["payable_sum"] for team in other_teams for m in team["members"]), Decimal("0"))
|
||||||
|
),
|
||||||
|
"other_team_share_total": _q2(
|
||||||
|
sum((team["team_share_amount"] for team in other_teams), Decimal("0"))
|
||||||
|
),
|
||||||
|
"other_team_company_share_total": _q2(
|
||||||
|
sum((team["company_share_amount"] for team in other_teams), Decimal("0"))
|
||||||
|
),
|
||||||
|
"other_team_aggregate_total": _q2(
|
||||||
|
sum((team["aggregate_amount"] for team in other_teams), Decimal("0"))
|
||||||
|
),
|
||||||
|
"other_adjust_net_total": _q2(
|
||||||
|
sum((team["adjust_net"] for team in other_teams), Decimal("0"))
|
||||||
|
),
|
||||||
|
"company_base": _q2(company_base),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
|
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
|
||||||
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
|
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
|
||||||
return manager_team_share(
|
return manager_team_share(
|
||||||
@@ -223,11 +398,19 @@ def _ratio(part: Decimal, whole: Decimal) -> Decimal:
|
|||||||
|
|
||||||
|
|
||||||
def _adjustments_by_person(orders) -> dict[int, dict]:
|
def _adjustments_by_person(orders) -> dict[int, dict]:
|
||||||
"""生效调整单按人汇总(补发为正、扣减为负;金额本身恒正)。"""
|
"""生效调整单按人汇总(补发为正、扣减为负;金额本身恒正)。
|
||||||
|
|
||||||
|
不要求该人本月有台账行——只有调整单的人也必须进入报表(计划 §一.3)。
|
||||||
|
"""
|
||||||
by_person: dict[int, dict] = {}
|
by_person: dict[int, dict] = {}
|
||||||
for order in orders:
|
for order in orders:
|
||||||
acc = by_person.setdefault(
|
acc = by_person.setdefault(
|
||||||
order.personnel_id, {"extra": Decimal("0"), "deduct": Decimal("0")}
|
order.personnel_id,
|
||||||
|
{
|
||||||
|
"extra": Decimal("0"),
|
||||||
|
"deduct": Decimal("0"),
|
||||||
|
"personnel_name": order.personnel_name,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
|
if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
|
||||||
acc["extra"] += order.amount
|
acc["extra"] += order.amount
|
||||||
@@ -237,7 +420,11 @@ def _adjustments_by_person(orders) -> dict[int, dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _member_map(rows) -> dict[int, dict]:
|
def _member_map(rows) -> dict[int, dict]:
|
||||||
"""台账行按人求和(成员明细的唯一数据源),经理个人户金额单独记账。"""
|
"""台账行按人求和(成员明细的唯一数据源)。
|
||||||
|
|
||||||
|
普通行与经理个人户行分开记账:团队基数只含后者,成员行也只能取
|
||||||
|
后者,否则经理本人的普通业绩会同时出现在成员行与未分配中。
|
||||||
|
"""
|
||||||
members: dict[int, dict] = {}
|
members: dict[int, dict] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
acc = members.setdefault(
|
acc = members.setdefault(
|
||||||
@@ -248,9 +435,13 @@ def _member_map(rows) -> dict[int, dict]:
|
|||||||
"is_probation": row.is_probation,
|
"is_probation": row.is_probation,
|
||||||
"performance": Decimal("0"),
|
"performance": Decimal("0"),
|
||||||
"payable": Decimal("0"),
|
"payable": Decimal("0"),
|
||||||
|
"regular_performance": Decimal("0"),
|
||||||
|
"regular_payable": Decimal("0"),
|
||||||
"manager_personal_performance": Decimal("0"),
|
"manager_personal_performance": Decimal("0"),
|
||||||
"manager_personal_payable": Decimal("0"),
|
"manager_personal_payable": Decimal("0"),
|
||||||
"ledger_ids": [],
|
"regular_ledger_ids": [],
|
||||||
|
"manager_personal_ledger_ids": [],
|
||||||
|
"has_regular_rows": False,
|
||||||
"has_manager_personal": False,
|
"has_manager_personal": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -258,34 +449,90 @@ def _member_map(rows) -> dict[int, dict]:
|
|||||||
payable = row.payable_amount or Decimal("0")
|
payable = row.payable_amount or Decimal("0")
|
||||||
acc["performance"] += performance
|
acc["performance"] += performance
|
||||||
acc["payable"] += payable
|
acc["payable"] += payable
|
||||||
acc["ledger_ids"].append(row.id)
|
|
||||||
if row.customer_type == "manager_personal":
|
if row.customer_type == "manager_personal":
|
||||||
acc["has_manager_personal"] = True
|
acc["has_manager_personal"] = True
|
||||||
acc["manager_personal_performance"] += performance
|
acc["manager_personal_performance"] += performance
|
||||||
acc["manager_personal_payable"] += payable
|
acc["manager_personal_payable"] += payable
|
||||||
|
acc["manager_personal_ledger_ids"].append(row.id)
|
||||||
|
else:
|
||||||
|
acc["has_regular_rows"] = True
|
||||||
|
acc["regular_performance"] += performance
|
||||||
|
acc["regular_payable"] += payable
|
||||||
|
acc["regular_ledger_ids"].append(row.id)
|
||||||
return members
|
return members
|
||||||
|
|
||||||
|
|
||||||
def _team_relations(
|
def _team_relations(
|
||||||
session, member_map: dict[int, dict], manager_ids: list[int]
|
session,
|
||||||
) -> tuple[dict[int, int | None], dict[int, str]]:
|
member_map: dict[int, dict],
|
||||||
"""人员 → 直属经理与人员 → 岗位编码(各一次查询,避免逐人 get 的 N+1)。
|
manager_ids: list[int],
|
||||||
|
adjustments: dict[int, dict],
|
||||||
|
) -> tuple[dict[int, int | None], dict[int, str], dict[int, dict]]:
|
||||||
|
"""人员 → 直属经理、岗位编码与展示信息(批量查询,避免 N+1)。
|
||||||
|
|
||||||
查询范围 = 成员 ∪ 经理枚举:无成员的经理也要有岗位判定,否则「零行团队」
|
查询范围 = 成员 ∪ 经理枚举 ∪ 调整单人员;随后再补查这些人引用的
|
||||||
会被岗位过滤静默跳过(FR-030 要求无团队成员出 0 行)。
|
上级。缺少上级的岗位信息时,其他经理的下属会被误判为「无经理」,
|
||||||
|
零成员团队也会被岗位过滤静默跳过(FR-030 要求出 0 行)。
|
||||||
经理判定与 ``manager_share.collect_team_buckets`` 的实时上溯同口径:
|
经理判定与 ``manager_share.collect_team_buckets`` 的实时上溯同口径:
|
||||||
离职人员的 manager_id 保留,历史业绩因此留在原团队。
|
离职人员的 manager_id 保留,历史业绩因此留在原团队。
|
||||||
"""
|
"""
|
||||||
pids = list(set(member_map) | set(manager_ids))
|
seed = set(member_map) | set(manager_ids) | set(adjustments)
|
||||||
if not pids:
|
if not seed:
|
||||||
return {}, {}
|
return {}, {}, {}
|
||||||
people = list(session.execute(select(Personnel).where(Personnel.id.in_(pids))).scalars())
|
people = list(session.execute(select(Personnel).where(Personnel.id.in_(seed))).scalars())
|
||||||
manager_of = {p.id: p.manager_id for p in people}
|
referenced = {p.manager_id for p in people if p.manager_id is not None}
|
||||||
|
missing = referenced - seed
|
||||||
|
if missing:
|
||||||
|
people.extend(
|
||||||
|
session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars()
|
||||||
|
)
|
||||||
role_by_id = {
|
role_by_id = {
|
||||||
r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()
|
r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()
|
||||||
}
|
}
|
||||||
|
manager_of = {p.id: p.manager_id for p in people}
|
||||||
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
|
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
|
||||||
return manager_of, manager_role
|
person_info = {
|
||||||
|
p.id: {
|
||||||
|
"personnel_name": p.personnel_name,
|
||||||
|
"role_code": role_by_id.get(p.business_role_id, ""),
|
||||||
|
"is_probation": p.is_probation,
|
||||||
|
}
|
||||||
|
for p in people
|
||||||
|
}
|
||||||
|
return manager_of, manager_role, person_info
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_adjustment_only_members(
|
||||||
|
member_map: dict[int, dict], adjustments: dict[int, dict], person_info: dict[int, dict]
|
||||||
|
) -> None:
|
||||||
|
"""把「只有调整单、本月无台账行」的人补成零值成员。
|
||||||
|
|
||||||
|
补进 member_map 后,``_member_rows`` / ``_unassigned_rows`` 的归属
|
||||||
|
逻辑无需再为调整单单独开一条遍历分支;绩效为 0 也保证团队基数不变。
|
||||||
|
"""
|
||||||
|
for personnel_id, adjustment in adjustments.items():
|
||||||
|
if personnel_id in member_map:
|
||||||
|
continue
|
||||||
|
info = person_info.get(personnel_id) or {
|
||||||
|
"personnel_name": adjustment["personnel_name"],
|
||||||
|
"role_code": "",
|
||||||
|
"is_probation": 0,
|
||||||
|
}
|
||||||
|
member_map[personnel_id] = {
|
||||||
|
"personnel_name": info["personnel_name"],
|
||||||
|
"role_code": info["role_code"],
|
||||||
|
"is_probation": info["is_probation"],
|
||||||
|
"performance": Decimal("0"),
|
||||||
|
"payable": Decimal("0"),
|
||||||
|
"regular_performance": Decimal("0"),
|
||||||
|
"regular_payable": Decimal("0"),
|
||||||
|
"manager_personal_performance": Decimal("0"),
|
||||||
|
"manager_personal_payable": Decimal("0"),
|
||||||
|
"regular_ledger_ids": [],
|
||||||
|
"manager_personal_ledger_ids": [],
|
||||||
|
"has_regular_rows": False,
|
||||||
|
"has_manager_personal": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _manager_ids(session, buckets: dict, only: int | None) -> list[int]:
|
def _manager_ids(session, buckets: dict, only: int | None) -> list[int]:
|
||||||
@@ -304,37 +551,143 @@ def _manager_ids(session, buckets: dict, only: int | None) -> list[int]:
|
|||||||
return sorted(found)
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
def _regular_team_of(
|
||||||
|
pid: int, manager_of: dict[int, int | None], manager_role: dict[int, str]
|
||||||
|
) -> int | None:
|
||||||
|
"""普通台账行的团队归属:直属上级是营销经理时归其团队。"""
|
||||||
|
manager_id = manager_of.get(pid)
|
||||||
|
if manager_id is not None and manager_role.get(manager_id) == "sales_manager":
|
||||||
|
return manager_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _adjustment_target(
|
||||||
|
pid: int,
|
||||||
|
member_map: dict[int, dict],
|
||||||
|
manager_of: dict[int, int | None],
|
||||||
|
manager_role: dict[int, str],
|
||||||
|
include_manager_personal: bool,
|
||||||
|
) -> int | None:
|
||||||
|
"""调整单归属:优先跟随普通行;只有经理个人户的经理跟随本人团队。
|
||||||
|
|
||||||
|
调整单没有行类型,只能按人的实时归属落位。每人只允许一行携带调整,
|
||||||
|
因此这里返回唯一的团队键(None = 未分配)。
|
||||||
|
"""
|
||||||
|
member = member_map.get(pid)
|
||||||
|
if member is not None and member["has_regular_rows"]:
|
||||||
|
return _regular_team_of(pid, manager_of, manager_role)
|
||||||
|
if (
|
||||||
|
member is not None
|
||||||
|
and include_manager_personal
|
||||||
|
and member["has_manager_personal"]
|
||||||
|
and manager_role.get(pid) == "sales_manager"
|
||||||
|
):
|
||||||
|
return pid
|
||||||
|
return _regular_team_of(pid, manager_of, manager_role)
|
||||||
|
|
||||||
|
|
||||||
def _member_rows(
|
def _member_rows(
|
||||||
manager_id: int,
|
manager_id: int,
|
||||||
member_map: dict[int, dict],
|
member_map: dict[int, dict],
|
||||||
manager_of: dict[int, int | None],
|
manager_of: dict[int, int | None],
|
||||||
|
manager_role: dict[int, str],
|
||||||
adjustments: dict[int, dict],
|
adjustments: dict[int, dict],
|
||||||
include_manager_personal: bool,
|
include_manager_personal: bool,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""团队内成员行:业务员按 manager_id 归属;经理个人户按开关归属本人。"""
|
"""团队内成员行:普通行按 manager_id 归属;经理个人户按开关归属本人。
|
||||||
pids = [pid for pid, mid in manager_of.items() if mid == manager_id and pid != manager_id]
|
|
||||||
# 经理本人(经理个人户行):#12 开关开启时计入本团队基数
|
经理本人只以「经理个人户金额」出现在自己团队,普通行业绩留在上级
|
||||||
if include_manager_personal and manager_id in member_map:
|
团队或未分配——同一笔业绩不会在团队与未分配中重复展示。
|
||||||
pids.append(manager_id)
|
"""
|
||||||
return [_member_row(pid, member_map[pid], adjustments) for pid in pids]
|
rows = []
|
||||||
|
for pid, member in member_map.items():
|
||||||
|
target = _adjustment_target(
|
||||||
|
pid, member_map, manager_of, manager_role, include_manager_personal
|
||||||
|
)
|
||||||
|
regular_team = _regular_team_of(pid, manager_of, manager_role)
|
||||||
|
if pid != manager_id and member["has_regular_rows"] and regular_team == manager_id:
|
||||||
|
rows.append(
|
||||||
|
_member_row(
|
||||||
|
pid,
|
||||||
|
member,
|
||||||
|
adjustments,
|
||||||
|
"regular",
|
||||||
|
performance=member["regular_performance"],
|
||||||
|
payable=member["regular_payable"],
|
||||||
|
ledger_ids=member["regular_ledger_ids"],
|
||||||
|
include_adjust=target == manager_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
personal_in_team = (
|
||||||
|
pid == manager_id
|
||||||
|
and include_manager_personal
|
||||||
|
and member["has_manager_personal"]
|
||||||
|
)
|
||||||
|
if personal_in_team:
|
||||||
|
rows.append(
|
||||||
|
_member_row(
|
||||||
|
pid,
|
||||||
|
member,
|
||||||
|
adjustments,
|
||||||
|
"manager_personal",
|
||||||
|
performance=member["manager_personal_performance"],
|
||||||
|
payable=member["manager_personal_payable"],
|
||||||
|
ledger_ids=member["manager_personal_ledger_ids"],
|
||||||
|
include_adjust=target == manager_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
carried = (
|
||||||
|
member["has_regular_rows"] and regular_team == manager_id and target == manager_id
|
||||||
|
) or (personal_in_team and target == manager_id)
|
||||||
|
# 调整单没有被普通行/个人户行承载时单独出一行(例如只有个人户、
|
||||||
|
# 但上级是营销经理的人员:调整随实时 manager_id 归上级团队)
|
||||||
|
if pid in adjustments and target == manager_id and not carried:
|
||||||
|
rows.append(
|
||||||
|
_member_row(
|
||||||
|
pid,
|
||||||
|
member,
|
||||||
|
adjustments,
|
||||||
|
"adjustment",
|
||||||
|
performance=Decimal("0"),
|
||||||
|
payable=Decimal("0"),
|
||||||
|
ledger_ids=[],
|
||||||
|
include_adjust=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _member_row(pid: int, member: dict, adjustments: dict[int, dict]) -> dict:
|
def _member_row(
|
||||||
"""单条成员行(绩效/应付求和 + 调整净额 + 台账 id 列表)。"""
|
pid: int,
|
||||||
adj = adjustments.get(pid, {"extra": Decimal("0"), "deduct": Decimal("0")})
|
member: dict,
|
||||||
net = member["payable"] + adj["extra"] - adj["deduct"]
|
adjustments: dict[int, dict],
|
||||||
|
member_kind: str,
|
||||||
|
*,
|
||||||
|
performance: Decimal,
|
||||||
|
payable: Decimal,
|
||||||
|
ledger_ids: list[int],
|
||||||
|
include_adjust: bool,
|
||||||
|
) -> dict:
|
||||||
|
"""单条成员行(按行类型取金额,调整净额最多计入一次)。"""
|
||||||
|
adj = (
|
||||||
|
adjustments.get(pid, {"extra": Decimal("0"), "deduct": Decimal("0")})
|
||||||
|
if include_adjust
|
||||||
|
else {"extra": Decimal("0"), "deduct": Decimal("0")}
|
||||||
|
)
|
||||||
|
net = payable + adj["extra"] - adj["deduct"]
|
||||||
return {
|
return {
|
||||||
"personnel_id": pid,
|
"personnel_id": pid,
|
||||||
"personnel_name": member["personnel_name"],
|
"personnel_name": member["personnel_name"],
|
||||||
"role_code": member["role_code"],
|
"role_code": member["role_code"],
|
||||||
"is_probation": member["is_probation"],
|
"is_probation": member["is_probation"],
|
||||||
"performance_sum": _q2(member["performance"]),
|
"member_kind": member_kind,
|
||||||
"payable_sum": _q2(member["payable"]),
|
"performance_sum": _q2(performance),
|
||||||
|
"payable_sum": _q2(payable),
|
||||||
"adjust_extra": _q2(adj["extra"]),
|
"adjust_extra": _q2(adj["extra"]),
|
||||||
"adjust_deduct": _q2(adj["deduct"]),
|
"adjust_deduct": _q2(adj["deduct"]),
|
||||||
"adjust_net": _q2(adj["extra"] - adj["deduct"]),
|
"adjust_net": _q2(adj["extra"] - adj["deduct"]),
|
||||||
"net_total": _q2(net),
|
"net_total": _q2(net),
|
||||||
"ledger_ids": member["ledger_ids"],
|
"ledger_ids": list(ledger_ids),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -343,24 +696,68 @@ def _unassigned_rows(
|
|||||||
manager_of: dict[int, int | None],
|
manager_of: dict[int, int | None],
|
||||||
manager_role: dict[int, str],
|
manager_role: dict[int, str],
|
||||||
adjustments: dict[int, dict],
|
adjustments: dict[int, dict],
|
||||||
|
include_manager_personal: bool,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""未进入团队基数的成员:无经理业务员 + 非营销经理上级的下属。
|
"""未进入团队基数的成员:无经理业务员 + 非营销经理上级的下属。
|
||||||
|
|
||||||
经理个人户业绩默认不计入任何基数(#12 开关关闭时),也不在此展示——
|
经理个人户开关关闭时不展示个人户金额(与全公司基数排除口径一致);
|
||||||
其金额仍可在个人明细报表中查询,避免与「团队基数 + 未分配」口径混淆。
|
开关开启但本人不是营销经理时放入未分配,避免金额静默消失。
|
||||||
|
调整单在这里只出现一次:普通行/个人户行未承载时才单列。
|
||||||
"""
|
"""
|
||||||
rows = []
|
rows = []
|
||||||
for pid, member in member_map.items():
|
for pid, member in member_map.items():
|
||||||
mid = manager_of.get(pid)
|
target = _adjustment_target(
|
||||||
if mid is not None and manager_role.get(mid) == "sales_manager":
|
pid, member_map, manager_of, manager_role, include_manager_personal
|
||||||
continue
|
)
|
||||||
regular = member["performance"] - member["manager_personal_performance"]
|
regular_team = _regular_team_of(pid, manager_of, manager_role)
|
||||||
if regular == 0 and member["manager_personal_performance"] != 0:
|
regular_unassigned = member["has_regular_rows"] and regular_team is None
|
||||||
continue
|
personal_unassigned = (
|
||||||
row = _member_row(pid, member, adjustments)
|
include_manager_personal
|
||||||
row["performance_sum"] = _q2(regular)
|
and member["has_manager_personal"]
|
||||||
row["payable_sum"] = _q2(member["payable"] - member["manager_personal_payable"])
|
and manager_role.get(pid) != "sales_manager"
|
||||||
rows.append(row)
|
)
|
||||||
|
if regular_unassigned:
|
||||||
|
row = _member_row(
|
||||||
|
pid,
|
||||||
|
member,
|
||||||
|
adjustments,
|
||||||
|
"regular",
|
||||||
|
performance=member["regular_performance"],
|
||||||
|
payable=member["regular_payable"],
|
||||||
|
ledger_ids=member["regular_ledger_ids"],
|
||||||
|
include_adjust=target is None,
|
||||||
|
)
|
||||||
|
row["unassigned_reason"] = "无经理或上级非营销经理"
|
||||||
|
rows.append(row)
|
||||||
|
if personal_unassigned:
|
||||||
|
row = _member_row(
|
||||||
|
pid,
|
||||||
|
member,
|
||||||
|
adjustments,
|
||||||
|
"manager_personal",
|
||||||
|
performance=member["manager_personal_performance"],
|
||||||
|
payable=member["manager_personal_payable"],
|
||||||
|
ledger_ids=member["manager_personal_ledger_ids"],
|
||||||
|
include_adjust=target is None and not member["has_regular_rows"],
|
||||||
|
)
|
||||||
|
row["unassigned_reason"] = "经理个人户(本人非营销经理)"
|
||||||
|
rows.append(row)
|
||||||
|
carried = (regular_unassigned and target is None) or (
|
||||||
|
personal_unassigned and target is None and not member["has_regular_rows"]
|
||||||
|
)
|
||||||
|
if pid in adjustments and target is None and not carried:
|
||||||
|
row = _member_row(
|
||||||
|
pid,
|
||||||
|
member,
|
||||||
|
adjustments,
|
||||||
|
"adjustment",
|
||||||
|
performance=Decimal("0"),
|
||||||
|
payable=Decimal("0"),
|
||||||
|
ledger_ids=[],
|
||||||
|
include_adjust=True,
|
||||||
|
)
|
||||||
|
row["unassigned_reason"] = "仅有调整单且无团队归属"
|
||||||
|
rows.append(row)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -547,26 +944,41 @@ EXPORT_FILENAMES = {
|
|||||||
"payment-stats": "payment_stats",
|
"payment-stats": "payment_stats",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 标题是导出审计摘要的一部分:进入 audit 前就要确定,不能在取数后才拼
|
||||||
|
EXPORT_TITLES = {
|
||||||
|
"personal-detail": "个人绩效工资明细表",
|
||||||
|
"department-summary": "部门绩效工资汇总表",
|
||||||
|
"business-stats": "业务统计表",
|
||||||
|
"payment-stats": "回款情况统计表",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def export_report(session, code: str, query, *, operator: User, meta: RequestMeta | None = None):
|
def export_report(session, code: str, query, *, operator: User, meta: RequestMeta | None = None):
|
||||||
"""导出 Excel:上限校验 → 取数 → 构建 → 审计留痕(独立 commit)。
|
"""导出 Excel:取数 → 构建 → 审计留痕(独立 commit)。
|
||||||
|
|
||||||
|
取数与上限校验必须在审计上下文内:超限 400 和取数异常同样要留下
|
||||||
|
失败记录(M4 计划 T12「导出 400/失败审计」)。`AuditRecord` 是 frozen
|
||||||
|
的,摘要只写静态标题,超限行数由异常文案带入 error_message。
|
||||||
|
|
||||||
返回 (bytes, 文件名);文件名由 API 层拼日期(ASCII,响应头 latin-1)。
|
返回 (bytes, 文件名);文件名由 API 层拼日期(ASCII,响应头 latin-1)。
|
||||||
"""
|
"""
|
||||||
from app.services import report_export
|
from app.services import report_export
|
||||||
|
|
||||||
rows, context = _export_payload(session, code, query)
|
title = EXPORT_TITLES.get(code)
|
||||||
|
if title is None:
|
||||||
|
raise BadRequestError(f"未知的报表编码:{code}")
|
||||||
with audit_service.audit(
|
with audit_service.audit(
|
||||||
session,
|
session,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
record=AuditRecord(
|
record=AuditRecord(
|
||||||
module=MODULE_REPORT,
|
module=MODULE_REPORT,
|
||||||
action=ACTION_EXPORT,
|
action=ACTION_EXPORT,
|
||||||
summary=f"导出{context['title']}({context['row_count']} 行)",
|
summary=f"导出{title}",
|
||||||
request_data=query.model_dump(exclude_none=True),
|
request_data=query.model_dump(exclude_none=True),
|
||||||
),
|
),
|
||||||
meta=meta,
|
meta=meta,
|
||||||
):
|
):
|
||||||
|
rows, context = _export_payload(session, code, query)
|
||||||
content = report_export.build_workbook(
|
content = report_export.build_workbook(
|
||||||
code=code,
|
code=code,
|
||||||
title=context["title"],
|
title=context["title"],
|
||||||
@@ -574,6 +986,7 @@ def export_report(session, code: str, query, *, operator: User, meta: RequestMet
|
|||||||
rows=rows,
|
rows=rows,
|
||||||
summary=context.get("summary"),
|
summary=context.get("summary"),
|
||||||
buckets=context.get("buckets"),
|
buckets=context.get("buckets"),
|
||||||
|
department=context.get("department"),
|
||||||
template_version=REPORT_TEMPLATE_VERSION,
|
template_version=REPORT_TEMPLATE_VERSION,
|
||||||
operator_name=getattr(operator, "username", ""),
|
operator_name=getattr(operator, "username", ""),
|
||||||
company_name=context["company_name"],
|
company_name=context["company_name"],
|
||||||
@@ -592,9 +1005,9 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
|
|||||||
role_names = _role_names(session)
|
role_names = _role_names(session)
|
||||||
sums = report_repository.sum_personal_detail(session, query)
|
sums = report_repository.sum_personal_detail(session, query)
|
||||||
return [_person_row(r, role_names) for r in rows], {
|
return [_person_row(r, role_names) for r in rows], {
|
||||||
"title": "个人绩效工资明细表",
|
"title": EXPORT_TITLES["personal-detail"],
|
||||||
"row_count": total,
|
"row_count": total,
|
||||||
"filters": _filter_text(query),
|
"filters": _filter_text(session, query),
|
||||||
"summary": {
|
"summary": {
|
||||||
"performance_total": _q2(_dec(sums["performance_total"])),
|
"performance_total": _q2(_dec(sums["performance_total"])),
|
||||||
"payable_total": _q2(_dec(sums["payable_total"])),
|
"payable_total": _q2(_dec(sums["payable_total"])),
|
||||||
@@ -603,14 +1016,17 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
|
|||||||
}
|
}
|
||||||
if code == "department-summary":
|
if code == "department-summary":
|
||||||
data = department_summary(session, query)
|
data = department_summary(session, query)
|
||||||
member_total = sum(len(t["members"]) for t in data["teams"])
|
row_count = (
|
||||||
_check_export_limit(member_total)
|
len(data["teams"])
|
||||||
rows = _department_export_rows(data)
|
+ sum(len(team["members"]) for team in data["teams"])
|
||||||
return rows, {
|
+ len(data["unassigned"])
|
||||||
"title": "部门绩效工资汇总表",
|
)
|
||||||
"row_count": member_total,
|
_check_export_limit(row_count)
|
||||||
"filters": f"统计月份 {query.settlement_month}",
|
return [], {
|
||||||
"summary": data["company"],
|
"title": EXPORT_TITLES["department-summary"],
|
||||||
|
"row_count": row_count,
|
||||||
|
"filters": _filter_text(session, query),
|
||||||
|
"department": data,
|
||||||
"company_name": _company_name(session),
|
"company_name": _company_name(session),
|
||||||
}
|
}
|
||||||
if code == "business-stats":
|
if code == "business-stats":
|
||||||
@@ -621,9 +1037,9 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
|
|||||||
)
|
)
|
||||||
sums = report_repository.sum_business_stats(session, query)
|
sums = report_repository.sum_business_stats(session, query)
|
||||||
return [_business_row(r) for r in rows], {
|
return [_business_row(r) for r in rows], {
|
||||||
"title": "业务统计表",
|
"title": EXPORT_TITLES["business-stats"],
|
||||||
"row_count": total,
|
"row_count": total,
|
||||||
"filters": _filter_text(query),
|
"filters": _filter_text(session, query),
|
||||||
"summary": {
|
"summary": {
|
||||||
"ship_total": _q2(_dec(sums["ship_total"])),
|
"ship_total": _q2(_dec(sums["ship_total"])),
|
||||||
"received_total": _q2(_dec(sums["received_total"])),
|
"received_total": _q2(_dec(sums["received_total"])),
|
||||||
@@ -635,9 +1051,9 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
|
|||||||
data = payment_stats(session, query)
|
data = payment_stats(session, query)
|
||||||
rows = data["buckets"]
|
rows = data["buckets"]
|
||||||
return rows, {
|
return rows, {
|
||||||
"title": "回款情况统计表",
|
"title": EXPORT_TITLES["payment-stats"],
|
||||||
"row_count": len(rows),
|
"row_count": len(rows),
|
||||||
"filters": _filter_text(query),
|
"filters": _filter_text(session, query),
|
||||||
"summary": data["totals"],
|
"summary": data["totals"],
|
||||||
"buckets": data,
|
"buckets": data,
|
||||||
"company_name": _company_name(session),
|
"company_name": _company_name(session),
|
||||||
@@ -654,43 +1070,79 @@ def _check_export_limit(row_count: int) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _department_export_rows(data: dict) -> list[dict]:
|
def _filter_text(session, query) -> str:
|
||||||
"""部门汇总导出展平:每个成员一行,团队列冗余(便于签字稿核对)。"""
|
"""筛选条件摘要(导出页眉展示;未设置项不展示;ID 转名称)。
|
||||||
rows = []
|
|
||||||
for team in data["teams"]:
|
|
||||||
for member in team["members"]:
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"manager_name": team["manager_name"],
|
|
||||||
"member_name": member["personnel_name"],
|
|
||||||
"role_code": member["role_code"],
|
|
||||||
"performance_sum": member["performance_sum"],
|
|
||||||
"payable_sum": member["payable_sum"],
|
|
||||||
"adjust_net": member["adjust_net"],
|
|
||||||
"net_total": member["net_total"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
页眉是签字稿追溯筛选范围的唯一凭据:漏掉人员/经理/客户/归属人/
|
||||||
def _filter_text(query) -> str:
|
状态会让复核者把筛选结果当成全量数据。
|
||||||
"""筛选条件摘要(导出页眉展示;未设置项不展示)。"""
|
"""
|
||||||
|
personnel_ids = {
|
||||||
|
value
|
||||||
|
for field in ("personnel_id", "manager_id", "owner_personnel_id")
|
||||||
|
if (value := getattr(query, field, None)) not in (None, "")
|
||||||
|
}
|
||||||
|
personnel_names = _personnel_names(session, personnel_ids)
|
||||||
parts = []
|
parts = []
|
||||||
for field, label in (
|
for field, label in (
|
||||||
("settlement_month", "统计月份"),
|
("settlement_month", "统计月份"),
|
||||||
|
("personnel_id", "人员"),
|
||||||
|
("manager_id", "经理"),
|
||||||
|
("customer_id", "客户"),
|
||||||
|
("owner_personnel_id", "归属人"),
|
||||||
("customer_type", "客户类型"),
|
("customer_type", "客户类型"),
|
||||||
("product_category", "产品类别"),
|
("product_category", "产品类别"),
|
||||||
("role_code", "岗位"),
|
("role_code", "岗位"),
|
||||||
|
("status", "状态"),
|
||||||
("keyword", "关键字"),
|
("keyword", "关键字"),
|
||||||
("date_from", "日期起"),
|
("date_from", "日期起"),
|
||||||
("date_to", "日期止"),
|
("date_to", "日期止"),
|
||||||
):
|
):
|
||||||
value = getattr(query, field, None)
|
value = getattr(query, field, None)
|
||||||
if value:
|
if value in (None, ""):
|
||||||
|
continue
|
||||||
|
if field in ("personnel_id", "manager_id", "owner_personnel_id"):
|
||||||
|
name = personnel_names.get(value)
|
||||||
|
parts.append(f"{label} {name}(#{value})" if name else f"{label} #{value}")
|
||||||
|
elif field == "customer_id":
|
||||||
|
name = _customer_name(session, value)
|
||||||
|
parts.append(f"{label} {name}(#{value})" if name else f"{label} #{value}")
|
||||||
|
elif field == "customer_type":
|
||||||
|
parts.append(f"{label} {CUSTOMER_TYPE_LABELS.get(value, value)}")
|
||||||
|
elif field == "product_category":
|
||||||
|
parts.append(f"{label} {PRODUCT_CATEGORY_LABELS.get(value, value)}")
|
||||||
|
elif field == "role_code":
|
||||||
|
parts.append(f"{label} {_role_name_by_code(session, value) or value}")
|
||||||
|
elif field == "status":
|
||||||
|
parts.append(f"{label} {BUSINESS_STATUS_LABELS.get(value, value)}")
|
||||||
|
else:
|
||||||
parts.append(f"{label} {value}")
|
parts.append(f"{label} {value}")
|
||||||
return " | ".join(parts) or "全部"
|
return " | ".join(parts) or "全部"
|
||||||
|
|
||||||
|
|
||||||
|
def _personnel_names(session, ids: set[int]) -> dict[int, str]:
|
||||||
|
"""批量取人员姓名;查不到时由调用方回退展示 #id。"""
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
rows = session.execute(
|
||||||
|
select(Personnel.id, Personnel.personnel_name).where(Personnel.id.in_(ids))
|
||||||
|
).all()
|
||||||
|
return dict(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _customer_name(session, customer_id: int) -> str | None:
|
||||||
|
"""客户名称(导出页眉 ID→名称)。"""
|
||||||
|
return session.execute(
|
||||||
|
select(Customer.company_name).where(Customer.id == customer_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def _role_name_by_code(session, role_code: str) -> str | None:
|
||||||
|
"""岗位名称(导出页眉展示中文,而不是内部编码)。"""
|
||||||
|
return session.execute(
|
||||||
|
select(BusinessRole.role_name).where(BusinessRole.role_code == role_code)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
def _company_name(session) -> str:
|
def _company_name(session) -> str:
|
||||||
"""导出页眉公司名(未配置时 system_setting_service 返回默认「衍水集团」)。"""
|
"""导出页眉公司名(未配置时 system_setting_service 返回默认「衍水集团」)。"""
|
||||||
from app.services import system_setting_service
|
from app.services import system_setting_service
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
"""系统设置(公司信息)业务编排。
|
"""系统设置(公司信息 + 站点信息)业务编排。
|
||||||
|
|
||||||
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径**
|
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo/favicon 只存**相对
|
||||||
(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次
|
路径**(uploads/logo、uploads/favicon 下固定名覆盖),文件不入库的理由:
|
||||||
读取与备份放大传输,且无法独立替换;DB 只持有一行指针。
|
图片进数据库会让每次读取与备份放大传输,且无法独立替换;DB 只持有指针。
|
||||||
|
|
||||||
logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
|
图像安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
|
||||||
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
|
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
|
||||||
覆盖任意文件。
|
覆盖任意文件。
|
||||||
|
|
||||||
|
logo 与 favicon 相互独立(2026-10-20):
|
||||||
|
- logo 用于登录页品牌位;
|
||||||
|
- favicon 用于浏览器标签页图标,未上传时前端使用内置 favicon.svg
|
||||||
|
(不再跟随 logo——公司 logo 是横版,作标签页图标常被裁切)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -24,15 +29,26 @@ from app.services.audit_service import AuditRecord, RequestMeta
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# magic bytes:PNG 固定 8 字节签名;JPEG 以 SOI + APP0/JFIF 起始
|
# magic bytes:PNG 8 字节签名;JPEG 以 SOI 起始;ICO 以 00 00 01 00 起始
|
||||||
_MAGIC = {
|
_MAGIC = {
|
||||||
"png": b"\x89PNG\r\n\x1a\n",
|
"png": b"\x89PNG\r\n\x1a\n",
|
||||||
"jpg": b"\xff\xd8\xff",
|
"jpg": b"\xff\xd8\xff",
|
||||||
|
"ico": b"\x00\x00\x01\x00",
|
||||||
}
|
}
|
||||||
LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用
|
# 允许的后缀集合:logo 沿用 png/jpg(页眉/登录页展示);favicon 额外接受 ico
|
||||||
|
LOGO_EXTS = ("png", "jpg")
|
||||||
|
FAVICON_EXTS = ("png", "jpg", "ico")
|
||||||
|
ASSET_MAX_BYTES = 2 * 1024 * 1024 # 2MB:展示尺寸上限,防上传滥用
|
||||||
|
|
||||||
SETTINGS_ID = 1 # 全系统单行主键
|
SETTINGS_ID = 1 # 全系统单行主键
|
||||||
|
|
||||||
|
# 公司档案中的可选文本字段:提交空串等价清空(落库 null,前端回退内置默认)
|
||||||
|
_OPTIONAL_TEXT_FIELDS = ("site_title", "brand_slogan", "footer_text", "icp_no")
|
||||||
|
|
||||||
|
# 资源种类 → 中文名(错误提示与审计摘要用)
|
||||||
|
_ASSET_LABELS = {"logo": "logo", "favicon": "站点图标"}
|
||||||
|
_ASSET_EXTS = {"logo": LOGO_EXTS, "favicon": FAVICON_EXTS}
|
||||||
|
|
||||||
|
|
||||||
def get_setting(session: Session) -> SystemSetting:
|
def get_setting(session: Session) -> SystemSetting:
|
||||||
"""取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。"""
|
"""取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。"""
|
||||||
@@ -45,10 +61,15 @@ def get_setting(session: Session) -> SystemSetting:
|
|||||||
|
|
||||||
|
|
||||||
def to_out(setting: SystemSetting) -> SettingOut:
|
def to_out(setting: SystemSetting) -> SettingOut:
|
||||||
"""管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。"""
|
"""管理端出参(图片地址带更新时间版本参数,替换后浏览器不再用旧缓存)。"""
|
||||||
return SettingOut(
|
return SettingOut(
|
||||||
company_name=setting.company_name,
|
company_name=setting.company_name,
|
||||||
logo_url=_logo_url(setting),
|
logo_url=_asset_url(setting, "logo"),
|
||||||
|
site_title=setting.site_title,
|
||||||
|
brand_slogan=setting.brand_slogan,
|
||||||
|
footer_text=setting.footer_text,
|
||||||
|
icp_no=setting.icp_no,
|
||||||
|
favicon_url=_asset_url(setting, "favicon"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -56,15 +77,26 @@ def to_public_out(setting: SystemSetting) -> SettingPublicOut:
|
|||||||
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
|
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
|
||||||
return SettingPublicOut(
|
return SettingPublicOut(
|
||||||
company_name=setting.company_name,
|
company_name=setting.company_name,
|
||||||
logo_url=_logo_url(setting),
|
logo_url=_asset_url(setting, "logo"),
|
||||||
|
site_title=setting.site_title,
|
||||||
|
brand_slogan=setting.brand_slogan,
|
||||||
|
footer_text=setting.footer_text,
|
||||||
|
icp_no=setting.icp_no,
|
||||||
|
favicon_url=_asset_url(setting, "favicon"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _logo_url(setting: SystemSetting) -> str | None:
|
def _asset_url(setting: SystemSetting, kind: str) -> str | None:
|
||||||
if not setting.logo_path:
|
"""资源 URL(kind=logo/favicon);未上传返回 None。
|
||||||
|
|
||||||
|
版本号取**上传计数器**而非 update_time:同扩展名替换时固定文件名不变
|
||||||
|
(logo_path 值不变),MySQL 的 ON UPDATE 不会刷新 update_time,用它做
|
||||||
|
?v= 会导致浏览器一直用旧图(2026-10-20 用户反馈的换图不刷新问题)。
|
||||||
|
"""
|
||||||
|
if not getattr(setting, f"{kind}_path"):
|
||||||
return None
|
return None
|
||||||
version = int(setting.update_time.timestamp()) if setting.update_time else 0
|
version = getattr(setting, f"{kind}_version") or 0
|
||||||
return f"/api/v1/system/settings/logo/public?v={version}"
|
return f"/api/v1/system/settings/{kind}/public?v={version}"
|
||||||
|
|
||||||
|
|
||||||
def update_company(
|
def update_company(
|
||||||
@@ -74,20 +106,34 @@ def update_company(
|
|||||||
operator: User,
|
operator: User,
|
||||||
meta: RequestMeta | None = None,
|
meta: RequestMeta | None = None,
|
||||||
) -> SettingOut:
|
) -> SettingOut:
|
||||||
"""更新公司名称(审计留痕)。"""
|
"""更新公司信息与站点信息(部分更新 + 审计留痕)。
|
||||||
|
|
||||||
|
仅提交的字段生效(``exclude_unset``:未提交/显式 null 保持原值),
|
||||||
|
便于「只改名称」「只改站点标题」等场景不覆盖其他字段;可选文本传
|
||||||
|
空串等价清空(落库 null)。
|
||||||
|
"""
|
||||||
|
changes = payload.model_dump(exclude_unset=True, exclude_none=True)
|
||||||
|
summary = "修改公司信息"
|
||||||
|
if "company_name" in changes:
|
||||||
|
summary = f"修改公司信息(公司名称「{changes['company_name']}」)"
|
||||||
with audit_service.audit(
|
with audit_service.audit(
|
||||||
session,
|
session,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
record=AuditRecord(
|
record=AuditRecord(
|
||||||
module=MODULE_SYSTEM_SETTINGS,
|
module=MODULE_SYSTEM_SETTINGS,
|
||||||
action=ACTION_UPDATE,
|
action=ACTION_UPDATE,
|
||||||
summary=f"修改公司名称为「{payload.company_name}」",
|
summary=summary,
|
||||||
request_data=payload.model_dump(),
|
request_data=changes or None,
|
||||||
),
|
),
|
||||||
meta=meta,
|
meta=meta,
|
||||||
):
|
):
|
||||||
setting = get_setting(session)
|
setting = get_setting(session)
|
||||||
setting.company_name = payload.company_name.strip()
|
if "company_name" in changes:
|
||||||
|
# schema 已保证提交值非空(min_length=1)
|
||||||
|
setting.company_name = changes["company_name"]
|
||||||
|
for field in _OPTIONAL_TEXT_FIELDS:
|
||||||
|
if field in changes:
|
||||||
|
setattr(setting, field, changes[field].strip() or None)
|
||||||
session.commit()
|
session.commit()
|
||||||
return to_out(setting)
|
return to_out(setting)
|
||||||
|
|
||||||
@@ -100,17 +146,39 @@ def save_logo(
|
|||||||
operator: User,
|
operator: User,
|
||||||
meta: RequestMeta | None = None,
|
meta: RequestMeta | None = None,
|
||||||
) -> SettingOut:
|
) -> SettingOut:
|
||||||
"""上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
|
"""上传公司 logo(登录页品牌位;png/jpg ≤2MB)。"""
|
||||||
|
return _save_asset(session, content, ext, kind="logo", operator=operator, meta=meta)
|
||||||
|
|
||||||
|
|
||||||
|
def save_favicon(
|
||||||
|
session: Session,
|
||||||
|
content: bytes,
|
||||||
|
ext: str,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> SettingOut:
|
||||||
|
"""上传站点图标(浏览器标签页;png/jpg/ico ≤2MB)。"""
|
||||||
|
return _save_asset(session, content, ext, kind="favicon", operator=operator, meta=meta)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_asset(
|
||||||
|
session: Session,
|
||||||
|
content: bytes,
|
||||||
|
ext: str,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None,
|
||||||
|
) -> SettingOut:
|
||||||
|
"""校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
|
||||||
|
|
||||||
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
|
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
|
||||||
"""
|
"""
|
||||||
|
label = _ASSET_LABELS[kind]
|
||||||
|
allowed = _ASSET_EXTS[kind]
|
||||||
ext = ext.lower()
|
ext = ext.lower()
|
||||||
if ext not in _MAGIC:
|
_validate_upload(content, ext, allowed=allowed, label=label)
|
||||||
raise BadRequestError("logo 仅支持 png/jpg 格式")
|
|
||||||
if len(content) > LOGO_MAX_BYTES:
|
|
||||||
raise BadRequestError("logo 文件不能超过 2MB")
|
|
||||||
if not content.startswith(_MAGIC[ext]):
|
|
||||||
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
|
|
||||||
|
|
||||||
with audit_service.audit(
|
with audit_service.audit(
|
||||||
session,
|
session,
|
||||||
@@ -118,54 +186,79 @@ def save_logo(
|
|||||||
record=AuditRecord(
|
record=AuditRecord(
|
||||||
module=MODULE_SYSTEM_SETTINGS,
|
module=MODULE_SYSTEM_SETTINGS,
|
||||||
action=ACTION_UPDATE,
|
action=ACTION_UPDATE,
|
||||||
summary="更新公司 logo",
|
summary=f"更新{label}",
|
||||||
request_data={"ext": ext, "size": len(content)},
|
request_data={"ext": ext, "size": len(content)},
|
||||||
),
|
),
|
||||||
meta=meta,
|
meta=meta,
|
||||||
):
|
):
|
||||||
setting = get_setting(session)
|
setting = get_setting(session)
|
||||||
old_path = setting.logo_path
|
old_path = getattr(setting, f"{kind}_path")
|
||||||
|
|
||||||
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
|
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
|
||||||
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
|
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
|
||||||
# 由写入覆盖,无需也不会误删新文件
|
# 由写入覆盖,无需也不会误删新文件
|
||||||
target = _logo_dir() / f"logo.{ext}"
|
target = _asset_dir(kind) / f"{kind}.{ext}"
|
||||||
target.write_bytes(content)
|
target.write_bytes(content)
|
||||||
|
|
||||||
setting.logo_path = f"logo/logo.{ext}"
|
setattr(setting, f"{kind}_path", f"{kind}/{kind}.{ext}")
|
||||||
|
# 版本号每次上传必增:即使同扩展名覆盖(路径不变)也能破浏览器缓存
|
||||||
|
setattr(setting, f"{kind}_version", (getattr(setting, f"{kind}_version") or 0) + 1)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||||
if old_ext and old_ext != ext:
|
if old_ext and old_ext != ext and old_ext in allowed:
|
||||||
try:
|
try:
|
||||||
_cleanup_old_logo(old_ext)
|
_cleanup_old_asset(kind, old_ext)
|
||||||
except OSError:
|
except OSError:
|
||||||
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
|
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
|
||||||
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反
|
# 成功的更新会被记成失败审计并 500 —— 审计与事实相反
|
||||||
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True)
|
logger.warning(
|
||||||
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
|
"旧%s清理失败(不影响本次更新)| ext=%s", label, old_ext, exc_info=True
|
||||||
|
)
|
||||||
|
logger.info("更新%s | size=%s ext=%s", label, len(content), ext)
|
||||||
return to_out(setting)
|
return to_out(setting)
|
||||||
|
|
||||||
|
|
||||||
def read_logo(session: Session) -> tuple[Path, str]:
|
def read_logo(session: Session) -> tuple[Path, str]:
|
||||||
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
|
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
|
||||||
|
return _read_asset(session, "logo")
|
||||||
|
|
||||||
|
|
||||||
|
def read_favicon(session: Session) -> tuple[Path, str]:
|
||||||
|
"""按库中记录定位站点图标文件,返回 (路径, 扩展名)。未上传抛 404。"""
|
||||||
|
return _read_asset(session, "favicon")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_asset(session: Session, kind: str) -> tuple[Path, str]:
|
||||||
|
label = _ASSET_LABELS[kind]
|
||||||
setting = get_setting(session)
|
setting = get_setting(session)
|
||||||
if not setting.logo_path:
|
path_str = getattr(setting, f"{kind}_path")
|
||||||
raise NotFoundError("尚未上传 logo")
|
if not path_str:
|
||||||
path = get_settings().upload_dir / setting.logo_path
|
raise NotFoundError(f"尚未上传{label}")
|
||||||
|
path = get_settings().upload_dir / path_str
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise NotFoundError("logo 文件不存在,请重新上传")
|
raise NotFoundError(f"{label}文件不存在,请重新上传")
|
||||||
return path, path.suffix.lstrip(".")
|
return path, path.suffix.lstrip(".")
|
||||||
|
|
||||||
|
|
||||||
def _logo_dir() -> Path:
|
def _validate_upload(content: bytes, ext: str, *, allowed: tuple[str, ...], label: str) -> None:
|
||||||
directory = get_settings().upload_dir / "logo"
|
"""后缀白名单 + magic bytes + 大小三重校验(同口径供 logo/favicon 复用)。"""
|
||||||
|
if ext not in allowed:
|
||||||
|
raise BadRequestError(f"{label}仅支持 {'/'.join(allowed)} 格式")
|
||||||
|
if len(content) > ASSET_MAX_BYTES:
|
||||||
|
raise BadRequestError(f"{label}文件不能超过 2MB")
|
||||||
|
if not content.startswith(_MAGIC[ext]):
|
||||||
|
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_dir(kind: str) -> Path:
|
||||||
|
directory = get_settings().upload_dir / kind
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
return directory
|
return directory
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_old_logo(old_ext: str) -> None:
|
def _cleanup_old_asset(kind: str, old_ext: str) -> None:
|
||||||
if old_ext in _MAGIC:
|
if old_ext in _ASSET_EXTS[kind]:
|
||||||
old_file = _logo_dir() / f"logo.{old_ext}"
|
old_file = _asset_dir(kind) / f"{kind}.{old_ext}"
|
||||||
if old_file.exists():
|
if old_file.exists():
|
||||||
old_file.unlink(missing_ok=True)
|
old_file.unlink(missing_ok=True)
|
||||||
+33
-7
@@ -140,12 +140,24 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
|
|
||||||
| 文件 | 守什么 | 用例数 |
|
| 文件 | 守什么 | 用例数 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、12.5% 分成按参数、无成员经理出 0 值行;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**(防翻倍)、绩效合计=台账;业务统计排除经理聚合行(生成月结后绩效不被整团队聚合额污染);导出 xlsx 可解析且含「财务复核/领导签字」栏 + 操作日志留痕(module=报表统计/action=导出);普通用户 403 且文案「无权访问报表统计」 | 7 |
|
| `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` | 区间分桶边界(NULL/负值/0/1/30/…/361+)与 8 档编码唯一;客户类型、产品类别标签覆盖全部常量枚举(防新增枚举后报表少一类);Excel 模板纯构建含页眉/签字栏、空数据提示行、金额为数值单元格且两位格式、合计行=明细之和、文本公式注入前置单引号、回款统计两个维度对比 sheet、业务统计状态转中文 | 11 |
|
| `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` → **7 passed**(2026-10-19);`uv run pytest tests/unit/test_report_helpers.py -q` → **11 passed**(2026-10-19);全量回归见运行记录。
|
运行:`uv run pytest tests/integration/test_reports.py -q` → **27 passed**(2026-10-20 复核);`uv run pytest tests/unit/test_report_helpers.py tests/unit/test_month_validation.py -q` → **34 passed**;全量 **862 passed**(2026-10-20,11:32)。
|
||||||
|
|
||||||
**待补**:e2e `frontend/e2e/report_flow.py`(四页筛选/导出下载/打印视图,需 `E2E_ADMIN_PASSWORD`)。
|
**e2e 实跑(2026-10-20)**:`frontend/e2e/report_flow.py` 12/12 通过。回款统计改为展开目标桶后按**本次业务编号**定位下钻行(不依赖桶累计值,可重复运行);另覆盖多桶同时展开、部门下钻筛选、打印截断提示、部门汇总打印五块结构。
|
||||||
|
|
||||||
|
### 工作台(M4 ⑧,2026-10-19 完成)
|
||||||
|
|
||||||
|
| 文件 | 守什么 | 用例数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志;**待办按角色过滤(财务/领导/多角色/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)。
|
||||||
|
|
||||||
## 三、运行记录
|
## 三、运行记录
|
||||||
|
|
||||||
@@ -167,6 +179,11 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) |
|
| 2026-10-19 | 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 | 全量回归 | 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 | 拆行收口批次 | 全量 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 | 单元全量 + 报表定点 | 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 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
|
||||||
|
|
||||||
@@ -174,7 +191,16 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
|
|
||||||
| 日期 | 缺陷 | 影响 | 处理 |
|
| 日期 | 缺陷 | 影响 | 处理 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 2026-10-19 | 分摊负责人份额按余额百分数乘全额导致行合计丢 q2 尾差 | 拆 30%+40% 后负责人 100−70=30% 再乘未 q2 全额(39.1111×0.30=11.7333),行合计 39.10 ≠ 39.11——**每笔分摊账丢 1 分**,守恒破坏(金标准用例 `行合计==业务级全额` 抓住) | 负责人行恒走余额公式(q2 全额 −∑参与人 q2 份额);余额百分数仅作快照展示不参与计算 |
|
| 2026-10-19 | 部门汇总导出/打印只展平成员行 | 团队分成、全公司 5% 块、未分配人员、零成员团队全部缺失——**签字稿合计小于应发**,财务按签字稿复核会漏发经理分成与未分配绩效 | 导出改为单 sheet 分块签字稿(团队/公司/对账/成员/未分配)+ 打印五块;应发公式与对账块由后端 Decimal 计算 |
|
||||||
|
| 2026-10-19 | `_member_rows` 直接 `member_map[pid]` 索引 | 无台账行的营销经理自身挂上级时 KeyError → **部门汇总接口与导出 500**(当月完全无台账也会触发) | 归属只从 member_map/adjustments 生成;`manager_role` 覆盖所有成员引用的上级 |
|
||||||
|
| 2026-10-19 | 生效调整单只并入有台账人员 | 只有调整单、当月无回款的人被静默丢弃——补发/扣减金额不出现,**按报表发放会少发** | 调整单人员补零值成员后参与团队/未分配归属;每人调整净额只计一次 |
|
||||||
|
| 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 |
|
||||||
|
| 2026-10-19 | 导出取数与超限校验在审计上下文之外 | 超限 400 与取数异常**不留失败日志**,与 M4 计划 T12「导出 400/失败审计」不符 | `_export_payload` 移入 `with audit`;静态标题映射(AuditRecord frozen) |
|
||||||
|
| 2026-10-19 | 月份只校验长度 | `2026-13` 传到 `calendar.monthrange` 抛 ValueError → **500**;导出手工构造模型抛裸 ValidationError 同样 500 | 新增 `core/month.py`(格式+01-12+年份);四类 query、月结、调整单接入;导出 ValidationError 转 400 |
|
||||||
|
| 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 |
|
||||||
|
| 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 |
|
||||||
|
| 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 |
|
||||||
|
| 2026-10-19 | 工作台待办未按角色过滤 | 财务看得到「待领导审核」、领导看得到「待财务复核」,与 FR-034「仅看到与自己职责相关」不符 | `pending_statuses_for_roles` 按角色映射在途节点;repository 参数化 statuses;admin 保留全部在途视角 |
|
||||||
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
|
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
|
||||||
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
|
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
|
||||||
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
|
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
|
||||||
@@ -228,7 +254,7 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
||||||
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||||
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
||||||
| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例。**待补**:e2e `calc_settlement_flow`(台账/月结两页真实浏览器链路);业务答复回填批次(88 会话:首日 10‰/聚合基数开关/二次分摊拆行)落地后按同粒度补测 |
|
| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例;2026-10-20 e2e `calc_settlement_flow` 8/8 实跑通过(台账/月结真实浏览器链路) |
|
||||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
| 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;2026-10-20 e2e `report_flow` 12/12 实跑通过,全量 862 passed |
|
||||||
|
|
||||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import delete, select, update
|
from sqlalchemy import delete, select, update
|
||||||
@@ -37,6 +36,7 @@ from app.models import (
|
|||||||
BusinessRole,
|
BusinessRole,
|
||||||
BusinessStatusLog,
|
BusinessStatusLog,
|
||||||
Customer,
|
Customer,
|
||||||
|
CustomerRating,
|
||||||
CustomerRatingGrade,
|
CustomerRatingGrade,
|
||||||
MonthlySettlement,
|
MonthlySettlement,
|
||||||
OperationLog,
|
OperationLog,
|
||||||
@@ -164,6 +164,10 @@ def _purge_test_data() -> None:
|
|||||||
session.execute(
|
session.execute(
|
||||||
delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids))
|
delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids))
|
||||||
)
|
)
|
||||||
|
# 评级行 FK RESTRICT:必须先于客户删除(V7 起客户评级每客户一行)
|
||||||
|
session.execute(
|
||||||
|
delete(CustomerRating).where(CustomerRating.customer_id.in_(customer_ids))
|
||||||
|
)
|
||||||
session.execute(delete(Customer).where(Customer.id.in_(customer_ids)))
|
session.execute(delete(Customer).where(Customer.id.in_(customer_ids)))
|
||||||
# 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条
|
# 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条
|
||||||
session.execute(
|
session.execute(
|
||||||
@@ -190,32 +194,103 @@ def _purge_test_data() -> None:
|
|||||||
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
||||||
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
||||||
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
||||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 25
|
# 开关参数恢复种子基线:测试提交的变更批次由 t_ 前缀测试账号创建,
|
||||||
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
|
# 按**批次操作人**删除本轮的参数条目与批次,解析后回退到种子值(开关=0)。
|
||||||
# 插入两个审批开关的 10-19 生效行 id 23/24,均属种子),删新条目与
|
# 不用 id 阈值:种子条目的主键在不同环境的漂移库中并不相同(历史库
|
||||||
# 新批次后解析回退到种子值
|
# 可能补过种子行),按操作人识别测试产物与环境数据分布无关。
|
||||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 25)).scalars())
|
test_batch_ids = list(
|
||||||
if stray_values:
|
session.execute(
|
||||||
stray_batches = {v.batch_id for v in stray_values}
|
select(ParamChangeBatch.id).where(ParamChangeBatch.operator_name.like("t\\_%"))
|
||||||
session.execute(delete(ParamValue).where(ParamValue.id > 25))
|
).scalars()
|
||||||
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
|
|
||||||
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
|
|
||||||
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
|
|
||||||
session.execute(
|
|
||||||
update(SystemSetting)
|
|
||||||
.where(SystemSetting.id == 1)
|
|
||||||
.values(company_name="衍水集团", logo_path=None)
|
|
||||||
)
|
)
|
||||||
|
if test_batch_ids:
|
||||||
|
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(test_batch_ids)))
|
||||||
|
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(test_batch_ids)))
|
||||||
session.commit()
|
session.commit()
|
||||||
logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo"
|
|
||||||
if logo_dir.is_dir():
|
|
||||||
for f in logo_dir.iterdir():
|
# ==================== 系统设置与上传文件的快照还原 ====================
|
||||||
f.unlink(missing_ok=True)
|
#
|
||||||
|
# 系统设置是**真实单行数据**,logo/favicon 是真实上传文件,没有 t_ 前缀可供
|
||||||
|
# 兜底清理。测试(尤其 logo 上传用例)会改公司名、覆盖/删除真实图片 ——
|
||||||
|
# 必须在用例结束后还原,否则跑一次测试就会破坏开发环境的品牌与 logo。
|
||||||
|
|
||||||
|
_ASSET_KINDS = ("logo", "favicon")
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_company_info() -> dict | None:
|
||||||
|
"""快照系统设置单行(无行时返回 None)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
row = session.get(SystemSetting, 1)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {column.name: getattr(row, column.name) for column in SystemSetting.__table__.columns}
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_company_info(snapshot: dict | None) -> None:
|
||||||
|
if snapshot is None:
|
||||||
|
return
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
row = session.get(SystemSetting, 1)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
for key, value in snapshot.items():
|
||||||
|
if key != "update_time": # 更新时间由数据库 ON UPDATE 维护
|
||||||
|
setattr(row, key, value)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_assets() -> dict[str, dict[str, bytes]]:
|
||||||
|
"""快照上传目录里的真实图片(logo/favicon)。"""
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
root = get_settings().upload_dir
|
||||||
|
snapshot: dict[str, dict[str, bytes]] = {}
|
||||||
|
for kind in _ASSET_KINDS:
|
||||||
|
directory = root / kind
|
||||||
|
snapshot[kind] = (
|
||||||
|
{path.name: path.read_bytes() for path in directory.glob("*") if path.is_file()}
|
||||||
|
if directory.is_dir()
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_assets(snapshot: dict[str, dict[str, bytes]]) -> None:
|
||||||
|
"""还原上传目录:删除测试新增文件、回写被测试覆盖的原文件。"""
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
root = get_settings().upload_dir
|
||||||
|
for kind, files in snapshot.items():
|
||||||
|
directory = root / kind
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
for path in directory.glob("*"):
|
||||||
|
if path.is_file() and path.name not in files:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
for name, content in files.items():
|
||||||
|
(directory / name).write_bytes(content)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _purge_after_each_test():
|
def _preserve_company_info():
|
||||||
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。"""
|
"""每个用例前快照系统设置与上传图片,结束后还原(见上方说明)。"""
|
||||||
|
info = _snapshot_company_info()
|
||||||
|
assets = _snapshot_assets()
|
||||||
|
yield
|
||||||
|
# 先还原文件:数据库路径随后回指同一批文件
|
||||||
|
_restore_assets(assets)
|
||||||
|
_restore_company_info(info)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _purge_after_each_test(_preserve_company_info):
|
||||||
|
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。
|
||||||
|
|
||||||
|
依赖 ``_preserve_company_info``:保证快照在该夹具之前建立、在该夹具
|
||||||
|
清理**之后**还原(夹具 teardown 为逆序)。
|
||||||
|
"""
|
||||||
yield
|
yield
|
||||||
_purge_test_data()
|
_purge_test_data()
|
||||||
|
|
||||||
|
|||||||
@@ -147,9 +147,18 @@ class TestBuiltinProtection:
|
|||||||
|
|
||||||
|
|
||||||
class TestPermission:
|
class TestPermission:
|
||||||
"""权限:普通用户全部 403(当前默认管理员专用)。"""
|
"""权限:管理接口普通用户 403;只读选项登录即可(报表筛选依赖)。"""
|
||||||
|
|
||||||
def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None:
|
def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None:
|
||||||
user = make_user([ROLE_USER])
|
user = make_user([ROLE_USER])
|
||||||
headers = auth_headers(user)
|
headers = auth_headers(user)
|
||||||
assert client.get(_ROLES, headers=headers).status_code == 403
|
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
|
||||||
@@ -11,6 +11,7 @@ append-only 完整性;「公司名称+负责人」组合唯一缺失会放行
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -376,3 +377,104 @@ class TestAudit:
|
|||||||
latest = logs[0]
|
latest = logs[0]
|
||||||
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
||||||
assert "→" in latest.summary, "摘要应体现新旧归属走向"
|
assert "→" in latest.summary, "摘要应体现新旧归属走向"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 客户评级列(V11) ====================
|
||||||
|
|
||||||
|
_RATING_KEYS = (
|
||||||
|
"score_ship_amount",
|
||||||
|
"score_receipt_rate",
|
||||||
|
"score_profit_rate",
|
||||||
|
"score_contract_cooperation",
|
||||||
|
"score_site_construction",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate(client: TestClient, headers: dict, customer_id: int, scores: list[str]) -> dict:
|
||||||
|
"""保存客户评级(五项 0-20),返回响应 data。"""
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer_id}/rating",
|
||||||
|
json=dict(zip(_RATING_KEYS, scores, strict=True)),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _list_by_keyword(client: TestClient, headers: dict, keyword: str, **params) -> list[dict]:
|
||||||
|
query: dict = {"keyword": keyword, "pageNum": 1, "pageSize": 50, **params}
|
||||||
|
resp = client.get(_CUSTOMERS, params=query, headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]["list"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRatingColumn:
|
||||||
|
"""客户评级列:列表带出评级快照,列头排序走服务端(未评级恒在末尾)。
|
||||||
|
|
||||||
|
列表是服务端分页,排序必须在 SQL 层完成 —— 前端只传
|
||||||
|
``order_field=rating`` + ``order_type``;MySQL 无 NULLS LAST,
|
||||||
|
未评级行用布尔键模拟,正序/倒序都在末尾。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _seed_three(self, client: TestClient, headers: dict) -> tuple[str, dict, dict, dict]:
|
||||||
|
"""建三名同批次客户:90 分(A)、0 分(D)、未评级。"""
|
||||||
|
token = uuid.uuid4().hex[:8]
|
||||||
|
keyword = f"t_公司_{token}"
|
||||||
|
high = _create_customer(client, headers, company_name=f"{keyword}_甲")
|
||||||
|
low = _create_customer(client, headers, company_name=f"{keyword}_乙")
|
||||||
|
none = _create_customer(client, headers, company_name=f"{keyword}_丙")
|
||||||
|
_rate(client, headers, high["id"], ["18"] * 5)
|
||||||
|
_rate(client, headers, low["id"], ["0"] * 5)
|
||||||
|
return keyword, high, low, none
|
||||||
|
|
||||||
|
def test_列表带出评级字段(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
keyword, high, low, none = self._seed_three(client, headers)
|
||||||
|
rows = _list_by_keyword(client, headers, keyword)
|
||||||
|
by_id = {row["id"]: row for row in rows}
|
||||||
|
|
||||||
|
assert Decimal(by_id[high["id"]]["rating_total"]) == Decimal("90")
|
||||||
|
assert by_id[high["id"]]["rating_grade_name"] == "A"
|
||||||
|
assert Decimal(by_id[low["id"]]["rating_total"]) == Decimal("0")
|
||||||
|
assert by_id[low["id"]]["rating_grade_name"] == "D"
|
||||||
|
assert by_id[none["id"]]["rating_total"] is None
|
||||||
|
assert by_id[none["id"]]["rating_grade_name"] is None
|
||||||
|
|
||||||
|
def test_评级列正序倒序_未评级恒末尾(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
keyword, high, low, none = self._seed_three(client, headers)
|
||||||
|
|
||||||
|
desc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="desc")
|
||||||
|
assert [row["id"] for row in desc] == [high["id"], low["id"], none["id"]]
|
||||||
|
|
||||||
|
asc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="asc")
|
||||||
|
assert [row["id"] for row in asc] == [low["id"], high["id"], none["id"]]
|
||||||
|
|
||||||
|
def test_非法排序字段_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""排序字段是白名单(Literal):不透传给 SQL,非法值 400。"""
|
||||||
|
resp = client.get(
|
||||||
|
_CUSTOMERS,
|
||||||
|
params={"order_field": "company_name", "order_type": "asc"},
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
def test_非法排序方向_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.get(
|
||||||
|
_CUSTOMERS,
|
||||||
|
params={"order_field": "rating", "order_type": "sideways"},
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
def test_删除客户联动删评级行(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""评级行 FK RESTRICT:删除客户须先清评级行,否则 500。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _create_customer(client, headers)
|
||||||
|
_rate(client, headers, customer["id"], ["20"] * 5)
|
||||||
|
|
||||||
|
resp = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert (
|
||||||
|
client.get(f"{_CUSTOMERS}/{customer['id']}/rating", headers=headers).status_code == 404
|
||||||
|
)
|
||||||
@@ -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:
|
class TestCatalog:
|
||||||
"""参数目录:种子完整性与时点化回溯。"""
|
"""参数目录:种子完整性与时点化回溯。"""
|
||||||
|
|
||||||
def test_目录共19项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
|
def test_目录共22项且覆盖九个分组(self, client: TestClient, admin: dict) -> None:
|
||||||
"""V7 起含调整单审批开关(16 计提项 + 3 BOOL 开关)。"""
|
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
|
||||||
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||||
assert len(items) == 19
|
assert len(items) == 22
|
||||||
groups = {item["param_group"] for item in items}
|
groups = {item["param_group"] for item in items}
|
||||||
assert groups == {
|
assert groups == {
|
||||||
"业务员个人户",
|
"业务员个人户",
|
||||||
@@ -100,6 +100,7 @@ class TestCatalog:
|
|||||||
"产品阈值",
|
"产品阈值",
|
||||||
"月工资口径",
|
"月工资口径",
|
||||||
"审批开关",
|
"审批开关",
|
||||||
|
"预警规则",
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
||||||
@@ -444,7 +445,7 @@ class TestPermission:
|
|||||||
headers = auth_headers(user)
|
headers = auth_headers(user)
|
||||||
resp = client.get(_PARAMS, headers=headers)
|
resp = client.get(_PARAMS, headers=headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert len(resp.json()["data"]) == 19
|
assert len(resp.json()["data"]) == 22
|
||||||
|
|
||||||
def test_未登录访问目录_403(self, client: TestClient) -> None:
|
def test_未登录访问目录_403(self, client: TestClient) -> None:
|
||||||
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
|
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
|
||||||
|
|||||||
@@ -21,15 +21,17 @@ from decimal import Decimal
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from app.core.constants import ACTION_CREATE, MODULE_RATING
|
from app.core.constants import ACTION_CREATE, MODULE_RATING
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.models import OperationLog
|
from app.models import CustomerRating, CustomerRatingGrade, OperationLog
|
||||||
|
from app.services.rating_service import resolve_grade
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
_GRADES = "/api/v1/business/rating-grades"
|
_GRADES = "/api/v1/business/rating-grades"
|
||||||
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
|
||||||
# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1))
|
# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1))
|
||||||
_SEED = [
|
_SEED = [
|
||||||
@@ -77,6 +79,41 @@ def _names(client: TestClient, headers: dict) -> list[str]:
|
|||||||
return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]]
|
return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _create_customer(client: TestClient, headers: dict) -> dict:
|
||||||
|
"""建一名 t_ 前缀测试客户(评级落库的被引用方,conftest 兜底清理)。"""
|
||||||
|
resp = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json={
|
||||||
|
"company_name": f"t_评级客户_{uuid.uuid4().hex[:8]}",
|
||||||
|
"owner_name": "t_老板",
|
||||||
|
"customer_type": "personal",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
_RATING_KEYS = (
|
||||||
|
"score_ship_amount",
|
||||||
|
"score_receipt_rate",
|
||||||
|
"score_profit_rate",
|
||||||
|
"score_contract_cooperation",
|
||||||
|
"score_site_construction",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rating_url(customer_id: int) -> str:
|
||||||
|
return f"{_CUSTOMERS}/{customer_id}/rating"
|
||||||
|
|
||||||
|
|
||||||
|
def _rating_payload(scores: list[str], remark: str | None = None) -> dict:
|
||||||
|
body: dict = dict(zip(_RATING_KEYS, scores, strict=True))
|
||||||
|
if remark is not None:
|
||||||
|
body["remark"] = remark
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
def _logs_of(username: str) -> list[OperationLog]:
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
||||||
factory = get_session_factory()
|
factory = get_session_factory()
|
||||||
@@ -258,8 +295,8 @@ class TestEdit:
|
|||||||
|
|
||||||
|
|
||||||
class TestDelete:
|
class TestDelete:
|
||||||
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表
|
"""删除允许清空(客户评级快照固化等级名、无外键引用)—— 删除后判定表
|
||||||
立即反映,二次删除 404。"""
|
立即反映,二次删除 404,已评级客户的历史等级快照不受影响。"""
|
||||||
|
|
||||||
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
|
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
|
||||||
headers = normal["headers"]
|
headers = normal["headers"]
|
||||||
@@ -313,3 +350,146 @@ class TestAudit:
|
|||||||
]
|
]
|
||||||
assert failed, "失败操作未留痕"
|
assert failed, "失败操作未留痕"
|
||||||
assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0]
|
assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveGrade:
|
||||||
|
"""判定纯函数边界:与前端打分卡同规则(sort_order 升序首个 min_score ≤ 总分)。
|
||||||
|
|
||||||
|
单边判定让边界分归 sort_order 靠前的等级:90→A、75→B、60→C、59.9→D;
|
||||||
|
等级表为空或总分低于全部下限时返回 None(落库等级为 NULL)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _seed_grades() -> list[CustomerRatingGrade]:
|
||||||
|
return [
|
||||||
|
CustomerRatingGrade(
|
||||||
|
grade_name=name,
|
||||||
|
min_score=Decimal(min_score),
|
||||||
|
max_score=Decimal(max_score),
|
||||||
|
sort_order=order,
|
||||||
|
)
|
||||||
|
for name, min_score, max_score, order in _SEED
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_边界分归高档(self) -> None:
|
||||||
|
grades = self._seed_grades()
|
||||||
|
assert resolve_grade(grades, Decimal("90")).grade_name == "A"
|
||||||
|
assert resolve_grade(grades, Decimal("75")).grade_name == "B"
|
||||||
|
assert resolve_grade(grades, Decimal("60")).grade_name == "C"
|
||||||
|
|
||||||
|
def test_下限下方归最低档(self) -> None:
|
||||||
|
assert resolve_grade(self._seed_grades(), Decimal("59.9")).grade_name == "D"
|
||||||
|
|
||||||
|
def test_等级表为空_未命中(self) -> None:
|
||||||
|
assert resolve_grade([], Decimal("80")) is None
|
||||||
|
|
||||||
|
def test_低于全部下限_未命中(self) -> None:
|
||||||
|
grades = [
|
||||||
|
CustomerRatingGrade(
|
||||||
|
grade_name="A",
|
||||||
|
min_score=Decimal("90"),
|
||||||
|
max_score=Decimal("100"),
|
||||||
|
sort_order=1,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert resolve_grade(grades, Decimal("50")) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomerRating:
|
||||||
|
"""客户评级落库(V11):每客户一行、服务端重算总分与等级、等级快照固化。
|
||||||
|
|
||||||
|
五项各 0-20 → 总分 0-100;等级按保存时点判定并写入 grade_name(无外键)。
|
||||||
|
未评级返回 data=null(正常态);分数越界 400 且审计留痕。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_未评级返回null(self, client: TestClient, normal: dict) -> None:
|
||||||
|
customer = _create_customer(client, normal["headers"])
|
||||||
|
resp = client.get(_rating_url(customer["id"]), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["data"] is None
|
||||||
|
|
||||||
|
def test_保存评级_服务端重算总分等级并留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _create_customer(client, headers)
|
||||||
|
put = client.put(
|
||||||
|
_rating_url(customer["id"]),
|
||||||
|
json=_rating_payload(["18", "20", "19", "17", "16"], remark="t_评级依据"),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert put.status_code == 200, put.text
|
||||||
|
data = put.json()["data"]
|
||||||
|
assert Decimal(data["total_score"]) == Decimal("90")
|
||||||
|
assert data["grade_name"] == "A"
|
||||||
|
assert data["remark"] == "t_评级依据"
|
||||||
|
assert data["scorer_name"] == normal["username"]
|
||||||
|
|
||||||
|
# 回读一致(列表评级列与弹窗回显同源)
|
||||||
|
got = client.get(_rating_url(customer["id"]), headers=headers).json()["data"]
|
||||||
|
assert Decimal(got["total_score"]) == Decimal("90")
|
||||||
|
assert got["grade_name"] == "A"
|
||||||
|
assert Decimal(got["score_ship_amount"]) == Decimal("18")
|
||||||
|
|
||||||
|
logs = [log for log in _logs_of(normal["username"]) if log.module == MODULE_RATING]
|
||||||
|
assert logs, "保存客户评级未留操作日志"
|
||||||
|
assert logs[0].success == 1
|
||||||
|
assert customer["company_name"] in logs[0].summary
|
||||||
|
assert "评级" in logs[0].summary
|
||||||
|
|
||||||
|
def test_重复保存覆盖同一客户仅一行(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _create_customer(client, headers)
|
||||||
|
first = client.put(
|
||||||
|
_rating_url(customer["id"]), json=_rating_payload(["0"] * 5), headers=headers
|
||||||
|
)
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
assert first.json()["data"]["grade_name"] == "D"
|
||||||
|
|
||||||
|
second = client.put(
|
||||||
|
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
|
||||||
|
)
|
||||||
|
assert second.status_code == 200, second.text
|
||||||
|
assert Decimal(second.json()["data"]["total_score"]) == Decimal("100")
|
||||||
|
assert second.json()["data"]["grade_name"] == "A"
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
count = session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(CustomerRating)
|
||||||
|
.where(CustomerRating.customer_id == customer["id"])
|
||||||
|
).scalar_one()
|
||||||
|
assert count == 1, "同一客户出现多行评级(upsert 失效)"
|
||||||
|
|
||||||
|
def test_单项分数越界_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _create_customer(client, headers)
|
||||||
|
for bad in (["21", "0", "0", "0", "0"], ["0", "-1", "0", "0", "0"]):
|
||||||
|
resp = client.put(
|
||||||
|
_rating_url(customer["id"]), json=_rating_payload(bad), headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "0~20" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_客户不存在_404(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
assert client.get(_rating_url(999_999_999), headers=headers).status_code == 404
|
||||||
|
put = client.put(_rating_url(999_999_999), json=_rating_payload(["0"] * 5), headers=headers)
|
||||||
|
assert put.status_code == 404, put.text
|
||||||
|
|
||||||
|
def test_等级表清空后保存_等级为null(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""等级映射允许清空(快照设计前提):清空后评分照常保存,仅等级为 NULL。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
for name in ("A", "B", "C", "D"):
|
||||||
|
assert (
|
||||||
|
client.delete(
|
||||||
|
f"{_GRADES}/{_seed_id(client, headers, name)}", headers=headers
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
customer = _create_customer(client, headers)
|
||||||
|
data = client.put(
|
||||||
|
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
|
||||||
|
).json()["data"]
|
||||||
|
assert Decimal(data["total_score"]) == Decimal("100")
|
||||||
|
assert data["grade_name"] is None
|
||||||
@@ -306,3 +306,498 @@ def test_权限_普通用户403文案(admin, normal, client: TestClient):
|
|||||||
resp = client.get(f"{_REPORTS}/personal-detail", headers=normal["headers"])
|
resp = client.get(f"{_REPORTS}/personal-detail", headers=normal["headers"])
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
assert resp.json()["message"] == "无权访问报表统计"
|
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
|
||||||
@@ -23,6 +23,7 @@ _SETTINGS = "/api/v1/system/settings"
|
|||||||
|
|
||||||
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
|
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
|
||||||
_JPG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32
|
_JPG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32
|
||||||
|
_ICO_BYTES = b"\x00\x00\x01\x00" + b"\x00" * 32
|
||||||
_TXT_BYTES = b"not an image at all"
|
_TXT_BYTES = b"not an image at all"
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +32,10 @@ def client() -> TestClient:
|
|||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# 系统设置单行与上传文件的快照/还原已上移到 integration/conftest.py,
|
||||||
|
# 对所有集成用例生效(不再只保护本模块)。
|
||||||
|
|
||||||
|
|
||||||
class TestCompanyInfo:
|
class TestCompanyInfo:
|
||||||
"""公司名称读取与更新。"""
|
"""公司名称读取与更新。"""
|
||||||
|
|
||||||
@@ -84,10 +89,30 @@ class TestPublicEndpoint:
|
|||||||
resp = client.get(f"{_SETTINGS}/public")
|
resp = client.get(f"{_SETTINGS}/public")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()["data"]
|
data = resp.json()["data"]
|
||||||
assert set(data) == {"company_name", "logo_url"}, "public 端点字段越界"
|
assert set(data) == {
|
||||||
|
"company_name",
|
||||||
|
"logo_url",
|
||||||
|
"site_title",
|
||||||
|
"brand_slogan",
|
||||||
|
"footer_text",
|
||||||
|
"icp_no",
|
||||||
|
"favicon_url",
|
||||||
|
}, "public 端点字段越界"
|
||||||
assert data["company_name"] == name
|
assert data["company_name"] == name
|
||||||
assert resp.headers.get("cache-control") is not None
|
assert resp.headers.get("cache-control") is not None
|
||||||
|
|
||||||
|
def test_已删除的档案字段不再返回(self, client: TestClient) -> None:
|
||||||
|
"""V11 精简:副标题/信用代码/地址/电话/邮箱已从模型与契约中删除。"""
|
||||||
|
data = client.get(f"{_SETTINGS}/public").json()["data"]
|
||||||
|
for field in (
|
||||||
|
"system_subtitle",
|
||||||
|
"credit_code",
|
||||||
|
"company_address",
|
||||||
|
"contact_phone",
|
||||||
|
"contact_email",
|
||||||
|
):
|
||||||
|
assert field not in data, f"已删除字段仍出现在匿名端点:{field}"
|
||||||
|
|
||||||
def test_写操作匿名不可用(self, client: TestClient) -> None:
|
def test_写操作匿名不可用(self, client: TestClient) -> None:
|
||||||
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
|
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
|
||||||
401,
|
401,
|
||||||
@@ -95,6 +120,41 @@ class TestPublicEndpoint:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompanyProfile:
|
||||||
|
"""站点标题/品牌信息(V11):部分更新语义与空串清空。"""
|
||||||
|
|
||||||
|
def test_部分更新_未提交字段保持不变(self, client: TestClient, admin: dict) -> None:
|
||||||
|
name = f"站点公司_{uuid.uuid4().hex[:6]}"
|
||||||
|
client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"])
|
||||||
|
|
||||||
|
put = client.put(_SETTINGS, json={"site_title": "测试站点标题"}, headers=admin["headers"])
|
||||||
|
assert put.status_code == 200, put.text
|
||||||
|
assert put.json()["data"]["site_title"] == "测试站点标题"
|
||||||
|
# 未提交的 company_name 保持不变(部分更新语义)
|
||||||
|
assert put.json()["data"]["company_name"] == name
|
||||||
|
|
||||||
|
got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"]
|
||||||
|
assert got["company_name"] == name
|
||||||
|
assert got["site_title"] == "测试站点标题"
|
||||||
|
|
||||||
|
def test_可选文本传空串_清空为null(self, client: TestClient, admin: dict) -> None:
|
||||||
|
client.put(
|
||||||
|
_SETTINGS,
|
||||||
|
json={"brand_slogan": "t_临时标语", "icp_no": "t_备案号"},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
put = client.put(
|
||||||
|
_SETTINGS, json={"brand_slogan": "", "icp_no": ""}, headers=admin["headers"]
|
||||||
|
)
|
||||||
|
assert put.status_code == 200, put.text
|
||||||
|
assert put.json()["data"]["brand_slogan"] is None
|
||||||
|
assert put.json()["data"]["icp_no"] is None
|
||||||
|
|
||||||
|
def test_超长字段被拒(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = client.put(_SETTINGS, json={"site_title": "x" * 65}, headers=admin["headers"])
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
|
||||||
class TestLogoUpload:
|
class TestLogoUpload:
|
||||||
"""logo 上传校验链。"""
|
"""logo 上传校验链。"""
|
||||||
|
|
||||||
@@ -135,3 +195,65 @@ class TestLogoUpload:
|
|||||||
files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")},
|
files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
|
||||||
|
"""同扩展名覆盖时路径与 update_time 都不变 —— 版本号必须递增,否则浏览器用旧图。"""
|
||||||
|
first = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
|
||||||
|
second = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
|
||||||
|
assert first and second
|
||||||
|
assert first != second, "同扩展名替换后图片 URL 未变化,浏览器会继续使用旧缓存"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFaviconUpload:
|
||||||
|
"""站点图标上传校验链(独立于 logo;favicon 额外接受 ico)。"""
|
||||||
|
|
||||||
|
def _upload(self, client: TestClient, admin: dict, filename: str, content: bytes):
|
||||||
|
return client.post(
|
||||||
|
f"{_SETTINGS}/favicon",
|
||||||
|
files={"file": (filename, content, "application/octet-stream")},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_合法ico上传成功且回读(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = self._upload(client, admin, "favicon.ico", _ICO_BYTES)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
out = resp.json()["data"]
|
||||||
|
assert out["favicon_url"] and "/favicon/public" in out["favicon_url"]
|
||||||
|
|
||||||
|
img = client.get(f"{_SETTINGS}/favicon/public")
|
||||||
|
assert img.status_code == 200
|
||||||
|
assert img.headers["content-type"] == "image/x-icon"
|
||||||
|
|
||||||
|
def test_合法png上传成功(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = self._upload(client, admin, "favicon.png", _PNG_BYTES)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert "/favicon/public" in resp.json()["data"]["favicon_url"]
|
||||||
|
|
||||||
|
def test_伪造扩展名被magic_bytes拦截(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = self._upload(client, admin, "fake.ico", _TXT_BYTES)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "不符" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
|
||||||
|
first = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"]["favicon_url"]
|
||||||
|
second = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"][
|
||||||
|
"favicon_url"
|
||||||
|
]
|
||||||
|
assert first and second
|
||||||
|
assert first != second, "同扩展名替换后站点图标 URL 未变化"
|
||||||
|
|
||||||
|
def test_非法格式被拒(self, client: TestClient, admin: dict) -> None:
|
||||||
|
assert self._upload(client, admin, "x.gif", _PNG_BYTES).status_code == 400
|
||||||
|
|
||||||
|
def test_超限被拒(self, client: TestClient, admin: dict) -> None:
|
||||||
|
big = _PNG_BYTES + b"\x00" * (2 * 1024 * 1024)
|
||||||
|
resp = self._upload(client, admin, "big.png", big)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "2MB" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_匿名上传被拒(self, client: TestClient) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_SETTINGS}/favicon",
|
||||||
|
files={"file": ("favicon.png", _PNG_BYTES, "application/octet-stream")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -749,3 +749,24 @@ class TestLockInterception:
|
|||||||
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
|
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
|
||||||
assert resp.status_code == 400, resp.text
|
assert resp.status_code == 400, resp.text
|
||||||
assert "锁定" in resp.json()["message"]
|
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
|
||||||
@@ -40,6 +40,7 @@ EXPECTED_TABLES = {
|
|||||||
"shipment_items",
|
"shipment_items",
|
||||||
"receipts",
|
"receipts",
|
||||||
"customer_rating_grades",
|
"customer_rating_grades",
|
||||||
|
"customer_ratings",
|
||||||
"performance_ledgers",
|
"performance_ledgers",
|
||||||
"monthly_settlements",
|
"monthly_settlements",
|
||||||
"settlement_items",
|
"settlement_items",
|
||||||
|
|||||||
@@ -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,
|
validate_and_normalize,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 与 db/migration 迁移种子保持一致的 19 项编码(V3 十六项 + V6 审批开关两项
|
# 与 db/migration 迁移种子保持一致的 22 项编码(V3 十六项 + V6 审批开关两项
|
||||||
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12)
|
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
|
||||||
|
# V10 增工作台预警规则三项 #10)
|
||||||
EXPECTED_CODES = {
|
EXPECTED_CODES = {
|
||||||
"salesperson.rate.start",
|
"salesperson.rate.start",
|
||||||
"salesperson.rate.slope",
|
"salesperson.rate.slope",
|
||||||
@@ -40,14 +41,17 @@ EXPECTED_CODES = {
|
|||||||
"approval.monthly_settlement.enabled",
|
"approval.monthly_settlement.enabled",
|
||||||
"approval.adjustment.enabled",
|
"approval.adjustment.enabled",
|
||||||
"manager.personal.included",
|
"manager.personal.included",
|
||||||
|
"warning.enabled",
|
||||||
|
"warning.receipt.overdue.days",
|
||||||
|
"warning.approval.timeout.days",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestRegistry:
|
class TestRegistry:
|
||||||
"""注册表元数据完整性(与迁移种子同源)。"""
|
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||||
|
|
||||||
def test_注册表共19项(self) -> None:
|
def test_注册表共22项(self) -> None:
|
||||||
assert len(PARAM_REGISTRY) == 19
|
assert len(PARAM_REGISTRY) == 22
|
||||||
|
|
||||||
def test_编码集合与迁移种子一致(self) -> None:
|
def test_编码集合与迁移种子一致(self) -> None:
|
||||||
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
||||||
|
|||||||
@@ -209,3 +209,214 @@ class TestExcel模板:
|
|||||||
]
|
]
|
||||||
text = _sheet_text(_build("business-stats", rows))
|
text = _sheet_text(_build("business-stats", rows))
|
||||||
assert "已完成" in text
|
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%", "")
|
||||||
+48
-6
@@ -23,6 +23,7 @@ DROP TABLE IF EXISTS `monthly_settlements`;
|
|||||||
DROP TABLE IF EXISTS `approval_steps`;
|
DROP TABLE IF EXISTS `approval_steps`;
|
||||||
DROP TABLE IF EXISTS `approval_orders`;
|
DROP TABLE IF EXISTS `approval_orders`;
|
||||||
DROP TABLE IF EXISTS `business_secondary_allocations`;
|
DROP TABLE IF EXISTS `business_secondary_allocations`;
|
||||||
|
DROP TABLE IF EXISTS `customer_ratings`;
|
||||||
DROP TABLE IF EXISTS `system_settings`;
|
DROP TABLE IF EXISTS `system_settings`;
|
||||||
DROP TABLE IF EXISTS `param_values`;
|
DROP TABLE IF EXISTS `param_values`;
|
||||||
DROP TABLE IF EXISTS `param_change_batches`;
|
DROP TABLE IF EXISTS `param_change_batches`;
|
||||||
@@ -455,7 +456,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),
|
(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),
|
(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),
|
(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
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
||||||
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
||||||
@@ -481,7 +486,11 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
|
|||||||
(20, 17, '', '2026-01-01', '1', 1),
|
(20, 17, '', '2026-01-01', '1', 1),
|
||||||
(21, 18, '', '2026-01-01', '1', 1),
|
(21, 18, '', '2026-01-01', '1', 1),
|
||||||
(22, 19, '', '2026-01-01', '0', 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 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
||||||
@@ -687,17 +696,50 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
|
|||||||
('C', 60, 74, 3, '合格'),
|
('C', 60, 74, 3, '合格'),
|
||||||
('D', 0, 59, 4, '待改进');
|
('D', 0, 59, 4, '待改进');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 客户评级落库(V11 迁移;每客户一行,等级名为快照固化无外键)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE customer_ratings (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
|
||||||
|
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
|
||||||
|
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
|
||||||
|
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
|
||||||
|
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
|
||||||
|
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
|
||||||
|
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
|
||||||
|
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
|
||||||
|
scorer_name VARCHAR(64) NOT NULL COMMENT '评级操作人用户名(冗余固化)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_customer_ratings PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批)
|
-- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批)
|
||||||
|
-- V11 迁移:增加品牌标语/页脚/备案号/站点标题/站点图标与图片版本号
|
||||||
|
-- (公司名称/logo/标语/页脚/备案号 + 站点信息,企业档案字段方案已废弃未发布)
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
CREATE TABLE system_settings (
|
CREATE TABLE system_settings (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页与导出页眉展示)',
|
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页/侧边栏/浏览器标题展示)',
|
||||||
logo_path VARCHAR(255) COMMENT 'logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
|
logo_path VARCHAR(255) COMMENT '公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
|
||||||
|
logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0',
|
||||||
|
site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)',
|
||||||
|
brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)',
|
||||||
|
footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)',
|
||||||
|
icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)',
|
||||||
|
favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)',
|
||||||
|
favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0',
|
||||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
CONSTRAINT pk_system_settings PRIMARY KEY (id)
|
CONSTRAINT pk_system_settings PRIMARY KEY (id)
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息,全系统单行)' COLLATE utf8mb4_general_ci;
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息与站点信息,全系统单行)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
CREATE TABLE approval_orders (
|
CREATE TABLE approval_orders (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
@@ -874,4 +916,4 @@ CREATE TABLE alembic_version (
|
|||||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
INSERT INTO alembic_version (version_num) VALUES ('ba0217f5091b');
|
INSERT INTO alembic_version (version_num) VALUES ('c4e2a86f9b13');
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Running upgrade ba0217f5091b -> 90653814a421
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21, 1);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22, 1);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23, 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (26, 21, '', '2026-10-19', '1', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (27, 22, '', '2026-10-19', '90', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (28, 23, '', '2026-10-19', '3', 1);
|
||||||
|
|
||||||
|
UPDATE alembic_version SET version_num='90653814a421' WHERE alembic_version.version_num = 'ba0217f5091b';
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- Running upgrade 90653814a421 -> c4e2a86f9b13
|
||||||
|
|
||||||
|
CREATE TABLE customer_ratings (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
|
||||||
|
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
|
||||||
|
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
|
||||||
|
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
|
||||||
|
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
|
||||||
|
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
|
||||||
|
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
|
||||||
|
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
|
||||||
|
scorer_name VARCHAR(64) NOT NULL COMMENT '评级操作人用户名(冗余固化)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_customer_ratings PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
ALTER TABLE system_settings ADD COLUMN site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)';
|
||||||
|
|
||||||
|
ALTER TABLE system_settings ADD COLUMN brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)';
|
||||||
|
|
||||||
|
ALTER TABLE system_settings ADD COLUMN footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)';
|
||||||
|
|
||||||
|
ALTER TABLE system_settings ADD COLUMN icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)';
|
||||||
|
|
||||||
|
ALTER TABLE system_settings ADD COLUMN favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)';
|
||||||
|
|
||||||
|
ALTER TABLE system_settings ADD COLUMN logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0';
|
||||||
|
|
||||||
|
ALTER TABLE system_settings ADD COLUMN favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0';
|
||||||
|
|
||||||
|
UPDATE alembic_version SET version_num='c4e2a86f9b13' WHERE alembic_version.version_num = '90653814a421';
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# T+ 12.3 字段映射草案(v0.1,2026-10-20)
|
||||||
|
|
||||||
|
> 本文仅记录字段级事实与页面索引,不复制厂商文档正文。
|
||||||
|
> 基线:`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`(75 项)+ v1.3 IT 18 项 + 《T+12.3数据字典》。
|
||||||
|
|
||||||
|
## 0. 结论摘要
|
||||||
|
|
||||||
|
- 覆盖:清单 **75/75** 项均已给出 T+ 侧候选字段与系统目标列;
|
||||||
|
- 状态分布:候选待确认 42 / 字典明确 23 / 未检出 9 / 需 IT 1;
|
||||||
|
- 数据字典无法让 IT 18 项中的任何一项完全闭环;但 9 项可改写为「候选字段选择题」,6 项部分可答,2 项纯 IT,1 项转业务。
|
||||||
|
- 收敛后:**13 项 IT 待确认 + 1 组业务裁决**(见第 12 节)。
|
||||||
|
- 关键缺口:人员工号/入职日期/试用期无字段、客户 status 缺失、回款 payment_method/confirm_status 待补、明细行无显式行号、无源端更新时间与负向事件模型。
|
||||||
|
|
||||||
|
## 1. 编制说明与证据等级
|
||||||
|
|
||||||
|
| 标记 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `D` | 字典明确 |
|
||||||
|
| `D?` | 候选待确认 |
|
||||||
|
| `IT` | 需 IT |
|
||||||
|
| `B` | 需业务 |
|
||||||
|
| `S` | 系统自维护 |
|
||||||
|
| `N` | 未检出 |
|
||||||
|
|
||||||
|
- `D` 仅用于字段存在、类型明确且语义直接匹配的项;涉及「取哪个字段 / 业务口径」一律标 `D?` 或 `IT`。
|
||||||
|
- 字典来源为 T+ 12.3 SchemaDoc;同一字段可能标注 `T+12.1/12.2/12.3` 版本,映射按 12.3 可用为准。
|
||||||
|
|
||||||
|
## 2. 公共字段与同步元数据
|
||||||
|
|
||||||
|
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | T+ 原始单据号 / 档案编码 | 各表 code(人员/存货/客户/订单/出库/收付款单均有) | nvarchar(30~64) | — | personnel/products/customers/businesses/shipments/receipts.source_no | TRIM;作废重开是否换号待 IT(#4) | 必须 | D? | AA_Person.code、AA_Inventory.code、AA_Partner.code、SA_SaleOrder.code、SA_SaleDelivery.code、ARAP_ReceivePayment.code |
|
||||||
|
| 2 | T+ 系统内部标识 | 各表 id(整型主键) | int(4) | — | 六表 t_plus_id | int → 字符串存储;辅助对账/改名追溯 | 建议 | D | 各表 id(主键) |
|
||||||
|
|
||||||
|
系统侧六张主表已预留 `source_no / t_plus_id / sync_batch_no / synced_at`;`source_no` 当前为普通索引,IT 确认幂等键后升级唯一约束。
|
||||||
|
|
||||||
|
## 3. 人员档案
|
||||||
|
|
||||||
|
人员主档;隶属关系需经部门树/部门负责人推断,T+ 无直属上级字段。
|
||||||
|
|
||||||
|
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 人员唯一编码(工号)★ | AA_Person.code | nvarchar(32) | — | personnel.source_no(建议补 personnel_code) | TRIM;编码优先、姓名辅助核对;是否即工号待 IT(#19) | 必须 | D? | AA_Person.code(编号) |
|
||||||
|
| 2 | 姓名 | AA_Person.name | nvarchar(200) | — | personnel.personnel_name | TRIM;仅辅助核对,不作唯一键 | 必须 | D | AA_Person.name(名称) |
|
||||||
|
| 3 | 在职状态 | AA_Person.disabled | tinyint(1) | 0 启用 / 1 停用(候选) | personnel.status(1 在职 / 0 离职) | disabled=1 → 0;无离职日期字段,离职时间需 IT 确认 | 必须 | D? | AA_Person.disabled(停用) |
|
||||||
|
| 4 | 是否试用期 | 未检出 | — | — | personnel.is_probation | T+ 人员档案无试用期字段;系统内人工维护或按入职日期规则推导 | 必须 | N | AA_Person 全表检索无 probation/试用 字段 |
|
||||||
|
| 5 | 入职日期 | 未检出(仅 madeDate 制单日期、birthday 出生日期) | — | — | personnel.hire_date | T+ 无入职日期字段;确认是否有自定义项承载,否则人工维护 | 必须 | N | AA_Person 全表检索无 hire/entry/入职 字段 |
|
||||||
|
| 6 | 联系电话 | AA_Person.mobilePhoneNo / officePhoneNo | nvarchar(20) / nvarchar(100) | — | personnel.phone | 优先 mobilePhoneNo,空则 officePhoneNo;日志脱敏 | 建议 | D | AA_Person.mobilePhoneNo(手机)、officePhoneNo(办公电话) |
|
||||||
|
| 7 | 业务员 ↔ 营销经理隶属关系 | AA_Person.iddepartment → AA_Department.idparent / iddirector | int(4) | — | personnel.manager_id | T+ 人员档案无直属上级字段;可经部门树/部门负责人推断,规则待确认;否则系统人工指定 | 必须 | D? | AA_Person.iddepartment(部门id);AA_Department.idparent(上级id)、iddirector(部门负责人) |
|
||||||
|
| 8 | 人员负责区域 / 产品辐射范围 | 未检出(可用 priuserdefnvc* 自定义项承载,需 IT 确认) | — | — | personnel_regions.region_name | 无专门字段则系统内人工维护 | 建议 | N | AA_Person 无区域/辐射范围字段 |
|
||||||
|
| 9 | 银行账户名 / 账号 / 开户行 | 未检出(AA_Partner 有银行账号字段,人员档案无) | — | — | 暂不落库 | 一期不采集;如后续代发工资再扩展 | 可选 | N | AA_Person 无银行账户字段 |
|
||||||
|
|
||||||
|
## 4. 产品档案
|
||||||
|
|
||||||
|
产品主档不含库存量与售价,数量取现存量表、价格取价目表/订单明细。
|
||||||
|
|
||||||
|
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 产品名称 | AA_Inventory.name | nvarchar(200) | — | products.product_name | TRIM;与规格型号组成系统组合唯一键 | 必须 | D | AA_Inventory.name(名称) |
|
||||||
|
| 2 | 规格型号 | AA_Inventory.specification | nvarchar(200) | — | products.spec_model | 空值归一为空串 | 必须 | D | AA_Inventory.specification(规格型号) |
|
||||||
|
| 3 | 计量单位 | AA_Inventory.idunit → 计量单位表 | int(4) | — | products.unit | 按 idunit join 单位表取名称;单位表名待索引确认 | 建议 | D? | AA_Inventory.idunit(计量单位id)、idunitgroup |
|
||||||
|
| 4 | 数量 | ST_CurrentStock.baseQuantity(现存量) | decimal(13) | — | products.stock_quantity | 按存货+仓库汇总;语义(现存量/在途/累计销量)待 IT(#12) | 必须 | D? | ST_CurrentStock.baseQuantity(主计量数量);AA_Inventory 无库存量字段 |
|
||||||
|
| 5 | 销售单价 | AA_InventoryPrice(零售价/批发价)、AA_InventoryUnitPrice.retailPrice;订单明细实际成交价 | decimal(13~17) / nvarchar(1000) 公式 | — | products.sale_price | 产品档案参考价取价目表;绩效以订单/发货明细实际单价为准 | 必须 | D? | AA_InventoryPrice、AA_InventoryUnitPrice;AA_Inventory 无销售单价字段 |
|
||||||
|
| 6 | 产品分类(类目) | AA_Inventory.idinventoryclass → AA_InventoryClass.name | int(4) | — | products.product_category | T+ 分类仅辅助判定市政水利/轨交;系统分类人工维护不覆盖 | 必须 | D? | AA_Inventory.idinventoryclass(存货分类id)、AA_InventoryClass.name |
|
||||||
|
| 7 | 计量单位换算关系(如存在) | AA_Inventory_MultiUnit / AA_InventoryUnitPrice.rateOfExchange | decimal(13) | — | 暂不实现(系统单单位存储) | 如业务有多单位换算需求,另行设计 | 可选 | D? | AA_Inventory_MultiUnit、AA_InventoryUnitPrice.rateOfExchange(换算率) |
|
||||||
|
|
||||||
|
## 5. 业务单据(销售订单)
|
||||||
|
|
||||||
|
成本额在订单明细仅有最新成本,出库明细才有成本金额;销售额需按数量×单价计算。
|
||||||
|
|
||||||
|
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 订单单号 | SA_SaleOrder.code | nvarchar(30) | — | businesses.source_no | TRIM;不得写入内部 business_no | 必须 | D | SA_SaleOrder.code(编号) |
|
||||||
|
| 2 | 业务(订单)日期 | SA_SaleOrder.voucherdate | datetime(8) | — | businesses.business_date | 截断为 DATE | 必须 | D | SA_SaleOrder.voucherdate(单据日期) |
|
||||||
|
| 3 | 客户标识 | SA_SaleOrder.idcustomer → AA_Partner.code | int(4) | — | businesses.customer_id | 按客户编码解析;回退公司名称+负责人 | 必须 | D? | SA_SaleOrder.idcustomer(客户id)、idsettlecustomer(结算客户id) |
|
||||||
|
| 4 | 负责营销人员 | SA_SaleOrder.idclerk → AA_Person.code | int(4) | — | businesses.owner_personnel_id | 按人员编码解析;新插入写 T+ 负责人、更新是否覆盖人工归属待裁决 | 必须 | D? | SA_SaleOrder.idclerk(业务员id) |
|
||||||
|
| 5 | 协助营销人员 | 未检出独立协助人字段 | — | — | businesses.assistant_personnel_id | 业务已回馈 T+ 合同只挂单一负责人;协助人系统内记录 | 建议 | N | SA_SaleOrder 无 assist/协助 字段 |
|
||||||
|
| 6 | 特殊业务标记 | SA_SaleOrder.idbusinesstype → 业务类型表 | int(4) | 业务类型枚举待字典/IT 提供 | businesses.is_special | 业务类型是否等同特殊业务待确认;无则人工标记 | 必须 | D? | SA_SaleOrder.idbusinesstype(业务类型id) |
|
||||||
|
| 7 | 明细行 · 产品名称 + 规格 | SA_SaleOrder_b.idinventory → AA_Inventory.name/specification | int(4) | — | business_items.product_name / spec_model | 冗余固化名称与规格;未建档产品允许自由录入 | 必须 | D | SA_SaleOrder_b.idinventory(存货id) |
|
||||||
|
| 8 | 明细行 · 数量 | SA_SaleOrder_b.quantity | decimal(13) | — | business_items.quantity | 主计量数量;需 >0 校验 | 必须 | D | SA_SaleOrder_b.quantity(数量)、baseQuantity(主计量数量) |
|
||||||
|
| 9 | 明细行 · 销售单价 | SA_SaleOrder_b.price / taxPrice | decimal(13) | — | business_items.sale_price | price 为不含税单价、taxPrice 为含税单价;取哪个待确认 | 必须 | D? | SA_SaleOrder_b.price(单价)、taxPrice(含税单价) |
|
||||||
|
| 10 | 明细行 · 销售额 | SA_SaleOrder_b 无「销售额」字段;由数量×单价计算,或 taxAmount−tax | decimal(13) | — | business_items.sale_amount | 系统按数量×单价固化;T+ 用于对账 | 必须 | D? | SA_SaleOrder_b.quantity、price、taxAmount、tax |
|
||||||
|
| 11 | 明细行 · 成本额 ★ | SA_SaleOrder_b.LatestCost(最新成本);SA_SaleDelivery_b.costPrice/costAmount | decimal(13) | — | business_items.cost_amount | 取订单最新成本还是出库成本、成本类型/时点待 IT(#1) | 必须 | D? | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType |
|
||||||
|
| 12 | 项目 / 工程编号或名称(如存在) | SA_SaleOrder.idproject → 项目表 | int(4) | — | 建议预留 project_code(当前不落库) | 按项目查询与报表归集;是否建列待业务 | 可选 | D? | SA_SaleOrder.idproject(项目id)、SA_SaleOrder_b.idproject |
|
||||||
|
| 13 | 服务类 / 维修类及其他收入单据(如存在) | 未检索到服务/维修类独立单据表 | — | — | 暂人工录入 | 业务规则未定前不纳入自动同步 | 可选 | N | SA_* 表索引未发现服务/维修类主表 |
|
||||||
|
|
||||||
|
## 6. 发货单据(销售出库/发货)
|
||||||
|
|
||||||
|
发货日期有四个候选字段;运费未在发货单据检出。
|
||||||
|
|
||||||
|
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 发货单号 | SA_SaleDelivery.code | nvarchar(30) | — | shipments.source_no | TRIM;不得写入内部 shipment_no | 必须 | D | SA_SaleDelivery.code(编号) |
|
||||||
|
| 2 | 发货日期 ★ | SA_SaleDelivery.voucherdate / SendDate / deliveryDate / auditeddate | datetime(8) | — | shipments.ship_date | 四个日期候选:单据日期/送货日期(T+12.3)/交货日期/审核日期;取哪个待 IT(#2) | 必须 | D? | SA_SaleDelivery.voucherdate、SendDate、deliveryDate、auditeddate |
|
||||||
|
| 3 | 关联订单号 | SA_SaleDelivery.SourceVoucherCode / idsourcevouchertype;明细 saleOrderCode/saleOrderId | nvarchar(50) / int(4) | — | shipments.business_id | 按订单号解析;主关联路径待 IT(#5) | 必须 | D? | SA_SaleDelivery.SourceVoucherCode、SA_SaleDelivery_b.saleOrderCode/saleOrderId/saleOrderDetailId |
|
||||||
|
| 4 | 运费金额 | 未检出运费字段(明细有 origExpenseAmount 费用分摊金额) | decimal(13) | — | shipments.freight_amount | T+ 运费可能在费用单/其他单据,需 IT 确认;系统暂人工 | 必须 | N | SA_SaleDelivery/SA_SaleDelivery_b 无 freight/运费字段 |
|
||||||
|
| 5 | 发货总金额 | SA_SaleDelivery.amount / taxAmount | decimal(13) | — | shipments.total_amount | 系统按明细汇总,不直写;T+ 金额用于对账 | 必须 | D | SA_SaleDelivery.amount(金额)、taxAmount(含税金额) |
|
||||||
|
| 6 | 明细行 · 产品/规格/数量/单价/行金额 | SA_SaleDelivery_b.idinventory / quantity / price / taxPrice / taxAmount | decimal(13) | — | shipment_items.product_name/spec_model/quantity/sale_price/amount | 行金额由系统按数量×单价固化;T+ 用于对账 | 必须 | D | SA_SaleDelivery_b.quantity、price、taxPrice、taxAmount |
|
||||||
|
| 7 | 发货单状态 | SA_SaleDelivery.voucherState / isCancel / auditeddate | int(4) / tinyint(1) | 单据状态枚举待 IT 提供 | 不落库(v1.4 统一) | 仅推已生效单据;状态随行供同步校验 | 必须 | D | SA_SaleDelivery.voucherState(单据状态)、isCancel(状态) |
|
||||||
|
| 8 | 承运商 / 物流单号 | SA_SaleDelivery.deliveryMode(运输方式 id) | int(4) | 运输方式枚举表待确认 | 暂不落库 | 承运商/物流单号无独立字段;如需建列再扩展 | 可选 | D? | SA_SaleDelivery.deliveryMode(运输方式) |
|
||||||
|
|
||||||
|
## 7. 回款单据(收款/核销)
|
||||||
|
|
||||||
|
到账日期无银行到账日字段;收款方式取结算方式表;确认/核销状态字段存在但枚举待 IT。
|
||||||
|
|
||||||
|
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 回款单号 | ARAP_ReceivePayment.code | nvarchar(30) | — | receipts.source_no | TRIM;不得写入内部 receipt_batch_no | 必须 | D | ARAP_ReceivePayment.code(编号) |
|
||||||
|
| 2 | 到账日期 ★ | ARAP_ReceivePayment.voucherDate / auditedDate / createdTime | datetime(8) | — | receipts.receipt_date | 无银行到账日字段;取单据日期/审核日期/创建时间哪个待 IT(#3) | 必须 | D? | ARAP_ReceivePayment.voucherDate、auditedDate、createdTime |
|
||||||
|
| 3 | 回款金额 | ARAP_ReceivePayment.amount(本币)/ origAmount(原币) | decimal(13) | — | receipts.amount | 取本币金额;一期禁负数,红字规则待 #8 | 必须 | D | ARAP_ReceivePayment.amount、origAmount |
|
||||||
|
| 4 | 关联订单号 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;明细 saleordercode/saleorderid/sourceordercode | nvarchar(50) / int(4) | — | receipts.business_id | 回款↔订单按订单号解析;回款↔发货批次系统人工指定 | 必须 | D? | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b.saleordercode/sourceordercode |
|
||||||
|
| 5 | 收款方式 | ARAP_ReceivePayment.IdSettleStyle → AA_SettleStyle.code/name | int(4) | 结算方式枚举表(AA_SettleStyle:code/name/停用) | receipts.payment_method(待补列) | 枚举映射;完整枚举清单需 IT 提供 | 必须 | D? | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle |
|
||||||
|
| 6 | 以物抵顶物说明 | 收款方式(以物抵顶枚举值)+ ARAP_ReceivePayment.memo | int(4) / nvarchar(200) | 以物抵顶对应结算方式代码待 IT 提供 | receipts.is_barter / remark | 按结算方式识别;抵顶物说明暂入备注 | 建议 | D? | ARAP_ReceivePayment.IdSettleStyle、memo |
|
||||||
|
| 7 | 回款确认 / 核销状态 | ARAP_ReceivePayment.cancelState / voucherstate / auditedDate | int(4) / datetime(8) | 核销/单据状态枚举待 IT 提供 | receipts.confirm_status(待补列) | 字段存在;哪个可作为人工确认依据待 IT(#16) | 必须 | D? | ARAP_ReceivePayment.cancelState(核销状态)、voucherstate(单据状态) |
|
||||||
|
| 8 | 银行流水号 / 回单参考号 | 未检出银行流水号/回单号字段(MultiSettle.documentCode 为票据号) | — | — | 建议预留 bank_serial_no | 需 IT 确认是否有对应字段 | 建议 | N | ARAP_ReceivePayment/ARAP_ReceivePayment_MultiSettle 仅票据号 |
|
||||||
|
| 9 | 票据信息(票号 / 到期日 / 贴现说明) | ARAP_ReceivePayment_MultiSettle.documentCode / amount / idsettlestyle | nvarchar(30) / decimal(13) | 票据类型枚举待 IT | 系统暂以备注承载 | 无票据到期日字段;承兑到期核销信息需 IT 确认 | 建议 | D? | ARAP_ReceivePayment_MultiSettle.documentCode(票据号) |
|
||||||
|
| 10 | 币种 / 汇率(如存在外币业务) | ARAP_ReceivePayment.idcurrency / exchangeRate | int(4) / decimal(13) | 币种表待索引 | 一期不落库(人民币记账) | 如存在外币业务再扩展 | 可选 | D | ARAP_ReceivePayment.idcurrency、exchangeRate |
|
||||||
|
|
||||||
|
## 8. 客户档案
|
||||||
|
|
||||||
|
采用往来单位表 AA_Partner;客户编码/停用字段存在,status 列需系统补。
|
||||||
|
|
||||||
|
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 公司名称 | AA_Partner.name | nvarchar(200) | — | customers.company_name | TRIM;与负责人组成系统唯一键 | 必须 | D | AA_Partner.name(名称) |
|
||||||
|
| 2 | 负责人姓名 | AA_Partner.Contact(联系人)/ representative(法人代表) | nvarchar(50) | — | customers.owner_name | 「负责人」对应 Contact 还是 representative 待 IT/业务确认 | 必须 | D? | AA_Partner.Contact、representative |
|
||||||
|
| 3 | 客户编码 / 状态 / 启用停用时间 | AA_Partner.code / disabled / madeDate / updated | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用(候选) | customers.source_no / status(待补列) | 启停时间无独立列,可用 updated 近似(需 IT 确认) | 必须 | D? | AA_Partner.code、disabled、madeDate、updated |
|
||||||
|
| 4 | 联系人 / 电话 / 地址 / 行业 | AA_Partner.Contact / TelephoneNo / MobilePhone / CustomerAddress / iddistrict | nvarchar(30~200) | — | customers.contact_person / contact_phone / address / industry | 行业无直接字段;地区按 iddistrict 关联 | 可选 | D? | AA_Partner.Contact、TelephoneNo、MobilePhone、CustomerAddress、iddistrict |
|
||||||
|
| 5 | 开票信息(税号 / 开票名称 / 开票地址电话) | AA_Partner.taxRegcode / name / CustomerAddress | nvarchar(50~200) | — | 系统暂仅供展示,不落库 | 税号存在;开票地址电话需 IT 确认字段 | 可选 | D? | AA_Partner.taxRegcode(纳税号)、CustomerAddress |
|
||||||
|
|
||||||
|
## 9. 同步完整性与补充字段
|
||||||
|
|
||||||
|
时间戳与负向字段部分存在;无统一事件表、无明细行号、无手续费字段。
|
||||||
|
|
||||||
|
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 数据最后更新时间 / 单据创建时间 / 修改时间 | 各表 createdTime/createdtime、updated、madeDate、auditedDate、ts | datetime(8) / timestamp(8) | — | 建议新增 source_created_at / source_updated_at(当前无) | 本地 update_time 不可作为源端水位;增量水位字段待 IT(#9) | 必须 | D? | 各表 updated/createdTime;ts(分布时间戳) |
|
||||||
|
| 2 | 变更类型 / 单据事件:新增、修改、删除、作废、红冲、退货、冲销 | 各表 isCancel(状态)、isCounteractDelivery(冲抵销货)、returnQuntity(退货数量) | int(4) / tinyint(1) / decimal(13) | 状态/事件枚举待 IT 提供 | 建议事件表(当前无) | 无统一变更事件表;负向事实需事件模型,规则待 #8 | 必须 | D? | SA_SaleOrder.isCancel、SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity |
|
||||||
|
| 3 | 单据状态 / 财务审核状态 / 状态变更时间 | voucherState / auditedDate / auditor / auditorid | int(4) / datetime(8) / nvarchar(50) | 单据状态枚举待 IT | 不落库(仅同步校验) | 仅推已生效单据;状态随行校验 | 必须 | D | SA_SaleOrder/SA_SaleDelivery/ARAP_ReceivePayment 的 voucherState、auditedDate |
|
||||||
|
| 4 | 明细行行号 / 行状态 / 行级变更事件 | 明细表主键 ID、code(明细编号);无显式行号字段 | int(4) / nvarchar(30) | — | 建议补 source_line_no / source_item_id | 确认 code 是否即行号;否则用 T+ 明细 ID 作行级幂等键(待 IT) | 必须 | D? | SA_SaleOrder_b.id/code、SA_SaleDelivery_b.ID/code、ARAP_ReceivePayment_b.id/detailCode |
|
||||||
|
| 5 | 税率 / 税额 / 价税合计 / 折扣或折让 | 明细表 taxRate、tax、taxAmount、origTaxAmount、discount、discountRate、discountAmount | decimal(13) | — | 待业务 #20 答复后决定是否建列 | 字段齐全;金额是否含税、折扣关联方式待业务 | 必须 | D? | SA_SaleOrder_b/SA_SaleDelivery_b 税额与折扣字段 |
|
||||||
|
| 6 | 发货客户 / 收货人 / 收货地址 / 联系电话 | SA_SaleDelivery.idcustomer / address / contactPhone / idSendPerson / idConsignor | int(4) / nvarchar(100~200) | — | 建议预留(当前无列) | 用于核对发货对象与代发情形 | 必须 | D | SA_SaleDelivery.idcustomer、address、contactPhone、idSendPerson、idConsignor |
|
||||||
|
| 7 | 发货业务员 / 发货组织 / 仓库 | SA_SaleDelivery.idclerk / iddepartment / idwarehouse | int(4) | — | 建议预留(当前无列) | 识别代发、调拨与多组织差异 | 必须 | D | SA_SaleDelivery.idclerk、iddepartment、idwarehouse |
|
||||||
|
| 8 | 发货退回 / 红字发货 / 退回数量 / 退回金额 / 原发货单关联 | SA_SaleDelivery.isCounteractDelivery、_b.returnQuntity/returnQuntity2/backSentQuantity、sourceVoucher* | tinyint(1) / decimal(13) / nvarchar(50) | 冲抵/退货枚举待 IT | 待业务 #21 答复后决定是否建列 | 原单关联字段存在;是否冲减绩效待业务 | 必须 | D? | SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity、backSentQuantity |
|
||||||
|
| 9 | 收款客户 / 经手人 / 财务审核状态 | ARAP_ReceivePayment.idpartner / idHandler / auditor / voucherstate | int(4) / nvarchar(50) / int(4) | — | 建议预留(当前无列) | 核对实际付款主体与经办责任 | 必须 | D | ARAP_ReceivePayment.idpartner、idHandler、auditor、voucherstate |
|
||||||
|
| 10 | 收款分配明细 / 分配金额 / 未分配金额 / 分配对象 | ARAP_ReceivePayment_b(源单/订单/金额/未结算)、ARAP_StrikeBalance(核销) | decimal(13) / int(4) | — | 人工指定发货批次的辅助参考 | 分配关系仅参考;系统仍人工指定(对照表 #18) | 必须 | D? | ARAP_ReceivePayment_b.saleordercode/amount/origNoSettleAmount;ARAP_StrikeBalance |
|
||||||
|
| 11 | 预收款 / 保证金 / 代收款 / 折让 / 抵扣 | ARAP_ReceivePayment.paymentAmount/origPaymentAmount(预收/预付)、overPayment(超收转预收)、allowances(折让) | decimal(13) | — | 待业务 #22 答复后决定是否建列 | 字段存在;是否计入绩效回款待业务 | 必须 | D? | ARAP_ReceivePayment.paymentAmount、overPayment、allowances |
|
||||||
|
| 12 | 手续费 / 交易费用 / 收款净额 | 未检出手续费/交易费用字段 | — | — | 待业务 #23 答复后决定 | 需 IT 确认手续费是否在其他单据/字段 | 必须 | N | ARAP_ReceivePayment 无 fee/charge 字段 |
|
||||||
|
| 13 | 红字收款 / 收款作废 / 收款退回 / 冲销及原单关联 | ARAP_ReceivePayment.voucherstate / isReceiveFlag;ARAP_StrikeBalance 冲销 | int(4) / tinyint(1) | 作废/红字状态枚举待 IT | 待业务 #24 答复后决定 | 无红字收款专用字段;负向事实需事件模型 | 必须 | D? | ARAP_ReceivePayment.voucherstate、isReceiveFlag;ARAP_StrikeBalance |
|
||||||
|
| 14 | 客户编码 / 客户状态 / 启用停用时间 | AA_Partner.code / disabled | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | customers.source_no / status(待补列) | 同六-3;客户编码唯一性待 IT | 必须 | D | AA_Partner.code、disabled |
|
||||||
|
| 15 | 客户分类 / 渠道 / 区域 / 所属组织 | AA_Partner.idpartnerclass / customerType / iddistrict / idMarketingOrgan | int(4) | 分类/客商类型枚举待 IT | customers.customer_type(不覆盖);渠道无列 | 系统客户分类人工维护;渠道/区域作备考 | 必须 | D? | AA_Partner.idpartnerclass、customerType、iddistrict、idMarketingOrgan |
|
||||||
|
| 16 | 客户名称变更 / 编码停用合并前后的对应关系 | AA_Partner.code(终身不变)+ updated;无变更历史表(待确认) | nvarchar(32) | — | 靠 source_no 追溯同一主体 | 改名/换负责人/合并靠 code 串同一主体;变更历史需 IT 确认 | 必须 | D? | AA_Partner.code、updated、HasEverChanged |
|
||||||
|
| 17 | 人员部门 / 岗位 / 调岗时间 / 离职和组织变更时间 | AA_Person.iddepartment / position / updated;调岗历史表待确认 | int(4) / datetime(8) | — | 不落库(备考/排查) | 字段部分存在;调岗/离职时间需 IT 确认 | 必须 | D? | AA_Person.iddepartment、position、updated |
|
||||||
|
| 18 | 产品编码 / 产品状态 / 启用停用时间 | AA_Inventory.code / disabled / madeDate | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | products.source_no / status | 稳定匹配;停用后历史明细仍可回溯 | 必须 | D | AA_Inventory.code、disabled、MadeRecordDate |
|
||||||
|
| 19 | 字典和枚举取值范围 / 编码与名称对照说明 | 枚举表:AA_SettleStyle、业务类型表、状态字典(需随数据导出) | — | 枚举字典随数据提供 | 同步映射表 | 不能只给代码;需编码与名称对照 | 必须 | D? | AA_SettleStyle、AA_BusinessType 等枚举表 |
|
||||||
|
| 20 | 对账汇总信息 / 数据记录数 / 数量合计 / 金额合计 / 抽取时间 | 由 IT 提供抽取汇总(接口/视图层) | — | — | 同步日志/对账表 | 记录明细条数、金额合计、抽取时间 | 必须 | IT | 字典不提供;需对接方式确定 |
|
||||||
|
| 21 | 单据历史版本 / 修改留痕(如 T+ 提供) | 未检索到统一单据历史版本表(待 IT 确认) | — | — | 系统同步快照可比对 | 无此数据不阻塞 | 可选 | D? | 表索引未见 history 类表 |
|
||||||
|
|
||||||
|
## 10. 跨表关联与键
|
||||||
|
|
||||||
|
| 关系 | T+ 字段(字典) | 系统目标 | 状态 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 人员唯一键 | AA_Person.code | personnel.source_no(建议补 personnel_code) | D? |
|
||||||
|
| 客户唯一键 | AA_Partner.code;名称+联系人 | customers.source_no;company_name+owner_name | D? |
|
||||||
|
| 订单→发货 | SA_SaleDelivery.SourceVoucherCode / _b.saleOrderCode/saleOrderId | shipments.business_id | D? |
|
||||||
|
| 订单/发货→回款 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;_b.saleordercode | receipts.business_id | D? |
|
||||||
|
| 回款→发货批次 | 系统人工指定(对照表 #18) | receipts.shipment_id | S |
|
||||||
|
| 明细行幂等 | 明细表 ID/code;无显式行号 | 建议补 source_line_no | D? |
|
||||||
|
|
||||||
|
## 11. 枚举与状态
|
||||||
|
|
||||||
|
| 枚举 | T+ 来源 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 收款/结算方式 | AA_SettleStyle(code/name/disabled) | 完整枚举需随数据导出 |
|
||||||
|
| 单据状态 | 各单据 voucherState / isCancel | 仅同步校验,不落库 |
|
||||||
|
| 核销状态 | ARAP_ReceivePayment.cancelState | 含义待 IT |
|
||||||
|
| 业务类型 | SA_SaleOrder.idbusinesstype → 业务类型表 | 是否等同特殊业务待确认 |
|
||||||
|
| 产品分类 | AA_InventoryClass | 仅辅助系统分类人工维护 |
|
||||||
|
|
||||||
|
## 12. 系统侧目标表缺口
|
||||||
|
|
||||||
|
| 缺口 | 说明 | 建议 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `personnel.personnel_code` | T+ 工号/编码是否即 `source_no` 未定 | 若不等则补唯一列 |
|
||||||
|
| `customers.status` / 启停时间 | T+ 有 disabled,系统无列 | 补 status 与停用时间 |
|
||||||
|
| `receipts.payment_method` / `confirm_status` | T+ 有结算方式与核销状态 | 补两列并接枚举 |
|
||||||
|
| `source_created_at` / `source_updated_at` | 本地 update_time 不可作源端水位 | 补列或建 staging 表 |
|
||||||
|
| `source_line_no` / `source_item_id` | 明细表无行号 | 补列支持行级幂等 |
|
||||||
|
| 负向事件模型 | 无删除/作废/红冲/退货事件表 | 设计事件表或冲销单 |
|
||||||
|
| `sync_log` / 失败明细表 | 完全缺失 | 同步模块前置 |
|
||||||
|
| T+ 配置项 | `.env` 有 `T_PLUS_*`,`Settings` 未定义 | 补配置字段与校验 |
|
||||||
|
|
||||||
|
## 13. IT 待确认事项收敛(18 → 13 + 1 组业务)
|
||||||
|
|
||||||
|
| 新# | 原# | 事项 | 字典证据 | 字典可答 | 仍需确认 | 优先级 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType;AA_Inventory.invSCost/latestCost/avagCost | 字段存在性、类型精度、挂在订单还是出库单 | 请确认绩效成本取哪个字段(订单最新成本 / 出库成本 / 其他),取数时点与精度 | 高(阻塞核算) |
|
||||||
|
| 2 | 2+3 | 发货日与到账日口径 | 发货:SA_SaleDelivery.voucherdate/SendDate/deliveryDate/auditeddate;到账:ARAP_ReceivePayment.voucherDate/auditedDate/createdTime | 各日期字段存在性与类型 | 请分别确认发货日、到账日取哪个字段;签收日/银行到账日能否从 T+ 获取 | 高 |
|
||||||
|
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键;SA_SaleOrder.ismodifiedcode、HasEverChanged | 编码字段与主键关系 | 各单据 code 是否唯一终身不变;作废重开是否换号;建议以 code 作 source_no | 高 |
|
||||||
|
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode/idsourcevouchertype;SA_SaleDelivery_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b;ARAP_StrikeBalance | 关联字段与核销表存在性 | 请确认订单→发货→回款的主关联路径(按单号还是 ID),以及分批发货/部分核销如何取 | 高 |
|
||||||
|
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment/isSalesman;AA_Department.idparent/iddirector | 编码/部门/负责人字段存在;无直属上级字段 | AA_Person.code 是否即工号;业务员→营销经理隶属在 T+ 如何维护(部门负责人?自定义项?) | 高 |
|
||||||
|
| 6 | 7 | 对接方式与安全 | 字典不涉及 | 无 | REST/视图/中间表/文件;只读账号与凭据、接口文档、样例数据 | 高 |
|
||||||
|
| 7 | 8 | 负数/红字/退货/冲销规则 | SA_SaleDelivery.isCounteractDelivery;SA_SaleDelivery_b.returnQuntity/backSentQuantity;ARAP_ReceivePayment.voucherstate;ARAP_StrikeBalance | 负向字段/单据形式存在性 | 负向单据的实际使用形式、原单关联字段、是否推送;绩效影响转业务 | 高 |
|
||||||
|
| 8 | 9 | 存量起始与增量水位 | 各表 updated/createdTime/madeDate/auditedDate/ts | 时间戳字段存在性 | 存量起始日期;增量用哪个时间戳(updated? ts?);时区 | 中 |
|
||||||
|
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 编码/名称/负责人/停用字段 | 客户唯一键;改名/换负责人/合并如何映射同一主体;负责人取 Contact 还是 representative | 中 |
|
||||||
|
| 10 | 11+16 | 收款方式与确认/核销枚举 | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle.code/name;ARAP_ReceivePayment.cancelState/voucherstate | 枚举表与状态字段存在性 | 收款方式完整枚举与以物抵顶对应代码;核销/确认状态枚举含义及可否作为人工确认依据 | 高 |
|
||||||
|
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量/价格字段存在性 | 产品「数量」取现存量/在途/累计销量;发货单价取订单价还是发货时价、含税与否 | 中 |
|
||||||
|
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 字段/表存在性 | 特殊业务是否有可识别字段;市场拓展计划 T+ 是否有表可提供;无则人工维护 | 低 |
|
||||||
|
| 13 | 15 | 同步窗口 | 字典不涉及 | 无 | 每周同步时间点,避开结账/备份;失败重试策略 | 低 |
|
||||||
|
| B1 | 18 | 业务裁决 5 项 | 税额/折扣/退货/预收/手续费字段已定位(待答复后决定是否建列) | 字段存在性 | 含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述 | 业务裁决 |
|
||||||
|
|
||||||
|
### 原 18 项对照
|
||||||
|
|
||||||
|
| 原# | 事项 | 原优先级 | 收敛去向 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 成本额取数口径 | 高(阻塞核算) | 1 |
|
||||||
|
| 2 | 发货日期口径 | 高 | 2 |
|
||||||
|
| 3 | 到账日期口径 | 高 | 2 |
|
||||||
|
| 4 | 幂等唯一键 | 高 | 3 |
|
||||||
|
| 5 | 三单关联键 | 高 | 4 |
|
||||||
|
| 6 | 人员匹配键与经理隶属(v1.2 更新) | 高 | 5 |
|
||||||
|
| 7 | 对接方式与安全 | 高 | 6 |
|
||||||
|
| 8 | 负数 / 红字 / 退货 / 冲销处理规则 | 高 | 7 |
|
||||||
|
| 9 | 存量范围与增量口径 | 中 | 8 |
|
||||||
|
| 10 | 客户档案匹配与唯一键 | 中 | 9 |
|
||||||
|
| 11 | 收款方式枚举 | 中 | 10 |
|
||||||
|
| 12 | 产品「数量」语义 | 中 | 11 |
|
||||||
|
| 13 | 发货单价口径 | 中 | 11 |
|
||||||
|
| 14 | 特殊业务标记 | 低 | 12 |
|
||||||
|
| 15 | 同步窗口 | 低 | 13 |
|
||||||
|
| 16 | 回款确认 / 核销状态 | 高 | 10 |
|
||||||
|
| 17 | 市场拓展计划数据 | 低 | 12 |
|
||||||
|
| 18 | 口径裁决 5 项(移交业务方) | 业务裁决 | B1 |
|
||||||
|
|
||||||
|
## 14. 待确认清单
|
||||||
|
|
||||||
|
**IT(13 项)**:见第 13 节新# 1–13。
|
||||||
|
|
||||||
|
**业务(1 组)**:含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18 / 待澄清 #20–#24)。
|
||||||
|
|
||||||
|
**系统侧(不阻塞 IT)**:补 `personnel_code`、`customers.status`、`receipts.payment_method/confirm_status`、`source_updated_at`、`source_line_no`、`sync_log` 与 T+ 配置项。
|
||||||
|
|
||||||
|
## 15. 验证记录
|
||||||
|
|
||||||
|
- 解包:1,457 个 HTML、`.hhc/.hhk` 齐全、零字节 0;
|
||||||
|
- 全量解析:1,457 页、938 个 Table 类型页、**931 张表、21,019 个字段、0 失败页**;
|
||||||
|
- 编码:逐页检测,GB18030(无替换字符);
|
||||||
|
- 抽样核验:六类主表结构一致(6 列字段表),关键字段逐项比对通过;
|
||||||
|
- 输入 SHA-256:`3e8cd3146e22bdebe8bacda56b5dafbbd8be744c4279d7b2bbf3d7ecdc7e2726`;
|
||||||
|
|
||||||
|
## 附录 A:六类主表索引
|
||||||
|
|
||||||
|
| 类别 | T+ 表 | 字典页 GUID |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 人员 | AA_Person | `fcd00c17-6778-4c6a-90ae-3f0dbaf2c102` |
|
||||||
|
| 产品 | AA_Inventory | `93b57dfa-4ca4-411d-a883-6d26c8e01787` |
|
||||||
|
| 客户 | AA_Partner | `871e93dd-c2cf-4bd2-af91-cbeb5951ac5e` |
|
||||||
|
| 业务 | SA_SaleOrder | `9a51f8a9-658a-4f7c-9372-8af2257511ee` |
|
||||||
|
| 发货 | SA_SaleDelivery | `bef98b52-c9f0-4d80-8e62-8968cced5b52` |
|
||||||
|
| 回款 | ARAP_ReceivePayment | `709de6e4-3b3d-4cf8-b5ed-5f657d42599a` |
|
||||||
|
|
||||||
|
## 附录 B:75 项覆盖矩阵
|
||||||
|
|
||||||
|
| 节 | 项数 | 已映射 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 公共要求 | 2 | 2 |
|
||||||
|
| 人员 | 9 | 9 |
|
||||||
|
| 产品 | 7 | 7 |
|
||||||
|
| 业务 | 13 | 13 |
|
||||||
|
| 发货 | 8 | 8 |
|
||||||
|
| 回款 | 10 | 10 |
|
||||||
|
| 客户 | 5 | 5 |
|
||||||
|
| 同步完整性 | 21 | 21 |
|
||||||
|
|
||||||
|
## 附录 C:失败页
|
||||||
|
|
||||||
|
无(`failures.csv` 为空)。
|
||||||
|
|
||||||
|
## 附录 D:复现方法
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. C:/Windows/hh.exe -decompile <out> <TPlus123.chm>
|
||||||
|
2. py -3.14 -I 03_extract_fields.py --extracted <out> --out <out>
|
||||||
|
3. py -3.14 -I 08_build_md.py --out <out> --target <md>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本文档由数据字典解析与人工核验生成,字段候选未经 IT 确认前不得视为同步契约。*
|
||||||
Binary file not shown.
@@ -45,12 +45,12 @@
|
|||||||
② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游)
|
② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游)
|
||||||
③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户
|
③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户
|
||||||
│ 增量(2026-10-15):二次分配方案(合同刚建立时录入)
|
│ 增量(2026-10-15):二次分配方案(合同刚建立时录入)
|
||||||
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
④ 绩效工资核算 ✅ ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
||||||
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清)
|
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1)
|
||||||
⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
|
⑦ 复核审批流(新)✅ ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
|
||||||
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约
|
⑤ 报表统计 ✅(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约(待 IT 18 项答复)
|
||||||
⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层
|
⑧ 工作台(新)✅ ← 消费 ④⑤⑥⑦ 数据的聚合展示层
|
||||||
⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早
|
⑨ 备份恢复与运维检查(新)✅ ← 独立于业务块,可与任一批次并行,建议尽早
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.2 顺序论证(要点)
|
### 1.2 顺序论证(要点)
|
||||||
@@ -68,12 +68,12 @@
|
|||||||
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 |
|
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 |
|
||||||
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
||||||
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 |
|
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 |
|
||||||
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(语义待澄清,见 3.3) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
|
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
|
||||||
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
|
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计。**✅ 已完成(2026-10-19)** | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
|
||||||
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发。**⏳ 阻塞(IT 18 项未答复)** | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
||||||
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions) | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
|
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions)。**✅ 已完成(2026-10-19;权限细目待业务清单)** | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
|
||||||
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态 | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
|
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态。**✅ 已完成(2026-10-19)** | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
|
||||||
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本 | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
|
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本。**✅ 已完成(2026-10-16)** | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
|
||||||
|
|
||||||
### 1.4 里程碑(交付批次)
|
### 1.4 里程碑(交付批次)
|
||||||
|
|
||||||
@@ -81,9 +81,9 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) |
|
| **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) |
|
||||||
| **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) |
|
| **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) |
|
||||||
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流 | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」;开工前必须澄清:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7);内部建议顺序 ①增量 → ③增量 → ⑦ 审批框架 → ④ 核算 → 月结与审批串联 |
|
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流。**✅ 已完成(2026-10-19,feat/m3-increments)** | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」已交付;余 3.3-1 答复与审批权限细目。开工前澄清项:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7,已参数化) |
|
||||||
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台 | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
|
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台。**✅ ⑤⑧ 已完成(2026-10-19);⑥ 待 IT 18 项答复** | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
|
||||||
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付) | 数据持续积累,备份先行;不依赖业务模块进度 |
|
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付)。**✅ 已完成(2026-10-16)** | 数据持续积累,备份先行;不依赖业务模块进度 |
|
||||||
|
|
||||||
### 1.5 需求依据对照
|
### 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.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(四) | — |
|
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R);增量见 3.1(四) | — |
|
||||||
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
|
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
|
||||||
| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——尚未回填需规与对照表,引用以第三章原文为准 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制,关系待业务澄清(3.3-1) |
|
| ⑦⑧⑨ 及各块增量 | **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(四) 字段为准,导出前与业务对一次列 |
|
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
|
||||||
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
|
| 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 功能清单核查补充(新增需求)
|
||||||
|
|
||||||
> **来源与地位:** 用户以现有功能清单核查后补充的需求(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 需求原文(照录)
|
### 3.1 需求原文(照录)
|
||||||
|
|
||||||
@@ -656,6 +658,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
|||||||
#### (六)系统设置
|
#### (六)系统设置
|
||||||
|
|
||||||
- 公司信息配置:公司名称、logo 等基本信息
|
- 公司信息配置:公司名称、logo 等基本信息
|
||||||
|
- **2026-10-20 回填**:参数注册表已扩至 22 项(V8/V10 增补审批开关、试用期迁移、预警阈值等)
|
||||||
|
|
||||||
#### (七)参数配置强调
|
#### (七)参数配置强调
|
||||||
|
|
||||||
@@ -666,7 +669,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
|||||||
#### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接
|
#### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接
|
||||||
|
|
||||||
- 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `businesses` 创建同事务或紧随其后);合同建立后是否允许补录/修改未明说,方案修改的留痕方式待设计;
|
- 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `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 已有该状态可复用) |
|
| 待办事项 | ⑦ 待审批单、⑥ 同步失败、回款未指定批次(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 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。
|
- **登录角色体系扩展**:营销后勤/财务/领导是系统新登录角色(`roles` 表基础设施已有;`permissions`/`role_permissions` 自 M1 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。
|
||||||
|
|
||||||
|
> **2026-10-20 回填**:审批框架与月结审批对象已交付(2026-10-19);权限点清单仍待业务方给出(澄清 #6,催办 B-6),当前按 role_code 粗粒度运行,营销后勤暂不可见审批列表。
|
||||||
|
|
||||||
#### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板
|
#### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板
|
||||||
|
|
||||||
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」定义待澄清(3.3-3);
|
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」已答复为人工确认、可手动关闭(3.3-3),月结按已确认回款取数;
|
||||||
- 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径;
|
- 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径;
|
||||||
- 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态);
|
- 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态);
|
||||||
- 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据;
|
- 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据;
|
||||||
@@ -708,6 +713,8 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
|||||||
|
|
||||||
### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清)
|
### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清)
|
||||||
|
|
||||||
|
> **回填状态(2026-10-20):** 下表保留 2026-10-15 原始问题与建议默认(历史快照);业务答复与落实情况见 3.3.1。3.3-1 仍无业务答复,开发侧已按暂定口径实现分摊拆行,答复后如有变更按口径调整。
|
||||||
|
|
||||||
| # | 事项 | 影响模块 | 建议默认处理 |
|
| # | 事项 | 影响模块 | 建议默认处理 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 |
|
| 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 |
|
||||||
@@ -718,27 +725,41 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
|||||||
| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 |
|
| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 |
|
||||||
| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 |
|
| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 |
|
||||||
|
|
||||||
### 3.3.1 答复回填(2026-10-19)
|
### 3.3.1 答复回填(2026-10-19,2026-10-20 修订)
|
||||||
|
|
||||||
> 业务方 24 项答复已到(2026-10-16,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列),2026-10-19 全部落实并实现;本节为 3.3 各项的落定状态,供后续引用。答复与默认不同处以答复为准;仍未答复项见 `docs/待答复催办清单_2026-10-19.md`。
|
> **修订说明(2026-10-20):** 2026-10-19 版本把 24 项统一写成「全部落实并实现」,与 3.3-1 答复空白、开发侧暂定分摊的事实不符。本次按「业务答复 / 落实状态 / 关联项」重写;3.3-1 明确标注为开发侧暂定口径,不视为业务确认。
|
||||||
|
>
|
||||||
|
> 业务方 24 项答复于 2026-10-16 到达(回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)。分组:确认按建议默认 13 项(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#22/#23/#24,其中 #22 为模糊确认按不计入);与默认不同、已按答复落实 4 项(#5/#14/#16/#19);部分答复 3 项(#4/#6/#12);仍开放 4 项(#1/#2/#3/#20)。未答复项对外跟催见 `docs/待答复催办清单_2026-10-19.md`。
|
||||||
|
|
||||||
| 3.3# | 事项 | 落定状态 |
|
| 3.3# | 事项 | 业务答复(2026-10-16) | 落实状态(2026-10-20) | 关联项 / 证据 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 关系) | **答复仍空白;2026-10-19 已按建议默认实现分摊拆行**——分配标的=绩效金额份额(参与人份额×比例、负责人拿余额、行合计守恒、合计>100% 与负责人自分 400 拦截);协助 25% 维持仅记录不核算;业务答复后如有变更走口径调整 |
|
| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 的关系) | **未答复**(澄清 #1–#3 空白) | **开发侧暂定口径已实现**(非业务答复):分配标的=绩效金额份额;参与人=份额×比例,负责人=全额−已分累计(余额归负责人);行合计守恒;合计>100% 与负责人自分 400 拦截;重算作废全部拆行。协助 25% 维持仅记录不核算。**答复后校准** | 澄清 #1/#2/#3;催办一、A-1 至 A-3;实现 ccfd206 |
|
||||||
| 2 | 二次分配审批开关默认值 | 已答复:**默认开启**,可手动关闭(已落实 V8 参数种子) |
|
| 2 | 二次分配审批开关默认值 | 默认开启,可手动关闭 | 已落实:V8 参数种子默认开启;关闭时提交即生效(自动通过留痕) | 澄清 #5;`approval.secondary_allocation.enabled` |
|
||||||
| 3 | 「确认回款」定义 | 已答复:需人工确认动作,可手动关闭(系统已按此预留并实现) |
|
| 3 | 「确认回款」定义 | 需人工确认动作,可手动关闭 | 已落实:月结按已确认回款取数;回款确认状态可手动关闭 | 澄清 #8 |
|
||||||
| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认;现按最小授权运行(后勤=提交录入,财务=复核+导出,领导=审核只读),营销后勤暂不可见审批列表 |
|
| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认 | 按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读;营销后勤暂不可见审批列表;permissions/role_permissions 细目待清单 | 澄清 #6;催办一、B-6 |
|
||||||
| 5 | 风险预警规则 | 已答复:方向确认参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步——⑧ 工作台实现 |
|
| 5 | 风险预警规则 | 方向确认参数化;首批三条未点名 | 已按默认三条实现:`warning.enabled`、`warning.receipt.overdue.days=90`、`warning.approval.timeout.days=3`(V10);同步失败类待 ⑥ | 澄清 #10 |
|
||||||
| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 已答复:同一审批链;跨月调整需管理员授权并留痕(已实现) |
|
| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 同一审批链;跨月需管理员授权并留痕 | 已落实:调整单走同一审批链;跨月调整需管理员授权并留痕 | 澄清 #9 |
|
||||||
| 7 | 待办事项范围 | 已答复:待审批 + 同步失败 + 回款未指定批次起步——⑧ 工作台实现 |
|
| 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 对路线图的影响(本次已修订)
|
### 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` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
||||||
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
|
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
|
||||||
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
|
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` 最新运行记录为准)。
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
|
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
|
||||||
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
|
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
|
||||||
> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。
|
> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。
|
||||||
|
> **2026-10-20 更新**:开发计划 3.3 已回填(3.3.1),SRS V2.1 已交付;二次分摊已按开发侧暂定口径实现(#1–#3 答复仍空白)。
|
||||||
|
|
||||||
## 一、立即待办(需项目内处理)
|
## 一、立即待办(需项目内处理)
|
||||||
|
|
||||||
@@ -14,16 +15,16 @@
|
|||||||
|
|
||||||
## 二、业务方澄清(24 项,**2026-10-16 答复已到**,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)
|
## 二、业务方澄清(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 项)
|
### 高优先级(阻塞对应模块开工,8 项)
|
||||||
|
|
||||||
| 清单编号 | 问题 | 建议默认(未答复时执行) | 阻塞模块 | 答复状态 |
|
| 清单编号 | 问题 | 建议默认(未答复时执行) | 阻塞模块 | 答复状态 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 未答复前只做方案录入,不实现分摊(已按此交付) | ④ 分摊计算 | ❌ 未答复 |
|
| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 方案录入已交付;2026-10-19 已按开发侧暂定口径实现分摊拆行(非业务答复,答复后校准) | ④ 分摊计算 | ❌ 未答复 |
|
||||||
| 2 | 分配比例合计不足 100% 时余额归谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | ❌ 未答复 |
|
| 2 | 分配比例合计不足 100% 时余额归谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | ❌ 未答复 |
|
||||||
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ | ❌ 未答复 |
|
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ | ❌ 未答复 |
|
||||||
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊实现(88 批次)进行中 |
|
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊已于 2026-10-19 实现(ccfd206) |
|
||||||
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** |
|
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** |
|
||||||
| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 |
|
| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 |
|
||||||
| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 |
|
| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 |
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL |
|
| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL |
|
||||||
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` |
|
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` |
|
||||||
| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 |
|
| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 |
|
||||||
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ❌ 未答复:「当前未提到预收款情况」,暂按不计入 |
|
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ⚠️ 模糊答复:「当前未提到预收款情况」,暂按不计入 |
|
||||||
|
|
||||||
### 低优先级(已有默认口径,顺手确认,7 项)
|
### 低优先级(已有默认口径,顺手确认,7 项)
|
||||||
|
|
||||||
@@ -55,8 +56,9 @@
|
|||||||
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
|
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
|
||||||
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
|
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
|
||||||
|
|
||||||
## 三、待 IT / T+ 侧答复(18 项,见《T+对接数据需求清单》v1.3「待IT确认事项」表)
|
## 三、待 IT / T+ 侧答复(18 项 → 收敛为 13 项 + 1 组业务裁决)
|
||||||
|
|
||||||
|
> **2026-10-20 更新**:T+12.3 数据字典已解析(1,457 页 / 931 张表 / 21,019 个字段),字段级映射已产出:`docs/T+字段映射草案_v0.1_2026-10-20.md`、`docs/T+对接数据需求清单_v1.5_2026-10-20.xlsx`。原 18 项收敛为 13 项 IT 待确认(每项附字典候选字段)+ 1 组业务裁决,详见催办清单第二节与 v1.5「待IT确认事项」sheet。以下原表保留为 2026-10-19 基线。
|
||||||
> **2026-10-19 清单已升 v1.4**(`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`,七节:人员/产品/业务单据/发货单/回款单/客户档案/同步完整性与补充字段)——本节口径待收编到 v1.4 后再对外;待 IT 逐项确认的状态以最新版文件为准。
|
> **2026-10-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 开放引用收敛;特殊业务标记待确认降为低优。
|
> **2026-10-16 业务方已对「口径与用途说明」修订,已收编清单升 v1.3**:#18 回款↔发货批次定稿**人工指定**(T+ 核销关系仅作辅助,`shipment_id` 保持「同步不覆盖」,v1.2.1「T+ 主源」修订作废,M2 回款页现状不动);「确认回款」两处 #8 开放引用收敛;特殊业务标记待确认降为低优。
|
||||||
> **新增追问 2 条**(随下一轮回执发业务方):① 「负责营销人员」口径被改为「人员编码**以姓名**为唯一识别」,与「编码是业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误;② 数据清单回款「关联订单号」仍写「由 T+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。
|
> **新增追问 2 条**(随下一轮回执发业务方):① 「负责营销人员」口径被改为「人员编码**以姓名**为唯一识别」,与「编码是业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误;② 数据清单回款「关联订单号」仍写「由 T+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。
|
||||||
@@ -83,7 +85,7 @@
|
|||||||
|
|
||||||
| 里程碑 | 内容 | 前置条件 |
|
| 里程碑 | 内容 | 前置条件 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;**余**:二次分摊实现(#1–#3 答复空白)、业务答复回填批次收口(88 会话) | #20(含税口径)、#1–#3 答复后按同粒度推进 |
|
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;二次分摊已按开发侧暂定口径实现(ccfd206),业务答复回填已完成(计划 3.3.1,2026-10-20);**余**:#1–#3/#20 答复与 #4/#6/#12 补值 | #20(含税口径)、#1–#3 答复后按同粒度推进 |
|
||||||
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 |
|
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 |
|
||||||
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
|
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
|
||||||
| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
|
| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
|
||||||
@@ -92,6 +94,6 @@
|
|||||||
## 五、文档待办(低优)
|
## 五、文档待办(低优)
|
||||||
|
|
||||||
- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准
|
- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准
|
||||||
- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.4**(2026-10-19,本节口径待收编;生成脚本 build_tplus_xlsx.py 已对齐 v1.3、待对齐 v1.4)
|
- 澄清答复已到(2026-10-16):开发计划 3.3 已回填(3.3.1,2026-10-20)、SRS V2.1 已交付(2026-10-19);《T+对接数据需求清单》已升 **v1.5**(2026-10-20,含 75 项映射 + 13 项 IT 收敛 + 不覆盖 12 条),映射草案 v0.1 已产出
|
||||||
- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)**
|
- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)**
|
||||||
- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可
|
- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可
|
||||||
+27
-23
@@ -1,7 +1,7 @@
|
|||||||
# 待答复事项催办清单(2026-10-19)
|
# 待答复事项催办清单(2026-10-19)
|
||||||
|
|
||||||
> **用途**:对外跟催材料,分三块可直接拆分发送——一、业务方;二、IT/T+ 侧;三、报表口径补充确认(本轮新增)。
|
> **用途**:对外跟催材料,分三块可直接拆分发送——一、业务方;二、IT/T+ 侧;三、报表口径补充确认(本轮新增)。
|
||||||
> **基线**:《待澄清问题清单_2026-10-15(1)(2).xlsx》(业务 24 项,2026-10-16 已回 15 项)、《T+对接数据需求清单》v1.3「待IT确认事项」18 项 + v1.4(2026-10-19 勘误)、《问题与解决方案对照表_v2》(28 项已裁决口径)。
|
> **基线**:《待澄清问题清单_2026-10-15(1)(2).xlsx》(业务 24 项,2026-10-16 已答复 19 项,其中部分答复 3 项;#1–#3/#20 仍空白)、《T+对接数据需求清单》v1.3「待IT确认事项」18 项 + v1.4(2026-10-19 勘误)、《问题与解决方案对照表_v2》(28 项已裁决口径)。
|
||||||
> **原则**:已答复项不重复询问;未答复项按清单内「建议默认」执行中,答复后以答复为准并回填文档。
|
> **原则**:已答复项不重复询问;未答复项按清单内「建议默认」执行中,答复后以答复为准并回填文档。
|
||||||
|
|
||||||
## 一、业务方(建议回复期限:____)
|
## 一、业务方(建议回复期限:____)
|
||||||
@@ -10,12 +10,12 @@
|
|||||||
|
|
||||||
| # | 问题(原文口径) | 当前执行默认 | 阻塞 |
|
| # | 问题(原文口径) | 当前执行默认 | 阻塞 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 未答复前只做方案录入,**不实现分摊计算**(已按此交付) | ④ 分摊计算 |
|
| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 方案录入已交付;**分摊已按开发侧暂定口径实现**(非业务答复,答复后校准) | ④ 分摊计算 |
|
||||||
| 2 | 分配比例合计不足 100% 时,剩余部分归属谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 |
|
| 2 | 分配比例合计不足 100% 时,剩余部分归属谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 |
|
||||||
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ 分摊 |
|
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ 分摊 |
|
||||||
| 20 | 含税口径:销售/成本/毛利润/运费各项是否含税?价税合计与折扣折让如何处理? | 按不含税口径实现中 | ④ 核算口径终验、⑥ 同步建列(是否建税率/税额列) |
|
| 20 | 含税口径:销售/成本/毛利润/运费各项是否含税?价税合计与折扣折让如何处理? | 按不含税口径实现中 | ④ 核算口径终验、⑥ 同步建列(是否建税率/税额列) |
|
||||||
|
|
||||||
> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批,但**分摊金额不会出现在绩效中**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。
|
> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批;开发侧已按暂定口径把分摊拆行计入绩效(参与人份额×比例、负责人余额),**标的与余额归属仍待业务答复,答复后可能调整**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。
|
||||||
|
|
||||||
### B. 已有答复但需补具体值 / 勾选(4 项)
|
### B. 已有答复但需补具体值 / 勾选(4 项)
|
||||||
|
|
||||||
@@ -35,35 +35,39 @@
|
|||||||
|
|
||||||
### 已按默认执行、无需回复的项(供核阅)
|
### 已按默认执行、无需回复的项(供核阅)
|
||||||
|
|
||||||
#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。
|
\#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。
|
||||||
|
|
||||||
## 二、IT / T+ 侧(建议回复期限:____)
|
## 二、IT / T+ 侧(建议回复期限:____)
|
||||||
|
|
||||||
> 2026-10-15 回稿收到后,v1.3「待IT确认事项」18 项**未逐项答复**(回稿仅做了必要性升级与第七节补充),机制性内容(同步频率/幂等/格式要求)未包含,「同步不覆盖字段」表被引用但未附。现有 v1.4 清单中「系统侧目标表.字段」列**全表留空待回填**。
|
> **2026-10-20 更新**:已用 T+12.3 数据字典完成字段级核对(1,457 页 / 931 张表 / 21,019 个字段),下列问题由 18 项收敛为 **13 项 IT 待确认 + 1 组业务裁决**,每项附字典候选字段。字段级证据与来源页见《T+字段映射草案_v0.1_2026-10-20.md》,完整清单见《T+对接数据需求清单_v1.5_2026-10-20.xlsx》。
|
||||||
|
|
||||||
### A. 高优先(9 项,阻塞核算口径与同步程序开工)
|
### A. 待确认(按优先级)
|
||||||
|
|
||||||
| # | 事项 | 为什么高 |
|
| 新# | 原# | 事项 | 字典候选(已定位) | 仍需答复 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| 1 | **成本额取数口径**:成本类型/毛利计算方式/更新时间/精度 | 毛利润 = 销售额 − 成本额,是绩效金额全部源头;缺此则同步无法自动跑,只能人工录入 |
|
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType | 取订单最新成本还是出库成本;取数时点与精度 |
|
||||||
| 2 | 发货日期口径:出库日 / 签收日 / 开票日 | 回款天数基准日(发货日首日计第 1 天),直接决定计提比例 |
|
| 2 | 2+3 | 发货日与到账日口径 | 发货:voucherdate/SendDate/deliveryDate/auditeddate;到账:voucherDate/auditedDate/createdTime | 分别取哪个字段;签收日/银行到账日能否提供 |
|
||||||
| 3 | 到账日期口径:收款日 / 银行到账日 / 核销日 | 计提参数版本匹配日,直接决定适用哪一版比例 |
|
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键 | code 是否终身唯一;作废重开是否换号 |
|
||||||
| 4 | 幂等唯一键规则:作废重开是否换号 | 重复同步不重复入库的前提 |
|
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode、_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_StrikeBalance | 主关联路径;分批发货/部分核销如何取 |
|
||||||
| 5 | 三单关联键:订单 / 发货 / 回款如何互相关联 | 一笔业务可分批发货;回款↔批次系统内人工指定,但订单↔发货关联仍必须由 T+ 提供 |
|
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment;AA_Department.idparent/iddirector(无直属上级字段) | code 是否即工号;业务员→经理隶属如何维护 |
|
||||||
| 6 | **人员唯一编码(工号)** + 业务员↔营销经理隶属关系 | 业绩归属唯一锚点(重名即错钱);经理 12.5% 团队聚合依隶属关系展开 |
|
| 6 | 7 | 对接方式与安全 | 字典不涉及 | REST/视图/中间表/文件;只读账号、接口文档、样例数据 |
|
||||||
| 7 | 对接方式与安全:REST / 数据库视图 / 中间表 / 文件交换 | 未定则同步程序无法开工(一期人工录入可闭环) |
|
| 7 | 8 | 负数/红字/退货/冲销规则 | isCounteractDelivery、returnQuntity/backSentQuantity、voucherstate、ARAP_StrikeBalance | 实际使用形式、原单关联、是否推送 |
|
||||||
| 8 | 负数 / 红字 / 退货 / 冲销处理规则与原单关联 | 决定第七节补充字段如何建列;如不提供则只能靠人工补录 |
|
| 8 | 9 | 存量起始与增量水位 | updated/createdTime/madeDate/auditedDate/ts | 存量起始日;增量用哪个时间戳;时区 |
|
||||||
| 16 | 回款确认 / 核销状态字段与枚举 | 月度结算按「确认回款」生成,需 T+ 核销/确认类字段作人工确认依据 |
|
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 唯一键;改名/换负责人/合并;负责人取 Contact 还是 representative |
|
||||||
|
| 10 | 11+16 | 收款方式与确认/核销枚举 | AA_SettleStyle;ARAP_ReceivePayment.IdSettleStyle/cancelState/voucherstate | 完整枚举与以物抵顶代码;核销状态含义与人工确认依据 |
|
||||||
|
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量取现存量/在途/累计销量;发货单价口径与含税 |
|
||||||
|
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 有无可识别字段/表;无则人工维护 |
|
||||||
|
| 13 | 15 | 同步窗口 | 字典不涉及 | 每周时间点,避结账/备份;失败重试策略 |
|
||||||
|
|
||||||
### B. 回稿三缺口(请补)
|
### B. 口径裁决(1 组,转业务方)
|
||||||
|
|
||||||
1. v1.3 第七节曾漏「人员唯一编码(工号)」——v1.4 数据清单第一节已补★项并注明「待IT确认 #19:T+ 档案编码是否即工号,若为自动内部编码请指明工号字段」;
|
含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18,对应《待澄清问题清单》#20–#24)。
|
||||||
2. 「客户档案是否同步」(待IT确认 #10)未点名答复——v1.4 第六节已列客户档案 5 项,请确认可提供;
|
|
||||||
3. 「系统侧目标表.字段(T+ 确认映射)」列全表留空——请回填,这是同步程序的字段契约。
|
|
||||||
|
|
||||||
### C. 中低优先(9 项,模块实现期内答复即可)
|
### C. 已由字典补齐、仅需确认可用性
|
||||||
|
|
||||||
存量数据起始日期与增量口径(影响能出多早的报表)、客户匹配与唯一键、收款方式完整枚举清单、产品「数量」语义(库存量/在途/累计销量)、发货单价口径(订单价/发货时价)、特殊业务标记字段、同步窗口时间、市场拓展计划数据、待IT确认 #18(口径裁决 5 项已转业务方,见第一部分)。
|
1. **人员编码**:字典已定位 `AA_Person.code`,请确认是否即工号;若为自动内部编码,请指明工号字段。
|
||||||
|
2. **客户档案**:字典已定位 `AA_Partner` 主表(编码/名称/联系人/停用/地区等),请确认可提供。
|
||||||
|
3. **映射列**:v1.5 清单已回填「T+ 表.字段 / 类型 / 枚举 / 系统目标列」,请按实际环境核对并修正。
|
||||||
|
|
||||||
## 三、报表口径补充确认(2026-10-19 新增,随本轮开发一并排)
|
## 三、报表口径补充确认(2026-10-19 新增,随本轮开发一并排)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 衍水集团营销人员工资管理系统项目阶段报告
|
||||||
|
|
||||||
|
- **报告日期**:2026-10-20
|
||||||
|
- **报告范围**:M4 ⑤ 报表统计、⑧ 工作台完成情况与 M4 收尾结论
|
||||||
|
- **项目分支**:`feat/m3-increments`(已合并 `main`,merge commit `3611474`)
|
||||||
|
- **阶段结论**:除 ⑥ T+ 数据同步受外部答复阻塞外,十大功能块全部交付;后端全量 **862 用例通过**,报表、工作台、核算月结三条 e2e 链路实跑通过。
|
||||||
|
|
||||||
|
## 一、3P 阶段摘要
|
||||||
|
|
||||||
|
### Progress(进展)
|
||||||
|
|
||||||
|
M4 两块(⑤ 报表统计、⑧ 工作台)已完成并通过 code-review 修复。报表侧交付个人绩效明细、部门汇总、业务统计、回款统计四张报表、Excel 签字导出与独立打印视图;工作台侧交付经营总览、待办事项、风险预警、最近动态四区块,待办与预警实时派生并参数化。本阶段同时完成 M4 修复批次的提交、全量回归与 e2e 实跑,开发计划 3.3 答复状态已回填。
|
||||||
|
|
||||||
|
### Plans(计划)
|
||||||
|
|
||||||
|
下一步优先解析 T+12.3 数据字典,形成六类单据字段映射草案并自答 IT 清单中可自答部分;其次补齐 code-review 遗留的方案版本承载与审批单快照;需规 V2.2 修订与对照表回填择期进行。
|
||||||
|
|
||||||
|
### Problems(问题)
|
||||||
|
|
||||||
|
⑥ T+ 数据同步仍受 IT 18 项答复阻塞,同步失败类待办/预警暂以「模块未启用」显式降级。业务方仍有 #1–#3 二次分摊、#20 含税口径、#4/#6/#12 补值与两处勘误待确认;审批角色权限细目未给出,当前按 `role_code` 粗粒度运行。需规仍含已作废条款,对外引用需注明以对照表/计划第三章为准。
|
||||||
|
|
||||||
|
## 二、阶段定位与完成度
|
||||||
|
|
||||||
|
| 维度 | 当前状态 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 项目阶段 | 开发收尾期 | M1–M3 与 M4 ⑤⑧ 已交付,仅 ⑥ 待外部输入 |
|
||||||
|
| 整体完成度 | 90% | 十大功能块交付;⑥ 按 0% 计入 |
|
||||||
|
| 交付主线 | M4 报表统计 + 工作台 | 前后端闭环,已合并 main |
|
||||||
|
| 质量状态 | 全绿 | 后端 862 passed(11:32);e2e role_home 4/4、report 12/12、calc_settlement 8/8 |
|
||||||
|
| 主要风险 | 外部对接与口径变更 | T+ 映射未定;含税口径未答复可能触发历史绩效重算 |
|
||||||
|
|
||||||
|
## 三、已完成成果
|
||||||
|
|
||||||
|
### 1. 报表统计(M4 ⑤)
|
||||||
|
|
||||||
|
- 四表接口 `/api/v1/reports/*`:个人绩效明细、部门绩效汇总、业务统计、回款区间统计。
|
||||||
|
- 部门归属按 `personnel.manager_id` 实时上溯;回款区间 8 档(≤0 / 1-30 / 31-60 / 61-90 / 91-180 / 181-360 / 361+ / 无天数)。
|
||||||
|
- 全公司 5% 按「有聚合行经理各计一次」计算,并与月结 `total_payable` 对拍;对账块单列聚合行舍入差。
|
||||||
|
- Excel 签字导出(openpyxl + StreamingResponse,ASCII 文件名)与独立打印视图(五块结构、每页表头、签字栏)。
|
||||||
|
- 权限 `admin/finance/leader` 前后端双层。
|
||||||
|
|
||||||
|
### 2. 工作台(M4 ⑧)
|
||||||
|
|
||||||
|
- 单端点 `/api/v1/dashboard` 一次返回四区块:经营总览、待办事项、风险预警、最近动态。
|
||||||
|
- 待办与预警**实时派生**(不落表、不引调度器);待办按角色过滤;逾期预警排除已终止业务。
|
||||||
|
- 预警阈值参数化:`warning.enabled`、`warning.receipt.overdue.days=90`、`warning.approval.timeout.days=3`(V10,参数注册表 22 项)。
|
||||||
|
- 前端 `/dashboard` 四区块 + ECharts 5.6 按需引入(SVG,显式思源黑体,规避 Windows 默认雅黑)。
|
||||||
|
- 普通用户落地页统一由 `router/access.ts` 解析(工作台 → 业务数据 → 回款),修复无限重定向。
|
||||||
|
|
||||||
|
### 3. 质量与收尾
|
||||||
|
|
||||||
|
- 后端全量 `uv run pytest -q` → **862 passed(11:32)**;`pnpm type-check` 与 `pnpm build` 通过。
|
||||||
|
- e2e 实跑:`role_home_flow` 4/4、`report_flow` 12/12、`calc_settlement_flow` 8/8;`report_flow` 回款统计改为按本次业务编号下钻断言,脚本可重复运行。
|
||||||
|
- 运行后核对:月结审批开关恢复为 1,无 `e2e_role_*` 残留账号。
|
||||||
|
- 开发计划 3.3.1 与《待确认事项汇总》已按 24 项答复分组回填(13 默认 / 4 按答复 / 3 部分 / 4 开放)。
|
||||||
|
|
||||||
|
## 四、当前进行中工作
|
||||||
|
|
||||||
|
无。M4 代码与文档已提交并合并 `main`(`3611474`)。
|
||||||
|
|
||||||
|
## 五、下一步计划
|
||||||
|
|
||||||
|
1. **T+ 同步前置**:解析 `docs/T_12.3数据字典/`,产出人员/产品/业务/发货/回款/客户六类字段映射草案,自答 IT 18 项中可从数据字典确认的部分。
|
||||||
|
2. **code-review 遗留 2 项**:方案生效版本承载、审批单内容快照(orders 加方案 JSON 快照列 + 前端详情展示)。
|
||||||
|
3. **需规 V2.2**:修订 26 天示例、含运费公式、协助 25% 等过时条款,回填 2026-10-15 补充需求;对照表 v2 收编 24 项答复。
|
||||||
|
4. **清理类**:5 处超长函数、散参数 DTO、`useDropdownOptions` 余两页、前端菜单图标。
|
||||||
|
5. **待外部输入**:业务方催办、审批权限清单、IT 18 项答复。
|
||||||
|
|
||||||
|
## 六、风险与应对
|
||||||
|
|
||||||
|
| 风险 | 影响 | 应对 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| IT 答复持续缺失 | ⑥ 无法开工,同步类待办/预警无法真实展示 | 先做数据字典映射草案;催办清单已含高优 9 项与三缺口 |
|
||||||
|
| 含税口径(#20)改变 | 历史绩效可能需要重算 | 当前按不含税实现;答复后评估重算范围并授权留痕 |
|
||||||
|
| 二次分摊口径(#1–#3)改变 | 已生成台账/月结可能需要调整 | 暂定口径已标注;变更走口径调整,重算需授权留痕 |
|
||||||
|
| 需规含作废条款 | 对外交付物误导 | 引用以对照表/计划第三章为准;V2.2 修订前排期 |
|
||||||
|
|
||||||
|
## 七、报告结论
|
||||||
|
|
||||||
|
M4 报表统计与工作台已完成交付并合并 `main`,系统除 ⑥ T+ 数据同步外具备完整业务闭环。质量证据充分(全量测试与三条 e2e 链路),剩余工作以外部输入与审计性技术债为主,可按计划推进。
|
||||||
+23
-3
@@ -188,15 +188,16 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
|||||||
| 页面 | 说明 |
|
| 页面 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/reports/personal-detail` 个人绩效明细(新组「报表统计」order19) | 人员/月份/客户类型/产品类别/岗位筛选;需规 2.4.1 全部列;行展开懒加载台账快照;页脚**全筛选合计**(注明非当前页);导出/打印 |
|
| `/reports/personal-detail` 个人绩效明细(新组「报表统计」order19) | 人员/月份/客户类型/产品类别/岗位筛选;需规 2.4.1 全部列;行展开懒加载台账快照;页脚**全筛选合计**(注明非当前页);导出/打印 |
|
||||||
| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司 5% 独立卡片;团队表(基数/12.5%/5%/占比/调整/净合计/月结冻结额);展开成员 → 跨页下钻个人明细;未分配桶 |
|
| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司分成独立卡片(单经理 5% × 计入人数 = 合计,每位有聚合行的经理各计一次);团队表(基数/团队分成/全公司分成/占比/调整/净合计/月结冻结额),净合计与月结聚合行同口径,比例列头随在线参数变化;展开成员 → 跨页下钻个人明细(人员与月份带入筛选);未分配桶(按经理筛选时为空) |
|
||||||
| `/reports/business-stats` 业务统计(order21) | 关键字/客户/归属人/状态/业务日期;发货额/回款额/绩效(累计口径提示);展开下钻回款与台账明细 |
|
| `/reports/business-stats` 业务统计(order21) | 关键字/客户/归属人/状态/业务日期;发货额/回款额/绩效(累计口径提示);展开下钻回款与台账明细 |
|
||||||
| `/reports/payment-stats` 回款统计(order22) | 月份/回款日期/客户类型/产品类别;8 档区间表(空桶 0,档位由后端下发);展开桶下钻分页明细;客户类型/产品类别对比表 |
|
| `/reports/payment-stats` 回款统计(order22) | 月份/回款日期/客户类型/产品类别;8 档区间表(空桶 0,档位由后端下发);展开桶下钻分页明细;客户类型/产品类别对比表 |
|
||||||
| `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(上限 2000 行截断提示);`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 |
|
| `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(以接口 total 判定超过 2000 行并提示截断);部门汇总按「团队分成 → 全公司 5% → 对账 → 成员明细 → 未分配」五块渲染;`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 |
|
||||||
|
|
||||||
- 路由组「报表统计」四页 `allowedRoles: [admin, finance, leader]`(前端菜单 + 后端 `require_roles` 双层;admin 须显式列入)。
|
- 路由组「报表统计」四页 `allowedRoles: [admin, finance, leader]`(前端菜单 + 后端 `require_roles` 双层;admin 须显式列入)。
|
||||||
- api 封装 `src/api/reports.ts`(类型 + 查询 + `exportReport` 裸 axios blob;产品类别标签额外覆盖 `mixed`)。
|
- api 封装 `src/api/reports.ts`(类型 + 查询 + `exportReport` 裸 axios blob;产品类别标签额外覆盖 `mixed`)。
|
||||||
- 权限:营销后勤「授权范围内」待业务权限清单 #6,当前不开放报表。
|
- 权限:营销后勤「授权范围内」待业务权限清单 #6,当前不开放报表。
|
||||||
- type-check 与 build 通过(2026-10-19)。e2e `report_flow.py` 待补(需 `E2E_ADMIN_PASSWORD` 环境)。
|
- type-check 与 build 通过(2026-10-19)。e2e `report_flow.py` 已补 12 步:登录 → API 造数 → 四表筛选/导出下载 → 部门下钻筛选生效 → 回款多桶同时展开互不串行 → 打印视图 → 打印截断提示 → 部门汇总打印五块结构;需 `E2E_ADMIN_PASSWORD` 且前后端在跑。
|
||||||
|
- **e2e 实跑(2026-10-20)**:`login_flow` 8/8、`system_management` 13/13、`role_home_flow` 4/4、`report_flow` 12/12、`calc_settlement_flow` 8/8 全过。`report_flow` 的回款统计改为展开目标桶后按**本次业务编号**定位明细行,不依赖桶累计值,脚本可重复运行(历史 e2e 数据累计不再造成假失败)。
|
||||||
|
|
||||||
## 端到端测试(2026-10-19 追加 · M3 台账与月结)
|
## 端到端测试(2026-10-19 追加 · M3 台账与月结)
|
||||||
|
|
||||||
@@ -210,3 +211,22 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
|||||||
② 三页均无 `onMounted` 初始加载 → 进页不取数;补 `onMounted(() => void load())`。
|
② 三页均无 `onMounted` 初始加载 → 进页不取数;补 `onMounted(() => void load())`。
|
||||||
③ 三页 `AppPagination` 用 `v-model:page-num` + `@change`(组件无该事件、也未实现 `update:*`)→ 翻页失效;统一为 `:page-num/:page-size` + `@page-change/@size-change`(usePagedList 的 onPageChange/onSizeChange)。
|
③ 三页 `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 登录失效」)。
|
**注意**: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`。
|
||||||
@@ -112,10 +112,12 @@ def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -
|
|||||||
fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}")
|
fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}")
|
||||||
|
|
||||||
|
|
||||||
def login(page: Page, username: str, password: str) -> None:
|
def login(page: Page, username: str, password: str, expected_path: str = "/dashboard") -> None:
|
||||||
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。
|
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入落地页。
|
||||||
|
|
||||||
验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。
|
验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。
|
||||||
|
``expected_path`` 按角色传入:管理员/财务/领导默认工作台,普通用户
|
||||||
|
落业务数据页(工作台权限收紧后守卫按角色解析首页)。
|
||||||
"""
|
"""
|
||||||
captcha_ids: list[str] = []
|
captcha_ids: list[str] = []
|
||||||
|
|
||||||
@@ -148,8 +150,8 @@ def login(page: Page, username: str, password: str) -> None:
|
|||||||
page.fill('input[autocomplete="off"]', answer)
|
page.fill('input[autocomplete="off"]', answer)
|
||||||
page.click("button:has-text('登 录')")
|
page.click("button:has-text('登 录')")
|
||||||
page.wait_for_timeout(2000)
|
page.wait_for_timeout(2000)
|
||||||
if current_path(page) != "/dashboard":
|
if current_path(page) != expected_path:
|
||||||
detail = f"未跳转到首页,当前 {page.url}"
|
detail = f"未跳转到 {expected_path},当前 {page.url}"
|
||||||
if message := toast_text(page):
|
if message := toast_text(page):
|
||||||
detail += f",提示:{message}"
|
detail += f",提示:{message}"
|
||||||
fail("登录", detail)
|
fail("登录", detail)
|
||||||
|
|||||||
+140
-8
@@ -159,6 +159,7 @@ def step_setup_via_api(page: Page) -> dict:
|
|||||||
return {
|
return {
|
||||||
"month": MONTH,
|
"month": MONTH,
|
||||||
"business_no": business["business_no"],
|
"business_no": business["business_no"],
|
||||||
|
"personnel_id": person["id"],
|
||||||
"personnel_name": person["personnel_name"],
|
"personnel_name": person["personnel_name"],
|
||||||
"manager_name": manager["personnel_name"],
|
"manager_name": manager["personnel_name"],
|
||||||
}
|
}
|
||||||
@@ -216,7 +217,7 @@ def step_business_stats(page: Page, ctx: dict) -> None:
|
|||||||
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
|
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
|
||||||
if row.count() == 0:
|
if row.count() == 0:
|
||||||
fail("业务统计", f"未找到业务 {ctx['business_no']}")
|
fail("业务统计", f"未找到业务 {ctx['business_no']}")
|
||||||
print("✓ 6. 业务统计 关键字筛选命中")
|
print("✓ 7. 业务统计 关键字筛选命中")
|
||||||
|
|
||||||
|
|
||||||
def step_payment_stats(page: Page, ctx: dict) -> None:
|
def step_payment_stats(page: Page, ctx: dict) -> None:
|
||||||
@@ -225,16 +226,143 @@ def step_payment_stats(page: Page, ctx: dict) -> None:
|
|||||||
page.locator(".filters button:has-text('查询')").first.click()
|
page.locator(".filters button:has-text('查询')").first.click()
|
||||||
page.wait_for_timeout(900)
|
page.wait_for_timeout(900)
|
||||||
|
|
||||||
rows = page.locator(".el-table__row")
|
# 页面同时有区间表与两个维度对比表,必须限定主区间表
|
||||||
|
rows = page.locator('[data-testid="payment-bucket-table"] .el-table__row')
|
||||||
if rows.count() != 8:
|
if rows.count() != 8:
|
||||||
fail("回款统计", f"区间表应为 8 行,实际 {rows.count()}")
|
fail("回款统计", f"区间表应为 8 行,实际 {rows.count()}")
|
||||||
bucket = page.locator(".el-table__row", has_text="61-90 天").first
|
bucket = page.locator(
|
||||||
|
'[data-testid="payment-bucket-table"] .el-table__row', has_text="61-90 天"
|
||||||
|
).first
|
||||||
if bucket.count() == 0:
|
if bucket.count() == 0:
|
||||||
fail("回款统计", "未找到 61-90 天区间行")
|
fail("回款统计", "未找到 61-90 天区间行")
|
||||||
bucket_text = bucket.inner_text()
|
# 按本次业务编号在下钻明细中定位,不依赖桶聚合值(历史 e2e 数据会累计到同桶)
|
||||||
if "10000.00" not in bucket_text:
|
bucket.locator(".el-table__expand-icon").click()
|
||||||
fail("回款统计", f"61-90 天桶回款金额不符:{bucket_text[:120]}")
|
page.wait_for_timeout(1000)
|
||||||
print("✓ 7. 回款统计 8 档区间与目标桶金额正确")
|
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:
|
def step_print_view(page: Page, ctx: dict) -> None:
|
||||||
@@ -253,7 +381,7 @@ def step_print_view(page: Page, ctx: dict) -> None:
|
|||||||
page.wait_for_timeout(400)
|
page.wait_for_timeout(400)
|
||||||
if page.evaluate("window.__printed") is not True:
|
if page.evaluate("window.__printed") is not True:
|
||||||
fail("打印视图", "window.print 未被调用")
|
fail("打印视图", "window.print 未被调用")
|
||||||
print("✓ 8. 打印视图 表格/签字栏/打印调用均正常")
|
print("✓ 10. 打印视图 表格/签字栏/打印调用均正常")
|
||||||
|
|
||||||
|
|
||||||
def run(page: Page) -> None:
|
def run(page: Page) -> None:
|
||||||
@@ -266,9 +394,13 @@ def run(page: Page) -> None:
|
|||||||
ctx = step_setup_via_api(page)
|
ctx = step_setup_via_api(page)
|
||||||
step_personal_detail(page, ctx)
|
step_personal_detail(page, ctx)
|
||||||
step_department_summary(page, ctx)
|
step_department_summary(page, ctx)
|
||||||
|
step_department_drill(page, ctx)
|
||||||
step_business_stats(page, ctx)
|
step_business_stats(page, ctx)
|
||||||
step_payment_stats(page, ctx)
|
step_payment_stats(page, ctx)
|
||||||
|
step_payment_multi_expand(page, ctx)
|
||||||
step_print_view(page, ctx)
|
step_print_view(page, ctx)
|
||||||
|
step_print_truncated(page, ctx)
|
||||||
|
step_print_department(page, ctx)
|
||||||
|
|
||||||
if console_errors:
|
if console_errors:
|
||||||
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
|
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
|
||||||
|
|||||||
@@ -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.click("text=退出登录")
|
||||||
page.wait_for_url("**/login**", timeout=10_000)
|
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:
|
if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0:
|
||||||
fail("菜单权限", "普通用户看到了「系统管理」菜单")
|
fail("菜单权限", "普通用户看到了「系统管理」菜单")
|
||||||
print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组")
|
print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组")
|
||||||
|
|
||||||
page.goto(f"{BASE_URL}/system/users", wait_until="networkidle")
|
page.goto(f"{BASE_URL}/system/users", wait_until="networkidle")
|
||||||
page.wait_for_timeout(800)
|
page.wait_for_timeout(800)
|
||||||
if current_path(page) != "/dashboard":
|
if current_path(page) != "/business/businesses":
|
||||||
fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}")
|
fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}")
|
||||||
if "需要管理员权限" not in toast_text(page):
|
if "需要管理员权限" not in toast_text(page):
|
||||||
fail("路由守卫", "直连管理页未给出权限提示")
|
fail("路由守卫", "直连管理页未给出权限提示")
|
||||||
print("✓ 12. 路由守卫 直连 /system/users 被弹回首页")
|
print("✓ 12. 路由守卫 直连 /system/users 被弹回业务数据页")
|
||||||
|
|
||||||
page.click("text=退出登录")
|
page.click("text=退出登录")
|
||||||
page.wait_for_url("**/login**", timeout=10_000)
|
page.wait_for_url("**/login**", timeout=10_000)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- 默认站点图标:未配置公司 logo 时使用(配置后由 settings store 动态替换) -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>衍水集团营销人员工资管理系统</title>
|
<title>衍水集团营销人员工资管理系统</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^8.1.5",
|
"@vue/devtools-api": "^8.1.5",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
|
"echarts": "^5.6.0",
|
||||||
"element-plus": "^2.14.7",
|
"element-plus": "^2.14.7",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"vue": "^3.5.43",
|
"vue": "^3.5.43",
|
||||||
|
|||||||
Generated
+23
@@ -14,6 +14,9 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.20.0
|
specifier: ^1.20.0
|
||||||
version: 1.20.0(debug@4.4.3)
|
version: 1.20.0(debug@4.4.3)
|
||||||
|
echarts:
|
||||||
|
specifier: ^5.6.0
|
||||||
|
version: 5.6.0
|
||||||
element-plus:
|
element-plus:
|
||||||
specifier: ^2.14.7
|
specifier: ^2.14.7
|
||||||
version: 2.14.7(vue@3.5.43(typescript@5.9.3))
|
version: 2.14.7(vue@3.5.43(typescript@5.9.3))
|
||||||
@@ -399,6 +402,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
echarts@5.6.0:
|
||||||
|
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
|
||||||
|
|
||||||
element-plus@2.14.7:
|
element-plus@2.14.7:
|
||||||
resolution: {integrity: sha512-lrG7pZyCS+mpCNVpzxxyu5r35+T6V0hzkAeIwn7HA6to772XSTHKuYCZG51OfzlqaxGbNegLxs650zAF1j43gA==}
|
resolution: {integrity: sha512-lrG7pZyCS+mpCNVpzxxyu5r35+T6V0hzkAeIwn7HA6to772XSTHKuYCZG51OfzlqaxGbNegLxs650zAF1j43gA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -693,6 +699,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -828,6 +837,9 @@ packages:
|
|||||||
webpack-virtual-modules@0.6.2:
|
webpack-virtual-modules@0.6.2:
|
||||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||||
|
|
||||||
|
zrender@5.6.1:
|
||||||
|
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.29.7': {}
|
'@babel/helper-string-parser@7.29.7': {}
|
||||||
@@ -1155,6 +1167,11 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.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)):
|
element-plus@2.14.7(vue@3.5.43(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ctrl/tinycolor': 4.2.1
|
'@ctrl/tinycolor': 4.2.1
|
||||||
@@ -1435,6 +1452,8 @@ snapshots:
|
|||||||
fdir: 6.5.0(picomatch@4.0.7)
|
fdir: 6.5.0(picomatch@4.0.7)
|
||||||
picomatch: 4.0.7
|
picomatch: 4.0.7
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
ufo@1.6.4: {}
|
ufo@1.6.4: {}
|
||||||
@@ -1521,3 +1540,7 @@ snapshots:
|
|||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
webpack-virtual-modules@0.6.2: {}
|
webpack-virtual-modules@0.6.2: {}
|
||||||
|
|
||||||
|
zrender@5.6.1:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#4a66c4" />
|
||||||
|
<stop offset="1" stop-color="#2e4ba8" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="14" fill="url(#brand)" />
|
||||||
|
<text
|
||||||
|
x="32"
|
||||||
|
y="43"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-family="'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif"
|
||||||
|
font-size="32"
|
||||||
|
font-weight="600"
|
||||||
|
fill="#ffffff"
|
||||||
|
>衍</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 532 B |
@@ -1,5 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { useSettingsStore } from '@/store/settings'
|
||||||
|
|
||||||
// 根组件只承载路由出口;布局由各页面自行决定
|
// 根组件只承载路由出口;布局由各页面自行决定
|
||||||
|
const route = useRoute()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览器标题 =「页面名 - 公司名 系统副标题」,跟随公司信息设置动态变化。
|
||||||
|
*
|
||||||
|
* 用 watch 而非 router.afterEach:品牌信息是异步载入的(匿名端点),
|
||||||
|
* afterEach 执行时只能取到当时的旧值;watch 在公司信息载入/设置页保存
|
||||||
|
* 后自动重算标题,无需等下一次路由跳转。
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => [route.meta.title, settingsStore.brandTitle] as const,
|
||||||
|
() => {
|
||||||
|
const title = route.meta.title as string | undefined
|
||||||
|
document.title = title ? `${title} - ${settingsStore.brandTitle}` : settingsStore.brandTitle
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// 品牌信息全局只载入一次(登录页/布局另有调用,内部去重)
|
||||||
|
void settingsStore.ensureLoaded()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -45,13 +45,26 @@ export interface BusinessRoleUpdateParams {
|
|||||||
sort_order?: number
|
sort_order?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分页查询岗位角色(含停用) */
|
/** 岗位下拉选项(登录即可;基础数据与报表筛选用,含停用) */
|
||||||
|
export interface BusinessRoleOption {
|
||||||
|
id: number
|
||||||
|
role_code: string
|
||||||
|
role_name: string
|
||||||
|
status: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询岗位角色(含停用,仅管理员) */
|
||||||
export function listBusinessRoles(
|
export function listBusinessRoles(
|
||||||
params: BusinessRoleQueryParams,
|
params: BusinessRoleQueryParams,
|
||||||
): Promise<PageData<BusinessRoleItem>> {
|
): Promise<PageData<BusinessRoleItem>> {
|
||||||
return request({ url: '/v1/system/business-roles', method: 'get', params })
|
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(
|
export function createBusinessRole(
|
||||||
data: BusinessRoleCreateParams,
|
data: BusinessRoleCreateParams,
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export interface CustomerItem {
|
|||||||
owner_personnel_id: number | null
|
owner_personnel_id: number | null
|
||||||
owner_personnel_name: string | null
|
owner_personnel_name: string | null
|
||||||
remark: string | null
|
remark: string | null
|
||||||
|
/** 评级总分(字符串直出,未评级为 null;等级由总分判定、同向) */
|
||||||
|
rating_total: string | null
|
||||||
|
/** 评级等级快照(保存时点固化;未评级/未命中为 null) */
|
||||||
|
rating_grade_name: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerQueryParams {
|
export interface CustomerQueryParams {
|
||||||
@@ -30,6 +34,10 @@ export interface CustomerQueryParams {
|
|||||||
keyword?: string
|
keyword?: string
|
||||||
customer_type?: string
|
customer_type?: string
|
||||||
owner_personnel_id?: number
|
owner_personnel_id?: number
|
||||||
|
/** 排序字段白名单:rating = 客户评级总分(后端校验,非法值 400) */
|
||||||
|
order_field?: 'rating'
|
||||||
|
/** 排序方向;未评级恒在末尾(后端 NULLS LAST 模拟) */
|
||||||
|
order_type?: 'asc' | 'desc'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
|
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -23,6 +23,26 @@ export interface ParamCatalogItem {
|
|||||||
values: ParamDimensionValue[]
|
values: ParamDimensionValue[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 枚举参数值 → 中文(展示层翻译;库与接口仍存英文码,审计口径不变)。
|
||||||
|
*
|
||||||
|
* 业界惯例(RuoYi 字典标签同款):映射单一出处,避免页内复制漂移;
|
||||||
|
* 未知码原样回显 —— 后端新增枚举项而前端未跟上时不会显示成空。
|
||||||
|
*/
|
||||||
|
export const PARAM_ENUM_LABELS: Record<string, string> = {
|
||||||
|
FULL: '当月全额',
|
||||||
|
BALANCE: '余额',
|
||||||
|
INCLUDE: '含基本工资',
|
||||||
|
DEDUCT: '扣除基本工资',
|
||||||
|
EXCLUDE: '不计基本工资',
|
||||||
|
'0': '关',
|
||||||
|
'1': '开',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paramEnumLabel(code: string): string {
|
||||||
|
return PARAM_ENUM_LABELS[code] ?? code
|
||||||
|
}
|
||||||
|
|
||||||
/** 参数目录(asOf 缺省为今日) */
|
/** 参数目录(asOf 缺省为今日) */
|
||||||
export function getCatalog(asOf?: string): Promise<ParamCatalogItem[]> {
|
export function getCatalog(asOf?: string): Promise<ParamCatalogItem[]> {
|
||||||
return request({ url: '/v1/system/params', method: 'get', params: asOf ? { as_of: asOf } : {} })
|
return request({ url: '/v1/system/params', method: 'get', params: asOf ? { as_of: asOf } : {} })
|
||||||
|
|||||||
@@ -54,7 +54,57 @@ export function updateRatingGrade(
|
|||||||
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'put', data })
|
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'put', data })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除评级等级(物理删除,等级表无下游引用,允许清空后由列表空态提示) */
|
/**
|
||||||
|
* 删除评级等级(物理删除;客户评级行快照固化等级名、无外键引用,删等级
|
||||||
|
* 不伤已评级客户的历史结论;允许清空,新评级等级记为 null)。
|
||||||
|
*/
|
||||||
export function deleteRatingGrade(gradeId: number): Promise<null> {
|
export function deleteRatingGrade(gradeId: number): Promise<null> {
|
||||||
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'delete' })
|
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'delete' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 客户评级(每客户一行,落库) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户当前评级(对应后端 `CustomerRatingOut`)。
|
||||||
|
*
|
||||||
|
* 分值按红线以字符串直出(比较前显式 parseFloat);total_score 由服务端
|
||||||
|
* 按五项重算。grade_name 是保存时点的等级**快照**(无外键),可能为 null
|
||||||
|
* (总分未命中任何等级 / 当时等级表为空)。
|
||||||
|
*/
|
||||||
|
export interface CustomerRatingItem {
|
||||||
|
customer_id: number
|
||||||
|
score_ship_amount: string
|
||||||
|
score_receipt_rate: string
|
||||||
|
score_profit_rate: string
|
||||||
|
score_contract_cooperation: string
|
||||||
|
score_site_construction: string
|
||||||
|
total_score: string
|
||||||
|
grade_name: string | null
|
||||||
|
remark: string | null
|
||||||
|
scorer_name: string
|
||||||
|
create_time: string
|
||||||
|
update_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存评级入参:五项 0-20 数字字符串;总分与等级由服务端重算 */
|
||||||
|
export interface CustomerRatingParams {
|
||||||
|
score_ship_amount: string
|
||||||
|
score_receipt_rate: string
|
||||||
|
score_profit_rate: string
|
||||||
|
score_contract_cooperation: string
|
||||||
|
score_site_construction: string
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查某客户当前评级;未评级时 data 为 null(正常态,不是错误) */
|
||||||
|
export function getCustomerRating(customerId: number): Promise<CustomerRatingItem | null> {
|
||||||
|
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存客户评级(upsert:首次创建、再次保存整体覆盖) */
|
||||||
|
export function saveCustomerRating(
|
||||||
|
customerId: number,
|
||||||
|
data: CustomerRatingParams,
|
||||||
|
): Promise<CustomerRatingItem> {
|
||||||
|
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'put', data })
|
||||||
|
}
|
||||||
@@ -47,6 +47,9 @@ export interface DepartmentMemberRow {
|
|||||||
personnel_name: string
|
personnel_name: string
|
||||||
role_code: string
|
role_code: string
|
||||||
is_probation: number
|
is_probation: number
|
||||||
|
/** regular 普通台账 / manager_personal 经理个人户 / adjustment 仅调整单 */
|
||||||
|
member_kind: string
|
||||||
|
unassigned_reason: string | null
|
||||||
performance_sum: string | null
|
performance_sum: string | null
|
||||||
payable_sum: string | null
|
payable_sum: string | null
|
||||||
adjust_extra: string
|
adjust_extra: string
|
||||||
@@ -63,7 +66,14 @@ export interface DepartmentTeamRow {
|
|||||||
member_count: number
|
member_count: number
|
||||||
team_base: string
|
team_base: string
|
||||||
team_share_amount: string
|
team_share_amount: string
|
||||||
|
/** 单经理 5% 名义值;无月结聚合行时为 0(不发放) */
|
||||||
company_share_amount: string
|
company_share_amount: string
|
||||||
|
/** 本月是否有月结聚合行(决定是否发放 5%) */
|
||||||
|
has_aggregate: boolean
|
||||||
|
/** 月结聚合行应发(12.5%+5% 一次舍入);无聚合行时 0 */
|
||||||
|
aggregate_amount: string
|
||||||
|
/** 团队应发小计 = 聚合行金额 + 调整净额 */
|
||||||
|
team_subtotal: string
|
||||||
company_base_ratio: string
|
company_base_ratio: string
|
||||||
adjust_extra: string
|
adjust_extra: string
|
||||||
adjust_deduct: string
|
adjust_deduct: string
|
||||||
@@ -74,6 +84,27 @@ export interface DepartmentTeamRow {
|
|||||||
members: DepartmentMemberRow[]
|
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 {
|
export interface DepartmentSummary {
|
||||||
settlement_month: string
|
settlement_month: string
|
||||||
params: {
|
params: {
|
||||||
@@ -88,8 +119,14 @@ export interface DepartmentSummary {
|
|||||||
team_base_total: string
|
team_base_total: string
|
||||||
company_base: string
|
company_base: string
|
||||||
company_share_pct: string
|
company_share_pct: string
|
||||||
|
/** 单经理 5% 名义值 */
|
||||||
company_share_amount: string
|
company_share_amount: string
|
||||||
|
/** 计入应发的经理数(有聚合行) */
|
||||||
|
company_share_count: number
|
||||||
|
/** 5% 合计 = 单份 × 计入次数 */
|
||||||
|
company_share_total: string
|
||||||
} | null
|
} | null
|
||||||
|
reconciliation: DepartmentReconciliation | null
|
||||||
totals: Record<string, string>
|
totals: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,64 @@
|
|||||||
import service, { request } from './request'
|
import { request } from './request'
|
||||||
|
|
||||||
/** 公司信息(管理端) */
|
/**
|
||||||
|
* 公司信息与站点信息(管理端)。
|
||||||
|
*
|
||||||
|
* 2026-10-20 精简:删除系统副标题与企业档案字段(信用代码/地址/电话/邮箱),
|
||||||
|
* 新增站点标题(浏览器标签页/侧边栏副标题)与站点图标(标签页 favicon)。
|
||||||
|
* 展示字段全部可空:null 表示未配置,前端回退内置默认值。
|
||||||
|
*/
|
||||||
export interface CompanySettings {
|
export interface CompanySettings {
|
||||||
company_name: string
|
company_name: string
|
||||||
/** 未上传时为 null */
|
/** 登录页品牌位 logo;未上传时为 null */
|
||||||
logo_url: string | null
|
logo_url: string | null
|
||||||
|
site_title: string | null
|
||||||
|
brand_slogan: string | null
|
||||||
|
footer_text: string | null
|
||||||
|
icp_no: string | null
|
||||||
|
/** 浏览器标签页图标;未上传时为 null(前端用内置 favicon.svg) */
|
||||||
|
favicon_url: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 匿名端点出参(登录页消费;与后端 SettingPublicOut 白名单一致) */
|
/** 匿名端点出参(登录页/浏览器标签页消费;与后端 SettingPublicOut 白名单一致) */
|
||||||
export interface PublicSettings {
|
export type PublicSettings = CompanySettings
|
||||||
company_name: string
|
|
||||||
logo_url: string | null
|
/**
|
||||||
|
* 更新入参:**部分更新**语义 —— 仅携带的字段生效,未携带字段保持不变。
|
||||||
|
* 可选文本传空串等价清空(后端落库 null、前端回退默认)。
|
||||||
|
*/
|
||||||
|
export interface SettingsUpdatePayload {
|
||||||
|
company_name?: string
|
||||||
|
site_title?: string
|
||||||
|
brand_slogan?: string
|
||||||
|
footer_text?: string
|
||||||
|
icp_no?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSettings(): Promise<CompanySettings> {
|
export function getSettings(): Promise<CompanySettings> {
|
||||||
return request({ url: '/v1/system/settings', method: 'get' })
|
return request({ url: '/v1/system/settings', method: 'get' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSettings(companyName: string): Promise<CompanySettings> {
|
export function updateSettings(payload: SettingsUpdatePayload): Promise<CompanySettings> {
|
||||||
return request({
|
return request({ url: '/v1/system/settings', method: 'put', data: payload })
|
||||||
url: '/v1/system/settings',
|
|
||||||
method: 'put',
|
|
||||||
data: { company_name: companyName },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** logo 上传走独立的 FormData(multipart,非 JSON) */
|
/**
|
||||||
|
* 图片上传走独立的 FormData(multipart,非 JSON)。
|
||||||
|
*
|
||||||
|
* 经统一 request 封装解包信封(响应形如 {code,message,data});Content-Type
|
||||||
|
* 交给浏览器/axios 为 FormData 自动生成(含 boundary),不手工设置。
|
||||||
|
*/
|
||||||
export function uploadLogo(file: File): Promise<CompanySettings> {
|
export function uploadLogo(file: File): Promise<CompanySettings> {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
return service.post('/v1/system/settings/logo', form, {
|
return request({ url: '/v1/system/settings/logo', method: 'post', data: form })
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
}
|
||||||
})
|
|
||||||
|
/** 站点图标上传(浏览器标签页;png/jpg/ico ≤2MB) */
|
||||||
|
export function uploadFavicon(file: File): Promise<CompanySettings> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return request({ url: '/v1/system/settings/favicon', method: 'post', data: form })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录页消费:无令牌也可调用(后端白名单端点)。
|
/** 登录页消费:无令牌也可调用(后端白名单端点)。
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
import AppMenu from '@/components/layout/AppMenu.vue'
|
import AppMenu from '@/components/layout/AppMenu.vue'
|
||||||
|
import { firstAccessiblePath } from '@/router/access'
|
||||||
|
import { useSettingsStore } from '@/store/settings'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
|
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
// 品牌区(名称/副标题/logo)来自公司信息设置;App 已全局载入一次,这里兜底
|
||||||
|
onMounted(() => {
|
||||||
|
void settingsStore.ensureLoaded()
|
||||||
|
})
|
||||||
|
|
||||||
const drawerVisible = ref(false)
|
const drawerVisible = ref(false)
|
||||||
|
|
||||||
|
/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */
|
||||||
|
const homePath = computed(() => firstAccessiblePath(router, userStore.roles))
|
||||||
|
|
||||||
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
|
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
|
||||||
const breadcrumbs = computed<string[]>(() => {
|
const breadcrumbs = computed<string[]>(() => {
|
||||||
const items: string[] = []
|
const items: string[] = []
|
||||||
if (route.meta.group) items.push(route.meta.group)
|
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
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -40,8 +51,8 @@ async function handleLogout(): Promise<void> {
|
|||||||
<el-container class="app-layout">
|
<el-container class="app-layout">
|
||||||
<el-aside class="app-aside" width="220px">
|
<el-aside class="app-aside" width="220px">
|
||||||
<div class="app-brand">
|
<div class="app-brand">
|
||||||
<span class="app-brand__name">衍水集团</span>
|
<span class="app-brand__name">{{ settingsStore.companyName }}</span>
|
||||||
<span class="app-brand__sub">营销人员工资管理系统</span>
|
<span class="app-brand__sub">{{ settingsStore.siteTitle }}</span>
|
||||||
<span class="app-brand__line"></span>
|
<span class="app-brand__line"></span>
|
||||||
</div>
|
</div>
|
||||||
<AppMenu class="app-aside__menu" />
|
<AppMenu class="app-aside__menu" />
|
||||||
@@ -66,7 +77,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<el-breadcrumb separator="/">
|
<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">
|
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item">
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</el-breadcrumb-item>
|
</el-breadcrumb-item>
|
||||||
@@ -95,8 +106,8 @@ async function handleLogout(): Promise<void> {
|
|||||||
:with-header="false"
|
:with-header="false"
|
||||||
>
|
>
|
||||||
<div class="app-brand">
|
<div class="app-brand">
|
||||||
<span class="app-brand__name">衍水集团</span>
|
<span class="app-brand__name">{{ settingsStore.companyName }}</span>
|
||||||
<span class="app-brand__sub">营销人员工资管理系统</span>
|
<span class="app-brand__sub">{{ settingsStore.siteTitle }}</span>
|
||||||
<span class="app-brand__line"></span>
|
<span class="app-brand__line"></span>
|
||||||
</div>
|
</div>
|
||||||
<AppMenu />
|
<AppMenu />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { canAccessRoute, isMenuRecord } from '@/router/access'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { ROLE_ADMIN } from '@/utils/constants'
|
|
||||||
|
|
||||||
/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */
|
/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */
|
||||||
export interface MenuNode {
|
export interface MenuNode {
|
||||||
@@ -30,19 +30,13 @@ export function useAppMenu() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
|
|
||||||
const userRoles = computed(() => userStore.roles)
|
const userRoles = computed(() => userStore.roles)
|
||||||
|
|
||||||
const menuNodes = computed<MenuNode[]>(() =>
|
const menuNodes = computed<MenuNode[]>(() =>
|
||||||
router
|
router
|
||||||
.getRoutes()
|
.getRoutes()
|
||||||
.filter((record) => Boolean(record.meta.title) && record.meta.menu !== false)
|
// 准入规则与路由守卫、落地页解析共用,避免三处漂移
|
||||||
.filter((record) => !record.meta.requiresAdmin || isAdmin.value)
|
.filter((record) => isMenuRecord(record) && canAccessRoute(record, userRoles.value))
|
||||||
// 职能页面(审批等):任一列入角色命中即可见(admin 须显式列入集合)
|
|
||||||
.filter(
|
|
||||||
(record) =>
|
|
||||||
!record.meta.allowedRoles || record.meta.allowedRoles.some((role) => userRoles.value.includes(role)),
|
|
||||||
)
|
|
||||||
.map((record) => ({
|
.map((record) => ({
|
||||||
path: record.path,
|
path: record.path,
|
||||||
title: record.meta.title ?? '',
|
title: record.meta.title ?? '',
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import { handledError } from '@/utils/error'
|
|||||||
* 请求的响应允许写入状态,否则旧数据会覆盖新数据、与分页器错位
|
* 请求的响应允许写入状态,否则旧数据会覆盖新数据、与分页器错位
|
||||||
* - 请求失败不外抛(提示由拦截器统一完成),loading 仅由最新请求复位
|
* - 请求失败不外抛(提示由拦截器统一完成),loading 仅由最新请求复位
|
||||||
*/
|
*/
|
||||||
export function usePagedList<T, F extends object>(
|
export function usePagedList<T, F extends object, D extends PageData<T> = PageData<T>>(
|
||||||
fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise<PageData<T>>,
|
fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise<D>,
|
||||||
getFilters: () => F,
|
getFilters: () => F,
|
||||||
|
/** 仅最新请求的响应会触发(fetcher 内的副作用不受序号保护) */
|
||||||
|
onData?: (data: D) => void,
|
||||||
) {
|
) {
|
||||||
const records = ref([]) as Ref<T[]>
|
const records = ref([]) as Ref<T[]>
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -39,6 +41,7 @@ export function usePagedList<T, F extends object>(
|
|||||||
if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应
|
if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应
|
||||||
records.value = data.list
|
records.value = data.list
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
|
onData?.(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error) // 失败提示由请求拦截器统一完成
|
handledError(error) // 失败提示由请求拦截器统一完成
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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 { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import { firstAccessiblePath } from '@/router/access'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { ROLE_ADMIN } from '@/utils/constants'
|
import { ROLE_ADMIN } from '@/utils/constants'
|
||||||
|
|
||||||
@@ -38,10 +39,16 @@ const routes: RouteRecordRaw[] = [
|
|||||||
redirect: '/dashboard',
|
redirect: '/dashboard',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
|
// 工作台(M4 ⑧):与报表/审批接口同权限(admin/finance/leader),
|
||||||
|
// 前后端双层;营销后勤可见性待权限清单 #6 答复
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: () => import('@/views/dashboard/index.vue'),
|
component: () => import('@/views/dashboard/index.vue'),
|
||||||
meta: { title: '首页', order: 1 },
|
meta: {
|
||||||
|
title: '首页',
|
||||||
|
order: 1,
|
||||||
|
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/users',
|
path: '/system/users',
|
||||||
@@ -136,14 +143,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/business/receipts/index.vue'),
|
component: () => import('@/views/business/receipts/index.vue'),
|
||||||
meta: { title: '回款管理', group: '业务数据', order: 15 },
|
meta: { title: '回款管理', group: '业务数据', order: 15 },
|
||||||
},
|
},
|
||||||
{
|
// 客户评级已改为「客户管理」页行内弹窗(2026-10-19):删除独立页面与
|
||||||
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
|
// 菜单入口(菜单由路由表派生,删路由即删菜单);旧 URL 落入兜底重定向
|
||||||
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
|
|
||||||
path: '/business/rating',
|
|
||||||
name: 'business-rating',
|
|
||||||
component: () => import('@/views/business/rating/index.vue'),
|
|
||||||
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
|
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
|
||||||
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
|
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
|
||||||
@@ -259,22 +260,23 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) {
|
if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) {
|
||||||
ElMessage.error('需要管理员权限')
|
const home = firstAccessiblePath(router, userStore.roles)
|
||||||
return { path: '/dashboard' }
|
// 根路径/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))) {
|
if (to.meta.allowedRoles && !to.meta.allowedRoles.some((role) => userStore.roles.includes(role))) {
|
||||||
ElMessage.error('无权访问该页面')
|
const home = firstAccessiblePath(router, userStore.roles)
|
||||||
return { path: '/dashboard' }
|
if (!to.redirectedFrom) ElMessage.error('无权访问该页面')
|
||||||
|
return home === to.path ? { path: '/login' } : { path: home }
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
router.afterEach((to) => {
|
// 浏览器标题在 App.vue 中由「当前页 + 公司品牌」响应式计算
|
||||||
const title = to.meta.title as string | undefined
|
// (品牌异步载入/管理端改名后要自动更新,afterEach 只能取到旧值)
|
||||||
document.title = title ? `${title} - ${import.meta.env.VITE_APP_TITLE}` : import.meta.env.VITE_APP_TITLE
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
import { getPublicSettings } from '@/api/settings'
|
||||||
|
import { setFavicon } from '@/utils/favicon'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌与站点信息全局状态。
|
||||||
|
*
|
||||||
|
* 数据来自匿名 public 端点(登录页无令牌也需要),设置未配置/接口失败时
|
||||||
|
* 回退内置默认值 —— 与改造前硬编码的品牌文案一致,存量行为零变化。
|
||||||
|
*
|
||||||
|
* 两个关键设计:
|
||||||
|
* - **乐观更新**:管理端保存/上传后直接用回包调 ``apply``,而不是重新请求
|
||||||
|
* public 端点 —— 匿名端点带 300s 浏览器缓存,管理员改完自己刷新会先看到
|
||||||
|
* 旧值(与登录页历史口径一致)
|
||||||
|
* - **站点图标优先使用自定义图标,未配置时跟随公司 logo**(2026-10-20 用户
|
||||||
|
* 确认):换 logo 后标签页图标随之变化;两者都没有时才用内置 SVG
|
||||||
|
*/
|
||||||
|
export const DEFAULT_COMPANY_NAME = '衍水集团'
|
||||||
|
export const DEFAULT_SITE_TITLE = '营销人员工资管理系统'
|
||||||
|
export const DEFAULT_BRAND_SLOGAN = '把每一笔回款,算成一份踏实。'
|
||||||
|
const DEFAULT_FAVICON = '/favicon.svg'
|
||||||
|
|
||||||
|
/** 品牌/站点相关字段(public 白名单 / 管理端出参的公共子集) */
|
||||||
|
interface BrandInfo {
|
||||||
|
company_name?: string
|
||||||
|
logo_url?: string | null
|
||||||
|
site_title?: string | null
|
||||||
|
brand_slogan?: string | null
|
||||||
|
footer_text?: string | null
|
||||||
|
icp_no?: string | null
|
||||||
|
favicon_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
|
const companyName = ref(DEFAULT_COMPANY_NAME)
|
||||||
|
const siteTitle = ref(DEFAULT_SITE_TITLE)
|
||||||
|
const brandSlogan = ref(DEFAULT_BRAND_SLOGAN)
|
||||||
|
const footerText = ref<string | null>(null)
|
||||||
|
const icpNo = ref<string | null>(null)
|
||||||
|
const logoUrl = ref<string | null>(null)
|
||||||
|
const faviconUrl = ref<string | null>(null)
|
||||||
|
|
||||||
|
/** public 端点是否已成功载入(失败不置位,下次挂载自然重试) */
|
||||||
|
const loaded = ref(false)
|
||||||
|
let pending: Promise<void> | null = null
|
||||||
|
|
||||||
|
/** 浏览器标题后缀:公司名 + 站点标题(站点标题为空则只有公司名) */
|
||||||
|
const brandTitle = computed(() =>
|
||||||
|
siteTitle.value ? `${companyName.value} ${siteTitle.value}` : companyName.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 登录页页脚:未配置版权文本时用「© 公司名 · 内部系统」 */
|
||||||
|
const footerLine = computed(() => footerText.value || `© ${companyName.value} · 内部系统`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生效的站点图标:自定义图标 → 公司 logo → 内置 SVG。
|
||||||
|
*
|
||||||
|
* 未单独上传站点图标时跟随 logo —— 管理员换 logo,浏览器标签页图标同步变化。
|
||||||
|
*/
|
||||||
|
const effectiveFavicon = computed(
|
||||||
|
() => faviconUrl.value ?? logoUrl.value ?? DEFAULT_FAVICON,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用品牌/站点信息(public 回包与设置页保存回包共用)。
|
||||||
|
*
|
||||||
|
* 可选字段为 null/空串表示**清空**(回退默认);undefined 表示回包未
|
||||||
|
* 携带该字段(保持不变)。company_name 为必填,空值忽略。
|
||||||
|
*/
|
||||||
|
function apply(info: BrandInfo): void {
|
||||||
|
if (info.company_name) companyName.value = info.company_name
|
||||||
|
if (info.logo_url !== undefined) logoUrl.value = info.logo_url
|
||||||
|
if (info.favicon_url !== undefined) faviconUrl.value = info.favicon_url
|
||||||
|
if (info.site_title !== undefined) {
|
||||||
|
siteTitle.value = info.site_title || DEFAULT_SITE_TITLE
|
||||||
|
}
|
||||||
|
if (info.brand_slogan !== undefined) {
|
||||||
|
brandSlogan.value = info.brand_slogan || DEFAULT_BRAND_SLOGAN
|
||||||
|
}
|
||||||
|
if (info.footer_text !== undefined) footerText.value = info.footer_text || null
|
||||||
|
if (info.icp_no !== undefined) icpNo.value = info.icp_no || null
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保品牌/站点信息已载入(App/布局/登录页启动时调用;并发去重)。
|
||||||
|
*
|
||||||
|
* 失败静默回退内置默认值 —— 品牌不是关键路径,不能阻断登录或业务页面。
|
||||||
|
*/
|
||||||
|
function ensureLoaded(): Promise<void> {
|
||||||
|
if (loaded.value) return Promise.resolve()
|
||||||
|
if (pending) return pending
|
||||||
|
pending = (async () => {
|
||||||
|
try {
|
||||||
|
apply(await getPublicSettings())
|
||||||
|
} catch {
|
||||||
|
// 静默:保持内置默认品牌(登录页必须可用)
|
||||||
|
} finally {
|
||||||
|
pending = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
// favicon 跟随生效图标(自定义 → logo → 内置);换图由 ?v= 版本参数破缓存
|
||||||
|
watch(effectiveFavicon, (url) => setFavicon(url), { immediate: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyName,
|
||||||
|
siteTitle,
|
||||||
|
brandSlogan,
|
||||||
|
footerText,
|
||||||
|
icpNo,
|
||||||
|
logoUrl,
|
||||||
|
faviconUrl,
|
||||||
|
effectiveFavicon,
|
||||||
|
loaded,
|
||||||
|
brandTitle,
|
||||||
|
footerLine,
|
||||||
|
apply,
|
||||||
|
ensureLoaded,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 动态站点图标。
|
||||||
|
*
|
||||||
|
* 手写同款实现(参照 vueuse `useFavicon`):修改已有 `<link rel="icon">`
|
||||||
|
* 的 href 即可让浏览器重新加载图标;没有 link 时补建一个。项目为保持
|
||||||
|
* 直接依赖克制(5 个)未引入 @vueuse/core,故按同口径手写。
|
||||||
|
*
|
||||||
|
* 缓存:logo 地址自带 `?v=更新时间戳` 版本参数,换图后不会被浏览器缓存拖住。
|
||||||
|
*/
|
||||||
|
const FAVICON_SELECTOR = 'link[rel~="icon"]'
|
||||||
|
|
||||||
|
export function setFavicon(href: string): void {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
let link = document.querySelector<HTMLLinkElement>(FAVICON_SELECTOR)
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link')
|
||||||
|
link.rel = 'icon'
|
||||||
|
document.head.appendChild(link)
|
||||||
|
}
|
||||||
|
link.href = href
|
||||||
|
}
|
||||||
+243
-138
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createRatingGrade,
|
createRatingGrade,
|
||||||
deleteRatingGrade,
|
deleteRatingGrade,
|
||||||
|
getCustomerRating,
|
||||||
listRatingGrades,
|
listRatingGrades,
|
||||||
|
saveCustomerRating as saveCustomerRatingApi,
|
||||||
updateRatingGrade,
|
updateRatingGrade,
|
||||||
type RatingGradeItem,
|
type RatingGradeItem,
|
||||||
type RatingGradeParams,
|
type RatingGradeParams,
|
||||||
@@ -14,13 +16,29 @@ import { useConfirmAction } from '@/composables/useConfirmAction'
|
|||||||
import { handledError } from '@/utils/error'
|
import { handledError } from '@/utils/error'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户评级计算器(需求 2.2.5 / 对照表 #25)。
|
* 客户评级弹窗(2026-10-19 由独立「客户评级计算器」页面迁移而来)。
|
||||||
*
|
*
|
||||||
* 评级计算器仅是**打分工具**:五项打分求总分、按等级映射实时判定等级,
|
* 双 Tab 设计避免三层弹层:
|
||||||
* 评分记录不落库、不参与绩效金额计算,因此页面没有保存动作(仅重置)。
|
* - Tab1「客户评级」:五项打分 → 总分/等级实时判定 → 保存落库(每客户一行,
|
||||||
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
|
* 等级按保存时点的映射快照固化);口径提示「评分不参与绩效金额计算」
|
||||||
* 重载判定用列表——判定与抽屉表格共用同一份数据。
|
* (对照表 #25)常驻
|
||||||
|
* - Tab2「等级映射设置」:等级表增删改(原页面抽屉整体迁入),判定规则与
|
||||||
|
* 打分区共用同一份列表,配置变更后实时生效
|
||||||
|
*
|
||||||
|
* 判定与后端 ``rating_service.resolve_grade`` 同规则:按 sort_order 升序取
|
||||||
|
* 首个 min_score ≤ 总分的等级;无命中显示未命中提示、保存后等级为 null。
|
||||||
*/
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 目标客户(未打开弹窗时可为 0,仅打开后读取) */
|
||||||
|
customerId: number
|
||||||
|
customerName: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ required: true })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
|
|
||||||
|
const activeTab = ref<'score' | 'grades'>('score')
|
||||||
|
|
||||||
// ==================== 等级判定(纯函数) ====================
|
// ==================== 等级判定(纯函数) ====================
|
||||||
|
|
||||||
@@ -29,10 +47,9 @@ import { handledError } from '@/utils/error'
|
|||||||
*
|
*
|
||||||
* 后端 min_score 是字符串直出(分值红线),比较前必须 parseFloat 转数值,
|
* 后端 min_score 是字符串直出(分值红线),比较前必须 parseFloat 转数值,
|
||||||
* 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。
|
* 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。
|
||||||
* 无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
|
|
||||||
*/
|
*/
|
||||||
function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGradeItem | null {
|
function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGradeItem | null {
|
||||||
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
|
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id)
|
||||||
for (const grade of ordered) {
|
for (const grade of ordered) {
|
||||||
if (totalScore >= parseFloat(grade.min_score)) {
|
if (totalScore >= parseFloat(grade.min_score)) {
|
||||||
return grade
|
return grade
|
||||||
@@ -41,7 +58,7 @@ function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGrad
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 评分计算(纯前端实时计算) ====================
|
// ==================== 评分计算 ====================
|
||||||
|
|
||||||
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
|
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
|
||||||
const SCORE_ITEMS = [
|
const SCORE_ITEMS = [
|
||||||
@@ -67,21 +84,49 @@ const totalScore = computed(() =>
|
|||||||
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
|
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 无保存动作(不落库),重置即把五项清零 */
|
|
||||||
function resetScores(): void {
|
function resetScores(): void {
|
||||||
for (const item of SCORE_ITEMS) {
|
for (const item of SCORE_ITEMS) {
|
||||||
scores[item.key] = 0
|
scores[item.key] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 等级映射(判定用列表) ====================
|
const remark = ref('')
|
||||||
|
/** 已存在的评级(用于回显打分与「上次评级」信息) */
|
||||||
|
const hasExisting = ref(false)
|
||||||
|
const existingInfo = ref<string | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function loadRating(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rating = await getCustomerRating(props.customerId)
|
||||||
|
if (!rating) {
|
||||||
|
hasExisting.value = false
|
||||||
|
existingInfo.value = null
|
||||||
|
resetScores()
|
||||||
|
remark.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasExisting.value = true
|
||||||
|
// 后端字符串直出,回填 parseFloat;保存时再 String() 转回
|
||||||
|
scores.ship_amount = parseFloat(rating.score_ship_amount)
|
||||||
|
scores.receipt_rate = parseFloat(rating.score_receipt_rate)
|
||||||
|
scores.profit_rate = parseFloat(rating.score_profit_rate)
|
||||||
|
scores.contract_cooperation = parseFloat(rating.score_contract_cooperation)
|
||||||
|
scores.site_construction = parseFloat(rating.score_site_construction)
|
||||||
|
remark.value = rating.remark ?? ''
|
||||||
|
existingInfo.value = `上次由 ${rating.scorer_name} 评定于 ${rating.update_time.replace('T', ' ').slice(0, 16)}`
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 等级映射(判定用列表 + 设置表共用) ====================
|
||||||
|
|
||||||
const { confirmAndRun } = useConfirmAction()
|
const { confirmAndRun } = useConfirmAction()
|
||||||
|
|
||||||
const grades = ref<RatingGradeItem[]>([])
|
const grades = ref<RatingGradeItem[]>([])
|
||||||
const gradesLoading = ref(false)
|
const gradesLoading = ref(false)
|
||||||
|
|
||||||
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
|
|
||||||
async function loadGrades(): Promise<void> {
|
async function loadGrades(): Promise<void> {
|
||||||
gradesLoading.value = true
|
gradesLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -95,17 +140,47 @@ async function loadGrades(): Promise<void> {
|
|||||||
|
|
||||||
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
|
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
|
||||||
|
|
||||||
// ==================== 等级映射设置抽屉 ====================
|
// ==================== 打开即初始化 ====================
|
||||||
|
|
||||||
const drawerVisible = ref(false)
|
async function init(): Promise<void> {
|
||||||
|
activeTab.value = 'score'
|
||||||
/** 打开时重新拉取,避免展示陈旧数据 */
|
hasExisting.value = false
|
||||||
function openDrawer(): void {
|
existingInfo.value = null
|
||||||
drawerVisible.value = true
|
resetScores()
|
||||||
void loadGrades()
|
remark.value = ''
|
||||||
|
if (!props.customerId) return
|
||||||
|
await Promise.all([loadGrades(), loadRating()])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 新增 / 编辑等级弹窗 ====================
|
watch(visible, (open) => {
|
||||||
|
if (open) void init()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 保存评级 ====================
|
||||||
|
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
if (!props.customerId) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await saveCustomerRatingApi(props.customerId, {
|
||||||
|
score_ship_amount: String(scores.ship_amount ?? 0),
|
||||||
|
score_receipt_rate: String(scores.receipt_rate ?? 0),
|
||||||
|
score_profit_rate: String(scores.profit_rate ?? 0),
|
||||||
|
score_contract_cooperation: String(scores.contract_cooperation ?? 0),
|
||||||
|
score_site_construction: String(scores.site_construction ?? 0),
|
||||||
|
remark: remark.value.trim() || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('评级已保存')
|
||||||
|
visible.value = false
|
||||||
|
emit('saved')
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 新增 / 编辑等级(二层弹窗) ====================
|
||||||
|
|
||||||
const editVisible = ref(false)
|
const editVisible = ref(false)
|
||||||
/** null = 新增,否则为编辑目标 id */
|
/** null = 新增,否则为编辑目标 id */
|
||||||
@@ -150,7 +225,6 @@ function editFormError(minScore: number | undefined, maxScore: number | undefine
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveGrade(): Promise<void> {
|
async function saveGrade(): Promise<void> {
|
||||||
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
|
|
||||||
const minScore = editForm.min_score
|
const minScore = editForm.min_score
|
||||||
const maxScore = editForm.max_score
|
const maxScore = editForm.max_score
|
||||||
const error = editFormError(minScore, maxScore)
|
const error = editFormError(minScore, maxScore)
|
||||||
@@ -165,7 +239,7 @@ async function saveGrade(): Promise<void> {
|
|||||||
max_score: String(maxScore),
|
max_score: String(maxScore),
|
||||||
sort_order: editForm.sort_order ?? 0,
|
sort_order: editForm.sort_order ?? 0,
|
||||||
}
|
}
|
||||||
// 后端为全量替换语义;remark 本页不维护,编辑时原样带回避免误清
|
// 后端为全量替换语义;remark 本弹窗不维护,编辑时原样带回避免误清
|
||||||
if (editingId.value !== null) {
|
if (editingId.value !== null) {
|
||||||
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
|
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
|
||||||
}
|
}
|
||||||
@@ -180,6 +254,7 @@ async function saveGrade(): Promise<void> {
|
|||||||
editVisible.value = false
|
editVisible.value = false
|
||||||
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
|
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
|
||||||
await loadGrades()
|
await loadGrades()
|
||||||
|
emit('saved')
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
handledError(requestError)
|
handledError(requestError)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -187,116 +262,134 @@ async function saveGrade(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
|
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级,已评级客户快照不受影响) */
|
||||||
function removeGrade(row: RatingGradeItem): void {
|
function removeGrade(row: RatingGradeItem): void {
|
||||||
void confirmAndRun({
|
void confirmAndRun({
|
||||||
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
|
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后新评级在该分数区间将无法映射等级,已评级客户的等级快照不受影响。`,
|
||||||
title: '删除确认',
|
title: '删除确认',
|
||||||
confirmButtonText: '删除',
|
confirmButtonText: '删除',
|
||||||
successText: '评级等级已删除',
|
successText: '评级等级已删除',
|
||||||
run: () => deleteRatingGrade(row.id),
|
run: () => deleteRatingGrade(row.id),
|
||||||
after: () => loadGrades(),
|
after: async () => {
|
||||||
|
await loadGrades()
|
||||||
|
emit('saved')
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadGrades()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rating-page">
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="`客户评级 · ${customerName}`"
|
||||||
|
width="680px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
|
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
|
||||||
<el-alert
|
<el-alert
|
||||||
class="page-alert"
|
class="rating-alert"
|
||||||
title="本页为评分计算工具,评分结果不参与绩效金额计算"
|
title="评级为评分工具,评分结果不参与绩效金额计算"
|
||||||
type="warning"
|
type="warning"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 评分计算区:无保存按钮,纯前端实时计算 -->
|
<el-tabs v-model="activeTab">
|
||||||
<el-card shadow="never">
|
<el-tab-pane label="客户评级" name="score">
|
||||||
<div class="page-toolbar">
|
<div class="calc-grid">
|
||||||
<span class="page-desc">五项各 0-20 分,总分 0-100;评级等级按配置的下限实时判定。</span>
|
<div class="calc-form">
|
||||||
<span>
|
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
|
||||||
<el-button @click="resetScores">重置</el-button>
|
<span class="score-label">{{ item.label }}</span>
|
||||||
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
|
<el-input-number
|
||||||
</span>
|
v-model="scores[item.key]"
|
||||||
</div>
|
:min="0"
|
||||||
|
:max="20"
|
||||||
<div class="calc-grid">
|
:step="1"
|
||||||
<div class="calc-form">
|
controls-position="right"
|
||||||
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
|
/>
|
||||||
<span class="score-label">{{ item.label }}</span>
|
<span class="score-unit">分</span>
|
||||||
<el-input-number
|
|
||||||
v-model="scores[item.key]"
|
|
||||||
:min="0"
|
|
||||||
:max="20"
|
|
||||||
:step="1"
|
|
||||||
controls-position="right"
|
|
||||||
/>
|
|
||||||
<span class="score-unit">分</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="calc-result">
|
|
||||||
<div>
|
|
||||||
<span class="result-label">总分</span>
|
|
||||||
<b class="total-num">{{ totalScore }}</b>
|
|
||||||
<span class="total-max">/ 100</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="result-label">评级等级</span>
|
|
||||||
<template v-if="grades.length === 0">
|
|
||||||
<div class="grade-name">—</div>
|
|
||||||
<div class="grade-hint">未配置等级映射</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="matchedGrade">
|
|
||||||
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
|
||||||
<div class="grade-hint">
|
|
||||||
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="grade-name">—</div>
|
|
||||||
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<!-- 等级映射设置抽屉 -->
|
|
||||||
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px">
|
|
||||||
<div class="drawer-hint">
|
|
||||||
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
|
||||||
</div>
|
|
||||||
<div class="drawer-toolbar">
|
|
||||||
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
|
|
||||||
</div>
|
|
||||||
<el-table v-loading="gradesLoading" :data="grades" size="small">
|
|
||||||
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
|
|
||||||
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
|
|
||||||
<el-table-column prop="max_score" label="上限" width="90" align="center" />
|
|
||||||
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
|
|
||||||
<el-table-column label="操作" width="110" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<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>
|
</div>
|
||||||
</template>
|
<p v-if="existingInfo" class="existing-hint">{{ existingInfo }}</p>
|
||||||
</el-table-column>
|
</div>
|
||||||
</el-table>
|
|
||||||
</el-drawer>
|
|
||||||
|
|
||||||
<!-- 新增 / 编辑等级 -->
|
<div class="calc-result">
|
||||||
|
<div>
|
||||||
|
<span class="result-label">总分</span>
|
||||||
|
<b class="total-num">{{ totalScore }}</b>
|
||||||
|
<span class="total-max">/ 100</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="result-label">评级等级</span>
|
||||||
|
<template v-if="grades.length === 0">
|
||||||
|
<div class="grade-name">—</div>
|
||||||
|
<div class="grade-hint">未配置等级映射,保存后等级为空</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="matchedGrade">
|
||||||
|
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
||||||
|
<div class="grade-hint">
|
||||||
|
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="grade-name">—</div>
|
||||||
|
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form label-width="70px" class="remark-form" @submit.prevent>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input
|
||||||
|
v-model="remark"
|
||||||
|
maxlength="255"
|
||||||
|
placeholder="选填,如评级依据说明"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="score-tip">
|
||||||
|
五项各 0-20 分,总分 0-100;保存后等级按当前映射快照固化,
|
||||||
|
之后调整等级映射不影响该客户已保存的等级。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<el-button @click="resetScores">重置</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||||
|
{{ hasExisting ? '更新评级' : '保存评级' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="等级映射设置" name="grades">
|
||||||
|
<div class="drawer-hint">
|
||||||
|
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
||||||
|
</div>
|
||||||
|
<div class="grades-toolbar">
|
||||||
|
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="gradesLoading" :data="grades" size="small">
|
||||||
|
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
|
||||||
|
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
|
||||||
|
<el-table-column prop="max_score" label="上限" width="90" align="center" />
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<!-- 新增 / 编辑等级(二层弹窗;append-to-body 避免嵌套弹层层级与裁剪问题) -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="editVisible"
|
v-model="editVisible"
|
||||||
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
|
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
|
||||||
width="440px"
|
width="440px"
|
||||||
|
append-to-body
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@closed="editingId = null"
|
@closed="editingId = null"
|
||||||
>
|
>
|
||||||
@@ -342,49 +435,36 @@ onMounted(() => {
|
|||||||
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
|
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-alert {
|
.rating-alert {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 6px;
|
||||||
}
|
|
||||||
|
|
||||||
.page-toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-desc {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ys-ink-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calc-grid {
|
.calc-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 28px;
|
gap: 24px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calc-form {
|
.calc-form {
|
||||||
flex: 1 1 380px;
|
flex: 1 1 320px;
|
||||||
min-width: 280px;
|
min-width: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-row {
|
.score-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
line-height: 2.7;
|
line-height: 2.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-label {
|
.score-label {
|
||||||
width: 140px;
|
width: 132px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -395,17 +475,24 @@ onMounted(() => {
|
|||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.existing-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
.calc-result {
|
.calc-result {
|
||||||
flex: 0 0 250px;
|
flex: 0 0 220px;
|
||||||
min-width: 220px;
|
min-width: 200px;
|
||||||
border: 1px solid var(--ys-line);
|
border: 1px solid var(--ys-line);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 18px 20px;
|
padding: 16px 18px;
|
||||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 18px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-label {
|
.result-label {
|
||||||
@@ -416,7 +503,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.total-num {
|
.total-num {
|
||||||
font-size: 40px;
|
font-size: 36px;
|
||||||
color: var(--ys-indigo);
|
color: var(--ys-indigo);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
@@ -429,7 +516,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.grade-name {
|
.grade-name {
|
||||||
font-family: var(--ys-font-brand);
|
font-family: var(--ys-font-brand);
|
||||||
font-size: 28px;
|
font-size: 26px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
@@ -442,6 +529,25 @@ onMounted(() => {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remark-form {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
background: var(--ys-mist);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.drawer-hint {
|
.drawer-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
@@ -451,7 +557,7 @@ onMounted(() => {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-toolbar {
|
.grades-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -471,7 +577,6 @@ onMounted(() => {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.score-label {
|
.score-label {
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules, type TableInstance } from 'element-plus'
|
||||||
|
|
||||||
import * as customerApi from '@/api/customers'
|
import * as customerApi from '@/api/customers'
|
||||||
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
||||||
import * as personnelApi from '@/api/personnel'
|
import * as personnelApi from '@/api/personnel'
|
||||||
|
import { listRatingGrades } from '@/api/ratings'
|
||||||
import AppPagination from '@/components/table/AppPagination.vue'
|
import AppPagination from '@/components/table/AppPagination.vue'
|
||||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||||
import { usePagedList } from '@/composables/usePagedList'
|
import { usePagedList } from '@/composables/usePagedList'
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
import { handledError } from '@/utils/error'
|
import { handledError } from '@/utils/error'
|
||||||
|
import CustomerRatingDialog from './CustomerRatingDialog.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
||||||
@@ -20,6 +22,9 @@ import { handledError } from '@/utils/error'
|
|||||||
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
||||||
* (对照表 #19 的业务口径)
|
* (对照表 #19 的业务口径)
|
||||||
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
||||||
|
* - 客户评级列(2026-10-19):每客户一行快照落库;评级配置走行内弹窗
|
||||||
|
* (原独立评级页删除);列头支持正序/倒序 —— 列表是服务端分页,
|
||||||
|
* 排序必须带参数回后端(`order_field=rating`),且未评级恒在末尾
|
||||||
*/
|
*/
|
||||||
const { confirmAndRun } = useConfirmAction()
|
const { confirmAndRun } = useConfirmAction()
|
||||||
|
|
||||||
@@ -31,6 +36,10 @@ const filters = reactive({
|
|||||||
owner_personnel_id: undefined as number | undefined,
|
owner_personnel_id: undefined as number | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 评级列排序方向(undefined = 默认 id 倒序;后端白名单只接受 rating) */
|
||||||
|
const ratingOrder = ref<'asc' | 'desc' | undefined>(undefined)
|
||||||
|
const tableRef = ref<TableInstance>()
|
||||||
|
|
||||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||||
usePagedList<CustomerItem, customerApi.CustomerQueryParams>(
|
usePagedList<CustomerItem, customerApi.CustomerQueryParams>(
|
||||||
customerApi.listCustomers,
|
customerApi.listCustomers,
|
||||||
@@ -38,16 +47,58 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange,
|
|||||||
keyword: filters.keyword,
|
keyword: filters.keyword,
|
||||||
customer_type: filters.customer_type,
|
customer_type: filters.customer_type,
|
||||||
owner_personnel_id: filters.owner_personnel_id,
|
owner_personnel_id: filters.owner_personnel_id,
|
||||||
|
order_field: ratingOrder.value ? 'rating' : undefined,
|
||||||
|
order_type: ratingOrder.value,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 评级列头排序:custom 模式只触发事件,排序交给后端(服务端分页必须如此) */
|
||||||
|
function onRatingSortChange({
|
||||||
|
prop,
|
||||||
|
order,
|
||||||
|
}: {
|
||||||
|
prop: string
|
||||||
|
order: 'ascending' | 'descending' | null
|
||||||
|
}): void {
|
||||||
|
if (prop !== 'rating_total') return
|
||||||
|
ratingOrder.value = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : undefined
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
function resetFilters(): void {
|
function resetFilters(): void {
|
||||||
filters.keyword = ''
|
filters.keyword = ''
|
||||||
filters.customer_type = undefined
|
filters.customer_type = undefined
|
||||||
filters.owner_personnel_id = undefined
|
filters.owner_personnel_id = undefined
|
||||||
|
ratingOrder.value = undefined
|
||||||
|
tableRef.value?.clearSort()
|
||||||
search()
|
search()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 评级列展示 ====================
|
||||||
|
|
||||||
|
/** 等级名按判定位次(sort_order 升序)排列,用于 tag 语义色映射 */
|
||||||
|
const gradeNames = ref<string[]>([])
|
||||||
|
|
||||||
|
async function loadGradeNames(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const grades = await listRatingGrades()
|
||||||
|
gradeNames.value = grades.map((grade) => grade.grade_name)
|
||||||
|
} catch {
|
||||||
|
// 配色不是关键路径:失败时所有等级走默认 info 色,列表照常可用
|
||||||
|
gradeNames.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RATING_TAG_TYPES = ['success', 'primary', 'warning', 'danger'] as const
|
||||||
|
|
||||||
|
/** 第 1 档 success → 第 2 档 primary → 第 3 档 warning → 其余 danger;未知等级 info */
|
||||||
|
function ratingTagType(gradeName: string | null): (typeof RATING_TAG_TYPES)[number] | 'info' {
|
||||||
|
if (!gradeName) return 'info'
|
||||||
|
const index = gradeNames.value.indexOf(gradeName)
|
||||||
|
if (index === -1) return 'info'
|
||||||
|
return RATING_TAG_TYPES[Math.min(index, RATING_TAG_TYPES.length - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 归属人员下拉数据源 ====================
|
// ==================== 归属人员下拉数据源 ====================
|
||||||
|
|
||||||
interface PersonnelOption {
|
interface PersonnelOption {
|
||||||
@@ -302,7 +353,7 @@ function ownerTransition(row: OwnershipLogItem): string {
|
|||||||
|
|
||||||
async function handleDelete(row: CustomerItem): Promise<void> {
|
async function handleDelete(row: CustomerItem): Promise<void> {
|
||||||
await confirmAndRun({
|
await confirmAndRun({
|
||||||
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕将一并失去展示主体。`,
|
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕与评级记录将一并失去展示主体。`,
|
||||||
title: '删除确认',
|
title: '删除确认',
|
||||||
confirmButtonText: '删除',
|
confirmButtonText: '删除',
|
||||||
successText: '客户已删除',
|
successText: '客户已删除',
|
||||||
@@ -311,12 +362,29 @@ async function handleDelete(row: CustomerItem): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 客户评级弹窗 ====================
|
||||||
|
|
||||||
|
const ratingVisible = ref(false)
|
||||||
|
const ratingTarget = ref<CustomerItem | null>(null)
|
||||||
|
|
||||||
|
function openRating(row: CustomerItem): void {
|
||||||
|
ratingTarget.value = row
|
||||||
|
ratingVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弹窗内保存评级/调整等级映射后:刷新列表行与 tag 配色 */
|
||||||
|
async function handleRatingSaved(): Promise<void> {
|
||||||
|
await load()
|
||||||
|
void loadGradeNames()
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 初始化 ====================
|
// ==================== 初始化 ====================
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
// 三个请求各自容错(内部已兜底),互不拖累
|
||||||
void load()
|
void load()
|
||||||
void loadOwnerOptions()
|
void loadOwnerOptions()
|
||||||
|
void loadGradeNames()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -369,16 +437,54 @@ onMounted(() => {
|
|||||||
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<el-table
|
||||||
<el-table-column prop="company_name" label="公司名称" min-width="180" />
|
ref="tableRef"
|
||||||
<el-table-column prop="owner_name" label="负责人" min-width="90" />
|
v-loading="loading"
|
||||||
<el-table-column prop="contact_person" label="联系人" min-width="90">
|
:data="records"
|
||||||
|
stripe
|
||||||
|
@sort-change="onRatingSortChange"
|
||||||
|
>
|
||||||
|
<!-- 列宽约定(2026-10-20 用户确认):8 列**等宽平分** —— 所有列共用同一
|
||||||
|
min-width,el-table 按 min-width 比例分配剩余宽度,等值即等宽;
|
||||||
|
全列居中后相邻两列的间距处处一致。不设 width/fixed:固定列会打破
|
||||||
|
等分(min-width 与 width 混排是按比例瓜分的根源)。窄屏下列宽收缩,
|
||||||
|
操作按钮自然换行,优先保证间距一致 -->
|
||||||
|
<el-table-column
|
||||||
|
prop="company_name"
|
||||||
|
label="公司名称"
|
||||||
|
min-width="140"
|
||||||
|
align="center"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="rating_total"
|
||||||
|
label="客户评级"
|
||||||
|
min-width="140"
|
||||||
|
align="center"
|
||||||
|
sortable="custom"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.rating_total !== null">
|
||||||
|
<el-tag
|
||||||
|
:type="ratingTagType(row.rating_grade_name)"
|
||||||
|
size="small"
|
||||||
|
disable-transitions
|
||||||
|
>
|
||||||
|
{{ row.rating_grade_name ?? '未定级' }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="rating-score">{{ row.rating_total }} 分</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="rating-none">未评级</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="owner_name" label="负责人" min-width="140" align="center" />
|
||||||
|
<el-table-column prop="contact_person" label="联系人" min-width="140" align="center">
|
||||||
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
|
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="contact_phone" label="电话" min-width="120">
|
<el-table-column prop="contact_phone" label="电话" min-width="140" align="center">
|
||||||
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
|
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="分类" width="110" align="center">
|
<el-table-column label="分类" min-width="140" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag
|
<el-tag
|
||||||
:type="
|
:type="
|
||||||
@@ -395,18 +501,23 @@ onMounted(() => {
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="归属人员" min-width="110">
|
<el-table-column label="归属人员" min-width="140" align="center">
|
||||||
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
|
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
<el-table-column label="操作" min-width="140" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<!-- 五项按钮单行均分:两端对齐让相邻按钮间距处处相等;
|
||||||
|
「配置客户评级」最长(6 字),在等宽列内无法再做 5 等分不换行,
|
||||||
|
故用等间距平分整列宽度 -->
|
||||||
|
<div class="ops-actions">
|
||||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="openRating(row)">
|
||||||
|
配置客户评级
|
||||||
|
</el-button>
|
||||||
<el-button link type="primary" size="small" @click="openOwnership(row)">
|
<el-button link type="primary" size="small" @click="openOwnership(row)">
|
||||||
归属变更
|
归属变更
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link type="primary" size="small" @click="openLogs(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>
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -530,6 +641,14 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 客户评级(打分 + 等级映射设置双 Tab;原独立评级页已删除) -->
|
||||||
|
<CustomerRatingDialog
|
||||||
|
v-model="ratingVisible"
|
||||||
|
:customer-id="ratingTarget?.id ?? 0"
|
||||||
|
:customer-name="ratingTarget?.company_name ?? ''"
|
||||||
|
@saved="handleRatingSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
|
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
|
||||||
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
|
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
|
||||||
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
|
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
|
||||||
@@ -564,4 +683,31 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 操作列:五个按钮单行、等间距平分整列(两端对齐);
|
||||||
|
去掉 Element Plus 相邻按钮的默认 12px 外边距,间距完全由 flex 控制 */
|
||||||
|
.ops-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-actions :deep(.el-button) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评级列:等级 tag + 总分灰字;未评级灰字 */
|
||||||
|
.rating-score {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-none {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -69,9 +69,9 @@ const managerOptions = ref<SelectOption[]>([])
|
|||||||
|
|
||||||
async function loadRoleOptions(): Promise<void> {
|
async function loadRoleOptions(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// 岗位规模有限(内置 3 个 + 自定义),一页足够
|
// 岗位规模有限(内置 3 个 + 自定义),选项接口一次取全
|
||||||
const data = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 100 })
|
const data = await businessRoleApi.listBusinessRoleOptions()
|
||||||
roleOptions.value = data.list.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
|
roleOptions.value = data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error) // 下拉为空不阻断列表使用
|
handledError(error) // 下拉为空不阻断列表使用
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,133 +1,383 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 dashboardApi from '@/api/dashboard'
|
||||||
import * as paramsApi from '@/api/params'
|
import type { DashboardData, TrendPoint } from '@/api/dashboard'
|
||||||
import { handledError } from '@/utils/error'
|
import { handledError } from '@/utils/error'
|
||||||
|
|
||||||
|
echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, SVGRenderer])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台(M4 ⑧):经营绩效总览 / 待办事项 / 风险预警 / 最近动态。
|
||||||
|
*
|
||||||
|
* - 四区块由一次聚合请求返回(后端实时派生,见 dashboard_service 注释)
|
||||||
|
* - 总览图表严格按设计预览:SVG 渲染、无网格线、靛蓝渐变;
|
||||||
|
* **必须显式设置 fontFamily**——ECharts 在 Windows 默认微软雅黑(字体合规红线)
|
||||||
|
* - ⑥ T+ 同步未建:同步失败待办/同步预警显示「模块未启用」,不显示 0 条
|
||||||
|
*/
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
/**
|
const CHART_FONT =
|
||||||
* 首页仪表盘(Bento Grid,2026-10-12 视觉方向)。
|
"'Noto Sans SC','Source Han Sans SC','HarmonyOS Sans SC','PingFang SC',sans-serif"
|
||||||
*
|
|
||||||
* M1 阶段:计提参数与岗位配置已就绪(真实数据);绩效/回款/待办等业务卡
|
const SETTLEMENT_STATUS_LABELS: Record<number, string> = {
|
||||||
* 展示空态 —— 业务数据管理上线后接入真实统计(图表用 ECharts 渲染)。
|
1: '草稿',
|
||||||
* 空态文案遵循全局规范:空态是邀请,说明「这里将出现什么、怎么让它出现」。
|
2: '待审批',
|
||||||
*/
|
3: '已锁定',
|
||||||
const roleCount = ref<number | null>(null)
|
4: '已驳回',
|
||||||
const paramCount = ref<number | null>(null)
|
}
|
||||||
const latestVersion = ref<string | null>(null)
|
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(() => {
|
onMounted(() => {
|
||||||
// 三项统计各自容错:一张卡失败不影响其余卡片
|
void load()
|
||||||
void (async () => {
|
window.addEventListener('resize', handleResize)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div v-loading="loading">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1 class="page-title">仪表盘</h1>
|
<h1 class="page-title">工作台</h1>
|
||||||
<p class="page-desc">系统概览 · 计提参数与岗位配置就绪,业务数据管理上线后接入核算统计</p>
|
<p class="page-desc">经营绩效总览 · 待办与风险集中处置 · 最近动态</p>
|
||||||
<span class="waterline"></span>
|
<span class="waterline"></span>
|
||||||
</div>
|
</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">
|
||||||
<!-- 绩效总额:业务上线前的空态 -->
|
<!-- 经营绩效总览:KPI -->
|
||||||
<div class="bento-card b-2x2">
|
<div class="bento-card">
|
||||||
<div class="b-eyebrow">本月绩效总额</div>
|
<div class="b-eyebrow">本月绩效总额</div>
|
||||||
<div class="b-empty">
|
<div class="b-num">{{ data?.overview.performance_total ?? '—' }}</div>
|
||||||
<div class="b-empty-title">暂无核算数据</div>
|
</div>
|
||||||
<div class="b-empty-desc">回款录入并核算后,这里将显示本月绩效总额与趋势</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>
|
</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="bento-card b-2x2">
|
||||||
<div class="b-eyebrow">近 6 个月回款</div>
|
<div class="b-eyebrow">近 6 个月回款</div>
|
||||||
<div class="b-empty">
|
<div ref="receiptChartRef" class="chart"></div>
|
||||||
<div class="b-empty-title">暂无回款数据</div>
|
</div>
|
||||||
<div class="b-empty-desc">业务数据管理上线后,此处展示按月回款趋势</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>
|
</div>
|
||||||
|
|
||||||
<!-- 待办 -->
|
<!-- 风险预警 -->
|
||||||
<div class="bento-card b-2x1">
|
<div class="bento-card b-2x2">
|
||||||
<div class="b-eyebrow">待办</div>
|
<div class="b-eyebrow">
|
||||||
<div class="b-empty-mini">业务数据管理上线后,待核算回款会出现在这里</div>
|
风险预警
|
||||||
|
<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>
|
||||||
|
|
||||||
<!-- 营销人员:人员管理上线后接入 -->
|
<!-- 最近动态 -->
|
||||||
<div class="bento-card">
|
<div class="bento-card b-full">
|
||||||
<div class="b-eyebrow">营销人员</div>
|
<div class="b-eyebrow">
|
||||||
<div class="b-empty-mini">人员管理上线后同步</div>
|
最近动态
|
||||||
</div>
|
<el-select
|
||||||
|
v-model="activityModule"
|
||||||
<!-- 岗位角色:真实数据 -->
|
size="small"
|
||||||
<div class="bento-card">
|
clearable
|
||||||
<div class="b-eyebrow">岗位角色</div>
|
placeholder="全部模块"
|
||||||
<div class="b-num">{{ roleCount ?? '—' }}<small>个</small></div>
|
style="width: 140px; margin-left: 12px"
|
||||||
<button class="b-link" type="button" @click="router.push('/system/business-roles')">
|
>
|
||||||
管理 ›
|
<el-option v-for="item in activityModules" :key="item" :label="item" :value="item" />
|
||||||
</button>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="filteredActivities.length" class="activity-list">
|
||||||
<!-- 计提参数:真实数据 -->
|
<div v-for="item in filteredActivities" :key="item.id" class="activity-row">
|
||||||
<div class="bento-card">
|
<span class="activity-time">{{ formatTime(item.create_time) }}</span>
|
||||||
<div class="b-eyebrow">计提参数</div>
|
<el-tag size="small" :type="item.success === 1 ? 'info' : 'danger'" disable-transitions>
|
||||||
<div class="b-num">{{ paramCount ?? '—' }}<small>项</small></div>
|
{{ item.module }} · {{ item.action }}
|
||||||
<button class="b-link" type="button" @click="router.push('/system/params')">
|
</el-tag>
|
||||||
维护 ›
|
<span class="activity-summary">{{ item.summary }}</span>
|
||||||
</button>
|
<span class="activity-user">{{ item.username ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="b-empty-mini">暂无动态</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 常用操作 -->
|
<!-- 常用操作 -->
|
||||||
<div class="bento-card b-2x1">
|
<div class="bento-card b-full">
|
||||||
<div class="b-eyebrow">常用操作</div>
|
<div class="b-eyebrow">常用操作</div>
|
||||||
<div class="b-links">
|
<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>
|
||||||
<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>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -139,56 +389,62 @@ onMounted(() => {
|
|||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-desc {
|
.page-desc {
|
||||||
margin: 4px 0 0;
|
margin: 6px 0 0;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.waterline {
|
.waterline {
|
||||||
display: block;
|
display: block;
|
||||||
width: 56px;
|
width: 46px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
margin-top: 10px;
|
margin-top: 12px;
|
||||||
border-radius: 1px;
|
|
||||||
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
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 {
|
.bento {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
grid-auto-rows: 102px;
|
grid-auto-rows: 102px;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bento-card {
|
.bento-card {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-direction: column;
|
padding: 16px 18px;
|
||||||
padding: 17px 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
border: 1px solid var(--ys-line);
|
border: 1px solid var(--ys-line);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 2px rgba(28, 35, 51, 0.04),
|
0 1px 2px rgba(28, 35, 51, 0.04),
|
||||||
0 3px 10px rgba(28, 35, 51, 0.03);
|
0 10px 24px -18px rgba(28, 35, 51, 0.18);
|
||||||
transition:
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
transform 180ms cubic-bezier(0.25, 0.1, 0.25, 1),
|
overflow: hidden;
|
||||||
box-shadow 180ms cubic-bezier(0.25, 0.1, 0.25, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bento-card:hover {
|
.bento-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 2px rgba(28, 35, 51, 0.05),
|
0 2px 4px rgba(28, 35, 51, 0.05),
|
||||||
0 6px 18px rgba(28, 35, 51, 0.07);
|
0 16px 30px -18px rgba(28, 35, 51, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-2x2 {
|
.b-2x2 {
|
||||||
@@ -196,149 +452,190 @@ onMounted(() => {
|
|||||||
grid-row: span 2;
|
grid-row: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-2x1 {
|
.b-full {
|
||||||
grid-column: span 2;
|
grid-column: span 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-eyebrow {
|
.b-eyebrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.04em;
|
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 {
|
.b-num {
|
||||||
margin-top: auto;
|
margin-top: 10px;
|
||||||
font-size: 24px;
|
font-size: 26px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
line-height: 1.2;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-num small {
|
.b-num small {
|
||||||
margin-left: 3px;
|
margin-left: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-sub {
|
.b-sub {
|
||||||
margin-top: 3px;
|
margin-top: 4px;
|
||||||
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;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
line-height: 1.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-empty-mini {
|
.b-empty-mini {
|
||||||
margin-top: auto;
|
margin-top: 8px;
|
||||||
margin-bottom: auto;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
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 {
|
.b-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
gap: 10px;
|
||||||
margin-top: auto;
|
margin-top: 10px;
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-op {
|
.b-op {
|
||||||
height: 30px;
|
padding: 6px 14px;
|
||||||
padding: 0 13px;
|
font-size: 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 {
|
|
||||||
color: var(--ys-indigo);
|
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 {
|
.b-op:hover {
|
||||||
color: var(--ys-ink-3);
|
border-color: var(--ys-indigo);
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.bento {
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.bento {
|
.bento {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-auto-rows: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bento-card {
|
|
||||||
min-height: 90px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-2x2,
|
.b-2x2,
|
||||||
.b-2x1 {
|
.b-full {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
grid-row: span 1;
|
|
||||||
min-height: 130px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,5 +643,9 @@ button.b-op:hover {
|
|||||||
.bento-card {
|
.bento-card {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bento-card:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -4,11 +4,14 @@ import { onMounted, reactive, ref } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { getCaptcha } from '@/api/auth'
|
import { getCaptcha } from '@/api/auth'
|
||||||
|
import { canAccessPath, firstAccessiblePath } from '@/router/access'
|
||||||
|
import { useSettingsStore } from '@/store/settings'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -23,32 +26,6 @@ const form = reactive({
|
|||||||
const captchaId = ref('')
|
const captchaId = ref('')
|
||||||
const captchaImage = ref('')
|
const captchaImage = ref('')
|
||||||
|
|
||||||
/** 品牌名:默认衍水集团,公司信息设置页改了以后登录页同步生效(public 端点) */
|
|
||||||
const brandName = ref('衍水集团')
|
|
||||||
const brandLogo = ref<string | null>(null)
|
|
||||||
|
|
||||||
async function loadBrand(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// 登录页必须拿到最新公司名(保存后立即生效):public 端点带 300s
|
|
||||||
// 响应缓存,axios 可能命中浏览器缓存副本。这里用带时间戳的原生
|
|
||||||
// fetch 绕过磁盘缓存,手动解包统一信封(与 request 拦截器同口径)。
|
|
||||||
const resp = await fetch(
|
|
||||||
`/api/v1/system/settings/public?_=${Date.now()}`,
|
|
||||||
{ cache: 'no-store' },
|
|
||||||
)
|
|
||||||
if (!resp.ok) throw new Error(String(resp.status))
|
|
||||||
const body = (await resp.json()) as {
|
|
||||||
code: number
|
|
||||||
data: { company_name?: string; logo_url?: string | null }
|
|
||||||
}
|
|
||||||
const data = body.data
|
|
||||||
if (data.company_name) brandName.value = data.company_name
|
|
||||||
brandLogo.value = data.logo_url ?? null
|
|
||||||
} catch {
|
|
||||||
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||||
@@ -87,8 +64,15 @@ async function handleSubmit(): Promise<void> {
|
|||||||
captcha_id: captchaId.value,
|
captcha_id: captchaId.value,
|
||||||
captcha_code: form.captcha_code.trim(),
|
captcha_code: form.captcha_code.trim(),
|
||||||
})
|
})
|
||||||
|
// 先拉角色再解析落地页:普通用户不能回跳到受限的工作台
|
||||||
|
await userStore.fetchUserInfo()
|
||||||
ElMessage.success('登录成功')
|
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 {
|
} catch {
|
||||||
// **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。
|
// **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。
|
||||||
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
|
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
|
||||||
@@ -100,7 +84,8 @@ async function handleSubmit(): Promise<void> {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshCaptcha()
|
refreshCaptcha()
|
||||||
loadBrand()
|
// 品牌信息失败静默回退内置默认值(不阻断登录;内部去重/缓存)
|
||||||
|
void settingsStore.ensureLoaded()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -114,9 +99,14 @@ onMounted(() => {
|
|||||||
<div class="login-wrap">
|
<div class="login-wrap">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="card-brand">
|
<div class="card-brand">
|
||||||
<img v-if="brandLogo" :src="brandLogo" alt="" class="card-brand__logo" />
|
<img
|
||||||
<div class="card-brand__name">{{ brandName }}</div>
|
v-if="settingsStore.logoUrl"
|
||||||
<p class="card-brand__slogan">把每一笔回款,算成一份踏实。</p>
|
:src="settingsStore.logoUrl"
|
||||||
|
alt=""
|
||||||
|
class="card-brand__logo"
|
||||||
|
/>
|
||||||
|
<div class="card-brand__name">{{ settingsStore.companyName }}</div>
|
||||||
|
<p class="card-brand__slogan">{{ settingsStore.brandSlogan }}</p>
|
||||||
<span class="waterline"></span>
|
<span class="waterline"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,7 +171,10 @@ onMounted(() => {
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="login-foot">© {{ brandName }} · 内部系统</p>
|
<p class="login-foot">
|
||||||
|
{{ settingsStore.footerLine
|
||||||
|
}}<template v-if="settingsStore.icpNo"> · {{ settingsStore.icpNo }}</template>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -60,14 +60,15 @@ function getFilters(): ReportFilterParams {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPage(params: ReportFilterParams) {
|
function fetchPage(params: ReportFilterParams) {
|
||||||
const data = await reportApi.listBusinessStats(params)
|
return reportApi.listBusinessStats(params)
|
||||||
summary.value = data.summary
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||||||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||||||
usePagedList<BusinessStatsRow, ReportFilterParams>(fetchPage, getFilters)
|
usePagedList(fetchPage, getFilters, (data) => {
|
||||||
|
summary.value = data.summary
|
||||||
|
})
|
||||||
|
|
||||||
// 进页即加载(报表页默认展示全部,筛选由用户主动收窄)
|
// 进页即加载(报表页默认展示全部,筛选由用户主动收窄)
|
||||||
onMounted(search)
|
onMounted(search)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ import { handledError } from '@/utils/error'
|
|||||||
*
|
*
|
||||||
* 部门口径 = personnel.manager_id 实时上溯的经理团队(无独立部门表);
|
* 部门口径 = personnel.manager_id 实时上溯的经理团队(无独立部门表);
|
||||||
* 团队基数与月结聚合行同源(manager_share 共享模块),因此可与月结
|
* 团队基数与月结聚合行同源(manager_share 共享模块),因此可与月结
|
||||||
* 冻结额(frozen_amount)对账。全公司 5% 部分单列——各团队行展示的
|
* 冻结额(frozen_amount)对账。全公司分成每位有聚合行的经理各计一次,
|
||||||
* 5% 金额是同一基数的名义值,不能相加。
|
* 团队应发小计与月结聚合行同口径;列头比例随在线参数变化。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -35,6 +35,18 @@ void loadManagers()
|
|||||||
const data = ref<DepartmentSummary | null>(null)
|
const data = ref<DepartmentSummary | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
|
// 比例来自在线参数,列头/卡片不能写死 12.5% / 5%
|
||||||
|
const teamShareLabel = computed(
|
||||||
|
() => `团队分成(${data.value?.params?.team_text ?? '—'}%)`,
|
||||||
|
)
|
||||||
|
const companyShareLabel = computed(
|
||||||
|
() => `全公司分成(${data.value?.params?.company_text ?? '—'}%)`,
|
||||||
|
)
|
||||||
|
const companyTitle = computed(
|
||||||
|
() => `全公司 ${data.value?.params?.company_text ?? '—'}% 部分(单列)`,
|
||||||
|
)
|
||||||
|
|
||||||
function currentMonth(): string {
|
function currentMonth(): string {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -46,16 +58,18 @@ async function load(): Promise<void> {
|
|||||||
ElMessage.warning('请选择统计月份(部门汇总按单月聚合)')
|
ElMessage.warning('请选择统计月份(部门汇总按单月聚合)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const seq = ++loadSeq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
data.value = await reportApi.getDepartmentSummary({
|
const result = await reportApi.getDepartmentSummary({
|
||||||
settlement_month: filters.settlement_month,
|
settlement_month: filters.settlement_month,
|
||||||
manager_id: filters.manager_id,
|
manager_id: filters.manager_id,
|
||||||
})
|
})
|
||||||
|
if (seq === loadSeq) data.value = result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error)
|
if (seq === loadSeq) handledError(error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,11 +178,16 @@ function pct(value: string): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="company-card">
|
<div v-loading="loading" class="company-card">
|
||||||
<span class="company-title">全公司 5% 部分(单列)</span>
|
<span class="company-title">{{ companyTitle }}</span>
|
||||||
<span>全公司基数:{{ data?.company?.company_base ?? '—' }}</span>
|
<span>全公司基数:{{ data?.company?.company_base ?? '—' }}</span>
|
||||||
<span>5% 分成:{{ data?.company?.company_share_amount ?? '—' }}</span>
|
<span>
|
||||||
|
单经理 {{ data?.params?.company_text ?? '—' }}% 分成:
|
||||||
|
{{ data?.company?.company_share_amount ?? '—' }} ×
|
||||||
|
{{ data?.company?.company_share_count ?? 0 }} 人 =
|
||||||
|
{{ data?.company?.company_share_total ?? '—' }}
|
||||||
|
</span>
|
||||||
<span class="company-note">
|
<span class="company-note">
|
||||||
各团队行的 5% 金额基于同一全公司基数,不可跨团队相加
|
每位有月结聚合行的经理各计一次;零成员团队无聚合行,不产生分成
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -210,8 +229,8 @@ function pct(value: string): string {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="member_count" label="团队人数" width="90" align="right" />
|
<el-table-column prop="member_count" label="团队人数" width="90" align="right" />
|
||||||
<el-table-column prop="team_base" label="团队基数" width="120" align="right" />
|
<el-table-column prop="team_base" label="团队基数" width="120" align="right" />
|
||||||
<el-table-column prop="team_share_amount" label="12.5% 分成" width="120" align="right" />
|
<el-table-column prop="team_share_amount" :label="teamShareLabel" width="150" align="right" />
|
||||||
<el-table-column prop="company_share_amount" label="5% 分成" width="110" align="right" />
|
<el-table-column prop="company_share_amount" :label="companyShareLabel" width="150" align="right" />
|
||||||
<el-table-column label="占全公司比" width="110" align="right">
|
<el-table-column label="占全公司比" width="110" align="right">
|
||||||
<template #default="{ row }">{{ pct(row.company_base_ratio) }}</template>
|
<template #default="{ row }">{{ pct(row.company_base_ratio) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -40,66 +40,86 @@ function getFilters() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DrillState {
|
||||||
|
rows: PaymentDetailRow[]
|
||||||
|
total: number
|
||||||
|
pageNum: number
|
||||||
|
pageSize: number
|
||||||
|
loading: boolean
|
||||||
|
seq: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每个桶独立的下钻状态:同时展开多个区间时互不覆盖 */
|
||||||
|
const drills = reactive<Record<string, DrillState>>({})
|
||||||
|
|
||||||
|
function drillOf(code: string): DrillState {
|
||||||
|
drills[code] ??= { rows: [], total: 0, pageNum: 1, pageSize: 10, loading: false, seq: 0 }
|
||||||
|
return drills[code]
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
|
const seq = ++loadSeq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
data.value = await reportApi.getPaymentStats(getFilters())
|
const result = await reportApi.getPaymentStats(getFilters())
|
||||||
|
if (seq !== loadSeq) return
|
||||||
|
data.value = result
|
||||||
|
// 筛选条件变化后旧桶明细不再对应当前口径;同时预建各桶状态,
|
||||||
|
// 避免模板渲染时才创建响应式属性
|
||||||
|
for (const code of Object.keys(drills)) delete drills[code]
|
||||||
|
for (const bucket of result.buckets) drillOf(bucket.code)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error)
|
if (seq === loadSeq) handledError(error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void load()
|
function loadDrill(bucket: BucketRow, expandedRows: BucketRow[]): void {
|
||||||
|
// Element Plus 2.x 的第二参是当前展开行数组,不是 boolean;
|
||||||
/** 桶展开下钻:分页明细按 (桶编码) 缓存当前页 */
|
// 收起时该桶不在数组里,自然不再请求
|
||||||
const drill = reactive({
|
if (!expandedRows.some((row) => row.code === bucket.code)) return
|
||||||
bucket: '',
|
drillOf(bucket.code).pageNum = 1
|
||||||
rows: [] as PaymentDetailRow[],
|
void fetchDrill(bucket.code)
|
||||||
total: 0,
|
|
||||||
pageNum: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
loading: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadDrill(bucket: BucketRow, expanded: boolean): Promise<void> {
|
|
||||||
if (!expanded) return
|
|
||||||
drill.bucket = bucket.code
|
|
||||||
drill.pageNum = 1
|
|
||||||
await fetchDrill()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDrill(): Promise<void> {
|
async function fetchDrill(code: string): Promise<void> {
|
||||||
if (!drill.bucket) return
|
const state = drillOf(code)
|
||||||
drill.loading = true
|
const seq = ++state.seq
|
||||||
|
state.loading = true
|
||||||
try {
|
try {
|
||||||
const page = await reportApi.listPaymentDetail({
|
const page = await reportApi.listPaymentDetail({
|
||||||
...getFilters(),
|
...getFilters(),
|
||||||
bucket: drill.bucket,
|
bucket: code,
|
||||||
pageNum: drill.pageNum,
|
pageNum: state.pageNum,
|
||||||
pageSize: drill.pageSize,
|
pageSize: state.pageSize,
|
||||||
})
|
})
|
||||||
drill.rows = page.list
|
if (seq !== state.seq) return
|
||||||
drill.total = page.total
|
state.rows = page.list
|
||||||
|
state.total = page.total
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error)
|
if (seq === state.seq) handledError(error)
|
||||||
} finally {
|
} finally {
|
||||||
drill.loading = false
|
if (seq === state.seq) state.loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrillPage(page: number): void {
|
function onDrillPage(code: string, page: number): void {
|
||||||
drill.pageNum = page
|
drillOf(code).pageNum = page
|
||||||
void fetchDrill()
|
void fetchDrill(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrillSize(size: number): void {
|
function onDrillSize(code: string, size: number): void {
|
||||||
drill.pageSize = size
|
const state = drillOf(code)
|
||||||
drill.pageNum = 1 // 改每页条数回到第 1 页,否则可能停在空页
|
state.pageSize = size
|
||||||
void fetchDrill()
|
state.pageNum = 1 // 改每页条数回到第 1 页,否则可能停在空页
|
||||||
|
void fetchDrill(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
|
||||||
async function doExport(): Promise<void> {
|
async function doExport(): Promise<void> {
|
||||||
exporting.value = true
|
exporting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -198,15 +218,22 @@ function today(): string {
|
|||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
|
class="bucket-table"
|
||||||
|
data-testid="payment-bucket-table"
|
||||||
:data="data?.buckets ?? []"
|
:data="data?.buckets ?? []"
|
||||||
row-key="code"
|
row-key="code"
|
||||||
stripe
|
stripe
|
||||||
@expand-change="loadDrill"
|
@expand-change="loadDrill"
|
||||||
>
|
>
|
||||||
<el-table-column type="expand">
|
<el-table-column type="expand">
|
||||||
<template #default>
|
<template #default="{ row }">
|
||||||
<div class="drill-wrap">
|
<div class="drill-wrap">
|
||||||
<el-table v-loading="drill.loading" :data="drill.rows" size="small" stripe>
|
<el-table
|
||||||
|
v-loading="drillOf(row.code).loading"
|
||||||
|
:data="drillOf(row.code).rows"
|
||||||
|
size="small"
|
||||||
|
stripe
|
||||||
|
>
|
||||||
<el-table-column prop="business_no" label="合同编号" min-width="150" />
|
<el-table-column prop="business_no" label="合同编号" min-width="150" />
|
||||||
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
|
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
|
||||||
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
||||||
@@ -226,11 +253,11 @@ function today(): string {
|
|||||||
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
|
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
|
||||||
</el-table>
|
</el-table>
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="drill.total"
|
:total="drillOf(row.code).total"
|
||||||
:page-num="drill.pageNum"
|
:page-num="drillOf(row.code).pageNum"
|
||||||
:page-size="drill.pageSize"
|
:page-size="drillOf(row.code).pageSize"
|
||||||
@page-change="onDrillPage"
|
@page-change="onDrillPage(row.code, $event)"
|
||||||
@size-change="onDrillSize"
|
@size-change="onDrillSize(row.code, $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import * as calcApi from '@/api/calc'
|
import * as calcApi from '@/api/calc'
|
||||||
import type { LedgerRow } from '@/api/calc'
|
import type { LedgerRow } from '@/api/calc'
|
||||||
@@ -9,7 +9,7 @@ import { CUSTOMER_TYPE_OPTIONS, customerTypeLabel } from '@/api/customers'
|
|||||||
import { listPersonnel } from '@/api/personnel'
|
import { listPersonnel } from '@/api/personnel'
|
||||||
import * as reportApi from '@/api/reports'
|
import * as reportApi from '@/api/reports'
|
||||||
import type { PersonalDetailRow, ReportFilterParams, ReportSummary } from '@/api/reports'
|
import type { PersonalDetailRow, ReportFilterParams, ReportSummary } from '@/api/reports'
|
||||||
import { listBusinessRoles } from '@/api/businessRoles'
|
import { listBusinessRoleOptions } from '@/api/businessRoles'
|
||||||
import AppPagination from '@/components/table/AppPagination.vue'
|
import AppPagination from '@/components/table/AppPagination.vue'
|
||||||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||||
import { usePagedList } from '@/composables/usePagedList'
|
import { usePagedList } from '@/composables/usePagedList'
|
||||||
@@ -23,6 +23,7 @@ import { handledError } from '@/utils/error'
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
settlement_month: '',
|
settlement_month: '',
|
||||||
@@ -32,14 +33,30 @@ const filters = reactive({
|
|||||||
role_code: '',
|
role_code: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门汇总「查看明细」下钻:路由 query 必须真正落到筛选条件上。
|
||||||
|
*
|
||||||
|
* 无 query 时也要清空(不能只覆盖):从菜单再次进入同一页面时组件复用,
|
||||||
|
* 旧的 personnel_id/settlement_month 会残留,用户看到的仍是被下钻者的数据。
|
||||||
|
*/
|
||||||
|
function applyRouteQuery(): void {
|
||||||
|
const personnelId = Number(route.query.personnel_id)
|
||||||
|
filters.personnel_id =
|
||||||
|
Number.isInteger(personnelId) && personnelId > 0 ? personnelId : undefined
|
||||||
|
const month = route.query.settlement_month
|
||||||
|
filters.settlement_month =
|
||||||
|
typeof month === 'string' && /^\d{4}-\d{2}$/.test(month) ? month : ''
|
||||||
|
}
|
||||||
|
applyRouteQuery()
|
||||||
|
|
||||||
const { options: personnelOptions, load: loadPersonnel } = useDropdownOptions(() =>
|
const { options: personnelOptions, load: loadPersonnel } = useDropdownOptions(() =>
|
||||||
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
|
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
|
||||||
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
|
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const { options: roleOptions, load: loadRoles } = useDropdownOptions(() =>
|
const { options: roleOptions, load: loadRoles } = useDropdownOptions(() =>
|
||||||
listBusinessRoles({ pageNum: 1, pageSize: 200 }).then((data) =>
|
listBusinessRoleOptions().then((data) =>
|
||||||
data.list.map((item) => ({ label: item.role_name, value: item.role_code })),
|
data.map((item) => ({ label: item.role_name, value: item.role_code })),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
void loadPersonnel()
|
void loadPersonnel()
|
||||||
@@ -58,18 +75,28 @@ function getFilters(): ReportFilterParams {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPage(params: ReportFilterParams) {
|
function fetchPage(params: ReportFilterParams) {
|
||||||
const data = await reportApi.listPersonalDetail(params)
|
return reportApi.listPersonalDetail(params)
|
||||||
summary.value = data.summary
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||||||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||||||
usePagedList<PersonalDetailRow, ReportFilterParams>(fetchPage, getFilters)
|
usePagedList(fetchPage, getFilters, (data) => {
|
||||||
|
summary.value = data.summary
|
||||||
|
})
|
||||||
|
|
||||||
// 进页即加载(与月结/台账页修复后的行为一致;筛选条件默认为「全部」)
|
// 进页即加载(筛选条件可能已由部门汇总下钻带入)
|
||||||
onMounted(search)
|
onMounted(search)
|
||||||
|
|
||||||
|
// 同一路由再次下钻(例如从部门汇总换成员)时重新应用 query
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
() => {
|
||||||
|
applyRouteQuery()
|
||||||
|
search()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
function resetFilters(): void {
|
function resetFilters(): void {
|
||||||
filters.settlement_month = ''
|
filters.settlement_month = ''
|
||||||
filters.personnel_id = undefined
|
filters.personnel_id = undefined
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { customerTypeLabel } from '@/api/customers'
|
|||||||
import * as reportApi from '@/api/reports'
|
import * as reportApi from '@/api/reports'
|
||||||
import type {
|
import type {
|
||||||
BusinessStatsRow,
|
BusinessStatsRow,
|
||||||
DepartmentMemberRow,
|
DepartmentSummary,
|
||||||
PersonalDetailRow,
|
PersonalDetailRow,
|
||||||
ReportFilterParams,
|
ReportFilterParams,
|
||||||
} from '@/api/reports'
|
} from '@/api/reports'
|
||||||
@@ -38,12 +38,30 @@ const title = ref('')
|
|||||||
// 表格行是四类报表的并集,模板按报表编码取用各自字段;用宽松记录类型
|
// 表格行是四类报表的并集,模板按报表编码取用各自字段;用宽松记录类型
|
||||||
// 避免为四种行形态各写一套模板 props
|
// 避免为四种行形态各写一套模板 props
|
||||||
const rows = ref<Record<string, any>[]>([])
|
const rows = ref<Record<string, any>[]>([])
|
||||||
|
const department = ref<DepartmentSummary | null>(null)
|
||||||
const buckets = ref<Record<string, any>[]>([])
|
const buckets = ref<Record<string, any>[]>([])
|
||||||
const groups = ref<{ title: string; rows: Record<string, any>[] }[]>([])
|
const groups = ref<{ title: string; rows: Record<string, any>[] }[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const truncated = ref(false)
|
const truncated = ref(false)
|
||||||
const printedAt = ref(new Date())
|
const printedAt = ref(new Date())
|
||||||
|
|
||||||
|
/** 部门汇总成员明细展平(团队列冗余,与签字稿其他块一致) */
|
||||||
|
const departmentMembers = computed(() =>
|
||||||
|
(department.value?.teams ?? []).flatMap((team) =>
|
||||||
|
team.members.map((member) => ({ manager_name: team.manager_name, ...member })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const MEMBER_KIND_LABELS: Record<string, string> = {
|
||||||
|
regular: '普通绩效',
|
||||||
|
manager_personal: '经理个人户',
|
||||||
|
adjustment: '仅调整单',
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberKindLabel(kind: string): string {
|
||||||
|
return MEMBER_KIND_LABELS[kind] ?? kind
|
||||||
|
}
|
||||||
|
|
||||||
const filterText = computed(() => {
|
const filterText = computed(() => {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
settlement_month: '统计月份',
|
settlement_month: '统计月份',
|
||||||
@@ -71,13 +89,19 @@ async function fetchAll<T>(
|
|||||||
): Promise<{ list: T[]; truncated: boolean }> {
|
): Promise<{ list: T[]; truncated: boolean }> {
|
||||||
const collected: T[] = []
|
const collected: T[] = []
|
||||||
let pageNum = 1
|
let pageNum = 1
|
||||||
|
let total = 0
|
||||||
while (collected.length < PRINT_MAX_ROWS) {
|
while (collected.length < PRINT_MAX_ROWS) {
|
||||||
const data = await fetcher({ ...params, pageNum, pageSize: PAGE_SIZE })
|
const data = await fetcher({ ...params, pageNum, pageSize: PAGE_SIZE })
|
||||||
|
total = data.total
|
||||||
collected.push(...data.list)
|
collected.push(...data.list)
|
||||||
if (collected.length >= data.total || data.list.length < PAGE_SIZE) break
|
if (collected.length >= data.total || data.list.length < PAGE_SIZE) break
|
||||||
pageNum += 1
|
pageNum += 1
|
||||||
}
|
}
|
||||||
return { list: collected.slice(0, PRINT_MAX_ROWS), truncated: collected.length > PRINT_MAX_ROWS }
|
// 以接口 total 判定:collected 最多正好等于上限,用长度比较永远为 false
|
||||||
|
return {
|
||||||
|
list: collected.slice(0, PRINT_MAX_ROWS),
|
||||||
|
truncated: total > PRINT_MAX_ROWS,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function personalRow(row: PersonalDetailRow): Record<string, any> {
|
function personalRow(row: PersonalDetailRow): Record<string, any> {
|
||||||
@@ -109,18 +133,7 @@ async function load(): Promise<void> {
|
|||||||
truncated.value = data.truncated
|
truncated.value = data.truncated
|
||||||
} else if (code.value === 'department-summary') {
|
} else if (code.value === 'department-summary') {
|
||||||
title.value = '部门绩效工资汇总表'
|
title.value = '部门绩效工资汇总表'
|
||||||
const data = await reportApi.getDepartmentSummary(queryParams.value)
|
department.value = await reportApi.getDepartmentSummary(queryParams.value)
|
||||||
rows.value = data.teams.flatMap((team) =>
|
|
||||||
team.members.map((member: DepartmentMemberRow) => ({
|
|
||||||
manager_name: team.manager_name,
|
|
||||||
member_name: member.personnel_name,
|
|
||||||
role_code: member.role_code,
|
|
||||||
performance_sum: member.performance_sum,
|
|
||||||
payable_sum: member.payable_sum,
|
|
||||||
adjust_net: member.adjust_net,
|
|
||||||
net_total: member.net_total,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
} else if (code.value === 'business-stats') {
|
} else if (code.value === 'business-stats') {
|
||||||
title.value = '业务统计表'
|
title.value = '业务统计表'
|
||||||
const data = await fetchAll(
|
const data = await fetchAll(
|
||||||
@@ -169,12 +182,13 @@ onMounted(load)
|
|||||||
<h1 class="title">{{ title }}</h1>
|
<h1 class="title">{{ title }}</h1>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span>筛选条件:{{ filterText }}</span>
|
<span>筛选条件:{{ filterText }}</span>
|
||||||
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.0</span>
|
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="truncated" class="truncated">
|
<div v-if="truncated" class="truncated">
|
||||||
数据量超过打印上限(2000 行),仅打印前 2000 行;请缩小筛选范围后重新打印。
|
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),仅打印前 {{ PRINT_MAX_ROWS }}
|
||||||
|
行;请缩小筛选范围后重新打印。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="code === 'personal-detail'">
|
<template v-if="code === 'personal-detail'">
|
||||||
@@ -204,23 +218,171 @@ onMounted(load)
|
|||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="code === 'department-summary'">
|
<template v-else-if="code === 'department-summary' && department">
|
||||||
<table class="sheet-table">
|
<h2 class="block-title">一、团队分成汇总</h2>
|
||||||
|
<table class="sheet-table" data-testid="print-department-teams">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>经理团队</th><th>成员</th><th>岗位</th><th>绩效金额</th>
|
<th>序号</th><th>经理团队</th><th>状态</th><th>团队人数</th><th>团队基数</th>
|
||||||
<th>应付绩效</th><th>调整净额</th><th>净合计</th>
|
<th>团队分成({{ department.params?.team_text ?? '—' }}%)</th>
|
||||||
|
<th>调整净额(仅核对)</th><th>团队应发小计(含聚合分成)</th><th>月结冻结额</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(row, index) in rows" :key="index">
|
<tr v-for="(team, index) in department.teams" :key="team.manager_id">
|
||||||
<td>{{ row.manager_name }}</td>
|
<td>{{ index + 1 }}</td>
|
||||||
<td>{{ row.member_name }}</td>
|
<td>{{ team.manager_name }}</td>
|
||||||
<td>{{ row.role_code }}</td>
|
<td>{{ team.manager_status === 1 ? '在职' : '离职' }}</td>
|
||||||
<td class="num">{{ row.performance_sum }}</td>
|
<td class="num">{{ team.member_count }}</td>
|
||||||
<td class="num">{{ row.payable_sum }}</td>
|
<td class="num">{{ team.team_base }}</td>
|
||||||
<td class="num">{{ row.adjust_net }}</td>
|
<td class="num">{{ team.team_share_amount }}</td>
|
||||||
<td class="num">{{ row.net_total }}</td>
|
<td class="num">{{ team.adjust_net }}</td>
|
||||||
|
<td class="num">{{ team.team_subtotal }}</td>
|
||||||
|
<td class="num">{{ team.frozen_amount ?? '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 class="block-title">
|
||||||
|
二、全公司分成(每位有聚合行的经理各计一次,共
|
||||||
|
{{ department.company?.company_share_count ?? 0 }} 次)
|
||||||
|
</h2>
|
||||||
|
<table class="sheet-table" data-testid="print-company-block">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>全公司基数</th><th>分成比例</th><th>单经理分成</th>
|
||||||
|
<th>计入次数</th><th>分成合计</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="num">{{ department.company?.company_base ?? '—' }}</td>
|
||||||
|
<td class="num">{{ department.params?.company_text ?? '—' }}%</td>
|
||||||
|
<td class="num">{{ department.company?.company_share_amount ?? '—' }}</td>
|
||||||
|
<td class="num">{{ department.company?.company_share_count ?? 0 }}</td>
|
||||||
|
<td class="num">{{ department.company?.company_share_total ?? '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 class="block-title">三、对账汇总</h2>
|
||||||
|
<table class="sheet-table" data-testid="print-reconciliation">
|
||||||
|
<thead>
|
||||||
|
<tr><th>项目</th><th>金额</th><th>说明</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>团队成员应付合计</td>
|
||||||
|
<td class="num">{{ department.reconciliation?.team_member_payable_total ?? '—' }}</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>未分配人员应付合计</td>
|
||||||
|
<td class="num">{{ department.reconciliation?.unassigned_payable_total ?? '—' }}</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>团队分成合计(分列)</td>
|
||||||
|
<td class="num">{{ department.reconciliation?.team_share_total ?? '—' }}</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
全公司分成合计({{ department.company?.company_share_count ?? 0 }} 次,分列)
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ department.reconciliation?.company_share_total ?? '—' }}</td>
|
||||||
|
<td>每位有聚合行的经理各计一次</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>聚合行舍入差</td>
|
||||||
|
<td class="num">{{ department.reconciliation?.aggregate_rounding_diff ?? '—' }}</td>
|
||||||
|
<td>分列舍入与聚合行一次舍入的差</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>调整净额合计(明细层)</td>
|
||||||
|
<td class="num">{{ department.reconciliation?.adjust_net_total ?? '—' }}</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>应发合计</td>
|
||||||
|
<td class="num">{{ department.reconciliation?.payable_total ?? '—' }}</td>
|
||||||
|
<td>上列各项之和</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>团队基数合计</td>
|
||||||
|
<td class="num">{{ department.reconciliation?.team_base_total ?? '—' }}</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>未分配绩效合计</td>
|
||||||
|
<td class="num">{{ department.reconciliation?.unassigned_performance_total ?? '—' }}</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>其他未展示基数</td>
|
||||||
|
<td class="num">{{ department.reconciliation?.other_team_base ?? '—' }}</td>
|
||||||
|
<td>筛选时含其他团队与全公司未分配</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>全公司基数</td>
|
||||||
|
<td class="num">{{ department.reconciliation?.company_base ?? '—' }}</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 class="block-title">四、团队成员明细</h2>
|
||||||
|
<table class="sheet-table" data-testid="print-department-members">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>经理团队</th><th>成员</th><th>岗位</th><th>来源</th><th>绩效金额</th>
|
||||||
|
<th>应付绩效</th><th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="member in departmentMembers"
|
||||||
|
:key="`${member.personnel_id}-${member.member_kind}`"
|
||||||
|
>
|
||||||
|
<td>{{ member.manager_name }}</td>
|
||||||
|
<td>{{ member.personnel_name }}</td>
|
||||||
|
<td>{{ member.role_code }}</td>
|
||||||
|
<td>{{ memberKindLabel(member.member_kind) }}</td>
|
||||||
|
<td class="num">{{ member.performance_sum }}</td>
|
||||||
|
<td class="num">{{ member.payable_sum }}</td>
|
||||||
|
<td class="num">{{ member.adjust_extra }}</td>
|
||||||
|
<td class="num">{{ member.adjust_deduct }}</td>
|
||||||
|
<td class="num">{{ member.adjust_net }}</td>
|
||||||
|
<td class="num">{{ member.net_total }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 class="block-title">五、未分配人员明细</h2>
|
||||||
|
<table class="sheet-table" data-testid="print-unassigned">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>人员</th><th>岗位</th><th>未纳入原因</th><th>绩效金额</th><th>应付绩效</th>
|
||||||
|
<th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="member in department.unassigned"
|
||||||
|
:key="`${member.personnel_id}-${member.member_kind}`"
|
||||||
|
>
|
||||||
|
<td>{{ member.personnel_name }}</td>
|
||||||
|
<td>{{ member.role_code }}</td>
|
||||||
|
<td>{{ member.unassigned_reason ?? '—' }}</td>
|
||||||
|
<td class="num">{{ member.performance_sum }}</td>
|
||||||
|
<td class="num">{{ member.payable_sum }}</td>
|
||||||
|
<td class="num">{{ member.adjust_extra }}</td>
|
||||||
|
<td class="num">{{ member.adjust_deduct }}</td>
|
||||||
|
<td class="num">{{ member.adjust_net }}</td>
|
||||||
|
<td class="num">{{ member.net_total }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="department.unassigned.length === 0">
|
||||||
|
<td colspan="9">(无)</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -374,6 +536,12 @@ onMounted(load)
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
margin: 18px 0 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.group-block {
|
.group-block {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
|
|
||||||
import * as businessRoleApi from '@/api/businessRoles'
|
import * as businessRoleApi from '@/api/businessRoles'
|
||||||
import * as paramsApi from '@/api/params'
|
import * as paramsApi from '@/api/params'
|
||||||
|
import { paramEnumLabel } from '@/api/params'
|
||||||
import type {
|
import type {
|
||||||
ParamBatch,
|
ParamBatch,
|
||||||
ParamBatchDetail,
|
ParamBatchDetail,
|
||||||
@@ -43,9 +44,10 @@ async function loadCatalog(): Promise<void> {
|
|||||||
catalogLoading.value = true
|
catalogLoading.value = true
|
||||||
try {
|
try {
|
||||||
catalog.value = await paramsApi.getCatalog()
|
catalog.value = await paramsApi.getCatalog()
|
||||||
// 预览「所用参数」按编码取中文名;目录加载后填充映射
|
// 目录加载后填充映射:预览「所用参数」按编码取中文名;值翻译按编码查类型
|
||||||
for (const item of catalog.value) {
|
for (const item of catalog.value) {
|
||||||
PARAM_NAME_BY_CODE[item.param_code] = item.param_name
|
PARAM_NAME_BY_CODE[item.param_code] = item.param_name
|
||||||
|
PARAM_VALUE_TYPES[item.param_code] = item.value_type
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error)
|
handledError(error)
|
||||||
@@ -83,20 +85,24 @@ function paramNameByCode(code: string): string {
|
|||||||
return PARAM_NAME_BY_CODE[code] ?? code
|
return PARAM_NAME_BY_CODE[code] ?? code
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 枚举值 → 中文(FULL/BALANCE、INCLUDE/DEDUCT/EXCLUDE、0/1 开关) */
|
/** 参数编码 → 值类型(目录加载后填充;未知参数不翻译,避免误译数值) */
|
||||||
const ENUM_VALUE_LABELS: Record<string, string> = {
|
const PARAM_VALUE_TYPES: Record<string, string> = {}
|
||||||
FULL: '当月全额',
|
|
||||||
BALANCE: '余额',
|
/**
|
||||||
INCLUDE: '含基本工资',
|
* 按参数编码翻译枚举值(旧值/新值等宽表用)。
|
||||||
DEDUCT: '扣除基本工资',
|
*
|
||||||
EXCLUDE: '不计基本工资',
|
* **必须先判参数类型**:枚举映射含 0=关/1=开,若无差别套用,值为 0 或 1
|
||||||
'0': '关',
|
* 的普通数值参数会被误译成「关/开」。非枚举(或目录尚未加载)原样返回。
|
||||||
'1': '开',
|
*/
|
||||||
|
function enumValueLabel(paramCode: string, value: string | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
return PARAM_VALUE_TYPES[paramCode] === 'ENUM' ? paramEnumLabel(value) : value
|
||||||
}
|
}
|
||||||
|
|
||||||
function valueLabel(value: string | null | undefined): string {
|
/** ENUM 下拉选项文案:中文主显 + 括号保留原码(当月全额(FULL)),便于与审计记录对上 */
|
||||||
if (value === null || value === undefined) return ''
|
function enumOptionLabel(option: string): string {
|
||||||
return ENUM_VALUE_LABELS[value] ?? value
|
const label = paramEnumLabel(option)
|
||||||
|
return label === option ? option : `${label}(${option})`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 带单位的值展示 */
|
/** 带单位的值展示 */
|
||||||
@@ -467,7 +473,14 @@ onMounted(() => {
|
|||||||
<template v-if="item.values.length">
|
<template v-if="item.values.length">
|
||||||
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
|
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
|
||||||
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
|
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
|
||||||
<b class="num">{{ formatValue(item, v.value_text) }}</b>
|
<b class="num">
|
||||||
|
{{ item.value_type === 'ENUM' ? paramEnumLabel(v.value_text) : formatValue(item, v.value_text) }}
|
||||||
|
</b>
|
||||||
|
<!-- 灰色小码:与库内/审计记录里的原始码对上;未知码(中文缺映射)不重复显示 -->
|
||||||
|
<span
|
||||||
|
v-if="item.value_type === 'ENUM' && paramEnumLabel(v.value_text) !== v.value_text"
|
||||||
|
class="enum-code"
|
||||||
|
>{{ v.value_text }}</span>
|
||||||
<span class="eff">{{ v.effective_date }} 起</span>
|
<span class="eff">{{ v.effective_date }} 起</span>
|
||||||
<el-tag
|
<el-tag
|
||||||
v-if="pendingOf(item, v.dimension_value)"
|
v-if="pendingOf(item, v.dimension_value)"
|
||||||
@@ -475,7 +488,7 @@ onMounted(() => {
|
|||||||
type="warning"
|
type="warning"
|
||||||
disable-transitions
|
disable-transitions
|
||||||
>
|
>
|
||||||
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
|
待改为 {{ enumValueLabel(item.param_code, pendingOf(item, v.dimension_value)!.new_value) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
@@ -527,10 +540,12 @@ onMounted(() => {
|
|||||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="旧值" min-width="110">
|
<el-table-column label="旧值" min-width="110">
|
||||||
<template #default="{ row }">{{ row.old_value || '—' }}</template>
|
<template #default="{ row }">{{ enumValueLabel(row.item.param_code, row.old_value) || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="新值" min-width="110">
|
<el-table-column label="新值" min-width="110">
|
||||||
<template #default="{ row }"><b class="num">{{ row.new_value }}</b></template>
|
<template #default="{ row }">
|
||||||
|
<b class="num">{{ enumValueLabel(row.item.param_code, row.new_value) }}</b>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="80">
|
<el-table-column label="操作" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -578,7 +593,9 @@ onMounted(() => {
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="当前值">
|
<el-form-item label="当前值">
|
||||||
<span class="num">{{ currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—' }}</span>
|
<span class="num">
|
||||||
|
{{ enumValueLabel(editTarget.item.param_code, currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—') }}
|
||||||
|
</span>
|
||||||
<span v-if="editTarget.item.unit" style="color: var(--ys-ink-3)"> {{ editTarget.item.unit }}</span>
|
<span v-if="editTarget.item.unit" style="color: var(--ys-ink-3)"> {{ editTarget.item.unit }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="新值">
|
<el-form-item label="新值">
|
||||||
@@ -587,7 +604,12 @@ onMounted(() => {
|
|||||||
v-model="editValue"
|
v-model="editValue"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
<el-option v-for="opt in editTarget.item.options" :key="opt" :label="opt" :value="opt" />
|
<el-option
|
||||||
|
v-for="opt in editTarget.item.options"
|
||||||
|
:key="opt"
|
||||||
|
:label="enumOptionLabel(opt)"
|
||||||
|
:value="opt"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
|
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -632,11 +654,15 @@ onMounted(() => {
|
|||||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="旧值" min-width="90">
|
<el-table-column label="旧值" min-width="90">
|
||||||
<template #default="{ row }">{{ row.old_value ?? '—' }}</template>
|
<template #default="{ row }">
|
||||||
|
{{ enumValueLabel(row.param_code, row.old_value) || '—' }}
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="新值" min-width="90">
|
<el-table-column label="新值" min-width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span :class="{ changed: row.changed }">{{ row.new_value ?? '—' }}</span>
|
<span :class="{ changed: row.changed }">
|
||||||
|
{{ enumValueLabel(row.param_code, row.new_value) || '—' }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="变化" width="70" align="center">
|
<el-table-column label="变化" width="70" align="center">
|
||||||
@@ -658,7 +684,10 @@ onMounted(() => {
|
|||||||
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
|
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
|
||||||
<span class="param-name">{{ item.param_name }}</span>
|
<span class="param-name">{{ item.param_name }}</span>
|
||||||
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
|
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
|
||||||
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
|
<span class="num">
|
||||||
|
{{ enumValueLabel(item.param_code, item.old_value_text) || '—' }} →
|
||||||
|
<b>{{ enumValueLabel(item.param_code, item.value_text) }}</b>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
|
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
|
||||||
@@ -707,7 +736,10 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="旧值 → 新值" min-width="130">
|
<el-table-column label="旧值 → 新值" min-width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="num">{{ row.old_value_text ?? '—' }} → {{ row.value_text }}</span>
|
<span class="num">
|
||||||
|
{{ enumValueLabel(historyItem?.param_code ?? '', row.old_value_text) || '—' }} →
|
||||||
|
{{ enumValueLabel(historyItem?.param_code ?? '', row.value_text) }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="version_no" label="版本" width="115" />
|
<el-table-column prop="version_no" label="版本" width="115" />
|
||||||
@@ -799,7 +831,7 @@ onMounted(() => {
|
|||||||
<div class="pr-used">
|
<div class="pr-used">
|
||||||
<div class="drawer-title">所用参数(按回款到账日取值)</div>
|
<div class="drawer-title">所用参数(按回款到账日取值)</div>
|
||||||
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
|
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
|
||||||
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ valueLabel(u.value_text) || u.value_text }}
|
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ enumValueLabel(u.param_code, u.value_text) }}
|
||||||
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
|
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -875,6 +907,13 @@ onMounted(() => {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 枚举值旁的灰色原始码(如「当月全额 FULL」的 FULL),与审计记录对账用 */
|
||||||
|
.enum-code {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
.eff {
|
.eff {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
|
|||||||
@@ -1,42 +1,85 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import type { UploadRawFile } from 'element-plus'
|
import type { UploadRawFile } from 'element-plus'
|
||||||
|
|
||||||
import * as settingsApi from '@/api/settings'
|
import * as settingsApi from '@/api/settings'
|
||||||
|
import type { CompanySettings } from '@/api/settings'
|
||||||
|
import { useSettingsStore } from '@/store/settings'
|
||||||
import { handledError } from '@/utils/error'
|
import { handledError } from '@/utils/error'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。
|
* 公司信息与站点信息设置(2026-10-20 定稿)。
|
||||||
*
|
*
|
||||||
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
|
* 页面按「左侧设置 → 右侧预览」成对排列,四张卡两行:
|
||||||
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
|
* - 第一行:登录页信息(名称/标语/页脚/备案号/logo)→ 登录页预览
|
||||||
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
|
* - 第二行:网页站点信息(站点标题/站点图标)→ 浏览器标签页预览
|
||||||
*
|
*
|
||||||
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。
|
* 两张设置卡**各自独立保存**(后端为部分更新语义):
|
||||||
|
* 改登录页信息不必触碰站点标题,反之亦然;保存后把回包写入 settings store,
|
||||||
|
* 侧边栏/浏览器标题/favicon/登录页立即同步(不重新请求 public 端点 ——
|
||||||
|
* 匿名端点带 300s 浏览器缓存)。
|
||||||
*/
|
*/
|
||||||
const formRef = ref<FormInstance>()
|
const route = useRoute()
|
||||||
const submitting = ref(false)
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
const form = reactive({
|
// ==================== 登录页信息 ====================
|
||||||
|
|
||||||
|
const loginFormRef = ref<FormInstance>()
|
||||||
|
const savingLogin = ref(false)
|
||||||
|
|
||||||
|
const loginForm = reactive({
|
||||||
company_name: '',
|
company_name: '',
|
||||||
|
brand_slogan: '',
|
||||||
|
footer_text: '',
|
||||||
|
icp_no: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const loginRules: FormRules = {
|
||||||
company_name: [
|
company_name: [
|
||||||
{ required: true, message: '请输入公司名称', trigger: 'blur' },
|
{ required: true, message: '请输入公司名称', trigger: 'blur' },
|
||||||
{ max: 64, message: '公司名称最长 64 个字符', trigger: 'blur' },
|
{ max: 64, message: '公司名称最长 64 个字符', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
|
brand_slogan: [{ max: 64, message: '标语最长 64 个字符', trigger: 'blur' }],
|
||||||
|
footer_text: [{ max: 64, message: '页脚文本最长 64 个字符', trigger: 'blur' }],
|
||||||
|
icp_no: [{ max: 32, message: '备案号最长 32 个字符', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 网页站点信息 ====================
|
||||||
|
|
||||||
|
const siteFormRef = ref<FormInstance>()
|
||||||
|
const savingSite = ref(false)
|
||||||
|
|
||||||
|
const siteForm = reactive({
|
||||||
|
site_title: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const siteRules: FormRules = {
|
||||||
|
site_title: [{ max: 64, message: '站点标题最长 64 个字符', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 图片资源 ====================
|
||||||
|
|
||||||
const logoUrl = ref<string | null>(null)
|
const logoUrl = ref<string | null>(null)
|
||||||
const uploading = ref(false)
|
const faviconUrl = ref<string | null>(null)
|
||||||
|
const uploadingLogo = ref(false)
|
||||||
|
const uploadingFavicon = ref(false)
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生效的站点图标:自定义图标 → 公司 logo → 内置 SVG。
|
||||||
|
* 与 settings store 同口径 —— 未单独上传图标时,换 logo 标签页图标跟着变。
|
||||||
|
*/
|
||||||
|
const effectiveFavicon = computed(() => faviconUrl.value ?? logoUrl.value ?? '/favicon.svg')
|
||||||
|
|
||||||
async function loadSettings(): Promise<void> {
|
async function loadSettings(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const data = await settingsApi.getSettings()
|
const data = await settingsApi.getSettings()
|
||||||
form.company_name = data.company_name
|
fillAll(data)
|
||||||
logoUrl.value = data.logo_url
|
// 管理端打开设置页即用最新值校准全局品牌/站点信息
|
||||||
|
settingsStore.apply(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error)
|
handledError(error)
|
||||||
}
|
}
|
||||||
@@ -44,118 +87,309 @@ async function loadSettings(): Promise<void> {
|
|||||||
|
|
||||||
loadSettings()
|
loadSettings()
|
||||||
|
|
||||||
async function handleSubmit(): Promise<void> {
|
function fillAll(data: CompanySettings): void {
|
||||||
const valid = await formRef.value?.validate().catch(() => false)
|
loginForm.company_name = data.company_name
|
||||||
|
loginForm.brand_slogan = data.brand_slogan ?? ''
|
||||||
|
loginForm.footer_text = data.footer_text ?? ''
|
||||||
|
loginForm.icp_no = data.icp_no ?? ''
|
||||||
|
siteForm.site_title = data.site_title ?? ''
|
||||||
|
logoUrl.value = data.logo_url
|
||||||
|
faviconUrl.value = data.favicon_url
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存登录页信息(只提交本卡字段,不触碰站点标题) */
|
||||||
|
async function saveLoginInfo(): Promise<void> {
|
||||||
|
const valid = await loginFormRef.value?.validate().catch(() => false)
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
submitting.value = true
|
savingLogin.value = true
|
||||||
try {
|
try {
|
||||||
const data = await settingsApi.updateSettings(form.company_name.trim())
|
const data = await settingsApi.updateSettings({
|
||||||
form.company_name = data.company_name
|
company_name: loginForm.company_name.trim(),
|
||||||
ElMessage.success('已保存')
|
brand_slogan: loginForm.brand_slogan.trim(),
|
||||||
} catch (error) {
|
footer_text: loginForm.footer_text.trim(),
|
||||||
handledError(error)
|
icp_no: loginForm.icp_no.trim(),
|
||||||
} finally {
|
})
|
||||||
submitting.value = false
|
// 只回填本卡字段:另一张卡未保存的编辑不被覆盖
|
||||||
}
|
loginForm.company_name = data.company_name
|
||||||
}
|
loginForm.brand_slogan = data.brand_slogan ?? ''
|
||||||
|
loginForm.footer_text = data.footer_text ?? ''
|
||||||
function beforeUpload(file: UploadRawFile): boolean {
|
loginForm.icp_no = data.icp_no ?? ''
|
||||||
const okType = ['image/png', 'image/jpeg'].includes(file.type)
|
|
||||||
if (!okType) {
|
|
||||||
ElMessage.warning('logo 仅支持 png / jpg 格式')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
|
||||||
ElMessage.warning('logo 文件不能超过 2MB')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpload(options: { file: UploadRawFile }): Promise<void> {
|
|
||||||
uploading.value = true
|
|
||||||
try {
|
|
||||||
const data = await settingsApi.uploadLogo(options.file)
|
|
||||||
logoUrl.value = data.logo_url
|
logoUrl.value = data.logo_url
|
||||||
ElMessage.success('logo 已更新')
|
faviconUrl.value = data.favicon_url
|
||||||
|
settingsStore.apply(data)
|
||||||
|
ElMessage.success('登录页信息已保存')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error)
|
handledError(error)
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
savingLogin.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 预览品牌名:跟随输入(空时回退占位,与登录页默认一致) */
|
/** 保存网页站点信息(只提交站点标题;图标由上传接口即时保存) */
|
||||||
const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
async function saveSiteInfo(): Promise<void> {
|
||||||
|
const valid = await siteFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
savingSite.value = true
|
||||||
|
try {
|
||||||
|
const data = await settingsApi.updateSettings({
|
||||||
|
site_title: siteForm.site_title.trim(),
|
||||||
|
})
|
||||||
|
siteForm.site_title = data.site_title ?? ''
|
||||||
|
logoUrl.value = data.logo_url
|
||||||
|
faviconUrl.value = data.favicon_url
|
||||||
|
settingsStore.apply(data)
|
||||||
|
ElMessage.success('站点信息已保存')
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error)
|
||||||
|
} finally {
|
||||||
|
savingSite.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 图片上传(logo / 站点图标) ====================
|
||||||
|
|
||||||
|
function handleLogoUpload(options: { file: UploadRawFile }): Promise<void> {
|
||||||
|
const file = options.file
|
||||||
|
if (!['image/png', 'image/jpeg'].includes(file.type)) {
|
||||||
|
ElMessage.warning('logo 仅支持 png / jpg 格式')
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
|
ElMessage.warning('logo 文件不能超过 2MB')
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
uploadingLogo.value = true
|
||||||
|
return settingsApi
|
||||||
|
.uploadLogo(file)
|
||||||
|
.then((data) => {
|
||||||
|
logoUrl.value = data.logo_url
|
||||||
|
faviconUrl.value = data.favicon_url
|
||||||
|
settingsStore.apply(data)
|
||||||
|
ElMessage.success('logo 已更新')
|
||||||
|
})
|
||||||
|
.catch(handledError)
|
||||||
|
.finally(() => {
|
||||||
|
uploadingLogo.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 浏览器对 .ico 的 MIME 常为空,改用扩展名判断(后端仍有 magic bytes 兜底) */
|
||||||
|
function handleFaviconUpload(options: { file: UploadRawFile }): Promise<void> {
|
||||||
|
const file = options.file
|
||||||
|
if (!/\.(png|jpe?g|ico)$/i.test(file.name)) {
|
||||||
|
ElMessage.warning('站点图标仅支持 png / jpg / ico 格式')
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
|
ElMessage.warning('站点图标文件不能超过 2MB')
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
uploadingFavicon.value = true
|
||||||
|
return settingsApi
|
||||||
|
.uploadFavicon(file)
|
||||||
|
.then((data) => {
|
||||||
|
faviconUrl.value = data.favicon_url
|
||||||
|
logoUrl.value = data.logo_url
|
||||||
|
settingsStore.apply(data)
|
||||||
|
ElMessage.success('站点图标已更新')
|
||||||
|
})
|
||||||
|
.catch(handledError)
|
||||||
|
.finally(() => {
|
||||||
|
uploadingFavicon.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 预览 ====================
|
||||||
|
|
||||||
|
/** 当前页面名(本页路由标题为「公司信息」),用于标签页标题预览 */
|
||||||
|
const previewPageTitle = computed(() => (route.meta.title as string | undefined) ?? '公司信息')
|
||||||
|
|
||||||
|
const previewName = computed(() => loginForm.company_name.trim() || '衍水集团')
|
||||||
|
const previewSlogan = computed(
|
||||||
|
() => loginForm.brand_slogan.trim() || '把每一笔回款,算成一份踏实。',
|
||||||
|
)
|
||||||
|
const previewFooter = computed(
|
||||||
|
() => loginForm.footer_text.trim() || `© ${previewName.value} · 内部系统`,
|
||||||
|
)
|
||||||
|
const previewIcp = computed(() => loginForm.icp_no.trim())
|
||||||
|
const previewSiteTitle = computed(
|
||||||
|
() => siteForm.site_title.trim() || '营销人员工资管理系统',
|
||||||
|
)
|
||||||
|
const previewTabTitle = computed(
|
||||||
|
() => `${previewPageTitle.value} - ${previewName.value} ${previewSiteTitle.value}`,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<el-card shadow="never" class="settings-card">
|
<!-- 左一:登录页信息 -->
|
||||||
<template #header>
|
<el-form
|
||||||
<span class="card-title">公司信息</span>
|
ref="loginFormRef"
|
||||||
</template>
|
:model="loginForm"
|
||||||
|
:rules="loginRules"
|
||||||
|
label-width="110px"
|
||||||
|
class="settings-form"
|
||||||
|
@submit.prevent
|
||||||
|
>
|
||||||
|
<el-card shadow="never" class="settings-card">
|
||||||
|
<template #header>
|
||||||
|
<span class="card-title">登录页信息</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<el-form
|
|
||||||
ref="formRef"
|
|
||||||
:model="form"
|
|
||||||
:rules="rules"
|
|
||||||
label-width="88px"
|
|
||||||
@submit.prevent
|
|
||||||
>
|
|
||||||
<el-form-item label="公司名称" prop="company_name">
|
<el-form-item label="公司名称" prop="company_name">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.company_name"
|
v-model="loginForm.company_name"
|
||||||
maxlength="64"
|
maxlength="64"
|
||||||
placeholder="展示在登录页与导出页眉"
|
placeholder="展示在登录页、侧边栏与浏览器标题"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="品牌标语" prop="brand_slogan">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.brand_slogan"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="留空使用默认:把每一笔回款,算成一份踏实。"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="页脚版权" prop="footer_text">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.footer_text"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="留空自动生成:© 公司名 · 内部系统"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="ICP 备案号" prop="icp_no">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.icp_no"
|
||||||
|
maxlength="32"
|
||||||
|
placeholder="选填,展示在登录页页脚"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="logo">
|
<el-form-item label="logo">
|
||||||
<div class="logo-field">
|
<div class="asset-field">
|
||||||
<el-upload
|
<el-upload
|
||||||
class="logo-uploader"
|
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
:http-request="handleUpload"
|
:http-request="handleLogoUpload"
|
||||||
:before-upload="beforeUpload"
|
|
||||||
accept="image/png,image/jpeg"
|
accept="image/png,image/jpeg"
|
||||||
>
|
>
|
||||||
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
|
<div v-loading="uploadingLogo" class="asset-box" title="点击上传 png / jpg(≤2MB)">
|
||||||
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
|
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
|
||||||
<span v-else class="logo-plus">+</span>
|
<span v-else class="asset-plus">+</span>
|
||||||
</div>
|
</div>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
|
<p class="asset-hint">
|
||||||
|
png / jpg,不超过 2MB;<br />展示于登录页品牌位。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item>
|
<div class="card-actions">
|
||||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
<el-button type="primary" :loading="savingLogin" @click="saveLoginInfo">
|
||||||
</el-form-item>
|
保存
|
||||||
</el-form>
|
</el-button>
|
||||||
</el-card>
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) -->
|
<!-- 右一:登录页预览(由左侧「登录页信息」控制) -->
|
||||||
<el-card shadow="never" class="preview-card">
|
<el-card shadow="never" class="settings-card preview-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<span class="card-title">登录页预览</span>
|
<span class="card-title">登录页预览</span>
|
||||||
<span class="preview-note">保存后对所有人生效</span>
|
<span class="preview-note">对应左侧「登录页信息」</span>
|
||||||
</template>
|
</template>
|
||||||
<div class="login-mock">
|
<div class="login-mock">
|
||||||
<div class="mock-brand">
|
<div class="mock-brand">
|
||||||
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
|
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
|
||||||
<div class="mock-name">{{ previewName }}</div>
|
<div class="mock-name">{{ previewName }}</div>
|
||||||
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
|
<p class="mock-slogan">{{ previewSlogan }}</p>
|
||||||
<span class="mock-waterline"></span>
|
<span class="mock-waterline"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mock-field"></div>
|
<div class="mock-field"></div>
|
||||||
<div class="mock-field"></div>
|
<div class="mock-field"></div>
|
||||||
<div class="mock-submit">登 录</div>
|
<div class="mock-submit">登 录</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mock-foot">
|
||||||
|
{{ previewFooter }}<template v-if="previewIcp"> · {{ previewIcp }}</template>
|
||||||
|
</p>
|
||||||
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
|
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 左二:网页站点信息 -->
|
||||||
|
<el-form
|
||||||
|
ref="siteFormRef"
|
||||||
|
:model="siteForm"
|
||||||
|
:rules="siteRules"
|
||||||
|
label-width="110px"
|
||||||
|
class="settings-form"
|
||||||
|
@submit.prevent
|
||||||
|
>
|
||||||
|
<el-card shadow="never" class="settings-card">
|
||||||
|
<template #header>
|
||||||
|
<span class="card-title">网页站点信息</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form-item label="站点标题" prop="site_title">
|
||||||
|
<el-input
|
||||||
|
v-model="siteForm.site_title"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="留空使用默认:营销人员工资管理系统"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="站点图标">
|
||||||
|
<div class="asset-field">
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="handleFaviconUpload"
|
||||||
|
accept="image/png,image/jpeg,image/x-icon,image/vnd.microsoft.icon,.ico"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-loading="uploadingFavicon"
|
||||||
|
class="asset-box asset-box--small"
|
||||||
|
title="点击上传 png / jpg / ico(≤2MB)"
|
||||||
|
>
|
||||||
|
<!-- 未自定义时显示公司 logo(跟随 logo 变化) -->
|
||||||
|
<img :src="effectiveFavicon" alt="站点图标" />
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
<p class="asset-hint">
|
||||||
|
png / jpg / ico,不超过 2MB;<br />
|
||||||
|
未上传时跟随公司 logo,展示于浏览器标签页。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<el-button type="primary" :loading="savingSite" @click="saveSiteInfo">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 右二:浏览器标签页预览(由左侧「网页站点信息」控制) -->
|
||||||
|
<el-card shadow="never" class="settings-card preview-card tab-preview-card">
|
||||||
|
<template #header>
|
||||||
|
<span class="card-title">浏览器标签页预览</span>
|
||||||
|
<span class="preview-note">对应左侧「网页站点信息」</span>
|
||||||
|
</template>
|
||||||
|
<div class="tab-bar">
|
||||||
|
<div class="tab-bar__tab">
|
||||||
|
<img :src="effectiveFavicon" alt="" class="tab-bar__icon" />
|
||||||
|
<span class="tab-bar__title">{{ previewTabTitle }}</span>
|
||||||
|
<span class="tab-bar__close">×</span>
|
||||||
|
</div>
|
||||||
|
<span class="tab-bar__plus">+</span>
|
||||||
|
</div>
|
||||||
|
<p class="preview-caption">浏览器标签页实景(标题与图标)</p>
|
||||||
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -163,9 +397,41 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px);
|
grid-template-columns: minmax(420px, 560px) minmax(300px, 380px);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
/* 同一行左右卡片等高:右侧预览卡拉伸到与左侧设置卡相同的底边 */
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form :deep(.el-card) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预览卡铺满行高,内容区可纵向伸展 */
|
||||||
|
.preview-card,
|
||||||
|
.tab-preview-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card :deep(.el-card__body),
|
||||||
|
.tab-preview-card :deep(.el-card__body) {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 登录页实景卡随可用高度放大:两个模拟输入框吃掉多余高度,
|
||||||
|
整张登录卡纵向撑满预览卡,底边与左侧「登录页信息」对齐 */
|
||||||
|
.login-mock {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
@@ -178,13 +444,19 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
|||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-field {
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-box {
|
.asset-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -198,24 +470,29 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
|||||||
transition: border-color 150ms ease;
|
transition: border-color 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-box:hover {
|
.asset-box--small {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-box:hover {
|
||||||
border-color: var(--ys-indigo);
|
border-color: var(--ys-indigo);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-box img {
|
.asset-box img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-plus {
|
.asset-plus {
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-hint {
|
.asset-hint {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
@@ -268,7 +545,8 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mock-field {
|
.mock-field {
|
||||||
height: 32px;
|
flex: 1;
|
||||||
|
min-height: 32px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
background: #f4f5f8;
|
background: #f4f5f8;
|
||||||
border: 1px solid rgba(28, 35, 51, 0.07);
|
border: 1px solid rgba(28, 35, 51, 0.07);
|
||||||
@@ -287,13 +565,74 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-caption {
|
.mock-foot {
|
||||||
margin: 10px 0 0;
|
margin: 12px 0 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-caption {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 浏览器标签页预览(示意;与登录页预览分成两张卡,对应左侧第二张卡) ── */
|
||||||
|
.tab-preview-card :deep(.el-card__body) {
|
||||||
|
/* 行高由左侧「网页站点信息」决定:标签页示意在卡内垂直居中 */
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
background: #e9ebf1;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar__tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
box-shadow: 0 -1px 2px rgba(28, 35, 51, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar__icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar__title {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar__close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar__plus {
|
||||||
|
padding: 0 6px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in new issue
Block a user