package logic import ( "context" "fmt" "math" "sort" "strconv" "strings" "time" "bj_power_mes/ent/dailyplan" "bj_power_mes/ent/workorder" "bj_power_mes/internal/wmsclient" "github.com/zeromicro/go-zero/core/logx" ) // producibleBomItem 可生产数量计算用的精简 BOM 行 type producibleBomItem struct { materialCode string materialName string unitQty float64 // 单台用量 } // SyncProducibleToWMS 计算「可生产数量」快照 + 「未来5天物料需求」并推送到 WMS。 // // 业务口径(问题记录 L470-474 + L256): // - 可生产套数 = min(各物料可用量 ÷ 单台用量)。BOM 单台用量在 MES、库存可用量在 WMS, // 故由 MES 拉取 WMS 可用量(/api/internal/stock/check)后本地计算,再把快照推回 WMS 只读展示。 // - 未来5天需求量 = 近5天(含今日)日排产台数 × 单台用量,按物料汇总,供 WMS 备料四态「预警」判定。 // // 两系统独立运行约束:仅 MES→WMS 单向调用;未配置 WMS 或不可达时记录日志并跳过,绝不阻塞 MES。 func (s *Service) SyncProducibleToWMS(ctx context.Context) error { if s.ctx.Wms == nil || s.ctx.EntClient == nil { return nil } all, err := s.ctx.EntClient.BomItem.Query().All(ctx) if err != nil { return err } if len(all) == 0 { return nil // 无 BOM 不推送 } // 1) 按 产品编码 → BOM名称 → 明细行 分组 byProd := map[string]map[string][]producibleBomItem{} for _, b := range all { if b.ProductCode == "" || b.MaterialCode == "" { continue } bn := b.BomName if bn == "" { bn = DefaultBOMName } if byProd[b.ProductCode] == nil { byProd[b.ProductCode] = map[string][]producibleBomItem{} } byProd[b.ProductCode][bn] = append(byProd[b.ProductCode][bn], producibleBomItem{ materialCode: b.MaterialCode, materialName: b.MaterialName, unitQty: b.UnitQty, }) } // 2) 每个产品选定一份 BOM:优先「默认」,否则按名排序取第一份 chosen := map[string][]producibleBomItem{} materialSet := map[string]bool{} for pc, boms := range byProd { var bn string if _, has := boms[DefaultBOMName]; has { bn = DefaultBOMName } else { names := make([]string, 0, len(boms)) for n := range boms { names = append(names, n) } sort.Strings(names) bn = names[0] } chosen[pc] = boms[bn] for _, it := range boms[bn] { materialSet[it.materialCode] = true } } // 3) 拉取 WMS 各物料可用量 codes := make([]string, 0, len(materialSet)) for c := range materialSet { codes = append(codes, c) } sort.Strings(codes) avail, err := s.ctx.Wms.StockAvailable(ctx, codes) if err != nil { logx.Errorf("[producible] 拉取 WMS 可用量失败,跳过本次同步: %v", err) return err } // 4) 产品名称映射(product_type 优先,work_order 兜底) nameMap := map[string]string{} if pts, e := s.ctx.EntClient.ProductType.Query().All(ctx); e == nil { for _, p := range pts { nameMap[p.Code] = p.Name } } if wos, e := s.ctx.EntClient.WorkOrder.Query().All(ctx); e == nil { for _, wo := range wos { if wo.ProductCode != "" && wo.ProductName != "" { if _, has := nameMap[wo.ProductCode]; !has { nameMap[wo.ProductCode] = wo.ProductName } } } } // 5) 逐产品计算可生产套数 = min(floor(可用量 ÷ 单台用量)),短板物料取达最小值的前3 now := time.Now().Unix() prodCodes := make([]string, 0, len(chosen)) for pc := range chosen { prodCodes = append(prodCodes, pc) } sort.Strings(prodCodes) items := make([]wmsclient.ProducibleItem, 0, len(prodCodes)) for _, pc := range prodCodes { bom := chosen[pc] minSets := -1 type shortRow struct { text string set int } shorts := make([]shortRow, 0, len(bom)) for _, it := range bom { if it.unitQty <= 0 { continue } a := avail[it.materialCode] sets := int(math.Floor(float64(a) / it.unitQty)) if sets < 0 { sets = 0 } if minSets < 0 || sets < minSets { minSets = sets } name := it.materialName if name == "" { name = it.materialCode } shorts = append(shorts, shortRow{ text: fmt.Sprintf("%s(%s) 可用%d÷单台%s=%d套", name, it.materialCode, a, trimNum(it.unitQty), sets), set: sets, }) } if minSets < 0 { minSets = 0 // BOM 全无有效单台用量 } sort.SliceStable(shorts, func(i, j int) bool { return shorts[i].set < shorts[j].set }) pick := make([]string, 0, 3) for _, sr := range shorts { if sr.set == minSets && len(pick) < 3 { pick = append(pick, sr.text) } } items = append(items, wmsclient.ProducibleItem{ ProductCode: pc, ProductName: nameMap[pc], ProducibleQty: minSets, ShortText: strings.Join(pick, "; "), ComputedAt: now, }) } // 6) 未来5天物料需求(备料四态「预警」数据源) demands := s.computeFuture5Demand(ctx, chosen) if err := s.ctx.Wms.SyncProducible(ctx, items, demands); err != nil { logx.Errorf("[producible] 推送 WMS 失败: %v", err) return err } logx.Infof("[producible] 已同步可生产数量 %d 项、未来5天需求 %d 项到 WMS", len(items), len(demands)) return nil } // computeFuture5Demand 近5天(含今日)日排产台数 × 单台用量,按物料汇总为未来5天需求量。 // 数据链路:daily_plan(工单号→台数) → work_order(工单号→产品编码) → BOM(产品编码→物料单台用量)。 func (s *Service) computeFuture5Demand(ctx context.Context, chosen map[string][]producibleBomItem) []wmsclient.ProducibleDemand { today := time.Now() start := today.Format("2006-01-02") end := today.AddDate(0, 0, 4).Format("2006-01-02") plans, err := s.ctx.EntClient.DailyPlan.Query(). Where(dailyplan.PlanDateGTE(start), dailyplan.PlanDateLTE(end)).All(ctx) if err != nil || len(plans) == 0 { return nil } // 工单号 → 未来5天台数(排除已取消) orderByNo := map[string]int{} for _, p := range plans { if p.Status == "CANCELLED" { continue } orderByNo[p.OrderNo] += p.PlanQty } if len(orderByNo) == 0 { return nil } nos := make([]string, 0, len(orderByNo)) for no := range orderByNo { nos = append(nos, no) } wos, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNoIn(nos...)).All(ctx) if err != nil { return nil } // 产品编码 → 未来5天台数 prodQty := map[string]int{} for _, wo := range wos { if wo.ProductCode == "" { continue } prodQty[wo.ProductCode] += orderByNo[wo.WorkOrderNo] } // 物料汇总:台数 × 单台用量(向上取整) matQty := map[string]int{} for pc, qty := range prodQty { bom := chosen[pc] if qty <= 0 || len(bom) == 0 { continue } for _, it := range bom { if it.unitQty <= 0 { continue } matQty[it.materialCode] += int(math.Ceil(float64(qty) * it.unitQty)) } } if len(matQty) == 0 { return nil } mcodes := make([]string, 0, len(matQty)) for c := range matQty { mcodes = append(mcodes, c) } sort.Strings(mcodes) out := make([]wmsclient.ProducibleDemand, 0, len(mcodes)) for _, c := range mcodes { out = append(out, wmsclient.ProducibleDemand{MaterialCode: c, Future5Qty: matQty[c]}) } return out } // trimNum 单台用量展示:整数不带小数点,非整数保留原精度 func trimNum(f float64) string { if f == math.Trunc(f) { return strconv.Itoa(int(f)) } return strconv.FormatFloat(f, 'f', -1, 64) }