Files
bj_power/bj_power_mes/internal/logic/producible.go
T
SunYF f274da044c feat: 重构BOM架构,新增基础设置与虚拟工位管理功能
本次重构删除原有BOM物料清单表,改用工单工艺组合×工艺物料清单作为唯一用料来源;新增系统基础设置表支持WMS地址、日志保留天数等配置,新增虚拟工位作业管理后台接口与前端页签控制功能,同时优化工位号校验逻辑、事件日志自动清理与工单用料查询能力。
2026-09-23 11:41:28 +08:00

502 lines
16 KiB
Go

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"
)
// 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 {
OrderNo string `json:"orderNo"`
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"`
}
// planOrdersBetween 取 [start,end] 窗口内(按排产日期 PlanDate,非工单起止日期)未取消日排产:
// 返回 工单号→排产台数 合并映射(同工单多日排产累加)。
func (s *Service) planOrdersBetween(ctx context.Context, 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
}
qty := map[string]int{}
for _, p := range plans {
if p.Status == "CANCELLED" {
continue
}
qty[p.OrderNo] += p.PlanQty
}
for no, q := range qty {
if q <= 0 {
delete(qty, no)
}
}
return qty, nil
}
// loadOrderUsage 批量加载工单(工单号→单台用料行)。工单号不存在或组合无物料的不进映射。
func (s *Service) loadOrderUsage(ctx context.Context, orderNos []string) map[string][]UsageRow {
usageByOrder := map[string][]UsageRow{}
if len(orderNos) == 0 {
return usageByOrder
}
wos, err := s.ctx.EntClient.WorkOrder.Query().
Where(workorder.WorkOrderNoIn(orderNos...)).All(ctx)
if err != nil {
return usageByOrder
}
for _, wo := range wos {
if u := s.WorkOrderUnitUsage(ctx, wo); len(u) > 0 {
usageByOrder[wo.WorkOrderNo] = u
}
}
return usageByOrder
}
// ComputeProducible 计算「可生产数量」+「未来5天物料需求」,结果直接返回(不做定时推送)。
//
// 业务口径(BOM 已删除,物料跟着工单走):
// - 可生产套数 = 按"有未取消日排产的工单"逐单计算:min(各物料可用量 ÷ 该工单单台含损耗用量)。
// 单台用量来自该工单工艺组合×工艺物料清单;库存可用量在 WMS,由 MES 拉取后本地计算。
// - 未来5天需求量 = 近5天(含今日)日排产台数 × 工单单台含损耗用量,按物料汇总(按排产日期算),
// 供 WMS 备料四态「预警」判定(客户诉求 问题记录 L256)。
//
// 两系统独立运行约束:未配置 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 地址,无法计算可生产数量")
}
today := time.Now().Format("2006-01-02")
planQty, err := s.planOrdersBetween(ctx, today, addDays(today, 59))
if err != nil {
return nil, err
}
if len(planQty) == 0 {
return &ProducibleResult{
Items: []wmsclient.ProducibleItem{}, Demands: []wmsclient.ProducibleDemand{},
Avail: map[string]int{}, ComputedAt: time.Now().Unix(),
}, nil
}
orderNos := make([]string, 0, len(planQty))
for no := range planQty {
orderNos = append(orderNos, no)
}
sort.Strings(orderNos)
usageByOrder := s.loadOrderUsage(ctx, orderNos)
// 拉取 WMS 各物料可用量(仅工单用料涉及的物料)
codeSet := map[string]bool{}
for _, usage := range usageByOrder {
for _, it := range usage {
codeSet[it.MaterialCode] = true
}
}
codes := make([]string, 0, len(codeSet))
for c := range codeSet {
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())
}
// 产品名称映射(product_type 优先,工单快照兜底)
nameMap := map[string]string{}
if rows, e := s.ctx.Wms.ProductTypes(ctx, ""); e == nil {
for _, p := range rows {
nameMap[p.Code] = p.Name
}
}
prodCodeByOrder := map[string]string{}
if wos, e := s.ctx.EntClient.WorkOrder.Query().
Where(workorder.WorkOrderNoIn(orderNos...)).All(ctx); e == nil {
for _, wo := range wos {
prodCodeByOrder[wo.WorkOrderNo] = wo.ProductCode
}
}
// 逐工单计算可生产套数 = min(floor(可用量 ÷ 单台含损耗用量)),短板物料取最小值前3
now := time.Now().Unix()
items := make([]wmsclient.ProducibleItem, 0, len(orderNos))
for _, no := range orderNos {
usage, ok := usageByOrder[no]
if !ok {
continue // 工单组合无物料清单,无法评估
}
minSets := -1
type shortRow struct {
text string
set int
}
shorts := make([]shortRow, 0, len(usage))
for _, it := range usage {
if it.EffQty <= 0 {
continue
}
a := avail[it.MaterialCode]
sets := int(math.Floor(float64(a) / it.EffQty))
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.EffQty), sets),
set: sets,
})
}
if minSets < 0 {
minSets = 0
}
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)
}
}
pc := prodCodeByOrder[no]
items = append(items, wmsclient.ProducibleItem{
OrderNo: no,
ProductCode: pc,
ProductName: nameMap[pc],
ProducibleQty: minSets,
ShortText: strings.Join(pick, "; "),
ComputedAt: now,
})
}
// 未来 N 天物料需求(备料四态「预警」数据源;窗口天数在「基础设置」页配置,默认 5)
demands := s.computeFutureDemand(ctx, s.CfgInt(ctx, CfgDemandDays, 5))
return &ProducibleResult{
Items: items, Demands: demands,
Avail: avail, ComputedAt: time.Now().Unix(),
}, nil
}
// ComputeMaterialDemand WMS 缺料补货数据源:未来 N 天(按排产日期)排产物料需求,按物料汇总。
// 与 ComputeProducible 共享工单用料汇总: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 = s.CfgInt(ctx, CfgDemandDays, 5) // 未传窗口 → 基础设置需求窗口天数
}
today := time.Now()
start := today.Format("2006-01-02")
end := today.AddDate(0, 0, days-1).Format("2006-01-02")
planQty, err := s.planOrdersBetween(ctx, start, end)
if err != nil {
return nil, err
}
if len(planQty) == 0 {
return []MaterialDemandRow{}, nil
}
orderNos := make([]string, 0, len(planQty))
for no := range planQty {
orderNos = append(orderNos, no)
}
usageByOrder := s.loadOrderUsage(ctx, orderNos)
if len(usageByOrder) == 0 {
return []MaterialDemandRow{}, nil
}
// 需求 = Σ 排产台数 × 单台含损耗用量(向上取整);名称/单位取自工艺物料快照
demand := map[string]int{}
nameUnit := map[string]struct{ name, unit string }{}
for no, qty := range planQty {
usage, ok := usageByOrder[no]
if !ok || qty <= 0 {
continue
}
for _, it := range usage {
if it.EffQty <= 0 {
continue
}
demand[it.MaterialCode] += int(math.Ceil(float64(qty) * it.EffQty))
nameUnit[it.MaterialCode] = struct{ name, unit string }{it.MaterialName, it.Unit}
}
}
if len(demand) == 0 {
return []MaterialDemandRow{}, nil
}
mcodes := make([]string, 0, len(demand))
for c := range demand {
mcodes = append(mcodes, c)
}
sort.Strings(mcodes)
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
}
// computeFutureDemand 近 N 天(含今日)日排产台数 × 工单单台含损耗用量,按物料汇总为未来需求量。
// 窗口按排产日期算(工单起止日期≠排产日期,一律以排产日期为准);N 在「基础设置」页配置(默认 5)。
func (s *Service) computeFutureDemand(ctx context.Context, days int) []wmsclient.ProducibleDemand {
rows, err := s.ComputeMaterialDemand(ctx, days)
if err != nil || len(rows) == 0 {
return nil
}
out := make([]wmsclient.ProducibleDemand, 0, len(rows))
for _, r := range rows {
out = append(out, wmsclient.ProducibleDemand{MaterialCode: r.MaterialCode, Future5Qty: r.DemandQty})
}
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{
OrderNo: it.OrderNo, ProductCode: it.ProductCode, ProductName: it.ProductName,
ProducibleQty: it.ProducibleQty, ShortText: it.ShortText,
})
}
usageByOrder := s.loadOrderUsage(ctx, orderNosOf(base.Items))
days, until, shortBoard := s.simulateSupport(ctx, usageByOrder, base.Avail)
res.SupportDays = days
res.SupportUntil = until
res.ShortBoardMaterial = shortBoard
return res, nil
}
// orderNosOf 从可生产结果提取工单号(保持顺序)
func orderNosOf(items []wmsclient.ProducibleItem) []string {
out := make([]string, 0, len(items))
for _, it := range items {
if it.OrderNo != "" {
out = append(out, it.OrderNo)
}
}
return out
}
// simulateSupport 逐日消耗模拟:用 WMS 可用量 avail 对日排产做逐日扣减,直到某物料不足。
// 返回 支撑满产天数、支撑到哪天、最先告罄短板物料(名称+编码,无排产返回"无排产计划")。
func (s *Service) simulateSupport(ctx context.Context, usageByOrder map[string][]UsageRow, 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, "无排产计划"
}
planByDate := map[string]map[string]int{} // date -> orderNo -> qty
for _, p := range plans {
if p.Status == "CANCELLED" || p.PlanQty <= 0 {
continue
}
if _, has := usageByOrder[p.OrderNo]; !has {
continue // 工单组合无物料清单,不参与消耗模拟
}
if planByDate[p.PlanDate] == nil {
planByDate[p.PlanDate] = map[string]int{}
}
planByDate[p.PlanDate][p.OrderNo] += p.PlanQty
}
if len(planByDate) == 0 {
return 0, start, "无排产计划"
}
// 物料名称
matName := map[string]string{}
for _, usage := range usageByOrder {
for _, it := range usage {
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], usageByOrder)
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++
}
// 排产日已耗尽仍有余量 → 用近 N 天平均日需求外推(N 在「基础设置」页配置,默认 3)
if shortBoard == "" {
// 计算均值(取最近 min(N,len) 天)
n := len(history)
if n == 0 {
return supportDays, addDays(start, supportDays), ""
}
window := s.CfgInt(ctx, CfgExtrapWindow, 3)
lo := 0
if n > window {
lo = n - window
}
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
}
// 近 N 天平均日需求全为0(无有效单台用量)→ 无更多需求,不再外推
totalAvg := 0.0
for _, q := range avg {
totalAvg += q
}
if totalAvg <= 0 {
return supportDays, addDays(start, supportDays), ""
}
// 外推:每日按 avg 扣减,直到某物料不足(封顶天数在「基础设置」页配置,防死循环)
capDays := s.CfgInt(ctx, CfgExtrapCap, 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, usageByOrder map[string][]UsageRow) map[string]int {
demand := map[string]int{}
for orderNo, qty := range planOfDay {
usage := usageByOrder[orderNo]
if len(usage) == 0 || qty <= 0 {
continue
}
for _, it := range usage {
if it.EffQty <= 0 {
continue
}
demand[it.MaterialCode] += int(math.Ceil(float64(qty) * it.EffQty))
}
}
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)
}