实现结账流程:替换旧归还逻辑为完整结账-修正-争议流程
- 新增 order_checkouts 表,支持结账明细(消耗/押金扣除/退回/号主入账) - 租客发起结账 → 号主确认 → 已完成(正常路径) - 号主修改结账 → 租客确认修正 → 已完成(修正路径) - 结账阶段任一方发起争议 → 后台仲裁 → 已完成/已关闭/异常(争议路径) - 争议模块适配结账争议类型,仲裁结果新增 mark_abnormal - 超时任务适配新的 pending_checkout_confirm 状态 - 前端结账明细面板、发起结账/确认/修正/拒绝表单全部实现 - 移除旧的 SubmitReturn/ConfirmReturn 接口
This commit is contained in:
@@ -253,16 +253,16 @@ func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresh
|
|||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
Type: "timeout",
|
Type: "timeout",
|
||||||
Title: "订单已逾期未归还",
|
Title: "订单已逾期未结账",
|
||||||
Content: "订单已超过预计截止时间,请尽快提交归还说明,避免进入申诉处理。",
|
Content: "订单已超过预计截止时间,请尽快发起结账,避免进入申诉处理。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.OwnerID,
|
UserID: order.OwnerID,
|
||||||
Type: "timeout",
|
Type: "timeout",
|
||||||
Title: "租客逾期未归还",
|
Title: "租客逾期未结账",
|
||||||
Content: "租客未在预计截止后及时归还,你可以发起申诉或等待客服处理。",
|
Content: "租客未在预计截止后及时发起结账,你可以发起申诉或等待客服处理。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
@@ -281,7 +281,7 @@ func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresh
|
|||||||
func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
|
func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
|
||||||
var rows []model.RentalOrder
|
var rows []model.RentalOrder
|
||||||
err := j.db.WithContext(ctx).
|
err := j.db.WithContext(ctx).
|
||||||
Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_return_confirm", "pending_owner_return_confirm", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)).
|
Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_checkout_confirm", "pending_owner_checkout", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)).
|
||||||
Order("id ASC").
|
Order("id ASC").
|
||||||
Limit(100).
|
Limit(100).
|
||||||
Find(&rows).Error
|
Find(&rows).Error
|
||||||
@@ -290,28 +290,28 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
|
|||||||
}
|
}
|
||||||
count := 0
|
count := 0
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if err := j.updateOrder(ctx, row.ID, "order.timeout.owner_return_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) {
|
if err := j.updateOrder(ctx, row.ID, "order.timeout.owner_checkout_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) {
|
||||||
if order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" {
|
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
before := snapshot(order)
|
before := snapshot(order)
|
||||||
order.Status = "abnormal"
|
order.Status = "abnormal"
|
||||||
order.HandoffStatus = "owner_return_confirm_timeout"
|
order.HandoffStatus = "owner_checkout_confirm_timeout"
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if err := notification.Append(tx,
|
if err := notification.Append(tx,
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
Type: "timeout",
|
Type: "timeout",
|
||||||
Title: "号主确认归还超时",
|
Title: "号主确认结账超时",
|
||||||
Content: "号主未在规定时间内确认归还,订单已进入客服复核状态。",
|
Content: "号主未在规定时间内确认结账,订单已进入客服复核状态。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.OwnerID,
|
UserID: order.OwnerID,
|
||||||
Type: "timeout",
|
Type: "timeout",
|
||||||
Title: "确认归还已超时",
|
Title: "确认结账已超时",
|
||||||
Content: "你未在规定时间内确认归还,订单已进入客服复核状态。",
|
Content: "你未在规定时间内确认结账,订单已进入客服复核状态。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderCheckout struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||||
|
InitiatedBy uint64 `gorm:"not null" json:"initiated_by"`
|
||||||
|
Status string `gorm:"size:32;not null;default:'submitted'" json:"status"`
|
||||||
|
RentAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"rent_amount"`
|
||||||
|
DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"`
|
||||||
|
ConsumableAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"consumable_amount"`
|
||||||
|
CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"`
|
||||||
|
OtherAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"other_amount"`
|
||||||
|
DepositDeductAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_deduct_amount"`
|
||||||
|
RenterRefundAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"renter_refund_amount"`
|
||||||
|
OwnerIncomeAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"owner_income_amount"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
|
||||||
|
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
|
||||||
|
OwnerAdjustedAt *time.Time `json:"owner_adjusted_at"`
|
||||||
|
RenterConfirmedAt *time.Time `json:"renter_confirmed_at"`
|
||||||
|
RenterRejectedAt *time.Time `json:"renter_rejected_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (OrderCheckout) TableName() string {
|
||||||
|
return "order_checkouts"
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ func (r *Repository) Summary() (*DashboardDTO, error) {
|
|||||||
if err := r.db.Model(&model.RentalOrder{}).Where("status = ? AND handoff_status IN ?", "pending_handoff", []string{"pending_owner", "pending_renter_confirm"}).Count(&pending.PendingHandoffs).Error; err != nil {
|
if err := r.db.Model(&model.RentalOrder{}).Where("status = ? AND handoff_status IN ?", "pending_handoff", []string{"pending_owner", "pending_renter_confirm"}).Count(&pending.PendingHandoffs).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := r.db.Model(&model.RentalOrder{}).Where("status = ?", "pending_return_confirm").Count(&pending.PendingReturnConfirms).Error; err != nil {
|
if err := r.db.Model(&model.RentalOrder{}).Where("status IN ?", []string{"pending_checkout_confirm", "pending_checkout_accept"}).Count(&pending.PendingReturnConfirms).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
recentOrders, err := r.recentOrders()
|
recentOrders, err := r.recentOrders()
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
|
|||||||
if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" {
|
if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" {
|
||||||
return ErrInvalidDispute
|
return ErrInvalidDispute
|
||||||
}
|
}
|
||||||
|
isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept"
|
||||||
var count int64
|
var count int64
|
||||||
if err := tx.Model(&model.Dispute{}).
|
if err := tx.Model(&model.Dispute{}).
|
||||||
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
|
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
|
||||||
@@ -57,7 +58,7 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
|
|||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
InitiatorID: userID,
|
InitiatorID: userID,
|
||||||
TargetUserID: targetID,
|
TargetUserID: targetID,
|
||||||
Type: req.Type,
|
Type: disputeType(req.Type, isCheckoutDispute),
|
||||||
Status: "open",
|
Status: "open",
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
EvidenceURLS: evidence,
|
EvidenceURLS: evidence,
|
||||||
@@ -65,17 +66,42 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
|
|||||||
if err := tx.Create(&row).Error; err != nil {
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
order.Status = "disputing"
|
if isCheckoutDispute {
|
||||||
|
now := time.Now()
|
||||||
|
order.Status = "checkout_disputing"
|
||||||
|
order.HandoffStatus = "checkout_disputed"
|
||||||
|
order.SettlementStatus = "disputed"
|
||||||
|
updates := map[string]any{
|
||||||
|
"status": "disputed",
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
if userID == order.RenterID {
|
||||||
|
updates["renter_rejected_at"] = now
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.OrderCheckout{}).
|
||||||
|
Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}).
|
||||||
|
Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
order.Status = "disputing"
|
||||||
|
}
|
||||||
if err := tx.Save(&order).Error; err != nil {
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
disputeID := row.ID
|
disputeID := row.ID
|
||||||
|
title := "订单进入申诉"
|
||||||
|
content := "对方已发起申诉,请等待客服仲裁或补充沟通记录。"
|
||||||
|
if isCheckoutDispute {
|
||||||
|
title = "订单进入结账争议"
|
||||||
|
content = "对方已发起结账争议,请等待客服仲裁或补充结账证据。"
|
||||||
|
}
|
||||||
if err := notification.Append(tx,
|
if err := notification.Append(tx,
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: targetID,
|
UserID: targetID,
|
||||||
Type: "dispute",
|
Type: "dispute",
|
||||||
Title: "订单进入申诉",
|
Title: title,
|
||||||
Content: "对方已发起申诉,请等待客服仲裁或补充沟通记录。",
|
Content: content,
|
||||||
BizType: "dispute",
|
BizType: "dispute",
|
||||||
BizID: &disputeID,
|
BizID: &disputeID,
|
||||||
},
|
},
|
||||||
@@ -177,6 +203,9 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest,
|
|||||||
if req.Result == "order_close" {
|
if req.Result == "order_close" {
|
||||||
listing.Status = "offline"
|
listing.Status = "offline"
|
||||||
account.Status = "offline"
|
account.Status = "offline"
|
||||||
|
} else if req.Result == "mark_abnormal" {
|
||||||
|
listing.Status = "abnormal"
|
||||||
|
account.Status = "abnormal"
|
||||||
} else {
|
} else {
|
||||||
listing.Status = "published"
|
listing.Status = "published"
|
||||||
account.Status = "published"
|
account.Status = "published"
|
||||||
@@ -335,6 +364,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (
|
|||||||
addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客")
|
addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客")
|
||||||
case "order_close":
|
case "order_close":
|
||||||
// Only release frozen funds. No available-balance settlement happens in development mode.
|
// Only release frozen funds. No available-balance settlement happens in development mode.
|
||||||
|
case "mark_abnormal":
|
||||||
|
// 标记异常只释放冻结账务,后续由客服继续线下复核。
|
||||||
default:
|
default:
|
||||||
return settlement, ErrInvalidDispute
|
return settlement, ErrInvalidDispute
|
||||||
}
|
}
|
||||||
@@ -395,11 +426,20 @@ func arbitrateOrderStatus(result string) string {
|
|||||||
switch result {
|
switch result {
|
||||||
case "full_refund", "partial_refund", "order_close":
|
case "full_refund", "partial_refund", "order_close":
|
||||||
return "closed"
|
return "closed"
|
||||||
|
case "mark_abnormal":
|
||||||
|
return "abnormal"
|
||||||
default:
|
default:
|
||||||
return "completed"
|
return "completed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func disputeType(input string, isCheckoutDispute bool) string {
|
||||||
|
if isCheckoutDispute {
|
||||||
|
return "checkout_dispute"
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error {
|
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error {
|
||||||
raw, err := json.Marshal(detail)
|
raw, err := json.Marshal(detail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type OrderDTO struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
HandoffStatus string `json:"handoff_status"`
|
HandoffStatus string `json:"handoff_status"`
|
||||||
SettlementStatus string `json:"settlement_status"`
|
SettlementStatus string `json:"settlement_status"`
|
||||||
|
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -39,8 +40,21 @@ type SubmitHandoffRequest struct {
|
|||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubmitReturnRequest struct {
|
type SubmitCheckoutRequest struct {
|
||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content" binding:"required"`
|
||||||
|
ConsumableAmount float64 `json:"consumable_amount"`
|
||||||
|
CoinConsumedM float64 `json:"coin_consumed_m"`
|
||||||
|
OtherAmount float64 `json:"other_amount"`
|
||||||
|
EvidenceURLS []string `json:"evidence_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CounterCheckoutRequest struct {
|
||||||
|
ConsumableAmount float64 `json:"consumable_amount"`
|
||||||
|
CoinConsumedM float64 `json:"coin_consumed_m"`
|
||||||
|
OtherAmount float64 `json:"other_amount"`
|
||||||
|
DepositDeductAmount float64 `json:"deposit_deduct_amount"`
|
||||||
|
Reason string `json:"reason" binding:"required"`
|
||||||
|
EvidenceURLS []string `json:"evidence_urls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminActionRequest struct {
|
type AdminActionRequest struct {
|
||||||
@@ -63,3 +77,26 @@ type HandoffRecordDTO struct {
|
|||||||
ConfirmedByOwnerAt *time.Time `json:"confirmed_by_owner_at"`
|
ConfirmedByOwnerAt *time.Time `json:"confirmed_by_owner_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CheckoutDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
OrderID uint64 `json:"order_id"`
|
||||||
|
InitiatedBy uint64 `json:"initiated_by"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RentAmount float64 `json:"rent_amount"`
|
||||||
|
DepositAmount float64 `json:"deposit_amount"`
|
||||||
|
ConsumableAmount float64 `json:"consumable_amount"`
|
||||||
|
CoinConsumedM float64 `json:"coin_consumed_m"`
|
||||||
|
OtherAmount float64 `json:"other_amount"`
|
||||||
|
DepositDeductAmount float64 `json:"deposit_deduct_amount"`
|
||||||
|
RenterRefundAmount float64 `json:"renter_refund_amount"`
|
||||||
|
OwnerIncomeAmount float64 `json:"owner_income_amount"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
EvidenceURLS []string `json:"evidence_urls"`
|
||||||
|
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
|
||||||
|
OwnerAdjustedAt *time.Time `json:"owner_adjusted_at"`
|
||||||
|
RenterConfirmedAt *time.Time `json:"renter_confirmed_at"`
|
||||||
|
RenterRejectedAt *time.Time `json:"renter_rejected_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ func (h *Handler) HandoffRecords(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, gin.H{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) SubmitReturn(c *gin.Context) {
|
func (h *Handler) SubmitCheckout(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
userID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
@@ -220,12 +220,12 @@ func (h *Handler) SubmitReturn(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req SubmitReturnRequest
|
var req SubmitCheckoutRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
response.BadRequest(c, "归还说明不能为空")
|
response.BadRequest(c, "结账说明不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
record, err := h.service.SubmitReturn(userID, id, req)
|
record, err := h.service.SubmitCheckout(userID, id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOrderError(c, err)
|
writeOrderError(c, err)
|
||||||
return
|
return
|
||||||
@@ -233,7 +233,7 @@ func (h *Handler) SubmitReturn(c *gin.Context) {
|
|||||||
response.Created(c, record)
|
response.Created(c, record)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ConfirmReturn(c *gin.Context) {
|
func (h *Handler) ConfirmCheckout(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
userID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
@@ -243,7 +243,47 @@ func (h *Handler) ConfirmReturn(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.service.ConfirmReturn(userID, id); err != nil {
|
if err := h.service.ConfirmCheckout(userID, id); err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"completed": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CounterCheckout(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(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.CounterCheckout(userID, id, req)
|
||||||
|
if err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, checkout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AcceptCheckout(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.AcceptCheckout(userID, id); err != nil {
|
||||||
writeOrderError(c, err)
|
writeOrderError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -293,10 +333,16 @@ func writeOrderError(c *gin.Context, err error) {
|
|||||||
response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接")
|
response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接")
|
||||||
case errors.Is(err, ErrOrderCannotReceive):
|
case errors.Is(err, ErrOrderCannotReceive):
|
||||||
response.Error(c, http.StatusConflict, "order_cannot_receive", "当前订单不能确认收号")
|
response.Error(c, http.StatusConflict, "order_cannot_receive", "当前订单不能确认收号")
|
||||||
case errors.Is(err, ErrOrderCannotReturn):
|
|
||||||
response.Error(c, http.StatusConflict, "order_cannot_return", "当前订单不能归还")
|
|
||||||
case errors.Is(err, ErrOrderCannotComplete):
|
case errors.Is(err, ErrOrderCannotComplete):
|
||||||
response.Error(c, http.StatusConflict, "order_cannot_complete", "当前订单不能完成")
|
response.Error(c, http.StatusConflict, "order_cannot_complete", "当前订单不能完成")
|
||||||
|
case errors.Is(err, ErrCheckoutCannotSubmit):
|
||||||
|
response.Error(c, http.StatusConflict, "checkout_cannot_submit", "当前订单不能发起结账")
|
||||||
|
case errors.Is(err, ErrCheckoutCannotConfirm):
|
||||||
|
response.Error(c, http.StatusConflict, "checkout_cannot_confirm", "当前结账不能确认")
|
||||||
|
case errors.Is(err, ErrCheckoutCannotCounter):
|
||||||
|
response.Error(c, http.StatusConflict, "checkout_cannot_counter", "当前结账不能修改")
|
||||||
|
case errors.Is(err, ErrInvalidCheckoutAmount):
|
||||||
|
response.BadRequest(c, "结账金额不符合规则")
|
||||||
case errors.Is(err, ErrPermissionDenied):
|
case errors.Is(err, ErrPermissionDenied):
|
||||||
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单")
|
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单")
|
||||||
case IsNotFound(err):
|
case IsNotFound(err):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -296,7 +297,7 @@ func (r *Repository) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRec
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) {
|
func (r *Repository) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) {
|
||||||
var recordID uint64
|
var recordID uint64
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var order model.RentalOrder
|
var order model.RentalOrder
|
||||||
@@ -307,28 +308,43 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
|
|||||||
return ErrPermissionDenied
|
return ErrPermissionDenied
|
||||||
}
|
}
|
||||||
if (order.Status != "renting" && order.Status != "overdue") || (order.HandoffStatus != "received" && order.HandoffStatus != "return_overdue") {
|
if (order.Status != "renting" && order.Status != "overdue") || (order.HandoffStatus != "received" && order.HandoffStatus != "return_overdue") {
|
||||||
return ErrOrderCannotReturn
|
return ErrCheckoutCannotSubmit
|
||||||
|
}
|
||||||
|
hasOpenCheckout, err := hasOpenCheckout(tx, order.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hasOpenCheckout {
|
||||||
|
return ErrCheckoutCannotSubmit
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
record := model.HandoffRecord{
|
record := model.HandoffRecord{
|
||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
FromUserID: order.RenterID,
|
FromUserID: order.RenterID,
|
||||||
ToUserID: order.OwnerID,
|
ToUserID: order.OwnerID,
|
||||||
Type: "renter_return",
|
Type: "renter_checkout",
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&record).Error; err != nil {
|
if err := tx.Create(&record).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
order.Status = "pending_return_confirm"
|
checkout, err := buildCheckout(order, order.RenterID, "submitted", req.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, 0, false)
|
||||||
order.HandoffStatus = "pending_owner_return_confirm"
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Create(&checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
order.Status = "pending_checkout_confirm"
|
||||||
|
order.HandoffStatus = "pending_owner_checkout"
|
||||||
|
order.SettlementStatus = "pending"
|
||||||
order.RentEndAt = &now
|
order.RentEndAt = &now
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if err := notification.Append(tx, notification.Entry{
|
if err := notification.Append(tx, notification.Entry{
|
||||||
UserID: order.OwnerID,
|
UserID: order.OwnerID,
|
||||||
Type: "return",
|
Type: "checkout",
|
||||||
Title: "租客已提交归还",
|
Title: "租客已发起结账",
|
||||||
Content: "请检查账号状态,确认无误后完成订单。",
|
Content: "请检查账号状态和消耗明细,确认无误后完成结算。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -346,7 +362,7 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
|
|||||||
return r.findHandoffRecord(recordID)
|
return r.findHandoffRecord(recordID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
|
func (r *Repository) ConfirmCheckout(userID uint64, orderID uint64) error {
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var order model.RentalOrder
|
var order model.RentalOrder
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
@@ -355,92 +371,120 @@ func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
|
|||||||
if order.OwnerID != userID {
|
if order.OwnerID != userID {
|
||||||
return ErrPermissionDenied
|
return ErrPermissionDenied
|
||||||
}
|
}
|
||||||
if order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" {
|
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
|
||||||
return ErrOrderCannotComplete
|
return ErrCheckoutCannotConfirm
|
||||||
|
}
|
||||||
|
var checkout model.OrderCheckout
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("order_id = ? AND status = ?", order.ID, "submitted").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := tx.Model(&model.HandoffRecord{}).
|
if err := tx.Model(&model.HandoffRecord{}).
|
||||||
Where("order_id = ? AND type = ?", order.ID, "renter_return").
|
Where("order_id = ? AND type = ?", order.ID, "renter_checkout").
|
||||||
Update("confirmed_by_owner_at", now).Error; err != nil {
|
Update("confirmed_by_owner_at", now).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var listing model.RentalListing
|
checkout.Status = "accepted"
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
|
checkout.OwnerAdjustedAt = &now
|
||||||
|
return r.finalizeCheckout(tx, &order, &checkout, "号主已确认租客结账,订单完成。")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CounterCheckout(userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) {
|
||||||
|
var checkoutID uint64
|
||||||
|
err := r.db.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
|
return err
|
||||||
}
|
}
|
||||||
var account model.GameAccount
|
if order.OwnerID != userID {
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
|
return ErrPermissionDenied
|
||||||
|
}
|
||||||
|
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
|
||||||
|
return ErrCheckoutCannotCounter
|
||||||
|
}
|
||||||
|
var checkout model.OrderCheckout
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("order_id = ? AND status = ?", order.ID, "submitted").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&checkout).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
order.Status = "completed"
|
next, err := buildCheckout(order, checkout.InitiatedBy, "countered", checkout.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, req.DepositDeductAmount, true)
|
||||||
order.HandoffStatus = "returned"
|
if err != nil {
|
||||||
order.SettlementStatus = "settled"
|
return err
|
||||||
order.SettledAt = &now
|
}
|
||||||
order.OwnerSettledAt = &now
|
now := time.Now()
|
||||||
|
checkout.Status = "countered"
|
||||||
|
checkout.ConsumableAmount = next.ConsumableAmount
|
||||||
|
checkout.CoinConsumedM = next.CoinConsumedM
|
||||||
|
checkout.OtherAmount = next.OtherAmount
|
||||||
|
checkout.DepositDeductAmount = next.DepositDeductAmount
|
||||||
|
checkout.RenterRefundAmount = next.RenterRefundAmount
|
||||||
|
checkout.OwnerIncomeAmount = next.OwnerIncomeAmount
|
||||||
|
checkout.OwnerAdjustmentReason = req.Reason
|
||||||
|
checkout.OwnerAdjustedAt = &now
|
||||||
|
checkout.EvidenceURLS = next.EvidenceURLS
|
||||||
|
order.Status = "pending_checkout_accept"
|
||||||
|
order.HandoffStatus = "pending_renter_checkout"
|
||||||
|
order.SettlementStatus = "pending"
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if err := wallet.AppendEntries(tx,
|
if err := notification.Append(tx, notification.Entry{
|
||||||
wallet.Entry{
|
UserID: order.RenterID,
|
||||||
UserID: order.RenterID,
|
Type: "checkout",
|
||||||
OrderID: &orderID,
|
Title: "号主已修改结账金额",
|
||||||
Direction: "out",
|
Content: "请核对号主修正的消耗和结算金额。同意后订单完成;不同意可发起争议。",
|
||||||
Amount: order.RentAmount + order.DepositAmount,
|
BizType: "order",
|
||||||
BalanceType: "frozen",
|
BizID: &orderID,
|
||||||
BizType: "order_settle",
|
}); err != nil {
|
||||||
BizNo: order.OrderNo,
|
|
||||||
Remark: "订单完成释放模拟冻结金额",
|
|
||||||
},
|
|
||||||
wallet.Entry{
|
|
||||||
UserID: order.OwnerID,
|
|
||||||
OrderID: &orderID,
|
|
||||||
Direction: "in",
|
|
||||||
Amount: order.RentAmount,
|
|
||||||
BalanceType: "available",
|
|
||||||
BizType: "owner_income",
|
|
||||||
BizNo: order.OrderNo,
|
|
||||||
Remark: "订单完成模拟结算订单金额",
|
|
||||||
},
|
|
||||||
wallet.Entry{
|
|
||||||
UserID: order.RenterID,
|
|
||||||
OrderID: &orderID,
|
|
||||||
Direction: "in",
|
|
||||||
Amount: order.DepositAmount,
|
|
||||||
BalanceType: "available",
|
|
||||||
BizType: "deposit_release",
|
|
||||||
BizNo: order.OrderNo,
|
|
||||||
Remark: "订单完成模拟退回押金",
|
|
||||||
},
|
|
||||||
); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := notification.Append(tx,
|
|
||||||
notification.Entry{
|
|
||||||
UserID: order.RenterID,
|
|
||||||
Type: "settlement",
|
|
||||||
Title: "订单已完成",
|
|
||||||
Content: "号主已确认归还,模拟押金已退回。",
|
|
||||||
BizType: "order",
|
|
||||||
BizID: &orderID,
|
|
||||||
},
|
|
||||||
notification.Entry{
|
|
||||||
UserID: order.OwnerID,
|
|
||||||
Type: "settlement",
|
|
||||||
Title: "订单已完成",
|
|
||||||
Content: "订单已完成,模拟订单金额已入账。",
|
|
||||||
BizType: "order",
|
|
||||||
BizID: &orderID,
|
|
||||||
},
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
listing.Status = "published"
|
|
||||||
account.Status = "published"
|
|
||||||
if err := tx.Save(&order).Error; err != nil {
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Save(&listing).Error; err != nil {
|
if err := tx.Save(&checkout).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Save(&account).Error
|
checkoutID = checkout.ID
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
checkout, err := r.findCheckout(checkoutID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dto := toCheckoutDTO(*checkout)
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AcceptCheckout(userID uint64, orderID uint64) error {
|
||||||
|
return r.db.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.RenterID != userID {
|
||||||
|
return ErrPermissionDenied
|
||||||
|
}
|
||||||
|
if order.Status != "pending_checkout_accept" || order.HandoffStatus != "pending_renter_checkout" {
|
||||||
|
return ErrCheckoutCannotConfirm
|
||||||
|
}
|
||||||
|
var checkout model.OrderCheckout
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("order_id = ? AND status = ?", order.ID, "countered").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
checkout.Status = "accepted"
|
||||||
|
checkout.RenterConfirmedAt = &now
|
||||||
|
return r.finalizeCheckout(tx, &order, &checkout, "租客已确认修正结账,订单完成。")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,6 +526,7 @@ func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dto := row.toDTO()
|
dto := row.toDTO()
|
||||||
|
dto.Checkout = r.latestCheckoutDTO(orderID)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,9 +700,175 @@ func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dto := row.toDTO()
|
dto := row.toDTO()
|
||||||
|
dto.Checkout = r.latestCheckoutDTO(orderID)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, checkout *model.OrderCheckout, renterContent string) error {
|
||||||
|
var listing model.RentalListing
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var account model.GameAccount
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
order.Status = "completed"
|
||||||
|
order.HandoffStatus = "returned"
|
||||||
|
order.SettlementStatus = "settled"
|
||||||
|
order.SettledAt = &now
|
||||||
|
order.OwnerSettledAt = &now
|
||||||
|
listing.Status = "published"
|
||||||
|
account.Status = "published"
|
||||||
|
orderID := order.ID
|
||||||
|
if err := wallet.AppendEntries(tx,
|
||||||
|
wallet.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
OrderID: &orderID,
|
||||||
|
Direction: "out",
|
||||||
|
Amount: order.RentAmount + order.DepositAmount,
|
||||||
|
BalanceType: "frozen",
|
||||||
|
BizType: "order_settle",
|
||||||
|
BizNo: order.OrderNo,
|
||||||
|
Remark: "订单结账释放模拟冻结金额",
|
||||||
|
},
|
||||||
|
wallet.Entry{
|
||||||
|
UserID: order.OwnerID,
|
||||||
|
OrderID: &orderID,
|
||||||
|
Direction: "in",
|
||||||
|
Amount: checkout.OwnerIncomeAmount,
|
||||||
|
BalanceType: "available",
|
||||||
|
BizType: "owner_income",
|
||||||
|
BizNo: order.OrderNo,
|
||||||
|
Remark: "订单结账收入",
|
||||||
|
},
|
||||||
|
wallet.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
OrderID: &orderID,
|
||||||
|
Direction: "in",
|
||||||
|
Amount: checkout.RenterRefundAmount,
|
||||||
|
BalanceType: "available",
|
||||||
|
BizType: "deposit_release",
|
||||||
|
BizNo: order.OrderNo,
|
||||||
|
Remark: "订单结账退回押金",
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := notification.Append(tx,
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "settlement",
|
||||||
|
Title: "订单已完成",
|
||||||
|
Content: renterContent,
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
},
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.OwnerID,
|
||||||
|
Type: "settlement",
|
||||||
|
Title: "订单已完成",
|
||||||
|
Content: "订单已完成,结账金额已入账。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(&listing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Save(&account).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, content string, evidenceURLS []string, consumableAmount float64, coinConsumedM float64, otherAmount float64, explicitDeduct float64, useExplicitDeduct bool) (model.OrderCheckout, error) {
|
||||||
|
if consumableAmount < 0 || coinConsumedM < 0 || otherAmount < 0 || explicitDeduct < 0 {
|
||||||
|
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
|
||||||
|
}
|
||||||
|
deductAmount := consumableAmount + otherAmount
|
||||||
|
if useExplicitDeduct {
|
||||||
|
deductAmount = explicitDeduct
|
||||||
|
}
|
||||||
|
if deductAmount > order.DepositAmount {
|
||||||
|
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
|
||||||
|
}
|
||||||
|
renterRefund := order.DepositAmount - deductAmount
|
||||||
|
evidence, err := marshalStringList(evidenceURLS)
|
||||||
|
if err != nil {
|
||||||
|
return model.OrderCheckout{}, err
|
||||||
|
}
|
||||||
|
return model.OrderCheckout{
|
||||||
|
OrderID: order.ID,
|
||||||
|
InitiatedBy: initiatedBy,
|
||||||
|
Status: status,
|
||||||
|
RentAmount: order.RentAmount,
|
||||||
|
DepositAmount: order.DepositAmount,
|
||||||
|
ConsumableAmount: roundMoney(consumableAmount),
|
||||||
|
CoinConsumedM: roundMoney(coinConsumedM),
|
||||||
|
OtherAmount: roundMoney(otherAmount),
|
||||||
|
DepositDeductAmount: roundMoney(deductAmount),
|
||||||
|
RenterRefundAmount: roundMoney(renterRefund),
|
||||||
|
OwnerIncomeAmount: roundMoney(order.RentAmount + deductAmount),
|
||||||
|
Content: content,
|
||||||
|
EvidenceURLS: evidence,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalStringList(items []string) (datatypes.JSON, error) {
|
||||||
|
if items == nil {
|
||||||
|
items = []string{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(items)
|
||||||
|
return datatypes.JSON(raw), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStringList(raw datatypes.JSON) []string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
var items []string
|
||||||
|
if err := json.Unmarshal(raw, &items); err != nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundMoney(value float64) float64 {
|
||||||
|
return math.Round(value*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasOpenCheckout(tx *gorm.DB, orderID uint64) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := tx.Model(&model.OrderCheckout{}).
|
||||||
|
Where("order_id = ? AND status IN ?", orderID, []string{"submitted", "countered", "accepted", "disputed"}).
|
||||||
|
Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) latestCheckoutDTO(orderID uint64) *CheckoutDTO {
|
||||||
|
var checkout model.OrderCheckout
|
||||||
|
if err := r.db.Where("order_id = ?", orderID).Order("id DESC").First(&checkout).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dto := toCheckoutDTO(checkout)
|
||||||
|
return &dto
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) findCheckout(id uint64) (*model.OrderCheckout, error) {
|
||||||
|
var checkout model.OrderCheckout
|
||||||
|
if err := r.db.First(&checkout, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &checkout, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) {
|
func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) {
|
||||||
var order model.RentalOrder
|
var order model.RentalOrder
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
@@ -751,6 +962,31 @@ func toHandoffDTO(record model.HandoffRecord) HandoffRecordDTO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toCheckoutDTO(checkout model.OrderCheckout) CheckoutDTO {
|
||||||
|
return CheckoutDTO{
|
||||||
|
ID: checkout.ID,
|
||||||
|
OrderID: checkout.OrderID,
|
||||||
|
InitiatedBy: checkout.InitiatedBy,
|
||||||
|
Status: checkout.Status,
|
||||||
|
RentAmount: checkout.RentAmount,
|
||||||
|
DepositAmount: checkout.DepositAmount,
|
||||||
|
ConsumableAmount: checkout.ConsumableAmount,
|
||||||
|
CoinConsumedM: checkout.CoinConsumedM,
|
||||||
|
OtherAmount: checkout.OtherAmount,
|
||||||
|
DepositDeductAmount: checkout.DepositDeductAmount,
|
||||||
|
RenterRefundAmount: checkout.RenterRefundAmount,
|
||||||
|
OwnerIncomeAmount: checkout.OwnerIncomeAmount,
|
||||||
|
Content: checkout.Content,
|
||||||
|
EvidenceURLS: decodeStringList(checkout.EvidenceURLS),
|
||||||
|
OwnerAdjustmentReason: checkout.OwnerAdjustmentReason,
|
||||||
|
OwnerAdjustedAt: checkout.OwnerAdjustedAt,
|
||||||
|
RenterConfirmedAt: checkout.RenterConfirmedAt,
|
||||||
|
RenterRejectedAt: checkout.RenterRejectedAt,
|
||||||
|
CreatedAt: checkout.CreatedAt,
|
||||||
|
UpdatedAt: checkout.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) {
|
func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"account_id": account.ID,
|
"account_id": account.ID,
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ var (
|
|||||||
ErrOrderCannotCancel = errors.New("order cannot cancel")
|
ErrOrderCannotCancel = errors.New("order cannot cancel")
|
||||||
ErrOrderCannotHandoff = errors.New("order cannot handoff")
|
ErrOrderCannotHandoff = errors.New("order cannot handoff")
|
||||||
ErrOrderCannotReceive = errors.New("order cannot receive")
|
ErrOrderCannotReceive = errors.New("order cannot receive")
|
||||||
ErrOrderCannotReturn = errors.New("order cannot return")
|
|
||||||
ErrOrderCannotComplete = errors.New("order cannot complete")
|
ErrOrderCannotComplete = errors.New("order cannot complete")
|
||||||
|
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
|
||||||
|
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
|
||||||
|
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
|
||||||
|
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
|
||||||
ErrPermissionDenied = errors.New("permission denied")
|
ErrPermissionDenied = errors.New("permission denied")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,21 +69,38 @@ func (s *Service) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRecord
|
|||||||
return s.repo.HandoffRecords(userID, orderID)
|
return s.repo.HandoffRecords(userID, orderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) {
|
func (s *Service) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
if orderID == 0 || req.Content == "" {
|
if orderID == 0 || req.Content == "" {
|
||||||
return nil, ErrOrderCannotReturn
|
return nil, ErrCheckoutCannotSubmit
|
||||||
}
|
}
|
||||||
return s.repo.SubmitReturn(userID, orderID, req)
|
return s.repo.SubmitCheckout(userID, orderID, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error {
|
func (s *Service) ConfirmCheckout(userID uint64, orderID uint64) error {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return ErrDependencyUnavailable
|
return ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.ConfirmReturn(userID, orderID)
|
return s.repo.ConfirmCheckout(userID, orderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CounterCheckout(userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if orderID == 0 || req.Reason == "" {
|
||||||
|
return nil, ErrCheckoutCannotCounter
|
||||||
|
}
|
||||||
|
return s.repo.CounterCheckout(userID, orderID, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AcceptCheckout(userID uint64, orderID uint64) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.AcceptCheckout(userID, orderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) {
|
func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) {
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ type defaultConfig struct {
|
|||||||
var defaultConfigs = []defaultConfig{
|
var defaultConfigs = []defaultConfig{
|
||||||
{Key: "handoff.owner_submit_timeout_minutes", Value: "30", Description: "号主待交接超时分钟数"},
|
{Key: "handoff.owner_submit_timeout_minutes", Value: "30", Description: "号主待交接超时分钟数"},
|
||||||
{Key: "handoff.renter_confirm_timeout_minutes", Value: "30", Description: "租客待确认收号超时分钟数"},
|
{Key: "handoff.renter_confirm_timeout_minutes", Value: "30", Description: "租客待确认收号超时分钟数"},
|
||||||
{Key: "handoff.owner_return_confirm_timeout_minutes", Value: "120", Description: "号主待确认归还超时分钟数"},
|
{Key: "handoff.owner_return_confirm_timeout_minutes", Value: "120", Description: "号主待确认结账超时分钟数"},
|
||||||
{Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后归还宽限分钟数"},
|
{Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"},
|
||||||
{Key: "deposit.min_amount", Value: "50", Description: "发布租号最低押金"},
|
{Key: "deposit.min_amount", Value: "50", Description: "发布租号最低押金"},
|
||||||
{Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"},
|
{Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"},
|
||||||
{Key: "risk.sms_limit_per_phone_hour", Value: "5", Description: "单手机号每小时短信验证码次数"},
|
{Key: "risk.sms_limit_per_phone_hour", Value: "5", Description: "单手机号每小时短信验证码次数"},
|
||||||
|
|||||||
@@ -170,8 +170,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
|
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
|
||||||
orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords)
|
orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords)
|
||||||
orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive)
|
orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive)
|
||||||
orderRoutes.POST("/:id/return", orderHandler.SubmitReturn)
|
orderRoutes.POST("/:id/checkout", orderHandler.SubmitCheckout)
|
||||||
orderRoutes.POST("/:id/confirm-return", orderHandler.ConfirmReturn)
|
orderRoutes.POST("/:id/checkout/confirm", orderHandler.ConfirmCheckout)
|
||||||
|
orderRoutes.POST("/:id/checkout/counter", orderHandler.CounterCheckout)
|
||||||
|
orderRoutes.POST("/:id/checkout/accept", orderHandler.AcceptCheckout)
|
||||||
orderRoutes.POST("/:id/dispute", disputeHandler.Create)
|
orderRoutes.POST("/:id/dispute", disputeHandler.Create)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,31 @@ CREATE TABLE handoff_records (
|
|||||||
KEY idx_handoff_records_order_id (order_id)
|
KEY idx_handoff_records_order_id (order_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE order_checkouts (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
initiated_by BIGINT UNSIGNED NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'submitted',
|
||||||
|
rent_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
deposit_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
consumable_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
coin_consumed_m DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
other_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
deposit_deduct_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
renter_refund_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
owner_income_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
content TEXT NULL,
|
||||||
|
evidence_urls JSON NULL,
|
||||||
|
owner_adjustment_reason TEXT NULL,
|
||||||
|
owner_adjusted_at DATETIME NULL,
|
||||||
|
renter_confirmed_at DATETIME NULL,
|
||||||
|
renter_rejected_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_order_checkouts_order_id (order_id),
|
||||||
|
KEY idx_order_checkouts_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE wallet_accounts (
|
CREATE TABLE wallet_accounts (
|
||||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
user_id BIGINT UNSIGNED NOT NULL,
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS order_checkouts (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
initiated_by BIGINT UNSIGNED NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'submitted',
|
||||||
|
rent_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
deposit_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
consumable_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
coin_consumed_m DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
other_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
deposit_deduct_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
renter_refund_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
owner_income_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
content TEXT NULL,
|
||||||
|
evidence_urls JSON NULL,
|
||||||
|
owner_adjustment_reason TEXT NULL,
|
||||||
|
owner_adjusted_at DATETIME NULL,
|
||||||
|
renter_confirmed_at DATETIME NULL,
|
||||||
|
renter_rejected_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_order_checkouts_order_id (order_id),
|
||||||
|
KEY idx_order_checkouts_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
+5
-3
@@ -7,7 +7,7 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
|||||||
- 认证:短信验证码、手机号登录、Token 刷新、退出登录。
|
- 认证:短信验证码、手机号登录、Token 刷新、退出登录。
|
||||||
- 实名:发起认证、查询状态、服务回调。
|
- 实名:发起认证、查询状态、服务回调。
|
||||||
- 发布:创建、修改、提交审核、下架、列表、详情。
|
- 发布:创建、修改、提交审核、下架、列表、详情。
|
||||||
- 订单:创建、取消、确认收号、提交归还、确认归还、发起申诉。
|
- 订单:创建、取消、确认收号、发起结账、确认结账、修改结账、确认/拒绝修正、发起申诉。
|
||||||
- 交接:提交交接说明、查看交接记录。
|
- 交接:提交交接说明、查看交接记录。
|
||||||
- 钱包:余额、流水、提现预留。
|
- 钱包:余额、流水、提现预留。
|
||||||
- 通知:站内信列表、标记已读。
|
- 通知:站内信列表、标记已读。
|
||||||
@@ -37,8 +37,10 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
|||||||
- `POST /api/orders/{id}/handoff`
|
- `POST /api/orders/{id}/handoff`
|
||||||
- `GET /api/orders/{id}/handoff-records`
|
- `GET /api/orders/{id}/handoff-records`
|
||||||
- `POST /api/orders/{id}/confirm-receive`
|
- `POST /api/orders/{id}/confirm-receive`
|
||||||
- `POST /api/orders/{id}/return`
|
- `POST /api/orders/{id}/checkout`
|
||||||
- `POST /api/orders/{id}/confirm-return`
|
- `POST /api/orders/{id}/checkout/confirm`
|
||||||
|
- `POST /api/orders/{id}/checkout/counter`
|
||||||
|
- `POST /api/orders/{id}/checkout/accept`
|
||||||
- `POST /api/orders/{id}/dispute`
|
- `POST /api/orders/{id}/dispute`
|
||||||
- `GET /api/disputes`
|
- `GET /api/disputes`
|
||||||
- `GET /api/disputes/{id}`
|
- `GET /api/disputes/{id}`
|
||||||
|
|||||||
+18
-13
@@ -49,24 +49,27 @@
|
|||||||
- 确认收号时会重新计算租赁开始时间和结束时间。
|
- 确认收号时会重新计算租赁开始时间和结束时间。
|
||||||
- 交接记录保存在 `handoff_records`,订单双方都可以查看。
|
- 交接记录保存在 `handoff_records`,订单双方都可以查看。
|
||||||
|
|
||||||
## 开发态归还与完成
|
## 开发态结账与完成
|
||||||
|
|
||||||
- 租赁中订单可以由租客提交归还。
|
- 租赁中订单可以由租客发起结账,填写使用结束说明、消耗金额、哈夫币消耗量、其他扣款和证据链接。
|
||||||
- 逾期中订单仍允许租客提交归还,但后台和号主可以根据逾期情况发起申诉或客服处理。
|
- 逾期中订单仍允许租客发起结账,但后台和号主可以根据逾期情况发起申诉或客服处理。
|
||||||
- 租客提交归还后,订单状态变为 `pending_return_confirm`,交接状态变为 `pending_owner_return_confirm`。
|
- 租客发起结账后,订单状态变为 `pending_checkout_confirm`,交接状态变为 `pending_owner_checkout`。
|
||||||
- 只有号主可以确认归还。
|
- 只有号主可以确认结账或修改结账。
|
||||||
- 号主确认归还后,订单状态变为 `completed`,交接状态变为 `returned`,结算状态先标记为 `settled`。
|
- 号主直接确认结账后,订单状态变为 `completed`,交接状态变为 `returned`,结算状态标记为 `settled`。
|
||||||
|
- 号主修改结账后,订单状态变为 `pending_checkout_accept`,交接状态变为 `pending_renter_checkout`,必须等待租客确认修正。
|
||||||
|
- 待号主确认结账或待租客确认修正时,任一方都可以发起结账争议。
|
||||||
|
- 结账争议后订单状态变为 `checkout_disputing`,交接状态变为 `checkout_disputed`,由客服仲裁。
|
||||||
- 订单完成后,账号和发布状态恢复为 `published`。
|
- 订单完成后,账号和发布状态恢复为 `published`。
|
||||||
- 当前会生成开发态模拟钱包流水,不代表真实支付或提现。
|
- 当前会生成开发态模拟钱包流水,不代表真实支付或提现。
|
||||||
|
|
||||||
## 开发态超时任务
|
## 开发态超时任务
|
||||||
|
|
||||||
- 后端启动后会运行订单超时扫描任务,默认每 1 分钟执行一次。
|
- 后端启动后会运行订单超时扫描任务,默认每 1 分钟执行一次。
|
||||||
- 任务从 `system_configs` 读取超时阈值,包括号主待交接、租客确认收号、租客归还宽限和号主确认归还。
|
- 任务从 `system_configs` 读取超时阈值,包括号主待交接、租客确认收号、租客结账宽限和号主确认结账。
|
||||||
- 号主待交接超时:订单保持 `pending_handoff`,交接状态变为 `owner_timeout`,租客仍可取消订单或发起申诉。
|
- 号主待交接超时:订单保持 `pending_handoff`,交接状态变为 `owner_timeout`,租客仍可取消订单或发起申诉。
|
||||||
- 租客确认收号超时:订单状态变为 `abnormal`,交接状态变为 `renter_confirm_timeout`,进入客服介入。
|
- 租客确认收号超时:订单状态变为 `abnormal`,交接状态变为 `renter_confirm_timeout`,进入客服介入。
|
||||||
- 租客逾期未归还:订单状态变为 `overdue`,交接状态变为 `return_overdue`,租客仍可提交归还,号主可发起申诉。
|
- 租客逾期未结账:订单状态变为 `overdue`,交接状态变为 `return_overdue`,租客仍可发起结账,号主可发起申诉。
|
||||||
- 号主确认归还超时:订单状态变为 `abnormal`,交接状态变为 `owner_return_confirm_timeout`,进入客服复核。
|
- 号主确认结账超时:订单状态变为 `abnormal`,交接状态变为 `owner_checkout_confirm_timeout`,进入客服复核。
|
||||||
- 每个超时动作只推进一次状态,避免重复通知。
|
- 每个超时动作只推进一次状态,避免重复通知。
|
||||||
- 超时动作会给相关用户写入站内信,并以 `system` 身份写入 `audit_logs`。
|
- 超时动作会给相关用户写入站内信,并以 `system` 身份写入 `audit_logs`。
|
||||||
|
|
||||||
@@ -97,8 +100,10 @@
|
|||||||
- 租客取消订单后通知号主和租客。
|
- 租客取消订单后通知号主和租客。
|
||||||
- 号主提交交接说明后通知租客。
|
- 号主提交交接说明后通知租客。
|
||||||
- 租客确认收号后通知号主。
|
- 租客确认收号后通知号主。
|
||||||
- 租客提交归还后通知号主。
|
- 租客发起结账后通知号主。
|
||||||
- 号主确认归还后通知号主和租客。
|
- 号主确认结账后通知号主和租客。
|
||||||
|
- 号主修改结账后通知租客。
|
||||||
|
- 租客拒绝修正结账后通知双方并进入争议。
|
||||||
- 发起申诉后通知对方和发起人。
|
- 发起申诉后通知对方和发起人。
|
||||||
- 仲裁完成后通知号主和租客。
|
- 仲裁完成后通知号主和租客。
|
||||||
- 站内信支持列表查询和标记已读。
|
- 站内信支持列表查询和标记已读。
|
||||||
@@ -107,7 +112,7 @@
|
|||||||
|
|
||||||
- 订单双方都可以在非终态订单发起申诉。
|
- 订单双方都可以在非终态订单发起申诉。
|
||||||
- 同一个订单同一时间只允许存在一个 `open` 或 `processing` 申诉。
|
- 同一个订单同一时间只允许存在一个 `open` 或 `processing` 申诉。
|
||||||
- 发起申诉后订单状态变为 `disputing`,账号和发布保持锁定。
|
- 发起普通申诉后订单状态变为 `disputing`,发起结账争议后订单状态变为 `checkout_disputing`,账号和发布保持锁定。
|
||||||
- 开发态后台仲裁接口为 `/api/admin/disputes`,当前只要求登录,后续接后台管理员和 RBAC。
|
- 开发态后台仲裁接口为 `/api/admin/disputes`,当前只要求登录,后续接后台管理员和 RBAC。
|
||||||
- 仲裁会释放开发态模拟冻结金额,并根据结果生成钱包流水。
|
- 仲裁会释放开发态模拟冻结金额,并根据结果生成钱包流水。
|
||||||
- 全额退款:释放冻结金额后,将租金加押金作为可用余额退给租客,订单置为 `closed`。
|
- 全额退款:释放冻结金额后,将租金加押金作为可用余额退给租客,订单置为 `closed`。
|
||||||
@@ -139,7 +144,7 @@
|
|||||||
|
|
||||||
- 后台仪表盘接口为 `/api/admin/dashboard`,前端页面为 `/admin/dashboard`。
|
- 后台仪表盘接口为 `/api/admin/dashboard`,前端页面为 `/admin/dashboard`。
|
||||||
- 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。
|
- 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。
|
||||||
- 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待归还确认订单。
|
- 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待结账确认订单。
|
||||||
- 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。
|
- 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。
|
||||||
|
|
||||||
## 开发态用户管理
|
## 开发态用户管理
|
||||||
|
|||||||
+28
-21
@@ -113,7 +113,7 @@
|
|||||||
|
|
||||||
- 租客选择商品和租期后创建订单。
|
- 租客选择商品和租期后创建订单。
|
||||||
- 系统锁定商品库存,避免同一账号被重复出租。
|
- 系统锁定商品库存,避免同一账号被重复出租。
|
||||||
- 订单流转覆盖待确认、待交接、租赁中、待归还确认、已完成、已取消、申诉中。
|
- 订单流转覆盖待确认、待交接、租赁中、待号主确认结账、待租客确认修正、结账争议中、已完成、已取消、申诉中。
|
||||||
|
|
||||||
钱包账务:
|
钱包账务:
|
||||||
|
|
||||||
@@ -142,10 +142,10 @@
|
|||||||
2. 订单确认后,商品进入锁定状态,订单进入待交接。
|
2. 订单确认后,商品进入锁定状态,订单进入待交接。
|
||||||
3. 号主在订单内提交交接说明,例如登录方式、注意事项、联系说明或外部交接备注。
|
3. 号主在订单内提交交接说明,例如登录方式、注意事项、联系说明或外部交接备注。
|
||||||
4. 租客确认收到账号后,订单进入租赁中,并记录租赁开始时间和预计结束时间。
|
4. 租客确认收到账号后,订单进入租赁中,并记录租赁开始时间和预计结束时间。
|
||||||
5. 租期即将结束时,系统通过站内信和短信提醒租客归还。
|
5. 租期即将结束时,系统通过站内信和短信提醒租客完成使用并发起结账。
|
||||||
6. 租客提交归还申请,上传必要的租后截图或说明。
|
6. 租客发起结账,填写使用结束说明、消耗金额、哈夫币消耗量和必要证据。
|
||||||
7. 号主确认账号状态无误后,订单进入结算。
|
7. 号主确认账号状态无误后直接完成结算,或修改结账金额后交由租客确认。
|
||||||
8. 如任一方对交接、使用、归还或资产变化有异议,订单进入申诉仲裁。
|
8. 租客同意修正后订单完成;拒绝修正或任一方对交接、使用、结账、资产变化有异议时,订单进入申诉仲裁。
|
||||||
|
|
||||||
回收边界:
|
回收边界:
|
||||||
|
|
||||||
@@ -158,8 +158,8 @@
|
|||||||
|
|
||||||
- 号主待交接超时:订单进入待交接后,默认 30 分钟未提交交接说明,租客可取消订单或发起申诉。
|
- 号主待交接超时:订单进入待交接后,默认 30 分钟未提交交接说明,租客可取消订单或发起申诉。
|
||||||
- 租客确认收号超时:号主提交交接说明后,默认 30 分钟未确认收号,订单进入客服介入,不自动开始租期。
|
- 租客确认收号超时:号主提交交接说明后,默认 30 分钟未确认收号,订单进入客服介入,不自动开始租期。
|
||||||
- 租客归还超时:超过租期结束时间仍未提交归还,系统提醒后进入逾期中,号主可发起申诉。
|
- 租客结账超时:超过租期结束时间仍未发起结账,系统提醒后进入逾期中,号主可发起申诉。
|
||||||
- 号主确认归还超时:租客提交归还后,默认 2 小时未确认,订单进入自动确认候选或客服复核。
|
- 号主确认结账超时:租客发起结账后,默认 2 小时未确认,订单进入客服复核。
|
||||||
- 所有超时阈值必须进入 `system_configs` 管理,后台可调整。
|
- 所有超时阈值必须进入 `system_configs` 管理,后台可调整。
|
||||||
|
|
||||||
订单状态机:
|
订单状态机:
|
||||||
@@ -173,12 +173,16 @@
|
|||||||
| 待交接 | 申诉中 | 租客/号主 | 交接争议或安全验证无法完成 |
|
| 待交接 | 申诉中 | 租客/号主 | 交接争议或安全验证无法完成 |
|
||||||
| 待收号确认 | 租赁中 | 租客 | 租客确认收到账号并可正常登录 |
|
| 待收号确认 | 租赁中 | 租客 | 租客确认收到账号并可正常登录 |
|
||||||
| 待收号确认 | 申诉中 | 租客/系统 | 租客确认超时、无法登录或描述不符 |
|
| 待收号确认 | 申诉中 | 租客/系统 | 租客确认超时、无法登录或描述不符 |
|
||||||
| 租赁中 | 逾期中 | 系统 | 超过租期结束时间仍未提交归还 |
|
| 租赁中 | 逾期中 | 系统 | 超过租期结束时间仍未发起结账 |
|
||||||
| 租赁中 | 待归还确认 | 租客 | 租客提交归还和租后凭证 |
|
| 租赁中 | 待号主确认结账 | 租客 | 租客发起结账和租后凭证 |
|
||||||
| 逾期中 | 待归还确认 | 租客 | 租客补交归还申请 |
|
| 逾期中 | 待号主确认结账 | 租客 | 租客补交结账申请 |
|
||||||
| 逾期中 | 申诉中 | 号主/系统 | 逾期未归还或疑似资产损失 |
|
| 逾期中 | 申诉中 | 号主/系统 | 逾期未归还或疑似资产损失 |
|
||||||
| 待归还确认 | 已完成 | 号主/系统 | 号主确认归还,或超时后客服/系统确认 |
|
| 待号主确认结账 | 已完成 | 号主 | 号主确认结账 |
|
||||||
| 待归还确认 | 申诉中 | 号主/租客 | 归还争议、资产损失或封号异常 |
|
| 待号主确认结账 | 待租客确认修正 | 号主 | 号主修改结账金额 |
|
||||||
|
| 待租客确认修正 | 已完成 | 租客 | 租客同意修正结账 |
|
||||||
|
| 待号主确认结账 | 结账争议中 | 任一方 | 发起结账争议 |
|
||||||
|
| 待租客确认修正 | 结账争议中 | 任一方 | 发起结账争议 |
|
||||||
|
| 结账争议中 | 已完成/已关闭/异常 | 客服 | 后台仲裁 |
|
||||||
| 申诉中 | 已完成 | 客服 | 仲裁为正常完成或部分扣款后完成 |
|
| 申诉中 | 已完成 | 客服 | 仲裁为正常完成或部分扣款后完成 |
|
||||||
| 申诉中 | 已取消 | 客服 | 仲裁为订单取消和退款 |
|
| 申诉中 | 已取消 | 客服 | 仲裁为订单取消和退款 |
|
||||||
| 申诉中 | 已关闭 | 客服 | 仲裁为异常关闭、冻结用户或冻结商品 |
|
| 申诉中 | 已关闭 | 客服 | 仲裁为异常关闭、冻结用户或冻结商品 |
|
||||||
@@ -216,7 +220,7 @@
|
|||||||
结算规则:
|
结算规则:
|
||||||
|
|
||||||
- 正常完成订单后,租金按平台抽成规则分账给号主。
|
- 正常完成订单后,租金按平台抽成规则分账给号主。
|
||||||
- 押金在号主确认归还后释放给租客。
|
- 押金在双方结账确认或客服仲裁后释放给租客。
|
||||||
- 如发生资产损失、无法登录、封号或超时归还,可由客服仲裁后扣除部分或全部押金。
|
- 如发生资产损失、无法登录、封号或超时归还,可由客服仲裁后扣除部分或全部押金。
|
||||||
- 如号主虚假描述、超时未交接或账号无法使用,可裁定全额或部分退款。
|
- 如号主虚假描述、超时未交接或账号无法使用,可裁定全额或部分退款。
|
||||||
- 仲裁导致的扣款、退款、赔付必须生成资金流水和审计日志。
|
- 仲裁导致的扣款、退款、赔付必须生成资金流水和审计日志。
|
||||||
@@ -281,8 +285,9 @@
|
|||||||
- 号主提交交接说明。
|
- 号主提交交接说明。
|
||||||
- 租客确认收号。
|
- 租客确认收号。
|
||||||
- 租期即将结束。
|
- 租期即将结束。
|
||||||
- 租客提交归还。
|
- 租客发起结账。
|
||||||
- 号主确认归还。
|
- 号主确认或修改结账。
|
||||||
|
- 租客确认或拒绝修正结账。
|
||||||
- 订单完成结算。
|
- 订单完成结算。
|
||||||
- 发起申诉。
|
- 发起申诉。
|
||||||
- 仲裁结果。
|
- 仲裁结果。
|
||||||
@@ -592,8 +597,10 @@
|
|||||||
- `GET /api/orders/{id}`:订单详情。
|
- `GET /api/orders/{id}`:订单详情。
|
||||||
- `POST /api/orders/{id}/cancel`:取消订单。
|
- `POST /api/orders/{id}/cancel`:取消订单。
|
||||||
- `POST /api/orders/{id}/confirm-receive`:租客确认收号。
|
- `POST /api/orders/{id}/confirm-receive`:租客确认收号。
|
||||||
- `POST /api/orders/{id}/return`:租客提交归还。
|
- `POST /api/orders/{id}/checkout`:租客发起结账。
|
||||||
- `POST /api/orders/{id}/confirm-return`:号主确认归还。
|
- `POST /api/orders/{id}/checkout/confirm`:号主确认结账。
|
||||||
|
- `POST /api/orders/{id}/checkout/counter`:号主修改结账。
|
||||||
|
- `POST /api/orders/{id}/checkout/accept`:租客同意修正结账。
|
||||||
- `POST /api/orders/{id}/dispute`:发起申诉。
|
- `POST /api/orders/{id}/dispute`:发起申诉。
|
||||||
|
|
||||||
交接:
|
交接:
|
||||||
@@ -835,8 +842,8 @@ hfb_sys/
|
|||||||
- 实现订单账号资产快照。
|
- 实现订单账号资产快照。
|
||||||
- 实现号主提交交接说明。
|
- 实现号主提交交接说明。
|
||||||
- 实现租客确认收号。
|
- 实现租客确认收号。
|
||||||
- 实现租客提交归还、号主确认归还。
|
- 实现租客发起结账、号主确认或修改结账、租客确认或拒绝修正结账。
|
||||||
- 实现交接、确认收号、归还、确认归还的超时处理。
|
- 实现交接、确认收号、结账逾期、确认结账超时处理。
|
||||||
- 实现钱包账户、押金冻结、租金流水、结算流水。
|
- 实现钱包账户、押金冻结、租金流水、结算流水。
|
||||||
- 预留支付和提现接口。
|
- 预留支付和提现接口。
|
||||||
|
|
||||||
@@ -887,9 +894,9 @@ hfb_sys/
|
|||||||
- 商品草稿、待审核、已上架、已下架、审核拒绝状态流转正确。
|
- 商品草稿、待审核、已上架、已下架、审核拒绝状态流转正确。
|
||||||
- 审核未通过商品不能下单。
|
- 审核未通过商品不能下单。
|
||||||
- 同一账号并发下单只能成功一单。
|
- 同一账号并发下单只能成功一单。
|
||||||
- 订单待交接、租赁中、待归还确认、已完成、申诉中状态流转正确。
|
- 订单待交接、租赁中、待号主确认结账、待租客确认修正、结账争议中、已完成、申诉中状态流转正确。
|
||||||
- 非法订单状态转换会被拒绝。
|
- 非法订单状态转换会被拒绝。
|
||||||
- 交接、收号、归还、确认归还超时会按配置触发取消、申诉、逾期或客服复核。
|
- 交接、收号、结账逾期、确认结账超时会按配置触发取消、申诉、逾期或客服复核。
|
||||||
- 押金冻结、释放、扣款、退款流水一致。
|
- 押金冻结、释放、扣款、退款流水一致。
|
||||||
- 每笔 `wallet_ledger` 正确记录 `balance_after`。
|
- 每笔 `wallet_ledger` 正确记录 `balance_after`。
|
||||||
- 租金、平台抽成、号主结算流水一致。
|
- 租金、平台抽成、号主结算流水一致。
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ export interface Order {
|
|||||||
status: string
|
status: string
|
||||||
handoff_status: string
|
handoff_status: string
|
||||||
settlement_status: string
|
settlement_status: string
|
||||||
|
checkout?: Checkout
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Checkout {
|
||||||
|
id: number
|
||||||
|
order_id: number
|
||||||
|
initiated_by: number
|
||||||
|
status: string
|
||||||
|
rent_amount: number
|
||||||
|
deposit_amount: number
|
||||||
|
consumable_amount: number
|
||||||
|
coin_consumed_m: number
|
||||||
|
other_amount: number
|
||||||
|
deposit_deduct_amount: number
|
||||||
|
renter_refund_amount: number
|
||||||
|
owner_income_amount: number
|
||||||
|
content: string
|
||||||
|
evidence_urls: string[]
|
||||||
|
owner_adjustment_reason: string
|
||||||
|
owner_adjusted_at?: string
|
||||||
|
renter_confirmed_at?: string
|
||||||
|
renter_rejected_at?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -37,6 +61,23 @@ export interface HandoffRecord {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubmitCheckoutPayload {
|
||||||
|
content: string
|
||||||
|
consumable_amount: number
|
||||||
|
coin_consumed_m: number
|
||||||
|
other_amount: number
|
||||||
|
evidence_urls: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CounterCheckoutPayload {
|
||||||
|
consumable_amount: number
|
||||||
|
coin_consumed_m: number
|
||||||
|
other_amount: number
|
||||||
|
deposit_deduct_amount: number
|
||||||
|
reason: string
|
||||||
|
evidence_urls: string[]
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
code: string
|
code: string
|
||||||
message: string
|
message: string
|
||||||
@@ -105,12 +146,22 @@ export async function confirmReceive(id: number) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitReturn(id: number, content: string) {
|
export async function submitCheckout(id: number, payload: SubmitCheckoutPayload) {
|
||||||
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/return`, { content })
|
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/checkout`, payload)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmReturn(id: number) {
|
export async function confirmCheckout(id: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/confirm-return`)
|
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/checkout/confirm`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function counterCheckout(id: number, payload: CounterCheckoutPayload) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Checkout>>(`/orders/${id}/checkout/counter`, payload)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptCheckout(id: number) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/checkout/accept`)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -619,6 +619,7 @@ h1 {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
column-gap: 16px;
|
column-gap: 16px;
|
||||||
|
row-gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-panel {
|
.order-panel {
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ const orderStatusMap: Record<string, string> = {
|
|||||||
pending_handoff: '待交接',
|
pending_handoff: '待交接',
|
||||||
renting: '使用中',
|
renting: '使用中',
|
||||||
overdue: '已逾期',
|
overdue: '已逾期',
|
||||||
pending_return_confirm: '待归还确认',
|
pending_return_confirm: '待结账确认',
|
||||||
|
pending_checkout_confirm: '待号主确认结账',
|
||||||
|
pending_checkout_accept: '待租客确认修正',
|
||||||
|
checkout_disputing: '结账争议中',
|
||||||
completed: '已完成',
|
completed: '已完成',
|
||||||
cancelled: '已取消',
|
cancelled: '已取消',
|
||||||
closed: '已关闭',
|
closed: '已关闭',
|
||||||
@@ -30,13 +33,17 @@ const handoffStatusMap: Record<string, string> = {
|
|||||||
pending_owner: '待号主交接',
|
pending_owner: '待号主交接',
|
||||||
pending_renter_confirm: '待租客确认',
|
pending_renter_confirm: '待租客确认',
|
||||||
received: '已确认收号',
|
received: '已确认收号',
|
||||||
pending_owner_return_confirm: '待号主确认归还',
|
pending_owner_return_confirm: '待号主确认结账',
|
||||||
|
pending_owner_checkout: '待号主确认结账',
|
||||||
|
pending_renter_checkout: '待租客确认修正',
|
||||||
|
checkout_disputed: '结账争议中',
|
||||||
returned: '已归还',
|
returned: '已归还',
|
||||||
cancelled: '已取消',
|
cancelled: '已取消',
|
||||||
owner_timeout: '号主交接超时',
|
owner_timeout: '号主交接超时',
|
||||||
renter_confirm_timeout: '租客确认超时',
|
renter_confirm_timeout: '租客确认超时',
|
||||||
return_overdue: '归还逾期',
|
return_overdue: '归还逾期',
|
||||||
owner_return_confirm_timeout: '号主确认归还超时',
|
owner_return_confirm_timeout: '号主确认结账超时',
|
||||||
|
owner_checkout_confirm_timeout: '号主确认结账超时',
|
||||||
admin_closed: '客服关闭',
|
admin_closed: '客服关闭',
|
||||||
admin_abnormal: '客服标记异常',
|
admin_abnormal: '客服标记异常',
|
||||||
arbitrated: '已仲裁',
|
arbitrated: '已仲裁',
|
||||||
@@ -50,6 +57,7 @@ const settlementStatusMap: Record<string, string> = {
|
|||||||
refunded: '已退款',
|
refunded: '已退款',
|
||||||
cancelled: '已取消',
|
cancelled: '已取消',
|
||||||
closed: '已关闭',
|
closed: '已关闭',
|
||||||
|
disputed: '争议中',
|
||||||
arbitrated: '已仲裁',
|
arbitrated: '已仲裁',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { createDispute } from '@/api/disputes'
|
import { createDispute } from '@/api/disputes'
|
||||||
import { uploadFile } from '@/api/files'
|
import { uploadFile } from '@/api/files'
|
||||||
import {
|
import {
|
||||||
|
acceptCheckout,
|
||||||
cancelOrder,
|
cancelOrder,
|
||||||
|
confirmCheckout,
|
||||||
confirmReceive,
|
confirmReceive,
|
||||||
confirmReturn,
|
counterCheckout,
|
||||||
fetchHandoffRecords,
|
fetchHandoffRecords,
|
||||||
fetchOrder,
|
fetchOrder,
|
||||||
|
submitCheckout,
|
||||||
submitHandoff,
|
submitHandoff,
|
||||||
submitReturn,
|
|
||||||
type HandoffRecord,
|
type HandoffRecord,
|
||||||
type Order,
|
type Order,
|
||||||
} from '@/api/orders'
|
} from '@/api/orders'
|
||||||
@@ -29,12 +31,30 @@ const handoffing = ref(false)
|
|||||||
const confirming = ref(false)
|
const confirming = ref(false)
|
||||||
const returning = ref(false)
|
const returning = ref(false)
|
||||||
const completing = ref(false)
|
const completing = ref(false)
|
||||||
|
const countering = ref(false)
|
||||||
|
const acceptingCheckout = ref(false)
|
||||||
|
const rejectingCheckout = ref(false)
|
||||||
const disputing = ref(false)
|
const disputing = ref(false)
|
||||||
const uploadingEvidence = ref(false)
|
const uploadingEvidence = ref(false)
|
||||||
const order = ref<Order | null>(null)
|
const order = ref<Order | null>(null)
|
||||||
const handoffRecords = ref<HandoffRecord[]>([])
|
const handoffRecords = ref<HandoffRecord[]>([])
|
||||||
const handoffContent = ref('')
|
const handoffContent = ref('')
|
||||||
const returnContent = ref('')
|
const checkoutForm = ref({
|
||||||
|
content: '',
|
||||||
|
consumable_amount: 0,
|
||||||
|
coin_consumed_m: 0,
|
||||||
|
other_amount: 0,
|
||||||
|
evidenceText: '',
|
||||||
|
})
|
||||||
|
const counterForm = ref({
|
||||||
|
consumable_amount: 0,
|
||||||
|
coin_consumed_m: 0,
|
||||||
|
other_amount: 0,
|
||||||
|
deposit_deduct_amount: 0,
|
||||||
|
reason: '',
|
||||||
|
evidenceText: '',
|
||||||
|
})
|
||||||
|
const rejectReason = ref('')
|
||||||
const disputeType = ref('cannot_login')
|
const disputeType = ref('cannot_login')
|
||||||
const disputeDescription = ref('')
|
const disputeDescription = ref('')
|
||||||
const disputeEvidenceText = ref('')
|
const disputeEvidenceText = ref('')
|
||||||
@@ -43,7 +63,10 @@ const isOwner = computed(() => order.value?.owner_id === session.userId)
|
|||||||
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
||||||
const canOpenDispute = computed(() => {
|
const canOpenDispute = computed(() => {
|
||||||
if (!order.value || (!isOwner.value && !isRenter.value)) return false
|
if (!order.value || (!isOwner.value && !isRenter.value)) return false
|
||||||
return !['completed', 'cancelled', 'closed', 'disputing'].includes(order.value.status)
|
return !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing', 'abnormal'].includes(order.value.status)
|
||||||
|
})
|
||||||
|
const isCheckoutDisputeStage = computed(() => {
|
||||||
|
return !!order.value && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(loadOrder)
|
onMounted(loadOrder)
|
||||||
@@ -53,6 +76,7 @@ async function loadOrder() {
|
|||||||
try {
|
try {
|
||||||
order.value = await fetchOrder(String(route.params.id))
|
order.value = await fetchOrder(String(route.params.id))
|
||||||
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
|
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
|
||||||
|
hydrateCounterForm()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -101,50 +125,114 @@ async function handleConfirmReceive() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmitReturn() {
|
async function handleSubmitCheckout() {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
returning.value = true
|
returning.value = true
|
||||||
try {
|
try {
|
||||||
await submitReturn(order.value.id, returnContent.value)
|
await submitCheckout(order.value.id, {
|
||||||
returnContent.value = ''
|
content: checkoutForm.value.content,
|
||||||
ElMessage.success('归还申请已提交,等待号主确认')
|
consumable_amount: checkoutForm.value.consumable_amount,
|
||||||
|
coin_consumed_m: checkoutForm.value.coin_consumed_m,
|
||||||
|
other_amount: checkoutForm.value.other_amount,
|
||||||
|
evidence_urls: linesToList(checkoutForm.value.evidenceText),
|
||||||
|
})
|
||||||
|
checkoutForm.value = {
|
||||||
|
content: '',
|
||||||
|
consumable_amount: 0,
|
||||||
|
coin_consumed_m: 0,
|
||||||
|
other_amount: 0,
|
||||||
|
evidenceText: '',
|
||||||
|
}
|
||||||
|
ElMessage.success('结账已发起,等待号主确认')
|
||||||
await loadOrder()
|
await loadOrder()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(readError(error, '提交归还失败'))
|
ElMessage.error(readError(error, '发起结账失败'))
|
||||||
} finally {
|
} finally {
|
||||||
returning.value = false
|
returning.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirmReturn() {
|
async function handleConfirmCheckout() {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
completing.value = true
|
completing.value = true
|
||||||
try {
|
try {
|
||||||
await confirmReturn(order.value.id)
|
await confirmCheckout(order.value.id)
|
||||||
ElMessage.success('归还已确认,订单完成')
|
ElMessage.success('结账已确认,订单完成')
|
||||||
await loadOrder()
|
await loadOrder()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(readError(error, '确认归还失败'))
|
ElMessage.error(readError(error, '确认结账失败'))
|
||||||
} finally {
|
} finally {
|
||||||
completing.value = false
|
completing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCounterCheckout() {
|
||||||
|
if (!order.value) return
|
||||||
|
countering.value = true
|
||||||
|
try {
|
||||||
|
await counterCheckout(order.value.id, {
|
||||||
|
consumable_amount: counterForm.value.consumable_amount,
|
||||||
|
coin_consumed_m: counterForm.value.coin_consumed_m,
|
||||||
|
other_amount: counterForm.value.other_amount,
|
||||||
|
deposit_deduct_amount: counterForm.value.deposit_deduct_amount,
|
||||||
|
reason: counterForm.value.reason,
|
||||||
|
evidence_urls: linesToList(counterForm.value.evidenceText),
|
||||||
|
})
|
||||||
|
ElMessage.success('结账修正已提交,等待租客确认')
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '修改结账失败'))
|
||||||
|
} finally {
|
||||||
|
countering.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAcceptCheckout() {
|
||||||
|
if (!order.value) return
|
||||||
|
acceptingCheckout.value = true
|
||||||
|
try {
|
||||||
|
await acceptCheckout(order.value.id)
|
||||||
|
ElMessage.success('已确认修正结账,订单完成')
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '确认修正失败'))
|
||||||
|
} finally {
|
||||||
|
acceptingCheckout.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRejectCheckout() {
|
||||||
|
if (!order.value) return
|
||||||
|
rejectingCheckout.value = true
|
||||||
|
try {
|
||||||
|
await createDispute(order.value.id, {
|
||||||
|
type: 'checkout_dispute',
|
||||||
|
description: rejectReason.value,
|
||||||
|
evidence_urls: [],
|
||||||
|
})
|
||||||
|
rejectReason.value = ''
|
||||||
|
ElMessage.success('已拒绝修正结账,订单进入争议处理')
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '拒绝修正失败'))
|
||||||
|
} finally {
|
||||||
|
rejectingCheckout.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateDispute() {
|
async function handleCreateDispute() {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
disputing.value = true
|
disputing.value = true
|
||||||
try {
|
try {
|
||||||
const evidence_urls = disputeEvidenceText.value
|
const evidence_urls = linesToList(disputeEvidenceText.value)
|
||||||
.split('\n')
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
await createDispute(order.value.id, {
|
await createDispute(order.value.id, {
|
||||||
type: disputeType.value,
|
type: isCheckoutDisputeStage.value ? 'checkout_dispute' : disputeType.value,
|
||||||
description: disputeDescription.value,
|
description: disputeDescription.value,
|
||||||
evidence_urls,
|
evidence_urls,
|
||||||
})
|
})
|
||||||
disputeDescription.value = ''
|
disputeDescription.value = ''
|
||||||
disputeEvidenceText.value = ''
|
disputeEvidenceText.value = ''
|
||||||
|
disputeType.value = 'cannot_login'
|
||||||
ElMessage.success('申诉已提交,订单进入仲裁处理')
|
ElMessage.success('申诉已提交,订单进入仲裁处理')
|
||||||
await loadOrder()
|
await loadOrder()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -171,6 +259,23 @@ async function handleEvidenceUpload(event: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hydrateCounterForm() {
|
||||||
|
if (!order.value?.checkout) return
|
||||||
|
const checkout = order.value.checkout
|
||||||
|
counterForm.value.consumable_amount = checkout.consumable_amount
|
||||||
|
counterForm.value.coin_consumed_m = checkout.coin_consumed_m
|
||||||
|
counterForm.value.other_amount = checkout.other_amount
|
||||||
|
counterForm.value.deposit_deduct_amount = checkout.deposit_deduct_amount
|
||||||
|
counterForm.value.evidenceText = (checkout.evidence_urls || []).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function linesToList(value: string) {
|
||||||
|
return value
|
||||||
|
.split('\n')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
function readError(error: unknown, fallback: string) {
|
function readError(error: unknown, fallback: string) {
|
||||||
if (typeof error === 'object' && error && 'response' in error) {
|
if (typeof error === 'object' && error && 'response' in error) {
|
||||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||||
@@ -240,21 +345,144 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<el-button type="primary" :loading="confirming" @click="handleConfirmReceive">确认已收到账号</el-button>
|
<el-button type="primary" :loading="confirming" @click="handleConfirmReceive">确认已收到账号</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="order-panel">
|
<div v-if="order?.checkout" class="order-panel">
|
||||||
<h2>提交归还</h2>
|
<h2>结账明细</h2>
|
||||||
<el-input v-model="returnContent" type="textarea" :rows="4" placeholder="填写归还说明、租后资产状态或注意事项" />
|
<div class="detail-grid">
|
||||||
<el-button class="panel-action" type="primary" :loading="returning" @click="handleSubmitReturn">提交归还</el-button>
|
<div class="metric-card">
|
||||||
|
<span>押金扣除</span>
|
||||||
|
<strong>¥{{ order.checkout.deposit_deduct_amount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>租客退回</span>
|
||||||
|
<strong>¥{{ order.checkout.renter_refund_amount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>号主入账</span>
|
||||||
|
<strong>¥{{ order.checkout.owner_income_amount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>消耗</span>
|
||||||
|
<strong>{{ order.checkout.coin_consumed_m }}M</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="order.checkout.content">租客说明:{{ order.checkout.content }}</p>
|
||||||
|
<p v-if="order.checkout.owner_adjustment_reason">号主修正:{{ order.checkout.owner_adjustment_reason }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order && isOwner && order.status === 'pending_return_confirm'" class="order-panel">
|
<div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="order-panel">
|
||||||
<h2>确认归还</h2>
|
<h2>发起结账</h2>
|
||||||
<p>确认账号状态无误后,订单会完成,账号重新上架。</p>
|
<el-input v-model="checkoutForm.content" type="textarea" :rows="4" placeholder="填写使用结束说明、账号状态和需要号主核对的内容" />
|
||||||
<el-button type="primary" :loading="completing" @click="handleConfirmReturn">确认归还并完成订单</el-button>
|
<div class="form-grid panel-action">
|
||||||
|
<el-input-number
|
||||||
|
v-model="checkoutForm.consumable_amount"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="消耗扣款"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="checkoutForm.coin_consumed_m"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="消耗哈夫币 M"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="checkoutForm.other_amount"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="其他扣款"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="checkoutForm.evidenceText"
|
||||||
|
class="panel-action"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="结账证据链接,一行一个。可填写截图地址或备注链接"
|
||||||
|
/>
|
||||||
|
<el-button class="panel-action" type="primary" :loading="returning" @click="handleSubmitCheckout">发起结账</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="order && isOwner && order.status === 'pending_checkout_confirm'" class="order-panel">
|
||||||
|
<h2>确认结账</h2>
|
||||||
|
<p>确认账号状态和扣款金额无误后,订单会完成,账号重新上架。</p>
|
||||||
|
<el-button type="primary" :loading="completing" @click="handleConfirmCheckout">确认结账并完成订单</el-button>
|
||||||
|
|
||||||
|
<h2 class="panel-action">修改结账</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-input-number
|
||||||
|
v-model="counterForm.consumable_amount"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="消耗扣款"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="counterForm.coin_consumed_m"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="消耗哈夫币 M"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="counterForm.other_amount"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="其他扣款"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="counterForm.deposit_deduct_amount"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:max="order.deposit_amount"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="押金扣除"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="counterForm.reason"
|
||||||
|
class="panel-action"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="填写修改原因,例如额外资产损耗、哈夫币消耗差异或截图核对结果"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="counterForm.evidenceText"
|
||||||
|
class="panel-action"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="修正证据链接,一行一个"
|
||||||
|
/>
|
||||||
|
<el-button class="panel-action" type="warning" :loading="countering" @click="handleCounterCheckout">提交修正给租客确认</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="order && isRenter && order.status === 'pending_checkout_accept'" class="order-panel">
|
||||||
|
<h2>确认修正结账</h2>
|
||||||
|
<p>同意后订单会完成结算;不同意会进入结账争议,由客服仲裁。</p>
|
||||||
|
<el-button type="primary" :loading="acceptingCheckout" @click="handleAcceptCheckout">同意修正并完成订单</el-button>
|
||||||
|
<el-input
|
||||||
|
v-model="rejectReason"
|
||||||
|
class="panel-action"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="不同意时填写原因,会进入争议处理"
|
||||||
|
/>
|
||||||
|
<el-button class="panel-action" type="danger" :loading="rejectingCheckout" @click="handleRejectCheckout">拒绝修正并发起争议</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order && canOpenDispute" class="order-panel">
|
<div v-if="order && canOpenDispute" class="order-panel">
|
||||||
<h2>发起申诉</h2>
|
<h2>{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h2>
|
||||||
<el-select v-model="disputeType" class="full-control" placeholder="选择申诉类型">
|
<el-select v-if="!isCheckoutDisputeStage" v-model="disputeType" class="full-control" placeholder="选择申诉类型">
|
||||||
<el-option label="无法登录" value="cannot_login" />
|
<el-option label="无法登录" value="cannot_login" />
|
||||||
<el-option label="虚假描述" value="false_description" />
|
<el-option label="虚假描述" value="false_description" />
|
||||||
<el-option label="账号被封" value="account_banned" />
|
<el-option label="账号被封" value="account_banned" />
|
||||||
@@ -280,7 +508,9 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<div class="panel-action upload-line">
|
<div class="panel-action upload-line">
|
||||||
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" :disabled="uploadingEvidence" @change="handleEvidenceUpload" />
|
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" :disabled="uploadingEvidence" @change="handleEvidenceUpload" />
|
||||||
</div>
|
</div>
|
||||||
<el-button class="panel-action" type="warning" :loading="disputing" @click="handleCreateDispute">提交申诉</el-button>
|
<el-button class="panel-action" type="warning" :loading="disputing" @click="handleCreateDispute">
|
||||||
|
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async function loadOrders() {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Orders</p>
|
<p class="eyebrow">Orders</p>
|
||||||
<h1>我的订单</h1>
|
<h1>我的订单</h1>
|
||||||
<p>跟踪待交接、使用中、待归还、申诉中和已完成订单。</p>
|
<p>跟踪待交接、使用中、待结账、申诉中和已完成订单。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="orders">
|
<el-table v-loading="loading" class="table-panel" :data="orders">
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ function actionType(action: string) {
|
|||||||
<el-option label="订单标记异常" value="order.mark_abnormal" />
|
<el-option label="订单标记异常" value="order.mark_abnormal" />
|
||||||
<el-option label="号主交接超时" value="order.timeout.owner_submit" />
|
<el-option label="号主交接超时" value="order.timeout.owner_submit" />
|
||||||
<el-option label="租客确认超时" value="order.timeout.renter_confirm" />
|
<el-option label="租客确认超时" value="order.timeout.renter_confirm" />
|
||||||
<el-option label="租客归还逾期" value="order.timeout.return_overdue" />
|
<el-option label="租客结账逾期" value="order.timeout.return_overdue" />
|
||||||
<el-option label="号主确认归还超时" value="order.timeout.owner_return_confirm" />
|
<el-option label="号主确认结账超时" value="order.timeout.owner_checkout_confirm" />
|
||||||
<el-option label="申诉仲裁" value="dispute.arbitrate" />
|
<el-option label="申诉仲裁" value="dispute.arbitrate" />
|
||||||
<el-option label="更新系统配置" value="system_config.update" />
|
<el-option label="更新系统配置" value="system_config.update" />
|
||||||
<el-option label="创建系统配置" value="system_config.create" />
|
<el-option label="创建系统配置" value="system_config.create" />
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function money(value?: number) {
|
|||||||
<strong>{{ dashboard.pending.pending_handoffs }}</strong>
|
<strong>{{ dashboard.pending.pending_handoffs }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="pending-row">
|
<div class="pending-row">
|
||||||
<span>待归还确认</span>
|
<span>待结账确认</span>
|
||||||
<strong>{{ dashboard.pending.pending_return_confirms }}</strong>
|
<strong>{{ dashboard.pending.pending_return_confirms }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Arbitration</p>
|
<p class="eyebrow">Arbitration</p>
|
||||||
<h1>仲裁中心</h1>
|
<h1>仲裁中心</h1>
|
||||||
<p>处理无法登录、资产损失、哈夫币争议和超时归还。</p>
|
<p>处理无法登录、资产损失、哈夫币争议和结账争议。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="disputes">
|
<el-table v-loading="loading" class="table-panel" :data="disputes">
|
||||||
@@ -134,6 +134,7 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<el-option label="释放押金" value="release_deposit" />
|
<el-option label="释放押金" value="release_deposit" />
|
||||||
<el-option label="赔付号主" value="compensate_owner" />
|
<el-option label="赔付号主" value="compensate_owner" />
|
||||||
<el-option label="关闭订单" value="order_close" />
|
<el-option label="关闭订单" value="order_close" />
|
||||||
|
<el-option label="标记异常" value="mark_abnormal" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
|
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
|
||||||
|
|||||||
@@ -121,6 +121,20 @@ function readError(error: unknown, fallback: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="order?.checkout" class="order-panel dashboard-panel">
|
||||||
|
<h2>结账信息</h2>
|
||||||
|
<div class="detail-grid checkout-grid">
|
||||||
|
<p>结账状态:{{ order.checkout.status }}</p>
|
||||||
|
<p>租金收入:¥{{ order.checkout.rent_amount }}</p>
|
||||||
|
<p>押金:¥{{ order.checkout.deposit_amount }}</p>
|
||||||
|
<p>消耗扣款:¥{{ order.checkout.deposit_deduct_amount }}</p>
|
||||||
|
<p>租客退回:¥{{ order.checkout.renter_refund_amount }}</p>
|
||||||
|
<p>号主入账:¥{{ order.checkout.owner_income_amount }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="order.checkout.content">租客说明:{{ order.checkout.content }}</p>
|
||||||
|
<p v-if="order.checkout.owner_adjustment_reason">号主修正:{{ order.checkout.owner_adjustment_reason }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="order" class="order-panel dashboard-panel code-panel">
|
<div v-if="order" class="order-panel dashboard-panel code-panel">
|
||||||
<h2>账号快照</h2>
|
<h2>账号快照</h2>
|
||||||
<pre>{{ snapshotText }}</pre>
|
<pre>{{ snapshotText }}</pre>
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ async function loadOrders() {
|
|||||||
<el-option label="待交接" value="pending_handoff" />
|
<el-option label="待交接" value="pending_handoff" />
|
||||||
<el-option label="使用中" value="renting" />
|
<el-option label="使用中" value="renting" />
|
||||||
<el-option label="逾期中" value="overdue" />
|
<el-option label="逾期中" value="overdue" />
|
||||||
<el-option label="待归还确认" value="pending_return_confirm" />
|
<el-option label="待结账确认" value="pending_return_confirm" />
|
||||||
|
<el-option label="待号主确认结账" value="pending_checkout_confirm" />
|
||||||
|
<el-option label="待租客确认修正" value="pending_checkout_accept" />
|
||||||
|
<el-option label="结账争议中" value="checkout_disputing" />
|
||||||
<el-option label="申诉中" value="disputing" />
|
<el-option label="申诉中" value="disputing" />
|
||||||
<el-option label="异常" value="abnormal" />
|
<el-option label="异常" value="abnormal" />
|
||||||
<el-option label="已完成" value="completed" />
|
<el-option label="已完成" value="completed" />
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const statusTabs = [
|
|||||||
{ key: "all", label: "全部" },
|
{ key: "all", label: "全部" },
|
||||||
{ key: "pending_handoff", label: "待交接" },
|
{ key: "pending_handoff", label: "待交接" },
|
||||||
{ key: "renting", label: "使用中" },
|
{ key: "renting", label: "使用中" },
|
||||||
{ key: "pending_return_confirm", label: "待归还" },
|
{ key: "pending_checkout_confirm", label: "待结账" },
|
||||||
{ key: "completed", label: "已完成" },
|
{ key: "completed", label: "已完成" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -52,6 +52,9 @@ function statusColor(status: string) {
|
|||||||
renting: "#1477ff",
|
renting: "#1477ff",
|
||||||
overdue: "#e91e63",
|
overdue: "#e91e63",
|
||||||
pending_return_confirm: "#e91e63",
|
pending_return_confirm: "#e91e63",
|
||||||
|
pending_checkout_confirm: "#e91e63",
|
||||||
|
pending_checkout_accept: "#9c27b0",
|
||||||
|
checkout_disputing: "#f44336",
|
||||||
completed: "#4caf50",
|
completed: "#4caf50",
|
||||||
cancelled: "#999",
|
cancelled: "#999",
|
||||||
disputing: "#f44336",
|
disputing: "#f44336",
|
||||||
@@ -66,7 +69,10 @@ function statusLabel(status: string) {
|
|||||||
pending_handoff: "待交接",
|
pending_handoff: "待交接",
|
||||||
renting: "使用中",
|
renting: "使用中",
|
||||||
overdue: "已逾期",
|
overdue: "已逾期",
|
||||||
pending_return_confirm: "待归还",
|
pending_return_confirm: "待结账",
|
||||||
|
pending_checkout_confirm: "待号主确认结账",
|
||||||
|
pending_checkout_accept: "待租客确认修正",
|
||||||
|
checkout_disputing: "结账争议中",
|
||||||
completed: "已完成",
|
completed: "已完成",
|
||||||
cancelled: "已取消",
|
cancelled: "已取消",
|
||||||
disputing: "申诉中",
|
disputing: "申诉中",
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function listingPrice(item: Listing) {
|
|||||||
<section class="pc-listings page">
|
<section class="pc-listings page">
|
||||||
<div class="anti-fraud-strip compact">
|
<div class="anti-fraud-strip compact">
|
||||||
<span
|
<span
|
||||||
>防骗提示:只在平台内沟通、下单和确认归还,谨防冒充客服与低价私单。</span
|
>防骗提示:只在平台内沟通、下单和确认结账,谨防冒充客服与低价私单。</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Handoffs</p>
|
<p class="eyebrow">Handoffs</p>
|
||||||
<h1>交接管理</h1>
|
<h1>交接管理</h1>
|
||||||
<p>处理待交接、待归还确认和超时订单。</p>
|
<p>处理待交接、待结账确认和超时订单。</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ init_database() {
|
|||||||
log "数据库结构已存在,跳过迁移"
|
log "数据库结构已存在,跳过迁移"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
log "应用增量迁移..."
|
||||||
|
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${ROOT_DIR}/backend/migrations/000002_order_checkouts.sql"
|
||||||
}
|
}
|
||||||
|
|
||||||
load_env_file() {
|
load_env_file() {
|
||||||
|
|||||||
Reference in New Issue
Block a user