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

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

View File

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

View File

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