@@ -172,7 +173,7 @@ async function showEditModal(row) {
stockStore.fetchStockByMaterialId(item.material_id).then(stock => {
itemStocks.value[item.material_id] = stock
})
- return { material_id: item.material_id, materialSearchText: material ? `${material.material_code} - ${material.name}` : '', quantity: item.quantity }
+ return { material_id: item.material_id, materialSearchText: material ? `${material.material_code} - ${material.name}` : `${item.material_code || ''} - ${item.material_name || ''}`, material_code: item.material_code, material_name: item.material_name, material_status: item.material_status, quantity: item.quantity }
})
: [{ material_id: null, materialSearchText: '', quantity: 1 }]
dialogVisible.value = true
@@ -204,7 +205,10 @@ function getFilteredMaterials(item) {
: source
// 当前选中的物料必须始终留在选项里:el-select 靠选项列表把 id 映射成名称,
// 选项里没有它就只能把物料 ID 当文字显示。
- const selected = source.find(m => m.id === item.material_id)
+ let selected = source.find(m => m.id === item.material_id)
+ if (!selected && item.material_status === 'obsolete') {
+ selected = { id: item.material_id, material_code: item.material_code, name: item.material_name, status: 'obsolete' }
+ }
if (selected && !matched.some(m => m.id === selected.id)) {
return [selected, ...matched]
}
@@ -231,7 +235,17 @@ function isLowStock(materialId) {
return stock?.stock_status === 'low'
}
+function firstMissingField() {
+ if (!form.receiver.trim()) return '请输入接收方'
+ return ''
+}
+
async function handleSave() {
+ const missing = firstMissingField()
+ if (missing) {
+ ElMessage.warning(missing)
+ return
+ }
saveLoading.value = true
try {
const validItems = form.items.filter(item => item.material_id && item.quantity > 0)
diff --git a/frontend/src/views/Stock.vue b/frontend/src/views/Stock.vue
index 11a65518..3a9efa33 100644
--- a/frontend/src/views/Stock.vue
+++ b/frontend/src/views/Stock.vue
@@ -112,7 +112,7 @@
@@ -250,7 +250,10 @@ const warningEnabled = ref(true)
onMounted(async () => {
stockStore.fetchStock()
stockStore.fetchStockWarning()
+ // 库存查询用有效物料即可(作废物料本来就不在库存列表里),
+ // 库存流水则要能查已作废物料的历史,所以额外拉一份含作废的选项。
materialsStore.fetchMaterialOptions()
+ materialsStore.fetchHistoricalMaterialOptions()
const catRes = await materialCategoriesApi.list()
categories.value = catRes.data || []
})
@@ -322,7 +325,13 @@ function logMaterialFilter(val) {
}
function getFilteredMaterialsForLog() {
- return searchMaterialOptions(materialsStore.materialOptions, logMaterialSearchText.value)
+ return searchMaterialOptions(materialsStore.historicalMaterialOptions, logMaterialSearchText.value)
+}
+
+// 流水检索框里会出现已作废的物料,标注出来免得和有效物料混淆。
+function logMaterialLabel(material) {
+ const label = `${material.material_code} - ${material.name}`
+ return material.status === 'obsolete' ? `${label}(已作废)` : label
}
function handleWarningPageChange(page) {
diff --git a/internal/handler/api.go b/internal/handler/api.go
index 1c4e14bb..486e9f95 100644
--- a/internal/handler/api.go
+++ b/internal/handler/api.go
@@ -29,6 +29,13 @@ type Handler struct {
warehouseSvc *service.WarehouseService
}
+func userFacingServiceErrorMessage(err error) string {
+ if errors.Is(err, service.ErrInsufficientStock) {
+ return "库存不足"
+ }
+ return err.Error()
+}
+
func NewHandler() *Handler {
return &Handler{
materialSvc: service.NewMaterialService(),
@@ -219,7 +226,11 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
func (h *Handler) ListMaterials(c *gin.Context) {
search := c.Query("search")
- category := c.Query("category")
+ status := c.DefaultQuery("status", "active")
+ if status != "active" && status != "obsolete" && status != "all" {
+ response.BadRequest(c, "status must be active, obsolete, or all")
+ return
+ }
limit := 20
offset := 0
@@ -237,7 +248,7 @@ func (h *Handler) ListMaterials(c *gin.Context) {
}
}
- materials, total, err := h.materialSvc.List(search, category, limit, offset)
+ materials, total, err := h.materialSvc.List(search, status, limit, offset)
if err != nil {
response.InternalError(c, err.Error())
return
@@ -468,6 +479,10 @@ func (h *Handler) CreateInbound(c *gin.Context) {
response.BadRequest(c, "material not found")
return
}
+ if errors.Is(err, service.ErrMaterialObsolete) {
+ response.BadRequest(c, "material is obsolete")
+ return
+ }
response.InternalError(c, err.Error())
return
}
@@ -510,6 +525,10 @@ func (h *Handler) UpdateInbound(c *gin.Context) {
response.BadRequest(c, "material not found")
return
}
+ if errors.Is(err, service.ErrMaterialObsolete) {
+ response.BadRequest(c, "material is obsolete")
+ return
+ }
response.InternalError(c, err.Error())
return
}
@@ -603,8 +622,12 @@ func (h *Handler) CreateOutbound(c *gin.Context) {
response.BadRequest(c, "material not found")
return
}
+ if errors.Is(err, service.ErrMaterialObsolete) {
+ response.BadRequest(c, "material is obsolete")
+ return
+ }
if errors.Is(err, service.ErrInsufficientStock) {
- response.BadRequest(c, "insufficient stock")
+ response.BadRequest(c, userFacingServiceErrorMessage(err))
return
}
response.InternalError(c, err.Error())
@@ -653,8 +676,12 @@ func (h *Handler) UpdateOutbound(c *gin.Context) {
response.BadRequest(c, "material not found")
return
}
+ if errors.Is(err, service.ErrMaterialObsolete) {
+ response.BadRequest(c, "material is obsolete")
+ return
+ }
if errors.Is(err, service.ErrInsufficientStock) {
- response.BadRequest(c, "insufficient stock")
+ response.BadRequest(c, userFacingServiceErrorMessage(err))
return
}
response.InternalError(c, err.Error())
@@ -1120,6 +1147,10 @@ func (h *Handler) CreateApplication(c *gin.Context) {
app, err := h.appSvc.Create(userID, &req)
if err != nil {
+ if errors.Is(err, service.ErrMaterialNotFound) || errors.Is(err, service.ErrMaterialObsolete) {
+ response.BadRequest(c, err.Error())
+ return
+ }
response.InternalError(c, err.Error())
return
}
@@ -1233,7 +1264,7 @@ func (h *Handler) ApproveApplication(c *gin.Context) {
return
}
if err == service.ErrInsufficientStock {
- response.BadRequest(c, "insufficient stock")
+ response.BadRequest(c, userFacingServiceErrorMessage(err))
return
}
response.InternalError(c, err.Error())
diff --git a/internal/handler/error_message_test.go b/internal/handler/error_message_test.go
new file mode 100644
index 00000000..85c4d727
--- /dev/null
+++ b/internal/handler/error_message_test.go
@@ -0,0 +1,13 @@
+package handler
+
+import (
+ "testing"
+
+ "github.com/wmms/wmms/internal/service"
+)
+
+func TestUserFacingServiceErrorMessage_LocalizesInsufficientStock(t *testing.T) {
+ if got := userFacingServiceErrorMessage(service.ErrInsufficientStock); got != "库存不足" {
+ t.Fatalf("库存不足应返回中文提示,实际为 %q", got)
+ }
+}
diff --git a/internal/model/model.go b/internal/model/model.go
index b6eac79c..4b527e89 100644
--- a/internal/model/model.go
+++ b/internal/model/model.go
@@ -76,6 +76,7 @@ type InboundItem struct {
Quantity int `json:"quantity"`
MaterialCode string `json:"material_code,omitempty"`
MaterialName string `json:"material_name,omitempty"`
+ MaterialStatus string `json:"material_status,omitempty"`
}
// OutboundOrder represents an outbound order
@@ -101,6 +102,7 @@ type OutboundItem struct {
Quantity int `json:"quantity"`
MaterialCode string `json:"material_code,omitempty"`
MaterialName string `json:"material_name,omitempty"`
+ MaterialStatus string `json:"material_status,omitempty"`
}
// OrderNo returns the stable display number used by inbound and outbound
diff --git a/internal/repository/inbound.go b/internal/repository/inbound.go
index e0ebdba4..0b2c3bec 100644
--- a/internal/repository/inbound.go
+++ b/internal/repository/inbound.go
@@ -76,7 +76,7 @@ func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) {
return nil, err
}
- itemsQuery := `SELECT ii.id, ii.inbound_order_id, ii.material_id, ii.quantity, m.material_code, m.name
+ itemsQuery := `SELECT ii.id, ii.inbound_order_id, ii.material_id, ii.quantity, m.material_code, m.name, m.status
FROM inbound_items ii
JOIN materials m ON ii.material_id = m.id
WHERE ii.inbound_order_id = ?`
@@ -88,7 +88,7 @@ func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) {
for rows.Next() {
var item model.InboundItem
- err := rows.Scan(&item.ID, &item.InboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName)
+ err := rows.Scan(&item.ID, &item.InboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus)
if err != nil {
return nil, err
}
diff --git a/internal/repository/material.go b/internal/repository/material.go
index 978943ad..e6b809a2 100644
--- a/internal/repository/material.go
+++ b/internal/repository/material.go
@@ -93,9 +93,13 @@ func (r *MaterialRepo) GetByMaterialCode(code string) (*model.Material, error) {
return m, nil
}
-func (r *MaterialRepo) List(search, category string, limit, offset int) ([]model.Material, int64, error) {
+func (r *MaterialRepo) List(search, status string, limit, offset int) ([]model.Material, int64, error) {
countQuery := `SELECT COUNT(*) FROM materials WHERE 1=1`
var countArgs []interface{}
+ if status != "all" {
+ countQuery += ` AND status = ?`
+ countArgs = append(countArgs, status)
+ }
if search != "" {
countQuery += ` AND (material_code LIKE ? OR name LIKE ?)`
@@ -113,6 +117,10 @@ func (r *MaterialRepo) List(search, category string, limit, offset int) ([]model
category_id, base_unit_id, unit, status, version, created_at, updated_at
FROM materials WHERE 1=1`
var args []interface{}
+ if status != "all" {
+ query += ` AND status = ?`
+ args = append(args, status)
+ }
if search != "" {
query += ` AND (material_code LIKE ? OR name LIKE ?)`
diff --git a/internal/repository/material_test.go b/internal/repository/material_test.go
index a71cbd0e..183e8e7f 100644
--- a/internal/repository/material_test.go
+++ b/internal/repository/material_test.go
@@ -146,3 +146,8 @@ func TestMaterialRepoObsoleteIsBlockedByStock(t *testing.T) {
t.Fatal(err)
}
}
+
+func TestMaterialRepoListUsesStatusFilterInsteadOfCategoryArgument(t *testing.T) {
+ var list func(string, string, int, int) ([]model.Material, int64, error) = NewMaterialRepo().List
+ _ = list
+}
diff --git a/internal/repository/outbound.go b/internal/repository/outbound.go
index ff5345cd..ade876a4 100644
--- a/internal/repository/outbound.go
+++ b/internal/repository/outbound.go
@@ -76,7 +76,7 @@ func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) {
return nil, err
}
- itemsQuery := `SELECT oi.id, oi.outbound_order_id, oi.material_id, oi.quantity, m.material_code, m.name
+ itemsQuery := `SELECT oi.id, oi.outbound_order_id, oi.material_id, oi.quantity, m.material_code, m.name, m.status
FROM outbound_items oi
JOIN materials m ON oi.material_id = m.id
WHERE oi.outbound_order_id = ?`
@@ -88,7 +88,7 @@ func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) {
for rows.Next() {
var item model.OutboundItem
- err := rows.Scan(&item.ID, &item.OutboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName)
+ err := rows.Scan(&item.ID, &item.OutboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus)
if err != nil {
return nil, err
}
diff --git a/internal/service/application.go b/internal/service/application.go
index fa838966..31aa10fe 100644
--- a/internal/service/application.go
+++ b/internal/service/application.go
@@ -36,6 +36,13 @@ func (s *ApplicationService) Create(userID int64, req *model.CreateApplicationRe
if err != nil || !exists {
return nil, ErrMaterialNotFound
}
+ active, err := s.materialSvc.IsActive(item.MaterialID)
+ if err != nil {
+ return nil, err
+ }
+ if !active {
+ return nil, ErrMaterialObsolete
+ }
items = append(items, model.ApplicationItem{
MaterialID: item.MaterialID,
Quantity: item.Quantity,
diff --git a/internal/service/errors.go b/internal/service/errors.go
index b509bd05..06da1b2e 100644
--- a/internal/service/errors.go
+++ b/internal/service/errors.go
@@ -4,4 +4,6 @@ import "errors"
var ErrMaterialNotFound = errors.New("material not found")
+var ErrMaterialObsolete = errors.New("material is obsolete")
+
var ErrInvalidWarningStock = errors.New("warning stock must be greater than or equal to 0")
diff --git a/internal/service/inbound.go b/internal/service/inbound.go
index 42c6e63d..55b21bdd 100644
--- a/internal/service/inbound.go
+++ b/internal/service/inbound.go
@@ -50,6 +50,13 @@ func (s *InboundService) Create(req *model.CreateInboundRequest, operator string
if !exists {
return nil, ErrMaterialNotFound
}
+ active, err := s.materialSvc.IsActive(item.MaterialID)
+ if err != nil {
+ return nil, err
+ }
+ if !active {
+ return nil, ErrMaterialObsolete
+ }
if item.WarningStock != nil && item.WarningStock.IsNegative() {
return nil, ErrInvalidWarningStock
}
@@ -193,6 +200,13 @@ func (s *InboundService) Update(id int64, req *model.UpdateInboundRequest) (*mod
// 阈值校验放在事务之外,避免为非法输入白开一个事务
for _, item := range req.Items {
+ active, err := s.materialSvc.IsActive(item.MaterialID)
+ if err != nil {
+ return nil, err
+ }
+ if !active {
+ return nil, ErrMaterialObsolete
+ }
if item.WarningStock != nil && item.WarningStock.IsNegative() {
return nil, ErrInvalidWarningStock
}
diff --git a/internal/service/material.go b/internal/service/material.go
index 2189b67d..69b226ce 100644
--- a/internal/service/material.go
+++ b/internal/service/material.go
@@ -125,8 +125,8 @@ func (s *MaterialService) GetByID(id int64) (*model.Material, error) {
return m, nil
}
-func (s *MaterialService) List(search, category string, limit, offset int) ([]model.Material, int64, error) {
- return s.repo.List(search, category, limit, offset)
+func (s *MaterialService) List(search, status string, limit, offset int) ([]model.Material, int64, error) {
+ return s.repo.List(search, status, limit, offset)
}
func (s *MaterialService) Update(id int64, req *model.UpdateMaterialRequest, operator string) (*model.Material, error) {
@@ -188,3 +188,17 @@ func (s *MaterialService) Exists(id int64) (bool, error) {
}
return true, nil
}
+
+// IsActive is intentionally separate from Exists: historical documents still
+// need to resolve retired materials, while new warehouse operations do not.
+func (s *MaterialService) IsActive(id int64) (bool, error) {
+ m := &model.Material{}
+ err := s.repo.GetByID(id, m)
+ if err == sql.ErrNoRows {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return m.Status == "active", nil
+}
diff --git a/internal/service/material_test.go b/internal/service/material_test.go
index bea10eef..da55b61d 100644
--- a/internal/service/material_test.go
+++ b/internal/service/material_test.go
@@ -4,6 +4,7 @@ import (
"errors"
"regexp"
"testing"
+ "time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
@@ -42,3 +43,35 @@ func TestMaterialServiceRejectsInvalidCategory(t *testing.T) {
t.Fatal(err)
}
}
+
+func TestMaterialServiceTreatsObsoleteMaterialAsInactive(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ if err != nil {
+ t.Fatalf("open sql mock: %v", err)
+ }
+ t.Cleanup(func() {
+ database.DB = nil
+ db.Close()
+ })
+ database.DB = db
+
+ query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
+ category_id, base_unit_id, unit, status, version, created_at, updated_at
+ FROM materials WHERE id = ?`
+ now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
+ mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(7)).WillReturnRows(
+ sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
+ AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, 12, 3, "件", "obsolete", 1, now, now),
+ )
+
+ active, err := NewMaterialService().IsActive(7)
+ if err != nil {
+ t.Fatalf("check material activity: %v", err)
+ }
+ if active {
+ t.Fatal("obsolete material must not be active")
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/service/outbound.go b/internal/service/outbound.go
index 01172578..3576c93a 100644
--- a/internal/service/outbound.go
+++ b/internal/service/outbound.go
@@ -44,6 +44,13 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest, operator stri
if !exists {
return nil, ErrMaterialNotFound
}
+ active, err := s.materialSvc.IsActive(item.MaterialID)
+ if err != nil {
+ return nil, err
+ }
+ if !active {
+ return nil, ErrMaterialObsolete
+ }
currentStock, err := s.stockRepo.GetStock(item.MaterialID)
if err != nil {
@@ -202,6 +209,13 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope
if !exists {
return nil, ErrMaterialNotFound
}
+ active, err := s.materialSvc.IsActive(item.MaterialID)
+ if err != nil {
+ return nil, err
+ }
+ if !active {
+ return nil, ErrMaterialObsolete
+ }
currentStock, err := s.stockRepo.GetStock(item.MaterialID)
if err != nil {
diff --git a/internal/service/stock.go b/internal/service/stock.go
index 828b8c80..3c0ff1c3 100644
--- a/internal/service/stock.go
+++ b/internal/service/stock.go
@@ -71,6 +71,16 @@ func (s *StockService) UpdateWarningStock(materialID int64, minimumAvailable dec
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 {
@@ -101,7 +111,7 @@ func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int)
ID: log.ID,
MaterialID: log.MaterialID,
ChangeType: changeType,
- ChangeQuantity: quantity,
+ ChangeQuantity: signedChangeQuantity(log.Type, quantity),
BeforeQuantity: beforeQuantity,
AfterQuantity: balance,
ReferenceID: log.RelatedOrderID,
@@ -131,17 +141,11 @@ func (s *StockService) GetAllLogs(limit, offset int) ([]model.StockLogResponse,
beforeQuantity = log.RunningBalance.Add(quantity)
}
- // inbound=+quantity, outbound=-quantity
- changeQuantity := quantity
- if log.Type != "in" {
- changeQuantity = quantity.Neg()
- }
-
responses = append(responses, model.StockLogResponse{
ID: log.ID,
MaterialID: log.MaterialID,
ChangeType: changeType,
- ChangeQuantity: changeQuantity,
+ ChangeQuantity: signedChangeQuantity(log.Type, quantity),
BeforeQuantity: beforeQuantity,
AfterQuantity: log.RunningBalance,
ReferenceID: log.RelatedOrderID,
diff --git a/internal/service/stock_test.go b/internal/service/stock_test.go
index f556b3af..7c250777 100644
--- a/internal/service/stock_test.go
+++ b/internal/service/stock_test.go
@@ -49,3 +49,22 @@ func TestClassifyStockBoundaries(t *testing.T) {
})
}
}
+
+func TestSignedChangeQuantityUsesTheSameConventionForInboundAndOutbound(t *testing.T) {
+ quantity := decimal.NewFromInt(100)
+ cases := []struct {
+ logType string
+ want string
+ }{
+ {logType: "in", want: "100"},
+ {logType: "out", want: "-100"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.logType, func(t *testing.T) {
+ if got := signedChangeQuantity(tc.logType, quantity); !got.Equal(decimal.RequireFromString(tc.want)) {
+ t.Fatalf("signedChangeQuantity(%q, 100) = %s, want %s", tc.logType, got, tc.want)
+ }
+ })
+ }
+}