feat(distribution): 补齐退款加价回退、本站自卖与开通默认规则
退款时压缩卖方加价,人工/自动批次仅完结单可入账,号主收入进主站钱包;仲裁后重算释放并支持作废未入账 DS。开通时四种模式按默认生效,对外口径合并为 35/40/25,不再单独展示通道费。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -131,8 +131,8 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.Status == "completed" {
|
||||
if err := distribution.EnsureCompletedReleaseAtTx(tx, order.ID); err != nil {
|
||||
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
||||
if err := distribution.ApplyRefundTx(tx, order.ID, settlement.RenterRefundAmountCent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -373,6 +373,7 @@ func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.
|
||||
checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent
|
||||
checkout.ShortfallCent = settlement.ShortfallCent
|
||||
checkout.OvershootAmountCent = settlement.OvershootAmountCent
|
||||
checkout.Status = "settled"
|
||||
}
|
||||
|
||||
func isPlatformManagedOrder(order model.RentalOrder) bool {
|
||||
|
||||
@@ -19,16 +19,13 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) {
|
||||
|
||||
// 1. Onboard new site
|
||||
req := OnboardSiteRequest{
|
||||
SiteCode: "gz_dist",
|
||||
SiteName: "广州分销站",
|
||||
Domain: "gz.distribution.com",
|
||||
AdminUsername: "gz_operator",
|
||||
AdminPassword: "Password123!",
|
||||
AdminNickname: "广州运营小李",
|
||||
MemberType: "operator",
|
||||
DistributorReceiveRatio: 3.8,
|
||||
PlatformSellRatio: 4.2,
|
||||
ServiceFeeValue: 2.0,
|
||||
SiteCode: "gz_dist",
|
||||
SiteName: "广州分销站",
|
||||
Domain: "gz.distribution.com",
|
||||
AdminUsername: "gz_operator",
|
||||
AdminPassword: "Password123!",
|
||||
AdminNickname: "广州运营小李",
|
||||
MemberType: "operator",
|
||||
}
|
||||
|
||||
resp, err := svc.OnboardSite(ctx, req)
|
||||
@@ -48,11 +45,11 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) {
|
||||
if resp.Member.SiteID == nil || *resp.Member.SiteID != resp.Site.ID {
|
||||
t.Fatalf("expected SiteID=%d, got %v", resp.Site.ID, resp.Member.SiteID)
|
||||
}
|
||||
if resp.Rule.DistributorReceiveRatio != 38 {
|
||||
t.Fatalf("expected rule percent 38, got %f", resp.Rule.DistributorReceiveRatio)
|
||||
if resp.Rule.DistributorReceiveRatio != 35 || resp.Rule.PlatformSellRatio != 40 || resp.Rule.ServiceFeeValue != 25 {
|
||||
t.Fatalf("expected default 35/40/25, got %+v", resp.Rule)
|
||||
}
|
||||
if resp.Rule.Status != "draft" {
|
||||
t.Fatalf("onboard rule must stay draft until split is verified, got %s", resp.Rule.Status)
|
||||
if resp.Rule.Status != "active" {
|
||||
t.Fatalf("onboard rules should be active by default, got %s", resp.Rule.Status)
|
||||
}
|
||||
|
||||
// 2. Duplicate site code or domain check
|
||||
|
||||
@@ -333,7 +333,7 @@ type OnboardSiteRequest struct {
|
||||
AdminPassword string `json:"admin_password" binding:"required,min=6,max=64"`
|
||||
AdminNickname string `json:"admin_nickname"`
|
||||
MemberType string `json:"member_type" binding:"omitempty,oneof=operator finance customer_service"`
|
||||
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
|
||||
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"` // 兼容旧入参,开通时忽略,四种模式一律走默认分成
|
||||
PlatformSellRatio float64 `json:"platform_sell_ratio"`
|
||||
ServiceFeeValue float64 `json:"service_fee_value"`
|
||||
}
|
||||
@@ -417,6 +417,7 @@ type SiteWalletDTO struct {
|
||||
PendingReleaseCent int64 `json:"pending_release_cent"`
|
||||
TotalSettledCent int64 `json:"total_settled_cent"`
|
||||
TotalWithdrawnCent int64 `json:"total_withdrawn_cent"`
|
||||
SettlementHoldDays int `json:"settlement_hold_days"`
|
||||
}
|
||||
|
||||
type ApplySiteWithdrawalRequest struct {
|
||||
|
||||
@@ -440,6 +440,20 @@ func (h *Handler) MarkSettlementBatch(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) CancelSettlementBatch(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
adminID, _ := currentAdminID(c)
|
||||
item, err := h.service.CancelSettlementBatch(c.Request.Context(), adminID, id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
// parseQueryUint 返回 -1 表示格式错误,0 表示未传入。
|
||||
func parseQueryUint(c *gin.Context, key string) int64 {
|
||||
raw := strings.TrimSpace(c.Query(key))
|
||||
@@ -761,6 +775,10 @@ func writeError(c *gin.Context, err error) {
|
||||
response.Error(c, http.StatusForbidden, "admin_disabled", "分销管理员账号已被禁用")
|
||||
case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled), errors.Is(err, ErrSettlementBatchEmpty), errors.Is(err, ErrSettlementTotalMismatch):
|
||||
response.BadRequest(c, "分销规则参数不正确")
|
||||
case errors.Is(err, ErrInvalidSettlementHold):
|
||||
response.BadRequest(c, "结算冷却天数须在 0 到 365 之间")
|
||||
case errors.Is(err, ErrBatchNotCancellable):
|
||||
response.BadRequest(c, "该结算批次无法作废")
|
||||
case errors.Is(err, ErrTooManySyncListings):
|
||||
response.BadRequest(c, "一次勾选同步的商品过多")
|
||||
case errors.Is(err, ErrSyncListingNotFound):
|
||||
|
||||
@@ -206,8 +206,8 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, markup)
|
||||
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossRent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, markup, splits), SettlementStatus: "pending"}
|
||||
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, result.MarkupAmountCent)
|
||||
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossRent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, result.MarkupAmountCent, splits), SettlementStatus: "pending"}
|
||||
// 支付回调可能重复触发:保留已写入的释放与结算时间,避免把已释放的快照重置回待释放。
|
||||
var existing model.DistributionOrderFinancial
|
||||
if err := tx.Where("order_id = ?", order.ID).First(&existing).Error; err == nil {
|
||||
@@ -336,7 +336,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, financial.MarkupAmountCent)
|
||||
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, result.MarkupAmountCent)
|
||||
// 已释放入账的订单保持 settled,避免自动释放任务重复入账;差额通过回冲流水追偿。
|
||||
settlementStatus := "pending"
|
||||
if financial.ReleasedAt != nil {
|
||||
@@ -352,7 +352,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
||||
"payment_fee_cent": result.PaymentFeeCent,
|
||||
"service_fee_cent": result.ServiceFeeCent,
|
||||
"split_base_amount_cent": result.SplitBaseAmountCent,
|
||||
"rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, financial.MarkupAmountCent, splits),
|
||||
"rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, result.MarkupAmountCent, splits),
|
||||
"settlement_status": settlementStatus,
|
||||
"release_at": releaseAt,
|
||||
}).Error; err != nil {
|
||||
@@ -433,7 +433,7 @@ func computeReleaseAt(tx *gorm.DB, order model.RentalOrder, financial model.Dist
|
||||
if financial.ReleasedAt != nil {
|
||||
return financial.ReleaseAt, nil
|
||||
}
|
||||
if order.Status != "completed" {
|
||||
if !orderStatusAllowsRelease(order.Status) {
|
||||
return financial.ReleaseAt, nil
|
||||
}
|
||||
completedAt := order.SettledAt
|
||||
@@ -464,7 +464,7 @@ func rewritePendingReleaseAt(tx *gorm.DB, siteID uint64) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if order.Status != "completed" {
|
||||
if !orderStatusAllowsRelease(order.Status) {
|
||||
continue
|
||||
}
|
||||
releaseAt, err := computeReleaseAt(tx, order, financial)
|
||||
@@ -479,6 +479,10 @@ func rewritePendingReleaseAt(tx *gorm.DB, siteID uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func orderStatusAllowsRelease(status string) bool {
|
||||
return status == "completed" || status == "closed"
|
||||
}
|
||||
|
||||
// partySettled 判断参与方份额是否已经通过结算批次入账。
|
||||
func partySettled(tx *gorm.DB, partyID uint64) (bool, error) {
|
||||
var count int64
|
||||
|
||||
@@ -472,3 +472,59 @@ func TestApplyRefundTxWithCheckoutAndDeposit(t *testing.T) {
|
||||
t.Fatalf("distributorParty.IncomeAmountCent = %d, want 2513", distributorParty.IncomeAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRefundTxClampsSellerMarkup(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
now := time.Now()
|
||||
site := model.DistributionSite{SiteCode: "markup_refund", SiteName: "加价回退站", Domain: "markup-refund.dist.com", Status: "active"}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := model.DistributionRuleVersion{
|
||||
SiteID: &site.ID, Mode: ModeTwoPartyPlatformReceive, Version: 1, Status: "active",
|
||||
PlatformReceiveRatio: 35, DistributorSellRatio: 40,
|
||||
ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-MARKUP-REFUND", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed",
|
||||
RentAmountCent: 10000, OwnerRentAmountCent: 6000, ShelfMarkupCent: 3000,
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, DistributionMode: ModeTwoPartyPlatformReceive,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var seller model.DistributionOrderParty
|
||||
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seller.IncomeAmountCent != 3400 {
|
||||
t.Fatalf("paid seller = %d, want 400+3000", seller.IncomeAmountCent)
|
||||
}
|
||||
|
||||
if err := ApplyRefundTx(db, order.ID, 2000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seller.IncomeAmountCent != 2000 {
|
||||
t.Fatalf("partial refund seller markup = %d, want 2000", seller.IncomeAmountCent)
|
||||
}
|
||||
|
||||
if err := ApplyRefundTx(db, order.ID, 10000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&seller).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seller.IncomeAmountCent != 0 {
|
||||
t.Fatalf("full refund must return seller markup, got %d", seller.IncomeAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,46 @@ func DefaultRule(mode string) (Rule, error) {
|
||||
base.DistributorARatio = DefaultReceivePercent
|
||||
base.DistributorBRatio = DefaultSellPercent
|
||||
case ModeSiteSelfSell:
|
||||
return Rule{}, ErrUnsupportedMode
|
||||
base.DistributorSellRatio = DefaultReceivePercent + DefaultSellPercent
|
||||
default:
|
||||
return Rule{}, ErrUnsupportedMode
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// ValidateRule 校验模式,并在百分比服务费下要求收号+卖出+服务费合计为 100。
|
||||
func ValidateRule(rule Rule) error {
|
||||
normalizeRulePercents(&rule)
|
||||
if _, err := DefaultRule(rule.Mode); err != nil {
|
||||
return ErrUnsupportedMode
|
||||
}
|
||||
unit := rule.ServiceFeeUnit
|
||||
if unit == "" || unit == ServiceFeeUnitRatio {
|
||||
unit = ServiceFeeUnitPercent
|
||||
}
|
||||
if unit != ServiceFeeUnitPercent && unit != ServiceFeeUnitFixedAmount {
|
||||
return ErrInvalidServiceUnit
|
||||
}
|
||||
if unit != ServiceFeeUnitPercent {
|
||||
return nil
|
||||
}
|
||||
var sum float64
|
||||
switch rule.Mode {
|
||||
case ModeTwoPartyDistributorReceive:
|
||||
sum = rule.DistributorReceiveRatio + rule.PlatformSellRatio + rule.ServiceFeeValue
|
||||
case ModeTwoPartyPlatformReceive:
|
||||
sum = rule.PlatformReceiveRatio + rule.DistributorSellRatio + rule.ServiceFeeValue
|
||||
case ModeThreeParty:
|
||||
sum = rule.DistributorARatio + rule.DistributorBRatio + rule.ServiceFeeValue
|
||||
case ModeSiteSelfSell:
|
||||
sum = rule.DistributorSellRatio + rule.ServiceFeeValue
|
||||
}
|
||||
if math.Abs(sum-100) > 0.15 {
|
||||
return ErrInvalidRate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeSplitPercent 把口头 3.5/4/2.5 换成 35/40/25;已经是百分数的原样保留。
|
||||
func NormalizeSplitPercent(value float64) float64 {
|
||||
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
@@ -147,7 +180,8 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
|
||||
}
|
||||
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
|
||||
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
|
||||
profit := netPaid - input.OwnerRentAmountCent - input.MarkupAmountCent
|
||||
effectiveMarkup := EffectiveMarkupCent(netPaid, input.OwnerRentAmountCent, input.MarkupAmountCent)
|
||||
profit := netPaid - input.OwnerRentAmountCent - effectiveMarkup
|
||||
if profit < 0 {
|
||||
profit = 0
|
||||
}
|
||||
@@ -164,7 +198,7 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
|
||||
NetPaidAmountCent: netPaid,
|
||||
PaymentFeeCent: paymentFee,
|
||||
ServiceFeeCent: serviceFee,
|
||||
MarkupAmountCent: input.MarkupAmountCent,
|
||||
MarkupAmountCent: effectiveMarkup,
|
||||
SplitBaseAmountCent: profit,
|
||||
}
|
||||
if rule.TaxEnabled {
|
||||
@@ -206,6 +240,25 @@ func maxInt64(a, b int64) int64 {
|
||||
return b
|
||||
}
|
||||
|
||||
func minInt64(a, b int64) int64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// EffectiveMarkupCent 按剩余净付款压缩加价:全额退租金后卖方不得再留加价。
|
||||
func EffectiveMarkupCent(netPaid, ownerRent, markup int64) int64 {
|
||||
if markup <= 0 {
|
||||
return 0
|
||||
}
|
||||
remain := netPaid - ownerRent
|
||||
if remain <= 0 {
|
||||
return 0
|
||||
}
|
||||
return minInt64(markup, remain)
|
||||
}
|
||||
|
||||
// PartySplit 是一次分成拆分中单个参与方的金额。
|
||||
type PartySplit struct {
|
||||
PartyRole string
|
||||
@@ -236,6 +289,11 @@ func SplitSettlement(rule Rule, profitCent int64, markupCent int64) []PartySplit
|
||||
role string
|
||||
ratio float64
|
||||
}{{"distributor_a", rule.DistributorARatio}, {"distributor_b", rule.DistributorBRatio}}
|
||||
case ModeSiteSelfSell:
|
||||
roles = []struct {
|
||||
role string
|
||||
ratio float64
|
||||
}{{"distributor_sell", rule.DistributorSellRatio}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ func TestDefaultRules(t *testing.T) {
|
||||
{ModeTwoPartyDistributorReceive, func(r Rule) bool { return r.DistributorReceiveRatio == 35 && r.PlatformSellRatio == 40 }},
|
||||
{ModeTwoPartyPlatformReceive, func(r Rule) bool { return r.PlatformReceiveRatio == 35 && r.DistributorSellRatio == 40 }},
|
||||
{ModeThreeParty, func(r Rule) bool { return r.DistributorARatio == 35 && r.DistributorBRatio == 40 }},
|
||||
{ModeSiteSelfSell, func(r Rule) bool { return r.DistributorSellRatio == 75 && r.ServiceFeeValue == 25 }},
|
||||
}
|
||||
for _, item := range cases {
|
||||
rule, err := DefaultRule(item.mode)
|
||||
@@ -17,8 +18,8 @@ func TestDefaultRules(t *testing.T) {
|
||||
t.Fatalf("default rule %s = %#v, err=%v", item.mode, rule, err)
|
||||
}
|
||||
}
|
||||
if _, err := DefaultRule(ModeSiteSelfSell); err != ErrUnsupportedMode {
|
||||
t.Fatalf("site self-sell must not have a default split, err=%v", err)
|
||||
if _, err := DefaultRule("unknown_mode"); err != ErrUnsupportedMode {
|
||||
t.Fatalf("unknown mode must be unsupported, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,3 +117,54 @@ func TestSplitSettlementThreeParty(t *testing.T) {
|
||||
t.Fatalf("splits = %#v", splits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSettlementClampsMarkupToRemainingNetPaid(t *testing.T) {
|
||||
rule := Rule{ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50}
|
||||
got, err := CalculateSettlement(rule, SettlementInput{
|
||||
GrossPaidAmountCent: 10000, RefundAmountCent: 10000, OwnerRentAmountCent: 6000, MarkupAmountCent: 3000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.MarkupAmountCent != 0 || got.SplitBaseAmountCent != 0 || got.NetPaidAmountCent != 0 {
|
||||
t.Fatalf("full refund must zero markup and profit: %#v", got)
|
||||
}
|
||||
|
||||
partial, err := CalculateSettlement(rule, SettlementInput{
|
||||
GrossPaidAmountCent: 10000, RefundAmountCent: 2000, OwnerRentAmountCent: 6000, MarkupAmountCent: 3000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if partial.NetPaidAmountCent != 8000 || partial.MarkupAmountCent != 2000 || partial.SplitBaseAmountCent != 0 {
|
||||
t.Fatalf("partial refund should clamp markup to remainder: %#v", partial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSettlementSiteSelfSell(t *testing.T) {
|
||||
rule, err := DefaultRule(ModeSiteSelfSell)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
splits := SplitSettlement(rule, 10000, 2000)
|
||||
if len(splits) != 1 || splits[0].PartyRole != "distributor_sell" || splits[0].AmountCent != 9500 {
|
||||
t.Fatalf("self-sell split = %#v, want 7500+2000", splits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRuleRequiresPercentSum(t *testing.T) {
|
||||
rule, err := DefaultRule(ModeTwoPartyDistributorReceive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateRule(rule); err != nil {
|
||||
t.Fatalf("default rule should be valid: %v", err)
|
||||
}
|
||||
rule.DistributorReceiveRatio = 10
|
||||
if err := ValidateRule(rule); err != ErrInvalidRate {
|
||||
t.Fatalf("invalid percent sum err = %v", err)
|
||||
}
|
||||
if err := ValidateRule(Rule{Mode: "not_a_mode", ServiceFeeUnit: ServiceFeeUnitPercent}); err != ErrUnsupportedMode {
|
||||
t.Fatalf("unsupported mode err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (r *Repository) AutoReleaseDueSettlements(ctx context.Context) (int, error)
|
||||
Select("DISTINCT f.order_id").
|
||||
Joins("JOIN rental_orders ro ON ro.id = f.order_id").
|
||||
Where("f.settlement_status = ? AND f.release_at IS NOT NULL AND f.release_at <= ?", "pending", now).
|
||||
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).
|
||||
Order("f.order_id ASC").
|
||||
@@ -189,7 +189,7 @@ func releasableOrder(tx *gorm.DB, orderID uint64, now time.Time) (bool, error) {
|
||||
if err := tx.Select("id, status, refund_status").First(&order, orderID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if order.Status != "completed" || slices.Contains(autoReleaseBlockedRefundStatuses, order.RefundStatus) {
|
||||
if !orderStatusAllowsRelease(order.Status) || slices.Contains(autoReleaseBlockedRefundStatuses, order.RefundStatus) {
|
||||
return false, nil
|
||||
}
|
||||
var openDisputes int64
|
||||
|
||||
@@ -441,3 +441,37 @@ func TestThreePartyRefundAfterReleaseReversesReceiverWallet(t *testing.T) {
|
||||
t.Fatalf("seller wallet B = %d, want 400", walletB.AvailableBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoReleaseDueSettlementsIncludesClosedOrders(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
site := mustCreateDistSite(t, db, "closed_site", "关单释放站", "closed.dist.com")
|
||||
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
|
||||
past := now.Add(-time.Minute)
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-CLOSED-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||
Status: "closed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none",
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderFinancial{
|
||||
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
|
||||
GrossPaidAmountCent: 10000, NetPaidAmountCent: 0, SplitBaseAmountCent: 0,
|
||||
SettlementStatus: "pending", ReleaseAt: &past,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderParty{
|
||||
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive",
|
||||
PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 700,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
released, err := repo.AutoReleaseDueSettlements(t.Context())
|
||||
if err != nil || released != 1 {
|
||||
t.Fatalf("closed order release = %d err=%v, want 1", released, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,95 @@ func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSettlementBatchSkipsRentingOrders(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
site := model.DistributionSite{SiteCode: "ds_renting", SiteName: "租赁中批次站", Domain: "ds-renting.example.com", Status: "active"}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := model.RentalOrder{OrderNo: "DS-RENTING-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "renting", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderFinancial{
|
||||
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderParty{
|
||||
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
|
||||
SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch.TotalAmountCent != 0 {
|
||||
t.Fatalf("renting order must not enter DS batch, total=%d", batch.TotalAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelSettlementBatchUnblocksAutoRelease(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
site := model.DistributionSite{SiteCode: "ds_cancel", SiteName: "作废批次站", Domain: "ds-cancel.example.com", Status: "active"}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := model.RentalOrder{OrderNo: "DS-CANCEL-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderFinancial{
|
||||
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderParty{
|
||||
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
|
||||
SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancelled, err := repo.CancelSettlementBatch(t.Context(), 0, batch.ID)
|
||||
if err != nil || cancelled.Status != "cancelled" {
|
||||
t.Fatalf("cancel = %#v err=%v", cancelled, err)
|
||||
}
|
||||
var leftover int64
|
||||
if err := db.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", batch.ID).Count(&leftover).Error; err != nil || leftover != 0 {
|
||||
t.Fatalf("cancelled batch must drop items, leftover=%d err=%v", leftover, err)
|
||||
}
|
||||
if _, err := repo.CancelSettlementBatch(t.Context(), 0, batch.ID); err != ErrBatchNotCancellable {
|
||||
t.Fatalf("second cancel err = %v", err)
|
||||
}
|
||||
again, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
|
||||
SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.TotalAmountCent != 1400 {
|
||||
t.Fatalf("cancelled items should be eligible again, total=%d", again.TotalAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
@@ -448,8 +537,16 @@ func TestOnboardSiteDoesNotDumpCatalog(t *testing.T) {
|
||||
if err := db.Where("user_id = 0 AND distribution_site_id = ?", onboardResp.Site.ID).First(&treasury).Error; err != nil {
|
||||
t.Fatalf("onboard must create site treasury wallet, err=%v", err)
|
||||
}
|
||||
if onboardResp.Rule.Status != "draft" || onboardResp.Rule.DistributorReceiveRatio != 0 || onboardResp.Rule.PlatformSellRatio != 0 {
|
||||
t.Fatalf("onboard must not activate default 3.5/4 split, got %+v", onboardResp.Rule)
|
||||
if onboardResp.Rule.Status != "active" || onboardResp.Rule.Mode != ModeTwoPartyDistributorReceive ||
|
||||
onboardResp.Rule.DistributorReceiveRatio != 35 || onboardResp.Rule.PlatformSellRatio != 40 || onboardResp.Rule.ServiceFeeValue != 25 {
|
||||
t.Fatalf("onboard receive rule should be default active 35/40/25, got %+v", onboardResp.Rule)
|
||||
}
|
||||
var extraRules []model.DistributionRuleVersion
|
||||
if err := db.Where("site_id = ? AND status = ?", onboardResp.Site.ID, "active").Order("mode ASC").Find(&extraRules).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(extraRules) != 4 {
|
||||
t.Fatalf("onboard should activate all four default modes, got %d", len(extraRules))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,12 @@ func (s *Service) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (
|
||||
}
|
||||
return s.repo.MarkSettlementBatch(ctx, adminID, id)
|
||||
}
|
||||
func (s *Service) CancelSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.CancelSettlementBatch(ctx, adminID, id)
|
||||
}
|
||||
|
||||
func (s *Service) CreateFinancialSnapshot(ctx context.Context, adminID, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
|
||||
@@ -77,15 +77,11 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement
|
||||
return nil
|
||||
}
|
||||
orderID := order.ID
|
||||
var siteID uint64
|
||||
if order.DistributionSiteID != nil {
|
||||
siteID = *order.DistributionSiteID
|
||||
}
|
||||
var ownerEntries []wallet.Entry
|
||||
if settlement.OwnerRentIncomeCent > 0 {
|
||||
ownerEntries = append(ownerEntries, wallet.Entry{
|
||||
UserID: order.OwnerID,
|
||||
DistributionSiteID: siteID,
|
||||
DistributionSiteID: 0,
|
||||
OrderID: &orderID,
|
||||
Direction: "in",
|
||||
AmountCent: settlement.OwnerRentIncomeCent,
|
||||
@@ -98,7 +94,7 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement
|
||||
if settlement.DepositCompensationCent > 0 {
|
||||
ownerEntries = append(ownerEntries, wallet.Entry{
|
||||
UserID: order.OwnerID,
|
||||
DistributionSiteID: siteID,
|
||||
DistributionSiteID: 0,
|
||||
OrderID: &orderID,
|
||||
Direction: "in",
|
||||
AmountCent: settlement.DepositCompensationCent,
|
||||
|
||||
Reference in New Issue
Block a user