feat(backend): 新增 /dashboard/rejected-tasks 驳回/返工下钻接口
- RejectedTask schema: 设备/工序/驳回人/返工负责人/原因/时间 - get_rejected_tasks 批量窗口取驳回人(reject log) + 复刻 reject_task 的返工负责人追溯逻辑(create log→父任务→自身) - 复用 get_display_names 中文名映射、BEIJING_TZ 时间转换
This commit is contained in:
@ -9,6 +9,7 @@ from app.services.dashboard_service import (
|
||||
get_dashboard_stats, DashboardStats,
|
||||
get_wip_tasks, WipTask,
|
||||
get_completed_tasks, CompletedTask,
|
||||
get_rejected_tasks, RejectedTask,
|
||||
get_people_workload, PersonWorkload,
|
||||
get_people_history, PersonHistoryRecord,
|
||||
search_product_messages, ProductMessageList,
|
||||
@ -56,6 +57,19 @@ async def completed_tasks(
|
||||
return await get_completed_tasks(db, 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),
|
||||
):
|
||||
"""驳回/返工下钻 — 按时段查询被驳回任务明细(含返工去向)"""
|
||||
since_dt = datetime.fromisoformat(since) if since 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)
|
||||
|
||||
|
||||
@router.get("/people-workload", response_model=list[PersonWorkload])
|
||||
async def people_workload(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@ -48,6 +48,19 @@ class CompletedTask(BaseModel):
|
||||
completed_at: str # 完成时间 ISO
|
||||
|
||||
|
||||
class RejectedTask(BaseModel):
|
||||
task_id: str
|
||||
task_name: str # 被驳回的工序
|
||||
product_sn: str # 16位HEX身份证
|
||||
external_serial: str | None # 业务序列号
|
||||
material_name: str # 产品名称(物料名称)
|
||||
spec_model: str # 规格型号
|
||||
rejected_by: str # 驳回人中文姓名
|
||||
rework_assignee: str # 返工任务负责人中文姓名(转交给谁返工)
|
||||
reject_reason: str | None # 驳回原因
|
||||
rejected_at: str # 驳回时间 ISO(BEIJING_TZ)
|
||||
|
||||
|
||||
class PersonDevice(BaseModel):
|
||||
product_id: str
|
||||
serial_number: str # 16位HEX身份证
|
||||
@ -283,6 +296,137 @@ async def get_completed_tasks(
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 被驳回任务明细(品质驳回下钻 — 按时段过滤)
|
||||
# ============================================================
|
||||
|
||||
async def get_rejected_tasks(
|
||||
db: AsyncSession,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[RejectedTask]:
|
||||
"""按时段查询被驳回任务明细(上帝视角),用于「驳回/返工」卡片下钻。
|
||||
|
||||
每条返回:设备信息、被驳回工序、驳回人、返工任务负责人(转交给谁)、
|
||||
驳回原因与时间。返工负责人追溯逻辑与 task_service.reject_task 一致:
|
||||
优先最早 create log 的 operator → 兜底父任务负责人 → 兜底自身。
|
||||
"""
|
||||
from app.models.task import Task, TASK_STATUS_REJECTED
|
||||
from app.models.task_log import TaskLog
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import BEIJING_TZ
|
||||
|
||||
stmt = (
|
||||
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(Task.status == TASK_STATUS_REJECTED)
|
||||
)
|
||||
if since:
|
||||
stmt = stmt.where(Task.completed_at >= since)
|
||||
if until:
|
||||
stmt = stmt.where(Task.completed_at <= until)
|
||||
stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
task_ids = [t.id for t, *_ in rows]
|
||||
|
||||
# ── 批量取每个任务的驳回人(最新一条 reject log 的 operator_id)──
|
||||
reject_op: dict = {}
|
||||
if task_ids:
|
||||
sub = (
|
||||
select(
|
||||
TaskLog.task_id, TaskLog.operator_id,
|
||||
func.row_number().over(
|
||||
partition_by=TaskLog.task_id,
|
||||
order_by=TaskLog.created_at.desc(),
|
||||
).label("rn"),
|
||||
)
|
||||
.where(TaskLog.task_id.in_(task_ids), TaskLog.action_type == "reject")
|
||||
).subquery()
|
||||
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
|
||||
for row in r:
|
||||
if row[1]:
|
||||
reject_op[row[0]] = row[1]
|
||||
|
||||
# ── 批量取每个任务最早 create log 的 operator(返工负责人的第一优先)──
|
||||
create_op: dict = {}
|
||||
if task_ids:
|
||||
sub = (
|
||||
select(
|
||||
TaskLog.task_id, TaskLog.operator_id,
|
||||
func.row_number().over(
|
||||
partition_by=TaskLog.task_id,
|
||||
order_by=TaskLog.created_at.asc(),
|
||||
).label("rn"),
|
||||
)
|
||||
.where(TaskLog.task_id.in_(task_ids), TaskLog.action_type == "create")
|
||||
).subquery()
|
||||
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
|
||||
for row in r:
|
||||
if row[1]:
|
||||
create_op[row[0]] = row[1]
|
||||
|
||||
# ── 兜底:父任务负责人(对没有 create log 的任务)──
|
||||
parent_assignee: dict = {}
|
||||
parent_ids = [t.parent_task_id for t, *_ in rows if t.parent_task_id and t.id not in create_op]
|
||||
if parent_ids:
|
||||
r = await db.execute(
|
||||
select(Task.id, Task.assignee_id).where(Task.id.in_(parent_ids))
|
||||
)
|
||||
for row in r:
|
||||
if row[1]:
|
||||
parent_assignee[row[0]] = row[1]
|
||||
|
||||
# ── 中文名映射(驳回人 + 返工负责人 + 任务负责人 一次批量查)──
|
||||
raw_ids: set[str] = set()
|
||||
for t, *_ in rows:
|
||||
raw_ids.add(reject_op.get(t.id) or "")
|
||||
raw_ids.add(create_op.get(t.id) or "")
|
||||
if t.id not in create_op and t.parent_task_id:
|
||||
raw_ids.add(parent_assignee.get(t.parent_task_id) or "")
|
||||
raw_ids.add(t.assignee_id or "")
|
||||
raw_ids.discard("")
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(list(raw_ids))
|
||||
|
||||
items: list[RejectedTask] = []
|
||||
for task, sn, ext, mat, spec in rows:
|
||||
# 复刻 reject_task 追溯逻辑:create op → 父任务负责人 → 自身
|
||||
rework_id = create_op.get(task.id)
|
||||
if not rework_id and task.parent_task_id:
|
||||
rework_id = parent_assignee.get(task.parent_task_id)
|
||||
if not rework_id:
|
||||
rework_id = task.assignee_id
|
||||
rejected_by_id = reject_op.get(task.id) or task.assignee_id
|
||||
|
||||
t = task.completed_at
|
||||
if t:
|
||||
if t.tzinfo is None:
|
||||
from datetime import timezone as dt_timezone
|
||||
t = t.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ)
|
||||
else:
|
||||
t = t.astimezone(BEIJING_TZ)
|
||||
time_str = t.isoformat() if t else ""
|
||||
|
||||
items.append(RejectedTask(
|
||||
task_id=str(task.id),
|
||||
task_name=task.task_name,
|
||||
product_sn=sn or "",
|
||||
external_serial=ext or None,
|
||||
material_name=mat or "",
|
||||
spec_model=spec or "",
|
||||
rejected_by=name_map.get(rejected_by_id or "", rejected_by_id or "—"),
|
||||
rework_assignee=name_map.get(rework_id or "", rework_id or "—"),
|
||||
reject_reason=task.reject_reason,
|
||||
rejected_at=time_str,
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 人员负载(按人聚合在制品设备 — 独立「人员看板」)
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user