fix(dashboard): 修复普通用户落地页与待办角色过滤

- 新增 router/access.ts 统一准入与落地页(工作台→业务数据→回款),
  守卫/登录回跳/菜单/面包屑复用;普通用户不再被无限重定向
- 待办按角色映射在途审批节点(财务/领导/admin),admin 不代办但可查全部
- 预警排除已终止业务;WarningBlock.total 为全量命中数;回款趋势改日期范围
- 工作台加载请求序号防竞态;KPI 口径说明补经理分成/调整单
- 新增 role_home_flow e2e,更新 system_management 普通用户断言

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-20 09:45:03 +08:00
1 parent ebb3a92f25
commit 39a3fa7796
16 files changed
+590 -59

No files matched your search

+8 -3
View File
@@ -30,10 +30,15 @@ _DashboardUser = Annotated[
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
def get_dashboard(
session: DbSession,
_: _DashboardUser,
current_user: _DashboardUser,
month: Annotated[
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None,
) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。"""
return ok(dashboard_service.get_dashboard(session, month))
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
"""
return ok(
dashboard_service.get_dashboard(session, month, current_user=current_user)
)
@@ -7,7 +7,7 @@
from datetime import date, datetime
from sqlalchemy import exists, func, select
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session
from app.models import (
@@ -66,12 +66,18 @@ def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
return dict(rows)
def receipt_trend(session: Session, months: list[str]) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。"""
def receipt_trend(
session: Session, *, start_date: date, end_date: date
) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
分组,不参与 WHERE,避免函数列导致全表扫描。
"""
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
rows = session.execute(
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
.where(month_expr.in_(months))
.where(Receipt.receipt_date >= start_date, Receipt.receipt_date < end_date)
.group_by(month_expr)
).all()
return dict(rows)
@@ -101,24 +107,33 @@ def get_month_settlement(session: Session, month: str) -> MonthlySettlement | No
# ==== 待办事项 ====
def list_pending_approvals(session: Session, *, limit: int) -> list[ApprovalOrder]:
"""待处理审批单(按提交时间升序——最久的排前面)。"""
def list_pending_approvals(
session: Session, *, statuses: tuple[int, ...], limit: int
) -> list[ApprovalOrder]:
"""待处理审批单(按提交时间升序——最久的排前面)。
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
"""
if not statuses:
return []
return list(
session.execute(
select(ApprovalOrder)
.where(ApprovalOrder.status.in_(_PENDING_STATUSES))
.where(ApprovalOrder.status.in_(statuses))
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
.limit(limit)
).scalars()
)
def count_pending_approvals(session: Session) -> int:
def count_pending_approvals(session: Session, *, statuses: tuple[int, ...]) -> int:
if not statuses:
return 0
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(ApprovalOrder.status.in_(_PENDING_STATUSES))
.where(ApprovalOrder.status.in_(statuses))
).scalar_one()
)
@@ -152,7 +167,11 @@ def count_unassigned_receipts(session: Session) -> int:
# ==== 风险预警 ====
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。"""
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
已终止业务(status=3)不再催收,排除在预警之外;业务行缺失时仍保留,
避免历史脏数据让发货单从预警中静默消失。
"""
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
return list(
session.execute(
@@ -161,7 +180,11 @@ def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> lis
Business.business_no.label("business_no"),
)
.outerjoin(Business, Business.id == Shipment.business_id)
.where(Shipment.ship_date <= cutoff, ~has_receipt)
.where(
Shipment.ship_date <= cutoff,
~has_receipt,
or_(Business.status.is_(None), Business.status != 3),
)
.order_by(Shipment.ship_date.asc())
.limit(limit)
).all()
@@ -174,7 +197,12 @@ def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
session.execute(
select(func.count())
.select_from(Shipment)
.where(Shipment.ship_date <= cutoff, ~has_receipt)
.outerjoin(Business, Business.id == Shipment.business_id)
.where(
Shipment.ship_date <= cutoff,
~has_receipt,
or_(Business.status.is_(None), Business.status != 3),
)
).scalar_one()
)
+1
View File
@@ -98,6 +98,7 @@ class WarningBlock(BaseModel):
receipt_overdue_days: int = 90
approval_timeout_days: int = 3
items: list[WarningItem] = Field(default_factory=list)
total: int = Field(default=0, description="全部规则命中总数(items 仅为展示上限内的子集)")
receipt_overdue_total: int = 0
approval_timeout_total: int = 0
sync_available: bool = False
+14
View File
@@ -19,6 +19,7 @@
"""
import logging
from collections.abc import Iterable
from datetime import date
from sqlalchemy import func, select
@@ -29,6 +30,7 @@ from app.core.constants import (
ACTION_REJECT,
MODULE_APPROVAL,
NO_PREFIX_APPROVAL,
ROLE_ADMIN,
ROLE_FINANCE,
ROLE_LEADER,
)
@@ -84,6 +86,18 @@ _NODE_ROLE_BY_STATUS = {
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
"""工作台「待我审批」:按当前用户角色映射在途审批状态。
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
的可见性一致);财务/领导只看到本节点可操作的条目。
"""
codes = set(role_codes)
if ROLE_ADMIN in codes:
return _PENDING_STATUSES
return tuple(status for status, role in _NODE_ROLE_BY_STATUS.items() if role in codes)
def is_enabled(session: Session, order_type: int) -> bool:
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
code = ENABLE_CODE_BY_TYPE.get(order_type)
+49 -16
View File
@@ -18,8 +18,9 @@ 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 import dashboard_repository, operation_log_repository, user_repository
from app.repositories.query_utils import LogFilters
from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5
@@ -30,14 +31,21 @@ _TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。"""
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。
"""
resolved = _normalize_month(month)
warning_params = _warning_params(session)
pending_statuses = approval_service.pending_statuses_for_roles(
user_repository.get_role_codes(current_user)
)
return {
"month": resolved,
"overview": _overview(session, resolved),
"todos": _todos(session),
"todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params),
"activities": _activities(session),
}
@@ -61,6 +69,11 @@ def _shift_month(month: str, delta: int) -> str:
return f"{index // 12:04d}-{index % 12 + 1:02d}"
def _month_start(month: str) -> date:
"""YYYY-MM → 该月首日(范围查询边界,避免函数列过滤)。"""
return date(int(month[:4]), int(month[5:7]), 1)
def _trend_months(month: str) -> list[str]:
"""窗口为「当月及之前共 6 个月」,升序返回便于前端画图。"""
return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)]
@@ -72,7 +85,11 @@ def _overview(session: Session, month: str) -> dict:
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)
receipts = dashboard_repository.receipt_trend(
session,
start_date=_month_start(months[0]),
end_date=_month_start(_shift_month(months[-1], 1)),
)
return {
"month": month,
"performance_total": _dec(sums["performance_total"]),
@@ -94,16 +111,27 @@ def _overview(session: Session, month: str) -> dict:
}
for item in months
],
"note": "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月(含未核算回款);不含调整单",
"note": (
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
),
}
def _todos(session: Session) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。"""
orders = dashboard_repository.list_pending_approvals(session, limit=_TODO_LIMIT)
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
领导只看待领导审核;admin 查看全部在途单。
"""
orders = dashboard_repository.list_pending_approvals(
session, statuses=pending_statuses, limit=_TODO_LIMIT
)
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
return {
"pending_approval_total": dashboard_repository.count_pending_approvals(session),
"pending_approval_total": dashboard_repository.count_pending_approvals(
session, statuses=pending_statuses
),
"pending_approvals": [
{
"order_id": order.id,
@@ -154,6 +182,7 @@ def _warnings(session: Session, params: dict) -> dict:
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": [],
"total": 0,
"receipt_overdue_total": 0,
"approval_timeout_total": 0,
"sync_available": False,
@@ -201,17 +230,21 @@ def _warnings(session: Session, params: dict) -> dict:
}
for order in orders
]
receipt_overdue_total = dashboard_repository.count_overdue_shipments(
session, cutoff=receipt_cutoff
)
approval_timeout_total = dashboard_repository.count_overdue_approvals(
session, cutoff=approval_cutoff
)
return {
"enabled": True,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": items,
"receipt_overdue_total": dashboard_repository.count_overdue_shipments(
session, cutoff=receipt_cutoff
),
"approval_timeout_total": dashboard_repository.count_overdue_approvals(
session, cutoff=approval_cutoff
),
# items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数
"total": receipt_overdue_total + approval_timeout_total,
"receipt_overdue_total": receipt_overdue_total,
"approval_timeout_total": approval_timeout_total,
"sync_available": False,
}