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

820 lines
30 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 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
}
}