sync: 同步 v1.0.4 并脱敏配置与镜像
This commit is contained in:
@@ -25,7 +25,7 @@ func (s *Service) UpdateCardsAfterRenew(ctx context.Context, tx gdb.TX, renew *R
|
||||
if bind == nil {
|
||||
return gerror.Newf("ICCID续费订购记录不存在:%s", iccid)
|
||||
}
|
||||
packageStatus := CalcPackageStatus(now, card.FlowPkgBeginAt, bind.ExpectedExpireTime)
|
||||
packageStatus, _ := calcPackageStatuses(now, bind.EffectiveTime, bind.ExpectedExpireTime)
|
||||
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
|
||||
Where(dao.TSimCardInfo.Columns().Iccid, iccid).
|
||||
Data(do.TSimCardInfo{
|
||||
|
||||
@@ -187,6 +187,7 @@ func (s *Service) RenewCurrentPackage(ctx context.Context, tx gdb.TX, in *RenewC
|
||||
latestBind := latestBindsByIccid[iccid]
|
||||
periodBegin := renewPeriodBeginAt(renewBaseExpireTime(card, latestBind), in.PackageBeginTime)
|
||||
periodEnd := addMonths(periodBegin, durationMonths+giftDurationMonths)
|
||||
_, effectiveStatus := calcPackageStatuses(now, periodBegin, periodEnd)
|
||||
subscriptionNo, err := batchimport.NextRandomNo(subscriptionNoPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -201,7 +202,7 @@ func (s *Service) RenewCurrentPackage(ctx context.Context, tx gdb.TX, in *RenewC
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: currentPackageCode,
|
||||
BusinessType: consts.PackageBusinessTypeRenew,
|
||||
EffectiveStatus: consts.EffectiveStatusPending,
|
||||
EffectiveStatus: effectiveStatus,
|
||||
HandleTime: now,
|
||||
EffectiveTime: periodBegin,
|
||||
ExpectedExpireTime: periodEnd,
|
||||
@@ -227,7 +228,7 @@ func (s *Service) RenewCurrentPackage(ctx context.Context, tx gdb.TX, in *RenewC
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: currentPackageCode,
|
||||
BusinessType: consts.PackageBusinessTypeRenew,
|
||||
EffectiveStatus: consts.EffectiveStatusPending,
|
||||
EffectiveStatus: effectiveStatus,
|
||||
HandleTime: now,
|
||||
EffectiveTime: periodBegin,
|
||||
ExpectedExpireTime: periodEnd,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestValidateRenewCardStatusRejectsStoppedCard(t *testing.T) {
|
||||
err := validateRenewCardStatus("89860401788404184188", &entity.TSimCardInfo{
|
||||
BossCardStatus: consts.V3ApiSimBossCardStatusStopped,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("validateRenewCardStatus() error = nil, want stopped card error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "当前为停机状态") {
|
||||
t.Fatalf("validateRenewCardStatus() error = %q, want stopped card error", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package cardpackage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCardPackageBindChunkSizeSupportsFiftyThousandCards(t *testing.T) {
|
||||
const totalCards = 50000
|
||||
if cardPackageBindInsertBatchSize <= 0 || cardPackageBindInsertBatchSize > 500 {
|
||||
t.Fatalf("cardPackageBindInsertBatchSize = %d, want 1..500", cardPackageBindInsertBatchSize)
|
||||
}
|
||||
if chunks := (totalCards + cardPackageBindInsertBatchSize - 1) / cardPackageBindInsertBatchSize; chunks != 100 {
|
||||
t.Fatalf("bind insert chunks = %d, want 100", chunks)
|
||||
}
|
||||
}
|
||||
@@ -506,6 +506,10 @@ func CalcPackageStatus(now, beginAt, endAt *gtime.Time) int {
|
||||
return consts.PackageStatusEffective
|
||||
}
|
||||
|
||||
func calcPackageStatuses(now, beginAt, endAt *gtime.Time) (packageStatus, effectiveStatus int) {
|
||||
return CalcPackageStatus(now, beginAt, endAt), CalcEffectiveStatus(now, beginAt, endAt)
|
||||
}
|
||||
|
||||
func CalcEffectiveStatus(now, beginAt, endAt *gtime.Time) int {
|
||||
if beginAt != nil && beginAt.After(now) {
|
||||
return consts.EffectiveStatusPending
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func TestOrderTypeForBusinessType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
businessType int
|
||||
want int
|
||||
}{
|
||||
{name: "subscribe", businessType: consts.PackageBusinessTypeSubscribe, want: consts.OrderTypeCardSubscribe},
|
||||
{name: "renew", businessType: consts.PackageBusinessTypeRenew, want: consts.OrderTypeCardRenew},
|
||||
{name: "change", businessType: consts.PackageBusinessTypeChange, want: consts.OrderTypeCardChange},
|
||||
{name: "unknown defaults to subscribe", businessType: 99, want: consts.OrderTypeCardSubscribe},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := orderTypeForBusinessType(test.businessType); got != test.want {
|
||||
t.Fatalf("orderTypeForBusinessType(%d) = %d, want %d", test.businessType, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalcPackageStatuses(t *testing.T) {
|
||||
now := gtime.NewFromStr("2026-10-27 12:00:00")
|
||||
tests := []struct {
|
||||
name string
|
||||
beginAt, endAt *gtime.Time
|
||||
wantPackage, wantBind int
|
||||
}{
|
||||
{
|
||||
name: "renew period already effective",
|
||||
beginAt: gtime.NewFromStr("2026-09-30 23:59:59"),
|
||||
endAt: gtime.NewFromStr("2027-09-30 23:59:59"),
|
||||
wantPackage: consts.PackageStatusEffective,
|
||||
wantBind: consts.EffectiveStatusEffective,
|
||||
},
|
||||
{
|
||||
name: "renew period starts in future",
|
||||
beginAt: gtime.NewFromStr("2026-11-01 23:59:59"),
|
||||
endAt: gtime.NewFromStr("2027-11-01 23:59:59"),
|
||||
wantPackage: consts.PackageStatusPending,
|
||||
wantBind: consts.EffectiveStatusPending,
|
||||
},
|
||||
{
|
||||
name: "renew period already expired",
|
||||
beginAt: gtime.NewFromStr("2025-09-30 23:59:59"),
|
||||
endAt: gtime.NewFromStr("2026-09-30 23:59:59"),
|
||||
wantPackage: consts.PackageStatusExpired,
|
||||
wantBind: consts.EffectiveStatusExpired,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
packageStatus, effectiveStatus := calcPackageStatuses(now, test.beginAt, test.endAt)
|
||||
if packageStatus != test.wantPackage || effectiveStatus != test.wantBind {
|
||||
t.Fatalf("calcPackageStatuses() = (%d, %d), want (%d, %d)", packageStatus, effectiveStatus, test.wantPackage, test.wantBind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type channelDisplayScope struct {
|
||||
TenantID string
|
||||
DeptID int64
|
||||
FullName bool
|
||||
}
|
||||
|
||||
// Channel names are private to the tenant's top organization. A broad data
|
||||
// scope or an administrator role does not grant downstream organizations names.
|
||||
func canViewChannelFullName(ctx context.Context) bool {
|
||||
return resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
var dept *entity.SysDept
|
||||
cols := dao.SysDept.Columns()
|
||||
err := dao.SysDept.Ctx(ctx).Fields(cols.DeptId, cols.ParentId, cols.Ancestors).
|
||||
Where(cols.TenantId, contexts.GetTenantId(ctx)).
|
||||
Where(cols.DeptId, contexts.GetDeptId(ctx)).Scan(&dept)
|
||||
return dept, err
|
||||
})
|
||||
}
|
||||
|
||||
func resolveChannelDisplayScope(ctx context.Context, load func() (*entity.SysDept, error)) bool {
|
||||
user := contexts.GetUser(ctx)
|
||||
if user == nil || user.DeptId <= 0 || strings.TrimSpace(user.TenantId) == "" {
|
||||
return false
|
||||
}
|
||||
const key = "iot.channelDisplayScope"
|
||||
if cached, ok := contexts.GetDataValue(ctx, key).(channelDisplayScope); ok && cached.TenantID == user.TenantId && cached.DeptID == user.DeptId {
|
||||
return cached.FullName
|
||||
}
|
||||
dept, err := load()
|
||||
fullName := err == nil && dept != nil && dept.DeptId == user.DeptId &&
|
||||
(dept.ParentId == 0 || strings.Trim(dept.Ancestors, ",") == "0")
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "resolve channel display organization failed: %v", err)
|
||||
}
|
||||
contexts.SetDataValue(ctx, key, channelDisplayScope{TenantID: user.TenantId, DeptID: user.DeptId, FullName: fullName})
|
||||
return fullName
|
||||
}
|
||||
|
||||
func channelDisplayNameColumn(ctx context.Context) string {
|
||||
if canViewChannelFullName(ctx) {
|
||||
return dao.TSimChannels.Columns().ChanName
|
||||
}
|
||||
return dao.TSimChannels.Columns().ChanAlias
|
||||
}
|
||||
|
||||
func channelDisplayNameSQL(ctx context.Context, table string) string {
|
||||
return fmt.Sprintf("COALESCE(NULLIF(TRIM(%s.%s), ''), '--')", table, channelDisplayNameColumn(ctx))
|
||||
}
|
||||
|
||||
func channelDisplayName(fullName bool, name, alias string) string {
|
||||
if fullName {
|
||||
return name
|
||||
}
|
||||
if alias = strings.TrimSpace(alias); alias != "" {
|
||||
return alias
|
||||
}
|
||||
return "--"
|
||||
}
|
||||
|
||||
func applyCardChannelDisplayNames(ctx context.Context, list []*model.TSimCardInfoListModel) {
|
||||
if len(list) == 0 {
|
||||
return
|
||||
}
|
||||
fullName := canViewChannelFullName(ctx)
|
||||
for _, item := range list {
|
||||
if item != nil {
|
||||
item.ChanName = channelDisplayName(fullName, item.ChanName, item.ChanAlias)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func channelDisplayTestContext(tenant string, dept int64) context.Context {
|
||||
return contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{
|
||||
ID: 1, TenantId: tenant, DeptId: dept,
|
||||
Roles: []model.Role{{RoleId: consts.SuperAdminRoleId, DataScope: consts.SysRoleDataScopeAll}},
|
||||
}}})
|
||||
}
|
||||
|
||||
func TestChannelDisplayScope(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
dept *entity.SysDept
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "top organization", dept: &entity.SysDept{DeptId: 100, ParentId: 0}, want: true},
|
||||
{name: "top organization legacy ancestors", dept: &entity.SysDept{DeptId: 100, ParentId: 1, Ancestors: "0"}, want: true},
|
||||
{name: "downstream super admin still uses alias", dept: &entity.SysDept{DeptId: 100, ParentId: 50, Ancestors: "0,50"}},
|
||||
{name: "missing organization"},
|
||||
{name: "different organization", dept: &entity.SysDept{DeptId: 999, ParentId: 0}},
|
||||
{name: "lookup failure", err: errors.New("lookup failed")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 100)
|
||||
got := resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { return tc.dept, tc.err })
|
||||
if got != tc.want {
|
||||
t.Fatalf("full name = %v, want %v", got, tc.want)
|
||||
}
|
||||
if got := canViewChannelFullName(ctx); got != tc.want {
|
||||
t.Fatalf("cached scope = %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, ctx := range []context.Context{context.Background(), channelDisplayTestContext("", 100), channelDisplayTestContext("tenant-A", 0)} {
|
||||
if resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
t.Fatal("invalid identity should not query organizations")
|
||||
return nil, nil
|
||||
}) {
|
||||
t.Fatal("invalid identity got full names")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplayScopeCacheIdentity(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 100)
|
||||
calls := 0
|
||||
load := func() (*entity.SysDept, error) {
|
||||
calls++
|
||||
return &entity.SysDept{DeptId: contexts.GetDeptId(ctx), ParentId: 0}, nil
|
||||
}
|
||||
if !resolveChannelDisplayScope(ctx, load) || !resolveChannelDisplayScope(ctx, load) || calls != 1 {
|
||||
t.Fatal("scope not cached for same identity")
|
||||
}
|
||||
contexts.GetUser(ctx).DeptId = 200
|
||||
resolveChannelDisplayScope(ctx, load)
|
||||
contexts.GetUser(ctx).TenantId = "tenant-B"
|
||||
resolveChannelDisplayScope(ctx, load)
|
||||
if calls != 3 {
|
||||
t.Fatalf("scope reused across organization or tenant: %d calls", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplayAliasAndExport(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 200)
|
||||
resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
return &entity.SysDept{DeptId: 200, ParentId: 100, Ancestors: "0,100"}, nil
|
||||
})
|
||||
list := []*model.TSimCardInfoListModel{
|
||||
{ChanName: "PRIVATE UPSTREAM NAME", ChanAlias: " 中国移动223 "},
|
||||
{ChanName: "PRIVATE UPSTREAM NAME", ChanAlias: " "},
|
||||
nil,
|
||||
}
|
||||
applyCardChannelDisplayNames(ctx, list)
|
||||
if list[0].ChanName != "中国移动223" || list[1].ChanName != "--" {
|
||||
t.Fatalf("name leak: %+v %+v", list[0], list[1])
|
||||
}
|
||||
for _, card := range list[:2] {
|
||||
row := cancellationExportRow(&model.TSimCardCancellationListModel{ChanName: card.ChanName})
|
||||
if row[7] != card.ChanName {
|
||||
t.Fatalf("export name differs: %v", row[7])
|
||||
}
|
||||
}
|
||||
if channelDisplayName(true, "full name", "alias") != "full name" {
|
||||
t.Fatal("top organization name changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplaySQL(t *testing.T) {
|
||||
db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close(context.Background()) })
|
||||
if _, err = db.Exec(context.Background(), "CREATE TABLE channels(chan_name TEXT,chan_alias TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, alias := range []interface{}{"中国移动223", "", " ", nil} {
|
||||
if _, err = db.Exec(context.Background(), "INSERT INTO channels VALUES(?,?)", "PRIVATE UPSTREAM NAME", alias); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, top := range []bool{false, true} {
|
||||
ctx := channelDisplayTestContext("tenant-A", 100)
|
||||
parent := int64(50)
|
||||
if top {
|
||||
parent = 0
|
||||
}
|
||||
resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
return &entity.SysDept{DeptId: 100, ParentId: parent, Ancestors: "0,50"}, nil
|
||||
})
|
||||
values, err := db.GetArray(ctx, "SELECT "+channelDisplayNameSQL(ctx, "channels")+" FROM channels ORDER BY rowid")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, v := range values {
|
||||
want := "--"
|
||||
if i == 0 {
|
||||
want = "中国移动223"
|
||||
}
|
||||
if top {
|
||||
want = "PRIVATE UPSTREAM NAME"
|
||||
}
|
||||
if v.String() != want {
|
||||
t.Fatalf("top=%v row=%d got=%q want=%q", top, i, v.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the real list query and export formatter against an isolated database.
|
||||
func TestChannelDisplayCancellationList(t *testing.T) {
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := g.DB()
|
||||
t.Cleanup(func() { _ = db.Close(context.Background()); _ = gdb.SetConfig(gdb.Config{"default": previous}) })
|
||||
statements := []string{
|
||||
"CREATE TABLE sys_dept(dept_id INTEGER,tenant_id TEXT,parent_id INTEGER,ancestors TEXT,dept_name TEXT,deleted_at TEXT)",
|
||||
"INSERT INTO sys_dept VALUES(100,'tenant-A',0,'0','Top',NULL),(200,'tenant-A',100,'0,100','Customer',NULL)",
|
||||
"CREATE TABLE t_sim_channels(id INTEGER,temp_key TEXT,chan_key TEXT,tenant_id TEXT,chan_name TEXT,chan_alias TEXT)",
|
||||
"CREATE TABLE t_sim_channel_template(temp_key TEXT,temp_name TEXT,temp_model TEXT)",
|
||||
"INSERT INTO t_sim_channels(id,chan_key,tenant_id,chan_name,chan_alias) VALUES(1,'SHARED','tenant-A','PRIVATE NAME A','中国移动223'),(2,'SHARED','tenant-B','PRIVATE NAME B','Other tenant alias')",
|
||||
"CREATE TABLE t_sim_flow_package_info(package_code TEXT,package_name TEXT,tenant_id TEXT)",
|
||||
"INSERT INTO t_sim_flow_package_info VALUES('PKG','套餐A','tenant-A'),('PKG','套餐B','tenant-B')",
|
||||
"CREATE TABLE t_sim_card_cancellation(id INTEGER,iccid TEXT,msisdn TEXT,imsi TEXT,imei TEXT,dept_id INTEGER,chan_key TEXT,cancel_batch_no TEXT,cancelled_at TEXT,boss_card_status INTEGER,current_package_code TEXT,inbound_batch_no TEXT,outbound_batch_no TEXT,allocation_batch_no TEXT,card_type INTEGER,net_model INTEGER,remark TEXT,created_by INTEGER,tenant_id TEXT)",
|
||||
"INSERT INTO t_sim_card_cancellation(id,iccid,dept_id,chan_key,created_by,tenant_id,current_package_code) VALUES(1,'CARD-A',200,'SHARED',0,'tenant-A','PKG'),(2,'CARD-B',200,'SHARED',0,'tenant-B','PKG')",
|
||||
}
|
||||
for _, sql := range statements {
|
||||
if _, err := db.Exec(context.Background(), sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, dept := range []int64{200, 100} {
|
||||
ctx := channelDisplayTestContext("tenant-A", dept)
|
||||
contexts.GetUser(ctx).Roles = []model.Role{{RoleId: 99, DataScope: consts.SysRoleDataScopeAll}}
|
||||
in := &model.TSimCardCancellationListParam{}
|
||||
in.Page = 1
|
||||
in.PageSize = 20
|
||||
list, total, err := NewIotTSimCardCancellation().List(ctx, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(list) != 1 || list[0].Iccid != "CARD-A" {
|
||||
t.Fatalf("cross tenant rows: total=%d list=%+v", total, list)
|
||||
}
|
||||
want := "中国移动223"
|
||||
if dept == 100 {
|
||||
want = "PRIVATE NAME A"
|
||||
}
|
||||
if list[0].PackageName != "套餐A" {
|
||||
t.Fatalf("cross tenant package: %q", list[0].PackageName)
|
||||
}
|
||||
if list[0].ChanName != want {
|
||||
t.Fatalf("dept=%d name=%q want=%q", dept, list[0].ChanName, want)
|
||||
}
|
||||
if row := cancellationExportRow(list[0]); row[7] != want {
|
||||
t.Fatalf("export name=%v", row[7])
|
||||
}
|
||||
channelQuery := &model.TSimChannelsListParam{}
|
||||
channelQuery.Page = 1
|
||||
channelQuery.PageSize = 20
|
||||
channelQuery.ChanName = want
|
||||
channels, count, err := NewIotTSimChannels().List(ctx, channelQuery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 || len(channels) != 1 || channels[0].ChanName != want {
|
||||
t.Fatalf("channel options: count=%d rows=%+v", count, channels)
|
||||
}
|
||||
stockNames, err := loadStockChannelNames(ctx, []string{"SHARED"})
|
||||
if err != nil || stockNames["SHARED"] != want {
|
||||
t.Fatalf("stock channel name=%v err=%v", stockNames, err)
|
||||
}
|
||||
channelView, err := NewIotTSimChannels().View(ctx, &model.TSimChannelsViewParam{Id: 1})
|
||||
if err != nil || channelView == nil || channelView.ChanName != want {
|
||||
t.Fatalf("channel view=%+v err=%v", channelView, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplayCancellationMissingAlias(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 200)
|
||||
resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { return &entity.SysDept{DeptId: 200, ParentId: 100}, nil })
|
||||
for _, alias := range []string{"", " "} {
|
||||
name := channelDisplayName(canViewChannelFullName(ctx), "PRIVATE NAME", alias)
|
||||
encoded, err := json.Marshal(&model.TSimCardCancellationListModel{ChanName: name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "PRIVATE NAME") || name != "--" {
|
||||
t.Fatalf("response exposes full name: %s", encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolvePackageEffectiveSalePriceUsesSystemDefaults(t *testing.T) {
|
||||
got, err := resolvePackageEffectiveSalePrice(
|
||||
context.Background(),
|
||||
nil,
|
||||
"tenant-1",
|
||||
0,
|
||||
0,
|
||||
packagePriceSource{
|
||||
PackageCode: "PKG-1",
|
||||
CostPrice: 10,
|
||||
TerminalCost: 20,
|
||||
SysDefaultRatio: 80,
|
||||
SysDefaultProfit: 1,
|
||||
SysDefaultTerminalRatio: 50,
|
||||
SysDefaultTerminalProfit: 2,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePackageEffectiveSalePrice() error = %v", err)
|
||||
}
|
||||
if got.AgentShipmentPrice != 9 {
|
||||
t.Fatalf("agent shipment price = %v, want 9", got.AgentShipmentPrice)
|
||||
}
|
||||
if got.TerminalSalePrice != 12 {
|
||||
t.Fatalf("terminal sale price = %v, want 12", got.TerminalSalePrice)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package packageprice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
type fakePriceStore struct {
|
||||
parents map[int64]int64
|
||||
prices map[priceKey]*entity.TSimFlowPackagePrice
|
||||
}
|
||||
|
||||
type priceKey struct {
|
||||
deptID int64
|
||||
scopeType int
|
||||
targetAgentID int64
|
||||
packageCode string
|
||||
}
|
||||
|
||||
func (s fakePriceStore) ParentDeptID(ctx context.Context, tenantID string, deptID int64) (int64, error) {
|
||||
return s.parents[deptID], nil
|
||||
}
|
||||
|
||||
func (s fakePriceStore) PriceOverride(ctx context.Context, tenantID string, deptID int64, scopeType int, targetAgentID int64, packageCode string) (*entity.TSimFlowPackagePrice, error) {
|
||||
return s.prices[priceKey{
|
||||
deptID: deptID,
|
||||
scopeType: scopeType,
|
||||
targetAgentID: targetAgentID,
|
||||
packageCode: packageCode,
|
||||
}], nil
|
||||
}
|
||||
|
||||
func TestResolveEffectiveSalePriceUsesRecursiveParentCost(t *testing.T) {
|
||||
store := fakePriceStore{
|
||||
parents: map[int64]int64{
|
||||
117: 100,
|
||||
131: 117,
|
||||
},
|
||||
prices: map[priceKey]*entity.TSimFlowPackagePrice{
|
||||
{deptID: 100, scopeType: 1, targetAgentID: 117, packageCode: "yd-100m-3y"}: {
|
||||
Id: 86,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 4,
|
||||
UserCostRatio: 1,
|
||||
UserCostProfit: 4,
|
||||
},
|
||||
{deptID: 117, scopeType: 0, targetAgentID: 0, packageCode: "yd-100m-3y"}: {
|
||||
Id: 91,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 1,
|
||||
UserCostRatio: 1,
|
||||
UserCostProfit: 1,
|
||||
},
|
||||
{deptID: 117, scopeType: 1, targetAgentID: 131, packageCode: "yd-100m-3y"}: {
|
||||
Id: 92,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 5,
|
||||
UserCostRatio: 1,
|
||||
UserCostProfit: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := resolveEffectiveSalePrice(
|
||||
context.Background(),
|
||||
store,
|
||||
"000000",
|
||||
117,
|
||||
131,
|
||||
Source{
|
||||
PackageCode: "yd-100m-3y",
|
||||
CostPrice: 10,
|
||||
TerminalCost: 10,
|
||||
SysDefaultRatio: 1,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveEffectiveSalePrice() error = %v", err)
|
||||
}
|
||||
if got.Base.AgentCost != 14 {
|
||||
t.Fatalf("base agent cost = %.2f, want 14.00", got.Base.AgentCost)
|
||||
}
|
||||
if got.AgentShipmentPrice != 19 {
|
||||
t.Fatalf("agent shipment price = %.2f, want 19.00", got.AgentShipmentPrice)
|
||||
}
|
||||
if got.Rule.PriceSource != "target" || got.Rule.TargetPriceId != 92 {
|
||||
t.Fatalf("rule = %+v, want target price 92", got.Rule)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestProcessorTemplateHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := []string{"ICCID", "MSISDN", "IMSI", "目标客户名称"}
|
||||
if !reflect.DeepEqual(NewProcessor().Config().TemplateHeaders, expected) {
|
||||
t.Fatalf("unexpected headers: %#v", NewProcessor().Config().TemplateHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsCustomerFacingColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "allocate.xlsx")
|
||||
f := excelize.NewFile()
|
||||
headers := []any{"ICCID", "MSISDN", "IMSI", "目标客户名称"}
|
||||
if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil {
|
||||
t.Fatalf("set headers failed: %v", err)
|
||||
}
|
||||
row := []any{
|
||||
"8986000000000000001",
|
||||
"13800008000",
|
||||
"460001234567890",
|
||||
"示例企业",
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil {
|
||||
t.Fatalf("set row failed: %v", err)
|
||||
}
|
||||
if err := f.SaveAs(filePath); err != nil {
|
||||
t.Fatalf("save excel failed: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
rows, err := ParseExcelFileRows(context.Background(), filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("parse excel failed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("unexpected row count: %d", len(rows))
|
||||
}
|
||||
rowModel, ok := rows[0].(*Row)
|
||||
if !ok || rowModel == nil {
|
||||
t.Fatalf("unexpected row type: %#v", rows[0])
|
||||
}
|
||||
if rowModel.Iccid != "8986000000000000001" {
|
||||
t.Fatalf("unexpected iccid: %s", rowModel.Iccid)
|
||||
}
|
||||
if rowModel.Msisdn != "13800008000" {
|
||||
t.Fatalf("unexpected msisdn: %s", rowModel.Msisdn)
|
||||
}
|
||||
if rowModel.Imsi != "460001234567890" {
|
||||
t.Fatalf("unexpected imsi: %s", rowModel.Imsi)
|
||||
}
|
||||
if rowModel.TargetDeptName != "示例企业" {
|
||||
t.Fatalf("unexpected target dept name: %s", rowModel.TargetDeptName)
|
||||
}
|
||||
if rowModel.Remark != "" {
|
||||
t.Fatalf("unexpected remark: %s", rowModel.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorConfigLimitsAllocationRows(t *testing.T) {
|
||||
config := NewProcessor().Config()
|
||||
if config.MaxRows != 10000 {
|
||||
t.Fatalf("MaxRows = %d, want 10000", config.MaxRows)
|
||||
}
|
||||
if config.MaxFileSize != 50<<20 {
|
||||
t.Fatalf("MaxFileSize = %d, want %d", config.MaxFileSize, 50<<20)
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ func createPendingSplit(
|
||||
if err := createAllocateParent(ctx, req, p, parentBatchNo, importOss, chunkSize); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
children := make([]*batchimport.Result, 0)
|
||||
|
||||
if _, err := stream(ctx, func(rows []batchimport.Row) error {
|
||||
if len(rows) == 0 {
|
||||
@@ -115,16 +116,20 @@ func createPendingSplit(
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := markAllocateCrossChunkDuplicates(ctx, parentBatchNo, req.Operator.TenantId, rows); err != nil {
|
||||
priorFailures, err := markAllocateCrossChunkDuplicates(ctx, parentBatchNo, req.Operator.TenantId, rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.Valid -= priorFailures
|
||||
state.Failed += priorFailures
|
||||
|
||||
state.ChunkCount++
|
||||
result, err := createAllocateChunk(ctx, req, p, parentBatchNo, state.ChunkCount, rows, importOss, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return common.PublishBatchMessageStrict(ctx, result, consts.QueueBatchExecute, "allocate")
|
||||
children = append(children, result)
|
||||
return nil
|
||||
}); err != nil {
|
||||
markAllocateParentFailed(ctx, parentBatchNo, err.Error())
|
||||
return nil, err
|
||||
@@ -138,6 +143,12 @@ func createPendingSplit(
|
||||
markAllocateParentFailed(ctx, parentBatchNo, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
if err := common.PublishBatchMessageStrict(ctx, child, consts.QueueBatchExecute, "allocate"); err != nil {
|
||||
markAllocateParentFailed(ctx, parentBatchNo, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
parentMessage := &batchimport.Result{
|
||||
BatchNo: parentBatchNo,
|
||||
@@ -397,9 +408,9 @@ func countBatchRows(rows []batchimport.Row) (valid, failed int) {
|
||||
return
|
||||
}
|
||||
|
||||
func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenantID string, rows []batchimport.Row) error {
|
||||
func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenantID string, rows []batchimport.Row) (int, error) {
|
||||
if strings.TrimSpace(parentBatchNo) == "" || len(rows) == 0 {
|
||||
return nil
|
||||
return 0, nil
|
||||
}
|
||||
columns := dao.BatchDetail.Columns()
|
||||
itemIDs := make([]string, 0, len(rows))
|
||||
@@ -420,24 +431,65 @@ func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenant
|
||||
itemIDs = append(itemIDs, itemID)
|
||||
}
|
||||
if len(itemIDs) == 0 {
|
||||
return nil
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var existing []struct {
|
||||
ItemId string `orm:"item_id"`
|
||||
Id int64 `orm:"id"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
ItemId string `orm:"item_id"`
|
||||
DetailStatus int `orm:"detail_status"`
|
||||
}
|
||||
query := g.DB().Model(dao.BatchDetail.Table()).
|
||||
Fields(columns.ItemId).
|
||||
Fields(columns.Id, columns.BatchNo, columns.ItemId, columns.DetailStatus).
|
||||
Where(columns.BatchType, consts.BatchTypeAllocate).
|
||||
Where(columns.TenantId, tenantID).
|
||||
WhereIn(columns.ItemId, itemIDs).
|
||||
Where("batch_no IN (SELECT batch_no FROM batch_main WHERE source_batch_no = ? AND batch_type = ? AND tenant_id = ?)",
|
||||
parentBatchNo, consts.BatchTypeAllocate, tenantID)
|
||||
if err := query.Scan(&existing); err != nil {
|
||||
return gerror.Wrap(err, "check duplicate allocate ICCIDs across chunks failed")
|
||||
return 0, gerror.Wrap(err, "check duplicate allocate ICCIDs across chunks failed")
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
priorFailures := 0
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
for _, detail := range existing {
|
||||
if detail.DetailStatus != consts.BatchDetailStatusPending {
|
||||
continue
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(columns.Id, detail.Id).
|
||||
Where(columns.TenantId, tenantID).
|
||||
Where(columns.DetailStatus, consts.BatchDetailStatusPending).
|
||||
Data(do.BatchDetail{DetailStatus: consts.BatchDetailStatusFailed, ErrorReason: "ICCID在划拨批次内重复"}).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "mark prior duplicate allocation detail failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
continue
|
||||
}
|
||||
priorFailures++
|
||||
mainColumns := dao.BatchMain.Columns()
|
||||
for _, batchNo := range []string{detail.BatchNo, parentBatchNo} {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(mainColumns.BatchNo, batchNo).
|
||||
Where(mainColumns.TenantId, tenantID).
|
||||
Data(do.BatchMain{ValidCount: gdb.Raw("valid_count - 1"), FailCount: gdb.Raw("fail_count + 1")}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "update cross-chunk duplicate counts failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
duplicates := make(map[string]struct{}, len(existing))
|
||||
@@ -452,10 +504,10 @@ func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenant
|
||||
continue
|
||||
}
|
||||
if _, exists := duplicates[strings.TrimSpace(row.Iccid)]; exists {
|
||||
row.AddErrorReason("ICCID appears in an earlier allocation chunk")
|
||||
row.AddErrorReason("ICCID在划拨批次内重复")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return priorFailures, nil
|
||||
}
|
||||
|
||||
func withAllocateInfo(info *gjson.Json, values map[string]any) *gjson.Json {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type allocateMetadataSQLiteDriver struct {
|
||||
*sqlite.Driver
|
||||
}
|
||||
|
||||
func (driver *allocateMetadataSQLiteDriver) New(core *gdb.Core, _ *gdb.ConfigNode) (gdb.DB, error) {
|
||||
return &allocateMetadataSQLiteDriver{Driver: &sqlite.Driver{Core: core}}, nil
|
||||
}
|
||||
|
||||
func (driver *allocateMetadataSQLiteDriver) DoSelect(ctx context.Context, link gdb.Link, query string, args ...any) (gdb.Result, error) {
|
||||
return driver.Core.DoSelect(ctx, link, strings.TrimSuffix(query, " FOR UPDATE"), args...)
|
||||
}
|
||||
|
||||
func TestAllocateSplitInheritsRequestMetadata(t *testing.T) {
|
||||
if err := gdb.Register("allocate_metadata_sqlite", &allocateMetadataSQLiteDriver{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "allocate_metadata_sqlite", Name: ":memory:", MaxOpenConnCount: 1}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = g.DB().Close(ctx) })
|
||||
if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for table, record := range map[string]any{"batch_main": entity.BatchMain{}, "batch_detail": entity.BatchDetail{}} {
|
||||
recordType := reflect.TypeOf(record)
|
||||
columns := make([]string, 0, recordType.NumField())
|
||||
for fieldIndex := 0; fieldIndex < recordType.NumField(); fieldIndex++ {
|
||||
field := recordType.Field(fieldIndex)
|
||||
column := field.Tag.Get("orm")
|
||||
if column == "id" {
|
||||
columns = append(columns, "id INTEGER PRIMARY KEY AUTOINCREMENT")
|
||||
} else {
|
||||
columns = append(columns, column+" TEXT")
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "CREATE TABLE "+table+" ("+strings.Join(columns, ",")+")"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.Model(table).Ctx(ctx).TableFields(table); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().GetCore().GetTablesWithCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mode := range []int{batchmetadata.Single, batchmetadata.Bulk} {
|
||||
parentNo := "META-SPLIT-SINGLE"
|
||||
if mode == batchmetadata.Bulk {
|
||||
parentNo = "META-SPLIT-BULK"
|
||||
}
|
||||
req := batchimport.SubmitRequest{SourceChannel: batchmetadata.Console, ExecutionMode: mode, SourceType: consts.BatchSourceTypeBatchInput, Operator: batchimport.OperatorContext{TenantId: "A", UserId: 7}}
|
||||
processor := NewProcessor()
|
||||
if err := createAllocateParent(ctx, req, processor, parentNo, nil, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := []batchimport.Row{&Row{RowNo: 1, Iccid: "card1"}}
|
||||
result, err := createAllocateChunk(ctx, req, processor, parentNo, 1, rows, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, batchNo := range []string{parentNo, result.BatchNo} {
|
||||
var batch *entity.BatchMain
|
||||
if err := g.Model("batch_main").Ctx(ctx).Where("batch_no", batchNo).Scan(&batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch == nil || batch.SourceChannel != req.SourceChannel || batch.ExecutionMode != uint(mode) || batch.SourceType != req.SourceType || batch.TotalCount != 1 {
|
||||
t.Fatalf("metadata not inherited: %+v", batch)
|
||||
}
|
||||
if batchNo != parentNo && batch.SourceBatchNo != parentNo {
|
||||
t.Fatalf("child lost parent association: %+v", batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestExcelTemplateHeaders(t *testing.T) {
|
||||
got := ExcelTemplateHeaders()
|
||||
want := []string{"ICCID", "MSISDN", "IMSI", "newPackageCode", "durationMonths", "giftDurationMonths", "effectiveMode", "offlineContractCompleted"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("headers len = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("header[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRows(t *testing.T) {
|
||||
path := buildTestExcelFile(t, [][]string{
|
||||
ExcelTemplateHeaders(),
|
||||
{"8986001", "", "", "PKG_NEW", "12", "", "immediate", "true"},
|
||||
})
|
||||
|
||||
rows, opts, err := ParseExcelFileRows(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExcelFileRows error = %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows len = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].GiftDurationMonths != 0 {
|
||||
t.Fatalf("giftDurationMonths = %d, want 0", rows[0].GiftDurationMonths)
|
||||
}
|
||||
if opts == nil || opts.GiftDurationMonths != 0 || !opts.OfflineContractCompleted {
|
||||
t.Fatalf("opts = %#v", opts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsRequireTrueOfflineContractCompleted(t *testing.T) {
|
||||
path := buildTestExcelFile(t, [][]string{
|
||||
ExcelTemplateHeaders(),
|
||||
{"8986001", "", "", "PKG_NEW", "12", "", "immediate", "false"},
|
||||
})
|
||||
|
||||
rows, _, err := ParseExcelFileRows(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExcelFileRows error = %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows len = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Reason == "" {
|
||||
t.Fatal("expected row error when offlineContractCompleted is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChangeRowSourceBatchOptional(t *testing.T) {
|
||||
operator := batchimport.OperatorContext{DeptId: 100}
|
||||
row := &Row{Iccid: "8986001"}
|
||||
card := &entity.TSimCardInfo{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
Imsi: "460001234567890",
|
||||
DeptId: 100,
|
||||
CurrentPackageCode: "PKG_OLD",
|
||||
PackageStatus: 1,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
PackageCode: "PKG_OLD",
|
||||
BatchNo: "BATCH-OTHER",
|
||||
}
|
||||
|
||||
if reason := validateChangeRow("", operator, row, card, bind); reason != "" {
|
||||
t.Fatalf("validateChangeRow without sourceBatchNo = %q, want empty", reason)
|
||||
}
|
||||
if reason := validateChangeRow("BATCH-REQ", operator, row, card, bind); reason == "" {
|
||||
t.Fatal("expected sourceBatchNo mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChangeRowIdentityMatch(t *testing.T) {
|
||||
operator := batchimport.OperatorContext{DeptId: 100}
|
||||
card := &entity.TSimCardInfo{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
Imsi: "460001234567890",
|
||||
DeptId: 100,
|
||||
CurrentPackageCode: "PKG_OLD",
|
||||
PackageStatus: 1,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
PackageCode: "PKG_OLD",
|
||||
}
|
||||
|
||||
if reason := validateChangeRow("", operator, &Row{Iccid: "8986001", Msisdn: "bad"}, card, bind); reason == "" {
|
||||
t.Fatal("expected MSISDN mismatch error")
|
||||
}
|
||||
if reason := validateChangeRow("", operator, &Row{Iccid: "8986001", Imsi: "bad"}, card, bind); reason == "" {
|
||||
t.Fatal("expected IMSI mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchInfoOmitsEmptySourceBatchNo(t *testing.T) {
|
||||
p := NewProcessor()
|
||||
info := p.BuildBatchInfo(context.Background(), batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG_NEW",
|
||||
DurationMonths: 12,
|
||||
GiftDurationMonths: 0,
|
||||
EffectiveMode: "immediate",
|
||||
OfflineContractCompleted: true,
|
||||
},
|
||||
ExecutionResult: &CompleteOutput{
|
||||
SourceBatchNo: "",
|
||||
SourceMode: 0,
|
||||
NewPackageCode: "PKG_NEW",
|
||||
NewPackageName: "New Package",
|
||||
OldPackageCode: "PKG_OLD",
|
||||
OldPackageName: "Old Package",
|
||||
DurationMonths: 12,
|
||||
GiftDurationMonths: 0,
|
||||
EffectiveMode: "immediate",
|
||||
OfflineContractCompleted: true,
|
||||
},
|
||||
}, nil, nil, nil, 0)
|
||||
|
||||
if info == nil {
|
||||
t.Fatal("info is nil")
|
||||
}
|
||||
data := info.Map()
|
||||
if _, ok := data["sourceBatchNo"]; ok {
|
||||
t.Fatalf("sourceBatchNo should be omitted when empty: %#v", data)
|
||||
}
|
||||
if _, ok := data["remark"]; ok {
|
||||
t.Fatalf("remark should not exist: %#v", data)
|
||||
}
|
||||
if _, ok := data["buyerNote"]; ok {
|
||||
t.Fatalf("buyerNote should not exist: %#v", data)
|
||||
}
|
||||
if _, ok := data["adminNote"]; ok {
|
||||
t.Fatalf("adminNote should not exist: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestExcelFile(t *testing.T, rows [][]string) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "change.xlsx")
|
||||
f := excelize.NewFile()
|
||||
for i, row := range rows {
|
||||
cell, err := excelize.CoordinatesToCellName(1, i+1)
|
||||
if err != nil {
|
||||
t.Fatalf("CoordinatesToCellName error = %v", err)
|
||||
}
|
||||
if err = f.SetSheetRow("Sheet1", cell, &row); err != nil {
|
||||
t.Fatalf("SetSheetRow error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := f.SaveAs(path); err != nil {
|
||||
t.Fatalf("SaveAs error = %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("Stat error = %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
@@ -135,7 +136,7 @@ func validateTask(task *Task, cards map[string]*entity.TSimCardInfo) bool {
|
||||
if hasFailure {
|
||||
for _, row := range task.Rows {
|
||||
if row != nil && row.Reason == "" {
|
||||
row.AddErrorReason("同批次存在校验失败卡,本卡未请求上游")
|
||||
row.AddErrorReason(batchimport.NotExecutedReasonPrefix + "本卡校验通过")
|
||||
}
|
||||
}
|
||||
task.FailureReason = "整批业务校验失败,未请求上游"
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
type fakeExecutorRepository struct {
|
||||
mu sync.Mutex
|
||||
claims []bool
|
||||
claimCalls int
|
||||
loadedBatchNos []string
|
||||
task *Task
|
||||
cards map[string]*entity.TSimCardInfo
|
||||
validationFailureCalls int
|
||||
outcomes []Outcome
|
||||
finalizeCalls int
|
||||
markFailedCalls int
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) ClaimBatch(context.Context, string) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
index := r.claimCalls
|
||||
r.claimCalls++
|
||||
if index >= len(r.claims) {
|
||||
return false, nil
|
||||
}
|
||||
return r.claims[index], nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) LoadTask(_ context.Context, batchNo string) (*Task, error) {
|
||||
r.loadedBatchNos = append(r.loadedBatchNos, batchNo)
|
||||
return r.task, nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) LoadCards(context.Context, *Task) (map[string]*entity.TSimCardInfo, error) {
|
||||
return r.cards, nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) SaveValidationFailure(context.Context, *Task) error {
|
||||
r.validationFailureCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) SaveOutcome(_ context.Context, _ *Task, _ *Row, _ *entity.TSimCardInfo, outcome Outcome) error {
|
||||
r.outcomes = append(r.outcomes, outcome)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) SaveDetailFailure(context.Context, *Task, *Row) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) FinalizeBatch(context.Context, *Task) error {
|
||||
r.finalizeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) MarkBatchFailed(context.Context, string, string) error {
|
||||
r.markFailedCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeStatusChanger struct {
|
||||
calls int
|
||||
requests []*model.SimCardStatusChangeInput
|
||||
response []model.SimBaseRes[model.SimCardStatusChangeItem]
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *fakeStatusChanger) ChangeSimStatus(_ context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeItem], error) {
|
||||
c.calls++
|
||||
c.requests = append(c.requests, input)
|
||||
return c.response, c.err
|
||||
}
|
||||
|
||||
func TestActionOperTypeMapping(t *testing.T) {
|
||||
stop, err := OperationSuspend.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, err := OperationResume.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stop.OperType != "0" || stop.BatchType != consts.BatchTypeStop {
|
||||
t.Fatalf("unexpected stop mapping: %+v", stop)
|
||||
}
|
||||
if start.OperType != "1" || start.BatchType != consts.BatchTypeStart {
|
||||
t.Fatalf("unexpected start mapping: %+v", start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidationFailureSkipsUpstream(t *testing.T) {
|
||||
task := newTestTask(t, OperationSuspend, []*Row{
|
||||
{RowNo: 1, Iccid: "missing", Authorized: true},
|
||||
{RowNo: 2, Iccid: "valid", Authorized: true},
|
||||
})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"valid": {Id: 2, Iccid: "valid", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{}
|
||||
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream called %d times for invalid batch", upstream.calls)
|
||||
}
|
||||
if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 {
|
||||
t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls)
|
||||
}
|
||||
if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" {
|
||||
t.Fatalf("expected all rows to retain failure reasons: %+v", task.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStopStatus(t *testing.T) {
|
||||
t.Skip("TODO: 恢复卡状态限制后启用此测试")
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
valid bool
|
||||
}{
|
||||
{name: "activated", status: consts.V3ApiSimBossCardStatusActivated, valid: true},
|
||||
{name: "pending", status: consts.V3ApiSimBossCardStatusPendingActivate},
|
||||
{name: "testable", status: consts.V3ApiSimBossCardStatusTestable},
|
||||
{name: "stock", status: consts.V3ApiSimBossCardStatusStock},
|
||||
{name: "stopped", status: consts.V3ApiSimBossCardStatusStopped},
|
||||
{name: "pre-cancel", status: consts.V3ApiSimBossCardStatusPreCancel},
|
||||
{name: "canceled", status: consts.V3ApiSimBossCardStatusCanceled},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
row := &Row{Iccid: "card-1"}
|
||||
validateCardStatus(OperationSuspend, row, &entity.TSimCardInfo{BossCardStatus: tt.status})
|
||||
if got := row.Reason == ""; got != tt.valid {
|
||||
t.Fatalf("status %d valid=%v, reason=%q", tt.status, got, row.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMixedStopStatusSkipsUpstream(t *testing.T) {
|
||||
t.Skip("TODO: 恢复卡状态限制后启用此测试")
|
||||
task := newTestTask(t, OperationSuspend, []*Row{
|
||||
{RowNo: 1, Iccid: "activated", Authorized: true},
|
||||
{RowNo: 2, Iccid: "stock", Authorized: true},
|
||||
})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"activated": {Id: 1, Iccid: "activated", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
"stock": {Id: 2, Iccid: "stock", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStock},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{}
|
||||
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-mixed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream called %d times for mixed status batch", upstream.calls)
|
||||
}
|
||||
if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 {
|
||||
t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls)
|
||||
}
|
||||
if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" {
|
||||
t.Fatalf("expected both rows to be failed without upstream call: %+v", task.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRestoresByBatchNoAndMapsPerCardResults(t *testing.T) {
|
||||
task := newTestTask(t, OperationSuspend, []*Row{
|
||||
{RowNo: 1, Iccid: "card-1", Authorized: true},
|
||||
{RowNo: 2, Iccid: "card-2", Authorized: true},
|
||||
})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
"card-2": {Id: 2, Iccid: "card-2", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{
|
||||
{Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}, ResData: model.SimCardStatusChangeItem{OrderNum: "order-1"}},
|
||||
{Code: 500, Message: "operator rejected", SimKey: &model.SimKey{ICCID: "card-2"}},
|
||||
}}
|
||||
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-restore"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.loadedBatchNos) != 1 || repo.loadedBatchNos[0] != "STP-restore" {
|
||||
t.Fatalf("executor did not restore by batchNo: %+v", repo.loadedBatchNos)
|
||||
}
|
||||
if upstream.calls != 1 || len(upstream.requests) != 1 {
|
||||
t.Fatalf("unexpected upstream calls: %d", upstream.calls)
|
||||
}
|
||||
if upstream.requests[0].ReqData.OperType != "0" {
|
||||
t.Fatalf("unexpected operType: %s", upstream.requests[0].ReqData.OperType)
|
||||
}
|
||||
if len(repo.outcomes) != 2 || !repo.outcomes[0].Success || repo.outcomes[1].Success {
|
||||
t.Fatalf("unexpected per-card outcomes: %+v", repo.outcomes)
|
||||
}
|
||||
if repo.outcomes[0].OrderNum != "order-1" || repo.outcomes[1].Message != "operator rejected" {
|
||||
t.Fatalf("upstream details were not preserved: %+v", repo.outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDuplicateClaimRunsOnlyOnce(t *testing.T) {
|
||||
task := newTestTask(t, OperationResume, []*Row{{RowNo: 1, Iccid: "card-1", Authorized: true}})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true, false},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStopped},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{
|
||||
{Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}},
|
||||
}}
|
||||
executor := newExecutor(repo, upstream)
|
||||
if err := executor.Execute(context.Background(), "STR-once"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := executor.Execute(context.Background(), "STR-once"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 1 || len(repo.loadedBatchNos) != 1 || repo.finalizeCalls != 1 {
|
||||
t.Fatalf("duplicate claim executed twice: upstream=%d load=%d finalize=%d", upstream.calls, len(repo.loadedBatchNos), repo.finalizeCalls)
|
||||
}
|
||||
if upstream.requests[0].ReqData.OperType != "1" {
|
||||
t.Fatalf("unexpected start operType: %s", upstream.requests[0].ReqData.OperType)
|
||||
}
|
||||
}
|
||||
|
||||
type blockingExecutor struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (e *blockingExecutor) Execute(context.Context, string) error {
|
||||
close(e.started)
|
||||
<-e.release
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *blockingExecutor) MarkFailed(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDispatchReturnsBeforeExecutorCompletes(t *testing.T) {
|
||||
executor := &blockingExecutor{started: make(chan struct{}), release: make(chan struct{})}
|
||||
Dispatch(executor, "STP-async")
|
||||
select {
|
||||
case <-executor.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("background executor did not start")
|
||||
}
|
||||
select {
|
||||
case <-executor.release:
|
||||
t.Fatal("release channel should still be blocked")
|
||||
default:
|
||||
}
|
||||
close(executor.release)
|
||||
}
|
||||
|
||||
func newTestTask(t *testing.T, operation Operation, rows []*Row) *Task {
|
||||
t.Helper()
|
||||
config, err := operation.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Task{
|
||||
Batch: &entity.BatchMain{BatchNo: "batch-test", BatchType: config.BatchType, TenantId: "tenant-1"},
|
||||
Operation: operation,
|
||||
Config: config,
|
||||
Rows: rows,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBatchExecuteMessageJSONContract(t *testing.T) {
|
||||
body, err := json.Marshal(BatchExecuteMessage{
|
||||
BatchNo: "STP202607170001",
|
||||
BatchType: 104,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal batch execute message: %v", err)
|
||||
}
|
||||
const expected = `{"batchNo":"STP202607170001","batchType":104}`
|
||||
if string(body) != expected {
|
||||
t.Fatalf("unexpected batch execute message: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
)
|
||||
|
||||
func TestValidateBasicRowsRejectsDuplicateIccid(t *testing.T) {
|
||||
rows := []batchimport.Row{
|
||||
&Row{RowNo: 2, Iccid: " 8986001 "},
|
||||
&Row{RowNo: 3, Iccid: "8986001"},
|
||||
}
|
||||
err := validateBasicRows(rows)
|
||||
if err == nil || !strings.Contains(err.Error(), "ICCID") || !strings.Contains(err.Error(), "重复") {
|
||||
t.Fatalf("expected readable duplicate ICCID error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBasicRowsRejectsEveryEmptyIccid(t *testing.T) {
|
||||
rows := []batchimport.Row{
|
||||
&Row{RowNo: 2, Iccid: ""},
|
||||
&Row{RowNo: 5, Iccid: " "},
|
||||
}
|
||||
err := validateBasicRows(rows)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty ICCID validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "第2行") || !strings.Contains(err.Error(), "第5行") {
|
||||
t.Fatalf("expected all empty ICCID rows in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBasicRowsRejectsWrongFieldCount(t *testing.T) {
|
||||
rows, err := ParseInputRows(context.Background(), "8986001 13800008000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = validateBasicRows(rows)
|
||||
if err == nil || !strings.Contains(err.Error(), "格式") {
|
||||
t.Fatalf("expected wrong field count error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidBasicRowsStopBeforePendingPreparation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rows []batchimport.Row
|
||||
}{
|
||||
{
|
||||
name: "duplicate",
|
||||
rows: []batchimport.Row{
|
||||
&Row{RowNo: 2, Iccid: "8986001"},
|
||||
&Row{RowNo: 3, Iccid: "8986001"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple empty iccid",
|
||||
rows: []batchimport.Row{
|
||||
&Row{RowNo: 2},
|
||||
&Row{RowNo: 3, Iccid: " "},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
processor, err := NewProcessor(OperationSuspend)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared := false
|
||||
result, err := batchimport.CreatePendingRows(
|
||||
context.Background(),
|
||||
batchimport.SubmitRequest{},
|
||||
processor,
|
||||
tt.rows,
|
||||
batchimport.PendingOptions{PrepareRows: func(context.Context, []batchimport.Row) error {
|
||||
prepared = true
|
||||
return nil
|
||||
}},
|
||||
)
|
||||
if err == nil || result != nil {
|
||||
t.Fatalf("expected pre-persistence validation error, result=%+v err=%v", result, err)
|
||||
}
|
||||
if prepared {
|
||||
t.Fatal("invalid rows reached pending preparation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -155,11 +155,15 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *Task) erro
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
status := consts.BatchDetailStatusFailed
|
||||
if batchimport.IsNotExecuted(row) {
|
||||
status = consts.BatchDetailStatusNotExecuted
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().Id, row.DetailID).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
|
||||
Data(do.BatchDetail{
|
||||
DetailStatus: consts.BatchDetailStatusFailed,
|
||||
DetailStatus: status,
|
||||
ErrorReason: row.Reason,
|
||||
Snapshot: row.Snapshot(),
|
||||
UpdatedBy: task.Operator.UserId,
|
||||
@@ -255,21 +259,23 @@ func (r *Repository) FinalizeBatch(ctx context.Context, task *Task) error {
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(task.Rows))
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
for _, row := range task.Rows {
|
||||
rows = append(rows, row)
|
||||
if row != nil && row.Reason == "" {
|
||||
if row != nil && row.Reason == "" && task.ValidationOK {
|
||||
successCount++
|
||||
} else if row == nil || !batchimport.IsNotExecuted(row) {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
failCount := len(task.Rows) - successCount
|
||||
validCount := 0
|
||||
validCount := len(task.Rows) - failCount
|
||||
if task.ValidationOK {
|
||||
validCount = len(task.Rows)
|
||||
}
|
||||
importStatus := consts.ImportStatusSuccess
|
||||
batchStatus := consts.BatchAsyncStatusSuccess
|
||||
runResult := consts.BatchRunResultSuccess
|
||||
if failCount > 0 {
|
||||
if failCount > 0 || !task.ValidationOK {
|
||||
importStatus = consts.ImportStatusFailed
|
||||
batchStatus = consts.BatchAsyncStatusFailed
|
||||
runResult = consts.BatchRunResultFailed
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestRepositorySaveOutcomeUpdatesOnlySuccessfulCard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{{
|
||||
Type: "sqlite",
|
||||
Link: "sqlite::@file(:memory:)",
|
||||
MaxOpenConnCount: 1,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.DB().Close(ctx)
|
||||
|
||||
statements := []string{
|
||||
`CREATE TABLE t_sim_card_info (
|
||||
id INTEGER PRIMARY KEY,
|
||||
iccid TEXT NOT NULL,
|
||||
boss_card_status INTEGER NOT NULL,
|
||||
status_sync_time DATETIME,
|
||||
tenant_id TEXT NOT NULL,
|
||||
updated_by INTEGER,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE batch_detail (
|
||||
id INTEGER PRIMARY KEY,
|
||||
batch_no TEXT NOT NULL,
|
||||
detail_status INTEGER NOT NULL,
|
||||
error_reason TEXT,
|
||||
snapshot TEXT,
|
||||
updated_by INTEGER,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`INSERT INTO t_sim_card_info(id, iccid, boss_card_status, tenant_id) VALUES
|
||||
(1, 'card-success', 2, 'tenant-1'),
|
||||
(2, 'card-failed', 2, 'tenant-1')`,
|
||||
`INSERT INTO batch_detail(id, batch_no, detail_status) VALUES
|
||||
(1, 'STP-test', 1),
|
||||
(2, 'STP-test', 1)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
config, err := OperationSuspend.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: &entity.BatchMain{BatchNo: "STP-test", TenantId: "tenant-1"},
|
||||
Config: config,
|
||||
Operator: batchimport.OperatorContext{TenantId: "tenant-1", UserId: 10},
|
||||
}
|
||||
repository := NewRepository()
|
||||
successRow := &Row{DetailID: 1, Iccid: "card-success", StatusBefore: consts.V3ApiSimBossCardStatusActivated}
|
||||
failedRow := &Row{DetailID: 2, Iccid: "card-failed", StatusBefore: consts.V3ApiSimBossCardStatusActivated}
|
||||
if err = repository.SaveOutcome(ctx, task, successRow, &entity.TSimCardInfo{Id: 1}, Outcome{Success: true, OrderNum: "order-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = repository.SaveOutcome(ctx, task, failedRow, &entity.TSimCardInfo{Id: 2}, Outcome{Code: 500, Message: "rejected"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var cards []struct {
|
||||
Iccid string `orm:"iccid"`
|
||||
BossCardStatus int `orm:"boss_card_status"`
|
||||
}
|
||||
if err = g.DB().Model("t_sim_card_info").OrderAsc("id").Scan(&cards); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 2 || cards[0].BossCardStatus != consts.V3ApiSimBossCardStatusStopped || cards[1].BossCardStatus != consts.V3ApiSimBossCardStatusActivated {
|
||||
t.Fatalf("unexpected local card statuses: %+v", cards)
|
||||
}
|
||||
|
||||
var details []struct {
|
||||
DetailStatus int `orm:"detail_status"`
|
||||
ErrorReason string `orm:"error_reason"`
|
||||
}
|
||||
if err = g.DB().Model("batch_detail").OrderAsc("id").Scan(&details); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(details) != 2 || details[0].DetailStatus != consts.BatchDetailStatusSuccess || details[1].DetailStatus != consts.BatchDetailStatusFailed || details[1].ErrorReason != "rejected" {
|
||||
t.Fatalf("unexpected detail results: %+v", details)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"strings"
|
||||
|
||||
@@ -26,6 +27,10 @@ type AsyncMessage struct {
|
||||
BatchNo string `json:"batchNo"`
|
||||
}
|
||||
|
||||
var errOutboundValidationRejected = errors.New("存在不满足出库条件的卡,本次未执行")
|
||||
|
||||
const outboundNotExecutedReason = "校验通过,但本批次存在失败明细,整批未执行出库"
|
||||
|
||||
func SubmitExcelAsync(ctx context.Context, file *ghttp.UploadFile, in *model.TSimCardOutboundExcelSubmitParam) (*batchimport.Result, error) {
|
||||
operator := batchimport.OperatorContext{TenantId: contexts.GetTenantId(ctx), DeptId: contexts.GetDeptId(ctx), UserId: contexts.GetUserId(ctx)}
|
||||
processor := NewProcessor(PreviewOptions{DeptId: operator.DeptId})
|
||||
@@ -119,8 +124,9 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error {
|
||||
requiredSource = strings.TrimSpace(main.BatchInfo.Get("inboundBatchNo").String())
|
||||
}
|
||||
var (
|
||||
claimed bool
|
||||
output *CompleteOutput
|
||||
claimed bool
|
||||
output *CompleteOutput
|
||||
validationRejected bool
|
||||
)
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
claimResult, claimErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
@@ -153,6 +159,7 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error {
|
||||
var executeErr error
|
||||
output, executeErr = processor.ExecutePending(ctx, tx, message.BatchNo, param, rows, operator, requiredSource)
|
||||
if executeErr != nil {
|
||||
validationRejected = errors.Is(executeErr, errOutboundValidationRejected)
|
||||
return executeErr
|
||||
}
|
||||
if _, updateErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where(dao.BatchMain.Columns().BatchNo, message.BatchNo).Data(do.BatchMain{
|
||||
@@ -169,6 +176,9 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if validationRejected {
|
||||
return markAsyncValidationFailed(ctx, message.BatchNo, main.TenantId, rows, err)
|
||||
}
|
||||
return markAsyncFailed(ctx, message.BatchNo, err)
|
||||
}
|
||||
if !claimed {
|
||||
@@ -186,6 +196,43 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func markAsyncValidationFailed(ctx context.Context, batchNo, tenantID string, rows []*Row, cause error) error {
|
||||
validCount, failCount := 0, 0
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
if row.Reason == outboundNotExecutedReason {
|
||||
validCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
status := consts.BatchDetailStatusFailed
|
||||
if row.Reason == outboundNotExecutedReason {
|
||||
status = consts.BatchDetailStatusNotExecuted
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().TenantId, tenantID).
|
||||
Where(dao.BatchDetail.Columns().RowNo, row.RowNo).
|
||||
Data(do.BatchDetail{DetailStatus: status, ErrorReason: row.Reason}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "保存出库校验明细失败")
|
||||
}
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchMain.Columns().TenantId, tenantID).
|
||||
Data(do.BatchMain{BatchAsyncStatus: consts.BatchAsyncStatusFailed, RunResult: consts.BatchRunResultFailed, ValidCount: validCount, SuccessCount: 0, FailCount: failCount, FailReason: asyncFailureReason(cause), EndTime: gtime.Now()}).Update()
|
||||
return gerror.Wrap(err, "保存出库校验结果失败")
|
||||
})
|
||||
}
|
||||
|
||||
func markAsyncFailed(ctx context.Context, batchNo string, err error) error {
|
||||
reason := asyncFailureReason(err)
|
||||
if _, updateErr := g.DB().Model(dao.BatchMain.Table()).Ctx(ctx).Where(dao.BatchMain.Columns().BatchNo, batchNo).Data(do.BatchMain{
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestValidateOutboundRowCollectsIndependentFailures(t *testing.T) {
|
||||
row := &Row{Iccid: "card", Msisdn: "wrong", Imsi: "wrong"}
|
||||
stock := &entity.TSimCardStock{CreatedDept: 200, BatchNo: "other", Msisdn: "13800008000", Imsi: "460001234567890"}
|
||||
reason := validateOutboundRow(row, stock, true, batchimport.OperatorContext{DeptId: 100}, true, "required")
|
||||
for _, want := range []string{"不属于当前机构库存", "不属于指定库存批次", "已存在正式卡记录", "MSISDN与库存记录不一致", "IMSI与库存记录不一致"} {
|
||||
if !strings.Contains(reason, want) {
|
||||
t.Fatalf("missing %q in %q", want, reason)
|
||||
}
|
||||
}
|
||||
rows := []*Row{{Reason: reason}, {}, {Reason: "ICCID不存在库存记录,不能出库"}}
|
||||
markRemainingRowsSkipped(rows)
|
||||
if rows[0].Reason != reason || rows[1].Reason != outboundNotExecutedReason || rows[2].Reason != "ICCID不存在库存记录,不能出库" {
|
||||
t.Fatalf("per-card validation results were overwritten: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundChunkSizesSupportFiftyThousandCards(t *testing.T) {
|
||||
const totalCards = 50000
|
||||
if outboundDatabaseInsertChunkSize <= 0 || outboundDatabaseInsertChunkSize > 500 {
|
||||
t.Fatalf("outboundDatabaseInsertChunkSize = %d, want 1..500", outboundDatabaseInsertChunkSize)
|
||||
}
|
||||
if chunks := (totalCards + outboundDatabaseInsertChunkSize - 1) / outboundDatabaseInsertChunkSize; chunks != 100 {
|
||||
t.Fatalf("card insert chunks = %d, want 100", chunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncFailureReasonSummarizesPlaceholderOverflow(t *testing.T) {
|
||||
reason := asyncFailureReason(errors.New("INSERT INTO t_sim_card_info (...) Error 1390: Prepared statement contains too many placeholders"))
|
||||
if reason != "新增正式卡失败:单次批量写入参数数量超过数据库限制" {
|
||||
t.Fatalf("unexpected reason: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncFailureReasonFitsBatchDetailColumn(t *testing.T) {
|
||||
reason := asyncFailureReason(errors.New(strings.Repeat("失败原因", 300)))
|
||||
if len([]rune(reason)) > maxAsyncFailureReasonLength {
|
||||
t.Fatalf("reason length = %d, want <= %d", len([]rune(reason)), maxAsyncFailureReasonLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOutboundIdentifiersReportsEveryDuplicate(t *testing.T) {
|
||||
rows := []*Row{
|
||||
{RowNo: 1, Iccid: " 8986000000000000001 "},
|
||||
{RowNo: 2, Iccid: "8986000000000000001"},
|
||||
{RowNo: 3, Iccid: ""},
|
||||
{RowNo: 4, Iccid: "8986000000000000002"},
|
||||
}
|
||||
iccids, err := validateOutboundIdentifiers(rows)
|
||||
if err != nil || len(iccids) != 1 || iccids[0] != rows[3].Iccid {
|
||||
t.Fatalf("valid identifiers = %v, err = %v", iccids, err)
|
||||
}
|
||||
for index := 0; index < 3; index++ {
|
||||
if rows[index].Reason == "" {
|
||||
t.Fatalf("row %d lost its validation reason", index+1)
|
||||
}
|
||||
}
|
||||
markRemainingRowsSkipped(rows)
|
||||
if rows[3].Reason != outboundNotExecutedReason {
|
||||
t.Fatalf("valid row reason = %q", rows[3].Reason)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
@@ -211,10 +212,13 @@ func (p *Processor) validateExecuteContext(ctx context.Context, tx gdb.TX, in *m
|
||||
if err := p.validateSelectablePackage(ctx, tx, in.PackageCode, operator.TenantId); err != nil {
|
||||
return err
|
||||
}
|
||||
iccids, err := strictRowsToIccids(rows)
|
||||
iccids, err := validateOutboundIdentifiers(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(iccids) == 0 {
|
||||
return errOutboundValidationRejected
|
||||
}
|
||||
stockByIccid, err := p.repo.LockStockCardsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "校验出库库存卡失败")
|
||||
@@ -237,11 +241,38 @@ func (p *Processor) validateExecuteContext(ctx context.Context, tx gdb.TX, in *m
|
||||
|
||||
if hasInvalidRows(rows) {
|
||||
markRemainingRowsSkipped(rows)
|
||||
return gerror.New("存在不满足出库条件的卡,本次未执行")
|
||||
return errOutboundValidationRejected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOutboundIdentifiers(rows []*Row) ([]string, error) {
|
||||
seen := make(map[string]*Row, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
return nil, gerror.New("出库行不能为空")
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
if row.Iccid == "" {
|
||||
row.AddReason("ICCID不能为空")
|
||||
continue
|
||||
}
|
||||
if first, exists := seen[row.Iccid]; exists {
|
||||
row.AddReason(fmt.Sprintf("ICCID与第%d行重复", first.RowNo))
|
||||
first.AddReason(fmt.Sprintf("ICCID与第%d行重复", row.RowNo))
|
||||
continue
|
||||
}
|
||||
seen[row.Iccid] = row
|
||||
}
|
||||
iccids := make([]string, 0, len(seen))
|
||||
for iccid, row := range seen {
|
||||
if row.Reason == "" {
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
}
|
||||
return iccids, nil
|
||||
}
|
||||
|
||||
func (p *Processor) validateSelectablePackage(ctx context.Context, tx gdb.TX, packageCode, tenantId string) error {
|
||||
pkg, err := p.repo.LoadPackage(ctx, tx, tenantId, packageCode)
|
||||
if err != nil {
|
||||
@@ -329,27 +360,28 @@ func (p *Processor) submitParams(params any) *model.TSimCardOutboundSubmitParam
|
||||
}
|
||||
|
||||
func validateOutboundRow(row *Row, stock *entity.TSimCardStock, formalExists bool, operator batchimport.OperatorContext, strictStockIdentity bool, requiredSourceBatchNo string) string {
|
||||
var reasons []string
|
||||
if stock == nil {
|
||||
return "ICCID不存在库存记录,不能出库"
|
||||
}
|
||||
if stock.CreatedDept != operator.DeptId {
|
||||
return "ICCID不属于当前机构库存,不能出库"
|
||||
reasons = append(reasons, "ICCID不属于当前机构库存,不能出库")
|
||||
}
|
||||
if requiredSourceBatchNo != "" && strings.TrimSpace(stock.BatchNo) != requiredSourceBatchNo {
|
||||
return "ICCID不属于指定库存批次,不能出库"
|
||||
reasons = append(reasons, "ICCID不属于指定库存批次,不能出库")
|
||||
}
|
||||
if formalExists {
|
||||
return "ICCID已存在正式卡记录,不能出库"
|
||||
reasons = append(reasons, "ICCID已存在正式卡记录,不能出库")
|
||||
}
|
||||
if strictStockIdentity {
|
||||
if strings.TrimSpace(row.Msisdn) != strings.TrimSpace(stock.Msisdn) {
|
||||
return "MSISDN与库存记录不一致,不能出库"
|
||||
reasons = append(reasons, "MSISDN与库存记录不一致,不能出库")
|
||||
}
|
||||
if strings.TrimSpace(row.Imsi) != strings.TrimSpace(stock.Imsi) {
|
||||
return "IMSI与库存记录不一致,不能出库"
|
||||
reasons = append(reasons, "IMSI与库存记录不一致,不能出库")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return strings.Join(reasons, ";")
|
||||
}
|
||||
|
||||
func hasInvalidRows(rows []*Row) bool {
|
||||
@@ -364,7 +396,7 @@ func hasInvalidRows(rows []*Row) bool {
|
||||
func markRemainingRowsSkipped(rows []*Row) {
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Reason == "" {
|
||||
row.AddReason("同批存在不满足出库条件的卡,本行未执行")
|
||||
row.AddReason(outboundNotExecutedReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestExcelTemplateHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := []string{
|
||||
"ICCID",
|
||||
"MSISDN",
|
||||
"IMSI",
|
||||
"续费年限",
|
||||
"是否走线下",
|
||||
}
|
||||
if !reflect.DeepEqual(ExcelTemplateHeaders(), expected) {
|
||||
t.Fatalf("unexpected headers: %#v", ExcelTemplateHeaders())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewDurationValueToMonths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
unit string
|
||||
value int
|
||||
want int
|
||||
}{
|
||||
{name: "one month", unit: consts.RenewDurationUnitMonth, value: 1, want: 1},
|
||||
{name: "one year", unit: consts.RenewDurationUnitYear, value: 1, want: 12},
|
||||
{name: "unknown unit defaults to year", unit: "unknown", value: 2, want: 24},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := consts.DurationValueToMonths(test.unit, test.value); got != test.want {
|
||||
t.Fatalf("DurationValueToMonths(%q, %d) = %d, want %d", test.unit, test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewDisplayDurationAndAmount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yearSnapshot := &model.TSimCardRenewPriceSnapshot{
|
||||
RenewDurationUnit: consts.RenewDurationUnitYear,
|
||||
RenewUnitPrice: 10,
|
||||
}
|
||||
yearParam := &model.TSimCardRenewBatchSubmitParam{DurationYears: 1}
|
||||
if got := renewDisplayDurationValue(yearParam); got != 1 {
|
||||
t.Fatalf("renewDisplayDurationValue(year) = %d, want 1", got)
|
||||
}
|
||||
if got := renewActualDurationMonths(yearSnapshot, renewDisplayDurationValue(yearParam)); got != 12 {
|
||||
t.Fatalf("renewActualDurationMonths(year) = %d, want 12", got)
|
||||
}
|
||||
yearAmount, err := resolveRenewAmount(yearSnapshot, yearParam, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRenewAmount(year) failed: %v", err)
|
||||
}
|
||||
if yearAmount != 20 {
|
||||
t.Fatalf("resolveRenewAmount(year) = %.2f, want 20.00", yearAmount)
|
||||
}
|
||||
|
||||
monthSnapshot := &model.TSimCardRenewPriceSnapshot{
|
||||
RenewDurationUnit: consts.RenewDurationUnitMonth,
|
||||
RenewUnitPrice: 10,
|
||||
}
|
||||
monthParam := &model.TSimCardRenewBatchSubmitParam{DurationMonths: 1}
|
||||
if got := renewActualDurationMonths(monthSnapshot, renewDisplayDurationValue(monthParam)); got != 12 {
|
||||
t.Fatalf("renewActualDurationMonths(month snapshot) = %d, want 12", got)
|
||||
}
|
||||
actualMonthParam := &model.TSimCardRenewBatchSubmitParam{DurationMonths: 12}
|
||||
if got := renewDisplayDurationValue(actualMonthParam); got != 1 {
|
||||
t.Fatalf("renewDisplayDurationValue(actual months legacy) = %d, want 1", got)
|
||||
}
|
||||
monthAmount, err := resolveRenewAmount(monthSnapshot, monthParam, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRenewAmount(month) failed: %v", err)
|
||||
}
|
||||
if monthAmount != 20 {
|
||||
t.Fatalf("resolveRenewAmount(month) = %.2f, want 20.00", monthAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExecutionOptionsKeepsDisplayDurationValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &model.TSimCardRenewBatchSubmitParam{}
|
||||
applyExecutionOptions(in, &executionOptions{
|
||||
DurationYears: 2,
|
||||
DurationMonths: 2,
|
||||
PaymentMode: renewPaymentModeBalance,
|
||||
})
|
||||
if in.DurationYears != 2 || in.DurationMonths != 2 {
|
||||
t.Fatalf("duration should keep display value: %#v", in)
|
||||
}
|
||||
if got := renewDisplayDurationValue(in); got != 2 {
|
||||
t.Fatalf("renewDisplayDurationValue = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewDeptAncestorsContain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ancestors string
|
||||
deptID int64
|
||||
want bool
|
||||
}{
|
||||
{name: "contains direct ancestor", ancestors: "0,100,200", deptID: 100, want: true},
|
||||
{name: "contains with spaces", ancestors: "0, 100 ,200", deptID: 100, want: true},
|
||||
{name: "does not match partial id", ancestors: "0,1001,200", deptID: 100, want: false},
|
||||
{name: "empty ancestors", ancestors: "", deptID: 100, want: false},
|
||||
{name: "invalid dept", ancestors: "0,100,200", deptID: 0, want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := renewDeptAncestorsContain(test.ancestors, test.deptID); got != test.want {
|
||||
t.Fatalf("renewDeptAncestorsContain(%q, %d) = %v, want %v", test.ancestors, test.deptID, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsAndOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "renew.xlsx")
|
||||
f := excelize.NewFile()
|
||||
headers := ExcelTemplateHeaders()
|
||||
if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil {
|
||||
t.Fatalf("set headers failed: %v", err)
|
||||
}
|
||||
row := []any{
|
||||
"8986000000000000001",
|
||||
"13800008000",
|
||||
"460001234567890",
|
||||
"1",
|
||||
"是",
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil {
|
||||
t.Fatalf("set row failed: %v", err)
|
||||
}
|
||||
if err := f.SaveAs(filePath); err != nil {
|
||||
t.Fatalf("save excel failed: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
rows, options, err := ParseExcelFileRows(context.Background(), filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("parse excel failed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("unexpected row count: %d", len(rows))
|
||||
}
|
||||
if rows[0].Reason != "" {
|
||||
t.Fatalf("unexpected row reason: %s", rows[0].Reason)
|
||||
}
|
||||
if rows[0].Msisdn != "13800008000" {
|
||||
t.Fatalf("unexpected msisdn: %s", rows[0].Msisdn)
|
||||
}
|
||||
if rows[0].Imsi != "460001234567890" {
|
||||
t.Fatalf("unexpected imsi: %s", rows[0].Imsi)
|
||||
}
|
||||
if options == nil {
|
||||
t.Fatal("options should not be nil")
|
||||
}
|
||||
if options.DurationYears != 1 {
|
||||
t.Fatalf("unexpected durationYears: %d", options.DurationYears)
|
||||
}
|
||||
if options.PaymentMode != "offline" {
|
||||
t.Fatalf("unexpected paymentMode: %s", options.PaymentMode)
|
||||
}
|
||||
|
||||
submitParam := options.ToBatchSubmitParam()
|
||||
if submitParam.DurationYears != 1 || submitParam.DurationMonths != 12 {
|
||||
t.Fatalf("unexpected submit duration fields: %#v", submitParam)
|
||||
}
|
||||
if submitParam.PaymentMode != "offline" {
|
||||
t.Fatalf("unexpected submit paymentMode: %#v", submitParam)
|
||||
}
|
||||
if !submitParam.OfflineContractCompleted {
|
||||
t.Fatalf("excel submit should mark offline contract as confirmed: %#v", submitParam)
|
||||
}
|
||||
if submitParam.SourceBatchNo != "" || submitParam.BuyerNote != "" || submitParam.AdminNote != "" || submitParam.Remark != "" {
|
||||
t.Fatalf("excel submit param should not carry removed fields: %#v", submitParam)
|
||||
}
|
||||
|
||||
confirmParam := options.ToBatchConfirmParam("RB20260706001")
|
||||
if confirmParam.BatchNo != "RB20260706001" {
|
||||
t.Fatalf("unexpected batchNo: %s", confirmParam.BatchNo)
|
||||
}
|
||||
if confirmParam.DurationYears != 1 || confirmParam.DurationMonths != 12 {
|
||||
t.Fatalf("unexpected confirm duration fields: %#v", confirmParam)
|
||||
}
|
||||
if confirmParam.PaymentMode != "offline" {
|
||||
t.Fatalf("unexpected confirm paymentMode: %#v", confirmParam)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsRejectsMultipleSheetsWithData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "renew-multi.xlsx")
|
||||
f := excelize.NewFile()
|
||||
headers := ExcelTemplateHeaders()
|
||||
if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil {
|
||||
t.Fatalf("set headers failed: %v", err)
|
||||
}
|
||||
row := []any{
|
||||
"8986000000000000001",
|
||||
"13800008000",
|
||||
"460001234567890",
|
||||
"1",
|
||||
"是",
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil {
|
||||
t.Fatalf("set row failed: %v", err)
|
||||
}
|
||||
_, err := f.NewSheet("Sheet2")
|
||||
if err != nil {
|
||||
t.Fatalf("new sheet failed: %v", err)
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet2", "A1", &headers); err != nil {
|
||||
t.Fatalf("set sheet2 headers failed: %v", err)
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet2", "A2", &row); err != nil {
|
||||
t.Fatalf("set sheet2 row failed: %v", err)
|
||||
}
|
||||
if err := f.SaveAs(filePath); err != nil {
|
||||
t.Fatalf("save excel failed: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
if _, _, err := ParseExcelFileRows(context.Background(), filePath); err == nil {
|
||||
t.Fatal("expected multiple-sheet excel to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareExcelProcessorUsesPrepareBatchType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
processor := NewPrepareExcelProcessor()
|
||||
cfg := processor.Config()
|
||||
if cfg.BatchType != consts.BatchTypePrepare {
|
||||
t.Fatalf("prepare excel batch type = %d, want %d", cfg.BatchType, consts.BatchTypePrepare)
|
||||
}
|
||||
if cfg.BatchNoPrefix != consts.BatchNoPrefixRenew {
|
||||
t.Fatalf("prepare excel batch prefix = %q, want %q", cfg.BatchNoPrefix, consts.BatchNoPrefixRenew)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRenewInputLineSupportsSingleAndTripleFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
line text.FieldLine
|
||||
wantCode string
|
||||
wantIccid string
|
||||
wantMsisdn string
|
||||
wantImsi string
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "single value",
|
||||
line: text.FieldLine{RowNo: 2, Fields: []string{"89860401786597927028"}},
|
||||
wantCode: "89860401786597927028",
|
||||
wantIccid: "89860401786597927028",
|
||||
},
|
||||
{
|
||||
name: "triple values",
|
||||
line: text.FieldLine{RowNo: 3, Fields: []string{"89860401786597927028", "14100007028", "460046597927028"}},
|
||||
wantCode: "89860401786597927028",
|
||||
wantIccid: "89860401786597927028",
|
||||
wantMsisdn: "14100007028",
|
||||
wantImsi: "460046597927028",
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
line: text.FieldLine{RowNo: 4, Fields: []string{"a", "b"}},
|
||||
wantReason: "格式错误",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
row := parseRenewInputLine(test.line)
|
||||
if row.InputCode != test.wantCode {
|
||||
t.Fatalf("input code = %q, want %q", row.InputCode, test.wantCode)
|
||||
}
|
||||
if row.Iccid != test.wantIccid {
|
||||
t.Fatalf("iccid = %q, want %q", row.Iccid, test.wantIccid)
|
||||
}
|
||||
if row.Msisdn != test.wantMsisdn {
|
||||
t.Fatalf("msisdn = %q, want %q", row.Msisdn, test.wantMsisdn)
|
||||
}
|
||||
if row.Imsi != test.wantImsi {
|
||||
t.Fatalf("imsi = %q, want %q", row.Imsi, test.wantImsi)
|
||||
}
|
||||
if test.wantReason != "" && row.Reason == "" {
|
||||
t.Fatal("expected row reason")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewIdentityResolverResolvesAnyCardIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newRenewIdentityResolverFromCards([]*entity.TSimCardInfo{
|
||||
{
|
||||
Id: 1,
|
||||
Iccid: "89860401786597927028",
|
||||
Msisdn: "14100007028",
|
||||
Imsi: "460046597927028",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Iccid: "89860401786597927029",
|
||||
Msisdn: "14100007029",
|
||||
Imsi: "460046597927029",
|
||||
},
|
||||
})
|
||||
|
||||
row := &Row{RowNo: 2, InputCode: "14100007028", Msisdn: "14100007028"}
|
||||
resolver.resolveRow(row)
|
||||
if row.Reason != "" {
|
||||
t.Fatalf("unexpected reason: %s", row.Reason)
|
||||
}
|
||||
if row.Iccid != "89860401786597927028" || row.Msisdn != "14100007028" || row.Imsi != "460046597927028" {
|
||||
t.Fatalf("unexpected resolved row: %#v", row)
|
||||
}
|
||||
|
||||
row = &Row{RowNo: 3, InputCode: "460046597927029", Imsi: "460046597927029"}
|
||||
resolver.resolveRow(row)
|
||||
if row.Reason != "" {
|
||||
t.Fatalf("unexpected reason for imsi row: %s", row.Reason)
|
||||
}
|
||||
if row.Iccid != "89860401786597927029" || row.Msisdn != "14100007029" || row.Imsi != "460046597927029" {
|
||||
t.Fatalf("unexpected imsi resolved row: %#v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewIdentityResolverRejectsAmbiguousOrMismatchedIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newRenewIdentityResolverFromCards([]*entity.TSimCardInfo{
|
||||
{Id: 1, Iccid: "89860401786597927028", Msisdn: "14100007028", Imsi: "460046597927028"},
|
||||
{Id: 2, Iccid: "89860401786597927029", Msisdn: "89860401786597927028", Imsi: "460046597927029"},
|
||||
})
|
||||
|
||||
ambiguous := &Row{RowNo: 2, InputCode: "89860401786597927028", Iccid: "89860401786597927028"}
|
||||
resolver.resolveRow(ambiguous)
|
||||
if ambiguous.Reason == "" {
|
||||
t.Fatal("expected ambiguous reason")
|
||||
}
|
||||
|
||||
mismatched := &Row{RowNo: 3, InputCode: "89860401786597927028", Iccid: "89860401786597927028", Msisdn: "00000000000"}
|
||||
resolver.resolveRow(mismatched)
|
||||
if mismatched.Reason == "" {
|
||||
t.Fatal("expected mismatch reason")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func TestBuildBatchInfoOmitsRemovedEmptyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
info := NewProcessor().BuildBatchInfo(context.Background(), batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardRenewBatchSubmitParam{
|
||||
DurationMonths: 12,
|
||||
GiftDurationMonths: 2,
|
||||
Amount: "100.00",
|
||||
OfflineContractCompleted: true,
|
||||
},
|
||||
ExecutionResult: &CompleteOutput{
|
||||
Quantity: 1,
|
||||
PackageCode: "PKG001",
|
||||
OrderSn: "ORDER001",
|
||||
},
|
||||
}, &batchimport.Result{}, nil, nil, 0)
|
||||
|
||||
data := info.Map()
|
||||
for _, key := range []string{"sourceBatchNo", "buyerNote", "adminNote", "remark"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("batch info should omit %s when empty: %#v", key, data)
|
||||
}
|
||||
}
|
||||
if data["durationMonths"] != 12 {
|
||||
t.Fatalf("unexpected durationMonths: %#v", data["durationMonths"])
|
||||
}
|
||||
if data["giftDurationMonths"] != 2 {
|
||||
t.Fatalf("unexpected giftDurationMonths: %#v", data["giftDurationMonths"])
|
||||
}
|
||||
if data["offlineContractCompleted"] != true {
|
||||
t.Fatalf("unexpected offlineContractCompleted: %#v", data["offlineContractCompleted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentPackagePeriodKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
EffectiveTime: gtime.NewFromStr("2026-01-01"),
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-12-31"),
|
||||
}
|
||||
if got, want := currentPackagePeriodKey(bind), "2026-01-01|2026-12-31"; got != want {
|
||||
t.Fatalf("currentPackagePeriodKey() = %q, want %q", got, want)
|
||||
}
|
||||
if got := currentPackagePeriodKey(&entity.TSimCardBindPkg{}); got != "" {
|
||||
t.Fatalf("currentPackagePeriodKey() for empty bind = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewBaseExpireTimePrefersLaterCardExpire(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
card := &entity.TSimCardInfo{
|
||||
FlowPkgEndAt: gtime.NewFromStr("2026-10-31 00:00:00"),
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-08-31 00:00:00"),
|
||||
}
|
||||
|
||||
if got, want := renewBaseExpireTime(card, bind).Format("Y-m-d"), "2026-10-31"; got != want {
|
||||
t.Fatalf("renewBaseExpireTime() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewBeginDateFromExpireUsesExpireDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
beginAt := renewBeginDateFromExpire(gtime.NewFromStr("2026-08-31 23:59:59"))
|
||||
if beginAt == nil {
|
||||
t.Fatal("renew begin date is nil")
|
||||
}
|
||||
if got, want := beginAt.Format("Y-m-d H:i:s"), "2026-08-31 23:59:59"; got != want {
|
||||
t.Fatalf("renewBeginDateFromExpire() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewRowRejectsExpireAfterSixMonths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
card := &entity.TSimCardInfo{
|
||||
CurrentPackageCode: "PKG001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
PackageCode: "PKG001",
|
||||
ExpectedExpireTime: gtime.Now().AddDate(0, 6, 1),
|
||||
EffectiveTime: gtime.Now().AddDate(-1, 0, 0),
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
|
||||
if got, want := validateRenewRow("", batchimport.OperatorContext{}, card, bind), "距离到期超过6个月,不能续费"; got != want {
|
||||
t.Fatalf("validateRenewRow() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewRowAllowsExpireWithinSixMonths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
card := &entity.TSimCardInfo{
|
||||
CurrentPackageCode: "PKG001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
PackageCode: "PKG001",
|
||||
ExpectedExpireTime: gtime.Now().AddDate(0, 6, 0),
|
||||
EffectiveTime: gtime.Now().AddDate(-1, 0, 0),
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
|
||||
if got := validateRenewRow("", batchimport.OperatorContext{}, card, bind); got != "" {
|
||||
t.Fatalf("validateRenewRow() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewRowRejectsStoppedCard(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
card := &entity.TSimCardInfo{
|
||||
BossCardStatus: consts.V3ApiSimBossCardStatusStopped,
|
||||
CurrentPackageCode: "PKG001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
PackageCode: "PKG001",
|
||||
}
|
||||
|
||||
if got, want := validateRenewRow("", batchimport.OperatorContext{}, card, bind), "ICCID当前为停机状态,不能续费"; got != want {
|
||||
t.Fatalf("validateRenewRow() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewPrepareSubmitParamUsesPrepareSourceBatchNo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardRenewBatchPrepareParam{SourceBatchNo: " OUT20260807001 "},
|
||||
}
|
||||
got := renewPrepareSubmitParam(req)
|
||||
if got.SourceBatchNo != "OUT20260807001" {
|
||||
t.Fatalf("SourceBatchNo = %q, want OUT20260807001", got.SourceBatchNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewPrepareSubmitParamFallsBackToRequestSourceBatchNo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardRenewBatchSubmitParam{},
|
||||
SourceBatchNo: " OUT20260807002 ",
|
||||
}
|
||||
got := renewPrepareSubmitParam(req)
|
||||
if got.SourceBatchNo != "OUT20260807002" {
|
||||
t.Fatalf("SourceBatchNo = %q, want OUT20260807002", got.SourceBatchNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareProcessorValidateRowsDoesNotBlockSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rows := []batchimport.Row{
|
||||
&Row{RowNo: 1, Iccid: "8986001"},
|
||||
&Row{RowNo: 2, Iccid: "8986001"},
|
||||
}
|
||||
if err := NewPrepareProcessor().ValidateRows(context.Background(), rows); err != nil {
|
||||
t.Fatalf("ValidateRows() error = %v", err)
|
||||
}
|
||||
for _, item := range rows {
|
||||
row := item.(*Row)
|
||||
if row.Reason != "" {
|
||||
t.Fatalf("row %d reason = %q, want empty before worker prepare", row.RowNo, row.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ package unsub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"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) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package unsub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestProcessorConfigTemplateHeaders(t *testing.T) {
|
||||
config := NewProcessor().Config()
|
||||
expected := []string{"ICCID", "MSISDN", "IMSI"}
|
||||
if len(config.TemplateHeaders) != len(expected) {
|
||||
t.Fatalf("unexpected template header count: got %d want %d", len(config.TemplateHeaders), len(expected))
|
||||
}
|
||||
for i, header := range expected {
|
||||
if config.TemplateHeaders[i] != header {
|
||||
t.Fatalf("unexpected template header at %d: got %q want %q", i, config.TemplateHeaders[i], header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchInfoExcelDoesNotIncludeRemarkOrSourceBatchNo(t *testing.T) {
|
||||
processor := NewProcessor()
|
||||
info := processor.BuildBatchInfo(context.Background(), batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardUnsubExcelSubmitParam{},
|
||||
}, nil, nil, nil, 0)
|
||||
|
||||
if info.Get("remark").String() != "" {
|
||||
t.Fatalf("excel batch info should not include remark")
|
||||
}
|
||||
if info.Get("sourceBatchNo").String() != "" {
|
||||
t.Fatalf("excel batch info should not include sourceBatchNo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchInfoInputKeepsRemark(t *testing.T) {
|
||||
processor := NewProcessor()
|
||||
info := processor.BuildBatchInfo(context.Background(), batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardUnsubInputSubmitParam{Remark: " note "},
|
||||
}, nil, nil, nil, 0)
|
||||
|
||||
if got := info.Get("remark").String(); got != "note" {
|
||||
t.Fatalf("unexpected remark: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/iot_excelize"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// 导出列白名单。
|
||||
//
|
||||
// 只放行白名单内的字段:前端传什么就导出什么,但传了名单外的字段一律忽略,
|
||||
// 避免误把内部字段(如 id、tenantId)带进对客户的导出文件。
|
||||
var baseColumnWhitelist = map[string]model.ExportColumn{
|
||||
"iccid": {
|
||||
Field: "iccid", Label: "ICCID",
|
||||
},
|
||||
"msisdn": {
|
||||
Field: "msisdn", Label: "MSISDN",
|
||||
},
|
||||
"imsi": {
|
||||
Field: "imsi", Label: "IMSI",
|
||||
},
|
||||
"imei": {
|
||||
Field: "imei", Label: "IMEI",
|
||||
},
|
||||
"usedFlowMb": {
|
||||
Field: "usedFlowMb", Label: "当月用量(MB)",
|
||||
},
|
||||
"remainFlowMb": {
|
||||
Field: "remainFlowMb", Label: "当月剩余用量(MB)",
|
||||
},
|
||||
"packageTotalMb": {
|
||||
Field: "packageTotalMb", Label: "套餐总流量(MB)",
|
||||
},
|
||||
"cardStatus": {
|
||||
Field: "cardStatus", Label: "卡状态", DictType: "t_boss_card_status",
|
||||
},
|
||||
"netModel": {
|
||||
Field: "netModel", Label: "网络类型", DictType: "t_net_model",
|
||||
},
|
||||
"operators": {
|
||||
Field: "operators", Label: "运营商",
|
||||
},
|
||||
"activateAt": {
|
||||
Field: "activateAt", Label: "激活时间",
|
||||
},
|
||||
"outStockTime": {
|
||||
Field: "outStockTime", Label: "出库时间",
|
||||
},
|
||||
"expireAt": {
|
||||
Field: "expireAt", Label: "到期时间",
|
||||
},
|
||||
"outboundBatch": {
|
||||
Field: "outboundBatch", Label: "出库批次",
|
||||
},
|
||||
"inboundBatch": {
|
||||
Field: "inboundBatch", Label: "入库批次",
|
||||
},
|
||||
"packageName": {
|
||||
Field: "packageName", Label: "当前套餐名称",
|
||||
},
|
||||
"deptName": {
|
||||
Field: "deptName", Label: "所属企业",
|
||||
},
|
||||
"remark": {
|
||||
Field: "remark", Label: "备注",
|
||||
},
|
||||
"extraRemark": {
|
||||
Field: "extraRemark", Label: "其他备注",
|
||||
},
|
||||
}
|
||||
|
||||
// baseDefaultOrder 未勾选列时的默认导出顺序,与卡信息列表的常用列保持一致。
|
||||
var baseDefaultOrder = []string{
|
||||
"iccid", "msisdn", "imsi", "imei", "usedFlowMb", "remainFlowMb", "packageTotalMb",
|
||||
"cardStatus", "netModel", "operators", "activateAt", "outStockTime", "expireAt",
|
||||
"outboundBatch", "inboundBatch", "packageName", "deptName", "remark", "extraRemark",
|
||||
}
|
||||
|
||||
// 查询结果与失败原因固定追加在末尾,保证「部分成功」时每条失败可追溯。
|
||||
const (
|
||||
queryResultField = "queryResult"
|
||||
failReasonField = "failReason"
|
||||
)
|
||||
|
||||
func baseDefaultTags() []string {
|
||||
tags := make([]string, 0, len(baseDefaultOrder)+2)
|
||||
for _, field := range baseDefaultOrder {
|
||||
tags = append(tags, baseColumnWhitelist[field].Label)
|
||||
}
|
||||
return append(tags, "查询结果", "失败原因")
|
||||
}
|
||||
|
||||
// baseMandatoryOrder 三码固定导出列。
|
||||
// 这三列是对账的锚点,文件里没有 ICCID/MSISDN/IMSI 就无法定位到卡,
|
||||
// 因此既不允许前端取消勾选,也不允许服务端被传参绕过。
|
||||
var baseMandatoryOrder = []string{"iccid", "msisdn", "imsi"}
|
||||
|
||||
// resolveBaseColumns 归一化前端传入的导出列:三码强制在前,其余去重后按白名单追加,并固定追加结果列。
|
||||
func resolveBaseColumns(in *model.SimCardQueryExportParam) ([]model.ExportColumn, error) {
|
||||
requested := decodeExportColumns(in)
|
||||
columns := make([]model.ExportColumn, 0, len(requested)+5)
|
||||
seen := make(map[string]struct{}, len(requested)+len(baseMandatoryOrder))
|
||||
for _, field := range baseMandatoryOrder {
|
||||
seen[field] = struct{}{}
|
||||
columns = append(columns, baseColumnWhitelist[field])
|
||||
}
|
||||
for _, column := range requested {
|
||||
field := strings.TrimSpace(column.Field)
|
||||
allowed, ok := baseColumnWhitelist[field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[field]; dup {
|
||||
continue
|
||||
}
|
||||
seen[field] = struct{}{}
|
||||
// 表头以服务端白名单为准,忽略前端自定义文案,避免导出列与页面口径不一致。
|
||||
columns = append(columns, allowed)
|
||||
}
|
||||
// 未勾选任何可选列时,把剩余默认列补齐;三码已在前面,不会重复。
|
||||
if len(columns) == len(baseMandatoryOrder) {
|
||||
for _, field := range baseDefaultOrder {
|
||||
if _, dup := seen[field]; dup {
|
||||
continue
|
||||
}
|
||||
seen[field] = struct{}{}
|
||||
columns = append(columns, baseColumnWhitelist[field])
|
||||
}
|
||||
}
|
||||
// 结果列不允许被去掉:部分成功时用户必须能看到每条失败的原因。
|
||||
columns = append(columns,
|
||||
model.ExportColumn{Field: queryResultField, Label: "查询结果"},
|
||||
model.ExportColumn{Field: failReasonField, Label: "失败原因"},
|
||||
)
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
// decodeExportColumns 解析前端传入的导出列。
|
||||
// 优先解析 JSON 字符串(表单提交时数组套对象会被拆散),失败再退回对象数组。
|
||||
func decodeExportColumns(in *model.SimCardQueryExportParam) []model.ExportColumn {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
if raw := strings.TrimSpace(in.ExportColumnsJson); raw != "" {
|
||||
cols := make([]model.ExportColumn, 0)
|
||||
if err := gjson.New(raw).Scan(&cols); err == nil && len(cols) > 0 {
|
||||
return cols
|
||||
}
|
||||
}
|
||||
return in.ExportColumns
|
||||
}
|
||||
|
||||
// baseQuerySnapshot 落库的筛选条件快照。
|
||||
// 只留模式、条数和前若干条样本,不存全量号码,避免 query_params 膨胀。
|
||||
func baseQuerySnapshot(in *model.SimCardQueryExportParam, inputRows int) g.Map {
|
||||
snapshot := g.Map{
|
||||
"mode": in.Mode,
|
||||
"inputRows": inputRows,
|
||||
}
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeRange:
|
||||
snapshot["dimension"] = in.Dimension
|
||||
snapshot["startNum"] = in.StartNum
|
||||
snapshot["endNum"] = in.EndNum
|
||||
case model.SimCardQueryModeInput:
|
||||
snapshot["batchInputPreview"] = previewText(in.BatchInput, 5)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func previewText(text string, limit int) []string {
|
||||
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
out := make([]string, 0, limit)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// roundMb 统一保留 3 位小数,与库表 decimal(18,3) 精度一致。
|
||||
func roundMb(value float64) float64 {
|
||||
return math.Round(value*1000) / 1000
|
||||
}
|
||||
|
||||
// usageHistoryWhitelist 月历史用量查询允许勾选的固定列。
|
||||
// 月份列是动态生成的,直接按月份值作 Field,不走白名单。
|
||||
var usageHistoryWhitelist = map[string]model.ExportColumn{
|
||||
"iccid": {Field: "iccid", Label: "ICCID"},
|
||||
"msisdn": {Field: "msisdn", Label: "MSISDN"},
|
||||
"imsi": {Field: "imsi", Label: "IMSI"},
|
||||
"outStockTime": {Field: "outStockTime", Label: "出库时间"},
|
||||
"expireAt": {Field: "expireAt", Label: "到期时间"},
|
||||
}
|
||||
|
||||
// usageHistoryOptionalFields 月历史用量里可勾选的固定列(三码固定导出,不在此列)。
|
||||
var usageHistoryOptionalFields = []string{"outStockTime", "expireAt"}
|
||||
|
||||
// resolveUsageHistoryColumns 生成月历史用量导出列。
|
||||
// 三码固定在最前且不可取消;月份列固定跟随导出口径,不允许前端自行增删。
|
||||
func resolveUsageHistoryColumns(in *model.SimCardUsageHistoryExportParam, months []string) []model.ExportColumn {
|
||||
requested := decodeExportColumns(&in.SimCardQueryExportParam)
|
||||
allowed := make(map[string]struct{}, len(requested))
|
||||
for _, column := range requested {
|
||||
field := strings.TrimSpace(column.Field)
|
||||
if _, ok := usageHistoryWhitelist[field]; ok {
|
||||
allowed[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
columns := make([]model.ExportColumn, 0, len(usageHistoryFixedFields)+len(months)+2)
|
||||
for _, field := range baseMandatoryOrder {
|
||||
columns = append(columns, usageHistoryWhitelist[field])
|
||||
}
|
||||
// 未勾选任何可选列时按默认全出,命中筛选时按固定顺序保留。
|
||||
if len(allowed) == 0 {
|
||||
for _, field := range usageHistoryOptionalFields {
|
||||
columns = append(columns, usageHistoryWhitelist[field])
|
||||
}
|
||||
} else {
|
||||
for _, field := range usageHistoryOptionalFields {
|
||||
if _, ok := allowed[field]; ok {
|
||||
columns = append(columns, usageHistoryWhitelist[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, month := range months {
|
||||
columns = append(columns, model.ExportColumn{Field: month, Label: month})
|
||||
}
|
||||
columns = append(columns,
|
||||
model.ExportColumn{Field: queryResultField, Label: "查询结果"},
|
||||
model.ExportColumn{Field: failReasonField, Label: "失败原因"},
|
||||
)
|
||||
return columns
|
||||
}
|
||||
|
||||
func usageHistoryTags(months []string) []string {
|
||||
tags := make([]string, 0, len(usageHistoryFixedFields)+len(months)+2)
|
||||
for _, field := range usageHistoryFixedFields {
|
||||
tags = append(tags, usageHistoryWhitelist[field].Label)
|
||||
}
|
||||
tags = append(tags, months...)
|
||||
return append(tags, "查询结果", "失败原因")
|
||||
}
|
||||
|
||||
func usageHistorySnapshot(in *model.SimCardUsageHistoryExportParam, months []string, rows int) g.Map {
|
||||
snapshot := g.Map{
|
||||
"mode": in.Mode,
|
||||
"months": months,
|
||||
"inputRows": rows,
|
||||
}
|
||||
if in.Mode == model.SimCardQueryModeDept {
|
||||
snapshot["deptId"] = in.DeptId
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// UsageHistoryTemplate 生成月历史用量查询模板(与卡基础信息查询同一份模板口径)。
|
||||
func (s *Service) UsageHistoryTemplate(ctx context.Context) (*excelize.File, string, error) {
|
||||
return s.BaseTemplate(ctx)
|
||||
}
|
||||
|
||||
// BaseColumnOptions 卡基础信息查询可选导出列,供前端渲染勾选框。
|
||||
func (s *Service) BaseColumnOptions() []model.ExportColumn {
|
||||
options := make([]model.ExportColumn, 0, len(baseDefaultOrder))
|
||||
for _, field := range baseDefaultOrder {
|
||||
options = append(options, baseColumnWhitelist[field])
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// UsageHistoryColumnOptions 月历史用量查询可选固定列(不含动态月份列)。
|
||||
func (s *Service) UsageHistoryColumnOptions() []model.ExportColumn {
|
||||
options := make([]model.ExportColumn, 0, len(usageHistoryFixedFields))
|
||||
for _, field := range usageHistoryFixedFields {
|
||||
options = append(options, usageHistoryWhitelist[field])
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// UsageHistoryMonths 当前导出口径的月份清单,前端用它预览列顺序。
|
||||
func (s *Service) UsageHistoryMonths(now time.Time) []string {
|
||||
return LastCompleteMonths(now, usageHistoryMonths)
|
||||
}
|
||||
|
||||
// BaseTemplate 生成卡基础信息查询模板。
|
||||
// 三列都允许为空,但至少填一个;表头与导入解析的 RequiredHeaders 保持一致。
|
||||
func (s *Service) BaseTemplate(ctx context.Context) (*excelize.File, string, error) {
|
||||
f := excelize.NewFile()
|
||||
sheet := "卡基础信息查询"
|
||||
index, err := f.NewSheet(sheet)
|
||||
if err != nil {
|
||||
return nil, "", gerror.Wrap(err, "创建模板工作表失败")
|
||||
}
|
||||
f.SetActiveSheet(index)
|
||||
if err := f.DeleteSheet("Sheet1"); err != nil {
|
||||
return nil, "", gerror.Wrap(err, "清理默认工作表失败")
|
||||
}
|
||||
|
||||
headers := []string{"ICCID", "MSISDN", "IMSI"}
|
||||
for i, header := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
if err = f.SetCellValue(sheet, cell, header); err != nil {
|
||||
return nil, "", gerror.Wrap(err, "写入模板表头失败")
|
||||
}
|
||||
}
|
||||
if err = f.SetColWidth(sheet, "A", "C", 26); err != nil {
|
||||
return nil, "", gerror.Wrap(err, "设置模板列宽失败")
|
||||
}
|
||||
if err = iot_excelize.ApplyHeaderColumnsTextStyle(f); err != nil {
|
||||
g.Log().Warningf(ctx, "apply template style failed: %v", err)
|
||||
}
|
||||
|
||||
return f, "卡基础信息查询模板", nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
// DeptOptions 所属公司下拉项(树形)。
|
||||
//
|
||||
// 两点关键口径:
|
||||
// 1. 可见范围交给 handler.FilterAuth:顶级看全部,其他角色按自身数据范围。
|
||||
// 2. CardCount 是「含下级」的累计量,不是本部门直接挂的卡。
|
||||
// 原因:顶级公司(如示例企业)自身往往不挂卡,卡都在下级代理名下,
|
||||
// 只统计本部门会显示 0,看起来像没数据。
|
||||
func (s *Service) DeptOptions(ctx context.Context) ([]*model.SimCardQueryDeptOption, error) {
|
||||
deptColumns := dao.SysDept.Columns()
|
||||
depts := make([]*struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
ParentId int64 `orm:"parent_id"`
|
||||
}, 0, 256)
|
||||
|
||||
err := handler.Model(dao.SysDept.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(deptColumns.DeptId, deptColumns.DeptName, deptColumns.ParentId).
|
||||
Where(deptColumns.Status, 0).
|
||||
WhereNull(deptColumns.DeletedAt).
|
||||
OrderAsc(deptColumns.OrderNum).
|
||||
OrderAsc(deptColumns.DeptId).
|
||||
Scan(&depts)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取可选公司失败,请稍后重试")
|
||||
}
|
||||
if len(depts) == 0 {
|
||||
return []*model.SimCardQueryDeptOption{}, nil
|
||||
}
|
||||
|
||||
direct, err := directCardCountByDept(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subtree := accumulateSubtreeCount(depts, direct)
|
||||
|
||||
options := make([]*model.SimCardQueryDeptOption, 0, len(depts))
|
||||
for _, dept := range depts {
|
||||
options = append(options, &model.SimCardQueryDeptOption{
|
||||
DeptId: dept.DeptId,
|
||||
DeptName: dept.DeptName,
|
||||
CardCount: subtree[dept.DeptId],
|
||||
ParentId: dept.ParentId,
|
||||
})
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// directCardCountByDept 按部门统计直接挂载的卡数量。
|
||||
// 卡表同样过数据权限,防止公司列表被收窄、但计数把无权卡的量带出来。
|
||||
func directCardCountByDept(ctx context.Context) (map[int64]int64, error) {
|
||||
cardColumns := dao.TSimCardInfo.Columns()
|
||||
rows := make([]*struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
Total int64 `orm:"total"`
|
||||
}, 0, 256)
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(fmt.Sprintf("%s AS dept_id, COUNT(1) AS total", cardColumns.DeptId)).
|
||||
Group(cardColumns.DeptId).
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "统计公司卡数量失败")
|
||||
}
|
||||
result := make(map[int64]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
result[row.DeptId] = row.Total
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// accumulateSubtreeCount 把「本部门直接卡数」沿部门树向上累加,得到含下级的卡数。
|
||||
//
|
||||
// 只累加本次查询可见的部门:无权部门根本不在 depts 里,其卡数也不会被计入,
|
||||
// 因此不会通过父级汇总把无权数据带出来。
|
||||
func accumulateSubtreeCount(
|
||||
depts []*struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
ParentId int64 `orm:"parent_id"`
|
||||
},
|
||||
direct map[int64]int64,
|
||||
) map[int64]int64 {
|
||||
visible := make(map[int64]struct{}, len(depts))
|
||||
children := make(map[int64][]int64, len(depts))
|
||||
roots := make([]int64, 0, len(depts))
|
||||
for _, dept := range depts {
|
||||
visible[dept.DeptId] = struct{}{}
|
||||
}
|
||||
for _, dept := range depts {
|
||||
if _, ok := visible[dept.ParentId]; ok && dept.ParentId != dept.DeptId {
|
||||
children[dept.ParentId] = append(children[dept.ParentId], dept.DeptId)
|
||||
continue
|
||||
}
|
||||
roots = append(roots, dept.DeptId)
|
||||
}
|
||||
|
||||
subtree := make(map[int64]int64, len(depts))
|
||||
// 部门树很浅,递归深度可控;用显式栈避免深层树爆栈。
|
||||
type frame struct {
|
||||
id int64
|
||||
child int
|
||||
amount int64
|
||||
}
|
||||
for _, rootID := range roots {
|
||||
stack := []*frame{{id: rootID, amount: direct[rootID]}}
|
||||
for len(stack) > 0 {
|
||||
top := stack[len(stack)-1]
|
||||
if kids := children[top.id]; top.child < len(kids) {
|
||||
childID := kids[top.child]
|
||||
top.child++
|
||||
stack = append(stack, &frame{id: childID, amount: direct[childID]})
|
||||
continue
|
||||
}
|
||||
subtree[top.id] = top.amount
|
||||
stack = stack[:len(stack)-1]
|
||||
if len(stack) > 0 {
|
||||
stack[len(stack)-1].amount += top.amount
|
||||
}
|
||||
}
|
||||
}
|
||||
return subtree
|
||||
}
|
||||
|
||||
// deptSubtreeIDs 返回某部门及其全部下级的 dept_id 子查询。
|
||||
// 与 DeptOptions 的统计口径保持一致:选中公司即包含下级公司。
|
||||
func deptSubtreeIDs(ctx context.Context, deptId int64) *gdb.Model {
|
||||
deptColumns := dao.SysDept.Columns()
|
||||
return handler.Model(dao.SysDept.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
}).Fields(deptColumns.DeptId).
|
||||
Where(fmt.Sprintf("%s = ? OR %s LIKE ?", deptColumns.DeptId, deptColumns.Ancestors),
|
||||
deptId, fmt.Sprintf("%%,%d,%%", deptId))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// buildRows 造 n 行「卡基础信息」级别的数据:22 列、字段长短接近真实。
|
||||
func buildRows(n int) [][]interface{} {
|
||||
rows := make([][]interface{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
id := strconv.Itoa(8986000000000000000 + i)
|
||||
rows[i] = []interface{}{
|
||||
id,
|
||||
"186" + strconv.Itoa(10000000+i),
|
||||
"46000" + strconv.Itoa(100000000+i),
|
||||
"86000000000000" + strconv.Itoa(i%100),
|
||||
float64(i % 5000),
|
||||
float64(5000 - i%5000),
|
||||
10240.0,
|
||||
2,
|
||||
4,
|
||||
"中国移动",
|
||||
"2026-01-01 00:00:00",
|
||||
"2025-12-01 00:00:00",
|
||||
"2027-12-31 23:59:59",
|
||||
"OUT20260101",
|
||||
"IN20251201",
|
||||
"标准套餐A",
|
||||
"示例企业",
|
||||
"备注内容示例",
|
||||
"",
|
||||
"成功",
|
||||
"",
|
||||
"",
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// TestExportWriteCost 量不同行数下「写 Excel + 生成文件」的真实耗时与体积。
|
||||
// 用来判断 20000 这个上限是否偏离实际能力,而不是拍脑袋。
|
||||
func TestExportWriteCost(t *testing.T) {
|
||||
for _, n := range []int{20000, 50000, 100000} {
|
||||
rows := buildRows(n)
|
||||
start := time.Now()
|
||||
|
||||
f := excelize.NewFile()
|
||||
sheet := f.GetSheetName(0)
|
||||
stream, err := f.NewStreamWriter(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("new stream writer: %v", err)
|
||||
}
|
||||
header := make([]interface{}, 22)
|
||||
for i := range header {
|
||||
header[i] = "列" + strconv.Itoa(i)
|
||||
}
|
||||
if err = stream.SetRow("A1", header); err != nil {
|
||||
t.Fatalf("write header: %v", err)
|
||||
}
|
||||
for i, row := range rows {
|
||||
cell, _ := excelize.CoordinatesToCellName(1, i+2)
|
||||
if err = stream.SetRow(cell, row); err != nil {
|
||||
t.Fatalf("write row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err = stream.Flush(); err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
buf, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatalf("write to buffer: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
t.Logf("rows=%6d write=%8.2fs size=%6.1fMB", n, elapsed.Seconds(), float64(buf.Len())/(1<<20))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
// Package simcardquery 卡基础信息查询 与 月历史用量查询。
|
||||
//
|
||||
// 两个功能都是「按号码批量查卡 → 异步导出」,共用同一套输入解析与导出列机制:
|
||||
// - 输入来源:Excel 模板导入 / 多行文本批量输入 / 连号号段;
|
||||
// - 号码识别:每行 1 个值(ICCID、MSISDN、IMSI 任意一种)或 3 个值(ICCID MSISDN IMSI),
|
||||
// 单值行在三列之间做 OR 匹配,因此不需要用户先选「用三码还是只用 ICCID」;
|
||||
// - 导出走通用异步导出中心,文件末尾固定带「查询结果」「失败原因」两列,
|
||||
// 未命中的输入也会出现在文件里,便于对账。
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/cardflow"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// 查询结果列取值,导出文件与执行日志共用同一套文案。
|
||||
const (
|
||||
queryResultHit = "成功"
|
||||
queryResultMiss = "失败"
|
||||
)
|
||||
|
||||
// chunkSize 单次 IN 查询的号码条数。
|
||||
// 1000 是 MySQL 解析大量占位符仍能稳定走索引的量级,也和现有批量查询上限一致。
|
||||
const chunkSize = 1000
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func New() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// inputLine 一行输入:可能是 1 个号码,也可能是 ICCID/MSISDN/IMSI 三码。
|
||||
type inputLine struct {
|
||||
RowNo int
|
||||
Values []string
|
||||
}
|
||||
|
||||
// cardBrief 查询命中的卡基础字段,按号码索引后复用。
|
||||
type cardBrief struct {
|
||||
Id int64 `orm:"id"`
|
||||
Iccid string `orm:"iccid"`
|
||||
Msisdn string `orm:"msisdn"`
|
||||
Imsi string `orm:"imsi"`
|
||||
Imei string `orm:"imei"`
|
||||
UsedFlowMb float64 `orm:"used_flow_mb"`
|
||||
PackageTotalMb float64 `orm:"query_total_flow_mb"`
|
||||
CardStatus int `orm:"boss_card_status"`
|
||||
NetModel int `orm:"net_model"`
|
||||
ChanKey string `orm:"chan_key"`
|
||||
ActivateAt *gtime.Time `orm:"boss_activate_at"`
|
||||
OutStockTime *gtime.Time `orm:"out_stock_time"`
|
||||
ExpireAt *gtime.Time `orm:"flow_pkg_end_at"`
|
||||
OutboundBatch string `orm:"outbound_batch_no"`
|
||||
InboundBatch string `orm:"inbound_batch_no"`
|
||||
PackageCode string `orm:"current_package_code"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
Remark string `orm:"remark"`
|
||||
}
|
||||
|
||||
// ExcelInput 上传文件的读取器工厂。
|
||||
// 每次调用返回新的读取器,保证后台解析时读到完整内容。
|
||||
type ExcelInput func() (io.Reader, error)
|
||||
|
||||
// ExportBase 卡基础信息查询导出。
|
||||
func (s *Service) ExportBase(ctx context.Context, in *model.SimCardQueryExportParam, input ExcelInput) (*exportcenter.SubmitResult, error) {
|
||||
lines, err := parseInputs(ctx, in, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns, err := resolveBaseColumns(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := &resolveStats{}
|
||||
once := &sync.Once{}
|
||||
var cached []*model.SimCardBaseQueryRow
|
||||
var cacheErr error
|
||||
resolve := func(ctx context.Context) ([]*model.SimCardBaseQueryRow, error) {
|
||||
once.Do(func() {
|
||||
if in.Mode == model.SimCardQueryModeRange {
|
||||
cached, cacheErr = s.resolveRangeRows(ctx, in, stats)
|
||||
return
|
||||
}
|
||||
cached, cacheErr = s.resolveBaseRows(ctx, lines, stats)
|
||||
})
|
||||
return cached, cacheErr
|
||||
}
|
||||
|
||||
return exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardBaseQuery",
|
||||
BizName: "卡基础信息查询",
|
||||
PermCode: "cpm:iot:tSimCardBaseQuery:view",
|
||||
SheetName: "卡基础信息",
|
||||
QueryParam: baseQuerySnapshot(in, len(lines)),
|
||||
Columns: columns,
|
||||
Tags: func() ([]string, error) {
|
||||
return baseDefaultTags(), nil
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) {
|
||||
// exportcenter 提交时会用 pageSize=1 做一次「总数探针」。
|
||||
// 直接返回输入行数(号段模式未知条数,返回 1 占位),让提交接口立即返回,
|
||||
// 真正的查库放到后台协程里做。
|
||||
if pageSize <= 1 && page <= 1 {
|
||||
if len(lines) > 0 {
|
||||
return nil, len(lines), nil
|
||||
}
|
||||
// 号段模式:号段包含多少个号码就是预计条数,与「查不查得到」无关。
|
||||
// 这样用户输入 10 个连续号,即使一条都查不到,页面也会显示「预计 10 条」。
|
||||
size, sizeErr := rangeSize(in)
|
||||
if sizeErr != nil {
|
||||
return nil, 0, sizeErr
|
||||
}
|
||||
return nil, int(size), nil
|
||||
}
|
||||
rows, err := resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total := len(rows)
|
||||
start := (page - 1) * pageSize
|
||||
if start >= total {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
out := make([]interface{}, 0, end-start)
|
||||
for _, row := range rows[start:end] {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, total, nil
|
||||
},
|
||||
Result: stats.resultSummary,
|
||||
})
|
||||
}
|
||||
|
||||
// parseInputs 按模式解析输入号码。
|
||||
func parseInputs(ctx context.Context, in *model.SimCardQueryExportParam, input ExcelInput) ([]inputLine, error) {
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeInput:
|
||||
return parseTextInputs(ctx, in.BatchInput)
|
||||
case model.SimCardQueryModeExcel:
|
||||
if input == nil {
|
||||
return nil, gerror.New("请先上传 Excel 文件")
|
||||
}
|
||||
reader, err := input()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reader == nil {
|
||||
return nil, gerror.New("请先上传 Excel 文件")
|
||||
}
|
||||
lines, err := streamExcelInputs(ctx, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return nil, gerror.New("Excel 中没有可解析的号码")
|
||||
}
|
||||
return lines, nil
|
||||
case model.SimCardQueryModeRange:
|
||||
// 号段模式不展开成逐个号码:连号可能跨上百万个号,展开会把内存打满。
|
||||
// 只做校验,实际取数交给 resolveRangeRows 用 BETWEEN 条件分页查。
|
||||
return nil, parseRangeInputs(in)
|
||||
default:
|
||||
return nil, gerror.New("不支持的查询方式")
|
||||
}
|
||||
}
|
||||
|
||||
// parseRangeInputs 校验连号号段参数。
|
||||
func parseRangeInputs(in *model.SimCardQueryExportParam) error {
|
||||
start := strings.TrimSpace(in.StartNum)
|
||||
end := strings.TrimSpace(in.EndNum)
|
||||
if start == "" || end == "" {
|
||||
return gerror.New("连号查询需同时填写起始和结束号码")
|
||||
}
|
||||
if len(start) != len(end) || !isDigits(start) || !isDigits(end) {
|
||||
return gerror.New("起止号码必须为等长数字")
|
||||
}
|
||||
if start > end {
|
||||
return gerror.New("起始号码不能大于结束号码")
|
||||
}
|
||||
_, err := rangeColumn(in.Dimension)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 号段按号码逐个输出(查到出行数据、没查到出失败行),条数必须与号段大小一致。
|
||||
// 因此号段不能超过单次上限,否则无法完整展开。
|
||||
size, err := rangeSize(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size > model.SimCardQueryMaxRows {
|
||||
return gerror.Newf("号段内号码数量超过单次上限 %d 个,请缩小范围或分批查询", model.SimCardQueryMaxRows)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTextInputs 解析多行文本。每行 1~3 个空白分隔的号码值。
|
||||
func isDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseTextInputs(ctx context.Context, input string) ([]inputLine, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New("批量输入内容不能为空")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(lines) > model.SimCardQueryMaxRows {
|
||||
return nil, gerror.Newf("批量输入 %d 行,超过单次上限 %d 行,请拆分后分批查询", len(lines), model.SimCardQueryMaxRows)
|
||||
}
|
||||
out := make([]inputLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if len(line.Fields) > 3 {
|
||||
return nil, gerror.Newf("第 %d 行格式错误:每行支持 1 至 3 个号码值", line.RowNo)
|
||||
}
|
||||
out = append(out, inputLine{RowNo: line.RowNo, Values: line.Fields})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// streamExcelInputs 流式解析 Excel 上传。ICCID / MSISDN / IMSI 三列任填其一即可。
|
||||
func streamExcelInputs(ctx context.Context, reader io.Reader) ([]inputLine, error) {
|
||||
out := make([]inputLine, 0)
|
||||
_, err := excel.StreamReader(ctx, reader, excel.ParseOptions{
|
||||
SingleSheet: true,
|
||||
MaxDataRows: model.SimCardQueryMaxRows,
|
||||
}, func(row excel.ParsedRow) error {
|
||||
values := make([]string, 0, 3)
|
||||
for _, name := range []string{"ICCID", "MSISDN", "IMSI"} {
|
||||
if value := excel.Field(row, name); value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
// 空行由解析库跳过;这里保留为失败行,便于用户知道模板里哪一行没填。
|
||||
values = append(values, "")
|
||||
}
|
||||
out = append(out, inputLine{RowNo: row.RowNo, Values: values})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, gerror.New("Excel 中没有可解析的号码")
|
||||
}
|
||||
if len(out) > model.SimCardQueryMaxRows {
|
||||
return nil, gerror.Newf("Excel 有效数据 %d 行,超过单次上限 %d 行,请拆分后分批查询", len(out), model.SimCardQueryMaxRows)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveStats 记录命中/未命中/去重数量,用于导出结束后的执行日志汇总。
|
||||
type resolveStats struct {
|
||||
mu sync.Mutex
|
||||
hit int
|
||||
miss int
|
||||
dup int
|
||||
}
|
||||
|
||||
func (s *resolveStats) add(hit bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if hit {
|
||||
s.hit++
|
||||
return
|
||||
}
|
||||
s.miss++
|
||||
}
|
||||
|
||||
// addDup 记录一条因重复而合并的输入。
|
||||
// 同一张卡可能是同一号码写了两遍,也可能一行 ICCID、另一行 MSISDN 指向同一张卡。
|
||||
func (s *resolveStats) addDup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.dup++
|
||||
}
|
||||
|
||||
func (s *resolveStats) resultSummary(_ int) (int, string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
total := s.hit + s.miss
|
||||
if total == 0 {
|
||||
return consts.BatchRunResultFailed, "查询结果:没有可导出的数据"
|
||||
}
|
||||
dupText := ""
|
||||
if s.dup > 0 {
|
||||
dupText = fmt.Sprintf(",重复 %d 条已合并", s.dup)
|
||||
}
|
||||
if s.miss == 0 {
|
||||
return consts.BatchRunResultSuccess,
|
||||
fmt.Sprintf("查询完成:成功 %d 条,失败 0 条%s", s.hit, dupText)
|
||||
}
|
||||
if s.hit == 0 {
|
||||
return consts.BatchRunResultFailed,
|
||||
fmt.Sprintf("查询失败:成功 0 条,失败 %d 条%s;失败原因见导出文件「失败原因」列", s.miss, dupText)
|
||||
}
|
||||
return consts.BatchRunResultPartialSuccess,
|
||||
fmt.Sprintf("部分成功:成功 %d 条,失败 %d 条%s;失败原因见导出文件「失败原因」列", s.hit, s.miss, dupText)
|
||||
}
|
||||
|
||||
// resolveBaseRows 把输入号码解析成导出行,未命中的行也保留并写明原因。
|
||||
func (s *Service) resolveBaseRows(ctx context.Context, lines []inputLine, stats *resolveStats) ([]*model.SimCardBaseQueryRow, error) {
|
||||
values := collectValues(lines)
|
||||
cards, err := queryCardsByValues(ctx, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byIccid := indexCards(cards, func(card *cardBrief) string { return card.Iccid })
|
||||
byMsisdn := indexCards(cards, func(card *cardBrief) string { return card.Msisdn })
|
||||
byImsi := indexCards(cards, func(card *cardBrief) string { return card.Imsi })
|
||||
|
||||
display := newDisplayResolver(ctx, cards)
|
||||
rows := make([]*model.SimCardBaseQueryRow, 0, len(lines))
|
||||
// 同一张卡在输入里出现多次时只导出一次:
|
||||
// 可能是同一号码重复填写,也可能一行是 ICCID、另一行是它的 MSISDN。
|
||||
exported := make(map[int64]struct{}, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &model.SimCardBaseQueryRow{}
|
||||
card, reason := matchCard(line, byIccid, byMsisdn, byImsi)
|
||||
if card == nil {
|
||||
// 未命中时把输入的第一列原样回填,方便用户定位是哪一条没查到。
|
||||
row.Iccid = firstValue(line.Values)
|
||||
row.QueryResult = queryResultMiss
|
||||
row.FailReason = inputFailure(line, reason)
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
if _, ok := exported[card.Id]; ok {
|
||||
stats.addDup()
|
||||
continue
|
||||
}
|
||||
exported[card.Id] = struct{}{}
|
||||
display.fill(row, card)
|
||||
row.QueryResult = queryResultHit
|
||||
stats.add(true)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// resolveRangeRows 号段模式:按起止号段分页取卡,取到的卡全部视为命中。
|
||||
// 取满上限即停,避免上百万号的号段把导出任务拖死。
|
||||
func (s *Service) resolveRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, stats *resolveStats) ([]*model.SimCardBaseQueryRow, error) {
|
||||
cards, err := queryCardsByRange(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildRangeRows(ctx, in, cards, stats), nil
|
||||
}
|
||||
|
||||
// setRangeNumber 按号段维度把号码写进对应列。
|
||||
func setRangeNumber(row *model.SimCardBaseQueryRow, dimension, number string) {
|
||||
switch dimension {
|
||||
case "msisdn":
|
||||
row.Msisdn = number
|
||||
case "imsi":
|
||||
row.Imsi = number
|
||||
default:
|
||||
row.Iccid = number
|
||||
}
|
||||
}
|
||||
|
||||
// rangeCardIndex 按号段维度值索引命中的卡。
|
||||
func rangeCardIndex(cards []*cardBrief, dimension string) map[string]*cardBrief {
|
||||
index := make(map[string]*cardBrief, len(cards))
|
||||
for _, card := range cards {
|
||||
var value string
|
||||
switch dimension {
|
||||
case "msisdn":
|
||||
value = card.Msisdn
|
||||
case "imsi":
|
||||
value = card.Imsi
|
||||
default:
|
||||
value = card.Iccid
|
||||
}
|
||||
if value != "" {
|
||||
index[value] = card
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// buildRangeRows 号段模式:按号段逐个号码输出一行,命中出行数据、未命中出失败行。
|
||||
//
|
||||
// 不能只输出命中的卡:号段里可能一部分在当前账号可见范围内、一部分不在,
|
||||
// 只输出命中部分会让导出条数与号段大小对不上,客户无法按号对账。
|
||||
func buildRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, cards []*cardBrief, stats *resolveStats) []*model.SimCardBaseQueryRow {
|
||||
dimension := strings.TrimSpace(in.Dimension)
|
||||
index := rangeCardIndex(cards, dimension)
|
||||
size, err := rangeSize(in)
|
||||
if err != nil {
|
||||
size = int64(len(cards))
|
||||
}
|
||||
numbers := rangeNumbers(in, size)
|
||||
display := newDisplayResolver(ctx, cards)
|
||||
rows := make([]*model.SimCardBaseQueryRow, 0, len(numbers))
|
||||
for _, number := range numbers {
|
||||
if card, ok := index[number]; ok {
|
||||
row := &model.SimCardBaseQueryRow{QueryResult: queryResultHit}
|
||||
display.fill(row, card)
|
||||
stats.add(true)
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
row := &model.SimCardBaseQueryRow{
|
||||
QueryResult: queryResultMiss,
|
||||
FailReason: rangeMissReason,
|
||||
}
|
||||
setRangeNumber(row, dimension, number)
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// rangeMissReason 号段/号码查不到数据时的统一说明。
|
||||
// 不能写「无权访问」:是否因为权限被过滤属于内部口径,不应暴露给客户。
|
||||
const rangeMissReason = "未查询到数据"
|
||||
|
||||
// rangeSize 号段包含的号码个数,用于提交时给出「预计 N 条」。
|
||||
// ICCID 有 20 位,超过 int64 表示范围,用 big.Int 计算。
|
||||
func rangeSize(in *model.SimCardQueryExportParam) (int64, error) {
|
||||
start := strings.TrimSpace(in.StartNum)
|
||||
end := strings.TrimSpace(in.EndNum)
|
||||
s, ok := new(big.Int).SetString(start, 10)
|
||||
if !ok {
|
||||
return 0, gerror.New("起始号码格式不正确")
|
||||
}
|
||||
e, ok := new(big.Int).SetString(end, 10)
|
||||
if !ok {
|
||||
return 0, gerror.New("结束号码格式不正确")
|
||||
}
|
||||
if e.Cmp(s) < 0 {
|
||||
return 0, gerror.New("起始号码不能大于结束号码")
|
||||
}
|
||||
span := new(big.Int).Sub(e, s)
|
||||
span.Add(span, big.NewInt(1))
|
||||
if !span.IsInt64() {
|
||||
return math.MaxInt64, nil
|
||||
}
|
||||
return span.Int64(), nil
|
||||
}
|
||||
|
||||
// padNumber 保持与输入等长,避免前导零被 big.Int 吃掉后与用户输入对不上。
|
||||
func padNumber(num string, width int) string {
|
||||
if len(num) >= width {
|
||||
return num
|
||||
}
|
||||
return strings.Repeat("0", width-len(num)) + num
|
||||
}
|
||||
|
||||
// rangeNumbers 按号段依次产出号码,最多 limit 个。
|
||||
func rangeNumbers(in *model.SimCardQueryExportParam, limit int64) []string {
|
||||
start := strings.TrimSpace(in.StartNum)
|
||||
s, ok := new(big.Int).SetString(start, 10)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, limit)
|
||||
cur := new(big.Int).Set(s)
|
||||
for i := int64(0); i < limit; i++ {
|
||||
out = append(out, padNumber(cur.String(), len(start)))
|
||||
cur.Add(cur, big.NewInt(1))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rangeColumn 号段维度对应的表字段。
|
||||
// 只允许三码维度,避免把任意字段拼进 SQL。
|
||||
func rangeColumn(dimension string) (string, error) {
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
switch strings.TrimSpace(dimension) {
|
||||
case "iccid":
|
||||
return columns.Iccid, nil
|
||||
case "msisdn":
|
||||
return columns.Msisdn, nil
|
||||
case "imsi":
|
||||
return columns.Imsi, nil
|
||||
default:
|
||||
return "", gerror.New("连号维度只能选 ICCID、MSISDN 或 IMSI")
|
||||
}
|
||||
}
|
||||
|
||||
// queryCardsByRange 按号段取卡;命中超过上限时拒绝,避免静默截断。
|
||||
func queryCardsByRange(ctx context.Context, in *model.SimCardQueryExportParam) ([]*cardBrief, error) {
|
||||
column, err := rangeColumn(in.Dimension)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
result := make([]*cardBrief, 0)
|
||||
for start := 0; start < model.SimCardQueryMaxRows; start += chunkSize {
|
||||
list := make([]*cardBrief, 0, chunkSize)
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(
|
||||
columns.Id, columns.Iccid, columns.Msisdn, columns.Imsi, columns.Imei,
|
||||
columns.UsedFlowMb, columns.PackageTotalFlowMb, columns.BossCardStatus,
|
||||
columns.NetModel, columns.ChanKey, columns.BossActivateAt, columns.OutStockTime,
|
||||
columns.FlowPkgEndAt, columns.OutboundBatchNo, columns.InboundBatchNo,
|
||||
columns.CurrentPackageCode, columns.DeptId, columns.Remark,
|
||||
).
|
||||
Fields(cardflow.TotalFlowExpr(dao.TSimCardInfo.Table(), "")+" AS query_total_flow_mb").
|
||||
Where(column+" >= ?", strings.TrimSpace(in.StartNum)).
|
||||
Where(column+" <= ?", strings.TrimSpace(in.EndNum)).
|
||||
Where(column+" != ?", "").
|
||||
OrderAsc(columns.Id).
|
||||
Limit(chunkSize).Offset(start).
|
||||
Scan(&list)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试")
|
||||
}
|
||||
result = append(result, list...)
|
||||
if len(list) < chunkSize {
|
||||
break
|
||||
}
|
||||
if len(result) == model.SimCardQueryMaxRows {
|
||||
var extra int
|
||||
extra, err = handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).Where(column+" >= ?", strings.TrimSpace(in.StartNum)).
|
||||
Where(column+" <= ?", strings.TrimSpace(in.EndNum)).
|
||||
Where(column+" != ?", "").Count()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "校验号段查询上限失败")
|
||||
}
|
||||
if extra > model.SimCardQueryMaxRows {
|
||||
return nil, gerror.Newf("号段命中超过单次上限 %d 张卡,请缩小范围", model.SimCardQueryMaxRows)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type cardIndex map[string][]*cardBrief
|
||||
|
||||
func indexCards(cards []*cardBrief, field func(*cardBrief) string) cardIndex {
|
||||
index := make(cardIndex, len(cards))
|
||||
for _, card := range cards {
|
||||
if card != nil {
|
||||
if value := field(card); value != "" {
|
||||
index[value] = append(index[value], card)
|
||||
}
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// matchCard 对每个号码保留全部候选卡,再求交集;重复 IMSI 和跨字段冲突不按顺序择卡。
|
||||
func matchCard(line inputLine, byIccid, byMsisdn, byImsi cardIndex) (*cardBrief, string) {
|
||||
var candidates map[int64]*cardBrief
|
||||
for _, value := range line.Values {
|
||||
value = strings.TrimSpace(value)
|
||||
matches := make(map[int64]*cardBrief)
|
||||
for _, index := range []cardIndex{byIccid, byMsisdn, byImsi} {
|
||||
for _, card := range index[value] {
|
||||
matches[card.Id] = card
|
||||
}
|
||||
}
|
||||
if value == "" || len(matches) == 0 {
|
||||
return nil, "未查询到数据:" + value
|
||||
}
|
||||
if candidates == nil {
|
||||
candidates = matches
|
||||
continue
|
||||
}
|
||||
for id := range candidates {
|
||||
if _, ok := matches[id]; !ok {
|
||||
delete(candidates, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, "同一行内的号码未指向同一张卡"
|
||||
}
|
||||
if len(candidates) > 1 {
|
||||
return nil, "号码匹配到多张卡,请补充同一卡的 ICCID/MSISDN/IMSI 消除歧义"
|
||||
}
|
||||
for _, card := range candidates {
|
||||
return card, ""
|
||||
}
|
||||
return nil, rangeMissReason
|
||||
}
|
||||
|
||||
func inputFailure(line inputLine, reason string) string {
|
||||
return fmt.Sprintf("第 %d 行,原始输入 [%s]:%s", line.RowNo, strings.Join(line.Values, " | "), reason)
|
||||
}
|
||||
|
||||
func firstValue(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
// collectValues 汇总需要查库的号码值,去重后返回。
|
||||
func collectValues(lines []inputLine) []string {
|
||||
seen := make(map[string]struct{}, len(lines)*3)
|
||||
out := make([]string, 0, len(lines)*3)
|
||||
for _, line := range lines {
|
||||
for _, value := range line.Values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// queryCardsByValues 分批按号码值查卡。
|
||||
// 单值在三列之间 OR 匹配(iccid 唯一索引 + msisdn 唯一索引 + imsi 索引),
|
||||
// 因此用户不需要提前声明用的是 ICCID 还是三码。
|
||||
func queryCardsByValues(ctx context.Context, values []string) ([]*cardBrief, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
cardTable := dao.TSimCardInfo.Table()
|
||||
result := make([]*cardBrief, 0, len(values))
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
|
||||
for start := 0; start < len(values); start += chunkSize {
|
||||
end := start + chunkSize
|
||||
if end > len(values) {
|
||||
end = len(values)
|
||||
}
|
||||
chunk := values[start:end]
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(chunk)), ",")
|
||||
condition := fmt.Sprintf("(%s.%s IN (%s) OR %s.%s IN (%s) OR %s.%s IN (%s))",
|
||||
cardTable, columns.Iccid, placeholders,
|
||||
cardTable, columns.Msisdn, placeholders,
|
||||
cardTable, columns.Imsi, placeholders,
|
||||
)
|
||||
args := make([]interface{}, 0, len(chunk)*3)
|
||||
for repeat := 0; repeat < 3; repeat++ {
|
||||
for _, value := range chunk {
|
||||
args = append(args, value)
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]*cardBrief, 0, len(chunk))
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(
|
||||
columns.Id,
|
||||
columns.Iccid,
|
||||
columns.Msisdn,
|
||||
columns.Imsi,
|
||||
columns.Imei,
|
||||
columns.UsedFlowMb,
|
||||
columns.PackageTotalFlowMb,
|
||||
columns.BossCardStatus,
|
||||
columns.NetModel,
|
||||
columns.ChanKey,
|
||||
columns.BossActivateAt,
|
||||
columns.OutStockTime,
|
||||
columns.FlowPkgEndAt,
|
||||
columns.OutboundBatchNo,
|
||||
columns.InboundBatchNo,
|
||||
columns.CurrentPackageCode,
|
||||
columns.DeptId,
|
||||
columns.Remark,
|
||||
).
|
||||
Fields(cardflow.TotalFlowExpr(cardTable, "")+" AS query_total_flow_mb").
|
||||
Where(condition, args...).
|
||||
Scan(&list)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试")
|
||||
}
|
||||
for _, card := range list {
|
||||
if _, ok := seen[card.Id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[card.Id] = struct{}{}
|
||||
result = append(result, card)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// displayResolver 把 chan_key / package_code / dept_id 批量翻译成展示名,避免逐行子查询。
|
||||
type displayResolver struct {
|
||||
chanNames map[string]string
|
||||
packageNames map[string]string
|
||||
packageFlows map[string]float64
|
||||
deptNames map[string]string
|
||||
}
|
||||
|
||||
func newDisplayResolver(ctx context.Context, cards []*cardBrief) *displayResolver {
|
||||
r := &displayResolver{
|
||||
chanNames: make(map[string]string),
|
||||
packageNames: make(map[string]string),
|
||||
packageFlows: make(map[string]float64),
|
||||
deptNames: make(map[string]string),
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return r
|
||||
}
|
||||
chanKeys := make([]string, 0)
|
||||
packageCodes := make([]string, 0)
|
||||
deptIds := make([]int64, 0)
|
||||
seenChan := map[string]struct{}{}
|
||||
seenPackage := map[string]struct{}{}
|
||||
seenDept := map[int64]struct{}{}
|
||||
for _, card := range cards {
|
||||
if card.ChanKey != "" {
|
||||
if _, ok := seenChan[card.ChanKey]; !ok {
|
||||
seenChan[card.ChanKey] = struct{}{}
|
||||
chanKeys = append(chanKeys, card.ChanKey)
|
||||
}
|
||||
}
|
||||
if card.PackageCode != "" {
|
||||
if _, ok := seenPackage[card.PackageCode]; !ok {
|
||||
seenPackage[card.PackageCode] = struct{}{}
|
||||
packageCodes = append(packageCodes, card.PackageCode)
|
||||
}
|
||||
}
|
||||
if card.DeptId > 0 {
|
||||
if _, ok := seenDept[card.DeptId]; !ok {
|
||||
seenDept[card.DeptId] = struct{}{}
|
||||
deptIds = append(deptIds, card.DeptId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(chanKeys) > 0 {
|
||||
type chanRow struct {
|
||||
ChanKey string `orm:"chan_key"`
|
||||
Operator int `orm:"operator"`
|
||||
}
|
||||
rows := make([]*chanRow, 0, len(chanKeys))
|
||||
if err := dao.TSimChannels.Ctx(ctx).
|
||||
Fields("chan_key, operator").
|
||||
WhereIn("chan_key", chanKeys).
|
||||
Scan(&rows); err == nil {
|
||||
for _, row := range rows {
|
||||
switch row.Operator {
|
||||
case 1:
|
||||
r.chanNames[row.ChanKey] = "中国移动"
|
||||
case 2:
|
||||
r.chanNames[row.ChanKey] = "中国联通"
|
||||
case 3:
|
||||
r.chanNames[row.ChanKey] = "中国电信"
|
||||
default:
|
||||
r.chanNames[row.ChanKey] = "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(packageCodes) > 0 {
|
||||
type pkgRow struct {
|
||||
PackageCode string `orm:"package_code"`
|
||||
PackageName string `orm:"package_name"`
|
||||
FlowMb float64 `orm:"flow_mb"`
|
||||
}
|
||||
rows := make([]*pkgRow, 0, len(packageCodes))
|
||||
if err := dao.TSimFlowPackageInfo.Ctx(ctx).
|
||||
Fields("package_code, package_name, flow_mb").
|
||||
WhereIn("package_code", packageCodes).
|
||||
Scan(&rows); err == nil {
|
||||
for _, row := range rows {
|
||||
r.packageNames[row.PackageCode] = row.PackageName
|
||||
r.packageFlows[row.PackageCode] = row.FlowMb
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(deptIds) > 0 {
|
||||
type deptRow struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
}
|
||||
rows := make([]*deptRow, 0, len(deptIds))
|
||||
if err := dao.SysDept.Ctx(ctx).
|
||||
Fields("dept_id, dept_name").
|
||||
WhereIn("dept_id", deptIds).
|
||||
WhereNull("deleted_at").
|
||||
Scan(&rows); err == nil {
|
||||
for _, row := range rows {
|
||||
r.deptNames[fmt.Sprintf("%d", row.DeptId)] = row.DeptName
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// fill 把库里字段落到导出行。
|
||||
func (r *displayResolver) fill(row *model.SimCardBaseQueryRow, card *cardBrief) {
|
||||
row.Iccid = card.Iccid
|
||||
row.Msisdn = card.Msisdn
|
||||
row.Imsi = card.Imsi
|
||||
row.Imei = card.Imei
|
||||
row.UsedFlowMb = roundMb(card.UsedFlowMb)
|
||||
row.CardStatus = card.CardStatus
|
||||
row.NetModel = card.NetModel
|
||||
row.Operators = r.chanNames[card.ChanKey]
|
||||
row.OutboundBatch = card.OutboundBatch
|
||||
row.InboundBatch = card.InboundBatch
|
||||
row.PackageName = r.packageNames[card.PackageCode]
|
||||
row.DeptName = r.deptNames[fmt.Sprintf("%d", card.DeptId)]
|
||||
row.Remark = card.Remark
|
||||
row.ActivateAt = card.ActivateAt
|
||||
row.OutStockTime = card.OutStockTime
|
||||
row.ExpireAt = card.ExpireAt
|
||||
|
||||
// 复用 cardflow.TotalFlowExpr:当前主套餐(或卡冗余值)加生效中的单卡加油包。
|
||||
row.PackageTotalMb = roundMb(card.PackageTotalMb)
|
||||
remain := card.PackageTotalMb - card.UsedFlowMb
|
||||
if remain < 0 {
|
||||
remain = 0
|
||||
}
|
||||
row.RemainFlowMb = roundMb(remain)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestTemplateRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f, _, err := New().BaseTemplate(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
sheet := f.GetSheetName(0)
|
||||
if err := f.SetCellStr(sheet, "A2", "89860000000000000001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines, err := streamExcelInputs(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) != 1 || lines[0].Values[0] != "89860000000000000001" {
|
||||
t.Fatalf("unexpected lines: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcelInputLimit(t *testing.T) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
sheet := f.GetSheetName(0)
|
||||
if err := f.SetCellStr(sheet, "A1", "ICCID"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 2; i <= model.SimCardQueryMaxRows+2; i++ {
|
||||
cell, _ := excelize.CoordinatesToCellName(1, i)
|
||||
if err := f.SetCellStr(sheet, cell, "89860000000000000001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := streamExcelInputs(context.Background(), b); err == nil {
|
||||
t.Fatal("expected over-limit error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCardAmbiguity(t *testing.T) {
|
||||
cards := []*cardBrief{
|
||||
{Id: 1, Iccid: "89860001", Msisdn: "10001", Imsi: "46000001"},
|
||||
{Id: 2, Iccid: "89860002", Msisdn: "10002", Imsi: "46000001"},
|
||||
}
|
||||
a := indexCards(cards, func(c *cardBrief) string { return c.Iccid })
|
||||
b := indexCards(cards, func(c *cardBrief) string { return c.Msisdn })
|
||||
c := indexCards(cards, func(c *cardBrief) string { return c.Imsi })
|
||||
if card, _ := matchCard(inputLine{Values: []string{"46000001"}}, a, b, c); card != nil {
|
||||
t.Fatal("ambiguous IMSI must fail")
|
||||
}
|
||||
if card, reason := matchCard(inputLine{Values: []string{"89860002", "46000001"}}, a, b, c); card == nil || card.Id != 2 {
|
||||
t.Fatalf("expected second card, got %+v %s", card, reason)
|
||||
}
|
||||
if card, _ := matchCard(inputLine{Values: []string{"89860001", "10002"}}, a, b, c); card != nil {
|
||||
t.Fatal("mixed card values must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeValidation(t *testing.T) {
|
||||
for _, pair := range [][3]string{{"1", "22", "iccid"}, {"aa", "bb", "iccid"}, {"22", "11", "iccid"}, {"11", "22", "imei"}} {
|
||||
if parseRangeInputs(&model.SimCardQueryExportParam{StartNum: pair[0], EndNum: pair[1], Dimension: pair[2]}) == nil {
|
||||
t.Fatalf("expected error: %v", pair)
|
||||
}
|
||||
}
|
||||
if err := parseRangeInputs(&model.SimCardQueryExportParam{StartNum: "0011", EndNum: "0022", Dimension: "iccid"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSafeIccid(t *testing.T) {
|
||||
ok := []string{"89860000000000000001", "8986abc001", "ABC123"}
|
||||
for _, v := range ok {
|
||||
if !isSafeIccid(v) {
|
||||
t.Fatalf("expected safe: %s", v)
|
||||
}
|
||||
}
|
||||
// 单引号、空格、分号、括号都必须拒绝,否则拼进 SQL 会破坏语句结构。
|
||||
bad := []string{"", "8986'001", "8986 001", "8986;001", "8986)001", strings.Repeat("1", 33)}
|
||||
for _, v := range bad {
|
||||
if isSafeIccid(v) {
|
||||
t.Fatalf("expected unsafe: %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMonthValuesThreeStates(t *testing.T) {
|
||||
months := []string{"2026-09", "2026-08", "2026-07"}
|
||||
|
||||
// 查询成功:9 月有量、8 月记录为 0、7 月无记录 → 数值 / 0 / "-"
|
||||
ok := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{"2026-09": 12.3456, "2026-08": 0},
|
||||
}
|
||||
got := formatMonthValues(ok, months)
|
||||
if got[0] != 12.346 {
|
||||
t.Fatalf("9 月应导出数值,实际 %v", got[0])
|
||||
}
|
||||
if got[1] != float64(0) {
|
||||
t.Fatalf("8 月零用量应导出 0,实际 %v", got[1])
|
||||
}
|
||||
if got[2] != usageNoDataText {
|
||||
t.Fatalf("7 月无记录应导出 %q,实际 %v", usageNoDataText, got[2])
|
||||
}
|
||||
|
||||
// 整卡查询失败:月份列一律留空(保证该列是纯数值),原因由「失败原因」列承载
|
||||
failed := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{"2026-09": 99},
|
||||
UsageQueryFailed: true,
|
||||
}
|
||||
got = formatMonthValues(failed, months)
|
||||
for i, v := range got {
|
||||
if v != "" {
|
||||
t.Fatalf("第 %d 列应留空,实际 %v", i+1, v)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(usageQueryFailedNote, "查询失败") {
|
||||
t.Fatalf("失败原因文案应说明查询失败:%s", usageQueryFailedNote)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMonthValuesPartialHistory 覆盖「平台只同步了几个月」和「新卡没历史」两种常见情况:
|
||||
// 这类卡不是查询失败,缺的月份必须显示 "-",不能整行留空或判失败。
|
||||
func TestFormatMonthValuesPartialHistory(t *testing.T) {
|
||||
months := []string{"2026-09", "2026-08", "2026-07", "2026-06", "2026-05", "2026-04"}
|
||||
|
||||
// 只有最近 3 个月有数据(新卡 / 平台同步只跑了 3 个月)
|
||||
partial := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{
|
||||
"2026-09": 120.5,
|
||||
"2026-08": 98,
|
||||
"2026-07": 0,
|
||||
},
|
||||
}
|
||||
got := formatMonthValues(partial, months)
|
||||
if len(got) != len(months) {
|
||||
t.Fatalf("列数应与月份数一致,实际 %d", len(got))
|
||||
}
|
||||
if got[0] != 120.5 {
|
||||
t.Fatalf("有数据的月份应导出数值,实际 %v", got[0])
|
||||
}
|
||||
if got[2] != float64(0) {
|
||||
t.Fatalf("有记录但用量为 0 应导出 0,实际 %v", got[2])
|
||||
}
|
||||
for i := 3; i < len(got); i++ {
|
||||
if got[i] != usageNoDataText {
|
||||
t.Fatalf("第 %d 列无记录应为 %q,实际 %v", i+1, usageNoDataText, got[i])
|
||||
}
|
||||
}
|
||||
// 部分缺失不能被当成查询失败
|
||||
if partial.UsageQueryFailed {
|
||||
t.Fatal("缺月份不等于查询失败")
|
||||
}
|
||||
|
||||
// 全新卡:12 个月一条记录都没有 → 全部 "-",仍然算查询成功
|
||||
brandNew := &model.SimCardUsageHistoryRow{MonthValues: map[string]float64{}}
|
||||
got = formatMonthValues(brandNew, months)
|
||||
for i, v := range got {
|
||||
if v != usageNoDataText {
|
||||
t.Fatalf("新卡第 %d 列应为 %q,实际 %v", i+1, usageNoDataText, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRangeRowsKeepsOneRowPerNumber 号段里只有部分号码可见时,
|
||||
// 导出必须仍然每个号码一行:可见的出行数据,不可见的出「未查询到数据」,
|
||||
// 总条数等于号段大小,客户才能按号对账。
|
||||
func TestBuildRangeRowsKeepsOneRowPerNumber(t *testing.T) {
|
||||
in := &model.SimCardQueryExportParam{
|
||||
Dimension: "iccid",
|
||||
StartNum: "89860000000000000001",
|
||||
EndNum: "89860000000000000010",
|
||||
}
|
||||
// 10 个号里只有第 3、5、8 个在可见范围内
|
||||
cards := []*cardBrief{
|
||||
{Id: 1, Iccid: "89860000000000000003"},
|
||||
{Id: 2, Iccid: "89860000000000000005"},
|
||||
{Id: 3, Iccid: "89860000000000000008"},
|
||||
}
|
||||
stats := &resolveStats{}
|
||||
rows := buildRangeRows(context.Background(), in, cards, stats)
|
||||
|
||||
if len(rows) != 10 {
|
||||
t.Fatalf("应输出 10 行(与号段大小一致),实际 %d", len(rows))
|
||||
}
|
||||
hit, miss := 0, 0
|
||||
seen := make(map[string]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
if _, dup := seen[row.Iccid]; dup {
|
||||
t.Fatalf("号码重复输出:%s", row.Iccid)
|
||||
}
|
||||
seen[row.Iccid] = struct{}{}
|
||||
switch row.QueryResult {
|
||||
case queryResultHit:
|
||||
hit++
|
||||
case queryResultMiss:
|
||||
miss++
|
||||
// 不能透露是因为权限查不到
|
||||
if row.FailReason != rangeMissReason {
|
||||
t.Fatalf("失败原因应为 %q,实际 %q", rangeMissReason, row.FailReason)
|
||||
}
|
||||
if strings.Contains(row.FailReason, "权限") {
|
||||
t.Fatalf("失败原因不能提及权限:%s", row.FailReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
if hit != 3 || miss != 7 {
|
||||
t.Fatalf("成功/失败应为 3/7,实际 %d/%d", hit, miss)
|
||||
}
|
||||
if stats.hit != 3 || stats.miss != 7 {
|
||||
t.Fatalf("统计应为 3/7,实际 %d/%d", stats.hit, stats.miss)
|
||||
}
|
||||
// 首尾号码要对得上,且保持等长(前导零不丢)
|
||||
if _, ok := seen["89860000000000000001"]; !ok {
|
||||
t.Fatal("缺少起始号码")
|
||||
}
|
||||
if _, ok := seen["89860000000000000010"]; !ok {
|
||||
t.Fatal("缺少结束号码")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeSizeAndLimit(t *testing.T) {
|
||||
in := &model.SimCardQueryExportParam{StartNum: "89860000000000000001", EndNum: "89860000000000000010"}
|
||||
size, err := rangeSize(in)
|
||||
if err != nil || size != 10 {
|
||||
t.Fatalf("号段大小应为 10,实际 %d err=%v", size, err)
|
||||
}
|
||||
// 超过单次上限的号段要在提交阶段直接拒绝,否则无法完整展开
|
||||
big := &model.SimCardQueryExportParam{
|
||||
Dimension: "iccid",
|
||||
StartNum: "89860000000000000001",
|
||||
EndNum: "89860000000000099999",
|
||||
}
|
||||
if err := parseRangeInputs(big); err == nil {
|
||||
t.Fatal("超限号段应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthsAndStats(t *testing.T) {
|
||||
months := LastCompleteMonths(time.Date(2026, 10, 23, 14, 0, 0, 0, time.FixedZone("CST", 8*3600)), 12)
|
||||
if len(months) != 12 || months[0] != "2026-09" || months[11] != "2025-10" {
|
||||
t.Fatalf("unexpected months: %v", months)
|
||||
}
|
||||
stats := &resolveStats{}
|
||||
stats.add(true)
|
||||
if result, summary := stats.resultSummary(1); result != consts.BatchRunResultSuccess || !strings.Contains(summary, "成功 1 条") {
|
||||
t.Fatalf("bad success: %d %s", result, summary)
|
||||
}
|
||||
stats.add(false)
|
||||
if result, _ := stats.resultSummary(2); result != consts.BatchRunResultPartialSuccess {
|
||||
t.Fatal("expected partial success")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/tdengine"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"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"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// usageHistoryMonths 近一年月流水的月份个数(不含当月)。
|
||||
// 例:当前 2026-10,导出 2026-09 ~ 2025-10 共 12 个月。
|
||||
const usageHistoryMonths = 12
|
||||
|
||||
// usageHistoryTDEngineChunk 单次向 TDengine 查询的 ICCID 条数。
|
||||
// 父表按 iccid 标签过滤会剪枝到对应子表,500 条一批可把单次查询控制在秒级。
|
||||
const usageHistoryTDEngineChunk = 500
|
||||
|
||||
// dept 模式没有输入号码,靠公司范围圈卡,必须设上限,否则顶级账号一次拖全库。
|
||||
// deptExportMaxRows 公司模式(含下级)单次导出上限。
|
||||
//
|
||||
// 与号码模式的 20000 分开取值:号码模式受「用户手上有多少号码」约束,
|
||||
// 公司模式是整批圈卡,按 2 万设限只能靠不断切分下级公司,反而更容易出错。
|
||||
//
|
||||
// 取值依据(实测,见 export_cost_test.go):5 万行写 Excel 约 3.2s、文件 4.5MB,
|
||||
// 远低于导出中心 10 分钟超时 / 100MB 上限;真正的约束是当前实现会把整批行
|
||||
// 先构造成内存切片再分页写出,5 万行内存占用约 50MB 量级,10 万行开始偏高。
|
||||
// 若要支持 10 万以上,需要把公司模式改成「按页直接查库」的流式导出。
|
||||
const deptExportMaxRows = 50000
|
||||
|
||||
var usageHistoryFixedFields = []string{"iccid", "msisdn", "imsi", "outStockTime", "expireAt"}
|
||||
|
||||
// 月用量列的取值口径:
|
||||
//
|
||||
// 查询失败(服务异常)→ 留空,原因写进「失败原因」列
|
||||
// 查到了但该月无记录 → "-"
|
||||
// 该月有记录且用量为 0 → 0
|
||||
//
|
||||
// 失败格留空而不是写文字:这样整列是「数字 + 空 + 横杠」,Excel 求和/透视不会被文本干扰;
|
||||
// 同时「失败原因」列会写明该卡流量查询失败,信息不会丢。
|
||||
const usageNoDataText = "-"
|
||||
|
||||
// usageQueryFailedNote 流量查询失败时写入「失败原因」列的说明。
|
||||
const usageQueryFailedNote = "该卡近12个月流量数据本次查询失败,月份列留空,可稍后重新导出"
|
||||
|
||||
// cardUsageResult 单张卡的历史用量查询结果。
|
||||
type cardUsageResult struct {
|
||||
// Months 键为 "YYYY-MM";缺失表示查询成功但该月无记录。
|
||||
Months map[string]float64
|
||||
// Failed 表示该卡所属批次的流量查询失败,数据不可信。
|
||||
Failed bool
|
||||
}
|
||||
|
||||
// ExportUsageHistory 月历史用量查询导出。
|
||||
func (s *Service) ExportUsageHistory(ctx context.Context, in *model.SimCardUsageHistoryExportParam, input ExcelInput) (*exportcenter.SubmitResult, error) {
|
||||
months := LastCompleteMonths(time.Now(), usageHistoryMonths)
|
||||
columns := resolveUsageHistoryColumns(in, months)
|
||||
|
||||
// 公司模式的卡列表必须在后台任务中解析,避免提交请求同步读取最多 2 万张卡。
|
||||
// 号码模式仍在提交阶段校验输入格式,实际查库统一延迟到导出 runner。
|
||||
var lines []inputLine
|
||||
var err error
|
||||
if in.Mode != model.SimCardQueryModeDept {
|
||||
lines, err = parseInputs(ctx, &in.SimCardQueryExportParam, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
stats := &resolveStats{}
|
||||
once := &sync.Once{}
|
||||
var cached []*model.SimCardUsageHistoryRow
|
||||
var cacheErr error
|
||||
resolve := func(ctx context.Context) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
once.Do(func() {
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeDept:
|
||||
cached, cacheErr = s.resolveDeptRows(ctx, in, months, stats)
|
||||
case model.SimCardQueryModeRange:
|
||||
cached, cacheErr = s.resolveUsageRangeRows(ctx, &in.SimCardQueryExportParam, months, stats)
|
||||
default:
|
||||
cached, cacheErr = s.resolveUsageHistoryRows(ctx, lines, months, stats)
|
||||
}
|
||||
})
|
||||
return cached, cacheErr
|
||||
}
|
||||
probeTotal := len(lines)
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeRange:
|
||||
// 号段模式:号段包含多少个号码就是预计条数,与是否查到无关。
|
||||
if size, sizeErr := rangeSize(&in.SimCardQueryExportParam); sizeErr == nil {
|
||||
probeTotal = int(size)
|
||||
} else {
|
||||
probeTotal = -1
|
||||
}
|
||||
case model.SimCardQueryModeDept:
|
||||
// 公司卡数只在后台 runner 中查询;提交探针不扫描业务表,条数未知。
|
||||
probeTotal = -1
|
||||
}
|
||||
|
||||
return exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardUsageHistory",
|
||||
BizName: "月历史用量查询",
|
||||
PermCode: "cpm:iot:tSimCardUsageHistory:view",
|
||||
SheetName: "月历史用量",
|
||||
QueryParam: usageHistorySnapshot(in, months, probeTotal),
|
||||
Columns: columns,
|
||||
RowFields: append(append([]string{}, usageHistoryFixedFields...), append(months, queryResultField, failReasonField)...),
|
||||
Tags: func() ([]string, error) {
|
||||
return usageHistoryTags(months), nil
|
||||
},
|
||||
ToRow: func(v interface{}) []interface{} {
|
||||
row, ok := v.(*model.SimCardUsageHistoryRow)
|
||||
if !ok || row == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]interface{}, 0, len(usageHistoryFixedFields)+len(months)+2)
|
||||
values = append(values, row.Iccid, row.Msisdn, row.Imsi, formatTime(row.OutStockTime), formatTime(row.ExpireAt))
|
||||
values = append(values, formatMonthValues(row, months)...)
|
||||
return append(values, row.QueryResult, row.FailReason)
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) {
|
||||
if pageSize <= 1 && page <= 1 {
|
||||
// probeTotal 为 -1 表示提交阶段无法确定条数(按公司模式),放行由后台实际取数。
|
||||
return nil, probeTotal, nil
|
||||
}
|
||||
rows, err := resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total := len(rows)
|
||||
start := (page - 1) * pageSize
|
||||
if start >= total {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
out := make([]interface{}, 0, end-start)
|
||||
for _, row := range rows[start:end] {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, total, nil
|
||||
},
|
||||
Result: stats.resultSummary,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveUsageHistoryRows 号码模式:解析输入 → 查卡 → 查近 12 个月用量。
|
||||
func (s *Service) resolveUsageHistoryRows(ctx context.Context, lines []inputLine, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
values := collectValues(lines)
|
||||
cards, err := queryCardsByValues(ctx, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byIccid := indexCards(cards, func(card *cardBrief) string { return card.Iccid })
|
||||
byMsisdn := indexCards(cards, func(card *cardBrief) string { return card.Msisdn })
|
||||
byImsi := indexCards(cards, func(card *cardBrief) string { return card.Imsi })
|
||||
iccids := make([]string, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card.Iccid != "" {
|
||||
iccids = append(iccids, card.Iccid)
|
||||
}
|
||||
}
|
||||
|
||||
usage, err := queryMonthlyUsage(ctx, iccids, months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]*model.SimCardUsageHistoryRow, 0, len(lines))
|
||||
// 同一张卡重复出现只导出一次(同号码重复填写,或 ICCID 与其 MSISDN 各写一行)。
|
||||
exported := make(map[int64]struct{}, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &model.SimCardUsageHistoryRow{MonthValues: map[string]float64{}}
|
||||
card, reason := matchCard(line, byIccid, byMsisdn, byImsi)
|
||||
if card == nil {
|
||||
row.Iccid = firstValue(line.Values)
|
||||
row.QueryResult = queryResultMiss
|
||||
row.FailReason = inputFailure(line, reason)
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
if _, ok := exported[card.Id]; ok {
|
||||
stats.addDup()
|
||||
continue
|
||||
}
|
||||
exported[card.Id] = struct{}{}
|
||||
rows = append(rows, newUsageRow(card, usage))
|
||||
stats.add(true)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// resolveUsageRangeRows 号段模式:按起止号段取卡,再补近 12 个月用量。
|
||||
func (s *Service) resolveUsageRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
cards, err := queryCardsByRange(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iccids := make([]string, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card.Iccid != "" {
|
||||
iccids = append(iccids, card.Iccid)
|
||||
}
|
||||
}
|
||||
usage, err := queryMonthlyUsage(ctx, iccids, months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 按号段逐个号码输出:命中出行数据,未命中出「未查询到数据」的失败行,
|
||||
// 保证导出条数与号段大小一致(号段里可能一部分可见、一部分不可见)。
|
||||
dimension := strings.TrimSpace(in.Dimension)
|
||||
index := rangeCardIndex(cards, dimension)
|
||||
size, sizeErr := rangeSize(in)
|
||||
if sizeErr != nil {
|
||||
size = int64(len(cards))
|
||||
}
|
||||
numbers := rangeNumbers(in, size)
|
||||
rows := make([]*model.SimCardUsageHistoryRow, 0, len(numbers))
|
||||
for _, number := range numbers {
|
||||
if card, ok := index[number]; ok {
|
||||
rows = append(rows, newUsageRow(card, usage))
|
||||
stats.add(true)
|
||||
continue
|
||||
}
|
||||
row := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{},
|
||||
QueryResult: queryResultMiss,
|
||||
FailReason: rangeMissReason,
|
||||
}
|
||||
switch dimension {
|
||||
case "msisdn":
|
||||
row.Msisdn = number
|
||||
case "imsi":
|
||||
row.Imsi = number
|
||||
default:
|
||||
row.Iccid = number
|
||||
}
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// resolveDeptRows 在后台按提交人的数据权限重新解析公司(含下级)卡列表。
|
||||
func (s *Service) resolveDeptRows(ctx context.Context, in *model.SimCardUsageHistoryExportParam, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
iccids, err := s.cardsByDept(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards, err := queryCardsByIccids(ctx, iccids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usage, err := queryMonthlyUsage(ctx, iccids, months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]*model.SimCardUsageHistoryRow, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
stats.add(true)
|
||||
rows = append(rows, newUsageRow(card, usage))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// cardsByDept 取公司(含全部下级公司)名下的 ICCID 列表。
|
||||
//
|
||||
// 选中公司即包含下级,与 DeptOptions 的卡数统计口径一致:
|
||||
// 顶级公司自身通常不挂卡,只查本级会得到 0 条。
|
||||
func (s *Service) cardsByDept(ctx context.Context, in *model.SimCardUsageHistoryExportParam) ([]string, error) {
|
||||
if in.DeptId <= 0 {
|
||||
return nil, gerror.New("请选择所属公司")
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
base := func() *gdb.Model {
|
||||
mod := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).WhereIn(columns.DeptId, deptSubtreeIDs(ctx, in.DeptId))
|
||||
if start := strings.TrimSpace(in.ExpireStartTime); start != "" {
|
||||
mod = mod.WhereGTE(columns.FlowPkgEndAt, start)
|
||||
}
|
||||
if end := strings.TrimSpace(in.ExpireEndTime); end != "" {
|
||||
mod = mod.WhereLTE(columns.FlowPkgEndAt, end)
|
||||
}
|
||||
return mod
|
||||
}
|
||||
|
||||
count, err := base().Fields(columns.Iccid).Count()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "统计公司名下卡数量失败")
|
||||
}
|
||||
if count <= 0 {
|
||||
return nil, gerror.New("该公司(含下级)下没有符合条件可导出的卡")
|
||||
}
|
||||
if count > deptExportMaxRows {
|
||||
return nil, gerror.Newf(
|
||||
"该公司(含下级)共 %d 张卡,超过单次导出上限 %d 张,请改选下级公司或改用号码导入",
|
||||
count, deptExportMaxRows,
|
||||
)
|
||||
}
|
||||
values, err := base().Fields(columns.Iccid).OrderAsc(columns.Id).Limit(deptExportMaxRows).Array()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询公司名下卡失败")
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if iccid := strings.TrimSpace(value.String()); iccid != "" {
|
||||
out = append(out, iccid)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// queryCardsByIccids 按 ICCID 批量取导出所需字段。
|
||||
func queryCardsByIccids(ctx context.Context, iccids []string) ([]*cardBrief, error) {
|
||||
if len(iccids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
out := make([]*cardBrief, 0, len(iccids))
|
||||
for start := 0; start < len(iccids); start += chunkSize {
|
||||
end := start + chunkSize
|
||||
if end > len(iccids) {
|
||||
end = len(iccids)
|
||||
}
|
||||
list := make([]*cardBrief, 0, end-start)
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(
|
||||
columns.Id, columns.Iccid, columns.Msisdn, columns.Imsi,
|
||||
columns.OutStockTime, columns.FlowPkgEndAt,
|
||||
).
|
||||
WhereIn(columns.Iccid, iccids[start:end]).
|
||||
Scan(&list)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试")
|
||||
}
|
||||
out = append(out, list...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// queryMonthlyUsage 从 TDengine 取多卡的多月用量。
|
||||
//
|
||||
// 每张卡带上自己的查询状态,三种情况必须在导出里可区分:
|
||||
// - Failed=true:该批查询异常,月份格写「查询失败」
|
||||
// - Months 里缺该月:查询成功但没有该月记录,月份格写 "-"
|
||||
// - Months 里有该月且为 0:查询成功、零用量,月份格写 0
|
||||
//
|
||||
// 部分批次失败不拖垮整单,只有全部批次失败(服务整体不可用)才返回错误。
|
||||
func queryMonthlyUsage(ctx context.Context, iccids []string, months []string) (map[string]*cardUsageResult, error) {
|
||||
result := make(map[string]*cardUsageResult, len(iccids))
|
||||
if len(iccids) == 0 || len(months) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if !tdengine.Enabled(ctx) {
|
||||
g.Log().Warning(ctx, "[simcardquery] 流量库未启用")
|
||||
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
|
||||
}
|
||||
cfg := tdengine.GetConfig(ctx)
|
||||
if err := validateTDEngineNames(cfg.DbName, cfg.MonthlyStable); err != nil {
|
||||
g.Log().Errorf(ctx, "[simcardquery] 流量库配置异常: %v", err)
|
||||
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
|
||||
}
|
||||
|
||||
start, end, err := monthRange(months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
table := fmt.Sprintf("%s.%s", cfg.DbName, cfg.MonthlyStable)
|
||||
// 用秒级时间字面量:月表写入点是当月最后一天 23:59:59,秒级精度已能覆盖。
|
||||
startText := start.Format("2006-01-02 15:04:05")
|
||||
endText := end.Format("2006-01-02 15:04:05")
|
||||
|
||||
batches, failedBatches := 0, 0
|
||||
for chunkStart := 0; chunkStart < len(iccids); chunkStart += usageHistoryTDEngineChunk {
|
||||
chunkEnd := chunkStart + usageHistoryTDEngineChunk
|
||||
if chunkEnd > len(iccids) {
|
||||
chunkEnd = len(iccids)
|
||||
}
|
||||
chunk := iccids[chunkStart:chunkEnd]
|
||||
// TDengine 驱动不会给字符串参数补引号,用 ? 绑定会让时间条件变成
|
||||
// `ts >= 2025-10-01 00:00:00` 这种非法表达式(报 syntax error)。
|
||||
// 因此这里把 ICCID 与时间都拼成带引号的字面量,
|
||||
// ICCID 必须经过字符白名单校验,杜绝拼接注入。
|
||||
quoted := make([]string, 0, len(chunk))
|
||||
safe := make([]string, 0, len(chunk))
|
||||
for _, iccid := range chunk {
|
||||
if !isSafeIccid(iccid) {
|
||||
continue
|
||||
}
|
||||
safe = append(safe, iccid)
|
||||
quoted = append(quoted, "'"+iccid+"'")
|
||||
}
|
||||
if len(quoted) == 0 {
|
||||
continue
|
||||
}
|
||||
batches++
|
||||
// 先按「查询成功、暂无记录」建条目;一旦本批出错再整体标记为查询失败。
|
||||
for _, iccid := range safe {
|
||||
if result[iccid] == nil {
|
||||
result[iccid] = &cardUsageResult{Months: make(map[string]float64, len(months))}
|
||||
}
|
||||
}
|
||||
markFailedBatch := func() {
|
||||
for _, iccid := range safe {
|
||||
if entry := result[iccid]; entry != nil {
|
||||
entry.Failed = true
|
||||
entry.Months = map[string]float64{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sql := fmt.Sprintf(
|
||||
"SELECT iccid, ts, usage_mb FROM %s WHERE iccid IN (%s) AND ts >= '%s' AND ts <= '%s'",
|
||||
table, strings.Join(quoted, ","), startText, endText,
|
||||
)
|
||||
rows, queryErr := tdengine.Query(ctx, sql)
|
||||
if queryErr != nil {
|
||||
// 单批失败不拖垮整个导出:本批卡片标记为「查询失败」,其余批次照常。
|
||||
failedBatches++
|
||||
markFailedBatch()
|
||||
g.Log().Errorf(ctx, "[simcardquery] 查询月流量失败 cards=%d range=%s~%s err=%v",
|
||||
len(chunk), startText, endText, queryErr)
|
||||
continue
|
||||
}
|
||||
latest := make(map[string]map[string]time.Time, len(chunk))
|
||||
scanFailed := false
|
||||
for rows.Next() {
|
||||
var iccid string
|
||||
var ts time.Time
|
||||
var usage float64
|
||||
if scanErr := rows.Scan(&iccid, &ts, &usage); scanErr != nil {
|
||||
scanFailed = true
|
||||
g.Log().Errorf(ctx, "[simcardquery] 解析月流量结果失败 err=%v", scanErr)
|
||||
break
|
||||
}
|
||||
key := ts.Format("2006-01")
|
||||
if latest[iccid] == nil {
|
||||
latest[iccid] = make(map[string]time.Time, len(months))
|
||||
}
|
||||
previous, exists := latest[iccid][key]
|
||||
if exists && !ts.After(previous) {
|
||||
continue
|
||||
}
|
||||
entry := result[iccid]
|
||||
if entry == nil {
|
||||
entry = &cardUsageResult{Months: make(map[string]float64, len(months))}
|
||||
result[iccid] = entry
|
||||
}
|
||||
latest[iccid][key] = ts
|
||||
entry.Months[key] = usage
|
||||
}
|
||||
if !scanFailed {
|
||||
if rowsErr := rows.Err(); rowsErr != nil {
|
||||
scanFailed = true
|
||||
g.Log().Errorf(ctx, "[simcardquery] 遍历月流量结果失败 err=%v", rowsErr)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if scanFailed {
|
||||
failedBatches++
|
||||
markFailedBatch()
|
||||
}
|
||||
}
|
||||
// 全部批次都失败才判定服务不可用。
|
||||
if batches > 0 && failedBatches == batches {
|
||||
g.Log().Errorf(ctx, "[simcardquery] 流量服务全部批次查询失败 batches=%d", batches)
|
||||
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
|
||||
}
|
||||
if failedBatches > 0 {
|
||||
g.Log().Warningf(ctx, "[simcardquery] 流量服务部分批次失败 batches=%d failed=%d", batches, failedBatches)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// formatMonthValues 把某行的月度用量格式化成导出的单元格值,三种状态严格区分:
|
||||
//
|
||||
// 查询失败(服务异常)→ 留空(原因见「失败原因」列)
|
||||
// 查询正常、该月无记录 → "-"
|
||||
// 查询正常、有记录 → 数值(零用量即 0)
|
||||
func formatMonthValues(row *model.SimCardUsageHistoryRow, months []string) []interface{} {
|
||||
values := make([]interface{}, 0, len(months))
|
||||
for _, month := range months {
|
||||
if row.UsageQueryFailed || row.QueryResult == queryResultMiss {
|
||||
// 整卡流量查询失败,或这张卡本身没查到:月份列留空,
|
||||
// 原因由「失败原因」列说明,保证月份列始终是「数字 + 半角横杠 + 空」。
|
||||
values = append(values, "")
|
||||
continue
|
||||
}
|
||||
usage, exists := row.MonthValues[month]
|
||||
if !exists {
|
||||
values = append(values, usageNoDataText)
|
||||
continue
|
||||
}
|
||||
values = append(values, roundMb(usage))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// newUsageRow 组装月历史导出行,并统一处理「流量查询失败」的说明。
|
||||
func newUsageRow(card *cardBrief, usage map[string]*cardUsageResult) *model.SimCardUsageHistoryRow {
|
||||
row := &model.SimCardUsageHistoryRow{
|
||||
Iccid: card.Iccid,
|
||||
Msisdn: card.Msisdn,
|
||||
Imsi: card.Imsi,
|
||||
OutStockTime: card.OutStockTime,
|
||||
ExpireAt: card.ExpireAt,
|
||||
MonthValues: usageMonths(usage, card.Iccid),
|
||||
UsageQueryFailed: usageFailed(usage, card.Iccid),
|
||||
QueryResult: queryResultHit,
|
||||
}
|
||||
if row.UsageQueryFailed {
|
||||
row.FailReason = usageQueryFailedNote
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// usageMonths 取某卡的月度用量;该卡查询失败或没有条目时返回空 map。
|
||||
func usageMonths(usage map[string]*cardUsageResult, iccid string) map[string]float64 {
|
||||
if entry := usage[iccid]; entry != nil {
|
||||
return entry.Months
|
||||
}
|
||||
return map[string]float64{}
|
||||
}
|
||||
|
||||
// usageFailed 该卡的流量数据是否查询失败。
|
||||
func usageFailed(usage map[string]*cardUsageResult, iccid string) bool {
|
||||
if entry := usage[iccid]; entry != nil {
|
||||
return entry.Failed
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LastCompleteMonths 返回最近 N 个「已结束的月份」,按时间倒序(最近的在前)。
|
||||
// 当月不计入:当月数据仍在累积,导出口径会和下个月对不上。
|
||||
func LastCompleteMonths(now time.Time, count int) []string {
|
||||
months := make([]string, 0, count)
|
||||
cursor := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -1, 0)
|
||||
for i := 0; i < count; i++ {
|
||||
months = append(months, cursor.Format("2006-01"))
|
||||
cursor = cursor.AddDate(0, -1, 0)
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
// monthRange 把月份清单换算成查询时间范围(含首尾)。
|
||||
func monthRange(months []string) (time.Time, time.Time, error) {
|
||||
if len(months) == 0 {
|
||||
return time.Time{}, time.Time{}, gerror.New("月份清单为空")
|
||||
}
|
||||
parsed := make([]time.Time, 0, len(months))
|
||||
for _, month := range months {
|
||||
value, err := time.ParseInLocation("2006-01", month, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, gerror.Wrapf(err, "月份格式错误:%s", month)
|
||||
}
|
||||
parsed = append(parsed, value)
|
||||
}
|
||||
sort.Slice(parsed, func(i, j int) bool { return parsed[i].Before(parsed[j]) })
|
||||
start := parsed[0]
|
||||
end := parsed[len(parsed)-1].AddDate(0, 1, 0).Add(-time.Second)
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// isSafeIccid ICCID 拼进 SQL 字面量前的白名单校验。
|
||||
// ICCID 正常只含数字,个别批次可能带字母;除此之外一律拒绝,避免拼接注入。
|
||||
func isSafeIccid(value string) bool {
|
||||
if value == "" || len(value) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateTDEngineNames(database, stable string) error {
|
||||
|
||||
if database == "" || stable == "" {
|
||||
return gerror.New("流量库配置不完整")
|
||||
}
|
||||
for _, value := range []string{database, stable} {
|
||||
for _, r := range value {
|
||||
if !(r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||
return gerror.Newf("流量库标识非法:%s", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatTime(value *gtime.Time) string {
|
||||
if value == nil || value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format("Y-m-d H:i:s")
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func applyStockBusinessFilters(ctx context.Context, mod *gdb.Model, filter stock
|
||||
stockTable,
|
||||
stockColumns.ChanKey,
|
||||
channelColumns.TenantId,
|
||||
channelColumns.ChanName,
|
||||
channelDisplayNameColumn(ctx),
|
||||
channelColumns.ChanKey,
|
||||
),
|
||||
contexts.GetTenantId(ctx),
|
||||
@@ -144,12 +144,16 @@ func enrichStockListBusinessFields(ctx context.Context, list []*model.TSimCardSt
|
||||
|
||||
batchNos := make([]string, 0, len(list))
|
||||
chanKeys := make([]string, 0, len(list))
|
||||
deptIds := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
batchNos = appendUniqueNonBlank(batchNos, item.BatchNo)
|
||||
chanKeys = appendUniqueNonBlank(chanKeys, item.ChanKey)
|
||||
if item.CreatedDept > 0 {
|
||||
deptIds = append(deptIds, item.CreatedDept)
|
||||
}
|
||||
}
|
||||
|
||||
batchMap, err := loadStockBatchBusinessFields(ctx, batchNos)
|
||||
@@ -167,6 +171,10 @@ func enrichStockListBusinessFields(ctx context.Context, list []*model.TSimCardSt
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deptMap, err := loadStockDeptNames(ctx, deptIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
@@ -178,6 +186,7 @@ func enrichStockListBusinessFields(ctx context.Context, list []*model.TSimCardSt
|
||||
item.UpPkgCode = fields.UpPkgCode
|
||||
item.UpPkgName = fields.UpPkgName
|
||||
item.AgreementExpiryDate = fields.AgreementExpiryDate
|
||||
item.CreatedDeptName = deptMap[item.CreatedDept]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -419,7 +428,7 @@ func loadStockChannelNames(ctx context.Context, chanKeys []string) (map[string]s
|
||||
var rows []*channelRow
|
||||
columns := dao.TSimChannels.Columns()
|
||||
if err := dao.TSimChannels.Ctx(ctx).
|
||||
Fields(columns.ChanKey, columns.ChanName).
|
||||
Fields(columns.ChanKey, channelDisplayNameSQL(ctx, dao.TSimChannels.Table())+" AS chan_name").
|
||||
Where(columns.TenantId, contexts.GetTenantId(ctx)).
|
||||
WhereIn(columns.ChanKey, chanKeys).
|
||||
Scan(&rows); err != nil {
|
||||
@@ -433,6 +442,51 @@ func loadStockChannelNames(ctx context.Context, chanKeys []string) (map[string]s
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadStockDeptNames 批量补充创建部门名称(sys_dept.dept_id -> dept_name)。
|
||||
func loadStockDeptNames(ctx context.Context, deptIds []int64) (map[int64]string, error) {
|
||||
result := make(map[int64]string)
|
||||
deptIds = uniqueInt64(deptIds)
|
||||
if len(deptIds) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type deptRow struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
}
|
||||
var rows []*deptRow
|
||||
columns := dao.SysDept.Columns()
|
||||
mod := dao.SysDept.Ctx(ctx).
|
||||
Fields(columns.DeptId, columns.DeptName).
|
||||
WhereIn(columns.DeptId, deptIds)
|
||||
if tenantId := strings.TrimSpace(contexts.GetTenantId(ctx)); tenantId != "" {
|
||||
mod = mod.Where(columns.TenantId, tenantId)
|
||||
}
|
||||
if err := mod.Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "补充创建部门名称失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row != nil && row.DeptId > 0 {
|
||||
result[row.DeptId] = row.DeptName
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// uniqueInt64 去重并剔除非正的部门ID。
|
||||
func uniqueInt64(values []int64) []int64 {
|
||||
seen := make(map[int64]bool, len(values))
|
||||
result := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func loadStockUpstreamPackageNames(ctx context.Context, upPkgCodes []string) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
upPkgCodes = uniqueNonBlank(upPkgCodes)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
@@ -11,22 +11,20 @@ import (
|
||||
"fmt"
|
||||
"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/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"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"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"strings"
|
||||
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTFlowPackageConfig struct{}
|
||||
@@ -171,31 +169,32 @@ func (s *sIotTFlowPackageConfig) List(ctx context.Context, in *model.TFlowPackag
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出SIM流量池套餐配置表
|
||||
// Export 导出流量池套餐配置(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTFlowPackageConfig) Export(ctx context.Context, in *model.TFlowPackageConfigListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TFlowPackageConfigExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出SIM流量池套餐配置表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TFlowPackageConfigExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tFlowPackageConfig",
|
||||
BizName: "流量池套餐配置",
|
||||
PermCode: "cpm:iot:tFlowPackageConfig:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TFlowPackageConfigListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增SIM流量池套餐配置表
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"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"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"strings"
|
||||
|
||||
"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/os/gctx"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTOrder struct{}
|
||||
@@ -357,51 +354,38 @@ func orderItemView(item *entity.TOrderItem) *model.TOrderItemViewModel {
|
||||
return view
|
||||
}
|
||||
|
||||
// Export 导出订单(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTOrder) Export(ctx context.Context, in *model.TOrderListParam) (err error) {
|
||||
query := *in
|
||||
query.Page = 1
|
||||
query.PageSize = orderExportPageSize
|
||||
list := make([]*model.TOrderListModel, 0)
|
||||
totalCount := 0
|
||||
for {
|
||||
var (
|
||||
pageList []*model.TOrderListModel
|
||||
pageTotal int
|
||||
)
|
||||
if query.Perspective == orderPerspectiveSell {
|
||||
pageList, pageTotal, err = s.SellList(ctx, &query)
|
||||
} else {
|
||||
pageList, pageTotal, err = s.List(ctx, &query)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalCount = pageTotal
|
||||
list = append(list, pageList...)
|
||||
if len(pageList) == 0 || len(list) >= totalCount {
|
||||
break
|
||||
}
|
||||
query.Page++
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TOrderExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出订单主表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("筛选结果共%v行", totalCount)
|
||||
exports []model.TOrderExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tOrder",
|
||||
BizName: "订单",
|
||||
PermCode: "cpm:iot:tOrder:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TOrderListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
var list []*model.TOrderListModel
|
||||
var count int
|
||||
if query.Perspective == orderPerspectiveSell {
|
||||
list, count, err = s.SellList(ctx, &query)
|
||||
} else {
|
||||
list, count, err = s.List(ctx, &query)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 订单由业务流程生成,不允许后台手工修改。
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestNormalizeOrderItemProduct(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
orderType int
|
||||
want string
|
||||
}{
|
||||
{name: "renew", orderType: consts.OrderTypeCardRenew, want: "卡续费"},
|
||||
{name: "change", orderType: consts.OrderTypeCardChange, want: "卡变更"},
|
||||
{name: "subscribe", orderType: consts.OrderTypeCardSubscribe, want: "卡订购"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
view := &model.TOrderItemViewModel{
|
||||
ProductName: "余额充值",
|
||||
ProductSn: "BALANCE_RECHARGE",
|
||||
}
|
||||
normalizeOrderItemProduct(test.orderType, view)
|
||||
if view.ProductName != test.want || view.ProductSn != "" {
|
||||
t.Fatalf("got name=%q sn=%q, want name=%q and empty sn", view.ProductName, view.ProductSn, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
@@ -2,24 +2,22 @@ package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"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/util/gconv"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
excelize "github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,6 +32,16 @@ var cancellationExportHeaders = []interface{}{
|
||||
"入库批次", "出库批次", "划拨批次", "操作人", "备注",
|
||||
}
|
||||
|
||||
// cancellationExportFields 与 cancellationExportHeaders 一一对应(json tag),
|
||||
// 供「按页面显示列导出」在保留 ToRow 格式化的前提下按字段取列。
|
||||
var cancellationExportFields = []string{
|
||||
"cancelBatchNo", "cancelledAt", "iccid", "msisdn", "imsi", "imei",
|
||||
"chanKey", "chanName", "deptId", "deptName",
|
||||
"currentPackageCode", "packageName", "bossCardStatus",
|
||||
// 字段名必须与列表页列的 field 一致(操作人页面用 createdBy,显示的是 createdBySumma 摘要)
|
||||
"inboundBatchNo", "outboundBatchNo", "allocationBatchNo", "createdBy", "remark",
|
||||
}
|
||||
|
||||
type sIotTSimCardCancellation struct{}
|
||||
|
||||
func NewIotTSimCardCancellation() *sIotTSimCardCancellation {
|
||||
@@ -72,6 +80,7 @@ func (s *sIotTSimCardCancellation) List(ctx context.Context, in *model.TSimCardC
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// Export 导出销户卡信息(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimCardCancellation) Export(ctx context.Context, filter *model.TSimCardCancellationFilter) error {
|
||||
if filter == nil {
|
||||
filter = &model.TSimCardCancellationFilter{}
|
||||
@@ -80,77 +89,50 @@ func (s *sIotTSimCardCancellation) Export(ctx context.Context, filter *model.TSi
|
||||
return err
|
||||
}
|
||||
|
||||
total, err := s.filteredModel(ctx, filter).Count()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "统计销户卡导出数量失败")
|
||||
}
|
||||
if total == 0 {
|
||||
return gerror.New("暂无可导出数据")
|
||||
}
|
||||
if total > cancellationExportMaxRows {
|
||||
return gerror.Newf("导出数据超过 Excel 上限 %d 条,请缩小筛选范围", cancellationExportMaxRows)
|
||||
}
|
||||
|
||||
file := excelize.NewFile()
|
||||
defer file.Close()
|
||||
const sheetName = "销户卡信息"
|
||||
file.SetSheetName(file.GetSheetName(0), sheetName)
|
||||
stream, err := file.NewStreamWriter(sheetName)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "创建销户卡 Excel 流失败")
|
||||
}
|
||||
if err = stream.SetColWidth(1, len(cancellationExportHeaders), 22); err != nil {
|
||||
return gerror.Wrap(err, "设置销户卡 Excel 列宽失败")
|
||||
}
|
||||
if err = stream.SetColWidth(3, 6, 28); err != nil {
|
||||
return gerror.Wrap(err, "设置销户卡号 Excel 列宽失败")
|
||||
}
|
||||
headerStyle, err := file.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "创建销户卡 Excel 表头样式失败")
|
||||
}
|
||||
if err = stream.SetRow("A1", cancellationExportHeaders, excelize.RowOpts{StyleID: headerStyle}); err != nil {
|
||||
return gerror.Wrap(err, "写入销户卡 Excel 表头失败")
|
||||
}
|
||||
|
||||
rowNumber := 2
|
||||
for offset := 0; offset < total; offset += cancellationExportBatchSize {
|
||||
var rows []*model.TSimCardCancellationListModel
|
||||
query := s.selectModel(ctx, filter).
|
||||
OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().CancelledAt).
|
||||
OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().Id).
|
||||
Limit(offset, cancellationExportBatchSize)
|
||||
if err = query.Scan(&rows); err != nil {
|
||||
return gerror.Wrap(err, "分批查询销户卡导出数据失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
cell, _ := excelize.CoordinatesToCellName(1, rowNumber)
|
||||
if err = stream.SetRow(cell, cancellationExportRow(row)); err != nil {
|
||||
return gerror.Wrap(err, "写入销户卡 Excel 数据失败")
|
||||
counted := false
|
||||
totalRows := 0
|
||||
_, err := exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardCancellation",
|
||||
BizName: "销户卡信息",
|
||||
PermCode: "cpm:iot:tSimCardCancellation:export",
|
||||
QueryParam: filter,
|
||||
Tags: func() ([]string, error) {
|
||||
headers := make([]string, 0, len(cancellationExportHeaders))
|
||||
for _, v := range cancellationExportHeaders {
|
||||
headers = append(headers, gconv.String(v))
|
||||
}
|
||||
rowNumber++
|
||||
}
|
||||
}
|
||||
if err = stream.Flush(); err != nil {
|
||||
return gerror.Wrap(err, "刷新销户卡 Excel 数据失败")
|
||||
}
|
||||
|
||||
request := ghttp.RequestFromCtx(ctx)
|
||||
if request == nil {
|
||||
return gerror.New("销户卡导出缺少 HTTP 请求上下文")
|
||||
}
|
||||
fileName := "销户卡信息-" + gtime.Now().Format("YmdHis") + ".xlsx"
|
||||
writer := request.Response.Writer
|
||||
writer.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", url.QueryEscape(fileName)))
|
||||
writer.Header().Set("Content-Transfer-Encoding", "binary")
|
||||
writer.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
|
||||
if err = file.Write(writer); err != nil {
|
||||
return gerror.Wrap(err, "输出销户卡 Excel 失败")
|
||||
}
|
||||
request.Response.Status = http.StatusOK
|
||||
writer.Flush()
|
||||
return nil
|
||||
return headers, nil
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
if !counted {
|
||||
totalRows, err = s.filteredModel(ctx, filter).Count()
|
||||
if err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "统计销户卡导出数量失败")
|
||||
}
|
||||
counted = true
|
||||
}
|
||||
var list []*model.TSimCardCancellationListModel
|
||||
query := s.selectModel(ctx, filter).
|
||||
OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().CancelledAt).
|
||||
OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().Id).
|
||||
Limit((page-1)*pageSize, pageSize)
|
||||
if err = query.Scan(&list); err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "分批查询销户卡导出数据失败")
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, row := range list {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, totalRows, nil
|
||||
},
|
||||
ToRow: func(v interface{}) []interface{} {
|
||||
row, _ := v.(*model.TSimCardCancellationListModel)
|
||||
return cancellationExportRow(row)
|
||||
},
|
||||
// ToRow 返回值与字段名的对应关系,供「按页面显示列导出」按字段取列。
|
||||
RowFields: cancellationExportFields,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sIotTSimCardCancellation) filteredModel(ctx context.Context, filter *model.TSimCardCancellationFilter) *gdb.Model {
|
||||
@@ -195,13 +177,13 @@ func (s *sIotTSimCardCancellation) selectModel(ctx context.Context, filter *mode
|
||||
query := s.filteredModel(ctx, filter)
|
||||
query = query.FieldsPrefix(cardTable, model.TSimCardCancellationListModel{})
|
||||
query = query.Fields(
|
||||
channelTable+"."+dao.TSimChannels.Columns().ChanName+" AS chanName",
|
||||
channelDisplayNameSQL(ctx, channelTable)+" AS chanName",
|
||||
deptTable+"."+dao.SysDept.Columns().DeptName+" AS deptName",
|
||||
packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageName+" AS packageName",
|
||||
)
|
||||
query = query.LeftJoin(channelTable, channelTable+"."+dao.TSimChannels.Columns().ChanKey+"="+cardTable+"."+dao.TSimCardCancellation.Columns().ChanKey)
|
||||
query = query.LeftJoin(channelTable, channelTable+"."+dao.TSimChannels.Columns().ChanKey+"="+cardTable+"."+dao.TSimCardCancellation.Columns().ChanKey+" AND "+channelTable+"."+dao.TSimChannels.Columns().TenantId+"="+cardTable+"."+dao.TSimCardCancellation.Columns().TenantId)
|
||||
query = query.LeftJoin(deptTable, deptTable+"."+dao.SysDept.Columns().DeptId+"="+cardTable+"."+dao.TSimCardCancellation.Columns().DeptId)
|
||||
query = query.LeftJoin(packageTable, packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+cardTable+"."+dao.TSimCardCancellation.Columns().CurrentPackageCode)
|
||||
query = query.LeftJoin(packageTable, packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+cardTable+"."+dao.TSimCardCancellation.Columns().CurrentPackageCode+" AND "+packageTable+"."+dao.TSimFlowPackageInfo.Columns().TenantId+"="+cardTable+"."+dao.TSimCardCancellation.Columns().TenantId)
|
||||
return query.Hook(hook.MemberSummary)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestAddRenewDurationByUnitKeepsSameCalendarDate(t *testing.T) {
|
||||
begin := time.Date(2026, time.September, 1, 0, 0, 0, 0, time.Local)
|
||||
got := addRenewDurationByUnit(begin, "year", 1)
|
||||
want := time.Date(2027, time.September, 1, 23, 59, 59, 0, time.Local)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf(
|
||||
"addRenewDurationByUnit() = %s, want %s",
|
||||
got.Format(time.RFC3339),
|
||||
want.Format(time.RFC3339),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewConfirmContract(t *testing.T) {
|
||||
snapshot := &model.TSimCardRenewPriceSnapshot{
|
||||
RenewableDurationOptions: []int{12, 24, 36},
|
||||
}
|
||||
if err := validateRenewConfirmContract(snapshot, 24); err != nil {
|
||||
t.Fatalf("valid renew contract rejected: %v", err)
|
||||
}
|
||||
if err := validateRenewConfirmContract(snapshot, 6); err == nil {
|
||||
t.Fatal("renew duration outside prepare contract was accepted")
|
||||
}
|
||||
if err := validateRenewConfirmContract(nil, 12); err == nil {
|
||||
t.Fatal("missing renew contract was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChangeConfirmContract(t *testing.T) {
|
||||
prepareSnapshot := &model.TSimCardChangePrepareSnapshot{
|
||||
OldPackageCode: "PKG-OLD",
|
||||
}
|
||||
priceSnapshot := &model.TSimCardChangePriceSnapshot{
|
||||
NewPackageCode: "PKG-NEW",
|
||||
RenewableDurationOptions: []int{12, 24},
|
||||
}
|
||||
valid := &model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG-NEW",
|
||||
DurationMonths: 12,
|
||||
EffectiveMode: cardChangeEffectiveNextMonth,
|
||||
}
|
||||
if err := validateChangeConfirmContract(prepareSnapshot, priceSnapshot, cardChangeEffectiveNextMonth, valid); err != nil {
|
||||
t.Fatalf("valid change contract rejected: %v", err)
|
||||
}
|
||||
if err := validateChangeConfirmContract(prepareSnapshot, priceSnapshot, "", valid); err != nil {
|
||||
t.Fatalf("legacy change contract without effective mode rejected: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in model.TSimCardChangeBatchSubmitParam
|
||||
}{
|
||||
{
|
||||
name: "current package selected",
|
||||
in: model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG-OLD",
|
||||
DurationMonths: 12,
|
||||
EffectiveMode: cardChangeEffectiveNextMonth,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "target package changed",
|
||||
in: model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG-OTHER",
|
||||
DurationMonths: 12,
|
||||
EffectiveMode: cardChangeEffectiveNextMonth,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duration outside contract",
|
||||
in: model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG-NEW",
|
||||
DurationMonths: 6,
|
||||
EffectiveMode: cardChangeEffectiveNextMonth,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "effective mode changed",
|
||||
in: model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG-NEW",
|
||||
DurationMonths: 12,
|
||||
EffectiveMode: cardChangeEffectiveImmediate,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := validateChangeConfirmContract(prepareSnapshot, priceSnapshot, cardChangeEffectiveNextMonth, &tt.in); err == nil {
|
||||
t.Fatal("invalid change contract was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := validateChangeConfirmContract(nil, priceSnapshot, cardChangeEffectiveNextMonth, valid); err == nil {
|
||||
t.Fatal("missing change contract was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAddPackagePaymentIntent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
confirmed bool
|
||||
isTopDept bool
|
||||
wantMode string
|
||||
wantConfirm bool
|
||||
wantErr string
|
||||
}{
|
||||
{name: "child default uses balance", wantMode: "balance"},
|
||||
{name: "top default uses balance", isTopDept: true, wantMode: "balance"},
|
||||
{name: "balance clears offline confirmation", mode: "balance", confirmed: true, isTopDept: true, wantMode: "balance"},
|
||||
{name: "child cannot use offline", mode: "offline", confirmed: true, wantErr: "必须使用余额支付"},
|
||||
{name: "top offline requires confirmation", mode: "offline", isTopDept: true, wantErr: "请确认线下合同"},
|
||||
{name: "top offline keeps confirmation", mode: "offline", confirmed: true, isTopDept: true, wantMode: "offline", wantConfirm: true},
|
||||
{name: "invalid payment mode", mode: "cash", isTopDept: true, wantErr: "付款方式不正确"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotMode, gotConfirm, err := normalizeAddPackagePaymentIntent(tt.mode, tt.confirmed, tt.isTopDept)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeAddPackagePaymentIntent() error = %v", err)
|
||||
}
|
||||
if gotMode != tt.wantMode {
|
||||
t.Fatalf("payment mode = %q, want %q", gotMode, tt.wantMode)
|
||||
}
|
||||
if gotConfirm != tt.wantConfirm {
|
||||
t.Fatalf("offline confirmation = %v, want %v", gotConfirm, tt.wantConfirm)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizedChangePrepareSourceBatchNo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sourceType int
|
||||
mainSource string
|
||||
snapshot string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "input ignores self reference",
|
||||
sourceType: consts.BatchSourceTypeBatchInput,
|
||||
mainSource: "CHG20260803170000INPUT",
|
||||
snapshot: "CHG20260803170000INPUT",
|
||||
},
|
||||
{
|
||||
name: "file ignores stale source",
|
||||
sourceType: consts.BatchSourceTypeFile,
|
||||
mainSource: "CHG20260803170000FILE",
|
||||
snapshot: "OUT20260803STALE",
|
||||
},
|
||||
{
|
||||
name: "batch selection keeps snapshot source",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSource: "OUT20260803MAIN",
|
||||
snapshot: "OUT20260803CONFIRMED",
|
||||
want: "OUT20260803CONFIRMED",
|
||||
},
|
||||
{
|
||||
name: "batch selection repairs worker self reference",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSource: "OUT20260803MAIN",
|
||||
snapshot: "CHG20260803170000BATCH",
|
||||
want: "OUT20260803MAIN",
|
||||
},
|
||||
{
|
||||
name: "legacy self reference without real source becomes empty",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSource: "CHG20260803170000BATCH",
|
||||
snapshot: "CHG20260803170000BATCH",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
batchNo := "CHG20260803170000BATCH"
|
||||
if tt.sourceType == consts.BatchSourceTypeBatchInput {
|
||||
batchNo = "CHG20260803170000INPUT"
|
||||
}
|
||||
if tt.sourceType == consts.BatchSourceTypeFile {
|
||||
batchNo = "CHG20260803170000FILE"
|
||||
}
|
||||
batch := &entity.BatchMain{
|
||||
BatchNo: batchNo,
|
||||
SourceType: tt.sourceType,
|
||||
SourceBatchNo: tt.mainSource,
|
||||
}
|
||||
if got := normalizedChangePrepareSourceBatchNo(batch, tt.snapshot); got != tt.want {
|
||||
t.Fatalf("sourceBatchNo = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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/batchinput/text"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
@@ -21,15 +24,13 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"strings"
|
||||
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTSimCardInfo struct{}
|
||||
@@ -66,6 +67,14 @@ func (s *sIotTSimCardInfo) Model(ctx context.Context, option ...*handler.Option)
|
||||
|
||||
// List 获取卡信息表列表
|
||||
func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListParam) (list []*model.TSimCardInfoListModel, totalCount int, err error) {
|
||||
var batchCondition string
|
||||
var batchArgs []interface{}
|
||||
if in.BatchInput != nil {
|
||||
batchCondition, batchArgs, err = simCardBatchCondition(ctx, *in.BatchInput)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
mod := s.Model(ctx)
|
||||
cardInfoTable := dao.TSimCardInfo.Table()
|
||||
simChannels := dao.TSimChannels.Table()
|
||||
@@ -116,6 +125,9 @@ func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListP
|
||||
dao.TSimCardInfo.Columns().Imsi + " = ?)"
|
||||
mod = mod.Where(cardValueCondition, in.CardValue, in.CardValue, in.CardValue)
|
||||
}
|
||||
if batchCondition != "" {
|
||||
mod = mod.Where(batchCondition, batchArgs...)
|
||||
}
|
||||
if in.Remark != "" {
|
||||
mod = mod.Where(dao.TSimCardInfo.Columns().Remark+" LIKE ?", "%"+in.Remark+"%")
|
||||
}
|
||||
@@ -254,6 +266,7 @@ func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListP
|
||||
err = gerror.Wrap(err, "获取卡信息表列表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
applyCardChannelDisplayNames(ctx, list)
|
||||
if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil {
|
||||
logCardDeptDisplayResolverError(ctx, resolverErr)
|
||||
} else {
|
||||
@@ -266,31 +279,127 @@ func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListP
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出卡信息表
|
||||
// exportColumns 取前端传入的导出列。
|
||||
// 优先解析 JSON 字符串形式(数组绑定可能丢失内容),失败再退回对象数组。
|
||||
func (s *sIotTSimCardInfo) exportColumns(in *model.TSimCardInfoListParam) []model.ExportColumn {
|
||||
if raw := strings.TrimSpace(in.ExportColumnsJson); raw != "" {
|
||||
cols := make([]model.ExportColumn, 0)
|
||||
if err := gjson.New(raw).Scan(&cols); err == nil && len(cols) > 0 {
|
||||
return cols
|
||||
}
|
||||
}
|
||||
return in.ExportColumns
|
||||
}
|
||||
|
||||
const simCardBatchMaxRows = 1000
|
||||
|
||||
// simCardBatchCondition 将批量查询输入编译为查询条件。
|
||||
// 每行支持 1 至 3 个号码值,同一行的多个值表示同一张卡的多个字段值。
|
||||
// 同一行的值互不相同,必然落在不同字段上,因此“该行每个值都命中这张卡的某个字段”
|
||||
// 已等价于“这些值都属于这张卡”,无需判断某个值具体是 ICCID、MSISDN 还是 IMSI。
|
||||
func simCardBatchCondition(ctx context.Context, input string) (string, []interface{}, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return "", nil, gerror.New("批量查询卡号不能为空")
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(lines) > simCardBatchMaxRows {
|
||||
return "", nil, gerror.New("批量查询最多支持1000行")
|
||||
}
|
||||
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
// 单值行之间相互独立,合并成 IN 列表后语义不变,可显著降低条件求值成本。
|
||||
singleValues := make([]string, 0, len(lines))
|
||||
singleIndex := make(map[string]struct{}, len(lines))
|
||||
// 多值行需要保留同一行内的配对关系,不能跨行合并。
|
||||
multiConditions := make([]string, 0, len(lines))
|
||||
multiArgs := make([]interface{}, 0, len(lines)*9)
|
||||
multiIndex := make(map[string]struct{}, len(lines))
|
||||
|
||||
for _, line := range lines {
|
||||
switch len(line.Fields) {
|
||||
case 1:
|
||||
value := line.Fields[0]
|
||||
if _, exists := singleIndex[value]; exists {
|
||||
continue
|
||||
}
|
||||
singleIndex[value] = struct{}{}
|
||||
singleValues = append(singleValues, value)
|
||||
case 2, 3:
|
||||
key := strings.Join(line.Fields, "\x00")
|
||||
if _, exists := multiIndex[key]; exists {
|
||||
continue
|
||||
}
|
||||
multiIndex[key] = struct{}{}
|
||||
groups := make([]string, 0, len(line.Fields))
|
||||
for _, value := range line.Fields {
|
||||
groups = append(groups, "("+columns.Iccid+" = ? OR "+
|
||||
columns.Msisdn+" = ? OR "+
|
||||
columns.Imsi+" = ?)")
|
||||
multiArgs = append(multiArgs, value, value, value)
|
||||
}
|
||||
multiConditions = append(multiConditions, "("+strings.Join(groups, " AND ")+")")
|
||||
default:
|
||||
return "", nil, gerror.Newf("第%d行格式错误,每行支持 1 至 3 个号码值", line.RowNo)
|
||||
}
|
||||
}
|
||||
|
||||
parts := make([]string, 0, 2)
|
||||
args := make([]interface{}, 0, len(singleValues)*3+len(multiArgs))
|
||||
if len(singleValues) > 0 {
|
||||
placeholders := "(" + strings.TrimSuffix(strings.Repeat("?,", len(singleValues)), ",") + ")"
|
||||
parts = append(parts, "("+columns.Iccid+" IN "+placeholders+" OR "+
|
||||
columns.Msisdn+" IN "+placeholders+" OR "+
|
||||
columns.Imsi+" IN "+placeholders+")")
|
||||
for repeat := 0; repeat < 3; repeat++ {
|
||||
for _, value := range singleValues {
|
||||
args = append(args, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(multiConditions) > 0 {
|
||||
parts = append(parts, "("+strings.Join(multiConditions, " OR ")+")")
|
||||
args = append(args, multiArgs...)
|
||||
}
|
||||
|
||||
return "(" + strings.Join(parts, " OR ") + ")", args, nil
|
||||
}
|
||||
|
||||
// Export 导出卡信息(异步:提交任务后到下载中心取文件)
|
||||
// 导出的列由前端按当前列表显示列传入,未传时按列表模型字段顺序全量导出。
|
||||
func (s *sIotTSimCardInfo) Export(ctx context.Context, in *model.TSimCardInfoListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
if in.BatchInput != nil {
|
||||
if _, _, err = simCardBatchCondition(ctx, *in.BatchInput); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TSimCardInfoExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出卡信息表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []*model.TSimCardInfoExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardInfo",
|
||||
BizName: "卡信息",
|
||||
PermCode: "cpm:iot:tSimCardInfo:export",
|
||||
QueryParam: in,
|
||||
Columns: s.exportColumns(in),
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimCardInfoListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增卡信息表
|
||||
@@ -428,6 +537,7 @@ func (s *sIotTSimCardInfo) View(ctx context.Context, in *model.TSimCardInfoViewP
|
||||
return
|
||||
}
|
||||
if res != nil {
|
||||
res.ChanName = channelDisplayName(canViewChannelFullName(ctx), res.ChanName, res.ChanAlias)
|
||||
if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil {
|
||||
logCardDeptDisplayResolverError(ctx, resolverErr)
|
||||
} else {
|
||||
@@ -940,6 +1050,7 @@ func (s *sIotTSimCardInfo) GetCardsByPackageCodes(ctx context.Context, req *mode
|
||||
err = gerror.Wrap(err, "获取卡列表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
applyCardChannelDisplayNames(ctx, list)
|
||||
if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil {
|
||||
logCardDeptDisplayResolverError(ctx, resolverErr)
|
||||
} else {
|
||||
|
||||
@@ -1,12 +1,127 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestSimCardBatchCondition(t *testing.T) {
|
||||
condition, args, err := simCardBatchCondition(context.Background(), "\n iccid-1 \n\t\niccid-2 13800000000 460001234567890\n")
|
||||
if err != nil {
|
||||
t.Fatalf("parse batch input: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(condition, "((") || !strings.HasSuffix(condition, "))") {
|
||||
t.Fatalf("batch conditions must be grouped: %s", condition)
|
||||
}
|
||||
if !strings.Contains(condition, "iccid IN (?) OR msisdn IN (?) OR imsi IN (?)") {
|
||||
t.Fatalf("single values must be folded into IN lists: %s", condition)
|
||||
}
|
||||
if strings.Count(condition, "(iccid = ? OR msisdn = ? OR imsi = ?)") != 3 {
|
||||
t.Fatalf("three-value row must require every value on the same card: %s", condition)
|
||||
}
|
||||
if strings.Contains(condition, " = ? AND msisdn = ? AND imsi = ?") {
|
||||
t.Fatalf("field positions must not be assumed: %s", condition)
|
||||
}
|
||||
wantArgs := []interface{}{
|
||||
"iccid-1", "iccid-1", "iccid-1",
|
||||
"iccid-2", "iccid-2", "iccid-2",
|
||||
"13800000000", "13800000000", "13800000000",
|
||||
"460001234567890", "460001234567890", "460001234567890",
|
||||
}
|
||||
if len(args) != len(wantArgs) {
|
||||
t.Fatalf("got %d args, want %d: %#v", len(args), len(wantArgs), args)
|
||||
}
|
||||
for i := range wantArgs {
|
||||
if args[i] != wantArgs[i] {
|
||||
t.Fatalf("arg %d = %#v, want %#v", i, args[i], wantArgs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardBatchConditionAcceptsAnyTwoValuesPerRow(t *testing.T) {
|
||||
condition, args, err := simCardBatchCondition(context.Background(),
|
||||
"89860000000000000001 13800000000\n13800000001 460001234567891\n")
|
||||
if err != nil {
|
||||
t.Fatalf("two values per row must be accepted: %v", err)
|
||||
}
|
||||
if strings.Count(condition, "(iccid = ? OR msisdn = ? OR imsi = ?)") != 4 {
|
||||
t.Fatalf("each value must match any field, two rows expected four groups: %s", condition)
|
||||
}
|
||||
if len(args) != 12 {
|
||||
t.Fatalf("got %d args, want 12: %#v", len(args), args)
|
||||
}
|
||||
if !strings.Contains(condition, " AND ") {
|
||||
t.Fatalf("values of one row must be paired on the same card: %s", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardBatchConditionRejectsEmptyAndInvalidInput(t *testing.T) {
|
||||
for _, input := range []string{"", " \n\t ", "one two three four", "one two three four five"} {
|
||||
if _, _, err := simCardBatchCondition(context.Background(), input); err == nil {
|
||||
t.Errorf("expected input %q to be rejected", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardBatchConditionLimitsNonEmptyRowsBeforeDeduplication(t *testing.T) {
|
||||
input := strings.Repeat("same-number\n", simCardBatchMaxRows)
|
||||
condition, args, err := simCardBatchCondition(context.Background(), input)
|
||||
if err != nil {
|
||||
t.Fatalf("accept exactly %d rows: %v", simCardBatchMaxRows, err)
|
||||
}
|
||||
if len(args) != 3 {
|
||||
t.Fatalf("duplicate single values must collapse to one IN list, got %d args: %s", len(args), condition)
|
||||
}
|
||||
|
||||
input += "one-more\n"
|
||||
if _, _, err := simCardBatchCondition(context.Background(), input); err == nil {
|
||||
t.Fatal("expected 1001 rows to be rejected even when values repeat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardBatchInputPresenceAndExportValidation(t *testing.T) {
|
||||
for _, payload := range []string{`{"batchInput":""}`, `{"batchInput":" \n\t"}`} {
|
||||
var in model.TSimCardInfoListParam
|
||||
if err := json.Unmarshal([]byte(payload), &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.BatchInput == nil {
|
||||
t.Fatal("显式空批量输入不能视为省略字段")
|
||||
}
|
||||
if _, _, err := NewIotTSimCardInfo().List(context.Background(), &in); err == nil {
|
||||
t.Fatal("列表应在访问数据库前拒绝空批量输入")
|
||||
}
|
||||
if err := NewIotTSimCardInfo().Export(context.Background(), &in); err == nil {
|
||||
t.Fatal("导出应在提交任务前拒绝空批量输入")
|
||||
}
|
||||
}
|
||||
var omitted model.TSimCardInfoListParam
|
||||
if err := json.Unmarshal([]byte(`{}`), &omitted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if omitted.BatchInput != nil {
|
||||
t.Fatal("省略批量字段应保持原列表查询语义")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardBatchExportKeepsListReuse(t *testing.T) {
|
||||
data, err := os.ReadFile("t_sim_card_info.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{"QueryParam: in,", "query := *in", "s.List(ctx, &query)", "mod := s.Model(ctx)", "mod = mod.Where(batchCondition, batchArgs...)"} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("批量查询必须保留现有权限及导出复用: %s", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardChannelAssocExistsSQLUsesChannelKeyOnly(t *testing.T) {
|
||||
query := cardChannelAssocExistsSQL("")
|
||||
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"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/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTSimCardStock struct{}
|
||||
@@ -130,31 +127,32 @@ func (s *sIotTSimCardStock) List(ctx context.Context, in *model.TSimCardStockLis
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出库存卡信息表
|
||||
// Export 导出库存卡(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimCardStock) Export(ctx context.Context, in *model.TSimCardStockListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TSimCardStockExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出库存卡信息表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TSimCardStockExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardStock",
|
||||
BizName: "库存卡",
|
||||
PermCode: "cpm:iot:tSimCardStock:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimCardStockListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增库存卡信息表
|
||||
|
||||
@@ -2,26 +2,23 @@ package iot
|
||||
|
||||
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/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/trafficcutoffconfig"
|
||||
"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"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/iot_excelize"
|
||||
|
||||
"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/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
@@ -103,25 +100,32 @@ func (s *sIotTSimCardTrafficCutoff) List(ctx context.Context, in *model.TSimCard
|
||||
return service.IotTSimCardInfo().List(ctx, &cardParam)
|
||||
}
|
||||
|
||||
func (s *sIotTSimCardTrafficCutoff) Export(ctx context.Context, in *model.TSimCardTrafficCutoffListParam) error {
|
||||
if in == nil {
|
||||
in = &model.TSimCardTrafficCutoffListParam{}
|
||||
}
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags, err := convert.GetEntityDescTags(model.TSimCardTrafficCutoffExportModel{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var exports []*model.TSimCardTrafficCutoffExportModel
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return err
|
||||
}
|
||||
fileName := "traffic-cutoff-card-info-" + gctx.CtxId(ctx)
|
||||
sheetName := fmt.Sprintf("traffic cutoff cards total %d, exported %d", totalCount, len(exports))
|
||||
return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
// Export 导出达量断网(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimCardTrafficCutoff) Export(ctx context.Context, in *model.TSimCardTrafficCutoffListParam) (err error) {
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardTrafficCutoff",
|
||||
BizName: "达量断网",
|
||||
PermCode: "cpm:iot:tSimCardTrafficCutoff:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimCardInfoListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sIotTSimCardTrafficCutoff) Edit(ctx context.Context, in *model.TSimCardTrafficCutoffEditParam) error {
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"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/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTSimChannelTemplate struct{}
|
||||
@@ -105,31 +102,32 @@ func (s *sIotTSimChannelTemplate) List(ctx context.Context, in *model.TSimChanne
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出通道模板
|
||||
// Export 导出通道模版(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimChannelTemplate) Export(ctx context.Context, in *model.TSimChannelTemplateListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TSimChannelTemplateExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出通道模板-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TSimChannelTemplateExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimChannelTemplate",
|
||||
BizName: "通道模版",
|
||||
PermCode: "cpm:iot:tSimChannelTemplate:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimChannelTemplateListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增通道模板
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
@@ -9,10 +9,11 @@ package iot
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"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/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
@@ -21,15 +22,12 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
@@ -90,7 +88,7 @@ func (s *sIotTSimChannels) List(ctx context.Context, in *model.TSimChannelsListP
|
||||
}
|
||||
// 查询通道名称
|
||||
if in.ChanName != "" {
|
||||
mod = mod.Where(dao.TSimChannels.Columns().ChanName+" LIKE ?", "%"+in.ChanName+"%")
|
||||
mod = mod.Where(channelDisplayNameSQL(ctx, simChannels)+" LIKE ?", "%"+in.ChanName+"%")
|
||||
}
|
||||
// 查询通道别名
|
||||
if in.ChanAlias != "" {
|
||||
@@ -136,6 +134,12 @@ func (s *sIotTSimChannels) List(ctx context.Context, in *model.TSimChannelsListP
|
||||
err = gerror.Wrap(err, "获取通道列表列表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
fullName := canViewChannelFullName(ctx)
|
||||
for _, item := range list {
|
||||
if item != nil {
|
||||
item.ChanName = channelDisplayName(fullName, item.ChanName, item.ChanAlias)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -193,31 +197,32 @@ func (s *sIotTSimChannels) RefreshCardCounts(ctx context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Export 导出通道列表
|
||||
// Export 导出通道管理(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimChannels) Export(ctx context.Context, in *model.TSimChannelsListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TSimChannelsExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出通道列表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TSimChannelsExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimChannels",
|
||||
BizName: "通道管理",
|
||||
PermCode: "cpm:iot:tSimChannels:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimChannelsListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// shouldGeneratePlatformAppKey reports whether a channel uses the shared-token
|
||||
@@ -569,6 +574,9 @@ func (s *sIotTSimChannels) View(ctx context.Context, in *model.TSimChannelsViewP
|
||||
err = gerror.Wrap(err, "获取通道列表信息,请稍后重试!")
|
||||
return
|
||||
}
|
||||
if res != nil {
|
||||
res.ChanName = channelDisplayName(canViewChannelFullName(ctx), res.ChanName, res.ChanAlias)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShouldGeneratePlatformAppKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tempKey string
|
||||
tempModel string
|
||||
want bool
|
||||
}{
|
||||
{name: "third party shared token", tempKey: "vendor_alpha", tempModel: "shared_token", want: true},
|
||||
{name: "model comparison is case insensitive", tempKey: "vendor_alpha", tempModel: " SHARED_TOKEN ", want: true},
|
||||
{name: "official mobile keeps upstream key", tempKey: mobileOfficialTemplateKey, tempModel: sharedTokenTemplateModel, want: false},
|
||||
{name: "official mobile key comparison is case insensitive", tempKey: " SIM_YD_V5 ", tempModel: sharedTokenTemplateModel, want: false},
|
||||
{name: "other template model", tempKey: "vendor_alpha", tempModel: "oauth", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := shouldGeneratePlatformAppKey(test.tempKey, test.tempModel); got != test.want {
|
||||
t.Fatalf("shouldGeneratePlatformAppKey(%q, %q) = %v, want %v", test.tempKey, test.tempModel, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMissingAppKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appKey string
|
||||
want bool
|
||||
}{
|
||||
{name: "empty", appKey: "", want: true},
|
||||
{name: "whitespace", appKey: " ", want: true},
|
||||
{name: "legacy slash placeholder", appKey: " / ", want: true},
|
||||
{name: "configured key", appKey: "YZT7F3K9M2Q8R6V4N5C2", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := isMissingAppKey(test.appKey); got != test.want {
|
||||
t.Fatalf("isMissingAppKey(%q) = %v, want %v", test.appKey, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePlatformAppKey(t *testing.T) {
|
||||
seen := make(map[string]struct{}, 100)
|
||||
for index := 0; index < 100; index++ {
|
||||
appKey, err := generatePlatformAppKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generatePlatformAppKey() error = %v", err)
|
||||
}
|
||||
if len(appKey) != len(platformAppKeyPrefix)+platformAppKeyRandomLength {
|
||||
t.Fatalf("generated app key length = %d, want %d", len(appKey), len(platformAppKeyPrefix)+platformAppKeyRandomLength)
|
||||
}
|
||||
if !strings.HasPrefix(appKey, platformAppKeyPrefix) {
|
||||
t.Fatalf("generated app key %q does not have prefix %q", appKey, platformAppKeyPrefix)
|
||||
}
|
||||
for _, character := range appKey[len(platformAppKeyPrefix):] {
|
||||
if !strings.ContainsRune(platformAppKeyAlphabet, character) {
|
||||
t.Fatalf("generated app key %q contains character %q outside alphabet", appKey, character)
|
||||
}
|
||||
}
|
||||
if _, duplicate := seen[appKey]; duplicate {
|
||||
t.Fatalf("generated duplicate app key %q", appKey)
|
||||
}
|
||||
seen[appKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSharedTokenChannelKeyChange(t *testing.T) {
|
||||
requested := " new-key "
|
||||
oldKey, newKey, changed, err := resolveChannelKeyChange("old-key", 0, &requested)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveChannelKeyChange() error = %v", err)
|
||||
}
|
||||
if oldKey != "old-key" || newKey != "new-key" || !changed {
|
||||
t.Fatalf("resolveChannelKeyChange() = (%q, %q, %v), want (%q, %q, true)", oldKey, newKey, changed, "old-key", "new-key")
|
||||
}
|
||||
|
||||
if _, _, _, err = resolveChannelKeyChange("old-key", 1, &requested); err == nil {
|
||||
t.Fatal("resolveChannelKeyChange() should reject renaming a shared-token channel")
|
||||
}
|
||||
empty := " "
|
||||
if _, _, _, err = resolveChannelKeyChange("old-key", 0, &empty); err == nil {
|
||||
t.Fatal("resolveChannelKeyChange() should reject an empty channel key")
|
||||
}
|
||||
|
||||
oldKey, newKey, changed, err = resolveChannelKeyChange("old-key", 0, nil)
|
||||
if err != nil || oldKey != "old-key" || newKey != "old-key" || changed {
|
||||
t.Fatalf("resolveChannelKeyChange() without request = (%q, %q, %v, %v), want unchanged", oldKey, newKey, changed, err)
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@ package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"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/library/xgorm/handler"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLegacyDiagnoseFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
previous := gdb.GetConfig("default")
|
||||
previousLegacy := gdb.GetConfig("legacyCard")
|
||||
adapter := g.Cfg().GetAdapter()
|
||||
cfg, err := gcfg.NewAdapterContent(`{"database":{"legacyCard":{"link":"sqlite::@file(:memory:)"}}}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(cfg)
|
||||
for _, group := range []string{"default", "legacyCard"} {
|
||||
if err := gdb.SetConfigGroup(group, gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = g.DB().Close(ctx)
|
||||
_ = g.DB("legacyCard").Close(ctx)
|
||||
_ = gdb.SetConfigGroup("default", previous)
|
||||
_ = gdb.SetConfigGroup("legacyCard", previousLegacy)
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
})
|
||||
for _, sql := range []string{
|
||||
"CREATE TABLE yz_card_info(iccid TEXT, imsi TEXT, msisdn TEXT, channel_id TEXT)",
|
||||
"CREATE TABLE yz_card_route(cd_id TEXT)",
|
||||
"INSERT INTO yz_card_route VALUES ('77')",
|
||||
"INSERT INTO yz_card_info VALUES ('898607B91923C0327236','imsi','phone','77')",
|
||||
} {
|
||||
if _, err := g.DB("legacyCard").Exec(ctx, sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, sql := range []string{
|
||||
"CREATE TABLE t_sim_card_info(iccid TEXT, imsi TEXT, msisdn TEXT, tenant_id TEXT)",
|
||||
"CREATE TABLE t_sim_channels(chan_key TEXT,temp_key TEXT,chan_status INTEGER,tenant_id TEXT,created_by INTEGER,created_dept INTEGER)",
|
||||
"INSERT INTO t_sim_channels VALUES ('77','sim_yunda_v1',0,'A',7,10)",
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
principal := &model.Identity{BaseClaims: model.BaseClaims{ID: 7, TenantId: "A", DeptId: 10, Roles: []model.Role{{RoleId: 9, DataScope: consts.SysRoleDataScopePersonal}}}}
|
||||
scopeCtx := contexts.Set(ctx, &model.Context{User: principal})
|
||||
for _, code := range []string{"898607B91923C0327236", "imsi", "phone"} {
|
||||
card, err := resolveLegacyDiagnoseCardInfo(scopeCtx, code)
|
||||
if err != nil || card == nil || card.ChanKey != "77" || card.HasLocalCard {
|
||||
t.Fatalf("card=%+v err=%v", card, err)
|
||||
}
|
||||
}
|
||||
if _, err := resolveLegacyDiagnoseCardInfo(ctx, "phone"); err == nil {
|
||||
t.Fatal("guest allowed")
|
||||
}
|
||||
principal.TenantId = "B"
|
||||
if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil {
|
||||
t.Fatal("cross tenant channel allowed")
|
||||
}
|
||||
principal.TenantId = "A"
|
||||
principal.ID = 8
|
||||
if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil {
|
||||
t.Fatal("foreign channel allowed")
|
||||
}
|
||||
principal.ID = 7
|
||||
if _, err := g.DB().Exec(ctx, "UPDATE t_sim_channels SET chan_status=1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil {
|
||||
t.Fatal("disabled channel allowed")
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO t_sim_card_info VALUES ('898607B91923C0327236','imsi','phone','A')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil {
|
||||
t.Fatal("local visibility bypassed")
|
||||
}
|
||||
if _, err := g.DB("legacyCard").Exec(ctx, "INSERT INTO yz_card_info VALUES ('duplicate','','phone','77')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := queryLegacyDiagnoseCard(ctx, "phone"); err == nil {
|
||||
t.Fatal("ambiguous card accepted")
|
||||
}
|
||||
missing, err := queryLegacyDiagnoseCard(ctx, "missing")
|
||||
if err != nil || missing != nil {
|
||||
t.Fatalf("missing=%+v err=%v", missing, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyDiagnoseReadOnlyIntegration(t *testing.T) {
|
||||
configPath := os.Getenv("IOTLCM_LEGACY_DIAGNOSIS_CONFIG")
|
||||
if configPath == "" {
|
||||
t.Skip("未启用真实旧库只读测试")
|
||||
}
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal("无法读取测试环境配置")
|
||||
}
|
||||
adapter, err := gcfg.NewAdapterContent(string(content))
|
||||
if err != nil {
|
||||
t.Fatal("环境配置格式无效")
|
||||
}
|
||||
previous := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
card, err := queryLegacyDiagnoseCard(ctx, "898607B91923C0327236")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if card == nil {
|
||||
t.Fatal("真实旧库未查到卡及路由映射")
|
||||
}
|
||||
t.Logf("旧库实际命中 ICCID=%s channelKey=%s IMSI存在=%t MSISDN存在=%t", card.ICCID, card.ChannelKey, card.IMSI != "", card.MSISDN != "")
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestDiagnoseDataServiceStatusUsesTargetApn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []model.SimApnInfoItem
|
||||
want string
|
||||
}{
|
||||
{name: "enabled", items: []model.SimApnInfoItem{{ApnName: "OTHER", Status: "0"}, {ApnName: "CMIOT", Status: "1"}}, want: "1"},
|
||||
{name: "disabled", items: []model.SimApnInfoItem{{ApnName: "cmiot", Status: "0"}}, want: "0"},
|
||||
{name: "missing target", items: []model.SimApnInfoItem{{ApnName: "OTHER", Status: "1"}}},
|
||||
{name: "invalid status", items: []model.SimApnInfoItem{{ApnName: "CMIOT", Status: "unknown"}}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := diagnoseDataServiceStatus(test.items)
|
||||
if test.want == "" {
|
||||
if got != nil {
|
||||
t.Fatalf("status = %q, want no data", *got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil || *got != test.want {
|
||||
t.Fatalf("status = %v, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnoseAPNStatusRequiresAgreement(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []model.SimApnInfoItem
|
||||
want string
|
||||
}{
|
||||
{name: "all enabled", items: []model.SimApnInfoItem{{ApnName: "ctnet", Status: "1"}, {ApnName: "ctiot", Status: "1"}}, want: "1"},
|
||||
{name: "all stopped", items: []model.SimApnInfoItem{{ApnName: "ctnet", Status: "0"}, {ApnName: "ctiot", Status: "0"}}, want: "0"},
|
||||
{name: "mixed", items: []model.SimApnInfoItem{{ApnName: "ctnet", Status: "1"}, {ApnName: "ctiot", Status: "0"}}},
|
||||
{name: "mixed reversed", items: []model.SimApnInfoItem{{ApnName: "ctiot", Status: "0"}, {ApnName: "ctnet", Status: "1"}}},
|
||||
{name: "missing status", items: []model.SimApnInfoItem{{ApnName: "ctnet", Status: "1"}, {ApnName: "ctiot"}}},
|
||||
{name: "unknown status", items: []model.SimApnInfoItem{{ApnName: "ctnet", Status: "1"}, {ApnName: "ctiot", Status: "-1"}}},
|
||||
{name: "missing name", items: []model.SimApnInfoItem{{Status: "1"}}},
|
||||
{name: "no mobile preference for telecom", items: []model.SimApnInfoItem{{ApnName: "CMIOT", Status: "1"}, {ApnName: "ctnet", Status: "0"}}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := diagnoseDataServiceStatusForChannel(test.items, "sim_dx_v1", "channel_dx")
|
||||
if test.want == "" {
|
||||
if got != nil {
|
||||
t.Fatalf("status = %q, want no data", *got)
|
||||
}
|
||||
} else if got == nil || *got != test.want {
|
||||
t.Fatalf("status = %v, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnoseDataServiceStatusForCarrier(t *testing.T) {
|
||||
telecom := []model.SimApnInfoItem{{ApnName: "CTNET", Status: "1"}}
|
||||
if got := diagnoseDataServiceStatusForChannel(telecom, "sim_dx_v1", "channel_dx"); got == nil || *got != "1" {
|
||||
t.Fatalf("telecom status = %v, want enabled", got)
|
||||
}
|
||||
mobile := []model.SimApnInfoItem{{ApnName: "CTNET", Status: "1"}}
|
||||
if got := diagnoseDataServiceStatusForChannel(mobile, "sim_yd_v5", "channel_yd"); got != nil {
|
||||
t.Fatalf("mobile status = %v, want no CMIOT data", *got)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@ package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"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/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/packageduration"
|
||||
@@ -17,13 +17,11 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
@@ -348,29 +346,32 @@ func applyPoolFlowPackageBaseFilter(mod *gdb.Model, mainTable, currentPoolPackag
|
||||
Where(mainTable+"."+columns.PackageType, "102")
|
||||
}
|
||||
|
||||
// Export 导出SIM流量套餐(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimFlowPackageInfo) Export(ctx context.Context, in *model.TSimFlowPackageInfoListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tags, err := convert.GetEntityDescTags(model.TSimFlowPackageInfoExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出SIM流量套餐管理-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行 共%v页 当前导出是第%v页 本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TSimFlowPackageInfoExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimFlowPackageInfo",
|
||||
BizName: "SIM流量套餐",
|
||||
PermCode: "cpm:iot:tSimFlowPackageInfo:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimFlowPackageInfoListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sIotTSimFlowPackageInfo) Edit(ctx context.Context, in *model.TSimFlowPackageInfoEditParam) (err error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"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/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTSimPackageCategory struct{}
|
||||
@@ -95,31 +92,32 @@ func (s *sIotTSimPackageCategory) List(ctx context.Context, in *model.TSimPackag
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出套餐分类表
|
||||
// Export 导出套餐分类(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimPackageCategory) Export(ctx context.Context, in *model.TSimPackageCategoryListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TSimPackageCategoryExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出套餐分类表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TSimPackageCategoryExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimPackageCategory",
|
||||
BizName: "套餐分类",
|
||||
PermCode: "cpm:iot:tSimPackageCategory:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimPackageCategoryListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增套餐分类表
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"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/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTUpstreamPackage struct{}
|
||||
@@ -95,31 +92,32 @@ func (s *sIotTUpstreamPackage) List(ctx context.Context, in *model.TUpstreamPack
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出上游套餐表
|
||||
// Export 导出上游套餐(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTUpstreamPackage) Export(ctx context.Context, in *model.TUpstreamPackageListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TUpstreamPackageExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出上游套餐表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []*model.TUpstreamPackageExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimUpstreamPackage",
|
||||
BizName: "上游套餐",
|
||||
PermCode: "cpm:iot:tSimUpstreamPackage:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TUpstreamPackageListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增上游套餐表
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
Reference in New Issue
Block a user