feat: 实现单卡资产分配与回收功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m45s

- 新增单卡分配/回收 API(支持 ICCID 列表、号段范围、筛选条件三种选卡方式)
- 新增资产分配记录查询 API(支持多条件筛选和分页)
- 新增 AssetAllocationRecord 模型、Store、Service、Handler 完整实现
- 扩展 IotCardStore 新增批量更新、号段查询、筛选查询等方法
- 修复 GORM Callback 处理 slice 类型(BatchCreate)的问题
- 新增完整的单元测试和集成测试
- 同步 OpenSpec 规范并归档 change
This commit is contained in:
2026-01-24 15:46:15 +08:00
parent a924e63e68
commit 194078674a
33 changed files with 2785 additions and 92 deletions

View File

@@ -28,5 +28,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
IotCard: admin.NewIotCardHandler(svc.IotCard),
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
}
}

View File

@@ -2,6 +2,7 @@ package bootstrap
import (
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
@@ -36,6 +37,7 @@ type services struct {
MyCommission *myCommissionSvc.Service
IotCard *iotCardSvc.Service
IotCardImport *iotCardImportSvc.Service
AssetAllocationRecord *assetAllocationRecordSvc.Service
}
func initServices(s *stores, deps *Dependencies) *services {
@@ -54,7 +56,8 @@ func initServices(s *stores, deps *Dependencies) *services {
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization),
CustomerAccount: customerAccountSvc.New(deps.DB, s.Account, s.Shop, s.Enterprise),
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction),
IotCard: iotCardSvc.New(deps.DB, s.IotCard),
IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord),
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
}
}

View File

@@ -22,6 +22,7 @@ type stores struct {
EnterpriseCardAuthorization *postgres.EnterpriseCardAuthorizationStore
IotCard *postgres.IotCardStore
IotCardImportTask *postgres.IotCardImportTaskStore
AssetAllocationRecord *postgres.AssetAllocationRecordStore
}
func initStores(deps *Dependencies) *stores {
@@ -43,5 +44,6 @@ func initStores(deps *Dependencies) *stores {
EnterpriseCardAuthorization: postgres.NewEnterpriseCardAuthorizationStore(deps.DB, deps.Redis),
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
}
}

View File

@@ -26,6 +26,7 @@ type Handlers struct {
MyCommission *admin.MyCommissionHandler
IotCard *admin.IotCardHandler
IotCardImport *admin.IotCardImportHandler
AssetAllocationRecord *admin.AssetAllocationRecordHandler
}
// Middlewares 封装所有中间件

View File

