material_manage_system/internal/service/stock.go
文睿 张 71d1829c29 fix: 库存列表分类筛选失效,后端补上 category 参数
前端 stores/stock.js 一直在发 category,但 handler 只读 search,service 与
repository 的方法签名里也没有这个参数,整条链路把它静默丢弃,于是选完分类
点「查询」没有任何反应。前端本来就传对了,无需改动。

repository 侧按大类 code 过滤(material_categories 里 parent_id 为空的那层,
即 SQL 里的 pc.code),与列表「大类类型」列同口径,前端下拉的 value 正是大类
code。注意 GetAllStock 的 COUNT 查询原本没有 JOIN 分类表,加 pc.code 前必须
补上与主查询一致的 LEFT JOIN,否则分页总数会和实际行数对不上、末尾多出空白
页;两张表都按主键关联,实测计数不变。小类码不会误命中(category=A01 返回 0)。

顺带修掉同一功能里的耦合缺陷:updateWarningStock 刷新列表时不传筛选参数,
筛选一旦生效,保存预警就会把列表刷回全量、而下拉框仍显示选中的分类。store
新增 stockFilters 记住当前条件,刷新时沿用。

stock_test.go 里 COUNT 的正则随 SQL 变形同步放宽,新增
TestStockRepoGetAllStockAppliesCategoryToBothQueries 覆盖两条 SQL 的成对条件
与参数顺序;前端补 4 条用例(store 沿用/清空筛选、组件查询与翻页传递分类)。
2026-09-21 16:19:18 +08:00

173 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"database/sql"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
)
type StockService struct {
stockRepo *repository.StockRepo
}
func NewStockService() *StockService {
return &StockService{
stockRepo: repository.NewStockRepo(),
}
}
// ClassifyStock 与 repository.stockWarningHavingSQL 是同一套规则,后者负责筛出预警列表,
// 两者必须保持一致:只要预警处于启用状态、并且现存量已经触达阈值就是 low。阈值为 0 是
// 合法配置("库存降到 0 时预警"),不再被排除。
//
// 没配过规则和手动关闭预警的物料都由 warningEnabled=false 表达,因此在这里自然落到
// normal——那是操作者自己选择不被告知,不是告警。
func ClassifyStock(totalOnHand, warningStock decimal.Decimal, warningEnabled bool) string {
if !warningEnabled {
return model.StockStatusNormal
}
if totalOnHand.LessThanOrEqual(warningStock) {
return model.StockStatusLow
}
return model.StockStatusNormal
}
func classifyAll(stocks []model.Stock) {
for i := range stocks {
stocks[i].StockStatus = ClassifyStock(stocks[i].TotalOnHand, stocks[i].WarningStock, stocks[i].WarningEnabled)
}
}
func (s *StockService) GetAllStock(search, category string, limit, offset int) ([]model.Stock, int64, error) {
stocks, total, err := s.stockRepo.GetAllStock(search, category, limit, offset)
if err != nil {
return nil, 0, err
}
classifyAll(stocks)
return stocks, total, nil
}
func (s *StockService) GetByMaterialID(materialID int64) (*model.Stock, error) {
stock, err := s.stockRepo.GetByMaterialID(materialID)
if err != nil {
return nil, err
}
stock.StockStatus = ClassifyStock(stock.TotalOnHand, stock.WarningStock, stock.WarningEnabled)
return stock, nil
}
func (s *StockService) GetWarningStock(limit, offset int) ([]model.Stock, int64, error) {
stocks, total, err := s.stockRepo.GetWarningStock(limit, offset)
if err != nil {
return nil, 0, err
}
classifyAll(stocks)
return stocks, total, nil
}
func (s *StockService) UpdateWarningStock(materialID int64, minimumAvailable decimal.Decimal, enabled bool) error {
return s.stockRepo.UpdateWarningStock(materialID, minimumAvailable, enabled)
}
// signedChangeQuantity keeps the API contract consistent: inbound changes are
// positive and outbound changes are negative. The type still identifies the
// business operation for display and filtering.
func signedChangeQuantity(logType string, quantity decimal.Decimal) decimal.Decimal {
if logType == "in" {
return quantity
}
return quantity.Neg()
}
func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int) ([]model.StockLogResponse, int64, error) {
logs, total, err := s.stockRepo.GetLogsByMaterialID(materialID, limit, offset)
if err != nil {
return nil, 0, err
}
var responses []model.StockLogResponse
balance := decimal.Zero
for _, log := range logs {
quantity := decimal.NewFromInt(int64(log.Quantity))
if log.Type == "in" {
balance = balance.Add(quantity)
} else {
balance = balance.Sub(quantity)
}
changeType := "outbound"
if log.Type == "in" {
changeType = "inbound"
}
beforeQuantity := balance.Sub(quantity)
if log.Type != "in" {
beforeQuantity = balance.Add(quantity)
}
responses = append(responses, model.StockLogResponse{
ID: log.ID,
MaterialID: log.MaterialID,
ChangeType: changeType,
ChangeQuantity: signedChangeQuantity(log.Type, quantity),
BeforeQuantity: beforeQuantity,
AfterQuantity: balance,
ReferenceID: log.RelatedOrderID,
ReferenceType: log.RelatedOrderType,
CreatedAt: log.CreatedAt,
})
}
return responses, total, nil
}
func (s *StockService) GetAllLogs(limit, offset int) ([]model.StockLogResponse, int64, error) {
logs, total, err := s.stockRepo.GetAllLogsWithBalance(limit, offset)
if err != nil {
return nil, 0, err
}
var responses []model.StockLogResponse
for _, log := range logs {
changeType := "outbound"
if log.Type == "in" {
changeType = "inbound"
}
quantity := decimal.NewFromInt(int64(log.Quantity))
beforeQuantity := log.RunningBalance.Sub(quantity)
if log.Type != "in" {
beforeQuantity = log.RunningBalance.Add(quantity)
}
responses = append(responses, model.StockLogResponse{
ID: log.ID,
MaterialID: log.MaterialID,
ChangeType: changeType,
ChangeQuantity: signedChangeQuantity(log.Type, quantity),
BeforeQuantity: beforeQuantity,
AfterQuantity: log.RunningBalance,
ReferenceID: log.RelatedOrderID,
ReferenceType: log.RelatedOrderType,
CreatedAt: log.CreatedAt,
})
}
return responses, total, nil
}
func (s *StockService) MaterialExists(materialID int64) (bool, error) {
_, err := s.stockRepo.GetByMaterialID(materialID)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (s *StockService) GetInventoryBalanceDetails(materialID int64) ([]model.InventoryBalanceDetail, error) {
return s.stockRepo.GetInventoryBalanceDetails(materialID)
}