Files
jiu/backend/testutil/setup.go
T
wangjia 73955a139c feat(backend): 库存三态落库(stock/on_sale/sold)——挂售可控 + 卖光留痕
- inventories 加 status 枚举列(默认 stock,AutoMigrate 存量即全部「库存」)+ 索引
- 出库审核扣光置 sold 不再软删(留痕台账);退单恢复行置回 stock;盘亏仍软删
- 新接口 PUT /inventory/:id/status(仅收 stock/on_sale,已售行拒改,租户校验)
- List 默认排除已售(显式 status=sold 才显示)+ status 多值筛选
- Summary:SKU/货值/数量排除已售,新增 sold_count
- 公开店铺页/API 只列 on_sale——酒单从全量裸奔改为人工精选
- 测试:卖光置售/部分不改/退单回库/接口边界/跨租户/汇总口径 8 用例
- 设计方案 docs/design/inventory-sale-status.html + db-schema/CLAUDE.md 同步

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

621 lines
16 KiB
Go

package testutil
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
// InitConfig 初始化测试用配置
func InitConfig() {
config.C = config.Config{
Server: config.ServerConfig{
Port: "8080",
Mode: "test",
},
JWT: config.JWTConfig{
Secret: "test-secret-key-for-testing",
AccessExpireMin: 60,
RefreshExpireH: 168,
},
Session: config.SessionConfig{
LimitTotal: 5,
LimitDesktop: 2,
LimitMobile: 2,
LimitWeb: 2,
MaxFailures: 5,
IPMaxFailures: 20,
LockMinutes: 15,
},
// 测试默认关闭限流,避免压测式用例触发 429;限流逻辑由 ratelimit_test.go 显式开启验证。
RateLimit: config.RateLimitConfig{Enabled: false},
}
}
// SetupTestDB 创建 SQLite in-memory 测试数据库并自动迁移
func SetupTestDB() *gorm.DB {
InitConfig()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
TranslateError: true,
})
if err != nil {
panic(fmt.Sprintf("failed to open sqlite: %v", err))
}
// SQLite 不支持 ENUM,直接用原始 SQL 建表
stmts := []string{
`CREATE TABLE IF NOT EXISTS shops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
name TEXT NOT NULL,
code TEXT UNIQUE,
description TEXT,
address TEXT,
phone TEXT,
manager_name TEXT,
logo_url TEXT DEFAULT '',
wechat_id TEXT DEFAULT '',
business_license TEXT,
business_hours TEXT,
shop_photos TEXT,
custom_fields TEXT
)`,
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
username TEXT,
password_hash TEXT,
real_name TEXT,
phone TEXT,
role TEXT DEFAULT 'operator',
is_active INTEGER DEFAULT 1,
last_login_at DATETIME,
custom_fields TEXT
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_username ON users(shop_id, username)`,
`CREATE TABLE IF NOT EXISTS user_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shop_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
sid TEXT NOT NULL UNIQUE,
device_id TEXT,
device_name TEXT,
platform TEXT,
platform_class TEXT,
ip TEXT,
user_agent TEXT,
refresh_jti TEXT,
created_at DATETIME,
last_seen_at DATETIME,
revoked_at DATETIME,
revoked_reason TEXT,
revoked_by INTEGER,
refresh_exp_at DATETIME,
kind TEXT DEFAULT 'login'
)`,
`CREATE TABLE IF NOT EXISTS web_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket TEXT NOT NULL UNIQUE,
shop_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
used_at DATETIME,
created_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shop_code TEXT,
username TEXT,
ip TEXT,
user_agent TEXT,
success INTEGER DEFAULT 0,
reason TEXT,
created_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
license_key TEXT UNIQUE,
device_id TEXT,
type TEXT DEFAULT 'trial',
tier TEXT DEFAULT 'standard',
expires_at DATETIME,
is_active INTEGER DEFAULT 1,
max_devices INTEGER DEFAULT 3,
features TEXT,
activated_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS license_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
type TEXT DEFAULT 'annual',
tier TEXT DEFAULT 'standard',
duration_days INTEGER NOT NULL DEFAULT 0,
max_devices INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unused',
redeemed_shop_id INTEGER,
redeemed_at DATETIME,
redeemed_device_id TEXT,
batch TEXT,
note TEXT,
created_at DATETIME,
updated_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS license_purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
product_biz_code TEXT NOT NULL,
amount TEXT,
out_trade_no TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
trade_no TEXT,
channel TEXT,
paid_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS license_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id INTEGER NOT NULL,
shop_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
device_name TEXT,
platform TEXT,
activated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(license_id, device_id)
)`,
`CREATE TABLE IF NOT EXISTS product_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
name TEXT NOT NULL,
parent_id INTEGER,
sort_order INTEGER DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
public_id TEXT DEFAULT '',
code TEXT,
barcode TEXT,
name TEXT NOT NULL,
series TEXT,
spec TEXT,
unit TEXT,
category_id INTEGER,
brand TEXT,
purchase_price REAL,
sale_price REAL,
production_date DATETIME,
batch_no TEXT,
min_stock INTEGER DEFAULT 0,
description TEXT,
custom_fields TEXT,
remark TEXT,
name_pinyin TEXT DEFAULT '',
name_initials TEXT DEFAULT '',
origin_id INTEGER,
shelf_life_id INTEGER,
storage_id INTEGER,
description_doc_id INTEGER
)`,
`CREATE TABLE IF NOT EXISTS product_origin_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_shelf_life_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_storage_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_description_docs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
shop_id INTEGER NOT NULL,
url TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS product_name_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_series_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_spec_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
quantity INTEGER DEFAULT 0,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
name TEXT NOT NULL,
location TEXT,
is_default INTEGER DEFAULT 0,
custom_fields TEXT
)`,
`CREATE TABLE IF NOT EXISTS partners (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
name_pinyin TEXT DEFAULT '',
name_initials TEXT DEFAULT '',
type TEXT DEFAULT 'supplier',
status TEXT NOT NULL DEFAULT 'enabled',
contact TEXT,
phone TEXT,
address TEXT,
bank_account TEXT,
credit_limit REAL,
balance REAL DEFAULT 0,
custom_fields TEXT,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS stock_in_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
order_no TEXT,
type TEXT DEFAULT 'purchase',
warehouse_id INTEGER NOT NULL,
partner_id INTEGER,
operator_id INTEGER NOT NULL,
creator_id INTEGER,
reviewer_id INTEGER,
status TEXT DEFAULT 'draft',
order_date DATETIME,
cost_total REAL DEFAULT 0,
reviewed_at DATETIME,
return_state TEXT DEFAULT 'none',
custom_fields TEXT,
remark TEXT
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_sio_order_no ON stock_in_orders(shop_id, order_no)`,
`CREATE TABLE IF NOT EXISTS stock_in_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
order_id INTEGER NOT NULL,
shop_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
product_code TEXT,
product_name TEXT,
series TEXT,
spec TEXT,
quantity REAL NOT NULL,
cost_price REAL DEFAULT 0,
cost_amount REAL DEFAULT 0,
returned_quantity REAL DEFAULT 0,
batch_no TEXT,
production_date DATETIME,
custom_fields TEXT,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS stock_out_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
order_no TEXT,
type TEXT DEFAULT 'sale',
warehouse_id INTEGER NOT NULL,
partner_id INTEGER,
operator_id INTEGER NOT NULL,
creator_id INTEGER,
reviewer_id INTEGER,
status TEXT DEFAULT 'draft',
order_date DATETIME,
sale_total REAL DEFAULT 0,
profit_total REAL DEFAULT 0,
reviewed_at DATETIME,
return_state TEXT DEFAULT 'none',
custom_fields TEXT,
remark TEXT
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_soo_order_no ON stock_out_orders(shop_id, order_no)`,
`CREATE TABLE IF NOT EXISTS stock_out_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
order_id INTEGER NOT NULL,
shop_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
product_code TEXT,
product_name TEXT,
series TEXT,
spec TEXT,
quantity REAL NOT NULL,
cost_price REAL DEFAULT 0,
sale_price REAL DEFAULT 0,
cost_amount REAL DEFAULT 0,
sale_amount REAL DEFAULT 0,
returned_quantity REAL DEFAULT 0,
batch_no TEXT,
production_date DATETIME,
custom_fields TEXT,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS inventories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shop_id INTEGER NOT NULL,
warehouse_id INTEGER,
product_id INTEGER,
stock_in_item_id INTEGER,
inventory_check_id INTEGER,
quantity REAL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'stock',
product_code TEXT DEFAULT '',
product_name TEXT DEFAULT '',
series TEXT DEFAULT '',
spec TEXT DEFAULT '',
unit TEXT DEFAULT '',
warehouse_name TEXT DEFAULT '',
unit_price REAL,
production_date DATETIME,
batch_no TEXT DEFAULT '',
supplier_name TEXT DEFAULT '',
remark TEXT DEFAULT '',
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS inventory_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shop_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
direction TEXT,
quantity REAL,
qty_before REAL,
qty_after REAL,
ref_type TEXT,
ref_id INTEGER,
operator_id INTEGER,
created_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS inventory_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
check_no TEXT,
warehouse_id INTEGER NOT NULL,
operator_id INTEGER NOT NULL,
status TEXT DEFAULT 'draft',
check_date DATETIME,
remark TEXT
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_check_no ON inventory_checks(shop_id, check_no)`,
`CREATE TABLE IF NOT EXISTS inventory_check_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
check_id INTEGER NOT NULL,
shop_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
system_qty REAL,
actual_qty REAL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS finance_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
partner_id INTEGER,
type TEXT,
amount REAL DEFAULT 0,
balance REAL DEFAULT 0,
status TEXT DEFAULT 'open',
ref_type TEXT,
ref_id INTEGER,
operator_id INTEGER,
record_date DATETIME,
remark TEXT,
custom_fields TEXT
)`,
`CREATE TABLE IF NOT EXISTS number_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shop_id INTEGER NOT NULL,
type TEXT,
prefix TEXT DEFAULT '',
current_no INTEGER DEFAULT 0,
date_format TEXT DEFAULT 'YYYYMMDD',
updated_at DATETIME,
UNIQUE(shop_id, type)
)`,
}
for _, stmt := range stmts {
if err := db.Exec(stmt).Error; err != nil {
panic(fmt.Sprintf("failed to create table: %v\nSQL: %s", err, stmt))
}
}
return db
}
// CreateTestShop 创建测试门店
func CreateTestShop(db *gorm.DB, code string) *model.Shop {
shop := &model.Shop{
Name: "Test Shop " + code,
Code: code,
}
if err := db.Create(shop).Error; err != nil {
panic(fmt.Sprintf("failed to create test shop: %v", err))
}
return shop
}
// hashPassword 内部使用的密码哈希函数,避免循环依赖
func hashPassword(plain string) string {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
panic(fmt.Sprintf("failed to hash password: %v", err))
}
return string(b)
}
// CreateTestUser 创建测试用户
func CreateTestUser(db *gorm.DB, shopID uint64, username, password, role string) *model.User {
hash := hashPassword(password)
user := &model.User{
ShopID: shopID,
Username: username,
PasswordHash: hash,
RealName: "Test User " + username,
Role: role,
IsActive: true,
}
if err := db.Create(user).Error; err != nil {
panic(fmt.Sprintf("failed to create test user: %v", err))
}
return user
}
// CreateTestWarehouse 创建测试仓库
func CreateTestWarehouse(db *gorm.DB, shopID uint64, name string) *model.Warehouse {
w := &model.Warehouse{
TenantBase: model.TenantBase{
ShopID: shopID,
},
Name: name,
IsDefault: true,
}
if err := db.Create(w).Error; err != nil {
panic(fmt.Sprintf("failed to create test warehouse: %v", err))
}
return w
}
// CreateTestProduct 创建测试商品
func CreateTestProduct(db *gorm.DB, shopID uint64, name string) *model.Product {
p := &model.Product{
TenantBase: model.TenantBase{
ShopID: shopID,
},
Name: name,
Code: "P-" + name,
Unit: "个",
}
if err := db.Create(p).Error; err != nil {
panic(fmt.Sprintf("failed to create test product: %v", err))
}
return p
}
// GetAuthToken 生成测试 JWT token
func GetAuthToken(userID, shopID uint64, role string) string {
InitConfig()
now := time.Now()
claims := middleware.Claims{
UserID: userID,
ShopID: shopID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).
SignedString([]byte(config.C.JWT.Secret))
if err != nil {
panic(fmt.Sprintf("failed to sign token: %v", err))
}
return token
}