certflow.views.scan_view 源代码

# src/certflow/views/scan_view.py
"""合格证扫描件生成视图(WYSIWYG 单页 · 配置驱动)

设计(呼应 VBA「插入背景图片 + 浮动透明文本框」思路,§11.5 OutputView 子页):
- 左右分屏(QSplitter):
  - 左:实时预览 —— 以 coordinates.yaml 的坐标在 底图 上绘制字段(复用 ScanImageHandler),
    所见即所得,与正式生成的扫描件版式完全一致(避免「点击生成扫描件看不到信息」)。
  - 右:参数配置 —— 可编辑的合格证字段表单(产品名称/型号/规格/PN/温度/介质/标准/工号/日期/编号)
    + 扫描选项(模板/格式/纸张/拼版/输出目录)+ 生成按钮。
- 字段值由 UI 编辑(内存态 data_overrides,不落库),任一改动即时重绘预览;
  生成时把覆盖值沿 ScanService 管道传给渲染,保证预览 == 产出。
"""

from __future__ import annotations

import os
import subprocess
import sys
from typing import Any

from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import (
    QComboBox,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMessageBox,
    QProgressBar,
    QPushButton,
    QScrollArea,
    QSpinBox,
    QSplitter,
    QVBoxLayout,
    QWidget,
)

from certflow.config.paths_override import cfg
from certflow.config.settings import (
    get_scan_default_layout,
    get_scan_layout_presets,
)
from certflow.controllers.scan_controller import ScanController
from certflow.utils.logger import logger

# 可编辑的底层字段(与 Certificate 列对应;覆盖值直接喂给 ScanService._build_cert_data)
# (底层字段名, 表单中文标签, 控件类型) —— 控件类型: "text" 文本框 / "unit" 压力单位下拉
_FIELD_DEFS: list[tuple[str, str, str]] = [
    ("product_name", "产品名称", "text"),
    ("product_model", "产品型号", "text"),
    ("product_spec", "规格 / DN", "text"),
    ("pn_value", "公称压力值", "text"),
    ("pn_unit", "压力单位", "unit"),
    ("working_temp", "工作温度", "text"),
    ("working_medium", "适用介质", "text"),
    ("test_standard", "检验标准", "text"),
    ("inspector_id", "检验工号", "text"),
    ("issue_date", "出厂日期 (YYYY.MM)", "text"),
    ("product_code_range", "产品编号区间", "text"),
]

# 扫描生成前完整性闸门必检字段(配置驱动;沿用 output.targets.scan.required_fields)
_SCAN_REQUIRED_FIELDS_DEFAULT = [
    "product_name",
    "product_model",
    "product_spec",
    "test_standard",
    "inspector_id",
    "issue_date",
]


