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

@ -1,10 +1,16 @@
"""效能分析 API — ECharts 数据源(个人能力图谱 / 设备流转对比 / 筛选选项)""" """效能分析 API — ECharts 数据源(个人能力图谱 / 设备流转对比 / 筛选选项)
⚠️ 2026-09 起不再是「上帝视角」:所有端点都挂了 get_data_scope,
结果按当前用户的业务分组范围过滤(超管不受限),且不再允许匿名访问。
"""
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import get_data_scope
from app.services.data_scope_service import DataScope
from app.services.analytics_service import ( from app.services.analytics_service import (
get_capability_profile, CapabilityResponse, get_capability_profile, CapabilityResponse,
get_flow_compare, FlowResponse, get_flow_compare, FlowResponse,
@ -23,14 +29,15 @@ async def capability_profile(
until: str | None = Query(None, description="截止日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"),
mode: str = Query("workdays", description="耗时口径: workdays(工作小时,默认) / natural(自然小时)"), mode: str = Query("workdays", description="耗时口径: workdays(工作小时,默认) / natural(自然小时)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""个人能力图谱 — X 轴=设备身份证,分组柱状图(单台设备总耗时)。""" """个人能力图谱 — X 轴=设备身份证,分组柱状图(单台设备总耗时),按当前用户数据范围过滤。"""
since_dt = datetime.fromisoformat(since) if since else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None until_dt = datetime.fromisoformat(until) if until else None
ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
return await get_capability_profile( return await get_capability_profile(
db, assignee_ids=ids, spec_models=specs, db, scope, assignee_ids=ids, spec_models=specs,
since=since_dt, until=until_dt, mode=mode, since=since_dt, until=until_dt, mode=mode,
) )
@ -43,13 +50,14 @@ async def flow_compare(
since: str | None = Query(None, description="起始日期 ISO"), since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""设备流转对比 — 每台设备各操作人耗时(堆叠柱状,按人堆叠,识别瓶颈)。""" """设备流转对比 — 每台设备各操作人耗时(堆叠柱状,按人堆叠,识别瓶颈),按当前用户数据范围过滤。"""
sns = [s.strip() for s in product_sns.split(",") if s.strip()] if product_sns else None sns = [s.strip() for s in product_sns.split(",") if s.strip()] if product_sns else None
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
since_dt = datetime.fromisoformat(since) if since else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None until_dt = datetime.fromisoformat(until) if until else None
return await get_flow_compare(db, product_sns=sns, spec_models=specs, mode=mode, since=since_dt, until=until_dt) return await get_flow_compare(db, scope, product_sns=sns, spec_models=specs, mode=mode, since=since_dt, until=until_dt)
@router.get("/options", response_model=AnalyticsOptions) @router.get("/options", response_model=AnalyticsOptions)
@ -57,11 +65,12 @@ async def analytics_options(
assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(联动过滤型号)"), assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(联动过滤型号)"),
spec_models: str | None = Query(None, description="规格型号,逗号分隔(联动过滤人员)"), spec_models: str | None = Query(None, description="规格型号,逗号分隔(联动过滤人员)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""顶部筛选栏选项 — 负责人 + 规格型号,支持动态联动。""" """顶部筛选栏选项 — 负责人 + 规格型号 + 设备字典,支持动态联动;按当前用户数据范围过滤。"""
ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
return await get_analytics_options(db, assignee_ids=ids, spec_models=specs) return await get_analytics_options(db, scope, assignee_ids=ids, spec_models=specs)
@router.get("/device-records", response_model=list[DeviceRecord]) @router.get("/device-records", response_model=list[DeviceRecord])
@ -69,7 +78,8 @@ async def device_records(
product_sn: str = Query(..., description="设备身份证"), product_sn: str = Query(..., description="设备身份证"),
assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(可选,用于过滤)"), assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(可选,用于过滤)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""某台设备的任务备注记录(含图片);可选按负责人过滤。""" """某台设备的任务备注记录(含图片);可选按负责人过滤;范围外设备返回空。"""
ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None
return await get_device_records(db, product_sn, assignee_ids=ids) return await get_device_records(db, scope, product_sn, assignee_ids=ids)

View File

@ -1,10 +1,18 @@
"""Dashboard API — 上帝视角(全厂数据,无用户过滤)""" """Dashboard API — 管理看板
⚠️ 2026-09 起不再是「上帝视角」:所有端点都挂了 get_data_scope,
结果按当前用户的业务分组范围过滤(超管不受限)。
这同时把这些接口从**匿名可访问**变成了需要登录 —— 顺带堵上了
`/people-history/export` 匿名批量导出全员工时台账的已知风险。
"""
import io import io
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import get_data_scope
from app.services.data_scope_service import DataScope
from app.core.time_utils import BEIJING_TZ from app.core.time_utils import BEIJING_TZ
from app.services.dashboard_service import ( from app.services.dashboard_service import (
get_dashboard_stats, DashboardStats, get_dashboard_stats, DashboardStats,
@ -41,16 +49,17 @@ async def dashboard_stats(
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"), since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
until: str | None = Query(None, description="截止日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
""" """
全局统计(上帝视角)。 统计概览(按当前用户的数据范围)。
时间筛选仅影响 COMPLETED / REJECTED 计数; 时间筛选仅影响 COMPLETED / REJECTED 计数;
PENDING / WIP / 总数永远返回实时快照。 PENDING / WIP / 总数永远返回实时快照。
""" """
since_dt = datetime.fromisoformat(since) if since else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None until_dt = datetime.fromisoformat(until) if until else None
return await get_dashboard_stats(db, since=since_dt, until=until_dt) return await get_dashboard_stats(db, scope, since=since_dt, until=until_dt)
@router.get("/my-stats", response_model=MyStats) @router.get("/my-stats", response_model=MyStats)
@ -77,9 +86,10 @@ async def my_stats(
async def wip_tasks( async def wip_tasks(
limit: int = Query(500, ge=1, le=1000, description="防御性安全上限;默认足以覆盖全部在制品"), limit: int = Query(500, ge=1, le=1000, description="防御性安全上限;默认足以覆盖全部在制品"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""在制品看板 — 永远实时的 PENDING/WIP 任务(默认全量,不再按 20 条静默截断)""" """在制品看板 — 永远实时的 PENDING/WIP 任务(默认全量,不再按 20 条静默截断)"""
return await get_wip_tasks(db, limit) return await get_wip_tasks(db, scope, limit)
@router.get("/completed-tasks", response_model=list[CompletedTask]) @router.get("/completed-tasks", response_model=list[CompletedTask])
@ -88,11 +98,12 @@ async def completed_tasks(
until: str | None = Query(None, description="截止日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"),
limit: int = Query(200, ge=1, le=500), limit: int = Query(200, ge=1, le=500),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""流转完成率下钻 — 按时段查询已完成任务明细""" """流转完成率下钻 — 按时段查询已完成任务明细"""
since_dt = datetime.fromisoformat(since) if since else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None until_dt = datetime.fromisoformat(until) if until else None
return await get_completed_tasks(db, since=since_dt, until=until_dt, limit=limit) return await get_completed_tasks(db, scope, since=since_dt, until=until_dt, limit=limit)
@router.get("/rejected-tasks", response_model=list[RejectedTask]) @router.get("/rejected-tasks", response_model=list[RejectedTask])
@ -101,11 +112,12 @@ async def rejected_tasks(
until: str | None = Query(None, description="截止日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"),
limit: int = Query(200, ge=1, le=500), limit: int = Query(200, ge=1, le=500),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""驳回/返工下钻 — 按时段查询被驳回任务明细(含返工去向)""" """驳回/返工下钻 — 按时段查询被驳回任务明细(含返工去向)"""
since_dt = datetime.fromisoformat(since) if since else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None until_dt = datetime.fromisoformat(until) if until else None
return await get_rejected_tasks(db, since=since_dt, until=until_dt, limit=limit) return await get_rejected_tasks(db, scope, since=since_dt, until=until_dt, limit=limit)
@router.get("/user-operations", response_model=list[UserOperation]) @router.get("/user-operations", response_model=list[UserOperation])
@ -113,11 +125,12 @@ async def user_operations(
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"), since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
until: str | None = Query(None, description="截止日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""人员操作统计 — 按人聚合 接收/转交/上传备注 次数,按时段过滤""" """人员操作统计 — 按人聚合 接收/转交/上传备注 次数,按时段过滤"""
since_dt = datetime.fromisoformat(since) if since else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None until_dt = datetime.fromisoformat(until) if until else None
return await get_user_operations(db, since=since_dt, until=until_dt) return await get_user_operations(db, scope, since=since_dt, until=until_dt)
@router.get("/user-operations/detail", response_model=list[OperationDetail]) @router.get("/user-operations/detail", response_model=list[OperationDetail])
@ -127,12 +140,13 @@ async def user_operations_detail(
since: str | None = Query(None, description="起始日期 ISO"), since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""人员操作明细下钻 — 某人在指定时段的接收/转交/上传备注明细""" """人员操作明细下钻 — 某人在指定时段的接收/转交/上传备注明细"""
since_dt = datetime.fromisoformat(since) if since else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None until_dt = datetime.fromisoformat(until) if until else None
return await get_user_operation_detail( return await get_user_operation_detail(
db, user_id, action_type, since=since_dt, until=until_dt, db, user_id, action_type, scope, since=since_dt, until=until_dt,
) )
@ -142,11 +156,12 @@ async def wip_matrix(
since: str | None = Query(None, description="起始日期 ISO"), since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""生产分布透视表 — 规格型号 × 人员/工序 的设备数量交叉聚合(含已完成/已入库/已出库)""" """生产分布透视表 — 规格型号 × 人员/工序 的设备数量交叉聚合(含已完成/已入库/已出库)"""
since_dt = datetime.fromisoformat(since) if since else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None until_dt = datetime.fromisoformat(until) if until else None
return await get_wip_matrix(db, dimension=dimension, since=since_dt, until=until_dt) return await get_wip_matrix(db, scope, dimension=dimension, since=since_dt, until=until_dt)
@router.get("/wip-matrix/detail", response_model=list[WipMatrixDetailRow]) @router.get("/wip-matrix/detail", response_model=list[WipMatrixDetailRow])
@ -156,19 +171,21 @@ async def wip_matrix_detail(
since: str | None = Query(None, description="起始日期 ISO"), since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""WIP 矩阵单元格下钻 — 返回某 规格型号×工序 交叉点下的设备明细""" """WIP 矩阵单元格下钻 — 返回某 规格型号×工序 交叉点下的设备明细"""
since_dt = datetime.fromisoformat(since) if since else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None until_dt = datetime.fromisoformat(until) if until else None
return await get_wip_matrix_detail(db, spec_model=spec, process=process, since=since_dt, until=until_dt) return await get_wip_matrix_detail(db, scope, spec_model=spec, process=process, since=since_dt, until=until_dt)
@router.get("/people-workload", response_model=list[PersonWorkload]) @router.get("/people-workload", response_model=list[PersonWorkload])
async def people_workload( async def people_workload(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""人员负载 — 按负责人聚合当前在制品设备数(独立人员看板)""" """人员负载 — 按负责人聚合当前在制品设备数(独立人员看板)"""
return await get_people_workload(db) return await get_people_workload(db, scope)
@router.get("/people-history", response_model=list[PersonHistoryRecord]) @router.get("/people-history", response_model=list[PersonHistoryRecord])
@ -180,12 +197,13 @@ async def people_history(
product_sn: str | None = Query(None, description="身份证(模糊)"), product_sn: str | None = Query(None, description="身份证(模糊)"),
task_name: str | None = Query(None, description="任务名(模糊)"), task_name: str | None = Query(None, description="任务名(模糊)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""人员效能与工时台账 — 平铺 Task 明细,多维筛选 + 时间交集""" """人员效能与工时台账 — 平铺 Task 明细,多维筛选 + 时间交集"""
since_dt = datetime.fromisoformat(since) if since else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None until_dt = datetime.fromisoformat(until) if until else None
return await get_people_history( return await get_people_history(
db, since=since_dt, until=until_dt, db, scope, since=since_dt, until=until_dt,
assignee_id=assignee_id, spec_model=spec_model, assignee_id=assignee_id, spec_model=spec_model,
product_sn=product_sn, task_name=task_name, product_sn=product_sn, task_name=task_name,
) )
@ -200,12 +218,17 @@ async def export_people_history(
product_sn: str | None = Query(None, description="身份证(模糊)"), product_sn: str | None = Query(None, description="身份证(模糊)"),
task_name: str | None = Query(None, description="任务名(模糊)"), task_name: str | None = Query(None, description="任务名(模糊)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
"""导出工时台账为 Excel(与查询接口相同筛选条件)""" """导出工时台账为 Excel(与查询接口相同筛选条件)
⚠️ 本接口此前是**匿名可访问**的,能批量导出全员工时台账。挂上 get_data_scope
后既要求登录、又按业务分组范围过滤 —— 这是本次改造顺带堵上的已知风险。
"""
since_dt = datetime.fromisoformat(since) if since else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None until_dt = datetime.fromisoformat(until) if until else None
records = await get_people_history( records = await get_people_history(
db, since=since_dt, until=until_dt, db, scope, since=since_dt, until=until_dt,
assignee_id=assignee_id, spec_model=spec_model, assignee_id=assignee_id, spec_model=spec_model,
product_sn=product_sn, task_name=task_name, product_sn=product_sn, task_name=task_name,
) )
@ -247,11 +270,12 @@ async def dashboard_messages(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(30, ge=1, le=200), limit: int = Query(30, ge=1, le=200),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
""" """
协同留言搜索(上帝视角 — 全厂所有产品留言)。 协同留言搜索(按当前用户的数据范围过滤)。
关联 Product 表返回 serial_number + material_name, 关联 Product 表返回 serial_number + material_name,
按时间倒序排列。 按时间倒序排列。
""" """
return await search_product_messages(db, keyword=keyword, skip=skip, limit=limit) return await search_product_messages(db, scope, keyword=keyword, skip=skip, limit=limit)

View File

@ -4,11 +4,16 @@
与 /dashboard 的区别:/dashboard 面向 PC 后台明细下钻(返回大列表), 与 /dashboard 的区别:/dashboard 面向 PC 后台明细下钻(返回大列表),
/screen 只返回图表直接可用的扁平聚合数据,字段少、无分页、供高频轮询。 /screen 只返回图表直接可用的扁平聚合数据,字段少、无分页、供高频轮询。
⚠️ 2026-09 起不再是「上帝视角」:三个端点都挂了 get_data_scope,
结果按当前用户的业务分组范围过滤(超管不受限),且不再允许匿名访问。
""" """
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import get_data_scope
from app.services.data_scope_service import DataScope
from app.services.screen_service import ( from app.services.screen_service import (
get_monthly_metrics, MonthlyMetrics, get_monthly_metrics, MonthlyMetrics,
get_wip_distribution, WipDistributionResponse, get_wip_distribution, WipDistributionResponse,
@ -19,34 +24,42 @@ router = APIRouter(prefix="/screen", tags=["大屏统计"])
@router.get("/monthly-metrics", response_model=MonthlyMetrics) @router.get("/monthly-metrics", response_model=MonthlyMetrics)
async def monthly_metrics(db: AsyncSession = Depends(get_db)): async def monthly_metrics(
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
""" """
当月吞吐 — 大屏顶部四张数字卡。 当月吞吐 — 大屏顶部四张数字卡(按当前用户的数据范围)。
返回:本月生产流转 / 本月已入库 / 本月已出库 / 本月返厂回流。 返回:本月生产流转 / 本月已入库 / 本月已出库 / 本月返厂回流。
统计区间为北京时间当月 1 日 00:00 至此刻。 统计区间为北京时间当月 1 日 00:00 至此刻。
""" """
return await get_monthly_metrics(db) return await get_monthly_metrics(db, scope)
@router.get("/wip-distribution", response_model=WipDistributionResponse) @router.get("/wip-distribution", response_model=WipDistributionResponse)
async def wip_distribution(db: AsyncSession = Depends(get_db)): async def wip_distribution(
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
""" """
工序积压分布 — 当前未完结设备按 overall_status 聚合的**纯数量**。 工序积压分布 — 当前数据范围内未完结设备按 overall_status 聚合的**纯数量**。
返回固定阶段列表(含 0 值),保证柱状图类目稳定、不因缺数据而塌陷。 返回固定阶段列表(含 0 值),保证柱状图类目稳定、不因缺数据而塌陷;
工序柱本身也按数据范围裁剪(不属于本组阶段的工序不画)。
""" """
return await get_wip_distribution(db) return await get_wip_distribution(db, scope)
@router.get("/active-users", response_model=ActiveUsersResponse) @router.get("/active-users", response_model=ActiveUsersResponse)
async def active_users( async def active_users(
top_n: int = Query(5, ge=1, le=20, description="返回的活跃人员数量"), top_n: int = Query(5, ge=1, le=20, description="返回的活跃人员数量"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
): ):
""" """
本月系统使用活跃度排行 — 接收 / 转交 / 上传备注次数。 本月系统使用活跃度排行 — 接收 / 转交 / 上传备注次数(按当前用户的数据范围)。
桥接 /dashboard/user-operations 的统计口径,仅返回本月确实有操作的人员。 桥接 /dashboard/user-operations 的统计口径,仅返回本月确实有操作的人员。
""" """
return await get_active_users(db, top_n=top_n) return await get_active_users(db, scope, top_n=top_n)

View File

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

View File

@ -1,4 +1,9 @@
"""Dashboard 统计服务 — 上帝视角(全厂全系统数据,不按用户过滤)""" """Dashboard 统计服务 — 按当前用户的**业务分组数据范围**过滤(超管不受限)
2026-09 起不再是「上帝视角」:统计函数一律接收调用方解析好的 DataScope,
查询里按 Product.lifecycle_phase 过滤(谓词只在 data_scope_service 生成,见该模块红线)。
唯一例外见 get_dashboard_stats 的 unread_notif(刻意不过滤,理由就地写明)。
"""
from datetime import datetime from datetime import datetime
from sqlalchemy import select, func, or_ from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -6,6 +11,7 @@ from pydantic import BaseModel
from app.core.config import settings from app.core.config import settings
from app.core.lifecycle import normalize_after_sales_step from app.core.lifecycle import normalize_after_sales_step
from app.services.data_scope_service import DataScope
# ============================================================ # ============================================================
@ -242,11 +248,12 @@ def _rework_visible_condition(
async def get_dashboard_stats( async def get_dashboard_stats(
db: AsyncSession, db: AsyncSession,
scope: DataScope,
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None, until: datetime | None = None,
) -> DashboardStats: ) -> DashboardStats:
""" """
上帝视角 — 全厂全系统统计。 看板统计 — 按当前用户的业务分组数据范围过滤。
时间筛选规则: 时间筛选规则:
- PENDING / WIP / 总数:永远忽略时间筛选,返回实时快照。 - PENDING / WIP / 总数:永远忽略时间筛选,返回实时快照。
@ -305,36 +312,63 @@ async def get_dashboard_stats(
select(Task.id).where(*done_time_conds).exists(), select(Task.id).where(*done_time_conds).exists(),
) )
p_unfinished = await db.scalar(select(func.count(Product.id)).where(not_finished)) or 0 # 🚀 业务分组数据范围:产品三态计数均以 Product 为主体,直接挂 product_where
p_progress = await db.scalar( p_unfinished = await db.scalar(
select(func.count(Product.id)).where(not_finished, has_active_task) select(func.count(Product.id)).where(not_finished, scope.product_where())
) or 0
p_progress = await db.scalar(
select(func.count(Product.id)).where(not_finished, has_active_task, scope.product_where())
) or 0
p_done = await db.scalar(
select(func.count(Product.id)).where(done_in_range, scope.product_where())
) or 0 ) or 0
p_done = await db.scalar(select(func.count(Product.id)).where(done_in_range)) or 0
# 待流转 = 未完结 - 在制;展示总数 = 未完结 + 所选时段已完结 # 待流转 = 未完结 - 在制;展示总数 = 未完结 + 所选时段已完结
# (三段互斥,保证进度条总和 = 展示总数) # (三段互斥,保证进度条总和 = 展示总数)
p_pending = max(p_unfinished - p_progress, 0) p_pending = max(p_unfinished - p_progress, 0)
p_total = p_unfinished + p_done p_total = p_unfinished + p_done
# ── 任务实时快照(PENDING/WIP — 永远不过滤) ── # ── 任务实时快照(PENDING/WIP — 永不受时间筛选,但受业务分组数据范围约束) ──
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING)) # 🚀 业务分组数据范围:Task 为主体,先 join Product 再 task_where(tasks 表无 lifecycle_phase)
t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_WIP)) t_pending = await db.scalar(
select(func.count(Task.id))
.join(Product, Task.product_id == Product.id)
.where(Task.status == TASK_STATUS_PENDING, scope.task_where())
)
t_progress = await db.scalar(
select(func.count(Task.id))
.join(Product, Task.product_id == Product.id)
.where(Task.status == TASK_STATUS_WIP, scope.task_where())
)
# 返工数不再是无边界的全时段快照:未完结卡点永远计入,已了结的只算本月。 # 返工数不再是无边界的全时段快照:未完结卡点永远计入,已了结的只算本月。
# 与 get_rejected_tasks 的 kind="rework" 共用同一份可见性条件, # 与 get_rejected_tasks 的 kind="rework" 共用同一份可见性条件,
# 保证「品质异常」卡片数字与点开后的明细条数对得上。 # 保证「品质异常」卡片数字与点开后的明细条数对得上。
from app.services.screen_service import _month_bounds from app.services.screen_service import _month_bounds
month_start_utc, _, _ = _month_bounds() month_start_utc, _, _ = _month_bounds()
# 🚀 业务分组数据范围:Task 为主体,先 join Product 再 task_where
t_rework = await db.scalar( t_rework = await db.scalar(
select(func.count(Task.id)).where( select(func.count(Task.id))
.join(Product, Task.product_id == Product.id)
.where(
Task.is_rework.is_(True), Task.is_rework.is_(True),
_rework_visible_condition(month_start_utc, since, until), _rework_visible_condition(month_start_utc, since, until),
scope.task_where(),
) )
) )
# ── 任务已完成/驳回(时间可过滤) ── # ── 任务已完成/驳回(时间可过滤) ──
t_done_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED) # 🚀 业务分组数据范围:同上,两条独立语句各自挂 task_where
t_rej_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_REJECTED) t_done_q = (
select(func.count(Task.id))
.join(Product, Task.product_id == Product.id)
.where(Task.status == TASK_STATUS_COMPLETED, scope.task_where())
)
t_rej_q = (
select(func.count(Task.id))
.join(Product, Task.product_id == Product.id)
.where(Task.status == TASK_STATUS_REJECTED, scope.task_where())
)
if since: if since:
t_done_q = t_done_q.where(Task.completed_at >= since) t_done_q = t_done_q.where(Task.completed_at >= since)
t_rej_q = t_rej_q.where(Task.completed_at >= since) t_rej_q = t_rej_q.where(Task.completed_at >= since)
@ -348,10 +382,19 @@ async def get_dashboard_stats(
t_total = (t_pending or 0) + (t_progress or 0) + (t_done or 0) + (t_rejected or 0) t_total = (t_pending or 0) + (t_progress or 0) + (t_done or 0) + (t_rejected or 0)
# ── 通知 & 留言(实时快照) ── # ── 通知 & 留言(实时快照) ──
# 🚀 业务分组数据范围:**有意不过滤** —— Notification.task_id 允许为空
# (ondelete=SET NULL,且留言提醒类通知本就不挂任务),按 Product 过滤会
# 把这些「无任务关联」的提醒整条漏掉,未读角标会凭空少几颗。
# 这是刻意为之,不要「顺手补上」过滤。
unread_notif = await db.scalar( unread_notif = await db.scalar(
select(func.count(Notification.id)).where(Notification.is_read.is_(False)) select(func.count(Notification.id)).where(Notification.is_read.is_(False))
) )
unread_msg = await db.scalar(select(func.count(ProductMessage.id))) # 🚀 业务分组数据范围:ProductMessage.product_id 非空,join Product 后按阶段过滤
unread_msg = await db.scalar(
select(func.count(ProductMessage.id))
.join(Product, ProductMessage.product_id == Product.id)
.where(scope.product_where())
)
return DashboardStats( return DashboardStats(
products_total=p_total or 0, products_total=p_total or 0,
@ -482,8 +525,8 @@ async def get_my_stats(
# 在制品看板(永远实时) # 在制品看板(永远实时)
# ============================================================ # ============================================================
async def get_wip_tasks(db: AsyncSession, limit: int = 500) -> list[WipTask]: async def get_wip_tasks(db: AsyncSession, scope: DataScope, limit: int = 500) -> list[WipTask]:
"""在制品看板 — 永远实时的 PENDING/WIP 任务。 """在制品看板 — 永远实时的 PENDING/WIP 任务(按业务分组数据范围过滤)。
默认返回**全部**活跃任务,limit 只是防御性安全上限。 默认返回**全部**活跃任务,limit 只是防御性安全上限。
@ -502,10 +545,14 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 500) -> list[WipTask]:
holidays = {r[0] for r in hres} holidays = {r[0] for r in hres}
# 不在 SQL 层做预取限制:排序在内存里按滞留时长进行,截断只作为上限保护 # 不在 SQL 层做预取限制:排序在内存里按滞留时长进行,截断只作为上限保护
# 🚀 业务分组数据范围:已 join Product,直接挂 task_where
stmt = ( stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id) .join(Product, Task.product_id == Product.id)
.where(Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP])) .where(
Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]),
scope.task_where(),
)
) )
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.all() rows = result.all()
@ -551,19 +598,24 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 500) -> list[WipTask]:
async def get_completed_tasks( async def get_completed_tasks(
db: AsyncSession, db: AsyncSession,
scope: DataScope,
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None, until: datetime | None = None,
limit: int = 200, limit: int = 200,
) -> list[CompletedTask]: ) -> list[CompletedTask]:
"""按时段查询已完成任务明细(上帝视角),用于「流转完成率」卡片下钻。""" """按时段查询已完成任务明细(按业务分组数据范围过滤),用于「流转完成率」卡片下钻。
与 get_dashboard_stats 的 t_done 同一口径 + 同一数据范围,保证点进来条数对得上。
"""
from app.models.task import Task, TASK_STATUS_COMPLETED from app.models.task import Task, TASK_STATUS_COMPLETED
from app.models.product import Product from app.models.product import Product
from app.core.time_utils import BEIJING_TZ from app.core.time_utils import BEIJING_TZ
# 🚀 业务分组数据范围:已 join Product,直接挂 task_where
stmt = ( stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id) .join(Product, Task.product_id == Product.id)
.where(Task.status == TASK_STATUS_COMPLETED) .where(Task.status == TASK_STATUS_COMPLETED, scope.task_where())
) )
if since: if since:
stmt = stmt.where(Task.completed_at >= since) stmt = stmt.where(Task.completed_at >= since)
@ -608,11 +660,15 @@ async def get_completed_tasks(
async def get_rejected_tasks( async def get_rejected_tasks(
db: AsyncSession, db: AsyncSession,
scope: DataScope,
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None, until: datetime | None = None,
limit: int = 200, limit: int = 200,
) -> list[RejectedTask]: ) -> list[RejectedTask]:
"""查询驳回/返工任务明细(上帝视角),用于「驳回/返工」卡片下钻。 """查询驳回/返工任务明细(按业务分组数据范围过滤),用于「驳回/返工」卡片下钻。
⚠️ 两条语句(已驳回 / 返工)都要挂同一个 task_where,否则与
get_dashboard_stats 的 tasks_rejected + tasks_rework 卡片数字对不上。
返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致): 返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致):
- kind="rejected":被驳回任务,按 completed_at 时间过滤 - kind="rejected":被驳回任务,按 completed_at 时间过滤
@ -641,10 +697,11 @@ async def get_rejected_tasks(
return dt.isoformat() return dt.isoformat()
# ── 1. 已驳回任务(按时间过滤)── # ── 1. 已驳回任务(按时间过滤)──
# 🚀 业务分组数据范围:已 join Product,直接挂 task_where
stmt = ( stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id) .join(Product, Task.product_id == Product.id)
.where(Task.status == TASK_STATUS_REJECTED) .where(Task.status == TASK_STATUS_REJECTED, scope.task_where())
) )
if since: if since:
stmt = stmt.where(Task.completed_at >= since) stmt = stmt.where(Task.completed_at >= since)
@ -659,6 +716,7 @@ async def get_rejected_tasks(
# 统一约束 —— 未完结卡点永远展示,已了结的仅限本月(详见该函数说明)。 # 统一约束 —— 未完结卡点永远展示,已了结的仅限本月(详见该函数说明)。
from app.services.screen_service import _month_bounds from app.services.screen_service import _month_bounds
month_start_utc, _, _ = _month_bounds() month_start_utc, _, _ = _month_bounds()
# 🚀 业务分组数据范围:同口径挂 task_where,保证与卡片 tasks_rework 一致
rework_stmt = ( rework_stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id) .join(Product, Task.product_id == Product.id)
@ -666,6 +724,7 @@ async def get_rejected_tasks(
Task.is_rework.is_(True), Task.is_rework.is_(True),
Task.status != TASK_STATUS_REJECTED, Task.status != TASK_STATUS_REJECTED,
_rework_visible_condition(month_start_utc, since, until), _rework_visible_condition(month_start_utc, since, until),
scope.task_where(),
) )
.order_by(Task.created_at.desc()) .order_by(Task.created_at.desc())
.limit(limit) .limit(limit)
@ -710,9 +769,16 @@ async def get_rejected_tasks(
rework_candidates: dict = {} rework_candidates: dict = {}
product_ids = {t.product_id for t, *_ in rejected_rows if t.product_id} product_ids = {t.product_id for t, *_ in rejected_rows if t.product_id}
if product_ids: if product_ids:
# 🚀 业务分组数据范围:本查询也是 Task 主体,一并挂 task_where
# (product_ids 已来自上方受限结果,这里只是把口径写全,结果集不变)
cand_rows = await db.execute( cand_rows = await db.execute(
select(Task.parent_task_id, Task.product_id, Task.created_at, Task.assignee_id) select(Task.parent_task_id, Task.product_id, Task.created_at, Task.assignee_id)
.where(Task.is_rework.is_(True), Task.product_id.in_(product_ids)) .join(Product, Task.product_id == Product.id)
.where(
Task.is_rework.is_(True),
Task.product_id.in_(product_ids),
scope.task_where(),
)
.order_by(Task.created_at.asc()) .order_by(Task.created_at.asc())
) )
for parent_id, prod_id, created_at, assignee_id in cand_rows.all(): for parent_id, prod_id, created_at, assignee_id in cand_rows.all():
@ -787,10 +853,11 @@ async def get_rejected_tasks(
async def get_user_operations( async def get_user_operations(
db: AsyncSession, db: AsyncSession,
scope: DataScope,
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None, until: datetime | None = None,
) -> list[UserOperation]: ) -> list[UserOperation]:
"""上帝视角 — 统计**全部人员**的操作次数(按时段过滤)。 """统计**当前数据范围内人员**的操作次数(按时段过滤)。
返回本部门(ORG_DEPARTMENT)全部人员(该时段无操作的计 0),并追加有操作 返回本部门(ORG_DEPARTMENT)全部人员(该时段无操作的计 0),并追加有操作
记录但不在人员清单中的账号(如历史/已离职)。 记录但不在人员清单中的账号(如历史/已离职)。
@ -803,17 +870,24 @@ async def get_user_operations(
""" """
from app.models.task_log import TaskLog from app.models.task_log import TaskLog
from app.models.task import Task, TaskRecord from app.models.task import Task, TaskRecord
from app.models.product import Product
from app.core.mom_database import MomSessionLocal from app.core.mom_database import MomSessionLocal
from sqlalchemy import text, or_ from sqlalchemy import text, or_
# ── 1. 接收 / 转交(task_logs 按 operator_id 聚合)── # ── 1. 接收 / 转交(task_logs 按 operator_id 聚合)──
rcv = func.count().filter(TaskLog.action_type == "receive") # ⚠️ 必须指明具体列:为接入数据范围本查询已 join Product,若写裸 count()
cpl = func.count().filter(TaskLog.action_type == "complete") # 会按 join 出的行数重复计数,卡片数字虚高。
rcv = func.count(TaskLog.id).filter(TaskLog.action_type == "receive")
cpl = func.count(TaskLog.id).filter(TaskLog.action_type == "complete")
# 🚀 业务分组数据范围:TaskLog → Task → Product 两级 join 后挂 task_where
stmt = ( stmt = (
select(TaskLog.operator_id, rcv.label("receive"), cpl.label("complete")) select(TaskLog.operator_id, rcv.label("receive"), cpl.label("complete"))
.join(Task, TaskLog.task_id == Task.id)
.join(Product, Task.product_id == Product.id)
.where( .where(
TaskLog.action_type.in_(["receive", "complete"]), TaskLog.action_type.in_(["receive", "complete"]),
TaskLog.operator_id.isnot(None), TaskLog.operator_id.isnot(None),
scope.task_where(),
) )
.group_by(TaskLog.operator_id) .group_by(TaskLog.operator_id)
) )
@ -825,12 +899,15 @@ async def get_user_operations(
op_map = {r[0]: (r[1] or 0, r[2] or 0) for r in op_rows} op_map = {r[0]: (r[1] or 0, r[2] or 0) for r in op_rows}
# ── 2. 上传备注(task_records 按任务 assignee 归因,排除系统自动备注)── # ── 2. 上传备注(task_records 按任务 assignee 归因,排除系统自动备注)──
# 🚀 业务分组数据范围:TaskRecord 经 Task 关联到 Product 后挂 task_where
rcd_stmt = ( rcd_stmt = (
select(Task.assignee_id, func.count(TaskRecord.id)) select(Task.assignee_id, func.count(TaskRecord.id))
.join(Task, TaskRecord.task_id == Task.id) .join(Task, TaskRecord.task_id == Task.id)
.join(Product, Task.product_id == Product.id)
.where( .where(
Task.assignee_id.isnot(None), Task.assignee_id.isnot(None),
or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")), or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")),
scope.task_where(),
) )
.group_by(Task.assignee_id) .group_by(Task.assignee_id)
) )
@ -896,11 +973,12 @@ async def get_user_operations(
async def get_wip_matrix( async def get_wip_matrix(
db: AsyncSession, db: AsyncSession,
scope: DataScope,
dimension: str = "assignee", dimension: str = "assignee",
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None, until: datetime | None = None,
) -> list[WipMatrixRow]: ) -> list[WipMatrixRow]:
"""生产分布透视表:Y=规格型号,X=人员 或 工序,单元格=设备数量。 """生产分布透视表:Y=规格型号,X=人员 或 工序,单元格=设备数量(按业务分组数据范围过滤)。
核心口径:**每台设备只统计一次**,按它「当前所处工序」归属—— 核心口径:**每台设备只统计一次**,按它「当前所处工序」归属——
1. 取该设备最新的一条主分支任务(parent_task_id IS NULL 或 TRANSFER/RECOVERY) 1. 取该设备最新的一条主分支任务(parent_task_id IS NULL 或 TRANSFER/RECOVERY)
@ -927,6 +1005,9 @@ async def get_wip_matrix(
_ActiveTask = aliased(Task, name="active_task") _ActiveTask = aliased(Task, name="active_task")
# 每台设备按主任务创建时间倒序,取第一条即「最新主任务」 # 每台设备按主任务创建时间倒序,取第一条即「最新主任务」
# 🚀 业务分组数据范围:主体是 Product(outerjoin tasks 不影响主体),挂 product_where。
# 矩阵格子的设备数与被过滤掉的设备在这里一次性拦掉,下钻(get_wip_matrix_detail)
# 必须用同一谓词,否则「格子里有数、点进去是空的」。
result = await db.execute( result = await db.execute(
select( select(
Product.id, Product.id,
@ -957,7 +1038,8 @@ async def get_wip_matrix(
Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收) Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收)
Task.parent_task_id.is_(None), Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]), Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]),
) ),
scope.product_where(), # 🚀 业务分组数据范围
) )
.order_by(Product.id, Task.created_at.desc()) .order_by(Product.id, Task.created_at.desc())
) )
@ -1104,6 +1186,7 @@ async def get_wip_matrix(
async def get_wip_matrix_detail( async def get_wip_matrix_detail(
db: AsyncSession, db: AsyncSession,
scope: DataScope,
spec_model: str, spec_model: str,
process: str, process: str,
since: datetime | None = None, since: datetime | None = None,
@ -1113,6 +1196,7 @@ async def get_wip_matrix_detail(
口径与 get_wip_matrix 完全一致:每台设备取最新一条主分支任务, 口径与 get_wip_matrix 完全一致:每台设备取最新一条主分支任务,
按其在库三态(已完成/已入库/已出库)或工序名归类到 process。 按其在库三态(已完成/已入库/已出库)或工序名归类到 process。
数据范围也必须与 get_wip_matrix 同谓词,否则格子里有数、点进来却是空的。
""" """
from datetime import timezone as dt_timezone from datetime import timezone as dt_timezone
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
@ -1157,7 +1241,8 @@ async def get_wip_matrix_detail(
Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收) Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收)
Task.parent_task_id.is_(None), Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]), Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]),
) ),
scope.product_where(), # 🚀 业务分组数据范围(与 get_wip_matrix 同谓词)
) )
.order_by(Product.id, Task.created_at.desc()) .order_by(Product.id, Task.created_at.desc())
) )
@ -1269,13 +1354,16 @@ async def get_wip_matrix_detail(
import uuid as uuid_mod import uuid as uuid_mod
from sqlalchemy import func as sa_func from sqlalchemy import func as sa_func
uuid_list = [uuid_mod.UUID(m.product_id) for m in matched] uuid_list = [uuid_mod.UUID(m.product_id) for m in matched]
# 🚀 业务分组数据范围:本查询也是 Task 主体,一并挂 task_where
# (uuid_list 已来自上方受限结果,这里只是把口径写全,结果集不变)
range_rows = (await db.execute( range_rows = (await db.execute(
select( select(
Task.product_id, Task.product_id,
sa_func.min(sa_func.coalesce(Task.received_at, Task.created_at)).label("t0"), sa_func.min(sa_func.coalesce(Task.received_at, Task.created_at)).label("t0"),
sa_func.max(sa_func.coalesce(Task.completed_at, now)).label("tmax"), sa_func.max(sa_func.coalesce(Task.completed_at, now)).label("tmax"),
) )
.where(Task.product_id.in_(uuid_list)) .join(Product, Task.product_id == Product.id)
.where(Task.product_id.in_(uuid_list), scope.task_where())
.group_by(Task.product_id) .group_by(Task.product_id)
)).all() )).all()
row_by_pid = {m.product_id: m for m in matched} row_by_pid = {m.product_id: m for m in matched}
@ -1297,10 +1385,13 @@ async def get_user_operation_detail(
db: AsyncSession, db: AsyncSession,
user_id: str, user_id: str,
action_type: str, action_type: str,
scope: DataScope,
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None, until: datetime | None = None,
) -> list[OperationDetail]: ) -> list[OperationDetail]:
"""查询某人在指定时段内的某类操作明细。 """查询某人在指定时段内的某类操作明细(按业务分组数据范围过滤)。
与 get_user_operations 的数字同口径,否则「点数字看到的条数」对不上。
action_type: action_type:
- receive: 接收(task_logs.action_type='receive') - receive: 接收(task_logs.action_type='receive')
@ -1326,11 +1417,16 @@ async def get_user_operation_detail(
if action_type in ("receive", "transfer"): if action_type in ("receive", "transfer"):
act = "receive" if action_type == "receive" else "complete" act = "receive" if action_type == "receive" else "complete"
# 🚀 业务分组数据范围:TaskLog 已 join Task 与 Product,直接挂 task_where
stmt = ( stmt = (
select(TaskLog.created_at, Task.task_name, Product.serial_number, Product.material_name, TaskLog.remark) select(TaskLog.created_at, Task.task_name, Product.serial_number, Product.material_name, TaskLog.remark)
.join(Task, TaskLog.task_id == Task.id) .join(Task, TaskLog.task_id == Task.id)
.join(Product, Task.product_id == Product.id) .join(Product, Task.product_id == Product.id)
.where(TaskLog.operator_id == user_id, TaskLog.action_type == act) .where(
TaskLog.operator_id == user_id,
TaskLog.action_type == act,
scope.task_where(),
)
.order_by(TaskLog.created_at.desc()) .order_by(TaskLog.created_at.desc())
) )
if since: if since:
@ -1354,6 +1450,7 @@ async def get_user_operation_detail(
.where( .where(
Task.assignee_id == user_id, Task.assignee_id == user_id,
or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")), or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")),
scope.task_where(), # 🚀 业务分组数据范围(已 join Product)
) )
.order_by(TaskRecord.created_at.desc()) .order_by(TaskRecord.created_at.desc())
) )
@ -1377,8 +1474,11 @@ async def get_user_operation_detail(
# 人员负载(按人聚合在制品设备 — 独立「人员看板」) # 人员负载(按人聚合在制品设备 — 独立「人员看板」)
# ============================================================ # ============================================================
async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]: async def get_people_workload(db: AsyncSession, scope: DataScope) -> list[PersonWorkload]:
"""上帝视角 — 按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重,含滞留时长)。""" """按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重,含滞留时长)。
按业务分组数据范围过滤。
"""
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
from app.models.product import Product from app.models.product import Product
from app.models.holiday import Holiday from app.models.holiday import Holiday
@ -1399,6 +1499,7 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
.where( .where(
Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]), Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]),
Task.assignee_id.isnot(None), Task.assignee_id.isnot(None),
scope.task_where(), # 🚀 业务分组数据范围(已 join Product)
) )
.order_by(Task.assignee_id, Product.created_at.desc()) .order_by(Task.assignee_id, Product.created_at.desc())
) )
@ -1473,6 +1574,7 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
async def get_people_history( async def get_people_history(
db: AsyncSession, db: AsyncSession,
scope: DataScope,
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None, until: datetime | None = None,
assignee_id: str | None = None, assignee_id: str | None = None,
@ -1480,7 +1582,12 @@ async def get_people_history(
product_sn: str | None = None, product_sn: str | None = None,
task_name: str | None = None, task_name: str | None = None,
) -> list[PersonHistoryRecord]: ) -> list[PersonHistoryRecord]:
"""上帝视角 — 人员效能与工时台账(平铺 Task 明细,含 WIP/PENDING/COMPLETED)。""" """人员效能与工时台账(平铺 Task 明细,含 WIP/PENDING/COMPLETED,按业务分组数据范围过滤)。
⚠️ 两个子查询(最新有效备注 / 记录总数)只扫 task_records,靠下面的 outerjoin
挂到本语句的 Task 上,会被主查询的 WHERE(含数据范围谓词)一并收窄,
故无需在子查询内部再过滤一次。
"""
from app.models.task import Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED from app.models.task import Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED
from app.models.product import Product from app.models.product import Product
from app.core.time_utils import get_beijing_time, BEIJING_TZ, to_beijing, working_duration_hours from app.core.time_utils import get_beijing_time, BEIJING_TZ, to_beijing, working_duration_hours
@ -1554,6 +1661,7 @@ async def get_people_history(
.where( .where(
Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]), Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]),
Task.assignee_id.isnot(None), Task.assignee_id.isnot(None),
scope.task_where(), # 🚀 业务分组数据范围(已 join Product)
) )
) )
@ -1642,12 +1750,13 @@ async def get_people_history(
async def search_product_messages( async def search_product_messages(
db: AsyncSession, db: AsyncSession,
scope: DataScope,
keyword: str = "", keyword: str = "",
skip: int = 0, skip: int = 0,
limit: int = 50, limit: int = 50,
) -> ProductMessageList: ) -> ProductMessageList:
""" """
上帝视角 — 全厂所有产品的协同留言。 协同留言搜索 — 只返回当前业务分组数据范围内产品的留言。
关联链: ProductMessage → Product → (material_name, serial_number) 关联链: ProductMessage → Product → (material_name, serial_number)
搜索支持: SN码、物料名称、留言人 搜索支持: SN码、物料名称、留言人
@ -1658,9 +1767,12 @@ async def search_product_messages(
from app.core.time_utils import BEIJING_TZ from app.core.time_utils import BEIJING_TZ
# 基础查询 — 同时取出 16位追溯码 + 业务序列号 # 基础查询 — 同时取出 16位追溯码 + 业务序列号
# 🚀 业务分组数据范围:已 join Product,直接挂 product_where
# (下方 total 由本 stmt 派生子查询,自动继承同一谓词,条数不会对不上)
stmt = ( stmt = (
select(ProductMessage, Product.serial_number, Product.material_name, Product.external_serial) select(ProductMessage, Product.serial_number, Product.material_name, Product.external_serial)
.join(Product, ProductMessage.product_id == Product.id) .join(Product, ProductMessage.product_id == Product.id)
.where(scope.product_where())
) )
# 关键词搜索 # 关键词搜索

View File

@ -10,6 +10,9 @@
视角说明: 视角说明:
管理层每天要看的是「这个月干得怎么样、现在卡在哪、系统有没有在跑」, 管理层每天要看的是「这个月干得怎么样、现在卡在哪、系统有没有在跑」,
而不是历史品质排名。故本模块聚焦三件事 —— 当月吞吐、当前卡点、使用活跃度。 而不是历史品质排名。故本模块聚焦三件事 —— 当月吞吐、当前卡点、使用活跃度。
2026-09 起不再是「上帝视角」:统计函数一律接收调用方解析好的 DataScope,
查询里按 Product.lifecycle_phase 过滤(谓词只在 data_scope_service 生成,见该模块红线)。
""" """
from __future__ import annotations from __future__ import annotations
@ -23,11 +26,13 @@ from app.core.lifecycle import (
AFTER_SALES_ONLY_STEPS, AFTER_SALES_ONLY_STEPS,
LIFECYCLE_AFTER_SALES, LIFECYCLE_AFTER_SALES,
LIFECYCLE_PRODUCTION, LIFECYCLE_PRODUCTION,
allowed_steps,
) )
from app.core.time_utils import get_beijing_time from app.core.time_utils import get_beijing_time
from app.models.product import Product from app.models.product import Product
from app.models.task import Task from app.models.task import Task
from app.models.task_log import TaskLog from app.models.task_log import TaskLog
from app.services.data_scope_service import DataScope
# 已完结的宏观状态 —— 与 dashboard_service.get_dashboard_stats 同口径 # 已完结的宏观状态 —— 与 dashboard_service.get_dashboard_stats 同口径
FINISHED_OVERALL = ("待仓库收货", "已入库", "在库", "已出库") FINISHED_OVERALL = ("待仓库收货", "已入库", "在库", "已出库")
@ -78,8 +83,8 @@ class MonthlyMetrics(BaseModel):
month_returned: int # 本月进入售后/回流状态的设备数 month_returned: int # 本月进入售后/回流状态的设备数
async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics: async def get_monthly_metrics(db: AsyncSession, scope: DataScope) -> MonthlyMetrics:
"""当月吞吐四联指标。 """当月吞吐四联指标(按当前用户的业务分组数据范围过滤)。
口径: 口径:
- month_production:lifecycle_phase = PRODUCTION,且「本月新建」或 - month_production:lifecycle_phase = PRODUCTION,且「本月新建」或
@ -90,6 +95,9 @@ async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics:
- month_returned:本月创建过售后专属工序任务(发货测试 / 售后维修)的 - month_returned:本月创建过售后专属工序任务(发货测试 / 售后维修)的
设备数(去重)。Product 表无 updated_at,无法直接查「转为 AFTER_SALES 设备数(去重)。Product 表无 updated_at,无法直接查「转为 AFTER_SALES
的时刻」,故以售后工序任务的创建时间作为进入售后阶段的时间锚点。 的时刻」,故以售后工序任务的创建时间作为进入售后阶段的时间锚点。
⚠️ month_production 上两条口径叠加(phase=PRODUCTION AND phase IN 范围),
维修组的本月生产数自然为 0 —— 这正是正确行为,不要为它加特判绕开范围。
""" """
month_start_utc, now_utc, month_label = _month_bounds() month_start_utc, now_utc, month_label = _month_bounds()
@ -103,24 +111,33 @@ async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics:
) )
.exists() .exists()
) )
# 🚀 业务分组数据范围:主体 Product,直接追加 product_where
month_production = await db.scalar( month_production = await db.scalar(
select(func.count(func.distinct(Product.id))).where( select(func.count(func.distinct(Product.id))).where(
Product.lifecycle_phase == LIFECYCLE_PRODUCTION, Product.lifecycle_phase == LIFECYCLE_PRODUCTION,
or_(Product.created_at >= month_start_utc, has_activity), or_(Product.created_at >= month_start_utc, has_activity),
scope.product_where(),
) )
) or 0 ) or 0
async def _count_log(action_type: str) -> int: async def _count_log(action_type: str) -> int:
# 🚀 业务分组数据范围:TaskLog 无阶段字段,两级 join 到 Product 后挂 task_where
# (计数列已是 TaskLog.id,join 不会放大行数)
return await db.scalar( return await db.scalar(
select(func.count(TaskLog.id)).where( 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.action_type == action_type,
TaskLog.created_at >= month_start_utc, TaskLog.created_at >= month_start_utc,
scope.task_where(),
) )
) or 0 ) or 0
month_inbound = await _count_log(LOG_WAREHOUSE_INBOUND) month_inbound = await _count_log(LOG_WAREHOUSE_INBOUND)
month_outbound = await _count_log(LOG_WAREHOUSE_OUTBOUND) month_outbound = await _count_log(LOG_WAREHOUSE_OUTBOUND)
# 🚀 业务分组数据范围:已 join Product,直接挂 product_where
month_returned = await db.scalar( month_returned = await db.scalar(
select(func.count(func.distinct(Task.product_id))) select(func.count(func.distinct(Task.product_id)))
.join(Product, Task.product_id == Product.id) .join(Product, Task.product_id == Product.id)
@ -128,6 +145,7 @@ async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics:
Product.lifecycle_phase == LIFECYCLE_AFTER_SALES, Product.lifecycle_phase == LIFECYCLE_AFTER_SALES,
Task.task_name.in_(tuple(AFTER_SALES_ONLY_STEPS)), Task.task_name.in_(tuple(AFTER_SALES_ONLY_STEPS)),
Task.created_at >= month_start_utc, Task.created_at >= month_start_utc,
scope.product_where(),
) )
) or 0 ) or 0
@ -170,17 +188,50 @@ WIP_STAGES: tuple[tuple[str, str], ...] = (
# 「待启动」对应的真实分组键(overall_status IS NULL / 空串) # 「待启动」对应的真实分组键(overall_status IS NULL / 空串)
_UNSTARTED_KEY = "" _UNSTARTED_KEY = ""
# 词表内工序 → 声明的阶段(补画「有数但被范围裁掉」的工序时,用它还原分区着色)
_STAGE_PHASE: dict[str, str] = {
(_UNSTARTED_KEY if s == "待启动" else s): p for s, p in WIP_STAGES
}
async def get_wip_distribution(db: AsyncSession) -> WipDistributionResponse:
"""工序积压分布 —— 当前未完结设备按 overall_status 聚合的数量。 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 值),保证大屏布局稳定、柱子不因某天缺数据而 返回**固定阶段列表**(含 0 值),保证大屏布局稳定、柱子不因某天缺数据而
整根消失;同时把数据里出现但不在词表内的状态追加在末尾,确保 items 的 整根消失;同时把数据里出现但不在词表内的状态追加在末尾,确保 items 的
count 之和恒等于 total(避免统计悄悄漏数)。 count 之和恒等于 total(避免统计悄悄漏数)。
⚠️ 「恒 0 才裁、有数必现」是裁剪的铁律:被 `_visible_stages` 裁掉的工序若
名下**真有设备**(如上个阶段的设备卡在旧工序名上没流转走),它会带着
真实计数回到列表末尾 —— 宁可多画一根柱子,也绝不把设备藏起来变成
「合计 1 台、柱子全是 0」的鬼故事。恒 0 的柱子才真正不画。
""" """
# 🚀 业务分组数据范围:主体 Product,直接挂 product_where(计数列是 Product.id,无 join 放大)
rows = await db.execute( rows = await db.execute(
select(Product.overall_status, func.count(Product.id)) select(Product.overall_status, func.count(Product.id))
.where(_not_finished()) .where(_not_finished(), scope.product_where())
.group_by(Product.overall_status) .group_by(Product.overall_status)
) )
counts: dict[str, int] = {} counts: dict[str, int] = {}
@ -190,17 +241,22 @@ async def get_wip_distribution(db: AsyncSession) -> WipDistributionResponse:
total = sum(counts.values()) total = sum(counts.values())
# 🚀 业务分组数据范围:只画本组阶段的工序柱(范围不限时即 WIP_STAGES 原样)
stages = _visible_stages(scope)
items: list[WipStage] = [] items: list[WipStage] = []
for stage, phase in WIP_STAGES: for stage, phase in stages:
key = _UNSTARTED_KEY if stage == "待启动" else stage key = _UNSTARTED_KEY if stage == "待启动" else stage
items.append(WipStage(stage=stage, phase=phase, count=counts.get(key, 0))) 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} # 词表外的状态追加在末尾。known_keys 只按**可见**柱子算:被裁掉的工序若名下
# 真有设备,就走这条追加路径回来(阶段着色按词表还原),保证合计不漏数;
# cnt 为 0 的则被下面的 `and cnt` 挡掉 —— 那才是本次裁剪要除掉的那排空柱。
known_keys = {_UNSTARTED_KEY if s == "待启动" else s for s, _ in stages}
for key, cnt in counts.items(): for key, cnt in counts.items():
if key not in known_keys and cnt: if key not in known_keys and cnt:
items.append(WipStage( items.append(WipStage(
stage=key or "待启动", stage=key or "待启动",
phase=LIFECYCLE_PRODUCTION, phase=_STAGE_PHASE.get(key, LIFECYCLE_PRODUCTION),
count=cnt, count=cnt,
)) ))
@ -225,17 +281,23 @@ class ActiveUsersResponse(BaseModel):
items: list[ActiveUser] items: list[ActiveUser]
async def get_active_users(db: AsyncSession, top_n: int = 5) -> ActiveUsersResponse: async def get_active_users(db: AsyncSession, scope: DataScope, top_n: int = 5) -> ActiveUsersResponse:
"""本月系统活跃度排行 —— 直接桥接 /dashboard/user-operations 的口径。 """本月系统活跃度排行 —— 直接桥接 /dashboard/user-operations 的口径。
不重复实现聚合逻辑:转交/接收取 task_logs(action_type = receive / complete), 不重复实现聚合逻辑:转交/接收取 task_logs(action_type = receive / complete),
上传备注取 task_records(排除系统自动备注)。仅返回本月**确实有操作**的人员, 上传备注取 task_records(排除系统自动备注)。仅返回本月**确实有操作**的人员,
避免排行榜被一串 0 稀释 —— 领导要看到的是"系统真的有人在用"。 避免排行榜被一串 0 稀释 —— 领导要看到的是"系统真的有人在用"。
按当前用户的业务分组数据范围过滤:范围直接透传给 get_user_operations,
两处口径共用一份谓词,不在这里另抄一遍。
""" """
from app.services.dashboard_service import get_user_operations from app.services.dashboard_service import get_user_operations
month_start_utc, _now, month_label = _month_bounds() month_start_utc, _now, month_label = _month_bounds()
operations = await get_user_operations(db, since=month_start_utc, until=None) # 🚀 业务分组数据范围:由调用方解析好的真实 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] active = [op for op in operations if op.total > 0][:top_n]
return ActiveUsersResponse( return ActiveUsersResponse(