fix(backend): JWT config mapstructure tag 修复 + 模型从 hotel 重构为 shop
- 修复 JWTConfig 缺少 mapstructure tag 导致 access_expire_min 解析为 0, token 签发即过期,所有 API 请求返回 401 - 全部 config struct 补齐 mapstructure tag(secret/dsn/hmac_secret 等) - 模型层从 hotel/HotelID 统一重命名为 shop/ShopID - 删除旧 migrations(001-004),新增 001_init 综合迁移文件 - 更新 schema.sql、testutil、handler/service/model 相关引用 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,17 +30,18 @@ type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"` // 秒
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
}
|
||||
|
||||
// Login 账号密码登录
|
||||
func (s *AuthService) Login(hotelCode, username, password string) (*TokenPair, *model.User, error) {
|
||||
var hotel model.Hotel
|
||||
if err := s.db.Where("code = ?", hotelCode).First(&hotel).Error; err != nil {
|
||||
func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *model.User, error) {
|
||||
var shop model.Shop
|
||||
if err := s.db.Where("code = ?", shopCode).First(&shop).Error; err != nil {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.Where("hotel_id = ? AND username = ? AND deleted_at IS NULL", hotel.ID, username).
|
||||
if err := s.db.Where("shop_id = ? AND username = ? AND deleted_at IS NULL", shop.ID, username).
|
||||
First(&user).Error; err != nil {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
@@ -53,7 +54,7 @@ func (s *AuthService) Login(hotelCode, username, password string) (*TokenPair, *
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
pair, err := s.issueTokens(user.ID, hotel.ID, user.Role)
|
||||
pair, err := s.issueTokens(user.ID, shop.ID, user.Role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -75,18 +76,18 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||
if err != nil || !token.Valid {
|
||||
return nil, errors.New("invalid refresh token")
|
||||
}
|
||||
return s.issueTokens(claims.UserID, claims.HotelID, claims.Role)
|
||||
return s.issueTokens(claims.UserID, claims.ShopID, claims.Role)
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTokens(userID, hotelID uint64, role string) (*TokenPair, error) {
|
||||
func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPair, error) {
|
||||
cfg := config.C.JWT
|
||||
now := time.Now()
|
||||
|
||||
accessExp := now.Add(time.Duration(cfg.AccessExpireMin) * time.Minute)
|
||||
accessClaims := middleware.Claims{
|
||||
UserID: userID,
|
||||
HotelID: hotelID,
|
||||
Role: role,
|
||||
UserID: userID,
|
||||
ShopID: shopID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(accessExp),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
@@ -99,9 +100,9 @@ func (s *AuthService) issueTokens(userID, hotelID uint64, role string) (*TokenPa
|
||||
|
||||
refreshExp := now.Add(time.Duration(cfg.RefreshExpireH) * time.Hour)
|
||||
refreshClaims := middleware.Claims{
|
||||
UserID: userID,
|
||||
HotelID: hotelID,
|
||||
Role: role,
|
||||
UserID: userID,
|
||||
ShopID: shopID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExp),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
@@ -116,5 +117,6 @@ func (s *AuthService) issueTokens(userID, hotelID uint64, role string) (*TokenPa
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: cfg.AccessExpireMin * 60,
|
||||
ShopID: shopID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
func TestAuthService_Login_Success(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "HOTEL001")
|
||||
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
|
||||
shop := testutil.CreateTestShop(db, "HOTEL001")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := NewAuthService(db)
|
||||
pair, user, err := svc.Login("HOTEL001", "admin", "password123")
|
||||
@@ -27,8 +27,8 @@ func TestAuthService_Login_Success(t *testing.T) {
|
||||
|
||||
func TestAuthService_Login_WrongPassword(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "HOTEL002")
|
||||
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
|
||||
shop := testutil.CreateTestShop(db, "HOTEL002")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := NewAuthService(db)
|
||||
pair, user, err := svc.Login("HOTEL002", "admin", "wrongpassword")
|
||||
@@ -41,7 +41,7 @@ func TestAuthService_Login_WrongPassword(t *testing.T) {
|
||||
|
||||
func TestAuthService_Login_WrongHotel(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
testutil.CreateTestHotel(db, "HOTEL003")
|
||||
testutil.CreateTestShop(db, "HOTEL003")
|
||||
|
||||
svc := NewAuthService(db)
|
||||
pair, user, err := svc.Login("NONEXISTENT", "admin", "password123")
|
||||
@@ -54,8 +54,8 @@ func TestAuthService_Login_WrongHotel(t *testing.T) {
|
||||
|
||||
func TestAuthService_Login_DisabledUser(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "HOTEL004")
|
||||
user := testutil.CreateTestUser(db, hotel.ID, "disabled", "password123", "operator")
|
||||
shop := testutil.CreateTestShop(db, "HOTEL004")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "disabled", "password123", "operator")
|
||||
// 禁用用户
|
||||
db.Model(user).Update("is_active", false)
|
||||
|
||||
@@ -70,8 +70,8 @@ func TestAuthService_Login_DisabledUser(t *testing.T) {
|
||||
|
||||
func TestAuthService_Login_WrongUsername(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "HOTEL005")
|
||||
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
|
||||
shop := testutil.CreateTestShop(db, "HOTEL005")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := NewAuthService(db)
|
||||
pair, user, err := svc.Login("HOTEL005", "nonexistent", "password123")
|
||||
@@ -84,8 +84,8 @@ func TestAuthService_Login_WrongUsername(t *testing.T) {
|
||||
|
||||
func TestAuthService_RefreshTokens(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "HOTEL006")
|
||||
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
|
||||
shop := testutil.CreateTestShop(db, "HOTEL006")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := NewAuthService(db)
|
||||
pair, _, err := svc.Login("HOTEL006", "admin", "password123")
|
||||
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLicenseNotFound = errors.New("license not found")
|
||||
ErrLicenseInactive = errors.New("license is inactive")
|
||||
ErrLicenseExpired = errors.New("license has expired")
|
||||
ErrDeviceMismatch = errors.New("license is bound to another device")
|
||||
ErrLicenseNotFound = errors.New("license not found")
|
||||
ErrLicenseInactive = errors.New("license is inactive")
|
||||
ErrLicenseExpired = errors.New("license has expired")
|
||||
ErrDeviceMismatch = errors.New("license is bound to another device")
|
||||
)
|
||||
|
||||
type LicenseService struct {
|
||||
@@ -31,9 +31,9 @@ func NewLicenseService(db *gorm.DB) *LicenseService {
|
||||
}
|
||||
|
||||
// GenerateKey 生成许可证激活码
|
||||
// 格式:HMAC-SHA256(hotelID+deviceID+expiry, secret) → base32, 每5字符加'-'
|
||||
func GenerateKey(hotelID uint64, licenseType string, expiresAt *time.Time) string {
|
||||
payload := fmt.Sprintf("%d:%s", hotelID, licenseType)
|
||||
// 格式:HMAC-SHA256(shopID+licenseType+expiry, secret) → base32, 每5字符加'-'
|
||||
func GenerateKey(shopID uint64, licenseType string, expiresAt *time.Time) string {
|
||||
payload := fmt.Sprintf("%d:%s", shopID, licenseType)
|
||||
if expiresAt != nil {
|
||||
payload += ":" + expiresAt.Format("20060102")
|
||||
}
|
||||
@@ -70,9 +70,9 @@ func (s *LicenseService) Activate(licenseKey, deviceID string) (*model.License,
|
||||
}
|
||||
|
||||
// Verify 验证(客户端启动时调用)
|
||||
func (s *LicenseService) Verify(hotelID uint64, deviceID string) (*model.License, error) {
|
||||
func (s *LicenseService) Verify(shopID uint64, deviceID string) (*model.License, error) {
|
||||
var lic model.License
|
||||
if err := s.db.Where("hotel_id = ? AND device_id = ? AND is_active = 1", hotelID, deviceID).
|
||||
if err := s.db.Where("shop_id = ? AND device_id = ? AND is_active = 1", shopID, deviceID).
|
||||
First(&lic).Error; err != nil {
|
||||
return nil, ErrLicenseNotFound
|
||||
}
|
||||
@@ -83,8 +83,8 @@ func (s *LicenseService) Verify(hotelID uint64, deviceID string) (*model.License
|
||||
}
|
||||
|
||||
// Deactivate 解绑设备(换机时使用)
|
||||
func (s *LicenseService) Deactivate(hotelID uint64, deviceID string) error {
|
||||
func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
|
||||
return s.db.Model(&model.License{}).
|
||||
Where("hotel_id = ? AND device_id = ?", hotelID, deviceID).
|
||||
Where("shop_id = ? AND device_id = ?", shopID, deviceID).
|
||||
Updates(map[string]interface{}{"device_id": "", "activated_at": nil}).Error
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
|
||||
func TestLicenseService_Activate_Success(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "LIC001")
|
||||
shop := testutil.CreateTestShop(db, "LIC001")
|
||||
|
||||
// 创建许可证
|
||||
expiry := time.Now().Add(30 * 24 * time.Hour)
|
||||
lic := &model.License{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "AAAAA-BBBBB-CCCCC-DDDDD",
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiry,
|
||||
@@ -36,10 +36,10 @@ func TestLicenseService_Activate_Success(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Activate_AlreadyBoundToDifferentDevice(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "LIC002")
|
||||
shop := testutil.CreateTestShop(db, "LIC002")
|
||||
|
||||
lic := &model.License{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "EEEEE-FFFFF-GGGGG-HHHHH",
|
||||
DeviceID: "existing-device",
|
||||
IsActive: true,
|
||||
@@ -56,10 +56,10 @@ func TestLicenseService_Activate_AlreadyBoundToDifferentDevice(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Activate_SameDevice(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "LIC003")
|
||||
shop := testutil.CreateTestShop(db, "LIC003")
|
||||
|
||||
lic := &model.License{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "IIIII-JJJJJ-KKKKK-LLLLL",
|
||||
DeviceID: "same-device",
|
||||
IsActive: true,
|
||||
@@ -88,11 +88,11 @@ func TestLicenseService_Activate_NotFound(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Activate_Inactive(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "LIC004")
|
||||
shop := testutil.CreateTestShop(db, "LIC004")
|
||||
|
||||
// 先创建激活的许可证,再禁用(避免 GORM 零值跳过问题)
|
||||
lic := &model.License{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "MMMMM-NNNNN-OOOOO-PPPPP",
|
||||
IsActive: true,
|
||||
}
|
||||
@@ -110,12 +110,12 @@ func TestLicenseService_Activate_Inactive(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Activate_Expired(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "LIC005")
|
||||
shop := testutil.CreateTestShop(db, "LIC005")
|
||||
|
||||
// 已过期
|
||||
expiry := time.Now().Add(-24 * time.Hour)
|
||||
lic := &model.License{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "QQQQQ-RRRRR-SSSSS-TTTTT",
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiry,
|
||||
@@ -132,11 +132,11 @@ func TestLicenseService_Activate_Expired(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Verify_Success(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "LIC006")
|
||||
shop := testutil.CreateTestShop(db, "LIC006")
|
||||
|
||||
expiry := time.Now().Add(30 * 24 * time.Hour)
|
||||
lic := &model.License{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "UUUUU-VVVVV-WWWWW-XXXXX",
|
||||
DeviceID: "my-device",
|
||||
IsActive: true,
|
||||
@@ -145,7 +145,7 @@ func TestLicenseService_Verify_Success(t *testing.T) {
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Verify(hotel.ID, "my-device")
|
||||
result, err := svc.Verify(shop.ID, "my-device")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
@@ -154,12 +154,12 @@ func TestLicenseService_Verify_Success(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Verify_Expired(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "LIC007")
|
||||
shop := testutil.CreateTestShop(db, "LIC007")
|
||||
|
||||
// 已过期
|
||||
expiry := time.Now().Add(-1 * time.Hour)
|
||||
lic := &model.License{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "YYYYY-ZZZZZ-AAAAA-BBBBB",
|
||||
DeviceID: "expired-device",
|
||||
IsActive: true,
|
||||
@@ -168,7 +168,7 @@ func TestLicenseService_Verify_Expired(t *testing.T) {
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Verify(hotel.ID, "expired-device")
|
||||
result, err := svc.Verify(shop.ID, "expired-device")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrLicenseExpired, err)
|
||||
@@ -177,10 +177,10 @@ func TestLicenseService_Verify_Expired(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Verify_NotFound(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "LIC008")
|
||||
shop := testutil.CreateTestShop(db, "LIC008")
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Verify(hotel.ID, "nonexistent-device")
|
||||
result, err := svc.Verify(shop.ID, "nonexistent-device")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrLicenseNotFound, err)
|
||||
@@ -189,11 +189,11 @@ func TestLicenseService_Verify_NotFound(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Verify_NoExpiry(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "LIC009")
|
||||
shop := testutil.CreateTestShop(db, "LIC009")
|
||||
|
||||
// 永久许可证(无过期时间)
|
||||
lic := &model.License{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "CCCCC-DDDDD-EEEEE-FFFFF",
|
||||
DeviceID: "lifetime-device",
|
||||
IsActive: true,
|
||||
@@ -202,7 +202,7 @@ func TestLicenseService_Verify_NoExpiry(t *testing.T) {
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Verify(hotel.ID, "lifetime-device")
|
||||
result, err := svc.Verify(shop.ID, "lifetime-device")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
@@ -21,11 +21,11 @@ func NewStockService(db *gorm.DB) *StockService {
|
||||
}
|
||||
|
||||
// ApproveStockIn 审核入库单,审核通过后更新库存(事务)
|
||||
func (s *StockService) ApproveStockIn(hotelID, orderID, reviewerID uint64) error {
|
||||
func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.StockInOrder
|
||||
if err := tx.Preload("Items").
|
||||
Where("id = ? AND hotel_id = ?", orderID, hotelID).
|
||||
Where("id = ? AND shop_id = ?", orderID, shopID).
|
||||
First(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func (s *StockService) ApproveStockIn(hotelID, orderID, reviewerID uint64) error
|
||||
|
||||
now := time.Now()
|
||||
for _, item := range order.Items {
|
||||
if err := s.updateInventory(tx, hotelID, order.WarehouseID, item.ProductID,
|
||||
if err := s.updateInventory(tx, shopID, order.WarehouseID, item.ProductID,
|
||||
"in", item.Quantity, orderID, "stock_in", reviewerID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -50,11 +50,11 @@ func (s *StockService) ApproveStockIn(hotelID, orderID, reviewerID uint64) error
|
||||
}
|
||||
|
||||
// ApproveStockOut 审核出库单
|
||||
func (s *StockService) ApproveStockOut(hotelID, orderID, reviewerID uint64) error {
|
||||
func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.StockOutOrder
|
||||
if err := tx.Preload("Items").
|
||||
Where("id = ? AND hotel_id = ?", orderID, hotelID).
|
||||
Where("id = ? AND shop_id = ?", orderID, shopID).
|
||||
First(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -65,8 +65,8 @@ func (s *StockService) ApproveStockOut(hotelID, orderID, reviewerID uint64) erro
|
||||
// 预检库存
|
||||
for _, item := range order.Items {
|
||||
var inv model.Inventory
|
||||
if err := tx.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
hotelID, order.WarehouseID, item.ProductID).First(&inv).Error; err != nil {
|
||||
if err := tx.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, order.WarehouseID, item.ProductID).First(&inv).Error; err != nil {
|
||||
return fmt.Errorf("product %d not in inventory", item.ProductID)
|
||||
}
|
||||
if inv.Quantity < item.Quantity {
|
||||
@@ -77,7 +77,7 @@ func (s *StockService) ApproveStockOut(hotelID, orderID, reviewerID uint64) erro
|
||||
|
||||
now := time.Now()
|
||||
for _, item := range order.Items {
|
||||
if err := s.updateInventory(tx, hotelID, order.WarehouseID, item.ProductID,
|
||||
if err := s.updateInventory(tx, shopID, order.WarehouseID, item.ProductID,
|
||||
"out", item.Quantity, orderID, "stock_out", reviewerID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -92,12 +92,12 @@ func (s *StockService) ApproveStockOut(hotelID, orderID, reviewerID uint64) erro
|
||||
}
|
||||
|
||||
// updateInventory 更新库存并写流水(在事务中调用)
|
||||
func (s *StockService) updateInventory(tx *gorm.DB, hotelID, warehouseID, productID uint64,
|
||||
func (s *StockService) updateInventory(tx *gorm.DB, shopID, warehouseID, productID uint64,
|
||||
direction string, qty float64, refID uint64, refType string, operatorID uint64) error {
|
||||
|
||||
var inv model.Inventory
|
||||
result := tx.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
hotelID, warehouseID, productID).First(&inv)
|
||||
result := tx.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, warehouseID, productID).First(&inv)
|
||||
|
||||
qtyBefore := inv.Quantity
|
||||
var qtyAfter float64
|
||||
@@ -106,7 +106,7 @@ func (s *StockService) updateInventory(tx *gorm.DB, hotelID, warehouseID, produc
|
||||
qtyAfter = qtyBefore + qty
|
||||
if result.Error != nil {
|
||||
// 不存在则创建
|
||||
inv = model.Inventory{HotelID: hotelID, WarehouseID: warehouseID, ProductID: productID, Quantity: qtyAfter}
|
||||
inv = model.Inventory{ShopID: shopID, WarehouseID: warehouseID, ProductID: productID, Quantity: qtyAfter}
|
||||
if err := tx.Create(&inv).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func (s *StockService) updateInventory(tx *gorm.DB, hotelID, warehouseID, produc
|
||||
}
|
||||
|
||||
log := model.InventoryLog{
|
||||
HotelID: hotelID,
|
||||
ShopID: shopID,
|
||||
WarehouseID: warehouseID,
|
||||
ProductID: productID,
|
||||
Direction: direction,
|
||||
@@ -138,14 +138,14 @@ func (s *StockService) updateInventory(tx *gorm.DB, hotelID, warehouseID, produc
|
||||
}
|
||||
|
||||
// GenerateOrderNo 生成单号(事务安全)
|
||||
func (s *StockService) GenerateOrderNo(hotelID uint64, orderType string) (string, error) {
|
||||
func (s *StockService) GenerateOrderNo(shopID uint64, orderType string) (string, error) {
|
||||
var no string
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var rule model.NumberRule
|
||||
result := tx.Where("hotel_id = ? AND type = ?", hotelID, orderType).First(&rule)
|
||||
result := tx.Where("shop_id = ? AND type = ?", shopID, orderType).First(&rule)
|
||||
if result.Error != nil {
|
||||
// 初始化规则
|
||||
rule = model.NumberRule{HotelID: hotelID, Type: orderType, Prefix: orderType[:2], CurrentNo: 0}
|
||||
rule = model.NumberRule{ShopID: shopID, Type: orderType, Prefix: orderType[:2], CurrentNo: 0}
|
||||
tx.Create(&rule)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ import (
|
||||
|
||||
func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "STOCK001")
|
||||
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Main Warehouse")
|
||||
product := testutil.CreateTestProduct(db, hotel.ID, "Beer")
|
||||
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
|
||||
shop := testutil.CreateTestShop(db, "STOCK001")
|
||||
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Main Warehouse")
|
||||
product := testutil.CreateTestProduct(db, shop.ID, "Beer")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
|
||||
// 创建入库单
|
||||
order := model.StockInOrder{
|
||||
TenantBase: model.TenantBase{HotelID: hotel.ID},
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "IN20240101000001",
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
@@ -30,7 +30,7 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
||||
OrderDate: time.Now(),
|
||||
Items: []model.StockInItem{
|
||||
{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 10,
|
||||
UnitPrice: 5.0,
|
||||
@@ -41,7 +41,7 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
||||
require.NoError(t, db.Create(&order).Error)
|
||||
|
||||
svc := NewStockService(db)
|
||||
err := svc.ApproveStockIn(hotel.ID, order.ID, user.ID)
|
||||
err := svc.ApproveStockIn(shop.ID, order.ID, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 验证单据状态
|
||||
@@ -51,13 +51,13 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
||||
|
||||
// 验证库存
|
||||
var inv model.Inventory
|
||||
db.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
hotel.ID, warehouse.ID, product.ID).First(&inv)
|
||||
db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shop.ID, warehouse.ID, product.ID).First(&inv)
|
||||
assert.Equal(t, float64(10), inv.Quantity)
|
||||
|
||||
// 验证库存流水
|
||||
var logs []model.InventoryLog
|
||||
db.Where("hotel_id = ? AND product_id = ?", hotel.ID, product.ID).Find(&logs)
|
||||
db.Where("shop_id = ? AND product_id = ?", shop.ID, product.ID).Find(&logs)
|
||||
require.Len(t, logs, 1)
|
||||
assert.Equal(t, "in", logs[0].Direction)
|
||||
assert.Equal(t, float64(10), logs[0].Quantity)
|
||||
@@ -67,13 +67,13 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
||||
|
||||
func TestStockService_ApproveStockIn_NotPending(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "STOCK002")
|
||||
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
|
||||
_ = testutil.CreateTestProduct(db, hotel.ID, "Wine")
|
||||
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
|
||||
shop := testutil.CreateTestShop(db, "STOCK002")
|
||||
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
||||
_ = testutil.CreateTestProduct(db, shop.ID, "Wine")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
|
||||
order := model.StockInOrder{
|
||||
TenantBase: model.TenantBase{HotelID: hotel.ID},
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "IN20240101000002",
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
@@ -83,21 +83,21 @@ func TestStockService_ApproveStockIn_NotPending(t *testing.T) {
|
||||
require.NoError(t, db.Create(&order).Error)
|
||||
|
||||
svc := NewStockService(db)
|
||||
err := svc.ApproveStockIn(hotel.ID, order.ID, user.ID)
|
||||
err := svc.ApproveStockIn(shop.ID, order.ID, user.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not in pending status")
|
||||
}
|
||||
|
||||
func TestStockService_ApproveStockOut_Success(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "STOCK003")
|
||||
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
|
||||
product := testutil.CreateTestProduct(db, hotel.ID, "Whiskey")
|
||||
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
|
||||
shop := testutil.CreateTestShop(db, "STOCK003")
|
||||
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
||||
product := testutil.CreateTestProduct(db, shop.ID, "Whiskey")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
|
||||
// 先入库
|
||||
inv := model.Inventory{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
WarehouseID: warehouse.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 20,
|
||||
@@ -106,7 +106,7 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) {
|
||||
|
||||
// 创建出库单
|
||||
order := model.StockOutOrder{
|
||||
TenantBase: model.TenantBase{HotelID: hotel.ID},
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "OUT20240101000001",
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
@@ -114,7 +114,7 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) {
|
||||
OrderDate: time.Now(),
|
||||
Items: []model.StockOutItem{
|
||||
{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 5,
|
||||
UnitPrice: 10.0,
|
||||
@@ -125,7 +125,7 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) {
|
||||
require.NoError(t, db.Create(&order).Error)
|
||||
|
||||
svc := NewStockService(db)
|
||||
err := svc.ApproveStockOut(hotel.ID, order.ID, user.ID)
|
||||
err := svc.ApproveStockOut(shop.ID, order.ID, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 验证库存减少
|
||||
@@ -141,14 +141,14 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) {
|
||||
|
||||
func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "STOCK004")
|
||||
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
|
||||
product := testutil.CreateTestProduct(db, hotel.ID, "Vodka")
|
||||
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
|
||||
shop := testutil.CreateTestShop(db, "STOCK004")
|
||||
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
||||
product := testutil.CreateTestProduct(db, shop.ID, "Vodka")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
|
||||
// 库存只有 3
|
||||
inv := model.Inventory{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
WarehouseID: warehouse.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 3,
|
||||
@@ -157,7 +157,7 @@ func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) {
|
||||
|
||||
// 要出库 10
|
||||
order := model.StockOutOrder{
|
||||
TenantBase: model.TenantBase{HotelID: hotel.ID},
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "OUT20240101000002",
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
@@ -165,7 +165,7 @@ func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) {
|
||||
OrderDate: time.Now(),
|
||||
Items: []model.StockOutItem{
|
||||
{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 10,
|
||||
},
|
||||
@@ -174,21 +174,21 @@ func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) {
|
||||
require.NoError(t, db.Create(&order).Error)
|
||||
|
||||
svc := NewStockService(db)
|
||||
err := svc.ApproveStockOut(hotel.ID, order.ID, user.ID)
|
||||
err := svc.ApproveStockOut(shop.ID, order.ID, user.ID)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrInsufficientStock)
|
||||
}
|
||||
|
||||
func TestStockService_ApproveStockOut_ProductNotInInventory(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "STOCK005")
|
||||
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
|
||||
product := testutil.CreateTestProduct(db, hotel.ID, "Rum")
|
||||
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
|
||||
shop := testutil.CreateTestShop(db, "STOCK005")
|
||||
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
||||
product := testutil.CreateTestProduct(db, shop.ID, "Rum")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
|
||||
// 没有创建库存记录
|
||||
order := model.StockOutOrder{
|
||||
TenantBase: model.TenantBase{HotelID: hotel.ID},
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "OUT20240101000003",
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
@@ -196,7 +196,7 @@ func TestStockService_ApproveStockOut_ProductNotInInventory(t *testing.T) {
|
||||
OrderDate: time.Now(),
|
||||
Items: []model.StockOutItem{
|
||||
{
|
||||
HotelID: hotel.ID,
|
||||
ShopID: shop.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 5,
|
||||
},
|
||||
@@ -205,24 +205,24 @@ func TestStockService_ApproveStockOut_ProductNotInInventory(t *testing.T) {
|
||||
require.NoError(t, db.Create(&order).Error)
|
||||
|
||||
svc := NewStockService(db)
|
||||
err := svc.ApproveStockOut(hotel.ID, order.ID, user.ID)
|
||||
err := svc.ApproveStockOut(shop.ID, order.ID, user.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not in inventory")
|
||||
}
|
||||
|
||||
func TestStockService_GenerateOrderNo(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "STOCK006")
|
||||
shop := testutil.CreateTestShop(db, "STOCK006")
|
||||
|
||||
svc := NewStockService(db)
|
||||
no, err := svc.GenerateOrderNo(hotel.ID, "stock_in")
|
||||
no, err := svc.GenerateOrderNo(shop.ID, "stock_in")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, no)
|
||||
// 单号格式: prefix + date + 6位序号
|
||||
assert.Contains(t, no, "st")
|
||||
|
||||
// 第二次生成序号应递增
|
||||
no2, err := svc.GenerateOrderNo(hotel.ID, "stock_in")
|
||||
no2, err := svc.GenerateOrderNo(shop.ID, "stock_in")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, no, no2)
|
||||
}
|
||||
@@ -231,7 +231,7 @@ func TestStockService_GenerateOrderNo(t *testing.T) {
|
||||
// 注:并发安全由 MySQL 的事务锁保证,SQLite in-memory 不模拟此场景
|
||||
func TestStockService_GenerateOrderNo_Sequential(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "STOCK007")
|
||||
shop := testutil.CreateTestShop(db, "STOCK007")
|
||||
|
||||
svc := NewStockService(db)
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestStockService_GenerateOrderNo_Sequential(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
no, err := svc.GenerateOrderNo(hotel.ID, "stock_in")
|
||||
no, err := svc.GenerateOrderNo(shop.ID, "stock_in")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, no)
|
||||
assert.False(t, seen[no], fmt.Sprintf("duplicate order no: %s", no))
|
||||
@@ -251,46 +251,46 @@ func TestStockService_GenerateOrderNo_Sequential(t *testing.T) {
|
||||
|
||||
func TestStockService_MultipleStockIn_AccumulatesInventory(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
hotel := testutil.CreateTestHotel(db, "STOCK008")
|
||||
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
|
||||
product := testutil.CreateTestProduct(db, hotel.ID, "Gin")
|
||||
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
|
||||
shop := testutil.CreateTestShop(db, "STOCK008")
|
||||
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
||||
product := testutil.CreateTestProduct(db, shop.ID, "Gin")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
|
||||
svc := NewStockService(db)
|
||||
|
||||
// 第一次入库
|
||||
order1 := model.StockInOrder{
|
||||
TenantBase: model.TenantBase{HotelID: hotel.ID},
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "IN001",
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
Status: "pending",
|
||||
OrderDate: time.Now(),
|
||||
Items: []model.StockInItem{
|
||||
{HotelID: hotel.ID, ProductID: product.ID, Quantity: 10},
|
||||
{ShopID: shop.ID, ProductID: product.ID, Quantity: 10},
|
||||
},
|
||||
}
|
||||
require.NoError(t, db.Create(&order1).Error)
|
||||
require.NoError(t, svc.ApproveStockIn(hotel.ID, order1.ID, user.ID))
|
||||
require.NoError(t, svc.ApproveStockIn(shop.ID, order1.ID, user.ID))
|
||||
|
||||
// 第二次入库
|
||||
order2 := model.StockInOrder{
|
||||
TenantBase: model.TenantBase{HotelID: hotel.ID},
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "IN002",
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
Status: "pending",
|
||||
OrderDate: time.Now(),
|
||||
Items: []model.StockInItem{
|
||||
{HotelID: hotel.ID, ProductID: product.ID, Quantity: 5},
|
||||
{ShopID: shop.ID, ProductID: product.ID, Quantity: 5},
|
||||
},
|
||||
}
|
||||
require.NoError(t, db.Create(&order2).Error)
|
||||
require.NoError(t, svc.ApproveStockIn(hotel.ID, order2.ID, user.ID))
|
||||
require.NoError(t, svc.ApproveStockIn(shop.ID, order2.ID, user.ID))
|
||||
|
||||
// 总库存应该是 15
|
||||
var inv model.Inventory
|
||||
db.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
hotel.ID, warehouse.ID, product.ID).First(&inv)
|
||||
db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shop.ID, warehouse.ID, product.ID).First(&inv)
|
||||
assert.Equal(t, float64(15), inv.Quantity)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user