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

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

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

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

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

282 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Dashboard API — 管理看板
⚠️ 2026-09 起不再是「上帝视角」:所有端点都挂了 get_data_scope,
结果按当前用户的业务分组范围过滤(超管不受限)。
这同时把这些接口从**匿名可访问**变成了需要登录 —— 顺带堵上了
`/people-history/export` 匿名批量导出全员工时台账的已知风险。
"""
import io
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
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.services.dashboard_service import (
get_dashboard_stats, DashboardStats,
get_my_stats, MyStats,
get_wip_tasks, WipTask,
get_completed_tasks, CompletedTask,
get_rejected_tasks, RejectedTask,
get_user_operations, UserOperation,
get_user_operation_detail, OperationDetail,
get_wip_matrix, WipMatrixRow,
get_wip_matrix_detail, WipMatrixDetailRow,
get_people_workload, PersonWorkload,
get_people_history, PersonHistoryRecord,
search_product_messages, ProductMessageList,
)
router = APIRouter(prefix="/dashboard", tags=["管理看板"])
def _parse_bound(value: str | None) -> datetime | None:
"""解析 ISO 时间边界。
裸时间(无时区偏移)按**北京时间**解释 —— 否则会被当作服务器本地时间,
在边界上整体偏移 8 小时,出现「选了今日却统计到昨天下午」这类错位。
"""
if not value:
return None
dt = datetime.fromisoformat(value)
return dt.replace(tzinfo=BEIJING_TZ) if dt.tzinfo is None else dt
@router.get("/stats", response_model=DashboardStats)
async def dashboard_stats(
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""
统计概览(按当前用户的数据范围)。
时间筛选仅影响 COMPLETED / REJECTED 计数;
PENDING / WIP / 总数永远返回实时快照。
"""
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
return await get_dashboard_stats(db, scope, since=since_dt, until=until_dt)
@router.get("/my-stats", response_model=MyStats)
async def my_stats(
assignee_id: str = Query(..., description="负责人ID(移动端传当前登录用户 username)"),
since: str | None = Query(None, description="起始时间 ISO(含时区偏移,如 2026-09-01T00:00:00+08:00);缺省=本月 1 日"),
until: str | None = Query(None, description="截止时间 ISO(含时区偏移);缺省=此刻"),
db: AsyncSession = Depends(get_db),
):
"""
一线工人个人效能 — 移动端「工作统计」页,支持自选时段。
- 生产战绩(完成 / 被驳回 / 参与产品)按本人名下任务归因;
- 操作统计(接收 / 转交 / 上传备注)与 PC /dashboard/user-operations 严格同口径,
工人自查的数与主管看到的面板对得上。
⚠️ since/until 必须带时区偏移。移动端发的是北京时间 (+08:00),
不带偏移的裸字符串会被当成"本地时间"导致边界偏移 8 小时。
"""
return await get_my_stats(db, assignee_id, since=_parse_bound(since), until=_parse_bound(until))
@router.get("/wip-tasks", response_model=list[WipTask])
async def wip_tasks(
limit: int = Query(500, ge=1, le=1000, description="防御性安全上限;默认足以覆盖全部在制品"),
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""在制品看板 — 永远实时的 PENDING/WIP 任务(默认全量,不再按 20 条静默截断)"""
return await get_wip_tasks(db, scope, limit)
@router.get("/completed-tasks", response_model=list[CompletedTask])
async def completed_tasks(
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
until: str | None = Query(None, description="截止日期 ISO"),
limit: int = Query(200, ge=1, le=500),
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""流转完成率下钻 — 按时段查询已完成任务明细"""
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
return await get_completed_tasks(db, scope, since=since_dt, until=until_dt, limit=limit)
@router.get("/rejected-tasks", response_model=list[RejectedTask])
async def rejected_tasks(
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
until: str | None = Query(None, description="截止日期 ISO"),
limit: int = Query(200, ge=1, le=500),
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""驳回/返工下钻 — 按时段查询被驳回任务明细(含返工去向)"""
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
return await get_rejected_tasks(db, scope, since=since_dt, until=until_dt, limit=limit)
@router.get("/user-operations", response_model=list[UserOperation])
async def user_operations(
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""人员操作统计 — 按人聚合 接收/转交/上传备注 次数,按时段过滤"""
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
return await get_user_operations(db, scope, since=since_dt, until=until_dt)
@router.get("/user-operations/detail", response_model=list[OperationDetail])
async def user_operations_detail(
user_id: str = Query(..., description="人员ID(username)"),
action_type: str = Query(..., description="操作类型: receive/transfer/record"),
since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""人员操作明细下钻 — 某人在指定时段的接收/转交/上传备注明细"""
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
return await get_user_operation_detail(
db, user_id, action_type, scope, since=since_dt, until=until_dt,
)
@router.get("/wip-matrix", response_model=list[WipMatrixRow])
async def wip_matrix(
dimension: str = Query("assignee", description="聚合维度: assignee(人员) / task_name(工序)"),
since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""生产分布透视表 — 规格型号 × 人员/工序 的设备数量交叉聚合(含已完成/已入库/已出库)"""
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
return await get_wip_matrix(db, scope, dimension=dimension, since=since_dt, until=until_dt)
@router.get("/wip-matrix/detail", response_model=list[WipMatrixDetailRow])
async def wip_matrix_detail(
spec: str = Query(..., description="规格型号"),
process: str = Query(..., description="当前工序(dimension_key)"),
since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""WIP 矩阵单元格下钻 — 返回某 规格型号×工序 交叉点下的设备明细"""
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
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])
async def people_workload(
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""人员负载 — 按负责人聚合当前在制品设备数(独立人员看板)"""
return await get_people_workload(db, scope)
@router.get("/people-history", response_model=list[PersonHistoryRecord])
async def people_history(
since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"),
assignee_id: str | None = Query(None, description="负责人ID(精确)"),
spec_model: str | None = Query(None, description="规格型号(模糊)"),
product_sn: str | None = Query(None, description="身份证(模糊)"),
task_name: str | None = Query(None, description="任务名(模糊)"),
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""人员效能与工时台账 — 平铺 Task 明细,多维筛选 + 时间交集"""
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
return await get_people_history(
db, scope, since=since_dt, until=until_dt,
assignee_id=assignee_id, spec_model=spec_model,
product_sn=product_sn, task_name=task_name,
)
@router.get("/people-history/export")
async def export_people_history(
since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"),
assignee_id: str | None = Query(None, description="负责人ID(精确)"),
spec_model: str | None = Query(None, description="规格型号(模糊)"),
product_sn: str | None = Query(None, description="身份证(模糊)"),
task_name: str | None = Query(None, description="任务名(模糊)"),
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""导出工时台账为 Excel(与查询接口相同筛选条件)
⚠️ 本接口此前是**匿名可访问**的,能批量导出全员工时台账。挂上 get_data_scope
后既要求登录、又按业务分组范围过滤 —— 这是本次改造顺带堵上的已知风险。
"""
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
records = await get_people_history(
db, scope, since=since_dt, until=until_dt,
assignee_id=assignee_id, spec_model=spec_model,
product_sn=product_sn, task_name=task_name,
)
import csv
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["状态", "负责人", "身份证", "业务序列号", "产品名称", "规格型号", "任务名", "开始时间", "结束时间", "总耗时(小时)", "最新有效备注"])
status_label = {"WIP": "进行中", "PENDING": "待接收", "COMPLETED": "已完成"}
for r in records:
writer.writerow([
status_label.get(r.status, r.status),
r.assignee_name,
r.product_sn,
r.external_serial or "",
r.material_name,
r.spec_model,
r.task_name,
r.received_at or "",
r.completed_at or "进行中",
r.duration_hours,
r.latest_valid_remark or "",
])
data = output.getvalue().encode("utf-8-sig") # 带 BOM,Excel 正确识别中文
buf = io.BytesIO(data)
buf.seek(0)
return StreamingResponse(
buf,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=people_history.csv"},
)
@router.get("/messages", response_model=ProductMessageList)
async def dashboard_messages(
keyword: str = Query("", description="搜索: SN码/物料名/留言人/内容"),
skip: int = Query(0, ge=0),
limit: int = Query(30, ge=1, le=200),
db: AsyncSession = Depends(get_db),
scope: DataScope = Depends(get_data_scope),
):
"""
协同留言搜索(按当前用户的数据范围过滤)。
关联 Product 表返回 serial_number + material_name,
按时间倒序排列。
"""
return await search_product_messages(db, scope, keyword=keyword, skip=skip, limit=limit)