# src/certflow/services/output_guard.py
"""统一输出护栏管道(print/scan 共用,配置驱动,与 UI 解耦)。
设计原则(docs/PRINT_BLUEPRINT.md §11.5 / §11.8 阶段1):
- 本模块为**纯逻辑层**,不依赖 PySide6 / 任何 UI 框架,便于 pytest 无头测试。
- 所有「用户交互」(弹输入框补录、确认闸口)通过 :class:`GuardPrompt` 协议注入,
view 层负责提供 PyQt 实现(如 ``print_view._QtGuardPrompt``),scan 可注入非交互实现。
- 历史差异比对(④ reconfirm_on_diff)依赖调用方传入 ``history_last``(由
:class:`~certflow.services.print_history_service.HistoryBackfillService` 提供,
读 ``PrintLog`` 聚合的「同型号上次实际值」);历史源缺失时该字段自动跳过差异闸。
管道顺序(与旧 ``print_view._guard_before_print`` 等价并补 ④):
② 必填补录 / ③ reconfirm(即使有值也确认)
④ 与历史差异确认(reconfirm_on_diff)
⑤ 确认闸口(confirmation.summary_fields 预览)
任一环节用户取消 → 返回 ``(False, data)``,调用方应中止本批(及多计划剩余批)。
"""
from __future__ import annotations
import re
from typing import Any, Protocol
from certflow.services.print_history_service import backfill as _backfill_history
# ============================================================
# 字段中文标签(合并 print + scan 需求,避免各 view 重复定义元组)
# ============================================================
FIELD_LABELS: dict[str, str] = {
"product_name": "产品名称",
"product_model": "产品型号",
"product_spec": "规格",
"test_standard": "检验标准",
"check_standard": "检验标准",
"inspector_id": "检验工号",
"issue_date": "出厂日期",
"manufacture_date": "出厂日期",
"dn": "公称通径",
"pn": "公称压力",
"temperature": "工作温度",
"medium": "适用介质",
"serial_number": "产品编号",
"pn_value": "公称压力值",
"pn_unit": "压力单位",
}
[文档]
def field_label(key: str) -> str:
"""字段 key → 中文标签(取不到回退 key 本身)。"""
return FIELD_LABELS.get(key, key)
# ============================================================
# 交互协议(由 view 层注入 PyQt / 非交互实现)
# ============================================================
[文档]
class GuardPrompt(Protocol):
"""护栏与用户交互的抽象接口。
- ``prompt_field``:请求用户补全/确认某字段,返回字符串或 ``None``(用户取消)。
- ``confirm``:确认闸口,返回是否继续。
"""
self, *, key: str, label: str, current: str, suggestion: str
) -> str | None: ...
[文档]
def confirm(self, *, message: str) -> bool: ...
# ============================================================
# 纯函数:完整性收集 + 差异计算(可独立测试)
# ============================================================
[文档]
def collect_incomplete(data: dict[str, Any], fields: list[str]) -> list[str]:
"""非交互完整性闸门:返回 ``fields`` 中值为空的字段 key 列表。
供 scan 视图在生成前检查合格证必填信息是否齐全(缺失即中止,不弹输入框)。
"""
return [k for k in fields if not (data.get(k) or "").strip()]
# ============================================================
# 模板语言族 → 压力单位门限(中文底版预印单位,其余族需带单位)
# ============================================================
[文档]
def family_requires_pn_unit(family: str) -> bool:
"""中文合格证底版已预印压力单位 → 不要求填单位;中英文/全英文/俄英文必须带单位。
Args:
family: 语言族(``全中文``/``中英文``/``全英文``/``俄英文``,来自 ``family_of``)。
"""
return family != "全中文"
[文档]
def scan_required_fields_for(family: str, base: list[str]) -> list[str]:
"""按模板语言族计算扫描件必填字段(配置 base + 压力字段门限)。
- 公称压力数值(``pn_value``)任何族都必填;
- 中文族:底版预印单位,不要求 ``pn_unit``;其余族必须带 ``pn_unit``。
"""
req = list(base)
if "pn_value" not in req:
req.append("pn_value")
if family_requires_pn_unit(family):
if "pn_unit" not in req:
req.append("pn_unit")
else:
req = [k for k in req if k != "pn_unit"]
return req
[文档]
def validate_pn_text(family: str, raw: str) -> str:
"""校验压力文本是否符合模板族的单位要求,返回错误提示(空串表示通过)。
``pn_display`` 为权威输入串(任意格式),本函数只做「门限」把关,不做改写:
- 中文族:底板预印单位,**放行不设置门限**。用户输入数值(如 ``25.0``)保存时
默认补 ``MPa``;若输入带单位(``32.0MPa`` 或英制 ``1500Lb``),渲染层按族处理
(MPa 剥离、非 MPa 的 Lb 保留,见 ``format_pn``),本函数不拦截;
- 其余族(中英文/全英文/俄英文):**必须带单位**(如 ``2.5MPa`` / ``150Lb``),
缺单位返回错误提示,阻断保存/入队以强制补齐(避免打印出无单位的压力文本)。
"""
raw = (raw or "").strip()
if not raw:
return ""
if family == "全中文":
return ""
if not re.search(r"(?i)(mpa|lb|kpa)", raw):
return "中英文/全英文/俄英文模板合格证压力需带单位(如 2.5MPa 或 150Lb),请补充单位"
return ""
[文档]
def diff_fields(
data: dict[str, Any],
history_last: dict[str, str] | None,
diff_keys: list[str],
) -> list[tuple[str, str, str]]:
"""计算与历史差异的项,返回 ``(key, current, history)`` 列表。
仅当历史有值、当前有值、且两者不同才计入(空历史/空当前不触发差异闸)。
"""
if not history_last:
return []
res: list[tuple[str, str, str]] = []
for k in diff_keys:
cur = (data.get(k) or "").strip()
his = (history_last.get(k) or "").strip()
if his and cur and cur != his:
res.append((k, cur, his))
return res
# ============================================================
# 统一护栏管道
# ============================================================
def _build_confirm_message(data: dict[str, Any], guard: dict[str, Any], *, multi: bool) -> str:
confirmation = guard.get("confirmation", {}) or {}
summary_fields = confirmation.get("summary_fields", []) or []
lines = [f"· {field_label(k)} {data.get(k, '')}" for k in summary_fields]
header = "即将批量发送以下合格证打印指令" if multi else "即将发送以下合格证打印指令"
return header + ",请核对:\n\n" + "\n".join(lines) + "\n\n确认发送?"
def _prompt_required(
data: dict[str, Any], guard: dict[str, Any], prompt: GuardPrompt
) -> tuple[bool, dict[str, Any]]:
"""② 必填补录 + ③ reconfirm(即使有值也确认)。返回 (ok, data)。"""
data = dict(data)
for f in guard.get("required_fields", []) or []:
key = f.get("key")
if not key:
continue
required = f.get("required", True)
reconfirm = f.get("reconfirm", False)
cur = (data.get(key) or "").strip()
if cur and not reconfirm:
continue
if not cur and not required:
continue
text = prompt.prompt_field(key=key, label=field_label(key), current=cur, suggestion="")
if text is None:
return False, data
text = text.strip()
if not text and required:
return False, data
data[key] = text
return True, data
def _prompt_diff(
data: dict[str, Any],
guard: dict[str, Any],
prompt: GuardPrompt,
history_last: dict[str, str] | None,
) -> tuple[bool, dict[str, Any]]:
"""④ 与历史差异确认(reconfirm_on_diff)。返回 (ok, data)。"""
data = dict(data)
for k in guard.get("reconfirm_on_diff", []) or []:
for key, cur, his in diff_fields(data, history_last, [k]):
text = prompt.prompt_field(key=key, label=field_label(key), current=cur, suggestion=his)
if text is None:
return False, data
data[key] = text.strip() or cur
return True, data
def _confirm_gate(
data: dict[str, Any], guard: dict[str, Any], *, multi: bool, prompt: GuardPrompt
) -> bool:
"""⑤ 确认闸口:按 single/multi_batch 开关决定是否弹确认。"""
confirmation = guard.get("confirmation", {}) or {}
gate_open = confirmation.get("multi_batch" if multi else "single_batch", True)
if not gate_open:
return True
return prompt.confirm(message=_build_confirm_message(data, guard, multi=multi))
[文档]
def run_guard(
data: dict[str, Any],
*,
guard: dict[str, Any],
prompt: GuardPrompt,
history_last: dict[str, str] | None = None,
multi: bool = False,
) -> tuple[bool, dict[str, Any]]:
"""统一护栏管道。返回 ``(ok, updated_data)``。
顺序:② 必填补录 / ③ reconfirm → ④ 差异确认 → ⑤ 确认闸口。
任一环节用户取消 → 返回 ``(False, data)``,调用方应中止本批(及多计划剩余批)。
Args:
data: 当前合格证字段字典(不会被原地修改,返回副本)。
guard: ``print.guard`` 段(来自 ``cfg("print.guard")``,dict 结构)。
prompt: 交互实现(PyQt 或非交互)。
history_last: 同型号上次实际值(Certificate 字段名 → 值),缺省跳过差异闸。
multi: 是否多计划批量(影响确认闸口的 single/multi_batch 开关)。
"""
# ① 历史自动补缺:用同型号上次实际值预填缺失字段(避免无谓弹框,且与 diff 闸不冲突)
data = _backfill_history(dict(data), history_last)
ok, data = _prompt_required(data, guard, prompt)
if not ok:
return False, data
ok, data = _prompt_diff(data, guard, prompt, history_last)
if not ok:
return False, data
if not _confirm_gate(data, guard, multi=multi, prompt=prompt):
return False, data
return True, data