@@ -0,0 +1,58 @@
package admin
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetAllocationRecordService "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type AssetAllocationRecordHandler struct {
service *assetAllocationRecordService.Service
}
func NewAssetAllocationRecordHandler(service *assetAllocationRecordService.Service) *AssetAllocationRecordHandler {
return &AssetAllocationRecordHandler{service: service}
}
func (h *AssetAllocationRecordHandler) List(c *fiber.Ctx) error {
var req dto.ListAssetAllocationRecordRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
var userShopID *uint
if userType == constants.UserTypeAgent {
shopID := middleware.GetShopIDFromContext(ctx)
if shopID > 0 {
userShopID = &shopID
}
}
result, err := h.service.List(ctx, &req, userShopID)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
func (h *AssetAllocationRecordHandler) GetByID(c *fiber.Ctx) error {
var req dto.GetAssetAllocationRecordRequest
if err := c.ParamsParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.GetByID(c.UserContext(), req.ID)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -5,7 +5,9 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model/dto"
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
@@ -30,3 +32,55 @@ func (h *IotCardHandler) ListStandalone(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error {
var req dto.AllocateStandaloneCardsRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
operatorID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
var operatorShopID *uint
if userType == constants.UserTypeAgent {
shopID := middleware.GetShopIDFromContext(ctx)
if shopID > 0 {
operatorShopID = &shopID
}
}
result, err := h.service.AllocateCards(ctx, &req, operatorID, operatorShopID)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *IotCardHandler) RecallCards(c *fiber.Ctx) error {
var req dto.RecallStandaloneCardsRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
operatorID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
var operatorShopID *uint
if userType == constants.UserTypeAgent {
shopID := middleware.GetShopIDFromContext(ctx)
if shopID > 0 {
operatorShopID = &shopID
}
}
result, err := h.service.RecallCards(ctx, &req, operatorID, operatorShopID)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -10,7 +10,7 @@ import (
type AssetAllocationRecord struct {
gorm.Model
BaseModel `gorm:"embedded"`
AllocationNo string `gorm:"column:allocation_no;type:varchar(50);uniqueIndex:uk_asset_allocation_no,where:deleted_at IS NULL;not null;comment:分配单号(唯一" json:"allocation_no"`
AllocationNo string `gorm:"column:allocation_no;type:varchar(50);index;not null;comment:分配单号(同批次相同" json:"allocation_no"`
AllocationType string `gorm:"column:allocation_type;type:varchar(20);index;not null;comment:分配类型 allocate=分配 recall=回收" json:"allocation_type"`
AssetType string `gorm:"column:asset_type;type:varchar(20);index;not null;comment:资产类型 iot_card=物联网卡 device=设备" json:"asset_type"`
AssetID uint `gorm:"column:asset_id;index;not null;comment:资产ID" json:"asset_id"`

View File

@@ -0,0 +1,62 @@
package dto
import "time"
// ListAssetAllocationRecordRequest 分配记录列表请求
type ListAssetAllocationRecordRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
AllocationType string `json:"allocation_type" query:"allocation_type" validate:"omitempty,oneof=allocate recall" enum:"allocate,recall" description:"分配类型 (allocate:分配, recall:回收)"`
AssetType string `json:"asset_type" query:"asset_type" validate:"omitempty,oneof=iot_card device" enum:"iot_card,device" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=50" maxLength:"50" description:"资产标识符ICCID或设备号模糊查询"`
AllocationNo string `json:"allocation_no" query:"allocation_no" validate:"omitempty,max=50" maxLength:"50" description:"分配单号(精确匹配)"`
FromShopID *uint `json:"from_shop_id" query:"from_shop_id" description:"来源店铺ID"`
ToShopID *uint `json:"to_shop_id" query:"to_shop_id" description:"目标店铺ID"`
OperatorID *uint `json:"operator_id" query:"operator_id" description:"操作人ID"`
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
}
// AssetAllocationRecordResponse 分配记录响应
type AssetAllocationRecordResponse struct {
ID uint `json:"id" description:"记录ID"`
AllocationNo string `json:"allocation_no" description:"分配单号"`
AllocationType string `json:"allocation_type" description:"分配类型 (allocate:分配, recall:回收)"`
AllocationName string `json:"allocation_name" description:"分配类型名称"`
AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetTypeName string `json:"asset_type_name" description:"资产类型名称"`
AssetID uint `json:"asset_id" description:"资产ID"`
AssetIdentifier string `json:"asset_identifier" description:"资产标识符ICCID或设备号"`
FromOwnerType string `json:"from_owner_type" description:"来源所有者类型"`
FromOwnerID *uint `json:"from_owner_id,omitempty" description:"来源所有者ID"`
FromOwnerName string `json:"from_owner_name" description:"来源所有者名称"`
ToOwnerType string `json:"to_owner_type" description:"目标所有者类型"`
ToOwnerID uint `json:"to_owner_id" description:"目标所有者ID"`
ToOwnerName string `json:"to_owner_name" description:"目标所有者名称"`
OperatorID uint `json:"operator_id" description:"操作人ID"`
OperatorName string `json:"operator_name" description:"操作人名称"`
Remark string `json:"remark,omitempty" description:"备注"`
RelatedDeviceID *uint `json:"related_device_id,omitempty" description:"关联设备ID"`
RelatedCardCount int `json:"related_card_count,omitempty" description:"关联卡数量"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// ListAssetAllocationRecordResponse 分配记录列表响应
type ListAssetAllocationRecordResponse struct {
List []*AssetAllocationRecordResponse `json:"list" description:"分配记录列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}
// GetAssetAllocationRecordRequest 获取分配记录详情请求
type GetAssetAllocationRecordRequest struct {
ID uint `json:"-" params:"id" path:"id" validate:"required,min=1" required:"true" minimum:"1" description:"记录ID"`
}
// AssetAllocationRecordDetailResponse 分配记录详情响应
type AssetAllocationRecordDetailResponse struct {
AssetAllocationRecordResponse
RelatedCardIDs []uint `json:"related_card_ids,omitempty" description:"关联卡ID列表"`
}

View File

@@ -0,0 +1,108 @@
package dto
// ========== 选卡方式常量 ==========
const (
// SelectionTypeList ICCID列表选择
SelectionTypeList = "list"
// SelectionTypeRange 号段范围选择
SelectionTypeRange = "range"
// SelectionTypeFilter 筛选条件选择
SelectionTypeFilter = "filter"
)
// ========== 分配请求/响应 ==========
// AllocateStandaloneCardsRequest 分配单卡请求
type AllocateStandaloneCardsRequest struct {
// ToShopID 目标店铺ID必填必须是直属下级
ToShopID uint `json:"to_shop_id" validate:"required,min=1" required:"true" minimum:"1" description:"目标店铺ID"`
// SelectionType 选卡方式(必填)
// list: ICCID列表选择
// range: 号段范围选择
// filter: 筛选条件选择
SelectionType string `json:"selection_type" validate:"required,oneof=list range filter" required:"true" enum:"list,range,filter" description:"选卡方式 (list:ICCID列表, range:号段范围, filter:筛选条件)"`
// ===== selection_type=list 时使用 =====
// ICCIDs ICCID列表最多1000个
ICCIDs []string `json:"iccids" validate:"required_if=SelectionType list,omitempty,max=1000,dive,required,max=20" description:"ICCID列表selection_type=list时必填最多1000个"`
// ===== selection_type=range 时使用 =====
// ICCIDStart 起始ICCID
ICCIDStart string `json:"iccid_start" validate:"required_if=SelectionType range,omitempty,max=20" maxLength:"20" description:"起始ICCIDselection_type=range时必填"`
// ICCIDEnd 结束ICCID
ICCIDEnd string `json:"iccid_end" validate:"required_if=SelectionType range,omitempty,max=20" maxLength:"20" description:"结束ICCIDselection_type=range时必填"`
// ===== selection_type=filter 时使用 =====
// CarrierID 运营商ID
CarrierID *uint `json:"carrier_id" description:"运营商IDselection_type=filter时可选"`
// BatchNo 批次号
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号selection_type=filter时可选"`
// Status 卡状态
Status *int `json:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"卡状态 (1:在库, 2:已分销)selection_type=filter时可选"`
// Remark 备注
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
}
// AllocateStandaloneCardsResponse 分配单卡响应
type AllocateStandaloneCardsResponse struct {
// TotalCount 待分配总数
TotalCount int `json:"total_count" description:"待分配总数"`
// SuccessCount 成功数
SuccessCount int `json:"success_count" description:"成功数"`
// FailCount 失败数
FailCount int `json:"fail_count" description:"失败数"`
// AllocationNo 分配单号
AllocationNo string `json:"allocation_no" description:"分配单号"`
// FailedItems 失败项列表
FailedItems []AllocationFailedItem `json:"failed_items" description:"失败项列表"`
}
// AllocationFailedItem 分配失败项
type AllocationFailedItem struct {
// ICCID 卡ICCID
ICCID string `json:"iccid" description:"ICCID"`
// Reason 失败原因
Reason string `json:"reason" description:"失败原因"`
}
// ========== 回收请求/响应 ==========
// RecallStandaloneCardsRequest 回收单卡请求
type RecallStandaloneCardsRequest struct {
// FromShopID 来源店铺ID必填被回收方必须是直属下级
FromShopID uint `json:"from_shop_id" validate:"required,min=1" required:"true" minimum:"1" description:"来源店铺ID被回收方"`
// SelectionType 选卡方式(必填)
SelectionType string `json:"selection_type" validate:"required,oneof=list range filter" required:"true" enum:"list,range,filter" description:"选卡方式 (list:ICCID列表, range:号段范围, filter:筛选条件)"`
// ===== selection_type=list 时使用 =====
ICCIDs []string `json:"iccids" validate:"required_if=SelectionType list,omitempty,max=1000,dive,required,max=20" description:"ICCID列表selection_type=list时必填最多1000个"`
// ===== selection_type=range 时使用 =====
ICCIDStart string `json:"iccid_start" validate:"required_if=SelectionType range,omitempty,max=20" maxLength:"20" description:"起始ICCIDselection_type=range时必填"`
ICCIDEnd string `json:"iccid_end" validate:"required_if=SelectionType range,omitempty,max=20" maxLength:"20" description:"结束ICCIDselection_type=range时必填"`
// ===== selection_type=filter 时使用 =====
CarrierID *uint `json:"carrier_id" description:"运营商IDselection_type=filter时可选"`
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号selection_type=filter时可选"`
// Remark 备注
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
}
// RecallStandaloneCardsResponse 回收单卡响应
type RecallStandaloneCardsResponse struct {
// TotalCount 待回收总数
TotalCount int `json:"total_count" description:"待回收总数"`
// SuccessCount 成功数
SuccessCount int `json:"success_count" description:"成功数"`
// FailCount 失败数
FailCount int `json:"fail_count" description:"失败数"`
// AllocationNo 回收单号
AllocationNo string `json:"allocation_no" description:"回收单号"`
// FailedItems 失败项列表
FailedItems []AllocationFailedItem `json:"failed_items" description:"失败项列表"`
}

View File

@@ -55,6 +55,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.IotCard != nil {
registerIotCardRoutes(authGroup, handlers.IotCard, handlers.IotCardImport, doc, basePath)
}
if handlers.AssetAllocationRecord != nil {
registerAssetAllocationRecordRoutes(authGroup, handlers.AssetAllocationRecord, doc, basePath)
}
}
func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {

View File

@@ -0,0 +1,30 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerAssetAllocationRecordRoutes(router fiber.Router, handler *admin.AssetAllocationRecordHandler, doc *openapi.Generator, basePath string) {
records := router.Group("/asset-allocation-records")
groupPath := basePath + "/asset-allocation-records"
Register(records, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "分配记录列表",
Tags: []string{"资产分配记录"},
Input: new(dto.ListAssetAllocationRecordRequest),
Output: new(dto.ListAssetAllocationRecordResponse),
Auth: true,
})
Register(records, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{
Summary: "分配记录详情",
Tags: []string{"资产分配记录"},
Input: new(dto.GetAssetAllocationRecordRequest),
Output: new(dto.AssetAllocationRecordDetailResponse),
Auth: true,
})
}

View File

@@ -43,4 +43,20 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i
Output: new(dto.ImportTaskDetailResponse),
Auth: true,
})
Register(iotCards, doc, groupPath, "POST", "/standalone/allocate", handler.AllocateCards, RouteSpec{
Summary: "批量分配单卡",
Tags: []string{"IoT卡管理"},
Input: new(dto.AllocateStandaloneCardsRequest),
Output: new(dto.AllocateStandaloneCardsResponse),
Auth: true,
})
Register(iotCards, doc, groupPath, "POST", "/standalone/recall", handler.RecallCards, RouteSpec{
Summary: "批量回收单卡",
Tags: []string{"IoT卡管理"},
Input: new(dto.RecallStandaloneCardsRequest),
Output: new(dto.RecallStandaloneCardsResponse),
Auth: true,
})
}

View File

@@ -0,0 +1,253 @@
package asset_allocation_record
import (
"context"
"encoding/json"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
}
func New(
db *gorm.DB,
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore,
) *Service {
return &Service{
db: db,
assetAllocationRecordStore: assetAllocationRecordStore,
shopStore: shopStore,
accountStore: accountStore,
}
}
func (s *Service) List(ctx context.Context, req *dto.ListAssetAllocationRecordRequest, userShopID *uint) (*dto.ListAssetAllocationRecordResponse, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = constants.DefaultPageSize
}
opts := &store.QueryOptions{
Page: page,
PageSize: pageSize,
}
filters := make(map[string]any)
if req.AllocationType != "" {
filters["allocation_type"] = req.AllocationType
}
if req.AssetType != "" {
filters["asset_type"] = req.AssetType
}
if req.AssetIdentifier != "" {
filters["asset_identifier"] = req.AssetIdentifier
}
if req.AllocationNo != "" {
filters["allocation_no"] = req.AllocationNo
}
if req.FromShopID != nil {
filters["from_shop_id"] = *req.FromShopID
}
if req.ToShopID != nil {
filters["to_shop_id"] = *req.ToShopID
}
if req.OperatorID != nil {
filters["operator_id"] = *req.OperatorID
}
if req.CreatedAtStart != nil {
filters["created_at_start"] = *req.CreatedAtStart
}
if req.CreatedAtEnd != nil {
filters["created_at_end"] = *req.CreatedAtEnd
}
if userShopID != nil {
subordinateIDs, err := s.shopStore.GetSubordinateShopIDs(ctx, *userShopID)
if err != nil {
return nil, err
}
filters["related_shop_ids"] = subordinateIDs
}
records, total, err := s.assetAllocationRecordStore.List(ctx, opts, filters)
if err != nil {
return nil, err
}
shopIDs, operatorIDs := s.collectRelatedIDs(records)
shopMap := s.loadShopNames(ctx, shopIDs)
operatorMap := s.loadOperatorNames(ctx, operatorIDs)
list := make([]*dto.AssetAllocationRecordResponse, 0, len(records))
for _, record := range records {
item := s.toResponse(record, shopMap, operatorMap)
list = append(list, item)
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return &dto.ListAssetAllocationRecordResponse{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AssetAllocationRecordDetailResponse, error) {
record, err := s.assetAllocationRecordStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.ErrAssetAllocationRecordNotFound
}
return nil, err
}
shopIDs, operatorIDs := s.collectRelatedIDs([]*model.AssetAllocationRecord{record})
shopMap := s.loadShopNames(ctx, shopIDs)
operatorMap := s.loadOperatorNames(ctx, operatorIDs)
resp := s.toResponse(record, shopMap, operatorMap)
detail := &dto.AssetAllocationRecordDetailResponse{
AssetAllocationRecordResponse: *resp,
}
if record.RelatedCardIDs != nil {
var cardIDs []uint
if err := json.Unmarshal(record.RelatedCardIDs, &cardIDs); err == nil {
detail.RelatedCardIDs = cardIDs
}
}
return detail, nil
}
func (s *Service) collectRelatedIDs(records []*model.AssetAllocationRecord) ([]uint, []uint) {
shopIDSet := make(map[uint]bool)
operatorIDSet := make(map[uint]bool)
for _, record := range records {
if record.FromOwnerType == constants.OwnerTypeShop && record.FromOwnerID != nil {
shopIDSet[*record.FromOwnerID] = true
}
if record.ToOwnerType == constants.OwnerTypeShop {
shopIDSet[record.ToOwnerID] = true
}
operatorIDSet[record.OperatorID] = true
}
shopIDs := make([]uint, 0, len(shopIDSet))
for id := range shopIDSet {
shopIDs = append(shopIDs, id)
}
operatorIDs := make([]uint, 0, len(operatorIDSet))
for id := range operatorIDSet {
operatorIDs = append(operatorIDs, id)
}
return shopIDs, operatorIDs
}
func (s *Service) loadShopNames(ctx context.Context, shopIDs []uint) map[uint]string {
result := make(map[uint]string)
if len(shopIDs) == 0 {
return result
}
var shops []model.Shop
s.db.WithContext(ctx).Where("id IN ?", shopIDs).Find(&shops)
for _, shop := range shops {
result[shop.ID] = shop.ShopName
}
return result
}
func (s *Service) loadOperatorNames(ctx context.Context, operatorIDs []uint) map[uint]string {
result := make(map[uint]string)
if len(operatorIDs) == 0 {
return result
}
var accounts []model.Account
s.db.WithContext(ctx).Where("id IN ?", operatorIDs).Find(&accounts)
for _, account := range accounts {
result[account.ID] = account.Username
}
return result
}
func (s *Service) toResponse(record *model.AssetAllocationRecord, shopMap map[uint]string, operatorMap map[uint]string) *dto.AssetAllocationRecordResponse {
resp := &dto.AssetAllocationRecordResponse{
ID: record.ID,
AllocationNo: record.AllocationNo,
AllocationType: record.AllocationType,
AssetType: record.AssetType,
AssetID: record.AssetID,
AssetIdentifier: record.AssetIdentifier,
FromOwnerType: record.FromOwnerType,
FromOwnerID: record.FromOwnerID,
ToOwnerType: record.ToOwnerType,
ToOwnerID: record.ToOwnerID,
OperatorID: record.OperatorID,
Remark: record.Remark,
RelatedDeviceID: record.RelatedDeviceID,
CreatedAt: record.CreatedAt,
}
if record.AllocationType == constants.AssetAllocationTypeAllocate {
resp.AllocationName = "分配"
} else {
resp.AllocationName = "回收"
}
if record.AssetType == constants.AssetTypeIotCard {
resp.AssetTypeName = "物联网卡"
} else {
resp.AssetTypeName = "设备"
}
if record.FromOwnerType == constants.OwnerTypePlatform {
resp.FromOwnerName = "平台"
} else if record.FromOwnerID != nil {
resp.FromOwnerName = shopMap[*record.FromOwnerID]
}
if record.ToOwnerType == constants.OwnerTypePlatform {
resp.ToOwnerName = "平台"
} else {
resp.ToOwnerName = shopMap[record.ToOwnerID]
}
resp.OperatorName = operatorMap[record.OperatorID]
if record.RelatedCardIDs != nil {
var cardIDs []uint
if err := json.Unmarshal(record.RelatedCardIDs, &cardIDs); err == nil {
resp.RelatedCardCount = len(cardIDs)
}
}
return resp
}

View File

@@ -8,18 +8,28 @@ import (
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
iotCardStore *postgres.IotCardStore
db *gorm.DB
iotCardStore *postgres.IotCardStore
shopStore *postgres.ShopStore
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
}
func New(db *gorm.DB, iotCardStore *postgres.IotCardStore) *Service {
func New(
db *gorm.DB,
iotCardStore *postgres.IotCardStore,
shopStore *postgres.ShopStore,
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
) *Service {
return &Service{
db: db,
iotCardStore: iotCardStore,
db: db,
iotCardStore: iotCardStore,
shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore,
}
}
@@ -169,3 +179,353 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, carrierMap map[uint]
return resp
}
func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateStandaloneCardsResponse, error) {
if err := s.validateDirectSubordinate(ctx, operatorShopID, req.ToShopID); err != nil {
return nil, err
}
cards, err := s.getCardsForAllocation(ctx, req, operatorShopID)
if err != nil {
return nil, err
}
if len(cards) == 0 {
return &dto.AllocateStandaloneCardsResponse{
TotalCount: 0,
SuccessCount: 0,
FailCount: 0,
FailedItems: []dto.AllocationFailedItem{},
}, nil
}
var cardIDs []uint
var failedItems []dto.AllocationFailedItem
boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards))
if err != nil {
return nil, err
}
boundCardIDSet := make(map[uint]bool)
for _, id := range boundCardIDs {
boundCardIDSet[id] = true
}
isPlatform := operatorShopID == nil
for _, card := range cards {
if boundCardIDSet[card.ID] {
failedItems = append(failedItems, dto.AllocationFailedItem{
ICCID: card.ICCID,
Reason: "已绑定设备的卡不能单独分配",
})
continue
}
if isPlatform && card.Status != constants.IotCardStatusInStock {
failedItems = append(failedItems, dto.AllocationFailedItem{
ICCID: card.ICCID,
Reason: "平台只能分配在库状态的卡",
})
continue
}
if !isPlatform && card.Status != constants.IotCardStatusDistributed {
failedItems = append(failedItems, dto.AllocationFailedItem{
ICCID: card.ICCID,
Reason: "代理只能分配已分销状态的卡",
})
continue
}
cardIDs = append(cardIDs, card.ID)
}
if len(cardIDs) == 0 {
return &dto.AllocateStandaloneCardsResponse{
TotalCount: len(cards),
SuccessCount: 0,
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
newStatus := constants.IotCardStatusDistributed
toShopID := req.ToShopID
err = s.db.Transaction(func(tx *gorm.DB) error {
txIotCardStore := postgres.NewIotCardStore(tx, nil)
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
if err := txIotCardStore.BatchUpdateShopIDAndStatus(ctx, cardIDs, &toShopID, newStatus); err != nil {
return err
}
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
records := s.buildAllocationRecords(cards, cardIDs, operatorShopID, toShopID, operatorID, allocationNo, req.Remark)
return txRecordStore.BatchCreate(ctx, records)
})
if err != nil {
return nil, err
}
return &dto.AllocateStandaloneCardsResponse{
TotalCount: len(cards),
SuccessCount: len(cardIDs),
FailCount: len(failedItems),
AllocationNo: s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate),
FailedItems: failedItems,
}, nil
}
func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCardsRequest, operatorID uint, operatorShopID *uint) (*dto.RecallStandaloneCardsResponse, error) {
if err := s.validateDirectSubordinate(ctx, operatorShopID, req.FromShopID); err != nil {
return nil, err
}
cards, err := s.getCardsForRecall(ctx, req)
if err != nil {
return nil, err
}
if len(cards) == 0 {
return &dto.RecallStandaloneCardsResponse{
TotalCount: 0,
SuccessCount: 0,
FailCount: 0,
FailedItems: []dto.AllocationFailedItem{},
}, nil
}
var cardIDs []uint
var failedItems []dto.AllocationFailedItem
boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards))
if err != nil {
return nil, err
}
boundCardIDSet := make(map[uint]bool)
for _, id := range boundCardIDs {
boundCardIDSet[id] = true
}
for _, card := range cards {
if boundCardIDSet[card.ID] {
failedItems = append(failedItems, dto.AllocationFailedItem{
ICCID: card.ICCID,
Reason: "已绑定设备的卡不能单独回收",
})
continue
}
if card.ShopID == nil || *card.ShopID != req.FromShopID {
failedItems = append(failedItems, dto.AllocationFailedItem{
ICCID: card.ICCID,
Reason: "卡不属于指定的店铺",
})
continue
}
cardIDs = append(cardIDs, card.ID)
}
if len(cardIDs) == 0 {
return &dto.RecallStandaloneCardsResponse{
TotalCount: len(cards),
SuccessCount: 0,
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
isPlatform := operatorShopID == nil
var newShopID *uint
var newStatus int
if isPlatform {
newShopID = nil
newStatus = constants.IotCardStatusInStock
} else {
newShopID = operatorShopID
newStatus = constants.IotCardStatusDistributed
}
err = s.db.Transaction(func(tx *gorm.DB) error {
txIotCardStore := postgres.NewIotCardStore(tx, nil)
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
if err := txIotCardStore.BatchUpdateShopIDAndStatus(ctx, cardIDs, newShopID, newStatus); err != nil {
return err
}
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall)
records := s.buildRecallRecords(cards, cardIDs, req.FromShopID, operatorShopID, operatorID, allocationNo, req.Remark)
return txRecordStore.BatchCreate(ctx, records)
})
if err != nil {
return nil, err
}
return &dto.RecallStandaloneCardsResponse{
TotalCount: len(cards),
SuccessCount: len(cardIDs),
FailCount: len(failedItems),
AllocationNo: s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall),
FailedItems: failedItems,
}, nil
}
func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID *uint, targetShopID uint) error {
if operatorShopID != nil && *operatorShopID == targetShopID {
return errors.ErrCannotAllocateToSelf
}
targetShop, err := s.shopStore.GetByID(ctx, targetShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeShopNotFound)
}
return err
}
if operatorShopID == nil {
if targetShop.ParentID != nil {
return errors.ErrNotDirectSubordinate
}
} else {
if targetShop.ParentID == nil || *targetShop.ParentID != *operatorShopID {
return errors.ErrNotDirectSubordinate
}
}
return nil
}
func (s *Service) getCardsForAllocation(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorShopID *uint) ([]*model.IotCard, error) {
switch req.SelectionType {
case dto.SelectionTypeList:
return s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs)
case dto.SelectionTypeRange:
return s.iotCardStore.GetStandaloneByICCIDRange(ctx, req.ICCIDStart, req.ICCIDEnd, operatorShopID)
case dto.SelectionTypeFilter:
filters := make(map[string]any)
if req.CarrierID != nil {
filters["carrier_id"] = *req.CarrierID
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.Status != nil {
filters["status"] = *req.Status
}
return s.iotCardStore.GetStandaloneByFilters(ctx, filters, operatorShopID)
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式")
}
}
func (s *Service) getCardsForRecall(ctx context.Context, req *dto.RecallStandaloneCardsRequest) ([]*model.IotCard, error) {
fromShopID := req.FromShopID
switch req.SelectionType {
case dto.SelectionTypeList:
return s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs)
case dto.SelectionTypeRange:
return s.iotCardStore.GetStandaloneByICCIDRange(ctx, req.ICCIDStart, req.ICCIDEnd, &fromShopID)
case dto.SelectionTypeFilter:
filters := make(map[string]any)
if req.CarrierID != nil {
filters["carrier_id"] = *req.CarrierID
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
return s.iotCardStore.GetStandaloneByFilters(ctx, filters, &fromShopID)
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式")
}
}
func (s *Service) extractCardIDs(cards []*model.IotCard) []uint {
ids := make([]uint, len(cards))
for i, card := range cards {
ids[i] = card.ID
}
return ids
}
func (s *Service) buildAllocationRecords(cards []*model.IotCard, successCardIDs []uint, fromShopID *uint, toShopID uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
successIDSet := make(map[uint]bool)
for _, id := range successCardIDs {
successIDSet[id] = true
}
var records []*model.AssetAllocationRecord
for _, card := range cards {
if !successIDSet[card.ID] {
continue
}
record := &model.AssetAllocationRecord{
AllocationNo: allocationNo,
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeIotCard,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
ToOwnerType: constants.OwnerTypeShop,
ToOwnerID: toShopID,
OperatorID: operatorID,
Remark: remark,
}
if fromShopID == nil {
record.FromOwnerType = constants.OwnerTypePlatform
record.FromOwnerID = nil
} else {
record.FromOwnerType = constants.OwnerTypeShop
record.FromOwnerID = fromShopID
}
records = append(records, record)
}
return records
}
func (s *Service) buildRecallRecords(cards []*model.IotCard, successCardIDs []uint, fromShopID uint, toShopID *uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
successIDSet := make(map[uint]bool)
for _, id := range successCardIDs {
successIDSet[id] = true
}
var records []*model.AssetAllocationRecord
for _, card := range cards {
if !successIDSet[card.ID] {
continue
}
record := &model.AssetAllocationRecord{
AllocationNo: allocationNo,
AllocationType: constants.AssetAllocationTypeRecall,
AssetType: constants.AssetTypeIotCard,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
FromOwnerType: constants.OwnerTypeShop,
FromOwnerID: &fromShopID,
OperatorID: operatorID,
Remark: remark,
}
if toShopID == nil {
record.ToOwnerType = constants.OwnerTypePlatform
record.ToOwnerID = 0
} else {
record.ToOwnerType = constants.OwnerTypeShop
record.ToOwnerID = *toShopID
}
records = append(records, record)
}
return records
}

