Files
jiu/backend/internal/handler/product_test.go
T
wangjia 8385c23c40 feat: 编号规则支持模式/日期格式,商品编码接入统一编号
- number_rules 新增 mode(date_seq 日期+序号3位按周期重置 / seq 连续6位)与 seq_period 列
- 单号生成按 mode + date_format 拼接,跨日期周期自动重置;启动回填 product→seq
- 商品建号接入 number_rules(seq 模式、前缀可配、从现有最大序列号播种,续发连续编号)
- 设置屏:加「模式」列、当前序号按周期显示(未开单显0);编辑弹窗前缀所有类型可改,
  有日期的序号系统自动(只读置灰)、可改日期格式,商品编码序号可调大不可调小
- 桌面原型 settings 补「编号规则」面板+编辑弹窗、m-settings 改准;atoms 去数字框原生箭头
- 同步 CONTRACT / screens.mjs / db-schema.html 与 settings fidelity 基准

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-09-02 21:30:10 +08:00

356 lines
13 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 (
"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))
}
// 商品编码走 number_rulesseq 模式、默认前缀 SP、6 位、从现有最大序列号播种)。
// 空店最大序列号=0,故从 000001 起连续递增。
assert.Equal(t, []string{"SP000001", "SP000002", "SP000003"}, 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++ { // SP000001 SP000002 SP000003
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)
}
// 软删 SP000003
w := makeRequest(r, "DELETE", fmt.Sprintf("/api/v1/products/%d", lastID), token, nil)
require.Equal(t, http.StatusOK, w.Code)
// 再建 → 必须是 SP000004,不能复用已软删的 SP000003number_rules 计数器只增不回退)
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, "SP000004", data["code"])
}
// 商品编码从「现有最大末尾序列号」播种:既有 ZXZ031114(导入序列号)时,
// 新建自动编码应延续为 SP031115(跨前缀延续数字、6 位),不从 000001 重来、不与既有重号。
func TestProductHandler_AutoCode_SeedsFromMaxSerial(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "AC003")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 预置一个导入风格的序列号商品(最大末尾数字 31114)
require.NoError(t, db.Create(&model.Product{
TenantBase: model.TenantBase{ShopID: shop.ID}, Name: "导入序列号", Code: "ZXZ031114",
}).Error)
w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{
"name": "新商品", "unit": "瓶",
})
require.Equal(t, http.StatusCreated, w.Code)
data := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "SP031115", 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"])
}