diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index 1a7781b..05de23d 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -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), diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index d3f2427..1be6de0 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -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 + + # ============================================================ # 人员负载(按人聚合在制品设备 — 独立「人员看板」) # ============================================================