"""BOM 零件牌号查询视图(统一查询框架迁移,阶段 A)
复用 ``BaseQueryView`` + ``BomQueryDataSource``,天然获得:
- 列头点击排序(客户端对全量记录排序)
- FilterBar 字段级筛选(首列即「生产令号」,驱动按令号检索)
- 分页(每页 20 / 50 / 100 / 200)
- 导出 CSV
迁移中保留原有能力,迁为工具栏 / 右键动作,确保不丢失:
- 「打开源文件」:双击行 / 工具栏 / 右键 → 回访来源材料清单(file:// 超链接)
- 「编辑牌号」:工具栏 / 右键 → 订正空白材料牌号并标注来源(手动 / 自学习)
"""
from __future__ import annotations
import contextlib
from typing import Any, override
from PySide6.QtCore import Qt, QTimer, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QMenu,
QMessageBox,
QTableWidgetItem,
)
from certflow.utils.logger import logger
from certflow.views.bases.base_query_view import BaseQueryView
from certflow.views.bases.datasources.bom_query import BomQueryDataSource
from certflow.views.bases.lookup_table_manager_view import (
FieldSpec,
LookupRecordEditDialog,
)
from certflow.widgets import ActionToolbar
# 来源取值 → 中文展示
_SOURCE_LABELS = {
"import": "导入",
"auto_learn": "自学习",
"manual": "手动",
}
# 编辑牌号表单(动态构建,来源选项由注入的 controller 提供,
# 避免视图层在模块级依赖具体 Controller 类,符合 BLUEPRINT §2.3 偏差 7 收口)
def _build_edit_fields(controller: Any) -> list[FieldSpec]:
return [
FieldSpec("material_grade", "材料牌号", placeholder="如 WCB, CF8, 304"),
FieldSpec(
"material_source",
"来源",
field_type="choice",
choices=controller.material_source_choices(),
default="manual",
),
FieldSpec("material_note", "补全备注", placeholder="手动/自学习补全说明"),
]
[文档]
class BomMaterialQueryView(BaseQueryView):
"""BOM 零件牌号查询视图(BaseQueryView + BomQueryDataSource 迁移版)。"""
def __init__(
self,
session: Any = None,
db_manager: Any = None,
controller: Any = None,
parent: Any = None,
) -> None:
super().__init__(session=session, db_manager=db_manager, parent=parent)
# M5 收口:controller 必须由宿主注入,视图层不再自行实例化下层控制器
# (见 BLUEPRINT §2.3 偏差 7)。未注入视为收口回归。
if controller is None:
raise TypeError(
"BomMaterialQueryView 需要注入 BomMaterialController 实例"
"(经 BaseDataHubWidget 等宿主传入),视图层不得自行实例化控制器。"
)
self._controller = controller
self._add_action_toolbar()
self._connect_table_signals()
self.set_data_source(BomQueryDataSource(self._controller))
# 派生列「源文件名」排序回退到真实字段 source_file(避免无效排序)
for idx, (_, field) in enumerate(self.data_source.get_columns()):
if field == "_source_name":
self.table.set_header_field(idx, "source_file")
# 预置「生产令号」检索行,使按令号检索开箱即用
self.filter_bar.add_row("production_order_no", "contains", "")
# ============================================================
# 工具栏 / 表格交互
# ============================================================
def _add_action_toolbar(self) -> None:
toolbar = ActionToolbar()
toolbar.add_button("🔗 打开源文件", self._on_open_source)
toolbar.add_button("✏️ 编辑牌号", self._on_edit)
toolbar.add_stretch()
self.layout().insertWidget(0, toolbar)
# FilterBar 条件变化 → 防抖重查(按令号检索实时生效)
self._search_timer = QTimer(self)
self._search_timer.setSingleShot(True)
self._search_timer.timeout.connect(lambda: self._do_search(reset_page=True))
self.filter_bar.conditions_changed.connect(self._on_filter_changed)
def _connect_table_signals(self) -> None:
self.table.doubleClicked.connect(self._on_open_source)
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._on_context_menu)
def _on_filter_changed(self) -> None:
self._search_timer.start(250)
# ============================================================
# 数据填充(含派生列 源文件名 + 来源中文)
# ============================================================
@override
def _populate(self, records: list[Any]) -> None:
assert self.data_source is not None
columns = self.data_source.get_columns()
self.table.setRowCount(len(records))
for r, rec in enumerate(records):
for c, (_, field) in enumerate(columns):
if field == "_source_name":
src = getattr(rec, "source_file", "") or ""
val = src.rsplit("\\", 1)[-1].rsplit("/", 1)[-1]
else:
val = getattr(rec, field, None)
if field == "material_source":
val = _SOURCE_LABELS.get(val, val or "")
self.table.setItem(r, c, QTableWidgetItem("" if val is None else str(val)))
[文档]
def refresh(self) -> None:
"""刷新当前结果(导入完成后由查询中心调用,修复 BOM 查询陈旧/双倍)。"""
self._do_search(reset_page=False)
# ============================================================
# 选中行
# ============================================================
def _selected_obj(self) -> Any | None:
row = self.table.currentRow()
if row < 0 or row >= len(self._records):
return None
return self._records[row]
# ============================================================
# 回访源文件
# ============================================================
def _on_open_source(self, *_: Any) -> None:
obj = self._selected_obj()
if obj is None:
QMessageBox.information(self, "提示", "请先选择一行")
return
path = obj.source_file
url = QUrl.fromLocalFile(path)
if not QDesktopServices.openUrl(url):
QMessageBox.warning(self, "提示", f"无法打开文件:\n{path}")
# ============================================================
# 编辑牌号(区分来源)
# ============================================================
def _on_edit(self) -> None:
obj = self._selected_obj()
if obj is None:
QMessageBox.information(self, "提示", "请先选择一行")
return
dlg = LookupRecordEditDialog(_build_edit_fields(self._controller), obj, "编辑牌号", self)
if dlg.exec() != dlg.DialogCode.Accepted:
return
try:
data = dlg.get_data()
except ValueError as e:
QMessageBox.warning(self, "校验失败", str(e))
return
try:
self._controller.update_material_grade(
obj.id,
material_grade=data.get("material_grade"),
material_source=data.get("material_source") or "manual",
material_note=data.get("material_note"),
)
except Exception as e:
logger.error(f"订正牌号失败: {e}")
QMessageBox.critical(self, "错误", f"保存失败: {e}")
return
self.refresh()
# ============================================================
# 右键菜单
# ============================================================
def _on_context_menu(self, pos: Any) -> None:
if self._selected_obj() is None:
return
menu = QMenu(self)
menu.addAction("🔗 打开源文件", self._on_open_source)
menu.addAction("✏️ 编辑牌号", self._on_edit)
menu.exec(self.table.viewport().mapToGlobal(pos))
# ============================================================
# 资源清理
# ============================================================
[文档]
@override
def closeEvent(self, event: Any) -> None:
"""关闭时释放控制器会话(持有独立会话时)。"""
with contextlib.suppress(Exception):
self._controller.close()
super().closeEvent(event)