package renewchange import ( "context" "sort" "strconv" "strings" "time" "github.com/gogf/gf/v2/container/gvar" "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" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange" ) const prepareProcessingRetryAfter = 2 * time.Minute // PrepareExecutor 处理 batch_type=4 的预处理批次。 // 它根据 batch_info.targetBatchType 把工作路由到 renew/change 的准备逻辑上。 // // 成功:保持 batch_type=4, batch_async_status=Success, run_result=Success; // // 写 batch_info 报价/客户/套餐快照;不改业务表。 // // 失败:转为目标业务类型后写 Failed;明细失败 + 原因;不改业务表。 // 旧/重复消息:被处理过的批次(async != Processing)会直接安全忽略。 type PrepareExecutor struct { repo *Repository nowFunc NowFunc } // NewPrepareExecutor 返回默认的预处理执行器。 func NewPrepareExecutor(repo *Repository) *PrepareExecutor { if repo == nil { repo = NewRepository() } return &PrepareExecutor{repo: repo, nowFunc: timeNow} } // SetNowFunc 注入时钟,便于测试。 func (p *PrepareExecutor) SetNowFunc(now NowFunc) { p.nowFunc = now } // CanExecuteProcessing allows a redelivered message to reclaim a stale prepare // batch after a worker crash or shutdown between claim and finalization. func (p *PrepareExecutor) CanExecuteProcessing(_ context.Context, main *entity.BatchMain) (bool, error) { if main == nil || main.BatchType != consts.BatchTypePrepare || main.BatchAsyncStatus != consts.BatchAsyncStatusProcessing { return false, nil } if main.StartTime == nil || main.StartTime.IsZero() { return true, nil } return p.nowFunc().Sub(main.StartTime.Time) >= prepareProcessingRetryAfter, nil } // Execute 实现 batch.Executor。 func (p *PrepareExecutor) Execute(ctx context.Context, batchNo string) error { batchNo = strings.TrimSpace(batchNo) if batchNo == "" { return gerror.New("prepare batch number is empty") } main, err := p.repo.LoadProcessingBatch(ctx, batchNo, consts.BatchTypePrepare) if err != nil { return err } targetType := readTargetBatchType(main.BatchInfo) if targetType != consts.BatchTypeRenew && targetType != consts.BatchTypeChange { return p.finalizeBatchFailure(ctx, main, "预处理批次缺少合法 targetBatchType(必须是 101 或 102)", nil) } details, err := p.repo.LoadRunningDetails(ctx, batchNo, consts.BatchTypePrepare) if err != nil { return gerror.Wrap(err, "load prepare details failed") } if len(details) == 0 { return p.finalizeBatchFailure(ctx, main, "预处理批次无可处理的明细", nil) } options := loadPrepareOptions(main.BatchInfo) result, err := p.validate(ctx, main, targetType, options, details) if err != nil { return gerror.Wrap(err, "prepare validate failed") } if result.hasFailure { return p.finalizeBatchFailure(ctx, main, result.failureReason, mergeRowErrors(result.perRow, result.rowErrors)) } return p.finalizeBatchSuccess(ctx, main, targetType, result, details) } type prepareOptions struct { customerAgentID int64 targetAgentID int64 priceScope int // change 专用 newPackageCode string durationValue int durationSet bool } type prepareOutcome struct { hasFailure bool failureReason string perRow map[int]string priceSnap *RenewPriceSnapshot changeSnap *ChangePrepareSnapshot changePrice *ChangePriceSnapshot customer *customerInfo packageInfo *packageInfo rowErrors map[int]string rows map[int]*RenewRow } type customerInfo struct { AgentID int64 `json:"agentId"` Name string `json:"name"` DeptID int64 `json:"deptId"` TenantID string `json:"tenantId"` } type packageInfo struct { Code string `json:"code"` Name string `json:"name"` Type int `json:"type"` FlowMb float64 `json:"flowMb"` Duration int `json:"durationMonths"` UnitPrice float64 `json:"unitPrice"` } const renewSamePeriodFailureReason = "同一批续费卡当前套餐开始时间和到期时间必须一致" const renewExpireWindowMonths = 6 const renewExpireFailureReason = "距离到期超过6个月,不能续费" func (p *PrepareExecutor) validate(ctx context.Context, main *rc.BatchMainRow, targetType int, options prepareOptions, details []*rc.BatchDetailRow) (*prepareOutcome, error) { now := p.nowFunc() rows := make([]*RenewRow, 0, len(details)) iccids := make([]string, 0, len(details)) rowErrors := map[int]string{} for _, d := range details { if d == nil { continue } iccid := strings.TrimSpace(d.ItemId) if iccid == "" && d.Snapshot != nil { iccid = prepareJSONValue(d.Snapshot, "iccid", "ICCID") } row := &RenewRow{ RowNo: d.RowNo, ICCID: iccid, Reason: strings.TrimSpace(d.ErrorReason), } if d.Snapshot != nil { row.MSISDN = prepareJSONValue(d.Snapshot, "msisdn", "MSISDN") row.IMSI = prepareJSONValue(d.Snapshot, "imsi", "IMSI") row.NewPkgCode = strings.TrimSpace(d.Snapshot.Get("newPackageCode").String()) row.PackageCode = strings.TrimSpace(d.Snapshot.Get("packageCode").String()) row.OldPkgCode = strings.TrimSpace(d.Snapshot.Get("oldPackageCode").String()) row.OldPkgName = strings.TrimSpace(d.Snapshot.Get("oldPackageName").String()) row.OutboundBatchNo = strings.TrimSpace(d.Snapshot.Get("outboundBatchNo").String()) row.SourceBatchNo = strings.TrimSpace(d.Snapshot.Get("sourceBatchNo").String()) } rows = append(rows, row) } if err := p.resolvePrepareRowIdentities(ctx, main.TenantId, rows, rowErrors); err != nil { return nil, err } iccids = collectPrepareIccids(rows, rowErrors) cards, err := p.repo.Tables().LoadCardsByIccids(ctx, iccids) if err != nil { return nil, gerror.Wrap(err, "load sim cards failed") } var bindRows []*rc.SimCardBindPkg if targetType == consts.BatchTypeRenew { bindRows, err = p.repo.Tables().LoadRenewBindRowsByIccids(ctx, iccids) } else { bindRows, err = p.repo.Tables().LoadActiveBindRowsByIccids(ctx, iccids) } if err != nil { return nil, gerror.Wrap(err, "load current subscriptions failed") } out := &prepareOutcome{ perRow: map[int]string{}, rowErrors: rowErrors, rows: make(map[int]*RenewRow, len(rows)), } for _, row := range rows { out.rows[row.RowNo] = row } sourceBatchNo := executionSourceBatchNo(main, main.BatchInfo) // 选择基准套餐/价格:先按相同 packageCode 收敛;多个则统一失败。 packageCodes := map[string]struct{}{} customerIDs := map[int64]struct{}{} for _, row := range rows { if row.Reason != "" { out.rowErrors[row.RowNo] = row.Reason } } for _, row := range rows { if strings.TrimSpace(out.rowErrors[row.RowNo]) != "" { continue } if row.ICCID == "" { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], "ICCID不能为空") continue } card, ok := cards[row.ICCID] if !ok || card == nil { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], rcLocalize("errCardNotFound")) continue } if card.TenantId != "" && main.TenantId != "" && card.TenantId != main.TenantId { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], "ICCID不属于当前租户") continue } if card.DeptId <= 0 { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], rcLocalize("errCardNotFound")) continue } if strings.TrimSpace(card.CurrentPackageCode) == "" { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], rcLocalize("errCardNoCurrentPackage")) continue } bind, futureBinds, bindErr := selectCurrentSubscription(bindRows, row.ICCID) if bindErr != nil { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], bindErr.Error()) continue } if bindErr = validateCurrentSubscription(card, bind, subscriptionValidation{ ICCID: row.ICCID, MSISDN: row.MSISDN, IMSI: row.IMSI, TenantID: main.TenantId, CustomerDeptID: card.DeptId, PackageCode: card.CurrentPackageCode, SourceBatchNo: sourceBatchNo, }); bindErr != nil { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], bindErr.Error()) continue } periodBind := bind if targetType == consts.BatchTypeRenew { periodBind = renewBaseSubscriptionForCard(card, bind, futureBinds) if periodBind == nil || periodBind.ExpectedExpireTime == nil { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], "没有预计到期时间,不能续费") continue } if renewExpireAfterAllowedWindow(periodBind.ExpectedExpireTime, now) { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], renewExpireFailureReason) continue } } row.MSISDN = strings.TrimSpace(card.Msisdn) row.IMSI = strings.TrimSpace(card.Imsi) row.CustomerID = card.DeptId row.PackageCode = strings.TrimSpace(card.CurrentPackageCode) row.OldPkgCode = strings.TrimSpace(periodBind.PackageCode) row.OldBindSubNo = strings.TrimSpace(periodBind.SubscriptionNo) row.OldBindOrdNo = strings.TrimSpace(periodBind.OrderCode) row.OldEffTime = periodBind.EffectiveTime row.OldExpireTime = periodBind.ExpectedExpireTime row.SourceBatchNo = sourceBatchNo customerIDs[card.DeptId] = struct{}{} packageCodes[row.PackageCode] = struct{}{} } if setPrepareFailureFromRows(out, rows) { return out, nil } // 续费/变更专属校验 if targetType == consts.BatchTypeRenew { if len(packageCodes) > 1 { out.rowErrors[0] = rcLocalize("errMultiplePackages") out.hasFailure = true out.failureReason = rcLocalize("errMultiplePackages") return out, nil } if err := validateRenewSameCurrentPeriod(rows, out.rowErrors); err != nil { out.rowErrors[0] = err.Error() out.hasFailure = true out.failureReason = err.Error() return out, nil } } else { if options.newPackageCode == "" { out.rowErrors[0] = rcLocalize("errNewPackageNotInChange") out.hasFailure = true out.failureReason = "变更批次缺少新套餐编码" return out, nil } } // 同批客户单一性:多个客户按全批失败处理。 if len(customerIDs) > 1 { out.rowErrors[0] = rcLocalize("errMultipleCustomers") out.hasFailure = true out.failureReason = rcLocalize("errMultipleCustomers") return out, nil } // 选择客户 var customerID int64 for id := range customerIDs { customerID = id } // 加载套餐、客户、价格 var ( curPkg *rc.FlowPackageInfo newPkg *rc.FlowPackageInfo ) for code := range packageCodes { pkg, err := p.repo.Tables().LoadPackageByCode(ctx, main.TenantId, code) if err != nil || pkg == nil { out.rowErrors[0] = rcLocalize("errPkgNotFound") out.hasFailure = true out.failureReason = "当前套餐不存在或未启用" return out, nil } if pkg.Enabled != 1 { out.rowErrors[0] = rcLocalize("errPkgDisabled") out.hasFailure = true out.failureReason = "当前套餐未启用" return out, nil } curPkg = pkg for _, row := range rows { if row.PackageCode == pkg.PackageCode { row.OldPkgName = pkg.PackageName } } } if targetType == consts.BatchTypeChange { newPkg, err = p.repo.Tables().LoadPackageByCode(ctx, main.TenantId, options.newPackageCode) if err != nil || newPkg == nil { out.rowErrors[0] = rcLocalize("errTargetPkgNotFound") out.hasFailure = true out.failureReason = "目标套餐不存在或未启用" return out, nil } if newPkg.Enabled != 1 { out.rowErrors[0] = rcLocalize("errPkgDisabled") out.hasFailure = true out.failureReason = "目标套餐未启用" return out, nil } if curPkg != nil && curPkg.PackageType != newPkg.PackageType { out.rowErrors[0] = rcLocalize("errPackageTypeMismatch") out.hasFailure = true out.failureReason = rcLocalize("errPackageTypeMismatch") return out, nil } if curPkg != nil && curPkg.PackageCode == newPkg.PackageCode { out.rowErrors[0] = rcLocalize("errNewPackageSameAsOld") out.hasFailure = true out.failureReason = rcLocalize("errNewPackageSameAsOld") return out, nil } if curPkg.ChangeablePackageCodes == nil || curPkg.ChangeablePackageCodes.IsNil() { out.rowErrors[0] = rcLocalize("errNoChangeablePackages") out.hasFailure = true out.failureReason = rcLocalize("errNoChangeablePackages") return out, nil } allowed := false for _, c := range curPkg.ChangeablePackageCodes.Interfaces() { if strings.TrimSpace(stringify(c)) == newPkg.PackageCode { allowed = true break } } if !allowed { out.rowErrors[0] = rcLocalize("errNewPackageNotInChange") out.hasFailure = true out.failureReason = rcLocalize("errNewPackageNotInChange") return out, nil } for _, row := range rows { row.NewPkgCode = newPkg.PackageCode } } customerName, err := p.repo.Tables().LoadDeptName(ctx, main.TenantId, customerID) if err != nil || customerName == "" { out.rowErrors[0] = rcLocalize("errDeptNotFound") out.hasFailure = true out.failureReason = rcLocalize("errDeptNotFound") return out, nil } customer := &customerInfo{ AgentID: options.customerAgentID, Name: customerName, DeptID: customerID, TenantID: main.TenantId, } out.customer = customer // 单卡校验 for _, row := range rows { if row.ICCID == "" { continue } card := cards[row.ICCID] if card == nil { continue } // 续费时长校验 if targetType == consts.BatchTypeRenew { if curPkg.RenewableDurationConfig == nil || curPkg.RenewableDurationConfig.IsNil() { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], rcLocalize("errRenewDurationEmpty")) continue } opts, err := renewableYearOptions(curPkg.RenewableDurationConfig) if err != nil || len(opts) == 0 { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], rcLocalize("errRenewDurationEmpty")) continue } if options.durationSet && !rc.ContainsInt(opts, options.durationValue) { out.rowErrors[row.RowNo] = appendIfEmpty(out.rowErrors[row.RowNo], rcLocalize("errRenewableDurationMiss")) continue } } } // 解析价格 priceSnap, changePrice, err := p.resolvePrice(ctx, main, options, targetType, curPkg, newPkg, customer) if err != nil { out.rowErrors[0] = err.Error() out.hasFailure = true out.failureReason = err.Error() return out, nil } out.priceSnap = priceSnap if targetType == consts.BatchTypeRenew { fillRenewBeginSnapshot(priceSnap, rows, out.rowErrors) } if changePrice != nil { out.changePrice = changePrice } if targetType == consts.BatchTypeChange && curPkg != nil { out.changeSnap = &ChangePrepareSnapshot{ SourceBatchNo: executionSourceBatchNo(main, main.BatchInfo), CustomerAgentId: customer.DeptID, CustomerName: customer.Name, OldPackageCode: curPkg.PackageCode, OldPackageName: curPkg.PackageName, OldPackageType: curPkg.PackageType, OldPackageFlowMb: curPkg.FlowMb, Quantity: len(rows), } } if curPkg != nil { out.packageInfo = &packageInfo{ Code: curPkg.PackageCode, Name: curPkg.PackageName, Type: curPkg.PackageType, FlowMb: curPkg.FlowMb, Duration: options.durationValue, UnitPrice: priceSnap.RenewUnitPrice, } } // 把行级错误汇总到 hasFailure for _, msg := range out.rowErrors { if strings.TrimSpace(msg) != "" { out.hasFailure = true break } } if out.hasFailure { if out.failureReason == "" { if len(rows) == 1 { out.failureReason = strings.TrimSpace(out.rowErrors[rows[0].RowNo]) } if out.failureReason != "" { return out, nil } out.failureReason = "同批存在不满足条件的卡,本次未执行" } } return out, nil } func setPrepareFailureFromRows(out *prepareOutcome, rows []*RenewRow) bool { if out == nil { return false } for _, msg := range out.rowErrors { if strings.TrimSpace(msg) != "" { out.hasFailure = true break } } if !out.hasFailure { return false } if len(rows) == 1 { out.failureReason = strings.TrimSpace(out.rowErrors[rows[0].RowNo]) } if out.failureReason == "" { out.failureReason = "同批存在不满足条件的卡,本次未执行" } return true } func (p *PrepareExecutor) resolvePrepareRowIdentities(ctx context.Context, tenantID string, rows []*RenewRow, rowErrors map[int]string) error { keys := collectPrepareIdentityKeys(rows, rowErrors) cards, err := p.repo.Tables().LoadCardsByIdentityValues(ctx, tenantID, keys) if err != nil { return gerror.Wrap(err, "load prepare identity cards failed") } resolvePrepareIdentityRowsFromCards(rows, rowErrors, cards) return nil } func collectPrepareIdentityKeys(rows []*RenewRow, rowErrors map[int]string) []string { keys := make([]string, 0, len(rows)*3) for _, row := range rows { if row == nil || row.Reason != "" || strings.TrimSpace(rowErrors[row.RowNo]) != "" { continue } keys = append(keys, row.ICCID, row.MSISDN, row.IMSI) } return uniquePrepareStrings(keys) } func collectPrepareIccids(rows []*RenewRow, rowErrors map[int]string) []string { seen := make(map[string]int, len(rows)) iccids := make([]string, 0, len(rows)) for _, row := range rows { if row == nil || row.Reason != "" || strings.TrimSpace(rowErrors[row.RowNo]) != "" { continue } row.ICCID = strings.TrimSpace(row.ICCID) if row.ICCID == "" { continue } if firstRowNo, exists := seen[row.ICCID]; exists { reason := rcLocalize("errDuplicateICCID") rowErrors[row.RowNo] = appendIfEmpty(rowErrors[row.RowNo], reason) rowErrors[firstRowNo] = appendIfEmpty(rowErrors[firstRowNo], reason) continue } seen[row.ICCID] = row.RowNo iccids = append(iccids, row.ICCID) } return iccids } func resolvePrepareIdentityRowsFromCards(rows []*RenewRow, rowErrors map[int]string, cards []*rc.SimCardInfo) { resolver := newPrepareIdentityResolver(cards) for _, row := range rows { if row == nil || row.Reason != "" || strings.TrimSpace(rowErrors[row.RowNo]) != "" { continue } identities := uniquePrepareStrings([]string{row.ICCID, row.MSISDN, row.IMSI}) if len(identities) == 0 { rowErrors[row.RowNo] = "ICCID/MSISDN/IMSI不能为空" continue } card, err := resolver.resolve(identities) if err != nil { rowErrors[row.RowNo] = err.Error() continue } if card == nil { continue } if !prepareCardIdentityMatchesAll(card, identities) { rowErrors[row.RowNo] = "ICCID/MSISDN/IMSI与卡信息不一致" continue } row.ICCID = strings.TrimSpace(card.Iccid) row.MSISDN = strings.TrimSpace(card.Msisdn) row.IMSI = strings.TrimSpace(card.Imsi) } } type prepareIdentityResolver struct { byValue map[string]map[int64]*rc.SimCardInfo } func newPrepareIdentityResolver(cards []*rc.SimCardInfo) *prepareIdentityResolver { resolver := &prepareIdentityResolver{byValue: map[string]map[int64]*rc.SimCardInfo{}} for _, card := range cards { resolver.addCard(card) } return resolver } func (r *prepareIdentityResolver) addCard(card *rc.SimCardInfo) { if r == nil || card == nil || card.Id <= 0 { return } r.addValue(card.Iccid, card) r.addValue(card.Msisdn, card) r.addValue(card.Imsi, card) } func (r *prepareIdentityResolver) addValue(value string, card *rc.SimCardInfo) { value = strings.TrimSpace(value) if r == nil || value == "" || card == nil || card.Id <= 0 { return } if r.byValue == nil { r.byValue = map[string]map[int64]*rc.SimCardInfo{} } matches := r.byValue[value] if matches == nil { matches = map[int64]*rc.SimCardInfo{} r.byValue[value] = matches } matches[card.Id] = card } func (r *prepareIdentityResolver) resolve(values []string) (*rc.SimCardInfo, error) { if r == nil || len(values) == 0 { return nil, nil } matches := map[int64]*rc.SimCardInfo{} for _, value := range uniquePrepareStrings(values) { for id, card := range r.byValue[strings.TrimSpace(value)] { matches[id] = card } } switch len(matches) { case 0: return nil, nil case 1: for _, card := range matches { return card, nil } } return nil, gerror.New("卡号对应多条卡记录,请输入 ICCID") } func prepareCardIdentityMatchesAll(card *rc.SimCardInfo, values []string) bool { if card == nil { return false } for _, value := range values { if !prepareCardIdentityMatches(card, value) { return false } } return true } func prepareCardIdentityMatches(card *rc.SimCardInfo, value string) bool { value = strings.TrimSpace(value) if value == "" || card == nil { return false } return value == strings.TrimSpace(card.Iccid) || value == strings.TrimSpace(card.Msisdn) || value == strings.TrimSpace(card.Imsi) } func prepareJSONValue(json *gjson.Json, keys ...string) string { if json == nil { return "" } for _, key := range keys { if value := strings.TrimSpace(json.Get(key).String()); value != "" { return value } } return "" } func uniquePrepareStrings(list []string) []string { if len(list) == 0 { return nil } seen := make(map[string]struct{}, len(list)) out := make([]string, 0, len(list)) for _, value := range list { value = strings.TrimSpace(value) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } sort.Strings(out) return out } func (p *PrepareExecutor) resolvePrice(ctx context.Context, main *rc.BatchMainRow, _ prepareOptions, targetType int, curPkg, newPkg *rc.FlowPackageInfo, customer *customerInfo) (*RenewPriceSnapshot, *ChangePriceSnapshot, error) { tenantID := main.TenantId pkg := newPkg if pkg == nil { pkg = curPkg } if pkg == nil { return nil, nil, gerror.New("缺失套餐信息") } unitPrice := 0.0 priceSource := PriceSourceSystem var ( agentDefaultID int64 targetID int64 ) priceOwnerDeptID := int64(0) if targetType == consts.BatchTypeRenew { if customer == nil || customer.DeptID <= 0 { return nil, nil, gerror.New("renew customer department is missing") } dept, err := p.repo.Tables().LoadDept(ctx, tenantID, customer.DeptID) if err != nil { return nil, nil, gerror.Wrap(err, "load renew customer department failed") } priceOwnerDeptID = renewPriceOwnerDeptID(dept) salePrice, err := resolveRenewEffectiveSalePrice(ctx, p.repo.Tables(), tenantID, priceOwnerDeptID, customer.DeptID, pkg) if err != nil { return nil, nil, gerror.Wrap(err, "calculate renew effective price failed") } unitPrice = salePrice.AgentShipmentPrice priceSource = salePrice.Rule.PriceSource agentDefaultID = salePrice.Rule.AgentDefaultPriceId targetID = salePrice.Rule.TargetPriceId } else { priceOwnerDeptID = main.DeptId if priceOwnerDeptID <= 0 { priceOwnerDeptID = main.CreatedDept } salePrice, err := resolveRenewEffectiveSalePrice(ctx, p.repo.Tables(), tenantID, priceOwnerDeptID, customer.DeptID, pkg) if err != nil { return nil, nil, gerror.Wrap(err, "calculate change effective price failed") } unitPrice = salePrice.AgentShipmentPrice priceSource = salePrice.Rule.PriceSource agentDefaultID = salePrice.Rule.AgentDefaultPriceId targetID = salePrice.Rule.TargetPriceId } unitPrice = rc.RoundAmount(unitPrice) if targetType == consts.BatchTypeRenew { opts, _ := renewableYearOptions(curPkg.RenewableDurationConfig) durationUnit := renewDurationUnitForPackage(curPkg) renewableCodes, renewablePackages, err := p.renewablePackageOptions(ctx, tenantID, customer.DeptID, priceOwnerDeptID, curPkg) if err != nil { return nil, nil, err } return &RenewPriceSnapshot{ CustomerAgentId: customer.DeptID, PriceOwnerDeptId: priceOwnerDeptID, PackageCode: curPkg.PackageCode, PackageName: curPkg.PackageName, BillingMethod: curPkg.BillingMethod, RenewDurationUnit: durationUnit, RenewDurationUnitText: consts.RenewDurationUnitText(durationUnit), PriceSource: priceSource, AgentDefaultPriceId: agentDefaultID, TargetPriceId: targetID, RenewUnitPrice: unitPrice, RenewableDurationOptions: opts, RenewablePackageCodes: renewableCodes, RenewablePackages: renewablePackages, }, nil, nil } oldOpts, _ := renewableYearOptions(curPkg.RenewableDurationConfig) newOpts, _ := changeDurationOptions(pkg.RenewableDurationConfig) oldDurationUnit := renewDurationUnitForPackage(curPkg) renewSnap := &RenewPriceSnapshot{ CustomerAgentId: customer.DeptID, PackageCode: curPkg.PackageCode, PackageName: curPkg.PackageName, BillingMethod: curPkg.BillingMethod, RenewDurationUnit: oldDurationUnit, RenewDurationUnitText: consts.RenewDurationUnitText(oldDurationUnit), PriceSource: priceSource, AgentDefaultPriceId: agentDefaultID, TargetPriceId: targetID, RenewUnitPrice: rc.RoundAmount(curPkg.CostPrice), RenewableDurationOptions: oldOpts, } changeSnap := &ChangePriceSnapshot{ CustomerAgentId: customer.DeptID, NewPackageCode: newPkg.PackageCode, NewPackageName: newPkg.PackageName, PriceSource: priceSource, AgentDefaultPriceId: agentDefaultID, TargetPriceId: targetID, ChangeUnitPrice: unitPrice, RenewableDurationOptions: newOpts, } return renewSnap, changeSnap, nil } func fillRenewBeginSnapshot(snapshot *RenewPriceSnapshot, rows []*RenewRow, rowErrors map[int]string) { if snapshot == nil { return } dates := make([]string, 0, len(rows)) for _, row := range rows { if row == nil || row.OldExpireTime == nil { continue } if strings.TrimSpace(rowErrors[row.RowNo]) != "" { continue } renewBeginAt := renewBeginDateFromExpire(row.OldExpireTime) if renewBeginAt == nil { continue } date := strings.TrimSpace(renewBeginAt.Format("Y-m-d")) if date != "" { dates = append(dates, date) } } sameDate, minDate, maxDate, same := summarizeRenewBeginDates(dates) snapshot.RenewBeginDate = sameDate snapshot.RenewBeginDateMin = minDate snapshot.RenewBeginDateMax = maxDate snapshot.SameRenewBeginDate = same } func validateRenewSameCurrentPeriod(rows []*RenewRow, rowErrors map[int]string) error { var beginTime, expireTime string hasBase := false for _, row := range rows { if row == nil { continue } if strings.TrimSpace(rowErrors[row.RowNo]) != "" { continue } rowBegin := formatTime(row.OldEffTime) rowExpire := formatTime(row.OldExpireTime) if rowBegin == "" || rowExpire == "" { return gerror.New(renewSamePeriodFailureReason) } if !hasBase { beginTime = rowBegin expireTime = rowExpire hasBase = true continue } if rowBegin != beginTime || rowExpire != expireTime { return gerror.New(renewSamePeriodFailureReason) } } return nil } func summarizeRenewBeginDates(dates []string) (sameDate string, minDate string, maxDate string, same bool) { if len(dates) == 0 { return "", "", "", false } sort.Strings(dates) minDate = dates[0] maxDate = dates[len(dates)-1] same = minDate == maxDate if same { sameDate = minDate } return sameDate, minDate, maxDate, same } func renewPriceOwnerDeptID(dept *rc.SysDeptRow) int64 { if dept == nil { return 0 } return dept.ParentId } func renewExpireAfterAllowedWindow(expireAt *gtime.Time, now time.Time) bool { if expireAt == nil { return false } return expireAt.Time.After(now.AddDate(0, renewExpireWindowMonths, 0)) } func priceByPackage(pkg *rc.FlowPackageInfo, override *rc.FlowPackagePrice) float64 { if pkg == nil { return 0 } ratio := pkg.SysDefaultRatio profit := pkg.SysDefaultProfit if override != nil { ratio = override.AgentCostRatio profit = override.AgentCostProfit } return rc.RoundAmount(pkg.CostPrice*normalizePriceRatio(ratio) + profit) } func (p *PrepareExecutor) renewablePackageOptions(ctx context.Context, 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("当前套餐未配置可续费套餐") } options := make([]RenewPackageOption, 0, len(codes)) validCodes := make([]string, 0, len(codes)) for _, code := range codes { pkg, err := p.repo.Tables().LoadPackageByCode(ctx, tenantID, code) if err != nil || pkg == nil || pkg.Enabled != 1 { continue } unitPrice, err := p.renewPackageUnitPrice(ctx, 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 (p *PrepareExecutor) renewPackageUnitPrice(ctx context.Context, tenantID string, customerDeptID, priceOwnerDeptID int64, pkg *rc.FlowPackageInfo) (float64, error) { if pkg == nil { return 0, gerror.New("续费套餐不存在") } salePrice, err := resolveRenewEffectiveSalePrice(ctx, p.repo.Tables(), tenantID, priceOwnerDeptID, customerDeptID, pkg) if err != nil { return 0, gerror.Wrap(err, "calculate renew package effective price failed") } return salePrice.AgentShipmentPrice, nil } func renewPackageCodesFromJSON(values *gjson.Json) []string { if values == nil || values.IsNil() { return nil } seen := make(map[string]struct{}) codes := make([]string, 0, len(values.Interfaces())) for _, value := range values.Interfaces() { code := strings.TrimSpace(gconvString(value)) if code == "" { continue } if _, ok := seen[code]; ok { continue } seen[code] = struct{}{} codes = append(codes, code) } return codes } func normalizePriceRatio(ratio float64) float64 { if ratio > 10 { return ratio / 100 } return ratio } func (p *PrepareExecutor) finalizeBatchFailure(ctx context.Context, main *rc.BatchMainRow, reason string, perRow map[int]string) error { g.Log().Warningf(ctx, "prepare batch failed, batchNo:%s, reason:%s", main.BatchNo, reason) now := p.nowFunc() err := p.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { finalBatchType := main.BatchType // 明细级失败 if perRow != nil { for rowNo, msg := range perRow { if rowNo <= 0 || msg == "" { continue } if err := p.repo.MarkDetailFailed(ctx, tx, main.BatchNo, main.BatchType, rowNo, msg, main.CreatedBy); err != nil { return err } } } // 兜底:所有仍是 running 的明细批量置为失败 if err := p.repo.MarkAllDetailsFailed(ctx, tx, main.BatchNo, main.BatchType, reason, main.CreatedBy); err != nil { return err } if targetType := readTargetBatchType(main.BatchInfo); targetType == consts.BatchTypeRenew || targetType == consts.BatchTypeChange { convertResult, err := p.repo.ConvertPrepareToRenewChange(ctx, tx, main.BatchNo, targetType) if err != nil { return err } g.Log().Warningf(ctx, "prepare failure converted to target batch, batchNo:%s, targetType:%d, mainAffected:%d, detailAffected:%d, reason:%s", main.BatchNo, targetType, convertResult.MainAffected, convertResult.DetailAffected, reason) finalBatchType = targetType } return p.repo.FinalizeBatchMain(ctx, tx, main.BatchNo, finalBatchType, &rc.BatchMainRow{ BatchAsyncStatus: failedStatus(), RunResult: consts.BatchRunResultFailed, FailReason: reason, SuccessCount: 0, FailCount: main.TotalCount, ValidCount: 0, TotalCount: main.TotalCount, EndTime: gtime.NewFromTime(now), UpdatedBy: main.CreatedBy, }) }) if err != nil { return gerror.Wrap(err, "finalize prepare failure failed") } return nil } func (p *PrepareExecutor) finalizeBatchSuccess(ctx context.Context, main *rc.BatchMainRow, targetType int, out *prepareOutcome, details []*rc.BatchDetailRow) error { now := p.nowFunc() info := buildPrepareInfo(main.BatchInfo, targetType, out, len(details)) tenantID := main.TenantId err := p.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { for _, d := range details { if d == nil { continue } snap := buildPrepareDetailSnapshot(d, out, targetType) itemID := "" if row := out.rows[d.RowNo]; row != nil { itemID = row.ICCID } if err := p.repo.MarkPrepareDetailSuccess(ctx, tx, main.BatchNo, d.RowNo, itemID, snap, main.CreatedBy); err != nil { return err } } // 批量将所有 running 置为 success(兜底覆盖) if err := p.repo.MarkAllDetailsSuccess(ctx, tx, main.BatchNo, main.BatchType, main.CreatedBy, nil); err != nil { return err } if err := p.repo.UpdateBatchMainInfo(ctx, tx, main.BatchNo, main.BatchType, &rc.BatchMainRow{ BatchType: main.BatchType, BatchAsyncStatus: consts.BatchAsyncStatusSuccess, RunResult: consts.BatchRunResultSuccess, TotalCount: main.TotalCount, ValidCount: len(details), SuccessCount: len(details), FailCount: 0, BatchInfo: gjson.New(info), EndTime: gtime.NewFromTime(now), UpdatedBy: main.CreatedBy, }); err != nil { return err } return nil }) if err != nil { return gerror.Wrap(err, "finalize prepare success failed") } g.Log().Infof(ctx, "prepare batch success, batchNo:%s, targetType:%d, tenant:%s", main.BatchNo, targetType, tenantID) return nil } func buildPrepareInfo(prev *gjson.Json, targetType int, out *prepareOutcome, quantity int) map[string]any { info := map[string]any{} if prev != nil { for k, v := range prev.Map() { info[k] = v } } info["targetBatchType"] = targetType info["quantity"] = quantity if out.customer != nil { info["customerAgentId"] = out.customer.DeptID info["customerName"] = out.customer.Name } if out.priceSnap != nil { info["priceSnapshot"] = out.priceSnap if targetType == consts.BatchTypeRenew { info["packageCode"] = out.priceSnap.PackageCode info["packageName"] = out.priceSnap.PackageName info["billingMethod"] = out.priceSnap.BillingMethod info["renewDurationUnit"] = out.priceSnap.RenewDurationUnit info["renewDurationUnitText"] = out.priceSnap.RenewDurationUnitText info["priceSource"] = out.priceSnap.PriceSource info["priceOwnerDeptId"] = out.priceSnap.PriceOwnerDeptId info["agentDefaultPriceId"] = out.priceSnap.AgentDefaultPriceId info["targetPriceId"] = out.priceSnap.TargetPriceId info["renewUnitPrice"] = out.priceSnap.RenewUnitPrice info["renewableDurationOptions"] = out.priceSnap.RenewableDurationOptions info["renewablePackageCodes"] = out.priceSnap.RenewablePackageCodes info["renewablePackages"] = out.priceSnap.RenewablePackages info["renewBeginDate"] = out.priceSnap.RenewBeginDate info["renewBeginDateMin"] = out.priceSnap.RenewBeginDateMin info["renewBeginDateMax"] = out.priceSnap.RenewBeginDateMax info["sameRenewBeginDate"] = out.priceSnap.SameRenewBeginDate } } if out.changePrice != nil { info["changePriceSnapshot"] = out.changePrice if targetType == consts.BatchTypeChange { info["newPackageCode"] = out.changePrice.NewPackageCode info["newPackageName"] = out.changePrice.NewPackageName info["priceSource"] = out.changePrice.PriceSource info["agentDefaultPriceId"] = out.changePrice.AgentDefaultPriceId info["targetPriceId"] = out.changePrice.TargetPriceId info["changeUnitPrice"] = out.changePrice.ChangeUnitPrice info["renewableDurationOptions"] = out.changePrice.RenewableDurationOptions } } if out.changeSnap != nil { info["changePrepareSnapshot"] = out.changeSnap if targetType == consts.BatchTypeChange { info["sourceBatchNo"] = out.changeSnap.SourceBatchNo info["oldPackageCode"] = out.changeSnap.OldPackageCode info["oldPackageName"] = out.changeSnap.OldPackageName info["oldPackageType"] = out.changeSnap.OldPackageType info["oldPackageFlowMb"] = out.changeSnap.OldPackageFlowMb if quantity <= 0 { info["quantity"] = out.changeSnap.Quantity } } } if out.customer != nil { info["customer"] = out.customer } if out.packageInfo != nil { info["packageInfo"] = out.packageInfo } info["prepareTime"] = timeNow().UTC().Format("2006-01-02 15:04:05") info["status"] = "success" return info } func buildPrepareDetailSnapshot(d *rc.BatchDetailRow, out *prepareOutcome, targetType int) *gjson.Json { base := map[string]any{} if d.Snapshot != nil { for k, v := range d.Snapshot.Map() { base[k] = v } } base["rowNo"] = d.RowNo base["success"] = true if row := out.rows[d.RowNo]; row != nil { base["iccid"] = row.ICCID base["msisdn"] = row.MSISDN base["imsi"] = row.IMSI base["oldPackageCode"] = row.OldPkgCode base["oldPackageName"] = row.OldPkgName base["oldSubscriptionNo"] = row.OldBindSubNo base["oldOrderSn"] = row.OldBindOrdNo base["sourceBatchNo"] = row.SourceBatchNo if out.changePrice != nil { base["newPackageCode"] = out.changePrice.NewPackageCode base["newPackageName"] = out.changePrice.NewPackageName } } if out.priceSnap != nil { base["priceSnapshot"] = out.priceSnap } if out.changePrice != nil { base["changePriceSnapshot"] = out.changePrice } if out.changeSnap != nil { base["changePrepareSnapshot"] = out.changeSnap } if out.customer != nil { base["customer"] = out.customer } if out.packageInfo != nil { base["packageInfo"] = out.packageInfo } base["targetBatchType"] = targetType return gjson.New(base) } func loadPrepareOptions(info *gjson.Json) prepareOptions { opts := prepareOptions{priceScope: PriceScopeAgentDefault} if info == nil { return opts } opts.customerAgentID = info.Get("customerAgentId").Int64() opts.targetAgentID = info.Get("targetAgentId").Int64() opts.priceScope = int(info.Get("priceScope").Int64()) if opts.priceScope == 0 { opts.priceScope = PriceScopeAgentDefault } opts.newPackageCode = strings.TrimSpace(info.Get("newPackageCode").String()) if v := firstPrepareOptionValue(info, "durationValue", "durationYears"); v != nil { opts.durationValue = int(v.Int64()) opts.durationSet = opts.durationValue > 0 } else if v := firstPrepareOptionValue(info, "durationMonths"); v != nil { opts.durationValue = normalizeLegacyRenewDurationValue(int(v.Int64())) opts.durationSet = opts.durationValue > 0 } return opts } func firstPrepareOptionValue(info *gjson.Json, paths ...string) *gvar.Var { if info == nil { return nil } for _, path := range paths { value := info.Get(path) if value != nil && !value.IsNil() { return value } } return nil } func mergeRowErrors(perRow, rowErrors map[int]string) map[int]string { if len(perRow) == 0 && len(rowErrors) == 0 { return nil } out := make(map[int]string, len(perRow)+len(rowErrors)) for rowNo, msg := range perRow { if strings.TrimSpace(msg) != "" { out[rowNo] = msg } } for rowNo, msg := range rowErrors { if strings.TrimSpace(msg) == "" { continue } if _, ok := out[rowNo]; !ok { out[rowNo] = msg } } return out } func readTargetBatchType(info *gjson.Json) int { if info == nil { return 0 } if v := info.Get("targetBatchType"); v != nil { return int(v.Int64()) } if v := info.Get("target_batch_type"); v != nil { return int(v.Int64()) } return 0 } func failedStatus() int { return consts.BatchAsyncStatusFailed } func rcLocalize(key string) string { switch key { case "errCardNotFound": return "ICCID不存在正式卡记录" case "errMsisdnMismatch": return "MSISDN与SIM卡记录不一致" case "errImsiMismatch": return "IMSI与SIM卡记录不一致" case "errCardNoCurrentPackage": return "ICCID没有当前套餐" case "errMultiplePackages": return "当前批次下存在多个套餐,不能统一处理" case "errMultipleCustomers": return "当前批次下存在多个客户,不能统一处理" case "errPkgNotFound": return "套餐不存在" case "errPkgDisabled": return "套餐未启用或不存在" case "errDeptNotFound": return "客户不存在" case "errNoChangeablePackages": return "当前套餐未配置可变更套餐范围" case "errNewPackageNotInChange": return "新套餐不在当前套餐可变更范围内" case "errNewPackageSameAsOld": return "新套餐不能与当前套餐相同" case "errPackageTypeMismatch": return "目标套餐类型与当前套餐类型不一致" case "errTargetPkgNotFound": return "目标套餐不存在" case "errRenewDurationEmpty": return "续费/变更套餐未配置可续费时长" case "errRenewableDurationMiss": return "续费时长不在当前套餐可续费范围内" } return key } func appendIfEmpty(existing, addition string) string { addition = strings.TrimSpace(addition) if addition == "" { return existing } if existing == "" { return addition } return existing + ";" + addition } func stringify(value any) string { switch v := value.(type) { case string: return v case int: return strconv.Itoa(v) case int32: return strconv.FormatInt(int64(v), 10) case int64: return strconv.FormatInt(v, 10) case float32: return strconv.FormatFloat(float64(v), 'f', -1, 32) case float64: return strconv.FormatFloat(v, 'f', -1, 64) } return "" }