diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 1be6de0..ca6daa1 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -50,15 +50,17 @@ class CompletedTask(BaseModel): class RejectedTask(BaseModel): task_id: str - task_name: str # 被驳回的工序 + kind: str = "rejected" # "rejected"(已驳回) | "rework"(返工任务) + task_name: str # 工序 product_sn: str # 16位HEX身份证 external_serial: str | None # 业务序列号 material_name: str # 产品名称(物料名称) spec_model: str # 规格型号 - rejected_by: str # 驳回人中文姓名 + rejected_by: str = "—" # 驳回人中文姓名(rework 可能无) rework_assignee: str # 返工任务负责人中文姓名(转交给谁返工) reject_reason: str | None # 驳回原因 - rejected_at: str # 驳回时间 ISO(BEIJING_TZ) + status: str | None = None # 当前状态(rework 任务用:PENDING/WIP/COMPLETED) + rejected_at: str | None # 驳回时间 ISO(BEIJING_TZ) class PersonDevice(BaseModel): @@ -306,10 +308,13 @@ async def get_rejected_tasks( until: datetime | None = None, limit: int = 200, ) -> list[RejectedTask]: - """按时段查询被驳回任务明细(上帝视角),用于「驳回/返工」卡片下钻。 + """查询驳回/返工任务明细(上帝视角),用于「驳回/返工」卡片下钻。 - 每条返回:设备信息、被驳回工序、驳回人、返工任务负责人(转交给谁)、 - 驳回原因与时间。返工负责人追溯逻辑与 task_service.reject_task 一致: + 返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致): + - kind="rejected":被驳回任务,按 completed_at 时间过滤 + - kind="rework":返工任务(is_rework=True 且非驳回状态),实时快照不过滤时间 + + 返工负责人追溯逻辑与 task_service.reject_task 一致: 优先最早 create log 的 operator → 兜底父任务负责人 → 兜底自身。 """ from app.models.task import Task, TASK_STATUS_REJECTED @@ -317,6 +322,17 @@ async def get_rejected_tasks( from app.models.product import Product from app.core.time_utils import BEIJING_TZ + def _to_bj_iso(dt) -> str: + if not dt: + return "" + if dt.tzinfo is None: + from datetime import timezone as dt_timezone + dt = dt.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ) + else: + dt = dt.astimezone(BEIJING_TZ) + return dt.isoformat() + + # ── 1. 已驳回任务(按时间过滤)── stmt = ( select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) .join(Product, Task.product_id == Product.id) @@ -327,14 +343,28 @@ async def get_rejected_tasks( 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() + rejected_rows = (await db.execute(stmt)).all() - task_ids = [t.id for t, *_ in rows] + # ── 2. 返工任务(is_rework=True 且当前非驳回状态,实时不过滤时间)── + rework_stmt = ( + select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) + .join(Product, Task.product_id == Product.id) + .where(Task.is_rework.is_(True), Task.status != TASK_STATUS_REJECTED) + .order_by(Task.created_at.desc()) + .limit(limit) + ) + rework_rows = (await db.execute(rework_stmt)).all() - # ── 批量取每个任务的驳回人(最新一条 reject log 的 operator_id)── + # 合并:rejected 在前,rework 在后 + all_rows: list[tuple[str, object]] = [ + *[("rejected", t) for t in rejected_rows], + *[("rework", t) for t in rework_rows], + ] + + # ── 已驳回任务:批量取驳回人 / 最早 create log / 父任务负责人 ── + rejected_ids = [t.id for t, *_ in rejected_rows] reject_op: dict = {} - if task_ids: + if rejected_ids: sub = ( select( TaskLog.task_id, TaskLog.operator_id, @@ -343,16 +373,15 @@ async def get_rejected_tasks( order_by=TaskLog.created_at.desc(), ).label("rn"), ) - .where(TaskLog.task_id.in_(task_ids), TaskLog.action_type == "reject") + .where(TaskLog.task_id.in_(rejected_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: + if rejected_ids: sub = ( select( TaskLog.task_id, TaskLog.operator_id, @@ -361,16 +390,15 @@ async def get_rejected_tasks( order_by=TaskLog.created_at.asc(), ).label("rn"), ) - .where(TaskLog.task_id.in_(task_ids), TaskLog.action_type == "create") + .where(TaskLog.task_id.in_(rejected_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] + parent_ids = [t.parent_task_id for t, *_ in rejected_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)) @@ -379,13 +407,14 @@ async def get_rejected_tasks( 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 "") + for kind, (t, *_row) in all_rows: + if kind == "rejected": + 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] = {} @@ -394,36 +423,45 @@ async def get_rejected_tasks( 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, - )) + for kind, (task, sn, ext, mat, spec) in all_rows: + if kind == "rejected": + # 复刻 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 + items.append(RejectedTask( + task_id=str(task.id), + kind="rejected", + 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, + status=task.status, + rejected_at=_to_bj_iso(task.completed_at), + )) + else: + # 返工任务:负责人即其 assignee + items.append(RejectedTask( + task_id=str(task.id), + kind="rework", + task_name=task.task_name, + product_sn=sn or "", + external_serial=ext or None, + material_name=mat or "", + spec_model=spec or "", + rejected_by="—", + rework_assignee=name_map.get(task.assignee_id or "", task.assignee_id or "—"), + reject_reason=None, + status=task.status, + rejected_at=_to_bj_iso(task.received_at or task.created_at), + )) return items diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 502a2b4..754379d 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -191,26 +191,42 @@ function CompletedRow({ t }: { t: CompletedTask }) { // ─── 被驳回明细项(卡片式) ────────────────────────────── function RejectedRow({ t }: { t: RejectedTask }) { const nav = useNavigate(); - const rejectedTime = t.rejected_at ? dayjs(t.rejected_at).format("YYYY-MM-DD HH:mm") : ""; + const timeStr = t.rejected_at ? dayjs(t.rejected_at).format("YYYY-MM-DD HH:mm") : ""; + const isRework = t.kind === "rework"; return (
t.product_sn && nav(`/admin/tasks?sn=${t.product_sn}`)} className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-red-200 hover:shadow-md" >
- + {isRework ? : } {t.task_name} | - 已驳回 - 驳回人: {t.rejected_by} - {rejectedTime} + {isRework ? ( + + {t.status === "WIP" ? "返工中" : t.status === "PENDING" ? "待返工" : "已返工"} + + ) : ( + 已驳回 + )} + + {isRework ? `返工人: ${t.rework_assignee}` : `驳回人: ${t.rejected_by}`} + + {timeStr}
- - 🔁 返工给: {t.rework_assignee} - - | - 原因: {t.reject_reason || "—"} + {!isRework && ( + <> + + 🔁 返工给: {t.rework_assignee} + + | + 原因: {t.reject_reason || "—"} + + )} + {isRework && ( + 🔁 返工任务 · {t.rework_assignee} 负责 + )}
{t.material_name || "未知设备"} @@ -588,7 +604,7 @@ export default function AdminDashboard() { {/* ═══ 驳回/返工明细抽屉 ═══ */} 🔴 驳回/返工明细 被驳回 {rejectedTasks.length} 条} + title={🔴 驳回/返工明细 共 {rejectedTasks.length} 条} open={rejectedDrawerOpen} onClose={() => setRejectedDrawerOpen(false)} size="large" @@ -600,11 +616,34 @@ export default function AdminDashboard() {
) : rejectedTasks.length === 0 ? (
- 该时段暂无驳回记录 + 该时段暂无驳回/返工记录
) : ( -
- {rejectedTasks.map(t => )} +
+ {(() => { + const rejected = rejectedTasks.filter(t => t.kind === "rejected"); + const rework = rejectedTasks.filter(t => t.kind === "rework"); + return ( + <> + {rejected.length > 0 && ( +
+

+ 已驳回 · {rejected.length} 条 +

+ {rejected.map(t => )} +
+ )} + {rework.length > 0 && ( +
+

+ 返工任务 · {rework.length} 条 +

+ {rework.map(t => )} +
+ )} + + ); + })()}
)} diff --git a/frontend/src/services/dashboardApi.ts b/frontend/src/services/dashboardApi.ts index e56934f..b7ff6b2 100644 --- a/frontend/src/services/dashboardApi.ts +++ b/frontend/src/services/dashboardApi.ts @@ -41,6 +41,7 @@ export interface CompletedTask { export interface RejectedTask { task_id: string; + kind: "rejected" | "rework"; task_name: string; product_sn: string; external_serial: string | null; @@ -49,7 +50,8 @@ export interface RejectedTask { rejected_by: string; rework_assignee: string; reject_reason: string | null; - rejected_at: string; + status: string | null; + rejected_at: string | null; } export interface PersonDevice {