diff --git a/CHANGELOG-server.md b/CHANGELOG-server.md index 9acd837..20dff69 100644 --- a/CHANGELOG-server.md +++ b/CHANGELOG-server.md @@ -5,6 +5,11 @@ 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.81] - 2026-06-24 + +### 修复 +- 入库单 / 出库单按商品名搜索:历史导入的单据现在也能按商品名搜到(此前明细统一挂占位商品,搜索被占位名顶替,只能搜到新建单据)。 + ## [1.0.80] - 2026-06-24 ### 改进 diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index fa41239..2ad897d 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -53,16 +53,18 @@ func (h *StockInHandler) List(c *gin.Context) { if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { like := "%" + kw + "%" // keyword 同时命中:单号 / 往来单位 / 酒名(含全拼+首字母,反查含该酒明细的单据)。 - // 酒名优先取 product 主数据、回退快照列;拼音只在 products 上(快照无拼音列)。 + // 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的 + // 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持, + // 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。 query = query.Where( "order_no LIKE ?"+ " OR partner_id IN (SELECT id FROM partners WHERE shop_id = ? AND name LIKE ?)"+ " OR id IN (SELECT it.order_id FROM stock_in_items it"+ " LEFT JOIN products p ON p.id = it.product_id AND p.shop_id = it.shop_id"+ " WHERE it.shop_id = ? AND it.deleted_at IS NULL"+ - " AND (COALESCE(NULLIF(p.name,''), it.product_name) LIKE ?"+ + " AND (p.name LIKE ? OR it.product_name LIKE ?"+ " OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))", - like, shopID, like, shopID, like, like, like, + like, shopID, like, shopID, like, like, like, like, ) } diff --git a/backend/internal/handler/stock_in_test.go b/backend/internal/handler/stock_in_test.go index ab1f1d5..ecf012b 100644 --- a/backend/internal/handler/stock_in_test.go +++ b/backend/internal/handler/stock_in_test.go @@ -263,7 +263,7 @@ func TestStockInHandler_TotalAmount(t *testing.T) { "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ - {"product_id": product1.ID, "quantity": 10.0, "unit_price": 5.0}, // 50 + {"product_id": product1.ID, "quantity": 10.0, "unit_price": 5.0}, // 50 {"product_id": product2.ID, "quantity": 3.0, "unit_price": 20.0}, // 60 }, }) @@ -487,6 +487,49 @@ func TestStockInHandler_List_FilterByProductName(t *testing.T) { assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) } +// 回归:历史导入单的明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空), +// 真实酒名只在快照列 product_name。修复前搜索用 COALESCE(NULLIF(p.name,”), product_name) +// 被占位名劫持,导入单按真实酒名搜不到;修复后「p.name OR product_name」任一命中。 +func TestStockInHandler_List_FilterByImportedSnapshotName(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SI012B") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + // 占位商品:名「历史导入占位」,编码 HIST-PLACEHOLDER(模拟 import-history) + placeholder := &model.Product{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + Name: "历史导入占位", + Code: "HIST-PLACEHOLDER", + } + require.NoError(t, db.Create(placeholder).Error) + + // 导入单:明细 product_id 指向占位商品,真实酒名「飞天茅台」仅在快照 product_name + order := &model.StockInOrder{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + OrderNo: "RK-IMPORT-001", + WarehouseID: warehouse.ID, + OperatorID: user.ID, + Status: "approved", + OrderDate: model.Date{Time: time.Now()}, + } + require.NoError(t, db.Create(order).Error) + require.NoError(t, db.Create(&model.StockInItem{ + OrderID: order.ID, + ShopID: shop.ID, + ProductID: placeholder.ID, + ProductCode: "ZXZ000010", + ProductName: "飞天茅台", + Quantity: 5.0, + }).Error) + + // 按真实酒名搜 → 命中导入单(修复前为 0) + w := makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) +} + func TestStockInHandler_List_FilterByProductPinyin(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI013") diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index 99d9292..ad2a84f 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -46,16 +46,18 @@ func (h *StockOutHandler) List(c *gin.Context) { if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { like := "%" + kw + "%" // keyword 同时命中:单号 / 往来单位 / 酒名(含全拼+首字母,反查含该酒明细的单据)。 - // 酒名优先取 product 主数据、回退快照列;拼音只在 products 上(快照无拼音列)。 + // 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的 + // 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持, + // 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。 query = query.Where( "order_no LIKE ?"+ " OR partner_id IN (SELECT id FROM partners WHERE shop_id = ? AND name LIKE ?)"+ " OR id IN (SELECT it.order_id FROM stock_out_items it"+ " LEFT JOIN products p ON p.id = it.product_id AND p.shop_id = it.shop_id"+ " WHERE it.shop_id = ? AND it.deleted_at IS NULL"+ - " AND (COALESCE(NULLIF(p.name,''), it.product_name) LIKE ?"+ + " AND (p.name LIKE ? OR it.product_name LIKE ?"+ " OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))", - like, shopID, like, shopID, like, like, like, + like, shopID, like, shopID, like, like, like, like, ) } // 按商品编码反查单据:精确匹配,走 idx_so_items_shop_code(shop_id, product_code) 索引 diff --git a/backend/internal/handler/stock_out_test.go b/backend/internal/handler/stock_out_test.go index af5148f..9a0aa78 100644 --- a/backend/internal/handler/stock_out_test.go +++ b/backend/internal/handler/stock_out_test.go @@ -248,6 +248,46 @@ func TestStockOutHandler_List_FilterByProductName(t *testing.T) { assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) } +// 回归:历史导入出库单的明细 product_id 指向占位商品「历史导入占位」,真实酒名只在快照列。 +// 修复前 COALESCE 被占位名劫持搜不到,修复后「p.name OR product_name」任一命中。 +func TestStockOutHandler_List_FilterByImportedSnapshotName(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SO012B") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + placeholder := &model.Product{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + Name: "历史导入占位", + Code: "HIST-PLACEHOLDER", + } + require.NoError(t, db.Create(placeholder).Error) + + order := &model.StockOutOrder{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + OrderNo: "CK-IMPORT-001", + WarehouseID: warehouse.ID, + OperatorID: user.ID, + Status: "approved", + OrderDate: model.Date{Time: time.Now()}, + } + require.NoError(t, db.Create(order).Error) + require.NoError(t, db.Create(&model.StockOutItem{ + OrderID: order.ID, + ShopID: shop.ID, + ProductID: placeholder.ID, + ProductCode: "ZXZ000010", + ProductName: "飞天茅台", + Quantity: 5.0, + }).Error) + + // 按真实酒名搜 → 命中导入单(修复前为 0) + w := makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=茅台", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) +} + func TestStockOutHandler_Reject(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO004")