package renewchange import ( "encoding/json" "reflect" "testing" "time" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/util/gconv" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do" rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange" ) func TestParseExecutionContractReadsConfirmedFlatContract(t *testing.T) { main := &rc.BatchMainRow{ BatchNo: "REN20260803100250ABCDEFGH", BatchType: consts.BatchTypeRenew, DeptId: 100, CreatedDept: 100, CreatedBy: 1, TenantId: "000000", BatchInfo: gjson.New(map[string]any{ "customerAgentId": 200, "packageCode": "PKG001", "packageName": "Year package", "renewUnitPrice": 10.25, "quantity": 2, "durationMonths": 12, "giftDurationMonths": 1, "offlineContractCompleted": true, "buyerNote": "buyer", "adminNote": "admin", }), } contract, err := ParseExecutionContract(main) if err != nil { t.Fatalf("ParseExecutionContract returned error: %v", err) } if contract.DurationDisplayValue != 1 || contract.DurationMonths != 12 || contract.GiftDurationMonths != 1 { t.Fatalf("duration = %d/%d+%d, want display 1 and actual 12+1", contract.DurationDisplayValue, contract.DurationMonths, contract.GiftDurationMonths) } if contract.CustomerAgentID != 200 || contract.SellerAgentID != 100 { t.Fatalf("buyer/seller = %d/%d, want 200/100", contract.CustomerAgentID, contract.SellerAgentID) } if contract.TotalAmount() != 20.5 { t.Fatalf("TotalAmount = %.2f, want 20.50", contract.TotalAmount()) } if contract.ItemPrice() != 10.25 { t.Fatalf("ItemPrice = %.2f, want 10.25", contract.ItemPrice()) } } func TestParseExecutionContractRenewDurationValueOverridesLegacyMonthSnapshot(t *testing.T) { main := &rc.BatchMainRow{ BatchNo: "REN20260803100250LEGACY", BatchType: consts.BatchTypeRenew, DeptId: 100, CreatedDept: 100, CreatedBy: 1, TenantId: "000000", BatchInfo: gjson.New(map[string]any{ "customerAgentId": 200, "packageCode": "PKG001", "packageName": "Year package", "renewUnitPrice": 10.25, "quantity": 1, "durationValue": 1, "durationYears": 1, "durationMonths": 1, "actualDurationMonths": 1, "billingMethod": consts.BillingMethodMonth, "renewDurationUnit": consts.RenewDurationUnitMonth, "offlineContractCompleted": true, }), } contract, err := ParseExecutionContract(main) if err != nil { t.Fatalf("ParseExecutionContract returned error: %v", err) } if contract.RenewDurationUnit != consts.RenewDurationUnitYear { t.Fatalf("renew duration unit = %q, want year", contract.RenewDurationUnit) } if contract.DurationDisplayValue != 1 || contract.DurationMonths != 12 { t.Fatalf("duration = %d/%d, want display 1 and actual 12 months", contract.DurationDisplayValue, contract.DurationMonths) } } func TestParseExecutionContractFallsBackToLegacyNestedSnapshot(t *testing.T) { main := &rc.BatchMainRow{ BatchNo: "CHG20260803100250ABCDEFGH", BatchType: consts.BatchTypeChange, DeptId: 100, CreatedDept: 100, CreatedBy: 1, TenantId: "000000", BatchInfo: gjson.New(map[string]any{ "changePriceSnapshot": map[string]any{ "customerAgentId": 200, "newPackageCode": "PKG002", "newPackageName": "Changed package", "changeUnitPrice": 8.5, }, "quantity": 1, "durationMonths": 24, "giftDurationMonths": 0, "offlineContractCompleted": true, }), } contract, err := ParseExecutionContract(main) if err != nil { t.Fatalf("ParseExecutionContract returned error: %v", err) } if contract.PackageCode != "PKG002" || contract.UnitPrice != 8.5 { t.Fatalf("legacy snapshot contract = %s/%.2f, want PKG002/8.50", contract.PackageCode, contract.UnitPrice) } } func TestParseExecutionContractNormalizesSourceBatchBySourceType(t *testing.T) { tests := []struct { name string sourceType int mainSourceBatchNo string infoSourceBatchNo string want string }{ { name: "input self reference is not a business source batch", sourceType: consts.BatchSourceTypeBatchInput, mainSourceBatchNo: "REN20260803161502D52JJU6B", want: "", }, { name: "file source ignores legacy source value", sourceType: consts.BatchSourceTypeFile, mainSourceBatchNo: "REN20260803161502D52JJU6B", infoSourceBatchNo: "OUT20260803STALE", want: "", }, { name: "batch selection keeps confirmed source batch", sourceType: consts.BatchSourceTypeBatchSelect, mainSourceBatchNo: "OUT20260803MAIN", infoSourceBatchNo: "OUT20260803CONFIRMED", want: "OUT20260803CONFIRMED", }, { name: "batch selection falls back to main source batch", sourceType: consts.BatchSourceTypeBatchSelect, mainSourceBatchNo: "OUT20260803MAIN", want: "OUT20260803MAIN", }, { name: "batch selection repairs self reference from main source batch", sourceType: consts.BatchSourceTypeBatchSelect, mainSourceBatchNo: "OUT20260803MAIN", infoSourceBatchNo: "REN20260803161502D52JJU6B", want: "OUT20260803MAIN", }, { name: "legacy self reference is normalized to empty", sourceType: consts.BatchSourceTypeBatchSelect, mainSourceBatchNo: "REN20260803161502D52JJU6B", want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { info := map[string]any{ "customerAgentId": 200, "packageCode": "PKG001", "packageName": "Year package", "renewUnitPrice": 10.25, "quantity": 1, "durationMonths": 12, "offlineContractCompleted": true, } if tt.infoSourceBatchNo != "" { info["sourceBatchNo"] = tt.infoSourceBatchNo } main := &rc.BatchMainRow{ BatchNo: "REN20260803161502D52JJU6B", BatchType: consts.BatchTypeRenew, SourceType: tt.sourceType, SourceBatchNo: tt.mainSourceBatchNo, DeptId: 100, CreatedDept: 100, CreatedBy: 1, TenantId: "000000", BatchInfo: gjson.New(info), } contract, err := ParseExecutionContract(main) if err != nil { t.Fatalf("ParseExecutionContract returned error: %v", err) } if contract.SourceBatchNo != tt.want { t.Fatalf("SourceBatchNo = %q, want %q", contract.SourceBatchNo, tt.want) } }) } } func TestParseExecutionContractRejectsMissingConfirmedFields(t *testing.T) { base := validExecutionContract() cases := []struct { name string mutate func(*ExecutionContract) }{ {name: "customer", mutate: func(c *ExecutionContract) { c.CustomerAgentID = 0 }}, {name: "duration", mutate: func(c *ExecutionContract) { c.DurationMonths = 0 }}, {name: "quantity", mutate: func(c *ExecutionContract) { c.Quantity = 0 }}, {name: "package", mutate: func(c *ExecutionContract) { c.PackageCode = "" }}, {name: "offline contract", mutate: func(c *ExecutionContract) { c.OfflineContractCompleted = false }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := *base tc.mutate(&got) if err := got.Validate(); err == nil { t.Fatalf("Validate accepted missing %s", tc.name) } }) } } func TestBuildOrderDocumentsUseAggregatePurchasePeriodPrice(t *testing.T) { contract := validExecutionContract() contract.UnitPrice = 10.25 contract.Quantity = 2 contract.DurationMonths = 12 contract.GiftDurationMonths = 1 now := time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local) orderSN := "SO20260803100250ABCDEFGH" order, err := BuildOrderDO(contract, orderSN, now) if err != nil { t.Fatalf("BuildOrderDO returned error: %v", err) } if gconv.String(order.OrderSn) != orderSN { t.Fatalf("order_sn = %q, want %q", order.OrderSn, orderSN) } if gconv.Int64(order.CustomerAgentId) != 200 || gconv.Int64(order.AgentId) != 100 { t.Fatalf("order buyer/seller = %v/%v, want 200/100", order.CustomerAgentId, order.AgentId) } if gconv.Int(order.OrderType) != consts.OrderTypeCardRenew { t.Fatalf("order_type = %v, want renew", order.OrderType) } if gconv.Float64(order.TotalAmount) != 246 || gconv.Float64(order.ProductAmount) != 246 || gconv.Float64(order.PaidAmount) != 246 { t.Fatalf("order amount fields = %v/%v/%v, want 246", order.TotalAmount, order.ProductAmount, order.PaidAmount) } if order.CreatedAt == nil || !order.CreatedAt.Time.Equal(now) || order.UpdatedAt == nil || !order.UpdatedAt.Time.Equal(now) { t.Fatalf("order audit times = %v/%v, want %v", order.CreatedAt, order.UpdatedAt, now) } beginAt := time.Date(2026, 8, 15, 0, 0, 0, 0, time.Local) endAt := contract.ServiceEnd(beginAt) item, err := BuildOrderItemDO(contract, OrderProductContract{ ID: 300, Code: "PKG001", Name: "Year package", Type: 1, }, 900, orderSN, beginAt, endAt, now) if err != nil { t.Fatalf("BuildOrderItemDO returned error: %v", err) } if gconv.Float64(item.Price) != 123 { t.Fatalf("item price = %v, want totalAmount/quantity = 123", item.Price) } if gconv.Int(item.Quantity) != 2 { t.Fatalf("item quantity = %v, want 2", item.Quantity) } if item.CreatedAt == nil || !item.CreatedAt.Time.Equal(now) || item.UpdatedAt == nil || !item.UpdatedAt.Time.Equal(now) { t.Fatalf("item audit times = %v/%v, want %v", item.CreatedAt, item.UpdatedAt, now) } if got := item.ServiceEndTime.Time; !got.Equal(time.Date(2027, 9, 15, 0, 0, 0, 0, time.Local)) { t.Fatalf("service end = %s, want 2027-09-15", got) } if item.CreatedAt == nil || item.UpdatedAt == nil || !item.CreatedAt.Time.Equal(now) || !item.UpdatedAt.Time.Equal(now) { t.Fatalf("order item audit fields = %v/%v, want %v", item.CreatedAt, item.UpdatedAt, now) } var extra map[string]any if err = json.Unmarshal([]byte(gconv.String(item.ExtraSkuData)), &extra); err != nil { t.Fatalf("extra_sku_data is invalid JSON: %v", err) } if gconv.String(extra["outboundBatchNo"]) != contract.BatchNo || gconv.Int(extra["durationMonths"]) != 12 || gconv.Int(extra["giftDurationMonths"]) != 1 { t.Fatalf("extra duration = %v+%v, want 12+1", extra["durationMonths"], extra["giftDurationMonths"]) } if gconv.Float64(extra["amount"]) != 246 { t.Fatalf("extra amount = %v, want 246", extra["amount"]) } } func TestBuildOrderUsesChangeOrderType(t *testing.T) { contract := validExecutionContract() contract.BatchType = consts.BatchTypeChange order, err := BuildOrderDO(contract, "SO20260803100250CHANGE01", time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local)) if err != nil { t.Fatalf("BuildOrderDO returned error: %v", err) } if gconv.Int(order.OrderType) != consts.OrderTypeCardChange { t.Fatalf("order_type = %v, want change", order.OrderType) } } func TestBuildOrderUsesBalancePaymentSnapshot(t *testing.T) { contract := validExecutionContract() contract.OfflineContractCompleted = false contract.PaymentMode = "balance" contract.PayType = consts.PayTypeBalance contract.PayAmount = 120 contract.BalanceAmount = 120 contract.BalanceDetailSerialNo = "RENPAYREN20260803100250ABCDEFGH" contract.UnitPrice = 10 contract.Quantity = 1 contract.DurationMonths = 12 order, err := BuildOrderDO(contract, "SO20260803100250ABCDEFGH", time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local)) if err != nil { t.Fatalf("BuildOrderDO returned error: %v", err) } if gconv.Int(order.PayType) != consts.PayTypeBalance { t.Fatalf("pay_type = %v, want balance", order.PayType) } if gconv.String(order.PayOrderSn) != contract.BalanceDetailSerialNo { t.Fatalf("pay_order_sn = %q, want %q", order.PayOrderSn, contract.BalanceDetailSerialNo) } if gconv.Float64(order.Balance) != 120 || gconv.Float64(order.OfflinePaidAmount) != 0 { t.Fatalf("balance/offline = %v/%v, want 120/0", order.Balance, order.OfflinePaidAmount) } } func TestBalancePaymentSnapshotMustMatchTotalAmount(t *testing.T) { contract := validExecutionContract() contract.OfflineContractCompleted = false contract.PaymentMode = "balance" contract.PayType = consts.PayTypeBalance contract.PayAmount = 1 contract.BalanceAmount = 1 contract.BalanceDetailSerialNo = "RENPAYREN20260803100250ABCDEFGH" if err := contract.Validate(); err == nil { t.Fatal("Validate accepted balance amount that does not match total amount") } } func TestBuildOrderRejectsEmptyOrderNumber(t *testing.T) { if _, err := BuildOrderDO(validExecutionContract(), "", time.Now()); err == nil { t.Fatal("BuildOrderDO accepted an empty order number") } } func TestBuildRenewCardUpdateOnlySetsRenewFields(t *testing.T) { contract := validExecutionContract() endAt := time.Date(2027, 8, 3, 0, 0, 0, 0, time.Local) now := time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local) update, err := BuildRenewCardUpdate(contract, endAt, consts.PackageStatusEffective, now) if err != nil { t.Fatalf("BuildRenewCardUpdate returned error: %v", err) } if update.FlowPkgEndAt == nil || !update.FlowPkgEndAt.Time.Equal(endAt) { t.Fatalf("flow_pkg_end_at = %v, want %v", update.FlowPkgEndAt, endAt) } if gconv.Int(update.PackageStatus) != consts.PackageStatusEffective || gconv.Int64(update.UpdatedBy) != contract.OperatorUserID { t.Fatalf("package_status/updated_by = %v/%v", update.PackageStatus, update.UpdatedBy) } if update.UpdatedAt == nil || !update.UpdatedAt.Time.Equal(now) { t.Fatalf("updated_at = %v, want %v", update.UpdatedAt, now) } assertOnlyRenewCardFieldsSet(t, update) } func validExecutionContract() *ExecutionContract { return &ExecutionContract{ BatchNo: "REN20260803100250ABCDEFGH", BatchType: consts.BatchTypeRenew, TenantID: "000000", CustomerAgentID: 200, SellerAgentID: 100, CreatedDeptID: 100, OperatorUserID: 1, PackageCode: "PKG001", PackageName: "Year package", UnitPrice: 10, Quantity: 1, DurationMonths: 12, GiftDurationMonths: 0, OfflineContractCompleted: true, } } func assertOnlyRenewCardFieldsSet(t *testing.T, update *do.TSimCardInfo) { t.Helper() allowed := map[string]bool{"FlowPkgEndAt": true, "PackageStatus": true, "UpdatedBy": true, "UpdatedAt": true} value := reflect.ValueOf(update).Elem() typ := value.Type() for i := 0; i < value.NumField(); i++ { fieldType := typ.Field(i) if fieldType.Name == "Meta" || allowed[fieldType.Name] { continue } field := value.Field(i) switch field.Kind() { case reflect.Interface, reflect.Pointer, reflect.Map, reflect.Slice: if !field.IsNil() { t.Fatalf("unexpected renew card field set: %s", fieldType.Name) } } } }