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() }