Files
track-LICA/backend/app/services/screen_service.py
duxingchen 394e1e39c3 feat(分组权限): 统计接口接入数据范围(dashboard / analytics / screen 共 20 个路由)
这三组接口此前是「上帝视角」且**匿名可访问**,现在全部挂 get_data_scope ——
既要求登录、又按业务分组范围过滤。顺带堵上了 AGENTS.md 点名的风险:
/dashboard/people-history/export 此前匿名即可批量导出全员工时台账。

dashboard(11 个函数 / 24 处注入)
· Product 主体 → product_where();Task、TaskLog 主体 → 先 join(Product) 再 task_where()
· 「卡片数字」与「下钻明细」成对出现的地方用同一谓词,避免「按钮显示 2、点开却是 0 条」
· get_user_operations 的 func.count() 改为 func.count(TaskLog.id) —— 显式化,不依赖
  join 形状(当前是 many-to-one 不会放大,但这样写更稳)
· unread_notif **刻意不过滤**:Notification.task_id 可空,按 Product 过滤会漏掉
  无任务关联的提醒(就地注释说明)
· get_my_stats 本轮不动 —— 它按本人归因,语义上不受分组影响

analytics(4 个函数 / 9 条语句)
· get_analytics_options 的 4 条独立语句全部处理 —— 它是筛选栏下拉的选项源,
  不过滤的话维修组能在下拉里看到生产组的人(最易漏的一处)
· get_device_records 的 product_id 查号是安全闸:范围外 SN 查不出 → 直接返回 []

screen(3 个函数)
· month_production **不做特判** —— 维修组的「本月生产数」本来就该是 0
· get_wip_distribution 按范围裁剪工序柱子,但坚持「恒 0 才裁、有数必现」,
  保证 total == sum(items) 在任何 scope 下都成立

实测(17 个端点):超管全部 200、匿名全部 401。
造 1 生产 + 1 售后产品后:
  超管   products=2 / wip-matrix 2 行 / options 2 个型号
  生产组 products=1 / wip-matrix 1 行 / options 1 个型号
  维修组 products=1 / wip-matrix 1 行 / options 1 个型号
列表与统计口径一致;未分组用户在过渡期开关下仍走 ungrouped_fallback。
测试数据已还原。
2026-09-21 17:22:01 +08:00

