package handler import ( "encoding/json" "net/http" "strings" "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, "工单物料台账不存在,请先在 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() } }() // 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") boxNo := r.URL.Query().Get("boxNo") 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)) } if boxNo != "" { q = q.Where(outboundorder.BoxNoContains(boxNo)) } 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 工单) 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"` BoxNo string `json:"boxNo"` ContractNo string `json:"contractNo"` Remark string `json:"remark"` } // 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 } 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 } 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"). SetMaterialCode(req.MaterialCode). SetManageMode(manageMode). SetNillableBatchNo(strPtr(req.BatchNo)). SetNillableZoneCode(strPtr(req.ZoneCode)). 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 } 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 { 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 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") orderNo := r.URL.Query().Get("orderNo") materialCode := r.URL.Query().Get("materialCode") boxNo := r.URL.Query().Get("boxNo") q := ctx.EntClient.OutboundOrder.Query() if outboundType != "" { q = q.Where(outboundorder.OutboundTypeEQ(outboundType)) } if orderNo != "" { q = q.Where(outboundorder.OrderNoEQ(orderNo)) } if materialCode != "" { q = q.Where(outboundorder.MaterialCodeEQ(materialCode)) } if boxNo != "" { q = q.Where(outboundorder.BoxNoContains(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 tn := "通用出库" if ob.OutboundType == "workorder" { tn = "备料出库" } matrix = append(matrix, []any{ ob.OutboundNo, tn, ob.OrderNo, ob.MaterialCode, ob.MaterialName, ob.Quantity, 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 }