9099c4af99
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
655 lines
26 KiB
Go
655 lines
26 KiB
Go
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")
|
||
find := testutil.CreateTestProduct(db, shop.ID, "ZXZFind") // code=P-ZXZFind
|
||
other := testutil.CreateTestProduct(db, shop.ID, "OtherOne") // code=P-OtherOne
|
||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||
r := setupProtectedRouter(db)
|
||
|
||
// 单1 不传 product_code(真实客户端行为);单2 传假编码,应被主数据覆盖
|
||
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": find.ID, "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": other.ID, "product_code": "FAKE-CODE", "quantity": 1.0},
|
||
},
|
||
})
|
||
|
||
// 按主数据编码命中 1 单
|
||
w := makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=P-ZXZFind", token, nil)
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
|
||
|
||
// 商品编码子串 LIKE(详细搜索友好):子串 ZXZ → 命中 P-ZXZFind
|
||
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))
|
||
|
||
// 客户端传的假编码已被主数据覆盖 → 搜不到
|
||
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=FAKE-CODE", token, nil)
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
assert.Equal(t, float64(0), 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))
|
||
}
|
||
|
||
// 建单回填快照:出库明细的编码/名称/系列/规格/批次/生产日期从商品主数据拷入
|
||
// (修复:此前 Create/Update 不填快照 → 新建单快照全空,按编码搜不到、退单提示空名)
|
||
func TestStockOutHandler_Create_FillsItemSnapshots(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "SO_SNAP")
|
||
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)
|
||
|
||
prodDate := model.Date{Time: time.Date(2024, 7, 11, 0, 0, 0, 0, time.UTC)}
|
||
product := &model.Product{
|
||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||
Code: "ZXZ020659",
|
||
Name: "百富威士忌",
|
||
Series: "12年",
|
||
Spec: "700ml",
|
||
BatchNo: "PZ240711",
|
||
ProductionDate: &prodDate,
|
||
}
|
||
require.NoError(t, db.Create(product).Error)
|
||
|
||
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)
|
||
|
||
var item model.StockOutItem
|
||
require.NoError(t, db.Where("shop_id = ? AND product_id = ?", shop.ID, product.ID).First(&item).Error)
|
||
assert.Equal(t, "ZXZ020659", item.ProductCode)
|
||
assert.Equal(t, "百富威士忌", item.ProductName)
|
||
assert.Equal(t, "12年", item.Series)
|
||
assert.Equal(t, "700ml", item.Spec)
|
||
assert.Equal(t, "PZ240711", item.BatchNo)
|
||
require.NotNil(t, item.ProductionDate)
|
||
assert.Equal(t, "2024-07-11", item.ProductionDate.Time.Format("2006-01-02"))
|
||
|
||
// 引用不存在商品 → 400
|
||
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": 99999, "quantity": 1.0},
|
||
},
|
||
})
|
||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||
}
|
||
|
||
// 存量单(快照为空、product_id 指向真实商品)按主数据编码可搜:
|
||
// keyword 与详细搜索都走「p.code OR it.product_code」双匹配(页面能看到的就能搜到)
|
||
func TestStockOutHandler_List_SearchByMasterCodeWithEmptySnapshot(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "SO_MCODE")
|
||
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)
|
||
|
||
product := &model.Product{
|
||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||
Code: "ZXZ020659",
|
||
Name: "百富威士忌",
|
||
}
|
||
require.NoError(t, db.Create(product).Error)
|
||
|
||
// 直造存量形态:明细快照全空串(修复前应用建单即如此)
|
||
order := &model.StockOutOrder{
|
||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||
OrderNo: "CK20260623000008",
|
||
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: product.ID,
|
||
Quantity: 1.0,
|
||
}).Error)
|
||
|
||
// keyword 按主数据编码 → 命中(修复前为 0)
|
||
w := makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=ZXZ020659", token, nil)
|
||
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
|
||
|
||
// 详细搜索 product_code / product 参数同样命中
|
||
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ020659", token, nil)
|
||
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
|
||
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product=ZXZ020659", token, nil)
|
||
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
|
||
|
||
// 别的编码搜不到
|
||
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=ZXZ999999", token, nil)
|
||
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)
|
||
|
||
// 往来单位名不再进入 keyword 范围(供应商/客户改由工具栏下拉筛选)→ 搜不到
|
||
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=海底捞", token, nil)
|
||
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)
|
||
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)
|
||
}
|
||
|
||
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)
|
||
}
|