feat(production): 新增排产支撑功能并移除手动报工页面

- 在main.go中更新排产物料需求注释说明,明确WMS与MES系统间交互方式
- 从前端help.js中移除手动报工(helpScan)相关帮助文档
- 更新工艺流程、绩效报表等页面帮助文档中的报工流程说明
- 在主布局中添加排产支撑菜单项并移除手动报工菜单项
- 新增ProducibleSupport.vue组件实现排产支撑页面功能
- 移除Scan.vue手动报工页面组件
- 更新相关帮助文档内容以匹配新的业务流程
This commit is contained in:
SunYF
2026-09-21 14:06:05 +08:00
parent 132c3ed6bd
commit 0e769f3813
105 changed files with 2313 additions and 5314 deletions
+381 -57
View File
@@ -11,6 +11,7 @@ import (
"time"
"bj_power_mes/ent/dailyplan"
"bj_power_mes/ent/producttype"
"bj_power_mes/ent/workorder"
"bj_power_mes/internal/wmsclient"
)
@@ -19,14 +20,44 @@ import (
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"`
ComputedAt int64 `json:"computedAt"`
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天物料需求」,结果直接返回(不做定时推送)。
@@ -41,52 +72,20 @@ func (s *Service) ComputeProducible(ctx context.Context) (*ProducibleResult, err
if s.ctx.Wms == nil || s.ctx.EntClient == nil {
return nil, errors.New("未配置 WMS 地址,无法计算可生产数量")
}
all, err := s.ctx.EntClient.BomItem.Query().All(ctx)
chosen, materialSet, err := s.buildChosenBom(ctx)
if err != nil {
return nil, err
}
if len(all) == 0 {
return &ProducibleResult{Items: []wmsclient.ProducibleItem{}, Demands: []wmsclient.ProducibleDemand{}, ComputedAt: time.Now().Unix()}, nil
if len(chosen) == 0 {
return &ProducibleResult{
Items: []wmsclient.ProducibleItem{}, Demands: []wmsclient.ProducibleDemand{},
Avail: map[string]int{}, ComputedAt: time.Now().Unix(),
}, nil
}
// 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
}
}
// §A2:可生产套数只算启用成品(停用产品不再展示产能)。
// materialSet 保持完整——排产消耗模拟(simulateSupport)按真实排产消耗全部 BOM 物料。
s.dropInactiveProducts(ctx, chosen)
// 3) 拉取 WMS 各物料可用量
codes := make([]string, 0, len(materialSet))
@@ -175,21 +174,132 @@ func (s *Service) ComputeProducible(ctx context.Context) (*ProducibleResult, err
// 6) 未来5天物料需求(备料四态「预警」数据源)
demands := s.computeFuture5Demand(ctx, chosen)
return &ProducibleResult{Items: items, Demands: demands, ComputedAt: now}, nil
return &ProducibleResult{Items: items, Demands: demands, Avail: avail, ComputedAt: now}, nil
}
// computeFuture5Demand 近5天(含今日)日排产台数 × 单台用量,按物料汇总为未来5天需求量。
// 数据链路:daily_plan(工单号→台数) → work_order(工单号→产品编码) → BOM(产品编码→物料单台用量)
func (s *Service) computeFuture5Demand(ctx context.Context, chosen map[string][]producibleBomItem) []wmsclient.ProducibleDemand {
// 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
}
pts, err := s.ctx.EntClient.ProductType.Query().Where(producttype.IsActiveEQ(false)).All(ctx)
if err != nil {
return
}
for _, p := range pts {
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, 4).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 || len(plans) == 0 {
return nil
if err != nil {
return nil, err
}
if len(plans) == 0 {
return nil, nil
}
// 工单号 → 未来5天台数(排除已取消)
orderByNo := map[string]int{}
for _, p := range plans {
if p.Status == "CANCELLED" {
@@ -198,7 +308,7 @@ func (s *Service) computeFuture5Demand(ctx context.Context, chosen map[string][]
orderByNo[p.OrderNo] += p.PlanQty
}
if len(orderByNo) == 0 {
return nil
return nil, nil
}
nos := make([]string, 0, len(orderByNo))
for no := range orderByNo {
@@ -206,9 +316,9 @@ func (s *Service) computeFuture5Demand(ctx context.Context, chosen map[string][]
}
wos, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNoIn(nos...)).All(ctx)
if err != nil {
return nil
return nil, err
}
// 产品编码 → 未来5天台数
// 产品编码 → 窗口内总台数
prodQty := map[string]int{}
for _, wo := range wos {
if wo.ProductCode == "" {
@@ -216,7 +326,6 @@ func (s *Service) computeFuture5Demand(ctx context.Context, chosen map[string][]
}
prodQty[wo.ProductCode] += orderByNo[wo.WorkOrderNo]
}
// 物料汇总:台数 × 单台用量(向上取整)
matQty := map[string]int{}
for pc, qty := range prodQty {
bom := chosen[pc]
@@ -230,7 +339,17 @@ func (s *Service) computeFuture5Demand(ctx context.Context, chosen map[string][]
matQty[it.materialCode] += int(math.Ceil(float64(qty) * it.unitQty))
}
}
if len(matQty) == 0 {
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))
@@ -245,6 +364,211 @@ func (s *Service) computeFuture5Demand(ctx context.Context, chosen map[string][]
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) {