feat(pickup): support platform managed offline settlement
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user