From 80e55ba28188039fa91e3e63ceb93102f22fb477 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 19:52:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8F=90=E5=8F=B7=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=90=8E=E8=B4=A2=E5=8A=A1=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/pickup_financial_adjustment.go | 23 ++ .../modules/adminfinance/dashboard.go | 39 ++- .../modules/adminfinance/dashboard_test.go | 57 ++++ .../modules/adminfinance/disbursement.go | 92 +++++- .../modules/adminfinance/disbursement_test.go | 55 +++- backend/internal/modules/pickup/dto.go | 120 ++++--- backend/internal/modules/pickup/handler.go | 67 ++++ backend/internal/modules/pickup/repository.go | 306 +++++++++++++++--- .../modules/pickup/repository_test.go | 156 +++++++++ backend/internal/modules/pickup/service.go | 34 ++ backend/internal/router/router.go | 3 + ...049_admin_pickup_financial_adjustments.sql | 23 ++ .../src/features/admin/api/adminPickup.ts | 59 ++++ .../admin/views/AdminPickupDetailView.vue | 227 ++++++++++++- .../features/admin/views/AdminPickupView.vue | 122 ++++++- 15 files changed, 1272 insertions(+), 111 deletions(-) create mode 100644 backend/internal/model/pickup_financial_adjustment.go create mode 100644 backend/migrations/000049_admin_pickup_financial_adjustments.sql diff --git a/backend/internal/model/pickup_financial_adjustment.go b/backend/internal/model/pickup_financial_adjustment.go new file mode 100644 index 0000000..83430c5 --- /dev/null +++ b/backend/internal/model/pickup_financial_adjustment.go @@ -0,0 +1,23 @@ +package model + +import "time" + +// AdminPickupFinancialAdjustment 保存已完成提号的财务调整,原始结算快照不可修改。 +type AdminPickupFinancialAdjustment struct { + ID uint64 `gorm:"primaryKey" json:"id"` + PickupID uint64 `gorm:"not null;index" json:"pickup_id"` + ProfitDeltaCent int64 `gorm:"not null;default:0" json:"profit_delta_cent"` + SettleDeltaCent int64 `gorm:"not null;default:0" json:"settle_delta_cent"` + SettlementMode string `gorm:"size:32;not null" json:"settlement_mode"` + Status string `gorm:"size:24;not null;index" json:"status"` + Reason string `gorm:"size:255;not null" json:"reason"` + CreatedBy uint64 `gorm:"not null" json:"created_by"` + SettledBy *uint64 `json:"settled_by,omitempty"` + SettledAt *time.Time `json:"settled_at,omitempty"` + SettlementRemark string `gorm:"size:255;not null;default:''" json:"settlement_remark"` + CreatedAt time.Time `json:"created_at"` +} + +func (AdminPickupFinancialAdjustment) TableName() string { + return "admin_pickup_financial_adjustments" +} diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 86f87ec..88d7f41 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -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 diff --git a/backend/internal/modules/adminfinance/dashboard_test.go b/backend/internal/modules/adminfinance/dashboard_test.go index 61e3d1d..4394252 100644 --- a/backend/internal/modules/adminfinance/dashboard_test.go +++ b/backend/internal/modules/adminfinance/dashboard_test.go @@ -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) + } +} diff --git a/backend/internal/modules/adminfinance/disbursement.go b/backend/internal/modules/adminfinance/disbursement.go index 51f5db4..02c40bb 100644 --- a/backend/internal/modules/adminfinance/disbursement.go +++ b/backend/internal/modules/adminfinance/disbursement.go @@ -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 { diff --git a/backend/internal/modules/adminfinance/disbursement_test.go b/backend/internal/modules/adminfinance/disbursement_test.go index 42d70e7..47e5efb 100644 --- a/backend/internal/modules/adminfinance/disbursement_test.go +++ b/backend/internal/modules/adminfinance/disbursement_test.go @@ -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]) } } diff --git a/backend/internal/modules/pickup/dto.go b/backend/internal/modules/pickup/dto.go index 269a235..8713a4b 100644 --- a/backend/internal/modules/pickup/dto.go +++ b/backend/internal/modules/pickup/dto.go @@ -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"` } diff --git a/backend/internal/modules/pickup/handler.go b/backend/internal/modules/pickup/handler.go index 99e469a..287da59 100644 --- a/backend/internal/modules/pickup/handler.go +++ b/backend/internal/modules/pickup/handler.go @@ -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()) } diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index affa73e..13d9c8b 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -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 diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index 30de82e..6d936e4 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -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{}, diff --git a/backend/internal/modules/pickup/service.go b/backend/internal/modules/pickup/service.go index b2bfeb2..874aff5 100644 --- a/backend/internal/modules/pickup/service.go +++ b/backend/internal/modules/pickup/service.go @@ -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 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 76119ca..4504235 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -617,11 +617,14 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/pickups", requirePerm("order:pickup"), pickupHandler.List) adminRoutes.GET("/pickups/shop-options", requirePerm("order:pickup"), pickupHandler.ShopOptions) adminRoutes.GET("/pickups/available-listings", requirePerm("order:pickup"), pickupHandler.AvailableListings) + adminRoutes.GET("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.ListFinancialAdjustments) + adminRoutes.POST("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.CreateFinancialAdjustment) adminRoutes.GET("/pickups/:id", requirePerm("order:pickup"), pickupHandler.Detail) adminRoutes.POST("/pickups/:id/complete", requirePerm("order:pickup"), pickupHandler.Complete) adminRoutes.POST("/pickups/:id/offline-settlement", requirePerm("order:pickup"), pickupHandler.MarkOfflineSettlement) adminRoutes.PUT("/pickups/:id/profit", requirePerm("order:pickup"), pickupHandler.UpdateProfit) adminRoutes.POST("/pickups/:id/cancel", requirePerm("order:pickup"), pickupHandler.Cancel) + adminRoutes.POST("/pickup-financial-adjustments/:id/settle", requirePerm("order:pickup"), pickupHandler.SettleFinancialAdjustment) adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin) adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview) diff --git a/backend/migrations/000049_admin_pickup_financial_adjustments.sql b/backend/migrations/000049_admin_pickup_financial_adjustments.sql new file mode 100644 index 0000000..ea59aca --- /dev/null +++ b/backend/migrations/000049_admin_pickup_financial_adjustments.sql @@ -0,0 +1,23 @@ +-- +goose Up + +CREATE TABLE admin_pickup_financial_adjustments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + pickup_id BIGINT UNSIGNED NOT NULL COMMENT '关联提号ID', + profit_delta_cent BIGINT NOT NULL DEFAULT 0 COMMENT '利润调整差额(分,可正负)', + settle_delta_cent BIGINT NOT NULL DEFAULT 0 COMMENT '打款调整差额(分,可正负)', + settlement_mode VARCHAR(32) NOT NULL COMMENT '调整时结算模式快照', + status VARCHAR(24) NOT NULL COMMENT '打款调整状态: settled已处理/pending待付款/recovery_pending待追回', + reason VARCHAR(255) NOT NULL COMMENT '调整原因', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建管理员ID', + settled_by BIGINT UNSIGNED NULL COMMENT '确认付款或追回的管理员ID', + settled_at DATETIME NULL COMMENT '确认付款或追回时间', + settlement_remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '确认备注', + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_pickup_financial_adjustment_pickup (pickup_id, created_at), + KEY idx_pickup_financial_adjustment_status (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员提号完成后的财务调整流水'; + +-- +goose Down + +DROP TABLE IF EXISTS admin_pickup_financial_adjustments; diff --git a/frontend/src/features/admin/api/adminPickup.ts b/frontend/src/features/admin/api/adminPickup.ts index f52540f..3800a76 100644 --- a/frontend/src/features/admin/api/adminPickup.ts +++ b/frontend/src/features/admin/api/adminPickup.ts @@ -4,6 +4,7 @@ import type { ApiResponse, PaginatedResult } from '@/shared/types/types' export type PickupAccountSource = 'internal' | 'external_platform_managed' export type PickupSettlementMode = 'owner_wallet' | 'platform_managed' export type PickupOfflineSettlementStatus = 'none' | 'pending' | 'settled' +export type PickupFinancialAdjustmentStatus = 'settled' | 'pending' | 'recovery_pending' export type AvailableListingSourceType = '' | 'external' | 'internal' export interface AdminPickup { @@ -27,10 +28,14 @@ export interface AdminPickup { owner_price_cent: number website_profit_cent: number profit_amount_cent: number + profit_adjustment_cent: number + effective_profit_amount_cent: number seller_ratio: number buyer_ratio: number account_snapshot?: Record settle_amount_cent: number + settle_adjustment_cent: number + effective_settle_amount_cent: number status: string offline_settlement_status: PickupOfflineSettlementStatus offline_settlement_remark: string @@ -43,6 +48,21 @@ export interface AdminPickup { cancelled_at?: string } +export interface AdminPickupFinancialAdjustment { + id: number + pickup_id: number + profit_delta_cent: number + settle_delta_cent: number + settlement_mode: PickupSettlementMode + status: PickupFinancialAdjustmentStatus + reason: string + created_by: number + settled_by?: number + settled_at?: string + settlement_remark: string + created_at: string +} + export interface AvailableListing { id: number listing_no: string @@ -90,6 +110,16 @@ export interface AdminPickupOfflineSettlementRequest { remark?: string } +export interface AdminPickupFinancialAdjustmentRequest { + profit_amount_cent?: number + settle_amount_cent?: number + reason: string +} + +export interface AdminPickupFinancialAdjustmentSettlementRequest { + remark?: string +} + export interface AdminPickupListQuery { page?: number page_size?: number @@ -177,6 +207,35 @@ export async function updateAdminPickupProfit(id: number, req: AdminPickupProfit return data.data } +export async function createAdminPickupFinancialAdjustment( + id: number, + req: AdminPickupFinancialAdjustmentRequest +) { + const { data } = await apiClient.post>( + `/admin/pickups/${id}/financial-adjustments`, + req + ) + return data.data +} + +export async function fetchAdminPickupFinancialAdjustments(id: string | number) { + const { data } = await apiClient.get>( + `/admin/pickups/${id}/financial-adjustments` + ) + return Array.isArray(data.data) ? data.data : [] +} + +export async function settleAdminPickupFinancialAdjustment( + id: number, + req: AdminPickupFinancialAdjustmentSettlementRequest = {} +) { + const { data } = await apiClient.post>( + `/admin/pickup-financial-adjustments/${id}/settle`, + req + ) + return data.data +} + export async function cancelAdminPickup(id: number, reason: string) { const { data } = await apiClient.post>( `/admin/pickups/${id}/cancel`, diff --git a/frontend/src/features/admin/views/AdminPickupDetailView.vue b/frontend/src/features/admin/views/AdminPickupDetailView.vue index a69b17b..af09824 100644 --- a/frontend/src/features/admin/views/AdminPickupDetailView.vue +++ b/frontend/src/features/admin/views/AdminPickupDetailView.vue @@ -1,13 +1,17 @@