feat: 完成客户第12条需求+多系统改造
1. 新增预警表添加工单号字段,支持按工单号检索预警 2. 添加工单过滤在制品/绩效报表/预警中心查询 3. 补全WMS台账字段口径与权限配置 4. 修复备料单与数量不符处理流程 5. 新增配送进度/库位联动/自动出库功能 6. 修复AGV配送状态更新逻辑 7. 优化退料与库存管理细节
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"bj_power_mes/ent/materialrequest"
|
||||
"bj_power_mes/ent/station"
|
||||
"bj_power_mes/ent/workorder"
|
||||
"bj_power_mes/internal/wmsclient"
|
||||
)
|
||||
|
||||
// GenerateMaterialRequest 按日排产 + 今日工位派工(station_process) 自动算料,生成工位级备料单。
|
||||
@@ -162,10 +164,127 @@ func (s *Service) GenerateMaterialRequest(ctx context.Context, planDate string,
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -177,71 +296,17 @@ type bomLeaf struct {
|
||||
processCode int
|
||||
}
|
||||
|
||||
// expandBomToLeaf 递归展开产品物料清单到叶子(原材料/外购件)。
|
||||
// expandBomToLeaf 递归展开产品物料清单到叶子(原材料/外购件),供备料算料。
|
||||
//
|
||||
// 业务口径:成品可以作为零部件被另一个成品再次加工(多级嵌套)。备料算料时必须把
|
||||
// 成品子项按其自身的物料清单继续展开,直到原材料/外购件,否则产线会缺料。
|
||||
// 用量按倍率相乘(父级单台用量 × 子级单台用量),损耗率取路径上的最大值(保守备料)。
|
||||
// 用量按倍率相乘(父级单台用量 × 子级单台用量)。
|
||||
//
|
||||
// 防环:同一条展开路径上出现重复编码视为配置错误,跳过该分支并记日志,避免死循环。
|
||||
// 降级:WMS 不可达时按"全部为叶子物料"处理(不递归),不阻塞备料生成。
|
||||
// R1(2026-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 {
|
||||
const maxDepth = 10
|
||||
if depth > maxDepth || productCode == "" {
|
||||
return nil
|
||||
}
|
||||
bom, err := s.ListBom(ctx, productCode, bomName)
|
||||
if err != nil || len(bom) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 一次性取本层所有子项的品类,判断哪些是成品(需继续展开)
|
||||
codes := make([]string, 0, len(bom))
|
||||
for _, b := range bom {
|
||||
if b.MaterialCode != "" {
|
||||
codes = append(codes, b.MaterialCode)
|
||||
}
|
||||
}
|
||||
types := map[string]int{}
|
||||
if s.ctx.Wms != nil {
|
||||
if t, e := s.ctx.Wms.MaterialTypes(ctx, codes); e == nil {
|
||||
types = t
|
||||
} else {
|
||||
s.ctx.EventLog.Write(ctx, "bom.expand.wms_skip", "", "", "product_bom", productCode,
|
||||
"WMS 物料品类查询不可达,本次按叶子物料展开", map[string]any{"error": e.Error()})
|
||||
}
|
||||
}
|
||||
out := make([]bomLeaf, 0, len(bom))
|
||||
for _, b := range bom {
|
||||
if b.MaterialCode == "" {
|
||||
continue
|
||||
}
|
||||
if path[b.MaterialCode] {
|
||||
s.ctx.EventLog.Write(ctx, "bom.expand.cycle", "", "", "product_bom", productCode,
|
||||
"产品物料清单存在循环引用,已跳过", map[string]any{"materialCode": b.MaterialCode, "depth": depth})
|
||||
continue
|
||||
}
|
||||
unitQty := b.UnitQty * mult
|
||||
if types[b.MaterialCode] == 3 { // 成品子项:继续向下展开
|
||||
path[b.MaterialCode] = true
|
||||
sub := s.expandBomToLeaf(ctx, b.MaterialCode, bomName, unitQty, depth+1, path)
|
||||
delete(path, b.MaterialCode)
|
||||
if len(sub) > 0 {
|
||||
out = append(out, sub...)
|
||||
continue
|
||||
}
|
||||
// 该成品没有自己的物料清单:按叶子处理,避免漏料
|
||||
}
|
||||
out = append(out, bomLeaf{
|
||||
materialCode: b.MaterialCode,
|
||||
materialName: b.MaterialName,
|
||||
unit: b.Unit,
|
||||
manageMode: b.ManageMode,
|
||||
unitQty: unitQty,
|
||||
lossRate: b.LossRate,
|
||||
processCode: b.ProcessCode,
|
||||
})
|
||||
}
|
||||
return out
|
||||
return bomTreeToLeaf(s.expandBomTree(ctx, productCode, bomName, mult, depth, path, s.wmsFinishedResolver))
|
||||
}
|
||||
|
||||
// splitQtyToStations 把日总量 dayQty 拆分到 stations 各工位:
|
||||
@@ -278,40 +343,54 @@ func splitQtyToStations(stations []int, dayQty int, stationQty map[int]int) []in
|
||||
return out
|
||||
}
|
||||
|
||||
// ListMaterialRequests 查询备料单
|
||||
func (s *Service) ListMaterialRequests(ctx context.Context, orderNo, planDate, status, stationNo string) ([]*ent.MaterialRequest, error) {
|
||||
return s.listMaterialRequests(ctx, orderNo, planDate, status, "", "", "", stationNo)
|
||||
// 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 // 兜底匹配 备料单号/工单号/物料编码
|
||||
}
|
||||
|
||||
// ListMaterialRequestsFiltered 管理页丰富筛选入口
|
||||
func (s *Service) ListMaterialRequestsFiltered(ctx context.Context, orderNo, planDate, status, materialCode, materialName, targetDock, stationNo string) ([]*ent.MaterialRequest, error) {
|
||||
return s.listMaterialRequests(ctx, orderNo, planDate, status, materialCode, materialName, targetDock, stationNo)
|
||||
}
|
||||
|
||||
// listMaterialRequests 备料单查询(2026-09-08 丰富筛选+大小写不敏感)
|
||||
// materialCode/materialName/targetDock/stationNo 可空;keyword 兜底匹配 备料单号/工单号/物料编码
|
||||
func (s *Service) listMaterialRequests(ctx context.Context, orderNo, planDate, status, materialCode, materialName, targetDock, stationNo string) ([]*ent.MaterialRequest, error) {
|
||||
// ListMaterialRequests 备料单查询(大小写不敏感)。
|
||||
func (s *Service) ListMaterialRequests(ctx context.Context, o MaterialRequestQuery) ([]*ent.MaterialRequest, error) {
|
||||
q := s.ctx.EntClient.MaterialRequest.Query()
|
||||
if orderNo != "" {
|
||||
q = q.Where(materialrequest.OrderNoEqualFold(orderNo))
|
||||
if o.OrderNo != "" {
|
||||
q = q.Where(materialrequest.OrderNoEqualFold(o.OrderNo))
|
||||
}
|
||||
if planDate != "" {
|
||||
q = q.Where(materialrequest.PlanDate(planDate))
|
||||
if o.PlanDate != "" {
|
||||
q = q.Where(materialrequest.PlanDate(o.PlanDate))
|
||||
}
|
||||
if status != "" {
|
||||
q = q.Where(materialrequest.Status(status))
|
||||
if o.Status != "" {
|
||||
q = q.Where(materialrequest.Status(o.Status))
|
||||
}
|
||||
if materialCode != "" {
|
||||
q = q.Where(materialrequest.MaterialCodeContainsFold(materialCode))
|
||||
if o.MaterialCode != "" {
|
||||
q = q.Where(materialrequest.MaterialCodeContainsFold(o.MaterialCode))
|
||||
}
|
||||
if materialName != "" {
|
||||
q = q.Where(materialrequest.MaterialNameContainsFold(materialName))
|
||||
if o.MaterialName != "" {
|
||||
q = q.Where(materialrequest.MaterialNameContainsFold(o.MaterialName))
|
||||
}
|
||||
if targetDock != "" {
|
||||
q = q.Where(materialrequest.TargetDockEqualFold(targetDock))
|
||||
if o.TargetDock != "" {
|
||||
q = q.Where(materialrequest.TargetDockEqualFold(o.TargetDock))
|
||||
}
|
||||
if stationNo != "" {
|
||||
if n, e := strconv.Atoi(stationNo); e == nil {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -319,35 +398,50 @@ func (s *Service) listMaterialRequests(ctx context.Context, orderNo, planDate, s
|
||||
}
|
||||
|
||||
// ReceiveMaterialRequest 接料确认(两个入口共用:工位终端扫码接料 / MES 备料页人工点「已接料」)。
|
||||
// 累加 material_request.sentQty(封顶 reqQty,不超额);累计达 reqQty 自动置 DONE(送达完结)。
|
||||
// qty<=0 表示「确认本批全部剩余」(sentQty 直接补到 reqQty),适合扫码/一键接料;>0 为本次实收批次量。
|
||||
//
|
||||
// 字段口径(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 = int(mr.ReqQty) - int(mr.SentQty)
|
||||
if add < 0 {
|
||||
add = 0
|
||||
}
|
||||
add = pending // 确认本批全部剩余
|
||||
}
|
||||
newSent := int(mr.SentQty) + add
|
||||
if newSent > int(mr.ReqQty) {
|
||||
newSent = int(mr.ReqQty)
|
||||
if add > pending {
|
||||
add = pending // 不超接:上限是仓库已发出的量
|
||||
}
|
||||
if add < 0 {
|
||||
add = 0
|
||||
}
|
||||
newReceived := already + add
|
||||
upd := s.ctx.EntClient.MaterialRequest.UpdateOneID(mr.ID).
|
||||
SetSentQty(float64(newSent)).SetOperator(operator)
|
||||
if newSent >= int(mr.ReqQty) && mr.Status != "DONE" {
|
||||
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, "sentQty": newSent, "reqQty": int(mr.ReqQty)})
|
||||
"接料确认", map[string]any{"add": add, "receivedQty": newReceived, "sentQty": sent,
|
||||
"reqQty": int(mr.ReqQty), "awaiting": sent - newReceived})
|
||||
s.notifyDashboard()
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user