"""报告 BOM 材质富集器(阶段 C:C1–C4 串联)
把「按生产令号查 BOM → 关键词归类 → 查材质化学/力学 → 产出主材位+待核对明细」
串成一个可单测、不污染 ``ReportView`` 的服务。
数据流(docs/PATCH_LOG.md 补丁 14「阶段 C:BOM 材质集成到报告生成」):
query_by_production_order_no(pno) → list[BomMaterial]
→ BomPartClassifier.classify → {主材位: [BomMaterial]} + 待核对
→ MaterialGradeService.get_by_grade → 化学/力学性能范围
→ MaterialPart(命中的主材位,带虚拟实测值)+ 待核对明细(未命中/多牌号/缺数据)
设计要点:
- 命中的主材位才自动落报告材质块;匹配不到 / 多对一 / 牌号缺 material_grades 记录
的零件一律进「待核对明细」,不写 sale_plan、不写超链接(原则 3)。
- 归类与报告富集各自独立、可单测(原则:不污染 UI)。
- 多标准牌号策略(§9.2 待定 → 取 is_active 优先):``generate_composition_values``
已按 ``is_active.is_(True)`` 过滤;富集器仅用 ``get_by_grade`` 判定牌号是否在表,
缺化学/力学范围时记入待核对明细提示,不报错中断生成(C3.3)。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from certflow.services.bom_part_classifier import (
DEFAULT_BOM_REPORT_CFG,
classify,
)
from certflow.services.report_migration.material_provider import MaterialPart
# BOM 分类槽位顺序(用于产出稳定有序的主材位结果)
_SLOT_ORDER = ["body", "stem", "disc", "gate", "seat", "bolt"]
# BOM 槽位 → 报告材质块展示用中文标签(零件名称列展示)
_SLOT_LABEL = {
"body": "阀体",
"stem": "阀杆",
"disc": "阀瓣",
"gate": "闸板",
"seat": "阀座",
"bolt": "螺栓",
}
[文档]
@dataclass
class BomEnrichResult:
"""BOM 材质富集结果。
Attributes:
production_order_no: 生产令号。
matched: 命中主材位数(已落地到报告材质块的 BOM 主材位数量)。
detail_count: 待核对明细条数。
classified: 主材位标识 → 该位代表性 MaterialPart(含化学/力学性能,来源=bom)。
detail: 待核对明细列表,每项 ``{part_name, material_grade, reason, note}``。
"""
production_order_no: str = ""
matched: int = 0
detail_count: int = 0
classified: dict[str, MaterialPart] = field(default_factory=dict)
detail: list[dict[str, str]] = field(default_factory=list)
# 未命中关键词 / 多对一被剔除的 BOM 零件(真实零件名 + 牌号 + 化学/力学),
# 供报告材质块空行「补位」使用(阶段 C 补丁 15 延伸);与 ``detail`` 同源但不重复计算。
leftover_parts: list[MaterialPart] = field(default_factory=list)
def _grade_missing_note(material_svc: Any, grade: str) -> str:
"""判定牌号命中但为何无化学/力学数据,生成待核对备注。"""
try:
rows = material_svc.get_by_grade(grade)
except Exception: # 查询异常不阻断,给出通用提示
rows = []
if not rows:
return f"牌号 {grade} 不在 material_grades 表中(需在基础数据维护)"
return f"牌号 {grade} 已登记,但化学/力学性能范围缺失(无法生成虚拟实测值)"
[文档]
def enrich_parts(
production_order_no: str,
parts: list[Any],
material_svc: Any,
cfg: dict[str, Any] | None = None,
) -> BomEnrichResult:
"""富集已取出的 BOM 零件列表(无 DB 依赖,便于单测)。
Args:
production_order_no: 生产令号(仅用于溯源标注)。
parts: 该令号的全部 BOM 零件(含 ``part_name`` / ``material_grade`` / ``seq``)。
material_svc: 材质服务,需提供 ``generate_composition_values(grade)`` 与
``get_by_grade(grade)``(通常为 ``MaterialGradeService`` 实例)。
cfg: 归类配置(同 ``classify``)。
Returns:
BomEnrichResult:命中的主材位 MaterialPart + 待核对明细。
"""
cfg = cfg or DEFAULT_BOM_REPORT_CFG
cls = classify(parts, cfg)
classified: dict[str, MaterialPart] = {}
detail: list[dict[str, str]] = []
leftover_parts: list[MaterialPart] = []
for slot in _SLOT_ORDER:
matched = cls.slots.get(slot) or []
if not matched:
continue
rep = matched[0] # 多对一已在 classify 阶段剔除(detail 策略)
grade = (getattr(rep, "material_grade", None) or "").strip()
name = getattr(rep, "part_name", None) or _SLOT_LABEL.get(slot, slot)
part = MaterialPart(
零件名称=name,
材料=grade,
来源="bom",
来源令号=production_order_no,
is_virtual=True,
)
if grade and material_svc is not None:
comp = material_svc.generate_composition_values(grade)
part.化学成分 = comp.get("化学成分", {})
part.力学性能 = comp.get("力学性能", {})
# 命中但缺化学/力学性能范围 → 待核对提示(C3.3:留空不报错)
if not part.化学成分 and not part.力学性能:
detail.append(
{
"part_name": name,
"material_grade": grade,
"reason": "命中主材位但 material_grades 无化学/力学性能数据",
"note": _grade_missing_note(material_svc, grade),
}
)
elif not grade:
detail.append(
{
"part_name": name,
"material_grade": "",
"reason": "BOM 零件缺材料牌号",
"note": "需在 BOM 导入阶段补全该零件牌号",
}
)
classified[slot] = part
# 未命中关键词 / 多对一 的零件 → 待核对明细(不强行落主材位),
# 同时构造成 MaterialPart 供报告材质块空行「补位」(保留真实零件名)。
for m in cls.detail:
mname = getattr(m, "part_name", None) or ""
mgrade = getattr(m, "material_grade", None) or ""
detail.append(
{
"part_name": mname,
"material_grade": mgrade,
"reason": "未命中主材位关键词 / 同主材位多零件",
"note": "可填入报告材质块空行(已自动补位)或人工核对",
}
)
mp = MaterialPart(
零件名称=mname,
材料=mgrade,
来源="bom",
来源令号=production_order_no,
is_virtual=True,
)
if mgrade and material_svc is not None:
comp = material_svc.generate_composition_values(mgrade)
mp.化学成分 = comp.get("化学成分", {})
mp.力学性能 = comp.get("力学性能", {})
leftover_parts.append(mp)
return BomEnrichResult(
production_order_no=production_order_no,
matched=len(classified),
detail_count=len(detail),
classified=classified,
detail=detail,
leftover_parts=leftover_parts,
)
[文档]
def enrich(
production_order_no: str,
session: Any,
cfg: dict[str, Any] | None = None,
) -> BomEnrichResult:
"""按生产令号从 DB 拉取 BOM 并富集(C1 入口)。
Args:
production_order_no: 生产令号。
session: SQLAlchemy 会话(已绑定 bom_materials / material_grades)。
cfg: 归类配置;缺省回退 ``DEFAULT_BOM_REPORT_CFG``。
Returns:
BomEnrichResult:命中的主材位 + 待核对明细。
Note:
BOM 查询 / 材质服务实例化失败会向上抛出;调用方(``_resolve_materials``)
应捕获降级为留空,避免阻断整批报告生成。
"""
from certflow.services.bom_material_service import query_by_production_order_no
from certflow.services.material_grade_service import MaterialGradeService
parts = query_by_production_order_no(production_order_no, session)
svc = MaterialGradeService(session)
return enrich_parts(production_order_no, parts, svc, cfg)