Files
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

332 lines
11 KiB
Go
Raw Permalink 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 (
"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.ErrDuplicatedKeyCreate/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"])
}