certflow.views.import_confirm_dialog 源代码

# src/certflow/views/import_confirm_dialog.py

"""销售计划导入「确认」对话框(ImportConfirmDialog)

承接 ImportConfigDialog 的第二步:展示分组/冲突预览,收集 5 选项冲突决议,
确认后执行导入。导入成功后通过 ``result_config_params`` 把配置交回设置对话框
(ImportConfigDialog)转发 ``config_confirmed`` 信号给主窗口刷新。
"""

from __future__ import annotations

from typing import Any

from loguru import logger
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (
    QApplication,
    QComboBox,
    QDialog,
    QHeaderView,
    QLabel,
    QMessageBox,
    QProgressBar,
    QSizePolicy,
    QTableWidgetItem,
    QVBoxLayout,
    QWidget,
)

from certflow.config.settings import _get_user_config
from certflow.widgets import ActionToolbar, ConfirmDialog
from certflow.widgets.enhanced_table import EnhancedTable


[文档] def run_import(session: Any, config_params: dict) -> dict: """执行导入(配置可含 conflict_resolutions),返回统计 dict。 供「0 冲突直接导入」与「确认对话框导入」共用,保证两条路径落库完全一致。 经 ImportController 统一调用面(蓝图 M5:导入链路补齐 Controller 层)。 """ from certflow.controllers.import_controller import ImportController with ImportController(db_session=session) as ctrl: return ctrl.run_import(config_params)
[文档] def should_skip_confirm_dialog( db_conflicts: list, intra_conflicts: list, skip_enabled: bool = True, ) -> bool: """判定是否可跳过确认对话框(0 冲突时直接导入)。 Args: db_conflicts: 跨库冲突列表(来自 _scan_conflicts) intra_conflicts: 批内撞键冲突列表 skip_enabled: import.skip_confirm_when_no_conflict 开关 Returns: bool: 开关开启且两类冲突均为空时为 True """ return bool(skip_enabled) and not db_conflicts and not intra_conflicts
[文档] class ImportConfirmDialog(QDialog): """导入确认对话框:冲突预览 + 5 选项决议 + 执行导入。""" def __init__( self, config_params: dict, preview_records: list[dict], db_conflicts: list[tuple[str, dict, str]], intra_conflicts: list[str], order_groups: int, parent: Any = None, db_manager: Any = None, session: Any = None, ) -> None: super().__init__(parent) self._config_params = config_params self._is_all_months = config_params.get("scope") == "all_months" self._preview_records = preview_records self._db_conflicts = db_conflicts self._intra_conflicts = intra_conflicts self._order_groups = order_groups self.db_manager = db_manager self._session = session self.result_config_params: dict | None = None self._conflict_resolutions: dict[str, str] = {} self._importing = False self.setWindowTitle("销售计划导入 · 确认") self.setMinimumSize(680, 480) self.setModal(True) self.resize(820, 600) self.setup_ui() self._populate_conflicts()
[文档] def setup_ui(self) -> None: layout = QVBoxLayout(self) # 概要 self.summary_label = QLabel() self.summary_label.setStyleSheet("font-weight:bold; padding:4px;") layout.addWidget(self.summary_label) # 冲突表 self.conflict_table = EnhancedTable() self.conflict_table.setColumnCount(5) self.conflict_table.setHorizontalHeaderLabels( ["产品型号", "唯一键", "现有值摘要", "导入动作", "备注"] ) for idx, field in enumerate(["product_model", "unique_key", "summary", "action", "note"]): self.conflict_table.set_header_field(idx, field) self.conflict_table.setMinimumHeight(200) # 列宽策略:长文本列(现有值摘要/备注)拉伸占宽并换行,短列按内容自适应, # 避免长中文被单行行高截断(EnhancedTable 默认 Interactive 不会撑开行高)。 header = self.conflict_table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) # 产品型号 header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # 唯一键 header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) # 现有值摘要(占宽+换行) header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # 导入动作(下拉固定宽) header.resizeSection(3, 96) self.conflict_table.setWordWrap(True) layout.addWidget(self.conflict_table, stretch=1) # 批量应用 batch_bar = ActionToolbar() batch_bar.add_widget(QLabel("批量应用动作:")) self.batch_combo = QComboBox() self.batch_combo.addItems(["跳过", "覆盖", "合并", "隔离", "新建"]) batch_bar.add_widget(self.batch_combo) self.batch_btn = batch_bar.add_button("应用到全部可决议行", self._batch_apply) batch_bar.add_stretch() layout.addWidget(batch_bar) # 撞键处理策略(盲区3 修复:为批次内撞键行提供动作选项) self.collision_bar = ActionToolbar() self.collision_bar.add_widget(QLabel("撞键处理:")) self.collision_policy_combo = QComboBox() self.collision_policy_combo.addItems(["隔离(当前)", "取首行", "丢弃全部重复"]) self.collision_policy_combo.setCurrentText("隔离(当前)") self.collision_bar.add_widget(self.collision_policy_combo) self.collision_bar.add_widget(QLabel("(仅对批次内撞键行生效)")) self.collision_bar.add_stretch() self.collision_policy_combo.currentTextChanged.connect(self._update_isolation_hint) layout.addWidget(self.collision_bar) # 进度条 self.progress_bar = QProgressBar() self.progress_bar.setVisible(False) self.progress_bar.setMinimum(0) self.progress_bar.setMaximum(0) # 不确定进度(动画模式) self.progress_bar.setTextVisible(True) self.progress_bar.setFormat("导入中...") layout.addWidget(self.progress_bar) # 撞键隔离提示(导入前让用户感知将自动隔离的撞键行) self.isolation_hint_label = QLabel() self.isolation_hint_label.setStyleSheet( "color:#b8860b; font-weight:bold; padding:6px; " "background-color:#fff8e1; border:1px solid #f0c36d; border-radius:4px;" ) self.isolation_hint_label.setWordWrap(True) self.isolation_hint_label.setVisible(False) layout.addWidget(self.isolation_hint_label) # 按钮 button_bar = ActionToolbar() self.back_btn = button_bar.add_button("← 返回", self.reject) button_bar.add_stretch() self.confirm_btn = button_bar.add_button("确认导入", self._confirm_import, primary=True) self.confirm_btn.setStyleSheet( "background-color: #2c7be5; color: white; padding: 8px 20px;" ) layout.addWidget(button_bar)
[文档] def showEvent(self, event) -> None: # noqa: N802 Qt 覆写方法,驼峰命名由框架规定 """首次显示后按内容撑开冲突表行高,并对过长行设上限。 行高由 ``resizeRowsToContents`` 按当前字体/DPI 推导,因此不同屏幕缩放下 表现一致(前提是应用启用了 ``AA_EnableHighDpiScaling``)。 不再强制所有行等高——旧实现取 ``max_h`` 统一行高,一旦某行摘要换行较多, 会把所有短行也拉高,视觉上「行高变高」。现改为:内容高 ≤ 上限时按内容, 超过上限则截断到上限(配合 ``setWordWrap`` 长文本仍完整换行显示)。 列 3(导入动作)的单元格放的是 QWidget 容器,内部 combo 用 ``minimumHeight`` 占位并垂直居中,不依赖整行等高也能整齐对齐。 """ super().showEvent(event) def _fit_rows() -> None: t = self.conflict_table t.resizeRowsToContents() n = t.rowCount() if n == 0: return # 行高上限:约 3 行正文 + 上下边距,避免单行摘要过长把整表撑爆。 # 随字体度量派生,高 DPI 下自动放大,保证物理观感一致。 fm = t.fontMetrics() line_h = fm.lineSpacing() or fm.height() max_h = line_h * 3 + 12 for r in range(n): h = t.rowHeight(r) if h > max_h: t.setRowHeight(r, max_h) QTimer.singleShot(0, _fit_rows)
def _populate_conflicts(self) -> None: # 全部月份表(大批量):不做逐表冲突扫描,改为信息提示,由导入时默认冲突策略处理跨表重复 if self._is_all_months: total = self._config_params.get("_all_months_total", len(self._preview_records)) self.summary_label.setText( f"⚠ 全部月份表模式:将依次导入 1-12 月中存在的全部欠交表(约 {total} 行)。\n" f"跨表重复行按「冲突处理默认策略」(建议在 Tab③ 设为「跳过」)自动排除结转重复。" ) self.conflict_table.setRowCount(0) self.isolation_hint_label.setVisible(False) self.collision_bar.setVisible(False) return n = len(self._preview_records) self.summary_label.setText( f"待导入 {n} 行 → {self._order_groups} 个订单组 | " f"批次内冲突 {len(self._intra_conflicts)} | " f"数据库已存在冲突 {len(self._db_conflicts)}" ) # 撞键行导入前提示:让用户感知撞键行将如何处理(隔离/取首行/丢弃全部) self.collision_bar.setVisible(bool(self._intra_conflicts)) self._update_isolation_hint() self.conflict_table.setRowCount(0) self._conflict_resolutions = {} for uk, rec, summary in self._db_conflicts: self._add_conflict_row(uk, rec, summary, "数据库已存在,选择导入动作") for uk in self._intra_conflicts: self._add_conflict_row( uk, {}, "(批次内多行同唯一键但内容不同)", "请先在源表合并/改值后重导", readonly=True ) # 撞键处理策略:UI 中文 -> handler 英文(与 save_handler._apply_batch_collision 一致) _COLLISION_POLICY_MAP = { "隔离(当前)": "isolate", "取首行": "first", "丢弃全部重复": "discard", } def _update_isolation_hint(self) -> None: """根据当前撞键处理策略动态刷新提示文案(盲区3 修复)。""" if not self._intra_conflicts: self.isolation_hint_label.setVisible(False) return cnt = len(self._intra_conflicts) policy_cn = self.collision_policy_combo.currentText() if policy_cn == "取首行": text = ( f"⚠ 本批含 {cnt} 个撞键唯一键:按「取首行」处理,重复行直接丢弃" f"(首行保留入主表,不进待处理表)。请先回源合并/改值后重导,或确认后导入。" ) elif policy_cn == "丢弃全部重复": text = ( f"⚠ 本批含 {cnt} 个撞键唯一键:按「丢弃全部重复」处理,整组(含首行)" f"直接丢弃,不进主表/待处理表。请先回源合并/改值后重导,或确认后导入。" ) else: # 隔离(当前) text = ( f"⚠ 本批含 {cnt} 个撞键唯一键:导入时将自动隔离到「待处理表」," f"主表不会重复写入。请先回源合并/改值后重导,或确认后导入。" ) self.isolation_hint_label.setText(text) self.isolation_hint_label.setVisible(True) def _add_conflict_row( self, uk: str, rec: dict, summary: str, note: str, readonly: bool = False ) -> None: """向冲突表追加一行,含动作下拉(5 选项)。""" ridx = self.conflict_table.rowCount() self.conflict_table.insertRow(ridx) pm = str(rec.get("product_model", "") or "")[:30] self.conflict_table.setItem(ridx, 0, QTableWidgetItem(pm)) self.conflict_table.setItem(ridx, 1, QTableWidgetItem(uk)) # 现有值摘要:完整保留(去掉原 [:40] 硬截断),配合列宽拉伸+换行+撑开行高显示 summary_item = QTableWidgetItem(str(summary)) summary_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) self.conflict_table.setItem(ridx, 2, summary_item) combo = QComboBox() combo.addItems(["跳过", "覆盖", "合并", "隔离", "新建"]) # 高度随字体度量派生(而非硬编码 22px),高 DPI / 大字体下自动放大, # 避免因像素与 DPI 不一致而导致下拉框比同行文字偏矮、错位。 # 用 minimumHeight 占位:容器被表格拉伸填满整格,combo 在容器内垂直居中, # 不再依赖「整行等高」即可形成对齐列(showEvent 仅对过长行封顶,短行不被拉高)。 combo_fm = combo.fontMetrics() combo_h = combo_fm.lineSpacing() + 8 combo.setMinimumHeight(combo_h) combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) if readonly: combo.setEnabled(False) combo.setCurrentText("跳过") else: combo.setCurrentText("跳过") combo.currentTextChanged.connect( lambda txt, k=uk: self._conflict_resolutions.update({k: self._action_en(txt)}) ) self._conflict_resolutions[uk] = "skip" combo_container = QWidget() cvl = QVBoxLayout(combo_container) cvl.setContentsMargins(2, 2, 2, 2) cvl.setSpacing(0) # 垂直居中:无论行高被内容撑到多少,下拉框都居于单元格中央,列对齐更稳。 cvl.addWidget(combo, alignment=Qt.AlignmentFlag.AlignVCenter) self.conflict_table.setCellWidget(ridx, 3, combo_container) note_item = QTableWidgetItem(note) note_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) self.conflict_table.setItem(ridx, 4, note_item) def _batch_apply(self) -> None: """将批量动作应用到所有可决议(非只读)行。""" text = self.batch_combo.currentText() for r in range(self.conflict_table.rowCount()): # 单元格放的是 QVBoxLayout 容器,需取出其中的 QComboBox cell = self.conflict_table.cellWidget(r, 3) combo = cell.findChild(QComboBox) if cell is not None else None if combo is None or not combo.isEnabled(): continue combo.setCurrentText(text) @staticmethod def _action_en(cn: str) -> str: """中文动作 -> 英文(与 save_handler._resolve_existing 一致)。""" return { "跳过": "skip", "覆盖": "overwrite", "合并": "merge", "隔离": "isolate", "新建": "new", }.get(cn, "skip") def _build_conflict_resolutions(self) -> dict[str, str]: return dict(self._conflict_resolutions) def _should_confirm_overwrite(self) -> bool: """检查用户偏好是否要求导入前确认。""" return _get_user_config().get("preferences", {}).get("confirm_before_overwrite", True) def _set_ui_enabled(self, enabled: bool) -> None: self.back_btn.setEnabled(enabled) self.batch_btn.setEnabled(enabled) self.batch_combo.setEnabled(enabled) self.conflict_table.setEnabled(enabled) # confirm_btn 由调用方单独控制 def _confirm_import(self) -> None: """收集冲突决议并执行导入;成功后交回 config_params。""" if self._session is None: self._show_import_result("无法导入", "缺少数据库会话,无法执行导入。", is_success=False) return if self._should_confirm_overwrite() and self._db_conflicts: confirmed = ConfirmDialog.danger( self, "确认导入", "即将导入数据,可能覆盖已有记录。\n\n确定要继续吗?", ) if not confirmed: return self._importing = True self._set_ui_enabled(False) self.progress_bar.setVisible(True) self.confirm_btn.setText("导入中...") QApplication.processEvents() try: config = dict(self._config_params) resolutions = self._build_conflict_resolutions() if resolutions: config["conflict_resolutions"] = resolutions # 盲区3 修复:批次内撞键处理策略透传给 service(覆盖 ON_COLLISION 配置) config["collision_policy"] = self._COLLISION_POLICY_MAP[ self.collision_policy_combo.currentText() ] result = run_import(self._session, config) logger.info("导入完成") msg = self._build_import_result_message(result) self._show_import_result("导入成功", msg, is_success=True) self.result_config_params = config self.accept() except Exception as e: logger.error(f"导入失败: {e}") self._show_import_result("导入失败", f"导入过程中发生错误:\n{e}", is_success=False) finally: self._importing = False self._set_ui_enabled(True) self.progress_bar.setVisible(False) self.confirm_btn.setText("确认导入") @staticmethod def _build_import_result_message(result: dict) -> str: """构建导入结果消息,按隔离来源分别计数展示(门控/撞键/冲突决议)。""" total = result.get("total", 0) new_count = result.get("new_count", 0) skipped = result.get( "skipped_count", result.get("skipped", result.get("duplicate_count", 0)) ) changes = result.get("changes_count", result.get("changes", 0)) gate_q = result.get("gate_quarantined", 0) collision_q = result.get("collision_quarantined", 0) collision_dropped = result.get("collision_dropped", 0) resolution_q = result.get("resolution_quarantined", 0) change_categories = result.get("change_categories") or {} parts = [f"总记录: {total}"] if new_count: parts.append(f"新增: {new_count}") if skipped: parts.append(f"跳过(已存在且无变更): {skipped}") if gate_q: parts.append(f"门控隔离(缺要货单号): {gate_q}") if collision_q: parts.append(f"撞键隔离(唯一键重复): {collision_q}") if collision_dropped: parts.append(f"撞键丢弃(取首行/全弃): {collision_dropped}") if resolution_q: parts.append(f"冲突决议隔离: {resolution_q}") if changes: parts.append(f"变更(已存在但内容变化): {changes}") for cat, cnt in change_categories.items(): parts.append(f" · {cat}: {cnt}") return "\n".join(parts) def _show_import_result(self, title: str, message: str, is_success: bool = True) -> None: """显示导入结果消息。""" if is_success: QMessageBox.information(self, title, message) else: QMessageBox.critical(self, title, message)