修复平台代管订单流程
This commit is contained in:
@@ -53,8 +53,11 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
|
|||||||
&model.RentalOrder{},
|
&model.RentalOrder{},
|
||||||
&model.PaymentOrder{},
|
&model.PaymentOrder{},
|
||||||
&model.Notification{},
|
&model.Notification{},
|
||||||
|
&model.AdminNotification{},
|
||||||
&model.HandoffRecord{},
|
&model.HandoffRecord{},
|
||||||
&model.OrderCheckout{},
|
&model.OrderCheckout{},
|
||||||
|
&model.WalletAccount{},
|
||||||
|
&model.WalletLedger{},
|
||||||
&model.AuditLog{},
|
&model.AuditLog{},
|
||||||
&model.ChatConversation{},
|
&model.ChatConversation{},
|
||||||
&model.ChatParticipant{},
|
&model.ChatParticipant{},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/adminnotification"
|
||||||
"hfb_sys/backend/internal/modules/chat"
|
"hfb_sys/backend/internal/modules/chat"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
|
||||||
@@ -55,6 +56,25 @@ func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPlatformManagedOrder(order *model.RentalOrder) bool {
|
||||||
|
if order == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return order.HandoffMode == "platform" || order.SettlementMode == "platform_managed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendManagedTimeoutNotification(tx *gorm.DB, order *model.RentalOrder, title string, content string) error {
|
||||||
|
if order == nil || order.ManagedAdminID == nil || *order.ManagedAdminID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return adminnotification.Append(tx, adminnotification.Entry{
|
||||||
|
AdminUserID: *order.ManagedAdminID,
|
||||||
|
Type: "timeout",
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// acquireLock 通过 Redis 分布式锁确保同一时刻只有一个实例执行超时扫描。
|
// acquireLock 通过 Redis 分布式锁确保同一时刻只有一个实例执行超时扫描。
|
||||||
// 未配置 Redis 时直接执行;Redis 出错时降级执行(事务内行锁与状态二次校验可兜底,不会写坏数据)。
|
// 未配置 Redis 时直接执行;Redis 出错时降级执行(事务内行锁与状态二次校验可兜底,不会写坏数据)。
|
||||||
func (j *Job) acquireLock(ctx context.Context) (func(), bool) {
|
func (j *Job) acquireLock(ctx context.Context) (func(), bool) {
|
||||||
@@ -314,6 +334,22 @@ func (j *Job) handleOwnerSubmitTimeout(ctx context.Context, now time.Time, cfg t
|
|||||||
before := snapshot(order)
|
before := snapshot(order)
|
||||||
order.HandoffStatus = "owner_timeout"
|
order.HandoffStatus = "owner_timeout"
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
|
if isPlatformManagedOrder(order) {
|
||||||
|
if err := notification.Append(tx, notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "timeout",
|
||||||
|
Title: "客服代交接超时",
|
||||||
|
Content: "客服未在规定时间内提交交接说明,你可以取消订单或发起申诉。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := appendManagedTimeoutNotification(tx, order, "代管订单交接超时", "订单已超过交接时限,请尽快进入订单详情处理。"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return before, nil
|
||||||
|
}
|
||||||
if err := notification.Append(tx,
|
if err := notification.Append(tx,
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
@@ -506,6 +542,22 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
|
|||||||
order.Status = "abnormal"
|
order.Status = "abnormal"
|
||||||
order.HandoffStatus = "owner_checkout_confirm_timeout"
|
order.HandoffStatus = "owner_checkout_confirm_timeout"
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
|
if isPlatformManagedOrder(order) {
|
||||||
|
if err := notification.Append(tx, notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "timeout",
|
||||||
|
Title: "客服确认结账超时",
|
||||||
|
Content: "客服未在规定时间内确认结账,订单已进入客服复核状态。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := appendManagedTimeoutNotification(tx, order, "代管订单确认结账超时", "订单已超过确认结账时限,请尽快进入订单详情处理。"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return before, nil
|
||||||
|
}
|
||||||
if err := notification.Append(tx,
|
if err := notification.Append(tx,
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func setupOrderTimeoutTestDB(t *testing.T) *gorm.DB {
|
|||||||
&model.RentalOrder{},
|
&model.RentalOrder{},
|
||||||
&model.HandoffRecord{},
|
&model.HandoffRecord{},
|
||||||
&model.Notification{},
|
&model.Notification{},
|
||||||
|
&model.AdminNotification{},
|
||||||
&model.ChatConversation{},
|
&model.ChatConversation{},
|
||||||
&model.ChatParticipant{},
|
&model.ChatParticipant{},
|
||||||
&model.ChatMessage{},
|
&model.ChatMessage{},
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ type RentalListing struct {
|
|||||||
InTransaction bool `gorm:"not null;default:false" json:"in_transaction"`
|
InTransaction bool `gorm:"not null;default:false" json:"in_transaction"`
|
||||||
Status string `gorm:"size:32;not null;default:'draft'" json:"status"`
|
Status string `gorm:"size:32;not null;default:'draft'" json:"status"`
|
||||||
ReviewStatus string `gorm:"size:32;not null;default:'none'" json:"review_status"`
|
ReviewStatus string `gorm:"size:32;not null;default:'none'" json:"review_status"`
|
||||||
|
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
|
||||||
|
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
|
||||||
|
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
|
||||||
ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"`
|
ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"`
|
||||||
PublishedAt *time.Time `json:"published_at"`
|
PublishedAt *time.Time `json:"published_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -7,38 +7,46 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type RentalOrder struct {
|
type RentalOrder struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
OrderNo string `gorm:"size:64;not null;uniqueIndex" json:"order_no"`
|
OrderNo string `gorm:"size:64;not null;uniqueIndex" json:"order_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"`
|
||||||
RenterID uint64 `gorm:"not null;index" json:"renter_id"`
|
RenterID uint64 `gorm:"not null;index" json:"renter_id"`
|
||||||
RentedAt *time.Time `json:"rented_at"`
|
RentedAt *time.Time `json:"rented_at"`
|
||||||
HandoffStartedAt *time.Time `json:"handoff_started_at"`
|
HandoffStartedAt *time.Time `json:"handoff_started_at"`
|
||||||
EstimatedDurationHours int `gorm:"not null;default:24" json:"estimated_duration_hours"`
|
EstimatedDurationHours int `gorm:"not null;default:24" json:"estimated_duration_hours"`
|
||||||
RentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
RentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
|
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||||
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
||||||
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
|
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
|
||||||
SettlementStatus string `gorm:"size:32;not null;default:'unsettled'" json:"settlement_status"`
|
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
|
||||||
RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"`
|
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
|
||||||
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
|
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
|
||||||
RefundedAt *time.Time `json:"refunded_at"`
|
SettlementStatus string `gorm:"size:32;not null;default:'unsettled'" json:"settlement_status"`
|
||||||
DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"`
|
OfflineSettlementStatus string `gorm:"size:16;not null;default:'none';index" json:"offline_settlement_status"`
|
||||||
DepositHoldAmountCent int64 `gorm:"not null;default:0" json:"deposit_hold_amount_cent"`
|
OfflineSettlementAmountCent int64 `gorm:"not null;default:0" json:"offline_settlement_amount_cent"`
|
||||||
DepositHoldReason string `gorm:"size:255;not null;default:''" json:"deposit_hold_reason"`
|
OfflineSettlementRemark string `gorm:"size:255;not null;default:''" json:"offline_settlement_remark"`
|
||||||
DepositHeldBy *uint64 `json:"deposit_held_by"`
|
OfflineSettledBy *uint64 `json:"offline_settled_by"`
|
||||||
DepositHeldAt *time.Time `json:"deposit_held_at"`
|
OfflineSettledAt *time.Time `json:"offline_settled_at"`
|
||||||
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"`
|
RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"`
|
||||||
OwnerSettledAt *time.Time `json:"owner_settled_at"`
|
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
|
||||||
SettledAt *time.Time `json:"settled_at"`
|
RefundedAt *time.Time `json:"refunded_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
DepositHoldAmountCent int64 `gorm:"not null;default:0" json:"deposit_hold_amount_cent"`
|
||||||
|
DepositHoldReason string `gorm:"size:255;not null;default:''" json:"deposit_hold_reason"`
|
||||||
|
DepositHeldBy *uint64 `json:"deposit_held_by"`
|
||||||
|
DepositHeldAt *time.Time `json:"deposit_held_at"`
|
||||||
|
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"`
|
||||||
|
OwnerSettledAt *time.Time `json:"owner_settled_at"`
|
||||||
|
SettledAt *time.Time `json:"settled_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (RentalOrder) TableName() string {
|
func (RentalOrder) TableName() string {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"hfb_sys/backend/internal/listingstatus"
|
"hfb_sys/backend/internal/listingstatus"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/adminnotification"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
"hfb_sys/backend/pkg/money"
|
"hfb_sys/backend/pkg/money"
|
||||||
@@ -62,7 +63,12 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
|||||||
order.Status = arbitrateOrderStatus(req.Result)
|
order.Status = arbitrateOrderStatus(req.Result)
|
||||||
order.SettlementStatus = "arbitrated"
|
order.SettlementStatus = "arbitrated"
|
||||||
order.SettledAt = &now
|
order.SettledAt = &now
|
||||||
order.OwnerSettledAt = &now
|
if isPlatformManagedOrder(order) {
|
||||||
|
order.OwnerSettledAt = nil
|
||||||
|
applyPlatformManagedSettlement(&order, &settlement)
|
||||||
|
} else {
|
||||||
|
order.OwnerSettledAt = &now
|
||||||
|
}
|
||||||
if order.HandoffStatus != "cancelled" && order.HandoffStatus != "returned" {
|
if order.HandoffStatus != "cancelled" && order.HandoffStatus != "returned" {
|
||||||
order.HandoffStatus = "arbitrated"
|
order.HandoffStatus = "arbitrated"
|
||||||
}
|
}
|
||||||
@@ -147,25 +153,32 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := notification.Append(tx,
|
renterNotification := notification.Entry{
|
||||||
notification.Entry{
|
UserID: order.RenterID,
|
||||||
UserID: order.RenterID,
|
Type: "arbitration",
|
||||||
Type: "arbitration",
|
Title: "申诉仲裁已完成",
|
||||||
Title: "申诉仲裁已完成",
|
Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。",
|
||||||
Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。",
|
BizType: "dispute",
|
||||||
BizType: "dispute",
|
BizID: &disputeID,
|
||||||
BizID: &disputeID,
|
}
|
||||||
},
|
if isPlatformManagedOrder(order) {
|
||||||
notification.Entry{
|
if err := notification.Append(tx, renterNotification); err != nil {
|
||||||
UserID: order.OwnerID,
|
return err
|
||||||
Type: "arbitration",
|
}
|
||||||
Title: "申诉仲裁已完成",
|
} else {
|
||||||
Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。",
|
if err := notification.Append(tx,
|
||||||
BizType: "dispute",
|
renterNotification,
|
||||||
BizID: &disputeID,
|
notification.Entry{
|
||||||
},
|
UserID: order.OwnerID,
|
||||||
); err != nil {
|
Type: "arbitration",
|
||||||
return err
|
Title: "申诉仲裁已完成",
|
||||||
|
Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。",
|
||||||
|
BizType: "dispute",
|
||||||
|
BizID: &disputeID,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -181,6 +194,42 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPlatformManagedOrder(order model.RentalOrder) bool {
|
||||||
|
return order.SettlementMode == "platform_managed" || order.HandoffMode == "platform"
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPlatformManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error {
|
||||||
|
if order.ManagedAdminID == nil || *order.ManagedAdminID == 0 || title == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return adminnotification.Append(tx, adminnotification.Entry{
|
||||||
|
AdminUserID: *order.ManagedAdminID,
|
||||||
|
Type: typ,
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPlatformManagedSettlement(order *model.RentalOrder, settlement *arbitrationSettlement) {
|
||||||
|
if order == nil || settlement == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filtered := settlement.Entries[:0]
|
||||||
|
for _, entry := range settlement.Entries {
|
||||||
|
if entry.UserID == order.OwnerID && entry.Direction == "in" && entry.BizType == "arbitration_owner_income" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
settlement.Entries = filtered
|
||||||
|
order.OfflineSettlementAmountCent = settlement.OwnerIncomeAmountCent
|
||||||
|
if settlement.OwnerIncomeAmountCent > 0 {
|
||||||
|
order.OfflineSettlementStatus = "pending"
|
||||||
|
} else {
|
||||||
|
order.OfflineSettlementStatus = "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type arbitrationSettlement struct {
|
type arbitrationSettlement struct {
|
||||||
Entries []wallet.Entry
|
Entries []wallet.Entry
|
||||||
RenterRefundAmountCent int64
|
RenterRefundAmountCent int64
|
||||||
|
|||||||
@@ -115,25 +115,35 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64,
|
|||||||
title = "订单进入结账争议"
|
title = "订单进入结账争议"
|
||||||
content = "对方已发起结账争议,请等待客服仲裁或补充结账证据。"
|
content = "对方已发起结账争议,请等待客服仲裁或补充结账证据。"
|
||||||
}
|
}
|
||||||
if err := notification.Append(tx,
|
initiatorNotification := notification.Entry{
|
||||||
notification.Entry{
|
UserID: userID,
|
||||||
UserID: targetID,
|
Type: "dispute",
|
||||||
Type: "dispute",
|
Title: "申诉已提交",
|
||||||
Title: title,
|
Content: "申诉已进入待处理状态,客服仲裁后会通知双方。",
|
||||||
Content: content,
|
BizType: "dispute",
|
||||||
BizType: "dispute",
|
BizID: &disputeID,
|
||||||
BizID: &disputeID,
|
}
|
||||||
},
|
if isPlatformManagedOrder(order) {
|
||||||
notification.Entry{
|
if err := appendPlatformManagedAdminNotification(tx, order, "dispute", title, content); err != nil {
|
||||||
UserID: userID,
|
return err
|
||||||
Type: "dispute",
|
}
|
||||||
Title: "申诉已提交",
|
if err := notification.Append(tx, initiatorNotification); err != nil {
|
||||||
Content: "申诉已进入待处理状态,客服仲裁后会通知双方。",
|
return err
|
||||||
BizType: "dispute",
|
}
|
||||||
BizID: &disputeID,
|
} else {
|
||||||
},
|
if err := notification.Append(tx,
|
||||||
); err != nil {
|
notification.Entry{
|
||||||
return err
|
UserID: targetID,
|
||||||
|
Type: "dispute",
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
BizType: "dispute",
|
||||||
|
BizID: &disputeID,
|
||||||
|
},
|
||||||
|
initiatorNotification,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createdID = row.ID
|
createdID = row.ID
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -169,6 +169,76 @@ func TestRepositoryCancelRestoresCheckoutDisputeStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
|
||||||
|
db := setupDisputeTestDB(t)
|
||||||
|
repo := NewRepository(db)
|
||||||
|
adminID := uint64(77)
|
||||||
|
|
||||||
|
owner, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{
|
||||||
|
Status: "renting",
|
||||||
|
HandoffStatus: "received",
|
||||||
|
SettlementStatus: "unsettled",
|
||||||
|
HandoffMode: "platform",
|
||||||
|
SettlementMode: "platform_managed",
|
||||||
|
ManagedAdminID: &adminID,
|
||||||
|
RentAmountCent: 10000,
|
||||||
|
OwnerRentAmountCent: 8000,
|
||||||
|
PlatformFeeCent: 2000,
|
||||||
|
OfflineSettlementStatus: "none",
|
||||||
|
OfflineSettlementAmountCent: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
|
||||||
|
Type: "cannot_login",
|
||||||
|
Description: "外部上传账号需要客服处理",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建申诉失败: %v", err)
|
||||||
|
}
|
||||||
|
var ownerNotificationCount int64
|
||||||
|
if err := db.Model(&model.Notification{}).Where("user_id = ?", owner.ID).Count(&ownerNotificationCount).Error; err != nil {
|
||||||
|
t.Fatalf("统计号主通知失败: %v", err)
|
||||||
|
}
|
||||||
|
if ownerNotificationCount != 0 {
|
||||||
|
t.Fatalf("owner notification count = %d, want 0", ownerNotificationCount)
|
||||||
|
}
|
||||||
|
var adminNotificationCount int64
|
||||||
|
if err := db.Model(&model.AdminNotification{}).Where("admin_user_id = ?", adminID).Count(&adminNotificationCount).Error; err != nil {
|
||||||
|
t.Fatalf("统计客服通知失败: %v", err)
|
||||||
|
}
|
||||||
|
if adminNotificationCount == 0 {
|
||||||
|
t.Fatal("platform managed dispute should notify managed admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{
|
||||||
|
Result: "release_deposit",
|
||||||
|
Remark: "确认租金给卖家,线下结算",
|
||||||
|
}, AuditMeta{}); err != nil {
|
||||||
|
t.Fatalf("仲裁失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved model.RentalOrder
|
||||||
|
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取订单失败: %v", err)
|
||||||
|
}
|
||||||
|
if saved.OfflineSettlementStatus != "pending" {
|
||||||
|
t.Fatalf("offline settlement status = %q, want pending", saved.OfflineSettlementStatus)
|
||||||
|
}
|
||||||
|
if saved.OfflineSettlementAmountCent != 8000 {
|
||||||
|
t.Fatalf("offline settlement amount = %d, want 8000", saved.OfflineSettlementAmountCent)
|
||||||
|
}
|
||||||
|
if saved.OwnerSettledAt != nil {
|
||||||
|
t.Fatalf("owner settled at = %#v, want nil before offline settlement", saved.OwnerSettledAt)
|
||||||
|
}
|
||||||
|
var ownerLedgerCount int64
|
||||||
|
if err := db.Model(&model.WalletLedger{}).Where("user_id = ?", owner.ID).Count(&ownerLedgerCount).Error; err != nil {
|
||||||
|
t.Fatalf("统计钱包流水失败: %v", err)
|
||||||
|
}
|
||||||
|
if ownerLedgerCount != 0 {
|
||||||
|
t.Fatalf("owner wallet ledger count = %d, want 0", ownerLedgerCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) {
|
func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) {
|
||||||
order := model.RentalOrder{
|
order := model.RentalOrder{
|
||||||
ID: 11,
|
ID: 11,
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ type ListingDTO struct {
|
|||||||
InTransaction bool `json:"in_transaction"`
|
InTransaction bool `json:"in_transaction"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ReviewStatus string `json:"review_status"`
|
ReviewStatus string `json:"review_status"`
|
||||||
|
HandoffMode string `json:"handoff_mode"`
|
||||||
|
SettlementMode string `json:"settlement_mode"`
|
||||||
|
ManagedAdminID *uint64 `json:"managed_admin_id,omitempty"`
|
||||||
ReviewReason string `json:"review_reason"`
|
ReviewReason string `json:"review_reason"`
|
||||||
PublishedAt *time.Time `json:"published_at"`
|
PublishedAt *time.Time `json:"published_at"`
|
||||||
ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"`
|
ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"`
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload extern
|
|||||||
DepositAmountCent: req.DepositAmountCent,
|
DepositAmountCent: req.DepositAmountCent,
|
||||||
Status: "draft",
|
Status: "draft",
|
||||||
ReviewStatus: "pending",
|
ReviewStatus: "pending",
|
||||||
|
HandoffMode: "platform",
|
||||||
|
SettlementMode: "platform_managed",
|
||||||
|
ManagedAdminID: &admin.ID,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&listing).Error; err != nil {
|
if err := tx.Create(&listing).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ func (row listingRow) toDTO() ListingDTO {
|
|||||||
InTransaction: row.InTransaction,
|
InTransaction: row.InTransaction,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
ReviewStatus: reviewStatus,
|
ReviewStatus: reviewStatus,
|
||||||
|
HandoffMode: row.HandoffMode,
|
||||||
|
SettlementMode: row.SettlementMode,
|
||||||
|
ManagedAdminID: row.ManagedAdminID,
|
||||||
ReviewReason: reviewReason,
|
ReviewReason: reviewReason,
|
||||||
PublishedAt: row.PublishedAt,
|
PublishedAt: row.PublishedAt,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
@@ -159,6 +162,9 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
|
|||||||
InTransaction: listing.InTransaction,
|
InTransaction: listing.InTransaction,
|
||||||
Status: listing.Status,
|
Status: listing.Status,
|
||||||
ReviewStatus: reviewStatus,
|
ReviewStatus: reviewStatus,
|
||||||
|
HandoffMode: listing.HandoffMode,
|
||||||
|
SettlementMode: listing.SettlementMode,
|
||||||
|
ManagedAdminID: listing.ManagedAdminID,
|
||||||
ReviewReason: reviewReason,
|
ReviewReason: reviewReason,
|
||||||
PublishedAt: listing.PublishedAt,
|
PublishedAt: listing.PublishedAt,
|
||||||
CreatedAt: listing.CreatedAt,
|
CreatedAt: listing.CreatedAt,
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ func (r *Repository) TransferOwner(ctx context.Context, adminID uint64, listingI
|
|||||||
|
|
||||||
beforeOwnerID := listing.OwnerID
|
beforeOwnerID := listing.OwnerID
|
||||||
listing.OwnerID = target.ID
|
listing.OwnerID = target.ID
|
||||||
|
listing.HandoffMode = "owner"
|
||||||
|
listing.SettlementMode = "owner_wallet"
|
||||||
|
listing.ManagedAdminID = nil
|
||||||
account.OwnerID = target.ID
|
account.OwnerID = target.ID
|
||||||
if err := tx.Save(account).Error; err != nil {
|
if err := tx.Save(account).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -31,10 +31,14 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
|
|||||||
if hasOpen {
|
if hasOpen {
|
||||||
return ErrCheckoutCannotSubmit
|
return ErrCheckoutCannotSubmit
|
||||||
}
|
}
|
||||||
|
checkoutToUserID := order.OwnerID
|
||||||
|
if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 {
|
||||||
|
checkoutToUserID = platformManagedAdminID(order)
|
||||||
|
}
|
||||||
record := model.HandoffRecord{
|
record := model.HandoffRecord{
|
||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
FromUserID: order.RenterID,
|
FromUserID: order.RenterID,
|
||||||
ToUserID: order.OwnerID,
|
ToUserID: checkoutToUserID,
|
||||||
Type: "renter_checkout",
|
Type: "renter_checkout",
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
}
|
}
|
||||||
@@ -58,15 +62,21 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
order.HandoffStartedAt = &now
|
order.HandoffStartedAt = &now
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if err := notification.Append(tx, notification.Entry{
|
if isPlatformSettlementOrder(order) {
|
||||||
UserID: order.OwnerID,
|
if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil {
|
||||||
Type: "checkout",
|
return err
|
||||||
Title: "租客已发起结账",
|
}
|
||||||
Content: "请检查账号状态和消耗明细,确认无误后完成结算;也可修改后交由租客确认。",
|
} else {
|
||||||
BizType: "order",
|
if err := notification.Append(tx, notification.Entry{
|
||||||
BizID: &orderID,
|
UserID: order.OwnerID,
|
||||||
}); err != nil {
|
Type: "checkout",
|
||||||
return err
|
Title: "租客已发起结账",
|
||||||
|
Content: "请检查账号状态和消耗明细,确认无误后完成结算;也可修改后交由租客确认。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Save(&order).Error; err != nil {
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -163,37 +173,18 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
|
|||||||
return ErrCheckoutMaxRounds
|
return ErrCheckoutMaxRounds
|
||||||
}
|
}
|
||||||
|
|
||||||
depositDeductCent := req.DepositDeductAmountCent
|
depositDeductCent := checkoutDepositDeductCent(req)
|
||||||
if depositDeductCent <= 0 && req.OtherAmountCent > 0 {
|
|
||||||
depositDeductCent = req.OtherAmountCent
|
|
||||||
}
|
|
||||||
next, err := buildCheckout(order, checkout.InitiatedBy, checkoutStatusCountered, checkout.Content, req.EvidenceURLS, req.ConsumableAmountCent, req.CoinConsumedM, depositDeductCent, depositDeductCent, true)
|
next, err := buildCheckout(order, checkout.InitiatedBy, checkoutStatusCountered, checkout.Content, req.EvidenceURLS, req.ConsumableAmountCent, req.CoinConsumedM, depositDeductCent, depositDeductCent, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
checkout.Status = checkoutStatusCountered
|
applyCounterCheckoutUpdate(checkout, next, req.Reason, userID, round+1, now)
|
||||||
checkout.RoundCount = round + 1
|
|
||||||
checkout.ProposedBy = userID
|
|
||||||
checkout.RentAmountCent = next.RentAmountCent
|
|
||||||
checkout.OwnerRentAmountCent = next.OwnerRentAmountCent
|
|
||||||
checkout.PlatformFeeCent = next.PlatformFeeCent
|
|
||||||
checkout.DepositAmountCent = next.DepositAmountCent
|
|
||||||
checkout.ConsumableAmountCent = next.ConsumableAmountCent
|
|
||||||
checkout.CoinConsumedM = next.CoinConsumedM
|
|
||||||
checkout.OtherAmountCent = next.OtherAmountCent
|
|
||||||
checkout.DepositDeductAmountCent = next.DepositDeductAmountCent
|
|
||||||
checkout.RenterRefundAmountCent = next.RenterRefundAmountCent
|
|
||||||
checkout.OwnerIncomeAmountCent = next.OwnerIncomeAmountCent
|
|
||||||
checkout.ShortfallCent = next.ShortfallCent
|
|
||||||
checkout.OvershootAmountCent = next.OvershootAmountCent
|
|
||||||
checkout.OwnerAdjustmentReason = req.Reason
|
|
||||||
checkout.OwnerAdjustedAt = &now
|
|
||||||
checkout.EvidenceURLS = next.EvidenceURLS
|
|
||||||
|
|
||||||
notifyUserID := order.RenterID
|
notifyUserID := order.RenterID
|
||||||
notifyTitle := "号主已修改结账金额"
|
notifyTitle := "号主已修改结账金额"
|
||||||
notifyContent := "请核对对方修正的消耗和结算金额。可同意完结、继续还价(最多 6 轮),或发起争议。"
|
notifyContent := "请核对对方修正的消耗和结算金额。可同意完结、继续还价(最多 6 轮),或发起争议。"
|
||||||
|
notifyManagedAdmin := false
|
||||||
if isOwner {
|
if isOwner {
|
||||||
checkout.Turn = checkoutTurnRenter
|
checkout.Turn = checkoutTurnRenter
|
||||||
order.Status = orderStatusPendingCheckoutAccept
|
order.Status = orderStatusPendingCheckoutAccept
|
||||||
@@ -202,20 +193,30 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
|
|||||||
checkout.Turn = checkoutTurnOwner
|
checkout.Turn = checkoutTurnOwner
|
||||||
order.Status = orderStatusPendingCheckoutConfirm
|
order.Status = orderStatusPendingCheckoutConfirm
|
||||||
order.HandoffStatus = handoffStatusPendingOwnerCheckout
|
order.HandoffStatus = handoffStatusPendingOwnerCheckout
|
||||||
notifyUserID = order.OwnerID
|
|
||||||
notifyTitle = "租客已修改结账金额"
|
notifyTitle = "租客已修改结账金额"
|
||||||
|
if isPlatformSettlementOrder(order) {
|
||||||
|
notifyManagedAdmin = true
|
||||||
|
} else {
|
||||||
|
notifyUserID = order.OwnerID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
order.SettlementStatus = settlementStatusPending
|
order.SettlementStatus = settlementStatusPending
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if err := notification.Append(tx, notification.Entry{
|
if notifyManagedAdmin {
|
||||||
UserID: notifyUserID,
|
if err := appendManagedAdminNotification(tx, order, "checkout", notifyTitle, notifyContent); err != nil {
|
||||||
Type: "checkout",
|
return err
|
||||||
Title: notifyTitle,
|
}
|
||||||
Content: notifyContent,
|
} else {
|
||||||
BizType: "order",
|
if err := notification.Append(tx, notification.Entry{
|
||||||
BizID: &orderID,
|
UserID: notifyUserID,
|
||||||
}); err != nil {
|
Type: "checkout",
|
||||||
return err
|
Title: notifyTitle,
|
||||||
|
Content: notifyContent,
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Save(&order).Error; err != nil {
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -238,6 +239,35 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkoutDepositDeductCent(req CounterCheckoutRequest) int64 {
|
||||||
|
depositDeductCent := req.DepositDeductAmountCent
|
||||||
|
if depositDeductCent <= 0 && req.OtherAmountCent > 0 {
|
||||||
|
depositDeductCent = req.OtherAmountCent
|
||||||
|
}
|
||||||
|
return depositDeductCent
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCounterCheckoutUpdate(checkout *model.OrderCheckout, next model.OrderCheckout, reason string, proposedBy uint64, roundCount int, now time.Time) {
|
||||||
|
checkout.Status = checkoutStatusCountered
|
||||||
|
checkout.RoundCount = roundCount
|
||||||
|
checkout.ProposedBy = proposedBy
|
||||||
|
checkout.RentAmountCent = next.RentAmountCent
|
||||||
|
checkout.OwnerRentAmountCent = next.OwnerRentAmountCent
|
||||||
|
checkout.PlatformFeeCent = next.PlatformFeeCent
|
||||||
|
checkout.DepositAmountCent = next.DepositAmountCent
|
||||||
|
checkout.ConsumableAmountCent = next.ConsumableAmountCent
|
||||||
|
checkout.CoinConsumedM = next.CoinConsumedM
|
||||||
|
checkout.OtherAmountCent = next.OtherAmountCent
|
||||||
|
checkout.DepositDeductAmountCent = next.DepositDeductAmountCent
|
||||||
|
checkout.RenterRefundAmountCent = next.RenterRefundAmountCent
|
||||||
|
checkout.OwnerIncomeAmountCent = next.OwnerIncomeAmountCent
|
||||||
|
checkout.ShortfallCent = next.ShortfallCent
|
||||||
|
checkout.OvershootAmountCent = next.OvershootAmountCent
|
||||||
|
checkout.OwnerAdjustmentReason = reason
|
||||||
|
checkout.OwnerAdjustedAt = &now
|
||||||
|
checkout.EvidenceURLS = next.EvidenceURLS
|
||||||
|
}
|
||||||
|
|
||||||
// AcceptCheckout 租客同意当前提案并完结(轮到租客时)
|
// AcceptCheckout 租客同意当前提案并完结(轮到租客时)
|
||||||
func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID uint64) error {
|
func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID uint64) error {
|
||||||
var refund *refundAction
|
var refund *refundAction
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
|||||||
order.HandoffStatus = handoffStatusReturned
|
order.HandoffStatus = handoffStatusReturned
|
||||||
order.SettlementStatus = settlementStatusSettled
|
order.SettlementStatus = settlementStatusSettled
|
||||||
order.SettledAt = &now
|
order.SettledAt = &now
|
||||||
order.OwnerSettledAt = &now
|
if !isPlatformSettlementOrder(*order) {
|
||||||
|
order.OwnerSettledAt = &now
|
||||||
|
}
|
||||||
if err := completeAssets(tx, listing, account); err != nil {
|
if err := completeAssets(tx, listing, account); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,16 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement checkoutSettlement) error {
|
func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement checkoutSettlement) error {
|
||||||
|
if isPlatformSettlementOrder(*order) {
|
||||||
|
amountCent := settlement.OwnerRentIncomeCent + settlement.DepositCompensationCent
|
||||||
|
order.OfflineSettlementAmountCent = amountCent
|
||||||
|
if amountCent > 0 {
|
||||||
|
order.OfflineSettlementStatus = offlineSettlementStatusPending
|
||||||
|
} else {
|
||||||
|
order.OfflineSettlementStatus = offlineSettlementStatusNone
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
var ownerEntries []wallet.Entry
|
var ownerEntries []wallet.Entry
|
||||||
if settlement.OwnerRentIncomeCent > 0 {
|
if settlement.OwnerRentIncomeCent > 0 {
|
||||||
@@ -102,6 +114,22 @@ func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutS
|
|||||||
|
|
||||||
func appendCheckoutCompletedNotifications(tx *gorm.DB, order *model.RentalOrder, renterContent string) error {
|
func appendCheckoutCompletedNotifications(tx *gorm.DB, order *model.RentalOrder, renterContent string) error {
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
|
if isPlatformSettlementOrder(*order) {
|
||||||
|
if err := notification.Append(tx, notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "settlement",
|
||||||
|
Title: "订单已完成",
|
||||||
|
Content: renterContent,
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.OfflineSettlementAmountCent > 0 {
|
||||||
|
return appendManagedAdminNotification(tx, *order, "settlement", "代管订单待线下结算", "订单已完成,请按线下流程向卖家转出结算金额并回填状态。")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return notification.Append(tx,
|
return notification.Append(tx,
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
handoffStatusReturnOverdue = "return_overdue"
|
handoffStatusReturnOverdue = "return_overdue"
|
||||||
handoffStatusPendingOwnerCheckout = "pending_owner_checkout"
|
handoffStatusPendingOwnerCheckout = "pending_owner_checkout"
|
||||||
handoffStatusPendingRenterCheckout = "pending_renter_checkout"
|
handoffStatusPendingRenterCheckout = "pending_renter_checkout"
|
||||||
|
handoffStatusCheckoutDisputed = "checkout_disputed"
|
||||||
handoffStatusReturned = "returned"
|
handoffStatusReturned = "returned"
|
||||||
handoffStatusOwnerTimeout = "owner_timeout"
|
handoffStatusOwnerTimeout = "owner_timeout"
|
||||||
handoffStatusRenterConfirmTimeout = "renter_confirm_timeout"
|
handoffStatusRenterConfirmTimeout = "renter_confirm_timeout"
|
||||||
@@ -35,6 +36,17 @@ const (
|
|||||||
settlementStatusPending = "pending"
|
settlementStatusPending = "pending"
|
||||||
settlementStatusSettled = "settled"
|
settlementStatusSettled = "settled"
|
||||||
settlementStatusClosed = "closed"
|
settlementStatusClosed = "closed"
|
||||||
|
settlementStatusDisputed = "disputed"
|
||||||
|
|
||||||
|
handoffModeOwner = "owner"
|
||||||
|
handoffModePlatform = "platform"
|
||||||
|
|
||||||
|
settlementModeOwnerWallet = "owner_wallet"
|
||||||
|
settlementModePlatformManaged = "platform_managed"
|
||||||
|
|
||||||
|
offlineSettlementStatusNone = "none"
|
||||||
|
offlineSettlementStatusPending = "pending"
|
||||||
|
offlineSettlementStatusSettled = "settled"
|
||||||
|
|
||||||
checkoutStatusSubmitted = "submitted"
|
checkoutStatusSubmitted = "submitted"
|
||||||
checkoutStatusCountered = "countered"
|
checkoutStatusCountered = "countered"
|
||||||
|
|||||||
@@ -9,50 +9,63 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type OrderDTO struct {
|
type OrderDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_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"`
|
||||||
OwnerID uint64 `json:"owner_id"`
|
OwnerID uint64 `json:"owner_id"`
|
||||||
RenterID uint64 `json:"renter_id"`
|
RenterID uint64 `json:"renter_id"`
|
||||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||||
RenterPhone string `json:"renter_phone,omitempty"`
|
RenterPhone string `json:"renter_phone,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
ServerRegion string `json:"server_region"`
|
ServerRegion string `json:"server_region"`
|
||||||
LoginPlatform string `json:"login_platform"`
|
LoginPlatform string `json:"login_platform"`
|
||||||
RentedAt *time.Time `json:"rented_at"`
|
RentedAt *time.Time `json:"rented_at"`
|
||||||
EstimatedDurationHours int `json:"estimated_duration_hours"`
|
EstimatedDurationHours int `json:"estimated_duration_hours"`
|
||||||
EstimatedEndAt *time.Time `json:"estimated_end_at,omitempty"`
|
EstimatedEndAt *time.Time `json:"estimated_end_at,omitempty"`
|
||||||
PriceRole string `json:"price_role,omitempty"`
|
PriceRole string `json:"price_role,omitempty"`
|
||||||
DisplayAmountCent int64 `json:"display_amount_cent"`
|
DisplayAmountCent int64 `json:"display_amount_cent"`
|
||||||
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
||||||
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
||||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||||
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
||||||
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
||||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
HandoffStatus string `json:"handoff_status"`
|
HandoffStatus string `json:"handoff_status"`
|
||||||
SettlementStatus string `json:"settlement_status"`
|
HandoffMode string `json:"handoff_mode"`
|
||||||
RefundStatus string `json:"refund_status,omitempty"`
|
SettlementMode string `json:"settlement_mode"`
|
||||||
RefundAmountCent int64 `json:"refund_amount_cent"`
|
ManagedAdminID *uint64 `json:"managed_admin_id,omitempty"`
|
||||||
DepositHoldStatus string `json:"deposit_hold_status,omitempty"`
|
SettlementStatus string `json:"settlement_status"`
|
||||||
DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"`
|
OfflineSettlementStatus string `json:"offline_settlement_status,omitempty"`
|
||||||
DepositHoldReason string `json:"deposit_hold_reason,omitempty"`
|
OfflineSettlementAmountCent int64 `json:"offline_settlement_amount_cent"`
|
||||||
DepositHeldAt *time.Time `json:"deposit_held_at,omitempty"`
|
OfflineSettlementRemark string `json:"offline_settlement_remark,omitempty"`
|
||||||
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at,omitempty"`
|
OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"`
|
||||||
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
|
OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"`
|
||||||
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
RefundStatus string `json:"refund_status,omitempty"`
|
||||||
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
|
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||||
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
|
DepositHoldStatus string `json:"deposit_hold_status,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
DepositHoldReason string `json:"deposit_hold_reason,omitempty"`
|
||||||
|
DepositHeldAt *time.Time `json:"deposit_held_at,omitempty"`
|
||||||
|
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at,omitempty"`
|
||||||
|
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
|
||||||
|
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
||||||
|
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
|
||||||
|
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminActionsDTO struct {
|
type AdminActionsDTO struct {
|
||||||
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
|
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
|
||||||
|
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
|
||||||
|
PlatformCheckoutConfirm *AdminActionDTO `json:"platform_checkout_confirm,omitempty"`
|
||||||
|
PlatformCheckoutCounter *AdminActionDTO `json:"platform_checkout_counter,omitempty"`
|
||||||
|
PlatformCheckoutDispute *AdminActionDTO `json:"platform_checkout_dispute,omitempty"`
|
||||||
|
PlatformOfflineSettlement *AdminActionDTO `json:"platform_offline_settlement,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminActionDTO struct {
|
type AdminActionDTO struct {
|
||||||
@@ -100,6 +113,15 @@ type AdminActionRequest struct {
|
|||||||
Reason string `json:"reason" binding:"required"`
|
Reason string `json:"reason" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlatformHandoffRequest struct {
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
Reason string `json:"reason" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfflineSettlementRequest struct {
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
type AdminOrderQuery struct {
|
type AdminOrderQuery struct {
|
||||||
Page int
|
Page int
|
||||||
PageSize int
|
PageSize int
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package order
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/middleware"
|
"hfb_sys/backend/internal/middleware"
|
||||||
"hfb_sys/backend/pkg/response"
|
"hfb_sys/backend/pkg/response"
|
||||||
@@ -74,6 +76,82 @@ func (h *Handler) AdminResetHandoff(c *gin.Context) {
|
|||||||
h.adminAction(c, h.service.AdminResetHandoff, gin.H{"reset": true})
|
h.adminAction(c, h.service.AdminResetHandoff, gin.H{"reset": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminPlatformHandoff(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req PlatformHandoffRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "交接说明和操作原因不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record, err := h.service.AdminPlatformHandoff(c.Request.Context(), adminID, id, req, auditMeta(c))
|
||||||
|
if err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Created(c, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminPlatformCheckoutConfirm(c *gin.Context) {
|
||||||
|
h.adminAction(c, h.service.AdminPlatformCheckoutConfirm, gin.H{"confirmed": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminPlatformCheckoutCounter(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req CounterCheckoutRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "结账修正原因不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkout, err := h.service.AdminPlatformCheckoutCounter(c.Request.Context(), adminID, id, req, auditMeta(c))
|
||||||
|
if err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, checkout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminPlatformCheckoutDispute(c *gin.Context) {
|
||||||
|
h.adminAction(c, h.service.AdminPlatformCheckoutDispute, gin.H{"disputed": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminMarkOfflineSettlement(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 && !errors.Is(err, io.EOF) {
|
||||||
|
response.BadRequest(c, "线下结算备注格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.AdminMarkOfflineSettlement(c.Request.Context(), adminID, id, req, auditMeta(c)); err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"settled": true})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) AdminHoldDeposit(c *gin.Context) {
|
func (h *Handler) AdminHoldDeposit(c *gin.Context) {
|
||||||
h.adminAction(c, h.service.AdminHoldDeposit, gin.H{"held": true})
|
h.adminAction(c, h.service.AdminHoldDeposit, gin.H{"held": true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ func writeOrderError(c *gin.Context, err error) {
|
|||||||
response.Error(c, http.StatusConflict, "checkout_deposit_shortfall", "押金不足以覆盖打超/赔付差额,无法自动完结,请发起争议由人工处理")
|
response.Error(c, http.StatusConflict, "checkout_deposit_shortfall", "押金不足以覆盖打超/赔付差额,无法自动完结,请发起争议由人工处理")
|
||||||
case errors.Is(err, ErrInvalidCheckoutAmount):
|
case errors.Is(err, ErrInvalidCheckoutAmount):
|
||||||
response.BadRequest(c, "结账金额不符合规则")
|
response.BadRequest(c, "结账金额不符合规则")
|
||||||
|
case errors.Is(err, ErrDisputeExists):
|
||||||
|
response.Error(c, http.StatusConflict, "dispute_exists", "当前订单已有处理中争议")
|
||||||
case errors.Is(err, ErrPermissionDenied):
|
case errors.Is(err, ErrPermissionDenied):
|
||||||
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单")
|
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单")
|
||||||
case errors.Is(err, ErrDepositCannotHold):
|
case errors.Is(err, ErrDepositCannotHold):
|
||||||
@@ -57,6 +59,8 @@ func writeOrderError(c *gin.Context, err error) {
|
|||||||
response.Error(c, http.StatusConflict, "deposit_not_held", "该订单押金未处于暂扣状态")
|
response.Error(c, http.StatusConflict, "deposit_not_held", "该订单押金未处于暂扣状态")
|
||||||
case errors.Is(err, ErrDepositHoldAmountEmpty):
|
case errors.Is(err, ErrDepositHoldAmountEmpty):
|
||||||
response.Error(c, http.StatusConflict, "deposit_hold_amount_empty", "暂扣押金尚未形成可归还金额")
|
response.Error(c, http.StatusConflict, "deposit_hold_amount_empty", "暂扣押金尚未形成可归还金额")
|
||||||
|
case errors.Is(err, ErrOfflineSettlementCannotMark):
|
||||||
|
response.Error(c, http.StatusConflict, "offline_settlement_cannot_mark", "当前订单不可确认线下结算")
|
||||||
case IsNotFound(err):
|
case IsNotFound(err):
|
||||||
response.Error(c, http.StatusNotFound, "not_found", "订单不存在")
|
response.Error(c, http.StatusNotFound, "not_found", "订单不存在")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
|||||||
AccountSnapshot: snapshot,
|
AccountSnapshot: snapshot,
|
||||||
Status: orderStatusPendingPayment,
|
Status: orderStatusPendingPayment,
|
||||||
HandoffStatus: handoffStatusNone,
|
HandoffStatus: handoffStatusNone,
|
||||||
|
HandoffMode: listingHandoffMode(listing),
|
||||||
|
SettlementMode: listingSettlementMode(listing),
|
||||||
|
ManagedAdminID: listing.ManagedAdminID,
|
||||||
SettlementStatus: settlementStatusUnsettled,
|
SettlementStatus: settlementStatusUnsettled,
|
||||||
|
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&order).Error; err != nil {
|
if err := tx.Create(&order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -94,6 +98,20 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
|||||||
return r.FindForUser(ctx, renterID, createdID)
|
return r.FindForUser(ctx, renterID, createdID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listingHandoffMode(listing model.RentalListing) string {
|
||||||
|
if listing.HandoffMode != "" {
|
||||||
|
return listing.HandoffMode
|
||||||
|
}
|
||||||
|
return handoffModeOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
func listingSettlementMode(listing model.RentalListing) string {
|
||||||
|
if listing.SettlementMode != "" {
|
||||||
|
return listing.SettlementMode
|
||||||
|
}
|
||||||
|
return settlementModeOwnerWallet
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) depositAmountsForOrder(tx *gorm.DB, renterID uint64, originalDepositCent int64) (int64, int64, error) {
|
func (r *Repository) depositAmountsForOrder(tx *gorm.DB, renterID uint64, originalDepositCent int64) (int64, int64, error) {
|
||||||
if originalDepositCent <= 0 {
|
if originalDepositCent <= 0 {
|
||||||
return 0, 0, nil
|
return 0, 0, nil
|
||||||
@@ -196,25 +214,41 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint
|
|||||||
conversationID = listingConv.ID
|
conversationID = listingConv.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := notification.Append(tx,
|
if isPlatformHandoffOrder(*order) {
|
||||||
notification.Entry{
|
if err := appendManagedAdminNotification(tx, *order, "order", "代管订单待交接", "租客已完成支付,请尽快在订单详情中提交交接说明。"); err != nil {
|
||||||
UserID: order.OwnerID,
|
return 0, err
|
||||||
Type: "order",
|
}
|
||||||
Title: "收到新的租号订单",
|
if err := notification.Append(tx, notification.Entry{
|
||||||
Content: "租客已完成支付,请尽快提交交接说明。",
|
|
||||||
BizType: "order",
|
|
||||||
BizID: &orderID,
|
|
||||||
},
|
|
||||||
notification.Entry{
|
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
Type: "order",
|
Type: "order",
|
||||||
Title: "订单支付成功",
|
Title: "订单支付成功",
|
||||||
Content: "支付已完成,等待号主提交交接说明。",
|
Content: "支付已完成,等待客服提交交接说明。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
}); err != nil {
|
||||||
); err != nil {
|
return 0, err
|
||||||
return 0, err
|
}
|
||||||
|
} else {
|
||||||
|
if err := notification.Append(tx,
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.OwnerID,
|
||||||
|
Type: "order",
|
||||||
|
Title: "收到新的租号订单",
|
||||||
|
Content: "租客已完成支付,请尽快提交交接说明。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
},
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "order",
|
||||||
|
Title: "订单支付成功",
|
||||||
|
Content: "支付已完成,等待号主提交交接说明。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Save(order).Error; err != nil {
|
if err := tx.Save(order).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -257,25 +291,41 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
|
|||||||
} else {
|
} else {
|
||||||
order.HandoffStatus = handoffStatusCancelled
|
order.HandoffStatus = handoffStatusCancelled
|
||||||
}
|
}
|
||||||
if err := notification.Append(tx,
|
if isPlatformHandoffOrder(order) {
|
||||||
notification.Entry{
|
if err := appendManagedAdminNotification(tx, order, "order", "代管订单已取消", "租客已取消订单,退款待客服审核。"); err != nil {
|
||||||
UserID: order.OwnerID,
|
return err
|
||||||
Type: "order",
|
}
|
||||||
Title: "订单已取消",
|
if err := notification.Append(tx, notification.Entry{
|
||||||
Content: "租客已取消订单,退款待客服审核。",
|
|
||||||
BizType: "order",
|
|
||||||
BizID: &orderID,
|
|
||||||
},
|
|
||||||
notification.Entry{
|
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
Type: "order",
|
Type: "order",
|
||||||
Title: "订单取消成功",
|
Title: "订单取消成功",
|
||||||
Content: "订单已取消,退款将由客服审核后原路退回。",
|
Content: "订单已取消,退款将由客服审核后原路退回。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
}); err != nil {
|
||||||
); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
|
} else {
|
||||||
|
if err := notification.Append(tx,
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.OwnerID,
|
||||||
|
Type: "order",
|
||||||
|
Title: "订单已取消",
|
||||||
|
Content: "租客已取消订单,退款待客服审核。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
},
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "order",
|
||||||
|
Title: "订单取消成功",
|
||||||
|
Content: "订单已取消,退款将由客服审核后原路退回。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := releaseAssetsForRental(tx, listing, account); err != nil {
|
if err := releaseAssetsForRental(tx, listing, account); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -329,7 +379,7 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID
|
|||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := tx.Model(&model.HandoffRecord{}).
|
if err := tx.Model(&model.HandoffRecord{}).
|
||||||
Where("order_id = ? AND type = ?", order.ID, "owner_handoff").
|
Where("order_id = ? AND type IN ?", order.ID, []string{"owner_handoff", "platform_handoff"}).
|
||||||
Update("confirmed_by_renter_at", now).Error; err != nil {
|
Update("confirmed_by_renter_at", now).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -338,15 +388,21 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID
|
|||||||
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
|
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
|
||||||
order.RentedAt = &now
|
order.RentedAt = &now
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if err := notification.Append(tx, notification.Entry{
|
if isPlatformHandoffOrder(order) {
|
||||||
UserID: order.OwnerID,
|
if err := appendManagedAdminNotification(tx, order, "handoff", "租客已确认收号", "代管订单已进入使用中。"); err != nil {
|
||||||
Type: "handoff",
|
return err
|
||||||
Title: "租客已确认收号",
|
}
|
||||||
Content: "订单已进入使用中。",
|
} else {
|
||||||
BizType: "order",
|
if err := notification.Append(tx, notification.Entry{
|
||||||
BizID: &orderID,
|
UserID: order.OwnerID,
|
||||||
}); err != nil {
|
Type: "handoff",
|
||||||
return err
|
Title: "租客已确认收号",
|
||||||
|
Content: "订单已进入使用中。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return tx.Save(&order).Error
|
return tx.Save(&order).Error
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/adminnotification"
|
||||||
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isPlatformHandoffOrder(order model.RentalOrder) bool {
|
||||||
|
return effectiveHandoffMode(order) == handoffModePlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPlatformSettlementOrder(order model.RentalOrder) bool {
|
||||||
|
return effectiveSettlementMode(order) == settlementModePlatformManaged
|
||||||
|
}
|
||||||
|
|
||||||
|
func platformManagedAdminID(order model.RentalOrder) uint64 {
|
||||||
|
if order.ManagedAdminID == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *order.ManagedAdminID
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error {
|
||||||
|
adminID := platformManagedAdminID(order)
|
||||||
|
if adminID == 0 || title == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return adminnotification.Append(tx, adminnotification.Entry{
|
||||||
|
AdminUserID: adminID,
|
||||||
|
Type: typ,
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func canAdminPlatformHandoff(order model.RentalOrder) bool {
|
||||||
|
if !isPlatformHandoffOrder(order) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if order.Status != orderStatusPendingHandoff {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return order.HandoffStatus == handoffStatusPendingOwner ||
|
||||||
|
order.HandoffStatus == handoffStatusOwnerTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
func canAdminPlatformCheckoutConfirm(order model.RentalOrder) bool {
|
||||||
|
if !isPlatformSettlementOrder(order) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if order.Status == orderStatusPendingCheckoutConfirm &&
|
||||||
|
order.HandoffStatus == handoffStatusPendingOwnerCheckout {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return order.Status == orderStatusAbnormal &&
|
||||||
|
order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
func canAdminMarkOfflineSettlement(order model.RentalOrder) bool {
|
||||||
|
return isPlatformSettlementOrder(order) &&
|
||||||
|
order.Status == orderStatusCompleted &&
|
||||||
|
order.SettlementStatus == settlementStatusSettled &&
|
||||||
|
effectiveOfflineSettlementStatus(order) == offlineSettlementStatusPending &&
|
||||||
|
order.OfflineSettlementAmountCent > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, orderID uint64, req PlatformHandoffRequest, meta AuditMeta) (*HandoffRecordDTO, error) {
|
||||||
|
var recordID uint64
|
||||||
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var order model.RentalOrder
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !canAdminPlatformHandoff(order) {
|
||||||
|
return ErrOrderCannotHandoff
|
||||||
|
}
|
||||||
|
beforeHandoffStatus := order.HandoffStatus
|
||||||
|
if order.ManagedAdminID == nil {
|
||||||
|
order.ManagedAdminID = &adminID
|
||||||
|
}
|
||||||
|
record := model.HandoffRecord{
|
||||||
|
OrderID: order.ID,
|
||||||
|
FromUserID: adminID,
|
||||||
|
ToUserID: order.RenterID,
|
||||||
|
Type: "platform_handoff",
|
||||||
|
Content: req.Content,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
order.HandoffStatus = handoffStatusPendingRenterConfirm
|
||||||
|
order.HandoffStartedAt = &now
|
||||||
|
orderID := order.ID
|
||||||
|
content := "客服已提交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。"
|
||||||
|
if beforeHandoffStatus == handoffStatusOwnerTimeout {
|
||||||
|
content = "客服已补交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。"
|
||||||
|
}
|
||||||
|
if err := notification.Append(tx, notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "handoff",
|
||||||
|
Title: "客服已提交交接说明",
|
||||||
|
Content: content,
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := appendAuditLog(tx, adminID, "order.platform_handoff", "order", order.ID, meta, map[string]any{
|
||||||
|
"order_id": order.ID,
|
||||||
|
"order_no": order.OrderNo,
|
||||||
|
"reason": req.Reason,
|
||||||
|
"before_handoff_status": beforeHandoffStatus,
|
||||||
|
"after_handoff_status": order.HandoffStatus,
|
||||||
|
"managed_admin_id": platformManagedAdminID(order),
|
||||||
|
"handoff_content_length": len(req.Content),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
recordID = record.ID
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.findHandoffRecord(ctx, recordID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||||
|
var refund *refundAction
|
||||||
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var order model.RentalOrder
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !canAdminPlatformCheckoutConfirm(order) {
|
||||||
|
return ErrCheckoutCannotConfirm
|
||||||
|
}
|
||||||
|
checkout, err := lockOpenCheckout(tx, order.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if normalizeCheckoutTurn(checkout) != checkoutTurnOwner {
|
||||||
|
return ErrCheckoutCannotConfirm
|
||||||
|
}
|
||||||
|
if err := ensureCheckoutCompletable(order, checkout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
beforeOrderStatus := order.Status
|
||||||
|
beforeHandoffStatus := order.HandoffStatus
|
||||||
|
beforeSettlementStatus := order.SettlementStatus
|
||||||
|
if order.ManagedAdminID == nil {
|
||||||
|
order.ManagedAdminID = &adminID
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := tx.Model(&model.HandoffRecord{}).
|
||||||
|
Where("order_id = ? AND type = ?", order.ID, "renter_checkout").
|
||||||
|
Update("confirmed_by_owner_at", now).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
checkout.Status = checkoutStatusAccepted
|
||||||
|
checkout.OwnerAdjustedAt = &now
|
||||||
|
action, err := r.finalizeCheckout(tx, &order, checkout, "客服已确认结账,订单完成。")
|
||||||
|
refund = action
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return appendAuditLog(tx, adminID, "order.platform_checkout_confirm", "order", order.ID, meta, map[string]any{
|
||||||
|
"order_id": order.ID,
|
||||||
|
"order_no": order.OrderNo,
|
||||||
|
"reason": req.Reason,
|
||||||
|
"before_order_status": beforeOrderStatus,
|
||||||
|
"after_order_status": order.Status,
|
||||||
|
"before_handoff_status": beforeHandoffStatus,
|
||||||
|
"after_handoff_status": order.HandoffStatus,
|
||||||
|
"before_settlement_status": beforeSettlementStatus,
|
||||||
|
"after_settlement_status": order.SettlementStatus,
|
||||||
|
"offline_settlement_status": order.OfflineSettlementStatus,
|
||||||
|
"offline_settlement_amount_cent": order.OfflineSettlementAmountCent,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.startRefundBestEffort(ctx, refund)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AdminPlatformCheckoutCounter(ctx context.Context, adminID uint64, orderID uint64, req CounterCheckoutRequest, meta AuditMeta) (*CheckoutDTO, error) {
|
||||||
|
var checkoutID uint64
|
||||||
|
var orderSnapshot model.RentalOrder
|
||||||
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var order model.RentalOrder
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !canAdminPlatformCheckoutConfirm(order) {
|
||||||
|
return ErrCheckoutCannotCounter
|
||||||
|
}
|
||||||
|
if order.ManagedAdminID == nil {
|
||||||
|
order.ManagedAdminID = &adminID
|
||||||
|
}
|
||||||
|
checkout, err := lockOpenCheckout(tx, order.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if normalizeCheckoutTurn(checkout) != checkoutTurnOwner {
|
||||||
|
return ErrCheckoutCannotCounter
|
||||||
|
}
|
||||||
|
round := checkout.RoundCount
|
||||||
|
if round <= 0 {
|
||||||
|
round = 1
|
||||||
|
}
|
||||||
|
if round >= checkoutMaxRounds {
|
||||||
|
return ErrCheckoutMaxRounds
|
||||||
|
}
|
||||||
|
|
||||||
|
depositDeductCent := checkoutDepositDeductCent(req)
|
||||||
|
next, err := buildCheckout(order, checkout.InitiatedBy, checkoutStatusCountered, checkout.Content, req.EvidenceURLS, req.ConsumableAmountCent, req.CoinConsumedM, depositDeductCent, depositDeductCent, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
beforeOrderStatus := order.Status
|
||||||
|
beforeHandoffStatus := order.HandoffStatus
|
||||||
|
applyCounterCheckoutUpdate(checkout, next, req.Reason, adminID, round+1, now)
|
||||||
|
checkout.Turn = checkoutTurnRenter
|
||||||
|
order.Status = orderStatusPendingCheckoutAccept
|
||||||
|
order.HandoffStatus = handoffStatusPendingRenterCheckout
|
||||||
|
order.SettlementStatus = settlementStatusPending
|
||||||
|
orderID := order.ID
|
||||||
|
if err := notification.Append(tx, notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "checkout",
|
||||||
|
Title: "客服已修改结账金额",
|
||||||
|
Content: "请核对客服修正的消耗和结算金额。可同意完结、继续协商,或发起争议。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
record := model.HandoffRecord{
|
||||||
|
OrderID: order.ID,
|
||||||
|
FromUserID: adminID,
|
||||||
|
ToUserID: order.RenterID,
|
||||||
|
Type: "platform_checkout_counter",
|
||||||
|
Content: "客服修改结账方案:" + req.Reason,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := appendAuditLog(tx, adminID, "order.platform_checkout_counter", "order", order.ID, meta, map[string]any{
|
||||||
|
"order_id": order.ID,
|
||||||
|
"order_no": order.OrderNo,
|
||||||
|
"reason": req.Reason,
|
||||||
|
"before_order_status": beforeOrderStatus,
|
||||||
|
"after_order_status": order.Status,
|
||||||
|
"before_handoff_status": beforeHandoffStatus,
|
||||||
|
"after_handoff_status": order.HandoffStatus,
|
||||||
|
"round_count": checkout.RoundCount,
|
||||||
|
"consumable_amount_cent": checkout.ConsumableAmountCent,
|
||||||
|
"coin_consumed_m": checkout.CoinConsumedM,
|
||||||
|
"deposit_deduct_amount_cent": checkout.DepositDeductAmountCent,
|
||||||
|
"owner_income_amount_cent": checkout.OwnerIncomeAmountCent,
|
||||||
|
"renter_refund_amount_cent": checkout.RenterRefundAmountCent,
|
||||||
|
"offline_settlement_amount_cent": order.OfflineSettlementAmountCent,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
checkoutID = checkout.ID
|
||||||
|
orderSnapshot = order
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
checkout, err := r.findCheckout(ctx, checkoutID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dto := toCheckoutAdminDTO(*checkout)
|
||||||
|
refreshCheckoutSettlementDTO(&dto, orderSnapshot, *checkout)
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var order model.RentalOrder
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !canAdminPlatformCheckoutConfirm(order) {
|
||||||
|
return ErrCheckoutCannotCounter
|
||||||
|
}
|
||||||
|
if order.ManagedAdminID == nil {
|
||||||
|
order.ManagedAdminID = &adminID
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&model.Dispute{}).
|
||||||
|
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return ErrDisputeExists
|
||||||
|
}
|
||||||
|
checkout, err := lockOpenCheckout(tx, order.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if normalizeCheckoutTurn(checkout) != checkoutTurnOwner {
|
||||||
|
return ErrCheckoutCannotCounter
|
||||||
|
}
|
||||||
|
evidence, err := marshalStringList([]string{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
beforeOrderStatus := order.Status
|
||||||
|
beforeHandoffStatus := order.HandoffStatus
|
||||||
|
beforeSettlementStatus := order.SettlementStatus
|
||||||
|
row := model.Dispute{
|
||||||
|
OrderID: order.ID,
|
||||||
|
InitiatorID: adminID,
|
||||||
|
TargetUserID: order.RenterID,
|
||||||
|
Type: "checkout_dispute",
|
||||||
|
Status: "open",
|
||||||
|
Description: req.Reason,
|
||||||
|
EvidenceURLS: evidence,
|
||||||
|
PreviousOrderStatus: beforeOrderStatus,
|
||||||
|
PreviousHandoffStatus: beforeHandoffStatus,
|
||||||
|
PreviousSettlementStatus: beforeSettlementStatus,
|
||||||
|
CheckoutID: &checkout.ID,
|
||||||
|
PreviousCheckoutStatus: checkout.Status,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
order.Status = orderStatusCheckoutDisputing
|
||||||
|
order.HandoffStatus = handoffStatusCheckoutDisputed
|
||||||
|
order.SettlementStatus = settlementStatusDisputed
|
||||||
|
checkout.Status = checkoutStatusDisputed
|
||||||
|
checkout.UpdatedAt = now
|
||||||
|
record := model.HandoffRecord{
|
||||||
|
OrderID: order.ID,
|
||||||
|
FromUserID: adminID,
|
||||||
|
ToUserID: order.RenterID,
|
||||||
|
Type: "platform_checkout_dispute_opened",
|
||||||
|
Content: "客服发起结账争议:" + req.Reason,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
disputeID := row.ID
|
||||||
|
orderID := order.ID
|
||||||
|
if err := notification.Append(tx, notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "dispute",
|
||||||
|
Title: "订单进入结账争议",
|
||||||
|
Content: "客服已发起结账争议,请等待仲裁处理或补充结账证据。",
|
||||||
|
BizType: "dispute",
|
||||||
|
BizID: &disputeID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := appendManagedAdminNotification(tx, order, "dispute", "代管订单进入结账争议", "客服已发起结账争议,请在争议列表继续仲裁处理。"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := appendAuditLog(tx, adminID, "order.platform_checkout_dispute", "order", order.ID, meta, map[string]any{
|
||||||
|
"order_id": orderID,
|
||||||
|
"order_no": order.OrderNo,
|
||||||
|
"dispute_id": row.ID,
|
||||||
|
"reason": req.Reason,
|
||||||
|
"before_order_status": beforeOrderStatus,
|
||||||
|
"after_order_status": order.Status,
|
||||||
|
"before_handoff_status": beforeHandoffStatus,
|
||||||
|
"after_handoff_status": order.HandoffStatus,
|
||||||
|
"before_settlement_status": beforeSettlementStatus,
|
||||||
|
"after_settlement_status": order.SettlementStatus,
|
||||||
|
"checkout_id": checkout.ID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Save(checkout).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AdminMarkOfflineSettlement(ctx context.Context, adminID uint64, orderID uint64, req OfflineSettlementRequest, meta AuditMeta) error {
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var order model.RentalOrder
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !canAdminMarkOfflineSettlement(order) {
|
||||||
|
return ErrOfflineSettlementCannotMark
|
||||||
|
}
|
||||||
|
beforeStatus := order.OfflineSettlementStatus
|
||||||
|
now := time.Now()
|
||||||
|
order.OfflineSettlementStatus = offlineSettlementStatusSettled
|
||||||
|
order.OfflineSettlementRemark = req.Remark
|
||||||
|
order.OfflineSettledBy = &adminID
|
||||||
|
order.OfflineSettledAt = &now
|
||||||
|
order.OwnerSettledAt = &now
|
||||||
|
if err := appendAuditLog(tx, adminID, "order.offline_settlement", "order", order.ID, meta, map[string]any{
|
||||||
|
"order_id": order.ID,
|
||||||
|
"order_no": order.OrderNo,
|
||||||
|
"before_offline_settlement_status": beforeStatus,
|
||||||
|
"after_offline_settlement_status": order.OfflineSettlementStatus,
|
||||||
|
"offline_settlement_amount_cent": order.OfflineSettlementAmountCent,
|
||||||
|
"remark": req.Remark,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Save(&order).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,16 +9,46 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO {
|
func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO {
|
||||||
|
actions := &AdminActionsDTO{}
|
||||||
target, ok := resetTargetForOrder(order)
|
target, ok := resetTargetForOrder(order)
|
||||||
if !ok {
|
if ok {
|
||||||
return nil
|
actions.ResetHandoff = &AdminActionDTO{
|
||||||
}
|
|
||||||
return &AdminActionsDTO{
|
|
||||||
ResetHandoff: &AdminActionDTO{
|
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Label: target.Label,
|
Label: target.Label,
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
if canAdminPlatformHandoff(order) {
|
||||||
|
actions.PlatformHandoff = &AdminActionDTO{
|
||||||
|
Enabled: true,
|
||||||
|
Label: "客服代交接",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if canAdminPlatformCheckoutConfirm(order) {
|
||||||
|
actions.PlatformCheckoutConfirm = &AdminActionDTO{
|
||||||
|
Enabled: true,
|
||||||
|
Label: "客服确认结账",
|
||||||
|
}
|
||||||
|
actions.PlatformCheckoutCounter = &AdminActionDTO{
|
||||||
|
Enabled: true,
|
||||||
|
Label: "客服修改结账方案",
|
||||||
|
}
|
||||||
|
actions.PlatformCheckoutDispute = &AdminActionDTO{
|
||||||
|
Enabled: true,
|
||||||
|
Label: "发起结账争议",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if canAdminMarkOfflineSettlement(order) {
|
||||||
|
actions.PlatformOfflineSettlement = &AdminActionDTO{
|
||||||
|
Enabled: true,
|
||||||
|
Label: "确认线下结算",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if actions.ResetHandoff == nil && actions.PlatformHandoff == nil &&
|
||||||
|
actions.PlatformCheckoutConfirm == nil && actions.PlatformCheckoutCounter == nil &&
|
||||||
|
actions.PlatformCheckoutDispute == nil && actions.PlatformOfflineSettlement == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return actions
|
||||||
}
|
}
|
||||||
|
|
||||||
func (row orderRow) toAdminDTO() OrderDTO {
|
func (row orderRow) toAdminDTO() OrderDTO {
|
||||||
@@ -29,43 +59,51 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
|||||||
ownerRentAmountCent := row.OwnerRentAmountCent
|
ownerRentAmountCent := row.OwnerRentAmountCent
|
||||||
platformFeeCent := row.PlatformFeeCent
|
platformFeeCent := row.PlatformFeeCent
|
||||||
return OrderDTO{
|
return OrderDTO{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
OrderNo: row.OrderNo,
|
OrderNo: row.OrderNo,
|
||||||
ListingID: row.ListingID,
|
ListingID: row.ListingID,
|
||||||
ListingNo: row.ListingNo,
|
ListingNo: row.ListingNo,
|
||||||
AccountID: row.AccountID,
|
AccountID: row.AccountID,
|
||||||
OwnerID: row.OwnerID,
|
OwnerID: row.OwnerID,
|
||||||
RenterID: row.RenterID,
|
RenterID: row.RenterID,
|
||||||
OwnerPhone: row.OwnerPhone,
|
OwnerPhone: row.OwnerPhone,
|
||||||
RenterPhone: row.RenterPhone,
|
RenterPhone: row.RenterPhone,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
ServerRegion: row.ServerRegion,
|
ServerRegion: row.ServerRegion,
|
||||||
LoginPlatform: row.LoginPlatform,
|
LoginPlatform: row.LoginPlatform,
|
||||||
RentedAt: rentedAt,
|
RentedAt: rentedAt,
|
||||||
EstimatedDurationHours: durationHours,
|
EstimatedDurationHours: durationHours,
|
||||||
EstimatedEndAt: estimatedEndAt,
|
EstimatedEndAt: estimatedEndAt,
|
||||||
PriceRole: "admin",
|
PriceRole: "admin",
|
||||||
DisplayAmountCent: row.RentAmountCent,
|
DisplayAmountCent: row.RentAmountCent,
|
||||||
RentAmountCent: &rentAmountCent,
|
RentAmountCent: &rentAmountCent,
|
||||||
OwnerRentAmountCent: &ownerRentAmountCent,
|
OwnerRentAmountCent: &ownerRentAmountCent,
|
||||||
DepositAmountCent: row.DepositAmountCent,
|
DepositAmountCent: row.DepositAmountCent,
|
||||||
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
|
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
|
||||||
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
|
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
|
||||||
PlatformFeeCent: &platformFeeCent,
|
PlatformFeeCent: &platformFeeCent,
|
||||||
AccountSnapshot: row.AccountSnapshot,
|
AccountSnapshot: row.AccountSnapshot,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
HandoffStatus: row.HandoffStatus,
|
HandoffStatus: row.HandoffStatus,
|
||||||
SettlementStatus: row.SettlementStatus,
|
HandoffMode: effectiveHandoffMode(row.RentalOrder),
|
||||||
RefundStatus: row.RefundStatus,
|
SettlementMode: effectiveSettlementMode(row.RentalOrder),
|
||||||
RefundAmountCent: row.RefundAmountCent,
|
ManagedAdminID: row.ManagedAdminID,
|
||||||
DepositHoldStatus: row.DepositHoldStatus,
|
SettlementStatus: row.SettlementStatus,
|
||||||
DepositHoldAmountCent: row.DepositHoldAmountCent,
|
OfflineSettlementStatus: effectiveOfflineSettlementStatus(row.RentalOrder),
|
||||||
DepositHoldReason: row.DepositHoldReason,
|
OfflineSettlementAmountCent: row.OfflineSettlementAmountCent,
|
||||||
DepositHeldAt: row.DepositHeldAt,
|
OfflineSettlementRemark: row.OfflineSettlementRemark,
|
||||||
DepositHoldReleasedAt: row.DepositHoldReleasedAt,
|
OfflineSettledBy: row.OfflineSettledBy,
|
||||||
AdminActions: adminActionsForOrder(row.RentalOrder),
|
OfflineSettledAt: row.OfflineSettledAt,
|
||||||
CreatedAt: row.CreatedAt,
|
RefundStatus: row.RefundStatus,
|
||||||
UpdatedAt: row.UpdatedAt,
|
RefundAmountCent: row.RefundAmountCent,
|
||||||
|
DepositHoldStatus: row.DepositHoldStatus,
|
||||||
|
DepositHoldAmountCent: row.DepositHoldAmountCent,
|
||||||
|
DepositHoldReason: row.DepositHoldReason,
|
||||||
|
DepositHeldAt: row.DepositHeldAt,
|
||||||
|
DepositHoldReleasedAt: row.DepositHoldReleasedAt,
|
||||||
|
AdminActions: adminActionsForOrder(row.RentalOrder),
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
UpdatedAt: row.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +116,37 @@ func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
|
|||||||
dto.DepositHoldReason = ""
|
dto.DepositHoldReason = ""
|
||||||
dto.DepositHeldAt = nil
|
dto.DepositHeldAt = nil
|
||||||
dto.DepositHoldReleasedAt = nil
|
dto.DepositHoldReleasedAt = nil
|
||||||
|
dto.ManagedAdminID = nil
|
||||||
|
dto.OfflineSettlementStatus = ""
|
||||||
|
dto.OfflineSettlementAmountCent = 0
|
||||||
|
dto.OfflineSettlementRemark = ""
|
||||||
|
dto.OfflineSettledBy = nil
|
||||||
|
dto.OfflineSettledAt = nil
|
||||||
applyOrderPriceView(&dto, row.RentalOrder, userID)
|
applyOrderPriceView(&dto, row.RentalOrder, userID)
|
||||||
return dto
|
return dto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func effectiveHandoffMode(order model.RentalOrder) string {
|
||||||
|
if order.HandoffMode != "" {
|
||||||
|
return order.HandoffMode
|
||||||
|
}
|
||||||
|
return handoffModeOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveSettlementMode(order model.RentalOrder) string {
|
||||||
|
if order.SettlementMode != "" {
|
||||||
|
return order.SettlementMode
|
||||||
|
}
|
||||||
|
return settlementModeOwnerWallet
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveOfflineSettlementStatus(order model.RentalOrder) string {
|
||||||
|
if order.OfflineSettlementStatus != "" {
|
||||||
|
return order.OfflineSettlementStatus
|
||||||
|
}
|
||||||
|
return offlineSettlementStatusNone
|
||||||
|
}
|
||||||
|
|
||||||
func effectiveDepositOriginalAmountCent(order model.RentalOrder) int64 {
|
func effectiveDepositOriginalAmountCent(order model.RentalOrder) int64 {
|
||||||
if order.DepositOriginalAmountCent > 0 {
|
if order.DepositOriginalAmountCent > 0 {
|
||||||
return order.DepositOriginalAmountCent
|
return order.DepositOriginalAmountCent
|
||||||
|
|||||||
@@ -155,16 +155,16 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c
|
|||||||
return model.OrderCheckout{}, err
|
return model.OrderCheckout{}, err
|
||||||
}
|
}
|
||||||
return model.OrderCheckout{
|
return model.OrderCheckout{
|
||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
InitiatedBy: initiatedBy,
|
InitiatedBy: initiatedBy,
|
||||||
Status: status,
|
Status: status,
|
||||||
RentAmountCent: settlement.ActualRentAmountCent,
|
RentAmountCent: settlement.ActualRentAmountCent,
|
||||||
OwnerRentAmountCent: settlement.OwnerRentIncomeCent,
|
OwnerRentAmountCent: settlement.OwnerRentIncomeCent,
|
||||||
PlatformFeeCent: settlement.PlatformFeeCent,
|
PlatformFeeCent: settlement.PlatformFeeCent,
|
||||||
DepositAmountCent: order.DepositAmountCent,
|
DepositAmountCent: order.DepositAmountCent,
|
||||||
ConsumableAmountCent: consumableAmountCent,
|
ConsumableAmountCent: consumableAmountCent,
|
||||||
CoinConsumedM: roundQuantity(coinConsumedM),
|
CoinConsumedM: roundQuantity(coinConsumedM),
|
||||||
OtherAmountCent: otherAmountCent,
|
OtherAmountCent: otherAmountCent,
|
||||||
// 存用户申报的押金赔付(损坏等),打超由结算自动计入 shortfall/overshoot
|
// 存用户申报的押金赔付(损坏等),打超由结算自动计入 shortfall/overshoot
|
||||||
DepositDeductAmountCent: deductAmountCent,
|
DepositDeductAmountCent: deductAmountCent,
|
||||||
RenterRefundAmountCent: settlement.RenterRefundCent,
|
RenterRefundAmountCent: settlement.RenterRefundCent,
|
||||||
@@ -219,6 +219,17 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
|||||||
} else {
|
} else {
|
||||||
usedOwnerCoinPriceCent = int64(math.Round(float64(sellerCoinBasePriceCent) * coinUseRatio))
|
usedOwnerCoinPriceCent = int64(math.Round(float64(sellerCoinBasePriceCent) * coinUseRatio))
|
||||||
}
|
}
|
||||||
|
coinOvershoot := totalCoinM > 0 && consumedM > totalCoinM
|
||||||
|
if !coinOvershoot {
|
||||||
|
// 未打超时,比例反推只能用于折算未用完部分,不能因四舍五入超过订单快照。
|
||||||
|
if totalCoinM > 0 && consumedM >= totalCoinM {
|
||||||
|
usedBuyerCoinPriceCent = buyerCoinBasePriceCent
|
||||||
|
usedOwnerCoinPriceCent = sellerCoinBasePriceCent
|
||||||
|
} else {
|
||||||
|
usedBuyerCoinPriceCent = minCent(usedBuyerCoinPriceCent, buyerCoinBasePriceCent)
|
||||||
|
usedOwnerCoinPriceCent = minCent(usedOwnerCoinPriceCent, sellerCoinBasePriceCent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 消耗品按申报金额计价,允许超过预收;号主侧按预收消耗品占比线性外推
|
// 消耗品按申报金额计价,允许超过预收;号主侧按预收消耗品占比线性外推
|
||||||
usedBuyerConsumablePriceCent := maxCent(consumableAmountCent, 0)
|
usedBuyerConsumablePriceCent := maxCent(consumableAmountCent, 0)
|
||||||
@@ -227,6 +238,10 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
|||||||
consumableUseRatio = maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0)
|
consumableUseRatio = maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0)
|
||||||
}
|
}
|
||||||
usedOwnerConsumablePriceCent := int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio))
|
usedOwnerConsumablePriceCent := int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio))
|
||||||
|
if usedBuyerConsumablePriceCent <= prepaidConsumablePriceCent {
|
||||||
|
usedBuyerConsumablePriceCent = minCent(usedBuyerConsumablePriceCent, prepaidConsumablePriceCent)
|
||||||
|
usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent)
|
||||||
|
}
|
||||||
|
|
||||||
actualBuyerRentCent := usedBuyerCoinPriceCent + usedBuyerConsumablePriceCent
|
actualBuyerRentCent := usedBuyerCoinPriceCent + usedBuyerConsumablePriceCent
|
||||||
actualOwnerRentCent := usedOwnerCoinPriceCent + usedOwnerConsumablePriceCent
|
actualOwnerRentCent := usedOwnerCoinPriceCent + usedOwnerConsumablePriceCent
|
||||||
|
|||||||
@@ -831,3 +831,332 @@ func TestSubmitCheckoutRefreshesStageTime(t *testing.T) {
|
|||||||
t.Fatalf("handoff started at = %#v, should be refreshed after old stage time", saved.HandoffStartedAt)
|
t.Fatalf("handoff started at = %#v, should be refreshed after old stage time", saved.HandoffStartedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlatformManagedOrderOfflineSettlementFlow(t *testing.T) {
|
||||||
|
db := setupOrderTestDB(t)
|
||||||
|
repo := NewRepository(db)
|
||||||
|
|
||||||
|
adminID := uint64(77)
|
||||||
|
owner := model.User{Phone: "admin:77"}
|
||||||
|
renter := model.User{Phone: "13900004001"}
|
||||||
|
if err := db.Create(&owner).Error; err != nil {
|
||||||
|
t.Fatalf("create owner failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&renter).Error; err != nil {
|
||||||
|
t.Fatalf("create renter failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
account := model.GameAccount{
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
Status: accountStatusRented,
|
||||||
|
ServerRegion: "国服",
|
||||||
|
LoginPlatform: "steam",
|
||||||
|
Title: "平台代管账号",
|
||||||
|
}
|
||||||
|
if err := db.Create(&account).Error; err != nil {
|
||||||
|
t.Fatalf("create account failed: %v", err)
|
||||||
|
}
|
||||||
|
listing := model.RentalListing{
|
||||||
|
ListingNo: "LST-PM-001",
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
AccountID: account.ID,
|
||||||
|
Status: listingStatusRented,
|
||||||
|
ReviewStatus: listingReviewStatusApproved,
|
||||||
|
InTransaction: true,
|
||||||
|
PriceCent: 10000,
|
||||||
|
HandoffMode: handoffModePlatform,
|
||||||
|
SettlementMode: settlementModePlatformManaged,
|
||||||
|
ManagedAdminID: &adminID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&listing).Error; err != nil {
|
||||||
|
t.Fatalf("create listing failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
order := model.RentalOrder{
|
||||||
|
OrderNo: "ORD-PM-001",
|
||||||
|
ListingID: listing.ID,
|
||||||
|
AccountID: account.ID,
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
RenterID: renter.ID,
|
||||||
|
Status: orderStatusPendingHandoff,
|
||||||
|
HandoffStatus: handoffStatusPendingOwner,
|
||||||
|
HandoffMode: handoffModePlatform,
|
||||||
|
SettlementMode: settlementModePlatformManaged,
|
||||||
|
ManagedAdminID: &adminID,
|
||||||
|
RentAmountCent: 10000,
|
||||||
|
OwnerRentAmountCent: 8000,
|
||||||
|
PlatformFeeCent: 2000,
|
||||||
|
DepositAmountCent: 0,
|
||||||
|
EstimatedDurationHours: 24,
|
||||||
|
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||||
|
AccountSnapshot: datatypes.JSON([]byte(`{"haf_coin_amount":1000000}`)),
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatalf("create order failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := repo.AdminPlatformHandoff(t.Context(), adminID, order.ID, PlatformHandoffRequest{
|
||||||
|
Content: "账号和登录说明",
|
||||||
|
Reason: "外部上传账号由客服代交接",
|
||||||
|
}, AuditMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AdminPlatformHandoff() error = %v", err)
|
||||||
|
}
|
||||||
|
if record.Type != "platform_handoff" || record.FromUserID != adminID || record.ToUserID != renter.ID {
|
||||||
|
t.Fatalf("handoff record = %#v, want platform_handoff from admin to renter", record)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.ConfirmReceive(t.Context(), renter.ID, order.ID); err != nil {
|
||||||
|
t.Fatalf("ConfirmReceive() error = %v", err)
|
||||||
|
}
|
||||||
|
var handoff model.HandoffRecord
|
||||||
|
if err := db.Where("order_id = ? AND type = ?", order.ID, "platform_handoff").First(&handoff).Error; err != nil {
|
||||||
|
t.Fatalf("load platform handoff failed: %v", err)
|
||||||
|
}
|
||||||
|
if handoff.ConfirmedByRenterAt == nil {
|
||||||
|
t.Fatal("platform handoff should be confirmed by renter")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.SubmitCheckout(t.Context(), renter.ID, order.ID, SubmitCheckoutRequest{
|
||||||
|
Content: "正常结账",
|
||||||
|
CoinConsumedM: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SubmitCheckout() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.AdminPlatformCheckoutConfirm(t.Context(), adminID, order.ID, AdminActionRequest{Reason: "客服核对无误"}, AuditMeta{}); err != nil {
|
||||||
|
t.Fatalf("AdminPlatformCheckoutConfirm() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved model.RentalOrder
|
||||||
|
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("load order failed: %v", err)
|
||||||
|
}
|
||||||
|
if saved.Status != orderStatusCompleted || saved.SettlementStatus != settlementStatusSettled {
|
||||||
|
t.Fatalf("status = %s/%s, want completed/settled", saved.Status, saved.SettlementStatus)
|
||||||
|
}
|
||||||
|
if saved.OfflineSettlementStatus != offlineSettlementStatusPending {
|
||||||
|
t.Fatalf("offline settlement status = %q, want pending", saved.OfflineSettlementStatus)
|
||||||
|
}
|
||||||
|
if saved.OfflineSettlementAmountCent != 8000 {
|
||||||
|
t.Fatalf("offline settlement amount = %d, want 8000", saved.OfflineSettlementAmountCent)
|
||||||
|
}
|
||||||
|
if saved.OwnerSettledAt != nil {
|
||||||
|
t.Fatalf("owner settled at = %#v, want nil before offline settlement", saved.OwnerSettledAt)
|
||||||
|
}
|
||||||
|
var ownerLedgerCount int64
|
||||||
|
if err := db.Model(&model.WalletLedger{}).Where("user_id = ?", owner.ID).Count(&ownerLedgerCount).Error; err != nil {
|
||||||
|
t.Fatalf("count wallet ledger failed: %v", err)
|
||||||
|
}
|
||||||
|
if ownerLedgerCount != 0 {
|
||||||
|
t.Fatalf("owner wallet ledger count = %d, want 0", ownerLedgerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
dto, err := repo.FindAdmin(t.Context(), order.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindAdmin() error = %v", err)
|
||||||
|
}
|
||||||
|
if dto.AdminActions == nil || dto.AdminActions.PlatformOfflineSettlement == nil {
|
||||||
|
t.Fatal("admin dto should expose offline settlement action")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.AdminMarkOfflineSettlement(t.Context(), adminID, order.ID, OfflineSettlementRequest{Remark: "支付宝线下转账"}, AuditMeta{}); err != nil {
|
||||||
|
t.Fatalf("AdminMarkOfflineSettlement() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("reload order failed: %v", err)
|
||||||
|
}
|
||||||
|
if saved.OfflineSettlementStatus != offlineSettlementStatusSettled {
|
||||||
|
t.Fatalf("offline settlement status = %q, want settled", saved.OfflineSettlementStatus)
|
||||||
|
}
|
||||||
|
if saved.OfflineSettledBy == nil || *saved.OfflineSettledBy != adminID {
|
||||||
|
t.Fatalf("offline settled by = %#v, want %d", saved.OfflineSettledBy, adminID)
|
||||||
|
}
|
||||||
|
if saved.OfflineSettledAt == nil || saved.OwnerSettledAt == nil {
|
||||||
|
t.Fatalf("offline settled at = %#v, owner settled at = %#v, want both set", saved.OfflineSettledAt, saved.OwnerSettledAt)
|
||||||
|
}
|
||||||
|
if saved.OfflineSettlementRemark != "支付宝线下转账" {
|
||||||
|
t.Fatalf("offline settlement remark = %q", saved.OfflineSettlementRemark)
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.WalletLedger{}).Where("user_id = ?", owner.ID).Count(&ownerLedgerCount).Error; err != nil {
|
||||||
|
t.Fatalf("recount wallet ledger failed: %v", err)
|
||||||
|
}
|
||||||
|
if ownerLedgerCount != 0 {
|
||||||
|
t.Fatalf("owner wallet ledger count after offline settlement = %d, want 0", ownerLedgerCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminPlatformCheckoutCounterCanBeAcceptedByRenter(t *testing.T) {
|
||||||
|
db := setupOrderTestDB(t)
|
||||||
|
repo := NewRepository(db)
|
||||||
|
adminID := uint64(78)
|
||||||
|
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
|
||||||
|
|
||||||
|
checkout := createOpenPlatformCheckout(t, db, order, renter.ID)
|
||||||
|
dto, err := repo.AdminPlatformCheckoutCounter(t.Context(), adminID, order.ID, CounterCheckoutRequest{
|
||||||
|
CoinConsumedM: 1,
|
||||||
|
DepositDeductAmountCent: 1000,
|
||||||
|
Reason: "客服核对后增加押金赔付",
|
||||||
|
}, AuditMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AdminPlatformCheckoutCounter() error = %v", err)
|
||||||
|
}
|
||||||
|
if dto.ID != checkout.ID || dto.Turn != checkoutTurnRenter {
|
||||||
|
t.Fatalf("checkout dto = %#v, want same checkout and renter turn", dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved model.RentalOrder
|
||||||
|
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("load order failed: %v", err)
|
||||||
|
}
|
||||||
|
if saved.Status != orderStatusPendingCheckoutAccept || saved.HandoffStatus != handoffStatusPendingRenterCheckout {
|
||||||
|
t.Fatalf("status = %s/%s, want pending_checkout_accept/pending_renter_checkout", saved.Status, saved.HandoffStatus)
|
||||||
|
}
|
||||||
|
var savedCheckout model.OrderCheckout
|
||||||
|
if err := db.First(&savedCheckout, checkout.ID).Error; err != nil {
|
||||||
|
t.Fatalf("load checkout failed: %v", err)
|
||||||
|
}
|
||||||
|
if savedCheckout.Status != checkoutStatusCountered || savedCheckout.ProposedBy != adminID {
|
||||||
|
t.Fatalf("checkout status/proposed = %s/%d, want countered/%d", savedCheckout.Status, savedCheckout.ProposedBy, adminID)
|
||||||
|
}
|
||||||
|
if savedCheckout.DepositDeductAmountCent != 1000 {
|
||||||
|
t.Fatalf("deposit deduct = %d, want 1000", savedCheckout.DepositDeductAmountCent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.AcceptCheckout(t.Context(), renter.ID, order.ID); err != nil {
|
||||||
|
t.Fatalf("AcceptCheckout() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("reload order failed: %v", err)
|
||||||
|
}
|
||||||
|
if saved.Status != orderStatusCompleted || saved.OfflineSettlementStatus != offlineSettlementStatusPending {
|
||||||
|
t.Fatalf("status/offline = %s/%s, want completed/pending", saved.Status, saved.OfflineSettlementStatus)
|
||||||
|
}
|
||||||
|
if saved.OfflineSettlementAmountCent != 9000 {
|
||||||
|
t.Fatalf("offline settlement amount = %d, want 9000", saved.OfflineSettlementAmountCent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminPlatformCheckoutDisputeCreatesArbitrationCase(t *testing.T) {
|
||||||
|
db := setupOrderTestDB(t)
|
||||||
|
if err := db.AutoMigrate(&model.Dispute{}); err != nil {
|
||||||
|
t.Fatalf("migrate dispute failed: %v", err)
|
||||||
|
}
|
||||||
|
repo := NewRepository(db)
|
||||||
|
adminID := uint64(79)
|
||||||
|
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
|
||||||
|
checkout := createOpenPlatformCheckout(t, db, order, renter.ID)
|
||||||
|
|
||||||
|
if err := repo.AdminPlatformCheckoutDispute(t.Context(), adminID, order.ID, AdminActionRequest{Reason: "租客与客服对结账金额有异议"}, AuditMeta{}); err != nil {
|
||||||
|
t.Fatalf("AdminPlatformCheckoutDispute() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved model.RentalOrder
|
||||||
|
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("load order failed: %v", err)
|
||||||
|
}
|
||||||
|
if saved.Status != orderStatusCheckoutDisputing || saved.HandoffStatus != handoffStatusCheckoutDisputed || saved.SettlementStatus != settlementStatusDisputed {
|
||||||
|
t.Fatalf("status = %s/%s/%s, want checkout_disputing/checkout_disputed/disputed", saved.Status, saved.HandoffStatus, saved.SettlementStatus)
|
||||||
|
}
|
||||||
|
var savedCheckout model.OrderCheckout
|
||||||
|
if err := db.First(&savedCheckout, checkout.ID).Error; err != nil {
|
||||||
|
t.Fatalf("load checkout failed: %v", err)
|
||||||
|
}
|
||||||
|
if savedCheckout.Status != checkoutStatusDisputed {
|
||||||
|
t.Fatalf("checkout status = %s, want disputed", savedCheckout.Status)
|
||||||
|
}
|
||||||
|
var dispute model.Dispute
|
||||||
|
if err := db.Where("order_id = ?", order.ID).First(&dispute).Error; err != nil {
|
||||||
|
t.Fatalf("load dispute failed: %v", err)
|
||||||
|
}
|
||||||
|
if dispute.InitiatorID != adminID || dispute.TargetUserID != renter.ID || dispute.Type != "checkout_dispute" {
|
||||||
|
t.Fatalf("dispute = %#v, want admin initiated checkout dispute to renter", dispute)
|
||||||
|
}
|
||||||
|
if dispute.CheckoutID == nil || *dispute.CheckoutID != checkout.ID || dispute.PreviousCheckoutStatus != checkoutStatusSubmitted {
|
||||||
|
t.Fatalf("dispute checkout snapshot = %#v/%s, want checkout %d submitted", dispute.CheckoutID, dispute.PreviousCheckoutStatus, checkout.ID)
|
||||||
|
}
|
||||||
|
dto, err := repo.FindAdmin(t.Context(), order.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindAdmin() error = %v", err)
|
||||||
|
}
|
||||||
|
if dto.ActiveDispute == nil || dto.ActiveDispute.ID != dispute.ID {
|
||||||
|
t.Fatalf("active dispute = %#v, want dispute %d", dto.ActiveDispute, dispute.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createPlatformManagedCheckoutOrder(t *testing.T, db *gorm.DB, adminID uint64) (model.User, model.User, model.RentalOrder) {
|
||||||
|
t.Helper()
|
||||||
|
owner := model.User{Phone: "admin:checkout"}
|
||||||
|
renter := model.User{Phone: "13900004002"}
|
||||||
|
if err := db.Create(&owner).Error; err != nil {
|
||||||
|
t.Fatalf("create owner failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&renter).Error; err != nil {
|
||||||
|
t.Fatalf("create renter failed: %v", err)
|
||||||
|
}
|
||||||
|
account := model.GameAccount{
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
Status: accountStatusRented,
|
||||||
|
ServerRegion: "国服",
|
||||||
|
LoginPlatform: "steam",
|
||||||
|
Title: "平台代管结账账号",
|
||||||
|
}
|
||||||
|
if err := db.Create(&account).Error; err != nil {
|
||||||
|
t.Fatalf("create account failed: %v", err)
|
||||||
|
}
|
||||||
|
listing := model.RentalListing{
|
||||||
|
ListingNo: "LST-PM-CHECKOUT",
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
AccountID: account.ID,
|
||||||
|
Status: listingStatusRented,
|
||||||
|
ReviewStatus: listingReviewStatusApproved,
|
||||||
|
InTransaction: true,
|
||||||
|
PriceCent: 10000,
|
||||||
|
HandoffMode: handoffModePlatform,
|
||||||
|
SettlementMode: settlementModePlatformManaged,
|
||||||
|
ManagedAdminID: &adminID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&listing).Error; err != nil {
|
||||||
|
t.Fatalf("create listing failed: %v", err)
|
||||||
|
}
|
||||||
|
order := model.RentalOrder{
|
||||||
|
OrderNo: "ORD-PM-CHECKOUT",
|
||||||
|
ListingID: listing.ID,
|
||||||
|
AccountID: account.ID,
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
RenterID: renter.ID,
|
||||||
|
Status: orderStatusPendingCheckoutConfirm,
|
||||||
|
HandoffStatus: handoffStatusPendingOwnerCheckout,
|
||||||
|
HandoffMode: handoffModePlatform,
|
||||||
|
SettlementMode: settlementModePlatformManaged,
|
||||||
|
ManagedAdminID: &adminID,
|
||||||
|
RentAmountCent: 10000,
|
||||||
|
OwnerRentAmountCent: 8000,
|
||||||
|
PlatformFeeCent: 2000,
|
||||||
|
DepositAmountCent: 1000,
|
||||||
|
EstimatedDurationHours: 24,
|
||||||
|
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||||
|
AccountSnapshot: datatypes.JSON([]byte(`{
|
||||||
|
"haf_coin_amount":1000000,
|
||||||
|
"asset_summary":{"price_breakdown":{"buyer_coin_base_price":100,"seller_coin_base_price":80,"consumable_price":0}}
|
||||||
|
}`)),
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatalf("create order failed: %v", err)
|
||||||
|
}
|
||||||
|
return owner, renter, order
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrder, renterID uint64) model.OrderCheckout {
|
||||||
|
t.Helper()
|
||||||
|
checkout, err := buildCheckout(order, renterID, checkoutStatusSubmitted, "租客申请结账", nil, 0, 1, 0, 0, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build checkout failed: %v", err)
|
||||||
|
}
|
||||||
|
checkout.RoundCount = 1
|
||||||
|
checkout.Turn = checkoutTurnOwner
|
||||||
|
checkout.ProposedBy = renterID
|
||||||
|
if err := db.Create(&checkout).Error; err != nil {
|
||||||
|
t.Fatalf("create checkout failed: %v", err)
|
||||||
|
}
|
||||||
|
return checkout
|
||||||
|
}
|
||||||
|
|||||||
@@ -233,6 +233,42 @@ func TestCalculateCheckoutSettlementUsesExplicitRatiosForActualConsume(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCalculateCheckoutSettlementCapsFullUseRatioRounding(t *testing.T) {
|
||||||
|
// 发布量刚好用完时,比例换算的四舍五入不能把线下待打款抬高。
|
||||||
|
order := model.RentalOrder{
|
||||||
|
RentAmountCent: 37210,
|
||||||
|
OwnerRentAmountCent: 34000,
|
||||||
|
PlatformFeeCent: 3210,
|
||||||
|
DepositAmountCent: 50000,
|
||||||
|
AccountSnapshot: datatypes.JSON([]byte(`{
|
||||||
|
"haf_coin_amount": 160000000,
|
||||||
|
"asset_summary": {
|
||||||
|
"price_breakdown": {
|
||||||
|
"buyer_ratio": 43,
|
||||||
|
"seller_ratio": 47,
|
||||||
|
"buyer_coin_base_price": 372.1,
|
||||||
|
"seller_coin_base_price": 340,
|
||||||
|
"consumable_price": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)),
|
||||||
|
}
|
||||||
|
|
||||||
|
settlement := calculateCheckoutSettlement(order, 0, 160, 0)
|
||||||
|
if settlement.ActualRentAmountCent != order.RentAmountCent {
|
||||||
|
t.Fatalf("ActualRentAmountCent = %d, want %d", settlement.ActualRentAmountCent, order.RentAmountCent)
|
||||||
|
}
|
||||||
|
if settlement.OwnerRentIncomeCent != order.OwnerRentAmountCent {
|
||||||
|
t.Fatalf("OwnerRentIncomeCent = %d, want %d", settlement.OwnerRentIncomeCent, order.OwnerRentAmountCent)
|
||||||
|
}
|
||||||
|
if settlement.PlatformFeeCent != order.PlatformFeeCent {
|
||||||
|
t.Fatalf("PlatformFeeCent = %d, want %d", settlement.PlatformFeeCent, order.PlatformFeeCent)
|
||||||
|
}
|
||||||
|
if settlement.OvershootAmountCent != 0 {
|
||||||
|
t.Fatalf("OvershootAmountCent = %d, want 0", settlement.OvershootAmountCent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCalculateDepositWaiverUsesSharedRemainingQuota(t *testing.T) {
|
func TestCalculateDepositWaiverUsesSharedRemainingQuota(t *testing.T) {
|
||||||
paid, waived := calculateDepositWaiver(500, 300, 0)
|
paid, waived := calculateDepositWaiver(500, 300, 0)
|
||||||
if paid != 200 || waived != 300 {
|
if paid != 200 || waived != 300 {
|
||||||
|
|||||||
@@ -6,29 +6,31 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
ErrInvalidRentHours = errors.New("invalid rent hours")
|
ErrInvalidRentHours = errors.New("invalid rent hours")
|
||||||
ErrListingUnavailable = errors.New("listing unavailable")
|
ErrListingUnavailable = errors.New("listing unavailable")
|
||||||
ErrCannotRentOwnListing = errors.New("cannot rent own listing")
|
ErrCannotRentOwnListing = errors.New("cannot rent own listing")
|
||||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||||
ErrOrderCannotPay = errors.New("order cannot pay")
|
ErrOrderCannotPay = errors.New("order cannot pay")
|
||||||
ErrChannelPaymentRequired = errors.New("channel payment required")
|
ErrChannelPaymentRequired = errors.New("channel payment required")
|
||||||
ErrOrderCannotCancel = errors.New("order cannot cancel")
|
ErrOrderCannotCancel = errors.New("order cannot cancel")
|
||||||
ErrOrderCannotHandoff = errors.New("order cannot handoff")
|
ErrOrderCannotHandoff = errors.New("order cannot handoff")
|
||||||
ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff")
|
ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff")
|
||||||
ErrOrderCannotReceive = errors.New("order cannot receive")
|
ErrOrderCannotReceive = errors.New("order cannot receive")
|
||||||
ErrOrderCannotReturn = errors.New("order cannot return")
|
ErrOrderCannotReturn = errors.New("order cannot return")
|
||||||
ErrOrderCannotComplete = errors.New("order cannot complete")
|
ErrOrderCannotComplete = errors.New("order cannot complete")
|
||||||
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
|
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
|
||||||
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
|
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
|
||||||
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
|
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
|
||||||
ErrCheckoutMaxRounds = errors.New("checkout max rounds reached")
|
ErrCheckoutMaxRounds = errors.New("checkout max rounds reached")
|
||||||
ErrCheckoutDepositShortfall = errors.New("checkout deposit shortfall")
|
ErrCheckoutDepositShortfall = errors.New("checkout deposit shortfall")
|
||||||
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
|
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
|
||||||
ErrPermissionDenied = errors.New("permission denied")
|
ErrDisputeExists = errors.New("dispute already exists")
|
||||||
ErrDepositCannotHold = errors.New("deposit cannot hold")
|
ErrPermissionDenied = errors.New("permission denied")
|
||||||
ErrDepositNotHeld = errors.New("deposit not held")
|
ErrDepositCannotHold = errors.New("deposit cannot hold")
|
||||||
ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty")
|
ErrDepositNotHeld = errors.New("deposit not held")
|
||||||
|
ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty")
|
||||||
|
ErrOfflineSettlementCannotMark = errors.New("offline settlement cannot mark")
|
||||||
)
|
)
|
||||||
|
|
||||||
const internalOrderHours = 24
|
const internalOrderHours = 24
|
||||||
@@ -221,6 +223,56 @@ func (s *Service) AdminResetHandoff(ctx context.Context, adminID uint64, orderID
|
|||||||
return s.repo.AdminResetHandoff(ctx, adminID, orderID, req, meta)
|
return s.repo.AdminResetHandoff(ctx, adminID, orderID, req, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) AdminPlatformHandoff(ctx context.Context, adminID uint64, orderID uint64, req PlatformHandoffRequest, meta AuditMeta) (*HandoffRecordDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if orderID == 0 || req.Content == "" || req.Reason == "" {
|
||||||
|
return nil, ErrOrderCannotHandoff
|
||||||
|
}
|
||||||
|
return s.repo.AdminPlatformHandoff(ctx, adminID, orderID, req, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AdminPlatformCheckoutConfirm(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if orderID == 0 || req.Reason == "" {
|
||||||
|
return ErrCheckoutCannotConfirm
|
||||||
|
}
|
||||||
|
return s.repo.AdminPlatformCheckoutConfirm(ctx, adminID, orderID, req, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AdminPlatformCheckoutCounter(ctx context.Context, adminID uint64, orderID uint64, req CounterCheckoutRequest, meta AuditMeta) (*CheckoutDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if orderID == 0 || req.Reason == "" {
|
||||||
|
return nil, ErrCheckoutCannotCounter
|
||||||
|
}
|
||||||
|
return s.repo.AdminPlatformCheckoutCounter(ctx, adminID, orderID, req, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AdminPlatformCheckoutDispute(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if orderID == 0 || req.Reason == "" {
|
||||||
|
return ErrCheckoutCannotCounter
|
||||||
|
}
|
||||||
|
return s.repo.AdminPlatformCheckoutDispute(ctx, adminID, orderID, req, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AdminMarkOfflineSettlement(ctx context.Context, adminID uint64, orderID uint64, req OfflineSettlementRequest, meta AuditMeta) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if orderID == 0 {
|
||||||
|
return ErrOfflineSettlementCannotMark
|
||||||
|
}
|
||||||
|
return s.repo.AdminMarkOfflineSettlement(ctx, adminID, orderID, req, meta)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) AdminHoldDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
func (s *Service) AdminHoldDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return ErrDependencyUnavailable
|
return ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -576,6 +576,11 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.POST("/orders/:id/seal", requirePerm("order:close"), orderHandler.AdminSeal)
|
adminRoutes.POST("/orders/:id/seal", requirePerm("order:close"), orderHandler.AdminSeal)
|
||||||
adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal)
|
adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal)
|
||||||
adminRoutes.POST("/orders/:id/reset-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminResetHandoff)
|
adminRoutes.POST("/orders/:id/reset-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminResetHandoff)
|
||||||
|
adminRoutes.POST("/orders/:id/platform-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformHandoff)
|
||||||
|
adminRoutes.POST("/orders/:id/platform-checkout/confirm", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutConfirm)
|
||||||
|
adminRoutes.POST("/orders/:id/platform-checkout/counter", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutCounter)
|
||||||
|
adminRoutes.POST("/orders/:id/platform-checkout/dispute", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutDispute)
|
||||||
|
adminRoutes.POST("/orders/:id/offline-settlement", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkOfflineSettlement)
|
||||||
adminRoutes.POST("/orders/:id/refund", requirePerm("order:close"), orderHandler.AdminRefund)
|
adminRoutes.POST("/orders/:id/refund", requirePerm("order:close"), orderHandler.AdminRefund)
|
||||||
adminRoutes.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus)
|
adminRoutes.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus)
|
||||||
adminRoutes.POST("/orders/:id/refund/approve", requirePerm("order:close"), orderHandler.AdminApproveRefund)
|
adminRoutes.POST("/orders/:id/refund/approve", requirePerm("order:close"), orderHandler.AdminApproveRefund)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE rental_listings
|
||||||
|
ADD COLUMN handoff_mode VARCHAR(16) NOT NULL DEFAULT 'owner' COMMENT '交接模式: owner号主交接/platform平台代管' AFTER review_status,
|
||||||
|
ADD COLUMN settlement_mode VARCHAR(32) NOT NULL DEFAULT 'owner_wallet' COMMENT '结算模式: owner_wallet号主钱包/platform_managed平台线下结算' AFTER handoff_mode,
|
||||||
|
ADD COLUMN managed_admin_id BIGINT UNSIGNED NULL COMMENT '平台代管负责客服ID' AFTER settlement_mode,
|
||||||
|
ADD KEY idx_rental_listings_handoff_mode (handoff_mode, status, review_status),
|
||||||
|
ADD KEY idx_rental_listings_managed_admin (managed_admin_id);
|
||||||
|
|
||||||
|
ALTER TABLE rental_orders
|
||||||
|
ADD COLUMN handoff_mode VARCHAR(16) NOT NULL DEFAULT 'owner' COMMENT '交接模式快照: owner号主交接/platform平台代管' AFTER handoff_status,
|
||||||
|
ADD COLUMN settlement_mode VARCHAR(32) NOT NULL DEFAULT 'owner_wallet' COMMENT '结算模式快照: owner_wallet号主钱包/platform_managed平台线下结算' AFTER handoff_mode,
|
||||||
|
ADD COLUMN managed_admin_id BIGINT UNSIGNED NULL COMMENT '平台代管负责客服ID快照' AFTER settlement_mode,
|
||||||
|
ADD COLUMN offline_settlement_status VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '线下结算状态: none无/pending待线下结算/settled已线下结算' AFTER settlement_status,
|
||||||
|
ADD COLUMN offline_settlement_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '待线下结算给卖家的金额(分)' AFTER offline_settlement_status,
|
||||||
|
ADD COLUMN offline_settlement_remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '线下结算备注' AFTER offline_settlement_amount_cent,
|
||||||
|
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_rental_orders_handoff_mode (handoff_mode, status, handoff_status),
|
||||||
|
ADD KEY idx_rental_orders_offline_settlement (offline_settlement_status, settlement_status);
|
||||||
|
|
||||||
|
UPDATE rental_listings AS l
|
||||||
|
JOIN listing_uploads AS lu ON lu.listing_id = l.id
|
||||||
|
JOIN users AS u ON u.id = l.owner_id AND u.phone LIKE 'admin:%'
|
||||||
|
SET l.handoff_mode = 'platform',
|
||||||
|
l.settlement_mode = 'platform_managed',
|
||||||
|
l.managed_admin_id = lu.matched_admin_id
|
||||||
|
WHERE l.handoff_mode = 'owner';
|
||||||
|
|
||||||
|
UPDATE rental_orders AS o
|
||||||
|
JOIN listing_uploads AS lu ON lu.listing_id = o.listing_id
|
||||||
|
JOIN users AS u ON u.id = o.owner_id AND u.phone LIKE 'admin:%'
|
||||||
|
SET o.handoff_mode = 'platform',
|
||||||
|
o.settlement_mode = 'platform_managed',
|
||||||
|
o.managed_admin_id = lu.matched_admin_id
|
||||||
|
WHERE o.handoff_mode = 'owner';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE rental_orders
|
||||||
|
DROP KEY idx_rental_orders_offline_settlement,
|
||||||
|
DROP KEY idx_rental_orders_handoff_mode,
|
||||||
|
DROP COLUMN offline_settled_at,
|
||||||
|
DROP COLUMN offline_settled_by,
|
||||||
|
DROP COLUMN offline_settlement_remark,
|
||||||
|
DROP COLUMN offline_settlement_amount_cent,
|
||||||
|
DROP COLUMN offline_settlement_status,
|
||||||
|
DROP COLUMN managed_admin_id,
|
||||||
|
DROP COLUMN settlement_mode,
|
||||||
|
DROP COLUMN handoff_mode;
|
||||||
|
|
||||||
|
ALTER TABLE rental_listings
|
||||||
|
DROP KEY idx_rental_listings_managed_admin,
|
||||||
|
DROP KEY idx_rental_listings_handoff_mode,
|
||||||
|
DROP COLUMN managed_admin_id,
|
||||||
|
DROP COLUMN settlement_mode,
|
||||||
|
DROP COLUMN handoff_mode;
|
||||||
@@ -33,7 +33,7 @@ import { adminPath } from '@/shared/utils/adminPath'
|
|||||||
import { centToYuan, formatCentWithSymbol, formatMoney } from '@/shared/utils/money'
|
import { centToYuan, formatCentWithSymbol, formatMoney } from '@/shared/utils/money'
|
||||||
import { formatDateMinute, formatDateTime } from '@/shared/utils/time'
|
import { formatDateMinute, formatDateTime } from '@/shared/utils/time'
|
||||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||||
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
import { orderHandoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
import TransferDialog from '../components/TransferDialog.vue'
|
import TransferDialog from '../components/TransferDialog.vue'
|
||||||
import QuickReplyDialog from '../components/QuickReplyDialog.vue'
|
import QuickReplyDialog from '../components/QuickReplyDialog.vue'
|
||||||
|
|
||||||
@@ -590,11 +590,14 @@ function paymentBizTypeLabel(type: string) {
|
|||||||
function formatHandoffRecordType(type: string) {
|
function formatHandoffRecordType(type: string) {
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
owner_handoff: '卖家交接',
|
owner_handoff: '卖家交接',
|
||||||
|
platform_handoff: '客服代交接',
|
||||||
renter_checkout: '买家结账',
|
renter_checkout: '买家结账',
|
||||||
owner_counter_checkout: '卖家反驳结账',
|
owner_counter_checkout: '卖家反驳结账',
|
||||||
|
platform_checkout_counter: '客服修改结账',
|
||||||
renter_confirm_checkout: '买家确认结账',
|
renter_confirm_checkout: '买家确认结账',
|
||||||
owner_accept_checkout: '卖家接受结账',
|
owner_accept_checkout: '卖家接受结账',
|
||||||
admin_arbitration: '客服仲裁',
|
admin_arbitration: '客服仲裁',
|
||||||
|
platform_checkout_dispute_opened: '客服发起结账争议',
|
||||||
}
|
}
|
||||||
return typeMap[type] || type
|
return typeMap[type] || type
|
||||||
}
|
}
|
||||||
@@ -776,7 +779,7 @@ function firstQueryValue(value: unknown) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>交接状态</span>
|
<span>交接状态</span>
|
||||||
<strong>{{ handoffStatusLabel(activeOrder.handoff_status) }}</strong>
|
<strong>{{ orderHandoffStatusLabel(activeOrder) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>订单金额</span>
|
<span>订单金额</span>
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { useRoute } from 'vue-router'
|
|||||||
import {
|
import {
|
||||||
adminCloseOrder,
|
adminCloseOrder,
|
||||||
adminHoldDeposit,
|
adminHoldDeposit,
|
||||||
|
adminMarkOfflineSettlement,
|
||||||
adminMarkOrderAbnormal,
|
adminMarkOrderAbnormal,
|
||||||
|
adminPlatformCheckoutCounter,
|
||||||
|
adminPlatformCheckoutConfirm,
|
||||||
|
adminPlatformCheckoutDispute,
|
||||||
|
adminPlatformHandoff,
|
||||||
adminRefundOrder,
|
adminRefundOrder,
|
||||||
adminRefundStatus,
|
adminRefundStatus,
|
||||||
adminReleaseDeposit,
|
adminReleaseDeposit,
|
||||||
@@ -22,14 +27,15 @@ import {
|
|||||||
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
|
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
|
||||||
import {
|
import {
|
||||||
getSnapshotHafCoinM,
|
getSnapshotHafCoinM,
|
||||||
|
linesToList,
|
||||||
readSnapshot,
|
readSnapshot,
|
||||||
readSnapshotResources,
|
readSnapshotResources,
|
||||||
} from '@/features/orders/composables/useOrderSnapshot'
|
} from '@/features/orders/composables/useOrderSnapshot'
|
||||||
import { adminPath } from '@/shared/utils/adminPath'
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
import { formatCentWithSymbol } from '@/shared/utils/money'
|
import { centToYuan, formatCentWithSymbol } from '@/shared/utils/money'
|
||||||
import {
|
import {
|
||||||
disputeStatusLabel,
|
disputeStatusLabel,
|
||||||
handoffStatusLabel,
|
orderHandoffStatusLabel,
|
||||||
orderStatusLabel,
|
orderStatusLabel,
|
||||||
refundStatusLabel,
|
refundStatusLabel,
|
||||||
settlementStatusLabel,
|
settlementStatusLabel,
|
||||||
@@ -48,12 +54,28 @@ type OrderActionType =
|
|||||||
| 'seal'
|
| 'seal'
|
||||||
| 'abnormal'
|
| 'abnormal'
|
||||||
| 'reset'
|
| 'reset'
|
||||||
|
| 'platform_checkout_confirm'
|
||||||
|
| 'platform_checkout_dispute'
|
||||||
| 'deposit_hold'
|
| 'deposit_hold'
|
||||||
| 'deposit_release'
|
| 'deposit_release'
|
||||||
| ''
|
| ''
|
||||||
const actionType = ref<OrderActionType>('')
|
const actionType = ref<OrderActionType>('')
|
||||||
const reason = ref('')
|
const reason = ref('')
|
||||||
const refundStatus = ref<RefundStatus | null>(null)
|
const refundStatus = ref<RefundStatus | null>(null)
|
||||||
|
const platformHandoffVisible = ref(false)
|
||||||
|
const platformHandoffContent = ref('')
|
||||||
|
const platformHandoffReason = ref('')
|
||||||
|
const platformCheckoutCounterVisible = ref(false)
|
||||||
|
const platformCheckoutCounterForm = ref({
|
||||||
|
consumableAmountYuan: 0,
|
||||||
|
coin_consumed_m: 0,
|
||||||
|
depositDeductAmountYuan: 0,
|
||||||
|
reason: '',
|
||||||
|
evidenceText: '',
|
||||||
|
})
|
||||||
|
const offlineSettlementVisible = ref(false)
|
||||||
|
const offlineSettlementRemark = ref('')
|
||||||
|
const platformSubmitting = ref(false)
|
||||||
|
|
||||||
type FundSplitRow = {
|
type FundSplitRow = {
|
||||||
label: string
|
label: string
|
||||||
@@ -94,10 +116,40 @@ const canOperate = computed(
|
|||||||
() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status)
|
() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status)
|
||||||
)
|
)
|
||||||
const resetAction = computed(() => order.value?.admin_actions?.reset_handoff)
|
const resetAction = computed(() => order.value?.admin_actions?.reset_handoff)
|
||||||
|
const platformHandoffAction = computed(() => order.value?.admin_actions?.platform_handoff)
|
||||||
|
const platformCheckoutConfirmAction = computed(
|
||||||
|
() => order.value?.admin_actions?.platform_checkout_confirm
|
||||||
|
)
|
||||||
|
const platformCheckoutCounterAction = computed(
|
||||||
|
() => order.value?.admin_actions?.platform_checkout_counter
|
||||||
|
)
|
||||||
|
const platformCheckoutDisputeAction = computed(
|
||||||
|
() => order.value?.admin_actions?.platform_checkout_dispute
|
||||||
|
)
|
||||||
|
const offlineSettlementAction = computed(
|
||||||
|
() => order.value?.admin_actions?.platform_offline_settlement
|
||||||
|
)
|
||||||
const resetActionLabel = computed(() => {
|
const resetActionLabel = computed(() => {
|
||||||
return resetAction.value?.label || '重置'
|
return resetAction.value?.label || '重置'
|
||||||
})
|
})
|
||||||
const canResetHandoff = computed(() => resetAction.value?.enabled === true)
|
const canResetHandoff = computed(() => resetAction.value?.enabled === true)
|
||||||
|
const canPlatformHandoff = computed(() => platformHandoffAction.value?.enabled === true)
|
||||||
|
const canPlatformCheckoutConfirm = computed(
|
||||||
|
() => platformCheckoutConfirmAction.value?.enabled === true
|
||||||
|
)
|
||||||
|
const canPlatformCheckoutCounter = computed(
|
||||||
|
() => platformCheckoutCounterAction.value?.enabled === true
|
||||||
|
)
|
||||||
|
const canPlatformCheckoutDispute = computed(
|
||||||
|
() => platformCheckoutDisputeAction.value?.enabled === true
|
||||||
|
)
|
||||||
|
const canOfflineSettlement = computed(() => offlineSettlementAction.value?.enabled === true)
|
||||||
|
const isPlatformManaged = computed(
|
||||||
|
() =>
|
||||||
|
order.value?.handoff_mode === 'platform' ||
|
||||||
|
order.value?.settlement_mode === 'platform_managed'
|
||||||
|
)
|
||||||
|
const offlineSettlementStatus = computed(() => order.value?.offline_settlement_status || 'none')
|
||||||
// 押金暂扣:仅进行中、有实付押金、且未暂扣过的订单可暂扣。
|
// 押金暂扣:仅进行中、有实付押金、且未暂扣过的订单可暂扣。
|
||||||
const depositHoldStatus = computed(() => order.value?.deposit_hold_status || 'none')
|
const depositHoldStatus = computed(() => order.value?.deposit_hold_status || 'none')
|
||||||
const depositHoldAmountCent = computed(() => Number(order.value?.deposit_hold_amount_cent || 0))
|
const depositHoldAmountCent = computed(() => Number(order.value?.deposit_hold_amount_cent || 0))
|
||||||
@@ -123,13 +175,17 @@ const actionTitle = computed(() => {
|
|||||||
if (actionType.value === 'close') return '客服关闭订单'
|
if (actionType.value === 'close') return '客服关闭订单'
|
||||||
if (actionType.value === 'seal') return '封存订单'
|
if (actionType.value === 'seal') return '封存订单'
|
||||||
if (actionType.value === 'reset') return `${resetActionLabel.value}(恢复到对应待办)`
|
if (actionType.value === 'reset') return `${resetActionLabel.value}(恢复到对应待办)`
|
||||||
|
if (actionType.value === 'platform_checkout_confirm') return '客服确认结账'
|
||||||
|
if (actionType.value === 'platform_checkout_dispute') return '发起结账争议'
|
||||||
if (actionType.value === 'deposit_hold') return '暂扣押金'
|
if (actionType.value === 'deposit_hold') return '暂扣押金'
|
||||||
if (actionType.value === 'deposit_release') return '归还暂扣押金'
|
if (actionType.value === 'deposit_release') return '归还暂扣押金'
|
||||||
return '标记订单异常'
|
return '标记订单异常'
|
||||||
})
|
})
|
||||||
const actionConfirmButtonType = computed(() =>
|
const actionConfirmButtonType = computed(() => {
|
||||||
actionType.value === 'deposit_release' ? 'primary' : 'danger'
|
if (['deposit_release', 'platform_checkout_confirm'].includes(actionType.value)) return 'primary'
|
||||||
)
|
if (actionType.value === 'platform_checkout_dispute') return 'warning'
|
||||||
|
return 'danger'
|
||||||
|
})
|
||||||
const closeActionTip =
|
const closeActionTip =
|
||||||
'关闭订单并归档商品/账号;已支付订单会原路退款,押金已暂扣时押金部分会继续挂起。'
|
'关闭订单并归档商品/账号;已支付订单会原路退款,押金已暂扣时押金部分会继续挂起。'
|
||||||
const sealActionTip =
|
const sealActionTip =
|
||||||
@@ -341,6 +397,12 @@ async function submitAction() {
|
|||||||
} else if (actionType.value === 'reset') {
|
} else if (actionType.value === 'reset') {
|
||||||
await adminResetHandoff(order.value.id, reason.value)
|
await adminResetHandoff(order.value.id, reason.value)
|
||||||
ElMessage.success(`${resetActionLabel.value}成功`)
|
ElMessage.success(`${resetActionLabel.value}成功`)
|
||||||
|
} else if (actionType.value === 'platform_checkout_confirm') {
|
||||||
|
await adminPlatformCheckoutConfirm(order.value.id, reason.value)
|
||||||
|
ElMessage.success('结账已确认')
|
||||||
|
} else if (actionType.value === 'platform_checkout_dispute') {
|
||||||
|
await adminPlatformCheckoutDispute(order.value.id, reason.value)
|
||||||
|
ElMessage.success('已发起结账争议')
|
||||||
} else if (actionType.value === 'deposit_hold') {
|
} else if (actionType.value === 'deposit_hold') {
|
||||||
await adminHoldDeposit(order.value.id, reason.value)
|
await adminHoldDeposit(order.value.id, reason.value)
|
||||||
ElMessage.success('押金已暂扣')
|
ElMessage.success('押金已暂扣')
|
||||||
@@ -360,6 +422,97 @@ async function submitAction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPlatformHandoff() {
|
||||||
|
platformHandoffContent.value = ''
|
||||||
|
platformHandoffReason.value = ''
|
||||||
|
platformHandoffVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPlatformHandoff() {
|
||||||
|
if (!order.value) return
|
||||||
|
if (!platformHandoffContent.value.trim() || !platformHandoffReason.value.trim()) {
|
||||||
|
ElMessage.warning('请填写交接说明和操作原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platformSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await adminPlatformHandoff(
|
||||||
|
order.value.id,
|
||||||
|
platformHandoffContent.value.trim(),
|
||||||
|
platformHandoffReason.value.trim()
|
||||||
|
)
|
||||||
|
ElMessage.success('客服代交接已提交')
|
||||||
|
platformHandoffVisible.value = false
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '代交接失败'))
|
||||||
|
} finally {
|
||||||
|
platformSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPlatformCheckoutCounter() {
|
||||||
|
const checkout = order.value?.checkout
|
||||||
|
platformCheckoutCounterForm.value = {
|
||||||
|
consumableAmountYuan: centToYuan(checkout?.consumable_amount_cent || 0),
|
||||||
|
coin_consumed_m: Number(checkout?.coin_consumed_m || snapshotHafCoinM.value || 0),
|
||||||
|
depositDeductAmountYuan: centToYuan(
|
||||||
|
checkout?.deposit_deduct_amount_cent || checkout?.other_amount_cent || 0
|
||||||
|
),
|
||||||
|
reason: '',
|
||||||
|
evidenceText: Array.isArray(checkout?.evidence_urls) ? checkout.evidence_urls.join('\n') : '',
|
||||||
|
}
|
||||||
|
platformCheckoutCounterVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPlatformCheckoutCounter() {
|
||||||
|
if (!order.value) return
|
||||||
|
const form = platformCheckoutCounterForm.value
|
||||||
|
if (!form.reason.trim()) {
|
||||||
|
ElMessage.warning('请填写修改原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platformSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await adminPlatformCheckoutCounter(order.value.id, {
|
||||||
|
content: form.reason.trim(),
|
||||||
|
consumableAmountYuan: form.consumableAmountYuan,
|
||||||
|
coin_consumed_m: form.coin_consumed_m,
|
||||||
|
otherAmountYuan: form.depositDeductAmountYuan,
|
||||||
|
depositDeductAmountYuan: form.depositDeductAmountYuan,
|
||||||
|
reason: form.reason.trim(),
|
||||||
|
evidence_urls: linesToList(form.evidenceText),
|
||||||
|
})
|
||||||
|
ElMessage.success('结账方案已修改,等待租客确认')
|
||||||
|
platformCheckoutCounterVisible.value = false
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '修改结账方案失败'))
|
||||||
|
} finally {
|
||||||
|
platformSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOfflineSettlement() {
|
||||||
|
offlineSettlementRemark.value = ''
|
||||||
|
offlineSettlementVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitOfflineSettlement() {
|
||||||
|
if (!order.value) return
|
||||||
|
platformSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await adminMarkOfflineSettlement(order.value.id, offlineSettlementRemark.value.trim())
|
||||||
|
ElMessage.success('线下结算已确认')
|
||||||
|
offlineSettlementVisible.value = false
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '确认线下结算失败'))
|
||||||
|
} finally {
|
||||||
|
platformSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function orderRentedAt() {
|
function orderRentedAt() {
|
||||||
return order.value?.rented_at
|
return order.value?.rented_at
|
||||||
}
|
}
|
||||||
@@ -462,15 +615,49 @@ function firstQueryValue(value: unknown) {
|
|||||||
function formatHandoffRecordType(type: string) {
|
function formatHandoffRecordType(type: string) {
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
owner_handoff: '卖家交接',
|
owner_handoff: '卖家交接',
|
||||||
|
platform_handoff: '客服代交接',
|
||||||
renter_checkout: '买家结账',
|
renter_checkout: '买家结账',
|
||||||
owner_counter_checkout: '卖家反驳结账',
|
owner_counter_checkout: '卖家反驳结账',
|
||||||
|
platform_checkout_counter: '客服修改结账',
|
||||||
renter_confirm_checkout: '买家确认结账',
|
renter_confirm_checkout: '买家确认结账',
|
||||||
owner_accept_checkout: '卖家接受结账',
|
owner_accept_checkout: '卖家接受结账',
|
||||||
admin_arbitration: '客服仲裁',
|
admin_arbitration: '客服仲裁',
|
||||||
|
platform_checkout_dispute_opened: '客服发起结账争议',
|
||||||
}
|
}
|
||||||
return typeMap[type] || type
|
return typeMap[type] || type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handoffModeLabel(mode?: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
owner: '号主交接',
|
||||||
|
platform: '平台代管',
|
||||||
|
}
|
||||||
|
return map[mode || 'owner'] || mode || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function settlementModeLabel(mode?: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
owner_wallet: '号主钱包',
|
||||||
|
platform_managed: '线下结算',
|
||||||
|
}
|
||||||
|
return map[mode || 'owner_wallet'] || mode || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function offlineSettlementStatusLabel(status?: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
none: '无需线下结算',
|
||||||
|
pending: '待线下结算',
|
||||||
|
settled: '已线下结算',
|
||||||
|
}
|
||||||
|
return map[status || 'none'] || status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function offlineSettlementStatusType(status?: string) {
|
||||||
|
if (status === 'settled') return 'success'
|
||||||
|
if (status === 'pending') return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
function orderStatusType(status: string) {
|
function orderStatusType(status: string) {
|
||||||
if (['completed', 'renting'].includes(status)) return 'success'
|
if (['completed', 'renting'].includes(status)) return 'success'
|
||||||
if (['overdue', 'abnormal', 'checkout_disputing'].includes(status)) return 'danger'
|
if (['overdue', 'abnormal', 'checkout_disputing'].includes(status)) return 'danger'
|
||||||
@@ -527,6 +714,13 @@ function userDisplay(phone: string | undefined, id: number) {
|
|||||||
return phone ? `${phone} / ID ${id}` : `ID ${id}`
|
return phone ? `${phone} / ID ${id}` : `ID ${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sellerDisplay(row: Order) {
|
||||||
|
if (row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed') {
|
||||||
|
return row.managed_admin_id ? `平台代管 / 客服ID ${row.managed_admin_id}` : '平台代管'
|
||||||
|
}
|
||||||
|
return userDisplay(row.owner_phone, row.owner_id)
|
||||||
|
}
|
||||||
|
|
||||||
function displayValue(value: unknown) {
|
function displayValue(value: unknown) {
|
||||||
if (value === undefined || value === null || value === '') return '-'
|
if (value === undefined || value === null || value === '') return '-'
|
||||||
if (Array.isArray(value))
|
if (Array.isArray(value))
|
||||||
@@ -574,6 +768,34 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')">
|
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')">
|
||||||
{{ resetActionLabel }}
|
{{ resetActionLabel }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button v-if="canPlatformHandoff" type="primary" plain @click="openPlatformHandoff">
|
||||||
|
{{ platformHandoffAction?.label || '客服代交接' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canPlatformCheckoutConfirm"
|
||||||
|
type="primary"
|
||||||
|
@click="openAction('platform_checkout_confirm')"
|
||||||
|
>
|
||||||
|
{{ platformCheckoutConfirmAction?.label || '客服确认结账' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canPlatformCheckoutCounter"
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
@click="openPlatformCheckoutCounter"
|
||||||
|
>
|
||||||
|
{{ platformCheckoutCounterAction?.label || '客服修改结账方案' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canPlatformCheckoutDispute"
|
||||||
|
type="warning"
|
||||||
|
@click="openAction('platform_checkout_dispute')"
|
||||||
|
>
|
||||||
|
{{ platformCheckoutDisputeAction?.label || '发起结账争议' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="canOfflineSettlement" type="success" @click="openOfflineSettlement">
|
||||||
|
{{ offlineSettlementAction?.label || '确认线下结算' }}
|
||||||
|
</el-button>
|
||||||
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
|
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
|
||||||
>标记异常</el-button
|
>标记异常</el-button
|
||||||
>
|
>
|
||||||
@@ -627,13 +849,18 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>订单状态</span>
|
<span>订单状态</span>
|
||||||
<strong>{{ orderStatusLabel(order.status) }}</strong>
|
<strong>{{ orderStatusLabel(order.status) }}</strong>
|
||||||
<small>{{ handoffStatusLabel(order.handoff_status) }}</small>
|
<small>{{ orderHandoffStatusLabel(order) }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>结算状态</span>
|
<span>结算状态</span>
|
||||||
<strong>{{ settlementStatusLabel(order.settlement_status) }}</strong>
|
<strong>{{ settlementStatusLabel(order.settlement_status) }}</strong>
|
||||||
<small>更新 {{ formatDateTime(order.updated_at) }}</small>
|
<small>更新 {{ formatDateTime(order.updated_at) }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isPlatformManaged" class="metric-card">
|
||||||
|
<span>线下结算</span>
|
||||||
|
<strong>{{ offlineSettlementStatusLabel(offlineSettlementStatus) }}</strong>
|
||||||
|
<small>{{ moneyCent(order.offline_settlement_amount_cent) }}</small>
|
||||||
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>订单总额</span>
|
<span>订单总额</span>
|
||||||
<strong>{{ moneyCent(orderTotalCent) }}</strong>
|
<strong>{{ moneyCent(orderTotalCent) }}</strong>
|
||||||
@@ -717,7 +944,7 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>号主</dt>
|
<dt>号主</dt>
|
||||||
<dd>{{ userDisplay(order.owner_phone, order.owner_id) }}</dd>
|
<dd>{{ sellerDisplay(order) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>创建时间</dt>
|
<dt>创建时间</dt>
|
||||||
@@ -738,6 +965,45 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="isPlatformManaged" class="dashboard-panel detail-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>平台代管</h2>
|
||||||
|
<el-tag :type="offlineSettlementStatusType(offlineSettlementStatus)" effect="light">
|
||||||
|
{{ offlineSettlementStatusLabel(offlineSettlementStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<div>
|
||||||
|
<dt>交接模式</dt>
|
||||||
|
<dd>{{ handoffModeLabel(order.handoff_mode) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>结算模式</dt>
|
||||||
|
<dd>{{ settlementModeLabel(order.settlement_mode) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>负责客服</dt>
|
||||||
|
<dd>{{ order.managed_admin_id ? `ID ${order.managed_admin_id}` : '-' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>待打款金额</dt>
|
||||||
|
<dd>{{ moneyCent(order.offline_settlement_amount_cent) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="order.offline_settled_by">
|
||||||
|
<dt>确认客服</dt>
|
||||||
|
<dd>ID {{ order.offline_settled_by }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="order.offline_settled_at">
|
||||||
|
<dt>确认时间</dt>
|
||||||
|
<dd>{{ formatDateTime(order.offline_settled_at) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="order.offline_settlement_remark" class="wide">
|
||||||
|
<dt>线下备注</dt>
|
||||||
|
<dd>{{ order.offline_settlement_remark }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="dashboard-panel detail-panel">
|
<section class="dashboard-panel detail-panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h2>资金拆分</h2>
|
<h2>资金拆分</h2>
|
||||||
@@ -999,6 +1265,131 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="platformHandoffVisible"
|
||||||
|
title="客服代交接"
|
||||||
|
width="560px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-if="order" class="dialog-body">
|
||||||
|
<p>
|
||||||
|
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
|
||||||
|
</p>
|
||||||
|
<el-input
|
||||||
|
v-model="platformHandoffContent"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
placeholder="填写发给租客的交接说明"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="platformHandoffReason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="填写客服操作原因"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="platformHandoffVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="platformSubmitting" @click="submitPlatformHandoff">
|
||||||
|
提交交接
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="platformCheckoutCounterVisible"
|
||||||
|
title="客服修改结账方案"
|
||||||
|
width="620px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-if="order" class="dialog-body">
|
||||||
|
<p>
|
||||||
|
<strong>{{ order.order_no }}</strong> · 当前号主收入
|
||||||
|
{{ moneyCent(order.checkout?.owner_income_amount_cent) }}
|
||||||
|
</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>消耗品金额(元)</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="platformCheckoutCounterForm.consumableAmountYuan"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>哈夫币消耗(M)</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="platformCheckoutCounterForm.coin_consumed_m"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>押金赔付扣除(元)</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="platformCheckoutCounterForm.depositDeductAmountYuan"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="platformCheckoutCounterForm.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="填写修改原因,将同步给租客并写入审计日志"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="platformCheckoutCounterForm.evidenceText"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="证据链接,一行一个,可留空"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="platformCheckoutCounterVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="platformSubmitting"
|
||||||
|
@click="submitPlatformCheckoutCounter"
|
||||||
|
>
|
||||||
|
提交修改
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="offlineSettlementVisible"
|
||||||
|
title="确认线下结算"
|
||||||
|
width="520px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-if="order" class="dialog-body">
|
||||||
|
<p>
|
||||||
|
待打款金额
|
||||||
|
<strong>{{ moneyCent(order.offline_settlement_amount_cent) }}</strong>
|
||||||
|
</p>
|
||||||
|
<el-input
|
||||||
|
v-model="offlineSettlementRemark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="填写线下转账备注,可留空"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="offlineSettlementVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="platformSubmitting" @click="submitOfflineSettlement">
|
||||||
|
确认已结算
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1250,6 +1641,35 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body p {
|
||||||
|
margin: 0;
|
||||||
|
color: #52616f;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: #52616f;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid :deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.order-detail-grid {
|
.order-detail-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
|
|||||||
|
|
||||||
import { fetchAdminOrders, type AdminOrderQuery, type Order } from '@/features/orders'
|
import { fetchAdminOrders, type AdminOrderQuery, type Order } from '@/features/orders'
|
||||||
import {
|
import {
|
||||||
handoffStatusLabel,
|
orderHandoffStatusLabel,
|
||||||
orderStatusLabel,
|
orderStatusLabel,
|
||||||
settlementStatusLabel,
|
settlementStatusLabel,
|
||||||
} from '@/shared/utils/statusLabels'
|
} from '@/shared/utils/statusLabels'
|
||||||
@@ -233,7 +233,7 @@ function userText(value: string | number | undefined) {
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="stacked-cell compact">
|
<div class="stacked-cell compact">
|
||||||
<strong>{{ orderStatusLabel(row.status) }}</strong>
|
<strong>{{ orderStatusLabel(row.status) }}</strong>
|
||||||
<span>{{ handoffStatusLabel(row.handoff_status) }}</span>
|
<span>{{ orderHandoffStatusLabel(row) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -33,7 +33,15 @@ export interface Order {
|
|||||||
counter_info?: string
|
counter_info?: string
|
||||||
status: OrderStatus
|
status: OrderStatus
|
||||||
handoff_status: HandoffStatus
|
handoff_status: HandoffStatus
|
||||||
|
handoff_mode?: string
|
||||||
|
settlement_mode?: string
|
||||||
|
managed_admin_id?: number
|
||||||
settlement_status: SettlementStatus
|
settlement_status: SettlementStatus
|
||||||
|
offline_settlement_status?: string
|
||||||
|
offline_settlement_amount_cent?: number
|
||||||
|
offline_settlement_remark?: string
|
||||||
|
offline_settled_by?: number
|
||||||
|
offline_settled_at?: string
|
||||||
refund_status?: string
|
refund_status?: string
|
||||||
refund_amount_cent?: number
|
refund_amount_cent?: number
|
||||||
deposit_hold_status?: string
|
deposit_hold_status?: string
|
||||||
@@ -51,6 +59,11 @@ export interface Order {
|
|||||||
|
|
||||||
export interface AdminActions {
|
export interface AdminActions {
|
||||||
reset_handoff?: AdminAction
|
reset_handoff?: AdminAction
|
||||||
|
platform_handoff?: AdminAction
|
||||||
|
platform_checkout_confirm?: AdminAction
|
||||||
|
platform_checkout_counter?: AdminAction
|
||||||
|
platform_checkout_dispute?: AdminAction
|
||||||
|
platform_offline_settlement?: AdminAction
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAction {
|
export interface AdminAction {
|
||||||
@@ -373,6 +386,46 @@ export async function adminResetHandoff(id: number, reason: string) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function adminPlatformHandoff(id: number, content: string, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(
|
||||||
|
`/admin/orders/${id}/platform-handoff`,
|
||||||
|
{ content, reason }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminPlatformCheckoutConfirm(id: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ confirmed: boolean }>>(
|
||||||
|
`/admin/orders/${id}/platform-checkout/confirm`,
|
||||||
|
{ reason }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminPlatformCheckoutCounter(id: number, payload: SubmitCheckoutPayload) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Checkout>>(
|
||||||
|
`/admin/orders/${id}/platform-checkout/counter`,
|
||||||
|
toCheckoutRequest(payload)
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminPlatformCheckoutDispute(id: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ disputed: boolean }>>(
|
||||||
|
`/admin/orders/${id}/platform-checkout/dispute`,
|
||||||
|
{ reason }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminMarkOfflineSettlement(id: number, remark: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ settled: boolean }>>(
|
||||||
|
`/admin/orders/${id}/offline-settlement`,
|
||||||
|
{ remark }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function adminRefundStatus(id: number) {
|
export async function adminRefundStatus(id: number) {
|
||||||
const { data } = await apiClient.get<ApiResponse<RefundStatus>>(
|
const { data } = await apiClient.get<ApiResponse<RefundStatus>>(
|
||||||
`/admin/orders/${id}/refund-status`
|
`/admin/orders/${id}/refund-status`
|
||||||
|
|||||||
@@ -234,13 +234,16 @@ export function ownerActualIncome(item: Order, userID: number | undefined | null
|
|||||||
export function formatHandoffRecordType(type: string) {
|
export function formatHandoffRecordType(type: string) {
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
owner_handoff: '卖家交接',
|
owner_handoff: '卖家交接',
|
||||||
|
platform_handoff: '客服代交接',
|
||||||
renter_checkout: '买家结账',
|
renter_checkout: '买家结账',
|
||||||
owner_counter_checkout: '卖家反驳结账',
|
owner_counter_checkout: '卖家反驳结账',
|
||||||
|
platform_checkout_counter: '客服修改结账',
|
||||||
renter_confirm_checkout: '买家确认结账',
|
renter_confirm_checkout: '买家确认结账',
|
||||||
owner_accept_checkout: '卖家接受结账',
|
owner_accept_checkout: '卖家接受结账',
|
||||||
admin_arbitration: '客服仲裁',
|
admin_arbitration: '客服仲裁',
|
||||||
dispute_opened: '发起申诉',
|
dispute_opened: '发起申诉',
|
||||||
checkout_dispute_opened: '发起结账争议',
|
checkout_dispute_opened: '发起结账争议',
|
||||||
|
platform_checkout_dispute_opened: '客服发起结账争议',
|
||||||
dispute_cancelled: '取消申诉',
|
dispute_cancelled: '取消申诉',
|
||||||
checkout_dispute_cancelled: '取消结账争议',
|
checkout_dispute_cancelled: '取消结账争议',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
OrderHandoffTimeline,
|
OrderHandoffTimeline,
|
||||||
OrderResourceUsageEditor,
|
OrderResourceUsageEditor,
|
||||||
} from '@/features/orders'
|
} from '@/features/orders'
|
||||||
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
import { orderHandoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
|
||||||
// 移动端支付收银台(基于通用 useOrderPaymentCashier 的薄封装:注入 vant toast + App 浏览器跳转)。
|
// 移动端支付收银台(基于通用 useOrderPaymentCashier 的薄封装:注入 vant toast + App 浏览器跳转)。
|
||||||
@@ -202,7 +202,7 @@ async function copyListingCode() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="meta-item">
|
<div class="meta-item">
|
||||||
<span class="meta-label">交接状态</span>
|
<span class="meta-label">交接状态</span>
|
||||||
<strong class="meta-value">{{ handoffStatusLabel(order.handoff_status) }}</strong>
|
<strong class="meta-value">{{ orderHandoffStatusLabel(order) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { useOrderActions } from '@/features/orders/composables/useOrderActions'
|
import { useOrderActions } from '@/features/orders/composables/useOrderActions'
|
||||||
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
|
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
|
||||||
import { formatCent } from '@/shared/utils/money'
|
import { formatCent } from '@/shared/utils/money'
|
||||||
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
import { orderHandoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
import type { PaymentPayWay } from '@/features/orders/api/orders'
|
import type { PaymentPayWay } from '@/features/orders/api/orders'
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ watch([() => route.query.focus, order, loading], () => {
|
|||||||
<el-step
|
<el-step
|
||||||
title="待交接"
|
title="待交接"
|
||||||
:description="
|
:description="
|
||||||
order.status === 'pending_handoff' ? handoffStatusLabel(order.handoff_status) : ''
|
order.status === 'pending_handoff' ? orderHandoffStatusLabel(order) : ''
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<el-step
|
<el-step
|
||||||
@@ -383,7 +383,7 @@ watch([() => route.query.focus, order, loading], () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span class="metric-label">交接状态</span>
|
<span class="metric-label">交接状态</span>
|
||||||
<strong class="metric-value">{{ handoffStatusLabel(order.handoff_status) }}</strong>
|
<strong class="metric-value">{{ orderHandoffStatusLabel(order) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card highlight">
|
<div class="metric-card highlight">
|
||||||
<span class="metric-label">{{ orderAmountLabel }}</span>
|
<span class="metric-label">{{ orderAmountLabel }}</span>
|
||||||
|
|||||||
@@ -160,6 +160,24 @@ export function handoffStatusLabel(status: string) {
|
|||||||
return readLabel(handoffStatusMap, status)
|
return readLabel(handoffStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function orderHandoffStatusLabel(order?: {
|
||||||
|
handoff_status?: string
|
||||||
|
handoff_mode?: string
|
||||||
|
settlement_mode?: string
|
||||||
|
} | null) {
|
||||||
|
const status = order?.handoff_status || ''
|
||||||
|
if (order?.handoff_mode !== 'platform' && order?.settlement_mode !== 'platform_managed') {
|
||||||
|
return handoffStatusLabel(status)
|
||||||
|
}
|
||||||
|
const platformMap: Record<string, string> = {
|
||||||
|
pending_owner: '待客服交接',
|
||||||
|
owner_timeout: '客服交接超时',
|
||||||
|
pending_owner_checkout: '待客服确认结账',
|
||||||
|
owner_checkout_confirm_timeout: '客服确认结账超时',
|
||||||
|
}
|
||||||
|
return platformMap[status] || handoffStatusLabel(status)
|
||||||
|
}
|
||||||
|
|
||||||
export function settlementStatusLabel(status: string) {
|
export function settlementStatusLabel(status: string) {
|
||||||
return readLabel(settlementStatusMap, status)
|
return readLabel(settlementStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user