Files
jiu/backend/internal/handler/inventory_test.go
T
wangjia 73955a139c feat(backend): 库存三态落库(stock/on_sale/sold)——挂售可控 + 卖光留痕
- inventories 加 status 枚举列(默认 stock,AutoMigrate 存量即全部「库存」)+ 索引
- 出库审核扣光置 sold 不再软删(留痕台账);退单恢复行置回 stock;盘亏仍软删
- 新接口 PUT /inventory/:id/status(仅收 stock/on_sale,已售行拒改,租户校验)
- List 默认排除已售(显式 status=sold 才显示)+ status 多值筛选
- Summary:SKU/货值/数量排除已售,新增 sold_count
- 公开店铺页/API 只列 on_sale——酒单从全量裸奔改为人工精选
- 测试:卖光置售/部分不改/退单回库/接口边界/跨租户/汇总口径 8 用例
- 设计方案 docs/design/inventory-sale-status.html + db-schema/CLAUDE.md 同步

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

528 lines
22 KiB
Go
Raw 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"
"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)
}