package handler import ( "net/http" "strconv" "time" "bj_power_wms/ent" "bj_power_wms/ent/inventory" "bj_power_wms/ent/ordermaterialledger" "bj_power_wms/internal/svc" "github.com/google/uuid" "github.com/zeromicro/go-zero/core/logx" ) // backflushRequest 反冲扣账请求(MES 报工完成时调用,X-API-TOKEN 内部接口) // 物料在该道工序被消耗,不依赖事先发料:报工完成即反冲扣库存。 // items 由 MES 按「工单产品 BOM + 当前工序 processCode」算好(含损耗),WMS 只负责 FIFO 扣减与记账。 type backflushRequest struct { OrderNo string `json:"orderNo"` // 关联工单号(可空=不挂台账) StationNo int `json:"stationNo"` // 当前工位号(记录用) ProcessCode int `json:"processCode"` // 当前工序号(记录用) Operator string `json:"operator"` Items []backflushItem `json:"items"` // 本次消耗的物料明细 } type backflushItem struct { MaterialCode string `json:"materialCode"` Qty int `json:"qty"` // 该工序单件消耗(已含损耗)* 完成件数 } // backflushInternalHandler 反冲扣账(库存 FIFO 扣减 + 生成 backflush 类型出库单 + 台账累计) // 事务保证:出库单 + 库存扣减 + 出库明细 + 工单台账 原子提交。 // 任意物料库存不足(FIFO 无法足额)时,扣足可用部分、记短欠日志、不阻塞产线(反冲降级同成品回流)。 func backflushInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req backflushRequest if err := parseJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, "参数错误: "+err.Error()) return } if len(req.Items) == 0 { ok(w, map[string]any{"outboundNo": "", "consumed": 0, "shortfall": false, "note": "无反冲物料"}) return } outboundNo := "BF" + 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. 创建反冲出库单(type=backflush,数量后置更新) ob, err := tx.OutboundOrder.Create(). SetOutboundNo(outboundNo). SetOutboundType("backflush"). SetNillableOrderNo(strPtr(req.OrderNo)). SetMaterialCode(""). // 反冲单为整份 BOM,主表物料留空,明细承载 SetManageMode(1). SetQuantity(0). SetNillableOperator(strPtr(req.Operator)). SetNillableTargetStation(strPtr(strconv.Itoa(req.StationNo))). SetRemark("反冲扣账(工序" + strconv.Itoa(req.ProcessCode) + ")"). Save(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, "创建反冲单失败: "+err.Error()) return } // 2. FIFO 逐物料扣减 + 台账累计 totalConsumed := 0 shortfall := false for _, it := range req.Items { if it.MaterialCode == "" || it.Qty <= 0 { continue } consumed, sf := fifoConsume(tx, it.MaterialCode, it.Qty, outboundNo, req.OrderNo) totalConsumed += consumed if sf { shortfall = true logx.Errorf("backflush shortfall: order=%s material=%s need=%d got=%d", req.OrderNo, it.MaterialCode, it.Qty, consumed) } // 台账累计(仅挂工单时) if req.OrderNo != "" { ledger, lerr := tx.OrderMaterialLedger.Query(). Where( ordermaterialledger.OrderNoEQ(req.OrderNo), ordermaterialledger.MaterialCodeEQ(it.MaterialCode), ).Only(ctx0()) if lerr == nil && ledger != nil { newOut := ledger.OutQty + consumed status := ledger.Status if newOut >= ledger.TotalQty { status = "领料完结" } upd := tx.OrderMaterialLedger.UpdateOneID(ledger.ID). SetOutQty(newOut). SetStatus(status) if status == "领料完结" && ledger.Status != "领料完结" { upd.SetCompletedAt(time.Now().Unix()) } if _, lerr = upd.Save(ctx0()); lerr != nil { fail(w, http.StatusInternalServerError, "更新台账失败: "+lerr.Error()) return } } } } // 3. 回写出库单实际数量 if _, err = tx.OutboundOrder.UpdateOneID(ob.ID).SetQuantity(totalConsumed).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(), "material.backflush", req.Operator, "backflush", outboundNo, "反冲扣账 工单 "+req.OrderNo+" 工序"+strconv.Itoa(req.ProcessCode)+" 共消耗 ×"+strconv.Itoa(totalConsumed), map[string]any{"orderNo": req.OrderNo, "stationNo": req.StationNo, "consumed": totalConsumed, "shortfall": shortfall}) logx.Infof("backflush: order=%s station=%d consumed=%d shortfall=%v", req.OrderNo, req.StationNo, totalConsumed, shortfall) ok(w, map[string]any{ "outboundNo": outboundNo, "consumed": totalConsumed, "shortfall": shortfall, }) } } // fifoConsume 按 created_at 升序(先进先出)扣减某物料库存,跨批次/单件 SN 统一处理。 // 返回实际扣减数;库存不足足额时 shortfall=true(已扣可用部分),不报错。 func fifoConsume(tx *ent.Tx, materialCode string, qty int, outboundNo, orderNo string) (int, bool) { rows, err := tx.Inventory.Query(). Where( inventory.MaterialCodeEQ(materialCode), inventory.StatusEQ("在库"), inventory.QualityStatusNotIn("不合格", "待退"), ). Order(ent.Asc("created_at")). All(ctx0()) if err != nil { return 0, true } remaining := qty consumed := 0 for _, row := range rows { if remaining <= 0 { break } avail := row.Quantity - row.LockedQty if avail <= 0 { continue } take := avail if take > remaining { take = remaining } if row.ManageMode == 2 { // SN 单件:整件消耗并置出库 aff, e := tx.Inventory.Update(). Where(inventory.ID(row.ID), inventory.StatusEQ("在库")). SetStatus("出库").Save(ctx0()) if e != nil || aff == 0 { continue } if _, e = tx.OutboundDetail.Create(). SetOutboundNo(outboundNo). SetSnCode(row.SnCode). SetMaterialCode(materialCode). SetQuantity(1). SetNillableOrderNo(strPtr(orderNo)).Save(ctx0()); e != nil { return consumed, true } consumed += 1 remaining -= 1 } else { // 批次:条件原子扣减(quantity>=take 才减,避免超扣/并发负库存) aff, e := tx.Inventory.Update(). Where(inventory.ID(row.ID), inventory.QuantityGTE(take)). AddQuantity(-take). AddLockedQty(-min(take, row.LockedQty)). Save(ctx0()) if e != nil || aff == 0 { continue } if _, e = tx.OutboundDetail.Create(). SetOutboundNo(outboundNo). SetBatchNo(row.BatchNo). SetMaterialCode(materialCode). SetQuantity(take). SetNillableOrderNo(strPtr(orderNo)).Save(ctx0()); e != nil { return consumed, true } consumed += take remaining -= take } } return consumed, remaining > 0 }