fix:白料回溯记忆化+访问上限, 入库兜底汇总原料性入库

- 回溯按批次号记忆化, 修复遗留无损批网状谱系导致的指数爆炸卡死
- 单次回溯超3000节点放弃并记口径"回溯规模超限"
- 入库兜底改为汇总pur_in/other_in全部入库明细, 修复多次入库低估
- save(update_fields)补update_time使auto_now生效

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-14 10:21:54 +08:00
parent fb5f1d072d
commit d1c7ffb598
4 changed files with 55 additions and 32 deletions

View File

@ -241,8 +241,9 @@ FtestWork where type2 = TYPE2_SOME
- **锚点**:批次存在锚点工段(`ZT_ANCHOR_MGROUPS=["黑化"]`)产出报工时停止,白料数 = 该批锚点工段 `mlogb_from.count_use`(进炉前领用数)。多次黑化的链取离大批最近的一次。 - **锚点**:批次存在锚点工段(`ZT_ANCHOR_MGROUPS=["黑化"]`)产出报工时停止,白料数 = 该批锚点工段 `mlogb_from.count_use`(进炉前领用数)。多次黑化的链取离大批最近的一次。
- **分摊**`_zt_source_out_ratio`):来源批有多条**生产主线**流出边(报工改号/拆批/汇入大批格式 `ZT_BIG_BATCH_RE` 的合批)时,按各去向数量占比分摊;汇入不良集中批(如 `黑检片-00`)的旁路不占白料份额,使炉后不良计入生产主线分母。单一去向全额传递。 - **分摊**`_zt_source_out_ratio`):来源批有多条**生产主线**流出边(报工改号/拆批/汇入大批格式 `ZT_BIG_BATCH_RE` 的合批)时,按各去向数量占比分摊;汇入不良集中批(如 `黑检片-00`)的旁路不占白料份额,使炉后不良计入生产主线分母。单一去向全额传递。
- **边数量**:拆批取 `Handoverb(batch=目标批)`,合批取 `Handoverb(batch=来源批)`,报工改号取目标产出行的 `mlogb_from.count_use`;无明细时兜底整单数量。 - **边数量**:拆批取 `Handoverb(batch=目标批)`,合批取 `Handoverb(batch=来源批)`,报工改号取目标产出行的 `mlogb_from.count_use`;无明细时兜底整单数量。
- **外购兜底**:无上游谱系且由入库创建 → 白料数 = 入库数量(`MIOItem.count`),口径记 `入库数量` - **外购/遗留兜底**:无上游谱系 → 白料数 = 该批原料性入库数量之和(`MIOItem`,仅 `pur_in`/`other_in`,排除生产入库),口径记 `入库数量`
- **老数据兜底**`_zt_white_legacy`BatchLog 上线前的批次无边,沿 `BatchSt.handover/mlog` 创建来源续上回溯,口径记 `谱系兜底` - **老数据兜底**`_zt_white_legacy`BatchLog 上线前的批次无边且无入库,沿 `BatchSt.handover/mlog` 创建来源续上回溯,口径记 `谱系兜底`
- **性能护栏**:回溯按批次号记忆化(网状/菱形谱系线性复杂度,防环截断过的节点不入缓存);单次回溯节点访问超 `ZT_MAX_VISITS=3000` 时放弃,口径记 `回溯规模超限`(负缓存,不重复付代价)。
### 缓存与隔离 ### 缓存与隔离

View File

