certflow.services.report_bom_enricher 源代码

"""报告 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)