chore: 并入 iotlcm-admin-svr 历史到 admin-svr/(原提交 49818c6)
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const cardChannelAssocTable = "card_channel_assoc"
|
||||
|
||||
type cardChannelAssocRecord struct {
|
||||
Id int64 `orm:"id"`
|
||||
ChannelKey string `orm:"channel_id"`
|
||||
MatchType int `orm:"match_type"`
|
||||
Iccid string `orm:"iccid"`
|
||||
IccidStart string `orm:"iccid_start"`
|
||||
IccidEnd string `orm:"iccid_end"`
|
||||
Status int `orm:"status"`
|
||||
}
|
||||
|
||||
type cardChannelDisplayRecord struct {
|
||||
ChanKey string
|
||||
ChanName string
|
||||
ChanAlias string
|
||||
TempKey string
|
||||
}
|
||||
|
||||
type cardChannelLookupRecord struct {
|
||||
Id int64 `orm:"id"`
|
||||
ChanKey string `orm:"chan_key"`
|
||||
ChanName string `orm:"chan_name"`
|
||||
ChanAlias string `orm:"chan_alias"`
|
||||
TempKey string `orm:"temp_key"`
|
||||
}
|
||||
|
||||
func applyCardChannelAssocFilter(mod *gdb.Model, cardInfoTable string, chanKey string) *gdb.Model {
|
||||
if mod == nil {
|
||||
return mod
|
||||
}
|
||||
chanKey = strings.TrimSpace(chanKey)
|
||||
if chanKey == "" {
|
||||
return mod
|
||||
}
|
||||
return mod.Where(cardChannelAssocExistsSQL(cardInfoTable), chanKey)
|
||||
}
|
||||
|
||||
func cardChannelAssocExistsSQL(cardInfoTable string) string {
|
||||
cardInfoTable = strings.TrimSpace(cardInfoTable)
|
||||
if cardInfoTable == "" {
|
||||
cardInfoTable = dao.TSimCardInfo.Table()
|
||||
}
|
||||
channelTable := dao.TSimChannels.Table()
|
||||
cardColumns := dao.TSimCardInfo.Columns()
|
||||
channelColumns := dao.TSimChannels.Columns()
|
||||
return fmt.Sprintf(
|
||||
"EXISTS (SELECT 1 FROM `%s` a LEFT JOIN `%s` c ON c.`%s` = a.`channel_id` WHERE a.`status` = 1 AND c.`%s` = `%s`.`%s` AND c.`%s` = ? AND ((a.`match_type` = 1 AND a.`iccid` COLLATE utf8mb4_general_ci = `%s`.`%s`) OR (a.`match_type` = 2 AND a.`iccid_start` COLLATE utf8mb4_general_ci <= `%s`.`%s` AND a.`iccid_end` COLLATE utf8mb4_general_ci >= `%s`.`%s`)))",
|
||||
cardChannelAssocTable,
|
||||
channelTable,
|
||||
channelColumns.ChanKey,
|
||||
channelColumns.TenantId,
|
||||
cardInfoTable,
|
||||
cardColumns.TenantId,
|
||||
channelColumns.ChanKey,
|
||||
cardInfoTable,
|
||||
cardColumns.Iccid,
|
||||
cardInfoTable,
|
||||
cardColumns.Iccid,
|
||||
cardInfoTable,
|
||||
cardColumns.Iccid,
|
||||
)
|
||||
}
|
||||
|
||||
func enrichCardChannelList(ctx context.Context, list []*model.TSimCardInfoListModel) error {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
channelCache := make(map[string]cardChannelDisplayRecord)
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
display, ok, err := resolveCardChannelDisplayByIccid(ctx, item.Iccid, channelCache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item.ChanKey = display.ChanKey
|
||||
item.ChanName = display.ChanName
|
||||
item.ChanAlias = display.ChanAlias
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func enrichCardChannelView(ctx context.Context, item *model.TSimCardInfoViewModel) error {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
display, ok, err := resolveCardChannelDisplayByIccid(ctx, item.Iccid, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
item.ChanKey = display.ChanKey
|
||||
item.ChanName = display.ChanName
|
||||
item.ChanAlias = display.ChanAlias
|
||||
return nil
|
||||
}
|
||||
|
||||
func enrichSimKeyChannelFromAssoc(ctx context.Context, simKey *model.SimKey) error {
|
||||
if simKey == nil {
|
||||
return nil
|
||||
}
|
||||
display, ok, err := resolveCardChannelDisplayByIccid(ctx, simKey.ICCID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok && strings.TrimSpace(display.ChanKey) != "" {
|
||||
simKey.ChanKey = display.ChanKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveCardChannelDisplayByIccid(ctx context.Context, iccid string, channelCache map[string]cardChannelDisplayRecord) (cardChannelDisplayRecord, bool, error) {
|
||||
assoc, err := resolveCardChannelAssocByIccid(ctx, iccid)
|
||||
if err != nil || assoc == nil {
|
||||
return cardChannelDisplayRecord{}, false, err
|
||||
}
|
||||
return resolveCardChannelDisplayByAssoc(ctx, assoc, channelCache)
|
||||
}
|
||||
|
||||
func resolveCardChannelDisplayByAssoc(ctx context.Context, assoc *cardChannelAssocRecord, channelCache map[string]cardChannelDisplayRecord) (cardChannelDisplayRecord, bool, error) {
|
||||
if assoc == nil {
|
||||
return cardChannelDisplayRecord{}, false, nil
|
||||
}
|
||||
return resolveCardChannelDisplayByChannelKey(ctx, assoc.ChannelKey, channelCache)
|
||||
}
|
||||
|
||||
func resolveCardChannelAssocByIccid(ctx context.Context, iccid string) (*cardChannelAssocRecord, error) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
if iccid == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var exact *cardChannelAssocRecord
|
||||
if err := g.DB().Ctx(ctx).Model(cardChannelAssocTable).
|
||||
Fields("id", "channel_id", "match_type", "iccid", "iccid_start", "iccid_end", "status").
|
||||
Where("status", 1).
|
||||
Where("match_type", 1).
|
||||
Where("iccid COLLATE utf8mb4_general_ci = ?", iccid).
|
||||
OrderDesc("id").
|
||||
Limit(1).
|
||||
Scan(&exact); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡通道单卡关联失败")
|
||||
}
|
||||
if exact != nil && strings.TrimSpace(exact.ChannelKey) != "" {
|
||||
return exact, nil
|
||||
}
|
||||
|
||||
var ranged *cardChannelAssocRecord
|
||||
if err := g.DB().Ctx(ctx).Model(cardChannelAssocTable).
|
||||
Fields("id", "channel_id", "match_type", "iccid", "iccid_start", "iccid_end", "status").
|
||||
Where("status", 1).
|
||||
Where("match_type", 2).
|
||||
Where("iccid_start COLLATE utf8mb4_general_ci <= ? AND iccid_end COLLATE utf8mb4_general_ci >= ?", iccid, iccid).
|
||||
OrderDesc("id").
|
||||
Limit(1).
|
||||
Scan(&ranged); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡通道号段关联失败")
|
||||
}
|
||||
if ranged != nil && strings.TrimSpace(ranged.ChannelKey) != "" {
|
||||
return ranged, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func resolveCardChannelDisplayByChannelKey(ctx context.Context, chanKey string, channelCache map[string]cardChannelDisplayRecord) (cardChannelDisplayRecord, bool, error) {
|
||||
chanKey = strings.TrimSpace(chanKey)
|
||||
if chanKey == "" {
|
||||
return cardChannelDisplayRecord{}, false, nil
|
||||
}
|
||||
if channelCache != nil {
|
||||
if cached, ok := channelCache[chanKey]; ok {
|
||||
return cached, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
channel, err := loadCardChannelByKey(ctx, chanKey)
|
||||
if err != nil {
|
||||
return cardChannelDisplayRecord{}, false, err
|
||||
}
|
||||
if channel == nil {
|
||||
return cardChannelDisplayRecord{}, false, nil
|
||||
}
|
||||
|
||||
display := cardChannelDisplayRecord{
|
||||
ChanKey: strings.TrimSpace(channel.ChanKey),
|
||||
ChanName: strings.TrimSpace(channel.ChanName),
|
||||
ChanAlias: strings.TrimSpace(channel.ChanAlias),
|
||||
TempKey: strings.TrimSpace(channel.TempKey),
|
||||
}
|
||||
if channelCache != nil {
|
||||
channelCache[chanKey] = display
|
||||
}
|
||||
return display, true, nil
|
||||
}
|
||||
|
||||
func loadCardChannelByKey(ctx context.Context, chanKey string) (*cardChannelLookupRecord, error) {
|
||||
chanKey = strings.TrimSpace(chanKey)
|
||||
if chanKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var channel *cardChannelLookupRecord
|
||||
columns := dao.TSimChannels.Columns()
|
||||
if err := cardChannelLookupModel(ctx).
|
||||
Where(columns.ChanKey, chanKey).
|
||||
Limit(1).
|
||||
Scan(&channel); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询通道信息失败")
|
||||
}
|
||||
if channel != nil && strings.TrimSpace(channel.ChanKey) != "" {
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
channel = nil
|
||||
if err := dao.TSimChannels.Ctx(ctx).
|
||||
Fields(columns.Id, columns.ChanKey, columns.ChanName, columns.ChanAlias, columns.TempKey).
|
||||
Where(columns.ChanKey, chanKey).
|
||||
Limit(1).
|
||||
Scan(&channel); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询通道信息失败")
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func cardChannelLookupModel(ctx context.Context) *gdb.Model {
|
||||
columns := dao.TSimChannels.Columns()
|
||||
return dao.TSimChannels.Ctx(ctx).
|
||||
Fields(columns.Id, columns.ChanKey, columns.ChanName, columns.ChanAlias, columns.TempKey).
|
||||
Where(columns.TenantId, contexts.GetTenantId(ctx))
|
||||
}
|
||||
|
||||
func logCardChannelDisplayResolverError(ctx context.Context, err error) {
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "load card channel display failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type cardDeptDisplayNode struct {
|
||||
DeptID int64 `orm:"dept_id"`
|
||||
ParentID int64 `orm:"parent_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
}
|
||||
|
||||
func newCardDeptDisplayResolver(ctx context.Context) (func(int64, string) string, error) {
|
||||
nodes, err := loadCardDeptDisplayNodes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
viewerDeptID := contexts.GetDeptId(ctx)
|
||||
return func(cardDeptID int64, fallback string) string {
|
||||
return resolveCardDisplayDeptName(nodes, viewerDeptID, cardDeptID, fallback)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadCardDeptDisplayNodes(ctx context.Context) (map[int64]cardDeptDisplayNode, error) {
|
||||
nodes := make(map[int64]cardDeptDisplayNode)
|
||||
tenantID := contexts.GetTenantId(ctx)
|
||||
if tenantID == "" {
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
var departments []*cardDeptDisplayNode
|
||||
mod := service.MemoryDB().DB(ctx).Ctx(ctx).Model(dao.SysDept.Table()).
|
||||
Fields(
|
||||
dao.SysDept.Columns().DeptId,
|
||||
dao.SysDept.Columns().ParentId,
|
||||
dao.SysDept.Columns().DeptName,
|
||||
).
|
||||
Where(dao.SysDept.Columns().TenantId, tenantID).
|
||||
WhereNull(dao.SysDept.Columns().DeletedAt)
|
||||
if err := mod.Scan(&departments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, department := range departments {
|
||||
if department == nil || department.DeptID <= 0 {
|
||||
continue
|
||||
}
|
||||
nodes[department.DeptID] = *department
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func resolveCardDisplayDeptName(
|
||||
nodes map[int64]cardDeptDisplayNode,
|
||||
viewerDeptID int64,
|
||||
cardDeptID int64,
|
||||
fallback string,
|
||||
) string {
|
||||
cardDept, ok := nodes[cardDeptID]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
if viewerDeptID <= 0 || viewerDeptID == cardDeptID {
|
||||
return cardDept.DeptName
|
||||
}
|
||||
|
||||
path := make([]int64, 0, 4)
|
||||
visited := make(map[int64]struct{})
|
||||
currentID := cardDeptID
|
||||
for currentID > 0 {
|
||||
if _, exists := visited[currentID]; exists {
|
||||
return fallback
|
||||
}
|
||||
visited[currentID] = struct{}{}
|
||||
path = append(path, currentID)
|
||||
|
||||
current, exists := nodes[currentID]
|
||||
if !exists {
|
||||
return fallback
|
||||
}
|
||||
if currentID == viewerDeptID {
|
||||
if len(path) == 1 {
|
||||
return current.DeptName
|
||||
}
|
||||
return nodes[path[len(path)-2]].DeptName
|
||||
}
|
||||
currentID = current.ParentID
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func logCardDeptDisplayResolverError(ctx context.Context, err error) {
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "load card department display nodes failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func (s *Service) UpdateCardsAfterRenew(ctx context.Context, tx gdb.TX, renew *RenewCurrentPackageOutput, userId int64) error {
|
||||
if renew == nil || len(renew.NewBindsByIccid) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := gtime.Now()
|
||||
for _, iccid := range renew.Iccids {
|
||||
card := renew.CardsByIccid[iccid]
|
||||
if card == nil {
|
||||
return gerror.Newf("ICCID卡信息不存在:%s", iccid)
|
||||
}
|
||||
bind := renew.NewBindsByIccid[iccid]
|
||||
if bind == nil {
|
||||
return gerror.Newf("ICCID续费订购记录不存在:%s", iccid)
|
||||
}
|
||||
packageStatus, _ := calcPackageStatuses(now, bind.EffectiveTime, bind.ExpectedExpireTime)
|
||||
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
|
||||
Where(dao.TSimCardInfo.Columns().Iccid, iccid).
|
||||
Data(do.TSimCardInfo{
|
||||
FlowPkgEndAt: bind.ExpectedExpireTime,
|
||||
PackageStatus: packageStatus,
|
||||
UpdatedBy: userId,
|
||||
UpdatedAt: now,
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新续费后卡套餐到期时间失败")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取续费后卡信息更新数量失败")
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.Newf("ICCID卡信息已变化,请刷新后重试:%s", iccid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/packageduration"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func (s *Service) RenewCurrentPackage(ctx context.Context, tx gdb.TX, in *RenewCurrentPackageInput) (*RenewCurrentPackageOutput, error) {
|
||||
if err := validateRenewInput(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iccids := uniqueStrings(in.Iccids)
|
||||
cardsByIccid, err := s.repo.LockCardsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "锁定卡信息失败")
|
||||
}
|
||||
if len(cardsByIccid) != len(iccids) {
|
||||
return nil, gerror.New("部分卡信息已变化,请刷新后重试")
|
||||
}
|
||||
|
||||
latestBindsByIccid, err := s.repo.LockLatestBindsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "锁定最新订购失败")
|
||||
}
|
||||
if len(latestBindsByIccid) != len(iccids) {
|
||||
return nil, gerror.New("部分卡没有订购记录,不能续费")
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
sourceBatchNo := strings.TrimSpace(in.SourceBatchNo)
|
||||
currentPackageCode := ""
|
||||
customerAgentId := int64(0)
|
||||
var renewBeginAt *gtime.Time
|
||||
for _, iccid := range iccids {
|
||||
card := cardsByIccid[iccid]
|
||||
bind := latestBindsByIccid[iccid]
|
||||
if err := validateRenewCardStatus(iccid, card); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bind == nil {
|
||||
return nil, gerror.Newf("ICCID没有订购记录:%s", iccid)
|
||||
}
|
||||
if strings.TrimSpace(card.CurrentPackageCode) == "" || card.PackageStatus == consts.PackageStatusNone {
|
||||
return nil, gerror.Newf("ICCID没有可续费套餐:%s", iccid)
|
||||
}
|
||||
if strings.TrimSpace(card.CurrentPackageCode) != strings.TrimSpace(bind.PackageCode) {
|
||||
return nil, gerror.Newf("ICCID当前套餐与最新订购记录不一致:%s", iccid)
|
||||
}
|
||||
baseExpireAt := renewBaseExpireTime(card, bind)
|
||||
if baseExpireAt == nil {
|
||||
return nil, gerror.Newf("ICCID最新订购没有预计到期时间:%s", iccid)
|
||||
}
|
||||
periodBegin := renewPeriodBeginAt(baseExpireAt, in.PackageBeginTime)
|
||||
if renewExpireAfterAllowedWindow(baseExpireAt, now) {
|
||||
return nil, gerror.Newf("距离到期超过6个月,不能续费:%s", iccid)
|
||||
}
|
||||
if renewBeginAt == nil {
|
||||
renewBeginAt = periodBegin
|
||||
} else if !sameRenewTime(renewBeginAt, periodBegin) {
|
||||
return nil, gerror.New("当前批次下卡不是统一续费批次")
|
||||
}
|
||||
if sourceBatchNo != "" && strings.TrimSpace(card.OutboundBatchNo) != sourceBatchNo {
|
||||
return nil, gerror.Newf("ICCID不属于来源出库批次:%s", iccid)
|
||||
}
|
||||
if currentPackageCode == "" {
|
||||
currentPackageCode = strings.TrimSpace(card.CurrentPackageCode)
|
||||
}
|
||||
if currentPackageCode != strings.TrimSpace(card.CurrentPackageCode) {
|
||||
return nil, gerror.New("当前批次下存在多个套餐,不能统一续费")
|
||||
}
|
||||
if customerAgentId == 0 {
|
||||
customerAgentId = card.DeptId
|
||||
}
|
||||
if customerAgentId != card.DeptId {
|
||||
return nil, gerror.New("当前批次下存在多个客户,不能统一续费")
|
||||
}
|
||||
}
|
||||
|
||||
customer, err := s.loadTargetDept(ctx, tx, customerAgentId, in.Operator, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetPackageCode := strings.TrimSpace(in.PackageCode)
|
||||
if targetPackageCode == "" {
|
||||
targetPackageCode = currentPackageCode
|
||||
}
|
||||
currentPkg, err := s.loadPackage(ctx, tx, currentPackageCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !renewPackageAllowed(currentPkg, targetPackageCode) {
|
||||
return nil, gerror.New("续费套餐不在当前套餐可续费范围内")
|
||||
}
|
||||
pkg := currentPkg
|
||||
if targetPackageCode != currentPackageCode {
|
||||
pkg, err = s.loadPackage(ctx, tx, targetPackageCode)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
durationUnit, durationValue, durationMonths, giftDurationValue, giftDurationMonths := normalizeRenewPackageDuration(in)
|
||||
if durationMonths <= 0 {
|
||||
return nil, gerror.New("续费时长必须大于0")
|
||||
}
|
||||
amount, err := parseAmount(in.Amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quantity := len(iccids)
|
||||
unitPrice := 0.0
|
||||
if quantity > 0 {
|
||||
unitPrice = roundAmount(amount / float64(quantity))
|
||||
}
|
||||
|
||||
firstBaseExpireAt := renewBaseExpireTime(cardsByIccid[iccids[0]], latestBindsByIccid[iccids[0]])
|
||||
minBeginAt := renewPeriodBeginAt(firstBaseExpireAt, in.PackageBeginTime)
|
||||
maxEndAt := firstBaseExpireAt
|
||||
for _, iccid := range iccids {
|
||||
baseExpireAt := renewBaseExpireTime(cardsByIccid[iccid], latestBindsByIccid[iccid])
|
||||
periodBegin := renewPeriodBeginAt(baseExpireAt, in.PackageBeginTime)
|
||||
if minBeginAt == nil || (periodBegin != nil && periodBegin.Before(minBeginAt)) {
|
||||
minBeginAt = periodBegin
|
||||
}
|
||||
endAt := addMonths(periodBegin, durationMonths+giftDurationMonths)
|
||||
if maxEndAt == nil || (endAt != nil && endAt.After(maxEndAt)) {
|
||||
maxEndAt = endAt
|
||||
}
|
||||
}
|
||||
if in.PackageEndTime != nil && !sameRenewTime(maxEndAt, in.PackageEndTime) {
|
||||
return nil, gerror.New("续费结束日期与续费时长不一致")
|
||||
}
|
||||
|
||||
orderSn, err := batchimport.NextRandomNo(orderNoPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderInput := &CreatePackageSubscriptionInput{
|
||||
BatchNo: in.BatchNo,
|
||||
CustomerAgentId: customerAgentId,
|
||||
PackageCode: targetPackageCode,
|
||||
PackageBeginTime: minBeginAt,
|
||||
DurationMonths: durationMonths,
|
||||
GiftDurationMonths: giftDurationMonths,
|
||||
DurationDisplayValue: durationValue,
|
||||
GiftDurationDisplayValue: giftDurationValue,
|
||||
DurationUnit: durationUnit,
|
||||
Amount: in.Amount,
|
||||
PayType: in.PayType,
|
||||
PayStatus: in.PayStatus,
|
||||
BalanceAmount: in.BalanceAmount,
|
||||
OfflinePaidAmount: in.OfflinePaidAmount,
|
||||
PayTime: in.PayTime,
|
||||
OfflineContractCompleted: in.OfflineContractCompleted,
|
||||
BuyerNote: in.BuyerNote,
|
||||
AdminNote: in.AdminNote,
|
||||
BusinessType: consts.PackageBusinessTypeRenew,
|
||||
OrderRemark: chooseString(in.OrderRemark, "卡续费业务订单载体"),
|
||||
BindRemark: in.BindRemark,
|
||||
Operator: in.Operator,
|
||||
}
|
||||
orderId, err := s.insertOrder(ctx, tx, orderInput, orderSn, amount, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = s.insertOrderItem(ctx, tx, orderInput, orderId, orderSn, pkg, amount, unitPrice, quantity, minBeginAt, maxEndAt, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bindRows := make([]do.TSimCardBindPkg, 0, quantity)
|
||||
newBindsByIccid := make(map[string]*entity.TSimCardBindPkg, quantity)
|
||||
for _, iccid := range iccids {
|
||||
card := cardsByIccid[iccid]
|
||||
latestBind := latestBindsByIccid[iccid]
|
||||
periodBegin := renewPeriodBeginAt(renewBaseExpireTime(card, latestBind), in.PackageBeginTime)
|
||||
periodEnd := addMonths(periodBegin, durationMonths+giftDurationMonths)
|
||||
_, effectiveStatus := calcPackageStatuses(now, periodBegin, periodEnd)
|
||||
subscriptionNo, err := batchimport.NextRandomNo(subscriptionNoPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := do.TSimCardBindPkg{
|
||||
SubscriptionNo: subscriptionNo,
|
||||
BatchNo: in.BatchNo,
|
||||
Iccid: iccid,
|
||||
Msisdn: strings.TrimSpace(card.Msisdn),
|
||||
DeptId: fmt.Sprintf("%d", customerAgentId),
|
||||
PackageType: pkg.PackageType,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: currentPackageCode,
|
||||
BusinessType: consts.PackageBusinessTypeRenew,
|
||||
EffectiveStatus: effectiveStatus,
|
||||
HandleTime: now,
|
||||
EffectiveTime: periodBegin,
|
||||
ExpectedExpireTime: periodEnd,
|
||||
DurationMonths: durationMonths,
|
||||
GiftDurationMonths: giftDurationMonths,
|
||||
OrderCode: orderSn,
|
||||
Remark: strings.TrimSpace(in.BindRemark),
|
||||
TenantId: in.Operator.TenantId,
|
||||
CreatedDept: in.Operator.DeptId,
|
||||
CreatedBy: in.Operator.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
bindRows = append(bindRows, row)
|
||||
newBindsByIccid[iccid] = &entity.TSimCardBindPkg{
|
||||
SubscriptionNo: subscriptionNo,
|
||||
BatchNo: in.BatchNo,
|
||||
Iccid: iccid,
|
||||
Msisdn: strings.TrimSpace(card.Msisdn),
|
||||
DeptId: fmt.Sprintf("%d", customerAgentId),
|
||||
PackageType: pkg.PackageType,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: currentPackageCode,
|
||||
BusinessType: consts.PackageBusinessTypeRenew,
|
||||
EffectiveStatus: effectiveStatus,
|
||||
HandleTime: now,
|
||||
EffectiveTime: periodBegin,
|
||||
ExpectedExpireTime: periodEnd,
|
||||
DurationMonths: durationMonths,
|
||||
GiftDurationMonths: giftDurationMonths,
|
||||
OrderCode: orderSn,
|
||||
Remark: strings.TrimSpace(in.BindRemark),
|
||||
TenantId: in.Operator.TenantId,
|
||||
CreatedDept: in.Operator.DeptId,
|
||||
CreatedBy: in.Operator.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
if err = s.InsertCardPackageBinds(ctx, tx, bindRows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &RenewCurrentPackageOutput{
|
||||
Iccids: iccids,
|
||||
OrderId: orderId,
|
||||
OrderSn: orderSn,
|
||||
Customer: customer,
|
||||
Package: pkg,
|
||||
CardsByIccid: cardsByIccid,
|
||||
LatestBindsByIccid: latestBindsByIccid,
|
||||
NewBindsByIccid: newBindsByIccid,
|
||||
BindRows: bindRows,
|
||||
RenewTime: now,
|
||||
PackageCode: targetPackageCode,
|
||||
Amount: amount,
|
||||
UnitPrice: unitPrice,
|
||||
Quantity: quantity,
|
||||
}
|
||||
if err = s.UpdateCardsAfterRenew(ctx, tx, out, in.Operator.UserId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renewExpireAfterAllowedWindow(expireAt, now *gtime.Time) bool {
|
||||
if expireAt == nil {
|
||||
return false
|
||||
}
|
||||
if now == nil {
|
||||
now = gtime.Now()
|
||||
}
|
||||
return expireAt.After(now.AddDate(0, 6, 0))
|
||||
}
|
||||
|
||||
func sameRenewTime(left, right *gtime.Time) bool {
|
||||
switch {
|
||||
case left == nil && right == nil:
|
||||
return true
|
||||
case left == nil || right == nil:
|
||||
return false
|
||||
default:
|
||||
return !left.Before(right) && !left.After(right)
|
||||
}
|
||||
}
|
||||
|
||||
func renewPeriodBeginAt(latest, requested *gtime.Time) *gtime.Time {
|
||||
if requested != nil {
|
||||
return renewDayEndAt(requested)
|
||||
}
|
||||
return renewBeginDateFromExpire(latest)
|
||||
}
|
||||
|
||||
func renewBeginDateFromExpire(expireAt *gtime.Time) *gtime.Time {
|
||||
if expireAt == nil {
|
||||
return nil
|
||||
}
|
||||
return renewDayEndAt(expireAt)
|
||||
}
|
||||
|
||||
func renewBaseExpireTime(card *entity.TSimCardInfo, bind *entity.TSimCardBindPkg) *gtime.Time {
|
||||
var expireAt *gtime.Time
|
||||
if bind != nil {
|
||||
expireAt = laterRenewExpireTime(expireAt, bind.ExpectedExpireTime)
|
||||
}
|
||||
if card != nil {
|
||||
expireAt = laterRenewExpireTime(expireAt, card.FlowPkgEndAt)
|
||||
expireAt = laterRenewExpireTime(expireAt, card.FlowPkgPresentAt)
|
||||
expireAt = laterRenewExpireTime(expireAt, card.FlowPkgRealPresentAt)
|
||||
}
|
||||
return renewDayEndAt(expireAt)
|
||||
}
|
||||
|
||||
func laterRenewExpireTime(current, candidate *gtime.Time) *gtime.Time {
|
||||
if candidate == nil || candidate.IsZero() {
|
||||
return current
|
||||
}
|
||||
if current == nil || current.IsZero() || candidate.After(current) {
|
||||
return candidate
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func renewDayEndAt(t *gtime.Time) *gtime.Time {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
tt := t.Time
|
||||
return gtime.NewFromTime(time.Date(tt.Year(), tt.Month(), tt.Day(), 23, 59, 59, 0, tt.Location()))
|
||||
}
|
||||
|
||||
func renewPackageAllowed(currentPkg *entity.TSimFlowPackageInfo, targetPackageCode string) bool {
|
||||
targetPackageCode = strings.TrimSpace(targetPackageCode)
|
||||
if currentPkg == nil || targetPackageCode == "" {
|
||||
return false
|
||||
}
|
||||
var codes []string
|
||||
if currentPkg.RenewablePackageCodes != nil {
|
||||
_ = currentPkg.RenewablePackageCodes.Scan(&codes)
|
||||
}
|
||||
for _, code := range uniqueStrings(codes) {
|
||||
if strings.TrimSpace(code) == targetPackageCode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeRenewPackageDuration(in *RenewCurrentPackageInput) (durationUnit string, durationValue int, durationMonths int, giftDurationValue int, giftDurationMonths int) {
|
||||
durationUnit = consts.RenewDurationUnitYear
|
||||
if in == nil {
|
||||
return
|
||||
}
|
||||
durationValue = in.DurationDisplayValue
|
||||
durationMonths = in.DurationMonths
|
||||
if durationValue > 0 {
|
||||
// Renewal duration is always expressed in years. The database keeps
|
||||
// the resulting subscription duration in months for lifecycle logic.
|
||||
durationMonths = packageduration.RenewMonths(durationValue)
|
||||
}
|
||||
|
||||
giftDurationMonths = in.GiftDurationMonths
|
||||
giftDurationValue = in.GiftDurationDisplayValue
|
||||
if giftDurationValue <= 0 {
|
||||
giftDurationValue = giftDurationMonths
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func validateRenewCardStatus(iccid string, card *entity.TSimCardInfo) error {
|
||||
if card == nil {
|
||||
return gerror.Newf("ICCID不存在正式卡记录:%s", iccid)
|
||||
}
|
||||
if card.BossCardStatus == consts.V3ApiSimBossCardStatusStopped {
|
||||
return gerror.Newf("ICCID当前为停机状态,不能续费:%s", iccid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRenewInput(in *RenewCurrentPackageInput) error {
|
||||
if in == nil {
|
||||
return gerror.New("续费参数不能为空")
|
||||
}
|
||||
if strings.TrimSpace(in.BatchNo) == "" {
|
||||
return gerror.New("续费批次号不能为空")
|
||||
}
|
||||
if len(in.Iccids) == 0 {
|
||||
return gerror.New("续费卡不能为空")
|
||||
}
|
||||
if in.DurationMonths <= 0 && in.DurationDisplayValue <= 0 {
|
||||
return gerror.New("续费时长必须大于0")
|
||||
}
|
||||
if in.GiftDurationMonths < 0 {
|
||||
return gerror.New("赠送时长不能小于0")
|
||||
}
|
||||
payType := in.PayType
|
||||
if payType == 0 {
|
||||
payType = consts.PayTypeOffline
|
||||
}
|
||||
if payType == consts.PayTypeOffline && !in.OfflineContractCompleted {
|
||||
return gerror.New("请确认线下合同、销售和走账已完成")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chooseString(primary, fallback string) string {
|
||||
primary = strings.TrimSpace(primary)
|
||||
if primary != "" {
|
||||
return primary
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestValidateRenewCardStatusRejectsStoppedCard(t *testing.T) {
|
||||
err := validateRenewCardStatus("89860401788404184188", &entity.TSimCardInfo{
|
||||
BossCardStatus: consts.V3ApiSimBossCardStatusStopped,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("validateRenewCardStatus() error = nil, want stopped card error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "当前为停机状态") {
|
||||
t.Fatalf("validateRenewCardStatus() error = %q, want stopped card error", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
const cardPackageBindInsertBatchSize = 500
|
||||
|
||||
type Repository struct{}
|
||||
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
func (r *Repository) LockCardsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*entity.TSimCardInfo, error) {
|
||||
result := make(map[string]*entity.TSimCardInfo)
|
||||
uniqueIccids := uniqueStrings(iccids)
|
||||
if len(uniqueIccids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var list []*entity.TSimCardInfo
|
||||
if err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
|
||||
WhereIn(dao.TSimCardInfo.Columns().Iccid, uniqueIccids).
|
||||
LockUpdate().
|
||||
Scan(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
if item != nil && item.Iccid != "" {
|
||||
result[item.Iccid] = item
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) LockCurrentActiveBindsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*entity.TSimCardBindPkg, error) {
|
||||
result := make(map[string]*entity.TSimCardBindPkg)
|
||||
priorities := make(map[string]int)
|
||||
uniqueIccids := uniqueStrings(iccids)
|
||||
if len(uniqueIccids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var list []*entity.TSimCardBindPkg
|
||||
if err := tx.Ctx(ctx).Model(dao.TSimCardBindPkg.Table()).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().Iccid, uniqueIccids).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().EffectiveStatus, []int{consts.EffectiveStatusPending, consts.EffectiveStatusEffective}).
|
||||
OrderDesc(dao.TSimCardBindPkg.Columns().EffectiveStatus).
|
||||
OrderAsc(dao.TSimCardBindPkg.Columns().EffectiveTime).
|
||||
OrderAsc(dao.TSimCardBindPkg.Columns().Id).
|
||||
LockUpdate().
|
||||
Scan(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
if item == nil || item.Iccid == "" {
|
||||
continue
|
||||
}
|
||||
priority := bindPriority(item.EffectiveStatus)
|
||||
currentPriority, exists := priorities[item.Iccid]
|
||||
if exists && priority < currentPriority {
|
||||
continue
|
||||
}
|
||||
if exists && priority == currentPriority {
|
||||
result[item.Iccid] = nil
|
||||
continue
|
||||
}
|
||||
priorities[item.Iccid] = priority
|
||||
result[item.Iccid] = item
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) LockLatestBindsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*entity.TSimCardBindPkg, error) {
|
||||
result := make(map[string]*entity.TSimCardBindPkg)
|
||||
uniqueIccids := uniqueStrings(iccids)
|
||||
if len(uniqueIccids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var list []*entity.TSimCardBindPkg
|
||||
if err := tx.Ctx(ctx).Model(dao.TSimCardBindPkg.Table()).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().Iccid, uniqueIccids).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().PackageType, []int{101, 102}).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().EffectiveStatus, []int{
|
||||
consts.EffectiveStatusPending,
|
||||
consts.EffectiveStatusEffective,
|
||||
consts.EffectiveStatusExpired,
|
||||
}).
|
||||
OrderDesc(dao.TSimCardBindPkg.Columns().ExpectedExpireTime).
|
||||
OrderDesc(dao.TSimCardBindPkg.Columns().Id).
|
||||
LockUpdate().
|
||||
Scan(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
effectiveCount := make(map[string]int)
|
||||
for _, item := range list {
|
||||
if item == nil || item.Iccid == "" {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(item.Iccid)
|
||||
if item.EffectiveStatus == consts.EffectiveStatusEffective {
|
||||
effectiveCount[iccid]++
|
||||
if effectiveCount[iccid] > 1 {
|
||||
return nil, gerror.Newf("同一张卡存在多个生效中的套餐订购记录:%s", iccid)
|
||||
}
|
||||
}
|
||||
if _, exists := result[iccid]; exists {
|
||||
continue
|
||||
}
|
||||
result[iccid] = item
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ActiveBindCountsByOrderCodes(ctx context.Context, tx gdb.TX, orderCodes []string) (map[string]int, error) {
|
||||
result := make(map[string]int)
|
||||
uniqueOrderCodes := uniqueStrings(orderCodes)
|
||||
if len(uniqueOrderCodes) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var list []struct {
|
||||
OrderCode string `orm:"order_code"`
|
||||
Count int `orm:"count"`
|
||||
}
|
||||
err := tx.Ctx(ctx).Model(dao.TSimCardBindPkg.Table()).
|
||||
Fields(dao.TSimCardBindPkg.Columns().OrderCode, "COUNT(1) AS count").
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().OrderCode, uniqueOrderCodes).
|
||||
Group(dao.TSimCardBindPkg.Columns().OrderCode).
|
||||
Scan(&list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
result[item.OrderCode] = item.Count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) LoadTargetDept(ctx context.Context, tx gdb.TX, deptId int64, tenantId string) (*entity.SysDept, error) {
|
||||
var customer *entity.SysDept
|
||||
mod := tx.Ctx(ctx).Model(dao.SysDept.Table()).
|
||||
Where(dao.SysDept.Columns().DeptId, deptId)
|
||||
if tenantId != "" {
|
||||
mod = mod.Where(dao.SysDept.Columns().TenantId, tenantId)
|
||||
}
|
||||
if err := mod.Scan(&customer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
func (r *Repository) LoadPackage(ctx context.Context, tx gdb.TX, packageCode string) (*entity.TSimFlowPackageInfo, error) {
|
||||
var pkg *entity.TSimFlowPackageInfo
|
||||
err := tx.Ctx(ctx).Model(dao.TSimFlowPackageInfo.Table()).
|
||||
Where(dao.TSimFlowPackageInfo.Columns().PackageCode, strings.TrimSpace(packageCode)).
|
||||
Scan(&pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func (r *Repository) InsertOrder(ctx context.Context, tx gdb.TX, data do.TOrder) (int64, error) {
|
||||
return tx.Ctx(ctx).Model(dao.TOrder.Table()).Data(data).InsertAndGetId()
|
||||
}
|
||||
|
||||
func (r *Repository) InsertOrderItem(ctx context.Context, tx gdb.TX, data do.TOrderItem) error {
|
||||
_, err := tx.Ctx(ctx).Model(dao.TOrderItem.Table()).Data(data).Insert()
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) InsertCardPackageBinds(ctx context.Context, tx gdb.TX, rows []do.TSimCardBindPkg) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
for start := 0; start < len(rows); start += cardPackageBindInsertBatchSize {
|
||||
end := start + cardPackageBindInsertBatchSize
|
||||
if end > len(rows) {
|
||||
end = len(rows)
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.TSimCardBindPkg.Table()).Data(rows[start:end]).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteBindsBySubscriptionNos(ctx context.Context, tx gdb.TX, subscriptionNos []string) (int64, error) {
|
||||
uniqueNos := uniqueStrings(subscriptionNos)
|
||||
if len(uniqueNos) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(dao.TSimCardBindPkg.Table()).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().SubscriptionNo, uniqueNos).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
func uniqueStrings(list []string) []string {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(list))
|
||||
result := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func bindPriority(status int) int {
|
||||
switch status {
|
||||
case consts.EffectiveStatusEffective:
|
||||
return 2
|
||||
case consts.EffectiveStatusPending:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package cardpackage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCardPackageBindChunkSizeSupportsFiftyThousandCards(t *testing.T) {
|
||||
const totalCards = 50000
|
||||
if cardPackageBindInsertBatchSize <= 0 || cardPackageBindInsertBatchSize > 500 {
|
||||
t.Fatalf("cardPackageBindInsertBatchSize = %d, want 1..500", cardPackageBindInsertBatchSize)
|
||||
}
|
||||
if chunks := (totalCards + cardPackageBindInsertBatchSize - 1) / cardPackageBindInsertBatchSize; chunks != 100 {
|
||||
t.Fatalf("bind insert chunks = %d, want 100", chunks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
const (
|
||||
orderNoPrefix = "SO"
|
||||
subscriptionNoPrefix = "SUB"
|
||||
)
|
||||
|
||||
func (s *Service) CreatePackageSubscription(ctx context.Context, tx gdb.TX, in *CreatePackageSubscriptionInput) (*CreatePackageSubscriptionOutput, error) {
|
||||
if err := validateSubscribeInput(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
customer, err := s.loadTargetDept(ctx, tx, in.CustomerAgentId, in.Operator, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkg, err := s.loadPackage(ctx, tx, strings.TrimSpace(in.PackageCode))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
periodBegin := in.PackageBeginTime
|
||||
periodEnd := packageEndAt(periodBegin, in.DurationMonths+in.GiftDurationMonths, in.EndAtPreviousMonthEnd)
|
||||
packageStatus := CalcPackageStatus(now, periodBegin, periodEnd)
|
||||
effectiveStatus := CalcEffectiveStatus(now, periodBegin, periodEnd)
|
||||
amount, err := parseAmount(in.Amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quantity := len(in.Cards)
|
||||
unitPrice := 0.0
|
||||
if quantity > 0 {
|
||||
unitPrice = roundAmount(amount / float64(quantity))
|
||||
}
|
||||
businessType := in.BusinessType
|
||||
if businessType == 0 {
|
||||
businessType = consts.PackageBusinessTypeSubscribe
|
||||
}
|
||||
|
||||
orderSn, err := batchimport.NextRandomNo(orderNoPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderId, err := s.insertOrder(ctx, tx, in, orderSn, amount, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.insertOrderItem(ctx, tx, in, orderId, orderSn, pkg, amount, unitPrice, quantity, periodBegin, periodEnd, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bindRows := make([]do.TSimCardBindPkg, 0, quantity)
|
||||
bindsByIccid := make(map[string]*entity.TSimCardBindPkg, quantity)
|
||||
for _, card := range in.Cards {
|
||||
iccid := strings.TrimSpace(card.Iccid)
|
||||
if iccid == "" {
|
||||
return nil, gerror.New("订购卡ICCID不能为空")
|
||||
}
|
||||
subscriptionNo, err := batchimport.NextRandomNo(subscriptionNoPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bindRow := do.TSimCardBindPkg{
|
||||
SubscriptionNo: subscriptionNo,
|
||||
BatchNo: in.BatchNo,
|
||||
Iccid: iccid,
|
||||
Msisdn: strings.TrimSpace(card.Msisdn),
|
||||
DeptId: fmt.Sprintf("%d", in.CustomerAgentId),
|
||||
PackageType: pkg.PackageType,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: strings.TrimSpace(in.PreviousPackageCode),
|
||||
BusinessType: businessType,
|
||||
EffectiveStatus: effectiveStatus,
|
||||
HandleTime: now,
|
||||
EffectiveTime: periodBegin,
|
||||
ExpectedExpireTime: periodEnd,
|
||||
DurationMonths: in.DurationMonths,
|
||||
GiftDurationMonths: in.GiftDurationMonths,
|
||||
OrderCode: orderSn,
|
||||
Remark: strings.TrimSpace(in.BindRemark),
|
||||
TenantId: in.Operator.TenantId,
|
||||
CreatedDept: in.Operator.DeptId,
|
||||
CreatedBy: in.Operator.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
bindRows = append(bindRows, bindRow)
|
||||
bindsByIccid[iccid] = &entity.TSimCardBindPkg{
|
||||
SubscriptionNo: subscriptionNo,
|
||||
BatchNo: in.BatchNo,
|
||||
Iccid: iccid,
|
||||
Msisdn: strings.TrimSpace(card.Msisdn),
|
||||
DeptId: fmt.Sprintf("%d", in.CustomerAgentId),
|
||||
PackageType: pkg.PackageType,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: strings.TrimSpace(in.PreviousPackageCode),
|
||||
BusinessType: businessType,
|
||||
EffectiveStatus: effectiveStatus,
|
||||
HandleTime: now,
|
||||
EffectiveTime: periodBegin,
|
||||
ExpectedExpireTime: periodEnd,
|
||||
DurationMonths: in.DurationMonths,
|
||||
GiftDurationMonths: in.GiftDurationMonths,
|
||||
OrderCode: orderSn,
|
||||
Remark: strings.TrimSpace(in.BindRemark),
|
||||
TenantId: in.Operator.TenantId,
|
||||
CreatedDept: in.Operator.DeptId,
|
||||
CreatedBy: in.Operator.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
if !in.SkipBindInsert {
|
||||
if err = s.InsertCardPackageBinds(ctx, tx, bindRows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &CreatePackageSubscriptionOutput{
|
||||
OrderId: orderId,
|
||||
OrderSn: orderSn,
|
||||
Customer: customer,
|
||||
Package: pkg,
|
||||
PackageBeginAt: periodBegin,
|
||||
PackageEndAt: periodEnd,
|
||||
SubscribeTime: now,
|
||||
PackageStatus: packageStatus,
|
||||
EffectiveStatus: effectiveStatus,
|
||||
Amount: amount,
|
||||
UnitPrice: unitPrice,
|
||||
Quantity: quantity,
|
||||
BindsByIccid: bindsByIccid,
|
||||
BindRows: bindRows,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) InsertCardPackageBinds(ctx context.Context, tx gdb.TX, rows []do.TSimCardBindPkg) error {
|
||||
if err := s.repo.InsertCardPackageBinds(ctx, tx, rows); err != nil {
|
||||
return gerror.Wrap(err, "写入卡套餐绑定失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSubscribeInput(in *CreatePackageSubscriptionInput) error {
|
||||
if in == nil {
|
||||
return gerror.New("订购参数不能为空")
|
||||
}
|
||||
if strings.TrimSpace(in.BatchNo) == "" {
|
||||
return gerror.New("订购批次号不能为空")
|
||||
}
|
||||
if len(in.Cards) == 0 {
|
||||
return gerror.New("订购卡不能为空")
|
||||
}
|
||||
if in.CustomerAgentId <= 0 {
|
||||
return gerror.New("客户不能为空")
|
||||
}
|
||||
if strings.TrimSpace(in.PackageCode) == "" {
|
||||
return gerror.New("套餐不能为空")
|
||||
}
|
||||
if in.PackageBeginTime == nil {
|
||||
return gerror.New("套餐开始时间不能为空")
|
||||
}
|
||||
if in.DurationMonths <= 0 {
|
||||
return gerror.New("套餐时长必须大于0")
|
||||
}
|
||||
if in.GiftDurationMonths < 0 {
|
||||
return gerror.New("赠送时长不能小于0")
|
||||
}
|
||||
payType := in.PayType
|
||||
if payType == 0 {
|
||||
payType = consts.PayTypeOffline
|
||||
}
|
||||
if payType == consts.PayTypeOffline && !in.OfflineContractCompleted {
|
||||
return gerror.New("请确认线下合同、销售和走账已完成")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) loadTargetDept(ctx context.Context, tx gdb.TX, deptId int64, operator batchimport.OperatorContext, allowDescendant bool) (*entity.SysDept, error) {
|
||||
if operator.DeptId == 0 {
|
||||
return nil, gerror.New("当前用户未绑定机构")
|
||||
}
|
||||
|
||||
customer, err := s.repo.LoadTargetDept(ctx, tx, deptId, operator.TenantId)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询客户/机构失败")
|
||||
}
|
||||
if customer == nil || customer.DeptId == 0 {
|
||||
return nil, gerror.New("客户/机构不存在或不在可操作范围内")
|
||||
}
|
||||
if customer.DeptId == operator.DeptId || customer.ParentId == operator.DeptId {
|
||||
return customer, nil
|
||||
}
|
||||
if allowDescendant && deptAncestorsContain(customer.Ancestors, operator.DeptId) {
|
||||
return customer, nil
|
||||
}
|
||||
if customer.DeptId != operator.DeptId && customer.ParentId != operator.DeptId {
|
||||
return nil, gerror.New("客户/机构不存在或不在可操作范围内")
|
||||
}
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
func deptAncestorsContain(ancestors string, deptId int64) bool {
|
||||
if deptId <= 0 {
|
||||
return false
|
||||
}
|
||||
target := strconv.FormatInt(deptId, 10)
|
||||
for _, item := range strings.Split(strings.Trim(ancestors, ","), ",") {
|
||||
if strings.TrimSpace(item) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) loadPackage(ctx context.Context, tx gdb.TX, packageCode string) (*entity.TSimFlowPackageInfo, error) {
|
||||
pkg, err := s.repo.LoadPackage(ctx, tx, packageCode)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询套餐失败")
|
||||
}
|
||||
if pkg == nil {
|
||||
return nil, gerror.New("套餐不存在")
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func (s *Service) insertOrder(ctx context.Context, tx gdb.TX, in *CreatePackageSubscriptionInput, orderSn string, amount float64, now *gtime.Time) (int64, error) {
|
||||
remark := strings.TrimSpace(in.OrderRemark)
|
||||
if remark == "" {
|
||||
remark = "顶级出库划拨业务订单载体"
|
||||
}
|
||||
payment := normalizeSubscriptionPayment(in, amount, now)
|
||||
orderId, err := s.repo.InsertOrder(ctx, tx, do.TOrder{
|
||||
OrderSn: orderSn,
|
||||
ParentOrderId: 0,
|
||||
ParentOrderSn: "",
|
||||
CustomerType: consts.CustomerTypeAgent,
|
||||
CustomerAgentId: in.CustomerAgentId,
|
||||
EndUserId: 0,
|
||||
AgentId: in.Operator.DeptId,
|
||||
OrderType: orderTypeForBusinessType(in.BusinessType),
|
||||
OrderStatus: consts.OrderStatusCompleted,
|
||||
ShippingStatus: 0,
|
||||
ReturnStatus: 0,
|
||||
Consignee: "",
|
||||
Mobile: "",
|
||||
Email: "",
|
||||
Address: "",
|
||||
BuyerNote: strings.TrimSpace(in.BuyerNote),
|
||||
AdminNote: strings.TrimSpace(in.AdminNote),
|
||||
Remark: remark,
|
||||
ShippingMethod: 3,
|
||||
LogisticsId: 0,
|
||||
LogisticsName: "",
|
||||
ShippingTypeId: 0,
|
||||
ShippingTypeName: "",
|
||||
TrackingNo: "",
|
||||
PayType: payment.PayType,
|
||||
PayStatus: payment.PayStatus,
|
||||
PayOrderSn: "",
|
||||
PayTime: payment.PayTime,
|
||||
TotalAmount: amount,
|
||||
ProductAmount: amount,
|
||||
Balance: payment.BalanceAmount,
|
||||
PaidAmount: amount,
|
||||
OfflinePaidAmount: payment.OfflinePaidAmount,
|
||||
IsNeedCommission: 0,
|
||||
DistributionStatus: 0,
|
||||
TenantId: in.Operator.TenantId,
|
||||
CreatedDept: in.Operator.DeptId,
|
||||
CreatedBy: in.Operator.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "创建订单失败")
|
||||
}
|
||||
return orderId, nil
|
||||
}
|
||||
|
||||
func orderTypeForBusinessType(businessType int) int {
|
||||
switch businessType {
|
||||
case consts.PackageBusinessTypeRenew:
|
||||
return consts.OrderTypeCardRenew
|
||||
case consts.PackageBusinessTypeChange:
|
||||
return consts.OrderTypeCardChange
|
||||
default:
|
||||
return consts.OrderTypeCardSubscribe
|
||||
}
|
||||
}
|
||||
|
||||
type subscriptionPayment struct {
|
||||
PayType int
|
||||
PayStatus int
|
||||
BalanceAmount float64
|
||||
OfflinePaidAmount float64
|
||||
PayTime *gtime.Time
|
||||
}
|
||||
|
||||
func normalizeSubscriptionPayment(in *CreatePackageSubscriptionInput, amount float64, now *gtime.Time) subscriptionPayment {
|
||||
payment := subscriptionPayment{
|
||||
PayType: consts.PayTypeOffline,
|
||||
PayStatus: consts.PayStatusPaid,
|
||||
OfflinePaidAmount: amount,
|
||||
PayTime: now,
|
||||
}
|
||||
if in == nil {
|
||||
return payment
|
||||
}
|
||||
if in.PayType > 0 {
|
||||
payment.PayType = in.PayType
|
||||
}
|
||||
if in.PayStatus > 0 {
|
||||
payment.PayStatus = in.PayStatus
|
||||
}
|
||||
if in.PayTime != nil {
|
||||
payment.PayTime = in.PayTime
|
||||
}
|
||||
switch payment.PayType {
|
||||
case consts.PayTypeBalance:
|
||||
payment.BalanceAmount = roundAmount(in.BalanceAmount)
|
||||
if payment.BalanceAmount == 0 && amount > 0 {
|
||||
payment.BalanceAmount = amount
|
||||
}
|
||||
payment.OfflinePaidAmount = 0
|
||||
default:
|
||||
payment.OfflinePaidAmount = roundAmount(in.OfflinePaidAmount)
|
||||
if payment.OfflinePaidAmount == 0 && amount > 0 {
|
||||
payment.OfflinePaidAmount = amount
|
||||
}
|
||||
payment.BalanceAmount = roundAmount(in.BalanceAmount)
|
||||
}
|
||||
return payment
|
||||
}
|
||||
|
||||
func (s *Service) insertOrderItem(ctx context.Context, tx gdb.TX, in *CreatePackageSubscriptionInput, orderId int64, orderSn string, pkg *entity.TSimFlowPackageInfo, amount, unitPrice float64, quantity int, beginAt, endAt *gtime.Time, now *gtime.Time) error {
|
||||
durationLabel := strings.TrimSpace(in.DurationDisplayLabel)
|
||||
if durationLabel == "" {
|
||||
durationLabel = fmt.Sprintf("%d个月", in.DurationMonths)
|
||||
}
|
||||
if strings.TrimSpace(in.DurationDisplayLabel) == "" && in.DurationDisplayValue > 0 && strings.TrimSpace(in.DurationUnit) != "" {
|
||||
durationLabel = fmt.Sprintf("%d%s", in.DurationDisplayValue, consts.RenewDurationUnitText(strings.TrimSpace(in.DurationUnit)))
|
||||
}
|
||||
skuData := marshalJSON([]g.Map{
|
||||
{"skuName": "套餐", "skuValue": pkg.PackageName, "packageCode": pkg.PackageCode, "packageType": pkg.PackageType},
|
||||
{"skuName": "数量", "skuValue": fmt.Sprintf("%d", quantity)},
|
||||
{"skuName": "生效时间", "skuValue": formatTime(beginAt)},
|
||||
{"skuName": "时长", "skuValue": durationLabel},
|
||||
})
|
||||
extraSkuData := marshalJSON(g.Map{
|
||||
"outboundBatchNo": in.BatchNo,
|
||||
"businessBatchNo": in.BatchNo,
|
||||
"offlineContractCompleted": in.OfflineContractCompleted,
|
||||
"packageBeginTime": formatTime(beginAt),
|
||||
"packageEndTime": formatTime(endAt),
|
||||
"durationMonths": in.DurationMonths,
|
||||
"giftDurationMonths": in.GiftDurationMonths,
|
||||
"durationValue": in.DurationDisplayValue,
|
||||
"giftDurationValue": in.GiftDurationDisplayValue,
|
||||
"durationLabel": durationLabel,
|
||||
"giftDurationLabel": strings.TrimSpace(in.GiftDurationDisplayLabel),
|
||||
"durationUnit": strings.TrimSpace(in.DurationUnit),
|
||||
"amount": amount,
|
||||
})
|
||||
if err := s.repo.InsertOrderItem(ctx, tx, do.TOrderItem{
|
||||
OrderId: orderId,
|
||||
OrderSn: orderSn,
|
||||
CustomerType: consts.CustomerTypeAgent,
|
||||
CustomerAgentId: in.CustomerAgentId,
|
||||
EndUserId: 0,
|
||||
Price: unitPrice,
|
||||
Quantity: quantity,
|
||||
ProductId: pkg.Id,
|
||||
ProductName: pkg.PackageName,
|
||||
ProductSn: pkg.PackageCode,
|
||||
PicThumb: "",
|
||||
SkuId: 0,
|
||||
SkuData: skuData,
|
||||
DeliveryQuantity: 0,
|
||||
ProductType: consts.ProductTypeCard,
|
||||
IsGift: 0,
|
||||
AgentId: in.Operator.DeptId,
|
||||
PrepayPrice: 0,
|
||||
Commission: "",
|
||||
OriginPrice: unitPrice,
|
||||
ExtraSkuData: extraSkuData,
|
||||
PayMode: consts.PayModeYearMonth,
|
||||
ServiceBeginTime: beginAt,
|
||||
ServiceEndTime: endAt,
|
||||
TenantId: in.Operator.TenantId,
|
||||
CreatedDept: in.Operator.DeptId,
|
||||
CreatedBy: in.Operator.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return gerror.Wrap(err, "创建订单项失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addMonths(t *gtime.Time, months int) *gtime.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
if months <= 0 {
|
||||
return endOfDayAt(t.Time)
|
||||
}
|
||||
beginAt := t.Time
|
||||
targetMonth := time.Date(
|
||||
beginAt.Year(),
|
||||
beginAt.Month()+time.Month(months),
|
||||
1,
|
||||
beginAt.Hour(),
|
||||
beginAt.Minute(),
|
||||
beginAt.Second(),
|
||||
beginAt.Nanosecond(),
|
||||
beginAt.Location(),
|
||||
)
|
||||
day := beginAt.Day()
|
||||
lastDay := time.Date(
|
||||
targetMonth.Year(),
|
||||
targetMonth.Month()+1,
|
||||
0,
|
||||
beginAt.Hour(),
|
||||
beginAt.Minute(),
|
||||
beginAt.Second(),
|
||||
beginAt.Nanosecond(),
|
||||
beginAt.Location(),
|
||||
).Day()
|
||||
if day > lastDay {
|
||||
day = lastDay
|
||||
}
|
||||
return endOfDayAt(time.Date(
|
||||
targetMonth.Year(),
|
||||
targetMonth.Month(),
|
||||
day,
|
||||
beginAt.Hour(),
|
||||
beginAt.Minute(),
|
||||
beginAt.Second(),
|
||||
beginAt.Nanosecond(),
|
||||
beginAt.Location(),
|
||||
))
|
||||
}
|
||||
|
||||
func endOfDayAt(t time.Time) *gtime.Time {
|
||||
return gtime.NewFromTime(time.Date(
|
||||
t.Year(),
|
||||
t.Month(),
|
||||
t.Day(),
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
0,
|
||||
t.Location(),
|
||||
))
|
||||
}
|
||||
|
||||
func packageEndAt(beginAt *gtime.Time, months int, previousMonthEnd bool) *gtime.Time {
|
||||
endAt := addMonths(beginAt, months)
|
||||
if !previousMonthEnd || endAt == nil {
|
||||
return endAt
|
||||
}
|
||||
return previousMonthEndAt(endAt)
|
||||
}
|
||||
|
||||
func previousMonthEndAt(t *gtime.Time) *gtime.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
tt := t.Time
|
||||
return gtime.NewFromTime(time.Date(tt.Year(), tt.Month(), 0, 23, 59, 59, 0, tt.Location()))
|
||||
}
|
||||
|
||||
func CalcPackageStatus(now, beginAt, endAt *gtime.Time) int {
|
||||
if beginAt != nil && beginAt.After(now) {
|
||||
return consts.PackageStatusPending
|
||||
}
|
||||
if endAt != nil && !endAt.After(now) {
|
||||
return consts.PackageStatusExpired
|
||||
}
|
||||
return consts.PackageStatusEffective
|
||||
}
|
||||
|
||||
func calcPackageStatuses(now, beginAt, endAt *gtime.Time) (packageStatus, effectiveStatus int) {
|
||||
return CalcPackageStatus(now, beginAt, endAt), CalcEffectiveStatus(now, beginAt, endAt)
|
||||
}
|
||||
|
||||
func CalcEffectiveStatus(now, beginAt, endAt *gtime.Time) int {
|
||||
if beginAt != nil && beginAt.After(now) {
|
||||
return consts.EffectiveStatusPending
|
||||
}
|
||||
if endAt != nil && !endAt.After(now) {
|
||||
return consts.EffectiveStatusExpired
|
||||
}
|
||||
return consts.EffectiveStatusEffective
|
||||
}
|
||||
|
||||
func parseAmount(value string) (float64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
amount, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0, gerror.New("订单金额格式不正确")
|
||||
}
|
||||
if amount < 0 {
|
||||
return 0, gerror.New("订单金额不能小于0")
|
||||
}
|
||||
return roundAmount(amount), nil
|
||||
}
|
||||
|
||||
func roundAmount(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
func marshalJSON(value any) string {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func TestOrderTypeForBusinessType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
businessType int
|
||||
want int
|
||||
}{
|
||||
{name: "subscribe", businessType: consts.PackageBusinessTypeSubscribe, want: consts.OrderTypeCardSubscribe},
|
||||
{name: "renew", businessType: consts.PackageBusinessTypeRenew, want: consts.OrderTypeCardRenew},
|
||||
{name: "change", businessType: consts.PackageBusinessTypeChange, want: consts.OrderTypeCardChange},
|
||||
{name: "unknown defaults to subscribe", businessType: 99, want: consts.OrderTypeCardSubscribe},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := orderTypeForBusinessType(test.businessType); got != test.want {
|
||||
t.Fatalf("orderTypeForBusinessType(%d) = %d, want %d", test.businessType, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalcPackageStatuses(t *testing.T) {
|
||||
now := gtime.NewFromStr("2026-10-27 12:00:00")
|
||||
tests := []struct {
|
||||
name string
|
||||
beginAt, endAt *gtime.Time
|
||||
wantPackage, wantBind int
|
||||
}{
|
||||
{
|
||||
name: "renew period already effective",
|
||||
beginAt: gtime.NewFromStr("2026-09-30 23:59:59"),
|
||||
endAt: gtime.NewFromStr("2027-09-30 23:59:59"),
|
||||
wantPackage: consts.PackageStatusEffective,
|
||||
wantBind: consts.EffectiveStatusEffective,
|
||||
},
|
||||
{
|
||||
name: "renew period starts in future",
|
||||
beginAt: gtime.NewFromStr("2026-11-01 23:59:59"),
|
||||
endAt: gtime.NewFromStr("2027-11-01 23:59:59"),
|
||||
wantPackage: consts.PackageStatusPending,
|
||||
wantBind: consts.EffectiveStatusPending,
|
||||
},
|
||||
{
|
||||
name: "renew period already expired",
|
||||
beginAt: gtime.NewFromStr("2025-09-30 23:59:59"),
|
||||
endAt: gtime.NewFromStr("2026-09-30 23:59:59"),
|
||||
wantPackage: consts.PackageStatusExpired,
|
||||
wantBind: consts.EffectiveStatusExpired,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
packageStatus, effectiveStatus := calcPackageStatuses(now, test.beginAt, test.endAt)
|
||||
if packageStatus != test.wantPackage || effectiveStatus != test.wantBind {
|
||||
t.Fatalf("calcPackageStatuses() = (%d, %d), want (%d, %d)", packageStatus, effectiveStatus, test.wantPackage, test.wantBind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func (s *Service) TerminateCurrentPackage(ctx context.Context, tx gdb.TX, in *TerminateCurrentPackageInput) (*TerminateCurrentPackageOutput, error) {
|
||||
if in == nil || len(in.Iccids) == 0 {
|
||||
return nil, gerror.New("退订卡不能为空")
|
||||
}
|
||||
|
||||
iccids := uniqueStrings(in.Iccids)
|
||||
cardsByIccid, err := s.repo.LockCardsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "锁定卡信息失败")
|
||||
}
|
||||
if len(cardsByIccid) != len(iccids) {
|
||||
return nil, gerror.New("部分卡信息已变化,请刷新后重试")
|
||||
}
|
||||
|
||||
bindsByIccid, err := s.repo.LockCurrentActiveBindsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "锁定当前订购失败")
|
||||
}
|
||||
if len(bindsByIccid) != len(iccids) {
|
||||
return nil, gerror.New("部分卡没有当前订购,不能退订")
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
sourceBatchNo := strings.TrimSpace(in.SourceBatchNo)
|
||||
packageCode := ""
|
||||
orderCodeSet := make(map[string]struct{}, len(iccids))
|
||||
orderUnsubCounts := make(map[string]int, len(iccids))
|
||||
logRows := make([]do.TSimBindPackageLogs, 0, len(iccids))
|
||||
subscriptionNos := make([]string, 0, len(iccids))
|
||||
|
||||
for _, iccid := range iccids {
|
||||
card := cardsByIccid[iccid]
|
||||
bind := bindsByIccid[iccid]
|
||||
if card == nil {
|
||||
return nil, gerror.Newf("ICCID不存在正式卡记录:%s", iccid)
|
||||
}
|
||||
if bind == nil {
|
||||
return nil, gerror.Newf("ICCID当前订购数据异常或不存在:%s", iccid)
|
||||
}
|
||||
if card.DeptId != in.Operator.DeptId && card.CreatedDept != in.Operator.DeptId {
|
||||
return nil, gerror.Newf("ICCID不属于当前机构:%s", iccid)
|
||||
}
|
||||
if strings.TrimSpace(card.CurrentPackageCode) == "" || card.PackageStatus == consts.PackageStatusNone {
|
||||
return nil, gerror.Newf("ICCID没有可退订套餐:%s", iccid)
|
||||
}
|
||||
if packageCode == "" {
|
||||
packageCode = strings.TrimSpace(bind.PackageCode)
|
||||
}
|
||||
if packageCode != strings.TrimSpace(bind.PackageCode) {
|
||||
return nil, gerror.New("当前批次下存在多个套餐,不能统一退订")
|
||||
}
|
||||
if strings.TrimSpace(card.CurrentPackageCode) != strings.TrimSpace(bind.PackageCode) {
|
||||
return nil, gerror.Newf("ICCID当前套餐与订购记录不一致:%s", iccid)
|
||||
}
|
||||
if sourceBatchNo != "" && strings.TrimSpace(bind.BatchNo) != sourceBatchNo {
|
||||
return nil, gerror.Newf("ICCID当前订购不属于来源出库批次:%s", iccid)
|
||||
}
|
||||
|
||||
orderCode := strings.TrimSpace(bind.OrderCode)
|
||||
if orderCode != "" {
|
||||
orderCodeSet[orderCode] = struct{}{}
|
||||
orderUnsubCounts[orderCode]++
|
||||
}
|
||||
subscriptionNos = append(subscriptionNos, bind.SubscriptionNo)
|
||||
logRows = append(logRows, bindToHistory(bind, now, in.BatchNo, in.Operator.UserId))
|
||||
}
|
||||
|
||||
if len(logRows) > 0 {
|
||||
if _, err = tx.Ctx(ctx).Model(dao.TSimBindPackageLogs.Table()).
|
||||
Data(logRows).
|
||||
OnConflict(dao.TSimBindPackageLogs.Columns().SubscriptionNo).
|
||||
Save(); err != nil {
|
||||
return nil, gerror.Wrap(err, "写入订购历史失败")
|
||||
}
|
||||
}
|
||||
|
||||
affected, err := s.repo.DeleteBindsBySubscriptionNos(ctx, tx, subscriptionNos)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "删除当前订购失败")
|
||||
}
|
||||
if int(affected) != len(iccids) {
|
||||
return nil, gerror.New("部分当前订购已变化,请刷新后重试")
|
||||
}
|
||||
|
||||
orderCodes := mapKeys(orderCodeSet)
|
||||
if err = s.updateOrdersAfterUnsub(ctx, tx, orderCodes, orderUnsubCounts, in.BatchNo, now, in.Operator.UserId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TerminateCurrentPackageOutput{
|
||||
Iccids: iccids,
|
||||
CardsByIccid: cardsByIccid,
|
||||
BindsByIccid: bindsByIccid,
|
||||
PackageCode: packageCode,
|
||||
OrderCodes: orderCodes,
|
||||
UnsubTime: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func bindToHistory(bind *entity.TSimCardBindPkg, actualExpireAt *gtime.Time, unsubBatchNo string, userId int64) do.TSimBindPackageLogs {
|
||||
return do.TSimBindPackageLogs{
|
||||
SubscriptionNo: bind.SubscriptionNo,
|
||||
BatchNo: bind.BatchNo,
|
||||
Iccid: bind.Iccid,
|
||||
Msisdn: bind.Msisdn,
|
||||
DeptId: bind.DeptId,
|
||||
PackageType: bind.PackageType,
|
||||
PackageCode: bind.PackageCode,
|
||||
PreviousPackageCode: bind.PreviousPackageCode,
|
||||
BusinessType: bind.BusinessType,
|
||||
EffectiveStatus: consts.EffectiveStatusTerminated,
|
||||
HandleTime: bind.HandleTime,
|
||||
EffectiveTime: bind.EffectiveTime,
|
||||
ExpectedExpireTime: bind.ExpectedExpireTime,
|
||||
ActualExpireTime: actualExpireAt,
|
||||
DurationMonths: bind.DurationMonths,
|
||||
GiftDurationMonths: bind.GiftDurationMonths,
|
||||
OrderCode: bind.OrderCode,
|
||||
Remark: appendRemark(bind.Remark, fmt.Sprintf("unsubscribeBatchNo=%s", unsubBatchNo)),
|
||||
TenantId: bind.TenantId,
|
||||
CreatedDept: bind.CreatedDept,
|
||||
CreatedBy: bind.CreatedBy,
|
||||
CreatedAt: bind.CreatedAt,
|
||||
UpdatedBy: userId,
|
||||
UpdatedAt: actualExpireAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) updateOrdersAfterUnsub(ctx context.Context, tx gdb.TX, orderCodes []string, unsubCounts map[string]int, batchNo string, unsubTime *gtime.Time, userId int64) error {
|
||||
if len(orderCodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
activeCounts, err := s.repo.ActiveBindCountsByOrderCodes(ctx, tx, orderCodes)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "统计订单剩余订购失败")
|
||||
}
|
||||
for _, orderCode := range orderCodes {
|
||||
returnStatus := consts.OrderReturnStatusSuccess
|
||||
if activeCounts[orderCode] > 0 {
|
||||
returnStatus = consts.OrderReturnStatusPartial
|
||||
}
|
||||
if _, err = tx.Ctx(ctx).Model(dao.TOrder.Table()).
|
||||
Where(dao.TOrder.Columns().OrderSn, orderCode).
|
||||
Data(do.TOrder{
|
||||
ReturnStatus: returnStatus,
|
||||
UpdatedBy: userId,
|
||||
UpdatedAt: unsubTime,
|
||||
}).
|
||||
Update(); err != nil {
|
||||
return gerror.Wrap(err, "更新订单退订状态失败")
|
||||
}
|
||||
if err = updateOrderItemExtra(ctx, tx, orderCode, batchNo, unsubCounts[orderCode], unsubTime, userId); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateOrderItemExtra(ctx context.Context, tx gdb.TX, orderCode, batchNo string, unsubQuantity int, unsubTime *gtime.Time, userId int64) error {
|
||||
var item *entity.TOrderItem
|
||||
if err := tx.Ctx(ctx).Model(dao.TOrderItem.Table()).
|
||||
Where(dao.TOrderItem.Columns().OrderSn, orderCode).
|
||||
Limit(1).
|
||||
Scan(&item); err != nil {
|
||||
return gerror.Wrap(err, "查询订单项失败")
|
||||
}
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
extra := g.Map{}
|
||||
if strings.TrimSpace(item.ExtraSkuData) != "" {
|
||||
_ = json.Unmarshal([]byte(item.ExtraSkuData), &extra)
|
||||
}
|
||||
extra["lastUnsubscribeBatchNo"] = batchNo
|
||||
extra["lastUnsubscribeTime"] = formatTime(unsubTime)
|
||||
extra["unsubscribedQuantity"] = unsubQuantity
|
||||
|
||||
data, err := json.Marshal(extra)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "生成订单项退订扩展失败")
|
||||
}
|
||||
if _, err = tx.Ctx(ctx).Model(dao.TOrderItem.Table()).
|
||||
Where(dao.TOrderItem.Columns().ItemId, item.ItemId).
|
||||
Data(do.TOrderItem{
|
||||
ExtraSkuData: string(data),
|
||||
UpdatedBy: userId,
|
||||
UpdatedAt: unsubTime,
|
||||
}).
|
||||
Update(); err != nil {
|
||||
return gerror.Wrap(err, "更新订单项退订扩展失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendRemark(old, addition string) string {
|
||||
old = strings.TrimSpace(old)
|
||||
addition = strings.TrimSpace(addition)
|
||||
if old == "" {
|
||||
return addition
|
||||
}
|
||||
if addition == "" {
|
||||
return old
|
||||
}
|
||||
return old + "; " + addition
|
||||
}
|
||||
|
||||
func mapKeys(set map[string]struct{}) []string {
|
||||
result := make([]string, 0, len(set))
|
||||
for key := range set {
|
||||
if strings.TrimSpace(key) != "" {
|
||||
result = append(result, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func formatTime(t *gtime.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("Y-m-d H:i:s")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{repo: NewRepository()}
|
||||
}
|
||||
|
||||
type TerminateCurrentPackageInput struct {
|
||||
BatchNo string
|
||||
SourceBatchNo string
|
||||
Iccids []string
|
||||
Operator batchimport.OperatorContext
|
||||
}
|
||||
|
||||
type TerminateCurrentPackageOutput struct {
|
||||
Iccids []string
|
||||
CardsByIccid map[string]*entity.TSimCardInfo
|
||||
BindsByIccid map[string]*entity.TSimCardBindPkg
|
||||
PackageCode string
|
||||
OrderCodes []string
|
||||
UnsubTime *gtime.Time
|
||||
}
|
||||
|
||||
type SubscribeCard struct {
|
||||
Iccid string
|
||||
Msisdn string
|
||||
Imsi string
|
||||
}
|
||||
|
||||
type CreatePackageSubscriptionInput struct {
|
||||
BatchNo string
|
||||
Cards []SubscribeCard
|
||||
CustomerAgentId int64
|
||||
PackageCode string
|
||||
PackageBeginTime *gtime.Time
|
||||
DurationMonths int
|
||||
GiftDurationMonths int
|
||||
DurationDisplayValue int
|
||||
GiftDurationDisplayValue int
|
||||
DurationDisplayLabel string
|
||||
GiftDurationDisplayLabel string
|
||||
DurationUnit string
|
||||
EndAtPreviousMonthEnd bool
|
||||
Amount string
|
||||
PayType int
|
||||
PayStatus int
|
||||
BalanceAmount float64
|
||||
OfflinePaidAmount float64
|
||||
PayTime *gtime.Time
|
||||
OfflineContractCompleted bool
|
||||
BuyerNote string
|
||||
AdminNote string
|
||||
PreviousPackageCode string
|
||||
BusinessType int
|
||||
OrderRemark string
|
||||
BindRemark string
|
||||
SkipBindInsert bool
|
||||
Operator batchimport.OperatorContext
|
||||
}
|
||||
|
||||
type CreatePackageSubscriptionOutput struct {
|
||||
OrderId int64
|
||||
OrderSn string
|
||||
Customer *entity.SysDept
|
||||
Package *entity.TSimFlowPackageInfo
|
||||
PackageBeginAt *gtime.Time
|
||||
PackageEndAt *gtime.Time
|
||||
SubscribeTime *gtime.Time
|
||||
PackageStatus int
|
||||
EffectiveStatus int
|
||||
Amount float64
|
||||
UnitPrice float64
|
||||
Quantity int
|
||||
BindsByIccid map[string]*entity.TSimCardBindPkg
|
||||
BindRows []do.TSimCardBindPkg
|
||||
}
|
||||
|
||||
type RenewCurrentPackageInput struct {
|
||||
BatchNo string
|
||||
SourceBatchNo string
|
||||
PackageCode string
|
||||
Iccids []string
|
||||
DurationMonths int
|
||||
GiftDurationMonths int
|
||||
DurationDisplayValue int
|
||||
GiftDurationDisplayValue int
|
||||
DurationUnit string
|
||||
PackageBeginTime *gtime.Time
|
||||
PackageEndTime *gtime.Time
|
||||
Amount string
|
||||
PayType int
|
||||
PayStatus int
|
||||
BalanceAmount float64
|
||||
OfflinePaidAmount float64
|
||||
PayTime *gtime.Time
|
||||
OfflineContractCompleted bool
|
||||
BuyerNote string
|
||||
AdminNote string
|
||||
OrderRemark string
|
||||
BindRemark string
|
||||
Operator batchimport.OperatorContext
|
||||
}
|
||||
|
||||
type RenewCurrentPackageOutput struct {
|
||||
Iccids []string
|
||||
OrderId int64
|
||||
OrderSn string
|
||||
Customer *entity.SysDept
|
||||
Package *entity.TSimFlowPackageInfo
|
||||
CardsByIccid map[string]*entity.TSimCardInfo
|
||||
LatestBindsByIccid map[string]*entity.TSimCardBindPkg
|
||||
NewBindsByIccid map[string]*entity.TSimCardBindPkg
|
||||
BindRows []do.TSimCardBindPkg
|
||||
RenewTime *gtime.Time
|
||||
PackageCode string
|
||||
Amount float64
|
||||
UnitPrice float64
|
||||
Quantity int
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type channelDisplayScope struct {
|
||||
TenantID string
|
||||
DeptID int64
|
||||
FullName bool
|
||||
}
|
||||
|
||||
// Channel names are private to the tenant's top organization. A broad data
|
||||
// scope or an administrator role does not grant downstream organizations names.
|
||||
func canViewChannelFullName(ctx context.Context) bool {
|
||||
return resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
var dept *entity.SysDept
|
||||
cols := dao.SysDept.Columns()
|
||||
err := dao.SysDept.Ctx(ctx).Fields(cols.DeptId, cols.ParentId, cols.Ancestors).
|
||||
Where(cols.TenantId, contexts.GetTenantId(ctx)).
|
||||
Where(cols.DeptId, contexts.GetDeptId(ctx)).Scan(&dept)
|
||||
return dept, err
|
||||
})
|
||||
}
|
||||
|
||||
func resolveChannelDisplayScope(ctx context.Context, load func() (*entity.SysDept, error)) bool {
|
||||
user := contexts.GetUser(ctx)
|
||||
if user == nil || user.DeptId <= 0 || strings.TrimSpace(user.TenantId) == "" {
|
||||
return false
|
||||
}
|
||||
const key = "iot.channelDisplayScope"
|
||||
if cached, ok := contexts.GetDataValue(ctx, key).(channelDisplayScope); ok && cached.TenantID == user.TenantId && cached.DeptID == user.DeptId {
|
||||
return cached.FullName
|
||||
}
|
||||
dept, err := load()
|
||||
fullName := err == nil && dept != nil && dept.DeptId == user.DeptId &&
|
||||
(dept.ParentId == 0 || strings.Trim(dept.Ancestors, ",") == "0")
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "resolve channel display organization failed: %v", err)
|
||||
}
|
||||
contexts.SetDataValue(ctx, key, channelDisplayScope{TenantID: user.TenantId, DeptID: user.DeptId, FullName: fullName})
|
||||
return fullName
|
||||
}
|
||||
|
||||
func channelDisplayNameColumn(ctx context.Context) string {
|
||||
if canViewChannelFullName(ctx) {
|
||||
return dao.TSimChannels.Columns().ChanName
|
||||
}
|
||||
return dao.TSimChannels.Columns().ChanAlias
|
||||
}
|
||||
|
||||
func channelDisplayNameSQL(ctx context.Context, table string) string {
|
||||
return fmt.Sprintf("COALESCE(NULLIF(TRIM(%s.%s), ''), '--')", table, channelDisplayNameColumn(ctx))
|
||||
}
|
||||
|
||||
func channelDisplayName(fullName bool, name, alias string) string {
|
||||
if fullName {
|
||||
return name
|
||||
}
|
||||
if alias = strings.TrimSpace(alias); alias != "" {
|
||||
return alias
|
||||
}
|
||||
return "--"
|
||||
}
|
||||
|
||||
func applyCardChannelDisplayNames(ctx context.Context, list []*model.TSimCardInfoListModel) {
|
||||
if len(list) == 0 {
|
||||
return
|
||||
}
|
||||
fullName := canViewChannelFullName(ctx)
|
||||
for _, item := range list {
|
||||
if item != nil {
|
||||
item.ChanName = channelDisplayName(fullName, item.ChanName, item.ChanAlias)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func channelDisplayTestContext(tenant string, dept int64) context.Context {
|
||||
return contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{
|
||||
ID: 1, TenantId: tenant, DeptId: dept,
|
||||
Roles: []model.Role{{RoleId: consts.SuperAdminRoleId, DataScope: consts.SysRoleDataScopeAll}},
|
||||
}}})
|
||||
}
|
||||
|
||||
func TestChannelDisplayScope(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
dept *entity.SysDept
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "top organization", dept: &entity.SysDept{DeptId: 100, ParentId: 0}, want: true},
|
||||
{name: "top organization legacy ancestors", dept: &entity.SysDept{DeptId: 100, ParentId: 1, Ancestors: "0"}, want: true},
|
||||
{name: "downstream super admin still uses alias", dept: &entity.SysDept{DeptId: 100, ParentId: 50, Ancestors: "0,50"}},
|
||||
{name: "missing organization"},
|
||||
{name: "different organization", dept: &entity.SysDept{DeptId: 999, ParentId: 0}},
|
||||
{name: "lookup failure", err: errors.New("lookup failed")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 100)
|
||||
got := resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { return tc.dept, tc.err })
|
||||
if got != tc.want {
|
||||
t.Fatalf("full name = %v, want %v", got, tc.want)
|
||||
}
|
||||
if got := canViewChannelFullName(ctx); got != tc.want {
|
||||
t.Fatalf("cached scope = %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, ctx := range []context.Context{context.Background(), channelDisplayTestContext("", 100), channelDisplayTestContext("tenant-A", 0)} {
|
||||
if resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
t.Fatal("invalid identity should not query organizations")
|
||||
return nil, nil
|
||||
}) {
|
||||
t.Fatal("invalid identity got full names")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplayScopeCacheIdentity(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 100)
|
||||
calls := 0
|
||||
load := func() (*entity.SysDept, error) {
|
||||
calls++
|
||||
return &entity.SysDept{DeptId: contexts.GetDeptId(ctx), ParentId: 0}, nil
|
||||
}
|
||||
if !resolveChannelDisplayScope(ctx, load) || !resolveChannelDisplayScope(ctx, load) || calls != 1 {
|
||||
t.Fatal("scope not cached for same identity")
|
||||
}
|
||||
contexts.GetUser(ctx).DeptId = 200
|
||||
resolveChannelDisplayScope(ctx, load)
|
||||
contexts.GetUser(ctx).TenantId = "tenant-B"
|
||||
resolveChannelDisplayScope(ctx, load)
|
||||
if calls != 3 {
|
||||
t.Fatalf("scope reused across organization or tenant: %d calls", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplayAliasAndExport(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 200)
|
||||
resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
return &entity.SysDept{DeptId: 200, ParentId: 100, Ancestors: "0,100"}, nil
|
||||
})
|
||||
list := []*model.TSimCardInfoListModel{
|
||||
{ChanName: "PRIVATE UPSTREAM NAME", ChanAlias: " 中国移动223 "},
|
||||
{ChanName: "PRIVATE UPSTREAM NAME", ChanAlias: " "},
|
||||
nil,
|
||||
}
|
||||
applyCardChannelDisplayNames(ctx, list)
|
||||
if list[0].ChanName != "中国移动223" || list[1].ChanName != "--" {
|
||||
t.Fatalf("name leak: %+v %+v", list[0], list[1])
|
||||
}
|
||||
for _, card := range list[:2] {
|
||||
row := cancellationExportRow(&model.TSimCardCancellationListModel{ChanName: card.ChanName})
|
||||
if row[7] != card.ChanName {
|
||||
t.Fatalf("export name differs: %v", row[7])
|
||||
}
|
||||
}
|
||||
if channelDisplayName(true, "full name", "alias") != "full name" {
|
||||
t.Fatal("top organization name changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplaySQL(t *testing.T) {
|
||||
db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close(context.Background()) })
|
||||
if _, err = db.Exec(context.Background(), "CREATE TABLE channels(chan_name TEXT,chan_alias TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, alias := range []interface{}{"中国移动223", "", " ", nil} {
|
||||
if _, err = db.Exec(context.Background(), "INSERT INTO channels VALUES(?,?)", "PRIVATE UPSTREAM NAME", alias); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, top := range []bool{false, true} {
|
||||
ctx := channelDisplayTestContext("tenant-A", 100)
|
||||
parent := int64(50)
|
||||
if top {
|
||||
parent = 0
|
||||
}
|
||||
resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
return &entity.SysDept{DeptId: 100, ParentId: parent, Ancestors: "0,50"}, nil
|
||||
})
|
||||
values, err := db.GetArray(ctx, "SELECT "+channelDisplayNameSQL(ctx, "channels")+" FROM channels ORDER BY rowid")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, v := range values {
|
||||
want := "--"
|
||||
if i == 0 {
|
||||
want = "中国移动223"
|
||||
}
|
||||
if top {
|
||||
want = "PRIVATE UPSTREAM NAME"
|
||||
}
|
||||
if v.String() != want {
|
||||
t.Fatalf("top=%v row=%d got=%q want=%q", top, i, v.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the real list query and export formatter against an isolated database.
|
||||
func TestChannelDisplayCancellationList(t *testing.T) {
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := g.DB()
|
||||
t.Cleanup(func() { _ = db.Close(context.Background()); _ = gdb.SetConfig(gdb.Config{"default": previous}) })
|
||||
statements := []string{
|
||||
"CREATE TABLE sys_dept(dept_id INTEGER,tenant_id TEXT,parent_id INTEGER,ancestors TEXT,dept_name TEXT,deleted_at TEXT)",
|
||||
"INSERT INTO sys_dept VALUES(100,'tenant-A',0,'0','Top',NULL),(200,'tenant-A',100,'0,100','Customer',NULL)",
|
||||
"CREATE TABLE t_sim_channels(id INTEGER,temp_key TEXT,chan_key TEXT,tenant_id TEXT,chan_name TEXT,chan_alias TEXT)",
|
||||
"CREATE TABLE t_sim_channel_template(temp_key TEXT,temp_name TEXT,temp_model TEXT)",
|
||||
"INSERT INTO t_sim_channels(id,chan_key,tenant_id,chan_name,chan_alias) VALUES(1,'SHARED','tenant-A','PRIVATE NAME A','中国移动223'),(2,'SHARED','tenant-B','PRIVATE NAME B','Other tenant alias')",
|
||||
"CREATE TABLE t_sim_flow_package_info(package_code TEXT,package_name TEXT,tenant_id TEXT)",
|
||||
"INSERT INTO t_sim_flow_package_info VALUES('PKG','套餐A','tenant-A'),('PKG','套餐B','tenant-B')",
|
||||
"CREATE TABLE t_sim_card_cancellation(id INTEGER,iccid TEXT,msisdn TEXT,imsi TEXT,imei TEXT,dept_id INTEGER,chan_key TEXT,cancel_batch_no TEXT,cancelled_at TEXT,boss_card_status INTEGER,current_package_code TEXT,inbound_batch_no TEXT,outbound_batch_no TEXT,allocation_batch_no TEXT,card_type INTEGER,net_model INTEGER,remark TEXT,created_by INTEGER,tenant_id TEXT)",
|
||||
"INSERT INTO t_sim_card_cancellation(id,iccid,dept_id,chan_key,created_by,tenant_id,current_package_code) VALUES(1,'CARD-A',200,'SHARED',0,'tenant-A','PKG'),(2,'CARD-B',200,'SHARED',0,'tenant-B','PKG')",
|
||||
}
|
||||
for _, sql := range statements {
|
||||
if _, err := db.Exec(context.Background(), sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, dept := range []int64{200, 100} {
|
||||
ctx := channelDisplayTestContext("tenant-A", dept)
|
||||
contexts.GetUser(ctx).Roles = []model.Role{{RoleId: 99, DataScope: consts.SysRoleDataScopeAll}}
|
||||
in := &model.TSimCardCancellationListParam{}
|
||||
in.Page = 1
|
||||
in.PageSize = 20
|
||||
list, total, err := NewIotTSimCardCancellation().List(ctx, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(list) != 1 || list[0].Iccid != "CARD-A" {
|
||||
t.Fatalf("cross tenant rows: total=%d list=%+v", total, list)
|
||||
}
|
||||
want := "中国移动223"
|
||||
if dept == 100 {
|
||||
want = "PRIVATE NAME A"
|
||||
}
|
||||
if list[0].PackageName != "套餐A" {
|
||||
t.Fatalf("cross tenant package: %q", list[0].PackageName)
|
||||
}
|
||||
if list[0].ChanName != want {
|
||||
t.Fatalf("dept=%d name=%q want=%q", dept, list[0].ChanName, want)
|
||||
}
|
||||
if row := cancellationExportRow(list[0]); row[7] != want {
|
||||
t.Fatalf("export name=%v", row[7])
|
||||
}
|
||||
channelQuery := &model.TSimChannelsListParam{}
|
||||
channelQuery.Page = 1
|
||||
channelQuery.PageSize = 20
|
||||
channelQuery.ChanName = want
|
||||
channels, count, err := NewIotTSimChannels().List(ctx, channelQuery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 || len(channels) != 1 || channels[0].ChanName != want {
|
||||
t.Fatalf("channel options: count=%d rows=%+v", count, channels)
|
||||
}
|
||||
stockNames, err := loadStockChannelNames(ctx, []string{"SHARED"})
|
||||
if err != nil || stockNames["SHARED"] != want {
|
||||
t.Fatalf("stock channel name=%v err=%v", stockNames, err)
|
||||
}
|
||||
channelView, err := NewIotTSimChannels().View(ctx, &model.TSimChannelsViewParam{Id: 1})
|
||||
if err != nil || channelView == nil || channelView.ChanName != want {
|
||||
t.Fatalf("channel view=%+v err=%v", channelView, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplayCancellationMissingAlias(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 200)
|
||||
resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { return &entity.SysDept{DeptId: 200, ParentId: 100}, nil })
|
||||
for _, alias := range []string{"", " "} {
|
||||
name := channelDisplayName(canViewChannelFullName(ctx), "PRIVATE NAME", alias)
|
||||
encoded, err := json.Marshal(&model.TSimCardCancellationListModel{ChanName: name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "PRIVATE NAME") || name != "--" {
|
||||
t.Fatalf("response exposes full name: %s", encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/packageprice"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type packagePriceSource = packageprice.Source
|
||||
type packageEffectiveBaseCosts = packageprice.BaseCosts
|
||||
type packageEffectivePriceRule = packageprice.Rule
|
||||
type packageEffectiveSalePrice = packageprice.SalePrice
|
||||
|
||||
func packagePriceSourceFromEntity(pkg *entity.TSimFlowPackageInfo) packagePriceSource {
|
||||
return packageprice.SourceFromPackageInfo(pkg)
|
||||
}
|
||||
|
||||
func packagePriceSourceFromPriceListItem(pkg *model.TSimFlowPackagePriceAgentDefaultPackagePricePackageItem) packagePriceSource {
|
||||
return packageprice.SourceFromPriceListItem(pkg)
|
||||
}
|
||||
|
||||
func resolvePackageEffectiveBaseCosts(ctx context.Context, tx gdb.TX, tenantID string, deptID int64, pkg packagePriceSource) (packageEffectiveBaseCosts, error) {
|
||||
return packageprice.ResolveEffectiveBaseCosts(ctx, tx, tenantID, deptID, pkg)
|
||||
}
|
||||
|
||||
func resolvePackageEffectiveSalePrice(ctx context.Context, tx gdb.TX, tenantID string, ownerDeptID int64, targetDeptID int64, pkg packagePriceSource) (packageEffectiveSalePrice, error) {
|
||||
return packageprice.ResolveEffectiveSalePrice(ctx, tx, tenantID, ownerDeptID, targetDeptID, pkg)
|
||||
}
|
||||
|
||||
func resolvePackageEffectiveBaseCostsDepth(ctx context.Context, tx gdb.TX, tenantID string, deptID int64, pkg packagePriceSource, visited map[int64]struct{}) (packageEffectiveBaseCosts, error) {
|
||||
if strings.TrimSpace(pkg.PackageCode) == "" {
|
||||
return packageEffectiveBaseCosts{}, gerror.New("套餐编码不能为空")
|
||||
}
|
||||
if deptID <= 0 {
|
||||
return packageSystemBaseCosts(pkg), nil
|
||||
}
|
||||
if _, ok := visited[deptID]; ok {
|
||||
return packageEffectiveBaseCosts{}, gerror.New("组织定价层级存在循环,无法计算生效成本")
|
||||
}
|
||||
visited[deptID] = struct{}{}
|
||||
|
||||
parentID, err := loadPackagePriceParentDeptID(ctx, tx, tenantID, deptID)
|
||||
if err != nil {
|
||||
return packageEffectiveBaseCosts{}, err
|
||||
}
|
||||
if parentID <= 0 {
|
||||
return packageSystemBaseCosts(pkg), nil
|
||||
}
|
||||
|
||||
parentBase, err := resolvePackageEffectiveBaseCostsDepth(ctx, tx, tenantID, parentID, pkg, visited)
|
||||
if err != nil {
|
||||
return packageEffectiveBaseCosts{}, err
|
||||
}
|
||||
parentRule, err := resolvePackageEffectivePriceRule(ctx, tx, tenantID, parentID, deptID, pkg)
|
||||
if err != nil {
|
||||
return packageEffectiveBaseCosts{}, err
|
||||
}
|
||||
return packageEffectiveBaseCosts{
|
||||
AgentCost: roundAmount(parentBase.AgentCost*parentRule.AgentCostRatio + parentRule.AgentCostProfit),
|
||||
TerminalCost: roundAmount(parentBase.TerminalCost*parentRule.TerminalCostRatio + parentRule.TerminalCostProfit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func packageSystemBaseCosts(pkg packagePriceSource) packageEffectiveBaseCosts {
|
||||
return packageEffectiveBaseCosts{
|
||||
AgentCost: roundAmount(pkg.CostPrice),
|
||||
TerminalCost: roundAmount(pkg.TerminalCost),
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePackageEffectivePriceRule(ctx context.Context, tx gdb.TX, tenantID string, ownerDeptID int64, targetDeptID int64, pkg packagePriceSource) (packageEffectivePriceRule, error) {
|
||||
rule := packageSystemPriceRule(pkg)
|
||||
if ownerDeptID <= 0 {
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
if targetDeptID > 0 {
|
||||
targetPrice, err := loadPackagePriceOverrideForCalc(ctx, tx, tenantID, ownerDeptID, 1, targetDeptID, pkg.PackageCode)
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
if targetPrice != nil {
|
||||
return packagePriceRuleFromOverride("target", targetPrice), nil
|
||||
}
|
||||
}
|
||||
|
||||
defaultPrice, err := loadPackagePriceOverrideForCalc(ctx, tx, tenantID, ownerDeptID, 0, 0, pkg.PackageCode)
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
if defaultPrice != nil {
|
||||
return packagePriceRuleFromOverride("agentDefault", defaultPrice), nil
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func packageSystemPriceRule(pkg packagePriceSource) packageEffectivePriceRule {
|
||||
return packageEffectivePriceRule{
|
||||
PriceSource: "system",
|
||||
AgentCostRatio: normalizePriceRatio(pkg.SysDefaultRatio),
|
||||
AgentCostProfit: pkg.SysDefaultProfit,
|
||||
TerminalCostRatio: normalizePriceRatio(pkg.SysDefaultTerminalRatio),
|
||||
TerminalCostProfit: pkg.SysDefaultTerminalProfit,
|
||||
}
|
||||
}
|
||||
|
||||
func packagePriceRuleFromOverride(priceSource string, price *entity.TSimFlowPackagePrice) packageEffectivePriceRule {
|
||||
if price == nil {
|
||||
return packageEffectivePriceRule{PriceSource: priceSource}
|
||||
}
|
||||
rule := packageEffectivePriceRule{
|
||||
PriceSource: priceSource,
|
||||
AgentCostRatio: normalizePriceRatio(price.AgentCostRatio),
|
||||
AgentCostProfit: price.AgentCostProfit,
|
||||
TerminalCostRatio: normalizePriceRatio(price.UserCostRatio),
|
||||
TerminalCostProfit: price.UserCostProfit,
|
||||
Remark: price.Remark,
|
||||
UpdatedAt: price.UpdatedAt,
|
||||
}
|
||||
if priceSource == "target" {
|
||||
rule.TargetPriceId = price.Id
|
||||
} else {
|
||||
rule.AgentDefaultPriceId = price.Id
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func loadPackagePriceParentDeptID(ctx context.Context, tx gdb.TX, tenantID string, deptID int64) (int64, error) {
|
||||
if deptID <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
dept := &entity.SysDept{}
|
||||
columns := dao.SysDept.Columns()
|
||||
err := packagePriceCalcModel(ctx, tx, dao.SysDept.Table()).
|
||||
Fields(columns.DeptId, columns.ParentId).
|
||||
Where(columns.TenantId, tenantID).
|
||||
Where(columns.DeptId, deptID).
|
||||
WhereNull(columns.DeletedAt).
|
||||
Scan(dept)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, gerror.New("组织不存在")
|
||||
}
|
||||
return 0, gerror.Wrap(err, "查询组织上级失败")
|
||||
}
|
||||
if dept.DeptId == 0 {
|
||||
return 0, gerror.New("组织不存在")
|
||||
}
|
||||
return dept.ParentId, nil
|
||||
}
|
||||
|
||||
func loadPackagePriceOverrideForCalc(ctx context.Context, tx gdb.TX, tenantID string, deptID int64, scopeType int, targetAgentID int64, packageCode string) (*entity.TSimFlowPackagePrice, error) {
|
||||
packageCode = strings.TrimSpace(packageCode)
|
||||
if tenantID == "" || deptID <= 0 || packageCode == "" {
|
||||
return nil, nil
|
||||
}
|
||||
price := &entity.TSimFlowPackagePrice{}
|
||||
columns := dao.TSimFlowPackagePrice.Columns()
|
||||
err := packagePriceCalcModel(ctx, tx, dao.TSimFlowPackagePrice.Table()).
|
||||
Where(columns.TenantId, tenantID).
|
||||
Where(columns.DeptId, deptID).
|
||||
Where(columns.AgentScopeType, scopeType).
|
||||
Where(columns.TargetAgentId, targetAgentID).
|
||||
Where(columns.PackageCode, packageCode).
|
||||
Where(columns.Enabled, 1).
|
||||
Scan(price)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, gerror.Wrap(err, "查询套餐定价失败")
|
||||
}
|
||||
if price.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return price, nil
|
||||
}
|
||||
|
||||
func packagePriceCalcModel(ctx context.Context, tx gdb.TX, table string) *gdb.Model {
|
||||
if tx != nil {
|
||||
return tx.Ctx(ctx).Model(table)
|
||||
}
|
||||
return g.DB().Ctx(ctx).Model(table)
|
||||
}
|
||||
|
||||
func addPackageDefaultPaymentMode(isTopDept bool) string {
|
||||
return "balance"
|
||||
}
|
||||
|
||||
func normalizeAddPackagePaymentIntent(paymentMode string, offlineContractCompleted bool, isTopDept bool) (string, bool, error) {
|
||||
paymentMode = strings.TrimSpace(paymentMode)
|
||||
if paymentMode == "" {
|
||||
paymentMode = addPackageDefaultPaymentMode(isTopDept)
|
||||
}
|
||||
switch paymentMode {
|
||||
case "balance":
|
||||
return "balance", false, nil
|
||||
case "offline":
|
||||
if !isTopDept {
|
||||
return "", false, gerror.New("当前组织加油包必须使用余额支付")
|
||||
}
|
||||
if !offlineContractCompleted {
|
||||
return "", false, gerror.New("请确认线下合同、销售和走账已完成")
|
||||
}
|
||||
return "offline", true, nil
|
||||
default:
|
||||
return "", false, gerror.New("加油包付款方式不正确")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAddPackagePaymentMode(ctx context.Context, paymentMode string, offlineContractCompleted bool) (string, bool, error) {
|
||||
isTopDept, _, err := service.AgentDept().IsTopDept(ctx)
|
||||
if err != nil {
|
||||
return "", false, gerror.Wrap(err, "判断当前组织类型失败")
|
||||
}
|
||||
return normalizeAddPackagePaymentIntent(paymentMode, offlineContractCompleted, isTopDept)
|
||||
}
|
||||
|
||||
func queryAddPackagePaymentContext(ctx context.Context) (*model.TSimAddPackageOptionsRes, error) {
|
||||
isTopDept, _, err := service.AgentDept().IsTopDept(ctx)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "判断当前组织类型失败")
|
||||
}
|
||||
availableBalance, err := queryAddPackageAvailableBalance(ctx, contexts.GetDeptId(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.TSimAddPackageOptionsRes{
|
||||
Items: []*model.TSimAddPackageOptionModel{},
|
||||
IsTopDept: isTopDept,
|
||||
DefaultPaymentMode: addPackageDefaultPaymentMode(isTopDept),
|
||||
AvailableBalance: availableBalance,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func queryAddPackageAvailableBalance(ctx context.Context, deptID int64) (float64, error) {
|
||||
if deptID <= 0 || strings.TrimSpace(contexts.GetTenantId(ctx)) == "" {
|
||||
return 0, gerror.New("当前用户未绑定组织或租户")
|
||||
}
|
||||
var account struct {
|
||||
ID int64 `orm:"id"`
|
||||
Balance float64 `orm:"balance"`
|
||||
}
|
||||
columns := dao.TAgentBalance.Columns()
|
||||
if err := dao.TAgentBalance.Ctx(ctx).
|
||||
Fields(columns.Id, columns.Balance).
|
||||
Where(columns.DeptId, deptID).
|
||||
Where(columns.TenantId, contexts.GetTenantId(ctx)).
|
||||
Scan(&account); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, gerror.Wrap(err, "查询账户余额失败")
|
||||
}
|
||||
if account.ID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return roundAmount(account.Balance), nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolvePackageEffectiveSalePriceUsesSystemDefaults(t *testing.T) {
|
||||
got, err := resolvePackageEffectiveSalePrice(
|
||||
context.Background(),
|
||||
nil,
|
||||
"tenant-1",
|
||||
0,
|
||||
0,
|
||||
packagePriceSource{
|
||||
PackageCode: "PKG-1",
|
||||
CostPrice: 10,
|
||||
TerminalCost: 20,
|
||||
SysDefaultRatio: 80,
|
||||
SysDefaultProfit: 1,
|
||||
SysDefaultTerminalRatio: 50,
|
||||
SysDefaultTerminalProfit: 2,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePackageEffectiveSalePrice() error = %v", err)
|
||||
}
|
||||
if got.AgentShipmentPrice != 9 {
|
||||
t.Fatalf("agent shipment price = %v, want 9", got.AgentShipmentPrice)
|
||||
}
|
||||
if got.TerminalSalePrice != 12 {
|
||||
t.Fatalf("terminal sale price = %v, want 12", got.TerminalSalePrice)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package packageduration
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
const (
|
||||
FlowCycleUnitMonth = "month"
|
||||
RenewUnitYear = "year"
|
||||
)
|
||||
|
||||
// Policy is the normalized duration contract used by package-related business.
|
||||
// The database keeps the existing fields; this type separates their meanings.
|
||||
type Policy struct {
|
||||
FlowCycleUnit string
|
||||
RenewUnit string
|
||||
RenewableYears []int
|
||||
ChangeMonths []int
|
||||
}
|
||||
|
||||
func FromPackage(pkg *entity.TSimFlowPackageInfo) (*Policy, error) {
|
||||
if pkg == nil {
|
||||
return nil, gerror.New("package information is empty")
|
||||
}
|
||||
years, err := RenewableYears(pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
months, err := ChangeMonths(pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Policy{
|
||||
FlowCycleUnit: FlowCycleUnitMonth,
|
||||
RenewUnit: RenewUnitYear,
|
||||
RenewableYears: years,
|
||||
ChangeMonths: months,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func MonthBillingMethodValue() string {
|
||||
return strconv.Itoa(consts.BillingMethodMonth)
|
||||
}
|
||||
|
||||
func RenewableYears(pkg *entity.TSimFlowPackageInfo) ([]int, error) {
|
||||
if pkg == nil {
|
||||
return nil, gerror.New("package information is empty")
|
||||
}
|
||||
return renewableYearsFromConfig(pkg.RenewableDurationConfig)
|
||||
}
|
||||
|
||||
func ChangeMonths(pkg *entity.TSimFlowPackageInfo) ([]int, error) {
|
||||
if pkg == nil {
|
||||
return nil, gerror.New("package information is empty")
|
||||
}
|
||||
values, legacyMonthConfig, err := rawDurationValues(pkg.RenewableDurationConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if legacyMonthConfig {
|
||||
return uniqueSorted(values), nil
|
||||
}
|
||||
|
||||
months := make([]int, 0, len(values))
|
||||
for _, year := range values {
|
||||
if year <= 0 {
|
||||
continue
|
||||
}
|
||||
months = append(months, year*12)
|
||||
}
|
||||
months = uniqueSorted(months)
|
||||
if len(months) == 0 {
|
||||
return nil, gerror.New("package has no valid change duration options")
|
||||
}
|
||||
return months, nil
|
||||
}
|
||||
|
||||
// NormalizeRenewableDurationConfig converts legacy month values such as
|
||||
// [12,24,36] into the new year values [1,2,3] before saving a package.
|
||||
func NormalizeRenewableDurationConfig(config *gjson.Json) (*gjson.Json, error) {
|
||||
values, legacyMonthConfig, err := rawDurationValues(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if legacyMonthConfig {
|
||||
years := make([]int, 0, len(values))
|
||||
for _, month := range values {
|
||||
if month > 0 && month%12 == 0 {
|
||||
years = append(years, month/12)
|
||||
}
|
||||
}
|
||||
years = uniqueSorted(years)
|
||||
if len(years) == 0 {
|
||||
return nil, gerror.New("package has no valid renewable duration options")
|
||||
}
|
||||
return gjson.New(years), nil
|
||||
}
|
||||
return gjson.New(uniqueSorted(values)), nil
|
||||
}
|
||||
|
||||
func NormalizeRenewableYears(values []int) []int {
|
||||
values = uniqueSorted(values)
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
legacyMonthConfig := true
|
||||
for _, value := range values {
|
||||
if value < 12 || value%12 != 0 {
|
||||
legacyMonthConfig = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !legacyMonthConfig {
|
||||
return values
|
||||
}
|
||||
years := make([]int, 0, len(values))
|
||||
for _, month := range values {
|
||||
if month > 0 && month%12 == 0 {
|
||||
years = append(years, month/12)
|
||||
}
|
||||
}
|
||||
return uniqueSorted(years)
|
||||
}
|
||||
|
||||
func RenewMonths(years int) int {
|
||||
if years <= 0 {
|
||||
return 0
|
||||
}
|
||||
return years * 12
|
||||
}
|
||||
|
||||
func renewableYearsFromConfig(config *gjson.Json) ([]int, error) {
|
||||
values, legacyMonthConfig, err := rawDurationValues(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
years := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
if legacyMonthConfig {
|
||||
if value%12 != 0 {
|
||||
continue
|
||||
}
|
||||
value /= 12
|
||||
}
|
||||
years = append(years, value)
|
||||
}
|
||||
years = uniqueSorted(years)
|
||||
if len(years) == 0 {
|
||||
return nil, gerror.New("package has no valid renewable duration options")
|
||||
}
|
||||
return years, nil
|
||||
}
|
||||
|
||||
func rawDurationValues(config *gjson.Json) ([]int, bool, error) {
|
||||
if config == nil || config.IsNil() {
|
||||
return nil, false, gerror.New("package has no renewable duration config")
|
||||
}
|
||||
values := config.Interfaces()
|
||||
if len(values) == 0 {
|
||||
return nil, false, gerror.New("package has no renewable duration config")
|
||||
}
|
||||
|
||||
result := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
parsed := gconv.Int(value)
|
||||
if parsed > 0 {
|
||||
result = append(result, parsed)
|
||||
}
|
||||
}
|
||||
result = uniqueSorted(result)
|
||||
if len(result) == 0 {
|
||||
return nil, false, gerror.New("package has no valid renewable duration options")
|
||||
}
|
||||
|
||||
legacyMonthConfig := true
|
||||
for _, value := range result {
|
||||
if value < 12 || value%12 != 0 {
|
||||
legacyMonthConfig = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, legacyMonthConfig, nil
|
||||
}
|
||||
|
||||
func uniqueSorted(values []int) []int {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int]struct{}, len(values))
|
||||
result := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Ints(result)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package packageprice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type Source struct {
|
||||
PackageCode string
|
||||
CostPrice float64
|
||||
TerminalCost float64
|
||||
SysDefaultRatio float64
|
||||
SysDefaultProfit float64
|
||||
SysDefaultTerminalRatio float64
|
||||
SysDefaultTerminalProfit float64
|
||||
}
|
||||
|
||||
type BaseCosts struct {
|
||||
AgentCost float64
|
||||
TerminalCost float64
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
PriceSource string
|
||||
AgentDefaultPriceId int64
|
||||
TargetPriceId int64
|
||||
AgentCostRatio float64
|
||||
AgentCostProfit float64
|
||||
TerminalCostRatio float64
|
||||
TerminalCostProfit float64
|
||||
Remark string
|
||||
UpdatedAt *gtime.Time
|
||||
}
|
||||
|
||||
type SalePrice struct {
|
||||
Base BaseCosts
|
||||
Rule Rule
|
||||
AgentShipmentPrice float64
|
||||
TerminalSalePrice float64
|
||||
}
|
||||
|
||||
type priceStore interface {
|
||||
ParentDeptID(ctx context.Context, tenantID string, deptID int64) (int64, error)
|
||||
PriceOverride(ctx context.Context, tenantID string, deptID int64, scopeType int, targetAgentID int64, packageCode string) (*entity.TSimFlowPackagePrice, error)
|
||||
}
|
||||
|
||||
type dbStore struct {
|
||||
tx gdb.TX
|
||||
}
|
||||
|
||||
func SourceFromPackageInfo(pkg *entity.TSimFlowPackageInfo) Source {
|
||||
if pkg == nil {
|
||||
return Source{}
|
||||
}
|
||||
return Source{
|
||||
PackageCode: strings.TrimSpace(pkg.PackageCode),
|
||||
CostPrice: pkg.CostPrice,
|
||||
TerminalCost: pkg.TerminalCost,
|
||||
SysDefaultRatio: pkg.SysDefaultRatio,
|
||||
SysDefaultProfit: pkg.SysDefaultProfit,
|
||||
SysDefaultTerminalRatio: pkg.SysDefaultTerminalRatio,
|
||||
SysDefaultTerminalProfit: pkg.SysDefaultTerminalProfit,
|
||||
}
|
||||
}
|
||||
|
||||
func SourceFromPriceListItem(pkg *model.TSimFlowPackagePriceAgentDefaultPackagePricePackageItem) Source {
|
||||
if pkg == nil {
|
||||
return Source{}
|
||||
}
|
||||
return Source{
|
||||
PackageCode: strings.TrimSpace(pkg.PackageCode),
|
||||
CostPrice: pkg.CostPrice,
|
||||
TerminalCost: pkg.TerminalCost,
|
||||
SysDefaultRatio: pkg.SysDefaultRatio,
|
||||
SysDefaultProfit: pkg.SysDefaultProfit,
|
||||
SysDefaultTerminalRatio: pkg.SysDefaultTerminalRatio,
|
||||
SysDefaultTerminalProfit: pkg.SysDefaultTerminalProfit,
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveEffectiveBaseCosts(ctx context.Context, tx gdb.TX, tenantID string, deptID int64, pkg Source) (BaseCosts, error) {
|
||||
return resolveEffectiveBaseCosts(ctx, dbStore{tx: tx}, tenantID, deptID, pkg)
|
||||
}
|
||||
|
||||
func ResolveEffectiveSalePrice(ctx context.Context, tx gdb.TX, tenantID string, ownerDeptID int64, targetDeptID int64, pkg Source) (SalePrice, error) {
|
||||
return resolveEffectiveSalePrice(ctx, dbStore{tx: tx}, tenantID, ownerDeptID, targetDeptID, pkg)
|
||||
}
|
||||
|
||||
func NormalizeRatio(ratio float64) float64 {
|
||||
if ratio > 10 {
|
||||
return ratio / 100
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
|
||||
func RoundAmount(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
func resolveEffectiveBaseCosts(ctx context.Context, store priceStore, tenantID string, deptID int64, pkg Source) (BaseCosts, error) {
|
||||
visited := make(map[int64]struct{})
|
||||
return resolveEffectiveBaseCostsDepth(ctx, store, tenantID, deptID, pkg, visited)
|
||||
}
|
||||
|
||||
func resolveEffectiveSalePrice(ctx context.Context, store priceStore, tenantID string, ownerDeptID int64, targetDeptID int64, pkg Source) (SalePrice, error) {
|
||||
base, err := resolveEffectiveBaseCosts(ctx, store, tenantID, ownerDeptID, pkg)
|
||||
if err != nil {
|
||||
return SalePrice{}, err
|
||||
}
|
||||
rule, err := resolveEffectivePriceRule(ctx, store, tenantID, ownerDeptID, targetDeptID, pkg)
|
||||
if err != nil {
|
||||
return SalePrice{}, err
|
||||
}
|
||||
return SalePrice{
|
||||
Base: base,
|
||||
Rule: rule,
|
||||
AgentShipmentPrice: RoundAmount(base.AgentCost*rule.AgentCostRatio + rule.AgentCostProfit),
|
||||
TerminalSalePrice: RoundAmount(base.TerminalCost*rule.TerminalCostRatio + rule.TerminalCostProfit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveEffectiveBaseCostsDepth(ctx context.Context, store priceStore, tenantID string, deptID int64, pkg Source, visited map[int64]struct{}) (BaseCosts, error) {
|
||||
if strings.TrimSpace(pkg.PackageCode) == "" {
|
||||
return BaseCosts{}, gerror.New("套餐编码不能为空")
|
||||
}
|
||||
if deptID <= 0 {
|
||||
return systemBaseCosts(pkg), nil
|
||||
}
|
||||
if _, ok := visited[deptID]; ok {
|
||||
return BaseCosts{}, gerror.New("组织定价层级存在循环,无法计算生效成本")
|
||||
}
|
||||
visited[deptID] = struct{}{}
|
||||
|
||||
parentID, err := store.ParentDeptID(ctx, tenantID, deptID)
|
||||
if err != nil {
|
||||
return BaseCosts{}, err
|
||||
}
|
||||
if parentID <= 0 {
|
||||
return systemBaseCosts(pkg), nil
|
||||
}
|
||||
|
||||
parentBase, err := resolveEffectiveBaseCostsDepth(ctx, store, tenantID, parentID, pkg, visited)
|
||||
if err != nil {
|
||||
return BaseCosts{}, err
|
||||
}
|
||||
parentRule, err := resolveEffectivePriceRule(ctx, store, tenantID, parentID, deptID, pkg)
|
||||
if err != nil {
|
||||
return BaseCosts{}, err
|
||||
}
|
||||
return BaseCosts{
|
||||
AgentCost: RoundAmount(parentBase.AgentCost*parentRule.AgentCostRatio + parentRule.AgentCostProfit),
|
||||
TerminalCost: RoundAmount(parentBase.TerminalCost*parentRule.TerminalCostRatio + parentRule.TerminalCostProfit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func systemBaseCosts(pkg Source) BaseCosts {
|
||||
return BaseCosts{
|
||||
AgentCost: RoundAmount(pkg.CostPrice),
|
||||
TerminalCost: RoundAmount(pkg.TerminalCost),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveEffectivePriceRule(ctx context.Context, store priceStore, tenantID string, ownerDeptID int64, targetDeptID int64, pkg Source) (Rule, error) {
|
||||
rule := systemPriceRule(pkg)
|
||||
if ownerDeptID <= 0 {
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
if targetDeptID > 0 {
|
||||
targetPrice, err := store.PriceOverride(ctx, tenantID, ownerDeptID, 1, targetDeptID, pkg.PackageCode)
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
if targetPrice != nil {
|
||||
return ruleFromOverride("target", targetPrice), nil
|
||||
}
|
||||
}
|
||||
|
||||
defaultPrice, err := store.PriceOverride(ctx, tenantID, ownerDeptID, 0, 0, pkg.PackageCode)
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
if defaultPrice != nil {
|
||||
return ruleFromOverride("agentDefault", defaultPrice), nil
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func systemPriceRule(pkg Source) Rule {
|
||||
return Rule{
|
||||
PriceSource: "system",
|
||||
AgentCostRatio: NormalizeRatio(pkg.SysDefaultRatio),
|
||||
AgentCostProfit: pkg.SysDefaultProfit,
|
||||
TerminalCostRatio: NormalizeRatio(pkg.SysDefaultTerminalRatio),
|
||||
TerminalCostProfit: pkg.SysDefaultTerminalProfit,
|
||||
}
|
||||
}
|
||||
|
||||
func ruleFromOverride(priceSource string, price *entity.TSimFlowPackagePrice) Rule {
|
||||
if price == nil {
|
||||
return Rule{PriceSource: priceSource}
|
||||
}
|
||||
rule := Rule{
|
||||
PriceSource: priceSource,
|
||||
AgentCostRatio: NormalizeRatio(price.AgentCostRatio),
|
||||
AgentCostProfit: price.AgentCostProfit,
|
||||
TerminalCostRatio: NormalizeRatio(price.UserCostRatio),
|
||||
TerminalCostProfit: price.UserCostProfit,
|
||||
Remark: price.Remark,
|
||||
UpdatedAt: price.UpdatedAt,
|
||||
}
|
||||
if priceSource == "target" {
|
||||
rule.TargetPriceId = price.Id
|
||||
} else {
|
||||
rule.AgentDefaultPriceId = price.Id
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func (s dbStore) ParentDeptID(ctx context.Context, tenantID string, deptID int64) (int64, error) {
|
||||
if deptID <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
dept := &entity.SysDept{}
|
||||
columns := dao.SysDept.Columns()
|
||||
err := s.model(ctx, dao.SysDept.Table()).
|
||||
Fields(columns.DeptId, columns.ParentId).
|
||||
Where(columns.TenantId, tenantID).
|
||||
Where(columns.DeptId, deptID).
|
||||
WhereNull(columns.DeletedAt).
|
||||
Scan(dept)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, gerror.New("组织不存在")
|
||||
}
|
||||
return 0, gerror.Wrap(err, "查询组织上级失败")
|
||||
}
|
||||
if dept.DeptId == 0 {
|
||||
return 0, gerror.New("组织不存在")
|
||||
}
|
||||
return dept.ParentId, nil
|
||||
}
|
||||
|
||||
func (s dbStore) PriceOverride(ctx context.Context, tenantID string, deptID int64, scopeType int, targetAgentID int64, packageCode string) (*entity.TSimFlowPackagePrice, error) {
|
||||
packageCode = strings.TrimSpace(packageCode)
|
||||
if tenantID == "" || deptID <= 0 || packageCode == "" {
|
||||
return nil, nil
|
||||
}
|
||||
price := &entity.TSimFlowPackagePrice{}
|
||||
columns := dao.TSimFlowPackagePrice.Columns()
|
||||
err := s.model(ctx, dao.TSimFlowPackagePrice.Table()).
|
||||
Where(columns.TenantId, tenantID).
|
||||
Where(columns.DeptId, deptID).
|
||||
Where(columns.AgentScopeType, scopeType).
|
||||
Where(columns.TargetAgentId, targetAgentID).
|
||||
Where(columns.PackageCode, packageCode).
|
||||
Where(columns.Enabled, 1).
|
||||
Scan(price)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, gerror.Wrap(err, "查询套餐定价失败")
|
||||
}
|
||||
if price.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return price, nil
|
||||
}
|
||||
|
||||
func (s dbStore) model(ctx context.Context, table string) *gdb.Model {
|
||||
if s.tx != nil {
|
||||
return s.tx.Ctx(ctx).Model(table)
|
||||
}
|
||||
return g.DB().Ctx(ctx).Model(table)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package packageprice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
type fakePriceStore struct {
|
||||
parents map[int64]int64
|
||||
prices map[priceKey]*entity.TSimFlowPackagePrice
|
||||
}
|
||||
|
||||
type priceKey struct {
|
||||
deptID int64
|
||||
scopeType int
|
||||
targetAgentID int64
|
||||
packageCode string
|
||||
}
|
||||
|
||||
func (s fakePriceStore) ParentDeptID(ctx context.Context, tenantID string, deptID int64) (int64, error) {
|
||||
return s.parents[deptID], nil
|
||||
}
|
||||
|
||||
func (s fakePriceStore) PriceOverride(ctx context.Context, tenantID string, deptID int64, scopeType int, targetAgentID int64, packageCode string) (*entity.TSimFlowPackagePrice, error) {
|
||||
return s.prices[priceKey{
|
||||
deptID: deptID,
|
||||
scopeType: scopeType,
|
||||
targetAgentID: targetAgentID,
|
||||
packageCode: packageCode,
|
||||
}], nil
|
||||
}
|
||||
|
||||
func TestResolveEffectiveSalePriceUsesRecursiveParentCost(t *testing.T) {
|
||||
store := fakePriceStore{
|
||||
parents: map[int64]int64{
|
||||
117: 100,
|
||||
131: 117,
|
||||
},
|
||||
prices: map[priceKey]*entity.TSimFlowPackagePrice{
|
||||
{deptID: 100, scopeType: 1, targetAgentID: 117, packageCode: "yd-100m-3y"}: {
|
||||
Id: 86,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 4,
|
||||
UserCostRatio: 1,
|
||||
UserCostProfit: 4,
|
||||
},
|
||||
{deptID: 117, scopeType: 0, targetAgentID: 0, packageCode: "yd-100m-3y"}: {
|
||||
Id: 91,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 1,
|
||||
UserCostRatio: 1,
|
||||
UserCostProfit: 1,
|
||||
},
|
||||
{deptID: 117, scopeType: 1, targetAgentID: 131, packageCode: "yd-100m-3y"}: {
|
||||
Id: 92,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 5,
|
||||
UserCostRatio: 1,
|
||||
UserCostProfit: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := resolveEffectiveSalePrice(
|
||||
context.Background(),
|
||||
store,
|
||||
"000000",
|
||||
117,
|
||||
131,
|
||||
Source{
|
||||
PackageCode: "yd-100m-3y",
|
||||
CostPrice: 10,
|
||||
TerminalCost: 10,
|
||||
SysDefaultRatio: 1,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveEffectiveSalePrice() error = %v", err)
|
||||
}
|
||||
if got.Base.AgentCost != 14 {
|
||||
t.Fatalf("base agent cost = %.2f, want 14.00", got.Base.AgentCost)
|
||||
}
|
||||
if got.AgentShipmentPrice != 19 {
|
||||
t.Fatalf("agent shipment price = %.2f, want 19.00", got.AgentShipmentPrice)
|
||||
}
|
||||
if got.Rule.PriceSource != "target" || got.Rule.TargetPriceId != 92 {
|
||||
t.Fatalf("rule = %+v, want target price 92", got.Rule)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
)
|
||||
|
||||
// EnqueueSimCardStatus is intentionally separate from SyncSimCardStatus. It
|
||||
// keeps the tenant/permission-filtered card lookup in Admin, then publishes a
|
||||
// complete routing message so Worker can process it asynchronously.
|
||||
func (s *sIotTSimCardInfo) EnqueueSimCardStatus(ctx context.Context, input *model.SyncSimCardStatusInput) error {
|
||||
if input == nil || input.Iccid == "" {
|
||||
return gerror.New("iccid is required")
|
||||
}
|
||||
|
||||
var card entity.TSimCardInfo
|
||||
if err := s.Model(ctx, &handler.Option{FilterTenant: true, FilterAuth: true}).
|
||||
Where(dao.TSimCardInfo.Columns().Iccid, input.Iccid).
|
||||
Limit(1).
|
||||
Scan(&card); err != nil {
|
||||
return gerror.Wrap(err, "load card for queued status sync")
|
||||
}
|
||||
if card.Id <= 0 || card.ChanKey == "" {
|
||||
return gerror.New("card not found or channel is empty")
|
||||
}
|
||||
|
||||
var channel entity.TSimChannels
|
||||
channelQuery := dao.TSimChannels.Ctx(ctx).
|
||||
Where(dao.TSimChannels.Columns().ChanKey, card.ChanKey)
|
||||
if card.TenantId != "" {
|
||||
channelQuery = channelQuery.Where(dao.TSimChannels.Columns().TenantId, card.TenantId)
|
||||
}
|
||||
if err := channelQuery.Limit(1).Scan(&channel); err != nil {
|
||||
return gerror.Wrap(err, "load channel for queued status sync")
|
||||
}
|
||||
if channel.ChanKey == "" || channel.TempKey == "" {
|
||||
return gerror.New("channel template is empty")
|
||||
}
|
||||
|
||||
return service.SimStatusQueue().EnqueueCard(ctx, model.SimStatusQueueTaskMessage{
|
||||
CardID: card.Id,
|
||||
ICCID: card.Iccid,
|
||||
MSISDN: card.Msisdn,
|
||||
IMSI: card.Imsi,
|
||||
ChanKey: card.ChanKey,
|
||||
TempKey: channel.TempKey,
|
||||
Operator: channel.Operator,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package addpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type AsyncConfirmInput struct {
|
||||
BatchNo string
|
||||
TargetBatchType int
|
||||
PackageCode string
|
||||
Remark string
|
||||
PaymentMode string
|
||||
OfflineContractCompleted bool
|
||||
EffectiveMode string
|
||||
Operator batchimport.OperatorContext
|
||||
}
|
||||
|
||||
type AsyncConfirmResult struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
BatchAsyncStatus int
|
||||
}
|
||||
|
||||
func ConvertPrepareToAsync(ctx context.Context, tx gdb.TX, in *AsyncConfirmInput) (*AsyncConfirmResult, error) {
|
||||
if in == nil {
|
||||
return nil, gerror.New("加油包确认参数不能为空")
|
||||
}
|
||||
batchNo := strings.TrimSpace(in.BatchNo)
|
||||
if batchNo == "" {
|
||||
return nil, gerror.New("加油包批次号不能为空")
|
||||
}
|
||||
targetBatchType := in.TargetBatchType
|
||||
if targetBatchType != consts.BatchTypeCardAddPackage && targetBatchType != consts.BatchTypePoolAddPackage {
|
||||
return nil, gerror.New("加油包目标批次类型不正确")
|
||||
}
|
||||
|
||||
columns := dao.BatchMain.Columns()
|
||||
main := &entity.BatchMain{}
|
||||
err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(columns.BatchNo, batchNo).
|
||||
Where(columns.TenantId, contexts.GetTenantId(ctx)).
|
||||
Where(columns.CreatedDept, contexts.GetDeptId(ctx)).
|
||||
LockUpdate().
|
||||
Scan(main)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询加油包预处理批次失败")
|
||||
}
|
||||
if main.Id == 0 {
|
||||
return nil, gerror.New("加油包预处理批次不存在")
|
||||
}
|
||||
if main.BatchType != consts.BatchTypePrepare {
|
||||
return nil, gerror.New("加油包批次不是预处理类型")
|
||||
}
|
||||
if main.BatchAsyncStatus != consts.BatchAsyncStatusSuccess {
|
||||
return nil, gerror.New("加油包预处理批次尚未完成")
|
||||
}
|
||||
if main.RunResult != consts.BatchRunResultSuccess {
|
||||
return nil, gerror.New("加油包预处理批次未成功")
|
||||
}
|
||||
if main.BatchInfo == nil {
|
||||
return nil, gerror.New("加油包预处理批次缺少快照")
|
||||
}
|
||||
if main.BatchInfo.Get("targetBatchType").Int() != targetBatchType {
|
||||
return nil, gerror.New("加油包预处理批次类型不匹配")
|
||||
}
|
||||
|
||||
opts, err := normalizeConfirmOptions(main.BatchInfo, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := gtime.Now()
|
||||
info := mergeConfirmBatchInfo(main.BatchInfo, opts)
|
||||
data := do.BatchMain{
|
||||
BatchType: targetBatchType,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
RunResult: consts.BatchRunResultPending,
|
||||
StartTime: nil,
|
||||
EndTime: nil,
|
||||
FailReason: "",
|
||||
ValidCount: 0,
|
||||
SuccessCount: 0,
|
||||
FailCount: 0,
|
||||
BatchInfo: gjson.New(info),
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
res, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(columns.Id, main.Id).
|
||||
Where(columns.BatchType, consts.BatchTypePrepare).
|
||||
Where(columns.BatchAsyncStatus, consts.BatchAsyncStatusSuccess).
|
||||
Where(columns.RunResult, consts.BatchRunResultSuccess).
|
||||
Data(data).
|
||||
Update()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "更新加油包批次状态失败")
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "读取加油包批次更新结果失败")
|
||||
}
|
||||
if affected != 1 {
|
||||
return nil, gerror.New("加油包批次状态已变化,请重试")
|
||||
}
|
||||
|
||||
detailColumns := dao.BatchDetail.Columns()
|
||||
var details []*entity.BatchDetail
|
||||
if err = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Fields(detailColumns.Id, detailColumns.RowNo, detailColumns.Snapshot).
|
||||
Where(detailColumns.BatchNo, batchNo).
|
||||
Where(detailColumns.BatchType, consts.BatchTypePrepare).
|
||||
Scan(&details); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询加油包预处理明细失败")
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil, gerror.New("加油包预处理批次没有可确认明细")
|
||||
}
|
||||
for _, detail := range details {
|
||||
if detail == nil || detail.Id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(detailColumns.Id, detail.Id).
|
||||
Where(detailColumns.BatchNo, batchNo).
|
||||
Where(detailColumns.BatchType, consts.BatchTypePrepare).
|
||||
Data(do.BatchDetail{
|
||||
BatchType: targetBatchType,
|
||||
DetailStatus: consts.BatchDetailStatusPending,
|
||||
ErrorCode: "",
|
||||
ErrorReason: "",
|
||||
Snapshot: mergeConfirmDetailSnapshot(detail.Snapshot, opts),
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}).
|
||||
Update(); err != nil {
|
||||
return nil, gerror.Wrap(err, "更新加油包批次明细类型失败")
|
||||
}
|
||||
}
|
||||
|
||||
return &AsyncConfirmResult{
|
||||
BatchNo: batchNo,
|
||||
BatchType: targetBatchType,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeConfirmOptions(info *gjson.Json, in *AsyncConfirmInput) (SubmitOptions, error) {
|
||||
opts, err := normalizeOptions(SubmitOptions{
|
||||
PackageCode: in.PackageCode,
|
||||
Remark: in.Remark,
|
||||
PaymentMode: in.PaymentMode,
|
||||
OfflineContractCompleted: in.OfflineContractCompleted,
|
||||
EffectiveMode: in.EffectiveMode,
|
||||
})
|
||||
if err != nil {
|
||||
return SubmitOptions{}, err
|
||||
}
|
||||
preparedPackageCode := strings.TrimSpace(info.Get("packageCode").String())
|
||||
if opts.PackageCode == "" {
|
||||
opts.PackageCode = preparedPackageCode
|
||||
}
|
||||
if opts.PackageCode == "" {
|
||||
return SubmitOptions{}, gerror.New("请选择加油包套餐")
|
||||
}
|
||||
if preparedPackageCode != "" && opts.PackageCode != preparedPackageCode {
|
||||
return SubmitOptions{}, gerror.New("加油包套餐与预处理结果不一致,请重新预处理")
|
||||
}
|
||||
if preparedPackageCode == "" {
|
||||
if err := validatePreparedPackageOption(info, opts.PackageCode); err != nil {
|
||||
return SubmitOptions{}, err
|
||||
}
|
||||
}
|
||||
if err := validatePreparedPaymentEnough(info, opts); err != nil {
|
||||
return SubmitOptions{}, err
|
||||
}
|
||||
preparedEffectiveMode := strings.TrimSpace(info.Get("effectiveMode").String())
|
||||
if preparedEffectiveMode != "" {
|
||||
normalized, err := NormalizeEffectiveMode(preparedEffectiveMode)
|
||||
if err != nil {
|
||||
return SubmitOptions{}, err
|
||||
}
|
||||
if opts.EffectiveMode != normalized {
|
||||
return SubmitOptions{}, gerror.New("加油包生效方式与预处理结果不一致,请重新预处理")
|
||||
}
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func validatePreparedPackageOption(info *gjson.Json, packageCode string) error {
|
||||
if info == nil || info.IsNil() {
|
||||
return nil
|
||||
}
|
||||
packageCode = strings.TrimSpace(packageCode)
|
||||
hasOptions := false
|
||||
for _, key := range []string{"availablePackages", "packageOptions"} {
|
||||
value := info.Get(key)
|
||||
if value == nil || value.IsNil() {
|
||||
continue
|
||||
}
|
||||
hasOptions = true
|
||||
for _, raw := range value.Interfaces() {
|
||||
item := gjson.New(raw)
|
||||
code := strings.TrimSpace(item.Get("packageCode").String())
|
||||
if code == "" {
|
||||
code = strings.TrimSpace(item.Get("value").String())
|
||||
}
|
||||
if code != packageCode {
|
||||
continue
|
||||
}
|
||||
if priceAvailable := item.Get("priceAvailable"); priceAvailable != nil && !priceAvailable.IsNil() && !priceAvailable.Bool() {
|
||||
reason := strings.TrimSpace(item.Get("priceReason").String())
|
||||
if reason == "" {
|
||||
reason = "当前套餐暂时无法计算价格,请联系管理员维护定价"
|
||||
}
|
||||
return gerror.New(reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if hasOptions {
|
||||
return gerror.New("请选择预处理返回的可购加油包套餐")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePreparedPaymentEnough(info *gjson.Json, opts SubmitOptions) error {
|
||||
if strings.TrimSpace(opts.PaymentMode) != "balance" || info == nil || info.IsNil() {
|
||||
return nil
|
||||
}
|
||||
if option := findPreparedPackageOption(info, opts.PackageCode); option != nil {
|
||||
if err := validatePaymentAccountsEnough(option.Get("paymentAccounts")); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasPaymentAccounts(option.Get("paymentAccounts")) {
|
||||
return nil
|
||||
}
|
||||
totalAmount := option.Get("totalAmount").Float64()
|
||||
availableBalance := option.Get("availableBalance").Float64()
|
||||
if availableBalance <= 0 {
|
||||
availableBalance = info.Get("availableBalance").Float64()
|
||||
}
|
||||
if totalAmount > 0 && availableBalance+0.0001 < totalAmount {
|
||||
return gerror.Newf("账户余额不足,需支付%.2f元,当前余额%.2f元", totalAmount, availableBalance)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := validatePaymentAccountsEnough(info.Get("paymentAccounts")); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasPaymentAccounts(info.Get("paymentAccounts")) {
|
||||
return nil
|
||||
}
|
||||
totalAmount := info.Get("totalAmount").Float64()
|
||||
availableBalance := info.Get("availableBalance").Float64()
|
||||
if totalAmount > 0 && availableBalance+0.0001 < totalAmount {
|
||||
return gerror.Newf("账户余额不足,需支付%.2f元,当前余额%.2f元", totalAmount, availableBalance)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findPreparedPackageOption(info *gjson.Json, packageCode string) *gjson.Json {
|
||||
if info == nil || info.IsNil() {
|
||||
return nil
|
||||
}
|
||||
packageCode = strings.TrimSpace(packageCode)
|
||||
for _, key := range []string{"availablePackages", "packageOptions"} {
|
||||
value := info.Get(key)
|
||||
if value == nil || value.IsNil() {
|
||||
continue
|
||||
}
|
||||
for _, raw := range value.Interfaces() {
|
||||
item := gjson.New(raw)
|
||||
code := strings.TrimSpace(item.Get("packageCode").String())
|
||||
if code == "" {
|
||||
code = strings.TrimSpace(item.Get("value").String())
|
||||
}
|
||||
if code == packageCode {
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePaymentAccountsEnough(value *gvar.Var) error {
|
||||
if !hasPaymentAccounts(value) {
|
||||
return nil
|
||||
}
|
||||
for _, raw := range value.Interfaces() {
|
||||
account := gjson.New(raw)
|
||||
requiredAmount := account.Get("requiredAmount").Float64()
|
||||
availableBalance := account.Get("availableBalance").Float64()
|
||||
enoughValue := account.Get("balanceEnough")
|
||||
if enoughValue != nil && !enoughValue.IsNil() {
|
||||
if enoughValue.Bool() {
|
||||
continue
|
||||
}
|
||||
} else if availableBalance+0.0001 >= requiredAmount {
|
||||
continue
|
||||
}
|
||||
return gerror.Newf(
|
||||
"账户余额不足,付款部门%v需支付%.2f元,当前余额%.2f元",
|
||||
account.Get("payerDeptId").Val(),
|
||||
requiredAmount,
|
||||
availableBalance,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasPaymentAccounts(value *gvar.Var) bool {
|
||||
return value != nil && !value.IsNil() && len(value.Interfaces()) > 0
|
||||
}
|
||||
|
||||
func mergeConfirmDetailSnapshot(base *gjson.Json, opts SubmitOptions) *gjson.Json {
|
||||
data := g.Map{}
|
||||
if base != nil {
|
||||
for key, value := range base.Map() {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
data["packageCode"] = strings.TrimSpace(opts.PackageCode)
|
||||
if remark := strings.TrimSpace(opts.Remark); remark != "" {
|
||||
data["remark"] = remark
|
||||
}
|
||||
data["effectiveMode"] = strings.TrimSpace(opts.EffectiveMode)
|
||||
data["effectiveModeText"] = EffectiveModeText(opts.EffectiveMode)
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
func mergeConfirmBatchInfo(base *gjson.Json, opts SubmitOptions) g.Map {
|
||||
data := g.Map{}
|
||||
if base != nil {
|
||||
for key, value := range base.Map() {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
data["packageCode"] = strings.TrimSpace(opts.PackageCode)
|
||||
data["remark"] = strings.TrimSpace(opts.Remark)
|
||||
data["paymentMode"] = strings.TrimSpace(opts.PaymentMode)
|
||||
data["offlineContractCompleted"] = opts.OfflineContractCompleted
|
||||
data["effectiveMode"] = strings.TrimSpace(opts.EffectiveMode)
|
||||
data["effectiveModeText"] = EffectiveModeText(opts.EffectiveMode)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package addpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
const (
|
||||
headerPackageCode = "加油包套餐编码"
|
||||
headerEffectiveMode = "生效方式"
|
||||
headerRemark = "备注"
|
||||
)
|
||||
|
||||
func parseExcelRows(ctx context.Context, filePath string, cfg Config, options SubmitOptions) ([]batchimport.Row, error) {
|
||||
parsedRows, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{
|
||||
RequiredHeaders: []string{cfg.TargetHeader, headerPackageCode},
|
||||
SingleSheet: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(parsedRows))
|
||||
for _, parsed := range parsedRows {
|
||||
row := &Row{
|
||||
RowNo: parsed.RowNo,
|
||||
TargetCode: fieldValue(parsed, targetAliases(cfg)...),
|
||||
PackageCode: fieldValue(parsed, headerPackageCode, "加油包编码", "packageCode", "套餐编码"),
|
||||
EffectiveMode: fieldValue(parsed, headerEffectiveMode, "effectiveMode", "生效模式"),
|
||||
Remark: fieldValue(parsed, headerRemark, "remark"),
|
||||
}
|
||||
applyDefaultOptions(row, options)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func parseInputRows(ctx context.Context, input string, options SubmitOptions) ([]batchimport.Row, error) {
|
||||
lines, err := text.ParseNonEmptyLines(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New("批量输入内容不能为空")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &Row{RowNo: line.RowNo}
|
||||
fields := parseInputFields(line.Text)
|
||||
if len(fields) != 1 {
|
||||
row.AddErrorReason("格式错误,应为目标编码")
|
||||
} else {
|
||||
row.TargetCode = fields[0]
|
||||
}
|
||||
applyDefaultOptions(row, options)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func fieldValue(row excel.ParsedRow, names ...string) string {
|
||||
for _, name := range names {
|
||||
if value := strings.TrimSpace(excel.Field(row, name)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func targetAliases(cfg Config) []string {
|
||||
if cfg.Operation == OperationPool {
|
||||
return []string{cfg.TargetHeader, "流量池编码", "流量池编号", "poolNo", "flowPoolCode", "targetCode"}
|
||||
}
|
||||
return []string{cfg.TargetHeader, "卡号", "iccid", "targetCode"}
|
||||
}
|
||||
|
||||
func parseInputFields(value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(value, "|") {
|
||||
return splitAndTrim(value, "|")
|
||||
}
|
||||
if strings.Contains(value, "\t") {
|
||||
return splitAndTrim(value, "\t")
|
||||
}
|
||||
return strings.Fields(value)
|
||||
}
|
||||
|
||||
func splitAndTrim(value, sep string) []string {
|
||||
parts := strings.Split(value, sep)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
out = append(out, strings.TrimSpace(part))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyDefaultOptions(row *Row, options SubmitOptions) {
|
||||
if row == nil {
|
||||
return
|
||||
}
|
||||
row.TargetCode = strings.TrimSpace(row.TargetCode)
|
||||
row.PackageCode = strings.TrimSpace(row.PackageCode)
|
||||
row.Remark = strings.TrimSpace(row.Remark)
|
||||
row.EffectiveMode = strings.TrimSpace(row.EffectiveMode)
|
||||
|
||||
if options.PackageCode != "" {
|
||||
if row.PackageCode != "" && row.PackageCode != options.PackageCode {
|
||||
row.AddErrorReason("行内加油包编码与页面选择不一致")
|
||||
}
|
||||
row.PackageCode = options.PackageCode
|
||||
}
|
||||
if row.EffectiveMode == "" && options.EffectiveMode != "" {
|
||||
row.EffectiveMode = options.EffectiveMode
|
||||
}
|
||||
if row.Remark == "" {
|
||||
row.Remark = strings.TrimSpace(options.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOptions(options SubmitOptions) (SubmitOptions, error) {
|
||||
options.PackageCode = strings.TrimSpace(options.PackageCode)
|
||||
options.Remark = strings.TrimSpace(options.Remark)
|
||||
mode, err := NormalizeEffectiveMode(options.EffectiveMode)
|
||||
if err != nil {
|
||||
return SubmitOptions{}, err
|
||||
}
|
||||
options.EffectiveMode = mode
|
||||
options.PaymentMode = strings.TrimSpace(options.PaymentMode)
|
||||
if options.PaymentMode == "" {
|
||||
options.PaymentMode = "balance"
|
||||
}
|
||||
switch options.PaymentMode {
|
||||
case "balance":
|
||||
options.OfflineContractCompleted = false
|
||||
case "offline":
|
||||
if !options.OfflineContractCompleted {
|
||||
return SubmitOptions{}, gerror.New("请确认线下合同、销售和走账已完成")
|
||||
}
|
||||
default:
|
||||
return SubmitOptions{}, gerror.New("加油包付款方式不正确")
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package addpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type cardTarget struct {
|
||||
Iccid string `orm:"iccid"`
|
||||
CurrentPackageCode string `orm:"current_package_code"`
|
||||
PackageStatus int `orm:"package_status"`
|
||||
}
|
||||
|
||||
type cardLatestBindTarget struct {
|
||||
Iccid string `orm:"iccid"`
|
||||
PackageCode string `orm:"package_code"`
|
||||
EffectiveStatus int `orm:"effective_status"`
|
||||
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
|
||||
}
|
||||
|
||||
type poolTarget struct {
|
||||
FlowPoolCode string `orm:"flow_pool_code"`
|
||||
PoolPackageCode int64 `orm:"pool_package_code"`
|
||||
PoolStatus int `orm:"pool_status"`
|
||||
}
|
||||
|
||||
func (p *Processor) PrepareRows(ctx context.Context, rows []batchimport.Row) error {
|
||||
if p.config.Operation == OperationPool {
|
||||
return p.preparePoolRows(ctx, rows)
|
||||
}
|
||||
return p.prepareCardRows(ctx, rows)
|
||||
}
|
||||
|
||||
func (p *Processor) prepareCardRows(ctx context.Context, rows []batchimport.Row) error {
|
||||
typedRows, targetCodes, err := collectTypedRows(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(targetCodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cards []*cardTarget
|
||||
cardColumns := dao.TSimCardInfo.Columns()
|
||||
if err = handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{FilterTenant: true, FilterAuth: true}).
|
||||
Fields(cardColumns.Iccid, cardColumns.CurrentPackageCode, cardColumns.PackageStatus, cardColumns.TenantId, cardColumns.DeptId, cardColumns.CreatedDept, cardColumns.CreatedBy).
|
||||
WhereIn(cardColumns.Iccid, targetCodes).
|
||||
Scan(&cards); err != nil {
|
||||
return gerror.Wrap(err, "查询加油包卡信息失败")
|
||||
}
|
||||
|
||||
cardMap := make(map[string]*cardTarget, len(cards))
|
||||
for _, card := range cards {
|
||||
if card == nil {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(card.Iccid)
|
||||
if iccid == "" {
|
||||
continue
|
||||
}
|
||||
cardMap[iccid] = card
|
||||
}
|
||||
|
||||
latestBinds, err := p.loadLatestCardBaseBinds(ctx, targetCodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latestBindMap := make(map[string]*cardLatestBindTarget, len(latestBinds))
|
||||
for _, bind := range latestBinds {
|
||||
if bind == nil {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(bind.Iccid)
|
||||
if iccid == "" || latestBindMap[iccid] != nil {
|
||||
continue
|
||||
}
|
||||
latestBindMap[iccid] = bind
|
||||
}
|
||||
|
||||
basePackageCodes := make([]string, 0, len(latestBindMap))
|
||||
for _, bind := range latestBindMap {
|
||||
if bind == nil {
|
||||
continue
|
||||
}
|
||||
basePackageCodes = append(basePackageCodes, bind.PackageCode)
|
||||
}
|
||||
|
||||
packages, err := p.loadPackageMap(ctx, append(basePackageCodes, rowPackageCodes(typedRows)...))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
for _, row := range typedRows {
|
||||
card := cardMap[row.TargetCode]
|
||||
if card == nil {
|
||||
row.AddErrorReason("ICCID不存在或无权限")
|
||||
continue
|
||||
}
|
||||
latestBind := latestBindMap[row.TargetCode]
|
||||
if latestBind == nil || strings.TrimSpace(latestBind.PackageCode) == "" {
|
||||
row.AddErrorReason("当前卡订购记录不存在")
|
||||
continue
|
||||
}
|
||||
row.Authorized = true
|
||||
if card.PackageStatus != consts.PackageStatusEffective {
|
||||
row.AddErrorReason("当前卡套餐未生效,不能订购加油包")
|
||||
continue
|
||||
}
|
||||
if bindExpired(latestBind, now) {
|
||||
row.AddErrorReason("当前卡基础套餐已到期,不能订购加油包")
|
||||
continue
|
||||
}
|
||||
if err := p.validateTargetPackage(row, packages, 103); err != nil {
|
||||
row.AddErrorReason(err.Error())
|
||||
continue
|
||||
}
|
||||
basePackage := packages[strings.TrimSpace(latestBind.PackageCode)]
|
||||
if basePackage == nil {
|
||||
row.AddErrorReason("当前卡基础套餐不存在")
|
||||
continue
|
||||
}
|
||||
if !containsString(packageCodesFromJSON(basePackage.TopupPackageCodes), row.PackageCode) {
|
||||
row.AddErrorReason("当前卡基础套餐不允许订购该加油包")
|
||||
continue
|
||||
}
|
||||
}
|
||||
return rowsValidationError(rows, "加油包卡信息校验失败")
|
||||
}
|
||||
|
||||
func (p *Processor) preparePoolRows(ctx context.Context, rows []batchimport.Row) error {
|
||||
typedRows, targetCodes, err := collectTypedRows(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(targetCodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pools []*poolTarget
|
||||
poolColumns := dao.TSimFlowPool.Columns()
|
||||
if err = handler.Model(dao.TSimFlowPool.Ctx(ctx), &handler.Option{FilterTenant: true, FilterAuth: true}).
|
||||
Fields(poolColumns.FlowPoolCode, poolColumns.PoolPackageCode, poolColumns.PoolStatus, poolColumns.TenantId, poolColumns.DeptId, poolColumns.CreatedDept, poolColumns.CreatedBy).
|
||||
WhereIn(poolColumns.FlowPoolCode, targetCodes).
|
||||
Scan(&pools); err != nil {
|
||||
return gerror.Wrap(err, "查询流量池信息失败")
|
||||
}
|
||||
poolMap := make(map[string]*poolTarget, len(pools))
|
||||
for _, pool := range pools {
|
||||
if pool == nil {
|
||||
continue
|
||||
}
|
||||
code := strings.TrimSpace(pool.FlowPoolCode)
|
||||
poolMap[code] = pool
|
||||
}
|
||||
|
||||
packages, err := p.loadPackageMap(ctx, rowPackageCodes(typedRows))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range typedRows {
|
||||
pool := poolMap[row.TargetCode]
|
||||
if pool == nil {
|
||||
row.AddErrorReason("流量池编号不存在或无权限")
|
||||
continue
|
||||
}
|
||||
row.Authorized = true
|
||||
if pool.PoolStatus != 0 {
|
||||
row.AddErrorReason("流量池状态异常,不能订购加油包")
|
||||
continue
|
||||
}
|
||||
if err := p.validateTargetPackage(row, packages, 104); err != nil {
|
||||
row.AddErrorReason(err.Error())
|
||||
continue
|
||||
}
|
||||
targetPackage := packages[row.PackageCode]
|
||||
if targetPackage == nil {
|
||||
row.AddErrorReason("加油包不存在")
|
||||
continue
|
||||
}
|
||||
if targetPackage.PoolPackageCode != pool.PoolPackageCode {
|
||||
row.AddErrorReason("加油包不属于当前流量池套餐")
|
||||
continue
|
||||
}
|
||||
}
|
||||
return rowsValidationError(rows, "流量池加油包校验失败")
|
||||
}
|
||||
|
||||
func (p *Processor) loadLatestCardBaseBinds(ctx context.Context, iccids []string) ([]*cardLatestBindTarget, error) {
|
||||
iccids = uniqueStrings(iccids)
|
||||
if len(iccids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var binds []*cardLatestBindTarget
|
||||
if err := handler.Model(dao.TSimCardBindPkg.Ctx(ctx), &handler.Option{FilterTenant: true, FilterAuth: true}).
|
||||
Fields(
|
||||
dao.TSimCardBindPkg.Columns().Iccid,
|
||||
dao.TSimCardBindPkg.Columns().PackageCode,
|
||||
dao.TSimCardBindPkg.Columns().EffectiveStatus,
|
||||
dao.TSimCardBindPkg.Columns().ExpectedExpireTime,
|
||||
dao.TSimCardBindPkg.Columns().TenantId,
|
||||
dao.TSimCardBindPkg.Columns().DeptId,
|
||||
dao.TSimCardBindPkg.Columns().CreatedDept,
|
||||
dao.TSimCardBindPkg.Columns().CreatedBy,
|
||||
).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().Iccid, iccids).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().PackageType, []int{101, 102}).
|
||||
Where(dao.TSimCardBindPkg.Columns().EffectiveStatus, consts.EffectiveStatusEffective).
|
||||
OrderDesc(dao.TSimCardBindPkg.Columns().ExpectedExpireTime).
|
||||
OrderDesc(dao.TSimCardBindPkg.Columns().Id).
|
||||
Scan(&binds); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡最新订购记录失败")
|
||||
}
|
||||
return binds, nil
|
||||
}
|
||||
|
||||
func bindExpired(bind *cardLatestBindTarget, now *gtime.Time) bool {
|
||||
if bind == nil {
|
||||
return true
|
||||
}
|
||||
if bind.EffectiveStatus == consts.EffectiveStatusExpired {
|
||||
return true
|
||||
}
|
||||
if bind.ExpectedExpireTime == nil || now == nil {
|
||||
return false
|
||||
}
|
||||
return !bind.ExpectedExpireTime.After(now)
|
||||
}
|
||||
|
||||
func (p *Processor) validateTargetPackage(row *Row, packages map[string]*entity.TSimFlowPackageInfo, packageType int) error {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
pkg := packages[row.PackageCode]
|
||||
if pkg == nil {
|
||||
return gerror.New("加油包不存在")
|
||||
}
|
||||
if pkg.Enabled != 1 {
|
||||
return gerror.New("加油包未启用")
|
||||
}
|
||||
if pkg.PackageType != packageType {
|
||||
return gerror.Newf("加油包类型错误,应为%d", packageType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Processor) loadPackageMap(ctx context.Context, codes []string) (map[string]*entity.TSimFlowPackageInfo, error) {
|
||||
out := make(map[string]*entity.TSimFlowPackageInfo)
|
||||
codes = uniqueStrings(codes)
|
||||
if len(codes) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var packages []*entity.TSimFlowPackageInfo
|
||||
columns := dao.TSimFlowPackageInfo.Columns()
|
||||
if err := handler.Model(dao.TSimFlowPackageInfo.Ctx(ctx), &handler.Option{FilterTenant: true, FilterAuth: false}).
|
||||
WhereIn(columns.PackageCode, codes).
|
||||
Scan(&packages); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询加油包套餐信息失败")
|
||||
}
|
||||
for _, pkg := range packages {
|
||||
if pkg == nil {
|
||||
continue
|
||||
}
|
||||
out[strings.TrimSpace(pkg.PackageCode)] = pkg
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func collectTypedRows(rows []batchimport.Row) ([]*Row, []string, error) {
|
||||
typedRows := make([]*Row, 0, len(rows))
|
||||
targetCodes := make([]string, 0, len(rows))
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return nil, nil, gerror.New("加油包批次行类型错误")
|
||||
}
|
||||
typedRows = append(typedRows, row)
|
||||
if row.TargetCode != "" {
|
||||
targetCodes = append(targetCodes, row.TargetCode)
|
||||
}
|
||||
}
|
||||
return typedRows, uniqueStrings(targetCodes), nil
|
||||
}
|
||||
|
||||
func rowPackageCodes(rows []*Row) []string {
|
||||
codes := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row != nil {
|
||||
codes = append(codes, row.PackageCode)
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func packageCodesFromJSON(jsonValue *gjson.Json) []string {
|
||||
if jsonValue == nil || jsonValue.IsNil() {
|
||||
return nil
|
||||
}
|
||||
values := jsonValue.Interfaces()
|
||||
out := make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
code := strings.TrimSpace(fmt.Sprint(raw))
|
||||
if code != "" {
|
||||
out = append(out, code)
|
||||
}
|
||||
}
|
||||
return uniqueStrings(out)
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func uniqueStrings(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, value := range in {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package addpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type PrepareProcessor struct {
|
||||
inner *Processor
|
||||
config batchimport.ProcessorConfig
|
||||
targetBatchType int
|
||||
}
|
||||
|
||||
func NewPrepareProcessor(operation Operation, options SubmitOptions) (*PrepareProcessor, error) {
|
||||
inner, err := NewProcessor(operation, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := inner.Config()
|
||||
cfg.Code = cfg.Code + "_prepare"
|
||||
cfg.BatchType = consts.BatchTypePrepare
|
||||
return &PrepareProcessor{
|
||||
inner: inner,
|
||||
config: cfg,
|
||||
targetBatchType: inner.config.BatchType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) Config() batchimport.ProcessorConfig {
|
||||
return p.config
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return p.inner.ParseRows(ctx, filePath)
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
|
||||
// Keep submit non-blocking; the worker owns add package prepare validation.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
|
||||
info := g.Map{}
|
||||
base := p.inner.BuildBatchInfo(ctx, req, result, importOss, resultOss, downloadId)
|
||||
if base != nil {
|
||||
for key, value := range base.Map() {
|
||||
info[key] = value
|
||||
}
|
||||
}
|
||||
info["operation"] = p.config.Code
|
||||
info["batchType"] = consts.BatchTypePrepare
|
||||
info["targetBatchType"] = p.targetBatchType
|
||||
info["targetBatchName"] = addPackageTargetBatchName(p.targetBatchType)
|
||||
info["targetLabel"] = p.inner.config.TargetLabel
|
||||
return gjson.New(info)
|
||||
}
|
||||
|
||||
func addPackageTargetBatchName(batchType int) string {
|
||||
switch batchType {
|
||||
case consts.BatchTypePoolAddPackage:
|
||||
return "流量池加油包"
|
||||
default:
|
||||
return "单卡加油包"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package addpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
config Config
|
||||
options SubmitOptions
|
||||
}
|
||||
|
||||
func NewProcessor(operation Operation, options SubmitOptions) (*Processor, error) {
|
||||
cfg, err := operation.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options, err = normalizeOptions(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Processor{config: cfg, options: options}, nil
|
||||
}
|
||||
|
||||
func (p *Processor) Config() batchimport.ProcessorConfig {
|
||||
maxFileSize := consts.SimCardAddPackageMaxFileSize
|
||||
if p.config.Operation == OperationPool {
|
||||
maxFileSize = consts.SimPoolAddPackageMaxFileSize
|
||||
}
|
||||
return batchimport.ProcessorConfig{
|
||||
Code: p.config.Code,
|
||||
BatchType: p.config.BatchType,
|
||||
BatchNoPrefix: p.config.BatchPrefix,
|
||||
TaskName: p.config.TaskName,
|
||||
ExportScene: p.config.ExportScene,
|
||||
ImportDir: p.config.ImportDir,
|
||||
ResultDir: p.config.ResultDir,
|
||||
MaxFileSize: int64(maxFileSize),
|
||||
TemplateHeaders: p.TemplateHeaders(),
|
||||
RequiredHeaders: []string{p.config.TargetHeader, headerPackageCode},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) TemplateHeaders() []string {
|
||||
return []string{p.config.TargetHeader, headerPackageCode, headerRemark}
|
||||
}
|
||||
|
||||
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return parseExcelRows(ctx, filePath, p.config, p.options)
|
||||
}
|
||||
|
||||
func (p *Processor) ValidateRows(_ context.Context, rows []batchimport.Row) error {
|
||||
firstByTarget := make(map[string]*Row, len(rows))
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return gerror.New("加油包批次行类型错误")
|
||||
}
|
||||
row.TargetCode = strings.TrimSpace(row.TargetCode)
|
||||
row.PackageCode = strings.TrimSpace(row.PackageCode)
|
||||
row.Remark = strings.TrimSpace(row.Remark)
|
||||
row.EffectiveMode = strings.TrimSpace(row.EffectiveMode)
|
||||
if row.TargetCode == "" {
|
||||
row.AddErrorReason(p.config.TargetLabel + "不能为空")
|
||||
continue
|
||||
}
|
||||
if first, exists := firstByTarget[row.TargetCode]; exists {
|
||||
row.AddErrorReason(fmt.Sprintf("%s与第%d行重复", p.config.TargetLabel, first.RowNo))
|
||||
first.AddErrorReason(p.config.TargetLabel + "重复")
|
||||
continue
|
||||
}
|
||||
firstByTarget[row.TargetCode] = row
|
||||
if row.PackageCode == "" {
|
||||
row.AddErrorReason("加油包编码不能为空")
|
||||
continue
|
||||
}
|
||||
if _, err := NormalizeEffectiveMode(row.EffectiveMode); err != nil {
|
||||
row.AddErrorReason(err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
return rowsValidationError(rows, "加油包批次基础校验失败")
|
||||
}
|
||||
|
||||
func (p *Processor) BuildBatchInfo(_ context.Context, _ batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
|
||||
info := g.Map{
|
||||
"operation": p.config.Code,
|
||||
"batchMode": string(p.config.Operation),
|
||||
"batchType": p.config.BatchType,
|
||||
"targetLabel": p.config.TargetLabel,
|
||||
"packageCode": p.options.PackageCode,
|
||||
"remark": p.options.Remark,
|
||||
"paymentMode": p.options.PaymentMode,
|
||||
"offlineContractCompleted": p.options.OfflineContractCompleted,
|
||||
"effectiveMode": p.options.EffectiveMode,
|
||||
"effectiveModeText": EffectiveModeText(p.options.EffectiveMode),
|
||||
"autoConfirm": p.options.AutoConfirm,
|
||||
"resultFile": g.Map{"status": "pending"},
|
||||
}
|
||||
if result != nil && result.ImportFileOssId > 0 {
|
||||
info["importFileOssId"] = result.ImportFileOssId
|
||||
} else if importOss != nil && importOss.OssId > 0 {
|
||||
info["importFileOssId"] = importOss.OssId
|
||||
}
|
||||
if resultOss != nil && resultOss.OssId > 0 {
|
||||
info["resultFileOssId"] = resultOss.OssId
|
||||
}
|
||||
if downloadId > 0 {
|
||||
info["resultDownloadId"] = downloadId
|
||||
}
|
||||
return gjson.New(info)
|
||||
}
|
||||
|
||||
func rowsValidationError(rows []batchimport.Row, prefix string) error {
|
||||
reasons := make([]string, 0)
|
||||
for _, item := range rows {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
rowReasons := item.GetErrorReasons()
|
||||
if len(rowReasons) == 0 {
|
||||
continue
|
||||
}
|
||||
reasons = append(reasons, fmt.Sprintf("第%d行:%s", item.GetRowNo(), strings.Join(rowReasons, ";")))
|
||||
if len(reasons) >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
return nil
|
||||
}
|
||||
return gerror.New(prefix + ":" + strings.Join(reasons, ";"))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package addpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/iot_excelize"
|
||||
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
type Submitter struct {
|
||||
operation Operation
|
||||
}
|
||||
|
||||
func NewCardSubmitter() *Submitter {
|
||||
return &Submitter{operation: OperationCard}
|
||||
}
|
||||
|
||||
func NewPoolSubmitter() *Submitter {
|
||||
return &Submitter{operation: OperationPool}
|
||||
}
|
||||
|
||||
func (s *Submitter) DownloadTemplate(ctx context.Context) error {
|
||||
processor, err := NewProcessor(s.operation, SubmitOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f := excelize.NewFile()
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
headers := processor.TemplateHeaders()
|
||||
if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := iot_excelize.ApplyHeaderColumnsTextStyle(f); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetColWidth("Sheet1", "A", "B", 24); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetColWidth("Sheet1", "C", "C", 32); err != nil {
|
||||
return err
|
||||
}
|
||||
fileName := "sim_card_add_package_template"
|
||||
if s.operation == OperationPool {
|
||||
fileName = "sim_pool_add_package_template"
|
||||
}
|
||||
return iot_excelize.WriteHTTP(ctx, f, fileName)
|
||||
}
|
||||
|
||||
func (s *Submitter) SubmitExcel(ctx context.Context, file *ghttp.UploadFile, options SubmitOptions) (*SubmitResult, error) {
|
||||
options.AutoConfirm = !options.ManualConfirm
|
||||
processor, err := NewPrepareProcessor(s.operation, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
File: file,
|
||||
BizParams: &processor.inner.options,
|
||||
SourceType: consts.BatchSourceTypeFile,
|
||||
Operator: common.CurrentOperator(ctx),
|
||||
}
|
||||
result, err := batchimport.CreatePendingUpload(ctx, req, processor, batchimport.PendingOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, string(s.operation)+"_add_package_prepare")
|
||||
res := pendingResultFromBatch(result, consts.BatchTypePrepare)
|
||||
res.TargetBatchType = processor.targetBatchType
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Submitter) SubmitInput(ctx context.Context, input string, options SubmitOptions) (*SubmitResult, error) {
|
||||
// Keep the legacy input/submit endpoint from bypassing async validation.
|
||||
return s.PrepareInput(ctx, input, options)
|
||||
}
|
||||
|
||||
func (s *Submitter) PrepareInput(ctx context.Context, input string, options SubmitOptions) (*SubmitResult, error) {
|
||||
if strings.TrimSpace(options.PackageCode) != "" || strings.TrimSpace(options.EffectiveMode) != "" {
|
||||
options.DefaultEffectiveMode = true
|
||||
}
|
||||
processor, err := NewPrepareProcessor(s.operation, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := parseInputRows(ctx, input, processor.inner.options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
BizParams: &processor.inner.options,
|
||||
SourceType: consts.BatchSourceTypeBatchInput,
|
||||
Operator: common.CurrentOperator(ctx),
|
||||
}
|
||||
result, err := batchimport.CreatePendingRows(ctx, req, processor, rows, batchimport.PendingOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, string(s.operation)+"_add_package_prepare")
|
||||
res := pendingResultFromBatch(result, consts.BatchTypePrepare)
|
||||
res.TargetBatchType = processor.targetBatchType
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func pendingResultFromBatch(result *batchimport.Result, batchType int) *SubmitResult {
|
||||
res := &SubmitResult{
|
||||
BatchType: batchType,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
}
|
||||
if result == nil {
|
||||
return res
|
||||
}
|
||||
valid := result.ValidRows
|
||||
if result.TotalRows > 0 && valid == 0 {
|
||||
valid = result.TotalRows - result.ErrorRows
|
||||
if valid < 0 {
|
||||
valid = 0
|
||||
}
|
||||
}
|
||||
res.BatchNo = result.BatchNo
|
||||
res.BatchType = result.BatchType
|
||||
if batchType != result.BatchType {
|
||||
res.TargetBatchType = batchType
|
||||
}
|
||||
res.Total = result.TotalRows
|
||||
res.ValidCount = valid
|
||||
res.FailCount = result.ErrorRows
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package addpackage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const (
|
||||
OperationCard Operation = "card"
|
||||
OperationPool Operation = "pool"
|
||||
)
|
||||
|
||||
type Operation string
|
||||
|
||||
type Config struct {
|
||||
Operation Operation
|
||||
Code string
|
||||
BatchType int
|
||||
BatchPrefix string
|
||||
TaskName string
|
||||
ExportScene int
|
||||
ImportDir string
|
||||
ResultDir string
|
||||
TargetHeader string
|
||||
TargetLabel string
|
||||
}
|
||||
|
||||
type SubmitOptions struct {
|
||||
PackageCode string
|
||||
Remark string
|
||||
PaymentMode string
|
||||
OfflineContractCompleted bool
|
||||
EffectiveMode string
|
||||
DefaultEffectiveMode bool
|
||||
ManualConfirm bool
|
||||
AutoConfirm bool
|
||||
}
|
||||
|
||||
type SubmitResult struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
TargetBatchType int
|
||||
BatchAsyncStatus int
|
||||
Total int
|
||||
ValidCount int
|
||||
FailCount int
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
RowNo int
|
||||
TargetCode string
|
||||
PackageCode string
|
||||
Remark string
|
||||
EffectiveMode string
|
||||
Authorized bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (op Operation) Config() (Config, error) {
|
||||
switch op {
|
||||
case OperationCard:
|
||||
return Config{
|
||||
Operation: op,
|
||||
Code: "sim_card_add_package",
|
||||
BatchType: consts.BatchTypeCardAddPackage,
|
||||
BatchPrefix: consts.BatchNoPrefixCardAddPackage,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardAddPackage,
|
||||
ExportScene: consts.ResourceExportSceneCardAddPackage,
|
||||
ImportDir: consts.SimCardAddPackageSubDirImportExcel,
|
||||
ResultDir: consts.SimCardAddPackageSubDirResultExcel,
|
||||
TargetHeader: "ICCID",
|
||||
TargetLabel: "ICCID",
|
||||
}, nil
|
||||
case OperationPool:
|
||||
return Config{
|
||||
Operation: op,
|
||||
Code: "sim_pool_add_package",
|
||||
BatchType: consts.BatchTypePoolAddPackage,
|
||||
BatchPrefix: consts.BatchNoPrefixPoolAddPackage,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimPoolAddPackage,
|
||||
ExportScene: consts.ResourceExportScenePoolAddPackage,
|
||||
ImportDir: consts.SimPoolAddPackageSubDirImportExcel,
|
||||
ResultDir: consts.SimPoolAddPackageSubDirResultExcel,
|
||||
TargetHeader: "流量池",
|
||||
TargetLabel: "流量池",
|
||||
}, nil
|
||||
default:
|
||||
return Config{}, gerror.New("加油包操作类型不支持")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Row) GetRowNo() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
return r.RowNo
|
||||
}
|
||||
|
||||
func (r *Row) GetItemID() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(r.TargetCode)
|
||||
}
|
||||
|
||||
func (r *Row) GetErrorReasons() []string {
|
||||
if r == nil || strings.TrimSpace(r.Reason) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{strings.TrimSpace(r.Reason)}
|
||||
}
|
||||
|
||||
func (r *Row) AddErrorReason(reason string) {
|
||||
if r == nil || strings.TrimSpace(r.Reason) != "" || strings.TrimSpace(reason) == "" {
|
||||
return
|
||||
}
|
||||
r.Reason = strings.TrimSpace(reason)
|
||||
}
|
||||
|
||||
func (r *Row) Snapshot() *gjson.Json {
|
||||
if r == nil {
|
||||
return gjson.New(g.Map{})
|
||||
}
|
||||
data := g.Map{
|
||||
"targetCode": strings.TrimSpace(r.TargetCode),
|
||||
"packageCode": strings.TrimSpace(r.PackageCode),
|
||||
"remark": strings.TrimSpace(r.Remark),
|
||||
"authorized": r.Authorized,
|
||||
}
|
||||
if mode := strings.TrimSpace(r.EffectiveMode); mode != "" {
|
||||
data["effectiveMode"] = mode
|
||||
data["effectiveModeText"] = EffectiveModeText(mode)
|
||||
}
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
const (
|
||||
EffectiveModeCurrentMonth = "current_month"
|
||||
)
|
||||
|
||||
func NormalizeEffectiveMode(mode string) (string, error) {
|
||||
switch strings.TrimSpace(mode) {
|
||||
case "", "immediate", "current_month", "this_month", "本月", "本月生效", "当月", "当月生效":
|
||||
return EffectiveModeCurrentMonth, nil
|
||||
case "next_month", "nextMonth", "下月", "次月", "次月生效":
|
||||
return "", gerror.New("加油包只支持本月生效")
|
||||
default:
|
||||
return "", gerror.New("加油包生效方式不正确")
|
||||
}
|
||||
}
|
||||
|
||||
func EffectiveModeText(mode string) string {
|
||||
_, _ = NormalizeEffectiveMode(mode)
|
||||
return "本月生效"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
func ParseExcelFileRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
parsedRows, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{
|
||||
RequiredHeaders: []string{"ICCID"},
|
||||
SingleSheet: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(parsedRows))
|
||||
for _, parsed := range parsedRows {
|
||||
row := &Row{
|
||||
RowNo: parsed.RowNo,
|
||||
Iccid: excel.Field(parsed, "ICCID"),
|
||||
Msisdn: excel.Field(parsed, "MSISDN"),
|
||||
Imsi: excel.Field(parsed, "IMSI"),
|
||||
TargetDeptKey: firstExcelField(parsed, "目标客户编码", "客户编码", "TARGETDEPTKEY", "TARGET_CUSTOMER_CODE"),
|
||||
TargetDeptName: firstExcelField(parsed, "目标客户名称", "客户名称", "TARGETDEPTNAME", "TARGET_CUSTOMER_NAME"),
|
||||
Remark: firstExcelField(parsed, "划拨备注", "备注", "REMARK"),
|
||||
}
|
||||
if value := firstExcelField(parsed, "目标客户ID", "目标部门ID", "TARGETDEPTID", "TARGET_CUSTOMER_ID"); value != "" {
|
||||
targetDeptID, parseErr := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if parseErr != nil || targetDeptID <= 0 {
|
||||
row.AddErrorReason("目标客户ID格式错误")
|
||||
} else {
|
||||
row.TargetDeptId = targetDeptID
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func ParseInputRows(ctx context.Context, input string, targetDeptID int64) ([]batchimport.Row, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New("批量输入内容不能为空")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &Row{RowNo: line.RowNo, TargetDeptId: targetDeptID}
|
||||
switch len(line.Fields) {
|
||||
case 1:
|
||||
row.Iccid = line.Fields[0]
|
||||
case 3:
|
||||
row.Iccid = line.Fields[0]
|
||||
row.Msisdn = line.Fields[1]
|
||||
row.Imsi = line.Fields[2]
|
||||
default:
|
||||
row.AddErrorReason("格式错误,应为 ICCID 或 ICCID MSISDN IMSI")
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func firstExcelField(row excel.ParsedRow, names ...string) string {
|
||||
for _, name := range names {
|
||||
if value := excel.Field(row, name); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/allocationunits"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const (
|
||||
importDir = "sim-card-allocate/import"
|
||||
resultDir = "sim-card-allocate/result"
|
||||
maxFileSize = 50 << 20
|
||||
maxAllocateRows = 10000
|
||||
)
|
||||
|
||||
type Processor struct{}
|
||||
|
||||
func NewProcessor() *Processor {
|
||||
return &Processor{}
|
||||
}
|
||||
|
||||
func (p *Processor) Config() batchimport.ProcessorConfig {
|
||||
return batchimport.ProcessorConfig{
|
||||
Code: "sim_card_allocate",
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
BatchNoPrefix: consts.BatchNoPrefixAllocate,
|
||||
TaskName: "卡划拨结果",
|
||||
ExportScene: consts.ResourceExportSceneAllocate,
|
||||
ImportDir: importDir,
|
||||
ResultDir: resultDir,
|
||||
MaxFileSize: maxFileSize,
|
||||
MaxRows: maxAllocateRows,
|
||||
TemplateHeaders: []string{"ICCID", "MSISDN", "IMSI", "目标客户名称"},
|
||||
RequiredHeaders: []string{"ICCID"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return ParseExcelFileRows(ctx, filePath)
|
||||
}
|
||||
|
||||
func (p *Processor) ValidateRows(_ context.Context, rows []batchimport.Row) error {
|
||||
firstByIccid := make(map[string]*Row, len(rows))
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
continue
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
row.Msisdn = strings.TrimSpace(row.Msisdn)
|
||||
row.Imsi = strings.TrimSpace(row.Imsi)
|
||||
row.TargetDeptKey = strings.TrimSpace(row.TargetDeptKey)
|
||||
row.TargetDeptName = strings.TrimSpace(row.TargetDeptName)
|
||||
row.Remark = strings.TrimSpace(row.Remark)
|
||||
if row.Iccid == "" {
|
||||
row.AddErrorReason("ICCID不能为空")
|
||||
continue
|
||||
}
|
||||
if first, exists := firstByIccid[row.Iccid]; exists {
|
||||
row.AddErrorReason(fmt.Sprintf("ICCID与第%d行重复", first.RowNo))
|
||||
first.AddErrorReason("ICCID重复")
|
||||
continue
|
||||
}
|
||||
firstByIccid[row.Iccid] = row
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Processor) BuildBatchInfo(_ context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
|
||||
options := optionsFromParams(req.BizParams)
|
||||
info := g.Map{
|
||||
"operation": "sim_card_allocate",
|
||||
"configName": "卡划拨",
|
||||
"submitScene": submitScene(req.SourceType),
|
||||
"targetDeptId": options.TargetDeptId,
|
||||
"remark": strings.TrimSpace(options.Remark),
|
||||
"resultFile": g.Map{"status": "pending"},
|
||||
}
|
||||
if result != nil && result.ImportFileOssId > 0 {
|
||||
info["importFileOssId"] = result.ImportFileOssId
|
||||
} else if importOss != nil && importOss.OssId > 0 {
|
||||
info["importFileOssId"] = importOss.OssId
|
||||
}
|
||||
if resultOss != nil && resultOss.OssId > 0 {
|
||||
info["resultFileOssId"] = resultOss.OssId
|
||||
}
|
||||
if downloadId > 0 {
|
||||
info["resultDownloadId"] = downloadId
|
||||
}
|
||||
return allocationunits.Merge(gjson.New(info), options.Units)
|
||||
}
|
||||
|
||||
func optionsFromParams(params any) SubmitOptions {
|
||||
if options, ok := params.(*SubmitOptions); ok && options != nil {
|
||||
return *options
|
||||
}
|
||||
return SubmitOptions{}
|
||||
}
|
||||
|
||||
func submitScene(sourceType int) string {
|
||||
switch sourceType {
|
||||
case consts.BatchSourceTypeFile:
|
||||
return "excel"
|
||||
case consts.BatchSourceTypeBatchInput:
|
||||
return "input"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestProcessorTemplateHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := []string{"ICCID", "MSISDN", "IMSI", "目标客户名称"}
|
||||
if !reflect.DeepEqual(NewProcessor().Config().TemplateHeaders, expected) {
|
||||
t.Fatalf("unexpected headers: %#v", NewProcessor().Config().TemplateHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsCustomerFacingColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "allocate.xlsx")
|
||||
f := excelize.NewFile()
|
||||
headers := []any{"ICCID", "MSISDN", "IMSI", "目标客户名称"}
|
||||
if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil {
|
||||
t.Fatalf("set headers failed: %v", err)
|
||||
}
|
||||
row := []any{
|
||||
"8986000000000000001",
|
||||
"13800008000",
|
||||
"460001234567890",
|
||||
"示例企业",
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil {
|
||||
t.Fatalf("set row failed: %v", err)
|
||||
}
|
||||
if err := f.SaveAs(filePath); err != nil {
|
||||
t.Fatalf("save excel failed: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
rows, err := ParseExcelFileRows(context.Background(), filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("parse excel failed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("unexpected row count: %d", len(rows))
|
||||
}
|
||||
rowModel, ok := rows[0].(*Row)
|
||||
if !ok || rowModel == nil {
|
||||
t.Fatalf("unexpected row type: %#v", rows[0])
|
||||
}
|
||||
if rowModel.Iccid != "8986000000000000001" {
|
||||
t.Fatalf("unexpected iccid: %s", rowModel.Iccid)
|
||||
}
|
||||
if rowModel.Msisdn != "13800008000" {
|
||||
t.Fatalf("unexpected msisdn: %s", rowModel.Msisdn)
|
||||
}
|
||||
if rowModel.Imsi != "460001234567890" {
|
||||
t.Fatalf("unexpected imsi: %s", rowModel.Imsi)
|
||||
}
|
||||
if rowModel.TargetDeptName != "示例企业" {
|
||||
t.Fatalf("unexpected target dept name: %s", rowModel.TargetDeptName)
|
||||
}
|
||||
if rowModel.Remark != "" {
|
||||
t.Fatalf("unexpected remark: %s", rowModel.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorConfigLimitsAllocationRows(t *testing.T) {
|
||||
config := NewProcessor().Config()
|
||||
if config.MaxRows != 10000 {
|
||||
t.Fatalf("MaxRows = %d, want 10000", config.MaxRows)
|
||||
}
|
||||
if config.MaxFileSize != 50<<20 {
|
||||
t.Fatalf("MaxFileSize = %d, want %d", config.MaxFileSize, 50<<20)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/allocationunits"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAllocateChunkSize = 5000
|
||||
defaultAllocateMaxFileSize = 50 << 20
|
||||
minAllocateChunkSize = 100
|
||||
maxAllocateChunkSize = 10000
|
||||
allocateDetailInsertSize = 500
|
||||
allocateParentJobRole = "allocate_parent"
|
||||
allocateChunkJobRole = "allocate_chunk"
|
||||
)
|
||||
|
||||
type allocateSplitState struct {
|
||||
ParentBatchNo string
|
||||
ChunkSize int
|
||||
ChunkCount int
|
||||
Total int
|
||||
Valid int
|
||||
Failed int
|
||||
}
|
||||
|
||||
type allocateChunkStream func(context.Context, func([]batchimport.Row) error) (int, error)
|
||||
|
||||
func allocateChunkSize(ctx context.Context) int {
|
||||
size := g.Cfg().MustGet(ctx, "batch.allocate.chunkSize", defaultAllocateChunkSize).Int()
|
||||
if size < minAllocateChunkSize {
|
||||
return minAllocateChunkSize
|
||||
}
|
||||
if size > maxAllocateChunkSize {
|
||||
return maxAllocateChunkSize
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func allocateMaxFileSize(ctx context.Context) int64 {
|
||||
size := g.Cfg().MustGet(ctx, "batch.allocate.maxFileSize", defaultAllocateMaxFileSize).Int64()
|
||||
if size <= 0 {
|
||||
return defaultAllocateMaxFileSize
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func createPendingSplit(
|
||||
ctx context.Context,
|
||||
req batchimport.SubmitRequest,
|
||||
p batchimport.PendingProcessor,
|
||||
options batchimport.PendingOptions,
|
||||
parentBatchNo string,
|
||||
importOss *model.SysOssSaveContentModel,
|
||||
stream allocateChunkStream,
|
||||
) (*PendingResult, error) {
|
||||
units, err := allocationunits.Snapshot(ctx, req.Operator.TenantId, req.Operator.DeptId, req.TargetDeptId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts, ok := req.BizParams.(*SubmitOptions); ok {
|
||||
opts.Units = units
|
||||
}
|
||||
|
||||
if err := batchmetadata.ValidateCreation(req.SourceChannel, req.ExecutionMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p == nil {
|
||||
return nil, gerror.New("allocate processor is required")
|
||||
}
|
||||
if strings.TrimSpace(parentBatchNo) == "" {
|
||||
return nil, gerror.New("parent batch number is required")
|
||||
}
|
||||
if stream == nil {
|
||||
return nil, gerror.New("allocate input stream is required")
|
||||
}
|
||||
|
||||
chunkSize := allocateChunkSize(ctx)
|
||||
state := &allocateSplitState{
|
||||
ParentBatchNo: parentBatchNo,
|
||||
ChunkSize: chunkSize,
|
||||
}
|
||||
if err := createAllocateParent(ctx, req, p, parentBatchNo, importOss, chunkSize); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
children := make([]*batchimport.Result, 0)
|
||||
|
||||
if _, err := stream(ctx, func(rows []batchimport.Row) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := p.ValidateRows(ctx, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
if options.PrepareRows != nil {
|
||||
if err := options.PrepareRows(ctx, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
priorFailures, err := markAllocateCrossChunkDuplicates(ctx, parentBatchNo, req.Operator.TenantId, rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.Valid -= priorFailures
|
||||
state.Failed += priorFailures
|
||||
|
||||
state.ChunkCount++
|
||||
result, err := createAllocateChunk(ctx, req, p, parentBatchNo, state.ChunkCount, rows, importOss, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
children = append(children, result)
|
||||
return nil
|
||||
}); err != nil {
|
||||
markAllocateParentFailed(ctx, parentBatchNo, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if state.Total == 0 {
|
||||
err := gerror.New("allocate input has no data rows")
|
||||
markAllocateParentFailed(ctx, parentBatchNo, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if err := markAllocateSplitCompleted(ctx, parentBatchNo, state); err != nil {
|
||||
markAllocateParentFailed(ctx, parentBatchNo, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
if err := common.PublishBatchMessageStrict(ctx, child, consts.QueueBatchExecute, "allocate"); err != nil {
|
||||
markAllocateParentFailed(ctx, parentBatchNo, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
parentMessage := &batchimport.Result{
|
||||
BatchNo: parentBatchNo,
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
}
|
||||
if err := common.PublishBatchMessageStrict(ctx, parentMessage, consts.QueueBatchExecute, "allocate_parent"); err != nil {
|
||||
markAllocateParentFailed(ctx, parentBatchNo, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PendingResult{
|
||||
BatchNo: parentBatchNo,
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
Total: state.Total,
|
||||
ValidCount: state.Valid,
|
||||
FailCount: state.Failed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createAllocateParent(ctx context.Context, req batchimport.SubmitRequest, p batchimport.PendingProcessor, parentBatchNo string, importOss *model.SysOssSaveContentModel, chunkSize int) error {
|
||||
result := &batchimport.Result{
|
||||
BatchNo: parentBatchNo,
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
ImportFileOssId: ossIDFromContent(importOss),
|
||||
ImportFileUrl: urlFromContent(importOss),
|
||||
}
|
||||
info := p.BuildBatchInfo(ctx, req, result, importOss, nil, 0)
|
||||
info = withAllocateInfo(info, map[string]any{
|
||||
"targetBatchType": consts.BatchTypeAllocate,
|
||||
"jobRole": allocateParentJobRole,
|
||||
"autoSplit": true,
|
||||
"chunkSize": chunkSize,
|
||||
"chunkCount": 0,
|
||||
"completedChunkCount": 0,
|
||||
"parsedCount": 0,
|
||||
})
|
||||
now := gtime.Now()
|
||||
_, err := g.DB().Model(dao.BatchMain.Table()).Data(do.BatchMain{
|
||||
BatchNo: parentBatchNo,
|
||||
OrderSn: "",
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
BatchStatus: consts.BatchStatusNormal,
|
||||
StartTime: now,
|
||||
SourceType: req.SourceType,
|
||||
SourceChannel: batchmetadata.Channel(req.SourceChannel),
|
||||
ExecutionMode: req.ExecutionMode,
|
||||
SourceOssId: ossIDFromContent(importOss),
|
||||
IsCrossDept: 0,
|
||||
TargetDeptId: req.TargetDeptId,
|
||||
DeptId: req.Operator.DeptId,
|
||||
TotalCount: 0,
|
||||
ValidCount: 0,
|
||||
SuccessCount: 0,
|
||||
FailCount: 0,
|
||||
RunResult: consts.BatchRunResultPending,
|
||||
BatchInfo: info,
|
||||
TenantId: req.Operator.TenantId,
|
||||
CreatedDept: req.Operator.DeptId,
|
||||
CreatedBy: req.Operator.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: req.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "insert allocate parent batch failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createAllocateChunk(ctx context.Context, req batchimport.SubmitRequest, p batchimport.PendingProcessor, parentBatchNo string, chunkNo int, rows []batchimport.Row, importOss *model.SysOssSaveContentModel, state *allocateSplitState) (*batchimport.Result, error) {
|
||||
childBatchNo, err := batchimport.NextRandomNo(consts.BatchNoPrefixAllocate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validCount, failCount := countBatchRows(rows)
|
||||
result := &batchimport.Result{
|
||||
ImportStatus: consts.ImportStatusSuccess,
|
||||
BatchNo: childBatchNo,
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
TotalRows: len(rows),
|
||||
ValidRows: validCount,
|
||||
ErrorRows: failCount,
|
||||
ImportFileOssId: ossIDFromContent(importOss),
|
||||
ImportFileUrl: urlFromContent(importOss),
|
||||
}
|
||||
childReq := req
|
||||
childReq.BatchNo = childBatchNo
|
||||
childReq.SourceBatchNo = parentBatchNo
|
||||
info := p.BuildBatchInfo(ctx, childReq, result, importOss, nil, 0)
|
||||
info = withAllocateInfo(info, map[string]any{
|
||||
"targetBatchType": consts.BatchTypeAllocate,
|
||||
"jobRole": allocateChunkJobRole,
|
||||
"parentBatchNo": parentBatchNo,
|
||||
"chunkNo": chunkNo,
|
||||
"chunkSize": len(rows),
|
||||
})
|
||||
now := gtime.Now()
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Data(do.BatchMain{
|
||||
BatchNo: childBatchNo,
|
||||
OrderSn: "",
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
BatchStatus: consts.BatchStatusNormal,
|
||||
SourceType: req.SourceType,
|
||||
SourceChannel: batchmetadata.Channel(req.SourceChannel),
|
||||
ExecutionMode: req.ExecutionMode,
|
||||
SourceBatchNo: parentBatchNo,
|
||||
SourceOssId: ossIDFromContent(importOss),
|
||||
IsCrossDept: 0,
|
||||
TargetDeptId: req.TargetDeptId,
|
||||
DeptId: req.Operator.DeptId,
|
||||
TotalCount: len(rows),
|
||||
ValidCount: validCount,
|
||||
SuccessCount: 0,
|
||||
FailCount: failCount,
|
||||
RunResult: consts.BatchRunResultPending,
|
||||
BatchInfo: info,
|
||||
TenantId: req.Operator.TenantId,
|
||||
CreatedDept: req.Operator.DeptId,
|
||||
CreatedBy: req.Operator.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: req.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "insert allocate child batch failed")
|
||||
}
|
||||
|
||||
details := make([]do.BatchDetail, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
status := consts.BatchDetailStatusPending
|
||||
if len(row.GetErrorReasons()) > 0 {
|
||||
status = consts.BatchDetailStatusFailed
|
||||
}
|
||||
details = append(details, do.BatchDetail{
|
||||
BatchNo: childBatchNo,
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
DetailStatus: status,
|
||||
BlankOutStatus: consts.BlankOutStatusNo,
|
||||
RowNo: row.GetRowNo(),
|
||||
ItemId: row.GetItemID(),
|
||||
ErrorReason: strings.Join(row.GetErrorReasons(), "; "),
|
||||
Snapshot: allocationunits.Merge(row.Snapshot(), sourceUnitInfo(info)),
|
||||
TenantId: req.Operator.TenantId,
|
||||
CreatedDept: req.Operator.DeptId,
|
||||
CreatedBy: req.Operator.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: req.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
for start := 0; start < len(details); start += allocateDetailInsertSize {
|
||||
end := start + allocateDetailInsertSize
|
||||
if end > len(details) {
|
||||
end = len(details)
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).Data(details[start:end]).Insert(); err != nil {
|
||||
return gerror.Wrapf(err, "insert allocate details failed, range:%d-%d", start+1, end)
|
||||
}
|
||||
}
|
||||
|
||||
parent := &entity.BatchMain{}
|
||||
columns := dao.BatchMain.Columns()
|
||||
if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(columns.BatchNo, parentBatchNo).
|
||||
Where(columns.BatchType, consts.BatchTypeAllocate).
|
||||
LockUpdate().Scan(parent); err != nil {
|
||||
return gerror.Wrap(err, "load allocate parent batch failed")
|
||||
}
|
||||
if parent.Id == 0 {
|
||||
return gerror.New("allocate parent batch does not exist")
|
||||
}
|
||||
parentInfo := withAllocateInfo(parent.BatchInfo, map[string]any{
|
||||
"chunkCount": chunkNo,
|
||||
"parsedCount": parent.TotalCount + len(rows),
|
||||
})
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where(columns.Id, parent.Id).Data(do.BatchMain{
|
||||
TotalCount: parent.TotalCount + len(rows),
|
||||
ValidCount: parent.ValidCount + validCount,
|
||||
FailCount: parent.FailCount + failCount,
|
||||
BatchInfo: parentInfo,
|
||||
UpdatedBy: req.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "update allocate parent split progress failed")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if state != nil {
|
||||
state.Total += len(rows)
|
||||
state.Valid += validCount
|
||||
state.Failed += failCount
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func markAllocateSplitCompleted(ctx context.Context, parentBatchNo string, state *allocateSplitState) error {
|
||||
columns := dao.BatchMain.Columns()
|
||||
parent := &entity.BatchMain{}
|
||||
if err := g.DB().Model(dao.BatchMain.Table()).
|
||||
Where(columns.BatchNo, parentBatchNo).
|
||||
Where(columns.BatchType, consts.BatchTypeAllocate).
|
||||
Scan(parent); err != nil {
|
||||
return err
|
||||
}
|
||||
if parent.Id == 0 {
|
||||
return gerror.New("allocate parent batch does not exist")
|
||||
}
|
||||
info := withAllocateInfo(parent.BatchInfo, map[string]any{
|
||||
"splitCompleted": true,
|
||||
"chunkCount": state.ChunkCount,
|
||||
"completedChunkCount": 0,
|
||||
"parsedCount": state.Total,
|
||||
})
|
||||
_, err := g.DB().Model(dao.BatchMain.Table()).Where(columns.Id, parent.Id).Data(do.BatchMain{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
RunResult: consts.BatchRunResultRunning,
|
||||
BatchInfo: info,
|
||||
UpdatedAt: gtime.Now(),
|
||||
}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func markAllocateParentFailed(ctx context.Context, parentBatchNo, reason string) {
|
||||
if strings.TrimSpace(parentBatchNo) == "" {
|
||||
return
|
||||
}
|
||||
now := gtime.Now()
|
||||
columns := dao.BatchMain.Columns()
|
||||
_, _ = g.DB().Model(dao.BatchMain.Table()).
|
||||
Where(columns.BatchNo, parentBatchNo).
|
||||
Where(columns.BatchType, consts.BatchTypeAllocate).
|
||||
WhereIn(columns.BatchAsyncStatus, []int{consts.BatchAsyncStatusPending, consts.BatchAsyncStatusProcessing}).
|
||||
Data(do.BatchMain{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
|
||||
RunResult: consts.BatchRunResultFailed,
|
||||
FailReason: strings.TrimSpace(reason),
|
||||
EndTime: now,
|
||||
UpdatedAt: now,
|
||||
}).Update()
|
||||
}
|
||||
|
||||
func countBatchRows(rows []batchimport.Row) (valid, failed int) {
|
||||
for _, row := range rows {
|
||||
if len(row.GetErrorReasons()) == 0 {
|
||||
valid++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenantID string, rows []batchimport.Row) (int, error) {
|
||||
if strings.TrimSpace(parentBatchNo) == "" || len(rows) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
columns := dao.BatchDetail.Columns()
|
||||
itemIDs := make([]string, 0, len(rows))
|
||||
seen := make(map[string]struct{}, len(rows))
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
continue
|
||||
}
|
||||
itemID := strings.TrimSpace(row.Iccid)
|
||||
if itemID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[itemID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[itemID] = struct{}{}
|
||||
itemIDs = append(itemIDs, itemID)
|
||||
}
|
||||
if len(itemIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var existing []struct {
|
||||
Id int64 `orm:"id"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
ItemId string `orm:"item_id"`
|
||||
DetailStatus int `orm:"detail_status"`
|
||||
}
|
||||
query := g.DB().Model(dao.BatchDetail.Table()).
|
||||
Fields(columns.Id, columns.BatchNo, columns.ItemId, columns.DetailStatus).
|
||||
Where(columns.BatchType, consts.BatchTypeAllocate).
|
||||
Where(columns.TenantId, tenantID).
|
||||
WhereIn(columns.ItemId, itemIDs).
|
||||
Where("batch_no IN (SELECT batch_no FROM batch_main WHERE source_batch_no = ? AND batch_type = ? AND tenant_id = ?)",
|
||||
parentBatchNo, consts.BatchTypeAllocate, tenantID)
|
||||
if err := query.Scan(&existing); err != nil {
|
||||
return 0, gerror.Wrap(err, "check duplicate allocate ICCIDs across chunks failed")
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
priorFailures := 0
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
for _, detail := range existing {
|
||||
if detail.DetailStatus != consts.BatchDetailStatusPending {
|
||||
continue
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(columns.Id, detail.Id).
|
||||
Where(columns.TenantId, tenantID).
|
||||
Where(columns.DetailStatus, consts.BatchDetailStatusPending).
|
||||
Data(do.BatchDetail{DetailStatus: consts.BatchDetailStatusFailed, ErrorReason: "ICCID在划拨批次内重复"}).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "mark prior duplicate allocation detail failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
continue
|
||||
}
|
||||
priorFailures++
|
||||
mainColumns := dao.BatchMain.Columns()
|
||||
for _, batchNo := range []string{detail.BatchNo, parentBatchNo} {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(mainColumns.BatchNo, batchNo).
|
||||
Where(mainColumns.TenantId, tenantID).
|
||||
Data(do.BatchMain{ValidCount: gdb.Raw("valid_count - 1"), FailCount: gdb.Raw("fail_count + 1")}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "update cross-chunk duplicate counts failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
duplicates := make(map[string]struct{}, len(existing))
|
||||
for _, item := range existing {
|
||||
if itemID := strings.TrimSpace(item.ItemId); itemID != "" {
|
||||
duplicates[itemID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := duplicates[strings.TrimSpace(row.Iccid)]; exists {
|
||||
row.AddErrorReason("ICCID在划拨批次内重复")
|
||||
}
|
||||
}
|
||||
return priorFailures, nil
|
||||
}
|
||||
|
||||
func withAllocateInfo(info *gjson.Json, values map[string]any) *gjson.Json {
|
||||
data := make(map[string]any)
|
||||
if info != nil {
|
||||
for key, value := range info.Map() {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
for key, value := range values {
|
||||
data[key] = value
|
||||
}
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
func ossIDFromContent(content *model.SysOssSaveContentModel) int64 {
|
||||
if content == nil || content.SysOss == nil {
|
||||
return 0
|
||||
}
|
||||
return content.OssId
|
||||
}
|
||||
|
||||
func urlFromContent(content *model.SysOssSaveContentModel) string {
|
||||
if content == nil || content.SysOss == nil {
|
||||
return ""
|
||||
}
|
||||
return content.Url
|
||||
}
|
||||
|
||||
func streamAllocateExcel(ctx context.Context, filePath string, chunkSize int, emit func([]batchimport.Row) error) (int, error) {
|
||||
return streamAllocateParsedFile(ctx, chunkSize, emit, func(handler func(excel.ParsedRow) error) (int, error) {
|
||||
return excel.StreamFile(ctx, filePath, excel.ParseOptions{
|
||||
RequiredHeaders: []string{"ICCID"},
|
||||
SingleSheet: true,
|
||||
}, handler)
|
||||
})
|
||||
}
|
||||
|
||||
func streamAllocateCSV(ctx context.Context, filePath string, chunkSize int, emit func([]batchimport.Row) error) (int, error) {
|
||||
return streamAllocateParsedFile(ctx, chunkSize, emit, func(handler func(excel.ParsedRow) error) (int, error) {
|
||||
return excel.StreamCSVFile(ctx, filePath, excel.ParseOptions{
|
||||
RequiredHeaders: []string{"ICCID"},
|
||||
}, handler)
|
||||
})
|
||||
}
|
||||
|
||||
func streamAllocateParsedFile(ctx context.Context, chunkSize int, emit func([]batchimport.Row) error, stream func(func(excel.ParsedRow) error) (int, error)) (int, error) {
|
||||
rows := make([]batchimport.Row, 0, chunkSize)
|
||||
total, err := stream(func(parsed excel.ParsedRow) error {
|
||||
rows = append(rows, rowFromExcel(parsed))
|
||||
if len(rows) < chunkSize {
|
||||
return nil
|
||||
}
|
||||
if err := emit(rows); err != nil {
|
||||
return err
|
||||
}
|
||||
rows = make([]batchimport.Row, 0, chunkSize)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err := emit(rows); err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func streamAllocateInput(ctx context.Context, input string, targetDeptID int64, chunkSize int, emit func([]batchimport.Row) error) (int, error) {
|
||||
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
rows := make([]batchimport.Row, 0, chunkSize)
|
||||
total := 0
|
||||
rowNo := 0
|
||||
for scanner.Scan() {
|
||||
rowNo++
|
||||
if err := ctx.Err(); err != nil {
|
||||
return total, err
|
||||
}
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
row := &Row{RowNo: rowNo, TargetDeptId: targetDeptID}
|
||||
fields := strings.Fields(line)
|
||||
switch len(fields) {
|
||||
case 1:
|
||||
row.Iccid = fields[0]
|
||||
case 3:
|
||||
row.Iccid = fields[0]
|
||||
row.Msisdn = fields[1]
|
||||
row.Imsi = fields[2]
|
||||
default:
|
||||
row.AddErrorReason("invalid input format; expected ICCID or ICCID MSISDN IMSI")
|
||||
}
|
||||
rows = append(rows, row)
|
||||
total++
|
||||
if len(rows) == chunkSize {
|
||||
if err := emit(rows); err != nil {
|
||||
return total, err
|
||||
}
|
||||
rows = make([]batchimport.Row, 0, chunkSize)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return total, err
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, gerror.New("allocate input has no data rows")
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err := emit(rows); err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func rowFromExcel(parsed excel.ParsedRow) *Row {
|
||||
row := &Row{
|
||||
RowNo: parsed.RowNo,
|
||||
Iccid: excel.Field(parsed, "ICCID"),
|
||||
Msisdn: excel.Field(parsed, "MSISDN"),
|
||||
Imsi: excel.Field(parsed, "IMSI"),
|
||||
TargetDeptKey: firstExcelField(parsed, "\u76ee\u6807\u5ba2\u6237\u7f16\u7801", "\u5ba2\u6237\u7f16\u7801", "TARGETDEPTKEY", "TARGET_CUSTOMER_CODE"),
|
||||
TargetDeptName: firstExcelField(parsed, "\u76ee\u6807\u5ba2\u6237\u540d\u79f0", "\u5ba2\u6237\u540d\u79f0", "TARGETDEPTNAME", "TARGET_CUSTOMER_NAME"),
|
||||
Remark: firstExcelField(parsed, "\u5212\u62e8\u5907\u6ce8", "\u5907\u6ce8", "REMARK"),
|
||||
}
|
||||
if value := firstExcelField(parsed, "\u76ee\u6807\u5ba2\u6237ID", "\u76ee\u6807\u90e8\u95e8ID", "TARGETDEPTID", "TARGET_CUSTOMER_ID"); value != "" {
|
||||
if targetDeptID, err := parseTargetDeptID(value); err == nil {
|
||||
row.TargetDeptId = targetDeptID
|
||||
} else {
|
||||
row.AddErrorReason("invalid target customer id")
|
||||
}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func parseTargetDeptID(value string) (int64, error) {
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return 0, gerror.New("invalid target customer id")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func sourceUnitInfo(info *gjson.Json) *gjson.Json {
|
||||
return gjson.New(map[string]any{"sourceDeptId": info.Get("sourceDeptId").Int64(), "sourceDeptKey": info.Get("sourceDeptKey").String(), "sourceDeptName": info.Get("sourceDeptName").String()})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type allocateMetadataSQLiteDriver struct {
|
||||
*sqlite.Driver
|
||||
}
|
||||
|
||||
func (driver *allocateMetadataSQLiteDriver) New(core *gdb.Core, _ *gdb.ConfigNode) (gdb.DB, error) {
|
||||
return &allocateMetadataSQLiteDriver{Driver: &sqlite.Driver{Core: core}}, nil
|
||||
}
|
||||
|
||||
func (driver *allocateMetadataSQLiteDriver) DoSelect(ctx context.Context, link gdb.Link, query string, args ...any) (gdb.Result, error) {
|
||||
return driver.Core.DoSelect(ctx, link, strings.TrimSuffix(query, " FOR UPDATE"), args...)
|
||||
}
|
||||
|
||||
func TestAllocateSplitInheritsRequestMetadata(t *testing.T) {
|
||||
if err := gdb.Register("allocate_metadata_sqlite", &allocateMetadataSQLiteDriver{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "allocate_metadata_sqlite", Name: ":memory:", MaxOpenConnCount: 1}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = g.DB().Close(ctx) })
|
||||
if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for table, record := range map[string]any{"batch_main": entity.BatchMain{}, "batch_detail": entity.BatchDetail{}} {
|
||||
recordType := reflect.TypeOf(record)
|
||||
columns := make([]string, 0, recordType.NumField())
|
||||
for fieldIndex := 0; fieldIndex < recordType.NumField(); fieldIndex++ {
|
||||
field := recordType.Field(fieldIndex)
|
||||
column := field.Tag.Get("orm")
|
||||
if column == "id" {
|
||||
columns = append(columns, "id INTEGER PRIMARY KEY AUTOINCREMENT")
|
||||
} else {
|
||||
columns = append(columns, column+" TEXT")
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "CREATE TABLE "+table+" ("+strings.Join(columns, ",")+")"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.Model(table).Ctx(ctx).TableFields(table); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().GetCore().GetTablesWithCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mode := range []int{batchmetadata.Single, batchmetadata.Bulk} {
|
||||
parentNo := "META-SPLIT-SINGLE"
|
||||
if mode == batchmetadata.Bulk {
|
||||
parentNo = "META-SPLIT-BULK"
|
||||
}
|
||||
req := batchimport.SubmitRequest{SourceChannel: batchmetadata.Console, ExecutionMode: mode, SourceType: consts.BatchSourceTypeBatchInput, Operator: batchimport.OperatorContext{TenantId: "A", UserId: 7}}
|
||||
processor := NewProcessor()
|
||||
if err := createAllocateParent(ctx, req, processor, parentNo, nil, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := []batchimport.Row{&Row{RowNo: 1, Iccid: "card1"}}
|
||||
result, err := createAllocateChunk(ctx, req, processor, parentNo, 1, rows, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, batchNo := range []string{parentNo, result.BatchNo} {
|
||||
var batch *entity.BatchMain
|
||||
if err := g.Model("batch_main").Ctx(ctx).Where("batch_no", batchNo).Scan(&batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch == nil || batch.SourceChannel != req.SourceChannel || batch.ExecutionMode != uint(mode) || batch.SourceType != req.SourceType || batch.TotalCount != 1 {
|
||||
t.Fatalf("metadata not inherited: %+v", batch)
|
||||
}
|
||||
if batchNo != parentNo && batch.SourceBatchNo != parentNo {
|
||||
t.Fatalf("child lost parent association: %+v", batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/allocationunits"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
func CreatePendingExcel(ctx context.Context, file *ghttp.UploadFile, in *model.TSimCardAllocateExcelSubmitParam) (*PendingResult, error) {
|
||||
if in == nil {
|
||||
in = &model.TSimCardAllocateExcelSubmitParam{}
|
||||
}
|
||||
if file == nil {
|
||||
return nil, gerror.New("upload file is required")
|
||||
}
|
||||
if err := service.SysOss().CheckType(ctx, consts.SysUploadFileTypeFile, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.Size > allocateMaxFileSize(ctx) {
|
||||
return nil, gerror.Newf("uploaded file exceeds the limit of %d MB", allocateMaxFileSize(ctx)>>20)
|
||||
}
|
||||
|
||||
options := SubmitOptions{
|
||||
TargetDeptId: in.TargetDeptId,
|
||||
Remark: strings.TrimSpace(in.Remark),
|
||||
}
|
||||
processor := NewProcessor()
|
||||
parentBatchNo, err := batchimport.NextBatchNoContext(ctx, processor.Config().BatchNoPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extension := strings.ToLower(filepath.Ext(file.Filename))
|
||||
if extension == "" {
|
||||
extension = ".xlsx"
|
||||
}
|
||||
importOss, err := batchimport.SaveUpload(ctx, file, parentBatchNo+"-import"+extension, processor.Config().ImportDir)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "save allocate import file failed")
|
||||
}
|
||||
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
BizParams: &options,
|
||||
SourceType: consts.BatchSourceTypeFile,
|
||||
TargetDeptId: options.TargetDeptId,
|
||||
Operator: common.CurrentOperator(ctx),
|
||||
BatchNo: parentBatchNo,
|
||||
}
|
||||
stream := func(streamCtx context.Context, emit func([]batchimport.Row) error) (int, error) {
|
||||
chunkSize := allocateChunkSize(streamCtx)
|
||||
if extension == ".csv" {
|
||||
return streamAllocateCSV(streamCtx, importOss.Path, chunkSize, emit)
|
||||
}
|
||||
return streamAllocateExcel(streamCtx, importOss.Path, chunkSize, emit)
|
||||
}
|
||||
return createPendingSplit(ctx, req, processor, prepareOptions(options), parentBatchNo, importOss, stream)
|
||||
}
|
||||
|
||||
func CreatePendingInput(ctx context.Context, in *model.TSimCardAllocatePrepareParam) (*PendingResult, error) {
|
||||
if in == nil {
|
||||
in = &model.TSimCardAllocatePrepareParam{}
|
||||
}
|
||||
if in.TargetDeptId <= 0 {
|
||||
return nil, gerror.New("target customer is required")
|
||||
}
|
||||
|
||||
options := SubmitOptions{TargetDeptId: in.TargetDeptId}
|
||||
processor := NewProcessor()
|
||||
parentBatchNo, err := batchimport.NextBatchNoContext(ctx, processor.Config().BatchNoPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
BizParams: &options,
|
||||
SourceType: consts.BatchSourceTypeBatchInput,
|
||||
TargetDeptId: options.TargetDeptId,
|
||||
Operator: common.CurrentOperator(ctx),
|
||||
BatchNo: parentBatchNo,
|
||||
}
|
||||
stream := func(streamCtx context.Context, emit func([]batchimport.Row) error) (int, error) {
|
||||
return streamAllocateInput(streamCtx, in.Input, options.TargetDeptId, allocateChunkSize(streamCtx), emit)
|
||||
}
|
||||
return createPendingSplit(ctx, req, processor, prepareOptions(options), parentBatchNo, nil, stream)
|
||||
}
|
||||
|
||||
func prepareOptions(options SubmitOptions) batchimport.PendingOptions {
|
||||
return batchimport.PendingOptions{
|
||||
PrepareRows: func(ctx context.Context, rows []batchimport.Row) error {
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return gerror.New("allocate row type is invalid")
|
||||
}
|
||||
if row.TargetDeptId <= 0 && options.TargetDeptId > 0 {
|
||||
row.TargetDeptId = options.TargetDeptId
|
||||
}
|
||||
if row.Remark == "" {
|
||||
row.Remark = options.Remark
|
||||
}
|
||||
if row.TargetDeptId <= 0 && row.TargetDeptKey == "" && row.TargetDeptName == "" {
|
||||
row.AddErrorReason("target customer is required")
|
||||
}
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, item := range rows {
|
||||
if row, ok := item.(*Row); ok {
|
||||
ids = append(ids, row.TargetDeptId)
|
||||
}
|
||||
}
|
||||
units, err := allocationunits.Load(ctx, common.CurrentOperator(ctx).TenantId, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range rows {
|
||||
if row, ok := item.(*Row); ok {
|
||||
if unit, exists := units[row.TargetDeptId]; exists {
|
||||
row.TargetDeptKey = unit.Key
|
||||
row.TargetDeptName = unit.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pendingResultFromBatch(result *batchimport.Result) *PendingResult {
|
||||
res := &PendingResult{
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
}
|
||||
if result == nil {
|
||||
return res
|
||||
}
|
||||
res.BatchNo = result.BatchNo
|
||||
res.BatchType = result.BatchType
|
||||
res.Total = result.TotalRows
|
||||
res.ValidCount = result.ValidRows
|
||||
res.FailCount = result.ErrorRows
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type Row struct {
|
||||
RowNo int
|
||||
Iccid string
|
||||
Msisdn string
|
||||
Imsi string
|
||||
TargetDeptId int64
|
||||
TargetDeptKey string
|
||||
TargetDeptName string
|
||||
Remark string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (r *Row) GetRowNo() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
return r.RowNo
|
||||
}
|
||||
|
||||
func (r *Row) GetItemID() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.Iccid
|
||||
}
|
||||
|
||||
func (r *Row) GetErrorReasons() []string {
|
||||
if r == nil || strings.TrimSpace(r.Reason) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{strings.TrimSpace(r.Reason)}
|
||||
}
|
||||
|
||||
func (r *Row) AddErrorReason(reason string) {
|
||||
if r == nil || strings.TrimSpace(reason) == "" {
|
||||
return
|
||||
}
|
||||
if r.Reason != "" {
|
||||
r.Reason += ";"
|
||||
}
|
||||
r.Reason += strings.TrimSpace(reason)
|
||||
}
|
||||
|
||||
func (r *Row) Snapshot() *gjson.Json {
|
||||
if r == nil {
|
||||
return gjson.New(g.Map{})
|
||||
}
|
||||
return gjson.New(g.Map{
|
||||
"ICCID": strings.TrimSpace(r.Iccid),
|
||||
"MSISDN": strings.TrimSpace(r.Msisdn),
|
||||
"IMSI": strings.TrimSpace(r.Imsi),
|
||||
"targetDeptId": r.TargetDeptId,
|
||||
"targetDeptKey": strings.TrimSpace(r.TargetDeptKey),
|
||||
"targetDeptName": strings.TrimSpace(r.TargetDeptName),
|
||||
"remark": strings.TrimSpace(r.Remark),
|
||||
})
|
||||
}
|
||||
|
||||
type SubmitOptions struct {
|
||||
Units *gjson.Json
|
||||
TargetDeptId int64
|
||||
Remark string
|
||||
}
|
||||
|
||||
type PendingResult struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
BatchAsyncStatus int
|
||||
Total int
|
||||
ValidCount int
|
||||
FailCount int
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package cancel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
func ParseExcelFileRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
parsed, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{RequiredHeaders: []string{"ICCID"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(parsed))
|
||||
for _, item := range parsed {
|
||||
rows = append(rows, &Row{
|
||||
RowNo: item.RowNo,
|
||||
Iccid: excel.Field(item, "ICCID"),
|
||||
Msisdn: excel.Field(item, "MSISDN"),
|
||||
Imsi: excel.Field(item, "IMSI"),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func ParseInputRows(ctx context.Context, input string) ([]batchimport.Row, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New("批量输入内容不能为空")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &Row{RowNo: line.RowNo}
|
||||
switch len(line.Fields) {
|
||||
case 1:
|
||||
row.Iccid = line.Fields[0]
|
||||
case 3:
|
||||
row.Iccid = line.Fields[0]
|
||||
row.Msisdn = line.Fields[1]
|
||||
row.Imsi = line.Fields[2]
|
||||
default:
|
||||
row.AddErrorReason("格式错误,应为 ICCID 或 ICCID MSISDN IMSI")
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func validateBasicRows(rows []batchimport.Row) error {
|
||||
firstByIccid := make(map[string]*Row, len(rows))
|
||||
issues := make([]string, 0)
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return gerror.New("销户批次行类型错误")
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
row.Msisdn = strings.TrimSpace(row.Msisdn)
|
||||
row.Imsi = strings.TrimSpace(row.Imsi)
|
||||
if row.Reason != "" {
|
||||
issues = append(issues, fmt.Sprintf("第%d行:%s", row.RowNo, row.Reason))
|
||||
continue
|
||||
}
|
||||
if row.Iccid == "" {
|
||||
row.AddErrorReason("ICCID不能为空")
|
||||
issues = append(issues, fmt.Sprintf("第%d行:ICCID不能为空", row.RowNo))
|
||||
continue
|
||||
}
|
||||
if first, exists := firstByIccid[row.Iccid]; exists {
|
||||
row.AddErrorReason(fmt.Sprintf("ICCID与第%d行重复", first.RowNo))
|
||||
first.AddErrorReason("ICCID重复")
|
||||
issues = append(issues, fmt.Sprintf("第%d行:ICCID与第%d行重复", row.RowNo, first.RowNo))
|
||||
continue
|
||||
}
|
||||
firstByIccid[row.Iccid] = row
|
||||
}
|
||||
if len(issues) > 0 {
|
||||
return gerror.New("销户批次基础格式校验失败:" + strings.Join(issues, ";"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package cancel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type Processor struct{}
|
||||
|
||||
func NewProcessor() *Processor {
|
||||
return &Processor{}
|
||||
}
|
||||
|
||||
func (p *Processor) Config() batchimport.ProcessorConfig {
|
||||
return batchimport.ProcessorConfig{
|
||||
Code: "sim_card_cancel",
|
||||
BatchType: consts.BatchTypeCancel,
|
||||
BatchNoPrefix: consts.BatchNoPrefixCancel,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardCancel,
|
||||
ExportScene: consts.ResourceExportSceneCancel,
|
||||
ImportDir: consts.SimCardCancelSubDirImportExcel,
|
||||
ResultDir: consts.SimCardCancelSubDirResultExcel,
|
||||
MaxFileSize: consts.SimCardCancelMaxFileSize,
|
||||
TemplateHeaders: []string{"ICCID", "MSISDN", "IMSI"},
|
||||
RequiredHeaders: []string{"ICCID"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return ParseExcelFileRows(ctx, filePath)
|
||||
}
|
||||
|
||||
func (p *Processor) ValidateRows(_ context.Context, rows []batchimport.Row) error {
|
||||
return validateBasicRows(rows)
|
||||
}
|
||||
|
||||
func (p *Processor) Execute(context.Context, gdb.TX, *batchimport.SubmitRequest, []batchimport.Row) error {
|
||||
return gerror.New("销户批次只能由异步执行器执行")
|
||||
}
|
||||
|
||||
func (p *Processor) BuildBatchInfo(_ context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadID int64) *gjson.Json {
|
||||
info := g.Map{
|
||||
"operation": "cancel",
|
||||
"mode": "internal",
|
||||
"requiresStopped": true,
|
||||
"targetBossCardStatus": consts.V3ApiSimBossCardStatusCanceled,
|
||||
"resultFile": g.Map{
|
||||
"status": "pending",
|
||||
},
|
||||
}
|
||||
if options, ok := req.BizParams.(*SubmitOptions); ok && options != nil {
|
||||
if remark := strings.TrimSpace(options.Remark); remark != "" {
|
||||
info["remark"] = remark
|
||||
}
|
||||
}
|
||||
if result != nil && result.ImportFileOssId > 0 {
|
||||
info["importFileOssId"] = result.ImportFileOssId
|
||||
} else if importOss != nil && importOss.OssId > 0 {
|
||||
info["importFileOssId"] = importOss.OssId
|
||||
}
|
||||
if resultOss != nil {
|
||||
info["resultFileOssId"] = resultOss.OssId
|
||||
}
|
||||
if downloadID > 0 {
|
||||
info["resultDownloadId"] = downloadID
|
||||
}
|
||||
return gjson.New(info)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package cancel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
type Submitter struct{}
|
||||
|
||||
func NewSubmitter() *Submitter {
|
||||
return &Submitter{}
|
||||
}
|
||||
|
||||
func (s *Submitter) SubmitExcel(ctx context.Context, file *ghttp.UploadFile, remark string) (*SubmitResult, error) {
|
||||
processor := NewProcessor()
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
File: file,
|
||||
BizParams: &SubmitOptions{Remark: strings.TrimSpace(remark)},
|
||||
SourceType: consts.BatchSourceTypeFile,
|
||||
Operator: currentOperator(ctx),
|
||||
}
|
||||
result, err := batchimport.CreatePendingUpload(ctx, req, processor, batchimport.PendingOptions{
|
||||
PrepareRows: s.prepareAuthorizedRows,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.publish(ctx, result)
|
||||
return pendingSubmitResult(result), nil
|
||||
}
|
||||
|
||||
func (s *Submitter) SubmitInput(ctx context.Context, input, remark string) (*SubmitResult, error) {
|
||||
rows, err := ParseInputRows(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
processor := NewProcessor()
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
BizParams: &SubmitOptions{Remark: strings.TrimSpace(remark)},
|
||||
SourceType: consts.BatchSourceTypeBatchInput,
|
||||
Operator: currentOperator(ctx),
|
||||
}
|
||||
result, err := batchimport.CreatePendingRows(ctx, req, processor, rows, batchimport.PendingOptions{
|
||||
PrepareRows: s.prepareAuthorizedRows,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.publish(ctx, result)
|
||||
return pendingSubmitResult(result), nil
|
||||
}
|
||||
|
||||
func (s *Submitter) publish(ctx context.Context, result *batchimport.Result) {
|
||||
if result == nil || strings.TrimSpace(result.BatchNo) == "" {
|
||||
return
|
||||
}
|
||||
if err := publishBatchExecute(ctx, batchimport.Message{
|
||||
BatchNo: result.BatchNo,
|
||||
BatchType: result.BatchType,
|
||||
}); err != nil {
|
||||
g.Log().Errorf(ctx, "cancel batch async publish failed, batchNo=%s, batchType=%d, tenantId=%s, error=%+v",
|
||||
result.BatchNo, result.BatchType, contexts.GetTenantId(ctx), err)
|
||||
}
|
||||
}
|
||||
|
||||
var publishBatchExecute = func(ctx context.Context, msg batchimport.Message) error {
|
||||
return rabbitmq.Publish(ctx, consts.QueueBatchExecute, msg)
|
||||
}
|
||||
|
||||
func (s *Submitter) prepareAuthorizedRows(ctx context.Context, rows []batchimport.Row) error {
|
||||
cardRows := make([]*Row, 0, len(rows))
|
||||
iccids := make([]string, 0, len(rows))
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return gerror.New("销户批次行类型错误")
|
||||
}
|
||||
cardRows = append(cardRows, row)
|
||||
if row.Iccid != "" {
|
||||
iccids = append(iccids, row.Iccid)
|
||||
}
|
||||
}
|
||||
if len(iccids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var visible []struct {
|
||||
Iccid string `orm:"iccid"`
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{FilterTenant: true, FilterAuth: true}).
|
||||
Fields(columns.Iccid, columns.TenantId, columns.DeptId, columns.CreatedDept, columns.CreatedBy).
|
||||
WhereIn(columns.Iccid, iccids).
|
||||
Scan(&visible)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "计算SIM卡数据权限失败")
|
||||
}
|
||||
authorized := make(map[string]struct{}, len(visible))
|
||||
for _, card := range visible {
|
||||
authorized[strings.TrimSpace(card.Iccid)] = struct{}{}
|
||||
}
|
||||
for _, row := range cardRows {
|
||||
_, row.Authorized = authorized[row.Iccid]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentOperator(ctx context.Context) batchimport.OperatorContext {
|
||||
return batchimport.OperatorContext{
|
||||
TenantId: contexts.GetTenantId(ctx),
|
||||
DeptId: contexts.GetDeptId(ctx),
|
||||
UserId: contexts.GetUserId(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func pendingSubmitResult(result *batchimport.Result) *SubmitResult {
|
||||
res := &SubmitResult{BatchAsyncStatus: consts.BatchAsyncStatusPending}
|
||||
if result == nil {
|
||||
return res
|
||||
}
|
||||
res.BatchNo = result.BatchNo
|
||||
res.BatchType = result.BatchType
|
||||
res.Total = result.TotalRows
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package cancel
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type Row struct {
|
||||
DetailID int64
|
||||
RowNo int
|
||||
Iccid string
|
||||
Msisdn string
|
||||
Imsi string
|
||||
Authorized bool
|
||||
Reason string
|
||||
StatusBefore int
|
||||
}
|
||||
|
||||
func (r *Row) GetRowNo() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
return r.RowNo
|
||||
}
|
||||
|
||||
func (r *Row) GetItemID() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.Iccid
|
||||
}
|
||||
|
||||
func (r *Row) GetErrorReasons() []string {
|
||||
if r == nil || r.Reason == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{r.Reason}
|
||||
}
|
||||
|
||||
func (r *Row) AddErrorReason(reason string) {
|
||||
if r == nil || r.Reason != "" || reason == "" {
|
||||
return
|
||||
}
|
||||
r.Reason = reason
|
||||
}
|
||||
|
||||
func (r *Row) Snapshot() *gjson.Json {
|
||||
if r == nil {
|
||||
return gjson.New(g.Map{})
|
||||
}
|
||||
return gjson.New(g.Map{
|
||||
"ICCID": r.Iccid,
|
||||
"MSISDN": r.Msisdn,
|
||||
"IMSI": r.Imsi,
|
||||
"authorized": r.Authorized,
|
||||
"statusBefore": r.StatusBefore,
|
||||
})
|
||||
}
|
||||
|
||||
type SubmitOptions struct {
|
||||
Remark string
|
||||
}
|
||||
|
||||
type SubmitResult struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
BatchAsyncStatus int
|
||||
Total int
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Batch *entity.BatchMain
|
||||
Rows []*Row
|
||||
Operator batchimport.OperatorContext
|
||||
Remark string
|
||||
StartTime *gtime.Time
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type AsyncConfirmInput struct {
|
||||
BatchNo string
|
||||
TargetBatchType int
|
||||
NewPackageCode string
|
||||
DurationMonths int
|
||||
GiftDurationMonths int
|
||||
EffectiveMode string
|
||||
OfflineContract bool
|
||||
BuyerNote string
|
||||
AdminNote string
|
||||
Remark string
|
||||
PriceSnapshot *model.TSimCardChangePriceSnapshot
|
||||
SourceBatchNo string
|
||||
PackageBeginTimeStr string
|
||||
Operator batchimport.OperatorContext
|
||||
}
|
||||
|
||||
type AsyncConfirmResult struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
BatchAsyncStatus int
|
||||
}
|
||||
|
||||
// ConvertPrepareToAsync 以 CAS 方式把 batch_type=4 + batch_async_status=Success + run_result=Success 的预处理批次
|
||||
// 转换为 batch_type=102 的待处理变更批次:重置主表正式执行相关字段并把明细的 BatchType 切换到 102、DetailStatus 重置为 Pending。
|
||||
func ConvertPrepareToAsync(ctx context.Context, tx gdb.TX, in *AsyncConfirmInput) (*AsyncConfirmResult, error) {
|
||||
if in == nil {
|
||||
return nil, gerror.New("变更确认参数不能为空")
|
||||
}
|
||||
batchNo := strings.TrimSpace(in.BatchNo)
|
||||
if batchNo == "" {
|
||||
return nil, gerror.New("变更批次号不能为空")
|
||||
}
|
||||
targetBatchType := in.TargetBatchType
|
||||
if targetBatchType == 0 {
|
||||
targetBatchType = consts.BatchTypeChange
|
||||
}
|
||||
columns := dao.BatchMain.Columns()
|
||||
main := &entity.BatchMain{}
|
||||
err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(columns.BatchNo, batchNo).
|
||||
Where(columns.TenantId, contexts.GetTenantId(ctx)).
|
||||
Where(columns.CreatedDept, contexts.GetDeptId(ctx)).
|
||||
LockUpdate().
|
||||
Scan(main)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询变更预处理批次失败")
|
||||
}
|
||||
if main.Id == 0 {
|
||||
return nil, gerror.New("变更预处理批次不存在")
|
||||
}
|
||||
if main.BatchType != consts.BatchTypePrepare {
|
||||
return nil, gerror.New("变更批次不是预处理类型")
|
||||
}
|
||||
if main.BatchAsyncStatus != consts.BatchAsyncStatusSuccess {
|
||||
return nil, gerror.New("变更预处理批次尚未完成")
|
||||
}
|
||||
if main.RunResult != consts.BatchRunResultSuccess {
|
||||
return nil, gerror.New("变更预处理批次未成功")
|
||||
}
|
||||
if main.BatchInfo == nil {
|
||||
return nil, gerror.New("变更预处理批次缺少快照")
|
||||
}
|
||||
if main.BatchInfo.Get("targetBatchType").Int() != targetBatchType {
|
||||
return nil, gerror.New("变更预处理批次类型不匹配")
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
info := mergeConfirmBatchInfo(main.BatchInfo, in)
|
||||
data := do.BatchMain{
|
||||
BatchType: targetBatchType,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
RunResult: consts.BatchRunResultPending,
|
||||
StartTime: nil,
|
||||
EndTime: nil,
|
||||
FailReason: "",
|
||||
SuccessCount: 0,
|
||||
FailCount: 0,
|
||||
BatchInfo: gjson.New(info),
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
res, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(columns.Id, main.Id).
|
||||
Where(columns.BatchType, consts.BatchTypePrepare).
|
||||
Where(columns.BatchAsyncStatus, consts.BatchAsyncStatusSuccess).
|
||||
Where(columns.RunResult, consts.BatchRunResultSuccess).
|
||||
Data(data).
|
||||
Update()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "更新变更批次状态失败")
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "读取变更批次更新结果失败")
|
||||
}
|
||||
if affected != 1 {
|
||||
return nil, gerror.New("变更批次状态已变化,请重试")
|
||||
}
|
||||
|
||||
detailColumns := dao.BatchDetail.Columns()
|
||||
if _, err = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(detailColumns.BatchNo, batchNo).
|
||||
Where(detailColumns.BatchType, consts.BatchTypePrepare).
|
||||
Data(do.BatchDetail{
|
||||
BatchType: targetBatchType,
|
||||
DetailStatus: consts.BatchDetailStatusPending,
|
||||
ErrorCode: "",
|
||||
ErrorReason: "",
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}).
|
||||
Update(); err != nil {
|
||||
return nil, gerror.Wrap(err, "更新变更批次明细类型失败")
|
||||
}
|
||||
|
||||
return &AsyncConfirmResult{
|
||||
BatchNo: batchNo,
|
||||
BatchType: targetBatchType,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mergeConfirmBatchInfo(base *gjson.Json, in *AsyncConfirmInput) g.Map {
|
||||
data := g.Map{}
|
||||
if base != nil {
|
||||
for key, value := range base.Map() {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
if in == nil {
|
||||
return data
|
||||
}
|
||||
if newPackageCode := strings.TrimSpace(in.NewPackageCode); newPackageCode != "" {
|
||||
data["newPackageCode"] = newPackageCode
|
||||
}
|
||||
if in.DurationMonths > 0 {
|
||||
data["durationMonths"] = in.DurationMonths
|
||||
}
|
||||
if in.GiftDurationMonths >= 0 {
|
||||
data["giftDurationMonths"] = in.GiftDurationMonths
|
||||
}
|
||||
if effectiveMode := strings.TrimSpace(in.EffectiveMode); effectiveMode != "" {
|
||||
data["effectiveMode"] = effectiveMode
|
||||
}
|
||||
if strings.TrimSpace(in.PackageBeginTimeStr) != "" {
|
||||
data["packageBeginTime"] = strings.TrimSpace(in.PackageBeginTimeStr)
|
||||
}
|
||||
data["offlineContractCompleted"] = in.OfflineContract
|
||||
if note := strings.TrimSpace(in.BuyerNote); note != "" {
|
||||
data["buyerNote"] = note
|
||||
}
|
||||
if note := strings.TrimSpace(in.AdminNote); note != "" {
|
||||
data["adminNote"] = note
|
||||
}
|
||||
if note := strings.TrimSpace(in.Remark); note != "" {
|
||||
data["remark"] = note
|
||||
}
|
||||
if in.PriceSnapshot != nil {
|
||||
data["changeUnitPrice"] = in.PriceSnapshot.ChangeUnitPrice
|
||||
}
|
||||
if strings.TrimSpace(in.SourceBatchNo) != "" {
|
||||
data["sourceBatchNo"] = strings.TrimSpace(in.SourceBatchNo)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/cardpackage"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func (p *Processor) Complete(ctx context.Context, tx gdb.TX, in *CompleteInput) (*CompleteOutput, error) {
|
||||
if in == nil || in.SubmitParam == nil {
|
||||
return nil, gerror.New("change params cannot be empty")
|
||||
}
|
||||
sourceBatchNo := strings.TrimSpace(in.SourceBatchNo)
|
||||
if strings.TrimSpace(in.BatchNo) == "" {
|
||||
return nil, gerror.New("change batchNo cannot be empty")
|
||||
}
|
||||
if len(in.Rows) == 0 {
|
||||
return nil, gerror.New("change cards cannot be empty")
|
||||
}
|
||||
if in.PriceSnapshot == nil {
|
||||
return nil, gerror.New("change price snapshot cannot be empty")
|
||||
}
|
||||
if in.PackageBeginTime == nil {
|
||||
return nil, gerror.New("change package begin time cannot be empty")
|
||||
}
|
||||
|
||||
iccids := rowsToIccids(in.Rows)
|
||||
packageSvc := cardpackage.NewService()
|
||||
termination, err := packageSvc.TerminateCurrentPackage(ctx, tx, &cardpackage.TerminateCurrentPackageInput{
|
||||
BatchNo: in.BatchNo,
|
||||
SourceBatchNo: sourceBatchNo,
|
||||
Iccids: iccids,
|
||||
Operator: in.Operator,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
voidedByIccid, err := p.voidFuturePendingBinds(ctx, tx, iccids, in.BatchNo, termination.UnsubTime, in.Operator.UserId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
quantity := len(iccids)
|
||||
totalAmount := roundAmount(in.PriceSnapshot.ChangeUnitPrice * float64(in.SubmitParam.DurationMonths) * float64(quantity))
|
||||
cards := make([]cardpackage.SubscribeCard, 0, quantity)
|
||||
for _, iccid := range iccids {
|
||||
card := termination.CardsByIccid[iccid]
|
||||
if card == nil {
|
||||
return nil, gerror.Newf("ICCID card info not found: %s", iccid)
|
||||
}
|
||||
cards = append(cards, cardpackage.SubscribeCard{
|
||||
Iccid: iccid,
|
||||
Msisdn: strings.TrimSpace(card.Msisdn),
|
||||
Imsi: strings.TrimSpace(card.Imsi),
|
||||
})
|
||||
}
|
||||
|
||||
subscribe, err := packageSvc.CreatePackageSubscription(ctx, tx, &cardpackage.CreatePackageSubscriptionInput{
|
||||
BatchNo: in.BatchNo,
|
||||
Cards: cards,
|
||||
CustomerAgentId: in.PriceSnapshot.CustomerAgentId,
|
||||
PackageCode: in.PriceSnapshot.NewPackageCode,
|
||||
PackageBeginTime: in.PackageBeginTime,
|
||||
DurationMonths: in.SubmitParam.DurationMonths,
|
||||
GiftDurationMonths: in.SubmitParam.GiftDurationMonths,
|
||||
Amount: fmt.Sprintf("%.2f", totalAmount),
|
||||
OfflineContractCompleted: in.SubmitParam.OfflineContractCompleted,
|
||||
BuyerNote: in.SubmitParam.BuyerNote,
|
||||
AdminNote: in.SubmitParam.AdminNote,
|
||||
PreviousPackageCode: termination.PackageCode,
|
||||
BusinessType: consts.PackageBusinessTypeChange,
|
||||
OrderRemark: "sim card change order",
|
||||
BindRemark: in.BatchNo,
|
||||
Operator: in.Operator,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = p.updateCardsAfterChange(ctx, tx, iccids, termination.CardsByIccid, subscribe, in.Operator.UserId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
detailUpdates := make(map[string]any, len(iccids))
|
||||
for _, row := range in.Rows {
|
||||
card := termination.CardsByIccid[row.Iccid]
|
||||
oldBind := termination.BindsByIccid[row.Iccid]
|
||||
newBind := subscribe.BindsByIccid[row.Iccid]
|
||||
detailUpdates[row.Iccid] = buildDetailSnapshot(row, card, oldBind, newBind, voidedByIccid[row.Iccid], in, termination, subscribe)
|
||||
}
|
||||
if err = updateBatchDetailSnapshots(ctx, tx, in.BatchNo, detailUpdates, in.Operator.UserId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = convertBatchDetailsAfterComplete(ctx, tx, in.BatchNo, in.Operator.UserId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
output := &CompleteOutput{
|
||||
BatchNo: in.BatchNo,
|
||||
BatchType: consts.BatchTypeChange,
|
||||
SourceBatchNo: sourceBatchNo,
|
||||
SourceMode: sourceMode(sourceBatchNo),
|
||||
Quantity: quantity,
|
||||
CustomerAgentId: in.PriceSnapshot.CustomerAgentId,
|
||||
CustomerName: "",
|
||||
OldPackageCode: termination.PackageCode,
|
||||
NewPackageCode: subscribe.Package.PackageCode,
|
||||
NewPackageName: subscribe.Package.PackageName,
|
||||
OrderSn: subscribe.OrderSn,
|
||||
Amount: subscribe.Amount,
|
||||
ChangeUnitPrice: in.PriceSnapshot.ChangeUnitPrice,
|
||||
DurationMonths: in.SubmitParam.DurationMonths,
|
||||
GiftDurationMonths: in.SubmitParam.GiftDurationMonths,
|
||||
EffectiveMode: strings.TrimSpace(in.SubmitParam.EffectiveMode),
|
||||
OfflineContractCompleted: in.SubmitParam.OfflineContractCompleted,
|
||||
PackageBeginTime: subscribe.PackageBeginAt,
|
||||
PackageEndTime: subscribe.PackageEndAt,
|
||||
ChangeTime: subscribe.SubscribeTime,
|
||||
}
|
||||
if in.PrepareSnapshot != nil {
|
||||
output.CustomerName = in.PrepareSnapshot.CustomerName
|
||||
output.OldPackageName = in.PrepareSnapshot.OldPackageName
|
||||
}
|
||||
if err = updateBatchMainAfterComplete(ctx, tx, in, output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (p *Processor) voidFuturePendingBinds(ctx context.Context, tx gdb.TX, iccids []string, batchNo string, voidTime *gtime.Time, userId int64) (map[string][]string, error) {
|
||||
result := make(map[string][]string)
|
||||
pendingBinds, err := p.repo.LockPendingBindsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "lock future pending binds failed")
|
||||
}
|
||||
if len(pendingBinds) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
orderCodeSet := make(map[string]struct{})
|
||||
orderVoidCounts := make(map[string]int)
|
||||
subscriptionNos := make([]string, 0, len(pendingBinds))
|
||||
logRows := make([]do.TSimBindPackageLogs, 0, len(pendingBinds))
|
||||
for _, bind := range pendingBinds {
|
||||
if bind == nil {
|
||||
continue
|
||||
}
|
||||
subscriptionNo := strings.TrimSpace(bind.SubscriptionNo)
|
||||
if subscriptionNo == "" {
|
||||
continue
|
||||
}
|
||||
subscriptionNos = append(subscriptionNos, subscriptionNo)
|
||||
result[strings.TrimSpace(bind.Iccid)] = append(result[strings.TrimSpace(bind.Iccid)], subscriptionNo)
|
||||
logRows = append(logRows, bindToVoidedHistory(bind, voidTime, batchNo, userId))
|
||||
orderCode := strings.TrimSpace(bind.OrderCode)
|
||||
if orderCode != "" {
|
||||
orderCodeSet[orderCode] = struct{}{}
|
||||
orderVoidCounts[orderCode]++
|
||||
}
|
||||
}
|
||||
if len(logRows) > 0 {
|
||||
if _, err = tx.Ctx(ctx).Model(dao.TSimBindPackageLogs.Table()).
|
||||
Data(logRows).
|
||||
OnConflict(dao.TSimBindPackageLogs.Columns().SubscriptionNo).
|
||||
Save(); err != nil {
|
||||
return nil, gerror.Wrap(err, "save voided bind history failed")
|
||||
}
|
||||
}
|
||||
affected, err := p.repo.DeleteBindsBySubscriptionNos(ctx, tx, subscriptionNos)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "delete future pending binds failed")
|
||||
}
|
||||
if int(affected) != len(subscriptionNos) {
|
||||
return nil, gerror.New("some future pending binds changed, please retry")
|
||||
}
|
||||
if err = p.updateOrdersAfterChangeVoid(ctx, tx, mapKeys(orderCodeSet), orderVoidCounts, batchNo, voidTime, userId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func bindToVoidedHistory(bind *entity.TSimCardBindPkg, actualExpireAt *gtime.Time, changeBatchNo string, userId int64) do.TSimBindPackageLogs {
|
||||
return do.TSimBindPackageLogs{
|
||||
SubscriptionNo: bind.SubscriptionNo,
|
||||
BatchNo: bind.BatchNo,
|
||||
Iccid: bind.Iccid,
|
||||
Msisdn: bind.Msisdn,
|
||||
DeptId: bind.DeptId,
|
||||
PackageType: bind.PackageType,
|
||||
PackageCode: bind.PackageCode,
|
||||
PreviousPackageCode: bind.PreviousPackageCode,
|
||||
BusinessType: bind.BusinessType,
|
||||
EffectiveStatus: consts.EffectiveStatusVoided,
|
||||
HandleTime: bind.HandleTime,
|
||||
EffectiveTime: bind.EffectiveTime,
|
||||
ExpectedExpireTime: bind.ExpectedExpireTime,
|
||||
ActualExpireTime: actualExpireAt,
|
||||
DurationMonths: bind.DurationMonths,
|
||||
GiftDurationMonths: bind.GiftDurationMonths,
|
||||
OrderCode: bind.OrderCode,
|
||||
Remark: appendRemark(bind.Remark, fmt.Sprintf("changeBatchNo=%s", changeBatchNo)),
|
||||
TenantId: bind.TenantId,
|
||||
CreatedDept: bind.CreatedDept,
|
||||
CreatedBy: bind.CreatedBy,
|
||||
CreatedAt: bind.CreatedAt,
|
||||
UpdatedBy: userId,
|
||||
UpdatedAt: actualExpireAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) updateOrdersAfterChangeVoid(ctx context.Context, tx gdb.TX, orderCodes []string, voidCounts map[string]int, batchNo string, voidTime *gtime.Time, userId int64) error {
|
||||
if len(orderCodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
activeCounts, err := p.repo.ActiveBindCountsByOrderCodes(ctx, tx, orderCodes)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "count active binds by order failed")
|
||||
}
|
||||
for _, orderCode := range orderCodes {
|
||||
returnStatus := consts.OrderReturnStatusSuccess
|
||||
if activeCounts[orderCode] > 0 {
|
||||
returnStatus = consts.OrderReturnStatusPartial
|
||||
}
|
||||
if _, err = tx.Ctx(ctx).Model(dao.TOrder.Table()).
|
||||
Where(dao.TOrder.Columns().OrderSn, orderCode).
|
||||
Data(do.TOrder{
|
||||
ReturnStatus: returnStatus,
|
||||
UpdatedBy: userId,
|
||||
UpdatedAt: voidTime,
|
||||
}).
|
||||
Update(); err != nil {
|
||||
return gerror.Wrap(err, "update order return status failed")
|
||||
}
|
||||
if err = updateOrderItemChangeExtra(ctx, tx, orderCode, batchNo, voidCounts[orderCode], voidTime, userId); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateOrderItemChangeExtra(ctx context.Context, tx gdb.TX, orderCode, batchNo string, voidQuantity int, voidTime *gtime.Time, userId int64) error {
|
||||
var item *entity.TOrderItem
|
||||
if err := tx.Ctx(ctx).Model(dao.TOrderItem.Table()).
|
||||
Where(dao.TOrderItem.Columns().OrderSn, orderCode).
|
||||
Limit(1).
|
||||
Scan(&item); err != nil {
|
||||
return gerror.Wrap(err, "load order item failed")
|
||||
}
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
extra := g.Map{}
|
||||
if strings.TrimSpace(item.ExtraSkuData) != "" {
|
||||
_ = json.Unmarshal([]byte(item.ExtraSkuData), &extra)
|
||||
}
|
||||
extra["lastChangeBatchNo"] = batchNo
|
||||
extra["lastChangeTime"] = formatTime(voidTime)
|
||||
extra["voidedByChangeQuantity"] = voidQuantity
|
||||
|
||||
data, err := json.Marshal(extra)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "marshal order item extra failed")
|
||||
}
|
||||
if _, err = tx.Ctx(ctx).Model(dao.TOrderItem.Table()).
|
||||
Where(dao.TOrderItem.Columns().ItemId, item.ItemId).
|
||||
Data(do.TOrderItem{
|
||||
ExtraSkuData: string(data),
|
||||
UpdatedBy: userId,
|
||||
UpdatedAt: voidTime,
|
||||
}).
|
||||
Update(); err != nil {
|
||||
return gerror.Wrap(err, "update order item extra failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Processor) updateCardsAfterChange(ctx context.Context, tx gdb.TX, iccids []string, cardsByIccid map[string]*entity.TSimCardInfo, subscribe *cardpackage.CreatePackageSubscriptionOutput, userId int64) error {
|
||||
if subscribe == nil || subscribe.Package == nil {
|
||||
return gerror.New("change subscription output is empty")
|
||||
}
|
||||
now := gtime.Now()
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
for _, iccid := range iccids {
|
||||
card := cardsByIccid[iccid]
|
||||
if card == nil {
|
||||
return gerror.Newf("ICCID card info not found: %s", iccid)
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
|
||||
Where(columns.Iccid, iccid).
|
||||
Data(g.Map{
|
||||
columns.CurrentPackageCode: subscribe.Package.PackageCode,
|
||||
columns.PackageStatus: subscribe.PackageStatus,
|
||||
columns.FlowPkgBeginAt: subscribe.PackageBeginAt,
|
||||
columns.FlowPkgPresentAt: nil,
|
||||
columns.FlowPkgRealPresentAt: nil,
|
||||
columns.FlowPkgEndAt: subscribe.PackageEndAt,
|
||||
columns.DataServiceStatus: 1,
|
||||
columns.PackageTotalFlowMb: subscribe.Package.FlowMb,
|
||||
columns.UsedFlowMb: 0,
|
||||
columns.TrafficCutoffStatus: 0,
|
||||
columns.TrafficCutoffThreshold: 0,
|
||||
columns.SmsServiceStatus: 0,
|
||||
columns.PackageTotalSms: 0,
|
||||
columns.UsedSms: 0,
|
||||
columns.VoiceServiceStatus: 0,
|
||||
columns.PackageTotalVoice: 0,
|
||||
columns.UsedVoice: 0,
|
||||
columns.RoamServiceStatus: 0,
|
||||
columns.PackageTotalRoamFlowMb: 0,
|
||||
columns.UsedRoamFlowMb: 0,
|
||||
columns.UpdatedBy: userId,
|
||||
columns.UpdatedAt: now,
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "update card after change failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "get updated card count failed")
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.Newf("ICCID card changed concurrently, retry: %s", iccid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateBatchDetailSnapshots(ctx context.Context, tx gdb.TX, batchNo string, snapshots map[string]any, userId int64) error {
|
||||
for iccid, snapshot := range snapshots {
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().ItemId, iccid).
|
||||
Data(do.BatchDetail{
|
||||
Snapshot: gjson.New(snapshot),
|
||||
UpdatedBy: userId,
|
||||
UpdatedAt: gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "update change batch detail snapshot failed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertBatchDetailsAfterComplete(ctx context.Context, tx gdb.TX, batchNo string, userId int64) error {
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Data(do.BatchDetail{
|
||||
BatchType: consts.BatchTypeChange,
|
||||
UpdatedBy: userId,
|
||||
UpdatedAt: gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "update change batch detail type failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateBatchMainAfterComplete(ctx context.Context, tx gdb.TX, in *CompleteInput, output *CompleteOutput) error {
|
||||
info := g.Map{
|
||||
"sourceMode": output.SourceMode,
|
||||
"orderSn": output.OrderSn,
|
||||
"customerAgentId": output.CustomerAgentId,
|
||||
"customerName": output.CustomerName,
|
||||
"oldPackageCode": output.OldPackageCode,
|
||||
"oldPackageName": output.OldPackageName,
|
||||
"newPackageCode": output.NewPackageCode,
|
||||
"newPackageName": output.NewPackageName,
|
||||
"durationMonths": output.DurationMonths,
|
||||
"giftDurationMonths": output.GiftDurationMonths,
|
||||
"effectiveMode": output.EffectiveMode,
|
||||
"changeUnitPrice": output.ChangeUnitPrice,
|
||||
"amount": output.Amount,
|
||||
"quantity": output.Quantity,
|
||||
"packageBeginTime": output.PackageBeginTime,
|
||||
"packageEndTime": output.PackageEndTime,
|
||||
"offlineContractCompleted": output.OfflineContractCompleted,
|
||||
"changeTime": output.ChangeTime,
|
||||
}
|
||||
if output.SourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = output.SourceBatchNo
|
||||
}
|
||||
if in.PriceSnapshot != nil {
|
||||
info["priceSource"] = in.PriceSnapshot.PriceSource
|
||||
info["agentDefaultPriceId"] = in.PriceSnapshot.AgentDefaultPriceId
|
||||
info["targetPriceId"] = in.PriceSnapshot.TargetPriceId
|
||||
info["renewableDurationOptions"] = in.PriceSnapshot.RenewableDurationOptions
|
||||
}
|
||||
if buyerNote := strings.TrimSpace(in.SubmitParam.BuyerNote); buyerNote != "" {
|
||||
info["buyerNote"] = buyerNote
|
||||
}
|
||||
if adminNote := strings.TrimSpace(in.SubmitParam.AdminNote); adminNote != "" {
|
||||
info["adminNote"] = adminNote
|
||||
}
|
||||
if remark := strings.TrimSpace(in.SubmitParam.Remark); remark != "" {
|
||||
info["remark"] = remark
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, in.BatchNo).
|
||||
Data(do.BatchMain{
|
||||
BatchType: consts.BatchTypeChange,
|
||||
OrderSn: output.OrderSn,
|
||||
TargetDeptId: output.CustomerAgentId,
|
||||
BatchInfo: gjson.New(info),
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: output.ChangeTime,
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "update change batch failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDetailSnapshot(row *Row, card *entity.TSimCardInfo, oldBind, newBind *entity.TSimCardBindPkg, voidedSubscriptionNos []string, in *CompleteInput, termination *cardpackage.TerminateCurrentPackageOutput, subscribe *cardpackage.CreatePackageSubscriptionOutput) g.Map {
|
||||
data := g.Map{
|
||||
"ICCID": row.Iccid,
|
||||
"MSISDN": row.Msisdn,
|
||||
"IMSI": row.Imsi,
|
||||
"sourceMode": sourceMode(in.SourceBatchNo),
|
||||
"changeBatchNo": in.BatchNo,
|
||||
"oldPackageCode": termination.PackageCode,
|
||||
"newPackageCode": subscribe.Package.PackageCode,
|
||||
"newPackageName": subscribe.Package.PackageName,
|
||||
"voidedSubscriptionNos": voidedSubscriptionNos,
|
||||
"durationMonths": in.SubmitParam.DurationMonths,
|
||||
"giftDurationMonths": in.SubmitParam.GiftDurationMonths,
|
||||
"effectiveMode": strings.TrimSpace(in.SubmitParam.EffectiveMode),
|
||||
"offlineContractCompleted": in.SubmitParam.OfflineContractCompleted,
|
||||
"changeUnitPrice": in.PriceSnapshot.ChangeUnitPrice,
|
||||
"amount": roundAmount(in.PriceSnapshot.ChangeUnitPrice * float64(in.SubmitParam.DurationMonths)),
|
||||
"newEffectiveTime": formatTime(subscribe.PackageBeginAt),
|
||||
"newExpectedExpireTime": formatTime(subscribe.PackageEndAt),
|
||||
"oldActualExpireTime": formatTime(termination.UnsubTime),
|
||||
"rowNo": row.RowNo,
|
||||
"rawFields": row.RawFields,
|
||||
}
|
||||
if strings.TrimSpace(in.SourceBatchNo) != "" {
|
||||
data["sourceBatchNo"] = strings.TrimSpace(in.SourceBatchNo)
|
||||
}
|
||||
if card != nil {
|
||||
data["ICCID"] = card.Iccid
|
||||
data["MSISDN"] = card.Msisdn
|
||||
data["IMSI"] = card.Imsi
|
||||
}
|
||||
if in.PrepareSnapshot != nil {
|
||||
data["oldPackageName"] = in.PrepareSnapshot.OldPackageName
|
||||
}
|
||||
if oldBind != nil {
|
||||
data["oldSubscriptionNo"] = oldBind.SubscriptionNo
|
||||
data["oldOrderSn"] = oldBind.OrderCode
|
||||
data["oldEffectiveTime"] = formatTime(oldBind.EffectiveTime)
|
||||
data["oldExpectedExpireTime"] = formatTime(oldBind.ExpectedExpireTime)
|
||||
}
|
||||
if newBind != nil {
|
||||
data["newSubscriptionNo"] = newBind.SubscriptionNo
|
||||
data["newOrderSn"] = newBind.OrderCode
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func appendRemark(old, addition string) string {
|
||||
old = strings.TrimSpace(old)
|
||||
addition = strings.TrimSpace(addition)
|
||||
if old == "" {
|
||||
return addition
|
||||
}
|
||||
if addition == "" {
|
||||
return old
|
||||
}
|
||||
return old + "; " + addition
|
||||
}
|
||||
|
||||
func mapKeys(set map[string]struct{}) []string {
|
||||
result := make([]string, 0, len(set))
|
||||
for key := range set {
|
||||
if strings.TrimSpace(key) != "" {
|
||||
result = append(result, key)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func roundAmount(value float64) float64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(int(value*100+0.5)) / 100
|
||||
}
|
||||
|
||||
func sourceMode(sourceBatchNo string) int {
|
||||
if strings.TrimSpace(sourceBatchNo) != "" {
|
||||
return consts.BatchSourceTypeBatchSelect
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
)
|
||||
|
||||
type ExcelProcessor struct {
|
||||
*Processor
|
||||
options *ExcelSubmitOptions
|
||||
}
|
||||
|
||||
func NewExcelProcessor() *ExcelProcessor {
|
||||
return &ExcelProcessor{Processor: NewProcessor()}
|
||||
}
|
||||
|
||||
func (p *ExcelProcessor) Config() batchimport.ProcessorConfig {
|
||||
cfg := p.Processor.Config()
|
||||
cfg.TemplateHeaders = ExcelTemplateHeaders()
|
||||
cfg.RequiredHeaders = ExcelTemplateHeaders()
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (p *ExcelProcessor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
rows, options, err := ParseExcelFileRows(ctx, filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.options = options
|
||||
|
||||
out := make([]batchimport.Row, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *ExcelProcessor) ExcelSubmitOptions() *ExcelSubmitOptions {
|
||||
return p.options
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common/cardsource"
|
||||
)
|
||||
|
||||
func ParseInputRows(ctx context.Context, input string) ([]batchimport.Row, error) {
|
||||
return cardsource.BuildIccidRows(ctx, input, "batch input change data is empty", func(line cardsource.IccidLine) *Row {
|
||||
return &Row{
|
||||
RowNo: line.RowNo,
|
||||
Iccid: line.Iccid,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
type ExcelSubmitOptions struct {
|
||||
NewPackageCode string
|
||||
DurationMonths int
|
||||
GiftDurationMonths int
|
||||
EffectiveMode string
|
||||
OfflineContractCompleted bool
|
||||
}
|
||||
|
||||
func ExcelTemplateHeaders() []string {
|
||||
return []string{
|
||||
"ICCID",
|
||||
"MSISDN",
|
||||
"IMSI",
|
||||
"newPackageCode",
|
||||
"durationMonths",
|
||||
"giftDurationMonths",
|
||||
"effectiveMode",
|
||||
"offlineContractCompleted",
|
||||
}
|
||||
}
|
||||
|
||||
func (o *ExcelSubmitOptions) ToBatchSubmitParam() *model.TSimCardChangeBatchSubmitParam {
|
||||
if o == nil {
|
||||
return &model.TSimCardChangeBatchSubmitParam{}
|
||||
}
|
||||
return &model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: strings.TrimSpace(o.NewPackageCode),
|
||||
DurationMonths: o.DurationMonths,
|
||||
GiftDurationMonths: o.GiftDurationMonths,
|
||||
EffectiveMode: strings.TrimSpace(o.EffectiveMode),
|
||||
OfflineContractCompleted: o.OfflineContractCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *ExcelSubmitOptions) ToBatchSubmitParamWithBatchNo(batchNo string) *model.TSimCardChangeBatchSubmitParam {
|
||||
param := o.ToBatchSubmitParam()
|
||||
param.BatchNo = strings.TrimSpace(batchNo)
|
||||
return param
|
||||
}
|
||||
|
||||
func ParseExcelFileRows(ctx context.Context, filePath string) ([]*Row, *ExcelSubmitOptions, error) {
|
||||
parsedRows, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{
|
||||
RequiredHeaders: ExcelTemplateHeaders(),
|
||||
SingleSheet: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rows := make([]*Row, 0, len(parsedRows))
|
||||
var options *ExcelSubmitOptions
|
||||
inconsistent := false
|
||||
|
||||
for _, parsed := range parsedRows {
|
||||
row := &Row{
|
||||
RowNo: parsed.RowNo,
|
||||
Iccid: excel.Field(parsed, "ICCID"),
|
||||
Msisdn: excel.Field(parsed, "MSISDN"),
|
||||
Imsi: excel.Field(parsed, "IMSI"),
|
||||
RawFields: map[string]string{
|
||||
"ICCID": excel.Field(parsed, "ICCID"),
|
||||
"MSISDN": excel.Field(parsed, "MSISDN"),
|
||||
"IMSI": excel.Field(parsed, "IMSI"),
|
||||
"newPackageCode": excel.Field(parsed, "newPackageCode"),
|
||||
"durationMonths": excel.Field(parsed, "durationMonths"),
|
||||
"giftDurationMonths": excel.Field(parsed, "giftDurationMonths"),
|
||||
"effectiveMode": excel.Field(parsed, "effectiveMode"),
|
||||
"offlineContractCompleted": excel.Field(parsed, "offlineContractCompleted"),
|
||||
},
|
||||
}
|
||||
rowOptions, reasons := parseExcelSubmitOptions(parsed)
|
||||
if len(reasons) > 0 {
|
||||
row.AddReason(strings.Join(reasons, "; "))
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
|
||||
row.NewPackageCode = rowOptions.NewPackageCode
|
||||
row.DurationMonths = rowOptions.DurationMonths
|
||||
row.GiftDurationMonths = rowOptions.GiftDurationMonths
|
||||
row.EffectiveMode = rowOptions.EffectiveMode
|
||||
row.OfflineContractCompleted = rowOptions.OfflineContractCompleted
|
||||
|
||||
if options == nil {
|
||||
options = rowOptions
|
||||
} else if !sameExcelSubmitOptions(options, rowOptions) {
|
||||
row.AddReason("same excel batch change params must be consistent")
|
||||
inconsistent = true
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
if inconsistent {
|
||||
markRowsWithoutReason(rows, "same excel batch change params must be consistent")
|
||||
}
|
||||
return rows, options, nil
|
||||
}
|
||||
|
||||
func parseExcelSubmitOptions(row excel.ParsedRow) (*ExcelSubmitOptions, []string) {
|
||||
var reasons []string
|
||||
|
||||
newPackageCode := strings.TrimSpace(excel.Field(row, "newPackageCode"))
|
||||
if newPackageCode == "" {
|
||||
reasons = append(reasons, "newPackageCode is required")
|
||||
}
|
||||
durationMonths, err := excel.ParsePositiveInt(excel.Field(row, "durationMonths"))
|
||||
if err != nil {
|
||||
reasons = append(reasons, "durationMonths must be a positive integer")
|
||||
}
|
||||
|
||||
giftDurationValue := strings.TrimSpace(excel.Field(row, "giftDurationMonths"))
|
||||
giftDurationMonths := 0
|
||||
if giftDurationValue != "" {
|
||||
giftDurationMonths, err = excel.ParseNonNegativeInt(giftDurationValue)
|
||||
if err != nil {
|
||||
reasons = append(reasons, "giftDurationMonths must be a non-negative integer")
|
||||
}
|
||||
}
|
||||
|
||||
effectiveMode := strings.TrimSpace(excel.Field(row, "effectiveMode"))
|
||||
if effectiveMode == "" {
|
||||
reasons = append(reasons, "effectiveMode is required")
|
||||
}
|
||||
|
||||
offlineContractCompleted, err := excel.ParseBool(excel.Field(row, "offlineContractCompleted"))
|
||||
if err != nil {
|
||||
reasons = append(reasons, "offlineContractCompleted format is invalid")
|
||||
} else if !offlineContractCompleted {
|
||||
reasons = append(reasons, "offlineContractCompleted must be true")
|
||||
}
|
||||
|
||||
if len(reasons) > 0 {
|
||||
return nil, reasons
|
||||
}
|
||||
|
||||
return &ExcelSubmitOptions{
|
||||
NewPackageCode: newPackageCode,
|
||||
DurationMonths: durationMonths,
|
||||
GiftDurationMonths: giftDurationMonths,
|
||||
EffectiveMode: effectiveMode,
|
||||
OfflineContractCompleted: offlineContractCompleted,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sameExcelSubmitOptions(left, right *ExcelSubmitOptions) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(left.NewPackageCode) == strings.TrimSpace(right.NewPackageCode) &&
|
||||
left.DurationMonths == right.DurationMonths &&
|
||||
left.GiftDurationMonths == right.GiftDurationMonths &&
|
||||
strings.TrimSpace(left.EffectiveMode) == strings.TrimSpace(right.EffectiveMode) &&
|
||||
left.OfflineContractCompleted == right.OfflineContractCompleted
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// PrepareProcessor 描述 batch_type=4 的卡变更预处理批次。
|
||||
type PrepareProcessor struct {
|
||||
inner *Processor
|
||||
repo *Repository
|
||||
config batchimport.ProcessorConfig
|
||||
}
|
||||
|
||||
func NewPrepareProcessor() *PrepareProcessor {
|
||||
inner := NewProcessor()
|
||||
cfg := inner.config
|
||||
cfg.Code = "sim_card_change_prepare"
|
||||
cfg.BatchType = consts.BatchTypePrepare
|
||||
cfg.BatchNoPrefix = consts.BatchNoPrefixChange
|
||||
return &PrepareProcessor{
|
||||
inner: inner,
|
||||
repo: inner.repo,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) Config() batchimport.ProcessorConfig {
|
||||
return p.config
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
|
||||
return p.inner.ValidateRows(ctx, rows)
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
|
||||
info := g.Map{}
|
||||
if result != nil && result.ImportFileOssId > 0 {
|
||||
info["importFileOssId"] = result.ImportFileOssId
|
||||
} else if importOss != nil && importOss.OssId > 0 {
|
||||
info["importFileOssId"] = importOss.OssId
|
||||
}
|
||||
if resultOss != nil {
|
||||
info["resultFileOssId"] = resultOss.OssId
|
||||
}
|
||||
if downloadId > 0 {
|
||||
info["resultDownloadId"] = downloadId
|
||||
}
|
||||
info["targetBatchType"] = consts.BatchTypeChange
|
||||
info["targetBatchName"] = "卡变更"
|
||||
|
||||
if in, ok := req.BizParams.(*model.TSimCardChangeBatchPrepareParam); ok && in != nil {
|
||||
if sourceBatchNo := strings.TrimSpace(in.SourceBatchNo); sourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = sourceBatchNo
|
||||
}
|
||||
}
|
||||
if in, ok := req.BizParams.(*model.TSimCardChangeBatchSubmitParam); ok && in != nil {
|
||||
if newPackageCode := strings.TrimSpace(in.NewPackageCode); newPackageCode != "" {
|
||||
info["newPackageCode"] = newPackageCode
|
||||
}
|
||||
if in.DurationMonths > 0 {
|
||||
info["durationMonths"] = in.DurationMonths
|
||||
}
|
||||
if in.GiftDurationMonths >= 0 {
|
||||
info["giftDurationMonths"] = in.GiftDurationMonths
|
||||
}
|
||||
if effectiveMode := strings.TrimSpace(in.EffectiveMode); effectiveMode != "" {
|
||||
info["effectiveMode"] = effectiveMode
|
||||
}
|
||||
info["offlineContractCompleted"] = in.OfflineContractCompleted
|
||||
}
|
||||
if snapshot, ok := req.ExecutionResult.(*model.TSimCardChangePrepareSnapshot); ok && snapshot != nil {
|
||||
if snapshot.SourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = snapshot.SourceBatchNo
|
||||
}
|
||||
info["customerAgentId"] = snapshot.CustomerAgentId
|
||||
info["customerName"] = snapshot.CustomerName
|
||||
info["oldPackageCode"] = snapshot.OldPackageCode
|
||||
info["oldPackageName"] = snapshot.OldPackageName
|
||||
info["oldPackageType"] = snapshot.OldPackageType
|
||||
info["oldPackageFlowMb"] = snapshot.OldPackageFlowMb
|
||||
if result != nil {
|
||||
info["quantity"] = result.ValidRows
|
||||
}
|
||||
}
|
||||
return gjson.New(info)
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) ValidatePrepareContext(ctx context.Context, tx gdb.TX, in *model.TSimCardChangeBatchPrepareParam, operator batchimport.OperatorContext, rows []*Row) (*PrepareContext, error) {
|
||||
return p.inner.ValidatePrepareContext(ctx, tx, in, operator, rows)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
repo *Repository
|
||||
config batchimport.ProcessorConfig
|
||||
}
|
||||
|
||||
func NewProcessor() *Processor {
|
||||
return &Processor{
|
||||
repo: NewRepository(),
|
||||
config: batchimport.ProcessorConfig{
|
||||
Code: "sim_card_change",
|
||||
BatchType: consts.BatchTypeChange,
|
||||
BatchNoPrefix: consts.BatchNoPrefixChange,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardChange,
|
||||
ExportScene: consts.ResourceExportSceneChange,
|
||||
ImportDir: consts.SimCardChangeSubDirImportExcel,
|
||||
ResultDir: consts.SimCardChangeSubDirResultExcel,
|
||||
MaxFileSize: consts.SimCardChangeMaxFileSize,
|
||||
TemplateHeaders: []string{"ICCID", "MSISDN", "IMSI"},
|
||||
RequiredHeaders: []string{"ICCID"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) Config() batchimport.ProcessorConfig {
|
||||
return p.config
|
||||
}
|
||||
|
||||
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return nil, gerror.New("sim card change does not support file import on base processor")
|
||||
}
|
||||
|
||||
func (p *Processor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
|
||||
changeRows := make([]*Row, 0, len(rows))
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
item.AddErrorReason("change row type is invalid")
|
||||
continue
|
||||
}
|
||||
changeRows = append(changeRows, row)
|
||||
}
|
||||
return validateRows(changeRows)
|
||||
}
|
||||
|
||||
func (p *Processor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
|
||||
return gerror.New("sim card change must be submitted from prepare batch")
|
||||
}
|
||||
|
||||
func (p *Processor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
|
||||
info := g.Map{}
|
||||
if resultOss != nil {
|
||||
info["resultFileOssId"] = resultOss.OssId
|
||||
}
|
||||
if downloadId > 0 {
|
||||
info["resultDownloadId"] = downloadId
|
||||
}
|
||||
if in, ok := req.BizParams.(*model.TSimCardChangeBatchPrepareParam); ok && in != nil {
|
||||
if sourceBatchNo := strings.TrimSpace(in.SourceBatchNo); sourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = sourceBatchNo
|
||||
}
|
||||
}
|
||||
if in, ok := req.BizParams.(*model.TSimCardChangeBatchSubmitParam); ok && in != nil {
|
||||
if newPackageCode := strings.TrimSpace(in.NewPackageCode); newPackageCode != "" {
|
||||
info["newPackageCode"] = newPackageCode
|
||||
}
|
||||
if in.DurationMonths > 0 {
|
||||
info["durationMonths"] = in.DurationMonths
|
||||
}
|
||||
if in.GiftDurationMonths >= 0 {
|
||||
info["giftDurationMonths"] = in.GiftDurationMonths
|
||||
}
|
||||
if effectiveMode := strings.TrimSpace(in.EffectiveMode); effectiveMode != "" {
|
||||
info["effectiveMode"] = effectiveMode
|
||||
}
|
||||
info["offlineContractCompleted"] = in.OfflineContractCompleted
|
||||
}
|
||||
if snapshot, ok := req.ExecutionResult.(*model.TSimCardChangePrepareSnapshot); ok && snapshot != nil {
|
||||
if result != nil && result.BatchType == consts.BatchTypePrepare {
|
||||
info["targetBatchType"] = consts.BatchTypeChange
|
||||
info["targetBatchName"] = "sim_card_change"
|
||||
}
|
||||
if snapshot.SourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = snapshot.SourceBatchNo
|
||||
}
|
||||
info["customerAgentId"] = snapshot.CustomerAgentId
|
||||
info["customerName"] = snapshot.CustomerName
|
||||
info["oldPackageCode"] = snapshot.OldPackageCode
|
||||
info["oldPackageName"] = snapshot.OldPackageName
|
||||
info["oldPackageType"] = snapshot.OldPackageType
|
||||
info["oldPackageFlowMb"] = snapshot.OldPackageFlowMb
|
||||
info["quantity"] = snapshot.Quantity
|
||||
}
|
||||
if output, ok := req.ExecutionResult.(*CompleteOutput); ok && output != nil {
|
||||
if output.SourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = output.SourceBatchNo
|
||||
}
|
||||
info["sourceMode"] = output.SourceMode
|
||||
info["orderSn"] = output.OrderSn
|
||||
info["customerAgentId"] = output.CustomerAgentId
|
||||
info["customerName"] = output.CustomerName
|
||||
info["oldPackageCode"] = output.OldPackageCode
|
||||
info["oldPackageName"] = output.OldPackageName
|
||||
info["newPackageCode"] = output.NewPackageCode
|
||||
info["newPackageName"] = output.NewPackageName
|
||||
info["durationMonths"] = output.DurationMonths
|
||||
info["giftDurationMonths"] = output.GiftDurationMonths
|
||||
info["effectiveMode"] = output.EffectiveMode
|
||||
info["offlineContractCompleted"] = output.OfflineContractCompleted
|
||||
info["changeUnitPrice"] = output.ChangeUnitPrice
|
||||
info["amount"] = output.Amount
|
||||
info["quantity"] = output.Quantity
|
||||
info["packageBeginTime"] = output.PackageBeginTime
|
||||
info["packageEndTime"] = output.PackageEndTime
|
||||
info["changeTime"] = output.ChangeTime
|
||||
}
|
||||
return gjson.New(info)
|
||||
}
|
||||
|
||||
func (p *Processor) ValidatePrepareContext(ctx context.Context, tx gdb.TX, in *model.TSimCardChangeBatchPrepareParam, operator batchimport.OperatorContext, rows []*Row) (*PrepareContext, error) {
|
||||
if in == nil {
|
||||
in = &model.TSimCardChangeBatchPrepareParam{}
|
||||
}
|
||||
sourceBatchNo := strings.TrimSpace(in.SourceBatchNo)
|
||||
|
||||
iccids := rowsToIccids(rows)
|
||||
cardsByIccid, err := p.repo.LockCardsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "validate change cards failed")
|
||||
}
|
||||
bindsByIccid, err := p.repo.LockCurrentActiveBindsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "validate change subscriptions failed")
|
||||
}
|
||||
|
||||
packageCodes := make(map[string]struct{})
|
||||
customerAgentIds := make(map[int64]struct{})
|
||||
oldPackageCode := ""
|
||||
customerAgentId := int64(0)
|
||||
for _, row := range rows {
|
||||
if row == nil || row.Reason != "" {
|
||||
continue
|
||||
}
|
||||
card := cardsByIccid[row.Iccid]
|
||||
bind := bindsByIccid[row.Iccid]
|
||||
reason := validateChangeRow(sourceBatchNo, operator, row, card, bind)
|
||||
if reason != "" {
|
||||
row.AddReason(reason)
|
||||
continue
|
||||
}
|
||||
packageCode := strings.TrimSpace(bind.PackageCode)
|
||||
packageCodes[packageCode] = struct{}{}
|
||||
customerAgentIds[card.DeptId] = struct{}{}
|
||||
oldPackageCode = packageCode
|
||||
customerAgentId = card.DeptId
|
||||
|
||||
row.Msisdn = strings.TrimSpace(card.Msisdn)
|
||||
row.Imsi = strings.TrimSpace(card.Imsi)
|
||||
row.OldPackageCode = packageCode
|
||||
row.OldSubscriptionNo = strings.TrimSpace(bind.SubscriptionNo)
|
||||
row.OldOrderSn = strings.TrimSpace(bind.OrderCode)
|
||||
row.OldEffectiveTime = bind.EffectiveTime
|
||||
row.OldExpireTime = bind.ExpectedExpireTime
|
||||
}
|
||||
|
||||
if len(packageCodes) > 1 {
|
||||
markRowsWithoutReason(rows, "current batch contains multiple old packages and cannot be changed together")
|
||||
}
|
||||
if len(customerAgentIds) > 1 {
|
||||
markRowsWithoutReason(rows, "current batch contains multiple customers and cannot be changed together")
|
||||
}
|
||||
if hasInvalidRows(rows) {
|
||||
markRemainingRowsSkipped(rows)
|
||||
return nil, gerror.New("some cards do not meet change conditions, batch was not executed")
|
||||
}
|
||||
if oldPackageCode == "" || customerAgentId <= 0 {
|
||||
return nil, gerror.New("change prepare result is incomplete")
|
||||
}
|
||||
|
||||
pkg, err := loadPackageByCode(ctx, tx, operator.TenantId, oldPackageCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pkg.ChangeablePackageCodes == nil || pkg.ChangeablePackageCodes.IsNil() || len(pkg.ChangeablePackageCodes.Interfaces()) == 0 {
|
||||
return nil, gerror.New("current package has no changeable packages configured")
|
||||
}
|
||||
customerName, err := loadDeptName(ctx, tx, operator.TenantId, customerAgentId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row != nil {
|
||||
row.OldPackageName = pkg.PackageName
|
||||
}
|
||||
}
|
||||
return &PrepareContext{
|
||||
CustomerAgentId: customerAgentId,
|
||||
CustomerName: customerName,
|
||||
OldPackage: pkg,
|
||||
Quantity: len(iccids),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateChangeRow(sourceBatchNo string, operator batchimport.OperatorContext, row *Row, card *entity.TSimCardInfo, bind *entity.TSimCardBindPkg) string {
|
||||
if card == nil {
|
||||
return "ICCID does not exist as formal card"
|
||||
}
|
||||
if bind == nil {
|
||||
return "ICCID current subscription data is missing or invalid"
|
||||
}
|
||||
if card.DeptId != operator.DeptId && card.CreatedDept != operator.DeptId {
|
||||
return "ICCID does not belong to current department"
|
||||
}
|
||||
if strings.TrimSpace(card.CurrentPackageCode) == "" || card.PackageStatus == consts.PackageStatusNone {
|
||||
return "ICCID has no current package"
|
||||
}
|
||||
if strings.TrimSpace(card.CurrentPackageCode) != strings.TrimSpace(bind.PackageCode) {
|
||||
return "ICCID current package and subscription package do not match"
|
||||
}
|
||||
if sourceBatchNo != "" && strings.TrimSpace(bind.BatchNo) != sourceBatchNo {
|
||||
return "ICCID current subscription does not belong to sourceBatchNo"
|
||||
}
|
||||
if row != nil {
|
||||
if msisdn := strings.TrimSpace(row.Msisdn); msisdn != "" && msisdn != strings.TrimSpace(card.Msisdn) {
|
||||
return "MSISDN does not match card fact"
|
||||
}
|
||||
if imsi := strings.TrimSpace(row.Imsi); imsi != "" && imsi != strings.TrimSpace(card.Imsi) {
|
||||
return "IMSI does not match card fact"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadPackageByCode(ctx context.Context, tx gdb.TX, tenantId, packageCode string) (*entity.TSimFlowPackageInfo, error) {
|
||||
pkg := &entity.TSimFlowPackageInfo{}
|
||||
columns := dao.TSimFlowPackageInfo.Columns()
|
||||
err := tx.Ctx(ctx).Model(dao.TSimFlowPackageInfo.Table()).
|
||||
Where(columns.TenantId, tenantId).
|
||||
Where(columns.PackageCode, strings.TrimSpace(packageCode)).
|
||||
Scan(pkg)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "load package failed")
|
||||
}
|
||||
if pkg.Id == 0 {
|
||||
return nil, gerror.New("package not found")
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func loadDeptName(ctx context.Context, tx gdb.TX, tenantId string, deptId int64) (string, error) {
|
||||
dept := &entity.SysDept{}
|
||||
columns := dao.SysDept.Columns()
|
||||
err := tx.Ctx(ctx).Model(dao.SysDept.Table()).
|
||||
Where(columns.TenantId, tenantId).
|
||||
Where(columns.DeptId, deptId).
|
||||
Scan(dept)
|
||||
if err != nil {
|
||||
return "", gerror.Wrap(err, "load customer failed")
|
||||
}
|
||||
if dept.DeptId == 0 {
|
||||
return "", gerror.New("customer not found")
|
||||
}
|
||||
return strings.TrimSpace(dept.DeptName), nil
|
||||
}
|
||||
|
||||
func hasInvalidRows(rows []*Row) bool {
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Reason != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func markRowsWithoutReason(rows []*Row, reason string) {
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Reason == "" {
|
||||
row.AddReason(reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markRemainingRowsSkipped(rows []*Row) {
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Reason == "" {
|
||||
row.AddReason("same batch has rows failing change conditions, this row was skipped")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateRows(rows []*Row) error {
|
||||
firstByIccid := make(map[string]*Row, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
row.Msisdn = strings.TrimSpace(row.Msisdn)
|
||||
row.Imsi = strings.TrimSpace(row.Imsi)
|
||||
if row.Iccid == "" {
|
||||
row.AddReason("ICCID cannot be empty")
|
||||
continue
|
||||
}
|
||||
if first, ok := firstByIccid[row.Iccid]; ok {
|
||||
row.AddReason("ICCID duplicated")
|
||||
first.AddReason("ICCID duplicated")
|
||||
continue
|
||||
}
|
||||
firstByIccid[row.Iccid] = row
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestExcelTemplateHeaders(t *testing.T) {
|
||||
got := ExcelTemplateHeaders()
|
||||
want := []string{"ICCID", "MSISDN", "IMSI", "newPackageCode", "durationMonths", "giftDurationMonths", "effectiveMode", "offlineContractCompleted"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("headers len = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("header[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRows(t *testing.T) {
|
||||
path := buildTestExcelFile(t, [][]string{
|
||||
ExcelTemplateHeaders(),
|
||||
{"8986001", "", "", "PKG_NEW", "12", "", "immediate", "true"},
|
||||
})
|
||||
|
||||
rows, opts, err := ParseExcelFileRows(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExcelFileRows error = %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows len = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].GiftDurationMonths != 0 {
|
||||
t.Fatalf("giftDurationMonths = %d, want 0", rows[0].GiftDurationMonths)
|
||||
}
|
||||
if opts == nil || opts.GiftDurationMonths != 0 || !opts.OfflineContractCompleted {
|
||||
t.Fatalf("opts = %#v", opts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsRequireTrueOfflineContractCompleted(t *testing.T) {
|
||||
path := buildTestExcelFile(t, [][]string{
|
||||
ExcelTemplateHeaders(),
|
||||
{"8986001", "", "", "PKG_NEW", "12", "", "immediate", "false"},
|
||||
})
|
||||
|
||||
rows, _, err := ParseExcelFileRows(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExcelFileRows error = %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows len = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Reason == "" {
|
||||
t.Fatal("expected row error when offlineContractCompleted is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChangeRowSourceBatchOptional(t *testing.T) {
|
||||
operator := batchimport.OperatorContext{DeptId: 100}
|
||||
row := &Row{Iccid: "8986001"}
|
||||
card := &entity.TSimCardInfo{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
Imsi: "460001234567890",
|
||||
DeptId: 100,
|
||||
CurrentPackageCode: "PKG_OLD",
|
||||
PackageStatus: 1,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
PackageCode: "PKG_OLD",
|
||||
BatchNo: "BATCH-OTHER",
|
||||
}
|
||||
|
||||
if reason := validateChangeRow("", operator, row, card, bind); reason != "" {
|
||||
t.Fatalf("validateChangeRow without sourceBatchNo = %q, want empty", reason)
|
||||
}
|
||||
if reason := validateChangeRow("BATCH-REQ", operator, row, card, bind); reason == "" {
|
||||
t.Fatal("expected sourceBatchNo mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChangeRowIdentityMatch(t *testing.T) {
|
||||
operator := batchimport.OperatorContext{DeptId: 100}
|
||||
card := &entity.TSimCardInfo{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
Imsi: "460001234567890",
|
||||
DeptId: 100,
|
||||
CurrentPackageCode: "PKG_OLD",
|
||||
PackageStatus: 1,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
PackageCode: "PKG_OLD",
|
||||
}
|
||||
|
||||
if reason := validateChangeRow("", operator, &Row{Iccid: "8986001", Msisdn: "bad"}, card, bind); reason == "" {
|
||||
t.Fatal("expected MSISDN mismatch error")
|
||||
}
|
||||
if reason := validateChangeRow("", operator, &Row{Iccid: "8986001", Imsi: "bad"}, card, bind); reason == "" {
|
||||
t.Fatal("expected IMSI mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchInfoOmitsEmptySourceBatchNo(t *testing.T) {
|
||||
p := NewProcessor()
|
||||
info := p.BuildBatchInfo(context.Background(), batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG_NEW",
|
||||
DurationMonths: 12,
|
||||
GiftDurationMonths: 0,
|
||||
EffectiveMode: "immediate",
|
||||
OfflineContractCompleted: true,
|
||||
},
|
||||
ExecutionResult: &CompleteOutput{
|
||||
SourceBatchNo: "",
|
||||
SourceMode: 0,
|
||||
NewPackageCode: "PKG_NEW",
|
||||
NewPackageName: "New Package",
|
||||
OldPackageCode: "PKG_OLD",
|
||||
OldPackageName: "Old Package",
|
||||
DurationMonths: 12,
|
||||
GiftDurationMonths: 0,
|
||||
EffectiveMode: "immediate",
|
||||
OfflineContractCompleted: true,
|
||||
},
|
||||
}, nil, nil, nil, 0)
|
||||
|
||||
if info == nil {
|
||||
t.Fatal("info is nil")
|
||||
}
|
||||
data := info.Map()
|
||||
if _, ok := data["sourceBatchNo"]; ok {
|
||||
t.Fatalf("sourceBatchNo should be omitted when empty: %#v", data)
|
||||
}
|
||||
if _, ok := data["remark"]; ok {
|
||||
t.Fatalf("remark should not exist: %#v", data)
|
||||
}
|
||||
if _, ok := data["buyerNote"]; ok {
|
||||
t.Fatalf("buyerNote should not exist: %#v", data)
|
||||
}
|
||||
if _, ok := data["adminNote"]; ok {
|
||||
t.Fatalf("adminNote should not exist: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestExcelFile(t *testing.T, rows [][]string) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "change.xlsx")
|
||||
f := excelize.NewFile()
|
||||
for i, row := range rows {
|
||||
cell, err := excelize.CoordinatesToCellName(1, i+1)
|
||||
if err != nil {
|
||||
t.Fatalf("CoordinatesToCellName error = %v", err)
|
||||
}
|
||||
if err = f.SetSheetRow("Sheet1", cell, &row); err != nil {
|
||||
t.Fatalf("SetSheetRow error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := f.SaveAs(path); err != nil {
|
||||
t.Fatalf("SaveAs error = %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("Stat error = %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common/cardsource"
|
||||
)
|
||||
|
||||
const maxRangeRows = 100000
|
||||
|
||||
func BuildRangeRows(ctx context.Context, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi string) ([]batchimport.Row, error) {
|
||||
return cardsource.BuildRangeRows(ctx, cardsource.RangeConfig{
|
||||
StartIccid: startIccid,
|
||||
EndIccid: endIccid,
|
||||
StartMsisdn: startMsisdn,
|
||||
EndMsisdn: endMsisdn,
|
||||
StartImsi: startImsi,
|
||||
EndImsi: endImsi,
|
||||
MaxRows: maxRangeRows,
|
||||
EmptyMessage: "range change data is empty",
|
||||
OverLimitMessage: "range change count cannot exceed %d",
|
||||
CountMismatchMessage: "ICCID range count(%d), MSISDN range count(%d), IMSI range count(%d) must match",
|
||||
}, func(rowNo int, iccid, msisdn, imsi string) *Row {
|
||||
return &Row{
|
||||
RowNo: rowNo,
|
||||
Iccid: iccid,
|
||||
Msisdn: msisdn,
|
||||
Imsi: imsi,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
)
|
||||
|
||||
type Repository struct{}
|
||||
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
// GetSourceBindRows 获取源绑定记录
|
||||
func (r *Repository) GetSourceBindRows(ctx context.Context, batchNo string) ([]*Row, error) {
|
||||
var binds []*entity.TSimCardBindPkg
|
||||
columns := dao.TSimCardBindPkg.Columns()
|
||||
err := dao.TSimCardBindPkg.Ctx(ctx).
|
||||
Fields(
|
||||
columns.Id,
|
||||
columns.Iccid,
|
||||
columns.Msisdn,
|
||||
columns.PackageCode,
|
||||
columns.SubscriptionNo,
|
||||
columns.OrderCode,
|
||||
columns.EffectiveTime,
|
||||
columns.ExpectedExpireTime,
|
||||
).
|
||||
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
|
||||
Where(columns.TenantId, contexts.GetTenantId(ctx)).
|
||||
WhereIn(columns.EffectiveStatus, []int{consts.EffectiveStatusPending, consts.EffectiveStatusEffective}).
|
||||
OrderAsc(columns.Id).
|
||||
Scan(&binds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]*Row, 0, len(binds))
|
||||
for i, bind := range binds {
|
||||
if bind == nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, &Row{
|
||||
RowNo: i + 1,
|
||||
Iccid: strings.TrimSpace(bind.Iccid),
|
||||
Msisdn: strings.TrimSpace(bind.Msisdn),
|
||||
OldPackageCode: strings.TrimSpace(bind.PackageCode),
|
||||
OldSubscriptionNo: strings.TrimSpace(bind.SubscriptionNo),
|
||||
OldOrderSn: strings.TrimSpace(bind.OrderCode),
|
||||
OldEffectiveTime: bind.EffectiveTime,
|
||||
OldExpireTime: bind.ExpectedExpireTime,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetChangeBatchRows 获取变更批次记录
|
||||
func (r *Repository) GetChangeBatchRows(ctx context.Context, batchNo string, batchType int) ([]*Row, error) {
|
||||
var details []*entity.BatchDetail
|
||||
columns := dao.BatchDetail.Columns()
|
||||
if batchType == 0 {
|
||||
batchType = consts.BatchTypeChange
|
||||
}
|
||||
err := dao.BatchDetail.Ctx(ctx).
|
||||
Fields(columns.RowNo, columns.ItemId, columns.Snapshot).
|
||||
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
|
||||
Where(columns.BatchType, batchType).
|
||||
Where(columns.DetailStatus, consts.BatchDetailStatusSuccess).
|
||||
OrderAsc(columns.RowNo).
|
||||
Scan(&details)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return detailsToRows(details), nil
|
||||
}
|
||||
|
||||
// detailsToRows 转换变更批次记录为行数据
|
||||
func detailsToRows(details []*entity.BatchDetail) []*Row {
|
||||
rows := make([]*Row, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
row := &Row{RowNo: detail.RowNo, Iccid: strings.TrimSpace(detail.ItemId)}
|
||||
if detail.Snapshot != nil {
|
||||
if row.Iccid == "" {
|
||||
row.Iccid = strings.TrimSpace(detail.Snapshot.Get("iccid").String())
|
||||
}
|
||||
if row.Iccid == "" {
|
||||
row.Iccid = strings.TrimSpace(detail.Snapshot.Get("ICCID").String())
|
||||
}
|
||||
row.Msisdn = strings.TrimSpace(detail.Snapshot.Get("msisdn").String())
|
||||
if row.Msisdn == "" {
|
||||
row.Msisdn = strings.TrimSpace(detail.Snapshot.Get("MSISDN").String())
|
||||
}
|
||||
row.Imsi = strings.TrimSpace(detail.Snapshot.Get("imsi").String())
|
||||
if row.Imsi == "" {
|
||||
row.Imsi = strings.TrimSpace(detail.Snapshot.Get("IMSI").String())
|
||||
}
|
||||
row.OldPackageCode = strings.TrimSpace(detail.Snapshot.Get("oldPackageCode").String())
|
||||
row.OldPackageName = strings.TrimSpace(detail.Snapshot.Get("oldPackageName").String())
|
||||
row.OldSubscriptionNo = strings.TrimSpace(detail.Snapshot.Get("oldSubscriptionNo").String())
|
||||
row.OldOrderSn = strings.TrimSpace(detail.Snapshot.Get("oldOrderSn").String())
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// LockCardsByIccids 锁定ICCID对应的SIM卡信息
|
||||
func (r *Repository) LockCardsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*entity.TSimCardInfo, error) {
|
||||
result := make(map[string]*entity.TSimCardInfo)
|
||||
uniqueIccids := uniqueStrings(iccids)
|
||||
if len(uniqueIccids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var list []*entity.TSimCardInfo
|
||||
if err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
|
||||
WhereIn(dao.TSimCardInfo.Columns().Iccid, uniqueIccids).
|
||||
LockUpdate().
|
||||
Scan(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
if item != nil && strings.TrimSpace(item.Iccid) != "" {
|
||||
result[strings.TrimSpace(item.Iccid)] = item
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LockCurrentActiveBindsByIccids 锁定ICCID对应的当前有效绑定记录
|
||||
func (r *Repository) LockCurrentActiveBindsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*entity.TSimCardBindPkg, error) {
|
||||
result := make(map[string]*entity.TSimCardBindPkg)
|
||||
priorities := make(map[string]int)
|
||||
uniqueIccids := uniqueStrings(iccids)
|
||||
if len(uniqueIccids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var list []*entity.TSimCardBindPkg
|
||||
if err := tx.Ctx(ctx).Model(dao.TSimCardBindPkg.Table()).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().Iccid, uniqueIccids).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().EffectiveStatus, []int{consts.EffectiveStatusPending, consts.EffectiveStatusEffective}).
|
||||
OrderDesc(dao.TSimCardBindPkg.Columns().EffectiveStatus).
|
||||
OrderAsc(dao.TSimCardBindPkg.Columns().EffectiveTime).
|
||||
OrderAsc(dao.TSimCardBindPkg.Columns().Id).
|
||||
LockUpdate().
|
||||
Scan(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
if item == nil || strings.TrimSpace(item.Iccid) == "" {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(item.Iccid)
|
||||
priority := bindPriority(item.EffectiveStatus)
|
||||
currentPriority, exists := priorities[iccid]
|
||||
if exists && priority < currentPriority {
|
||||
continue
|
||||
}
|
||||
if exists && priority == currentPriority {
|
||||
result[iccid] = nil
|
||||
continue
|
||||
}
|
||||
priorities[iccid] = priority
|
||||
result[iccid] = item
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LockPendingBindsByIccids 锁定ICCID对应的待生效绑定记录
|
||||
func (r *Repository) LockPendingBindsByIccids(ctx context.Context, tx gdb.TX, iccids []string) ([]*entity.TSimCardBindPkg, error) {
|
||||
uniqueIccids := uniqueStrings(iccids)
|
||||
if len(uniqueIccids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var list []*entity.TSimCardBindPkg
|
||||
if err := tx.Ctx(ctx).Model(dao.TSimCardBindPkg.Table()).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().Iccid, uniqueIccids).
|
||||
Where(dao.TSimCardBindPkg.Columns().EffectiveStatus, consts.EffectiveStatusPending).
|
||||
OrderAsc(dao.TSimCardBindPkg.Columns().EffectiveTime).
|
||||
OrderAsc(dao.TSimCardBindPkg.Columns().Id).
|
||||
LockUpdate().
|
||||
Scan(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// DeleteBindsBySubscriptionNos 删除指定订阅号的绑定记录
|
||||
func (r *Repository) DeleteBindsBySubscriptionNos(ctx context.Context, tx gdb.TX, subscriptionNos []string) (int64, error) {
|
||||
uniqueNos := uniqueStrings(subscriptionNos)
|
||||
if len(uniqueNos) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(dao.TSimCardBindPkg.Table()).
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().SubscriptionNo, uniqueNos).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// ActiveBindCountsByOrderCodes 获取指定订单号的当前有效绑定记录数量
|
||||
func (r *Repository) ActiveBindCountsByOrderCodes(ctx context.Context, tx gdb.TX, orderCodes []string) (map[string]int, error) {
|
||||
result := make(map[string]int)
|
||||
uniqueCodes := uniqueStrings(orderCodes)
|
||||
if len(uniqueCodes) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
OrderCode string `orm:"order_code"`
|
||||
Count int `orm:"count"`
|
||||
}
|
||||
var rows []row
|
||||
if err := tx.Ctx(ctx).Model(dao.TSimCardBindPkg.Table()).
|
||||
Fields(dao.TSimCardBindPkg.Columns().OrderCode, "COUNT(1) AS count").
|
||||
WhereIn(dao.TSimCardBindPkg.Columns().OrderCode, uniqueCodes).
|
||||
Group(dao.TSimCardBindPkg.Columns().OrderCode).
|
||||
Scan(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range rows {
|
||||
result[strings.TrimSpace(item.OrderCode)] = item.Count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// bindPriority 获取绑定记录的优先级
|
||||
func bindPriority(status int) int {
|
||||
switch status {
|
||||
case consts.EffectiveStatusEffective:
|
||||
return 2
|
||||
case consts.EffectiveStatusPending:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type Row struct {
|
||||
RowNo int
|
||||
Iccid string
|
||||
Msisdn string
|
||||
Imsi string
|
||||
NewPackageCode string
|
||||
DurationMonths int
|
||||
GiftDurationMonths int
|
||||
EffectiveMode string
|
||||
OfflineContractCompleted bool
|
||||
OldPackageCode string
|
||||
OldPackageName string
|
||||
OldSubscriptionNo string
|
||||
OldOrderSn string
|
||||
OldEffectiveTime *gtime.Time
|
||||
OldExpireTime *gtime.Time
|
||||
RawFields map[string]string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (r *Row) Valid() bool {
|
||||
return r != nil && r.Reason == ""
|
||||
}
|
||||
|
||||
func (r *Row) AddReason(reason string) {
|
||||
if r == nil || r.Reason != "" || reason == "" {
|
||||
return
|
||||
}
|
||||
r.Reason = reason
|
||||
}
|
||||
|
||||
func (r *Row) GetRowNo() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
return r.RowNo
|
||||
}
|
||||
|
||||
func (r *Row) GetItemID() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.Iccid
|
||||
}
|
||||
|
||||
func (r *Row) GetErrorReasons() []string {
|
||||
if r == nil || r.Reason == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{r.Reason}
|
||||
}
|
||||
|
||||
func (r *Row) AddErrorReason(reason string) {
|
||||
r.AddReason(reason)
|
||||
}
|
||||
|
||||
func (r *Row) Snapshot() *gjson.Json {
|
||||
if r == nil {
|
||||
return gjson.New(g.Map{})
|
||||
}
|
||||
return gjson.New(g.Map{
|
||||
"ICCID": r.Iccid,
|
||||
"MSISDN": r.Msisdn,
|
||||
"IMSI": r.Imsi,
|
||||
"newPackageCode": r.NewPackageCode,
|
||||
"durationMonths": r.DurationMonths,
|
||||
"giftDurationMonths": r.GiftDurationMonths,
|
||||
"effectiveMode": r.EffectiveMode,
|
||||
"offlineContractCompleted": r.OfflineContractCompleted,
|
||||
"oldPackageCode": r.OldPackageCode,
|
||||
"oldPackageName": r.OldPackageName,
|
||||
"oldSubscriptionNo": r.OldSubscriptionNo,
|
||||
"oldOrderSn": r.OldOrderSn,
|
||||
"oldEffectiveTime": formatTime(r.OldEffectiveTime),
|
||||
"oldExpectedExpireTime": formatTime(r.OldExpireTime),
|
||||
"rawFields": r.RawFields,
|
||||
"rowNo": r.RowNo,
|
||||
})
|
||||
}
|
||||
|
||||
type PrepareContext struct {
|
||||
CustomerAgentId int64
|
||||
CustomerName string
|
||||
OldPackage *entity.TSimFlowPackageInfo
|
||||
Quantity int
|
||||
}
|
||||
|
||||
type CompleteInput struct {
|
||||
SubmitParam *model.TSimCardChangeBatchSubmitParam
|
||||
BatchNo string
|
||||
SourceBatchNo string
|
||||
Rows []*Row
|
||||
Operator batchimport.OperatorContext
|
||||
PrepareSnapshot *model.TSimCardChangePrepareSnapshot
|
||||
PriceSnapshot *model.TSimCardChangePriceSnapshot
|
||||
PackageBeginTime *gtime.Time
|
||||
}
|
||||
|
||||
type CompleteOutput struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
SourceBatchNo string
|
||||
SourceMode int
|
||||
Quantity int
|
||||
CustomerAgentId int64
|
||||
CustomerName string
|
||||
OldPackageCode string
|
||||
OldPackageName string
|
||||
NewPackageCode string
|
||||
NewPackageName string
|
||||
OrderSn string
|
||||
Amount float64
|
||||
ChangeUnitPrice float64
|
||||
DurationMonths int
|
||||
GiftDurationMonths int
|
||||
EffectiveMode string
|
||||
OfflineContractCompleted bool
|
||||
PackageBeginTime *gtime.Time
|
||||
PackageEndTime *gtime.Time
|
||||
ChangeTime *gtime.Time
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func uniqueStrings(list []string) []string {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(list))
|
||||
result := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func rowsToIccids(rows []*Row) []string {
|
||||
iccids := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(row.Iccid)
|
||||
if iccid != "" {
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
}
|
||||
return uniqueStrings(iccids)
|
||||
}
|
||||
|
||||
func formatTime(t *gtime.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("Y-m-d H:i:s")
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type AuthorizedRow interface {
|
||||
GetIccid() string
|
||||
SetAuthorized(bool)
|
||||
}
|
||||
|
||||
type SubmitOptions struct {
|
||||
Remark string
|
||||
}
|
||||
|
||||
type BatchExecuteMessage struct {
|
||||
BatchNo string `json:"batchNo"`
|
||||
BatchType int `json:"batchType"`
|
||||
}
|
||||
|
||||
type SubmitResult struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
BatchAsyncStatus int
|
||||
Total int
|
||||
}
|
||||
|
||||
func PrepareAuthorizedRows[T AuthorizedRow](
|
||||
ctx context.Context,
|
||||
rows []batchimport.Row,
|
||||
rowFactory func(batchimport.Row) (T, error),
|
||||
typeErrorMsg string,
|
||||
) error {
|
||||
cardRows := make([]T, 0, len(rows))
|
||||
iccids := make([]string, 0, len(rows))
|
||||
for _, item := range rows {
|
||||
row, err := rowFactory(item)
|
||||
if err != nil {
|
||||
return gerror.New(typeErrorMsg)
|
||||
}
|
||||
cardRows = append(cardRows, row)
|
||||
if id := row.GetIccid(); id != "" {
|
||||
iccids = append(iccids, id)
|
||||
}
|
||||
}
|
||||
if len(iccids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var visible []struct {
|
||||
Iccid string `orm:"iccid"`
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{FilterTenant: true, FilterAuth: true}).
|
||||
Fields(columns.Iccid, columns.TenantId, columns.DeptId, columns.CreatedDept, columns.CreatedBy).
|
||||
WhereIn(columns.Iccid, iccids).
|
||||
Scan(&visible)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "计算SIM卡数据权限失败")
|
||||
}
|
||||
|
||||
authorized := make(map[string]struct{}, len(visible))
|
||||
for _, card := range visible {
|
||||
authorized[strings.TrimSpace(card.Iccid)] = struct{}{}
|
||||
}
|
||||
for _, row := range cardRows {
|
||||
_, ok := authorized[row.GetIccid()]
|
||||
row.SetAuthorized(ok)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func PublishBatchMessage(ctx context.Context, result *batchimport.Result, queueName, logPrefix string) {
|
||||
_ = PublishBatchMessageStrict(ctx, result, queueName, logPrefix)
|
||||
}
|
||||
|
||||
func PublishBatchMessageStrict(ctx context.Context, result *batchimport.Result, queueName, logPrefix string) error {
|
||||
if result == nil || strings.TrimSpace(result.BatchNo) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := rabbitmq.Publish(ctx, queueName, BatchExecuteMessage{
|
||||
BatchNo: result.BatchNo,
|
||||
BatchType: result.BatchType,
|
||||
}); err != nil {
|
||||
g.Log().Errorf(ctx, "%s batch async publish failed, batchNo=%s, batchType=%d, tenantId=%s, error=%+v",
|
||||
logPrefix, result.BatchNo, result.BatchType, contexts.GetTenantId(ctx), err)
|
||||
return gerror.Wrap(err, "publish batch async message")
|
||||
}
|
||||
g.Log().Infof(ctx, "%s batch async published, queue=%s, batchNo=%s, batchType=%d, tenantId=%s",
|
||||
logPrefix, queueName, result.BatchNo, result.BatchType, contexts.GetTenantId(ctx))
|
||||
return nil
|
||||
}
|
||||
|
||||
func CurrentOperator(ctx context.Context) batchimport.OperatorContext {
|
||||
return batchimport.OperatorContext{
|
||||
TenantId: contexts.GetTenantId(ctx),
|
||||
DeptId: contexts.GetDeptId(ctx),
|
||||
UserId: contexts.GetUserId(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func PendingSubmitResult(result *batchimport.Result, status int) *SubmitResult {
|
||||
res := &SubmitResult{BatchAsyncStatus: status}
|
||||
if result == nil {
|
||||
return res
|
||||
}
|
||||
res.BatchNo = result.BatchNo
|
||||
res.BatchType = result.BatchType
|
||||
res.Total = result.TotalRows
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package cardsource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
type IccidLine struct {
|
||||
RowNo int
|
||||
Iccid string
|
||||
}
|
||||
|
||||
type CardIdentityLine struct {
|
||||
RowNo int
|
||||
Iccid string
|
||||
Msisdn string
|
||||
Imsi string
|
||||
Imei string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func ParseIccidLines(ctx context.Context, input string) ([]IccidLine, error) {
|
||||
lines, err := text.ParseNonEmptyLines(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]IccidLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
result = append(result, IccidLine{
|
||||
RowNo: line.RowNo,
|
||||
Iccid: line.Text,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ParseCardIdentityLines(ctx context.Context, input string) ([]CardIdentityLine, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]CardIdentityLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
item := CardIdentityLine{RowNo: line.RowNo}
|
||||
switch len(line.Fields) {
|
||||
case 3:
|
||||
item.Iccid = line.Fields[0]
|
||||
item.Msisdn = line.Fields[1]
|
||||
item.Imsi = line.Fields[2]
|
||||
case 4:
|
||||
item.Iccid = line.Fields[0]
|
||||
item.Msisdn = line.Fields[1]
|
||||
item.Imsi = line.Fields[2]
|
||||
item.Imei = line.Fields[3]
|
||||
default:
|
||||
item.Reason = "格式错误,需为 ICCID MSISDN IMSI [IMEI]"
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BuildIccidRows[T batchimport.Row](ctx context.Context, input, emptyMessage string, factory func(IccidLine) T) ([]batchimport.Row, error) {
|
||||
lines, err := ParseIccidLines(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New(emptyMessage)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]batchimport.Row, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
rows = append(rows, factory(line))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func BuildCardIdentityRows[T batchimport.Row](ctx context.Context, input, emptyMessage string, factory func(CardIdentityLine) T) ([]batchimport.Row, error) {
|
||||
lines, err := ParseCardIdentityLines(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New(emptyMessage)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]batchimport.Row, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
row := factory(line)
|
||||
row.AddErrorReason(line.Reason)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package cardsource
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/numrange"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
type RangeConfig struct {
|
||||
StartIccid string
|
||||
EndIccid string
|
||||
StartMsisdn string
|
||||
EndMsisdn string
|
||||
StartImsi string
|
||||
EndImsi string
|
||||
MaxRows int
|
||||
EmptyMessage string
|
||||
OverLimitMessage string
|
||||
CountMismatchMessage string
|
||||
}
|
||||
|
||||
func BuildRangeRows[T batchimport.Row](ctx context.Context, cfg RangeConfig, factory func(rowNo int, iccid, msisdn, imsi string) T) ([]batchimport.Row, error) {
|
||||
iccids, err := numrange.Build(ctx, cfg.StartIccid, cfg.EndIccid, "ICCID", cfg.MaxRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msisdns, err := numrange.Build(ctx, cfg.StartMsisdn, cfg.EndMsisdn, "MSISDN", cfg.MaxRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imsis, err := numrange.Build(ctx, cfg.StartImsi, cfg.EndImsi, "IMSI", cfg.MaxRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(iccids) != len(msisdns) || len(iccids) != len(imsis) {
|
||||
return nil, gerror.Newf(cfg.CountMismatchMessage, len(iccids), len(msisdns), len(imsis))
|
||||
}
|
||||
if len(iccids) == 0 {
|
||||
return nil, gerror.New(cfg.EmptyMessage)
|
||||
}
|
||||
if cfg.MaxRows > 0 && len(iccids) > cfg.MaxRows {
|
||||
return nil, gerror.Newf(cfg.OverLimitMessage, cfg.MaxRows)
|
||||
}
|
||||
|
||||
rows := make([]batchimport.Row, 0, len(iccids))
|
||||
for i := range iccids {
|
||||
if err = ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, factory(i+1, iccids[i], msisdns[i], imsis[i]))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package datacut
|
||||
|
||||
import "github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
func invalidOperation(op Operation) error {
|
||||
return gerror.Newf("不支持的达量断网操作: %s", op)
|
||||
}
|
||||
|
||||
func errInvalidBatchType(batchType int) error {
|
||||
return gerror.Newf("批次类型%d不是达量断网批次", batchType)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package datacut
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type Row struct {
|
||||
DetailID int64
|
||||
RowNo int
|
||||
Iccid string
|
||||
Msisdn string
|
||||
Imsi string
|
||||
Reason string
|
||||
Authorized bool
|
||||
}
|
||||
|
||||
func (r *Row) GetRowNo() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
return r.RowNo
|
||||
}
|
||||
|
||||
func (r *Row) GetItemID() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.Iccid
|
||||
}
|
||||
|
||||
func (r *Row) GetErrorReasons() []string {
|
||||
if r == nil || r.Reason == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{r.Reason}
|
||||
}
|
||||
|
||||
func (r *Row) AddErrorReason(reason string) {
|
||||
if r == nil || r.Reason != "" || reason == "" {
|
||||
return
|
||||
}
|
||||
r.Reason = reason
|
||||
}
|
||||
|
||||
func (r *Row) GetIccid() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.Iccid
|
||||
}
|
||||
|
||||
func (r *Row) SetAuthorized(v bool) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.Authorized = v
|
||||
}
|
||||
|
||||
func (r *Row) Snapshot() *gjson.Json {
|
||||
if r == nil {
|
||||
return gjson.New(g.Map{})
|
||||
}
|
||||
return gjson.New(g.Map{
|
||||
"ICCID": r.Iccid,
|
||||
"MSISDN": r.Msisdn,
|
||||
"IMSI": r.Imsi,
|
||||
"authorized": r.Authorized,
|
||||
})
|
||||
}
|
||||
|
||||
func parseExcelFileRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
parsed, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{RequiredHeaders: []string{"ICCID"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(parsed))
|
||||
for _, item := range parsed {
|
||||
rows = append(rows, &Row{
|
||||
RowNo: item.RowNo,
|
||||
Iccid: excel.Field(item, "ICCID"),
|
||||
Msisdn: excel.Field(item, "MSISDN"),
|
||||
Imsi: excel.Field(item, "IMSI"),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func parseInputRows(ctx context.Context, input string) ([]batchimport.Row, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New("批量输入内容不能为空")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &Row{RowNo: line.RowNo}
|
||||
switch len(line.Fields) {
|
||||
case 1:
|
||||
row.Iccid = line.Fields[0]
|
||||
case 3:
|
||||
row.Iccid = line.Fields[0]
|
||||
row.Msisdn = line.Fields[1]
|
||||
row.Imsi = line.Fields[2]
|
||||
default:
|
||||
row.AddErrorReason("格式错误,应为 ICCID 或 ICCID MSISDN IMSI")
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func validateBasicRows(rows []batchimport.Row) error {
|
||||
firstByIccid := make(map[string]*Row, len(rows))
|
||||
issues := make([]string, 0)
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return gerror.New("达量断网批次行类型错误")
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
row.Msisdn = strings.TrimSpace(row.Msisdn)
|
||||
row.Imsi = strings.TrimSpace(row.Imsi)
|
||||
if row.Reason != "" {
|
||||
issues = append(issues, fmt.Sprintf("第%d行:%s", row.RowNo, row.Reason))
|
||||
continue
|
||||
}
|
||||
if row.Iccid == "" {
|
||||
row.AddErrorReason("ICCID不能为空")
|
||||
issues = append(issues, fmt.Sprintf("第%d行:ICCID不能为空", row.RowNo))
|
||||
continue
|
||||
}
|
||||
if first, exists := firstByIccid[row.Iccid]; exists {
|
||||
row.AddErrorReason(fmt.Sprintf("ICCID与第%d行重复", first.RowNo))
|
||||
first.AddErrorReason("ICCID重复")
|
||||
issues = append(issues, fmt.Sprintf("第%d行:ICCID与第%d行重复", row.RowNo, first.RowNo))
|
||||
continue
|
||||
}
|
||||
firstByIccid[row.Iccid] = row
|
||||
}
|
||||
if len(issues) > 0 {
|
||||
return gerror.New("达量断网批次基础格式校验失败:" + strings.Join(issues, ";"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package datacut
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
func NewProcessor(operation Operation) (*Processor, error) {
|
||||
cfg, err := operation.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Processor{config: cfg}, nil
|
||||
}
|
||||
|
||||
func (p *Processor) Config() batchimport.ProcessorConfig {
|
||||
return batchimport.ProcessorConfig{
|
||||
Code: "sim_card_" + p.config.Code,
|
||||
BatchType: p.config.BatchType,
|
||||
BatchNoPrefix: p.config.BatchPrefix,
|
||||
TaskName: p.config.TaskName,
|
||||
ExportScene: p.config.ExportScene,
|
||||
ImportDir: p.config.ImportDir,
|
||||
ResultDir: p.config.ResultDir,
|
||||
MaxFileSize: int64(consts.SimCardDataCutMaxFileSize),
|
||||
TemplateHeaders: []string{"ICCID", "MSISDN", "IMSI"},
|
||||
RequiredHeaders: []string{"ICCID"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return parseExcelFileRows(ctx, filePath)
|
||||
}
|
||||
|
||||
func (p *Processor) ValidateRows(_ context.Context, rows []batchimport.Row) error {
|
||||
return validateBasicRows(rows)
|
||||
}
|
||||
|
||||
func (p *Processor) Execute(context.Context, gdb.TX, *batchimport.SubmitRequest, []batchimport.Row) error {
|
||||
return gerror.New("达量断网批次只能由异步执行器执行")
|
||||
}
|
||||
|
||||
func (p *Processor) BuildBatchInfo(_ context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, _ *model.SysOssSaveContentModel, _ int64) *gjson.Json {
|
||||
info := buildBatchInfo(req, p.config)
|
||||
if result != nil && result.ImportFileOssId > 0 {
|
||||
info.Set("importFileOssId", result.ImportFileOssId)
|
||||
} else if importOss != nil && importOss.OssId > 0 {
|
||||
info.Set("importFileOssId", importOss.OssId)
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package datacut
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
type Submitter struct {
|
||||
operation Operation
|
||||
}
|
||||
|
||||
func NewSubmitter(operation Operation) *Submitter {
|
||||
return &Submitter{operation: operation}
|
||||
}
|
||||
|
||||
func (s *Submitter) Operation() Operation {
|
||||
return s.operation
|
||||
}
|
||||
|
||||
func (s *Submitter) SubmitExcel(ctx context.Context, file *ghttp.UploadFile, threshold float64, remark string) (*SubmitResult, error) {
|
||||
if err := validateThreshold(threshold); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
processor, err := NewProcessor(s.operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
File: file,
|
||||
BizParams: &SubmitOptions{Remark: strings.TrimSpace(remark), TrafficCutoffThreshold: threshold},
|
||||
SourceType: consts.BatchSourceTypeFile,
|
||||
Operator: common.CurrentOperator(ctx),
|
||||
}
|
||||
result, err := batchimport.CreatePendingUpload(ctx, req, processor, batchimport.PendingOptions{
|
||||
PrepareRows: func(ctx context.Context, rows []batchimport.Row) error {
|
||||
return common.PrepareAuthorizedRows(ctx, rows, func(item batchimport.Row) (*Row, error) {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return nil, gerror.New("达量断网批次行类型错误")
|
||||
}
|
||||
return row, nil
|
||||
}, "达量断网批次行类型错误")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, "datacut")
|
||||
return common.PendingSubmitResult(result, consts.BatchAsyncStatusPending), nil
|
||||
}
|
||||
|
||||
func (s *Submitter) SubmitInput(ctx context.Context, input string, threshold float64, remark string) (*SubmitResult, error) {
|
||||
if err := validateThreshold(threshold); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := parseInputRows(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
processor, err := NewProcessor(s.operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
BizParams: &SubmitOptions{Remark: strings.TrimSpace(remark), TrafficCutoffThreshold: threshold},
|
||||
SourceType: consts.BatchSourceTypeBatchInput,
|
||||
Operator: common.CurrentOperator(ctx),
|
||||
}
|
||||
result, err := batchimport.CreatePendingRows(ctx, req, processor, rows, batchimport.PendingOptions{
|
||||
PrepareRows: func(ctx context.Context, rows []batchimport.Row) error {
|
||||
return common.PrepareAuthorizedRows(ctx, rows, func(item batchimport.Row) (*Row, error) {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return nil, gerror.New("达量断网批次行类型错误")
|
||||
}
|
||||
return row, nil
|
||||
}, "达量断网批次行类型错误")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, "datacut")
|
||||
return common.PendingSubmitResult(result, consts.BatchAsyncStatusPending), nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package datacut
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const (
|
||||
OperationDataCut Operation = "dataCut"
|
||||
)
|
||||
|
||||
type Operation string
|
||||
|
||||
type Config struct {
|
||||
Operation Operation
|
||||
Code string
|
||||
BatchType int
|
||||
BatchPrefix string
|
||||
TaskName string
|
||||
ExportScene int
|
||||
ImportDir string
|
||||
ResultDir string
|
||||
}
|
||||
|
||||
func (op Operation) Config() (Config, error) {
|
||||
switch op {
|
||||
case OperationDataCut:
|
||||
return Config{
|
||||
Operation: op,
|
||||
Code: "dataCut",
|
||||
BatchType: consts.BatchTypeDataCut,
|
||||
BatchPrefix: consts.BatchNoPrefixDataCut,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardDataCut,
|
||||
ExportScene: consts.ResourceExportSceneDataCut,
|
||||
ImportDir: consts.SimCardDataCutSubDirImportExcel,
|
||||
ResultDir: consts.SimCardDataCutSubDirResultExcel,
|
||||
}, nil
|
||||
default:
|
||||
return Config{}, invalidOperation(op)
|
||||
}
|
||||
}
|
||||
|
||||
func OperationByBatchType(batchType int) (Operation, error) {
|
||||
switch batchType {
|
||||
case consts.BatchTypeDataCut:
|
||||
return OperationDataCut, nil
|
||||
default:
|
||||
return "", errInvalidBatchType(batchType)
|
||||
}
|
||||
}
|
||||
|
||||
type SubmitOptions struct {
|
||||
Remark string
|
||||
TrafficCutoffThreshold float64
|
||||
}
|
||||
|
||||
type BatchExecuteMessage = common.BatchExecuteMessage
|
||||
|
||||
type SubmitResult = common.SubmitResult
|
||||
|
||||
func buildBatchInfo(req batchimport.SubmitRequest, cfg Config) *gjson.Json {
|
||||
info := g.Map{
|
||||
"operation": cfg.Code,
|
||||
"batchType": cfg.BatchType,
|
||||
"trafficCutoffStatus": 1,
|
||||
"trafficCutoffThreshold": 0,
|
||||
"thresholdUnit": "MB",
|
||||
"resultFile": g.Map{"status": "pending"},
|
||||
}
|
||||
if options, ok := req.BizParams.(*SubmitOptions); ok && options != nil {
|
||||
info["trafficCutoffThreshold"] = options.TrafficCutoffThreshold
|
||||
if remark := trimSpace(options.Remark); remark != "" {
|
||||
info["remark"] = remark
|
||||
}
|
||||
}
|
||||
return gjson.New(info)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package datacut
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
func trimSpace(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func validateThreshold(value float64) error {
|
||||
if value <= 0 {
|
||||
return gerror.New("达量断网阈值必须大于0")
|
||||
}
|
||||
rounded := math.Round(value*1000) / 1000
|
||||
if math.Abs(value-rounded) > 0.0000001 {
|
||||
return gerror.New("达量断网阈值最多保留3位小数")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dataswitch
|
||||
|
||||
import "github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
func invalidOperation(op Operation) error {
|
||||
return gerror.Newf("不支持的数据服务操作: %s", op)
|
||||
}
|
||||
|
||||
func errInvalidBatchType(batchType int) error {
|
||||
return gerror.Newf("批次类型%d不是数据服务批次", batchType)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package dataswitch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type Row struct {
|
||||
DetailID int64
|
||||
RowNo int
|
||||
Iccid string
|
||||
Msisdn string
|
||||
Imsi string
|
||||
Reason string
|
||||
Authorized bool
|
||||
CardID int64
|
||||
CutoffThreshold float64
|
||||
UsedFlowMb float64
|
||||
PackageTotalFlowMb float64
|
||||
RemainFlowMb float64
|
||||
DataServiceStatusBefore int
|
||||
TrafficCutoffCrossMonthHold int
|
||||
TriggeredAt string
|
||||
TriggerReason string
|
||||
}
|
||||
|
||||
func (r *Row) GetRowNo() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
return r.RowNo
|
||||
}
|
||||
|
||||
func (r *Row) GetItemID() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.Iccid
|
||||
}
|
||||
|
||||
func (r *Row) GetErrorReasons() []string {
|
||||
if r == nil || r.Reason == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{r.Reason}
|
||||
}
|
||||
|
||||
func (r *Row) AddErrorReason(reason string) {
|
||||
if r == nil || r.Reason != "" || reason == "" {
|
||||
return
|
||||
}
|
||||
r.Reason = reason
|
||||
}
|
||||
|
||||
func (r *Row) GetIccid() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.Iccid
|
||||
}
|
||||
|
||||
func (r *Row) SetAuthorized(v bool) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.Authorized = v
|
||||
}
|
||||
|
||||
func (r *Row) Snapshot() *gjson.Json {
|
||||
if r == nil {
|
||||
return gjson.New(g.Map{})
|
||||
}
|
||||
snapshot := g.Map{
|
||||
"ICCID": r.Iccid,
|
||||
"MSISDN": r.Msisdn,
|
||||
"IMSI": r.Imsi,
|
||||
"authorized": r.Authorized,
|
||||
}
|
||||
if r.CardID > 0 {
|
||||
snapshot["cardId"] = r.CardID
|
||||
}
|
||||
if r.CutoffThreshold > 0 {
|
||||
snapshot["cutoffThreshold"] = r.CutoffThreshold
|
||||
}
|
||||
if r.UsedFlowMb > 0 {
|
||||
snapshot["usedFlowMb"] = r.UsedFlowMb
|
||||
}
|
||||
if r.PackageTotalFlowMb > 0 {
|
||||
snapshot["packageTotalFlowMb"] = r.PackageTotalFlowMb
|
||||
}
|
||||
if r.RemainFlowMb >= 0 && r.CutoffThreshold > 0 {
|
||||
snapshot["remainFlowMb"] = r.RemainFlowMb
|
||||
}
|
||||
if r.DataServiceStatusBefore != 0 {
|
||||
snapshot["dataServiceStatusBefore"] = r.DataServiceStatusBefore
|
||||
}
|
||||
if r.TrafficCutoffCrossMonthHold != 0 {
|
||||
snapshot["trafficCutoffCrossMonthHold"] = r.TrafficCutoffCrossMonthHold
|
||||
}
|
||||
if r.TriggeredAt != "" {
|
||||
snapshot["triggeredAt"] = r.TriggeredAt
|
||||
}
|
||||
if r.TriggerReason != "" {
|
||||
snapshot["triggerReason"] = r.TriggerReason
|
||||
}
|
||||
return gjson.New(snapshot)
|
||||
}
|
||||
|
||||
func parseExcelFileRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
parsed, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{RequiredHeaders: []string{"ICCID"}, MaxDataRows: consts.SimCardBatchMaxRows})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(parsed))
|
||||
for _, item := range parsed {
|
||||
rows = append(rows, &Row{
|
||||
RowNo: item.RowNo,
|
||||
Iccid: excel.Field(item, "ICCID"),
|
||||
Msisdn: excel.Field(item, "MSISDN"),
|
||||
Imsi: excel.Field(item, "IMSI"),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func parseInputRows(ctx context.Context, input string) ([]batchimport.Row, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New("批量输入内容不能为空")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &Row{RowNo: line.RowNo}
|
||||
switch len(line.Fields) {
|
||||
case 1:
|
||||
row.Iccid = line.Fields[0]
|
||||
case 3:
|
||||
row.Iccid = line.Fields[0]
|
||||
row.Msisdn = line.Fields[1]
|
||||
row.Imsi = line.Fields[2]
|
||||
default:
|
||||
row.AddErrorReason("格式错误,应为 ICCID 或 ICCID MSISDN IMSI")
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func validateBasicRows(rows []batchimport.Row) error {
|
||||
firstByIccid := make(map[string]*Row, len(rows))
|
||||
issues := make([]string, 0)
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return gerror.New("数据服务批次行类型错误")
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
row.Msisdn = strings.TrimSpace(row.Msisdn)
|
||||
row.Imsi = strings.TrimSpace(row.Imsi)
|
||||
if row.Reason != "" {
|
||||
issues = append(issues, fmt.Sprintf("第%d行:%s", row.RowNo, row.Reason))
|
||||
continue
|
||||
}
|
||||
if row.Iccid == "" {
|
||||
row.AddErrorReason("ICCID不能为空")
|
||||
issues = append(issues, fmt.Sprintf("第%d行:ICCID不能为空", row.RowNo))
|
||||
continue
|
||||
}
|
||||
if first, exists := firstByIccid[row.Iccid]; exists {
|
||||
row.AddErrorReason(fmt.Sprintf("ICCID与第%d行重复", first.RowNo))
|
||||
first.AddErrorReason("ICCID重复")
|
||||
issues = append(issues, fmt.Sprintf("第%d行:ICCID与第%d行重复", row.RowNo, first.RowNo))
|
||||
continue
|
||||
}
|
||||
firstByIccid[row.Iccid] = row
|
||||
}
|
||||
if len(issues) > 0 {
|
||||
return gerror.New("数据服务批次基础格式校验失败:" + strings.Join(issues, ";"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package dataswitch
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
func NewProcessor(operation Operation) (*Processor, error) {
|
||||
cfg, err := operation.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Processor{config: cfg}, nil
|
||||
}
|
||||
|
||||
func (p *Processor) Config() batchimport.ProcessorConfig {
|
||||
maxFile := consts.SimCardDataStartMaxFileSize
|
||||
if p.config.BatchType == consts.BatchTypeDataStop {
|
||||
maxFile = consts.SimCardDataStopMaxFileSize
|
||||
}
|
||||
if p.config.BatchType == consts.BatchTypeDataCut {
|
||||
maxFile = consts.SimCardDataCutMaxFileSize
|
||||
}
|
||||
return batchimport.ProcessorConfig{
|
||||
Code: "sim_card_" + p.config.Code,
|
||||
BatchType: p.config.BatchType,
|
||||
BatchNoPrefix: p.config.BatchPrefix,
|
||||
TaskName: p.config.TaskName,
|
||||
ExportScene: p.config.ExportScene,
|
||||
ImportDir: p.config.ImportDir,
|
||||
ResultDir: p.config.ResultDir,
|
||||
MaxFileSize: int64(maxFile),
|
||||
MaxRows: consts.SimCardBatchMaxRows,
|
||||
TemplateHeaders: []string{"ICCID", "MSISDN", "IMSI"},
|
||||
RequiredHeaders: []string{"ICCID"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return parseExcelFileRows(ctx, filePath)
|
||||
}
|
||||
|
||||
func (p *Processor) ValidateRows(_ context.Context, rows []batchimport.Row) error {
|
||||
return validateBasicRows(rows)
|
||||
}
|
||||
|
||||
func (p *Processor) Execute(context.Context, gdb.TX, *batchimport.SubmitRequest, []batchimport.Row) error {
|
||||
return gerror.New("数据服务批次只能由异步执行器执行")
|
||||
}
|
||||
|
||||
func (p *Processor) BuildBatchInfo(_ context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, _ *model.SysOssSaveContentModel, _ int64) *gjson.Json {
|
||||
info := buildBatchInfo(req, p.config)
|
||||
if result != nil && result.ImportFileOssId > 0 {
|
||||
info.Set("importFileOssId", result.ImportFileOssId)
|
||||
} else if importOss != nil && importOss.OssId > 0 {
|
||||
info.Set("importFileOssId", importOss.OssId)
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package dataswitch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
type Submitter struct {
|
||||
operation Operation
|
||||
}
|
||||
|
||||
func NewSubmitter(operation Operation) *Submitter {
|
||||
return &Submitter{operation: operation}
|
||||
}
|
||||
|
||||
func (s *Submitter) Operation() Operation {
|
||||
return s.operation
|
||||
}
|
||||
|
||||
func (s *Submitter) SubmitExcel(ctx context.Context, file *ghttp.UploadFile, remark string) (*SubmitResult, error) {
|
||||
processor, err := NewProcessor(s.operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
File: file,
|
||||
BizParams: &common.SubmitOptions{Remark: strings.TrimSpace(remark)},
|
||||
SourceType: consts.BatchSourceTypeFile,
|
||||
Operator: common.CurrentOperator(ctx),
|
||||
}
|
||||
result, err := batchimport.CreatePendingUpload(ctx, req, processor, batchimport.PendingOptions{
|
||||
PrepareRows: func(ctx context.Context, rows []batchimport.Row) error {
|
||||
return common.PrepareAuthorizedRows(ctx, rows, func(item batchimport.Row) (*Row, error) {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return nil, gerror.New("数据服务批次行类型错误")
|
||||
}
|
||||
return row, nil
|
||||
}, "数据服务批次行类型错误")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, "dataswitch")
|
||||
return common.PendingSubmitResult(result, consts.BatchAsyncStatusPending), nil
|
||||
}
|
||||
|
||||
func (s *Submitter) SubmitInput(ctx context.Context, input, remark string) (*SubmitResult, error) {
|
||||
rows, err := parseInputRows(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
processor, err := NewProcessor(s.operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
BizParams: &common.SubmitOptions{Remark: strings.TrimSpace(remark)},
|
||||
SourceType: consts.BatchSourceTypeBatchInput,
|
||||
Operator: common.CurrentOperator(ctx),
|
||||
}
|
||||
result, err := batchimport.CreatePendingRows(ctx, req, processor, rows, batchimport.PendingOptions{
|
||||
PrepareRows: func(ctx context.Context, rows []batchimport.Row) error {
|
||||
return common.PrepareAuthorizedRows(ctx, rows, func(item batchimport.Row) (*Row, error) {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return nil, gerror.New("数据服务批次行类型错误")
|
||||
}
|
||||
return row, nil
|
||||
}, "数据服务批次行类型错误")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, "dataswitch")
|
||||
return common.PendingSubmitResult(result, consts.BatchAsyncStatusPending), nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package dataswitch
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const (
|
||||
OperationDataStart Operation = "dataStart"
|
||||
OperationDataStop Operation = "dataStop"
|
||||
OperationDataCut Operation = "dataCut"
|
||||
)
|
||||
|
||||
type Operation string
|
||||
|
||||
type Config struct {
|
||||
Operation Operation
|
||||
Code string
|
||||
BatchType int
|
||||
BatchPrefix string
|
||||
OperType int
|
||||
ApnName string
|
||||
TaskName string
|
||||
ExportScene int
|
||||
ImportDir string
|
||||
ResultDir string
|
||||
}
|
||||
|
||||
func (op Operation) Config() (Config, error) {
|
||||
switch op {
|
||||
case OperationDataStart:
|
||||
return Config{
|
||||
Operation: op,
|
||||
Code: "dataStart",
|
||||
BatchType: consts.BatchTypeDataStart,
|
||||
BatchPrefix: consts.BatchNoPrefixDataStart,
|
||||
OperType: consts.SimCardDataOperTypeStart,
|
||||
ApnName: consts.SimCardDataApnName,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardDataStart,
|
||||
ExportScene: consts.ResourceExportSceneDataStart,
|
||||
ImportDir: consts.SimCardDataStartSubDirImportExcel,
|
||||
ResultDir: consts.SimCardDataStartSubDirResultExcel,
|
||||
}, nil
|
||||
case OperationDataStop:
|
||||
return Config{
|
||||
Operation: op,
|
||||
Code: "dataStop",
|
||||
BatchType: consts.BatchTypeDataStop,
|
||||
BatchPrefix: consts.BatchNoPrefixDataStop,
|
||||
OperType: consts.SimCardDataOperTypeStop,
|
||||
ApnName: consts.SimCardDataApnName,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardDataStop,
|
||||
ExportScene: consts.ResourceExportSceneDataStop,
|
||||
ImportDir: consts.SimCardDataStopSubDirImportExcel,
|
||||
ResultDir: consts.SimCardDataStopSubDirResultExcel,
|
||||
}, nil
|
||||
case OperationDataCut:
|
||||
return Config{
|
||||
Operation: op,
|
||||
Code: "dataCut",
|
||||
BatchType: consts.BatchTypeDataCut,
|
||||
BatchPrefix: consts.BatchNoPrefixDataCut,
|
||||
OperType: consts.SimCardDataOperTypeStop,
|
||||
ApnName: consts.SimCardDataApnName,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardDataCut,
|
||||
ExportScene: consts.ResourceExportSceneDataCut,
|
||||
ImportDir: consts.SimCardDataCutSubDirImportExcel,
|
||||
ResultDir: consts.SimCardDataCutSubDirResultExcel,
|
||||
}, nil
|
||||
default:
|
||||
return Config{}, invalidOperation(op)
|
||||
}
|
||||
}
|
||||
|
||||
func OperationByBatchType(batchType int) (Operation, error) {
|
||||
switch batchType {
|
||||
case consts.BatchTypeDataStart:
|
||||
return OperationDataStart, nil
|
||||
case consts.BatchTypeDataStop:
|
||||
return OperationDataStop, nil
|
||||
case consts.BatchTypeDataCut:
|
||||
return OperationDataCut, nil
|
||||
default:
|
||||
return "", errInvalidBatchType(batchType)
|
||||
}
|
||||
}
|
||||
|
||||
type SubmitOptions = common.SubmitOptions
|
||||
|
||||
type BatchExecuteMessage = common.BatchExecuteMessage
|
||||
|
||||
type SubmitResult = common.SubmitResult
|
||||
|
||||
func buildBatchInfo(req batchimport.SubmitRequest, cfg Config) *gjson.Json {
|
||||
info := g.Map{
|
||||
"operation": cfg.Code,
|
||||
"operType": cfg.OperType,
|
||||
"apnName": cfg.ApnName,
|
||||
"batchType": cfg.BatchType,
|
||||
"resultFile": g.Map{"status": "pending"},
|
||||
}
|
||||
if options, ok := req.BizParams.(*SubmitOptions); ok && options != nil {
|
||||
if remark := trimSpace(options.Remark); remark != "" {
|
||||
info["remark"] = remark
|
||||
}
|
||||
}
|
||||
return gjson.New(info)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dataswitch
|
||||
|
||||
import "strings"
|
||||
|
||||
func trimSpace(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package inbound
|
||||
|
||||
func TemplateHeaders() []string {
|
||||
return inboundExcelTemplateHeaders()
|
||||
}
|
||||
|
||||
func inboundExcelTemplateHeaders() []string {
|
||||
return []string{
|
||||
"ICCID*",
|
||||
"MSISDN*",
|
||||
"IMSI*",
|
||||
"IMEI",
|
||||
"通道编号*",
|
||||
"卡类型",
|
||||
"上游套餐编码",
|
||||
"供应商编码",
|
||||
"上游激活时间",
|
||||
"是否贴片转插拔",
|
||||
"是否运营商达量断网",
|
||||
"协议到期时间",
|
||||
"是否有测试期",
|
||||
"测试期天数",
|
||||
"测试期最晚过期日期",
|
||||
"是否有沉默期",
|
||||
"沉默期天数",
|
||||
"沉默期最晚过期日期",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
const inputInlineMaxRowsConfigKey = "batch.inbound.inputInlineMaxRows"
|
||||
|
||||
type BatchExecuteMessage struct {
|
||||
BatchNo string `json:"batchNo"`
|
||||
BatchType int `json:"batchType"`
|
||||
}
|
||||
|
||||
type PendingResult struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
}
|
||||
|
||||
func CreatePendingExcel(ctx context.Context, file *ghttp.UploadFile, in *model.TSimCardInboundExcelSubmitParam) (*PendingResult, error) {
|
||||
if in == nil {
|
||||
in = &model.TSimCardInboundExcelSubmitParam{}
|
||||
}
|
||||
if file == nil {
|
||||
return nil, gerror.New("请选择上传文件")
|
||||
}
|
||||
if err := service.SysOss().CheckType(ctx, consts.SysUploadFileTypeFile, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.Size > consts.SimCardInboundMaxFileSize {
|
||||
return nil, gerror.Newf("上传文件大小超过限制,最大为%dM", consts.SimCardInboundMaxFileSize>>20)
|
||||
}
|
||||
|
||||
batchNo, err := batchimport.NextBatchNoContext(ctx, consts.BatchNoPrefixInbound)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := readUploadContent(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
importOss, err := service.SysOss().SaveContent(ctx, &model.SysOssSaveContentParam{
|
||||
Content: content,
|
||||
FileName: batchNo + "-import.xlsx",
|
||||
NewFileType: consts.SysUploadNewFileTypeUserFile,
|
||||
SubDirName: consts.SimCardInboundSubDirImportExcel,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "保存入库原始Excel失败")
|
||||
}
|
||||
|
||||
info := newPendingBatchInfo()
|
||||
info["source"] = g.Map{"importFileOssId": importOss.OssId}
|
||||
info["inboundOptions"] = inboundOptions(in.InboundCommon())
|
||||
return createPendingAndPublish(ctx, pendingInput{
|
||||
BatchNo: batchNo,
|
||||
SourceType: consts.BatchSourceTypeFile,
|
||||
SourceOssId: importOss.OssId,
|
||||
BatchInfo: gjson.New(info),
|
||||
})
|
||||
}
|
||||
|
||||
func CreatePendingRange(ctx context.Context, in *model.TSimCardInboundRangeSubmitParam) (*PendingResult, error) {
|
||||
if in == nil {
|
||||
in = &model.TSimCardInboundRangeSubmitParam{}
|
||||
}
|
||||
if err := validateInboundCommon(in.InboundCommon()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rangeInfo, err := pendingRangeInfo(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batchNo, err := batchimport.NextBatchNoContext(ctx, consts.BatchNoPrefixInbound)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := newPendingBatchInfo()
|
||||
info["source"] = g.Map{"range": rangeInfo}
|
||||
info["inboundOptions"] = inboundOptions(in.InboundCommon())
|
||||
return createPendingAndPublish(ctx, pendingInput{
|
||||
BatchNo: batchNo,
|
||||
SourceType: consts.BatchSourceTypeRange,
|
||||
BatchInfo: gjson.New(info),
|
||||
})
|
||||
}
|
||||
|
||||
func CreatePendingInput(ctx context.Context, in *model.TSimCardInboundInputSubmitParam) (*PendingResult, error) {
|
||||
if in == nil {
|
||||
in = &model.TSimCardInboundInputSubmitParam{}
|
||||
}
|
||||
if err := validateInboundCommon(in.InboundCommon()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inputText := in.Input
|
||||
if strings.TrimSpace(inputText) == "" {
|
||||
return nil, gerror.New("批量输入入库数据为空")
|
||||
}
|
||||
batchNo, err := batchimport.NextBatchNoContext(ctx, consts.BatchNoPrefixInbound)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sourceOssId int64
|
||||
source := g.Map{}
|
||||
if countNonEmptyLines(inputText) <= inputInlineMaxRows(ctx) {
|
||||
source["inputText"] = inputText
|
||||
} else {
|
||||
inputOss, saveErr := service.SysOss().SaveContent(ctx, &model.SysOssSaveContentParam{
|
||||
Content: []byte(inputText),
|
||||
FileName: batchNo + "-input.txt",
|
||||
NewFileType: consts.SysUploadNewFileTypeUserFile,
|
||||
SubDirName: consts.SimCardInboundSubDirImportInput,
|
||||
})
|
||||
if saveErr != nil {
|
||||
return nil, gerror.Wrap(saveErr, "保存入库批量输入原文失败")
|
||||
}
|
||||
source["inputFileOssId"] = inputOss.OssId
|
||||
sourceOssId = inputOss.OssId
|
||||
}
|
||||
|
||||
info := newPendingBatchInfo()
|
||||
info["source"] = source
|
||||
info["inboundOptions"] = inboundOptions(in.InboundCommon())
|
||||
return createPendingAndPublish(ctx, pendingInput{
|
||||
BatchNo: batchNo,
|
||||
SourceType: consts.BatchSourceTypeBatchInput,
|
||||
SourceOssId: sourceOssId,
|
||||
BatchInfo: gjson.New(info),
|
||||
})
|
||||
}
|
||||
|
||||
type pendingInput struct {
|
||||
BatchNo string
|
||||
SourceType int
|
||||
SourceOssId int64
|
||||
BatchInfo *gjson.Json
|
||||
}
|
||||
|
||||
func createPendingAndPublish(ctx context.Context, input pendingInput) (*PendingResult, error) {
|
||||
if strings.TrimSpace(input.BatchNo) == "" {
|
||||
return nil, gerror.New("批次号为空")
|
||||
}
|
||||
operator := batchimport.OperatorContext{
|
||||
TenantId: contexts.GetTenantId(ctx),
|
||||
DeptId: contexts.GetDeptId(ctx),
|
||||
UserId: contexts.GetUserId(ctx),
|
||||
}
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if _, insertErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Data(do.BatchMain{
|
||||
BatchNo: input.BatchNo,
|
||||
OrderSn: "",
|
||||
BatchType: consts.BatchTypeInbound,
|
||||
BatchStatus: consts.BatchStatusNormal,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
SourceType: input.SourceType,
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
SourceBatchNo: input.BatchNo,
|
||||
SourceOssId: inboundOssIDValue(input.SourceOssId),
|
||||
IsCrossDept: 0,
|
||||
DeptId: operator.DeptId,
|
||||
TotalCount: 0,
|
||||
ValidCount: 0,
|
||||
SuccessCount: 0,
|
||||
FailCount: 0,
|
||||
RunResult: consts.BatchRunResultPending,
|
||||
FailReason: "",
|
||||
BatchInfo: input.BatchInfo,
|
||||
TenantId: operator.TenantId,
|
||||
CreatedDept: operator.DeptId,
|
||||
CreatedBy: operator.UserId,
|
||||
UpdatedBy: operator.UserId,
|
||||
}).Insert(); insertErr != nil {
|
||||
return gerror.Wrap(insertErr, "写入待处理入库批次失败")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
publishBatch(ctx, input.BatchNo)
|
||||
return &PendingResult{BatchNo: input.BatchNo, BatchType: consts.BatchTypeInbound}, nil
|
||||
}
|
||||
|
||||
func publishBatch(ctx context.Context, batchNo string) {
|
||||
if err := rabbitmq.Publish(ctx, consts.QueueBatchExecute, BatchExecuteMessage{
|
||||
BatchNo: batchNo,
|
||||
BatchType: consts.BatchTypeInbound,
|
||||
}); err != nil {
|
||||
g.Log().Errorf(ctx, "inbound batch async publish failed, batchNo=%s, batchType=%d, tenantId=%s, error=%+v",
|
||||
batchNo, consts.BatchTypeInbound, contexts.GetTenantId(ctx), err)
|
||||
}
|
||||
}
|
||||
|
||||
func inboundOssIDValue(ossID int64) any {
|
||||
if ossID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return ossID
|
||||
}
|
||||
|
||||
func newPendingBatchInfo() g.Map {
|
||||
return g.Map{
|
||||
"resultFile": g.Map{
|
||||
"status": "pending",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func inboundOptions(common model.TSimCardInboundCommonFields) g.Map {
|
||||
return g.Map{
|
||||
"chanKey": strings.TrimSpace(common.ChanKey),
|
||||
"validateUpstreamBasicInfo": common.ValidateUpstreamBasicInfo,
|
||||
"cardType": strings.TrimSpace(common.CardType),
|
||||
"upPkgCode": strings.TrimSpace(common.UpPkgCode),
|
||||
"suppliersCode": strings.TrimSpace(common.SuppliersCode),
|
||||
"upstreamActivationTime": common.UpstreamActivationTime,
|
||||
"insertToPlugEnabled": common.InsertToPlugEnabled,
|
||||
"carrierTrafficCutoffEnabled": common.CarrierTrafficCutoffEnabled,
|
||||
"agreementExpiryDate": common.AgreementExpiryDate,
|
||||
"hasTestPeriod": common.HasTestPeriod,
|
||||
"testPeriodDays": common.TestPeriodDays,
|
||||
"testPeriodLatestExpiryDate": common.TestPeriodLatestExpiryDate,
|
||||
"hasSilentPeriod": common.HasSilentPeriod,
|
||||
"silentPeriodDays": common.SilentPeriodDays,
|
||||
"silentPeriodLatestExpiryDate": common.SilentPeriodLatestExpiryDate,
|
||||
}
|
||||
}
|
||||
|
||||
func validateInboundCommon(common model.TSimCardInboundCommonFields) error {
|
||||
if strings.TrimSpace(common.ChanKey) == "" {
|
||||
return gerror.New("通道编号不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pendingRangeInfo(in *model.TSimCardInboundRangeSubmitParam) (g.Map, error) {
|
||||
rangeType := strings.ToLower(strings.TrimSpace(in.RangeType))
|
||||
if rangeType == "" {
|
||||
rangeType = "iccid"
|
||||
}
|
||||
if rangeType != "iccid" && rangeType != "msisdn" && rangeType != "imsi" {
|
||||
return nil, gerror.New("连号类型必须是 iccid、msisdn 或 imsi")
|
||||
}
|
||||
values := g.Map{
|
||||
"rangeType": rangeType,
|
||||
"startIccid": strings.TrimSpace(in.StartIccid),
|
||||
"endIccid": strings.TrimSpace(in.EndIccid),
|
||||
"startMsisdn": strings.TrimSpace(in.StartMsisdn),
|
||||
"endMsisdn": strings.TrimSpace(in.EndMsisdn),
|
||||
"startImsi": strings.TrimSpace(in.StartImsi),
|
||||
"endImsi": strings.TrimSpace(in.EndImsi),
|
||||
}
|
||||
fieldNames := map[string][2]string{
|
||||
"iccid": {"startIccid", "endIccid"},
|
||||
"msisdn": {"startMsisdn", "endMsisdn"},
|
||||
"imsi": {"startImsi", "endImsi"},
|
||||
}
|
||||
for _, key := range fieldNames[rangeType] {
|
||||
if strings.TrimSpace(values[key].(string)) == "" {
|
||||
return nil, gerror.Newf("%s不能为空", key)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func countNonEmptyLines(input string) int {
|
||||
count := 0
|
||||
for _, line := range strings.Split(input, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func inputInlineMaxRows(ctx context.Context) int {
|
||||
value := g.Cfg().MustGet(ctx, inputInlineMaxRowsConfigKey, consts.SimCardInboundDefaultInputInlineMaxRows).Int()
|
||||
if value <= 0 {
|
||||
return consts.SimCardInboundDefaultInputInlineMaxRows
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func readUploadContent(file *ghttp.UploadFile) ([]byte, error) {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "打开上传文件失败")
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "读取上传文件失败")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type BatchExecutor interface {
|
||||
Execute(ctx context.Context, batchNo string) error
|
||||
MarkFailed(ctx context.Context, batchNo, reason string) error
|
||||
}
|
||||
|
||||
func Dispatch(executor BatchExecutor, batchNo string) {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
reason := fmt.Sprintf("后台执行异常: %v", recovered)
|
||||
g.Log().Errorf(ctx, "SIM lifecycle batch panic, batchNo=%s, reason=%s", batchNo, reason)
|
||||
if err := executor.MarkFailed(ctx, batchNo, reason); err != nil {
|
||||
g.Log().Errorf(ctx, "mark SIM lifecycle batch failed after panic, batchNo=%s, error=%+v", batchNo, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := executor.Execute(ctx, batchNo); err != nil {
|
||||
g.Log().Errorf(ctx, "execute SIM lifecycle batch failed, batchNo=%s, error=%+v", batchNo, err)
|
||||
if markErr := executor.MarkFailed(ctx, batchNo, err.Error()); markErr != nil {
|
||||
g.Log().Errorf(ctx, "mark SIM lifecycle batch failed, batchNo=%s, error=%+v", batchNo, markErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
type executorRepository interface {
|
||||
ClaimBatch(ctx context.Context, batchNo string) (bool, error)
|
||||
LoadTask(ctx context.Context, batchNo string) (*Task, error)
|
||||
LoadCards(ctx context.Context, task *Task) (map[string]*entity.TSimCardInfo, error)
|
||||
SaveValidationFailure(ctx context.Context, task *Task) error
|
||||
SaveOutcome(ctx context.Context, task *Task, row *Row, card *entity.TSimCardInfo, outcome Outcome) error
|
||||
SaveDetailFailure(ctx context.Context, task *Task, row *Row) error
|
||||
FinalizeBatch(ctx context.Context, task *Task) error
|
||||
MarkBatchFailed(ctx context.Context, batchNo, reason string) error
|
||||
}
|
||||
|
||||
type statusChanger interface {
|
||||
ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeItem], error)
|
||||
}
|
||||
|
||||
type Executor struct {
|
||||
repo executorRepository
|
||||
upstream statusChanger
|
||||
}
|
||||
|
||||
func NewExecutor() *Executor {
|
||||
return &Executor{repo: NewRepository(), upstream: service.UpstreamChan()}
|
||||
}
|
||||
|
||||
func newExecutor(repo executorRepository, upstream statusChanger) *Executor {
|
||||
return &Executor{repo: repo, upstream: upstream}
|
||||
}
|
||||
|
||||
func (e *Executor) Execute(ctx context.Context, batchNo string) error {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
return gerror.New("批次号不能为空")
|
||||
}
|
||||
claimed, err := e.repo.ClaimBatch(ctx, batchNo)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "抢占停复机批次失败")
|
||||
}
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
task, err := e.repo.LoadTask(ctx, batchNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cards, err := e.repo.LoadCards(ctx, task)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取SIM卡业务数据失败")
|
||||
}
|
||||
if !validateTask(task, cards) {
|
||||
if err = e.repo.SaveValidationFailure(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.repo.FinalizeBatch(ctx, task)
|
||||
}
|
||||
|
||||
groups := groupRowsByChannel(task.Rows)
|
||||
channelKeys := make([]string, 0, len(groups))
|
||||
for chanKey := range groups {
|
||||
channelKeys = append(channelKeys, chanKey)
|
||||
}
|
||||
sort.Strings(channelKeys)
|
||||
for _, chanKey := range channelKeys {
|
||||
rows := groups[chanKey]
|
||||
outcomes := e.changeGroup(ctx, task, rows)
|
||||
for i, row := range rows {
|
||||
outcome := outcomes[i]
|
||||
card := cards[row.Iccid]
|
||||
if err = e.repo.SaveOutcome(ctx, task, row, card, outcome); err == nil {
|
||||
continue
|
||||
}
|
||||
row.OrderNum = outcome.OrderNum
|
||||
row.UpstreamCode = outcome.Code
|
||||
row.UpstreamReason = outcome.Message
|
||||
row.Reason = "上游已返回,但本地状态落库失败: " + err.Error()
|
||||
if saveErr := e.repo.SaveDetailFailure(ctx, task, row); saveErr != nil {
|
||||
return gerror.Wrap(saveErr, "记录本地状态落库失败结果失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
return e.repo.FinalizeBatch(ctx, task)
|
||||
}
|
||||
|
||||
func (e *Executor) MarkFailed(ctx context.Context, batchNo, reason string) error {
|
||||
return e.repo.MarkBatchFailed(ctx, strings.TrimSpace(batchNo), strings.TrimSpace(reason))
|
||||
}
|
||||
|
||||
func validateTask(task *Task, cards map[string]*entity.TSimCardInfo) bool {
|
||||
hasFailure := false
|
||||
for _, row := range task.Rows {
|
||||
if row == nil {
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
if row.Reason != "" {
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
card := cards[row.Iccid]
|
||||
if card == nil {
|
||||
row.AddErrorReason("ICCID不存在于正式卡记录")
|
||||
} else if !row.Authorized {
|
||||
row.AddErrorReason("ICCID不在当前操作人的数据权限范围内")
|
||||
} else if strings.TrimSpace(card.ChanKey) == "" {
|
||||
row.AddErrorReason("ICCID缺少通道标识chanKey")
|
||||
} else if row.Msisdn != "" && row.Msisdn != strings.TrimSpace(card.Msisdn) {
|
||||
row.AddErrorReason("MSISDN与正式卡记录不一致")
|
||||
} else if row.Imsi != "" && row.Imsi != strings.TrimSpace(card.Imsi) {
|
||||
row.AddErrorReason("IMSI与正式卡记录不一致")
|
||||
} else {
|
||||
validateCardStatus(task.Operation, row, card)
|
||||
}
|
||||
if row.Reason != "" {
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
row.Msisdn = strings.TrimSpace(card.Msisdn)
|
||||
row.Imsi = strings.TrimSpace(card.Imsi)
|
||||
row.ChanKey = strings.TrimSpace(card.ChanKey)
|
||||
row.StatusBefore = card.BossCardStatus
|
||||
}
|
||||
if hasFailure {
|
||||
for _, row := range task.Rows {
|
||||
if row != nil && row.Reason == "" {
|
||||
row.AddErrorReason(batchimport.NotExecutedReasonPrefix + "本卡校验通过")
|
||||
}
|
||||
}
|
||||
task.FailureReason = "整批业务校验失败,未请求上游"
|
||||
return false
|
||||
}
|
||||
task.ValidationOK = true
|
||||
return true
|
||||
}
|
||||
|
||||
func validateCardStatus(operation Operation, row *Row, card *entity.TSimCardInfo) {
|
||||
// TODO: 正常系统开发完成后恢复卡状态限制
|
||||
// 注释原因:开发环境经常出现卡状态不在预期可操作范围内的情况,先放行
|
||||
_ = operation
|
||||
_ = row
|
||||
_ = card
|
||||
// switch operation {
|
||||
// case OperationSuspend:
|
||||
// if card.BossCardStatus != consts.V3ApiSimBossCardStatusActivated {
|
||||
// row.AddErrorReason("仅已激活状态的卡可以自主停机")
|
||||
// }
|
||||
// case OperationResume:
|
||||
// if card.BossCardStatus != consts.V3ApiSimBossCardStatusStopped {
|
||||
// row.AddErrorReason("仅停机状态的卡可以自主复机")
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
func groupRowsByChannel(rows []*Row) map[string][]*Row {
|
||||
groups := make(map[string][]*Row)
|
||||
for _, row := range rows {
|
||||
groups[row.ChanKey] = append(groups[row.ChanKey], row)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func (e *Executor) changeGroup(ctx context.Context, task *Task, rows []*Row) []Outcome {
|
||||
simKeys := make([]*model.SimKey, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
simKeys = append(simKeys, &model.SimKey{
|
||||
ChanKey: row.ChanKey,
|
||||
ICCID: row.Iccid,
|
||||
MSISDN: row.Msisdn,
|
||||
IMSI: row.Imsi,
|
||||
})
|
||||
}
|
||||
response, err := e.upstream.ChangeSimStatus(ctx, &model.SimCardStatusChangeInput{
|
||||
SimKeys: simKeys,
|
||||
ReqData: model.SimCardStatusChangeInputItem{OperType: task.Config.OperType},
|
||||
})
|
||||
if err != nil {
|
||||
outcomes := make([]Outcome, len(rows))
|
||||
for i := range outcomes {
|
||||
outcomes[i] = Outcome{Message: "上游请求失败: " + err.Error()}
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
return mapOutcomes(rows, response)
|
||||
}
|
||||
|
||||
func mapOutcomes(rows []*Row, response []model.SimBaseRes[model.SimCardStatusChangeItem]) []Outcome {
|
||||
outcomes := make([]Outcome, len(rows))
|
||||
used := make([]bool, len(response))
|
||||
for rowIndex, row := range rows {
|
||||
matched := -1
|
||||
for responseIndex, item := range response {
|
||||
if !used[responseIndex] && sameSim(row, item.SimKey) {
|
||||
matched = responseIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched < 0 && rowIndex < len(response) && !used[rowIndex] && response[rowIndex].SimKey == nil {
|
||||
matched = rowIndex
|
||||
}
|
||||
if matched < 0 {
|
||||
outcomes[rowIndex] = Outcome{Message: "上游未返回该卡处理结果"}
|
||||
continue
|
||||
}
|
||||
used[matched] = true
|
||||
item := response[matched]
|
||||
message := strings.TrimSpace(item.Message)
|
||||
if item.Code != 0 && message == "" {
|
||||
message = "上游处理失败"
|
||||
}
|
||||
outcomes[rowIndex] = Outcome{
|
||||
Success: item.Code == 0,
|
||||
Code: item.Code,
|
||||
Message: message,
|
||||
OrderNum: strings.TrimSpace(item.ResData.OrderNum),
|
||||
}
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
func sameSim(row *Row, key *model.SimKey) bool {
|
||||
if row == nil || key == nil {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(key.ICCID); value != "" {
|
||||
return value == row.Iccid
|
||||
}
|
||||
if value := strings.TrimSpace(key.MSISDN); value != "" {
|
||||
return value == row.Msisdn
|
||||
}
|
||||
if value := strings.TrimSpace(key.IMSI); value != "" {
|
||||
return value == row.Imsi
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
type fakeExecutorRepository struct {
|
||||
mu sync.Mutex
|
||||
claims []bool
|
||||
claimCalls int
|
||||
loadedBatchNos []string
|
||||
task *Task
|
||||
cards map[string]*entity.TSimCardInfo
|
||||
validationFailureCalls int
|
||||
outcomes []Outcome
|
||||
finalizeCalls int
|
||||
markFailedCalls int
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) ClaimBatch(context.Context, string) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
index := r.claimCalls
|
||||
r.claimCalls++
|
||||
if index >= len(r.claims) {
|
||||
return false, nil
|
||||
}
|
||||
return r.claims[index], nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) LoadTask(_ context.Context, batchNo string) (*Task, error) {
|
||||
r.loadedBatchNos = append(r.loadedBatchNos, batchNo)
|
||||
return r.task, nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) LoadCards(context.Context, *Task) (map[string]*entity.TSimCardInfo, error) {
|
||||
return r.cards, nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) SaveValidationFailure(context.Context, *Task) error {
|
||||
r.validationFailureCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) SaveOutcome(_ context.Context, _ *Task, _ *Row, _ *entity.TSimCardInfo, outcome Outcome) error {
|
||||
r.outcomes = append(r.outcomes, outcome)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) SaveDetailFailure(context.Context, *Task, *Row) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) FinalizeBatch(context.Context, *Task) error {
|
||||
r.finalizeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) MarkBatchFailed(context.Context, string, string) error {
|
||||
r.markFailedCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeStatusChanger struct {
|
||||
calls int
|
||||
requests []*model.SimCardStatusChangeInput
|
||||
response []model.SimBaseRes[model.SimCardStatusChangeItem]
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *fakeStatusChanger) ChangeSimStatus(_ context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeItem], error) {
|
||||
c.calls++
|
||||
c.requests = append(c.requests, input)
|
||||
return c.response, c.err
|
||||
}
|
||||
|
||||
func TestActionOperTypeMapping(t *testing.T) {
|
||||
stop, err := OperationSuspend.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, err := OperationResume.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stop.OperType != "0" || stop.BatchType != consts.BatchTypeStop {
|
||||
t.Fatalf("unexpected stop mapping: %+v", stop)
|
||||
}
|
||||
if start.OperType != "1" || start.BatchType != consts.BatchTypeStart {
|
||||
t.Fatalf("unexpected start mapping: %+v", start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidationFailureSkipsUpstream(t *testing.T) {
|
||||
task := newTestTask(t, OperationSuspend, []*Row{
|
||||
{RowNo: 1, Iccid: "missing", Authorized: true},
|
||||
{RowNo: 2, Iccid: "valid", Authorized: true},
|
||||
})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"valid": {Id: 2, Iccid: "valid", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{}
|
||||
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream called %d times for invalid batch", upstream.calls)
|
||||
}
|
||||
if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 {
|
||||
t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls)
|
||||
}
|
||||
if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" {
|
||||
t.Fatalf("expected all rows to retain failure reasons: %+v", task.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStopStatus(t *testing.T) {
|
||||
t.Skip("TODO: 恢复卡状态限制后启用此测试")
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
valid bool
|
||||
}{
|
||||
{name: "activated", status: consts.V3ApiSimBossCardStatusActivated, valid: true},
|
||||
{name: "pending", status: consts.V3ApiSimBossCardStatusPendingActivate},
|
||||
{name: "testable", status: consts.V3ApiSimBossCardStatusTestable},
|
||||
{name: "stock", status: consts.V3ApiSimBossCardStatusStock},
|
||||
{name: "stopped", status: consts.V3ApiSimBossCardStatusStopped},
|
||||
{name: "pre-cancel", status: consts.V3ApiSimBossCardStatusPreCancel},
|
||||
{name: "canceled", status: consts.V3ApiSimBossCardStatusCanceled},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
row := &Row{Iccid: "card-1"}
|
||||
validateCardStatus(OperationSuspend, row, &entity.TSimCardInfo{BossCardStatus: tt.status})
|
||||
if got := row.Reason == ""; got != tt.valid {
|
||||
t.Fatalf("status %d valid=%v, reason=%q", tt.status, got, row.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMixedStopStatusSkipsUpstream(t *testing.T) {
|
||||
t.Skip("TODO: 恢复卡状态限制后启用此测试")
|
||||
task := newTestTask(t, OperationSuspend, []*Row{
|
||||
{RowNo: 1, Iccid: "activated", Authorized: true},
|
||||
{RowNo: 2, Iccid: "stock", Authorized: true},
|
||||
})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"activated": {Id: 1, Iccid: "activated", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
"stock": {Id: 2, Iccid: "stock", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStock},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{}
|
||||
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-mixed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream called %d times for mixed status batch", upstream.calls)
|
||||
}
|
||||
if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 {
|
||||
t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls)
|
||||
}
|
||||
if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" {
|
||||
t.Fatalf("expected both rows to be failed without upstream call: %+v", task.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRestoresByBatchNoAndMapsPerCardResults(t *testing.T) {
|
||||
task := newTestTask(t, OperationSuspend, []*Row{
|
||||
{RowNo: 1, Iccid: "card-1", Authorized: true},
|
||||
{RowNo: 2, Iccid: "card-2", Authorized: true},
|
||||
})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
"card-2": {Id: 2, Iccid: "card-2", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{
|
||||
{Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}, ResData: model.SimCardStatusChangeItem{OrderNum: "order-1"}},
|
||||
{Code: 500, Message: "operator rejected", SimKey: &model.SimKey{ICCID: "card-2"}},
|
||||
}}
|
||||
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-restore"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.loadedBatchNos) != 1 || repo.loadedBatchNos[0] != "STP-restore" {
|
||||
t.Fatalf("executor did not restore by batchNo: %+v", repo.loadedBatchNos)
|
||||
}
|
||||
if upstream.calls != 1 || len(upstream.requests) != 1 {
|
||||
t.Fatalf("unexpected upstream calls: %d", upstream.calls)
|
||||
}
|
||||
if upstream.requests[0].ReqData.OperType != "0" {
|
||||
t.Fatalf("unexpected operType: %s", upstream.requests[0].ReqData.OperType)
|
||||
}
|
||||
if len(repo.outcomes) != 2 || !repo.outcomes[0].Success || repo.outcomes[1].Success {
|
||||
t.Fatalf("unexpected per-card outcomes: %+v", repo.outcomes)
|
||||
}
|
||||
if repo.outcomes[0].OrderNum != "order-1" || repo.outcomes[1].Message != "operator rejected" {
|
||||
t.Fatalf("upstream details were not preserved: %+v", repo.outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDuplicateClaimRunsOnlyOnce(t *testing.T) {
|
||||
task := newTestTask(t, OperationResume, []*Row{{RowNo: 1, Iccid: "card-1", Authorized: true}})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true, false},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStopped},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{
|
||||
{Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}},
|
||||
}}
|
||||
executor := newExecutor(repo, upstream)
|
||||
if err := executor.Execute(context.Background(), "STR-once"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := executor.Execute(context.Background(), "STR-once"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 1 || len(repo.loadedBatchNos) != 1 || repo.finalizeCalls != 1 {
|
||||
t.Fatalf("duplicate claim executed twice: upstream=%d load=%d finalize=%d", upstream.calls, len(repo.loadedBatchNos), repo.finalizeCalls)
|
||||
}
|
||||
if upstream.requests[0].ReqData.OperType != "1" {
|
||||
t.Fatalf("unexpected start operType: %s", upstream.requests[0].ReqData.OperType)
|
||||
}
|
||||
}
|
||||
|
||||
type blockingExecutor struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (e *blockingExecutor) Execute(context.Context, string) error {
|
||||
close(e.started)
|
||||
<-e.release
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *blockingExecutor) MarkFailed(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDispatchReturnsBeforeExecutorCompletes(t *testing.T) {
|
||||
executor := &blockingExecutor{started: make(chan struct{}), release: make(chan struct{})}
|
||||
Dispatch(executor, "STP-async")
|
||||
select {
|
||||
case <-executor.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("background executor did not start")
|
||||
}
|
||||
select {
|
||||
case <-executor.release:
|
||||
t.Fatal("release channel should still be blocked")
|
||||
default:
|
||||
}
|
||||
close(executor.release)
|
||||
}
|
||||
|
||||
func newTestTask(t *testing.T, operation Operation, rows []*Row) *Task {
|
||||
t.Helper()
|
||||
config, err := operation.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Task{
|
||||
Batch: &entity.BatchMain{BatchNo: "batch-test", BatchType: config.BatchType, TenantId: "tenant-1"},
|
||||
Operation: operation,
|
||||
Config: config,
|
||||
Rows: rows,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBatchExecuteMessageJSONContract(t *testing.T) {
|
||||
body, err := json.Marshal(BatchExecuteMessage{
|
||||
BatchNo: "STP202607170001",
|
||||
BatchType: 104,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal batch execute message: %v", err)
|
||||
}
|
||||
const expected = `{"batchNo":"STP202607170001","batchType":104}`
|
||||
if string(body) != expected {
|
||||
t.Fatalf("unexpected batch execute message: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
type Operation int
|
||||
|
||||
const (
|
||||
OperationSuspend Operation = iota + 1
|
||||
OperationResume
|
||||
OperationRestart
|
||||
)
|
||||
|
||||
type OperationConfig struct {
|
||||
Operation Operation
|
||||
Code string
|
||||
Name string
|
||||
BatchType int
|
||||
BatchPrefix string
|
||||
OperType string
|
||||
TargetStatus int
|
||||
TaskName string
|
||||
ExportScene int
|
||||
ImportDir string
|
||||
ResultDir string
|
||||
}
|
||||
|
||||
func (operation Operation) Config() (OperationConfig, error) {
|
||||
switch operation {
|
||||
case OperationSuspend:
|
||||
return OperationConfig{
|
||||
Operation: operation,
|
||||
Code: "stop",
|
||||
Name: "自主停机",
|
||||
BatchType: consts.BatchTypeStop,
|
||||
BatchPrefix: consts.BatchNoPrefixStop,
|
||||
OperType: consts.SimCardStatusOperTypeStop,
|
||||
TargetStatus: consts.V3ApiSimBossCardStatusStopped,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardStop,
|
||||
ExportScene: consts.ResourceExportSceneStop,
|
||||
ImportDir: consts.SimCardStopSubDirImportExcel,
|
||||
ResultDir: consts.SimCardStopSubDirResultExcel,
|
||||
}, nil
|
||||
case OperationResume:
|
||||
return OperationConfig{
|
||||
Operation: operation,
|
||||
Code: "start",
|
||||
Name: "自主复机",
|
||||
BatchType: consts.BatchTypeStart,
|
||||
BatchPrefix: consts.BatchNoPrefixStart,
|
||||
OperType: consts.SimCardStatusOperTypeStart,
|
||||
TargetStatus: consts.V3ApiSimBossCardStatusActivated,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardStart,
|
||||
ExportScene: consts.ResourceExportSceneStart,
|
||||
ImportDir: consts.SimCardStartSubDirImportExcel,
|
||||
ResultDir: consts.SimCardStartSubDirResultExcel,
|
||||
}, nil
|
||||
case OperationRestart:
|
||||
return OperationConfig{
|
||||
Operation: operation,
|
||||
Code: "restart",
|
||||
Name: "自主重启",
|
||||
BatchType: consts.BatchTypeRestart,
|
||||
BatchPrefix: consts.BatchNoPrefixRestart,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardRestart,
|
||||
ExportScene: consts.ResourceExportSceneRestart,
|
||||
ImportDir: consts.SimCardRestartSubDirImportExcel,
|
||||
ResultDir: consts.SimCardRestartSubDirResultExcel,
|
||||
}, nil
|
||||
default:
|
||||
return OperationConfig{}, gerror.New("不支持的SIM卡状态操作")
|
||||
}
|
||||
}
|
||||
|
||||
func OperationByBatchType(batchType int) (Operation, error) {
|
||||
switch batchType {
|
||||
case consts.BatchTypeStop:
|
||||
return OperationSuspend, nil
|
||||
case consts.BatchTypeStart:
|
||||
return OperationResume, nil
|
||||
default:
|
||||
return 0, gerror.Newf("批次类型%d不是停复机执行批次", batchType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
func ParseExcelFileRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
parsed, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{RequiredHeaders: []string{"ICCID"}, MaxDataRows: consts.SimCardBatchMaxRows})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(parsed))
|
||||
for _, item := range parsed {
|
||||
rows = append(rows, &Row{
|
||||
RowNo: item.RowNo,
|
||||
Iccid: excel.Field(item, "ICCID"),
|
||||
Msisdn: excel.Field(item, "MSISDN"),
|
||||
Imsi: excel.Field(item, "IMSI"),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func ParseInputRows(ctx context.Context, input string) ([]batchimport.Row, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New("批量输入内容不能为空")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &Row{RowNo: line.RowNo}
|
||||
switch len(line.Fields) {
|
||||
case 1:
|
||||
row.Iccid = line.Fields[0]
|
||||
case 3:
|
||||
row.Iccid = line.Fields[0]
|
||||
row.Msisdn = line.Fields[1]
|
||||
row.Imsi = line.Fields[2]
|
||||
default:
|
||||
row.AddErrorReason("格式错误,应为 ICCID 或 ICCID MSISDN IMSI")
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func validateBasicRows(rows []batchimport.Row) error {
|
||||
firstByIccid := make(map[string]*Row, len(rows))
|
||||
issues := make([]string, 0)
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return gerror.New("停复机批次行类型错误")
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
row.Msisdn = strings.TrimSpace(row.Msisdn)
|
||||
row.Imsi = strings.TrimSpace(row.Imsi)
|
||||
if row.Reason != "" {
|
||||
issues = append(issues, fmt.Sprintf("第%d行:%s", row.RowNo, row.Reason))
|
||||
continue
|
||||
}
|
||||
if row.Iccid == "" {
|
||||
row.AddErrorReason("ICCID不能为空")
|
||||
issues = append(issues, fmt.Sprintf("第%d行:ICCID不能为空", row.RowNo))
|
||||
continue
|
||||
}
|
||||
if first, exists := firstByIccid[row.Iccid]; exists {
|
||||
row.AddErrorReason(fmt.Sprintf("ICCID与第%d行重复", first.RowNo))
|
||||
first.AddErrorReason("ICCID重复")
|
||||
issues = append(issues, fmt.Sprintf("第%d行:ICCID与第%d行重复", row.RowNo, first.RowNo))
|
||||
continue
|
||||
}
|
||||
firstByIccid[row.Iccid] = row
|
||||
}
|
||||
if len(issues) > 0 {
|
||||
return gerror.New("停复机批次基础格式校验失败:" + strings.Join(issues, ";"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
)
|
||||
|
||||
func TestValidateBasicRowsRejectsDuplicateIccid(t *testing.T) {
|
||||
rows := []batchimport.Row{
|
||||
&Row{RowNo: 2, Iccid: " 8986001 "},
|
||||
&Row{RowNo: 3, Iccid: "8986001"},
|
||||
}
|
||||
err := validateBasicRows(rows)
|
||||
if err == nil || !strings.Contains(err.Error(), "ICCID") || !strings.Contains(err.Error(), "重复") {
|
||||
t.Fatalf("expected readable duplicate ICCID error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBasicRowsRejectsEveryEmptyIccid(t *testing.T) {
|
||||
rows := []batchimport.Row{
|
||||
&Row{RowNo: 2, Iccid: ""},
|
||||
&Row{RowNo: 5, Iccid: " "},
|
||||
}
|
||||
err := validateBasicRows(rows)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty ICCID validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "第2行") || !strings.Contains(err.Error(), "第5行") {
|
||||
t.Fatalf("expected all empty ICCID rows in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBasicRowsRejectsWrongFieldCount(t *testing.T) {
|
||||
rows, err := ParseInputRows(context.Background(), "8986001 13800008000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = validateBasicRows(rows)
|
||||
if err == nil || !strings.Contains(err.Error(), "格式") {
|
||||
t.Fatalf("expected wrong field count error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidBasicRowsStopBeforePendingPreparation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rows []batchimport.Row
|
||||
}{
|
||||
{
|
||||
name: "duplicate",
|
||||
rows: []batchimport.Row{
|
||||
&Row{RowNo: 2, Iccid: "8986001"},
|
||||
&Row{RowNo: 3, Iccid: "8986001"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple empty iccid",
|
||||
rows: []batchimport.Row{
|
||||
&Row{RowNo: 2},
|
||||
&Row{RowNo: 3, Iccid: " "},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
processor, err := NewProcessor(OperationSuspend)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared := false
|
||||
result, err := batchimport.CreatePendingRows(
|
||||
context.Background(),
|
||||
batchimport.SubmitRequest{},
|
||||
processor,
|
||||
tt.rows,
|
||||
batchimport.PendingOptions{PrepareRows: func(context.Context, []batchimport.Row) error {
|
||||
prepared = true
|
||||
return nil
|
||||
}},
|
||||
)
|
||||
if err == nil || result != nil {
|
||||
t.Fatalf("expected pre-persistence validation error, result=%+v err=%v", result, err)
|
||||
}
|
||||
if prepared {
|
||||
t.Fatal("invalid rows reached pending preparation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
config OperationConfig
|
||||
}
|
||||
|
||||
func NewProcessor(operation Operation) (*Processor, error) {
|
||||
config, err := operation.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Processor{config: config}, nil
|
||||
}
|
||||
|
||||
func (p *Processor) Config() batchimport.ProcessorConfig {
|
||||
return batchimport.ProcessorConfig{
|
||||
Code: "sim_card_" + p.config.Code,
|
||||
BatchType: p.config.BatchType,
|
||||
BatchNoPrefix: p.config.BatchPrefix,
|
||||
TaskName: p.config.TaskName,
|
||||
ExportScene: p.config.ExportScene,
|
||||
ImportDir: p.config.ImportDir,
|
||||
ResultDir: p.config.ResultDir,
|
||||
MaxFileSize: consts.SimCardLifecycleMaxFileSize,
|
||||
MaxRows: consts.SimCardBatchMaxRows,
|
||||
TemplateHeaders: []string{"ICCID", "MSISDN", "IMSI"},
|
||||
RequiredHeaders: []string{"ICCID"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return ParseExcelFileRows(ctx, filePath)
|
||||
}
|
||||
|
||||
func (p *Processor) ValidateRows(_ context.Context, rows []batchimport.Row) error {
|
||||
return validateBasicRows(rows)
|
||||
}
|
||||
|
||||
func (p *Processor) Execute(context.Context, gdb.TX, *batchimport.SubmitRequest, []batchimport.Row) error {
|
||||
return gerror.New("停复机批次只能由异步执行器执行")
|
||||
}
|
||||
|
||||
func (p *Processor) BuildBatchInfo(_ context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadID int64) *gjson.Json {
|
||||
info := g.Map{
|
||||
"operation": p.config.Code,
|
||||
}
|
||||
if p.config.OperType != "" {
|
||||
info["operType"] = p.config.OperType
|
||||
}
|
||||
if p.config.Operation == OperationRestart {
|
||||
info["stopOperType"] = consts.SimCardStatusOperTypeStop
|
||||
info["resumeOperType"] = consts.SimCardStatusOperTypeStart
|
||||
info["resumeDelaySeconds"] = 300
|
||||
info["restart"] = g.Map{"phase": "created"}
|
||||
}
|
||||
if options, ok := req.BizParams.(*SubmitOptions); ok && options != nil {
|
||||
if remark := strings.TrimSpace(options.Remark); remark != "" {
|
||||
info["remark"] = remark
|
||||
}
|
||||
}
|
||||
if result != nil && result.ImportFileOssId > 0 {
|
||||
info["importFileOssId"] = result.ImportFileOssId
|
||||
} else if importOss != nil && importOss.OssId > 0 {
|
||||
info["importFileOssId"] = importOss.OssId
|
||||
}
|
||||
if resultOss != nil {
|
||||
info["resultFileOssId"] = resultOss.OssId
|
||||
}
|
||||
if downloadID > 0 {
|
||||
info["resultDownloadId"] = downloadID
|
||||
}
|
||||
return gjson.New(info)
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type Repository struct{}
|
||||
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
func (r *Repository) ClaimBatch(ctx context.Context, batchNo string) (bool, error) {
|
||||
claimed := false
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
WhereIn(dao.BatchMain.Columns().BatchType, []int{consts.BatchTypeStart, consts.BatchTypeStop}).
|
||||
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusPending).
|
||||
Data(do.BatchMain{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
RunResult: consts.BatchRunResultRunning,
|
||||
StartTime: gtime.Now(),
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return nil
|
||||
}
|
||||
claimed = true
|
||||
_, err = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusPending).
|
||||
Data(do.BatchDetail{DetailStatus: consts.BatchDetailStatusRunning}).
|
||||
Update()
|
||||
return err
|
||||
})
|
||||
return claimed, err
|
||||
}
|
||||
|
||||
func (r *Repository) LoadTask(ctx context.Context, batchNo string) (*Task, error) {
|
||||
batch := &entity.BatchMain{}
|
||||
if err := dao.BatchMain.Ctx(ctx).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Scan(batch); err != nil {
|
||||
return nil, gerror.Wrap(err, "读取停复机批次失败")
|
||||
}
|
||||
if batch.Id == 0 {
|
||||
return nil, gerror.New("停复机批次不存在")
|
||||
}
|
||||
operation, err := OperationByBatchType(batch.BatchType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config, err := operation.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var details []*entity.BatchDetail
|
||||
if err = dao.BatchDetail.Ctx(ctx).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, batch.BatchType).
|
||||
OrderAsc(dao.BatchDetail.Columns().RowNo).
|
||||
Scan(&details); err != nil {
|
||||
return nil, gerror.Wrap(err, "读取停复机批次明细失败")
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil, gerror.New("停复机批次没有明细")
|
||||
}
|
||||
|
||||
task := &Task{
|
||||
Batch: batch,
|
||||
Operation: operation,
|
||||
Config: config,
|
||||
Rows: make([]*Row, 0, len(details)),
|
||||
Operator: batchimport.OperatorContext{TenantId: batch.TenantId, DeptId: batch.CreatedDept, UserId: batch.CreatedBy},
|
||||
StartTime: batch.StartTime,
|
||||
}
|
||||
if batch.BatchInfo != nil {
|
||||
task.Remark = strings.TrimSpace(batch.BatchInfo.Get("remark").String())
|
||||
}
|
||||
task.ImportOssID = sourceOssIDFromBatch(batch)
|
||||
for _, detail := range details {
|
||||
row := &Row{
|
||||
DetailID: detail.Id,
|
||||
RowNo: detail.RowNo,
|
||||
Iccid: strings.TrimSpace(detail.ItemId),
|
||||
Reason: strings.TrimSpace(detail.ErrorReason),
|
||||
}
|
||||
if detail.Snapshot != nil {
|
||||
if value := strings.TrimSpace(detail.Snapshot.Get("ICCID").String()); value != "" {
|
||||
row.Iccid = value
|
||||
}
|
||||
row.Msisdn = strings.TrimSpace(detail.Snapshot.Get("MSISDN").String())
|
||||
row.Imsi = strings.TrimSpace(detail.Snapshot.Get("IMSI").String())
|
||||
row.Authorized = detail.Snapshot.Get("authorized").Bool()
|
||||
}
|
||||
task.Rows = append(task.Rows, row)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *Repository) LoadCards(ctx context.Context, task *Task) (map[string]*entity.TSimCardInfo, error) {
|
||||
result := make(map[string]*entity.TSimCardInfo, len(task.Rows))
|
||||
iccids := make([]string, 0, len(task.Rows))
|
||||
for _, row := range task.Rows {
|
||||
if row != nil && row.Iccid != "" {
|
||||
iccids = append(iccids, row.Iccid)
|
||||
}
|
||||
}
|
||||
if len(iccids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var cards []*entity.TSimCardInfo
|
||||
err := dao.TSimCardInfo.Ctx(ctx).
|
||||
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantId).
|
||||
WhereIn(dao.TSimCardInfo.Columns().Iccid, iccids).
|
||||
Scan(&cards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card != nil {
|
||||
result[strings.TrimSpace(card.Iccid)] = card
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SaveValidationFailure(ctx context.Context, task *Task) error {
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
for _, row := range task.Rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
status := consts.BatchDetailStatusFailed
|
||||
if batchimport.IsNotExecuted(row) {
|
||||
status = consts.BatchDetailStatusNotExecuted
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().Id, row.DetailID).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
|
||||
Data(do.BatchDetail{
|
||||
DetailStatus: status,
|
||||
ErrorReason: row.Reason,
|
||||
Snapshot: row.Snapshot(),
|
||||
UpdatedBy: task.Operator.UserId,
|
||||
}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "保存整批校验失败明细失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) SaveOutcome(ctx context.Context, task *Task, row *Row, card *entity.TSimCardInfo, outcome Outcome) error {
|
||||
row.OrderNum = outcome.OrderNum
|
||||
row.UpstreamCode = outcome.Code
|
||||
row.UpstreamReason = outcome.Message
|
||||
if outcome.Success {
|
||||
row.Reason = ""
|
||||
} else if strings.TrimSpace(outcome.Message) != "" {
|
||||
row.Reason = outcome.Message
|
||||
} else {
|
||||
row.Reason = "上游处理失败"
|
||||
}
|
||||
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if outcome.Success {
|
||||
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
|
||||
Where(dao.TSimCardInfo.Columns().Id, card.Id).
|
||||
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantId).
|
||||
Where(dao.TSimCardInfo.Columns().BossCardStatus, row.StatusBefore).
|
||||
Data(do.TSimCardInfo{
|
||||
BossCardStatus: task.Config.TargetStatus,
|
||||
StatusSyncTime: gtime.Now(),
|
||||
UpdatedBy: task.Operator.UserId,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.New("SIM卡本地状态已变化")
|
||||
}
|
||||
}
|
||||
|
||||
detailStatus := consts.BatchDetailStatusFailed
|
||||
if outcome.Success {
|
||||
detailStatus = consts.BatchDetailStatusSuccess
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().Id, row.DetailID).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
|
||||
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
|
||||
Data(do.BatchDetail{
|
||||
DetailStatus: detailStatus,
|
||||
ErrorReason: row.Reason,
|
||||
Snapshot: row.Snapshot(),
|
||||
UpdatedBy: task.Operator.UserId,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.New("批次明细状态已变化")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) SaveDetailFailure(ctx context.Context, task *Task, row *Row) error {
|
||||
_, err := dao.BatchDetail.Ctx(ctx).
|
||||
Where(dao.BatchDetail.Columns().Id, row.DetailID).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
|
||||
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
|
||||
Data(do.BatchDetail{
|
||||
DetailStatus: consts.BatchDetailStatusFailed,
|
||||
ErrorReason: row.Reason,
|
||||
Snapshot: row.Snapshot(),
|
||||
UpdatedBy: task.Operator.UserId,
|
||||
}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) FinalizeBatch(ctx context.Context, task *Task) error {
|
||||
processor, err := NewProcessor(task.Operation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(task.Rows))
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
for _, row := range task.Rows {
|
||||
rows = append(rows, row)
|
||||
if row != nil && row.Reason == "" && task.ValidationOK {
|
||||
successCount++
|
||||
} else if row == nil || !batchimport.IsNotExecuted(row) {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
validCount := len(task.Rows) - failCount
|
||||
if task.ValidationOK {
|
||||
validCount = len(task.Rows)
|
||||
}
|
||||
importStatus := consts.ImportStatusSuccess
|
||||
batchStatus := consts.BatchAsyncStatusSuccess
|
||||
runResult := consts.BatchRunResultSuccess
|
||||
if failCount > 0 || !task.ValidationOK {
|
||||
importStatus = consts.ImportStatusFailed
|
||||
batchStatus = consts.BatchAsyncStatusFailed
|
||||
runResult = consts.BatchRunResultFailed
|
||||
if task.FailureReason == "" {
|
||||
task.FailureReason = fmt.Sprintf("%d张卡处理失败", failCount)
|
||||
}
|
||||
}
|
||||
|
||||
result := &batchimport.Result{
|
||||
ImportStatus: importStatus,
|
||||
BatchNo: task.Batch.BatchNo,
|
||||
BatchType: task.Batch.BatchType,
|
||||
FailReason: task.FailureReason,
|
||||
ImportFileOssId: task.ImportOssID,
|
||||
TotalRows: len(task.Rows),
|
||||
ValidRows: validCount,
|
||||
ErrorRows: failCount,
|
||||
InsertedRows: successCount,
|
||||
}
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Channel(task.Batch.SourceChannel),
|
||||
ExecutionMode: int(task.Batch.ExecutionMode),
|
||||
BizParams: &SubmitOptions{Remark: task.Remark},
|
||||
SourceType: task.Batch.SourceType,
|
||||
Operator: task.Operator,
|
||||
BatchNo: task.Batch.BatchNo,
|
||||
BatchType: task.Batch.BatchType,
|
||||
}
|
||||
importOss, err := r.loadImportOss(ctx, task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endTime := gtime.Now()
|
||||
backgroundCtx := contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{
|
||||
ID: task.Operator.UserId,
|
||||
DeptId: task.Operator.DeptId,
|
||||
TenantId: task.Operator.TenantId,
|
||||
}}})
|
||||
resultOss, downloadID, err := batchimport.SaveAsyncResultFiles(backgroundCtx, req, processor, result, rows, importOss, task.StartTime, endTime)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "生成停复机结果Excel失败")
|
||||
}
|
||||
batchInfo := processor.BuildBatchInfo(backgroundCtx, req, result, importOss, resultOss, downloadID)
|
||||
_, err = dao.BatchMain.Ctx(ctx).
|
||||
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
|
||||
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
|
||||
Data(do.BatchMain{
|
||||
BatchAsyncStatus: batchStatus,
|
||||
ValidCount: validCount,
|
||||
SuccessCount: successCount,
|
||||
FailCount: failCount,
|
||||
RunResult: runResult,
|
||||
FailReason: task.FailureReason,
|
||||
ResultOssId: lifecycleOssIDValue(resultOss.OssId),
|
||||
BatchInfo: batchInfo,
|
||||
EndTime: endTime,
|
||||
UpdatedBy: task.Operator.UserId,
|
||||
}).Update()
|
||||
return gerror.Wrap(err, "汇总停复机批次失败")
|
||||
}
|
||||
|
||||
func (r *Repository) loadImportOss(ctx context.Context, task *Task) (*model.SysOssSaveContentModel, error) {
|
||||
if task.ImportOssID == 0 {
|
||||
return &model.SysOssSaveContentModel{SysOss: &entity.SysOss{}}, nil
|
||||
}
|
||||
oss := &entity.SysOss{}
|
||||
if err := dao.SysOss.Ctx(ctx).
|
||||
Where(dao.SysOss.Columns().OssId, task.ImportOssID).
|
||||
Where(dao.SysOss.Columns().TenantId, task.Batch.TenantId).
|
||||
Scan(oss); err != nil {
|
||||
return nil, gerror.Wrap(err, "读取原始导入文件失败")
|
||||
}
|
||||
return &model.SysOssSaveContentModel{SysOss: oss}, nil
|
||||
}
|
||||
|
||||
func sourceOssIDFromBatch(batch *entity.BatchMain) int64 {
|
||||
if batch == nil {
|
||||
return 0
|
||||
}
|
||||
if batch.SourceOssId > 0 {
|
||||
return batch.SourceOssId
|
||||
}
|
||||
if batch.BatchInfo == nil {
|
||||
return 0
|
||||
}
|
||||
for _, path := range []string{"importFileOssId", "source.importFileOssId", "source.inputFileOssId"} {
|
||||
if ossID := batch.BatchInfo.Get(path).Int64(); ossID > 0 {
|
||||
return ossID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func lifecycleOssIDValue(ossID int64) any {
|
||||
if ossID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return ossID
|
||||
}
|
||||
|
||||
func (r *Repository) MarkBatchFailed(ctx context.Context, batchNo, reason string) error {
|
||||
if batchNo == "" {
|
||||
return gerror.New("批次号不能为空")
|
||||
}
|
||||
if reason == "" {
|
||||
reason = "后台执行失败"
|
||||
}
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
WhereIn(dao.BatchDetail.Columns().DetailStatus, []int{consts.BatchDetailStatusPending, consts.BatchDetailStatusRunning}).
|
||||
Data(do.BatchDetail{
|
||||
DetailStatus: consts.BatchDetailStatusFailed,
|
||||
ErrorReason: reason,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
successCount, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusSuccess).
|
||||
Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
failCount, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusFailed).
|
||||
Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
WhereIn(dao.BatchMain.Columns().BatchAsyncStatus, []int{consts.BatchAsyncStatusPending, consts.BatchAsyncStatusProcessing}).
|
||||
Data(do.BatchMain{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
|
||||
SuccessCount: successCount,
|
||||
FailCount: failCount,
|
||||
RunResult: consts.BatchRunResultFailed,
|
||||
FailReason: reason,
|
||||
EndTime: gtime.Now(),
|
||||
}).Update()
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestRepositorySaveOutcomeUpdatesOnlySuccessfulCard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{{
|
||||
Type: "sqlite",
|
||||
Link: "sqlite::@file(:memory:)",
|
||||
MaxOpenConnCount: 1,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.DB().Close(ctx)
|
||||
|
||||
statements := []string{
|
||||
`CREATE TABLE t_sim_card_info (
|
||||
id INTEGER PRIMARY KEY,
|
||||
iccid TEXT NOT NULL,
|
||||
boss_card_status INTEGER NOT NULL,
|
||||
status_sync_time DATETIME,
|
||||
tenant_id TEXT NOT NULL,
|
||||
updated_by INTEGER,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE batch_detail (
|
||||
id INTEGER PRIMARY KEY,
|
||||
batch_no TEXT NOT NULL,
|
||||
detail_status INTEGER NOT NULL,
|
||||
error_reason TEXT,
|
||||
snapshot TEXT,
|
||||
updated_by INTEGER,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`INSERT INTO t_sim_card_info(id, iccid, boss_card_status, tenant_id) VALUES
|
||||
(1, 'card-success', 2, 'tenant-1'),
|
||||
(2, 'card-failed', 2, 'tenant-1')`,
|
||||
`INSERT INTO batch_detail(id, batch_no, detail_status) VALUES
|
||||
(1, 'STP-test', 1),
|
||||
(2, 'STP-test', 1)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
config, err := OperationSuspend.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: &entity.BatchMain{BatchNo: "STP-test", TenantId: "tenant-1"},
|
||||
Config: config,
|
||||
Operator: batchimport.OperatorContext{TenantId: "tenant-1", UserId: 10},
|
||||
}
|
||||
repository := NewRepository()
|
||||
successRow := &Row{DetailID: 1, Iccid: "card-success", StatusBefore: consts.V3ApiSimBossCardStatusActivated}
|
||||
failedRow := &Row{DetailID: 2, Iccid: "card-failed", StatusBefore: consts.V3ApiSimBossCardStatusActivated}
|
||||
if err = repository.SaveOutcome(ctx, task, successRow, &entity.TSimCardInfo{Id: 1}, Outcome{Success: true, OrderNum: "order-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = repository.SaveOutcome(ctx, task, failedRow, &entity.TSimCardInfo{Id: 2}, Outcome{Code: 500, Message: "rejected"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var cards []struct {
|
||||
Iccid string `orm:"iccid"`
|
||||
BossCardStatus int `orm:"boss_card_status"`
|
||||
}
|
||||
if err = g.DB().Model("t_sim_card_info").OrderAsc("id").Scan(&cards); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 2 || cards[0].BossCardStatus != consts.V3ApiSimBossCardStatusStopped || cards[1].BossCardStatus != consts.V3ApiSimBossCardStatusActivated {
|
||||
t.Fatalf("unexpected local card statuses: %+v", cards)
|
||||
}
|
||||
|
||||
var details []struct {
|
||||
DetailStatus int `orm:"detail_status"`
|
||||
ErrorReason string `orm:"error_reason"`
|
||||
}
|
||||
if err = g.DB().Model("batch_detail").OrderAsc("id").Scan(&details); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(details) != 2 || details[0].DetailStatus != consts.BatchDetailStatusSuccess || details[1].DetailStatus != consts.BatchDetailStatusFailed || details[1].ErrorReason != "rejected" {
|
||||
t.Fatalf("unexpected detail results: %+v", details)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
type BatchExecuteMessage = common.BatchExecuteMessage
|
||||
|
||||
type SubmitResult = common.SubmitResult
|
||||
|
||||
type Submitter struct {
|
||||
operation Operation
|
||||
}
|
||||
|
||||
func NewSubmitter(operation Operation) *Submitter {
|
||||
return &Submitter{operation: operation}
|
||||
}
|
||||
|
||||
func (s *Submitter) SubmitExcel(ctx context.Context, file *ghttp.UploadFile, remark string) (*SubmitResult, error) {
|
||||
processor, err := NewProcessor(s.operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
File: file,
|
||||
BizParams: &common.SubmitOptions{Remark: strings.TrimSpace(remark)},
|
||||
SourceType: consts.BatchSourceTypeFile,
|
||||
Operator: common.CurrentOperator(ctx),
|
||||
}
|
||||
result, err := batchimport.CreatePendingUpload(ctx, req, processor, batchimport.PendingOptions{
|
||||
PrepareRows: func(ctx context.Context, rows []batchimport.Row) error {
|
||||
return common.PrepareAuthorizedRows(ctx, rows, func(item batchimport.Row) (*Row, error) {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return nil, gerror.New("停复机批次行类型错误")
|
||||
}
|
||||
return row, nil
|
||||
}, "停复机批次行类型错误")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, "lifecycle")
|
||||
return common.PendingSubmitResult(result, consts.BatchAsyncStatusPending), nil
|
||||
}
|
||||
|
||||
func (s *Submitter) SubmitInput(ctx context.Context, input, remark string) (*SubmitResult, error) {
|
||||
rows, err := ParseInputRows(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
processor, err := NewProcessor(s.operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
BizParams: &common.SubmitOptions{Remark: strings.TrimSpace(remark)},
|
||||
SourceType: consts.BatchSourceTypeBatchInput,
|
||||
Operator: common.CurrentOperator(ctx),
|
||||
}
|
||||
result, err := batchimport.CreatePendingRows(ctx, req, processor, rows, batchimport.PendingOptions{
|
||||
PrepareRows: func(ctx context.Context, rows []batchimport.Row) error {
|
||||
return common.PrepareAuthorizedRows(ctx, rows, func(item batchimport.Row) (*Row, error) {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return nil, gerror.New("停复机批次行类型错误")
|
||||
}
|
||||
return row, nil
|
||||
}, "停复机批次行类型错误")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, "lifecycle")
|
||||
return common.PendingSubmitResult(result, consts.BatchAsyncStatusPending), nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type Row struct {
|
||||
DetailID int64
|
||||
RowNo int
|
||||
Iccid string
|
||||
Msisdn string
|
||||
Imsi string
|
||||
Authorized bool
|
||||
ChanKey string
|
||||
StatusBefore int
|
||||
OrderNum string
|
||||
UpstreamCode int
|
||||
UpstreamReason string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (r *Row) GetRowNo() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
return r.RowNo
|
||||
}
|
||||
|
||||
func (r *Row) GetItemID() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.Iccid
|
||||
}
|
||||
|
||||
func (r *Row) GetErrorReasons() []string {
|
||||
if r == nil || r.Reason == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{r.Reason}
|
||||
}
|
||||
|
||||
func (r *Row) AddErrorReason(reason string) {
|
||||
if r == nil || r.Reason != "" || reason == "" {
|
||||
return
|
||||
}
|
||||
r.Reason = reason
|
||||
}
|
||||
|
||||
func (r *Row) GetIccid() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.Iccid
|
||||
}
|
||||
|
||||
func (r *Row) SetAuthorized(v bool) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.Authorized = v
|
||||
}
|
||||
|
||||
func (r *Row) Snapshot() *gjson.Json {
|
||||
if r == nil {
|
||||
return gjson.New(g.Map{})
|
||||
}
|
||||
return gjson.New(g.Map{
|
||||
"ICCID": r.Iccid,
|
||||
"MSISDN": r.Msisdn,
|
||||
"IMSI": r.Imsi,
|
||||
"authorized": r.Authorized,
|
||||
"chanKey": r.ChanKey,
|
||||
"statusBefore": r.StatusBefore,
|
||||
"orderNum": r.OrderNum,
|
||||
"upstreamCode": r.UpstreamCode,
|
||||
"upstreamMessage": r.UpstreamReason,
|
||||
})
|
||||
}
|
||||
|
||||
type SubmitOptions = common.SubmitOptions
|
||||
|
||||
type Task struct {
|
||||
Batch *entity.BatchMain
|
||||
Operation Operation
|
||||
Config OperationConfig
|
||||
Rows []*Row
|
||||
Operator batchimport.OperatorContext
|
||||
ImportOssID int64
|
||||
Remark string
|
||||
StartTime *gtime.Time
|
||||
ValidationOK bool
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
type Outcome struct {
|
||||
Success bool
|
||||
Code int
|
||||
Message string
|
||||
OrderNum string
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/packagecustomer"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type AsyncMessage struct {
|
||||
BatchNo string `json:"batchNo"`
|
||||
}
|
||||
|
||||
var errOutboundValidationRejected = errors.New("存在不满足出库条件的卡,本次未执行")
|
||||
|
||||
const outboundNotExecutedReason = "校验通过,但本批次存在失败明细,整批未执行出库"
|
||||
|
||||
func SubmitExcelAsync(ctx context.Context, file *ghttp.UploadFile, in *model.TSimCardOutboundExcelSubmitParam) (*batchimport.Result, error) {
|
||||
operator := batchimport.OperatorContext{TenantId: contexts.GetTenantId(ctx), DeptId: contexts.GetDeptId(ctx), UserId: contexts.GetUserId(ctx)}
|
||||
processor := NewProcessor(PreviewOptions{DeptId: operator.DeptId})
|
||||
result, err := batchimport.CreatePendingUpload(ctx, batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
File: file, BizParams: in, SourceType: consts.BatchSourceTypeFile, Operator: operator,
|
||||
}, processor, batchimport.PendingOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, publishAsync(ctx, result.BatchNo)
|
||||
}
|
||||
|
||||
func SubmitRowsAsync(ctx context.Context, in any, rows []batchimport.Row, sourceType int, sourceBatchNo string, strict bool) (*batchimport.Result, error) {
|
||||
operator := batchimport.OperatorContext{TenantId: contexts.GetTenantId(ctx), DeptId: contexts.GetDeptId(ctx), UserId: contexts.GetUserId(ctx)}
|
||||
processor := NewProcessor(PreviewOptions{DeptId: operator.DeptId, StrictStockIdentity: strict})
|
||||
result, err := batchimport.CreatePendingRows(ctx, batchimport.SubmitRequest{
|
||||
SourceChannel: batchmetadata.Console,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
BizParams: in, SourceType: sourceType, SourceBatchNo: sourceBatchNo, Operator: operator,
|
||||
}, processor, rows, batchimport.PendingOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, publishAsync(ctx, result.BatchNo)
|
||||
}
|
||||
|
||||
func publishAsync(ctx context.Context, batchNo string) error {
|
||||
if strings.TrimSpace(batchNo) == "" {
|
||||
return gerror.New("出库批次号不能为空")
|
||||
}
|
||||
if !rabbitmq.Enabled(ctx) {
|
||||
err := gerror.New("RabbitMQ未启用,无法提交异步出库任务")
|
||||
if markErr := markAsyncFailed(ctx, batchNo, err); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := rabbitmq.Publish(ctx, consts.QueueBatchOutboundExecute, AsyncMessage{BatchNo: batchNo}); err != nil {
|
||||
publishErr := gerror.Wrap(err, "发布异步出库任务失败")
|
||||
if markErr := markAsyncFailed(ctx, batchNo, publishErr); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return publishErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ConsumeAsync(ctx context.Context) error {
|
||||
return rabbitmq.ConsumeJSON[AsyncMessage](ctx, consts.QueueBatchOutboundExecute, "iotlcm-admin-outbound", handleAsyncMessage)
|
||||
}
|
||||
|
||||
func handleAsyncMessage(ctx context.Context, message AsyncMessage) error {
|
||||
if strings.TrimSpace(message.BatchNo) == "" {
|
||||
return nil
|
||||
}
|
||||
main := &struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
BatchAsyncStatus int
|
||||
BatchInfo *gjson.Json
|
||||
SourceBatchNo string
|
||||
TenantId string
|
||||
CreatedDept int64
|
||||
CreatedBy int64
|
||||
TotalCount int
|
||||
}{}
|
||||
if err := dao.BatchMain.Ctx(ctx).Where(dao.BatchMain.Columns().BatchNo, message.BatchNo).Scan(main); err != nil {
|
||||
return err
|
||||
}
|
||||
if main.BatchNo == "" || main.BatchType != consts.BatchTypeOutbound {
|
||||
return nil
|
||||
}
|
||||
strictStockIdentity := false
|
||||
if main.BatchInfo != nil {
|
||||
strictStockIdentity = main.BatchInfo.Get("strictStockIdentity").Bool()
|
||||
}
|
||||
processor := NewProcessor(PreviewOptions{DeptId: main.CreatedDept, StrictStockIdentity: strictStockIdentity})
|
||||
param, err := submitParamFromInfo(main.BatchInfo)
|
||||
if err != nil {
|
||||
return markAsyncFailed(ctx, message.BatchNo, err)
|
||||
}
|
||||
rows, err := loadAsyncRows(ctx, message.BatchNo)
|
||||
if err != nil {
|
||||
return markAsyncFailed(ctx, message.BatchNo, err)
|
||||
}
|
||||
operator := batchimport.OperatorContext{TenantId: main.TenantId, DeptId: main.CreatedDept, UserId: main.CreatedBy}
|
||||
requiredSource := ""
|
||||
if main.BatchInfo != nil {
|
||||
requiredSource = strings.TrimSpace(main.BatchInfo.Get("inboundBatchNo").String())
|
||||
}
|
||||
var (
|
||||
claimed bool
|
||||
output *CompleteOutput
|
||||
validationRejected bool
|
||||
)
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
claimResult, claimErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, message.BatchNo).
|
||||
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusPending).
|
||||
Data(do.BatchMain{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
RunResult: consts.BatchRunResultRunning,
|
||||
StartTime: gtime.Now(),
|
||||
UpdatedBy: main.CreatedBy,
|
||||
}).
|
||||
Update()
|
||||
if claimErr != nil {
|
||||
return claimErr
|
||||
}
|
||||
claimCount, claimErr := claimResult.RowsAffected()
|
||||
if claimErr != nil {
|
||||
return gerror.Wrap(claimErr, "读取异步出库任务领取结果失败")
|
||||
}
|
||||
if claimCount != 1 {
|
||||
return nil
|
||||
}
|
||||
claimed = true
|
||||
if _, updateErr := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, message.BatchNo).
|
||||
Data(do.BatchDetail{DetailStatus: consts.BatchDetailStatusRunning, UpdatedBy: main.CreatedBy}).
|
||||
Update(); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
var executeErr error
|
||||
output, executeErr = processor.ExecutePending(ctx, tx, message.BatchNo, param, rows, operator, requiredSource)
|
||||
if executeErr != nil {
|
||||
validationRejected = errors.Is(executeErr, errOutboundValidationRejected)
|
||||
return executeErr
|
||||
}
|
||||
if _, updateErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where(dao.BatchMain.Columns().BatchNo, message.BatchNo).Data(do.BatchMain{
|
||||
OrderSn: output.OrderSn, BatchAsyncStatus: consts.BatchAsyncStatusSuccess, RunResult: consts.BatchRunResultSuccess,
|
||||
SuccessCount: output.Quantity, ValidCount: output.Quantity, FailCount: 0, EndTime: gtime.Now(), UpdatedBy: main.CreatedBy,
|
||||
}).Update(); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
if _, updateErr := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).Where(dao.BatchDetail.Columns().BatchNo, message.BatchNo).Data(do.BatchDetail{
|
||||
DetailStatus: consts.BatchDetailStatusSuccess, ErrorCode: "", ErrorReason: "", UpdatedBy: main.CreatedBy,
|
||||
}).Update(); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if validationRejected {
|
||||
return markAsyncValidationFailed(ctx, message.BatchNo, main.TenantId, rows, err)
|
||||
}
|
||||
return markAsyncFailed(ctx, message.BatchNo, err)
|
||||
}
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
if output != nil && output.Customer != nil && output.Customer.DeptId > 0 {
|
||||
packagecustomer.PublishSyncSafe(ctx, model.PackageCustomerSyncMessage{
|
||||
TenantId: operator.TenantId,
|
||||
DeptId: output.Customer.DeptId,
|
||||
UserId: operator.UserId,
|
||||
Trigger: "outbound",
|
||||
SourceBatchNo: output.BatchNo,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markAsyncValidationFailed(ctx context.Context, batchNo, tenantID string, rows []*Row, cause error) error {
|
||||
validCount, failCount := 0, 0
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
if row.Reason == outboundNotExecutedReason {
|
||||
validCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
status := consts.BatchDetailStatusFailed
|
||||
if row.Reason == outboundNotExecutedReason {
|
||||
status = consts.BatchDetailStatusNotExecuted
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().TenantId, tenantID).
|
||||
Where(dao.BatchDetail.Columns().RowNo, row.RowNo).
|
||||
Data(do.BatchDetail{DetailStatus: status, ErrorReason: row.Reason}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "保存出库校验明细失败")
|
||||
}
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchMain.Columns().TenantId, tenantID).
|
||||
Data(do.BatchMain{BatchAsyncStatus: consts.BatchAsyncStatusFailed, RunResult: consts.BatchRunResultFailed, ValidCount: validCount, SuccessCount: 0, FailCount: failCount, FailReason: asyncFailureReason(cause), EndTime: gtime.Now()}).Update()
|
||||
return gerror.Wrap(err, "保存出库校验结果失败")
|
||||
})
|
||||
}
|
||||
|
||||
func markAsyncFailed(ctx context.Context, batchNo string, err error) error {
|
||||
reason := asyncFailureReason(err)
|
||||
if _, updateErr := g.DB().Model(dao.BatchMain.Table()).Ctx(ctx).Where(dao.BatchMain.Columns().BatchNo, batchNo).Data(do.BatchMain{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
|
||||
RunResult: consts.BatchRunResultFailed,
|
||||
ValidCount: 0,
|
||||
SuccessCount: 0,
|
||||
FailCount: gdb.Raw(dao.BatchMain.Columns().TotalCount),
|
||||
FailReason: reason,
|
||||
EndTime: gtime.Now(),
|
||||
}).Update(); updateErr != nil {
|
||||
return gerror.Wrap(updateErr, reason)
|
||||
}
|
||||
if _, updateErr := g.DB().Model(dao.BatchDetail.Table()).Ctx(ctx).Where(dao.BatchDetail.Columns().BatchNo, batchNo).Data(do.BatchDetail{
|
||||
DetailStatus: consts.BatchDetailStatusFailed,
|
||||
ErrorReason: reason,
|
||||
}).Update(); updateErr != nil {
|
||||
return gerror.Wrap(updateErr, reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadAsyncRows(ctx context.Context, batchNo string) ([]*Row, error) {
|
||||
var details []*struct {
|
||||
RowNo int
|
||||
ItemId string
|
||||
Snapshot *gjson.Json
|
||||
}
|
||||
if err := dao.BatchDetail.Ctx(ctx).Fields(dao.BatchDetail.Columns().RowNo, dao.BatchDetail.Columns().ItemId, dao.BatchDetail.Columns().Snapshot).Where(dao.BatchDetail.Columns().BatchNo, batchNo).OrderAsc(dao.BatchDetail.Columns().RowNo).Scan(&details); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]*Row, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
row := &Row{RowNo: detail.RowNo, Iccid: strings.TrimSpace(detail.ItemId)}
|
||||
if detail.Snapshot != nil {
|
||||
row.Msisdn = detail.Snapshot.Get("MSISDN").String()
|
||||
row.Imsi = detail.Snapshot.Get("IMSI").String()
|
||||
row.SourceBatchNo = detail.Snapshot.Get("batchNo").String()
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, gerror.New("出库批次没有明细")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func submitParamFromInfo(info *gjson.Json) (*model.TSimCardOutboundSubmitParam, error) {
|
||||
if info == nil {
|
||||
return nil, gerror.New("出库批次参数不存在")
|
||||
}
|
||||
param := &model.TSimCardOutboundSubmitParam{}
|
||||
if err := info.Scan(param); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if param.CustomerAgentId <= 0 || strings.TrimSpace(param.PackageCode) == "" || param.PackageBeginTime == nil {
|
||||
return nil, gerror.New("出库批次参数不完整")
|
||||
}
|
||||
return param, nil
|
||||
}
|
||||
|
||||
const maxAsyncFailureReasonLength = 512
|
||||
|
||||
func asyncFailureReason(err error) string {
|
||||
if err == nil {
|
||||
return "异步出库失败"
|
||||
}
|
||||
reason := strings.TrimSpace(err.Error())
|
||||
if reason == "" {
|
||||
return "异步出库失败"
|
||||
}
|
||||
if strings.Contains(reason, "Prepared statement contains too many placeholders") {
|
||||
return "新增正式卡失败:单次批量写入参数数量超过数据库限制"
|
||||
}
|
||||
if strings.Contains(reason, "Data too long for column 'error_reason'") {
|
||||
return "异步出库失败:失败原因超过数据库字段长度限制"
|
||||
}
|
||||
runes := []rune(reason)
|
||||
if len(runes) > maxAsyncFailureReasonLength {
|
||||
return string(runes[:maxAsyncFailureReasonLength])
|
||||
}
|
||||
return reason
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestValidateOutboundRowCollectsIndependentFailures(t *testing.T) {
|
||||
row := &Row{Iccid: "card", Msisdn: "wrong", Imsi: "wrong"}
|
||||
stock := &entity.TSimCardStock{CreatedDept: 200, BatchNo: "other", Msisdn: "13800008000", Imsi: "460001234567890"}
|
||||
reason := validateOutboundRow(row, stock, true, batchimport.OperatorContext{DeptId: 100}, true, "required")
|
||||
for _, want := range []string{"不属于当前机构库存", "不属于指定库存批次", "已存在正式卡记录", "MSISDN与库存记录不一致", "IMSI与库存记录不一致"} {
|
||||
if !strings.Contains(reason, want) {
|
||||
t.Fatalf("missing %q in %q", want, reason)
|
||||
}
|
||||
}
|
||||
rows := []*Row{{Reason: reason}, {}, {Reason: "ICCID不存在库存记录,不能出库"}}
|
||||
markRemainingRowsSkipped(rows)
|
||||
if rows[0].Reason != reason || rows[1].Reason != outboundNotExecutedReason || rows[2].Reason != "ICCID不存在库存记录,不能出库" {
|
||||
t.Fatalf("per-card validation results were overwritten: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundChunkSizesSupportFiftyThousandCards(t *testing.T) {
|
||||
const totalCards = 50000
|
||||
if outboundDatabaseInsertChunkSize <= 0 || outboundDatabaseInsertChunkSize > 500 {
|
||||
t.Fatalf("outboundDatabaseInsertChunkSize = %d, want 1..500", outboundDatabaseInsertChunkSize)
|
||||
}
|
||||
if chunks := (totalCards + outboundDatabaseInsertChunkSize - 1) / outboundDatabaseInsertChunkSize; chunks != 100 {
|
||||
t.Fatalf("card insert chunks = %d, want 100", chunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncFailureReasonSummarizesPlaceholderOverflow(t *testing.T) {
|
||||
reason := asyncFailureReason(errors.New("INSERT INTO t_sim_card_info (...) Error 1390: Prepared statement contains too many placeholders"))
|
||||
if reason != "新增正式卡失败:单次批量写入参数数量超过数据库限制" {
|
||||
t.Fatalf("unexpected reason: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncFailureReasonFitsBatchDetailColumn(t *testing.T) {
|
||||
reason := asyncFailureReason(errors.New(strings.Repeat("失败原因", 300)))
|
||||
if len([]rune(reason)) > maxAsyncFailureReasonLength {
|
||||
t.Fatalf("reason length = %d, want <= %d", len([]rune(reason)), maxAsyncFailureReasonLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOutboundIdentifiersReportsEveryDuplicate(t *testing.T) {
|
||||
rows := []*Row{
|
||||
{RowNo: 1, Iccid: " 8986000000000000001 "},
|
||||
{RowNo: 2, Iccid: "8986000000000000001"},
|
||||
{RowNo: 3, Iccid: ""},
|
||||
{RowNo: 4, Iccid: "8986000000000000002"},
|
||||
}
|
||||
iccids, err := validateOutboundIdentifiers(rows)
|
||||
if err != nil || len(iccids) != 1 || iccids[0] != rows[3].Iccid {
|
||||
t.Fatalf("valid identifiers = %v, err = %v", iccids, err)
|
||||
}
|
||||
for index := 0; index < 3; index++ {
|
||||
if rows[index].Reason == "" {
|
||||
t.Fatalf("row %d lost its validation reason", index+1)
|
||||
}
|
||||
}
|
||||
markRemainingRowsSkipped(rows)
|
||||
if rows[3].Reason != outboundNotExecutedReason {
|
||||
t.Fatalf("valid row reason = %q", rows[3].Reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/cardpackage"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
const (
|
||||
outboundPaymentModeOffline = "offline"
|
||||
outboundPaymentModeBalance = "balance"
|
||||
outboundDetailUpdateChunkSize = 200
|
||||
|
||||
outboundFinanceIncomeExpenseExpense = 1
|
||||
outboundFinanceTransactionTypeConsume = 1
|
||||
outboundFinanceTransactionChannelBalance = 3
|
||||
outboundFinanceTransactionStatusPaid = 1
|
||||
outboundFinanceAccountStatusNormal = 0
|
||||
)
|
||||
|
||||
func (p *Processor) Complete(ctx context.Context, tx gdb.TX, in *CompleteInput) (*CompleteOutput, error) {
|
||||
if in == nil || in.SubmitParam == nil {
|
||||
return nil, gerror.New("出库参数不能为空")
|
||||
}
|
||||
if in.BatchNo == "" {
|
||||
return nil, gerror.New("出库批次号不能为空")
|
||||
}
|
||||
if len(in.Rows) == 0 {
|
||||
return nil, gerror.New("出库卡不能为空")
|
||||
}
|
||||
normalizeSubmitParam(in.SubmitParam)
|
||||
if err := validateSubmitParam(in.SubmitParam); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iccids, err := strictRowsToIccids(in.Rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stockByIccid, err := p.repo.LockStockCardsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "锁定库存卡失败")
|
||||
}
|
||||
if len(stockByIccid) != len(iccids) {
|
||||
return nil, gerror.New("部分库存卡已变化,请刷新后重试")
|
||||
}
|
||||
|
||||
for _, iccid := range iccids {
|
||||
stock := stockByIccid[iccid]
|
||||
if stock == nil {
|
||||
return nil, gerror.Newf("ICCID不存在库存记录:%s", iccid)
|
||||
}
|
||||
if stock.CreatedDept != in.Operator.DeptId {
|
||||
return nil, gerror.Newf("ICCID不属于当前机构库存:%s", iccid)
|
||||
}
|
||||
if in.RequiredSourceBatchNo != "" && strings.TrimSpace(stock.BatchNo) != in.RequiredSourceBatchNo {
|
||||
return nil, gerror.Newf("ICCID 不属于指定批次:%s", iccid)
|
||||
}
|
||||
}
|
||||
if in.StrictStockIdentity {
|
||||
for _, row := range in.Rows {
|
||||
stock := stockByIccid[row.Iccid]
|
||||
if stock == nil {
|
||||
return nil, gerror.Newf("ICCID不存在库存记录:%s", row.Iccid)
|
||||
}
|
||||
if strings.TrimSpace(row.Msisdn) != strings.TrimSpace(stock.Msisdn) {
|
||||
return nil, gerror.Newf("MSISDN与库存记录不一致:%s", row.Iccid)
|
||||
}
|
||||
if strings.TrimSpace(row.Imsi) != strings.TrimSpace(stock.Imsi) {
|
||||
return nil, gerror.Newf("IMSI与库存记录不一致:%s", row.Iccid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formalByIccid, err := p.repo.GetExistingFormalIccids(ctx, iccids)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询正式卡失败")
|
||||
}
|
||||
for _, iccid := range iccids {
|
||||
if formalByIccid[iccid] {
|
||||
return nil, gerror.Newf("ICCID已存在正式卡记录:%s", iccid)
|
||||
}
|
||||
}
|
||||
|
||||
quantity := len(iccids)
|
||||
subscribeCards := make([]cardpackage.SubscribeCard, 0, quantity)
|
||||
cardRows := make([]do.TSimCardInfo, 0, quantity)
|
||||
detailUpdates := make(map[string]*gjsonJsonHolder, quantity)
|
||||
sourceBatchNos := make([]string, 0, quantity)
|
||||
sourceBatchSet := make(map[string]struct{}, quantity)
|
||||
|
||||
for _, row := range in.Rows {
|
||||
stock := stockByIccid[row.Iccid]
|
||||
if stock == nil {
|
||||
return nil, gerror.Newf("ICCID不存在库存记录:%s", row.Iccid)
|
||||
}
|
||||
if strings.TrimSpace(stock.Iccid) != strings.TrimSpace(row.Iccid) {
|
||||
return nil, gerror.Newf("库存卡映射异常:第%d行%s匹配到%s", row.RowNo, row.Iccid, stock.Iccid)
|
||||
}
|
||||
row.SourceBatchNo = strings.TrimSpace(stock.BatchNo)
|
||||
row.Msisdn = chooseString(row.Msisdn, stock.Msisdn)
|
||||
row.Imsi = chooseString(row.Imsi, stock.Imsi)
|
||||
if row.SourceBatchNo != "" {
|
||||
if _, ok := sourceBatchSet[row.SourceBatchNo]; !ok {
|
||||
sourceBatchSet[row.SourceBatchNo] = struct{}{}
|
||||
sourceBatchNos = append(sourceBatchNos, row.SourceBatchNo)
|
||||
}
|
||||
}
|
||||
subscribeCards = append(subscribeCards, cardpackage.SubscribeCard{
|
||||
Iccid: stock.Iccid,
|
||||
Msisdn: stock.Msisdn,
|
||||
Imsi: stock.Imsi,
|
||||
})
|
||||
}
|
||||
inboundBatchInfos, err := p.repo.GetInboundBatchInfos(ctx, tx, sourceBatchNos)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询入库批次信息失败")
|
||||
}
|
||||
payment, err := prepareOutboundPayment(ctx, tx, in.BatchNo, subscriptionAmount(in.SubmitParam), in.Operator, in.SubmitParam)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
packageSvc := cardpackage.NewService()
|
||||
subscription, err := packageSvc.CreatePackageSubscription(ctx, tx, &cardpackage.CreatePackageSubscriptionInput{
|
||||
BatchNo: in.BatchNo,
|
||||
Cards: subscribeCards,
|
||||
CustomerAgentId: in.SubmitParam.CustomerAgentId,
|
||||
PackageCode: strings.TrimSpace(in.SubmitParam.PackageCode),
|
||||
PackageBeginTime: in.SubmitParam.PackageBeginTime,
|
||||
DurationMonths: in.SubmitParam.DurationMonths,
|
||||
GiftDurationMonths: in.SubmitParam.GiftDurationMonths,
|
||||
DurationDisplayValue: displayDurationValue(in.SubmitParam.DurationYears, in.SubmitParam.DurationMonths),
|
||||
GiftDurationDisplayValue: displayDurationValue(in.SubmitParam.GiftDurationYears, in.SubmitParam.GiftDurationMonths),
|
||||
DurationDisplayLabel: displayDurationLabel(in.SubmitParam.DurationYears, in.SubmitParam.DurationMonthPart, in.SubmitParam.DurationMonths),
|
||||
GiftDurationDisplayLabel: displayDurationLabel(in.SubmitParam.GiftDurationYears, in.SubmitParam.GiftDurationMonthPart, in.SubmitParam.GiftDurationMonths),
|
||||
DurationUnit: consts.RenewDurationUnitYear,
|
||||
EndAtPreviousMonthEnd: true,
|
||||
Amount: in.SubmitParam.Amount,
|
||||
PayType: payment.PayType,
|
||||
PayStatus: payment.PayStatus,
|
||||
BalanceAmount: payment.BalanceAmount,
|
||||
OfflinePaidAmount: payment.OfflinePaidAmount,
|
||||
PayTime: payment.PaymentTime,
|
||||
OfflineContractCompleted: in.SubmitParam.OfflineContractCompleted,
|
||||
BuyerNote: normalizedRemark(in.SubmitParam),
|
||||
AdminNote: normalizedRemark(in.SubmitParam),
|
||||
BindRemark: in.BatchNo,
|
||||
SkipBindInsert: true,
|
||||
Operator: in.Operator,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(sourceBatchNos)
|
||||
if err = updateBatchMainAfterComplete(
|
||||
ctx,
|
||||
tx,
|
||||
in,
|
||||
subscription.OrderSn,
|
||||
subscription.Customer,
|
||||
subscription.Package,
|
||||
subscription.Amount,
|
||||
subscription.PackageBeginAt,
|
||||
subscription.PackageEndAt,
|
||||
sourceBatchNos,
|
||||
subscription.PackageStatus,
|
||||
subscription.EffectiveStatus,
|
||||
payment,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := subscription.SubscribeTime
|
||||
for _, row := range in.Rows {
|
||||
stock := stockByIccid[row.Iccid]
|
||||
bind := subscription.BindsByIccid[row.Iccid]
|
||||
if bind == nil {
|
||||
return nil, gerror.Newf("ICCID订购绑定生成失败:%s", row.Iccid)
|
||||
}
|
||||
cardType := inboundCardTypeValue(inboundBatchInfos[stock.BatchNo])
|
||||
cardRows = append(cardRows, do.TSimCardInfo{
|
||||
Iccid: stock.Iccid,
|
||||
Msisdn: stock.Msisdn,
|
||||
Imsi: stock.Imsi,
|
||||
Imei: stock.Imei,
|
||||
DeptId: in.SubmitParam.CustomerAgentId,
|
||||
CurrentPackageCode: subscription.Package.PackageCode,
|
||||
PackageStatus: subscription.PackageStatus,
|
||||
PackageTotalFlowMb: packageTotalFlowMB(subscription.Package),
|
||||
BossCardStatus: consts.V3ApiSimBossCardStatusPendingActivate,
|
||||
CardType: cardType,
|
||||
InboundBatchNo: stock.BatchNo,
|
||||
OutboundBatchNo: in.BatchNo,
|
||||
OutStockTime: now,
|
||||
FlowPkgBeginAt: subscription.PackageBeginAt,
|
||||
FlowPkgEndAt: subscription.PackageEndAt,
|
||||
ChanKey: stock.ChanKey,
|
||||
TenantId: in.Operator.TenantId,
|
||||
CreatedDept: in.Operator.DeptId,
|
||||
CreatedBy: in.Operator.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
|
||||
detailUpdates[row.Iccid] = &gjsonJsonHolder{Data: buildDetailSnapshot(row, stock, cardType, subscription.Customer, subscription.Package, in, subscription.OrderSn, bind.SubscriptionNo, subscription.Amount, subscription.PackageBeginAt, subscription.PackageEndAt)}
|
||||
}
|
||||
|
||||
for start := 0; start < len(cardRows); start += outboundDatabaseInsertChunkSize {
|
||||
end := start + outboundDatabaseInsertChunkSize
|
||||
if end > len(cardRows) {
|
||||
end = len(cardRows)
|
||||
}
|
||||
if _, err = tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).Data(cardRows[start:end]).Insert(); err != nil {
|
||||
return nil, gerror.Wrap(err, "新增正式卡失败")
|
||||
}
|
||||
}
|
||||
if err = packageSvc.InsertCardPackageBinds(ctx, tx, subscription.BindRows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = updateBatchDetailSnapshots(ctx, tx, in.BatchNo, detailUpdates, in.Operator.UserId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var affected int64
|
||||
for start := 0; start < len(iccids); start += outboundDatabaseChunkSize {
|
||||
end := start + outboundDatabaseChunkSize
|
||||
if end > len(iccids) {
|
||||
end = len(iccids)
|
||||
}
|
||||
result, deleteErr := tx.Ctx(ctx).Model(dao.TSimCardStock.Table()).
|
||||
WhereIn(dao.TSimCardStock.Columns().Iccid, iccids[start:end]).
|
||||
Where(dao.TSimCardStock.Columns().CreatedDept, in.Operator.DeptId).
|
||||
Delete()
|
||||
if deleteErr != nil {
|
||||
return nil, gerror.Wrap(deleteErr, "删除库存卡失败")
|
||||
}
|
||||
chunkAffected, rowsErr := result.RowsAffected()
|
||||
if rowsErr != nil {
|
||||
return nil, gerror.Wrap(rowsErr, "读取库存删除数量失败")
|
||||
}
|
||||
affected += chunkAffected
|
||||
}
|
||||
if int(affected) != quantity {
|
||||
return nil, gerror.New("部分库存卡已变化,请刷新后重试")
|
||||
}
|
||||
|
||||
return &CompleteOutput{
|
||||
OrderId: subscription.OrderId,
|
||||
OrderSn: subscription.OrderSn,
|
||||
BatchNo: in.BatchNo,
|
||||
BatchType: consts.BatchTypeOutbound,
|
||||
Quantity: quantity,
|
||||
SourceBatchNos: sourceBatchNos,
|
||||
Customer: subscription.Customer,
|
||||
Package: subscription.Package,
|
||||
PackageBeginAt: subscription.PackageBeginAt,
|
||||
PackageEndAt: subscription.PackageEndAt,
|
||||
PackageStatus: subscription.PackageStatus,
|
||||
EffectiveStatus: subscription.EffectiveStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type gjsonJsonHolder struct {
|
||||
Data any
|
||||
}
|
||||
|
||||
func validateSubmitParam(in *model.TSimCardOutboundSubmitParam) error {
|
||||
normalizeSubmitParam(in)
|
||||
if in.CustomerAgentId <= 0 {
|
||||
return gerror.New("客户不能为空")
|
||||
}
|
||||
if strings.TrimSpace(in.PackageCode) == "" {
|
||||
return gerror.New("套餐不能为空")
|
||||
}
|
||||
if in.PackageBeginTime == nil {
|
||||
return gerror.New("套餐开始时间不能为空")
|
||||
}
|
||||
if in.DurationMonths <= 0 {
|
||||
return gerror.New("套餐时长必须大于0")
|
||||
}
|
||||
if in.DurationMonthPart < 0 || in.DurationMonthPart > 11 {
|
||||
return gerror.New("套餐购买月份必须在0到11之间")
|
||||
}
|
||||
if in.GiftDurationMonthPart < 0 || in.GiftDurationMonthPart > 11 {
|
||||
return gerror.New("赠送月份必须在0到11之间")
|
||||
}
|
||||
if in.GiftDurationMonths < 0 {
|
||||
return gerror.New("赠送时长不能小于0")
|
||||
}
|
||||
amount, err := parseOutboundAmount(in.Amount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if amount <= 0 {
|
||||
return gerror.New("订单金额必须大于0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rowsToIccids(rows []*Row) []string {
|
||||
iccids := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(row.Iccid)
|
||||
if iccid != "" {
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
}
|
||||
return uniqueStrings(iccids)
|
||||
}
|
||||
|
||||
func strictRowsToIccids(rows []*Row) ([]string, error) {
|
||||
seen := make(map[string]int, len(rows))
|
||||
iccids := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
return nil, gerror.New("出库行不能为空")
|
||||
}
|
||||
iccid := strings.TrimSpace(row.Iccid)
|
||||
if iccid == "" {
|
||||
return nil, gerror.Newf("第%d行ICCID为空,不能出库", row.RowNo)
|
||||
}
|
||||
if firstRowNo, ok := seen[iccid]; ok {
|
||||
return nil, gerror.Newf("第%d行ICCID与第%d行重复:%s", row.RowNo, firstRowNo, iccid)
|
||||
}
|
||||
seen[iccid] = row.RowNo
|
||||
row.Iccid = iccid
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
return iccids, nil
|
||||
}
|
||||
|
||||
func updateBatchMainAfterComplete(
|
||||
ctx context.Context,
|
||||
tx gdb.TX,
|
||||
in *CompleteInput,
|
||||
orderSn string,
|
||||
customer *entity.SysDept,
|
||||
pkg *entity.TSimFlowPackageInfo,
|
||||
amount float64,
|
||||
beginAt, endAt *gtime.Time,
|
||||
sourceBatchNos []string,
|
||||
packageStatus, effectiveStatus int,
|
||||
payment *outboundPaymentSnapshot,
|
||||
) error {
|
||||
current := &struct {
|
||||
BatchInfo *gjson.Json
|
||||
}{}
|
||||
if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Fields(dao.BatchMain.Columns().BatchInfo).
|
||||
Where(dao.BatchMain.Columns().BatchNo, in.BatchNo).
|
||||
Scan(current); err != nil {
|
||||
return gerror.Wrap(err, "读取出库批次信息失败")
|
||||
}
|
||||
info := g.Map{}
|
||||
if current.BatchInfo != nil {
|
||||
for key, value := range current.BatchInfo.Map() {
|
||||
info[key] = value
|
||||
}
|
||||
}
|
||||
completedInfo := g.Map{
|
||||
"orderSn": orderSn,
|
||||
"customerAgentId": in.SubmitParam.CustomerAgentId,
|
||||
"customerName": customer.DeptName,
|
||||
"packageCode": pkg.PackageCode,
|
||||
"packageName": pkg.PackageName,
|
||||
"packageType": pkg.PackageType,
|
||||
"packageBeginTime": formatTime(beginAt),
|
||||
"packageEndTime": formatTime(endAt),
|
||||
"durationYears": in.SubmitParam.DurationYears,
|
||||
"durationMonthPart": in.SubmitParam.DurationMonthPart,
|
||||
"durationMonths": in.SubmitParam.DurationMonths,
|
||||
"giftDurationYears": in.SubmitParam.GiftDurationYears,
|
||||
"giftDurationMonthPart": in.SubmitParam.GiftDurationMonthPart,
|
||||
"giftDurationMonths": in.SubmitParam.GiftDurationMonths,
|
||||
"durationLabel": displayDurationLabel(in.SubmitParam.DurationYears, in.SubmitParam.DurationMonthPart, in.SubmitParam.DurationMonths),
|
||||
"giftDurationLabel": displayDurationLabel(in.SubmitParam.GiftDurationYears, in.SubmitParam.GiftDurationMonthPart, in.SubmitParam.GiftDurationMonths),
|
||||
"amount": amount,
|
||||
"quantity": len(in.Rows),
|
||||
"offlineContractCompleted": in.SubmitParam.OfflineContractCompleted,
|
||||
"remark": normalizedRemark(in.SubmitParam),
|
||||
"buyerNote": normalizedRemark(in.SubmitParam),
|
||||
"adminNote": normalizedRemark(in.SubmitParam),
|
||||
"sourceBatchNos": sourceBatchNos,
|
||||
"packageStatus": packageStatus,
|
||||
"effectiveStatus": effectiveStatus,
|
||||
}
|
||||
for key, value := range completedInfo {
|
||||
info[key] = value
|
||||
}
|
||||
if payment != nil {
|
||||
info["paymentMode"] = payment.PaymentMode
|
||||
info["payType"] = payment.PayType
|
||||
info["payStatus"] = payment.PayStatus
|
||||
info["balanceAmount"] = payment.BalanceAmount
|
||||
info["offlinePaidAmount"] = payment.OfflinePaidAmount
|
||||
info["balanceBefore"] = payment.BalanceBefore
|
||||
info["balanceAfter"] = payment.BalanceAfter
|
||||
info["balanceDetailSerialNo"] = payment.BalanceDetailSerialNo
|
||||
info["payerDeptId"] = payment.PayerDeptId
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, in.BatchNo).
|
||||
Data(do.BatchMain{
|
||||
OrderSn: orderSn,
|
||||
TargetDeptId: in.SubmitParam.CustomerAgentId,
|
||||
BatchInfo: gjson.New(info),
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新出库批次失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateBatchDetailSnapshots(ctx context.Context, tx gdb.TX, batchNo string, snapshots map[string]*gjsonJsonHolder, userId int64) error {
|
||||
if len(snapshots) == 0 {
|
||||
return nil
|
||||
}
|
||||
iccids := make([]string, 0, len(snapshots))
|
||||
for iccid := range snapshots {
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
sort.Strings(iccids)
|
||||
|
||||
columns := dao.BatchDetail.Columns()
|
||||
for start := 0; start < len(iccids); start += outboundDetailUpdateChunkSize {
|
||||
end := start + outboundDetailUpdateChunkSize
|
||||
if end > len(iccids) {
|
||||
end = len(iccids)
|
||||
}
|
||||
chunk := iccids[start:end]
|
||||
var sqlBuilder strings.Builder
|
||||
args := make([]any, 0, len(chunk)*3+2)
|
||||
sqlBuilder.WriteString("UPDATE `")
|
||||
sqlBuilder.WriteString(dao.BatchDetail.Table())
|
||||
sqlBuilder.WriteString("` SET `")
|
||||
sqlBuilder.WriteString(columns.Snapshot)
|
||||
sqlBuilder.WriteString("` = CASE `")
|
||||
sqlBuilder.WriteString(columns.ItemId)
|
||||
sqlBuilder.WriteString("`")
|
||||
for _, iccid := range chunk {
|
||||
sqlBuilder.WriteString(" WHEN ? THEN ?")
|
||||
args = append(args, iccid, gjson.New(snapshots[iccid].Data).String())
|
||||
}
|
||||
sqlBuilder.WriteString(" ELSE `")
|
||||
sqlBuilder.WriteString(columns.Snapshot)
|
||||
sqlBuilder.WriteString("` END, `")
|
||||
sqlBuilder.WriteString(columns.UpdatedBy)
|
||||
sqlBuilder.WriteString("` = ? WHERE `")
|
||||
sqlBuilder.WriteString(columns.BatchNo)
|
||||
sqlBuilder.WriteString("` = ? AND `")
|
||||
sqlBuilder.WriteString(columns.ItemId)
|
||||
sqlBuilder.WriteString("` IN (")
|
||||
args = append(args, userId, batchNo)
|
||||
for index, iccid := range chunk {
|
||||
if index > 0 {
|
||||
sqlBuilder.WriteByte(',')
|
||||
}
|
||||
sqlBuilder.WriteByte('?')
|
||||
args = append(args, iccid)
|
||||
}
|
||||
sqlBuilder.WriteByte(')')
|
||||
if _, err := tx.Exec(sqlBuilder.String(), args...); err != nil {
|
||||
return gerror.Wrapf(err, "更新批次明细快照失败,范围:%d-%d", start+1, end)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDetailSnapshot(row *Row, stock *entity.TSimCardStock, cardType any, customer *entity.SysDept, pkg *entity.TSimFlowPackageInfo, in *CompleteInput, orderSn, subscriptionNo string, amount float64, beginAt, endAt *gtime.Time) g.Map {
|
||||
return g.Map{
|
||||
"ICCID": stock.Iccid,
|
||||
"MSISDN": stock.Msisdn,
|
||||
"IMSI": stock.Imsi,
|
||||
"IMEI": stock.Imei,
|
||||
"cardType": cardType,
|
||||
"inboundBatchNo": stock.BatchNo,
|
||||
"outboundBatchNo": in.BatchNo,
|
||||
"orderSn": orderSn,
|
||||
"subscriptionNo": subscriptionNo,
|
||||
"customerAgentId": in.SubmitParam.CustomerAgentId,
|
||||
"customerName": customer.DeptName,
|
||||
"packageCode": pkg.PackageCode,
|
||||
"packageName": pkg.PackageName,
|
||||
"packageType": pkg.PackageType,
|
||||
"packageBeginTime": formatTime(beginAt),
|
||||
"packageEndTime": formatTime(endAt),
|
||||
"durationYears": in.SubmitParam.DurationYears,
|
||||
"durationMonthPart": in.SubmitParam.DurationMonthPart,
|
||||
"durationMonths": in.SubmitParam.DurationMonths,
|
||||
"giftDurationYears": in.SubmitParam.GiftDurationYears,
|
||||
"giftDurationMonthPart": in.SubmitParam.GiftDurationMonthPart,
|
||||
"giftDurationMonths": in.SubmitParam.GiftDurationMonths,
|
||||
"durationLabel": displayDurationLabel(in.SubmitParam.DurationYears, in.SubmitParam.DurationMonthPart, in.SubmitParam.DurationMonths),
|
||||
"giftDurationLabel": displayDurationLabel(in.SubmitParam.GiftDurationYears, in.SubmitParam.GiftDurationMonthPart, in.SubmitParam.GiftDurationMonths),
|
||||
"packageTotalFlowMb": packageTotalFlowMB(pkg),
|
||||
"amount": amount,
|
||||
"remark": normalizedRemark(in.SubmitParam),
|
||||
"rowNo": row.RowNo,
|
||||
"stock": g.Map{
|
||||
"chanKey": stock.ChanKey,
|
||||
"sourceInboundBatchNo": stock.BatchNo,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func inboundCardTypeValue(info *gjson.Json) any {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
value := strings.TrimSpace(info.Get("inboundOptions.cardType").String())
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(info.Get("cardType").String())
|
||||
}
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
cardType, err := strconv.Atoi(value)
|
||||
if err != nil || cardType <= 0 {
|
||||
return nil
|
||||
}
|
||||
return cardType
|
||||
}
|
||||
|
||||
func formatTime(t *gtime.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("Y-m-d H:i:s")
|
||||
}
|
||||
|
||||
func chooseString(primary, fallback string) string {
|
||||
primary = strings.TrimSpace(primary)
|
||||
if primary != "" {
|
||||
return primary
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
type outboundPaymentSnapshot struct {
|
||||
PaymentMode string
|
||||
PayType int
|
||||
PayStatus int
|
||||
PayAmount float64
|
||||
BalanceAmount float64
|
||||
OfflinePaidAmount float64
|
||||
BalanceBefore float64
|
||||
BalanceAfter float64
|
||||
BalanceDetailSerialNo string
|
||||
PaymentTime *gtime.Time
|
||||
PayerDeptId int64
|
||||
}
|
||||
|
||||
func prepareOutboundPayment(ctx context.Context, tx gdb.TX, batchNo string, amount float64, operator batchimport.OperatorContext, in *model.TSimCardOutboundSubmitParam) (*outboundPaymentSnapshot, error) {
|
||||
if in == nil {
|
||||
return nil, gerror.New("出库付款参数不能为空")
|
||||
}
|
||||
if amount < 0 {
|
||||
return nil, gerror.New("订单金额不能小于0")
|
||||
}
|
||||
now := gtime.Now()
|
||||
if in.OfflineContractCompleted {
|
||||
return &outboundPaymentSnapshot{
|
||||
PaymentMode: outboundPaymentModeOffline,
|
||||
PayType: consts.PayTypeOffline,
|
||||
PayStatus: consts.PayStatusPaid,
|
||||
PayAmount: amount,
|
||||
OfflinePaidAmount: amount,
|
||||
PaymentTime: now,
|
||||
PayerDeptId: in.CustomerAgentId,
|
||||
}, nil
|
||||
}
|
||||
return deductOutboundBalanceTx(ctx, tx, batchNo, amount, now, operator, in.CustomerAgentId)
|
||||
}
|
||||
|
||||
func deductOutboundBalanceTx(ctx context.Context, tx gdb.TX, batchNo string, amount float64, paymentTime *gtime.Time, operator batchimport.OperatorContext, payerDeptID int64) (*outboundPaymentSnapshot, error) {
|
||||
if tx == nil {
|
||||
return nil, gerror.New("出库余额支付必须在事务中执行")
|
||||
}
|
||||
if operator.DeptId <= 0 || operator.TenantId == "" || operator.UserId <= 0 {
|
||||
return nil, gerror.New("当前用户未绑定组织、租户或用户信息")
|
||||
}
|
||||
if payerDeptID <= 0 {
|
||||
return nil, gerror.New("出库客户不能为空")
|
||||
}
|
||||
if paymentTime == nil {
|
||||
paymentTime = gtime.Now()
|
||||
}
|
||||
serialNo := outboundBalanceSerialNo(batchNo)
|
||||
if serialNo == "" {
|
||||
return nil, gerror.New("出库余额流水号生成失败")
|
||||
}
|
||||
|
||||
account := &entity.TAgentBalance{}
|
||||
if err := tx.Ctx(ctx).Model(dao.TAgentBalance.Table()).
|
||||
Where(dao.TAgentBalance.Columns().DeptId, payerDeptID).
|
||||
Where(dao.TAgentBalance.Columns().TenantId, operator.TenantId).
|
||||
LockUpdate().
|
||||
Scan(account); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, gerror.Wrap(err, "查询账户余额失败")
|
||||
}
|
||||
if account.Id == 0 {
|
||||
return nil, gerror.New("客户暂无余额账户,请先充值")
|
||||
}
|
||||
if account.Status != outboundFinanceAccountStatusNormal {
|
||||
return nil, gerror.New("账户余额已冻结,不能扣余额")
|
||||
}
|
||||
record, err := tx.Ctx(ctx).Model(dao.TBalanceDetail.Table()).
|
||||
Where(dao.TBalanceDetail.Columns().TenantId, operator.TenantId).
|
||||
Where(dao.TBalanceDetail.Columns().SerialNo, serialNo).
|
||||
LockUpdate().
|
||||
One()
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, gerror.Wrap(err, "查询出库余额流水失败")
|
||||
}
|
||||
if record != nil && !record.IsEmpty() {
|
||||
return nil, gerror.New("当前出库批次已扣款,请勿重复提交")
|
||||
}
|
||||
|
||||
before := roundOutboundAmount(account.Balance)
|
||||
if before+0.0001 < amount {
|
||||
return nil, gerror.Newf("账户余额不足,需支付%.2f元,当前余额%.2f元", amount, before)
|
||||
}
|
||||
after := roundOutboundAmount(before - amount)
|
||||
now := gtime.Now()
|
||||
result, err := tx.Ctx(ctx).Model(dao.TAgentBalance.Table()).
|
||||
WherePri(account.Id).
|
||||
Data(g.Map{
|
||||
dao.TAgentBalance.Columns().Balance: after,
|
||||
dao.TAgentBalance.Columns().UpdatedBy: operator.UserId,
|
||||
dao.TAgentBalance.Columns().UpdatedAt: now,
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "扣减账户余额失败")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "读取账户余额更新结果失败")
|
||||
}
|
||||
if affected != 1 {
|
||||
return nil, gerror.New("账户余额已变化,请重试")
|
||||
}
|
||||
|
||||
if _, err = tx.Ctx(ctx).Model(dao.TBalanceDetail.Table()).Data(g.Map{
|
||||
dao.TBalanceDetail.Columns().DeptId: payerDeptID,
|
||||
dao.TBalanceDetail.Columns().SerialNo: serialNo,
|
||||
dao.TBalanceDetail.Columns().TransactionTime: paymentTime,
|
||||
dao.TBalanceDetail.Columns().IncomeExpenseType: outboundFinanceIncomeExpenseExpense,
|
||||
dao.TBalanceDetail.Columns().TransactionType: outboundFinanceTransactionTypeConsume,
|
||||
dao.TBalanceDetail.Columns().TransactionChannel: outboundFinanceTransactionChannelBalance,
|
||||
dao.TBalanceDetail.Columns().TransactionStatus: outboundFinanceTransactionStatusPaid,
|
||||
dao.TBalanceDetail.Columns().Amount: amount,
|
||||
dao.TBalanceDetail.Columns().BeforeBalance: before,
|
||||
dao.TBalanceDetail.Columns().BalanceAfter: after,
|
||||
dao.TBalanceDetail.Columns().OrderSn: batchNo,
|
||||
dao.TBalanceDetail.Columns().Remark: fmt.Sprintf("SIM卡出库余额支付,批次号:%s", batchNo),
|
||||
dao.TBalanceDetail.Columns().TenantId: operator.TenantId,
|
||||
dao.TBalanceDetail.Columns().CreatedDept: operator.DeptId,
|
||||
dao.TBalanceDetail.Columns().CreatedBy: operator.UserId,
|
||||
dao.TBalanceDetail.Columns().CreatedAt: now,
|
||||
}).Insert(); err != nil {
|
||||
return nil, gerror.Wrap(err, "写入出库余额流水失败")
|
||||
}
|
||||
|
||||
return &outboundPaymentSnapshot{
|
||||
PaymentMode: outboundPaymentModeBalance,
|
||||
PayType: consts.PayTypeBalance,
|
||||
PayStatus: consts.PayStatusPaid,
|
||||
PayAmount: amount,
|
||||
BalanceAmount: amount,
|
||||
BalanceBefore: before,
|
||||
BalanceAfter: after,
|
||||
BalanceDetailSerialNo: serialNo,
|
||||
PaymentTime: paymentTime,
|
||||
PayerDeptId: payerDeptID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func subscriptionAmount(in *model.TSimCardOutboundSubmitParam) float64 {
|
||||
amount, _ := parseOutboundAmount(in.Amount)
|
||||
return amount
|
||||
}
|
||||
|
||||
func parseOutboundAmount(value string) (float64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, gerror.New("订单金额不能为空")
|
||||
}
|
||||
amount, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0, gerror.New("订单金额格式不正确")
|
||||
}
|
||||
if amount < 0 {
|
||||
return 0, gerror.New("订单金额不能小于0")
|
||||
}
|
||||
return roundOutboundAmount(amount), nil
|
||||
}
|
||||
|
||||
func roundOutboundAmount(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
func outboundBalanceSerialNo(batchNo string) string {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
return ""
|
||||
}
|
||||
return "OUTPAY" + batchNo
|
||||
}
|
||||
|
||||
func displayDurationValue(years, months int) int {
|
||||
if years > 0 {
|
||||
return years
|
||||
}
|
||||
if months > 0 && months%12 == 0 {
|
||||
return months / 12
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
func displayDurationLabel(years, monthPart, totalMonths int) string {
|
||||
if years <= 0 && monthPart <= 0 && totalMonths > 0 {
|
||||
years = totalMonths / 12
|
||||
monthPart = totalMonths % 12
|
||||
}
|
||||
parts := make([]string, 0, 2)
|
||||
if years > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d年", years))
|
||||
}
|
||||
if monthPart > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d个月", monthPart))
|
||||
}
|
||||
if len(parts) == 0 && totalMonths > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d个月", totalMonths))
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func packageTotalFlowMB(pkg *entity.TSimFlowPackageInfo) float64 {
|
||||
if pkg == nil {
|
||||
return 0
|
||||
}
|
||||
if pkg.FlowMb > 0 {
|
||||
return pkg.FlowMb
|
||||
}
|
||||
if pkg.FlowGb > 0 {
|
||||
return pkg.FlowGb * 1024
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common/cardsource"
|
||||
)
|
||||
|
||||
func ParseInputRows(ctx context.Context, input string) ([]batchimport.Row, error) {
|
||||
return cardsource.BuildIccidRows(ctx, input, "批量输入出库数据为空", func(line cardsource.IccidLine) *Row {
|
||||
return &Row{
|
||||
RowNo: line.RowNo,
|
||||
Iccid: line.Iccid,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type ExcelParseResult struct {
|
||||
Rows []*Row
|
||||
SubmitParam *model.TSimCardOutboundSubmitParam
|
||||
}
|
||||
|
||||
type consistencyValue struct {
|
||||
rowNo int
|
||||
normalized string
|
||||
}
|
||||
|
||||
func excelHeaders() []string {
|
||||
return []string{
|
||||
"ICCID",
|
||||
"MSISDN",
|
||||
"IMSI",
|
||||
"客户/机构",
|
||||
"套餐",
|
||||
"套餐开始时间",
|
||||
"购买时长(年)",
|
||||
"赠送时长(月)",
|
||||
"订单金额",
|
||||
"线下合同/销售/走账已完成",
|
||||
"备注",
|
||||
}
|
||||
}
|
||||
|
||||
func excelRequiredHeaders() []string {
|
||||
return []string{"ICCID"}
|
||||
}
|
||||
|
||||
func ParseExcelFile(ctx context.Context, filePath string) (*ExcelParseResult, error) {
|
||||
parsedRows, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{
|
||||
RequiredHeaders: excelRequiredHeaders(),
|
||||
SingleSheet: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildExcelParseResult(parsedRows), nil
|
||||
}
|
||||
|
||||
func ParseExcelReader(ctx context.Context, reader io.Reader) (*ExcelParseResult, error) {
|
||||
parsedRows, err := excel.ParseReader(ctx, reader, excel.ParseOptions{
|
||||
RequiredHeaders: excelRequiredHeaders(),
|
||||
SingleSheet: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildExcelParseResult(parsedRows), nil
|
||||
}
|
||||
|
||||
func buildExcelParseResult(parsedRows []excel.ParsedRow) *ExcelParseResult {
|
||||
rows := make([]*Row, 0, len(parsedRows))
|
||||
consistentCustomerAgentId := consistencyValue{}
|
||||
consistentPackageCode := consistencyValue{}
|
||||
consistentPackageBeginTime := consistencyValue{}
|
||||
consistentDurationYears := consistencyValue{}
|
||||
consistentDurationMonthPart := consistencyValue{}
|
||||
consistentGiftDurationYears := consistencyValue{}
|
||||
consistentGiftDurationMonthPart := consistencyValue{}
|
||||
consistentAmount := consistencyValue{}
|
||||
consistentOfflineContractCompleted := consistencyValue{}
|
||||
consistentRemark := consistencyValue{}
|
||||
|
||||
for _, parsed := range parsedRows {
|
||||
row := &Row{
|
||||
RowNo: parsed.RowNo,
|
||||
Iccid: excel.Field(parsed, "ICCID"),
|
||||
Msisdn: excel.Field(parsed, "MSISDN"),
|
||||
Imsi: excel.Field(parsed, "IMSI"),
|
||||
// 出库不再配置测试期、沉默期;1 表示否。
|
||||
HasTestPeriod: 1,
|
||||
HasSilentPeriod: 1,
|
||||
}
|
||||
|
||||
parseRequiredPositiveInt(row, excelField(parsed, "customerAgentId"), "客户/机构", func(value int) {
|
||||
row.CustomerAgentId = int64(value)
|
||||
checkConsistency(row, "客户/机构", strconv.FormatInt(row.CustomerAgentId, 10), &consistentCustomerAgentId)
|
||||
})
|
||||
|
||||
row.PackageCode = strings.TrimSpace(excelField(parsed, "packageCode"))
|
||||
if row.PackageCode == "" {
|
||||
row.AddReason("套餐不能为空")
|
||||
} else {
|
||||
checkConsistency(row, "套餐", row.PackageCode, &consistentPackageCode)
|
||||
}
|
||||
|
||||
parseRequiredDateTime(row, excelField(parsed, "packageBeginTime"), "套餐开始时间", func(value *gtime.Time) {
|
||||
row.PackageBeginTime = value
|
||||
checkConsistency(row, "套餐开始时间", formatTime(value), &consistentPackageBeginTime)
|
||||
})
|
||||
|
||||
parseExcelDuration(row, parsed, &consistentDurationYears, &consistentDurationMonthPart)
|
||||
|
||||
parseExcelGiftDuration(row, parsed, &consistentGiftDurationYears, &consistentGiftDurationMonthPart)
|
||||
|
||||
parseRequiredAmount(row, excelField(parsed, "amount"), func(value string) {
|
||||
row.Amount = value
|
||||
checkConsistency(row, "订单金额", value, &consistentAmount)
|
||||
})
|
||||
|
||||
parseRequiredBool(row, excelField(parsed, "offlineContractCompleted"), "线下合同/销售/走账已完成", func(value bool) {
|
||||
row.OfflineContractCompleted = value
|
||||
checkConsistency(row, "线下合同/销售/走账已完成", strconv.FormatBool(value), &consistentOfflineContractCompleted)
|
||||
})
|
||||
|
||||
row.Remark = strings.TrimSpace(excelField(parsed, "remark"))
|
||||
checkConsistency(row, "备注", row.Remark, &consistentRemark)
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
result := &ExcelParseResult{Rows: rows}
|
||||
if hasInvalidRows(rows) {
|
||||
return result
|
||||
}
|
||||
result.SubmitParam = &model.TSimCardOutboundSubmitParam{
|
||||
CustomerAgentId: rows[0].CustomerAgentId,
|
||||
PackageCode: rows[0].PackageCode,
|
||||
PackageBeginTime: rows[0].PackageBeginTime,
|
||||
DurationYears: rows[0].DurationYears,
|
||||
DurationMonthPart: rows[0].DurationMonthPart,
|
||||
DurationMonths: rows[0].DurationMonths,
|
||||
GiftDurationYears: rows[0].GiftDurationYears,
|
||||
GiftDurationMonthPart: rows[0].GiftDurationMonthPart,
|
||||
GiftDurationMonths: rows[0].GiftDurationMonths,
|
||||
HasTestPeriod: 1,
|
||||
HasSilentPeriod: 1,
|
||||
Amount: rows[0].Amount,
|
||||
OfflineContractCompleted: rows[0].OfflineContractCompleted,
|
||||
Remark: rows[0].Remark,
|
||||
BuyerNote: rows[0].Remark,
|
||||
AdminNote: rows[0].Remark,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func excelField(row excel.ParsedRow, name string) string {
|
||||
for _, alias := range excelFieldAliases(name) {
|
||||
if value := strings.TrimSpace(excel.Field(row, alias)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func excelFieldAliases(name string) []string {
|
||||
switch name {
|
||||
case "customerAgentId":
|
||||
return []string{"客户/机构", "客户/机构ID", "客户ID", "customerAgentId"}
|
||||
case "packageCode":
|
||||
return []string{"套餐", "套餐编码", "packageCode"}
|
||||
case "packageBeginTime":
|
||||
return []string{"套餐开始时间", "packageBeginTime"}
|
||||
case "durationYears":
|
||||
return []string{"购买时长(年)", "套餐购买时长(年)", "durationYears"}
|
||||
case "durationMonthPart":
|
||||
return []string{"购买时长(月)", "套餐购买时长(月)", "durationMonthPart"}
|
||||
case "durationMonths":
|
||||
return []string{"durationMonths"}
|
||||
case "giftDurationYears":
|
||||
return []string{"赠送时长(年)", "giftDurationYears"}
|
||||
case "giftDurationMonthPart":
|
||||
return []string{"赠送时长(月)", "giftDurationMonthPart"}
|
||||
case "giftDurationMonths":
|
||||
return []string{"giftDurationMonths"}
|
||||
case "amount":
|
||||
return []string{"订单金额", "amount"}
|
||||
case "offlineContractCompleted":
|
||||
return []string{"线下合同/销售/走账已完成", "是否走线下", "offlineContractCompleted"}
|
||||
case "remark":
|
||||
return []string{"备注", "remark", "buyerNote", "adminNote"}
|
||||
default:
|
||||
return []string{name}
|
||||
}
|
||||
}
|
||||
|
||||
func parseExcelDuration(row *Row, parsed excel.ParsedRow, consistentYears, consistentMonthPart *consistencyValue) {
|
||||
yearsRaw := excelField(parsed, "durationYears")
|
||||
monthPartRaw := excelField(parsed, "durationMonthPart")
|
||||
if strings.TrimSpace(yearsRaw) != "" || strings.TrimSpace(monthPartRaw) != "" {
|
||||
parseOptionalNonNegativeInt(row, yearsRaw, "购买时长(年)", 0, func(value int) {
|
||||
row.DurationYears = value
|
||||
checkConsistency(row, "购买时长(年)", strconv.Itoa(value), consistentYears)
|
||||
})
|
||||
parseOptionalMonthPart(row, monthPartRaw, "购买时长(月)", func(value int) {
|
||||
row.DurationMonthPart = value
|
||||
checkConsistency(row, "购买时长(月)", strconv.Itoa(value), consistentMonthPart)
|
||||
})
|
||||
row.DurationMonths = row.DurationYears*12 + row.DurationMonthPart
|
||||
if row.DurationMonths <= 0 {
|
||||
row.AddReason("购买时长必须大于0")
|
||||
}
|
||||
return
|
||||
}
|
||||
parseRequiredPositiveInt(row, excelField(parsed, "durationMonths"), "套餐购买时长(月)", func(value int) {
|
||||
row.DurationMonths = value
|
||||
row.DurationYears = value / 12
|
||||
row.DurationMonthPart = value % 12
|
||||
checkConsistency(row, "套餐购买时长(月)", strconv.Itoa(value), consistentMonthPart)
|
||||
})
|
||||
}
|
||||
|
||||
func parseExcelGiftDuration(row *Row, parsed excel.ParsedRow, consistentYears, consistentMonthPart *consistencyValue) {
|
||||
yearsRaw := excelField(parsed, "giftDurationYears")
|
||||
monthPartRaw := excelField(parsed, "giftDurationMonthPart")
|
||||
if strings.TrimSpace(yearsRaw) != "" || strings.TrimSpace(monthPartRaw) != "" {
|
||||
parseOptionalNonNegativeInt(row, yearsRaw, "赠送时长(年)", 0, func(value int) {
|
||||
row.GiftDurationYears = value
|
||||
checkConsistency(row, "赠送时长(年)", strconv.Itoa(value), consistentYears)
|
||||
})
|
||||
parseOptionalMonthPart(row, monthPartRaw, "赠送时长(月)", func(value int) {
|
||||
row.GiftDurationMonthPart = value
|
||||
checkConsistency(row, "赠送时长(月)", strconv.Itoa(value), consistentMonthPart)
|
||||
})
|
||||
row.GiftDurationMonths = row.GiftDurationYears*12 + row.GiftDurationMonthPart
|
||||
return
|
||||
}
|
||||
parseOptionalNonNegativeInt(row, excelField(parsed, "giftDurationMonths"), "赠送时长(月)", 0, func(value int) {
|
||||
row.GiftDurationMonths = value
|
||||
row.GiftDurationYears = value / 12
|
||||
row.GiftDurationMonthPart = value % 12
|
||||
checkConsistency(row, "赠送时长(月)", strconv.Itoa(value), consistentMonthPart)
|
||||
})
|
||||
}
|
||||
|
||||
func checkConsistency(row *Row, field, normalized string, base *consistencyValue) {
|
||||
if row == nil || !row.Valid() {
|
||||
return
|
||||
}
|
||||
if base.rowNo == 0 {
|
||||
base.rowNo = row.RowNo
|
||||
base.normalized = normalized
|
||||
return
|
||||
}
|
||||
if base.normalized != normalized {
|
||||
row.AddReason(fmt.Sprintf("%s与第%d行不一致", field, base.rowNo))
|
||||
}
|
||||
}
|
||||
|
||||
func parseRequiredPositiveInt(row *Row, raw, field string, apply func(value int)) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
row.AddReason(field + "不能为空")
|
||||
return
|
||||
}
|
||||
value, err := excel.ParsePositiveInt(raw)
|
||||
if err != nil {
|
||||
row.AddReason(field + "格式不正确")
|
||||
return
|
||||
}
|
||||
apply(value)
|
||||
}
|
||||
|
||||
func parseOptionalNonNegativeInt(row *Row, raw, field string, defaultValue int, apply func(value int)) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
apply(defaultValue)
|
||||
return
|
||||
}
|
||||
value, err := excel.ParseNonNegativeInt(raw)
|
||||
if err != nil {
|
||||
row.AddReason(field + "格式不正确")
|
||||
return
|
||||
}
|
||||
apply(value)
|
||||
}
|
||||
|
||||
func parseOptionalMonthPart(row *Row, raw, field string, apply func(value int)) {
|
||||
parseOptionalNonNegativeInt(row, raw, field, 0, func(value int) {
|
||||
if value > 11 {
|
||||
row.AddReason(field + "必须在0到11之间")
|
||||
return
|
||||
}
|
||||
apply(value)
|
||||
})
|
||||
}
|
||||
|
||||
func parseOptionalAmount(row *Row, raw string, apply func(value string)) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
apply("")
|
||||
return
|
||||
}
|
||||
value, err := excel.ParseAmount(raw)
|
||||
if err != nil {
|
||||
row.AddReason("订单金额格式不正确")
|
||||
return
|
||||
}
|
||||
apply(fmt.Sprintf("%.2f", value))
|
||||
}
|
||||
|
||||
func parseRequiredAmount(row *Row, raw string, apply func(value string)) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
row.AddReason("订单金额不能为空")
|
||||
return
|
||||
}
|
||||
parseOptionalAmount(row, raw, apply)
|
||||
}
|
||||
|
||||
func parseRequiredBool(row *Row, raw, field string, apply func(value bool)) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
row.AddReason(field + "不能为空")
|
||||
return
|
||||
}
|
||||
value, err := excel.ParseBool(raw)
|
||||
if err != nil {
|
||||
row.AddReason(field + "格式不正确")
|
||||
return
|
||||
}
|
||||
apply(value)
|
||||
}
|
||||
|
||||
func parseRequiredDateTime(row *Row, raw, field string, apply func(value *gtime.Time)) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
row.AddReason(field + "不能为空")
|
||||
return
|
||||
}
|
||||
value, err := excel.ParseDateTime(raw)
|
||||
if err != nil {
|
||||
row.AddReason(field + "格式不正确")
|
||||
return
|
||||
}
|
||||
apply(value)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateRows(ctx context.Context, rows []*Row, opts PreviewOptions, repo *Repository) error {
|
||||
identityFirstRow := make(map[string]*Row, len(rows))
|
||||
iccids := make([]string, 0, len(rows))
|
||||
msisdns := make([]string, 0, len(rows))
|
||||
imsis := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
row.Msisdn = strings.TrimSpace(row.Msisdn)
|
||||
row.Imsi = strings.TrimSpace(row.Imsi)
|
||||
row.RangeCodeType = rowIdentityCodeType(row)
|
||||
key := rowIdentityKey(row)
|
||||
if key == "" {
|
||||
row.AddReason(rowIdentityLabel(row.RangeCodeType) + "不能为空")
|
||||
continue
|
||||
}
|
||||
identityKey := row.RangeCodeType + ":" + key
|
||||
if firstRow, ok := identityFirstRow[identityKey]; ok {
|
||||
label := rowIdentityLabel(row.RangeCodeType)
|
||||
row.AddReason(fmt.Sprintf("%s与第%d行重复", label, firstRow.RowNo))
|
||||
firstRow.AddReason(fmt.Sprintf("%s与第%d行重复", label, row.RowNo))
|
||||
continue
|
||||
}
|
||||
identityFirstRow[identityKey] = row
|
||||
switch row.RangeCodeType {
|
||||
case "msisdn":
|
||||
msisdns = append(msisdns, key)
|
||||
case "imsi":
|
||||
imsis = append(imsis, key)
|
||||
default:
|
||||
iccids = append(iccids, key)
|
||||
}
|
||||
}
|
||||
|
||||
stockByIccid, err := repo.GetStockCardsByIccids(ctx, iccids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stockByMsisdn, err := repo.GetStockCardsByMsisdns(ctx, msisdns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stockByImsi, err := repo.GetStockCardsByImsis(ctx, imsis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowStock := make(map[*Row]*StockCard, len(rows))
|
||||
resolvedIccidFirstRow := make(map[string]*Row, len(rows))
|
||||
resolvedIccids := make([]string, 0, len(rows))
|
||||
|
||||
for _, row := range rows {
|
||||
if !row.Valid() {
|
||||
continue
|
||||
}
|
||||
stock := stockForRow(row, stockByIccid, stockByMsisdn, stockByImsi)
|
||||
if stock == nil {
|
||||
row.AddReason(rowIdentityLabel(row.RangeCodeType) + "不存在库存记录")
|
||||
continue
|
||||
}
|
||||
providedIccid := row.Iccid
|
||||
providedMsisdn := row.Msisdn
|
||||
providedImsi := row.Imsi
|
||||
row.Iccid = strings.TrimSpace(stock.Iccid)
|
||||
row.Msisdn = chooseString(row.Msisdn, stock.Msisdn)
|
||||
row.Imsi = chooseString(row.Imsi, stock.Imsi)
|
||||
if row.Iccid == "" {
|
||||
row.AddReason("库存卡ICCID为空")
|
||||
continue
|
||||
}
|
||||
if firstRow, ok := resolvedIccidFirstRow[row.Iccid]; ok {
|
||||
row.AddReason(fmt.Sprintf("ICCID与第%d行指向同一库存卡", firstRow.RowNo))
|
||||
firstRow.AddReason(fmt.Sprintf("ICCID与第%d行指向同一库存卡", row.RowNo))
|
||||
continue
|
||||
}
|
||||
resolvedIccidFirstRow[row.Iccid] = row
|
||||
if stock.CreatedDept != opts.DeptId {
|
||||
row.AddReason("ICCID不属于当前机构库存")
|
||||
continue
|
||||
}
|
||||
if opts.StrictStockIdentity {
|
||||
if providedIccid != "" && providedIccid != strings.TrimSpace(stock.Iccid) {
|
||||
row.AddReason("ICCID与库存记录不一致")
|
||||
continue
|
||||
}
|
||||
if providedMsisdn != "" && providedMsisdn != strings.TrimSpace(stock.Msisdn) {
|
||||
row.AddReason("MSISDN与库存记录不一致")
|
||||
continue
|
||||
}
|
||||
if providedImsi != "" && providedImsi != strings.TrimSpace(stock.Imsi) {
|
||||
row.AddReason("IMSI与库存记录不一致")
|
||||
continue
|
||||
}
|
||||
}
|
||||
row.SourceBatchNo = strings.TrimSpace(stock.BatchNo)
|
||||
rowStock[row] = stock
|
||||
resolvedIccids = append(resolvedIccids, row.Iccid)
|
||||
}
|
||||
|
||||
formalByIccid, err := repo.GetExistingFormalIccids(ctx, resolvedIccids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if !row.Valid() || rowStock[row] == nil {
|
||||
continue
|
||||
}
|
||||
if formalByIccid[row.Iccid] {
|
||||
row.AddReason("ICCID已存在正式卡记录")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rowIdentityCodeType(row *Row) string {
|
||||
if row == nil {
|
||||
return "iccid"
|
||||
}
|
||||
if codeType := normalizeRangeCodeType(row.RangeCodeType); codeType != "" {
|
||||
return codeType
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(row.Iccid) != "":
|
||||
return "iccid"
|
||||
case strings.TrimSpace(row.Msisdn) != "":
|
||||
return "msisdn"
|
||||
case strings.TrimSpace(row.Imsi) != "":
|
||||
return "imsi"
|
||||
default:
|
||||
return "iccid"
|
||||
}
|
||||
}
|
||||
|
||||
func rowIdentityKey(row *Row) string {
|
||||
if row == nil {
|
||||
return ""
|
||||
}
|
||||
switch row.RangeCodeType {
|
||||
case "msisdn":
|
||||
return strings.TrimSpace(row.Msisdn)
|
||||
case "imsi":
|
||||
return strings.TrimSpace(row.Imsi)
|
||||
default:
|
||||
return strings.TrimSpace(row.Iccid)
|
||||
}
|
||||
}
|
||||
|
||||
func rowIdentityLabel(codeType string) string {
|
||||
switch codeType {
|
||||
case "msisdn":
|
||||
return "MSISDN"
|
||||
case "imsi":
|
||||
return "IMSI"
|
||||
default:
|
||||
return "ICCID"
|
||||
}
|
||||
}
|
||||
|
||||
func stockForRow(row *Row, stockByIccid, stockByMsisdn, stockByImsi map[string]*StockCard) *StockCard {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
switch row.RangeCodeType {
|
||||
case "msisdn":
|
||||
return stockByMsisdn[strings.TrimSpace(row.Msisdn)]
|
||||
case "imsi":
|
||||
return stockByImsi[strings.TrimSpace(row.Imsi)]
|
||||
default:
|
||||
return stockByIccid[strings.TrimSpace(row.Iccid)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
repo *Repository
|
||||
opts PreviewOptions
|
||||
config batchimport.ProcessorConfig
|
||||
excelSubmitParam *model.TSimCardOutboundSubmitParam
|
||||
}
|
||||
|
||||
func (p *Processor) ExecutePending(ctx context.Context, tx gdb.TX, batchNo string, in *model.TSimCardOutboundSubmitParam, rows []*Row, operator batchimport.OperatorContext, requiredSourceBatchNo string) (*CompleteOutput, error) {
|
||||
if strings.TrimSpace(batchNo) == "" {
|
||||
return nil, gerror.New("出库批次号不能为空")
|
||||
}
|
||||
if in == nil {
|
||||
return nil, gerror.New("出库参数不能为空")
|
||||
}
|
||||
if err := p.validateExecuteContext(ctx, tx, in, operator, rows, requiredSourceBatchNo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.Complete(ctx, tx, &CompleteInput{
|
||||
SubmitParam: in,
|
||||
BatchNo: batchNo,
|
||||
Rows: rows,
|
||||
Operator: operator,
|
||||
StrictStockIdentity: p.opts.StrictStockIdentity,
|
||||
RequiredSourceBatchNo: requiredSourceBatchNo,
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
outboundCardPackageType = 101
|
||||
outboundPoolPackageType = 102
|
||||
)
|
||||
|
||||
func NewProcessor(opts PreviewOptions) *Processor {
|
||||
return &Processor{
|
||||
repo: NewRepository(),
|
||||
opts: opts,
|
||||
config: batchimport.ProcessorConfig{
|
||||
Code: "sim_card_outbound",
|
||||
BatchType: consts.BatchTypeOutbound,
|
||||
BatchNoPrefix: consts.BatchNoPrefixOutbound,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardOutbound,
|
||||
ExportScene: consts.ResourceExportSceneOutbound,
|
||||
ImportDir: consts.SimCardOutboundSubDirImportExcel,
|
||||
ResultDir: consts.SimCardOutboundSubDirResultExcel,
|
||||
MaxFileSize: consts.SimCardOutboundMaxFileSize,
|
||||
TemplateHeaders: excelHeaders(),
|
||||
RequiredHeaders: excelRequiredHeaders(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) Config() batchimport.ProcessorConfig {
|
||||
return p.config
|
||||
}
|
||||
|
||||
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
parsed, err := ParseExcelFile(ctx, filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.excelSubmitParam = nil
|
||||
if parsed != nil {
|
||||
p.excelSubmitParam = parsed.SubmitParam
|
||||
}
|
||||
result := make([]batchimport.Row, 0, len(parsed.Rows))
|
||||
for _, row := range parsed.Rows {
|
||||
result = append(result, row)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Processor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
|
||||
outboundRows := make([]*Row, 0, len(rows))
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok {
|
||||
item.AddErrorReason("导入行类型错误")
|
||||
continue
|
||||
}
|
||||
outboundRows = append(outboundRows, row)
|
||||
}
|
||||
|
||||
opts := p.opts
|
||||
if opts.DeptId == 0 {
|
||||
opts.DeptId = contexts.GetDeptId(ctx)
|
||||
}
|
||||
return validateRows(ctx, outboundRows, opts, p.repo)
|
||||
}
|
||||
|
||||
func (p *Processor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
|
||||
if req == nil {
|
||||
return gerror.New("出库请求不能为空")
|
||||
}
|
||||
in := p.submitParams(req.BizParams)
|
||||
if in == nil {
|
||||
return gerror.New("出库参数类型错误")
|
||||
}
|
||||
|
||||
outboundRows := make([]*Row, 0, len(rows))
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok {
|
||||
return gerror.New("导入行类型错误")
|
||||
}
|
||||
outboundRows = append(outboundRows, row)
|
||||
}
|
||||
if err := p.validateExecuteContext(ctx, tx, in, req.Operator, outboundRows, requiredSourceBatchNo(req.BizParams)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := p.Complete(ctx, tx, &CompleteInput{
|
||||
SubmitParam: in,
|
||||
BatchNo: req.BatchNo,
|
||||
Rows: outboundRows,
|
||||
Operator: req.Operator,
|
||||
StrictStockIdentity: p.opts.StrictStockIdentity,
|
||||
RequiredSourceBatchNo: requiredSourceBatchNo(req.BizParams),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.ExecutionResult = output
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Processor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
|
||||
info := g.Map{}
|
||||
info["strictStockIdentity"] = p.opts.StrictStockIdentity
|
||||
if result != nil && result.ImportFileOssId > 0 {
|
||||
info["importFileOssId"] = result.ImportFileOssId
|
||||
} else if importOss != nil {
|
||||
info["importFileOssId"] = importOss.OssId
|
||||
}
|
||||
if resultOss != nil {
|
||||
info["resultFileOssId"] = resultOss.OssId
|
||||
}
|
||||
if downloadId > 0 {
|
||||
info["resultDownloadId"] = downloadId
|
||||
}
|
||||
if in := p.submitParams(req.BizParams); in != nil {
|
||||
normalizeSubmitParam(in)
|
||||
info["customerAgentId"] = in.CustomerAgentId
|
||||
info["packageCode"] = strings.TrimSpace(in.PackageCode)
|
||||
info["packageBeginTime"] = in.PackageBeginTime
|
||||
info["durationYears"] = in.DurationYears
|
||||
info["durationMonthPart"] = in.DurationMonthPart
|
||||
info["durationMonths"] = in.DurationMonths
|
||||
info["giftDurationYears"] = in.GiftDurationYears
|
||||
info["giftDurationMonthPart"] = in.GiftDurationMonthPart
|
||||
info["giftDurationMonths"] = in.GiftDurationMonths
|
||||
info["amount"] = strings.TrimSpace(in.Amount)
|
||||
info["offlineContractCompleted"] = in.OfflineContractCompleted
|
||||
info["remark"] = normalizedRemark(in)
|
||||
info["buyerNote"] = normalizedRemark(in)
|
||||
info["adminNote"] = normalizedRemark(in)
|
||||
}
|
||||
if in, ok := req.BizParams.(*model.TSimCardOutboundRangeSubmitParam); ok && in != nil {
|
||||
info["rangeCodeType"] = normalizeRangeCodeType(in.RangeCodeType)
|
||||
info["startIccid"] = strings.TrimSpace(in.StartIccid)
|
||||
info["endIccid"] = strings.TrimSpace(in.EndIccid)
|
||||
info["startMsisdn"] = strings.TrimSpace(in.StartMsisdn)
|
||||
info["endMsisdn"] = strings.TrimSpace(in.EndMsisdn)
|
||||
info["startImsi"] = strings.TrimSpace(in.StartImsi)
|
||||
info["endImsi"] = strings.TrimSpace(in.EndImsi)
|
||||
}
|
||||
if in, ok := req.BizParams.(*model.TSimCardOutboundBatchSubmitParam); ok && in != nil {
|
||||
info["inboundBatchNo"] = strings.TrimSpace(in.BatchNo)
|
||||
}
|
||||
if output, ok := req.ExecutionResult.(*CompleteOutput); ok && output != nil {
|
||||
info["orderSn"] = output.OrderSn
|
||||
info["quantity"] = output.Quantity
|
||||
info["sourceBatchNos"] = output.SourceBatchNos
|
||||
info["packageBeginTime"] = output.PackageBeginAt
|
||||
info["packageEndTime"] = output.PackageEndAt
|
||||
info["packageStatus"] = output.PackageStatus
|
||||
info["effectiveStatus"] = output.EffectiveStatus
|
||||
if output.Customer != nil {
|
||||
info["customerName"] = output.Customer.DeptName
|
||||
}
|
||||
if output.Package != nil {
|
||||
info["packageName"] = output.Package.PackageName
|
||||
info["packageType"] = output.Package.PackageType
|
||||
}
|
||||
}
|
||||
return gjson.New(info)
|
||||
}
|
||||
|
||||
func (p *Processor) validateExecuteContext(ctx context.Context, tx gdb.TX, in *model.TSimCardOutboundSubmitParam, operator batchimport.OperatorContext, rows []*Row, requiredSourceBatchNo string) error {
|
||||
normalizeSubmitParam(in)
|
||||
if err := validateSubmitParam(in); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.validateSelectablePackage(ctx, tx, in.PackageCode, operator.TenantId); err != nil {
|
||||
return err
|
||||
}
|
||||
iccids, err := validateOutboundIdentifiers(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(iccids) == 0 {
|
||||
return errOutboundValidationRejected
|
||||
}
|
||||
stockByIccid, err := p.repo.LockStockCardsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "校验出库库存卡失败")
|
||||
}
|
||||
formalByIccid, err := p.repo.GetExistingFormalIccids(ctx, iccids)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "校验正式卡失败")
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if row == nil || row.Reason != "" {
|
||||
continue
|
||||
}
|
||||
stock := stockByIccid[row.Iccid]
|
||||
reason := validateOutboundRow(row, stock, formalByIccid[row.Iccid], operator, p.opts.StrictStockIdentity, requiredSourceBatchNo)
|
||||
if reason != "" {
|
||||
row.AddReason(reason)
|
||||
}
|
||||
}
|
||||
|
||||
if hasInvalidRows(rows) {
|
||||
markRemainingRowsSkipped(rows)
|
||||
return errOutboundValidationRejected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOutboundIdentifiers(rows []*Row) ([]string, error) {
|
||||
seen := make(map[string]*Row, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
return nil, gerror.New("出库行不能为空")
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
if row.Iccid == "" {
|
||||
row.AddReason("ICCID不能为空")
|
||||
continue
|
||||
}
|
||||
if first, exists := seen[row.Iccid]; exists {
|
||||
row.AddReason(fmt.Sprintf("ICCID与第%d行重复", first.RowNo))
|
||||
first.AddReason(fmt.Sprintf("ICCID与第%d行重复", row.RowNo))
|
||||
continue
|
||||
}
|
||||
seen[row.Iccid] = row
|
||||
}
|
||||
iccids := make([]string, 0, len(seen))
|
||||
for iccid, row := range seen {
|
||||
if row.Reason == "" {
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
}
|
||||
return iccids, nil
|
||||
}
|
||||
|
||||
func (p *Processor) validateSelectablePackage(ctx context.Context, tx gdb.TX, packageCode, tenantId string) error {
|
||||
pkg, err := p.repo.LoadPackage(ctx, tx, tenantId, packageCode)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "校验出库套餐失败")
|
||||
}
|
||||
if pkg == nil {
|
||||
return gerror.New("出库套餐不存在")
|
||||
}
|
||||
if !isOutboundSelectablePackageType(pkg.PackageType) {
|
||||
return gerror.New("出库套餐必须是卡套餐或池套餐")
|
||||
}
|
||||
if pkg.Enabled != 1 {
|
||||
return gerror.New("出库套餐未启用,不能出库")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isOutboundSelectablePackageType(packageType int) bool {
|
||||
return packageType == outboundCardPackageType || packageType == outboundPoolPackageType
|
||||
}
|
||||
|
||||
func normalizeSubmitParam(in *model.TSimCardOutboundSubmitParam) {
|
||||
if in == nil {
|
||||
return
|
||||
}
|
||||
if in.DurationMonths <= 0 && in.DurationYears > 0 {
|
||||
in.DurationMonths = in.DurationYears*12 + in.DurationMonthPart
|
||||
}
|
||||
if in.DurationMonths <= 0 && in.DurationMonthPart > 0 {
|
||||
in.DurationMonths = in.DurationMonthPart
|
||||
}
|
||||
if in.DurationYears <= 0 && in.DurationMonthPart <= 0 && in.DurationMonths > 0 {
|
||||
in.DurationYears = in.DurationMonths / 12
|
||||
in.DurationMonthPart = in.DurationMonths % 12
|
||||
}
|
||||
if in.GiftDurationMonths <= 0 && in.GiftDurationYears > 0 {
|
||||
in.GiftDurationMonths = in.GiftDurationYears*12 + in.GiftDurationMonthPart
|
||||
}
|
||||
if in.GiftDurationMonths <= 0 && in.GiftDurationMonthPart > 0 {
|
||||
in.GiftDurationMonths = in.GiftDurationMonthPart
|
||||
}
|
||||
if in.GiftDurationYears <= 0 && in.GiftDurationMonthPart <= 0 && in.GiftDurationMonths > 0 {
|
||||
in.GiftDurationYears = in.GiftDurationMonths / 12
|
||||
in.GiftDurationMonthPart = in.GiftDurationMonths % 12
|
||||
}
|
||||
disablePeriodOptions(in)
|
||||
if remark := normalizedRemark(in); remark != "" {
|
||||
in.Remark = remark
|
||||
in.BuyerNote = remark
|
||||
in.AdminNote = remark
|
||||
}
|
||||
}
|
||||
|
||||
func disablePeriodOptions(in *model.TSimCardOutboundSubmitParam) {
|
||||
if in == nil {
|
||||
return
|
||||
}
|
||||
// 1 表示否。出库不再暴露测试期、沉默期配置,统一按无测试期/无沉默期保存。
|
||||
in.HasTestPeriod = 1
|
||||
in.TestPeriodDays = 0
|
||||
in.TestPeriodLatestExpiryDate = nil
|
||||
in.HasSilentPeriod = 1
|
||||
in.SilentPeriodDays = 0
|
||||
in.SilentPeriodLatestExpiryDate = nil
|
||||
}
|
||||
|
||||
func normalizedRemark(in *model.TSimCardOutboundSubmitParam) string {
|
||||
if in == nil {
|
||||
return ""
|
||||
}
|
||||
if value := strings.TrimSpace(in.Remark); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimSpace(in.BuyerNote); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(in.AdminNote)
|
||||
}
|
||||
|
||||
func (p *Processor) submitParams(params any) *model.TSimCardOutboundSubmitParam {
|
||||
if in := outboundSubmitParams(params); in != nil {
|
||||
return in
|
||||
}
|
||||
return p.excelSubmitParam
|
||||
}
|
||||
|
||||
func validateOutboundRow(row *Row, stock *entity.TSimCardStock, formalExists bool, operator batchimport.OperatorContext, strictStockIdentity bool, requiredSourceBatchNo string) string {
|
||||
var reasons []string
|
||||
if stock == nil {
|
||||
return "ICCID不存在库存记录,不能出库"
|
||||
}
|
||||
if stock.CreatedDept != operator.DeptId {
|
||||
reasons = append(reasons, "ICCID不属于当前机构库存,不能出库")
|
||||
}
|
||||
if requiredSourceBatchNo != "" && strings.TrimSpace(stock.BatchNo) != requiredSourceBatchNo {
|
||||
reasons = append(reasons, "ICCID不属于指定库存批次,不能出库")
|
||||
}
|
||||
if formalExists {
|
||||
reasons = append(reasons, "ICCID已存在正式卡记录,不能出库")
|
||||
}
|
||||
if strictStockIdentity {
|
||||
if strings.TrimSpace(row.Msisdn) != strings.TrimSpace(stock.Msisdn) {
|
||||
reasons = append(reasons, "MSISDN与库存记录不一致,不能出库")
|
||||
}
|
||||
if strings.TrimSpace(row.Imsi) != strings.TrimSpace(stock.Imsi) {
|
||||
reasons = append(reasons, "IMSI与库存记录不一致,不能出库")
|
||||
}
|
||||
}
|
||||
return strings.Join(reasons, ";")
|
||||
}
|
||||
|
||||
func hasInvalidRows(rows []*Row) bool {
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Reason != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func markRemainingRowsSkipped(rows []*Row) {
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Reason == "" {
|
||||
row.AddReason(outboundNotExecutedReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func outboundSubmitParams(params any) *model.TSimCardOutboundSubmitParam {
|
||||
switch in := params.(type) {
|
||||
case *model.TSimCardOutboundSubmitParam:
|
||||
return in
|
||||
case *model.TSimCardOutboundRangeSubmitParam:
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
return &in.TSimCardOutboundSubmitParam
|
||||
case *model.TSimCardOutboundInputSubmitParam:
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
return &in.TSimCardOutboundSubmitParam
|
||||
case *model.TSimCardOutboundBatchSubmitParam:
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
return &in.TSimCardOutboundSubmitParam
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func requiredSourceBatchNo(params any) string {
|
||||
if in, ok := params.(*model.TSimCardOutboundBatchSubmitParam); ok && in != nil {
|
||||
return strings.TrimSpace(in.BatchNo)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/cmd/inithttp"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func init() {
|
||||
inithttp.RegisterHttpInitFunc("outboundBatchQueue", func(ctx context.Context) error {
|
||||
if !rabbitmq.Enabled(ctx) {
|
||||
g.Log().Warning(ctx, "outbound batch queue is disabled because RabbitMQ is not configured")
|
||||
return nil
|
||||
}
|
||||
return ConsumeAsync(ctx)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/numrange"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common/cardsource"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
const maxRangeRows = 100000
|
||||
|
||||
func BuildRangeRows(ctx context.Context, rangeCodeType, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi string) ([]batchimport.Row, error) {
|
||||
if isLegacyFullIdentityRange(rangeCodeType, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi) {
|
||||
return buildFullIdentityRangeRows(ctx, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi)
|
||||
}
|
||||
|
||||
codeType, start, end, err := selectedRange(ctx, rangeCodeType, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := numrange.Build(ctx, start, end, strings.ToUpper(codeType), maxRangeRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, gerror.New("连号出库数据为空")
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(values))
|
||||
for i, value := range values {
|
||||
row := &Row{RowNo: i + 1, RangeCodeType: codeType}
|
||||
switch codeType {
|
||||
case "msisdn":
|
||||
row.Msisdn = value
|
||||
case "imsi":
|
||||
row.Imsi = value
|
||||
default:
|
||||
row.Iccid = value
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func buildFullIdentityRangeRows(ctx context.Context, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi string) ([]batchimport.Row, error) {
|
||||
return cardsource.BuildRangeRows(ctx, cardsource.RangeConfig{
|
||||
StartIccid: startIccid,
|
||||
EndIccid: endIccid,
|
||||
StartMsisdn: startMsisdn,
|
||||
EndMsisdn: endMsisdn,
|
||||
StartImsi: startImsi,
|
||||
EndImsi: endImsi,
|
||||
MaxRows: maxRangeRows,
|
||||
EmptyMessage: "连号出库数据为空",
|
||||
OverLimitMessage: "连号出库数量不能超过%d条",
|
||||
CountMismatchMessage: "ICCID号段数量(%d)、MSISDN号段数量(%d)、IMSI号段数量(%d)必须一致",
|
||||
}, func(rowNo int, iccid, msisdn, imsi string) *Row {
|
||||
return &Row{
|
||||
RowNo: rowNo,
|
||||
Iccid: iccid,
|
||||
Msisdn: msisdn,
|
||||
Imsi: imsi,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isLegacyFullIdentityRange(rangeCodeType, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi string) bool {
|
||||
return strings.TrimSpace(rangeCodeType) == "" &&
|
||||
strings.TrimSpace(startIccid) != "" && strings.TrimSpace(endIccid) != "" &&
|
||||
strings.TrimSpace(startMsisdn) != "" && strings.TrimSpace(endMsisdn) != "" &&
|
||||
strings.TrimSpace(startImsi) != "" && strings.TrimSpace(endImsi) != ""
|
||||
}
|
||||
|
||||
func selectedRange(ctx context.Context, rangeCodeType, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi string) (codeType, start, end string, err error) {
|
||||
_ = ctx
|
||||
codeType = normalizeRangeCodeType(rangeCodeType)
|
||||
if codeType == "" {
|
||||
codeType = inferRangeCodeType(startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi)
|
||||
}
|
||||
switch codeType {
|
||||
case "iccid":
|
||||
start, end = strings.TrimSpace(startIccid), strings.TrimSpace(endIccid)
|
||||
case "msisdn":
|
||||
start, end = strings.TrimSpace(startMsisdn), strings.TrimSpace(endMsisdn)
|
||||
case "imsi":
|
||||
start, end = strings.TrimSpace(startImsi), strings.TrimSpace(endImsi)
|
||||
default:
|
||||
return "", "", "", gerror.New("请选择连号号码类型")
|
||||
}
|
||||
if start == "" || end == "" {
|
||||
return "", "", "", gerror.Newf("%s起止号不能为空", strings.ToUpper(codeType))
|
||||
}
|
||||
return codeType, start, end, nil
|
||||
}
|
||||
|
||||
func normalizeRangeCodeType(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "iccid", "iccids":
|
||||
return "iccid"
|
||||
case "msisdn", "msisdns":
|
||||
return "msisdn"
|
||||
case "imsi", "imsis":
|
||||
return "imsi"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func inferRangeCodeType(startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi string) string {
|
||||
complete := func(start, end string) bool {
|
||||
return strings.TrimSpace(start) != "" && strings.TrimSpace(end) != ""
|
||||
}
|
||||
switch {
|
||||
case complete(startIccid, endIccid):
|
||||
return "iccid"
|
||||
case complete(startMsisdn, endMsisdn):
|
||||
return "msisdn"
|
||||
case complete(startImsi, endImsi):
|
||||
return "imsi"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
outboundDatabaseChunkSize = 5000
|
||||
outboundDatabaseInsertChunkSize = 500
|
||||
)
|
||||
|
||||
type Repository struct{}
|
||||
|
||||
type StockCard struct {
|
||||
Iccid string `orm:"iccid"`
|
||||
Msisdn string `orm:"msisdn"`
|
||||
Imsi string `orm:"imsi"`
|
||||
Imei string `orm:"imei"`
|
||||
ChanKey string `orm:"chan_key"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
}
|
||||
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
func (r *Repository) GetStockCardsByIccids(ctx context.Context, iccids []string) (map[string]*StockCard, error) {
|
||||
columns := dao.TSimCardStock.Columns()
|
||||
return r.getStockCardsByColumn(ctx, columns.Iccid, iccids)
|
||||
|
||||
}
|
||||
|
||||
func (r *Repository) GetStockCardsByMsisdns(ctx context.Context, msisdns []string) (map[string]*StockCard, error) {
|
||||
columns := dao.TSimCardStock.Columns()
|
||||
return r.getStockCardsByColumn(ctx, columns.Msisdn, msisdns)
|
||||
}
|
||||
|
||||
func (r *Repository) GetStockCardsByImsis(ctx context.Context, imsis []string) (map[string]*StockCard, error) {
|
||||
columns := dao.TSimCardStock.Columns()
|
||||
return r.getStockCardsByColumn(ctx, columns.Imsi, imsis)
|
||||
}
|
||||
|
||||
func (r *Repository) getStockCardsByColumn(ctx context.Context, column string, values []string) (map[string]*StockCard, error) {
|
||||
result := make(map[string]*StockCard)
|
||||
uniqueValues := uniqueStrings(values)
|
||||
if len(uniqueValues) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
columns := dao.TSimCardStock.Columns()
|
||||
for start := 0; start < len(uniqueValues); start += outboundDatabaseChunkSize {
|
||||
end := start + outboundDatabaseChunkSize
|
||||
if end > len(uniqueValues) {
|
||||
end = len(uniqueValues)
|
||||
}
|
||||
|
||||
var list []*StockCard
|
||||
if err := dao.TSimCardStock.Ctx(ctx).
|
||||
Fields(columns.Iccid, columns.Msisdn, columns.Imsi, columns.Imei, columns.ChanKey, columns.CreatedDept, columns.BatchNo).
|
||||
WhereIn(column, uniqueValues[start:end]).
|
||||
Scan(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
key := ""
|
||||
switch column {
|
||||
case columns.Msisdn:
|
||||
key = item.Msisdn
|
||||
case columns.Imsi:
|
||||
key = item.Imsi
|
||||
default:
|
||||
key = item.Iccid
|
||||
}
|
||||
if key != "" {
|
||||
result[key] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) LockStockCardsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*entity.TSimCardStock, error) {
|
||||
result := make(map[string]*entity.TSimCardStock)
|
||||
uniqueIccids := uniqueStrings(iccids)
|
||||
if len(uniqueIccids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for start := 0; start < len(uniqueIccids); start += outboundDatabaseChunkSize {
|
||||
end := start + outboundDatabaseChunkSize
|
||||
if end > len(uniqueIccids) {
|
||||
end = len(uniqueIccids)
|
||||
}
|
||||
|
||||
var list []*entity.TSimCardStock
|
||||
if err := tx.Ctx(ctx).Model(dao.TSimCardStock.Table()).
|
||||
WhereIn(dao.TSimCardStock.Columns().Iccid, uniqueIccids[start:end]).
|
||||
LockUpdate().
|
||||
Scan(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
if item != nil && item.Iccid != "" {
|
||||
result[item.Iccid] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetExistingFormalIccids(ctx context.Context, iccids []string) (map[string]bool, error) {
|
||||
result := make(map[string]bool)
|
||||
uniqueIccids := uniqueStrings(iccids)
|
||||
if len(uniqueIccids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
for start := 0; start < len(uniqueIccids); start += outboundDatabaseChunkSize {
|
||||
end := start + outboundDatabaseChunkSize
|
||||
if end > len(uniqueIccids) {
|
||||
end = len(uniqueIccids)
|
||||
}
|
||||
|
||||
var list []struct {
|
||||
Iccid string `orm:"iccid"`
|
||||
}
|
||||
if err := dao.TSimCardInfo.Ctx(ctx).
|
||||
Fields(columns.Iccid).
|
||||
WhereIn(columns.Iccid, uniqueIccids[start:end]).
|
||||
Scan(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
if item.Iccid != "" {
|
||||
result[item.Iccid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetInboundBatchInfos(ctx context.Context, tx gdb.TX, batchNos []string) (map[string]*gjson.Json, error) {
|
||||
result := make(map[string]*gjson.Json)
|
||||
uniqueBatchNos := uniqueStrings(batchNos)
|
||||
if len(uniqueBatchNos) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var list []struct {
|
||||
BatchNo string `orm:"batch_no"`
|
||||
BatchInfo *gjson.Json `orm:"batch_info"`
|
||||
}
|
||||
dbModel := dao.BatchMain.Ctx(ctx)
|
||||
if tx != nil {
|
||||
dbModel = tx.Ctx(ctx).Model(dao.BatchMain.Table()).Safe()
|
||||
}
|
||||
columns := dao.BatchMain.Columns()
|
||||
if err := dbModel.
|
||||
Fields(columns.BatchNo, columns.BatchInfo).
|
||||
WhereIn(columns.BatchNo, uniqueBatchNos).
|
||||
Scan(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
if item.BatchNo != "" {
|
||||
result[item.BatchNo] = item.BatchInfo
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) LoadPackage(ctx context.Context, tx gdb.TX, tenantId, packageCode string) (*entity.TSimFlowPackageInfo, error) {
|
||||
packageCode = strings.TrimSpace(packageCode)
|
||||
if packageCode == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mod := dao.TSimFlowPackageInfo.Ctx(ctx)
|
||||
if tx != nil {
|
||||
mod = tx.Ctx(ctx).Model(dao.TSimFlowPackageInfo.Table()).Safe()
|
||||
}
|
||||
mod = mod.Where(dao.TSimFlowPackageInfo.Columns().PackageCode, packageCode)
|
||||
if strings.TrimSpace(tenantId) != "" {
|
||||
mod = mod.Where(dao.TSimFlowPackageInfo.Columns().TenantId, strings.TrimSpace(tenantId))
|
||||
}
|
||||
|
||||
var pkg *entity.TSimFlowPackageInfo
|
||||
if err := mod.Scan(&pkg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type Row struct {
|
||||
RowNo int
|
||||
Iccid string
|
||||
Msisdn string
|
||||
Imsi string
|
||||
RangeCodeType string
|
||||
CustomerAgentId int64
|
||||
PackageCode string
|
||||
PackageBeginTime *gtime.Time
|
||||
DurationYears int
|
||||
DurationMonthPart int
|
||||
DurationMonths int
|
||||
GiftDurationYears int
|
||||
GiftDurationMonthPart int
|
||||
GiftDurationMonths int
|
||||
HasTestPeriod int
|
||||
TestPeriodDays int
|
||||
TestPeriodLatestExpiryDate *gtime.Time
|
||||
HasSilentPeriod int
|
||||
SilentPeriodDays int
|
||||
SilentPeriodLatestExpiryDate *gtime.Time
|
||||
Amount string
|
||||
OfflineContractCompleted bool
|
||||
Remark string
|
||||
SourceBatchNo string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (r *Row) Valid() bool {
|
||||
return r != nil && r.Reason == ""
|
||||
}
|
||||
|
||||
func (r *Row) AddReason(reason string) {
|
||||
if r == nil || r.Reason != "" || reason == "" {
|
||||
return
|
||||
}
|
||||
r.Reason = reason
|
||||
}
|
||||
|
||||
func (r *Row) GetRowNo() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
return r.RowNo
|
||||
}
|
||||
|
||||
func (r *Row) GetItemID() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.Iccid
|
||||
}
|
||||
|
||||
func (r *Row) GetErrorReasons() []string {
|
||||
if r == nil || r.Reason == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{r.Reason}
|
||||
}
|
||||
|
||||
func (r *Row) AddErrorReason(reason string) {
|
||||
r.AddReason(reason)
|
||||
}
|
||||
|
||||
func (r *Row) Snapshot() *gjson.Json {
|
||||
if r == nil {
|
||||
return gjson.New(g.Map{})
|
||||
}
|
||||
return gjson.New(g.Map{
|
||||
"ICCID": r.Iccid,
|
||||
"MSISDN": r.Msisdn,
|
||||
"IMSI": r.Imsi,
|
||||
"rangeCodeType": r.RangeCodeType,
|
||||
"customerAgentId": r.CustomerAgentId,
|
||||
"packageCode": r.PackageCode,
|
||||
"packageBeginTime": formatTime(r.PackageBeginTime),
|
||||
"durationYears": r.DurationYears,
|
||||
"durationMonthPart": r.DurationMonthPart,
|
||||
"durationMonths": r.DurationMonths,
|
||||
"giftDurationYears": r.GiftDurationYears,
|
||||
"giftDurationMonthPart": r.GiftDurationMonthPart,
|
||||
"giftDurationMonths": r.GiftDurationMonths,
|
||||
"amount": r.Amount,
|
||||
"offlineContractCompleted": r.OfflineContractCompleted,
|
||||
"remark": r.Remark,
|
||||
"batchNo": r.SourceBatchNo,
|
||||
})
|
||||
}
|
||||
|
||||
type PreviewOptions struct {
|
||||
DeptId int64
|
||||
StrictStockIdentity bool
|
||||
}
|
||||
|
||||
type CompleteInput struct {
|
||||
SubmitParam *model.TSimCardOutboundSubmitParam
|
||||
BatchNo string
|
||||
Rows []*Row
|
||||
Operator batchimport.OperatorContext
|
||||
StrictStockIdentity bool
|
||||
RequiredSourceBatchNo string
|
||||
}
|
||||
|
||||
type CompleteOutput struct {
|
||||
OrderId int64
|
||||
OrderSn string
|
||||
BatchNo string
|
||||
BatchType int
|
||||
Quantity int
|
||||
SourceBatchNos []string
|
||||
Customer *entity.SysDept
|
||||
Package *entity.TSimFlowPackageInfo
|
||||
PackageBeginAt *gtime.Time
|
||||
PackageEndAt *gtime.Time
|
||||
PackageStatus int
|
||||
EffectiveStatus int
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func uniqueStrings(list []string) []string {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(list))
|
||||
result := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type AsyncConfirmInput struct {
|
||||
BatchNo string
|
||||
TargetBatchType int
|
||||
DurationYears int
|
||||
DurationMonths int
|
||||
GiftDurationMonths int
|
||||
RenewDate string
|
||||
PackageBeginTime string
|
||||
PackageEndTime string
|
||||
OfflineContract bool
|
||||
BuyerNote string
|
||||
AdminNote string
|
||||
Remark string
|
||||
PriceSnapshot *model.TSimCardRenewPriceSnapshot
|
||||
PaymentSnapshot *model.TSimCardRenewPaymentSnapshot
|
||||
SourceBatchNo string
|
||||
Operator batchimport.OperatorContext
|
||||
}
|
||||
|
||||
type AsyncConfirmResult struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
BatchAsyncStatus int
|
||||
}
|
||||
|
||||
// ConvertPrepareToAsync 以 CAS 方式把 batch_type=4 + batch_async_status=Success + run_result=Success 的预处理批次
|
||||
// 转换为 batch_type=101 的待处理续费批次:重置主表正式执行相关字段并把明细的 BatchType 切换到 101、DetailStatus 重置为 Pending。
|
||||
func ConvertPrepareToAsync(ctx context.Context, tx gdb.TX, in *AsyncConfirmInput) (*AsyncConfirmResult, error) {
|
||||
if in == nil {
|
||||
return nil, gerror.New("续费确认参数不能为空")
|
||||
}
|
||||
batchNo := strings.TrimSpace(in.BatchNo)
|
||||
if batchNo == "" {
|
||||
return nil, gerror.New("续费批次号不能为空")
|
||||
}
|
||||
targetBatchType := in.TargetBatchType
|
||||
if targetBatchType == 0 {
|
||||
targetBatchType = consts.BatchTypeRenew
|
||||
}
|
||||
columns := dao.BatchMain.Columns()
|
||||
main := &entity.BatchMain{}
|
||||
err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(columns.BatchNo, batchNo).
|
||||
Where(columns.TenantId, contexts.GetTenantId(ctx)).
|
||||
Where(columns.CreatedDept, contexts.GetDeptId(ctx)).
|
||||
LockUpdate().
|
||||
Scan(main)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询续费预处理批次失败")
|
||||
}
|
||||
if main.Id == 0 {
|
||||
return nil, gerror.New("续费预处理批次不存在")
|
||||
}
|
||||
if main.BatchType != consts.BatchTypePrepare {
|
||||
return nil, gerror.New("续费批次不是预处理类型")
|
||||
}
|
||||
if main.BatchAsyncStatus != consts.BatchAsyncStatusSuccess {
|
||||
return nil, gerror.New("续费预处理批次尚未完成")
|
||||
}
|
||||
if main.RunResult != consts.BatchRunResultSuccess {
|
||||
return nil, gerror.New("续费预处理批次未成功")
|
||||
}
|
||||
if main.BatchInfo == nil {
|
||||
return nil, gerror.New("续费预处理批次缺少快照")
|
||||
}
|
||||
if main.BatchInfo.Get("targetBatchType").Int() != targetBatchType {
|
||||
return nil, gerror.New("续费预处理批次类型不匹配")
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
info := mergeConfirmBatchInfo(main.BatchInfo, in)
|
||||
data := do.BatchMain{
|
||||
BatchType: targetBatchType,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
RunResult: consts.BatchRunResultPending,
|
||||
StartTime: nil,
|
||||
EndTime: nil,
|
||||
FailReason: "",
|
||||
SuccessCount: 0,
|
||||
FailCount: 0,
|
||||
BatchInfo: gjson.New(info),
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
res, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(columns.Id, main.Id).
|
||||
Where(columns.BatchType, consts.BatchTypePrepare).
|
||||
Where(columns.BatchAsyncStatus, consts.BatchAsyncStatusSuccess).
|
||||
Where(columns.RunResult, consts.BatchRunResultSuccess).
|
||||
Data(data).
|
||||
Update()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "更新续费批次状态失败")
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "读取续费批次更新结果失败")
|
||||
}
|
||||
if affected != 1 {
|
||||
return nil, gerror.New("续费批次状态已变化,请重试")
|
||||
}
|
||||
|
||||
detailColumns := dao.BatchDetail.Columns()
|
||||
if _, err = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(detailColumns.BatchNo, batchNo).
|
||||
Where(detailColumns.BatchType, consts.BatchTypePrepare).
|
||||
Data(do.BatchDetail{
|
||||
BatchType: targetBatchType,
|
||||
DetailStatus: consts.BatchDetailStatusPending,
|
||||
ErrorCode: "",
|
||||
ErrorReason: "",
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: now,
|
||||
}).
|
||||
Update(); err != nil {
|
||||
return nil, gerror.Wrap(err, "更新续费批次明细类型失败")
|
||||
}
|
||||
|
||||
return &AsyncConfirmResult{
|
||||
BatchNo: batchNo,
|
||||
BatchType: targetBatchType,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mergeConfirmBatchInfo(base *gjson.Json, in *AsyncConfirmInput) g.Map {
|
||||
data := g.Map{}
|
||||
if base != nil {
|
||||
for key, value := range base.Map() {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
if in == nil {
|
||||
return data
|
||||
}
|
||||
durationValue := in.DurationYears
|
||||
if durationValue <= 0 {
|
||||
durationValue = renewDurationYearsFromLegacy(in.DurationMonths, 0, in.PriceSnapshot)
|
||||
}
|
||||
if durationValue > 0 {
|
||||
data["durationValue"] = durationValue
|
||||
data["durationYears"] = durationValue
|
||||
actualDurationMonths := renewActualDurationMonths(in.PriceSnapshot, durationValue)
|
||||
data["durationMonths"] = actualDurationMonths
|
||||
data["actualDurationMonths"] = actualDurationMonths
|
||||
}
|
||||
if in.GiftDurationMonths >= 0 {
|
||||
data["giftDurationMonths"] = in.GiftDurationMonths
|
||||
}
|
||||
if strings.TrimSpace(in.RenewDate) != "" {
|
||||
data["renewDate"] = strings.TrimSpace(in.RenewDate)
|
||||
}
|
||||
if strings.TrimSpace(in.PackageBeginTime) != "" {
|
||||
data["packageBeginTime"] = strings.TrimSpace(in.PackageBeginTime)
|
||||
}
|
||||
if strings.TrimSpace(in.PackageEndTime) != "" {
|
||||
data["packageEndTime"] = strings.TrimSpace(in.PackageEndTime)
|
||||
}
|
||||
data["offlineContractCompleted"] = in.OfflineContract
|
||||
if note := strings.TrimSpace(in.BuyerNote); note != "" {
|
||||
data["buyerNote"] = note
|
||||
}
|
||||
if note := strings.TrimSpace(in.AdminNote); note != "" {
|
||||
data["adminNote"] = note
|
||||
}
|
||||
if note := strings.TrimSpace(in.Remark); note != "" {
|
||||
data["remark"] = note
|
||||
}
|
||||
if in.PriceSnapshot != nil {
|
||||
data["priceSnapshot"] = in.PriceSnapshot
|
||||
data["packageCode"] = in.PriceSnapshot.PackageCode
|
||||
data["packageName"] = in.PriceSnapshot.PackageName
|
||||
data["renewUnitPrice"] = in.PriceSnapshot.RenewUnitPrice
|
||||
data["billingMethod"] = in.PriceSnapshot.BillingMethod
|
||||
data["renewDurationUnit"] = in.PriceSnapshot.RenewDurationUnit
|
||||
data["renewDurationUnitText"] = in.PriceSnapshot.RenewDurationUnitText
|
||||
data["priceSource"] = in.PriceSnapshot.PriceSource
|
||||
data["priceOwnerDeptId"] = in.PriceSnapshot.PriceOwnerDeptId
|
||||
data["agentDefaultPriceId"] = in.PriceSnapshot.AgentDefaultPriceId
|
||||
data["targetPriceId"] = in.PriceSnapshot.TargetPriceId
|
||||
data["renewableDurationOptions"] = in.PriceSnapshot.RenewableDurationOptions
|
||||
data["renewablePackageCodes"] = in.PriceSnapshot.RenewablePackageCodes
|
||||
data["renewablePackages"] = in.PriceSnapshot.RenewablePackages
|
||||
if durationValue > 0 {
|
||||
data["actualDurationMonths"] = renewActualDurationMonths(in.PriceSnapshot, durationValue)
|
||||
}
|
||||
}
|
||||
if in.PaymentSnapshot != nil {
|
||||
data["paymentMode"] = strings.TrimSpace(in.PaymentSnapshot.PaymentMode)
|
||||
data["payType"] = in.PaymentSnapshot.PayType
|
||||
data["payStatus"] = in.PaymentSnapshot.PayStatus
|
||||
data["payAmount"] = in.PaymentSnapshot.PayAmount
|
||||
data["balanceAmount"] = in.PaymentSnapshot.BalanceAmount
|
||||
data["offlinePaidAmount"] = in.PaymentSnapshot.OfflinePaidAmount
|
||||
data["balanceBefore"] = in.PaymentSnapshot.BalanceBefore
|
||||
data["balanceAfter"] = in.PaymentSnapshot.BalanceAfter
|
||||
data["balanceDetailSerialNo"] = strings.TrimSpace(in.PaymentSnapshot.BalanceDetailSerialNo)
|
||||
data["payerDeptId"] = in.PaymentSnapshot.PayerDeptId
|
||||
data["payerUserId"] = in.PaymentSnapshot.PayerUserId
|
||||
data["isTopDept"] = in.PaymentSnapshot.IsTopDept
|
||||
if in.PaymentSnapshot.PaymentTime != nil {
|
||||
data["paymentTime"] = in.PaymentSnapshot.PaymentTime.Format("Y-m-d H:i:s")
|
||||
}
|
||||
data["paymentSnapshot"] = in.PaymentSnapshot
|
||||
}
|
||||
if strings.TrimSpace(in.SourceBatchNo) != "" {
|
||||
data["sourceBatchNo"] = strings.TrimSpace(in.SourceBatchNo)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/cardpackage"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func (p *Processor) Complete(ctx context.Context, tx gdb.TX, in *CompleteInput) (*CompleteOutput, error) {
|
||||
if in == nil || in.SubmitParam == nil {
|
||||
return nil, gerror.New("续费参数不能为空")
|
||||
}
|
||||
sourceBatchNo := strings.TrimSpace(in.SubmitParam.SourceBatchNo)
|
||||
if in.BatchNo == "" {
|
||||
return nil, gerror.New("续费批次号不能为空")
|
||||
}
|
||||
if len(in.Rows) == 0 {
|
||||
return nil, gerror.New("续费卡不能为空")
|
||||
}
|
||||
|
||||
iccids := rowsToIccids(in.Rows)
|
||||
paymentSnapshot, err := executeRenewPayment(ctx, tx, paymentExecutionInput{
|
||||
BatchNo: in.BatchNo,
|
||||
PriceSnapshot: in.PriceSnapshot,
|
||||
Quantity: len(iccids),
|
||||
SubmitParam: in.SubmitParam,
|
||||
Operator: in.Operator,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.SubmitParam.PaymentSnapshot = paymentSnapshot
|
||||
in.SubmitParam.PaymentMode = paymentSnapshot.PaymentMode
|
||||
in.SubmitParam.Amount = formatRenewAmount(paymentSnapshot.PayAmount)
|
||||
|
||||
packageSvc := cardpackage.NewService()
|
||||
durationValue := renewDisplayDurationValue(in.SubmitParam)
|
||||
durationUnit := renewDurationUnitFromSnapshot(in.PriceSnapshot)
|
||||
actualDurationMonths := renewActualDurationMonths(in.PriceSnapshot, durationValue)
|
||||
if actualDurationMonths <= 0 {
|
||||
return nil, gerror.New("续费时长必须大于0")
|
||||
}
|
||||
packageBeginTime := strings.TrimSpace(in.SubmitParam.PackageBeginTime)
|
||||
var packageBeginAt *gtime.Time
|
||||
if strings.TrimSpace(packageBeginTime) != "" {
|
||||
packageBeginAt, err = gtime.StrToTime(packageBeginTime)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "续费开始日期格式不正确")
|
||||
}
|
||||
packageBeginAt = normalizeRenewDayEndTime(packageBeginAt)
|
||||
}
|
||||
packageEndTime := strings.TrimSpace(in.SubmitParam.PackageEndTime)
|
||||
var packageEndAt *gtime.Time
|
||||
if strings.TrimSpace(packageEndTime) != "" {
|
||||
packageEndAt, err = gtime.StrToTime(packageEndTime)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "续费结束日期格式不正确")
|
||||
}
|
||||
packageEndAt = normalizeRenewDayEndTime(packageEndAt)
|
||||
}
|
||||
renew, err := packageSvc.RenewCurrentPackage(ctx, tx, &cardpackage.RenewCurrentPackageInput{
|
||||
BatchNo: in.BatchNo,
|
||||
SourceBatchNo: sourceBatchNo,
|
||||
PackageCode: strings.TrimSpace(in.SubmitParam.PackageCode),
|
||||
Iccids: iccids,
|
||||
DurationMonths: actualDurationMonths,
|
||||
GiftDurationMonths: in.SubmitParam.GiftDurationMonths,
|
||||
DurationDisplayValue: durationValue,
|
||||
DurationUnit: durationUnit,
|
||||
PackageBeginTime: packageBeginAt,
|
||||
PackageEndTime: packageEndAt,
|
||||
Amount: in.SubmitParam.Amount,
|
||||
PayType: paymentSnapshot.PayType,
|
||||
PayStatus: paymentSnapshot.PayStatus,
|
||||
BalanceAmount: paymentSnapshot.BalanceAmount,
|
||||
OfflinePaidAmount: paymentSnapshot.OfflinePaidAmount,
|
||||
PayTime: paymentSnapshot.PaymentTime,
|
||||
OfflineContractCompleted: in.SubmitParam.OfflineContractCompleted,
|
||||
BuyerNote: in.SubmitParam.BuyerNote,
|
||||
AdminNote: in.SubmitParam.AdminNote,
|
||||
OrderRemark: "卡续费业务订单载体",
|
||||
BindRemark: in.BatchNo,
|
||||
Operator: in.Operator,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
detailUpdates := make(map[string]*gjsonJsonHolder, len(iccids))
|
||||
for _, row := range in.Rows {
|
||||
card := renew.CardsByIccid[row.Iccid]
|
||||
latestBind := renew.LatestBindsByIccid[row.Iccid]
|
||||
newBind := renew.NewBindsByIccid[row.Iccid]
|
||||
detailUpdates[row.Iccid] = &gjsonJsonHolder{Data: buildDetailSnapshot(row, card, latestBind, newBind, in, renew)}
|
||||
}
|
||||
if err = updateBatchDetailSnapshots(ctx, tx, in.BatchNo, detailUpdates, in.Operator.UserId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = convertBatchDetailsAfterComplete(ctx, tx, in.BatchNo, in.Operator.UserId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = updateBatchMainAfterComplete(ctx, tx, in, renew); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
renewUnitPrice := renew.UnitPrice
|
||||
if in.PriceSnapshot != nil {
|
||||
renewUnitPrice = in.PriceSnapshot.RenewUnitPrice
|
||||
}
|
||||
return &CompleteOutput{
|
||||
BatchNo: in.BatchNo,
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
SourceBatchNo: sourceBatchNo,
|
||||
Quantity: len(iccids),
|
||||
PackageCode: renew.PackageCode,
|
||||
PackageName: renew.Package.PackageName,
|
||||
OrderSn: renew.OrderSn,
|
||||
Amount: renew.Amount,
|
||||
RenewUnitPrice: renewUnitPrice,
|
||||
RenewDate: in.RenewDate,
|
||||
RenewTime: renew.RenewTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeRenewDayEndTime(t *gtime.Time) *gtime.Time {
|
||||
if t == nil || t.IsZero() {
|
||||
return t
|
||||
}
|
||||
tt := t.Time
|
||||
return gtime.NewFromTime(time.Date(tt.Year(), tt.Month(), tt.Day(), 23, 59, 59, 0, tt.Location()))
|
||||
}
|
||||
|
||||
type gjsonJsonHolder struct {
|
||||
Data any
|
||||
}
|
||||
|
||||
func rowsToIccids(rows []*Row) []string {
|
||||
iccids := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(row.Iccid)
|
||||
if iccid != "" {
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
}
|
||||
return uniqueStrings(iccids)
|
||||
}
|
||||
|
||||
func updateBatchDetailSnapshots(ctx context.Context, tx gdb.TX, batchNo string, snapshots map[string]*gjsonJsonHolder, userId int64) error {
|
||||
for iccid, snapshot := range snapshots {
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().ItemId, iccid).
|
||||
Data(do.BatchDetail{
|
||||
Snapshot: gjson.New(snapshot.Data),
|
||||
UpdatedBy: userId,
|
||||
UpdatedAt: gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新续费批次明细快照失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateBatchMainAfterComplete(ctx context.Context, tx gdb.TX, in *CompleteInput, renew *cardpackage.RenewCurrentPackageOutput) error {
|
||||
durationValue := renewDisplayDurationValue(in.SubmitParam)
|
||||
actualDurationMonths := renewActualDurationMonths(in.PriceSnapshot, durationValue)
|
||||
info := g.Map{
|
||||
"orderSn": renew.OrderSn,
|
||||
"packageCode": renew.PackageCode,
|
||||
"packageName": renew.Package.PackageName,
|
||||
"packageType": renew.Package.PackageType,
|
||||
"durationYears": in.SubmitParam.DurationYears,
|
||||
"durationMonths": actualDurationMonths,
|
||||
"durationValue": durationValue,
|
||||
"actualDurationMonths": actualDurationMonths,
|
||||
"giftDurationMonths": in.SubmitParam.GiftDurationMonths,
|
||||
"amount": renew.Amount,
|
||||
"quantity": renew.Quantity,
|
||||
"renewDate": in.RenewDate,
|
||||
"packageBeginTime": in.SubmitParam.PackageBeginTime,
|
||||
"packageEndTime": in.SubmitParam.PackageEndTime,
|
||||
"offlineContractCompleted": in.SubmitParam.OfflineContractCompleted,
|
||||
"renewTime": renew.RenewTime,
|
||||
}
|
||||
if sourceBatchNo := strings.TrimSpace(in.SubmitParam.SourceBatchNo); sourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = sourceBatchNo
|
||||
}
|
||||
if buyerNote := strings.TrimSpace(in.SubmitParam.BuyerNote); buyerNote != "" {
|
||||
info["buyerNote"] = buyerNote
|
||||
}
|
||||
if adminNote := strings.TrimSpace(in.SubmitParam.AdminNote); adminNote != "" {
|
||||
info["adminNote"] = adminNote
|
||||
}
|
||||
if remark := strings.TrimSpace(in.SubmitParam.Remark); remark != "" {
|
||||
info["remark"] = remark
|
||||
}
|
||||
if in.PriceSnapshot != nil {
|
||||
info["customerAgentId"] = in.PriceSnapshot.CustomerAgentId
|
||||
info["billingMethod"] = in.PriceSnapshot.BillingMethod
|
||||
info["renewDurationUnit"] = in.PriceSnapshot.RenewDurationUnit
|
||||
info["renewDurationUnitText"] = in.PriceSnapshot.RenewDurationUnitText
|
||||
info["priceOwnerDeptId"] = in.PriceSnapshot.PriceOwnerDeptId
|
||||
info["priceSource"] = in.PriceSnapshot.PriceSource
|
||||
info["agentDefaultPriceId"] = in.PriceSnapshot.AgentDefaultPriceId
|
||||
info["targetPriceId"] = in.PriceSnapshot.TargetPriceId
|
||||
info["renewUnitPrice"] = in.PriceSnapshot.RenewUnitPrice
|
||||
info["renewableDurationOptions"] = in.PriceSnapshot.RenewableDurationOptions
|
||||
info["renewablePackageCodes"] = in.PriceSnapshot.RenewablePackageCodes
|
||||
info["renewablePackages"] = in.PriceSnapshot.RenewablePackages
|
||||
}
|
||||
if in.SubmitParam.PaymentSnapshot != nil {
|
||||
appendRenewPaymentSnapshot(info, in.SubmitParam.PaymentSnapshot)
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, in.BatchNo).
|
||||
Data(do.BatchMain{
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
OrderSn: renew.OrderSn,
|
||||
TargetDeptId: renew.Customer.DeptId,
|
||||
BatchInfo: gjson.New(info),
|
||||
UpdatedBy: in.Operator.UserId,
|
||||
UpdatedAt: renew.RenewTime,
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新续费批次失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertBatchDetailsAfterComplete(ctx context.Context, tx gdb.TX, batchNo string, userId int64) error {
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Data(do.BatchDetail{
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
UpdatedBy: userId,
|
||||
UpdatedAt: gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新续费批次明细类型失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDetailSnapshot(row *Row, card *entity.TSimCardInfo, latestBind, newBind *entity.TSimCardBindPkg, in *CompleteInput, renew *cardpackage.RenewCurrentPackageOutput) g.Map {
|
||||
durationValue := renewDisplayDurationValue(in.SubmitParam)
|
||||
actualDurationMonths := renewActualDurationMonths(in.PriceSnapshot, durationValue)
|
||||
data := g.Map{
|
||||
"ICCID": card.Iccid,
|
||||
"MSISDN": card.Msisdn,
|
||||
"IMSI": card.Imsi,
|
||||
"renewBatchNo": in.BatchNo,
|
||||
"packageCode": renew.PackageCode,
|
||||
"packageName": renew.Package.PackageName,
|
||||
"oldSubscriptionNo": latestBind.SubscriptionNo,
|
||||
"newSubscriptionNo": newBind.SubscriptionNo,
|
||||
"orderSn": renew.OrderSn,
|
||||
"oldExpectedExpireTime": formatTime(latestBind.ExpectedExpireTime),
|
||||
"renewEffectiveTime": formatTime(newBind.EffectiveTime),
|
||||
"renewExpectedExpireTime": formatTime(newBind.ExpectedExpireTime),
|
||||
"cardFlowPkgEndAt": formatTime(newBind.ExpectedExpireTime),
|
||||
"durationYears": in.SubmitParam.DurationYears,
|
||||
"durationMonths": actualDurationMonths,
|
||||
"durationValue": durationValue,
|
||||
"actualDurationMonths": actualDurationMonths,
|
||||
"giftDurationMonths": in.SubmitParam.GiftDurationMonths,
|
||||
"renewDate": in.RenewDate,
|
||||
"packageBeginTime": in.SubmitParam.PackageBeginTime,
|
||||
"packageEndTime": in.SubmitParam.PackageEndTime,
|
||||
"amount": renew.Amount,
|
||||
"rowNo": row.RowNo,
|
||||
}
|
||||
if sourceBatchNo := strings.TrimSpace(in.SubmitParam.SourceBatchNo); sourceBatchNo != "" {
|
||||
data["sourceBatchNo"] = sourceBatchNo
|
||||
}
|
||||
if in.PriceSnapshot != nil {
|
||||
data["renewUnitPrice"] = in.PriceSnapshot.RenewUnitPrice
|
||||
data["billingMethod"] = in.PriceSnapshot.BillingMethod
|
||||
data["renewDurationUnit"] = in.PriceSnapshot.RenewDurationUnit
|
||||
data["renewDurationUnitText"] = in.PriceSnapshot.RenewDurationUnitText
|
||||
data["priceOwnerDeptId"] = in.PriceSnapshot.PriceOwnerDeptId
|
||||
data["priceSource"] = in.PriceSnapshot.PriceSource
|
||||
}
|
||||
if snapshot := in.SubmitParam.PaymentSnapshot; snapshot != nil {
|
||||
data["paymentMode"] = snapshot.PaymentMode
|
||||
data["payType"] = snapshot.PayType
|
||||
data["payAmount"] = snapshot.PayAmount
|
||||
data["balanceAmount"] = snapshot.BalanceAmount
|
||||
data["balanceBefore"] = snapshot.BalanceBefore
|
||||
data["balanceAfter"] = snapshot.BalanceAfter
|
||||
data["balanceDetailSerialNo"] = snapshot.BalanceDetailSerialNo
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func formatTime(t *gtime.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("Y-m-d H:i:s")
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
)
|
||||
|
||||
type ExcelProcessor struct {
|
||||
*Processor
|
||||
options *ExcelSubmitOptions
|
||||
}
|
||||
|
||||
func NewExcelProcessor() *ExcelProcessor {
|
||||
return &ExcelProcessor{
|
||||
Processor: NewProcessor(),
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareExcelProcessor parses the customer-facing Excel format but persists
|
||||
// an internal prepare batch. The worker performs the database validation before
|
||||
// the user can confirm the formal renew batch.
|
||||
type PrepareExcelProcessor struct {
|
||||
parser *ExcelProcessor
|
||||
prepare *PrepareProcessor
|
||||
}
|
||||
|
||||
func NewPrepareExcelProcessor() *PrepareExcelProcessor {
|
||||
return &PrepareExcelProcessor{
|
||||
parser: NewExcelProcessor(),
|
||||
prepare: NewPrepareProcessor(),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ExcelProcessor) Config() batchimport.ProcessorConfig {
|
||||
cfg := p.Processor.Config()
|
||||
cfg.TemplateHeaders = ExcelTemplateHeaders()
|
||||
cfg.RequiredHeaders = []string{"ICCID"}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (p *ExcelProcessor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
rows, options, err := ParseExcelFileRows(ctx, filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.options = options
|
||||
|
||||
out := make([]batchimport.Row, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *ExcelProcessor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
|
||||
return p.Processor.Execute(ctx, tx, req, rows)
|
||||
}
|
||||
|
||||
func (p *ExcelProcessor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
|
||||
return p.Processor.BuildBatchInfo(ctx, req, result, importOss, resultOss, downloadId)
|
||||
}
|
||||
|
||||
func (p *ExcelProcessor) ExcelSubmitOptions() *ExcelSubmitOptions {
|
||||
return p.options
|
||||
}
|
||||
|
||||
func (p *PrepareExcelProcessor) Config() batchimport.ProcessorConfig {
|
||||
cfg := p.prepare.Config()
|
||||
cfg.TemplateHeaders = ExcelTemplateHeaders()
|
||||
cfg.RequiredHeaders = []string{"ICCID"}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (p *PrepareExcelProcessor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return p.parser.ParseRows(ctx, filePath)
|
||||
}
|
||||
|
||||
func (p *PrepareExcelProcessor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
|
||||
// Keep submit non-blocking; the worker owns prepare validation.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PrepareExcelProcessor) ExcelSubmitOptions() *ExcelSubmitOptions {
|
||||
return p.parser.ExcelSubmitOptions()
|
||||
}
|
||||
|
||||
func (p *PrepareExcelProcessor) BuildBatchInfo(
|
||||
ctx context.Context,
|
||||
req batchimport.SubmitRequest,
|
||||
result *batchimport.Result,
|
||||
importOss *model.SysOssSaveContentModel,
|
||||
resultOss *model.SysOssSaveContentModel,
|
||||
downloadId int64,
|
||||
) *gjson.Json {
|
||||
return p.prepare.BuildBatchInfo(ctx, req, result, importOss, resultOss, downloadId)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
type renewIdentityResolver struct {
|
||||
byValue map[string]map[int64]*entity.TSimCardInfo
|
||||
}
|
||||
|
||||
var renewIdentityResolverLoader = loadRenewIdentityResolver
|
||||
|
||||
func newRenewIdentityResolverFromCards(cards []*entity.TSimCardInfo) *renewIdentityResolver {
|
||||
resolver := &renewIdentityResolver{
|
||||
byValue: make(map[string]map[int64]*entity.TSimCardInfo),
|
||||
}
|
||||
for _, card := range cards {
|
||||
resolver.addCard(card)
|
||||
}
|
||||
return resolver
|
||||
}
|
||||
|
||||
func loadRenewIdentityResolver(ctx context.Context, rows []*Row) (*renewIdentityResolver, error) {
|
||||
keys := collectRenewIdentityKeys(rows)
|
||||
resolver := newRenewIdentityResolverFromCards(nil)
|
||||
if len(keys) == 0 {
|
||||
return resolver, nil
|
||||
}
|
||||
|
||||
for _, column := range []string{
|
||||
dao.TSimCardInfo.Columns().Iccid,
|
||||
dao.TSimCardInfo.Columns().Msisdn,
|
||||
dao.TSimCardInfo.Columns().Imsi,
|
||||
} {
|
||||
cards, err := queryRenewIdentityCardsByColumn(ctx, column, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, card := range cards {
|
||||
resolver.addCard(card)
|
||||
}
|
||||
}
|
||||
return resolver, nil
|
||||
}
|
||||
|
||||
func queryRenewIdentityCardsByColumn(ctx context.Context, column string, values []string) ([]*entity.TSimCardInfo, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var cards []*entity.TSimCardInfo
|
||||
if err := service.IotTSimCardInfo().Model(ctx).
|
||||
Fields(
|
||||
dao.TSimCardInfo.Columns().Id,
|
||||
dao.TSimCardInfo.Columns().Iccid,
|
||||
dao.TSimCardInfo.Columns().Msisdn,
|
||||
dao.TSimCardInfo.Columns().Imsi,
|
||||
).
|
||||
WhereIn(column, values).
|
||||
Scan(&cards); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询续费卡信息失败")
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func collectRenewIdentityKeys(rows []*Row) []string {
|
||||
keys := make([]string, 0, len(rows)*3)
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
keys = append(keys,
|
||||
strings.TrimSpace(row.Iccid),
|
||||
strings.TrimSpace(row.Msisdn),
|
||||
strings.TrimSpace(row.Imsi),
|
||||
)
|
||||
}
|
||||
return uniqueStrings(keys)
|
||||
}
|
||||
|
||||
func (r *renewIdentityResolver) addCard(card *entity.TSimCardInfo) {
|
||||
if r == nil || card == nil || card.Id <= 0 {
|
||||
return
|
||||
}
|
||||
r.addValue(strings.TrimSpace(card.Iccid), card)
|
||||
r.addValue(strings.TrimSpace(card.Msisdn), card)
|
||||
r.addValue(strings.TrimSpace(card.Imsi), card)
|
||||
}
|
||||
|
||||
func (r *renewIdentityResolver) addValue(value string, card *entity.TSimCardInfo) {
|
||||
if r == nil || card == nil || card.Id <= 0 || value == "" {
|
||||
return
|
||||
}
|
||||
if r.byValue == nil {
|
||||
r.byValue = make(map[string]map[int64]*entity.TSimCardInfo)
|
||||
}
|
||||
cards := r.byValue[value]
|
||||
if cards == nil {
|
||||
cards = make(map[int64]*entity.TSimCardInfo)
|
||||
r.byValue[value] = cards
|
||||
}
|
||||
cards[card.Id] = card
|
||||
}
|
||||
|
||||
func (r *renewIdentityResolver) resolveRow(row *Row) {
|
||||
if r == nil || row == nil {
|
||||
return
|
||||
}
|
||||
identities := uniqueStrings([]string{
|
||||
strings.TrimSpace(row.Iccid),
|
||||
strings.TrimSpace(row.Msisdn),
|
||||
strings.TrimSpace(row.Imsi),
|
||||
})
|
||||
if len(identities) == 0 {
|
||||
row.AddReason("ICCID/MSISDN/IMSI不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
card, err := r.resolve(identities)
|
||||
if err != nil {
|
||||
row.AddReason(err.Error())
|
||||
return
|
||||
}
|
||||
if card == nil {
|
||||
row.AddReason("卡号不存在或无权限访问")
|
||||
return
|
||||
}
|
||||
for _, value := range identities {
|
||||
if !cardIdentityMatches(card, value) {
|
||||
row.AddReason("ICCID/MSISDN/IMSI与卡信息不一致")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
row.Iccid = strings.TrimSpace(card.Iccid)
|
||||
row.Msisdn = strings.TrimSpace(card.Msisdn)
|
||||
row.Imsi = strings.TrimSpace(card.Imsi)
|
||||
}
|
||||
|
||||
func (r *renewIdentityResolver) resolve(values []string) (*entity.TSimCardInfo, error) {
|
||||
if r == nil || len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
matches := make(map[int64]*entity.TSimCardInfo)
|
||||
for _, value := range uniqueStrings(values) {
|
||||
for id, card := range r.byValue[strings.TrimSpace(value)] {
|
||||
matches[id] = card
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return nil, nil
|
||||
case 1:
|
||||
for _, card := range matches {
|
||||
return card, nil
|
||||
}
|
||||
}
|
||||
return nil, gerror.New("卡号对应多条卡记录,请输入 ICCID")
|
||||
}
|
||||
|
||||
func cardIdentityMatches(card *entity.TSimCardInfo, value string) bool {
|
||||
if card == nil {
|
||||
return false
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
return value == strings.TrimSpace(card.Iccid) ||
|
||||
value == strings.TrimSpace(card.Msisdn) ||
|
||||
value == strings.TrimSpace(card.Imsi)
|
||||
}
|
||||
|
||||
func normalizeRenewIdentityRows(ctx context.Context, rows []*Row) error {
|
||||
resolver, err := renewIdentityResolverLoader(ctx, rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
resolver.resolveRow(row)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
func ParseInputRows(ctx context.Context, input string) ([]batchimport.Row, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New("批量输入续费数据为空")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
renewRows := make([]*Row, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
renewRows = append(renewRows, parseRenewInputLine(line))
|
||||
}
|
||||
|
||||
rows := make([]batchimport.Row, 0, len(renewRows))
|
||||
for _, row := range renewRows {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func parseRenewInputLine(line text.FieldLine) *Row {
|
||||
row := &Row{RowNo: line.RowNo}
|
||||
switch len(line.Fields) {
|
||||
case 1:
|
||||
row.InputCode = strings.TrimSpace(line.Fields[0])
|
||||
row.Iccid = row.InputCode
|
||||
case 3, 4:
|
||||
row.Iccid = strings.TrimSpace(line.Fields[0])
|
||||
row.Msisdn = strings.TrimSpace(line.Fields[1])
|
||||
row.Imsi = strings.TrimSpace(line.Fields[2])
|
||||
row.InputCode = firstNonEmpty(row.Iccid, row.Msisdn, row.Imsi)
|
||||
default:
|
||||
row.AddReason("格式错误,应为 ICCID/MSISDN/IMSI 任一号码,或 ICCID MSISDN IMSI")
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
type ExcelSubmitOptions struct {
|
||||
DurationYears int
|
||||
PaymentMode string
|
||||
}
|
||||
|
||||
func ExcelTemplateHeaders() []string {
|
||||
return []string{
|
||||
"ICCID",
|
||||
"MSISDN",
|
||||
"IMSI",
|
||||
"续费年限",
|
||||
"是否走线下",
|
||||
}
|
||||
}
|
||||
|
||||
func (o *ExcelSubmitOptions) ToBatchSubmitParam() *model.TSimCardRenewBatchSubmitParam {
|
||||
if o == nil {
|
||||
return &model.TSimCardRenewBatchSubmitParam{}
|
||||
}
|
||||
return &model.TSimCardRenewBatchSubmitParam{
|
||||
DurationYears: o.DurationYears,
|
||||
DurationMonths: o.DurationYears * 12,
|
||||
PaymentMode: strings.TrimSpace(o.PaymentMode),
|
||||
OfflineContractCompleted: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *ExcelSubmitOptions) ToBatchConfirmParam(batchNo string) *model.TSimCardRenewBatchConfirmParam {
|
||||
if o == nil {
|
||||
return &model.TSimCardRenewBatchConfirmParam{BatchNo: strings.TrimSpace(batchNo), OfflineContractCompleted: true}
|
||||
}
|
||||
return &model.TSimCardRenewBatchConfirmParam{
|
||||
BatchNo: strings.TrimSpace(batchNo),
|
||||
DurationYears: o.DurationYears,
|
||||
DurationMonths: o.DurationYears * 12,
|
||||
PaymentMode: strings.TrimSpace(o.PaymentMode),
|
||||
OfflineContractCompleted: true,
|
||||
}
|
||||
}
|
||||
|
||||
func ParseExcelFileRows(ctx context.Context, filePath string) ([]*Row, *ExcelSubmitOptions, error) {
|
||||
parsedRows, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{
|
||||
RequiredHeaders: []string{"ICCID"},
|
||||
SingleSheet: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rows := make([]*Row, 0, len(parsedRows))
|
||||
var options *ExcelSubmitOptions
|
||||
inconsistent := false
|
||||
|
||||
for _, parsed := range parsedRows {
|
||||
row := &Row{
|
||||
RowNo: parsed.RowNo,
|
||||
Iccid: excel.Field(parsed, "ICCID"),
|
||||
Msisdn: excel.Field(parsed, "MSISDN"),
|
||||
Imsi: excel.Field(parsed, "IMSI"),
|
||||
InputCode: firstNonEmpty(
|
||||
excel.Field(parsed, "ICCID"),
|
||||
excel.Field(parsed, "MSISDN"),
|
||||
excel.Field(parsed, "IMSI"),
|
||||
),
|
||||
}
|
||||
rowOptions, reasons := parseExcelSubmitOptions(parsed)
|
||||
if len(reasons) > 0 {
|
||||
row.AddReason(strings.Join(reasons, "; "))
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
if options == nil {
|
||||
options = rowOptions
|
||||
} else if !sameExcelSubmitOptions(options, rowOptions) {
|
||||
row.AddReason("同一批续费年限和是否走线下必须一致")
|
||||
inconsistent = true
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
if inconsistent {
|
||||
markRowsWithoutReason(rows, "同一批续费年限和是否走线下必须一致")
|
||||
}
|
||||
return rows, options, nil
|
||||
}
|
||||
|
||||
func parseExcelSubmitOptions(row excel.ParsedRow) (*ExcelSubmitOptions, []string) {
|
||||
var reasons []string
|
||||
|
||||
durationYears, err := parseExcelDurationYears(row)
|
||||
if err != nil {
|
||||
reasons = append(reasons, err.Error())
|
||||
}
|
||||
paymentMode, err := parseExcelPaymentMode(row)
|
||||
if err != nil {
|
||||
reasons = append(reasons, err.Error())
|
||||
}
|
||||
if len(reasons) > 0 {
|
||||
return nil, reasons
|
||||
}
|
||||
|
||||
return &ExcelSubmitOptions{
|
||||
DurationYears: durationYears,
|
||||
PaymentMode: paymentMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseExcelDurationYears(row excel.ParsedRow) (int, error) {
|
||||
if value := strings.TrimSpace(excelField(row, "续费年限(年)")); value != "" {
|
||||
years, err := parsePositiveInt(value)
|
||||
if err != nil {
|
||||
return 0, errors.New("续费年限必须为正整数")
|
||||
}
|
||||
return years, nil
|
||||
}
|
||||
if value := strings.TrimSpace(excelField(row, "续费年限")); value != "" {
|
||||
years, err := parsePositiveInt(value)
|
||||
if err != nil {
|
||||
return 0, errors.New("续费年限必须为正整数")
|
||||
}
|
||||
return years, nil
|
||||
}
|
||||
if value := strings.TrimSpace(excelField(row, "续费时长")); value != "" {
|
||||
years, err := parsePositiveInt(value)
|
||||
if err != nil {
|
||||
return 0, errors.New("续费年限必须为正整数")
|
||||
}
|
||||
return years, nil
|
||||
}
|
||||
if value := strings.TrimSpace(excelField(row, "durationMonths")); value != "" {
|
||||
years, err := parsePositiveInt(value)
|
||||
if err != nil {
|
||||
return 0, errors.New("续费年限必须为正整数")
|
||||
}
|
||||
return years, nil
|
||||
}
|
||||
return 0, errors.New("续费年限不能为空")
|
||||
}
|
||||
|
||||
func parseExcelPaymentMode(row excel.ParsedRow) (string, error) {
|
||||
value := strings.TrimSpace(excelField(row, "是否走线下"))
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(excelField(row, "付款方式"))
|
||||
}
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(excelField(row, "paymentMode"))
|
||||
}
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "y", "yes", "true", "offline", "线下", "走线下", "是":
|
||||
return "offline", nil
|
||||
case "0", "n", "no", "false", "balance", "余额", "余额支付", "否":
|
||||
return "balance", nil
|
||||
default:
|
||||
return "", errors.New("是否走线下格式不正确")
|
||||
}
|
||||
}
|
||||
|
||||
func excelField(row excel.ParsedRow, name string) string {
|
||||
return excel.Field(row, name)
|
||||
}
|
||||
|
||||
func parsePositiveInt(value string) (int, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed <= 0 {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func sameExcelSubmitOptions(left, right *ExcelSubmitOptions) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return left.DurationYears == right.DurationYears && strings.TrimSpace(left.PaymentMode) == strings.TrimSpace(right.PaymentMode)
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestExcelTemplateHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := []string{
|
||||
"ICCID",
|
||||
"MSISDN",
|
||||
"IMSI",
|
||||
"续费年限",
|
||||
"是否走线下",
|
||||
}
|
||||
if !reflect.DeepEqual(ExcelTemplateHeaders(), expected) {
|
||||
t.Fatalf("unexpected headers: %#v", ExcelTemplateHeaders())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewDurationValueToMonths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
unit string
|
||||
value int
|
||||
want int
|
||||
}{
|
||||
{name: "one month", unit: consts.RenewDurationUnitMonth, value: 1, want: 1},
|
||||
{name: "one year", unit: consts.RenewDurationUnitYear, value: 1, want: 12},
|
||||
{name: "unknown unit defaults to year", unit: "unknown", value: 2, want: 24},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := consts.DurationValueToMonths(test.unit, test.value); got != test.want {
|
||||
t.Fatalf("DurationValueToMonths(%q, %d) = %d, want %d", test.unit, test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewDisplayDurationAndAmount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yearSnapshot := &model.TSimCardRenewPriceSnapshot{
|
||||
RenewDurationUnit: consts.RenewDurationUnitYear,
|
||||
RenewUnitPrice: 10,
|
||||
}
|
||||
yearParam := &model.TSimCardRenewBatchSubmitParam{DurationYears: 1}
|
||||
if got := renewDisplayDurationValue(yearParam); got != 1 {
|
||||
t.Fatalf("renewDisplayDurationValue(year) = %d, want 1", got)
|
||||
}
|
||||
if got := renewActualDurationMonths(yearSnapshot, renewDisplayDurationValue(yearParam)); got != 12 {
|
||||
t.Fatalf("renewActualDurationMonths(year) = %d, want 12", got)
|
||||
}
|
||||
yearAmount, err := resolveRenewAmount(yearSnapshot, yearParam, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRenewAmount(year) failed: %v", err)
|
||||
}
|
||||
if yearAmount != 20 {
|
||||
t.Fatalf("resolveRenewAmount(year) = %.2f, want 20.00", yearAmount)
|
||||
}
|
||||
|
||||
monthSnapshot := &model.TSimCardRenewPriceSnapshot{
|
||||
RenewDurationUnit: consts.RenewDurationUnitMonth,
|
||||
RenewUnitPrice: 10,
|
||||
}
|
||||
monthParam := &model.TSimCardRenewBatchSubmitParam{DurationMonths: 1}
|
||||
if got := renewActualDurationMonths(monthSnapshot, renewDisplayDurationValue(monthParam)); got != 12 {
|
||||
t.Fatalf("renewActualDurationMonths(month snapshot) = %d, want 12", got)
|
||||
}
|
||||
actualMonthParam := &model.TSimCardRenewBatchSubmitParam{DurationMonths: 12}
|
||||
if got := renewDisplayDurationValue(actualMonthParam); got != 1 {
|
||||
t.Fatalf("renewDisplayDurationValue(actual months legacy) = %d, want 1", got)
|
||||
}
|
||||
monthAmount, err := resolveRenewAmount(monthSnapshot, monthParam, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRenewAmount(month) failed: %v", err)
|
||||
}
|
||||
if monthAmount != 20 {
|
||||
t.Fatalf("resolveRenewAmount(month) = %.2f, want 20.00", monthAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExecutionOptionsKeepsDisplayDurationValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &model.TSimCardRenewBatchSubmitParam{}
|
||||
applyExecutionOptions(in, &executionOptions{
|
||||
DurationYears: 2,
|
||||
DurationMonths: 2,
|
||||
PaymentMode: renewPaymentModeBalance,
|
||||
})
|
||||
if in.DurationYears != 2 || in.DurationMonths != 2 {
|
||||
t.Fatalf("duration should keep display value: %#v", in)
|
||||
}
|
||||
if got := renewDisplayDurationValue(in); got != 2 {
|
||||
t.Fatalf("renewDisplayDurationValue = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewDeptAncestorsContain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ancestors string
|
||||
deptID int64
|
||||
want bool
|
||||
}{
|
||||
{name: "contains direct ancestor", ancestors: "0,100,200", deptID: 100, want: true},
|
||||
{name: "contains with spaces", ancestors: "0, 100 ,200", deptID: 100, want: true},
|
||||
{name: "does not match partial id", ancestors: "0,1001,200", deptID: 100, want: false},
|
||||
{name: "empty ancestors", ancestors: "", deptID: 100, want: false},
|
||||
{name: "invalid dept", ancestors: "0,100,200", deptID: 0, want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := renewDeptAncestorsContain(test.ancestors, test.deptID); got != test.want {
|
||||
t.Fatalf("renewDeptAncestorsContain(%q, %d) = %v, want %v", test.ancestors, test.deptID, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsAndOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "renew.xlsx")
|
||||
f := excelize.NewFile()
|
||||
headers := ExcelTemplateHeaders()
|
||||
if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil {
|
||||
t.Fatalf("set headers failed: %v", err)
|
||||
}
|
||||
row := []any{
|
||||
"8986000000000000001",
|
||||
"13800008000",
|
||||
"460001234567890",
|
||||
"1",
|
||||
"是",
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil {
|
||||
t.Fatalf("set row failed: %v", err)
|
||||
}
|
||||
if err := f.SaveAs(filePath); err != nil {
|
||||
t.Fatalf("save excel failed: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
rows, options, err := ParseExcelFileRows(context.Background(), filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("parse excel failed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("unexpected row count: %d", len(rows))
|
||||
}
|
||||
if rows[0].Reason != "" {
|
||||
t.Fatalf("unexpected row reason: %s", rows[0].Reason)
|
||||
}
|
||||
if rows[0].Msisdn != "13800008000" {
|
||||
t.Fatalf("unexpected msisdn: %s", rows[0].Msisdn)
|
||||
}
|
||||
if rows[0].Imsi != "460001234567890" {
|
||||
t.Fatalf("unexpected imsi: %s", rows[0].Imsi)
|
||||
}
|
||||
if options == nil {
|
||||
t.Fatal("options should not be nil")
|
||||
}
|
||||
if options.DurationYears != 1 {
|
||||
t.Fatalf("unexpected durationYears: %d", options.DurationYears)
|
||||
}
|
||||
if options.PaymentMode != "offline" {
|
||||
t.Fatalf("unexpected paymentMode: %s", options.PaymentMode)
|
||||
}
|
||||
|
||||
submitParam := options.ToBatchSubmitParam()
|
||||
if submitParam.DurationYears != 1 || submitParam.DurationMonths != 12 {
|
||||
t.Fatalf("unexpected submit duration fields: %#v", submitParam)
|
||||
}
|
||||
if submitParam.PaymentMode != "offline" {
|
||||
t.Fatalf("unexpected submit paymentMode: %#v", submitParam)
|
||||
}
|
||||
if !submitParam.OfflineContractCompleted {
|
||||
t.Fatalf("excel submit should mark offline contract as confirmed: %#v", submitParam)
|
||||
}
|
||||
if submitParam.SourceBatchNo != "" || submitParam.BuyerNote != "" || submitParam.AdminNote != "" || submitParam.Remark != "" {
|
||||
t.Fatalf("excel submit param should not carry removed fields: %#v", submitParam)
|
||||
}
|
||||
|
||||
confirmParam := options.ToBatchConfirmParam("RB20260706001")
|
||||
if confirmParam.BatchNo != "RB20260706001" {
|
||||
t.Fatalf("unexpected batchNo: %s", confirmParam.BatchNo)
|
||||
}
|
||||
if confirmParam.DurationYears != 1 || confirmParam.DurationMonths != 12 {
|
||||
t.Fatalf("unexpected confirm duration fields: %#v", confirmParam)
|
||||
}
|
||||
if confirmParam.PaymentMode != "offline" {
|
||||
t.Fatalf("unexpected confirm paymentMode: %#v", confirmParam)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsRejectsMultipleSheetsWithData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "renew-multi.xlsx")
|
||||
f := excelize.NewFile()
|
||||
headers := ExcelTemplateHeaders()
|
||||
if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil {
|
||||
t.Fatalf("set headers failed: %v", err)
|
||||
}
|
||||
row := []any{
|
||||
"8986000000000000001",
|
||||
"13800008000",
|
||||
"460001234567890",
|
||||
"1",
|
||||
"是",
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil {
|
||||
t.Fatalf("set row failed: %v", err)
|
||||
}
|
||||
_, err := f.NewSheet("Sheet2")
|
||||
if err != nil {
|
||||
t.Fatalf("new sheet failed: %v", err)
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet2", "A1", &headers); err != nil {
|
||||
t.Fatalf("set sheet2 headers failed: %v", err)
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet2", "A2", &row); err != nil {
|
||||
t.Fatalf("set sheet2 row failed: %v", err)
|
||||
}
|
||||
if err := f.SaveAs(filePath); err != nil {
|
||||
t.Fatalf("save excel failed: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
if _, _, err := ParseExcelFileRows(context.Background(), filePath); err == nil {
|
||||
t.Fatal("expected multiple-sheet excel to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareExcelProcessorUsesPrepareBatchType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
processor := NewPrepareExcelProcessor()
|
||||
cfg := processor.Config()
|
||||
if cfg.BatchType != consts.BatchTypePrepare {
|
||||
t.Fatalf("prepare excel batch type = %d, want %d", cfg.BatchType, consts.BatchTypePrepare)
|
||||
}
|
||||
if cfg.BatchNoPrefix != consts.BatchNoPrefixRenew {
|
||||
t.Fatalf("prepare excel batch prefix = %q, want %q", cfg.BatchNoPrefix, consts.BatchNoPrefixRenew)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRenewInputLineSupportsSingleAndTripleFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
line text.FieldLine
|
||||
wantCode string
|
||||
wantIccid string
|
||||
wantMsisdn string
|
||||
wantImsi string
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "single value",
|
||||
line: text.FieldLine{RowNo: 2, Fields: []string{"89860401786597927028"}},
|
||||
wantCode: "89860401786597927028",
|
||||
wantIccid: "89860401786597927028",
|
||||
},
|
||||
{
|
||||
name: "triple values",
|
||||
line: text.FieldLine{RowNo: 3, Fields: []string{"89860401786597927028", "14100007028", "460046597927028"}},
|
||||
wantCode: "89860401786597927028",
|
||||
wantIccid: "89860401786597927028",
|
||||
wantMsisdn: "14100007028",
|
||||
wantImsi: "460046597927028",
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
line: text.FieldLine{RowNo: 4, Fields: []string{"a", "b"}},
|
||||
wantReason: "格式错误",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
row := parseRenewInputLine(test.line)
|
||||
if row.InputCode != test.wantCode {
|
||||
t.Fatalf("input code = %q, want %q", row.InputCode, test.wantCode)
|
||||
}
|
||||
if row.Iccid != test.wantIccid {
|
||||
t.Fatalf("iccid = %q, want %q", row.Iccid, test.wantIccid)
|
||||
}
|
||||
if row.Msisdn != test.wantMsisdn {
|
||||
t.Fatalf("msisdn = %q, want %q", row.Msisdn, test.wantMsisdn)
|
||||
}
|
||||
if row.Imsi != test.wantImsi {
|
||||
t.Fatalf("imsi = %q, want %q", row.Imsi, test.wantImsi)
|
||||
}
|
||||
if test.wantReason != "" && row.Reason == "" {
|
||||
t.Fatal("expected row reason")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewIdentityResolverResolvesAnyCardIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newRenewIdentityResolverFromCards([]*entity.TSimCardInfo{
|
||||
{
|
||||
Id: 1,
|
||||
Iccid: "89860401786597927028",
|
||||
Msisdn: "14100007028",
|
||||
Imsi: "460046597927028",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Iccid: "89860401786597927029",
|
||||
Msisdn: "14100007029",
|
||||
Imsi: "460046597927029",
|
||||
},
|
||||
})
|
||||
|
||||
row := &Row{RowNo: 2, InputCode: "14100007028", Msisdn: "14100007028"}
|
||||
resolver.resolveRow(row)
|
||||
if row.Reason != "" {
|
||||
t.Fatalf("unexpected reason: %s", row.Reason)
|
||||
}
|
||||
if row.Iccid != "89860401786597927028" || row.Msisdn != "14100007028" || row.Imsi != "460046597927028" {
|
||||
t.Fatalf("unexpected resolved row: %#v", row)
|
||||
}
|
||||
|
||||
row = &Row{RowNo: 3, InputCode: "460046597927029", Imsi: "460046597927029"}
|
||||
resolver.resolveRow(row)
|
||||
if row.Reason != "" {
|
||||
t.Fatalf("unexpected reason for imsi row: %s", row.Reason)
|
||||
}
|
||||
if row.Iccid != "89860401786597927029" || row.Msisdn != "14100007029" || row.Imsi != "460046597927029" {
|
||||
t.Fatalf("unexpected imsi resolved row: %#v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewIdentityResolverRejectsAmbiguousOrMismatchedIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newRenewIdentityResolverFromCards([]*entity.TSimCardInfo{
|
||||
{Id: 1, Iccid: "89860401786597927028", Msisdn: "14100007028", Imsi: "460046597927028"},
|
||||
{Id: 2, Iccid: "89860401786597927029", Msisdn: "89860401786597927028", Imsi: "460046597927029"},
|
||||
})
|
||||
|
||||
ambiguous := &Row{RowNo: 2, InputCode: "89860401786597927028", Iccid: "89860401786597927028"}
|
||||
resolver.resolveRow(ambiguous)
|
||||
if ambiguous.Reason == "" {
|
||||
t.Fatal("expected ambiguous reason")
|
||||
}
|
||||
|
||||
mismatched := &Row{RowNo: 3, InputCode: "89860401786597927028", Iccid: "89860401786597927028", Msisdn: "00000000000"}
|
||||
resolver.resolveRow(mismatched)
|
||||
if mismatched.Reason == "" {
|
||||
t.Fatal("expected mismatch reason")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// PrepareProcessor 描述 batch_type=4 的卡续费预处理批次。
|
||||
// 与正式续费 Processor 共享 row/validate 逻辑,但 BatchType/BuildBatchInfo 走 prepare 语义。
|
||||
type PrepareProcessor struct {
|
||||
inner *Processor
|
||||
repo *Repository
|
||||
config batchimport.ProcessorConfig
|
||||
}
|
||||
|
||||
func NewPrepareProcessor() *PrepareProcessor {
|
||||
inner := NewProcessor()
|
||||
cfg := inner.config
|
||||
cfg.Code = "sim_card_renew_prepare"
|
||||
cfg.BatchType = consts.BatchTypePrepare
|
||||
cfg.BatchNoPrefix = consts.BatchNoPrefixRenew
|
||||
return &PrepareProcessor{
|
||||
inner: inner,
|
||||
repo: inner.repo,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) Config() batchimport.ProcessorConfig {
|
||||
return p.config
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
|
||||
// Keep submit non-blocking; the worker owns prepare validation.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
|
||||
if req == nil {
|
||||
return gerror.New("续费预处理请求不能为空")
|
||||
}
|
||||
renewRows := make([]*Row, 0, len(rows))
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok || row == nil {
|
||||
return gerror.New("续费行类型错误")
|
||||
}
|
||||
renewRows = append(renewRows, row)
|
||||
}
|
||||
submitParam := renewPrepareSubmitParam(req)
|
||||
prepared, err := p.inner.ValidatePrepareContext(ctx, tx, submitParam, req.Operator, renewRows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
priceSnapshot, err := ensurePriceSnapshot(ctx, tx, req.Operator, prepared, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.TargetDeptId = prepared.CustomerAgentId
|
||||
req.ExecutionResult = priceSnapshot
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
|
||||
info := g.Map{}
|
||||
if result != nil && result.ImportFileOssId > 0 {
|
||||
info["importFileOssId"] = result.ImportFileOssId
|
||||
} else if importOss != nil && importOss.OssId > 0 {
|
||||
info["importFileOssId"] = importOss.OssId
|
||||
}
|
||||
if resultOss != nil {
|
||||
info["resultFileOssId"] = resultOss.OssId
|
||||
}
|
||||
if downloadId > 0 {
|
||||
info["resultDownloadId"] = downloadId
|
||||
}
|
||||
info["targetBatchType"] = consts.BatchTypeRenew
|
||||
info["targetBatchName"] = "卡续费"
|
||||
if in, ok := req.BizParams.(*model.TSimCardRenewBatchPrepareParam); ok && in != nil {
|
||||
if sourceBatchNo := strings.TrimSpace(in.SourceBatchNo); sourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = sourceBatchNo
|
||||
}
|
||||
}
|
||||
if in, ok := req.BizParams.(*model.TSimCardRenewBatchPrepareAllParam); ok && in != nil {
|
||||
if sourceBatchNo := strings.TrimSpace(in.SourceBatchNo); sourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = sourceBatchNo
|
||||
}
|
||||
}
|
||||
if in, ok := req.BizParams.(*model.TSimCardRenewBatchSubmitParam); ok && in != nil {
|
||||
durationValue := renewDisplayDurationValue(in)
|
||||
if durationValue > 0 {
|
||||
actualDurationMonths := renewActualDurationMonths(nil, durationValue)
|
||||
info["durationValue"] = durationValue
|
||||
info["durationYears"] = durationValue
|
||||
info["durationMonths"] = actualDurationMonths
|
||||
info["actualDurationMonths"] = actualDurationMonths
|
||||
}
|
||||
if paymentMode := strings.TrimSpace(in.PaymentMode); paymentMode != "" {
|
||||
info["paymentMode"] = paymentMode
|
||||
}
|
||||
if packageCode := strings.TrimSpace(in.PackageCode); packageCode != "" {
|
||||
info["packageCode"] = packageCode
|
||||
}
|
||||
info["offlineContractCompleted"] = in.OfflineContractCompleted
|
||||
if sourceBatchNo := strings.TrimSpace(in.SourceBatchNo); sourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = sourceBatchNo
|
||||
}
|
||||
}
|
||||
if snapshot, ok := req.ExecutionResult.(*model.TSimCardRenewPriceSnapshot); ok && snapshot != nil {
|
||||
info["customerAgentId"] = snapshot.CustomerAgentId
|
||||
info["packageCode"] = snapshot.PackageCode
|
||||
info["packageName"] = snapshot.PackageName
|
||||
info["billingMethod"] = snapshot.BillingMethod
|
||||
info["renewDurationUnit"] = snapshot.RenewDurationUnit
|
||||
info["renewDurationUnitText"] = snapshot.RenewDurationUnitText
|
||||
info["priceSource"] = snapshot.PriceSource
|
||||
info["priceOwnerDeptId"] = snapshot.PriceOwnerDeptId
|
||||
info["agentDefaultPriceId"] = snapshot.AgentDefaultPriceId
|
||||
info["targetPriceId"] = snapshot.TargetPriceId
|
||||
info["renewUnitPrice"] = snapshot.RenewUnitPrice
|
||||
info["renewableDurationOptions"] = snapshot.RenewableDurationOptions
|
||||
info["renewablePackageCodes"] = snapshot.RenewablePackageCodes
|
||||
info["renewablePackages"] = snapshot.RenewablePackages
|
||||
info["renewBeginDate"] = snapshot.RenewBeginDate
|
||||
info["renewBeginDateMin"] = snapshot.RenewBeginDateMin
|
||||
info["renewBeginDateMax"] = snapshot.RenewBeginDateMax
|
||||
info["sameRenewBeginDate"] = snapshot.SameRenewBeginDate
|
||||
if result != nil {
|
||||
info["quantity"] = result.ValidRows
|
||||
}
|
||||
}
|
||||
return gjson.New(info)
|
||||
}
|
||||
|
||||
func (p *PrepareProcessor) ValidatePrepareContext(ctx context.Context, tx gdb.TX, in *model.TSimCardRenewBatchSubmitParam, operator batchimport.OperatorContext, rows []*Row) (*PrepareContext, error) {
|
||||
return p.inner.ValidatePrepareContext(ctx, tx, in, operator, rows)
|
||||
}
|
||||
|
||||
func renewPrepareSubmitParam(req *batchimport.SubmitRequest) *model.TSimCardRenewBatchSubmitParam {
|
||||
submitParam := &model.TSimCardRenewBatchSubmitParam{}
|
||||
if req == nil {
|
||||
return submitParam
|
||||
}
|
||||
switch in := req.BizParams.(type) {
|
||||
case *model.TSimCardRenewBatchSubmitParam:
|
||||
if in != nil {
|
||||
submitParam = in
|
||||
}
|
||||
case *model.TSimCardRenewBatchPrepareParam:
|
||||
if in != nil {
|
||||
submitParam.SourceBatchNo = strings.TrimSpace(in.SourceBatchNo)
|
||||
}
|
||||
case *model.TSimCardRenewBatchPrepareAllParam:
|
||||
if in != nil {
|
||||
submitParam.SourceBatchNo = strings.TrimSpace(in.SourceBatchNo)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(submitParam.SourceBatchNo) == "" {
|
||||
submitParam.SourceBatchNo = strings.TrimSpace(req.SourceBatchNo)
|
||||
}
|
||||
return submitParam
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
repo *Repository
|
||||
config batchimport.ProcessorConfig
|
||||
}
|
||||
|
||||
func NewProcessor() *Processor {
|
||||
return &Processor{
|
||||
repo: NewRepository(),
|
||||
config: batchimport.ProcessorConfig{
|
||||
Code: "sim_card_renew",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
BatchNoPrefix: consts.BatchNoPrefixRenew,
|
||||
TaskName: consts.ResourceBatchDownloadTaskSimCardRenew,
|
||||
ExportScene: consts.ResourceExportSceneRenew,
|
||||
ImportDir: consts.SimCardRenewSubDirImportExcel,
|
||||
ResultDir: consts.SimCardRenewSubDirResultExcel,
|
||||
MaxFileSize: consts.SimCardRenewMaxFileSize,
|
||||
TemplateHeaders: ExcelTemplateHeaders(),
|
||||
RequiredHeaders: []string{"ICCID"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) Config() batchimport.ProcessorConfig {
|
||||
return p.config
|
||||
}
|
||||
|
||||
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
return nil, gerror.New("卡续费不支持文件导入")
|
||||
}
|
||||
|
||||
func (p *Processor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
|
||||
renewRows := make([]*Row, 0, len(rows))
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok {
|
||||
item.AddErrorReason("续费行类型错误")
|
||||
continue
|
||||
}
|
||||
renewRows = append(renewRows, row)
|
||||
}
|
||||
if err := normalizeRenewIdentityRows(ctx, renewRows); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRows(renewRows)
|
||||
}
|
||||
|
||||
func (p *Processor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
|
||||
if req == nil {
|
||||
return gerror.New("续费请求不能为空")
|
||||
}
|
||||
in, ok := req.BizParams.(*model.TSimCardRenewBatchSubmitParam)
|
||||
if !ok || in == nil {
|
||||
return gerror.New("续费参数类型错误")
|
||||
}
|
||||
|
||||
renewRows := make([]*Row, 0, len(rows))
|
||||
for _, item := range rows {
|
||||
row, ok := item.(*Row)
|
||||
if !ok {
|
||||
return gerror.New("续费行类型错误")
|
||||
}
|
||||
renewRows = append(renewRows, row)
|
||||
}
|
||||
options, err := loadExecutionOptions(ctx, tx, req.BatchNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyExecutionOptions(in, options)
|
||||
|
||||
prepared, err := p.validateRenewContext(ctx, tx, in, req.Operator, renewRows, req.BatchNo, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
priceSnapshot, err := ensurePriceSnapshot(ctx, tx, req.Operator, prepared, options.PriceSnapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
durationValue := renewDisplayDurationValue(in)
|
||||
if durationValue <= 0 {
|
||||
return gerror.New("续费时长必须大于0")
|
||||
}
|
||||
if !containsRenewDurationOption(priceSnapshot.RenewableDurationOptions, durationValue) {
|
||||
return gerror.New("续费时长不在当前套餐可续费范围内")
|
||||
}
|
||||
|
||||
output, err := p.Complete(ctx, tx, &CompleteInput{
|
||||
SubmitParam: in,
|
||||
BatchNo: req.BatchNo,
|
||||
Rows: renewRows,
|
||||
Operator: req.Operator,
|
||||
PriceSnapshot: priceSnapshot,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.ExecutionResult = output
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Processor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
|
||||
info := g.Map{}
|
||||
if result != nil && result.ImportFileOssId > 0 {
|
||||
info["importFileOssId"] = result.ImportFileOssId
|
||||
} else if importOss != nil && importOss.OssId > 0 {
|
||||
info["importFileOssId"] = importOss.OssId
|
||||
}
|
||||
if resultOss != nil {
|
||||
info["resultFileOssId"] = resultOss.OssId
|
||||
}
|
||||
if downloadId > 0 {
|
||||
info["resultDownloadId"] = downloadId
|
||||
}
|
||||
if in, ok := req.BizParams.(*model.TSimCardRenewBatchSubmitParam); ok && in != nil {
|
||||
durationValue := renewDisplayDurationValue(in)
|
||||
if durationValue > 0 {
|
||||
actualDurationMonths := renewActualDurationMonths(nil, durationValue)
|
||||
info["durationYears"] = durationValue
|
||||
info["durationMonths"] = actualDurationMonths
|
||||
info["durationValue"] = durationValue
|
||||
info["actualDurationMonths"] = actualDurationMonths
|
||||
}
|
||||
info["giftDurationMonths"] = in.GiftDurationMonths
|
||||
info["amount"] = strings.TrimSpace(in.Amount)
|
||||
if in.PaymentSnapshot != nil {
|
||||
appendRenewPaymentSnapshot(info, in.PaymentSnapshot)
|
||||
} else if paymentMode := strings.TrimSpace(in.PaymentMode); paymentMode != "" {
|
||||
info["paymentMode"] = paymentMode
|
||||
}
|
||||
if packageCode := strings.TrimSpace(in.PackageCode); packageCode != "" {
|
||||
info["packageCode"] = packageCode
|
||||
}
|
||||
info["offlineContractCompleted"] = in.OfflineContractCompleted
|
||||
if sourceBatchNo := strings.TrimSpace(in.SourceBatchNo); sourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = sourceBatchNo
|
||||
}
|
||||
if buyerNote := strings.TrimSpace(in.BuyerNote); buyerNote != "" {
|
||||
info["buyerNote"] = buyerNote
|
||||
}
|
||||
if adminNote := strings.TrimSpace(in.AdminNote); adminNote != "" {
|
||||
info["adminNote"] = adminNote
|
||||
}
|
||||
if remark := strings.TrimSpace(in.Remark); remark != "" {
|
||||
info["remark"] = remark
|
||||
}
|
||||
}
|
||||
if snapshot, ok := req.ExecutionResult.(*model.TSimCardRenewPriceSnapshot); ok && snapshot != nil {
|
||||
if result != nil && result.BatchType == consts.BatchTypePrepare {
|
||||
info["targetBatchType"] = consts.BatchTypeRenew
|
||||
info["targetBatchName"] = "卡续费"
|
||||
}
|
||||
info["customerAgentId"] = snapshot.CustomerAgentId
|
||||
info["packageCode"] = snapshot.PackageCode
|
||||
info["packageName"] = snapshot.PackageName
|
||||
info["billingMethod"] = snapshot.BillingMethod
|
||||
info["renewDurationUnit"] = snapshot.RenewDurationUnit
|
||||
info["renewDurationUnitText"] = snapshot.RenewDurationUnitText
|
||||
if durationValue := gconv.Int(info["durationValue"]); durationValue > 0 {
|
||||
actualDurationMonths := renewActualDurationMonths(snapshot, durationValue)
|
||||
info["durationMonths"] = actualDurationMonths
|
||||
info["actualDurationMonths"] = actualDurationMonths
|
||||
}
|
||||
info["quantity"] = result.ValidRows
|
||||
info["priceSource"] = snapshot.PriceSource
|
||||
info["priceOwnerDeptId"] = snapshot.PriceOwnerDeptId
|
||||
info["agentDefaultPriceId"] = snapshot.AgentDefaultPriceId
|
||||
info["targetPriceId"] = snapshot.TargetPriceId
|
||||
info["renewUnitPrice"] = snapshot.RenewUnitPrice
|
||||
info["renewableDurationOptions"] = snapshot.RenewableDurationOptions
|
||||
info["renewablePackageCodes"] = snapshot.RenewablePackageCodes
|
||||
info["renewablePackages"] = snapshot.RenewablePackages
|
||||
info["renewBeginDate"] = snapshot.RenewBeginDate
|
||||
info["renewBeginDateMin"] = snapshot.RenewBeginDateMin
|
||||
info["renewBeginDateMax"] = snapshot.RenewBeginDateMax
|
||||
info["sameRenewBeginDate"] = snapshot.SameRenewBeginDate
|
||||
}
|
||||
if output, ok := req.ExecutionResult.(*CompleteOutput); ok && output != nil {
|
||||
info["quantity"] = output.Quantity
|
||||
info["packageCode"] = output.PackageCode
|
||||
info["orderSn"] = output.OrderSn
|
||||
info["renewTime"] = output.RenewTime
|
||||
if sourceBatchNo := strings.TrimSpace(output.SourceBatchNo); sourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = sourceBatchNo
|
||||
}
|
||||
}
|
||||
return gjson.New(info)
|
||||
}
|
||||
|
||||
func appendRenewPaymentSnapshot(info g.Map, snapshot *model.TSimCardRenewPaymentSnapshot) {
|
||||
if info == nil || snapshot == nil {
|
||||
return
|
||||
}
|
||||
info["paymentMode"] = strings.TrimSpace(snapshot.PaymentMode)
|
||||
info["payType"] = snapshot.PayType
|
||||
info["payStatus"] = snapshot.PayStatus
|
||||
info["payAmount"] = snapshot.PayAmount
|
||||
info["balanceAmount"] = snapshot.BalanceAmount
|
||||
info["offlinePaidAmount"] = snapshot.OfflinePaidAmount
|
||||
info["balanceBefore"] = snapshot.BalanceBefore
|
||||
info["balanceAfter"] = snapshot.BalanceAfter
|
||||
info["balanceDetailSerialNo"] = strings.TrimSpace(snapshot.BalanceDetailSerialNo)
|
||||
info["payerDeptId"] = snapshot.PayerDeptId
|
||||
info["payerUserId"] = snapshot.PayerUserId
|
||||
info["isTopDept"] = snapshot.IsTopDept
|
||||
if snapshot.PaymentTime != nil {
|
||||
info["paymentTime"] = snapshot.PaymentTime.Format("Y-m-d H:i:s")
|
||||
}
|
||||
info["paymentSnapshot"] = snapshot
|
||||
}
|
||||
|
||||
func (p *Processor) ValidatePrepareContext(ctx context.Context, tx gdb.TX, in *model.TSimCardRenewBatchSubmitParam, operator batchimport.OperatorContext, rows []*Row) (*PrepareContext, error) {
|
||||
return p.validateRenewContext(ctx, tx, in, operator, rows, "", false)
|
||||
}
|
||||
|
||||
func (p *Processor) RevalidatePrepareContext(ctx context.Context, tx gdb.TX, in *model.TSimCardRenewBatchSubmitParam, operator batchimport.OperatorContext, rows []*Row, currentBatchNo string) (*PrepareContext, bool, error) {
|
||||
prepared, err := p.validateRenewContext(ctx, tx, in, operator, rows, currentBatchNo, true)
|
||||
return prepared, hasInvalidRows(rows), err
|
||||
}
|
||||
|
||||
func (p *Processor) validateRenewContext(ctx context.Context, tx gdb.TX, in *model.TSimCardRenewBatchSubmitParam, operator batchimport.OperatorContext, rows []*Row, currentBatchNo string, allowPartial bool) (*PrepareContext, error) {
|
||||
iccids := rowsToIccids(rows)
|
||||
cardsByIccid, err := p.repo.LockCardsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "校验续费卡信息失败")
|
||||
}
|
||||
bindsByIccid, err := p.repo.LockLatestBindsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "校验续费订购记录失败")
|
||||
}
|
||||
conflictingBatches, err := p.repo.UnfinishedRenewBatchNosByIccids(
|
||||
ctx,
|
||||
tx,
|
||||
operator.TenantId,
|
||||
iccids,
|
||||
currentBatchNo,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "校验未完成续费批次失败")
|
||||
}
|
||||
|
||||
sourceBatchNo := strings.TrimSpace(in.SourceBatchNo)
|
||||
packageCodes := make(map[string]struct{})
|
||||
customerAgentIds := make(map[int64]struct{})
|
||||
currentPackagePeriods := make(map[string]struct{})
|
||||
renewBeginDates := make([]string, 0, len(rows))
|
||||
packageCode := ""
|
||||
customerAgentId := int64(0)
|
||||
for _, row := range rows {
|
||||
if row == nil || row.Reason != "" {
|
||||
continue
|
||||
}
|
||||
card := cardsByIccid[row.Iccid]
|
||||
bind := bindsByIccid[row.Iccid]
|
||||
if strings.TrimSpace(conflictingBatches[row.Iccid]) != "" {
|
||||
row.AddReason("存在未完成续费批次,不能重复续费")
|
||||
continue
|
||||
}
|
||||
reason := validateRenewRow(sourceBatchNo, operator, card, bind)
|
||||
if reason != "" {
|
||||
row.AddReason(reason)
|
||||
continue
|
||||
}
|
||||
baseExpireAt := renewBaseExpireTime(card, bind)
|
||||
|
||||
periodKey := currentPackagePeriodKeyWithExpire(bind, baseExpireAt)
|
||||
if periodKey == "" {
|
||||
row.AddReason("不是统一批次")
|
||||
continue
|
||||
}
|
||||
currentPackageCode := strings.TrimSpace(card.CurrentPackageCode)
|
||||
packageCodes[currentPackageCode] = struct{}{}
|
||||
customerAgentIds[card.DeptId] = struct{}{}
|
||||
currentPackagePeriods[periodKey] = struct{}{}
|
||||
packageCode = currentPackageCode
|
||||
customerAgentId = card.DeptId
|
||||
renewBeginAt := renewBeginDateFromExpire(baseExpireAt)
|
||||
if renewBeginAt != nil {
|
||||
renewBeginDates = append(renewBeginDates, renewBeginAt.Format("Y-m-d"))
|
||||
}
|
||||
}
|
||||
|
||||
if len(packageCodes) > 1 {
|
||||
markRowsWithoutReason(rows, "当前批次下存在多个套餐,不能统一续费")
|
||||
}
|
||||
if len(customerAgentIds) > 1 {
|
||||
markRowsWithoutReason(rows, "当前批次下存在多个客户,不能统一续费")
|
||||
}
|
||||
if len(currentPackagePeriods) > 1 {
|
||||
markRowsWithoutReason(rows, "不是统一批次")
|
||||
}
|
||||
if len(customerAgentIds) == 1 && customerAgentId > 0 {
|
||||
if err := validateRenewCustomerDeptScope(ctx, tx, operator, customerAgentId); err != nil {
|
||||
markRowsWithoutReason(rows, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if hasInvalidRows(rows) && !allowPartial {
|
||||
markRemainingRowsSkipped(rows)
|
||||
return nil, gerror.New("存在不满足续费条件的卡,本次未执行")
|
||||
}
|
||||
validCount := countValidRows(rows)
|
||||
if validCount == 0 {
|
||||
return &PrepareContext{}, nil
|
||||
}
|
||||
renewBeginDate, renewBeginDateMin, renewBeginDateMax, sameRenewBeginDate := summarizeRenewBeginDates(renewBeginDates)
|
||||
return &PrepareContext{
|
||||
CustomerAgentId: customerAgentId,
|
||||
PackageCode: packageCode,
|
||||
Quantity: validCount,
|
||||
RenewBeginDate: renewBeginDate,
|
||||
RenewBeginDateMin: renewBeginDateMin,
|
||||
RenewBeginDateMax: renewBeginDateMax,
|
||||
SameRenewBeginDate: sameRenewBeginDate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func currentPackagePeriodKey(bind *entity.TSimCardBindPkg) string {
|
||||
if bind == nil {
|
||||
return ""
|
||||
}
|
||||
return currentPackagePeriodKeyWithExpire(bind, bind.ExpectedExpireTime)
|
||||
}
|
||||
|
||||
func currentPackagePeriodKeyWithExpire(bind *entity.TSimCardBindPkg, expireAt *gtime.Time) string {
|
||||
if bind == nil || bind.EffectiveTime == nil {
|
||||
return ""
|
||||
}
|
||||
if expireAt == nil {
|
||||
expireAt = bind.ExpectedExpireTime
|
||||
}
|
||||
if expireAt == nil {
|
||||
return ""
|
||||
}
|
||||
return bind.EffectiveTime.Format("Y-m-d") + "|" + expireAt.Format("Y-m-d")
|
||||
}
|
||||
|
||||
func summarizeRenewBeginDates(dates []string) (sameDate string, minDate string, maxDate string, same bool) {
|
||||
cleaned := make([]string, 0, len(dates))
|
||||
for _, date := range dates {
|
||||
date = strings.TrimSpace(date)
|
||||
if date != "" {
|
||||
cleaned = append(cleaned, date)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return "", "", "", false
|
||||
}
|
||||
sort.Strings(cleaned)
|
||||
minDate = cleaned[0]
|
||||
maxDate = cleaned[len(cleaned)-1]
|
||||
same = minDate == maxDate
|
||||
if same {
|
||||
sameDate = minDate
|
||||
}
|
||||
return sameDate, minDate, maxDate, same
|
||||
}
|
||||
|
||||
func renewBeginDateFromExpire(expireAt *gtime.Time) *gtime.Time {
|
||||
if expireAt == nil {
|
||||
return nil
|
||||
}
|
||||
return normalizeRenewDateAtDayEnd(expireAt)
|
||||
}
|
||||
|
||||
func normalizeRenewDateAtDayEnd(expireAt *gtime.Time) *gtime.Time {
|
||||
if expireAt == nil || expireAt.IsZero() {
|
||||
return nil
|
||||
}
|
||||
t := expireAt.Time
|
||||
return gtime.NewFromTime(
|
||||
time.Date(
|
||||
t.Year(),
|
||||
t.Month(),
|
||||
t.Day(),
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
0,
|
||||
t.Location(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func validateRenewRow(sourceBatchNo string, operator batchimport.OperatorContext, card *entity.TSimCardInfo, bind *entity.TSimCardBindPkg) string {
|
||||
if card == nil {
|
||||
return "ICCID不存在正式卡记录,不能续费"
|
||||
}
|
||||
if card.BossCardStatus == consts.V3ApiSimBossCardStatusStopped {
|
||||
return "ICCID当前为停机状态,不能续费"
|
||||
}
|
||||
if bind == nil {
|
||||
return "ICCID没有订购记录,不能续费"
|
||||
}
|
||||
if strings.TrimSpace(card.CurrentPackageCode) == "" || card.PackageStatus == consts.PackageStatusNone {
|
||||
return "ICCID没有当前套餐,不能续费"
|
||||
}
|
||||
if strings.TrimSpace(card.CurrentPackageCode) != strings.TrimSpace(bind.PackageCode) {
|
||||
return "ICCID当前套餐与最新订购记录不一致,不能续费"
|
||||
}
|
||||
baseExpireAt := renewBaseExpireTime(card, bind)
|
||||
if baseExpireAt == nil {
|
||||
return "ICCID最新订购没有预计到期时间,不能续费"
|
||||
}
|
||||
if renewExpireAfterAllowedWindow(baseExpireAt) {
|
||||
return "距离到期超过6个月,不能续费"
|
||||
}
|
||||
if sourceBatchNo != "" && strings.TrimSpace(card.OutboundBatchNo) != sourceBatchNo {
|
||||
return "ICCID不属于来源出库批次,不能续费"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func renewBaseExpireTime(card *entity.TSimCardInfo, bind *entity.TSimCardBindPkg) *gtime.Time {
|
||||
var expireAt *gtime.Time
|
||||
if bind != nil {
|
||||
expireAt = laterRenewExpireTime(expireAt, bind.ExpectedExpireTime)
|
||||
}
|
||||
if card != nil {
|
||||
expireAt = laterRenewExpireTime(expireAt, card.FlowPkgEndAt)
|
||||
expireAt = laterRenewExpireTime(expireAt, card.FlowPkgPresentAt)
|
||||
expireAt = laterRenewExpireTime(expireAt, card.FlowPkgRealPresentAt)
|
||||
}
|
||||
return expireAt
|
||||
}
|
||||
|
||||
func laterRenewExpireTime(current, candidate *gtime.Time) *gtime.Time {
|
||||
if candidate == nil || candidate.IsZero() {
|
||||
return current
|
||||
}
|
||||
if current == nil || current.IsZero() || candidate.After(current) {
|
||||
return candidate
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func renewExpireAfterAllowedWindow(expireAt *gtime.Time) bool {
|
||||
if expireAt == nil {
|
||||
return false
|
||||
}
|
||||
return expireAt.After(gtime.Now().AddDate(0, 6, 0))
|
||||
}
|
||||
|
||||
func validateRenewCustomerDeptScope(ctx context.Context, tx gdb.TX, operator batchimport.OperatorContext, customerDeptID int64) error {
|
||||
if operator.DeptId <= 0 || operator.TenantId == "" {
|
||||
return gerror.New("当前用户未绑定组织或租户")
|
||||
}
|
||||
if customerDeptID <= 0 {
|
||||
return gerror.New("ICCID不属于当前机构,不能续费")
|
||||
}
|
||||
if customerDeptID == operator.DeptId {
|
||||
return nil
|
||||
}
|
||||
dept := &entity.SysDept{}
|
||||
if err := tx.Ctx(ctx).Model(dao.SysDept.Table()).
|
||||
Fields(dao.SysDept.Columns().DeptId, dao.SysDept.Columns().ParentId, dao.SysDept.Columns().Ancestors).
|
||||
Where(dao.SysDept.Columns().TenantId, operator.TenantId).
|
||||
Where(dao.SysDept.Columns().DeptId, customerDeptID).
|
||||
WhereNull(dao.SysDept.Columns().DeletedAt).
|
||||
Scan(dept); err != nil {
|
||||
return gerror.Wrap(err, "校验ICCID归属失败")
|
||||
}
|
||||
if dept.DeptId == 0 {
|
||||
return gerror.New("ICCID不属于当前机构,不能续费")
|
||||
}
|
||||
if dept.ParentId == operator.DeptId || renewDeptAncestorsContain(dept.Ancestors, operator.DeptId) {
|
||||
return nil
|
||||
}
|
||||
return gerror.New("ICCID不属于当前机构,不能续费")
|
||||
}
|
||||
|
||||
func renewDeptAncestorsContain(ancestors string, deptID int64) bool {
|
||||
if deptID <= 0 {
|
||||
return false
|
||||
}
|
||||
target := strconv.FormatInt(deptID, 10)
|
||||
for _, item := range strings.Split(strings.Trim(ancestors, ","), ",") {
|
||||
if strings.TrimSpace(item) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasInvalidRows(rows []*Row) bool {
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Reason != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countValidRows(rows []*Row) int {
|
||||
count := 0
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Valid() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func markRowsWithoutReason(rows []*Row, reason string) {
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Reason == "" {
|
||||
row.AddReason(reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markRemainingRowsSkipped(rows []*Row) {
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Reason == "" {
|
||||
row.AddReason("同批存在不满足续费条件的卡,本行未执行")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateRows(rows []*Row) error {
|
||||
firstByIccid := make(map[string]*Row, len(rows))
|
||||
for _, row := range rows {
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
if row.Iccid == "" {
|
||||
row.AddReason("ICCID不能为空")
|
||||
continue
|
||||
}
|
||||
if first, ok := firstByIccid[row.Iccid]; ok {
|
||||
row.AddReason("ICCID重复")
|
||||
first.AddReason("ICCID重复")
|
||||
continue
|
||||
}
|
||||
firstByIccid[row.Iccid] = row
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user