# src/certflow/controllers/scan_controller.py
"""扫描件控制器 - UI 交互层
提供扫描件生成的异步控制,封装 ScanWorker 线程和信号机制。
通过信号槽实现非阻塞扫描件生成。
"""
from __future__ import annotations
import io
from typing import TYPE_CHECKING, Any
from PySide6.QtCore import QObject, QThread, Signal
from certflow.services.output_guard import (
FIELD_LABELS,
collect_incomplete,
family_requires_pn_unit,
scan_required_fields_for,
)
from certflow.services.pn_service import PNService
from certflow.services.printer.template_field_formatter import family_of
from certflow.services.printer.template_manager import get_template_manager
from certflow.services.sale_plan_service import SalePlanService
from certflow.services.scan_service import ScanService
from certflow.utils.logger import logger
if TYPE_CHECKING:
from certflow.models.certificate import Certificate
[文档]
class ScanWorker(QThread):
"""扫描件生成工作线程"""
progress = Signal(int, int, str) # current, total, message
finished = Signal(dict) # result
error = Signal(str) # error_message
def __init__(self, service: ScanService, params: dict[str, Any]) -> None:
"""初始化工作线程
Args:
service: ScanService 实例
params: 生成参数字典
"""
super().__init__()
self.service = service
self.params = params
self._is_cancelled = False
[文档]
def cancel(self) -> None:
"""取消任务
Returns:
None
"""
self._is_cancelled = True
[文档]
def run(self) -> None:
"""线程主执行方法
调用 ScanService 生成扫描件,成功后发出 finished 信号,
异常时发出 error 信号。
Returns:
None
"""
try:
result = self.service.generate_scans(
**self.params,
progress_callback=self._on_progress,
)
if not self._is_cancelled:
self.finished.emit(result)
else:
self.finished.emit({"success": 0, "failed": 0, "files": [], "message": "已取消"})
except Exception as e:
logger.error(f"扫描件生成线程异常: {e}")
if not self._is_cancelled:
self.error.emit(str(e))
def _on_progress(self, current: int, total: int, message: str) -> None:
"""进度回调
Args:
current: 当前已处理数量
total: 总数量
message: 进度描述信息
"""
if not self._is_cancelled:
self.progress.emit(current, total, message)
[文档]
class ScanController(QObject):
"""扫描件控制器
管理扫描件生成任务,通过信号槽实现 UI 与业务逻辑的解耦。
Signals:
progress: 进度信号 (current, total, message)
completed: 完成信号 (result: dict)
error: 错误信号 (error_message: str)
"""
progress = Signal(int, int, str)
completed = Signal(dict)
error = Signal(str)
def __init__(self, session: Any = None, db_manager: Any = None) -> None:
"""初始化扫描件控制器
Args:
session: SQLAlchemy 会话对象(优先使用)
db_manager: 数据库管理器(session 为 None 时经其创建会话)
"""
super().__init__()
if session is None and db_manager is not None:
session = db_manager.get_session()
self._service = ScanService(session)
self._worker: ScanWorker | None = None
self._current_task_id: int = 0
# ------------------------------------------------------------------
# 视图层穿透收口(阶段 C):ScanView 原先直连 services/handlers,
# 现统一经本控制器委托,消除视图对底层服务/工具的散落依赖。
# ------------------------------------------------------------------
[文档]
def strip_unit(self, pn_display: str) -> tuple[str, str]:
"""从展示用压力串拆出 ``(数值, 单位)``
收口 ``ScanView`` 原先直连 ``PNService.strip_unit`` 的静态调用,
使压力单位解析逻辑的唯一入口收敛到 Controller 层。
Args:
pn_display: 打印界面展示的压力串(如 ``25MPa`` / ``150Lb``)
Returns:
tuple[str, str]: (数值, 单位);无法拆分时返回 ``("", "")``
"""
return PNService.strip_unit(pn_display)
# ------------------------------------------------------------------
# 静态门面:收口 ScanView 对 service / handler / model 的散落直连
# (即使 ScanController 未注入实例,View 仍可经类名调用,向后兼容)
# ------------------------------------------------------------------
[文档]
@staticmethod
def family_of(template_key: str | None) -> str:
"""由模板键推导语言族(委托 ``template_field_formatter.family_of``)。"""
return family_of(template_key)
[文档]
@staticmethod
def scan_required_fields(family: str, base: list[str]) -> list[str]:
"""按语言族计算扫描必填字段(委托 ``output_guard.scan_required_fields_for``)。"""
return scan_required_fields_for(family, base)
[文档]
@staticmethod
def family_requires_pn_unit(family: str) -> bool:
"""判断语言族是否要求压力单位(委托 ``output_guard.family_requires_pn_unit``)。"""
return family_requires_pn_unit(family)
[文档]
@staticmethod
def collect_incomplete(cdata: dict[str, Any], required: list[str]) -> list[str]:
"""收集缺失必填字段(委托 ``output_guard.collect_incomplete``)。"""
return collect_incomplete(cdata, required)
[文档]
@staticmethod
def field_label(field: str) -> str:
"""字段中文标签(委托 ``output_guard.FIELD_LABELS``)。"""
return FIELD_LABELS.get(field, field)
[文档]
@staticmethod
def list_enabled_templates() -> list[tuple[str, str, str]]:
"""列出已启用模板(委托 ``template_manager.get_template_manager``)。"""
return get_template_manager().list_enabled_templates()
[文档]
@staticmethod
def load_dual_defaults(session: Any, sale_plan_id: Any) -> dict[str, str]:
"""读取关联 SalePlan 的 SN/KKS 基准值(委托 ``SalePlanService.find_by_id``)。
查询失败或不存在时返回空串字典,不影响基础字段。
"""
sn, kks = "", ""
try:
if sale_plan_id is not None and session is not None:
plan = SalePlanService(session).find_by_id(sale_plan_id)
if plan is not None:
sn = getattr(plan, "sn_code", "") or ""
kks = getattr(plan, "kks_code", "") or ""
except Exception: # noqa: BLE001 - 查询失败不影响基础字段
pass
return {"sn_code": sn, "kks_code": kks}
[文档]
def individual_codes(
self, cert: Certificate, override: dict[str, Any] | None = None
) -> list[str]:
"""展开逐台编号(委托 ``ScanService._individual_codes``)。"""
return self._service._individual_codes(cert, override)
[文档]
def load_coordinates(self, template_type: str) -> dict[str, Any]:
"""加载模板坐标(委托 ``ScanService._load_coordinates``)。"""
return self._service._load_coordinates(template_type)
[文档]
def build_cert_data(
self,
cert: Certificate,
template_type: str,
serial_number: str | None = None,
overrides: dict[str, Any] | None = None,
dual_code: bool = False,
) -> dict[str, Any]:
"""构建单台合格证字段数据(委托 ``ScanService._build_cert_data``)。"""
return self._service._build_cert_data(
cert,
template_type,
serial_number=serial_number,
overrides=overrides,
dual_code=dual_code,
)
[文档]
def render_preview_png(
self,
cert: Certificate,
template_type: str,
width_mm: float,
height_mm: float,
serial_number: str | None = None,
overrides: dict[str, Any] | None = None,
dual_code: bool = False,
) -> bytes:
"""渲染扫描件预览并返回 PNG 字节流(框架无关,不含 PySide6)。
收口 ScanView 原先对 ``ScanService``(坐标/数据)与 ``ScanImageHandler``
(画布/绘制)的直连:预览 == 产出,统一经 Controller 管道。
"""
coords = self._service._load_coordinates(template_type)
bg = coords.get("background_path", "")
fields = coords.get("fields", {})
handler = self._service.image_handler
img = handler.create_canvas(width_mm, height_mm, bg)
data = self._service._build_cert_data(
cert,
template_type,
serial_number=serial_number,
overrides=overrides,
dual_code=dual_code,
)
for field_key, pos in fields.items():
value = data.get(field_key, "")
if value:
handler.draw_field(
img,
value,
pos.get("x", 0),
pos.get("y", 0),
pos.get("font_size", 9),
width_mm=pos.get("width"),
height_mm=pos.get("height"),
align=pos.get("align", "left"),
)
buf = io.BytesIO()
img.save(buf, "PNG")
return buf.getvalue()
[文档]
def load_certificates(self, certificate_ids: list[int]) -> list[Certificate]:
"""加载合格证记录
Args:
certificate_ids: 合格证 ID 列表
Returns:
合格证列表
"""
return self._service.load_certificates(certificate_ids)
[文档]
def generate_scans(
self,
certificate_ids: list[int],
output_dir: str,
template_type: str = "全中文",
output_format: str = "JPG",
rows: int = 1,
cols: int = 1,
paper_width_mm: float = 60,
paper_height_mm: float = 100,
data_overrides: dict[int, dict] | None = None,
dual_code: bool = False,
) -> int:
"""异步生成扫描件
Args:
certificate_ids: 合格证 ID 列表
output_dir: 输出目录
template_type: 模板类型 (全中文/中英文/俄英文)
output_format: 输出格式 (JPG/PDF)
rows: 每页行数
cols: 每页列数
paper_width_mm: 纸张宽度 (mm)
paper_height_mm: 纸张高度 (mm)
Returns:
任务 ID
Examples:
>>> controller = ScanController(session)
>>> from certflow.config.paths_override import cfg
>>> task_id = controller.generate_scans([1, 2, 3], cfg("paths.output.scans", "output/scans"), "全中文")
>>> print(task_id)
"""
# 取消当前正在执行的任务
self.cancel()
params = {
"certificate_ids": certificate_ids,
"output_dir": output_dir,
"template_type": template_type,
"output_format": output_format,
"rows": rows,
"cols": cols,
"paper_width_mm": paper_width_mm,
"paper_height_mm": paper_height_mm,
"data_overrides": data_overrides,
"dual_code": dual_code,
}
self._current_task_id += 1
task_id = self._current_task_id
self._worker = ScanWorker(self._service, params)
self._worker.progress.connect(self.progress)
self._worker.finished.connect(self._on_finished)
self._worker.error.connect(self.error)
self._worker.start()
logger.info(
f"开始生成扫描件 [任务 {task_id}]: "
f"{len(certificate_ids)} 份合格证, 模板={template_type}"
)
return task_id
[文档]
def cancel(self) -> None:
"""取消当前生成任务"""
if self._worker and self._worker.isRunning():
self._worker.cancel()
self._worker.quit()
self._worker.wait(3000) # 等待最多 3 秒
logger.info("扫描件生成任务已取消")
self._worker = None
def _on_finished(self, result: dict[str, Any]) -> None:
"""任务完成回调
Args:
result: 生成结果字典,含 success/failed/files/message 等键
"""
self.completed.emit(result)
self._worker = None
logger.info(f"扫描件生成完成: 成功 {result.get('success', 0)} 个文件")
[文档]
def is_running(self) -> bool:
"""检查是否有任务正在运行
Returns:
是否有任务正在运行
"""
return self._worker is not None and self._worker.isRunning()