from apps.wpm.models import BatchSt, BatchLog, Handoverb
import logging
import re
from apps.qm.models import Defect, FtestWork, FtestworkDefect
from apps.wpm.models import Mlogb, MlogbDefect, Mlog
from apps.mtm.models import Mgroup
from apps.inm.models import MIOItem
import decimal
from decimal import Decimal
from django.db.models import Sum
from datetime import datetime
from apps.wpm.services_2 import get_f_l_date
import json
from apps.utils.tools import MyJSONEncoder
myLogger = logging.getLogger("log")

# ==================== 大批直通良率统计 ====================
# 大批 = 拆出检验小批的那个批(由拆批交接关系确定, 不依赖批次号命名/工艺路线)
# 直通良率 = 大批下所有子批次外观检验直通合格数之和 / 大批白料数
# 白料数(入料数) = 沿拆合批关系(BatchLog)从大批向上回溯, 找到锚点工段产出批,
#   取其锚点工段领用数(即进炉前数量); 一条炉料链拆给多个大批时按生产主线流出数量占比分摊
#   (汇入不良集中批的旁路不占份额), 占比截断为1; 无锚点谱系(如外购半成品)时兜底用入库数量。
ZT_ANCHOR_MGROUPS = ["黑化"]  # 入料数锚点工段
ZT_MAX_DEPTH = 12  # 白料回溯最大层数
ZT_MAX_VISITS = 300  # 单个大批白料回溯的节点访问上限(防病态网状谱系组合爆炸; 实测正常批1~10次, 最重合法案例77次)
ZT_BIG_BATCH_RE = re.compile(r"^\d{4}-[0-9A-Za-z.]+-\d+")  # 大批批号格式(合批改名产生), 用于区分生产分流与不良汇集

