feat: 完成WMS库房管理系统功能迭代与界面优化
1. 统一系统名称为WMS库房管理,替换所有页面标题、登录页、侧边栏文字 2. 新增公共物料下拉选择组件MaterialSelect,替换多处本地物料字典加载逻辑 3. 用户列表、AGV任务列表、入库/出库/盘点/库存列表新增ID与创建时间列 4. 用户列表、物料查询等接口改为后端真分页,按创建时间倒序排序 5. 库存查询新增全局模糊搜索,支持物料编码/批次号/SN匹配 6. 库存汇总新增区域汇总视图与导出功能,修复导出表头与数据字段 7. 优化侧边栏菜单样式与布局,修复菜单图标间距与文字对齐问题 8. 完善库存查询、区域汇总的文档注释与导出字段
This commit is contained in:
@@ -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})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>库房客户端</title>
|
||||
<title>WMS库房管理</title>
|
||||
<!-- 系统图标:库房箱体(内联 SVG,避免依赖外部文件) -->
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231668dc'/><path d='M16 7l9 4.5v9L16 25l-9-4.5v-9L16 7z' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/><path d='M7 11.5L16 16l9-4.5M16 16v9' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/></svg>">
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
// 物料下拉选择器(公共组件)
|
||||
// 规范:数据量不确定的下拉只加载最近 100 条(created_at desc),支持远程模糊搜索(keyword 查询,结果仍封顶 100)。
|
||||
// 选中后通过 item-change 事件抛出完整物料对象(无匹配为 null),供页面校验 manageMode 等。
|
||||
import { ref, onMounted } from 'vue'
|
||||
import request from '../utils/request'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Array], default: '' },
|
||||
multiple: { type: Boolean, default: false },
|
||||
manageMode: { type: Number, default: 0 }, // 0=全部 1=结构件 2=精密件
|
||||
placeholder: { type: String, default: '选择物料' },
|
||||
clearable: { type: Boolean, default: true },
|
||||
width: { type: String, default: '100%' }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'item-change'])
|
||||
|
||||
const options = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 最近 100 条 + keyword 模糊搜索(后端 /material/query 兼容空 keyword)
|
||||
async function load(kw = '') {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await request.get('/material/query', {
|
||||
params: { page: 1, pageSize: 100, keyword: kw || '', manageMode: props.manageMode || '' }
|
||||
})
|
||||
options.value = data?.list || []
|
||||
} catch {
|
||||
options.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(v) {
|
||||
emit('update:modelValue', v)
|
||||
if (props.multiple) {
|
||||
emit('item-change', options.value.filter((o) => v.includes(o.code)))
|
||||
} else {
|
||||
emit('item-change', options.value.find((o) => o.code === v) || null)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-select :model-value="modelValue" :multiple="multiple" filterable remote clearable
|
||||
:remote-method="load" :loading="loading" :placeholder="placeholder"
|
||||
:style="{ width }" @change="onChange">
|
||||
<el-option v-for="m in options" :key="m.id" :value="m.code"
|
||||
:label="`${m.code} ${m.name || ''}`" />
|
||||
</el-select>
|
||||
</template>
|
||||
@@ -1,4 +1,4 @@
|
||||
// WMS 库房客户端 页面级操作说明:整体流程 + 各字段 数据来源/用途/怎么填
|
||||
// WMS库房管理 页面级操作说明:整体流程 + 各字段 数据来源/用途/怎么填
|
||||
// 由 components/PageHelp.vue 在左下角 "?" 按钮弹出展示。
|
||||
// 说明:这里的"数据来源"特指该字段数据由谁产生、从哪个系统/页面来,帮助跑通真实流程。
|
||||
|
||||
@@ -60,7 +60,7 @@ export const helpOutbound = {
|
||||
export const helpInventory = {
|
||||
title: '库存查询',
|
||||
overview:
|
||||
'查看当前库存:结构件按批次(数量/锁定/区域),精密件按 SN(状态/区域)。数据由入库、出库、检验、盘点等业务实时维护,本页只读。\n\n本页三个视图页签(首屏默认「物料汇总」):\n· 物料汇总:一物料一行,直接回答"这个物料总共有多少"——总量/锁定/可用 + 质量分布(合格/未检/不合格) + 分布在哪些区域;点行下钻该物料的批次/SN 明细。\n· 库存明细:按 物料 × 区域 × 质量 聚合,可组合 区域/质量状态/入库时间 筛选(物料汇总不受区域/质量筛选影响)。\n· 区域汇总:按 区域 × 质量状态 × 类型 看各库区的货量。\n\n层级概念:入库单 → 批次/SN → 库存行。区域/质量状态/批次/最近入库单都是某物料的一对多属性,放在下钻明细中查看,不在物料行上并排平铺。',
|
||||
'查看当前库存:结构件按批次(数量/锁定/区域),精密件按 SN(状态/区域)。数据由入库、出库、检验、盘点等业务实时维护,本页只读。\n\n本页三个视图页签(首屏默认「物料汇总」):\n· 物料汇总:一物料一行,直接回答"这个物料总共有多少"——总量/锁定/可用 + 质量分布(合格/未检/不合格) + 分布在哪些区域;ID 与创建时间取自物料主表,另有最近入库单/最近入库时间;点行下钻该物料的批次/SN 明细。\n· 库存明细:按 物料 × 区域 × 质量 聚合,可组合 区域/质量状态/入库时间 筛选(物料汇总不受区域/质量筛选影响)。\n· 区域汇总:按 区域 × 质量状态 × 类型 看各库区的货量;ID 与创建时间取自区域主表,另有组内最近入库时间;支持全量导出。\n\n层级概念:入库单 → 批次/SN → 库存行。区域/质量状态/批次/最近入库单都是某物料的一对多属性,放在下钻明细中查看,不在物料行上并排平铺。',
|
||||
fields: [
|
||||
{ name: '物料编码 / 物料名称', source: '筛选条件。', purpose: '只看某个物料', fill: '输入编码或名称,可空=全部' },
|
||||
{ name: '库存类型', source: '单选(全部/结构件/精密件)。', purpose: '按管理粒度过滤', fill: '全部 / 结构件(批次) / 精密件(SN)' },
|
||||
@@ -68,7 +68,8 @@ export const helpInventory = {
|
||||
{ name: '物料汇总-分布区域', source: '系统统计该物料当前占用了哪些区域。', purpose: '一个物料可跨多个区域存放(批次可拆分),物料行不写死单一区域', fill: '如 Z02、Z05;点行下钻可看每个批次/SN 具体在哪个区' },
|
||||
{ name: '库存明细-区域/质量/入库时间', source: '仅「库存明细」视图生效的筛选。', purpose: '在物料基础上再按存放区/质量状态/入库时间收窄', fill: '可组合筛选' },
|
||||
{ name: '批次/SN 明细(下钻)', source: '点击任一行弹出抽屉。', purpose: '看该物料的每个批次/SN 所在区域、质量状态、锁定与来源入库单', fill: '抽屉内分页展示;点入库单号可跳转入库页' },
|
||||
{ name: '导出', source: '按钮。后端按当前筛选全量导出 xlsx。', purpose: '把结果导出存档', fill: '物料汇总视图导出「物料汇总.xlsx」;库存明细视图导出「库存汇总.xlsx」' }
|
||||
{ name: 'ID / 创建时间', source: '物料汇总行取物料主表(material),区域汇总行取区域主表(zone)。', purpose: '行标识与主档建立时间,用于追溯与对账', fill: '系统自动带出,只读;物料档案/区域缺失时显示 -' },
|
||||
{ name: '导出', source: '按钮。后端按当前筛选全量导出 xlsx。', purpose: '把结果导出存档', fill: '物料汇总视图导出「物料汇总.xlsx」;库存明细视图导出「库存汇总.xlsx」;区域汇总视图导出「区域汇总.xlsx」' }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -167,22 +167,22 @@ function onLogout() {
|
||||
:class="{ 'drawer': isMobile, 'drawer-open': isMobile && mobileOpen }">
|
||||
<div class="brand">
|
||||
<el-icon :size="22"><Box /></el-icon>
|
||||
<span v-show="!isCollapse">库房客户端</span>
|
||||
<span v-show="!isCollapse">WMS库房管理</span>
|
||||
</div>
|
||||
<el-menu :key="menuKey" :default-active="route.path" :default-openeds="defaultOpeneds" class="menu"
|
||||
background-color="#001529" text-color="#c2cad8" active-text-color="#ffffff" router
|
||||
background-color="#001529" text-color="#a6adb4" active-text-color="#409eff" router
|
||||
:collapse="isCollapse && !isMobile">
|
||||
<el-menu-item v-if="showTopMenu" :index="topMenu.path">
|
||||
<el-icon><component :is="topMenu.icon" /></el-icon>
|
||||
<el-icon style="margin-right:8px"><component :is="topMenu.icon" /></el-icon>
|
||||
<span>{{ topMenu.title }}</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu v-for="g in menus" :key="g.title" :index="'group:' + g.title">
|
||||
<template #title>
|
||||
<el-icon><component :is="g.icon" /></el-icon>
|
||||
<el-icon style="margin-right:8px"><component :is="g.icon" /></el-icon>
|
||||
<span>{{ g.title }}</span>
|
||||
</template>
|
||||
<el-menu-item v-for="m in g.children" :key="m.path" :index="m.path">
|
||||
<el-icon><component :is="m.icon" /></el-icon>
|
||||
<el-icon style="margin-right:8px"><component :is="m.icon" /></el-icon>
|
||||
<span>{{ m.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
@@ -231,10 +231,9 @@ function onLogout() {
|
||||
.drawer-open { transform: translateX(0); }
|
||||
.drawer-mask { position: fixed; inset: 0; background: rgba(0, 0, 0, .45); z-index: 1000; }
|
||||
.brand {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
height: 56px; padding: 0 16px;
|
||||
color: #fff; font-weight: 600; font-size: 15px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
height: 60px;
|
||||
color: #fff; font-weight: 600; font-size: 17px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu { border-right: none; }
|
||||
|
||||
@@ -162,6 +162,7 @@ onMounted(() => { loadDocks(); loadTasks() })
|
||||
<el-button @click="taskPage.current = 1; loadTasks()">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="tasks" v-loading="loadingTasks" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column prop="taskNo" label="任务号" width="150" />
|
||||
<el-table-column prop="sourceDock" label="源" width="80" align="center" />
|
||||
<el-table-column prop="targetDock" label="目标" width="80" align="center" />
|
||||
@@ -186,6 +187,9 @@ onMounted(() => { loadDocks(); loadTasks() })
|
||||
<el-table-column label="到位时间" width="150">
|
||||
<template #default="{ row }">{{ row.arrivedAt ? fmtTime(row.arrivedAt) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="155" align="center">
|
||||
<template #default="{ row }">{{ row.createdAt ? fmtTime(row.createdAt) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operator" label="下发人" width="100" />
|
||||
<el-table-column prop="hikTaskCode" label="RCS任务码" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fmtTime } from '../utils/format'
|
||||
import { exportXlsxFetch } from '../utils/export'
|
||||
import { can } from '../utils/perm'
|
||||
import PageHelp from '../components/PageHelp.vue'
|
||||
import MaterialSelect from '../components/MaterialSelect.vue'
|
||||
import { helpInbound } from '../help'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -17,20 +18,14 @@ const operator = getRealName()
|
||||
// ===== 多 Tab:结构件入库 / 精密件 SN 入库 在前(常操作),入库记录在后 =====
|
||||
const activeTab = ref('batch') // batch=结构件入库 / sn=精密件SN入库 / records=入库记录
|
||||
|
||||
// 公共字典:物料 / 区域
|
||||
const materials = ref([])
|
||||
// 公共字典:区域(物料下拉由 MaterialSelect 组件按需远程搜索,最近 100 条)
|
||||
const zones = ref([])
|
||||
async function loadDicts() {
|
||||
if (materials.value.length && zones.value.length) return
|
||||
const [ms, zs] = await Promise.all([request.get('/material/list'), request.get('/zone/picker')])
|
||||
materials.value = Array.isArray(ms) ? ms : ms?.list || []
|
||||
if (zones.value.length) return
|
||||
const zs = await request.get('/zone/picker')
|
||||
zones.value = Array.isArray(zs) ? zs : zs?.list || []
|
||||
}
|
||||
|
||||
// 按当前页过滤物料:结构件页只显示批次管理(manageMode=1),SN 页只显示序列号管理(manageMode=2)
|
||||
const batchMaterials = computed(() => materials.value.filter((m) => Number(m.manageMode) === 1))
|
||||
const snMaterials = computed(() => materials.value.filter((m) => Number(m.manageMode) === 2))
|
||||
|
||||
/* ===================== Tab1 结构件入库(批次,支持多批到货追加) ===================== */
|
||||
const batchRef = ref()
|
||||
const submitting1 = ref(false)
|
||||
@@ -51,11 +46,6 @@ async function submitBatch() {
|
||||
ElMessage.warning('数量必须大于 0')
|
||||
return
|
||||
}
|
||||
const mat1 = materials.value.find(m => m.code === form1.materialCode)
|
||||
if (mat1 && Number(mat1.manageMode) === 2) {
|
||||
ElMessage.warning('该物料为精密件(按 SN 管理),请切换到「精密件 SN 入库」页扫码录入后再提交')
|
||||
return
|
||||
}
|
||||
submitting1.value = true
|
||||
try {
|
||||
const data = await request.post('/inbound/create', {
|
||||
@@ -117,11 +107,6 @@ async function submitSn() {
|
||||
ElMessage.warning('请先扫码录入 SN(输入后点击【录入到列表】)')
|
||||
return
|
||||
}
|
||||
const mat2 = materials.value.find(m => m.code === form2.materialCode)
|
||||
if (mat2 && Number(mat2.manageMode) === 1) {
|
||||
ElMessage.warning('该物料为结构件(按批次管理),请切换到「结构件入库」页按数量入库')
|
||||
return
|
||||
}
|
||||
submitting2.value = true
|
||||
try {
|
||||
const data = await request.post('/inbound/create', {
|
||||
@@ -299,10 +284,7 @@ onMounted(() => {
|
||||
<el-card shadow="never">
|
||||
<el-form ref="batchRef" :model="form1" :rules="rules1" label-width="110px" style="max-width:560px">
|
||||
<el-form-item label="物料" prop="materialCode">
|
||||
<el-select v-model="form1.materialCode" filterable placeholder="选择结构件物料" style="width:100%">
|
||||
<el-option v-for="m in batchMaterials" :key="m.id" :value="m.code"
|
||||
:label="`${m.code} ${m.name || ''}`" />
|
||||
</el-select>
|
||||
<MaterialSelect v-model="form1.materialCode" :manage-mode="1" placeholder="选择结构件物料" />
|
||||
</el-form-item>
|
||||
<el-form-item label="批次号">
|
||||
<el-input v-model="form1.batchNo" placeholder="留空则自动生成" clearable />
|
||||
@@ -355,10 +337,7 @@ onMounted(() => {
|
||||
<el-card shadow="never">
|
||||
<el-form label-width="110px" style="max-width:680px">
|
||||
<el-form-item label="物料" required>
|
||||
<el-select v-model="form2.materialCode" filterable placeholder="请选择精密件物料(必填)" style="width:100%">
|
||||
<el-option v-for="m in snMaterials" :key="m.id" :value="m.code"
|
||||
:label="`${m.code} ${m.name || ''}`" />
|
||||
</el-select>
|
||||
<MaterialSelect v-model="form2.materialCode" :manage-mode="2" placeholder="请选择精密件物料(必填)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="区域" required>
|
||||
<el-select v-model="form2.zoneCode" placeholder="请选择入库区域(必填)" clearable style="width:100%">
|
||||
@@ -451,6 +430,7 @@ onMounted(() => {
|
||||
<!-- 入库单列表(一单一行,结构件/精密件同表) -->
|
||||
<el-card shadow="never" header="入库单(每行一笔入库;点「明细」在右侧抽屉查看该单的行)">
|
||||
<el-table :data="list" border size="small" v-loading="loadingRec" empty-text="暂无入库数据">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column prop="inboundNo" label="入库单号" min-width="160" />
|
||||
<el-table-column label="类型" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
|
||||
@@ -38,17 +38,22 @@ const inputPlaceholder = computed(() =>
|
||||
: '扫入或粘贴 SN 序列号,可用 逗号/分号/空格/回车 分隔,一次可录多条'
|
||||
)
|
||||
|
||||
// 库存拾取器:直接选已存在的批次号/SN,避免手输(垃圾设计)
|
||||
// 库存拾取器:直接选已存在的批次号/SN,避免手输。
|
||||
// 规范:库存量不确定 → 只加载最近 100 条(created_at desc),输入走远程模糊搜索(keyword 匹配 物料编码/批次号/SN)
|
||||
const pickerOptions = ref([])
|
||||
async function loadPicker() {
|
||||
const pickerLoading = ref(false)
|
||||
async function loadPicker(kw = '') {
|
||||
pickerLoading.value = true
|
||||
try {
|
||||
const mm = targetType.value === 'BATCH' ? 1 : 2
|
||||
const data = await request.get('/stock/query', {
|
||||
params: { manageMode: mm, page: 1, pageSize: 500 }
|
||||
params: { manageMode: mm, keyword: kw || '', page: 1, pageSize: 100 }
|
||||
})
|
||||
pickerOptions.value = data?.list || []
|
||||
} catch (e) {
|
||||
pickerOptions.value = []
|
||||
} finally {
|
||||
pickerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +213,7 @@ onMounted(() => {
|
||||
<el-form label-width="96px">
|
||||
<!-- 目标类型直接改变输入框标题与占位,直观体现当前录入对象 -->
|
||||
<el-form-item label="目标类型">
|
||||
<el-radio-group v-model="targetType" @change="loadPicker">
|
||||
<el-radio-group v-model="targetType" @change="() => loadPicker()">
|
||||
<el-radio-button value="BATCH">批次 (结构件)</el-radio-button>
|
||||
<el-radio-button value="SN">序列号 SN (精密件)</el-radio-button>
|
||||
</el-radio-group>
|
||||
@@ -239,13 +244,16 @@ onMounted(() => {
|
||||
|
||||
<el-divider content-position="left">待检编号(可扫/选多条,统一提交)</el-divider>
|
||||
|
||||
<!-- 从库存直接拾取,避免手输批次号 -->
|
||||
<!-- 从库存直接拾取,避免手输批次号(远程模糊搜索,最近 100 条) -->
|
||||
<el-form-item :label="`从库存选${inputLabel}`">
|
||||
<el-select
|
||||
v-model="pickerValue"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
:placeholder="`选择已存在的${inputLabel}(来自库存)`"
|
||||
:remote-method="loadPicker"
|
||||
:loading="pickerLoading"
|
||||
:placeholder="`输入批次号/SN/物料编码搜索(最近100条)`"
|
||||
style="width:100%"
|
||||
@change="addFromPicker"
|
||||
>
|
||||
|
||||
@@ -190,7 +190,7 @@ function gotoInbound(no) {
|
||||
router.push({ path: '/inbound', query: { inboundNo: no } })
|
||||
}
|
||||
|
||||
// 导出:按当前视图导出对应粒度(物料汇总/库存明细)
|
||||
// 导出:按当前视图导出对应粒度(物料汇总 / 库存明细 / 区域汇总)
|
||||
async function exportCsv() {
|
||||
try {
|
||||
if (viewTab.value === 'material') {
|
||||
@@ -202,6 +202,10 @@ async function exportCsv() {
|
||||
}, '物料汇总.xlsx')
|
||||
return
|
||||
}
|
||||
if (viewTab.value === 'summary') {
|
||||
await exportXlsxFetch('/api/stock/export', { view: 'zone' }, '区域汇总.xlsx')
|
||||
return
|
||||
}
|
||||
await exportXlsxFetch('/api/stock/export', {
|
||||
materialCode: filters.materialCode.trim(),
|
||||
materialName: filters.materialName.trim(),
|
||||
@@ -282,6 +286,9 @@ onMounted(() => {
|
||||
<el-card v-if="viewTab === 'material'" shadow="never" header="物料汇总(一物料一行:总数 + 质量分布 + 分布区域,点击行下钻批次/SN 明细)">
|
||||
<el-table :data="matRows" border size="small" v-loading="matLoading" empty-text="暂无库存数据"
|
||||
@row-click="openDetails" row-key="materialCode">
|
||||
<el-table-column label="ID" prop="id" width="70" align="center">
|
||||
<template #default="{ row }">{{ row.id || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="materialCode" label="物料编码" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="link">{{ row.materialCode }}</span>
|
||||
@@ -333,6 +340,9 @@ onMounted(() => {
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="物料创建时间" width="155" align="center">
|
||||
<template #default="{ row }">{{ row.materialCreatedAt ? fmtTime(row.materialCreatedAt) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近入库时间" width="155" align="center">
|
||||
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
@@ -405,8 +415,18 @@ onMounted(() => {
|
||||
</el-card>
|
||||
|
||||
<!-- ============ 视图三:区域汇总 ============ -->
|
||||
<el-card v-if="viewTab === 'summary'" shadow="never" header="各区域库存数量(按 区域 + 质量状态 + 类型)">
|
||||
<el-card v-if="viewTab === 'summary'" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span>各区域库存数量(按 区域 + 质量状态 + 类型)</span>
|
||||
<el-button v-if="can('inventory:export')" :disabled="!summaryRows.length" size="small"
|
||||
@click="exportCsv">导出区域汇总</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="summaryRows" border size="small" v-loading="summaryLoading" empty-text="暂无数据">
|
||||
<el-table-column label="ID" prop="id" width="70" align="center">
|
||||
<template #default="{ row }">{{ row.id || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="zoneCode" label="区域编码" width="110" />
|
||||
<el-table-column prop="zoneName" label="区域名称" min-width="140" />
|
||||
<el-table-column prop="quality" label="质量状态" width="110" align="center">
|
||||
@@ -417,6 +437,12 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="100" align="center" />
|
||||
<el-table-column prop="qty" label="数量" width="120" align="center" />
|
||||
<el-table-column label="区域创建时间" width="155" align="center">
|
||||
<template #default="{ row }">{{ row.zoneCreatedAt ? fmtTime(row.zoneCreatedAt) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近入库时间" width="155" align="center">
|
||||
<template #default="{ row }">{{ row.lastCreatedAt ? fmtTime(row.lastCreatedAt) : '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ async function onSubmit() {
|
||||
<div class="login-page">
|
||||
<el-card class="card">
|
||||
<div class="head">
|
||||
<h2>库房客户端</h2>
|
||||
<h2>WMS库房管理</h2>
|
||||
<p>请使用 WMS 账号登录</p>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @keyup.enter="onSubmit">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fmtTime } from '../utils/format'
|
||||
import { exportXlsxFetch } from '../utils/export'
|
||||
import { can } from '../utils/perm'
|
||||
import PageHelp from '../components/PageHelp.vue'
|
||||
import MaterialSelect from '../components/MaterialSelect.vue'
|
||||
import { helpOutbound } from '../help'
|
||||
|
||||
const activeTab = ref('prep') // prep=备料出库 / general=通用出库 / records=出库记录
|
||||
@@ -98,7 +99,7 @@ async function queryPrep() {
|
||||
}
|
||||
|
||||
/* ===================== 通用出库(手动,不依赖工单) ===================== */
|
||||
const materials = ref([])
|
||||
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条)
|
||||
const zones = ref([])
|
||||
const genForm = reactive({ materialCode: '', mode: 1, batchNo: '', qty: 1, zoneCode: '', boxNo: '', contractNo: '', remark: '' })
|
||||
const snInput = ref('')
|
||||
@@ -120,9 +121,9 @@ function addSnLines() {
|
||||
function removeSn(i) { snList.value.splice(i, 1) }
|
||||
|
||||
async function loadDicts() {
|
||||
if (materials.value.length) return
|
||||
const [ms, zs] = await Promise.all([request.get('/material/list'), request.get('/zone/picker')])
|
||||
materials.value = Array.isArray(ms) ? ms : ms?.list || []
|
||||
if (zones.value.length) return
|
||||
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条),此处只加载区域
|
||||
const zs = await request.get('/zone/picker')
|
||||
zones.value = Array.isArray(zs) ? zs : zs?.list || []
|
||||
}
|
||||
|
||||
@@ -278,9 +279,7 @@ onMounted(loadDicts)
|
||||
<el-card shadow="never" class="mb12" header="通用出库(不依赖工单:退料 / 样品 / 报废 / 发货)">
|
||||
<el-form label-width="96px" style="max-width:880px">
|
||||
<el-form-item label="物料" required>
|
||||
<el-select v-model="genForm.materialCode" filterable placeholder="选择物料" style="width:100%">
|
||||
<el-option v-for="m in materials" :key="m.id" :value="m.code" :label="`${m.code} ${m.name || ''}`" />
|
||||
</el-select>
|
||||
<MaterialSelect v-model="genForm.materialCode" placeholder="选择物料" />
|
||||
</el-form-item>
|
||||
<el-form-item label="出库类型">
|
||||
<el-radio-group v-model="genForm.mode">
|
||||
@@ -378,9 +377,7 @@ onMounted(loadDicts)
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="物料">
|
||||
<el-select v-model="recFilters.materialCode" filterable clearable placeholder="物料" style="width:180px">
|
||||
<el-option v-for="m in materials" :key="m.id" :value="m.code" :label="`${m.code} ${m.name || ''}`" />
|
||||
</el-select>
|
||||
<MaterialSelect v-model="recFilters.materialCode" placeholder="物料" width="180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="箱号">
|
||||
<el-input v-model="recFilters.boxNo" placeholder="按箱号查询" clearable style="width:160px" @keyup.enter="searchRec" />
|
||||
|
||||
@@ -6,16 +6,13 @@ import request from '../utils/request'
|
||||
import { fmtTime } from '../utils/format'
|
||||
import { getRealName } from '../utils/auth'
|
||||
import PageHelp from '../components/PageHelp.vue'
|
||||
import MaterialSelect from '../components/MaterialSelect.vue'
|
||||
import { helpSemi } from '../help'
|
||||
|
||||
/* ---------- 半成品/成品入库(携带已完成工序,连续扫码) ---------- */
|
||||
const materials = ref([])
|
||||
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const ms = await request.get('/material/list')
|
||||
materials.value = Array.isArray(ms) ? ms : ms?.list || []
|
||||
} catch { /* 字典加载失败不阻塞入库 */ }
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
})
|
||||
|
||||
@@ -129,10 +126,7 @@ function search() {
|
||||
<el-tab-pane label="半成品/成品入库">
|
||||
<el-form label-width="110px" style="max-width:680px">
|
||||
<el-form-item label="物料">
|
||||
<el-select v-model="inForm.materialCode" filterable placeholder="选择物料编码" style="width:100%">
|
||||
<el-option v-for="m in materials" :key="m.id" :value="m.code"
|
||||
:label="`${m.code} ${m.name || ''}`" />
|
||||
</el-select>
|
||||
<MaterialSelect v-model="inForm.materialCode" placeholder="选择物料编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="已完成工序">
|
||||
<el-input v-model="inForm.completedProcess" placeholder="如:1,3,5(可空)" clearable />
|
||||
@@ -200,6 +194,7 @@ function search() {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="list" border size="small" v-loading="loading" empty-text="暂无记录">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column prop="sn" label="SN" min-width="200" />
|
||||
<el-table-column prop="materialCode" label="物料编码" min-width="140" />
|
||||
<el-table-column prop="completedProcess" label="已完成工序" min-width="120">
|
||||
|
||||
@@ -6,6 +6,7 @@ import request from '../utils/request'
|
||||
import { fmtTime } from '../utils/format'
|
||||
import { getRealName } from '../utils/auth'
|
||||
import PageHelp from '../components/PageHelp.vue'
|
||||
import MaterialSelect from '../components/MaterialSelect.vue'
|
||||
import { helpStocktake } from '../help'
|
||||
|
||||
/* ---------- 发起盘点(可选范围:全库 / 按区域 / 按物料) ---------- */
|
||||
@@ -21,19 +22,14 @@ const scope = reactive({
|
||||
qualityStatus: '' // 空=全部
|
||||
})
|
||||
|
||||
// 发起范围所需的字典
|
||||
// 发起范围所需的字典(物料下拉由 MaterialSelect 组件按需远程搜索,最近 100 条)
|
||||
const zonesDict = ref([])
|
||||
const materialsDict = ref([])
|
||||
async function loadScopeDicts() {
|
||||
try {
|
||||
const z = await request.get('/zone/picker')
|
||||
const arr = Array.isArray(z) ? z : (z?.list || [])
|
||||
zonesDict.value = arr.map(x => x.zoneCode || x.code).filter(Boolean)
|
||||
} catch { /* 忽略 */ }
|
||||
try {
|
||||
const m = await request.get('/material/query', { params: { page: 1, pageSize: 500 } })
|
||||
materialsDict.value = (m?.list || []).map(x => ({ code: x.code, name: x.name }))
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function openScope() {
|
||||
@@ -222,11 +218,8 @@ const currentStep = computed(() => {
|
||||
</el-form-item>
|
||||
<template v-if="scope.type === 'material'">
|
||||
<el-form-item label="物料">
|
||||
<el-select v-model="scope.materialCodes" multiple filterable clearable
|
||||
placeholder="选择盘点物料(可多选)" style="width:100%">
|
||||
<el-option v-for="m in materialsDict" :key="m.code"
|
||||
:value="m.code" :label="`${m.code} ${m.name || ''}`" />
|
||||
</el-select>
|
||||
<MaterialSelect v-model="scope.materialCodes" multiple
|
||||
placeholder="选择盘点物料(可多选,输入可搜索)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="scope.manageMode" clearable placeholder="全部类型" style="width:100%">
|
||||
@@ -319,6 +312,7 @@ const currentStep = computed(() => {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="history" border size="small" empty-text="暂无盘点记录">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column prop="stocktakeNo" label="盘点单号" min-width="200" />
|
||||
<el-table-column prop="operator" label="操作人" width="110" align="center">
|
||||
<template #default="{ row }">{{ row.operator || '-' }}</template>
|
||||
|
||||
@@ -22,28 +22,27 @@ async function loadRoles() {
|
||||
} catch { /* 无权限时不阻断用户页 */ }
|
||||
}
|
||||
|
||||
const all = ref([]) // 全量(无分页全量拉取,页面前端分页)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = reactive({ current: 1, size: 20 })
|
||||
|
||||
// 后端真分页(created_at desc 最新置顶),禁止前端全量拉取后本地分页
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await request.get('/user/list')
|
||||
all.value = data?.list || []
|
||||
sliceList()
|
||||
const data = await request.get('/user/list', {
|
||||
params: { page: page.current, pageSize: page.size }
|
||||
})
|
||||
list.value = data?.list || []
|
||||
total.value = data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function sliceList() {
|
||||
const start = (page.current - 1) * page.size
|
||||
list.value = all.value.slice(start, start + page.size)
|
||||
}
|
||||
function onPage() {
|
||||
sliceList()
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function roleName(row) {
|
||||
@@ -185,6 +184,9 @@ onMounted(() => {
|
||||
<el-table-column label="最近登录" width="150" align="center">
|
||||
<template #default="{ row }">{{ row.lastLoginAt ? fmtTime(row.lastLoginAt) : '从未' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="155" align="center">
|
||||
<template #default="{ row }">{{ row.createdAt ? fmtTime(row.createdAt) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="can('user:edit')" link type="primary" size="small" :disabled="row.username === 'admin'"
|
||||
@@ -196,8 +198,8 @@ onMounted(() => {
|
||||
</el-table>
|
||||
|
||||
<el-pagination v-model:current-page="page.current" v-model:page-size="page.size" :page-sizes="[10, 20, 50]"
|
||||
:total="all.length" background layout="total, sizes, prev, pager, next, jumper" style="margin-top:12px"
|
||||
@size-change="onPage" @current-change="onPage" />
|
||||
:total="total" background layout="total, sizes, prev, pager, next, jumper" style="margin-top:12px"
|
||||
@size-change="() => { page.current = 1; loadUsers() }" @current-change="onPage" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑账号' : '新增账号'" width="520px"
|
||||
|
||||
@@ -44,7 +44,7 @@ router.beforeEach((to) => {
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
document.title = to.meta?.title ? `${to.meta.title} - 库房客户端` : '库房客户端'
|
||||
document.title = to.meta?.title ? `${to.meta.title} - WMS库房管理` : 'WMS库房管理'
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -3,11 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>库房客户端</title>
|
||||
<title>WMS库房管理</title>
|
||||
<!-- 系统图标:库房箱体(内联 SVG,避免依赖外部文件) -->
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231668dc'/><path d='M16 7l9 4.5v9L16 25l-9-4.5v-9L16 7z' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/><path d='M7 11.5L16 16l9-4.5M16 16v9' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-BU25nTUO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-3pBbrjFT.css">
|
||||
<script type="module" crossorigin src="/assets/index-yykpqais.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DTC9tJqp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user