certflow.views.bom_import_widget 源代码

"""BOM 零件牌号导入控件(目录扫描入库)

作为「计划导入」堆叠页的第二个标签页(与销售计划导入并列),复用
``certflow.services.bom_material_service.scan_and_import`` 做目录递归扫描入库。
对应 CLI ``certflow bom import``;``--mode`` 对应本控件的「导入模式」四选一。

提供四种导入模式,UI 上明确告知用法与去重风险:
- 清空重导(clear):清除全表后重导,结果干净、无重复;重建全表用;
- 直接追加(append):不清空直接插入,同一文件重复导入会产生重复行;
- 按文件刷新(refresh):已导入文件删旧行写最新内容,新文件追加;
- 增量跳过已导入(skip):只导未入库文件,已存在文件(即便已更新)跳过。

默认目录与默认模式由配置 ``bom_import.default_folder`` / ``bom_import.default_mode``
(config/import.yaml,经 !include 并入)驱动;本控件打开时自动预填目录、预选模式,
日常只扫新文件把 ``default_mode`` 设为 ``skip`` 即可,需重建全表时再改回 ``clear``
或在 UI 手动切换,无需改代码。

导入在后台线程执行,避免阻塞 UI;通过 ``import_started`` / ``import_finished``
信号通知外层(用于锁定导航)。

蓝图关联:``docs/BLUEPRINT_QUERY_UNIFY.md`` 阶段 B1(BOM 导入 tab)。
"""

from __future__ import annotations

import contextlib
from pathlib import Path
from typing import Any

from PySide6.QtCore import QThread, Signal
from PySide6.QtWidgets import (
    QFileDialog,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QProgressBar,
    QPushButton,
    QRadioButton,
    QTextEdit,
    QVBoxLayout,
    QWidget,
)

from certflow.config.settings import cfg
from certflow.controllers.bom_material_controller import BomMaterialController
from certflow.utils.logger import logger

# 合法的导入模式键(与 scan_and_import 的 BomImportMode 对应)
_VALID_MODES = ("clear", "append", "refresh", "skip")


class _BomScanWorker(QThread):
    """后台扫描入库线程。"""

    log = Signal(str)
    done = Signal(dict)
    failed = Signal(str)

    def __init__(
        self,
        db_manager: Any,
        session: Any,
        folder: str,
        mode: str,
    ) -> None:
        super().__init__()
        self._db = db_manager
        self._session = session
        self._folder = folder
        self._mode = mode

    def run(self) -> None:  # noqa: D401
        session = None
        try:
            session = self._db.get_session() if self._db is not None else self._session
            self.log.emit(f"开始扫描目录: {self._folder}(模式: {self._mode})")
            stat = BomMaterialController.scan_and_import(self._folder, session, mode=self._mode)
            self.log.emit(
                f"导入统计: {stat.get('files')} 文件 / "
                f"{stat.get('rows')} 行零件 / {stat.get('orders')} 个生产令号"
            )
            if stat.get("new_files"):
                self.log.emit(f"  新增文件: {stat['new_files']}")
            if stat.get("refreshed_files"):
                self.log.emit(f"  刷新文件: {stat['refreshed_files']}")
            if stat.get("skipped_files"):
                self.log.emit(f"  跳过已导入: {stat['skipped_files']}")
            self.done.emit(stat)
        except Exception as e:  # noqa: BLE001
            logger.error(f"BOM 扫描入库失败: {e}")
            self.failed.emit(str(e))
        finally:
            if self._db is not None and session is not None:
                with contextlib.suppress(Exception):
                    session.close()


