实现分销网后台基础功能与独立前端
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package distribution
|
||||
|
||||
import "time"
|
||||
|
||||
type SiteDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
SiteCode string `json:"site_code"`
|
||||
SiteName string `json:"site_name"`
|
||||
Domain string `json:"domain"`
|
||||
Status string `json:"status"`
|
||||
DisplayAccountEnabled bool `json:"display_account_enabled"`
|
||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
||||
TaxEnabled bool `json:"tax_enabled"`
|
||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateSiteRequest struct {
|
||||
SiteCode string `json:"site_code" binding:"required,max=64"`
|
||||
SiteName string `json:"site_name" binding:"required,max=128"`
|
||||
Domain string `json:"domain" binding:"required,max=255"`
|
||||
DisplayAccountEnabled *bool `json:"display_account_enabled"`
|
||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||
TaxEnabled *bool `json:"tax_enabled"`
|
||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||
}
|
||||
|
||||
type UpdateSiteRequest struct {
|
||||
SiteName *string `json:"site_name"`
|
||||
Domain *string `json:"domain"`
|
||||
Status *string `json:"status"`
|
||||
DisplayAccountEnabled *bool `json:"display_account_enabled"`
|
||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||
TaxEnabled *bool `json:"tax_enabled"`
|
||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||
}
|
||||
|
||||
type RuleDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
SiteID *uint64 `json:"site_id,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
Version int `json:"version"`
|
||||
Status string `json:"status"`
|
||||
PlatformReceiveRatio float64 `json:"platform_receive_ratio"`
|
||||
PlatformSellRatio float64 `json:"platform_sell_ratio"`
|
||||
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
|
||||
DistributorSellRatio float64 `json:"distributor_sell_ratio"`
|
||||
DistributorARatio float64 `json:"distributor_a_ratio"`
|
||||
DistributorBRatio float64 `json:"distributor_b_ratio"`
|
||||
ServiceFeeValue float64 `json:"service_fee_value"`
|
||||
ServiceFeeUnit string `json:"service_fee_unit"`
|
||||
PaymentFeeBPS int64 `json:"payment_fee_bps"`
|
||||
TaxEnabled bool `json:"tax_enabled"`
|
||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||
EffectiveFrom time.Time `json:"effective_from"`
|
||||
EffectiveTo *time.Time `json:"effective_to,omitempty"`
|
||||
CreatedBy *uint64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateRuleRequest struct {
|
||||
SiteID *uint64 `json:"site_id"`
|
||||
Mode string `json:"mode" binding:"required"`
|
||||
PlatformReceiveRatio float64 `json:"platform_receive_ratio"`
|
||||
PlatformSellRatio float64 `json:"platform_sell_ratio"`
|
||||
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
|
||||
DistributorSellRatio float64 `json:"distributor_sell_ratio"`
|
||||
DistributorARatio float64 `json:"distributor_a_ratio"`
|
||||
DistributorBRatio float64 `json:"distributor_b_ratio"`
|
||||
ServiceFeeValue float64 `json:"service_fee_value"`
|
||||
ServiceFeeUnit string `json:"service_fee_unit" binding:"required"`
|
||||
PaymentFeeBPS int64 `json:"payment_fee_bps"`
|
||||
TaxEnabled bool `json:"tax_enabled"`
|
||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||
EffectiveFrom time.Time `json:"effective_from"`
|
||||
}
|
||||
|
||||
type UpdateRuleStatusRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=draft active disabled"`
|
||||
}
|
||||
|
||||
type DistributionOrderDTO struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
SiteID uint64 `json:"site_id"`
|
||||
ListingID uint64 `json:"listing_id"`
|
||||
AccountID uint64 `json:"account_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
RenterID uint64 `json:"renter_id"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
RefundStatus string `json:"refund_status"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
DistributionMode string `json:"distribution_mode"`
|
||||
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
|
||||
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||
NetPaidAmountCent int64 `json:"net_paid_amount_cent"`
|
||||
PaymentFeeCent int64 `json:"payment_fee_cent"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type DistributionOrderListQuery struct {
|
||||
SiteID uint64
|
||||
Kind string
|
||||
Status string
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
type DistributionOrderPage struct {
|
||||
Items []DistributionOrderDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type AccountDisplayDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
SiteID uint64 `json:"site_id"`
|
||||
AccountID uint64 `json:"account_id"`
|
||||
ListingID uint64 `json:"listing_id"`
|
||||
DisplayStatus string `json:"display_status"`
|
||||
}
|
||||
|
||||
type UpdateAccountDisplayRequest struct {
|
||||
DisplayStatus string `json:"display_status" binding:"required,oneof=enabled disabled sold offline"`
|
||||
}
|
||||
|
||||
type PaymentChannelDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
SiteID uint64 `json:"site_id"`
|
||||
ConfigID uint64 `json:"config_id"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type BindPaymentChannelRequest struct {
|
||||
ConfigID uint64 `json:"config_id" binding:"required"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
type SettlementBatchDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
SiteID uint64 `json:"site_id"`
|
||||
BatchNo string `json:"batch_no"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
TotalAmountCent int64 `json:"total_amount_cent"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type CreateSettlementBatchRequest struct {
|
||||
SiteID uint64 `json:"site_id" binding:"required"`
|
||||
PeriodStart time.Time `json:"period_start" binding:"required"`
|
||||
PeriodEnd time.Time `json:"period_end" binding:"required"`
|
||||
}
|
||||
|
||||
type SnapshotPartyRequest struct {
|
||||
PartyRole string `json:"party_role" binding:"required"`
|
||||
PartyType string `json:"party_type" binding:"required"`
|
||||
PartyID uint64 `json:"party_id"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
IncomeAmountCent int64 `json:"income_amount_cent"`
|
||||
CostAmountCent int64 `json:"cost_amount_cent"`
|
||||
}
|
||||
|
||||
type CreateFinancialSnapshotRequest struct {
|
||||
SiteID uint64 `json:"site_id" binding:"required"`
|
||||
RuleVersionID uint64 `json:"rule_version_id" binding:"required"`
|
||||
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
|
||||
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||
MarkupAmountCent int64 `json:"markup_amount_cent"`
|
||||
Parties []SnapshotPartyRequest `json:"parties"`
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct{ service *Service }
|
||||
|
||||
func NewHandler(service *Service) *Handler { return &Handler{service: service} }
|
||||
|
||||
func (h *Handler) ListSites(c *gin.Context) {
|
||||
items, err := h.service.ListSites(c.Request.Context())
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) GetSite(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.FindSite(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateSite(c *gin.Context) {
|
||||
var req CreateSiteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "分销网站参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateSite(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateSite(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpdateSiteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "分销网站参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateSite(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListRules(c *gin.Context) {
|
||||
var siteID *uint64
|
||||
if raw := strings.TrimSpace(c.Query("site_id")); raw != "" {
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
return
|
||||
}
|
||||
siteID = &id
|
||||
}
|
||||
items, err := h.service.ListRules(c.Request.Context(), siteID, c.Query("mode"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateRule(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
var req CreateRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "分销规则参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateRule(c.Request.Context(), adminID, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateRuleStatus(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpdateRuleStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "规则状态不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateRuleStatus(c.Request.Context(), id, req.Status)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListOrders(c *gin.Context) {
|
||||
siteID := parseQueryUint(c, "site_id")
|
||||
if siteID < 0 {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
result, err := h.service.ListOrders(c.Request.Context(), DistributionOrderListQuery{SiteID: uint64(siteID), Kind: c.Query("kind"), Status: c.Query("status"), Page: page, Size: size})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) FindOrder(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.FindOrder(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateFinancialSnapshot(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req CreateFinancialSnapshotRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "分销财务快照参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateFinancialSnapshot(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListAccountDisplays(c *gin.Context) {
|
||||
siteID := parseQueryUint(c, "site_id")
|
||||
if siteID <= 0 {
|
||||
if siteID == 0 {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
}
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListAccountDisplays(c.Request.Context(), uint64(siteID), c.Query("status"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAccountDisplay(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpdateAccountDisplayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "账号展示状态不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateAccountDisplay(c.Request.Context(), id, req.DisplayStatus)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListPaymentChannels(c *gin.Context) {
|
||||
siteID := parseQueryUint(c, "site_id")
|
||||
if siteID <= 0 {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListPaymentChannels(c.Request.Context(), uint64(siteID))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) BindPaymentChannel(c *gin.Context) {
|
||||
siteID := parseQueryUint(c, "site_id")
|
||||
if siteID <= 0 {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
return
|
||||
}
|
||||
var req BindPaymentChannelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "支付通道参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.BindPaymentChannel(c.Request.Context(), uint64(siteID), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UnbindPaymentChannel(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.UnbindPaymentChannel(c.Request.Context(), id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *Handler) ListSettlementBatches(c *gin.Context) {
|
||||
siteID := parseQueryUint(c, "site_id")
|
||||
if siteID <= 0 {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListSettlementBatches(c.Request.Context(), uint64(siteID))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateSettlementBatch(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
var req CreateSettlementBatchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "结算周期参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateSettlementBatch(c.Request.Context(), adminID, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) MarkSettlementBatch(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.MarkSettlementBatch(c.Request.Context(), 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))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, key+" 不正确")
|
||||
return -1
|
||||
}
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
func currentAdminID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextAdminID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
id, ok := value.(uint64)
|
||||
return id, ok && id > 0
|
||||
}
|
||||
|
||||
func parseID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, "ID 不正确")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
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):
|
||||
response.BadRequest(c, "分销规则参数不正确")
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "分销服务暂时不可用")
|
||||
default:
|
||||
response.RecordError(c, err)
|
||||
response.Error(c, http.StatusInternalServerError, "distribution_error", "分销服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
ModeTwoPartyDistributorReceive = "two_party_distributor_receive"
|
||||
ModeTwoPartyPlatformReceive = "two_party_platform_receive"
|
||||
ModeThreeParty = "three_party"
|
||||
|
||||
ServiceFeeUnitPercent = "percent"
|
||||
ServiceFeeUnitFixedAmount = "fixed_amount"
|
||||
ServiceFeeUnitRatio = "ratio"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidAmount = errors.New("invalid distribution amount")
|
||||
ErrInvalidRate = errors.New("invalid distribution rate")
|
||||
ErrInvalidServiceUnit = errors.New("invalid service fee unit")
|
||||
ErrUnsupportedMode = errors.New("unsupported distribution mode")
|
||||
)
|
||||
|
||||
// Rule 是后台可编辑的分账规则。比例字段按参与方角色命名,避免方向歧义。
|
||||
type Rule struct {
|
||||
Mode string
|
||||
PlatformReceiveRatio float64
|
||||
PlatformSellRatio float64
|
||||
DistributorReceiveRatio float64
|
||||
DistributorSellRatio float64
|
||||
DistributorARatio float64
|
||||
DistributorBRatio float64
|
||||
ServiceFeeValue float64
|
||||
ServiceFeeUnit string
|
||||
PaymentFeeBPS int64
|
||||
TaxEnabled bool
|
||||
TaxRateBPS int64
|
||||
}
|
||||
|
||||
// SettlementInput 使用支付单和退款单的整数分快照计算分销费用。
|
||||
type SettlementInput struct {
|
||||
GrossPaidAmountCent int64
|
||||
RefundAmountCent int64
|
||||
MarkupAmountCent int64
|
||||
GrossProfitByParty []PartyProfitInput
|
||||
}
|
||||
|
||||
type PartyProfitInput struct {
|
||||
PartyRole string
|
||||
IncomeAmountCent int64
|
||||
CostAmountCent int64
|
||||
}
|
||||
|
||||
type PartyTaxResult struct {
|
||||
PartyRole string `json:"party_role"`
|
||||
GrossProfitCent int64 `json:"gross_profit_cent"`
|
||||
TaxAmountCent int64 `json:"tax_amount_cent"`
|
||||
}
|
||||
|
||||
type SettlementResult struct {
|
||||
GrossPaidAmountCent int64
|
||||
RefundAmountCent int64
|
||||
NetPaidAmountCent int64
|
||||
PaymentFeeCent int64
|
||||
ServiceFeeCent int64
|
||||
MarkupAmountCent int64
|
||||
SplitBaseAmountCent int64
|
||||
TaxAmountCent int64
|
||||
TaxByParty []PartyTaxResult
|
||||
}
|
||||
|
||||
// DefaultRule 返回文档中约定的第一版默认值;调用方应将其写入规则表而非写死到业务流程。
|
||||
func DefaultRule(mode string) (Rule, error) {
|
||||
base := Rule{
|
||||
Mode: mode, ServiceFeeValue: 2.5, ServiceFeeUnit: ServiceFeeUnitRatio,
|
||||
PaymentFeeBPS: 50, TaxEnabled: false, TaxRateBPS: 100,
|
||||
}
|
||||
switch mode {
|
||||
case ModeTwoPartyDistributorReceive:
|
||||
base.DistributorReceiveRatio = 3.5
|
||||
base.PlatformSellRatio = 4
|
||||
case ModeTwoPartyPlatformReceive:
|
||||
base.PlatformReceiveRatio = 3.5
|
||||
base.DistributorSellRatio = 4
|
||||
case ModeThreeParty:
|
||||
base.DistributorARatio = 3.5
|
||||
base.DistributorBRatio = 4
|
||||
default:
|
||||
return Rule{}, ErrUnsupportedMode
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// CalculateSettlement 先扣支付手续费,再计算服务费和分成基数;退款金额不能超过支付金额。
|
||||
func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, error) {
|
||||
if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent {
|
||||
return SettlementResult{}, ErrInvalidAmount
|
||||
}
|
||||
if rule.PaymentFeeBPS < 0 || rule.PaymentFeeBPS > 10000 || rule.TaxRateBPS < 0 || rule.TaxRateBPS > 10000 || rule.ServiceFeeValue < 0 {
|
||||
return SettlementResult{}, ErrInvalidRate
|
||||
}
|
||||
if rule.ServiceFeeUnit != ServiceFeeUnitPercent && rule.ServiceFeeUnit != ServiceFeeUnitFixedAmount && rule.ServiceFeeUnit != ServiceFeeUnitRatio {
|
||||
return SettlementResult{}, ErrInvalidServiceUnit
|
||||
}
|
||||
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
|
||||
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
|
||||
serviceFee, err := serviceFeeCent(rule, netPaid)
|
||||
if err != nil {
|
||||
return SettlementResult{}, err
|
||||
}
|
||||
splitBase := netPaid - paymentFee - serviceFee
|
||||
if splitBase < 0 {
|
||||
splitBase = 0
|
||||
}
|
||||
result := SettlementResult{
|
||||
GrossPaidAmountCent: input.GrossPaidAmountCent,
|
||||
RefundAmountCent: input.RefundAmountCent,
|
||||
NetPaidAmountCent: netPaid,
|
||||
PaymentFeeCent: paymentFee,
|
||||
ServiceFeeCent: serviceFee,
|
||||
MarkupAmountCent: input.MarkupAmountCent,
|
||||
SplitBaseAmountCent: splitBase,
|
||||
}
|
||||
if rule.TaxEnabled {
|
||||
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)
|
||||
result.TaxAmountCent += tax
|
||||
result.TaxByParty = append(result.TaxByParty, PartyTaxResult{PartyRole: party.PartyRole, GrossProfitCent: profit, TaxAmountCent: tax})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func serviceFeeCent(rule Rule, netPaid int64) (int64, error) {
|
||||
switch rule.ServiceFeeUnit {
|
||||
case ServiceFeeUnitPercent:
|
||||
return roundHalfUp(float64(netPaid) * rule.ServiceFeeValue / 100), nil
|
||||
case ServiceFeeUnitFixedAmount:
|
||||
return roundHalfUp(rule.ServiceFeeValue * 100), nil
|
||||
case ServiceFeeUnitRatio:
|
||||
if rule.ServiceFeeValue <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return roundHalfUp(float64(netPaid) / rule.ServiceFeeValue), nil
|
||||
default:
|
||||
return 0, ErrInvalidServiceUnit
|
||||
}
|
||||
}
|
||||
|
||||
func roundHalfUp(value float64) int64 {
|
||||
return int64(math.Floor(value + 0.5))
|
||||
}
|
||||
|
||||
func max(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package distribution
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultRules(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
check func(Rule) bool
|
||||
}{
|
||||
{ModeTwoPartyDistributorReceive, func(r Rule) bool { return r.DistributorReceiveRatio == 3.5 && r.PlatformSellRatio == 4 }},
|
||||
{ModeTwoPartyPlatformReceive, func(r Rule) bool { return r.PlatformReceiveRatio == 3.5 && r.DistributorSellRatio == 4 }},
|
||||
{ModeThreeParty, func(r Rule) bool { return r.DistributorARatio == 3.5 && r.DistributorBRatio == 4 }},
|
||||
}
|
||||
for _, item := range cases {
|
||||
rule, err := DefaultRule(item.mode)
|
||||
if err != nil || !item.check(rule) || rule.ServiceFeeValue != 2.5 || rule.PaymentFeeBPS != 50 {
|
||||
t.Fatalf("default rule %s = %#v, err=%v", item.mode, rule, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSettlementDeductsFeeBeforeSplit(t *testing.T) {
|
||||
rule := Rule{ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50}
|
||||
got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.NetPaidAmountCent != 9001 || got.PaymentFeeCent != 45 || got.SplitBaseAmountCent != 8956 {
|
||||
t.Fatalf("unexpected settlement: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSettlementTaxesEachPositivePartyProfit(t *testing.T) {
|
||||
rule := Rule{ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 0, TaxEnabled: true, TaxRateBPS: 100}
|
||||
got, err := CalculateSettlement(rule, SettlementInput{
|
||||
GrossPaidAmountCent: 10000,
|
||||
GrossProfitByParty: []PartyProfitInput{
|
||||
{PartyRole: "distributor_a", IncomeAmountCent: 1000, CostAmountCent: 400},
|
||||
{PartyRole: "distributor_b", IncomeAmountCent: 500, CostAmountCent: 700},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TaxAmountCent != 6 || len(got.TaxByParty) != 2 || got.TaxByParty[0].TaxAmountCent != 6 || got.TaxByParty[1].TaxAmountCent != 0 {
|
||||
t.Fatalf("unexpected tax result: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSettlementRejectsRefundOverPayment(t *testing.T) {
|
||||
_, err := CalculateSettlement(Rule{ServiceFeeUnit: ServiceFeeUnitFixedAmount}, SettlementInput{GrossPaidAmountCent: 1, RefundAmountCent: 2})
|
||||
if err != ErrInvalidAmount {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSiteNotFound = errors.New("distribution site not found")
|
||||
ErrRuleNotFound = errors.New("distribution rule not found")
|
||||
ErrOrderNotFound = errors.New("distribution order not found")
|
||||
ErrInvalidOrderKind = errors.New("invalid distribution order kind")
|
||||
ErrAccountDisplayNotFound = errors.New("distribution account display not found")
|
||||
ErrPaymentChannelNotFound = errors.New("distribution payment channel not found")
|
||||
ErrInvalidPeriod = errors.New("invalid distribution settlement period")
|
||||
ErrSettlementBatchNotFound = errors.New("distribution settlement batch not found")
|
||||
ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled")
|
||||
)
|
||||
|
||||
type Repository struct{ db *gorm.DB }
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]SiteDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, siteDTO(row))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindSite(ctx context.Context, 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) {
|
||||
return nil, ErrSiteNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
item := siteDTO(row)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
|
||||
row := model.DistributionSite{
|
||||
SiteCode: strings.TrimSpace(req.SiteCode), SiteName: strings.TrimSpace(req.SiteName), Domain: strings.TrimSpace(req.Domain),
|
||||
Status: "active", DisplayAccountEnabled: true, TaxRateBPS: 100,
|
||||
}
|
||||
if req.DisplayAccountEnabled != nil {
|
||||
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
|
||||
}
|
||||
if req.DefaultSupportGroupID != nil {
|
||||
row.DefaultSupportGroupID = req.DefaultSupportGroupID
|
||||
}
|
||||
if req.TaxEnabled != nil {
|
||||
row.TaxEnabled = *req.TaxEnabled
|
||||
}
|
||||
if req.TaxRateBPS != nil {
|
||||
row.TaxRateBPS = *req.TaxRateBPS
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := siteDTO(row)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateSite(ctx context.Context, 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) {
|
||||
return nil, ErrSiteNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if req.SiteName != nil {
|
||||
row.SiteName = strings.TrimSpace(*req.SiteName)
|
||||
}
|
||||
if req.Domain != nil {
|
||||
row.Domain = strings.TrimSpace(*req.Domain)
|
||||
}
|
||||
if req.Status != nil {
|
||||
row.Status = strings.TrimSpace(*req.Status)
|
||||
}
|
||||
if req.DisplayAccountEnabled != nil {
|
||||
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
|
||||
}
|
||||
if req.DefaultSupportGroupID != nil {
|
||||
row.DefaultSupportGroupID = req.DefaultSupportGroupID
|
||||
}
|
||||
if req.TaxEnabled != nil {
|
||||
row.TaxEnabled = *req.TaxEnabled
|
||||
}
|
||||
if req.TaxRateBPS != nil {
|
||||
row.TaxRateBPS = *req.TaxRateBPS
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := siteDTO(row)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListRules(ctx context.Context, 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 {
|
||||
db = db.Where("site_id = ?", *siteID)
|
||||
}
|
||||
if mode = strings.TrimSpace(mode); mode != "" {
|
||||
db = db.Where("mode = ?", mode)
|
||||
}
|
||||
var rows []model.DistributionRuleVersion
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]RuleDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, ruleDTO(row))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
|
||||
version := 1
|
||||
var maxVersion int
|
||||
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Where("mode = ?", req.Mode)
|
||||
if req.SiteID == nil {
|
||||
db = db.Where("site_id IS NULL")
|
||||
} else {
|
||||
db = db.Where("site_id = ?", *req.SiteID)
|
||||
}
|
||||
if err := db.Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version = maxVersion + 1
|
||||
effectiveFrom := req.EffectiveFrom
|
||||
if effectiveFrom.IsZero() {
|
||||
effectiveFrom = time.Now()
|
||||
}
|
||||
row := model.DistributionRuleVersion{
|
||||
SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft",
|
||||
PlatformReceiveRatio: req.PlatformReceiveRatio, PlatformSellRatio: req.PlatformSellRatio,
|
||||
DistributorReceiveRatio: req.DistributorReceiveRatio, DistributorSellRatio: req.DistributorSellRatio,
|
||||
DistributorARatio: req.DistributorARatio, DistributorBRatio: req.DistributorBRatio,
|
||||
ServiceFeeValue: req.ServiceFeeValue, ServiceFeeUnit: strings.TrimSpace(req.ServiceFeeUnit),
|
||||
PaymentFeeBPS: req.PaymentFeeBPS, TaxEnabled: req.TaxEnabled, TaxRateBPS: req.TaxRateBPS,
|
||||
EffectiveFrom: effectiveFrom, CreatedBy: &adminID,
|
||||
}
|
||||
if row.PaymentFeeBPS == 0 {
|
||||
row.PaymentFeeBPS = 50
|
||||
}
|
||||
if row.TaxRateBPS == 0 {
|
||||
row.TaxRateBPS = 100
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := ruleDTO(row)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateRuleStatus(ctx context.Context, 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) {
|
||||
return nil, ErrRuleNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
row.Status = strings.TrimSpace(status)
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := ruleDTO(row)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func siteDTO(row model.DistributionSite) SiteDTO {
|
||||
return SiteDTO{ID: row.ID, SiteCode: row.SiteCode, SiteName: row.SiteName, Domain: row.Domain, Status: row.Status, DisplayAccountEnabled: row.DisplayAccountEnabled, DefaultSupportGroupID: row.DefaultSupportGroupID, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
|
||||
func ruleDTO(row model.DistributionRuleVersion) RuleDTO {
|
||||
return RuleDTO{ID: row.ID, SiteID: row.SiteID, Mode: row.Mode, Version: row.Version, Status: row.Status, PlatformReceiveRatio: row.PlatformReceiveRatio, PlatformSellRatio: row.PlatformSellRatio, DistributorReceiveRatio: row.DistributorReceiveRatio, DistributorSellRatio: row.DistributorSellRatio, DistributorARatio: row.DistributorARatio, DistributorBRatio: row.DistributorBRatio, ServiceFeeValue: row.ServiceFeeValue, ServiceFeeUnit: row.ServiceFeeUnit, PaymentFeeBPS: row.PaymentFeeBPS, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, EffectiveFrom: row.EffectiveFrom, EffectiveTo: row.EffectiveTo, CreatedBy: row.CreatedBy, CreatedAt: row.CreatedAt}
|
||||
}
|
||||
|
||||
func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
|
||||
page, size := query.Page, query.Size
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
base := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id")
|
||||
if query.SiteID > 0 {
|
||||
base = base.Where("ro.distribution_site_id = ?", query.SiteID)
|
||||
}
|
||||
if query.Status != "" {
|
||||
base = base.Where("ro.status = ?", query.Status)
|
||||
}
|
||||
switch strings.TrimSpace(query.Kind) {
|
||||
case "sold":
|
||||
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_sell','distributor_b'))")
|
||||
case "receive":
|
||||
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_receive','distributor_a'))")
|
||||
case "", "all":
|
||||
default:
|
||||
return nil, ErrInvalidOrderKind
|
||||
}
|
||||
var total int64
|
||||
if err := base.Session(&gorm.Session{}).Select("ro.id").Count(&total).Error; err != nil {
|
||||
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"
|
||||
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) {
|
||||
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
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.OrderID == 0 {
|
||||
return nil, ErrOrderNotFound
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
|
||||
var rows []model.DistributionSiteAccount
|
||||
db := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("id DESC")
|
||||
if status != "" {
|
||||
db = db.Where("display_status = ?", status)
|
||||
}
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]AccountDisplayDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, AccountDisplayDTO{ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateAccountDisplay(ctx context.Context, 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) {
|
||||
return nil, ErrAccountDisplayNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
row.DisplayStatus = strings.TrimSpace(status)
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := &AccountDisplayDTO{ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
|
||||
var rows []model.DistributionSitePaymentChannel
|
||||
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("priority ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]PaymentChannelDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, PaymentChannelDTO{ID: row.ID, SiteID: row.SiteID, ConfigID: row.ConfigID, Priority: row.Priority, Status: row.Status})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrPaymentChannelNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
|
||||
var rows []model.DistributionSettlementBatch
|
||||
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("id DESC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]SettlementBatchDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64, req CreateSettlementBatchRequest) (*SettlementBatchDTO, error) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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 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) {
|
||||
var rule model.DistributionRuleVersion
|
||||
if err := r.db.WithContext(ctx).First(&rule, req.RuleVersionID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrRuleNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if req.SiteID == 0 || orderID == 0 {
|
||||
return nil, ErrInvalidAmount
|
||||
}
|
||||
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})
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
for idx, party := range req.Parties {
|
||||
tax := int64(0)
|
||||
if idx < len(result.TaxByParty) {
|
||||
tax = result.TaxByParty[idx].TaxAmountCent
|
||||
}
|
||||
profit := party.IncomeAmountCent - party.CostAmountCent
|
||||
row := model.DistributionOrderParty{OrderID: orderID, SiteID: req.SiteID, PartyRole: party.PartyRole, PartyType: party.PartyType, PartyID: party.PartyID, Ratio: party.Ratio, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent, ProfitAmountCent: profit, TaxAmountCent: tax}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
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 != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.FindOrder(ctx, orderID)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func distributionTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.DistributionSite{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestDistributionOrderSnapshotAndQueries(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", DisplayAccountEnabled: true, TaxRateBPS: 100}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50, TaxRateBPS: 100, EffectiveFrom: now}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := model.RentalOrder{OrderNo: "D-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed"}
|
||||
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}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{SiteID: site.ID, Kind: "receive", Page: 1, Size: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", TaxRateBPS: 100}
|
||||
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 != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := repo.ListPaymentChannels(t.Context(), site.ID)
|
||||
if err != nil || len(items) != 1 || items[0].ConfigID != 9 {
|
||||
t.Fatalf("channels = %#v, err=%v", items, err)
|
||||
}
|
||||
if err := repo.UnbindPaymentChannel(t.Context(), channel.ID); 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()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settled, err := repo.MarkSettlementBatch(t.Context(), 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 {
|
||||
t.Fatalf("second settle err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrDependencyUnavailable = errors.New("distribution dependency unavailable")
|
||||
|
||||
type Service struct{ repo *Repository }
|
||||
|
||||
func NewService(repo *Repository) *Service { return &Service{repo: repo} }
|
||||
|
||||
func (s *Service) available() error {
|
||||
if s == nil || s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListSites(ctx)
|
||||
}
|
||||
func (s *Service) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.FindSite(ctx, id)
|
||||
}
|
||||
func (s *Service) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.CreateSite(ctx, req)
|
||||
}
|
||||
func (s *Service) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.UpdateSite(ctx, id, req)
|
||||
}
|
||||
func (s *Service) ListRules(ctx context.Context, siteID *uint64, mode string) ([]RuleDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListRules(ctx, siteID, mode)
|
||||
}
|
||||
func (s *Service) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.CreateRule(ctx, adminID, req)
|
||||
}
|
||||
func (s *Service) UpdateRuleStatus(ctx context.Context, id uint64, status string) (*RuleDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.UpdateRuleStatus(ctx, id, status)
|
||||
}
|
||||
|
||||
func (s *Service) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListOrders(ctx, query)
|
||||
}
|
||||
func (s *Service) FindOrder(ctx context.Context, id uint64) (*DistributionOrderDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.FindOrder(ctx, id)
|
||||
}
|
||||
func (s *Service) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListAccountDisplays(ctx, siteID, status)
|
||||
}
|
||||
func (s *Service) UpdateAccountDisplay(ctx context.Context, id uint64, status string) (*AccountDisplayDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.UpdateAccountDisplay(ctx, id, status)
|
||||
}
|
||||
func (s *Service) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListPaymentChannels(ctx, siteID)
|
||||
}
|
||||
func (s *Service) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.BindPaymentChannel(ctx, siteID, req)
|
||||
}
|
||||
func (s *Service) UnbindPaymentChannel(ctx context.Context, id uint64) error {
|
||||
if err := s.available(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.UnbindPaymentChannel(ctx, id)
|
||||
}
|
||||
func (s *Service) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListSettlementBatches(ctx, siteID)
|
||||
}
|
||||
func (s *Service) CreateSettlementBatch(ctx context.Context, adminID uint64, req CreateSettlementBatchRequest) (*SettlementBatchDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.CreateSettlementBatch(ctx, adminID, req)
|
||||
}
|
||||
func (s *Service) MarkSettlementBatch(ctx context.Context, id uint64) (*SettlementBatchDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.MarkSettlementBatch(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) CreateFinancialSnapshot(ctx context.Context, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.CreateFinancialSnapshot(ctx, orderID, req)
|
||||
}
|
||||
Reference in New Issue
Block a user