chore: release server-v1.0.81
Deploy Server / release-deploy-server (push) Successful in 1m54s

修复入库单/出库单按商品名搜不到历史导入单:搜索酒名条件由 COALESCE(被占位名劫持) 改为 p.name OR it.product_name 任一命中,并加回归测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
This commit is contained in:
wangjia
2026-06-24 11:56:21 +08:00
parent 33b0f6c30c
commit 5ff41cc505
5 changed files with 99 additions and 7 deletions
+5 -3
View File
@@ -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,
)
}
+44 -1
View File
@@ -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")
+5 -3
View File
@@ -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) 索引
@@ -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")