package logic import ( "context" "errors" "fmt" "math" "sort" "strconv" "strings" "time" "bj_power_mes/ent/dailyplan" "bj_power_mes/ent/workorder" "bj_power_mes/internal/wmsclient" ) // producibleBomItem 可生产数量计算用的精简 BOM 行 type producibleBomItem struct { materialCode string materialName string unit string // 计量单位(来自 BOM,与物料档案单位一致) unitQty float64 // 单台用量 } // ProducibleResult 可生产数量计算结果(供 WMS 按需实时拉取) type ProducibleResult struct { Items []wmsclient.ProducibleItem `json:"items"` Demands []wmsclient.ProducibleDemand `json:"demands"` Avail map[string]int `json:"avail"` // 各物料 WMS 可用量(供排产支撑逐日消耗模拟复用) ComputedAt int64 `json:"computedAt"` } // MaterialDemandRow MES 排产物料需求行(按物料汇总,未来 N 天窗口)。 // WMS「缺料补货」视图实时拉此数据,对比本地可用库存得出排产缺料量。 type MaterialDemandRow struct { MaterialCode string `json:"materialCode"` MaterialName string `json:"materialName"` Unit string `json:"unit"` DemandQty int `json:"demandQty"` // 未来 N 天排产总需求(台数×单台用量,向上取整) StartDate string `json:"startDate"` // 需求窗口起(YYYY-MM-DD) EndDate string `json:"endDate"` // 需求窗口止(YYYY-MM-DD) } // ProducibleSupportItem 排产支撑:单个成品的可生产套数与短板物料说明 type ProducibleSupportItem struct { ProductCode string `json:"productCode"` ProductName string `json:"productName"` ProducibleQty int `json:"producibleQty"` ShortText string `json:"shortText"` } // ProducibleSupportResult 排产支撑总览:逐成品可生产套数 + 整体还能满产多少天/支撑到哪天/最先告罄短板物料 type ProducibleSupportResult struct { Items []ProducibleSupportItem `json:"items"` SupportDays int `json:"supportDays"` SupportUntil string `json:"supportUntil"` ShortBoardMaterial string `json:"shortBoardMaterial"` ComputedAt int64 `json:"computedAt"` } // ComputeProducible 计算「可生产数量」+「未来5天物料需求」,结果直接返回(不做定时推送)。 // // 业务口径(问题记录 L470-474 + L256): // - 可生产套数 = min(各物料可用量 ÷ 单台用量)。BOM 单台用量在 MES、库存可用量在 WMS, // 故由 MES 拉取 WMS 可用量(/api/internal/stock/check)后本地计算,WMS 打开页面时实时来拉。 // - 未来5天需求量 = 近5天(含今日)日排产台数 × 单台用量,按物料汇总,供 WMS 备料四态「预警」判定。 // // 两系统独立运行约束:未配置 WMS 或不可达时返回错误,由调用方(WMS)提示,绝不阻塞 MES。 func (s *Service) ComputeProducible(ctx context.Context) (*ProducibleResult, error) { if s.ctx.Wms == nil || s.ctx.EntClient == nil { return nil, errors.New("未配置 WMS 地址,无法计算可生产数量") } chosen, materialSet, err := s.buildChosenBom(ctx) if err != nil { return nil, err } if len(chosen) == 0 { return &ProducibleResult{ Items: []wmsclient.ProducibleItem{}, Demands: []wmsclient.ProducibleDemand{}, Avail: map[string]int{}, ComputedAt: time.Now().Unix(), }, nil } // §A2:可生产套数只算启用成品(停用产品不再展示产能)。 // materialSet 保持完整——排产消耗模拟(simulateSupport)按真实排产消耗全部 BOM 物料。 s.dropInactiveProducts(ctx, chosen) // 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 { return nil, errors.New("拉取 WMS 可用量失败: " + err.Error()) } // 4) 产品名称映射(product_type 优先,work_order 兜底) nameMap := map[string]string{} if rows, e := s.ctx.Wms.ProductTypes(ctx, ""); e == nil { for _, p := range rows { 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) return &ProducibleResult{Items: items, Demands: demands, Avail: avail, ComputedAt: now}, nil } // dropInactiveProducts 剔除停用产品(product_type.isActive=false)的 BOM 条目, // 使「可生产套数」(排产支撑视图 Items)只统计启用成品(§A2:只算启用成品)。 // 注意:只影响可生产套数计算;逐日消耗模拟(simulateSupport)仍用完整 BOM—— // 停用产品若有遗留排产,物料消耗是真实的,不能漏算。 func (s *Service) dropInactiveProducts(ctx context.Context, chosen map[string][]producibleBomItem) { if len(chosen) == 0 { return } rows, err := s.ctx.Wms.ProductTypes(ctx, "false") if err != nil { return } for _, p := range rows { delete(chosen, p.Code) } } // buildChosenBom 按产品编码分组 BOM 明细,并为每个产品选定一份 BOM(优先“默认”,否则按名排序取第一份)。 // 返回 chosen(产品编码→明细行) 与 materialSet(BOM 涉及的全部物料编码)。 func (s *Service) buildChosenBom(ctx context.Context) (map[string][]producibleBomItem, map[string]bool, error) { all, err := s.ctx.EntClient.BomItem.Query().All(ctx) if err != nil { return nil, nil, err } 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, unit: b.Unit, unitQty: b.UnitQty, }) } 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 } } return chosen, materialSet, nil } // ComputeMaterialDemand 按物料汇总未来 N 天(含今日)排产需求量,供 WMS 缺料补货视图实时拉取。 // 需求 = Σ(逐日排产台数 × 单台用量),向上取整,按物料汇总;返回需求窗口起止日期。 // 与 ComputeProducible 共享 BOM 选定逻辑,各自只算自己视角:MES 只产出"需求量",可用量由 WMS 自己比。 func (s *Service) ComputeMaterialDemand(ctx context.Context, days int) ([]MaterialDemandRow, error) { if s.ctx.EntClient == nil { return nil, errors.New("未配置数据库连接") } if days <= 0 { days = 5 } chosen, _, err := s.buildChosenBom(ctx) if err != nil { return nil, err } if len(chosen) == 0 { return []MaterialDemandRow{}, nil } today := time.Now() start := today.Format("2006-01-02") end := today.AddDate(0, 0, days-1).Format("2006-01-02") demand, err := s.dailyMaterialDemand(ctx, chosen, start, end) if err != nil { return nil, err } if len(demand) == 0 { return []MaterialDemandRow{}, nil } mcodes := make([]string, 0, len(demand)) for c := range demand { mcodes = append(mcodes, c) } sort.Strings(mcodes) // 物料名称/单位取自选定 BOM nameUnit := map[string]struct{ name, unit string }{} for _, bom := range chosen { for _, it := range bom { nameUnit[it.materialCode] = struct{ name, unit string }{it.materialName, it.unit} } } out := make([]MaterialDemandRow, 0, len(mcodes)) for _, c := range mcodes { nu := nameUnit[c] out = append(out, MaterialDemandRow{ MaterialCode: c, MaterialName: nu.name, Unit: nu.unit, DemandQty: demand[c], StartDate: start, EndDate: end, }) } return out, nil } // dailyMaterialDemand 累计 [start,end] 窗口内各物料排产需求量(向上取整)。 // 数据链路:daily_plan(工单号→台数) → work_order(工单号→产品编码) → BOM(产品编码→物料单台用量)。 func (s *Service) dailyMaterialDemand(ctx context.Context, chosen map[string][]producibleBomItem, start, end string) (map[string]int, error) { plans, err := s.ctx.EntClient.DailyPlan.Query(). Where(dailyplan.PlanDateGTE(start), dailyplan.PlanDateLTE(end)).All(ctx) if err != nil { return nil, err } if len(plans) == 0 { return nil, nil } orderByNo := map[string]int{} for _, p := range plans { if p.Status == "CANCELLED" { continue } orderByNo[p.OrderNo] += p.PlanQty } if len(orderByNo) == 0 { return nil, 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, err } // 产品编码 → 窗口内总台数 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)) } } return matQty, 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") matQty, err := s.dailyMaterialDemand(ctx, chosen, start, end) if err != nil || 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 } // ComputeProducibleSupport 计算排产支撑视图(§A2 / §D1)。 // 复用 ComputeProducible 得到逐成品可生产套数,再用 WMS 可用量对日排产做逐日消耗模拟, // 得出整体还能满产多少天、支撑到哪天、最先告罄的短板物料;排产只排 N 天时,第 N+1 天起用近3天平均日需求外推。 func (s *Service) ComputeProducibleSupport(ctx context.Context) (*ProducibleSupportResult, error) { base, err := s.ComputeProducible(ctx) if err != nil { return nil, err } res := &ProducibleSupportResult{ ComputedAt: base.ComputedAt, SupportDays: 0, SupportUntil: time.Now().Format("2006-01-02"), } for _, it := range base.Items { res.Items = append(res.Items, ProducibleSupportItem{ ProductCode: it.ProductCode, ProductName: it.ProductName, ProducibleQty: it.ProducibleQty, ShortText: it.ShortText, }) } chosen, _, err := s.buildChosenBom(ctx) if err != nil { return nil, err } days, until, shortBoard := s.simulateSupport(ctx, chosen, base.Avail) res.SupportDays = days res.SupportUntil = until res.ShortBoardMaterial = shortBoard return res, nil } // simulateSupport 逐日消耗模拟:用 WMS 可用量 avail 对日排产做逐日扣减,直到某物料不足。 // 返回 支撑满产天数、支撑到哪天、最先告罄短板物料(名称+编码,无排产返回"无排产计划")。 func (s *Service) simulateSupport(ctx context.Context, chosen map[string][]producibleBomItem, avail map[string]int) (int, string, string) { today := time.Now() start := today.Format("2006-01-02") end := today.AddDate(0, 0, 59).Format("2006-01-02") plans, err := s.ctx.EntClient.DailyPlan.Query(). Where(dailyplan.PlanDateGTE(start), dailyplan.PlanDateLTE(end)).All(ctx) if err != nil { return 0, start, "无排产计划" } // 工单号 → 产品编码 orderNoSet := map[string]bool{} planByDate := map[string]map[string]int{} // date -> orderNo -> qty for _, p := range plans { if p.Status == "CANCELLED" || p.PlanQty <= 0 { continue } if planByDate[p.PlanDate] == nil { planByDate[p.PlanDate] = map[string]int{} } planByDate[p.PlanDate][p.OrderNo] += p.PlanQty orderNoSet[p.OrderNo] = true } if len(planByDate) == 0 { return 0, start, "无排产计划" } // 拉工单产品编码 orderNos := make([]string, 0, len(orderNoSet)) for no := range orderNoSet { orderNos = append(orderNos, no) } wos, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNoIn(orderNos...)).All(ctx) if err != nil { return 0, start, "无排产计划" } orderProduct := map[string]string{} for _, wo := range wos { if wo.ProductCode != "" { orderProduct[wo.WorkOrderNo] = wo.ProductCode } } // 物料名称 matName := map[string]string{} for _, bom := range chosen { for _, it := range bom { matName[it.materialCode] = it.materialName } } // 排序排产日 dates := make([]string, 0, len(planByDate)) for d := range planByDate { dates = append(dates, d) } sort.Strings(dates) // 当前剩余可用量 remaining := map[string]float64{} for k, v := range avail { remaining[k] = float64(v) } // 逐日真实排产消耗 history := make([]map[string]int, 0, len(dates)) // 每日各物料需求(用于外推均值) supportDays := 0 shortBoard := "" for _, d := range dates { dayDemand := dayMaterialDemand(planByDate[d], orderProduct, chosen) history = append(history, dayDemand) // 扣减 fail := false for mat, q := range dayDemand { remaining[mat] -= float64(q) if remaining[mat] < 0 && shortBoard == "" { name := matName[mat] if name == "" { name = mat } shortBoard = fmt.Sprintf("%s(%s)", name, mat) fail = true } } if fail { break } supportDays++ } // 排产日已耗尽仍有余量 → 用近3天平均日需求外推 if shortBoard == "" { // 计算均值(取最近 min(3,len) 天) n := len(history) if n == 0 { return supportDays, addDays(start, supportDays), "" } lo := 0 if n > 3 { lo = n - 3 } avg := map[string]float64{} cnt := float64(n - lo) for i := lo; i < n; i++ { for mat, q := range history[i] { avg[mat] += float64(q) } } for mat := range avg { avg[mat] /= cnt } // 近3天平均日需求全为0(无有效 BOM 用量)→ 无更多需求,不再外推 totalAvg := 0.0 for _, q := range avg { totalAvg += q } if totalAvg <= 0 { return supportDays, addDays(start, supportDays), "" } // 外推:每日按 avg 扣减,直到某物料不足(封顶 365 天防死循环) capDays := 365 for supportDays < capDays { fail := false for mat, q := range avg { if q <= 0 { continue } remaining[mat] -= q if remaining[mat] < 0 && shortBoard == "" { name := matName[mat] if name == "" { name = mat } shortBoard = fmt.Sprintf("%s(%s)", name, mat) fail = true } } if fail { break } supportDays++ } } return supportDays, addDays(start, supportDays), shortBoard } // dayMaterialDemand 计算某日各物料需求(逐工单台数 × 单台用量,向上取整) func dayMaterialDemand(planOfDay map[string]int, orderProduct map[string]string, chosen map[string][]producibleBomItem) map[string]int { demand := map[string]int{} for orderNo, qty := range planOfDay { pc := orderProduct[orderNo] if pc == "" || qty <= 0 { continue } bom := chosen[pc] if len(bom) == 0 { continue } for _, it := range bom { if it.unitQty <= 0 { continue } demand[it.materialCode] += int(math.Ceil(float64(qty) * it.unitQty)) } } return demand } // addDays YYYY-MM-DD + n 天 → YYYY-MM-DD func addDays(date string, n int) string { t, err := time.Parse("2006-01-02", date) if err != nil { return date } return t.AddDate(0, 0, n).Format("2006-01-02") } // trimNum 单台用量展示:整数不带小数点,非整数保留原精度 func trimNum(f float64) string { if f == math.Trunc(f) { return strconv.Itoa(int(f)) } return strconv.FormatFloat(f, 'f', -1, 64) }