Files
bj_power/bj_power_wms/internal/handler/stock.go
T

624 lines
19 KiB
Go
Raw Normal View History

2026-08-28 15:06:01 +08:00
package handler
import (
"fmt"
2026-08-28 15:06:01 +08:00
"net/http"
"strings"
"time"
2026-08-28 15:06:01 +08:00
"entgo.io/ent/dialect/sql"
"bj_power_wms/ent"
"bj_power_wms/ent/inventory"
2026-08-28 15:06:01 +08:00
"bj_power_wms/ent/inventorylock"
"bj_power_wms/ent/material"
"bj_power_wms/ent/predicate"
2026-08-28 15:06:01 +08:00
"bj_power_wms/internal/svc"
)
// 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 点击下钻(分页懒加载)。
2026-08-28 15:06:01 +08:00
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") // 物料名称模糊
2026-08-28 15:06:01 +08:00
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,
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))
}
2026-09-03 18:13:59 +08:00
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
}
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)
2026-08-28 15:06:01 +08:00
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)))
2026-08-28 15:06:01 +08:00
if materialCode != "" {
q = q.Where(inventory.MaterialCodeEQ(materialCode))
2026-08-28 15:06:01 +08:00
}
if zoneCode != "" {
q = q.Where(inventory.ZoneCodeEQ(zoneCode))
}
if qualityStatus != "" {
q = q.Where(inventory.QualityStatusEQ(qualityStatus))
2026-08-28 15:06:01 +08:00
}
if manageMode != 0 {
q = q.Where(inventory.ManageModeEQ(manageMode))
2026-08-28 15:06:01 +08:00
}
total, err := q.Count(ctx0())
2026-08-28 15:06:01 +08:00
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
2026-09-03 18:13:59 +08:00
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).
2026-08-28 15:06:01 +08:00
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,
})
}
}
2026-08-28 15:06:01 +08:00
// 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}]
// 仅按"区域(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
2026-08-28 15:06:01 +08:00
}
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"`
2026-08-28 15:06:01 +08:00
}
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
}
2026-08-28 15:06:01 +08:00
}
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})
2026-08-28 15:06:01 +08:00
}
ok(w, map[string]any{"rows": out})
2026-08-28 15:06:01 +08:00
}
}
// 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 工单锁定库存(库存占用)
2026-08-28 15:06:01 +08:00
// body: { orderNo, items: [{ materialCode, batchNo?, sn?, qty }] }
// 锁定为"增量"操作:结构件 locked_qty += qty;精密件状态置 锁定。绝不覆盖终值。
// 锁批次用原子增量 + 可用量条件校验;锁 SN 用状态条件原子更新,杜绝超锁与并发冲突。
2026-08-28 15:06:01 +08:00
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(原子:仅 在库 才能锁)
2026-08-28 15:06:01 +08:00
if item.Sn != "" {
aff, err := ctx.EntClient.Inventory.Update().
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(item.Sn), inventory.StatusEQ("在库")).
SetStatus("锁定").SetLockedQty(1).
Save(ctx0())
2026-08-28 15:06:01 +08:00
if err != nil {
fail(w, http.StatusInternalServerError, "锁定 SN 失败: "+err.Error())
return
}
if aff == 0 {
2026-08-28 15:06:01 +08:00
fail(w, http.StatusBadRequest, "SN 不在库或不存在: "+item.Sn)
return
}
ctx.EntClient.InventoryLock.Create().
2026-08-28 15:06:01 +08:00
SetTargetType("SN").
SetTargetID(item.Sn).
2026-08-28 15:06:01 +08:00
SetMaterialCode(item.MaterialCode).
SetLockedQty(1).
SetOrderNo(req.OrderNo).
SetStatus("ACTIVE").
SaveX(ctx0())
2026-08-28 15:06:01 +08:00
locked++
continue
}
// 结构件:锁批次数量(原子增量 + 可用量校验)
2026-08-28 15:06:01 +08:00
qty := item.Qty
if qty <= 0 {
qty = 1
}
b, err := ctx.EntClient.Inventory.Query().
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(item.BatchNo)).Only(ctx0())
2026-08-28 15:06:01 +08:00
if err != nil {
fail(w, http.StatusBadRequest, "批次不存在: "+item.BatchNo)
return
}
avail := b.Quantity - b.LockedQty
2026-08-28 15:06:01 +08:00
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().
2026-08-28 15:06:01 +08:00
SetTargetType("BATCH").
SetTargetID(item.BatchNo).
2026-08-28 15:06:01 +08:00
SetMaterialCode(item.MaterialCode).
SetLockedQty(qty).
SetOrderNo(req.OrderNo).
SetStatus("ACTIVE").
SaveX(ctx0())
2026-08-28 15:06:01 +08:00
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"`
2026-08-28 15:06:01 +08:00
}
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())
2026-08-28 15:06:01 +08:00
} else if l.TargetType == "SN" {
ctx.EntClient.Inventory.Update().
Where(inventory.SnCodeEQ(l.TargetID), inventory.StatusEQ("锁定")).
SetStatus("在库").SetLockedQty(0).
SaveX(ctx0())
2026-08-28 15:06:01 +08:00
}
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())
2026-08-28 15:06:01 +08:00
availBatch := 0
availSerial := 0
for _, row := range rows {
if row.ManageMode == 1 {
availBatch += row.Quantity - row.LockedQty
} else if row.Status == "在库" {
availSerial++
}
2026-08-28 15:06:01 +08:00
}
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:])
}