diff --git a/backend/internal/modules/dispute/mutation.go b/backend/internal/modules/dispute/mutation.go index 03a910c..a3f12bd 100644 --- a/backend/internal/modules/dispute/mutation.go +++ b/backend/internal/modules/dispute/mutation.go @@ -32,7 +32,8 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" { return ErrInvalidDispute } - isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept" + isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept" || + (order.Status == "abnormal" && (order.HandoffStatus == "owner_checkout_confirm_timeout" || order.HandoffStatus == "owner_return_confirm_timeout" || order.HandoffStatus == "pending_owner_checkout")) var count int64 if err := tx.Model(&model.Dispute{}). Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}). diff --git a/backend/internal/modules/order/checkout.go b/backend/internal/modules/order/checkout.go index acbd364..5982f57 100644 --- a/backend/internal/modules/order/checkout.go +++ b/backend/internal/modules/order/checkout.go @@ -112,7 +112,15 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID if order.OwnerID != userID { return ErrPermissionDenied } - if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout { + isOwnerPending := order.Status == orderStatusPendingCheckoutConfirm || + (order.Status == orderStatusAbnormal && + (order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout || + order.HandoffStatus == handoffStatusOwnerReturnConfirmTimeout || + order.HandoffStatus == handoffStatusPendingOwnerCheckout)) + if !isOwnerPending || + (order.HandoffStatus != handoffStatusPendingOwnerCheckout && + order.HandoffStatus != handoffStatusOwnerCheckoutConfirmTimeout && + order.HandoffStatus != handoffStatusOwnerReturnConfirmTimeout) { return ErrCheckoutCannotConfirm } before := orderState(order) @@ -162,7 +170,13 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID if !isOwner && !isRenter { return ErrPermissionDenied } - if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept { + isOwnerPending := order.Status == orderStatusPendingCheckoutConfirm || + (order.Status == orderStatusAbnormal && + (order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout || + order.HandoffStatus == handoffStatusOwnerReturnConfirmTimeout || + order.HandoffStatus == handoffStatusPendingOwnerCheckout)) + isRenterPending := order.Status == orderStatusPendingCheckoutAccept + if !isOwnerPending && !isRenterPending { return ErrCheckoutCannotCounter } before := orderState(order) @@ -171,10 +185,10 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID return err } turn := normalizeCheckoutTurn(checkout) - if isOwner && (turn != checkoutTurnOwner || order.Status != orderStatusPendingCheckoutConfirm) { + if isOwner && (turn != checkoutTurnOwner || !isOwnerPending) { return ErrCheckoutCannotCounter } - if isRenter && (turn != checkoutTurnRenter || order.Status != orderStatusPendingCheckoutAccept) { + if isRenter && (turn != checkoutTurnRenter || !isRenterPending) { return ErrCheckoutCannotCounter } round := checkout.RoundCount diff --git a/backend/internal/modules/order/force_handoff.go b/backend/internal/modules/order/force_handoff.go index 0c89ddf..09c5f07 100644 --- a/backend/internal/modules/order/force_handoff.go +++ b/backend/internal/modules/order/force_handoff.go @@ -15,20 +15,22 @@ import ( func canAdminForceHandoff(order model.RentalOrder) bool { if isPlatformHandoffOrder(order) { - if order.Status == orderStatusPendingHandoff { - return order.HandoffStatus == handoffStatusPendingRenterConfirm + if order.Status == orderStatusPendingHandoff || order.Status == orderStatusAbnormal { + return order.HandoffStatus == handoffStatusPendingRenterConfirm || + order.HandoffStatus == handoffStatusRenterConfirmTimeout } - return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout + return false } if isPlatformSettlementOrder(order) { return false } - if order.Status == orderStatusPendingHandoff { + if order.Status == orderStatusPendingHandoff || order.Status == orderStatusAbnormal { return order.HandoffStatus == handoffStatusPendingOwner || order.HandoffStatus == handoffStatusOwnerTimeout || - order.HandoffStatus == handoffStatusPendingRenterConfirm + order.HandoffStatus == handoffStatusPendingRenterConfirm || + order.HandoffStatus == handoffStatusRenterConfirmTimeout } - return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout + return false } func forceHandoffRecordType(order model.RentalOrder) string { diff --git a/backend/internal/modules/order/platform_managed.go b/backend/internal/modules/order/platform_managed.go index 963876e..cc2895b 100644 --- a/backend/internal/modules/order/platform_managed.go +++ b/backend/internal/modules/order/platform_managed.go @@ -45,7 +45,7 @@ func canAdminPlatformHandoff(order model.RentalOrder) bool { if !isPlatformHandoffOrder(order) { return false } - if order.Status != orderStatusPendingHandoff { + if order.Status != orderStatusPendingHandoff && order.Status != orderStatusAbnormal { return false } return order.HandoffStatus == handoffStatusPendingOwner || @@ -61,7 +61,9 @@ func canAdminPlatformCheckoutConfirm(order model.RentalOrder) bool { return true } return order.Status == orderStatusAbnormal && - order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout + (order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout || + order.HandoffStatus == handoffStatusOwnerReturnConfirmTimeout || + order.HandoffStatus == handoffStatusPendingOwnerCheckout) } func canAdminMarkOfflineSettlement(order model.RentalOrder) bool { @@ -98,6 +100,7 @@ func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, o return err } now := time.Now() + order.Status = orderStatusPendingHandoff order.HandoffStatus = handoffStatusPendingRenterConfirm order.HandoffStartedAt = &now if err := appendOrderEvent(tx, order, "handoff", "platform_handoff_submitted", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, req.Content, req.Reason, nil, before, nil); err != nil { diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index b5b2bf6..5b88bd3 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -374,7 +374,13 @@ func applyCheckoutActionFlags(dto *CheckoutDTO, checkout model.OrderCheckout, or if dto == nil { return } - negotiating := order.Status == orderStatusPendingCheckoutConfirm || order.Status == orderStatusPendingCheckoutAccept + isOwnerPending := order.Status == orderStatusPendingCheckoutConfirm || + (order.Status == orderStatusAbnormal && + (order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout || + order.HandoffStatus == handoffStatusOwnerReturnConfirmTimeout || + order.HandoffStatus == handoffStatusPendingOwnerCheckout)) + isRenterPending := order.Status == orderStatusPendingCheckoutAccept + negotiating := isOwnerPending || isRenterPending if !negotiating || (checkout.Status != checkoutStatusSubmitted && checkout.Status != checkoutStatusCountered) { return } @@ -382,11 +388,11 @@ func applyCheckoutActionFlags(dto *CheckoutDTO, checkout model.OrderCheckout, or round := dto.RoundCount isOwner := userID == order.OwnerID isRenter := userID == order.RenterID - if isOwner && turn == checkoutTurnOwner && order.Status == orderStatusPendingCheckoutConfirm { + if isOwner && turn == checkoutTurnOwner && isOwnerPending { dto.CanAccept = dto.ShortfallCent <= 0 dto.CanCounter = round < checkoutMaxRounds } - if isRenter && turn == checkoutTurnRenter && order.Status == orderStatusPendingCheckoutAccept { + if isRenter && turn == checkoutTurnRenter && isRenterPending { dto.CanAccept = dto.ShortfallCent <= 0 dto.CanCounter = round < checkoutMaxRounds } diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 2b62a42..1c84138 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -1385,6 +1385,145 @@ func TestAdminForceHandoffStartsNormalOwnerOrder(t *testing.T) { } } +func TestAdminForceHandoffAbnormalOwnerTimeout(t *testing.T) { + db := setupOrderTestDB(t) + if err := db.AutoMigrate(&model.Dispute{}); err != nil { + t.Fatalf("migrate disputes failed: %v", err) + } + repo := NewRepository(db) + + owner := model.User{Phone: "13800003051"} + renter := model.User{Phone: "13900003051"} + 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-FORCE-ABNORMAL-001", + ListingID: 1, + AccountID: 1, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusAbnormal, + HandoffStatus: handoffStatusOwnerTimeout, + HandoffMode: handoffModeOwner, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + + if !canAdminForceHandoff(order) { + t.Fatal("expected canAdminForceHandoff to be true for abnormal owner_timeout") + } + + err := repo.AdminForceHandoff(t.Context(), 99, order.ID, ForceHandoffRequest{ + Content: "客服核实账号已给租客", + Reason: "超时后人工介入交接", + }, AuditMeta{}) + if err != nil { + t.Fatalf("AdminForceHandoff() 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 != orderStatusRenting || saved.HandoffStatus != handoffStatusReceived { + t.Fatalf("status = %s/%s, want renting/received", saved.Status, saved.HandoffStatus) + } +} + +func TestOwnerConfirmCheckoutAfterTimeout(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + + owner := model.User{Phone: "13800003052"} + renter := model.User{Phone: "13900003052"} + 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, + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "测试超时结账账号", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create account failed: %v", err) + } + + listing := model.RentalListing{ + OwnerID: owner.ID, + AccountID: account.ID, + Status: listingStatusRented, + ReviewStatus: listingReviewStatusApproved, + PriceCent: 1000, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("create listing failed: %v", err) + } + + order := model.RentalOrder{ + OrderNo: "ORD-CHECKOUT-TIMEOUT-001", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusAbnormal, + HandoffStatus: handoffStatusOwnerCheckoutConfirmTimeout, + SettlementStatus: settlementStatusPending, + RentAmountCent: 1000, + DepositAmountCent: 0, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + + checkout := model.OrderCheckout{ + OrderID: order.ID, + InitiatedBy: renter.ID, + ProposedBy: renter.ID, + Turn: checkoutTurnOwner, + RoundCount: 1, + Status: checkoutStatusSubmitted, + DepositAmountCent: 0, + RentAmountCent: 1000, + OwnerIncomeAmountCent: 1000, + RenterRefundAmountCent: 0, + ShortfallCent: 0, + } + if err := db.Create(&checkout).Error; err != nil { + t.Fatalf("create checkout failed: %v", err) + } + + // 验证前端 flags 在超时状态下仍正确开放 + checkoutDTO := toCheckoutDTOForUser(checkout, owner.ID, order) + if !checkoutDTO.CanAccept { + t.Fatal("expected CanAccept to be true for owner after timeout") + } + + // 验证号主超时后补确认结账 + err := repo.ConfirmCheckout(t.Context(), owner.ID, order.ID) + if err != nil { + t.Fatalf("ConfirmCheckout() 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 { + t.Fatalf("order status = %s, want completed", saved.Status) + } +} + func TestAdminForceHandoffUsesExistingOwnerRecordAndRejectsPlatformOrderBeforeHandoff(t *testing.T) { db := setupOrderTestDB(t) if err := db.AutoMigrate(&model.Dispute{}); err != nil { diff --git a/frontend/src/features/orders/composables/useOrderActions.ts b/frontend/src/features/orders/composables/useOrderActions.ts index c356fb4..1e85d64 100644 --- a/frontend/src/features/orders/composables/useOrderActions.ts +++ b/frontend/src/features/orders/composables/useOrderActions.ts @@ -189,13 +189,13 @@ export function useOrderActions(options: UseOrderActionsOptions) { ) const canOpenDispute = computed(() => { if (!order.value || (!isOwner.value && !isRenter.value)) return false + if (order.value.active_dispute) return false return ![ 'completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing', - 'abnormal', ].includes(order.value.status) }) const canCounterCheckout = computed(() => { @@ -208,8 +208,15 @@ export function useOrderActions(options: UseOrderActionsOptions) { if (typeof order.value.checkout.can_accept === 'boolean') { return order.value.checkout.can_accept } + const isOwnerPending = + isOwner.value && + (order.value.status === 'pending_checkout_confirm' || + (order.value.status === 'abnormal' && + (order.value.handoff_status === 'owner_checkout_confirm_timeout' || + order.value.handoff_status === 'owner_return_confirm_timeout' || + order.value.handoff_status === 'pending_owner_checkout'))) return ( - (isOwner.value && order.value.status === 'pending_checkout_confirm') || + isOwnerPending || (isRenter.value && order.value.status === 'pending_checkout_accept') ) }) diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue index a3d1e62..f6d68e8 100644 --- a/frontend/src/features/orders/views/MobileOrderDetailView.vue +++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue @@ -121,11 +121,21 @@ const { const showDisputePopup = ref(false) const showCounterPopup = ref(false) const showRejectPopup = ref(false) +const isOwnerCheckoutPending = computed(() => { + if (!order.value || !isOwner.value) return false + return ( + order.value.status === 'pending_checkout_confirm' || + (order.value.status === 'abnormal' && + (order.value.handoff_status === 'owner_checkout_confirm_timeout' || + order.value.handoff_status === 'owner_return_confirm_timeout' || + order.value.handoff_status === 'pending_owner_checkout')) + ) +}) const showCheckoutCounterAction = computed( () => !!order.value && canCounterCheckout.value && - ((isOwner.value && order.value.status === 'pending_checkout_confirm') || + (isOwnerCheckoutPending.value || (isRenter.value && order.value.status === 'pending_checkout_accept')) ) @@ -263,12 +273,21 @@ async function copyListingCode() {

提交交接说明

-

提交登录所需的账号密码、密保注意事项或扫码交接联系方式。

+

+ 当前交接已超时,请尽快补提交交接说明避免订单被关闭。 +

+

提交登录所需的账号密码、密保注意事项或扫码交接联系方式。

结账处理

-

+

+ 结账确认已超时,你可以直接同意当前方案完结,或修改方案交对方确认。 +

+

{{ checkoutRoundLabel || '请核对消耗与扣款' }}。确认无误后同意完结;也可修改方案交对方确认(最多 6 diff --git a/frontend/src/features/orders/views/OrderDetailView.vue b/frontend/src/features/orders/views/OrderDetailView.vue index 9a5c690..67f3504 100644 --- a/frontend/src/features/orders/views/OrderDetailView.vue +++ b/frontend/src/features/orders/views/OrderDetailView.vue @@ -127,11 +127,20 @@ const qrGenerating = cashier.qrGenerating const checkingPayment = cashier.checkingPayment const paymentPayURL = cashier.payURL const activePayWayLabel = computedPayWayLabel(activePayment) -const handoffFocused = ref(false) +const isOwnerCheckoutPending = computed(() => { + if (!order.value || !isOwner.value) return false + return ( + order.value.status === 'pending_checkout_confirm' || + (order.value.status === 'abnormal' && + (order.value.handoff_status === 'owner_checkout_confirm_timeout' || + order.value.handoff_status === 'owner_return_confirm_timeout' || + order.value.handoff_status === 'pending_owner_checkout')) + ) +}) const hasSidebarActions = computed(() => { if (!order.value) return false return ( - (isOwner.value && order.value.status === 'pending_checkout_confirm') || + isOwnerCheckoutPending.value || (isRenter.value && order.value.status === 'pending_checkout_accept') ) }) @@ -139,7 +148,7 @@ const showCheckoutCounterForm = computed( () => !!order.value && canCounterCheckout.value && - ((isOwner.value && order.value.status === 'pending_checkout_confirm') || + (isOwnerCheckoutPending.value || (isRenter.value && order.value.status === 'pending_checkout_accept')) ) const disputeSupportTitle = computed(() => { @@ -325,14 +334,20 @@ watch([() => route.query.focus, order, loading], () => {

当前待办:提交交接说明

- 填写后租客即可确认收号 + + 当前交接已超时,请尽快补提交交接说明避免订单被关闭 + + 填写后租客即可确认收号
route.query.focus, order, loading], () => { v-if=" order && order.checkout && - [ - 'pending_checkout_confirm', - 'pending_checkout_accept', - 'checkout_disputing', - 'completed', - ].includes(order.status) + ( + [ + 'pending_checkout_confirm', + 'pending_checkout_accept', + 'checkout_disputing', + 'completed', + ].includes(order.status) || order.status === 'abnormal' + ) " :order="order" :is-owner="isOwner" @@ -618,9 +635,16 @@ watch([() => route.query.focus, order, loading], () => {
-