fix: WIP矩阵-左外连接纳入无任务新品归待接收,PENDING并入待接收,空负责人显式计数为未分配; 表头去待确认列并做TOP N折叠

This commit is contained in:
2026-09-09 17:29:36 +08:00
parent d757c7985b
commit 7b7dbbb0d8
2 changed files with 63 additions and 20 deletions

View File

@ -672,15 +672,17 @@ async def get_wip_matrix(
Product.material_name, Product.material_name,
Task.task_name, Task.task_name,
Task.assignee_id, Task.assignee_id,
Task.status.label("task_status"),
Task.created_at, Task.created_at,
Task.completed_at, Task.completed_at,
Product.current_location_id, Product.current_location_id,
Product.overall_status, Product.overall_status,
Product.status, Product.status,
) )
.join(Task, Task.product_id == Product.id) .outerjoin(Task, Task.product_id == Product.id)
.where( .where(
or_( or_(
Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收)
Task.parent_task_id.is_(None), Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]), Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]),
) )
@ -692,7 +694,7 @@ async def get_wip_matrix(
# 每台设备 → (spec, 当前工序/负责人, 当前负责人ID) # 每台设备 → (spec, 当前工序/负责人, 当前负责人ID)
device_cur: dict[str, tuple] = {} device_cur: dict[str, tuple] = {}
seen: set[str] = set() seen: set[str] = set()
for pid, spec, product_name, task_name, assignee, created, completed, loc, overall_status, product_status in rows: for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status in rows:
if pid in seen: if pid in seen:
continue continue
seen.add(pid) seen.add(pid)
@ -730,41 +732,65 @@ async def get_wip_matrix(
elif overall_status == "待仓库收货" or loc == "virtual_warehouse": elif overall_status == "待仓库收货" or loc == "virtual_warehouse":
key = "已完成" key = "已完成"
else: else:
key = task_name or "—" # 🔧 对齐全景/产品管理:PENDING(含 task_name=待确认) 与 无任务新品 统一归「待接收」
if tstatus == "PENDING" or (tstatus is None and task_name is None):
key = "待接收"
else:
key = task_name if (task_name and task_name != "—") else "待接收"
else: else:
key = assignee or "未分配" key = assignee or "未分配"
device_cur[pid] = (spec or "未知型号", key, assignee or "", product_name or "") device_cur[pid] = (spec or "未知型号", key, assignee or "", product_name or "")
# 聚合:规格 × 当前工序 → 设备数;同时收集负责人 与 产品名称 from collections import Counter
# 聚合:规格 × 当前工序 → 设备数;负责人按人头计数,空值显式计为「未分配」
agg: dict[tuple, int] = {} agg: dict[tuple, int] = {}
assignee_map: dict[tuple, set] = {} assignee_counter: dict[tuple, Counter] = {}
product_name_map: dict[tuple, str] = {} product_name_map: dict[tuple, str] = {}
for spec, key, assignee, product_name in device_cur.values(): for spec, key, assignee, product_name in device_cur.values():
k = (spec, key) k = (spec, key)
agg[k] = agg.get(k, 0) + 1 agg[k] = agg.get(k, 0) + 1
product_name_map.setdefault(k, product_name) product_name_map.setdefault(k, product_name)
if assignee: raw = assignee if assignee and str(assignee).strip() not in ("", "—", "-", "null", "None") else ""
assignee_map.setdefault(k, set()).add(assignee) assignee_counter.setdefault(k, Counter())[raw] += 1
# 负责人 ID → 中文名 # 负责人 ID → 中文名
raw_ids: set[str] = set() raw_ids: set[str] = set()
for s in assignee_map.values(): for counter in assignee_counter.values():
raw_ids |= s raw_ids |= set(counter.keys())
raw_ids.discard("")
name_map: dict[str, str] = {} name_map: dict[str, str] = {}
if raw_ids: if raw_ids:
from app.services.mom_cache import get_display_names from app.services.mom_cache import get_display_names
name_map = get_display_names(list(raw_ids)) name_map = get_display_names(list(raw_ids))
def _disp(raw: str) -> str:
"""负责人显示名:空值统一为 未分配"""
return "未分配" if not raw else name_map.get(raw, raw)
def _fmt_label(raw: str, n: int) -> str:
"""负责人标签:>1 台必须带数量后缀;1 台不带"""
disp = _disp(raw)
return f"{disp}({n})" if n > 1 else disp
items: list[WipMatrixRow] = [] items: list[WipMatrixRow] = []
for (spec, key), cnt in agg.items(): for (spec, key), cnt in agg.items():
dim_display = name_map.get(key, key) if dimension == "assignee" else key dim_display = _disp(key) if dimension == "assignee" else key
assignees = [name_map.get(a, a) for a in assignee_map.get((spec, key), set())] or [] counter = assignee_counter.get((spec, key), Counter())
# 负责人名单:未分配固定排最前,其余按台数降序、名称升序;带数量后缀(>1 台)
labels = [
_fmt_label(raw, n)
for raw, n in sorted(
counter.items(),
key=lambda kv: (0 if kv[0] == "" else 1, -kv[1], _disp(kv[0])),
)
]
items.append(WipMatrixRow( items.append(WipMatrixRow(
spec_model=spec, spec_model=spec,
product_name=product_name_map.get((spec, key), ""), product_name=product_name_map.get((spec, key), ""),
dimension_key=dim_display, dimension_key=dim_display,
count=cnt, count=cnt,
assignees=assignees, assignees=labels,
)) ))
items.sort(key=lambda x: (x.spec_model, x.dimension_key)) items.sort(key=lambda x: (x.spec_model, x.dimension_key))
return items return items
@ -812,9 +838,10 @@ async def get_wip_matrix_detail(
Task.completed_at, Task.completed_at,
Task.received_at, Task.received_at,
) )
.join(Task, Task.product_id == Product.id) .outerjoin(Task, Task.product_id == Product.id)
.where( .where(
or_( or_(
Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收)
Task.parent_task_id.is_(None), Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]), Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]),
) )
@ -863,7 +890,11 @@ async def get_wip_matrix_detail(
elif overall == "待仓库收货" or loc == "virtual_warehouse": elif overall == "待仓库收货" or loc == "virtual_warehouse":
key = "已完成" key = "已完成"
else: else:
key = task_name or "—" # 🔧 对齐全景/产品管理:PENDING(含 task_name=待确认) 与 无任务新品 统一归「待接收」
if tstatus == "PENDING" or (tstatus is None and task_name is None):
key = "待接收"
else:
key = task_name if (task_name and task_name != "—") else "待接收"
if process and key != process: if process and key != process:
continue continue

View File

@ -12,7 +12,7 @@ import { fetchDeviceRecords, type DeviceRecord } from "../services/analyticsApi"
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
// 🔧 固定标准主轴:核心工序 + 状态列始终显示(即使计数为 0),表头不随操作内容增减 // 🔧 固定标准主轴:核心工序 + 状态列始终显示(即使计数为 0),表头不随操作内容增减
const FIXED_STEP_COLUMNS = ["备货", "生产", "测试", "维修", "待确认", "已完成", "已入库", "已出库"]; const FIXED_STEP_COLUMNS = ["待接收", "备货", "生产", "测试", "维修", "已完成", "已入库", "已出库"];
// ⏰ 时间筛选(与全局概览一致) // ⏰ 时间筛选(与全局概览一致)
type DateRangeKey = "today" | "7d" | "30d" | "custom"; type DateRangeKey = "today" | "7d" | "30d" | "custom";
@ -42,6 +42,21 @@ function imageUrl(u: string) {
return base + path; return base + path;
} }
// ─── 单元格负责人聚合预览:数量解析 + TOP 折叠 ────────────────────
function labelCount(label: string): number {
const m = label.match(/\((\d+)\)\s*$/);
return m ? Number(m[1]) : 1;
}
function summarizeAssignees(list: string[]): string {
if (!list.length) return "";
// 数量降序(“未分配”同样参与按台数排序),数量最多者排最前
const sorted = [...list].sort((a, b) => labelCount(b) - labelCount(a));
if (sorted.length <= 2) return sorted.join("、");
// ≥3 人:只显示台数最多的第 1 人(去掉数量后缀),折叠为 “等 N 人”
const first = sorted[0].replace(/\(\d+\)\s*$/, "");
return `${first}等 ${sorted.length} 人`;
}
export default function MatrixBoard() { export default function MatrixBoard() {
const [dateKey, setDateKey] = useState<DateRangeKey>("today"); const [dateKey, setDateKey] = useState<DateRangeKey>("today");
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null); const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
@ -140,11 +155,8 @@ export default function MatrixBoard() {
> >
<div className={`text-sm font-bold leading-none ${v > 0 ? "text-blue-600" : "text-gray-800"}`}>{v}</div> <div className={`text-sm font-bold leading-none ${v > 0 ? "text-blue-600" : "text-gray-800"}`}>{v}</div>
{assignees.length > 0 && ( {assignees.length > 0 && (
<div <div className="mx-auto max-w-[92px] text-xs leading-tight text-gray-500">
className="mx-auto max-w-[90px] truncate text-[10px] leading-tight text-gray-400" {summarizeAssignees(assignees)}
title={assignees.join("、")}
>
{assignees.join("、")}
</div> </div>
)} )}
</div> </div>