fix(驳回/返工): 抽屉与卡片数字口径一致,同时展示已驳回+返工任务
- 根因:卡片数字含返工任务(tasks_rework 实时快照),但抽屉只列已驳回 → 点开为空 - get_rejected_tasks 扩展返回两类(kind=rejected 按时间过滤 / kind=rework 实时不过滤) - 前端抽屉按「已驳回」「返工任务」分组展示,数字与明细一一对应
This commit is contained in:
@ -50,15 +50,17 @@ class CompletedTask(BaseModel):
|
|||||||
|
|
||||||
class RejectedTask(BaseModel):
|
class RejectedTask(BaseModel):
|
||||||
task_id: str
|
task_id: str
|
||||||
task_name: str # 被驳回的工序
|
kind: str = "rejected" # "rejected"(已驳回) | "rework"(返工任务)
|
||||||
|
task_name: str # 工序
|
||||||
product_sn: str # 16位HEX身份证
|
product_sn: str # 16位HEX身份证
|
||||||
external_serial: str | None # 业务序列号
|
external_serial: str | None # 业务序列号
|
||||||
material_name: str # 产品名称(物料名称)
|
material_name: str # 产品名称(物料名称)
|
||||||
spec_model: str # 规格型号
|
spec_model: str # 规格型号
|
||||||
rejected_by: str # 驳回人中文姓名
|
rejected_by: str = "—" # 驳回人中文姓名(rework 可能无)
|
||||||
rework_assignee: str # 返工任务负责人中文姓名(转交给谁返工)
|
rework_assignee: str # 返工任务负责人中文姓名(转交给谁返工)
|
||||||
reject_reason: str | None # 驳回原因
|
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):
|
class PersonDevice(BaseModel):
|
||||||
@ -306,10 +308,13 @@ async def get_rejected_tasks(
|
|||||||
until: datetime | None = None,
|
until: datetime | None = None,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
) -> list[RejectedTask]:
|
) -> list[RejectedTask]:
|
||||||
"""按时段查询被驳回任务明细(上帝视角),用于「驳回/返工」卡片下钻。
|
"""查询驳回/返工任务明细(上帝视角),用于「驳回/返工」卡片下钻。
|
||||||
|
|
||||||
每条返回:设备信息、被驳回工序、驳回人、返工任务负责人(转交给谁)、
|
返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致):
|
||||||
驳回原因与时间。返工负责人追溯逻辑与 task_service.reject_task 一致:
|
- kind="rejected":被驳回任务,按 completed_at 时间过滤
|
||||||
|
- kind="rework":返工任务(is_rework=True 且非驳回状态),实时快照不过滤时间
|
||||||
|
|
||||||
|
返工负责人追溯逻辑与 task_service.reject_task 一致:
|
||||||
优先最早 create log 的 operator → 兜底父任务负责人 → 兜底自身。
|
优先最早 create log 的 operator → 兜底父任务负责人 → 兜底自身。
|
||||||
"""
|
"""
|
||||||
from app.models.task import Task, TASK_STATUS_REJECTED
|
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.models.product import Product
|
||||||
from app.core.time_utils import BEIJING_TZ
|
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 = (
|
stmt = (
|
||||||
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
|
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
|
||||||
.join(Product, Task.product_id == Product.id)
|
.join(Product, Task.product_id == Product.id)
|
||||||
@ -327,14 +343,28 @@ async def get_rejected_tasks(
|
|||||||
if until:
|
if until:
|
||||||
stmt = stmt.where(Task.completed_at <= until)
|
stmt = stmt.where(Task.completed_at <= until)
|
||||||
stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
|
stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
|
||||||
result = await db.execute(stmt)
|
rejected_rows = (await db.execute(stmt)).all()
|
||||||
rows = result.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 = {}
|
reject_op: dict = {}
|
||||||
if task_ids:
|
if rejected_ids:
|
||||||
sub = (
|
sub = (
|
||||||
select(
|
select(
|
||||||
TaskLog.task_id, TaskLog.operator_id,
|
TaskLog.task_id, TaskLog.operator_id,
|
||||||
@ -343,16 +373,15 @@ async def get_rejected_tasks(
|
|||||||
order_by=TaskLog.created_at.desc(),
|
order_by=TaskLog.created_at.desc(),
|
||||||
).label("rn"),
|
).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()
|
).subquery()
|
||||||
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
|
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
|
||||||
for row in r:
|
for row in r:
|
||||||
if row[1]:
|
if row[1]:
|
||||||
reject_op[row[0]] = row[1]
|
reject_op[row[0]] = row[1]
|
||||||
|
|
||||||
# ── 批量取每个任务最早 create log 的 operator(返工负责人的第一优先)──
|
|
||||||
create_op: dict = {}
|
create_op: dict = {}
|
||||||
if task_ids:
|
if rejected_ids:
|
||||||
sub = (
|
sub = (
|
||||||
select(
|
select(
|
||||||
TaskLog.task_id, TaskLog.operator_id,
|
TaskLog.task_id, TaskLog.operator_id,
|
||||||
@ -361,16 +390,15 @@ async def get_rejected_tasks(
|
|||||||
order_by=TaskLog.created_at.asc(),
|
order_by=TaskLog.created_at.asc(),
|
||||||
).label("rn"),
|
).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()
|
).subquery()
|
||||||
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
|
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
|
||||||
for row in r:
|
for row in r:
|
||||||
if row[1]:
|
if row[1]:
|
||||||
create_op[row[0]] = row[1]
|
create_op[row[0]] = row[1]
|
||||||
|
|
||||||
# ── 兜底:父任务负责人(对没有 create log 的任务)──
|
|
||||||
parent_assignee: dict = {}
|
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:
|
if parent_ids:
|
||||||
r = await db.execute(
|
r = await db.execute(
|
||||||
select(Task.id, Task.assignee_id).where(Task.id.in_(parent_ids))
|
select(Task.id, Task.assignee_id).where(Task.id.in_(parent_ids))
|
||||||
@ -379,13 +407,14 @@ async def get_rejected_tasks(
|
|||||||
if row[1]:
|
if row[1]:
|
||||||
parent_assignee[row[0]] = row[1]
|
parent_assignee[row[0]] = row[1]
|
||||||
|
|
||||||
# ── 中文名映射(驳回人 + 返工负责人 + 任务负责人 一次批量查)──
|
# ── 中文名映射(驳回人 + 返工负责人 一次批量查)──
|
||||||
raw_ids: set[str] = set()
|
raw_ids: set[str] = set()
|
||||||
for t, *_ in rows:
|
for kind, (t, *_row) in all_rows:
|
||||||
raw_ids.add(reject_op.get(t.id) or "")
|
if kind == "rejected":
|
||||||
raw_ids.add(create_op.get(t.id) or "")
|
raw_ids.add(reject_op.get(t.id) or "")
|
||||||
if t.id not in create_op and t.parent_task_id:
|
raw_ids.add(create_op.get(t.id) or "")
|
||||||
raw_ids.add(parent_assignee.get(t.parent_task_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.add(t.assignee_id or "")
|
||||||
raw_ids.discard("")
|
raw_ids.discard("")
|
||||||
name_map: dict[str, str] = {}
|
name_map: dict[str, str] = {}
|
||||||
@ -394,36 +423,45 @@ async def get_rejected_tasks(
|
|||||||
name_map = get_display_names(list(raw_ids))
|
name_map = get_display_names(list(raw_ids))
|
||||||
|
|
||||||
items: list[RejectedTask] = []
|
items: list[RejectedTask] = []
|
||||||
for task, sn, ext, mat, spec in rows:
|
for kind, (task, sn, ext, mat, spec) in all_rows:
|
||||||
# 复刻 reject_task 追溯逻辑:create op → 父任务负责人 → 自身
|
if kind == "rejected":
|
||||||
rework_id = create_op.get(task.id)
|
# 复刻 reject_task 追溯逻辑:create op → 父任务负责人 → 自身
|
||||||
if not rework_id and task.parent_task_id:
|
rework_id = create_op.get(task.id)
|
||||||
rework_id = parent_assignee.get(task.parent_task_id)
|
if not rework_id and task.parent_task_id:
|
||||||
if not rework_id:
|
rework_id = parent_assignee.get(task.parent_task_id)
|
||||||
rework_id = task.assignee_id
|
if not rework_id:
|
||||||
rejected_by_id = reject_op.get(task.id) or task.assignee_id
|
rework_id = task.assignee_id
|
||||||
|
rejected_by_id = reject_op.get(task.id) or task.assignee_id
|
||||||
t = task.completed_at
|
items.append(RejectedTask(
|
||||||
if t:
|
task_id=str(task.id),
|
||||||
if t.tzinfo is None:
|
kind="rejected",
|
||||||
from datetime import timezone as dt_timezone
|
task_name=task.task_name,
|
||||||
t = t.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ)
|
product_sn=sn or "",
|
||||||
else:
|
external_serial=ext or None,
|
||||||
t = t.astimezone(BEIJING_TZ)
|
material_name=mat or "",
|
||||||
time_str = t.isoformat() if t else ""
|
spec_model=spec or "",
|
||||||
|
rejected_by=name_map.get(rejected_by_id or "", rejected_by_id or "—"),
|
||||||
items.append(RejectedTask(
|
rework_assignee=name_map.get(rework_id or "", rework_id or "—"),
|
||||||
task_id=str(task.id),
|
reject_reason=task.reject_reason,
|
||||||
task_name=task.task_name,
|
status=task.status,
|
||||||
product_sn=sn or "",
|
rejected_at=_to_bj_iso(task.completed_at),
|
||||||
external_serial=ext or None,
|
))
|
||||||
material_name=mat or "",
|
else:
|
||||||
spec_model=spec or "",
|
# 返工任务:负责人即其 assignee
|
||||||
rejected_by=name_map.get(rejected_by_id or "", rejected_by_id or "—"),
|
items.append(RejectedTask(
|
||||||
rework_assignee=name_map.get(rework_id or "", rework_id or "—"),
|
task_id=str(task.id),
|
||||||
reject_reason=task.reject_reason,
|
kind="rework",
|
||||||
rejected_at=time_str,
|
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
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -191,26 +191,42 @@ function CompletedRow({ t }: { t: CompletedTask }) {
|
|||||||
// ─── 被驳回明细项(卡片式) ──────────────────────────────
|
// ─── 被驳回明细项(卡片式) ──────────────────────────────
|
||||||
function RejectedRow({ t }: { t: RejectedTask }) {
|
function RejectedRow({ t }: { t: RejectedTask }) {
|
||||||
const nav = useNavigate();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => t.product_sn && nav(`/admin/tasks?sn=${t.product_sn}`)}
|
onClick={() => 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"
|
className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-red-200 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertTriangle className="h-4 w-4 shrink-0 text-red-500" />
|
{isRework ? <RefreshCw className="h-4 w-4 shrink-0 text-orange-500" /> : <AlertTriangle className="h-4 w-4 shrink-0 text-red-500" />}
|
||||||
<span className="text-sm font-semibold text-gray-800 truncate">{t.task_name}</span>
|
<span className="text-sm font-semibold text-gray-800 truncate">{t.task_name}</span>
|
||||||
<span className="text-xs text-gray-500">|</span>
|
<span className="text-xs text-gray-500">|</span>
|
||||||
<span className="shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600">已驳回</span>
|
{isRework ? (
|
||||||
<span className="text-xs text-gray-500 shrink-0">驳回人: {t.rejected_by}</span>
|
<span className="shrink-0 rounded-full bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
|
||||||
<span className="ml-auto shrink-0 text-xs text-gray-400">{rejectedTime}</span>
|
{t.status === "WIP" ? "返工中" : t.status === "PENDING" ? "待返工" : "已返工"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600">已驳回</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-500 shrink-0">
|
||||||
|
{isRework ? `返工人: ${t.rework_assignee}` : `驳回人: ${t.rejected_by}`}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0 text-xs text-gray-400">{timeStr}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
|
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||||
<span className="inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600">
|
{!isRework && (
|
||||||
🔁 返工给: {t.rework_assignee}
|
<>
|
||||||
</span>
|
<span className="inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600">
|
||||||
<span className="text-gray-300">|</span>
|
🔁 返工给: {t.rework_assignee}
|
||||||
<span className="truncate text-red-500/80" title={t.reject_reason || ""}>原因: {t.reject_reason || "—"}</span>
|
</span>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<span className="truncate text-red-500/80" title={t.reject_reason || ""}>原因: {t.reject_reason || "—"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isRework && (
|
||||||
|
<span className="truncate text-orange-600/80">🔁 返工任务 · {t.rework_assignee} 负责</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
|
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||||
<span className="font-medium text-gray-500">{t.material_name || "未知设备"}</span>
|
<span className="font-medium text-gray-500">{t.material_name || "未知设备"}</span>
|
||||||
@ -588,7 +604,7 @@ export default function AdminDashboard() {
|
|||||||
|
|
||||||
{/* ═══ 驳回/返工明细抽屉 ═══ */}
|
{/* ═══ 驳回/返工明细抽屉 ═══ */}
|
||||||
<Drawer
|
<Drawer
|
||||||
title={<span className="text-base font-bold">🔴 驳回/返工明细 <span className="font-normal text-gray-400">被驳回 {rejectedTasks.length} 条</span></span>}
|
title={<span className="text-base font-bold">🔴 驳回/返工明细 <span className="font-normal text-gray-400">共 {rejectedTasks.length} 条</span></span>}
|
||||||
open={rejectedDrawerOpen}
|
open={rejectedDrawerOpen}
|
||||||
onClose={() => setRejectedDrawerOpen(false)}
|
onClose={() => setRejectedDrawerOpen(false)}
|
||||||
size="large"
|
size="large"
|
||||||
@ -600,11 +616,34 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
) : rejectedTasks.length === 0 ? (
|
) : rejectedTasks.length === 0 ? (
|
||||||
<div className="py-16 text-center text-sm text-gray-400">
|
<div className="py-16 text-center text-sm text-gray-400">
|
||||||
该时段暂无驳回记录
|
该时段暂无驳回/返工记录
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
{rejectedTasks.map(t => <RejectedRow key={t.task_id} t={t} />)}
|
{(() => {
|
||||||
|
const rejected = rejectedTasks.filter(t => t.kind === "rejected");
|
||||||
|
const rework = rejectedTasks.filter(t => t.kind === "rework");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rejected.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-red-600">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />已驳回 · {rejected.length} 条
|
||||||
|
</h4>
|
||||||
|
{rejected.map(t => <RejectedRow key={t.task_id} t={t} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rework.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-orange-600">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />返工任务 · {rework.length} 条
|
||||||
|
</h4>
|
||||||
|
{rework.map(t => <RejectedRow key={t.task_id} t={t} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export interface CompletedTask {
|
|||||||
|
|
||||||
export interface RejectedTask {
|
export interface RejectedTask {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
|
kind: "rejected" | "rework";
|
||||||
task_name: string;
|
task_name: string;
|
||||||
product_sn: string;
|
product_sn: string;
|
||||||
external_serial: string | null;
|
external_serial: string | null;
|
||||||
@ -49,7 +50,8 @@ export interface RejectedTask {
|
|||||||
rejected_by: string;
|
rejected_by: string;
|
||||||
rework_assignee: string;
|
rework_assignee: string;
|
||||||
reject_reason: string | null;
|
reject_reason: string | null;
|
||||||
rejected_at: string;
|
status: string | null;
|
||||||
|
rejected_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonDevice {
|
export interface PersonDevice {
|
||||||
|
|||||||
Reference in New Issue
Block a user