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")).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 } // 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")). 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 同一套聚合逻辑,但导出当前筛选下的全部行(不分页)。 // 返回 .xlsx 二进制,文件名 = 库存汇总_时间.xlsx。 func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { 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:]) }