Files
bj_power/bj_power_mes/internal/logic/material.go
T
SunYF 05b0b8a909 feat: 完成客户第12条需求+多系统改造
1. 新增预警表添加工单号字段,支持按工单号检索预警
2. 添加工单过滤在制品/绩效报表/预警中心查询
3. 补全WMS台账字段口径与权限配置
4. 修复备料单与数量不符处理流程
5. 新增配送进度/库位联动/自动出库功能
6. 修复AGV配送状态更新逻辑
7. 优化退料与库存管理细节
2026-09-19 17:04:58 +08:00

465 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package logic
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"time"
"bj_power_mes/ent"
"bj_power_mes/ent/materialrequest"
"bj_power_mes/ent/station"
"bj_power_mes/ent/workorder"
"bj_power_mes/internal/wmsclient"
)
// GenerateMaterialRequest 按日排产 + 今日工位派工(station_process) 自动算料,生成工位级备料单。
//
// 算法:
// - 路线来源:今日 station_process 派工(工位号→工序编号)。某工序(1~12)今日派工到哪些工位,
// 该工序的 BOM 物料就拆到这些工位各生成一行备料单(物料随工序上料)。
// - 数量:单工位量 = 该工位分配产量(日排产 stationPlanQty,未配置则日总量平均到各派工工位)
// × BOM 单台用量 ×(1+损耗率);同工序多工位时日总量按分配/平均拆分,不重复放大。
// - 目标接驳台:取该派工工位的 station.dockCode(无接驳台则留空待仓管指定)。
// - processCode=0 的 BOM 物料(不参与工位绑定):按日总量生成一行,工位/接驳台留空。
//
// 生成前校验:BOM 内所有物料编码须在 WMS 物料档案存在,有缺失返回 missing(不生成);
// WMS 不可达/异常时降级为"全部存在"并记 eventlog,避免 WMS 抖动阻塞产线。
// 返回值:count=本次生成条数;missing=WMS 中不存在的物料编码(非空时 count=0)。
func (s *Service) GenerateMaterialRequest(ctx context.Context, planDate string, operator string) (int, []string, error) {
plans, err := s.ListDailyPlans(ctx, "", planDate)
if err != nil {
return 0, nil, err
}
if len(plans) == 0 {
return 0, nil, errors.New("该日期没有排产计划")
}
// 今日工位派工:工位号 → 工序编号
procMap, err := RouteStationProcessCodeMap(ctx, s.ctx.EntClient, TodayStr(), "")
if err != nil {
return 0, nil, err
}
if len(procMap) == 0 {
return 0, nil, errors.New("今日未配置工位派工(station_process),无法生成备料单")
}
// 工序编号 → 工位号列表(反查,供 BOM 物料按工序拆到工位)
stationsByProcess := map[int][]int{}
stNos := make([]int, 0, len(procMap))
for stNo, pc := range procMap {
stationsByProcess[pc] = append(stationsByProcess[pc], stNo)
stNos = append(stNos, stNo)
}
// 工位号 → 接驳台编码
dockByStation := map[int]string{}
if sts, e := s.ctx.EntClient.Station.Query().Where(station.StationNoIn(stNos...)).All(ctx); e == nil {
for _, st := range sts {
dockByStation[st.StationNo] = st.DockCode
}
}
type planBom struct {
plan *ent.DailyPlan
wo *ent.WorkOrder
bom []*ent.BomItem
}
groups := make([]planBom, 0, len(plans))
codeSet := map[string]bool{}
for _, plan := range plans {
if plan.Status == "CANCELLED" || plan.Status == "DONE" {
continue // 已取消/已完成的排产不再算料
}
wo, err := s.ctx.EntClient.WorkOrder.Query().
Where(workorder.WorkOrderNo(plan.OrderNo)).Only(ctx)
if err != nil {
continue
}
bom, err := s.ListBom(ctx, wo.ProductCode, bomNameOrDefault(""))
if err != nil {
continue
}
if len(bom) == 0 {
continue
}
for _, item := range bom {
if item.MaterialCode != "" {
codeSet[item.MaterialCode] = true
}
}
groups = append(groups, planBom{plan: plan, wo: wo, bom: bom})
}
if len(groups) == 0 {
return 0, nil, errors.New("该日期排产均无有效 BOM 物料,无法生成备料单")
}
// 阶段1:WMS 物料存在性校验(全部存在才生成)
codes := make([]string, 0, len(codeSet))
for c := range codeSet {
codes = append(codes, c)
}
missing, wmsErr := s.ctx.Wms.MaterialExists(ctx, codes)
if wmsErr != nil {
// 降级:WMS 不可达按"全部存在"放行,记日志供事后核查
s.ctx.EventLog.Write(ctx, "material.request.wms_skip", "", operator, "material_request", planDate,
"WMS 物料校验不可达,已降级放行", map[string]any{"planDate": planDate, "error": wmsErr.Error()})
} else if len(missing) > 0 {
return 0, missing, nil
}
// 阶段2:全部存在 → 按工位拆料生成
created := 0
tsBase := time.Now().Format("20060102150405")
seq := 1
for _, g := range groups {
// 工位产量分配(日排产层):stationNo(字符串键) → 计划产量
stationQty := map[int]int{}
for k, v := range g.plan.StationPlanQty {
if n, e := strconv.Atoi(k); e == nil {
stationQty[n] = v
}
}
// 成品可作零部件:BOM 子项若为成品,递归展开到叶子(原材料/外购件)再算料
leaves := s.expandBomToLeaf(ctx, g.wo.ProductCode, bomNameOrDefault(""), 1, 0, map[string]bool{})
for _, item := range leaves {
pc := item.processCode // 该物料所属装配工序(1~12),0=不参与工位绑定
if pc == 0 {
reqQty := float64(g.plan.PlanQty) * item.unitQty * (1 + item.lossRate/100)
if reqQty <= 0 {
continue
}
reqNo := fmt.Sprintf("MR%s%03d", tsBase, seq)
seq++
_ = s.ctx.EntClient.MaterialRequest.Create().
SetRequestNo(reqNo).SetOrderNo(g.plan.OrderNo).SetPlanDate(g.plan.PlanDate).
SetMaterialCode(item.materialCode).SetMaterialName(item.materialName).
SetUnit(item.unit).SetManageMode(item.manageMode).SetReqQty(reqQty).
SetStatus("PENDING").SetOperator(operator).Exec(ctx)
created++
continue
}
stations := stationsByProcess[pc]
if len(stations) == 0 {
// 该工序今日未派工到任何工位,本日无需上料(非缺料)
continue
}
qtys := splitQtyToStations(stations, g.plan.PlanQty, stationQty)
for i, stNo := range stations {
q := qtys[i]
if q <= 0 {
continue
}
reqQty := float64(q) * item.unitQty * (1 + item.lossRate/100)
if reqQty <= 0 {
continue
}
reqNo := fmt.Sprintf("MR%s%03d", tsBase, seq)
seq++
_ = s.ctx.EntClient.MaterialRequest.Create().
SetRequestNo(reqNo).SetOrderNo(g.plan.OrderNo).SetPlanDate(g.plan.PlanDate).
SetMaterialCode(item.materialCode).SetMaterialName(item.materialName).
SetUnit(item.unit).SetManageMode(item.manageMode).SetReqQty(reqQty).
SetStatus("PENDING").SetOperator(operator).
SetStationNo(stNo).SetProcessCode(pc).SetTargetDock(dockByStation[stNo]).Exec(ctx)
created++
}
}
}
s.ctx.EventLog.Write(ctx, "material.request.generate", "", operator, "material_request", planDate, "按日排产+工位派工自动生成工位级备料单", map[string]any{"planDate": planDate, "count": created})
// 生成后立即自动出库(客户第1条:平时无需操作)。缺料/失败不阻塞生成,仅记预警等待补库存。
if _, short := s.AutoOutboundRequests(ctx, planDate, "", operator); len(short) > 0 {
s.ctx.EventLog.Write(ctx, "material.outbound.short", "", operator, "material_request", planDate,
"备料自动出库未齐套", map[string]any{"planDate": planDate, "short": strings.Join(short, "; ")})
}
s.notifyDashboard()
return created, nil, nil
}
// AutoOutboundRequests 工单备料自动出库(客户第1条:备料出库根据 MES 排产自动生成,平时无需操作)。
//
// 对指定排产日 / 工单下的备料单,按 FIFO 从 WMS「合格」在库库存自动领料:
// - 结构件(manageMode=1):按批次号升序逐批出库,直到补足未出数量;
// - 电气件(manageMode=2):按可用 SN 逐件出库。
//
// 幂等:只出「未出数量 = 需求量 − 已发数量」,重复调用不会超领。
// 库存不足的行已出部分照常计入 sentQty,差额维持待备料并触发「备料未齐套」预警,
// 库存补齐后再次调用即可补出(补出由 WMS 台账强约束兜底,不会超出工单 BOM 需求)。
// 返回:本次补足(出库总量达标)的备料单条数、未出齐的明细文案。
func (s *Service) AutoOutboundRequests(ctx context.Context, planDate, orderNo, operator string) (int, []string) {
list, err := s.ListMaterialRequests(ctx, MaterialRequestQuery{OrderNo: orderNo, PlanDate: planDate})
if err != nil {
return 0, []string{"查询备料单失败: " + err.Error()}
}
// 补料单(refill)优先于排产备料(plan):先把补料发出,避免产线因缺料停线。
sort.SliceStable(list, func(i, j int) bool {
return list[i].Source == "REFILL" && list[j].Source != "REFILL"
})
done := 0
short := make([]string, 0)
for _, mr := range list {
need := int(mr.ReqQty - mr.SentQty + 0.5)
if need <= 0 {
continue
}
batches, sns, err := s.ctx.Wms.StockBatches(ctx, mr.MaterialCode)
if err != nil {
short = append(short, fmt.Sprintf("%s %s 查询 WMS 库存失败", mr.OrderNo, mr.MaterialCode))
continue
}
dock := mr.TargetDock
if dock == "" {
dock = "DOCK01"
}
remain := need
outboundNos := make([]string, 0, 4)
// 出库参数:补料(source=REFILL) 记为 refill 类型并默认叫车,且携带超领原因
// (补料常因损耗/报废超出 BOM 需求,WMS 侧有原因才放行)。
obType := "workorder"
if mr.Source == "REFILL" {
obType = "refill"
}
opts := wmsclient.StockDeductOpts{
Qty: need, Operator: operator, TargetDock: dock,
OutboundType: obType, NeedAgv: mr.NeedAgv, OverReason: mr.OverReason,
}
if mr.ManageMode == "2" {
for _, sn := range sns {
if remain <= 0 {
break
}
opts.SnList = []string{sn}
opts.BatchNo = ""
opts.Qty = 1
no, _, e := s.ctx.Wms.StockDeduct(ctx, mr.OrderNo, mr.MaterialCode, opts)
if e != nil {
break
}
outboundNos = append(outboundNos, no)
remain--
}
} else {
for _, b := range batches {
if remain <= 0 {
break
}
take := b.Avail
if take > remain {
take = remain
}
if take <= 0 {
continue
}
opts.SnList = nil
opts.BatchNo = b.BatchNo
opts.Qty = take
opts.ZoneCode = b.ZoneCode
no, _, e := s.ctx.Wms.StockDeduct(ctx, mr.OrderNo, mr.MaterialCode, opts)
if e != nil {
break
}
outboundNos = append(outboundNos, no)
remain -= take
}
}
sent := need - remain
if sent > 0 {
upd := s.ctx.EntClient.MaterialRequest.UpdateOneID(mr.ID).AddSentQty(float64(sent))
if remain == 0 {
// 出库量已达标:备料完成,等 AGV 配送(WMS 下发后回写 DELIVERING
upd = upd.SetStatus("LOCKED")
done++
}
if _, e := upd.Save(ctx); e == nil {
s.ctx.EventLog.Write(ctx, "material.outbound.auto", mr.OrderNo, operator, "material_request", mr.RequestNo,
"备料自动出库", map[string]any{"materialCode": mr.MaterialCode, "qty": sent, "outboundNos": strings.Join(outboundNos, ",")})
}
}
if remain > 0 {
short = append(short, fmt.Sprintf("%s %s 缺 %d", mr.OrderNo, mr.MaterialCode, remain))
s.Evaluate(ctx, "material_shortage", 1, "备料未齐套",
fmt.Sprintf("工单 %s 物料 %s 自动出库后仍缺 %d(已出 %d/%d", mr.OrderNo, mr.MaterialCode, remain, sent, need),
"material_request", mr.RequestNo, mr.OrderNo)
}
}
if done > 0 {
s.notifyDashboard()
}
return done, short
}
// bomLeaf 递归展开后的叶子物料行(原材料/外购件)
type bomLeaf struct {
materialCode string
materialName string
unit string
manageMode string
unitQty float64 // 已折算父级倍率的单台用量
lossRate float64
processCode int
}
// expandBomToLeaf 递归展开产品物料清单到叶子(原材料/外购件),供备料算料。
//
// 业务口径:成品可以作为零部件被另一个成品再次加工(多级嵌套)。备料算料时必须把
// 成品子项按其自身的物料清单继续展开,直到原材料/外购件,否则产线会缺料。
// 用量按倍率相乘(父级单台用量 × 子级单台用量)。
//
// R12026-09-19):递归骨架统一到 bom_expand.go 的 expandBomTree,与追溯侧
// expandBindBom)共用同一份实现;下钻判定由 wmsFinishedResolver 注入,
// WMS 不可达时按叶子处理,不阻塞备料生成。
func (s *Service) expandBomToLeaf(ctx context.Context, productCode, bomName string, mult float64, depth int, path map[string]bool) []bomLeaf {
return bomTreeToLeaf(s.expandBomTree(ctx, productCode, bomName, mult, depth, path, s.wmsFinishedResolver))
}
// splitQtyToStations 把日总量 dayQty 拆分到 stations 各工位:
// 优先使用 stationQty 显式分配(其和>0 时直接采用);否则平均分配(余数补到前面工位)。
func splitQtyToStations(stations []int, dayQty int, stationQty map[int]int) []int {
n := len(stations)
out := make([]int, n)
usePlan := true
sum := 0
for _, st := range stations {
v, ok := stationQty[st]
if !ok {
usePlan = false
break
}
sum += v
}
if usePlan && sum > 0 {
for i, st := range stations {
out[i] = stationQty[st]
}
return out
}
if n == 0 {
return out
}
base := dayQty / n
for i := 0; i < n; i++ {
out[i] = base
}
for i := 0; i < dayQty-base*n; i++ {
out[i]++
}
return out
}
// MaterialRequestQuery 备料单查询条件(零值=不过滤)。
// 统一入口:原 ListMaterialRequests / ListMaterialRequestsFiltered 两套位置参数合并到本结构,
// 避免参数越加越长、调用点易错。
type MaterialRequestQuery struct {
OrderNo string
PlanDate string
Status string
MaterialCode string
MaterialName string
TargetDock string
StationNo string
Source string // PLAN / REFILL
Keyword string // 兜底匹配 备料单号/工单号/物料编码
}
// ListMaterialRequests 备料单查询(大小写不敏感)。
func (s *Service) ListMaterialRequests(ctx context.Context, o MaterialRequestQuery) ([]*ent.MaterialRequest, error) {
q := s.ctx.EntClient.MaterialRequest.Query()
if o.OrderNo != "" {
q = q.Where(materialrequest.OrderNoEqualFold(o.OrderNo))
}
if o.PlanDate != "" {
q = q.Where(materialrequest.PlanDate(o.PlanDate))
}
if o.Status != "" {
q = q.Where(materialrequest.Status(o.Status))
}
if o.MaterialCode != "" {
q = q.Where(materialrequest.MaterialCodeContainsFold(o.MaterialCode))
}
if o.MaterialName != "" {
q = q.Where(materialrequest.MaterialNameContainsFold(o.MaterialName))
}
if o.TargetDock != "" {
q = q.Where(materialrequest.TargetDockEqualFold(o.TargetDock))
}
if o.Source != "" {
q = q.Where(materialrequest.Source(o.Source))
}
if o.Keyword != "" {
q = q.Where(materialrequest.Or(
materialrequest.RequestNoContainsFold(o.Keyword),
materialrequest.OrderNoContainsFold(o.Keyword),
materialrequest.MaterialCodeContainsFold(o.Keyword),
))
}
if o.StationNo != "" {
if n, e := strconv.Atoi(o.StationNo); e == nil {
q = q.Where(materialrequest.StationNo(n))
}
}
return q.Order(ent.Desc(materialrequest.FieldCreatedAt), ent.Desc(materialrequest.FieldID)).All(ctx)
}
// ReceiveMaterialRequest 接料确认(两个入口共用:工位终端扫码接料 / MES 备料页人工点「已接料」)。
//
// 字段口径(2026-09-19 定稿):写 receivedQty(已接料),**不再累加 sentQty**。
// sentQty 已发出量 [写者:自动出库] —— 幂等基准「还需发多少 = reqQty sentQty」
// receivedQty 已接料量 [写者:本方法] —— 在途 = sentQty receivedQty
//
// 上限 = 已发出量(工位只能接到仓库已发出来的料),不超接。
// qty<=0 表示「确认本批全部剩余」,适合扫码/一键接料;>0 为本次实收批次量。
// 累计接到 sentQty 且已发齐 reqQty → 状态置 DONE(送达并与接料双完结)。
func (s *Service) ReceiveMaterialRequest(ctx context.Context, requestNo string, qty int, operator string) error {
mr, err := s.ctx.EntClient.MaterialRequest.Query().
Where(materialrequest.RequestNo(requestNo)).Only(ctx)
if err != nil {
return errors.New("备料单不存在")
}
sent := int(mr.SentQty)
already := int(mr.ReceivedQty)
pending := sent - already // 已发出但尚未接料(在途)
if pending < 0 {
pending = 0
}
add := qty
if add <= 0 {
add = pending // 确认本批全部剩余
}
if add > pending {
add = pending // 不超接:上限是仓库已发出的量
}
if add < 0 {
add = 0
}
newReceived := already + add
upd := s.ctx.EntClient.MaterialRequest.UpdateOneID(mr.ID).
SetReceivedQty(float64(newReceived)).
SetReceivedAt(time.Now().Unix()).
SetReceiveBy(operator)
if mr.Status != "DONE" && sent >= int(mr.ReqQty) && newReceived >= sent {
upd.SetStatus("DONE")
}
if _, err = upd.Save(ctx); err != nil {
return err
}
s.ctx.EventLog.Write(ctx, "material.request.receive", requestNo, operator, "material_request", requestNo,
"接料确认", map[string]any{"add": add, "receivedQty": newReceived, "sentQty": sent,
"reqQty": int(mr.ReqQty), "awaiting": sent - newReceived})
s.notifyDashboard()
return nil
}
// SetMaterialRequestStatus 推进备料单状态(WMS 下发 AGV DELIVERING / 到位 DONE
func (s *Service) SetMaterialRequestStatus(ctx context.Context, requestNo, status string) error {
n, err := s.ctx.EntClient.MaterialRequest.Update().
Where(materialrequest.RequestNo(requestNo)).
SetStatus(status).
Save(ctx)
if err != nil {
return err
}
if n == 0 {
return errors.New("备料单不存在")
}
s.notifyDashboard()
return nil
}