fix(invitation): 线下提号接入邀请返利并补发历史漏发,返利比例文案改读配置
- 线下提号(admin_pickups)不走订单结账流程,此前完全不发邀请返利;现于 Complete() 内按号主利润对半基数发放卖家侧返利 - 迁移 000086:invitation_rebate_ledgers 增加 source 列,唯一键调整为 (source, order_id, trade_role),支持提号来源且保持幂等 - 新增 cmd/rebate-backfill 幂等补发脚本:默认预演,加 -apply 才写库 - 返利阶段文案不再写死 60%/30%/15%,改为按本次实际生效比例生成(钱包流水备注同步修正) - 前台邀请页阶段标签/横幅/空态文案、移动端个人中心入口文案均改读接口配置,并修掉合法 0% 比例被 || 覆盖成 60% 的问题 - 新增单测:提号返利发放与幂等、cycleLabel 跟随后台配置
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// rebate-backfill 为「线下提号」补发邀请返利(一次性运维脚本,可安全重复执行)。
|
||||
//
|
||||
// 背景:线下提号(admin_pickups)不走正常订单结账流程,历史上未接入邀请返利结算,
|
||||
// 导致已完成提号单的号主邀请人拿不到分成。代码侧已在 pickup.Complete 内接入,
|
||||
// 本脚本用于补齐「接入前已完成」的历史提号单;仍在进行中的提号单会在完成时自动发放。
|
||||
//
|
||||
// 幂等:写入 (source='pickup', order_id, trade_role='seller') 唯一键,
|
||||
// 且候选集已排除已有提号返利记录的单据,重复执行不会重复发放。
|
||||
//
|
||||
// 口径:单边基数 = 提号利润对半(profit_amount_cent / 2),与租赁订单卖家侧一致;
|
||||
// 比例阶梯按提号完成时刻距今(被邀请人实名生效)的档位计算。
|
||||
//
|
||||
// 用法(默认预演,不写库):
|
||||
//
|
||||
// go run ./cmd/rebate-backfill # 预演,只列待补发单据
|
||||
// go run ./cmd/rebate-backfill -apply # 实际补发
|
||||
// go run ./cmd/rebate-backfill -apply -pickup-no PK20261011113459355
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/config"
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/logging"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/invitation"
|
||||
"hfb_sys/backend/internal/modules/pickup"
|
||||
"hfb_sys/backend/pkg/money"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type candidate struct {
|
||||
ID uint64
|
||||
PickupNo string
|
||||
OwnerID uint64
|
||||
ProfitAmountCent int64
|
||||
CompletedAt *time.Time
|
||||
ListingSiteID *uint64
|
||||
}
|
||||
|
||||
func main() {
|
||||
apply := flag.Bool("apply", false, "实际执行补发(默认仅预演)")
|
||||
pickupNo := flag.String("pickup-no", "", "仅处理指定提号单号")
|
||||
limit := flag.Int("limit", 0, "最多处理条数,0 表示不限制")
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.Load()
|
||||
logger, err := logging.New(cfg.Log)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化日志失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = logger.Sync()
|
||||
}()
|
||||
|
||||
db, err := database.OpenMySQLWithOptions(cfg.MySQLDSN, cfg.Log.Level, logger, database.MySQLOptions{})
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
|
||||
rows, err := loadCandidates(db, *pickupNo, *limit)
|
||||
if err != nil {
|
||||
log.Fatalf("查询待补发提号单失败: %v", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("没有需要补发的提号单:已完成提号单均已覆盖或不存在生效邀请关系。")
|
||||
return
|
||||
}
|
||||
|
||||
mode := "预演"
|
||||
if *apply {
|
||||
mode = "实际补发"
|
||||
}
|
||||
fmt.Printf("模式=%s,候选提号单=%d\n", mode, len(rows))
|
||||
|
||||
var paidCount, skippedCount int
|
||||
var paidCent int64
|
||||
for _, row := range rows {
|
||||
completedAt := time.Now()
|
||||
if row.CompletedAt != nil {
|
||||
completedAt = *row.CompletedAt
|
||||
}
|
||||
siteID := uint64(0)
|
||||
if row.ListingSiteID != nil {
|
||||
siteID = *row.ListingSiteID
|
||||
}
|
||||
|
||||
if !*apply {
|
||||
fmt.Printf("[预演] %s 号主=%d 利润=%s 单边基数=%s 完成于=%s\n",
|
||||
row.PickupNo, row.OwnerID,
|
||||
money.FormatWithSymbol(row.ProfitAmountCent),
|
||||
money.FormatWithSymbol(row.ProfitAmountCent/2),
|
||||
completedAt.Format("2006-01-02 15:04:05"))
|
||||
continue
|
||||
}
|
||||
|
||||
amountCent, err := backfillOne(db, row.ID, siteID, completedAt)
|
||||
if err != nil {
|
||||
log.Printf("补发失败 %s: %v", row.PickupNo, err)
|
||||
continue
|
||||
}
|
||||
if amountCent <= 0 {
|
||||
skippedCount++
|
||||
fmt.Printf("[跳过] %s 未产生返利(无生效邀请 / 邀请人未实名 / 返利已关闭 / 已存在记录)\n", row.PickupNo)
|
||||
continue
|
||||
}
|
||||
paidCount++
|
||||
paidCent += amountCent
|
||||
fmt.Printf("[补发] %s 返利 %s 已入邀请人钱包\n", row.PickupNo, money.FormatWithSymbol(amountCent))
|
||||
}
|
||||
|
||||
if *apply {
|
||||
fmt.Printf("完成:补发 %d 单,合计 %s;跳过 %d 单。\n",
|
||||
paidCount, money.FormatWithSymbol(paidCent), skippedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func loadCandidates(db *gorm.DB, pickupNo string, limit int) ([]candidate, error) {
|
||||
query := db.Table("admin_pickups AS p").
|
||||
Select(`p.id, p.pickup_no, p.owner_id, p.profit_amount_cent, p.completed_at,
|
||||
l.distribution_site_id AS listing_site_id`).
|
||||
Joins("JOIN user_invitations i ON i.invitee_id = p.owner_id AND i.status = ? AND i.inviter_id <> p.owner_id", invitation.StatusActive).
|
||||
Joins("LEFT JOIN rental_listings l ON l.id = p.listing_id").
|
||||
Where("p.status = ?", pickup.StatusCompleted).
|
||||
Where("p.profit_amount_cent > 0").
|
||||
Where(`NOT EXISTS (
|
||||
SELECT 1 FROM invitation_rebate_ledgers r
|
||||
WHERE r.source = ? AND r.order_id = p.id AND r.trade_role = 'seller'
|
||||
)`, invitation.RebateSourcePickup).
|
||||
Order("p.completed_at ASC")
|
||||
|
||||
if trimmed := strings.TrimSpace(pickupNo); trimmed != "" {
|
||||
query = query.Where("p.pickup_no = ?", trimmed)
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
var rows []candidate
|
||||
if err := query.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// backfillOne 在独立事务内为单张提号单补发返利并返回实际发放金额(分)。
|
||||
func backfillOne(db *gorm.DB, pickupID uint64, siteID uint64, completedAt time.Time) (int64, error) {
|
||||
var amountCent int64
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var record model.AdminPickup
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, pickupID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if record.Status != pickup.StatusCompleted {
|
||||
return nil
|
||||
}
|
||||
amount, err := invitation.AwardSellerSideRebateTx(tx, invitation.SellerSideRebateInput{
|
||||
Source: invitation.RebateSourcePickup,
|
||||
SourceID: record.ID,
|
||||
SourceNo: record.PickupNo,
|
||||
SellerID: record.OwnerID,
|
||||
GrossBasisCent: record.ProfitAmountCent,
|
||||
BasisCent: record.ProfitAmountCent / 2,
|
||||
SiteID: siteID,
|
||||
RateReferenceAt: completedAt,
|
||||
SettledAt: time.Now(),
|
||||
RemarkPrefix: "邀请卖家提号返利",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
amountCent = amount
|
||||
return nil
|
||||
})
|
||||
return amountCent, err
|
||||
}
|
||||
@@ -38,12 +38,13 @@ func (InvitationRebateConfig) TableName() string {
|
||||
// InvitationRebateLedger 邀请返利结算记录明细表
|
||||
type InvitationRebateLedger struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
OrderID uint64 `gorm:"not null;uniqueIndex:uk_invitation_rebate_order_role,priority:1" json:"order_id"`
|
||||
OrderID uint64 `gorm:"not null;uniqueIndex:uk_invitation_rebate_source_role,priority:2" json:"order_id"`
|
||||
OrderNo string `gorm:"size:64;not null" json:"order_no"`
|
||||
Source string `gorm:"size:32;not null;default:'rental_order';uniqueIndex:uk_invitation_rebate_source_role,priority:1" json:"source"` // rental_order / pickup
|
||||
DistributionSiteID uint64 `gorm:"not null;default:0;index:idx_invitation_rebate_site" json:"distribution_site_id"`
|
||||
InviterID uint64 `gorm:"not null;index:idx_invitation_rebate_inviter" json:"inviter_id"`
|
||||
InviteeID uint64 `gorm:"not null" json:"invitee_id"`
|
||||
TradeRole string `gorm:"size:16;not null;uniqueIndex:uk_invitation_rebate_order_role,priority:2" json:"trade_role"` // buyer / seller
|
||||
TradeRole string `gorm:"size:16;not null;uniqueIndex:uk_invitation_rebate_source_role,priority:3" json:"trade_role"` // buyer / seller
|
||||
OrderProfitBasisCent int64 `gorm:"not null;default:0" json:"order_profit_basis_cent"`
|
||||
SingleSideBasisCent int64 `gorm:"not null;default:0" json:"single_side_basis_cent"`
|
||||
RateApplied float64 `gorm:"type:decimal(6,4);not null;default:0" json:"rate_applied"`
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -38,6 +39,10 @@ const (
|
||||
DefaultFirstMonthRate = 0.6000
|
||||
DefaultSecondMonthRate = 0.3000
|
||||
DefaultSubsequentRate = 0.1500
|
||||
|
||||
// 返利来源单据类型:source=biz_type 用于区分返利由哪类单据产生。
|
||||
RebateSourceRentalOrder = "rental_order"
|
||||
RebateSourcePickup = "pickup"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
@@ -433,7 +438,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
||||
BalanceType: "available",
|
||||
BizType: "invite_rebate",
|
||||
BizNo: order.OrderNo,
|
||||
Remark: fmt.Sprintf("邀请买家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle)),
|
||||
Remark: fmt.Sprintf("邀请买家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle, rate)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -495,7 +500,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
||||
BalanceType: "available",
|
||||
BizType: "invite_rebate",
|
||||
BizNo: order.OrderNo,
|
||||
Remark: fmt.Sprintf("邀请卖家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle)),
|
||||
Remark: fmt.Sprintf("邀请卖家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle, rate)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -508,17 +513,137 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
|
||||
return nil
|
||||
}
|
||||
|
||||
func cycleLabel(cycle string) string {
|
||||
// cycleLabel 生成返利周期的展示文案。
|
||||
// 比例必须取本次实际生效的 rate(后台/分销站可改配置),
|
||||
// 不能写死 60%/30%/15%,否则钱包流水备注会和实际比例不一致。
|
||||
func cycleLabel(cycle string, rate float64) string {
|
||||
percent := formatRebateRate(rate)
|
||||
switch cycle {
|
||||
case CycleFirstMonth:
|
||||
return "首月高额奖励 60%"
|
||||
return "首月奖励 " + percent
|
||||
case CycleSecondMonth:
|
||||
return "次月奖励 30%"
|
||||
return "次月奖励 " + percent
|
||||
case CycleSubsequent:
|
||||
return "长期奖励 " + percent
|
||||
default:
|
||||
return "长期奖励 15%"
|
||||
return "返利 " + percent
|
||||
}
|
||||
}
|
||||
|
||||
// formatRebateRate 把比例(0.6)格式化为展示文案(60%)。
|
||||
// 先按配置列精度 decimal(6,4) 规整,避免浮点尾差显示成 59.999999%。
|
||||
func formatRebateRate(rate float64) string {
|
||||
percent := math.Round(rate*10000) / 100
|
||||
return strconv.FormatFloat(percent, 'f', -1, 64) + "%"
|
||||
}
|
||||
|
||||
// SellerSideRebateInput 单边(卖家/服务提供方)返利结算入参。
|
||||
//
|
||||
// 租赁订单与线下提号共用同一套规则:按被邀请人实名生效时刻计算比例阶梯、
|
||||
// 用 (source, order_id, trade_role) 唯一键保证幂等、入账到邀请人钱包可用余额。
|
||||
// 差异只在来源标识与收益基数,因此统一走这里,避免两处各写一遍算钱逻辑。
|
||||
type SellerSideRebateInput struct {
|
||||
Source string // 来源单据类型:RebateSourceRentalOrder / RebateSourcePickup
|
||||
SourceID uint64 // 来源单据 ID
|
||||
SourceNo string // 来源单据编号(写入返利流水与钱包流水备注)
|
||||
SellerID uint64 // 交易人(被邀请人本人)
|
||||
GrossBasisCent int64 // 来源单据整体收益基数(分)
|
||||
BasisCent int64 // 单边收益基数(分),通常为 GrossBasisCent 对半
|
||||
SiteID uint64 // 成本分担站点(0=主站)
|
||||
RateReferenceAt time.Time // 比例阶梯的基准时刻(单据完成时刻);零值取当前时间
|
||||
SettledAt time.Time // 实际入账时刻;零值取当前时间
|
||||
RemarkPrefix string // 钱包流水备注前缀
|
||||
}
|
||||
|
||||
// AwardSellerSideRebateTx 在事务内为「交易人的邀请人」发放单边返利(幂等)。
|
||||
// 未命中生效邀请关系、邀请人未实名、返利配置关闭或金额为 0 时不写任何记录并返回 0。
|
||||
func AwardSellerSideRebateTx(tx *gorm.DB, in SellerSideRebateInput) (int64, error) {
|
||||
if tx == nil || in.SellerID == 0 || in.SourceID == 0 || in.BasisCent <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
var invitation model.UserInvitation
|
||||
if err := tx.WithContext(ctx).
|
||||
Where("invitee_id = ? AND status = ?", in.SellerID, StatusActive).
|
||||
First(&invitation).Error; err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
if invitation.VerifiedAt == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var inviter model.User
|
||||
if err := tx.WithContext(ctx).First(&inviter, invitation.InviterID).Error; err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
if inviter.RealnameStatus != "verified" || inviter.ID == in.SellerID {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
repo := NewRepository(tx)
|
||||
cfg, _ := repo.GetRebateConfig(ctx, in.SiteID)
|
||||
if cfg != nil && !cfg.Enabled {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
referenceAt := in.RateReferenceAt
|
||||
if referenceAt.IsZero() {
|
||||
referenceAt = time.Now()
|
||||
}
|
||||
settledAt := in.SettledAt
|
||||
if settledAt.IsZero() {
|
||||
settledAt = time.Now()
|
||||
}
|
||||
rate, cycle, days := CalculateRebateRate(*invitation.VerifiedAt, referenceAt, cfg)
|
||||
rebateCent := int64(math.Round(float64(in.BasisCent) * rate))
|
||||
if rebateCent <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
grossBasis := in.GrossBasisCent
|
||||
if grossBasis <= 0 {
|
||||
grossBasis = in.BasisCent
|
||||
}
|
||||
ledger := model.InvitationRebateLedger{
|
||||
OrderID: in.SourceID,
|
||||
OrderNo: in.SourceNo,
|
||||
Source: in.Source,
|
||||
DistributionSiteID: in.SiteID,
|
||||
InviterID: invitation.InviterID,
|
||||
InviteeID: in.SellerID,
|
||||
TradeRole: "seller",
|
||||
OrderProfitBasisCent: grossBasis,
|
||||
SingleSideBasisCent: in.BasisCent,
|
||||
RateApplied: rate,
|
||||
DaysSinceVerified: days,
|
||||
CycleStage: cycle,
|
||||
RebateAmountCent: rebateCent,
|
||||
WalletLedgerNo: fmt.Sprintf("REB-S-%s-%d", in.SourceNo, invitation.InviterID),
|
||||
Status: "settled",
|
||||
SettledAt: settledAt,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ledger.ID == 0 {
|
||||
// 同来源同角色已有返利记录:幂等跳过(重复调用、补发脚本重跑均安全)。
|
||||
return 0, nil
|
||||
}
|
||||
if err := wallet.AppendEntries(tx, wallet.Entry{
|
||||
UserID: invitation.InviterID,
|
||||
DistributionSiteID: in.SiteID,
|
||||
Direction: "in",
|
||||
AmountCent: rebateCent,
|
||||
BalanceType: "available",
|
||||
BizType: "invite_rebate",
|
||||
BizNo: in.SourceNo,
|
||||
Remark: fmt.Sprintf("%s(%s,%s)", in.RemarkPrefix, in.SourceNo, cycleLabel(cycle, rate)),
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rebateCent, nil
|
||||
}
|
||||
|
||||
// GetUserSummary 获取用户个人中心的邀请返利数据看板
|
||||
func (r *Repository) GetUserSummary(ctx context.Context, userID uint64, siteID uint64) (*UserInvitationSummaryDTO, error) {
|
||||
if r == nil || r.db == nil {
|
||||
@@ -835,6 +960,7 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
|
||||
ID uint64 `gorm:"column:id"`
|
||||
OrderID uint64 `gorm:"column:order_id"`
|
||||
OrderNo string `gorm:"column:order_no"`
|
||||
Source string `gorm:"column:source"`
|
||||
DistributionSiteID uint64 `gorm:"column:distribution_site_id"`
|
||||
InviterID uint64 `gorm:"column:inviter_id"`
|
||||
InviterPhone string `gorm:"column:inviter_phone"`
|
||||
@@ -853,7 +979,7 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
|
||||
|
||||
var rows []rawLedger
|
||||
offset := (query.Page - 1) * query.PageSize
|
||||
err := db.Select("irl.id, irl.order_id, irl.order_no, irl.distribution_site_id, irl.inviter_id, inviter.phone as inviter_phone, irl.invitee_id, invitee.phone as invitee_phone, irl.trade_role, irl.order_profit_basis_cent, irl.single_side_basis_cent, irl.rate_applied, irl.days_since_verified, irl.cycle_stage, irl.rebate_amount_cent, irl.status, irl.settled_at").
|
||||
err := db.Select("irl.id, irl.order_id, irl.order_no, irl.source, irl.distribution_site_id, irl.inviter_id, inviter.phone as inviter_phone, irl.invitee_id, invitee.phone as invitee_phone, irl.trade_role, irl.order_profit_basis_cent, irl.single_side_basis_cent, irl.rate_applied, irl.days_since_verified, irl.cycle_stage, irl.rebate_amount_cent, irl.status, irl.settled_at").
|
||||
Order("irl.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -865,6 +991,7 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
|
||||
"id": row.ID,
|
||||
"order_id": row.OrderID,
|
||||
"order_no": row.OrderNo,
|
||||
"source": row.Source,
|
||||
"distribution_site_id": row.DistributionSiteID,
|
||||
"inviter_id": row.InviterID,
|
||||
"inviter_phone": row.InviterPhone,
|
||||
|
||||
@@ -289,3 +289,141 @@ func TestCrossSiteDistributionRebate(t *testing.T) {
|
||||
t.Fatalf("卖家端跨站返利错误: siteID=%d, amount=%d", sellerLedger.DistributionSiteID, sellerLedger.RebateAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
// cycleLabel 的比例必须来自实际生效配置,改配置后文案要跟着变,
|
||||
// 否则钱包流水备注会与后台比例不一致(历史缺陷)。
|
||||
func TestCycleLabelFollowsConfiguredRate(t *testing.T) {
|
||||
cases := []struct {
|
||||
cycle string
|
||||
rate float64
|
||||
want string
|
||||
}{
|
||||
{CycleFirstMonth, 0.60, "首月奖励 60%"},
|
||||
{CycleFirstMonth, 0.50, "首月奖励 50%"},
|
||||
{CycleFirstMonth, 0.80, "首月奖励 80%"},
|
||||
{CycleSecondMonth, 0.30, "次月奖励 30%"},
|
||||
{CycleSecondMonth, 0.25, "次月奖励 25%"},
|
||||
{CycleSubsequent, 0.15, "长期奖励 15%"},
|
||||
{CycleSubsequent, 0.125, "长期奖励 12.5%"},
|
||||
{CycleSubsequent, 0, "长期奖励 0%"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := cycleLabel(c.cycle, c.rate); got != c.want {
|
||||
t.Fatalf("cycleLabel(%s, %v) = %q, want %q", c.cycle, c.rate, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 线下提号返利与租赁订单卖家侧共用同一个入口 AwardSellerSideRebateTx:
|
||||
// 这里验证金额计算正确,且同一来源单据重复调用不会重复入账
|
||||
// (唯一键 source+order_id+trade_role 保证幂等,补发脚本重跑安全)。
|
||||
func TestAwardSellerSideRebateTxAwardsOncePerSource(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
inviterCode := "inv9001"
|
||||
inviter := model.User{
|
||||
Phone: "13800009001",
|
||||
Nickname: "提号邀请人",
|
||||
RealnameStatus: "verified",
|
||||
Status: "active",
|
||||
InviteCode: &inviterCode,
|
||||
}
|
||||
if err := db.Create(&inviter).Error; err != nil {
|
||||
t.Fatalf("创建邀请人失败: %v", err)
|
||||
}
|
||||
seller := model.User{
|
||||
Phone: "13800009002",
|
||||
Nickname: "提号号主",
|
||||
RealnameStatus: "verified",
|
||||
Status: "active",
|
||||
}
|
||||
if err := db.Create(&seller).Error; err != nil {
|
||||
t.Fatalf("创建号主失败: %v", err)
|
||||
}
|
||||
|
||||
verifiedAt := time.Now().AddDate(0, 0, -2) // 距结算 2 天 => 首月 60%
|
||||
if err := db.Create(&model.UserInvitation{
|
||||
InviterID: inviter.ID,
|
||||
InviteeID: seller.ID,
|
||||
InviteCode: inviterCode,
|
||||
Status: StatusActive,
|
||||
VerifiedAt: &verifiedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建邀请关系失败: %v", err)
|
||||
}
|
||||
|
||||
input := SellerSideRebateInput{
|
||||
Source: RebateSourcePickup,
|
||||
SourceID: 900001,
|
||||
SourceNo: "PK-REBATE-TEST-1",
|
||||
SellerID: seller.ID,
|
||||
GrossBasisCent: 10000,
|
||||
BasisCent: 5000,
|
||||
SiteID: 0,
|
||||
SettledAt: time.Now(),
|
||||
RemarkPrefix: "邀请卖家提号返利",
|
||||
}
|
||||
|
||||
apply := func() int64 {
|
||||
t.Helper()
|
||||
var amount int64
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
got, err := AwardSellerSideRebateTx(tx, input)
|
||||
amount = got
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("发放提号返利失败: %v", err)
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
// 单边基数 5000,首月 60% => 3000 分
|
||||
if got := apply(); got != 3000 {
|
||||
t.Fatalf("首次发放金额 = %d, want 3000", got)
|
||||
}
|
||||
|
||||
var rebate model.InvitationRebateLedger
|
||||
if err := db.Where("source = ? AND order_id = ? AND trade_role = 'seller'", RebateSourcePickup, input.SourceID).
|
||||
First(&rebate).Error; err != nil {
|
||||
t.Fatalf("未找到提号返利流水: %v", err)
|
||||
}
|
||||
if rebate.InviterID != inviter.ID || rebate.InviteeID != seller.ID || rebate.OrderNo != input.SourceNo {
|
||||
t.Fatalf("返利流水字段错误: %+v", rebate)
|
||||
}
|
||||
if rebate.CycleStage != CycleFirstMonth || rebate.DaysSinceVerified != 2 {
|
||||
t.Fatalf("返利档位错误: cycle=%s, days=%d", rebate.CycleStage, rebate.DaysSinceVerified)
|
||||
}
|
||||
|
||||
// 同来源同角色重复调用必须跳过,不重复写流水、不重复入账
|
||||
if got := apply(); got != 0 {
|
||||
t.Fatalf("重复调用应幂等跳过, got %d", got)
|
||||
}
|
||||
|
||||
var ledgerCount int64
|
||||
if err := db.Model(&model.InvitationRebateLedger{}).
|
||||
Where("source = ? AND order_id = ?", RebateSourcePickup, input.SourceID).
|
||||
Count(&ledgerCount).Error; err != nil {
|
||||
t.Fatalf("统计返利流水失败: %v", err)
|
||||
}
|
||||
if ledgerCount != 1 {
|
||||
t.Fatalf("返利流水条数 = %d, want 1", ledgerCount)
|
||||
}
|
||||
|
||||
var walletCount int64
|
||||
if err := db.Model(&model.WalletLedger{}).
|
||||
Where("biz_type = ? AND biz_no = ?", "invite_rebate", input.SourceNo).
|
||||
Count(&walletCount).Error; err != nil {
|
||||
t.Fatalf("统计钱包流水失败: %v", err)
|
||||
}
|
||||
if walletCount != 1 {
|
||||
t.Fatalf("钱包流水条数 = %d, want 1", walletCount)
|
||||
}
|
||||
|
||||
var account model.WalletAccount
|
||||
if err := db.Where("user_id = ? AND distribution_site_id = 0", inviter.ID).First(&account).Error; err != nil {
|
||||
t.Fatalf("未找到邀请人钱包账户: %v", err)
|
||||
}
|
||||
if account.AvailableBalanceCent != 3000 {
|
||||
t.Fatalf("邀请人可用余额 = %d, want 3000", account.AvailableBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/invitation"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
@@ -305,6 +306,30 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请返利:线下提号不走正常订单结账流程,必须在这里补发号主(卖家)侧返利,
|
||||
// 否则号主邀请人永远拿不到提号产生的分成。基数按提号利润对半(与租赁订单卖家侧口径一致)。
|
||||
if grossBasisCent := pickup.ProfitAmountCent; grossBasisCent > 0 {
|
||||
sellerSiteID := uint64(0)
|
||||
if listing.DistributionSiteID != nil {
|
||||
sellerSiteID = *listing.DistributionSiteID
|
||||
}
|
||||
if _, err := invitation.AwardSellerSideRebateTx(tx, invitation.SellerSideRebateInput{
|
||||
Source: invitation.RebateSourcePickup,
|
||||
SourceID: pickup.ID,
|
||||
SourceNo: pickup.PickupNo,
|
||||
SellerID: pickup.OwnerID,
|
||||
GrossBasisCent: grossBasisCent,
|
||||
BasisCent: grossBasisCent / 2,
|
||||
SiteID: sellerSiteID,
|
||||
RateReferenceAt: now,
|
||||
SettledAt: now,
|
||||
RemarkPrefix: "邀请卖家提号返利",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := appendPickupEvent(tx, pickup, "pickup_completed", adminID, "管理员完成提号。", pickup.CompleteRemark, map[string]any{
|
||||
"settle_amount_cent": pickup.SettleAmountCent, "profit_amount_cent": pickup.ProfitAmountCent,
|
||||
"settlement_mode": pickup.SettlementMode, "offline_settlement_status": pickup.OfflineSettlementStatus,
|
||||
|
||||
@@ -365,6 +365,74 @@ func TestRepositoryCompleteInternalPickupCreditsWallet(t *testing.T) {
|
||||
assertInt64(t, "站内提号钱包入账", ledger.AmountCent, 12000)
|
||||
}
|
||||
|
||||
// 线下提号不走正常订单结账流程,完成时必须同步给号主邀请人发放返利,
|
||||
// 否则邀请人永远拿不到提号产生的分成(历史漏发缺口)。
|
||||
func TestRepositoryCompletePickupAwardsSellerInvitationRebate(t *testing.T) {
|
||||
repo, db := newPickupTestRepo(t)
|
||||
|
||||
inviter := model.User{Phone: "19900000001", Nickname: "提号邀请人", RealnameStatus: "verified", Status: "active"}
|
||||
if err := db.Create(&inviter).Error; err != nil {
|
||||
t.Fatalf("创建邀请人失败: %v", err)
|
||||
}
|
||||
|
||||
listing := seedAvailableListing(t, db, "LST-REBATE-001", "返利提号账号", false)
|
||||
verifiedAt := time.Now().AddDate(0, 0, -2) // 距提号完成 2 天 => 首月 60% 档
|
||||
if err := db.Create(&model.UserInvitation{
|
||||
InviterID: inviter.ID,
|
||||
InviteeID: listing.OwnerID,
|
||||
InviteCode: "rebate01",
|
||||
Status: "active",
|
||||
VerifiedAt: &verifiedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("绑定号主邀请关系失败: %v", err)
|
||||
}
|
||||
|
||||
pickup, err := repo.Create(t.Context(), CreateRequest{
|
||||
ListingID: listing.ID,
|
||||
OrderAmountCent: 20000,
|
||||
ProfitAmountCent: 10000,
|
||||
}, 99, auditlog.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
var stored model.AdminPickup
|
||||
if err := db.First(&stored, pickup.ID).Error; err != nil {
|
||||
t.Fatalf("读取提号记录失败: %v", err)
|
||||
}
|
||||
if stored.ProfitAmountCent != 10000 {
|
||||
t.Fatalf("提号利润被改写为 %d,期望 10000(用例前提失效,请同步调整断言)", stored.ProfitAmountCent)
|
||||
}
|
||||
|
||||
if _, err := repo.Complete(t.Context(), pickup.ID, CompleteRequest{SettleAmountCent: 10000}, 99, auditlog.Meta{}); err != nil {
|
||||
t.Fatalf("Complete() error = %v", err)
|
||||
}
|
||||
|
||||
var rebate model.InvitationRebateLedger
|
||||
if err := db.Where("source = ? AND order_id = ? AND trade_role = ?", "pickup", pickup.ID, "seller").
|
||||
First(&rebate).Error; err != nil {
|
||||
t.Fatalf("提号完成应发放号主邀请人返利: %v", err)
|
||||
}
|
||||
if rebate.InviterID != inviter.ID || rebate.InviteeID != listing.OwnerID {
|
||||
t.Fatalf("返利双方 = %d/%d, want %d/%d", rebate.InviterID, rebate.InviteeID, inviter.ID, listing.OwnerID)
|
||||
}
|
||||
if rebate.OrderNo != pickup.PickupNo {
|
||||
t.Fatalf("返利单据号 = %q, want %q", rebate.OrderNo, pickup.PickupNo)
|
||||
}
|
||||
// 基数 = 提号利润 10000 对半 = 5000,首月 60% = 3000
|
||||
assertInt64(t, "提号返利单边基数", rebate.SingleSideBasisCent, 5000)
|
||||
assertInt64(t, "提号返利金额", rebate.RebateAmountCent, 3000)
|
||||
|
||||
var wallet model.WalletLedger
|
||||
if err := db.Where("biz_type = ? AND biz_no = ?", "invite_rebate", pickup.PickupNo).First(&wallet).Error; err != nil {
|
||||
t.Fatalf("提号返利应写钱包流水: %v", err)
|
||||
}
|
||||
if wallet.UserID != inviter.ID {
|
||||
t.Fatalf("返利入账用户 = %d, want %d", wallet.UserID, inviter.ID)
|
||||
}
|
||||
assertInt64(t, "提号返利入账金额", wallet.AmountCent, 3000)
|
||||
}
|
||||
|
||||
func TestRepositoryCompletePlatformManagedPickupUsesOfflineSettlement(t *testing.T) {
|
||||
repo, db := newPickupTestRepo(t)
|
||||
listing := seedAvailableListing(t, db, "LST-CMPL-EXTERNAL", "代管结算账号", true)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
|
||||
-- 邀请返利来源扩展:线下提号(admin_pickups)此前不经过正常订单结账流程,
|
||||
-- 因此提号产生的平台毛利从未参与返利计算(已确认漏发)。
|
||||
-- source 标识来源单据类型:rental_order=租赁订单,pickup=线下提号;
|
||||
-- order_id 统一存「来源单据 ID」(rental_orders.id 或 admin_pickups.id);
|
||||
-- 唯一键加入 source 后,两类来源的幂等键互不干扰,历史租赁订单行默认 rental_order。
|
||||
ALTER TABLE invitation_rebate_ledgers
|
||||
ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'rental_order' COMMENT '来源单据类型: rental_order=租赁订单, pickup=线下提号' AFTER order_no,
|
||||
MODIFY COLUMN order_id BIGINT UNSIGNED NOT NULL COMMENT '来源单据ID(rental_order=>rental_orders.id;pickup=>admin_pickups.id)',
|
||||
DROP INDEX uk_invitation_rebate_order_role,
|
||||
ADD UNIQUE KEY uk_invitation_rebate_source_role (source, order_id, trade_role);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE invitation_rebate_ledgers
|
||||
DROP INDEX uk_invitation_rebate_source_role,
|
||||
ADD UNIQUE KEY uk_invitation_rebate_order_role (order_id, trade_role),
|
||||
MODIFY COLUMN order_id BIGINT UNSIGNED NOT NULL COMMENT '关联订单ID',
|
||||
DROP COLUMN source;
|
||||
@@ -23,6 +23,8 @@ export interface AdminRebateRow {
|
||||
id: number
|
||||
order_id: number
|
||||
order_no: string
|
||||
/** 来源单据类型:rental_order=租赁订单,pickup=线下提号 */
|
||||
source?: string
|
||||
distribution_site_id: number
|
||||
inviter_id: number
|
||||
inviter_phone: string
|
||||
|
||||
@@ -367,6 +367,13 @@ function cycleStageLabel(stage: string) {
|
||||
return labels[stage] || stage || '-'
|
||||
}
|
||||
|
||||
/** 返利来源单据类型文案(提号单不走正常订单流程,需要单独标识)。 */
|
||||
function rebateSourceLabel(source?: string) {
|
||||
if (source === 'pickup') return '提号'
|
||||
if (source === 'rental_order') return '订单'
|
||||
return source || '订单'
|
||||
}
|
||||
|
||||
function paymentStatusLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
created: '已创建',
|
||||
@@ -652,7 +659,18 @@ function goPayments() {
|
||||
<el-table-column label="入账时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.settled_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="order_no" label="关联订单" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="来源" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="row.source === 'pickup' ? 'warning' : 'info'"
|
||||
>
|
||||
{{ rebateSourceLabel(row.source) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="order_no" label="关联单据" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="交易人(被邀请人)" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<code class="cell-sub">{{ row.invitee_phone || '-' }}</code>
|
||||
|
||||
@@ -401,7 +401,18 @@ function cycleLabel(cycle: string) {
|
||||
style="width: 100%; margin-top: 16px"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="order_no" label="关联订单号" min-width="160">
|
||||
<el-table-column label="来源" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="row.source === 'pickup' ? 'warning' : 'info'"
|
||||
>
|
||||
{{ row.source === 'pickup' ? '提号' : '订单' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="order_no" label="关联单据号" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<code>{{ row.order_no }}</code>
|
||||
</template>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { formatDateMinute } from '@/shared/utils/time'
|
||||
import { uploadFile } from '@/shared/api/files'
|
||||
import { formatCent } from '@/shared/utils/money'
|
||||
import { setPassword } from '@/features/auth/api/auth'
|
||||
import { fetchUserInvitationSummary } from '@/features/invitation/api/invitation'
|
||||
|
||||
import { useDistributionSiteStore } from '@/stores/distributionSite'
|
||||
|
||||
@@ -24,6 +25,9 @@ const session = useSessionStore()
|
||||
const siteStore = useDistributionSiteStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
/** 邀请返利入口右侧比例文案,取自后台返利配置。 */
|
||||
const inviteRateText = ref('')
|
||||
const { unreadCount: notificationUnreadCount, unreadLabel: notificationUnreadLabel } =
|
||||
useNotificationUnreadCount(route)
|
||||
|
||||
@@ -117,6 +121,7 @@ onMounted(async () => {
|
||||
// 保留本地会话展示,避免个人中心因网络短暂异常整页不可用。
|
||||
}
|
||||
void loadBalance()
|
||||
void loadInviteRate()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -129,6 +134,17 @@ async function loadBalance() {
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请返利入口右侧文案取后台实时配置的首月比例,不能写死。
|
||||
async function loadInviteRate() {
|
||||
try {
|
||||
const summary = await fetchUserInvitationSummary()
|
||||
inviteRateText.value = `首月 ${Math.round(summary.first_month_rate * 100)}% 收益`
|
||||
} catch {
|
||||
// 取不到配置时不展示数字,避免与后台实际比例不一致。
|
||||
inviteRateText.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function openLedgers() {
|
||||
showLedgers.value = true
|
||||
loadingLedgers.value = true
|
||||
@@ -472,7 +488,7 @@ function resolveAvatarURL(url: string | undefined | null) {
|
||||
icon="gold-coin-o"
|
||||
is-link
|
||||
to="/m/invitations"
|
||||
value="首月60%收益"
|
||||
:value="inviteRateText || '阶梯返利'"
|
||||
/>
|
||||
<van-cell title="租后须知" icon="info-o" is-link @click="openPostRentalNotice" />
|
||||
<van-cell title="系统设置" icon="setting-o" is-link @click="showSettings = true" />
|
||||
|
||||
@@ -143,16 +143,43 @@ function formatRate(rate: number) {
|
||||
return `${Math.round(rate * 100)}%`
|
||||
}
|
||||
|
||||
function cycleLabel(cycle: string) {
|
||||
/** 配置未加载时用占位符,避免展示与后台配置不符的假比例。 */
|
||||
function formatRateOrPlaceholder(rate: number | undefined | null) {
|
||||
return rate === undefined || rate === null ? '-' : formatRate(rate)
|
||||
}
|
||||
|
||||
/** 返利阶段文案:比例取该笔流水实际生效的 rate_applied,不能写死 60/30/15。 */
|
||||
function cycleLabel(cycle: string, rate: number) {
|
||||
const percent = formatRate(rate)
|
||||
switch (cycle) {
|
||||
case 'first_month':
|
||||
return '首月高额 (60%)'
|
||||
return `首月奖励 ${percent}`
|
||||
case 'second_month':
|
||||
return '次月奖励 (30%)'
|
||||
return `次月奖励 ${percent}`
|
||||
case 'subsequent':
|
||||
return `长期奖励 ${percent}`
|
||||
default:
|
||||
return '长期分成 (15%)'
|
||||
return `返利 ${percent}`
|
||||
}
|
||||
}
|
||||
|
||||
/** 顶部横幅文案按后台实时配置比例生成。 */
|
||||
const heroSubtitle = computed(() => {
|
||||
const current = summary.value
|
||||
if (!current) return '买卖双边分成按实名生效周期阶梯计算 · 多劳多得零门槛'
|
||||
return `买卖双边分成:首月单边 ${formatRate(current.first_month_rate)},次月 ${formatRate(
|
||||
current.second_month_rate
|
||||
)},长期 ${formatRate(current.subsequent_rate)} · 多劳多得零门槛`
|
||||
})
|
||||
|
||||
/** 好友空态文案按首月配置比例生成。 */
|
||||
const emptyFriendsTip = computed(() => {
|
||||
const rate = summary.value?.first_month_rate
|
||||
if (rate === undefined || rate === null) {
|
||||
return '您尚未邀请好友,快去分享专属链接赚取返利吧!'
|
||||
}
|
||||
return `您尚未邀请好友,快去分享专属链接赚取首月 ${formatRate(rate)} 返利吧!`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -166,7 +193,7 @@ function cycleLabel(cycle: string) {
|
||||
</div>
|
||||
<h1 class="hero-title">邀请好友赚丰厚返利</h1>
|
||||
<p class="hero-subtitle">
|
||||
买卖双边分成:首月单边高达 60%,次月 30%,永久 15% · 多劳多得零门槛
|
||||
{{ heroSubtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -279,21 +306,21 @@ function cycleLabel(cycle: string) {
|
||||
<div class="ladder-grid">
|
||||
<div class="ladder-item ladder-first">
|
||||
<div class="ladder-tag">阶梯一 · 首月</div>
|
||||
<div class="ladder-rate">{{ formatRate(summary?.first_month_rate || 0.6) }}</div>
|
||||
<div class="ladder-rate">{{ formatRateOrPlaceholder(summary?.first_month_rate) }}</div>
|
||||
<div class="ladder-time">实名生效 1 ~ 30 天</div>
|
||||
<div class="ladder-desc">每笔订单单边收益高比例奖励</div>
|
||||
</div>
|
||||
|
||||
<div class="ladder-item ladder-second">
|
||||
<div class="ladder-tag">阶梯二 · 次月</div>
|
||||
<div class="ladder-rate">{{ formatRate(summary?.second_month_rate || 0.3) }}</div>
|
||||
<div class="ladder-rate">{{ formatRateOrPlaceholder(summary?.second_month_rate) }}</div>
|
||||
<div class="ladder-time">实名生效 31 ~ 60 天</div>
|
||||
<div class="ladder-desc">持续稳定鼓励买卖活跃交易</div>
|
||||
</div>
|
||||
|
||||
<div class="ladder-item ladder-subsequent">
|
||||
<div class="ladder-tag">阶梯三 · 长期永久</div>
|
||||
<div class="ladder-rate">{{ formatRate(summary?.subsequent_rate || 0.15) }}</div>
|
||||
<div class="ladder-rate">{{ formatRateOrPlaceholder(summary?.subsequent_rate) }}</div>
|
||||
<div class="ladder-time">实名生效 60 天后永久</div>
|
||||
<div class="ladder-desc">终身绑定,笔笔成交享分成收益</div>
|
||||
</div>
|
||||
@@ -339,7 +366,7 @@ function cycleLabel(cycle: string) {
|
||||
<div v-if="loadingFriends" class="loading-state">加载好友列表中...</div>
|
||||
<div v-else-if="friends.length === 0" class="empty-state">
|
||||
<el-icon :size="40" color="#cbd5e1"><User /></el-icon>
|
||||
<p>您尚未邀请好友,快去分享专属链接赚取首月60%返利吧!</p>
|
||||
<p>{{ emptyFriendsTip }}</p>
|
||||
</div>
|
||||
<div v-else class="table-responsive">
|
||||
<table class="data-table">
|
||||
@@ -414,7 +441,7 @@ function cycleLabel(cycle: string) {
|
||||
</td>
|
||||
<td>¥{{ formatCent(r.single_side_basis_cent) }}</td>
|
||||
<td>
|
||||
<span class="cycle-pill">{{ cycleLabel(r.cycle_stage) }}</span>
|
||||
<span class="cycle-pill">{{ cycleLabel(r.cycle_stage, r.rate_applied) }}</span>
|
||||
</td>
|
||||
<td class="rebate-amount">+¥{{ formatCent(r.rebate_amount_cent) }}</td>
|
||||
<td>{{ r.settled_at ? r.settled_at.slice(0, 16) : '-' }}</td>
|
||||
|
||||
Reference in New Issue
Block a user