diff --git a/apps/wpm/scripts/batch_gxerp.md b/apps/wpm/scripts/batch_gxerp.md index bf6bd051..175f672c 100644 --- a/apps/wpm/scripts/batch_gxerp.md +++ b/apps/wpm/scripts/batch_gxerp.md @@ -241,8 +241,9 @@ FtestWork where type2 = TYPE2_SOME - **锚点**:批次存在锚点工段(`ZT_ANCHOR_MGROUPS=["黑化"]`)产出报工时停止,白料数 = 该批锚点工段 `mlogb_from.count_use`(进炉前领用数)。多次黑化的链取离大批最近的一次。 - **分摊**(`_zt_source_out_ratio`):来源批有多条**生产主线**流出边(报工改号/拆批/汇入大批格式 `ZT_BIG_BATCH_RE` 的合批)时,按各去向数量占比分摊;汇入不良集中批(如 `黑检片-00`)的旁路不占白料份额,使炉后不良计入生产主线分母。单一去向全额传递。 - **边数量**:拆批取 `Handoverb(batch=目标批)`,合批取 `Handoverb(batch=来源批)`,报工改号取目标产出行的 `mlogb_from.count_use`;无明细时兜底整单数量。 -- **外购兜底**:无上游谱系且由入库创建 → 白料数 = 入库数量(`MIOItem.count`),口径记 `入库数量`。 -- **老数据兜底**(`_zt_white_legacy`):BatchLog 上线前的批次无边,沿 `BatchSt.handover/mlog` 创建来源续上回溯,口径记 `谱系兜底`。 +- **外购/遗留兜底**:无上游谱系 → 白料数 = 该批原料性入库数量之和(`MIOItem`,仅 `pur_in`/`other_in`,排除生产入库),口径记 `入库数量`。 +- **老数据兜底**(`_zt_white_legacy`):BatchLog 上线前的批次无边且无入库,沿 `BatchSt.handover/mlog` 创建来源续上回溯,口径记 `谱系兜底`。 +- **性能护栏**:回溯按批次号记忆化(网状/菱形谱系线性复杂度,防环截断过的节点不入缓存);单次回溯节点访问超 `ZT_MAX_VISITS=3000` 时放弃,口径记 `回溯规模超限`(负缓存,不重复付代价)。 ### 缓存与隔离 diff --git a/apps/wpm/scripts/batch_gxerp.py b/apps/wpm/scripts/batch_gxerp.py index 9d01eb6c..f95324dd 100644 --- a/apps/wpm/scripts/batch_gxerp.py +++ b/apps/wpm/scripts/batch_gxerp.py @@ -22,6 +22,7 @@ myLogger = logging.getLogger("log") # (汇入不良集中批的旁路不占份额), 占比截断为1; 无锚点谱系(如外购半成品)时兜底用入库数量。 ZT_ANCHOR_MGROUPS = ["黑化"] # 入料数锚点工段 ZT_MAX_DEPTH = 12 # 白料回溯最大层数 +ZT_MAX_VISITS = 3000 # 单个大批白料回溯的节点访问上限(防病态网状谱系组合爆炸) ZT_BIG_BATCH_RE = re.compile(r"^\d{4}-[0-9A-Za-z.]+-\d+") # 大批批号格式(合批改名产生), 用于区分生产分流与不良汇集 def main(batch: str, mgroup_obj): @@ -214,7 +215,7 @@ 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"]) + 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 @@ -303,14 +304,28 @@ def _zt_source_out_ratio(edge: BatchLog, qty): return min(Decimal(qty) / total, Decimal(1)) -def _zt_white_count(batchst: BatchSt, depth=0, path=frozenset()): +def _zt_white_count(batchst: BatchSt, depth=0, path=frozenset(), memo=None): """回溯计算该批对应的白料数(进炉前数量) 返回 (白料数, 口径set); 不可得时白料数为 None。 - 分摊: 来源批白料 p_white 按 qty 占其全部流出量的比例分给当前批。 + 分摊: 来源批白料 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, {"超出回溯深度"} + return None, {"超出回溯深度"}, False + memo["visits"] += 1 + if memo["visits"] > ZT_MAX_VISITS: + return None, {"回溯规模超限"}, False path = path | {batchst.batch} # 锚点: 该批有锚点工段产出报工, 白料数=锚点工段领用数 @@ -326,40 +341,47 @@ def _zt_white_count(batchst: BatchSt, depth=0, path=frozenset()): white += row.mlogb_from.count_use if row.mlogb_from else row.count_real kinds.add(f"{row.mlog.mgroup.name}领用") if found_anchor: - return white, kinds + memo["res"][batchst.batch] = (white, kinds, True) + return white, kinds, True - edges = [e for e in BatchLog.objects.filter(target=batchst).select_related("source", "handover") - if e.source.batch not in path] # 跳过自环/已访问节点(同名重复改号、合回上游等) + 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: - # 无上游谱系: 入库创建的批(如外购半成品)兜底用入库数量 - mioitem = batchst.mioitem - if mioitem is None and batchst.mio_id: - mioitem = MIOItem.objects.filter(mio_id=batchst.mio_id, batch=batchst.batch).first() - if mioitem and mioitem.count: - return mioitem.count, {"入库数量"} + # 无上游谱系: 兜底汇总原料性入库数量(采购/其他入库, 排除生产入库) + 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) + legacy = _zt_white_legacy(batchst, depth, path, memo) if legacy is not None: return legacy - return None, {f"{batchst.batch}无上游谱系且无入库数量"} + 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}边数量缺失"} - p_white, p_kinds = _zt_white_count(edge.source, depth + 1, path) + 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 + return None, p_kinds, False + clean = clean and p_clean white_total += Decimal(p_white) * _zt_source_out_ratio(edge, qty) kinds |= p_kinds - return white_total, kinds + if clean: + memo["res"][batchst.batch] = (white_total, kinds, True) + return white_total, kinds, clean -def _zt_white_legacy(batchst: BatchSt, depth, path): +def _zt_white_legacy(batchst: BatchSt, depth, path, memo): """BatchLog 上线前的老批次没有拆合批边, 根据 BatchSt 记录的创建来源续上回溯 - 可解时返回 (白料数, 口径set), 否则返回 None。 + 可解时返回 (白料数, 口径set, 可缓存标记), 否则返回 None。 """ src_batch = None qty = None @@ -385,10 +407,10 @@ def _zt_white_legacy(batchst: BatchSt, depth, path): src_bs = BatchSt.objects.get(batch=src_batch, version=1) except BatchSt.DoesNotExist: return None - p_white, p_kinds = _zt_white_count(src_bs, depth + 1, path) + 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 | {"谱系兜底"} + return p_white * ratio, p_kinds | {"谱系兜底"}, p_clean def cal_zt_big(big_batch: str): @@ -400,7 +422,7 @@ def cal_zt_big(big_batch: str): return if big_bs.zt_batch != big_batch: big_bs.zt_batch = big_batch - big_bs.save(update_fields=["zt_batch"]) + big_bs.save(update_fields=["zt_batch", "update_time"]) # 从大批侧发现子批: 拆批目标 + 完全由本大批子批合并出的复检批(如 大批-A-1), 支持多级 known = {big_batch} @@ -434,7 +456,7 @@ def cal_zt_big(big_batch: str): continue if t.zt_batch != big_batch: t.zt_batch = big_batch - t.save(update_fields=["zt_batch"]) + t.save(update_fields=["zt_batch", "update_time"]) # 分子: 所有归属本大批的批次(检验小批/复检批)外观检验直通合格数之和 count_zt = 0 @@ -476,7 +498,7 @@ def cal_zt_big(big_batch: str): data["直通_白料数"] = None data["直通_良率"] = None big_bs.data = json.loads(json.dumps(data, cls=MyJSONEncoder)) - big_bs.save(update_fields=["data"]) + big_bs.save(update_fields=["data", "update_time"]) if __name__ == '__main__': diff --git a/apps/wpm/services.py b/apps/wpm/services.py index a9250370..796cbd85 100644 --- a/apps/wpm/services.py +++ b/apps/wpm/services.py @@ -38,7 +38,7 @@ def inherit_zt_batch(source: BatchSt, target: BatchSt): myLogger.error(f"直通统计-{target.batch}已归属{target.zt_batch}, 与来源{source.batch}的{source.zt_batch}冲突, 不覆盖") return target.zt_batch = source.zt_batch - target.save(update_fields=["zt_batch"]) + target.save(update_fields=["zt_batch", "update_time"]) def check_wpr_number(number: str): return (len(number) >= 5 and diff --git a/scripts/correct_zt_gxerp.py b/scripts/correct_zt_gxerp.py index 266b1590..8bc863e5 100644 --- a/scripts/correct_zt_gxerp.py +++ b/scripts/correct_zt_gxerp.py @@ -26,7 +26,7 @@ import json from apps.utils.tools import MyJSONEncoder -def main(since: str = "2025-06-17", fresh: bool = False): +def main(since: str = "2026-06-01", fresh: bool = False): qs = BatchSt.objects.filter( create_time__gte=datetime.strptime(since, "%Y-%m-%d"), version=1 ).filter( @@ -42,7 +42,7 @@ def main(since: str = "2025-06-17", fresh: bool = False): big = resolve_zt_big(bs, bs.data or {}) if big and bs.zt_batch != big: bs.zt_batch = big - bs.save(update_fields=["zt_batch"]) + bs.save(update_fields=["zt_batch", "update_time"]) except Exception as e: print(f"[{ind + 1}/{total}] {bs.batch} 归属解析失败: {e}") continue @@ -61,7 +61,7 @@ def main(since: str = "2025-06-17", fresh: bool = False): data = big_bs.data data.pop("直通_白料数", None) big_bs.data = json.loads(json.dumps(data, cls=MyJSONEncoder)) - big_bs.save(update_fields=["data"]) + big_bs.save(update_fields=["data", "update_time"]) cal_zt_big(big) except Exception as e: print(f"{big} 计算失败: {e}")