From 44ca09ae22d668447b1541919c6dbe3c85e4e784 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Tue, 15 Sep 2026 15:51:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E6=95=88=E8=83=BD=E7=BB=9F=E8=AE=A1=E6=8E=A5=E5=8F=A3=20/dashb?= =?UTF-8?q?oard/my-stats=EF=BC=88=E6=94=AF=E6=8C=81=E8=87=AA=E9=80=89?= =?UTF-8?q?=E6=97=B6=E6=AE=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动端「个人中心 → 工作统计」的数据源。现有接口拿不到这份数据: /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 日 ~ 此刻」。 --- backend/app/api/v1/endpoints/dashboard.py | 34 ++++++ backend/app/services/dashboard_service.py | 133 ++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index dab9358..c20a4d4 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -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), diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 332811a..9ef4abe 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -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_at,assignee_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, + ) + + # ============================================================ # 在制品看板(永远实时) # ============================================================