certflow.services.cert_log_service 源代码

"""合格证打印日志服务

从 PrintView 迁移出来,负责打印后数据持久化:
- PrintLog 51 字段构建与写入
- Certificate 打印状态更新与回填
- SN/KKS 推导、年月提取等数据清理

对标 VBA: 将打印内容添加到数据库() + 取消自动筛选后静默保存当前工作簿
"""

from __future__ import annotations

import re
from contextlib import suppress
from datetime import datetime
from typing import TYPE_CHECKING, Any

from sqlalchemy.orm import Session

from certflow.services.print_history_service import HistoryBackfillService
from certflow.utils.database import DatabaseManager
from certflow.utils.logger import logger

if TYPE_CHECKING:
    from certflow.models.print_log import PrintLog


def _cfg_get(key: str, default: Any) -> Any:
    """读取配置(隔离导入失败场景,配置不可用时回退默认)。"""
    try:
        from certflow.config.settings import cfg
    except ImportError:
        return default
    return cfg(key, default)


def _resolve_duplicate_config() -> tuple[bool, list[str], list[str], int]:
    """解析 ``certificate.print_duplicate_check`` 配置。

    兼容 dict(测试桩)与 pydantic 模型(运行时配置)两种形态。
    返回 ``(enabled, customers, compare_fields, threshold)``;配置缺失时返回全关。
    """
    cfg_root = _cfg_get("certificate.print_duplicate_check", None)
    if cfg_root is None:
        return False, [], [], 0
    if hasattr(cfg_root, "enabled"):
        enabled = bool(cfg_root.enabled)
        customers = list(cfg_root.customers or [])
        compare_fields = list(cfg_root.compare_fields or [])
        threshold = int(cfg_root.threshold or 0)
    else:
        enabled = bool(cfg_root.get("enabled", False))
        customers = cfg_root.get("customers") or []
        compare_fields = cfg_root.get("compare_fields") or []
        threshold = int(cfg_root.get("threshold", 0) or 0)
    return enabled, customers, compare_fields, threshold


def _count_matched_fields(log: PrintLog, rec: PrintLog, fields: list[str]) -> int:
    """统计两条 PrintLog 在给定字段上相等的数量(含双 None 视为相等)。"""
    matched = 0
    for field in fields:
        cur = getattr(log, field, None)
        old = getattr(rec, field, None)
        if (
            cur is None
            and old is None
            or cur is not None
            and old is not None
            and str(cur) == str(old)
        ):
            matched += 1
    return matched


