package unsub import ( "context" "database/sql" "errors" "fmt" "math" "sort" "strconv" "strings" "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" unsub "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/unsub" ) const ( agentBalanceTable = "t_agent_balance" balanceDetailTable = "t_balance_detail" balanceAccountStatusNormal = 0 financeIncomeExpenseIncome = 0 financeTransactionTypeConsume = 1 financeTransactionChannelBalance = 3 financeTransactionStatusPaid = 1 refundStatusRefunded = "refunded" refundStatusNoRefund = "no_refund" refundStatusSkipped = "skipped" ) type balanceAccountQuery struct { Id int64 `orm:"id"` DeptId int64 `orm:"dept_id"` Balance float64 `orm:"balance"` Status int `orm:"status"` TenantId string `orm:"tenant_id"` } type balanceDetailQuery struct { Id int64 `orm:"id"` IncomeExpenseType int `orm:"income_expense_type"` TransactionStatus int `orm:"transaction_status"` Amount float64 `orm:"amount"` } func unsubscribeTime(task *unsub.Task, fallback *gtime.Time) *gtime.Time { if task != nil && task.Batch.StartTime != nil { return task.Batch.StartTime } return fallback } func (r *Repository) lockOrdersByOrderCodes(ctx context.Context, tx gdb.TX, tenantID string, orderCounts map[string]int) (map[string]*orderQuery, error) { result := make(map[string]*orderQuery) orderCodes := sortedOrderCodes(orderCounts) if len(orderCodes) == 0 { return result, nil } var rows []*orderQuery model := r.applyLock(tx.Ctx(ctx).Model(orderTable)). WhereIn("order_sn", orderCodes). Fields("id", "order_sn", "customer_agent_id", "pay_type", "pay_status", "pay_order_sn", "total_amount", "paid_amount", "balance", "unrefund_amount", "tenant_id", "created_dept") if strings.TrimSpace(tenantID) != "" { model = model.Where("tenant_id", tenantID) } if err := model.Scan(&rows); err != nil { return nil, err } for _, row := range rows { if row != nil && strings.TrimSpace(row.OrderSn) != "" { result[strings.TrimSpace(row.OrderSn)] = row } } return result, nil } func (r *Repository) lockOrderItemsByOrderCodes(ctx context.Context, tx gdb.TX, tenantID string, orderCounts map[string]int) (map[string]*orderItemQuery, error) { result := make(map[string]*orderItemQuery) orderCodes := sortedOrderCodes(orderCounts) if len(orderCodes) == 0 { return result, nil } var rows []*orderItemQuery model := r.applyLock(tx.Ctx(ctx).Model(orderItemTable)). WhereIn("order_sn", orderCodes). Fields("item_id", "order_sn", "price", "quantity", "service_begin_time", "service_end_time", "extra_sku_data") if strings.TrimSpace(tenantID) != "" { model = model.Where("tenant_id", tenantID) } if err := model.Scan(&rows); err != nil { return nil, err } for _, row := range rows { if row == nil || strings.TrimSpace(row.OrderSn) == "" { continue } orderCode := strings.TrimSpace(row.OrderSn) if _, exists := result[orderCode]; !exists { result[orderCode] = row } } return result, nil } func (r *Repository) refundOrders( ctx context.Context, tx gdb.TX, task *unsub.Task, orderCounts map[string]int, bindsByIccid map[string]*bindPkgQuery, orders map[string]*orderQuery, orderItems map[string]*orderItemQuery, activeCounts map[string]int, cutoffTime, transactionTime *gtime.Time, ) (map[string]*unsub.OrderRefund, error) { if cutoffTime == nil { cutoffTime = transactionTime } if cutoffTime == nil { return nil, gerror.New("unsub refund cutoff time is empty") } result := make(map[string]*unsub.OrderRefund, len(orderCounts)) orderBinds := make(map[string][]*bindPkgQuery, len(orderCounts)) for _, detail := range task.Details { if detail == nil { continue } bind := bindsByIccid[detail.ICCID] if bind == nil { continue } orderCode := strings.TrimSpace(bind.OrderCode) if orderCode != "" { orderBinds[orderCode] = append(orderBinds[orderCode], bind) } } for _, orderCode := range sortedOrderCodes(orderCounts) { refund := &unsub.OrderRefund{Status: refundStatusNoRefund} result[orderCode] = refund order := orders[orderCode] if order == nil { refund.Status = refundStatusSkipped refund.Reason = "order not found" g.Log().Warningf(ctx, "unsub balance refund skipped, order not found, batchNo=%s orderNo=%s", task.Batch.BatchNo, orderCode) continue } if order.PayType != consts.PayTypeBalance { refund.Status = refundStatusSkipped refund.Reason = "order is not paid by balance" g.Log().Warningf(ctx, "unsub balance refund skipped, order is not paid by balance, batchNo=%s orderNo=%s payType=%d", task.Batch.BatchNo, orderCode, order.PayType) continue } if order.PayStatus != 0 && order.PayStatus != consts.PayStatusPaid { refund.Status = refundStatusSkipped refund.Reason = "order is not paid" g.Log().Warningf(ctx, "unsub balance refund skipped, order is not paid, batchNo=%s orderNo=%s payStatus=%d", task.Batch.BatchNo, orderCode, order.PayStatus) continue } binds := orderBinds[orderCode] if len(binds) == 0 { continue } item := orderItems[orderCode] totalQuantity := resolveOrderQuantity(order, item, activeCounts[orderCode]) if totalQuantity <= 0 { return nil, gerror.Newf("order %s lacks a valid subscription quantity for refund", orderCode) } paidAmount := orderPaidAmount(order, item, totalQuantity) if paidAmount <= 0 { refund.Reason = "order paid amount is zero" continue } unitPaidAmount := paidAmount / float64(totalQuantity) refundAmount := 0.0 for _, bind := range binds { if bind == nil { continue } if bind.EffectiveTime == nil || bind.DurationMonths <= 0 { return nil, gerror.Newf("order %s has an invalid paid subscription period", orderCode) } refundAmount += unitPaidAmount * calculateRefundRatio(bind.EffectiveTime.Time, cutoffTime.Time, bind.DurationMonths) } refund.Amount = roundUnsubAmount(refundAmount) if refund.Amount <= 0 { refund.Reason = "paid subscription period has been fully used" continue } refund.SerialNo = unsubscribeRefundSerialNo(task.Batch.BatchNo, orderCode) existing, err := r.lockRefundDetail(ctx, tx, task.Batch.TenantID, refund.SerialNo) if err != nil { return nil, gerror.Wrap(err, "query existing unsub refund detail") } if existing.Id != 0 { if existing.IncomeExpenseType != financeIncomeExpenseIncome || existing.TransactionStatus != financeTransactionStatusPaid { return nil, gerror.Newf("existing unsub refund detail is invalid, order %s", orderCode) } refund.Amount = roundUnsubAmount(existing.Amount) refund.Status = refundStatusRefunded refund.Reason = "refund detail already exists" continue } payerDeptID := refundPayerDeptID(order, binds) if payerDeptID <= 0 { return nil, gerror.Newf("order %s lacks a balance payer department", orderCode) } tenantID := strings.TrimSpace(order.TenantId) if tenantID == "" { tenantID = strings.TrimSpace(task.Batch.TenantID) } if tenantID == "" { return nil, gerror.Newf("order %s lacks a tenant id for refund", orderCode) } var account balanceAccountQuery accountModel := r.applyLock(tx.Ctx(ctx).Model(agentBalanceTable)). Where("dept_id", payerDeptID). Where("tenant_id", tenantID). Fields("id", "dept_id", "balance", "status", "tenant_id") if err := accountModel.Scan(&account); err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, gerror.Wrap(err, "query balance account for unsub refund") } if account.Id == 0 { return nil, gerror.Newf("balance account for order %s does not exist", orderCode) } if account.Status != balanceAccountStatusNormal { return nil, gerror.Newf("balance account for order %s is not available", orderCode) } before := roundUnsubAmount(account.Balance) after := roundUnsubAmount(before + refund.Amount) accountUpdate, err := tx.Ctx(ctx).Model(agentBalanceTable). Where("id", account.Id). Data(g.Map{ "balance": after, "updated_by": task.Batch.CreatedBy, "updated_at": transactionTime, }).Update() if err != nil { return nil, gerror.Wrap(err, "update balance account for unsub refund") } if affected, err := accountUpdate.RowsAffected(); err != nil { return nil, gerror.Wrap(err, "read balance account update result") } else if affected != 1 { return nil, gerror.Newf("balance account for order %s changed during refund", orderCode) } createdDept := order.CreatedDept if createdDept <= 0 { createdDept = payerDeptID } if _, err := tx.Ctx(ctx).Model(balanceDetailTable).Data(g.Map{ "dept_id": payerDeptID, "serial_no": refund.SerialNo, "transaction_time": transactionTime, "income_expense_type": financeIncomeExpenseIncome, "transaction_type": financeTransactionTypeConsume, "transaction_channel": financeTransactionChannelBalance, "transaction_status": financeTransactionStatusPaid, "amount": refund.Amount, "before_balance": before, "balance_after": after, "order_sn": orderCode, "remark": fmt.Sprintf("SIM card unsubscribe balance refund, batchNo=%s, orderNo=%s", task.Batch.BatchNo, orderCode), "tenant_id": tenantID, "created_dept": createdDept, "created_by": task.Batch.CreatedBy, "created_at": transactionTime, }).Insert(); err != nil { return nil, gerror.Wrap(err, "insert unsub refund detail") } refund.Status = refundStatusRefunded } return result, nil } func (r *Repository) lockRefundDetail(ctx context.Context, tx gdb.TX, tenantID, serialNo string) (*balanceDetailQuery, error) { var row balanceDetailQuery model := r.applyLock(tx.Ctx(ctx).Model(balanceDetailTable)). Where("tenant_id", tenantID). Where("serial_no", serialNo). Fields("id", "income_expense_type", "transaction_status", "amount") if err := model.Scan(&row); err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } return &row, nil } func sortedOrderCodes(orderCounts map[string]int) []string { orderCodes := make([]string, 0, len(orderCounts)) for orderCode := range orderCounts { if strings.TrimSpace(orderCode) != "" { orderCodes = append(orderCodes, orderCode) } } sort.Strings(orderCodes) return orderCodes } func resolveOrderQuantity(order *orderQuery, item *orderItemQuery, activeCount int) int { if item != nil && item.Quantity > 0 { return item.Quantity } paidAmount := orderPaidAmount(order, nil, 0) if item != nil && item.Price > 0 && paidAmount > 0 { quantity := int(math.Round(paidAmount / item.Price)) if quantity > 0 { return quantity } } return activeCount } func orderPaidAmount(order *orderQuery, item *orderItemQuery, quantity int) float64 { if order != nil { if order.Balance > 0 { return order.Balance } if order.PaidAmount > 0 { return order.PaidAmount } if order.TotalAmount > 0 { return order.TotalAmount } } if item != nil && item.Price > 0 && quantity > 0 { return item.Price * float64(quantity) } return 0 } func refundPayerDeptID(order *orderQuery, binds []*bindPkgQuery) int64 { if order != nil && order.CustomerAgentId > 0 { return order.CustomerAgentId } for _, bind := range binds { if bind == nil { continue } if deptID, err := strconv.ParseInt(strings.TrimSpace(bind.DeptId), 10, 64); err == nil && deptID > 0 { return deptID } } if order != nil && order.CreatedDept > 0 { return order.CreatedDept } return 0 } func unsubscribeRefundSerialNo(batchNo, orderCode string) string { return fmt.Sprintf("UNSUBREFUND-%s-%s", strings.TrimSpace(batchNo), strings.TrimSpace(orderCode)) } func roundUnsubAmount(value float64) float64 { if value <= 0 { return 0 } return math.Round(value*100) / 100 } func calculateRefundRatio(effectiveAt, cutoffAt time.Time, durationMonths int) float64 { if effectiveAt.IsZero() || cutoffAt.IsZero() || durationMonths <= 0 { return 0 } paidEndAt := addRefundMonths(effectiveAt, durationMonths) if !cutoffAt.After(effectiveAt) { return 1 } if !cutoffAt.Before(paidEndAt) { return 0 } totalSeconds := paidEndAt.Sub(effectiveAt).Seconds() remainingSeconds := paidEndAt.Sub(cutoffAt).Seconds() if totalSeconds <= 0 || remainingSeconds <= 0 { return 0 } ratio := remainingSeconds / totalSeconds if ratio < 0 { return 0 } if ratio > 1 { return 1 } return ratio } func addRefundMonths(value time.Time, months int) time.Time { if months <= 0 { return value } targetMonth := time.Date(value.Year(), value.Month()+time.Month(months), 1, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location()) day := value.Day() lastDay := time.Date(targetMonth.Year(), targetMonth.Month()+1, 0, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location()).Day() if day > lastDay { day = lastDay } return time.Date(targetMonth.Year(), targetMonth.Month(), day, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location()) }