补全分销分账结算闭环并修复站点越权
- 权限落库:新增 000066 迁移,注册 11 个 distribution:* 权限并授权 super_admin/ops/finance - 越权修复:siteAccessAllowed 校验贯穿站点/规则/订单/展示/支付通道/结算全部按 ID 操作 - 分账闭环:SplitSettlement 按比例拆分分成基数,销售加价归卖出方,尾差记入 rounding_adjustment_cent 归平台;支付快照与退款重算均按拆分写入参与方金额 - 结算批次:创建时聚合周期内待结算订单生成明细并汇总金额,结算时校验明细与总额一致后联动置位财务快照 - 手工补数接口禁止覆盖已有快照并写入审计日志 - 杂项:二维码池校验站点启用状态且不吞真实 DB 错误;绑定支付通道校验商户配置存在;表探测改为 sync.Once 缓存
This commit is contained in:
@@ -306,11 +306,18 @@ func fetchUnusedQrCode(tx *gorm.DB) (*model.ChatQrCode, error) {
|
||||
|
||||
func fetchUnusedQrCodeForListing(tx *gorm.DB, listingID uint64) (*model.ChatQrCode, error) {
|
||||
var relation model.DistributionSiteAccount
|
||||
if err := tx.Where("listing_id = ? AND display_status = 'enabled'", listingID).Order("id ASC").First(&relation).Error; err == nil {
|
||||
// 仅匹配启用中的分销站点,真实 DB 错误直接返回,避免误回退到全局二维码池。
|
||||
err := tx.Table("distribution_site_accounts AS relation").
|
||||
Select("relation.*").
|
||||
Joins("JOIN distribution_sites AS site ON site.id = relation.site_id AND site.status = 'active'").
|
||||
Where("relation.listing_id = ? AND relation.display_status = 'enabled'", listingID).
|
||||
Order("relation.id ASC").
|
||||
Scan(&relation).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if relation.ID != 0 {
|
||||
return fetchUnusedQrCodeForSite(tx, &relation.SiteID)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 兼容尚未执行分销迁移的环境,继续使用全局二维码池。
|
||||
return fetchUnusedQrCodeForSite(tx, nil)
|
||||
}
|
||||
return fetchUnusedQrCodeForSite(tx, nil)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ func setupQrCodeTestDB(t *testing.T) *gorm.DB {
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.ChatQrCode{},
|
||||
&model.DistributionSite{},
|
||||
&model.DistributionSiteAccount{},
|
||||
&model.ChatConversation{},
|
||||
&model.ChatParticipant{},
|
||||
&model.ChatMessage{},
|
||||
|
||||
@@ -100,8 +100,9 @@ type DistributionOrderDTO struct {
|
||||
ServiceFeeCent int64 `json:"service_fee_cent"`
|
||||
MarkupAmountCent int64 `json:"markup_amount_cent"`
|
||||
TaxAmountCent int64 `json:"tax_amount_cent"`
|
||||
SplitBaseAmountCent int64 `json:"split_base_amount_cent"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SplitBaseAmountCent int64 `json:"split_base_amount_cent"`
|
||||
RoundingAdjustmentCent int64 `json:"rounding_adjustment_cent"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type DistributionOrderListQuery struct {
|
||||
|
||||
@@ -31,7 +31,8 @@ func (h *Handler) GetSite(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.FindSite(c.Request.Context(), id)
|
||||
adminID, _ := currentAdminID(c)
|
||||
item, err := h.service.FindSite(c.Request.Context(), adminID, id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -63,7 +64,8 @@ func (h *Handler) UpdateSite(c *gin.Context) {
|
||||
response.BadRequest(c, "分销网站参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateSite(c.Request.Context(), id, req)
|
||||
adminID, _ := currentAdminID(c)
|
||||
item, err := h.service.UpdateSite(c.Request.Context(), adminID, id, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -81,7 +83,8 @@ func (h *Handler) ListRules(c *gin.Context) {
|
||||
}
|
||||
siteID = &id
|
||||
}
|
||||
items, err := h.service.ListRules(c.Request.Context(), siteID, c.Query("mode"))
|
||||
adminID, _ := currentAdminID(c)
|
||||
items, err := h.service.ListRules(c.Request.Context(), adminID, siteID, c.Query("mode"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -118,7 +121,8 @@ func (h *Handler) UpdateRuleStatus(c *gin.Context) {
|
||||
response.BadRequest(c, "规则状态不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateRuleStatus(c.Request.Context(), id, req.Status)
|
||||
adminID, _ := currentAdminID(c)
|
||||
item, err := h.service.UpdateRuleStatus(c.Request.Context(), adminID, id, req.Status)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -152,7 +156,8 @@ func (h *Handler) FindOrder(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.FindOrder(c.Request.Context(), id)
|
||||
adminID, _ := currentAdminID(c)
|
||||
item, err := h.service.FindOrder(c.Request.Context(), adminID, id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -170,7 +175,8 @@ func (h *Handler) CreateFinancialSnapshot(c *gin.Context) {
|
||||
response.BadRequest(c, "分销财务快照参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateFinancialSnapshot(c.Request.Context(), id, req)
|
||||
adminID, _ := currentAdminID(c)
|
||||
item, err := h.service.CreateFinancialSnapshot(c.Request.Context(), adminID, id, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -210,7 +216,8 @@ func (h *Handler) UpdateAccountDisplay(c *gin.Context) {
|
||||
response.BadRequest(c, "账号展示状态不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateAccountDisplay(c.Request.Context(), id, req.DisplayStatus)
|
||||
adminID, _ := currentAdminID(c)
|
||||
item, err := h.service.UpdateAccountDisplay(c.Request.Context(), adminID, id, req.DisplayStatus)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -268,7 +275,8 @@ func (h *Handler) UnbindPaymentChannel(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.UnbindPaymentChannel(c.Request.Context(), id); err != nil {
|
||||
adminID, _ := currentAdminID(c)
|
||||
if err := h.service.UnbindPaymentChannel(c.Request.Context(), adminID, id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -319,7 +327,8 @@ func (h *Handler) MarkSettlementBatch(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.MarkSettlementBatch(c.Request.Context(), id)
|
||||
adminID, _ := currentAdminID(c)
|
||||
item, err := h.service.MarkSettlementBatch(c.Request.Context(), adminID, id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -363,7 +372,11 @@ func writeError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrSiteNotFound), errors.Is(err, ErrRuleNotFound), errors.Is(err, ErrOrderNotFound), errors.Is(err, ErrAccountDisplayNotFound), errors.Is(err, ErrPaymentChannelNotFound), errors.Is(err, ErrSettlementBatchNotFound):
|
||||
response.NotFound(c, "分销资源不存在")
|
||||
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):
|
||||
case errors.Is(err, ErrPaymentConfigNotFound):
|
||||
response.BadRequest(c, "支付商户配置不存在")
|
||||
case errors.Is(err, ErrOrderSnapshotExists):
|
||||
response.Error(c, http.StatusConflict, "snapshot_exists", "该订单已存在财务快照,不能重复补数")
|
||||
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, ErrSiteAccessDenied):
|
||||
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点")
|
||||
|
||||
@@ -2,6 +2,7 @@ package distribution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
@@ -9,7 +10,22 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// distributionTablesPresent 探测分销财务表是否存在,仅首次访问查询 information_schema,
|
||||
// 兼容尚未执行分销迁移的旧环境,避免每次支付/退款事务都做系统表查询。
|
||||
var (
|
||||
distributionTablePresent bool
|
||||
distributionTableCheckOnce sync.Once
|
||||
)
|
||||
|
||||
func distributionTablesPresent(tx *gorm.DB) bool {
|
||||
distributionTableCheckOnce.Do(func() {
|
||||
distributionTablePresent = tx.Migrator().HasTable(&model.DistributionOrderFinancial{})
|
||||
})
|
||||
return distributionTablePresent
|
||||
}
|
||||
|
||||
// ResolveOrderSourceTx 校验站点、账号展示关系和生效规则,供正常订单创建事务写入快照引用。
|
||||
// 第一期仅启用 two_party_distributor_receive 模式,其他模式规则不会命中(见设计文档实施顺序)。
|
||||
func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uint64, at time.Time) (*model.DistributionSite, *model.DistributionRuleVersion, error) {
|
||||
if siteCode == "" {
|
||||
return nil, nil, nil
|
||||
@@ -32,9 +48,18 @@ func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uin
|
||||
return &site, &rule, nil
|
||||
}
|
||||
|
||||
// calculateForRule 按规则快照计算手续费、服务费和分成基数。
|
||||
func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput) (SettlementResult, error) {
|
||||
return CalculateSettlement(Rule{
|
||||
Mode: rule.Mode, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit,
|
||||
PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS,
|
||||
}, input)
|
||||
}
|
||||
|
||||
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
|
||||
// 参与方金额按规则比例对分成基数拆分,剩余(含尾差)记入 RoundingAdjustmentCent 归平台。
|
||||
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
|
||||
if !tx.Migrator().HasTable(&model.DistributionOrderFinancial{}) {
|
||||
if !distributionTablesPresent(tx) {
|
||||
return nil
|
||||
}
|
||||
var order model.RentalOrder
|
||||
@@ -49,11 +74,12 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
|
||||
return err
|
||||
}
|
||||
markup := order.PlatformFeeCent
|
||||
result, err := CalculateSettlement(Rule{ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: grossPaidCent, MarkupAmountCent: markup, GrossProfitByParty: []PartyProfitInput{{PartyRole: "distributor_receive", IncomeAmountCent: order.OwnerRentAmountCent}, {PartyRole: "platform_sell", IncomeAmountCent: order.PlatformFeeCent}}})
|
||||
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: grossPaidCent, MarkupAmountCent: markup})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossPaidCent, 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, SettlementStatus: "pending"}
|
||||
splits := SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, markup)
|
||||
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossPaidCent, 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: SplitRemainderCent(result.SplitBaseAmountCent, splits), SettlementStatus: "pending"}
|
||||
if err := tx.Where("order_id = ?", order.ID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -64,16 +90,27 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
parties := []model.DistributionOrderParty{
|
||||
{OrderID: order.ID, SiteID: *order.DistributionSiteID, PartyRole: "distributor_receive", PartyType: "site", PartyID: *order.DistributionSiteID, Ratio: rule.DistributorReceiveRatio, IncomeAmountCent: order.OwnerRentAmountCent, ProfitAmountCent: order.OwnerRentAmountCent},
|
||||
{OrderID: order.ID, SiteID: *order.DistributionSiteID, PartyRole: "platform_sell", PartyType: "platform", Ratio: rule.PlatformSellRatio, IncomeAmountCent: order.PlatformFeeCent, ProfitAmountCent: order.PlatformFeeCent},
|
||||
parties := make([]model.DistributionOrderParty, 0, len(splits))
|
||||
for _, split := range splits {
|
||||
partyType := "site"
|
||||
if split.PartyRole == "platform_sell" || split.PartyRole == "platform_receive" {
|
||||
partyType = "platform"
|
||||
}
|
||||
partyID := *order.DistributionSiteID
|
||||
if partyType == "platform" {
|
||||
partyID = 0
|
||||
}
|
||||
parties = append(parties, model.DistributionOrderParty{OrderID: order.ID, SiteID: *order.DistributionSiteID, PartyRole: split.PartyRole, PartyType: partyType, PartyID: partyID, Ratio: split.Ratio, IncomeAmountCent: split.AmountCent, ProfitAmountCent: split.AmountCent})
|
||||
}
|
||||
if len(parties) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(&parties).Error
|
||||
}
|
||||
|
||||
// ApplyRefundTx 按累计成功退款金额重算手续费和分成基数,不覆盖订单创建时的规则版本。
|
||||
// ApplyRefundTx 按累计成功退款金额重算手续费、分成基数和各方分成,不覆盖订单创建时的规则版本。
|
||||
func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
||||
if !tx.Migrator().HasTable(&model.DistributionOrderFinancial{}) {
|
||||
if !distributionTablesPresent(tx) {
|
||||
return nil
|
||||
}
|
||||
var financial model.DistributionOrderFinancial
|
||||
@@ -90,9 +127,20 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
||||
if err := tx.First(&rule, financial.RuleVersionID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := CalculateSettlement(Rule{ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, MarkupAmountCent: financial.MarkupAmountCent})
|
||||
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, MarkupAmountCent: financial.MarkupAmountCent})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&financial).Updates(map[string]any{"refund_amount_cent": refundAmountCent, "net_paid_amount_cent": result.NetPaidAmountCent, "payment_fee_cent": result.PaymentFeeCent, "service_fee_cent": result.ServiceFeeCent, "split_base_amount_cent": result.SplitBaseAmountCent, "settlement_status": "pending"}).Error
|
||||
splits := SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, financial.MarkupAmountCent)
|
||||
if err := tx.Model(&financial).Updates(map[string]any{"refund_amount_cent": refundAmountCent, "net_paid_amount_cent": result.NetPaidAmountCent, "payment_fee_cent": result.PaymentFeeCent, "service_fee_cent": result.ServiceFeeCent, "split_base_amount_cent": result.SplitBaseAmountCent, "rounding_adjustment_cent": SplitRemainderCent(result.SplitBaseAmountCent, splits), "settlement_status": "pending"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, split := range splits {
|
||||
if err := tx.Model(&model.DistributionOrderParty{}).
|
||||
Where("order_id = ? AND party_role = ?", orderID, split.PartyRole).
|
||||
Updates(map[string]any{"income_amount_cent": split.AmountCent, "profit_amount_cent": split.AmountCent}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
|
||||
result.TaxByParty = make([]PartyTaxResult, 0, len(input.GrossProfitByParty))
|
||||
for _, party := range input.GrossProfitByParty {
|
||||
profit := party.IncomeAmountCent - party.CostAmountCent
|
||||
tax := roundHalfUp(float64(max(profit, 0)) * float64(rule.TaxRateBPS) / 10000)
|
||||
tax := roundHalfUp(float64(maxInt64(profit, 0)) * float64(rule.TaxRateBPS) / 10000)
|
||||
result.TaxAmountCent += tax
|
||||
result.TaxByParty = append(result.TaxByParty, PartyTaxResult{PartyRole: party.PartyRole, GrossProfitCent: profit, TaxAmountCent: tax})
|
||||
}
|
||||
@@ -154,9 +154,76 @@ func roundHalfUp(value float64) int64 {
|
||||
return int64(math.Floor(value + 0.5))
|
||||
}
|
||||
|
||||
func max(a, b int64) int64 {
|
||||
func maxInt64(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// PartySplit 是一次分成拆分中单个参与方的金额。
|
||||
type PartySplit struct {
|
||||
PartyRole string
|
||||
Ratio float64
|
||||
AmountCent int64
|
||||
}
|
||||
|
||||
// SplitSettlement 按规则模式把分成基数以百分比拆给各分销参与方。
|
||||
// 销售加价全部计入卖出方收入(设计文档 7.6);
|
||||
// 剩余金额(含四舍五入尾差)由调用方归入平台留存并记录 RoundingAdjustmentCent,
|
||||
// 最终业务口径以设计文档 7.2 待确认项的结论为准。
|
||||
func SplitSettlement(rule Rule, splitBase int64, markupCent int64) []PartySplit {
|
||||
roles := []struct {
|
||||
role string
|
||||
ratio float64
|
||||
}{}
|
||||
switch rule.Mode {
|
||||
case ModeTwoPartyDistributorReceive:
|
||||
roles = []struct {
|
||||
role string
|
||||
ratio float64
|
||||
}{{"distributor_receive", rule.DistributorReceiveRatio}, {"platform_sell", rule.PlatformSellRatio}}
|
||||
case ModeTwoPartyPlatformReceive:
|
||||
roles = []struct {
|
||||
role string
|
||||
ratio float64
|
||||
}{{"distributor_sell", rule.DistributorSellRatio}, {"platform_receive", rule.PlatformReceiveRatio}}
|
||||
case ModeThreeParty:
|
||||
roles = []struct {
|
||||
role string
|
||||
ratio float64
|
||||
}{{"distributor_a", rule.DistributorARatio}, {"distributor_b", rule.DistributorBRatio}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
splits := make([]PartySplit, 0, len(roles))
|
||||
for _, item := range roles {
|
||||
amount := roundHalfUp(float64(splitBase) * item.ratio / 100)
|
||||
splits = append(splits, PartySplit{PartyRole: item.role, Ratio: item.ratio, AmountCent: maxInt64(amount, 0)})
|
||||
}
|
||||
if markupCent > 0 {
|
||||
// 加价归卖出方:两方-分销收号与三方模式的卖出方分别是 platform_sell / distributor_b。
|
||||
sellerRole := "distributor_sell"
|
||||
switch rule.Mode {
|
||||
case ModeTwoPartyDistributorReceive:
|
||||
sellerRole = "platform_sell"
|
||||
case ModeThreeParty:
|
||||
sellerRole = "distributor_b"
|
||||
}
|
||||
for i := range splits {
|
||||
if splits[i].PartyRole == sellerRole {
|
||||
splits[i].AmountCent += markupCent
|
||||
}
|
||||
}
|
||||
}
|
||||
return splits
|
||||
}
|
||||
|
||||
// SplitRemainderCent 返回分成基数扣除各方分成后的平台留存金额(含尾差)。
|
||||
func SplitRemainderCent(splitBase int64, splits []PartySplit) int64 {
|
||||
sum := int64(0)
|
||||
for _, item := range splits {
|
||||
sum += item.AmountCent
|
||||
}
|
||||
return splitBase - sum
|
||||
}
|
||||
|
||||
@@ -53,3 +53,50 @@ func TestCalculateSettlementRejectsRefundOverPayment(t *testing.T) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSettlementTwoPartyDistributorReceive(t *testing.T) {
|
||||
rule := Rule{Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 3.5, PlatformSellRatio: 4}
|
||||
splits := SplitSettlement(rule, 10000, 0)
|
||||
if len(splits) != 2 {
|
||||
t.Fatalf("splits = %#v", splits)
|
||||
}
|
||||
if splits[0].PartyRole != "distributor_receive" || splits[0].AmountCent != 350 {
|
||||
t.Fatalf("distributor split = %#v", splits[0])
|
||||
}
|
||||
if splits[1].PartyRole != "platform_sell" || splits[1].AmountCent != 400 {
|
||||
t.Fatalf("platform split = %#v", splits[1])
|
||||
}
|
||||
if got := SplitRemainderCent(10000, splits); got != 9250 {
|
||||
t.Fatalf("remainder = %d, want 9250", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSettlementMarkupGoesToSeller(t *testing.T) {
|
||||
// 两方-分销收号模式:卖出方是主站(platform_sell)。
|
||||
rule := Rule{Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 3.5, PlatformSellRatio: 4}
|
||||
splits := SplitSettlement(rule, 10000, 300)
|
||||
if splits[1].AmountCent != 700 {
|
||||
t.Fatalf("platform split with markup = %d, want 700", splits[1].AmountCent)
|
||||
}
|
||||
// 两方-分销卖出模式:卖出方是分销网(distributor_sell)。
|
||||
ruleSell := Rule{Mode: ModeTwoPartyPlatformReceive, DistributorSellRatio: 4, PlatformReceiveRatio: 3.5}
|
||||
splitsSell := SplitSettlement(ruleSell, 10000, 300)
|
||||
if splitsSell[0].PartyRole != "distributor_sell" || splitsSell[0].AmountCent != 700 {
|
||||
t.Fatalf("distributor sell split with markup = %#v", splitsSell[0])
|
||||
}
|
||||
if got := SplitRemainderCent(10000, splitsSell); got != 8950 {
|
||||
t.Fatalf("remainder = %d, want 8950", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSettlementThreeParty(t *testing.T) {
|
||||
rule := Rule{Mode: ModeThreeParty, DistributorARatio: 3.5, DistributorBRatio: 4}
|
||||
splits := SplitSettlement(rule, 10001, 0)
|
||||
if len(splits) != 2 || splits[0].AmountCent != 350 || splits[1].AmountCent != 400 {
|
||||
t.Fatalf("splits = %#v", splits)
|
||||
}
|
||||
// 尾差归平台:10001 - 750 = 9251。
|
||||
if got := SplitRemainderCent(10001, splits); got != 9251 {
|
||||
t.Fatalf("remainder = %d, want 9251", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -21,6 +23,10 @@ var (
|
||||
ErrInvalidPeriod = errors.New("invalid distribution settlement period")
|
||||
ErrSettlementBatchNotFound = errors.New("distribution settlement batch not found")
|
||||
ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled")
|
||||
ErrSettlementBatchEmpty = errors.New("distribution settlement batch empty")
|
||||
ErrSettlementTotalMismatch = errors.New("distribution settlement total mismatch")
|
||||
ErrPaymentConfigNotFound = errors.New("distribution payment config not found")
|
||||
ErrOrderSnapshotExists = errors.New("distribution order snapshot exists")
|
||||
ErrSiteAccessDenied = errors.New("distribution site access denied")
|
||||
)
|
||||
|
||||
@@ -53,6 +59,34 @@ func (r *Repository) ScopeSiteID(ctx context.Context, adminID, requested uint64)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// ensureSiteAccess 校验管理员可访问指定站点:
|
||||
// 无成员记录(超级管理员语义)放行,有成员记录则必须命中。
|
||||
func (r *Repository) ensureSiteAccess(ctx context.Context, adminID, siteID uint64) error {
|
||||
if adminID == 0 {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}).
|
||||
Where("admin_user_id = ? AND status = 'active'", adminID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil
|
||||
}
|
||||
if siteID == 0 {
|
||||
return ErrSiteAccessDenied
|
||||
}
|
||||
var allowed int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}).
|
||||
Where("admin_user_id = ? AND site_id = ? AND status = 'active'", adminID, siteID).Count(&allowed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if allowed == 0 {
|
||||
return ErrSiteAccessDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
||||
var rows []model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
@@ -65,7 +99,7 @@ func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
|
||||
func (r *Repository) FindSite(ctx context.Context, adminID, id uint64) (*SiteDTO, error) {
|
||||
var row model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -73,6 +107,9 @@ func (r *Repository) FindSite(ctx context.Context, id uint64) (*SiteDTO, error)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := siteDTO(row)
|
||||
return &item, nil
|
||||
}
|
||||
@@ -101,7 +138,7 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
||||
func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
||||
var row model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -109,6 +146,9 @@ func (r *Repository) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRe
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.SiteName != nil {
|
||||
row.SiteName = strings.TrimSpace(*req.SiteName)
|
||||
}
|
||||
@@ -137,11 +177,14 @@ func (r *Repository) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRe
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListRules(ctx context.Context, siteID *uint64, mode string) ([]RuleDTO, error) {
|
||||
func (r *Repository) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) {
|
||||
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Order("effective_from DESC, id DESC")
|
||||
if siteID == nil {
|
||||
db = db.Where("site_id IS NULL")
|
||||
} else {
|
||||
if err := r.ensureSiteAccess(ctx, adminID, *siteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db = db.Where("site_id = ?", *siteID)
|
||||
}
|
||||
if mode = strings.TrimSpace(mode); mode != "" {
|
||||
@@ -159,6 +202,11 @@ func (r *Repository) ListRules(ctx context.Context, siteID *uint64, mode string)
|
||||
}
|
||||
|
||||
func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
|
||||
if req.SiteID != nil {
|
||||
if err := r.ensureSiteAccess(ctx, adminID, *req.SiteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
version := 1
|
||||
var maxVersion int
|
||||
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Where("mode = ?", req.Mode)
|
||||
@@ -197,7 +245,7 @@ func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateR
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateRuleStatus(ctx context.Context, id uint64, status string) (*RuleDTO, error) {
|
||||
func (r *Repository) UpdateRuleStatus(ctx context.Context, adminID, id uint64, status string) (*RuleDTO, error) {
|
||||
var row model.DistributionRuleVersion
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -205,6 +253,11 @@ func (r *Repository) UpdateRuleStatus(ctx context.Context, id uint64, status str
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if row.SiteID != nil {
|
||||
if err := r.ensureSiteAccess(ctx, adminID, *row.SiteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
row.Status = strings.TrimSpace(status)
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -253,22 +306,25 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList
|
||||
return nil, err
|
||||
}
|
||||
items := make([]DistributionOrderDTO, 0)
|
||||
selectSQL := "ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, ro.created_at"
|
||||
selectSQL := "ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, f.rounding_adjustment_cent, ro.created_at"
|
||||
if err := base.Select(selectSQL).Order("ro.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DistributionOrderPage{Items: items, Total: total, Page: page, PageSize: size}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindOrder(ctx context.Context, id uint64) (*DistributionOrderDTO, error) {
|
||||
func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*DistributionOrderDTO, error) {
|
||||
var item DistributionOrderDTO
|
||||
err := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").Select("ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, ro.created_at").Where("ro.id = ?", id).Scan(&item).Error
|
||||
err := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").Select("ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, f.rounding_adjustment_cent, ro.created_at").Where("ro.id = ?", id).Scan(&item).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.OrderID == 0 {
|
||||
return nil, ErrOrderNotFound
|
||||
}
|
||||
if err := r.ensureSiteAccess(ctx, adminID, item.SiteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
@@ -288,7 +344,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateAccountDisplay(ctx context.Context, id uint64, status string) (*AccountDisplayDTO, error) {
|
||||
func (r *Repository) UpdateAccountDisplay(ctx context.Context, adminID, id uint64, status string) (*AccountDisplayDTO, error) {
|
||||
var row model.DistributionSiteAccount
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -296,6 +352,9 @@ func (r *Repository) UpdateAccountDisplay(ctx context.Context, id uint64, status
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.DisplayStatus = strings.TrimSpace(status)
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -317,6 +376,14 @@ func (r *Repository) ListPaymentChannels(ctx context.Context, siteID uint64) ([]
|
||||
}
|
||||
|
||||
func (r *Repository) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
|
||||
var configCount int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.PaymentMerchantConfig{}).
|
||||
Where("id = ?", req.ConfigID).Count(&configCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if configCount == 0 {
|
||||
return nil, ErrPaymentConfigNotFound
|
||||
}
|
||||
row := model.DistributionSitePaymentChannel{SiteID: siteID, ConfigID: req.ConfigID, Priority: req.Priority, Status: "active"}
|
||||
if err := r.db.WithContext(ctx).Where("site_id = ? AND config_id = ?", siteID, req.ConfigID).FirstOrCreate(&row).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -324,15 +391,18 @@ func (r *Repository) BindPaymentChannel(ctx context.Context, siteID uint64, req
|
||||
return &PaymentChannelDTO{ID: row.ID, SiteID: row.SiteID, ConfigID: row.ConfigID, Priority: row.Priority, Status: row.Status}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UnbindPaymentChannel(ctx context.Context, id uint64) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.DistributionSitePaymentChannel{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
func (r *Repository) UnbindPaymentChannel(ctx context.Context, adminID, id uint64) error {
|
||||
var row model.DistributionSitePaymentChannel
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrPaymentChannelNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrPaymentChannelNotFound
|
||||
if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return r.db.WithContext(ctx).Delete(&row).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
|
||||
@@ -351,34 +421,107 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64,
|
||||
if req.PeriodEnd.Before(req.PeriodStart) {
|
||||
return nil, ErrInvalidPeriod
|
||||
}
|
||||
row := model.DistributionSettlementBatch{SiteID: req.SiteID, BatchNo: "DS" + time.Now().Format("20060102150405.000000000"), PeriodStart: req.PeriodStart, PeriodEnd: req.PeriodEnd, Status: "draft", CreatedBy: &adminID}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
if err := r.ensureSiteAccess(ctx, adminID, req.SiteID); 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 (r *Repository) MarkSettlementBatch(ctx context.Context, id uint64) (*SettlementBatchDTO, error) {
|
||||
var row model.DistributionSettlementBatch
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrSettlementBatchNotFound
|
||||
if req.SiteID == 0 {
|
||||
return nil, ErrInvalidPeriod
|
||||
}
|
||||
var batch model.DistributionSettlementBatch
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
batch = model.DistributionSettlementBatch{SiteID: req.SiteID, BatchNo: "DS" + time.Now().Format("20060102150405.000000000"), PeriodStart: req.PeriodStart, PeriodEnd: req.PeriodEnd, Status: "draft", CreatedBy: &adminID}
|
||||
if err := tx.Create(&batch).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 聚合周期内尚未进入任何批次的待结算订单财务快照。
|
||||
var financials []model.DistributionOrderFinancial
|
||||
if err := tx.Where("site_id = ? AND settlement_status = 'pending'", req.SiteID).
|
||||
Where("created_at >= ? AND created_at < ?", req.PeriodStart, req.PeriodEnd).
|
||||
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.order_id = distribution_order_financials.order_id)").
|
||||
Order("id ASC").Find(&financials).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
total := int64(0)
|
||||
for _, financial := range financials {
|
||||
var parties []model.DistributionOrderParty
|
||||
if err := tx.Where("order_id = ?", financial.OrderID).Find(&parties).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, party := range parties {
|
||||
item := model.DistributionSettlementItem{BatchID: batch.ID, OrderID: financial.OrderID, PartyID: party.ID, AmountCent: party.IncomeAmountCent, Status: "pending"}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
total += party.IncomeAmountCent
|
||||
}
|
||||
}
|
||||
batch.TotalAmountCent = total
|
||||
return tx.Model(&model.DistributionSettlementBatch{}).Where("id = ?", batch.ID).
|
||||
Update("total_amount_cent", total).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.Status == "settled" {
|
||||
return nil, ErrBatchAlreadySettled
|
||||
}
|
||||
now := time.Now()
|
||||
row.Status = "settled"
|
||||
row.SettledAt = &now
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return &SettlementBatchDTO{ID: batch.ID, SiteID: batch.SiteID, BatchNo: batch.BatchNo, PeriodStart: batch.PeriodStart, PeriodEnd: batch.PeriodEnd, TotalAmountCent: batch.TotalAmountCent, Status: batch.Status}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) MarkSettlementBatch(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.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
if row.Status == "settled" {
|
||||
return ErrBatchAlreadySettled
|
||||
}
|
||||
var items []model.DistributionSettlementItem
|
||||
if err := tx.Where("batch_id = ?", row.ID).Find(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return ErrSettlementBatchEmpty
|
||||
}
|
||||
var total int64
|
||||
for _, item := range items {
|
||||
total += item.AmountCent
|
||||
}
|
||||
if total != row.TotalAmountCent {
|
||||
return ErrSettlementTotalMismatch
|
||||
}
|
||||
now := time.Now()
|
||||
if err := tx.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", row.ID).
|
||||
Update("status", "settled").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.DistributionOrderFinancial{}).
|
||||
Where("order_id IN (?)", tx.Model(&model.DistributionSettlementItem{}).
|
||||
Select("order_id").Where("batch_id = ?", row.ID)).
|
||||
Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
row.Status = "settled"
|
||||
row.SettledAt = &now
|
||||
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 (r *Repository) CreateFinancialSnapshot(ctx context.Context, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
|
||||
func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
|
||||
if req.SiteID == 0 || orderID == 0 {
|
||||
return nil, ErrInvalidAmount
|
||||
}
|
||||
if err := r.ensureSiteAccess(ctx, adminID, req.SiteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rule model.DistributionRuleVersion
|
||||
if err := r.db.WithContext(ctx).First(&rule, req.RuleVersionID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -386,23 +529,33 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, orderID uint64
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if req.SiteID == 0 || orderID == 0 {
|
||||
return nil, ErrInvalidAmount
|
||||
var orderExists int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Count(&orderExists).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orderExists == 0 {
|
||||
return nil, ErrOrderNotFound
|
||||
}
|
||||
// 手工补数只允许创建,不允许覆盖支付事务写入的真实快照。
|
||||
var existing int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.DistributionOrderFinancial{}).
|
||||
Where("order_id = ?", orderID).Count(&existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing > 0 {
|
||||
return nil, ErrOrderSnapshotExists
|
||||
}
|
||||
partyInputs := make([]PartyProfitInput, 0, len(req.Parties))
|
||||
for _, party := range req.Parties {
|
||||
partyInputs = append(partyInputs, PartyProfitInput{PartyRole: party.PartyRole, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent})
|
||||
}
|
||||
result, err := CalculateSettlement(Rule{ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
|
||||
result, err := CalculateSettlement(Rule{Mode: rule.Mode, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, SettlementStatus: "pending"}
|
||||
if err := tx.Where("order_id = ?", orderID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("order_id = ?", orderID).Delete(&model.DistributionOrderParty{}).Error; err != nil {
|
||||
financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRemainderCent(result.SplitBaseAmountCent, SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, result.MarkupAmountCent)), SettlementStatus: "pending"}
|
||||
if err := tx.Create(&financial).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for idx, party := range req.Parties {
|
||||
@@ -416,10 +569,16 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, orderID uint64
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{"distribution_site_id": req.SiteID, "distribution_rule_version_id": req.RuleVersionID}).Error
|
||||
if err := tx.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{"distribution_site_id": req.SiteID, "distribution_rule_version_id": req.RuleVersionID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return auditlog.Append(tx, auditlog.Entry{
|
||||
ActorType: "admin", ActorID: adminID, Action: "distribution_financial_snapshot_create", BizType: "distribution_order", BizID: &orderID,
|
||||
Detail: map[string]any{"site_id": req.SiteID, "rule_version_id": req.RuleVersionID, "gross_paid_amount_cent": req.GrossPaidAmountCent, "refund_amount_cent": req.RefundAmountCent, "parties": len(req.Parties)},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.FindOrder(ctx, orderID)
|
||||
return r.FindOrder(ctx, adminID, orderID)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func distributionTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.AuditLog{}, &model.PaymentMerchantConfig{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
@@ -38,10 +38,14 @@ func TestDistributionOrderSnapshotAndQueries(t *testing.T) {
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := repo.CreateFinancialSnapshot(t.Context(), order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300, Parties: []SnapshotPartyRequest{{PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 1000, CostAmountCent: 400}}})
|
||||
_, err := repo.CreateFinancialSnapshot(t.Context(), 0, order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300, Parties: []SnapshotPartyRequest{{PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 1000, CostAmountCent: 400}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 重复补数必须被拒绝,避免覆盖真实财务快照。
|
||||
if _, err := repo.CreateFinancialSnapshot(t.Context(), 0, order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001}); err != ErrOrderSnapshotExists {
|
||||
t.Fatalf("duplicate snapshot err = %v", err)
|
||||
}
|
||||
page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{SiteID: site.ID, Kind: "receive", Page: 1, Size: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -49,7 +53,7 @@ func TestDistributionOrderSnapshotAndQueries(t *testing.T) {
|
||||
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].PaymentFeeCent != 45 || page.Items[0].NetPaidAmountCent != 9001 {
|
||||
t.Fatalf("unexpected page: %#v", page)
|
||||
}
|
||||
if _, err := repo.FindOrder(t.Context(), order.ID); err != nil {
|
||||
if _, err := repo.FindOrder(t.Context(), 0, order.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -61,27 +65,75 @@ func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 9, Priority: 1})
|
||||
if err := db.Create(&model.PaymentMerchantConfig{Name: "拉卡拉", Provider: "lkl", MerchantID: "M-1", PayWay: "ZFBZF"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 404, Priority: 1}); err != ErrPaymentConfigNotFound {
|
||||
t.Fatalf("unknown config err = %v", err)
|
||||
}
|
||||
channel, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 1, Priority: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := repo.ListPaymentChannels(t.Context(), site.ID)
|
||||
if err != nil || len(items) != 1 || items[0].ConfigID != 9 {
|
||||
if err != nil || len(items) != 1 || items[0].ConfigID != 1 {
|
||||
t.Fatalf("channels = %#v, err=%v", items, err)
|
||||
}
|
||||
if err := repo.UnbindPaymentChannel(t.Context(), channel.ID); err != nil {
|
||||
if err := repo.UnbindPaymentChannel(t.Context(), 0, channel.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 准备一笔待结算订单:财务快照 + 参与方。
|
||||
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", PaymentFeeBPS: 50, EffectiveFrom: time.Now().Add(-2 * time.Hour)}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := model.RentalOrder{OrderNo: "D-2", 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)
|
||||
}
|
||||
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, NetPaidAmountCent: 9950, SplitBaseAmountCent: 9900, SettlementStatus: "pending"}
|
||||
if err := db.Create(&financial).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parties := []model.DistributionOrderParty{
|
||||
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 350},
|
||||
{OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: "platform", IncomeAmountCent: 400},
|
||||
}
|
||||
if err := db.Create(&parties).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := time.Now().Add(-time.Hour)
|
||||
batch, err := repo.CreateSettlementBatch(t.Context(), 1, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start, PeriodEnd: time.Now()})
|
||||
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start, PeriodEnd: time.Now().Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settled, err := repo.MarkSettlementBatch(t.Context(), batch.ID)
|
||||
if batch.TotalAmountCent != 750 {
|
||||
t.Fatalf("batch total = %d, want 750", batch.TotalAmountCent)
|
||||
}
|
||||
var batchItems []model.DistributionSettlementItem
|
||||
if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 2 {
|
||||
t.Fatalf("batch items = %d, err=%v", len(batchItems), err)
|
||||
}
|
||||
// 空批次不允许结算。
|
||||
emptyBatch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start.Add(-48 * time.Hour), PeriodEnd: start.Add(-24 * time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.MarkSettlementBatch(t.Context(), 0, emptyBatch.ID); err != ErrSettlementBatchEmpty {
|
||||
t.Fatalf("empty batch settle err = %v", err)
|
||||
}
|
||||
|
||||
settled, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID)
|
||||
if err != nil || settled.Status != "settled" {
|
||||
t.Fatalf("settled = %#v, err=%v", settled, err)
|
||||
}
|
||||
if _, err := repo.MarkSettlementBatch(t.Context(), batch.ID); err != ErrBatchAlreadySettled {
|
||||
var settledFinancial model.DistributionOrderFinancial
|
||||
if err := db.Where("order_id = ?", order.ID).First(&settledFinancial).Error; err != nil || settledFinancial.SettlementStatus != "settled" || settledFinancial.SettledAt == nil {
|
||||
t.Fatalf("financial after settle = %#v, err=%v", settledFinancial, err)
|
||||
}
|
||||
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != ErrBatchAlreadySettled {
|
||||
t.Fatalf("second settle err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -93,6 +145,10 @@ func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := model.DistributionSite{SiteCode: "other", SiteName: "其他站", Domain: "other.example.com"}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionSiteMember{SiteID: site.ID, AdminUserID: 7, MemberType: "customer_service", Status: "active"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -105,4 +161,19 @@ func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
|
||||
if got, err := repo.ScopeSiteID(t.Context(), 99, 123); err != nil || got != 123 {
|
||||
t.Fatalf("global scope = %d, err=%v", got, err)
|
||||
}
|
||||
|
||||
// 被限制到站点 A 的成员不能通过资源 ID 跨站点访问。
|
||||
if _, err := repo.FindSite(t.Context(), 7, other.ID); err != ErrSiteAccessDenied {
|
||||
t.Fatalf("FindSite cross-site err = %v", err)
|
||||
}
|
||||
if _, err := repo.UpdateAccountDisplay(t.Context(), 7, 999, "enabled"); err != ErrAccountDisplayNotFound {
|
||||
t.Fatalf("UpdateAccountDisplay missing err = %v", err)
|
||||
}
|
||||
display := model.DistributionSiteAccount{SiteID: other.ID, AccountID: 5, ListingID: 6, DisplayStatus: "enabled"}
|
||||
if err := db.Create(&display).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.UpdateAccountDisplay(t.Context(), 7, display.ID, "disabled"); err != ErrSiteAccessDenied {
|
||||
t.Fatalf("UpdateAccountDisplay cross-site err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +61,11 @@ func (s *Service) ScopeSiteID(ctx context.Context, adminID, requested uint64) (u
|
||||
}
|
||||
return s.repo.ScopeSiteID(ctx, adminID, requested)
|
||||
}
|
||||
func (s *Service) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
|
||||
func (s *Service) FindSite(ctx context.Context, adminID, id uint64) (*SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.FindSite(ctx, id)
|
||||
return s.repo.FindSite(ctx, adminID, id)
|
||||
}
|
||||
func (s *Service) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
@@ -73,17 +73,17 @@ func (s *Service) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteD
|
||||
}
|
||||
return s.repo.CreateSite(ctx, req)
|
||||
}
|
||||
func (s *Service) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
||||
func (s *Service) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.UpdateSite(ctx, id, req)
|
||||
return s.repo.UpdateSite(ctx, adminID, id, req)
|
||||
}
|
||||
func (s *Service) ListRules(ctx context.Context, siteID *uint64, mode string) ([]RuleDTO, error) {
|
||||
func (s *Service) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListRules(ctx, siteID, mode)
|
||||
return s.repo.ListRules(ctx, adminID, siteID, mode)
|
||||
}
|
||||
func (s *Service) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
@@ -91,11 +91,11 @@ func (s *Service) CreateRule(ctx context.Context, adminID uint64, req CreateRule
|
||||
}
|
||||
return s.repo.CreateRule(ctx, adminID, req)
|
||||
}
|
||||
func (s *Service) UpdateRuleStatus(ctx context.Context, id uint64, status string) (*RuleDTO, error) {
|
||||
func (s *Service) UpdateRuleStatus(ctx context.Context, adminID, id uint64, status string) (*RuleDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.UpdateRuleStatus(ctx, id, status)
|
||||
return s.repo.UpdateRuleStatus(ctx, adminID, id, status)
|
||||
}
|
||||
|
||||
func (s *Service) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
|
||||
@@ -104,11 +104,11 @@ func (s *Service) ListOrders(ctx context.Context, query DistributionOrderListQue
|
||||
}
|
||||
return s.repo.ListOrders(ctx, query)
|
||||
}
|
||||
func (s *Service) FindOrder(ctx context.Context, id uint64) (*DistributionOrderDTO, error) {
|
||||
func (s *Service) FindOrder(ctx context.Context, adminID, id uint64) (*DistributionOrderDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.FindOrder(ctx, id)
|
||||
return s.repo.FindOrder(ctx, adminID, id)
|
||||
}
|
||||
func (s *Service) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
@@ -116,11 +116,11 @@ func (s *Service) ListAccountDisplays(ctx context.Context, siteID uint64, status
|
||||
}
|
||||
return s.repo.ListAccountDisplays(ctx, siteID, status)
|
||||
}
|
||||
func (s *Service) UpdateAccountDisplay(ctx context.Context, id uint64, status string) (*AccountDisplayDTO, error) {
|
||||
func (s *Service) UpdateAccountDisplay(ctx context.Context, adminID, id uint64, status string) (*AccountDisplayDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.UpdateAccountDisplay(ctx, id, status)
|
||||
return s.repo.UpdateAccountDisplay(ctx, adminID, id, status)
|
||||
}
|
||||
func (s *Service) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
@@ -134,11 +134,11 @@ func (s *Service) BindPaymentChannel(ctx context.Context, siteID uint64, req Bin
|
||||
}
|
||||
return s.repo.BindPaymentChannel(ctx, siteID, req)
|
||||
}
|
||||
func (s *Service) UnbindPaymentChannel(ctx context.Context, id uint64) error {
|
||||
func (s *Service) UnbindPaymentChannel(ctx context.Context, adminID, id uint64) error {
|
||||
if err := s.available(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.UnbindPaymentChannel(ctx, id)
|
||||
return s.repo.UnbindPaymentChannel(ctx, adminID, id)
|
||||
}
|
||||
func (s *Service) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
@@ -152,16 +152,16 @@ func (s *Service) CreateSettlementBatch(ctx context.Context, adminID uint64, req
|
||||
}
|
||||
return s.repo.CreateSettlementBatch(ctx, adminID, req)
|
||||
}
|
||||
func (s *Service) MarkSettlementBatch(ctx context.Context, id uint64) (*SettlementBatchDTO, error) {
|
||||
func (s *Service) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.MarkSettlementBatch(ctx, id)
|
||||
return s.repo.MarkSettlementBatch(ctx, adminID, id)
|
||||
}
|
||||
|
||||
func (s *Service) CreateFinancialSnapshot(ctx context.Context, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
|
||||
func (s *Service) CreateFinancialSnapshot(ctx context.Context, adminID, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.CreateFinancialSnapshot(ctx, orderID, req)
|
||||
return s.repo.CreateFinancialSnapshot(ctx, adminID, orderID, req)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- +goose Up
|
||||
|
||||
-- 分销后台权限:站点、规则、订单、账号展示、支付通道、结算批次。
|
||||
INSERT INTO permissions (code, name, resource, action) VALUES
|
||||
('distribution:site_view', '查看分销网站', 'distribution_site', 'view'),
|
||||
('distribution:site_manage', '管理分销网站', 'distribution_site', 'manage'),
|
||||
('distribution:rule_view', '查看分销规则', 'distribution_rule', 'view'),
|
||||
('distribution:rule_manage', '管理分销规则', 'distribution_rule', 'manage'),
|
||||
('distribution:order_view', '查看分销订单', 'distribution_order', 'view'),
|
||||
('distribution:account_view', '查看分销账号展示', 'distribution_account', 'view'),
|
||||
('distribution:account_display', '管理分销账号展示', 'distribution_account', 'display'),
|
||||
('distribution:payment_view', '查看分销支付通道', 'distribution_payment', 'view'),
|
||||
('distribution:payment_manage', '管理分销支付通道', 'distribution_payment', 'manage'),
|
||||
('distribution:settlement_view', '查看分销结算', 'distribution_settlement', 'view'),
|
||||
('distribution:settlement_manage', '管理分销结算', 'distribution_settlement', 'manage')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
resource = VALUES(resource),
|
||||
action = VALUES(action);
|
||||
|
||||
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.code LIKE 'distribution:%'
|
||||
WHERE r.code IN ('super_admin', 'ops', 'finance');
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DELETE rp
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE p.code LIKE 'distribution:%';
|
||||
|
||||
DELETE FROM permissions WHERE code LIKE 'distribution:%';
|
||||
Reference in New Issue
Block a user