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