feat(pickup): support platform managed offline settlement
This commit is contained in:
@@ -7,33 +7,38 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// AdminPickup 管理员线下提号记录。
|
// AdminPickup 管理员线下提号记录。
|
||||||
// 独立于 rental_orders,不进入正常订单状态机与财务统计口径,
|
// 独立于 rental_orders,不进入正常订单状态机与财务统计口径。
|
||||||
// 完成时通过 wallet.AppendEntries 给卖家增加可用余额。
|
// 站内上传完成时入卖家钱包;平台代管完成后进入线下结算。
|
||||||
type AdminPickup struct {
|
type AdminPickup struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
PickupNo string `gorm:"size:64;not null;uniqueIndex" json:"pickup_no"`
|
PickupNo string `gorm:"size:64;not null;uniqueIndex" json:"pickup_no"`
|
||||||
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
|
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
|
||||||
AccountID uint64 `gorm:"not null;index" json:"account_id"`
|
AccountID uint64 `gorm:"not null;index" json:"account_id"`
|
||||||
OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
|
OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
|
||||||
AdminID uint64 `gorm:"not null" json:"admin_id"`
|
AdminID uint64 `gorm:"not null" json:"admin_id"`
|
||||||
Platform string `gorm:"size:32;not null;default:''" json:"platform"`
|
Platform string `gorm:"size:32;not null;default:''" json:"platform"`
|
||||||
ShopName string `gorm:"size:100;not null;default:'';index" json:"shop_name"`
|
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"`
|
AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"`
|
||||||
SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"`
|
SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"`
|
||||||
ListingPriceCent int64 `gorm:"not null;default:0" json:"listing_price_cent"`
|
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
|
||||||
OwnerPriceCent int64 `gorm:"not null;default:0" json:"owner_price_cent"`
|
ListingPriceCent int64 `gorm:"not null;default:0" json:"listing_price_cent"`
|
||||||
WebsiteProfitCent int64 `gorm:"not null;default:0" json:"website_profit_cent"`
|
OwnerPriceCent int64 `gorm:"not null;default:0" json:"owner_price_cent"`
|
||||||
ProfitAmountCent int64 `gorm:"not null;default:0" json:"profit_amount_cent"`
|
WebsiteProfitCent int64 `gorm:"not null;default:0" json:"website_profit_cent"`
|
||||||
SellerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"seller_ratio"`
|
ProfitAmountCent int64 `gorm:"not null;default:0" json:"profit_amount_cent"`
|
||||||
BuyerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"buyer_ratio"`
|
SellerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"seller_ratio"`
|
||||||
AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"`
|
BuyerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"buyer_ratio"`
|
||||||
SettleAmountCent int64 `gorm:"not null;default:0" json:"settle_amount_cent"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"`
|
||||||
Status string `gorm:"size:20;not null;default:'picking_up';index" json:"status"`
|
SettleAmountCent int64 `gorm:"not null;default:0" json:"settle_amount_cent"`
|
||||||
Remark string `gorm:"size:255;not null;default:''" json:"remark"`
|
Status string `gorm:"size:20;not null;default:'picking_up';index" json:"status"`
|
||||||
CompleteRemark string `gorm:"size:255;not null;default:''" json:"complete_remark"`
|
OfflineSettlementStatus string `gorm:"size:16;not null;default:'none';index" json:"offline_settlement_status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
OfflineSettlementRemark string `gorm:"size:255;not null;default:''" json:"offline_settlement_remark"`
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"`
|
||||||
CancelledAt *time.Time `json:"cancelled_at,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 {
|
func (AdminPickup) TableName() string {
|
||||||
|
|||||||
@@ -17,48 +17,59 @@ const (
|
|||||||
AccountSourceExternalPlatformManaged = "external_platform_managed"
|
AccountSourceExternalPlatformManaged = "external_platform_managed"
|
||||||
ListingSourceExternal = "external"
|
ListingSourceExternal = "external"
|
||||||
ListingSourceInternal = "internal"
|
ListingSourceInternal = "internal"
|
||||||
|
SettlementModeOwnerWallet = "owner_wallet"
|
||||||
|
SettlementModePlatformManaged = "platform_managed"
|
||||||
|
OfflineSettlementStatusNone = "none"
|
||||||
|
OfflineSettlementStatusPending = "pending"
|
||||||
|
OfflineSettlementStatusSettled = "settled"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
ErrListingUnavailable = errors.New("listing unavailable")
|
ErrListingUnavailable = errors.New("listing unavailable")
|
||||||
ErrPickupNotFound = errors.New("pickup not found")
|
ErrPickupNotFound = errors.New("pickup not found")
|
||||||
ErrPickupNotPickingUp = errors.New("pickup not in picking_up status")
|
ErrPickupNotPickingUp = errors.New("pickup not in picking_up status")
|
||||||
ErrInvalidAmount = errors.New("invalid amount")
|
ErrInvalidAmount = errors.New("invalid amount")
|
||||||
ErrInvalidProfit = errors.New("invalid profit amount")
|
ErrInvalidProfit = errors.New("invalid profit amount")
|
||||||
ErrDuplicatePickup = errors.New("duplicate pickup in progress")
|
ErrDuplicatePickup = errors.New("duplicate pickup in progress")
|
||||||
|
ErrOfflineSettlementCannotMark = errors.New("pickup offline settlement cannot be marked")
|
||||||
)
|
)
|
||||||
|
|
||||||
type PickupDTO struct {
|
type PickupDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
PickupNo string `json:"pickup_no"`
|
PickupNo string `json:"pickup_no"`
|
||||||
ListingID uint64 `json:"listing_id"`
|
ListingID uint64 `json:"listing_id"`
|
||||||
ListingNo string `json:"listing_no"`
|
ListingNo string `json:"listing_no"`
|
||||||
AccountID uint64 `json:"account_id"`
|
AccountID uint64 `json:"account_id"`
|
||||||
AccountTitle string `json:"account_title"`
|
AccountTitle string `json:"account_title"`
|
||||||
ServerRegion string `json:"server_region"`
|
ServerRegion string `json:"server_region"`
|
||||||
LoginPlatform string `json:"login_platform"`
|
LoginPlatform string `json:"login_platform"`
|
||||||
OwnerID uint64 `json:"owner_id"`
|
OwnerID uint64 `json:"owner_id"`
|
||||||
OwnerPhone string `json:"owner_phone"`
|
OwnerPhone string `json:"owner_phone"`
|
||||||
AdminID uint64 `json:"admin_id"`
|
AdminID uint64 `json:"admin_id"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
ShopName string `json:"shop_name"`
|
ShopName string `json:"shop_name"`
|
||||||
AccountSource string `json:"account_source"`
|
AccountSource string `json:"account_source"`
|
||||||
SourceChannel string `json:"source_channel"`
|
SourceChannel string `json:"source_channel"`
|
||||||
ListingPriceCent int64 `json:"listing_price_cent"`
|
SettlementMode string `json:"settlement_mode"`
|
||||||
OwnerPriceCent int64 `json:"owner_price_cent"`
|
ListingPriceCent int64 `json:"listing_price_cent"`
|
||||||
WebsiteProfitCent int64 `json:"website_profit_cent"`
|
OwnerPriceCent int64 `json:"owner_price_cent"`
|
||||||
ProfitAmountCent int64 `json:"profit_amount_cent"`
|
WebsiteProfitCent int64 `json:"website_profit_cent"`
|
||||||
SellerRatio float64 `json:"seller_ratio"`
|
ProfitAmountCent int64 `json:"profit_amount_cent"`
|
||||||
BuyerRatio float64 `json:"buyer_ratio"`
|
SellerRatio float64 `json:"seller_ratio"`
|
||||||
AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"`
|
BuyerRatio float64 `json:"buyer_ratio"`
|
||||||
SettleAmountCent int64 `json:"settle_amount_cent"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"`
|
||||||
Status string `json:"status"`
|
SettleAmountCent int64 `json:"settle_amount_cent"`
|
||||||
Remark string `json:"remark"`
|
Status string `json:"status"`
|
||||||
CompleteRemark string `json:"complete_remark"`
|
OfflineSettlementStatus string `json:"offline_settlement_status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
OfflineSettlementRemark string `json:"offline_settlement_remark"`
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"`
|
||||||
CancelledAt *time.Time `json:"cancelled_at,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 {
|
type CreateRequest struct {
|
||||||
@@ -75,6 +86,10 @@ type CompleteRequest struct {
|
|||||||
CompleteRemark string `json:"complete_remark"`
|
CompleteRemark string `json:"complete_remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OfflineSettlementRequest struct {
|
||||||
|
Remark string `json:"remark" binding:"max=255"`
|
||||||
|
}
|
||||||
|
|
||||||
type UpdateProfitRequest struct {
|
type UpdateProfitRequest struct {
|
||||||
ProfitAmountCent int64 `json:"profit_amount_cent" binding:"min=0"`
|
ProfitAmountCent int64 `json:"profit_amount_cent" binding:"min=0"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
response.OK(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete 完成提号结算(管理员)
|
// Complete 完成提号(管理员)
|
||||||
func (h *Handler) Complete(c *gin.Context) {
|
func (h *Handler) Complete(c *gin.Context) {
|
||||||
adminID, ok := currentAdminID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -64,6 +64,30 @@ func (h *Handler) Complete(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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 修改提号中的线下利润(管理员)
|
// UpdateProfit 修改提号中的线下利润(管理员)
|
||||||
func (h *Handler) UpdateProfit(c *gin.Context) {
|
func (h *Handler) UpdateProfit(c *gin.Context) {
|
||||||
adminID, ok := currentAdminID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
@@ -195,6 +219,8 @@ func writePickupError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "结算金额不正确")
|
response.BadRequest(c, "结算金额不正确")
|
||||||
case errors.Is(err, ErrInvalidProfit):
|
case errors.Is(err, ErrInvalidProfit):
|
||||||
response.BadRequest(c, "利润金额不正确")
|
response.BadRequest(c, "利润金额不正确")
|
||||||
|
case errors.Is(err, ErrOfflineSettlementCannotMark):
|
||||||
|
response.BadRequest(c, "当前提号不可确认线下结算")
|
||||||
default:
|
default:
|
||||||
response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error())
|
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),
|
ShopName: strings.TrimSpace(req.ShopName),
|
||||||
AccountSource: accountSource,
|
AccountSource: accountSource,
|
||||||
SourceChannel: sourceChannel,
|
SourceChannel: sourceChannel,
|
||||||
|
SettlementMode: pickupSettlementMode(listing.SettlementMode, accountSource),
|
||||||
ListingPriceCent: priceSnapshot.ListingPriceCent,
|
ListingPriceCent: priceSnapshot.ListingPriceCent,
|
||||||
OwnerPriceCent: priceSnapshot.OwnerPriceCent,
|
OwnerPriceCent: priceSnapshot.OwnerPriceCent,
|
||||||
WebsiteProfitCent: priceSnapshot.WebsiteProfitCent,
|
WebsiteProfitCent: priceSnapshot.WebsiteProfitCent,
|
||||||
@@ -168,7 +169,7 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint
|
|||||||
return r.FindByID(ctx, createdID)
|
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) {
|
func (r *Repository) Complete(ctx context.Context, pickupID uint64, req CompleteRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
@@ -233,28 +234,44 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
|
|||||||
return err
|
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
|
bid := pickup.ID
|
||||||
if err := notification.Append(tx, notification.Entry{
|
if pickupRequiresOfflineSettlement(pickup) {
|
||||||
UserID: pickup.OwnerID,
|
pickup.OfflineSettlementStatus = OfflineSettlementStatusPending
|
||||||
Type: "pickup",
|
if err := tx.Save(&pickup).Error; err != nil {
|
||||||
Title: "提号已完成,已结算到账",
|
return err
|
||||||
Content: fmt.Sprintf("账号「%s」提号完成,已结算 %s 到您的钱包。", account.Title, money.FormatWithSymbol(req.SettleAmountCent)),
|
}
|
||||||
BizType: "pickup",
|
if err := notification.Append(tx, notification.Entry{
|
||||||
BizID: &bid,
|
UserID: pickup.OwnerID,
|
||||||
}); err != nil {
|
Type: "pickup",
|
||||||
return err
|
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{
|
if err := auditlog.Append(tx, auditlog.Entry{
|
||||||
@@ -265,9 +282,11 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
|
|||||||
BizID: &bid,
|
BizID: &bid,
|
||||||
Meta: meta,
|
Meta: meta,
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"pickup_no": pickup.PickupNo,
|
"pickup_no": pickup.PickupNo,
|
||||||
"settle_amount_cent": req.SettleAmountCent,
|
"settle_amount_cent": req.SettleAmountCent,
|
||||||
"profit_amount_cent": pickup.ProfitAmountCent,
|
"profit_amount_cent": pickup.ProfitAmountCent,
|
||||||
|
"settlement_mode": pickup.SettlementMode,
|
||||||
|
"offline_settlement_status": pickup.OfflineSettlementStatus,
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -280,6 +299,56 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
|
|||||||
return r.FindByID(ctx, pickupID)
|
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 修改提号中的线下利润,已完成和已取消的提号不可修改。
|
// UpdateProfit 修改提号中的线下利润,已完成和已取消的提号不可修改。
|
||||||
func (r *Repository) UpdateProfit(ctx context.Context, pickupID uint64, req UpdateProfitRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
|
func (r *Repository) UpdateProfit(ctx context.Context, pickupID uint64, req UpdateProfitRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
@@ -566,38 +635,54 @@ type pickupRow struct {
|
|||||||
|
|
||||||
func (row pickupRow) toDTO() PickupDTO {
|
func (row pickupRow) toDTO() PickupDTO {
|
||||||
return PickupDTO{
|
return PickupDTO{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
PickupNo: row.PickupNo,
|
PickupNo: row.PickupNo,
|
||||||
ListingID: row.ListingID,
|
ListingID: row.ListingID,
|
||||||
ListingNo: row.ListingNo,
|
ListingNo: row.ListingNo,
|
||||||
AccountID: row.AccountID,
|
AccountID: row.AccountID,
|
||||||
AccountTitle: row.AccountTitle,
|
AccountTitle: row.AccountTitle,
|
||||||
ServerRegion: row.ServerRegion,
|
ServerRegion: row.ServerRegion,
|
||||||
LoginPlatform: row.LoginPlatform,
|
LoginPlatform: row.LoginPlatform,
|
||||||
OwnerID: row.OwnerID,
|
OwnerID: row.OwnerID,
|
||||||
OwnerPhone: row.OwnerPhone,
|
OwnerPhone: row.OwnerPhone,
|
||||||
AdminID: row.AdminID,
|
AdminID: row.AdminID,
|
||||||
Platform: row.Platform,
|
Platform: row.Platform,
|
||||||
ShopName: row.ShopName,
|
ShopName: row.ShopName,
|
||||||
AccountSource: row.AccountSource,
|
AccountSource: row.AccountSource,
|
||||||
SourceChannel: row.SourceChannel,
|
SourceChannel: row.SourceChannel,
|
||||||
ListingPriceCent: row.ListingPriceCent,
|
SettlementMode: row.SettlementMode,
|
||||||
OwnerPriceCent: row.OwnerPriceCent,
|
ListingPriceCent: row.ListingPriceCent,
|
||||||
WebsiteProfitCent: row.WebsiteProfitCent,
|
OwnerPriceCent: row.OwnerPriceCent,
|
||||||
ProfitAmountCent: row.ProfitAmountCent,
|
WebsiteProfitCent: row.WebsiteProfitCent,
|
||||||
SellerRatio: row.SellerRatio,
|
ProfitAmountCent: row.ProfitAmountCent,
|
||||||
BuyerRatio: row.BuyerRatio,
|
SellerRatio: row.SellerRatio,
|
||||||
AccountSnapshot: row.AccountSnapshot,
|
BuyerRatio: row.BuyerRatio,
|
||||||
SettleAmountCent: row.SettleAmountCent,
|
AccountSnapshot: row.AccountSnapshot,
|
||||||
Status: row.Status,
|
SettleAmountCent: row.SettleAmountCent,
|
||||||
Remark: row.Remark,
|
Status: row.Status,
|
||||||
CompleteRemark: row.CompleteRemark,
|
OfflineSettlementStatus: row.OfflineSettlementStatus,
|
||||||
CreatedAt: row.CreatedAt,
|
OfflineSettlementRemark: row.OfflineSettlementRemark,
|
||||||
CompletedAt: row.CompletedAt,
|
OfflineSettledBy: row.OfflineSettledBy,
|
||||||
CancelledAt: row.CancelledAt,
|
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 {
|
type availableListingRow struct {
|
||||||
ID uint64
|
ID uint64
|
||||||
ListingNo string
|
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) {
|
func assertInt64(t *testing.T, name string, got, want int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if got != want {
|
if got != want {
|
||||||
@@ -328,6 +409,8 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) {
|
|||||||
&model.RentalListing{},
|
&model.RentalListing{},
|
||||||
&model.ListingUpload{},
|
&model.ListingUpload{},
|
||||||
&model.AdminPickup{},
|
&model.AdminPickup{},
|
||||||
|
&model.WalletAccount{},
|
||||||
|
&model.WalletLedger{},
|
||||||
&model.AuditLog{},
|
&model.AuditLog{},
|
||||||
&model.Notification{},
|
&model.Notification{},
|
||||||
&model.ListingStatusEvent{},
|
&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)
|
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) {
|
func (s *Service) UpdateProfit(ctx context.Context, pickupID uint64, req UpdateProfitRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
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/available-listings", requirePerm("order:pickup"), pickupHandler.AvailableListings)
|
||||||
adminRoutes.GET("/pickups/:id", requirePerm("order:pickup"), pickupHandler.Detail)
|
adminRoutes.GET("/pickups/:id", requirePerm("order:pickup"), pickupHandler.Detail)
|
||||||
adminRoutes.POST("/pickups/:id/complete", requirePerm("order:pickup"), pickupHandler.Complete)
|
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.PUT("/pickups/:id/profit", requirePerm("order:pickup"), pickupHandler.UpdateProfit)
|
||||||
adminRoutes.POST("/pickups/:id/cancel", requirePerm("order:pickup"), pickupHandler.Cancel)
|
adminRoutes.POST("/pickups/:id/cancel", requirePerm("order:pickup"), pickupHandler.Cancel)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -2,6 +2,8 @@ import { apiClient } from '@/shared/api/client'
|
|||||||
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
|
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
|
||||||
|
|
||||||
export type PickupAccountSource = 'internal' | 'external_platform_managed'
|
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 type AvailableListingSourceType = '' | 'external' | 'internal'
|
||||||
|
|
||||||
export interface AdminPickup {
|
export interface AdminPickup {
|
||||||
@@ -20,6 +22,7 @@ export interface AdminPickup {
|
|||||||
shop_name: string
|
shop_name: string
|
||||||
account_source: PickupAccountSource
|
account_source: PickupAccountSource
|
||||||
source_channel: string
|
source_channel: string
|
||||||
|
settlement_mode: PickupSettlementMode
|
||||||
listing_price_cent: number
|
listing_price_cent: number
|
||||||
owner_price_cent: number
|
owner_price_cent: number
|
||||||
website_profit_cent: number
|
website_profit_cent: number
|
||||||
@@ -29,6 +32,10 @@ export interface AdminPickup {
|
|||||||
account_snapshot?: Record<string, unknown>
|
account_snapshot?: Record<string, unknown>
|
||||||
settle_amount_cent: number
|
settle_amount_cent: number
|
||||||
status: string
|
status: string
|
||||||
|
offline_settlement_status: PickupOfflineSettlementStatus
|
||||||
|
offline_settlement_remark: string
|
||||||
|
offline_settled_by?: number
|
||||||
|
offline_settled_at?: string
|
||||||
remark: string
|
remark: string
|
||||||
complete_remark: string
|
complete_remark: string
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -79,6 +86,10 @@ export interface AdminPickupProfitUpdateRequest {
|
|||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminPickupOfflineSettlementRequest {
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminPickupListQuery {
|
export interface AdminPickupListQuery {
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
@@ -150,6 +161,17 @@ export async function completeAdminPickup(id: number, req: AdminPickupCompleteRe
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function confirmAdminPickupOfflineSettlement(
|
||||||
|
id: number,
|
||||||
|
req: AdminPickupOfflineSettlementRequest = {}
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<AdminPickup>>(
|
||||||
|
`/admin/pickups/${id}/offline-settlement`,
|
||||||
|
req
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateAdminPickupProfit(id: number, req: AdminPickupProfitUpdateRequest) {
|
export async function updateAdminPickupProfit(id: number, req: AdminPickupProfitUpdateRequest) {
|
||||||
const { data } = await apiClient.put<ApiResponse<AdminPickup>>(`/admin/pickups/${id}/profit`, req)
|
const { data } = await apiClient.put<ApiResponse<AdminPickup>>(`/admin/pickups/${id}/profit`, req)
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
@@ -157,6 +157,17 @@ function pickupSourceTagType(source: AdminPickup['account_source']) {
|
|||||||
return source === 'external_platform_managed' ? 'primary' : 'info'
|
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) {
|
function ratioText(value: number) {
|
||||||
const ratio = Number(value || 0)
|
const ratio = Number(value || 0)
|
||||||
if (ratio <= 0) return '-'
|
if (ratio <= 0) return '-'
|
||||||
@@ -317,6 +328,18 @@ function readSnapshotResources(summary: Record<string, unknown> | null): Snapsho
|
|||||||
<span v-if="pickup.source_channel">{{ pickup.source_channel }}</span>
|
<span v-if="pickup.source_channel">{{ pickup.source_channel }}</span>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>结算方式</dt>
|
||||||
|
<dd>{{ settlementModeLabel(pickup) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>结算状态</dt>
|
||||||
|
<dd>{{ offlineSettlementLabel(pickup) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="pickup.settlement_mode === 'platform_managed'">
|
||||||
|
<dt>线下结算时间</dt>
|
||||||
|
<dd>{{ formatDateTime(pickup.offline_settled_at, '待确认') }}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>交易渠道</dt>
|
<dt>交易渠道</dt>
|
||||||
<dd>{{ pickup.platform || '-' }}</dd>
|
<dd>{{ pickup.platform || '-' }}</dd>
|
||||||
@@ -371,6 +394,10 @@ function readSnapshotResources(summary: Record<string, unknown> | null): Snapsho
|
|||||||
<dt>完成备注</dt>
|
<dt>完成备注</dt>
|
||||||
<dd>{{ pickup.complete_remark || '-' }}</dd>
|
<dd>{{ pickup.complete_remark || '-' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="pickup.settlement_mode === 'platform_managed'" class="wide">
|
||||||
|
<dt>线下结算备注</dt>
|
||||||
|
<dd>{{ pickup.offline_settlement_remark || '-' }}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import { EditPen, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
import { EditPen, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
cancelAdminPickup,
|
cancelAdminPickup,
|
||||||
|
confirmAdminPickupOfflineSettlement,
|
||||||
completeAdminPickup,
|
completeAdminPickup,
|
||||||
createAdminPickup,
|
createAdminPickup,
|
||||||
fetchAdminPickups,
|
fetchAdminPickups,
|
||||||
@@ -36,6 +37,7 @@ const createDialogVisible = ref(false)
|
|||||||
const completeDialogVisible = ref(false)
|
const completeDialogVisible = ref(false)
|
||||||
const profitDialogVisible = ref(false)
|
const profitDialogVisible = ref(false)
|
||||||
const activePickupId = ref(0)
|
const activePickupId = ref(0)
|
||||||
|
const activePickup = ref<AdminPickup | null>(null)
|
||||||
const profitSaving = ref(false)
|
const profitSaving = ref(false)
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
@@ -184,12 +186,41 @@ async function handleCreate() {
|
|||||||
|
|
||||||
function openCompleteDialog(row: AdminPickup) {
|
function openCompleteDialog(row: AdminPickup) {
|
||||||
activePickupId.value = row.id
|
activePickupId.value = row.id
|
||||||
|
activePickup.value = row
|
||||||
completeForm.settle_amount = 0
|
completeForm.settle_amount = 0
|
||||||
completeForm.profit_amount = centToYuan(row.profit_amount_cent)
|
completeForm.profit_amount = centToYuan(row.profit_amount_cent)
|
||||||
completeForm.complete_remark = ''
|
completeForm.complete_remark = ''
|
||||||
completeDialogVisible.value = true
|
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) {
|
function openProfitDialog(row: AdminPickup) {
|
||||||
activePickupId.value = row.id
|
activePickupId.value = row.id
|
||||||
profitForm.profit_amount = centToYuan(row.profit_amount_cent)
|
profitForm.profit_amount = centToYuan(row.profit_amount_cent)
|
||||||
@@ -213,7 +244,11 @@ async function handleComplete() {
|
|||||||
profit_amount_cent: yuanToCent(completeForm.profit_amount),
|
profit_amount_cent: yuanToCent(completeForm.profit_amount),
|
||||||
complete_remark: completeForm.complete_remark,
|
complete_remark: completeForm.complete_remark,
|
||||||
})
|
})
|
||||||
ElMessage.success('提号已完成,已给卖家结算')
|
ElMessage.success(
|
||||||
|
activePickup.value?.settlement_mode === 'platform_managed'
|
||||||
|
? '提号已完成,待线下结算'
|
||||||
|
: '提号已完成,已给卖家结算'
|
||||||
|
)
|
||||||
completeDialogVisible.value = false
|
completeDialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -310,6 +345,24 @@ function pickupSourceTagType(source: PickupAccountSource) {
|
|||||||
return isExternalSource(source) ? 'primary' : 'info'
|
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) {
|
function listingSourceText(item: AvailableListing) {
|
||||||
const label = pickupSourceLabel(item.account_source)
|
const label = pickupSourceLabel(item.account_source)
|
||||||
return item.source_channel ? `${label} · ${item.source_channel}` : label
|
return item.source_channel ? `${label} · ${item.source_channel}` : label
|
||||||
@@ -326,7 +379,7 @@ function listingOptionLabel(item: AvailableListing) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Admin Pickup</p>
|
<p class="eyebrow">Admin Pickup</p>
|
||||||
<h1>线下提号</h1>
|
<h1>线下提号</h1>
|
||||||
<p>管理员线下提号,跳过支付与交接流程,完成后给卖家结算余额。</p>
|
<p>管理员线下提号,站内上传结算到钱包,平台代管通过线下结算确认打款。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<el-button type="primary" :icon="Plus" @click="openCreateDialog">创建提号</el-button>
|
<el-button type="primary" :icon="Plus" @click="openCreateDialog">创建提号</el-button>
|
||||||
@@ -416,10 +469,17 @@ function listingOptionLabel(item: AvailableListing) {
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="结算状态" width="125">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="offlineSettlementTagType(row)" effect="light">
|
||||||
|
{{ offlineSettlementLabel(row) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="创建时间" width="170">
|
<el-table-column label="创建时间" width="170">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="300" fixed="right">
|
<el-table-column label="操作" width="340" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<RouterLink :to="adminPath(`pickup/${row.id}`)">
|
<RouterLink :to="adminPath(`pickup/${row.id}`)">
|
||||||
@@ -441,7 +501,16 @@ function listingOptionLabel(item: AvailableListing) {
|
|||||||
size="small"
|
size="small"
|
||||||
@click="openCompleteDialog(row)"
|
@click="openCompleteDialog(row)"
|
||||||
>
|
>
|
||||||
完成结算
|
完成提号
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'completed' && row.offline_settlement_status === 'pending'"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
plain
|
||||||
|
@click="handleConfirmOfflineSettlement(row)"
|
||||||
|
>
|
||||||
|
确认线下结算
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.status === 'picking_up'"
|
v-if="row.status === 'picking_up'"
|
||||||
@@ -599,8 +668,8 @@ function listingOptionLabel(item: AvailableListing) {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 完成结算 -->
|
<!-- 完成提号 -->
|
||||||
<el-dialog v-model="completeDialogVisible" title="完成提号结算" width="480px">
|
<el-dialog v-model="completeDialogVisible" title="完成提号" width="480px">
|
||||||
<el-form label-width="110px">
|
<el-form label-width="110px">
|
||||||
<el-form-item label="结算金额(元)" required>
|
<el-form-item label="结算金额(元)" required>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
@@ -611,7 +680,13 @@ function listingOptionLabel(item: AvailableListing) {
|
|||||||
controls-position="right"
|
controls-position="right"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
<div class="form-hint">给卖家钱包增加的可用余额</div>
|
<div class="form-hint">
|
||||||
|
{{
|
||||||
|
activePickup?.settlement_mode === 'platform_managed'
|
||||||
|
? '记录待线下支付的结算金额'
|
||||||
|
: '给卖家钱包增加的可用余额'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="线下利润(元)">
|
<el-form-item label="线下利润(元)">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
@@ -629,16 +704,16 @@ function listingOptionLabel(item: AvailableListing) {
|
|||||||
v-model="completeForm.complete_remark"
|
v-model="completeForm.complete_remark"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="3"
|
:rows="3"
|
||||||
placeholder="结算说明(可选)"
|
placeholder="完成说明(可选)"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-alert type="warning" :closable="false">
|
<el-alert type="warning" :closable="false">
|
||||||
完成后提号记录置为"已完成",卖家钱包立即入账,账号下架不可再上架。操作不可撤销。
|
完成后账号下架不可再上架。站内上传会立即入账卖家钱包;平台代管会转为待线下结算,实际打款后需单独确认。
|
||||||
</el-alert>
|
</el-alert>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="completeDialogVisible = false">取消</el-button>
|
<el-button @click="completeDialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="loading" @click="handleComplete">确认结算</el-button>
|
<el-button type="primary" :loading="loading" @click="handleComplete">确认完成</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ export const AUDIT_ACTION_OPTIONS: AuditOption[] = [
|
|||||||
{ value: 'chat.renter_retention.remove', label: '到期移除租客会话', group: '系统任务' },
|
{ value: 'chat.renter_retention.remove', label: '到期移除租客会话', group: '系统任务' },
|
||||||
// 提号
|
// 提号
|
||||||
{ value: 'pickup_create', label: '创建提号', group: '提号' },
|
{ value: 'pickup_create', label: '创建提号', group: '提号' },
|
||||||
{ value: 'pickup_complete', label: '完成提号结算', group: '提号', highRisk: true },
|
{ value: 'pickup_complete', label: '完成提号结算', group: '提号', highRisk: true },
|
||||||
|
{ value: 'pickup_offline_settlement', label: '确认提号线下结算', group: '提号', highRisk: true },
|
||||||
{ value: 'pickup_profit_update', label: '修改提号利润', group: '提号', highRisk: true },
|
{ value: 'pickup_profit_update', label: '修改提号利润', group: '提号', highRisk: true },
|
||||||
{ value: 'pickup_cancel', label: '取消提号', group: '提号' },
|
{ value: 'pickup_cancel', label: '取消提号', group: '提号' },
|
||||||
// 申诉
|
// 申诉
|
||||||
|
|||||||
Reference in New Issue
Block a user