支持提号完成后财务调整

This commit is contained in:
yml2213
2026-08-16 19:52:12 +08:00
parent 786d20e9cd
commit 80e55ba281
15 changed files with 1272 additions and 111 deletions
@@ -52,13 +52,25 @@ func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (*
Scan(&row).Error; err != nil {
return nil, err
}
var adjustments struct {
SettledAmountCent int64
ProfitAmountCent int64
}
if err := db.Table("admin_pickup_financial_adjustments AS fa").
Select(`COALESCE(SUM(fa.settle_delta_cent), 0) AS settled_amount_cent,
COALESCE(SUM(fa.profit_delta_cent), 0) AS profit_amount_cent`).
Joins("JOIN admin_pickups AS p ON p.id = fa.pickup_id AND p.status = ?", "completed").
Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate).
Scan(&adjustments).Error; err != nil {
return nil, err
}
var inProgress int64
if err := db.Table("admin_pickups").Where("status = ?", "picking_up").Count(&inProgress).Error; err != nil {
return nil, err
}
return &PickupSummaryDTO{
SettledAmountCent: row.SettledAmountCent,
ProfitAmountCent: row.ProfitAmountCent,
SettledAmountCent: row.SettledAmountCent + adjustments.SettledAmountCent,
ProfitAmountCent: row.ProfitAmountCent + adjustments.ProfitAmountCent,
CompletedCount: row.CompletedCount,
InProgressCount: inProgress,
}, nil
@@ -257,6 +269,16 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
Scan(&pickupProfits).Error; err != nil {
return nil, err
}
pickupAdjustments := make([]dailyPickupAdjustmentRow, 0)
if err := db.Table("admin_pickup_financial_adjustments AS fa").
Select(`DATE(fa.created_at) AS date,
COALESCE(SUM(fa.profit_delta_cent), 0) AS profit_amount_cent`).
Joins("JOIN admin_pickups AS p ON p.id = fa.pickup_id AND p.status = ?", "completed").
Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate).
Group("DATE(fa.created_at)").
Scan(&pickupAdjustments).Error; err != nil {
return nil, err
}
// 取消提号按取消日统计,与「已付款订单数」中的提号创建口径区分开。
pickupCancelled, err := r.dailyPickupCancelled(ctx, query)
@@ -335,6 +357,13 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
item.SalesCount += row.CompletedCount
itemsByDate[date] = item
}
for _, row := range pickupAdjustments {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.PlatformIncomeAmountCent += row.ProfitAmountCent
itemsByDate[date] = item
}
for _, row := range pickupCancelled {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
@@ -496,6 +525,12 @@ type dailyPickupProfitRow struct {
CompletedCount int64
}
// dailyPickupAdjustmentRow 调整按调整创建日进入当日利润,避免回写历史完成日报。
type dailyPickupAdjustmentRow struct {
Date string
ProfitAmountCent int64
}
type dailyEstimatedRow struct {
Date string
EstimatedIncomeAmountCent int64
@@ -183,3 +183,60 @@ func TestDailyPickupCancelledByCancelledAt(t *testing.T) {
t.Fatalf("8月2日取消提号数 = %d, want 2", result[0].Count)
}
}
func TestPickupSummaryKeepsOriginalAndAdjustmentOnTheirOwnDates(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
if err := db.Exec(`CREATE TABLE admin_pickups (
id INTEGER PRIMARY KEY,
status TEXT NOT NULL,
settle_amount_cent INTEGER NOT NULL,
profit_amount_cent INTEGER NOT NULL,
completed_at DATETIME NULL
)`).Error; err != nil {
t.Fatalf("创建提号表失败: %v", err)
}
if err := db.Exec(`CREATE TABLE admin_pickup_financial_adjustments (
id INTEGER PRIMARY KEY,
pickup_id INTEGER NOT NULL,
settle_delta_cent INTEGER NOT NULL,
profit_delta_cent INTEGER NOT NULL,
created_at DATETIME NOT NULL
)`).Error; err != nil {
t.Fatalf("创建提号调整表失败: %v", err)
}
loc := timeutil.ShanghaiLocation()
completedAt := time.Date(2026, 8, 2, 10, 0, 0, 0, loc)
adjustedAt := time.Date(2026, 8, 4, 10, 0, 0, 0, loc)
if err := db.Exec(`INSERT INTO admin_pickups (id, status, settle_amount_cent, profit_amount_cent, completed_at)
VALUES (1, 'completed', 12000, 1000, ?)`, completedAt).Error; err != nil {
t.Fatalf("写入提号失败: %v", err)
}
if err := db.Exec(`INSERT INTO admin_pickup_financial_adjustments (id, pickup_id, settle_delta_cent, profit_delta_cent, created_at)
VALUES (1, 1, 1500, 500, ?)`, adjustedAt).Error; err != nil {
t.Fatalf("写入调整失败: %v", err)
}
repo := NewRepository(db)
dayQuery := func(day int) DashboardQuery {
return DashboardQuery{
StartDate: time.Date(2026, 8, day, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 8, day, 23, 59, 59, 0, loc),
}
}
original, err := repo.pickupSummary(t.Context(), dayQuery(2))
if err != nil {
t.Fatalf("查询完成日提号汇总失败: %v", err)
}
if original.SettledAmountCent != 12000 || original.ProfitAmountCent != 1000 || original.CompletedCount != 1 {
t.Fatalf("完成日汇总 = %+v", original)
}
adjusted, err := repo.pickupSummary(t.Context(), dayQuery(4))
if err != nil {
t.Fatalf("查询调整日提号汇总失败: %v", err)
}
if adjusted.SettledAmountCent != 1500 || adjusted.ProfitAmountCent != 500 || adjusted.CompletedCount != 0 {
t.Fatalf("调整日汇总 = %+v", adjusted)
}
}
@@ -18,6 +18,31 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
Scan(&offlinePaid).Error; err != nil {
return nil, err
}
var pickupPaid struct {
AmountCent int64
Count int64
}
if err := db.Table("admin_pickups").
Select(`COALESCE(SUM(settle_amount_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("settlement_mode = ?", "platform_managed").
Where("offline_settlement_status = ?", "settled").
Where("offline_settled_at >= ? AND offline_settled_at <= ?", query.StartDate, query.EndDate).
Scan(&pickupPaid).Error; err != nil {
return nil, err
}
var pickupAdjustmentPaid struct {
AmountCent int64
Count int64
}
if err := db.Table("admin_pickup_financial_adjustments").
Select(`COALESCE(SUM(settle_delta_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("settlement_mode = ?", "platform_managed").
Where("status = ?", "settled").
Where("settle_delta_cent > 0").
Where("settled_at >= ? AND settled_at <= ?", query.StartDate, query.EndDate).
Scan(&pickupAdjustmentPaid).Error; err != nil {
return nil, err
}
var offlinePending struct {
AmountCent int64
@@ -31,6 +56,30 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
Scan(&offlinePending).Error; err != nil {
return nil, err
}
var pickupPending struct {
AmountCent int64
Count int64
}
if err := db.Table("admin_pickups").
Select(`COALESCE(SUM(settle_amount_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("settlement_mode = ?", "platform_managed").
Where("offline_settlement_status = ?", "pending").
Where("settle_amount_cent > 0").
Scan(&pickupPending).Error; err != nil {
return nil, err
}
var pickupAdjustmentPending struct {
AmountCent int64
Count int64
}
if err := db.Table("admin_pickup_financial_adjustments").
Select(`COALESCE(SUM(settle_delta_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("settlement_mode = ?", "platform_managed").
Where("status = ?", "pending").
Where("settle_delta_cent > 0").
Scan(&pickupAdjustmentPending).Error; err != nil {
return nil, err
}
var withdrawalPaid struct {
AmountCent int64
@@ -84,12 +133,12 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
}
return &DisbursementSummaryDTO{
PaidAmountCent: offlinePaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent,
PaidCount: offlinePaid.Count + withdrawalPaid.Count + manualPaid.Count,
PendingPaymentAmountCent: offlinePending.AmountCent + withdrawalPending.ActualAmountCent,
PendingPaymentCount: offlinePending.Count + withdrawalPending.Count,
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent,
OfflineSettlementPaidCount: offlinePaid.Count,
PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent,
PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawalPaid.Count + manualPaid.Count,
PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawalPending.ActualAmountCent,
PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawalPending.Count,
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent,
OfflineSettlementPaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count,
OfflineSettlementPendingAmountCent: offlinePending.AmountCent,
OfflineSettlementPendingCount: offlinePending.Count,
WithdrawalAmountCent: withdrawalPaid.AmountCent,
@@ -119,6 +168,33 @@ func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuer
Scan(&offlineRows).Error; err != nil {
return nil, err
}
pickupOfflineRows := make([]dailyDisbursementSourceRow, 0)
if err := db.Table("admin_pickups").
Select(`DATE(offline_settled_at) AS date,
COALESCE(SUM(settle_amount_cent), 0) AS amount_cent,
COUNT(id) AS count`).
Where("settlement_mode = ?", "platform_managed").
Where("offline_settlement_status = ?", "settled").
Where("offline_settled_at >= ? AND offline_settled_at <= ?", query.StartDate, query.EndDate).
Group("DATE(offline_settled_at)").
Scan(&pickupOfflineRows).Error; err != nil {
return nil, err
}
pickupAdjustmentRows := make([]dailyDisbursementSourceRow, 0)
if err := db.Table("admin_pickup_financial_adjustments").
Select(`DATE(settled_at) AS date,
COALESCE(SUM(settle_delta_cent), 0) AS amount_cent,
COUNT(id) AS count`).
Where("settlement_mode = ?", "platform_managed").
Where("status = ?", "settled").
Where("settle_delta_cent > 0").
Where("settled_at >= ? AND settled_at <= ?", query.StartDate, query.EndDate).
Group("DATE(settled_at)").
Scan(&pickupAdjustmentRows).Error; err != nil {
return nil, err
}
offlineRows = append(offlineRows, pickupOfflineRows...)
offlineRows = append(offlineRows, pickupAdjustmentRows...)
withdrawalRows := make([]dailyDisbursementSourceRow, 0)
if err := db.Table("withdrawal_requests").
@@ -149,8 +225,8 @@ func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuer
date := dailyDateKey(row.Date)
item := rowsByDate[date]
item.Date = date
item.OfflineSettlementPaidAmountCent = row.AmountCent
item.OfflineSettlementPaidCount = row.Count
item.OfflineSettlementPaidAmountCent += row.AmountCent
item.OfflineSettlementPaidCount += row.Count
rowsByDate[date] = item
}
for _, row := range withdrawalRows {
@@ -43,6 +43,24 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
)`).Error; err != nil {
t.Fatalf("创建其他线下出款表失败: %v", err)
}
if err := db.Exec(`CREATE TABLE admin_pickups (
id INTEGER PRIMARY KEY,
settlement_mode TEXT NOT NULL,
offline_settlement_status TEXT NOT NULL,
settle_amount_cent INTEGER NOT NULL,
offline_settled_at DATETIME NULL
)`).Error; err != nil {
t.Fatalf("创建提号表失败: %v", err)
}
if err := db.Exec(`CREATE TABLE admin_pickup_financial_adjustments (
id INTEGER PRIMARY KEY,
settlement_mode TEXT NOT NULL,
status TEXT NOT NULL,
settle_delta_cent INTEGER NOT NULL,
settled_at DATETIME NULL
)`).Error; err != nil {
t.Fatalf("创建提号调整表失败: %v", err)
}
loc := timeutil.ShanghaiLocation()
inRangeOffline := time.Date(2026, 7, 1, 10, 0, 0, 0, loc)
@@ -63,6 +81,27 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
t.Fatalf("写入订单失败: %v", err)
}
}
for _, args := range [][]any{
{1, "platform_managed", "settled", 4000, inRangeOffline},
{2, "platform_managed", "pending", 2500, nil},
} {
if err := db.Exec(`INSERT INTO admin_pickups
(id, settlement_mode, offline_settlement_status, settle_amount_cent, offline_settled_at)
VALUES (?, ?, ?, ?, ?)`, args...).Error; err != nil {
t.Fatalf("写入提号失败: %v", err)
}
}
for _, args := range [][]any{
{1, "platform_managed", "settled", 500, inRangeManual},
{2, "platform_managed", "pending", 300, nil},
{3, "platform_managed", "recovery_pending", -200, nil},
} {
if err := db.Exec(`INSERT INTO admin_pickup_financial_adjustments
(id, settlement_mode, status, settle_delta_cent, settled_at)
VALUES (?, ?, ?, ?, ?)`, args...).Error; err != nil {
t.Fatalf("写入提号调整失败: %v", err)
}
}
for _, args := range [][]any{
{1, "paid", 2500, inRangeManual},
{2, "paid", 1000, outOfRange},
@@ -96,11 +135,11 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
if err != nil {
t.Fatalf("disbursementSummary() error = %v", err)
}
if summary.PaidAmountCent != 24400 || summary.PaidCount != 3 {
t.Fatalf("实际出款 = %d/%d, want 24400/3", summary.PaidAmountCent, summary.PaidCount)
if summary.PaidAmountCent != 28900 || summary.PaidCount != 5 {
t.Fatalf("实际出款 = %d/%d, want 28900/5", summary.PaidAmountCent, summary.PaidCount)
}
if summary.OfflineSettlementPaidAmountCent != 12000 || summary.OfflineSettlementPaidCount != 1 {
t.Fatalf("代管打款 = %d/%d, want 12000/1", summary.OfflineSettlementPaidAmountCent, summary.OfflineSettlementPaidCount)
if summary.OfflineSettlementPaidAmountCent != 16500 || summary.OfflineSettlementPaidCount != 3 {
t.Fatalf("代管打款 = %d/%d, want 16500/3", summary.OfflineSettlementPaidAmountCent, summary.OfflineSettlementPaidCount)
}
if summary.WithdrawalAmountCent != 10000 || summary.WithdrawalFeeAmountCent != 100 || summary.WithdrawalPaidAmountCent != 9900 {
t.Fatalf("提现申请/手续费/实付 = %d/%d/%d, want 10000/100/9900", summary.WithdrawalAmountCent, summary.WithdrawalFeeAmountCent, summary.WithdrawalPaidAmountCent)
@@ -108,8 +147,8 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
if summary.ManualPaidAmountCent != 2500 || summary.ManualPaidCount != 1 {
t.Fatalf("其他线下出款 = %d/%d, want 2500/1", summary.ManualPaidAmountCent, summary.ManualPaidCount)
}
if summary.PendingPaymentAmountCent != 12800 || summary.PendingPaymentCount != 2 {
t.Fatalf("待出款 = %d/%d, want 12800/2", summary.PendingPaymentAmountCent, summary.PendingPaymentCount)
if summary.PendingPaymentAmountCent != 15600 || summary.PendingPaymentCount != 4 {
t.Fatalf("待出款 = %d/%d, want 15600/4", summary.PendingPaymentAmountCent, summary.PendingPaymentCount)
}
if summary.WithdrawalReviewAmountCent != 2000 || summary.WithdrawalReviewCount != 1 {
t.Fatalf("待审核提现 = %d/%d, want 2000/1", summary.WithdrawalReviewAmountCent, summary.WithdrawalReviewCount)
@@ -122,13 +161,13 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
if len(daily) != 3 {
t.Fatalf("每日出款行数 = %d, want 3", len(daily))
}
if daily[0].Date != "2026-07-01" || daily[0].OfflineSettlementPaidAmountCent != 12000 {
if daily[0].Date != "2026-07-01" || daily[0].OfflineSettlementPaidAmountCent != 16000 {
t.Fatalf("第一日代管出款 = %+v", daily[0])
}
if daily[1].Date != "2026-07-02" || daily[1].WithdrawalPaidAmountCent != 9900 {
t.Fatalf("第二日提现出款 = %+v", daily[1])
}
if daily[2].Date != "2026-07-03" || daily[2].ManualPaidAmountCent != 2500 {
if daily[2].Date != "2026-07-03" || daily[2].ManualPaidAmountCent != 2500 || daily[2].OfflineSettlementPaidAmountCent != 500 {
t.Fatalf("第三日其他线下出款 = %+v", daily[2])
}
}
+78 -42
View File
@@ -22,54 +22,79 @@ const (
OfflineSettlementStatusNone = "none"
OfflineSettlementStatusPending = "pending"
OfflineSettlementStatusSettled = "settled"
FinancialAdjustmentStatusSettled = "settled"
FinancialAdjustmentStatusPending = "pending"
FinancialAdjustmentStatusRecovery = "recovery_pending"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrListingUnavailable = errors.New("listing unavailable")
ErrPickupNotFound = errors.New("pickup not found")
ErrPickupNotPickingUp = errors.New("pickup not in picking_up status")
ErrInvalidAmount = errors.New("invalid amount")
ErrInvalidProfit = errors.New("invalid profit amount")
ErrDuplicatePickup = errors.New("duplicate pickup in progress")
ErrOfflineSettlementCannotMark = errors.New("pickup offline settlement cannot be marked")
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrListingUnavailable = errors.New("listing unavailable")
ErrPickupNotFound = errors.New("pickup not found")
ErrPickupNotPickingUp = errors.New("pickup not in picking_up status")
ErrInvalidAmount = errors.New("invalid amount")
ErrInvalidProfit = errors.New("invalid profit amount")
ErrDuplicatePickup = errors.New("duplicate pickup in progress")
ErrOfflineSettlementCannotMark = errors.New("pickup offline settlement cannot be marked")
ErrFinancialAdjustmentInvalid = errors.New("financial adjustment invalid")
ErrFinancialAdjustmentNotFound = errors.New("financial adjustment not found")
ErrFinancialAdjustmentCannotSettle = errors.New("financial adjustment cannot be settled")
)
type PickupDTO struct {
ID uint64 `json:"id"`
PickupNo string `json:"pickup_no"`
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
AccountID uint64 `json:"account_id"`
AccountTitle string `json:"account_title"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
OwnerID uint64 `json:"owner_id"`
OwnerPhone string `json:"owner_phone"`
AdminID uint64 `json:"admin_id"`
Platform string `json:"platform"`
ShopName string `json:"shop_name"`
AccountSource string `json:"account_source"`
SourceChannel string `json:"source_channel"`
SettlementMode string `json:"settlement_mode"`
ListingPriceCent int64 `json:"listing_price_cent"`
OwnerPriceCent int64 `json:"owner_price_cent"`
WebsiteProfitCent int64 `json:"website_profit_cent"`
ProfitAmountCent int64 `json:"profit_amount_cent"`
SellerRatio float64 `json:"seller_ratio"`
BuyerRatio float64 `json:"buyer_ratio"`
AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"`
SettleAmountCent int64 `json:"settle_amount_cent"`
Status string `json:"status"`
OfflineSettlementStatus string `json:"offline_settlement_status"`
OfflineSettlementRemark string `json:"offline_settlement_remark"`
OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"`
OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"`
Remark string `json:"remark"`
CompleteRemark string `json:"complete_remark"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CancelledAt *time.Time `json:"cancelled_at,omitempty"`
ID uint64 `json:"id"`
PickupNo string `json:"pickup_no"`
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
AccountID uint64 `json:"account_id"`
AccountTitle string `json:"account_title"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
OwnerID uint64 `json:"owner_id"`
OwnerPhone string `json:"owner_phone"`
AdminID uint64 `json:"admin_id"`
Platform string `json:"platform"`
ShopName string `json:"shop_name"`
AccountSource string `json:"account_source"`
SourceChannel string `json:"source_channel"`
SettlementMode string `json:"settlement_mode"`
ListingPriceCent int64 `json:"listing_price_cent"`
OwnerPriceCent int64 `json:"owner_price_cent"`
WebsiteProfitCent int64 `json:"website_profit_cent"`
ProfitAmountCent int64 `json:"profit_amount_cent"`
ProfitAdjustmentCent int64 `json:"profit_adjustment_cent"`
EffectiveProfitAmountCent int64 `json:"effective_profit_amount_cent"`
SellerRatio float64 `json:"seller_ratio"`
BuyerRatio float64 `json:"buyer_ratio"`
AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"`
SettleAmountCent int64 `json:"settle_amount_cent"`
SettleAdjustmentCent int64 `json:"settle_adjustment_cent"`
EffectiveSettleAmountCent int64 `json:"effective_settle_amount_cent"`
Status string `json:"status"`
OfflineSettlementStatus string `json:"offline_settlement_status"`
OfflineSettlementRemark string `json:"offline_settlement_remark"`
OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"`
OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"`
Remark string `json:"remark"`
CompleteRemark string `json:"complete_remark"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CancelledAt *time.Time `json:"cancelled_at,omitempty"`
}
type FinancialAdjustmentDTO struct {
ID uint64 `json:"id"`
PickupID uint64 `json:"pickup_id"`
ProfitDeltaCent int64 `json:"profit_delta_cent"`
SettleDeltaCent int64 `json:"settle_delta_cent"`
SettlementMode string `json:"settlement_mode"`
Status string `json:"status"`
Reason string `json:"reason"`
CreatedBy uint64 `json:"created_by"`
SettledBy *uint64 `json:"settled_by,omitempty"`
SettledAt *time.Time `json:"settled_at,omitempty"`
SettlementRemark string `json:"settlement_remark"`
CreatedAt time.Time `json:"created_at"`
}
type CreateRequest struct {
@@ -95,6 +120,17 @@ type UpdateProfitRequest struct {
Reason string `json:"reason"`
}
// FinancialAdjustmentRequest 按目标金额创建完成后的差额流水,至少提供一个金额。
type FinancialAdjustmentRequest struct {
ProfitAmountCent *int64 `json:"profit_amount_cent"`
SettleAmountCent *int64 `json:"settle_amount_cent"`
Reason string `json:"reason" binding:"required,max=255"`
}
type FinancialAdjustmentSettlementRequest struct {
Remark string `json:"remark" binding:"max=255"`
}
type CancelRequest struct {
Reason string `json:"reason" binding:"required"`
}
@@ -112,6 +112,67 @@ func (h *Handler) UpdateProfit(c *gin.Context) {
response.OK(c, item)
}
// CreateFinancialAdjustment 为已完成提号创建利润或打款调整。
func (h *Handler) CreateFinancialAdjustment(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req FinancialAdjustmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "调整金额或原因不正确")
return
}
item, err := h.service.CreateFinancialAdjustment(c.Request.Context(), id, req, adminID, auditMeta(c))
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, item)
}
// ListFinancialAdjustments 返回提号完成后的财务调整历史。
func (h *Handler) ListFinancialAdjustments(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
items, err := h.service.ListFinancialAdjustments(c.Request.Context(), id)
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, items)
}
// SettleFinancialAdjustment 确认代管提号的补款或追回调整已线下处理。
func (h *Handler) SettleFinancialAdjustment(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req FinancialAdjustmentSettlementRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "确认备注格式不正确")
return
}
if err := h.service.SettleFinancialAdjustment(c.Request.Context(), id, req, adminID, auditMeta(c)); err != nil {
writePickupError(c, err)
return
}
response.OK(c, gin.H{"settled": true})
}
// Cancel 取消提号(管理员)
func (h *Handler) Cancel(c *gin.Context) {
adminID, ok := currentAdminID(c)
@@ -221,6 +282,12 @@ func writePickupError(c *gin.Context, err error) {
response.BadRequest(c, "利润金额不正确")
case errors.Is(err, ErrOfflineSettlementCannotMark):
response.BadRequest(c, "当前提号不可确认线下结算")
case errors.Is(err, ErrFinancialAdjustmentInvalid):
response.BadRequest(c, "调整金额、状态或原因不正确")
case errors.Is(err, ErrFinancialAdjustmentNotFound):
response.Error(c, http.StatusNotFound, "pickup_financial_adjustment_not_found", "财务调整记录不存在")
case errors.Is(err, ErrFinancialAdjustmentCannotSettle):
response.BadRequest(c, "当前调整不可确认处理")
default:
response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error())
}
+265 -41
View File
@@ -316,6 +316,14 @@ func (r *Repository) MarkOfflineSettlement(ctx context.Context, pickupID uint64,
pickup.OfflineSettlementStatus != OfflineSettlementStatusPending || pickup.SettleAmountCent <= 0 {
return ErrOfflineSettlementCannotMark
}
pendingTotals, err := pendingPickupFinancialAdjustmentTotals(tx, pickup.ID)
if err != nil {
return err
}
effectiveSettleAmountCent := pickup.SettleAmountCent + pendingTotals.SettleAdjustmentCent
if effectiveSettleAmountCent < 0 {
return ErrFinancialAdjustmentInvalid
}
now := time.Now()
pickup.OfflineSettlementStatus = OfflineSettlementStatusSettled
@@ -325,6 +333,18 @@ func (r *Repository) MarkOfflineSettlement(ctx context.Context, pickupID uint64,
if err := tx.Save(&pickup).Error; err != nil {
return err
}
if pendingTotals.Count > 0 {
if err := tx.Model(&model.AdminPickupFinancialAdjustment{}).
Where("pickup_id = ? AND status IN ?", pickup.ID, []string{FinancialAdjustmentStatusPending, FinancialAdjustmentStatusRecovery}).
Updates(map[string]any{
"status": FinancialAdjustmentStatusSettled,
"settled_by": adminID,
"settled_at": now,
"settlement_remark": pickup.OfflineSettlementRemark,
}).Error; err != nil {
return err
}
}
bid := pickup.ID
return auditlog.Append(tx, auditlog.Entry{
@@ -336,7 +356,8 @@ func (r *Repository) MarkOfflineSettlement(ctx context.Context, pickupID uint64,
Meta: meta,
Detail: map[string]any{
"pickup_no": pickup.PickupNo,
"settle_amount_cent": pickup.SettleAmountCent,
"settle_amount_cent": effectiveSettleAmountCent,
"settled_adjustment_count": pendingTotals.Count,
"before_offline_settlement_status": OfflineSettlementStatusPending,
"after_offline_settlement_status": pickup.OfflineSettlementStatus,
"remark": pickup.OfflineSettlementRemark,
@@ -394,6 +415,170 @@ func (r *Repository) UpdateProfit(ctx context.Context, pickupID uint64, req Upda
return r.FindByID(ctx, pickupID)
}
// CreateFinancialAdjustment 为已完成提号追加财务差额,不改写完成时的原始金额。
func (r *Repository) CreateFinancialAdjustment(ctx context.Context, pickupID uint64, req FinancialAdjustmentRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var pickup model.AdminPickup
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pickup, pickupID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPickupNotFound
}
return err
}
if pickup.Status != StatusCompleted {
return ErrFinancialAdjustmentInvalid
}
totals, err := pickupFinancialAdjustmentTotals(tx, pickup.ID)
if err != nil {
return err
}
profitDeltaCent := int64(0)
if req.ProfitAmountCent != nil {
profitDeltaCent = *req.ProfitAmountCent - (pickup.ProfitAmountCent + totals.ProfitAdjustmentCent)
}
settleDeltaCent := int64(0)
if req.SettleAmountCent != nil {
settleDeltaCent = *req.SettleAmountCent - (pickup.SettleAmountCent + totals.SettleAdjustmentCent)
}
if profitDeltaCent == 0 && settleDeltaCent == 0 {
return ErrFinancialAdjustmentInvalid
}
status := FinancialAdjustmentStatusSettled
if settleDeltaCent != 0 {
if pickupRequiresOfflineSettlement(pickup) && pickup.OfflineSettlementStatus == OfflineSettlementStatusPending {
// 原结算尚未付款时,差额随同一次线下打款处理。
status = FinancialAdjustmentStatusPending
} else if settleDeltaCent < 0 {
status = FinancialAdjustmentStatusRecovery
} else if pickupRequiresOfflineSettlement(pickup) {
status = FinancialAdjustmentStatusPending
}
}
adjustment := model.AdminPickupFinancialAdjustment{
PickupID: pickup.ID,
ProfitDeltaCent: profitDeltaCent,
SettleDeltaCent: settleDeltaCent,
SettlementMode: pickup.SettlementMode,
Status: status,
Reason: strings.TrimSpace(req.Reason),
CreatedBy: adminID,
}
if err := tx.Create(&adjustment).Error; err != nil {
return err
}
// 站内钱包的补款在创建调整时立即到账;扣款统一转待追回,避免余额被扣成负数。
if pickup.SettlementMode == SettlementModeOwnerWallet && settleDeltaCent > 0 {
if err := wallet.AppendEntries(tx, wallet.Entry{
UserID: pickup.OwnerID,
Direction: "in",
AmountCent: settleDeltaCent,
BalanceType: "available",
BizType: "admin_pickup_adjustment",
BizNo: fmt.Sprintf("%s-A%d", pickup.PickupNo, adjustment.ID),
Remark: fmt.Sprintf("管理员提号结算补款:%s", pickup.PickupNo),
}); err != nil {
return err
}
}
bid := adjustment.ID
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "pickup_financial_adjustment_create",
BizType: "admin_pickup_financial_adjustment",
BizID: &bid,
Meta: meta,
Detail: map[string]any{
"pickup_id": pickup.ID,
"pickup_no": pickup.PickupNo,
"profit_delta_cent": profitDeltaCent,
"settle_delta_cent": settleDeltaCent,
"settlement_mode": pickup.SettlementMode,
"adjustment_status": status,
"reason": adjustment.Reason,
},
})
})
if err != nil {
return nil, err
}
return r.FindByID(ctx, pickupID)
}
// ListFinancialAdjustments 返回提号的不可变财务调整流水。
func (r *Repository) ListFinancialAdjustments(ctx context.Context, pickupID uint64) ([]FinancialAdjustmentDTO, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
var count int64
if err := r.db.WithContext(ctx).Model(&model.AdminPickup{}).Where("id = ?", pickupID).Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return nil, ErrPickupNotFound
}
items := make([]FinancialAdjustmentDTO, 0)
if err := r.db.WithContext(ctx).Model(&model.AdminPickupFinancialAdjustment{}).
Where("pickup_id = ?", pickupID).
Order("id DESC").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// SettleFinancialAdjustment 确认平台代管的补款或追回调整已在线下处理。
func (r *Repository) SettleFinancialAdjustment(ctx context.Context, adjustmentID uint64, req FinancialAdjustmentSettlementRequest, adminID uint64, meta auditlog.Meta) error {
if r == nil || r.db == nil {
return ErrDependencyUnavailable
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var adjustment model.AdminPickupFinancialAdjustment
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&adjustment, adjustmentID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFinancialAdjustmentNotFound
}
return err
}
if adjustment.Status != FinancialAdjustmentStatusPending && adjustment.Status != FinancialAdjustmentStatusRecovery {
return ErrFinancialAdjustmentCannotSettle
}
beforeStatus := adjustment.Status
now := time.Now()
adjustment.Status = FinancialAdjustmentStatusSettled
adjustment.SettledBy = &adminID
adjustment.SettledAt = &now
adjustment.SettlementRemark = strings.TrimSpace(req.Remark)
if err := tx.Save(&adjustment).Error; err != nil {
return err
}
bid := adjustment.ID
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "pickup_financial_adjustment_settle",
BizType: "admin_pickup_financial_adjustment",
BizID: &bid,
Meta: meta,
Detail: map[string]any{
"pickup_id": adjustment.PickupID,
"profit_delta_cent": adjustment.ProfitDeltaCent,
"settle_delta_cent": adjustment.SettleDeltaCent,
"before_status": beforeStatus,
"after_status": adjustment.Status,
"settlement_remark": adjustment.SettlementRemark,
},
})
})
}
// Cancel 取消提号:恢复 listing 为 published + 解锁 in_transaction,账号恢复可租。
func (r *Repository) Cancel(ctx context.Context, pickupID uint64, reason string, adminID uint64, meta auditlog.Meta) error {
if r == nil || r.db == nil {
@@ -560,6 +745,8 @@ func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int, sellerView
if sellerView {
item.WebsiteProfitCent = 0
item.ProfitAmountCent = 0
item.ProfitAdjustmentCent = 0
item.EffectiveProfitAmountCent = 0
item.BuyerRatio = 0
item.ShopName = ""
item.SourceChannel = ""
@@ -618,7 +805,9 @@ func (r *Repository) ListAvailableListings(ctx context.Context, query AvailableL
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).Table("admin_pickups AS p").
Select("p.*, l.listing_no, a.title AS account_title, a.server_region, a.login_platform, owner.phone AS owner_phone").
Select(`p.*, l.listing_no, a.title AS account_title, a.server_region, a.login_platform, owner.phone AS owner_phone,
COALESCE((SELECT SUM(fa.profit_delta_cent) FROM admin_pickup_financial_adjustments AS fa WHERE fa.pickup_id = p.id), 0) AS profit_adjustment_cent,
COALESCE((SELECT SUM(fa.settle_delta_cent) FROM admin_pickup_financial_adjustments AS fa WHERE fa.pickup_id = p.id), 0) AS settle_adjustment_cent`).
Joins("JOIN rental_listings AS l ON l.id = p.listing_id").
Joins("JOIN game_accounts AS a ON a.id = p.account_id").
Joins("JOIN users AS owner ON owner.id = p.owner_id")
@@ -626,52 +815,87 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
type pickupRow struct {
model.AdminPickup
ListingNo string
AccountTitle string
ServerRegion string
LoginPlatform string
OwnerPhone string
ListingNo string
AccountTitle string
ServerRegion string
LoginPlatform string
OwnerPhone string
ProfitAdjustmentCent int64
SettleAdjustmentCent int64
}
func (row pickupRow) toDTO() PickupDTO {
return PickupDTO{
ID: row.ID,
PickupNo: row.PickupNo,
ListingID: row.ListingID,
ListingNo: row.ListingNo,
AccountID: row.AccountID,
AccountTitle: row.AccountTitle,
ServerRegion: row.ServerRegion,
LoginPlatform: row.LoginPlatform,
OwnerID: row.OwnerID,
OwnerPhone: row.OwnerPhone,
AdminID: row.AdminID,
Platform: row.Platform,
ShopName: row.ShopName,
AccountSource: row.AccountSource,
SourceChannel: row.SourceChannel,
SettlementMode: row.SettlementMode,
ListingPriceCent: row.ListingPriceCent,
OwnerPriceCent: row.OwnerPriceCent,
WebsiteProfitCent: row.WebsiteProfitCent,
ProfitAmountCent: row.ProfitAmountCent,
SellerRatio: row.SellerRatio,
BuyerRatio: row.BuyerRatio,
AccountSnapshot: row.AccountSnapshot,
SettleAmountCent: row.SettleAmountCent,
Status: row.Status,
OfflineSettlementStatus: row.OfflineSettlementStatus,
OfflineSettlementRemark: row.OfflineSettlementRemark,
OfflineSettledBy: row.OfflineSettledBy,
OfflineSettledAt: row.OfflineSettledAt,
Remark: row.Remark,
CompleteRemark: row.CompleteRemark,
CreatedAt: row.CreatedAt,
CompletedAt: row.CompletedAt,
CancelledAt: row.CancelledAt,
ID: row.ID,
PickupNo: row.PickupNo,
ListingID: row.ListingID,
ListingNo: row.ListingNo,
AccountID: row.AccountID,
AccountTitle: row.AccountTitle,
ServerRegion: row.ServerRegion,
LoginPlatform: row.LoginPlatform,
OwnerID: row.OwnerID,
OwnerPhone: row.OwnerPhone,
AdminID: row.AdminID,
Platform: row.Platform,
ShopName: row.ShopName,
AccountSource: row.AccountSource,
SourceChannel: row.SourceChannel,
SettlementMode: row.SettlementMode,
ListingPriceCent: row.ListingPriceCent,
OwnerPriceCent: row.OwnerPriceCent,
WebsiteProfitCent: row.WebsiteProfitCent,
ProfitAmountCent: row.ProfitAmountCent,
ProfitAdjustmentCent: row.ProfitAdjustmentCent,
EffectiveProfitAmountCent: row.ProfitAmountCent + row.ProfitAdjustmentCent,
SellerRatio: row.SellerRatio,
BuyerRatio: row.BuyerRatio,
AccountSnapshot: row.AccountSnapshot,
SettleAmountCent: row.SettleAmountCent,
SettleAdjustmentCent: row.SettleAdjustmentCent,
EffectiveSettleAmountCent: row.SettleAmountCent + row.SettleAdjustmentCent,
Status: row.Status,
OfflineSettlementStatus: row.OfflineSettlementStatus,
OfflineSettlementRemark: row.OfflineSettlementRemark,
OfflineSettledBy: row.OfflineSettledBy,
OfflineSettledAt: row.OfflineSettledAt,
Remark: row.Remark,
CompleteRemark: row.CompleteRemark,
CreatedAt: row.CreatedAt,
CompletedAt: row.CompletedAt,
CancelledAt: row.CancelledAt,
}
}
type pickupAdjustmentTotals struct {
ProfitAdjustmentCent int64
SettleAdjustmentCent int64
}
type pendingPickupAdjustmentTotals struct {
SettleAdjustmentCent int64
Count int64
}
func pickupFinancialAdjustmentTotals(db *gorm.DB, pickupID uint64) (pickupAdjustmentTotals, error) {
var totals pickupAdjustmentTotals
err := db.Model(&model.AdminPickupFinancialAdjustment{}).
Select(`COALESCE(SUM(profit_delta_cent), 0) AS profit_adjustment_cent,
COALESCE(SUM(settle_delta_cent), 0) AS settle_adjustment_cent`).
Where("pickup_id = ?", pickupID).
Scan(&totals).Error
return totals, err
}
func pendingPickupFinancialAdjustmentTotals(db *gorm.DB, pickupID uint64) (pendingPickupAdjustmentTotals, error) {
var totals pendingPickupAdjustmentTotals
err := db.Model(&model.AdminPickupFinancialAdjustment{}).
Select(`COALESCE(SUM(settle_delta_cent), 0) AS settle_adjustment_cent, COUNT(id) AS count`).
Where("pickup_id = ? AND status IN ?", pickupID, []string{FinancialAdjustmentStatusPending, FinancialAdjustmentStatusRecovery}).
Scan(&totals).Error
return totals, err
}
func pickupSettlementMode(listingMode, accountSource string) string {
if listingMode == SettlementModePlatformManaged || accountSource == AccountSourceExternalPlatformManaged {
return SettlementModePlatformManaged
@@ -380,6 +380,161 @@ func TestRepositoryCompletePlatformManagedPickupUsesOfflineSettlement(t *testing
}
}
func TestRepositoryFinancialAdjustmentCreditsInternalPickupWallet(t *testing.T) {
repo, db := newPickupTestRepo(t)
listing := seedAvailableListing(t, db, "LST-ADJUST-INTERNAL", "站内补款账号", false)
pickup, err := repo.Create(t.Context(), CreateRequest{ListingID: listing.ID, ProfitAmountCent: 1000}, 99, auditlog.Meta{})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if _, err = repo.Complete(t.Context(), pickup.ID, CompleteRequest{SettleAmountCent: 12000}, 99, auditlog.Meta{}); err != nil {
t.Fatalf("Complete() error = %v", err)
}
updated, err := repo.CreateFinancialAdjustment(t.Context(), pickup.ID, FinancialAdjustmentRequest{
ProfitAmountCent: ptrInt64(1500),
SettleAmountCent: ptrInt64(13500),
Reason: "补录实际成交金额",
}, 100, auditlog.Meta{})
if err != nil {
t.Fatalf("CreateFinancialAdjustment() error = %v", err)
}
assertInt64(t, "原始利润不变", updated.ProfitAmountCent, 1000)
assertInt64(t, "有效利润", updated.EffectiveProfitAmountCent, 1500)
assertInt64(t, "原始结算额不变", updated.SettleAmountCent, 12000)
assertInt64(t, "有效结算额", updated.EffectiveSettleAmountCent, 13500)
var adjustment model.AdminPickupFinancialAdjustment
if err := db.Where("pickup_id = ?", pickup.ID).First(&adjustment).Error; err != nil {
t.Fatalf("查询调整流水失败: %v", err)
}
if adjustment.Status != FinancialAdjustmentStatusSettled || adjustment.ProfitDeltaCent != 500 || adjustment.SettleDeltaCent != 1500 {
t.Fatalf("调整流水 = %+v", adjustment)
}
var ledger model.WalletLedger
if err := db.Where("biz_type = ?", "admin_pickup_adjustment").First(&ledger).Error; err != nil {
t.Fatalf("站内补款应生成钱包流水: %v", err)
}
assertInt64(t, "站内补款钱包入账", ledger.AmountCent, 1500)
}
func TestRepositoryFinancialAdjustmentUsesRecoveryForInternalReduction(t *testing.T) {
repo, db := newPickupTestRepo(t)
listing := seedAvailableListing(t, db, "LST-ADJUST-RECOVERY", "站内追回账号", false)
pickup, err := repo.Create(t.Context(), CreateRequest{ListingID: listing.ID}, 99, auditlog.Meta{})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if _, err = repo.Complete(t.Context(), pickup.ID, CompleteRequest{SettleAmountCent: 12000}, 99, auditlog.Meta{}); err != nil {
t.Fatalf("Complete() error = %v", err)
}
updated, err := repo.CreateFinancialAdjustment(t.Context(), pickup.ID, FinancialAdjustmentRequest{
SettleAmountCent: ptrInt64(10000),
Reason: "实际应结算金额减少",
}, 100, auditlog.Meta{})
if err != nil {
t.Fatalf("CreateFinancialAdjustment() error = %v", err)
}
assertInt64(t, "追回后的有效结算额", updated.EffectiveSettleAmountCent, 10000)
var adjustment model.AdminPickupFinancialAdjustment
if err := db.Where("pickup_id = ?", pickup.ID).First(&adjustment).Error; err != nil {
t.Fatalf("查询追回调整失败: %v", err)
}
if adjustment.Status != FinancialAdjustmentStatusRecovery || adjustment.SettleDeltaCent != -2000 {
t.Fatalf("追回调整 = %+v", adjustment)
}
var ledgerCount int64
if err := db.Model(&model.WalletLedger{}).Where("biz_type = ?", "admin_pickup_adjustment").Count(&ledgerCount).Error; err != nil {
t.Fatalf("统计补款流水失败: %v", err)
}
if ledgerCount != 0 {
t.Fatalf("待追回不能直接扣钱包, got %d 条流水", ledgerCount)
}
}
func TestRepositoryFinancialAdjustmentSettlesPlatformManagedPickupSeparately(t *testing.T) {
repo, db := newPickupTestRepo(t)
listing := seedAvailableListing(t, db, "LST-ADJUST-PLATFORM", "代管调整账号", true)
if err := db.Create(&model.ListingUpload{UploaderName: "外部号主", ListingID: &listing.ID, Status: "listing_created"}).Error; err != nil {
t.Fatalf("创建外部上传记录失败: %v", err)
}
pickup, err := repo.Create(t.Context(), CreateRequest{ListingID: listing.ID}, 99, auditlog.Meta{})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if _, err = repo.Complete(t.Context(), pickup.ID, CompleteRequest{SettleAmountCent: 12000}, 99, auditlog.Meta{}); err != nil {
t.Fatalf("Complete() error = %v", err)
}
if _, err = repo.MarkOfflineSettlement(t.Context(), pickup.ID, OfflineSettlementRequest{}, 100, auditlog.Meta{}); err != nil {
t.Fatalf("MarkOfflineSettlement() error = %v", err)
}
updated, err := repo.CreateFinancialAdjustment(t.Context(), pickup.ID, FinancialAdjustmentRequest{
ProfitAmountCent: ptrInt64(2000),
SettleAmountCent: ptrInt64(13500),
Reason: "补录代管打款",
}, 101, auditlog.Meta{})
if err != nil {
t.Fatalf("CreateFinancialAdjustment() error = %v", err)
}
if updated.OfflineSettlementStatus != OfflineSettlementStatusSettled {
t.Fatalf("原线下结算状态不应被重置: %q", updated.OfflineSettlementStatus)
}
adjustments, err := repo.ListFinancialAdjustments(t.Context(), pickup.ID)
if err != nil || len(adjustments) != 1 {
t.Fatalf("ListFinancialAdjustments() = %+v, %v", adjustments, err)
}
if adjustments[0].Status != FinancialAdjustmentStatusPending {
t.Fatalf("代管补款状态 = %q, want pending", adjustments[0].Status)
}
if err := repo.SettleFinancialAdjustment(t.Context(), adjustments[0].ID, FinancialAdjustmentSettlementRequest{Remark: "已补款"}, 102, auditlog.Meta{}); err != nil {
t.Fatalf("SettleFinancialAdjustment() error = %v", err)
}
adjustments, err = repo.ListFinancialAdjustments(t.Context(), pickup.ID)
if err != nil || adjustments[0].Status != FinancialAdjustmentStatusSettled || adjustments[0].SettledBy == nil || *adjustments[0].SettledBy != 102 {
t.Fatalf("确认后的调整流水 = %+v, %v", adjustments, err)
}
}
func TestRepositoryOfflineSettlementProcessesPendingPickupAdjustments(t *testing.T) {
repo, db := newPickupTestRepo(t)
listing := seedAvailableListing(t, db, "LST-ADJUST-WITH-SETTLEMENT", "代管合并结算账号", true)
if err := db.Create(&model.ListingUpload{UploaderName: "外部号主", ListingID: &listing.ID, Status: "listing_created"}).Error; err != nil {
t.Fatalf("创建外部上传记录失败: %v", err)
}
pickup, err := repo.Create(t.Context(), CreateRequest{ListingID: listing.ID}, 99, auditlog.Meta{})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if _, err = repo.Complete(t.Context(), pickup.ID, CompleteRequest{SettleAmountCent: 12000}, 99, auditlog.Meta{}); err != nil {
t.Fatalf("Complete() error = %v", err)
}
if _, err = repo.CreateFinancialAdjustment(t.Context(), pickup.ID, FinancialAdjustmentRequest{
SettleAmountCent: ptrInt64(10000),
Reason: "付款前修正应付金额",
}, 100, auditlog.Meta{}); err != nil {
t.Fatalf("CreateFinancialAdjustment() error = %v", err)
}
adjustments, err := repo.ListFinancialAdjustments(t.Context(), pickup.ID)
if err != nil || len(adjustments) != 1 || adjustments[0].Status != FinancialAdjustmentStatusPending {
t.Fatalf("结算前调整流水 = %+v, %v", adjustments, err)
}
settled, err := repo.MarkOfflineSettlement(t.Context(), pickup.ID, OfflineSettlementRequest{Remark: "按修正后金额打款"}, 101, auditlog.Meta{})
if err != nil {
t.Fatalf("MarkOfflineSettlement() error = %v", err)
}
if settled.OfflineSettlementStatus != OfflineSettlementStatusSettled || settled.EffectiveSettleAmountCent != 10000 {
t.Fatalf("合并线下结算结果 = %+v", settled)
}
adjustments, err = repo.ListFinancialAdjustments(t.Context(), pickup.ID)
if err != nil || adjustments[0].Status != FinancialAdjustmentStatusSettled || adjustments[0].SettledBy == nil || *adjustments[0].SettledBy != 101 {
t.Fatalf("合并结算后的调整流水 = %+v, %v", adjustments, err)
}
}
func ptrInt64(value int64) *int64 {
return &value
}
func assertInt64(t *testing.T, name string, got, want int64) {
t.Helper()
if got != want {
@@ -409,6 +564,7 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) {
&model.RentalListing{},
&model.ListingUpload{},
&model.AdminPickup{},
&model.AdminPickupFinancialAdjustment{},
&model.WalletAccount{},
&model.WalletLedger{},
&model.AuditLog{},
@@ -2,6 +2,7 @@ package pickup
import (
"context"
"strings"
"hfb_sys/backend/internal/auditlog"
)
@@ -63,6 +64,39 @@ func (s *Service) UpdateProfit(ctx context.Context, pickupID uint64, req UpdateP
return s.repo.UpdateProfit(ctx, pickupID, req, adminID, meta)
}
func (s *Service) CreateFinancialAdjustment(ctx context.Context, pickupID uint64, req FinancialAdjustmentRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if pickupID == 0 || (req.ProfitAmountCent == nil && req.SettleAmountCent == nil) || strings.TrimSpace(req.Reason) == "" {
return nil, ErrFinancialAdjustmentInvalid
}
if (req.ProfitAmountCent != nil && *req.ProfitAmountCent < 0) || (req.SettleAmountCent != nil && *req.SettleAmountCent < 0) {
return nil, ErrFinancialAdjustmentInvalid
}
return s.repo.CreateFinancialAdjustment(ctx, pickupID, req, adminID, meta)
}
func (s *Service) ListFinancialAdjustments(ctx context.Context, pickupID uint64) ([]FinancialAdjustmentDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if pickupID == 0 {
return nil, ErrPickupNotFound
}
return s.repo.ListFinancialAdjustments(ctx, pickupID)
}
func (s *Service) SettleFinancialAdjustment(ctx context.Context, adjustmentID uint64, req FinancialAdjustmentSettlementRequest, adminID uint64, meta auditlog.Meta) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if adjustmentID == 0 {
return ErrFinancialAdjustmentNotFound
}
return s.repo.SettleFinancialAdjustment(ctx, adjustmentID, req, adminID, meta)
}
func (s *Service) Cancel(ctx context.Context, pickupID uint64, reason string, adminID uint64, meta auditlog.Meta) error {
if s.repo == nil {
return ErrDependencyUnavailable