feat: 完成客户第12条需求+多系统改造

1. 新增预警表添加工单号字段,支持按工单号检索预警
2. 添加工单过滤在制品/绩效报表/预警中心查询
3. 补全WMS台账字段口径与权限配置
4. 修复备料单与数量不符处理流程
5. 新增配送进度/库位联动/自动出库功能
6. 修复AGV配送状态更新逻辑
7. 优化退料与库存管理细节
This commit is contained in:
SunYF
2026-09-19 17:04:58 +08:00
parent 62e45b7379
commit 05b0b8a909
89 changed files with 7912 additions and 429 deletions
+164 -20
View File
@@ -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":