修复超时订单重置流程
This commit is contained in:
@@ -347,6 +347,8 @@ func (j *Job) handleRenterConfirmTimeout(ctx context.Context, now time.Time, cfg
|
||||
if cfg.RenterConfirmTimeoutMinutes <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
deadline := now.Add(-time.Duration(cfg.RenterConfirmTimeoutMinutes) * time.Minute)
|
||||
|
||||
var rows []model.RentalOrder
|
||||
err := j.db.WithContext(ctx).
|
||||
Where("status = ? AND handoff_status = ?", "pending_handoff", "pending_renter_confirm").
|
||||
@@ -356,15 +358,42 @@ func (j *Job) handleRenterConfirmTimeout(ctx context.Context, now time.Time, cfg
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 批量查询所有订单的最新交接记录,减少 N+1 查询
|
||||
orderIDs := make([]uint64, len(rows))
|
||||
for i, row := range rows {
|
||||
orderIDs[i] = row.ID
|
||||
}
|
||||
var handoffs []model.HandoffRecord
|
||||
if err := j.db.WithContext(ctx).
|
||||
Where("order_id IN ? AND type = ?", orderIDs, "owner_handoff").
|
||||
Order("order_id ASC, id DESC").
|
||||
Find(&handoffs).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 构建 order_id -> 最新交接记录的映射
|
||||
latestHandoff := make(map[uint64]model.HandoffRecord, len(handoffs))
|
||||
for _, h := range handoffs {
|
||||
if _, exists := latestHandoff[h.OrderID]; !exists {
|
||||
latestHandoff[h.OrderID] = h
|
||||
}
|
||||
}
|
||||
|
||||
count := 0
|
||||
deadline := now.Add(-time.Duration(cfg.RenterConfirmTimeoutMinutes) * time.Minute)
|
||||
for _, row := range rows {
|
||||
var handoff model.HandoffRecord
|
||||
err := j.db.WithContext(ctx).
|
||||
Where("order_id = ? AND type = ?", row.ID, "owner_handoff").
|
||||
Order("id DESC").
|
||||
First(&handoff).Error
|
||||
if err != nil || handoff.CreatedAt.After(deadline) {
|
||||
handoff, exists := latestHandoff[row.ID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
// 判断阶段开始时间:优先使用 HandoffStartedAt(管理员重置后刷新的时间)
|
||||
stageStart := handoff.CreatedAt
|
||||
if row.HandoffStartedAt != nil && row.HandoffStartedAt.After(handoff.CreatedAt) {
|
||||
stageStart = *row.HandoffStartedAt
|
||||
}
|
||||
if stageStart.After(deadline) {
|
||||
continue
|
||||
}
|
||||
if err := j.updateOrder(ctx, row.ID, "order.timeout.renter_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) {
|
||||
@@ -460,7 +489,7 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
|
||||
}
|
||||
var rows []model.RentalOrder
|
||||
err := j.db.WithContext(ctx).
|
||||
Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_checkout_confirm", "pending_owner_checkout", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)).
|
||||
Where("status = ? AND handoff_status = ? AND COALESCE(handoff_started_at, updated_at) <= ?", "pending_checkout_confirm", "pending_owner_checkout", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)).
|
||||
Order("id ASC").
|
||||
Limit(100).
|
||||
Find(&rows).Error
|
||||
|
||||
@@ -23,6 +23,8 @@ func setupOrderTimeoutTestDB(t *testing.T) *gorm.DB {
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.RentalOrder{},
|
||||
&model.HandoffRecord{},
|
||||
&model.Notification{},
|
||||
&model.ChatConversation{},
|
||||
&model.ChatParticipant{},
|
||||
&model.ChatMessage{},
|
||||
@@ -152,6 +154,101 @@ func TestHandleEndedOrderRenterRetention(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRenterConfirmTimeoutUsesRefreshedStageTime(t *testing.T) {
|
||||
db := setupOrderTimeoutTestDB(t)
|
||||
job := New(db, nil, zap.NewNop())
|
||||
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
oldHandoffAt := now.Add(-2 * time.Hour)
|
||||
refreshedAt := now.Add(-5 * time.Minute)
|
||||
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORDER-RENTER-CONFIRM-RESET",
|
||||
ListingID: 101,
|
||||
AccountID: 201,
|
||||
OwnerID: 301,
|
||||
RenterID: 401,
|
||||
Status: "pending_handoff",
|
||||
HandoffStatus: "pending_renter_confirm",
|
||||
HandoffStartedAt: &refreshedAt,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("创建订单失败: %v", err)
|
||||
}
|
||||
record := model.HandoffRecord{
|
||||
OrderID: order.ID,
|
||||
FromUserID: order.OwnerID,
|
||||
ToUserID: order.RenterID,
|
||||
Type: "owner_handoff",
|
||||
Content: "交接说明",
|
||||
CreatedAt: oldHandoffAt,
|
||||
}
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
t.Fatalf("创建交接记录失败: %v", err)
|
||||
}
|
||||
|
||||
count, err := job.handleRenterConfirmTimeout(t.Context(), now, thresholds{
|
||||
RenterConfirmTimeoutMinutes: 30,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("确认收号超时任务失败: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("处理数量 = %d, want 0", count)
|
||||
}
|
||||
|
||||
var saved model.RentalOrder
|
||||
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||
t.Fatalf("读取订单失败: %v", err)
|
||||
}
|
||||
if saved.Status != "pending_handoff" || saved.HandoffStatus != "pending_renter_confirm" {
|
||||
t.Fatalf("订单状态 = %s/%s, want pending_handoff/pending_renter_confirm", saved.Status, saved.HandoffStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOwnerReturnConfirmTimeoutUsesStageTime(t *testing.T) {
|
||||
db := setupOrderTimeoutTestDB(t)
|
||||
job := New(db, nil, zap.NewNop())
|
||||
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
oldUpdatedAt := now.Add(-2 * time.Hour)
|
||||
refreshedAt := now.Add(-5 * time.Minute)
|
||||
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORDER-CHECKOUT-CONFIRM-RESET",
|
||||
ListingID: 102,
|
||||
AccountID: 202,
|
||||
OwnerID: 302,
|
||||
RenterID: 402,
|
||||
Status: "pending_checkout_confirm",
|
||||
HandoffStatus: "pending_owner_checkout",
|
||||
HandoffStartedAt: &refreshedAt,
|
||||
UpdatedAt: oldUpdatedAt,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("创建订单失败: %v", err)
|
||||
}
|
||||
if err := db.Model(&model.RentalOrder{}).Where("id = ?", order.ID).Update("updated_at", oldUpdatedAt).Error; err != nil {
|
||||
t.Fatalf("回写旧更新时间失败: %v", err)
|
||||
}
|
||||
|
||||
count, err := job.handleOwnerReturnConfirmTimeout(t.Context(), now, thresholds{
|
||||
OwnerReturnConfirmTimeoutMinutes: 30,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("号主确认结账超时任务失败: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("处理数量 = %d, want 0", count)
|
||||
}
|
||||
|
||||
var saved model.RentalOrder
|
||||
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||
t.Fatalf("读取订单失败: %v", err)
|
||||
}
|
||||
if saved.Status != "pending_checkout_confirm" || saved.HandoffStatus != "pending_owner_checkout" {
|
||||
t.Fatalf("订单状态 = %s/%s, want pending_checkout_confirm/pending_owner_checkout", saved.Status, saved.HandoffStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampRenterRetentionDays(t *testing.T) {
|
||||
tests := []struct {
|
||||
value int
|
||||
|
||||
@@ -163,45 +163,64 @@ func (r *Repository) AdminMarkAbnormal(ctx context.Context, adminID uint64, orde
|
||||
})
|
||||
}
|
||||
|
||||
// AdminResetHandoff 将号主交接超时(owner_timeout)的订单重置回待号主交接,并刷新交接计时,
|
||||
// 让客服可以给号主再次提交交接说明的机会,避免订单卡死在超时状态。
|
||||
type adminResetTarget struct {
|
||||
OrderStatus string
|
||||
HandoffStatus string
|
||||
Label string
|
||||
OwnerTitle string
|
||||
OwnerContent string
|
||||
RenterTitle string
|
||||
RenterContent string
|
||||
AuditAction string
|
||||
RefreshStageTime bool
|
||||
}
|
||||
|
||||
// AdminResetHandoff 将超时卡住的订单恢复到对应待办阶段,并刷新阶段计时,
|
||||
// 让客服可以给责任方再次处理机会,避免订单卡死在异常状态。
|
||||
func (r *Repository) AdminResetHandoff(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 order.Status != orderStatusPendingHandoff || order.HandoffStatus != handoffStatusOwnerTimeout {
|
||||
target, ok := resetTargetForOrder(order)
|
||||
if !ok {
|
||||
return ErrOrderCannotResetHandoff
|
||||
}
|
||||
beforeOrderStatus := order.Status
|
||||
beforeHandoffStatus := order.HandoffStatus
|
||||
now := time.Now()
|
||||
order.HandoffStatus = handoffStatusPendingOwner
|
||||
order.HandoffStartedAt = &now
|
||||
order.Status = target.OrderStatus
|
||||
order.HandoffStatus = target.HandoffStatus
|
||||
if target.RefreshStageTime {
|
||||
order.HandoffStartedAt = &now
|
||||
}
|
||||
if err := notification.Append(tx,
|
||||
notification.Entry{
|
||||
UserID: order.OwnerID,
|
||||
Type: "order_admin",
|
||||
Title: "请重新提交交接说明",
|
||||
Content: "客服已重置交接状态,请尽快提交交接说明,避免再次超时。原因:" + req.Reason,
|
||||
Title: target.OwnerTitle,
|
||||
Content: target.OwnerContent + "原因:" + req.Reason,
|
||||
BizType: "order",
|
||||
BizID: &order.ID,
|
||||
},
|
||||
notification.Entry{
|
||||
UserID: order.RenterID,
|
||||
Type: "order_admin",
|
||||
Title: "订单交接已重置",
|
||||
Content: "客服已重置交接状态,正在等待号主重新提交交接说明。原因:" + req.Reason,
|
||||
Title: target.RenterTitle,
|
||||
Content: target.RenterContent + "原因:" + req.Reason,
|
||||
BizType: "order",
|
||||
BizID: &order.ID,
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAuditLog(tx, adminID, "order.reset_handoff", "order", order.ID, meta, map[string]any{
|
||||
if err := appendAuditLog(tx, adminID, target.AuditAction, "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,
|
||||
}); err != nil {
|
||||
@@ -211,6 +230,51 @@ func (r *Repository) AdminResetHandoff(ctx context.Context, adminID uint64, orde
|
||||
})
|
||||
}
|
||||
|
||||
func resetTargetForOrder(order model.RentalOrder) (adminResetTarget, bool) {
|
||||
if order.Status == orderStatusPendingHandoff && order.HandoffStatus == handoffStatusOwnerTimeout {
|
||||
return adminResetTarget{
|
||||
OrderStatus: orderStatusPendingHandoff,
|
||||
HandoffStatus: handoffStatusPendingOwner,
|
||||
Label: "重置交接",
|
||||
OwnerTitle: "请重新提交交接说明",
|
||||
OwnerContent: "客服已重置交接状态,请尽快提交交接说明,避免再次超时。",
|
||||
RenterTitle: "订单交接已重置",
|
||||
RenterContent: "客服已重置交接状态,正在等待号主重新提交交接说明。",
|
||||
AuditAction: "order.reset_handoff",
|
||||
RefreshStageTime: true,
|
||||
}, true
|
||||
}
|
||||
if order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout {
|
||||
return adminResetTarget{
|
||||
OrderStatus: orderStatusPendingHandoff,
|
||||
HandoffStatus: handoffStatusPendingRenterConfirm,
|
||||
Label: "重置确认收号",
|
||||
OwnerTitle: "订单确认收号已重置",
|
||||
OwnerContent: "客服已重置确认收号状态,正在等待租客重新确认收号。",
|
||||
RenterTitle: "请重新确认收号",
|
||||
RenterContent: "客服已重置确认收号状态,请尽快检查账号并确认收号,避免再次超时。",
|
||||
AuditAction: "order.reset_renter_confirm",
|
||||
RefreshStageTime: true,
|
||||
}, true
|
||||
}
|
||||
if order.Status == orderStatusAbnormal &&
|
||||
(order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout ||
|
||||
order.HandoffStatus == handoffStatusOwnerReturnConfirmTimeout) {
|
||||
return adminResetTarget{
|
||||
OrderStatus: orderStatusPendingCheckoutConfirm,
|
||||
HandoffStatus: handoffStatusPendingOwnerCheckout,
|
||||
Label: "重置结账确认",
|
||||
OwnerTitle: "请重新确认结账",
|
||||
OwnerContent: "客服已重置结账确认状态,请尽快核对账号和结账金额,避免再次超时。",
|
||||
RenterTitle: "结账确认已重置",
|
||||
RenterContent: "客服已重置结账确认状态,正在等待号主重新确认结账。",
|
||||
AuditAction: "order.reset_checkout_confirm",
|
||||
RefreshStageTime: true,
|
||||
}, true
|
||||
}
|
||||
return adminResetTarget{}, false
|
||||
}
|
||||
|
||||
// AdminRefund 触发后台人工退款,退款由 payment 模块走渠道原路退回。
|
||||
func (r *Repository) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) {
|
||||
var order model.RentalOrder
|
||||
|
||||
@@ -51,6 +51,8 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
|
||||
order.Status = orderStatusPendingCheckoutConfirm
|
||||
order.HandoffStatus = handoffStatusPendingOwnerCheckout
|
||||
order.SettlementStatus = settlementStatusPending
|
||||
now := time.Now()
|
||||
order.HandoffStartedAt = &now
|
||||
orderID := order.ID
|
||||
if err := notification.Append(tx, notification.Entry{
|
||||
UserID: order.OwnerID,
|
||||
|
||||
@@ -13,18 +13,21 @@ const (
|
||||
orderStatusClosed = "closed"
|
||||
orderStatusAbnormal = "abnormal"
|
||||
|
||||
handoffStatusNone = "none"
|
||||
handoffStatusPendingOwner = "pending_owner"
|
||||
handoffStatusPendingRenterConfirm = "pending_renter_confirm"
|
||||
handoffStatusReceived = "received"
|
||||
handoffStatusReturnOverdue = "return_overdue"
|
||||
handoffStatusPendingOwnerCheckout = "pending_owner_checkout"
|
||||
handoffStatusPendingRenterCheckout = "pending_renter_checkout"
|
||||
handoffStatusReturned = "returned"
|
||||
handoffStatusOwnerTimeout = "owner_timeout"
|
||||
handoffStatusCancelled = "cancelled"
|
||||
handoffStatusAdminClosed = "admin_closed"
|
||||
handoffStatusAdminAbnormal = "admin_abnormal"
|
||||
handoffStatusNone = "none"
|
||||
handoffStatusPendingOwner = "pending_owner"
|
||||
handoffStatusPendingRenterConfirm = "pending_renter_confirm"
|
||||
handoffStatusReceived = "received"
|
||||
handoffStatusReturnOverdue = "return_overdue"
|
||||
handoffStatusPendingOwnerCheckout = "pending_owner_checkout"
|
||||
handoffStatusPendingRenterCheckout = "pending_renter_checkout"
|
||||
handoffStatusReturned = "returned"
|
||||
handoffStatusOwnerTimeout = "owner_timeout"
|
||||
handoffStatusRenterConfirmTimeout = "renter_confirm_timeout"
|
||||
handoffStatusOwnerReturnConfirmTimeout = "owner_return_confirm_timeout"
|
||||
handoffStatusOwnerCheckoutConfirmTimeout = "owner_checkout_confirm_timeout"
|
||||
handoffStatusCancelled = "cancelled"
|
||||
handoffStatusAdminClosed = "admin_closed"
|
||||
handoffStatusAdminAbnormal = "admin_abnormal"
|
||||
|
||||
settlementStatusUnsettled = "unsettled"
|
||||
settlementStatusPending = "pending"
|
||||
|
||||
@@ -38,11 +38,21 @@ type OrderDTO struct {
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type AdminActionDTO struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type ActiveDisputeDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
InitiatorID uint64 `json:"initiator_id"`
|
||||
|
||||
@@ -30,7 +30,7 @@ func writeOrderError(c *gin.Context, err error) {
|
||||
case errors.Is(err, ErrOrderCannotHandoff):
|
||||
response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接")
|
||||
case errors.Is(err, ErrOrderCannotResetHandoff):
|
||||
response.Error(c, http.StatusConflict, "order_cannot_reset_handoff", "仅号主交接超时的订单可重置交接")
|
||||
response.Error(c, http.StatusConflict, "order_cannot_reset_handoff", "仅超时卡住的订单可重置到对应待办阶段")
|
||||
case errors.Is(err, ErrOrderCannotReceive):
|
||||
response.Error(c, http.StatusConflict, "order_cannot_receive", "当前订单不能确认收号")
|
||||
case errors.Is(err, ErrOrderCannotReturn):
|
||||
|
||||
@@ -8,6 +8,19 @@ import (
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO {
|
||||
target, ok := resetTargetForOrder(order)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &AdminActionsDTO{
|
||||
ResetHandoff: &AdminActionDTO{
|
||||
Enabled: true,
|
||||
Label: target.Label,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (row orderRow) toAdminDTO() OrderDTO {
|
||||
rentedAt := row.RentedAt
|
||||
durationHours := orderDurationHours(row.RentalOrder)
|
||||
@@ -43,6 +56,7 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
||||
Status: row.Status,
|
||||
HandoffStatus: row.HandoffStatus,
|
||||
SettlementStatus: row.SettlementStatus,
|
||||
AdminActions: adminActionsForOrder(row.RentalOrder),
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
@@ -50,6 +64,7 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
||||
|
||||
func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
|
||||
dto := row.toAdminDTO()
|
||||
dto.AdminActions = nil
|
||||
applyOrderPriceView(&dto, row.RentalOrder, userID)
|
||||
return dto
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func setupOrderTestDB(t *testing.T) *gorm.DB {
|
||||
&model.Notification{},
|
||||
&model.HandoffRecord{},
|
||||
&model.OrderCheckout{},
|
||||
&model.AuditLog{},
|
||||
); err != nil {
|
||||
t.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
@@ -555,3 +556,176 @@ func TestCancelClosesPendingPaymentOrder(t *testing.T) {
|
||||
t.Fatalf("payment status = %q, want closed", latestPayment.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminResetCheckoutConfirmTimeout(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
owner := model.User{Phone: "13800003001"}
|
||||
renter := model.User{Phone: "13900003001"}
|
||||
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)
|
||||
}
|
||||
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-RESET-CHECKOUT-001",
|
||||
ListingID: 1,
|
||||
AccountID: 1,
|
||||
OwnerID: owner.ID,
|
||||
RenterID: renter.ID,
|
||||
Status: orderStatusAbnormal,
|
||||
HandoffStatus: handoffStatusOwnerCheckoutConfirmTimeout,
|
||||
SettlementStatus: settlementStatusPending,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order failed: %v", err)
|
||||
}
|
||||
|
||||
err := repo.AdminResetHandoff(t.Context(), 1, order.ID, AdminActionRequest{Reason: "号主已联系上"}, AuditMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("AdminResetHandoff() 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 != orderStatusPendingCheckoutConfirm {
|
||||
t.Fatalf("status = %q, want %q", saved.Status, orderStatusPendingCheckoutConfirm)
|
||||
}
|
||||
if saved.HandoffStatus != handoffStatusPendingOwnerCheckout {
|
||||
t.Fatalf("handoff status = %q, want %q", saved.HandoffStatus, handoffStatusPendingOwnerCheckout)
|
||||
}
|
||||
if saved.HandoffStartedAt == nil {
|
||||
t.Fatal("handoff started at should be refreshed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrderDTOIncludesResetAction(t *testing.T) {
|
||||
order := model.RentalOrder{
|
||||
Status: orderStatusAbnormal,
|
||||
HandoffStatus: handoffStatusOwnerCheckoutConfirmTimeout,
|
||||
}
|
||||
dto := orderRow{RentalOrder: order}.toAdminDTO()
|
||||
if dto.AdminActions == nil || dto.AdminActions.ResetHandoff == nil {
|
||||
t.Fatal("reset handoff action should be present")
|
||||
}
|
||||
if !dto.AdminActions.ResetHandoff.Enabled {
|
||||
t.Fatal("reset handoff action should be enabled")
|
||||
}
|
||||
if dto.AdminActions.ResetHandoff.Label != "重置结账确认" {
|
||||
t.Fatalf("label = %q, want 重置结账确认", dto.AdminActions.ResetHandoff.Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserOrderDTODoesNotIncludeAdminActions(t *testing.T) {
|
||||
order := model.RentalOrder{
|
||||
OwnerID: 1,
|
||||
RenterID: 2,
|
||||
Status: orderStatusAbnormal,
|
||||
HandoffStatus: handoffStatusOwnerCheckoutConfirmTimeout,
|
||||
}
|
||||
dto := orderRow{RentalOrder: order}.toDTOForUser(order.RenterID)
|
||||
if dto.AdminActions != nil {
|
||||
t.Fatal("user order dto should not include admin actions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminResetRenterConfirmTimeoutRefreshesStageTime(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
owner := model.User{Phone: "13800003002"}
|
||||
renter := model.User{Phone: "13900003002"}
|
||||
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)
|
||||
}
|
||||
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-RESET-RECEIVE-001",
|
||||
ListingID: 1,
|
||||
AccountID: 1,
|
||||
OwnerID: owner.ID,
|
||||
RenterID: renter.ID,
|
||||
Status: orderStatusAbnormal,
|
||||
HandoffStatus: handoffStatusRenterConfirmTimeout,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order failed: %v", err)
|
||||
}
|
||||
|
||||
err := repo.AdminResetHandoff(t.Context(), 1, order.ID, AdminActionRequest{Reason: "租客申请重新确认"}, AuditMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("AdminResetHandoff() 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 != orderStatusPendingHandoff {
|
||||
t.Fatalf("status = %q, want %q", saved.Status, orderStatusPendingHandoff)
|
||||
}
|
||||
if saved.HandoffStatus != handoffStatusPendingRenterConfirm {
|
||||
t.Fatalf("handoff status = %q, want %q", saved.HandoffStatus, handoffStatusPendingRenterConfirm)
|
||||
}
|
||||
if saved.HandoffStartedAt == nil {
|
||||
t.Fatal("handoff started at should be refreshed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCheckoutRefreshesStageTime(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
owner := model.User{Phone: "13800003003"}
|
||||
renter := model.User{Phone: "13900003003"}
|
||||
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)
|
||||
}
|
||||
|
||||
oldStageTime := time.Now().Add(-2 * time.Hour)
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-CHECKOUT-STAGE-001",
|
||||
ListingID: 1,
|
||||
AccountID: 1,
|
||||
OwnerID: owner.ID,
|
||||
RenterID: renter.ID,
|
||||
Status: orderStatusRenting,
|
||||
HandoffStatus: handoffStatusReceived,
|
||||
HandoffStartedAt: &oldStageTime,
|
||||
RentAmountCent: 1000,
|
||||
OwnerRentAmountCent: 800,
|
||||
DepositAmountCent: 1000,
|
||||
PlatformFeeCent: 200,
|
||||
EstimatedDurationHours: 24,
|
||||
AccountSnapshot: datatypes.JSON([]byte(`{"haf_coin_amount":1000000,"asset_summary":{"price_breakdown":{"buyer_coin_base_price":10,"seller_coin_base_price":8}}}`)),
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.SubmitCheckout(t.Context(), renter.ID, order.ID, SubmitCheckoutRequest{Content: "申请结账"}); err != nil {
|
||||
t.Fatalf("SubmitCheckout() 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 != orderStatusPendingCheckoutConfirm || saved.HandoffStatus != handoffStatusPendingOwnerCheckout {
|
||||
t.Fatalf("status = %s/%s, want pending_checkout_confirm/pending_owner_checkout", saved.Status, saved.HandoffStatus)
|
||||
}
|
||||
if saved.HandoffStartedAt == nil || !saved.HandoffStartedAt.After(oldStageTime) {
|
||||
t.Fatalf("handoff started at = %#v, should be refreshed after old stage time", saved.HandoffStartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,20 +62,19 @@ const returnLabel = computed(() =>
|
||||
const refunding = ref(false)
|
||||
|
||||
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
|
||||
const actionTitle = computed(() => {
|
||||
if (actionType.value === 'close') return '客服关闭订单'
|
||||
if (actionType.value === 'reset') return '重置交接(给号主再次机会)'
|
||||
return '标记订单异常'
|
||||
})
|
||||
const canOperate = computed(
|
||||
() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status)
|
||||
)
|
||||
const canResetHandoff = computed(
|
||||
() =>
|
||||
!!order.value &&
|
||||
order.value.status === 'pending_handoff' &&
|
||||
order.value.handoff_status === 'owner_timeout'
|
||||
)
|
||||
const resetAction = computed(() => order.value?.admin_actions?.reset_handoff)
|
||||
const resetActionLabel = computed(() => {
|
||||
return resetAction.value?.label || '重置'
|
||||
})
|
||||
const canResetHandoff = computed(() => resetAction.value?.enabled === true)
|
||||
const actionTitle = computed(() => {
|
||||
if (actionType.value === 'close') return '客服关闭订单'
|
||||
if (actionType.value === 'reset') return `${resetActionLabel.value}(恢复到对应待办)`
|
||||
return '标记订单异常'
|
||||
})
|
||||
|
||||
onMounted(loadOrder)
|
||||
|
||||
@@ -113,7 +112,7 @@ async function submitAction() {
|
||||
ElMessage.success('订单已关闭')
|
||||
} else if (actionType.value === 'reset') {
|
||||
await adminResetHandoff(order.value.id, reason.value)
|
||||
ElMessage.success('交接已重置,等待号主重新提交')
|
||||
ElMessage.success(`${resetActionLabel.value}成功`)
|
||||
} else {
|
||||
await adminMarkOrderAbnormal(order.value.id, reason.value)
|
||||
ElMessage.success('订单已标记异常')
|
||||
@@ -241,9 +240,9 @@ function formatHandoffRecordType(type: string) {
|
||||
<RouterLink :to="returnTarget">
|
||||
<el-button>{{ returnLabel }}</el-button>
|
||||
</RouterLink>
|
||||
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')"
|
||||
>重置交接</el-button
|
||||
>
|
||||
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')">
|
||||
{{ resetActionLabel }}
|
||||
</el-button>
|
||||
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
|
||||
>标记异常</el-button
|
||||
>
|
||||
|
||||
@@ -36,11 +36,21 @@ export interface Order {
|
||||
settlement_status: SettlementStatus
|
||||
active_dispute?: ActiveDispute
|
||||
checkout?: Checkout
|
||||
admin_actions?: AdminActions
|
||||
payment_deadline_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminActions {
|
||||
reset_handoff?: AdminAction
|
||||
}
|
||||
|
||||
export interface AdminAction {
|
||||
enabled: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface ActiveDispute {
|
||||
id: number
|
||||
initiator_id: number
|
||||
|
||||
Reference in New Issue
Block a user