[文档] def is_duplicate_print_record(session: Session, log: PrintLog) -> bool: """打印存库重复检测(§5.7.2-C / 维度11,对齐 VBA「大连大高重复跳过存库」)。 仅当配置 ``certificate.print_duplicate_check.enabled=true`` 且当前打印记录的 订货单位(``DingHuoDanWei``)命中 ``customers`` 关键词时启用:在 PrintLog 表中 查找与当前记录在 ``compare_fields`` 上字段值相等数 ≥ ``threshold`` 的既有记录, 命中则返回 True(调用方跳过新增 PrintLog 行,仅更新合格证清单/ Certificate 状态)。 默认配置 ``enabled=false`` → 永远返回 False,零差异(所有客户照常落库)。 Args: session: SQLAlchemy 会话。 log: 已构建但未落库的 PrintLog 对象(取 ``DingHuoDanWei`` / ``compare_fields`` 值)。 Returns: bool: 是否应跳过落库(True=重复,跳过)。 """ enabled, customers, compare_fields, threshold = _resolve_duplicate_config() if not enabled or not compare_fields or threshold <= 0: return False if customers: customer = str(getattr(log, "DingHuoDanWei", "") or "") if not any(str(c) in customer for c in customers): return False from certflow.models.print_log import PrintLog # 逐条比对既有 PrintLog(阈值通常较小,全表扫可接受;大连大高场景量可控) existing = session.query(PrintLog).all() return any(_count_matched_fields(log, rec, compare_fields) >= threshold for rec in existing)
# 检验工号默认(按模板语言族,BUG-006 I / H3): # 仅全中文默认「检5」;其余语言族(中英文 / 全英文 / 俄英文)默认「No 5」。 # 真实打印流程中操作者可在参数补充对话框录入具体工号;未录入时回退到此默认值。 # 主路径改为配置驱动(config/certificate.yaml 的 certificate.inspector_id), # 下方硬编码仅作为「配置不可用」时的最终回退,保持既有行为。 _INSPECTOR_ID_DEFAULTS = { "全中文": "检5", "中英文": "No 5", "全英文": "No 5", "俄英文": "No 5", } def _default_inspector_id(template_type: str) -> str: """按模板语言族返回检验工号默认值(配置驱动,回退硬编码)。 读取 ``certificate.inspector_id``: - ``enabled=False`` → 返回空串(不自动填默认工号,由操作者手动录入)。 - 命中 ``defaults[语言族]`` → 该值;否则用 ``fallback``。 配置不可用时回退 ``_INSPECTOR_ID_DEFAULTS``(兼容既有行为)。 """ try: from certflow.config.settings import cfg inspector_id = cfg("certificate.inspector_id", None) except ImportError: inspector_id = None if inspector_id: if not bool(inspector_id.get("enabled", True)): return "" defaults = inspector_id.get("defaults", {}) or {} if template_type in defaults: return str(defaults[template_type]) return str(inspector_id.get("fallback", "检5")) # 配置不可用:回退硬编码默认 return _INSPECTOR_ID_DEFAULTS.get(template_type, "检5")
[文档] class CertLogService: """打印日志保存服务""" def __init__(self, session: Session, certificate_id: int | None = None) -> None: """初始化打印日志服务。 Args: session: SQLAlchemy 数据库会话。 certificate_id: 关联的合格证 ID,可为 None(仅构建日志时不回填)。 """ self.session = session self.certificate_id = certificate_id # ============================================================ # SN/KKS 推导 # ============================================================
[文档] @staticmethod def derive_sn_kks(serial: str) -> tuple[str, str]: """从完整编码推导 SN(短编号)和 KKS(完整编码) - SN = 去掉中间4位年月,如 "V2512123Y" → "V123Y" - KKS = 完整编码原样返回 """ m = re.match(r"^([A-Z])(\d{4})(\d+)([A-Z]?)$", serial) if m: prefix, seq, suffix = m.group(1), int(m.group(3)), m.group(4) or "" return f"{prefix}{seq:03d}{suffix}", serial return serial, serial
[文档] @staticmethod def derive_manufacture_ym(serial: str) -> tuple[int | None, int | None]: """从完整编码提取出厂年月,例如 "V2512123Y" → (25, 12)""" m = re.match(r"^[A-Z](\d{2})(\d{2})\d+[A-Z]?$", serial) if m: return int(m.group(1)), int(m.group(2)) return None, None
# ============================================================ # 数据获取 # ============================================================
[文档] def get_cert_data(self) -> dict[str, Any]: """从 Certificate 表获取当前批次的业务数据,关联 SalePlan 获取销售信息。 Returns: dict[str, Any]: 合格证及关联销售计划的业务数据字典。 """ from certflow.models.certificate import Certificate from certflow.models.sale_plan import SalePlan if not self.certificate_id: return {} try: cert = self.session.get(Certificate, self.certificate_id) if not cert: return {} sp_data = {} if cert.sale_plan_id: sp = self.session.get(SalePlan, cert.sale_plan_id) if sp: sp_data = { "product_code": sp.product_code or "", "category": sp.category or "", "weight": str(sp.weight) if sp.weight else "", "sort_order": str(sp.sort_order) if sp.sort_order else "", "plan_no": sp.plan_no or "", } return { "plan_date": cert.plan_date or "", "customer": cert.customer or "", "project_name": cert.project_name or "", "product_name": cert.product_name or "", "product_model": cert.product_model or "", "dn": cert.product_spec or "", "pn": cert.pn_display or cert.pn_value or "", "temperature": cert.working_temp or "", "medium": cert.working_medium or "", "check_standard": cert.test_standard or "", "fati_material": cert.fati_material or "", "fagan_material": cert.fagan_material or "", "qibijian_material": cert.qibijian_material or "", "tech_requirements": cert.tech_requirements or "", "sales_order_no": cert.sales_order_no or "", "production_order_no": cert.production_order_no or "", "quantity": cert.quantity or 1, **sp_data, } except Exception as e: logger.warning(f"[CertLog] 获取 Certificate 数据失败: {e}") return {}
[文档] def get_sn_dup_count(self, sn: str) -> str: """查询 SN 重复条目数,格式 "重复条目数:02" """ from certflow.models.print_log import PrintLog if not sn or not self.session: return "重复条目数:01" try: count = self.session.query(PrintLog).filter(sn == PrintLog.SN).count() return f"重复条目数:{count + 1:02d}" except Exception: return "重复条目数:01"
# ============================================================ # PrintLog 构建 # ============================================================
[文档] def build_print_log( self, serial: str, data: dict[str, Any], printer_name: str, now: datetime | None = None ) -> PrintLog: """构建单条 PrintLog 记录(51字段对齐 Access SignTb)。 Args: serial: 产品完整编码。 data: 表单/打印数据字典。 printer_name: 打印机名称。 now: 打印时间戳,为 None 时使用当前时间。 Returns: PrintLog: 已构建但尚未提交的打印日志对象。 """ from certflow.models.vba_mapping import VBAMapping now = now or datetime.now() sn, kks = self.derive_sn_kks(serial) # §5.7.2-B:清单保存用 list 分隔符(默认 "-",如 V2607099Y-1000Y), # 与编号阶段 stage 分隔符("---")区分;PrintLog/SN/KKS 落库即清单形态。 from certflow.services.certificate_number_service import CertificateNumberService sn = CertificateNumberService.to_list_format(sn) kks = CertificateNumberService.to_list_format(kks) year, month = self.derive_manufacture_ym(serial) cert_data = self.get_cert_data() def _val(key: str, default: str = "") -> str: v = data.get(key) or cert_data.get(key) return str(v) if v else default # 温度/DN/PN 清理 temp = _val("temperature", "").replace(" ", "").replace("≤", "").replace("℃", "") dn = _val("dn", "") if dn.upper().startswith("DN"): dn = dn[2:] if dn.upper().endswith("MM"): dn = dn[:-2] pn = _val("pn", "") if pn.upper().endswith("MPA"): pn = pn[:-3] if data.get("product_model", "").upper().find("LB") > 0 and not pn.upper().endswith("LB"): pn = f"{pn}Lb" jhrq = None plan_date_str = cert_data.get("plan_date", "") if plan_date_str: with suppress(ValueError): jhrq = datetime.strptime(plan_date_str, "%Y-%m-%d") template_type = data.get("template_type", "全中文") number_mode = data.get("number_mode", "直接复制") style_code = VBAMapping.get_style_code(self.session, template_type) number_code = VBAMapping.get_number_mode_code(self.session, number_mode) printer_code = VBAMapping.get_printer_code(self.session, printer_name) fields = { "JiHuaRiQi": jhrq or now, "DingHuoDanWei": _val("customer", "【空白订货单位】"), "XiangMuMingCheng": _val("project_name", "【空白项目名称】"), "ChanPinMingCheng": _val("product_name", "【空白产品名称】"), "ChanPinXingHao": _val("product_model", "【空白产品型号】"), "DN": dn, "PN": pn, "ShiYongJieZhi": _val("medium", "【空白适用介质】"), "ShiYongWenDu": temp, "ChuChangRiQiYear": year or 1900, "ChuChangRiQiMonth": month or 1, "SN": sn, "KKS": kks, "hgz_ShuLiang": data.get("quantity", cert_data.get("quantity", 1)), "hgz_YangShi": style_code, "hgz_BianMaModel": number_code, "hgz_xlMuBanName": data.get("template_name") or data.get("template_type", ""), "hgz_PrintTime": now, # B3:铭牌打印时间 = 合格证打印时间(打印即铭牌刻印产出,合理回填); # 其余 Sign_* 刻印标记(Br14-23/46-48)与 水头字段(Br50/51)由铭牌刻印 # 环节置位 / 暂无业务数据源,保持模型默认(False / 空),不臆造。 "Sign_PrintTime": now, "PrinterName": str(printer_code), "Sign_PrintCount": 0, "Sign_ModelFullName": self.get_sn_dup_count(sn), "FaTiCaiZhi": _val("fati_material", "【未录入阀体材质】"), "FaGanCaiZhi": _val("fagan_material", "【未录入阀杆材质】"), "QiBiJianCaiZhi": _val("qibijian_material", "【未录入启闭件材质】"), "JiShuYaoQiu": cert_data.get("tech_requirements", "【空白技术要求】"), "SheBeiWeiHao": cert_data.get("sales_order_no", ""), "ZiXiangMu": cert_data.get("production_order_no", ""), "SNFromXSB": cert_data.get("product_code", ""), "ZhongLei": cert_data.get("category", ""), "ZhongLiang": cert_data.get("weight", ""), "XuHao": cert_data.get("sort_order", ""), "YaoHuoDanHaoFromXSB": cert_data.get("plan_no", ""), "DcOrPlPrint": "1", "status": "success", "content_summary": f"{_val('product_name')} {_val('product_model')}", } # 单一 PrintLog 构造器(§11.8 阶段2·B):消除 cert_log_service / certificate_print_service 重复构造 return HistoryBackfillService(self.session).assemble_print_log( certificate_id=self.certificate_id, fields=fields )
# ============================================================ # Certificate 回填 # ============================================================
[文档] def update_certificate_status( self, data: dict[str, Any], printer_name: str, now: datetime | None = None ) -> None: """更新 Certificate 打印状态,用表单数据覆盖(以实际打印内容为准)。 Args: data: 表单/打印数据字典。 printer_name: 打印机名称。 now: 打印时间戳,为 None 时使用当前时间。 """ from certflow.models.certificate import Certificate if not self.certificate_id: return cert = self.session.get(Certificate, self.certificate_id) if not cert: return cert.print_status = "已打印" cert.print_time = now or datetime.now() cert.printer_name = printer_name # A3:落库写证/打印时选择的模板语言族,供重打恢复 cert.template_type = data.get("template_type", "全中文") cert.working_temp = data.get("temperature", "") cert.working_medium = data.get("medium", "") cert.test_standard = data.get("check_standard", "") # H3:真实回填检验工号(此前漏写);未提供时按模板语言族取默认值 cert.inspector_id = data.get("inspector_id") or _default_inspector_id( data.get("template_type", "全中文") ) if data.get("pn"): cert.pn_display = data["pn"]
# ============================================================ # 批量保存 # ============================================================
[文档] def get_recent_logs(self, limit: int = 200) -> list[PrintLog]: """获取最近的打印日志 Args: limit: 返回条数上限,默认 200 Returns: PrintLog 列表,按 id 降序排列 """ from certflow.models.print_log import PrintLog try: return self.session.query(PrintLog).order_by(PrintLog.id.desc()).limit(limit).all() except Exception as e: logger.error(f"获取打印历史失败: {e}") return []
[文档] def query_logs( self, filters: dict | None = None, limit: int = 2000, ) -> list[PrintLog]: """按条件查询打印日志(参数化,防注入) 支持按日期区间(hgz_PrintTime 或 JiHuaRiQi)与产品型号模糊搜索。 Args: filters: 过滤条件字典,可选键: date_field: "hgz_PrintTime"(默认) 或 "JiHuaRiQi" date_from: datetime 起始(含) date_to: datetime 结束(含) model_keyword: 产品型号模糊关键字(LIKE,不区分大小写) limit: 返回条数上限,默认 2000 Returns: PrintLog 列表,按 id 降序排列 """ from certflow.models.print_log import PrintLog filters = filters or {} try: query = self.session.query(PrintLog) date_field = filters.get("date_field", "hgz_PrintTime") date_col = ( PrintLog.hgz_PrintTime if date_field == "hgz_PrintTime" else PrintLog.JiHuaRiQi ) date_from = filters.get("date_from") if date_from is not None: query = query.filter(date_col >= date_from) date_to = filters.get("date_to") if date_to is not None: query = query.filter(date_col <= date_to) keyword = filters.get("model_keyword") if keyword: query = query.filter(PrintLog.ChanPinXingHao.ilike(f"%{keyword}%")) return query.order_by(PrintLog.id.desc()).limit(limit).all() except Exception as e: logger.error(f"查询打印历史失败: {e}") return []
[文档] def auto_save(self, serials: list[str], data: dict[str, Any], printer_name: str) -> None: """逐台保存 PrintLog + 更新 Certificate,统一提交。 Args: serials: 产品编码列表(逐台打印)。 data: 表单/打印数据字典。 printer_name: 打印机名称。 """ if not self.session: logger.warning("[CertLog] 无数据库会话,跳过保存") return now = datetime.now() failures: list[str] = [] skipped_duplicates: list[str] = [] for serial in serials: try: log = self.build_print_log(serial, data, printer_name, now) # §5.7.2-C:指定客户打印时检测 PrintLog 重复,命中则跳过落库行 # (仅更新合格证清单/ Certificate 状态,与 VBA「重复跳过存库」一致)。 if is_duplicate_print_record(self.session, log): skipped_duplicates.append(serial) logger.info(f"[CertLog] 检测到重复打印记录,跳过落库: {serial}") continue self.session.add(log) except Exception as e: failures.append(f"{serial}: {e}") logger.error(f"[CertLog] 添加打印日志失败 [{serial}]: {e}") try: self.update_certificate_status(data, printer_name, now) except Exception as e: logger.error(f"[CertLog] 更新 Certificate 状态失败: {e}") # G3: 打印后回填 SalePlan 的 sn_code/kks_code(仅在该计划全部台数打印完毕) if self.certificate_id: backfill_dual_codes_if_fully_printed(self.session, self.certificate_id) if failures: # F3(a): 不再静默吞异常谎报成功——汇总失败并抛出,交由调用方处理 summary = "; ".join(failures[:3]) more = "" if len(failures) <= 3 else f" 等共 {len(failures)} 条" raise RuntimeError( f"[CertLog] {len(failures)}/{len(serials)} 条打印日志保存失败: {summary}{more}" ) try: DatabaseManager.commit_with_retry(self.session) skip_msg = f",跳过重复 {len(skipped_duplicates)} 条" if skipped_duplicates else "" logger.info(f"[CertLog] 已保存 {len(serials)} 条打印记录{skip_msg}") except Exception as e: logger.error(f"[CertLog] 提交打印记录失败: {e}") self.session.rollback() raise
# G3 回填已在 commit 前(update_certificate_status 之后)执行,此处无需重复。
[文档] def backfill_dual_codes_if_fully_printed(session: Session, certificate_id: int) -> bool: """G3: 打印后回填 SalePlan 的 sn_code/kks_code。 设计意图(§11.4.1):SN/KKS 为「打印后记录」字段——编号阶段不再派生, 打印时逐台由 product_code_range 正则拆分得到短码 SN(如 ``V520Y``)/长码 KKS(如 ``V2604520Y``) 写入 PrintLog;SalePlan 的 sn_code/kks_code 仅在 该计划全部台数(quantity)对应的 PrintLog 均已写入时才回填。 G5 补充触发点:``SalePlan.fully_shipped``(全部台数发完,等价 ``shipping_status='已发货'``)为真时也执行回填——满足「全部发完才回填」 的语义,且不必等到逐台 PrintLog 全部落库。 回填值由**实际打印的编号**推导(补丁36 修正):优先取 ``Certificate.product_code_range``(用户可能手动修订过编号,打印出的 serial 即来自此);仅在空时回退 ``SalePlan.product_code``。 一般件 kks_code=长 V 码区间串、sn_code=剥 YYMM 短码;电厂件已录入的真 KKS(不以 V 开头)受保护不被覆盖。 Args: session: SQLAlchemy 会话 certificate_id: 本次打印关联的 Certificate.id Returns: bool: 是否执行了回填(False 表示尚未全部打印/发完或无需回填) """ from certflow.models.certificate import Certificate from certflow.models.print_log import PrintLog from certflow.models.sale_plan import SalePlan cert = session.get(Certificate, certificate_id) if not cert or not cert.sale_plan_id: return False sp = session.get(SalePlan, cert.sale_plan_id) if not sp or not (sp.quantity or 0) > 0: return False # G5 触发点①:全部台数已发完(fully_shipped)即回填。 # 触发点②(G3 原逻辑):统计该 SalePlan 下所有 Certificate 关联的 # PrintLog 台数,仅当全部台数打印完毕才回填。两者满足其一即可。 if not sp.fully_shipped: cert_ids = [ c.id for c in session.query(Certificate.id).filter(Certificate.sale_plan_id == sp.id).all() ] if not cert_ids: return False printed = session.query(PrintLog).filter(PrintLog.certificate_id.in_(cert_ids)).count() if printed < sp.quantity: return False # 补丁36 修正:回填必须使用「实际打印的编号」,而非 SalePlan 原始 product_code。 # 用户可能手动修订过编号,打印出的 serial 来自 Certificate.product_code_range; # 若取 sp.product_code 会得到与原编号一致、但非实际打印值的 sn/kks # (日志实证:实际打印 V2607460/461,旧逻辑回填 V001---002)。 from certflow.services.dual_code import _is_real_kks, to_sn_code printed = (cert.product_code_range or "").strip() or (sp.product_code or "").strip() # 电厂件真 KKS(不以 V 开头)受保护,不被实际打印的 V 码覆盖(B1-sn-elec) if not _is_real_kks(sp.kks_code or ""): sp.kks_code = printed sp.sn_code = to_sn_code(printed) if printed else to_sn_code(sp.product_code or "") logger.info( f"[CertLog] G3 回填 sn/kks | sale_plan_id={sp.id} | sn={sp.sn_code!r} kks={sp.kks_code!r}" f" | 来源={'实际打印=' + cert.product_code_range if cert.product_code_range else 'sp.product_code'}" ) return True