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 }