4fd0bb8b83
- 字段重命名(旧列弃用保留,启动幂等回填 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
332 lines
11 KiB
Go
332 lines
11 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/wangjia/jiu/backend/internal/model"
|
||
"github.com/wangjia/jiu/backend/testutil"
|
||
)
|
||
|
||
func TestProductHandler_CRUD(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "PROD001")
|
||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||
r := setupProtectedRouter(db)
|
||
|
||
// 1. Create
|
||
w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{
|
||
"name": "Test Beer",
|
||
"code": "BEER001",
|
||
"unit": "瓶",
|
||
})
|
||
assert.Equal(t, http.StatusCreated, w.Code)
|
||
productID := extractID(w)
|
||
assert.NotZero(t, productID)
|
||
|
||
// 2. List
|
||
w = makeRequest(r, "GET", "/api/v1/products", token, nil)
|
||
assert.Equal(t, http.StatusOK, w.Code)
|
||
resp := parseResponse(w)
|
||
total := resp["total"].(float64)
|
||
assert.Equal(t, float64(1), total)
|
||
|
||
// 3. Update
|
||
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/products/%d", productID), token, map[string]interface{}{
|
||
"name": "Updated Beer",
|
||
"code": "BEER001",
|
||
"unit": "瓶",
|
||
})
|
||
assert.Equal(t, http.StatusOK, w.Code)
|
||
updatedData := parseResponse(w)["data"].(map[string]interface{})
|
||
assert.Equal(t, "Updated Beer", updatedData["name"])
|
||
|
||
// 4. List with keyword
|
||
w = makeRequest(r, "GET", "/api/v1/products?keyword=Updated", token, nil)
|
||
assert.Equal(t, http.StatusOK, w.Code)
|
||
resp = parseResponse(w)
|
||
assert.Equal(t, float64(1), resp["total"].(float64))
|
||
|
||
// 5. Delete (soft)
|
||
w = makeRequest(r, "DELETE", fmt.Sprintf("/api/v1/products/%d", productID), token, nil)
|
||
assert.Equal(t, http.StatusOK, w.Code)
|
||
|
||
// 6. List after delete - should be 0
|
||
w = makeRequest(r, "GET", "/api/v1/products", token, nil)
|
||
resp = parseResponse(w)
|
||
assert.Equal(t, float64(0), resp["total"].(float64))
|
||
}
|
||
|
||
// 拼音搜索(原型口径:商品名 / 拼音 / 编码):List keyword 匹配 name_pinyin/name_initials
|
||
func TestProductHandler_List_PinyinSearch(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "PROD_PY")
|
||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||
r := setupProtectedRouter(db)
|
||
|
||
w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{
|
||
"name": "飞天茅台", "code": "MT-FT-500", "unit": "件",
|
||
})
|
||
require.Equal(t, http.StatusCreated, w.Code)
|
||
|
||
for kw, want := range map[string]float64{
|
||
"feitian": 1, // 全拼
|
||
"ftmt": 1, // 首字母
|
||
"MT-FT": 1, // 编码
|
||
"wuliang": 0,
|
||
} {
|
||
w := makeRequest(r, "GET", "/api/v1/products?keyword="+kw, token, nil)
|
||
assert.Equal(t, want, parseResponse(w)["total"].(float64), "keyword=%s", kw)
|
||
}
|
||
}
|
||
|
||
func TestProductHandler_HotelIsolation(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
|
||
// 酒店 A
|
||
shopA := testutil.CreateTestShop(db, "ISOL_A")
|
||
userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin")
|
||
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
|
||
|
||
// 酒店 B
|
||
shopB := testutil.CreateTestShop(db, "ISOL_B")
|
||
userB := testutil.CreateTestUser(db, shopB.ID, "adminB", "pass", "admin")
|
||
tokenB := getAuthToken(userB.ID, shopB.ID, "admin")
|
||
|
||
r := setupProtectedRouter(db)
|
||
|
||
// 酒店 A 创建商品
|
||
w := makeRequest(r, "POST", "/api/v1/products", tokenA, map[string]interface{}{
|
||
"name": "Hotel A Beer",
|
||
"unit": "瓶",
|
||
})
|
||
require.Equal(t, http.StatusCreated, w.Code)
|
||
productAID := extractID(w)
|
||
|
||
// 酒店 B 创建商品
|
||
w = makeRequest(r, "POST", "/api/v1/products", tokenB, map[string]interface{}{
|
||
"name": "Hotel B Wine",
|
||
"unit": "瓶",
|
||
})
|
||
require.Equal(t, http.StatusCreated, w.Code)
|
||
|
||
// 酒店 A 只能看到自己的商品
|
||
w = makeRequest(r, "GET", "/api/v1/products", tokenA, nil)
|
||
respA := parseResponse(w)
|
||
assert.Equal(t, float64(1), respA["total"].(float64))
|
||
dataA := respA["data"].([]interface{})
|
||
assert.Equal(t, "Hotel A Beer", dataA[0].(map[string]interface{})["name"])
|
||
|
||
// 酒店 B 只能看到自己的商品
|
||
w = makeRequest(r, "GET", "/api/v1/products", tokenB, nil)
|
||
respB := parseResponse(w)
|
||
assert.Equal(t, float64(1), respB["total"].(float64))
|
||
dataB := respB["data"].([]interface{})
|
||
assert.Equal(t, "Hotel B Wine", dataB[0].(map[string]interface{})["name"])
|
||
|
||
// 酒店 B 不能修改酒店 A 的商品
|
||
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/products/%d", productAID), tokenB, map[string]interface{}{
|
||
"name": "Hacked Product",
|
||
})
|
||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||
|
||
// 酒店 B 不能删除酒店 A 的商品
|
||
w = makeRequest(r, "DELETE", fmt.Sprintf("/api/v1/products/%d", productAID), tokenB, nil)
|
||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||
}
|
||
|
||
func TestProductHandler_NoAuth(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
r := setupProtectedRouter(db)
|
||
|
||
// 无 token 访问
|
||
w := makeRequest(r, "GET", "/api/v1/products", "", nil)
|
||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||
}
|
||
|
||
func TestProductHandler_List_Pagination(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "PROD002")
|
||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||
r := setupProtectedRouter(db)
|
||
|
||
// 创建 5 个商品
|
||
for i := 1; i <= 5; i++ {
|
||
makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{
|
||
"name": fmt.Sprintf("Product %d", i),
|
||
"unit": "个",
|
||
})
|
||
}
|
||
|
||
// 每页 2 个,第 1 页
|
||
w := makeRequest(r, "GET", "/api/v1/products?page=1&page_size=2", token, nil)
|
||
assert.Equal(t, http.StatusOK, w.Code)
|
||
resp := parseResponse(w)
|
||
data := resp["data"].([]interface{})
|
||
assert.Len(t, data, 2)
|
||
assert.Equal(t, float64(5), resp["total"].(float64))
|
||
}
|
||
|
||
func TestProductHandler_UpdateNotFound(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "PROD003")
|
||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||
r := setupProtectedRouter(db)
|
||
|
||
w := makeRequest(r, "PUT", "/api/v1/products/99999", token, map[string]interface{}{
|
||
"name": "Nonexistent",
|
||
})
|
||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||
}
|
||
|
||
func TestProductHandler_DeleteNotFound(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "PROD004")
|
||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||
r := setupProtectedRouter(db)
|
||
|
||
w := makeRequest(r, "DELETE", "/api/v1/products/99999", token, nil)
|
||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||
}
|
||
|
||
func TestProductHandler_Create_ShopIDFromToken(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "PROD005")
|
||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||
r := setupProtectedRouter(db)
|
||
|
||
// 尝试在请求体中传入不同的 shop_id
|
||
w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{
|
||
"name": "Test Product",
|
||
"shop_id": 9999, // 尝试注入其他门店 ID
|
||
"unit": "个",
|
||
})
|
||
require.Equal(t, http.StatusCreated, w.Code)
|
||
resp := parseResponse(w)
|
||
data := resp["data"].(map[string]interface{})
|
||
|
||
// shop_id 应该是从 token 中获取的,而不是请求体中的
|
||
createdShopID := uint64(data["shop_id"].(float64))
|
||
assert.Equal(t, shop.ID, createdShopID)
|
||
|
||
// 反序列化验证
|
||
dataBytes, _ := json.Marshal(data)
|
||
_ = dataBytes
|
||
}
|
||
|
||
// 自动编码按最大序号递增:P001 → P002 → P003。
|
||
func TestProductHandler_AutoCode_Increment(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "AC001")
|
||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||
r := setupProtectedRouter(db)
|
||
|
||
var codes []string
|
||
for i := 0; i < 3; i++ {
|
||
w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{
|
||
"name": fmt.Sprintf("AutoP %d", i), "unit": "个",
|
||
})
|
||
require.Equal(t, http.StatusCreated, w.Code)
|
||
data := parseResponse(w)["data"].(map[string]interface{})
|
||
codes = append(codes, data["code"].(string))
|
||
}
|
||
assert.Equal(t, []string{"P001", "P002", "P003"}, codes)
|
||
}
|
||
|
||
// 软删商品后,新建不复用被删的号(旧 count+1 逻辑会复用 → 重复,此为根因修复回归测试)。
|
||
func TestProductHandler_AutoCode_NoReuseAfterSoftDelete(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "AC002")
|
||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||
r := setupProtectedRouter(db)
|
||
|
||
var lastID uint64
|
||
for i := 0; i < 3; i++ { // P001 P002 P003
|
||
w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{
|
||
"name": fmt.Sprintf("NR %d", i), "unit": "个",
|
||
})
|
||
require.Equal(t, http.StatusCreated, w.Code)
|
||
lastID = extractID(w)
|
||
}
|
||
// 软删 P003
|
||
w := makeRequest(r, "DELETE", fmt.Sprintf("/api/v1/products/%d", lastID), token, nil)
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
|
||
// 再建 → 必须是 P004,不能复用已软删的 P003
|
||
w = makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{
|
||
"name": "NR new", "unit": "个",
|
||
})
|
||
require.Equal(t, http.StatusCreated, w.Code)
|
||
data := parseResponse(w)["data"].(map[string]interface{})
|
||
assert.Equal(t, "P004", data["code"])
|
||
}
|
||
|
||
// (shop_id, code) 唯一约束生效,且重复被翻译成 gorm.ErrDuplicatedKey(Create/FindOrCreate 重试的前提)。
|
||
func TestProductHandler_UniqueShopCode(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "UQ001")
|
||
require.NoError(t, db.Exec("CREATE UNIQUE INDEX uk_shop_code ON products(shop_id, code)").Error)
|
||
|
||
p1 := model.Product{TenantBase: model.TenantBase{ShopID: shop.ID}, Name: "A", Code: "P001"}
|
||
require.NoError(t, db.Create(&p1).Error)
|
||
|
||
p2 := model.Product{TenantBase: model.TenantBase{ShopID: shop.ID}, Name: "B", Code: "P001"}
|
||
err := db.Create(&p2).Error
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, gorm.ErrDuplicatedKey))
|
||
}
|
||
|
||
func TestProductHandler_PriceHistory(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "PH001")
|
||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
|
||
product := testutil.CreateTestProduct(db, shop.ID, "Maotai")
|
||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||
r := setupProtectedRouter(db)
|
||
|
||
mkOrder := func(no string, daysAgo int, price float64, status string) {
|
||
o := &model.StockInOrder{
|
||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||
OrderNo: no,
|
||
WarehouseID: wh.ID,
|
||
OperatorID: user.ID,
|
||
Status: status,
|
||
OrderDate: model.Date{Time: time.Now().AddDate(0, 0, -daysAgo)},
|
||
}
|
||
require.NoError(t, db.Create(o).Error)
|
||
require.NoError(t, db.Create(&model.StockInItem{
|
||
OrderID: o.ID, ShopID: shop.ID, ProductID: product.ID,
|
||
CostPrice: price, Quantity: 1,
|
||
}).Error)
|
||
}
|
||
mkOrder("RK-PH-1", 60, 2580, "approved")
|
||
mkOrder("RK-PH-2", 30, 2620, "approved")
|
||
mkOrder("RK-PH-3", 5, 2680, "approved")
|
||
mkOrder("RK-PH-4", 1, 9999, "draft") // 草稿不计入
|
||
|
||
w := makeRequest(r, "GET",
|
||
fmt.Sprintf("/api/v1/products/%d/price-history", product.ID), token, nil)
|
||
assert.Equal(t, http.StatusOK, w.Code)
|
||
data := parseResponse(w)["data"].([]interface{})
|
||
assert.Len(t, data, 3) // 三个已审核不同价;草稿排除
|
||
// 倒序:最新价在前
|
||
assert.Equal(t, float64(2680), data[0].(map[string]interface{})["price"])
|
||
}
|