Files
junhong_cmp_fiber/internal/service/exchange/service.go
break 5e552d99bc 收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展
Confidence: medium
Scope-risk: broad
Directive: 后续修改需保持审计事件与业务事务边界一致
Tested: git diff --cached --check
Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
2026-08-05 14:30:54 +08:00

1272 lines
52 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package exchange
import (
"context"
"strings"
"time"
exchangeapp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
db *gorm.DB
exchangeStore *postgres.ExchangeOrderStore
refundStore *postgres.RefundStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
assetWalletStore *postgres.AssetWalletStore
assetWalletTransactionStore *postgres.AssetWalletTransactionStore
packageUsageStore *postgres.PackageUsageStore
packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore
resourceTagStore *postgres.ResourceTagStore
customerBinding *customerBindingSvc.Service
shippingCreatedNotifier *exchangeapp.ShippingCreatedNotifier
auditWriter *audit.Writer
logger *zap.Logger
}
func New(
db *gorm.DB,
exchangeStore *postgres.ExchangeOrderStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
assetWalletStore *postgres.AssetWalletStore,
assetWalletTransactionStore *postgres.AssetWalletTransactionStore,
packageUsageStore *postgres.PackageUsageStore,
packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore,
resourceTagStore *postgres.ResourceTagStore,
customerBinding *customerBindingSvc.Service,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
exchangeStore: exchangeStore,
refundStore: postgres.NewRefundStore(db),
iotCardStore: iotCardStore,
deviceStore: deviceStore,
assetWalletStore: assetWalletStore,
assetWalletTransactionStore: assetWalletTransactionStore,
packageUsageStore: packageUsageStore,
packageUsageDailyRecordStore: packageUsageDailyRecordStore,
resourceTagStore: resourceTagStore,
customerBinding: customerBinding,
logger: logger,
}
}
// SetShippingCreatedNotifier 注入物流换货创建后的可靠通知用例。
func (s *Service) SetShippingCreatedNotifier(notifier *exchangeapp.ShippingCreatedNotifier) {
s.shippingCreatedNotifier = notifier
}
func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*dto.ExchangeOrderResponse, error) {
flowType := normalizeExchangeFlowType(req.FlowType)
if !isValidExchangeFlowType(flowType) {
return nil, errors.New(errors.CodeInvalidParam, "换货流程类型不合法")
}
if flowType == constants.ExchangeFlowTypeDirect && strings.TrimSpace(req.NewIdentifier) == "" {
return nil, errors.New(errors.CodeInvalidParam, "直接换货必须填写新资产标识")
}
asset, err := s.resolveAssetByIdentifier(ctx, req.OldAssetType, req.OldIdentifier)
if err != nil {
return nil, err
}
migrateData := false
if req.MigrateData != nil {
migrateData = *req.MigrateData
}
creator := middleware.GetUserIDFromContext(ctx)
order := &model.ExchangeOrder{
ExchangeNo: model.GenerateExchangeNo(),
FlowType: flowType,
OldAssetType: asset.AssetType,
OldAssetID: asset.AssetID,
OldAssetIdentifier: asset.Identifier,
ExchangeReason: req.ExchangeReason,
Remark: req.Remark,
Status: constants.ExchangeStatusPendingInfo,
MigrationCompleted: false,
MigrationBalance: 0,
MigrateData: flowType == constants.ExchangeFlowTypeDirect && migrateData,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
}
if asset.ShopID != nil {
order.ShopID = asset.ShopID
}
if !isExchangeableAssetStatus(asset.AssetStatus) {
err = oldAssetStatusError(asset.AssetStatus)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
return nil, err
}
hasUnfinishedRefund, err := s.refundStore.HasUnfinishedByAsset(ctx, asset.AssetType, asset.AssetID)
if err != nil {
err = errors.Wrap(errors.CodeDatabaseError, err, "查询资产退款申请失败")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
if hasUnfinishedRefund {
err = errors.New(errors.CodeExchangeActiveRefund)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
return nil, err
}
if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil {
err = errors.New(errors.CodeExchangeInProgress)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
return nil, err
} else if err != gorm.ErrRecordNotFound {
err = errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
if flowType == constants.ExchangeFlowTypeDirect {
orderID, directErr := s.createDirectExchange(ctx, req, asset, order)
if directErr != nil {
order.ID = 0
order.Status = constants.ExchangeStatusPendingInfo
order.MigrationCompleted = false
order.MigrationBalance = 0
order.CompletedAt = nil
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡直接换货失败", order, directErr)
return nil, directErr
}
return s.Get(ctx, orderID)
}
if s.shippingCreatedNotifier == nil {
err = errors.New(errors.CodeInternalError, "物流换货通知服务未配置")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if createErr := tx.WithContext(ctx).Create(order).Error; createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建换货单失败")
}
customerIDs, queryErr := s.customerBinding.ActiveCustomerIDsByAsset(ctx, tx, asset.AssetType, asset.AssetID)
if queryErr != nil {
return queryErr
}
for _, customerID := range customerIDs {
if notifyErr := s.shippingCreatedNotifier.Notify(ctx, tx, exchangeapp.ShippingCreatedEvent{
ExchangeID: order.ID, ExchangeNo: order.ExchangeNo, CustomerID: customerID,
AssetType: asset.AssetType, AssetID: asset.AssetID, AssetIdentifier: asset.Identifier,
RequestID: requestID, CorrelationID: requestID,
}); notifyErr != nil {
return notifyErr
}
}
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCreated, "已创建卡换货单", constants.AuditResultSuccess,
order, asset, nil,
map[string]any{"exists": false}, map[string]any{"status": constants.ExchangeStatusPendingInfo},
nil, nil, nil, nil, nil, nil)
})
if err != nil {
order.ID = 0
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
resp := s.toExchangeOrderResponse(order)
resp.SubmitterName = s.loadExchangeSubmitterNameBestEffort(ctx, order.Creator)
return resp, nil
}
func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, error) {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeExchangeOrderNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单详情失败")
}
resp := s.toExchangeOrderResponse(order)
resp.SubmitterName = s.loadExchangeSubmitterNameBestEffort(ctx, order.Creator)
return resp, nil
}
func (s *Service) Ship(ctx context.Context, id uint, req *dto.ExchangeShipRequest) (*dto.ExchangeOrderResponse, error) {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeExchangeOrderNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusPendingShip {
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货被拒绝", order, err)
return nil, err
}
if !isShippingExchangeFlow(order.FlowType) {
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货被拒绝", order, err)
return nil, err
}
if err = s.shipWithTx(ctx, order, req); err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货失败", order, err)
return nil, err
}
return s.Get(ctx, id)
}
func (s *Service) Complete(ctx context.Context, id uint) error {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeOrderNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusShipped {
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err)
return err
}
if !isShippingExchangeFlow(order.FlowType) {
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err)
return err
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedOrder, lockErr := s.lockExchangeOrderByID(ctx, tx, id)
if lockErr != nil {
return lockErr
}
if !isShippingExchangeFlow(lockedOrder.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成")
}
if lockedOrder.Status != constants.ExchangeStatusShipped {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return s.completeExchangeWithTx(ctx, tx, lockedOrder, constants.ExchangeStatusShipped)
})
if err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货失败", order, err)
}
return err
}
func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRequest) error {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeOrderNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusPendingInfo && order.Status != constants.ExchangeStatusPendingShip {
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货被拒绝", order, err)
return err
}
if !isShippingExchangeFlow(order.FlowType) {
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持取消")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货被拒绝", order, err)
return err
}
updates := map[string]any{
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}
if req != nil {
updates["remark"] = req.Remark
}
oldAsset, resolveErr := s.resolveAssetByID(ctx, order.OldAssetType, order.OldAssetID)
if resolveErr != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货失败", order, resolveErr)
return resolveErr
}
fromStatus := order.Status
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
values := make(map[string]any, len(updates)+1)
for key, value := range updates {
values[key] = value
}
values["status"] = constants.ExchangeStatusCancelled
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).Where("id = ? AND status = ?", id, fromStatus).Updates(values)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "取消换货失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
order.Status = constants.ExchangeStatusCancelled
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCancelled, "已取消卡换货单", constants.AuditResultSuccess,
order, oldAsset, nil,
map[string]any{"status": fromStatus}, map[string]any{"status": constants.ExchangeStatusCancelled},
nil, nil, nil, nil, nil, nil)
})
if err != nil {
order.Status = fromStatus
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货失败", order, err)
}
return err
}
func (s *Service) Renew(ctx context.Context, id uint) error {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeOrderNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusCompleted {
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeRenewed, "换出旧卡转新被拒绝", order, err)
return err
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if order.OldAssetType == constants.ExchangeAssetTypeIotCard {
var card model.IotCard
if queryErr := tx.WithContext(ctx).Where("id = ?", order.OldAssetID).First(&card).Error; queryErr != nil {
if queryErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧卡失败")
}
if card.AssetStatus != constants.AssetStatusExchanged {
return errors.New(errors.CodeExchangeAssetNotExchanged)
}
var newCard *model.IotCard
if order.NewAssetID != nil && *order.NewAssetID > 0 {
var value model.IotCard
if queryErr := tx.WithContext(ctx).Where("id = ?", *order.NewAssetID).First(&value).Error; queryErr != nil {
if queryErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询换货新卡失败")
}
newCard = &value
}
auditBefore, auditErr := s.captureCardExchangeAuditBefore(ctx, tx, &card, newCard)
if auditErr != nil {
return auditErr
}
cardBefore := map[string]any{"generation": card.Generation, "asset_status": card.AssetStatus}
if updateErr := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
"generation": card.Generation + 1,
"asset_status": constants.AssetStatusInStock,
"accumulated_recharge_by_series": "{}",
"first_recharge_triggered_by_series": "{}",
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}).Error; updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "重置旧卡转新状态失败")
}
cardKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &card, VirtualNo: card.VirtualNo})
if cardKey != "" {
if unbindErr := s.customerBinding.UnbindByVirtualNo(ctx, tx, constants.ExchangeAssetTypeIotCard, card.ID, cardKey); unbindErr != nil {
return unbindErr
}
}
if deleteErr := tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; deleteErr != nil {
return errors.Wrap(errors.CodeDatabaseError, deleteErr, "清理旧钱包失败")
}
shopTag := uint(0)
if card.ShopID != nil {
shopTag = *card.ShopID
}
if createErr := tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建新钱包失败")
}
var renewedCard model.IotCard
if queryErr := tx.WithContext(ctx).Where("id = ?", card.ID).First(&renewedCard).Error; queryErr != nil {
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧卡转新结果失败")
}
walletResource, resourceErr := loadCardExchangeRenewWalletResource(ctx, tx, card.ID, auditBefore.Wallets)
if resourceErr != nil {
return resourceErr
}
extra := cardExchangeOldBindingResources(auditBefore)
extra = append(extra, *walletResource)
return s.appendCardExchangeAudit(ctx, tx, constants.AuditActionCardExchangeRenewed, "换出旧卡已转为新卡状态", constants.AuditResultSuccess,
order, &renewedCard, newCard,
nil, nil,
cardBefore, map[string]any{"generation": renewedCard.Generation, "asset_status": renewedCard.AssetStatus},
nil, nil, extra, nil)
}
var device model.Device
if err = tx.Where("id = ?", order.OldAssetID).First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询旧设备失败")
}
if device.AssetStatus != constants.AssetStatusExchanged {
return errors.New(errors.CodeExchangeAssetNotExchanged)
}
var newDevice *model.Device
if order.NewAssetID != nil && *order.NewAssetID > 0 {
var value model.Device
if queryErr := tx.WithContext(ctx).Where("id = ?", *order.NewAssetID).First(&value).Error; queryErr != nil {
if queryErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询换货新设备失败")
}
newDevice = &value
}
deviceAuditBefore, auditErr := s.captureDeviceExchangeAuditBefore(ctx, tx, &device, newDevice)
if auditErr != nil {
return auditErr
}
deviceBefore := map[string]any{"generation": device.Generation, "asset_status": device.AssetStatus}
if err = tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{
"generation": device.Generation + 1,
"asset_status": constants.AssetStatusInStock,
"accumulated_recharge_by_series": "{}",
"first_recharge_triggered_by_series": "{}",
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "重置旧设备转新状态失败")
}
deviceKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &device, VirtualNo: device.VirtualNo})
if deviceKey != "" {
if err = s.customerBinding.UnbindByVirtualNo(ctx, tx, constants.ExchangeAssetTypeDevice, device.ID, deviceKey); err != nil {
return err
}
}
if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeDevice, device.ID).Delete(&model.AssetWallet{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败")
}
shopTag := uint(0)
if device.ShopID != nil {
shopTag = *device.ShopID
}
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeDevice, ResourceID: device.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
}
var renewedDevice model.Device
if queryErr := tx.WithContext(ctx).Where("id = ?", device.ID).First(&renewedDevice).Error; queryErr != nil {
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧设备转新结果失败")
}
walletResource, resourceErr := loadDeviceExchangeRenewWalletResource(ctx, tx, device.ID, deviceAuditBefore.Wallets)
if resourceErr != nil {
return resourceErr
}
extra := deviceExchangeOldCustomerBindingResources(deviceAuditBefore)
simResources, resourceErr := loadDeviceExchangeSIMResources(ctx, tx, &renewedDevice, newDevice)
if resourceErr != nil {
return resourceErr
}
extra = append(extra, simResources...)
extra = append(extra, *walletResource)
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeRenewed, "换出旧卡已转为新卡状态", constants.AuditResultSuccess,
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &renewedDevice}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: newDevice},
nil, nil,
deviceBefore, map[string]any{"generation": renewedDevice.Generation, "asset_status": renewedDevice.AssetStatus},
nil, nil, extra, nil)
})
if err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeRenewed, "换出旧卡转新失败", order, err)
}
return err
}
func (s *Service) GetPending(ctx context.Context, identifier string) (*dto.ClientExchangePendingResponse, error) {
asset, err := s.resolveAssetByIdentifier(ctx, "", identifier)
if err != nil {
return nil, err
}
if !s.customerOwnsAsset(ctx, asset) {
return nil, errors.New(errors.CodeAssetNotFound)
}
order, err := s.exchangeStore.FindShippingPendingByOldAsset(ctx, asset.AssetType, asset.AssetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待处理换货单失败")
}
return &dto.ClientExchangePendingResponse{
ID: order.ID,
ExchangeNo: order.ExchangeNo,
FlowType: effectiveExchangeFlowType(order.FlowType),
Status: order.Status,
StatusName: constants.GetExchangeStatusName(order.Status),
StatusText: constants.GetExchangeStatusName(order.Status),
ExchangeReason: order.ExchangeReason,
CreatedAt: order.CreatedAt,
}, nil
}
func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.ClientShippingInfoRequest) error {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeOrderNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if !isShippingExchangeFlow(order.FlowType) {
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持填写收货信息")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
return err
}
if order.Status != constants.ExchangeStatusPendingInfo {
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
return err
}
oldAsset, err := s.resolveAssetByID(ctx, order.OldAssetType, order.OldAssetID)
if err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息失败", order, err)
return err
}
if !s.customerOwnsAsset(ctx, oldAsset) {
err = errors.New(errors.CodeExchangeOrderNotFound)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
return err
}
updates := map[string]any{
"recipient_name": req.RecipientName,
"recipient_phone": req.RecipientPhone,
"recipient_address": req.RecipientAddress,
"updated_at": time.Now(),
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
values := make(map[string]any, len(updates)+1)
for key, value := range updates {
values[key] = value
}
values["status"] = constants.ExchangeStatusPendingShip
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("id = ? AND status = ?", id, constants.ExchangeStatusPendingInfo).
Updates(values)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "提交收货信息失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
order.Status = constants.ExchangeStatusPendingShip
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeShippingInfoSubmitted, "已提交卡换货收货信息", constants.AuditResultSuccess,
order, oldAsset, nil,
map[string]any{"status": constants.ExchangeStatusPendingInfo}, map[string]any{"status": constants.ExchangeStatusPendingShip},
nil, nil, nil, nil, nil, nil)
})
if err != nil {
order.Status = constants.ExchangeStatusPendingInfo
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息失败", order, err)
}
return err
}
type resolvedExchangeAsset struct {
AssetType string
AssetID uint
Identifier string
VirtualNo string
AssetStatus int
ShopID *uint
Card *model.IotCard
Device *model.Device
}
func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetType, identifier string) (*resolvedExchangeAsset, error) {
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeDevice {
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
if err == nil {
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return newResolvedDeviceAsset(device), nil
}
if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
}
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeIotCard {
card, err := s.iotCardStore.GetByIdentifier(ctx, identifier)
if err == nil {
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return newResolvedIotCardAsset(card), nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
}
return nil, errors.New(errors.CodeAssetNotFound)
}
// isExchangeableAssetStatus 判断旧资产状态是否允许发起换货(在库和已销售均可换货)
func isExchangeableAssetStatus(status int) bool {
return status == constants.AssetStatusInStock || status == constants.AssetStatusSold
}
// oldAssetStatusError 根据旧资产的实际状态返回具体的错误原因
func oldAssetStatusError(status int) error {
switch status {
case constants.AssetStatusExchanged:
return errors.New(errors.CodeExchangeStatusInvalid, "该资产已完成过换货,如需再次换货请使用换货后的新资产")
case constants.AssetStatusDeactivated:
return errors.New(errors.CodeExchangeStatusInvalid, "该资产已停用,无法发起换货")
default:
return errors.New(errors.CodeExchangeStatusInvalid, "该资产当前状态不允许换货")
}
}
func normalizeExchangeFlowType(flowType string) string {
flowType = strings.TrimSpace(flowType)
if flowType == "" {
return constants.ExchangeFlowTypeShipping
}
return flowType
}
func effectiveExchangeFlowType(flowType string) string {
return normalizeExchangeFlowType(flowType)
}
func isValidExchangeFlowType(flowType string) bool {
return flowType == constants.ExchangeFlowTypeShipping || flowType == constants.ExchangeFlowTypeDirect
}
func isShippingExchangeFlow(flowType string) bool {
return effectiveExchangeFlowType(flowType) == constants.ExchangeFlowTypeShipping
}
func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExchangeRequest, oldAsset *resolvedExchangeAsset, order *model.ExchangeOrder) (uint, error) {
var orderID uint
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedOldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, oldAsset.AssetType, oldAsset.AssetID)
if err != nil {
return err
}
if !isExchangeableAssetStatus(lockedOldAsset.AssetStatus) {
return oldAssetStatusError(lockedOldAsset.AssetStatus)
}
if err = s.ensureNoActiveExchangeWithTx(ctx, tx, lockedOldAsset.AssetType, lockedOldAsset.AssetID); err != nil {
return err
}
newAsset, err := s.resolveAssetByIdentifierWithTx(ctx, tx, "", req.NewIdentifier)
if err != nil {
return err
}
if newAsset.AssetType != lockedOldAsset.AssetType {
return errors.New(errors.CodeExchangeAssetTypeMismatch)
}
order.OldAssetType = lockedOldAsset.AssetType
order.OldAssetID = lockedOldAsset.AssetID
order.OldAssetIdentifier = lockedOldAsset.Identifier
order.NewAssetType = newAsset.AssetType
order.NewAssetID = &newAsset.AssetID
order.NewAssetIdentifier = newAsset.Identifier
order.ShopID = cloneShopID(lockedOldAsset.ShopID)
if err = tx.WithContext(ctx).Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建直接换货单失败")
}
if err = s.completeExchangeWithTx(ctx, tx, order, constants.ExchangeStatusPendingInfo); err != nil {
return err
}
orderID = order.ID
return nil
})
if err != nil {
return 0, err
}
return orderID, nil
}
func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, req *dto.ExchangeShipRequest) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedOrder, err := s.lockExchangeOrderByID(ctx, tx, order.ID)
if err != nil {
return err
}
if !isShippingExchangeFlow(lockedOrder.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货")
}
if lockedOrder.Status != constants.ExchangeStatusPendingShip {
return errors.New(errors.CodeExchangeStatusInvalid)
}
oldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, lockedOrder.OldAssetType, lockedOrder.OldAssetID)
if err != nil {
return err
}
newAsset, err := s.resolveAssetByIdentifierWithTx(ctx, tx, "", req.NewIdentifier)
if err != nil {
return err
}
if newAsset.AssetType != lockedOrder.OldAssetType {
return errors.New(errors.CodeExchangeAssetTypeMismatch)
}
if err = s.validateExchangeAssetsWithTx(ctx, tx, lockedOrder.ID, oldAsset, newAsset); err != nil {
return err
}
now := time.Now()
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("id = ? AND status = ?", lockedOrder.ID, constants.ExchangeStatusPendingShip).
Updates(map[string]any{
"new_asset_type": newAsset.AssetType,
"new_asset_id": newAsset.AssetID,
"new_asset_identifier": newAsset.Identifier,
"express_company": req.ExpressCompany,
"express_no": req.ExpressNo,
"migrate_data": req.MigrateData,
"shipped_at": now,
"status": constants.ExchangeStatusShipped,
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新换货单发货状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
lockedOrder.NewAssetType = newAsset.AssetType
lockedOrder.NewAssetID = &newAsset.AssetID
lockedOrder.NewAssetIdentifier = newAsset.Identifier
lockedOrder.MigrateData = req.MigrateData
lockedOrder.ShippedAt = &now
lockedOrder.Status = constants.ExchangeStatusShipped
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeShipped, "卡换货单已发货", constants.AuditResultSuccess,
lockedOrder, oldAsset, newAsset,
map[string]any{"status": constants.ExchangeStatusPendingShip}, map[string]any{"status": constants.ExchangeStatusShipped},
nil, nil, nil, nil, nil, nil)
})
}
func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, fromStatus int) error {
if order.NewAssetID == nil || *order.NewAssetID == 0 || order.NewAssetIdentifier == "" {
return errors.New(errors.CodeInvalidParam, "新资产信息缺失")
}
oldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, order.OldAssetType, order.OldAssetID)
if err != nil {
return err
}
newAsset, err := s.resolveAssetByIDWithTx(ctx, tx, order.NewAssetType, *order.NewAssetID)
if err != nil {
return err
}
if err = s.validateExchangeAssetsWithTx(ctx, tx, order.ID, oldAsset, newAsset); err != nil {
return err
}
var auditBefore *cardExchangeAuditBefore
var deviceAuditBefore *deviceExchangeAuditBefore
if order.OldAssetType == constants.ExchangeAssetTypeIotCard {
auditBefore, err = s.captureCardExchangeAuditBefore(ctx, tx, oldAsset.Card, newAsset.Card)
if err != nil {
return err
}
} else {
deviceAuditBefore, err = s.captureDeviceExchangeAuditBefore(ctx, tx, oldAsset.Device, newAsset.Device)
if err != nil {
return err
}
}
if err = s.syncNewAssetOwnershipWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return err
}
if err = s.switchCustomerBindingWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return err
}
if err = s.updateAssetStatusesForCompletion(ctx, tx, oldAsset, newAsset); err != nil {
return err
}
var migration *exchangeMigrationResult
if order.MigrateData {
migration, err = s.executeMigrationWithTx(ctx, tx, order, oldAsset, newAsset)
if err != nil {
return err
}
}
now := time.Now()
updates := map[string]any{
"status": constants.ExchangeStatusCompleted,
"completed_at": now,
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": now,
}
if order.MigrateData {
updates["migration_completed"] = true
updates["migration_balance"] = migration.Balance
}
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("id = ? AND status = ?", order.ID, fromStatus).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "确认换货完成失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
orderBefore := map[string]any{"status": fromStatus, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance}
order.Status = constants.ExchangeStatusCompleted
order.CompletedAt = &now
if migration != nil {
order.MigrationCompleted = true
order.MigrationBalance = migration.Balance
}
if order.OldAssetType == constants.ExchangeAssetTypeDevice {
var oldDeviceAfter, newDeviceAfter model.Device
if err = tx.WithContext(ctx).Where("id = ?", oldAsset.AssetID).First(&oldDeviceAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货旧设备结果失败")
}
if err = tx.WithContext(ctx).Where("id = ?", newAsset.AssetID).First(&newDeviceAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货新设备结果失败")
}
extra, resourceErr := s.buildDeviceExchangeCompletionResources(ctx, tx, order, &oldDeviceAfter, &newDeviceAfter, deviceAuditBefore, migration)
if resourceErr != nil {
return resourceErr
}
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess,
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &oldDeviceAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &newDeviceAfter},
orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance},
map[string]any{"asset_status": oldAsset.Device.AssetStatus, "shop_id": oldAsset.Device.ShopID}, map[string]any{"asset_status": oldDeviceAfter.AssetStatus, "shop_id": oldDeviceAfter.ShopID},
map[string]any{"asset_status": newAsset.Device.AssetStatus, "shop_id": newAsset.Device.ShopID}, map[string]any{"asset_status": newDeviceAfter.AssetStatus, "shop_id": newDeviceAfter.ShopID},
extra, nil)
}
var oldCardAfter, newCardAfter model.IotCard
if err = tx.WithContext(ctx).Where("id = ?", oldAsset.AssetID).First(&oldCardAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货旧卡结果失败")
}
if err = tx.WithContext(ctx).Where("id = ?", newAsset.AssetID).First(&newCardAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货新卡结果失败")
}
extra, err := s.buildCardExchangeCompletionResources(ctx, tx, order, &oldCardAfter, &newCardAfter, auditBefore, migration)
if err != nil {
return err
}
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess,
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &oldCardAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &newCardAfter},
orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance},
map[string]any{"asset_status": oldAsset.Card.AssetStatus, "shop_id": oldAsset.Card.ShopID}, map[string]any{"asset_status": oldCardAfter.AssetStatus, "shop_id": oldCardAfter.ShopID},
map[string]any{"asset_status": newAsset.Card.AssetStatus, "shop_id": newAsset.Card.ShopID}, map[string]any{"asset_status": newCardAfter.AssetStatus, "shop_id": newCardAfter.ShopID},
extra, nil)
}
func (s *Service) lockExchangeOrderByID(ctx context.Context, tx *gorm.DB, id uint) (*model.ExchangeOrder, error) {
var order model.ExchangeOrder
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&order).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeExchangeOrderNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
return &order, nil
}
func (s *Service) resolveAssetByID(ctx context.Context, assetType string, assetID uint) (*resolvedExchangeAsset, error) {
if assetType == constants.ExchangeAssetTypeIotCard {
card, err := s.iotCardStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeAssetNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
return newResolvedIotCardAsset(card), nil
}
if assetType == constants.ExchangeAssetTypeDevice {
device, err := s.deviceStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeAssetNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
return newResolvedDeviceAsset(device), nil
}
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
}
func (s *Service) resolveAssetByIDWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (*resolvedExchangeAsset, error) {
if assetType == constants.ExchangeAssetTypeIotCard {
var card model.IotCard
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", assetID)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeAssetNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
return newResolvedIotCardAsset(&card), nil
}
if assetType == constants.ExchangeAssetTypeDevice {
var device model.Device
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", assetID)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeAssetNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
return newResolvedDeviceAsset(&device), nil
}
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
}
func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.DB, expectedAssetType, identifier string) (*resolvedExchangeAsset, error) {
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeDevice {
var device model.Device
query := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&device).Error; err == nil {
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return newResolvedDeviceAsset(&device), nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
}
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeIotCard {
var card model.IotCard
query := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("virtual_no = ? OR iccid = ? OR msisdn = ? OR iccid_19 = ? OR iccid_20 = ?", identifier, identifier, identifier, identifier, identifier)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&card).Error; err == nil {
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return newResolvedIotCardAsset(&card), nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
}
return nil, errors.New(errors.CodeAssetNotFound)
}
// newResolvedIotCardAsset 将卡的权威 ICCID 固化为换货快照,避免请求标识污染历史记录。
func newResolvedIotCardAsset(card *model.IotCard) *resolvedExchangeAsset {
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.ICCID, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: card}
}
// newResolvedDeviceAsset 按虚拟号、IMEI、SN 的稳定优先级生成设备换货快照。
func newResolvedDeviceAsset(device *model.Device) *resolvedExchangeAsset {
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}
}
func (s *Service) ensureNoActiveExchangeWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
var count int64
query := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("old_asset_type = ? AND old_asset_id = ?", assetType, assetID).
Where("status IN ?", []int{constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped})
query = middleware.ApplyShopFilter(ctx, query)
if err := query.Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
}
if count > 0 {
return errors.New(errors.CodeExchangeInProgress)
}
return nil
}
func (s *Service) validateExchangeAssetsWithTx(ctx context.Context, tx *gorm.DB, orderID uint, oldAsset, newAsset *resolvedExchangeAsset) error {
if oldAsset.AssetType != newAsset.AssetType {
return errors.New(errors.CodeExchangeAssetTypeMismatch)
}
if !isExchangeableAssetStatus(oldAsset.AssetStatus) {
return oldAssetStatusError(oldAsset.AssetStatus)
}
if err := s.ensureNewAssetBindingAvailableWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return err
}
occupied, err := s.hasShippingNewAssetOccupiedWithTx(ctx, tx, newAsset.AssetType, newAsset.AssetID, orderID)
if err != nil {
return err
}
if occupied {
return errors.New(errors.CodeExchangeStatusInvalid, "新资产已被其他换货单占用")
}
return nil
}
func (s *Service) ensureNewAssetBindingAvailableWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
newKey := exchangeAssetBindingKey(newAsset)
// 新资产无虚拟号时不再拦截Migrate() 已能正确处理无绑定跳过、有绑定迁移到 pci 的情形
if newKey == "" {
return nil
}
var newBindCount int64
if err := tx.WithContext(ctx).Model(&model.PersonalCustomerDevice{}).
Where("virtual_no = ? AND status = ?", newKey, constants.StatusEnabled).
Count(&newBindCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询新资产客户绑定失败")
}
if newBindCount > 0 {
return errors.New(errors.CodeExchangeStatusInvalid, "新资产存在有效客户绑定,不可用于换货")
}
return nil
}
func (s *Service) switchCustomerBindingWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
return s.customerBinding.Migrate(ctx, tx, oldAsset.AssetType, oldAsset.AssetID, newAsset.AssetType, newAsset.AssetID)
}
// syncNewAssetOwnershipWithTx 将新资产归属同步为旧资产当前归属。
// 归属继承不受 migrate_data 控制,避免平台库存资产换入店铺后仍处于平台租户范围。
func (s *Service) syncNewAssetOwnershipWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
ownershipStatus := constants.IotCardStatusInStock
if oldAsset.ShopID != nil {
ownershipStatus = constants.IotCardStatusDistributed
}
modelValue := any(&model.IotCard{})
if newAsset.AssetType == constants.ExchangeAssetTypeDevice {
modelValue = &model.Device{}
ownershipStatus = constants.DeviceStatusInStock
if oldAsset.ShopID != nil {
ownershipStatus = constants.DeviceStatusDistributed
}
}
result := tx.WithContext(ctx).Model(modelValue).
Where("id = ?", newAsset.AssetID).
Updates(map[string]any{
"shop_id": oldAsset.ShopID,
"status": ownershipStatus,
"updated_at": time.Now(),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "同步新资产店铺归属失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeAssetNotFound)
}
shopIDTag := uint(0)
if oldAsset.ShopID != nil {
shopIDTag = *oldAsset.ShopID
}
if err := tx.WithContext(ctx).Model(&model.AssetWallet{}).
Where("resource_type = ? AND resource_id = ?", newAsset.AssetType, newAsset.AssetID).
Updates(map[string]any{"shop_id_tag": shopIDTag, "updated_at": time.Now()}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "同步新资产钱包店铺标签失败")
}
newAsset.ShopID = cloneShopID(oldAsset.ShopID)
return nil
}
func cloneShopID(shopID *uint) *uint {
if shopID == nil {
return nil
}
value := *shopID
return &value
}
func (s *Service) updateAssetStatusesForCompletion(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
now := time.Now()
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
result := tx.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ? AND asset_status IN ?", oldAsset.AssetID, []int{constants.AssetStatusInStock, constants.AssetStatusSold}).
Updates(map[string]any{"asset_status": constants.AssetStatusExchanged, "updated_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新旧卡状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid, "旧资产状态已被修改,请重试")
}
result = tx.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ?", newAsset.AssetID).
Updates(map[string]any{"asset_status": constants.AssetStatusSold, "updated_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新新卡状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeAssetNotFound)
}
return nil
}
result := tx.WithContext(ctx).Model(&model.Device{}).
Where("id = ? AND asset_status IN ?", oldAsset.AssetID, []int{constants.AssetStatusInStock, constants.AssetStatusSold}).
Updates(map[string]any{"asset_status": constants.AssetStatusExchanged, "updated_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新旧设备状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid, "旧资产状态已被修改,请重试")
}
result = tx.WithContext(ctx).Model(&model.Device{}).
Where("id = ?", newAsset.AssetID).
Updates(map[string]any{"asset_status": constants.AssetStatusSold, "updated_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新新设备状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeAssetNotFound)
}
return nil
}
func (s *Service) hasShippingNewAssetOccupiedWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, excludeOrderID uint) (bool, error) {
var count int64
query := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("new_asset_type = ? AND new_asset_id = ?", assetType, assetID).
Where("status = ?", constants.ExchangeStatusShipped).
Where("COALESCE(NULLIF(flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, constants.ExchangeFlowTypeShipping)
if excludeOrderID > 0 {
query = query.Where("id <> ?", excludeOrderID)
}
query = middleware.ApplyShopFilter(ctx, query)
if err := query.Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询新资产占用失败")
}
return count > 0, nil
}
func (s *Service) customerOwnsAsset(ctx context.Context, asset *resolvedExchangeAsset) bool {
customerID := middleware.GetCustomerIDFromContext(ctx)
if customerID == 0 || asset == nil {
return false
}
key := exchangeAssetBindingKey(asset)
if key == "" {
return false
}
var count int64
if err := s.db.WithContext(ctx).Model(&model.PersonalCustomerDevice{}).
Where("customer_id = ? AND virtual_no = ? AND status = ?", customerID, key, constants.StatusEnabled).
Count(&count).Error; err != nil {
return false
}
return count > 0
}
func exchangeAssetBindingKey(asset *resolvedExchangeAsset) string {
if asset == nil {
return ""
}
if asset.AssetType == constants.ExchangeAssetTypeIotCard {
if asset.Card != nil {
return asset.Card.VirtualNo
}
return asset.VirtualNo
}
if asset.Device == nil {
return asset.VirtualNo
}
if asset.Device.VirtualNo != "" {
return asset.Device.VirtualNo
}
return asset.Device.IMEI
}
func preferredDeviceIdentifier(device *model.Device) string {
if device == nil {
return ""
}
if device.VirtualNo != "" {
return device.VirtualNo
}
if device.IMEI != "" {
return device.IMEI
}
return device.SN
}
func (s *Service) toExchangeOrderResponse(order *model.ExchangeOrder) *dto.ExchangeOrderResponse {
if order == nil {
return nil
}
var deletedAt *time.Time
if order.DeletedAt.Valid {
deletedAt = &order.DeletedAt.Time
}
return &dto.ExchangeOrderResponse{
ID: order.ID,
ExchangeNo: order.ExchangeNo,
FlowType: effectiveExchangeFlowType(order.FlowType),
FlowTypeName: constants.GetExchangeFlowTypeName(order.FlowType),
OldAssetType: order.OldAssetType,
OldAssetID: order.OldAssetID,
OldAssetIdentifier: order.OldAssetIdentifier,
NewAssetType: order.NewAssetType,
NewAssetID: order.NewAssetID,
NewAssetIdentifier: order.NewAssetIdentifier,
RecipientName: order.RecipientName,
RecipientPhone: order.RecipientPhone,
RecipientAddress: order.RecipientAddress,
ExpressCompany: order.ExpressCompany,
ExpressNo: order.ExpressNo,
MigrateData: order.MigrateData,
MigrationCompleted: order.MigrationCompleted,
MigrationBalance: order.MigrationBalance,
ShippedAt: order.ShippedAt,
CompletedAt: order.CompletedAt,
ExchangeReason: order.ExchangeReason,
Remark: order.Remark,
Status: order.Status,
StatusName: constants.GetExchangeStatusName(order.Status),
StatusText: constants.GetExchangeStatusName(order.Status),
ShopID: order.ShopID,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
DeletedAt: deletedAt,
SubmitterID: order.Creator,
Creator: order.Creator,
Updater: order.Updater,
}
}
func (s *Service) loadExchangeSubmitterNameBestEffort(ctx context.Context, id uint) string {
accounts, err := postgres.NewAccountStore(s.db, nil).GetDisplayAccountsByIDs(ctx, []uint{id})
if err != nil {
s.logger.Warn("查询换货提交人失败", zap.Uint("submitter_id", id), zap.Error(err))
return ""
}
if len(accounts) == 0 {
return ""
}
return accounts[0].Username
}