新增客服一键交接功能
This commit is contained in:
@@ -80,6 +80,7 @@ type OrderDTO struct {
|
|||||||
type AdminActionsDTO struct {
|
type AdminActionsDTO struct {
|
||||||
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
|
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
|
||||||
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
|
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
|
||||||
|
ForceHandoff *AdminActionDTO `json:"force_handoff,omitempty"`
|
||||||
PlatformCheckoutConfirm *AdminActionDTO `json:"platform_checkout_confirm,omitempty"`
|
PlatformCheckoutConfirm *AdminActionDTO `json:"platform_checkout_confirm,omitempty"`
|
||||||
PlatformCheckoutCounter *AdminActionDTO `json:"platform_checkout_counter,omitempty"`
|
PlatformCheckoutCounter *AdminActionDTO `json:"platform_checkout_counter,omitempty"`
|
||||||
PlatformCheckoutDispute *AdminActionDTO `json:"platform_checkout_dispute,omitempty"`
|
PlatformCheckoutDispute *AdminActionDTO `json:"platform_checkout_dispute,omitempty"`
|
||||||
@@ -138,6 +139,12 @@ type PlatformHandoffRequest struct {
|
|||||||
Reason string `json:"reason" binding:"required"`
|
Reason string `json:"reason" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceHandoffRequest 客服确认普通号主订单已完成线下交接。
|
||||||
|
type ForceHandoffRequest struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Reason string `json:"reason" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type OfflineSettlementRequest struct {
|
type OfflineSettlementRequest struct {
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func canAdminForceHandoff(order model.RentalOrder) bool {
|
||||||
|
if isPlatformHandoffOrder(order) || isPlatformSettlementOrder(order) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if order.Status == orderStatusPendingHandoff {
|
||||||
|
return order.HandoffStatus == handoffStatusPendingOwner ||
|
||||||
|
order.HandoffStatus == handoffStatusOwnerTimeout ||
|
||||||
|
order.HandoffStatus == handoffStatusPendingRenterConfirm
|
||||||
|
}
|
||||||
|
return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
func forceHandoffNeedsContent(order model.RentalOrder) bool {
|
||||||
|
return order.HandoffStatus == handoffStatusPendingOwner || order.HandoffStatus == handoffStatusOwnerTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminForceHandoff 由客服确认普通号主订单已完成线下交接,并立即开始租期。
|
||||||
|
func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orderID uint64, req ForceHandoffRequest, 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 !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" {
|
||||||
|
return ErrOrderCannotForceHandoff
|
||||||
|
}
|
||||||
|
if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview {
|
||||||
|
return ErrOrderCannotForceHandoff
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeDisputeCount int64
|
||||||
|
if err := tx.Model(&model.Dispute{}).
|
||||||
|
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
|
||||||
|
Count(&activeDisputeCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if activeDisputeCount > 0 {
|
||||||
|
return ErrOrderCannotForceHandoff
|
||||||
|
}
|
||||||
|
|
||||||
|
needsContent := forceHandoffNeedsContent(order)
|
||||||
|
content := strings.TrimSpace(req.Content)
|
||||||
|
if needsContent && content == "" {
|
||||||
|
return ErrOrderCannotForceHandoff
|
||||||
|
}
|
||||||
|
if !needsContent {
|
||||||
|
var handoffCount int64
|
||||||
|
if err := tx.Model(&model.HandoffRecord{}).
|
||||||
|
Where("order_id = ? AND type = ?", order.ID, "owner_handoff").
|
||||||
|
Count(&handoffCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if handoffCount == 0 {
|
||||||
|
return ErrOrderCannotForceHandoff
|
||||||
|
}
|
||||||
|
content = "客服已确认双方完成交接,订单已进入使用中。"
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeOrderStatus := order.Status
|
||||||
|
beforeHandoffStatus := order.HandoffStatus
|
||||||
|
now := time.Now()
|
||||||
|
record := model.HandoffRecord{
|
||||||
|
OrderID: order.ID,
|
||||||
|
FromUserID: adminID,
|
||||||
|
ToUserID: order.RenterID,
|
||||||
|
Type: "admin_force_handoff",
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
order.Status = orderStatusRenting
|
||||||
|
order.HandoffStatus = handoffStatusReceived
|
||||||
|
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
|
||||||
|
order.RentedAt = &now
|
||||||
|
orderID := order.ID
|
||||||
|
if err := notification.Append(tx,
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "order_admin",
|
||||||
|
Title: "客服已确认交接",
|
||||||
|
Content: "客服已确认双方完成交接,订单已进入使用中,租期已开始计算。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
},
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.OwnerID,
|
||||||
|
Type: "order_admin",
|
||||||
|
Title: "客服已确认交接",
|
||||||
|
Content: "客服已确认双方完成交接,订单已进入使用中,租期已开始计算。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := appendAuditLog(tx, adminID, "order.force_handoff", "order", order.ID, meta, map[string]any{
|
||||||
|
"order_id": order.ID,
|
||||||
|
"order_no": order.OrderNo,
|
||||||
|
"reason": strings.TrimSpace(req.Reason),
|
||||||
|
"content_length": len(content),
|
||||||
|
"before_order_status": beforeOrderStatus,
|
||||||
|
"after_order_status": order.Status,
|
||||||
|
"before_handoff_status": beforeHandoffStatus,
|
||||||
|
"after_handoff_status": order.HandoffStatus,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Save(&order).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -99,6 +99,28 @@ func (h *Handler) AdminPlatformHandoff(c *gin.Context) {
|
|||||||
response.Created(c, record)
|
response.Created(c, record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminForceHandoff(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req ForceHandoffRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "操作原因不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.AdminForceHandoff(c.Request.Context(), adminID, id, req, auditMeta(c)); err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"received": true})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) AdminPlatformCheckoutConfirm(c *gin.Context) {
|
func (h *Handler) AdminPlatformCheckoutConfirm(c *gin.Context) {
|
||||||
h.adminAction(c, h.service.AdminPlatformCheckoutConfirm, gin.H{"confirmed": true})
|
h.adminAction(c, h.service.AdminPlatformCheckoutConfirm, gin.H{"confirmed": true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ func writeOrderError(c *gin.Context, err error) {
|
|||||||
response.Error(c, http.StatusConflict, "order_cannot_cancel", "当前订单不能取消")
|
response.Error(c, http.StatusConflict, "order_cannot_cancel", "当前订单不能取消")
|
||||||
case errors.Is(err, ErrOrderCannotHandoff):
|
case errors.Is(err, ErrOrderCannotHandoff):
|
||||||
response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接")
|
response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接")
|
||||||
|
case errors.Is(err, ErrOrderCannotForceHandoff):
|
||||||
|
response.Error(c, http.StatusConflict, "order_cannot_force_handoff", "当前订单不能客服一键交接")
|
||||||
case errors.Is(err, ErrOrderCannotResetHandoff):
|
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):
|
case errors.Is(err, ErrOrderCannotReceive):
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO {
|
|||||||
Label: "客服代交接",
|
Label: "客服代交接",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if canAdminForceHandoff(order) {
|
||||||
|
actions.ForceHandoff = &AdminActionDTO{
|
||||||
|
Enabled: true,
|
||||||
|
Label: "客服一键交接",
|
||||||
|
}
|
||||||
|
}
|
||||||
if canAdminPlatformCheckoutConfirm(order) {
|
if canAdminPlatformCheckoutConfirm(order) {
|
||||||
actions.PlatformCheckoutConfirm = &AdminActionDTO{
|
actions.PlatformCheckoutConfirm = &AdminActionDTO{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@@ -43,7 +49,7 @@ func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO {
|
|||||||
Label: "确认线下结算",
|
Label: "确认线下结算",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if actions.ResetHandoff == nil && actions.PlatformHandoff == nil &&
|
if actions.ResetHandoff == nil && actions.PlatformHandoff == nil && actions.ForceHandoff == nil &&
|
||||||
actions.PlatformCheckoutConfirm == nil && actions.PlatformCheckoutCounter == nil &&
|
actions.PlatformCheckoutConfirm == nil && actions.PlatformCheckoutCounter == nil &&
|
||||||
actions.PlatformCheckoutDispute == nil && actions.PlatformOfflineSettlement == nil {
|
actions.PlatformCheckoutDispute == nil && actions.PlatformOfflineSettlement == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1011,6 +1011,155 @@ func TestAdminResetRenterConfirmTimeoutRefreshesStageTime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminForceHandoffStartsNormalOwnerOrder(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: "13800003006"}
|
||||||
|
renter := model.User{Phone: "13900003006"}
|
||||||
|
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-HANDOFF-001",
|
||||||
|
ListingID: 1,
|
||||||
|
AccountID: 1,
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
RenterID: renter.ID,
|
||||||
|
Status: orderStatusPendingHandoff,
|
||||||
|
HandoffStatus: handoffStatusPendingOwner,
|
||||||
|
HandoffMode: handoffModeOwner,
|
||||||
|
AccountSnapshot: datatypes.JSON([]byte(`{
|
||||||
|
"haf_coin_amount": 100000000,
|
||||||
|
"daily_loss_m": 50
|
||||||
|
}`)),
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatalf("create order failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if saved.RentedAt == nil || saved.EstimatedDurationHours != 48 {
|
||||||
|
t.Fatalf("rented at/duration = %#v/%d, want set/48", saved.RentedAt, saved.EstimatedDurationHours)
|
||||||
|
}
|
||||||
|
var record model.HandoffRecord
|
||||||
|
if err := db.Where("order_id = ? AND type = ?", order.ID, "admin_force_handoff").First(&record).Error; err != nil {
|
||||||
|
t.Fatalf("find force handoff record failed: %v", err)
|
||||||
|
}
|
||||||
|
if record.Content != "账号已通过群聊完成交接" {
|
||||||
|
t.Fatalf("record content = %q", record.Content)
|
||||||
|
}
|
||||||
|
var notificationCount int64
|
||||||
|
if err := db.Model(&model.Notification{}).Where("biz_type = ? AND biz_id = ?", "order", order.ID).Count(¬ificationCount).Error; err != nil {
|
||||||
|
t.Fatalf("count notifications failed: %v", err)
|
||||||
|
}
|
||||||
|
if notificationCount != 2 {
|
||||||
|
t.Fatalf("notification count = %d, want 2", notificationCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminForceHandoffUsesExistingOwnerRecordAndRejectsPlatformOrder(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: "13800003007"}
|
||||||
|
renter := model.User{Phone: "13900003007"}
|
||||||
|
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-HANDOFF-002",
|
||||||
|
ListingID: 2,
|
||||||
|
AccountID: 2,
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
RenterID: renter.ID,
|
||||||
|
Status: orderStatusAbnormal,
|
||||||
|
HandoffStatus: handoffStatusRenterConfirmTimeout,
|
||||||
|
HandoffMode: handoffModeOwner,
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatalf("create order failed: %v", err)
|
||||||
|
}
|
||||||
|
ownerRecord := model.HandoffRecord{
|
||||||
|
OrderID: order.ID,
|
||||||
|
FromUserID: owner.ID,
|
||||||
|
ToUserID: renter.ID,
|
||||||
|
Type: "owner_handoff",
|
||||||
|
Content: "号主已提交交接说明",
|
||||||
|
}
|
||||||
|
if err := db.Create(&ownerRecord).Error; err != nil {
|
||||||
|
t.Fatalf("create owner handoff record failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.AdminForceHandoff(t.Context(), 99, order.ID, ForceHandoffRequest{
|
||||||
|
Reason: "已联系双方确认完成交接",
|
||||||
|
}, AuditMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AdminForceHandoff() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.First(&order, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("load order failed: %v", err)
|
||||||
|
}
|
||||||
|
if order.Status != orderStatusRenting || order.HandoffStatus != handoffStatusReceived {
|
||||||
|
t.Fatalf("status = %s/%s, want renting/received", order.Status, order.HandoffStatus)
|
||||||
|
}
|
||||||
|
var savedOwnerRecord model.HandoffRecord
|
||||||
|
if err := db.First(&savedOwnerRecord, ownerRecord.ID).Error; err != nil {
|
||||||
|
t.Fatalf("load owner handoff record failed: %v", err)
|
||||||
|
}
|
||||||
|
if savedOwnerRecord.ConfirmedByRenterAt != nil {
|
||||||
|
t.Fatal("owner handoff record must not be marked as renter confirmed")
|
||||||
|
}
|
||||||
|
|
||||||
|
platformOrder := model.RentalOrder{
|
||||||
|
OrderNo: "ORD-FORCE-HANDOFF-003",
|
||||||
|
ListingID: 3,
|
||||||
|
AccountID: 3,
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
RenterID: renter.ID,
|
||||||
|
Status: orderStatusPendingHandoff,
|
||||||
|
HandoffStatus: handoffStatusPendingOwner,
|
||||||
|
HandoffMode: handoffModePlatform,
|
||||||
|
}
|
||||||
|
if err := db.Create(&platformOrder).Error; err != nil {
|
||||||
|
t.Fatalf("create platform order failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.AdminForceHandoff(t.Context(), 99, platformOrder.ID, ForceHandoffRequest{
|
||||||
|
Content: "不应允许",
|
||||||
|
Reason: "测试",
|
||||||
|
}, AuditMeta{}); err != ErrOrderCannotForceHandoff {
|
||||||
|
t.Fatalf("platform order error = %v, want %v", err, ErrOrderCannotForceHandoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSubmitCheckoutRefreshesStageTime(t *testing.T) {
|
func TestSubmitCheckoutRefreshesStageTime(t *testing.T) {
|
||||||
db := setupOrderTestDB(t)
|
db := setupOrderTestDB(t)
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ var (
|
|||||||
ErrChannelPaymentRequired = errors.New("channel payment required")
|
ErrChannelPaymentRequired = errors.New("channel payment required")
|
||||||
ErrOrderCannotCancel = errors.New("order cannot cancel")
|
ErrOrderCannotCancel = errors.New("order cannot cancel")
|
||||||
ErrOrderCannotHandoff = errors.New("order cannot handoff")
|
ErrOrderCannotHandoff = errors.New("order cannot handoff")
|
||||||
|
ErrOrderCannotForceHandoff = errors.New("order cannot force handoff")
|
||||||
ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff")
|
ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff")
|
||||||
ErrOrderCannotReceive = errors.New("order cannot receive")
|
ErrOrderCannotReceive = errors.New("order cannot receive")
|
||||||
ErrOrderCannotReturn = errors.New("order cannot return")
|
ErrOrderCannotReturn = errors.New("order cannot return")
|
||||||
@@ -233,6 +234,16 @@ func (s *Service) AdminPlatformHandoff(ctx context.Context, adminID uint64, orde
|
|||||||
return s.repo.AdminPlatformHandoff(ctx, adminID, orderID, req, meta)
|
return s.repo.AdminPlatformHandoff(ctx, adminID, orderID, req, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) AdminForceHandoff(ctx context.Context, adminID uint64, orderID uint64, req ForceHandoffRequest, meta AuditMeta) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if orderID == 0 || req.Reason == "" {
|
||||||
|
return ErrOrderCannotForceHandoff
|
||||||
|
}
|
||||||
|
return s.repo.AdminForceHandoff(ctx, adminID, orderID, req, meta)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) AdminPlatformCheckoutConfirm(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
func (s *Service) AdminPlatformCheckoutConfirm(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return ErrDependencyUnavailable
|
return ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -578,6 +578,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal)
|
adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal)
|
||||||
adminRoutes.POST("/orders/:id/reset-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminResetHandoff)
|
adminRoutes.POST("/orders/:id/reset-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminResetHandoff)
|
||||||
adminRoutes.POST("/orders/:id/platform-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformHandoff)
|
adminRoutes.POST("/orders/:id/platform-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformHandoff)
|
||||||
|
adminRoutes.POST("/orders/:id/force-handoff", requirePerm("order:force_handoff"), orderHandler.AdminForceHandoff)
|
||||||
adminRoutes.POST("/orders/:id/platform-checkout/confirm", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutConfirm)
|
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/counter", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutCounter)
|
||||||
adminRoutes.POST("/orders/:id/platform-checkout/dispute", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutDispute)
|
adminRoutes.POST("/orders/:id/platform-checkout/dispute", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutDispute)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
INSERT INTO permissions (code, name, resource, action)
|
||||||
|
VALUES ('order:force_handoff', '客服一键交接', 'order', 'force_handoff')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
resource = VALUES(resource),
|
||||||
|
action = VALUES(action);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.code = 'order:force_handoff'
|
||||||
|
WHERE r.code IN ('cs', 'ops');
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
DELETE rp
|
||||||
|
FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE p.code = 'order:force_handoff';
|
||||||
|
|
||||||
|
DELETE FROM permissions WHERE code = 'order:force_handoff';
|
||||||
@@ -6,6 +6,7 @@ import { useRoute } from 'vue-router'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
adminCloseOrder,
|
adminCloseOrder,
|
||||||
|
adminForceHandoff,
|
||||||
adminHoldDeposit,
|
adminHoldDeposit,
|
||||||
adminMarkOfflineSettlement,
|
adminMarkOfflineSettlement,
|
||||||
adminMarkOrderAbnormal,
|
adminMarkOrderAbnormal,
|
||||||
@@ -66,6 +67,9 @@ const refundStatus = ref<RefundStatus | null>(null)
|
|||||||
const platformHandoffVisible = ref(false)
|
const platformHandoffVisible = ref(false)
|
||||||
const platformHandoffContent = ref('')
|
const platformHandoffContent = ref('')
|
||||||
const platformHandoffReason = ref('')
|
const platformHandoffReason = ref('')
|
||||||
|
const forceHandoffVisible = ref(false)
|
||||||
|
const forceHandoffContent = ref('')
|
||||||
|
const forceHandoffReason = ref('')
|
||||||
const platformCheckoutCounterVisible = ref(false)
|
const platformCheckoutCounterVisible = ref(false)
|
||||||
const platformCheckoutCounterForm = ref({
|
const platformCheckoutCounterForm = ref({
|
||||||
consumableAmountYuan: 0,
|
consumableAmountYuan: 0,
|
||||||
@@ -126,6 +130,7 @@ const canOperate = computed(
|
|||||||
)
|
)
|
||||||
const resetAction = computed(() => order.value?.admin_actions?.reset_handoff)
|
const resetAction = computed(() => order.value?.admin_actions?.reset_handoff)
|
||||||
const platformHandoffAction = computed(() => order.value?.admin_actions?.platform_handoff)
|
const platformHandoffAction = computed(() => order.value?.admin_actions?.platform_handoff)
|
||||||
|
const forceHandoffAction = computed(() => order.value?.admin_actions?.force_handoff)
|
||||||
const platformCheckoutConfirmAction = computed(
|
const platformCheckoutConfirmAction = computed(
|
||||||
() => order.value?.admin_actions?.platform_checkout_confirm
|
() => order.value?.admin_actions?.platform_checkout_confirm
|
||||||
)
|
)
|
||||||
@@ -143,6 +148,12 @@ const resetActionLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
const canResetHandoff = computed(() => resetAction.value?.enabled === true)
|
const canResetHandoff = computed(() => resetAction.value?.enabled === true)
|
||||||
const canPlatformHandoff = computed(() => platformHandoffAction.value?.enabled === true)
|
const canPlatformHandoff = computed(() => platformHandoffAction.value?.enabled === true)
|
||||||
|
const canForceHandoff = computed(() => forceHandoffAction.value?.enabled === true)
|
||||||
|
const forceHandoffNeedsContent = computed(
|
||||||
|
() =>
|
||||||
|
order.value?.handoff_status === 'pending_owner' ||
|
||||||
|
order.value?.handoff_status === 'owner_timeout'
|
||||||
|
)
|
||||||
const canPlatformCheckoutConfirm = computed(
|
const canPlatformCheckoutConfirm = computed(
|
||||||
() => platformCheckoutConfirmAction.value?.enabled === true
|
() => platformCheckoutConfirmAction.value?.enabled === true
|
||||||
)
|
)
|
||||||
@@ -498,6 +509,39 @@ async function submitPlatformHandoff() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openForceHandoff() {
|
||||||
|
forceHandoffContent.value = ''
|
||||||
|
forceHandoffReason.value = ''
|
||||||
|
forceHandoffVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForceHandoff() {
|
||||||
|
if (!order.value) return
|
||||||
|
if (!forceHandoffReason.value.trim()) {
|
||||||
|
ElMessage.warning('请填写客服操作原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (forceHandoffNeedsContent.value && !forceHandoffContent.value.trim()) {
|
||||||
|
ElMessage.warning('当前尚无交接说明,请填写交接内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platformSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await adminForceHandoff(
|
||||||
|
order.value.id,
|
||||||
|
forceHandoffContent.value.trim(),
|
||||||
|
forceHandoffReason.value.trim()
|
||||||
|
)
|
||||||
|
ElMessage.success('客服已确认交接,订单进入使用中')
|
||||||
|
forceHandoffVisible.value = false
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '客服一键交接失败'))
|
||||||
|
} finally {
|
||||||
|
platformSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openPlatformCheckoutCounter() {
|
function openPlatformCheckoutCounter() {
|
||||||
const checkout = order.value?.checkout
|
const checkout = order.value?.checkout
|
||||||
platformCheckoutCounterForm.value = {
|
platformCheckoutCounterForm.value = {
|
||||||
@@ -880,6 +924,9 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
>
|
>
|
||||||
{{ platformHandoffAction?.label || '客服代交接' }}
|
{{ platformHandoffAction?.label || '客服代交接' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button v-if="canForceHandoff" type="warning" @click="openForceHandoff">
|
||||||
|
{{ forceHandoffAction?.label || '客服一键交接' }}
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canPlatformCheckoutConfirm"
|
v-if="canPlatformCheckoutConfirm"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -1433,6 +1480,39 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="forceHandoffVisible" title="客服一键交接" width="560px" destroy-on-close>
|
||||||
|
<div v-if="order" class="dialog-body">
|
||||||
|
<p>
|
||||||
|
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
|
||||||
|
</p>
|
||||||
|
<el-alert
|
||||||
|
title="确认后将跳过号主交接与租客确认步骤,订单会立即进入使用中并开始计算租期。"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-if="forceHandoffNeedsContent"
|
||||||
|
v-model="forceHandoffContent"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
placeholder="填写可供租客查看的交接说明"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="forceHandoffReason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="填写客服确认双方已完成交接的原因"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="forceHandoffVisible = false">取消</el-button>
|
||||||
|
<el-button type="warning" :loading="platformSubmitting" @click="submitForceHandoff">
|
||||||
|
确认一键交接
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="adminDisputeVisible" title="发起订单申诉" width="620px" destroy-on-close>
|
<el-dialog v-model="adminDisputeVisible" title="发起订单申诉" width="620px" destroy-on-close>
|
||||||
<div v-if="order" class="dialog-body">
|
<div v-if="order" class="dialog-body">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export interface Order {
|
|||||||
export interface AdminActions {
|
export interface AdminActions {
|
||||||
reset_handoff?: AdminAction
|
reset_handoff?: AdminAction
|
||||||
platform_handoff?: AdminAction
|
platform_handoff?: AdminAction
|
||||||
|
force_handoff?: AdminAction
|
||||||
platform_checkout_confirm?: AdminAction
|
platform_checkout_confirm?: AdminAction
|
||||||
platform_checkout_counter?: AdminAction
|
platform_checkout_counter?: AdminAction
|
||||||
platform_checkout_dispute?: AdminAction
|
platform_checkout_dispute?: AdminAction
|
||||||
@@ -418,6 +419,14 @@ export async function adminPlatformHandoff(id: number, content: string, reason:
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function adminForceHandoff(id: number, content: string, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(
|
||||||
|
`/admin/orders/${id}/force-handoff`,
|
||||||
|
{ content, reason }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function adminPlatformCheckoutConfirm(id: number, reason: string) {
|
export async function adminPlatformCheckoutConfirm(id: number, reason: string) {
|
||||||
const { data } = await apiClient.post<ApiResponse<{ confirmed: boolean }>>(
|
const { data } = await apiClient.post<ApiResponse<{ confirmed: boolean }>>(
|
||||||
`/admin/orders/${id}/platform-checkout/confirm`,
|
`/admin/orders/${id}/platform-checkout/confirm`,
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export function formatHandoffRecordType(type: string) {
|
|||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
owner_handoff: '卖家交接',
|
owner_handoff: '卖家交接',
|
||||||
platform_handoff: '客服代交接',
|
platform_handoff: '客服代交接',
|
||||||
|
admin_force_handoff: '客服一键交接',
|
||||||
renter_checkout: '买家结账',
|
renter_checkout: '买家结账',
|
||||||
owner_counter_checkout: '卖家反驳结账',
|
owner_counter_checkout: '卖家反驳结账',
|
||||||
platform_checkout_counter: '客服修改结账',
|
platform_checkout_counter: '客服修改结账',
|
||||||
|
|||||||
Reference in New Issue
Block a user