package handler import ( "fmt" "net/http" "strings" "time" "entgo.io/ent/dialect/sql" "bj_power_wms/ent" "bj_power_wms/ent/inventory" "bj_power_wms/ent/inventorylock" "bj_power_wms/ent/material" "bj_power_wms/ent/predicate" "bj_power_wms/internal/svc" ) // stockKey 库存聚合行的唯一标识(组合键)+ 明细下钻的过滤条件 type stockKey struct { MaterialCode string ZoneCode string QualityStatus string ManageMode int } // stockAggRow 聚合主列表的行结构(按 物料+区域+质量+管理粒度 聚合,只返回数量,不铺开明细) type stockAggRow struct { ID int64 `json:"id"` // 组合键稳定序号的伪 id,供前端行 key 使用 MaterialCode string `json:"materialCode"` // 物料编码 MaterialName string `json:"materialName"` // 物料名称 Spec string `json:"spec"` // 规格型号 ManageMode int `json:"manageMode"` // 1结构件/2精密件 ZoneCode string `json:"zoneCode"` // 区域 QualityStatus string `json:"qualityStatus"` // 质量状态 TotalQty int `json:"totalQty"` // 总数量(结构件=数量求和;精密件=SN 行数) LockedQty int `json:"lockedQty"` // 锁定量 AvailQty int `json:"availQty"` // 可用量 = total - locked BatchCount int `json:"batchCount"` // 批次数量(结构件维度的行数) SnCount int `json:"snCount"` // SN 数量(精密件维度的行数) LastInboundNo string `json:"lastInboundNo"` // 最近入库单号 CreatedAt int64 `json:"createdAt"` // 最近一次入库时间(聚合组内最大 created_at) } // queryStockHandler 库存查询(主列表 = 按物料聚合数量) // 统一查询 inventory 一张表,按 物料编码+区域+质量状态+管理粒度 分组聚合, // 只返回每个物料的数量/锁定/可用与 批次数/SN数,不把每个批次/SN 平铺出来(避免 SN 泄漏与数据量膨胀)。 // 明细通过 /api/stock/details 点击下钻(分页懒加载)。 func queryStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { materialCode := r.URL.Query().Get("materialCode") materialName := r.URL.Query().Get("materialName") // 物料名称模糊 zoneCode := r.URL.Query().Get("zoneCode") qualityStatus := r.URL.Query().Get("qualityStatus") manageMode := r.URL.Query().Get("manageMode") // 1/2/空=全部 inboundNo := r.URL.Query().Get("inboundNo") startDate := r.URL.Query().Get("startDate") // 默认近3个月(由接口保证) endDate := r.URL.Query().Get("endDate") page := atoi(r.URL.Query().Get("page"), 1) pageSize := atoi(r.URL.Query().Get("pageSize"), 20) rows, total, err := aggregateStock(ctx, aggregateStockArgs{ MaterialCode: materialCode, MaterialName: materialName, ZoneCode: zoneCode, QualityStatus: qualityStatus, ManageMode: manageMode, InboundNo: inboundNo, StartDate: startDate, EndDate: endDate, Page: page, PageSize: pageSize, }) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } ok(w, map[string]any{ "list": rows, "total": total, "page": page, "pageSize": pageSize, }) } } // aggregateStockArgs 聚合主列表的筛选条件 type aggregateStockArgs struct { MaterialCode string MaterialName string ZoneCode string QualityStatus string ManageMode string InboundNo string StartDate string EndDate string Page int PageSize int } // aggregateStock 按物料聚合库存主数据。 // 用相机内存聚合(inventory 表每物料的行数有限:结构件=批次数、精密件=SN 数), // 先拉取符合状态条件的行,在 Go 侧分组聚合,再做内存分页。 // 这样避免手写 GROUP BY 子查询的复杂度,且数据量受"在库/锁定"上限约束,安全可控。 func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggRow, int, error) { q := ctx.EntClient.Inventory.Query() // 仅展示在库/锁定(出库/使用/报废不计入库存) q = q.Where(inventory.StatusIn("在库", "锁定")) // 结构件隐藏数量为 0 的行 q = q.Where(inventory.Or(inventory.ManageModeEQ(2), inventory.QuantityGT(0))) if a.MaterialCode != "" { q = q.Where(inventory.MaterialCodeContains(a.MaterialCode)) } if a.MaterialName != "" { q = q.Where(inventory.MaterialNameContains(a.MaterialName)) } if a.ZoneCode != "" { q = q.Where(inventory.ZoneCodeEQ(a.ZoneCode)) } if a.QualityStatus != "" { q = q.Where(inventory.QualityStatusEQ(a.QualityStatus)) } if a.InboundNo != "" { q = q.Where(inventory.InboundNoContains(a.InboundNo)) } if a.ManageMode == "1" { q = q.Where(inventory.ManageModeEQ(1)) } else if a.ManageMode == "2" { q = q.Where(inventory.ManageModeEQ(2)) } // 时间筛选:默认近3个月 startUnix := int64(0) if a.StartDate != "" { if t, err := time.ParseInLocation("2006-01-02", a.StartDate, time.Local); err == nil { startUnix = t.Unix() } } endUnix := int64(0) if a.EndDate != "" { if t, err := time.ParseInLocation("2006-01-02", a.EndDate, time.Local); err == nil { endUnix = t.Add(24 * time.Hour).Unix() // 含当天全天 } } if a.StartDate == "" && a.EndDate == "" { startUnix = time.Now().AddDate(0, -3, 0).Unix() // 默认近3个月 } if startUnix > 0 { q = q.Where(inventory.CreatedAtGTE(startUnix)) } if endUnix > 0 { q = q.Where(inventory.CreatedAtLTE(endUnix)) } all, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0()) if err != nil { return nil, 0, err } // 收集物料信息(名称/规格从 materials 表补齐,字段更权威) matName := map[string]string{} matSpec := map[string]string{} matCodes := []string{} seen := map[string]bool{} for _, inv := range all { if !seen[inv.MaterialCode] { seen[inv.MaterialCode] = true matCodes = append(matCodes, inv.MaterialCode) } } if len(matCodes) > 0 { mats, _ := ctx.EntClient.Material.Query(). Where(material.CodeIn(matCodes...)).All(ctx0()) for _, m := range mats { matName[m.Code] = m.Name matSpec[m.Code] = m.Spec } } // 分组聚合 type agg struct { totalQty int lockedQty int batchCnt int snCnt int lastInbNo string lastTime int64 } group := map[stockKey]*agg{} order := []stockKey{} for _, inv := range all { zc := inv.ZoneCode if zc == "" { zc = "未分区" } key := stockKey{inv.MaterialCode, zc, inv.QualityStatus, inv.ManageMode} g, ok := group[key] if !ok { g = &agg{} group[key] = g order = append(order, key) } if inv.ManageMode == 1 { g.totalQty += inv.Quantity g.lockedQty += inv.LockedQty g.batchCnt++ } else { g.totalQty++ if inv.Status == "锁定" { g.lockedQty++ } g.snCnt++ } if inv.CreatedAt > g.lastTime { g.lastTime = inv.CreatedAt g.lastInbNo = inv.InboundNo } } total := len(order) // 分页 start := (a.Page - 1) * a.PageSize if start < 0 { start = 0 } end := start + a.PageSize if end > total { end = total } rows := make([]*stockAggRow, 0, end-start) for i := start; i < end; i++ { key := order[i] g := group[key] name := matName[key.MaterialCode] if name == "" { name = key.MaterialCode } rows = append(rows, &stockAggRow{ ID: int64(i + 1), MaterialCode: key.MaterialCode, MaterialName: name, Spec: matSpec[key.MaterialCode], ManageMode: key.ManageMode, ZoneCode: key.ZoneCode, QualityStatus: key.QualityStatus, TotalQty: g.totalQty, LockedQty: g.lockedQty, AvailQty: g.totalQty - g.lockedQty, BatchCount: g.batchCnt, SnCount: g.snCnt, LastInboundNo: g.lastInbNo, CreatedAt: g.lastTime, }) } return rows, total, nil } // materialAggRow 物料汇总行(按 物料编码 聚合,忽略区域与质量状态) // 回答"这个物料总共有多少":总数/锁定/可用 + 质量分布(合格/未检/不合格) + 分布区域列表。 type materialAggRow struct { MaterialCode string `json:"materialCode"` MaterialName string `json:"materialName"` Spec string `json:"spec"` ManageMode int `json:"manageMode"` // 1结构件/2精密件 TotalQty int `json:"totalQty"` // 总数量(结构件=数量求和;精密件=SN 行数) LockedQty int `json:"lockedQty"` // 锁定量 AvailQty int `json:"availQty"` // 可用量 = total - locked QtyQualified int `json:"qtyQualified"` // 合格数量 QtyPending int `json:"qtyPending"` // 未检数量 QtyRejected int `json:"qtyRejected"` // 不合格数量 Zones []string `json:"zones"` // 分布区域列表 ZoneCount int `json:"zoneCount"` // 分布区域数 BatchCount int `json:"batchCount"` // 批次数(结构件维度的行数) SnCount int `json:"snCount"` // SN 数(精密件维度的行数) LastInboundNo string `json:"lastInboundNo"` // 最近入库单号 CreatedAt int64 `json:"createdAt"` // 最近一次入库时间 } // materialSummaryArgs 物料汇总的筛选条件 type materialSummaryArgs struct { MaterialCode string MaterialName string ManageMode string Page int PageSize int } // materialSummaryHandler 物料汇总(按物料聚合所有区域/质量状态) // GET /api/stock/material-summary?materialCode=&materialName=&manageMode=&page=&pageSize= // 返回 rows: 一物料一行,含 总数/可用/锁定/合格/未检/不合格/分布区域。明细经 /stock/details 点击下钻。 // 与主列表不同:汇总忽略区域/质量/入库时间筛选(回答总量问题,只认物料与类型)。 func materialSummaryHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rows, total, err := aggregateMaterialSummary(ctx, materialSummaryArgs{ MaterialCode: r.URL.Query().Get("materialCode"), MaterialName: r.URL.Query().Get("materialName"), ManageMode: r.URL.Query().Get("manageMode"), Page: atoi(r.URL.Query().Get("page"), 1), PageSize: atoi(r.URL.Query().Get("pageSize"), 20), }) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } ok(w, map[string]any{"rows": rows, "total": total}) } } // aggregateMaterialSummary 按物料编码聚合全部在库库存(忽略区域/质量/时间维度),Go 侧内存聚合 + 分页。 func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([]*materialAggRow, int, error) { q := ctx.EntClient.Inventory.Query() q = q.Where(inventory.StatusIn("在库", "锁定")) q = q.Where(inventory.Or(inventory.ManageModeEQ(2), inventory.QuantityGT(0))) if a.MaterialCode != "" { q = q.Where(inventory.MaterialCodeContains(a.MaterialCode)) } if a.MaterialName != "" { q = q.Where(inventory.MaterialNameContains(a.MaterialName)) } if a.ManageMode == "1" { q = q.Where(inventory.ManageModeEQ(1)) } else if a.ManageMode == "2" { q = q.Where(inventory.ManageModeEQ(2)) } all, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0()) if err != nil { return nil, 0, err } // 物料名称/规格补全 matName := map[string]string{} matSpec := map[string]string{} matCodes := []string{} seen := map[string]bool{} for _, inv := range all { if !seen[inv.MaterialCode] { seen[inv.MaterialCode] = true matCodes = append(matCodes, inv.MaterialCode) } } if len(matCodes) > 0 { mats, _ := ctx.EntClient.Material.Query(). Where(material.CodeIn(matCodes...)).All(ctx0()) for _, m := range mats { matName[m.Code] = m.Name matSpec[m.Code] = m.Spec } } type agg struct { totalQty int lockedQty int qQualified int qPending int qRejected int mMode int // 管理粒度(同物料唯一,取首见行的值) batchCnt int snCnt int zones []string zoneSeen map[string]bool lastInbNo string lastTime int64 } group := map[string]*agg{} // key: materialCode order := []string{} for _, inv := range all { key := inv.MaterialCode g, ok := group[key] if !ok { g = &agg{mMode: inv.ManageMode, zoneSeen: map[string]bool{}} group[key] = g order = append(order, key) } // 数量 if inv.ManageMode == 1 { g.totalQty += inv.Quantity g.lockedQty += inv.LockedQty g.batchCnt++ } else { g.totalQty++ if inv.Status == "锁定" { g.lockedQty++ } g.snCnt++ } // 质量分布(未检=空或非合格/不合格) switch inv.QualityStatus { case "合格": g.qQualified += qtyOf(inv) case "不合格": g.qRejected += qtyOf(inv) default: g.qPending += qtyOf(inv) } // 分布区域 zc := inv.ZoneCode if zc == "" { zc = "未分区" } if !g.zoneSeen[zc] { g.zoneSeen[zc] = true g.zones = append(g.zones, zc) } if inv.CreatedAt > g.lastTime { g.lastTime = inv.CreatedAt g.lastInbNo = inv.InboundNo } } total := len(order) start := (a.Page - 1) * a.PageSize if start < 0 { start = 0 } end := start + a.PageSize if end > total { end = total } rows := make([]*materialAggRow, 0, end-start) for i := start; i < end; i++ { code := order[i] g := group[code] name := matName[code] if name == "" { name = code } rows = append(rows, &materialAggRow{ MaterialCode: code, MaterialName: name, Spec: matSpec[code], ManageMode: g.mMode, TotalQty: g.totalQty, LockedQty: g.lockedQty, AvailQty: g.totalQty - g.lockedQty, QtyQualified: g.qQualified, QtyPending: g.qPending, QtyRejected: g.qRejected, Zones: g.zones, ZoneCount: len(g.zones), BatchCount: g.batchCnt, SnCount: g.snCnt, LastInboundNo: g.lastInbNo, CreatedAt: g.lastTime, }) } return rows, total, nil } // qtyOf 库存行计入数量的口径:结构件按行数量、精密件按 1 func qtyOf(inv *ent.Inventory) int { if inv.ManageMode == 1 { return inv.Quantity } return 1 } // stockDetailsHandler 库存明细(点击聚合行下钻,分页懒加载) // GET /api/stock/details?materialCode=&zoneCode=&qualityStatus=&manageMode=&page=&pageSize= // 返回该物料/区域/质量/类型下的原始批次/SN 明细行(扁平展示,含 SN/批次号)。 func stockDetailsHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { materialCode := r.URL.Query().Get("materialCode") zoneCode := r.URL.Query().Get("zoneCode") if zoneCode == "未分区" { zoneCode = "" } qualityStatus := r.URL.Query().Get("qualityStatus") manageMode := atoi(r.URL.Query().Get("manageMode"), 0) page := atoi(r.URL.Query().Get("page"), 1) pageSize := atoi(r.URL.Query().Get("pageSize"), 50) q := ctx.EntClient.Inventory.Query() q = q.Where(inventory.StatusIn("在库", "锁定")) q = q.Where(inventory.Or(inventory.ManageModeEQ(2), inventory.QuantityGT(0))) if materialCode != "" { q = q.Where(inventory.MaterialCodeEQ(materialCode)) } if zoneCode != "" { q = q.Where(inventory.ZoneCodeEQ(zoneCode)) } if qualityStatus != "" { q = q.Where(inventory.QualityStatusEQ(qualityStatus)) } if manageMode != 0 { q = q.Where(inventory.ManageModeEQ(manageMode)) } 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 } ok(w, map[string]any{ "list": list, "total": total, "page": page, "pageSize": pageSize, }) } } // exportStockHandler 库存汇总全量导出(xlsx) // 与 /stock/query 同一套聚合逻辑,但导出当前筛选下的全部行(不分页)。 // 支持 ?view=material 导出「物料汇总」(/stock/material-summary 的全量版本)。 // 返回 .xlsx 二进制,文件名 = 库存汇总_时间.xlsx。 func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("view") == "material" { rows, _, err := aggregateMaterialSummary(ctx, materialSummaryArgs{ MaterialCode: r.URL.Query().Get("materialCode"), MaterialName: r.URL.Query().Get("materialName"), ManageMode: r.URL.Query().Get("manageMode"), Page: 1, PageSize: 1000000, // 全量 }) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } headers := []string{"物料编码", "物料名称", "规格", "类型", "总数量", "锁定量", "可用量", "合格", "未检", "不合格", "分布区域", "批次数", "SN数", "最近入库单", "最近入库时间"} matrix := make([][]any, 0, len(rows)) for _, rw := range rows { matrix = append(matrix, []any{ rw.MaterialCode, rw.MaterialName, rw.Spec, manageModeLabel(rw.ManageMode), rw.TotalQty, rw.LockedQty, rw.AvailQty, rw.QtyQualified, rw.QtyPending, rw.QtyRejected, strings.Join(rw.Zones, "、"), rw.BatchCount, rw.SnCount, rw.LastInboundNo, unixFmt(rw.CreatedAt), }) } sendExcel(w, xlsxFilename("物料汇总"), headers, matrix) return } rows, _, err := aggregateStock(ctx, aggregateStockArgs{ MaterialCode: r.URL.Query().Get("materialCode"), MaterialName: r.URL.Query().Get("materialName"), ZoneCode: r.URL.Query().Get("zoneCode"), QualityStatus: r.URL.Query().Get("qualityStatus"), ManageMode: r.URL.Query().Get("manageMode"), InboundNo: r.URL.Query().Get("inboundNo"), StartDate: r.URL.Query().Get("startDate"), EndDate: r.URL.Query().Get("endDate"), Page: 1, PageSize: 1000000, // 全量 }) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } headers := []string{"物料编码", "物料名称", "规格", "类型", "区域", "质量状态", "总数量", "锁定量", "可用量", "批次数", "SN数", "最近入库单", "最近入库时间"} matrix := make([][]any, 0, len(rows)) for _, rw := range rows { matrix = append(matrix, []any{ rw.MaterialCode, rw.MaterialName, rw.Spec, manageModeLabel(rw.ManageMode), rw.ZoneCode, rw.QualityStatus, rw.TotalQty, rw.LockedQty, rw.AvailQty, rw.BatchCount, rw.SnCount, rw.LastInboundNo, unixFmt(rw.CreatedAt), }) } sendExcel(w, xlsxFilename("库存汇总"), headers, matrix) } } // zoneSummaryHandler 区域库存汇总(按 区域 + 质量状态 + 类型 聚合) // GET /api/stock/zone-summary // 返回 rows: [{zoneCode, zoneName, quality, type(批次/精密件), qty}] // 仅按"区域(Z01~Z04)"维度聚合,不引入货架/层/格(业务决策:小货架高频取货无需精确货位索引)。 func zoneSummaryHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { zones, _ := ctx.EntClient.Zone.Query().All(ctx0()) zoneNameMap := map[string]string{} for _, z := range zones { zoneNameMap[z.ZoneCode] = z.ZoneName } rows, _ := ctx.EntClient.Inventory.Query(). Where(inventory.StatusIn("在库", "锁定")).All(ctx0()) type summRow struct { ZoneCode string `json:"zoneCode"` ZoneName string `json:"zoneName"` Quality string `json:"quality"` Type string `json:"type"` // 批次/精密件 Qty int `json:"qty"` } agg := map[string]int{} // key: zc|quality|type for _, inv := range rows { zc := inv.ZoneCode if zc == "" { zc = "未分区" } t := "批次" if inv.ManageMode == 2 { t = "精密件" } key := zc + "|" + inv.QualityStatus + "|" + t if inv.ManageMode == 1 { agg[key] += inv.Quantity } else { agg[key] += 1 } } out := make([]summRow, 0, len(agg)) for k, v := range agg { parts := strings.Split(k, "|") name := zoneNameMap[parts[0]] if name == "" { name = parts[0] } out = append(out, summRow{parts[0], name, parts[1], parts[2], v}) } ok(w, map[string]any{"rows": out}) } } // invAvailGTE 自定义谓词:可用量(quantity - locked_qty) >= need // 用于锁库存时的原子条件校验,避免并发超锁。 func invAvailGTE(need int) predicate.Inventory { return predicate.Inventory(func(s *sql.Selector) { s.Where(sql.ExprP( fmt.Sprintf("%s.%s - %s.%s >= ?", inventory.Table, inventory.FieldQuantity, inventory.Table, inventory.FieldLockedQty), need, )) }) } // lockStockHandler 工单锁定库存(库存占用) // body: { orderNo, items: [{ materialCode, batchNo?, sn?, qty }] } // 锁定为"增量"操作:结构件 locked_qty += qty;精密件状态置 锁定。绝不覆盖终值。 // 锁批次用原子增量 + 可用量条件校验;锁 SN 用状态条件原子更新,杜绝超锁与并发冲突。 func lockStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { OrderNo string `json:"orderNo"` Items []struct { MaterialCode string `json:"materialCode"` BatchNo string `json:"batchNo"` Sn string `json:"sn"` Qty int `json:"qty"` } `json:"items"` } if err := parseJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, "参数错误: "+err.Error()) return } if req.OrderNo == "" || len(req.Items) == 0 { fail(w, http.StatusBadRequest, "orderNo 和 items 必填") return } locked := 0 for _, item := range req.Items { if item.MaterialCode == "" { continue } // 精密件:锁 SN(原子:仅 在库 才能锁) if item.Sn != "" { aff, err := ctx.EntClient.Inventory.Update(). Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(item.Sn), inventory.StatusEQ("在库")). SetStatus("锁定").SetLockedQty(1). Save(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, "锁定 SN 失败: "+err.Error()) return } if aff == 0 { fail(w, http.StatusBadRequest, "SN 不在库或不存在: "+item.Sn) return } ctx.EntClient.InventoryLock.Create(). SetTargetType("SN"). SetTargetID(item.Sn). SetMaterialCode(item.MaterialCode). SetLockedQty(1). SetOrderNo(req.OrderNo). SetStatus("ACTIVE"). SaveX(ctx0()) locked++ continue } // 结构件:锁批次数量(原子增量 + 可用量校验) qty := item.Qty if qty <= 0 { qty = 1 } b, err := ctx.EntClient.Inventory.Query(). Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(item.BatchNo)).Only(ctx0()) if err != nil { fail(w, http.StatusBadRequest, "批次不存在: "+item.BatchNo) return } avail := b.Quantity - b.LockedQty if avail < qty { fail(w, http.StatusConflict, "批次库存不足: "+item.BatchNo+" 可用 "+itoa(avail)) return } aff, err := ctx.EntClient.Inventory.Update(). Where(inventory.ID(b.ID), invAvailGTE(qty)). AddLockedQty(qty). Save(ctx0()) if err != nil || aff == 0 { fail(w, http.StatusConflict, "锁定失败(库存不足或并发冲突): "+item.BatchNo) return } ctx.EntClient.InventoryLock.Create(). SetTargetType("BATCH"). SetTargetID(item.BatchNo). SetMaterialCode(item.MaterialCode). SetLockedQty(qty). SetOrderNo(req.OrderNo). SetStatus("ACTIVE"). SaveX(ctx0()) locked += qty } ok(w, map[string]any{"locked": locked}) } } // unlockStockHandler 释放库存锁定(取消锁定/返工) func unlockStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { OrderNo string `json:"orderNo"` LockIDs []int `json:"lockIds"` } if err := parseJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, "参数错误") return } q := ctx.EntClient.InventoryLock.Query(). Where(inventorylock.StatusEQ("ACTIVE")) if req.OrderNo != "" { q = q.Where(inventorylock.OrderNoEQ(req.OrderNo)) } if len(req.LockIDs) > 0 { q = q.Where(inventorylock.IDIn(req.LockIDs...)) } locks, err := q.All(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } released := 0 for _, l := range locks { // 还原批次锁定数 / SN 状态 if l.TargetType == "BATCH" { ctx.EntClient.Inventory.Update(). Where(inventory.BatchNoEQ(l.TargetID)). AddLockedQty(-l.LockedQty). SaveX(ctx0()) // 防止并发下锁定数为负 ctx.EntClient.Inventory.Update(). Where(inventory.BatchNoEQ(l.TargetID), inventory.LockedQtyLT(0)). SetLockedQty(0). SaveX(ctx0()) } else if l.TargetType == "SN" { ctx.EntClient.Inventory.Update(). Where(inventory.SnCodeEQ(l.TargetID), inventory.StatusEQ("锁定")). SetStatus("在库").SetLockedQty(0). SaveX(ctx0()) } ctx.EntClient.InventoryLock.UpdateOneID(l.ID). SetStatus("RELEASED").ExecX(ctx0()) released++ } ok(w, map[string]any{"released": released}) } } // checkStockHandler 校验库存是否满足需求(锁库前检查) func checkStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { Items []struct { MaterialCode string `json:"materialCode"` Qty int `json:"qty"` } `json:"items"` } if err := parseJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, "参数错误") return } results := make([]map[string]any, 0, len(req.Items)) allOk := true for _, item := range req.Items { // 汇总可用量:结构件 quantity-locked_qty;精密件 在库数量 rows, _ := ctx.EntClient.Inventory.Query(). Where(inventory.MaterialCodeEQ(item.MaterialCode)).All(ctx0()) availBatch := 0 availSerial := 0 for _, row := range rows { if row.ManageMode == 1 { availBatch += row.Quantity - row.LockedQty } else if row.Status == "在库" { availSerial++ } } enough := availBatch+availSerial >= item.Qty if !enough { allOk = false } results = append(results, map[string]any{ "materialCode": item.MaterialCode, "requireQty": item.Qty, "availQty": availBatch + availSerial, "batchAvail": availBatch, "serialAvail": availSerial, "enough": enough, }) } ok(w, map[string]any{"allOk": allOk, "items": results}) } } func itoa(n int) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } var b [20]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } if neg { i-- b[i] = '-' } return string(b[i:]) }