chore: 初始开源发布

iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。

- Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件)
- 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、
  库名 xiujie_admin → iotlcm、哨兵名占位
- 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异,
  不破坏「校验 MSISDN 格式」等测试断言)
- 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像
  digest 记录
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23),
  移除写死的 git 凭据
- README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0
  LICENSE,LF 行尾 + .gitattributes

已知:3 个测试包在源仓同一提交上同样失败(client/upstream、
service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
This commit is contained in:
MiFaZhan
2026-10-22 16:37:56 +08:00
commit 2bec257289
361 changed files with 71524 additions and 0 deletions
+505
View File
@@ -0,0 +1,505 @@
package renewchange
import (
"context"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer"
)
// ChangeExecutor executes a confirmed batch_type=102 change as one business
// transaction. The transaction includes old subscription cleanup, the new
// order/subscriptions, card reset, detail success, and batch success.
type ChangeExecutor struct {
repo *Repository
nowFunc NowFunc
}
func NewChangeExecutor(repo *Repository) *ChangeExecutor {
if repo == nil {
repo = NewRepository()
}
return &ChangeExecutor{repo: repo, nowFunc: timeNow}
}
func (c *ChangeExecutor) SetNowFunc(now NowFunc) { c.nowFunc = now }
func (c *ChangeExecutor) Execute(ctx context.Context, batchNo string) error {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return gerror.New("change batch number is empty")
}
main, err := c.repo.LoadProcessingBatch(ctx, batchNo, consts.BatchTypeChange)
if err != nil {
return err
}
details, err := c.repo.LoadRunningDetails(ctx, batchNo, consts.BatchTypeChange)
if err != nil {
return gerror.Wrap(err, "load change details failed")
}
if len(details) == 0 {
return c.finalizeFailure(ctx, main, "change batch has no runnable details")
}
contract, err := ParseExecutionContract(main)
if err != nil {
return c.finalizeFailure(ctx, main, err.Error())
}
if contract.BatchType != consts.BatchTypeChange {
return c.finalizeFailure(ctx, main, "change execution contract has invalid batch type")
}
now := pnow(c.nowFunc)
orderSN, skipped, err := c.apply(ctx, main, contract, details, now)
if err != nil {
return c.finalizeFailure(ctx, main, err.Error())
}
if skipped {
return nil
}
packagecustomer.PublishSyncForDeptsSafe(ctx, main.TenantId, main.CreatedBy, []int64{contract.CustomerAgentID}, "change", main.BatchNo)
rows, snapshotErr := c.repo.ResolveResultSnapshot(ctx, main.BatchNo, main.BatchType)
if snapshotErr == nil {
_ = c.repo.WriteResultFileSafe(ctx, main.BatchNo, main.BatchType, main.TenantId, main.CreatedDept, main.CreatedBy, rows)
}
g.Log().Infof(ctx, "change batch success, batchNo:%s, orderSn:%s, tenant:%s", main.BatchNo, orderSN, main.TenantId)
return nil
}
// apply is deliberately the only success transaction. A duplicate delivery
// that reaches this method after a completed transaction is safely ignored.
func (c *ChangeExecutor) apply(ctx context.Context, main *rc.BatchMainRow, contract *ExecutionContract, details []*rc.BatchDetailRow, now time.Time) (orderSN string, skipped bool, err error) {
iccids := uniqueIccidsFromDetails(details)
if len(iccids) != len(details) {
return "", false, gerror.New("change batch contains duplicate or empty ICCID")
}
if contract.Quantity != len(iccids) {
return "", false, gerror.Newf("change contract quantity mismatch: contract=%d actual=%d", contract.Quantity, len(iccids))
}
err = c.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
lockedMain, lockErr := c.repo.Tables().LockChangeBatch(ctx, tx, main.BatchNo)
if lockErr != nil {
return lockErr
}
if lockedMain.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
skipped = true
return nil
}
lockedDetails, lockErr := c.repo.Tables().LockChangeDetails(ctx, tx, main.BatchNo)
if lockErr != nil {
return lockErr
}
if err = validateRunningChangeDetails(lockedDetails, details); err != nil {
return err
}
cards, lockErr := c.repo.Tables().LockCardsByIccids(ctx, tx, iccids)
if lockErr != nil {
return lockErr
}
if len(cards) != len(iccids) {
return gerror.New("some change cards no longer exist")
}
bindRows, lockErr := c.repo.Tables().LockChangeBinds(ctx, tx, iccids)
if lockErr != nil {
return lockErr
}
currentBinds, futureBinds, lockErr := resolveCurrentSubscriptions(bindRows, iccids)
if lockErr != nil {
return lockErr
}
oldPackageCode := readChangeOldPackageCode(main.BatchInfo)
if oldPackageCode == "" {
return gerror.New("change contract old package is missing")
}
newPackage, loadErr := c.repo.Tables().LoadChangePackage(ctx, tx, contract.TenantID, contract.PackageCode)
if loadErr != nil {
return loadErr
}
oldPackage, loadErr := c.repo.Tables().LoadChangePackage(ctx, tx, contract.TenantID, oldPackageCode)
if loadErr != nil {
return loadErr
}
if newPackage.Enabled != 1 || oldPackage.Enabled != 1 {
return gerror.New("change package is disabled")
}
if newPackage.PackageType != oldPackage.PackageType {
return gerror.New("new package type differs from current package")
}
if !containsPackageCode(oldPackage.ChangeablePackageCodes, contract.PackageCode) {
return gerror.New("new package is not in current package changeable range")
}
durationOptions, durationErr := changeDurationOptions(newPackage.RenewableDurationConfig)
if durationErr != nil {
return gerror.Wrap(durationErr, "load target package change duration options failed")
}
if !rc.ContainsInt(durationOptions, contract.DurationMonths) {
return gerror.Newf("change duration %d months is not allowed for target package", contract.DurationMonths)
}
customer, loadErr := c.repo.Tables().LoadChangeDept(ctx, tx, contract.TenantID, contract.CustomerAgentID)
if loadErr != nil {
return loadErr
}
if customer.DeptId != contract.SellerAgentID && customer.ParentId != contract.SellerAgentID {
return gerror.New("change customer is outside the operator scope")
}
sourceBatchNo := contract.SourceBatchNo
for _, iccid := range iccids {
card := cards[iccid]
current := currentBinds[iccid]
var expectedMSISDN, expectedIMSI string
if detail := findDetailByICCID(lockedDetails, iccid); detail != nil && detail.Snapshot != nil {
expectedMSISDN = strings.TrimSpace(detail.Snapshot.Get("msisdn").String())
expectedIMSI = strings.TrimSpace(detail.Snapshot.Get("imsi").String())
}
if err = validateChangeCard(card, current, contract, iccid, oldPackageCode, sourceBatchNo, expectedMSISDN, expectedIMSI); err != nil {
return gerror.Wrapf(err, "validate change card failed, iccid:%s", iccid)
}
for _, future := range futureBinds[iccid] {
if future == nil || future.TenantId != contract.TenantID || future.DeptId != contract.CustomerAgentID {
return gerror.Newf("future change subscription is inconsistent: %s", iccid)
}
}
}
currentList := make([]*rc.SimCardBindPkg, 0, len(iccids))
currentOrderCodes := make([]string, 0, len(iccids))
currentOrderQuantities := make(map[string]int)
for _, iccid := range iccids {
currentList = append(currentList, currentBinds[iccid])
if orderCode := strings.TrimSpace(currentBinds[iccid].OrderCode); orderCode != "" {
currentOrderCodes = append(currentOrderCodes, orderCode)
currentOrderQuantities[orderCode]++
}
}
if err = c.repo.Tables().ArchiveChangeBinds(ctx, tx, currentList, consts.EffectiveStatusTerminated, gtime.NewFromTime(now), main.BatchNo, contract.OperatorUserID); err != nil {
return err
}
currentSubscriptionNos := subscriptionNos(currentList)
if affected, deleteErr := c.repo.Tables().DeleteChangeBinds(ctx, tx, currentSubscriptionNos); deleteErr != nil {
return deleteErr
} else if int(affected) != len(currentSubscriptionNos) {
return gerror.New("current change subscriptions changed concurrently")
}
if err = c.repo.Tables().UpdateChangeOrdersAfterRemoval(ctx, tx, currentOrderCodes, main.BatchNo, "lastUnsubscribe", currentOrderQuantities, gtime.NewFromTime(now), contract.OperatorUserID); err != nil {
return err
}
futureList := flattenFutureBinds(futureBinds, iccids)
futureOrderCodes := make([]string, 0, len(futureList))
futureOrderQuantities := make(map[string]int)
for _, bind := range futureList {
if orderCode := strings.TrimSpace(bind.OrderCode); orderCode != "" {
futureOrderCodes = append(futureOrderCodes, orderCode)
futureOrderQuantities[orderCode]++
}
}
if err = c.repo.Tables().ArchiveChangeBinds(ctx, tx, futureList, consts.EffectiveStatusVoided, gtime.NewFromTime(now), main.BatchNo, contract.OperatorUserID); err != nil {
return err
}
futureSubscriptionNos := subscriptionNos(futureList)
if affected, deleteErr := c.repo.Tables().DeleteChangeBinds(ctx, tx, futureSubscriptionNos); deleteErr != nil {
return deleteErr
} else if int(affected) != len(futureSubscriptionNos) {
return gerror.New("future change subscriptions changed concurrently")
}
if err = c.repo.Tables().UpdateChangeOrdersAfterRemoval(ctx, tx, futureOrderCodes, main.BatchNo, "lastChange", futureOrderQuantities, gtime.NewFromTime(now), contract.OperatorUserID); err != nil {
return err
}
beginAt, resolveErr := resolveChangeBeginAt(contract, now)
if resolveErr != nil {
return resolveErr
}
endAt := contract.ServiceEnd(beginAt)
packageStatus := calculateChangePackageStatus(now, beginAt, endAt)
effectiveStatus := calculateChangeEffectiveStatus(now, beginAt, endAt)
orderSN, err = rc.NextOrderSN(now)
if err != nil {
return err
}
order, buildErr := BuildOrderDO(contract, orderSN, now)
if buildErr != nil {
return buildErr
}
orderID, insertErr := c.repo.Tables().InsertContractOrder(ctx, tx, order)
if insertErr != nil {
return gerror.Wrap(insertErr, "insert change order failed")
}
item, buildErr := BuildOrderItemDO(contract, OrderProductContract{ID: newPackage.Id, Code: newPackage.PackageCode, Name: newPackage.PackageName, Type: newPackage.PackageType}, orderID, orderSN, beginAt, endAt, now)
if buildErr != nil {
return buildErr
}
if err = c.repo.Tables().InsertContractOrderItem(ctx, tx, item); err != nil {
return gerror.Wrap(err, "insert change order item failed")
}
snapshots := make(map[int]*gjson.Json, len(lockedDetails))
for _, detail := range lockedDetails {
iccid := strings.TrimSpace(detail.ItemId)
card := cards[iccid]
newSubscriptionNo := rc.GenerateSubscriptionNo("SUB")
newBind := buildChangeBind(contract, newPackage, card, main.BatchNo, oldPackageCode, orderSN, newSubscriptionNo, effectiveStatus, beginAt, endAt, now)
if err = c.repo.Tables().InsertBind(ctx, tx, newBind); err != nil {
return gerror.Wrap(err, "insert change subscription failed")
}
if err = c.repo.Tables().UpdateChangeCard(ctx, tx, iccid, newPackage.PackageCode, packageStatus, newPackage.FlowMb, gtime.NewFromTime(beginAt), gtime.NewFromTime(endAt), contract.OperatorUserID); err != nil {
return err
}
snapshots[detail.RowNo] = buildChangeDetailSnapshot(detail, card, currentBinds[iccid], newSubscriptionNo, futureBinds[iccid], contract, oldPackageCode, orderSN, beginAt, endAt)
}
if err = c.repo.Tables().MarkChangeDetailsSuccess(ctx, tx, main.BatchNo, lockedDetails, snapshots, contract.OperatorUserID); err != nil {
return err
}
info := mergeBatchInfo(main.BatchInfo, map[string]any{
"orderSn": orderSN,
"oldPackageCode": oldPackageCode,
"newPackageCode": newPackage.PackageCode,
"newPackageName": newPackage.PackageName,
"durationMonths": contract.DurationMonths,
"giftDurationMonths": contract.GiftDurationMonths,
"effectiveMode": contract.EffectiveMode,
"packageBeginTime": formatTime(gtime.NewFromTime(beginAt)),
"packageEndTime": formatTime(gtime.NewFromTime(endAt)),
"amount": contract.TotalAmount(),
"changeTime": formatTime(gtime.NewFromTime(now)),
})
return c.repo.UpdateBatchMainInfo(ctx, tx, main.BatchNo, main.BatchType, buildChangeSuccessBatchMain(main, contract, gjson.New(info), orderSN, lockedMain.StartTime, now, len(lockedDetails)))
})
return orderSN, skipped, err
}
func buildChangeBind(contract *ExecutionContract, pkg *rc.FlowPackageInfo, card *rc.SimCardInfo, batchNo, oldPackageCode, orderSN, subscriptionNo string, effectiveStatus int, beginAt, endAt, now time.Time) *rc.SimCardBindPkg {
return &rc.SimCardBindPkg{
BatchNo: batchNo,
Iccid: card.Iccid,
Msisdn: card.Msisdn,
DeptId: contract.CustomerAgentID,
PackageType: pkg.PackageType,
PackageCode: pkg.PackageCode,
PreviousPackageCode: oldPackageCode,
BusinessType: consts.PackageBusinessTypeChange,
EffectiveStatus: effectiveStatus,
HandleTime: gtime.NewFromTime(now),
EffectiveTime: gtime.NewFromTime(beginAt),
ExpectedExpireTime: gtime.NewFromTime(endAt),
DurationMonths: contract.DurationMonths,
GiftDurationMonths: contract.GiftDurationMonths,
OrderCode: orderSN,
SubscriptionNo: subscriptionNo,
Remark: "卡变更套餐自动生成",
TenantId: contract.TenantID,
CreatedDept: contract.CreatedDeptID,
CreatedBy: contract.OperatorUserID,
CreatedAt: gtime.NewFromTime(now),
UpdatedBy: contract.OperatorUserID,
UpdatedAt: gtime.NewFromTime(now),
}
}
func buildChangeSuccessBatchMain(main *rc.BatchMainRow, contract *ExecutionContract, info *gjson.Json, orderSN string, startTime *gtime.Time, now time.Time, validCount int) *rc.BatchMainRow {
return &rc.BatchMainRow{
BatchType: main.BatchType,
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
RunResult: consts.BatchRunResultSuccess,
TotalCount: main.TotalCount,
ValidCount: validCount,
SuccessCount: validCount,
FailCount: 0,
BatchInfo: info,
OrderSn: orderSN,
StartTime: startTime,
EndTime: gtime.NewFromTime(now),
TargetDeptId: contract.CustomerAgentID,
UpdatedBy: contract.OperatorUserID,
}
}
func (c *ChangeExecutor) finalizeFailure(ctx context.Context, main *rc.BatchMainRow, reason string) error {
now := pnow(c.nowFunc)
err := c.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if err := c.repo.MarkAllDetailsFailed(ctx, tx, main.BatchNo, main.BatchType, reason, main.CreatedBy); err != nil {
return err
}
return c.repo.FinalizeBatchMain(ctx, tx, main.BatchNo, main.BatchType, &rc.BatchMainRow{
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
RunResult: consts.BatchRunResultFailed,
FailReason: reason,
TotalCount: main.TotalCount,
FailCount: main.TotalCount,
EndTime: gtime.NewFromTime(now),
UpdatedBy: main.CreatedBy,
})
})
if err != nil {
return gerror.Wrap(err, "finalize change failure failed")
}
g.Log().Warningf(ctx, "change batch failed, batchNo:%s, reason:%s, tenant:%s", main.BatchNo, reason, main.TenantId)
return nil
}
func validateRunningChangeDetails(locked, loaded []*rc.BatchDetailRow) error {
if len(locked) != len(loaded) {
return gerror.New("change detail count changed during execution")
}
for _, detail := range locked {
if detail == nil || detail.DetailStatus != consts.BatchDetailStatusRunning {
return gerror.New("change detail is no longer running")
}
}
return nil
}
func validateChangeCard(card *rc.SimCardInfo, bind *rc.SimCardBindPkg, contract *ExecutionContract, iccid, oldPackageCode, sourceBatchNo, expectedMSISDN, expectedIMSI string) error {
if contract == nil {
return gerror.New("change execution contract is missing")
}
return validateCurrentSubscription(card, bind, subscriptionValidation{
ICCID: iccid,
MSISDN: expectedMSISDN,
IMSI: expectedIMSI,
TenantID: contract.TenantID,
CustomerDeptID: contract.CustomerAgentID,
PackageCode: oldPackageCode,
SourceBatchNo: sourceBatchNo,
})
}
func findDetailByICCID(details []*rc.BatchDetailRow, iccid string) *rc.BatchDetailRow {
for _, detail := range details {
if detail != nil && strings.TrimSpace(detail.ItemId) == strings.TrimSpace(iccid) {
return detail
}
}
return nil
}
func readChangeOldPackageCode(info *gjson.Json) string {
return firstString(info, "oldPackageCode", "changePrepareSnapshot.oldPackageCode")
}
func containsPackageCode(values *gjson.Json, target string) bool {
if values == nil || values.IsNil() {
return false
}
for _, value := range values.Interfaces() {
if strings.TrimSpace(gconvString(value)) == strings.TrimSpace(target) {
return true
}
}
return false
}
func resolveChangeBeginAt(contract *ExecutionContract, now time.Time) (time.Time, error) {
if contract == nil || contract.PackageBeginTime == nil {
return time.Time{}, gerror.New("change package begin time is missing")
}
switch strings.TrimSpace(contract.EffectiveMode) {
case "immediate":
return now, nil
case "next_month":
begin := contract.PackageBeginTime.Time
if begin.Day() != 1 || begin.Hour() != 0 || begin.Minute() != 0 || begin.Second() != 0 {
return time.Time{}, gerror.New("change next month begin time is invalid")
}
return begin, nil
default:
return time.Time{}, gerror.New("change effective mode is unsupported")
}
}
func calculateChangePackageStatus(now, begin, end time.Time) int {
if begin.After(now) {
return consts.PackageStatusPending
}
if !end.After(now) {
return consts.PackageStatusExpired
}
return consts.PackageStatusEffective
}
func calculateChangeEffectiveStatus(now, begin, end time.Time) int {
if begin.After(now) {
return consts.EffectiveStatusPending
}
if !end.After(now) {
return consts.EffectiveStatusExpired
}
return consts.EffectiveStatusEffective
}
func subscriptionNos(rows []*rc.SimCardBindPkg) []string {
result := make([]string, 0, len(rows))
for _, row := range rows {
if row != nil {
result = append(result, strings.TrimSpace(row.SubscriptionNo))
}
}
return result
}
func flattenFutureBinds(future map[string][]*rc.SimCardBindPkg, iccids []string) []*rc.SimCardBindPkg {
result := make([]*rc.SimCardBindPkg, 0)
for _, iccid := range iccids {
result = append(result, future[iccid]...)
}
return result
}
func buildChangeDetailSnapshot(detail *rc.BatchDetailRow, card *rc.SimCardInfo, oldBind *rc.SimCardBindPkg, newSubscriptionNo string, voided []*rc.SimCardBindPkg, contract *ExecutionContract, oldPackageCode, orderSN string, beginAt, endAt time.Time) *gjson.Json {
data := map[string]any{}
if detail != nil && detail.Snapshot != nil {
for key, value := range detail.Snapshot.Map() {
data[key] = value
}
}
data["ICCID"] = card.Iccid
data["MSISDN"] = card.Msisdn
data["IMSI"] = card.Imsi
data["iccid"] = card.Iccid
data["msisdn"] = card.Msisdn
data["imsi"] = card.Imsi
data["oldPackageCode"] = oldPackageCode
data["newPackageCode"] = contract.PackageCode
data["newPackageName"] = contract.PackageName
data["oldSubscriptionNo"] = oldBind.SubscriptionNo
data["oldOrderSn"] = oldBind.OrderCode
data["newSubscriptionNo"] = newSubscriptionNo
data["newOrderSn"] = orderSN
data["durationMonths"] = contract.DurationMonths
data["giftDurationMonths"] = contract.GiftDurationMonths
data["effectiveMode"] = contract.EffectiveMode
data["changeUnitPrice"] = contract.UnitPrice
data["amount"] = roundAmount(contract.UnitPrice * float64(contract.DurationMonths))
data["newEffectiveTime"] = formatTime(gtime.NewFromTime(beginAt))
data["newExpectedExpireTime"] = formatTime(gtime.NewFromTime(endAt))
data["oldActualExpireTime"] = formatTime(gtime.Now())
voidedNos := make([]string, 0, len(voided))
for _, row := range voided {
if row != nil {
voidedNos = append(voidedNos, row.SubscriptionNo)
}
}
data["voidedSubscriptionNos"] = voidedNos
return gjson.New(data)
}
func gconvString(value any) string {
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
+115
View File
@@ -0,0 +1,115 @@
package renewchange
import (
"testing"
"time"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
func TestResolveCurrentSubscriptionsSelectsEffectiveAndFuturePending(t *testing.T) {
rows := []*rc.SimCardBindPkg{
{Iccid: "8986001", SubscriptionNo: "SUB-E", EffectiveStatus: consts.EffectiveStatusEffective},
{Iccid: "8986001", SubscriptionNo: "SUB-P", EffectiveStatus: consts.EffectiveStatusPending},
}
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
if err != nil {
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
}
if current["8986001"].SubscriptionNo != "SUB-E" {
t.Fatalf("current subscription = %q, want SUB-E", current["8986001"].SubscriptionNo)
}
if len(future["8986001"]) != 1 || future["8986001"][0].SubscriptionNo != "SUB-P" {
t.Fatalf("future subscriptions = %+v, want SUB-P", future["8986001"])
}
}
func TestResolveCurrentSubscriptionsRejectsDuplicateCurrentSubscriptions(t *testing.T) {
rows := []*rc.SimCardBindPkg{
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
}
if _, _, err := resolveCurrentSubscriptions(rows, []string{"8986001"}); err == nil {
t.Fatal("resolveCurrentSubscriptions() error = nil, want duplicate-current error")
}
}
func TestResolveChangeBeginAtUsesConfirmedNextMonthContract(t *testing.T) {
now := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
confirmed := time.Date(2026, 9, 1, 0, 0, 0, 0, time.Local)
contract := &ExecutionContract{
EffectiveMode: "next_month",
PackageBeginTime: gtime.NewFromTime(confirmed),
}
got, err := resolveChangeBeginAt(contract, now)
if err != nil {
t.Fatalf("resolveChangeBeginAt() error = %v", err)
}
if !got.Equal(confirmed) {
t.Fatalf("begin time = %v, want %v", got, confirmed)
}
}
func TestResolveChangeBeginAtUsesExecutionTimeForImmediate(t *testing.T) {
now := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
contract := &ExecutionContract{
EffectiveMode: "immediate",
PackageBeginTime: gtime.NewFromTime(now.Add(-time.Minute)),
}
got, err := resolveChangeBeginAt(contract, now)
if err != nil {
t.Fatalf("resolveChangeBeginAt() error = %v", err)
}
if !got.Equal(now) {
t.Fatalf("begin time = %v, want %v", got, now)
}
}
func TestChangeAmountAndServiceEndUseConfirmedDurationAndGift(t *testing.T) {
contract := &ExecutionContract{UnitPrice: 8.5, Quantity: 2, DurationMonths: 12, GiftDurationMonths: 2}
if got := contract.TotalAmount(); got != 204 {
t.Fatalf("TotalAmount() = %v, want 204", got)
}
begin := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
wantEnd := begin.AddDate(0, 14, 0)
if got := contract.ServiceEnd(begin); !got.Equal(wantEnd) {
t.Fatalf("ServiceEnd() = %v, want %v", got, wantEnd)
}
}
func TestBuildChangeBindAndSuccessBatchKeepConfirmedContractAndAuditFields(t *testing.T) {
contract := validExecutionContract()
contract.BatchType = consts.BatchTypeChange
contract.UnitPrice = 8.5
contract.Quantity = 2
contract.DurationMonths = 12
contract.GiftDurationMonths = 2
now := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
begin := now.AddDate(0, 1, 0)
end := contract.ServiceEnd(begin)
pkg := &rc.FlowPackageInfo{Id: 300, PackageCode: contract.PackageCode, PackageName: "Changed package", PackageType: 1}
card := &rc.SimCardInfo{Iccid: "8986001", Msisdn: "13800000000"}
bind := buildChangeBind(contract, pkg, card, contract.BatchNo, "PKG-OLD", "SO-NEW", "SUB-NEW", consts.EffectiveStatusPending, begin, end, now)
if bind.CreatedAt == nil || bind.UpdatedAt == nil || !bind.CreatedAt.Time.Equal(now) || !bind.UpdatedAt.Time.Equal(now) {
t.Fatalf("subscription audit fields = %v/%v, want %v", bind.CreatedAt, bind.UpdatedAt, now)
}
if bind.DurationMonths != 12 || bind.GiftDurationMonths != 2 || bind.PackageCode != contract.PackageCode {
t.Fatalf("subscription contract = package %q, duration %d+%d", bind.PackageCode, bind.DurationMonths, bind.GiftDurationMonths)
}
if bind.BusinessType != consts.PackageBusinessTypeChange {
t.Fatalf("subscription business type = %d, want %d", bind.BusinessType, consts.PackageBusinessTypeChange)
}
if !bind.EffectiveTime.Time.Equal(begin) || !bind.ExpectedExpireTime.Time.Equal(end) {
t.Fatalf("subscription service period = %v/%v, want %v/%v", bind.EffectiveTime, bind.ExpectedExpireTime, begin, end)
}
main := &rc.BatchMainRow{BatchType: consts.BatchTypeChange, TotalCount: 2, StartTime: gtime.NewFromTime(now.Add(-time.Minute))}
batch := buildChangeSuccessBatchMain(main, contract, nil, "SO-NEW", main.StartTime, now, 2)
if batch.TargetDeptId != contract.CustomerAgentID {
t.Fatalf("target_dept_id = %d, want %d", batch.TargetDeptId, contract.CustomerAgentID)
}
}
+9
View File
@@ -0,0 +1,9 @@
package renewchange
// roundAmount 四舍五入到分。
func roundAmount(value float64) float64 {
if value < 0 {
return 0
}
return float64(int(value*100+0.5)) / 100
}
@@ -0,0 +1,660 @@
package renewchange
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
"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"
)
// ExecutionContract is the confirmed renew/change contract persisted in
// batch_main.batch_info. DurationMonths is the actual persisted service
// duration in months; DurationDisplayValue is the value selected by the user.
type ExecutionContract struct {
BatchNo string
BatchType int
SourceBatchNo string
TenantID string
CustomerAgentID int64
SellerAgentID int64
CreatedDeptID int64
OperatorUserID int64
PackageCode string
PackageName string
PriceSource string
AgentDefaultPriceID int64
TargetPriceID int64
UnitPrice float64
Quantity int
BillingMethod int
RenewDurationUnit string
RenewDurationUnitText string
DurationDisplayValue int
DurationMonths int
GiftDurationMonths int
OfflineContractCompleted bool
PaymentMode string
PayType int
PayStatus int
PayAmount float64
BalanceAmount float64
OfflinePaidAmount float64
BalanceDetailSerialNo string
PaymentTime string
PayerDeptID int64
PayerUserID int64
BuyerNote string
AdminNote string
Remark string
EffectiveMode string
RenewDate string
PackageBeginTime *gtime.Time
}
type OrderProductContract struct {
ID int64
Code string
Name string
Type int
}
// ParseExecutionContract reads the canonical top-level contract and falls
// back to the prepare snapshots only for batches created before the flat
// contract migration.
func ParseExecutionContract(main *rc.BatchMainRow) (*ExecutionContract, error) {
if main == nil {
return nil, gerror.New("batch main cannot be nil")
}
if main.BatchType != consts.BatchTypeRenew && main.BatchType != consts.BatchTypeChange {
return nil, gerror.Newf("unsupported renew/change batch type: %d", main.BatchType)
}
if main.BatchInfo == nil || main.BatchInfo.IsNil() {
return nil, gerror.New("confirmed batch contract is missing")
}
var snapshotPath, packageCodeField, packageNameField, unitPriceField string
if main.BatchType == consts.BatchTypeRenew {
snapshotPath = "priceSnapshot"
packageCodeField = "packageCode"
packageNameField = "packageName"
unitPriceField = "renewUnitPrice"
} else {
snapshotPath = "changePriceSnapshot"
packageCodeField = "newPackageCode"
packageNameField = "newPackageName"
unitPriceField = "changeUnitPrice"
}
info := main.BatchInfo
duration := executionDurationFromInfo(info, snapshotPath)
if main.BatchType == consts.BatchTypeRenew {
duration = normalizeRenewExecutionDuration(info, duration)
}
contract := &ExecutionContract{
BatchNo: strings.TrimSpace(main.BatchNo),
BatchType: main.BatchType,
SourceBatchNo: executionSourceBatchNo(main, info),
TenantID: strings.TrimSpace(main.TenantId),
CustomerAgentID: firstInt64(info, "customerAgentId", snapshotPath+".customerAgentId"),
SellerAgentID: main.DeptId,
CreatedDeptID: main.CreatedDept,
OperatorUserID: main.CreatedBy,
PackageCode: firstString(info, packageCodeField, snapshotPath+"."+packageCodeField),
PackageName: firstString(info, packageNameField, snapshotPath+"."+packageNameField),
PriceSource: firstString(info, "priceSource", snapshotPath+".priceSource"),
AgentDefaultPriceID: firstInt64(info, "agentDefaultPriceId", snapshotPath+".agentDefaultPriceId"),
TargetPriceID: firstInt64(info, "targetPriceId", snapshotPath+".targetPriceId"),
Quantity: firstInt(info, "quantity"),
BillingMethod: duration.billingMethod,
RenewDurationUnit: duration.unit,
RenewDurationUnitText: duration.unitText,
DurationDisplayValue: duration.displayValue,
DurationMonths: duration.actualMonths,
GiftDurationMonths: duration.giftMonths,
OfflineContractCompleted: info.Get("offlineContractCompleted").Bool(),
PaymentMode: firstString(info, "paymentMode", "paymentSnapshot.paymentMode"),
PayType: firstInt(info, "payType", "paymentSnapshot.payType"),
PayStatus: firstInt(info, "payStatus", "paymentSnapshot.payStatus"),
PayAmount: firstAmount(info, "payAmount", "paymentSnapshot.payAmount"),
BalanceAmount: firstAmount(info, "balanceAmount", "paymentSnapshot.balanceAmount"),
OfflinePaidAmount: firstAmount(info, "offlinePaidAmount", "paymentSnapshot.offlinePaidAmount"),
BalanceDetailSerialNo: firstString(info, "balanceDetailSerialNo", "paymentSnapshot.balanceDetailSerialNo"),
PaymentTime: firstString(info, "paymentTime", "paymentSnapshot.paymentTime"),
PayerDeptID: firstInt64(info, "payerDeptId", "paymentSnapshot.payerDeptId"),
PayerUserID: firstInt64(info, "payerUserId", "paymentSnapshot.payerUserId"),
BuyerNote: strings.TrimSpace(info.Get("buyerNote").String()),
AdminNote: strings.TrimSpace(info.Get("adminNote").String()),
Remark: strings.TrimSpace(info.Get("remark").String()),
EffectiveMode: strings.TrimSpace(info.Get("effectiveMode").String()),
RenewDate: strings.TrimSpace(info.Get("renewDate").String()),
}
if value, ok := firstFloat64(info, unitPriceField, snapshotPath+"."+unitPriceField); ok {
contract.UnitPrice = value
} else {
contract.UnitPrice = -1
}
if contract.SellerAgentID <= 0 {
contract.SellerAgentID = main.CreatedDept
}
if contract.CreatedDeptID <= 0 {
contract.CreatedDeptID = contract.SellerAgentID
}
contract.normalizePaymentContract()
if raw := strings.TrimSpace(info.Get("packageBeginTime").String()); raw != "" {
beginAt, err := parseExecutionTime(raw)
if err != nil {
return nil, gerror.Wrap(err, "parse package begin time failed")
}
contract.PackageBeginTime = beginAt
}
if err := contract.Validate(); err != nil {
return nil, err
}
return contract, nil
}
func executionSourceBatchNo(main *rc.BatchMainRow, info *gjson.Json) string {
if main == nil || main.SourceType != consts.BatchSourceTypeBatchSelect {
return ""
}
sourceBatchNo := firstString(info, "sourceBatchNo", "changePrepareSnapshot.sourceBatchNo")
if sourceBatchNo == "" || sourceBatchNo == strings.TrimSpace(main.BatchNo) {
sourceBatchNo = strings.TrimSpace(main.SourceBatchNo)
}
if sourceBatchNo == strings.TrimSpace(main.BatchNo) {
return ""
}
return sourceBatchNo
}
type executionDurationContract struct {
billingMethod int
unit string
unitText string
displayValue int
actualMonths int
giftMonths int
}
func executionDurationFromInfo(info *gjson.Json, snapshotPath string) executionDurationContract {
rawDurationMonths := firstInt(info, "durationMonths")
displayValue := firstInt(info, "durationValue", "durationYears")
hasDisplayValue := hasJSONValue(info, "durationValue") || hasJSONValue(info, "durationYears")
if displayValue <= 0 {
displayValue = rawDurationMonths
}
billingMethod := firstInt(info, "billingMethod", snapshotPath+".billingMethod")
unit := firstString(info, "renewDurationUnit", snapshotPath+".renewDurationUnit")
if strings.TrimSpace(unit) == "" {
if hasDisplayValue {
unit = renewDurationUnitForBillingMethod(billingMethod)
} else {
unit = consts.RenewDurationUnitMonth
}
}
unit = consts.NormalizeRenewDurationUnit(unit)
unitText := firstString(info, "renewDurationUnitText", snapshotPath+".renewDurationUnitText")
if strings.TrimSpace(unitText) == "" {
unitText = consts.RenewDurationUnitText(unit)
}
actualMonths := firstInt(info, "actualDurationMonths")
if actualMonths <= 0 {
if hasDisplayValue {
actualMonths = consts.DurationValueToMonths(unit, displayValue)
} else {
actualMonths = rawDurationMonths
}
}
giftMonths := firstInt(info, "actualGiftDurationMonths")
if giftMonths <= 0 {
giftMonths = firstInt(info, "giftDurationMonths")
}
if hasDisplayValue && giftMonths > 0 && firstInt(info, "actualGiftDurationMonths") <= 0 {
giftMonths = consts.DurationValueToMonths(unit, giftMonths)
}
return executionDurationContract{
billingMethod: billingMethod,
unit: unit,
unitText: unitText,
displayValue: displayValue,
actualMonths: actualMonths,
giftMonths: giftMonths,
}
}
func normalizeRenewExecutionDuration(info *gjson.Json, duration executionDurationContract) executionDurationContract {
displayValue := firstInt(info, "durationValue", "durationYears")
if displayValue <= 0 {
displayValue = normalizeLegacyRenewDurationValue(firstInt(info, "durationMonths"))
}
if displayValue <= 0 {
return duration
}
duration.unit = consts.RenewDurationUnitYear
duration.unitText = consts.RenewDurationUnitText(duration.unit)
duration.displayValue = displayValue
duration.actualMonths = consts.DurationValueToMonths(duration.unit, displayValue)
return duration
}
func (c *ExecutionContract) Validate() error {
if c == nil {
return gerror.New("execution contract cannot be nil")
}
if strings.TrimSpace(c.BatchNo) == "" {
return gerror.New("execution contract batch number is missing")
}
if c.BatchType != consts.BatchTypeRenew && c.BatchType != consts.BatchTypeChange {
return gerror.New("execution contract batch type is invalid")
}
if strings.TrimSpace(c.TenantID) == "" {
return gerror.New("execution contract tenant id is missing")
}
if c.CustomerAgentID <= 0 || c.SellerAgentID <= 0 || c.CreatedDeptID <= 0 || c.OperatorUserID <= 0 {
return gerror.New("execution contract organization or operator is incomplete")
}
if strings.TrimSpace(c.PackageCode) == "" || strings.TrimSpace(c.PackageName) == "" {
return gerror.New("execution contract package is incomplete")
}
if c.UnitPrice < 0 {
return gerror.New("execution contract unit price is missing")
}
if c.Quantity <= 0 {
return gerror.New("execution contract quantity must be greater than zero")
}
if c.DurationDisplayValue <= 0 {
c.DurationDisplayValue = c.DurationMonths
}
if c.DurationMonths <= 0 {
return gerror.New("execution contract duration months must be greater than zero")
}
if c.GiftDurationMonths < 0 {
return gerror.New("execution contract gift duration months cannot be negative")
}
c.normalizePaymentContract()
switch strings.TrimSpace(c.PaymentMode) {
case "offline":
if !c.OfflineContractCompleted {
return gerror.New("execution contract offline confirmation is missing")
}
case "balance":
if c.PayType != consts.PayTypeBalance {
return gerror.New("execution contract balance pay type is invalid")
}
if c.PayAmount < 0 || c.BalanceAmount < 0 {
return gerror.New("execution contract balance amount is invalid")
}
totalAmount := roundAmount(c.TotalAmount())
if roundAmount(c.PayAmount) != totalAmount || roundAmount(c.BalanceAmount) != totalAmount {
return gerror.New("execution contract balance payment amount does not match total amount")
}
if strings.TrimSpace(c.BalanceDetailSerialNo) == "" && c.PayAmount > 0 {
return gerror.New("execution contract balance serial number is missing")
}
default:
return gerror.New("execution contract payment mode is invalid")
}
return nil
}
func (c *ExecutionContract) normalizePaymentContract() {
if c == nil {
return
}
if strings.TrimSpace(c.PaymentMode) == "" {
if c.PayType == consts.PayTypeBalance || c.BalanceAmount > 0 || strings.TrimSpace(c.BalanceDetailSerialNo) != "" {
c.PaymentMode = "balance"
} else {
c.PaymentMode = "offline"
}
}
amount := c.TotalAmount()
if c.PayAmount <= 0 && amount > 0 {
c.PayAmount = amount
}
if c.PayStatus == 0 {
c.PayStatus = consts.PayStatusPaid
}
if c.PaymentMode == "offline" {
if c.PayType == 0 {
c.PayType = consts.PayTypeOffline
}
if c.OfflinePaidAmount <= 0 {
c.OfflinePaidAmount = amount
}
return
}
if c.PaymentMode == "balance" {
if c.PayType == 0 {
c.PayType = consts.PayTypeBalance
}
if c.BalanceAmount <= 0 {
c.BalanceAmount = amount
}
}
}
func (c *ExecutionContract) TotalAmount() float64 {
if c == nil {
return 0
}
return roundAmount(c.UnitPrice * float64(c.DisplayDurationValue()) * float64(c.Quantity))
}
func (c *ExecutionContract) ItemPrice() float64 {
if c == nil {
return 0
}
return roundAmount(c.UnitPrice * float64(c.DisplayDurationValue()))
}
func (c *ExecutionContract) DisplayDurationValue() int {
if c == nil {
return 0
}
if c.DurationDisplayValue > 0 {
return c.DurationDisplayValue
}
return c.DurationMonths
}
func (c *ExecutionContract) ServiceEnd(begin time.Time) time.Time {
if c == nil {
return begin
}
return addMonths(begin, c.DurationMonths+c.GiftDurationMonths)
}
func BuildOrderDO(contract *ExecutionContract, orderSN string, now time.Time) (*rc.OrderDO, error) {
if err := contract.Validate(); err != nil {
return nil, err
}
orderSN = strings.TrimSpace(orderSN)
if orderSN == "" {
return nil, gerror.New("order number must be generated before building the order")
}
amount := contract.TotalAmount()
balanceAmount := float64(0)
offlinePaidAmount := amount
payType := consts.PayTypeOffline
payOrderSN := ""
if contract.PaymentMode == "balance" {
balanceAmount = contract.BalanceAmount
offlinePaidAmount = 0
payType = consts.PayTypeBalance
payOrderSN = contract.BalanceDetailSerialNo
}
remark := contract.Remark
if remark == "" {
remark = "SIM card package business order"
}
payTime := gtime.NewFromTime(now)
if strings.TrimSpace(contract.PaymentTime) != "" {
if parsed, err := parseExecutionTime(strings.TrimSpace(contract.PaymentTime)); err == nil {
payTime = parsed
}
}
orderType := consts.OrderTypeCardRenew
if contract.BatchType == consts.BatchTypeChange {
orderType = consts.OrderTypeCardChange
}
return &rc.OrderDO{
OrderSn: orderSN,
ParentOrderId: int64(0),
ParentOrderSn: "",
CustomerType: consts.CustomerTypeAgent,
CustomerAgentId: contract.CustomerAgentID,
EndUserId: int64(0),
AgentId: contract.SellerAgentID,
OrderType: orderType,
OrderStatus: consts.OrderStatusCompleted,
ShippingStatus: 0,
ReturnStatus: consts.OrderReturnStatusNone,
Consignee: "",
Mobile: "",
Email: "",
Address: "",
AreaCode: "",
BuyerNote: contract.BuyerNote,
AdminNote: contract.AdminNote,
Remark: remark,
ShippingMethod: consts.ShippingMethodNone,
LogisticsId: int64(0),
LogisticsName: "",
ShippingTypeId: int64(0),
ShippingTypeName: "",
TrackingNo: "",
PayType: payType,
PayStatus: consts.PayStatusPaid,
PayOrderSn: payOrderSN,
PayTime: payTime,
TotalAmount: amount,
ProductAmount: amount,
ShippingFee: 0,
ServiceFee: 0,
InvoiceFee: 0,
CouponAmount: 0,
DiscountAmount: 0,
Balance: balanceAmount,
PaidAmount: amount,
OnlinePaidAmount: 0,
OfflinePaidAmount: offlinePaidAmount,
UnpaidAmount: 0,
UnrefundAmount: 0,
IsNeedCommission: 0,
DistributionStatus: 0,
TenantId: contract.TenantID,
CreatedDept: contract.CreatedDeptID,
CreatedBy: contract.OperatorUserID,
CreatedAt: payTime,
UpdatedBy: contract.OperatorUserID,
UpdatedAt: payTime,
}, nil
}
func BuildOrderItemDO(contract *ExecutionContract, product OrderProductContract, orderID int64, orderSN string, beginAt, endAt, now time.Time) (*rc.OrderItemDO, error) {
if err := contract.Validate(); err != nil {
return nil, err
}
if orderID <= 0 || strings.TrimSpace(orderSN) == "" {
return nil, gerror.New("order item requires a persisted order id and order number")
}
if product.ID <= 0 || strings.TrimSpace(product.Code) == "" || strings.TrimSpace(product.Name) == "" {
return nil, gerror.New("order product contract is incomplete")
}
if strings.TrimSpace(product.Code) != strings.TrimSpace(contract.PackageCode) {
return nil, gerror.New("order product does not match the confirmed package")
}
if !endAt.After(beginAt) {
return nil, gerror.New("order service end time must be after begin time")
}
skuData, err := buildSKUData(contract, product, beginAt)
if err != nil {
return nil, err
}
extraSKUData, err := buildExtraSKUData(contract, beginAt, endAt)
if err != nil {
return nil, err
}
return &rc.OrderItemDO{
OrderId: orderID,
OrderSn: strings.TrimSpace(orderSN),
CustomerType: consts.CustomerTypeAgent,
CustomerAgentId: contract.CustomerAgentID,
EndUserId: int64(0),
Price: contract.ItemPrice(),
Quantity: contract.Quantity,
ProductId: product.ID,
ProductName: product.Name,
ProductSn: product.Code,
PicThumb: "",
SkuId: int64(0),
SkuData: skuData,
DeliveryQuantity: 0,
ProductType: consts.ProductTypeCard,
IsGift: 0,
AgentId: contract.SellerAgentID,
PrepayPrice: 0,
Commission: "",
OriginPrice: contract.ItemPrice(),
PromotionData: "",
ExtraSkuData: extraSKUData,
PayMode: consts.PayModeYearMonth,
ServiceBeginTime: gtime.NewFromTime(beginAt),
ServiceEndTime: gtime.NewFromTime(endAt),
TenantId: contract.TenantID,
CreatedDept: contract.CreatedDeptID,
CreatedBy: contract.OperatorUserID,
CreatedAt: gtime.NewFromTime(now),
UpdatedBy: contract.OperatorUserID,
UpdatedAt: gtime.NewFromTime(now),
}, nil
}
// BuildRenewCardUpdate returns the intentionally narrow card update used by
// renew execution. GoFrame ignores every nil field in this generated DO.
func BuildRenewCardUpdate(contract *ExecutionContract, endAt time.Time, packageStatus int, now time.Time) (*do.TSimCardInfo, error) {
if err := contract.Validate(); err != nil {
return nil, err
}
if contract.BatchType != consts.BatchTypeRenew {
return nil, gerror.New("renew card update requires a renew contract")
}
if endAt.IsZero() {
return nil, gerror.New("renew card end time cannot be empty")
}
if now.IsZero() {
return nil, gerror.New("renew card update time cannot be empty")
}
if packageStatus < consts.PackageStatusPending || packageStatus > consts.PackageStatusExpired {
return nil, gerror.New("renew card package status is invalid")
}
return &do.TSimCardInfo{
FlowPkgEndAt: gtime.NewFromTime(endAt),
PackageStatus: packageStatus,
UpdatedBy: contract.OperatorUserID,
UpdatedAt: gtime.NewFromTime(now),
}, nil
}
func buildSKUData(contract *ExecutionContract, product OrderProductContract, beginAt time.Time) (string, error) {
durationLabel := fmt.Sprintf("%d months", contract.DurationMonths)
if contract.DurationDisplayValue > 0 && strings.TrimSpace(contract.RenewDurationUnit) != "" {
durationLabel = renewDurationLabel(contract.DurationDisplayValue, contract.RenewDurationUnit)
}
data := []map[string]any{
{"skuName": "package", "skuValue": product.Name, "packageCode": product.Code, "packageType": product.Type},
{"skuName": "quantity", "skuValue": fmt.Sprintf("%d", contract.Quantity)},
{"skuName": "effectiveTime", "skuValue": beginAt.Format("2006-01-02 15:04:05")},
{"skuName": "duration", "skuValue": durationLabel},
}
encoded, err := json.Marshal(data)
if err != nil {
return "", gerror.Wrap(err, "marshal order SKU data failed")
}
return string(encoded), nil
}
func buildExtraSKUData(contract *ExecutionContract, beginAt, endAt time.Time) (string, error) {
data := map[string]any{
"outboundBatchNo": contract.BatchNo,
"businessBatchNo": contract.BatchNo,
"offlineContractCompleted": contract.OfflineContractCompleted,
"packageBeginTime": beginAt.Format("2006-01-02 15:04:05"),
"packageEndTime": endAt.Format("2006-01-02 15:04:05"),
"durationMonths": contract.DurationMonths,
"durationValue": contract.DisplayDurationValue(),
"actualDurationMonths": contract.DurationMonths,
"durationUnit": strings.TrimSpace(contract.RenewDurationUnit),
"billingMethod": contract.BillingMethod,
"giftDurationMonths": contract.GiftDurationMonths,
"unitPrice": contract.UnitPrice,
"amount": contract.TotalAmount(),
"paymentMode": contract.PaymentMode,
"payType": contract.PayType,
"balanceAmount": contract.BalanceAmount,
"offlinePaidAmount": contract.OfflinePaidAmount,
}
if contract.BalanceDetailSerialNo != "" {
data["balanceDetailSerialNo"] = contract.BalanceDetailSerialNo
}
if contract.SourceBatchNo != "" {
data["sourceBatchNo"] = contract.SourceBatchNo
}
if contract.EffectiveMode != "" {
data["effectiveMode"] = contract.EffectiveMode
}
encoded, err := json.Marshal(data)
if err != nil {
return "", gerror.Wrap(err, "marshal order extra SKU data failed")
}
return string(encoded), nil
}
func firstString(info *gjson.Json, paths ...string) string {
for _, path := range paths {
value := strings.TrimSpace(info.Get(path).String())
if value != "" {
return value
}
}
return ""
}
func hasJSONValue(info *gjson.Json, path string) bool {
if info == nil || strings.TrimSpace(path) == "" {
return false
}
value := info.Get(path)
return value != nil && !value.IsNil()
}
func firstInt(info *gjson.Json, paths ...string) int {
return int(firstInt64(info, paths...))
}
func firstInt64(info *gjson.Json, paths ...string) int64 {
for _, path := range paths {
value := info.Get(path)
if value != nil && !value.IsNil() {
return value.Int64()
}
}
return 0
}
func firstFloat64(info *gjson.Json, paths ...string) (float64, bool) {
for _, path := range paths {
value := info.Get(path)
if value != nil && !value.IsNil() {
return value.Float64(), true
}
}
return 0, false
}
func firstAmount(info *gjson.Json, paths ...string) float64 {
value, _ := firstFloat64(info, paths...)
return roundAmount(value)
}
func parseExecutionTime(value string) (*gtime.Time, error) {
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02"} {
parsed, err := time.ParseInLocation(layout, value, time.Local)
if err == nil {
return gtime.NewFromTime(parsed), nil
}
}
return nil, gerror.Newf("invalid execution time: %s", value)
}
@@ -0,0 +1,413 @@
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)
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,495 @@
package renewchange
import (
"context"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
func TestPrepareExecutorCanReclaimStaleProcessingBatch(t *testing.T) {
now := time.Date(2026, 8, 26, 14, 0, 0, 0, time.Local)
executor := &PrepareExecutor{nowFunc: func() time.Time { return now }}
tests := []struct {
name string
main *entity.BatchMain
canRun bool
}{
{
name: "fresh processing batch",
main: &entity.BatchMain{
BatchType: consts.BatchTypePrepare,
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
StartTime: gtime.NewFromTime(now.Add(-prepareProcessingRetryAfter + time.Second)),
},
},
{
name: "stale processing batch",
main: &entity.BatchMain{
BatchType: consts.BatchTypePrepare,
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
StartTime: gtime.NewFromTime(now.Add(-prepareProcessingRetryAfter)),
},
canRun: true,
},
{
name: "missing start time",
main: &entity.BatchMain{
BatchType: consts.BatchTypePrepare,
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
},
canRun: true,
},
{
name: "terminal batch",
main: &entity.BatchMain{
BatchType: consts.BatchTypePrepare,
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := executor.CanExecuteProcessing(context.Background(), test.main)
if err != nil {
t.Fatalf("CanExecuteProcessing() error = %v", err)
}
if got != test.canRun {
t.Fatalf("CanExecuteProcessing() = %v, want %v", got, test.canRun)
}
})
}
}
func TestLoadPrepareOptionsDurationIsExplicitOnly(t *testing.T) {
opts := loadPrepareOptions(gjson.New(map[string]any{
"targetBatchType": 101,
}))
if opts.durationSet {
t.Fatalf("durationSet = true, want false")
}
if opts.durationValue != 0 {
t.Fatalf("durationValue = %d, want 0", opts.durationValue)
}
opts = loadPrepareOptions(gjson.New(map[string]any{
"durationMonths": 6,
}))
if !opts.durationSet {
t.Fatalf("durationSet = false, want true")
}
if opts.durationValue != 6 {
t.Fatalf("durationValue = %d, want 6", opts.durationValue)
}
opts = loadPrepareOptions(gjson.New(map[string]any{
"durationMonths": 12,
}))
if !opts.durationSet {
t.Fatalf("durationSet = false, want true")
}
if opts.durationValue != 1 {
t.Fatalf("durationValue = %d, want 1", opts.durationValue)
}
}
func TestChangeDurationOptionsUseMonthsAtExecutionBoundary(t *testing.T) {
got, err := changeDurationOptions(gjson.New([]int{1, 2, 3}))
if err != nil {
t.Fatalf("changeDurationOptions(years) error = %v", err)
}
if want := []int{12, 24, 36}; len(got) != len(want) {
t.Fatalf("changeDurationOptions(years) = %#v, want %#v", got, want)
} else {
for i := range want {
if got[i] != want[i] {
t.Fatalf("changeDurationOptions(years) = %#v, want %#v", got, want)
}
}
}
got, err = changeDurationOptions(gjson.New([]int{12, 24, 36}))
if err != nil {
t.Fatalf("changeDurationOptions(legacy months) error = %v", err)
}
if want := []int{12, 24, 36}; len(got) != len(want) {
t.Fatalf("changeDurationOptions(legacy months) = %#v, want %#v", got, want)
} else {
for i := range want {
if got[i] != want[i] {
t.Fatalf("changeDurationOptions(legacy months) = %#v, want %#v", got, want)
}
}
}
}
func TestMergeRowErrorsKeepsValidationReasons(t *testing.T) {
got := mergeRowErrors(
map[int]string{2: "parse failed"},
map[int]string{1: "duration not allowed", 2: "validation failed", 0: "batch failed"},
)
if got[1] != "duration not allowed" {
t.Fatalf("row 1 reason = %q", got[1])
}
if got[2] != "parse failed" {
t.Fatalf("row 2 reason = %q", got[2])
}
if got[0] != "batch failed" {
t.Fatalf("row 0 reason = %q", got[0])
}
}
func TestResolvePrepareIdentityRowsFromCardsCanonicalizesMSISDN(t *testing.T) {
rows := []*RenewRow{
{RowNo: 1, ICCID: "13800008000"},
{RowNo: 2, ICCID: "8986002", MSISDN: "bad-msisdn"},
}
rowErrors := map[int]string{}
resolvePrepareIdentityRowsFromCards(rows, rowErrors, []*rc.SimCardInfo{
{Id: 1, Iccid: "8986001", Msisdn: "13800008000", Imsi: "460001"},
{Id: 2, Iccid: "8986002", Msisdn: "13800008002", Imsi: "460002"},
})
if rows[0].ICCID != "8986001" || rows[0].MSISDN != "13800008000" || rows[0].IMSI != "460001" {
t.Fatalf("row 1 was not canonicalized: %#v", rows[0])
}
if rowErrors[1] != "" {
t.Fatalf("row 1 error = %q, want empty", rowErrors[1])
}
if rowErrors[2] == "" {
t.Fatal("row 2 should fail when ICCID and MSISDN point to different identities")
}
}
func TestCollectPrepareIccidsMarksDuplicateAfterCanonicalization(t *testing.T) {
rows := []*RenewRow{
{RowNo: 1, ICCID: "8986001"},
{RowNo: 2, ICCID: "8986001"},
}
rowErrors := map[int]string{}
got := collectPrepareIccids(rows, rowErrors)
if len(got) != 1 || got[0] != "8986001" {
t.Fatalf("iccids = %#v, want single canonical ICCID", got)
}
if rowErrors[1] == "" || rowErrors[2] == "" {
t.Fatalf("duplicate errors not marked: %#v", rowErrors)
}
}
func TestBuildPrepareInfoWritesRenewFlatContract(t *testing.T) {
got := buildPrepareInfo(nil, 101, &prepareOutcome{
customer: &customerInfo{
AgentID: 9,
DeptID: 117,
Name: "customer",
},
priceSnap: &RenewPriceSnapshot{
CustomerAgentId: 117,
PackageCode: "PKG-1",
PackageName: "package 1",
PriceSource: PriceSourceSystem,
AgentDefaultPriceId: 11,
TargetPriceId: 22,
RenewUnitPrice: 3.5,
RenewableDurationOptions: []int{1, 3, 12},
RenewBeginDate: "2026-08-31",
RenewBeginDateMin: "2026-08-31",
RenewBeginDateMax: "2026-08-31",
SameRenewBeginDate: true,
},
}, 2)
if got["customerAgentId"] != int64(117) {
t.Fatalf("customerAgentId = %v, want 117", got["customerAgentId"])
}
if got["packageCode"] != "PKG-1" {
t.Fatalf("packageCode = %v, want PKG-1", got["packageCode"])
}
if got["quantity"] != 2 {
t.Fatalf("quantity = %v, want 2", got["quantity"])
}
if got["renewUnitPrice"] != 3.5 {
t.Fatalf("renewUnitPrice = %v, want 3.5", got["renewUnitPrice"])
}
if got["priceSnapshot"] == nil {
t.Fatalf("priceSnapshot is nil")
}
if got["renewBeginDate"] != "2026-08-31" {
t.Fatalf("renewBeginDate = %v, want 2026-08-31", got["renewBeginDate"])
}
if got["sameRenewBeginDate"] != true {
t.Fatalf("sameRenewBeginDate = %v, want true", got["sameRenewBeginDate"])
}
}
func TestFillRenewBeginSnapshotSummarizesRenewBeginDate(t *testing.T) {
snapshot := &RenewPriceSnapshot{}
fillRenewBeginSnapshot(snapshot, []*RenewRow{
{RowNo: 1, OldExpireTime: gtime.NewFromStr("2026-09-10 00:00:00")},
{RowNo: 2, OldExpireTime: gtime.NewFromStr("2026-08-31 23:59:59")},
}, nil)
if snapshot.RenewBeginDate != "" {
t.Fatalf("renewBeginDate = %q, want empty for mixed dates", snapshot.RenewBeginDate)
}
if snapshot.RenewBeginDateMin != "2026-08-31" {
t.Fatalf("renewBeginDateMin = %q, want 2026-08-31", snapshot.RenewBeginDateMin)
}
if snapshot.RenewBeginDateMax != "2026-09-10" {
t.Fatalf("renewBeginDateMax = %q, want 2026-09-10", snapshot.RenewBeginDateMax)
}
if snapshot.SameRenewBeginDate {
t.Fatalf("sameRenewBeginDate = true, want false")
}
fillRenewBeginSnapshot(snapshot, []*RenewRow{
{RowNo: 1, OldExpireTime: gtime.NewFromStr("2026-10-01 00:00:00")},
{RowNo: 2, OldExpireTime: gtime.NewFromStr("2026-10-01 23:59:59")},
}, nil)
if snapshot.RenewBeginDate != "2026-10-01" {
t.Fatalf("renewBeginDate = %q, want 2026-10-01", snapshot.RenewBeginDate)
}
if !snapshot.SameRenewBeginDate {
t.Fatalf("sameRenewBeginDate = false, want true")
}
}
func TestRenewBeginDateFromExpireUsesExpireDate(t *testing.T) {
beginAt := renewBeginDateFromExpire(gtime.NewFromStr("2026-08-15 00:00:00"))
if beginAt == nil {
t.Fatal("renew begin date is nil")
}
if got := beginAt.Format("Y-m-d H:i:s"); got != "2026-08-15 23:59:59" {
t.Fatalf("renew begin date = %s, want 2026-08-15 23:59:59", got)
}
if got := addMonths(beginAt.Time, 12).Format("2006-01-02 15:04:05"); got != "2027-08-15 23:59:59" {
t.Fatalf("renew end date = %s, want 2027-08-15 23:59:59", got)
}
nextBeginAt := renewBeginDateFromExpire(gtime.NewFromStr("2027-07-31 00:00:00"))
if nextBeginAt == nil {
t.Fatal("next renew begin date is nil")
}
if got := nextBeginAt.Format("Y-m-d H:i:s"); got != "2027-07-31 23:59:59" {
t.Fatalf("next renew begin date = %s, want 2027-07-31 23:59:59", got)
}
giftBeginAt := renewBeginDateFromExpire(gtime.NewFromStr("2026-08-31 00:00:00"))
if giftBeginAt == nil {
t.Fatal("gift renew begin date is nil")
}
if got := giftBeginAt.Format("Y-m-d H:i:s"); got != "2026-08-31 23:59:59" {
t.Fatalf("gift renew begin date = %s, want 2026-08-31 23:59:59", got)
}
if got := addMonths(giftBeginAt.Time, 13).Format("2006-01-02 15:04:05"); got != "2027-09-30 23:59:59" {
t.Fatalf("renew end date with gift month = %s, want 2027-09-30 23:59:59", got)
}
}
func TestValidateRenewSameCurrentPeriod(t *testing.T) {
sameRows := []*RenewRow{
{
RowNo: 1,
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
},
{
RowNo: 2,
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
},
}
if err := validateRenewSameCurrentPeriod(sameRows, nil); err != nil {
t.Fatalf("validateRenewSameCurrentPeriod() error = %v", err)
}
diffBeginRows := []*RenewRow{
{
RowNo: 1,
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
},
{
RowNo: 2,
OldEffTime: gtime.NewFromStr("2026-02-01 00:00:00"),
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
},
}
if err := validateRenewSameCurrentPeriod(diffBeginRows, nil); err == nil {
t.Fatalf("validateRenewSameCurrentPeriod() error = nil, want begin mismatch")
}
diffExpireRows := []*RenewRow{
{
RowNo: 1,
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
},
{
RowNo: 2,
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
OldExpireTime: gtime.NewFromStr("2027-01-31 23:59:59"),
},
}
if err := validateRenewSameCurrentPeriod(diffExpireRows, nil); err == nil {
t.Fatalf("validateRenewSameCurrentPeriod() error = nil, want expire mismatch")
}
missingTimeRows := []*RenewRow{
{
RowNo: 1,
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
},
{RowNo: 2, OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00")},
}
if err := validateRenewSameCurrentPeriod(missingTimeRows, nil); err == nil {
t.Fatalf("validateRenewSameCurrentPeriod() error = nil, want missing time")
}
rowErrors := map[int]string{2: "existing validation error"}
if err := validateRenewSameCurrentPeriod(missingTimeRows, rowErrors); err != nil {
t.Fatalf("validateRenewSameCurrentPeriod() with rowErrors error = %v", err)
}
}
func TestRenewPriceOwnerUsesCurrentCustomerParent(t *testing.T) {
if got := renewPriceOwnerDeptID(&rc.SysDeptRow{DeptId: 117, ParentId: 100}); got != 100 {
t.Fatalf("price owner dept = %d, want 100", got)
}
if got := renewPriceOwnerDeptID(&rc.SysDeptRow{DeptId: 100, ParentId: 0}); got != 0 {
t.Fatalf("top-level price owner dept = %d, want 0", got)
}
}
func TestRenewExpireAfterAllowedWindow(t *testing.T) {
now := time.Date(2026, 8, 7, 16, 50, 39, 0, time.Local)
if renewExpireAfterAllowedWindow(nil, now) {
t.Fatal("nil expire time should not be rejected")
}
if renewExpireAfterAllowedWindow(gtime.NewFromTime(now.AddDate(0, 6, 0)), now) {
t.Fatal("exactly 6 months ahead should be allowed")
}
if !renewExpireAfterAllowedWindow(gtime.NewFromTime(now.AddDate(0, 6, 0).Add(time.Second)), now) {
t.Fatal("beyond 6 months should be rejected")
}
}
func TestBuildPrepareInfoWritesChangeFlatContract(t *testing.T) {
got := buildPrepareInfo(nil, 102, &prepareOutcome{
customer: &customerInfo{
DeptID: 117,
Name: "customer",
},
changeSnap: &ChangePrepareSnapshot{
SourceBatchNo: "CHG-1",
CustomerAgentId: 117,
CustomerName: "customer",
OldPackageCode: "OLD",
OldPackageName: "old package",
OldPackageType: 101,
OldPackageFlowMb: 1024,
Quantity: 1,
},
changePrice: &ChangePriceSnapshot{
CustomerAgentId: 117,
NewPackageCode: "NEW",
NewPackageName: "new package",
PriceSource: PriceSourceAgentDefault,
AgentDefaultPriceId: 33,
TargetPriceId: 44,
ChangeUnitPrice: 6.5,
RenewableDurationOptions: []int{1, 2},
},
}, 1)
if got["customerAgentId"] != int64(117) {
t.Fatalf("customerAgentId = %v, want 117", got["customerAgentId"])
}
if got["sourceBatchNo"] != "CHG-1" {
t.Fatalf("sourceBatchNo = %v, want CHG-1", got["sourceBatchNo"])
}
if got["oldPackageCode"] != "OLD" {
t.Fatalf("oldPackageCode = %v, want OLD", got["oldPackageCode"])
}
if got["newPackageCode"] != "NEW" {
t.Fatalf("newPackageCode = %v, want NEW", got["newPackageCode"])
}
if got["changeUnitPrice"] != 6.5 {
t.Fatalf("changeUnitPrice = %v, want 6.5", got["changeUnitPrice"])
}
if got["changePrepareSnapshot"] == nil || got["changePriceSnapshot"] == nil {
t.Fatalf("change snapshots are missing")
}
}
func TestBuildPrepareDetailSnapshotIncludesChangeSubscriptionContract(t *testing.T) {
out := &prepareOutcome{
rows: map[int]*RenewRow{
1: {
RowNo: 1,
ICCID: "8986001",
MSISDN: "13800000000",
IMSI: "460001",
OldPkgCode: "OLD",
OldPkgName: "old package",
OldBindSubNo: "SUB-OLD",
OldBindOrdNo: "SO-OLD",
SourceBatchNo: "OUT-1",
},
},
changePrice: &ChangePriceSnapshot{
NewPackageCode: "NEW",
NewPackageName: "new package",
},
}
snapshot := buildPrepareDetailSnapshot(&rc.BatchDetailRow{RowNo: 1}, out, 102)
want := map[string]string{
"oldPackageCode": "OLD",
"oldPackageName": "old package",
"oldSubscriptionNo": "SUB-OLD",
"oldOrderSn": "SO-OLD",
"sourceBatchNo": "OUT-1",
"newPackageCode": "NEW",
"newPackageName": "new package",
}
for key, expected := range want {
if got := snapshot.Get(key).String(); got != expected {
t.Fatalf("%s = %q, want %q", key, got, expected)
}
}
}
func TestPriceByPackageUsesAdminContractFormula(t *testing.T) {
pkg := &rc.FlowPackageInfo{
CostPrice: 10,
SysDefaultRatio: 120,
SysDefaultProfit: 1.23,
}
if got := priceByPackage(pkg, nil); got != 13.23 {
t.Fatalf("system price = %v, want 13.23", got)
}
got := priceByPackage(pkg, &rc.FlowPackagePrice{
AgentCostRatio: 80,
AgentCostProfit: 2.5,
})
if got != 10.5 {
t.Fatalf("override price = %v, want 10.5", got)
}
}
@@ -0,0 +1,146 @@
package renewchange
import (
"context"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
type renewEffectivePriceStore interface {
LoadDept(ctx context.Context, tenantID string, deptID int64) (*rc.SysDeptRow, error)
LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error)
}
type renewEffectivePriceRule struct {
PriceSource string
AgentDefaultPriceId int64
TargetPriceId int64
AgentCostRatio float64
AgentCostProfit float64
}
type renewEffectiveSalePrice struct {
BaseAgentCost float64
Rule renewEffectivePriceRule
AgentShipmentPrice float64
}
type EffectivePriceStore = renewEffectivePriceStore
type EffectivePriceRule = renewEffectivePriceRule
type EffectiveSalePrice = renewEffectiveSalePrice
type renewTxEffectivePriceStore struct {
tx gdb.TX
}
func (s renewTxEffectivePriceStore) LoadDept(ctx context.Context, tenantID string, deptID int64) (*rc.SysDeptRow, error) {
return loadRenewDeptTx(ctx, s.tx, tenantID, deptID)
}
func (s renewTxEffectivePriceStore) LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error) {
return loadRenewPriceOverrideTx(ctx, s.tx, tenantID, deptID, targetAgentID, scopeType, packageCode)
}
func ResolveEffectiveSalePrice(ctx context.Context, store EffectivePriceStore, tenantID string, ownerDeptID int64, targetDeptID int64, pkg *rc.FlowPackageInfo) (EffectiveSalePrice, error) {
return resolveRenewEffectiveSalePrice(ctx, store, tenantID, ownerDeptID, targetDeptID, pkg)
}
func resolveRenewEffectiveSalePrice(ctx context.Context, store renewEffectivePriceStore, tenantID string, ownerDeptID int64, targetDeptID int64, pkg *rc.FlowPackageInfo) (renewEffectiveSalePrice, error) {
baseCost, err := resolveRenewEffectiveBaseCost(ctx, store, tenantID, ownerDeptID, pkg, make(map[int64]struct{}))
if err != nil {
return renewEffectiveSalePrice{}, err
}
rule, err := resolveRenewEffectivePriceRule(ctx, store, tenantID, ownerDeptID, targetDeptID, pkg)
if err != nil {
return renewEffectiveSalePrice{}, err
}
return renewEffectiveSalePrice{
BaseAgentCost: baseCost,
Rule: rule,
AgentShipmentPrice: rc.RoundAmount(baseCost*rule.AgentCostRatio + rule.AgentCostProfit),
}, nil
}
func resolveRenewEffectiveBaseCost(ctx context.Context, store renewEffectivePriceStore, tenantID string, deptID int64, pkg *rc.FlowPackageInfo, visited map[int64]struct{}) (float64, error) {
if pkg == nil || strings.TrimSpace(pkg.PackageCode) == "" {
return 0, gerror.New("套餐编码不能为空")
}
if deptID <= 0 {
return rc.RoundAmount(pkg.CostPrice), nil
}
if _, ok := visited[deptID]; ok {
return 0, gerror.New("组织定价层级存在循环,无法计算生效成本")
}
visited[deptID] = struct{}{}
dept, err := store.LoadDept(ctx, tenantID, deptID)
if err != nil {
return 0, err
}
if dept == nil || dept.ParentId <= 0 {
return rc.RoundAmount(pkg.CostPrice), nil
}
parentBaseCost, err := resolveRenewEffectiveBaseCost(ctx, store, tenantID, dept.ParentId, pkg, visited)
if err != nil {
return 0, err
}
parentRule, err := resolveRenewEffectivePriceRule(ctx, store, tenantID, dept.ParentId, deptID, pkg)
if err != nil {
return 0, err
}
return rc.RoundAmount(parentBaseCost*parentRule.AgentCostRatio + parentRule.AgentCostProfit), nil
}
func resolveRenewEffectivePriceRule(ctx context.Context, store renewEffectivePriceStore, tenantID string, ownerDeptID int64, targetDeptID int64, pkg *rc.FlowPackageInfo) (renewEffectivePriceRule, error) {
rule := renewSystemPriceRule(pkg)
if ownerDeptID <= 0 {
return rule, nil
}
if targetDeptID > 0 {
targetPrice, err := store.LoadPriceOverride(ctx, tenantID, ownerDeptID, targetDeptID, PriceScopeTargetAgent, pkg.PackageCode)
if err != nil {
return rule, err
}
if targetPrice != nil {
return renewRuleFromOverride(PriceSourceTarget, targetPrice), nil
}
}
defaultPrice, err := store.LoadPriceOverride(ctx, tenantID, ownerDeptID, 0, PriceScopeAgentDefault, pkg.PackageCode)
if err != nil {
return rule, err
}
if defaultPrice != nil {
return renewRuleFromOverride(PriceSourceAgentDefault, defaultPrice), nil
}
return rule, nil
}
func renewSystemPriceRule(pkg *rc.FlowPackageInfo) renewEffectivePriceRule {
if pkg == nil {
return renewEffectivePriceRule{PriceSource: PriceSourceSystem}
}
return renewEffectivePriceRule{
PriceSource: PriceSourceSystem,
AgentCostRatio: normalizePriceRatio(pkg.SysDefaultRatio),
AgentCostProfit: pkg.SysDefaultProfit,
}
}
func renewRuleFromOverride(source string, price *rc.FlowPackagePrice) renewEffectivePriceRule {
rule := renewEffectivePriceRule{
PriceSource: source,
AgentCostRatio: normalizePriceRatio(price.AgentCostRatio),
AgentCostProfit: price.AgentCostProfit,
}
if source == PriceSourceTarget {
rule.TargetPriceId = price.Id
} else {
rule.AgentDefaultPriceId = price.Id
}
return rule
}
@@ -0,0 +1,85 @@
package renewchange
import (
"context"
"testing"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
type fakeRenewEffectivePriceStore struct {
depts map[int64]*rc.SysDeptRow
prices map[renewEffectivePriceKey]*rc.FlowPackagePrice
}
type renewEffectivePriceKey struct {
deptID int64
targetAgentID int64
scopeType int
packageCode string
}
func (s fakeRenewEffectivePriceStore) LoadDept(ctx context.Context, tenantID string, deptID int64) (*rc.SysDeptRow, error) {
return s.depts[deptID], nil
}
func (s fakeRenewEffectivePriceStore) LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error) {
return s.prices[renewEffectivePriceKey{
deptID: deptID,
targetAgentID: targetAgentID,
scopeType: scopeType,
packageCode: packageCode,
}], nil
}
func TestResolveRenewEffectiveSalePriceUsesRecursiveParentCost(t *testing.T) {
store := fakeRenewEffectivePriceStore{
depts: map[int64]*rc.SysDeptRow{
100: {DeptId: 100, ParentId: 0},
117: {DeptId: 117, ParentId: 100},
131: {DeptId: 131, ParentId: 117},
},
prices: map[renewEffectivePriceKey]*rc.FlowPackagePrice{
{deptID: 100, targetAgentID: 117, scopeType: PriceScopeTargetAgent, packageCode: "yd-100m-3y"}: {
Id: 86,
AgentCostRatio: 1,
AgentCostProfit: 4,
},
{deptID: 117, targetAgentID: 0, scopeType: PriceScopeAgentDefault, packageCode: "yd-100m-3y"}: {
Id: 91,
AgentCostRatio: 1,
AgentCostProfit: 1,
},
{deptID: 117, targetAgentID: 131, scopeType: PriceScopeTargetAgent, packageCode: "yd-100m-3y"}: {
Id: 92,
AgentCostRatio: 1,
AgentCostProfit: 5,
},
},
}
got, err := resolveRenewEffectiveSalePrice(
context.Background(),
store,
"000000",
117,
131,
&rc.FlowPackageInfo{
PackageCode: "yd-100m-3y",
CostPrice: 10,
SysDefaultRatio: 1,
},
)
if err != nil {
t.Fatalf("resolveRenewEffectiveSalePrice() error = %v", err)
}
if got.BaseAgentCost != 14 {
t.Fatalf("base agent cost = %.2f, want 14.00", got.BaseAgentCost)
}
if got.AgentShipmentPrice != 19 {
t.Fatalf("agent shipment price = %.2f, want 19.00", got.AgentShipmentPrice)
}
if got.Rule.PriceSource != PriceSourceTarget || got.Rule.TargetPriceId != 92 {
t.Fatalf("rule = %+v, want target price 92", got.Rule)
}
}
+576
View File
@@ -0,0 +1,576 @@
package renewchange
import (
"context"
"errors"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
var errRenewAlreadyHandled = errors.New("renew batch was already handled")
// RenewExecutor executes a confirmed type=101 batch. All business writes and
// the success state are committed by one database transaction.
type RenewExecutor struct {
repo *Repository
nowFunc NowFunc
}
func NewRenewExecutor(repo *Repository) *RenewExecutor {
if repo == nil {
repo = NewRepository()
}
return &RenewExecutor{repo: repo, nowFunc: timeNow}
}
func (r *RenewExecutor) SetNowFunc(now NowFunc) { r.nowFunc = now }
func (r *RenewExecutor) Execute(ctx context.Context, batchNo string) error {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return gerror.New("renew batch number is empty")
}
main, err := r.repo.LoadProcessingBatch(ctx, batchNo, consts.BatchTypeRenew)
if err != nil {
return err
}
if strings.TrimSpace(main.OrderSn) != "" {
g.Log().Warningf(ctx, "renew batch already has order, skip duplicate: batchNo=%s orderSn=%s", batchNo, main.OrderSn)
return nil
}
details, err := r.repo.LoadRunningDetails(ctx, batchNo, consts.BatchTypeRenew)
if err != nil {
return gerror.Wrap(err, "load renew details failed")
}
if len(details) == 0 {
return r.finalizeFailure(ctx, main, "renew batch has no runnable details")
}
now := pnow(r.nowFunc)
orderSN, err := r.apply(ctx, main, details, now)
if errors.Is(err, errRenewAlreadyHandled) {
return nil
}
if err != nil {
return r.finalizeFailure(ctx, main, err.Error())
}
snap, _ := r.repo.ResolveResultSnapshot(ctx, main.BatchNo, main.BatchType)
_ = r.repo.WriteResultFileSafe(ctx, main.BatchNo, main.BatchType, main.TenantId, main.CreatedDept, main.CreatedBy, snap)
g.Log().Infof(ctx, "renew batch success, batchNo=%s, orderSn=%s, tenant=%s", main.BatchNo, orderSN, main.TenantId)
return nil
}
type renewExecution struct {
detail *rc.BatchDetailRow
card *rc.SimCardInfo
bind *rc.SimCardBindPkg
begin time.Time
end time.Time
}
func (r *RenewExecutor) apply(ctx context.Context, main *rc.BatchMainRow, details []*rc.BatchDetailRow, now time.Time) (string, error) {
iccids, err := strictIccidsFromDetails(details)
if err != nil {
return "", err
}
var orderSN string
err = r.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
lockedMain, err := r.repo.Tables().LockRenewBatchMain(ctx, tx, main.BatchNo, consts.BatchTypeRenew)
if err != nil {
return err
}
if strings.TrimSpace(lockedMain.OrderSn) != "" || lockedMain.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
return errRenewAlreadyHandled
}
lockedDetails, err := r.repo.Tables().LockRenewDetails(ctx, tx, main.BatchNo, consts.BatchTypeRenew)
if err != nil {
return err
}
if err = validateRunningRenewDetails(lockedDetails, details); err != nil {
return err
}
details = lockedDetails
cards, err := r.repo.Tables().LockCardsByIccids(ctx, tx, iccids)
if err != nil {
return err
}
bindRows, err := r.repo.Tables().LockRenewBindRowsByIccids(ctx, tx, iccids)
if err != nil {
return err
}
binds, futureBinds, err := resolveCurrentSubscriptions(bindRows, iccids)
if err != nil {
return err
}
contract, err := r.ensureRenewExecutionContract(ctx, tx, lockedMain, details, cards, binds, futureBinds, now)
if err != nil {
return err
}
executions, err := validateRenewExecutions(contract, details, cards, binds, futureBinds, now)
if err != nil {
return err
}
pkg, err := r.repo.Tables().LoadRenewPackage(ctx, tx, contract.TenantID, contract.PackageCode)
if err != nil {
return err
}
orderSN, err = rc.NextOrderSN(now)
if err != nil {
return err
}
order, err := BuildOrderDO(contract, orderSN, now)
if err != nil {
return err
}
orderID, err := r.repo.Tables().InsertContractOrder(ctx, tx, order)
if err != nil {
return gerror.Wrap(err, "insert renew order failed")
}
minBegin, maxEnd := renewServiceRange(executions)
item, err := BuildOrderItemDO(contract, OrderProductContract{
ID: pkg.Id,
Code: pkg.PackageCode,
Name: pkg.PackageName,
Type: pkg.PackageType,
}, orderID, orderSN, minBegin, maxEnd, now)
if err != nil {
return err
}
if err = r.repo.Tables().InsertContractOrderItem(ctx, tx, item); err != nil {
return gerror.Wrap(err, "insert renew order item failed")
}
for _, execution := range executions {
subscriptionNo, err := generateRenewSubscriptionNo(now)
if err != nil {
return err
}
newBind := buildRenewBind(contract, pkg, execution, orderSN, subscriptionNo, now)
if err = r.repo.Tables().InsertBind(ctx, tx, newBind); err != nil {
return err
}
packageStatus := renewPackageStatus(now, execution.card.FlowPkgBeginAt, newBind.ExpectedExpireTime)
cardUpdate, err := BuildRenewCardUpdate(contract, execution.end, packageStatus, now)
if err != nil {
return err
}
if err = r.repo.Tables().UpdateRenewCard(ctx, tx, execution.card.Iccid, cardUpdate); err != nil {
return err
}
snapshot := buildRenewDetailSnapshot(execution, contract, pkg, orderSN, subscriptionNo)
if err = r.repo.MarkDetailSuccess(ctx, tx, main.BatchNo, consts.BatchTypeRenew, execution.detail.RowNo, snapshot, contract.OperatorUserID); err != nil {
return err
}
}
info := mergeBatchInfo(lockedMain.BatchInfo, map[string]any{
"orderSn": orderSN,
"packageCode": contract.PackageCode,
"packageName": pkg.PackageName,
"packageType": pkg.PackageType,
"billingMethod": contract.BillingMethod,
"renewDurationUnit": contract.RenewDurationUnit,
"renewDurationUnitText": contract.RenewDurationUnitText,
"durationValue": contract.DisplayDurationValue(),
"actualDurationMonths": contract.DurationMonths,
"durationMonths": contract.DurationMonths,
"giftDurationMonths": contract.GiftDurationMonths,
"amount": contract.TotalAmount(),
"quantity": contract.Quantity,
"renewTime": now.Format("2006-01-02 15:04:05"),
})
if err = r.repo.UpdateBatchMainInfo(ctx, tx, main.BatchNo, consts.BatchTypeRenew, &rc.BatchMainRow{
BatchType: consts.BatchTypeRenew,
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
RunResult: consts.BatchRunResultSuccess,
TotalCount: lockedMain.TotalCount,
ValidCount: len(executions),
SuccessCount: len(executions),
FailCount: 0,
BatchInfo: gjson.New(info),
OrderSn: orderSN,
StartTime: lockedMain.StartTime,
EndTime: gtime.NewFromTime(now),
TargetDeptId: contract.CustomerAgentID,
UpdatedBy: contract.OperatorUserID,
}); err != nil {
return err
}
return nil
})
return orderSN, err
}
func validateRenewExecutions(contract *ExecutionContract, details []*rc.BatchDetailRow, cards map[string]*rc.SimCardInfo, binds map[string]*rc.SimCardBindPkg, futureBinds map[string][]*rc.SimCardBindPkg, now time.Time) ([]renewExecution, error) {
if contract == nil {
return nil, gerror.New("renew execution contract is missing")
}
if contract.Quantity != len(details) {
return nil, gerror.New("renew detail quantity does not match confirmed contract")
}
seen := make(map[string]struct{}, len(details))
result := make([]renewExecution, 0, len(details))
for _, detail := range details {
if detail == nil {
return nil, gerror.New("renew detail is nil")
}
iccid := strings.TrimSpace(detail.ItemId)
if iccid == "" {
return nil, gerror.New("renew detail ICCID is empty")
}
if _, exists := seen[iccid]; exists {
return nil, gerror.Newf("renew detail ICCID is duplicated: %s", iccid)
}
seen[iccid] = struct{}{}
card := cards[iccid]
if card == nil {
return nil, gerror.Newf("renew card not found: %s", iccid)
}
bind := binds[iccid]
if bind == nil {
return nil, gerror.Newf("renew latest subscription not found: %s", iccid)
}
var expectedMSISDN, expectedIMSI string
if detail.Snapshot != nil {
expectedMSISDN = strings.TrimSpace(detail.Snapshot.Get("msisdn").String())
expectedIMSI = strings.TrimSpace(detail.Snapshot.Get("imsi").String())
}
if err := validateCurrentSubscription(card, bind, subscriptionValidation{
ICCID: iccid,
MSISDN: expectedMSISDN,
IMSI: expectedIMSI,
TenantID: contract.TenantID,
CustomerDeptID: contract.CustomerAgentID,
PackageCode: contract.PackageCode,
SourceBatchNo: contract.SourceBatchNo,
}); err != nil {
return nil, gerror.Wrapf(err, "validate renew current subscription failed, iccid:%s", iccid)
}
// The latest subscription is the source of truth for the next renewal
// start. The legacy Admin transaction did not require the denormalized
// card expiration to equal that subscription; future subscriptions can
// legitimately make the two values differ.
renewBase := renewBaseSubscriptionForCard(card, bind, futureBinds[iccid])
if err := validateRenewBaseWindow(renewBase, now, iccid); err != nil {
return nil, err
}
begin, ok := renewBeginTimeFromExpire(renewBase.ExpectedExpireTime)
if !ok {
return nil, gerror.Newf("ICCID没有预计到期时间:%s", strings.TrimSpace(iccid))
}
if contract.PackageBeginTime != nil {
begin = contract.PackageBeginTime.Time
}
result = append(result, renewExecution{
detail: detail,
card: card,
bind: renewBase,
begin: begin,
end: contract.ServiceEnd(begin),
})
}
return result, nil
}
func renewBaseSubscription(current *rc.SimCardBindPkg, future []*rc.SimCardBindPkg) *rc.SimCardBindPkg {
base := current
for _, bind := range future {
if bind == nil {
continue
}
if base == nil || subscriptionAfter(bind, base) {
base = bind
}
}
return base
}
func renewBaseSubscriptionForCard(card *rc.SimCardInfo, current *rc.SimCardBindPkg, future []*rc.SimCardBindPkg) *rc.SimCardBindPkg {
base := renewBaseSubscription(current, future)
cardExpireAt := latestCardRenewExpireTime(card)
if cardExpireAt == nil {
return base
}
if base == nil {
return &rc.SimCardBindPkg{
Iccid: strings.TrimSpace(card.Iccid),
PackageCode: strings.TrimSpace(card.CurrentPackageCode),
ExpectedExpireTime: cardExpireAt,
}
}
if base.ExpectedExpireTime != nil && !cardExpireAt.After(base.ExpectedExpireTime) {
return base
}
clone := *base
clone.ExpectedExpireTime = cardExpireAt
return &clone
}
func latestCardRenewExpireTime(card *rc.SimCardInfo) *gtime.Time {
if card == nil {
return nil
}
var expireAt *gtime.Time
for _, candidate := range []*gtime.Time{
card.FlowPkgEndAt,
card.FlowPkgPresentAt,
card.FlowPkgRealPresent,
} {
if candidate == nil || candidate.IsZero() {
continue
}
if expireAt == nil || candidate.After(expireAt) {
expireAt = candidate
}
}
return expireAt
}
func validateRenewBaseWindow(base *rc.SimCardBindPkg, now time.Time, iccid string) error {
if base == nil || base.ExpectedExpireTime == nil {
return gerror.Newf("ICCID没有预计到期时间:%s", strings.TrimSpace(iccid))
}
if renewExpireAfterAllowedWindow(base.ExpectedExpireTime, now) {
return gerror.Newf("%s:%s", renewExpireFailureReason, strings.TrimSpace(iccid))
}
return nil
}
func validateRunningRenewDetails(locked, loaded []*rc.BatchDetailRow) error {
if len(locked) != len(loaded) {
return gerror.New("renew detail count changed during execution")
}
for i, detail := range locked {
if detail == nil || detail.DetailStatus != consts.BatchDetailStatusRunning {
return gerror.New("renew detail is no longer running")
}
loadedDetail := loaded[i]
if loadedDetail == nil || detail.Id != loadedDetail.Id || detail.RowNo != loadedDetail.RowNo ||
strings.TrimSpace(detail.ItemId) != strings.TrimSpace(loadedDetail.ItemId) {
return gerror.New("renew detail changed during execution")
}
}
return nil
}
func renewServiceRange(executions []renewExecution) (time.Time, time.Time) {
minBegin := executions[0].begin
maxEnd := executions[0].end
for _, execution := range executions[1:] {
if execution.begin.Before(minBegin) {
minBegin = execution.begin
}
if execution.end.After(maxEnd) {
maxEnd = execution.end
}
}
return minBegin, maxEnd
}
func generateRenewSubscriptionNo(now time.Time) (string, error) {
orderSN, err := rc.NextOrderSN(now)
if err != nil {
return "", err
}
return "SUB" + strings.TrimPrefix(orderSN, "SO"), nil
}
func buildRenewBind(contract *ExecutionContract, pkg *rc.FlowPackageInfo, execution renewExecution, orderSN, subscriptionNo string, now time.Time) *rc.SimCardBindPkg {
remark := contract.Remark
if remark == "" {
remark = "card renew subscription"
}
return &rc.SimCardBindPkg{
SubscriptionNo: subscriptionNo,
BatchNo: contract.BatchNo,
Iccid: execution.card.Iccid,
Msisdn: execution.card.Msisdn,
DeptId: contract.CustomerAgentID,
PackageType: pkg.PackageType,
PackageCode: pkg.PackageCode,
PreviousPackageCode: pkg.PackageCode,
BusinessType: consts.PackageBusinessTypeRenew,
EffectiveStatus: consts.EffectiveStatusPending,
HandleTime: gtime.NewFromTime(now),
EffectiveTime: gtime.NewFromTime(execution.begin),
ExpectedExpireTime: gtime.NewFromTime(execution.end),
DurationMonths: contract.DurationMonths,
GiftDurationMonths: contract.GiftDurationMonths,
OrderCode: orderSN,
Remark: remark,
TenantId: contract.TenantID,
CreatedDept: contract.CreatedDeptID,
CreatedBy: contract.OperatorUserID,
CreatedAt: gtime.NewFromTime(now),
UpdatedBy: contract.OperatorUserID,
UpdatedAt: gtime.NewFromTime(now),
}
}
func buildRenewDetailSnapshot(execution renewExecution, contract *ExecutionContract, pkg *rc.FlowPackageInfo, orderSN, subscriptionNo string) *gjson.Json {
data := map[string]any{}
if execution.detail.Snapshot != nil {
for key, value := range execution.detail.Snapshot.Map() {
data[key] = value
}
}
data["iccid"] = execution.card.Iccid
data["msisdn"] = execution.card.Msisdn
data["imsi"] = execution.card.Imsi
data["renewBatchNo"] = contract.BatchNo
data["packageCode"] = pkg.PackageCode
data["packageName"] = pkg.PackageName
data["oldSubscriptionNo"] = execution.bind.SubscriptionNo
data["newSubscriptionNo"] = subscriptionNo
data["orderSn"] = orderSN
data["oldExpectedExpireTime"] = formatRenewTime(execution.bind.ExpectedExpireTime)
data["renewEffectiveTime"] = formatRenewTime(gtime.NewFromTime(execution.begin))
data["renewExpectedExpireTime"] = formatRenewTime(gtime.NewFromTime(execution.end))
data["cardFlowPkgEndAt"] = formatRenewTime(gtime.NewFromTime(execution.end))
data["durationValue"] = contract.DisplayDurationValue()
data["actualDurationMonths"] = contract.DurationMonths
data["durationMonths"] = contract.DurationMonths
data["giftDurationMonths"] = contract.GiftDurationMonths
data["billingMethod"] = contract.BillingMethod
data["renewDurationUnit"] = contract.RenewDurationUnit
data["renewDurationUnitText"] = contract.RenewDurationUnitText
data["renewDate"] = contract.RenewDate
data["amount"] = contract.TotalAmount()
data["renewUnitPrice"] = contract.UnitPrice
data["priceSource"] = contract.PriceSource
data["rowNo"] = execution.detail.RowNo
if contract.SourceBatchNo != "" {
data["sourceBatchNo"] = contract.SourceBatchNo
}
return gjson.New(data)
}
func formatRenewTime(value *gtime.Time) string {
if value == nil {
return ""
}
return value.Format("Y-m-d H:i:s")
}
func renewPackageStatus(now time.Time, begin, end *gtime.Time) int {
if begin != nil && begin.After(gtime.NewFromTime(now)) {
return consts.PackageStatusPending
}
if end != nil && !end.After(gtime.NewFromTime(now)) {
return consts.PackageStatusExpired
}
return consts.PackageStatusEffective
}
func strictIccidsFromDetails(details []*rc.BatchDetailRow) ([]string, error) {
seen := make(map[string]struct{}, len(details))
result := make([]string, 0, len(details))
for _, detail := range details {
if detail == nil {
return nil, gerror.New("renew detail is nil")
}
iccid := strings.TrimSpace(detail.ItemId)
if iccid == "" {
return nil, gerror.New("renew detail ICCID is empty")
}
if _, exists := seen[iccid]; exists {
return nil, gerror.Newf("renew detail ICCID is duplicated: %s", iccid)
}
seen[iccid] = struct{}{}
result = append(result, iccid)
}
return result, nil
}
// uniqueIccidsFromDetails remains available to the change executor while the
// renew path uses strictIccidsFromDetails to reject malformed input.
func uniqueIccidsFromDetails(details []*rc.BatchDetailRow) []string {
seen := make(map[string]struct{}, len(details))
result := make([]string, 0, len(details))
for _, detail := range details {
if detail == nil {
continue
}
iccid := strings.TrimSpace(detail.ItemId)
if iccid == "" {
continue
}
if _, exists := seen[iccid]; exists {
continue
}
seen[iccid] = struct{}{}
result = append(result, iccid)
}
return result
}
func (r *RenewExecutor) finalizeFailure(ctx context.Context, main *rc.BatchMainRow, reason string) error {
now := pnow(r.nowFunc)
contract, _ := ParseExecutionContract(main)
if contract == nil {
contract = renewRefundContractFromBatch(main)
}
nowTime := gtime.NewFromTime(now)
err := r.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
lockedMain, err := r.repo.Tables().LockRenewBatchMain(ctx, tx, main.BatchNo, main.BatchType)
if err != nil {
return err
}
if contract != nil {
if err := refundRenewBalanceIfNeeded(ctx, tx, lockedMain, contract, reason, nowTime); err != nil {
return err
}
}
if err := r.repo.MarkAllDetailsFailed(ctx, tx, main.BatchNo, main.BatchType, reason, main.CreatedBy); err != nil {
return err
}
return r.repo.FinalizeBatchMain(ctx, tx, main.BatchNo, main.BatchType, &rc.BatchMainRow{
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
RunResult: consts.BatchRunResultFailed,
TotalCount: main.TotalCount,
FailCount: main.TotalCount,
FailReason: reason,
EndTime: nowTime,
UpdatedBy: main.CreatedBy,
})
})
if err != nil {
return gerror.Wrap(err, "finalize renew failure failed")
}
snap, _ := r.repo.ResolveResultSnapshot(ctx, main.BatchNo, main.BatchType)
_ = r.repo.WriteResultFileSafe(ctx, main.BatchNo, main.BatchType, main.TenantId, main.CreatedDept, main.CreatedBy, snap)
g.Log().Warningf(ctx, "renew batch failed, batchNo=%s, reason=%s, tenant=%s", main.BatchNo, reason, main.TenantId)
return nil
}
func mergeBatchInfo(prev *gjson.Json, addition map[string]any) map[string]any {
out := map[string]any{}
if prev != nil {
for key, value := range prev.Map() {
out[key] = value
}
}
for key, value := range addition {
out[key] = value
}
return out
}
func pnow(fn NowFunc) time.Time {
if fn == nil {
return timeNow()
}
return fn()
}
@@ -0,0 +1,108 @@
package renewchange
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
func TestDeductRuntimeRenewBalanceRejectsInsufficientBalanceAtomically(t *testing.T) {
ctx := context.Background()
if err := gdb.SetConfig(gdb.Config{
"default": gdb.ConfigGroup{{Type: "sqlite", Name: filepath.Join(t.TempDir(), "renew-balance.db")}},
}); err != nil {
t.Fatalf("configure sqlite: %v", err)
}
defer g.DB().Close(ctx)
createRenewBalanceTables(t, ctx)
if _, err := g.DB().Model(rc.TableAgentBalance).Ctx(ctx).Data(g.Map{
"dept_id": 117,
"tenant_id": "000000",
"balance": 100,
"status": renewFinanceAccountStatusNormal,
}).Insert(); err != nil {
t.Fatalf("seed balance: %v", err)
}
main := &rc.BatchMainRow{
BatchNo: "REN20260806110000TEST",
TenantId: "000000",
CreatedBy: 60,
}
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, _, _, err := deductRuntimeRenewBalance(ctx, tx, main, 117, 576, time.Date(2026, 8, 6, 11, 0, 0, 0, time.Local))
return err
})
if err == nil || !strings.Contains(err.Error(), "账户余额不足") {
t.Fatalf("deduct error = %v, want insufficient balance", err)
}
balance, err := g.DB().Model(rc.TableAgentBalance).Ctx(ctx).
Where("dept_id", 117).
Where("tenant_id", "000000").
Value("balance")
if err != nil {
t.Fatalf("query balance: %v", err)
}
if got := balance.Float64(); got != 100 {
t.Fatalf("balance = %.2f, want unchanged 100.00", got)
}
count, err := g.DB().Model(rc.TableBalanceDetail).Ctx(ctx).
Where("serial_no", "RENPAY"+main.BatchNo).
Count()
if err != nil {
t.Fatalf("count balance detail: %v", err)
}
if count != 0 {
t.Fatalf("balance detail count = %d, want 0", count)
}
}
func createRenewBalanceTables(t *testing.T, ctx context.Context) {
t.Helper()
statements := []string{
`CREATE TABLE t_agent_balance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dept_id INTEGER NOT NULL,
tenant_id TEXT NOT NULL,
balance REAL NOT NULL,
status INTEGER NOT NULL DEFAULT 0,
updated_by INTEGER,
updated_at DATETIME
)`,
`CREATE TABLE t_balance_detail (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dept_id INTEGER NOT NULL,
serial_no TEXT NOT NULL,
transaction_time DATETIME,
income_expense_type INTEGER,
transaction_type INTEGER,
transaction_channel INTEGER,
transaction_status INTEGER,
amount REAL,
before_balance REAL,
balance_after REAL,
order_sn TEXT,
remark TEXT,
tenant_id TEXT NOT NULL,
created_dept INTEGER,
created_by INTEGER,
created_at DATETIME
)`,
}
for _, statement := range statements {
if _, err := g.DB().Exec(ctx, statement); err != nil {
t.Fatalf("create renew balance table: %v", err)
}
}
}
@@ -0,0 +1,632 @@
package renewchange
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
const (
renewFinanceAccountStatusNormal = 0
renewFinanceIncomeExpenseExpense = 1
)
type renewPaymentAccountRow struct {
ID int64 `orm:"id"`
Balance float64 `orm:"balance"`
Status int `orm:"status"`
}
func (r *RenewExecutor) ensureRenewExecutionContract(
ctx context.Context,
tx gdb.TX,
main *rc.BatchMainRow,
details []*rc.BatchDetailRow,
cards map[string]*rc.SimCardInfo,
binds map[string]*rc.SimCardBindPkg,
futureBinds map[string][]*rc.SimCardBindPkg,
now time.Time,
) (*ExecutionContract, error) {
if contract, err := ParseExecutionContract(main); err == nil {
if err = r.validateRenewRuntimePermissions(ctx, tx, main, contract); err != nil {
return nil, err
}
return contract, nil
}
info := main.BatchInfo
if info == nil || info.IsNil() {
return nil, gerror.New("续费批次缺少导入参数")
}
durationValue := firstInt(info, "durationValue", "durationYears")
if durationValue <= 0 {
durationValue = normalizeLegacyRenewDurationValue(firstInt(info, "durationMonths"))
}
if durationValue <= 0 {
return nil, gerror.New("续费时长不能为空")
}
giftDurationMonths := firstInt(info, "giftDurationMonths")
if giftDurationMonths < 0 {
return nil, gerror.New("赠送时长不能小于0")
}
iccids, err := strictIccidsFromDetails(details)
if err != nil {
return nil, err
}
customerID, packageCode, beginDates, err := runtimeRenewFacts(main, details, cards, binds, futureBinds, now)
if err != nil {
return nil, err
}
pkg, err := r.repo.Tables().LoadRenewPackage(ctx, tx, main.TenantId, packageCode)
if err != nil {
return nil, err
}
if pkg.Enabled != 1 {
return nil, gerror.New("当前套餐未启用")
}
durationUnit := renewDurationUnitForPackage(pkg)
actualDurationMonths := consts.DurationValueToMonths(durationUnit, durationValue)
actualGiftDurationMonths := consts.DurationValueToMonths(durationUnit, giftDurationMonths)
options, err := validateRuntimeRenewDuration(pkg, durationValue)
if err != nil {
return nil, err
}
customerDept, err := loadRenewDeptTx(ctx, tx, main.TenantId, customerID)
if err != nil {
return nil, err
}
priceSnap, err := loadRuntimeRenewPriceSnapshot(ctx, tx, main.TenantId, pkg, customerDept, options, beginDates)
if err != nil {
return nil, err
}
amount := roundAmount(priceSnap.RenewUnitPrice * float64(durationValue) * float64(len(iccids)))
mode := strings.TrimSpace(firstString(info, "paymentMode", "paymentSnapshot.paymentMode"))
payment, err := r.buildRuntimeRenewPayment(ctx, tx, main, mode, customerID, amount, now)
if err != nil {
return nil, err
}
addition := map[string]any{
"customerAgentId": customerID,
"customerName": strings.TrimSpace(customerDept.DeptName),
"packageCode": priceSnap.PackageCode,
"packageName": priceSnap.PackageName,
"priceSource": priceSnap.PriceSource,
"priceOwnerDeptId": priceSnap.PriceOwnerDeptId,
"agentDefaultPriceId": priceSnap.AgentDefaultPriceId,
"targetPriceId": priceSnap.TargetPriceId,
"renewUnitPrice": priceSnap.RenewUnitPrice,
"renewableDurationOptions": priceSnap.RenewableDurationOptions,
"renewablePackageCodes": priceSnap.RenewablePackageCodes,
"renewablePackages": priceSnap.RenewablePackages,
"billingMethod": priceSnap.BillingMethod,
"renewDurationUnit": priceSnap.RenewDurationUnit,
"renewDurationUnitText": priceSnap.RenewDurationUnitText,
"renewBeginDate": priceSnap.RenewBeginDate,
"renewBeginDateMin": priceSnap.RenewBeginDateMin,
"renewBeginDateMax": priceSnap.RenewBeginDateMax,
"sameRenewBeginDate": priceSnap.SameRenewBeginDate,
"priceSnapshot": priceSnap,
"quantity": len(iccids),
"durationValue": durationValue,
"durationMonths": actualDurationMonths,
"actualDurationMonths": actualDurationMonths,
"giftDurationMonths": giftDurationMonths,
"actualGiftDurationMonths": actualGiftDurationMonths,
"amount": amount,
}
for key, value := range payment {
addition[key] = value
}
merged := mergeBatchInfo(main.BatchInfo, addition)
main.BatchInfo = gjson.New(merged)
contract, err := ParseExecutionContract(main)
if err != nil {
return nil, err
}
if err = r.validateRenewRuntimePermissions(ctx, tx, main, contract); err != nil {
return nil, err
}
return contract, nil
}
func runtimeRenewFacts(
main *rc.BatchMainRow,
details []*rc.BatchDetailRow,
cards map[string]*rc.SimCardInfo,
binds map[string]*rc.SimCardBindPkg,
futureBinds map[string][]*rc.SimCardBindPkg,
now time.Time,
) (int64, string, []string, error) {
customerIDs := map[int64]struct{}{}
packageCodes := map[string]struct{}{}
beginDates := make([]string, 0, len(details))
periodRows := make([]*RenewRow, 0, len(details))
sourceBatchNo := executionSourceBatchNo(main, main.BatchInfo)
for _, detail := range details {
if detail == nil {
return 0, "", nil, gerror.New("续费明细为空")
}
iccid := strings.TrimSpace(detail.ItemId)
card := cards[iccid]
bind := binds[iccid]
if card == nil {
return 0, "", nil, gerror.Newf("ICCID不存在:%s", iccid)
}
if bind == nil {
return 0, "", nil, gerror.Newf("ICCID没有订购记录:%s", iccid)
}
expectedMSISDN, expectedIMSI := snapshotIdentity(detail.Snapshot)
if err := validateCurrentSubscription(card, bind, subscriptionValidation{
ICCID: iccid,
MSISDN: expectedMSISDN,
IMSI: expectedIMSI,
TenantID: main.TenantId,
CustomerDeptID: card.DeptId,
PackageCode: card.CurrentPackageCode,
SourceBatchNo: sourceBatchNo,
}); err != nil {
return 0, "", nil, gerror.Wrapf(err, "校验续费卡失败,ICCID:%s", iccid)
}
base := renewBaseSubscriptionForCard(card, bind, futureBinds[iccid])
if err := validateRenewBaseWindow(base, now, iccid); err != nil {
return 0, "", nil, err
}
customerIDs[card.DeptId] = struct{}{}
packageCodes[strings.TrimSpace(card.CurrentPackageCode)] = struct{}{}
periodRows = append(periodRows, &RenewRow{
RowNo: detail.RowNo,
ICCID: iccid,
OldEffTime: base.EffectiveTime,
OldExpireTime: base.ExpectedExpireTime,
})
beginAt := renewBeginDateFromExpire(base.ExpectedExpireTime)
if beginAt != nil {
beginDates = append(beginDates, beginAt.Format("Y-m-d"))
}
}
if err := validateRenewSameCurrentPeriod(periodRows, nil); err != nil {
return 0, "", nil, err
}
if len(customerIDs) != 1 {
return 0, "", nil, gerror.New("当前批次下存在多个客户,不能统一续费")
}
if len(packageCodes) != 1 {
return 0, "", nil, gerror.New("当前批次下存在多个套餐,不能统一续费")
}
var customerID int64
for id := range customerIDs {
customerID = id
}
var packageCode string
for code := range packageCodes {
packageCode = code
}
return customerID, packageCode, beginDates, nil
}
func normalizeLegacyRenewDurationValue(raw int) int {
if raw <= 0 {
return 0
}
if raw >= 12 && raw%12 == 0 {
return raw / 12
}
return raw
}
func validateRuntimeRenewDuration(pkg *rc.FlowPackageInfo, durationValue int) ([]int, error) {
if pkg == nil {
return nil, gerror.New("续费套餐不存在")
}
options, err := renewableYearOptions(pkg.RenewableDurationConfig)
if err != nil || len(options) == 0 {
return nil, gerror.Newf("当前套餐%s未配置可续费时长", runtimeRenewPackageLabel(pkg))
}
if !rc.ContainsInt(options, durationValue) {
return nil, gerror.Newf(
"续费时长%d%s不在当前套餐%s可续费范围内,可选:%s",
durationValue,
consts.RenewDurationUnitText(renewDurationUnitForPackage(pkg)),
runtimeRenewPackageLabel(pkg),
formatRuntimeRenewDurationOptions(options, renewDurationUnitForPackage(pkg)),
)
}
return options, nil
}
func runtimeRenewPackageLabel(pkg *rc.FlowPackageInfo) string {
if pkg == nil {
return ""
}
name := strings.TrimSpace(pkg.PackageName)
code := strings.TrimSpace(pkg.PackageCode)
switch {
case name != "" && code != "":
return name + "(" + code + ")"
case name != "":
return name
default:
return code
}
}
func formatRuntimeRenewDurationOptions(options []int, unit string) string {
if len(options) == 0 {
return ""
}
parts := make([]string, 0, len(options))
for _, option := range options {
parts = append(parts, fmt.Sprintf("%d%s", option, consts.RenewDurationUnitText(unit)))
}
return strings.Join(parts, "、")
}
func snapshotIdentity(snapshot *gjson.Json) (string, string) {
if snapshot == nil {
return "", ""
}
msisdn := strings.TrimSpace(snapshot.Get("msisdn").String())
if msisdn == "" {
msisdn = strings.TrimSpace(snapshot.Get("MSISDN").String())
}
imsi := strings.TrimSpace(snapshot.Get("imsi").String())
if imsi == "" {
imsi = strings.TrimSpace(snapshot.Get("IMSI").String())
}
return msisdn, imsi
}
func loadRuntimeRenewPriceSnapshot(
ctx context.Context,
tx gdb.TX,
tenantID string,
pkg *rc.FlowPackageInfo,
customerDept *rc.SysDeptRow,
options []int,
beginDates []string,
) (*RenewPriceSnapshot, error) {
if pkg == nil || customerDept == nil {
return nil, gerror.New("续费套餐或客户信息缺失")
}
priceOwnerDeptID := renewPriceOwnerDeptID(customerDept)
salePrice, err := resolveRenewEffectiveSalePrice(ctx, renewTxEffectivePriceStore{tx: tx}, tenantID, priceOwnerDeptID, customerDept.DeptId, pkg)
if err != nil {
return nil, err
}
sameDate, minDate, maxDate, same := summarizeRenewBeginDates(beginDates)
durationUnit := renewDurationUnitForPackage(pkg)
renewableCodes, renewablePackages, err := runtimeRenewablePackageOptions(ctx, tx, tenantID, customerDept.DeptId, priceOwnerDeptID, pkg)
if err != nil {
return nil, err
}
return &RenewPriceSnapshot{
CustomerAgentId: customerDept.DeptId,
PriceOwnerDeptId: priceOwnerDeptID,
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
BillingMethod: pkg.BillingMethod,
RenewDurationUnit: durationUnit,
RenewDurationUnitText: consts.RenewDurationUnitText(durationUnit),
PriceSource: salePrice.Rule.PriceSource,
AgentDefaultPriceId: salePrice.Rule.AgentDefaultPriceId,
TargetPriceId: salePrice.Rule.TargetPriceId,
RenewUnitPrice: salePrice.AgentShipmentPrice,
RenewableDurationOptions: options,
RenewablePackageCodes: renewableCodes,
RenewablePackages: renewablePackages,
RenewBeginDate: sameDate,
RenewBeginDateMin: minDate,
RenewBeginDateMax: maxDate,
SameRenewBeginDate: same,
}, nil
}
func runtimeRenewablePackageOptions(ctx context.Context, tx gdb.TX, tenantID string, customerDeptID, priceOwnerDeptID int64, currentPkg *rc.FlowPackageInfo) ([]string, []RenewPackageOption, error) {
if currentPkg == nil {
return nil, nil, gerror.New("当前套餐不存在或未启用")
}
codes := renewPackageCodesFromJSON(currentPkg.RenewablePackageCodes)
if len(codes) == 0 {
return nil, nil, gerror.New("当前套餐未配置可续费套餐")
}
validCodes := make([]string, 0, len(codes))
options := make([]RenewPackageOption, 0, len(codes))
for _, code := range codes {
pkg, err := loadRenewPackageOptionTx(ctx, tx, tenantID, code)
if err != nil || pkg == nil || pkg.Enabled != 1 {
continue
}
unitPrice, err := runtimeRenewPackageUnitPrice(ctx, tx, tenantID, customerDeptID, priceOwnerDeptID, pkg)
if err != nil {
return nil, nil, err
}
durations, err := renewableYearOptions(pkg.RenewableDurationConfig)
if err != nil {
continue
}
validCodes = append(validCodes, pkg.PackageCode)
options = append(options, RenewPackageOption{
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
RenewUnitPrice: unitPrice,
RenewableDurationOptions: durations,
})
}
if len(options) == 0 {
return nil, nil, gerror.New("当前套餐未配置可用的可续费套餐")
}
return validCodes, options, nil
}
func loadRenewPackageOptionTx(ctx context.Context, tx gdb.TX, tenantID, packageCode string) (*rc.FlowPackageInfo, error) {
var pkg rc.FlowPackageInfo
err := tx.Ctx(ctx).Model(rc.TableSimFlowPackageInfo).
Where("tenant_id", strings.TrimSpace(tenantID)).
Where("package_code", strings.TrimSpace(packageCode)).
LockShared().
Scan(&pkg)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, gerror.Wrap(err, "查询可续费套餐失败")
}
if pkg.Id == 0 {
return nil, nil
}
return &pkg, nil
}
func runtimeRenewPackageUnitPrice(ctx context.Context, tx gdb.TX, tenantID string, customerDeptID, priceOwnerDeptID int64, pkg *rc.FlowPackageInfo) (float64, error) {
if pkg == nil {
return 0, gerror.New("续费套餐不存在")
}
salePrice, err := resolveRenewEffectiveSalePrice(ctx, renewTxEffectivePriceStore{tx: tx}, tenantID, priceOwnerDeptID, customerDeptID, pkg)
if err != nil {
return 0, err
}
return salePrice.AgentShipmentPrice, nil
}
func (r *RenewExecutor) buildRuntimeRenewPayment(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, requestedMode string, payerDeptID int64, amount float64, now time.Time) (map[string]any, error) {
isTop, operatorDeptID, err := isRenewTopDeptTx(ctx, tx, main)
if err != nil {
return nil, err
}
if payerDeptID <= 0 {
payerDeptID = operatorDeptID
}
mode := strings.TrimSpace(requestedMode)
if mode == "" {
if isTop {
mode = "offline"
} else {
mode = "balance"
}
}
switch mode {
case "offline":
if !isTop {
return nil, gerror.New("当前组织续费必须使用余额支付")
}
if main.BatchInfo != nil && !main.BatchInfo.Get("offlineContractCompleted").Bool() {
return nil, gerror.New("请确认线下业务已完成")
}
return runtimePaymentSnapshot(mode, consts.PayTypeOffline, amount, 0, amount, 0, 0, "", payerDeptID, main.CreatedBy, isTop, now), nil
case "balance":
before, after, serialNo, err := deductRuntimeRenewBalance(ctx, tx, main, payerDeptID, amount, now)
if err != nil {
return nil, err
}
return runtimePaymentSnapshot(mode, consts.PayTypeBalance, amount, amount, 0, before, after, serialNo, payerDeptID, main.CreatedBy, isTop, now), nil
default:
return nil, gerror.New("续费付款方式不正确")
}
}
func runtimePaymentSnapshot(mode string, payType int, payAmount, balanceAmount, offlineAmount, before, after float64, serialNo string, payerDeptID, payerUserID int64, isTop bool, now time.Time) map[string]any {
paymentTime := now.Format("2006-01-02 15:04:05")
snapshot := map[string]any{
"paymentMode": mode,
"payType": payType,
"payStatus": consts.PayStatusPaid,
"payAmount": payAmount,
"balanceAmount": balanceAmount,
"offlinePaidAmount": offlineAmount,
"balanceBefore": before,
"balanceAfter": after,
"balanceDetailSerialNo": strings.TrimSpace(serialNo),
"paymentTime": paymentTime,
"payerDeptId": payerDeptID,
"payerUserId": payerUserID,
"isTopDept": isTop,
}
return map[string]any{
"paymentMode": mode,
"payType": payType,
"payStatus": consts.PayStatusPaid,
"payAmount": payAmount,
"balanceAmount": balanceAmount,
"offlinePaidAmount": offlineAmount,
"balanceBefore": before,
"balanceAfter": after,
"balanceDetailSerialNo": strings.TrimSpace(serialNo),
"paymentTime": paymentTime,
"payerDeptId": payerDeptID,
"payerUserId": payerUserID,
"isTopDept": isTop,
"paymentSnapshot": snapshot,
}
}
func deductRuntimeRenewBalance(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, payerDeptID int64, amount float64, now time.Time) (float64, float64, string, error) {
amount = roundAmount(amount)
if amount <= 0 {
return 0, 0, "", nil
}
serialNo := "RENPAY" + strings.TrimSpace(main.BatchNo)
if serialNo == "RENPAY" {
return 0, 0, "", gerror.New("续费余额流水号生成失败")
}
existingModel := tx.Ctx(ctx).Model(rc.TableBalanceDetail).
Where("tenant_id", main.TenantId).
Where("serial_no", serialNo)
existing, err := runtimeRenewLockForUpdate(existingModel).One()
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return 0, 0, "", gerror.Wrap(err, "查询续费余额流水失败")
}
if existing != nil && !existing.IsEmpty() {
return 0, 0, "", gerror.New("当前续费批次已扣款,请勿重复提交")
}
var account renewPaymentAccountRow
recordModel := tx.Ctx(ctx).Model(rc.TableAgentBalance).
Where("dept_id", payerDeptID).
Where("tenant_id", main.TenantId)
record, err := runtimeRenewLockForUpdate(recordModel).One()
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return 0, 0, "", gerror.Wrap(err, "查询账户余额失败")
}
if record != nil && !record.IsEmpty() {
if err := record.Struct(&account); err != nil {
return 0, 0, "", gerror.Wrap(err, "解析账户余额失败")
}
}
if account.ID == 0 {
return 0, 0, "", gerror.New("账户余额不存在,请先充值")
}
if account.Status != renewFinanceAccountStatusNormal {
return 0, 0, "", gerror.New("账户余额状态异常,不能续费")
}
before := roundAmount(account.Balance)
if before+0.0001 < amount {
return 0, 0, "", gerror.Newf("账户余额不足,需支付%.2f元,当前余额%.2f元", amount, before)
}
after := roundAmount(before - amount)
nowTime := gtime.NewFromTime(now)
if _, err := tx.Ctx(ctx).Model(rc.TableAgentBalance).Where("id", account.ID).Data(g.Map{
"balance": after,
"updated_by": main.CreatedBy,
"updated_at": nowTime,
}).Update(); err != nil {
return 0, 0, "", gerror.Wrap(err, "扣减账户余额失败")
}
if _, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).Data(g.Map{
"dept_id": payerDeptID,
"serial_no": serialNo,
"transaction_time": nowTime,
"income_expense_type": renewFinanceIncomeExpenseExpense,
"transaction_type": renewFinanceTransactionTypeConsume,
"transaction_channel": renewFinanceTransactionChannelBalance,
"transaction_status": renewFinanceTransactionStatusPaid,
"amount": amount,
"before_balance": before,
"balance_after": after,
"order_sn": main.BatchNo,
"remark": fmt.Sprintf("SIM卡续费余额支付,批次号:%s", main.BatchNo),
"tenant_id": main.TenantId,
"created_dept": payerDeptID,
"created_by": main.CreatedBy,
"created_at": nowTime,
}).Insert(); err != nil {
return 0, 0, "", gerror.Wrap(err, "写入续费余额流水失败")
}
return before, after, serialNo, nil
}
func runtimeRenewLockForUpdate(model *gdb.Model) *gdb.Model {
if g.DB().GetConfig().Type == "sqlite" {
return model
}
return model.LockUpdate()
}
func (r *RenewExecutor) validateRenewRuntimePermissions(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, contract *ExecutionContract) error {
if contract == nil {
return gerror.New("续费执行合同不能为空")
}
isTop, _, err := isRenewTopDeptTx(ctx, tx, main)
if err != nil {
return err
}
if contract.PaymentMode == "offline" && !isTop {
return gerror.New("当前组织续费必须使用余额支付")
}
if contract.PackageBeginTime != nil && !isTop {
return gerror.New("当前组织不能修改续费日期")
}
return nil
}
func isRenewTopDeptTx(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow) (bool, int64, error) {
deptID := main.CreatedDept
if deptID <= 0 {
deptID = main.DeptId
}
if deptID <= 0 {
return false, 0, gerror.New("当前批次缺少操作组织")
}
dept, err := loadRenewDeptTx(ctx, tx, main.TenantId, deptID)
if err != nil {
return false, 0, err
}
return dept.ParentId == 0, deptID, nil
}
func loadRenewDeptTx(ctx context.Context, tx gdb.TX, tenantID string, deptID int64) (*rc.SysDeptRow, error) {
var row rc.SysDeptRow
err := tx.Ctx(ctx).Model(rc.TableSysDept).
Where("tenant_id", tenantID).
Where("dept_id", deptID).
WhereNull("deleted_at").
LockShared().
Scan(&row)
if err != nil {
return nil, gerror.Wrap(err, "查询客户部门失败")
}
if row.DeptId == 0 {
return nil, gerror.New("客户部门不存在")
}
return &row, nil
}
func loadRenewPriceOverrideTx(ctx context.Context, tx gdb.TX, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error) {
var row rc.FlowPackagePrice
err := tx.Ctx(ctx).Model(rc.TableSimFlowPackagePrice).
Where("tenant_id", tenantID).
Where("dept_id", deptID).
Where("agent_scope_type", scopeType).
Where("target_agent_id", targetAgentID).
Where("package_code", strings.TrimSpace(packageCode)).
Where("enabled", 1).
LockShared().
Scan(&row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, gerror.Wrap(err, "查询代理续费定价失败")
}
if row.Id == 0 {
return nil, nil
}
return &row, nil
}
@@ -0,0 +1,176 @@
package renewchange
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"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"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
const (
renewFinanceIncomeExpenseIncome = 0
renewFinanceTransactionTypeConsume = 1
renewFinanceTransactionChannelBalance = 3
renewFinanceTransactionStatusPaid = 1
)
type renewBalanceAccountRow struct {
ID int64 `orm:"id"`
Balance float64 `orm:"balance"`
}
type renewBalanceDetailRow struct {
ID int64 `orm:"id"`
DeptID int64 `orm:"dept_id"`
SerialNo string `orm:"serial_no"`
Amount float64 `orm:"amount"`
TransactionStatus int `orm:"transaction_status"`
TenantID string `orm:"tenant_id"`
CreatedDept int64 `orm:"created_dept"`
CreatedBy int64 `orm:"created_by"`
}
func refundRenewBalanceIfNeeded(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, contract *ExecutionContract, reason string, now *gtime.Time) error {
if main == nil || contract == nil {
return nil
}
if contract.PaymentMode != "balance" || strings.TrimSpace(contract.BalanceDetailSerialNo) == "" || contract.BalanceAmount <= 0 {
return nil
}
if strings.TrimSpace(main.OrderSn) != "" {
return nil
}
refundSerialNo := "REFUND" + strings.TrimSpace(contract.BalanceDetailSerialNo)
var existing renewBalanceDetailRow
existingRecord, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).
Where("tenant_id", contract.TenantID).
Where("serial_no", refundSerialNo).
LockUpdate().
One()
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return gerror.Wrap(err, "query renew refund detail failed")
}
if existingRecord != nil && !existingRecord.IsEmpty() {
if err := existingRecord.Struct(&existing); err != nil {
return gerror.Wrap(err, "decode renew refund detail failed")
}
}
if existing.ID != 0 {
return nil
}
var original renewBalanceDetailRow
originalRecord, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).
Where("tenant_id", contract.TenantID).
Where("serial_no", strings.TrimSpace(contract.BalanceDetailSerialNo)).
LockUpdate().
One()
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return gerror.Wrap(err, "query renew payment detail failed")
}
if originalRecord != nil && !originalRecord.IsEmpty() {
if err := originalRecord.Struct(&original); err != nil {
return gerror.Wrap(err, "decode renew payment detail failed")
}
}
if original.ID == 0 {
g.Log().Warningf(ctx, "renew balance refund skipped, original payment detail missing, batchNo=%s serialNo=%s",
main.BatchNo, contract.BalanceDetailSerialNo)
return nil
}
payerDeptID := contract.PayerDeptID
if payerDeptID <= 0 {
payerDeptID = original.DeptID
}
var account renewBalanceAccountRow
accountRecord, err := tx.Ctx(ctx).Model(rc.TableAgentBalance).
Where("dept_id", payerDeptID).
Where("tenant_id", contract.TenantID).
LockUpdate().
One()
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return gerror.Wrap(err, "lock renew refund account failed")
}
if accountRecord != nil && !accountRecord.IsEmpty() {
if err := accountRecord.Struct(&account); err != nil {
return gerror.Wrap(err, "decode renew refund account failed")
}
}
if account.ID == 0 {
return gerror.New("renew refund account missing")
}
if now == nil {
now = gtime.Now()
}
amount := roundAmount(original.Amount)
before := roundAmount(account.Balance)
after := roundAmount(before + amount)
if _, err := tx.Ctx(ctx).Model(rc.TableAgentBalance).Where("id", account.ID).Data(g.Map{
"balance": after,
"updated_by": contract.OperatorUserID,
"updated_at": now,
}).Update(); err != nil {
return gerror.Wrap(err, "refund renew balance failed")
}
remark := fmt.Sprintf("SIM卡续费批次失败退款,批次号:%s,原因:%s", main.BatchNo, strings.TrimSpace(reason))
if _, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).Data(g.Map{
"dept_id": payerDeptID,
"serial_no": refundSerialNo,
"transaction_time": now,
"income_expense_type": renewFinanceIncomeExpenseIncome,
"transaction_type": renewFinanceTransactionTypeConsume,
"transaction_channel": renewFinanceTransactionChannelBalance,
"transaction_status": renewFinanceTransactionStatusPaid,
"amount": amount,
"before_balance": before,
"balance_after": after,
"order_sn": main.BatchNo,
"remark": remark,
"tenant_id": contract.TenantID,
"created_dept": original.CreatedDept,
"created_by": original.CreatedBy,
"created_at": now,
}).Insert(); err != nil {
return gerror.Wrap(err, "insert renew refund detail failed")
}
g.Log().Warningf(ctx, "renew balance refunded, batchNo=%s serialNo=%s refundSerialNo=%s amount=%.2f reason=%s",
main.BatchNo, contract.BalanceDetailSerialNo, refundSerialNo, amount, reason)
return nil
}
func renewRefundContractFromBatch(main *rc.BatchMainRow) *ExecutionContract {
if main == nil || main.BatchInfo == nil || main.BatchInfo.IsNil() {
return nil
}
info := main.BatchInfo
contract := &ExecutionContract{
BatchNo: strings.TrimSpace(main.BatchNo),
BatchType: main.BatchType,
TenantID: strings.TrimSpace(main.TenantId),
CustomerAgentID: firstInt64(info, "customerAgentId", "priceSnapshot.customerAgentId"),
SellerAgentID: main.DeptId,
CreatedDeptID: main.CreatedDept,
OperatorUserID: main.CreatedBy,
PaymentMode: firstString(info, "paymentMode", "paymentSnapshot.paymentMode"),
PayType: firstInt(info, "payType", "paymentSnapshot.payType"),
PayAmount: firstAmount(info, "payAmount", "paymentSnapshot.payAmount"),
BalanceAmount: firstAmount(info, "balanceAmount", "paymentSnapshot.balanceAmount"),
BalanceDetailSerialNo: firstString(info, "balanceDetailSerialNo", "paymentSnapshot.balanceDetailSerialNo"),
PayerDeptID: firstInt64(info, "payerDeptId", "paymentSnapshot.payerDeptId"),
PayerUserID: firstInt64(info, "payerUserId", "paymentSnapshot.payerUserId"),
}
contract.normalizePaymentContract()
if contract.PaymentMode != "balance" || strings.TrimSpace(contract.BalanceDetailSerialNo) == "" || contract.BalanceAmount <= 0 {
return nil
}
return contract
}
+543
View File
@@ -0,0 +1,543 @@
package renewchange
import (
"strings"
"testing"
"time"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
func TestValidateRuntimeRenewDurationRejectsUnsupportedOption(t *testing.T) {
pkg := &rc.FlowPackageInfo{
PackageCode: "PKG001",
BillingMethod: consts.BillingMethodMonth,
PackageName: "测试套餐",
RenewableDurationConfig: gjson.New([]any{1, 2, 3}),
}
options, err := validateRuntimeRenewDuration(pkg, 1)
if err != nil {
t.Fatalf("validateRuntimeRenewDuration returned error: %v", err)
}
if len(options) != 3 {
t.Fatalf("options = %v, want 3 entries", options)
}
_, err = validateRuntimeRenewDuration(pkg, 4)
if err == nil {
t.Fatal("expected unsupported renew duration to be rejected")
}
if got := err.Error(); !strings.Contains(got, "4年") || !strings.Contains(got, "测试套餐(PKG001)") || !strings.Contains(got, "3年") {
t.Fatalf("error message %q does not include duration/package/options", got)
}
}
func TestValidateRuntimeRenewDurationNormalizesLegacyMonthOptions(t *testing.T) {
pkg := &rc.FlowPackageInfo{
PackageCode: "PKG001",
PackageName: "测试套餐",
RenewableDurationConfig: gjson.New([]any{12, 24, 36}),
}
options, err := validateRuntimeRenewDuration(pkg, 2)
if err != nil {
t.Fatalf("validateRuntimeRenewDuration returned error: %v", err)
}
if got := options; len(got) != 3 || got[0] != 1 || got[1] != 2 || got[2] != 3 {
t.Fatalf("options = %v, want [1 2 3]", got)
}
}
func TestAddMonthsClampsToTargetMonthEnd(t *testing.T) {
begin := time.Date(2027, time.January, 31, 0, 0, 0, 0, time.UTC)
want := time.Date(2027, time.February, 28, 0, 0, 0, 0, time.UTC)
if got := addMonths(begin, 1); !got.Equal(want) {
t.Fatalf("addMonths() = %v, want %v", got, want)
}
}
func TestRenewBaseSubscriptionForCardPrefersLaterCardExpire(t *testing.T) {
card := &rc.SimCardInfo{
Iccid: "8986001",
CurrentPackageCode: "PKG001",
FlowPkgEndAt: gtime.NewFromStr("2026-08-31 00:00:00"),
FlowPkgPresentAt: gtime.NewFromStr("2026-09-30 00:00:00"),
FlowPkgRealPresent: gtime.NewFromStr("2026-10-31 00:00:00"),
}
bind := &rc.SimCardBindPkg{
Iccid: "8986001",
PackageCode: "PKG001",
ExpectedExpireTime: gtime.NewFromStr("2026-08-31 00:00:00"),
}
if got := renewBaseSubscriptionForCard(card, bind, nil); got == nil || got.ExpectedExpireTime.Format("Y-m-d") != "2026-10-31" {
t.Fatalf("renew base expire = %v, want 2026-10-31", got)
}
}
func TestParseExecutionContractConvertsYearDurationToActualMonths(t *testing.T) {
main := &rc.BatchMainRow{
BatchNo: "REN20260806000000TEST",
BatchType: consts.BatchTypeRenew,
TenantId: "000000",
DeptId: 1000,
CreatedDept: 1000,
CreatedBy: 1,
BatchInfo: gjson.New(map[string]any{
"customerAgentId": 1001,
"packageCode": "PKG001",
"packageName": "Test package",
"renewUnitPrice": 120,
"quantity": 2,
"billingMethod": consts.BillingMethodYear,
"renewDurationUnit": consts.RenewDurationUnitYear,
"durationValue": 1,
"durationMonths": 1,
"offlineContractCompleted": true,
"paymentMode": "offline",
}),
}
contract, err := ParseExecutionContract(main)
if err != nil {
t.Fatalf("ParseExecutionContract returned error: %v", err)
}
if contract.DurationDisplayValue != 1 {
t.Fatalf("display duration = %d, want 1", contract.DurationDisplayValue)
}
if contract.DurationMonths != 12 {
t.Fatalf("actual duration months = %d, want 12", contract.DurationMonths)
}
if got := contract.TotalAmount(); got != 240 {
t.Fatalf("total amount = %v, want 240", got)
}
}
func TestRuntimeRenewFactsRequiresSameCurrentPeriod(t *testing.T) {
main := &rc.BatchMainRow{
BatchNo: "REN20260806000000TEST",
TenantId: "000000",
}
begin := gtime.NewFromStr("2026-01-01 00:00:00")
expire := gtime.NewFromStr("2026-12-31 23:59:59")
details := []*rc.BatchDetailRow{
{RowNo: 1, ItemId: "898600000000000001"},
{RowNo: 2, ItemId: "898600000000000002"},
}
cards := map[string]*rc.SimCardInfo{
details[0].ItemId: {
Iccid: details[0].ItemId,
DeptId: 117,
CurrentPackageCode: "PKG001",
PackageStatus: consts.PackageStatusEffective,
TenantId: main.TenantId,
},
details[1].ItemId: {
Iccid: details[1].ItemId,
DeptId: 117,
CurrentPackageCode: "PKG001",
PackageStatus: consts.PackageStatusEffective,
TenantId: main.TenantId,
},
}
binds := map[string]*rc.SimCardBindPkg{
details[0].ItemId: {
Iccid: details[0].ItemId,
DeptId: 117,
PackageCode: "PKG001",
EffectiveTime: begin,
ExpectedExpireTime: expire,
TenantId: main.TenantId,
},
details[1].ItemId: {
Iccid: details[1].ItemId,
DeptId: 117,
PackageCode: "PKG001",
EffectiveTime: begin,
ExpectedExpireTime: expire,
TenantId: main.TenantId,
},
}
now := time.Date(2026, 8, 1, 0, 0, 0, 0, time.Local)
if _, _, _, err := runtimeRenewFacts(main, details, cards, binds, nil, now); err != nil {
t.Fatalf("runtimeRenewFacts() error = %v", err)
}
binds[details[1].ItemId].ExpectedExpireTime = gtime.NewFromStr("2027-01-31 23:59:59")
if _, _, _, err := runtimeRenewFacts(main, details, cards, binds, nil, now); err == nil {
t.Fatalf("runtimeRenewFacts() error = nil, want same period failure")
}
}
func TestValidateRenewExecutionsUsesConfirmedDuration(t *testing.T) {
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
base := now.AddDate(0, 1, 0)
contract := testRenewContract(12, 2)
details := []*rc.BatchDetailRow{{RowNo: 1, ItemId: "8986000000000000001"}}
cards := map[string]*rc.SimCardInfo{
details[0].ItemId: {
Iccid: details[0].ItemId,
DeptId: contract.CustomerAgentID,
CurrentPackageCode: contract.PackageCode,
PackageStatus: consts.PackageStatusEffective,
FlowPkgBeginAt: gtime.NewFromTime(now.AddDate(0, -1, 0)),
FlowPkgEndAt: gtime.NewFromTime(base),
OutboundBatchNo: contract.SourceBatchNo,
TenantId: contract.TenantID,
},
}
binds := map[string]*rc.SimCardBindPkg{
details[0].ItemId: {
Iccid: details[0].ItemId,
BatchNo: contract.SourceBatchNo,
Msisdn: cards[details[0].ItemId].Msisdn,
DeptId: contract.CustomerAgentID,
PackageCode: contract.PackageCode,
ExpectedExpireTime: gtime.NewFromTime(base),
TenantId: contract.TenantID,
},
}
got, err := validateRenewExecutions(contract, details, cards, binds, nil, now)
if err != nil {
t.Fatalf("validateRenewExecutions returned error: %v", err)
}
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(base))
wantEnd := addMonths(renewBegin.Time, 14)
if len(got) != 1 || !got[0].end.Equal(wantEnd) {
t.Fatalf("renew end = %v, want %v", got[0].end, wantEnd)
}
}
func TestValidateRenewExecutionsUsesLatestBindExpiration(t *testing.T) {
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
contract := testRenewContract(12, 0)
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000002"}
cardEnd := gtime.NewFromTime(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
cards := map[string]*rc.SimCardInfo{
detail.ItemId: {
Iccid: detail.ItemId,
DeptId: contract.CustomerAgentID,
CurrentPackageCode: contract.PackageCode,
PackageStatus: consts.PackageStatusEffective,
FlowPkgEndAt: cardEnd,
OutboundBatchNo: contract.SourceBatchNo,
TenantId: contract.TenantID,
},
}
binds := map[string]*rc.SimCardBindPkg{
detail.ItemId: {
Iccid: detail.ItemId,
BatchNo: contract.SourceBatchNo,
Msisdn: cards[detail.ItemId].Msisdn,
DeptId: contract.CustomerAgentID,
PackageCode: contract.PackageCode,
ExpectedExpireTime: gtime.NewFromTime(cardEnd.Time.AddDate(0, 0, 1)),
TenantId: contract.TenantID,
},
}
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, nil, now)
if err != nil {
t.Fatalf("validateRenewExecutions returned error: %v", err)
}
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(cardEnd.Time.AddDate(0, 0, 1)))
wantEnd := addMonths(renewBegin.Time, contract.DurationMonths)
if len(got) != 1 || !got[0].end.Equal(wantEnd) {
t.Fatalf("renew end = %v, want %v", got[0].end, wantEnd)
}
}
func TestValidateRenewExecutionsAllowsRenewalFromExpiredSubscription(t *testing.T) {
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
contract := testRenewContract(12, 0)
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000006"}
expiredAt := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
cards := map[string]*rc.SimCardInfo{
detail.ItemId: {
Iccid: detail.ItemId,
DeptId: contract.CustomerAgentID,
CurrentPackageCode: contract.PackageCode,
PackageStatus: consts.PackageStatusExpired,
OutboundBatchNo: contract.SourceBatchNo,
TenantId: contract.TenantID,
},
}
binds := map[string]*rc.SimCardBindPkg{
detail.ItemId: {
Iccid: detail.ItemId,
BatchNo: contract.SourceBatchNo,
DeptId: contract.CustomerAgentID,
PackageCode: contract.PackageCode,
EffectiveStatus: consts.EffectiveStatusExpired,
ExpectedExpireTime: gtime.NewFromTime(expiredAt),
TenantId: contract.TenantID,
},
}
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, nil, now)
if err != nil {
t.Fatalf("validateRenewExecutions returned error: %v", err)
}
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(expiredAt))
wantEnd := addMonths(renewBegin.Time, contract.DurationMonths)
if len(got) != 1 || !got[0].begin.Equal(renewBegin.Time) || !got[0].end.Equal(wantEnd) {
t.Fatalf("renew range = %v - %v, want %v - %v", got[0].begin, got[0].end, renewBegin.Time, wantEnd)
}
}
func TestValidateRenewExecutionsAppendsAfterFuturePendingExpiration(t *testing.T) {
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
contract := testRenewContract(12, 0)
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000005"}
currentExpire := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
futureExpire := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
cards := map[string]*rc.SimCardInfo{
detail.ItemId: {
Iccid: detail.ItemId,
DeptId: contract.CustomerAgentID,
CurrentPackageCode: contract.PackageCode,
PackageStatus: consts.PackageStatusEffective,
OutboundBatchNo: contract.SourceBatchNo,
TenantId: contract.TenantID,
},
}
binds := map[string]*rc.SimCardBindPkg{
detail.ItemId: {
Iccid: detail.ItemId,
BatchNo: contract.SourceBatchNo,
DeptId: contract.CustomerAgentID,
PackageCode: contract.PackageCode,
ExpectedExpireTime: gtime.NewFromTime(currentExpire),
TenantId: contract.TenantID,
},
}
futureBinds := map[string][]*rc.SimCardBindPkg{
detail.ItemId: {
{
Iccid: detail.ItemId,
BatchNo: "REN-FUTURE",
DeptId: contract.CustomerAgentID,
PackageCode: contract.PackageCode,
ExpectedExpireTime: gtime.NewFromTime(futureExpire),
TenantId: contract.TenantID,
},
},
}
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, futureBinds, now)
if err != nil {
t.Fatalf("validateRenewExecutions returned error: %v", err)
}
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(futureExpire))
wantEnd := addMonths(renewBegin.Time, contract.DurationMonths)
if len(got) != 1 || !got[0].begin.Equal(renewBegin.Time) || !got[0].end.Equal(wantEnd) {
t.Fatalf("renew range = %v - %v, want %v - %v", got[0].begin, got[0].end, renewBegin.Time, wantEnd)
}
}
func TestValidateRenewExecutionsRejectsPendingExpirationBeyondWindow(t *testing.T) {
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
contract := testRenewContract(12, 0)
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000007"}
currentExpire := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
futureExpire := time.Date(2027, 9, 1, 0, 0, 0, 0, time.UTC)
cards := map[string]*rc.SimCardInfo{
detail.ItemId: {
Iccid: detail.ItemId,
DeptId: contract.CustomerAgentID,
CurrentPackageCode: contract.PackageCode,
PackageStatus: consts.PackageStatusEffective,
OutboundBatchNo: contract.SourceBatchNo,
TenantId: contract.TenantID,
},
}
binds := map[string]*rc.SimCardBindPkg{
detail.ItemId: {
Iccid: detail.ItemId,
BatchNo: contract.SourceBatchNo,
DeptId: contract.CustomerAgentID,
PackageCode: contract.PackageCode,
ExpectedExpireTime: gtime.NewFromTime(currentExpire),
TenantId: contract.TenantID,
},
}
futureBinds := map[string][]*rc.SimCardBindPkg{
detail.ItemId: {
{
Iccid: detail.ItemId,
BatchNo: "REN-FUTURE",
DeptId: contract.CustomerAgentID,
PackageCode: contract.PackageCode,
ExpectedExpireTime: gtime.NewFromTime(futureExpire),
TenantId: contract.TenantID,
},
},
}
_, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, futureBinds, now)
if err == nil || !strings.Contains(err.Error(), renewExpireFailureReason) {
t.Fatalf("validateRenewExecutions error = %v, want renew window failure", err)
}
}
func TestValidateRunningRenewDetailsRejectsChangedSnapshot(t *testing.T) {
cases := []struct {
name string
locked []*rc.BatchDetailRow
loaded []*rc.BatchDetailRow
}{
{
name: "count",
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
loaded: nil,
},
{
name: "id",
locked: []*rc.BatchDetailRow{{Id: 2, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
},
{
name: "row number",
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 2, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
},
{
name: "iccid",
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986002", DetailStatus: consts.BatchDetailStatusRunning}},
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
},
{
name: "status",
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusSuccess}},
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := validateRunningRenewDetails(tc.locked, tc.loaded); err == nil {
t.Fatal("expected changed renew details to be rejected")
}
})
}
}
func TestValidateRenewExecutionsUsesConfirmedPackageBeginTime(t *testing.T) {
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.Local)
contract := testRenewContract(12, 0)
confirmedBegin := time.Date(2026, 10, 1, 0, 0, 0, 0, time.Local)
contract.PackageBeginTime = gtime.NewFromTime(confirmedBegin)
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000004"}
latestExpire := time.Date(2026, 9, 1, 0, 0, 0, 0, time.Local)
cards := map[string]*rc.SimCardInfo{
detail.ItemId: {
Iccid: detail.ItemId,
DeptId: contract.CustomerAgentID,
CurrentPackageCode: contract.PackageCode,
PackageStatus: consts.PackageStatusEffective,
OutboundBatchNo: contract.SourceBatchNo,
TenantId: contract.TenantID,
},
}
binds := map[string]*rc.SimCardBindPkg{
detail.ItemId: {
Iccid: detail.ItemId,
BatchNo: contract.SourceBatchNo,
DeptId: contract.CustomerAgentID,
PackageCode: contract.PackageCode,
ExpectedExpireTime: gtime.NewFromTime(latestExpire),
TenantId: contract.TenantID,
},
}
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, nil, now)
if err != nil {
t.Fatalf("validateRenewExecutions returned error: %v", err)
}
wantEnd := addMonths(confirmedBegin, contract.DurationMonths)
if len(got) != 1 || !got[0].begin.Equal(confirmedBegin) || !got[0].end.Equal(wantEnd) {
t.Fatalf("renew range = %v - %v, want %v - %v", got[0].begin, got[0].end, confirmedBegin, wantEnd)
}
}
func TestBuildRenewBindUsesConfirmedContract(t *testing.T) {
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
begin := now.AddDate(0, 1, 0)
contract := testRenewContract(12, 3)
pkg := &rc.FlowPackageInfo{PackageCode: contract.PackageCode, PackageType: 7}
execution := renewExecution{
card: &rc.SimCardInfo{Iccid: "8986000000000000003", Msisdn: "13800000000"},
bind: &rc.SimCardBindPkg{ExpectedExpireTime: gtime.NewFromTime(begin)},
begin: begin,
end: contract.ServiceEnd(begin),
}
bind := buildRenewBind(contract, pkg, execution, "SO20260803100000ABC", "SUB20260803100000ABC", now)
if bind.DurationMonths != 12 || bind.GiftDurationMonths != 3 {
t.Fatalf("duration = %d+%d, want 12+3", bind.DurationMonths, bind.GiftDurationMonths)
}
if bind.PackageType != 7 || bind.EffectiveTime.Time != begin {
t.Fatalf("bind contract fields were not preserved: %+v", bind)
}
}
func TestBuildRenewDetailSnapshotUsesConfirmedBatchAmount(t *testing.T) {
contract := testRenewContract(12, 0)
contract.Quantity = 2
execution := renewExecution{
detail: &rc.BatchDetailRow{RowNo: 1},
card: &rc.SimCardInfo{Iccid: "8986000000000000003"},
bind: &rc.SimCardBindPkg{},
begin: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
end: time.Date(2027, 8, 3, 0, 0, 0, 0, time.UTC),
}
pkg := &rc.FlowPackageInfo{PackageCode: contract.PackageCode, PackageName: contract.PackageName}
snapshot := buildRenewDetailSnapshot(execution, contract, pkg, "SO-1", "SUB-1")
if got := snapshot.Get("amount").Float64(); got != 240 {
t.Fatalf("detail amount = %v, want confirmed batch amount 240", got)
}
}
func TestRenewPackageStatus(t *testing.T) {
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
if got := renewPackageStatus(now, gtime.NewFromTime(now.AddDate(0, 1, 0)), gtime.NewFromTime(now.AddDate(0, 13, 0))); got != consts.PackageStatusPending {
t.Fatalf("future package status = %d, want pending", got)
}
if got := renewPackageStatus(now, gtime.NewFromTime(now.AddDate(0, -1, 0)), gtime.NewFromTime(now.AddDate(0, 1, 0))); got != consts.PackageStatusEffective {
t.Fatalf("active package status = %d, want effective", got)
}
if got := renewPackageStatus(now, gtime.NewFromTime(now.AddDate(0, -2, 0)), gtime.NewFromTime(now.AddDate(0, -1, 0))); got != consts.PackageStatusExpired {
t.Fatalf("expired package status = %d, want expired", got)
}
}
func testRenewContract(duration, gift int) *ExecutionContract {
return &ExecutionContract{
BatchNo: "REN20260803100000TEST",
BatchType: consts.BatchTypeRenew,
SourceBatchNo: "OUT20260801TEST",
TenantID: "000000",
CustomerAgentID: 1001,
SellerAgentID: 1000,
CreatedDeptID: 1000,
OperatorUserID: 1,
PackageCode: "PKG001",
PackageName: "Test package",
PriceSource: PriceSourceSystem,
UnitPrice: 10,
Quantity: 1,
DurationMonths: duration,
GiftDurationMonths: gift,
OfflineContractCompleted: true,
BuyerNote: "buyer",
AdminNote: "admin",
Remark: "renew test",
}
}
+396
View File
@@ -0,0 +1,396 @@
package renewchange
import (
"context"
"strconv"
"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/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
batchrepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/batch"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
)
// Repository orchestrates table access + batch status writes for renew/change
// service. It embeds the typed table repository and adds batch lifecycle
// helpers that align with the Admin side batch_main/batch_detail contract.
type Repository struct {
tables *rc.Repository
batchRepo *batchrepo.Repository
results batchresult.Generator
}
// NewRepository builds a service-level repository backed by the typed table
// repository (g.DB() default) and the shared batch repository.
func NewRepository() *Repository {
return &Repository{
tables: rc.New(),
batchRepo: batchrepo.New(),
results: batchresult.New(),
}
}
// newRepositoryWithDeps allows tests to inject custom dependencies.
func newRepositoryWithDeps(tables *rc.Repository, batchRepo *batchrepo.Repository, results batchresult.Generator) *Repository {
return &Repository{tables: tables, batchRepo: batchRepo, results: results}
}
// Tables exposes the low-level repository for tests.
func (r *Repository) Tables() *rc.Repository { return r.tables }
// Batch exposes the batch repository.
func (r *Repository) Batch() *batchrepo.Repository { return r.batchRepo }
// Results exposes the result file generator.
func (r *Repository) Results() batchresult.Generator { return r.results }
// DB exposes the underlying gdb.DB handle.
func (r *Repository) DB() gdb.DB { return r.tables.DB() }
// LoadProcessingBatch loads a processing renew/change/prepare batch by
// (batchNo, batchType). It returns an error if the batch is not in
// processing state so the caller can log and skip safely.
func (r *Repository) LoadProcessingBatch(ctx context.Context, batchNo string, batchType int) (*rc.BatchMainRow, error) {
main, err := r.tables.LoadBatchMain(ctx, batchNo, batchType)
if err != nil {
return nil, err
}
if main == nil {
return nil, gerror.Newf("batch %s (type %d) does not exist", batchNo, batchType)
}
if main.BatchAsyncStatus != batch.AsyncStatusProcessing {
return nil, gerror.Newf("batch %s (type %d) is not in processing state: %d", batchNo, batchType, main.BatchAsyncStatus)
}
return main, nil
}
// LoadRunningDetails returns the running batch details ordered by row_no.
func (r *Repository) LoadRunningDetails(ctx context.Context, batchNo string, batchType int) ([]*rc.BatchDetailRow, error) {
return r.tables.LoadBatchDetails(ctx, batchNo, batchType, []int{consts.BatchDetailStatusRunning})
}
// LoadAllDetails returns every detail regardless of status, used for the
// confirm step (renew/change) to inspect previously committed results.
func (r *Repository) LoadAllDetails(ctx context.Context, batchNo string, batchType int) ([]*rc.BatchDetailRow, error) {
return r.tables.LoadBatchDetails(ctx, batchNo, batchType, nil)
}
type ConvertPrepareResult struct {
MainAffected int64
DetailAffected int64
}
// ConvertPrepareToRenewChange switches the prepare batch (type=4) to the
// matching business type (101/102). The transaction must be the one used for
// the detail update.
func (r *Repository) ConvertPrepareToRenewChange(ctx context.Context, tx gdb.TX, batchNo string, targetType int) (*ConvertPrepareResult, error) {
detailResult, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
Data(do.BatchDetail{BatchType: targetType, UpdatedAt: gtime.Now()}).
Update()
if err != nil {
return nil, gerror.Wrap(err, "convert prepare details to target type failed")
}
detailAffected, err := detailResult.RowsAffected()
if err != nil {
return nil, err
}
mainResult, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypePrepare).
Data(do.BatchMain{BatchType: targetType, UpdatedAt: gtime.Now()}).
Update()
if err != nil {
return nil, gerror.Wrap(err, "convert prepare batch type failed")
}
mainAffected, err := mainResult.RowsAffected()
if err != nil {
return nil, err
}
if mainAffected == 0 {
return nil, gerror.New("prepare batch main already converted or missing")
}
return &ConvertPrepareResult{
MainAffected: mainAffected,
DetailAffected: detailAffected,
}, nil
}
// MarkDetailFailed writes a single detail as failed with the given reason.
func (r *Repository) MarkDetailFailed(ctx context.Context, tx gdb.TX, batchNo string, batchType, rowNo int, reason string, userID int64) error {
return r.tables.UpdateBatchDetailSnapshot(ctx, tx, batchNo, batchType, rowNo, consts.BatchDetailStatusFailed, nil, reason, userID)
}
// MarkDetailSuccess writes a single detail as success with the given snapshot.
func (r *Repository) MarkDetailSuccess(ctx context.Context, tx gdb.TX, batchNo string, batchType, rowNo int, snapshot *gjson.Json, userID int64) error {
return r.tables.UpdateBatchDetailSnapshot(ctx, tx, batchNo, batchType, rowNo, consts.BatchDetailStatusSuccess, snapshot, "", userID)
}
func (r *Repository) MarkPrepareDetailSuccess(ctx context.Context, tx gdb.TX, batchNo string, rowNo int, itemID string, snapshot *gjson.Json, userID int64) error {
data := map[string]any{
dao.BatchDetail.Columns().DetailStatus: consts.BatchDetailStatusSuccess,
dao.BatchDetail.Columns().Snapshot: snapshot,
dao.BatchDetail.Columns().UpdatedBy: userID,
dao.BatchDetail.Columns().UpdatedAt: gtime.Now(),
}
if itemID = strings.TrimSpace(itemID); itemID != "" {
data[dao.BatchDetail.Columns().ItemId] = itemID
}
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
Where(dao.BatchDetail.Columns().RowNo, rowNo).
Data(data).
Update()
return gerror.Wrap(err, "mark prepare detail success failed")
}
// MarkAllDetailsFailed marks every unfinished detail as failed in a single update.
func (r *Repository) MarkAllDetailsFailed(ctx context.Context, tx gdb.TX, batchNo string, batchType int, reason string, userID int64) error {
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, batchType).
WhereIn(dao.BatchDetail.Columns().DetailStatus, []int{consts.BatchDetailStatusPending, consts.BatchDetailStatusRunning}).
Data(do.BatchDetail{
DetailStatus: consts.BatchDetailStatusFailed,
ErrorReason: reason,
UpdatedBy: userID,
UpdatedAt: gtime.Now(),
}).
Update()
return gerror.Wrap(err, "mark all running details failed")
}
// MarkAllDetailsSuccess marks every running detail as success.
func (r *Repository) MarkAllDetailsSuccess(ctx context.Context, tx gdb.TX, batchNo string, batchType int, userID int64, extra map[string]any) error {
data := map[string]any{
"detail_status": consts.BatchDetailStatusSuccess,
"updated_by": userID,
"updated_at": gtime.Now(),
}
for k, v := range extra {
data[k] = v
}
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, batchType).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Data(data).
Update()
return gerror.Wrap(err, "mark all running details success")
}
// UpdateBatchMainInfo merges new entries into batch_main.batch_info and
// updates the lifecycle counters in a single statement.
func (r *Repository) UpdateBatchMainInfo(ctx context.Context, tx gdb.TX, batchNo string, batchType int, data *rc.BatchMainRow) error {
return r.tables.UpdateBatchMainStatus(ctx, tx, batchNo, batchType, data)
}
// FinalizeBatchMain updates only the lifecycle fields (status, counts,
// fail_reason, end_time) while keeping batch_info untouched.
func (r *Repository) FinalizeBatchMain(ctx context.Context, tx gdb.TX, batchNo string, batchType int, data *rc.BatchMainRow) error {
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Where(dao.BatchMain.Columns().BatchType, batchType).
Data(do.BatchMain{
BatchAsyncStatus: data.BatchAsyncStatus,
RunResult: data.RunResult,
SuccessCount: data.SuccessCount,
FailCount: data.FailCount,
ValidCount: data.ValidCount,
TotalCount: data.TotalCount,
FailReason: data.FailReason,
EndTime: data.EndTime,
UpdatedBy: data.UpdatedBy,
}).
Update()
return gerror.Wrap(err, "finalize batch main failed")
}
// ResolveResultSnapshot returns the per-row data needed to render the result
// file. It always includes every detail (success and failure) for the batch.
func (r *Repository) ResolveResultSnapshot(ctx context.Context, batchNo string, batchType int) ([]ResultRow, error) {
details, err := r.tables.LoadBatchDetails(ctx, batchNo, batchType, nil)
if err != nil {
return nil, err
}
rows := make([]ResultRow, 0, len(details))
for _, d := range details {
if d == nil {
continue
}
msisdn, imsi := "", ""
targetCode, packageCode, effectiveMode, orderNum := "", "", "", ""
if d.Snapshot != nil {
msisdn = strings.TrimSpace(d.Snapshot.Get("msisdn").String())
imsi = strings.TrimSpace(d.Snapshot.Get("imsi").String())
targetCode = strings.TrimSpace(d.Snapshot.Get("targetCode").String())
if targetCode == "" {
targetCode = strings.TrimSpace(d.ItemId)
}
packageCode = strings.TrimSpace(d.Snapshot.Get("packageCode").String())
effectiveMode = strings.TrimSpace(d.Snapshot.Get("effectiveMode").String())
orderNum = strings.TrimSpace(d.Snapshot.Get("orderSn").String())
if orderNum == "" {
orderNum = strings.TrimSpace(d.Snapshot.Get("orderNo").String())
}
}
rows = append(rows, ResultRow{
RowNo: d.RowNo,
ICCID: strings.TrimSpace(d.ItemId),
TargetCode: targetCode,
PackageCode: packageCode,
EffectiveMode: effectiveMode,
MSISDN: msisdn,
IMSI: imsi,
Success: d.DetailStatus == consts.BatchDetailStatusSuccess,
Reason: strings.TrimSpace(d.ErrorReason),
OrderNum: orderNum,
Snap: d.Snapshot,
DetailID: d.Id,
DetailCode: d.ErrorCode,
})
}
return rows, nil
}
// WriteResultFileSafe persists a result Excel and OSS/download records. It
// never panics on a bad batch type; missing configuration is logged and the
// business outcome is preserved. The caller must not abort the batch when
// this returns an error.
func (r *Repository) WriteResultFileSafe(ctx context.Context, batchNo string, batchType int, tenantID string, deptID, userID int64, rows []ResultRow) error {
if r.results == nil {
return nil
}
brRows := make([]batchresult.Row, 0, len(rows))
for _, row := range rows {
brRows = append(brRows, batchresult.Row{
RowNo: row.RowNo,
ObjectCode: row.TargetCode,
TargetCode: row.TargetCode,
PackageCode: row.PackageCode,
EffectiveMode: row.EffectiveMode,
ICCID: row.ICCID,
MSISDN: row.MSISDN,
IMSI: row.IMSI,
Success: row.Success,
Reason: row.Reason,
OrderNum: row.OrderNum,
})
}
request := batchresult.Request{
BatchNo: batchNo,
BatchType: batchType,
TenantID: strings.TrimSpace(tenantID),
Rows: brRows,
}
artifact, err := r.results.Create(ctx, request)
if err != nil {
g.Log().Warningf(ctx, "renew/change result file generation failed, batchNo:%s, batchType:%d, err:%+v", batchNo, batchType, err)
return err
}
if err = r.persistArtifact(ctx, artifact, batchNo, batchType, tenantID, deptID, userID, len(rows)); err != nil {
g.Log().Warningf(ctx, "renew/change result file bookkeeping failed, batchNo:%s, batchType:%d, err:%+v", batchNo, batchType, err)
return err
}
return nil
}
func (r *Repository) persistArtifact(ctx context.Context, artifact batchresult.Artifact, batchNo string, batchType int, tenantID string, deptID, userID int64, total int) error {
return r.tables.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
ossID, err := r.tables.InsertSysOss(ctx, tx, &rc.SysOssRow{
TenantId: tenantID,
FileName: artifact.FileName,
OriginalName: artifact.OriginalName,
FileSuffix: artifact.FileSuffix,
Path: artifact.Path,
Url: artifact.URL,
Md5: artifact.MD5,
FileSize: artifact.Size,
Service: "local",
CreatedDept: deptID,
CreatedBy: userID,
UpdatedBy: userID,
})
if err != nil {
return err
}
successCount, failCount := 0, 0
main, mErr := r.tables.LoadBatchMain(ctx, batchNo, batchType)
if mErr == nil && main != nil {
successCount = main.SuccessCount
failCount = main.FailCount
}
_, err = r.tables.InsertBatchDownload(ctx, tx, &rc.ResourceBatchDownloadRow{
BatchNo: batchNo,
TaskName: batchresult.TaskName(batchType),
ResourceType: consts.ResourceTypeExcel,
ExportScene: batchType,
TotalCount: total,
SuccessCount: successCount,
FailCount: failCount,
DownloadStatus: consts.DownloadStatusGenerated,
OssId: ossID,
FileName: artifact.FileName,
FileExt: artifact.FileSuffix,
FileUrl: artifact.URL,
FilePath: artifact.Path,
FileSize: artifact.Size,
FileMd5: artifact.MD5,
DownloadCount: 0,
DeptId: deptID,
StartTime: gtime.Now(),
FinishTime: gtime.Now(),
TenantId: tenantID,
CreatedDept: deptID,
CreatedBy: userID,
UpdatedBy: userID,
})
return err
})
}
// LoadSystemContext returns tenant id and a default user id (CreatedBy) for
// the given batch. Centralized so individual executors stay slim.
func (r *Repository) LoadSystemContext(ctx context.Context, batchNo string, batchType int) (string, int64, int64, error) {
main, err := r.tables.LoadBatchMain(ctx, batchNo, batchType)
if err != nil {
return "", 0, 0, err
}
if main == nil {
return "", 0, 0, gerror.Newf("batch %s (type %d) does not exist", batchNo, batchType)
}
return main.TenantId, main.CreatedBy, main.CreatedDept, nil
}
// ResultRow is the per-row information needed by the result file.
type ResultRow struct {
RowNo int
ICCID string
TargetCode string
PackageCode string
EffectiveMode string
MSISDN string
IMSI string
Success bool
Reason string
OrderNum string
Upstream int
Snap *gjson.Json
DetailID int64
DetailCode string
}
func tenantIDtoString(tenantID int64) string {
return strconv.FormatInt(tenantID, 10)
}
@@ -0,0 +1,209 @@
package renewchange
import (
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
type subscriptionValidation struct {
ICCID string
MSISDN string
IMSI string
TenantID string
CustomerDeptID int64
PackageCode string
SourceBatchNo string
}
// resolveCurrentSubscriptions selects the current package fact per ICCID.
// It keeps future pending subscriptions for change-package cleanup, while
// allowing renew to append after the farthest pending subscription.
func resolveCurrentSubscriptions(rows []*rc.SimCardBindPkg, iccids []string) (map[string]*rc.SimCardBindPkg, map[string][]*rc.SimCardBindPkg, error) {
grouped := make(map[string][]*rc.SimCardBindPkg, len(iccids))
for _, row := range rows {
if row == nil {
continue
}
iccid := strings.TrimSpace(row.Iccid)
if iccid == "" {
continue
}
grouped[iccid] = append(grouped[iccid], row)
}
current := make(map[string]*rc.SimCardBindPkg, len(iccids))
future := make(map[string][]*rc.SimCardBindPkg, len(iccids))
for _, rawICCID := range iccids {
iccid := strings.TrimSpace(rawICCID)
selected, pending, err := selectCurrentSubscription(grouped[iccid], iccid)
if err != nil {
return nil, nil, err
}
current[iccid] = selected
future[iccid] = pending
}
return current, future, nil
}
func selectCurrentSubscription(rows []*rc.SimCardBindPkg, iccid string) (*rc.SimCardBindPkg, []*rc.SimCardBindPkg, error) {
var (
effective *rc.SimCardBindPkg
pending []*rc.SimCardBindPkg
expired []*rc.SimCardBindPkg
)
for _, row := range rows {
if row == nil || strings.TrimSpace(row.Iccid) != strings.TrimSpace(iccid) {
continue
}
if consts.IsAddPackageType(row.PackageType) {
continue
}
switch row.EffectiveStatus {
case consts.EffectiveStatusEffective:
if effective != nil {
return nil, nil, gerror.Newf("同一张卡存在多个生效中的套餐订购记录:%s", iccid)
}
effective = row
case consts.EffectiveStatusPending:
pending = append(pending, row)
case consts.EffectiveStatusExpired:
expired = append(expired, row)
}
}
sortPendingSubscriptions(pending)
if effective != nil {
return effective, pending, nil
}
if len(pending) > 0 {
return pending[0], pending[1:], nil
}
if latest := latestExpiredSubscription(expired); latest != nil {
return latest, nil, nil
}
return nil, nil, gerror.Newf("ICCID没有当前套餐订购记录:%s", iccid)
}
func latestExpiredSubscription(rows []*rc.SimCardBindPkg) *rc.SimCardBindPkg {
var latest *rc.SimCardBindPkg
for _, row := range rows {
if row == nil {
continue
}
if latest == nil || subscriptionAfter(row, latest) {
latest = row
}
}
return latest
}
func sortPendingSubscriptions(rows []*rc.SimCardBindPkg) {
if len(rows) <= 1 {
return
}
for i := 1; i < len(rows); i++ {
current := rows[i]
j := i - 1
for j >= 0 && subscriptionAfter(rows[j], current) {
rows[j+1] = rows[j]
j--
}
rows[j+1] = current
}
}
func subscriptionAfter(left, right *rc.SimCardBindPkg) bool {
if compareTime(expectedExpireTime(left), expectedExpireTime(right)) != 0 {
return compareTime(expectedExpireTime(left), expectedExpireTime(right)) > 0
}
if compareTime(effectiveTime(left), effectiveTime(right)) != 0 {
return compareTime(effectiveTime(left), effectiveTime(right)) > 0
}
return strings.TrimSpace(subscriptionNo(left)) > strings.TrimSpace(subscriptionNo(right))
}
func expectedExpireTime(row *rc.SimCardBindPkg) *gtime.Time {
if row == nil {
return nil
}
return row.ExpectedExpireTime
}
func effectiveTime(row *rc.SimCardBindPkg) *gtime.Time {
if row == nil {
return nil
}
return row.EffectiveTime
}
func subscriptionNo(row *rc.SimCardBindPkg) string {
if row == nil {
return ""
}
return row.SubscriptionNo
}
func compareTime(left, right *gtime.Time) int {
switch {
case left == nil && right == nil:
return 0
case left == nil:
return -1
case right == nil:
return 1
case left.Before(right):
return -1
case left.After(right):
return 1
default:
return 0
}
}
func validateCurrentSubscription(card *rc.SimCardInfo, bind *rc.SimCardBindPkg, expected subscriptionValidation) error {
if card == nil || bind == nil {
return gerror.New("卡信息或当前订购记录缺失")
}
iccid := strings.TrimSpace(expected.ICCID)
if iccid == "" || strings.TrimSpace(card.Iccid) != iccid || strings.TrimSpace(bind.Iccid) != iccid {
return gerror.New("ICCID不一致")
}
if strings.TrimSpace(expected.TenantID) != "" &&
(strings.TrimSpace(card.TenantId) != expected.TenantID || strings.TrimSpace(bind.TenantId) != expected.TenantID) {
return gerror.New("租户不一致")
}
// t_sim_card_info.dept_id is the current card owner. A card allocation
// changes that field, while the historical bind row keeps the department
// that originally purchased the subscription.
if expected.CustomerDeptID <= 0 || card.DeptId != expected.CustomerDeptID {
return gerror.New("客户部门不一致")
}
packageCode := strings.TrimSpace(expected.PackageCode)
if packageCode == "" || strings.TrimSpace(card.CurrentPackageCode) != packageCode || strings.TrimSpace(bind.PackageCode) != packageCode {
return gerror.New("当前套餐不一致")
}
if card.PackageStatus == consts.PackageStatusNone {
return gerror.New("卡没有当前套餐")
}
if msisdn := strings.TrimSpace(expected.MSISDN); msisdn != "" && strings.TrimSpace(card.Msisdn) != msisdn {
return gerror.New("MSISDN不一致")
}
if bindMSISDN := strings.TrimSpace(bind.Msisdn); bindMSISDN != "" && strings.TrimSpace(card.Msisdn) != bindMSISDN {
return gerror.New("订购记录MSISDN不一致")
}
if imsi := strings.TrimSpace(expected.IMSI); imsi != "" && strings.TrimSpace(card.Imsi) != imsi {
return gerror.New("IMSI不一致")
}
if sourceBatchNo := strings.TrimSpace(expected.SourceBatchNo); sourceBatchNo != "" {
bindBatchNo := strings.TrimSpace(bind.BatchNo)
outboundBatchNo := strings.TrimSpace(card.OutboundBatchNo)
if bindBatchNo != sourceBatchNo && outboundBatchNo != sourceBatchNo {
return gerror.New("当前订购来源批次不一致")
}
}
return nil
}
@@ -0,0 +1,293 @@
package renewchange
import (
"strings"
"testing"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
func TestResolveCurrentSubscriptionsRejectsMissingAndDuplicatePriorities(t *testing.T) {
tests := []struct {
name string
rows []*rc.SimCardBindPkg
}{
{name: "missing"},
{
name: "duplicate effective",
rows: []*rc.SimCardBindPkg{
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, _, err := resolveCurrentSubscriptions(tt.rows, []string{"8986001"}); err == nil {
t.Fatal("resolveCurrentSubscriptions() error = nil")
}
})
}
}
func TestSelectCurrentSubscriptionIgnoresCardAddPackage(t *testing.T) {
iccid := "8986000000000000103"
formal := &rc.SimCardBindPkg{
Iccid: iccid,
PackageType: consts.PackageTypeCard,
PackageCode: "CARD-FORMAL",
EffectiveStatus: consts.EffectiveStatusEffective,
}
addPackage := &rc.SimCardBindPkg{
Iccid: iccid,
PackageType: consts.PackageTypeCardAddPackage,
PackageCode: "CARD-ADD",
EffectiveStatus: consts.EffectiveStatusEffective,
}
current, future, err := selectCurrentSubscription([]*rc.SimCardBindPkg{formal, addPackage}, iccid)
if err != nil {
t.Fatalf("selectCurrentSubscription() error = %v", err)
}
if current != formal {
t.Fatalf("current package = %+v, want formal package", current)
}
if len(future) != 0 {
t.Fatalf("future package count = %d, want 0", len(future))
}
}
func TestSelectCurrentSubscriptionIgnoresPoolAddPackage(t *testing.T) {
iccid := "8986000000000000104"
formal := &rc.SimCardBindPkg{
Iccid: iccid,
PackageType: consts.PackageTypePool,
PackageCode: "POOL-FORMAL",
EffectiveStatus: consts.EffectiveStatusEffective,
}
addPackage := &rc.SimCardBindPkg{
Iccid: iccid,
PackageType: consts.PackageTypePoolAddPackage,
PackageCode: "POOL-ADD",
EffectiveStatus: consts.EffectiveStatusEffective,
}
current, _, err := selectCurrentSubscription([]*rc.SimCardBindPkg{addPackage, formal}, iccid)
if err != nil {
t.Fatalf("selectCurrentSubscription() error = %v", err)
}
if current != formal {
t.Fatalf("current package = %+v, want formal package", current)
}
}
func TestSelectCurrentSubscriptionRejectsTwoEffectiveFormalPackages(t *testing.T) {
iccid := "8986000000000000102"
rows := []*rc.SimCardBindPkg{
{
Iccid: iccid,
PackageType: consts.PackageTypeCard,
PackageCode: "FORMAL-1",
EffectiveStatus: consts.EffectiveStatusEffective,
},
{
Iccid: iccid,
PackageType: consts.PackageTypePool,
PackageCode: "FORMAL-2",
EffectiveStatus: consts.EffectiveStatusEffective,
},
}
_, _, err := selectCurrentSubscription(rows, iccid)
if err == nil {
t.Fatal("selectCurrentSubscription() error = nil, want duplicate formal package error")
}
if !strings.Contains(err.Error(), "多个生效中的套餐订购记录") {
t.Fatalf("error = %q, want duplicate formal package message", err.Error())
}
}
func TestResolveCurrentSubscriptionsAllowsChainedPendingRenewals(t *testing.T) {
rows := []*rc.SimCardBindPkg{
{Iccid: "8986001", SubscriptionNo: "SUB-E", EffectiveStatus: consts.EffectiveStatusEffective},
{Iccid: "8986001", SubscriptionNo: "SUB-P2", EffectiveStatus: consts.EffectiveStatusPending, ExpectedExpireTime: gtime.NewFromStr("2028-01-01 00:00:00")},
{Iccid: "8986001", SubscriptionNo: "SUB-P1", EffectiveStatus: consts.EffectiveStatusPending, ExpectedExpireTime: gtime.NewFromStr("2027-01-01 00:00:00")},
}
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
if err != nil {
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
}
if current["8986001"].SubscriptionNo != "SUB-E" {
t.Fatalf("current subscription = %q, want SUB-E", current["8986001"].SubscriptionNo)
}
if len(future["8986001"]) != 2 {
t.Fatalf("future subscriptions len = %d, want 2", len(future["8986001"]))
}
if future["8986001"][0].SubscriptionNo != "SUB-P1" || future["8986001"][1].SubscriptionNo != "SUB-P2" {
t.Fatalf("future subscriptions order = %+v, want SUB-P1,SUB-P2", future["8986001"])
}
}
func TestResolveCurrentSubscriptionsFallsBackToLatestExpiredSubscription(t *testing.T) {
rows := []*rc.SimCardBindPkg{
{
Iccid: "8986001",
SubscriptionNo: "SUB-X1",
EffectiveStatus: consts.EffectiveStatusExpired,
ExpectedExpireTime: gtime.NewFromStr("2026-01-01 00:00:00"),
},
{
Iccid: "8986001",
SubscriptionNo: "SUB-X2",
EffectiveStatus: consts.EffectiveStatusExpired,
ExpectedExpireTime: gtime.NewFromStr("2026-07-01 00:00:00"),
},
{
Iccid: "8986001",
SubscriptionNo: "SUB-T",
EffectiveStatus: consts.EffectiveStatusTerminated,
},
}
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
if err != nil {
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
}
if current["8986001"] == nil || current["8986001"].SubscriptionNo != "SUB-X2" {
t.Fatalf("current subscription = %+v, want SUB-X2", current["8986001"])
}
if len(future["8986001"]) != 0 {
t.Fatalf("future subscriptions = %+v, want empty", future["8986001"])
}
}
func TestResolveCurrentSubscriptionsPrefersPendingOverExpiredHistory(t *testing.T) {
rows := []*rc.SimCardBindPkg{
{
Iccid: "8986001",
SubscriptionNo: "SUB-X",
EffectiveStatus: consts.EffectiveStatusExpired,
ExpectedExpireTime: gtime.NewFromStr("2026-07-01 00:00:00"),
},
{
Iccid: "8986001",
SubscriptionNo: "SUB-P",
EffectiveStatus: consts.EffectiveStatusPending,
ExpectedExpireTime: gtime.NewFromStr("2027-07-01 00:00:00"),
},
}
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
if err != nil {
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
}
if current["8986001"] == nil || current["8986001"].SubscriptionNo != "SUB-P" {
t.Fatalf("current subscription = %+v, want SUB-P", current["8986001"])
}
if len(future["8986001"]) != 0 {
t.Fatalf("future subscriptions = %+v, want empty", future["8986001"])
}
}
func TestResolveCurrentSubscriptionsStillRejectsOnlyTerminatedOrVoidedHistory(t *testing.T) {
rows := []*rc.SimCardBindPkg{
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusTerminated},
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusVoided},
}
if _, _, err := resolveCurrentSubscriptions(rows, []string{"8986001"}); err == nil {
t.Fatal("terminated/voided history was accepted as current subscription")
}
}
func TestValidateCurrentSubscriptionChecksPackageAndBusinessSource(t *testing.T) {
card, bind, expected := validSubscriptionValidationFixture()
if err := validateCurrentSubscription(card, bind, expected); err != nil {
t.Fatalf("validateCurrentSubscription() error = %v", err)
}
bind.PackageCode = "OTHER"
if err := validateCurrentSubscription(card, bind, expected); err == nil {
t.Fatal("package mismatch was accepted")
}
bind.PackageCode = expected.PackageCode
bind.BatchNo = "OUT-OTHER"
card.OutboundBatchNo = "OUT-OTHER"
if err := validateCurrentSubscription(card, bind, expected); err == nil {
t.Fatal("source batch mismatch was accepted")
}
}
func TestValidateCurrentSubscriptionIgnoresSourceForInputSelfReference(t *testing.T) {
card, bind, expected := validSubscriptionValidationFixture()
bind.BatchNo = "OUT-ACTUAL"
card.OutboundBatchNo = ""
expected.SourceBatchNo = ""
if err := validateCurrentSubscription(card, bind, expected); err != nil {
t.Fatalf("input source should be ignored, got %v", err)
}
}
func TestValidateCurrentSubscriptionRejectsIdentityChanges(t *testing.T) {
tests := []struct {
name string
mutate func(*rc.SimCardInfo, *rc.SimCardBindPkg, *subscriptionValidation)
}{
{name: "ICCID", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.Iccid = "8986002" }},
{name: "MSISDN", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) {
card.Msisdn = "13900000000"
}},
{name: "IMSI", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.Imsi = "460002" }},
{name: "tenant", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.TenantId = "OTHER" }},
{name: "customer", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.DeptId = 118 }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
card, bind, expected := validSubscriptionValidationFixture()
tt.mutate(card, bind, &expected)
if err := validateCurrentSubscription(card, bind, expected); err == nil {
t.Fatalf("%s change was accepted", tt.name)
}
})
}
}
func TestValidateCurrentSubscriptionAllowsAllocatedCardWithHistoricalBindDept(t *testing.T) {
card, bind, expected := validSubscriptionValidationFixture()
bind.DeptId = 100
if err := validateCurrentSubscription(card, bind, expected); err != nil {
t.Fatalf("allocated card with historical bind department was rejected: %v", err)
}
}
func validSubscriptionValidationFixture() (*rc.SimCardInfo, *rc.SimCardBindPkg, subscriptionValidation) {
expected := subscriptionValidation{
ICCID: "8986001",
MSISDN: "13800000000",
IMSI: "460001",
TenantID: "000000",
CustomerDeptID: 117,
PackageCode: "PKG-OLD",
SourceBatchNo: "OUT-1",
}
card := &rc.SimCardInfo{
Iccid: expected.ICCID,
Msisdn: expected.MSISDN,
Imsi: expected.IMSI,
DeptId: expected.CustomerDeptID,
CurrentPackageCode: expected.PackageCode,
PackageStatus: consts.PackageStatusEffective,
OutboundBatchNo: expected.SourceBatchNo,
TenantId: expected.TenantID,
}
bind := &rc.SimCardBindPkg{
Iccid: expected.ICCID,
Msisdn: expected.MSISDN,
DeptId: expected.CustomerDeptID,
PackageCode: expected.PackageCode,
EffectiveStatus: consts.EffectiveStatusEffective,
BatchNo: expected.SourceBatchNo,
TenantId: expected.TenantID,
}
return card, bind, expected
}
+343
View File
@@ -0,0 +1,343 @@
package renewchange
import (
"context"
"fmt"
"sort"
"strings"
"time"
"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/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
const (
SourceTypeExcel = 3
SourceTypeInput = 2
// 价格来源
PriceSourceSystem = "system"
PriceSourceAgentDefault = "agentDefault"
PriceSourceTarget = "target"
// Agent 价格 scope_type
PriceScopeAgentDefault = 0
PriceScopeTargetAgent = 1
)
// RenewRow 续费/变更行(按业务语义复用同一行结构)。
type RenewRow struct {
RowNo int
ICCID string
MSISDN string
IMSI string
NewPkgCode string
Reason string
PackageCode string
CustomerID int64
// 续费使用
OutboundBatchNo string
// 变更使用
OldPkgCode string
OldPkgName string
OldBindSubNo string
OldBindOrdNo string
OldEffTime *gtime.Time
OldExpireTime *gtime.Time
// 准备结果快照
SourceBatchNo string
}
// RenewPriceSnapshot 续费价格快照(与 Admin 对齐)。
type RenewPriceSnapshot struct {
CustomerAgentId int64 `json:"customerAgentId"`
PriceOwnerDeptId int64 `json:"priceOwnerDeptId"`
PackageCode string `json:"packageCode"`
PackageName string `json:"packageName"`
BillingMethod int `json:"billingMethod"`
RenewDurationUnit string `json:"renewDurationUnit"`
RenewDurationUnitText string `json:"renewDurationUnitText"`
PriceSource string `json:"priceSource"`
AgentDefaultPriceId int64 `json:"agentDefaultPriceId"`
TargetPriceId int64 `json:"targetPriceId"`
RenewUnitPrice float64 `json:"renewUnitPrice"`
RenewableDurationOptions []int `json:"renewableDurationOptions"`
RenewablePackageCodes []string `json:"renewablePackageCodes"`
RenewablePackages []RenewPackageOption `json:"renewablePackages"`
RenewBeginDate string `json:"renewBeginDate"`
RenewBeginDateMin string `json:"renewBeginDateMin"`
RenewBeginDateMax string `json:"renewBeginDateMax"`
SameRenewBeginDate bool `json:"sameRenewBeginDate"`
}
type RenewPackageOption struct {
PackageCode string `json:"packageCode"`
PackageName string `json:"packageName"`
RenewUnitPrice float64 `json:"renewUnitPrice"`
RenewableDurationOptions []int `json:"renewableDurationOptions"`
}
// ChangePriceSnapshot 变更价格快照。
type ChangePriceSnapshot struct {
CustomerAgentId int64 `json:"customerAgentId"`
NewPackageCode string `json:"newPackageCode"`
NewPackageName string `json:"newPackageName"`
PriceSource string `json:"priceSource"`
AgentDefaultPriceId int64 `json:"agentDefaultPriceId"`
TargetPriceId int64 `json:"targetPriceId"`
ChangeUnitPrice float64 `json:"changeUnitPrice"`
RenewableDurationOptions []int `json:"renewableDurationOptions"`
}
// ChangePrepareSnapshot 变更预处理快照。
type ChangePrepareSnapshot struct {
SourceBatchNo string `json:"sourceBatchNo"`
CustomerAgentId int64 `json:"customerAgentId"`
CustomerName string `json:"customerName"`
OldPackageCode string `json:"oldPackageCode"`
OldPackageName string `json:"oldPackageName"`
OldPackageType int `json:"oldPackageType"`
OldPackageFlowMb float64 `json:"oldPackageFlowMb"`
Quantity int `json:"quantity"`
}
// Task 续费/变更通用任务。
type Task struct {
Batch *rc.BatchMainRow
Rows []*RenewRow
FailureReason string
ValidCount int
SuccessCount int
FailCount int
// 续费
PriceSnapshot *RenewPriceSnapshot
// 变更
PrepareSnapshot *ChangePrepareSnapshot
PriceSnapshot2 *ChangePriceSnapshot
}
// Executor 抽象。
type Executor interface {
Execute(ctx context.Context, batchNo string) error
}
// NowFunc 提供时钟,便于测试。
type NowFunc func() time.Time
// timeNow 默认时钟。
func timeNow() time.Time {
return time.Now()
}
// addMonths 在给定时间上增加 N 个月,保持日时分秒。
func addMonths(t time.Time, months int) time.Time {
if months <= 0 {
return t
}
targetMonth := time.Date(
t.Year(),
t.Month()+time.Month(months),
1,
t.Hour(),
t.Minute(),
t.Second(),
t.Nanosecond(),
t.Location(),
)
day := t.Day()
lastDay := time.Date(
targetMonth.Year(),
targetMonth.Month()+1,
0,
t.Hour(),
t.Minute(),
t.Second(),
t.Nanosecond(),
t.Location(),
).Day()
if day > lastDay {
day = lastDay
}
return time.Date(
targetMonth.Year(),
targetMonth.Month(),
day,
t.Hour(),
t.Minute(),
t.Second(),
t.Nanosecond(),
t.Location(),
)
}
func renewBeginDateFromExpire(expireAt *gtime.Time) *gtime.Time {
if expireAt == nil {
return nil
}
return dayEndAt(expireAt)
}
func renewBeginTimeFromExpire(expireAt *gtime.Time) (time.Time, bool) {
beginAt := renewBeginDateFromExpire(expireAt)
if beginAt == nil {
return time.Time{}, false
}
return beginAt.Time, true
}
func previousMonthEndAt(t *gtime.Time) *gtime.Time {
if t == nil {
return nil
}
tt := t.Time
return gtime.NewFromTime(time.Date(tt.Year(), tt.Month(), 0, 23, 59, 59, 0, tt.Location()))
}
func dayEndAt(t *gtime.Time) *gtime.Time {
if t == nil {
return nil
}
tt := t.Time
return gtime.NewFromTime(time.Date(tt.Year(), tt.Month(), tt.Day(), 23, 59, 59, 0, tt.Location()))
}
func renewDurationUnitForBillingMethod(billingMethod int) string {
return consts.BillingMethodDurationUnit(billingMethod)
}
func renewDurationUnitForPackage(pkg *rc.FlowPackageInfo) string {
return consts.RenewDurationUnitYear
}
func renewableYearOptions(config *gjson.Json) ([]int, error) {
values, err := rc.RenewableDurationOptions(config)
if err != nil {
return nil, err
}
legacyMonthConfig := len(values) > 0
for _, value := range values {
if value < 12 || value%12 != 0 {
legacyMonthConfig = false
break
}
}
if !legacyMonthConfig {
return values, nil
}
years := make([]int, 0, len(values))
for _, value := range values {
years = append(years, value/12)
}
sort.Ints(years)
return years, nil
}
// changeDurationOptions returns months because the change contract and the
// subscription records store the actual service duration in months. The
// package configuration is normally expressed in years, while legacy rows
// may still contain month values such as [12, 24, 36].
func changeDurationOptions(config *gjson.Json) ([]int, error) {
values, err := rc.RenewableDurationOptions(config)
if err != nil {
return nil, err
}
legacyMonthConfig := len(values) > 0
for _, value := range values {
if value < 12 || value%12 != 0 {
legacyMonthConfig = false
break
}
}
if legacyMonthConfig {
return values, nil
}
months := make([]int, 0, len(values))
for _, value := range values {
if value > 0 {
months = append(months, value*12)
}
}
sort.Ints(months)
if len(months) == 0 {
return nil, gerror.New("package has no valid change duration options")
}
return months, nil
}
func renewDurationLabel(value int, unit string) string {
if value <= 0 {
return ""
}
return fmt.Sprintf("%d%s", value, consts.RenewDurationUnitText(unit))
}
func parseRenewDate(value string, now NowFunc) (*gtime.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
t := now()
return gtime.NewFromTime(t), nil
}
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
if err != nil {
parsed, err = time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
if err != nil {
return nil, err
}
}
return gtime.NewFromTime(parsed), nil
}
func formatTime(t *gtime.Time) string {
if t == nil {
return ""
}
return t.Format("Y-m-d H:i:s")
}
func snapshotIntSlice(j *gjson.Json, path string) []int {
if j == nil {
return nil
}
raw := j.Get(path)
if raw == nil || raw.IsNil() {
return nil
}
values := raw.Interfaces()
out := make([]int, 0, len(values))
for _, v := range values {
switch n := v.(type) {
case int:
out = append(out, n)
case int32:
out = append(out, int(n))
case int64:
out = append(out, int(n))
case float32:
out = append(out, int(n))
case float64:
out = append(out, int(n))
}
}
sort.Ints(out)
return out
}
// TxFn 在事务内执行回调。
type TxFn func(ctx context.Context, fn func(ctx context.Context, tx gdb.TX) error) error
// DefaultTx 真实事务实现。
func DefaultTx(database gdb.DB) TxFn {
if database == nil {
database = rc.DB()
}
return func(ctx context.Context, fn func(ctx context.Context, tx gdb.TX) error) error {
return database.Transaction(ctx, fn)
}
}