# src/certflow/services/certificate_print_service.py
"""合格证打印服务(编排层)
编排完整的合格证打印业务流程,组合编号、打印引擎、日志等子模块。
拆分为:
- config/print_config.py: 配置加载(canonical,原 cert_print_config.py 已迁入)
- cert_numbering.py: 编号与 Certificate 写入
- cert_print_engine.py: GDI/HTML 打印引擎
- certificate_print_service.py (本文件): 编排层(参数补充、批量打印、日志、模板)
"""
from __future__ import annotations
import os
from collections.abc import Callable
from datetime import datetime
from typing import Any
from sqlalchemy.orm import Session
from certflow.config.print_config import get_empty_markers
from certflow.models.certificate import Certificate
from certflow.models.print_log import PrintLog
from certflow.services.cert_numbering import CertNumberingService
from certflow.services.cert_numbering_policy import get_number_model, is_auto_calc_dn_pn
from certflow.services.cert_print_engine import CertPrintEngine
from certflow.services.dn_service import DNService
from certflow.services.pn_service import PNService
from certflow.services.print_history_service import HistoryBackfillService
from certflow.utils.database import DatabaseManager
from certflow.utils.logger import logger
[文档]
class CertificatePrintService:
"""合格证打印服务(编排层)
组合编号、打印引擎、日志等子模块,提供完整的打印业务流程。
编号模式(委托 CertNumberingService):
- auto_number: 自动编号(正则解析 production_order_no / supply_type,最常用)
- manual_number: 手动指定前缀和起始序号
- copy_number: 复制已有编号(单件直接复制)
打印(委托 CertPrintEngine):
- print_single: 单台 GDI 精确打印
- batch_print: 批量展开编号 → 逐台打印 → 记录日志
- print_html_fallback: HTML 降级打印
持久化(委托 CertNumberingService):
- create_certificates: 将已编号的 SalePlan 写入 Certificate 表
- export_to_access: 导出到 Access 数据库
- export_sign_tb_csv: 导出 CSV
日志与历史:
- record_print_log: 记录打印日志到 SQLite print_logs 表
- get_print_history: 查询打印历史
Attributes:
session: SQLAlchemy 数据库会话对象
output_dir: 输出目录路径
printer_name: 默认打印机名称
Examples:
>>> service = CertificatePrintService(session)
>>> # 自动编号
>>> count = service.auto_number([1, 2, 3], prefix_override="2506")
>>> # 写入 Certificate
>>> cert_count = service.create_certificates([1, 2, 3])
>>> # 逐台打印
>>> service.batch_print(certificate_id=1, printer_name="EPSON LQ-635KII")
"""
def __init__(
self,
session: Session,
output_dir: str = "output/certificates",
printer_name: str = "",
) -> None:
"""初始化合格证打印服务
Args:
session: SQLAlchemy 数据库会话对象
output_dir: 输出目录路径,默认为 "output/certificates"
printer_name: 默认打印机名称,为空则使用系统默认打印机
"""
self.session = session
self.output_dir = output_dir
self.printer_name = printer_name
self._numbering = CertNumberingService(session)
self._engine = CertPrintEngine(printer_name)
os.makedirs(output_dir, exist_ok=True)
# ============================================================
# 编号模式(委托 CertNumberingService)
# ============================================================
[文档]
def auto_number(self, ids: list[int], prefix_override: str | None = None) -> int:
"""自动编号(委托 CertNumberingService)
通过正则解析 production_order_no / supply_type 自动生成前缀并编号。
Args:
ids: SalePlan.id 列表
prefix_override: 自定义前缀覆盖(如 "2506"),None 则按规则推导
Returns:
int: 成功编号的记录数
"""
return self._numbering.auto_number(ids, prefix_override)
[文档]
def manual_number(
self, ids: list[int], prefix: str, start: int, quantity: int, suffix: str = ""
) -> int:
"""手动编号(委托 CertNumberingService)
Args:
ids: SalePlan.id 列表
prefix: 编号前缀
start: 起始序号
quantity: 每台数量
suffix: 编号后缀
Returns:
int: 成功编号的记录数
"""
return self._numbering.manual_number(ids, prefix, start, quantity, suffix)
[文档]
def copy_number(self, ids: list[int], source_code: str) -> int:
"""复制编号(委托 CertNumberingService)
将已有编号直接复制到目标记录(单件直接复制)。
Args:
ids: SalePlan.id 列表
source_code: 源编号(作为复制模板)
Returns:
int: 成功编号的记录数
"""
return self._numbering.copy_number(ids, source_code)
# ============================================================
# Certificate 写入与导出(委托 CertNumberingService)
# ============================================================
[文档]
def create_certificates(
self, ids: list[int], printer_name: str = "", print_status: str = "已打印"
) -> int:
"""将已编号的记录写入 Certificate 表(委托 CertNumberingService)
Args:
ids: SalePlan.id 列表
printer_name: 写入的打印机名称
print_status: 写入的打印状态,默认 "已打印"
Returns:
int: 成功创建的 Certificate 记录数
"""
return self._numbering.create_certificates(ids, printer_name, print_status)
[文档]
def export_to_access(self, ids: list[int], mdb_path: str | None = None) -> int:
"""导出到 Access 数据库(委托 CertNumberingService)
Args:
ids: Certificate.id 列表
mdb_path: 目标 .mdb 文件路径,None 则使用默认路径
Returns:
int: 成功导出的记录数
"""
return self._numbering.export_to_access(ids, mdb_path)
[文档]
def export_sign_tb_csv(self, output_path: str | None = None, limit: int = 1000) -> str | None:
"""导出 CSV(委托 CertNumberingService)
Args:
output_path: 输出 CSV 文件路径,None 则使用默认路径
limit: 最大导出条数
Returns:
str | None: 生成的 CSV 文件路径;失败返回 None
"""
return self._numbering.export_sign_tb_csv(output_path, limit)
# ============================================================
# 生成合格证(统一「打印合格证」入口,委托 CertNumberingService 数据访问)
# ============================================================
[文档]
def generate_print_certificates(
self,
ids: list[int],
prefix_override: str | None = None,
print_status: str = "待打印",
) -> tuple[int, int]:
"""为选中的销售计划生成合格证(去重安全)。
跳过已生成 Certificate 的计划(避免证书重复生成与唯一约束冲突),
对剩余计划自动编号并写入 Certificate 表。编号与写库均委托
``CertNumberingService``(数据访问层),本方法仅做编排与去重判定。
Args:
ids: 销售计划 ID 列表
prefix_override: 编号前缀覆盖(None 则按规则推导)
print_status: 写入的打印状态
Returns:
tuple[int, int]: (编号数, 合格证生成数)
"""
from certflow.models import Certificate
if not ids:
return 0, 0
# 去重:跳过已生成合格证的计划
existing = {
c.sale_plan_id
for c in self.session.query(Certificate.sale_plan_id)
.filter(Certificate.sale_plan_id.in_(ids))
.all()
}
new_ids = [i for i in ids if i not in existing]
if not new_ids:
logger.info("选中记录均已生成合格证,无需重复生成")
return 0, 0
numbered = self._numbering.auto_number(new_ids, prefix_override)
cert_count = self._numbering.create_certificates(new_ids, self.printer_name, print_status)
logger.info(f"生成合格证完成 | 编号={numbered} | 证书={cert_count}")
return numbered, cert_count
# ============================================================
# 打印(委托 CertPrintEngine)
# ============================================================
[文档]
def print_single(
self,
data: dict[str, Any],
serial_number: str,
printer_name: str | None = None,
print_fields: list[str] | None = None,
) -> bool:
"""打印单台合格证(委托 CertPrintEngine)
Args:
data: 打印数据字典(含 product_name、dn、pn 等字段)
serial_number: 合格证编号(SN)
printer_name: 打印机名称,None 则使用服务默认打印机
print_fields: 需要打印的字段名列表,None 表示打印全部字段
Returns:
bool: 是否打印成功
"""
return self._engine.print_single(data, serial_number, printer_name, print_fields)
@property
def last_os_job_id(self) -> int | None:
"""最近一次打印提交的 OS 作业 ID(委托 CertPrintEngine 捕获)。
偏差 5 作业 ID 捕获链路末端:队列的 ``_default_print_func`` 经此属性
拿到 spooler 作业 ID,写入 ``PrintJob.os_job_id``,供 OS 级取消使用。
"""
return self._engine.last_os_job_id
[文档]
def print_html_fallback(self, data: dict[str, Any], serial_number: str) -> str | None:
"""HTML 降级打印(委托 CertPrintEngine)
Args:
data: 打印数据字典
serial_number: 合格证编号(SN)
Returns:
str | None: 生成的 HTML 文件路径;失败返回 None
"""
return self._engine.print_html_fallback(data, serial_number)
# ============================================================
# 参数补充(核心编排逻辑)
# ============================================================
[文档]
def check_and_supplement_params(
self,
cert: Certificate,
parent: object | None = None,
supplement_callback: Callable | None = None,
) -> dict[str, Any]:
"""检查 Certificate 参数完整性,必要时弹出参数补充对话框
对标 VBA print.vb 中一连串的 InputBox 调用:
- 阀体材质 InputBox(dd.vb:1066)
- 阀杆材质 InputBox(dd.vb:1074)
- 启闭件材质 InputBox(dd.vb:1082)
- 检验工号 InputBox(dd.vb:894-906)
- 出厂年月修正 InputBox(dd.vb:986,1009)
Args:
cert: Certificate ORM 对象
parent: 对话框父级窗口,服务层不感知其具体类型,仅原样透传给
supplement_callback;由 Controller/View 层传入 QWidget
supplement_callback: 可选回调 (model, prefill, material_grades, parent)
→ (confirmed, skip_all, params),由 Controller/View 层注入
"""
result: dict[str, Any] = {
"supplemented": False,
"skipped": False,
"params": {},
}
dn_service = DNService(self.session)
pn_service = PNService(self.session)
model = cert.product_model or ""
prefill = self._build_prefill(cert, model, dn_service)
# 判断是否需要弹出对话框
missing_params = self._detect_missing_params(cert, prefill)
if not missing_params:
logger.info(f"Certificate {cert.certificate_no} 参数完整,跳过补充对话框")
return result
logger.info(
f"Certificate {cert.certificate_no} 缺少参数: "
f"{', '.join(missing_params)},弹出补充对话框"
)
# 弹出参数补充对话框(通过回调委托给上层处理 UI)
try:
material_grades = self._load_material_grades()
if supplement_callback is not None:
confirmed, skip_all, params = supplement_callback(
model=model,
prefill=prefill,
material_grades=material_grades,
parent=parent,
)
else:
logger.warning("无参数补充回调,跳过对话框")
return result
if not confirmed:
return result
result["supplemented"] = True
result["skipped"] = skip_all
result["params"] = params
if not skip_all:
self._apply_supplemented_params(cert, params, dn_service, pn_service)
except Exception as e:
logger.error(f"参数补充对话框异常: {e}")
return result
@staticmethod
def _build_prefill(
cert: Certificate,
model: str,
dn_service: DNService,
) -> dict[str, str]:
"""构建参数预填字典(包含材质、检验工号、出厂年月)
Args:
cert: Certificate ORM 对象
model: 产品型号
dn_service: DN 服务实例
Returns:
dict[str, str]: 预填字典,键含 caliber/pressure/temperature/medium 等
"""
dn_value = ""
if cert.product_spec:
if is_auto_calc_dn_pn():
# 开启自动计算(默认):从口径串解析标准化口径值
dn_result = dn_service.resolve_caliber(cert.product_spec, model=model)
dn_value = dn_result.get("caliber_value") or cert.product_spec
else:
# 关闭自动计算(§5.7.2-E / 维度8):仅用已存原始口径,不自动推算
dn_value = cert.product_spec
pn_value = ""
if cert.pn_value:
pn_value = cert.pn_display or cert.pn_value
return {
"caliber": dn_value,
"pressure": pn_value,
"temperature": cert.working_temp or "",
"medium": cert.working_medium or "",
"test_standard": cert.test_standard or "",
"inspector_id": getattr(cert, "inspector_id", "") or "",
"body_material": getattr(cert, "fati_material", "") or "",
"stem_material": getattr(cert, "fagan_material", "") or "",
"disc_material": getattr(cert, "qibijian_material", "") or "",
"manufacture_date": getattr(cert, "issue_date", "") or "",
}
@staticmethod
def _detect_missing_params(cert: Certificate, prefill: dict[str, str]) -> list[str]:
"""检测缺失的参数列表
Args:
cert: Certificate ORM 对象
prefill: 由 _build_prefill 生成的预填字典
Returns:
list[str]: 缺失参数中文名列表(如 ["口径(DN)", "压力(PN)"])
"""
missing: list[str] = []
# 关闭自动计算口径压力时(§5.7.2-E / 维度8),不就口径/压力缺失弹补充对话框
if is_auto_calc_dn_pn():
if not prefill.get("caliber"):
missing.append("口径(DN)")
if not prefill.get("pressure"):
missing.append("压力(PN)")
if not cert.working_temp:
missing.append("温度")
if not cert.working_medium:
missing.append("介质")
return missing
def _apply_dn_param(
self, cert: Certificate, params: dict[str, str], dn_service: DNService
) -> None:
"""应用 DN/口径 参数
Args:
cert: Certificate ORM 对象
params: 用户填写的参数(含 caliber 键)
dn_service: DN 服务实例
"""
model = cert.product_model or ""
if not params.get("caliber"):
return
dn_result = dn_service.resolve_caliber(params["caliber"], model=model)
cert.product_spec = dn_result.get("caliber_value") or params["caliber"]
if dn_result.get("needs_manual"):
dn_service.auto_learn(
raw_text=params["caliber"],
caliber_value=cert.product_spec,
certificate_no=cert.certificate_no,
)
def _apply_pn_param(
self, cert: Certificate, params: dict[str, str], pn_service: PNService
) -> None:
"""应用 PN/压力 参数
Args:
cert: Certificate ORM 对象
params: 用户填写的参数(含 pressure 键)
pn_service: PN 服务实例
"""
model = cert.product_model or ""
if not params.get("pressure"):
return
pn_info = pn_service.resolve_pn(model=model, template_pn=params["pressure"])
if pn_info.get("pn_value"):
cert.pn_value = pn_info["pn_value"]
cert.pn_unit = pn_info["pn_unit"]
cert.pn_display = pn_info["pn_display"]
pn_service.auto_learn(
model=model,
pn_value=pn_info["pn_value"],
pn_unit=pn_info["pn_unit"],
test_standard=params.get("test_standard", ""),
certificate_no=cert.certificate_no,
)
def _apply_basic_params(self, cert: Certificate, params: dict[str, str]) -> None:
"""应用温度、介质、试压标准、材质、工号、出厂年月等基础参数
Args:
cert: Certificate ORM 对象
params: 用户填写的参数(含 temperature/medium 等键)
"""
if params.get("temperature"):
cert.working_temp = params["temperature"]
if params.get("medium"):
cert.working_medium = params["medium"]
if params.get("test_standard"):
cert.test_standard = params["test_standard"]
# === v9 新增:材质信息(对齐VBA InputBox)===
if params.get("body_material"):
cert.fati_material = params["body_material"]
if params.get("stem_material"):
cert.fagan_material = params["stem_material"]
if params.get("disc_material"):
cert.qibijian_material = params["disc_material"]
# === v9 新增:检验工号(对齐VBA dd.vb:894-906)===
if params.get("inspector_id") and hasattr(cert, "inspector_id"):
cert.inspector_id = params["inspector_id"]
# === v9 新增:出厂年月修正(对齐VBA dd.vb:986,1009)===
if params.get("manufacture_date"):
cert.issue_date = params["manufacture_date"]
def _apply_supplemented_params(
self,
cert: Certificate,
params: dict[str, str],
dn_service: DNService,
pn_service: PNService,
) -> None:
"""将补充的参数应用到 Certificate 对象
Args:
cert: Certificate 对象
params: 用户填写的参数(含材质、检验工号、出厂年月)
dn_service: DN 服务实例
pn_service: PN 服务实例
"""
self._apply_dn_param(cert, params, dn_service)
self._apply_pn_param(cert, params, pn_service)
self._apply_basic_params(cert, params)
# 提交
self.session.commit()
logger.info(
f"参数已补充: cert_id={cert.id}, 口径={params.get('caliber', '')}, "
f"压力={params.get('pressure', '')}, 温度={params.get('temperature', '')}, "
f"介质={params.get('medium', '')}, "
f"阀体材质={params.get('body_material', '')}, "
f"阀杆材质={params.get('stem_material', '')}, "
f"启闭件材质={params.get('disc_material', '')}, "
f"工号={params.get('inspector_id', '')}"
)
# 材质牌号已迁移到 SQLite material_grades 表(见 MaterialGrade 模型与 MaterialGradeService)。
# _load_material_grades 优先按阀门零件类别从数据库分组读取;表为空或异常时回退硬编码,
# 保证打印参数补充对话框不降级。
def _load_material_grades(self) -> dict[str, list[str]]:
"""从 SQLite material_grades 表加载材质牌号列表(按阀门零件类别分组)
优先读取数据库;若表为空或读取失败,回退到内置硬编码字典,保证对话框始终可用。
Returns:
dict: {分类: [牌号列表]},如 {"body": ["WCB", "CF8"], "stem": ["2Cr13"], ...}
"""
try:
from certflow.services.material_grade_service import MaterialGradeService
grades = MaterialGradeService(self.session).get_grades_by_category()
if grades:
return grades
logger.info("material_grades 表为空,回退硬编码材质牌号")
except Exception as e:
logger.warning(f"从数据库读取材质牌号失败,回退硬编码: {e}")
return self._hardcoded_material_grades()
@staticmethod
def _hardcoded_material_grades() -> dict[str, list[str]]:
"""内置硬编码材质牌号(数据库为空时的兜底)
Returns:
dict[str, list[str]]: {分类: [牌号列表]},如 {"body": ["WCB"], ...}
"""
# 预设常用阀门材质牌号
return {
"body": [
"WCB",
"LCB",
"LCC",
"WC6",
"WC9",
"CF8",
"CF8M",
"CF3",
"CF3M",
"HT200",
"HT250",
"QT450-10",
],
"stem": [
"2Cr13",
"3Cr13",
"304",
"316",
"17-4PH",
"Monel",
"F51",
"F53",
],
"disc": [
"2Cr13",
"3Cr13",
"304",
"316",
"WCB",
"CF8",
"CF8M",
"F51",
],
}
# ============================================================
# 批量打印 & 日志记录
# ============================================================
[文档]
def batch_print(
self,
certificate_id: int,
printer_name: str | None = None,
progress_callback: Callable[[int, int], None] | None = None,
skip_supplement: bool = False,
parent: object | None = None,
supplement_callback: Callable | None = None,
) -> dict[str, Any]:
"""批量打印:参数补充 → 解析编号 → 展开逐台编号 → 打印 → 记录日志
对标 VBA print.vb 批量打印流程:
1. 检查参数完整性,缺失则弹出补充对话框(对标 InputBox)
2. 解析 Certificate 编号范围
3. 展开为逐台编号后依次打印
4. 每台记录一条 print_log
Args:
certificate_id: Certificate.id
printer_name: 打印机名称
progress_callback: 进度回调 fn(current, total)
skip_supplement: 跳过参数补充对话框(批量自动化时使用)
parent: 父窗口(用于对话框模态),服务层不感知其具体类型,原样透传
supplement_callback: 参数补充对话框回调
(model, prefill, material_grades, parent) → (confirmed, skip_all, params)
Returns:
{"success": int, "failed": int, "serials": [...], "message": str}
"""
cert = self.session.query(Certificate).filter(Certificate.id == certificate_id).first()
if not cert:
return {"success": 0, "failed": 0, "serials": [], "message": "Certificate 不存在"}
# === 参数补充(对标 VBA InputBox)===
if not skip_supplement:
supp_result = self.check_and_supplement_params(
cert, parent=parent, supplement_callback=supplement_callback
)
if not supp_result.get("supplemented", False) and not supp_result.get("skipped", False):
return {"success": 0, "failed": 0, "serials": [], "message": "用户取消打印"}
self.session.refresh(cert)
# 展开编号列表
serials = cert.code_list
if not serials:
product_code = cert.product_code_range or cert.certificate_no or ""
serials = [product_code]
if not serials:
return {"success": 0, "failed": 0, "serials": [], "message": "无法展开编号"}
# 构建打印数据
print_data = self._build_print_data(cert)
printer = printer_name or self.printer_name
total = len(serials)
success_count = 0
failed_count = 0
logger.info(
f"开始批量打印 cert_id={certificate_id}, 编号范围={cert.certificate_no}, 共 {total} 台"
)
for i, sn in enumerate(serials):
# 逐台打印
ok = self.print_single(print_data, sn, printer)
if not ok:
self.print_html_fallback(print_data, sn)
# 记录打印日志到 SQLite(51个字段完全兼容 Access SignTb)
self.record_print_log(
certificate_id=certificate_id,
certificate_no=sn,
cert_data={
# Br(1) ~ Br(9): 基础信息
"plan_date": cert.plan_date,
"customer": cert.customer,
"project_name": cert.project_name,
"product_name": cert.product_name,
"product_model": cert.product_model,
"dn": cert.product_spec,
"pn": cert.pn_display or cert.pn_value or "",
"medium": cert.working_medium,
"temperature": cert.working_temp,
# Br(10) ~ Br(11): 出厂日期年月
"manufacture_year": int(cert.product_code_ym[:4])
if cert.product_code_ym and len(cert.product_code_ym) >= 4
else 0,
"manufacture_month": int(cert.product_code_ym[4:6])
if cert.product_code_ym and len(cert.product_code_ym) >= 6
else 0,
# Br(25) ~ Br(27): 合格证版型/图片/编码模式
"template_style": 1,
"pic_style": 0,
# 步骤 6.2:编码模式由 certificate.numbering.modes 配置判定(默认 0)
"number_model": get_number_model(cert),
# Br(28): 合格证模板名称
"template_name": cert.product_name or "",
# Br(32): 铭牌路径(model_full_name)
"model_full_name": print_data.get("model_full_name", ""),
# SN/KKS 位置标记(落库值,由编号模式规则确定;驱动双编码互换)
"sn_kks_position": getattr(cert, "sn_kks_position", "") or "",
# Br(35) ~ Br(45): 材质/技术要求/销售信息
"body_material": "",
"stem_material": "",
"disc_material": "",
"tech_requirements": cert.tech_requirements,
"equipment_no": "",
"sub_project": "",
"sales_sn": "",
"category": "",
"weight": "",
"seq_no": "",
"requisition_no": "",
# Br(49): 打印模式
"print_mode": "1",
# Br(50) ~ Br(51): 水头
"max_inlet_head": "",
"max_boost_head": "",
# hgz_ShuLiang
"copies": 1,
},
printer_name=printer,
status="success" if ok else "failed",
error_message="" if ok else "GDI 打印失败,已降级为 HTML 打印",
content_summary=self._build_content_summary(print_data, sn),
)
if ok:
success_count += 1
else:
failed_count += 1
if progress_callback:
progress_callback(i + 1, total)
# 更新 Certificate 打印状态
cert.print_status = "已打印"
cert.print_time = datetime.now()
cert.printer_name = printer
# G3: 若本 SalePlan 全部台数已打印,回填 sn_code/kks_code 到 SalePlan
from certflow.services.cert_log_service import backfill_dual_codes_if_fully_printed
backfill_dual_codes_if_fully_printed(self.session, certificate_id)
DatabaseManager.commit_with_retry(self.session)
msg = f"打印完成: 成功{success_count}台, 失败{failed_count}台"
logger.info(msg)
return {
"success": success_count,
"failed": failed_count,
"serials": serials,
"message": msg,
}
def _build_print_data(self, cert: Certificate) -> dict[str, Any]:
"""从 Certificate ORM 对象构建打印数据字典
DN 字段对标 VBA print.vb 第681行:Replace(str_kjA, "DN", "")
PN 字段根据模板类型生成合适的显示文本:
- 全中文模板:纯数值(底图已印 MPa)
- 中英文/俄英文模板:数值+单位
v9 新增:英制处理(LB判断/等级提取/C5C6回退)
Args:
cert: Certificate ORM 对象
Returns:
dict[str, Any]: 打印数据字典(含 product_name、dn、pn、temperature 等)
"""
# === DN 标准化 ===
dn_display = ""
if cert.product_spec:
dn_service = DNService(self.session)
dn_result = dn_service.resolve_caliber(cert.product_spec)
dn_display = dn_result.get("caliber_value") or cert.product_spec
else:
dn_display = ""
# === PN 显示文本 ===
pn_display = ""
pn_service = PNService(self.session)
model = cert.product_model or ""
if cert.pn_value:
template_name = getattr(cert, "template_type", "") or "全中文"
template_type = "全中文"
if "中英文" in template_name or "俄英文" in template_name:
template_type = "中英文"
elif "全英文" in template_name:
template_type = "全英文"
pn_display = pn_service.get_display_text(
pn_value=cert.pn_value,
pn_unit=cert.pn_unit or "",
template_type=template_type,
)
elif cert.pn_display:
pn_display = cert.pn_display
# === 英制处理(对齐VBA dd.vb:723-755, 841-857)===
is_imperial = pn_service.is_imperial(model)
lb_level = pn_service.extract_lb_level(model) if is_imperial else None
imperial_dn = "" # C5: 英制口径备用列
imperial_pn = "" # C6: 英制压力备用列
if is_imperial and lb_level:
imperial_pn = f"{lb_level}Lb"
if cert.product_spec:
imperial_dn = cert.product_spec
return {
"product_name": cert.product_name or "",
"product_model": model,
"dn": dn_display,
"pn": pn_display,
"temperature": cert.working_temp or "",
"medium": cert.working_medium or "",
"check_standard": cert.test_standard or "",
# H3/实物缺工号根因:此前硬编码为 "",导致队列实际打印永远丢检验工号;
# 改为读取 cert.inspector_id(建证时按语言族烘焙默认检5/No5,订正/下拉亦可覆盖)。
"inspector_id": cert.inspector_id or "",
"manufacture_date": cert.issue_date or datetime.now().strftime("%Y.%m"),
"serial_number": cert.certificate_no or "",
"customer": cert.customer or "",
"project_name": cert.project_name or "",
"template_name": getattr(cert, "template_name", "") or "全中文合格证模板",
"template_type": getattr(cert, "template_type", "") or "全中文",
# 英制信息(用于C5/C6备用列)
"is_imperial": is_imperial,
"lb_level": lb_level,
"imperial_dn": imperial_dn,
"imperial_pn": imperial_pn,
}
@staticmethod
def _build_content_summary(data: dict[str, Any], serial_number: str) -> str:
"""构建打印内容摘要
Args:
data: 打印数据字典
serial_number: 合格证编号(SN)
Returns:
str: 以分号连接的内容摘要字符串
"""
parts = [
f"编号={serial_number}",
f"产品={data.get('product_name', '')}",
f"型号={data.get('product_model', '')}",
f"DN={data.get('dn', '')}",
f"介质={data.get('medium', '')}",
f"温度={data.get('temperature', '')}",
]
return "; ".join(p for p in parts if "=" not in p[-2:])
# ============================================================
# 打印日志记录
# ============================================================
@staticmethod
def _derive_sn_and_kks(
full_code: str, number_model: int = 0, ar28: str = ""
) -> tuple[str, str]:
"""根据 VBA 逻辑从完整编码推导 SN(短编号)和 KKS(长编码)
对应 VBA print.vb 中「将打印内容添加到Access数据库」的赋值逻辑:
- 默认模式: SN (Br(12)) = 去掉中间4位年月,保留 V+流水号+后缀
- 默认模式: KKS (Br(13)) = 完整编码(含年月)
- 编码模式5/6: SN 和 KKS 根据 ar(2,8) 位置互换
- KKS 截断到12位(VBA Br(13) 逻辑)
Args:
full_code: 完整编码,如 "V2604520Y" 或 "V2604123"
number_model: 编码模式 (0=默认, 4=电建三KKS, 5=电建三双编码, 6=合肥水泥双编码)
ar28: ar(2,8) 的值,指示 "B12/B13 哪个是KKS哪个是SN"
Returns:
(SN, KKS) 元组,SN 为短编号,KKS 为完整编码(截断到12位)
"""
if not full_code or not full_code.startswith("V"):
return full_code or "", full_code or ""
# V + 4位年月 + 流水号 + 后缀 = V2604520Y
# SN = V + 流水号 + 后缀(去掉中间4位年月)
sn = full_code[0] + full_code[5:] if len(full_code) > 5 else full_code
kks = full_code
# === 编码模式5/6: SN/KKS 互换(对齐VBA dd.vb:2205-2239)===
if number_model in (5, 6):
if ar28 == "B12单元格为KKS编码,B13单元格为产品编号":
sn, kks = kks, sn
elif ar28 == "B12单元格为产品编号,B13单元格为KKS编码":
pass
# === KKS 截断到12位(对齐VBA dd.vb:2234,2238)===
if len(kks) > 12:
kks = kks[:12]
return sn, kks
@staticmethod
def _printer_name_to_code(printer_name: str) -> int:
"""打印机名称 → 整数代码映射(对齐 VBA dd.vb:2266-2284)
铭牌刻印系统按整数代码解析打印机类型。
Args:
printer_name: 打印机名称字符串
Returns:
int: 打印机代码 (0=未知, 1=Foxit PDF, 2=EPSON针打, 3=网络打印机,
4=HP LaserJet生产, 5=Microsoft XPS, 6=DocuCom PDF, 7=HP LaserJet销售)
"""
if not printer_name:
return 0
mapping = [
("Foxit Reader PDF Printer", 1),
("EPSON LQ-635KII", 2),
("NPI15539C", 3),
("HP LaserJet Pro MFP M225-M226", 4),
("Microsoft XPS Document Writer", 5),
("DocuCom PDF Driver", 6),
]
for keyword, code in mapping:
if keyword in printer_name:
return code
if "HP LaserJet" in printer_name and "M225-M226" in printer_name:
return 4
return 0
@staticmethod
def _clean_temperature(temp: str) -> str:
"""清理温度字符串中的 ≤/℃ 符号(对齐VBA dd.vb:2194-2196)"""
if not temp:
return ""
return temp.replace(" ", "").replace("≤", "").replace("℃", "")
@staticmethod
def _clean_pn_for_access(pn: str, is_imperial: bool = False) -> str:
"""清理 PN 字符串中的单位后缀(对齐VBA dd.vb:2192-2193)"""
if not pn:
return ""
pn = pn.strip()
upper = pn.upper()
if upper.endswith("MPA"):
pn = pn[:-3].strip()
if is_imperial and not upper.endswith("LB"):
pn = f"{pn}Lb"
return pn
[文档]
def record_print_log(
self,
certificate_id: int,
certificate_no: str,
cert_data: dict[str, Any] | None = None,
printer_name: str = "",
status: str = "success",
error_message: str = "",
content_summary: str = "",
) -> PrintLog | None:
"""记录打印日志到 SQLite print_logs 表(字段名与 Access SignTb 兼容)
每次打印操作(单台或批量中的每台)都写入一条记录。
字段命名与 Access SignTb 保持一致,方便铭牌刻印系统直接读取。
SN 和 KKS 赋值遵循 VBA 逻辑:
- SN = 短编号(去掉中间4位年月),如 V520Y
- KKS = 完整编码(含年月),如 V2604520Y
- 编码模式5/6时 SN/KKS 互换
- KKS 截断到12位
字段清理对齐VBA(温度去≤/℃,PN去MPA后缀等)。
Args:
certificate_id: 关联合格证批次ID
certificate_no: 合格证完整编号(如 V2604520Y)
cert_data: 证书完整数据字典
printer_name: 打印机名称
status: 打印状态 (success/failed)
error_message: 错误信息
content_summary: 打印内容摘要
Returns:
PrintLog 对象或 None
"""
try:
cert_data = cert_data or {}
copies = cert_data.get("copies", 1)
# 对齐VBA: 空值填入默认标记(从配置读取)
markers = get_empty_markers()
for key, marker in markers.items():
if not cert_data.get(key):
cert_data[key] = marker
# 按 VBA 逻辑推导 SN(短编号)和 KKS(完整编码)
number_model = cert_data.get("number_model", 0)
sn_kks_position = cert_data.get("sn_kks_position", "") or ""
sn, kks = self._derive_sn_and_kks(certificate_no, number_model, sn_kks_position)
# === 字段清理(对齐VBA dd.vb:2191-2200)===
clean_temp = self._clean_temperature(cert_data.get("temperature", ""))
model = cert_data.get("product_model", "")
is_imperial = PNService.is_imperial(model)
clean_pn = self._clean_pn_for_access(cert_data.get("pn", ""), is_imperial)
dn_raw = cert_data.get("dn", "")
if dn_raw.upper().endswith("MM"):
dn_raw = dn_raw[:-2].strip()
printer_code = self._printer_name_to_code(printer_name)
plan_date = cert_data.get("plan_date", "")
if isinstance(plan_date, str) and plan_date:
try:
plan_date = datetime.strptime(plan_date, "%Y-%m-%d")
except ValueError:
logger.warning(f"无法解析计划日期 [{plan_date}],使用当前时间")
plan_date = datetime.now()
elif not plan_date:
plan_date = None
sign_model_full_name = cert_data.get("model_full_name", "")
if not sign_model_full_name:
try:
dup_count = self.session.query(PrintLog).filter(sn == PrintLog.SN).count()
sign_model_full_name = f"重复条目数:{dup_count + 1:02d}"
except Exception:
pass
fields = {
# Br(1) ~ Br(13): 基础信息
"JiHuaRiQi": plan_date or datetime.now(),
"DingHuoDanWei": cert_data["customer"],
"XiangMuMingCheng": cert_data["project_name"],
"ChanPinMingCheng": cert_data["product_name"],
"ChanPinXingHao": cert_data["product_model"],
"DN": dn_raw,
"PN": clean_pn,
"ShiYongJieZhi": cert_data["medium"],
"ShiYongWenDu": clean_temp,
"ChuChangRiQiYear": cert_data.get("manufacture_year", 0),
"ChuChangRiQiMonth": cert_data.get("manufacture_month", 0),
"SN": sn,
"KKS": kks,
# Br(14) ~ Br(23): 刻印标记
# Br(24) ~ Br(33): 合格证/铭牌打印信息
"hgz_ShuLiang": copies,
"hgz_YangShi": cert_data.get("template_style", 0),
"hgz_PdfPic": cert_data.get("pic_style", 0),
"hgz_BianMaModel": number_model,
"hgz_xlMuBanName": cert_data.get("template_name") or cert_data.get("template", ""),
"hgz_PrintTime": datetime.now(),
"PrinterName": str(printer_code),
"Sign_PrintCount": 0,
"Sign_ModelFullName": sign_model_full_name,
# Br(35) ~ Br(45): 材质/技术要求/销售信息
"FaTiCaiZhi": cert_data["body_material"],
"FaGanCaiZhi": cert_data["stem_material"],
"QiBiJianCaiZhi": cert_data["disc_material"],
"JiShuYaoQiu": cert_data["tech_requirements"],
"SheBeiWeiHao": cert_data["equipment_no"],
"ZiXiangMu": cert_data["sub_project"],
"SNFromXSB": cert_data["sales_sn"],
"ZhongLei": cert_data["category"],
"ZhongLiang": cert_data["weight"],
"XuHao": cert_data["seq_no"],
"YaoHuoDanHaoFromXSB": cert_data["requisition_no"],
# Br(49) ~ Br(51): 打印模式/水头
"DcOrPlPrint": cert_data.get("print_mode", "1"),
"MaxJinShuiTou": cert_data["max_inlet_head"],
"MaxShengYaShuiTou": cert_data["max_boost_head"],
# 扩展字段
"status": status,
"error_message": error_message,
"content_summary": content_summary,
}
# 单一写点(§11.8 阶段2·B):保留本方法的 SN/KKS/清洗派生,统一经 record_after_generate 落库
return HistoryBackfillService(self.session).record_after_generate(
certificate_id=certificate_id,
fields=fields,
status=status,
error_message=error_message,
content_summary=content_summary,
)
except Exception as e:
logger.error(f"记录打印日志失败 [{certificate_no}]: {e}")
self.session.rollback()
return None
[文档]
def get_print_history(
self,
limit: int = 100,
status_filter: str | None = None,
) -> list[dict[str, Any]]:
"""查询打印历史(返回字段名与 Access SignTb 兼容)
Args:
limit: 最大返回条数
status_filter: 状态筛选 (success/failed),None 表示全部
Returns:
打印历史列表,每条记录包含 SignTb 兼容字段
"""
query = self.session.query(PrintLog).order_by(PrintLog.hgz_PrintTime.desc())
if status_filter:
query = query.filter(PrintLog.status == status_filter)
logs = query.limit(limit).all()
return [
{
# 主键和关联
"id": log.id,
"certificate_id": log.certificate_id,
# Br(1) ~ Br(13): 基础信息
"JiHuaRiQi": log.JiHuaRiQi.strftime("%Y-%m-%d") if log.JiHuaRiQi else "",
"DingHuoDanWei": log.DingHuoDanWei,
"XiangMuMingCheng": log.XiangMuMingCheng,
"ChanPinMingCheng": log.ChanPinMingCheng,
"ChanPinXingHao": log.ChanPinXingHao,
"DN": log.DN,
"PN": log.PN,
"ShiYongJieZhi": log.ShiYongJieZhi,
"ShiYongWenDu": log.ShiYongWenDu,
"ChuChangRiQiYear": log.ChuChangRiQiYear,
"ChuChangRiQiMonth": log.ChuChangRiQiMonth,
"SN": log.SN,
"KKS": log.KKS,
# Br(14) ~ Br(23): 刻印标记
"Sign_XingHao": log.Sign_XingHao,
"Sign_DN": log.Sign_DN,
"Sign_PN": log.Sign_PN,
"Sign_JieZhi": log.Sign_JieZhi,
"Sign_WenDu": log.Sign_WenDu,
"Sign_Year": log.Sign_Year,
"Sign_Month": log.Sign_Month,
"Sign_SN": log.Sign_SN,
"Sign_KKS": log.Sign_KKS,
"Sign_ALL": log.Sign_ALL,
# Br(24) ~ Br(33): 合格证/铭牌打印信息
"hgz_ShuLiang": log.hgz_ShuLiang,
"hgz_YangShi": log.hgz_YangShi,
"hgz_PdfPic": log.hgz_PdfPic,
"hgz_BianMaModel": log.hgz_BianMaModel,
"hgz_xlMuBanName": log.hgz_xlMuBanName,
"hgz_PrintTime": log.hgz_PrintTime.strftime("%Y-%m-%d %H:%M:%S")
if log.hgz_PrintTime
else "",
"PrinterName": log.PrinterName,
"Sign_PrintTime": log.Sign_PrintTime.strftime("%Y-%m-%d %H:%M:%S")
if log.Sign_PrintTime
else "",
"Sign_ModelFullName": log.Sign_ModelFullName,
"Sign_PrintCount": log.Sign_PrintCount,
# Br(35) ~ Br(45): 材质/技术要求/销售信息
"FaTiCaiZhi": log.FaTiCaiZhi,
"FaGanCaiZhi": log.FaGanCaiZhi,
"QiBiJianCaiZhi": log.QiBiJianCaiZhi,
"JiShuYaoQiu": log.JiShuYaoQiu,
"SheBeiWeiHao": log.SheBeiWeiHao,
"ZiXiangMu": log.ZiXiangMu,
"SNFromXSB": log.SNFromXSB,
"ZhongLei": log.ZhongLei,
"ZhongLiang": log.ZhongLiang,
"XuHao": log.XuHao,
"YaoHuoDanHaoFromXSB": log.YaoHuoDanHaoFromXSB,
# Br(46) ~ Br(51): 刻印标记/打印模式/水头
"Sign_FTCZ": log.Sign_FTCZ,
"Sign_FGCZ": log.Sign_FGCZ,
"Sign_QBJCZ": log.Sign_QBJCZ,
"DcOrPlPrint": log.DcOrPlPrint,
"MaxJinShuiTou": log.MaxJinShuiTou,
"MaxShengYaShuiTou": log.MaxShengYaShuiTou,
# 扩展字段
"status": log.status,
"error_message": log.error_message,
"content_summary": log.content_summary,
}
for log in logs
]
# ============================================================
# 模板匹配 & 回填
# ============================================================
[文档]
def apply_template_to_certificate(self, cert_id: int, template: dict[str, Any]) -> bool:
"""将 templates.xlsx 中的模板参数应用到 Certificate 记录
Args:
cert_id: Certificate.id
template: 从 TemplateManager.query_by_model 返回的模板 dict
Returns:
bool: 是否成功
"""
cert = self.session.query(Certificate).filter(Certificate.id == cert_id).first()
if not cert:
logger.warning(f"Certificate 不存在: id={cert_id}")
return False
field_map = {
"product_name": "product_name",
"product_model": "product_model",
"dn": "product_spec",
"temperature": "working_temp",
"medium": "working_medium",
"check_standard": "test_standard",
"inspector_id": "inspector_id",
}
updated = 0
for tmpl_key, cert_key in field_map.items():
val = template.get(tmpl_key, "").strip()
if val and hasattr(cert, cert_key):
setattr(cert, cert_key, val)
updated += 1
# === PN 处理 ===
tmpl_pn = template.get("pn", "").strip()
if tmpl_pn:
pn_service = PNService(self.session)
model = cert.product_model or ""
pn_info = pn_service.resolve_pn(model=model, template_pn=tmpl_pn)
cert.pn_value = pn_info["pn_value"]
cert.pn_unit = pn_info["pn_unit"]
cert.pn_display = pn_info["pn_display"]
updated += 1
logger.info(
f"PN 解析: 型号={model}, 模板PN={tmpl_pn} → "
f"value={cert.pn_value}, unit={cert.pn_unit}, display={cert.pn_display}"
)
if template.get("manufacture_date", "").strip():
cert.issue_date = template["manufacture_date"].strip()
self.session.commit()
logger.info(f"应用模板到 cert_id={cert_id}: {updated} 个字段")
return True
[文档]
def writeback_template_to_xlsx(self, cert_id: int, xlsx_path: str) -> bool:
"""打印完成后回填到 templates.xlsx「数据库」表
Args:
cert_id: Certificate.id
xlsx_path: templates.xlsx 文件路径
Returns:
bool: 是否成功
"""
from certflow.handlers.template_handler import TemplateManager
cert = self.session.query(Certificate).filter(Certificate.id == cert_id).first()
if not cert:
return False
tm = TemplateManager(templates_xlsx_path=xlsx_path)
return tm.writeback_to_xlsx(
{
"product_name": cert.product_name,
"product_model": cert.product_model,
"product_spec": cert.product_spec,
"dn": cert.product_spec,
"pn": cert.pn_display or cert.pn_value or "",
"working_temp": cert.working_temp,
"working_medium": cert.working_medium,
"test_standard": cert.test_standard,
"inspector_id": getattr(cert, "inspector_id", ""),
"manufacture_date": cert.issue_date,
"certificate_no": cert.certificate_no,
},
xlsx_path,
)