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"` ShelfNo string `json:"shelfNo"` LayerNo string `json:"layerNo"` PositionNo string `json:"positionNo"` Remark string `json:"remark"` // OutboundType 出库类型:workorder(备料出库,默认) / refill(工位叫料·补料出库) OutboundType string `json:"outboundType"` // NeedAgv 是否需要 AGV 配送到接驳台(备料/补料一般为 true) NeedAgv bool `json:"needAgv"` // OverReason 超 BOM 领用原因(损耗/报废/返修/其他)。 // 超领不硬卡:net_out > 需求时,填了原因即放行并留痕;未填仍 409 拦截。 OverReason string `json:"overReason"` } // 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 } // 盘点冻结守卫(问题2):盘点期间该批库存禁止出库,避免差异混入业务变动。 if !assertNotFrozenTargets(ctx, w, req.BatchNo, req.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() } }() // 1.5 出库库位兜底(客户诉求 一.9「出库相同」):未填的层级按 // 「批次在库记录 → 首个 SN 在库记录」补全,保证出库单留痕到具体货位。 req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo = outboundLocationFallback(ctx, req.BatchNo, req.SnList, req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo) // 2. 创建出库单(出库即扣库存 → 初始状态「已出库」;叫AGV到位后置「已送达」) obType := req.OutboundType if obType != "refill" { obType = "workorder" } ob, err := tx.OutboundOrder.Create(). SetOutboundNo(outboundNo). SetOutboundType(obType). SetStatus("已出库"). SetNeedAgv(req.NeedAgv). SetNillableOrderNo(strPtr(req.OrderNo)). SetMaterialCode(req.MaterialCode). SetManageMode(1). SetNillableBatchNo(strPtr(req.BatchNo)). SetNillableZoneCode(strPtr(req.ZoneCode)). SetNillableShelfNo(strPtr(req.ShelfNo)). SetNillableLayerNo(strPtr(req.LayerNo)). SetNillablePositionNo(strPtr(req.PositionNo)). 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,客户口径):仅「合格」批次可领料上线;未检(待检)/不合格/待退 一律拦截 if b.QualityStatus != QStatusPass { fail(w, http.StatusConflict, "批次 "+req.BatchNo+" 质量状态为「"+qualityLabel(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.QualityStatusEQ(QStatusPass)). 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 // 超量拦截按「净领用」判定(out_qty − returned_qty):退料会回补缺口。 // 否则「出库100 → 退料20 → 再补料20」累计 120 > 需求 100,会被 409 永久拦死。 netOut := newOutQty - ledgerNow.ReturnedQty overQty := 0 if netOut > ledgerNow.TotalQty { // 超 BOM 领用不硬卡:损耗/报废/返修会真实超出 BOM 需求,硬卡会停线。 // 规则:填了超领原因即放行并留痕(可统计、可预警);未填仍拦截。 if req.OverReason == "" { fail(w, http.StatusConflict, "出库数量超过工单 BOM 需求: 净领用 "+itoa(netOut)+" > 需求 "+itoa(ledgerNow.TotalQty)+ "(已发出 "+itoa(ledgerNow.OutQty)+" − 已退回 "+itoa(ledgerNow.ReturnedQty)+" + 本次 "+itoa(actualQty)+");"+ "如确需超领(损耗/报废/返修),请填写超领原因后重试") return } overQty = netOut - ledgerNow.TotalQty } status := ledgerNow.Status if netOut >= ledgerNow.TotalQty { status = "领料完结" } else if status == "领料完结" { // 退料回补后净额回落 → 状态回归「进行中」,允许再次补料 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, "overQty": overQty, "overReason": req.OverReason}) if overQty > 0 { // 超领留痕:可统计、可预警(某工位频繁超领即异常信号) ctx.EventLog.Write(ctx0(), "material.over", r.Header.Get("X-Username"), "order_material_ledger", req.OrderNo, "超BOM领用 "+req.MaterialCode+" 超 "+itoa(overQty)+" 原因:"+req.OverReason, map[string]any{"orderNo": req.OrderNo, "materialCode": req.MaterialCode, "overQty": overQty, "reason": req.OverReason, "netOut": netOut, "total": ledgerNow.TotalQty}) } logx.Infof("deduct stock: order=%s material=%s qty=%d over=%d", req.OrderNo, req.MaterialCode, actualQty, overQty) ok(w, map[string]any{ "outboundNo": outboundNo, "qty": actualQty, "orderNo": req.OrderNo, "outboundType": obType, "ledgerOut": newOutQty, "ledgerReturned": ledgerNow.ReturnedQty, "ledgerNet": netOut, "ledgerTotal": ledgerNow.TotalQty, "overQty": overQty, "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") status := r.URL.Query().Get("status") q := ctx.EntClient.OutboundOrder.Query() if status != "" { q = q.Where(outboundorder.StatusEQ(status)) } 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 ownershipNo != "" { q = q.Where(outboundorder.OwnershipNoContainsFold(ownershipNo)) } if outboundType != "" { q = q.Where(outboundorder.OutboundTypeEQ(outboundType)) } if outboundCategory != "" { q = q.Where(outboundorder.OutboundCategoryEQ(outboundCategory)) } 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"` ShelfNo string `json:"shelfNo"` LayerNo string `json:"layerNo"` PositionNo string `json:"positionNo"` 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"` TargetDock string `json:"targetDock"` // 目标接驳台(叫AGV时必填) NeedAgv bool `json:"needAgv"` // 是否叫AGV配送到接驳台(可选) } // 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 } // 盘点冻结守卫(问题2):盘点期间该批库存禁止出库。 if !assertNotFrozenTargets(ctx, w, req.BatchNo, req.SnList) { return } // 库位兜底(与工单出库同口径):未填的层级按「批次在库 → 首个 SN 在库」补全 req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo = outboundLocationFallback(ctx, req.BatchNo, req.SnList, req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo) 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"). SetStatus("已出库"). SetNeedAgv(req.NeedAgv). SetNillableTargetDock(strPtr(req.TargetDock)). 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)). SetNillableShelfNo(strPtr(req.ShelfNo)). SetNillableLayerNo(strPtr(req.LayerNo)). SetNillablePositionNo(strPtr(req.PositionNo)). 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 != QStatusPass { fail(w, http.StatusConflict, "批次 "+req.BatchNo+" 质量状态为「"+qualityLabel(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) { passCnt, _ := tx.Inventory.Query(). Where(inventory.ManageModeEQ(2), inventory.SnCodeIn(req.SnList...), inventory.QualityStatusEQ(QStatusPass)). Count(ctx0()) if passCnt < len(req.SnList) { 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") status := r.URL.Query().Get("status") orderNo := r.URL.Query().Get("orderNo") materialCode := r.URL.Query().Get("materialCode") boxNo := r.URL.Query().Get("boxNo") ownershipNo := r.URL.Query().Get("ownershipNo") 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 status != "" { q = q.Where(outboundorder.StatusEQ(status)) } if orderNo != "" { q = q.Where(outboundorder.OrderNoEqualFold(orderNo)) } if materialCode != "" { q = q.Where(outboundorder.MaterialCodeEqualFold(materialCode)) } if boxNo != "" { q = q.Where(outboundorder.BoxNoContainsFold(boxNo)) } if ownershipNo != "" { q = q.Where(outboundorder.OwnershipNoContainsFold(ownershipNo)) } 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 matrix = append(matrix, []any{ ob.OutboundNo, outboundTypeLabel(ob.OutboundType), ob.Status, outboundCategoryLabel(ob.OutboundCategory), ob.OrderNo, ob.OwnershipNo, ob.MaterialCode, ob.MaterialName, locationText(ob.ZoneCode, ob.ShelfNo, ob.LayerNo, ob.PositionNo), 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 } // locationText 库位展示串。入库/出库/库存统一口径:`Z02 A架/3层/12位`,无内容返回空串。 // outboundLocationFallback 出库四级库位兜底(客户诉求 一.9「出库相同」)。 // // 调用方(MES 备料自动出库、前端手工出库)可能只传了区域甚至什么都没传; // 这里按「指定批次在库的实际货位 → 首个出库 SN 在库的实际货位」顺序, // 把**缺失的层级**补全,使出库单、列表、导出都能留痕到具体货位(货架/层/位置号)。 // 只补空值、不覆盖调用方显式指定的值。 func outboundLocationFallback(ctx *svc.ServiceContext, batchNo string, snList []string, zoneCode, shelfNo, layerNo, positionNo string) (string, string, string, string) { if zoneCode != "" && shelfNo != "" && layerNo != "" && positionNo != "" { return zoneCode, shelfNo, layerNo, positionNo } var src *ent.Inventory if batchNo != "" { src, _ = ctx.EntClient.Inventory.Query(). Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(batchNo)).Only(ctx0()) } if src == nil && len(snList) > 0 { src, _ = ctx.EntClient.Inventory.Query(). Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(snList[0])).Only(ctx0()) } if src == nil { return zoneCode, shelfNo, layerNo, positionNo } if zoneCode == "" { zoneCode = src.ZoneCode } if shelfNo == "" { shelfNo = src.ShelfNo } if layerNo == "" { layerNo = src.LayerNo } if positionNo == "" { positionNo = src.PositionNo } return zoneCode, shelfNo, layerNo, positionNo } // locationText 库位显示文案(区域/货架/层/位置号)——列表与导出共用。 func locationText(zoneCode, shelfNo, layerNo, positionNo string) string { parts := make([]string, 0, 3) if shelfNo != "" { parts = append(parts, shelfNo+"架") } if layerNo != "" { parts = append(parts, layerNo+"层") } if positionNo != "" { parts = append(parts, positionNo+"位") } loc := strings.Join(parts, "/") if zoneCode == "" { return loc } if loc == "" { return zoneCode } return zoneCode + " " + loc } // outboundCategoryLabel 出库类别显示中文(客户诉求 L96-108:退料/样品/报废/发货/其他) // outboundTypeLabel 出库类型中文(界面禁英文) func outboundTypeLabel(v string) string { switch v { case "workorder": return "备料出库" case "refill": return "补料出库" case "general": return "通用出库" default: return "通用出库" } } 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 } }