package handler import ( "encoding/json" "net/http" "strings" "time" "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 } // 质量门禁(P0-2):不合格/待退 批次禁止上线领料(客户诉求 L135:不合格不上产线) if b.QualityStatus == "不合格" || b.QualityStatus == "待退" { fail(w, http.StatusConflict, "批次 "+req.BatchNo+" 质量状态为「"+b.QualityStatus+"」,禁止领料上线(请先完成不合格处置)") 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 可出库 → 置出库) // 质量门禁(P0-2):不合格/待退 的 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("在库", "锁定"), inventory.QualityStatusNotIn("不合格", "待退")). 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 = "领料完结" } // 台账 completed_at = 最后一笔领料完结出库时间(客户诉求 问题记录 L238-240:台账增加完成时间筛选) ledgerUpd := tx.OrderMaterialLedger.UpdateOneID(ledgerNow.ID). SetOutQty(newOutQty). SetStatus(status) if status == "领料完结" && ledgerNow.Status != "领料完结" { ledgerUpd.SetCompletedAt(time.Now().Unix()) } if _, err = ledgerUpd.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 ctx.EventLog.Write(ctx0(), "outbound.create", r.Header.Get("X-Username"), "outbound", outboundNo, "备料出库 工单 "+req.OrderNo+" 物料 "+req.MaterialCode+" ×"+itoa(actualQty), map[string]any{"orderNo": req.OrderNo, "qty": actualQty}) 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 出库单查询 // 客户诉求 L96-108:按 归属单号 / 名称图号 / 日期范围 / 目标工位 查询,多条件取交集; // 修复此前"前端传了 outboundType/日期但后端忽略"的缺陷。 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") outboundType := r.URL.Query().Get("outboundType") outboundCategory := r.URL.Query().Get("outboundCategory") ownershipNo := r.URL.Query().Get("ownershipNo") materialName := r.URL.Query().Get("materialName") targetStation := r.URL.Query().Get("targetStation") q := ctx.EntClient.OutboundOrder.Query() if orderNo != "" { q = q.Where(outboundorder.OrderNoEqualFold(orderNo)) } if materialCode != "" { q = q.Where(outboundorder.MaterialCodeEqualFold(materialCode)) } if outboundNo != "" { q = q.Where(outboundorder.OutboundNoEqualFold(outboundNo)) } if boxNo != "" { q = q.Where(outboundorder.BoxNoContainsFold(boxNo)) } if outboundType != "" { q = q.Where(outboundorder.OutboundTypeEQ(outboundType)) } if outboundCategory != "" { q = q.Where(outboundorder.OutboundCategoryEQ(outboundCategory)) } if ownershipNo != "" { q = q.Where(outboundorder.OwnershipNoContainsFold(ownershipNo)) } if materialName != "" { q = q.Where(outboundorder.MaterialNameContainsFold(materialName)) } if targetStation != "" { q = q.Where(outboundorder.TargetStationEQ(targetStation)) } if v := r.URL.Query().Get("startDate"); v != "" { if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil { q = q.Where(outboundorder.CreatedAtGTE(t.Unix())) } } if v := r.URL.Query().Get("endDate"); v != "" { if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil { q = q.Where(outboundorder.CreatedAtLTE(t.Add(24 * time.Hour).Unix())) } } 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 工单) // 客户诉求 问题记录 L96-108:通用出库先点选类别(退料/样品/报废/发货/其他手输), // 并记录归属(工单号/科技项目号/其他)与目标工位。 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"` OutboundCategory string `json:"outboundCategory"` // return(退料)/supplier_return(退货退供应商)/sample(样品)/scrap(报废)/deliver(发货)/other(其他) OwnershipType string `json:"ownershipType"` // workorder/project/other OwnershipNo string `json:"ownershipNo"` TargetStation string `json:"targetStation"` } // 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 } // 客户诉求 L96-108:出库类别必选;选「其他」时备注必填(承载手输原因) if req.OutboundCategory == "" { fail(w, http.StatusBadRequest, "出库类别必选(退料/样品/报废/发货/其他)") return } if req.OutboundCategory == "other" && strings.TrimSpace(req.Remark) == "" { fail(w, http.StatusBadRequest, "出库类别为「其他」时必须填写原因") 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"). SetNillableOutboundCategory(strPtr(req.OutboundCategory)). SetNillableOwnershipType(strPtr(req.OwnershipType)). SetNillableOwnershipNo(strPtr(req.OwnershipNo)). SetNillableTargetStation(strPtr(req.TargetStation)). 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 } // 质量门禁(U5,客户口径):发货/样品/其他类出库禁止不合格/待退品流向下游;退货/报废/退料类豁免(本身即处置不合格品)。 if outboundBlocksUnqualified(req.OutboundCategory) && (b.QualityStatus == "不合格" || b.QualityStatus == "待退") { fail(w, http.StatusConflict, "批次 "+req.BatchNo+" 质量状态为「"+b.QualityStatus+"」,"+outboundCategoryLabel(req.OutboundCategory)+"出库禁止发出(请走退货/报废处置)") 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 { // 质量门禁(U5,客户口径):发货/样品/其他类出库禁止不合格/待退 SN 流出;退货/报废/退料类豁免。 if outboundBlocksUnqualified(req.OutboundCategory) { bad, _ := tx.Inventory.Query(). Where(inventory.ManageModeEQ(2), inventory.SnCodeIn(req.SnList...), inventory.QualityStatusIn("不合格", "待退")). Count(ctx0()) if bad > 0 { fail(w, http.StatusConflict, "所选 SN 含不合格/待退品,"+outboundCategoryLabel(req.OutboundCategory)+"出库禁止发出(请走退货/报废处置)") return } } 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 ctx.EventLog.Write(ctx0(), "outbound.general", r.Header.Get("X-Username"), "outbound", outboundNo, "通用出库 "+req.MaterialCode+" ×"+itoa(actualQty), map[string]any{"qty": actualQty, "boxNo": req.BoxNo, "snCount": len(req.SnList)}) 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") startDate, endDate, rerr := normalizeExportRange(r.URL.Query().Get("startDate"), r.URL.Query().Get("endDate")) if rerr != nil { fail(w, http.StatusBadRequest, rerr.Error()) return } startUnix, endUnix := exportRangeUnix(startDate, endDate) q := ctx.EntClient.OutboundOrder.Query() q = q.Where(outboundorder.CreatedAtGTE(startUnix), outboundorder.CreatedAtLTE(endUnix)) if outboundType != "" { q = q.Where(outboundorder.OutboundTypeEQ(outboundType)) } if orderNo != "" { q = q.Where(outboundorder.OrderNoEqualFold(orderNo)) } if materialCode != "" { q = q.Where(outboundorder.MaterialCodeEqualFold(materialCode)) } if boxNo != "" { q = q.Where(outboundorder.BoxNoContainsFold(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, outboundCategoryLabel(ob.OutboundCategory), ob.OrderNo, ob.OwnershipNo, ob.MaterialCode, ob.MaterialName, ob.Quantity, ob.TargetStation, 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 } // outboundCategoryLabel 出库类别显示中文(客户诉求 L96-108:退料/样品/报废/发货/其他) func outboundCategoryLabel(v string) string { switch v { case "return": return "退料" case "sample": return "样品" case "scrap": return "报废" case "deliver": return "发货" case "supplier_return": return "退货" case "other": return "其他" default: return "" } } // outboundBlocksUnqualified 出库质量门禁判定(U5,客户口径): // 发货(deliver)/样品(sample)/其他(other) 及任何未知类别 → 拦截不合格/待退品(默认拦截,避免漏洞); // 退货(supplier_return,退回供应商)/报废(scrap)/退料(return,工位退料) → 豁免(本身即在处置不合格品)。 func outboundBlocksUnqualified(category string) bool { switch category { case "supplier_return", "scrap", "return": return false default: return true } }