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。
测试数据已还原。
This commit is contained in:
2026-09-21 17:22:01 +08:00
parent 3c1c5d6fb5
commit 394e1e39c3
6 changed files with 360 additions and 88 deletions

View File

@ -8,6 +8,9 @@
耗时口径:
- 单台真实耗时 = (completed_at or now) - (received_at or created_at),单位小时。
- naive datetime 按 UTC 处理,统一换算北京时间。
2026-09 起不再是「上帝视角」:统计函数一律接收调用方解析好的 DataScope,
查询里按 Product.lifecycle_phase 过滤(谓词只在 data_scope_service 生成,见该模块红线)。
"""
from datetime import datetime, timezone
@ -15,6 +18,8 @@ from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.services.data_scope_service import DataScope
# ============================================================
# Schemas(与前端 src/services/analyticsApi.ts 对齐)
@ -133,6 +138,7 @@ def _duration_hours(start: datetime | None, end: datetime, holidays: set = None,
async def get_capability_profile(
db: AsyncSession,
scope: DataScope,
assignee_ids: list[str] | None = None,
spec_models: list[str] | None = None,
since: datetime | None = None,
@ -144,6 +150,8 @@ async def get_capability_profile(
按 (负责人, 设备) 分组,累加该人在该设备所有工序的耗时,
series.data 与 categories 严格对齐,未触及设备补 0。
耗时口径:(coalesce(completed_at, now) - coalesce(received_at, created_at))。
按当前用户的业务分组数据范围过滤。
"""
from app.models.task import (
Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED,
@ -169,6 +177,7 @@ async def get_capability_profile(
.where(
Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]),
Task.assignee_id.isnot(None),
scope.task_where(), # 🚀 业务分组数据范围(已 join Product)
)
)
if assignee_ids:
@ -282,6 +291,7 @@ async def get_capability_profile(
async def get_flow_compare(
db: AsyncSession,
scope: DataScope,
product_sns: list[str] | None = None,
spec_models: list[str] | None = None,
mode: str = "natural",
@ -311,11 +321,13 @@ async def get_flow_compare(
now = get_beijing_time()
if product_sns:
# 🚀 业务分组数据范围:主体是 Product,直接挂 product_where
# (范围外的身份证查不出来 → 与下钻接口同口径,不会泄露别组设备)
product_rows = (await db.execute(
select(
Product.id, Product.serial_number, Product.external_serial,
Product.material_name, Product.spec_model,
).where(Product.serial_number.in_(product_sns))
).where(Product.serial_number.in_(product_sns), scope.product_where())
)).all()
elif spec_models:
latest_subq = (
@ -332,7 +344,7 @@ async def get_flow_compare(
Product.material_name, Product.spec_model,
)
.join(latest_subq, latest_subq.c.product_id == Product.id)
.where(Product.spec_model.in_(spec_models))
.where(Product.spec_model.in_(spec_models), scope.product_where()) # 🚀 业务分组数据范围(主体 Product)
.order_by(latest_subq.c.latest.desc())
.limit(20)
)).all()
@ -346,16 +358,20 @@ async def get_flow_compare(
product_ids = [r[0] for r in product_rows]
# ── 候选设备的全部任务(含工序名,用于区间图) ──
# 🚀 业务分组数据范围:product_ids 已来自上方受限结果,这里把口径写全
# (Task 主体,必须先 join Product 再挂 task_where;结果集不变)
task_rows = (await db.execute(
select(
Task.product_id, Task.assignee_id, Task.task_name,
Task.received_at, Task.created_at, Task.completed_at,
Task.task_type,
)
.join(Product, Task.product_id == Product.id)
.where(
Task.product_id.in_(product_ids),
Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]),
Task.assignee_id.isnot(None),
scope.task_where(),
)
)).all()
@ -497,28 +513,39 @@ async def get_flow_compare(
async def get_device_records(
db: AsyncSession,
scope: DataScope,
product_sn: str,
assignee_ids: list[str] | None = None,
) -> list[DeviceRecord]:
"""查询某台设备(身份证)的所有任务备注记录,含图片,按时间倒序。
可选按负责人过滤(assignee_ids)。"""
可选按负责人过滤(assignee_ids)。
按当前用户的业务分组数据范围过滤 —— 范围外的身份证查不出 product_id,
直接返回空列表(否则维修组拿生产组的身份证就能读到别组的备注与照片)。
"""
import json
from app.models.task import Task, TaskRecord
from app.models.product import Product
# 🚀 业务分组数据范围:主体 Product,范围外查不出 product_id → 下方直接空返回
product_id = await db.scalar(
select(Product.id).where(Product.serial_number == product_sn)
select(Product.id).where(
Product.serial_number == product_sn,
scope.product_where(),
)
)
if not product_id:
return []
# 🚀 业务分组数据范围:product_id 已受限,这里把口径写全(Task 主体,先 join Product)
stmt = (
select(
TaskRecord.remark, TaskRecord.images, TaskRecord.created_at,
Task.task_name, Task.assignee_id, Task.status,
)
.join(Task, TaskRecord.task_id == Task.id)
.where(Task.product_id == product_id)
.join(Product, Task.product_id == Product.id)
.where(Task.product_id == product_id, scope.task_where())
)
if assignee_ids:
stmt = stmt.where(Task.assignee_id.in_(assignee_ids))
@ -558,31 +585,45 @@ async def get_device_records(
async def get_analytics_options(
db: AsyncSession,
scope: DataScope,
assignee_ids: list[str] | None = None,
spec_models: list[str] | None = None,
) -> AnalyticsOptions:
"""返回筛选栏选项,支持动态联动:
- 传入 spec_models:只返回碰过这些型号的人;
- 传入 assignee_ids:只返回这些人处理过的型号;
- devices:根据筛选条件返回关联设备(无筛选则返回最近流转的设备)。"""
- devices:根据筛选条件返回关联设备(无筛选则返回最近流转的设备)。
⚠️ 本函数是**筛选栏下拉的选项源**,四条语句(负责人 / 型号 / 设备,各含
带筛选与不带筛选两个分支)必须条条挂上数据范围谓词 —— 漏掉任意一条,
维修组就能在下拉里看到生产组的人与型号,点开却是空的。
"""
from app.models.task import Task
from app.models.product import Product
# ── 负责人:从 tasks 去重(可选按 spec_models 过滤) ──
if spec_models:
# 🚀 业务分组数据范围:已 join Product,直接挂 task_where
assignee_stmt = (
select(Task.assignee_id)
.join(Product, Task.product_id == Product.id)
.where(
Task.assignee_id.isnot(None),
Product.spec_model.in_(spec_models),
scope.task_where(),
)
.distinct()
)
else:
# 🚀 业务分组数据范围:本分支原先没有 join,必须补上 Product 才能挂
# task_where(tasks 表没有 lifecycle_phase)
assignee_stmt = (
select(Task.assignee_id)
.where(Task.assignee_id.isnot(None))
.join(Product, Task.product_id == Product.id)
.where(
Task.assignee_id.isnot(None),
scope.task_where(),
)
.distinct()
)
assignee_rows = (await db.execute(assignee_stmt)).all()
@ -600,6 +641,7 @@ async def get_analytics_options(
# ── 规格型号 + 物料名(可选按 assignee_ids 过滤;同型号取首个非空物料名) ──
if assignee_ids:
# 🚀 业务分组数据范围:主体 Product,直接挂 product_where
spec_stmt = (
select(Product.spec_model, Product.material_name)
.join(Task, Task.product_id == Product.id)
@ -607,13 +649,19 @@ async def get_analytics_options(
Product.spec_model.isnot(None),
func.trim(Product.spec_model) != "",
Task.assignee_id.in_(assignee_ids),
scope.product_where(),
)
.distinct()
)
else:
# 🚀 业务分组数据范围:主体 Product,直接挂 product_where
spec_stmt = (
select(Product.spec_model, Product.material_name)
.where(Product.spec_model.isnot(None), func.trim(Product.spec_model) != "")
.where(
Product.spec_model.isnot(None),
func.trim(Product.spec_model) != "",
scope.product_where(),
)
.distinct()
)
spec_rows = (await db.execute(spec_stmt)).all()
@ -632,10 +680,12 @@ async def get_analytics_options(
# ── 设备字典(可选按 assignee_ids / spec_models 过滤;无筛选返回最近流转 100 台) ──
if assignee_ids or spec_models:
# 🚀 业务分组数据范围:主体 Product,直接挂 product_where
# (即使上面 join 了 Task,产品去重后条数仍由 Product 决定)
device_stmt = select(
Product.serial_number, Product.external_serial,
Product.material_name, Product.spec_model,
)
).where(scope.product_where())
if assignee_ids:
device_stmt = device_stmt.join(
Task, Task.product_id == Product.id
@ -658,6 +708,7 @@ async def get_analytics_options(
Product.material_name, Product.spec_model,
)
.join(latest_subq, latest_subq.c.product_id == Product.id)
.where(scope.product_where()) # 🚀 业务分组数据范围(主体 Product)
.order_by(latest_subq.c.latest.desc())
.limit(100)
)