feat: 完成WMS系统权限体系与业务功能迭代
本提交完成了WMS系统的多维度优化升级: 1. 新增RBAC权限系统,支持角色/菜单/按钮三级权限管控 2. 重构库存盘点、物料管理、区域维护等模块的查询筛选与展示逻辑 3. 优化入库/出库/质检等业务流程,完善数据冗余与业务闭环 4. 移除旧装箱表,将装箱逻辑合并到出库主表 5. 新增前端权限判断工具、导出工具与端到端测试用例 6. 补充完善各类注释与数据库字段说明
This commit is contained in:
@@ -4,31 +4,273 @@ 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"
|
||||
)
|
||||
|
||||
// queryStockHandler 库存查询(MES 查料 / 客户端查料)
|
||||
// 统一查询 inventory 一张表:按 物料编码/区域/关键字/管理粒度(manageMode) 过滤。
|
||||
// 返回单一 list(结构件与精密件同表),前端统一展示、统一导出。
|
||||
// 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/空=全部
|
||||
keyword := r.URL.Query().Get("keyword")
|
||||
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("在库", "锁定"))
|
||||
// 结构件隐藏数量为 0 的行
|
||||
q = q.Where(inventory.Or(inventory.ManageModeEQ(2), inventory.QuantityGT(0)))
|
||||
if materialCode != "" {
|
||||
q = q.Where(inventory.MaterialCodeEQ(materialCode))
|
||||
@@ -36,25 +278,19 @@ func queryStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
if zoneCode != "" {
|
||||
q = q.Where(inventory.ZoneCodeEQ(zoneCode))
|
||||
}
|
||||
if manageMode == "1" {
|
||||
q = q.Where(inventory.ManageModeEQ(1))
|
||||
} else if manageMode == "2" {
|
||||
q = q.Where(inventory.ManageModeEQ(2))
|
||||
if qualityStatus != "" {
|
||||
q = q.Where(inventory.QualityStatusEQ(qualityStatus))
|
||||
}
|
||||
if keyword != "" {
|
||||
q = q.Where(inventory.Or(
|
||||
inventory.BatchNoContains(keyword),
|
||||
inventory.MaterialCodeContains(keyword),
|
||||
inventory.SnCodeContains(keyword),
|
||||
inventory.MaterialNameContains(keyword),
|
||||
))
|
||||
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("id")).
|
||||
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())
|
||||
@@ -69,6 +305,40 @@ func queryStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 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}]
|
||||
|
||||
Reference in New Issue
Block a user