1. 统一系统名称为WMS库房管理,替换所有页面标题、登录页、侧边栏文字 2. 新增公共物料下拉选择组件MaterialSelect,替换多处本地物料字典加载逻辑 3. 用户列表、AGV任务列表、入库/出库/盘点/库存列表新增ID与创建时间列 4. 用户列表、物料查询等接口改为后端真分页,按创建时间倒序排序 5. 库存查询新增全局模糊搜索,支持物料编码/批次号/SN匹配 6. 库存汇总新增区域汇总视图与导出功能,修复导出表头与数据字段 7. 优化侧边栏菜单样式与布局,修复菜单图标间距与文字对齐问题 8. 完善库存查询、区域汇总的文档注释与导出字段
961 lines
31 KiB
Go
961 lines
31 KiB
Go
package handler
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"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 聚合主列表的行结构(按 物料+区域+质量+管理粒度 聚合,只返回数量,不铺开明细)
|
||
// ID = 组内最近入库的库存行真实主键(inventory.id)——聚合视图无自身实体,取真实主键,禁止伪 id
|
||
type stockAggRow struct {
|
||
ID int64 `json:"id"` // inventory.id(组内最近入库行)
|
||
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") // 物料名称模糊
|
||
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/空=全部
|
||
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,
|
||
Keyword: keyword,
|
||
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
|
||
Keyword string // 模糊搜索:物料编码/批次号/SN 任一匹配(下拉拾取器用)
|
||
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.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))
|
||
}
|
||
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"), ent.Desc("id")).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
|
||
lastInvID int64 // 组内最近入库的库存行真实主键(inventory.id),作行标识
|
||
}
|
||
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++
|
||
}
|
||
// all 已按 created_at desc, id desc 排序,首见即最近一条
|
||
if inv.CreatedAt > g.lastTime {
|
||
g.lastTime = inv.CreatedAt
|
||
g.lastInbNo = inv.InboundNo
|
||
g.lastInvID = int64(inv.ID)
|
||
}
|
||
}
|
||
|
||
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: g.lastInvID, // 组内最近入库的库存行真实主键(inventory.id),禁止伪 id
|
||
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
|
||
}
|
||
|
||
// materialAggRow 物料汇总行(按 物料编码 聚合,忽略区域与质量状态)
|
||
// 回答"这个物料总共有多少":总数/锁定/可用 + 质量分布(合格/未检/不合格) + 分布区域列表。
|
||
// ID 取物料主表(material)主键——聚合视图无自身实体,主表 ID 即行标识,禁止伪 id。
|
||
type materialAggRow struct {
|
||
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 物料汇总的筛选条件
|
||
type materialSummaryArgs struct {
|
||
MaterialCode string
|
||
MaterialName string
|
||
ManageMode string
|
||
Page int
|
||
PageSize int
|
||
}
|
||
|
||
// materialSummaryHandler 物料汇总(按物料聚合所有区域/质量状态)
|
||
// GET /api/stock/material-summary?materialCode=&materialName=&manageMode=&page=&pageSize=
|
||
// 返回 rows: 一物料一行,含 总数/可用/锁定/合格/未检/不合格/分布区域。明细经 /stock/details 点击下钻。
|
||
// 与主列表不同:汇总忽略区域/质量/入库时间筛选(回答总量问题,只认物料与类型)。
|
||
func materialSummaryHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
rows, total, err := aggregateMaterialSummary(ctx, materialSummaryArgs{
|
||
MaterialCode: r.URL.Query().Get("materialCode"),
|
||
MaterialName: r.URL.Query().Get("materialName"),
|
||
ManageMode: r.URL.Query().Get("manageMode"),
|
||
Page: atoi(r.URL.Query().Get("page"), 1),
|
||
PageSize: atoi(r.URL.Query().Get("pageSize"), 20),
|
||
})
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
ok(w, map[string]any{"rows": rows, "total": total})
|
||
}
|
||
}
|
||
|
||
// aggregateMaterialSummary 按物料编码聚合全部在库库存(忽略区域/质量/时间维度),Go 侧内存聚合 + 分页。
|
||
func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([]*materialAggRow, int, error) {
|
||
q := ctx.EntClient.Inventory.Query()
|
||
q = q.Where(inventory.StatusIn("在库", "锁定"))
|
||
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.ManageMode == "1" {
|
||
q = q.Where(inventory.ManageModeEQ(1))
|
||
} else if a.ManageMode == "2" {
|
||
q = q.Where(inventory.ManageModeEQ(2))
|
||
}
|
||
|
||
all, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0())
|
||
if err != nil {
|
||
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 {
|
||
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
|
||
matID[m.Code] = int64(m.ID)
|
||
matCreatedAt[m.Code] = m.CreatedAt
|
||
}
|
||
}
|
||
|
||
type agg struct {
|
||
totalQty int
|
||
lockedQty int
|
||
qQualified int
|
||
qPending int
|
||
qRejected int
|
||
mMode int // 管理粒度(同物料唯一,取首见行的值)
|
||
batchCnt int
|
||
snCnt int
|
||
zones []string
|
||
zoneSeen map[string]bool
|
||
lastInbNo string
|
||
lastTime int64
|
||
}
|
||
group := map[string]*agg{} // key: materialCode
|
||
order := []string{}
|
||
for _, inv := range all {
|
||
key := inv.MaterialCode
|
||
g, ok := group[key]
|
||
if !ok {
|
||
g = &agg{mMode: inv.ManageMode, zoneSeen: map[string]bool{}}
|
||
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++
|
||
}
|
||
// 质量分布(未检=空或非合格/不合格)
|
||
switch inv.QualityStatus {
|
||
case "合格":
|
||
g.qQualified += qtyOf(inv)
|
||
case "不合格":
|
||
g.qRejected += qtyOf(inv)
|
||
default:
|
||
g.qPending += qtyOf(inv)
|
||
}
|
||
// 分布区域
|
||
zc := inv.ZoneCode
|
||
if zc == "" {
|
||
zc = "未分区"
|
||
}
|
||
if !g.zoneSeen[zc] {
|
||
g.zoneSeen[zc] = true
|
||
g.zones = append(g.zones, zc)
|
||
}
|
||
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([]*materialAggRow, 0, end-start)
|
||
for i := start; i < end; i++ {
|
||
code := order[i]
|
||
g := group[code]
|
||
name := matName[code]
|
||
if name == "" {
|
||
name = code
|
||
}
|
||
rows = append(rows, &materialAggRow{
|
||
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
|
||
}
|
||
|
||
// qtyOf 库存行计入数量的口径:结构件按行数量、精密件按 1
|
||
func qtyOf(inv *ent.Inventory) int {
|
||
if inv.ManageMode == 1 {
|
||
return inv.Quantity
|
||
}
|
||
return 1
|
||
}
|
||
|
||
// 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"), ent.Desc("id")).
|
||
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 同一套聚合逻辑,但导出当前筛选下的全部行(不分页)。
|
||
// 支持 ?view=material 导出「物料汇总」(/stock/material-summary 的全量版本)。
|
||
// 返回 .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"),
|
||
MaterialName: r.URL.Query().Get("materialName"),
|
||
ManageMode: r.URL.Query().Get("manageMode"),
|
||
Page: 1,
|
||
PageSize: 1000000, // 全量
|
||
})
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
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{
|
||
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.MaterialCreatedAt), unixFmt(rw.CreatedAt),
|
||
})
|
||
}
|
||
sendExcel(w, xlsxFilename("物料汇总"), headers, matrix)
|
||
return
|
||
}
|
||
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{"ID", "物料编码", "物料名称", "规格", "类型", "区域", "质量状态", "总数量", "锁定量", "可用量", "批次数", "SN数", "最近入库单", "最近入库时间"}
|
||
matrix := make([][]any, 0, len(rows))
|
||
for _, rw := range rows {
|
||
matrix = append(matrix, []any{
|
||
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),
|
||
})
|
||
}
|
||
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: zoneSummRow(含区域主表 ID/创建时间 + 组内最近入库时间)
|
||
// 仅按"区域(Z01~Z04)"维度聚合,不引入货架/层/格(业务决策:小货架高频取货无需精确货位索引)。
|
||
func zoneSummaryHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
out, err := buildZoneSummary(ctx)
|
||
if err != nil {
|
||
fail(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
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:])
|
||
}
|