接入订单结账确认流程
This commit is contained in:
@@ -318,16 +318,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,
|
||||||
},
|
},
|
||||||
@@ -346,7 +346,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
|
||||||
@@ -355,28 +355,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"
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,23 @@ type SubmitReturnRequest struct {
|
|||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubmitCheckoutRequest struct {
|
||||||
|
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 {
|
||||||
Reason string `json:"reason" binding:"required"`
|
Reason string `json:"reason" binding:"required"`
|
||||||
}
|
}
|
||||||
@@ -63,3 +81,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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -267,6 +267,86 @@ func (h *Handler) ConfirmReturn(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"completed": true})
|
response.OK(c, gin.H{"completed": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SubmitCheckout(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req SubmitCheckoutRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "结账说明不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record, err := h.service.SubmitCheckout(userID, id, req)
|
||||||
|
if err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Created(c, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ConfirmCheckout(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"completed": true})
|
||||||
|
}
|
||||||
|
|
||||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||||
value, ok := c.Get(middleware.ContextUserID)
|
value, ok := c.Get(middleware.ContextUserID)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -318,6 +398,14 @@ func writeOrderError(c *gin.Context, err error) {
|
|||||||
response.Error(c, http.StatusConflict, "order_cannot_return", "当前订单不能归还")
|
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"
|
||||||
|
|
||||||
@@ -376,6 +377,10 @@ func (r *Repository) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRec
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) {
|
func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) {
|
||||||
|
return r.SubmitCheckout(userID, orderID, SubmitCheckoutRequest{Content: req.Content})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -386,28 +391,41 @@ 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
|
||||||
|
}
|
||||||
|
hasOpen, err := hasOpenCheckout(tx, order.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hasOpen {
|
||||||
|
return ErrCheckoutCannotSubmit
|
||||||
}
|
}
|
||||||
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 {
|
||||||
order.RentEndAt = &now
|
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"
|
||||||
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 {
|
||||||
@@ -426,6 +444,10 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
|
func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
|
||||||
|
return r.ConfirmCheckout(userID, orderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -434,93 +456,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"
|
|
||||||
listing.InTransaction = false
|
|
||||||
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, "租客已确认修正结账,订单完成。")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +611,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,9 +802,95 @@ 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"
|
||||||
|
listing.InTransaction = false
|
||||||
|
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 (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 {
|
||||||
@@ -797,6 +933,87 @@ func pendingPaymentTimeoutMinutes(tx *gorm.DB) int {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) baseQuery() *gorm.DB {
|
func (r *Repository) baseQuery() *gorm.DB {
|
||||||
return r.db.Table("rental_orders AS o").
|
return r.db.Table("rental_orders AS o").
|
||||||
Select("o.*, a.title, a.server_region, a.login_platform").
|
Select("o.*, a.title, a.server_region, a.login_platform").
|
||||||
@@ -861,6 +1078,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,
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ var (
|
|||||||
ErrOrderCannotReceive = errors.New("order cannot receive")
|
ErrOrderCannotReceive = errors.New("order cannot receive")
|
||||||
ErrOrderCannotReturn = errors.New("order cannot return")
|
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")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,6 +92,16 @@ func (s *Service) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRe
|
|||||||
return s.repo.SubmitReturn(userID, orderID, req)
|
return s.repo.SubmitReturn(userID, orderID, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if orderID == 0 || req.Content == "" {
|
||||||
|
return nil, ErrCheckoutCannotSubmit
|
||||||
|
}
|
||||||
|
return s.repo.SubmitCheckout(userID, orderID, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error {
|
func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return ErrDependencyUnavailable
|
return ErrDependencyUnavailable
|
||||||
@@ -95,6 +109,30 @@ func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error {
|
|||||||
return s.repo.ConfirmReturn(userID, orderID)
|
return s.repo.ConfirmReturn(userID, orderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ConfirmCheckout(userID uint64, orderID uint64) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
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) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -173,6 +173,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive)
|
orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive)
|
||||||
orderRoutes.POST("/:id/return", orderHandler.SubmitReturn)
|
orderRoutes.POST("/:id/return", orderHandler.SubmitReturn)
|
||||||
orderRoutes.POST("/:id/confirm-return", orderHandler.ConfirmReturn)
|
orderRoutes.POST("/:id/confirm-return", orderHandler.ConfirmReturn)
|
||||||
|
orderRoutes.POST("/:id/checkout", orderHandler.SubmitCheckout)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,3 +18,28 @@ DEALLOCATE PREPARE add_listing_in_transaction_stmt;
|
|||||||
INSERT INTO system_configs (`key`, `value`, description)
|
INSERT INTO system_configs (`key`, `value`, description)
|
||||||
VALUES ('order.pending_payment_timeout_minutes', '15', '订单待支付超时取消分钟数')
|
VALUES ('order.pending_payment_timeout_minutes', '15', '订单待支付超时取消分钟数')
|
||||||
ON DUPLICATE KEY UPDATE `key` = VALUES(`key`);
|
ON DUPLICATE KEY UPDATE `key` = VALUES(`key`);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -119,3 +160,23 @@ export async function confirmReturn(id: number) {
|
|||||||
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/confirm-return`)
|
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/confirm-return`)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function submitCheckout(id: number, payload: SubmitCheckoutPayload) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/checkout`, payload)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmCheckout(id: number) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,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: '已关闭',
|
||||||
@@ -31,13 +34,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: '已仲裁',
|
||||||
@@ -51,6 +58,7 @@ const settlementStatusMap: Record<string, string> = {
|
|||||||
refunded: '已退款',
|
refunded: '已退款',
|
||||||
cancelled: '已取消',
|
cancelled: '已取消',
|
||||||
closed: '已关闭',
|
closed: '已关闭',
|
||||||
|
disputed: '争议中',
|
||||||
arbitrated: '已仲裁',
|
arbitrated: '已仲裁',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ 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,
|
||||||
payOrder,
|
payOrder,
|
||||||
|
submitCheckout,
|
||||||
submitHandoff,
|
submitHandoff,
|
||||||
submitReturn,
|
|
||||||
type HandoffRecord,
|
type HandoffRecord,
|
||||||
type Order,
|
type Order,
|
||||||
} from '@/api/orders'
|
} from '@/api/orders'
|
||||||
@@ -31,12 +33,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('')
|
||||||
@@ -45,7 +65,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)
|
||||||
@@ -55,6 +78,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
|
||||||
}
|
}
|
||||||
@@ -117,45 +141,108 @@ 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,
|
||||||
})
|
})
|
||||||
@@ -194,6 +281,22 @@ function readError(error: unknown, fallback: string) {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function linesToList(value: string) {
|
||||||
|
return value
|
||||||
|
.split('\n')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -261,20 +364,59 @@ function readError(error: unknown, fallback: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="order-panel">
|
<div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="order-panel">
|
||||||
<h2>提交归还</h2>
|
<h2>发起结账</h2>
|
||||||
<el-input v-model="returnContent" type="textarea" :rows="4" placeholder="填写归还说明、租后资产状态或注意事项" />
|
<el-input v-model="checkoutForm.content" type="textarea" :rows="4" placeholder="填写结账说明、租后资产状态或注意事项" />
|
||||||
<el-button class="panel-action" type="primary" :loading="returning" @click="handleSubmitReturn">提交归还</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>
|
||||||
|
|
||||||
<div v-if="order && isOwner && order.status === 'pending_return_confirm'" class="order-panel">
|
<div v-if="order && order.checkout && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)" class="order-panel">
|
||||||
<h2>确认归还</h2>
|
<h2>结账明细</h2>
|
||||||
<p>确认账号状态无误后,订单会完成,账号重新上架。</p>
|
<p>租金:¥{{ order.checkout.rent_amount }},押金:¥{{ order.checkout.deposit_amount }}</p>
|
||||||
<el-button type="primary" :loading="completing" @click="handleConfirmReturn">确认归还并完成订单</el-button>
|
<p>扣除:¥{{ order.checkout.deposit_deduct_amount }},退还租客:¥{{ order.checkout.renter_refund_amount }},号主收入:¥{{ order.checkout.owner_income_amount }}</p>
|
||||||
|
<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 && 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" />
|
||||||
@@ -300,7 +442,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>
|
||||||
|
|||||||
@@ -40,7 +40,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" />
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const statusTabs = [
|
|||||||
{ key: "pending_payment", label: "待支付" },
|
{ key: "pending_payment", 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: "已完成" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -55,6 +55,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",
|
||||||
@@ -70,7 +73,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: "申诉中",
|
||||||
|
|||||||
Reference in New Issue
Block a user