feat: 实现单卡资产分配与回收功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m45s
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -74,3 +74,4 @@ ai-gateway.conf
|
|||||||
__debug_bin1621385388
|
__debug_bin1621385388
|
||||||
docs/admin-openapi.yaml
|
docs/admin-openapi.yaml
|
||||||
/api
|
/api
|
||||||
|
/gendocs
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
|
|||||||
MyCommission: admin.NewMyCommissionHandler(nil),
|
MyCommission: admin.NewMyCommissionHandler(nil),
|
||||||
IotCard: admin.NewIotCardHandler(nil),
|
IotCard: admin.NewIotCardHandler(nil),
|
||||||
IotCardImport: admin.NewIotCardImportHandler(nil),
|
IotCardImport: admin.NewIotCardImportHandler(nil),
|
||||||
|
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func generateAdminDocs(outputPath string) error {
|
|||||||
MyCommission: admin.NewMyCommissionHandler(nil),
|
MyCommission: admin.NewMyCommissionHandler(nil),
|
||||||
IotCard: admin.NewIotCardHandler(nil),
|
IotCard: admin.NewIotCardHandler(nil),
|
||||||
IotCardImport: admin.NewIotCardImportHandler(nil),
|
IotCardImport: admin.NewIotCardImportHandler(nil),
|
||||||
|
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
|
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
|
||||||
IotCard: admin.NewIotCardHandler(svc.IotCard),
|
IotCard: admin.NewIotCardHandler(svc.IotCard),
|
||||||
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
|
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
|
||||||
|
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package bootstrap
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
|
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"
|
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
|
||||||
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
||||||
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
|
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
|
||||||
@@ -36,6 +37,7 @@ type services struct {
|
|||||||
MyCommission *myCommissionSvc.Service
|
MyCommission *myCommissionSvc.Service
|
||||||
IotCard *iotCardSvc.Service
|
IotCard *iotCardSvc.Service
|
||||||
IotCardImport *iotCardImportSvc.Service
|
IotCardImport *iotCardImportSvc.Service
|
||||||
|
AssetAllocationRecord *assetAllocationRecordSvc.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func initServices(s *stores, deps *Dependencies) *services {
|
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),
|
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization),
|
||||||
CustomerAccount: customerAccountSvc.New(deps.DB, s.Account, s.Shop, s.Enterprise),
|
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),
|
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),
|
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
|
||||||
|
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type stores struct {
|
|||||||
EnterpriseCardAuthorization *postgres.EnterpriseCardAuthorizationStore
|
EnterpriseCardAuthorization *postgres.EnterpriseCardAuthorizationStore
|
||||||
IotCard *postgres.IotCardStore
|
IotCard *postgres.IotCardStore
|
||||||
IotCardImportTask *postgres.IotCardImportTaskStore
|
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||||
|
AssetAllocationRecord *postgres.AssetAllocationRecordStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func initStores(deps *Dependencies) *stores {
|
func initStores(deps *Dependencies) *stores {
|
||||||
@@ -43,5 +44,6 @@ func initStores(deps *Dependencies) *stores {
|
|||||||
EnterpriseCardAuthorization: postgres.NewEnterpriseCardAuthorizationStore(deps.DB, deps.Redis),
|
EnterpriseCardAuthorization: postgres.NewEnterpriseCardAuthorizationStore(deps.DB, deps.Redis),
|
||||||
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||||
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
||||||
|
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Handlers struct {
|
|||||||
MyCommission *admin.MyCommissionHandler
|
MyCommission *admin.MyCommissionHandler
|
||||||
IotCard *admin.IotCardHandler
|
IotCard *admin.IotCardHandler
|
||||||
IotCardImport *admin.IotCardImportHandler
|
IotCardImport *admin.IotCardImportHandler
|
||||||
|
AssetAllocationRecord *admin.AssetAllocationRecordHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middlewares 封装所有中间件
|
// Middlewares 封装所有中间件
|
||||||
|
|||||||
58
internal/handler/admin/asset_allocation_record.go
Normal file
58
internal/handler/admin/asset_allocation_record.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
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/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
"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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
type AssetAllocationRecord struct {
|
type AssetAllocationRecord struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
BaseModel `gorm:"embedded"`
|
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"`
|
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"`
|
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"`
|
AssetID uint `gorm:"column:asset_id;index;not null;comment:资产ID" json:"asset_id"`
|
||||||
|
|||||||
62
internal/model/dto/asset_allocation_record_dto.go
Normal file
62
internal/model/dto/asset_allocation_record_dto.go
Normal 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列表"`
|
||||||
|
}
|
||||||
108
internal/model/dto/standalone_card_allocation_dto.go
Normal file
108
internal/model/dto/standalone_card_allocation_dto.go
Normal 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:"起始ICCID(selection_type=range时必填)"`
|
||||||
|
// ICCIDEnd 结束ICCID
|
||||||
|
ICCIDEnd string `json:"iccid_end" validate:"required_if=SelectionType range,omitempty,max=20" maxLength:"20" description:"结束ICCID(selection_type=range时必填)"`
|
||||||
|
|
||||||
|
// ===== selection_type=filter 时使用 =====
|
||||||
|
// CarrierID 运营商ID
|
||||||
|
CarrierID *uint `json:"carrier_id" description:"运营商ID(selection_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:"起始ICCID(selection_type=range时必填)"`
|
||||||
|
ICCIDEnd string `json:"iccid_end" validate:"required_if=SelectionType range,omitempty,max=20" maxLength:"20" description:"结束ICCID(selection_type=range时必填)"`
|
||||||
|
|
||||||
|
// ===== selection_type=filter 时使用 =====
|
||||||
|
CarrierID *uint `json:"carrier_id" description:"运营商ID(selection_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:"失败项列表"`
|
||||||
|
}
|
||||||
@@ -55,6 +55,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
|||||||
if handlers.IotCard != nil {
|
if handlers.IotCard != nil {
|
||||||
registerIotCardRoutes(authGroup, handlers.IotCard, handlers.IotCardImport, doc, basePath)
|
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) {
|
func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {
|
||||||
|
|||||||
30
internal/routes/asset_allocation_record.go
Normal file
30
internal/routes/asset_allocation_record.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -43,4 +43,20 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i
|
|||||||
Output: new(dto.ImportTaskDetailResponse),
|
Output: new(dto.ImportTaskDetailResponse),
|
||||||
Auth: true,
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
253
internal/service/asset_allocation_record/service.go
Normal file
253
internal/service/asset_allocation_record/service.go
Normal 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
|
||||||
|
}
|
||||||
@@ -8,18 +8,28 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
iotCardStore *postgres.IotCardStore
|
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{
|
return &Service{
|
||||||
db: db,
|
db: db,
|
||||||
iotCardStore: iotCardStore,
|
iotCardStore: iotCardStore,
|
||||||
|
shopStore: shopStore,
|
||||||
|
assetAllocationRecordStore: assetAllocationRecordStore,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,3 +179,353 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, carrierMap map[uint]
|
|||||||
|
|
||||||
return resp
|
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
|
||||||
|
}
|
||||||
|
|||||||
127
internal/store/postgres/asset_allocation_record_store.go
Normal file
127
internal/store/postgres/asset_allocation_record_store.go
Normal 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)
|
||||||
|
}
|
||||||
232
internal/store/postgres/asset_allocation_record_store_test.go
Normal file
232
internal/store/postgres/asset_allocation_record_store_test.go
Normal 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])
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -172,11 +172,50 @@ func (s *IotCardStore) ListStandalone(ctx context.Context, opts *store.QueryOpti
|
|||||||
return cards, total, nil
|
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 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 {
|
if status, ok := filters["status"].(int); ok && status > 0 {
|
||||||
query = query.Where("status = ?", status)
|
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 {
|
if carrierID, ok := filters["carrier_id"].(uint); ok && carrierID > 0 {
|
||||||
query = query.Where("carrier_id = ?", carrierID)
|
query = query.Where("carrier_id = ?", carrierID)
|
||||||
}
|
}
|
||||||
if shopID, ok := filters["shop_id"].(uint); ok && shopID > 0 {
|
if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" {
|
||||||
query = query.Where("shop_id = ?", shopID)
|
query = query.Where("batch_no = ?", batchNo)
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cards []*model.IotCard
|
||||||
if err := query.Find(&cards).Error; err != nil {
|
if err := query.Find(&cards).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return cards, nil
|
||||||
return cards, total, 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,3 +240,174 @@ func TestIotCardStore_ListStandalone_Filters(t *testing.T) {
|
|||||||
assert.Equal(t, int64(2), total)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS tb_asset_allocation_record;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- 创建资产分配记录表
|
||||||
|
-- 记录卡/设备在平台和代理商之间的流转历史
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_asset_allocation_record (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
creator BIGINT,
|
||||||
|
updater BIGINT,
|
||||||
|
allocation_no VARCHAR(50) NOT NULL,
|
||||||
|
allocation_type VARCHAR(20) NOT NULL,
|
||||||
|
asset_type VARCHAR(20) NOT NULL,
|
||||||
|
asset_id BIGINT NOT NULL,
|
||||||
|
asset_identifier VARCHAR(50) NOT NULL,
|
||||||
|
from_owner_type VARCHAR(20),
|
||||||
|
from_owner_id BIGINT,
|
||||||
|
to_owner_type VARCHAR(20) NOT NULL,
|
||||||
|
to_owner_id BIGINT NOT NULL,
|
||||||
|
related_device_id BIGINT,
|
||||||
|
related_card_ids JSONB,
|
||||||
|
operator_id BIGINT NOT NULL,
|
||||||
|
remark TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建索引(非唯一,同批次多条记录共用相同单号)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_allocation_record_allocation_no ON tb_asset_allocation_record(allocation_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_allocation_record_allocation_type ON tb_asset_allocation_record(allocation_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_allocation_record_asset_type ON tb_asset_allocation_record(asset_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_allocation_record_asset_id ON tb_asset_allocation_record(asset_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_allocation_record_deleted_at ON tb_asset_allocation_record(deleted_at);
|
||||||
|
|
||||||
|
-- 添加表注释
|
||||||
|
COMMENT ON TABLE tb_asset_allocation_record IS '资产分配记录表';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.allocation_no IS '分配单号(同批次相同)';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.allocation_type IS '分配类型 allocate=分配 recall=回收';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.asset_type IS '资产类型 iot_card=物联网卡 device=设备';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.asset_id IS '资产ID';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.asset_identifier IS '资产标识符(ICCID或设备号)';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.from_owner_type IS '来源所有者类型 platform=平台 shop=店铺';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.from_owner_id IS '来源所有者ID';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.to_owner_type IS '目标所有者类型 platform=平台 shop=店铺';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.to_owner_id IS '目标所有者ID';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.related_device_id IS '关联设备ID(设备分配时使用)';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.related_card_ids IS '关联卡ID列表(设备分配时使用)';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.operator_id IS '操作人ID';
|
||||||
|
COMMENT ON COLUMN tb_asset_allocation_record.remark IS '备注';
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Change: 单卡资产分配功能
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
平台和代理商需要将单卡(未绑定设备的 IoT 卡)分销给下级代理,实现资产的层级流转。当前系统只有企业卡授权功能(授权企业可见特定卡),缺少代理商之间的卡所有权转移功能。
|
||||||
|
|
||||||
|
业务场景:
|
||||||
|
- 平台批量导入卡后,分销给一级代理
|
||||||
|
- 一级代理继续分销给二级代理
|
||||||
|
- 上级可以回收已分销的卡
|
||||||
|
- 查看卡的分配/回收历史记录
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### 新增功能
|
||||||
|
|
||||||
|
**单卡分配 API**
|
||||||
|
- `POST /api/admin/iot-cards/standalone/allocate` - 批量分配单卡给直属下级店铺
|
||||||
|
- `POST /api/admin/iot-cards/standalone/recall` - 批量回收已分配的单卡
|
||||||
|
|
||||||
|
**分配记录查询 API**
|
||||||
|
- `GET /api/admin/asset-allocation-records` - 分配记录列表(支持分页和筛选)
|
||||||
|
- `GET /api/admin/asset-allocation-records/:id` - 分配记录详情
|
||||||
|
|
||||||
|
**分配方式支持**
|
||||||
|
- ICCID 列表选择
|
||||||
|
- ICCID 号段范围(起始~结束)
|
||||||
|
- 筛选条件批量(运营商、批次号等)
|
||||||
|
|
||||||
|
### 业务规则
|
||||||
|
|
||||||
|
**分配规则**
|
||||||
|
- 只能分配给直属下级店铺,不可跨级
|
||||||
|
- 平台只能分配在库(status=1)的卡
|
||||||
|
- 代理可以分配已分销(status=2)的卡(继续往下分销)
|
||||||
|
- 分配后状态变更:在库(1)→已分销(2),已分销(2)保持不变
|
||||||
|
- 分配后 shop_id 变更为目标店铺 ID
|
||||||
|
|
||||||
|
**回收规则**
|
||||||
|
- 只有上级可以回收,代理不能主动退回
|
||||||
|
- 平台回收:shop_id 变为 NULL
|
||||||
|
- 店铺回收:shop_id 变为执行回收的店铺 ID
|
||||||
|
- 只能回收直属下级的卡,不可跨级回收
|
||||||
|
|
||||||
|
**可见性**
|
||||||
|
- 分配后上级仍能看到和管理(通过数据权限机制实现)
|
||||||
|
|
||||||
|
**注意**
|
||||||
|
- 本次只做单卡分配,设备卡分配暂不实现
|
||||||
|
- 分配不涉及费用,纯资产所有权转移
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `asset-allocation-record`: 资产分配记录管理,包含分配记录的查询功能
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `iot-card`: 新增单卡分配和回收功能
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
### API 影响
|
||||||
|
- 新增:`POST /api/admin/iot-cards/standalone/allocate`
|
||||||
|
- 新增:`POST /api/admin/iot-cards/standalone/recall`
|
||||||
|
- 新增:`GET /api/admin/asset-allocation-records`
|
||||||
|
- 新增:`GET /api/admin/asset-allocation-records/:id`
|
||||||
|
|
||||||
|
### 代码影响
|
||||||
|
- `internal/handler/admin/iot_card.go`:新增 AllocateCards、RecallCards 方法
|
||||||
|
- `internal/handler/admin/asset_allocation_record.go`:新增(分配记录 Handler)
|
||||||
|
- `internal/service/iot_card/service.go`:新增分配、回收业务逻辑
|
||||||
|
- `internal/service/asset_allocation_record/service.go`:新增(分配记录 Service)
|
||||||
|
- `internal/store/postgres/iot_card_store.go`:新增批量更新 shop_id 方法
|
||||||
|
- `internal/store/postgres/asset_allocation_record_store.go`:新增(分配记录 Store)
|
||||||
|
- `internal/model/dto/iot_card_dto.go`:新增分配、回收相关 DTO
|
||||||
|
- `internal/model/dto/asset_allocation_record_dto.go`:新增(分配记录 DTO)
|
||||||
|
- `internal/routes/asset_allocation_record.go`:新增(分配记录路由)
|
||||||
|
|
||||||
|
### 数据库影响
|
||||||
|
- 无表结构变更(使用现有 `tb_iot_card` 和 `tb_asset_allocation_record` 表)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 资产分配记录查询
|
||||||
|
|
||||||
|
系统 SHALL 提供资产分配记录的查询功能,支持查看卡和设备在平台与代理商之间的流转历史。
|
||||||
|
|
||||||
|
**记录类型**:
|
||||||
|
- `allocate`: 分配记录(上级分配给下级)
|
||||||
|
- `recall`: 回收记录(上级从下级回收)
|
||||||
|
|
||||||
|
**资产类型**:
|
||||||
|
- `iot_card`: 物联网卡(单卡)
|
||||||
|
- `device`: 设备(未来扩展)
|
||||||
|
|
||||||
|
**查询条件**:
|
||||||
|
- `allocation_type`(可选): 分配类型,枚举值 "allocate" | "recall"
|
||||||
|
- `asset_type`(可选): 资产类型,枚举值 "iot_card" | "device"
|
||||||
|
- `asset_identifier`(可选): 资产标识符(ICCID 或设备号),模糊匹配
|
||||||
|
- `allocation_no`(可选): 分配单号,精确匹配
|
||||||
|
- `from_shop_id`(可选): 来源店铺 ID
|
||||||
|
- `to_shop_id`(可选): 目标店铺 ID
|
||||||
|
- `operator_id`(可选): 操作人 ID
|
||||||
|
- `created_at_start`(可选): 创建时间起始
|
||||||
|
- `created_at_end`(可选): 创建时间结束
|
||||||
|
|
||||||
|
**分页**:
|
||||||
|
- 默认每页 20 条,最大每页 100 条
|
||||||
|
- 返回总记录数和总页数
|
||||||
|
|
||||||
|
**数据权限**:
|
||||||
|
- 平台用户可查看所有记录
|
||||||
|
- 代理用户只能查看与自己店铺相关的记录(作为来源或目标)
|
||||||
|
|
||||||
|
**API 端点**: `GET /api/admin/asset-allocation-records`
|
||||||
|
|
||||||
|
**响应字段**:
|
||||||
|
- `id`: 记录 ID
|
||||||
|
- `allocation_no`: 分配单号
|
||||||
|
- `allocation_type`: 分配类型
|
||||||
|
- `allocation_type_name`: 分配类型名称(分配/回收)
|
||||||
|
- `asset_type`: 资产类型
|
||||||
|
- `asset_type_name`: 资产类型名称(物联网卡/设备)
|
||||||
|
- `asset_id`: 资产 ID
|
||||||
|
- `asset_identifier`: 资产标识符
|
||||||
|
- `from_owner_type`: 来源所有者类型
|
||||||
|
- `from_owner_id`: 来源所有者 ID
|
||||||
|
- `from_owner_name`: 来源所有者名称
|
||||||
|
- `to_owner_type`: 目标所有者类型
|
||||||
|
- `to_owner_id`: 目标所有者 ID
|
||||||
|
- `to_owner_name`: 目标所有者名称
|
||||||
|
- `operator_id`: 操作人 ID
|
||||||
|
- `operator_name`: 操作人名称
|
||||||
|
- `remark`: 备注
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
|
||||||
|
#### Scenario: 查询所有分配记录
|
||||||
|
|
||||||
|
- **WHEN** 平台管理员查询分配记录列表,不带任何筛选条件
|
||||||
|
- **THEN** 系统返回所有分配和回收记录,按创建时间倒序排列
|
||||||
|
|
||||||
|
#### Scenario: 按资产类型筛选记录
|
||||||
|
|
||||||
|
- **WHEN** 管理员查询资产类型为 "iot_card" 的记录
|
||||||
|
- **THEN** 系统只返回物联网卡的分配/回收记录,不包含设备记录
|
||||||
|
|
||||||
|
#### Scenario: 按分配类型筛选记录
|
||||||
|
|
||||||
|
- **WHEN** 管理员查询分配类型为 "allocate" 的记录
|
||||||
|
- **THEN** 系统只返回分配记录,不包含回收记录
|
||||||
|
|
||||||
|
#### Scenario: 按 ICCID 模糊查询
|
||||||
|
|
||||||
|
- **WHEN** 管理员输入 asset_identifier = "8986001"
|
||||||
|
- **THEN** 系统返回 ICCID 包含 "8986001" 的所有分配记录
|
||||||
|
|
||||||
|
#### Scenario: 代理查询自己相关的记录
|
||||||
|
|
||||||
|
- **WHEN** 代理用户(店铺 ID=10)查询分配记录
|
||||||
|
- **THEN** 系统只返回 from_owner_id=10 或 to_owner_id=10 的记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 资产分配记录详情
|
||||||
|
|
||||||
|
系统 SHALL 提供资产分配记录详情查询功能。
|
||||||
|
|
||||||
|
**API 端点**: `GET /api/admin/asset-allocation-records/:id`
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
- 包含记录的所有字段
|
||||||
|
- 关联卡 ID 列表(如果是设备分配,包含设备下的所有卡 ID)
|
||||||
|
|
||||||
|
#### Scenario: 查询分配记录详情
|
||||||
|
|
||||||
|
- **WHEN** 管理员查询分配记录详情(ID=1)
|
||||||
|
- **THEN** 系统返回该记录的完整信息,包括来源/目标所有者名称、操作人名称等
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在的记录
|
||||||
|
|
||||||
|
- **WHEN** 管理员查询不存在的分配记录(ID=999)
|
||||||
|
- **THEN** 系统返回 404 错误,提示"分配记录不存在"
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 单卡分配功能
|
||||||
|
|
||||||
|
系统 SHALL 支持将单卡(未绑定设备的 IoT 卡)分配给直属下级店铺,实现资产所有权的层级流转。
|
||||||
|
|
||||||
|
**分配规则**:
|
||||||
|
- 只能分配给直属下级店铺,不可跨级分配
|
||||||
|
- 平台(shop_id=NULL)只能分配状态为在库(status=1)的卡
|
||||||
|
- 代理店铺可以分配状态为已分销(status=2)的卡(继续往下分销)
|
||||||
|
- 分配后状态变更:在库(1)→已分销(2),已分销(2)保持不变
|
||||||
|
- 分配后 shop_id 变更为目标店铺 ID
|
||||||
|
- 分配不涉及费用,纯资产所有权转移
|
||||||
|
- 分配后上级仍能看到和管理(通过数据权限机制)
|
||||||
|
|
||||||
|
**选卡方式**(三选一):
|
||||||
|
- ICCID 列表:指定具体的 ICCID 列表
|
||||||
|
- 号段范围:指定起始 ICCID 和结束 ICCID
|
||||||
|
- 筛选条件:按运营商、批次号、状态等条件批量选择
|
||||||
|
|
||||||
|
**API 端点**: `POST /api/admin/iot-cards/standalone/allocate`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
- `to_shop_id`(必填): 目标店铺 ID
|
||||||
|
- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter"
|
||||||
|
- `iccids`(selection_type=list 时必填): ICCID 列表
|
||||||
|
- `iccid_start`(selection_type=range 时必填): 起始 ICCID
|
||||||
|
- `iccid_end`(selection_type=range 时必填): 结束 ICCID
|
||||||
|
- `carrier_id`(selection_type=filter 时可选): 运营商 ID
|
||||||
|
- `batch_no`(selection_type=filter 时可选): 批次号
|
||||||
|
- `status`(selection_type=filter 时可选): 卡状态
|
||||||
|
- `remark`(可选): 备注
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
- `total_count`: 待分配总数
|
||||||
|
- `success_count`: 成功数
|
||||||
|
- `fail_count`: 失败数
|
||||||
|
- `failed_items`: 失败项列表(包含 ICCID 和失败原因)
|
||||||
|
|
||||||
|
#### Scenario: 平台通过 ICCID 列表分配单卡给一级代理
|
||||||
|
|
||||||
|
- **WHEN** 平台管理员选择 3 张在库单卡(ICCID 列表),分配给一级代理店铺(ID=10)
|
||||||
|
- **THEN** 系统将这 3 张卡的 shop_id 更新为 10,status 从 1 变为 2,创建分配记录,返回成功数 3
|
||||||
|
|
||||||
|
#### Scenario: 平台通过号段范围批量分配单卡
|
||||||
|
|
||||||
|
- **WHEN** 平台管理员指定 ICCID 范围 "8986001000000000000" 至 "8986001000000000099",分配给一级代理店铺(ID=10)
|
||||||
|
- **THEN** 系统查询该范围内的所有在库单卡,批量更新 shop_id 和 status,创建分配记录
|
||||||
|
|
||||||
|
#### Scenario: 代理通过筛选条件分配单卡给下级
|
||||||
|
|
||||||
|
- **WHEN** 一级代理(店铺 ID=10)按条件筛选(运营商=电信,批次号=BATCH-001)自己的已分销卡,分配给二级代理店铺(ID=20)
|
||||||
|
- **THEN** 系统查询符合条件的卡,校验店铺 20 是店铺 10 的直属下级,批量更新 shop_id 为 20,status 保持 2
|
||||||
|
|
||||||
|
#### Scenario: 拒绝跨级分配
|
||||||
|
|
||||||
|
- **WHEN** 平台尝试将卡直接分配给二级代理店铺(非直属下级)
|
||||||
|
- **THEN** 系统拒绝分配,返回错误"只能分配给直属下级店铺"
|
||||||
|
|
||||||
|
#### Scenario: 拒绝平台分配已分销的卡
|
||||||
|
|
||||||
|
- **WHEN** 平台尝试分配状态为已分销(status=2)的卡
|
||||||
|
- **THEN** 系统拒绝分配,返回错误"在库状态的卡才能分配,请先回收"
|
||||||
|
|
||||||
|
#### Scenario: 拒绝分配已绑定设备的卡
|
||||||
|
|
||||||
|
- **WHEN** 用户尝试分配已绑定设备的卡(在 device_sim_bindings 中 bind_status=1)
|
||||||
|
- **THEN** 系统拒绝分配,返回错误"已绑定设备的卡不能单独分配"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 单卡回收功能
|
||||||
|
|
||||||
|
系统 SHALL 支持上级回收已分配给直属下级的单卡,将卡的所有权收回。
|
||||||
|
|
||||||
|
**回收规则**:
|
||||||
|
- 只有上级可以回收,代理不能主动退回给上级
|
||||||
|
- 只能回收直属下级的卡,不可跨级回收
|
||||||
|
- 平台回收:shop_id 变为 NULL,status 变为 1(在库)
|
||||||
|
- 店铺回收:shop_id 变为执行回收的店铺 ID,status 保持 2(已分销)
|
||||||
|
- 只能回收单卡(未绑定设备的卡)
|
||||||
|
|
||||||
|
**选卡方式**(与分配相同,三选一):
|
||||||
|
- ICCID 列表
|
||||||
|
- 号段范围
|
||||||
|
- 筛选条件
|
||||||
|
|
||||||
|
**API 端点**: `POST /api/admin/iot-cards/standalone/recall`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
- `from_shop_id`(必填): 来源店铺 ID(被回收方)
|
||||||
|
- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter"
|
||||||
|
- `iccids`(selection_type=list 时必填): ICCID 列表
|
||||||
|
- `iccid_start`(selection_type=range 时必填): 起始 ICCID
|
||||||
|
- `iccid_end`(selection_type=range 时必填): 结束 ICCID
|
||||||
|
- `carrier_id`(selection_type=filter 时可选): 运营商 ID
|
||||||
|
- `batch_no`(selection_type=filter 时可选): 批次号
|
||||||
|
- `remark`(可选): 备注
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
- `total_count`: 待回收总数
|
||||||
|
- `success_count`: 成功数
|
||||||
|
- `fail_count`: 失败数
|
||||||
|
- `failed_items`: 失败项列表
|
||||||
|
|
||||||
|
#### Scenario: 平台回收一级代理的单卡
|
||||||
|
|
||||||
|
- **WHEN** 平台管理员选择一级代理店铺(ID=10)的 5 张单卡进行回收
|
||||||
|
- **THEN** 系统将这 5 张卡的 shop_id 更新为 NULL,status 从 2 变为 1,创建回收记录
|
||||||
|
|
||||||
|
#### Scenario: 一级代理回收二级代理的单卡
|
||||||
|
|
||||||
|
- **WHEN** 一级代理(店铺 ID=10)选择二级代理店铺(ID=20)的 3 张单卡进行回收
|
||||||
|
- **THEN** 系统将这 3 张卡的 shop_id 更新为 10,status 保持 2,创建回收记录
|
||||||
|
|
||||||
|
#### Scenario: 拒绝回收非直属下级的卡
|
||||||
|
|
||||||
|
- **WHEN** 一级代理(店铺 ID=10)尝试回收非直属下级店铺(ID=30,归属于店铺 ID=20)的卡
|
||||||
|
- **THEN** 系统拒绝回收,返回错误"只能回收直属下级店铺的卡"
|
||||||
|
|
||||||
|
#### Scenario: 拒绝代理主动退回
|
||||||
|
|
||||||
|
- **WHEN** 二级代理(店铺 ID=20)尝试将卡退回给上级店铺(ID=10)
|
||||||
|
- **THEN** 系统拒绝操作,返回错误"不能主动退回卡给上级,请联系上级进行回收"
|
||||||
|
|
||||||
|
#### Scenario: 拒绝回收已绑定设备的卡
|
||||||
|
|
||||||
|
- **WHEN** 用户尝试回收已绑定设备的卡
|
||||||
|
- **THEN** 系统拒绝回收,返回错误"已绑定设备的卡不能单独回收"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Tasks: 单卡资产分配功能
|
||||||
|
|
||||||
|
## 1. DTO 定义
|
||||||
|
|
||||||
|
- [x] 1.1 创建 AllocateStandaloneCardsRequest DTO(支持三种选卡方式)
|
||||||
|
- [x] 1.2 创建 AllocateStandaloneCardsResponse DTO(返回成功/失败统计)
|
||||||
|
- [x] 1.3 创建 RecallStandaloneCardsRequest DTO
|
||||||
|
- [x] 1.4 创建 RecallStandaloneCardsResponse DTO
|
||||||
|
- [x] 1.5 创建 ListAssetAllocationRecordRequest DTO
|
||||||
|
- [x] 1.6 创建 AssetAllocationRecordResponse DTO
|
||||||
|
- [x] 1.7 创建 ListAssetAllocationRecordResponse DTO
|
||||||
|
|
||||||
|
## 2. Store 层
|
||||||
|
|
||||||
|
- [x] 2.1 创建 AssetAllocationRecordStore
|
||||||
|
- [x] 2.1.1 实现 Create 方法
|
||||||
|
- [x] 2.1.2 实现 BatchCreate 方法
|
||||||
|
- [x] 2.1.3 实现 GetByID 方法
|
||||||
|
- [x] 2.1.4 实现 List 方法(支持筛选和分页)
|
||||||
|
- [x] 2.2 实现 IotCardStore.BatchUpdateShopIDAndStatus 方法
|
||||||
|
- [x] 2.3 实现 IotCardStore.GetStandaloneByICCIDRange 方法(号段查询)
|
||||||
|
- [x] 2.4 实现 IotCardStore.GetStandaloneByFilters 方法(筛选条件查询,排除已绑定设备的卡)
|
||||||
|
- [x] 2.5 实现 IotCardStore.GetByICCIDs 方法
|
||||||
|
- [x] 2.6 实现 IotCardStore.GetBoundCardIDs 方法
|
||||||
|
|
||||||
|
## 3. Service 层
|
||||||
|
|
||||||
|
- [x] 3.1 创建 AssetAllocationRecordService
|
||||||
|
- [x] 3.1.1 实现 List 方法
|
||||||
|
- [x] 3.1.2 实现 GetByID 方法
|
||||||
|
- [x] 3.2 实现 IotCardService.AllocateCards 方法
|
||||||
|
- [x] 3.2.1 校验目标店铺是当前用户的直属下级
|
||||||
|
- [x] 3.2.2 根据选卡方式获取待分配的卡列表
|
||||||
|
- [x] 3.2.3 校验卡是单卡(未绑定设备)
|
||||||
|
- [x] 3.2.4 校验卡状态和所有权
|
||||||
|
- [x] 3.2.5 批量更新 shop_id 和 status
|
||||||
|
- [x] 3.2.6 创建分配记录
|
||||||
|
- [x] 3.3 实现 IotCardService.RecallCards 方法
|
||||||
|
- [x] 3.3.1 校验来源店铺是当前用户的直属下级
|
||||||
|
- [x] 3.3.2 根据选卡方式获取待回收的卡列表
|
||||||
|
- [x] 3.3.3 校验卡是单卡(未绑定设备)
|
||||||
|
- [x] 3.3.4 批量更新 shop_id(平台→NULL,店铺→回收方ID)和 status
|
||||||
|
- [x] 3.3.5 创建回收记录
|
||||||
|
|
||||||
|
## 4. Handler 层
|
||||||
|
|
||||||
|
- [x] 4.1 创建 AssetAllocationRecordHandler
|
||||||
|
- [x] 4.1.1 实现 List 方法
|
||||||
|
- [x] 4.1.2 实现 GetByID 方法
|
||||||
|
- [x] 4.2 实现 IotCardHandler.AllocateCards 方法
|
||||||
|
- [x] 4.3 实现 IotCardHandler.RecallCards 方法
|
||||||
|
|
||||||
|
## 5. 路由注册
|
||||||
|
|
||||||
|
- [x] 5.1 注册 POST /api/admin/iot-cards/standalone/allocate
|
||||||
|
- [x] 5.2 注册 POST /api/admin/iot-cards/standalone/recall
|
||||||
|
- [x] 5.3 注册 GET /api/admin/asset-allocation-records
|
||||||
|
- [x] 5.4 注册 GET /api/admin/asset-allocation-records/:id
|
||||||
|
- [x] 5.5 更新 Bootstrap(handlers.go、services.go、stores.go、types.go)
|
||||||
|
- [x] 5.6 更新文档生成器(cmd/api/docs.go 和 cmd/gendocs/main.go)
|
||||||
|
|
||||||
|
## 6. 测试
|
||||||
|
|
||||||
|
- [x] 6.1 AssetAllocationRecordStore 单元测试
|
||||||
|
- [x] 6.2 IotCardStore.BatchUpdateShopIDAndStatus 单元测试
|
||||||
|
- [x] 6.3 IotCardStore.GetStandaloneByICCIDRange 单元测试
|
||||||
|
- [x] 6.4 IotCardStore.GetStandaloneByFilters 单元测试
|
||||||
|
- [x] 6.5 IotCardStore.GetByICCIDs 单元测试
|
||||||
|
- [x] 6.6 IotCardStore.GetBoundCardIDs 单元测试
|
||||||
|
- [x] 6.7 分配 API 集成测试(TestStandaloneCardAllocation_AllocateByList)
|
||||||
|
- [x] 6.8 回收 API 集成测试(TestStandaloneCardAllocation_Recall)
|
||||||
|
- [x] 6.9 分配记录查询 API 集成测试(TestAssetAllocationRecord_List)
|
||||||
|
|
||||||
|
## 7. 文档更新
|
||||||
|
|
||||||
|
- [x] 7.1 同步 delta spec 到主规范
|
||||||
107
openspec/specs/asset-allocation-record/spec.md
Normal file
107
openspec/specs/asset-allocation-record/spec.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Asset Allocation Record
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
管理资产(IoT 卡、设备)在平台与代理商之间的流转记录,支持分配和回收操作的完整追溯。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 资产分配记录查询
|
||||||
|
|
||||||
|
系统 SHALL 提供资产分配记录的查询功能,支持查看卡和设备在平台与代理商之间的流转历史。
|
||||||
|
|
||||||
|
**记录类型**:
|
||||||
|
- `allocate`: 分配记录(上级分配给下级)
|
||||||
|
- `recall`: 回收记录(上级从下级回收)
|
||||||
|
|
||||||
|
**资产类型**:
|
||||||
|
- `iot_card`: 物联网卡(单卡)
|
||||||
|
- `device`: 设备(未来扩展)
|
||||||
|
|
||||||
|
**查询条件**:
|
||||||
|
- `allocation_type`(可选): 分配类型,枚举值 "allocate" | "recall"
|
||||||
|
- `asset_type`(可选): 资产类型,枚举值 "iot_card" | "device"
|
||||||
|
- `asset_identifier`(可选): 资产标识符(ICCID 或设备号),模糊匹配
|
||||||
|
- `allocation_no`(可选): 分配单号,精确匹配
|
||||||
|
- `from_shop_id`(可选): 来源店铺 ID
|
||||||
|
- `to_shop_id`(可选): 目标店铺 ID
|
||||||
|
- `operator_id`(可选): 操作人 ID
|
||||||
|
- `created_at_start`(可选): 创建时间起始
|
||||||
|
- `created_at_end`(可选): 创建时间结束
|
||||||
|
|
||||||
|
**分页**:
|
||||||
|
- 默认每页 20 条,最大每页 100 条
|
||||||
|
- 返回总记录数和总页数
|
||||||
|
|
||||||
|
**数据权限**:
|
||||||
|
- 平台用户可查看所有记录
|
||||||
|
- 代理用户只能查看与自己店铺相关的记录(作为来源或目标)
|
||||||
|
|
||||||
|
**API 端点**: `GET /api/admin/asset-allocation-records`
|
||||||
|
|
||||||
|
**响应字段**:
|
||||||
|
- `id`: 记录 ID
|
||||||
|
- `allocation_no`: 分配单号
|
||||||
|
- `allocation_type`: 分配类型
|
||||||
|
- `allocation_type_name`: 分配类型名称(分配/回收)
|
||||||
|
- `asset_type`: 资产类型
|
||||||
|
- `asset_type_name`: 资产类型名称(物联网卡/设备)
|
||||||
|
- `asset_id`: 资产 ID
|
||||||
|
- `asset_identifier`: 资产标识符
|
||||||
|
- `from_owner_type`: 来源所有者类型
|
||||||
|
- `from_owner_id`: 来源所有者 ID
|
||||||
|
- `from_owner_name`: 来源所有者名称
|
||||||
|
- `to_owner_type`: 目标所有者类型
|
||||||
|
- `to_owner_id`: 目标所有者 ID
|
||||||
|
- `to_owner_name`: 目标所有者名称
|
||||||
|
- `operator_id`: 操作人 ID
|
||||||
|
- `operator_name`: 操作人名称
|
||||||
|
- `remark`: 备注
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
|
||||||
|
#### Scenario: 查询所有分配记录
|
||||||
|
|
||||||
|
- **WHEN** 平台管理员查询分配记录列表,不带任何筛选条件
|
||||||
|
- **THEN** 系统返回所有分配和回收记录,按创建时间倒序排列
|
||||||
|
|
||||||
|
#### Scenario: 按资产类型筛选记录
|
||||||
|
|
||||||
|
- **WHEN** 管理员查询资产类型为 "iot_card" 的记录
|
||||||
|
- **THEN** 系统只返回物联网卡的分配/回收记录,不包含设备记录
|
||||||
|
|
||||||
|
#### Scenario: 按分配类型筛选记录
|
||||||
|
|
||||||
|
- **WHEN** 管理员查询分配类型为 "allocate" 的记录
|
||||||
|
- **THEN** 系统只返回分配记录,不包含回收记录
|
||||||
|
|
||||||
|
#### Scenario: 按 ICCID 模糊查询
|
||||||
|
|
||||||
|
- **WHEN** 管理员输入 asset_identifier = "8986001"
|
||||||
|
- **THEN** 系统返回 ICCID 包含 "8986001" 的所有分配记录
|
||||||
|
|
||||||
|
#### Scenario: 代理查询自己相关的记录
|
||||||
|
|
||||||
|
- **WHEN** 代理用户(店铺 ID=10)查询分配记录
|
||||||
|
- **THEN** 系统只返回 from_owner_id=10 或 to_owner_id=10 的记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 资产分配记录详情
|
||||||
|
|
||||||
|
系统 SHALL 提供资产分配记录详情查询功能。
|
||||||
|
|
||||||
|
**API 端点**: `GET /api/admin/asset-allocation-records/:id`
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
- 包含记录的所有字段
|
||||||
|
- 关联卡 ID 列表(如果是设备分配,包含设备下的所有卡 ID)
|
||||||
|
|
||||||
|
#### Scenario: 查询分配记录详情
|
||||||
|
|
||||||
|
- **WHEN** 管理员查询分配记录详情(ID=1)
|
||||||
|
- **THEN** 系统返回该记录的完整信息,包括来源/目标所有者名称、操作人名称等
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在的记录
|
||||||
|
|
||||||
|
- **WHEN** 管理员查询不存在的分配记录(ID=999)
|
||||||
|
- **THEN** 系统返回 404 错误,提示"分配记录不存在"
|
||||||
@@ -373,3 +373,133 @@ This capability supports:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Requirement: 单卡分配功能
|
||||||
|
|
||||||
|
系统 SHALL 支持将单卡(未绑定设备的 IoT 卡)分配给直属下级店铺,实现资产所有权的层级流转。
|
||||||
|
|
||||||
|
**分配规则**:
|
||||||
|
- 只能分配给直属下级店铺,不可跨级分配
|
||||||
|
- 平台(shop_id=NULL)只能分配状态为在库(status=1)的卡
|
||||||
|
- 代理店铺可以分配状态为已分销(status=2)的卡(继续往下分销)
|
||||||
|
- 分配后状态变更:在库(1)→已分销(2),已分销(2)保持不变
|
||||||
|
- 分配后 shop_id 变更为目标店铺 ID
|
||||||
|
- 分配不涉及费用,纯资产所有权转移
|
||||||
|
- 分配后上级仍能看到和管理(通过数据权限机制)
|
||||||
|
|
||||||
|
**选卡方式**(三选一):
|
||||||
|
- ICCID 列表:指定具体的 ICCID 列表
|
||||||
|
- 号段范围:指定起始 ICCID 和结束 ICCID
|
||||||
|
- 筛选条件:按运营商、批次号、状态等条件批量选择
|
||||||
|
|
||||||
|
**API 端点**: `POST /api/admin/iot-cards/standalone/allocate`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
- `to_shop_id`(必填): 目标店铺 ID
|
||||||
|
- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter"
|
||||||
|
- `iccids`(selection_type=list 时必填): ICCID 列表
|
||||||
|
- `iccid_start`(selection_type=range 时必填): 起始 ICCID
|
||||||
|
- `iccid_end`(selection_type=range 时必填): 结束 ICCID
|
||||||
|
- `carrier_id`(selection_type=filter 时可选): 运营商 ID
|
||||||
|
- `batch_no`(selection_type=filter 时可选): 批次号
|
||||||
|
- `status`(selection_type=filter 时可选): 卡状态
|
||||||
|
- `remark`(可选): 备注
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
- `total_count`: 待分配总数
|
||||||
|
- `success_count`: 成功数
|
||||||
|
- `fail_count`: 失败数
|
||||||
|
- `failed_items`: 失败项列表(包含 ICCID 和失败原因)
|
||||||
|
|
||||||
|
#### Scenario: 平台通过 ICCID 列表分配单卡给一级代理
|
||||||
|
|
||||||
|
- **WHEN** 平台管理员选择 3 张在库单卡(ICCID 列表),分配给一级代理店铺(ID=10)
|
||||||
|
- **THEN** 系统将这 3 张卡的 shop_id 更新为 10,status 从 1 变为 2,创建分配记录,返回成功数 3
|
||||||
|
|
||||||
|
#### Scenario: 平台通过号段范围批量分配单卡
|
||||||
|
|
||||||
|
- **WHEN** 平台管理员指定 ICCID 范围 "8986001000000000000" 至 "8986001000000000099",分配给一级代理店铺(ID=10)
|
||||||
|
- **THEN** 系统查询该范围内的所有在库单卡,批量更新 shop_id 和 status,创建分配记录
|
||||||
|
|
||||||
|
#### Scenario: 代理通过筛选条件分配单卡给下级
|
||||||
|
|
||||||
|
- **WHEN** 一级代理(店铺 ID=10)按条件筛选(运营商=电信,批次号=BATCH-001)自己的已分销卡,分配给二级代理店铺(ID=20)
|
||||||
|
- **THEN** 系统查询符合条件的卡,校验店铺 20 是店铺 10 的直属下级,批量更新 shop_id 为 20,status 保持 2
|
||||||
|
|
||||||
|
#### Scenario: 拒绝跨级分配
|
||||||
|
|
||||||
|
- **WHEN** 平台尝试将卡直接分配给二级代理店铺(非直属下级)
|
||||||
|
- **THEN** 系统拒绝分配,返回错误"只能分配给直属下级店铺"
|
||||||
|
|
||||||
|
#### Scenario: 拒绝平台分配已分销的卡
|
||||||
|
|
||||||
|
- **WHEN** 平台尝试分配状态为已分销(status=2)的卡
|
||||||
|
- **THEN** 系统拒绝分配,返回错误"在库状态的卡才能分配,请先回收"
|
||||||
|
|
||||||
|
#### Scenario: 拒绝分配已绑定设备的卡
|
||||||
|
|
||||||
|
- **WHEN** 用户尝试分配已绑定设备的卡(在 device_sim_bindings 中 bind_status=1)
|
||||||
|
- **THEN** 系统拒绝分配,返回错误"已绑定设备的卡不能单独分配"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 单卡回收功能
|
||||||
|
|
||||||
|
系统 SHALL 支持上级回收已分配给直属下级的单卡,将卡的所有权收回。
|
||||||
|
|
||||||
|
**回收规则**:
|
||||||
|
- 只有上级可以回收,代理不能主动退回给上级
|
||||||
|
- 只能回收直属下级的卡,不可跨级回收
|
||||||
|
- 平台回收:shop_id 变为 NULL,status 变为 1(在库)
|
||||||
|
- 店铺回收:shop_id 变为执行回收的店铺 ID,status 保持 2(已分销)
|
||||||
|
- 只能回收单卡(未绑定设备的卡)
|
||||||
|
|
||||||
|
**选卡方式**(与分配相同,三选一):
|
||||||
|
- ICCID 列表
|
||||||
|
- 号段范围
|
||||||
|
- 筛选条件
|
||||||
|
|
||||||
|
**API 端点**: `POST /api/admin/iot-cards/standalone/recall`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
- `from_shop_id`(必填): 来源店铺 ID(被回收方)
|
||||||
|
- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter"
|
||||||
|
- `iccids`(selection_type=list 时必填): ICCID 列表
|
||||||
|
- `iccid_start`(selection_type=range 时必填): 起始 ICCID
|
||||||
|
- `iccid_end`(selection_type=range 时必填): 结束 ICCID
|
||||||
|
- `carrier_id`(selection_type=filter 时可选): 运营商 ID
|
||||||
|
- `batch_no`(selection_type=filter 时可选): 批次号
|
||||||
|
- `remark`(可选): 备注
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
- `total_count`: 待回收总数
|
||||||
|
- `success_count`: 成功数
|
||||||
|
- `fail_count`: 失败数
|
||||||
|
- `failed_items`: 失败项列表
|
||||||
|
|
||||||
|
#### Scenario: 平台回收一级代理的单卡
|
||||||
|
|
||||||
|
- **WHEN** 平台管理员选择一级代理店铺(ID=10)的 5 张单卡进行回收
|
||||||
|
- **THEN** 系统将这 5 张卡的 shop_id 更新为 NULL,status 从 2 变为 1,创建回收记录
|
||||||
|
|
||||||
|
#### Scenario: 一级代理回收二级代理的单卡
|
||||||
|
|
||||||
|
- **WHEN** 一级代理(店铺 ID=10)选择二级代理店铺(ID=20)的 3 张单卡进行回收
|
||||||
|
- **THEN** 系统将这 3 张卡的 shop_id 更新为 10,status 保持 2,创建回收记录
|
||||||
|
|
||||||
|
#### Scenario: 拒绝回收非直属下级的卡
|
||||||
|
|
||||||
|
- **WHEN** 一级代理(店铺 ID=10)尝试回收非直属下级店铺(ID=30,归属于店铺 ID=20)的卡
|
||||||
|
- **THEN** 系统拒绝回收,返回错误"只能回收直属下级店铺的卡"
|
||||||
|
|
||||||
|
#### Scenario: 拒绝代理主动退回
|
||||||
|
|
||||||
|
- **WHEN** 二级代理(店铺 ID=20)尝试将卡退回给上级店铺(ID=10)
|
||||||
|
- **THEN** 系统拒绝操作,返回错误"不能主动退回卡给上级,请联系上级进行回收"
|
||||||
|
|
||||||
|
#### Scenario: 拒绝回收已绑定设备的卡
|
||||||
|
|
||||||
|
- **WHEN** 用户尝试回收已绑定设备的卡
|
||||||
|
- **THEN** 系统拒绝回收,返回错误"已绑定设备的卡不能单独回收"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ const (
|
|||||||
CodeWithdrawalNotFound = 1052 // 提现申请不存在
|
CodeWithdrawalNotFound = 1052 // 提现申请不存在
|
||||||
CodeWalletNotFound = 1053 // 钱包不存在
|
CodeWalletNotFound = 1053 // 钱包不存在
|
||||||
|
|
||||||
|
// IoT 卡相关错误 (1070-1089)
|
||||||
|
CodeIotCardNotFound = 1070 // IoT 卡不存在
|
||||||
|
CodeIotCardBoundToDevice = 1071 // IoT 卡已绑定设备
|
||||||
|
CodeIotCardStatusNotAllowed = 1072 // 卡状态不允许此操作
|
||||||
|
CodeAssetAllocationRecordNotFound = 1073 // 分配记录不存在
|
||||||
|
CodeNotDirectSubordinate = 1074 // 非直属下级店铺
|
||||||
|
CodeCannotAllocateToSelf = 1075 // 不能分配给自己
|
||||||
|
CodeCannotRecallFromSelf = 1076 // 不能从自己回收
|
||||||
|
|
||||||
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码
|
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码
|
||||||
CodeInternalError = 2001 // 内部服务器错误
|
CodeInternalError = 2001 // 内部服务器错误
|
||||||
CodeDatabaseError = 2002 // 数据库错误
|
CodeDatabaseError = 2002 // 数据库错误
|
||||||
@@ -114,6 +123,13 @@ var allErrorCodes = []int{
|
|||||||
CodeInsufficientBalance,
|
CodeInsufficientBalance,
|
||||||
CodeWithdrawalNotFound,
|
CodeWithdrawalNotFound,
|
||||||
CodeWalletNotFound,
|
CodeWalletNotFound,
|
||||||
|
CodeIotCardNotFound,
|
||||||
|
CodeIotCardBoundToDevice,
|
||||||
|
CodeIotCardStatusNotAllowed,
|
||||||
|
CodeAssetAllocationRecordNotFound,
|
||||||
|
CodeNotDirectSubordinate,
|
||||||
|
CodeCannotAllocateToSelf,
|
||||||
|
CodeCannotRecallFromSelf,
|
||||||
CodeInternalError,
|
CodeInternalError,
|
||||||
CodeDatabaseError,
|
CodeDatabaseError,
|
||||||
CodeRedisError,
|
CodeRedisError,
|
||||||
@@ -171,6 +187,13 @@ var errorMessages = map[int]string{
|
|||||||
CodeInsufficientBalance: "余额不足",
|
CodeInsufficientBalance: "余额不足",
|
||||||
CodeWithdrawalNotFound: "提现申请不存在",
|
CodeWithdrawalNotFound: "提现申请不存在",
|
||||||
CodeWalletNotFound: "钱包不存在",
|
CodeWalletNotFound: "钱包不存在",
|
||||||
|
CodeIotCardNotFound: "IoT 卡不存在",
|
||||||
|
CodeIotCardBoundToDevice: "IoT 卡已绑定设备,不能单独操作",
|
||||||
|
CodeIotCardStatusNotAllowed: "卡状态不允许此操作",
|
||||||
|
CodeAssetAllocationRecordNotFound: "分配记录不存在",
|
||||||
|
CodeNotDirectSubordinate: "只能操作直属下级店铺",
|
||||||
|
CodeCannotAllocateToSelf: "不能分配给自己",
|
||||||
|
CodeCannotRecallFromSelf: "不能从自己回收",
|
||||||
CodeInvalidCredentials: "用户名或密码错误",
|
CodeInvalidCredentials: "用户名或密码错误",
|
||||||
CodeAccountLocked: "账号已锁定",
|
CodeAccountLocked: "账号已锁定",
|
||||||
CodePasswordExpired: "密码已过期",
|
CodePasswordExpired: "密码已过期",
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ var (
|
|||||||
ErrTooManyRequests = errors.New("too many requests")
|
ErrTooManyRequests = errors.New("too many requests")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 预定义业务错误(常用错误可直接引用)
|
||||||
|
var (
|
||||||
|
ErrAssetAllocationRecordNotFound = &AppError{Code: CodeAssetAllocationRecordNotFound, Message: "分配记录不存在"}
|
||||||
|
ErrNotDirectSubordinate = &AppError{Code: CodeNotDirectSubordinate, Message: "只能操作直属下级店铺"}
|
||||||
|
ErrIotCardBoundToDevice = &AppError{Code: CodeIotCardBoundToDevice, Message: "IoT 卡已绑定设备,不能单独操作"}
|
||||||
|
ErrIotCardStatusNotAllowed = &AppError{Code: CodeIotCardStatusNotAllowed, Message: "卡状态不允许此操作"}
|
||||||
|
ErrCannotAllocateToSelf = &AppError{Code: CodeCannotAllocateToSelf, Message: "不能分配给自己"}
|
||||||
|
ErrCannotRecallFromSelf = &AppError{Code: CodeCannotRecallFromSelf, Message: "不能从自己回收"}
|
||||||
|
)
|
||||||
|
|
||||||
// AppError 表示带错误码的应用错误
|
// AppError 表示带错误码的应用错误
|
||||||
type AppError struct {
|
type AppError struct {
|
||||||
Code int // 应用错误码
|
Code int // 应用错误码
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package gorm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||||
@@ -236,13 +237,47 @@ func RegisterDataPermissionCallback(db *gorm.DB, shopStore ShopStoreInterface) e
|
|||||||
func RegisterSetCreatorUpdaterCallback(db *gorm.DB) error {
|
func RegisterSetCreatorUpdaterCallback(db *gorm.DB) error {
|
||||||
err := db.Callback().Create().Before("gorm:create").Register("set_creator_updater", func(tx *gorm.DB) {
|
err := db.Callback().Create().Before("gorm:create").Register("set_creator_updater", func(tx *gorm.DB) {
|
||||||
ctx := tx.Statement.Context
|
ctx := tx.Statement.Context
|
||||||
if userID, ok := tx.Statement.Context.Value(constants.ContextKeyUserID).(uint); ok {
|
userID, ok := tx.Statement.Context.Value(constants.ContextKeyUserID).(uint)
|
||||||
if f := tx.Statement.Schema; f != nil {
|
if !ok || tx.Statement.Schema == nil {
|
||||||
if c, ok := f.FieldsByName["Creator"]; ok {
|
return
|
||||||
_ = c.Set(ctx, tx.Statement.ReflectValue, userID)
|
|
||||||
}
|
}
|
||||||
if u, ok := f.FieldsByName["Updater"]; ok {
|
|
||||||
_ = u.Set(ctx, tx.Statement.ReflectValue, userID)
|
creatorField, hasCreator := tx.Statement.Schema.FieldsByName["Creator"]
|
||||||
|
updaterField, hasUpdater := tx.Statement.Schema.FieldsByName["Updater"]
|
||||||
|
if !hasCreator && !hasUpdater {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rv := tx.Statement.ReflectValue
|
||||||
|
switch rv.Kind() {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
for i := 0; i < rv.Len(); i++ {
|
||||||
|
elem := rv.Index(i)
|
||||||
|
if elem.Kind() == reflect.Ptr {
|
||||||
|
elem = elem.Elem()
|
||||||
|
}
|
||||||
|
if hasCreator {
|
||||||
|
_ = creatorField.Set(ctx, elem, userID)
|
||||||
|
}
|
||||||
|
if hasUpdater {
|
||||||
|
_ = updaterField.Set(ctx, elem, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case reflect.Struct:
|
||||||
|
if hasCreator {
|
||||||
|
_ = creatorField.Set(ctx, rv, userID)
|
||||||
|
}
|
||||||
|
if hasUpdater {
|
||||||
|
_ = updaterField.Set(ctx, rv, userID)
|
||||||
|
}
|
||||||
|
case reflect.Ptr:
|
||||||
|
elem := rv.Elem()
|
||||||
|
if elem.Kind() == reflect.Struct {
|
||||||
|
if hasCreator {
|
||||||
|
_ = creatorField.Set(ctx, elem, userID)
|
||||||
|
}
|
||||||
|
if hasUpdater {
|
||||||
|
_ = updaterField.Set(ctx, elem, userID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
426
tests/integration/standalone_card_allocation_test.go
Normal file
426
tests/integration/standalone_card_allocation_test.go
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
|
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
pkggorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/break/junhong_cmp_fiber/tests/testutil"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type allocationTestEnv struct {
|
||||||
|
db *gorm.DB
|
||||||
|
rdb *redis.Client
|
||||||
|
tokenManager *auth.TokenManager
|
||||||
|
app *fiber.App
|
||||||
|
adminToken string
|
||||||
|
agentToken string
|
||||||
|
adminID uint
|
||||||
|
agentID uint
|
||||||
|
shopID uint
|
||||||
|
subShopID uint
|
||||||
|
t *testing.T
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAllocationTestEnv(t *testing.T) *allocationTestEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Setenv("CONFIG_ENV", "dev")
|
||||||
|
t.Setenv("CONFIG_PATH", "../../configs/config.dev.yaml")
|
||||||
|
cfg, err := config.Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = config.Set(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
zapLogger, _ := zap.NewDevelopment()
|
||||||
|
|
||||||
|
dsn := "host=cxd.whcxd.cn port=16159 user=erp_pgsql password=erp_2025 dbname=junhong_cmp_test sslmode=disable TimeZone=Asia/Shanghai"
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
db.Exec("DELETE FROM tb_asset_allocation_record WHERE asset_identifier LIKE 'ALLOC_TEST%'")
|
||||||
|
db.Exec("DELETE FROM tb_iot_card WHERE iccid LIKE 'ALLOC_TEST%'")
|
||||||
|
db.Exec("DROP INDEX IF EXISTS uk_asset_allocation_no")
|
||||||
|
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: "cxd.whcxd.cn:16299",
|
||||||
|
Password: "cpNbWtAaqgo1YJmbMp3h",
|
||||||
|
DB: 15,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err = rdb.Ping(ctx).Err()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testPrefix := fmt.Sprintf("test:%s:", t.Name())
|
||||||
|
keys, _ := rdb.Keys(ctx, testPrefix+"*").Result()
|
||||||
|
if len(keys) > 0 {
|
||||||
|
rdb.Del(ctx, keys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenManager := auth.NewTokenManager(rdb, 24*time.Hour, 7*24*time.Hour)
|
||||||
|
superAdmin := testutil.CreateSuperAdmin(t, db)
|
||||||
|
adminToken, _ := testutil.GenerateTestToken(t, rdb, superAdmin, "web")
|
||||||
|
|
||||||
|
shop := &model.Shop{
|
||||||
|
ShopName: fmt.Sprintf("测试店铺_%d", time.Now().UnixNano()),
|
||||||
|
ShopCode: fmt.Sprintf("ALLOC_SHOP_%d", time.Now().UnixNano()),
|
||||||
|
ContactName: "测试联系人",
|
||||||
|
ContactPhone: "13800000001",
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Create(shop).Error)
|
||||||
|
|
||||||
|
subShop := &model.Shop{
|
||||||
|
ShopName: fmt.Sprintf("测试下级店铺_%d", time.Now().UnixNano()),
|
||||||
|
ShopCode: fmt.Sprintf("ALLOC_SUB_%d", time.Now().UnixNano()),
|
||||||
|
ParentID: &shop.ID,
|
||||||
|
Level: 2,
|
||||||
|
ContactName: "下级联系人",
|
||||||
|
ContactPhone: "13800000002",
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Create(subShop).Error)
|
||||||
|
|
||||||
|
agentAccount := &model.Account{
|
||||||
|
Username: fmt.Sprintf("agent_alloc_%d", time.Now().UnixNano()),
|
||||||
|
Phone: fmt.Sprintf("139%08d", time.Now().UnixNano()%100000000),
|
||||||
|
Password: "hashed_password",
|
||||||
|
UserType: constants.UserTypeAgent,
|
||||||
|
ShopID: &shop.ID,
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Create(agentAccount).Error)
|
||||||
|
agentToken, _ := testutil.GenerateTestToken(t, rdb, agentAccount, "web")
|
||||||
|
|
||||||
|
queueClient := queue.NewClient(rdb, zapLogger)
|
||||||
|
|
||||||
|
deps := &bootstrap.Dependencies{
|
||||||
|
DB: db,
|
||||||
|
Redis: rdb,
|
||||||
|
Logger: zapLogger,
|
||||||
|
TokenManager: tokenManager,
|
||||||
|
QueueClient: queueClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := bootstrap.Bootstrap(deps)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
app := fiber.New(fiber.Config{
|
||||||
|
ErrorHandler: internalMiddleware.ErrorHandler(zapLogger),
|
||||||
|
})
|
||||||
|
|
||||||
|
routes.RegisterRoutes(app, result.Handlers, result.Middlewares)
|
||||||
|
|
||||||
|
return &allocationTestEnv{
|
||||||
|
db: db,
|
||||||
|
rdb: rdb,
|
||||||
|
tokenManager: tokenManager,
|
||||||
|
app: app,
|
||||||
|
adminToken: adminToken,
|
||||||
|
agentToken: agentToken,
|
||||||
|
adminID: superAdmin.ID,
|
||||||
|
agentID: agentAccount.ID,
|
||||||
|
shopID: shop.ID,
|
||||||
|
subShopID: subShop.ID,
|
||||||
|
t: t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *allocationTestEnv) teardown() {
|
||||||
|
e.db.Exec("DELETE FROM tb_iot_card WHERE iccid LIKE 'ALLOC_TEST%'")
|
||||||
|
e.db.Exec("DELETE FROM tb_asset_allocation_record WHERE asset_identifier LIKE 'ALLOC_TEST%'")
|
||||||
|
e.db.Exec("DELETE FROM tb_shop WHERE shop_code LIKE 'ALLOC_%'")
|
||||||
|
e.db.Exec("DELETE FROM tb_account WHERE username LIKE 'agent_alloc_%'")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
testPrefix := fmt.Sprintf("test:%s:", e.t.Name())
|
||||||
|
keys, _ := e.rdb.Keys(ctx, testPrefix+"*").Result()
|
||||||
|
if len(keys) > 0 {
|
||||||
|
e.rdb.Del(ctx, keys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.rdb.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStandaloneCardAllocation_AllocateByList(t *testing.T) {
|
||||||
|
env := setupAllocationTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
cards := []*model.IotCard{
|
||||||
|
{ICCID: "ALLOC_TEST001", CardType: "data_card", CarrierID: 1, Status: constants.IotCardStatusInStock},
|
||||||
|
{ICCID: "ALLOC_TEST002", CardType: "data_card", CarrierID: 1, Status: constants.IotCardStatusInStock},
|
||||||
|
{ICCID: "ALLOC_TEST003", CardType: "data_card", CarrierID: 1, Status: constants.IotCardStatusInStock},
|
||||||
|
}
|
||||||
|
for _, card := range cards {
|
||||||
|
require.NoError(t, env.db.Create(card).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("平台分配卡给一级店铺", func(t *testing.T) {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"to_shop_id": env.shopID,
|
||||||
|
"selection_type": "list",
|
||||||
|
"iccids": []string{"ALLOC_TEST001", "ALLOC_TEST002"},
|
||||||
|
"remark": "测试分配",
|
||||||
|
}
|
||||||
|
bodyBytes, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/iot-cards/standalone/allocate", bytes.NewReader(bodyBytes))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Logf("Allocate response: code=%d, message=%s, data=%v", result.Code, result.Message, result.Data)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
assert.Equal(t, 0, result.Code, "应返回成功: %s", result.Message)
|
||||||
|
|
||||||
|
if result.Data != nil {
|
||||||
|
dataMap := result.Data.(map[string]interface{})
|
||||||
|
assert.Equal(t, float64(2), dataMap["total_count"])
|
||||||
|
assert.Equal(t, float64(2), dataMap["success_count"])
|
||||||
|
assert.Equal(t, float64(0), dataMap["fail_count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := pkggorm.SkipDataPermission(context.Background())
|
||||||
|
var updatedCards []model.IotCard
|
||||||
|
env.db.WithContext(ctx).Where("iccid IN ?", []string{"ALLOC_TEST001", "ALLOC_TEST002"}).Find(&updatedCards)
|
||||||
|
for _, card := range updatedCards {
|
||||||
|
assert.Equal(t, env.shopID, *card.ShopID, "卡应分配给目标店铺")
|
||||||
|
assert.Equal(t, constants.IotCardStatusDistributed, card.Status, "状态应为已分销")
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordCount int64
|
||||||
|
env.db.WithContext(ctx).Model(&model.AssetAllocationRecord{}).
|
||||||
|
Where("asset_identifier IN ?", []string{"ALLOC_TEST001", "ALLOC_TEST002"}).
|
||||||
|
Count(&recordCount)
|
||||||
|
assert.Equal(t, int64(2), recordCount, "应创建2条分配记录")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("代理分配卡给下级店铺", func(t *testing.T) {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"to_shop_id": env.subShopID,
|
||||||
|
"selection_type": "list",
|
||||||
|
"iccids": []string{"ALLOC_TEST001"},
|
||||||
|
"remark": "代理分配测试",
|
||||||
|
}
|
||||||
|
bodyBytes, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/iot-cards/standalone/allocate", bytes.NewReader(bodyBytes))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.agentToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Logf("Agent allocate response: code=%d, message=%s", result.Code, result.Message)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
assert.Equal(t, 0, result.Code, "代理应能分配给下级: %s", result.Message)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("分配不存在的卡应返回空结果", func(t *testing.T) {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"to_shop_id": env.shopID,
|
||||||
|
"selection_type": "list",
|
||||||
|
"iccids": []string{"NOT_EXISTS_001", "NOT_EXISTS_002"},
|
||||||
|
}
|
||||||
|
bodyBytes, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/iot-cards/standalone/allocate", bytes.NewReader(bodyBytes))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
if result.Data != nil {
|
||||||
|
dataMap := result.Data.(map[string]interface{})
|
||||||
|
assert.Equal(t, float64(0), dataMap["total_count"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStandaloneCardAllocation_Recall(t *testing.T) {
|
||||||
|
env := setupAllocationTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
shopID := env.shopID
|
||||||
|
cards := []*model.IotCard{
|
||||||
|
{ICCID: "ALLOC_TEST101", CardType: "data_card", CarrierID: 1, Status: constants.IotCardStatusDistributed, ShopID: &shopID},
|
||||||
|
{ICCID: "ALLOC_TEST102", CardType: "data_card", CarrierID: 1, Status: constants.IotCardStatusDistributed, ShopID: &shopID},
|
||||||
|
}
|
||||||
|
for _, card := range cards {
|
||||||
|
require.NoError(t, env.db.Create(card).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("平台回收卡", func(t *testing.T) {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"from_shop_id": env.shopID,
|
||||||
|
"selection_type": "list",
|
||||||
|
"iccids": []string{"ALLOC_TEST101"},
|
||||||
|
"remark": "平台回收测试",
|
||||||
|
}
|
||||||
|
bodyBytes, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/iot-cards/standalone/recall", bytes.NewReader(bodyBytes))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Logf("Recall response: code=%d, message=%s, data=%v", result.Code, result.Message, result.Data)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
assert.Equal(t, 0, result.Code, "应返回成功: %s", result.Message)
|
||||||
|
|
||||||
|
ctx := pkggorm.SkipDataPermission(context.Background())
|
||||||
|
var recalledCard model.IotCard
|
||||||
|
env.db.WithContext(ctx).Where("iccid = ?", "ALLOC_TEST101").First(&recalledCard)
|
||||||
|
assert.Nil(t, recalledCard.ShopID, "平台回收后shop_id应为NULL")
|
||||||
|
assert.Equal(t, constants.IotCardStatusInStock, recalledCard.Status, "状态应恢复为在库")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssetAllocationRecord_List(t *testing.T) {
|
||||||
|
env := setupAllocationTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
fromShopID := env.shopID
|
||||||
|
records := []*model.AssetAllocationRecord{
|
||||||
|
{
|
||||||
|
AllocationNo: fmt.Sprintf("AL%d001", time.Now().UnixNano()),
|
||||||
|
AllocationType: constants.AssetAllocationTypeAllocate,
|
||||||
|
AssetType: constants.AssetTypeIotCard,
|
||||||
|
AssetID: 1,
|
||||||
|
AssetIdentifier: "ALLOC_TEST_REC001",
|
||||||
|
FromOwnerType: constants.OwnerTypePlatform,
|
||||||
|
ToOwnerType: constants.OwnerTypeShop,
|
||||||
|
ToOwnerID: env.shopID,
|
||||||
|
OperatorID: env.adminID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AllocationNo: fmt.Sprintf("RC%d001", time.Now().UnixNano()),
|
||||||
|
AllocationType: constants.AssetAllocationTypeRecall,
|
||||||
|
AssetType: constants.AssetTypeIotCard,
|
||||||
|
AssetID: 2,
|
||||||
|
AssetIdentifier: "ALLOC_TEST_REC002",
|
||||||
|
FromOwnerType: constants.OwnerTypeShop,
|
||||||
|
FromOwnerID: &fromShopID,
|
||||||
|
ToOwnerType: constants.OwnerTypePlatform,
|
||||||
|
ToOwnerID: 0,
|
||||||
|
OperatorID: env.adminID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
require.NoError(t, env.db.Create(record).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("获取分配记录列表", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/admin/asset-allocation-records?page=1&page_size=20", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按分配类型过滤", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/admin/asset-allocation-records?allocation_type=allocate", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("获取分配记录详情", func(t *testing.T) {
|
||||||
|
url := fmt.Sprintf("/api/admin/asset-allocation-records/%d", records[0].ID)
|
||||||
|
req := httptest.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("未认证请求应返回错误", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/admin/asset-allocation-records", nil)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, 0, result.Code, "未认证请求应返回错误码")
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user