sync: 同步 v1.0.4 并脱敏配置与镜像
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user