Files
jiu/backend/internal/handler/stock_out_test.go
T
wangjia 5ff41cc505
Deploy Server / release-deploy-server (push) Successful in 1m54s
chore: release server-v1.0.81
修复入库单/出库单按商品名搜不到历史导入单:搜索酒名条件由 COALESCE(被占位名劫持) 改为 p.name OR it.product_name 任一命中,并加回归测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-24 11:56:21 +08:00

463 lines
17 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 TestStockOutHandler_FullFlow(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO001")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Main")
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: 100,
})
// 1. 创建出库单
w := makeRequest(r, "POST", "/api/v1/stock-out/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": 15.0,
"unit_price": 20.0,
},
},
})
require.Equal(t, http.StatusCreated, w.Code)
orderID := extractID(w)
assert.NotZero(t, orderID)
data := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "draft", data["status"])
assert.NotEmpty(t, data["order_no"])
// 2. 提交
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil)
require.Equal(t, http.StatusOK, w.Code)
// 3. 获取详情
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), token, nil)
require.Equal(t, http.StatusOK, w.Code)
detailData := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "pending", detailData["status"])
// 4. 审核通过
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil)
require.Equal(t, http.StatusOK, w.Code)
// 5. 验证库存减少
var inv model.Inventory
db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
shop.ID, warehouse.ID, product.ID).First(&inv)
assert.Equal(t, float64(85), inv.Quantity)
}
func TestStockOutHandler_InsufficientStock(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO002")
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)
// 库存只有 5
db.Create(&model.Inventory{
ShopID: shop.ID,
WarehouseID: &warehouse.ID,
ProductID: &product.ID,
Quantity: 5,
})
// 创建出库单要出 10
w := makeRequest(r, "POST", "/api/v1/stock-out/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": 10.0},
},
})
orderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil)
// 审核应该失败
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestStockOutHandler_List(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO003")
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)
// 先建立库存
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 100})
// 创建 2 个出库单
for i := 0; i < 2; i++ {
makeRequest(r, "POST", "/api/v1/stock-out/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": 1.0},
},
})
}
w := makeRequest(r, "GET", "/api/v1/stock-out/orders", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
assert.Equal(t, float64(2), resp["total"].(float64))
}
// 按商品编码精确反查出库单(明细 product_code = ?
func TestStockOutHandler_List_FilterByProductCode(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO_CODE")
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)
makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_id": product.ID, "product_code": "ZXZ-FIND", "quantity": 1.0},
},
})
makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_id": product.ID, "product_code": "OTHER-CODE", "quantity": 1.0},
},
})
// 精确命中 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;前缀不算精确(不命中)
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(0), parseResponse(w)["total"].(float64))
}
func TestStockOutHandler_List_FilterByKeyword(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO011")
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)
// 往来单位:海底捞客户
partner := &model.Partner{Code: "C001", Name: "海底捞客户", Type: "customer"}
partner.ShopID = shop.ID
require.NoError(t, db.Create(partner).Error)
// 单据1:挂往来单位
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID,
"partner_id": partner.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": product.ID, "quantity": 1.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
order1No := parseResponse(w)["data"].(map[string]interface{})["order_no"].(string)
// 单据2:无往来单位
w = makeRequest(r, "POST", "/api/v1/stock-out/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": 1.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
// 按往来单位名搜索
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=海底捞", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
// 按单号搜索
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword="+order1No, token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
// 无关键词
w = makeRequest(r, "GET", "/api/v1/stock-out/orders", token, nil)
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
}
func TestStockOutHandler_List_FilterByProductName(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO012")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
maotai := testutil.CreateTestProduct(db, shop.ID, "飞天茅台")
lafei := testutil.CreateTestProduct(db, shop.ID, "拉菲红酒")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": maotai.ID, "quantity": 1.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
w = makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": lafei.ID, "quantity": 1.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
// 按酒名模糊反查 → 各命中 1 单
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=茅台", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=红酒", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
// 无关键词 → 两单都在
w = makeRequest(r, "GET", "/api/v1/stock-out/orders", token, nil)
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
}
// 回归:历史导入出库单的明细 product_id 指向占位商品「历史导入占位」,真实酒名只在快照列。
// 修复前 COALESCE 被占位名劫持搜不到,修复后「p.name OR product_name」任一命中。
func TestStockOutHandler_List_FilterByImportedSnapshotName(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO012B")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
placeholder := &model.Product{
TenantBase: model.TenantBase{ShopID: shop.ID},
Name: "历史导入占位",
Code: "HIST-PLACEHOLDER",
}
require.NoError(t, db.Create(placeholder).Error)
order := &model.StockOutOrder{
TenantBase: model.TenantBase{ShopID: shop.ID},
OrderNo: "CK-IMPORT-001",
WarehouseID: warehouse.ID,
OperatorID: user.ID,
Status: "approved",
OrderDate: model.Date{Time: time.Now()},
}
require.NoError(t, db.Create(order).Error)
require.NoError(t, db.Create(&model.StockOutItem{
OrderID: order.ID,
ShopID: shop.ID,
ProductID: placeholder.ID,
ProductCode: "ZXZ000010",
ProductName: "飞天茅台",
Quantity: 5.0,
}).Error)
// 按真实酒名搜 → 命中导入单(修复前为 0)
w := makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=茅台", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
}
func TestStockOutHandler_Reject(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO004")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Rum")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 100})
w := makeRequest(r, "POST", "/api/v1/stock-out/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": 5.0},
},
})
orderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil)
// 驳回
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/reject", orderID), token, nil)
assert.Equal(t, http.StatusOK, w.Code)
// 验证状态
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), token, nil)
detailData := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "rejected", detailData["status"])
}
func TestStockOutHandler_GetNotFound(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO005")
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/stock-out/orders/99999", token, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestStockOutHandler_NoAuth(t *testing.T) {
db := testutil.SetupTestDB()
r := setupProtectedRouter(db)
w := makeRequest(r, "GET", "/api/v1/stock-out/orders", "", nil)
assert.Equal(t, http.StatusUnauthorized, w.Code)
w = makeRequest(r, "POST", "/api/v1/stock-out/orders", "", map[string]interface{}{
"warehouse_id": 1,
})
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestStockOutHandler_TenantIsolation(t *testing.T) {
db := testutil.SetupTestDB()
shopA := testutil.CreateTestShop(db, "SO_A")
userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin")
warehouseA := testutil.CreateTestWarehouse(db, shopA.ID, "WA")
productA := testutil.CreateTestProduct(db, shopA.ID, "BrandyA")
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
shopB := testutil.CreateTestShop(db, "SO_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: 50,
})
// 门店 A 创建出库单
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", tokenA, map[string]interface{}{
"warehouse_id": warehouseA.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_id": productA.ID, "quantity": 5.0},
},
})
require.Equal(t, http.StatusCreated, w.Code)
orderAID := extractID(w)
// 门店 B 看不到门店 A 的出库单
w = makeRequest(r, "GET", "/api/v1/stock-out/orders", tokenB, nil)
resp := parseResponse(w)
assert.Equal(t, float64(0), resp["total"].(float64))
// 门店 B 不能获取门店 A 的出库单详情
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderAID), tokenB, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
// 门店 B 不能提交门店 A 的出库单
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderAID), tokenB, nil)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestStockOutHandler_Create_MissingWarehouse(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO006")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 缺少 warehouse_id(必填)
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"order_date": time.Now().Format(time.RFC3339),
})
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestStockOutHandler_Approve_NotPending(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO007")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Cognac")
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/stock-out/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": 5.0},
},
})
orderID := extractID(w)
// 直接审核(应该失败,因为是 draft 状态)
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestStockOutHandler_InventoryLog_OnApprove(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO008")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Moutai")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 100})
// 创建、提交、审核
w := makeRequest(r, "POST", "/api/v1/stock-out/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}},
})
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 logs []model.InventoryLog
db.Where("shop_id = ? AND product_id = ? AND direction = 'out'", shop.ID, product.ID).Find(&logs)
require.Len(t, logs, 1)
assert.Equal(t, float64(20), logs[0].Quantity)
assert.Equal(t, float64(100), logs[0].QtyBefore)
assert.Equal(t, float64(80), logs[0].QtyAfter)
}