diff --git a/bj_power_wms/internal/handler/rbac.go b/bj_power_wms/internal/handler/rbac.go index ddad0d1..8e28a03 100644 --- a/bj_power_wms/internal/handler/rbac.go +++ b/bj_power_wms/internal/handler/rbac.go @@ -147,10 +147,25 @@ func userDTO(u *ent.User) map[string]any { } } -// listUsersHandler GET /api/user/list 管理员查看全部用户 +// listUsersHandler GET /api/user/list 管理员查看全部用户(后端真分页,created_at desc 最新置顶) func listUsersHandler(ctx *svc.ServiceContext) http.HandlerFunc { return requirePerm(ctx, "user:manage", func(w http.ResponseWriter, r *http.Request) { - list, err := ctx.EntClient.User.Query().Order(ent.Asc("id")).All(ctx0()) + page := atoi(r.URL.Query().Get("page"), 1) + pageSize := atoi(r.URL.Query().Get("pageSize"), 20) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 200 { + pageSize = 20 + } + q := ctx.EntClient.User.Query() + 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 @@ -159,7 +174,7 @@ func listUsersHandler(ctx *svc.ServiceContext) http.HandlerFunc { for _, u := range list { out = append(out, userDTO(u)) } - ok(w, map[string]any{"list": out}) + ok(w, map[string]any{"list": out, "total": total, "page": page, "pageSize": pageSize}) }) } diff --git a/bj_power_wms/internal/handler/stock.go b/bj_power_wms/internal/handler/stock.go index bc3575c..cd1951d 100644 --- a/bj_power_wms/internal/handler/stock.go +++ b/bj_power_wms/internal/handler/stock.go @@ -3,6 +3,7 @@ package handler import ( "fmt" "net/http" + "sort" "strings" "time" @@ -24,8 +25,9 @@ type stockKey struct { } // stockAggRow 聚合主列表的行结构(按 物料+区域+质量+管理粒度 聚合,只返回数量,不铺开明细) +// ID = 组内最近入库的库存行真实主键(inventory.id)——聚合视图无自身实体,取真实主键,禁止伪 id type stockAggRow struct { - ID int64 `json:"id"` // 组合键稳定序号的伪 id,供前端行 key 使用 + ID int64 `json:"id"` // inventory.id(组内最近入库行) MaterialCode string `json:"materialCode"` // 物料编码 MaterialName string `json:"materialName"` // 物料名称 Spec string `json:"spec"` // 规格型号 @@ -49,6 +51,7 @@ 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") // 物料名称模糊 + keyword := r.URL.Query().Get("keyword") // 模糊搜索:物料编码/批次号/SN 任一匹配(下拉拾取器用) zoneCode := r.URL.Query().Get("zoneCode") qualityStatus := r.URL.Query().Get("qualityStatus") manageMode := r.URL.Query().Get("manageMode") // 1/2/空=全部 @@ -61,6 +64,7 @@ func queryStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { rows, total, err := aggregateStock(ctx, aggregateStockArgs{ MaterialCode: materialCode, MaterialName: materialName, + Keyword: keyword, ZoneCode: zoneCode, QualityStatus: qualityStatus, ManageMode: manageMode, @@ -87,6 +91,7 @@ func queryStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { type aggregateStockArgs struct { MaterialCode string MaterialName string + Keyword string // 模糊搜索:物料编码/批次号/SN 任一匹配(下拉拾取器用) ZoneCode string QualityStatus string ManageMode string @@ -114,6 +119,14 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR if a.MaterialName != "" { q = q.Where(inventory.MaterialNameContains(a.MaterialName)) } + if a.Keyword != "" { + // 模糊搜索:物料编码 / 批次号 / SN 任一命中(库存拾取下拉用);ContainsFold 大小写不敏感 + q = q.Where(inventory.Or( + inventory.MaterialCodeContainsFold(a.Keyword), + inventory.BatchNoContainsFold(a.Keyword), + inventory.SnCodeContainsFold(a.Keyword), + )) + } if a.ZoneCode != "" { q = q.Where(inventory.ZoneCodeEQ(a.ZoneCode)) } @@ -184,6 +197,7 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR snCnt int lastInbNo string lastTime int64 + lastInvID int64 // 组内最近入库的库存行真实主键(inventory.id),作行标识 } group := map[stockKey]*agg{} order := []stockKey{} @@ -210,9 +224,11 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR } g.snCnt++ } + // all 已按 created_at desc, id desc 排序,首见即最近一条 if inv.CreatedAt > g.lastTime { g.lastTime = inv.CreatedAt g.lastInbNo = inv.InboundNo + g.lastInvID = int64(inv.ID) } } @@ -235,7 +251,7 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR name = key.MaterialCode } rows = append(rows, &stockAggRow{ - ID: int64(i + 1), + ID: g.lastInvID, // 组内最近入库的库存行真实主键(inventory.id),禁止伪 id MaterialCode: key.MaterialCode, MaterialName: name, Spec: matSpec[key.MaterialCode], @@ -256,23 +272,26 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR // materialAggRow 物料汇总行(按 物料编码 聚合,忽略区域与质量状态) // 回答"这个物料总共有多少":总数/锁定/可用 + 质量分布(合格/未检/不合格) + 分布区域列表。 +// ID 取物料主表(material)主键——聚合视图无自身实体,主表 ID 即行标识,禁止伪 id。 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"` // 最近一次入库时间 + ID int64 `json:"id"` // 物料主表 ID(material.id,档案缺失时为 0) + 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"` // 最近入库单号 + MaterialCreatedAt int64 `json:"materialCreatedAt"` // 物料主表创建时间(material.created_at) + CreatedAt int64 `json:"createdAt"` // 最近一次入库时间 } // materialSummaryArgs 物料汇总的筛选条件 @@ -328,9 +347,11 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([ return nil, 0, err } - // 物料名称/规格补全 + // 物料名称/规格/主表ID/主表创建时间补全 matName := map[string]string{} matSpec := map[string]string{} + matID := map[string]int64{} + matCreatedAt := map[string]int64{} matCodes := []string{} seen := map[string]bool{} for _, inv := range all { @@ -345,6 +366,8 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([ for _, m := range mats { matName[m.Code] = m.Name matSpec[m.Code] = m.Spec + matID[m.Code] = int64(m.ID) + matCreatedAt[m.Code] = m.CreatedAt } } @@ -426,22 +449,24 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([ 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, + ID: matID[code], + 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, + MaterialCreatedAt: matCreatedAt[code], + CreatedAt: g.lastTime, }) } return rows, total, nil @@ -512,6 +537,27 @@ func stockDetailsHandler(ctx *svc.ServiceContext) http.HandlerFunc { // 返回 .xlsx 二进制,文件名 = 库存汇总_时间.xlsx。 func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("view") == "zone" { + out, err := buildZoneSummary(ctx) + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + headers := []string{"ID", "区域编码", "区域名称", "质量状态", "类型", "数量", "区域创建时间", "最近入库时间"} + matrix := make([][]any, 0, len(out)) + for _, rw := range out { + id := any("") + if rw.ID > 0 { + id = rw.ID + } + matrix = append(matrix, []any{ + id, rw.ZoneCode, rw.ZoneName, rw.Quality, rw.Type, rw.Qty, + unixFmt(rw.ZoneCreatedAt), unixFmt(rw.LastCreatedAt), + }) + } + sendExcel(w, xlsxFilename("区域汇总"), headers, matrix) + return + } if r.URL.Query().Get("view") == "material" { rows, _, err := aggregateMaterialSummary(ctx, materialSummaryArgs{ MaterialCode: r.URL.Query().Get("materialCode"), @@ -524,15 +570,19 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { fail(w, http.StatusInternalServerError, err.Error()) return } - headers := []string{"物料编码", "物料名称", "规格", "类型", "总数量", "锁定量", "可用量", "合格", "未检", "不合格", "分布区域", "批次数", "SN数", "最近入库单", "最近入库时间"} + headers := []string{"ID", "物料编码", "物料名称", "规格", "类型", "总数量", "锁定量", "可用量", "合格", "未检", "不合格", "分布区域", "批次数", "SN数", "最近入库单", "物料创建时间", "最近入库时间"} matrix := make([][]any, 0, len(rows)) for _, rw := range rows { + id := any("") + if rw.ID > 0 { + id = rw.ID + } matrix = append(matrix, []any{ - rw.MaterialCode, rw.MaterialName, rw.Spec, + id, 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), + rw.LastInboundNo, unixFmt(rw.MaterialCreatedAt), unixFmt(rw.CreatedAt), }) } sendExcel(w, xlsxFilename("物料汇总"), headers, matrix) @@ -554,11 +604,11 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { fail(w, http.StatusInternalServerError, err.Error()) return } - headers := []string{"物料编码", "物料名称", "规格", "类型", "区域", "质量状态", "总数量", "锁定量", "可用量", "批次数", "SN数", "最近入库单", "最近入库时间"} + headers := []string{"ID", "物料编码", "物料名称", "规格", "类型", "区域", "质量状态", "总数量", "锁定量", "可用量", "批次数", "SN数", "最近入库单", "最近入库时间"} matrix := make([][]any, 0, len(rows)) for _, rw := range rows { matrix = append(matrix, []any{ - rw.MaterialCode, rw.MaterialName, rw.Spec, + rw.ID, 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), @@ -567,54 +617,113 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc { sendExcel(w, xlsxFilename("库存汇总"), headers, matrix) } } +// zoneSummRow 区域汇总行(按 区域 + 质量状态 + 类型 聚合) +// ID/ZoneCreatedAt 取区域主表(zone)主键与创建时间——聚合视图无自身实体,主表字段即行标识,禁止伪 id。 +type zoneSummRow struct { + ID int64 `json:"id"` // 区域主表 ID(zone.id,未分区时为 0) + ZoneCode string `json:"zoneCode"` + ZoneName string `json:"zoneName"` + Quality string `json:"quality"` + Type string `json:"type"` // 批次/精密件 + Qty int `json:"qty"` + ZoneCreatedAt int64 `json:"zoneCreatedAt"` // 区域主表创建时间(zone.created_at) + LastCreatedAt int64 `json:"lastCreatedAt"` // 组内最近一次入库时间 +} + +// buildZoneSummary 区域汇总聚合:供列表接口与导出共用。 +// 行排序固定(区域编码 → 质量 → 类型),避免 map 迭代顺序随机导致每次刷新行序跳动。 +func buildZoneSummary(ctx *svc.ServiceContext) ([]zoneSummRow, error) { + zones, err := ctx.EntClient.Zone.Query().All(ctx0()) + if err != nil { + return nil, err + } + zoneNameMap := map[string]string{} + zoneIDMap := map[string]int64{} + zoneCreatedAtMap := map[string]int64{} + for _, z := range zones { + zoneNameMap[z.ZoneCode] = z.ZoneName + zoneIDMap[z.ZoneCode] = int64(z.ID) + zoneCreatedAtMap[z.ZoneCode] = z.CreatedAt + } + + rows, err := ctx.EntClient.Inventory.Query(). + Where(inventory.StatusIn("在库", "锁定")).All(ctx0()) + if err != nil { + return nil, err + } + + type aggInfo struct { + qty int + lastCreated int64 + } + agg := map[string]*aggInfo{} // 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 + g, ok := agg[key] + if !ok { + g = &aggInfo{} + agg[key] = g + } + if inv.ManageMode == 1 { + g.qty += inv.Quantity + } else { + g.qty += 1 + } + if inv.CreatedAt > g.lastCreated { + g.lastCreated = inv.CreatedAt + } + } + + out := make([]zoneSummRow, 0, len(agg)) + for k, v := range agg { + parts := strings.Split(k, "|") + zc := parts[0] + name := zoneNameMap[zc] + if name == "" { + name = zc + } + out = append(out, zoneSummRow{ + ID: zoneIDMap[zc], + ZoneCode: zc, + ZoneName: name, + Quality: parts[1], + Type: parts[2], + Qty: v.qty, + ZoneCreatedAt: zoneCreatedAtMap[zc], + LastCreatedAt: v.lastCreated, + }) + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + if a.ZoneCode != b.ZoneCode { + return a.ZoneCode < b.ZoneCode + } + if a.Quality != b.Quality { + return a.Quality < b.Quality + } + return a.Type < b.Type + }) + return out, nil +} + // zoneSummaryHandler 区域库存汇总(按 区域 + 质量状态 + 类型 聚合) // GET /api/stock/zone-summary -// 返回 rows: [{zoneCode, zoneName, quality, type(批次/精密件), qty}] +// 返回 rows: zoneSummRow(含区域主表 ID/创建时间 + 组内最近入库时间) // 仅按"区域(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}) + out, err := buildZoneSummary(ctx) + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return } ok(w, map[string]any{"rows": out}) } diff --git a/bj_power_wms_client/frontend/index.html b/bj_power_wms_client/frontend/index.html index cd43927..e0a5b0f 100644 --- a/bj_power_wms_client/frontend/index.html +++ b/bj_power_wms_client/frontend/index.html @@ -3,7 +3,7 @@
-请使用 WMS 账号登录