certflow.views.bom_material_query_view 源代码

"""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)