feat: 完成客户第12条需求+多系统改造
1. 新增预警表添加工单号字段,支持按工单号检索预警 2. 添加工单过滤在制品/绩效报表/预警中心查询 3. 补全WMS台账字段口径与权限配置 4. 修复备料单与数量不符处理流程 5. 新增配送进度/库位联动/自动出库功能 6. 修复AGV配送状态更新逻辑 7. 优化退料与库存管理细节
This commit is contained in:
@@ -33,7 +33,17 @@ type deductStockRequest struct {
|
||||
Reviewer string `json:"reviewer"`
|
||||
TargetDock string `json:"targetDock"`
|
||||
ZoneCode string `json:"zoneCode"`
|
||||
ShelfNo string `json:"shelfNo"`
|
||||
LayerNo string `json:"layerNo"`
|
||||
PositionNo string `json:"positionNo"`
|
||||
Remark string `json:"remark"`
|
||||
// OutboundType 出库类型:workorder(备料出库,默认) / refill(工位叫料·补料出库)
|
||||
OutboundType string `json:"outboundType"`
|
||||
// NeedAgv 是否需要 AGV 配送到接驳台(备料/补料一般为 true)
|
||||
NeedAgv bool `json:"needAgv"`
|
||||
// OverReason 超 BOM 领用原因(损耗/报废/返修/其他)。
|
||||
// 超领不硬卡:net_out > 需求时,填了原因即放行并留痕;未填仍 409 拦截。
|
||||
OverReason string `json:"overReason"`
|
||||
}
|
||||
|
||||
// deductStockHandler 出库扣减
|
||||
@@ -93,15 +103,29 @@ func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. 创建出库单
|
||||
// 1.5 出库库位兜底(客户诉求 一.9「出库相同」):未填的层级按
|
||||
// 「批次在库记录 → 首个 SN 在库记录」补全,保证出库单留痕到具体货位。
|
||||
req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo =
|
||||
outboundLocationFallback(ctx, req.BatchNo, req.SnList, req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo)
|
||||
|
||||
// 2. 创建出库单(出库即扣库存 → 初始状态「已出库」;叫AGV到位后置「已送达」)
|
||||
obType := req.OutboundType
|
||||
if obType != "refill" {
|
||||
obType = "workorder"
|
||||
}
|
||||
ob, err := tx.OutboundOrder.Create().
|
||||
SetOutboundNo(outboundNo).
|
||||
SetOutboundType("workorder").
|
||||
SetOutboundType(obType).
|
||||
SetStatus("已出库").
|
||||
SetNeedAgv(req.NeedAgv).
|
||||
SetNillableOrderNo(strPtr(req.OrderNo)).
|
||||
SetMaterialCode(req.MaterialCode).
|
||||
SetManageMode(1).
|
||||
SetNillableBatchNo(strPtr(req.BatchNo)).
|
||||
SetNillableZoneCode(strPtr(req.ZoneCode)).
|
||||
SetNillableShelfNo(strPtr(req.ShelfNo)).
|
||||
SetNillableLayerNo(strPtr(req.LayerNo)).
|
||||
SetNillablePositionNo(strPtr(req.PositionNo)).
|
||||
SetQuantity(0).
|
||||
SetNillableOperator(strPtr(req.Operator)).
|
||||
SetNillableReviewer(strPtr(req.Reviewer)).
|
||||
@@ -204,13 +228,27 @@ func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
newOutQty := ledgerNow.OutQty + actualQty
|
||||
if newOutQty > ledgerNow.TotalQty {
|
||||
fail(w, http.StatusConflict, "出库数量超过工单 BOM 需求: 累计 "+itoa(ledgerNow.OutQty)+" + "+itoa(actualQty)+" > 需求 "+itoa(ledgerNow.TotalQty))
|
||||
return
|
||||
// 超量拦截按「净领用」判定(out_qty − returned_qty):退料会回补缺口。
|
||||
// 否则「出库100 → 退料20 → 再补料20」累计 120 > 需求 100,会被 409 永久拦死。
|
||||
netOut := newOutQty - ledgerNow.ReturnedQty
|
||||
overQty := 0
|
||||
if netOut > ledgerNow.TotalQty {
|
||||
// 超 BOM 领用不硬卡:损耗/报废/返修会真实超出 BOM 需求,硬卡会停线。
|
||||
// 规则:填了超领原因即放行并留痕(可统计、可预警);未填仍拦截。
|
||||
if req.OverReason == "" {
|
||||
fail(w, http.StatusConflict, "出库数量超过工单 BOM 需求: 净领用 "+itoa(netOut)+" > 需求 "+itoa(ledgerNow.TotalQty)+
|
||||
"(已发出 "+itoa(ledgerNow.OutQty)+" − 已退回 "+itoa(ledgerNow.ReturnedQty)+" + 本次 "+itoa(actualQty)+");"+
|
||||
"如确需超领(损耗/报废/返修),请填写超领原因后重试")
|
||||
return
|
||||
}
|
||||
overQty = netOut - ledgerNow.TotalQty
|
||||
}
|
||||
status := ledgerNow.Status
|
||||
if newOutQty >= ledgerNow.TotalQty {
|
||||
if netOut >= ledgerNow.TotalQty {
|
||||
status = "领料完结"
|
||||
} else if status == "领料完结" {
|
||||
// 退料回补后净额回落 → 状态回归「进行中」,允许再次补料
|
||||
status = "进行中"
|
||||
}
|
||||
// 台账 completed_at = 最后一笔领料完结出库时间(客户诉求 问题记录 L238-240:台账增加完成时间筛选)
|
||||
ledgerUpd := tx.OrderMaterialLedger.UpdateOneID(ledgerNow.ID).
|
||||
@@ -233,15 +271,26 @@ func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
|
||||
ctx.EventLog.Write(ctx0(), "outbound.create", r.Header.Get("X-Username"), "outbound", outboundNo,
|
||||
"备料出库 工单 "+req.OrderNo+" 物料 "+req.MaterialCode+" ×"+itoa(actualQty),
|
||||
map[string]any{"orderNo": req.OrderNo, "qty": actualQty})
|
||||
logx.Infof("deduct stock: order=%s material=%s qty=%d", req.OrderNo, req.MaterialCode, actualQty)
|
||||
map[string]any{"orderNo": req.OrderNo, "qty": actualQty, "overQty": overQty, "overReason": req.OverReason})
|
||||
if overQty > 0 {
|
||||
// 超领留痕:可统计、可预警(某工位频繁超领即异常信号)
|
||||
ctx.EventLog.Write(ctx0(), "material.over", r.Header.Get("X-Username"), "order_material_ledger", req.OrderNo,
|
||||
"超BOM领用 "+req.MaterialCode+" 超 "+itoa(overQty)+" 原因:"+req.OverReason,
|
||||
map[string]any{"orderNo": req.OrderNo, "materialCode": req.MaterialCode,
|
||||
"overQty": overQty, "reason": req.OverReason, "netOut": netOut, "total": ledgerNow.TotalQty})
|
||||
}
|
||||
logx.Infof("deduct stock: order=%s material=%s qty=%d over=%d", req.OrderNo, req.MaterialCode, actualQty, overQty)
|
||||
ok(w, map[string]any{
|
||||
"outboundNo": outboundNo,
|
||||
"qty": actualQty,
|
||||
"orderNo": req.OrderNo,
|
||||
"ledgerOut": newOutQty,
|
||||
"ledgerTotal": ledgerNow.TotalQty,
|
||||
"status": status,
|
||||
"outboundNo": outboundNo,
|
||||
"qty": actualQty,
|
||||
"orderNo": req.OrderNo,
|
||||
"outboundType": obType,
|
||||
"ledgerOut": newOutQty,
|
||||
"ledgerReturned": ledgerNow.ReturnedQty,
|
||||
"ledgerNet": netOut,
|
||||
"ledgerTotal": ledgerNow.TotalQty,
|
||||
"overQty": overQty,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -262,8 +311,12 @@ func queryOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
ownershipNo := r.URL.Query().Get("ownershipNo")
|
||||
materialName := r.URL.Query().Get("materialName")
|
||||
targetStation := r.URL.Query().Get("targetStation")
|
||||
status := r.URL.Query().Get("status")
|
||||
|
||||
q := ctx.EntClient.OutboundOrder.Query()
|
||||
if status != "" {
|
||||
q = q.Where(outboundorder.StatusEQ(status))
|
||||
}
|
||||
if orderNo != "" {
|
||||
q = q.Where(outboundorder.OrderNoEqualFold(orderNo))
|
||||
}
|
||||
@@ -346,6 +399,9 @@ type generalOutboundRequest struct {
|
||||
Qty int `json:"qty"`
|
||||
Operator string `json:"operator"`
|
||||
ZoneCode string `json:"zoneCode"`
|
||||
ShelfNo string `json:"shelfNo"`
|
||||
LayerNo string `json:"layerNo"`
|
||||
PositionNo string `json:"positionNo"`
|
||||
BoxNo string `json:"boxNo"`
|
||||
ContractNo string `json:"contractNo"`
|
||||
Remark string `json:"remark"`
|
||||
@@ -353,6 +409,8 @@ type generalOutboundRequest struct {
|
||||
OwnershipType string `json:"ownershipType"` // workorder/project/other
|
||||
OwnershipNo string `json:"ownershipNo"`
|
||||
TargetStation string `json:"targetStation"`
|
||||
TargetDock string `json:"targetDock"` // 目标接驳台(叫AGV时必填)
|
||||
NeedAgv bool `json:"needAgv"` // 是否叫AGV配送到接驳台(可选)
|
||||
}
|
||||
|
||||
// generalOutboundHandler 通用出库(手动出库,不依赖工单)
|
||||
@@ -399,6 +457,10 @@ func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 库位兜底(与工单出库同口径):未填的层级按「批次在库 → 首个 SN 在库」补全
|
||||
req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo =
|
||||
outboundLocationFallback(ctx, req.BatchNo, req.SnList, req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo)
|
||||
|
||||
outboundNo := "OB" + uuid.NewString()[:12]
|
||||
|
||||
tx, err := ctx.EntClient.Tx(ctx0())
|
||||
@@ -416,6 +478,9 @@ func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
ob, err := tx.OutboundOrder.Create().
|
||||
SetOutboundNo(outboundNo).
|
||||
SetOutboundType("general").
|
||||
SetStatus("已出库").
|
||||
SetNeedAgv(req.NeedAgv).
|
||||
SetNillableTargetDock(strPtr(req.TargetDock)).
|
||||
SetNillableOutboundCategory(strPtr(req.OutboundCategory)).
|
||||
SetNillableOwnershipType(strPtr(req.OwnershipType)).
|
||||
SetNillableOwnershipNo(strPtr(req.OwnershipNo)).
|
||||
@@ -424,6 +489,9 @@ func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
SetManageMode(manageMode).
|
||||
SetNillableBatchNo(strPtr(req.BatchNo)).
|
||||
SetNillableZoneCode(strPtr(req.ZoneCode)).
|
||||
SetNillableShelfNo(strPtr(req.ShelfNo)).
|
||||
SetNillableLayerNo(strPtr(req.LayerNo)).
|
||||
SetNillablePositionNo(strPtr(req.PositionNo)).
|
||||
SetQuantity(0).
|
||||
SetNillableOperator(strPtr(req.Operator)).
|
||||
SetNillableRemark(strPtr(req.Remark)).
|
||||
@@ -556,6 +624,7 @@ func snListJSON(sn []string) string {
|
||||
func exportOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
outboundType := r.URL.Query().Get("outboundType")
|
||||
status := r.URL.Query().Get("status")
|
||||
orderNo := r.URL.Query().Get("orderNo")
|
||||
materialCode := r.URL.Query().Get("materialCode")
|
||||
boxNo := r.URL.Query().Get("boxNo")
|
||||
@@ -571,6 +640,9 @@ func exportOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
if outboundType != "" {
|
||||
q = q.Where(outboundorder.OutboundTypeEQ(outboundType))
|
||||
}
|
||||
if status != "" {
|
||||
q = q.Where(outboundorder.StatusEQ(status))
|
||||
}
|
||||
if orderNo != "" {
|
||||
q = q.Where(outboundorder.OrderNoEqualFold(orderNo))
|
||||
}
|
||||
@@ -612,17 +684,14 @@ func exportOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
|
||||
// xlsx 导出
|
||||
if r.URL.Query().Get("format") == "xlsx" {
|
||||
headers := []string{"出库单号", "类型", "类别", "工单号", "归属单号", "图号", "物料名称", "数量", "目标工位", "装箱号", "合同号", "SN清单", "创建时间"}
|
||||
headers := []string{"出库单号", "类型", "状态", "类别", "工单号", "归属单号", "图号", "物料名称", "库位", "数量", "目标工位", "装箱号", "合同号", "SN清单", "创建时间"}
|
||||
matrix := make([][]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ob := row.OutboundOrder
|
||||
tn := "通用出库"
|
||||
if ob.OutboundType == "workorder" {
|
||||
tn = "备料出库"
|
||||
}
|
||||
matrix = append(matrix, []any{
|
||||
ob.OutboundNo, tn, outboundCategoryLabel(ob.OutboundCategory), ob.OrderNo,
|
||||
ob.OutboundNo, outboundTypeLabel(ob.OutboundType), ob.Status, outboundCategoryLabel(ob.OutboundCategory), ob.OrderNo,
|
||||
ob.OwnershipNo, ob.MaterialCode, ob.MaterialName,
|
||||
locationText(ob.ZoneCode, ob.ShelfNo, ob.LayerNo, ob.PositionNo),
|
||||
ob.Quantity, ob.TargetStation, ob.BoxNo, ob.ContractNo, row.SnCodes,
|
||||
unixFmt(ob.CreatedAt),
|
||||
})
|
||||
@@ -642,7 +711,82 @@ func min(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
// locationText 库位展示串。入库/出库/库存统一口径:`Z02 A架/3层/12位`,无内容返回空串。
|
||||
// outboundLocationFallback 出库四级库位兜底(客户诉求 一.9「出库相同」)。
|
||||
//
|
||||
// 调用方(MES 备料自动出库、前端手工出库)可能只传了区域甚至什么都没传;
|
||||
// 这里按「指定批次在库的实际货位 → 首个出库 SN 在库的实际货位」顺序,
|
||||
// 把**缺失的层级**补全,使出库单、列表、导出都能留痕到具体货位(货架/层/位置号)。
|
||||
// 只补空值、不覆盖调用方显式指定的值。
|
||||
func outboundLocationFallback(ctx *svc.ServiceContext, batchNo string, snList []string,
|
||||
zoneCode, shelfNo, layerNo, positionNo string) (string, string, string, string) {
|
||||
if zoneCode != "" && shelfNo != "" && layerNo != "" && positionNo != "" {
|
||||
return zoneCode, shelfNo, layerNo, positionNo
|
||||
}
|
||||
var src *ent.Inventory
|
||||
if batchNo != "" {
|
||||
src, _ = ctx.EntClient.Inventory.Query().
|
||||
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(batchNo)).Only(ctx0())
|
||||
}
|
||||
if src == nil && len(snList) > 0 {
|
||||
src, _ = ctx.EntClient.Inventory.Query().
|
||||
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(snList[0])).Only(ctx0())
|
||||
}
|
||||
if src == nil {
|
||||
return zoneCode, shelfNo, layerNo, positionNo
|
||||
}
|
||||
if zoneCode == "" {
|
||||
zoneCode = src.ZoneCode
|
||||
}
|
||||
if shelfNo == "" {
|
||||
shelfNo = src.ShelfNo
|
||||
}
|
||||
if layerNo == "" {
|
||||
layerNo = src.LayerNo
|
||||
}
|
||||
if positionNo == "" {
|
||||
positionNo = src.PositionNo
|
||||
}
|
||||
return zoneCode, shelfNo, layerNo, positionNo
|
||||
}
|
||||
|
||||
// locationText 库位显示文案(区域/货架/层/位置号)——列表与导出共用。
|
||||
func locationText(zoneCode, shelfNo, layerNo, positionNo string) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if shelfNo != "" {
|
||||
parts = append(parts, shelfNo+"架")
|
||||
}
|
||||
if layerNo != "" {
|
||||
parts = append(parts, layerNo+"层")
|
||||
}
|
||||
if positionNo != "" {
|
||||
parts = append(parts, positionNo+"位")
|
||||
}
|
||||
loc := strings.Join(parts, "/")
|
||||
if zoneCode == "" {
|
||||
return loc
|
||||
}
|
||||
if loc == "" {
|
||||
return zoneCode
|
||||
}
|
||||
return zoneCode + " " + loc
|
||||
}
|
||||
|
||||
// outboundCategoryLabel 出库类别显示中文(客户诉求 L96-108:退料/样品/报废/发货/其他)
|
||||
// outboundTypeLabel 出库类型中文(界面禁英文)
|
||||
func outboundTypeLabel(v string) string {
|
||||
switch v {
|
||||
case "workorder":
|
||||
return "备料出库"
|
||||
case "refill":
|
||||
return "补料出库"
|
||||
case "general":
|
||||
return "通用出库"
|
||||
default:
|
||||
return "通用出库"
|
||||
}
|
||||
}
|
||||
|
||||
func outboundCategoryLabel(v string) string {
|
||||
switch v {
|
||||
case "return":
|
||||
|
||||
Reference in New Issue
Block a user