400 lines
17 KiB
Go
400 lines
17 KiB
Go
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)
|
|
}
|