package logic import ( "context" "bj_power_mes/ent/bomitem" ) // ============================================================ // BOM 展开统一核心(R1 工程债,2026-09-19) // ============================================================ // // 背景:备料算料(expandBomToLeaf)与追溯物料组成(expandBindBom)原先各自实现了一套 // 递归展开,两套的"是否继续下钻"判定不同—— // - 备料:子项在 WMS 的品类为成品(3) 才继续展开; // - 追溯:本地存在该编码的下级物料清单才继续展开。 // 结果是同一产品在"备料料单"和"追溯物料组成"里可能呈现不同结构,追溯断链。 // // 此处抽出唯一递归骨架,两处共用同一份实现:深度上限、防环、用量倍率相乘、日志口径 // 完全一致;下钻判定与 BOM 名称策略由调用方以 resolver/参数注入,保证业务口径不变。 // bomNode BOM 展开的中间态树节点(备料拍平为叶子、追溯直接展示层级)。 type bomNode struct { MaterialCode string MaterialName string Spec string Unit string ManageMode string UnitQty float64 // 已按路径倍率折算的单台用量 LossRate float64 ProcessCode int Level int // 1=顶层物料,2+=下级 Children []bomNode } // bomMaxDepth 展开深度上限(防配置错误导致的无界递归)。 const bomMaxDepth = 10 // bomCompositeResolver 批量判定本层哪些子项属于"组合件"(需继续下钻展开其自身清单)。 // 每层只调用一次,避免逐条查 WMS/库造成 N+1。 type bomCompositeResolver func(ctx context.Context, productCode string, codes []string) map[string]bool // expandBomTree 统一递归展开核心。 // - productCode: 当前层产品/组合件编码; // - bomName: 物料清单名称("" 由 ListBom 决定不过滤,具体策略由调用方传入); // - mult: 父级累计倍率; // - path: 当前展开路径(防环,同路径重复编码视为配置错误并记日志)。 func (s *Service) expandBomTree(ctx context.Context, productCode, bomName string, mult float64, depth int, path map[string]bool, resolve bomCompositeResolver) []bomNode { if depth > bomMaxDepth || productCode == "" { return nil } bom, err := s.ListBom(ctx, productCode, bomName) if err != nil || len(bom) == 0 { return nil } codes := make([]string, 0, len(bom)) for _, b := range bom { if b.MaterialCode != "" { codes = append(codes, b.MaterialCode) } } var composite map[string]bool if resolve != nil { composite = resolve(ctx, productCode, codes) } out := make([]bomNode, 0, len(bom)) for _, b := range bom { if b.MaterialCode == "" { continue } if path[b.MaterialCode] { s.ctx.EventLog.Write(ctx, "bom.expand.cycle", "", "", "product_bom", productCode, "产品物料清单存在循环引用,已跳过", map[string]any{"materialCode": b.MaterialCode, "depth": depth}) continue } unitQty := b.UnitQty * mult node := bomNode{ MaterialCode: b.MaterialCode, MaterialName: b.MaterialName, Spec: b.Spec, Unit: b.Unit, ManageMode: b.ManageMode, UnitQty: unitQty, LossRate: b.LossRate, ProcessCode: b.ProcessCode, Level: depth, } if composite[b.MaterialCode] { path[b.MaterialCode] = true node.Children = s.expandBomTree(ctx, b.MaterialCode, bomName, unitQty, depth+1, path, resolve) delete(path, b.MaterialCode) // 组合件没有自身清单(或展开为空):按叶子处理,避免漏料 if len(node.Children) == 0 { node.Children = nil } } out = append(out, node) } return out } // bomTreeToLeaf 把展开树拍平为叶子清单(备料算料用):组合件递归下沉,叶子原样输出。 func bomTreeToLeaf(nodes []bomNode) []bomLeaf { out := make([]bomLeaf, 0, len(nodes)) for _, n := range nodes { if len(n.Children) > 0 { out = append(out, bomTreeToLeaf(n.Children)...) continue } out = append(out, bomLeaf{ materialCode: n.MaterialCode, materialName: n.MaterialName, unit: n.Unit, manageMode: n.ManageMode, unitQty: n.UnitQty, lossRate: n.LossRate, processCode: n.ProcessCode, }) } return out } // bomTreeToBindNodes 把展开树转为追溯展示节点(保留层级与用量)。 func bomTreeToBindNodes(nodes []bomNode) []BindNode { out := make([]BindNode, 0, len(nodes)) for _, n := range nodes { node := BindNode{ MaterialCode: n.MaterialCode, MaterialName: n.MaterialName, Spec: n.Spec, Unit: n.Unit, ManageMode: n.ManageMode, Qty: n.UnitQty, Level: n.Level, } if len(n.Children) > 0 { node.Children = bomTreeToBindNodes(n.Children) } out = append(out, node) } return out } // wmsFinishedResolver 备料侧下钻判定:子项在 WMS 的品类为成品(3) 时继续展开。 // 客户口径:成品可作为零部件被再次加工,备料必须展开到原材料,否则产线缺料。 // WMS 不可达时返回空集(全部按叶子处理),不阻塞备料生成,并记日志。 func (s *Service) wmsFinishedResolver(ctx context.Context, productCode string, codes []string) map[string]bool { out := map[string]bool{} if s.ctx.Wms == nil || len(codes) == 0 { return out } types, err := s.ctx.Wms.MaterialTypes(ctx, codes) if err != nil { s.ctx.EventLog.Write(ctx, "bom.expand.wms_skip", "", "", "product_bom", productCode, "WMS 物料品类查询不可达,本次按叶子物料展开", map[string]any{"error": err.Error()}) return out } for _, c := range codes { if types[c] == 3 { out[c] = true } } return out } // localBomExistsResolver 追溯侧下钻判定:本地存在该编码的下级物料清单时继续展开。 // 不依赖 WMS(追溯页在 WMS 不可达时也必须能展示完整物料组成)。 func (s *Service) localBomExistsResolver(ctx context.Context, _ string, codes []string) map[string]bool { out := map[string]bool{} for _, c := range codes { if has, _ := s.ctx.EntClient.BomItem.Query().Where(bomitem.ProductCode(c)).Exist(ctx); has { out[c] = true } } return out }