317 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""大屏统计服务 — 面向管理层**日常运营与督导**的轻量只读聚合
设计约定:
- 只读,无写操作,字段扁平,便于大屏高频轮询。
- 时间一律按**北京时间**判定DB 列为 timestamptz实存 UTC
比较前统一把边界换算成 UTC避免月初/凌晨的边界漂移。
- 口径与 dashboard_service.get_dashboard_stats 保持一致:
未完结 = overall_status 不属于 {待仓库收货, 已入库, 在库, 已出库}。
视角说明:
管理层每天要看的是「这个月干得怎么样、现在卡在哪、系统有没有在跑」,
而不是历史品质排名。故本模块聚焦三件事 —— 当月吞吐、当前卡点、使用活跃度。
2026-09 起不再是「上帝视角」:统计函数一律接收调用方解析好的 DataScope
查询里按 Product.lifecycle_phase 过滤(谓词只在 data_scope_service 生成,见该模块红线)。
"""
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,
allowed_steps,
)
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
from app.services.data_scope_service import DataScope
# 已完结的宏观状态 —— 与 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, scope: DataScope) -> MonthlyMetrics:
"""当月吞吐四联指标(按当前用户的业务分组数据范围过滤)。
口径:
- month_productionlifecycle_phase = PRODUCTION且「本月新建」或
「本月产生过任意 TaskLog 流转记录」的设备数(按设备去重)。
反映这个月实际被推着走的机器有多少。
- month_inbound / month_outboundtask_logs 中 action_type =
warehouse_inbound / warehouse_outbound 的记录数MOM 扫码回调写入)。
- month_returned本月创建过售后专属工序任务发货测试 / 售后维修)的
设备数去重。Product 表无 updated_at无法直接查「转为 AFTER_SALES
的时刻」,故以售后工序任务的创建时间作为进入售后阶段的时间锚点。
⚠️ month_production 上两条口径叠加phase=PRODUCTION AND phase IN 范围),
维修组的本月生产数自然为 0 —— 这正是正确行为,不要为它加特判绕开范围。
"""
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()
)
# 🚀 业务分组数据范围:主体 Product直接追加 product_where
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),
scope.product_where(),
)
) or 0
async def _count_log(action_type: str) -> int:
# 🚀 业务分组数据范围TaskLog 无阶段字段,两级 join 到 Product 后挂 task_where
# (计数列已是 TaskLog.idjoin 不会放大行数)
return await db.scalar(
select(func.count(TaskLog.id))
.join(Task, Task.id == TaskLog.task_id)
.join(Product, Task.product_id == Product.id)
.where(
TaskLog.action_type == action_type,
TaskLog.created_at >= month_start_utc,
scope.task_where(),
)
) or 0
month_inbound = await _count_log(LOG_WAREHOUSE_INBOUND)
month_outbound = await _count_log(LOG_WAREHOUSE_OUTBOUND)
# 🚀 业务分组数据范围:已 join Product直接挂 product_where
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,
scope.product_where(),
)
) 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 = ""
# 词表内工序 → 声明的阶段(补画「有数但被范围裁掉」的工序时,用它还原分区着色)
_STAGE_PHASE: dict[str, str] = {
(_UNSTARTED_KEY if s == "待启动" else s): p for s, p in WIP_STAGES
}
def _visible_stages(scope: DataScope) -> tuple[tuple[str, str], ...]:
"""按当前业务分组范围裁剪工序柱 —— 只保留本组阶段内的工序。
维修组AFTER_SALES的大屏上挂一排恒为 0 的「备货 / 生产 / 测试」柱子没有意义,
反而让人误以为"有活压着没干"。判定取并集(宁可多留、不可错删):
· 工序自己声明的 phase 落在范围内 ——「待启动」这类合成列按 PRODUCTION 论;
· 或工序名属于该阶段的合法工序词表(词表取自 core.lifecycle本模块不另抄一份
「发货测试」两个阶段都合法,故生产组也照常保留。
范围不限(超管 / 未分组主管)时原样返回,保证大屏类目与改造前完全一致。
"""
if scope.phases is None:
return WIP_STAGES
allowed: set[str] = set()
for phase in scope.phases:
allowed |= set(allowed_steps(phase))
return tuple(
(stage, phase)
for stage, phase in WIP_STAGES
if phase in scope.phases or stage in allowed
)
async def get_wip_distribution(db: AsyncSession, scope: DataScope) -> WipDistributionResponse:
"""工序积压分布 —— 当前数据范围内未完结设备按 overall_status 聚合的数量。
返回**固定阶段列表**(含 0 值),保证大屏布局稳定、柱子不因某天缺数据而
整根消失;同时把数据里出现但不在词表内的状态追加在末尾,确保 items 的
count 之和恒等于 total避免统计悄悄漏数
⚠️ 「恒 0 才裁、有数必现」是裁剪的铁律:被 `_visible_stages` 裁掉的工序若
名下**真有设备**(如上个阶段的设备卡在旧工序名上没流转走),它会带着
真实计数回到列表末尾 —— 宁可多画一根柱子,也绝不把设备藏起来变成
「合计 1 台、柱子全是 0」的鬼故事。恒 0 的柱子才真正不画。
"""
# 🚀 业务分组数据范围:主体 Product直接挂 product_where计数列是 Product.id无 join 放大)
rows = await db.execute(
select(Product.overall_status, func.count(Product.id))
.where(_not_finished(), scope.product_where())
.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())
# 🚀 业务分组数据范围:只画本组阶段的工序柱(范围不限时即 WIP_STAGES 原样)
stages = _visible_stages(scope)
items: list[WipStage] = []
for stage, phase in stages:
key = _UNSTARTED_KEY if stage == "待启动" else stage
items.append(WipStage(stage=stage, phase=phase, count=counts.get(key, 0)))
# 词表外的状态追加在末尾。known_keys 只按**可见**柱子算:被裁掉的工序若名下
# 真有设备,就走这条追加路径回来(阶段着色按词表还原),保证合计不漏数;
# cnt 为 0 的则被下面的 `and cnt` 挡掉 —— 那才是本次裁剪要除掉的那排空柱。
known_keys = {_UNSTARTED_KEY if s == "待启动" else s for s, _ in stages}
for key, cnt in counts.items():
if key not in known_keys and cnt:
items.append(WipStage(
stage=key or "待启动",
phase=_STAGE_PHASE.get(key, 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, scope: DataScope, top_n: int = 5) -> ActiveUsersResponse:
"""本月系统活跃度排行 —— 直接桥接 /dashboard/user-operations 的口径。
不重复实现聚合逻辑:转交/接收取 task_logsaction_type = receive / complete
上传备注取 task_records排除系统自动备注。仅返回本月**确实有操作**的人员,
避免排行榜被一串 0 稀释 —— 领导要看到的是"系统真的有人在用"
按当前用户的业务分组数据范围过滤:范围直接透传给 get_user_operations
两处口径共用一份谓词,不在这里另抄一遍。
"""
from app.services.dashboard_service import get_user_operations
month_start_utc, _now, month_label = _month_bounds()
# 🚀 业务分组数据范围:由调用方解析好的真实 scope 透传(不再是「不限」占位)
operations = await get_user_operations(
db, scope, 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
],
)