feat(distribution): 补齐退款加价回退、本站自卖与开通默认规则
退款时压缩卖方加价,人工/自动批次仅完结单可入账,号主收入进主站钱包;仲裁后重算释放并支持作废未入账 DS。开通时四种模式按默认生效,对外口径合并为 35/40/25,不再单独展示通道费。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user