feat: 管理层数据大屏(后端聚合接口 + 前端全屏页面)
PC 管理端新增独立全屏数据大屏,供管理层查看直通率/产量趋势/不良分布, 替代原 AdminDashboard 上零散的手工下钻。 后端: - endpoints/screen.py + services/screen_service.py: 大屏聚合接口 (复用 lifecycle 的售后工序归一,保证统计口径与展示一致) - router.py: 注册 screen_router 前端: - pages/admin/ScreenDashboard.tsx: 全屏大屏页(自带鉴权守卫,无侧边栏) - services/screenApi.ts: 大屏数据接口封装 - components/admin/UserOperationDetailDrawer.tsx: 人员操作明细抽屉, 由 AdminDashboard 的原生 state 抽成独立组件(含命令式 handle) - AdminDashboard.tsx: 改为使用该抽屉组件,移除内联的下钻状态 - MatrixBoard.tsx / App.tsx / AdminLayout.tsx: 挂载路由与导航入口 - BaseEChart.tsx: 注册 GaugeChart 与 GraphicComponent (graphic 需显式注册,否则饼图中心文字静默不渲染)
This commit is contained in:
52
backend/app/api/v1/endpoints/screen.py
Normal file
52
backend/app/api/v1/endpoints/screen.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""大屏 API — 面向管理层**日常运营与督导**的轻量聚合接口
|
||||
|
||||
视角:当月吞吐 / 当前卡点 / 系统活跃度。
|
||||
|
||||
与 /dashboard 的区别:/dashboard 面向 PC 后台明细下钻(返回大列表),
|
||||
/screen 只返回图表直接可用的扁平聚合数据,字段少、无分页、供高频轮询。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.screen_service import (
|
||||
get_monthly_metrics, MonthlyMetrics,
|
||||
get_wip_distribution, WipDistributionResponse,
|
||||
get_active_users, ActiveUsersResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/screen", tags=["大屏统计"])
|
||||
|
||||
|
||||
@router.get("/monthly-metrics", response_model=MonthlyMetrics)
|
||||
async def monthly_metrics(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
当月吞吐 — 大屏顶部四张数字卡。
|
||||
|
||||
返回:本月生产流转 / 本月已入库 / 本月已出库 / 本月返厂回流。
|
||||
统计区间为北京时间当月 1 日 00:00 至此刻。
|
||||
"""
|
||||
return await get_monthly_metrics(db)
|
||||
|
||||
|
||||
@router.get("/wip-distribution", response_model=WipDistributionResponse)
|
||||
async def wip_distribution(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
工序积压分布 — 当前未完结设备按 overall_status 聚合的**纯数量**。
|
||||
|
||||
返回固定阶段列表(含 0 值),保证柱状图类目稳定、不因缺数据而塌陷。
|
||||
"""
|
||||
return await get_wip_distribution(db)
|
||||
|
||||
|
||||
@router.get("/active-users", response_model=ActiveUsersResponse)
|
||||
async def active_users(
|
||||
top_n: int = Query(5, ge=1, le=20, description="返回的活跃人员数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
本月系统使用活跃度排行 — 接收 / 转交 / 上传备注次数。
|
||||
|
||||
桥接 /dashboard/user-operations 的统计口径,仅返回本月确实有操作的人员。
|
||||
"""
|
||||
return await get_active_users(db, top_n=top_n)
|
||||
@ -16,6 +16,7 @@ from app.api.v1.endpoints.analytics import router as analytics_router
|
||||
from app.api.v1.endpoints.holidays import router as holidays_router
|
||||
from app.api.v1.endpoints.webhooks import router as webhooks_router
|
||||
from app.api.v1.endpoints.external_products import router as external_products_router
|
||||
from app.api.v1.endpoints.screen import router as screen_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -35,3 +36,4 @@ api_router.include_router(analytics_router)
|
||||
api_router.include_router(holidays_router)
|
||||
api_router.include_router(webhooks_router)
|
||||
api_router.include_router(external_products_router)
|
||||
api_router.include_router(screen_router)
|
||||
|
||||
254
backend/app/services/screen_service.py
Normal file
254
backend/app/services/screen_service.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""大屏统计服务 — 面向管理层**日常运营与督导**的轻量只读聚合
|
||||
|
||||
设计约定:
|
||||
- 只读,无写操作,字段扁平,便于大屏高频轮询。
|
||||
- 时间一律按**北京时间**判定;DB 列为 timestamptz(实存 UTC),
|
||||
比较前统一把边界换算成 UTC,避免月初/凌晨的边界漂移。
|
||||
- 口径与 dashboard_service.get_dashboard_stats 保持一致:
|
||||
未完结 = overall_status 不属于 {待仓库收货, 已入库, 在库, 已出库}。
|
||||
|
||||
视角说明:
|
||||
管理层每天要看的是「这个月干得怎么样、现在卡在哪、系统有没有在跑」,
|
||||
而不是历史品质排名。故本模块聚焦三件事 —— 当月吞吐、当前卡点、使用活跃度。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.lifecycle import (
|
||||
AFTER_SALES_ONLY_STEPS,
|
||||
LIFECYCLE_AFTER_SALES,
|
||||
LIFECYCLE_PRODUCTION,
|
||||
)
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task
|
||||
from app.models.task_log import TaskLog
|
||||
|
||||
# 已完结的宏观状态 —— 与 dashboard_service.get_dashboard_stats 同口径
|
||||
FINISHED_OVERALL = ("待仓库收货", "已入库", "在库", "已出库")
|
||||
|
||||
# 仓储动作日志类型(由 webhooks / product_finalize_service 写入)
|
||||
LOG_WAREHOUSE_INBOUND = "warehouse_inbound"
|
||||
LOG_WAREHOUSE_OUTBOUND = "warehouse_outbound"
|
||||
|
||||
|
||||
def _not_finished():
|
||||
"""未完结条件 —— overall_status 为 NULL 或不在已完结集合内。
|
||||
|
||||
注意 PostgreSQL 中 `NULL NOT IN (...)` 结果为 NULL 而非 TRUE,
|
||||
必须显式带上 IS NULL 分支,否则建单后未流转的设备会被整批漏掉。
|
||||
"""
|
||||
return or_(
|
||||
Product.overall_status.is_(None),
|
||||
~Product.overall_status.in_(FINISHED_OVERALL),
|
||||
)
|
||||
|
||||
|
||||
def _month_bounds() -> tuple[datetime, datetime, str]:
|
||||
"""返回 (本月起点 UTC, 当前时刻 UTC, 'YYYY-MM')。
|
||||
|
||||
本月起点 = 北京时间当月 1 日 00:00 —— 直接换算成 UTC 参与 timestamptz 比较,
|
||||
不依赖数据库会话时区设置。
|
||||
"""
|
||||
now_bj = get_beijing_time()
|
||||
month_start_bj = now_bj.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
return (
|
||||
month_start_bj.astimezone(timezone.utc),
|
||||
now_bj.astimezone(timezone.utc),
|
||||
month_start_bj.strftime("%Y-%m"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 1. 当月吞吐指标
|
||||
# ============================================================
|
||||
|
||||
class MonthlyMetrics(BaseModel):
|
||||
"""大屏顶部四张数字卡(当月视角)"""
|
||||
month: str # "2026-09"
|
||||
month_start: str # "2026-09-01"(北京时间)
|
||||
month_production: int # 本月有流转记录或新建的生产态设备数
|
||||
month_inbound: int # 本月扫码入库数
|
||||
month_outbound: int # 本月扫码出库数
|
||||
month_returned: int # 本月进入售后/回流状态的设备数
|
||||
|
||||
|
||||
async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics:
|
||||
"""当月吞吐四联指标。
|
||||
|
||||
口径:
|
||||
- month_production:lifecycle_phase = PRODUCTION,且「本月新建」或
|
||||
「本月产生过任意 TaskLog 流转记录」的设备数(按设备去重)。
|
||||
反映这个月实际被推着走的机器有多少。
|
||||
- month_inbound / month_outbound:task_logs 中 action_type =
|
||||
warehouse_inbound / warehouse_outbound 的记录数(MOM 扫码回调写入)。
|
||||
- month_returned:本月创建过售后专属工序任务(发货测试 / 售后维修)的
|
||||
设备数(去重)。Product 表无 updated_at,无法直接查「转为 AFTER_SALES
|
||||
的时刻」,故以售后工序任务的创建时间作为进入售后阶段的时间锚点。
|
||||
"""
|
||||
month_start_utc, now_utc, month_label = _month_bounds()
|
||||
|
||||
# 本月该设备产生过任意流转日志
|
||||
has_activity = (
|
||||
select(TaskLog.id)
|
||||
.join(Task, Task.id == TaskLog.task_id)
|
||||
.where(
|
||||
Task.product_id == Product.id,
|
||||
TaskLog.created_at >= month_start_utc,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
month_production = await db.scalar(
|
||||
select(func.count(func.distinct(Product.id))).where(
|
||||
Product.lifecycle_phase == LIFECYCLE_PRODUCTION,
|
||||
or_(Product.created_at >= month_start_utc, has_activity),
|
||||
)
|
||||
) or 0
|
||||
|
||||
async def _count_log(action_type: str) -> int:
|
||||
return await db.scalar(
|
||||
select(func.count(TaskLog.id)).where(
|
||||
TaskLog.action_type == action_type,
|
||||
TaskLog.created_at >= month_start_utc,
|
||||
)
|
||||
) or 0
|
||||
|
||||
month_inbound = await _count_log(LOG_WAREHOUSE_INBOUND)
|
||||
month_outbound = await _count_log(LOG_WAREHOUSE_OUTBOUND)
|
||||
|
||||
month_returned = await db.scalar(
|
||||
select(func.count(func.distinct(Task.product_id)))
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(
|
||||
Product.lifecycle_phase == LIFECYCLE_AFTER_SALES,
|
||||
Task.task_name.in_(tuple(AFTER_SALES_ONLY_STEPS)),
|
||||
Task.created_at >= month_start_utc,
|
||||
)
|
||||
) or 0
|
||||
|
||||
return MonthlyMetrics(
|
||||
month=month_label,
|
||||
month_start=f"{month_label}-01",
|
||||
month_production=int(month_production),
|
||||
month_inbound=int(month_inbound),
|
||||
month_outbound=int(month_outbound),
|
||||
month_returned=int(month_returned),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2. 工序积压分布(柱状图)
|
||||
# ============================================================
|
||||
|
||||
class WipStage(BaseModel):
|
||||
stage: str # 工序名(中文,直接作为图表类目)
|
||||
phase: str # PRODUCTION / AFTER_SALES,供前端分区着色
|
||||
count: int
|
||||
|
||||
|
||||
class WipDistributionResponse(BaseModel):
|
||||
total: int # 未完结设备总数
|
||||
items: list[WipStage]
|
||||
|
||||
|
||||
# 阶段与流转顺序 —— 顺序即「工序先后」,前端据此排布柱子
|
||||
WIP_STAGES: tuple[tuple[str, str], ...] = (
|
||||
("待启动", LIFECYCLE_PRODUCTION), # overall_status 为空:建单后尚未流转
|
||||
("备货", LIFECYCLE_PRODUCTION),
|
||||
("生产", LIFECYCLE_PRODUCTION),
|
||||
("测试", LIFECYCLE_PRODUCTION),
|
||||
("维修", LIFECYCLE_PRODUCTION),
|
||||
("发货测试", LIFECYCLE_AFTER_SALES),
|
||||
("售后维修", LIFECYCLE_AFTER_SALES),
|
||||
)
|
||||
|
||||
# 「待启动」对应的真实分组键(overall_status IS NULL / 空串)
|
||||
_UNSTARTED_KEY = ""
|
||||
|
||||
|
||||
async def get_wip_distribution(db: AsyncSession) -> WipDistributionResponse:
|
||||
"""工序积压分布 —— 当前未完结设备按 overall_status 聚合的数量。
|
||||
|
||||
返回**固定阶段列表**(含 0 值),保证大屏布局稳定、柱子不因某天缺数据而
|
||||
整根消失;同时把数据里出现但不在词表内的状态追加在末尾,确保 items 的
|
||||
count 之和恒等于 total(避免统计悄悄漏数)。
|
||||
"""
|
||||
rows = await db.execute(
|
||||
select(Product.overall_status, func.count(Product.id))
|
||||
.where(_not_finished())
|
||||
.group_by(Product.overall_status)
|
||||
)
|
||||
counts: dict[str, int] = {}
|
||||
for raw_status, cnt in rows.all():
|
||||
key = (raw_status or "").strip()
|
||||
counts[key] = counts.get(key, 0) + cnt
|
||||
|
||||
total = sum(counts.values())
|
||||
|
||||
items: list[WipStage] = []
|
||||
for stage, phase in WIP_STAGES:
|
||||
key = _UNSTARTED_KEY if stage == "待启动" else stage
|
||||
items.append(WipStage(stage=stage, phase=phase, count=counts.get(key, 0)))
|
||||
|
||||
known_keys = {_UNSTARTED_KEY if s == "待启动" else s for s, _ in WIP_STAGES}
|
||||
for key, cnt in counts.items():
|
||||
if key not in known_keys and cnt:
|
||||
items.append(WipStage(
|
||||
stage=key or "待启动",
|
||||
phase=LIFECYCLE_PRODUCTION,
|
||||
count=cnt,
|
||||
))
|
||||
|
||||
return WipDistributionResponse(total=total, items=items)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 3. 系统使用活跃度(本月人员排行)
|
||||
# ============================================================
|
||||
|
||||
class ActiveUser(BaseModel):
|
||||
user_id: str
|
||||
user_name: str
|
||||
receive_count: int # 接收
|
||||
transfer_count: int # 转交
|
||||
record_count: int # 上传备注
|
||||
total: int
|
||||
|
||||
|
||||
class ActiveUsersResponse(BaseModel):
|
||||
month: str
|
||||
items: list[ActiveUser]
|
||||
|
||||
|
||||
async def get_active_users(db: AsyncSession, top_n: int = 5) -> ActiveUsersResponse:
|
||||
"""本月系统活跃度排行 —— 直接桥接 /dashboard/user-operations 的口径。
|
||||
|
||||
不重复实现聚合逻辑:转交/接收取 task_logs(action_type = receive / complete),
|
||||
上传备注取 task_records(排除系统自动备注)。仅返回本月**确实有操作**的人员,
|
||||
避免排行榜被一串 0 稀释 —— 领导要看到的是"系统真的有人在用"。
|
||||
"""
|
||||
from app.services.dashboard_service import get_user_operations
|
||||
|
||||
month_start_utc, _now, month_label = _month_bounds()
|
||||
operations = await get_user_operations(db, since=month_start_utc, until=None)
|
||||
active = [op for op in operations if op.total > 0][:top_n]
|
||||
|
||||
return ActiveUsersResponse(
|
||||
month=month_label,
|
||||
items=[
|
||||
ActiveUser(
|
||||
user_id=op.user_id,
|
||||
user_name=op.user_name,
|
||||
receive_count=op.receive_count,
|
||||
transfer_count=op.transfer_count,
|
||||
record_count=op.record_count,
|
||||
total=op.total,
|
||||
)
|
||||
for op in active
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user