@ -22,6 +22,7 @@ myLogger = logging.getLogger("log")
# (汇入不良集中批的旁路不占份额), 占比截断为1; 无锚点谱系(如外购半成品)时兜底用入库数量。 # (汇入不良集中批的旁路不占份额), 占比截断为1; 无锚点谱系(如外购半成品)时兜底用入库数量。
ZT_ANCHOR_MGROUPS = ["黑化"] # 入料数锚点工段 ZT_ANCHOR_MGROUPS = ["黑化"] # 入料数锚点工段
ZT_MAX_DEPTH = 12 # 白料回溯最大层数 ZT_MAX_DEPTH = 12 # 白料回溯最大层数
ZT_MAX_VISITS = 3000 # 单个大批白料回溯的节点访问上限(防病态网状谱系组合爆炸)
ZT_BIG_BATCH_RE = re.compile(r"^\d{4}-[0-9A-Za-z.]+-\d+") # 大批批号格式(合批改名产生), 用于区分生产分流与不良汇集 ZT_BIG_BATCH_RE = re.compile(r"^\d{4}-[0-9A-Za-z.]+-\d+") # 大批批号格式(合批改名产生), 用于区分生产分流与不良汇集
def main(batch: str, mgroup_obj): def main(batch: str, mgroup_obj):
@ -214,7 +215,7 @@ def handle_zt(batchst: BatchSt, data: dict):
big_batch = resolve_zt_big(batchst, data) big_batch = resolve_zt_big(batchst, data)
if big_batch and batchst.zt_batch != big_batch: if big_batch and batchst.zt_batch != big_batch:
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(): if big_batch is None and BatchSt.objects.filter(zt_batch=batchst.batch, version=1).exclude(id=batchst.id).exists():
# 当前批自身是大批(已有子批归属于它), 其数据变化(如白料数)也需重算 # 当前批自身是大批(已有子批归属于它), 其数据变化(如白料数)也需重算
big_batch = batchst.batch big_batch = batchst.batch
@ -303,14 +304,28 @@ def _zt_source_out_ratio(edge: BatchLog, qty):
return min(Decimal(qty) / total, Decimal(1)) 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 返回 (白料数, 口径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: 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} 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 white += row.mlogb_from.count_use if row.mlogb_from else row.count_real
kinds.add(f"{row.mlog.mgroup.name}领用") kinds.add(f"{row.mlog.mgroup.name}领用")
if found_anchor: 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") all_edges = list(BatchLog.objects.filter(target=batchst).select_related("source", "handover"))
if e.source.batch not in path] # 跳过自环/已访问节点(同名重复改号、合回上游等) edges = [e for e in all_edges if e.source.batch not in path] # 跳过自环/已访问节点(同名重复改号、合回上游等)
clean = len(edges) == len(all_edges)
if not edges: if not edges:
# 无上游谱系: 入库创建的批(如外购半成品)兜底用入库数量 # 无上游谱系: 兜底汇总原料性入库数量(采购/其他入库, 排除生产入库)
mioitem = batchst.mioitem mio_total = MIOItem.objects.filter(
if mioitem is None and batchst.mio_id: batch=batchst.batch, mio__submit_time__isnull=False,
mioitem = MIOItem.objects.filter(mio_id=batchst.mio_id, batch=batchst.batch).first() mio__type__in=["pur_in", "other_in"]).aggregate(t=Sum("count"))["t"]
if mioitem and mioitem.count: if mio_total:
return mioitem.count, {"入库数量"} if clean:
memo["res"][batchst.batch] = (mio_total, {"入库数量"}, True)
return mio_total, {"入库数量"}, clean
# BatchLog 上线前的老批次没有边, 但 BatchSt 记录了创建它的交接/报工, 由此续上回溯 # BatchLog 上线前的老批次没有边, 但 BatchSt 记录了创建它的交接/报工, 由此续上回溯
legacy = _zt_white_legacy(batchst, depth, path) legacy = _zt_white_legacy(batchst, depth, path, memo)
if legacy is not None: if legacy is not None:
return legacy return legacy
return None, {f"{batchst.batch}无上游谱系且无入库数量"} return None, {f"{batchst.batch}无上游谱系且无入库数量"}, False
white_total = Decimal(0) white_total = Decimal(0)
for edge in edges: for edge in edges:
qty = _zt_edge_qty(edge, batchst.batch) qty = _zt_edge_qty(edge, batchst.batch)
if not qty: if not qty:
return None, {f"{edge.source.batch}->{batchst.batch}边数量缺失"} return None, {f"{edge.source.batch}->{batchst.batch}边数量缺失"}, False
p_white, p_kinds = _zt_white_count(edge.source, depth + 1, path) p_white, p_kinds, p_clean = _zt_white_walk(edge.source, depth + 1, path, memo)
if p_white is None: 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) white_total += Decimal(p_white) * _zt_source_out_ratio(edge, qty)
kinds |= p_kinds 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 记录的创建来源续上回溯 """BatchLog 上线前的老批次没有拆合批边, 根据 BatchSt 记录的创建来源续上回溯
可解时返回 (白料数, 口径set), 否则返回 None 可解时返回 (白料数, 口径set, 可缓存标记), 否则返回 None
""" """
src_batch = None src_batch = None
qty = 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) src_bs = BatchSt.objects.get(batch=src_batch, version=1)
except BatchSt.DoesNotExist: except BatchSt.DoesNotExist:
return None 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: if p_white is None:
return None return None
return p_white * ratio, p_kinds | {"谱系兜底"} return p_white * ratio, p_kinds | {"谱系兜底"}, p_clean
def cal_zt_big(big_batch: str): def cal_zt_big(big_batch: str):
@ -400,7 +422,7 @@ def cal_zt_big(big_batch: str):
return return
if big_bs.zt_batch != big_batch: if big_bs.zt_batch != big_batch:
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), 支持多级 # 从大批侧发现子批: 拆批目标 + 完全由本大批子批合并出的复检批(如 大批-A-1), 支持多级
known = {big_batch} known = {big_batch}
@ -434,7 +456,7 @@ def cal_zt_big(big_batch: str):
continue continue
if t.zt_batch != big_batch: if t.zt_batch != big_batch:
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 count_zt = 0
@ -476,7 +498,7 @@ def cal_zt_big(big_batch: str):
data["直通_白料数"] = None data["直通_白料数"] = None
data["直通_良率"] = None data["直通_良率"] = None
big_bs.data = json.loads(json.dumps(data, cls=MyJSONEncoder)) 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__': if __name__ == '__main__':

View File

@ -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}冲突, 不覆盖") myLogger.error(f"直通统计-{target.batch}已归属{target.zt_batch}, 与来源{source.batch}{source.zt_batch}冲突, 不覆盖")
return return
target.zt_batch = source.zt_batch 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): def check_wpr_number(number: str):
return (len(number) >= 5 and return (len(number) >= 5 and

View File

@ -26,7 +26,7 @@ import json
from apps.utils.tools import MyJSONEncoder 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( qs = BatchSt.objects.filter(
create_time__gte=datetime.strptime(since, "%Y-%m-%d"), version=1 create_time__gte=datetime.strptime(since, "%Y-%m-%d"), version=1
).filter( ).filter(
@ -42,7 +42,7 @@ def main(since: str = "2025-06-17", fresh: bool = False):
big = resolve_zt_big(bs, bs.data or {}) big = resolve_zt_big(bs, bs.data or {})
if big and bs.zt_batch != big: if big and bs.zt_batch != big:
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: except Exception as e:
print(f"[{ind + 1}/{total}] {bs.batch} 归属解析失败: {e}") print(f"[{ind + 1}/{total}] {bs.batch} 归属解析失败: {e}")
continue continue
@ -61,7 +61,7 @@ def main(since: str = "2025-06-17", fresh: bool = False):
data = big_bs.data data = big_bs.data
data.pop("直通_白料数", None) data.pop("直通_白料数", None)
big_bs.data = json.loads(json.dumps(data, cls=MyJSONEncoder)) 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) cal_zt_big(big)
except Exception as e: except Exception as e:
print(f"{big} 计算失败: {e}") print(f"{big} 计算失败: {e}")