- 重构半成品流转逻辑:进度权威源改为 workpiece.currentStationNo,新增 completedText 快照字段,废除 doneProcessCodes - 清理物料品类中半成品类型,存量数据幂等清理,调整物料管理方式文案为批次/序列号 - 新增日排产状态专用更新接口,修复工单工艺校验逻辑 - 新增物料档案代理接口、深路径文件下载接口 - 优化前端界面文案与工位选择逻辑,补充追溯页半成品流转展示 - 调整WMS端物料品类校验与入库接口契约 - 新增/更新数据库表结构与实体类代码
1418 lines
49 KiB
Go
1418 lines
49 KiB
Go
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"` // 物料主表 ID(material.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})
|
||
}
|
||
}
|