feat: 完成WMS库房管理系统功能迭代与界面优化

1.  统一系统名称为WMS库房管理,替换所有页面标题、登录页、侧边栏文字
2.  新增公共物料下拉选择组件MaterialSelect,替换多处本地物料字典加载逻辑
3.  用户列表、AGV任务列表、入库/出库/盘点/库存列表新增ID与创建时间列
4.  用户列表、物料查询等接口改为后端真分页,按创建时间倒序排序
5.  库存查询新增全局模糊搜索,支持物料编码/批次号/SN匹配
6.  库存汇总新增区域汇总视图与导出功能,修复导出表头与数据字段
7.  优化侧边栏菜单样式与布局,修复菜单图标间距与文字对齐问题
8.  完善库存查询、区域汇总的文档注释与导出字段
This commit is contained in:
SunYF
2026-09-07 15:26:37 +08:00
parent 6ee131a4bd
commit 649d2cc358
17 changed files with 364 additions and 181 deletions
+18 -3
View File
@@ -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})
})
}
+192 -83
View File
@@ -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"` // 物料主表 IDmaterial.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})
}