Files
bj_power/bj_power_wms/internal/handler/stock.go
T
SunYF d3eda6b588 feat&refactor: 2026-09-22 半成品业务模型重构与功能完善
- 重构半成品流转逻辑:进度权威源改为 workpiece.currentStationNo,新增 completedText 快照字段,废除 doneProcessCodes
- 清理物料品类中半成品类型,存量数据幂等清理,调整物料管理方式文案为批次/序列号
- 新增日排产状态专用更新接口,修复工单工艺校验逻辑
- 新增物料档案代理接口、深路径文件下载接口
- 优化前端界面文案与工位选择逻辑,补充追溯页半成品流转展示
- 调整WMS端物料品类校验与入库接口契约
- 新增/更新数据库表结构与实体类代码
2026-09-22 14:42:52 +08:00

1418 lines
49 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 库存聚合行的唯一标识(组合键)+ 明细下钻的过滤条件
// 注意(方案 §A1):质量状态不再进入分组键——质量降为"列",避免同一物料/区域被质量拆成多行。
type stockKey struct {
MaterialCode string
ZoneCode string
ShelfNo string // 货架号(§A1:库位四级进分组键,一物一库位一行)
LayerNo string // 第几层
PositionNo string // 位置号
ManageMode int
}
// categoryLabel 品类中文名
func categoryLabel(c int) string {
switch c {
case invCatSemi:
return "半成品"
case invCatFinished:
return "成品"
default:
return "原材料"
}
}
// typeLabelOf 汇总行类型标签(与区域汇总「其他」口径一致):物料类型 manage_mode=3 → 其他;否则按管理粒度。
func typeLabelOf(matManageMode int) string {
if matManageMode == invManageOther {
return "其他"
}
return manageModeLabel(matManageMode)
}
// stockAggRow 聚合主列表的行结构(按 物料+区域+管理粒度 聚合,只返回数量,不铺开明细)
// 质量状态降为"列"(合格/未检/不合格三列数量),不再作为拆行维度(方案 §A1)。
// 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电气件(SN)
ItemType int `json:"itemType"` // 物料品类(1原材料/3成品/4其他)
MatManageMode int `json:"matManageMode"` // 物料档案类型(1结构件/2电气件/3其他):类型标签判定用
ZoneCode string `json:"zoneCode"` // 区域
ShelfNo string `json:"shelfNo"` // 货架号
LayerNo string `json:"layerNo"` // 第几层
PositionNo string `json:"positionNo"` // 位置号
QtyQualified int `json:"qtyQualified"` // 合格数量
QtyPending int `json:"qtyPending"` // 未检数量
QtyRejected int `json:"qtyRejected"` // 不合格数量
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")
shelfNo := r.URL.Query().Get("shelfNo") // 货架号(§A4 库位四级联动)
layerNo := r.URL.Query().Get("layerNo") // 第几层
positionNo := r.URL.Query().Get("positionNo") // 位置号
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,
ShelfNo: shelfNo,
LayerNo: layerNo,
PositionNo: positionNo,
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
ShelfNo string // 货架号(方案 §A4 库位四级联动筛选)
LayerNo string // 第几层
PositionNo 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.ShelfNo != "" {
q = q.Where(inventory.ShelfNoEqualFold(a.ShelfNo))
}
if a.LayerNo != "" {
q = q.Where(inventory.LayerNoEqualFold(a.LayerNo))
}
if a.PositionNo != "" {
q = q.Where(inventory.PositionNoEqualFold(a.PositionNo))
}
if a.QualityStatus != "" {
// 质量状态仅作"筛选"维度(快捷定位未检/合格/不合格),不进分组键(方案 §A1/§A5)
q = q.Where(inventory.QualityStatusEQ(a.QualityStatus))
}
if a.InboundNo != "" {
q = q.Where(inventory.InboundNoContains(a.InboundNo))
}
// 类型(manage_mode)下拉:结构件=1 / 电气件=2 / 其他=3,一律按物料档案类型取编码集 IN,不与品类(itemType)字段混用
switch a.ManageMode {
case "1", "2":
codes := materialCodesByManageMode(ctx, atoi(a.ManageMode, 0))
if len(codes) == 0 {
return []*stockAggRow{}, 0, nil
}
q = q.Where(inventory.MaterialCodeIn(codes...))
case "3":
codes := materialCodesByManageMode(ctx, invManageOther)
if len(codes) == 0 {
return []*stockAggRow{}, 0, nil
}
q = q.Where(inventory.MaterialCodeIn(codes...))
}
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{}
matItemType := map[string]int{}
matManageMode := map[string]int{}
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
matItemType[m.Code] = m.ItemType
matManageMode[m.Code] = m.ManageMode
}
}
// 分组聚合(质量状态降为列,不进分组键,方案 §A1)
type agg struct {
totalQty int
lockedQty int
qQualified int
qPending int
qRejected 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.ShelfNo, inv.LayerNo, inv.PositionNo, 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++
}
// 质量分布(未检=空或非合格/不合格);数量口径同总量:结构件按行数量、电气件按 1
switch inv.QualityStatus {
case "合格":
g.qQualified += qtyOf(inv)
case "不合格":
g.qRejected += qtyOf(inv)
default:
g.qPending += qtyOf(inv)
}
// 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,
ItemType: matItemType[key.MaterialCode],
MatManageMode: matManageMode[key.MaterialCode],
ZoneCode: key.ZoneCode,
ShelfNo: key.ShelfNo,
LayerNo: key.LayerNo,
PositionNo: key.PositionNo,
QtyQualified: g.qQualified,
QtyPending: g.qPending,
QtyRejected: g.qRejected,
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电气件(SN)
ItemType int `json:"itemType"` // 物料品类(1原材料/3成品/4其他)
MatManageMode int `json:"matManageMode"` // 物料档案类型(1结构件/2电气件/3其他):类型标签判定用
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"),
Category: r.URL.Query().Get("category"),
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))
}
// 类型(manage_mode)下拉:结构件=1 / 电气件=2 / 其他=3,一律按物料档案类型取编码集 IN,不与品类(itemType)字段混用
switch a.ManageMode {
case "1", "2":
codes := materialCodesByManageMode(ctx, atoi(a.ManageMode, 0))
if len(codes) == 0 {
return []*materialAggRow{}, 0, nil
}
q = q.Where(inventory.MaterialCodeIn(codes...))
case "3":
codes := materialCodesByManageMode(ctx, invManageOther)
if len(codes) == 0 {
return []*materialAggRow{}, 0, nil
}
q = q.Where(inventory.MaterialCodeIn(codes...))
}
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{}
matItemType := map[string]int{}
matManageMode := map[string]int{}
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
matItemType[m.Code] = m.ItemType
matManageMode[m.Code] = m.ManageMode
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,
ItemType: matItemType[code],
MatManageMode: matManageMode[code],
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 原始批次/SN 明细(点击聚合行下钻;也是库存拾取器/出库批次下拉的数据源)
// GET /api/stock/details?materialCode=&zoneCode=&shelfNo=&layerNo=&positionNo=&qualityStatus=&manageMode=&category=&itemType=&keyword=&page=&pageSize=
// 返回原始批次/SN 明细行(扁平展示,含 SN/批次号/质量状态);keyword 模糊匹配 图号/批次号/SN(同 /stock/query 口径)。
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 = ""
}
shelfNo := r.URL.Query().Get("shelfNo")
layerNo := r.URL.Query().Get("layerNo")
positionNo := r.URL.Query().Get("positionNo")
qualityStatus := r.URL.Query().Get("qualityStatus")
keyword := r.URL.Query().Get("keyword")
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 shelfNo != "" {
q = q.Where(inventory.ShelfNoEqualFold(shelfNo))
}
if layerNo != "" {
q = q.Where(inventory.LayerNoEqualFold(layerNo))
}
if positionNo != "" {
q = q.Where(inventory.PositionNoEqualFold(positionNo))
}
if qualityStatus != "" {
q = q.Where(inventory.QualityStatusEQ(qualityStatus))
}
if keyword != "" {
q = q.Where(inventory.Or(
inventory.MaterialCodeContainsFold(keyword),
inventory.BatchNoContainsFold(keyword),
inventory.SnCodeContainsFold(keyword),
))
}
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.QtyQualified, rw.QtyPending, rw.QtyRejected,
rw.QtyStruct, rw.QtyElec, rw.QtyOther,
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"),
Category: r.URL.Query().Get("category"),
Page: 1,
PageSize: 1000000, // 全量
})
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
// 列头与「物料汇总」页签对齐:汇总行去掉误导性的「最近入库单」(§A6),类型带「其他」标签
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,
typeLabelOf(rw.MatManageMode), rw.TotalQty, rw.LockedQty, rw.AvailQty,
rw.QtyQualified, rw.QtyPending, rw.QtyRejected,
strings.Join(rw.Zones, "、"), rw.BatchCount, rw.SnCount,
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"),
ShelfNo: r.URL.Query().Get("shelfNo"),
LayerNo: r.URL.Query().Get("layerNo"),
PositionNo: r.URL.Query().Get("positionNo"),
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
}
// 列头与「库位明细」页签对齐:库位四级 + 质量三列;汇总行去掉误导性的「最近入库单」(§A6)
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,
typeLabelOf(rw.MatManageMode), rw.ZoneCode, rw.ShelfNo, rw.LayerNo, rw.PositionNo,
rw.QtyQualified, rw.QtyPending, rw.QtyRejected,
rw.TotalQty, rw.LockedQty, rw.AvailQty, rw.BatchCount, rw.SnCount,
unixFmt(rw.CreatedAt),
})
}
sendExcel(w, xlsxFilename("库存汇总"), headers, matrix)
}
}
// zoneSummRow 区域汇总行(一区域一行,方案 §A1/§A5)
// 质量(合格/未检/不合格)与类型(结构件/电气件/其他)均降为列,主维度只有"区域"。
// ID/ZoneName 取区域主表(zone)——聚合视图无自身实体,主表字段即行标识,禁止伪 id。
type zoneSummRow struct {
ID int64 `json:"id"` // 区域主表 ID(zone.id,未分区时为 0
ZoneCode string `json:"zoneCode"`
ZoneName string `json:"zoneName"`
QtyQualified int `json:"qtyQualified"` // 合格数量
QtyPending int `json:"qtyPending"` // 未检数量
QtyRejected int `json:"qtyRejected"` // 不合格数量
QtyStruct int `json:"qtyStruct"` // 结构件数量
QtyElec int `json:"qtyElec"` // 电气件数量
QtyOther int `json:"qtyOther"` // 其他(辅料/工装/试验设备)数量
LastCreatedAt int64 `json:"lastCreatedAt"` // 组内最近一次入库时间
}
// buildZoneSummary 区域汇总聚合:供列表接口与导出共用。
// 一区域一行(方案 §A1/§A5):质量(合格/未检/不合格) 与 类型(结构件/电气件/其他) 均降为列,
// 主维度只有"区域",不再把同一区域按质量/类型拆成多行。
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{}
for _, z := range zones {
zoneNameMap[z.ZoneCode] = z.ZoneName
zoneIDMap[z.ZoneCode] = int64(z.ID)
}
// 区域汇总属常规库存汇总口径,排除半成品(半成品单独记录,不并入库存体系)
rows, err := ctx.EntClient.Inventory.Query().
Where(inventory.StatusIn("在库", "锁定"), inventory.CategoryNEQ(invCatSemi)).All(ctx0())
if err != nil {
return nil, err
}
// 物料类型(manageMode)映射:manage_mode=3 → 其他(辅料/工装/试验设备)
mats, _ := ctx.EntClient.Material.Query().All(ctx0())
manageModeMap := make(map[string]int, len(mats))
for _, m := range mats {
manageModeMap[m.Code] = m.ManageMode
}
// 一区域一行:质量与类型均为列(数量口径:结构件按行数量、电气件按 1)
agg := map[string]*zoneSummRow{} // key: zoneCode
order := []string{}
for _, inv := range rows {
zc := inv.ZoneCode
if zc == "" {
zc = "未分区"
}
g, ok := agg[zc]
if !ok {
g = &zoneSummRow{ZoneCode: zc}
agg[zc] = g
order = append(order, zc)
}
qty := qtyOf(inv)
switch inv.QualityStatus {
case "合格":
g.QtyQualified += qty
case "不合格":
g.QtyRejected += qty
default:
g.QtyPending += qty
}
switch {
case manageModeMap[inv.MaterialCode] == invManageOther:
g.QtyOther += qty
case inv.ManageMode == 2:
g.QtyElec += qty
default:
g.QtyStruct += qty
}
if inv.CreatedAt > g.LastCreatedAt {
g.LastCreatedAt = inv.CreatedAt
}
}
out := make([]zoneSummRow, 0, len(order))
for _, zc := range order {
g := agg[zc]
name := zoneNameMap[zc]
if name == "" {
name = zc
}
g.ID = zoneIDMap[zc]
g.ZoneName = name
out = append(out, *g)
}
sort.Slice(out, func(i, j int) bool { return out[i].ZoneCode < out[j].ZoneCode })
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)})
}
}
// materialDemandRow 缺料补货行(方案 §A3/§D2):安全库存补货量 与 排产缺料量 分两列,不合并。
type materialDemandRow struct {
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
Unit string `json:"unit"`
SafetyStock int `json:"safetyStock"`
AvailQty int `json:"availQty"`
PlanDemand int `json:"planDemand"` // 排产需求(未来 N 天,来自 MES)
PlanShort int `json:"planShort"` // 排产缺料量 = max(0, planDemand - avail)
SafetyReplenish int `json:"safetyReplenish"` // 安全库存补货量 = max(0, safetyStock - avail)
InTransit string `json:"inTransit"` // 在途量(暂无数据源,留空显示"—",不臆造)
PlanStart string `json:"planStart"`
PlanEnd string `json:"planEnd"`
}
// availableQty 计算物料的合格可用量(口径同出库门禁 QStatusPass;半成品不纳入常规库存)。
// 结构件 = Σ(quantity - locked_qty);电气件 = 在库 SN 行数。
func availableQty(ctx *svc.ServiceContext, materialCode string) int {
invRows, err := ctx.EntClient.Inventory.Query().
Where(
inventory.MaterialCodeEQ(materialCode),
inventory.StatusIn("在库", "锁定"),
inventory.QualityStatusEQ(QStatusPass),
).All(ctx0())
if err != nil {
return 0
}
avail := 0
for _, inv := range invRows {
if inv.ManageMode == 1 {
avail += inv.Quantity - inv.LockedQty
} else if inv.Status == "在库" {
avail++
}
}
return avail
}
// materialDemandHandler 缺料补货(方案 §A3):拉 MES 排产需求,对比本地可用库存,
// 给出「安全库存补货量」与「排产缺料量」两列(§D2:两列不合并,合并会掩盖两种不同性质的缺料)。
// 在途量当前无数据源,列留空显示"—"(§D2:不臆造)。
// 打开页面实时拉一次(同旧 producible 口径),不做定时同步,保持 WMS/MES 两系统无定时耦合。
// GET /api/stock/material-demand
func materialDemandHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 1) 拉 MES 排产需求(若不可达则需求为空,仅展示安全库存补货)
demandMap := map[string]int{}
var planStart, planEnd string
if ctx.Mes != nil {
rows, err := ctx.Mes.FetchMaterialDemand(ctx0(), 5)
if err == nil {
for _, d := range rows {
demandMap[d.MaterialCode] += d.DemandQty
if planStart == "" || d.StartDate < planStart {
planStart = d.StartDate
}
if planEnd == "" || d.EndDate > planEnd {
planEnd = d.EndDate
}
}
// 回写 material_demand(全量覆盖,语义同已下线的 queryProducibleHandler):
// 备料四态“预警”档(misc.go ledgerState)依赖本表 future5_qty;旧快照写入口删除后,
// 必须由本处专职喂数,否则“预警”永久静默失效(客户诉求 问题记录 L256)。
// best-effort:回写失败不影响本页视图(demandMap 已在内存算好)。
if tx, terr := ctx.EntClient.Tx(ctx0()); terr == nil {
now := time.Now().Unix()
done := false
if _, derr := tx.MaterialDemand.Delete().Exec(ctx0()); derr == nil {
done = true
for code, qty := range demandMap {
if code == "" {
continue
}
if _, cerr := tx.MaterialDemand.Create().
SetMaterialCode(code).
SetFuture5Qty(qty).
SetUpdatedAt(now).
Save(ctx0()); cerr != nil {
done = false
break
}
}
}
if done {
_ = tx.Commit()
} else {
_ = tx.Rollback()
}
}
}
}
// 2) 本地可用量对比(只统计纳入库存体系的品类,半成品/成品单独处理不并入)
mats, err := ctx.EntClient.Material.Query().
Where(material.IsActiveEQ(true), material.ItemTypeNEQ(2)).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]*materialDemandRow, 0)
for _, m := range mats {
avail := availableQty(ctx, m.Code)
planDemand := demandMap[m.Code]
planShort := planDemand - avail
if planShort < 0 {
planShort = 0
}
safetyReplenish := m.SafetyStock - avail
if safetyReplenish < 0 {
safetyReplenish = 0
}
// 仅列有缺口的物料(安全库存缺货 或 排产缺料),无缺口不展示
if planShort == 0 && safetyReplenish == 0 {
continue
}
name := m.Name
if name == "" {
name = m.Code
}
out = append(out, &materialDemandRow{
MaterialCode: m.Code,
MaterialName: name,
Spec: m.Spec,
Unit: m.Unit,
SafetyStock: m.SafetyStock,
AvailQty: avail,
PlanDemand: planDemand,
PlanShort: planShort,
SafetyReplenish: safetyReplenish,
InTransit: "",
PlanStart: planStart,
PlanEnd: planEnd,
})
}
// 排序:排产缺料优先,其次安全库存补货量
sort.Slice(out, func(i, j int) bool {
if out[i].PlanShort != out[j].PlanShort {
return out[i].PlanShort > out[j].PlanShort
}
return out[i].SafetyReplenish > out[j].SafetyReplenish
})
ok(w, map[string]any{"list": out, "planStart": planStart, "planEnd": planEnd, "syncedAt": time.Now().Unix()})
}
}
// 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})
}
}