def main(batch: str, mgroup_obj):
    try:
        batchst = BatchSt.objects.get(batch=batch, version=1)
    except BatchSt.DoesNotExist:
        myLogger.error(f"Batch {batch} does not exist")
        return
    
    data = {"批次号": batch}

    mgroup_qs = Mgroup.objects.all().order_by("sort")
    for mgroup in mgroup_qs:
        mgroup_name = mgroup.name
        mlogb1_qs = Mlogb.objects.filter(mlog__submit_time__isnull=False, 
                                         material_out__isnull=False, mlog__mgroup=mgroup, 
                                         mlog__is_fix=False, batch=batch, need_inout=True)
        if mlogb1_qs.exists():
            data[f"{mgroup_name}_日期"] = []
            data[f"{mgroup_name}_操作人"] = []
            data[f"{mgroup_name}_班次"] = []
            data[f"{mgroup_name}_count_use"] = 0
            data[f"{mgroup_name}_count_real"] = 0
            data[f"{mgroup_name}_count_ok"] = 0
            data[f"{mgroup_name}_count_notok"] = 0
            data[f"{mgroup_name}_count_ok_full"] = 0
            data[f"{mgroup_name}_count_pn_jgqbl"] = 0
            mlogb_q_ids = []
            for item in mlogb1_qs:
                # 找到对应的输入
                mlogb_from:Mlogb = item.mlogb_from
                if mlogb_from:
                    mlogb_q_ids.append(mlogb_from.id)
                    data[f"{mgroup_name}_count_use"] += mlogb_from.count_use
                    data[f"{mgroup_name}_count_pn_jgqbl"] += mlogb_from.count_pn_jgqbl
                if item.mlog.handle_user:
                    data[f"{mgroup_name}_操作人"].append(item.mlog.handle_user)
                if item.mlog.handle_date:
                    data[f"{mgroup_name}_日期"].append(item.mlog.handle_date)
                if item.mlog.shift:
                    data[f"{mgroup_name}_班次"].append(item.mlog.shift.name)
                data[f"{mgroup_name}_count_real"] += item.count_real
                data[f"{mgroup_name}_count_ok"] += item.count_ok
                data[f"{mgroup_name}_count_ok_full"] += item.count_ok_full if item.count_ok_full else 0
                data[f"{mgroup_name}_count_notok"] += item.count_notok if item.count_notok else 0
            
            try:
                data[f"{mgroup_name}_完全合格率"] = round((data[f"{mgroup_name}_count_ok_full"] / data[f"{mgroup_name}_count_real"])*100, 2)
            except (decimal.InvalidOperation, ZeroDivisionError):
                data[f"{mgroup_name}_完全合格率"] = 0

            try:
                data[f"{mgroup_name}_合格率"] = round((data[f"{mgroup_name}_count_ok"] / data[f"{mgroup_name}_count_real"])*100, 2)
            except (decimal.InvalidOperation, ZeroDivisionError):
                data[f"{mgroup_name}_合格率"] = 0
            
            mlogbd1_qs = MlogbDefect.objects.filter(mlogb__in=mlogb1_qs, count__gt=0).values("defect__name").annotate(total=Sum("count"))
            mlogbd1_q_qs = MlogbDefect.objects.filter(mlogb__id__in=mlogb_q_ids, count__gt=0).values("defect__name").annotate(total=Sum("count"))

            for item in mlogbd1_q_qs:
                data[f"{mgroup_name}_加工前_缺陷_{item['defect__name']}"] = item["total"]
                data[f"{mgroup_name}_加工前_缺陷_{item['defect__name']}_比例"] = round((item["total"] / data[f"{mgroup_name}_count_use"])*100, 2)

            for item in mlogbd1_qs:
                data[f"{mgroup_name}_缺陷_{item['defect__name']}"] = item["total"]
                data[f"{mgroup_name}_缺陷_{item['defect__name']}_比例"] = round((item["total"] / data[f"{mgroup_name}_count_real"])*100, 2)
            
            data[f"{mgroup_name}_日期"] = list(set(data[f"{mgroup_name}_日期"]))
            data[f"{mgroup_name}_日期"].sort()
            data[f"{mgroup_name}_小日期"] = min(data[f"{mgroup_name}_日期"]).strftime("%Y-%m-%d")
            data[f"{mgroup_name}_大日期"] = max(data[f"{mgroup_name}_日期"]).strftime("%Y-%m-%d")
            data[f"{mgroup_name}_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data[f"{mgroup_name}_日期"]])
            data[f"{mgroup_name}_操作人"] = list(set(data[f"{mgroup_name}_操作人"]))
            data[f"{mgroup_name}_操作人"] = ";".join([item.name for item in data[f"{mgroup_name}_操作人"]])
            data[f"{mgroup_name}_班次"] = list(set(data[f"{mgroup_name}_班次"]))
            data[f"{mgroup_name}_班次"] = ";".join([item for item in data[f"{mgroup_name}_班次"]])


    # 按 mlog__submit_time, id 排序，每条 Mlogb 记录独立为一次返修
    # （同一 Mlog 下有多条同批次 Mlogb 时也能正确拆分为多次返修）
    _all_fix_qs = Mlogb.objects.filter(
        mlog__submit_time__isnull=False,
        material_out__isnull=False,
        mlog__mgroup__name="外观检验",
        mlog__is_fix=True,
        batch=batch,
        need_inout=True,
    ).order_by("mlog__submit_time", "id")
    _fix_prefixes = []
    for fix_idx, fix_mlogb in enumerate(_all_fix_qs):
        suffix = "" if fix_idx == 0 else str(fix_idx + 1)
        prefix = f"外观检验_返修{suffix}_"
        _fix_prefixes.append(prefix)
        mlog = fix_mlogb.mlog
        handle_date = mlog.handle_date
        data[f"{prefix}count_real"] = fix_mlogb.count_real
        data[f"{prefix}count_ok"] = fix_mlogb.count_ok
        data[f"{prefix}count_ok_full"] = fix_mlogb.count_ok_full or 0
        data[f"{prefix}count_notok"] = fix_mlogb.count_notok or 0
        try:
            data[f"{prefix}合格率"] = round((fix_mlogb.count_ok / fix_mlogb.count_real) * 100, 2)
        except (decimal.InvalidOperation, ZeroDivisionError):
            data[f"{prefix}合格率"] = 0
        try:
            data[f"{prefix}完全合格率"] = round(((fix_mlogb.count_ok_full or 0) / fix_mlogb.count_real) * 100, 2)
        except (decimal.InvalidOperation, ZeroDivisionError):
            data[f"{prefix}完全合格率"] = 0
        data[f"{prefix}日期"] = handle_date.strftime("%Y-%m-%d") if handle_date else ""
        data[f"{prefix}小日期"] = handle_date.strftime("%Y-%m-%d") if handle_date else ""
        data[f"{prefix}大日期"] = handle_date.strftime("%Y-%m-%d") if handle_date else ""
        data[f"{prefix}操作人"] = mlog.handle_user.name if mlog.handle_user else ""
        data[f"{prefix}班次"] = mlog.shift.name if mlog.shift else ""
        fix_defect_qs = MlogbDefect.objects.filter(mlogb=fix_mlogb, count__gt=0).values("defect__name").annotate(total=Sum("count"))
        for item in fix_defect_qs:
            data[f"{prefix}缺陷_{item['defect__name']}"] = item["total"]
            data[f"{prefix}缺陷_{item['defect__name']}_比例"] = round((item["total"] / fix_mlogb.count_real) * 100, 2)
    
    # 车间库存抽检
    ft_qs = FtestWork.objects.filter(type2=FtestWork.TYPE2_SOME, wm__mgroup__name="外观检验", batch=batch, submit_time__isnull=False)
    if ft_qs.exists():
        data["外观检验_车间库存抽检_日期"] = []
        data["外观检验_车间库存抽检_操作人"] = []
        data["外观检验_车间库存抽检_count_notok"] = 0
        for item in ft_qs:
            if item.test_user:
                data["外观检验_车间库存抽检_操作人"].append(item.test_user)
            if item.test_date:
                data["外观检验_车间库存抽检_日期"].append(item.test_date)
            data["外观检验_车间库存抽检_count_notok"] += item.count_notok if item.count_notok else 0
        
        data["外观检验_车间库存抽检_日期"] = list(set(data["外观检验_车间库存抽检_日期"]))
        data["外观检验_车间库存抽检_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["外观检验_车间库存抽检_日期"]])
        data["外观检验_车间库存抽检_操作人"] = list(set(data["外观检验_车间库存抽检_操作人"]))
        data["外观检验_车间库存抽检_操作人"] = ";".join([item.name for item in data["外观检验_车间库存抽检_操作人"]])

    # 车间库存抽检缺陷
    ftd_qs = FtestworkDefect.objects.filter(ftestwork__in=ft_qs, count__gt=0).values("defect__name").annotate(total=Sum("count"))
    for item in ftd_qs:
        data[f"外观检验_车间库存抽检_缺陷_{item['defect__name']}"] = item["total"]

    if "外观检验_count_ok" in data:
        data["外观检验_总合格数"] = data["外观检验_count_ok"] + sum(data.get(f"{p}count_ok", 0) for p in _fix_prefixes)
        try:
            data["外观检验_总合格率"] = round((data["外观检验_总合格数"] / data["外观检验_count_real"])*100, 2)
        except (decimal.InvalidOperation, ZeroDivisionError):
            data["外观检验_总合格率"] = 0

        data["外观检验_完全总合格数"] = data["外观检验_count_ok_full"] + sum(data.get(f"{p}count_ok_full", 0) for p in _fix_prefixes)
        try:
            data["外观检验_完全总合格率"] = round((data["外观检验_完全总合格数"] / data["外观检验_count_real"])*100, 2)
        except (decimal.InvalidOperation, ZeroDivisionError):
            data["外观检验_完全总合格率"] = 0

        data["外观检验_直通合格数"] = data["外观检验_总合格数"] - data.get("外观检验_车间库存抽检_count_notok", 0)
        if "尺寸检验_合格率" in data:
            try:
                data["外观检验_直通合格率"] = round((data["外观检验_总合格率"]* data["尺寸检验_合格率"])/100, 2)
            except (decimal.InvalidOperation, ZeroDivisionError):
                data["外观检验_直通合格率"] = 0

            try:
                data["外观检验_直通合格率2"] = round((data["外观检验_直通合格数"]/data["尺寸检验_count_use"])*100, 2)
            except (decimal.InvalidOperation, ZeroDivisionError):
                data["外观检验_直通合格率2"] = 0

        if "尺寸检验_完全合格率" in data:
            try:
                data["外观检验_完全直通合格率"] = round((data["外观检验_完全总合格率"]* data["尺寸检验_完全合格率"])/100, 2)
            except (decimal.InvalidOperation, ZeroDivisionError):
                data["外观检验_完全直通合格率"] = 0
    
    res = get_f_l_date(data)


    batchst.data = json.loads(json.dumps(data, cls=MyJSONEncoder))
    if batchst.first_time is None or (res["first_time"] and res["first_time"] < batchst.first_time):
        batchst.first_time = res["first_time"]
    if batchst.last_time is None or (res["last_time"] and res["last_time"] > batchst.last_time):
        batchst.last_time = res["last_time"]
    batchst.save()

    try:
        handle_zt(batchst, data)
    except Exception:
        myLogger.exception(f"直通统计-{batch}计算失败")


def handle_zt(batchst: BatchSt, data: dict):
    """确定当前批归属的大批并重算该大批的直通良率"""
    big_batch = resolve_zt_big(batchst, data)
    if big_batch and batchst.zt_batch != big_batch:
        batchst.zt_batch = big_batch
        batchst.save(update_fields=["zt_batch", "update_time"])
    if big_batch is None and BatchSt.objects.filter(zt_batch=batchst.batch, version=1).exclude(id=batchst.id).exists():
        # 当前批自身是大批(已有子批归属于它), 其数据变化(如白料数)也需重算
        big_batch = batchst.batch
    if big_batch:
        cal_zt_big(big_batch)


def _zt_split_parents(batchst: BatchSt):
    """经拆批交接产生该批的来源批次号集合"""
    qs = BatchLog.objects.filter(target=batchst, relation_type="split", handover__isnull=False).select_related("source")
    return {e.source.batch for e in qs}


def _zt_climb_split_top(batchst: BatchSt, max_hops=4):
    """沿拆批链向上爬到顶层批(多级拆批的孙批归属顶层大批)

    无拆批父级返回 None; 多个拆批父级(异常)停在当前层。
    """
    node = batchst
    top = None
    for _ in range(max_hops):
        ps = _zt_split_parents(node)
        if len(ps) != 1:
            break
        top = ps.pop()
        parent = BatchSt.objects.filter(batch=top, version=1).first()
        if parent is None:
            break
        node = parent
    return top


def resolve_zt_big(batchst: BatchSt, data: dict, use_lock=True):
    """确定检验小批所属的大批号, 无法确定返回 None

    use_lock=False 时忽略已锁定的 zt_batch, 重新从谱系解析(供回刷纠错)。
    """
    if use_lock and batchst.zt_batch:
        return batchst.zt_batch
    if not any(k in data for k in ("尺寸检验_count_real", "外观检验_count_real")):
        return None
    top = _zt_climb_split_top(batchst)
    if top:
        return top
    # 合批复检批(如 大批-A-1): 所有可归属的合批来源须属于同一大批
    merge_srcs = [e.source for e in BatchLog.objects.filter(target=batchst, relation_type="merge").select_related("source")]
    if merge_srcs:
        bigs = set()
        for s in merge_srcs:
            if s.zt_batch and s.zt_batch != s.batch:
                bigs.add(s.zt_batch)
            else:
                t = _zt_climb_split_top(s)
                if t:
                    bigs.add(t)
                # 无拆批谱系的来源(炉料链/改号链)不参与判定
        if len(bigs) == 1:
            return bigs.pop()
        if len(bigs) > 1:
            myLogger.error(f"直通统计-{batchst.batch}合批来源大批不一致{bigs}, 无法归属")
            return None
        # 来源均为炉料链: 合批改名诞生的大批未拆批直接检验, 自身即大批;
        # 非大批格式(如不良集中池)不归属
        if ZT_BIG_BATCH_RE.match(batchst.batch):
            return batchst.batch
        return None
    # 无任何拆合批上游: 自身即大批(未拆批直接检验/外购直检)
    return batchst.batch


def _zt_edge_qty(edge: BatchLog, target_batch: str):
    """该条拆合批边流入 target 的数量"""
    if edge.handover_id:
        # 拆批明细行记目标批, 合批明细行记来源批
        hb_batch = target_batch if edge.relation_type == "split" else edge.source.batch
        qty = Handoverb.objects.filter(handover_id=edge.handover_id, batch=hb_batch).aggregate(t=Sum("count"))["t"]
        if qty:
            return qty
        return edge.handover.count  # 无明细行时兜底整单数量
    if edge.mlog_id:
        # 报工改号: 目标批产出行对应的领用数
        row = Mlogb.objects.filter(mlog_id=edge.mlog_id, batch=target_batch,
                                   material_out__isnull=False).select_related("mlogb_from").first()
        if row:
            return row.mlogb_from.count_use if row.mlogb_from else row.count_real
    return None


def _zt_source_out_ratio(edge: BatchLog, qty):
    """qty 占来源批生产主线流出量的比例(白料分摊系数)

    生产主线流出 = 报工改号边 / 拆批边 / 汇入大批格式批号的合批边(即当前回溯所走的这类边);
    汇入不良集中批(如 黑检片-00)等旁路的合批边不占白料份额 —— 炉后不良对应的白料
    应计入生产主线大批的分母, 直通良率才能体现炉段损耗。
    合并回自身的整理性合批边(source==target)不算流出。
    只有一条生产主线流出边时全额传递(ratio=1); 多条(如炉料链拆给多个大批)按数量占比分摊。
    """
    out_edges = [e for e in BatchLog.objects.filter(source=edge.source).select_related("target", "handover")
                 if e.target.batch != e.source.batch]
    prod_edges = [e for e in out_edges
                  if e.mlog_id or e.relation_type == "split" or ZT_BIG_BATCH_RE.match(e.target.batch) or e.id == edge.id]
    if len(prod_edges) <= 1:
        return Decimal(1)
    total = Decimal(0)
    for e in prod_edges:
        e_qty = _zt_edge_qty(e, e.target.batch)
        if e_qty:
            total += Decimal(e_qty)
        else:
            myLogger.error(f"直通统计-{e.source.batch}->{e.target.batch}流出边数量缺失, 分摊占比可能偏大")
    if not total:
        return Decimal(1)
    return min(Decimal(qty) / total, Decimal(1))


def _zt_white_count(batchst: BatchSt, depth=0, path=frozenset(), memo=None):
    """回溯计算该批对应的白料数(进炉前数量)

    返回 (白料数, 口径set); 不可得时白料数为 None。
    分摊: 来源批白料 p_white 按 qty 占其生产主线流出量的比例分给当前批。
    memo 按批次号缓存本次回溯的中间结果, 网状/菱形谱系保持线性复杂度;
    经防环截断的节点结果与路径相关, 不入缓存。visits 超限直接放弃(隔离病态谱系)。
    """
    white, kinds, _clean = _zt_white_walk(batchst, depth, path,
                                          memo if memo is not None else {"visits": 0, "res": {}})
    return white, kinds


def _zt_white_walk(batchst: BatchSt, depth, path, memo):
    """返回 (白料数, 口径set, 结果是否未经防环截断可缓存)"""
    if batchst.batch in memo["res"]:
        return memo["res"][batchst.batch]
    if depth > ZT_MAX_DEPTH:
        return None, {"超出回溯深度"}, False
    memo["visits"] += 1
    if memo["visits"] > ZT_MAX_VISITS:
        return None, {"回溯规模超限"}, False
    path = path | {batchst.batch}

    # 锚点: 该批有锚点工段产出报工, 白料数=锚点工段领用数
    anchor_qs = Mlogb.objects.filter(
        batch=batchst.batch, material_out__isnull=False, need_inout=True,
        mlog__mgroup__name__in=ZT_ANCHOR_MGROUPS, mlog__submit_time__isnull=False,
        mlog__is_fix=False).select_related("mlogb_from", "mlog__mgroup")
    white = Decimal(0)
    kinds = set()
    found_anchor = False
    for row in anchor_qs:
        found_anchor = True
        white += row.mlogb_from.count_use if row.mlogb_from else row.count_real
        kinds.add(f"{row.mlog.mgroup.name}领用")
    if found_anchor:
        memo["res"][batchst.batch] = (white, kinds, True)
        return white, kinds, True

    all_edges = list(BatchLog.objects.filter(target=batchst).select_related("source", "handover"))
    edges = [e for e in all_edges if e.source.batch not in path]  # 跳过自环/已访问节点(同名重复改号、合回上游等)
    clean = len(edges) == len(all_edges)
    if not edges:
        # 无上游谱系: 兜底汇总原料性入库数量(采购/其他入库, 排除生产入库)
        mio_total = MIOItem.objects.filter(
            batch=batchst.batch, mio__submit_time__isnull=False,
            mio__type__in=["pur_in", "other_in"]).aggregate(t=Sum("count"))["t"]
        if mio_total:
            if clean:
                memo["res"][batchst.batch] = (mio_total, {"入库数量"}, True)
            return mio_total, {"入库数量"}, clean
        # BatchLog 上线前的老批次没有边, 但 BatchSt 记录了创建它的交接/报工, 由此续上回溯
        legacy = _zt_white_legacy(batchst, depth, path, memo)
        if legacy is not None:
            return legacy
        return None, {f"{batchst.batch}无上游谱系且无入库数量"}, False

    white_total = Decimal(0)
    for edge in edges:
        qty = _zt_edge_qty(edge, batchst.batch)
        if not qty:
            return None, {f"{edge.source.batch}->{batchst.batch}边数量缺失"}, False
        p_white, p_kinds, p_clean = _zt_white_walk(edge.source, depth + 1, path, memo)
        if p_white is None:
            return None, p_kinds, False
        clean = clean and p_clean
        white_total += Decimal(p_white) * _zt_source_out_ratio(edge, qty)
        kinds |= p_kinds
    if clean:
        memo["res"][batchst.batch] = (white_total, kinds, True)
    return white_total, kinds, clean


def _zt_white_legacy(batchst: BatchSt, depth, path, memo):
    """BatchLog 上线前的老批次没有拆合批边, 根据 BatchSt 记录的创建来源续上回溯

    可解时返回 (白料数, 口径set, 可缓存标记), 否则返回 None。
    """
    src_batch = None
    qty = None
    ratio = Decimal(1)
    if batchst.handover_id and batchst.handover.wm_id:
        # 拆批交接创建: 来源为交接的车间库存批, 按同单各行数量占比分摊
        src_batch = batchst.handover.wm.batch
        own = Handoverb.objects.filter(handover_id=batchst.handover_id, batch=batchst.batch).aggregate(t=Sum("count"))["t"]
        total = Handoverb.objects.filter(handover_id=batchst.handover_id).aggregate(t=Sum("count"))["t"]
        qty = own or batchst.handover.count
        if own and total and total > own:
            ratio = Decimal(own) / Decimal(total)
    elif batchst.mlog_id:
        # 报工改号创建: 来源为报工的投入批
        row = Mlogb.objects.filter(mlog_id=batchst.mlog_id, batch=batchst.batch,
                                   material_out__isnull=False).select_related("mlogb_from").first()
        if row and row.mlogb_from:
            src_batch = row.mlogb_from.batch
            qty = row.mlogb_from.count_use
    if not src_batch or src_batch in path or not qty:
        return None
    try:
        src_bs = BatchSt.objects.get(batch=src_batch, version=1)
    except BatchSt.DoesNotExist:
        return None
    p_white, p_kinds, p_clean = _zt_white_walk(src_bs, depth + 1, path, memo)
    if p_white is None:
        return None
    return p_white * ratio, p_kinds | {"谱系兜底"}, p_clean


def cal_zt_big(big_batch: str, use_white_cache=True):
    """重算大批的直通良率并写入其 BatchSt.data

    use_white_cache=False 时忽略已缓存的白料数强制重新回溯(供回刷纠错)。
    """
    try:
        big_bs = BatchSt.objects.get(batch=big_batch, version=1)
    except BatchSt.DoesNotExist:
        myLogger.error(f"直通统计-大批{big_batch}不存在")
        return
    if big_bs.zt_batch != big_batch:
        big_bs.zt_batch = big_batch
        big_bs.save(update_fields=["zt_batch", "update_time"])

    # 从大批侧发现子批: 拆批目标 + 完全由本大批子批合并出的复检批(如 大批-A-1), 支持多级
    known = {big_batch}
    sub_map = {}
    frontier_ids = [big_bs.id]
    for _ in range(3):
        new_nodes = []
        merge_targets = {}
        for e in BatchLog.objects.filter(source_id__in=frontier_ids).select_related("target"):
            t = e.target
            if t.batch in known:
                continue
            if e.relation_type == "split" and e.handover_id:
                known.add(t.batch)
                sub_map[t.batch] = t
                new_nodes.append(t)
            elif e.relation_type == "merge":
                merge_targets[t.id] = t
        if merge_targets:
            src_by_target = {}
            for tid, s_batch, s_zt in BatchLog.objects.filter(
                    target_id__in=merge_targets.keys(), relation_type="merge"
            ).values_list("target_id", "source__batch", "source__zt_batch"):
                src_by_target.setdefault(tid, []).append((s_batch, s_zt))
            for tid, t in merge_targets.items():
                if t.batch in known:
                    continue
                pairs = src_by_target.get(tid, [])
                if pairs and all(s_batch in known or s_zt == big_batch for s_batch, s_zt in pairs):
                    known.add(t.batch)
                    sub_map[t.batch] = t
                    new_nodes.append(t)
        frontier_ids = [t.id for t in new_nodes]
        if not frontier_ids:
            break
    # 锁定归属
    for b, t in sub_map.items():
        if t.zt_batch and t.zt_batch != big_batch:
            myLogger.error(f"直通统计-{b}已归属{t.zt_batch}, 与{big_batch}冲突, 跳过")
            continue
        if t.zt_batch != big_batch:
            t.zt_batch = big_batch
            t.save(update_fields=["zt_batch", "update_time"])

    # 分子: 所有归属本大批的批次(检验小批/复检批)外观检验直通合格数之和
    count_zt = 0
    sub_batches = []
    for b, v in BatchSt.objects.filter(zt_batch=big_batch, version=1).exclude(
            batch=big_batch).values_list("batch", "data__外观检验_直通合格数"):
        sub_batches.append(b)
        count_zt += v or 0
    # 大批未拆批直接检验时自身也计入
    count_zt += (big_bs.data or {}).get("外观检验_直通合格数", 0) or 0

    # 分母: 白料数(大批成型后基本不变, 已算过的直接复用, 含算不出的负缓存;
    # 大批自身重算时 main 会重建 data, 缓存自然失效并重新回溯)
    data = big_bs.data or {}
    if use_white_cache and "直通_白料数" in data:
        white = data.get("直通_白料数")
        white = Decimal(str(white)) if white else None
        kinds = set((data.get("直通_口径") or "").split(";")) - {"", "谱系异常(合格数大于白料数)"}
    else:
        white, kinds = _zt_white_count(big_bs)
        if not white:
            myLogger.error(f"直通统计-{big_batch}白料数不可得: {kinds}")

    data["直通_子批次"] = ";".join(sorted(sub_batches))
    data["直通_总合格数"] = count_zt
    data["直通_口径"] = ";".join(sorted(kinds))
    if white and Decimal(str(count_zt)) > white:
        # 分子超过分母说明谱系/分摊异常(如白料池循环整理导致回溯不全), 隔离不出良率
        myLogger.error(f"直通统计-{big_batch}总合格数{count_zt}大于白料数{white}, 谱系或分摊异常")
        data["直通_白料数"] = round(white, 1)
        data["直通_良率"] = None
        data["直通_口径"] = ";".join(sorted(kinds | {"谱系异常(合格数大于白料数)"}))
    elif white:
        data["直通_白料数"] = round(white, 1)
        try:
            data["直通_良率"] = round((Decimal(str(count_zt)) / white) * 100, 2)
        except (decimal.InvalidOperation, ZeroDivisionError):
            data["直通_良率"] = 0
    else:
        data["直通_白料数"] = None
        data["直通_良率"] = None
    big_bs.data = json.loads(json.dumps(data, cls=MyJSONEncoder))
    big_bs.save(update_fields=["data", "update_time"])
    return {"白料数": big_bs.data.get("直通_白料数"), "总合格数": big_bs.data.get("直通_总合格数"),
            "良率": big_bs.data.get("直通_良率"), "口径": big_bs.data.get("直通_口径"), "子批数": len(sub_batches)}


if __name__ == '__main__':
    pass