Files
track-LICA/backend/app/services/screen_service.py

317 lines
13 KiB
Python
Raw Normal View History

"""大屏统计服务 — 面向管理层**日常运营与督导**的轻量只读聚合
设计约定:
- 只读,无写操作,字段扁平,便于大屏高频轮询。
- 时间一律按**北京时间**判定;DB 列为 timestamptz(实存 UTC),
比较前统一把边界换算成 UTC,避免月初/凌晨的边界漂移。
- 口径与 dashboard_service.get_dashboard_stats 保持一致:
未完结 = overall_status 不属于 {待仓库收货, 已入库, 在库, 已出库}。
视角说明:
管理层每天要看的是「这个月干得怎么样、现在卡在哪、系统有没有在跑」,
而不是历史品质排名。故本模块聚焦三件事 —— 当月吞吐、当前卡点、使用活跃度。
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
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,
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
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
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
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 # 本月进入售后/回流状态的设备数
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
async def get_monthly_metrics(db: AsyncSession, scope: DataScope) -> 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
的时刻」,故以售后工序任务的创建时间作为进入售后阶段的时间锚点。
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
⚠️ 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()
)
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
# 🚀 业务分组数据范围:主体 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),
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
scope.product_where(),
)
) or 0
async def _count_log(action_type: str) -> int:
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
# 🚀 业务分组数据范围:TaskLog 无阶段字段,两级 join 到 Product 后挂 task_where
# (计数列已是 TaskLog.id,join 不会放大行数)
return await db.scalar(
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
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,
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
scope.task_where(),
)
) or 0
month_inbound = await _count_log(LOG_WAREHOUSE_INBOUND)
month_outbound = await _count_log(LOG_WAREHOUSE_OUTBOUND)
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
# 🚀 业务分组数据范围:已 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,
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
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 = ""
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
# 词表内工序 → 声明的阶段(补画「有数但被范围裁掉」的工序时,用它还原分区着色)
_STAGE_PHASE: dict[str, str] = {
(_UNSTARTED_KEY if s == "待启动" else s): p for s, p in WIP_STAGES
}
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
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(避免统计悄悄漏数)。
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
⚠️ 「恒 0 才裁、有数必现」是裁剪的铁律:被 `_visible_stages` 裁掉的工序若
名下**真有设备**(如上个阶段的设备卡在旧工序名上没流转走),它会带着
真实计数回到列表末尾 —— 宁可多画一根柱子,也绝不把设备藏起来变成
「合计 1 台、柱子全是 0」的鬼故事。恒 0 的柱子才真正不画。
"""
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
# 🚀 业务分组数据范围:主体 Product,直接挂 product_where(计数列是 Product.id,无 join 放大)
rows = await db.execute(
select(Product.overall_status, func.count(Product.id))
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
.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())
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
# 🚀 业务分组数据范围:只画本组阶段的工序柱(范围不限时即 WIP_STAGES 原样)
stages = _visible_stages(scope)
items: list[WipStage] = []
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
for stage, phase in stages:
key = _UNSTARTED_KEY if stage == "待启动" else stage
items.append(WipStage(stage=stage, phase=phase, count=counts.get(key, 0)))
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
# 词表外的状态追加在末尾。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 "待启动",
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
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]
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
async def get_active_users(db: AsyncSession, scope: DataScope, top_n: int = 5) -> ActiveUsersResponse:
"""本月系统活跃度排行 —— 直接桥接 /dashboard/user-operations 的口径。
不重复实现聚合逻辑:转交/接收取 task_logs(action_type = receive / complete),
上传备注取 task_records(排除系统自动备注)。仅返回本月**确实有操作**的人员,
避免排行榜被一串 0 稀释 —— 领导要看到的是"系统真的有人在用"。
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
按当前用户的业务分组数据范围过滤:范围直接透传给 get_user_operations,
两处口径共用一份谓词,不在这里另抄一遍。
"""
from app.services.dashboard_service import get_user_operations
month_start_utc, _now, month_label = _month_bounds()
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
# 🚀 业务分组数据范围:由调用方解析好的真实 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
],
)