diff --git a/backend/internal/database/test_helper.go b/backend/internal/database/test_helper.go index 6859f1d..0f2394b 100644 --- a/backend/internal/database/test_helper.go +++ b/backend/internal/database/test_helper.go @@ -53,8 +53,11 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error { &model.RentalOrder{}, &model.PaymentOrder{}, &model.Notification{}, + &model.AdminNotification{}, &model.HandoffRecord{}, &model.OrderCheckout{}, + &model.WalletAccount{}, + &model.WalletLedger{}, &model.AuditLog{}, &model.ChatConversation{}, &model.ChatParticipant{}, diff --git a/backend/internal/jobs/ordertimeout/job.go b/backend/internal/jobs/ordertimeout/job.go index 0f031d7..5a698ea 100644 --- a/backend/internal/jobs/ordertimeout/job.go +++ b/backend/internal/jobs/ordertimeout/job.go @@ -10,6 +10,7 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/adminnotification" "hfb_sys/backend/internal/modules/chat" "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 分布式锁确保同一时刻只有一个实例执行超时扫描。 // 未配置 Redis 时直接执行;Redis 出错时降级执行(事务内行锁与状态二次校验可兜底,不会写坏数据)。 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) order.HandoffStatus = "owner_timeout" 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, notification.Entry{ UserID: order.RenterID, @@ -506,6 +542,22 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time order.Status = "abnormal" order.HandoffStatus = "owner_checkout_confirm_timeout" 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, notification.Entry{ UserID: order.RenterID, diff --git a/backend/internal/jobs/ordertimeout/job_test.go b/backend/internal/jobs/ordertimeout/job_test.go index 215190c..75d4074 100644 --- a/backend/internal/jobs/ordertimeout/job_test.go +++ b/backend/internal/jobs/ordertimeout/job_test.go @@ -25,6 +25,7 @@ func setupOrderTimeoutTestDB(t *testing.T) *gorm.DB { &model.RentalOrder{}, &model.HandoffRecord{}, &model.Notification{}, + &model.AdminNotification{}, &model.ChatConversation{}, &model.ChatParticipant{}, &model.ChatMessage{}, diff --git a/backend/internal/model/listing.go b/backend/internal/model/listing.go index 7b32deb..53cf73c 100644 --- a/backend/internal/model/listing.go +++ b/backend/internal/model/listing.go @@ -38,6 +38,9 @@ type RentalListing struct { InTransaction bool `gorm:"not null;default:false" json:"in_transaction"` Status string `gorm:"size:32;not null;default:'draft'" json:"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"` PublishedAt *time.Time `json:"published_at"` CreatedAt time.Time `json:"created_at"` diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 75dda49..234390a 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -7,38 +7,46 @@ import ( ) type RentalOrder struct { - ID uint64 `gorm:"primaryKey" json:"id"` - OrderNo string `gorm:"size:64;not null;uniqueIndex" json:"order_no"` - ListingID uint64 `gorm:"not null;index" json:"listing_id"` - AccountID uint64 `gorm:"not null;index" json:"account_id"` - OwnerID uint64 `gorm:"not null;index" json:"owner_id"` - RenterID uint64 `gorm:"not null;index" json:"renter_id"` - RentedAt *time.Time `json:"rented_at"` - HandoffStartedAt *time.Time `json:"handoff_started_at"` - EstimatedDurationHours int `gorm:"not null;default:24" json:"estimated_duration_hours"` - RentAmountCent int64 `gorm:"not null;default:0" json:"-"` - OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"` - DepositAmountCent int64 `gorm:"not null;default:0" json:"-"` - DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` - DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"` - PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"` - AccountSnapshot datatypes.JSON `json:"account_snapshot"` - Status string `gorm:"size:32;not null;default:'pending_payment'" json:"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"` - RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"` - RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"` - RefundedAt *time.Time `json:"refunded_at"` - DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"` - 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"` + ID uint64 `gorm:"primaryKey" json:"id"` + OrderNo string `gorm:"size:64;not null;uniqueIndex" json:"order_no"` + ListingID uint64 `gorm:"not null;index" json:"listing_id"` + AccountID uint64 `gorm:"not null;index" json:"account_id"` + OwnerID uint64 `gorm:"not null;index" json:"owner_id"` + RenterID uint64 `gorm:"not null;index" json:"renter_id"` + RentedAt *time.Time `json:"rented_at"` + HandoffStartedAt *time.Time `json:"handoff_started_at"` + EstimatedDurationHours int `gorm:"not null;default:24" json:"estimated_duration_hours"` + RentAmountCent int64 `gorm:"not null;default:0" json:"-"` + OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"` + DepositAmountCent int64 `gorm:"not null;default:0" json:"-"` + DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` + DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"` + PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"` + AccountSnapshot datatypes.JSON `json:"account_snapshot"` + Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"` + HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_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"` + SettlementStatus string `gorm:"size:32;not null;default:'unsettled'" json:"settlement_status"` + OfflineSettlementStatus string `gorm:"size:16;not null;default:'none';index" json:"offline_settlement_status"` + OfflineSettlementAmountCent int64 `gorm:"not null;default:0" json:"offline_settlement_amount_cent"` + OfflineSettlementRemark string `gorm:"size:255;not null;default:''" json:"offline_settlement_remark"` + OfflineSettledBy *uint64 `json:"offline_settled_by"` + OfflineSettledAt *time.Time `json:"offline_settled_at"` + RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"` + RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"` + RefundedAt *time.Time `json:"refunded_at"` + DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"` + 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 { diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index fdb9f8e..febb9f9 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -10,6 +10,7 @@ import ( "hfb_sys/backend/internal/listingstatus" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/adminnotification" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/wallet" "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.SettlementStatus = "arbitrated" 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" { order.HandoffStatus = "arbitrated" } @@ -147,25 +153,32 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r }); err != nil { return err } - if err := notification.Append(tx, - notification.Entry{ - UserID: order.RenterID, - Type: "arbitration", - Title: "申诉仲裁已完成", - Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。", - BizType: "dispute", - BizID: &disputeID, - }, - notification.Entry{ - UserID: order.OwnerID, - Type: "arbitration", - Title: "申诉仲裁已完成", - Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。", - BizType: "dispute", - BizID: &disputeID, - }, - ); err != nil { - return err + renterNotification := notification.Entry{ + UserID: order.RenterID, + Type: "arbitration", + Title: "申诉仲裁已完成", + Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。", + BizType: "dispute", + BizID: &disputeID, + } + if isPlatformManagedOrder(order) { + if err := notification.Append(tx, renterNotification); err != nil { + return err + } + } else { + if err := notification.Append(tx, + renterNotification, + notification.Entry{ + UserID: order.OwnerID, + Type: "arbitration", + Title: "申诉仲裁已完成", + Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。", + BizType: "dispute", + BizID: &disputeID, + }, + ); err != nil { + return err + } } return nil }) @@ -181,6 +194,42 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r 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 { Entries []wallet.Entry RenterRefundAmountCent int64 diff --git a/backend/internal/modules/dispute/mutation.go b/backend/internal/modules/dispute/mutation.go index 553fe32..5024349 100644 --- a/backend/internal/modules/dispute/mutation.go +++ b/backend/internal/modules/dispute/mutation.go @@ -115,25 +115,35 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, title = "订单进入结账争议" content = "对方已发起结账争议,请等待客服仲裁或补充结账证据。" } - if err := notification.Append(tx, - notification.Entry{ - UserID: targetID, - Type: "dispute", - Title: title, - Content: content, - BizType: "dispute", - BizID: &disputeID, - }, - notification.Entry{ - UserID: userID, - Type: "dispute", - Title: "申诉已提交", - Content: "申诉已进入待处理状态,客服仲裁后会通知双方。", - BizType: "dispute", - BizID: &disputeID, - }, - ); err != nil { - return err + initiatorNotification := notification.Entry{ + UserID: userID, + Type: "dispute", + Title: "申诉已提交", + Content: "申诉已进入待处理状态,客服仲裁后会通知双方。", + BizType: "dispute", + BizID: &disputeID, + } + if isPlatformManagedOrder(order) { + if err := appendPlatformManagedAdminNotification(tx, order, "dispute", title, content); err != nil { + return err + } + if err := notification.Append(tx, initiatorNotification); err != nil { + return err + } + } else { + if err := notification.Append(tx, + notification.Entry{ + UserID: targetID, + Type: "dispute", + Title: title, + Content: content, + BizType: "dispute", + BizID: &disputeID, + }, + initiatorNotification, + ); err != nil { + return err + } } createdID = row.ID return nil diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index 174c44f..8dd4cdb 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -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) { order := model.RentalOrder{ ID: 11, diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 023668f..6afdb74 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -32,6 +32,9 @@ type ListingDTO struct { InTransaction bool `json:"in_transaction"` Status string `json:"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"` PublishedAt *time.Time `json:"published_at"` ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"` diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go index a06728d..5c07519 100644 --- a/backend/internal/modules/listing/mutation.go +++ b/backend/internal/modules/listing/mutation.go @@ -170,6 +170,9 @@ func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload extern DepositAmountCent: req.DepositAmountCent, Status: "draft", ReviewStatus: "pending", + HandoffMode: "platform", + SettlementMode: "platform_managed", + ManagedAdminID: &admin.ID, } if err := tx.Create(&listing).Error; err != nil { return err diff --git a/backend/internal/modules/listing/presenter.go b/backend/internal/modules/listing/presenter.go index 04b0e21..fd009e6 100644 --- a/backend/internal/modules/listing/presenter.go +++ b/backend/internal/modules/listing/presenter.go @@ -127,6 +127,9 @@ func (row listingRow) toDTO() ListingDTO { InTransaction: row.InTransaction, Status: row.Status, ReviewStatus: reviewStatus, + HandoffMode: row.HandoffMode, + SettlementMode: row.SettlementMode, + ManagedAdminID: row.ManagedAdminID, ReviewReason: reviewReason, PublishedAt: row.PublishedAt, CreatedAt: row.CreatedAt, @@ -159,6 +162,9 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO { InTransaction: listing.InTransaction, Status: listing.Status, ReviewStatus: reviewStatus, + HandoffMode: listing.HandoffMode, + SettlementMode: listing.SettlementMode, + ManagedAdminID: listing.ManagedAdminID, ReviewReason: reviewReason, PublishedAt: listing.PublishedAt, CreatedAt: listing.CreatedAt, diff --git a/backend/internal/modules/listing/review.go b/backend/internal/modules/listing/review.go index c844cec..80121f2 100644 --- a/backend/internal/modules/listing/review.go +++ b/backend/internal/modules/listing/review.go @@ -63,6 +63,9 @@ func (r *Repository) TransferOwner(ctx context.Context, adminID uint64, listingI beforeOwnerID := listing.OwnerID listing.OwnerID = target.ID + listing.HandoffMode = "owner" + listing.SettlementMode = "owner_wallet" + listing.ManagedAdminID = nil account.OwnerID = target.ID if err := tx.Save(account).Error; err != nil { return err diff --git a/backend/internal/modules/order/checkout.go b/backend/internal/modules/order/checkout.go index 628d0bd..b9d1f7e 100644 --- a/backend/internal/modules/order/checkout.go +++ b/backend/internal/modules/order/checkout.go @@ -31,10 +31,14 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID if hasOpen { return ErrCheckoutCannotSubmit } + checkoutToUserID := order.OwnerID + if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 { + checkoutToUserID = platformManagedAdminID(order) + } record := model.HandoffRecord{ OrderID: order.ID, FromUserID: order.RenterID, - ToUserID: order.OwnerID, + ToUserID: checkoutToUserID, Type: "renter_checkout", Content: req.Content, } @@ -58,15 +62,21 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID now := time.Now() order.HandoffStartedAt = &now orderID := order.ID - if err := notification.Append(tx, notification.Entry{ - UserID: order.OwnerID, - Type: "checkout", - Title: "租客已发起结账", - Content: "请检查账号状态和消耗明细,确认无误后完成结算;也可修改后交由租客确认。", - BizType: "order", - BizID: &orderID, - }); err != nil { - return err + if isPlatformSettlementOrder(order) { + if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil { + return err + } + } else { + if err := notification.Append(tx, notification.Entry{ + UserID: order.OwnerID, + Type: "checkout", + Title: "租客已发起结账", + Content: "请检查账号状态和消耗明细,确认无误后完成结算;也可修改后交由租客确认。", + BizType: "order", + BizID: &orderID, + }); err != nil { + return err + } } if err := tx.Save(&order).Error; err != nil { return err @@ -163,37 +173,18 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID return ErrCheckoutMaxRounds } - depositDeductCent := req.DepositDeductAmountCent - if depositDeductCent <= 0 && req.OtherAmountCent > 0 { - depositDeductCent = req.OtherAmountCent - } + 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() - checkout.Status = checkoutStatusCountered - 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 + applyCounterCheckoutUpdate(checkout, next, req.Reason, userID, round+1, now) notifyUserID := order.RenterID notifyTitle := "号主已修改结账金额" notifyContent := "请核对对方修正的消耗和结算金额。可同意完结、继续还价(最多 6 轮),或发起争议。" + notifyManagedAdmin := false if isOwner { checkout.Turn = checkoutTurnRenter order.Status = orderStatusPendingCheckoutAccept @@ -202,20 +193,30 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID checkout.Turn = checkoutTurnOwner order.Status = orderStatusPendingCheckoutConfirm order.HandoffStatus = handoffStatusPendingOwnerCheckout - notifyUserID = order.OwnerID notifyTitle = "租客已修改结账金额" + if isPlatformSettlementOrder(order) { + notifyManagedAdmin = true + } else { + notifyUserID = order.OwnerID + } } order.SettlementStatus = settlementStatusPending orderID := order.ID - if err := notification.Append(tx, notification.Entry{ - UserID: notifyUserID, - Type: "checkout", - Title: notifyTitle, - Content: notifyContent, - BizType: "order", - BizID: &orderID, - }); err != nil { - return err + if notifyManagedAdmin { + if err := appendManagedAdminNotification(tx, order, "checkout", notifyTitle, notifyContent); err != nil { + return err + } + } else { + if err := notification.Append(tx, notification.Entry{ + UserID: notifyUserID, + Type: "checkout", + Title: notifyTitle, + Content: notifyContent, + BizType: "order", + BizID: &orderID, + }); err != nil { + return err + } } if err := tx.Save(&order).Error; err != nil { return err @@ -238,6 +239,35 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID 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 租客同意当前提案并完结(轮到租客时) func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID uint64) error { var refund *refundAction diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index d21d59a..02fecdc 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -20,7 +20,9 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che order.HandoffStatus = handoffStatusReturned order.SettlementStatus = settlementStatusSettled order.SettledAt = &now - order.OwnerSettledAt = &now + if !isPlatformSettlementOrder(*order) { + order.OwnerSettledAt = &now + } if err := completeAssets(tx, listing, account); err != nil { 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 { + 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 var ownerEntries []wallet.Entry 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 { 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, notification.Entry{ UserID: order.RenterID, diff --git a/backend/internal/modules/order/constants.go b/backend/internal/modules/order/constants.go index 94c2282..29bae02 100644 --- a/backend/internal/modules/order/constants.go +++ b/backend/internal/modules/order/constants.go @@ -22,6 +22,7 @@ const ( handoffStatusReturnOverdue = "return_overdue" handoffStatusPendingOwnerCheckout = "pending_owner_checkout" handoffStatusPendingRenterCheckout = "pending_renter_checkout" + handoffStatusCheckoutDisputed = "checkout_disputed" handoffStatusReturned = "returned" handoffStatusOwnerTimeout = "owner_timeout" handoffStatusRenterConfirmTimeout = "renter_confirm_timeout" @@ -35,6 +36,17 @@ const ( settlementStatusPending = "pending" settlementStatusSettled = "settled" settlementStatusClosed = "closed" + settlementStatusDisputed = "disputed" + + handoffModeOwner = "owner" + handoffModePlatform = "platform" + + settlementModeOwnerWallet = "owner_wallet" + settlementModePlatformManaged = "platform_managed" + + offlineSettlementStatusNone = "none" + offlineSettlementStatusPending = "pending" + offlineSettlementStatusSettled = "settled" checkoutStatusSubmitted = "submitted" checkoutStatusCountered = "countered" diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 88285b6..b01e46a 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -9,50 +9,63 @@ import ( ) type OrderDTO struct { - ID uint64 `json:"id"` - OrderNo string `json:"order_no"` - ListingID uint64 `json:"listing_id"` - ListingNo string `json:"listing_no"` - AccountID uint64 `json:"account_id"` - OwnerID uint64 `json:"owner_id"` - RenterID uint64 `json:"renter_id"` - OwnerPhone string `json:"owner_phone,omitempty"` - RenterPhone string `json:"renter_phone,omitempty"` - Title string `json:"title"` - ServerRegion string `json:"server_region"` - LoginPlatform string `json:"login_platform"` - RentedAt *time.Time `json:"rented_at"` - EstimatedDurationHours int `json:"estimated_duration_hours"` - EstimatedEndAt *time.Time `json:"estimated_end_at,omitempty"` - PriceRole string `json:"price_role,omitempty"` - DisplayAmountCent int64 `json:"display_amount_cent"` - RentAmountCent *int64 `json:"rent_amount_cent,omitempty"` - OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"` - DepositAmountCent int64 `json:"deposit_amount_cent"` - DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"` - DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"` - PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` - AccountSnapshot datatypes.JSON `json:"account_snapshot"` - Status string `json:"status"` - HandoffStatus string `json:"handoff_status"` - SettlementStatus string `json:"settlement_status"` - RefundStatus string `json:"refund_status,omitempty"` - RefundAmountCent int64 `json:"refund_amount_cent"` - DepositHoldStatus string `json:"deposit_hold_status,omitempty"` - DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"` - 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"` + ID uint64 `json:"id"` + OrderNo string `json:"order_no"` + ListingID uint64 `json:"listing_id"` + ListingNo string `json:"listing_no"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + RenterID uint64 `json:"renter_id"` + OwnerPhone string `json:"owner_phone,omitempty"` + RenterPhone string `json:"renter_phone,omitempty"` + Title string `json:"title"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + RentedAt *time.Time `json:"rented_at"` + EstimatedDurationHours int `json:"estimated_duration_hours"` + EstimatedEndAt *time.Time `json:"estimated_end_at,omitempty"` + PriceRole string `json:"price_role,omitempty"` + DisplayAmountCent int64 `json:"display_amount_cent"` + RentAmountCent *int64 `json:"rent_amount_cent,omitempty"` + OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"` + DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"` + PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` + AccountSnapshot datatypes.JSON `json:"account_snapshot"` + Status string `json:"status"` + HandoffStatus string `json:"handoff_status"` + HandoffMode string `json:"handoff_mode"` + SettlementMode string `json:"settlement_mode"` + ManagedAdminID *uint64 `json:"managed_admin_id,omitempty"` + SettlementStatus string `json:"settlement_status"` + OfflineSettlementStatus string `json:"offline_settlement_status,omitempty"` + OfflineSettlementAmountCent int64 `json:"offline_settlement_amount_cent"` + OfflineSettlementRemark string `json:"offline_settlement_remark,omitempty"` + OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"` + OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"` + RefundStatus string `json:"refund_status,omitempty"` + RefundAmountCent int64 `json:"refund_amount_cent"` + DepositHoldStatus string `json:"deposit_hold_status,omitempty"` + DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"` + 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 { - 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 { @@ -100,6 +113,15 @@ type AdminActionRequest struct { 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 { Page int PageSize int diff --git a/backend/internal/modules/order/handler_admin.go b/backend/internal/modules/order/handler_admin.go index b031a6c..bef88f3 100644 --- a/backend/internal/modules/order/handler_admin.go +++ b/backend/internal/modules/order/handler_admin.go @@ -2,6 +2,8 @@ package order import ( "context" + "errors" + "io" "hfb_sys/backend/internal/middleware" "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}) } +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) { h.adminAction(c, h.service.AdminHoldDeposit, gin.H{"held": true}) } diff --git a/backend/internal/modules/order/handler_error.go b/backend/internal/modules/order/handler_error.go index f602031..0e9bc3a 100644 --- a/backend/internal/modules/order/handler_error.go +++ b/backend/internal/modules/order/handler_error.go @@ -49,6 +49,8 @@ func writeOrderError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "checkout_deposit_shortfall", "押金不足以覆盖打超/赔付差额,无法自动完结,请发起争议由人工处理") case errors.Is(err, ErrInvalidCheckoutAmount): response.BadRequest(c, "结账金额不符合规则") + case errors.Is(err, ErrDisputeExists): + response.Error(c, http.StatusConflict, "dispute_exists", "当前订单已有处理中争议") case errors.Is(err, ErrPermissionDenied): response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单") case errors.Is(err, ErrDepositCannotHold): @@ -57,6 +59,8 @@ func writeOrderError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "deposit_not_held", "该订单押金未处于暂扣状态") case errors.Is(err, ErrDepositHoldAmountEmpty): 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): response.Error(c, http.StatusNotFound, "not_found", "订单不存在") default: diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index ab0b2d3..0034001 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -63,7 +63,11 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ AccountSnapshot: snapshot, Status: orderStatusPendingPayment, HandoffStatus: handoffStatusNone, + HandoffMode: listingHandoffMode(listing), + SettlementMode: listingSettlementMode(listing), + ManagedAdminID: listing.ManagedAdminID, SettlementStatus: settlementStatusUnsettled, + OfflineSettlementStatus: offlineSettlementStatusNone, } if err := tx.Create(&order).Error; err != nil { return err @@ -94,6 +98,20 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ 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) { if originalDepositCent <= 0 { return 0, 0, nil @@ -196,25 +214,41 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint conversationID = listingConv.ID } - if err := notification.Append(tx, - notification.Entry{ - UserID: order.OwnerID, - Type: "order", - Title: "收到新的租号订单", - Content: "租客已完成支付,请尽快提交交接说明。", - BizType: "order", - BizID: &orderID, - }, - notification.Entry{ + if isPlatformHandoffOrder(*order) { + if err := appendManagedAdminNotification(tx, *order, "order", "代管订单待交接", "租客已完成支付,请尽快在订单详情中提交交接说明。"); err != nil { + return 0, err + } + if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, Type: "order", Title: "订单支付成功", - Content: "支付已完成,等待号主提交交接说明。", + Content: "支付已完成,等待客服提交交接说明。", BizType: "order", BizID: &orderID, - }, - ); err != nil { - return 0, err + }); err != nil { + 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 { return 0, err @@ -257,25 +291,41 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) } else { order.HandoffStatus = handoffStatusCancelled } - if err := notification.Append(tx, - notification.Entry{ - UserID: order.OwnerID, - Type: "order", - Title: "订单已取消", - Content: "租客已取消订单,退款待客服审核。", - BizType: "order", - BizID: &orderID, - }, - notification.Entry{ + if isPlatformHandoffOrder(order) { + if err := appendManagedAdminNotification(tx, order, "order", "代管订单已取消", "租客已取消订单,退款待客服审核。"); err != nil { + return err + } + if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, Type: "order", Title: "订单取消成功", Content: "订单已取消,退款将由客服审核后原路退回。", BizType: "order", BizID: &orderID, - }, - ); err != nil { - return err + }); err != nil { + 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 { return err @@ -329,7 +379,7 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID } now := time.Now() 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 { return err } @@ -338,15 +388,21 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) order.RentedAt = &now orderID := order.ID - if err := notification.Append(tx, notification.Entry{ - UserID: order.OwnerID, - Type: "handoff", - Title: "租客已确认收号", - Content: "订单已进入使用中。", - BizType: "order", - BizID: &orderID, - }); err != nil { - return err + if isPlatformHandoffOrder(order) { + if err := appendManagedAdminNotification(tx, order, "handoff", "租客已确认收号", "代管订单已进入使用中。"); err != nil { + return err + } + } else { + if err := notification.Append(tx, notification.Entry{ + UserID: order.OwnerID, + Type: "handoff", + Title: "租客已确认收号", + Content: "订单已进入使用中。", + BizType: "order", + BizID: &orderID, + }); err != nil { + return err + } } return tx.Save(&order).Error }) diff --git a/backend/internal/modules/order/platform_managed.go b/backend/internal/modules/order/platform_managed.go new file mode 100644 index 0000000..bfcd57b --- /dev/null +++ b/backend/internal/modules/order/platform_managed.go @@ -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 + }) +} diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index cb7eedc..bde5392 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -9,16 +9,46 @@ import ( ) func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO { + actions := &AdminActionsDTO{} target, ok := resetTargetForOrder(order) - if !ok { - return nil - } - return &AdminActionsDTO{ - ResetHandoff: &AdminActionDTO{ + if ok { + actions.ResetHandoff = &AdminActionDTO{ Enabled: true, 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 { @@ -29,43 +59,51 @@ func (row orderRow) toAdminDTO() OrderDTO { ownerRentAmountCent := row.OwnerRentAmountCent platformFeeCent := row.PlatformFeeCent return OrderDTO{ - ID: row.ID, - OrderNo: row.OrderNo, - ListingID: row.ListingID, - ListingNo: row.ListingNo, - AccountID: row.AccountID, - OwnerID: row.OwnerID, - RenterID: row.RenterID, - OwnerPhone: row.OwnerPhone, - RenterPhone: row.RenterPhone, - Title: row.Title, - ServerRegion: row.ServerRegion, - LoginPlatform: row.LoginPlatform, - RentedAt: rentedAt, - EstimatedDurationHours: durationHours, - EstimatedEndAt: estimatedEndAt, - PriceRole: "admin", - DisplayAmountCent: row.RentAmountCent, - RentAmountCent: &rentAmountCent, - OwnerRentAmountCent: &ownerRentAmountCent, - DepositAmountCent: row.DepositAmountCent, - DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder), - DepositWaivedAmountCent: row.DepositWaivedAmountCent, - PlatformFeeCent: &platformFeeCent, - AccountSnapshot: row.AccountSnapshot, - Status: row.Status, - HandoffStatus: row.HandoffStatus, - SettlementStatus: row.SettlementStatus, - RefundStatus: row.RefundStatus, - 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, + ID: row.ID, + OrderNo: row.OrderNo, + ListingID: row.ListingID, + ListingNo: row.ListingNo, + AccountID: row.AccountID, + OwnerID: row.OwnerID, + RenterID: row.RenterID, + OwnerPhone: row.OwnerPhone, + RenterPhone: row.RenterPhone, + Title: row.Title, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + RentedAt: rentedAt, + EstimatedDurationHours: durationHours, + EstimatedEndAt: estimatedEndAt, + PriceRole: "admin", + DisplayAmountCent: row.RentAmountCent, + RentAmountCent: &rentAmountCent, + OwnerRentAmountCent: &ownerRentAmountCent, + DepositAmountCent: row.DepositAmountCent, + DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder), + DepositWaivedAmountCent: row.DepositWaivedAmountCent, + PlatformFeeCent: &platformFeeCent, + AccountSnapshot: row.AccountSnapshot, + Status: row.Status, + HandoffStatus: row.HandoffStatus, + HandoffMode: effectiveHandoffMode(row.RentalOrder), + SettlementMode: effectiveSettlementMode(row.RentalOrder), + ManagedAdminID: row.ManagedAdminID, + SettlementStatus: row.SettlementStatus, + OfflineSettlementStatus: effectiveOfflineSettlementStatus(row.RentalOrder), + OfflineSettlementAmountCent: row.OfflineSettlementAmountCent, + OfflineSettlementRemark: row.OfflineSettlementRemark, + OfflineSettledBy: row.OfflineSettledBy, + OfflineSettledAt: row.OfflineSettledAt, + RefundStatus: row.RefundStatus, + 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.DepositHeldAt = 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) 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 { if order.DepositOriginalAmountCent > 0 { return order.DepositOriginalAmountCent diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index 844715c..03a0e8f 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -155,16 +155,16 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c return model.OrderCheckout{}, err } return model.OrderCheckout{ - OrderID: order.ID, - InitiatedBy: initiatedBy, - Status: status, - RentAmountCent: settlement.ActualRentAmountCent, - OwnerRentAmountCent: settlement.OwnerRentIncomeCent, - PlatformFeeCent: settlement.PlatformFeeCent, - DepositAmountCent: order.DepositAmountCent, - ConsumableAmountCent: consumableAmountCent, - CoinConsumedM: roundQuantity(coinConsumedM), - OtherAmountCent: otherAmountCent, + OrderID: order.ID, + InitiatedBy: initiatedBy, + Status: status, + RentAmountCent: settlement.ActualRentAmountCent, + OwnerRentAmountCent: settlement.OwnerRentIncomeCent, + PlatformFeeCent: settlement.PlatformFeeCent, + DepositAmountCent: order.DepositAmountCent, + ConsumableAmountCent: consumableAmountCent, + CoinConsumedM: roundQuantity(coinConsumedM), + OtherAmountCent: otherAmountCent, // 存用户申报的押金赔付(损坏等),打超由结算自动计入 shortfall/overshoot DepositDeductAmountCent: deductAmountCent, RenterRefundAmountCent: settlement.RenterRefundCent, @@ -219,6 +219,17 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i } else { 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) @@ -227,6 +238,10 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i consumableUseRatio = maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0) } usedOwnerConsumablePriceCent := int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio)) + if usedBuyerConsumablePriceCent <= prepaidConsumablePriceCent { + usedBuyerConsumablePriceCent = minCent(usedBuyerConsumablePriceCent, prepaidConsumablePriceCent) + usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent) + } actualBuyerRentCent := usedBuyerCoinPriceCent + usedBuyerConsumablePriceCent actualOwnerRentCent := usedOwnerCoinPriceCent + usedOwnerConsumablePriceCent diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 4556b23..9792969 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -831,3 +831,332 @@ func TestSubmitCheckoutRefreshesStageTime(t *testing.T) { 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 +} diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index 6435803..466d0b3 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -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) { paid, waived := calculateDepositWaiver(500, 300, 0) if paid != 200 || waived != 300 { diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index d9cf67c..274488e 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -6,29 +6,31 @@ import ( ) var ( - ErrDependencyUnavailable = errors.New("dependency unavailable") - ErrInvalidRentHours = errors.New("invalid rent hours") - ErrListingUnavailable = errors.New("listing unavailable") - ErrCannotRentOwnListing = errors.New("cannot rent own listing") - ErrInsufficientBalance = errors.New("insufficient balance") - ErrOrderCannotPay = errors.New("order cannot pay") - ErrChannelPaymentRequired = errors.New("channel payment required") - ErrOrderCannotCancel = errors.New("order cannot cancel") - ErrOrderCannotHandoff = errors.New("order cannot handoff") - ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff") - ErrOrderCannotReceive = errors.New("order cannot receive") - ErrOrderCannotReturn = errors.New("order cannot return") - ErrOrderCannotComplete = errors.New("order cannot complete") - ErrCheckoutCannotSubmit = errors.New("checkout cannot submit") - ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm") - ErrCheckoutCannotCounter = errors.New("checkout cannot counter") - ErrCheckoutMaxRounds = errors.New("checkout max rounds reached") - ErrCheckoutDepositShortfall = errors.New("checkout deposit shortfall") - ErrInvalidCheckoutAmount = errors.New("invalid checkout amount") - ErrPermissionDenied = errors.New("permission denied") - ErrDepositCannotHold = errors.New("deposit cannot hold") - ErrDepositNotHeld = errors.New("deposit not held") - ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty") + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrInvalidRentHours = errors.New("invalid rent hours") + ErrListingUnavailable = errors.New("listing unavailable") + ErrCannotRentOwnListing = errors.New("cannot rent own listing") + ErrInsufficientBalance = errors.New("insufficient balance") + ErrOrderCannotPay = errors.New("order cannot pay") + ErrChannelPaymentRequired = errors.New("channel payment required") + ErrOrderCannotCancel = errors.New("order cannot cancel") + ErrOrderCannotHandoff = errors.New("order cannot handoff") + ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff") + ErrOrderCannotReceive = errors.New("order cannot receive") + ErrOrderCannotReturn = errors.New("order cannot return") + ErrOrderCannotComplete = errors.New("order cannot complete") + ErrCheckoutCannotSubmit = errors.New("checkout cannot submit") + ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm") + ErrCheckoutCannotCounter = errors.New("checkout cannot counter") + ErrCheckoutMaxRounds = errors.New("checkout max rounds reached") + ErrCheckoutDepositShortfall = errors.New("checkout deposit shortfall") + ErrInvalidCheckoutAmount = errors.New("invalid checkout amount") + ErrDisputeExists = errors.New("dispute already exists") + ErrPermissionDenied = errors.New("permission denied") + ErrDepositCannotHold = errors.New("deposit cannot hold") + ErrDepositNotHeld = errors.New("deposit not held") + ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty") + ErrOfflineSettlementCannotMark = errors.New("offline settlement cannot mark") ) 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) } +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 { if s.repo == nil { return ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a26f8ea..c1d5938 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal) 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.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus) adminRoutes.POST("/orders/:id/refund/approve", requirePerm("order:close"), orderHandler.AdminApproveRefund) diff --git a/backend/migrations/000035_platform_managed_handoff.sql b/backend/migrations/000035_platform_managed_handoff.sql new file mode 100644 index 0000000..e0418dc --- /dev/null +++ b/backend/migrations/000035_platform_managed_handoff.sql @@ -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; diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index ea555b2..f0db5e9 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -33,7 +33,7 @@ import { adminPath } from '@/shared/utils/adminPath' import { centToYuan, formatCentWithSymbol, formatMoney } from '@/shared/utils/money' import { formatDateMinute, formatDateTime } from '@/shared/utils/time' 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 QuickReplyDialog from '../components/QuickReplyDialog.vue' @@ -590,11 +590,14 @@ function paymentBizTypeLabel(type: string) { function formatHandoffRecordType(type: string) { const typeMap: Record = { owner_handoff: '卖家交接', + platform_handoff: '客服代交接', renter_checkout: '买家结账', owner_counter_checkout: '卖家反驳结账', + platform_checkout_counter: '客服修改结账', renter_confirm_checkout: '买家确认结账', owner_accept_checkout: '卖家接受结账', admin_arbitration: '客服仲裁', + platform_checkout_dispute_opened: '客服发起结账争议', } return typeMap[type] || type } @@ -776,7 +779,7 @@ function firstQueryValue(value: unknown) {
交接状态 - {{ handoffStatusLabel(activeOrder.handoff_status) }} + {{ orderHandoffStatusLabel(activeOrder) }}
订单金额 diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index be6158e..32083b7 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -7,7 +7,12 @@ import { useRoute } from 'vue-router' import { adminCloseOrder, adminHoldDeposit, + adminMarkOfflineSettlement, adminMarkOrderAbnormal, + adminPlatformCheckoutCounter, + adminPlatformCheckoutConfirm, + adminPlatformCheckoutDispute, + adminPlatformHandoff, adminRefundOrder, adminRefundStatus, adminReleaseDeposit, @@ -22,14 +27,15 @@ import { import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments' import { getSnapshotHafCoinM, + linesToList, readSnapshot, readSnapshotResources, } from '@/features/orders/composables/useOrderSnapshot' import { adminPath } from '@/shared/utils/adminPath' -import { formatCentWithSymbol } from '@/shared/utils/money' +import { centToYuan, formatCentWithSymbol } from '@/shared/utils/money' import { disputeStatusLabel, - handoffStatusLabel, + orderHandoffStatusLabel, orderStatusLabel, refundStatusLabel, settlementStatusLabel, @@ -48,12 +54,28 @@ type OrderActionType = | 'seal' | 'abnormal' | 'reset' + | 'platform_checkout_confirm' + | 'platform_checkout_dispute' | 'deposit_hold' | 'deposit_release' | '' const actionType = ref('') const reason = ref('') const refundStatus = ref(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 = { label: string @@ -94,10 +116,40 @@ const canOperate = computed( () => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status) ) 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(() => { return resetAction.value?.label || '重置' }) 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 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 === 'seal') return '封存订单' 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_release') return '归还暂扣押金' return '标记订单异常' }) -const actionConfirmButtonType = computed(() => - actionType.value === 'deposit_release' ? 'primary' : 'danger' -) +const actionConfirmButtonType = computed(() => { + if (['deposit_release', 'platform_checkout_confirm'].includes(actionType.value)) return 'primary' + if (actionType.value === 'platform_checkout_dispute') return 'warning' + return 'danger' +}) const closeActionTip = '关闭订单并归档商品/账号;已支付订单会原路退款,押金已暂扣时押金部分会继续挂起。' const sealActionTip = @@ -341,6 +397,12 @@ async function submitAction() { } else if (actionType.value === 'reset') { await adminResetHandoff(order.value.id, reason.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') { await adminHoldDeposit(order.value.id, reason.value) 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() { return order.value?.rented_at } @@ -462,15 +615,49 @@ function firstQueryValue(value: unknown) { function formatHandoffRecordType(type: string) { const typeMap: Record = { owner_handoff: '卖家交接', + platform_handoff: '客服代交接', renter_checkout: '买家结账', owner_counter_checkout: '卖家反驳结账', + platform_checkout_counter: '客服修改结账', renter_confirm_checkout: '买家确认结账', owner_accept_checkout: '卖家接受结账', admin_arbitration: '客服仲裁', + platform_checkout_dispute_opened: '客服发起结账争议', } return typeMap[type] || type } +function handoffModeLabel(mode?: string) { + const map: Record = { + owner: '号主交接', + platform: '平台代管', + } + return map[mode || 'owner'] || mode || '-' +} + +function settlementModeLabel(mode?: string) { + const map: Record = { + owner_wallet: '号主钱包', + platform_managed: '线下结算', + } + return map[mode || 'owner_wallet'] || mode || '-' +} + +function offlineSettlementStatusLabel(status?: string) { + const map: Record = { + 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) { if (['completed', 'renting'].includes(status)) return 'success' 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}` } +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) { if (value === undefined || value === null || value === '') return '-' if (Array.isArray(value)) @@ -574,6 +768,34 @@ function paymentPaidAt(record: AdminPayment) { {{ resetActionLabel }} + + {{ platformHandoffAction?.label || '客服代交接' }} + + + {{ platformCheckoutConfirmAction?.label || '客服确认结账' }} + + + {{ platformCheckoutCounterAction?.label || '客服修改结账方案' }} + + + {{ platformCheckoutDisputeAction?.label || '发起结账争议' }} + + + {{ offlineSettlementAction?.label || '确认线下结算' }} + 标记异常 @@ -627,13 +849,18 @@ function paymentPaidAt(record: AdminPayment) {
订单状态 {{ orderStatusLabel(order.status) }} - {{ handoffStatusLabel(order.handoff_status) }} + {{ orderHandoffStatusLabel(order) }}
结算状态 {{ settlementStatusLabel(order.settlement_status) }} 更新 {{ formatDateTime(order.updated_at) }}
+
+ 线下结算 + {{ offlineSettlementStatusLabel(offlineSettlementStatus) }} + {{ moneyCent(order.offline_settlement_amount_cent) }} +
订单总额 {{ moneyCent(orderTotalCent) }} @@ -717,7 +944,7 @@ function paymentPaidAt(record: AdminPayment) {
号主
-
{{ userDisplay(order.owner_phone, order.owner_id) }}
+
{{ sellerDisplay(order) }}
创建时间
@@ -738,6 +965,45 @@ function paymentPaidAt(record: AdminPayment) { +
+
+

平台代管

+ + {{ offlineSettlementStatusLabel(offlineSettlementStatus) }} + +
+
+
+
交接模式
+
{{ handoffModeLabel(order.handoff_mode) }}
+
+
+
结算模式
+
{{ settlementModeLabel(order.settlement_mode) }}
+
+
+
负责客服
+
{{ order.managed_admin_id ? `ID ${order.managed_admin_id}` : '-' }}
+
+
+
待打款金额
+
{{ moneyCent(order.offline_settlement_amount_cent) }}
+
+
+
确认客服
+
ID {{ order.offline_settled_by }}
+
+
+
确认时间
+
{{ formatDateTime(order.offline_settled_at) }}
+
+
+
线下备注
+
{{ order.offline_settlement_remark }}
+
+
+
+

资金拆分

@@ -999,6 +1265,131 @@ function paymentPaidAt(record: AdminPayment) { + + +
+

+ {{ order.order_no }} · 商品编号 {{ listingCode }} · {{ order.title }} +

+ + +
+ +
+ + +
+

+ {{ order.order_no }} · 当前号主收入 + {{ moneyCent(order.checkout?.owner_income_amount_cent) }} +

+
+ + + +
+ + +
+ +
+ + +
+

+ 待打款金额 + {{ moneyCent(order.offline_settlement_amount_cent) }} +

+ +
+ +
@@ -1250,6 +1641,35 @@ function paymentPaidAt(record: AdminPayment) { 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) { .order-detail-grid { grid-template-columns: 1fr; diff --git a/frontend/src/features/admin/views/AdminOrdersView.vue b/frontend/src/features/admin/views/AdminOrdersView.vue index 3a893f2..73a211a 100644 --- a/frontend/src/features/admin/views/AdminOrdersView.vue +++ b/frontend/src/features/admin/views/AdminOrdersView.vue @@ -4,7 +4,7 @@ import { onMounted, reactive, ref } from 'vue' import { fetchAdminOrders, type AdminOrderQuery, type Order } from '@/features/orders' import { - handoffStatusLabel, + orderHandoffStatusLabel, orderStatusLabel, settlementStatusLabel, } from '@/shared/utils/statusLabels' @@ -233,7 +233,7 @@ function userText(value: string | number | undefined) { diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 50c0ece..bef6374 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -33,7 +33,15 @@ export interface Order { counter_info?: string status: OrderStatus handoff_status: HandoffStatus + handoff_mode?: string + settlement_mode?: string + managed_admin_id?: number 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_amount_cent?: number deposit_hold_status?: string @@ -51,6 +59,11 @@ export interface Order { export interface AdminActions { 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 { @@ -373,6 +386,46 @@ export async function adminResetHandoff(id: number, reason: string) { return data.data } +export async function adminPlatformHandoff(id: number, content: string, reason: string) { + const { data } = await apiClient.post>( + `/admin/orders/${id}/platform-handoff`, + { content, reason } + ) + return data.data +} + +export async function adminPlatformCheckoutConfirm(id: number, reason: string) { + const { data } = await apiClient.post>( + `/admin/orders/${id}/platform-checkout/confirm`, + { reason } + ) + return data.data +} + +export async function adminPlatformCheckoutCounter(id: number, payload: SubmitCheckoutPayload) { + const { data } = await apiClient.post>( + `/admin/orders/${id}/platform-checkout/counter`, + toCheckoutRequest(payload) + ) + return data.data +} + +export async function adminPlatformCheckoutDispute(id: number, reason: string) { + const { data } = await apiClient.post>( + `/admin/orders/${id}/platform-checkout/dispute`, + { reason } + ) + return data.data +} + +export async function adminMarkOfflineSettlement(id: number, remark: string) { + const { data } = await apiClient.post>( + `/admin/orders/${id}/offline-settlement`, + { remark } + ) + return data.data +} + export async function adminRefundStatus(id: number) { const { data } = await apiClient.get>( `/admin/orders/${id}/refund-status` diff --git a/frontend/src/features/orders/composables/useOrderSnapshot.ts b/frontend/src/features/orders/composables/useOrderSnapshot.ts index 41974f0..baf46fa 100644 --- a/frontend/src/features/orders/composables/useOrderSnapshot.ts +++ b/frontend/src/features/orders/composables/useOrderSnapshot.ts @@ -234,13 +234,16 @@ export function ownerActualIncome(item: Order, userID: number | undefined | null export function formatHandoffRecordType(type: string) { const typeMap: Record = { owner_handoff: '卖家交接', + platform_handoff: '客服代交接', renter_checkout: '买家结账', owner_counter_checkout: '卖家反驳结账', + platform_checkout_counter: '客服修改结账', renter_confirm_checkout: '买家确认结账', owner_accept_checkout: '卖家接受结账', admin_arbitration: '客服仲裁', dispute_opened: '发起申诉', checkout_dispute_opened: '发起结账争议', + platform_checkout_dispute_opened: '客服发起结账争议', dispute_cancelled: '取消申诉', checkout_dispute_cancelled: '取消结账争议', } diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue index d261a57..f75e035 100644 --- a/frontend/src/features/orders/views/MobileOrderDetailView.vue +++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue @@ -15,7 +15,7 @@ import { OrderHandoffTimeline, OrderResourceUsageEditor, } from '@/features/orders' -import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels' +import { orderHandoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' // 移动端支付收银台(基于通用 useOrderPaymentCashier 的薄封装:注入 vant toast + App 浏览器跳转)。 @@ -202,7 +202,7 @@ async function copyListingCode() {
交接状态 - {{ handoffStatusLabel(order.handoff_status) }} + {{ orderHandoffStatusLabel(order) }}
diff --git a/frontend/src/features/orders/views/OrderDetailView.vue b/frontend/src/features/orders/views/OrderDetailView.vue index 03cafe0..2d0f56b 100644 --- a/frontend/src/features/orders/views/OrderDetailView.vue +++ b/frontend/src/features/orders/views/OrderDetailView.vue @@ -15,7 +15,7 @@ import { import { useOrderActions } from '@/features/orders/composables/useOrderActions' import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier' 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 type { PaymentPayWay } from '@/features/orders/api/orders' @@ -302,7 +302,7 @@ watch([() => route.query.focus, order, loading], () => { route.query.focus, order, loading], () => {
交接状态 - {{ handoffStatusLabel(order.handoff_status) }} + {{ orderHandoffStatusLabel(order) }}
{{ orderAmountLabel }} diff --git a/frontend/src/shared/utils/statusLabels.ts b/frontend/src/shared/utils/statusLabels.ts index a1d6e4b..dda323f 100644 --- a/frontend/src/shared/utils/statusLabels.ts +++ b/frontend/src/shared/utils/statusLabels.ts @@ -160,6 +160,24 @@ export function handoffStatusLabel(status: string) { 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 = { + pending_owner: '待客服交接', + owner_timeout: '客服交接超时', + pending_owner_checkout: '待客服确认结账', + owner_checkout_confirm_timeout: '客服确认结账超时', + } + return platformMap[status] || handoffStatusLabel(status) +} + export function settlementStatusLabel(status: string) { return readLabel(settlementStatusMap, status) }