修复平台代管订单流程

This commit is contained in:
yml2213
2026-07-25 19:17:19 +08:00
parent e0cb15f408
commit 4bc25b87d8
35 changed files with 2216 additions and 284 deletions
+3
View File
@@ -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{},
+52
View File
@@ -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{},
+3
View File
@@ -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"`
+40 -32
View File
@@ -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 {
+69 -20
View File
@@ -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
+29 -19
View File
@@ -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,
+3
View File
@@ -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
+72 -42
View File
@@ -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"
+63 -41
View File
@@ -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:
+93 -37
View File
@@ -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
})
}
+108 -43
View File
@@ -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
+25 -10
View File
@@ -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 {
+75 -23
View File
@@ -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
+5
View File
@@ -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>
+18
View File
@@ -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)
} }