feat(distribution): 补齐退款加价回退、本站自卖与开通默认规则

退款时压缩卖方加价,人工/自动批次仅完结单可入账,号主收入进主站钱包;仲裁后重算释放并支持作废未入账 DS。开通时四种模式按默认生效,对外口径合并为 35/40/25,不再单独展示通道费。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-10 18:13:46 +08:00
co-authored by Cursor
parent e621722218
commit 768902f2f7
28 changed files with 627 additions and 263 deletions
@@ -47,8 +47,11 @@ var (
ErrEmptyListingIDs = errors.New("listing ids required")
ErrInvalidListingStatus = errors.New("invalid listing status")
ErrInvalidSettlementHold = errors.New("settlement hold days must be between 0 and 365")
ErrBatchNotCancellable = errors.New("distribution settlement batch cannot be cancelled")
)
var settlementReadyOrderStatuses = []string{"completed", "closed"}
const maxSyncListingIDs = 200
func liveMoneyMovementEnabled() bool {
@@ -390,6 +393,10 @@ func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateR
ServiceFeeValue: req.ServiceFeeValue, ServiceFeeUnit: strings.TrimSpace(req.ServiceFeeUnit),
}
normalizeRulePercents(&parsed)
parsed.Mode = strings.TrimSpace(req.Mode)
if err := ValidateRule(parsed); err != nil {
return nil, err
}
row := model.DistributionRuleVersion{
SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft",
PlatformReceiveRatio: parsed.PlatformReceiveRatio, PlatformSellRatio: parsed.PlatformSellRatio,
@@ -979,14 +986,17 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64,
return err
}
// 只收本站 site party。平台份不进批次,三方订单按 party 分别结算。
// 仅纳入已完结订单,避免租赁中订单被提前打入人工批次。
var parties []model.DistributionOrderParty
if err := tx.Where("party_type = ? AND party_id = ?", PartyTypeSite, req.SiteID).
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id)").
Where("order_id IN (?)",
tx.Model(&model.DistributionOrderFinancial{}).
Select("order_id").
Where("settlement_status = 'pending'").
Where("created_at >= ? AND created_at < ?", req.PeriodStart, req.PeriodEnd),
tx.Table("distribution_order_financials f").
Select("f.order_id").
Joins("JOIN rental_orders ro ON ro.id = f.order_id").
Where("f.settlement_status = 'pending'").
Where("ro.status IN ?", settlementReadyOrderStatuses).
Where("f.created_at >= ? AND f.created_at < ?", req.PeriodStart, req.PeriodEnd),
).
Order("id ASC").
Find(&parties).Error; err != nil {
@@ -1028,6 +1038,9 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64
if row.Status == "settled" {
return ErrBatchAlreadySettled
}
if row.Status == "cancelled" {
return ErrBatchNotCancellable
}
var items []model.DistributionSettlementItem
if err := tx.Where("batch_id = ?", row.ID).Find(&items).Error; err != nil {
return err
@@ -1073,6 +1086,37 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func (r *Repository) CancelSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
var row model.DistributionSettlementBatch
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrSettlementBatchNotFound
}
return err
}
if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.SiteID); err != nil {
return err
}
if row.Status == "settled" {
return ErrBatchAlreadySettled
}
if row.Status == "cancelled" {
return ErrBatchNotCancellable
}
if err := tx.Where("batch_id = ?", row.ID).Delete(&model.DistributionSettlementItem{}).Error; err != nil {
return err
}
row.Status = "cancelled"
row.TotalAmountCent = 0
return tx.Save(&row).Error
})
if err != nil {
return nil, err
}
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func uniqueSettlementOrderIDs(items []model.DistributionSettlementItem) []uint64 {
seen := make(map[uint64]struct{}, len(items))
ids := make([]uint64, 0, len(items))
@@ -1121,13 +1165,13 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, order
}
return nil, err
}
var orderExists int64
if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Count(&orderExists).Error; err != nil {
var order model.RentalOrder
if err := r.db.WithContext(ctx).First(&order, orderID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrOrderNotFound
}
return nil, err
}
if orderExists == 0 {
return nil, ErrOrderNotFound
}
// 手工补数只允许创建,不允许覆盖支付事务写入的真实快照。
var existing int64
if err := r.db.WithContext(ctx).Model(&model.DistributionOrderFinancial{}).
@@ -1142,7 +1186,13 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, order
partyInputs = append(partyInputs, PartyProfitInput{PartyRole: party.PartyRole, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent})
}
parsed := ruleFromVersion(rule)
result, err := CalculateSettlement(parsed, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
result, err := CalculateSettlement(parsed, SettlementInput{
GrossPaidAmountCent: req.GrossPaidAmountCent,
RefundAmountCent: req.RefundAmountCent,
OwnerRentAmountCent: order.OwnerRentAmountCent,
MarkupAmountCent: req.MarkupAmountCent,
GrossProfitByParty: partyInputs,
})
if err != nil {
return nil, err
}
@@ -1423,29 +1473,9 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
return err
}
// 一键开通只落草稿。字段存百分数;口头 3.5/4/2.5 写入时换成 35/40/25。
parsed := Rule{
DistributorReceiveRatio: req.DistributorReceiveRatio,
PlatformSellRatio: req.PlatformSellRatio,
ServiceFeeValue: req.ServiceFeeValue,
ServiceFeeUnit: ServiceFeeUnitPercent,
}
normalizeRulePercents(&parsed)
rule := model.DistributionRuleVersion{
SiteID: &site.ID,
Mode: ModeTwoPartyDistributorReceive,
Version: 1,
Status: "draft",
DistributorReceiveRatio: parsed.DistributorReceiveRatio,
PlatformSellRatio: parsed.PlatformSellRatio,
ServiceFeeValue: parsed.ServiceFeeValue,
ServiceFeeUnit: parsed.ServiceFeeUnit,
PaymentFeeBPS: 50,
TaxEnabled: taxEnabled,
TaxRateBPS: taxRate,
EffectiveFrom: time.Now(),
}
if err := tx.Create(&rule).Error; err != nil {
now := time.Now()
receiveRule, err := createOnboardDefaultRules(tx, site.ID, taxEnabled, taxRate, now)
if err != nil {
return err
}
if err := wallet.EnsureAccountTx(tx, 0, site.ID); err != nil {
@@ -1454,7 +1484,7 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
resp = OnboardSiteResponse{
Site: siteDTO(site),
Rule: ruleDTO(rule),
Rule: ruleDTO(receiveRule),
Member: DistributionCurrentMemberDTO{
AdminID: adminUser.ID,
Username: adminUser.Username,
@@ -1475,6 +1505,43 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
return &resp, nil
}
// createOnboardDefaultRules 开通时四种模式都按默认分成直接生效,之后在规则页改即可。
func createOnboardDefaultRules(tx *gorm.DB, siteID uint64, taxEnabled bool, taxRate int64, effectiveFrom time.Time) (model.DistributionRuleVersion, error) {
var receive model.DistributionRuleVersion
modes := []string{ModeTwoPartyDistributorReceive, ModeTwoPartyPlatformReceive, ModeThreeParty, ModeSiteSelfSell}
for _, mode := range modes {
def, err := DefaultRule(mode)
if err != nil {
return model.DistributionRuleVersion{}, err
}
row := model.DistributionRuleVersion{
SiteID: &siteID,
Mode: mode,
Version: 1,
Status: "active",
PlatformReceiveRatio: def.PlatformReceiveRatio,
PlatformSellRatio: def.PlatformSellRatio,
DistributorReceiveRatio: def.DistributorReceiveRatio,
DistributorSellRatio: def.DistributorSellRatio,
DistributorARatio: def.DistributorARatio,
DistributorBRatio: def.DistributorBRatio,
ServiceFeeValue: def.ServiceFeeValue,
ServiceFeeUnit: def.ServiceFeeUnit,
PaymentFeeBPS: def.PaymentFeeBPS,
TaxEnabled: taxEnabled,
TaxRateBPS: taxRate,
EffectiveFrom: effectiveFrom,
}
if err := tx.Create(&row).Error; err != nil {
return model.DistributionRuleVersion{}, err
}
if mode == ModeTwoPartyDistributorReceive {
receive = row
}
}
return receive, nil
}
func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string, page, pageSize int) (*PublicDistributionListingsResult, error) {
if r.db == nil {
return nil, ErrDependencyUnavailable
@@ -1940,6 +2007,13 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
return nil, err
}
var site model.DistributionSite
if err := r.db.WithContext(ctx).Select("id, settlement_hold_days").First(&site, siteID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
var account model.WalletAccount
err := r.db.WithContext(ctx).Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&account).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -1959,7 +2033,7 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
Joins("JOIN rental_orders ro ON ro.id = p.order_id").
Where("p.party_type = ? AND p.party_id = ?", PartyTypeSite, siteID).
Where("f.settlement_status = 'pending' AND f.release_at IS NOT NULL").
Where("ro.status = 'completed'").
Where("ro.status IN ?", settlementReadyOrderStatuses).
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)").
@@ -1977,6 +2051,7 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
PendingReleaseCent: pendingRelease,
TotalSettledCent: totalSettled,
TotalWithdrawnCent: totalWithdrawn,
SettlementHoldDays: site.SettlementHoldDays,
}, nil
}