Files
jiu/backend/internal/handler/stock_out_test.go
T
wangjia 4fd0bb8b83 feat(backend): 出入库定价字段消歧 + 总利润落库 + 成本仅管理员可见
- 字段重命名(旧列弃用保留,启动幂等回填 BackfillPricingColumns):
  入库 unit_price/total_price/total_amount → cost_price/cost_amount/cost_total;
  出库同前缀 + 新增 sale_amount(售价小计)/ sale_total(应收)/ profit_total(总利润)
- 建单落三值;确认售价联动重算 sale_amount/sale_total/profit_total;
  确认进价回填成本后联动重算受影响出库单利润(recalcStockOutProfit)
- 出库 List/Get 对 operator/readonly 抹零成本与利润(stripStockOutCost 服务端兜底)
- 兼容一版:Create/Update 接受旧 key unit_price 回落(v1.0.87 及之前客户端)
- SUM 聚合/价格趋势/导入/种子工具同步切新列;测试全量改名 + 6 个新回归

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

655 lines
26 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")
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.SaleTotal) // 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)
}