Files
jiu/backend/internal/handler/inventory_test.go
T
wangjia ca7595b113 feat(backend): 出入库汇总近30天滚动窗口 + 跨租户安全加固
- summaryBounds:本月/近30天滚动双口径(stock-in/out Summary ?window=rolling30)
- security(SEC-001):新增 ownership.go ensureShopRef 写入侧防线(stock-in/out/finance/
  盘点建单的 warehouse/partner/product 外键归属校验);读取侧全部 Preload 补
  shop_id 作用域,finance Summary JOIN 补租户条件;回归测试 CrossTenantRefs
- security(SEC-002):release 模式 JWT 密钥为空/默认值时拒绝启动
- gofmt 对齐若干 model/cmd 文件

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

396 lines
15 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"])
}