Files
bj_power/bj_power_wms/internal/handler/stock.go
T
SunYF 60eacc9c4f style(formatter): 格式化代码中的缩进和空行
- 统一了结构体字段的对齐方式,保持一致的缩进
- 调整了 import 语句的顺序,将 fmt 移至合适位置
- 修复了多处多余的空行和空白字符
- 规范化了注释的缩进格式
- 整理了路由配置的缩进层次
- 调整了常量定义的对齐格式
2026-09-20 14:53:14 +08:00

1169 lines
39 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"
"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/ent/zone"
"bj_power_wms/internal/svc"
"entgo.io/ent/dialect/sql"
)
// stockKey 库存聚合行的唯一标识(组合键)+ 明细下钻的过滤条件
type stockKey struct {
MaterialCode string
ZoneCode string
QualityStatus string
ManageMode int
Category int // 品类 1原材料/2半成品/3成品(统一主表收编后加入分组)
}
// categoryLabel 品类中文名
func categoryLabel(c int) string {
switch c {
case invCatSemi:
return "半成品"
case invCatFinished:
return "成品"
default:
return "原材料"
}
}
// 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/空=全部
category := r.URL.Query().Get("category") // 1原材料/2半成品/3成品/空=全部(完工检 SN 拾取器按成品过滤)
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,
Category: category,
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
Category string // 1原材料/2半成品/3成品/空=全部
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.ZoneCodeEqualFold(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))
}
if a.Category == "1" {
q = q.Where(inventory.CategoryEQ(invCatRaw))
} else if a.Category == "2" {
q = q.Where(inventory.CategoryEQ(invCatSemi))
} else if a.Category == "3" {
q = q.Where(inventory.CategoryEQ(invCatFinished))
}
// 时间筛选:默认近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, inv.Category}
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电气件
Category int `json:"category"` // 品类 1原材料/2半成品/3成品
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
Category string // 1原材料/2半成品/3成品/空=全部
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))
}
if a.Category == "1" {
q = q.Where(inventory.CategoryEQ(invCatRaw))
} else if a.Category == "2" {
q = q.Where(inventory.CategoryEQ(invCatSemi))
} else if a.Category == "3" {
q = q.Where(inventory.CategoryEQ(invCatFinished))
} else {
// 半成品是自产中间品,不计入库存体系(不参与常规库存汇总/缺货预警统计),
// 其流转在「半成品管理」单独记录;未显式指定品类时,汇总口径一律排除半成品。
q = q.Where(inventory.CategoryNEQ(invCatSemi))
}
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 // 管理粒度(同物料唯一,取首见行的值)
mCategory 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, mCategory: inv.Category, 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,
Category: g.mCategory,
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)
category := atoi(r.URL.Query().Get("category"), 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.MaterialCodeEqualFold(materialCode))
}
if zoneCode != "" {
q = q.Where(inventory.ZoneCodeEqualFold(zoneCode))
}
if qualityStatus != "" {
q = q.Where(inventory.QualityStatusEQ(qualityStatus))
}
if manageMode != 0 {
q = q.Where(inventory.ManageModeEQ(manageMode))
}
if category != 0 {
q = q.Where(inventory.CategoryEQ(category))
}
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
}
startDate, endDate, rerr := normalizeExportRange(r.URL.Query().Get("startDate"), r.URL.Query().Get("endDate"))
if rerr != nil {
fail(w, http.StatusBadRequest, rerr.Error())
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: startDate,
EndDate: 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().Where(zone.LevelEQ(1)).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("在库", "锁定"), inventory.CategoryNEQ(invCatSemi)).All(ctx0())
if err != nil {
return nil, err
}
type aggInfo struct {
qty int
lastCreated int64
}
// 物料品类(itemType)映射:区域汇总「类型」列需按 itemType=4 补「其他」(需求24)。
mats, _ := ctx.EntClient.Material.Query().All(ctx0())
itemTypeMap := make(map[string]int, len(mats))
for _, m := range mats {
itemTypeMap[m.Code] = m.ItemType
}
agg := map[string]*aggInfo{} // key: zc|quality|type
for _, inv := range rows {
zc := inv.ZoneCode
if zc == "" {
zc = "未分区"
}
// 类型标签口径统一按管理粒度(manageMode),并对 itemType=4「其他」品类单列(需求24):
// itemType=4 → 其他(辅料/工装/试验设备);manageMode=2 → 电气件(SN)manageMode=1 → 结构件(批次)。
// 不再混入半成品/成品品类(半成品已在上方排除;成品按 manageMode 归电气件/结构件),
// 品类维度在「物料汇总/库存明细」的品类列查看,避免「类型」列口径混乱。
t := "结构件"
if itemTypeMap[inv.MaterialCode] == invItemOther {
t = "其他"
} else 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/创建时间 + 组内最近入库时间)
// 说明:本汇总按“区域(zone_code)”维度聚合,用于看板概览;货位四级(区域→货架→层→位置)已在
// 入库/出库/库存明细/盘点/导出全链路贯通(U18),精确货位见库存明细与盘点快照,此处仅做大区级汇总。
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
}
// 盘点冻结守卫(问题2):盘点期间锁定量亦冻结,禁止新锁定。
if item.Sn != "" {
if fno := frozenByStocktake(ctx, "", item.Sn, ""); fno != "" {
fail(w, http.StatusConflict, "SN "+item.Sn+" 正在盘点单 "+fno+" 锁定中,盘点期间禁止锁定")
return
}
} else if item.BatchNo != "" {
if fno := frozenByStocktake(ctx, item.BatchNo, "", ""); fno != "" {
fail(w, http.StatusConflict, "批次 "+item.BatchNo+" 正在盘点单 "+fno+" 锁定中,盘点期间禁止锁定")
return
}
}
// 电气件:锁 SN(原子:仅 在库 且 合格 才能锁)
if item.Sn != "" {
aff, err := ctx.EntClient.Inventory.Update().
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(item.Sn), inventory.StatusEQ("在库"),
inventory.QualityStatusEQ(QStatusPass)).
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
}
// 质量门禁(客户口径):仅「合格」批次可锁库(未检/不合格/待退 不可锁)
if b.QualityStatus != QStatusPass {
fail(w, http.StatusConflict, "批次 "+item.BatchNo+" 质量状态为「"+qualityLabel(b.QualityStatus)+"」,仅合格品可锁库")
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
}
// 盘点冻结守卫(问题2):盘点期间锁定量亦冻结,禁止解锁。
// unlock 无事务且用 SaveX,故先全量校验再释放,避免部分解锁后中断造成不一致。
for _, l := range locks {
var fno string
if l.TargetType == "SN" {
fno = frozenByStocktake(ctx, "", l.TargetID, "")
} else {
fno = frozenByStocktake(ctx, l.TargetID, "", "")
}
if fno != "" {
fail(w, http.StatusConflict, "目标 "+l.TargetID+" 正在盘点单 "+fno+" 锁定中,盘点期间禁止解锁")
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:])
}
// shortageRow 缺货预警行(安全库存 > 0 且可用量 < 安全库存)
type shortageRow struct {
ID int64 `json:"id"` // 物料主表 ID
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
Unit string `json:"unit"`
SafetyStock int `json:"safetyStock"`
AvailQty int `json:"availQty"`
ShortQty int `json:"shortQty"`
}
// shortageHandler 缺货预警:安全库存 > 0 且可用量低于安全库存的物料。
// 客户诉求《问题记录》L473「安全库存/缺货预警进 WMS 首页」。
// 可用量口径与出库门禁一致:在库/锁定 且质量状态=合格(未检/不合格/待退不计可用)。
func shortageHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 缺货预警只统计纳入库存体系的品类:半成品为自产中间品、不参与常规库存统计,故排除。
mats, err := ctx.EntClient.Material.Query().
Where(material.SafetyStockGT(0), material.IsActiveEQ(true), material.ItemTypeNEQ(2)).
All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
rows := make([]*shortageRow, 0)
for _, m := range mats {
invRows, err := ctx.EntClient.Inventory.Query().
Where(
inventory.MaterialCodeEQ(m.Code),
inventory.StatusIn("在库", "锁定"),
inventory.QualityStatusEQ(QStatusPass),
).All(ctx0())
if err != nil {
continue
}
avail := 0
for _, inv := range invRows {
if inv.ManageMode == 1 {
avail += inv.Quantity - inv.LockedQty
} else if inv.Status == "在库" {
avail++
}
}
if avail < m.SafetyStock {
name := m.Name
if name == "" {
name = m.Code
}
rows = append(rows, &shortageRow{
ID: int64(m.ID),
MaterialCode: m.Code,
MaterialName: name,
Spec: m.Spec,
Unit: m.Unit,
SafetyStock: m.SafetyStock,
AvailQty: avail,
ShortQty: m.SafetyStock - avail,
})
}
}
ok(w, map[string]any{"list": rows, "total": len(rows)})
}
}
// listStockBatchesInternalHandler GET /api/internal/stock/batches?materialCode=&zoneCode=
// 供 MES「工单备料自动出库」分配批次用:返回该物料可出库的批次明细(结构件)与可用 SN 列表(电气件)。
// 结构件按批次号升序(FIFO);统一质量门禁:仅「合格」库存可出库,未检/不合格/待退不返回。
func listStockBatchesInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
materialCode := strings.TrimSpace(r.URL.Query().Get("materialCode"))
if materialCode == "" {
fail(w, http.StatusBadRequest, "materialCode 必填")
return
}
q := ctx.EntClient.Inventory.Query().
Where(
inventory.MaterialCodeEQ(materialCode),
inventory.QuantityGT(0),
inventory.QualityStatusEQ(QStatusPass),
)
if zc := strings.TrimSpace(r.URL.Query().Get("zoneCode")); zc != "" {
q = q.Where(inventory.ZoneCodeEQ(zc))
}
rows, err := q.Order(ent.Asc("batch_no"), ent.Asc("created_at")).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
batches := make([]map[string]any, 0, len(rows))
sns := make([]string, 0, len(rows))
for _, inv := range rows {
avail := inv.Quantity - inv.LockedQty
if avail <= 0 {
continue
}
if inv.ManageMode == 2 {
if inv.SnCode != "" {
sns = append(sns, inv.SnCode)
}
continue
}
batches = append(batches, map[string]any{
"batchNo": inv.BatchNo, "quantity": inv.Quantity, "lockedQty": inv.LockedQty,
"avail": avail, "zoneCode": inv.ZoneCode, "qualityStatus": inv.QualityStatus,
})
}
ok(w, map[string]any{"batches": batches, "snList": sns})
}
}