refactor(wms): 合并库存表为统一inventory,重构业务逻辑
1. 合并inventory_batch与serial_number为统一inventory表,用manage_mode区分结构件批次/精密件SN 2. 移除冗余的material_type字段与相关逻辑 3. 重构库存查询、检验、入库出库等业务逻辑适配新表结构 4. 调整前端路由与菜单,拆分基础数据为区域维护和物料档案 5. 优化入库校验逻辑,避免空单问题 6. 调整Vite构建配置,关闭自动清空输出目录 7. 为各模块添加详细中文注释,统一业务术语 8. 生成并更新Ent自动代码文件
This commit is contained in:
@@ -4,12 +4,10 @@ import (
|
||||
"net/http"
|
||||
|
||||
"bj_power_wms/ent"
|
||||
"bj_power_wms/ent/inventorybatch"
|
||||
"bj_power_wms/ent/inventorylock"
|
||||
"bj_power_wms/ent/inventory"
|
||||
"bj_power_wms/ent/ordermaterialledger"
|
||||
"bj_power_wms/ent/outbounddetail"
|
||||
"bj_power_wms/ent/outboundorder"
|
||||
"bj_power_wms/ent/serialnumber"
|
||||
"bj_power_wms/internal/svc"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -37,7 +35,11 @@ type deductStockRequest struct {
|
||||
|
||||
// deductStockHandler 出库扣减
|
||||
// body: { orderNo, materialCode, batchNo?, snList?[], qty, operator, targetDock? }
|
||||
// 强约束:累计出库 ≤ 工单 BOM 需求量;等于时领料完结
|
||||
// 强约束:累计出库 ≤ 工单 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
|
||||
@@ -75,8 +77,21 @@ func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
|
||||
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 := ctx.EntClient.OutboundOrder.Create().
|
||||
ob, err := tx.OutboundOrder.Create().
|
||||
SetOutboundNo(outboundNo).
|
||||
SetOutboundType("workorder").
|
||||
SetNillableOrderNo(strPtr(req.OrderNo)).
|
||||
@@ -84,7 +99,7 @@ func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
SetManageMode(1).
|
||||
SetNillableBatchNo(strPtr(req.BatchNo)).
|
||||
SetNillableZoneCode(strPtr(req.ZoneCode)).
|
||||
SetQuantity(actualQty).
|
||||
SetQuantity(0).
|
||||
SetNillableOperator(strPtr(req.Operator)).
|
||||
SetNillableReviewer(strPtr(req.Reviewer)).
|
||||
SetNillableTargetDock(strPtr(req.TargetDock)).
|
||||
@@ -95,117 +110,125 @@ func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 扣减批次
|
||||
// 3. 扣减批次(原子:quantity>=qty 才减,避免超扣/负数;locked_qty 同步回收)
|
||||
if req.BatchNo != "" {
|
||||
batch, err := ctx.EntClient.InventoryBatch.Query().
|
||||
Where(inventorybatch.BatchNoEQ(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
|
||||
}
|
||||
if batch.Quantity < req.Qty {
|
||||
fail(w, http.StatusConflict, "批次库存不足: "+req.BatchNo+" 当前 "+itoa(batch.Quantity))
|
||||
avail := b.Quantity - b.LockedQty
|
||||
if avail < req.Qty {
|
||||
fail(w, http.StatusConflict, "批次库存不足: "+req.BatchNo+" 可用 "+itoa(avail))
|
||||
return
|
||||
}
|
||||
ctx.EntClient.InventoryBatch.UpdateOneID(batch.ID).
|
||||
SetQuantity(batch.Quantity - req.Qty).
|
||||
SetLockedQty(batch.LockedQty - min(req.Qty, batch.LockedQty)).
|
||||
ExecX(ctx0())
|
||||
ctx.EntClient.OutboundDetail.Create().
|
||||
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)).
|
||||
SaveX(ctx0())
|
||||
Save(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
actualQty = req.Qty
|
||||
}
|
||||
|
||||
// 4. 扣减 SN
|
||||
// 4. 扣减 SN(原子:仅 在库/锁定 的 SN 可出库 → 置出库)
|
||||
if len(req.SnList) > 0 {
|
||||
for _, sn := range req.SnList {
|
||||
snRec, err := ctx.EntClient.SerialNumber.Query().
|
||||
Where(
|
||||
serialnumber.SnCodeEQ(sn),
|
||||
serialnumber.StatusIn("在库", "锁定"),
|
||||
).
|
||||
Only(ctx0())
|
||||
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.StatusBadRequest, "SN 不可出库: "+sn)
|
||||
fail(w, http.StatusInternalServerError, "SN 出库失败: "+sn+" "+err.Error())
|
||||
return
|
||||
}
|
||||
ctx.EntClient.SerialNumber.UpdateOneID(snRec.ID).
|
||||
SetStatus("出库").
|
||||
SetCurrentZone(req.ZoneCode).
|
||||
ExecX(ctx0())
|
||||
ctx.EntClient.OutboundDetail.Create().
|
||||
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)).
|
||||
SaveX(ctx0())
|
||||
Save(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
actualQty = len(req.SnList)
|
||||
}
|
||||
|
||||
// 5. 更新出库单实际数量
|
||||
ctx.EntClient.OutboundOrder.UpdateOneID(ob.ID).
|
||||
SetQuantity(actualQty).ExecX(ctx0())
|
||||
|
||||
// 6. 更新工单台账:累计出库
|
||||
newOutQty := ledger.OutQty + actualQty
|
||||
if newOutQty > ledger.TotalQty {
|
||||
// 超过需求:回滚库存(先释放已扣)
|
||||
rollbackDeduct(ctx, req, actualQty)
|
||||
fail(w, http.StatusConflict, "出库数量超过工单 BOM 需求: 累计 "+itoa(ledger.OutQty)+" + "+itoa(actualQty)+" > 需求 "+itoa(ledger.TotalQty))
|
||||
if _, err = tx.OutboundOrder.UpdateOneID(ob.ID).
|
||||
SetQuantity(actualQty).Save(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "更新出库单失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
status := ledger.Status
|
||||
if newOutQty >= ledger.TotalQty {
|
||||
|
||||
// 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 = "领料完结"
|
||||
}
|
||||
ctx.EntClient.OrderMaterialLedger.UpdateOneID(ledger.ID).
|
||||
if _, err = tx.OrderMaterialLedger.UpdateOneID(ledgerNow.ID).
|
||||
SetOutQty(newOutQty).
|
||||
SetStatus(status).
|
||||
ExecX(ctx0())
|
||||
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
|
||||
|
||||
// 7. 出库记录已生成,返回成功
|
||||
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": ledger.TotalQty,
|
||||
"ledgerTotal": ledgerNow.TotalQty,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// rollbackDeduct 出库扣减回滚(超发保护)
|
||||
func rollbackDeduct(ctx *svc.ServiceContext, req deductStockRequest, qty int) {
|
||||
if req.BatchNo != "" {
|
||||
batch, err := ctx.EntClient.InventoryBatch.Query().
|
||||
Where(inventorybatch.BatchNoEQ(req.BatchNo)).Only(ctx0())
|
||||
if err == nil {
|
||||
ctx.EntClient.InventoryBatch.UpdateOneID(batch.ID).
|
||||
SetQuantity(batch.Quantity + qty).
|
||||
ExecX(ctx0())
|
||||
}
|
||||
}
|
||||
for _, sn := range req.SnList {
|
||||
ctx.EntClient.SerialNumber.Update().
|
||||
Where(serialnumber.SnCodeEQ(sn)).
|
||||
SetStatus("在库").ExecX(ctx0())
|
||||
}
|
||||
// 释放对应锁定
|
||||
ctx.EntClient.InventoryLock.Update().
|
||||
Where(inventorylock.OrderNoEQ(req.OrderNo), inventorylock.StatusEQ("ACTIVE")).
|
||||
SetStatus("RELEASED").
|
||||
ExecX(ctx0())
|
||||
}
|
||||
|
||||
// queryOutboundHandler 出库单查询
|
||||
func queryOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user