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 汇总行类型标签(与区域汇总「其他」口径一致):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(组内最近入库行) MaterialCode string `json:"materialCode"` // 物料编码 MaterialName string `json:"materialName"` // 物料名称 Spec string `json:"spec"` // 规格型号 ManageMode int `json:"manageMode"` // 1结构件/2电气件 ItemType int `json:"itemType"` // 物料品类(4=其他:辅料/工装/试验设备) 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 拾取器按成品过滤) 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") 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, ItemType: itemType, ItemTypeNot: itemTypeNot, 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成品/空=全部 ItemType int // 品类 itemType(B1:4=其他入库物料) ItemTypeNot int // 排除品类(§A1:结构件筛选时排除「其他」类 itemType=4) 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.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)) } if a.ManageMode == "1" { q = q.Where(inventory.ManageModeEQ(1)) } else if a.ManageMode == "2" { q = q.Where(inventory.ManageModeEQ(2)) } if a.Category == "1" { q = q.Where(inventory.CategoryEQ(invCatRaw)) } else if a.Category == "2" { q = q.Where(inventory.CategoryEQ(invCatSemi)) } else if a.Category == "3" { q = q.Where(inventory.CategoryEQ(invCatFinished)) } // 时间筛选:默认近3个月 startUnix := int64(0) if a.StartDate != "" { if t, err := time.ParseInLocation("2006-01-02", a.StartDate, time.Local); err == nil { startUnix = t.Unix() } } endUnix := int64(0) if a.EndDate != "" { if t, err := time.ParseInLocation("2006-01-02", a.EndDate, time.Local); err == nil { endUnix = t.Add(24 * time.Hour).Unix() // 含当天全天 } } if a.StartDate == "" && a.EndDate == "" { startUnix = time.Now().AddDate(0, -3, 0).Unix() // 默认近3个月 } if startUnix > 0 { q = q.Where(inventory.CreatedAtGTE(startUnix)) } if endUnix > 0 { q = q.Where(inventory.CreatedAtLTE(endUnix)) } all, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0()) if err != nil { return nil, 0, err } // 收集物料信息(名称/规格/品类从 materials 表补齐,字段更权威) matName := map[string]string{} matSpec := map[string]string{} matItemType := 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 } } // 分组聚合(质量状态降为列,不进分组键,方案 §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], 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电气件 ItemType int `json:"itemType"` // 物料品类(4=其他:辅料/工装/试验设备) 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成品/空=全部 ItemType int // 品类 itemType(4=其他入库物料,§A1 类型筛选) ItemTypeNot int // 排除品类(§A1:结构件筛选时排除「其他」类) 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"), 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), }) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } ok(w, map[string]any{"rows": rows, "total": total}) } } // aggregateMaterialSummary 按物料编码聚合全部在库库存(忽略区域/质量/时间维度),Go 侧内存聚合 + 分页。 func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([]*materialAggRow, int, error) { q := ctx.EntClient.Inventory.Query() q = q.Where(inventory.StatusIn("在库", "锁定")) q = q.Where(inventory.Or(inventory.ManageModeEQ(2), inventory.QuantityGT(0))) if a.MaterialCode != "" { q = q.Where(inventory.MaterialCodeContains(a.MaterialCode)) } if a.MaterialName != "" { q = q.Where(inventory.MaterialNameContains(a.MaterialName)) } if a.ManageMode == "1" { q = q.Where(inventory.ManageModeEQ(1)) } else if a.ManageMode == "2" { q = q.Where(inventory.ManageModeEQ(2)) } if a.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" { 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{} 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 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], 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) itemType := atoi(r.URL.Query().Get("itemType"), 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)) } 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 { 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"), ItemType: atoi(r.URL.Query().Get("itemType"), 0), ItemTypeNot: atoi(r.URL.Query().Get("itemTypeNot"), 0), 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.ManageMode, rw.ItemType), 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"), 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, 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.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, 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 } // 物料品类(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 } // 一区域一行:质量与类型均为列(数量口径:结构件按行数量、电气件按 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 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(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}) } }