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 }