From fb1e637ce4f52bb1a1353727b7d09c008400aadb Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 20 Jun 2026 16:02:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E5=85=A5=E5=BA=93=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E5=BA=93=E5=AD=98=E6=B2=BF=E7=94=A8=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E5=BF=AB=E7=85=A7=E5=90=8D=20+=20=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E5=88=97=E8=A1=A8=E8=BF=87=E6=BB=A4=E8=BD=AF=E5=88=A0?= =?UTF-8?q?=E5=95=86=E5=93=81=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=8D=A0=E4=BD=8D=E9=A1=B6=E6=9B=BF=EF=BC=9B?= =?UTF-8?q?=E5=85=A5=E5=BA=93/=E5=87=BA=E5=BA=93=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20keyword=20=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx --- CHANGELOG-server.md | 9 +++++ backend/internal/handler/inventory.go | 14 +++---- backend/internal/handler/inventory_test.go | 46 ++++++++++++++++++++++ backend/internal/handler/stock_in.go | 7 ++++ backend/internal/handler/stock_in_test.go | 45 +++++++++++++++++++++ backend/internal/handler/stock_out.go | 7 ++++ backend/internal/handler/stock_out_test.go | 45 +++++++++++++++++++++ backend/internal/service/stock.go | 26 ++++++++---- backend/internal/service/stock_test.go | 41 +++++++++++++++++++ 9 files changed, 225 insertions(+), 15 deletions(-) diff --git a/CHANGELOG-server.md b/CHANGELOG-server.md index 7d27653..3139d3c 100644 --- a/CHANGELOG-server.md +++ b/CHANGELOG-server.md @@ -5,6 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.67] - 2026-06-20 + +### 修复 +- 历史导入单审核入库后,库存里商品显示为「历史导入占位」、按真实名搜不到的问题:审核生成库存时改用单据明细自带的真实商品名/编码/系列/规格,库存列表也不再显示已删除的占位商品名 +- 库存列表按真实商品名/编码即可搜到这些历史商品 + +### 新功能 +- 入库单 / 出库单列表支持关键词搜索:按单号或往来单位名称即时筛选 + ## [1.0.66] - 2026-06-20 ### 修复 diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index 7098fe6..a812728 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -68,7 +68,7 @@ func (h *InventoryHandler) List(c *gin.Context) { specStr := c.Query("spec") if keyword != "" { - baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)" + baseWhere += " AND (COALESCE(NULLIF(p.name,''), NULLIF(sii.product_name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), NULLIF(sii.product_code,''), inv.product_code) LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)" like := "%" + keyword + "%" args = append(args, like, like, like, like) } @@ -101,7 +101,7 @@ func (h *InventoryHandler) List(c *gin.Context) { SELECT COUNT(*) FROM inventories inv LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id - LEFT JOIN products p ON p.id = inv.product_id + LEFT JOIN products p ON p.id = inv.product_id AND p.deleted_at IS NULL LEFT JOIN warehouses w ON w.id = inv.warehouse_id WHERE ` + baseWhere @@ -116,10 +116,10 @@ func (h *InventoryHandler) List(c *gin.Context) { SELECT inv.id, inv.shop_id, inv.warehouse_id, inv.product_id, inv.stock_in_item_id, inv.quantity, - COALESCE(NULLIF(p.code,''), inv.product_code, '') AS product_code, - COALESCE(NULLIF(p.name,''), inv.product_name, '') AS product_name, - COALESCE(NULLIF(p.series,''), inv.series, '') AS series, - COALESCE(NULLIF(p.spec,''), inv.spec, '') AS spec, + COALESCE(NULLIF(p.code,''), NULLIF(sii.product_code,''), inv.product_code, '') AS product_code, + COALESCE(NULLIF(p.name,''), NULLIF(sii.product_name,''), inv.product_name, '') AS product_name, + COALESCE(NULLIF(p.series,''), NULLIF(sii.series,''), inv.series, '') AS series, + COALESCE(NULLIF(p.spec,''), NULLIF(sii.spec,''), inv.spec, '') AS spec, COALESCE(NULLIF(p.unit,''), inv.unit, '') AS unit, COALESCE(NULLIF(w.name,''), inv.warehouse_name, '') AS warehouse_name, COALESCE(sii.unit_price, inv.unit_price) AS unit_price, @@ -132,7 +132,7 @@ func (h *InventoryHandler) List(c *gin.Context) { inv.created_at FROM inventories inv LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id - LEFT JOIN products p ON p.id = inv.product_id + LEFT JOIN products p ON p.id = inv.product_id AND p.deleted_at IS NULL LEFT JOIN warehouses w ON w.id = inv.warehouse_id WHERE ` + baseWhere + ` ORDER BY inv.id DESC diff --git a/backend/internal/handler/inventory_test.go b/backend/internal/handler/inventory_test.go index 355d6a2..ebf07b4 100644 --- a/backend/internal/handler/inventory_test.go +++ b/backend/internal/handler/inventory_test.go @@ -219,6 +219,52 @@ func TestInventoryHandler_List_HotelIsolation(t *testing.T) { assert.Equal(t, float64(0), respB["total"].(float64)) } +// 库存指向已软删的占位商品时,列表应回退到明细/库存快照真名, +// 而非占位商品名「历史导入占位」,且能按真名搜索到。 +func TestInventoryHandler_List_DeletedProductFallsBackToSnapshot(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "INV009") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + placeholder := testutil.CreateTestProduct(db, shop.ID, "历史导入占位") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + // 明细携带真实快照名 + item := model.StockInItem{ + ShopID: shop.ID, + ProductID: placeholder.ID, + ProductCode: "ZXZ029751", + ProductName: "贵州茅台酒2006", + Quantity: 10, + } + require.NoError(t, db.Create(&item).Error) + + // 库存:product_id 指向占位商品,且 product_name 也是占位名(模拟旧脏数据) + require.NoError(t, db.Create(&model.Inventory{ + ShopID: shop.ID, + WarehouseID: &warehouse.ID, + ProductID: &placeholder.ID, + StockInItemID: &item.ID, + ProductName: "历史导入占位", + Quantity: 10, + }).Error) + + // 软删占位商品 + require.NoError(t, db.Delete(&placeholder).Error) + + // 列表:名称应回退到明细快照真名 + w := makeRequest(r, "GET", "/api/v1/inventory", token, nil) + resp := parseResponse(w) + require.Equal(t, float64(1), resp["total"].(float64)) + invItem := resp["data"].([]interface{})[0].(map[string]interface{}) + assert.Equal(t, "贵州茅台酒2006", invItem["product_name"]) + + // 按真名可搜到 + w = makeRequest(r, "GET", "/api/v1/inventory?keyword=茅台", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) +} + func TestInventoryHandler_AfterStockInApprove(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV008") diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index fa87064..84ee0bb 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -49,6 +49,13 @@ func (h *StockInHandler) List(c *gin.Context) { if endDate := c.Query("end_date"); endDate != "" { query = query.Where("order_date <= ?", endDate) } + if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { + like := "%" + kw + "%" + query = query.Where( + "order_no LIKE ? OR partner_id IN (SELECT id FROM partners WHERE shop_id = ? AND name LIKE ?)", + like, shopID, like, + ) + } var total int64 query.Count(&total) diff --git a/backend/internal/handler/stock_in_test.go b/backend/internal/handler/stock_in_test.go index 77be869..476c49d 100644 --- a/backend/internal/handler/stock_in_test.go +++ b/backend/internal/handler/stock_in_test.go @@ -308,6 +308,51 @@ func TestStockInHandler_Reject_NotPending(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } +func TestStockInHandler_List_FilterByKeyword(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SI011") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + product := testutil.CreateTestProduct(db, shop.ID, "Brandy") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + // 往来单位:茅台供应商 + partner := &model.Partner{Code: "P001", Name: "茅台供应商", Type: "supplier"} + partner.ShopID = shop.ID + require.NoError(t, db.Create(partner).Error) + + // 单据1:挂往来单位 + w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ + "warehouse_id": warehouse.ID, + "partner_id": partner.ID, + "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{{"product_id": product.ID, "quantity": 5.0}}, + }) + require.Equal(t, http.StatusCreated, w.Code) + order1No := parseResponse(w)["data"].(map[string]interface{})["order_no"].(string) + + // 单据2:无往来单位 + w = makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ + "warehouse_id": warehouse.ID, + "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{{"product_id": product.ID, "quantity": 3.0}}, + }) + require.Equal(t, http.StatusCreated, w.Code) + + // 按往来单位名搜索 → 只命中单据1 + w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + // 按单号搜索 → 只命中单据1 + w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword="+order1No, token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + // 无关键词 → 两条都在 + w = makeRequest(r, "GET", "/api/v1/stock-in/orders", token, nil) + assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) +} + func TestStockInHandler_List_FilterByStatus(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI010") diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index f8dc93f..9c0eac9 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -42,6 +42,13 @@ func (h *StockOutHandler) List(c *gin.Context) { if endDate := c.Query("end_date"); endDate != "" { query = query.Where("order_date <= ?", endDate) } + if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { + like := "%" + kw + "%" + query = query.Where( + "order_no LIKE ? OR partner_id IN (SELECT id FROM partners WHERE shop_id = ? AND name LIKE ?)", + like, shopID, like, + ) + } var total int64 query.Count(&total) diff --git a/backend/internal/handler/stock_out_test.go b/backend/internal/handler/stock_out_test.go index 9a7ec93..ab00688 100644 --- a/backend/internal/handler/stock_out_test.go +++ b/backend/internal/handler/stock_out_test.go @@ -133,6 +133,51 @@ func TestStockOutHandler_List(t *testing.T) { assert.Equal(t, float64(2), resp["total"].(float64)) } +func TestStockOutHandler_List_FilterByKeyword(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SO011") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + product := testutil.CreateTestProduct(db, shop.ID, "Beer") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + // 往来单位:海底捞客户 + partner := &model.Partner{Code: "C001", Name: "海底捞客户", Type: "customer"} + partner.ShopID = shop.ID + require.NoError(t, db.Create(partner).Error) + + // 单据1:挂往来单位 + w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ + "warehouse_id": warehouse.ID, + "partner_id": partner.ID, + "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{{"product_id": product.ID, "quantity": 1.0}}, + }) + require.Equal(t, http.StatusCreated, w.Code) + order1No := parseResponse(w)["data"].(map[string]interface{})["order_no"].(string) + + // 单据2:无往来单位 + w = makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ + "warehouse_id": warehouse.ID, + "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{{"product_id": product.ID, "quantity": 1.0}}, + }) + require.Equal(t, http.StatusCreated, w.Code) + + // 按往来单位名搜索 + w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=海底捞", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + // 按单号搜索 + w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword="+order1No, token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + // 无关键词 + w = makeRequest(r, "GET", "/api/v1/stock-out/orders", token, nil) + assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) +} + func TestStockOutHandler_Reject(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO004") diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go index aff0d70..04a3c1a 100644 --- a/backend/internal/service/stock.go +++ b/backend/internal/service/stock.go @@ -64,16 +64,26 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error unitPricePtr = &itemCopy.UnitPrice } - productCode := "" - productName := "" - series := "" - spec := "" + // 优先使用明细自带快照列(历史导入单的真实商品信息在此), + // Product 关联仅作兜底(普通 UI 录入单的明细快照为空)。 + productCode := itemCopy.ProductCode + productName := itemCopy.ProductName + series := itemCopy.Series + spec := itemCopy.Spec unit := "" if itemCopy.Product != nil { - productCode = itemCopy.Product.Code - productName = itemCopy.Product.Name - series = itemCopy.Product.Series - spec = itemCopy.Product.Spec + if productCode == "" { + productCode = itemCopy.Product.Code + } + if productName == "" { + productName = itemCopy.Product.Name + } + if series == "" { + series = itemCopy.Product.Series + } + if spec == "" { + spec = itemCopy.Product.Spec + } unit = itemCopy.Product.Unit } diff --git a/backend/internal/service/stock_test.go b/backend/internal/service/stock_test.go index 6acb019..de228b0 100644 --- a/backend/internal/service/stock_test.go +++ b/backend/internal/service/stock_test.go @@ -65,6 +65,47 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) { assert.Equal(t, float64(10), logs[0].QtyAfter) } +// 历史导入单:明细自带真实快照名,product_id 指向占位商品(名「历史导入占位」)。 +// 审核生成的库存应使用明细快照真名,而非占位商品名。 +func TestStockService_ApproveStockIn_PrefersItemSnapshot(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "STOCK009") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + placeholder := testutil.CreateTestProduct(db, shop.ID, "历史导入占位") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + + order := model.StockInOrder{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + OrderNo: "IN20240101000009", + WarehouseID: warehouse.ID, + OperatorID: user.ID, + Status: "pending", + OrderDate: model.Date{Time: time.Now()}, + Items: []model.StockInItem{ + { + ShopID: shop.ID, + ProductID: placeholder.ID, + ProductCode: "ZXZ029751", + ProductName: "贵州茅台酒2006", + Series: "茅台", + Spec: "500ml", + Quantity: 10, + }, + }, + } + require.NoError(t, db.Create(&order).Error) + + svc := NewStockService(db) + require.NoError(t, svc.ApproveStockIn(shop.ID, order.ID, user.ID)) + + var inv model.Inventory + require.NoError(t, db.Where("shop_id = ? AND product_id = ?", shop.ID, placeholder.ID).First(&inv).Error) + assert.Equal(t, "贵州茅台酒2006", inv.ProductName) + assert.Equal(t, "ZXZ029751", inv.ProductCode) + assert.Equal(t, "茅台", inv.Series) + assert.Equal(t, "500ml", inv.Spec) +} + func TestStockService_ApproveStockIn_NotPending(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "STOCK002")