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:
@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user