feat: 新增个人效能统计接口 /dashboard/my-stats(支持自选时段)

移动端「个人中心 → 工作统计」的数据源。现有接口拿不到这份数据:
/dashboard/people-history 支持 assignee_id 但只返回 WIP/PENDING/COMPLETED,
不含 REJECTED;/dashboard/rejected-tasks 则根本没有 assignee_id 参数 ——
「今日被驳回数」按现有接口无论如何都过滤不到个人。故补一个薄桥接端点。

服务端 get_my_stats(db, assignee_id, since, until) 返回两组指标:

生产战绩(按 Task.assignee_id 归因)
- tasks_completed / tasks_rejected:status 判定 + completed_at 落在区间,
  与 get_dashboard_stats 的 t_done_q 同一口径,只多了 assignee_id 过滤
- products_touched:按 product_id 去重,同一台设备做多道工序只算一台

操作统计(与 PC /dashboard/user-operations 严格同口径)
- receive/transfer:task_logs 的 receive / complete,按 operator_id 归因
- record:task_records 按 Task.assignee_id 归因,排除 '[' 开头的系统自动备注
- 前两项归因 operator_id、第三项归因 assignee_id 是 PC 端既有口径,
  此处刻意保持一致,便于工人自查的数与主管看到的面板对得上

端点侧新增 _parse_bound():裸时间字符串(无时区偏移)按北京时间解释,
否则会被当作服务器本地时间,边界整体偏 8 小时,出现「选了今日却统计到
昨天下午」。since/until 缺省为「本月 1 日 ~ 此刻」。
This commit is contained in:
2026-09-15 15:51:34 +08:00
parent 242a6d5463
commit 44ca09ae22
2 changed files with 167 additions and 0 deletions

View File

@ -5,8 +5,10 @@ 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.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,
@ -22,6 +24,18 @@ from app.services.dashboard_service import (
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"),
@ -39,6 +53,26 @@ async def dashboard_stats(
return await get_dashboard_stats(db, 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(20, ge=1, le=100),

View File

@ -26,6 +26,30 @@ class DashboardStats(BaseModel):
unread_messages: int = 0
class MyStats(BaseModel):
"""一线工人个人效能 — /dashboard/my-stats 桥接口径。
与 DashboardStats 的「上帝视角」不同,这里强制过滤到本人;
供移动端「个人中心 → 工作统计」使用,支持自选时段。
两组指标口径:
「生产战绩」按 Task.assignee_id 归因(我名下的活);
「操作统计」与 PC /dashboard/user-operations 完全一致,
便于工人自查看到的数与管理端面板对得上。
"""
assignee_id: str
# ── 生产战绩(所选时段内)──
tasks_completed: int = 0 # 完成数
tasks_rejected: int = 0 # 被驳回数(本人名下任务被驳回)
products_touched: int = 0 # 参与产品数(按 product_id 去重)
# ── 操作统计(与 PC /dashboard/user-operations 同一口径,所选时段内)──
receive_count: int = 0 # 接收次数
transfer_count: int = 0 # 转交次数
record_count: int = 0 # 上传备注次数
op_total: int = 0 # 操作总计 = 接收 + 转交 + 上传备注
class WipTask(BaseModel):
task_id: str
task_name: str
@ -245,6 +269,115 @@ async def get_dashboard_stats(
)
# ============================================================
# 个人效能(按 assignee_id 过滤到本人 — 移动端工作统计)
# ============================================================
async def get_my_stats(
db: AsyncSession,
assignee_id: str,
since: datetime | None = None,
until: datetime | None = None,
) -> MyStats:
"""一线工人个人效能:生产战绩 + 操作统计(均可按自选时段)。
时段缺省为「本月」;由调用方(移动端)传入北京时间边界。
【生产战绩】按 Task.assignee_id 归因:
- 完成 = status == COMPLETED 且 completed_at 落在区间内(与
get_dashboard_stats 的 t_done_q 同一口径,只多加了 assignee_id 过滤)
- 被驳回 = status == REJECTED。reject_task 只把状态改成 REJECTED 并写
completed_atassignee_id 仍是被驳回的那个人,直接过滤即可拿到
「我的活被驳了几次」
- 参与产品数按 product_id 去重 —— 同一台设备做多道工序只算一台
【操作统计】与 PC /dashboard/user-operations 严格同口径,便于工人自查的
数与主管看到的面板对得上:
- 接收 task_logs.action_type='receive' ,按 operator_id 归因
- 转交 task_logs.action_type='complete',按 operator_id 归因
- 上传备注 task_records 按 **Task.assignee_id** 归因,并排除以 '[' 开头的
系统自动备注(如 "[接收] 操作员已确认接收"
注意前两项归因于 operator_id、第三项归因于 assignee_id —— 这是 PC 端既有
口径,此处刻意保持一致,不要"顺手统一"
时间边界统一用北京时间再转 UTC时间戳字段都是 timestamptz拿北京时区的
aware datetime 比较语义一致,且不受数据库会话时区影响
(与 screen_service._month_bounds 的写法一致)。
"""
from datetime import timezone as dt_timezone
from app.core.time_utils import get_beijing_time
from app.models.task import Task, TaskRecord, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED
from app.models.task_log import TaskLog
from sqlalchemy import or_
now_bj = get_beijing_time()
if since is None:
since = now_bj.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if until is None:
until = now_bj
since_utc = since.astimezone(dt_timezone.utc)
until_utc = until.astimezone(dt_timezone.utc)
# ── 1. 生产战绩 ──
async def _count_task(status: str) -> int:
return await db.scalar(
select(func.count(Task.id)).where(
Task.assignee_id == assignee_id,
Task.status == status,
Task.completed_at.isnot(None),
Task.completed_at >= since_utc,
Task.completed_at <= until_utc,
)
) or 0
products_touched = await db.scalar(
select(func.count(func.distinct(Task.product_id))).where(
Task.assignee_id == assignee_id,
Task.status == TASK_STATUS_COMPLETED,
Task.completed_at.isnot(None),
Task.completed_at >= since_utc,
Task.completed_at <= until_utc,
)
) or 0
# ── 2. 操作统计(口径同 get_user_operations只是固定到本人──
async def _count_log(action_type: str) -> int:
return await db.scalar(
select(func.count(TaskLog.id)).where(
TaskLog.operator_id == assignee_id,
TaskLog.action_type == action_type,
TaskLog.created_at >= since_utc,
TaskLog.created_at <= until_utc,
)
) or 0
# 备注归因给任务负责人(不是记录人)—— 与 PC 面板一致
record_count = await db.scalar(
select(func.count(TaskRecord.id))
.join(Task, TaskRecord.task_id == Task.id)
.where(
Task.assignee_id == assignee_id,
or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")),
TaskRecord.created_at >= since_utc,
TaskRecord.created_at <= until_utc,
)
) or 0
receive_count = await _count_log("receive")
transfer_count = await _count_log("complete")
return MyStats(
assignee_id=assignee_id,
tasks_completed=await _count_task(TASK_STATUS_COMPLETED),
tasks_rejected=await _count_task(TASK_STATUS_REJECTED),
products_touched=products_touched,
receive_count=receive_count,
transfer_count=transfer_count,
record_count=record_count,
op_total=receive_count + transfer_count + record_count,
)
# ============================================================
# 在制品看板(永远实时)
# ============================================================