feat: 支持批量合成并收口不包邮运费规则
本次提交同时完成碎片批量合成与盒柜发货运费规则改造,减少用户重复操作,并确保不包邮商品在任何件数下都必须支付运费。 - 合成功能:新增批量合成接口与前端一键合成入口,按配方可合成上限批量消耗碎片并生成对应资产,同时补充批量合成测试 - 运费规则:后端新增统一运费判定逻辑,命中 category_id 14/15 的商品时整单强制收取运费,否则继续沿用少于 5 件收运费的旧规则 - 发货流程:新增运费检查接口,前端发货前先向后端确认是否需要支付运费,并根据“件数不足”或“包含不包邮商品”展示不同提示文案 - 接口校验:运费预下单与批量申请发货统一复用后端判定逻辑,避免前端规则被绕过
This commit is contained in:
parent
a96b1543f0
commit
0a397adf41
@ -46,8 +46,13 @@ func (h *handler) RequestShippingBatch() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
userID := int64(ctx.SessionUserInfo().Id)
|
userID := int64(ctx.SessionUserInfo().Id)
|
||||||
|
|
||||||
// 运费校验:不满 5 件须已支付运费订单
|
needFee, reason, err := h.user.CheckShippingFeeRequirement(ctx.RequestContext(), userID, req.InventoryIDs)
|
||||||
if len(req.InventoryIDs) < shippingFeeThreshold {
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150004, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if needFee {
|
||||||
paid, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).
|
paid, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).
|
||||||
Where(
|
Where(
|
||||||
h.readDB.Orders.UserID.Eq(userID),
|
h.readDB.Orders.UserID.Eq(userID),
|
||||||
@ -55,7 +60,13 @@ func (h *handler) RequestShippingBatch() core.HandlerFunc {
|
|||||||
h.readDB.Orders.Status.Eq(2),
|
h.readDB.Orders.Status.Eq(2),
|
||||||
).Count()
|
).Count()
|
||||||
if paid == 0 {
|
if paid == 0 {
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150003, "不满5件需先支付运费"))
|
msg := "需先支付运费"
|
||||||
|
if reason == shippingFeeReasonContainsNonFreeShipping {
|
||||||
|
msg = "所选商品包含不包邮商品,需先支付运费"
|
||||||
|
} else if reason == shippingFeeReasonBelowThreshold {
|
||||||
|
msg = "不满5件需先支付运费"
|
||||||
|
}
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150003, msg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,9 +13,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
shippingFeeThreshold = 5 // 低于此件数收运费
|
shippingFeeThreshold = 5
|
||||||
shippingFeeCents = 1000 // 运费金额(分),10 元
|
shippingFeeCents = 1000 // 运费金额(分),10 元
|
||||||
shippingFeeSourceType = int32(5) // orders.source_type: 5 = 运费订单
|
shippingFeeSourceType = int32(5) // orders.source_type: 5 = 运费订单
|
||||||
|
shippingFeeReasonBelowThreshold = "below_threshold"
|
||||||
|
shippingFeeReasonContainsNonFreeShipping = "contains_non_free_shipping_item"
|
||||||
)
|
)
|
||||||
|
|
||||||
type shippingFeePreorderRequest struct {
|
type shippingFeePreorderRequest struct {
|
||||||
@ -26,9 +28,40 @@ type shippingFeePreorderResponse struct {
|
|||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type shippingFeeCheckResponse struct {
|
||||||
|
NeedFee bool `json:"need_fee"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
FeeCents int64 `json:"fee_cents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) ShippingFeeCheck() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
req := new(shippingFeePreorderRequest)
|
||||||
|
rsp := &shippingFeeCheckResponse{FeeCents: shippingFeeCents}
|
||||||
|
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.InventoryIDs) == 0 {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "inventory_ids 不能为空"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := int64(ctx.SessionUserInfo().Id)
|
||||||
|
needFee, reason, err := h.user.CheckShippingFeeRequirement(ctx.RequestContext(), userID, req.InventoryIDs)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150002, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rsp.NeedFee = needFee
|
||||||
|
rsp.Reason = reason
|
||||||
|
ctx.Payload(rsp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ShippingFeePreorder 创建运费订单
|
// ShippingFeePreorder 创建运费订单
|
||||||
// @Summary 创建运费订单
|
// @Summary 创建运费订单
|
||||||
// @Description 选中件数不满 5 件时,创建 10 元运费订单并返回 order_no;前端再调用 /pay/wechat/jsapi/preorder 发起支付;满 5 件包邮无需调用
|
// @Description 选中商品命中运费规则时,创建 10 元运费订单并返回 order_no;前端再调用 /pay/wechat/jsapi/preorder 发起支付;无需运费时不应调用
|
||||||
// @Tags APP端.用户
|
// @Tags APP端.用户
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@ -50,12 +83,17 @@ func (h *handler) ShippingFeePreorder() core.HandlerFunc {
|
|||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "inventory_ids 不能为空"))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "inventory_ids 不能为空"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.InventoryIDs) >= shippingFeeThreshold {
|
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150001, fmt.Sprintf("件数满 %d 件,无需支付运费", shippingFeeThreshold)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := int64(ctx.SessionUserInfo().Id)
|
userID := int64(ctx.SessionUserInfo().Id)
|
||||||
|
needFee, _, err := h.user.CheckShippingFeeRequirement(ctx.RequestContext(), userID, req.InventoryIDs)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150002, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !needFee {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150001, fmt.Sprintf("件数满 %d 件且均非不包邮分类商品,无需支付运费", shippingFeeThreshold)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
remarkBytes, _ := json.Marshal(req.InventoryIDs)
|
remarkBytes, _ := json.Marshal(req.InventoryIDs)
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,23 @@ func (h *handler) DoSynthesis() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *handler) DoBatchSynthesis() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
req := new(synthesizeRequest)
|
||||||
|
if err := ctx.ShouldBindJSON(req); err != nil || req.RecipeID <= 0 {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid recipe_id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := int64(ctx.SessionUserInfo().Id)
|
||||||
|
result, err := h.synthesis.BatchSynthesize(ctx.RequestContext(), userID, req.RecipeID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *handler) ListSynthesisLogsForUser() core.HandlerFunc {
|
func (h *handler) ListSynthesisLogsForUser() core.HandlerFunc {
|
||||||
return func(ctx core.Context) {
|
return func(ctx core.Context) {
|
||||||
userID := int64(ctx.SessionUserInfo().Id)
|
userID := int64(ctx.SessionUserInfo().Id)
|
||||||
|
|||||||
@ -538,6 +538,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
|
|||||||
lotteryGroup.POST("/users/:user_id/points/redeem-item-card", userHandler.RedeemPointsToItemCard())
|
lotteryGroup.POST("/users/:user_id/points/redeem-item-card", userHandler.RedeemPointsToItemCard())
|
||||||
|
|
||||||
// 资产操作(发货/回收)
|
// 资产操作(发货/回收)
|
||||||
|
lotteryGroup.POST("/users/:user_id/inventory/shipping-fee/check", userHandler.ShippingFeeCheck())
|
||||||
lotteryGroup.POST("/users/:user_id/inventory/shipping-fee/preorder", userHandler.ShippingFeePreorder())
|
lotteryGroup.POST("/users/:user_id/inventory/shipping-fee/preorder", userHandler.ShippingFeePreorder())
|
||||||
lotteryGroup.POST("/users/:user_id/inventory/request-shipping", userHandler.RequestShippingBatch())
|
lotteryGroup.POST("/users/:user_id/inventory/request-shipping", userHandler.RequestShippingBatch())
|
||||||
lotteryGroup.POST("/users/:user_id/inventory/cancel-shipping", userHandler.CancelShipping())
|
lotteryGroup.POST("/users/:user_id/inventory/cancel-shipping", userHandler.CancelShipping())
|
||||||
@ -547,6 +548,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
|
|||||||
// 碎片合成
|
// 碎片合成
|
||||||
appAuthApiRouter.GET("/users/:user_id/synthesis/recipes", userHandler.ListSynthesisRecipesForUser())
|
appAuthApiRouter.GET("/users/:user_id/synthesis/recipes", userHandler.ListSynthesisRecipesForUser())
|
||||||
appAuthApiRouter.POST("/users/:user_id/synthesis/do", userHandler.DoSynthesis())
|
appAuthApiRouter.POST("/users/:user_id/synthesis/do", userHandler.DoSynthesis())
|
||||||
|
appAuthApiRouter.POST("/users/:user_id/synthesis/do-batch", userHandler.DoBatchSynthesis())
|
||||||
appAuthApiRouter.GET("/users/:user_id/synthesis/logs", userHandler.ListSynthesisLogsForUser())
|
appAuthApiRouter.GET("/users/:user_id/synthesis/logs", userHandler.ListSynthesisLogsForUser())
|
||||||
|
|
||||||
// 对对碰其他接口(不需要严查黑名单,或者已在preorder查过)
|
// 对对碰其他接口(不需要严查黑名单,或者已在preorder查过)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
@ -20,6 +21,7 @@ type Service interface {
|
|||||||
DeleteRecipe(ctx context.Context, id int64) error
|
DeleteRecipe(ctx context.Context, id int64) error
|
||||||
GetAvailableRecipesForUser(ctx context.Context, userID int64) ([]*UserRecipeView, error)
|
GetAvailableRecipesForUser(ctx context.Context, userID int64) ([]*UserRecipeView, error)
|
||||||
Synthesize(ctx context.Context, userID int64, recipeID int64) (*model.UserInventory, error)
|
Synthesize(ctx context.Context, userID int64, recipeID int64) (*model.UserInventory, error)
|
||||||
|
BatchSynthesize(ctx context.Context, userID int64, recipeID int64) (*BatchSynthesizeResult, error)
|
||||||
ListLogs(ctx context.Context, page, size int, userID *int64) (list []*SynthesisLogView, total int64, err error)
|
ListLogs(ctx context.Context, page, size int, userID *int64) (list []*SynthesisLogView, total int64, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,12 +53,22 @@ type UserMaterialView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserRecipeView struct {
|
type UserRecipeView struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
TargetProduct *model.Products `json:"target_product"`
|
TargetProduct *model.Products `json:"target_product"`
|
||||||
CanSynthesize bool `json:"can_synthesize"`
|
CanSynthesize bool `json:"can_synthesize"`
|
||||||
Materials []UserMaterialView `json:"materials"`
|
MaxSynthesizeCount int64 `json:"max_synthesize_count"`
|
||||||
|
Materials []UserMaterialView `json:"materials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchSynthesizeResult struct {
|
||||||
|
RecipeID int64 `json:"recipe_id"`
|
||||||
|
TargetProductID int64 `json:"target_product_id"`
|
||||||
|
TargetProductName string `json:"target_product_name"`
|
||||||
|
SynthesizedCount int64 `json:"synthesized_count"`
|
||||||
|
ProducedInventoryIDs []int64 `json:"produced_inventory_ids"`
|
||||||
|
ConsumedInventoryCount int `json:"consumed_inventory_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SynthesisLogView struct {
|
type SynthesisLogView struct {
|
||||||
@ -241,10 +253,11 @@ func (s *service) GetAvailableRecipesForUser(ctx context.Context, userID int64)
|
|||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
Description: r.Description,
|
Description: r.Description,
|
||||||
TargetProduct: &targetProduct,
|
TargetProduct: &targetProduct,
|
||||||
CanSynthesize: true,
|
|
||||||
Materials: make([]UserMaterialView, 0, len(materials)),
|
Materials: make([]UserMaterialView, 0, len(materials)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxSynthesizeCount := int64(0)
|
||||||
|
initialized := false
|
||||||
for _, m := range materials {
|
for _, m := range materials {
|
||||||
var p model.Products
|
var p model.Products
|
||||||
db.WithContext(ctx).Where("id = ?", m.FragmentProductID).First(&p)
|
db.WithContext(ctx).Where("id = ?", m.FragmentProductID).First(&p)
|
||||||
@ -254,9 +267,15 @@ func (s *service) GetAvailableRecipesForUser(ctx context.Context, userID int64)
|
|||||||
Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID).
|
Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID).
|
||||||
Count(&ownedCount)
|
Count(&ownedCount)
|
||||||
|
|
||||||
if ownedCount < int64(m.RequiredCount) {
|
currentCount := int64(0)
|
||||||
view.CanSynthesize = false
|
if m.RequiredCount > 0 {
|
||||||
|
currentCount = ownedCount / int64(m.RequiredCount)
|
||||||
}
|
}
|
||||||
|
if !initialized || currentCount < maxSynthesizeCount {
|
||||||
|
maxSynthesizeCount = currentCount
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
image := ""
|
image := ""
|
||||||
if p.ImagesJSON != "" {
|
if p.ImagesJSON != "" {
|
||||||
var imgs []string
|
var imgs []string
|
||||||
@ -272,12 +291,34 @@ func (s *service) GetAvailableRecipesForUser(ctx context.Context, userID int64)
|
|||||||
OwnedCount: ownedCount,
|
OwnedCount: ownedCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
view.MaxSynthesizeCount = maxSynthesizeCount
|
||||||
|
view.CanSynthesize = maxSynthesizeCount > 0
|
||||||
result = append(result, view)
|
result = append(result, view)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) Synthesize(ctx context.Context, userID int64, recipeID int64) (*model.UserInventory, error) {
|
func (s *service) Synthesize(ctx context.Context, userID int64, recipeID int64) (*model.UserInventory, error) {
|
||||||
|
result, err := s.batchSynthesize(ctx, userID, recipeID, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(result.ProducedInventoryIDs) == 0 {
|
||||||
|
return nil, fmt.Errorf("synthesis_failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
var newInv model.UserInventory
|
||||||
|
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", result.ProducedInventoryIDs[0]).First(&newInv).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &newInv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) BatchSynthesize(ctx context.Context, userID int64, recipeID int64) (*BatchSynthesizeResult, error) {
|
||||||
|
return s.batchSynthesize(ctx, userID, recipeID, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) batchSynthesize(ctx context.Context, userID int64, recipeID int64, limitTimes int64) (*BatchSynthesizeResult, error) {
|
||||||
db := s.repo.GetDbR()
|
db := s.repo.GetDbR()
|
||||||
|
|
||||||
var recipe model.FragmentSynthesisRecipes
|
var recipe model.FragmentSynthesisRecipes
|
||||||
@ -302,16 +343,42 @@ func (s *service) Synthesize(ctx context.Context, userID int64, recipeID int64)
|
|||||||
InventoryIDs []int64
|
InventoryIDs []int64
|
||||||
}
|
}
|
||||||
toConsume := make([]materialConsume, 0, len(materials))
|
toConsume := make([]materialConsume, 0, len(materials))
|
||||||
|
maxTimes := int64(0)
|
||||||
|
initialized := false
|
||||||
|
|
||||||
for _, m := range materials {
|
for _, m := range materials {
|
||||||
|
var ownedCount int64
|
||||||
|
db.WithContext(ctx).Model(&model.UserInventory{}).
|
||||||
|
Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID).
|
||||||
|
Count(&ownedCount)
|
||||||
|
|
||||||
|
currentTimes := int64(0)
|
||||||
|
if m.RequiredCount > 0 {
|
||||||
|
currentTimes = ownedCount / int64(m.RequiredCount)
|
||||||
|
}
|
||||||
|
if !initialized || currentTimes < maxTimes {
|
||||||
|
maxTimes = currentTimes
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if limitTimes > 0 && maxTimes > limitTimes {
|
||||||
|
maxTimes = limitTimes
|
||||||
|
}
|
||||||
|
if maxTimes <= 0 {
|
||||||
|
return nil, fmt.Errorf("insufficient_fragments")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range materials {
|
||||||
|
requiredTotal := int(m.RequiredCount) * int(maxTimes)
|
||||||
var invList []*model.UserInventory
|
var invList []*model.UserInventory
|
||||||
db.WithContext(ctx).
|
db.WithContext(ctx).
|
||||||
Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID).
|
Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID).
|
||||||
Order("id ASC").
|
Order("id ASC").
|
||||||
Limit(int(m.RequiredCount)).
|
Limit(requiredTotal).
|
||||||
Find(&invList)
|
Find(&invList)
|
||||||
|
|
||||||
if int32(len(invList)) < m.RequiredCount {
|
if len(invList) < requiredTotal {
|
||||||
return nil, fmt.Errorf("insufficient_fragments")
|
return nil, fmt.Errorf("insufficient_fragments")
|
||||||
}
|
}
|
||||||
ids := make([]int64, len(invList))
|
ids := make([]int64, len(invList))
|
||||||
@ -325,52 +392,84 @@ func (s *service) Synthesize(ctx context.Context, userID int64, recipeID int64)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var newInv model.UserInventory
|
result := &BatchSynthesizeResult{
|
||||||
|
RecipeID: recipeID,
|
||||||
|
TargetProductID: recipe.TargetProductID,
|
||||||
|
TargetProductName: targetProduct.Name,
|
||||||
|
SynthesizedCount: maxTimes,
|
||||||
|
}
|
||||||
|
|
||||||
wdb := s.repo.GetDbW()
|
wdb := s.repo.GetDbW()
|
||||||
err := wdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := wdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
allConsumedIDs := make([]int64, 0)
|
consumedByRound := make([][]int64, int(maxTimes))
|
||||||
|
allConsumedCount := 0
|
||||||
|
|
||||||
for _, mc := range toConsume {
|
for _, mc := range toConsume {
|
||||||
var locked []model.UserInventory
|
var locked []model.UserInventory
|
||||||
if err := tx.Raw("SELECT * FROM user_inventory WHERE id IN ? AND user_id = ? AND status = 1 FOR UPDATE", mc.InventoryIDs, userID).Scan(&locked).Error; err != nil {
|
query := tx.WithContext(ctx).Where("id IN ? AND user_id = ? AND status = 1", mc.InventoryIDs, userID)
|
||||||
|
if tx.Dialector.Name() != "sqlite" {
|
||||||
|
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||||
|
}
|
||||||
|
if err := query.Find(&locked).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if int32(len(locked)) < mc.Required {
|
if len(locked) < len(mc.InventoryIDs) {
|
||||||
return fmt.Errorf("insufficient_fragments")
|
return fmt.Errorf("insufficient_fragments")
|
||||||
}
|
}
|
||||||
if err := tx.Exec(
|
updates := map[string]interface{}{
|
||||||
"UPDATE user_inventory SET status = 2, updated_at = NOW(3), remark = CONCAT(IFNULL(remark,''), '|synthesis_consumed:recipe_', ?) WHERE id IN ? AND user_id = ? AND status = 1",
|
"status": 2,
|
||||||
recipeID, mc.InventoryIDs, userID,
|
"updated_at": time.Now(),
|
||||||
).Error; err != nil {
|
}
|
||||||
|
if tx.Dialector.Name() == "sqlite" {
|
||||||
|
updates["remark"] = gorm.Expr("COALESCE(remark, '') || ?", fmt.Sprintf("|synthesis_consumed:recipe_%d", recipeID))
|
||||||
|
} else {
|
||||||
|
updates["remark"] = gorm.Expr("CONCAT(IFNULL(remark,''), ?)", fmt.Sprintf("|synthesis_consumed:recipe_%d", recipeID))
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.UserInventory{}).
|
||||||
|
Where("id IN ? AND user_id = ? AND status = 1", mc.InventoryIDs, userID).
|
||||||
|
Updates(updates).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
allConsumedIDs = append(allConsumedIDs, mc.InventoryIDs...)
|
allConsumedCount += len(mc.InventoryIDs)
|
||||||
|
for round := int64(0); round < maxTimes; round++ {
|
||||||
|
start := int(round) * int(mc.Required)
|
||||||
|
end := start + int(mc.Required)
|
||||||
|
consumedByRound[round] = append(consumedByRound[round], mc.InventoryIDs[start:end]...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newInv = model.UserInventory{
|
result.ConsumedInventoryCount = allConsumedCount
|
||||||
UserID: userID,
|
result.ProducedInventoryIDs = make([]int64, 0, int(maxTimes))
|
||||||
ProductID: recipe.TargetProductID,
|
for round := int64(0); round < maxTimes; round++ {
|
||||||
ValueCents: targetProduct.Price,
|
newInv := model.UserInventory{
|
||||||
Status: 1,
|
UserID: userID,
|
||||||
Remark: fmt.Sprintf("synthesis_produced:recipe_%d", recipeID),
|
ProductID: recipe.TargetProductID,
|
||||||
}
|
ValueCents: targetProduct.Price,
|
||||||
if err := tx.Omit("ValueSnapshotAt", "ShippingNo").Create(&newInv).Error; err != nil {
|
Status: 1,
|
||||||
return err
|
Remark: fmt.Sprintf("batch_synthesis_produced:recipe_%d:round_%d", recipeID, round+1),
|
||||||
}
|
}
|
||||||
|
if err := tx.Omit("ValueSnapshotAt", "ShippingNo").Create(&newInv).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.ProducedInventoryIDs = append(result.ProducedInventoryIDs, newInv.ID)
|
||||||
|
|
||||||
consumedJSON, _ := json.Marshal(allConsumedIDs)
|
consumedJSON, _ := json.Marshal(consumedByRound[round])
|
||||||
log := &model.FragmentSynthesisLogs{
|
log := &model.FragmentSynthesisLogs{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
RecipeID: recipeID,
|
RecipeID: recipeID,
|
||||||
ConsumedInventoryIDs: string(consumedJSON),
|
ConsumedInventoryIDs: string(consumedJSON),
|
||||||
ProducedInventoryID: newInv.ID,
|
ProducedInventoryID: newInv.ID,
|
||||||
|
}
|
||||||
|
if err := tx.Create(log).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return tx.Create(log).Error
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &newInv, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) ListLogs(ctx context.Context, page, size int, userID *int64) ([]*SynthesisLogView, int64, error) {
|
func (s *service) ListLogs(ctx context.Context, page, size int, userID *int64) ([]*SynthesisLogView, int64, error) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"bindbox-game/internal/repository/mysql"
|
"bindbox-game/internal/repository/mysql"
|
||||||
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -18,24 +19,69 @@ func newSynthesisServiceForTest(t *testing.T) *service {
|
|||||||
t.Fatalf("open sqlite failed: %v", err)
|
t.Fatalf("open sqlite failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Exec(`
|
statements := []string{
|
||||||
CREATE TABLE product_categories (
|
`CREATE TABLE product_categories (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
is_fragment INTEGER NOT NULL DEFAULT 0,
|
is_fragment INTEGER NOT NULL DEFAULT 0,
|
||||||
deleted_at DATETIME NULL
|
deleted_at DATETIME NULL
|
||||||
);
|
);`,
|
||||||
`).Error; err != nil {
|
`CREATE TABLE products (
|
||||||
t.Fatalf("create product_categories failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Exec(`
|
|
||||||
CREATE TABLE products (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
category_id INTEGER NOT NULL DEFAULT 0,
|
category_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
price INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
images_json TEXT NOT NULL DEFAULT '',
|
||||||
deleted_at DATETIME NULL
|
deleted_at DATETIME NULL
|
||||||
);
|
);`,
|
||||||
`).Error; err != nil {
|
`CREATE TABLE fragment_synthesis_recipes (
|
||||||
t.Fatalf("create products failed: %v", err)
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
target_product_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NULL,
|
||||||
|
updated_at DATETIME NULL,
|
||||||
|
deleted_at DATETIME NULL
|
||||||
|
);`,
|
||||||
|
`CREATE TABLE fragment_synthesis_recipe_materials (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
recipe_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fragment_product_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
required_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NULL,
|
||||||
|
updated_at DATETIME NULL,
|
||||||
|
deleted_at DATETIME NULL
|
||||||
|
);`,
|
||||||
|
`CREATE TABLE fragment_synthesis_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at DATETIME NULL,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
recipe_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
consumed_inventory_ids TEXT NOT NULL DEFAULT '',
|
||||||
|
produced_inventory_id INTEGER NOT NULL DEFAULT 0
|
||||||
|
);`,
|
||||||
|
`CREATE TABLE user_inventory (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at DATETIME NULL,
|
||||||
|
updated_at DATETIME NULL,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
product_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
value_cents INTEGER NOT NULL DEFAULT 0,
|
||||||
|
value_source INTEGER NOT NULL DEFAULT 0,
|
||||||
|
value_snapshot_at DATETIME NULL,
|
||||||
|
order_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
activity_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reward_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
shipping_no TEXT NOT NULL DEFAULT '',
|
||||||
|
remark TEXT NOT NULL DEFAULT ''
|
||||||
|
);`,
|
||||||
|
}
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
t.Fatalf("exec schema failed: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return New(mysql.NewTestRepo(db)).(*service)
|
return New(mysql.NewTestRepo(db)).(*service)
|
||||||
@ -93,3 +139,111 @@ func TestValidateRecipeProducts_ValidCombination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBatchSynthesizeProducesAllPossibleItems(t *testing.T) {
|
||||||
|
svc := newSynthesisServiceForTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedBatchSynthesisFixture(t, svc)
|
||||||
|
|
||||||
|
result, err := svc.BatchSynthesize(ctx, 1001, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch synthesize failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.SynthesizedCount != 3 {
|
||||||
|
t.Fatalf("expected 3 syntheses, got %d", result.SynthesizedCount)
|
||||||
|
}
|
||||||
|
if len(result.ProducedInventoryIDs) != 3 {
|
||||||
|
t.Fatalf("expected 3 produced ids, got %d", len(result.ProducedInventoryIDs))
|
||||||
|
}
|
||||||
|
if result.ConsumedInventoryCount != 9 {
|
||||||
|
t.Fatalf("expected 9 consumed inventory items, got %d", result.ConsumedInventoryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertInventoryStatusCount(t, svc, 1001, 11, 2, 6)
|
||||||
|
assertInventoryStatusCount(t, svc, 1001, 12, 2, 3)
|
||||||
|
assertInventoryStatusCount(t, svc, 1001, 10, 1, 3)
|
||||||
|
assertSynthesisLogCount(t, svc, 1001, 1, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchSynthesizeUsesShortestMaterial(t *testing.T) {
|
||||||
|
svc := newSynthesisServiceForTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedBatchSynthesisFixture(t, svc)
|
||||||
|
|
||||||
|
if err := svc.repo.GetDbW().Exec("INSERT INTO user_inventory(user_id, product_id, value_cents, status, remark) VALUES (1001, 12, 0, 1, 'extra_fragment')").Error; err != nil {
|
||||||
|
t.Fatalf("seed extra fragment failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.BatchSynthesize(ctx, 1001, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch synthesize failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.SynthesizedCount != 3 {
|
||||||
|
t.Fatalf("expected shortest material to cap at 3, got %d", result.SynthesizedCount)
|
||||||
|
}
|
||||||
|
assertInventoryStatusCount(t, svc, 1001, 12, 1, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchSynthesizeFailsWhenInsufficient(t *testing.T) {
|
||||||
|
svc := newSynthesisServiceForTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedBatchSynthesisFixture(t, svc)
|
||||||
|
|
||||||
|
if err := svc.repo.GetDbW().Exec("DELETE FROM user_inventory WHERE user_id = ? AND product_id = ?", 1001, 12).Error; err != nil {
|
||||||
|
t.Fatalf("clear fragments failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.BatchSynthesize(ctx, 1001, 1)
|
||||||
|
if err == nil || err.Error() != "insufficient_fragments" {
|
||||||
|
t.Fatalf("expected insufficient_fragments, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedBatchSynthesisFixture(t *testing.T, svc *service) {
|
||||||
|
t.Helper()
|
||||||
|
db := svc.repo.GetDbW()
|
||||||
|
|
||||||
|
statements := []string{
|
||||||
|
"INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)",
|
||||||
|
"INSERT INTO products(id, category_id, name, price, status) VALUES (10, 2, '目标商品', 1999, 1), (11, 1, '碎片A', 0, 1), (12, 1, '碎片B', 0, 1)",
|
||||||
|
"INSERT INTO fragment_synthesis_recipes(id, name, description, target_product_id, status) VALUES (1, '配方1', '测试配方', 10, 1)",
|
||||||
|
"INSERT INTO fragment_synthesis_recipe_materials(id, recipe_id, fragment_product_id, required_count) VALUES (1, 1, 11, 2), (2, 1, 12, 1)",
|
||||||
|
}
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
t.Fatalf("seed fixture failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
if err := db.Create(&model.UserInventory{UserID: 1001, ProductID: 11, ValueCents: 0, Status: 1, Remark: "fragment_a"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed fragment a failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := db.Create(&model.UserInventory{UserID: 1001, ProductID: 12, ValueCents: 0, Status: 1, Remark: "fragment_b"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed fragment b failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertInventoryStatusCount(t *testing.T, svc *service, userID, productID int64, status int32, want int64) {
|
||||||
|
t.Helper()
|
||||||
|
var count int64
|
||||||
|
if err := svc.repo.GetDbR().Model(&model.UserInventory{}).Where("user_id = ? AND product_id = ? AND status = ?", userID, productID, status).Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count inventory failed: %v", err)
|
||||||
|
}
|
||||||
|
if count != want {
|
||||||
|
t.Fatalf("expected %d inventory rows for product %d status %d, got %d", want, productID, status, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSynthesisLogCount(t *testing.T, svc *service, userID, recipeID int64, want int64) {
|
||||||
|
t.Helper()
|
||||||
|
var count int64
|
||||||
|
if err := svc.repo.GetDbR().Model(&model.FragmentSynthesisLogs{}).Where("user_id = ? AND recipe_id = ?", userID, recipeID).Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count logs failed: %v", err)
|
||||||
|
}
|
||||||
|
if count != want {
|
||||||
|
t.Fatalf("expected %d synthesis logs, got %d", want, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -15,6 +15,17 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
shippingFeeThreshold = 5
|
||||||
|
shippingFeeReasonBelowThreshold = "below_threshold"
|
||||||
|
shippingFeeReasonContainsNonFreeShipping = "contains_non_free_shipping_item"
|
||||||
|
)
|
||||||
|
|
||||||
|
var nonFreeShippingCategoryIDs = map[int64]struct{}{
|
||||||
|
14: {},
|
||||||
|
15: {},
|
||||||
|
}
|
||||||
|
|
||||||
type shareClaims struct {
|
type shareClaims struct {
|
||||||
OwnerUserID int64 `json:"owner_user_id"`
|
OwnerUserID int64 `json:"owner_user_id"`
|
||||||
InventoryID int64 `json:"inventory_id"`
|
InventoryID int64 `json:"inventory_id"`
|
||||||
@ -319,6 +330,63 @@ func generateBatchNo(userID int64) string {
|
|||||||
return fmt.Sprintf("B%d%d", userID, time.Now().UnixNano()/1000000)
|
return fmt.Sprintf("B%d%d", userID, time.Now().UnixNano()/1000000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *service) CheckShippingFeeRequirement(ctx context.Context, userID int64, inventoryIDs []int64) (bool, string, error) {
|
||||||
|
uniqMap := make(map[int64]struct{}, len(inventoryIDs))
|
||||||
|
uniq := make([]int64, 0, len(inventoryIDs))
|
||||||
|
for _, id := range inventoryIDs {
|
||||||
|
if id <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := uniqMap[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uniqMap[id] = struct{}{}
|
||||||
|
uniq = append(uniq, id)
|
||||||
|
}
|
||||||
|
if len(uniq) == 0 {
|
||||||
|
return false, "", fmt.Errorf("invalid inventory_ids")
|
||||||
|
}
|
||||||
|
|
||||||
|
invList, err := s.readDB.UserInventory.WithContext(ctx).
|
||||||
|
Where(s.readDB.UserInventory.ID.In(uniq...)).
|
||||||
|
Find()
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
productIDSet := make(map[int64]struct{}, len(invList))
|
||||||
|
productIDs := make([]int64, 0, len(invList))
|
||||||
|
for _, inv := range invList {
|
||||||
|
if inv == nil || inv.UserID != userID || inv.ProductID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := productIDSet[inv.ProductID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
productIDSet[inv.ProductID] = struct{}{}
|
||||||
|
productIDs = append(productIDs, inv.ProductID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(productIDs) > 0 {
|
||||||
|
products, err := s.readDB.Products.WithContext(ctx).
|
||||||
|
Where(s.readDB.Products.ID.In(productIDs...)).
|
||||||
|
Find()
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
for _, product := range products {
|
||||||
|
if _, ok := nonFreeShippingCategoryIDs[product.CategoryID]; ok {
|
||||||
|
return true, shippingFeeReasonContainsNonFreeShipping, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(uniq) < shippingFeeThreshold {
|
||||||
|
return true, shippingFeeReasonBelowThreshold, nil
|
||||||
|
}
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (addrID int64, batchNo string, success []int64, skipped []struct {
|
func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (addrID int64, batchNo string, success []int64, skipped []struct {
|
||||||
ID int64
|
ID int64
|
||||||
Reason string
|
Reason string
|
||||||
|
|||||||
@ -29,7 +29,6 @@ func TestRequestShippings_EmptyInventoryIDs(t *testing.T) {
|
|||||||
db, _ := setupMockDBForShipping(t)
|
db, _ := setupMockDBForShipping(t)
|
||||||
svc := newTestService(db)
|
svc := newTestService(db)
|
||||||
|
|
||||||
// Empty inventory IDs should return failed with "invalid_params"
|
|
||||||
_, _, _, _, failed, err := svc.RequestShippings(context.Background(), 1, []int64{}, nil)
|
_, _, _, _, failed, err := svc.RequestShippings(context.Background(), 1, []int64{}, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, failed, 1)
|
assert.Len(t, failed, 1)
|
||||||
@ -40,7 +39,6 @@ func TestRequestShippings_AllZeroInventoryIDs(t *testing.T) {
|
|||||||
db, _ := setupMockDBForShipping(t)
|
db, _ := setupMockDBForShipping(t)
|
||||||
svc := newTestService(db)
|
svc := newTestService(db)
|
||||||
|
|
||||||
// All zero or negative IDs should be filtered, resulting in empty uniq list
|
|
||||||
_, _, _, _, failed, err := svc.RequestShippings(context.Background(), 1, []int64{0, -1, 0}, nil)
|
_, _, _, _, failed, err := svc.RequestShippings(context.Background(), 1, []int64{0, -1, 0}, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, failed, 1)
|
assert.Len(t, failed, 1)
|
||||||
@ -51,17 +49,88 @@ func TestRequestShippings_NoDefaultAddress(t *testing.T) {
|
|||||||
db, mock := setupMockDBForShipping(t)
|
db, mock := setupMockDBForShipping(t)
|
||||||
svc := newTestService(db)
|
svc := newTestService(db)
|
||||||
|
|
||||||
// Mock default address query - return no rows
|
mock.ExpectQuery("SELECT .* FROM `user_addresses`").
|
||||||
|
WillReturnRows(sqlmock.NewRows(nil))
|
||||||
mock.ExpectQuery("SELECT .* FROM `user_addresses`").
|
mock.ExpectQuery("SELECT .* FROM `user_addresses`").
|
||||||
WillReturnRows(sqlmock.NewRows(nil))
|
WillReturnRows(sqlmock.NewRows(nil))
|
||||||
|
|
||||||
// Mock all addresses query - return empty
|
|
||||||
mock.ExpectQuery("SELECT .* FROM `user_addresses`").
|
|
||||||
WillReturnRows(sqlmock.NewRows(nil))
|
|
||||||
|
|
||||||
// With valid IDs but no address, should return no_default_address error
|
|
||||||
_, _, _, _, failed, err := svc.RequestShippings(context.Background(), 1, []int64{1, 2}, nil)
|
_, _, _, _, failed, err := svc.RequestShippings(context.Background(), 1, []int64{1, 2}, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, failed, 1)
|
assert.Len(t, failed, 1)
|
||||||
assert.Equal(t, "no_default_address", failed[0].Reason)
|
assert.Equal(t, "no_default_address", failed[0].Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckShippingFeeRequirement_BelowThreshold(t *testing.T) {
|
||||||
|
db, mock := setupMockDBForShipping(t)
|
||||||
|
svc := newTestService(db)
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `user_inventory`").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "product_id"}).
|
||||||
|
AddRow(1, 99, 101).
|
||||||
|
AddRow(2, 99, 102).
|
||||||
|
AddRow(3, 99, 103).
|
||||||
|
AddRow(4, 99, 104))
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `products`").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "category_id"}).
|
||||||
|
AddRow(101, 1).
|
||||||
|
AddRow(102, 2).
|
||||||
|
AddRow(103, 3).
|
||||||
|
AddRow(104, 4))
|
||||||
|
|
||||||
|
needFee, reason, err := svc.CheckShippingFeeRequirement(context.Background(), 99, []int64{1, 2, 3, 4})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, needFee)
|
||||||
|
assert.Equal(t, shippingFeeReasonBelowThreshold, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckShippingFeeRequirement_FreeWhenThresholdReached(t *testing.T) {
|
||||||
|
db, mock := setupMockDBForShipping(t)
|
||||||
|
svc := newTestService(db)
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `user_inventory`").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "product_id"}).
|
||||||
|
AddRow(1, 99, 101).
|
||||||
|
AddRow(2, 99, 102).
|
||||||
|
AddRow(3, 99, 103).
|
||||||
|
AddRow(4, 99, 104).
|
||||||
|
AddRow(5, 99, 105))
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `products`").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "category_id"}).
|
||||||
|
AddRow(101, 1).
|
||||||
|
AddRow(102, 2).
|
||||||
|
AddRow(103, 3).
|
||||||
|
AddRow(104, 4).
|
||||||
|
AddRow(105, 5))
|
||||||
|
|
||||||
|
needFee, reason, err := svc.CheckShippingFeeRequirement(context.Background(), 99, []int64{1, 2, 3, 4, 5})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, needFee)
|
||||||
|
assert.Equal(t, "", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckShippingFeeRequirement_NonFreeCategoryOverridesThreshold(t *testing.T) {
|
||||||
|
db, mock := setupMockDBForShipping(t)
|
||||||
|
svc := newTestService(db)
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `user_inventory`").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "product_id"}).
|
||||||
|
AddRow(1, 99, 101).
|
||||||
|
AddRow(2, 99, 102).
|
||||||
|
AddRow(3, 99, 103).
|
||||||
|
AddRow(4, 99, 104).
|
||||||
|
AddRow(5, 99, 105).
|
||||||
|
AddRow(6, 99, 106))
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `products`").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "category_id"}).
|
||||||
|
AddRow(101, 1).
|
||||||
|
AddRow(102, 2).
|
||||||
|
AddRow(103, 3).
|
||||||
|
AddRow(104, 4).
|
||||||
|
AddRow(105, 14).
|
||||||
|
AddRow(106, 5))
|
||||||
|
|
||||||
|
needFee, reason, err := svc.CheckShippingFeeRequirement(context.Background(), 99, []int64{1, 2, 3, 4, 5, 6})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, needFee)
|
||||||
|
assert.Equal(t, shippingFeeReasonContainsNonFreeShipping, reason)
|
||||||
|
}
|
||||||
|
|||||||
@ -57,6 +57,7 @@ type Service interface {
|
|||||||
SubmitAddressShare(ctx context.Context, shareToken string, name string, mobile string, province string, city string, district string, address string, submittedByUserID *int64, submittedIP *string) (int64, error)
|
SubmitAddressShare(ctx context.Context, shareToken string, name string, mobile string, province string, city string, district string, address string, submittedByUserID *int64, submittedIP *string) (int64, error)
|
||||||
RequestShipping(ctx context.Context, userID int64, inventoryID int64) (int64, error)
|
RequestShipping(ctx context.Context, userID int64, inventoryID int64) (int64, error)
|
||||||
CancelShipping(ctx context.Context, userID int64, inventoryID int64, batchNo string) (int64, error)
|
CancelShipping(ctx context.Context, userID int64, inventoryID int64, batchNo string) (int64, error)
|
||||||
|
CheckShippingFeeRequirement(ctx context.Context, userID int64, inventoryIDs []int64) (needFee bool, reason string, err error)
|
||||||
RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (addrID int64, batchNo string, success []int64, skipped []struct {
|
RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (addrID int64, batchNo string, success []int64, skipped []struct {
|
||||||
ID int64
|
ID int64
|
||||||
Reason string
|
Reason string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user