[文档] class BomImportWidget(QWidget): """BOM 导入控件:目录选择 + 追加/清除选项 + 扫描入库 + 进度/日志。""" import_started = Signal() import_finished = Signal() def __init__( self, db_manager: Any = None, session: Any = None, parent: QWidget | None = None, ) -> None: super().__init__(parent) self._db_manager = db_manager self._session = session self._worker: _BomScanWorker | None = None self._init_ui() def _init_ui(self) -> None: layout = QVBoxLayout(self) layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(10) tip = QLabel( "扫描「生产-下料计划 TAO」材料清单目录(含子目录递归 .xls/.xlsx)," "解析零件牌号并入库到 bom_materials 表,供报告生成时按生产令号查询阀体/阀杆等材质。" ) tip.setWordWrap(True) tip.setStyleSheet("font-size: 11px; color: #666;") layout.addWidget(tip) # —— 目录选择 —— dir_row = QHBoxLayout() dir_row.addWidget(QLabel("材料清单目录:")) self._folder_edit = QLineEdit() self._folder_edit.setPlaceholderText("点击「浏览」选择材料清单根目录") # 配置驱动:bom_import.default_folder 预填默认目录(留空则不预填) default_folder = (str(cfg("bom_import.default_folder", "")) or "").strip() if default_folder: self._folder_edit.setText(default_folder) dir_row.addWidget(self._folder_edit, stretch=1) self._browse_btn = QPushButton("📁 浏览...") self._browse_btn.clicked.connect(self._browse) dir_row.addWidget(self._browse_btn) layout.addLayout(dir_row) # —— 导入模式(四选一,明确告知用法与去重风险)—— mode_box = QGroupBox("导入模式(决定旧数据如何处理)") mode_layout = QVBoxLayout(mode_box) mode_layout.setSpacing(4) self._mode_radios: dict[str, QRadioButton] = {} self._mode_descs: dict[str, str] = {} _mode_defs = [ ( "clear", "清空重导(推荐)", "清除全部旧数据后重新导入,结果干净、无重复。适合首次导入或目录整体更新。", ), ( "append", "直接追加", "不清空,所有扫描到的零件直接插入。⚠ 同一文件重复导入会产生重复行(如需去重请用「按文件刷新」)。", ), ( "refresh", "按文件刷新", "不清空;已导入过的文件删除其旧零件再写入最新内容(某文件 12→10 行则删 2、12→15 行则加 5),新文件追加。库与目录当前内容一致。", ), ( "skip", "增量跳过已导入", "不清空;只导入尚未入库的新文件,已导入文件(即便内容已更新)跳过。最安全最快;想刷新旧文件请改用「清空重导」。", ), ] for key, title, desc in _mode_defs: rb = QRadioButton(title) rb.setToolTip(desc) rb.toggled.connect(lambda checked, k=key: self._on_mode_toggled(k, checked)) mode_layout.addWidget(rb) self._mode_radios[key] = rb self._mode_descs[key] = desc self._mode_help = QLabel() self._mode_help.setWordWrap(True) self._mode_help.setStyleSheet( "font-size: 11px; color: #555; padding: 6px 8px; " "background: #f5f5f5; border-radius: 4px;" ) mode_layout.addWidget(self._mode_help) self._mode_radios["clear"].setChecked(True) # 配置驱动:bom_import.default_mode 决定打开时预选模式(默认 skip) default_mode = (str(cfg("bom_import.default_mode", "skip")) or "skip").strip().lower() if default_mode not in _VALID_MODES: default_mode = "skip" self._mode_radios[default_mode].setChecked(True) self._on_mode_toggled(default_mode, True) # 初始化帮助文本 layout.addWidget(mode_box) # —— 扫描按钮 —— btn_row = QHBoxLayout() self._scan_btn = QPushButton("🚀 扫描入库") self._scan_btn.setStyleSheet("background-color: #28a745; color: white; padding: 8px 20px;") self._scan_btn.clicked.connect(self._scan) btn_row.addWidget(self._scan_btn) btn_row.addStretch() layout.addLayout(btn_row) # —— 进度 —— self._progress = QProgressBar() self._progress.setVisible(False) self._progress.setMinimum(0) self._progress.setMaximum(0) self._progress.setFormat("扫描中...") layout.addWidget(self._progress) # —— 日志 —— self._log_area = QTextEdit() self._log_area.setReadOnly(True) self._log_area.setPlaceholderText("导入日志将显示在这里...") self._log_area.setMaximumHeight(160) layout.addWidget(self._log_area) layout.addStretch() def _browse(self) -> None: start = self._folder_edit.text() or "" folder = QFileDialog.getExistingDirectory(self, "选择材料清单目录", start) if folder: self._folder_edit.setText(folder) def _scan(self) -> None: folder = self._folder_edit.text().strip() if not folder or not Path(folder).exists(): from PySide6.QtWidgets import QMessageBox QMessageBox.warning(self, "提示", "请先选择有效的材料清单目录") return mode = next(k for k, rb in self._mode_radios.items() if rb.isChecked()) self._set_ui_enabled(False) self._progress.setVisible(True) self.import_started.emit() self._worker = _BomScanWorker(self._db_manager, self._session, folder, mode) self._worker.log.connect(self._log_area.append) self._worker.done.connect(self._on_done) self._worker.failed.connect(self._on_failed) self._worker.finished.connect(self._worker.deleteLater) self._worker.start() def _on_mode_toggled(self, key: str, checked: bool) -> None: """选中某模式时更新帮助文本(告知用法/去重风险)。""" if checked: self._mode_help.setText(self._mode_descs.get(key, "")) def _on_done(self, stat: dict) -> None: self._log_area.append( f"✅ 导入完成: 写入文件 {stat.get('files')} " f"(新增 {stat.get('new_files', 0)} / 刷新 {stat.get('refreshed_files', 0)} " f"/ 跳过 {stat.get('skipped_files', 0)})," f"{stat.get('rows')} 行零件, {stat.get('orders')} 个生产令号" ) self._finish() def _on_failed(self, msg: str) -> None: self._log_area.append(f"❌ 导入失败: {msg}") self._finish() def _finish(self) -> None: self._progress.setVisible(False) self._set_ui_enabled(True) self.import_finished.emit() def _set_ui_enabled(self, enabled: bool) -> None: self._browse_btn.setEnabled(enabled) self._folder_edit.setEnabled(enabled) for rb in self._mode_radios.values(): rb.setEnabled(enabled) self._scan_btn.setEnabled(enabled)