229 lines
7.1 KiB
Go
229 lines
7.1 KiB
Go
// rebate-backfill-rental 为「仲裁按实际消耗结算」的租赁订单补发邀请返利。
|
||
//
|
||
// 背景:仲裁结案历史路径未触发邀请返利;且订单保留原预收金额快照,
|
||
// 不能直接用 order.rent_amount_cent - owner_rent_amount_cent 计算。
|
||
// 本脚本按仲裁后的实际平台毛利(优先取已结算 checkout 的平台费,
|
||
// 否则取 actual_pure_coin_amount_cent - 仲裁入账给号主的金额)补发。
|
||
//
|
||
// 幂等:复用 invitation_rebate_ledgers 的 (source, order_id, trade_role) 唯一键。
|
||
//
|
||
// 用法(默认预演):
|
||
//
|
||
// go run ./cmd/rebate-backfill-rental -order-no RO20261017002241506,RO20261012185753383
|
||
// go run ./cmd/rebate-backfill-rental -apply -order-no RO20261017002241506,RO20261012185753383
|
||
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/pkg/money"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type candidate struct {
|
||
Order model.RentalOrder
|
||
ActualProfitCent int64
|
||
ActualProfitSource string
|
||
}
|
||
|
||
func main() {
|
||
apply := flag.Bool("apply", false, "实际执行补发(默认仅预演)")
|
||
orderNos := flag.String("order-no", "", "需要补发的租赁订单号,多个用逗号分隔")
|
||
flag.Parse()
|
||
|
||
orderNoList := splitOrderNos(*orderNos)
|
||
if len(orderNoList) == 0 {
|
||
log.Fatal("必须通过 -order-no 指定订单号,避免误补全站历史订单")
|
||
}
|
||
|
||
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, orderNoList)
|
||
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 int
|
||
var paidCent int64
|
||
for _, row := range rows {
|
||
rateAt := time.Now()
|
||
if row.Order.SettledAt != nil {
|
||
rateAt = *row.Order.SettledAt
|
||
}
|
||
fmt.Printf("[%s] %s 号主=%d 原毛利=%s 实际平台毛利=%s(%s)计提时刻=%s\n",
|
||
mode, row.Order.OrderNo, row.Order.OwnerID,
|
||
money.FormatWithSymbol(row.Order.RentAmountCent-row.Order.OwnerRentAmountCent),
|
||
money.FormatWithSymbol(row.ActualProfitCent), row.ActualProfitSource,
|
||
rateAt.Format("2006-01-02 15:04:05"))
|
||
|
||
if !*apply {
|
||
continue
|
||
}
|
||
amountCent, err := backfillOne(db, row, rateAt)
|
||
if err != nil {
|
||
log.Printf("补发失败 %s: %v", row.Order.OrderNo, err)
|
||
continue
|
||
}
|
||
if amountCent <= 0 {
|
||
fmt.Printf("[跳过] %s 未产生返利(无生效邀请 / 邀请人未实名 / 返利已关闭 / 已存在记录)\n", row.Order.OrderNo)
|
||
continue
|
||
}
|
||
paidCount++
|
||
paidCent += amountCent
|
||
fmt.Printf("[补发] %s 返利 %s 已入邀请人钱包\n", row.Order.OrderNo, money.FormatWithSymbol(amountCent))
|
||
}
|
||
|
||
if *apply {
|
||
fmt.Printf("完成:补发 %d 单,合计 %s。\n", paidCount, money.FormatWithSymbol(paidCent))
|
||
}
|
||
}
|
||
|
||
func splitOrderNos(raw string) []string {
|
||
parts := strings.Split(raw, ",")
|
||
result := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
if value := strings.TrimSpace(part); value != "" {
|
||
result = append(result, value)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func loadCandidates(db *gorm.DB, orderNos []string) ([]candidate, error) {
|
||
var orders []model.RentalOrder
|
||
if err := db.Where("order_no IN ?", orderNos).Order("id ASC").Find(&orders).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
result := make([]candidate, 0, len(orders))
|
||
for _, order := range orders {
|
||
if order.Status != "completed" || order.SettlementStatus != "arbitrated" {
|
||
continue
|
||
}
|
||
var rebateCount int64
|
||
if err := db.Model(&model.InvitationRebateLedger{}).
|
||
Where("source = ? AND order_id = ? AND trade_role = 'seller'", invitation.RebateSourceRentalOrder, order.ID).
|
||
Count(&rebateCount).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if rebateCount > 0 {
|
||
continue
|
||
}
|
||
|
||
profitCent, source, err := actualProfitCent(db, order)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if profitCent <= 0 {
|
||
continue
|
||
}
|
||
result = append(result, candidate{
|
||
Order: order,
|
||
ActualProfitCent: profitCent,
|
||
ActualProfitSource: source,
|
||
})
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// actualProfitCent 返回仲裁后的实际平台毛利。
|
||
// 已结算 checkout 的平台费最准确;没有 checkout 时用实际纯币收入减去仲裁入账号主的金额。
|
||
func actualProfitCent(db *gorm.DB, order model.RentalOrder) (int64, string, error) {
|
||
var checkout model.OrderCheckout
|
||
checkoutErr := db.Where("order_id = ? AND status = ?", order.ID, "settled").
|
||
Order("id DESC").First(&checkout).Error
|
||
if checkoutErr == nil && checkout.PlatformFeeCent > 0 {
|
||
return checkout.PlatformFeeCent, "settled_checkout.platform_fee_cent", nil
|
||
}
|
||
if checkoutErr != nil && checkoutErr != gorm.ErrRecordNotFound {
|
||
return 0, "", checkoutErr
|
||
}
|
||
|
||
var ownerIncomeCent int64
|
||
if err := db.Model(&model.WalletLedger{}).
|
||
Where("order_id = ? AND biz_type = ? AND direction = 'in' AND balance_type = 'available'", order.ID, "arbitration_owner_income").
|
||
Select("COALESCE(SUM(amount_cent), 0)").Scan(&ownerIncomeCent).Error; err != nil {
|
||
return 0, "", err
|
||
}
|
||
if order.ActualPureCoinAmountCent <= 0 || ownerIncomeCent <= 0 {
|
||
return 0, "", nil
|
||
}
|
||
return order.ActualPureCoinAmountCent - ownerIncomeCent, "actual_pure_coin - arbitration_owner_income", nil
|
||
}
|
||
|
||
func backfillOne(db *gorm.DB, row candidate, rateAt time.Time) (int64, error) {
|
||
var amountCent int64
|
||
err := db.Transaction(func(tx *gorm.DB) error {
|
||
// 事务内重新读取,避免预演后订单状态或金额发生变化。
|
||
var order model.RentalOrder
|
||
if err := tx.First(&order, row.Order.ID).Error; err != nil {
|
||
return err
|
||
}
|
||
if order.Status != "completed" || order.SettlementStatus != "arbitrated" {
|
||
return nil
|
||
}
|
||
|
||
profitCent, _, err := actualProfitCent(tx, order)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if profitCent <= 0 {
|
||
return nil
|
||
}
|
||
|
||
options := invitation.OrderRebateOptions{
|
||
GrossProfitCent: &profitCent,
|
||
RateReferenceAt: rateAt,
|
||
SettledAt: time.Now(),
|
||
}
|
||
before := int64(0)
|
||
_ = tx.Model(&model.InvitationRebateLedger{}).
|
||
Where("source = ? AND order_id = ? AND trade_role = 'seller'", invitation.RebateSourceRentalOrder, order.ID).
|
||
Select("COALESCE(SUM(rebate_amount_cent), 0)").Scan(&before).Error
|
||
if err := invitation.ProcessOrderInvitationRebateWithOptionsTx(tx, order.ID, options); err != nil {
|
||
return err
|
||
}
|
||
var after int64
|
||
if err := tx.Model(&model.InvitationRebateLedger{}).
|
||
Where("source = ? AND order_id = ? AND trade_role = 'seller'", invitation.RebateSourceRentalOrder, order.ID).
|
||
Select("COALESCE(SUM(rebate_amount_cent), 0)").Scan(&after).Error; err != nil {
|
||
return err
|
||
}
|
||
amountCent = after - before
|
||
return nil
|
||
})
|
||
return amountCent, err
|
||
}
|