2026-10-23 04:15:09 -04:00
|
|
|
|
"""工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。
|
2026-10-19 06:44:15 -04:00
|
|
|
|
|
|
|
|
|
|
设计决策(2026-10-19):
|
|
|
|
|
|
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
|
|
|
|
|
|
不复用报表的部门汇总(后者会加载整月台账,过重)
|
|
|
|
|
|
- **待办与预警实时派生**:不建待办/预警表、不引调度器;处置动作本身
|
|
|
|
|
|
已有操作日志可追溯。与 FR-035「预警产生/消除留痕」的差距记录在
|
|
|
|
|
|
计划开放项,待业务确认是否物化
|
2026-11-02 22:20:55 -05:00
|
|
|
|
- 同步记录与采集开关分别展示;读取关闭不抹掉已完成的采集记录
|
2026-10-19 06:44:15 -04:00
|
|
|
|
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
|
2026-10-23 04:15:09 -04:00
|
|
|
|
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
|
|
|
|
|
|
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
|
2026-10-19 06:44:15 -04:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import date, datetime, timedelta
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from sqlalchemy import func, inspect, select
|
2026-10-19 06:44:15 -04:00
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from app.core.config import get_settings
|
2026-10-19 06:44:15 -04:00
|
|
|
|
from app.core.exceptions import BadRequestError
|
|
|
|
|
|
from app.core.month import validate_month
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from app.models import TPlusSyncBatch
|
|
|
|
|
|
from app.repositories import dashboard_repository, operation_log_repository
|
2026-10-19 06:44:15 -04:00
|
|
|
|
from app.repositories.query_utils import LogFilters
|
2026-10-23 04:15:09 -04:00
|
|
|
|
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
|
2026-10-19 21:45:03 -04:00
|
|
|
|
from app.services import approval_service
|
2026-10-19 06:44:15 -04:00
|
|
|
|
|
|
|
|
|
|
# 工作台只展示摘要,完整列表跳转对应管理页
|
|
|
|
|
|
_TODO_LIMIT = 5
|
|
|
|
|
|
_WARNING_LIMIT = 20
|
|
|
|
|
|
_TREND_MONTHS = 6
|
|
|
|
|
|
|
|
|
|
|
|
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 21:45:03 -04:00
|
|
|
|
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
|
2026-10-23 04:15:09 -04:00
|
|
|
|
"""三区块一次性出数(前端一次请求渲染整页)。
|
2026-10-19 21:45:03 -04:00
|
|
|
|
|
|
|
|
|
|
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
|
|
|
|
|
|
风险预警仍是全局经营视角,不按人裁剪。
|
2026-10-23 04:15:09 -04:00
|
|
|
|
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
|
2026-10-19 21:45:03 -04:00
|
|
|
|
"""
|
2026-10-19 06:44:15 -04:00
|
|
|
|
resolved = _normalize_month(month)
|
|
|
|
|
|
warning_params = _warning_params(session)
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from app.services.authorization_service import calculate
|
|
|
|
|
|
|
|
|
|
|
|
permissions = calculate(session, current_user)["permissions"]
|
|
|
|
|
|
pending_statuses = approval_service.pending_statuses_for_roles([
|
|
|
|
|
|
role for role in ("finance", "leader") if "approvals:" + role in permissions
|
|
|
|
|
|
])
|
|
|
|
|
|
sync = _sync_status(session)
|
2026-10-19 06:44:15 -04:00
|
|
|
|
return {
|
|
|
|
|
|
"month": resolved,
|
|
|
|
|
|
"overview": _overview(session, resolved),
|
2026-11-02 22:20:55 -05:00
|
|
|
|
"todos": _todos(session, pending_statuses, sync),
|
|
|
|
|
|
"warnings": _warnings(session, warning_params, sync),
|
|
|
|
|
|
"sync": sync,
|
2026-10-19 06:44:15 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
def _sync_status(session: Session) -> dict:
|
|
|
|
|
|
"""只读本地批次元数据,不连接源库,兼容尚未应用自动化迁移的开发库。"""
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
result = {
|
|
|
|
|
|
"available": False,
|
|
|
|
|
|
"enabled": settings.t_plus_enabled,
|
|
|
|
|
|
"automation_enabled": settings.t_plus_automation_enabled,
|
|
|
|
|
|
"latest_status": None,
|
|
|
|
|
|
"last_success_at": None,
|
|
|
|
|
|
"historical_failure_total": 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
if not inspect(session.connection()).has_table(TPlusSyncBatch.__tablename__):
|
|
|
|
|
|
return result
|
|
|
|
|
|
# 关联检查成功不能替代数据采集成功;仅查询原有列,避免加载 V20 新字段。
|
|
|
|
|
|
reads = TPlusSyncBatch.sync_mode.in_(("FULL", "INCREMENTAL"))
|
|
|
|
|
|
result["available"] = True
|
|
|
|
|
|
result["latest_status"] = session.scalar(
|
|
|
|
|
|
select(TPlusSyncBatch.status).where(reads).order_by(TPlusSyncBatch.id.desc()).limit(1)
|
|
|
|
|
|
)
|
|
|
|
|
|
result["last_success_at"] = session.scalar(
|
|
|
|
|
|
select(func.max(TPlusSyncBatch.finished_at)).where(
|
|
|
|
|
|
reads, TPlusSyncBatch.status == "SUCCEEDED"
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
result["historical_failure_total"] = session.scalar(
|
|
|
|
|
|
select(func.count()).select_from(TPlusSyncBatch).where(
|
|
|
|
|
|
reads, TPlusSyncBatch.status.in_(("FAILED", "PARTIAL"))
|
|
|
|
|
|
)
|
|
|
|
|
|
) or 0
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 06:44:15 -04:00
|
|
|
|
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}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 21:45:03 -04:00
|
|
|
|
def _month_start(month: str) -> date:
|
|
|
|
|
|
"""YYYY-MM → 该月首日(范围查询边界,避免函数列过滤)。"""
|
|
|
|
|
|
return date(int(month[:4]), int(month[5:7]), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 06:44:15 -04:00
|
|
|
|
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)
|
2026-10-19 21:45:03 -04:00
|
|
|
|
receipts = dashboard_repository.receipt_trend(
|
|
|
|
|
|
session,
|
|
|
|
|
|
start_date=_month_start(months[0]),
|
|
|
|
|
|
end_date=_month_start(_shift_month(months[-1], 1)),
|
|
|
|
|
|
)
|
2026-10-19 06:44:15 -04:00
|
|
|
|
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
|
|
|
|
|
|
],
|
2026-10-19 21:45:03 -04:00
|
|
|
|
"note": (
|
2026-10-23 04:15:09 -04:00
|
|
|
|
"绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);"
|
|
|
|
|
|
"回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单"
|
2026-10-19 21:45:03 -04:00
|
|
|
|
),
|
2026-10-19 06:44:15 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
def _todos(session: Session, pending_statuses: tuple[int, ...], sync: dict) -> dict:
|
2026-10-19 21:45:03 -04:00
|
|
|
|
"""待办事项(FR-034,澄清 #11 起步范围)。
|
|
|
|
|
|
|
|
|
|
|
|
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
|
|
|
|
|
|
领导只看待领导审核;admin 查看全部在途单。
|
|
|
|
|
|
"""
|
|
|
|
|
|
orders = dashboard_repository.list_pending_approvals(
|
|
|
|
|
|
session, statuses=pending_statuses, limit=_TODO_LIMIT
|
|
|
|
|
|
)
|
2026-10-19 06:44:15 -04:00
|
|
|
|
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
|
|
|
|
|
|
return {
|
2026-10-19 21:45:03 -04:00
|
|
|
|
"pending_approval_total": dashboard_repository.count_pending_approvals(
|
|
|
|
|
|
session, statuses=pending_statuses
|
|
|
|
|
|
),
|
2026-10-19 06:44:15 -04:00
|
|
|
|
"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
|
|
|
|
|
|
],
|
2026-11-02 22:20:55 -05:00
|
|
|
|
"sync_available": sync["available"],
|
|
|
|
|
|
"sync_failure_total": sync["historical_failure_total"],
|
2026-10-19 06:44:15 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"))),
|
2026-10-22 07:43:20 -04:00
|
|
|
|
"approval_timeout_days": int(parse_fraction(snapshot.get("warning.approval.timeout.days"))),
|
2026-10-19 06:44:15 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
def _warnings(session: Session, params: dict, sync: dict) -> dict:
|
|
|
|
|
|
"""风险预警(FR-035);采集状态与经营预警开关分别展示。"""
|
2026-10-19 06:44:15 -04:00
|
|
|
|
if not params["enabled"]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"enabled": False,
|
|
|
|
|
|
"receipt_overdue_days": params["receipt_overdue_days"],
|
|
|
|
|
|
"approval_timeout_days": params["approval_timeout_days"],
|
|
|
|
|
|
"items": [],
|
2026-10-19 21:45:03 -04:00
|
|
|
|
"total": 0,
|
2026-10-19 06:44:15 -04:00
|
|
|
|
"receipt_overdue_total": 0,
|
|
|
|
|
|
"approval_timeout_total": 0,
|
2026-11-02 22:20:55 -05:00
|
|
|
|
"sync_available": sync["available"],
|
2026-10-19 06:44:15 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
]
|
2026-10-19 21:45:03 -04:00
|
|
|
|
receipt_overdue_total = dashboard_repository.count_overdue_shipments(
|
|
|
|
|
|
session, cutoff=receipt_cutoff
|
|
|
|
|
|
)
|
|
|
|
|
|
approval_timeout_total = dashboard_repository.count_overdue_approvals(
|
|
|
|
|
|
session, cutoff=approval_cutoff
|
|
|
|
|
|
)
|
2026-10-19 06:44:15 -04:00
|
|
|
|
return {
|
|
|
|
|
|
"enabled": True,
|
|
|
|
|
|
"receipt_overdue_days": params["receipt_overdue_days"],
|
|
|
|
|
|
"approval_timeout_days": params["approval_timeout_days"],
|
|
|
|
|
|
"items": items,
|
2026-10-19 21:45:03 -04:00
|
|
|
|
# items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数
|
|
|
|
|
|
"total": receipt_overdue_total + approval_timeout_total,
|
|
|
|
|
|
"receipt_overdue_total": receipt_overdue_total,
|
|
|
|
|
|
"approval_timeout_total": approval_timeout_total,
|
2026-11-02 22:20:55 -05:00
|
|
|
|
"sync_available": sync["available"],
|
2026-10-19 06:44:15 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-23 04:15:09 -04:00
|
|
|
|
def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
|
|
|
|
|
|
"""最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
|
|
|
|
|
|
|
|
|
|
|
|
模块筛选项与列表同源返回(全量模块名,不随当前页变化),
|
|
|
|
|
|
前端模块下拉不必另取一次全量。
|
|
|
|
|
|
"""
|
|
|
|
|
|
repo_query = operation_log_repository.OperationLogListQuery(
|
|
|
|
|
|
filters=LogFilters(),
|
|
|
|
|
|
offset=query.offset,
|
|
|
|
|
|
limit=query.page_size,
|
|
|
|
|
|
module=query.module,
|
|
|
|
|
|
)
|
|
|
|
|
|
logs = operation_log_repository.list_logs(session, repo_query)
|
|
|
|
|
|
total = operation_log_repository.count_logs(session, repo_query)
|
|
|
|
|
|
return ActivityPageOut(
|
|
|
|
|
|
records=[
|
|
|
|
|
|
ActivityItem(
|
|
|
|
|
|
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
|
|
|
|
|
|
],
|
|
|
|
|
|
total=total,
|
|
|
|
|
|
page_num=query.page_num,
|
|
|
|
|
|
page_size=query.page_size,
|
|
|
|
|
|
modules=operation_log_repository.distinct_modules(session),
|
2026-10-19 06:44:15 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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))
|