package addpackage import ( "context" "database/sql" "encoding/json" "errors" "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/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" rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange" renewchangeservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/renewchange" ) type executor struct { repo *renewchangeservice.Repository batchType int poolMode bool nowFunc NowFunc } type flowPoolRow struct { Id int64 `orm:"id"` FlowPoolCode string `orm:"flow_pool_code"` DeptId int64 `orm:"dept_id"` PoolPackageCode int64 `orm:"pool_package_code"` AddOnFlowMb float64 `orm:"add_on_flow_mb"` TotalFlowMb float64 `orm:"total_flow_mb"` RemainFlowMb float64 `orm:"remain_flow_mb"` PoolStatus int `orm:"pool_status"` TenantId string `orm:"tenant_id"` CreatedDept int64 `orm:"created_dept"` CreatedBy int64 `orm:"created_by"` UpdatedBy int64 `orm:"updated_by"` UpdatedAt *gtime.Time `orm:"updated_at"` } type sysDeptRow struct { DeptId int64 `orm:"dept_id"` ParentId int64 `orm:"parent_id"` TenantId string `orm:"tenant_id"` DeletedAt *gtime.Time `orm:"deleted_at"` } type agentBalanceRow struct { Id int64 `orm:"id"` DeptId int64 `orm:"dept_id"` Balance float64 `orm:"balance"` Status int `orm:"status"` TenantId string `orm:"tenant_id"` } type balanceDetailRow struct { DeptId int64 `orm:"dept_id"` SerialNo string `orm:"serial_no"` TransactionTime *gtime.Time `orm:"transaction_time"` IncomeExpenseType int `orm:"income_expense_type"` TransactionType int `orm:"transaction_type"` TransactionChannel int `orm:"transaction_channel"` TransactionStatus int `orm:"transaction_status"` Amount float64 `orm:"amount"` BeforeBalance float64 `orm:"before_balance"` BalanceAfter float64 `orm:"balance_after"` ChannelSerialNo string `orm:"channel_serial_no"` OrderSn string `orm:"order_sn"` Remark string `orm:"remark"` TenantId string `orm:"tenant_id"` CreatedDept int64 `orm:"created_dept"` CreatedBy int64 `orm:"created_by"` CreatedAt *gtime.Time `orm:"created_at"` } const ( addPackageBizType = 3 addPackageTransactionType = 1 addPackageTransactionChan = 3 addPackageTransactionPaid = 1 addPackageAccountStatusOK = 0 ) const ( addPackageSameBaseFailureReason = "同一批加油包目标当前基础套餐必须一致" addPackageSamePackageFailureReason = "同一批加油包套餐必须一致" addPackageSameEffectiveFailureReason = "同一批加油包生效方式必须一致" addPackageSamePriceFailureReason = "同一批加油包单价必须一致" ) func NewCardExecutor(repo *renewchangeservice.Repository) batch.Executor { if repo == nil { repo = renewchangeservice.NewRepository() } return &executor{repo: repo, batchType: consts.BatchTypeCardAddPackage, nowFunc: timeNow} } func NewPoolExecutor(repo *renewchangeservice.Repository) batch.Executor { if repo == nil { repo = renewchangeservice.NewRepository() } return &executor{repo: repo, batchType: consts.BatchTypePoolAddPackage, poolMode: true, nowFunc: timeNow} } func (e *executor) Execute(ctx context.Context, batchNo string) error { batchNo = strings.TrimSpace(batchNo) if batchNo == "" { return gerror.New("add package batch number is empty") } main, err := e.repo.LoadProcessingBatch(ctx, batchNo, e.batchType) if err != nil { return err } details, err := e.repo.LoadRunningDetails(ctx, batchNo, e.batchType) if err != nil { return gerror.Wrap(err, "load add package details failed") } task := &batchTask{ main: main, total: len(details), plans: make([]*detailPlan, 0, len(details)), paymentIntent: readPaymentIntent(main.BatchInfo), paymentSnapshot: make(map[int64]*PaymentSnapshot), } if len(details) == 0 { return e.finalizeFailure(ctx, task, "加油包批次没有可处理明细") } if err := e.buildTask(ctx, task, details); err != nil { return e.finalizeFailure(ctx, task, err.Error()) } if task.valid == 0 { if task.reason == "" { task.reason = "加油包批次没有可处理明细" } return e.finalizeFailure(ctx, task, task.reason) } orderSN, err := e.applyBusiness(ctx, task) if err != nil { return e.finalizeFailure(ctx, task, err.Error()) } if err = e.finalizeSuccess(ctx, task, orderSN); err != nil { return err } return nil } func (e *executor) buildTask(ctx context.Context, task *batchTask, details []*rc.BatchDetailRow) error { seen := make(map[string]struct{}, len(details)) now := e.nowTime() for _, detail := range details { if detail == nil { continue } plan := &detailPlan{detail: detail} snapshot := readDetailSnapshot(detail.Snapshot) plan.targetCode = strings.TrimSpace(detail.ItemId) if snapshot != nil { if v := strings.TrimSpace(snapshot.TargetCode); v != "" { plan.targetCode = v } plan.packageCode = strings.TrimSpace(snapshot.PackageCode) plan.remark = strings.TrimSpace(snapshot.Remark) } if plan.packageCode == "" && task.main.BatchInfo != nil { plan.packageCode = strings.TrimSpace(task.main.BatchInfo.Get("packageCode").String()) } mode, modeErr := effectiveModeFrom(task.main.BatchInfo, snapshot) if modeErr != nil { plan.reason = modeErr.Error() task.plans = append(task.plans, plan) continue } plan.effectiveMode = mode if plan.targetCode == "" { plan.reason = "目标编码不能为空" task.plans = append(task.plans, plan) continue } hasPackageCode := plan.packageCode != "" if !hasPackageCode && !task.prepareOnly { plan.reason = "加油包套餐编码不能为空" task.plans = append(task.plans, plan) continue } dupKey := strings.Join([]string{plan.targetCode, plan.packageCode}, "|") if _, ok := seen[dupKey]; ok { plan.reason = "批次内重复的目标编码 + 套餐编码" task.plans = append(task.plans, plan) continue } seen[dupKey] = struct{}{} if e.poolMode { pool, err := e.loadPool(ctx, task.main.TenantId, plan.targetCode) if err != nil { plan.reason = err.Error() task.plans = append(task.plans, plan) continue } if pool == nil || pool.Id == 0 { plan.reason = "流量池不存在" task.plans = append(task.plans, plan) continue } plan.pool = pool plan.customerDeptID = pool.DeptId plan.basePackageKey = fmt.Sprintf("%d", pool.PoolPackageCode) if pool.PoolStatus != 0 { plan.reason = "流量池状态异常,不能订购加油包" task.plans = append(task.plans, plan) continue } if err := e.ensureNoActiveTopup(ctx, nil, task.main.TenantId, plan.targetCode, now); err != nil { plan.reason = err.Error() task.plans = append(task.plans, plan) continue } if !hasPackageCode { task.valid++ task.plans = append(task.plans, plan) continue } pkg, price, err := e.resolvePrice(ctx, task.main.TenantId, pool.DeptId, plan.packageCode) if err != nil { plan.reason = err.Error() task.plans = append(task.plans, plan) continue } if pkg.PackageType != 104 { plan.reason = "目标套餐类型不是流量池加油包" task.plans = append(task.plans, plan) continue } if pkg.PoolPackageCode != pool.PoolPackageCode { plan.reason = "加油包不属于当前流量池套餐" task.plans = append(task.plans, plan) continue } plan.price = price plan.unitAmount = price.UnitPrice plan.amount = roundAmount(price.UnitPrice) plan.effectiveTime, plan.expireTime, plan.effectiveNow, err = calcMonthWindow(plan.effectiveMode, now) if err != nil { plan.reason = err.Error() task.plans = append(task.plans, plan) continue } task.valid++ task.plans = append(task.plans, plan) continue } cardMap, err := e.repo.Tables().LoadCardsByIccids(ctx, []string{plan.targetCode}) if err != nil { plan.reason = err.Error() task.plans = append(task.plans, plan) continue } card := cardMap[plan.targetCode] if card == nil || card.Id == 0 { plan.reason = "ICCID不存在" task.plans = append(task.plans, plan) continue } plan.card = card plan.customerDeptID = card.DeptId plan.basePackageKey = strings.TrimSpace(card.CurrentPackageCode) if card.PackageStatus != consts.PackageStatusEffective { plan.reason = "当前卡套餐未生效,不能订购加油包" task.plans = append(task.plans, plan) continue } if err := e.ensureNoActiveTopup(ctx, nil, task.main.TenantId, plan.targetCode, now); err != nil { plan.reason = err.Error() task.plans = append(task.plans, plan) continue } basePkg, err := e.repo.Tables().LoadPackageByCode(ctx, task.main.TenantId, card.CurrentPackageCode) if err != nil || basePkg == nil { plan.reason = "当前卡基础套餐不存在" task.plans = append(task.plans, plan) continue } if !containsString(packageCodesFromJSON(basePkg.TopupPackageCodes), plan.packageCode) { if hasPackageCode { plan.reason = "当前套餐不允许订购该加油包" task.plans = append(task.plans, plan) continue } } if !hasPackageCode { task.valid++ task.plans = append(task.plans, plan) continue } pkg, price, err := e.resolvePrice(ctx, task.main.TenantId, card.DeptId, plan.packageCode) if err != nil { plan.reason = err.Error() task.plans = append(task.plans, plan) continue } if pkg.PackageType != 103 { plan.reason = "目标套餐类型不是单卡加油包" task.plans = append(task.plans, plan) continue } plan.price = price plan.unitAmount = price.UnitPrice plan.amount = roundAmount(price.UnitPrice) plan.effectiveTime, plan.expireTime, plan.effectiveNow, err = calcMonthWindow(plan.effectiveMode, now) if err != nil { plan.reason = err.Error() task.plans = append(task.plans, plan) continue } task.valid++ task.plans = append(task.plans, plan) } enforceAddPackageBatchUniformity(task) task.valid = 0 task.success = 0 task.fail = 0 task.reason = "" for _, plan := range task.plans { if plan == nil { continue } if plan.reason != "" { task.fail++ if task.reason == "" { task.reason = plan.reason } continue } task.success++ task.valid++ } if task.reason == "" && task.valid == 0 { task.reason = "加油包批次没有可处理明细" } return nil } func enforceAddPackageBatchUniformity(task *batchTask) { if task == nil { return } var first *detailPlan for _, plan := range task.plans { if plan == nil || plan.reason != "" { continue } if first == nil { first = plan continue } switch { case strings.TrimSpace(plan.basePackageKey) != strings.TrimSpace(first.basePackageKey): plan.reason = addPackageSameBaseFailureReason case strings.TrimSpace(plan.packageCode) != strings.TrimSpace(first.packageCode): plan.reason = addPackageSamePackageFailureReason case strings.TrimSpace(plan.effectiveMode) != strings.TrimSpace(first.effectiveMode): plan.reason = addPackageSameEffectiveFailureReason case !sameAddPackageAmount(plan.unitAmount, first.unitAmount): plan.reason = addPackageSamePriceFailureReason } } } func sameAddPackageAmount(left, right float64) bool { return amountCents(left) == amountCents(right) } func amountCents(value float64) int64 { if value < 0 { value = 0 } return int64(value*100 + 0.5) } func (e *executor) applyBusiness(ctx context.Context, task *batchTask) (string, error) { now := e.nowTime() tenantID := task.main.TenantId userID := task.main.CreatedBy deptID := task.main.CreatedDept isTopDept, err := e.isTopDept(ctx, tenantID, deptID) if err != nil { return "", err } intent, err := normalizePaymentIntent(task.paymentIntent, isTopDept) if err != nil { return "", err } task.paymentIntent = intent if orderSN, recovered, err := e.recoverAppliedBusiness(ctx, task); err != nil { return "", err } else if recovered { return orderSN, nil } planGroups := make(map[int64][]*detailPlan) orderedDeptIDs := make([]int64, 0, len(task.plans)) for _, plan := range task.plans { if plan == nil || plan.reason != "" { continue } payerDeptID := plan.customerDeptID if payerDeptID <= 0 { return "", gerror.New("加油包归属部门不能为空") } if _, ok := planGroups[payerDeptID]; !ok { orderedDeptIDs = append(orderedDeptIDs, payerDeptID) } planGroups[payerDeptID] = append(planGroups[payerDeptID], plan) } if len(planGroups) == 0 { return "", gerror.New("加油包订单金额不能为空") } sort.Slice(orderedDeptIDs, func(i, j int) bool { return orderedDeptIDs[i] < orderedDeptIDs[j] }) orderSNs := make([]string, 0, len(orderedDeptIDs)) if err := e.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { for _, plan := range task.plans { if plan == nil || plan.reason != "" { continue } if err := e.ensureNoActiveTopup(ctx, tx, tenantID, plan.targetCode, now); err != nil { return err } } for _, payerDeptID := range orderedDeptIDs { groupPlans := planGroups[payerDeptID] if len(groupPlans) == 0 { continue } groupAmount := 0.0 for _, plan := range groupPlans { groupAmount = roundAmount(groupAmount + plan.amount) } if groupAmount <= 0 { return gerror.New("加油包订单金额不能为空") } groupOrderSN := rc.GenerateOrderSn(&now) payment, err := e.executePayment( ctx, tx, task.main, intent, payerDeptID, groupAmount, groupOrderSN, now, isTopDept, ) if err != nil { return err } task.paymentSnapshot[payerDeptID] = payment orderRow := buildAddPackageOrderDO(groupOrderSN, task.main, payerDeptID, groupAmount, payment, now) orderID, err := e.repo.Tables().InsertContractOrder(ctx, tx, orderRow) if err != nil { return err } for _, plan := range groupPlans { subscriptionNo := rc.GenerateSubscriptionNo("ADP") plan.orderSN = groupOrderSN if e.poolMode { if err := e.insertPoolBind(ctx, tx, plan, task.main, groupOrderSN, subscriptionNo, now); err != nil { return err } } else { if err := e.insertCardBind(ctx, tx, plan, task.main, groupOrderSN, subscriptionNo, now); err != nil { return err } } item := buildAddPackageOrderItemDO(orderID, groupOrderSN, task.main, payerDeptID, deptID, plan, now) if err := e.repo.Tables().InsertContractOrderItem(ctx, tx, item); err != nil { return err } } orderSNs = append(orderSNs, groupOrderSN) } if e.poolMode { if err := e.updateFlowPool(ctx, tx, task.plans, tenantID, userID, now); err != nil { return err } } else { if err := e.updateCardTopupFlow(ctx, tx, task.plans, tenantID, userID, now); err != nil { return err } } return nil }); err != nil { return "", err } if len(orderSNs) == 0 { return "", nil } return orderSNs[0], nil } func (e *executor) finalizeSuccess(ctx context.Context, task *batchTask, orderSN string) error { now := e.nowTime() tenantID := task.main.TenantId userID := task.main.CreatedBy deptID := task.main.CreatedDept successCount := task.valid failCount := task.total - task.valid runResult := consts.BatchRunResultSuccess asyncStatus := consts.BatchAsyncStatusSuccess failReason := "" if failCount > 0 && successCount > 0 { runResult = consts.BatchRunResultPartialSuccess asyncStatus = consts.BatchAsyncStatusPartialSuccess failReason = firstFailureReason(task.plans) } if failCount > 0 && successCount == 0 { runResult = consts.BatchRunResultFailed asyncStatus = consts.BatchAsyncStatusFailed failReason = firstFailureReason(task.plans) } if err := e.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { for _, plan := range task.plans { if plan == nil || plan.detail == nil { continue } snapshot := buildDetailSnapshot(plan, plan.orderSN) status := consts.BatchDetailStatusSuccess reason := "" if plan.reason != "" { status = consts.BatchDetailStatusFailed reason = plan.reason } if err := e.repo.Tables().UpdateBatchDetailSnapshot(ctx, tx, task.main.BatchNo, task.main.BatchType, plan.detail.RowNo, status, snapshot, reason, userID); err != nil { return err } } info := mergeBatchInfo(task.main.BatchInfo, map[string]any{ "orderSn": orderSN, "orderSnList": collectOrderSNs(task.plans), "priceSnapshot": buildBatchPriceSnapshot(task), "paymentMode": task.paymentIntent.PaymentMode, "offlineContractCompleted": task.paymentIntent.OfflineContractCompleted, "paymentSnapshotByPayerDept": task.paymentSnapshot, "batchMode": map[bool]string{true: "pool", false: "card"}[e.poolMode], }) return e.repo.UpdateBatchMainInfo(ctx, tx, task.main.BatchNo, task.main.BatchType, &rc.BatchMainRow{ BatchType: task.main.BatchType, BatchAsyncStatus: asyncStatus, RunResult: runResult, TotalCount: task.total, ValidCount: task.valid, SuccessCount: successCount, FailCount: failCount, FailReason: failReason, BatchInfo: gjson.New(info), OrderSn: orderSN, EndTime: gtime.NewFromTime(now), UpdatedBy: userID, }) }); err != nil { return gerror.Wrap(err, "finalize add package success failed") } rows, _ := e.repo.ResolveResultSnapshot(ctx, task.main.BatchNo, task.main.BatchType) _ = e.repo.WriteResultFileSafe(ctx, task.main.BatchNo, task.main.BatchType, tenantID, deptID, userID, rows) g.Log().Infof(ctx, "add package batch success, batchNo:%s, orderSn:%s, batchType:%d", task.main.BatchNo, orderSN, task.main.BatchType) return nil } func (e *executor) finalizeFailure(ctx context.Context, task *batchTask, reason string) error { now := e.nowTime() tenantID := task.main.TenantId userID := task.main.CreatedBy deptID := task.main.CreatedDept if err := e.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { for _, plan := range task.plans { if plan == nil || plan.detail == nil { continue } snapshot := buildDetailSnapshot(plan, "") rowReason := plan.reason if rowReason == "" { rowReason = reason } if err := e.repo.Tables().UpdateBatchDetailSnapshot(ctx, tx, task.main.BatchNo, task.main.BatchType, plan.detail.RowNo, consts.BatchDetailStatusFailed, snapshot, rowReason, userID); err != nil { return err } } return e.repo.FinalizeBatchMain(ctx, tx, task.main.BatchNo, task.main.BatchType, &rc.BatchMainRow{ BatchAsyncStatus: consts.BatchAsyncStatusFailed, RunResult: consts.BatchRunResultFailed, FailReason: reason, TotalCount: task.total, ValidCount: task.valid, SuccessCount: 0, FailCount: task.total, EndTime: gtime.NewFromTime(now), UpdatedBy: userID, }) }); err != nil { return gerror.Wrap(err, "finalize add package failure failed") } rows, _ := e.repo.ResolveResultSnapshot(ctx, task.main.BatchNo, task.main.BatchType) _ = e.repo.WriteResultFileSafe(ctx, task.main.BatchNo, task.main.BatchType, tenantID, deptID, userID, rows) g.Log().Warningf(ctx, "add package batch failed, batchNo:%s, batchType:%d, reason:%s", task.main.BatchNo, task.main.BatchType, reason) return nil } func buildAddPackageOrderDO(orderSN string, main *rc.BatchMainRow, payerDeptID int64, totalAmount float64, payment *PaymentSnapshot, now time.Time) *rc.OrderDO { payTime := gtime.NewFromTime(now) createdDept := main.CreatedDept if createdDept <= 0 { createdDept = payerDeptID } payType := consts.PayTypeBalance balanceAmount := totalAmount offlinePaidAmount := 0.0 payOrderSN := orderSN if payment != nil { payType = payment.PayType balanceAmount = payment.BalanceAmount offlinePaidAmount = payment.OfflinePaidAmount if payment.BalanceDetailSerialNo != "" { payOrderSN = payment.BalanceDetailSerialNo } if !payment.PaymentTime.IsZero() { payTime = gtime.NewFromTime(payment.PaymentTime) } } return &rc.OrderDO{ OrderSn: orderSN, ParentOrderId: int64(0), ParentOrderSn: "", CustomerType: consts.CustomerTypeAgent, CustomerAgentId: payerDeptID, EndUserId: int64(0), AgentId: payerDeptID, OrderType: consts.OrderTypeCardSubscribe, OrderStatus: consts.OrderStatusCompleted, ShippingStatus: 0, ReturnStatus: consts.OrderReturnStatusNone, Consignee: "", Mobile: "", Email: "", Address: "", AreaCode: "", BuyerNote: "加油包自动生成订单", AdminNote: "", Remark: "加油包自动生成订单", ShippingMethod: consts.ShippingMethodNone, LogisticsId: int64(0), LogisticsName: "", ShippingTypeId: int64(0), ShippingTypeName: "", TrackingNo: "", PayType: payType, PayStatus: consts.PayStatusPaid, PayOrderSn: payOrderSN, PayTime: payTime, TotalAmount: totalAmount, ProductAmount: totalAmount, ShippingFee: 0, ServiceFee: 0, InvoiceFee: 0, CouponAmount: 0, DiscountAmount: 0, Balance: balanceAmount, PaidAmount: totalAmount, OnlinePaidAmount: 0, OfflinePaidAmount: offlinePaidAmount, UnpaidAmount: 0, UnrefundAmount: 0, IsNeedCommission: 0, DistributionStatus: 0, TenantId: main.TenantId, CreatedDept: createdDept, CreatedBy: main.CreatedBy, CreatedAt: payTime, UpdatedBy: main.CreatedBy, UpdatedAt: payTime, } } func buildAddPackageOrderItemDO(orderID int64, orderSN string, main *rc.BatchMainRow, payerDeptID, createdDeptID int64, plan *detailPlan, now time.Time) *rc.OrderItemDO { if plan == nil || plan.price == nil { return nil } createdDept := createdDeptID if createdDept <= 0 { createdDept = payerDeptID } skuData, _ := json.Marshal([]map[string]any{ {"skuName": "targetCode", "skuValue": plan.targetCode}, {"skuName": "package", "skuValue": plan.price.PackageName, "packageCode": plan.price.PackageCode, "packageType": plan.price.PackageType}, {"skuName": "effectiveMode", "skuValue": effectiveModeText(plan.effectiveMode)}, }) extraSKUData, _ := json.Marshal(map[string]any{ "businessBatchNo": main.BatchNo, "targetCode": plan.targetCode, "packageCode": plan.price.PackageCode, "effectiveMode": plan.effectiveMode, "effectiveModeText": effectiveModeText(plan.effectiveMode), "remark": plan.remark, }) return &rc.OrderItemDO{ OrderId: orderID, OrderSn: orderSN, CustomerType: consts.CustomerTypeAgent, CustomerAgentId: payerDeptID, EndUserId: int64(0), Price: plan.amount, Quantity: 1, ProductId: plan.price.PackageID, ProductName: plan.price.PackageName, ProductSn: plan.price.PackageCode, PicThumb: "", SkuId: int64(0), SkuData: string(skuData), DeliveryQuantity: 0, ProductType: consts.ProductTypeCard, IsGift: 0, AgentId: payerDeptID, PrepayPrice: 0, Commission: "", OriginPrice: plan.amount, PromotionData: "", ExtraSkuData: string(extraSKUData), PayMode: consts.PayModeYearMonth, ServiceBeginTime: gtime.NewFromTime(plan.effectiveTime), ServiceEndTime: gtime.NewFromTime(plan.expireTime), TenantId: main.TenantId, CreatedDept: createdDept, CreatedBy: main.CreatedBy, CreatedAt: gtime.NewFromTime(now), UpdatedBy: main.CreatedBy, UpdatedAt: gtime.NewFromTime(now), } } func collectOrderSNs(plans []*detailPlan) []string { if len(plans) == 0 { return nil } seen := make(map[string]struct{}, len(plans)) out := make([]string, 0, len(plans)) for _, plan := range plans { if plan == nil || strings.TrimSpace(plan.orderSN) == "" { continue } orderSN := strings.TrimSpace(plan.orderSN) if _, ok := seen[orderSN]; ok { continue } seen[orderSN] = struct{}{} out = append(out, orderSN) } sort.Strings(out) return out } func (e *executor) insertCardBind(ctx context.Context, tx gdb.TX, plan *detailPlan, main *rc.BatchMainRow, orderSN, subscriptionNo string, now time.Time) error { nowGT := gtime.NewFromTime(now) bind := &rc.SimCardBindPkg{ BatchNo: main.BatchNo, Iccid: plan.targetCode, Msisdn: "", DeptId: plan.customerDeptID, PackageType: plan.price.PackageType, PackageCode: plan.packageCode, PreviousPackageCode: "", BusinessType: addPackageBizType, EffectiveStatus: map[bool]int{true: consts.EffectiveStatusEffective, false: consts.EffectiveStatusPending}[plan.effectiveNow], HandleTime: nowGT, EffectiveTime: gtime.NewFromTime(plan.effectiveTime), ExpectedExpireTime: gtime.NewFromTime(plan.expireTime), ActualExpireTime: nil, DurationMonths: 1, GiftDurationMonths: 0, OrderCode: orderSN, SubscriptionNo: subscriptionNo, Remark: plan.remark, TenantId: main.TenantId, CreatedDept: main.CreatedDept, CreatedBy: main.CreatedBy, CreatedAt: nowGT, } if err := e.repo.Tables().InsertBind(ctx, tx, bind); err != nil { return err } return nil } func (e *executor) insertPoolBind(ctx context.Context, tx gdb.TX, plan *detailPlan, main *rc.BatchMainRow, orderSN, subscriptionNo string, now time.Time) error { nowGT := gtime.NewFromTime(now) _, err := tx.Ctx(ctx).Model("t_sim_pool_bind_package").Data(map[string]any{ "subscription_no": subscriptionNo, "batch_no": main.BatchNo, "pool_no": plan.targetCode, "dept_id": plan.customerDeptID, "package_type": plan.price.PackageType, "package_code": plan.packageCode, "previous_package_code": "", "business_type": addPackageBizType, "effective_status": map[bool]int{true: consts.EffectiveStatusEffective, false: consts.EffectiveStatusPending}[plan.effectiveNow], "handle_time": nowGT, "effective_time": gtime.NewFromTime(plan.effectiveTime), "expected_expire_time": gtime.NewFromTime(plan.expireTime), "actual_expire_time": nil, "duration_months": 1, "gift_duration_months": 0, "order_code": orderSN, "remark": plan.remark, "tenant_id": main.TenantId, "created_dept": main.CreatedDept, "created_by": main.CreatedBy, "created_at": nowGT, }).Insert() if err != nil { return gerror.Wrap(err, "insert flow pool add package failed") } return nil } func (e *executor) updateFlowPool(ctx context.Context, tx gdb.TX, plans []*detailPlan, tenantID string, userID int64, now time.Time) error { type poolDelta struct { pool *flowPoolRow flow float64 } pools := make(map[int64]*poolDelta) for _, plan := range plans { if plan == nil || !plan.effectiveNow || plan.price == nil || plan.pool == nil { continue } delta, ok := pools[plan.pool.Id] if !ok { delta = &poolDelta{pool: plan.pool} pools[plan.pool.Id] = delta } delta.flow = roundAmount(delta.flow + plan.price.FlowMb) } if len(pools) == 0 { return nil } for _, delta := range pools { if delta == nil || delta.pool == nil || delta.flow <= 0 { continue } _, err := tx.Ctx(ctx).Model("t_sim_flow_pool"). Where("id", delta.pool.Id). Data(map[string]any{ "add_on_flow_mb": roundAmount(delta.pool.AddOnFlowMb + delta.flow), "total_flow_mb": roundAmount(delta.pool.TotalFlowMb + delta.flow), "remain_flow_mb": roundAmount(delta.pool.RemainFlowMb + delta.flow), "updated_by": userID, "updated_at": gtime.NewFromTime(now), }). Update() if err != nil { return gerror.Wrap(err, "update flow pool add-on flow failed") } } return nil } func (e *executor) updateCardTopupFlow(ctx context.Context, tx gdb.TX, plans []*detailPlan, tenantID string, userID int64, now time.Time) error { type cardDelta struct { card *rc.SimCardInfo flow float64 } cards := make(map[int64]*cardDelta) for _, plan := range plans { if plan == nil || !plan.effectiveNow || plan.price == nil || plan.card == nil { continue } delta, ok := cards[plan.card.Id] if !ok { delta = &cardDelta{card: plan.card} cards[plan.card.Id] = delta } delta.flow = roundAmount(delta.flow + plan.price.FlowMb) } if len(cards) == 0 { return nil } for _, delta := range cards { if delta == nil || delta.card == nil || delta.card.Id <= 0 || delta.flow <= 0 { continue } update := map[string]any{ "package_total_flow_mb": roundAmount(delta.card.PackageTotalFlowMb + delta.flow), "updated_by": userID, "updated_at": gtime.NewFromTime(now), } mod := tx.Ctx(ctx).Model("t_sim_card_info").Where("id", delta.card.Id) if strings.TrimSpace(tenantID) != "" { mod = mod.Where("tenant_id", tenantID) } if _, err := mod.Data(update).Update(); err != nil { return gerror.Wrap(err, "update card add-on flow failed") } } return nil } func normalizePaymentIntent(intent PaymentIntent, isTopDept bool) (PaymentIntent, error) { intent.PaymentMode = strings.TrimSpace(intent.PaymentMode) if intent.PaymentMode == "" { intent.PaymentMode = "balance" } switch intent.PaymentMode { case "balance": intent.OfflineContractCompleted = false return intent, nil case "offline": if !isTopDept { return PaymentIntent{}, gerror.New("当前组织加油包必须使用余额支付") } if !intent.OfflineContractCompleted { return PaymentIntent{}, gerror.New("请确认线下合同、销售和走账已完成") } return intent, nil default: return PaymentIntent{}, gerror.New("加油包付款方式不正确") } } func (e *executor) executePayment( ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, intent PaymentIntent, payerDeptID int64, amount float64, orderSN string, now time.Time, isTopDept bool, ) (*PaymentSnapshot, error) { if main == nil { return nil, gerror.New("加油包批次不能为空") } snapshot := &PaymentSnapshot{ PaymentMode: intent.PaymentMode, PayStatus: consts.PayStatusPaid, PayAmount: amount, PaymentTime: now, PayerDeptID: payerDeptID, PayerUserID: main.CreatedBy, IsTopDept: isTopDept, } if intent.PaymentMode == "offline" { snapshot.PayType = consts.PayTypeOffline snapshot.OfflinePaidAmount = amount return snapshot, nil } serialNo := addPackageBalanceSerialNo(main.BatchNo, payerDeptID) before, after, err := e.debitBalance( ctx, tx, main.TenantId, payerDeptID, amount, main.CreatedBy, orderSN, serialNo, now, main.CreatedDept, ) if err != nil { return nil, err } snapshot.PayType = consts.PayTypeBalance snapshot.BalanceAmount = amount snapshot.BalanceBefore = before snapshot.BalanceAfter = after snapshot.BalanceDetailSerialNo = serialNo return snapshot, nil } func addPackageBalanceSerialNo(batchNo string, payerDeptID int64) string { batchNo = strings.TrimSpace(batchNo) if batchNo == "" || payerDeptID <= 0 { return "" } return fmt.Sprintf("ADPPAY%sD%d", batchNo, payerDeptID) } func (e *executor) isTopDept(ctx context.Context, tenantID string, deptID int64) (bool, error) { if strings.TrimSpace(tenantID) == "" || deptID <= 0 { return false, gerror.New("当前用户未绑定组织或租户") } var dept sysDeptRow if err := e.repo.DB().Model("sys_dept").Ctx(ctx). Where("tenant_id", tenantID). Where("dept_id", deptID). WhereNull("deleted_at"). Scan(&dept); err != nil { return false, gerror.Wrap(err, "query current department failed") } if dept.DeptId == 0 { return false, gerror.New("当前组织不存在") } return dept.ParentId == 0, nil } func (e *executor) recoverAppliedBusiness(ctx context.Context, task *batchTask) (string, bool, error) { if task == nil || task.main == nil { return "", false, nil } type existingOrderItem struct { OrderSN string `orm:"order_sn"` ExtraSKUData string `orm:"extra_sku_data"` } var existingItems []existingOrderItem if err := e.repo.DB().Model("t_order_item oi").Ctx(ctx). Fields("oi.order_sn, oi.extra_sku_data"). LeftJoin("t_order o", "o.id = oi.order_id"). Where("o.tenant_id", task.main.TenantId). Where("oi.extra_sku_data LIKE ?", "%\"businessBatchNo\":\""+task.main.BatchNo+"\"%"). OrderAsc("oi.item_id"). Scan(&existingItems); err != nil { return "", false, gerror.Wrap(err, "query existing add package orders failed") } if len(existingItems) == 0 { return "", false, nil } orderByPlan := make(map[string]string, len(existingItems)) for _, item := range existingItems { orderSN := strings.TrimSpace(item.OrderSN) if orderSN == "" || strings.TrimSpace(item.ExtraSKUData) == "" { continue } var extra struct { BusinessBatchNo string `json:"businessBatchNo"` TargetCode string `json:"targetCode"` PackageCode string `json:"packageCode"` } if err := json.Unmarshal([]byte(item.ExtraSKUData), &extra); err != nil { return "", false, gerror.Wrap(err, "decode existing add package order item failed") } if extra.BusinessBatchNo != task.main.BatchNo { continue } key := addPackageRecoveryKey(extra.TargetCode, extra.PackageCode) if key == "" { return "", false, gerror.New("existing add package order item is missing target or package") } if previous, ok := orderByPlan[key]; ok && previous != orderSN { return "", false, gerror.New("existing add package orders contain duplicate target records") } orderByPlan[key] = orderSN } matched := 0 firstOrderSN := "" for _, plan := range task.plans { if plan == nil || plan.reason != "" { continue } key := addPackageRecoveryKey(plan.targetCode, plan.packageCode) orderSN, ok := orderByPlan[key] if !ok { return "", false, gerror.New("existing add package orders are incomplete; refusing to retry business writes") } plan.orderSN = orderSN if firstOrderSN == "" { firstOrderSN = orderSN } matched++ } if matched == 0 { return "", false, nil } return firstOrderSN, true, nil } func addPackageRecoveryKey(targetCode, packageCode string) string { targetCode = strings.TrimSpace(targetCode) packageCode = strings.TrimSpace(packageCode) if targetCode == "" || packageCode == "" { return "" } return targetCode + "\\x00" + packageCode } func (e *executor) ensureNoActiveTopup(ctx context.Context, tx gdb.TX, tenantID, targetCode string, now time.Time) error { targetCode = strings.TrimSpace(targetCode) tenantID = strings.TrimSpace(tenantID) if targetCode == "" || tenantID == "" { return nil } if e.poolMode { return ensureNoActiveTopupRecord(ctx, tx, rc.TableSimPoolBindPackage, "pool_no", consts.PackageTypePoolAddPackage, tenantID, targetCode, now, "当前流量池存在未过期加油包,不能重复订购") } return ensureNoActiveTopupRecord(ctx, tx, rc.TableSimCardBindPkg, "iccid", consts.PackageTypeCardAddPackage, tenantID, targetCode, now, "当前卡存在未过期加油包,不能重复订购") } func ensureNoActiveTopupRecord(ctx context.Context, tx gdb.TX, table, targetColumn string, packageType int, tenantID, targetCode string, now time.Time, message string) error { model := g.DB().Model(table).Ctx(ctx) if tx != nil { model = tx.Ctx(ctx).Model(table) } model = model. Where("tenant_id", tenantID). Where(targetColumn, targetCode). Where("package_type", packageType). WhereIn("effective_status", []int{consts.EffectiveStatusPending, consts.EffectiveStatusEffective}). Where("(expected_expire_time IS NULL OR expected_expire_time > ?)", gtime.NewFromTime(now)) if tx != nil { model = model.LockUpdate() } var row struct { Id int64 `orm:"id"` } err := model.Fields("id").Limit(1).Scan(&row) if err != nil && !errors.Is(err, sql.ErrNoRows) { return gerror.Wrap(err, "查询未过期加油包失败") } if row.Id > 0 { return gerror.New(message) } return nil } func (e *executor) debitBalance(ctx context.Context, tx gdb.TX, tenantID string, deptID int64, amount float64, userID int64, orderSN, serialNo string, now time.Time, createdDept int64) (float64, float64, error) { serialNo = strings.TrimSpace(serialNo) if serialNo == "" { return 0, 0, gerror.New("加油包余额流水号不能为空") } var existing balanceDetailRow if err := tx.Ctx(ctx).Model("t_balance_detail"). Where("tenant_id", tenantID). Where("serial_no", serialNo). LockUpdate(). Scan(&existing); err != nil && !errors.Is(err, sql.ErrNoRows) { return 0, 0, gerror.Wrap(err, "query add package balance detail failed") } if strings.TrimSpace(existing.SerialNo) != "" { return 0, 0, gerror.New("当前加油包批次已扣款,请勿重复提交") } var account agentBalanceRow if err := tx.Ctx(ctx).Model("t_agent_balance"). Where("dept_id", deptID). Where("tenant_id", tenantID). LockUpdate(). Scan(&account); err != nil { return 0, 0, gerror.Wrap(err, "lock balance account failed") } if account.Id == 0 { return 0, 0, gerror.New("账户余额不存在") } if account.Status != addPackageAccountStatusOK { 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) nowGT := gtime.NewFromTime(now) if _, err := tx.Ctx(ctx).Model("t_agent_balance"). Where("id", account.Id). Data(map[string]any{ "balance": after, "updated_by": userID, "updated_at": nowGT, }). Update(); err != nil { return 0, 0, gerror.Wrap(err, "update agent balance failed") } if _, err := tx.Ctx(ctx).Model("t_balance_detail").Data(balanceDetailRow{ DeptId: deptID, SerialNo: serialNo, TransactionTime: nowGT, IncomeExpenseType: 1, TransactionType: addPackageTransactionType, TransactionChannel: addPackageTransactionChan, TransactionStatus: addPackageTransactionPaid, Amount: amount, BeforeBalance: before, BalanceAfter: after, ChannelSerialNo: serialNo, OrderSn: orderSN, Remark: fmt.Sprintf("加油包余额支付,批次号:%s", serialNo), TenantId: tenantID, CreatedDept: createdDept, CreatedBy: userID, CreatedAt: nowGT, }).Insert(); err != nil { return 0, 0, gerror.Wrap(err, "insert balance detail failed") } return before, after, nil } func (e *executor) resolvePrice(ctx context.Context, tenantID string, customerDeptID int64, packageCode string) (*rc.FlowPackageInfo, *PriceSnapshot, error) { pkg, err := e.repo.Tables().LoadPackageByCode(ctx, tenantID, packageCode) if err != nil || pkg == nil { return nil, nil, gerror.New("加油包套餐不存在") } if pkg.Enabled != 1 { return nil, nil, gerror.New("加油包套餐未启用") } parentDeptID, err := e.loadParentDeptID(ctx, tenantID, customerDeptID) if err != nil { return nil, nil, err } snapshot := &PriceSnapshot{ PackageID: pkg.Id, PackageCode: packageCode, PackageName: pkg.PackageName, PackageType: pkg.PackageType, PriceSource: "system", FlowMb: pkg.FlowMb, } salePrice, err := renewchangeservice.ResolveEffectiveSalePrice(ctx, e.repo.Tables(), tenantID, parentDeptID, customerDeptID, pkg) if err != nil { return nil, nil, gerror.Wrap(err, "calculate add package effective price failed") } snapshot.PriceSource = salePrice.Rule.PriceSource snapshot.AgentDefaultPriceID = salePrice.Rule.AgentDefaultPriceId snapshot.TargetPriceID = salePrice.Rule.TargetPriceId snapshot.UnitPrice = salePrice.AgentShipmentPrice return pkg, snapshot, nil } func (e *executor) loadParentDeptID(ctx context.Context, tenantID string, deptID int64) (int64, error) { if deptID <= 0 { return 0, nil } var dept sysDeptRow if err := e.repo.DB().Model("sys_dept").Ctx(ctx). Where("tenant_id", tenantID). Where("dept_id", deptID). WhereNull("deleted_at"). Scan(&dept); err != nil { return 0, gerror.Wrap(err, "query parent dept failed") } if dept.DeptId == 0 { return 0, nil } return dept.ParentId, nil } func (e *executor) loadPool(ctx context.Context, tenantID, flowPoolCode string) (*flowPoolRow, error) { var row flowPoolRow if err := e.repo.DB().Model("t_sim_flow_pool").Ctx(ctx). Where("tenant_id", tenantID). Where("flow_pool_code", flowPoolCode). Scan(&row); err != nil { return nil, gerror.Wrap(err, "query flow pool failed") } if row.Id == 0 { return nil, nil } return &row, nil } func (e *executor) nowTime() time.Time { if e.nowFunc == nil { return time.Now() } return e.nowFunc() } func effectiveModeFrom(info *gjson.Json, snapshot *DetailSnapshot) (string, error) { if snapshot != nil && strings.TrimSpace(snapshot.EffectiveMode) != "" { return normalizeEffectiveMode(snapshot.EffectiveMode) } if info != nil && !info.IsNil() { if mode := strings.TrimSpace(info.Get("effectiveMode").String()); mode != "" { return normalizeEffectiveMode(mode) } } return effectiveModeCurrentMonth, nil } func normalizeEffectiveMode(mode string) (string, error) { switch strings.TrimSpace(mode) { case "", "immediate", "current_month", "this_month", "本月生效", "本月", "当月", "当月生效": return effectiveModeCurrentMonth, nil case "next_month", "nextMonth", "次月生效", "次月", "下月", "下月生效": return "", gerror.New("加油包只支持本月生效") default: return "", gerror.New("加油包生效方式不正确") } } func calcMonthWindow(mode string, now time.Time) (time.Time, time.Time, bool, error) { if _, err := normalizeEffectiveMode(mode); err != nil { return time.Time{}, time.Time{}, false, err } start := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second(), 0, now.Location()) next := time.Date(start.Year(), start.Month()+1, 1, 0, 0, 0, 0, start.Location()) return start, next.Add(-time.Second), !start.After(now), nil } func effectiveModeText(mode string) string { _, _ = normalizeEffectiveMode(mode) return effectiveModeCurrentText } func buildDetailSnapshot(plan *detailPlan, orderSN string) *gjson.Json { if plan == nil { return nil } data := map[string]any{ "targetCode": plan.targetCode, "packageCode": plan.packageCode, "basePackageKey": plan.basePackageKey, "effectiveMode": plan.effectiveMode, "effectiveModeText": effectiveModeText(plan.effectiveMode), "remark": plan.remark, "orderSn": orderSN, } if plan.price != nil { data["priceSnapshot"] = plan.price data["packageName"] = plan.price.PackageName data["packageType"] = plan.price.PackageType data["unitPrice"] = plan.price.UnitPrice data["flowMb"] = plan.price.FlowMb } if !plan.effectiveTime.IsZero() { data["effectiveTime"] = gtime.NewFromTime(plan.effectiveTime).Format("Y-m-d H:i:s") } if !plan.expireTime.IsZero() { data["expectedExpireTime"] = gtime.NewFromTime(plan.expireTime).Format("Y-m-d H:i:s") } if plan.effectiveNow { data["effectiveStatus"] = consts.EffectiveStatusEffective } else { data["effectiveStatus"] = consts.EffectiveStatusPending } return gjson.New(data) } func buildBatchPriceSnapshot(task *batchTask) map[string]any { if task == nil { return nil } rows := make([]map[string]any, 0, len(task.plans)) for _, plan := range task.plans { if plan == nil || plan.price == nil { continue } rows = append(rows, map[string]any{ "targetCode": plan.targetCode, "basePackageKey": plan.basePackageKey, "packageCode": plan.packageCode, "effectiveMode": plan.effectiveMode, "effectiveModeText": effectiveModeText(plan.effectiveMode), "priceSource": plan.price.PriceSource, "unitPrice": plan.price.UnitPrice, }) } return map[string]any{"rows": rows} } func mergeBatchInfo(prev *gjson.Json, addition map[string]any) map[string]any { out := map[string]any{} if prev != nil { for k, v := range prev.Map() { out[k] = v } } for k, v := range addition { out[k] = v } return out } func firstFailureReason(plans []*detailPlan) string { for _, plan := range plans { if plan != nil && plan.reason != "" { return plan.reason } } return "" } func packageCodesFromJSON(jsonValue *gjson.Json) []string { if jsonValue == nil || jsonValue.IsNil() { return nil } values := jsonValue.Interfaces() out := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, raw := range values { code := strings.TrimSpace(fmt.Sprint(raw)) if code == "" { continue } if _, ok := seen[code]; ok { continue } seen[code] = struct{}{} out = append(out, code) } return out } func containsString(values []string, target string) bool { target = strings.TrimSpace(target) for _, value := range values { if strings.TrimSpace(value) == target { return true } } return false } func normalizePriceRatio(ratio float64) float64 { if ratio > 10 { return ratio / 100 } return ratio } func roundAmount(value float64) float64 { if value < 0 { return 0 } return float64(int(value*100+0.5)) / 100 }