1. 新增预警表添加工单号字段,支持按工单号检索预警 2. 添加工单过滤在制品/绩效报表/预警中心查询 3. 补全WMS台账字段口径与权限配置 4. 修复备料单与数量不符处理流程 5. 新增配送进度/库位联动/自动出库功能 6. 修复AGV配送状态更新逻辑 7. 优化退料与库存管理细节
820 lines
30 KiB
Go
820 lines
30 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"bj_power_wms/ent"
|
||
"bj_power_wms/ent/inventory"
|
||
"bj_power_wms/ent/ordermaterialledger"
|
||
"bj_power_wms/ent/outbounddetail"
|
||
"bj_power_wms/ent/outboundorder"
|
||
"bj_power_wms/internal/svc"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/zeromicro/go-zero/core/logx"
|
||
)
|
||
|
||
// deductStockRequest 出库扣减请求
|
||
// createOutboundHandler 创建出库单(等价于扣减库存,走同一套约束)
|
||
func createOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return deductStockHandler(ctx)
|
||
}
|
||
|
||
type deductStockRequest struct {
|
||
OrderNo string `json:"orderNo"`
|
||
MaterialCode string `json:"materialCode"`
|
||
BatchNo string `json:"batchNo"`
|
||
SnList []string `json:"snList"`
|
||
Qty int `json:"qty"`
|
||
Operator string `json:"operator"`
|
||
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 出库扣减
|
||
// body: { orderNo, materialCode, batchNo?, snList?[], qty, operator, targetDock? }
|
||
// 强约束:累计出库 ≤ 工单 BOM 需求量;等于时领料完结。
|
||
//
|
||
// 事务保证:出库单 + 库存行扣减(inventory) + 出库明细 + 工单台账 原子提交,
|
||
// 任一环节失败整体回滚,杜绝"出库单生成了但库存没扣"或"台账没更新"的中间态。
|
||
// 扣减本身用条件原子更新(AddQuantity(-qty) WHERE quantity>=qty),即使并发也不会出现负库存。
|
||
func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
var req deductStockRequest
|
||
if err := parseJSON(r, &req); err != nil {
|
||
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
|
||
return
|
||
}
|
||
if req.OrderNo == "" || req.MaterialCode == "" {
|
||
fail(w, http.StatusBadRequest, "orderNo 和 materialCode 必填")
|
||
return
|
||
}
|
||
|
||
// 1. 工单台账强约束:累计出库 ≤ 总需求
|
||
ledger, err := ctx.EntClient.OrderMaterialLedger.Query().
|
||
Where(
|
||
ordermaterialledger.OrderNoEQ(req.OrderNo),
|
||
ordermaterialledger.MaterialCodeEQ(req.MaterialCode),
|
||
).
|
||
Only(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusBadRequest, "工单物料台账不存在,请先在 MES 同步工单 BOM 后再领料;如无需挂靠工单(退料/样品/报废/发货),请改用「通用出库」: "+req.OrderNo+"/"+req.MaterialCode)
|
||
return
|
||
}
|
||
if ledger.Status == "领料完结" {
|
||
fail(w, http.StatusConflict, "工单领料已完结,禁止再出库")
|
||
return
|
||
}
|
||
|
||
// 实际扣减数量
|
||
actualQty := req.Qty
|
||
if req.BatchNo == "" && len(req.SnList) == 0 {
|
||
fail(w, http.StatusBadRequest, "batchNo 或 snList 至少提供一个")
|
||
return
|
||
}
|
||
|
||
outboundNo := "OB" + uuid.NewString()[:12]
|
||
|
||
// 开启事务:出库单 + 库存扣减 + 明细 + 台账 原子提交
|
||
tx, err := ctx.EntClient.Tx(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
|
||
return
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// 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(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)).
|
||
SetNillableTargetDock(strPtr(req.TargetDock)).
|
||
SetNillableRemark(strPtr(req.Remark)).
|
||
Save(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, "创建出库单失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 3. 扣减批次(原子:quantity>=qty 才减,避免超扣/负数;locked_qty 同步回收)
|
||
if req.BatchNo != "" {
|
||
b, err := tx.Inventory.Query().
|
||
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(req.BatchNo)).
|
||
Only(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusBadRequest, "批次不存在: "+req.BatchNo)
|
||
return
|
||
}
|
||
avail := b.Quantity - b.LockedQty
|
||
if avail < req.Qty {
|
||
fail(w, http.StatusConflict, "批次库存不足: "+req.BatchNo+" 可用 "+itoa(avail))
|
||
return
|
||
}
|
||
// 质量门禁(P0-2,客户口径):仅「合格」批次可领料上线;未检(待检)/不合格/待退 一律拦截
|
||
if b.QualityStatus != QStatusPass {
|
||
fail(w, http.StatusConflict, "批次 "+req.BatchNo+" 质量状态为「"+qualityLabel(b.QualityStatus)+"」,仅合格品可领料上线(请先完成质量检验)")
|
||
return
|
||
}
|
||
aff, err := tx.Inventory.Update().
|
||
Where(inventory.ID(b.ID), inventory.QuantityGTE(req.Qty)).
|
||
AddQuantity(-req.Qty).
|
||
AddLockedQty(-min(req.Qty, b.LockedQty)).
|
||
Save(ctx0())
|
||
if err != nil || aff == 0 {
|
||
fail(w, http.StatusConflict, "批次扣减失败(库存不足或并发冲突): "+req.BatchNo)
|
||
return
|
||
}
|
||
if _, err = tx.OutboundDetail.Create().
|
||
SetOutboundNo(outboundNo).
|
||
SetBatchNo(req.BatchNo).
|
||
SetMaterialCode(req.MaterialCode).
|
||
SetQuantity(req.Qty).
|
||
SetNillableOrderNo(strPtr(req.OrderNo)).
|
||
Save(ctx0()); err != nil {
|
||
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
|
||
return
|
||
}
|
||
actualQty = req.Qty
|
||
}
|
||
|
||
// 4. 扣减 SN(原子:仅 在库/锁定 的 SN 可出库 → 置出库)
|
||
// 质量门禁(P0-2,客户口径):仅「合格」SN 可出库(未检/不合格/待退 拦截;退货/报废走「通用出库」)
|
||
if len(req.SnList) > 0 {
|
||
for _, sn := range req.SnList {
|
||
aff, err := tx.Inventory.Update().
|
||
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(sn), inventory.StatusIn("在库", "锁定"),
|
||
inventory.QualityStatusEQ(QStatusPass)).
|
||
SetStatus("出库").
|
||
SetNillableZoneCode(strPtr(req.ZoneCode)).
|
||
Save(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, "SN 出库失败: "+sn+" "+err.Error())
|
||
return
|
||
}
|
||
if aff == 0 {
|
||
fail(w, http.StatusBadRequest, "SN 不可出库(不存在、非在库/锁定,或质量状态非「合格」): "+sn)
|
||
return
|
||
}
|
||
if _, err = tx.OutboundDetail.Create().
|
||
SetOutboundNo(outboundNo).
|
||
SetSnCode(sn).
|
||
SetMaterialCode(req.MaterialCode).
|
||
SetQuantity(1).
|
||
SetNillableOrderNo(strPtr(req.OrderNo)).
|
||
Save(ctx0()); err != nil {
|
||
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
actualQty = len(req.SnList)
|
||
}
|
||
|
||
// 5. 更新出库单实际数量
|
||
if _, err = tx.OutboundOrder.UpdateOneID(ob.ID).
|
||
SetQuantity(actualQty).Save(ctx0()); err != nil {
|
||
fail(w, http.StatusInternalServerError, "更新出库单失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 6. 更新工单台账:累计出库(在事务内重读台账,避免并发重复累计)
|
||
ledgerNow, err := tx.OrderMaterialLedger.Query().
|
||
Where(
|
||
ordermaterialledger.OrderNoEQ(req.OrderNo),
|
||
ordermaterialledger.MaterialCodeEQ(req.MaterialCode),
|
||
).Only(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, "重读台账失败: "+err.Error())
|
||
return
|
||
}
|
||
newOutQty := ledgerNow.OutQty + actualQty
|
||
// 超量拦截按「净领用」判定(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 netOut >= ledgerNow.TotalQty {
|
||
status = "领料完结"
|
||
} else if status == "领料完结" {
|
||
// 退料回补后净额回落 → 状态回归「进行中」,允许再次补料
|
||
status = "进行中"
|
||
}
|
||
// 台账 completed_at = 最后一笔领料完结出库时间(客户诉求 问题记录 L238-240:台账增加完成时间筛选)
|
||
ledgerUpd := tx.OrderMaterialLedger.UpdateOneID(ledgerNow.ID).
|
||
SetOutQty(newOutQty).
|
||
SetStatus(status)
|
||
if status == "领料完结" && ledgerNow.Status != "领料完结" {
|
||
ledgerUpd.SetCompletedAt(time.Now().Unix())
|
||
}
|
||
if _, err = ledgerUpd.Save(ctx0()); err != nil {
|
||
fail(w, http.StatusInternalServerError, "更新台账失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 7. 提交事务
|
||
if err = tx.Commit(); err != nil {
|
||
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
|
||
return
|
||
}
|
||
committed = true
|
||
|
||
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, "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,
|
||
"outboundType": obType,
|
||
"ledgerOut": newOutQty,
|
||
"ledgerReturned": ledgerNow.ReturnedQty,
|
||
"ledgerNet": netOut,
|
||
"ledgerTotal": ledgerNow.TotalQty,
|
||
"overQty": overQty,
|
||
"status": status,
|
||
})
|
||
}
|
||
}
|
||
|
||
// queryOutboundHandler 出库单查询
|
||
// 客户诉求 L96-108:按 归属单号 / 名称图号 / 日期范围 / 目标工位 查询,多条件取交集;
|
||
// 修复此前"前端传了 outboundType/日期但后端忽略"的缺陷。
|
||
func queryOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
page := atoi(r.URL.Query().Get("page"), 1)
|
||
pageSize := atoi(r.URL.Query().Get("pageSize"), 20)
|
||
orderNo := r.URL.Query().Get("orderNo")
|
||
materialCode := r.URL.Query().Get("materialCode")
|
||
outboundNo := r.URL.Query().Get("outboundNo")
|
||
boxNo := r.URL.Query().Get("boxNo")
|
||
outboundType := r.URL.Query().Get("outboundType")
|
||
outboundCategory := r.URL.Query().Get("outboundCategory")
|
||
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))
|
||
}
|
||
if materialCode != "" {
|
||
q = q.Where(outboundorder.MaterialCodeEqualFold(materialCode))
|
||
}
|
||
if outboundNo != "" {
|
||
q = q.Where(outboundorder.OutboundNoEqualFold(outboundNo))
|
||
}
|
||
if boxNo != "" {
|
||
q = q.Where(outboundorder.BoxNoContainsFold(boxNo))
|
||
}
|
||
if outboundType != "" {
|
||
q = q.Where(outboundorder.OutboundTypeEQ(outboundType))
|
||
}
|
||
if outboundCategory != "" {
|
||
q = q.Where(outboundorder.OutboundCategoryEQ(outboundCategory))
|
||
}
|
||
if ownershipNo != "" {
|
||
q = q.Where(outboundorder.OwnershipNoContainsFold(ownershipNo))
|
||
}
|
||
if materialName != "" {
|
||
q = q.Where(outboundorder.MaterialNameContainsFold(materialName))
|
||
}
|
||
if targetStation != "" {
|
||
q = q.Where(outboundorder.TargetStationEQ(targetStation))
|
||
}
|
||
if v := r.URL.Query().Get("startDate"); v != "" {
|
||
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
|
||
q = q.Where(outboundorder.CreatedAtGTE(t.Unix()))
|
||
}
|
||
}
|
||
if v := r.URL.Query().Get("endDate"); v != "" {
|
||
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
|
||
q = q.Where(outboundorder.CreatedAtLTE(t.Add(24 * time.Hour).Unix()))
|
||
}
|
||
}
|
||
|
||
total, err := q.Count(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).
|
||
Offset((page - 1) * pageSize).Limit(pageSize).All(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
// 附带明细
|
||
type row struct {
|
||
*ent.OutboundOrder
|
||
Details []*ent.OutboundDetail `json:"details"`
|
||
}
|
||
rows := make([]row, 0, len(list))
|
||
for _, ob := range list {
|
||
details, _ := ctx.EntClient.OutboundDetail.Query().
|
||
Where(outbounddetail.OutboundNoEQ(ob.OutboundNo)).
|
||
All(ctx0())
|
||
rows = append(rows, row{ob, details})
|
||
}
|
||
|
||
ok(w, map[string]any{
|
||
"total": total,
|
||
"list": rows,
|
||
"page": page,
|
||
"pageSize": pageSize,
|
||
})
|
||
}
|
||
}
|
||
|
||
// generalOutboundRequest 通用出库请求(不依赖 MES 工单)
|
||
// 客户诉求 问题记录 L96-108:通用出库先点选类别(退料/样品/报废/发货/其他手输),
|
||
// 并记录归属(工单号/科技项目号/其他)与目标工位。
|
||
type generalOutboundRequest struct {
|
||
MaterialCode string `json:"materialCode"`
|
||
BatchNo string `json:"batchNo"`
|
||
SnList []string `json:"snList"`
|
||
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"`
|
||
OutboundCategory string `json:"outboundCategory"` // return(退料)/supplier_return(退货退供应商)/sample(样品)/scrap(报废)/deliver(发货)/other(其他)
|
||
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 通用出库(手动出库,不依赖工单)
|
||
// 适用:退料 / 样品 / 报废 / 发货 等不挂靠 MES 工单的场景。
|
||
// 支持结构件批次与电气件 SN;填箱号时一并把装箱信息(箱号/SN清单/合同号)
|
||
// 落到统一的出库主表,不再另建装箱表。
|
||
// 事务保证:出库单 + 库存扣减 + 出库明细 原子提交;库存用条件原子更新避免负库存。
|
||
func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
var req generalOutboundRequest
|
||
if err := parseJSON(r, &req); err != nil {
|
||
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
|
||
return
|
||
}
|
||
if req.MaterialCode == "" {
|
||
fail(w, http.StatusBadRequest, "materialCode 必填")
|
||
return
|
||
}
|
||
if req.BatchNo == "" && len(req.SnList) == 0 {
|
||
fail(w, http.StatusBadRequest, "batchNo 或 snList 至少提供一个")
|
||
return
|
||
}
|
||
// 客户诉求 L96-108:出库类别必选;选「其他」时备注必填(承载手输原因)
|
||
if req.OutboundCategory == "" {
|
||
fail(w, http.StatusBadRequest, "出库类别必选(退料/样品/报废/发货/其他)")
|
||
return
|
||
}
|
||
if req.OutboundCategory == "other" && strings.TrimSpace(req.Remark) == "" {
|
||
fail(w, http.StatusBadRequest, "出库类别为「其他」时必须填写原因")
|
||
return
|
||
}
|
||
|
||
manageMode := 1
|
||
if len(req.SnList) > 0 {
|
||
manageMode = 2
|
||
}
|
||
// SN 出库数量以列表长度计
|
||
actualQty := req.Qty
|
||
if manageMode == 2 {
|
||
actualQty = len(req.SnList)
|
||
}
|
||
if actualQty <= 0 {
|
||
fail(w, http.StatusBadRequest, "数量必须大于 0")
|
||
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())
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
|
||
return
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
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)).
|
||
SetNillableTargetStation(strPtr(req.TargetStation)).
|
||
SetMaterialCode(req.MaterialCode).
|
||
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)).
|
||
SetNillableBoxNo(strPtr(req.BoxNo)).
|
||
SetNillableContractNo(strPtr(req.ContractNo)).
|
||
Save(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, "创建出库单失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 批次扣减(结构件)
|
||
if req.BatchNo != "" {
|
||
b, err := tx.Inventory.Query().
|
||
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(req.BatchNo)).
|
||
Only(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusBadRequest, "批次不存在: "+req.BatchNo)
|
||
return
|
||
}
|
||
// 质量门禁(U5,客户口径):发货/样品/其他类出库仅允许「合格」品流向下游;
|
||
// 退货/报废/退料类豁免(本身即处置不合格品)。
|
||
if outboundBlocksUnqualified(req.OutboundCategory) && b.QualityStatus != QStatusPass {
|
||
fail(w, http.StatusConflict, "批次 "+req.BatchNo+" 质量状态为「"+qualityLabel(b.QualityStatus)+"」,"+outboundCategoryLabel(req.OutboundCategory)+"出库仅允许合格品(请先完成质量检验/处置)")
|
||
return
|
||
}
|
||
avail := b.Quantity - b.LockedQty
|
||
if avail < actualQty {
|
||
fail(w, http.StatusConflict, "批次库存不足: "+req.BatchNo+" 可用 "+itoa(avail))
|
||
return
|
||
}
|
||
aff, err := tx.Inventory.Update().
|
||
Where(inventory.ID(b.ID), inventory.QuantityGTE(actualQty)).
|
||
AddQuantity(-actualQty).
|
||
AddLockedQty(-min(actualQty, b.LockedQty)).
|
||
Save(ctx0())
|
||
if err != nil || aff == 0 {
|
||
fail(w, http.StatusConflict, "批次扣减失败(库存不足或并发冲突): "+req.BatchNo)
|
||
return
|
||
}
|
||
if _, err = tx.OutboundDetail.Create().
|
||
SetOutboundNo(outboundNo).
|
||
SetBatchNo(req.BatchNo).
|
||
SetMaterialCode(req.MaterialCode).
|
||
SetQuantity(actualQty).
|
||
Save(ctx0()); err != nil {
|
||
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
|
||
// SN 扣减(电气件):仅在库/锁定的 SN 可出库
|
||
if len(req.SnList) > 0 {
|
||
// 质量门禁(U5,客户口径):发货/样品/其他类出库仅允许「合格」SN;退货/报废/退料类豁免。
|
||
if outboundBlocksUnqualified(req.OutboundCategory) {
|
||
passCnt, _ := tx.Inventory.Query().
|
||
Where(inventory.ManageModeEQ(2), inventory.SnCodeIn(req.SnList...), inventory.QualityStatusEQ(QStatusPass)).
|
||
Count(ctx0())
|
||
if passCnt < len(req.SnList) {
|
||
fail(w, http.StatusConflict, "所选 SN 含非合格品(未检/不合格/待退),"+outboundCategoryLabel(req.OutboundCategory)+"出库仅允许合格品(请先完成质量检验/处置)")
|
||
return
|
||
}
|
||
}
|
||
for _, sn := range req.SnList {
|
||
aff, err := tx.Inventory.Update().
|
||
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(sn), inventory.StatusIn("在库", "锁定")).
|
||
SetStatus("出库").
|
||
SetNillableZoneCode(strPtr(req.ZoneCode)).
|
||
Save(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, "SN 出库失败: "+sn+" "+err.Error())
|
||
return
|
||
}
|
||
if aff == 0 {
|
||
fail(w, http.StatusBadRequest, "SN 不可出库(不存在或非在库/锁定): "+sn)
|
||
return
|
||
}
|
||
if _, err = tx.OutboundDetail.Create().
|
||
SetOutboundNo(outboundNo).
|
||
SetSnCode(sn).
|
||
SetMaterialCode(req.MaterialCode).
|
||
SetQuantity(1).
|
||
Save(ctx0()); err != nil {
|
||
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// 回写实际数量 + 装箱 SN 清单(JSON)
|
||
upd := tx.OutboundOrder.UpdateOneID(ob.ID).SetQuantity(actualQty)
|
||
if len(req.SnList) > 0 && req.BoxNo != "" {
|
||
upd.SetBoxSnList(snListJSON(req.SnList))
|
||
}
|
||
if _, err = upd.Save(ctx0()); err != nil {
|
||
fail(w, http.StatusInternalServerError, "更新出库单失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
if err = tx.Commit(); err != nil {
|
||
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
|
||
return
|
||
}
|
||
committed = true
|
||
|
||
ctx.EventLog.Write(ctx0(), "outbound.general", r.Header.Get("X-Username"), "outbound", outboundNo,
|
||
"通用出库 "+req.MaterialCode+" ×"+itoa(actualQty),
|
||
map[string]any{"qty": actualQty, "boxNo": req.BoxNo, "snCount": len(req.SnList)})
|
||
logx.Infof("general outbound: material=%s qty=%d box=%s", req.MaterialCode, actualQty, req.BoxNo)
|
||
ok(w, map[string]any{
|
||
"outboundNo": outboundNo,
|
||
"qty": actualQty,
|
||
"boxNo": req.BoxNo,
|
||
"type": "general",
|
||
})
|
||
}
|
||
}
|
||
|
||
// snListJSON 把 SN 列表序列化为 JSON 数组字符串
|
||
func snListJSON(sn []string) string {
|
||
b, err := json.Marshal(sn)
|
||
if err != nil {
|
||
return "[]"
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// exportOutboundHandler 统一主表全部出库记录导出
|
||
// 不依赖前端分页,一次返回全部(可按类型/工单/物料/箱号过滤),便于整表导出。
|
||
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")
|
||
startDate, endDate, rerr := normalizeExportRange(r.URL.Query().Get("startDate"), r.URL.Query().Get("endDate"))
|
||
if rerr != nil {
|
||
fail(w, http.StatusBadRequest, rerr.Error())
|
||
return
|
||
}
|
||
startUnix, endUnix := exportRangeUnix(startDate, endDate)
|
||
|
||
q := ctx.EntClient.OutboundOrder.Query()
|
||
q = q.Where(outboundorder.CreatedAtGTE(startUnix), outboundorder.CreatedAtLTE(endUnix))
|
||
if outboundType != "" {
|
||
q = q.Where(outboundorder.OutboundTypeEQ(outboundType))
|
||
}
|
||
if status != "" {
|
||
q = q.Where(outboundorder.StatusEQ(status))
|
||
}
|
||
if orderNo != "" {
|
||
q = q.Where(outboundorder.OrderNoEqualFold(orderNo))
|
||
}
|
||
if materialCode != "" {
|
||
q = q.Where(outboundorder.MaterialCodeEqualFold(materialCode))
|
||
}
|
||
if boxNo != "" {
|
||
q = q.Where(outboundorder.BoxNoContainsFold(boxNo))
|
||
}
|
||
|
||
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0())
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
type exportRow struct {
|
||
*ent.OutboundOrder
|
||
SnCodes string `json:"snCodes"`
|
||
Details []*ent.OutboundDetail `json:"details"`
|
||
}
|
||
rows := make([]exportRow, 0, len(list))
|
||
for _, ob := range list {
|
||
details, _ := ctx.EntClient.OutboundDetail.Query().
|
||
Where(outbounddetail.OutboundNoEQ(ob.OutboundNo)).
|
||
All(ctx0())
|
||
er := exportRow{OutboundOrder: ob, SnCodes: "", Details: details}
|
||
sns := make([]string, 0, len(details))
|
||
for _, d := range details {
|
||
if d.SnCode != "" {
|
||
sns = append(sns, d.SnCode)
|
||
}
|
||
}
|
||
if len(sns) > 0 {
|
||
er.SnCodes = strings.Join(sns, ",")
|
||
}
|
||
rows = append(rows, er)
|
||
}
|
||
|
||
// xlsx 导出
|
||
if r.URL.Query().Get("format") == "xlsx" {
|
||
headers := []string{"出库单号", "类型", "状态", "类别", "工单号", "归属单号", "图号", "物料名称", "库位", "数量", "目标工位", "装箱号", "合同号", "SN清单", "创建时间"}
|
||
matrix := make([][]any, 0, len(rows))
|
||
for _, row := range rows {
|
||
ob := row.OutboundOrder
|
||
matrix = append(matrix, []any{
|
||
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),
|
||
})
|
||
}
|
||
sendExcel(w, xlsxFilename("出库管理"), headers, matrix)
|
||
return
|
||
}
|
||
|
||
ok(w, map[string]any{"total": len(rows), "list": rows})
|
||
}
|
||
}
|
||
|
||
func min(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
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":
|
||
return "退料"
|
||
case "sample":
|
||
return "样品"
|
||
case "scrap":
|
||
return "报废"
|
||
case "deliver":
|
||
return "发货"
|
||
case "supplier_return":
|
||
return "退货"
|
||
case "other":
|
||
return "其他"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// outboundBlocksUnqualified 出库质量门禁判定(U5,客户口径):
|
||
// 发货(deliver)/样品(sample)/其他(other) 及任何未知类别 → 拦截不合格/待退品(默认拦截,避免漏洞);
|
||
// 退货(supplier_return,退回供应商)/报废(scrap)/退料(return,工位退料) → 豁免(本身即在处置不合格品)。
|
||
func outboundBlocksUnqualified(category string) bool {
|
||
switch category {
|
||
case "supplier_return", "scrap", "return":
|
||
return false
|
||
default:
|
||
return true
|
||
}
|
||
}
|