Files
wangjia e012f6e02b feat(backend): 库存列表列头排序——库存/成本价/总价/生产日期/入库时间全局排序
sort_by 白名单列映射 SQL 表达式(cost/price/prod_date 与 SELECT 同 COALESCE
口径,排序与显示一致)+ sort_dir,非法值忽略回默认;id 兜底保证分页稳定。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 16:45:38 +08:00

570 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
func TestInventoryHandler_List(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV001")
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)
// 直接插入库存记录
inv := model.Inventory{
ShopID: shop.ID,
WarehouseID: &warehouse.ID,
ProductID: &product.ID,
Quantity: 100,
}
require.NoError(t, db.Create(&inv).Error)
w := makeRequest(r, "GET", "/api/v1/inventory", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
assert.Equal(t, float64(1), resp["total"].(float64))
}
func TestInventoryHandler_List_FilterByWarehouse(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV002")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse1 := testutil.CreateTestWarehouse(db, shop.ID, "W1")
warehouse2 := testutil.CreateTestWarehouse(db, shop.ID, "W2")
product1 := testutil.CreateTestProduct(db, shop.ID, "Beer1")
product2 := testutil.CreateTestProduct(db, shop.ID, "Beer2")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 两个仓库各有一个库存
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse1.ID, ProductID: &product1.ID, Quantity: 10})
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse2.ID, ProductID: &product2.ID, Quantity: 20})
// 按仓库过滤
w := makeRequest(r, "GET", fmt.Sprintf("/api/v1/inventory?warehouse_id=%d", warehouse1.ID), token, nil)
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
assert.Equal(t, float64(1), resp["total"].(float64))
}
func TestInventoryHandler_List_FilterByProduct(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_PID")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh1 := testutil.CreateTestWarehouse(db, shop.ID, "W1")
wh2 := testutil.CreateTestWarehouse(db, shop.ID, "W2")
product1 := testutil.CreateTestProduct(db, shop.ID, "Beer1")
product2 := testutil.CreateTestProduct(db, shop.ID, "Beer2")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// product1 在两个仓库各有库存;product2 一条
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh1.ID, ProductID: &product1.ID, Quantity: 10})
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh2.ID, ProductID: &product1.ID, Quantity: 20})
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh1.ID, ProductID: &product2.ID, Quantity: 5})
// 按商品过滤 → 只返回 product1 的两条(商品详情:各仓库分布)
w := makeRequest(r, "GET", fmt.Sprintf("/api/v1/inventory?product_id=%d&page_size=100", product1.ID), token, nil)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
}
func TestInventoryHandler_List_InStockOnly(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV003")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product1 := testutil.CreateTestProduct(db, shop.ID, "InStock")
product2 := testutil.CreateTestProduct(db, shop.ID, "OutOfStock")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product1.ID, Quantity: 10})
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product2.ID, Quantity: 0})
// 只显示有库存的
w := makeRequest(r, "GET", "/api/v1/inventory?in_stock=1", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
assert.Equal(t, float64(1), resp["total"].(float64))
}
func TestInventoryHandler_Logs(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV004")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Wine")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 创建库存流水
opID := user.ID
db.Create(&model.InventoryLog{
ShopID: shop.ID,
WarehouseID: warehouse.ID,
ProductID: product.ID,
Direction: "in",
Quantity: 10,
QtyBefore: 0,
QtyAfter: 10,
RefType: "stock_in",
RefID: 1,
OperatorID: &opID,
})
w := makeRequest(r, "GET", "/api/v1/inventory/logs", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
assert.Equal(t, float64(1), resp["total"].(float64))
}
func TestInventoryHandler_CreateCheck(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV005")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
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: &warehouse.ID,
ProductID: &product.ID,
Quantity: 50,
})
// 创建盘点单
w := makeRequest(r, "POST", "/api/v1/inventory/checks", token, map[string]interface{}{
"check_no": "CHK001",
"warehouse_id": warehouse.ID,
"check_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{
"product_id": product.ID,
"actual_qty": 48.0,
},
},
})
require.Equal(t, http.StatusCreated, w.Code)
checkID := extractID(w)
assert.NotZero(t, checkID)
data := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "draft", data["status"])
// 检查 system_qty 是否自动填入
items := data["items"].([]interface{})
require.Len(t, items, 1)
item := items[0].(map[string]interface{})
assert.Equal(t, float64(50), item["system_qty"])
}
func TestInventoryHandler_GetCheck(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV006")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Vodka")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 创建盘点单
w := makeRequest(r, "POST", "/api/v1/inventory/checks", token, map[string]interface{}{
"check_no": "CHK002",
"warehouse_id": warehouse.ID,
"check_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_id": product.ID, "actual_qty": 10.0},
},
})
checkID := extractID(w)
// 获取盘点单
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/inventory/checks/%d", checkID), token, nil)
assert.Equal(t, http.StatusOK, w.Code)
data := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "CHK002", data["check_no"])
}
func TestInventoryHandler_GetCheck_NotFound(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV007")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
w := makeRequest(r, "GET", "/api/v1/inventory/checks/99999", token, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestInventoryHandler_List_HotelIsolation(t *testing.T) {
db := testutil.SetupTestDB()
shopA := testutil.CreateTestShop(db, "INV_A")
userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin")
warehouseA := testutil.CreateTestWarehouse(db, shopA.ID, "WA")
productA := testutil.CreateTestProduct(db, shopA.ID, "ProductA")
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
shopB := testutil.CreateTestShop(db, "INV_B")
userB := testutil.CreateTestUser(db, shopB.ID, "adminB", "pass", "admin")
tokenB := getAuthToken(userB.ID, shopB.ID, "admin")
r := setupProtectedRouter(db)
// 酒店 A 有库存
db.Create(&model.Inventory{ShopID: shopA.ID, WarehouseID: &warehouseA.ID, ProductID: &productA.ID, Quantity: 100})
// 酒店 A 能看到自己的库存
w := makeRequest(r, "GET", "/api/v1/inventory", tokenA, nil)
respA := parseResponse(w)
assert.Equal(t, float64(1), respA["total"].(float64))
// 酒店 B 看不到酒店 A 的库存
w = makeRequest(r, "GET", "/api/v1/inventory", tokenB, nil)
respB := parseResponse(w)
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))
}
// 商品编码独立筛选(?code=,库存屏第二个搜索框)
func TestInventoryHandler_List_FilterByCode(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_CODE")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product1 := testutil.CreateTestProduct(db, shop.ID, "Beer1") // code=P-Beer1
product2 := testutil.CreateTestProduct(db, shop.ID, "Wine2") // code=P-Wine2
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product1.ID, Quantity: 10})
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product2.ID, Quantity: 20})
// 精确前缀命中一条
w := makeRequest(r, "GET", "/api/v1/inventory?code=P-Beer1", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
// 模糊命中两条
w = makeRequest(r, "GET", "/api/v1/inventory?code=P-", token, nil)
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
// 无命中
w = makeRequest(r, "GET", "/api/v1/inventory?code=NOPE", token, nil)
assert.Equal(t, float64(0), parseResponse(w)["total"].(float64))
}
// Summary 月初快照字段:月初数量 = 当前数量 − 本月净流水
func TestInventoryHandler_Summary_LastMonth(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_SUM")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product1 := testutil.CreateTestProduct(db, shop.ID, "Old")
product2 := testutil.CreateTestProduct(db, shop.ID, "New")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
now := time.Now()
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
price1 := 100.0
// inv1:上月已存在(月初数量 = 30 − (25−5) = 10
inv1 := model.Inventory{
ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product1.ID,
Quantity: 30, UnitPrice: &price1,
CreatedAt: monthStart.AddDate(0, 0, -10),
}
require.NoError(t, db.Create(&inv1).Error)
// 本月流水:入 25、出 5 → 净 +20
db.Create(&model.InventoryLog{ShopID: shop.ID, WarehouseID: warehouse.ID, ProductID: product1.ID, Direction: "in", Quantity: 25})
db.Create(&model.InventoryLog{ShopID: shop.ID, WarehouseID: warehouse.ID, ProductID: product1.ID, Direction: "out", Quantity: 5})
// inv2:本月新入库,不计入月初快照
price2 := 10.0
db.Create(&model.Inventory{
ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product2.ID,
Quantity: 5, UnitPrice: &price2,
})
db.Create(&model.InventoryLog{ShopID: shop.ID, WarehouseID: warehouse.ID, ProductID: product2.ID, Direction: "in", Quantity: 5})
w := makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
assert.Equal(t, float64(2), resp["sku_count"].(float64))
assert.Equal(t, float64(35), resp["in_stock_qty"].(float64))
assert.Equal(t, float64(30*100+5*10), resp["stock_value"].(float64))
assert.Equal(t, float64(1), resp["last_month_sku"].(float64))
assert.Equal(t, float64(10), resp["last_month_qty"].(float64))
assert.Equal(t, float64(10*100), resp["last_month_value"].(float64))
}
func TestInventoryHandler_AfterStockInApprove(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV008")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Champagne")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 初始库存为 0
w := makeRequest(r, "GET", "/api/v1/inventory", token, nil)
assert.Equal(t, float64(0), parseResponse(w)["total"].(float64))
// 入库流程
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": 20.0, "unit_price": 15.0},
},
})
orderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), token, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", orderID), token, nil)
// 检查库存
w = makeRequest(r, "GET", "/api/v1/inventory", token, nil)
resp := parseResponse(w)
assert.Equal(t, float64(1), resp["total"].(float64))
data := resp["data"].([]interface{})
invItem := data[0].(map[string]interface{})
assert.Equal(t, float64(20), invItem["quantity"])
}
// 库存成本仅管理员可见(stripInventoryCost):operator/readonly 的 List unit_price 抹空、
// Summary 货值抹零;admin 原样返回。与出库 stripStockOutCost 同口径。
func TestInventoryHandler_CostVisibility(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_COST")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
operator := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
readonly := testutil.CreateTestUser(db, shop.ID, "ro", "pass", "readonly")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Beer")
r := setupProtectedRouter(db)
price := 100.0
require.NoError(t, db.Create(&model.Inventory{
ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID,
Quantity: 10, UnitPrice: &price,
}).Error)
// adminunit_price / stock_value 原样可见
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
w := makeRequest(r, "GET", "/api/v1/inventory", adminToken, nil)
assert.Equal(t, http.StatusOK, w.Code)
row := parseResponse(w)["data"].([]interface{})[0].(map[string]interface{})
assert.Equal(t, float64(100), row["unit_price"])
w = makeRequest(r, "GET", "/api/v1/inventory/summary", adminToken, nil)
assert.Equal(t, float64(1000), parseResponse(w)["stock_value"].(float64))
// operator / readonlyunit_price 抹空、货值抹零,其余字段不受影响
for _, u := range []struct {
id uint64
role string
}{{operator.ID, "operator"}, {readonly.ID, "readonly"}} {
token := getAuthToken(u.id, shop.ID, u.role)
w = makeRequest(r, "GET", "/api/v1/inventory", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
row = parseResponse(w)["data"].([]interface{})[0].(map[string]interface{})
assert.Nil(t, row["unit_price"], "role=%s unit_price 应被抹空", u.role)
assert.Equal(t, float64(10), row["quantity"])
w = makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil)
resp := parseResponse(w)
assert.Equal(t, float64(0), resp["stock_value"].(float64), "role=%s 货值应抹零", u.role)
assert.Equal(t, float64(0), resp["last_month_value"].(float64))
assert.Equal(t, float64(1), resp["sku_count"].(float64))
assert.Equal(t, float64(10), resp["in_stock_qty"].(float64))
}
}
// 库存三态(2026-07-07):List 默认排除已售、status 筛选、切换接口、Summary sold_count。
func TestInventoryHandler_StatusFlow(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_ST")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
p1 := testutil.CreateTestProduct(db, shop.ID, "库存酒")
p2 := testutil.CreateTestProduct(db, shop.ID, "在售酒")
p3 := testutil.CreateTestProduct(db, shop.ID, "已售酒")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
price := 50.0
invStock := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p1.ID, Quantity: 3, UnitPrice: &price, Status: "stock"}
invSale := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p2.ID, Quantity: 2, UnitPrice: &price, Status: "on_sale"}
invSold := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p3.ID, Quantity: 0, UnitPrice: &price, Status: "sold"}
require.NoError(t, db.Create(&invStock).Error)
require.NoError(t, db.Create(&invSale).Error)
require.NoError(t, db.Create(&invSold).Error)
// List 默认:不含已售(2 行)
w := makeRequest(r, "GET", "/api/v1/inventory", token, nil)
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
// status=sold:只看已售
w = makeRequest(r, "GET", "/api/v1/inventory?status=sold", token, nil)
resp := parseResponse(w)
assert.Equal(t, float64(1), resp["total"].(float64))
row := resp["data"].([]interface{})[0].(map[string]interface{})
assert.Equal(t, "sold", row["status"])
// status 多值
w = makeRequest(r, "GET", "/api/v1/inventory?status=stock,on_sale,sold", token, nil)
assert.Equal(t, float64(3), parseResponse(w)["total"].(float64))
// 切换:stock → on_sale
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invStock.ID), token,
map[string]interface{}{"status": "on_sale"})
assert.Equal(t, http.StatusOK, w.Code)
var got model.Inventory
require.NoError(t, db.Where("id = ?", invStock.ID).First(&got).Error)
assert.Equal(t, "on_sale", got.Status)
// 拒收 sold 值
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invSale.ID), token,
map[string]interface{}{"status": "sold"})
assert.Equal(t, http.StatusBadRequest, w.Code)
// 已售行不可改
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invSold.ID), token,
map[string]interface{}{"status": "stock"})
assert.Equal(t, http.StatusNotFound, w.Code)
// Summarysku_count 排除已售、sold_count=1、货值只算未售
w = makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil)
sm := parseResponse(w)
assert.Equal(t, float64(2), sm["sku_count"].(float64))
assert.Equal(t, float64(1), sm["sold_count"].(float64))
assert.Equal(t, float64(3*50+2*50), sm["stock_value"].(float64))
assert.Equal(t, float64(0), sm["shortage_count"].(float64), "sold 不计入缺货")
}
// 跨租户:不能改他店库存状态
func TestInventoryHandler_StatusTenantIsolation(t *testing.T) {
db := testutil.SetupTestDB()
shopA := testutil.CreateTestShop(db, "INV_STA")
shopB := testutil.CreateTestShop(db, "INV_STB")
userA := testutil.CreateTestUser(db, shopA.ID, "admin", "pass", "admin")
whB := testutil.CreateTestWarehouse(db, shopB.ID, "仓B")
pB := testutil.CreateTestProduct(db, shopB.ID, "他店酒")
invB := model.Inventory{ShopID: shopB.ID, WarehouseID: &whB.ID, ProductID: &pB.ID, Quantity: 1, Status: "stock"}
require.NoError(t, db.Create(&invB).Error)
r := setupProtectedRouter(db)
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
w := makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invB.ID), tokenA,
map[string]interface{}{"status": "on_sale"})
assert.Equal(t, http.StatusNotFound, w.Code)
var got model.Inventory
require.NoError(t, db.Where("id = ?", invB.ID).First(&got).Error)
assert.Equal(t, "stock", got.Status)
}
// 列头排序(2026-07-07):白名单列全局排序,非法列忽略回默认
func TestInventoryHandler_ListSort(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_SORT")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
for i, qty := range []float64{5, 20, 10} {
p := testutil.CreateTestProduct(db, shop.ID, fmt.Sprintf("酒%d", i))
price := float64(100 * (i + 1))
require.NoError(t, db.Create(&model.Inventory{
ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p.ID,
Quantity: qty, UnitPrice: &price,
}).Error)
}
qtys := func(w *httptest.ResponseRecorder) []float64 {
rows := parseResponse(w)["data"].([]interface{})
out := make([]float64, len(rows))
for i, r := range rows {
out[i] = r.(map[string]interface{})["quantity"].(float64)
}
return out
}
// qty 升序
w := makeRequest(r, "GET", "/api/v1/inventory?sort_by=qty&sort_dir=asc", token, nil)
assert.Equal(t, []float64{5, 10, 20}, qtys(w))
// qty 降序
w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=qty&sort_dir=desc", token, nil)
assert.Equal(t, []float64{20, 10, 5}, qtys(w))
// 总价 = qty×进价:5×100=500, 20×200=4000, 10×300=3000 → 升序 5,10,20
w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=price&sort_dir=asc", token, nil)
assert.Equal(t, []float64{5, 10, 20}, qtys(w))
// 非法列忽略 → 默认 id 倒序(最后插入的 qty=10 在前)
w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=evil;drop", token, nil)
assert.Equal(t, []float64{10, 20, 5}, qtys(w))
}