feat(production): 新增排产支撑功能并移除手动报工页面
- 在main.go中更新排产物料需求注释说明,明确WMS与MES系统间交互方式 - 从前端help.js中移除手动报工(helpScan)相关帮助文档 - 更新工艺流程、绩效报表等页面帮助文档中的报工流程说明 - 在主布局中添加排产支撑菜单项并移除手动报工菜单项 - 新增ProducibleSupport.vue组件实现排产支撑页面功能 - 移除Scan.vue手动报工页面组件 - 更新相关帮助文档内容以匹配新的业务流程
This commit is contained in:
@@ -19,12 +19,14 @@ import (
|
||||
)
|
||||
|
||||
// stockKey 库存聚合行的唯一标识(组合键)+ 明细下钻的过滤条件
|
||||
// 注意(方案 §A1):质量状态不再进入分组键——质量降为"列",避免同一物料/区域被质量拆成多行。
|
||||
type stockKey struct {
|
||||
MaterialCode string
|
||||
ZoneCode string
|
||||
QualityStatus string
|
||||
ManageMode int
|
||||
Category int // 品类 1原材料/2半成品/3成品(统一主表收编后加入分组)
|
||||
MaterialCode string
|
||||
ZoneCode string
|
||||
ShelfNo string // 货架号(§A1:库位四级进分组键,一物一库位一行)
|
||||
LayerNo string // 第几层
|
||||
PositionNo string // 位置号
|
||||
ManageMode int
|
||||
}
|
||||
|
||||
// categoryLabel 品类中文名
|
||||
@@ -39,7 +41,16 @@ func categoryLabel(c int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// stockAggRow 聚合主列表的行结构(按 物料+区域+质量+管理粒度 聚合,只返回数量,不铺开明细)
|
||||
// typeLabelOf 汇总行类型标签(与区域汇总「其他」口径一致):itemType=4 → 其他;否则按管理粒度。
|
||||
func typeLabelOf(manageMode, itemType int) string {
|
||||
if itemType == invItemOther {
|
||||
return "其他"
|
||||
}
|
||||
return manageModeLabel(manageMode)
|
||||
}
|
||||
|
||||
// stockAggRow 聚合主列表的行结构(按 物料+区域+管理粒度 聚合,只返回数量,不铺开明细)
|
||||
// 质量状态降为"列"(合格/未检/不合格三列数量),不再作为拆行维度(方案 §A1)。
|
||||
// ID = 组内最近入库的库存行真实主键(inventory.id)——聚合视图无自身实体,取真实主键,禁止伪 id
|
||||
type stockAggRow struct {
|
||||
ID int64 `json:"id"` // inventory.id(组内最近入库行)
|
||||
@@ -47,8 +58,14 @@ type stockAggRow struct {
|
||||
MaterialName string `json:"materialName"` // 物料名称
|
||||
Spec string `json:"spec"` // 规格型号
|
||||
ManageMode int `json:"manageMode"` // 1结构件/2电气件
|
||||
ItemType int `json:"itemType"` // 物料品类(4=其他:辅料/工装/试验设备)
|
||||
ZoneCode string `json:"zoneCode"` // 区域
|
||||
QualityStatus string `json:"qualityStatus"` // 质量状态
|
||||
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
|
||||
@@ -68,9 +85,14 @@ func queryStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
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 拾取器按成品过滤)
|
||||
manageMode := r.URL.Query().Get("manageMode") // 1/2/空=全部
|
||||
category := r.URL.Query().Get("category") // 1原材料/2半成品/3成品/空=全部(完工检 SN 拾取器按成品过滤)
|
||||
itemType := atoi(r.URL.Query().Get("itemType"), 0) // 品类(B1:4=其他入库物料)
|
||||
itemTypeNot := atoi(r.URL.Query().Get("itemTypeNot"), 0) // 排除品类(§A1 类型三分互斥:结构件 = 管理粒度1 且非「其他」类)
|
||||
inboundNo := r.URL.Query().Get("inboundNo")
|
||||
startDate := r.URL.Query().Get("startDate") // 默认近3个月(由接口保证)
|
||||
endDate := r.URL.Query().Get("endDate")
|
||||
@@ -82,9 +104,14 @@ func queryStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
MaterialName: materialName,
|
||||
Keyword: keyword,
|
||||
ZoneCode: zoneCode,
|
||||
ShelfNo: shelfNo,
|
||||
LayerNo: layerNo,
|
||||
PositionNo: positionNo,
|
||||
QualityStatus: qualityStatus,
|
||||
ManageMode: manageMode,
|
||||
Category: category,
|
||||
ItemType: itemType,
|
||||
ItemTypeNot: itemTypeNot,
|
||||
InboundNo: inboundNo,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
@@ -110,9 +137,14 @@ type aggregateStockArgs struct {
|
||||
MaterialName string
|
||||
Keyword string // 模糊搜索:物料编码/批次号/SN 任一匹配(下拉拾取器用)
|
||||
ZoneCode string
|
||||
ShelfNo string // 货架号(方案 §A4 库位四级联动筛选)
|
||||
LayerNo string // 第几层
|
||||
PositionNo string // 位置号
|
||||
QualityStatus string
|
||||
ManageMode string
|
||||
Category string // 1原材料/2半成品/3成品/空=全部
|
||||
ItemType int // 品类 itemType(B1:4=其他入库物料)
|
||||
ItemTypeNot int // 排除品类(§A1:结构件筛选时排除「其他」类 itemType=4)
|
||||
InboundNo string
|
||||
StartDate string
|
||||
EndDate string
|
||||
@@ -148,9 +180,34 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
|
||||
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.ItemType > 0 {
|
||||
// 按品类过滤(B1):先取编码集再 IN,避免逐行 join
|
||||
codes := materialCodesByItemType(ctx, a.ItemType)
|
||||
if len(codes) == 0 {
|
||||
return []*stockAggRow{}, 0, nil
|
||||
}
|
||||
q = q.Where(inventory.MaterialCodeIn(codes...))
|
||||
}
|
||||
if a.ItemTypeNot > 0 {
|
||||
// 排除品类(§A1 类型三分互斥:结构件 = 管理粒度1 且非「其他」类)
|
||||
codes := materialCodesByItemType(ctx, a.ItemTypeNot)
|
||||
if len(codes) > 0 {
|
||||
q = q.Where(inventory.MaterialCodeNotIn(codes...))
|
||||
}
|
||||
}
|
||||
if a.InboundNo != "" {
|
||||
q = q.Where(inventory.InboundNoContains(a.InboundNo))
|
||||
}
|
||||
@@ -194,9 +251,10 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 收集物料信息(名称/规格从 materials 表补齐,字段更权威)
|
||||
// 收集物料信息(名称/规格/品类从 materials 表补齐,字段更权威)
|
||||
matName := map[string]string{}
|
||||
matSpec := map[string]string{}
|
||||
matItemType := map[string]int{}
|
||||
matCodes := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, inv := range all {
|
||||
@@ -211,18 +269,22 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
|
||||
for _, m := range mats {
|
||||
matName[m.Code] = m.Name
|
||||
matSpec[m.Code] = m.Spec
|
||||
matItemType[m.Code] = m.ItemType
|
||||
}
|
||||
}
|
||||
|
||||
// 分组聚合
|
||||
// 分组聚合(质量状态降为列,不进分组键,方案 §A1)
|
||||
type agg struct {
|
||||
totalQty int
|
||||
lockedQty int
|
||||
batchCnt int
|
||||
snCnt int
|
||||
lastInbNo string
|
||||
lastTime int64
|
||||
lastInvID int64 // 组内最近入库的库存行真实主键(inventory.id),作行标识
|
||||
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{}
|
||||
@@ -231,7 +293,7 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
|
||||
if zc == "" {
|
||||
zc = "未分区"
|
||||
}
|
||||
key := stockKey{inv.MaterialCode, zc, inv.QualityStatus, inv.ManageMode, inv.Category}
|
||||
key := stockKey{inv.MaterialCode, zc, inv.ShelfNo, inv.LayerNo, inv.PositionNo, inv.ManageMode}
|
||||
g, ok := group[key]
|
||||
if !ok {
|
||||
g = &agg{}
|
||||
@@ -249,6 +311,15 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
|
||||
}
|
||||
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
|
||||
@@ -281,8 +352,14 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
|
||||
MaterialName: name,
|
||||
Spec: matSpec[key.MaterialCode],
|
||||
ManageMode: key.ManageMode,
|
||||
ItemType: matItemType[key.MaterialCode],
|
||||
ZoneCode: key.ZoneCode,
|
||||
QualityStatus: key.QualityStatus,
|
||||
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,
|
||||
@@ -304,6 +381,7 @@ type materialAggRow struct {
|
||||
MaterialName string `json:"materialName"`
|
||||
Spec string `json:"spec"`
|
||||
ManageMode int `json:"manageMode"` // 1结构件/2电气件
|
||||
ItemType int `json:"itemType"` // 物料品类(4=其他:辅料/工装/试验设备)
|
||||
Category int `json:"category"` // 品类 1原材料/2半成品/3成品
|
||||
TotalQty int `json:"totalQty"` // 总数量(结构件=数量求和;电气件=SN 行数)
|
||||
LockedQty int `json:"lockedQty"` // 锁定量
|
||||
@@ -326,6 +404,8 @@ type materialSummaryArgs struct {
|
||||
MaterialName string
|
||||
ManageMode string
|
||||
Category string // 1原材料/2半成品/3成品/空=全部
|
||||
ItemType int // 品类 itemType(4=其他入库物料,§A1 类型筛选)
|
||||
ItemTypeNot int // 排除品类(§A1:结构件筛选时排除「其他」类)
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
@@ -340,6 +420,9 @@ func materialSummaryHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
MaterialCode: r.URL.Query().Get("materialCode"),
|
||||
MaterialName: r.URL.Query().Get("materialName"),
|
||||
ManageMode: r.URL.Query().Get("manageMode"),
|
||||
Category: r.URL.Query().Get("category"),
|
||||
ItemType: atoi(r.URL.Query().Get("itemType"), 0),
|
||||
ItemTypeNot: atoi(r.URL.Query().Get("itemTypeNot"), 0),
|
||||
Page: atoi(r.URL.Query().Get("page"), 1),
|
||||
PageSize: atoi(r.URL.Query().Get("pageSize"), 20),
|
||||
})
|
||||
@@ -368,6 +451,21 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([
|
||||
} else if a.ManageMode == "2" {
|
||||
q = q.Where(inventory.ManageModeEQ(2))
|
||||
}
|
||||
if a.ItemType > 0 {
|
||||
// 按品类过滤(§A1):先取编码集再 IN,避免逐行 join
|
||||
codes := materialCodesByItemType(ctx, a.ItemType)
|
||||
if len(codes) == 0 {
|
||||
return []*materialAggRow{}, 0, nil
|
||||
}
|
||||
q = q.Where(inventory.MaterialCodeIn(codes...))
|
||||
}
|
||||
if a.ItemTypeNot > 0 {
|
||||
// 排除品类(§A1 类型三分互斥:结构件 = 管理粒度1 且非「其他」类)
|
||||
codes := materialCodesByItemType(ctx, a.ItemTypeNot)
|
||||
if len(codes) > 0 {
|
||||
q = q.Where(inventory.MaterialCodeNotIn(codes...))
|
||||
}
|
||||
}
|
||||
if a.Category == "1" {
|
||||
q = q.Where(inventory.CategoryEQ(invCatRaw))
|
||||
} else if a.Category == "2" {
|
||||
@@ -385,9 +483,10 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 物料名称/规格/主表ID/主表创建时间补全
|
||||
// 物料名称/规格/品类/主表ID/主表创建时间补全
|
||||
matName := map[string]string{}
|
||||
matSpec := map[string]string{}
|
||||
matItemType := map[string]int{}
|
||||
matID := map[string]int64{}
|
||||
matCreatedAt := map[string]int64{}
|
||||
matCodes := []string{}
|
||||
@@ -404,6 +503,7 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([
|
||||
for _, m := range mats {
|
||||
matName[m.Code] = m.Name
|
||||
matSpec[m.Code] = m.Spec
|
||||
matItemType[m.Code] = m.ItemType
|
||||
matID[m.Code] = int64(m.ID)
|
||||
matCreatedAt[m.Code] = m.CreatedAt
|
||||
}
|
||||
@@ -493,6 +593,7 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([
|
||||
MaterialName: name,
|
||||
Spec: matSpec[code],
|
||||
ManageMode: g.mMode,
|
||||
ItemType: matItemType[code],
|
||||
Category: g.mCategory,
|
||||
TotalQty: g.totalQty,
|
||||
LockedQty: g.lockedQty,
|
||||
@@ -520,9 +621,9 @@ func qtyOf(inv *ent.Inventory) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
// stockDetailsHandler 库存明细(点击聚合行下钻,分页懒加载)
|
||||
// GET /api/stock/details?materialCode=&zoneCode=&qualityStatus=&manageMode=&page=&pageSize=
|
||||
// 返回该物料/区域/质量/类型下的原始批次/SN 明细行(扁平展示,含 SN/批次号)。
|
||||
// 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")
|
||||
@@ -530,9 +631,14 @@ func stockDetailsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
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)
|
||||
itemType := atoi(r.URL.Query().Get("itemType"), 0)
|
||||
page := atoi(r.URL.Query().Get("page"), 1)
|
||||
pageSize := atoi(r.URL.Query().Get("pageSize"), 50)
|
||||
|
||||
@@ -545,15 +651,40 @@ func stockDetailsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
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))
|
||||
}
|
||||
if itemType > 0 {
|
||||
// 按品类过滤(B1):先取编码集再 IN,避免逐行 join
|
||||
codes := materialCodesByItemType(ctx, itemType)
|
||||
if len(codes) == 0 {
|
||||
ok(w, map[string]any{"list": []any{}, "total": 0, "page": page, "pageSize": pageSize})
|
||||
return
|
||||
}
|
||||
q = q.Where(inventory.MaterialCodeIn(codes...))
|
||||
}
|
||||
|
||||
total, err := q.Count(ctx0())
|
||||
if err != nil {
|
||||
@@ -587,7 +718,7 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
headers := []string{"ID", "区域编码", "区域名称", "质量状态", "类型", "数量", "区域创建时间", "最近入库时间"}
|
||||
headers := []string{"ID", "区域编码", "区域名称", "合格", "未检", "不合格", "结构件", "电气件", "其他", "最近入库时间"}
|
||||
matrix := make([][]any, 0, len(out))
|
||||
for _, rw := range out {
|
||||
id := any("")
|
||||
@@ -595,8 +726,10 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
id = rw.ID
|
||||
}
|
||||
matrix = append(matrix, []any{
|
||||
id, rw.ZoneCode, rw.ZoneName, rw.Quality, rw.Type, rw.Qty,
|
||||
unixFmt(rw.ZoneCreatedAt), unixFmt(rw.LastCreatedAt),
|
||||
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)
|
||||
@@ -607,6 +740,9 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
MaterialCode: r.URL.Query().Get("materialCode"),
|
||||
MaterialName: r.URL.Query().Get("materialName"),
|
||||
ManageMode: r.URL.Query().Get("manageMode"),
|
||||
Category: r.URL.Query().Get("category"),
|
||||
ItemType: atoi(r.URL.Query().Get("itemType"), 0),
|
||||
ItemTypeNot: atoi(r.URL.Query().Get("itemTypeNot"), 0),
|
||||
Page: 1,
|
||||
PageSize: 1000000, // 全量
|
||||
})
|
||||
@@ -614,7 +750,8 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
headers := []string{"ID", "图号", "物料名称", "规格", "类型", "总数量", "锁定量", "可用量", "合格", "未检", "不合格", "分布区域", "批次数", "SN数", "最近入库单", "物料创建时间", "最近入库时间"}
|
||||
// 列头与「物料汇总」页签对齐:汇总行去掉误导性的「最近入库单」(§A6),类型带「其他」标签
|
||||
headers := []string{"ID", "图号", "物料名称", "规格", "类型", "总数量", "锁定量", "可用量", "合格", "未检", "不合格", "分布区域", "批次数", "SN数", "物料创建时间", "最近入库时间"}
|
||||
matrix := make([][]any, 0, len(rows))
|
||||
for _, rw := range rows {
|
||||
id := any("")
|
||||
@@ -623,10 +760,10 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
matrix = append(matrix, []any{
|
||||
id, rw.MaterialCode, rw.MaterialName, rw.Spec,
|
||||
manageModeLabel(rw.ManageMode), rw.TotalQty, rw.LockedQty, rw.AvailQty,
|
||||
typeLabelOf(rw.ManageMode, rw.ItemType), 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),
|
||||
unixFmt(rw.MaterialCreatedAt), unixFmt(rw.CreatedAt),
|
||||
})
|
||||
}
|
||||
sendExcel(w, xlsxFilename("物料汇总"), headers, matrix)
|
||||
@@ -641,8 +778,13 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
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"),
|
||||
ItemType: atoi(r.URL.Query().Get("itemType"), 0),
|
||||
ItemTypeNot: atoi(r.URL.Query().Get("itemTypeNot"), 0),
|
||||
InboundNo: r.URL.Query().Get("inboundNo"),
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
@@ -653,35 +795,41 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
headers := []string{"ID", "图号", "物料名称", "规格", "类型", "区域", "质量状态", "总数量", "锁定量", "可用量", "批次数", "SN数", "最近入库单", "最近入库时间"}
|
||||
// 列头与「库位明细」页签对齐:库位四级 + 质量三列;汇总行去掉误导性的「最近入库单」(§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,
|
||||
manageModeLabel(rw.ManageMode), rw.ZoneCode, rw.QualityStatus,
|
||||
typeLabelOf(rw.ManageMode, rw.ItemType), rw.ZoneCode, rw.ShelfNo, rw.LayerNo, rw.PositionNo,
|
||||
rw.QtyQualified, rw.QtyPending, rw.QtyRejected,
|
||||
rw.TotalQty, rw.LockedQty, rw.AvailQty, rw.BatchCount, rw.SnCount,
|
||||
rw.LastInboundNo, unixFmt(rw.CreatedAt),
|
||||
unixFmt(rw.CreatedAt),
|
||||
})
|
||||
}
|
||||
sendExcel(w, xlsxFilename("库存汇总"), headers, matrix)
|
||||
}
|
||||
}
|
||||
|
||||
// zoneSummRow 区域汇总行(按 区域 + 质量状态 + 类型 聚合)
|
||||
// ID/ZoneCreatedAt 取区域主表(zone)主键与创建时间——聚合视图无自身实体,主表字段即行标识,禁止伪 id。
|
||||
// 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"`
|
||||
Quality string `json:"quality"`
|
||||
Type string `json:"type"` // 批次/电气件
|
||||
Qty int `json:"qty"`
|
||||
ZoneCreatedAt int64 `json:"zoneCreatedAt"` // 区域主表创建时间(zone.created_at)
|
||||
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 区域汇总聚合:供列表接口与导出共用。
|
||||
// 行排序固定(区域编码 → 质量 → 类型),避免 map 迭代顺序随机导致每次刷新行序跳动。
|
||||
// 一区域一行(方案 §A1/§A5):质量(合格/未检/不合格) 与 类型(结构件/电气件/其他) 均降为列,
|
||||
// 主维度只有"区域",不再把同一区域按质量/类型拆成多行。
|
||||
func buildZoneSummary(ctx *svc.ServiceContext) ([]zoneSummRow, error) {
|
||||
zones, err := ctx.EntClient.Zone.Query().Where(zone.LevelEQ(1)).All(ctx0())
|
||||
if err != nil {
|
||||
@@ -689,11 +837,9 @@ func buildZoneSummary(ctx *svc.ServiceContext) ([]zoneSummRow, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 区域汇总属常规库存汇总口径,排除半成品(半成品单独记录,不并入库存体系)
|
||||
@@ -703,77 +849,61 @@ func buildZoneSummary(ctx *svc.ServiceContext) ([]zoneSummRow, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type aggInfo struct {
|
||||
qty int
|
||||
lastCreated int64
|
||||
}
|
||||
// 物料品类(itemType)映射:区域汇总「类型」列需按 itemType=4 补「其他」(需求24)。
|
||||
// 物料品类(itemType)映射:itemType=4 → 其他(辅料/工装/试验设备)
|
||||
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
|
||||
|
||||
// 一区域一行:质量与类型均为列(数量口径:结构件按行数量、电气件按 1)
|
||||
agg := map[string]*zoneSummRow{} // key: zoneCode
|
||||
order := []string{}
|
||||
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]
|
||||
g, ok := agg[zc]
|
||||
if !ok {
|
||||
g = &aggInfo{}
|
||||
agg[key] = g
|
||||
g = &zoneSummRow{ZoneCode: zc}
|
||||
agg[zc] = g
|
||||
order = append(order, zc)
|
||||
}
|
||||
if inv.ManageMode == 1 {
|
||||
g.qty += inv.Quantity
|
||||
} else {
|
||||
g.qty += 1
|
||||
qty := qtyOf(inv)
|
||||
switch inv.QualityStatus {
|
||||
case "合格":
|
||||
g.QtyQualified += qty
|
||||
case "不合格":
|
||||
g.QtyRejected += qty
|
||||
default:
|
||||
g.QtyPending += qty
|
||||
}
|
||||
if inv.CreatedAt > g.lastCreated {
|
||||
g.lastCreated = inv.CreatedAt
|
||||
switch {
|
||||
case itemTypeMap[inv.MaterialCode] == invItemOther:
|
||||
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(agg))
|
||||
for k, v := range agg {
|
||||
parts := strings.Split(k, "|")
|
||||
zc := parts[0]
|
||||
out := make([]zoneSummRow, 0, len(order))
|
||||
for _, zc := range order {
|
||||
g := agg[zc]
|
||||
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,
|
||||
})
|
||||
g.ID = zoneIDMap[zc]
|
||||
g.ZoneName = name
|
||||
out = append(out, *g)
|
||||
}
|
||||
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
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ZoneCode < out[j].ZoneCode })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1121,6 +1251,151 @@ func shortageHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);统一质量门禁:仅「合格」库存可出库,未检/不合格/待退不返回。
|
||||
|
||||
Reference in New Issue
Block a user