feat(backend): 出入库搜索重构 + 商品编码搜索 + KPI/确认售价/详细搜索

- 搜索 keyword 命中改为 单号/酒名(名+拼音)/商品编码,去掉往来单位匹配
- applyStockOrderDetailFilters 合并明细过滤为单条子查询(同一明细语义)+ 新增 product_code
- 新增出入库 KPI summary、出库确认售价(confirm-sale) 及路由
- 补充/更新对应单元测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-02 08:47:44 +08:00
parent 2c2bb7ccb3
commit 998bbab546
7 changed files with 421 additions and 21 deletions
+91 -2
View File
@@ -101,6 +101,94 @@ func TestStockInHandler_List(t *testing.T) {
assert.Equal(t, float64(2), resp["total"].(float64))
}
func TestStockInHandler_Summary(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SISUM")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
for i := 0; i < 2; i++ {
makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
"warehouse_id": wh.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_name": "A", "quantity": 2.0, "unit_price": 100.0}},
})
}
var first model.StockInOrder
db.Where("shop_id = ?", shop.ID).Order("id ASC").First(&first)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", first.ID), token, nil)
w := makeRequest(r, "GET", "/api/v1/stock-in/summary", token, nil)
require.Equal(t, http.StatusOK, w.Code)
s := parseResponse(w)
assert.Equal(t, float64(2), s["month_count"])
assert.Equal(t, float64(400), s["month_amount"])
assert.Equal(t, float64(1), s["pending_count"])
}
func TestStockInHandler_DetailFilter(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SIDF")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_name": "BlueWine", "series": "Blue", "spec": "500ml", "quantity": 1.0, "unit_price": 10.0}},
})
makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_name": "PlainWine", "series": "Plain", "spec": "480ml", "quantity": 1.0, "unit_price": 10.0}},
})
// 系列过滤(JOIN 明细)
w := makeRequest(r, "GET", "/api/v1/stock-in/orders?series=Blue", token, nil)
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, float64(1), parseResponse(w)["total"])
// 商品信息过滤(明细快照名 LIKE)
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?product=Plain", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"])
// 规格过滤
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?spec=500ml", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"])
// 组合:系列 Blue + 规格 480ml → 无交集
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?series=Blue&spec=480ml", token, nil)
assert.Equal(t, float64(0), parseResponse(w)["total"])
// 商品编码过滤(明细 product_code LIKE):给 BlueWine 明细设已知码再按子串筛
db.Model(&model.StockInItem{}).
Where("shop_id = ? AND product_name = ?", shop.ID, "BlueWine").
Update("product_code", "PC-XYZ")
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?product_code=XYZ", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"])
// 同一明细语义:一张单两条明细,系列在明细1、规格分属明细2。
makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_name": "MixA", "series": "MixS", "spec": "111", "quantity": 1.0, "unit_price": 10.0},
{"product_name": "MixB", "series": "OtherS", "spec": "222", "quantity": 1.0, "unit_price": 10.0},
},
})
// 跨明细组合(系列在明细1、规格在明细2)→ 同一明细语义下无单命中
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?series=MixS&spec=222", token, nil)
assert.Equal(t, float64(0), parseResponse(w)["total"])
// 同一明细命中(系列 MixS + 规格 111 均在明细1)→ 1
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?series=MixS&spec=111", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"])
// 退单状态过滤:把一单标 partial 再按 return_state 筛
var one model.StockInOrder
db.Where("shop_id = ?", shop.ID).First(&one)
db.Model(&model.StockInOrder{}).Where("id = ?", one.ID).Update("return_state", "partial")
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?return_state=partial", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"])
}
func TestStockInHandler_Submit_WrongStatus(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SI003")
@@ -438,9 +526,10 @@ func TestStockInHandler_List_FilterByKeyword(t *testing.T) {
})
require.Equal(t, http.StatusCreated, w.Code)
// 往来单位名搜索 → 只命中单据1
// 往来单位名不再进入 keyword 范围(供应商/客户改由工具栏下拉筛选)→ 搜不到
// (商品是 Brandy,不含「茅台」)
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
assert.Equal(t, float64(0), parseResponse(w)["total"].(float64))
// 按单号搜索 → 只命中单据1
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword="+order1No, token, nil)