From 8fb7d3bac0d8cb1bb79e08006cab57f614920935 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 9 Aug 2026 20:20:39 +0800 Subject: [PATCH] feat(pickup): support platform managed offline settlement --- backend/internal/model/pickup.go | 57 +++--- backend/internal/modules/pickup/dto.go | 87 ++++---- backend/internal/modules/pickup/handler.go | 28 ++- backend/internal/modules/pickup/repository.go | 193 +++++++++++++----- .../modules/pickup/repository_test.go | 83 ++++++++ backend/internal/modules/pickup/service.go | 10 + backend/internal/router/router.go | 1 + ...000046_admin_pickup_offline_settlement.sql | 21 ++ .../src/features/admin/api/adminPickup.ts | 22 ++ .../admin/views/AdminPickupDetailView.vue | 27 +++ .../features/admin/views/AdminPickupView.vue | 95 ++++++++- frontend/src/shared/utils/auditLabels.ts | 3 +- 12 files changed, 499 insertions(+), 128 deletions(-) create mode 100644 backend/migrations/000046_admin_pickup_offline_settlement.sql diff --git a/backend/internal/model/pickup.go b/backend/internal/model/pickup.go index 1ae2d58..2446076 100644 --- a/backend/internal/model/pickup.go +++ b/backend/internal/model/pickup.go @@ -7,33 +7,38 @@ import ( ) // AdminPickup 管理员线下提号记录。 -// 独立于 rental_orders,不进入正常订单状态机与财务统计口径, -// 完成时通过 wallet.AppendEntries 给卖家增加可用余额。 +// 独立于 rental_orders,不进入正常订单状态机与财务统计口径。 +// 站内上传完成时入卖家钱包;平台代管完成后进入线下结算。 type AdminPickup struct { - ID uint64 `gorm:"primaryKey" json:"id"` - PickupNo string `gorm:"size:64;not null;uniqueIndex" json:"pickup_no"` - ListingID uint64 `gorm:"not null;index" json:"listing_id"` - AccountID uint64 `gorm:"not null;index" json:"account_id"` - OwnerID uint64 `gorm:"not null;index" json:"owner_id"` - AdminID uint64 `gorm:"not null" json:"admin_id"` - Platform string `gorm:"size:32;not null;default:''" json:"platform"` - ShopName string `gorm:"size:100;not null;default:'';index" json:"shop_name"` - AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"` - SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"` - ListingPriceCent int64 `gorm:"not null;default:0" json:"listing_price_cent"` - OwnerPriceCent int64 `gorm:"not null;default:0" json:"owner_price_cent"` - WebsiteProfitCent int64 `gorm:"not null;default:0" json:"website_profit_cent"` - ProfitAmountCent int64 `gorm:"not null;default:0" json:"profit_amount_cent"` - SellerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"seller_ratio"` - BuyerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"buyer_ratio"` - AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"` - SettleAmountCent int64 `gorm:"not null;default:0" json:"settle_amount_cent"` - Status string `gorm:"size:20;not null;default:'picking_up';index" json:"status"` - Remark string `gorm:"size:255;not null;default:''" json:"remark"` - CompleteRemark string `gorm:"size:255;not null;default:''" 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 `gorm:"primaryKey" json:"id"` + PickupNo string `gorm:"size:64;not null;uniqueIndex" json:"pickup_no"` + ListingID uint64 `gorm:"not null;index" json:"listing_id"` + AccountID uint64 `gorm:"not null;index" json:"account_id"` + OwnerID uint64 `gorm:"not null;index" json:"owner_id"` + AdminID uint64 `gorm:"not null" json:"admin_id"` + Platform string `gorm:"size:32;not null;default:''" json:"platform"` + ShopName string `gorm:"size:100;not null;default:'';index" json:"shop_name"` + AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"` + SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"` + SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"` + ListingPriceCent int64 `gorm:"not null;default:0" json:"listing_price_cent"` + OwnerPriceCent int64 `gorm:"not null;default:0" json:"owner_price_cent"` + WebsiteProfitCent int64 `gorm:"not null;default:0" json:"website_profit_cent"` + ProfitAmountCent int64 `gorm:"not null;default:0" json:"profit_amount_cent"` + SellerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"seller_ratio"` + BuyerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"buyer_ratio"` + AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"` + SettleAmountCent int64 `gorm:"not null;default:0" json:"settle_amount_cent"` + Status string `gorm:"size:20;not null;default:'picking_up';index" json:"status"` + OfflineSettlementStatus string `gorm:"size:16;not null;default:'none';index" json:"offline_settlement_status"` + OfflineSettlementRemark string `gorm:"size:255;not null;default:''" json:"offline_settlement_remark"` + OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"` + OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"` + Remark string `gorm:"size:255;not null;default:''" json:"remark"` + CompleteRemark string `gorm:"size:255;not null;default:''" json:"complete_remark"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CancelledAt *time.Time `json:"cancelled_at,omitempty"` } func (AdminPickup) TableName() string { diff --git a/backend/internal/modules/pickup/dto.go b/backend/internal/modules/pickup/dto.go index 6afa71b..269a235 100644 --- a/backend/internal/modules/pickup/dto.go +++ b/backend/internal/modules/pickup/dto.go @@ -17,48 +17,59 @@ const ( AccountSourceExternalPlatformManaged = "external_platform_managed" ListingSourceExternal = "external" ListingSourceInternal = "internal" + SettlementModeOwnerWallet = "owner_wallet" + SettlementModePlatformManaged = "platform_managed" + OfflineSettlementStatusNone = "none" + OfflineSettlementStatusPending = "pending" + OfflineSettlementStatusSettled = "settled" ) 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") + 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") ) 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"` - 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"` - 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"` + 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"` } type CreateRequest struct { @@ -75,6 +86,10 @@ type CompleteRequest struct { CompleteRemark string `json:"complete_remark"` } +type OfflineSettlementRequest struct { + Remark string `json:"remark" binding:"max=255"` +} + type UpdateProfitRequest struct { ProfitAmountCent int64 `json:"profit_amount_cent" binding:"min=0"` Reason string `json:"reason"` diff --git a/backend/internal/modules/pickup/handler.go b/backend/internal/modules/pickup/handler.go index ffd56e1..99e469a 100644 --- a/backend/internal/modules/pickup/handler.go +++ b/backend/internal/modules/pickup/handler.go @@ -40,7 +40,7 @@ func (h *Handler) Create(c *gin.Context) { response.OK(c, item) } -// Complete 完成提号结算(管理员) +// Complete 完成提号(管理员) func (h *Handler) Complete(c *gin.Context) { adminID, ok := currentAdminID(c) if !ok { @@ -64,6 +64,30 @@ func (h *Handler) Complete(c *gin.Context) { response.OK(c, item) } +// MarkOfflineSettlement 确认平台代管提号已完成实际线下打款(管理员)。 +func (h *Handler) MarkOfflineSettlement(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req OfflineSettlementRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "线下结算备注格式不正确") + return + } + item, err := h.service.MarkOfflineSettlement(c.Request.Context(), id, req, adminID, auditMeta(c)) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, item) +} + // UpdateProfit 修改提号中的线下利润(管理员) func (h *Handler) UpdateProfit(c *gin.Context) { adminID, ok := currentAdminID(c) @@ -195,6 +219,8 @@ func writePickupError(c *gin.Context, err error) { response.BadRequest(c, "结算金额不正确") case errors.Is(err, ErrInvalidProfit): response.BadRequest(c, "利润金额不正确") + case errors.Is(err, ErrOfflineSettlementCannotMark): + 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 1cd1fc4..affa73e 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -91,6 +91,7 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint ShopName: strings.TrimSpace(req.ShopName), AccountSource: accountSource, SourceChannel: sourceChannel, + SettlementMode: pickupSettlementMode(listing.SettlementMode, accountSource), ListingPriceCent: priceSnapshot.ListingPriceCent, OwnerPriceCent: priceSnapshot.OwnerPriceCent, WebsiteProfitCent: priceSnapshot.WebsiteProfitCent, @@ -168,7 +169,7 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint return r.FindByID(ctx, createdID) } -// Complete 完成提号:给卖家加可用余额,listing 置 completed(售出终态,不可再上架)。 +// Complete 完成提号。站内上传结算至卖家钱包;平台代管提号转为待线下结算。 func (r *Repository) Complete(ctx context.Context, pickupID uint64, req CompleteRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable @@ -233,28 +234,44 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete return err } - if err := wallet.AppendEntries(tx, wallet.Entry{ - UserID: pickup.OwnerID, - Direction: "in", - AmountCent: req.SettleAmountCent, - BalanceType: "available", - BizType: "admin_pickup_settle", - BizNo: pickup.PickupNo, - Remark: fmt.Sprintf("管理员提号结算:%s", pickup.PickupNo), - }); err != nil { - return err - } - bid := pickup.ID - if err := notification.Append(tx, notification.Entry{ - UserID: pickup.OwnerID, - Type: "pickup", - Title: "提号已完成,已结算到账", - Content: fmt.Sprintf("账号「%s」提号完成,已结算 %s 到您的钱包。", account.Title, money.FormatWithSymbol(req.SettleAmountCent)), - BizType: "pickup", - BizID: &bid, - }); err != nil { - return err + if pickupRequiresOfflineSettlement(pickup) { + pickup.OfflineSettlementStatus = OfflineSettlementStatusPending + if err := tx.Save(&pickup).Error; err != nil { + return err + } + if err := notification.Append(tx, notification.Entry{ + UserID: pickup.OwnerID, + Type: "pickup", + Title: "提号已完成,待线下结算", + Content: fmt.Sprintf("账号「%s」提号完成,结算金额 %s 将通过线下方式支付。", account.Title, money.FormatWithSymbol(req.SettleAmountCent)), + BizType: "pickup", + BizID: &bid, + }); err != nil { + return err + } + } else { + if err := wallet.AppendEntries(tx, wallet.Entry{ + UserID: pickup.OwnerID, + Direction: "in", + AmountCent: req.SettleAmountCent, + BalanceType: "available", + BizType: "admin_pickup_settle", + BizNo: pickup.PickupNo, + Remark: fmt.Sprintf("管理员提号结算:%s", pickup.PickupNo), + }); err != nil { + return err + } + if err := notification.Append(tx, notification.Entry{ + UserID: pickup.OwnerID, + Type: "pickup", + Title: "提号已完成,已结算到账", + Content: fmt.Sprintf("账号「%s」提号完成,已结算 %s 到您的钱包。", account.Title, money.FormatWithSymbol(req.SettleAmountCent)), + BizType: "pickup", + BizID: &bid, + }); err != nil { + return err + } } if err := auditlog.Append(tx, auditlog.Entry{ @@ -265,9 +282,11 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete BizID: &bid, Meta: meta, Detail: map[string]any{ - "pickup_no": pickup.PickupNo, - "settle_amount_cent": req.SettleAmountCent, - "profit_amount_cent": pickup.ProfitAmountCent, + "pickup_no": pickup.PickupNo, + "settle_amount_cent": req.SettleAmountCent, + "profit_amount_cent": pickup.ProfitAmountCent, + "settlement_mode": pickup.SettlementMode, + "offline_settlement_status": pickup.OfflineSettlementStatus, }, }); err != nil { return err @@ -280,6 +299,56 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete return r.FindByID(ctx, pickupID) } +// MarkOfflineSettlement 确认平台代管提号已完成实际线下打款。 +func (r *Repository) MarkOfflineSettlement(ctx context.Context, pickupID uint64, req OfflineSettlementRequest, 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 !pickupRequiresOfflineSettlement(pickup) || pickup.Status != StatusCompleted || + pickup.OfflineSettlementStatus != OfflineSettlementStatusPending || pickup.SettleAmountCent <= 0 { + return ErrOfflineSettlementCannotMark + } + + now := time.Now() + pickup.OfflineSettlementStatus = OfflineSettlementStatusSettled + pickup.OfflineSettlementRemark = strings.TrimSpace(req.Remark) + pickup.OfflineSettledBy = &adminID + pickup.OfflineSettledAt = &now + if err := tx.Save(&pickup).Error; err != nil { + return err + } + + bid := pickup.ID + return auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "pickup_offline_settlement", + BizType: "admin_pickup", + BizID: &bid, + Meta: meta, + Detail: map[string]any{ + "pickup_no": pickup.PickupNo, + "settle_amount_cent": pickup.SettleAmountCent, + "before_offline_settlement_status": OfflineSettlementStatusPending, + "after_offline_settlement_status": pickup.OfflineSettlementStatus, + "remark": pickup.OfflineSettlementRemark, + }, + }) + }) + if err != nil { + return nil, err + } + return r.FindByID(ctx, pickupID) +} + // UpdateProfit 修改提号中的线下利润,已完成和已取消的提号不可修改。 func (r *Repository) UpdateProfit(ctx context.Context, pickupID uint64, req UpdateProfitRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { if r == nil || r.db == nil { @@ -566,38 +635,54 @@ type pickupRow struct { 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, - 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, - 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, + 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, } } +func pickupSettlementMode(listingMode, accountSource string) string { + if listingMode == SettlementModePlatformManaged || accountSource == AccountSourceExternalPlatformManaged { + return SettlementModePlatformManaged + } + return SettlementModeOwnerWallet +} + +func pickupRequiresOfflineSettlement(pickup model.AdminPickup) bool { + return pickup.SettlementMode == SettlementModePlatformManaged +} + type availableListingRow struct { ID uint64 ListingNo string diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index 15d7cf0..30de82e 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -299,6 +299,87 @@ func TestRepositoryCreateSnapshotsExternalAccountSource(t *testing.T) { } } +func TestRepositoryCompleteInternalPickupCreditsWallet(t *testing.T) { + repo, db := newPickupTestRepo(t) + listing := seedAvailableListing(t, db, "LST-COMPLETE-INTERNAL", "站内结算账号", false) + pickup, err := repo.Create(t.Context(), CreateRequest{ListingID: listing.ID}, 99, auditlog.Meta{}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + completed, err := repo.Complete(t.Context(), pickup.ID, CompleteRequest{SettleAmountCent: 12000}, 99, auditlog.Meta{}) + if err != nil { + t.Fatalf("Complete() error = %v", err) + } + if completed.Status != StatusCompleted || completed.SettlementMode != SettlementModeOwnerWallet { + t.Fatalf("站内提号完成状态 = %q/%q", completed.Status, completed.SettlementMode) + } + if completed.OfflineSettlementStatus != OfflineSettlementStatusNone { + t.Fatalf("站内提号线下结算状态 = %q, want none", completed.OfflineSettlementStatus) + } + + var ledger model.WalletLedger + if err := db.Where("biz_type = ? AND biz_no = ?", "admin_pickup_settle", pickup.PickupNo).First(&ledger).Error; err != nil { + t.Fatalf("站内提号应生成钱包流水: %v", err) + } + assertInt64(t, "站内提号钱包入账", ledger.AmountCent, 12000) +} + +func TestRepositoryCompletePlatformManagedPickupUsesOfflineSettlement(t *testing.T) { + repo, db := newPickupTestRepo(t) + listing := seedAvailableListing(t, db, "LST-COMPLETE-EXTERNAL", "代管结算账号", true) + if err := db.Create(&model.ListingUpload{ + UploaderName: "外部号主", + SourceChannel: "闲鱼", + 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 pickup.SettlementMode != SettlementModePlatformManaged { + t.Fatalf("平台代管提号结算模式 = %q", pickup.SettlementMode) + } + + completed, err := repo.Complete(t.Context(), pickup.ID, CompleteRequest{SettleAmountCent: 13500}, 99, auditlog.Meta{}) + if err != nil { + t.Fatalf("Complete() error = %v", err) + } + if completed.OfflineSettlementStatus != OfflineSettlementStatusPending { + t.Fatalf("平台代管提号线下结算状态 = %q, want pending", completed.OfflineSettlementStatus) + } + if completed.OfflineSettledAt != nil || completed.OfflineSettledBy != nil { + t.Fatalf("待线下结算不应记录确认信息: %+v", completed) + } + var ledgerCount int64 + if err := db.Model(&model.WalletLedger{}). + Where("biz_type = ? AND biz_no = ?", "admin_pickup_settle", pickup.PickupNo). + Count(&ledgerCount).Error; err != nil { + t.Fatalf("统计钱包流水失败: %v", err) + } + if ledgerCount != 0 { + t.Fatalf("平台代管提号不应生成钱包流水, got %d", ledgerCount) + } + + settled, err := repo.MarkOfflineSettlement(t.Context(), pickup.ID, OfflineSettlementRequest{Remark: "已转账"}, 100, auditlog.Meta{}) + if err != nil { + t.Fatalf("MarkOfflineSettlement() error = %v", err) + } + if settled.OfflineSettlementStatus != OfflineSettlementStatusSettled || settled.OfflineSettledAt == nil || settled.OfflineSettledBy == nil || *settled.OfflineSettledBy != 100 { + t.Fatalf("平台代管线下结算确认结果 = %+v", settled) + } + if settled.OfflineSettlementRemark != "已转账" { + t.Fatalf("线下结算备注 = %q", settled.OfflineSettlementRemark) + } + var audit model.AuditLog + if err := db.Where("action = ? AND biz_id = ?", "pickup_offline_settlement", pickup.ID).First(&audit).Error; err != nil { + t.Fatalf("确认线下结算应写审计日志: %v", err) + } +} + func assertInt64(t *testing.T, name string, got, want int64) { t.Helper() if got != want { @@ -328,6 +409,8 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) { &model.RentalListing{}, &model.ListingUpload{}, &model.AdminPickup{}, + &model.WalletAccount{}, + &model.WalletLedger{}, &model.AuditLog{}, &model.Notification{}, &model.ListingStatusEvent{}, diff --git a/backend/internal/modules/pickup/service.go b/backend/internal/modules/pickup/service.go index 4d1fd11..b2bfeb2 100644 --- a/backend/internal/modules/pickup/service.go +++ b/backend/internal/modules/pickup/service.go @@ -40,6 +40,16 @@ func (s *Service) Complete(ctx context.Context, pickupID uint64, req CompleteReq return s.repo.Complete(ctx, pickupID, req, adminID, meta) } +func (s *Service) MarkOfflineSettlement(ctx context.Context, pickupID uint64, req OfflineSettlementRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if pickupID == 0 { + return nil, ErrPickupNotFound + } + return s.repo.MarkOfflineSettlement(ctx, pickupID, req, adminID, meta) +} + func (s *Service) UpdateProfit(ctx context.Context, pickupID uint64, req UpdateProfitRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 9824ae6..481c66f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -598,6 +598,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/pickups/available-listings", requirePerm("order:pickup"), pickupHandler.AvailableListings) 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) diff --git a/backend/migrations/000046_admin_pickup_offline_settlement.sql b/backend/migrations/000046_admin_pickup_offline_settlement.sql new file mode 100644 index 0000000..f724458 --- /dev/null +++ b/backend/migrations/000046_admin_pickup_offline_settlement.sql @@ -0,0 +1,21 @@ +-- +goose Up + +ALTER TABLE admin_pickups + ADD COLUMN settlement_mode VARCHAR(32) NOT NULL DEFAULT 'owner_wallet' COMMENT '结算模式快照: owner_wallet号主钱包/platform_managed平台线下结算' AFTER source_channel, + ADD COLUMN offline_settlement_status VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '线下结算状态: none无/pending待线下结算/settled已线下结算' AFTER status, + ADD COLUMN offline_settlement_remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '线下结算备注' AFTER offline_settlement_status, + ADD COLUMN offline_settled_by BIGINT UNSIGNED NULL COMMENT '确认线下结算的管理员ID' AFTER offline_settlement_remark, + ADD COLUMN offline_settled_at DATETIME NULL COMMENT '线下结算确认时间' AFTER offline_settled_by, + ADD KEY idx_admin_pickups_offline_settlement (settlement_mode, offline_settlement_status, offline_settled_at); + +-- 历史提号已按钱包路径结算,保留默认 owner_wallet/none,避免迁移后产生重复线下付款。 + +-- +goose Down + +ALTER TABLE admin_pickups + DROP KEY idx_admin_pickups_offline_settlement, + DROP COLUMN offline_settled_at, + DROP COLUMN offline_settled_by, + DROP COLUMN offline_settlement_remark, + DROP COLUMN offline_settlement_status, + DROP COLUMN settlement_mode; diff --git a/frontend/src/features/admin/api/adminPickup.ts b/frontend/src/features/admin/api/adminPickup.ts index 8287b2d..f52540f 100644 --- a/frontend/src/features/admin/api/adminPickup.ts +++ b/frontend/src/features/admin/api/adminPickup.ts @@ -2,6 +2,8 @@ import { apiClient } from '@/shared/api/client' 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 AvailableListingSourceType = '' | 'external' | 'internal' export interface AdminPickup { @@ -20,6 +22,7 @@ export interface AdminPickup { shop_name: string account_source: PickupAccountSource source_channel: string + settlement_mode: PickupSettlementMode listing_price_cent: number owner_price_cent: number website_profit_cent: number @@ -29,6 +32,10 @@ export interface AdminPickup { account_snapshot?: Record settle_amount_cent: number status: string + offline_settlement_status: PickupOfflineSettlementStatus + offline_settlement_remark: string + offline_settled_by?: number + offline_settled_at?: string remark: string complete_remark: string created_at: string @@ -79,6 +86,10 @@ export interface AdminPickupProfitUpdateRequest { reason?: string } +export interface AdminPickupOfflineSettlementRequest { + remark?: string +} + export interface AdminPickupListQuery { page?: number page_size?: number @@ -150,6 +161,17 @@ export async function completeAdminPickup(id: number, req: AdminPickupCompleteRe return data.data } +export async function confirmAdminPickupOfflineSettlement( + id: number, + req: AdminPickupOfflineSettlementRequest = {} +) { + const { data } = await apiClient.post>( + `/admin/pickups/${id}/offline-settlement`, + req + ) + return data.data +} + export async function updateAdminPickupProfit(id: number, req: AdminPickupProfitUpdateRequest) { const { data } = await apiClient.put>(`/admin/pickups/${id}/profit`, req) return data.data diff --git a/frontend/src/features/admin/views/AdminPickupDetailView.vue b/frontend/src/features/admin/views/AdminPickupDetailView.vue index 0fc4744..a69b17b 100644 --- a/frontend/src/features/admin/views/AdminPickupDetailView.vue +++ b/frontend/src/features/admin/views/AdminPickupDetailView.vue @@ -157,6 +157,17 @@ function pickupSourceTagType(source: AdminPickup['account_source']) { return source === 'external_platform_managed' ? 'primary' : 'info' } +function settlementModeLabel(row: AdminPickup) { + return row.settlement_mode === 'platform_managed' ? '平台线下结算' : '号主钱包结算' +} + +function offlineSettlementLabel(row: AdminPickup) { + if (row.settlement_mode !== 'platform_managed') return '钱包已结算' + if (row.offline_settlement_status === 'pending') return '待线下结算' + if (row.offline_settlement_status === 'settled') return '已线下结算' + return '待完成提号' +} + function ratioText(value: number) { const ratio = Number(value || 0) if (ratio <= 0) return '-' @@ -317,6 +328,18 @@ function readSnapshotResources(summary: Record | null): Snapsho {{ pickup.source_channel }} +
+
结算方式
+
{{ settlementModeLabel(pickup) }}
+
+
+
结算状态
+
{{ offlineSettlementLabel(pickup) }}
+
+
+
线下结算时间
+
{{ formatDateTime(pickup.offline_settled_at, '待确认') }}
+
交易渠道
{{ pickup.platform || '-' }}
@@ -371,6 +394,10 @@ function readSnapshotResources(summary: Record | null): Snapsho
完成备注
{{ pickup.complete_remark || '-' }}
+
+
线下结算备注
+
{{ pickup.offline_settlement_remark || '-' }}
+
diff --git a/frontend/src/features/admin/views/AdminPickupView.vue b/frontend/src/features/admin/views/AdminPickupView.vue index 36259cd..af0afc4 100644 --- a/frontend/src/features/admin/views/AdminPickupView.vue +++ b/frontend/src/features/admin/views/AdminPickupView.vue @@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { EditPen, Plus, Refresh, Search } from '@element-plus/icons-vue' import { cancelAdminPickup, + confirmAdminPickupOfflineSettlement, completeAdminPickup, createAdminPickup, fetchAdminPickups, @@ -36,6 +37,7 @@ const createDialogVisible = ref(false) const completeDialogVisible = ref(false) const profitDialogVisible = ref(false) const activePickupId = ref(0) +const activePickup = ref(null) const profitSaving = ref(false) const createForm = reactive({ @@ -184,12 +186,41 @@ async function handleCreate() { function openCompleteDialog(row: AdminPickup) { activePickupId.value = row.id + activePickup.value = row completeForm.settle_amount = 0 completeForm.profit_amount = centToYuan(row.profit_amount_cent) completeForm.complete_remark = '' completeDialogVisible.value = true } +async function handleConfirmOfflineSettlement(row: AdminPickup) { + try { + const { value } = await ElMessageBox.prompt( + `确认已向卖家线下支付 ${formatCentWithSymbol(row.settle_amount_cent)}?`, + '确认线下结算', + { + confirmButtonText: '确认已打款', + cancelButtonText: '取消', + inputType: 'textarea', + inputPlaceholder: '结算备注(可选)', + inputValidator: input => input.length <= 255 || '备注不能超过 255 个字符', + } + ) + loading.value = true + try { + await confirmAdminPickupOfflineSettlement(row.id, { remark: value }) + ElMessage.success('已确认线下结算') + loadList() + } catch (e: unknown) { + ElMessage.error(errorMessage(e) || '确认失败') + } finally { + loading.value = false + } + } catch { + /* 用户放弃 */ + } +} + function openProfitDialog(row: AdminPickup) { activePickupId.value = row.id profitForm.profit_amount = centToYuan(row.profit_amount_cent) @@ -213,7 +244,11 @@ async function handleComplete() { profit_amount_cent: yuanToCent(completeForm.profit_amount), complete_remark: completeForm.complete_remark, }) - ElMessage.success('提号已完成,已给卖家结算') + ElMessage.success( + activePickup.value?.settlement_mode === 'platform_managed' + ? '提号已完成,待线下结算' + : '提号已完成,已给卖家结算' + ) completeDialogVisible.value = false loadList() } catch (e: unknown) { @@ -310,6 +345,24 @@ function pickupSourceTagType(source: PickupAccountSource) { return isExternalSource(source) ? 'primary' : 'info' } +function isPlatformManaged(row: AdminPickup) { + return row.settlement_mode === 'platform_managed' +} + +function offlineSettlementLabel(row: AdminPickup) { + if (!isPlatformManaged(row)) return '钱包已结算' + if (row.offline_settlement_status === 'pending') return '待线下结算' + if (row.offline_settlement_status === 'settled') return '已线下结算' + return '待完成提号' +} + +function offlineSettlementTagType(row: AdminPickup) { + if (!isPlatformManaged(row)) return 'success' + if (row.offline_settlement_status === 'pending') return 'warning' + if (row.offline_settlement_status === 'settled') return 'success' + return 'info' +} + function listingSourceText(item: AvailableListing) { const label = pickupSourceLabel(item.account_source) return item.source_channel ? `${label} · ${item.source_channel}` : label @@ -326,7 +379,7 @@ function listingOptionLabel(item: AvailableListing) {
创建提号 @@ -416,10 +469,17 @@ function listingOptionLabel(item: AvailableListing) { + + + - +