feat(production): 新增排产支撑功能并移除手动报工页面

- 在main.go中更新排产物料需求注释说明,明确WMS与MES系统间交互方式
- 从前端help.js中移除手动报工(helpScan)相关帮助文档
- 更新工艺流程、绩效报表等页面帮助文档中的报工流程说明
- 在主布局中添加排产支撑菜单项并移除手动报工菜单项
- 新增ProducibleSupport.vue组件实现排产支撑页面功能
- 移除Scan.vue手动报工页面组件
- 更新相关帮助文档内容以匹配新的业务流程
This commit is contained in:
SunYF
2026-09-21 14:06:05 +08:00
parent 132c3ed6bd
commit 0e769f3813
105 changed files with 2313 additions and 5314 deletions
+367 -92
View File
@@ -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) // 品类(B14=其他入库物料)
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 // 品类 itemTypeB14=其他入库物料)
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);统一质量门禁:仅「合格」库存可出库,未检/不合格/待退不返回。