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
+90 -4
View File
@@ -156,14 +156,19 @@ func TestStockOutHandler_List_FilterByProductCode(t *testing.T) {
},
})
// 精确命中 1 单
// 命中 1 单
w := makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ-FIND", token, nil)
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
// 不存在的编码 → 0;前缀不算精确(不命中)
// 商品编码改为子串 LIKE(详细搜索友好):子串 ZXZ → 命中 ZXZ-FIND
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ", token, nil)
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
// 不存在的编码 → 0
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=NOPE", token, nil)
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, float64(0), parseResponse(w)["total"].(float64))
}
@@ -199,9 +204,13 @@ func TestStockOutHandler_List_FilterByKeyword(t *testing.T) {
})
require.Equal(t, http.StatusCreated, w.Code)
// 往来单位名搜索
// 往来单位名不再进入 keyword 范围(供应商/客户改由工具栏下拉筛选)→ 搜不到
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=海底捞", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
assert.Equal(t, float64(0), parseResponse(w)["total"].(float64))
// 按酒名搜索(两单都含 Beer
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=Beer", token, nil)
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
// 按单号搜索
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword="+order1No, token, nil)
@@ -460,3 +469,80 @@ func TestStockOutHandler_InventoryLog_OnApprove(t *testing.T) {
assert.Equal(t, float64(100), logs[0].QtyBefore)
assert.Equal(t, float64(80), logs[0].QtyAfter)
}
func TestStockOutHandler_Summary(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SOSUM")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
product := testutil.CreateTestProduct(db, shop.ID, "Wine")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &product.ID, Quantity: 100})
for i := 0; i < 2; i++ {
makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": product.ID, "quantity": 1.0, "unit_price": 5.0, "sale_price": 100.0}},
})
}
var first model.StockOutOrder
db.Where("shop_id = ?", shop.ID).Order("id ASC").First(&first)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", first.ID), token, nil)
w := makeRequest(r, "GET", "/api/v1/stock-out/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(200), s["month_amount"]) // 2 单 × (1×100 售价)
assert.Equal(t, float64(1), s["pending_count"])
}
func TestStockOutHandler_ConfirmSale(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SOCS")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
product := testutil.CreateTestProduct(db, shop.ID, "Whiskey")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &product.ID, Quantity: 100})
// 待定价售价出库单(不传 sale_price → 0
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": product.ID, "quantity": 10.0, "unit_price": 5.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
orderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil)
var item model.StockOutItem
db.Where("order_id = ?", orderID).First(&item)
assert.Equal(t, float64(0), item.SalePrice)
// 确认售价 8
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-out/orders/%d/confirm-sale", orderID), token, map[string]interface{}{
"items": []map[string]interface{}{{"item_id": item.ID, "sale_price": 8.0}},
})
require.Equal(t, http.StatusOK, w.Code)
var got model.StockOutItem
db.First(&got, item.ID)
assert.Equal(t, float64(8), got.SalePrice)
var order model.StockOutOrder
db.First(&order, orderID)
assert.Equal(t, float64(80), order.TotalAmount) // 8 × 10
var fr model.FinanceRecord
require.NoError(t, db.Where("shop_id = ? AND ref_type = ?", shop.ID, "stock_out_sale_adjust").First(&fr).Error)
assert.Equal(t, float64(80), fr.Amount)
// 操作员无权确认售价
op := testutil.CreateTestUser(db, shop.ID, "op1", "pass", "operator")
opToken := getAuthToken(op.ID, shop.ID, "operator")
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-out/orders/%d/confirm-sale", orderID), opToken, map[string]interface{}{
"items": []map[string]interface{}{{"item_id": item.ID, "sale_price": 9.0}},
})
assert.Equal(t, http.StatusForbidden, w.Code)
}