certflow.views.print_view 源代码

# src/certflow/views/print_view.py
"""合格证打印视图 - 只负责打印,不包含数据源/历史/设置

从 CertificatePrintView 拆出,保留核心打印功能:
- 模板选择
- 产品信息编辑
- 编号设置
- 打印预览
- 单次/批量打印
"""

from __future__ import annotations

import json
import os
from datetime import datetime
from typing import TYPE_CHECKING, Any

from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
    QCheckBox,
    QComboBox,
    QFileDialog,
    QFrame,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QInputDialog,
    QLabel,
    QLineEdit,
    QMessageBox,
    QProgressBar,
    QPushButton,
    QScrollArea,
    QSpinBox,
    QSplitter,
    QVBoxLayout,
    QWidget,
)

from certflow.config.paths_override import cfg
from certflow.controllers.model_param_mapping_controller import ModelParamMappingController
from certflow.controllers.print_queue_controller import PrintQueueController
from certflow.controllers.printer_controller import PrinterController
from certflow.utils.logger import logger
from certflow.views.certificate_preview_painter import render_certificate_pixmap
from certflow.views.queue_panel import QueuePanel

if TYPE_CHECKING:
    pass


[文档] class PrintView(QWidget): """合格证打印视图 Signals: print_completed: 打印完成信号,携带 certificate_ids 列表 """ print_completed = Signal(list) # 跳转扫描件生成:携带已加载的合格证 ID 列表(供主窗口切换到扫描件视图) navigate_to_scan = Signal(list) def __init__( self, session: Any, config: dict = None, db_manager: Any = None, parent: Any = None, printer_controller: Any = None, ) -> None: """初始化PrintView。 Args: printer_controller: 注入共享打印机控制器(OutputController 统一调度, §11.8 阶段4);为 None 时沿用原有懒加载逻辑(测试/旧调用方零改动)。 Args: session: SQLAlchemy 会话对象 config: 配置字典 db_manager: 数据库管理器实例 parent: 父控件(可选) Returns: None """ super().__init__(parent) self.db_manager = db_manager self.session = session self.config = config or {} # 会话生命周期统一由 db_manager 管理;优先 db_manager,回退裸 session(测试/旧调用方)。 # 视图不再直接把裸 session 注入 Controller,统一经 db_manager 派生(阶段 C 补丁 59)。 self._printer_controller = None if printer_controller is not None: self._printer_controller = printer_controller # 连接打印完成信号,感知异步打印结果(与懒加载路径保持一致) self._printer_controller.print_completed.connect(self._on_print_done) # 型号字典控制器(懒加载) self._mpm_controller = None # 状态 self._current_certificate_id: int | None = None self._current_cert: Any = None # 当前绑定的 Certificate(供模式切换重加载) self._current_sale_plan: Any = None # 当前绑定的 SalePlan(供编号回填取整批区间) self._loaded_plans: list = [] # 本次加载的多个销售计划(支持一次打印多条记录) self._loaded_certs: list = [] # 对应的 Certificate(按 unique_key 取最新一条) self._serial_numbers: list[str] = [] self._current_serial_index: int = 0 self._three_digit_mode: bool = False # 编号模式「三位流水」时强制 3 位流水 # 编号续号:配置驱动自动续号 + 控制器懒加载 + 加载记录抑制标志 self._auto_continue = bool(cfg("certificate.numbering.auto_continue", True)) self._cert_controller = None self._loading_record = False # 加载记录时抑制「目标年月」自动覆盖起始 # 防抖定时器 self._refresh_timer = QTimer() self._refresh_timer.setSingleShot(True) self._refresh_timer.timeout.connect(self._do_refresh_preview) # 打印防重复标志 self._is_printing = False # 扫描件生成控制器(懒加载,仅当勾选「生成扫描件」时触发) self._scan_controller = None # 打印机检查缓存(同一批次只检查一次) self._checked_printer: str | None = None self._init_config() self._init_queue() self.setup_ui() self._connect_signals() self._refresh_preview() # ============================================================ # 配置加载 # ============================================================ def _init_config(self) -> None: """加载配置""" print_cfg = self.config.get("print", {}) self._default_printer = print_cfg.get("default_printer", "默认打印机") self._default_copies = print_cfg.get("default_copies", 1) layout_cfg = print_cfg.get("layout", {}) paper_cfg = print_cfg.get("paper", {}) self._label_x = layout_cfg.get("label_x", 8) self._value_x = layout_cfg.get("value_x", 28) self._start_y = layout_cfg.get("start_y", 28) self._line_spacing = layout_cfg.get("line_spacing", 7.5) self._paper_width = paper_cfg.get("width", 60) self._paper_height = paper_cfg.get("height", 100) # 模板列表(A1:动态从 TemplateManager 读取全部变体,含印章差异) self._template_options = self._load_template_options() # list[(显示标签, 配置键)] self._current_template_key = ( self._template_options[0][1] if self._template_options else "full_chinese_1" ) # 配置驱动默认模板(print.default_template,设置页可改;匹配 key/标签/去变体后缀,找不到回退首个) cfg_default = cfg("print.default_template", "") if cfg_default: for _label, _key in self._template_options: if ( _key == cfg_default or _label == cfg_default or _label.split(" · ")[0] == cfg_default ): self._current_template_key = _key break # 打印选项默认值(配置驱动:output.targets.print.options,回退 True) self._opt_auto_fill = bool(cfg("output.targets.print.options.auto_fill", True)) self._opt_save_log = bool(cfg("output.targets.print.options.save_log", True)) # 预览是否显示字段标题(配置驱动:优先 output.targets.print.options.preview_labels, # 回退 print.preview.show_labels;预印合格证可关,仅看值) self._show_labels = bool( cfg( "output.targets.print.options.preview_labels", cfg("print.preview.show_labels", True), ) ) # 扫描件生成选项默认值(output.targets.scan.defaults,供「生成扫描件」复选框使用) self._scan_defaults = dict(cfg("output.targets.scan.defaults", {}) or {}) # ============================================================ # 模板选项(A1/A2/A3:动态填充 + 语言族映射 + 重打恢复) # ============================================================ # 语言族判定统一收口到 TemplateFieldFormatter.family_of(蓝图 §9-5 清理:删除本类副本) def _load_template_options(self) -> list[tuple[str, str]]: """从 TemplateManager 读取全部模板变体,返回 [(显示标签, 配置键), ...] 显示标签为 ``名称 · 变体``(如「全中文合格证 · 检验5灰度」), 暴露 coordinates.yaml 中全部 9 个变体(含印章差异)。加载失败时回退到 基础 4 语言族,保证 UI 始终可用。 """ try: manager = PrinterController.get_template_manager() options = [ (f"{name} · {variant}" if variant else name, key) for key, name, variant in manager.list_enabled_templates() ] if options: return options except Exception as exc: # pragma: no cover - 配置缺失时回退 logger.warning(f"[PrintView] 加载模板列表失败,回退默认: {exc}") return [ ("全中文合格证 · 检验5灰度", "full_chinese_1"), ("中英文合格证 · 检5灰度", "bilingual_1"), ("俄英文合格证", "russian"), ("全英文合格证", "full_english"), ] def _restore_template_by_family(self, family: str) -> None: """按语言族恢复下拉选中(A3:重打时选中上次版式所属语言族的首个变体)""" if not family: return for index, (_label, key) in enumerate(self._template_options): if PrinterController.family_of(key) == family: self.template_combo.setCurrentIndex(index) self._current_template_key = key return def _init_queue(self) -> None: """初始化打印队列控制器与面板(启动时报废未完成任务)""" self._queue_controller = PrintQueueController(db_manager=self.db_manager) if self._default_printer and self._default_printer != "默认打印机": self._queue_controller.set_printer(self._default_printer) self._queue_panel = QueuePanel(self._queue_controller) try: recovered = self._queue_controller.recover() if recovered: logger.info(f"打印队列恢复 {recovered} 个未完成任务") except Exception as e: # noqa: BLE001 logger.warning(f"打印队列恢复失败(可忽略): {e}") # H2 事件驱动重载:队列 worker 完成(含参数补充确认写库 + 打印回填) # 后,按 job 关联的 certificate_id 重载当前表单,保证「选中快照」与库一致。 self._queue_controller.job_finished.connect(self._on_queue_job_finished) # ============================================================ # 信号连接(集中管理) # ============================================================ def _connect_signals(self) -> None: """集中管理信号连接""" # 模板切换 self.template_combo.currentIndexChanged.connect(self._on_template_changed) # 产品信息 - 使用防抖 self.product_name_edit.textChanged.connect(self._on_data_changed) self.product_model_edit.textChanged.connect(self._on_data_changed) self.dn_edit.textChanged.connect(self._on_data_changed) self.pn_edit.textChanged.connect(self._on_data_changed) self.temperature_edit.textChanged.connect(self._on_data_changed) self.medium_edit.textChanged.connect(self._on_data_changed) self.manufacture_date_edit.textChanged.connect(self._on_data_changed) self.check_standard_edit.textChanged.connect(self._on_data_changed) self.inspector_id_edit.textChanged.connect(self._on_data_changed) self.inspector_id_edit.textChanged.connect(self._sync_inspector_combo) # 编号设置 - 变化需同步刷新编号导航(重置/重算),并防抖刷新预览 self.prefix_edit.textChanged.connect(self._on_number_field_changed) self.year_month_edit.textChanged.connect(self._on_number_field_changed) # 目标年月变化:自动续号 + 下一号预览(独立于导航刷新,避免相互干扰) self.year_month_edit.textChanged.connect(self._on_target_ym_changed) self.start_number_spin.valueChanged.connect(self._on_number_field_changed) self.quantity_spin.valueChanged.connect(self._on_number_field_changed) self.suffix_edit.textChanged.connect(self._on_number_field_changed) # 编号模式切换 self.number_mode_combo.currentTextChanged.connect(self._on_number_mode_changed) # H2:活化「自动填写」复选框——作为事件驱动重载的覆盖策略开关。 # 勾选 ⇒ 重载时 DB 值覆盖手改;取消 ⇒ 重载时保留手改(仅填空白)。 # 切换即时按新策略重载当前证书(对齐文档 §10.4-1 H2 接线要求)。 self.auto_fill_check.toggled.connect(self._on_auto_fill_toggled) # ============================================================ # UI 构建 # ============================================================
[文档] def setup_ui(self) -> None: """构建打印界面 整体:顶部【主操作面:队列传输控制条】+ QSplitter(左控制面板 / 右预览面板) + 底部【打印队列表格】。传输控制条(启动/暂停/继续/置顶/上移/取消/清空已完成) 提升到顶部,用户可显式控制队列;左右均包 QScrollArea 兜底防裁切。 """ layout = QVBoxLayout(self) layout.setContentsMargins(4, 4, 4, 4) layout.setSpacing(4) # —— 主操作面:同级一排 —— 左:配置动作(保存订正/入队/入队启动/生成扫描件,靠前高频); # 右:队列传输控制(启动/暂停/继续/置顶/上移/取消/清空已完成)—— layout.addWidget(self._build_top_action_bar()) left_panel = self._build_left_panel() right_panel = self._build_right_panel() splitter = QSplitter() splitter.addWidget(self._wrap_in_scroll(left_panel, "控制面板")) splitter.addWidget(self._wrap_in_scroll(right_panel, "预览面板")) splitter.setSizes([520, 420]) layout.addWidget(splitter, stretch=1) # 队列面板(仅任务表格 + 状态)限高:避免抢走 splitter 空间; # 内容超出时面板内表格自带滚动条,用户可下拉查看历史任务。 self._queue_panel.setMaximumHeight(180) layout.addWidget(self._queue_panel)
@staticmethod def _wrap_in_scroll(widget: QWidget, name: str) -> QWidget: """将任意 widget 包裹到 QScrollArea,保证内容溢出时可滚动。""" scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) scroll.setObjectName(name) scroll.setWidget(widget) return scroll def _build_left_panel(self) -> QWidget: """左侧控制面板(被 QScrollArea 包裹:内容溢出可滚动)""" panel = QWidget() layout = QVBoxLayout(panel) layout.setContentsMargins(4, 4, 4, 4) layout.setSpacing(6) # 模板选择 layout.addWidget(self._build_template_group()) # 产品信息 layout.addWidget(self._build_product_group()) # 编号设置 layout.addWidget(self._build_number_group()) # 打印选项(紧凑两列,回到左侧以恢复左右面板均衡) layout.addWidget(self._build_print_options_group()) layout.addStretch() return panel def _build_template_group(self) -> QGroupBox: """模板选择组""" group = QGroupBox("模板") layout = QVBoxLayout(group) layout.setContentsMargins(4, 6, 4, 4) self.template_combo = QComboBox() for _label, _key in self._template_options: self.template_combo.addItem(_label, _key) # 选中配置驱动默认模板(print.default_template,回退首个) for _i, (_label, _key) in enumerate(self._template_options): if _key == self._current_template_key: self.template_combo.setCurrentIndex(_i) break layout.addWidget(self.template_combo) return group def _build_product_group(self) -> QGroupBox: """产品信息组""" group = QGroupBox("产品信息") layout = QGridLayout(group) layout.setContentsMargins(6, 10, 6, 6) layout.setHorizontalSpacing(8) fields = [ ("产品名称", "product_name_edit", 0, 0), ("产品型号", "product_model_edit", 0, 1), ("公称通径", "dn_edit", 1, 0), ("公称压力", "pn_edit", 1, 1), ("适用温度", "temperature_edit", 2, 0), ("适用介质", "medium_edit", 2, 1), ("出厂年月", "manufacture_date_edit", 3, 0), ("检验标准", "check_standard_edit", 3, 1), ("检验工号", "inspector_id_edit", 4, 0), ] for label_text, attr_name, row, col in fields: label = QLabel(label_text) label.setMinimumWidth(60) layout.addWidget(label, row, col * 2) edit = QLineEdit() setattr(self, attr_name, edit) layout.addWidget(edit, row, col * 2 + 1) # 检验工号快速切换下拉(检5 / No 5):与模板语言族默认同源, # 提供一个显式控件在两者间一键切换,覆盖自动逻辑(手动输入其他值则下拉置空)。 self.inspector_combo = QComboBox() self.inspector_combo.addItems(["检5", "No 5"]) self.inspector_combo.setToolTip( "按模板语言族快速切换检验工号默认(中文→检5 / 其他→No 5);手动输入其他值后下拉显示为空" ) self.inspector_combo.setMinimumWidth(60) self.inspector_combo.currentTextChanged.connect(self._on_inspector_combo_changed) # inspector_id_edit 在 row 4 col 1;右侧 col 2/3 空闲,放下拉 layout.addWidget(self.inspector_combo, 4, 2) self.manufacture_date_edit.setText(datetime.now().strftime("%Y.%m")) # 出厂日期来源选择(配置驱动 + UI 覆盖): # 默认勾选态来自 config certificate.issue_date_source(current_ym→勾选); # 勾选=强制当前年月;不勾选=配置默认(解析编号年月,加载记录后显示编号年月)。 self.use_current_ym_check = QCheckBox("出厂日期用当前年月") self.use_current_ym_check.setChecked( PrinterController.get_issue_date_source() == "current_ym" ) self.use_current_ym_check.setToolTip( "勾选则出厂年月强制取当前年月;不勾选则按配置默认(解析编号年月)" ) self.use_current_ym_check.toggled.connect(self._on_use_current_ym_toggled) layout.addWidget(self.use_current_ym_check, 5, 0, 1, 4) # 缓存最近加载记录解析出的编号年月(供取消勾选时回显) self._last_issue_ym = None self._apply_issue_date_display() return group def _on_use_current_ym_toggled(self, checked: bool) -> None: """勾选/取消「出厂日期用当前年月」时刷新显示值。""" self._apply_issue_date_display() def _apply_issue_date_display(self) -> None: """根据「出厂日期用当前年月」复选框决定出厂年月显示值。""" if self.use_current_ym_check.isChecked(): self.manufacture_date_edit.setText(datetime.now().strftime("%Y.%m")) else: # 配置默认:编号年月解析值;无加载记录时回退今天(加载后会被覆盖) self.manufacture_date_edit.setText( self._last_issue_ym or datetime.now().strftime("%Y.%m") ) def _build_number_group(self) -> QGroupBox: """编号设置组(目标年月 / 自动续号 / 起始流水手填覆盖) 目标年月默认当前月;补历史编号填如 ``2604``。自动续号(配置 ``certificate.numbering.auto_continue``,默认开)下,「起始」由系统按该月 已用最大流水 + 1 自动填充(框置灰),勾选「起始流水手填覆盖」可手动插入。 """ group = QGroupBox("编号设置") layout = QGridLayout(group) layout.setContentsMargins(6, 10, 6, 6) # 第 0 行:模式 + 起始流水手填覆盖 + 下一号预览(同排,避免模式独占一行) layout.addWidget(QLabel("模式"), 0, 0) self.number_mode_combo = QComboBox() self.number_mode_combo.addItems(["读取编号", "正则刷新", "直接复制", "三位流水"]) layout.addWidget(self.number_mode_combo, 0, 1, 1, 2) self._start_override_check = QCheckBox("起始流水手填覆盖") self._start_override_check.setChecked(not self._auto_continue) self._start_override_check.setToolTip( "勾选后可手动填写「起始」流水(特殊插入);不勾选则按目标年月自动续该月最大流水" ) self._start_override_check.toggled.connect(self._on_start_override_toggled) layout.addWidget(self._start_override_check, 0, 3, 1, 2) self._next_serial_label = QLabel("下一号: —") self._next_serial_label.setStyleSheet("color: #555;") layout.addWidget(self._next_serial_label, 0, 5) # 第 1 行:前缀 + 目标年月 + 起始(标签在偶数列、控件在奇数列) layout.addWidget(QLabel("前缀"), 1, 0) self.prefix_edit = QLineEdit("V") layout.addWidget(self.prefix_edit, 1, 1) layout.addWidget(QLabel("目标年月"), 1, 2) self.year_month_edit = QLineEdit(datetime.now().strftime("%y%m")) self.year_month_edit.setToolTip("编号年月:补历史编号填如 2604;留空/默认当前月") layout.addWidget(self.year_month_edit, 1, 3) layout.addWidget(QLabel("起始"), 1, 4) self.start_number_spin = QSpinBox() self.start_number_spin.setRange(1, 99999) self.start_number_spin.setValue(1) layout.addWidget(self.start_number_spin, 1, 5) # 第 2 行:数量 + 后缀 + 结束 layout.addWidget(QLabel("数量"), 2, 0) self.quantity_spin = QSpinBox() self.quantity_spin.setRange(1, 1000) self.quantity_spin.setValue(1) layout.addWidget(self.quantity_spin, 2, 1) layout.addWidget(QLabel("后缀"), 2, 2) self.suffix_edit = QLineEdit() layout.addWidget(self.suffix_edit, 2, 3) layout.addWidget(QLabel("结束"), 2, 4) self.end_preview_label = QLabel("—") self.end_preview_label.setStyleSheet("font-weight: bold; color: #0078d4;") layout.addWidget(self.end_preview_label, 2, 5) # 自动续号时「起始」由系统控制(禁用手填),避免与自动值冲突 self.start_number_spin.setEnabled(self._start_override_check.isChecked()) # 初始化预览(当前月下一号) self._update_next_serial_label() return group # ============================================================ # 编号续号(目标年月 / 自动续号 / 手填覆盖) # ============================================================ def _compose_prefix(self) -> str: """由前缀与年月框组合出编号前缀(如 V + 2604 → V2604)。""" return (self.prefix_edit.text().strip() or "V") + ( self.year_month_edit.text().strip() or "" ) def _get_cert_controller(self) -> Any: """懒加载合格证控制器(编号续号后台支撑)。 优先使用 ``db_manager`` 派生会话(会话生命周期由 Manager 统一管理), 回退裸 ``session``(测试/旧调用方零改动)。 """ if self._cert_controller is None: from certflow.controllers.certificate_controller import CertificateController if self.db_manager is not None: self._cert_controller = CertificateController(db_manager=self.db_manager) else: self._cert_controller = CertificateController(db_session=self.session) return self._cert_controller def _update_next_serial_label(self) -> None: """只读刷新「下一号」预览(不改动起始)。""" ym = self.year_month_edit.text().strip() if len(ym) != 4 or not ym.isdigit(): self._next_serial_label.setText("下一号: —") return try: nxt = self._get_cert_controller().get_next_serial(self._compose_prefix()) except Exception as e: # noqa: BLE001 logger.debug(f"[PrintView] 预瞄下一号失败: {e}") self._next_serial_label.setText("下一号: —") return self._next_serial_label.setText(f"下一号: {nxt:03d}") def _on_target_ym_changed(self, text: str) -> None: """目标年月变化:刷新下一号预览;自动续号模式下把「起始」填为该月下一号。""" self._update_next_serial_label() if self._loading_record: return if self._start_override_check.isChecked(): return # 手填覆盖:尊重用户手动起始 ym = text.strip() if len(ym) != 4 or not ym.isdigit(): return try: nxt = self._get_cert_controller().get_next_serial(self._compose_prefix()) except Exception as e: # noqa: BLE001 logger.debug(f"[PrintView] 自动续号失败: {e}") return self.start_number_spin.setValue(nxt) # 起始变化需同步刷新编号导航与预览 self._current_serial_index = 0 self._serial_numbers = [] self._update_serial_nav() self._refresh_timer.start(300) def _on_start_override_toggled(self, checked: bool) -> None: """手填覆盖开关:启用/禁用「起始」自旋框;切回自动时重算下一号并填起始。""" self.start_number_spin.setEnabled(bool(checked)) if not checked: self._on_target_ym_changed(self.year_month_edit.text()) def _build_print_options_group(self) -> QGroupBox: """打印选项组(紧凑两列 + 扫描件子组按需插入:缓解左侧行高,默认窗口可见按钮区) 配置驱动:复选框默认态从 output.targets.print.options 读取。 扫描件子组默认**不插入**布局(隐藏),仅勾选「生成扫描件」时才插入并启用, 节省默认状态下的垂直空间,让按钮区在默认窗口内直接可见。 """ group = QGroupBox("选项") self._print_options_grid = QGridLayout(group) self._print_options_grid.setContentsMargins(6, 8, 6, 6) self._print_options_grid.setHorizontalSpacing(10) self._print_options_grid.setVerticalSpacing(4) # 自动填写:默认态来自配置;勾选为用户意图开关(_autofill_from_dict 内再判配置总闸) self.auto_fill_check = QCheckBox("自动填写") self.auto_fill_check.setChecked(self._opt_auto_fill) self._print_options_grid.addWidget(self.auto_fill_check, 0, 0) # 保存日志:默认态来自配置;_get_form_data 据此决定是否写回 PrintLog self.save_to_db_check = QCheckBox("保存日志") self.save_to_db_check.setChecked(self._opt_save_log) self._print_options_grid.addWidget(self.save_to_db_check, 0, 1) self.show_labels_check = QCheckBox("预览显示标题") self.show_labels_check.setChecked(self._show_labels) self.show_labels_check.setToolTip( "取消勾选则预览仅显示字段值(适配预印合格证:标题已由印刷底板提供,只填值)" ) self.show_labels_check.toggled.connect(self._on_show_labels_toggled) self._print_options_grid.addWidget(self.show_labels_check, 1, 0) # —— 生成扫描件:勾选后「入队启动」会在打印同时,按下方选项生成图片/PDF 扫描件(配置驱动)—— self.generate_scan_check = QCheckBox("生成扫描件") self.generate_scan_check.setChecked(False) self.generate_scan_check.setToolTip( "勾选后,点击「入队启动」会在打印的同时,按下方扫描件选项生成扫描件(后台并行,不阻塞打印)" ) self.generate_scan_check.toggled.connect(self._on_generate_scan_toggled) self._print_options_grid.addWidget(self.generate_scan_check, 1, 1) # 扫描件选项子组(紧凑一行:模板·格式·每行·每列;默认隐藏,按需插入布局) scan_box = QGroupBox("扫描件选项") sgrid = QGridLayout(scan_box) sgrid.setContentsMargins(6, 8, 6, 6) sgrid.setHorizontalSpacing(8) sgrid.setVerticalSpacing(4) defaults = self._scan_defaults self._scan_template_combo = QComboBox() self._scan_template_combo.addItems(["全中文", "中英文", "俄英文", "全英文"]) self._scan_template_combo.setCurrentText(defaults.get("template_type", "全中文")) sgrid.addWidget(QLabel("模板"), 0, 0) sgrid.addWidget(self._scan_template_combo, 0, 1) self._scan_format_combo = QComboBox() self._scan_format_combo.addItems(["JPG", "PDF"]) self._scan_format_combo.setCurrentText(defaults.get("format", "JPG")) sgrid.addWidget(QLabel("格式"), 0, 2) sgrid.addWidget(self._scan_format_combo, 0, 3) self._scan_rows_spin = QSpinBox() self._scan_rows_spin.setRange(1, 10) self._scan_rows_spin.setValue(int(defaults.get("rows", 1))) sgrid.addWidget(QLabel("每行"), 0, 4) sgrid.addWidget(self._scan_rows_spin, 0, 5) self._scan_cols_spin = QSpinBox() self._scan_cols_spin.setRange(1, 10) self._scan_cols_spin.setValue(int(defaults.get("cols", 1))) sgrid.addWidget(QLabel("每列"), 0, 6) sgrid.addWidget(self._scan_cols_spin, 0, 7) self._scan_options_box = scan_box scan_box.setEnabled(False) scan_box.hide() # 默认隐藏;勾选「生成扫描件」时由 _on_generate_scan_toggled 插入布局 return group def _on_generate_scan_toggled(self, checked: bool) -> None: """勾选「生成扫描件」时插入扫描件选项子组;取消时移除(省垂直空间)""" if checked: self._scan_options_box.show() self._scan_options_box.setEnabled(True) # 已插入则跳过 if self._scan_options_box.parentWidget() is None: self._print_options_grid.addWidget(self._scan_options_box, 2, 0, 1, 2) else: self._print_options_grid.removeWidget(self._scan_options_box) self._scan_options_box.setEnabled(False) self._scan_options_box.hide() def _build_top_action_bar(self) -> QWidget: """顶部主操作栏:同级一排,左配置动作(靠前)+ 右队列传输控制。 - 左:保存订正 / 入队 / 入队启动 / 生成扫描件(高频配置动作,置前) - 右:队列传输控制条(启动/暂停/继续/置顶/上移/取消/清空已完成, 由 QueuePanel 提供,提升到此与主操作并列) 两组用 stretch 隔开,配置动作靠左、队列控制靠右,层级清晰。 """ bar = QWidget() layout = QHBoxLayout(bar) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(6) # 左:配置动作(靠前) self._build_config_action_buttons(layout) # 隔开两组 layout.addStretch(1) # 右:队列传输控制 layout.addWidget(self._queue_panel.transport_bar) return bar def _build_config_action_buttons(self, layout: QHBoxLayout) -> None: """构建高频配置动作按钮(保存订正 / 入队 / 入队启动 / 生成扫描件)并入指定布局。 流水线:核对补录 → 保存订正 → 入队(/入队启动)。按 momo 在 ISSUE #2 的要求: - 「保存订正」把补录内容写回数据库(Certificate); - 「入队」先校验无缺失 → 保存订正 → 从数据库重新读取最新内容 → 入队到 print_jobs,不立即启动,交由队列面板显式控制打印节奏(批量入队后统一排序打印); - 「入队启动」是「入队」的快捷方式:入队后立即启动队列 worker(立刻打这一张); - 「生成扫描件」跳转扫描件视图,传入当前已加载合格证 ID。 移除旧版「单次/批量」直发路径——它绕过队列、用内存表单直接发打印,且与队列 worker 抢锁引发 database is locked。 """ buttons = [ ("保存订正", "#0078d4", self._on_save_correction), ("入队", "#17a2b8", self._on_enqueue), ("入队启动", "#28a745", self._on_enqueue_and_start), ("生成扫描件", "#ff9800", self._on_navigate_to_scan), ("保存图片", "#6c757d", self._save_preview_image), ] for text, color, slot in buttons: button = QPushButton(text) button.setMinimumHeight(30) button.setStyleSheet(f""" QPushButton {{ background-color: {color}; color: white; font-weight: bold; border-radius: 4px; }} QPushButton:hover {{ background-color: {color}cc; }} QPushButton:disabled {{ background-color: #aaa; color: #eee; }} """) button.clicked.connect(slot) layout.addWidget(button) def _get_loaded_certificate_ids(self) -> list[int]: """返回本次已加载的合格证 ID 列表(过滤 None)。 供「生成扫描件」按钮跳转时把当前视图里的合格证传入扫描件视图, 实现「打印视图 → 扫描件生成」一键衔接(scan_view 提示「请从打印页面跳转」)。 """ return [c.id for c in self._loaded_certs if c] def _on_navigate_to_scan(self) -> None: """跳转扫描件生成:把当前已加载的合格证 ID 传入扫描件视图。 服务端 ScanService / ScanController / ScanView 已完整实现,这里仅做控制器 接线——通过 navigate_to_scan 信号通知主窗口切换到扫描件视图并自动加载证书。 """ cert_ids = self._get_loaded_certificate_ids() if not cert_ids: QMessageBox.warning(self, "提示", "尚未加载合格证,请先从查询清单选择编号") return self.navigate_to_scan.emit(cert_ids) def _build_right_panel(self) -> QWidget: """右侧预览面板""" panel = QWidget() panel.setMaximumWidth(420) lyt = QVBoxLayout(panel) lyt.setContentsMargins(4, 4, 4, 4) # 预览头部 header = QHBoxLayout() self.prev_btn = QPushButton("◀") self.prev_btn.setFixedSize(28, 26) self.prev_btn.clicked.connect(self._prev_serial) header.addWidget(self.prev_btn) self.serial_label = QLabel("0 / 0") self.serial_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.serial_label.setFixedWidth(50) header.addWidget(self.serial_label) self.next_btn = QPushButton("▶") self.next_btn.setFixedSize(28, 26) self.next_btn.clicked.connect(self._next_serial) header.addWidget(self.next_btn) header.addStretch() lyt.addLayout(header) # 预览(单一 QLabel,由 QPainter 绘制合格证位图,C2 去 Chromium) self.preview_label = QLabel() self.preview_label.setFixedWidth(280) self.preview_label.setFixedHeight(467) # 280 / 60 * 100 self.preview_label.setStyleSheet("border: 1px solid #ccc; background: white;") self.preview_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) lyt.addWidget(self.preview_label, stretch=1) # 选项组已移回左侧(保持左右面板视觉均衡;此处不再添加) # 状态标签 self.status_label = QLabel("就绪") self.status_label.setStyleSheet("color: #666; font-size: 11px;") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) lyt.addWidget(self.status_label) # 进度条 self.progress_bar = QProgressBar() self.progress_bar.setVisible(False) lyt.addWidget(self.progress_bar) return panel # ============================================================ # 打印操作 # ============================================================ # 流水线:核对补录 → 保存订正 → 从 DB 重读 → 入队启动 # ============================================================ # 打印前必须补齐的表单控件(key = 控件属性名,label = 中文名) # 注:出厂年月控件在初始化时已预填当天日期(_%Y.%m),属"非空白", # 不计入"遗漏的空白控件";如需强制改填可在界面手动覆盖。 _REQUIRED_FIELDS: tuple[tuple[str, str], ...] = ( ("product_name_edit", "产品名称"), ("product_model_edit", "产品型号"), ("dn_edit", "公称通径"), ("pn_edit", "公称压力"), ("temperature_edit", "适用温度"), ("medium_edit", "适用介质"), ("check_standard_edit", "检验标准"), ("inspector_id_edit", "检验工号"), ) def _check_missing_fields(self) -> list[str]: """校验打印前必填控件是否有空白。 Returns: list[str]: 缺失字段的中文名列表;为空表示全部已填。 """ missing: list[str] = [] for attr, label in self._REQUIRED_FIELDS: edit = getattr(self, attr, None) if edit is None or not edit.text().strip(): missing.append(label) return missing def _on_save_correction(self) -> None: """保存订正:把当前表单控件的内容写回数据库(Certificate)。 这是打印前的强制前置步骤——只有把补录内容落库,队列打印时 才能「从数据库读取最新的、无缺失的打印内容」。 """ if self._current_certificate_id is None: QMessageBox.warning(self, "提示", "尚未加载合格证,请先从查询清单选择编号") return # 先校验,避免把残缺数据写回库 missing = self._check_missing_fields() if missing: QMessageBox.warning( self, "信息缺失", "以下必填项仍为空白,请补充录入后再保存订正:\n\n" + "、".join(missing), ) return # 模板族压力单位门限(中文放行不门限;中英文/全英文/俄英文必须带单位,缺则拦截) pn_err = PrinterController.validate_pn_text( PrinterController.family_of(self._current_template_key), self._get_form_data().get("pn", ""), ) if pn_err: QMessageBox.warning(self, "压力单位不符模板", pn_err) return try: self._write_correction_to_db() self.status_label.setText("已保存订正(已写回数据库)") QMessageBox.information(self, "已保存", "订正内容已写回数据库,可入队启动打印") except Exception as e: # noqa: BLE001 logger.error(f"[PrintView] 保存订正失败: {e}") QMessageBox.critical(self, "保存失败", f"写回数据库失败:\n{e}") def _write_correction_to_db(self) -> None: """把表单控件写回 Certificate(含编号组),经 CertificateController 收口。 编号范围串按当前 prefix/year_month/start/quantity/suffix 重组, 同时回填 product_code_* 拆分字段,保证队列打印展开编号时数据完整。 视图不再直连 session / ORM,统一委托 CertificateController.save_correction。 """ data = self._get_form_data() self._get_cert_controller().save_correction( self._current_certificate_id, data, self._current_template_key ) def _prepare_and_enqueue(self) -> str | None: """入队前置流水线(「入队」与「入队启动」共用): 1. 检查打印机 (_check_printer,main 护栏能力,入队前必检); 2. 字典自动回填 (_autofill_from_dict,print.guard 链); 3. 缺失字段交互补录 (_validate_and_prompt_fields,print.guard 链); 4. 学习回写字典 (_learn_back,print.guard 链); 5. 保存订正(写回数据库); 6. **从数据库重新读取最新 Certificate**(确保信息无缺失、与表单一致); 若 print.guard 未启用或补录后仍缺失,由 _check_missing_fields 作为最终安全网拦截; 7. 入队到 print_jobs(**不启动**,启动与否由调用方决定)。 队列 worker 内部再按 certificate_id 从数据库读取 Certificate 构建打印 数据——最终送上打印机的一定是「数据库里最新、无缺失」的内容, 而不是内存表单里可能残缺的值。 Returns: str | None: 成功返回 SN 串;任意一步失败(已弹框提示)返回 None。 """ if self._current_certificate_id is None: QMessageBox.warning(self, "提示", "尚未加载合格证,请先从查询清单选择编号") return None cert = self._get_cert_controller().get_certificate_by_id(self._current_certificate_id) if cert is None: QMessageBox.critical(self, "错误", "合格证记录不存在") return None # ============================================================ # 步骤1: 检查打印机 (main 护栏能力,入队前必检,避免无效入队) # 仅当显式配置了指定打印机时才做严格匹配;未配置(默认打印机占位)则 # 跳过严格检查,避免无配置环境下误拦截入队(与旧流水线行为一致)。 # ============================================================ if ( self._default_printer and self._default_printer != "默认打印机" and self._check_printer() is None ): # _check_printer 内部已弹框提示(CI 下被桩掉) return None # ============================================================ # 步骤2: 字典自动回填 (_autofill_from_dict,print.guard 链) # 从 model_param_mappings 回填空白控件(已填的人工值保留) # ============================================================ self._autofill_from_dict(cert) # ============================================================ # 步骤3: 缺失字段交互补录 (_validate_and_prompt_fields,print.guard 链) # 该方法依赖当前表单数据;先构建 data,再交给护栏弹框补录 # (print.guard 未启用时为 no-op,由下方 _check_missing_fields 兜底) # ============================================================ data = self._get_form_data() if not self._validate_and_prompt_fields(data): # 用户取消或补录失败 → 终止流水线(绝不发空白指令) return None # 补录可能已写回控件,重读表单数据(并同步控件 → data) self._sync_form_fields_from_data(data) # 检验工号空值兜底:入队前若仍空白,按模板语言族预填默认工号(配置驱动) self._apply_inspector_default() # ============================================================ # 步骤4: 学习回写字典 (_learn_back,print.guard 链) # 将用户补录的值写回 model_param_mappings,下次同一型号自动填充 # ============================================================ self._learn_back(cert, data) # 模板族压力单位门限(中文放行不门限;中英文/全英文/俄英文必须带单位,缺则拦截) pn_err = PrinterController.validate_pn_text( PrinterController.family_of(self._current_template_key), (data or {}).get("pn", "") ) if pn_err: QMessageBox.warning(self, "压力单位不符模板", pn_err) return None # ============================================================ # 步骤5: 保存订正(把补录写回 DB) # ============================================================ try: self._write_correction_to_db() except Exception as e: # noqa: BLE001 logger.error(f"[PrintView] 入队前保存订正失败: {e}") QMessageBox.critical(self, "保存失败", f"写回数据库失败:\n{e}") return None # ============================================================ # 步骤6: 从数据库重新读取最新 Certificate(信息无缺失) # ============================================================ fresh = self._get_cert_controller().get_certificate_by_id(self._current_certificate_id) if fresh is None: QMessageBox.critical(self, "读取失败", "无法从数据库读取最新合格证内容") return None # 入队前最后一道闸门:DB 里的关键字段必须无缺失,否则不让启动打印 # (保留 _check_missing_fields 作为最终安全网——覆盖 print.guard 未启用 # 或补录后仍缺失的情况,避免把残缺信息直接送上打印机,这正是旧流程的问题) db_missing = self._check_missing_fields() if db_missing: QMessageBox.critical( self, "数据库内容仍缺失", "已写回数据库,但以下字段在库中仍为空,请重新核对补录:\n\n" + "、".join(db_missing), ) return None serial = fresh.product_code_range or fresh.certificate_no or "" self.status_label.setText(f"已从数据库读取最新内容,准备入队:{serial}") # ============================================================ # 🎯 入队前抓取 raw 打印字节(可选,配置 print.raw_capture.enabled 控制) # 此时数据已通过护栏补录、保存订正、DB 重读,是「最终要打印的完整数据」。 # 复用真实渲染链路生成 ESC/P-K 字节但不真正出纸,便于落盘 / 十六进制分析。 # 抓取失败不影响入队(内部 try/except 已包裹)。 # ============================================================ self._capture_raw_if_enabled() # ============================================================ # 步骤7: 入队(不启动) # ============================================================ try: # replace_pending=True:入队前取消该证已有 pending,避免重复点击 # "入队启动"或"订正后再入队"产生同一证多份 pending 导致重复打印。 self._queue_controller.enqueue( certificate_id=self._current_certificate_id, sn=serial, priority=0, replace_pending=True, ) except Exception as e: # noqa: BLE001 logger.error(f"[PrintView] 入队失败: {e}") QMessageBox.critical(self, "入队失败", f"入队失败:\n{e}") return None return serial # ============================================================ # 入队前 raw 打印字节抓取(调试/分析用,配置驱动,不影响入队) # ============================================================ def _capture_raw_if_enabled(self) -> None: """入队前可选抓取 raw 字节(配置 print.raw_capture.enabled 控制)。""" if not self._should_capture_raw(): return raw_bytes, raw_data = self._capture_raw_print_data() if raw_bytes: self._save_raw_binary(raw_bytes, raw_data) def _should_capture_raw(self) -> bool: """是否启用入队前 raw 字节抓取。 优先级:userconfig 覆盖层 print_raw_capture.enabled > 主配置 print.raw_capture.enabled; 两者皆缺省时回退 True(初期调试默认开启,后期可在 UI 或配置关闭)。 """ from certflow.config.settings import get_override ov = get_override("print_raw_capture", "enabled", None) if ov is not None: return bool(ov) return bool(cfg("print.raw_capture.enabled", True)) def _capture_raw_print_data(self) -> tuple[bytes | None, dict | None]: """生成即将发送给打印机的 ESC/P-K 原始字节流(不真正出纸)。 复用真实打印渲染链路(PrinterService.render_certificate_bytes), 在当前「最终完整数据」上渲染,得到与打印启动后完全一致的字节; 同时返回结构化 print_data 供落盘 JSON 便于人工分析。 Returns: (bytes | None, dict | None): 字节流与结构化数据;失败返回 (None, None)。 """ try: data = self._get_form_data() serial = self._get_current_serial() print_data = self._format_print_data(data) print_data["serial_number"] = serial print_data["cert_number"] = serial config = self._build_print_config() if config is None: return None, None printer_name = self._default_printer if not printer_name or printer_name == "默认打印机": printer_name = None raw = self._get_cert_controller().render_certificate_bytes( config, print_data, copies=1, x_offsets=print_data.get("_x_offsets"), printer_name=printer_name, ) return raw, print_data except Exception as e: # noqa: BLE001 logger.error(f"[PrintView] 抓取 raw 打印数据失败: {e}") return None, None def _save_raw_binary(self, data: bytes, print_data: dict | None = None) -> str | None: """保存 raw 字节(.prn)及结构化 JSON 到目录(配置 print.raw_capture.output_dir)。 Returns: str | None: 保存的文件路径;失败返回 None。 """ try: serial = self._get_current_serial() or "unknown" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_dir = cfg("print.raw_capture.output_dir", "raw_print") os.makedirs(output_dir, exist_ok=True) filename = f"raw_print_{serial}_{timestamp}.prn" filepath = os.path.join(output_dir, filename) with open(filepath, "wb") as f: f.write(data) logger.info(f"[PrintView] Raw 二进制已保存: {filepath}") if cfg("print.raw_capture.save_json", True) and print_data is not None: jpath = os.path.join(output_dir, f"raw_print_{serial}_{timestamp}.json") with open(jpath, "w", encoding="utf-8") as jf: json.dump(print_data, jf, ensure_ascii=False, indent=2) self.status_label.setText(f"Raw 已保存: {filename} ({len(data)} bytes)") return filepath except Exception as e: # noqa: BLE001 logger.error(f"[PrintView] 保存 raw 二进制失败: {e}") return None def _on_enqueue(self) -> None: """独立「入队」:仅入队到 print_jobs,不立即启动。 适合批量入队后统一在「打印队列」面板点「启动」控制打印节奏; 入队后队列面板的 启动/置顶/上移 等按钮恢复为有效主操作。 """ serial = self._prepare_and_enqueue() if serial is None: return if self._queue_panel is not None: self._queue_panel.status_label.setText("已入队,待打印——请在队列面板点「启动」") QMessageBox.information( self, "已入队", f"合格证已入队(待打印):\n{serial}\n\n" "请在下方「打印队列」面板点击「启动」开始打印;\n" "可多张入队后统一排序(置顶/上移)再启动。", ) def _on_enqueue_and_start(self) -> None: """入队启动(快捷方式):入队后立即启动队列 worker。 校验/补录/订正/从 DB 重读 与「入队」共用 _prepare_and_enqueue, 区别仅在入队后立即 start()(适合「立刻打这一张」)。 """ serial = self._prepare_and_enqueue() if serial is None: return try: self._queue_controller.start() # 若勾选「生成扫描件」:同一批合格证在后台一并生成扫描件(不阻塞打印) if self.generate_scan_check.isChecked(): self._trigger_scan_generation() QMessageBox.information( self, "已入队", f"合格证已入队并启动打印:\n{serial}\n\n" "队列将从数据库读取最新内容(无缺失)后逐台打印。", ) except Exception as e: # noqa: BLE001 logger.error(f"[PrintView] 入队启动失败: {e}") QMessageBox.critical(self, "入队失败", f"入队/启动失败:\n{e}") # ============================================================ # 勾选「生成扫描件」时:入队启动打印的同时,在后台一并生成扫描件 # 选项来自 output.targets.scan.defaults(配置驱动),UI 内可改。 # ============================================================ def _trigger_scan_generation(self) -> None: """后台触发扫描件生成(与打印并行,不阻塞 UI)。 复用 ScanController / ScanService;选项取 UI 当前值,回退到 ``output.targets.scan.defaults`` 配置默认。双编码开关仍走配置 ``output.targets.scan.dual_code.enabled``(默认 false,长期不用)。 """ cert_ids = self._get_loaded_certificate_ids() if not cert_ids: return try: defaults = self._scan_defaults template_type = self._scan_template_combo.currentText() or defaults.get( "template_type", "全中文" ) output_format = self._scan_format_combo.currentText() or defaults.get("format", "JPG") rows = self._scan_rows_spin.value() cols = self._scan_cols_spin.value() output_dir = defaults.get("output_dir") or cfg("paths.output.scans", "output/scans") from certflow.controllers.scan_controller import ScanController if self._scan_controller is None: if self.db_manager is not None: self._scan_controller = ScanController(db_manager=self.db_manager) else: self._scan_controller = ScanController(self.session) self._scan_controller.completed.connect(self._on_scan_completed) self._scan_controller.error.connect(self._on_scan_error) self._scan_controller.generate_scans( certificate_ids=cert_ids, output_dir=output_dir, template_type=template_type, output_format=output_format, rows=rows, cols=cols, dual_code=bool(cfg("output.targets.scan.dual_code.enabled", False)), ) self.status_label.setText(f"已触发扫描件生成(后台):{len(cert_ids)} 份合格证") except Exception as e: # noqa: BLE001 logger.error(f"[PrintView] 触发扫描件生成失败: {e}") QMessageBox.warning(self, "扫描件生成失败", f"触发扫描件生成失败:\n{e}") def _on_scan_completed(self, result: dict) -> None: """扫描件生成完成回调。""" ok = result.get("success", 0) failed = result.get("failed", 0) msg = result.get("message", "") self.status_label.setText(f"扫描件生成完成:成功 {ok} / 失败 {failed}{msg}") def _on_scan_error(self, err: str) -> None: """扫描件生成错误回调。""" self.status_label.setText(f"扫描件生成失败:{err}") logger.error(f"[PrintView] 扫描件生成错误: {err}") def _sync_form_fields_from_data(self, data: dict) -> None: """把护栏补录后的 data 回写到表单控件,保证控件与 data 一致。 _validate_and_prompt_fields 已通过 _set_form_field 写回控件,这里做一次 幂等同步,避免控件与内存 data 不一致(尤其 CI 下 QInputDialog 被桩为 返回空 ok 的场景)。 """ for key in ( "product_name", "product_model", "dn", "pn", "temperature", "medium", "check_standard", "inspector_id", ): value = (data.get(key) or "").strip() widget = { "product_name": self.product_name_edit, "product_model": self.product_model_edit, "dn": self.dn_edit, "pn": self.pn_edit, "temperature": self.temperature_edit, "medium": self.medium_edit, "check_standard": self.check_standard_edit, "inspector_id": self.inspector_id_edit, }.get(key) if widget is not None and value and not widget.text().strip(): widget.setText(value) def _do_single_print(self) -> None: """单次打印(防重复)。 说明:本 PR 默认 GUI 入口为「入队启动」队列流水线(见 _on_enqueue_and_start, 对应 ISSUE #2:从 DB 重读无缺失再打印、规避写锁)。本直发方法保留为底层 能力,供 main 的 print-guard 端到端测试与内部调用使用,二者互不冲突。 """ if self._is_printing: logger.warning("打印进行中,忽略重复调用") return self._is_printing = True try: data = self._get_form_data() # ② ③ 发送前护栏(单张:multi=False) ok, data = self._guard_before_print(data, multi=False) if not ok: QMessageBox.warning(self, "已中止", "打印已取消:用户中止了护栏检查。") return serial = self._get_current_serial() print_data = self._format_print_data(data) print_data["serial_number"] = serial # 使用 RuntimePrintConfig config = self._build_print_config() if config is None: return config.copies = 1 controller = self._get_printer_controller() controller.print_certificate(print_data, copies=1, config=config) self._advance_start_after_print(1) if data.get("save_to_db", True): self._auto_save([serial], data) # ④ 成功打印后学回字典(保守合并) self._learn_back(self._current_cert, data) finally: self._is_printing = False def _do_batch_print(self) -> None: """批量打印(防重复) 加载了多条销售计划时,依次打印全部计划(每条按各自整批区间); 仅加载一条时保持原有单计划交互(先发第一张再确认)。 """ if self._is_printing: logger.warning("打印进行中,忽略重复调用") return if len(self._loaded_plans) > 1: self._do_batch_print_all() return self._update_serial_nav() serials = self._serial_numbers if not serials: QMessageBox.warning(self, "提示", "无可打印的编号") return if len(serials) == 1: self._do_single_print() return self._is_printing = True try: # 先打印第一张预览 data = self._get_form_data() # ② ③ 发送前护栏(单计划批量:multi=False) ok, data = self._guard_before_print(data, multi=False) if not ok: QMessageBox.warning(self, "已中止", "打印已取消:用户中止了护栏检查。") return print_data = self._format_print_data(data) print_data["serial_number"] = serials[0] config = self._build_print_config() if config is None: return config.copies = 1 controller = self._get_printer_controller() controller.print_certificate(print_data, copies=1, config=config) reply = QMessageBox.question( self, "确认批量打印", f"已发送第一张打印: {serials[0]}\n\n剩余 {len(serials) - 1} 张,是否继续?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: self.status_label.setText("批量打印已取消") return self._emit_remaining(serials, data, config, controller) QMessageBox.information(self, "完成", f"批量打印完成,共 {len(serials)} 张") self._advance_start_after_print(len(serials)) if data.get("save_to_db", True): self._auto_save(serials, data) # ④ 成功打印后学回字典(保守合并) self._learn_back(self._current_cert, data) finally: self._is_printing = False def _emit_remaining(self, serials: list[str], data: dict, config: Any, controller: Any) -> None: """发送 serials[1:] 的剩余编号到打印机(共享首张已建好的 config)。""" self.progress_bar.setVisible(True) self.progress_bar.setMaximum(max(1, len(serials) - 1)) self.progress_bar.setValue(0) for i, serial in enumerate(serials[1:], 1): batch_data = self._format_print_data(data) batch_data["serial_number"] = serial controller.print_certificate(batch_data, copies=1, config=config) self.progress_bar.setValue(i) self.progress_bar.setVisible(False) def _do_batch_print_all(self) -> None: """多销售计划批量打印:依次逐条打印全部计划(每条按各自整批区间)。""" if self._is_printing: return plans = list(zip(self._loaded_plans, self._loaded_certs, strict=True)) if not plans: return total_units = sum(self._plan_quantity(sp, cert) for sp, cert in plans) reply = QMessageBox.question( self, "确认批量打印", f"将对 {len(plans)} 条记录共 {total_units} 台合格证依次打印,是否继续?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: self.status_label.setText("批量打印已取消") return config = self._build_print_config() if config is None: return config.copies = 1 controller = self._get_printer_controller() self._is_printing = True try: printed = 0 for plan, cert in plans: # 绑定该计划到表单(产品信息 + 编号设置),使打印数据正确 self._bind_active_record(plan, cert) self._current_serial_index = 0 self._serial_numbers = [] self._update_serial_nav() serials = self._serial_numbers if not serials: continue data = self._get_form_data() # ② ③ 发送前护栏(多计划批量每批:multi=True) ok, data = self._guard_before_print(data, multi=True) if not ok: self.status_label.setText("批量打印已中止") break print_data = self._format_print_data(data) print_data["serial_number"] = serials[0] controller.print_certificate(print_data, copies=1, config=config) self._emit_remaining(serials, data, config, controller) printed += len(serials) if data.get("save_to_db", True): self._auto_save(serials, data) # ④ 成功打印后学回字典(保守合并) self._learn_back(cert, data) QMessageBox.information(self, "完成", f"批量打印完成,共 {printed} 张") self.status_label.setText(f"批量打印完成,共 {printed} 张") finally: self._is_printing = False # ============================================================ # 打印防错护栏:① 自动带出 / ② 必填校验+InputBox / ③ 确认闸口 / ④ 字典学回 # 设计见 docs/PRINT_BLUEPRINT.md # ============================================================ # ---- 配置与字典查询 ---- def _guard_enabled(self) -> bool: """print.guard 段已配置(存在即启用护栏)。""" return cfg("print.guard", None) is not None def _get_mpm_controller(self) -> ModelParamMappingController: if self._mpm_controller is None: if self.db_manager is not None: self._mpm_controller = ModelParamMappingController(db_manager=self.db_manager) else: self._mpm_controller = ModelParamMappingController(db_session=self.session) return self._mpm_controller def _lookup_model_param(self, product_model: str) -> Any: """按型号查 model_param_mappings,返回记录或 None。""" if not product_model: return None try: ctrl = self._get_mpm_controller() return ctrl.get_by_model(product_model) except Exception as exc: # noqa: BLE001 logger.warning(f"[PrintView] 查询型号字典失败({product_model}): {exc}") return None # ---- ① 加载自动带出 ---- def _apply_autofill_field(self, widget: Any, db_col: str, rec: Any) -> None: """按型号字典预填单个字段(仅在控件为空时写入,人工优先)。""" if widget.text().strip(): return # 已有值不覆盖(人工优先) val = getattr(rec, db_col, None) or "" if val: widget.setText(str(val)) def _validate_and_prompt_fields(self, data: dict) -> bool: """③ 缺失字段交互补录(print.guard 链 — 仅必填项补录)。 入队流水线专用:只做必填字段补录,不含差异确认与确认闸口 (差异确认和闸口在发送前护栏 _guard_before_print 中处理)。 Returns: True 表示补录完成(或无缺失);False 表示用户取消。 """ if not self._guard_enabled(): return True guard = cfg("print.guard", {}) or {} required_fields = guard.get("required_fields", []) or [] if not required_fields: return True for f in required_fields: key = f.get("key") if not key: continue required = f.get("required", True) cur = (data.get(key) or "").strip() if cur or not required: continue # 弹框补录 label = self._field_label(key) text, ok = QInputDialog.getText( self, "补全必填字段", f"请填写{label}:", QLineEdit.Normal, "" ) if not ok: return False text = (text or "").strip() if not text: return False data[key] = text self._set_form_field(key, text) return True # 字段 key → 中文标签映射(供必填补录弹框展示,§print.guard) _FIELD_LABELS: dict[str, str] = { "product_name": "产品名称", "product_model": "产品型号", "dn": "公称通径", "pn": "公称压力", "temperature": "工作温度", "medium": "适用介质", "check_standard": "检验标准", "inspector_id": "检验员工号", } def _autofill_from_dict(self, cert: Any) -> None: """_fill_product_fields 之后调用:表单字段为空时按型号预填字典建议值。""" if not self._guard_enabled(): return if not cfg("print.guard.autofill.enabled", False): return # 「自动填写」复选框为用户意图开关:配置总闸开启且用户勾选才真正预填 if not self.auto_fill_check.isChecked(): return fields_map = cfg("print.guard.autofill.fields", {}) or {} if not fields_map: return model = (getattr(cert, "product_model", None) or "").strip() if not model: return rec = self._lookup_model_param(model) if rec is None: return widget_for = { "temperature": self.temperature_edit, "medium": self.medium_edit, "check_standard": self.check_standard_edit, "pn": self.pn_edit, "dn": self.dn_edit, } for form_field, db_col in fields_map.items(): widget = widget_for.get(form_field) if widget is None: continue self._apply_autofill_field(widget, db_col, rec) def _apply_inspector_default(self) -> None: """检验工号空值兜底(配置驱动 print.guard.autofill.inspector_default)。 表单补充/绑定后若检验工号仍空白,按模板语言族填入默认工号: - 中文合格证(全中文模板)→ chinese 值(默认「检5」) - 其他合格证(中/俄/全英文模板)→ other 值(默认「No 5」) 与字典自动带出同口径:受「自动填写」复选框 + print.guard.autofill.enabled 总闸约束; 人工已填则保留(不覆盖)。配置缺失或总闸关闭时不做任何动作。 """ if not self.auto_fill_check.isChecked(): return if not cfg("print.guard.autofill.enabled", False): return inspector_default = cfg("print.guard.autofill.inspector_default", None) if not inspector_default: return if self.inspector_id_edit.text().strip(): return # 人工已填,优先 family = PrinterController.family_of(self._current_template_key) default = cfg.get("chinese" if family == "全中文" else "other") if not default: return self.inspector_id_edit.setText(str(default)) self._sync_inspector_combo() def _sync_inspector_for_template_change(self, old_family: str) -> None: """模板语言切换时同步检验工号默认值(与生成期烘焙同源:``_default_inspector_id``)。 需求:切换模板下拉(不同语言)时,检验工号要跟随语言: - 中文合格证(全中文)→「检5」,其他(中/俄/全英文)→「No 5」。 仅当当前检验工号等于「旧语言的默认工号」(即此前是自动带出、非人工手填) 才换成新语言默认值;人工手填了其他值则保留不动,避免误覆盖。 当前为空也按新语言默认补填。 注意:此联动是「模板语言族属性」,与「字典自动带出(自动填写复选框 / print.guard.autofill.enabled)」无关,故不受其约束——否则关掉自动填写 后切换模板,检5/No5 不会刷新(原实现的坑)。统一复用生成期同款 ``_default_inspector_id``,保证加载/生成/切换三处默认值一致。 """ current = self.inspector_id_edit.text().strip() old_default = PrinterController.default_inspector_id(old_family) # 人工手填(不等于旧默认)则保留,不误覆盖 if current and current != old_default: return new_family = PrinterController.family_of(self._current_template_key) new_default = PrinterController.default_inspector_id(new_family) if new_default: self.inspector_id_edit.setText(str(new_default)) self._sync_inspector_combo() def _on_inspector_combo_changed(self, text: str) -> None: """下拉选择检验工号默认(检5 / No 5)时,写回文本框并触发预览刷新。 下拉是「显式覆盖」控件:选中即覆盖当前值,不再走模板自动切换的 「非人工手填才刷新」约束。 """ if not text: return self.inspector_id_edit.setText(text) self._on_data_changed() def _sync_inspector_combo(self) -> None: """把下拉选中态同步到文本框当前值: - 文本框值为 检5 / No 5 → 下拉选中对应项; - 文本框为其他值(人工手填)→ 下拉置空(index -1),提示非默认值。 """ val = self.inspector_id_edit.text().strip() idx = self.inspector_combo.findText(val, Qt.MatchFixedString) self.inspector_combo.setCurrentIndex(idx if idx >= 0 else -1) # ---- ② 必填校验 + VBA 式 InputBox 补录 ---- def _field_label(self, key: str) -> str: return self._FIELD_LABELS.get(key, key) def _set_form_field(self, key: str, text: str) -> None: widget = { "product_name": self.product_name_edit, "product_model": self.product_model_edit, "dn": self.dn_edit, "pn": self.pn_edit, "temperature": self.temperature_edit, "medium": self.medium_edit, "check_standard": self.check_standard_edit, "inspector_id": self.inspector_id_edit, "manufacture_date": self.manufacture_date_edit, }.get(key) # 容错:部分字段(如 serial_number)为系统自动生成、无对应录入控件, # 缺失控件时仅更新 data 字典(由调用方处理),不回写 UI。 if widget is not None: widget.setText(text) # ---- 统一护栏管道(抽公共到 services.output_guard,print/scan 共用,§11.5/§11.8 阶段1)---- def _guard_before_print(self, data: dict, *, multi: bool) -> tuple[bool, dict]: """发送前统一护栏(② 必填补录 / ③ reconfirm / ④ 差异确认 / ⑤ 确认闸口)。 返回 ``(ok, updated_data)``;``ok=False`` 表示用户取消,本批应中止。 内部将更新后的值写回表单控件(UX),并返回更新后的 data 供后续打印使用。 """ if not self._guard_enabled(): return True, data guard = cfg("print.guard", {}) or {} history_last = self._get_history_last(data) prompt = self._QtGuardPrompt(self) ok, updated = PrinterController.run_guard( data, guard=guard, prompt=prompt, history_last=history_last, multi=multi ) if ok: self._sync_form_from_data(updated) return ok, updated def _get_history_last(self, data: dict) -> dict | None: """取同型号上次实际打印值(供 ④ diff 闸)。无历史/异常返回 None。""" model = (data.get("product_model") or "").strip() if not model: return None try: ctrl = self._get_cert_controller() return ctrl.get_history_last_values(model) except Exception as exc: # noqa: BLE001 logger.debug(f"[PrintView] 历史候选获取失败: {exc}") return None def _sync_form_from_data(self, data: dict) -> None: """将护栏更新后的字段值写回对应表单控件(UX 同步)。""" for key, val in data.items(): self._set_form_field(key, val) class _QtGuardPrompt: """GuardPrompt 的 PyQt 实现:弹 InputBox 补录/确认 + QMessageBox 确认闸口。""" def __init__(self, view: PrintView) -> None: self._view = view def prompt_field( self, *, key: str, label: str, current: str, suggestion: str ) -> str | None: suggest = suggestion or self._dict_suggestion(key) title = "确认字段" if current else "补全必填字段" verb = "确认" if current else "填写" text, ok = QInputDialog.getText( self._view, title, f"请{verb}{label}:", QLineEdit.Normal, current or suggest, ) if not ok: return None return text def confirm(self, *, message: str) -> bool: return ( QMessageBox.question( self._view, "确认打印", message, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) == QMessageBox.StandardButton.Yes ) def _dict_suggestion(self, key: str) -> str: """从 model_param_mappings 取该字段建议值(仅 temperature/medium/check_standard/pn 有意义)。""" mapping = { "temperature": "working_temp", "medium": "working_medium", "check_standard": "test_standard", "pn": "pressure_value", } col = mapping.get(key) if not col: return "" model = (self._view.product_model_edit.text() or "").strip() rec = self._view._lookup_model_param(model) if model else None if rec is None: return "" return getattr(rec, col, None) or "" # ---- ④ 学回字典(保守合并,manual 永不覆盖) ---- def _learn_back(self, cert: Any, data: dict) -> None: if not self._guard_enabled(): return if not cfg("print.guard.learn_back.enabled", False): return fields = cfg("print.guard.learn_back.fields", []) or [] if not fields: return model = (getattr(cert, "product_model", None) or "") or (data.get("product_model") or "") model = (model or "").strip() if not model: return form_to_db = { "working_temp": data.get("temperature", ""), "working_medium": data.get("medium", ""), "test_standard": data.get("check_standard", ""), "pressure_value": data.get("pn", ""), } updates = {col: (val or "").strip() for col, val in form_to_db.items() if col in fields} if not any(updates.values()): return strategy = cfg("print.guard.learn_back.strategy", "conservative") overwrite_manual = cfg("print.guard.learn_back.overwrite_manual", False) try: self._get_mpm_controller().upsert_for_learnback( model, updates, strategy=strategy, overwrite_manual=overwrite_manual, ) except Exception as exc: # noqa: BLE001 logger.warning(f"[PrintView] 学回型号字典失败({model}): {exc}") def _save_preview_image(self) -> None: """保存预览为图片(PNG)。 对齐补丁 19:对话框「导出 PDF」已改为「导出图片」,主预览页此按钮同步落地, 复用 ``render_certificate_pixmap`` 渲染位图并 ``QPixmap.save`` 落盘,消除 「PDF 生成功能开发中」占位(蓝图 D-13)。PDF 导出暂不在范围内。 """ serial = self._get_current_serial() file_path, _ = QFileDialog.getSaveFileName( self, "保存图片", f"合格证_{serial}.png", "PNG 图片 (*.png)" ) if not file_path: return data = self._get_form_data() pixmap = render_certificate_pixmap( data, serial, self._current_template_key, show_labels=self._show_labels, paper_w_mm=self._paper_width, paper_h_mm=self._paper_height, preview_w_px=280, label_x_mm=self._label_x, ) if pixmap.isNull(): QMessageBox.warning(self, "保存失败", "预览渲染为空,无法保存图片") return if pixmap.save(file_path, "PNG"): QMessageBox.information(self, "已保存", f"预览图片已保存到:\n{file_path}") else: QMessageBox.warning(self, "保存失败", "图片保存失败,请检查路径与权限") # ============================================================ # 预览相关 # ============================================================ # 预览渲染已迁移至 certificate_preview_painter.render_certificate_pixmap(C2 去 Chromium)。 # 字段标题映射 (_FIELD_LABELS) 与模板感知格式化 (TemplateFieldFormatter) 均在该模块内; # 移除原 QWebEngineView/HTML 渲染(含 _is_headless 段错误绕过、底板 data URI 内嵌等)。 def _refresh_preview(self) -> None: """刷新预览(QPainter 绘制,替代 QWebEngineView / HTML)""" if not hasattr(self, "preview_label"): return data = self._get_form_data() serial = self._get_current_serial() pixmap = render_certificate_pixmap( data, serial, self._current_template_key, show_labels=self._show_labels, paper_w_mm=self._paper_width, paper_h_mm=self._paper_height, preview_w_px=280, label_x_mm=self._label_x, ) self.preview_label.setPixmap(pixmap) # ============================================================ # 辅助方法 # ============================================================ def _get_form_data(self) -> dict: """获取表单数据""" return { "product_name": self.product_name_edit.text().strip(), "product_model": self.product_model_edit.text().strip(), "dn": self.dn_edit.text().strip(), "pn": self.pn_edit.text().strip(), "temperature": self.temperature_edit.text().strip(), "medium": self.medium_edit.text().strip(), "manufacture_date": self.manufacture_date_edit.text().strip(), "check_standard": self.check_standard_edit.text().strip(), "inspector_id": self.inspector_id_edit.text().strip(), "prefix": self.prefix_edit.text().strip(), "year_month": self.year_month_edit.text().strip(), "start_number": self.start_number_spin.value(), "quantity": self.quantity_spin.value(), "suffix": self.suffix_edit.text().strip(), "number_mode": self.number_mode_combo.currentText(), "template_type": PrinterController.family_of(self._current_template_key), "template_name": self.template_combo.currentText(), "save_to_db": self.save_to_db_check.isChecked(), } # ============================================================ # 模板感知字段格式化(预览与打印共用,配置驱动) # 实现见 certflow.services.printer.template_field_formatter(docs/PRINT_BLUEPRINT.md §4) # ============================================================ def _format_print_data(self, data: dict) -> dict: """返回一份用于「实际打印」的格式化副本(不污染原始 data,避免影响落库)。 除按模板语言族格式化文本外,还会计算英制字段的 X 偏移(mm), 以 ``_x_offsets`` 随副本透传,供 ESC/P 引擎将数值右移到预印单位标签下方 (详见 docs/PRINT_BLUEPRINT.md §4.2)。 """ out = dict(data) formatter = PrinterController.get_field_formatter() family = PrinterController.family_of(self._current_template_key) x_offsets: dict[str, float] = {} for f in ("pn", "temperature", "medium", "dn"): raw = out.get(f) if not raw: continue text, x_off = formatter.format(f, raw, family) out[f] = text if x_off: x_offsets[f] = x_off if x_offsets: out["_x_offsets"] = x_offsets return out def _compose_serial( self, prefix: str, year_month: str, start: int, quantity: int, suffix: str, three_digit: bool = False, ) -> str: """单一编号组合函数(编号设置组与导航共用,避免两套拼接逻辑漂移)。 - 单台(quantity==1):``{prefix}{year_month}{起始:03d}{suffix}`` - 多台:``{prefix}{year_month}{起始:03d}{suffix}---{结束:03d}{suffix}`` ``three_digit=True``(模式「三位流水」)强制 3 位流水;否则按原规则 (<1000 用 3 位,>=1000 用原始位数)保持兼容。 """ start_str = ( f"{start:03d}" if three_digit else f"{start:03d}" if start < 1000 else str(start) ) if quantity == 1: return f"{prefix}{year_month}{start_str}{suffix}" end = start + quantity - 1 end_str = f"{end:03d}" if three_digit else f"{end:03d}" if end < 1000 else str(end) return f"{prefix}{year_month}{start_str}{suffix}---{end_str}{suffix}" def _get_current_serial(self) -> str: """获取当前编号(优先取导航列表,否则按表单实时组合)""" if self._serial_numbers and 0 <= self._current_serial_index < len(self._serial_numbers): return self._serial_numbers[self._current_serial_index] prefix = self.prefix_edit.text().strip() year_month = self.year_month_edit.text().strip() start = self.start_number_spin.value() suffix = self.suffix_edit.text().strip() quantity = self.quantity_spin.value() return self._compose_serial( prefix, year_month, start, quantity, suffix, self._three_digit_mode ) def _update_serial_nav(self) -> None: """更新编号导航(仅在列表为空时按表单重算,避免覆盖已生成的导航)""" if not self._serial_numbers: prefix = self.prefix_edit.text().strip() year_month = self.year_month_edit.text().strip() start = self.start_number_spin.value() suffix = self.suffix_edit.text().strip() quantity = self.quantity_spin.value() self._serial_numbers = [ self._compose_serial(prefix, year_month, s, 1, suffix, self._three_digit_mode) for s in range(start, start + quantity) ] total = len(self._serial_numbers) current = self._current_serial_index + 1 if total > 0 else 0 self.serial_label.setText(f"{current} / {total}") self.prev_btn.setEnabled(self._current_serial_index > 0) self.next_btn.setEnabled(self._current_serial_index < total - 1) if self._serial_numbers: self.end_preview_label.setText(self._serial_numbers[-1]) def _auto_save(self, serials: list[str], data: dict) -> None: """自动保存打印记录到数据库(经 CertificateController 收口)。""" printer_name = self._checked_printer or self._default_printer self._get_cert_controller().auto_save_log( serials, data, printer_name, self._current_certificate_id ) def _prev_serial(self) -> None: if self._current_serial_index > 0: self._current_serial_index -= 1 self._update_serial_nav() self._refresh_preview() def _next_serial(self) -> None: if self._current_serial_index < len(self._serial_numbers) - 1: self._current_serial_index += 1 self._update_serial_nav() self._refresh_preview() def _on_data_changed(self) -> None: """数据变化(使用防抖,300ms 后统一刷新)""" self._refresh_timer.start(300) def _on_number_field_changed(self) -> None: """编号设置字段变化:重置导航索引、清空已生成列表并重算,再防抖刷新预览。 修复原逻辑中改起始/数量后右侧编号导航与结束号不刷新的不一致。 """ self._current_serial_index = 0 self._serial_numbers = [] self._update_serial_nav() self._refresh_timer.start(300) def _do_refresh_preview(self) -> None: """实际刷新预览(由防抖定时器触发)""" self._refresh_preview() def _on_template_changed(self, _index: int = 0) -> None: """模板变化(下拉改选项即刷新预览 + 同步检验工号默认)""" key = self.template_combo.currentData() if key: old_family = PrinterController.family_of(self._current_template_key) self._current_template_key = key # 切换语言族时同步检验工号默认值(中文→检5 / 其他→No 5) self._sync_inspector_for_template_change(old_family) self._refresh_preview() def _on_show_labels_toggled(self, checked: bool) -> None: """预览「显示标题」开关:仅影响预览渲染(预印合格证可关,只显示值)""" self._show_labels = bool(checked) self._refresh_preview() def _on_number_mode_changed(self, mode: str) -> None: """编号模式变化:切换流水位数 / 重新从证书加载编号。 - 「三位流水」:强制 3 位流水(覆盖默认的 <1000 用 3 位、>=1000 用原位的规则)。 - 「读取编号」:从当前绑定的 Certificate 重新回填编号设置组。 - 其余模式:仅刷新编号导航与预览。 """ logger.info(f"编号模式切换为: {mode}") self._three_digit_mode = mode == "三位流水" if mode == "读取编号" and self._current_cert is not None: self._bind_number_fields(self._current_cert) self._current_serial_index = 0 self._serial_numbers = [] self._update_serial_nav() self._refresh_preview() def _on_auto_fill_toggled(self, checked: bool) -> None: """H2:「自动填写」开关切换 → 按新策略即时重载当前证书。 - 勾选(checked=True):重载时 DB 值**覆盖**手改(force=True); - 取消(checked=False):重载时**保留**手改、仅填空白(force=False)。 """ if self._current_certificate_id is None: return self._reload_current_certificate(force=bool(checked)) # 表单控件属性名 → Certificate 列名(用于入队前校验 DB 中是否仍缺失) _FIELD_TO_CERT_ATTR: dict[str, str] = { "product_name_edit": "product_name", "product_model_edit": "product_model", "dn_edit": "product_spec", "pn_edit": "pn_display", "temperature_edit": "working_temp", "medium_edit": "working_medium", "check_standard_edit": "test_standard", "inspector_id_edit": "inspector_id", }
[文档] def load_selected_sale_plan_ids(self, sale_plan_ids: list[int]) -> None: """加载选中的销售计划 ID 列表,支持一次打印多条记录。 多条记录均加入打印视图:各自绑定最新 Certificate 并按整批编号区间 回填;批量打印时依次逐条打印(如 12 台 + 32 台 = 44 台)。表单编辑 / 预览默认展示第一条,可通过「批量」按钮一次打印全部。 """ if not sale_plan_ids: return # 收集所有销售计划及其关联的最新 Certificate(按 unique_key) self._loaded_plans = [] self._loaded_certs = [] for pid in sale_plan_ids: sp = self._get_cert_controller().get_sale_plan_by_id(pid) if sp is None: continue self._loaded_plans.append(sp) cert = ( self._get_cert_controller().get_certificate_by_unique_key(sp.unique_key) if sp.unique_key else None ) self._loaded_certs.append(cert) if not self._loaded_plans: return # 绑定第一条到表单(产品信息 / 编号设置,供编辑与预览) sp0 = self._loaded_plans[0] cert0 = self._loaded_certs[0] self._bind_active_record(sp0, cert0) total_units = sum( self._plan_quantity(sp, cert) for sp, cert in zip(self._loaded_plans, self._loaded_certs, strict=True) ) logger.info( "[PrintView] 加载销售计划: {} 条, 合计 {} 台, cert_id={}", len(self._loaded_plans), total_units, self._current_certificate_id, ) self.status_label.setText(f"已加载 {len(self._loaded_plans)} 条记录 / 共 {total_units} 台")
def _bind_active_record(self, plan: Any, cert: Any) -> None: """将一条 (销售计划, Certificate) 绑定到表单(产品信息 + 编号设置)。""" if cert: self._current_certificate_id = cert.id self._current_cert = cert self._fill_product_fields(cert) # A3:恢复写证/打印时记录的模板(按语言族选中对应变体) self._restore_template_by_family(cert.template_type or "") # H1:补全出厂年月 / 检验工号 self._last_issue_ym = self._format_issue_ym(cert.issue_date) self._apply_issue_date_display() self.inspector_id_edit.setText(cert.inspector_id or "") # 检验工号空值兜底:绑定即按模板语言族预填默认工号(配置驱动) self._apply_inspector_default() self._sync_inspector_combo() self._current_sale_plan = plan # 编号设置:按销售计划整批区间回填(前缀/年月/起始/数量/后缀) self._bind_number_fields(cert, plan) def _fill_product_fields(self, cert: Any) -> None: """从 Certificate 回填产品信息字段(名称/型号/口径/压力/温度/介质/标准)。""" self.product_name_edit.setText(cert.product_name or "") self.product_model_edit.setText(cert.product_model or "") self.dn_edit.setText(cert.product_spec or "") self.pn_edit.setText(cert.pn_display or "") self.temperature_edit.setText(cert.working_temp or "") self.medium_edit.setText(cert.working_medium or "") self.check_standard_edit.setText(cert.test_standard or "") # ① 加载自动带出:表单字段为空时按型号预填字典建议值(docs/PRINT_BLUEPRINT.md) self._autofill_from_dict(cert) def _reload_current_certificate(self, force: bool | None = None) -> None: """H2 事件驱动重载:从数据库重读当前 Certificate 并完整重绑表单。 解决簇 H 核心缺口——表单是「选中时拷贝一次的快照」,Certificate 被改 (参数补充确认 / 打印回填 / 订正写库)后表单不重载,导致用户看到的表单 与库里真实值不一致。本方法是「及时刷新」的统一入口,被以下事件驱动: - 打印队列 worker 完成(``job_finished`` 信号,覆盖参数补充确认 + 打印回填); - 参数补充确认后即时重载(``supplement_callback`` 包装层); - 保存订正写库成功后重载(可选显式调用)。 重载策略(覆盖 vs 保留手改值)由 ``auto_fill_check`` 决定,对齐文档 §10.4-1 H2「接线 auto_fill_check 决定是否自动覆盖手改值」: - ``force=True``:DB 值强制覆盖所有控件(无视手改); - ``force=False``:仅填空白控件,保留用户手改值; - ``force=None``(默认):由 ``auto_fill_check.isChecked()`` 决定—— 勾选 ⇒ 覆盖(True),未勾选 ⇒ 保留手改(False)。 重载后刷新预览(防抖)。 """ if self._current_certificate_id is None: return # 重读数据库最新值:worker 在独立线程/会话写回并已 commit,主线程会话 # 的 identity map 可能缓存旧对象——先 expire 再经 controller 门面重查, # 保证拿到库里最新内容(controller 在 db_manager 模式下用独立会话,天然最新)。 self.session.expire_all() fresh = self._get_cert_controller().get_certificate_by_id(self._current_certificate_id) if fresh is None: logger.warning( f"[PrintView] H2 重载失败:cert_id={self._current_certificate_id} 不存在" ) return overwrite = force if force is not None else self.auto_fill_check.isChecked() # 产品信息字段:按策略填/覆盖 self._apply_reload_field(self.product_name_edit, fresh.product_name, overwrite) self._apply_reload_field(self.product_model_edit, fresh.product_model, overwrite) self._apply_reload_field(self.dn_edit, fresh.product_spec, overwrite) self._apply_reload_field(self.pn_edit, fresh.pn_display, overwrite) self._apply_reload_field(self.temperature_edit, fresh.working_temp, overwrite) self._apply_reload_field(self.medium_edit, fresh.working_medium, overwrite) self._apply_reload_field(self.check_standard_edit, fresh.test_standard, overwrite) self._apply_reload_field(self.inspector_id_edit, fresh.inspector_id, overwrite) # 出厂年月:由 cert.issue_date 格式化(H1 已补,重载时同步最新值) self._last_issue_ym = self._format_issue_ym(fresh.issue_date) if overwrite or not self.manufacture_date_edit.text().strip(): self._apply_issue_date_display() self._sync_inspector_combo() # 编号设置组:始终按 DB 最新区间回填(编号是派生数据,非用户手改焦点) self._bind_number_fields(fresh, self._current_sale_plan) # 模板:按 DB 记录的语言族恢复(A3) self._restore_template_by_family(fresh.template_type or "") # 检验工号空值兜底(配置驱动,受 auto_fill_check 约束) self._apply_inspector_default() # 刷新预览(防抖,复用既有机制) self._refresh_timer.start(300) logger.info(f"[PrintView] H2 表单已事件驱动重载 cert_id={fresh.id} | overwrite={overwrite}") def _apply_reload_field(self, widget: Any, value: Any, overwrite: bool) -> None: """按重载策略写回单个控件。 - ``overwrite=True``:DB 值覆盖控件当前内容; - ``overwrite=False``:仅当控件为空时填 DB 值,保留用户手改。 """ new_val = (value or "").strip() if overwrite or not widget.text().strip(): widget.setText(new_val) def _plan_quantity(self, plan: Any, cert: Any) -> int: """计算一条销售计划对应的合格证台数(整批区间优先,回退单张证书)。""" meta = None if plan and getattr(plan, "product_code", None): meta = PrinterController.parse_product_code_meta(plan.product_code) if meta is None and cert and cert.product_code_range: meta = PrinterController.parse_product_code_meta(cert.product_code_range) if meta: return int(meta["quantity"]) return int(cert.quantity) if cert and cert.quantity else 1 # ============================================================ # H1:表单信源补全(出厂年月 / 检验工号 / 产品编号) # ============================================================ @staticmethod def _format_issue_ym(issue_date: str | None) -> str: """出厂日期格式化为 YYYY.MM(与 BUG-006 J 展示口径一致)。""" if not issue_date: return datetime.now().strftime("%Y.%m") s = str(issue_date).replace("/", "-") parts = s.split("-") if len(parts) >= 2: return f"{parts[0]}.{parts[1]}" return str(issue_date) def _bind_number_fields(self, cert: Any = None, sale_plan: Any = None) -> None: """从 SalePlan / Certificate 回填编号设置组(前缀/年月/起始/数量/后缀)。 数据源绑定的一部分:``load_selected_sale_plan_ids`` 负责产品信息字段, 本方法负责编号设置组,二者解耦清晰。 编号设置以「销售计划整批编号区间」为准(BUG-006 按销售计划关联 + 打印 按数量分批):优先正则拆分 ``sale_plan.product_code`` 区间串(如 ``V2607012-023`` → 前缀 V / 年月 2607 / 起始 12 / 数量 12 / 后缀), 使打印设置直接反映整批台数,便于按数量批量打印(数量 12 = 第一批, 后续改数量 32 = 第二批)。拆分失败再回退单张 Certificate 的 ``product_code_range`` 或已拆填的 ``product_code_*`` 字段。 """ # 优先取销售计划整批编号区间(含整批台数),其次单张证书区间 sp = sale_plan or getattr(self, "_current_sale_plan", None) meta = None if sp and getattr(sp, "product_code", None): meta = PrinterController.parse_product_code_meta(sp.product_code) if meta is None and cert and cert.product_code_range: meta = PrinterController.parse_product_code_meta(cert.product_code_range) if meta: prefix = meta["prefix"] ym = meta["ym"] seq_start = meta["seq_start"] quantity = meta["quantity"] suffix = meta["suffix"] or "" else: prefix = (cert.product_code_prefix if cert else None) or "V" ym = (cert.product_code_ym if cert else None) or datetime.now().strftime("%y%m") seq_start = (cert.product_code_seq_start if cert else None) or 1 quantity = (cert.quantity if cert else None) or 1 suffix = "" self.prefix_edit.setText(prefix or "V") self.year_month_edit.setText(ym or datetime.now().strftime("%y%m")) self.start_number_spin.setValue(int(seq_start) if seq_start else 1) self.quantity_spin.setValue(int(quantity) if quantity else 1) self.suffix_edit.setText(suffix or "") # 加载期间抑制「目标年月」自动覆盖起始;加载后刷新下一号预览(不改起始) self._loading_record = True # (上面 setText 已触发 _on_target_ym_changed,因 _loading_record 被跳过自动填充) self._loading_record = False self._update_next_serial_label() # ============================================================ # 以下为 main 分支的「直发/护栏」底层基础设施方法(合并自 origin/main)。 # 本 PR 的 GUI 默认入口为「保存订正 / 入队启动」队列流水线(见 _on_save_correction / # _on_enqueue_and_start),但保留这些底层方法,以便 main 的 print-guard 端到端 # 测试(tests/test_print_guard_e2e_smoke.py)及内部直发调用(_do_batch_print_all 等) # 仍可正常工作;它们与队列流水线互不冲突。 # ============================================================ def _get_printer_controller(self) -> Any: """获取打印机控制器""" if self._printer_controller is None: from certflow.controllers.printer_controller import PrinterController self._printer_controller = PrinterController() # 使用当前活动打印机名(由 PrinterController.current_printer 惰性获取系统默认) real_printer = self._printer_controller.current_printer logger.info(f"[PrintConfig] 系统默认打印机: {real_printer}") if real_printer: self._printer_controller.set_printer(real_printer) # 连接打印完成信号,感知异步打印结果 self._printer_controller.print_completed.connect(self._on_print_done) return self._printer_controller def _on_print_done(self, success: bool, message: str) -> None: """打印完成回调(异步)""" if success: self.status_label.setText(f"打印成功: {message}") else: self.status_label.setText(f"打印失败: {message}") logger.error(f"[PrintConfig] 打印失败: {message}") def _on_queue_job_finished(self, job_id: int, success: bool, message: str) -> None: """H2 事件驱动重载:打印队列任务完成后按 job 关联证书重载表单。 ``PrintQueueController.job_finished`` 信号(主线程 emit,worker 写库已 commit)携带 ``(job_id, success, message)``。任务成功且关联证书正是当前 加载的证时,重读数据库并重绑表单——覆盖「参数补充确认写库」与「打印回填」 两类 Certificate 变更,使表单不再停留在选中时的旧快照。 重载策略遵循 ``auto_fill_check``(见 ``_reload_current_certificate``)。 """ if not success: return # 经 CertificateController 门面查 job 关联的 certificate_id(视图不直连 models) try: job_cert_id = self._get_cert_controller().get_job_certificate_id(job_id) except Exception as e: # noqa: BLE001 logger.warning(f"[PrintView] H2 查 job 失败,跳过重载: {e}") return if job_cert_id is None: return if job_cert_id != self._current_certificate_id: return logger.info(f"[PrintView] H2 队列任务完成触发重载 job_id={job_id} cert_id={job_cert_id}") self._reload_current_certificate() def _check_printer(self) -> str | None: """严格检查打印机:配置指定名称必须在系统列表中,并设为活动打印机 同一批次多次调用时,使用缓存结果避免重复枚举打印机列表。 Returns: 匹配的打印机名称,不匹配时弹框提示并返回 None """ import win32print # 缓存:同一批次已检查过则直接返回 if self._checked_printer: return self._checked_printer # 1. 从配置文件读取期望的打印机名 expected = self._default_printer if not expected or expected == "默认打印机": logger.warning("[PrintConfig] 配置中未指定打印机,跳过检查") return None # 2. 获取系统打印机列表 try: printers = [ p[2] for p in win32print.EnumPrinters( win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS ) ] except Exception: printers = [] # 3. 精确匹配:只认定配置中的单一打印机,名称不一致即视为不可用 # (不接受模糊匹配,避免错打到其它设备) matched = expected if expected in printers else None if matched is None: logger.error( f"[PrintConfig] 打印机未找到: 配置={expected}, 系统可用={', '.join(printers[:5])}" ) QMessageBox.warning( self, "打印机未就绪", f"配置指定的打印机:\n {expected}\n\n" "在系统中未找到。\n\n" "请确认:\n" "1. 打印机已开机并连接\n" "2. 驱动程序已正确安装\n" "3. 打印机名称与 config.yaml 中一致", ) return None # 5. 设为系统默认打印机,确保打印作业发送到正确设备 current_default = win32print.GetDefaultPrinter() if current_default != matched: logger.info(f"[PrintConfig] 切换默认打印机: {current_default}{matched}") try: win32print.SetDefaultPrinter(matched) except Exception as e: logger.error(f"[PrintConfig] 设置默认打印机失败: {e}") QMessageBox.warning( self, "打印机设置失败", f"无法将默认打印机切换为:\n {matched}\n\n错误: {e}", ) return None logger.info(f"[PrintConfig] 打印机就绪: {matched}") self._checked_printer = matched return matched def _build_print_config(self) -> Any: """构建打印配置,打印机不可用时返回 None""" from certflow.config.print_config import RuntimePrintConfig # 严格检查打印机 printer_name = self._check_printer() if printer_name is None: return None # 根据模板配置键映射(A2:不再将「全英文」静默降级到 bilingual_1) key = self._current_template_key or "full_chinese_1" return RuntimePrintConfig.from_template( template_key=key, printer_name=printer_name, copies=1, ) def _advance_start_after_print(self, count: int) -> None: """打印后推进起始编号""" current = self.start_number_spin.value() self.start_number_spin.setValue(current + count) self._serial_numbers = [] self._update_serial_nav()