Files
bj_power/bj_power_wms/internal/handler/outbound.go
T
SunYF 68f50ee46e refactor(wms): 合并库存表为统一inventory,重构业务逻辑
1. 合并inventory_batch与serial_number为统一inventory表,用manage_mode区分结构件批次/精密件SN
2. 移除冗余的material_type字段与相关逻辑
3. 重构库存查询、检验、入库出库等业务逻辑适配新表结构
4. 调整前端路由与菜单,拆分基础数据为区域维护和物料档案
5. 优化入库校验逻辑,避免空单问题
6. 调整Vite构建配置,关闭自动清空输出目录
7. 为各模块添加详细中文注释,统一业务术语
8. 生成并更新Ent自动代码文件
2026-09-02 08:21:11 +08:00

292 lines
9.0 KiB
Go

package handler
import (
"net/http"
"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"`
Remark string `json:"remark"`
}
// 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, "工单物料台账不存在,请先同步工单 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()
}
}()
// 2. 创建出库单
ob, err := tx.OutboundOrder.Create().
SetOutboundNo(outboundNo).
SetOutboundType("workorder").
SetNillableOrderNo(strPtr(req.OrderNo)).
SetMaterialCode(req.MaterialCode).
SetManageMode(1).
SetNillableBatchNo(strPtr(req.BatchNo)).
SetNillableZoneCode(strPtr(req.ZoneCode)).
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
}
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 可出库 → 置出库)
if len(req.SnList) > 0 {
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).
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
if newOutQty > ledgerNow.TotalQty {
fail(w, http.StatusConflict, "出库数量超过工单 BOM 需求: 累计 "+itoa(ledgerNow.OutQty)+" + "+itoa(actualQty)+" > 需求 "+itoa(ledgerNow.TotalQty))
return
}
status := ledgerNow.Status
if newOutQty >= ledgerNow.TotalQty {
status = "领料完结"
}
if _, err = tx.OrderMaterialLedger.UpdateOneID(ledgerNow.ID).
SetOutQty(newOutQty).
SetStatus(status).
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
logx.Infof("deduct stock: order=%s material=%s qty=%d", req.OrderNo, req.MaterialCode, actualQty)
ok(w, map[string]any{
"outboundNo": outboundNo,
"qty": actualQty,
"orderNo": req.OrderNo,
"ledgerOut": newOutQty,
"ledgerTotal": ledgerNow.TotalQty,
"status": status,
})
}
}
// queryOutboundHandler 出库单查询
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")
q := ctx.EntClient.OutboundOrder.Query()
if orderNo != "" {
q = q.Where(outboundorder.OrderNoEQ(orderNo))
}
if materialCode != "" {
q = q.Where(outboundorder.MaterialCodeEQ(materialCode))
}
if outboundNo != "" {
q = q.Where(outboundorder.OutboundNoEQ(outboundNo))
}
total, err := q.Count(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
list, err := q.Order(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,
})
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}