View File

@@ -0,0 +1,127 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type AssetAllocationRecordStore struct {
db *gorm.DB
redis *redis.Client
}
func NewAssetAllocationRecordStore(db *gorm.DB, redis *redis.Client) *AssetAllocationRecordStore {
return &AssetAllocationRecordStore{
db: db,
redis: redis,
}
}
func (s *AssetAllocationRecordStore) Create(ctx context.Context, record *model.AssetAllocationRecord) error {
return s.db.WithContext(ctx).Create(record).Error
}
func (s *AssetAllocationRecordStore) BatchCreate(ctx context.Context, records []*model.AssetAllocationRecord) error {
if len(records) == 0 {
return nil
}
return s.db.WithContext(ctx).CreateInBatches(records, 100).Error
}
func (s *AssetAllocationRecordStore) GetByID(ctx context.Context, id uint) (*model.AssetAllocationRecord, error) {
var record model.AssetAllocationRecord
if err := s.db.WithContext(ctx).First(&record, id).Error; err != nil {
return nil, err
}
return &record, nil
}
func (s *AssetAllocationRecordStore) GetByAllocationNo(ctx context.Context, allocationNo string) (*model.AssetAllocationRecord, error) {
var record model.AssetAllocationRecord
if err := s.db.WithContext(ctx).Where("allocation_no = ?", allocationNo).First(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
func (s *AssetAllocationRecordStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.AssetAllocationRecord, int64, error) {
var records []*model.AssetAllocationRecord
var total int64
query := s.db.WithContext(ctx).Model(&model.AssetAllocationRecord{})
if allocationType, ok := filters["allocation_type"].(string); ok && allocationType != "" {
query = query.Where("allocation_type = ?", allocationType)
}
if assetType, ok := filters["asset_type"].(string); ok && assetType != "" {
query = query.Where("asset_type = ?", assetType)
}
if assetIdentifier, ok := filters["asset_identifier"].(string); ok && assetIdentifier != "" {
query = query.Where("asset_identifier LIKE ?", "%"+assetIdentifier+"%")
}
if allocationNo, ok := filters["allocation_no"].(string); ok && allocationNo != "" {
query = query.Where("allocation_no = ?", allocationNo)
}
if fromShopID, ok := filters["from_shop_id"].(uint); ok && fromShopID > 0 {
query = query.Where("from_owner_type = ? AND from_owner_id = ?", constants.OwnerTypeShop, fromShopID)
}
if toShopID, ok := filters["to_shop_id"].(uint); ok && toShopID > 0 {
query = query.Where("to_owner_type = ? AND to_owner_id = ?", constants.OwnerTypeShop, toShopID)
}
if operatorID, ok := filters["operator_id"].(uint); ok && operatorID > 0 {
query = query.Where("operator_id = ?", operatorID)
}
if createdAtStart, ok := filters["created_at_start"].(time.Time); ok {
query = query.Where("created_at >= ?", createdAtStart)
}
if createdAtEnd, ok := filters["created_at_end"].(time.Time); ok {
query = query.Where("created_at <= ?", createdAtEnd)
}
if relatedShopIDs, ok := filters["related_shop_ids"].([]uint); ok && len(relatedShopIDs) > 0 {
query = query.Where(
"(from_owner_type = ? AND from_owner_id IN ?) OR (to_owner_type = ? AND to_owner_id IN ?)",
constants.OwnerTypeShop, relatedShopIDs,
constants.OwnerTypeShop, relatedShopIDs,
)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("created_at DESC")
}
if err := query.Find(&records).Error; err != nil {
return nil, 0, err
}
return records, total, nil
}
func (s *AssetAllocationRecordStore) GenerateAllocationNo(ctx context.Context, allocationType string) string {
prefix := "AL"
if allocationType == constants.AssetAllocationTypeRecall {
prefix = "RC"
}
return fmt.Sprintf("%s%s%d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%10000)
}

View File

@@ -0,0 +1,232 @@
package postgres
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAssetAllocationRecordStore_Create(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewAssetAllocationRecordStore(tx, rdb)
ctx := context.Background()
record := &model.AssetAllocationRecord{
AllocationNo: "AL20260124100001",
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeIotCard,
AssetID: 1,
AssetIdentifier: "89860012345678901234",
FromOwnerType: constants.OwnerTypePlatform,
ToOwnerType: constants.OwnerTypeShop,
ToOwnerID: 10,
OperatorID: 1,
}
err := s.Create(ctx, record)
require.NoError(t, err)
assert.NotZero(t, record.ID)
}
func TestAssetAllocationRecordStore_BatchCreate(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewAssetAllocationRecordStore(tx, rdb)
ctx := context.Background()
records := []*model.AssetAllocationRecord{
{
AllocationNo: "AL20260124100010",
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeIotCard,
AssetID: 1,
AssetIdentifier: "89860012345678901001",
FromOwnerType: constants.OwnerTypePlatform,
ToOwnerType: constants.OwnerTypeShop,
ToOwnerID: 10,
OperatorID: 1,
},
{
AllocationNo: "AL20260124100011",
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeIotCard,
AssetID: 2,
AssetIdentifier: "89860012345678901002",
FromOwnerType: constants.OwnerTypePlatform,
ToOwnerType: constants.OwnerTypeShop,
ToOwnerID: 10,
OperatorID: 1,
},
}
err := s.BatchCreate(ctx, records)
require.NoError(t, err)
for _, record := range records {
assert.NotZero(t, record.ID)
}
t.Run("空列表不报错", func(t *testing.T) {
err := s.BatchCreate(ctx, []*model.AssetAllocationRecord{})
require.NoError(t, err)
})
}
func TestAssetAllocationRecordStore_GetByID(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewAssetAllocationRecordStore(tx, rdb)
ctx := context.Background()
record := &model.AssetAllocationRecord{
AllocationNo: "AL20260124100003",
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeIotCard,
AssetID: 1,
AssetIdentifier: "89860012345678903001",
FromOwnerType: constants.OwnerTypePlatform,
ToOwnerType: constants.OwnerTypeShop,
ToOwnerID: 10,
OperatorID: 1,
Remark: "测试备注",
}
require.NoError(t, s.Create(ctx, record))
result, err := s.GetByID(ctx, record.ID)
require.NoError(t, err)
assert.Equal(t, record.AllocationNo, result.AllocationNo)
assert.Equal(t, record.AssetIdentifier, result.AssetIdentifier)
assert.Equal(t, "测试备注", result.Remark)
}
func TestAssetAllocationRecordStore_List(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewAssetAllocationRecordStore(tx, rdb)
ctx := context.Background()
shopID := uint(100)
records := []*model.AssetAllocationRecord{
{
AllocationNo: "AL20260124100004",
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeIotCard,
AssetID: 1,
AssetIdentifier: "89860012345678904001",
FromOwnerType: constants.OwnerTypePlatform,
ToOwnerType: constants.OwnerTypeShop,
ToOwnerID: shopID,
OperatorID: 1,
},
{
AllocationNo: "AL20260124100005",
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeIotCard,
AssetID: 2,
AssetIdentifier: "89860012345678904002",
FromOwnerType: constants.OwnerTypeShop,
FromOwnerID: &shopID,
ToOwnerType: constants.OwnerTypeShop,
ToOwnerID: 200,
OperatorID: 2,
},
{
AllocationNo: "RC20260124100001",
AllocationType: constants.AssetAllocationTypeRecall,
AssetType: constants.AssetTypeIotCard,
AssetID: 3,
AssetIdentifier: "89860012345678904003",
FromOwnerType: constants.OwnerTypeShop,
FromOwnerID: &shopID,
ToOwnerType: constants.OwnerTypePlatform,
ToOwnerID: 0,
OperatorID: 1,
},
}
require.NoError(t, s.BatchCreate(ctx, records))
t.Run("查询所有记录", func(t *testing.T) {
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
require.NoError(t, err)
assert.Equal(t, int64(3), total)
assert.Len(t, result, 3)
})
t.Run("按分配类型过滤", func(t *testing.T) {
filters := map[string]any{"allocation_type": constants.AssetAllocationTypeAllocate}
result, total, err := s.List(ctx, nil, filters)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
for _, r := range result {
assert.Equal(t, constants.AssetAllocationTypeAllocate, r.AllocationType)
}
})
t.Run("按分配单号过滤", func(t *testing.T) {
filters := map[string]any{"allocation_no": "AL20260124100004"}
result, total, err := s.List(ctx, nil, filters)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Equal(t, "AL20260124100004", result[0].AllocationNo)
})
t.Run("按资产标识模糊查询", func(t *testing.T) {
filters := map[string]any{"asset_identifier": "904002"}
result, total, err := s.List(ctx, nil, filters)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Contains(t, result[0].AssetIdentifier, "904002")
})
t.Run("按目标店铺过滤", func(t *testing.T) {
filters := map[string]any{"to_shop_id": shopID}
result, total, err := s.List(ctx, nil, filters)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Equal(t, shopID, result[0].ToOwnerID)
})
t.Run("按操作人过滤", func(t *testing.T) {
filters := map[string]any{"operator_id": uint(2)}
result, total, err := s.List(ctx, nil, filters)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Equal(t, uint(2), result[0].OperatorID)
})
}
func TestAssetAllocationRecordStore_GenerateAllocationNo(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewAssetAllocationRecordStore(tx, rdb)
ctx := context.Background()
t.Run("分配单号前缀为AL", func(t *testing.T) {
no := s.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
assert.True(t, len(no) > 0)
assert.Equal(t, "AL", no[:2])
})
t.Run("回收单号前缀为RC", func(t *testing.T) {
no := s.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall)
assert.True(t, len(no) > 0)
assert.Equal(t, "RC", no[:2])
})
}

View File

@@ -172,11 +172,50 @@ func (s *IotCardStore) ListStandalone(ctx context.Context, opts *store.QueryOpti
return cards, total, nil
}
func (s *IotCardStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.IotCard, int64, error) {
func (s *IotCardStore) GetByICCIDs(ctx context.Context, iccids []string) ([]*model.IotCard, error) {
if len(iccids) == 0 {
return nil, nil
}
var cards []*model.IotCard
var total int64
if err := s.db.WithContext(ctx).Where("iccid IN ?", iccids).Find(&cards).Error; err != nil {
return nil, err
}
return cards, nil
}
query := s.db.WithContext(ctx).Model(&model.IotCard{})
func (s *IotCardStore) GetStandaloneByICCIDRange(ctx context.Context, iccidStart, iccidEnd string, shopID *uint) ([]*model.IotCard, error) {
query := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id NOT IN (?)",
s.db.Model(&model.DeviceSimBinding{}).
Select("iot_card_id").
Where("bind_status = ?", 1)).
Where("iccid >= ? AND iccid <= ?", iccidStart, iccidEnd)
if shopID == nil {
query = query.Where("shop_id IS NULL")
} else {
query = query.Where("shop_id = ?", *shopID)
}
var cards []*model.IotCard
if err := query.Find(&cards).Error; err != nil {
return nil, err
}
return cards, nil
}
func (s *IotCardStore) GetStandaloneByFilters(ctx context.Context, filters map[string]any, shopID *uint) ([]*model.IotCard, error) {
query := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id NOT IN (?)",
s.db.Model(&model.DeviceSimBinding{}).
Select("iot_card_id").
Where("bind_status = ?", 1))
if shopID == nil {
query = query.Where("shop_id IS NULL")
} else {
query = query.Where("shop_id = ?", *shopID)
}
if status, ok := filters["status"].(int); ok && status > 0 {
query = query.Where("status = ?", status)
@@ -184,35 +223,38 @@ func (s *IotCardStore) List(ctx context.Context, opts *store.QueryOptions, filte
if carrierID, ok := filters["carrier_id"].(uint); ok && carrierID > 0 {
query = query.Where("carrier_id = ?", carrierID)
}
if shopID, ok := filters["shop_id"].(uint); ok && shopID > 0 {
query = query.Where("shop_id = ?", shopID)
}
if iccid, ok := filters["iccid"].(string); ok && iccid != "" {
query = query.Where("iccid LIKE ?", "%"+iccid+"%")
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("created_at DESC")
if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" {
query = query.Where("batch_no = ?", batchNo)
}
var cards []*model.IotCard
if err := query.Find(&cards).Error; err != nil {
return nil, 0, err
return nil, err
}
return cards, total, nil
return cards, nil
}
func (s *IotCardStore) BatchUpdateShopIDAndStatus(ctx context.Context, cardIDs []uint, shopID *uint, status int) error {
if len(cardIDs) == 0 {
return nil
}
updates := map[string]any{
"shop_id": shopID,
"status": status,
}
return s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id IN ?", cardIDs).
Updates(updates).Error
}
func (s *IotCardStore) GetBoundCardIDs(ctx context.Context, cardIDs []uint) ([]uint, error) {
if len(cardIDs) == 0 {
return nil, nil
}
var boundCardIDs []uint
err := s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Select("iot_card_id").
Where("iot_card_id IN ? AND bind_status = ?", cardIDs, 1).
Pluck("iot_card_id", &boundCardIDs).Error
return boundCardIDs, err
}

View File

@@ -240,3 +240,174 @@ func TestIotCardStore_ListStandalone_Filters(t *testing.T) {
assert.Equal(t, int64(2), total)
})
}
func TestIotCardStore_GetByICCIDs(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewIotCardStore(tx, rdb)
ctx := context.Background()
cards := []*model.IotCard{
{ICCID: "89860012345678905001", CardType: "data_card", CarrierID: 1, Status: 1},
{ICCID: "89860012345678905002", CardType: "data_card", CarrierID: 1, Status: 1},
{ICCID: "89860012345678905003", CardType: "data_card", CarrierID: 1, Status: 1},
}
require.NoError(t, s.CreateBatch(ctx, cards))
t.Run("查询存在的ICCID", func(t *testing.T) {
result, err := s.GetByICCIDs(ctx, []string{"89860012345678905001", "89860012345678905002"})
require.NoError(t, err)
assert.Len(t, result, 2)
})
t.Run("查询不存在的ICCID", func(t *testing.T) {
result, err := s.GetByICCIDs(ctx, []string{"89860012345678909999"})
require.NoError(t, err)
assert.Len(t, result, 0)
})
t.Run("空列表返回nil", func(t *testing.T) {
result, err := s.GetByICCIDs(ctx, []string{})
require.NoError(t, err)
assert.Nil(t, result)
})
}
func TestIotCardStore_GetStandaloneByICCIDRange(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewIotCardStore(tx, rdb)
ctx := context.Background()
shopID := uint(100)
cards := []*model.IotCard{
{ICCID: "89860012345678906001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil},
{ICCID: "89860012345678906002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil},
{ICCID: "89860012345678906003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID},
{ICCID: "89860012345678906004", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID},
}
require.NoError(t, s.CreateBatch(ctx, cards))
t.Run("平台查询未分配的卡", func(t *testing.T) {
result, err := s.GetStandaloneByICCIDRange(ctx, "89860012345678906001", "89860012345678906004", nil)
require.NoError(t, err)
assert.Len(t, result, 2)
for _, card := range result {
assert.Nil(t, card.ShopID)
}
})
t.Run("店铺查询自己的卡", func(t *testing.T) {
result, err := s.GetStandaloneByICCIDRange(ctx, "89860012345678906001", "89860012345678906004", &shopID)
require.NoError(t, err)
assert.Len(t, result, 2)
for _, card := range result {
assert.Equal(t, shopID, *card.ShopID)
}
})
}
func TestIotCardStore_GetStandaloneByFilters(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewIotCardStore(tx, rdb)
ctx := context.Background()
shopID := uint(100)
cards := []*model.IotCard{
{ICCID: "89860012345678907001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: "BATCH001"},
{ICCID: "89860012345678907002", CardType: "data_card", CarrierID: 2, Status: 1, ShopID: nil, BatchNo: "BATCH002"},
{ICCID: "89860012345678907003", CardType: "data_card", CarrierID: 1, Status: 2, ShopID: &shopID, BatchNo: "BATCH001"},
}
require.NoError(t, s.CreateBatch(ctx, cards))
t.Run("按运营商过滤", func(t *testing.T) {
filters := map[string]any{"carrier_id": uint(1)}
result, err := s.GetStandaloneByFilters(ctx, filters, nil)
require.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, uint(1), result[0].CarrierID)
})
t.Run("按批次号过滤", func(t *testing.T) {
filters := map[string]any{"batch_no": "BATCH001"}
result, err := s.GetStandaloneByFilters(ctx, filters, &shopID)
require.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, "BATCH001", result[0].BatchNo)
})
}
func TestIotCardStore_BatchUpdateShopIDAndStatus(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewIotCardStore(tx, rdb)
ctx := context.Background()
cards := []*model.IotCard{
{ICCID: "89860012345678908001", CardType: "data_card", CarrierID: 1, Status: 1},
{ICCID: "89860012345678908002", CardType: "data_card", CarrierID: 1, Status: 1},
}
require.NoError(t, s.CreateBatch(ctx, cards))
newShopID := uint(200)
cardIDs := []uint{cards[0].ID, cards[1].ID}
err := s.BatchUpdateShopIDAndStatus(ctx, cardIDs, &newShopID, 2)
require.NoError(t, err)
var updatedCards []*model.IotCard
require.NoError(t, tx.Where("id IN ?", cardIDs).Find(&updatedCards).Error)
for _, card := range updatedCards {
assert.Equal(t, newShopID, *card.ShopID)
assert.Equal(t, 2, card.Status)
}
t.Run("空列表不报错", func(t *testing.T) {
err := s.BatchUpdateShopIDAndStatus(ctx, []uint{}, nil, 1)
require.NoError(t, err)
})
}
func TestIotCardStore_GetBoundCardIDs(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewIotCardStore(tx, rdb)
ctx := context.Background()
cards := []*model.IotCard{
{ICCID: "89860012345678909001", CardType: "data_card", CarrierID: 1, Status: 1},
{ICCID: "89860012345678909002", CardType: "data_card", CarrierID: 1, Status: 1},
{ICCID: "89860012345678909003", CardType: "data_card", CarrierID: 1, Status: 1},
}
require.NoError(t, s.CreateBatch(ctx, cards))
binding := &model.DeviceSimBinding{
DeviceID: 1,
IotCardID: cards[0].ID,
BindStatus: 1,
}
require.NoError(t, tx.Create(binding).Error)
cardIDs := []uint{cards[0].ID, cards[1].ID, cards[2].ID}
boundIDs, err := s.GetBoundCardIDs(ctx, cardIDs)
require.NoError(t, err)
assert.Len(t, boundIDs, 1)
assert.Contains(t, boundIDs, cards[0].ID)
t.Run("空列表返回nil", func(t *testing.T) {
result, err := s.GetBoundCardIDs(ctx, []uint{})
require.NoError(t, err)
assert.Nil(t, result)
})
}