Compare commits
No commits in common. "c91d1db78ac0d58f4c839dcf5b3d4b443c8e3229" and "4e54d09ccb4aa2fcbc5ae1fe7d38be73a0075574" have entirely different histories.
c91d1db78a
...
4e54d09ccb
@ -129,16 +129,6 @@ app/
|
||||
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
|
||||
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
|
||||
|
||||
### 工作台接口(M4 ⑧,限管理员/财务/领导)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/dashboard?month=YYYY-MM` | 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志) |
|
||||
|
||||
待办与预警**实时派生**(不落表、不引调度器);预警阈值走参数体系
|
||||
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
|
||||
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
@ -221,26 +211,15 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
||||
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
|
||||
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
|
||||
|
||||
### 工作台口径(M4 ⑧,改动前先读)
|
||||
|
||||
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
|
||||
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定;回款未指定批次 = `receipts.shipment_id IS NULL`。
|
||||
- **同步降级**:⑥ 未建时 `sync_available=false`,前端显示「模块未启用」——不要返回 0 条冒充「无失败」。
|
||||
- **参数快照一次解析**:`warning.*` 三个阈值在每次请求只 `resolve_snapshot` 一次,多条规则复用。
|
||||
- **导出 SQL 的编码坑**:Windows 下 `alembic upgrade --sql > V*.sql` 默认按控制台 GBK 输出,`test_schema_sync` 按 UTF-8 读取会报 `UnicodeDecodeError`——必须 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1` 前缀。
|
||||
|
||||
### 报表口径(M4 ⑤,改动前先读)
|
||||
|
||||
- **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。
|
||||
- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入,**不要求该人当月有台账行**(只有调整单也要出零绩效成员行);`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。调整单归属跟随该人普通行的实时 `manager_id`;只有经理个人户的经理跟随本人团队。
|
||||
- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入;`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。
|
||||
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
|
||||
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块),**12.5% 与 5% 分列**——两项之和才是聚合行金额。
|
||||
- **人员归属按行类型**:普通台账行跟随 `manager_id`;经理个人户行在 `manager.personal.included=1` 时归经理本人团队,且经理成员行**只取个人户金额**(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 `manager_id` 筛选时 `unassigned` 为空(语义是「仅看该经理团队」)。
|
||||
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
|
||||
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);全公司分成只出现一次,调整净额只在明细层求和,应发合计 = 团队成员应付 + 未分配应付 + 团队分成合计 + 全公司分成 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
|
||||
- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。
|
||||
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
|
||||
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
|
||||
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。
|
||||
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;集成测试逐项断言。
|
||||
|
||||
## 常见问题
|
||||
|
||||
|
||||
@ -1,143 +0,0 @@
|
||||
"""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)")
|
||||
@ -8,7 +8,6 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api import health
|
||||
from app.api.v1 import auth
|
||||
from app.api.v1.base import business_roles as base_business_roles
|
||||
from app.api.v1.base import customers as base_customers
|
||||
from app.api.v1.base import personnel as base_personnel
|
||||
from app.api.v1.base import products as base_products
|
||||
@ -17,7 +16,6 @@ from app.api.v1.business import ratings as business_ratings
|
||||
from app.api.v1.business import receipts as business_receipts
|
||||
from app.api.v1.business import shipments as business_shipments
|
||||
from app.api.v1.calc import router as calc_router
|
||||
from app.api.v1.dashboard import router as dashboard_router
|
||||
from app.api.v1.reports import router as reports_router
|
||||
from app.api.v1.settlements import router as settlements_router
|
||||
from app.api.v1.system import approvals as system_approvals
|
||||
@ -54,7 +52,6 @@ base_router = APIRouter(prefix="/base")
|
||||
base_router.include_router(base_personnel.router)
|
||||
base_router.include_router(base_products.router)
|
||||
base_router.include_router(base_customers.router)
|
||||
base_router.include_router(base_business_roles.router)
|
||||
v1_router.include_router(base_router)
|
||||
|
||||
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
|
||||
@ -71,7 +68,5 @@ v1_router.include_router(calc_router)
|
||||
v1_router.include_router(settlements_router)
|
||||
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
|
||||
v1_router.include_router(reports_router)
|
||||
# 工作台(M4 ⑧):/api/v1/dashboard,四区块只读聚合,限管理员/财务/领导
|
||||
v1_router.include_router(dashboard_router)
|
||||
|
||||
api_router.include_router(v1_router)
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
"""岗位下拉选项(基础数据与报表筛选共用)。
|
||||
|
||||
岗位管理本身仅管理员可用(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
|
||||
]
|
||||
)
|
||||
@ -1,39 +0,0 @@
|
||||
"""工作台接口(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,
|
||||
_: _DashboardUser,
|
||||
month: Annotated[
|
||||
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
|
||||
] = None,
|
||||
) -> ApiResponse[DashboardOut]:
|
||||
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。"""
|
||||
return ok(dashboard_service.get_dashboard(session, month))
|
||||
@ -1,26 +0,0 @@
|
||||
"""月份参数校验(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])
|
||||
@ -1,209 +0,0 @@
|
||||
"""工作台仓储:四区块的只读聚合查询;不 commit、不抛业务异常。
|
||||
|
||||
总览只做 SQL 聚合(SUM/COUNT DISTINCT),不复用报表的
|
||||
``department_summary``——后者会加载整月台账并逐团队查人员,适合报表页,
|
||||
不适合每次打开工作台。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import exists, func, 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, months: list[str]) -> dict[str, object]:
|
||||
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。"""
|
||||
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
|
||||
rows = session.execute(
|
||||
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
|
||||
.where(month_expr.in_(months))
|
||||
.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, *, limit: int) -> list[ApprovalOrder]:
|
||||
"""待处理审批单(按提交时间升序——最久的排前面)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.where(ApprovalOrder.status.in_(_PENDING_STATUSES))
|
||||
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
|
||||
.limit(limit)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_pending_approvals(session: Session) -> int:
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(ApprovalOrder)
|
||||
.where(ApprovalOrder.status.in_(_PENDING_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:
|
||||
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。"""
|
||||
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)
|
||||
.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)
|
||||
.where(Shipment.ship_date <= cutoff, ~has_receipt)
|
||||
).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()
|
||||
)
|
||||
@ -1,125 +0,0 @@
|
||||
"""工作台的请求/响应模型(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)
|
||||
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)
|
||||
@ -1,245 +0,0 @@
|
||||
"""工作台服务(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
|
||||
from app.repositories.query_utils import LogFilters
|
||||
|
||||
# 工作台只展示摘要,完整列表跳转对应管理页
|
||||
_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) -> dict:
|
||||
"""四区块一次性出数(前端一次请求渲染整页)。"""
|
||||
resolved = _normalize_month(month)
|
||||
warning_params = _warning_params(session)
|
||||
return {
|
||||
"month": resolved,
|
||||
"overview": _overview(session, resolved),
|
||||
"todos": _todos(session),
|
||||
"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 _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, months)
|
||||
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": "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月(含未核算回款);不含调整单",
|
||||
}
|
||||
|
||||
|
||||
def _todos(session: Session) -> dict:
|
||||
"""待办事项(FR-034,澄清 #11 起步范围)。"""
|
||||
orders = dashboard_repository.list_pending_approvals(session, limit=_TODO_LIMIT)
|
||||
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
|
||||
return {
|
||||
"pending_approval_total": dashboard_repository.count_pending_approvals(session),
|
||||
"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": [],
|
||||
"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
|
||||
]
|
||||
return {
|
||||
"enabled": True,
|
||||
"receipt_overdue_days": params["receipt_overdue_days"],
|
||||
"approval_timeout_days": params["approval_timeout_days"],
|
||||
"items": items,
|
||||
"receipt_overdue_total": dashboard_repository.count_overdue_shipments(
|
||||
session, cutoff=receipt_cutoff
|
||||
),
|
||||
"approval_timeout_total": dashboard_repository.count_overdue_approvals(
|
||||
session, cutoff=approval_cutoff
|
||||
),
|
||||
"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))
|
||||
@ -314,54 +314,6 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
),
|
||||
sort_order=20,
|
||||
),
|
||||
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
|
||||
# 首批三条中「同步连续失败」依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目
|
||||
ParamSpec(
|
||||
code="warning.enabled",
|
||||
name="风险预警总开关",
|
||||
group="预警规则",
|
||||
value_type="BOOL",
|
||||
unit=None,
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
|
||||
sort_order=21,
|
||||
),
|
||||
ParamSpec(
|
||||
code="warning.receipt.overdue.days",
|
||||
name="回款逾期天数阈值",
|
||||
group="预警规则",
|
||||
value_type="INT",
|
||||
unit="天",
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="90",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description=(
|
||||
"发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"
|
||||
),
|
||||
sort_order=22,
|
||||
),
|
||||
ParamSpec(
|
||||
code="warning.approval.timeout.days",
|
||||
name="审批超时天数阈值",
|
||||
group="预警规则",
|
||||
value_type="INT",
|
||||
unit="天",
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="3",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description=(
|
||||
"待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"
|
||||
),
|
||||
sort_order=23,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -140,24 +140,12 @@ uv run pytest tests/integration -q # 仅集成
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、分成按参数、无成员经理出 0 值行;**无台账经理挂上级不 500、仅有调整单人员并入团队/未分配、经理个人户开关两侧口径、按经理筛选不误判其他团队下属、对账恒等式**;导出页眉 ID→名称/中文标签、回款维度 sheet 透传筛选、**导出超限与取数异常的失败审计**、非法月份查询/导出均 400;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**;业务统计排除经理聚合行;普通用户 403 | 24 |
|
||||
| `tests/unit/test_report_helpers.py` | 区间分桶边界与 8 档编码唯一;标签覆盖全部枚举;Excel 模板页眉/签字栏/空数据/金额格式/合计/公式注入/维度 sheet/状态中文;**部门汇总签字稿五块顺序、零成员团队与未分配行、全公司分成只出现一次、应发合计取对账口径、比例标题参数化** | 16 |
|
||||
| `tests/unit/test_month_validation.py` | 非法月份(`2026-13`/`2026-00`/`0000-01` 等)在报表、月结、调整单 schema 入口即拒绝;可选月份空串规范为 None | 17 |
|
||||
| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、12.5% 分成按参数、无成员经理出 0 值行;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**(防翻倍)、绩效合计=台账;业务统计排除经理聚合行(生成月结后绩效不被整团队聚合额污染);导出 xlsx 可解析且含「财务复核/领导签字」栏 + 操作日志留痕(module=报表统计/action=导出);普通用户 403 且文案「无权访问报表统计」 | 7 |
|
||||
| `tests/unit/test_report_helpers.py` | 区间分桶边界(NULL/负值/0/1/30/…/361+)与 8 档编码唯一;客户类型、产品类别标签覆盖全部常量枚举(防新增枚举后报表少一类);Excel 模板纯构建含页眉/签字栏、空数据提示行、金额为数值单元格且两位格式、合计行=明细之和、文本公式注入前置单引号、回款统计两个维度对比 sheet、业务统计状态转中文 | 11 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_reports.py -q` → **24 passed**(2026-10-19 修复回归);`uv run pytest tests/unit/test_report_helpers.py tests/unit/test_month_validation.py -q` → **33 passed**;全量 **847 passed**(2026-10-19)。
|
||||
运行:`uv run pytest tests/integration/test_reports.py -q` → **7 passed**(2026-10-19);`uv run pytest tests/unit/test_report_helpers.py -q` → **11 passed**(2026-10-19);全量回归见运行记录。
|
||||
|
||||
**e2e**:`frontend/e2e/report_flow.py` 已补回款多桶同时展开、部门下钻筛选生效、打印截断提示、部门汇总打印五块结构;需 `E2E_ADMIN_PASSWORD` 与前后端在跑。
|
||||
|
||||
### 工作台(M4 ⑧,2026-10-19 完成)
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志 | 5 |
|
||||
| `tests/unit/test_param_registry.py`(改) | 注册表 19→**22 项**(V10 预警规则三项);编码集合与迁移种子一致 | 改 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_dashboard.py -q` → **5 passed**(2026-10-19)。
|
||||
|
||||
**实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。
|
||||
**待补**:e2e `frontend/e2e/report_flow.py`(四页筛选/导出下载/打印视图,需 `E2E_ADMIN_PASSWORD`)。
|
||||
|
||||
## 三、运行记录
|
||||
|
||||
@ -179,8 +167,6 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) |
|
||||
| 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 |
|
||||
| 2026-10-19 | 拆行收口批次 | 全量 788 | ✅ 788 passed(9:20) | **2026-10-19 业务答复落实完成**:#14 首日取整 10‰(公式段/锚点不变,用例反转);#12 单笔侧开关消费(1=业务员递减计提);**二次分摊拆行**(参与人份额+负责人余额守恒,uk 三列版);权限分层(params 查看放开/写仅 admin);V8 迁移(审批开关默认开/撤试用期参数/增基数开关)。定点:calc 20 / settlements 13(含 #12 开关两档金标准 6.84→24.34)/ businesses 30(保存侧+3)/ formula 35。e2e calc_settlement_flow 交接并行会话 |
|
||||
| 2026-10-19 | **报表统计修复回归** | 全量 847 | ✅ 847 passed(10:13) | **M4 报表 code-review 15 项修复后全量回归**(后端 8 + 前端 7):归属三集合/调整单全量并入/经理个人户拆分/对账出参、分块签字稿导出、导出审计覆盖取数、筛选页眉 ID→名称、月份校验 400、岗位只读选项。报表集成 24 / 报表单元 16 / 月份校验 17。type-check + build 绿;e2e `report_flow.py` 扩至 12 步,待 `E2E_ADMIN_PASSWORD` 实跑 |
|
||||
| 2026-10-19 | 全部 | 852 | ✅ 852 passed(9:17) | **M4 ⑧ 工作台完成**(四区块实时聚合 + ECharts 5.6 前端 + V10 预警参数三项;新增 5 例;参数注册表 19→22;同步模块显式降级)。期间修复:alembic `--sql` 导出 GBK 导致 schema 同步误报 |
|
||||
| 2026-10-19 | 单元全量 + 报表定点 | 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` 运行 |
|
||||
|
||||
@ -188,13 +174,7 @@ uv run pytest tests/integration -q # 仅集成
|
||||
|
||||
| 日期 | 缺陷 | 影响 | 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 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 | 分摊负责人份额按余额百分数乘全额导致行合计丢 q2 尾差 | 拆 30%+40% 后负责人 100−70=30% 再乘未 q2 全额(39.1111×0.30=11.7333),行合计 39.10 ≠ 39.11——**每笔分摊账丢 1 分**,守恒破坏(金标准用例 `行合计==业务级全额` 抓住) | 负责人行恒走余额公式(q2 全额 −∑参与人 q2 份额);余额百分数仅作快照展示不参与计算 |
|
||||
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
|
||||
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
|
||||
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
|
||||
@ -249,6 +229,6 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
||||
| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例。**待补**:e2e `calc_settlement_flow`(台账/月结两页真实浏览器链路);业务答复回填批次(88 会话:首日 10‰/聚合基数开关/二次分摊拆行)落地后按同粒度补测 |
|
||||
| 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;e2e 用例已写好,需完整环境用 `E2E_ADMIN_PASSWORD` 实跑 |
|
||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||
|
||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||
|
||||
@ -190,14 +190,14 @@ def _purge_test_data() -> None:
|
||||
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
||||
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
||||
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28
|
||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 25
|
||||
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
|
||||
# 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数
|
||||
# id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值
|
||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars())
|
||||
# 插入两个审批开关的 10-19 生效行 id 23/24,均属种子),删新条目与
|
||||
# 新批次后解析回退到种子值
|
||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 25)).scalars())
|
||||
if stray_values:
|
||||
stray_batches = {v.batch_id for v in stray_values}
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 28))
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 25))
|
||||
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
|
||||
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
|
||||
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
|
||||
|
||||
@ -1,232 +0,0 @@
|
||||
"""工作台集成测试(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 select, 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])
|
||||
@ -86,10 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
|
||||
class TestCatalog:
|
||||
"""参数目录:种子完整性与时点化回溯。"""
|
||||
|
||||
def test_目录共22项且覆盖九个分组(self, client: TestClient, admin: dict) -> None:
|
||||
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
|
||||
def test_目录共19项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
|
||||
"""V7 起含调整单审批开关(16 计提项 + 3 BOOL 开关)。"""
|
||||
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||
assert len(items) == 22
|
||||
assert len(items) == 19
|
||||
groups = {item["param_group"] for item in items}
|
||||
assert groups == {
|
||||
"业务员个人户",
|
||||
@ -100,7 +100,6 @@ class TestCatalog:
|
||||
"产品阈值",
|
||||
"月工资口径",
|
||||
"审批开关",
|
||||
"预警规则",
|
||||
}
|
||||
|
||||
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
||||
@ -445,7 +444,7 @@ class TestPermission:
|
||||
headers = auth_headers(user)
|
||||
resp = client.get(_PARAMS, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) == 22
|
||||
assert len(resp.json()["data"]) == 19
|
||||
|
||||
def test_未登录访问目录_403(self, client: TestClient) -> None:
|
||||
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
"""月份校验单元测试。
|
||||
|
||||
守的口径:报表、月结、调整单入口都必须拦住 `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,9 +18,8 @@ from app.services.param_registry import (
|
||||
validate_and_normalize,
|
||||
)
|
||||
|
||||
# 与 db/migration 迁移种子保持一致的 22 项编码(V3 十六项 + V6 审批开关两项
|
||||
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
|
||||
# V10 增工作台预警规则三项 #10)
|
||||
# 与 db/migration 迁移种子保持一致的 19 项编码(V3 十六项 + V6 审批开关两项
|
||||
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12)
|
||||
EXPECTED_CODES = {
|
||||
"salesperson.rate.start",
|
||||
"salesperson.rate.slope",
|
||||
@ -41,17 +40,14 @@ EXPECTED_CODES = {
|
||||
"approval.monthly_settlement.enabled",
|
||||
"approval.adjustment.enabled",
|
||||
"manager.personal.included",
|
||||
"warning.enabled",
|
||||
"warning.receipt.overdue.days",
|
||||
"warning.approval.timeout.days",
|
||||
}
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||
|
||||
def test_注册表共22项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 22
|
||||
def test_注册表共19项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 19
|
||||
|
||||
def test_编码集合与迁移种子一致(self) -> None:
|
||||
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
||||
|
||||
@ -455,11 +455,7 @@ INSERT INTO param_definitions (id, param_code, param_name, param_group, value_ty
|
||||
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17),
|
||||
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18),
|
||||
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19),
|
||||
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20),
|
||||
-- 预警规则参数(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);
|
||||
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
||||
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
||||
@ -485,11 +481,7 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
|
||||
(20, 17, '', '2026-01-01', '1', 1),
|
||||
(21, 18, '', '2026-01-01', '1', 1),
|
||||
(22, 19, '', '2026-01-01', '0', 1),
|
||||
(25, 20, '', '2026-01-01', '0', 1),
|
||||
-- 预警规则参数生效值(M4 ⑧)
|
||||
(26, 21, '', '2026-10-19', '1', 1),
|
||||
(27, 22, '', '2026-10-19', '90', 1),
|
||||
(28, 23, '', '2026-10-19', '3', 1);
|
||||
(25, 20, '', '2026-01-01', '0', 1);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
||||
@ -882,4 +874,4 @@ CREATE TABLE alembic_version (
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('90653814a421');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('ba0217f5091b');
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
-- 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';
|
||||
|
||||
@ -188,15 +188,15 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||
| 页面 | 说明 |
|
||||
| --- | --- |
|
||||
| `/reports/personal-detail` 个人绩效明细(新组「报表统计」order19) | 人员/月份/客户类型/产品类别/岗位筛选;需规 2.4.1 全部列;行展开懒加载台账快照;页脚**全筛选合计**(注明非当前页);导出/打印 |
|
||||
| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司分成独立卡片;团队表(基数/团队分成/全公司分成/占比/调整/净合计/月结冻结额),比例列头随在线参数变化;展开成员 → 跨页下钻个人明细(人员与月份带入筛选);未分配桶(按经理筛选时为空) |
|
||||
| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司 5% 独立卡片;团队表(基数/12.5%/5%/占比/调整/净合计/月结冻结额);展开成员 → 跨页下钻个人明细;未分配桶 |
|
||||
| `/reports/business-stats` 业务统计(order21) | 关键字/客户/归属人/状态/业务日期;发货额/回款额/绩效(累计口径提示);展开下钻回款与台账明细 |
|
||||
| `/reports/payment-stats` 回款统计(order22) | 月份/回款日期/客户类型/产品类别;8 档区间表(空桶 0,档位由后端下发);展开桶下钻分页明细;客户类型/产品类别对比表 |
|
||||
| `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(以接口 total 判定超过 2000 行并提示截断);部门汇总按「团队分成 → 全公司 5% → 对账 → 成员明细 → 未分配」五块渲染;`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 |
|
||||
| `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(上限 2000 行截断提示);`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 |
|
||||
|
||||
- 路由组「报表统计」四页 `allowedRoles: [admin, finance, leader]`(前端菜单 + 后端 `require_roles` 双层;admin 须显式列入)。
|
||||
- api 封装 `src/api/reports.ts`(类型 + 查询 + `exportReport` 裸 axios blob;产品类别标签额外覆盖 `mixed`)。
|
||||
- 权限:营销后勤「授权范围内」待业务权限清单 #6,当前不开放报表。
|
||||
- type-check 与 build 通过(2026-10-19)。e2e `report_flow.py` 已补 12 步:登录 → API 造数 → 四表筛选/导出下载 → 部门下钻筛选生效 → 回款多桶同时展开互不串行 → 打印视图 → 打印截断提示 → 部门汇总打印五块结构;需 `E2E_ADMIN_PASSWORD` 且前后端在跑。
|
||||
- type-check 与 build 通过(2026-10-19)。e2e `report_flow.py` 待补(需 `E2E_ADMIN_PASSWORD` 环境)。
|
||||
|
||||
## 端到端测试(2026-10-19 追加 · M3 台账与月结)
|
||||
|
||||
@ -210,19 +210,3 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||
② 三页均无 `onMounted` 初始加载 → 进页不取数;补 `onMounted(() => void load())`。
|
||||
③ 三页 `AppPagination` 用 `v-model:page-num` + `@change`(组件无该事件、也未实现 `update:*`)→ 翻页失效;统一为 `:page-num/:page-size` + `@page-change/@size-change`(usePagedList 的 onPageChange/onSizeChange)。
|
||||
**注意**:e2e 与 `pytest tests/integration` **不可并发**——conftest 每用例整体清空开发库,会清掉 e2e 造的数据造成假失败(本次排查耗时即源于此,两次假失败的报错均为「台账行消失 / 401 登录失效」)。
|
||||
|
||||
## 前端页面(2026-10-19 追加 · M4 ⑧ 工作台)
|
||||
|
||||
`/dashboard` 由 Bento 占位升级为工作台四区块(**一次聚合请求** `/api/v1/dashboard`):
|
||||
|
||||
| 区块 | 展示 |
|
||||
| --- | --- |
|
||||
| 经营绩效总览 | KPI 四卡(本月绩效/应付/核算人数/团队数 + 月结状态);近 6 个月绩效折线 + 回款柱状(ECharts) |
|
||||
| 待办事项 | 待审批(跳审批管理)、回款未指定批次(跳回款管理)、T+ 同步失败(未启用显式占位) |
|
||||
| 风险预警 | 回款逾期(danger)/ 审批超时(warning),点击跳处理页;总开关关闭时显示参数提示 |
|
||||
| 最近动态 | 操作日志时间线,按模块本地筛选 |
|
||||
|
||||
- **ECharts 5.6 按需引入**:`echarts/core` + `BarChart/LineChart/Grid/Tooltip` + `SVGRenderer`(与设计预览一致),只注册用到的模块。
|
||||
- **字体红线**:图表 `textStyle.fontFamily` 必须显式设为思源黑体栈——ECharts 在 Windows 默认微软雅黑(字体商用合规要求)。
|
||||
- 权限:路由 `allowedRoles: [admin, finance, leader]`,与后端 `require_roles` 双层;营销后勤待权限清单 #6。
|
||||
- `package.json` 新增依赖 `echarts@5.6.0`(MIT);type-check 与 build 通过(2026-10-19)。
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^8.1.5",
|
||||
"axios": "^1.20.0",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.14.7",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.43",
|
||||
|
||||
23
frontend/pnpm-lock.yaml
generated
23
frontend/pnpm-lock.yaml
generated
@ -14,9 +14,6 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.20.0
|
||||
version: 1.20.0(debug@4.4.3)
|
||||
echarts:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0
|
||||
element-plus:
|
||||
specifier: ^2.14.7
|
||||
version: 2.14.7(vue@3.5.43(typescript@5.9.3))
|
||||
@ -402,9 +399,6 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts@5.6.0:
|
||||
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
|
||||
|
||||
element-plus@2.14.7:
|
||||
resolution: {integrity: sha512-lrG7pZyCS+mpCNVpzxxyu5r35+T6V0hzkAeIwn7HA6to772XSTHKuYCZG51OfzlqaxGbNegLxs650zAF1j43gA==}
|
||||
peerDependencies:
|
||||
@ -699,9 +693,6 @@ packages:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@ -837,9 +828,6 @@ packages:
|
||||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
zrender@5.6.1:
|
||||
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
@ -1167,11 +1155,6 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts@5.6.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 5.6.1
|
||||
|
||||
element-plus@2.14.7(vue@3.5.43(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@ctrl/tinycolor': 4.2.1
|
||||
@ -1452,8 +1435,6 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
picomatch: 4.0.7
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
ufo@1.6.4: {}
|
||||
@ -1540,7 +1521,3 @@ snapshots:
|
||||
typescript: 5.9.3
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
zrender@5.6.1:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
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[]
|
||||
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 })
|
||||
}
|
||||
@ -38,16 +38,10 @@ const routes: RouteRecordRaw[] = [
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
// 工作台(M4 ⑧):与报表/审批接口同权限(admin/finance/leader),
|
||||
// 前后端双层;营销后勤可见性待权限清单 #6 答复
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: {
|
||||
title: '首页',
|
||||
order: 1,
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
},
|
||||
meta: { title: '首页', order: 1 },
|
||||
},
|
||||
{
|
||||
path: '/system/users',
|
||||
|
||||
@ -1,372 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { SVGRenderer } from 'echarts/renderers'
|
||||
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||||
|
||||
import * as dashboardApi from '@/api/dashboard'
|
||||
import type { DashboardData, TrendPoint } from '@/api/dashboard'
|
||||
import * as businessRoleApi from '@/api/businessRoles'
|
||||
import * as paramsApi from '@/api/params'
|
||||
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 CHART_FONT =
|
||||
"'Noto Sans SC','Source Han Sans SC','HarmonyOS Sans SC','PingFang SC',sans-serif"
|
||||
|
||||
const SETTLEMENT_STATUS_LABELS: Record<number, string> = {
|
||||
1: '草稿',
|
||||
2: '待审批',
|
||||
3: '已锁定',
|
||||
4: '已驳回',
|
||||
}
|
||||
const ORDER_TYPE_LABELS: Record<number, string> = {
|
||||
1: '二次分配方案',
|
||||
2: '月度结算',
|
||||
3: '调整单',
|
||||
}
|
||||
|
||||
const month = ref(currentMonth())
|
||||
const data = ref<DashboardData | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
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> {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await dashboardApi.getDashboard({ month: month.value })
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
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()
|
||||
}
|
||||
/**
|
||||
* 首页仪表盘(Bento Grid,2026-10-12 视觉方向)。
|
||||
*
|
||||
* M1 阶段:计提参数与岗位配置已就绪(真实数据);绩效/回款/待办等业务卡
|
||||
* 展示空态 —— 业务数据管理上线后接入真实统计(图表用 ECharts 渲染)。
|
||||
* 空态文案遵循全局规范:空态是邀请,说明「这里将出现什么、怎么让它出现」。
|
||||
*/
|
||||
const roleCount = ref<number | null>(null)
|
||||
const paramCount = ref<number | null>(null)
|
||||
const latestVersion = ref<string | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
window.addEventListener('resize', handleResize)
|
||||
// 三项统计各自容错:一张卡失败不影响其余卡片
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 1 })
|
||||
roleCount.value = page.total
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
}
|
||||
})()
|
||||
void (async () => {
|
||||
try {
|
||||
const catalog = await paramsApi.getCatalog()
|
||||
paramCount.value = catalog.length
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
}
|
||||
})()
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await paramsApi.listBatches({ pageNum: 1, pageSize: 1 })
|
||||
latestVersion.value = page.list[0]?.version_no ?? null
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
perfChart?.dispose()
|
||||
receiptChart?.dispose()
|
||||
})
|
||||
|
||||
const activityModule = ref('')
|
||||
const activityModules = computed(() => {
|
||||
const modules = new Set((data.value?.activities ?? []).map((item) => item.module))
|
||||
return [...modules]
|
||||
})
|
||||
const filteredActivities = computed(() =>
|
||||
(data.value?.activities ?? []).filter(
|
||||
(item) => !activityModule.value || item.module === activityModule.value,
|
||||
),
|
||||
)
|
||||
|
||||
function go(path: string | null): void {
|
||||
if (path) void router.push(path)
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
return value.replace('T', ' ').slice(0, 16)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div>
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">工作台</h1>
|
||||
<p class="page-desc">经营绩效总览 · 待办与风险集中处置 · 最近动态</p>
|
||||
<h1 class="page-title">仪表盘</h1>
|
||||
<p class="page-desc">系统概览 · 计提参数与岗位配置就绪,业务数据管理上线后接入核算统计</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-date-picker
|
||||
v-model="month"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
:clearable="false"
|
||||
style="width: 140px"
|
||||
@change="load"
|
||||
/>
|
||||
<el-button type="primary" :loading="loading" @click="load">刷新</el-button>
|
||||
<span class="toolbar-note">{{ data?.overview.note ?? '' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="bento">
|
||||
<!-- 经营绩效总览:KPI -->
|
||||
<div class="bento-card">
|
||||
<!-- 绩效总额:业务上线前的空态 -->
|
||||
<div class="bento-card b-2x2">
|
||||
<div class="b-eyebrow">本月绩效总额</div>
|
||||
<div class="b-num">{{ data?.overview.performance_total ?? '—' }}</div>
|
||||
</div>
|
||||
<div class="bento-card">
|
||||
<div class="b-eyebrow">本月应付绩效</div>
|
||||
<div class="b-num">{{ data?.overview.payable_total ?? '—' }}</div>
|
||||
</div>
|
||||
<div class="bento-card">
|
||||
<div class="b-eyebrow">核算人数</div>
|
||||
<div class="b-num">{{ data?.overview.person_count ?? '—' }}<small>人</small></div>
|
||||
</div>
|
||||
<div class="bento-card">
|
||||
<div class="b-eyebrow">营销团队</div>
|
||||
<div class="b-num">{{ data?.overview.team_count ?? '—' }}<small>个</small></div>
|
||||
<div class="b-sub">
|
||||
月结:
|
||||
{{
|
||||
data?.overview.settlement.exists
|
||||
? SETTLEMENT_STATUS_LABELS[data.overview.settlement.status ?? 0] ?? '—'
|
||||
: '未生成'
|
||||
}}
|
||||
<div class="b-empty">
|
||||
<div class="b-empty-title">暂无核算数据</div>
|
||||
<div class="b-empty-desc">回款录入并核算后,这里将显示本月绩效总额与趋势</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="b-eyebrow">近 6 个月回款</div>
|
||||
<div ref="receiptChartRef" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<!-- 待办事项 -->
|
||||
<div class="bento-card b-2x2">
|
||||
<div class="b-eyebrow">
|
||||
待办事项
|
||||
<span class="b-count">{{ data?.todos.pending_approval_total ?? 0 }}</span>
|
||||
</div>
|
||||
<div v-if="data?.todos.pending_approvals.length" class="todo-list">
|
||||
<button
|
||||
v-for="item in data.todos.pending_approvals"
|
||||
:key="item.order_id"
|
||||
class="todo-row"
|
||||
type="button"
|
||||
@click="go('/system/approvals')"
|
||||
>
|
||||
<span class="todo-title">
|
||||
{{ ORDER_TYPE_LABELS[item.order_type] ?? '审批' }} · {{ item.order_no }}
|
||||
</span>
|
||||
<span class="todo-meta">{{ item.submitter_name ?? '—' }} · {{ formatTime(item.create_time) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="b-empty-mini">无待审批事项</div>
|
||||
|
||||
<div class="b-eyebrow b-eyebrow-gap">
|
||||
回款未指定批次
|
||||
<span class="b-count">{{ data?.todos.unassigned_receipt_total ?? 0 }}</span>
|
||||
</div>
|
||||
<div v-if="data?.todos.unassigned_receipts.length" class="todo-list">
|
||||
<button
|
||||
v-for="item in data.todos.unassigned_receipts"
|
||||
:key="item.receipt_id"
|
||||
class="todo-row"
|
||||
type="button"
|
||||
@click="go('/business/receipts')"
|
||||
>
|
||||
<span class="todo-title">{{ item.receipt_batch_no }} · {{ item.amount }}</span>
|
||||
<span class="todo-meta">业务 {{ item.business_no ?? item.business_id }} · {{ item.receipt_date }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="b-empty-mini">无未指定批次的回款</div>
|
||||
|
||||
<div class="b-eyebrow b-eyebrow-gap">T+ 同步失败</div>
|
||||
<div class="b-empty-mini">
|
||||
{{ data?.todos.sync_available ? `${data.todos.sync_failure_total} 条待处置` : '同步模块未启用(待 T+ 对接)' }}
|
||||
<div class="b-empty">
|
||||
<div class="b-empty-title">暂无回款数据</div>
|
||||
<div class="b-empty-desc">业务数据管理上线后,此处展示按月回款趋势</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 风险预警 -->
|
||||
<div class="bento-card b-2x2">
|
||||
<div class="b-eyebrow">
|
||||
风险预警
|
||||
<span class="b-count">{{ data?.warnings.items.length ?? 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 class="b-eyebrow b-eyebrow-gap">同步异常预警</div>
|
||||
<div class="b-empty-mini">
|
||||
{{ data?.warnings.sync_available ? '已接入' : '同步模块未启用(待 T+ 对接)' }}
|
||||
</div>
|
||||
<!-- 待办 -->
|
||||
<div class="bento-card b-2x1">
|
||||
<div class="b-eyebrow">待办</div>
|
||||
<div class="b-empty-mini">业务数据管理上线后,待核算回款会出现在这里</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近动态 -->
|
||||
<div class="bento-card b-full">
|
||||
<div class="b-eyebrow">
|
||||
最近动态
|
||||
<el-select
|
||||
v-model="activityModule"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="全部模块"
|
||||
style="width: 140px; margin-left: 12px"
|
||||
>
|
||||
<el-option v-for="item in activityModules" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="filteredActivities.length" class="activity-list">
|
||||
<div v-for="item in filteredActivities" :key="item.id" class="activity-row">
|
||||
<span class="activity-time">{{ formatTime(item.create_time) }}</span>
|
||||
<el-tag size="small" :type="item.success === 1 ? 'info' : 'danger'" disable-transitions>
|
||||
{{ item.module }} · {{ item.action }}
|
||||
</el-tag>
|
||||
<span class="activity-summary">{{ item.summary }}</span>
|
||||
<span class="activity-user">{{ item.username ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="b-empty-mini">暂无动态</div>
|
||||
<!-- 营销人员:人员管理上线后接入 -->
|
||||
<div class="bento-card">
|
||||
<div class="b-eyebrow">营销人员</div>
|
||||
<div class="b-empty-mini">人员管理上线后同步</div>
|
||||
</div>
|
||||
|
||||
<!-- 岗位角色:真实数据 -->
|
||||
<div class="bento-card">
|
||||
<div class="b-eyebrow">岗位角色</div>
|
||||
<div class="b-num">{{ roleCount ?? '—' }}<small>个</small></div>
|
||||
<button class="b-link" type="button" @click="router.push('/system/business-roles')">
|
||||
管理 ›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 计提参数:真实数据 -->
|
||||
<div class="bento-card">
|
||||
<div class="b-eyebrow">计提参数</div>
|
||||
<div class="b-num">{{ paramCount ?? '—' }}<small>项</small></div>
|
||||
<button class="b-link" type="button" @click="router.push('/system/params')">
|
||||
维护 ›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 常用操作 -->
|
||||
<div class="bento-card b-full">
|
||||
<div class="bento-card b-2x1">
|
||||
<div class="b-eyebrow">常用操作</div>
|
||||
<div class="b-links">
|
||||
<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 class="b-op" type="button" @click="router.push('/system/params')">
|
||||
计提参数
|
||||
</button>
|
||||
<button class="b-op" type="button" @click="router.push('/system/business-roles')">
|
||||
岗位角色
|
||||
</button>
|
||||
<button class="b-op" type="button" @click="router.push('/system/logs')">
|
||||
操作日志
|
||||
</button>
|
||||
<span class="b-op b-op-disabled" title="业务数据管理上线后开放">录入回款</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- T+ 同步 -->
|
||||
<div class="bento-card">
|
||||
<div class="b-eyebrow">T+ 同步</div>
|
||||
<div class="b-state">待接入</div>
|
||||
<div class="b-sub">每周一次 · 手动触发</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -378,62 +139,56 @@ function formatTime(value: string): string {
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.waterline {
|
||||
display: block;
|
||||
width: 46px;
|
||||
width: 56px;
|
||||
height: 2px;
|
||||
margin-top: 12px;
|
||||
margin-top: 10px;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.toolbar-note {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* Bento Grid:4 列基格,卡片按重要性混排大小 */
|
||||
.bento {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-auto-rows: 102px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bento-card {
|
||||
position: relative;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 17px 20px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(28, 35, 51, 0.04),
|
||||
0 10px 24px -18px rgba(28, 35, 51, 0.18);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
overflow: hidden;
|
||||
0 3px 10px rgba(28, 35, 51, 0.03);
|
||||
transition:
|
||||
transform 180ms cubic-bezier(0.25, 0.1, 0.25, 1),
|
||||
box-shadow 180ms cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
}
|
||||
|
||||
.bento-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 2px 4px rgba(28, 35, 51, 0.05),
|
||||
0 16px 30px -18px rgba(28, 35, 51, 0.22);
|
||||
0 1px 2px rgba(28, 35, 51, 0.05),
|
||||
0 6px 18px rgba(28, 35, 51, 0.07);
|
||||
}
|
||||
|
||||
.b-2x2 {
|
||||
@ -441,190 +196,149 @@ function formatTime(value: string): string {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.b-full {
|
||||
grid-column: span 4;
|
||||
.b-2x1 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.b-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
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;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.b-num {
|
||||
margin-top: 10px;
|
||||
font-size: 26px;
|
||||
margin-top: auto;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.b-num small {
|
||||
margin-left: 4px;
|
||||
margin-left: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.b-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.b-empty-mini {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--ys-ink-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
.b-state {
|
||||
margin-top: auto;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.activity-user {
|
||||
.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;
|
||||
color: var(--ys-ink-3);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.b-empty-mini {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.b-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.b-link {
|
||||
padding: 0;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-indigo);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.b-link:hover {
|
||||
color: var(--ys-indigo-deep);
|
||||
}
|
||||
|
||||
.b-op {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--ys-indigo);
|
||||
background: var(--ys-indigo-wash);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
height: 30px;
|
||||
padding: 0 13px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
color: var(--ys-ink);
|
||||
cursor: pointer;
|
||||
background: linear-gradient(180deg, #ffffff, #fbfcfe);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 7px;
|
||||
transition:
|
||||
color 150ms cubic-bezier(0.25, 0.1, 0.25, 1),
|
||||
border-color 150ms cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
}
|
||||
|
||||
.b-op:hover {
|
||||
border-color: var(--ys-indigo);
|
||||
button.b-op:hover {
|
||||
color: var(--ys-indigo);
|
||||
border-color: rgba(46, 75, 168, 0.45);
|
||||
}
|
||||
|
||||
.b-op-disabled {
|
||||
color: var(--ys-ink-3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.bento {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.b-full {
|
||||
grid-column: span 2;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.bento {
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
|
||||
.bento-card {
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
.b-2x2,
|
||||
.b-full {
|
||||
.b-2x1 {
|
||||
grid-column: span 1;
|
||||
grid-row: span 1;
|
||||
min-height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -632,9 +346,5 @@ function formatTime(value: string): string {
|
||||
.bento-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.bento-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user