package handler import ( "net/http" "strings" "bj_power_wms/ent/inbounddetail" "bj_power_wms/ent/inboundorder" "bj_power_wms/ent/inventory" "bj_power_wms/internal/svc" ) // voidInboundHandler 入库单作废(客户诉求 2026-09-19): // 填写作废原因 → 回滚本入库单对应的库存 → 单据标记作废(保留记录供追溯)。 // // 回滚口径(绝不混淆): // - 结构件(按批次):按"本单明细"的批次号扣减对应批次库存数量,扣到 0 删除该库存行。 // - 电气件(按 SN):按"本单明细"的 SN 精确删除对应 SN 库存行——只回滚本单的 SN, // 不会波及其它入库单/物料的 SN(SN 全局唯一,一码一物)。 // // 安全门禁:目标库存已被锁定(lockedQty>0,已被出库预留)或数量不足/已出库, // 一律拒绝作废并提示先处理,避免把已消耗库存回滚成负数或误删他单库存。 func voidInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { InboundNo string `json:"inboundNo"` Reason string `json:"reason"` } if err := parseJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, "参数错误") return } req.InboundNo = strings.TrimSpace(req.InboundNo) req.Reason = strings.TrimSpace(req.Reason) if req.InboundNo == "" { fail(w, http.StatusBadRequest, "入库单号必填") return } if req.Reason == "" { fail(w, http.StatusBadRequest, "作废原因必填") return } ib, err := ctx.EntClient.InboundOrder.Query(). Where(inboundorder.InboundNoEQ(req.InboundNo)).Only(ctx0()) if err != nil { fail(w, http.StatusNotFound, "入库单不存在:"+req.InboundNo) return } if ib.Voided { fail(w, http.StatusConflict, "该入库单已作废,请勿重复操作") return } tx, err := ctx.EntClient.Tx(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error()) return } committed := false defer func() { if !committed { _ = tx.Rollback() } }() details, err := tx.InboundDetail.Query(). Where(inbounddetail.InboundNoEQ(req.InboundNo)).All(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } if len(details) == 0 { fail(w, http.StatusConflict, "该入库单无明细,无法回滚库存") return } rolledSN := 0 rolledQty := 0 if ib.ManageMode == 2 { // 电气件:逐个 SN 精确回滚(只删本单 SN 的库存行) for _, d := range details { if d.SnCode == "" { continue } inv, e := tx.Inventory.Query().Where(inventory.SnCodeEQ(d.SnCode)).Only(ctx0()) if e != nil { fail(w, http.StatusConflict, "SN 库存行不存在,无法作废:"+d.SnCode) return } if inv.LockedQty > 0 { fail(w, http.StatusConflict, "SN "+d.SnCode+" 已被出库锁定,请先取消出库再作废") return } if inv.Status == "出库" { fail(w, http.StatusConflict, "SN "+d.SnCode+" 已出库,无法作废(请走退库)") return } if err := tx.Inventory.DeleteOneID(inv.ID).Exec(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "回滚 SN 库存失败: "+err.Error()) return } rolledSN++ } } else { // 结构件:按批次扣减本单数量 for _, d := range details { if d.BatchNo == "" { continue } inv, e := tx.Inventory.Query(). Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(d.BatchNo)).Only(ctx0()) if e != nil { fail(w, http.StatusConflict, "批次库存行不存在,无法作废:"+d.BatchNo) return } if inv.LockedQty > 0 { fail(w, http.StatusConflict, "批次 "+d.BatchNo+" 存在锁定库存,请先取消出库再作废") return } if inv.Quantity < d.Quantity { fail(w, http.StatusConflict, "批次 "+d.BatchNo+" 在库数量不足(已被出库消耗),无法作废") return } newQty := inv.Quantity - d.Quantity if newQty <= 0 { if err := tx.Inventory.DeleteOneID(inv.ID).Exec(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "回滚批次库存失败: "+err.Error()) return } } else if _, err := tx.Inventory.UpdateOneID(inv.ID).SetQuantity(newQty).Save(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "回滚批次库存失败: "+err.Error()) return } rolledQty += d.Quantity } } if _, err := tx.InboundOrder.UpdateOneID(ib.ID). SetVoided(true).SetVoidReason(req.Reason). SetNillableVoidedBy(strPtr(r.Header.Get("X-Username"))). SetVoidedAt(timeNow().Unix()). 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(), "inbound.void", r.Header.Get("X-Username"), "inbound", req.InboundNo, "作废入库单 "+req.InboundNo+",原因:"+req.Reason, map[string]any{ "rolledSN": rolledSN, "rolledQty": rolledQty}) ok(w, map[string]any{ "inboundNo": req.InboundNo, "rolledSN": rolledSN, "rolledQty": rolledQty, }) } }