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