[文档] class ScanView(QWidget): """合格证扫描件生成视图(WYSIWYG 单页)""" scan_completed = Signal(list) def __init__( self, session: Any, db_manager: Any = None, parent: Any = None, scan_controller: Any = None, ) -> None: """初始化 ScanView。 Args: session: SQLAlchemy 会话对象 db_manager: 数据库管理器实例 parent: 父控件(可选) scan_controller: 注入共享扫描控制器(OutputController 统一调度, §11.8 阶段4);为 None 时沿用原有懒加载逻辑(测试/旧调用方零改动)。 """ super().__init__(parent) self.db_manager = db_manager self.session = session # 当前选中的打印模板 key(与打印侧 9 变体一一对应,驱动扫描件底图) self._template_key = "full_chinese_1" # 双编码(SN/KKS)开关:默认关闭,仅特殊用户/场景经配置启用 self._dual_code = bool(cfg("output.targets.scan.dual_code.enabled", False)) # 可编辑字段集:双编码开启时追加 sn_code/kks_code(从 Certificate/SalePlan 取) self._field_defs = list(_FIELD_DEFS) if self._dual_code: self._field_defs = self._field_defs + [ ("sn_code", "SN 短码", "text"), ("kks_code", "KKS 长码", "text"), ] self._controller: ScanController | None = None if scan_controller is not None: self._controller = scan_controller self._controller.progress.connect(self._on_progress) self._controller.completed.connect(self._on_finished) self._controller.error.connect(self._on_error) self._certificates: list[Any] = [] self._cert_by_id: dict[int, Any] = {} # cert.id -> 底层字段覆盖字典(内存态,不落库) self._edits: dict[int, dict[str, Any]] = {} self._selected_id: int | None = None self._unit_index: int = 0 # 当前预览/展开到的本批第几台(0 基) self._combo_base_text: dict[int, str] = {} # cert.id -> 下拉项原始文本(用于缺失标注) self._current_template: str = cfg("output.default_target", "print") # 仅占位,下方初始化 self._controller = self._controller or ScanController(self.session) self._preview_pixmap: QPixmap | None = None self._init_ui() self._load_default_output_dir() # ============================================================ # UI 构建(左右分屏) # ============================================================ def _init_ui(self) -> None: root = QHBoxLayout(self) splitter = QSplitter(Qt.Orientation.Horizontal) root.addWidget(splitter) # ---------- 左:实时预览 ---------- preview_widget = QWidget() preview_layout = QVBoxLayout(preview_widget) preview_layout.setContentsMargins(8, 8, 8, 8) self.preview_label = QLabel("请从打印页面跳转或加载合格证以预览") self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.preview_label.setStyleSheet("background:#f0f0f0; border:1px solid #ccc; color:#666;") self.preview_label.setMinimumSize(300, 500) preview_scroll = QScrollArea() preview_scroll.setWidgetResizable(True) preview_scroll.setWidget(self.preview_label) preview_layout.addWidget(preview_scroll) splitter.addWidget(preview_widget) # ---------- 右:参数配置(可滚动) ---------- right_widget = QWidget() right_layout = QVBoxLayout(right_widget) right_layout.setContentsMargins(8, 8, 8, 8) right_scroll = QScrollArea() right_scroll.setWidgetResizable(True) right_inner = QWidget() right_form = QVBoxLayout(right_inner) right_form.setContentsMargins(4, 4, 4, 4) right_scroll.setWidget(right_inner) right_layout.addWidget(right_scroll) # 合格证选择 + 本批台号导航 sel_group = QGroupBox("合格证选择 / 预览台号") sel_outer = QVBoxLayout(sel_group) sel_row = QHBoxLayout() sel_row.addWidget(QLabel("当前合格证:")) self.cert_combo = QComboBox() self.cert_combo.setMinimumWidth(240) self.cert_combo.currentIndexChanged.connect(self._on_cert_selected) sel_row.addWidget(self.cert_combo, 1) sel_outer.addLayout(sel_row) unit_row = QHBoxLayout() unit_row.addWidget(QLabel("预览台号:")) self.unit_spin = QSpinBox() self.unit_spin.setRange(1, 1) self.unit_spin.valueChanged.connect(self._on_unit_changed) unit_row.addWidget(self.unit_spin) self.unit_label = QLabel("/ 1 台") unit_row.addWidget(self.unit_label, 1) sel_outer.addLayout(unit_row) right_form.addWidget(sel_group) # 字段表单(两列网格:纵向行数减半,压缩高度避免滚动条) field_group = QGroupBox("合格证字段(编辑即时预览)") field_grid = QGridLayout(field_group) field_grid.setVerticalSpacing(4) field_grid.setHorizontalSpacing(8) field_grid.setColumnStretch(1, 1) field_grid.setColumnStretch(3, 1) self._field_edits: dict[str, QWidget] = {} for i, (key, label, kind) in enumerate(self._field_defs): if kind == "unit": widget = QComboBox() widget.addItems(["", "MPa", "Lb"]) widget.currentTextChanged.connect(lambda _=None, k=key: self._on_field_edited(k)) else: widget = QLineEdit() widget.editingFinished.connect(lambda k=key: self._on_field_edited(k)) # 文本变化也即时预览(更跟手) widget.textChanged.connect(lambda _=None, k=key: self._on_field_edited(k)) self._field_edits[key] = widget row = i // 2 col = (i % 2) * 2 field_grid.addWidget(QLabel(label), row, col) field_grid.addWidget(widget, row, col + 1) right_form.addWidget(field_group) # 扫描选项(两列网格,进一步压缩) # 默认值配置驱动:读取 output.targets.scan.defaults,未配置则用括号回退值 sd = cfg("output.targets.scan.defaults", {}) or {} opt_group = QGroupBox("扫描选项") opt_grid = QGridLayout(opt_group) opt_grid.setVerticalSpacing(4) opt_grid.setHorizontalSpacing(8) opt_grid.setColumnStretch(1, 1) opt_grid.setColumnStretch(3, 1) self.template_combo = QComboBox() # 模板下拉由 print_templates 配置驱动(9 个变体,含印章差异),与打印侧一致; # 每项 userData 存模板 key(如 full_chinese_1),便于扫描件底图与打印一一对应。 self._template_options = self._load_template_options() for _label, key in self._template_options: self.template_combo.addItem(_label, key) self._template_key = self._resolve_template_key(sd.get("template_type", "全中文")) idx = next( (i for i, (_l, k) in enumerate(self._template_options) if k == self._template_key), 0 ) self.template_combo.setCurrentIndex(idx) self.template_combo.currentIndexChanged.connect(self._on_template_changed) opt_grid.addWidget(QLabel("模板类型:"), 0, 0) opt_grid.addWidget(self.template_combo, 0, 1) self.format_combo = QComboBox() self.format_combo.addItems(["JPG", "PDF"]) self.format_combo.setCurrentText(sd.get("format", "JPG")) self.format_combo.currentTextChanged.connect(self._on_option_changed) opt_grid.addWidget(QLabel("输出格式:"), 0, 2) opt_grid.addWidget(self.format_combo, 0, 3) # 纸张尺寸:固化 config(60×100mm),UI 完全不暴露编辑控件(不创建、不显示); # 值从 output.targets.scan.defaults 读取,供预览/生成使用。 self._paper_width_mm = int(sd.get("paper_width_mm", 60)) self._paper_height_mm = int(sd.get("paper_height_mm", 100)) self.row_spin = QSpinBox() self.row_spin.setRange(1, 10) self.row_spin.setValue(int(sd.get("cols", 1))) self.row_spin.valueChanged.connect(self._on_layout_spin_changed) opt_grid.addWidget(QLabel("每行张数(横):"), 1, 0) opt_grid.addWidget(self.row_spin, 1, 1) self.col_spin = QSpinBox() self.col_spin.setRange(1, 10) self.col_spin.setValue(int(sd.get("rows", 1))) self.col_spin.valueChanged.connect(self._on_layout_spin_changed) opt_grid.addWidget(QLabel("每列张数(纵):"), 1, 2) opt_grid.addWidget(self.col_spin, 1, 3) # 推荐拼版下拉(来自 config layout_presets):选预设即套用 rows/cols 并灰显自旋框; # 选「自定义」则启用自旋框手动调整。启动默认由 _apply_default_layout 套用。 self.layout_combo = QComboBox() self._layout_presets = get_scan_layout_presets() self.layout_combo.addItem("自定义") for _p in self._layout_presets: self.layout_combo.addItem(_p.get("label", f"{_p.get('cols')}×{_p.get('rows')}")) self.layout_combo.currentIndexChanged.connect(self._on_layout_preset_changed) opt_grid.addWidget(QLabel("推荐拼版:"), 2, 0) opt_grid.addWidget(self.layout_combo, 2, 1) self._apply_default_layout() out_row = QHBoxLayout() self.path_edit = QLineEdit(cfg("paths.output.scans", "output/scans")) browse_btn = QPushButton("浏览...") browse_btn.clicked.connect(self._browse_output_dir) out_row.addWidget(self.path_edit, 1) out_row.addWidget(browse_btn) opt_grid.addWidget(QLabel("输出目录:"), 3, 0) opt_grid.addLayout(out_row, 3, 1, 1, 3) self.path_edit.editingFinished.connect(self._on_option_changed) right_form.addWidget(opt_group) # 信息 + 进度 + 按钮 self.info_label = QLabel("请从打印页面跳转或手动加载合格证") self.info_label.setStyleSheet("color:#666; padding:6px;") right_form.addWidget(self.info_label) self.progress_bar = QProgressBar() self.progress_bar.setVisible(False) right_form.addWidget(self.progress_bar) btn_layout = QHBoxLayout() self.generate_btn = QPushButton("生成扫描件") self.generate_btn.setStyleSheet("background-color:#28a745; color:white; padding:8px;") self.generate_btn.clicked.connect(self._generate_scans) self.generate_btn.setEnabled(False) btn_layout.addWidget(self.generate_btn) self.open_btn = QPushButton("打开输出目录") self.open_btn.clicked.connect(self._open_output_dir) btn_layout.addWidget(self.open_btn) right_form.addLayout(btn_layout) splitter.addWidget(right_widget) # 初始分屏比例:预览稍宽 splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 2) # ============================================================ # 数据加载 # ============================================================
[文档] def load_certificates(self, certificate_ids: list[int]) -> None: """加载要生成扫描件的合格证(来自打印视图跳转或外部调用)。""" controller = self._get_controller() self._certificates = controller.load_certificates(certificate_ids) self._cert_by_id = {c.id: c for c in self._certificates} self._edits = {c.id: self._db_values(c) for c in self._certificates} # 双编码开启时,预填 SN/KKS 基准值(来自关联 SalePlan) if self._dual_code: for c in self._certificates: self._edits[c.id].update(self._load_dual_defaults(c)) self.cert_combo.blockSignals(True) self.cert_combo.clear() self._combo_base_text = {} for c in self._certificates: base = f"{c.certificate_no} {c.product_name or ''}" self._combo_base_text[c.id] = base self.cert_combo.addItem(base, c.id) self.cert_combo.blockSignals(False) # 加载后即时标注缺失项,便于一眼定位待补合格证 self._refresh_combo_markers() # 按模板族灰显/启用压力单位栏(无论是否加载到合格证都需设初值) self._apply_family_constraints() if self._certificates: self._selected_id = self._certificates[0].id self.cert_combo.setCurrentIndex(0) self._populate_form(self._selected_id) # 初始化「预览台号」导航:setCurrentIndex(0) 不会触发 currentIndexChanged # (索引本就是 0),故必须显式刷新,否则台数滚动框停留在初始 1(BUG: # 预览已显示展开后的首台编号,但台号上限仍是 1)。 self._update_unit_nav() self._render_preview() self.generate_btn.setEnabled(True) self.info_label.setText(f"已加载 {len(self._certificates)} 份合格证,可编辑字段后生成") else: self._selected_id = None self.preview_label.setText("无合格证数据") self.generate_btn.setEnabled(False) self.info_label.setText("未加载到合格证")
def _split_pn_display(self, pn_display: str) -> tuple[str, str]: """从打印界面显示的 ``pn_display``(如 ``25MPa`` / ``150Lb``)拆出 ``(数值, 单位)``, 供扫描表单以「数值 + 单位」方式呈现,保证与打印界面压力一致。 压力单位解析经注入的 ``ScanController`` 委托(阶段 C 收口,消除对 ``PNService`` 的直连)。 无法拆分(如空串)时返回 ``("", "")``,由调用方回退证书存储值。 """ if not pn_display: return "", "" ctrl = self._controller or ScanController value, unit = ctrl.strip_unit(pn_display) return (value or ""), (unit or "") def _db_values(self, cert: Any) -> dict[str, Any]: """从 Certificate 取底层字段初值(覆盖字典的基准)。 公称压力与打印界面同源:打印界面显示 ``cert.pn_display``,扫描表单以 「数值 + 单位」呈现,故优先从 ``pn_display`` 拆出,避免 ``cert.pn_value`` 与 ``pn_display`` 因旧数据 / 不同写入路径分裂时,扫描件压力与打印压力对不上 (如 pn_value='2' 而 pn_display='25MPa' 的场景)。``pn_display`` 为空时回退 证书存储的 ``pn_value`` / ``pn_unit``。 """ pn_display = getattr(cert, "pn_display", "") or "" pn_value, pn_unit = self._split_pn_display(pn_display) if not pn_value: pn_value = getattr(cert, "pn_value", "") or "" pn_unit = getattr(cert, "pn_unit", "") or "" return { "product_name": getattr(cert, "product_name", "") or "", "product_model": getattr(cert, "product_model", "") or "", "product_spec": getattr(cert, "product_spec", "") or "", "pn_value": pn_value, "pn_unit": pn_unit, "working_temp": getattr(cert, "working_temp", "") or "", "working_medium": getattr(cert, "working_medium", "") or "", "test_standard": getattr(cert, "test_standard", "") or "", "inspector_id": getattr(cert, "inspector_id", "") or "", "issue_date": getattr(cert, "issue_date", "") or "", "product_code_range": getattr(cert, "product_code_range", "") or "", } def _load_dual_defaults(self, cert: Any) -> dict[str, str]: """双编码开启时,从关联 SalePlan 读取 SN/KKS 基准值(无则返回空串)。 此处给出批次级基准值;预览/生成时 scan_service 会按逐台单号重新派生 逐台 SN/KKS,表单里的覆盖值(若有)优先。 """ sp_id = getattr(cert, "sale_plan_id", None) ctrl = self._controller or ScanController return ctrl.load_dual_defaults(self.session, sp_id) # ============================================================ # 表单 <-> 预览 # ============================================================ def _populate_form(self, cert_id: int) -> None: """把覆盖字典填入右侧表单控件。""" vals = self._edits.get(cert_id, {}) for key, _label, _kind in self._field_defs: widget = self._field_edits[key] value = vals.get(key, "") if isinstance(widget, QComboBox): idx = widget.findText(str(value)) widget.setCurrentIndex(idx if idx >= 0 else 0) else: widget.blockSignals(True) widget.setText(str(value)) widget.blockSignals(False) # 按模板族灰显/启用压力单位栏(中文底版预印单位) self._apply_family_constraints() def _on_cert_selected(self, _index: int) -> None: cert_id = self.cert_combo.currentData() if cert_id is None: return self._selected_id = cert_id self._populate_form(cert_id) self._unit_index = 0 self._update_unit_nav() self._render_preview() def _units_of_selected(self) -> list[str]: """返回当前选中合格证展开后的逐台编号列表(空则回退区间串)。""" if self._selected_id is None: return [] cert = self._cert_by_id.get(self._selected_id) if cert is None: return [] return self._controller.individual_codes(cert, self._edits.get(self._selected_id)) def _update_unit_nav(self) -> None: """根据当前合格证的展开台数刷新「预览台号」导航控件。""" units = self._units_of_selected() n = len(units) self.unit_spin.blockSignals(True) self.unit_spin.setRange(1, max(1, n)) self.unit_spin.setValue(1) self.unit_spin.blockSignals(False) self._unit_index = 0 self.unit_label.setText(f"/ {n} 台" if n else "/ 1 台") def _on_unit_changed(self, val: int) -> None: """切换本批预览台号 → 重绘预览为对应单台编号。""" self._unit_index = max(0, val - 1) self._render_preview() def _on_field_edited(self, key: str) -> None: """表单编辑 → 写入覆盖字典(内存态)→ 即时重绘预览。""" if self._selected_id is None: return widget = self._field_edits[key] value = widget.currentText() if isinstance(widget, QComboBox) else widget.text() self._edits.setdefault(self._selected_id, {})[key] = value self._render_preview() # 编辑后即时刷新下拉缺失标注(补完整则 ⚠ 自动消失) self._refresh_combo_markers() # 编辑「产品编号区间」会影响展开台数,刷新台号导航 if key == "product_code_range": self._update_unit_nav() def _on_option_changed(self, *_: Any) -> None: """扫描选项(模板/尺寸/拼版等)变化 → 重绘预览。""" self._render_preview() # ------------------------------------------------------------------ # 扫描拼版推荐值(config layout_presets):下拉套用 + 自旋框灰显 # ------------------------------------------------------------------ def _on_layout_preset_changed(self, idx: int) -> None: """推荐拼版下拉变化。 - 选「自定义」(idx==0):启用拼版自旋框,允许手动调整。 - 选某预设:把 preset.cols/preset.rows 套用到 row_spin/col_spin (row_spin→生成 cols 每行张数·横;col_spin→生成 rows 每列张数·纵), 并灰显自旋框(固化思路延伸)。 """ if idx <= 0: self.row_spin.setEnabled(True) self.col_spin.setEnabled(True) else: preset = self._layout_presets[idx - 1] rows = int(preset.get("rows", 1)) cols = int(preset.get("cols", 1)) # blockSignals 避免 setValue 回触发 _on_layout_spin_changed 误判为「自定义」 self.row_spin.blockSignals(True) self.col_spin.blockSignals(True) self.row_spin.setValue(cols) # 每行张数(横) = cols self.col_spin.setValue(rows) # 每列张数(纵) = rows self.row_spin.blockSignals(False) self.col_spin.blockSignals(False) self.row_spin.setEnabled(False) self.col_spin.setEnabled(False) self._render_preview() def _on_layout_spin_changed(self, *_: Any) -> None: """拼版自旋框被手动改动 → 偏离预设,自动切回「自定义」并启用编辑。""" if self.layout_combo.currentIndex() != 0: self.layout_combo.blockSignals(True) self.layout_combo.setCurrentIndex(0) self.layout_combo.blockSignals(False) self.row_spin.setEnabled(True) self.col_spin.setEnabled(True) self._render_preview() def _apply_default_layout(self) -> None: """启动时套用 config default_layout;空或匹配不到则回退「自定义」(启用自旋框)。""" default_label = get_scan_default_layout() if default_label: for i, _p in enumerate(self._layout_presets): if _p.get("label") == default_label: self.layout_combo.setCurrentIndex(i + 1) # 触发 _on_layout_preset_changed return self.layout_combo.setCurrentIndex(0) self.row_spin.setEnabled(True) self.col_spin.setEnabled(True) def _load_template_options(self) -> list[tuple[str, str]]: """从 TemplateManager 读取全部模板变体,返回 [(显示标签, 配置键), ...] 显示标签为 ``名称 · 变体``(如「全中文合格证 · 检验5灰度」),暴露 coordinates.yaml 中全部变体(含印章差异),与打印侧一致。加载失败时回退到 基础 4 语言族,保证 UI 始终可用。 """ try: options = [ (f"{name} · {variant}" if variant else name, key) for key, name, variant in ScanController.list_enabled_templates() ] if options: return options except Exception as exc: # pragma: no cover - 配置缺失时回退 logger.warning(f"[ScanView] 加载模板列表失败,回退默认: {exc}") return [ ("全中文合格证 · 检验5灰度", "full_chinese_1"), ("中英文合格证 · 检5灰度", "bilingual_1"), ("俄英文合格证", "russian"), ("全英文合格证", "full_english"), ] def _resolve_template_key(self, value: str) -> str: """把配置/历史值解析为合法模板 key。 - 已是已知 key(如 full_chinese_1)→ 直接返回; - 语言族(全中文/中英文/全英文/俄英文)→ 取该族首个变体; - 否则回退 full_chinese_1。 """ keys = [k for _, k in self._template_options] if value in keys: return value family_prefix = { "全中文": "full_chinese", "中英文": "bilingual", "全英文": "full_english", "俄英文": "russian", }.get(value) if family_prefix: for k in keys: if k.startswith(family_prefix): return k return "full_chinese_1" def _on_template_changed(self, _index: int = 0) -> None: """模板下拉变化 → 记录 key 并重绘预览。""" key = self.template_combo.currentData() if key: self._template_key = key # 切换模板族后,压力单位栏可用性需随之变化(中文灰显) self._apply_family_constraints() self._on_option_changed() # ------------------------------------------------------------------ # 常用布局(D)套用接口:供 OutputView 顶部「常用布局」下拉调用 # ------------------------------------------------------------------
[文档] def set_template_by_family(self, family: str) -> None: """按语言族(全中文/中英文/全英文/俄英文)设置模板下拉(D 套用)。 解析 family→具体 key(取该族首个启用变体),再定位 ``template_combo`` 项设选中; 找不到则忽略(保持现状)。设置会触发 ``_on_template_changed`` 重绘预览。 """ if not family: return key = self._resolve_template_key(family) for i in range(self.template_combo.count()): if self.template_combo.itemData(i) == key: self.template_combo.setCurrentIndex(i) return
[文档] def apply_layout_preset(self, label: str) -> None: """按拼版推荐值 label 套用扫描拼版(D 套用)。 - ``label`` 为空或「自定义」→ 设为自定义(启用自旋框)。 - 否则在 ``layout_combo`` 精确/包含匹配;匹配到则选中并触发预设套用, 匹配不到则回退自定义。 """ target_idx = 0 if label and label != "自定义": for i in range(self.layout_combo.count()): text = self.layout_combo.itemText(i) if text == label or label in text or text in label: target_idx = i break if self.layout_combo.currentIndex() == target_idx: self._on_layout_preset_changed(target_idx) else: self.layout_combo.setCurrentIndex(target_idx)
def _effective(self, cert_id: int, key: str) -> str: """取某合格证某底层字段的有效值(覆盖优先,回退 DB)。""" return str( self._edits.get(cert_id, {}).get(key, getattr(self._cert_by_id.get(cert_id), key, "")) or "" ) def _render_preview(self) -> None: """用当前模板坐标在底图上绘制选中合格证的字段,缩放后显示。""" if self._selected_id is None: return cert = self._cert_by_id.get(self._selected_id) if cert is None: return template_type = self._template_key width_mm = self._paper_width_mm height_mm = self._paper_height_mm try: # 预览按逐台编号展开:取当前「预览台号」对应的单台编码; # 无可展开(无区间/单台)时回退为区间串,保证预览与生成一致。 units = self._units_of_selected() serial = units[self._unit_index] if units else None ctrl = self._controller or ScanController png_bytes = ctrl.render_preview_png( cert, template_type, width_mm, height_mm, serial_number=serial, overrides=self._edits.get(self._selected_id), dual_code=self._dual_code, ) qimg = QImage.fromData(png_bytes) self._preview_pixmap = QPixmap.fromImage(qimg) self._scale_preview() except Exception as e: # 预览失败不应阻断主流程 logger.warning(f"扫描预览渲染失败: {e}") self.preview_label.setText(f"预览渲染失败:{e}") def _scale_preview(self) -> None: """按预览标签可用宽度等比缩放显示(保持 60:100 比例)。""" if self._preview_pixmap is None: return avail_w = max(120, self.preview_label.width() - 16) avail_h = max(200, self.preview_label.height() - 16) scaled = self._preview_pixmap.scaled( avail_w, avail_h, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) self.preview_label.setPixmap(scaled)
[文档] def resizeEvent(self, event: Any) -> None: # noqa: N802 (Qt 覆写方法,必须此名) """窗口/分屏尺寸变化 → 重新缩放预览。""" super().resizeEvent(event) self._scale_preview()
# ============================================================ # 生成 # ============================================================ # ------------------------------------------------------------------ # 完整性标注(下拉实时显示哪份合格证缺必填项,避免「盲切批号」) # ------------------------------------------------------------------ def _scan_required_fields(self) -> list[str]: """取扫描生成闸门必检字段(按模板语言族门限:中文不要求单位,其余要求)。""" base = cfg("output.targets.scan.required_fields", _SCAN_REQUIRED_FIELDS_DEFAULT) return ScanController.scan_required_fields( ScanController.family_of(self._template_key), base ) def _apply_family_constraints(self) -> None: """按模板语言族切换「压力单位」栏可用性(中文底版预印单位 → 灰显禁用)。 - 中文族:禁用并清空压力单位下拉,移除该证可能残留的单位覆盖,确保中文扫描 不带入单位(渲染层 ``format_pn`` 中文族也会忽略单位,双保险); - 其余族:启用压力单位下拉,且为必填(见 ``_scan_required_fields``)。 """ unit_widget = self._field_edits.get("pn_unit") if unit_widget is None: return if ScanController.family_requires_pn_unit(ScanController.family_of(self._template_key)): unit_widget.setEnabled(True) else: unit_widget.setEnabled(False) unit_widget.setCurrentIndex(0) # 清空("") if self._selected_id is not None: self._edits.get(self._selected_id, {}).pop("pn_unit", None) self._refresh_combo_markers() def _missing_fields(self, cert: Any) -> list[str]: """返回该合格证当前有效值下缺失的必填字段 key 列表。""" req = self._scan_required_fields() cdata = {k: self._effective(cert.id, k) for k in req} return ScanController.collect_incomplete(cdata, req) def _refresh_combo_markers(self) -> None: """在下拉项文本前标注 ⚠ + 缺项,便于一眼定位待补合格证。""" if not hasattr(self, "_combo_base_text"): return self.cert_combo.blockSignals(True) for i in range(self.cert_combo.count()): cert_id = self.cert_combo.itemData(i) cert = self._cert_by_id.get(cert_id) base = self._combo_base_text.get(cert_id, self.cert_combo.itemText(i)) miss = self._missing_fields(cert) if cert else [] if miss: labels = "、".join(ScanController.field_label(k) for k in miss) self.cert_combo.setItemText(i, f"⚠ {base}(缺:{labels})") else: self.cert_combo.setItemText(i, base) self.cert_combo.blockSignals(False) def _generate_scans(self) -> None: """生成扫描件(含完整性闸门;覆盖值经 data_overrides 传入渲染管道)。""" if not self._certificates: QMessageBox.warning(self, "提示", "请先加载合格证数据") return # 完整性闸门:任一张合格证缺失必填字段则中止(按模板族门限,复用统一护栏 collect_incomplete) scan_required = self._scan_required_fields() incomplete = [] for cert in self._certificates: cdata = {k: self._effective(cert.id, k) for k in scan_required} miss = ScanController.collect_incomplete(cdata, scan_required) if miss: labels = "、".join(ScanController.field_label(k) for k in miss) incomplete.append((cert, labels)) if incomplete: lines = [ f"• 合格证 {cert.certificate_no}{cert.product_name} {cert.product_model}):{labels}" for cert, labels in incomplete ] box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Warning) box.setWindowTitle("存在缺失信息,已中止生成") box.setText("以下合格证必填信息不完整,请先在右侧表单补齐后再生成扫描件:") box.setInformativeText("\n".join(lines)) jump_btn = box.addButton("定位到首个缺失项", QMessageBox.ButtonRole.AcceptRole) box.addButton("知道了", QMessageBox.ButtonRole.RejectRole) box.exec() if box.clickedButton() == jump_btn: first_id = incomplete[0][0].id idx = self.cert_combo.findData(first_id) if idx >= 0: self.cert_combo.setCurrentIndex(idx) return certificate_ids = [c.id for c in self._certificates] output_dir = self.path_edit.text().strip() or cfg("paths.output.scans", "output/scans") os.makedirs(output_dir, exist_ok=True) self.generate_btn.setEnabled(False) self.progress_bar.setVisible(True) self.progress_bar.setValue(0) controller = self._get_controller() controller.generate_scans( certificate_ids=certificate_ids, output_dir=output_dir, template_type=self._template_key, output_format=self.format_combo.currentText(), # 注意:「每行张数」= 横向每行列数(cols),「每列张数」= 纵向每列行数(rows), # 与 ScanLayoutHandler(cols 横向 / rows 纵向)对齐,不可互换(否则行列颠倒)。 rows=self.col_spin.value(), cols=self.row_spin.value(), paper_width_mm=self._paper_width_mm, paper_height_mm=self._paper_height_mm, data_overrides=self._edits, dual_code=self._dual_code, ) def _on_progress(self, current: int, total: int, message: str) -> None: """进度更新""" self.progress_bar.setMaximum(total) self.progress_bar.setValue(current) self.info_label.setText(message) def _on_finished(self, result: dict) -> None: """生成完成""" self.progress_bar.setVisible(False) self.generate_btn.setEnabled(True) output_files = result.get("files", []) or [] self.scan_completed.emit(output_files) QMessageBox.information( self, "生成完成", f"扫描件生成完成!\n{len(output_files)} 个文件\n保存位置: {self.path_edit.text()}", ) def _on_error(self, error_msg: str) -> None: """生成错误""" self.progress_bar.setVisible(False) self.generate_btn.setEnabled(True) QMessageBox.critical(self, "生成失败", error_msg) # ============================================================ # 控制器 / 工具 # ============================================================ def _get_controller(self) -> ScanController: """获取控制器(懒加载)""" if self._controller is None: self._controller = ScanController(self.session) self._controller.progress.connect(self._on_progress) self._controller.completed.connect(self._on_finished) self._controller.error.connect(self._on_error) return self._controller def _load_default_output_dir(self) -> None: """加载默认输出目录(优先 scan.defaults.output_dir,回退 paths.output.scans)""" try: sd = cfg("output.targets.scan.defaults", {}) or {} default_dir = sd.get("output_dir") or cfg("paths.output.scans", "output/scans") self.path_edit.setText(default_dir) except Exception: pass def _browse_output_dir(self) -> None: """浏览输出目录""" from PySide6.QtWidgets import QFileDialog dir_path = QFileDialog.getExistingDirectory(self, "选择输出目录") if dir_path: self.path_edit.setText(dir_path) def _open_output_dir(self) -> None: """打开输出目录(绝对路径 + normpath,失败时弹窗而非抛异常)。""" path = os.path.abspath( os.path.normpath(self.path_edit.text() or cfg("paths.output.scans", "output/scans")) ) try: os.makedirs(path, exist_ok=True) if sys.platform == "win32": os.startfile(path) # type: ignore[attr-defined] else: subprocess.run( ["open" if sys.platform == "darwin" else "xdg-open", path], check=False ) except OSError as exc: logger.warning(f"打开输出目录失败: {path} - {exc}") QMessageBox.warning(self, "打开失败", f"无法打开输出目录:\n{path}\n\n{exc}")