Files
bj_power/bj_power_wms/internal/handler/stock.go
T
SunYF 649d2cc358 feat: 完成WMS库房管理系统功能迭代与界面优化
1.  统一系统名称为WMS库房管理,替换所有页面标题、登录页、侧边栏文字
2.  新增公共物料下拉选择组件MaterialSelect,替换多处本地物料字典加载逻辑
3.  用户列表、AGV任务列表、入库/出库/盘点/库存列表新增ID与创建时间列
4.  用户列表、物料查询等接口改为后端真分页,按创建时间倒序排序
5.  库存查询新增全局模糊搜索,支持物料编码/批次号/SN匹配
6.  库存汇总新增区域汇总视图与导出功能,修复导出表头与数据字段
7.  优化侧边栏菜单样式与布局,修复菜单图标间距与文字对齐问题
8.  完善库存查询、区域汇总的文档注释与导出字段
2026-09-07 15:26:37 +08:00

961 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"` // 物料主表 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 物料汇总的筛选条件
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:])
}