chore: 初始开源发布

iotlcm-admin-svr:管理后台服务。

- Go module:iotlcm → git.5iot.com/iotlcm-public/iotlcm-admin-svr(729 文件)
- 凭据与地址脱敏:JWT signingKey(含代码里的 MustGet 默认值)、
  MySQL DSN、Redis 口令、告警邮件发件人邮箱全部改为占位;
  私网 IP → 文档地址段;库名 xiujie_admin → iotlcm
- 真实手机号/邮箱 → 合法 11 位编造号 / example.com
- 移除内部资产:CI 触发器、过时 DDL(sql/db_iotlcm.sql)、
  私有依赖 go get 行、明文 registry 登录口令
- 启动 banner 统一 IOTLCM 方块字 + 社区惯例署名
- Dockerfile 改用公共基础镜像
- 拼错的 RADEME.md → README.md,README 重写
- 需要真实数据库的用例改为可用 IOTLCM_TEST_WITH_DB=1 显式启用
- 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes

数据库 schema 不随本仓发布(生产 DDL 已过时),README 已说明。
This commit is contained in:
MiFaZhan
2026-10-23 14:46:05 +08:00
commit 59eb34976a
1132 changed files with 138197 additions and 0 deletions
@@ -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 := CalcPackageStatus(now, card.FlowPkgBeginAt, 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,418 @@
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)
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: consts.EffectiveStatusPending,
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: consts.EffectiveStatusPending,
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,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,544 @@
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 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,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,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,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,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,609 @@
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
}
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
}
}
if err := markAllocateCrossChunkDuplicates(ctx, parentBatchNo, req.Operator.TenantId, rows); err != nil {
return err
}
state.ChunkCount++
result, err := createAllocateChunk(ctx, req, p, parentBatchNo, state.ChunkCount, rows, importOss, state)
if err != nil {
return err
}
return common.PublishBatchMessageStrict(ctx, result, consts.QueueBatchExecute, "allocate")
}); 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
}
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) error {
if strings.TrimSpace(parentBatchNo) == "" || len(rows) == 0 {
return 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 nil
}
var existing []struct {
ItemId string `orm:"item_id"`
}
query := g.DB().Model(dao.BatchDetail.Table()).
Fields(columns.ItemId).
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 gerror.Wrap(err, "check duplicate allocate ICCIDs across chunks failed")
}
if len(existing) == 0 {
return nil
}
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 appears in an earlier allocation chunk")
}
}
return 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,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,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,246 @@
package lifecycle
import (
"context"
"sort"
"strings"
"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("同批次存在校验失败卡,本卡未请求上游")
}
}
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,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,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,418 @@
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
}
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: consts.BatchDetailStatusFailed,
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
for _, row := range task.Rows {
rows = append(rows, row)
if row != nil && row.Reason == "" {
successCount++
}
}
failCount := len(task.Rows) - successCount
validCount := 0
if task.ValidationOK {
validCount = len(task.Rows)
}
importStatus := consts.ImportStatusSuccess
batchStatus := consts.BatchAsyncStatusSuccess
runResult := consts.BatchRunResultSuccess
if failCount > 0 {
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,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,274 @@
package outbound
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/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"`
}
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
)
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 {
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 {
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 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,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,401 @@
package outbound
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/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 := strictRowsToIccids(rows)
if err != nil {
return err
}
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 gerror.New("存在不满足出库条件的卡,本次未执行")
}
return 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 {
if stock == nil {
return "ICCID不存在库存记录,不能出库"
}
if stock.CreatedDept != operator.DeptId {
return "ICCID不属于当前机构库存,不能出库"
}
if requiredSourceBatchNo != "" && strings.TrimSpace(stock.BatchNo) != requiredSourceBatchNo {
return "ICCID不属于指定库存批次,不能出库"
}
if formalExists {
return "ICCID已存在正式卡记录,不能出库"
}
if strictStockIdentity {
if strings.TrimSpace(row.Msisdn) != strings.TrimSpace(stock.Msisdn) {
return "MSISDN与库存记录不一致,不能出库"
}
if strings.TrimSpace(row.Imsi) != strings.TrimSpace(stock.Imsi) {
return "IMSI与库存记录不一致,不能出库"
}
}
return ""
}
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("同批存在不满足出库条件的卡,本行未执行")
}
}
}
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)
}
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
}
@@ -0,0 +1,299 @@
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/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
)
type Repository struct{}
func NewRepository() *Repository {
return &Repository{}
}
func (r *Repository) GetSourceBatch(ctx context.Context, batchNo string) (*entity.BatchMain, error) {
batch := &entity.BatchMain{}
columns := dao.BatchMain.Columns()
err := dao.BatchMain.Ctx(ctx).
Fields(
columns.Id,
columns.BatchNo,
columns.BatchType,
columns.BatchStatus,
columns.RunResult,
columns.TenantId,
columns.DeptId,
columns.CreatedDept,
columns.CreatedBy,
).
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
Scan(batch)
if err != nil {
return nil, err
}
if batch.Id == 0 {
return nil, nil
}
return batch, nil
}
func (r *Repository) GetRenewBatchRows(ctx context.Context, batchNo string, batchType int) ([]*Row, error) {
return r.getRenewBatchRows(ctx, nil, batchNo, batchType)
}
func (r *Repository) GetRenewBatchRowsTx(ctx context.Context, tx gdb.TX, batchNo string, batchType int) ([]*Row, error) {
return r.getRenewBatchRows(ctx, tx, batchNo, batchType)
}
func (r *Repository) getRenewBatchRows(ctx context.Context, tx gdb.TX, batchNo string, batchType int) ([]*Row, error) {
var details []*entity.BatchDetail
columns := dao.BatchDetail.Columns()
if batchType == 0 {
batchType = consts.BatchTypeRenew
}
model := dao.BatchDetail.Ctx(ctx)
if tx != nil {
model = tx.Ctx(ctx).Model(dao.BatchDetail.Table())
}
err := model.
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
}
func (r *Repository) GetSourceBatchRows(ctx context.Context, batchNo string) ([]*Row, error) {
var details []*entity.BatchDetail
columns := dao.BatchDetail.Columns()
err := dao.BatchDetail.Ctx(ctx).
Fields(columns.RowNo, columns.ItemId, columns.Snapshot).
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
Where(columns.BatchType, consts.BatchTypeOutbound).
WhereIn(columns.DetailStatus, []int{consts.BatchDetailStatusSuccess, consts.BatchAsyncStatusSuccess}).
OrderAsc(columns.RowNo).
Scan(&details)
if err != nil {
return nil, err
}
return detailsToRows(details), nil
}
func (r *Repository) BatchDetailItemIDs(ctx context.Context, tx gdb.TX, batchNo string, batchType int, statuses []int) ([]string, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return nil, nil
}
var details []*entity.BatchDetail
model := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Fields(dao.BatchDetail.Columns().ItemId).
Where(dao.BatchDetail.Columns().BatchNo, batchNo)
if batchType > 0 {
model = model.Where(dao.BatchDetail.Columns().BatchType, batchType)
}
if len(statuses) > 0 {
model = model.WhereIn(dao.BatchDetail.Columns().DetailStatus, statuses)
}
if err := model.Scan(&details); err != nil {
return nil, err
}
ids := make([]string, 0, len(details))
for _, detail := range details {
if detail == nil {
continue
}
if itemID := strings.TrimSpace(detail.ItemId); itemID != "" {
ids = append(ids, itemID)
}
}
return uniqueStrings(ids), nil
}
func (r *Repository) UnfinishedRenewBatchNosByIccids(ctx context.Context, tx gdb.TX, tenantID string, iccids []string, currentBatchNo string) (map[string]string, error) {
result := make(map[string]string)
iccids = uniqueStrings(iccids)
if len(iccids) == 0 {
return result, nil
}
currentBatchID, err := r.batchIDByNo(ctx, tx, currentBatchNo)
if err != nil {
return nil, err
}
type conflictRow struct {
ItemId string `orm:"item_id"`
BatchNo string `orm:"batch_no"`
}
bd := "bd"
bm := "bm"
detailColumns := dao.BatchDetail.Columns()
mainColumns := dao.BatchMain.Columns()
model := tx.Ctx(ctx).Model(dao.BatchDetail.Table()+" "+bd).
InnerJoin(dao.BatchMain.Table()+" "+bm, bm+"."+mainColumns.BatchNo+" = "+bd+"."+detailColumns.BatchNo).
Fields(bd+"."+detailColumns.ItemId, bm+"."+mainColumns.BatchNo).
Where(bd+"."+detailColumns.BatchType, consts.BatchTypeRenew).
Where(bm+"."+mainColumns.BatchType, consts.BatchTypeRenew).
Where(bm+"."+mainColumns.BatchStatus, consts.BatchStatusNormal).
WhereIn(bm+"."+mainColumns.BatchAsyncStatus, []int{
consts.BatchAsyncStatusCreated,
consts.BatchAsyncStatusPending,
consts.BatchAsyncStatusProcessing,
}).
WhereIn(bm+"."+mainColumns.RunResult, []int{
consts.BatchRunResultPending,
consts.BatchRunResultRunning,
}).
WhereIn(bd+"."+detailColumns.DetailStatus, []int{
consts.BatchDetailStatusPending,
consts.BatchDetailStatusRunning,
}).
WhereIn(bd+"."+detailColumns.ItemId, iccids).
OrderAsc(bm + "." + mainColumns.Id)
if strings.TrimSpace(tenantID) != "" {
model = model.Where(bm+"."+mainColumns.TenantId, strings.TrimSpace(tenantID))
}
if currentBatchID > 0 {
// When duplicate formal renew batches already exist, the earliest batch
// wins and later batches fail. This prevents two batches from blocking
// each other forever.
model = model.Where(bm+"."+mainColumns.Id+" < ?", currentBatchID)
} else if currentBatchNo = strings.TrimSpace(currentBatchNo); currentBatchNo != "" {
model = model.Where(bm+"."+mainColumns.BatchNo+" <> ?", currentBatchNo)
}
var rows []conflictRow
if err := model.Scan(&rows); err != nil {
return nil, err
}
for _, row := range rows {
itemID := strings.TrimSpace(row.ItemId)
if itemID == "" {
continue
}
if _, exists := result[itemID]; !exists {
result[itemID] = strings.TrimSpace(row.BatchNo)
}
}
return result, nil
}
func (r *Repository) batchIDByNo(ctx context.Context, tx gdb.TX, batchNo string) (int64, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return 0, nil
}
batch := &entity.BatchMain{}
if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Fields(dao.BatchMain.Columns().Id).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Limit(1).
Scan(batch); err != nil {
return 0, err
}
return batch.Id, nil
}
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())
}
}
rows = append(rows, row)
}
return rows
}
func (r *Repository) LockCardsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*entity.TSimCardInfo, error) {
result := make(map[string]*entity.TSimCardInfo)
if len(iccids) == 0 {
return result, nil
}
var list []*entity.TSimCardInfo
if err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
WhereIn(dao.TSimCardInfo.Columns().Iccid, uniqueStrings(iccids)).
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
}
func (r *Repository) LockLatestBindsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*entity.TSimCardBindPkg, error) {
result := make(map[string]*entity.TSimCardBindPkg)
if len(iccids) == 0 {
return result, nil
}
var list []*entity.TSimCardBindPkg
if err := tx.Ctx(ctx).Model(dao.TSimCardBindPkg.Table()).
WhereIn(dao.TSimCardBindPkg.Columns().Iccid, uniqueStrings(iccids)).
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 || strings.TrimSpace(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
}
@@ -0,0 +1,99 @@
package renew
import (
"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"
"github.com/gogf/gf/v2/os/gtime"
)
type Row struct {
RowNo int
InputCode string
Iccid string
Msisdn string
Imsi 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,
})
}
type CompleteInput struct {
SubmitParam *model.TSimCardRenewBatchSubmitParam
BatchNo string
Rows []*Row
Operator batchimport.OperatorContext
PriceSnapshot *model.TSimCardRenewPriceSnapshot
RenewDate *gtime.Time
}
type CompleteOutput struct {
BatchNo string
BatchType int
SourceBatchNo string
Quantity int
PackageCode string
PackageName string
OrderSn string
Amount float64
RenewUnitPrice float64
RenewDate *gtime.Time
RenewTime *gtime.Time
}
type PrepareContext struct {
CustomerAgentId int64
PackageCode string
Quantity int
RenewBeginDate string
RenewBeginDateMin string
RenewBeginDateMax string
SameRenewBeginDate bool
}
@@ -0,0 +1,27 @@
package renew
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,82 @@
package trafficcutoffconfig
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/excel"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
"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 ParseExcelFileRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
parsedRows, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{
RequiredHeaders: []string{"ICCID"},
})
if err != nil {
return nil, err
}
rows := make([]batchimport.Row, 0, len(parsedRows))
for _, parsed := range parsedRows {
rows = append(rows, &Row{
RowNo: parsed.RowNo,
Iccid: excel.Field(parsed, "ICCID"),
Msisdn: excel.Field(parsed, "MSISDN"),
Imsi: excel.Field(parsed, "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 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: "连号达量断网配置数据为空",
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,
}
})
}
@@ -0,0 +1,176 @@
package trafficcutoffconfig
import (
"context"
"fmt"
"math"
"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{}
func NewProcessor() *Processor {
return &Processor{}
}
func (p *Processor) Config() batchimport.ProcessorConfig {
return batchimport.ProcessorConfig{
Code: "sim_card_traffic_cutoff_config",
BatchType: consts.BatchTypeTrafficCutoffConfig,
BatchNoPrefix: consts.BatchNoPrefixTrafficCutoffConfig,
TaskName: consts.ResourceBatchDownloadTaskTrafficCutoffConfig,
ExportScene: consts.ResourceExportSceneTrafficCutoffConfig,
ImportDir: consts.SimCardTrafficCutoffConfigSubDirImportExcel,
ResultDir: consts.SimCardTrafficCutoffConfigSubDirResultExcel,
MaxFileSize: consts.SimCardTrafficCutoffConfigMaxFileSize,
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 {
item.AddErrorReason("达量断网配置行类型错误")
continue
}
row.Iccid = strings.TrimSpace(row.Iccid)
row.Msisdn = strings.TrimSpace(row.Msisdn)
row.Imsi = strings.TrimSpace(row.Imsi)
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(ctx 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": "traffic_cutoff_config",
"configName": "达量断网配置",
"submitScene": submitScene(req.BizParams),
"trafficCutoffStatus": options.TrafficCutoffStatus,
"trafficCutoffThreshold": options.TrafficCutoffThreshold,
"trafficCutoffCrossMonthHold": options.TrafficCutoffCrossMonthHold,
"trafficCutoffThresholdUnit": "MB",
"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 OptionsFromParams(params any) (Options, error) {
switch in := params.(type) {
case *model.TSimCardTrafficCutoffExcelSubmitParam:
if in == nil {
return Options{}, gerror.New("达量断网配置参数不能为空")
}
return NormalizeOptions(&in.TSimCardTrafficCutoffOptions)
case *model.TSimCardTrafficCutoffInputSubmitParam:
if in == nil {
return Options{}, gerror.New("达量断网配置参数不能为空")
}
return NormalizeOptions(&in.TSimCardTrafficCutoffOptions)
case *model.TSimCardTrafficCutoffRangeSubmitParam:
if in == nil {
return Options{}, gerror.New("达量断网配置参数不能为空")
}
return NormalizeOptions(&in.TSimCardTrafficCutoffOptions)
default:
return Options{}, gerror.New("达量断网配置参数不能为空")
}
}
func NormalizeOptions(in *model.TSimCardTrafficCutoffOptions) (Options, error) {
if in == nil || in.TrafficCutoffStatus == nil {
return Options{}, gerror.New("达量断网配置状态不能为空")
}
crossMonthHold := 0
if in.TrafficCutoffCrossMonthHold != nil {
crossMonthHold = *in.TrafficCutoffCrossMonthHold
}
options := Options{
TrafficCutoffCrossMonthHold: crossMonthHold,
TrafficCutoffStatus: *in.TrafficCutoffStatus,
TrafficCutoffThreshold: in.TrafficCutoffThreshold,
}
if err := ValidateConfig(options.TrafficCutoffStatus, options.TrafficCutoffThreshold, options.TrafficCutoffCrossMonthHold); err != nil {
return Options{}, err
}
return options, nil
}
func ValidateConfig(status int, threshold float64, crossMonthHoldValues ...int) error {
crossMonthHold := 0
if len(crossMonthHoldValues) > 0 {
crossMonthHold = crossMonthHoldValues[0]
}
if status != 0 && status != 1 && status != 2 {
return gerror.New("达量断网启停状态无效")
}
if threshold < 0 {
return gerror.New("达量断网阈值不能小于0")
}
if status == 0 && threshold != 0 {
return gerror.New("未配置达量断网时阈值必须为0")
}
if status == 1 && threshold <= 0 {
return gerror.New("开启达量断网时阈值必须大于0")
}
if crossMonthHold != 0 && crossMonthHold != 1 {
return gerror.New("invalid cross-month traffic cutoff hold value")
}
if status != 1 && crossMonthHold != 0 {
return gerror.New("cross-month hold requires traffic cutoff to be enabled")
}
rounded := math.Round(threshold*1000) / 1000
if math.Abs(threshold-rounded) > 0.0000001 {
return gerror.New("达量断网阈值最多保留3位小数")
}
return nil
}
func submitScene(params any) string {
switch params.(type) {
case *model.TSimCardTrafficCutoffExcelSubmitParam:
return "excel"
case *model.TSimCardTrafficCutoffInputSubmitParam:
return "input"
case *model.TSimCardTrafficCutoffRangeSubmitParam:
return "range"
default:
return ""
}
}
@@ -0,0 +1,146 @@
package trafficcutoffconfig
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
"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/internal/model"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/net/ghttp"
)
func CreatePendingExcel(ctx context.Context, file *ghttp.UploadFile, in *model.TSimCardTrafficCutoffExcelSubmitParam) (*PendingResult, error) {
if in == nil {
in = &model.TSimCardTrafficCutoffExcelSubmitParam{}
}
options, err := OptionsFromParams(in)
if err != nil {
return nil, err
}
processor := NewProcessor()
req := batchimport.SubmitRequest{
SourceChannel: batchmetadata.Console,
ExecutionMode: batchmetadata.Bulk,
File: file,
BizParams: in,
SourceType: consts.BatchSourceTypeFile,
Operator: common.CurrentOperator(ctx),
}
result, err := batchimport.CreatePendingUpload(ctx, req, processor, prepareOptions(options))
if err != nil {
return nil, err
}
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, "traffic_cutoff_config")
return pendingResultFromBatch(result), nil
}
func CreatePendingInput(ctx context.Context, in *model.TSimCardTrafficCutoffInputSubmitParam) (*PendingResult, error) {
if in == nil {
in = &model.TSimCardTrafficCutoffInputSubmitParam{}
}
options, err := OptionsFromParams(in)
if err != nil {
return nil, err
}
rows, err := ParseInputRows(ctx, in.Input)
if err != nil {
return nil, err
}
processor := NewProcessor()
req := batchimport.SubmitRequest{
SourceChannel: batchmetadata.Console,
ExecutionMode: batchmetadata.Bulk,
BizParams: in,
SourceType: consts.BatchSourceTypeBatchInput,
Operator: common.CurrentOperator(ctx),
}
result, err := batchimport.CreatePendingRows(ctx, req, processor, rows, prepareOptions(options))
if err != nil {
return nil, err
}
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, "traffic_cutoff_config")
return pendingResultFromBatch(result), nil
}
func CreatePendingRange(ctx context.Context, in *model.TSimCardTrafficCutoffRangeSubmitParam) (*PendingResult, error) {
if in == nil {
in = &model.TSimCardTrafficCutoffRangeSubmitParam{}
}
options, err := OptionsFromParams(in)
if err != nil {
return nil, err
}
rows, err := BuildRangeRows(ctx, in.StartIccid, in.EndIccid, in.StartMsisdn, in.EndMsisdn, in.StartImsi, in.EndImsi)
if err != nil {
return nil, err
}
processor := NewProcessor()
req := batchimport.SubmitRequest{
SourceChannel: batchmetadata.Console,
ExecutionMode: batchmetadata.Bulk,
BizParams: in,
SourceType: consts.BatchSourceTypeRange,
Operator: common.CurrentOperator(ctx),
}
result, err := batchimport.CreatePendingRows(ctx, req, processor, rows, prepareOptions(options))
if err != nil {
return nil, err
}
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, "traffic_cutoff_config")
return pendingResultFromBatch(result), nil
}
func prepareOptions(options Options) batchimport.PendingOptions {
return batchimport.PendingOptions{
PrepareRows: func(ctx context.Context, rows []batchimport.Row) error {
if err := 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
}, "达量断网配置行类型错误"); err != nil {
return err
}
applyConfigSnapshot(rows, options)
return nil
},
}
}
func applyConfigSnapshot(rows []batchimport.Row, options Options) {
for _, item := range rows {
row, ok := item.(*Row)
if !ok || row == nil {
continue
}
row.Config = options
}
}
func pendingResultFromBatch(result *batchimport.Result) *PendingResult {
res := &PendingResult{
BatchType: consts.BatchTypeTrafficCutoffConfig,
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
res.Total = result.TotalRows
res.ValidCount = valid
res.FailCount = result.ErrorRows
return res
}
@@ -0,0 +1,90 @@
package trafficcutoffconfig
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
Authorized bool
Reason string
Config Options
}
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 != "" || strings.TrimSpace(reason) == "" {
return
}
r.Reason = strings.TrimSpace(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,
"trafficCutoffStatus": r.Config.TrafficCutoffStatus,
"trafficCutoffThreshold": r.Config.TrafficCutoffThreshold,
"trafficCutoffCrossMonthHold": r.Config.TrafficCutoffCrossMonthHold,
})
}
type Options struct {
TrafficCutoffCrossMonthHold int
TrafficCutoffStatus int
TrafficCutoffThreshold float64
}
type PendingResult struct {
BatchNo string
BatchType int
BatchAsyncStatus int
Total int
ValidCount int
FailCount int
}
@@ -0,0 +1,17 @@
package unsub
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,50 @@
package unsub
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
"io"
)
func ParseExcelFileRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
parsedRows, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{
RequiredHeaders: []string{"ICCID"},
})
if err != nil {
return nil, err
}
return toBatchRows(parsedRows), nil
}
func ParseExcelRows(ctx context.Context, reader io.Reader) ([]*Row, error) {
parsedRows, err := excel.ParseReader(ctx, reader, excel.ParseOptions{
RequiredHeaders: []string{"ICCID"},
})
if err != nil {
return nil, err
}
return toRows(parsedRows), nil
}
func toBatchRows(parsedRows []excel.ParsedRow) []batchimport.Row {
rows := toRows(parsedRows)
result := make([]batchimport.Row, 0, len(rows))
for _, row := range rows {
result = append(result, row)
}
return result
}
func toRows(parsedRows []excel.ParsedRow) []*Row {
rows := make([]*Row, 0, len(parsedRows))
for _, parsed := range parsedRows {
rows = append(rows, &Row{
RowNo: parsed.RowNo,
Iccid: excel.Field(parsed, "ICCID"),
Msisdn: excel.Field(parsed, "MSISDN"),
Imsi: excel.Field(parsed, "IMSI"),
})
}
return rows
}
@@ -0,0 +1,230 @@
package unsub
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/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type BatchExecuteMessage struct {
BatchNo string `json:"batchNo"`
BatchType int `json:"batchType"`
}
type PendingResult struct {
BatchNo string
BatchType int
BatchAsyncStatus int
Total int
ValidCount int
FailCount int
}
type Publisher interface {
PublishBatch(ctx context.Context, msg BatchExecuteMessage) error
}
type rabbitPublisher struct{}
func (rabbitPublisher) PublishBatch(ctx context.Context, msg BatchExecuteMessage) error {
return rabbitmq.Publish(ctx, consts.QueueBatchExecute, msg)
}
var publisher Publisher = rabbitPublisher{}
func SetPublisher(p Publisher) {
if p == nil {
publisher = rabbitPublisher{}
return
}
publisher = p
}
func CreatePendingExcel(ctx context.Context, file *ghttp.UploadFile, in *model.TSimCardUnsubExcelSubmitParam) (*PendingResult, error) {
if in == nil {
in = &model.TSimCardUnsubExcelSubmitParam{}
}
processor := NewProcessor()
req := batchimport.SubmitRequest{
SourceChannel: batchmetadata.Console,
ExecutionMode: batchmetadata.Bulk,
File: file,
BizParams: in,
SourceType: consts.BatchSourceTypeFile,
Operator: operatorContext(ctx),
}
result, err := batchimport.CreatePendingUpload(ctx, req, processor, batchimport.PendingOptions{})
if err != nil {
return nil, err
}
return finalizePending(ctx, processor, result), nil
}
func CreatePendingRange(ctx context.Context, in *model.TSimCardUnsubRangeSubmitParam) (*PendingResult, error) {
if in == nil {
in = &model.TSimCardUnsubRangeSubmitParam{}
}
rows, err := BuildRangeRows(ctx, in.StartIccid, in.EndIccid, in.StartMsisdn, in.EndMsisdn, in.StartImsi, in.EndImsi)
if err != nil {
return nil, err
}
processor := NewProcessor()
req := batchimport.SubmitRequest{
SourceChannel: batchmetadata.Console,
ExecutionMode: batchmetadata.Bulk,
BizParams: in,
SourceType: consts.BatchSourceTypeRange,
Operator: operatorContext(ctx),
}
result, err := batchimport.CreatePendingRows(ctx, req, processor, rows, batchimport.PendingOptions{})
if err != nil {
return nil, err
}
return finalizePending(ctx, processor, result), nil
}
func CreatePendingInput(ctx context.Context, in *model.TSimCardUnsubInputSubmitParam) (*PendingResult, error) {
if in == nil {
in = &model.TSimCardUnsubInputSubmitParam{}
}
rows, err := ParseInputRows(ctx, in.Input)
if err != nil {
return nil, err
}
processor := NewProcessor()
req := batchimport.SubmitRequest{
SourceChannel: batchmetadata.Console,
ExecutionMode: batchmetadata.Bulk,
BizParams: in,
SourceType: consts.BatchSourceTypeBatchInput,
Operator: operatorContext(ctx),
}
result, err := batchimport.CreatePendingRows(ctx, req, processor, rows, batchimport.PendingOptions{})
if err != nil {
return nil, err
}
return finalizePending(ctx, processor, result), nil
}
func CreatePendingBatch(ctx context.Context, in *model.TSimCardUnsubBatchSubmitParam) (*PendingResult, error) {
if in == nil {
in = &model.TSimCardUnsubBatchSubmitParam{}
}
in.SourceBatchNo = strings.TrimSpace(in.SourceBatchNo)
if in.SourceBatchNo == "" {
return nil, gerror.New("来源批次号不能为空")
}
processor := NewProcessor()
repo := NewRepository()
sourceBatch, err := repo.GetSourceBatch(ctx, in.SourceBatchNo)
if err != nil {
return nil, gerror.Wrap(err, "查询来源批次失败")
}
if sourceBatch == nil {
return nil, gerror.New("来源批次不存在")
}
if tenantId := contexts.GetTenantId(ctx); tenantId != "" && sourceBatch.TenantId != tenantId {
return nil, gerror.New("来源批次不存在")
}
if sourceBatch.BatchType != consts.BatchTypeOutbound {
return nil, gerror.New("来源批次不是出库批次")
}
if sourceBatch.RunResult != consts.BatchRunResultSuccess {
return nil, gerror.New("来源批次不是成功批次")
}
rows, err := repo.GetSourceBatchRows(ctx, in.SourceBatchNo)
if err != nil {
return nil, gerror.Wrap(err, "查询来源批次卡明细失败")
}
if len(rows) == 0 {
return nil, gerror.New("来源批次没有可退订卡")
}
importRows := make([]batchimport.Row, 0, len(rows))
for _, row := range rows {
importRows = append(importRows, row)
}
req := batchimport.SubmitRequest{
SourceChannel: batchmetadata.Console,
ExecutionMode: batchmetadata.Bulk,
BizParams: in,
SourceType: consts.BatchSourceTypeBatchSelect,
SourceBatchNo: in.SourceBatchNo,
Operator: operatorContext(ctx),
}
result, err := batchimport.CreatePendingRows(ctx, req, processor, importRows, batchimport.PendingOptions{})
if err != nil {
return nil, err
}
return finalizePending(ctx, processor, result), nil
}
func finalizePending(ctx context.Context, p *Processor, result *batchimport.Result) *PendingResult {
if result == nil {
result = &batchimport.Result{}
}
if result.BatchType == 0 {
result.BatchType = p.Config().BatchType
}
publishPending(ctx, result.BatchNo, result.BatchType)
return pendingResultFromBatch(result, p)
}
func pendingResultFromBatch(result *batchimport.Result, p *Processor) *PendingResult {
if result == nil {
return &PendingResult{
BatchType: p.Config().BatchType,
BatchAsyncStatus: consts.BatchAsyncStatusPending,
}
}
valid := result.ValidRows
if result.TotalRows > 0 && valid == 0 {
valid = result.TotalRows - result.ErrorRows
if valid < 0 {
valid = 0
}
}
return &PendingResult{
BatchNo: result.BatchNo,
BatchType: result.BatchType,
BatchAsyncStatus: consts.BatchAsyncStatusPending,
Total: result.TotalRows,
ValidCount: valid,
FailCount: result.ErrorRows,
}
}
func publishPending(ctx context.Context, batchNo string, batchType int) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return
}
if batchType == 0 {
batchType = consts.BatchTypeUnsub
}
msg := BatchExecuteMessage{BatchNo: batchNo, BatchType: batchType}
if err := publisher.PublishBatch(ctx, msg); err != nil {
g.Log().Errorf(ctx, "unsub batch async publish failed, batchNo=%s, batchType=%d, tenantId=%s, error=%+v",
batchNo, batchType, contexts.GetTenantId(ctx), err)
}
}
func operatorContext(ctx context.Context) batchimport.OperatorContext {
return batchimport.OperatorContext{
TenantId: contexts.GetTenantId(ctx),
DeptId: contexts.GetDeptId(ctx),
UserId: contexts.GetUserId(ctx),
}
}
@@ -0,0 +1,148 @@
package unsub
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/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
)
type Processor struct {
config batchimport.ProcessorConfig
}
type submitOptions struct {
SourceBatchNo string
Remark string
}
func NewProcessor() *Processor {
return &Processor{
config: batchimport.ProcessorConfig{
Code: "sim_card_unsub",
BatchType: consts.BatchTypeUnsub,
BatchNoPrefix: consts.BatchNoPrefixUnsub,
TaskName: consts.ResourceBatchDownloadTaskSimCardUnsub,
ExportScene: consts.ResourceExportSceneUnsub,
ImportDir: consts.SimCardUnsubSubDirImportExcel,
ResultDir: consts.SimCardUnsubSubDirResultExcel,
MaxFileSize: consts.SimCardUnsubMaxFileSize,
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 ParseExcelFileRows(ctx, filePath)
}
func (p *Processor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
unsubRows := make([]*Row, 0, len(rows))
for _, item := range rows {
row, ok := item.(*Row)
if !ok || row == nil {
item.AddErrorReason("退订行类型错误")
continue
}
unsubRows = append(unsubRows, row)
}
return validateRows(unsubRows)
}
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{
"operation": "unsub",
"resultFile": g.Map{"status": "pending"},
"submitScene": submitScene(req.BizParams),
}
if result != nil && result.ImportFileOssId > 0 {
info["importFileOssId"] = result.ImportFileOssId
} else if importOss != nil && importOss.OssId > 0 {
info["importFileOssId"] = importOss.OssId
}
if opts, ok := maybeUnsubSubmitOptions(req.BizParams); ok {
if opts.SourceBatchNo != "" {
info["sourceBatchNo"] = opts.SourceBatchNo
}
if opts.Remark != "" {
info["remark"] = opts.Remark
}
}
return gjson.New(info)
}
func submitScene(params any) string {
switch params.(type) {
case *model.TSimCardUnsubBatchSubmitParam:
return "sourceBatch"
case *model.TSimCardUnsubExcelSubmitParam:
return "excel"
case *model.TSimCardUnsubInputSubmitParam:
return "input"
case *model.TSimCardUnsubRangeSubmitParam:
return "range"
default:
return ""
}
}
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)
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
}
func maybeUnsubSubmitOptions(params any) (submitOptions, bool) {
switch in := params.(type) {
case *model.TSimCardUnsubBatchSubmitParam:
if in == nil {
return submitOptions{}, false
}
return submitOptions{
SourceBatchNo: strings.TrimSpace(in.SourceBatchNo),
Remark: strings.TrimSpace(in.Remark),
}, true
case *model.TSimCardUnsubExcelSubmitParam:
if in == nil {
return submitOptions{}, false
}
return submitOptions{}, true
case *model.TSimCardUnsubInputSubmitParam:
if in == nil {
return submitOptions{}, false
}
return submitOptions{Remark: strings.TrimSpace(in.Remark)}, true
case *model.TSimCardUnsubRangeSubmitParam:
if in == nil {
return submitOptions{}, false
}
return submitOptions{Remark: strings.TrimSpace(in.Remark)}, true
default:
return submitOptions{}, false
}
}
@@ -0,0 +1,32 @@
package unsub
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: "连号退订数据为空",
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,
}
})
}
@@ -0,0 +1,83 @@
package unsub
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/model/entity"
)
type Repository struct{}
func NewRepository() *Repository {
return &Repository{}
}
func (r *Repository) GetSourceBatch(ctx context.Context, batchNo string) (*entity.BatchMain, error) {
batch := &entity.BatchMain{}
columns := dao.BatchMain.Columns()
err := dao.BatchMain.Ctx(ctx).
Fields(
columns.Id,
columns.BatchNo,
columns.BatchType,
columns.BatchStatus,
columns.RunResult,
columns.TenantId,
columns.DeptId,
columns.CreatedDept,
columns.CreatedBy,
).
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
Scan(batch)
if err != nil {
return nil, err
}
if batch.Id == 0 {
return nil, nil
}
return batch, nil
}
func (r *Repository) GetSourceBatchRows(ctx context.Context, batchNo string) ([]*Row, error) {
var details []*entity.BatchDetail
columns := dao.BatchDetail.Columns()
err := dao.BatchDetail.Ctx(ctx).
Fields(columns.RowNo, columns.ItemId, columns.Snapshot).
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
Where(columns.BatchType, consts.BatchTypeOutbound).
WhereIn(columns.DetailStatus, []int{consts.BatchDetailStatusSuccess, consts.BatchAsyncStatusSuccess}).
OrderAsc(columns.RowNo).
Scan(&details)
if 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 {
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())
}
}
rows = append(rows, row)
}
return rows, nil
}
@@ -0,0 +1,61 @@
package unsub
import (
"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
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,
})
}
@@ -0,0 +1,385 @@
package warningconfig
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"mime"
"net"
"net/mail"
"net/smtp"
"strings"
"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/service"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
const (
warnMethodSMS = "0"
warnMethodEmail = "1"
warnMethodPhone = "2"
warnMethodPlatform = "3"
warnEmailCodeTTLSeconds = 300
warnEmailCodeRedisKeyScope = "iotlcm:warning:email-code"
)
func SendEmailCode(ctx context.Context, in *model.TSimWarningConfigEmailCodeParam) error {
if in == nil {
return gerror.New("邮箱验证码参数不能为空")
}
email, err := normalizeSingleWarnEmail(in.Email)
if err != nil {
return err
}
if strings.TrimSpace(in.CaptchaID) == "" || strings.TrimSpace(in.CaptchaValue) == "" {
return gerror.New("图片验证码不能为空")
}
if err = service.SysCaptcha().VerifyCaptcha(ctx, in.CaptchaID, in.CaptchaValue); err != nil {
return err
}
userID := contexts.GetUserId(ctx)
tenantID := contexts.GetTenantId(ctx)
if userID <= 0 || strings.TrimSpace(tenantID) == "" {
return gerror.New("登录信息已过期")
}
code, err := generateWarnEmailCode()
if err != nil {
return gerror.Wrap(err, "生成邮箱验证码失败")
}
if err = setWarnEmailCode(ctx, tenantID, userID, email, code); err != nil {
return gerror.Wrap(err, "保存邮箱验证码失败,请稍后重试")
}
return sendWarnEmailCode(ctx, email, code)
}
func ValidateEmailCode(ctx context.Context, options Options) error {
return validateEmailCode(ctx, options, false)
}
func ConsumeEmailCode(ctx context.Context, options Options) error {
return validateEmailCode(ctx, options, true)
}
func validateEmailCode(ctx context.Context, options Options, consume bool) error {
if !hasEmailMethod(options.WarnMethods) {
return nil
}
email, err := normalizeSingleWarnEmail(options.WarnEmails)
if err != nil {
return err
}
code := strings.TrimSpace(options.WarnEmailCode)
if code == "" {
return gerror.New("请选择邮箱提醒时必须填写邮箱验证码")
}
userID := contexts.GetUserId(ctx)
tenantID := contexts.GetTenantId(ctx)
if userID <= 0 || strings.TrimSpace(tenantID) == "" {
return gerror.New("登录信息已过期")
}
cacheCode, err := getWarnEmailCode(ctx, tenantID, userID, email)
if err != nil {
return gerror.Wrap(err, "获取邮箱验证码失败,请稍后重试")
}
if cacheCode == "" {
return gerror.New("邮箱验证码已失效")
}
if cacheCode != code {
return gerror.New("邮箱验证码错误")
}
if consume {
return deleteWarnEmailCode(ctx, tenantID, userID, email)
}
return nil
}
func validateNotificationOptions(options *Options) error {
if options == nil {
return gerror.New("预警配置参数不能为空")
}
if hasSMSMethod(options.WarnMethods) {
phones, err := normalizeWarnPhones(options.WarnPhones)
if err != nil {
return err
}
options.WarnPhones = phones
}
if hasEmailMethod(options.WarnMethods) {
emails, err := normalizeWarnEmails(options.WarnEmails)
if err != nil {
return err
}
options.WarnEmails = emails
}
return nil
}
func hasSMSMethod(methods string) bool {
for _, method := range splitWarnList(methods) {
if method == warnMethodSMS {
return true
}
}
return false
}
func hasEmailMethod(methods string) bool {
for _, method := range splitWarnList(methods) {
if method == warnMethodEmail {
return true
}
}
return false
}
func normalizeSingleWarnEmail(value string) (string, error) {
emails := splitWarnList(value)
if len(emails) == 0 {
return "", gerror.New("请选择邮箱提醒时必须填写邮箱地址")
}
if len(emails) > 1 {
return "", gerror.New("邮箱提醒验证码第一版仅支持一个邮箱地址")
}
address, err := mail.ParseAddress(emails[0])
if err != nil || address == nil || strings.TrimSpace(address.Address) == "" {
return "", gerror.New("邮箱格式不正确")
}
return strings.ToLower(strings.TrimSpace(address.Address)), nil
}
func normalizeWarnEmails(value string) (string, error) {
emails := splitWarnList(value)
if len(emails) == 0 {
return "", gerror.New("请选择邮箱提醒时必须填写邮箱地址")
}
result := make([]string, 0, len(emails))
for _, email := range emails {
address, err := mail.ParseAddress(email)
if err != nil || address == nil || strings.TrimSpace(address.Address) == "" {
return "", gerror.Newf("邮箱格式不正确:%s", email)
}
result = append(result, strings.ToLower(strings.TrimSpace(address.Address)))
}
return strings.Join(result, ","), nil
}
func normalizeWarnPhones(value string) (string, error) {
phones := splitWarnList(value)
if len(phones) == 0 {
return "", gerror.New("请选择短信提醒时必须填写短信号码")
}
for _, phone := range phones {
if !isValidWarnPhone(phone) {
return "", gerror.Newf("手机号格式不正确:%s", phone)
}
}
return strings.Join(phones, ","), nil
}
func isValidWarnPhone(phone string) bool {
if len(phone) != 11 || phone[0] != '1' {
return false
}
for _, r := range phone {
if r < '0' || r > '9' {
return false
}
}
return phone[1] >= '3' && phone[1] <= '9'
}
func splitWarnList(value string) []string {
parts := strings.FieldsFunc(value, func(r rune) bool {
switch r {
case ',', ';', ',', ';', '|', '\n', '\r', '\t', ' ':
return true
default:
return false
}
})
seen := make(map[string]struct{}, len(parts))
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
key := strings.ToLower(part)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
result = append(result, part)
}
return result
}
func warnEmailCodeKey(tenantID string, userID int64, email string) string {
return fmt.Sprintf("%s:%s:%d:%s", warnEmailCodeRedisKeyScope, strings.TrimSpace(tenantID), userID, strings.ToLower(strings.TrimSpace(email)))
}
func setWarnEmailCode(ctx context.Context, tenantID string, userID int64, email string, code string) error {
_, err := g.Redis().Do(ctx, "SETEX", warnEmailCodeKey(tenantID, userID, email), warnEmailCodeTTLSeconds, code)
return err
}
func getWarnEmailCode(ctx context.Context, tenantID string, userID int64, email string) (string, error) {
value, err := g.Redis().Get(ctx, warnEmailCodeKey(tenantID, userID, email))
if err != nil {
return "", err
}
if value.IsNil() {
return "", nil
}
return value.String(), nil
}
func deleteWarnEmailCode(ctx context.Context, tenantID string, userID int64, email string) error {
_, err := g.Redis().Del(ctx, warnEmailCodeKey(tenantID, userID, email))
return err
}
func generateWarnEmailCode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
func sendWarnEmailCode(ctx context.Context, email string, code string) error {
cfg, ok := loadWarnEmailCodeConfig(ctx)
if !ok || strings.TrimSpace(cfg.Host) == "" {
if strings.TrimSpace(cfg.Url) != "" {
return callWarnEmailCodeHTTP(ctx, cfg.UserCodeHttpSenderConfig, email, code)
}
g.Log().Infof(ctx, "warning email config code, email:%s, code:%s", email, code)
return nil
}
if cfg.Port <= 0 {
cfg.Port = 25
}
if strings.TrimSpace(cfg.From) == "" {
cfg.From = cfg.Username
}
if strings.TrimSpace(cfg.From) == "" {
return gerror.New("预警邮箱验证码发件人为空")
}
addr := net.JoinHostPort(cfg.Host, gconv.String(cfg.Port))
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
msg := buildWarnEmailCodeMessage(cfg, email, code)
err := utility.SendSMTP(ctx, utility.SMTPOptions{
Address: addr,
Host: cfg.Host,
Auth: auth,
From: cfg.From,
To: []string{email},
Data: msg,
SSL: cfg.SSL,
Timeout: utility.ExternalRequestTimeout(cfg.Timeout),
})
if err != nil {
g.Log().Errorf(ctx, "send warning email config code failed, email:%s, err:%+v", email, err)
return gerror.New("发送邮箱验证码失败,请稍后重试")
}
return nil
}
func loadWarnEmailCodeConfig(ctx context.Context) (model.UserCodeEmailSenderConfig, bool) {
var cfg model.UserCodeEmailSenderConfig
for _, key := range []string{"warning.email", "register.email"} {
v, err := g.Cfg().Get(ctx, key)
if err != nil || v.IsNil() {
continue
}
_ = v.Scan(&cfg)
if strings.TrimSpace(cfg.Url) != "" || strings.TrimSpace(cfg.Host) != "" {
return cfg, true
}
}
return cfg, false
}
func buildWarnEmailCodeMessage(cfg model.UserCodeEmailSenderConfig, email string, code string) []byte {
subject := strings.TrimSpace(cfg.Subject)
if subject == "" || subject == "注册验证码" {
subject = "预警邮箱验证码"
}
body := renderWarnEmailCodeTemplate(cfg.BodyTemplate, email, code)
if body == "" || strings.Contains(body, "注册验证码") {
body = fmt.Sprintf("您的预警邮箱验证码是:%s,5分钟内有效。", code)
}
from := strings.TrimSpace(cfg.From)
if strings.TrimSpace(cfg.FromName) != "" {
from = mime.QEncoding.Encode("UTF-8", cfg.FromName) + " <" + from + ">"
}
message := strings.Builder{}
message.WriteString("From: " + from + "\r\n")
message.WriteString("To: " + email + "\r\n")
message.WriteString("Subject: " + mime.QEncoding.Encode("UTF-8", subject) + "\r\n")
message.WriteString("MIME-Version: 1.0\r\n")
message.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
message.WriteString("\r\n")
message.WriteString(body)
return []byte(message.String())
}
func renderWarnEmailCodeTemplate(template string, contact string, code string) string {
if strings.TrimSpace(template) == "" {
return ""
}
replacer := strings.NewReplacer(
"{contact}", contact,
"{{contact}}", contact,
"{email}", contact,
"{{email}}", contact,
"{code}", code,
"{{code}}", code,
)
return replacer.Replace(template)
}
func callWarnEmailCodeHTTP(ctx context.Context, cfg model.UserCodeHttpSenderConfig, email string, code string) error {
method := strings.ToUpper(strings.TrimSpace(cfg.Method))
if method == "" {
method = "POST"
}
successStatus := cfg.SuccessStatus
if successStatus <= 0 {
successStatus = 200
}
body := renderWarnEmailCodeTemplate(cfg.BodyTemplate, email, code)
if body == "" {
bodyBytes, _ := json.Marshal(g.Map{
"contact": email,
"email": email,
"code": code,
})
body = string(bodyBytes)
}
headers := cfg.Headers
if headers == nil {
headers = map[string]string{}
}
if _, ok := headers["Content-Type"]; !ok {
headers["Content-Type"] = "application/json"
}
resp, err := g.Client().Timeout(utility.ExternalRequestTimeout(cfg.Timeout)).SetHeaderMap(headers).DoRequest(ctx, method, cfg.Url, body)
if err != nil {
return gerror.Wrap(err, "发送邮箱验证码失败,请稍后重试")
}
defer resp.Close()
if resp.StatusCode != successStatus {
return gerror.Newf("发送邮箱验证码失败,服务响应状态码:%d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,99 @@
package warningconfig
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, itemType, warnUnit int) ([]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, ItemType: itemType}
fields := parseInputFields(line.Text)
if isCompleteInputRow(itemType, fields) {
row.ConfigSet = true
row.Config, err = parseInputConfig(itemType, warnUnit, fields[inputConfigOffset(itemType):])
if err != nil {
row.AddErrorReason(err.Error())
}
if itemType == warnItemTypeFlowPool {
row.FlowPoolCode = strings.TrimSpace(fields[0])
} else {
row.Iccid = strings.TrimSpace(fields[0])
row.Msisdn = strings.TrimSpace(fields[1])
row.Imsi = strings.TrimSpace(fields[2])
}
rows = append(rows, row)
continue
}
if itemType == warnItemTypeFlowPool {
fields = strings.Fields(line.Text)
if len(fields) == 1 {
row.FlowPoolCode = fields[0]
} else {
row.AddErrorReason("格式错误,应为流量池编号")
}
rows = append(rows, row)
continue
}
fields = strings.Fields(line.Text)
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("格式错误,应为 ICCID 或 ICCID MSISDN IMSI")
}
rows = append(rows, row)
}
return rows, nil
}
func parseInputFields(value string) []string {
value = strings.TrimSpace(value)
if strings.Contains(value, "|") {
parts := strings.Split(value, "|")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
if strings.Contains(value, "\t") {
parts := strings.Split(value, "\t")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
return nil
}
func inputConfigOffset(itemType int) int {
if itemType == warnItemTypeFlowPool {
return 1
}
return 3
}
func isCompleteInputRow(itemType int, fields []string) bool {
if itemType == warnItemTypeFlowPool {
return len(fields) == 9
}
return len(fields) == 12
}

Some files were not shown because too many files have changed in this diff Show More