添加订单押金暂扣与归还
This commit is contained in:
@@ -29,6 +29,12 @@ type RentalOrder struct {
|
||||
RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"`
|
||||
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
|
||||
RefundedAt *time.Time `json:"refunded_at"`
|
||||
DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"`
|
||||
DepositHoldAmountCent int64 `gorm:"not null;default:0" json:"deposit_hold_amount_cent"`
|
||||
DepositHoldReason string `gorm:"size:255;not null;default:''" json:"deposit_hold_reason"`
|
||||
DepositHeldBy *uint64 `json:"deposit_held_by"`
|
||||
DepositHeldAt *time.Time `json:"deposit_held_at"`
|
||||
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"`
|
||||
OwnerSettledAt *time.Time `json:"owner_settled_at"`
|
||||
SettledAt *time.Time `json:"settled_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -38,3 +44,45 @@ type RentalOrder struct {
|
||||
func (RentalOrder) TableName() string {
|
||||
return "rental_orders"
|
||||
}
|
||||
|
||||
// 押金暂扣状态:客服可对进行中订单的押金退款进行暂扣,订单照常结算,
|
||||
// 但本应原路退给租客的押金部分挂起不退,后续由客服手动归还。
|
||||
const (
|
||||
DepositHoldStatusNone = "none" // 未暂扣
|
||||
DepositHoldStatusHeld = "held" // 已暂扣(押金退款被拦截/挂起)
|
||||
DepositHoldStatusReleased = "released" // 已归还
|
||||
)
|
||||
|
||||
// IsDepositHeld 判断订单当前是否处于押金暂扣状态。
|
||||
func (o *RentalOrder) IsDepositHeld() bool {
|
||||
return o.DepositHoldStatus == DepositHoldStatusHeld
|
||||
}
|
||||
|
||||
// ApplyDepositHold 在任一结算退款路径中拦截押金退款。
|
||||
// totalRefundCent 是本次本应原路退还给租客的总金额(含租金退款和押金退款),
|
||||
// depositPortionCent 是其中属于押金退款的部分。
|
||||
// 若订单已暂扣,则把押金部分(不超过本次退款总额)从退款中扣除,
|
||||
// 并把尚未记录过的部分累加到暂扣金额(累计不超过实付押金),返回扣除后实际发起原路退款的金额;
|
||||
// 未暂扣时原样返回 totalRefundCent。
|
||||
// 注意:本函数只做“少退、挂起”,绝不多退,因此在任何路径下都不会造成租客损失或平台多付。
|
||||
func (o *RentalOrder) ApplyDepositHold(totalRefundCent, depositPortionCent int64) int64 {
|
||||
if !o.IsDepositHeld() {
|
||||
return totalRefundCent
|
||||
}
|
||||
withheld := depositPortionCent
|
||||
if withheld > totalRefundCent {
|
||||
withheld = totalRefundCent
|
||||
}
|
||||
if withheld <= 0 {
|
||||
return totalRefundCent
|
||||
}
|
||||
remainingDepositHoldCent := o.DepositAmountCent - o.DepositHoldAmountCent
|
||||
if remainingDepositHoldCent > 0 {
|
||||
newHold := withheld
|
||||
if newHold > remainingDepositHoldCent {
|
||||
newHold = remainingDepositHoldCent
|
||||
}
|
||||
o.DepositHoldAmountCent += newHold
|
||||
}
|
||||
return totalRefundCent - withheld
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRentalOrderApplyDepositHold(t *testing.T) {
|
||||
order := RentalOrder{
|
||||
DepositAmountCent: 1000,
|
||||
DepositHoldStatus: DepositHoldStatusHeld,
|
||||
}
|
||||
|
||||
actualRefundCent := order.ApplyDepositHold(1500, 1000)
|
||||
if actualRefundCent != 500 {
|
||||
t.Fatalf("actual refund = %d, want 500", actualRefundCent)
|
||||
}
|
||||
if order.DepositHoldAmountCent != 1000 {
|
||||
t.Fatalf("hold amount = %d, want 1000", order.DepositHoldAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRentalOrderApplyDepositHoldKeepsWithholdingAfterAmountRecorded(t *testing.T) {
|
||||
order := RentalOrder{
|
||||
DepositAmountCent: 1000,
|
||||
DepositHoldStatus: DepositHoldStatusHeld,
|
||||
DepositHoldAmountCent: 1000,
|
||||
}
|
||||
|
||||
actualRefundCent := order.ApplyDepositHold(1500, 1000)
|
||||
if actualRefundCent != 500 {
|
||||
t.Fatalf("actual refund = %d, want 500", actualRefundCent)
|
||||
}
|
||||
if order.DepositHoldAmountCent != 1000 {
|
||||
t.Fatalf("hold amount = %d, want 1000", order.DepositHoldAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRentalOrderApplyDepositHoldSkipsWhenNotHeld(t *testing.T) {
|
||||
order := RentalOrder{
|
||||
DepositAmountCent: 1000,
|
||||
DepositHoldStatus: DepositHoldStatusNone,
|
||||
}
|
||||
|
||||
actualRefundCent := order.ApplyDepositHold(1500, 1000)
|
||||
if actualRefundCent != 1500 {
|
||||
t.Fatalf("actual refund = %d, want 1500", actualRefundCent)
|
||||
}
|
||||
if order.DepositHoldAmountCent != 0 {
|
||||
t.Fatalf("hold amount = %d, want 0", order.DepositHoldAmountCent)
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,10 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil {
|
||||
return err
|
||||
}
|
||||
if settlement.RenterRefundAmountCent > 0 {
|
||||
action, err := r.prepareRefund(&order, settlement.RenterRefundAmountCent, "arbitration_refund", "仲裁退款原路退还")
|
||||
// 押金暂扣:若订单被暂扣,则把本次退款中的押金部分挂起不退,仅退还剩余部分。
|
||||
refundAmountCent := order.ApplyDepositHold(settlement.RenterRefundAmountCent, settlement.RenterDepositRefundCent)
|
||||
if refundAmountCent > 0 {
|
||||
action, err := r.prepareRefund(&order, refundAmountCent, "arbitration_refund", "仲裁退款原路退还")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -169,6 +171,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
type arbitrationSettlement struct {
|
||||
Entries []wallet.Entry
|
||||
RenterRefundAmountCent int64
|
||||
RenterDepositRefundCent int64
|
||||
OwnerIncomeAmountCent int64
|
||||
DepositDeductAmountCent int64
|
||||
}
|
||||
@@ -223,15 +226,19 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
|
||||
switch req.Result {
|
||||
case "full_refund":
|
||||
addRenterRefund(totalCent, "仲裁全额退款")
|
||||
settlement.RenterDepositRefundCent = depositAmountCent
|
||||
case "partial_refund":
|
||||
if req.AmountCent <= 0 || req.AmountCent > totalCent {
|
||||
return settlement, ErrInvalidDispute
|
||||
}
|
||||
addRenterRefund(req.AmountCent, "仲裁部分退款")
|
||||
// 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。
|
||||
settlement.RenterDepositRefundCent = money.MinCent(req.AmountCent, depositAmountCent)
|
||||
addOwnerIncome(money.MinCent(totalCent-req.AmountCent, ownerRentAmountCent+depositAmountCent), "仲裁剩余金额结算给号主")
|
||||
case "release_deposit":
|
||||
addOwnerIncome(ownerRentAmountCent, "仲裁确认订单金额结算给号主")
|
||||
addRenterRefund(depositAmountCent, "仲裁释放押金给租客")
|
||||
settlement.RenterDepositRefundCent = depositAmountCent
|
||||
case "deduct_deposit", "compensate_owner":
|
||||
deductAmountCent := req.AmountCent
|
||||
if deductAmountCent <= 0 {
|
||||
@@ -243,6 +250,7 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
|
||||
settlement.DepositDeductAmountCent = deductAmountCent
|
||||
addOwnerIncome(ownerRentAmountCent+deductAmountCent, "仲裁订单金额及押金赔付结算给号主")
|
||||
addRenterRefund(depositAmountCent-deductAmountCent, "仲裁退回剩余押金给租客")
|
||||
settlement.RenterDepositRefundCent = depositAmountCent - deductAmountCent
|
||||
case "order_close":
|
||||
// Only release frozen funds. No available-balance settlement happens in development mode.
|
||||
case "mark_abnormal":
|
||||
|
||||
@@ -38,7 +38,9 @@ func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uin
|
||||
archiveAssets(listing, account)
|
||||
if beforeOrderStatus != orderStatusPendingPayment {
|
||||
totalCent := order.RentAmountCent + order.DepositAmountCent
|
||||
action, err := r.prepareRefund(order, totalCent, refundBizAdminClose, "客服关闭订单原路退款")
|
||||
// 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。
|
||||
refundAmountCent := order.ApplyDepositHold(totalCent, order.DepositAmountCent)
|
||||
action, err := r.prepareRefund(order, refundAmountCent, refundBizAdminClose, "客服关闭订单原路退款")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -307,26 +309,50 @@ func resetTargetForOrder(order model.RentalOrder) (adminResetTarget, bool) {
|
||||
|
||||
// AdminRefund 触发后台人工退款,退款由 payment 模块走渠道原路退回。
|
||||
func (r *Repository) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) {
|
||||
var order model.RentalOrder
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.First(&order, orderID).Error; err != nil {
|
||||
return nil, err
|
||||
var refund *refundAction
|
||||
err := 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.RefundStatus == refundStatusRefunded {
|
||||
return r.buildRefundStatusDTO(&order), nil
|
||||
}
|
||||
if r.refundStarter == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
return nil
|
||||
}
|
||||
totalCent := order.RentAmountCent + order.DepositAmountCent
|
||||
if totalCent <= 0 {
|
||||
return nil, ErrInvalidCheckoutAmount
|
||||
return ErrInvalidCheckoutAmount
|
||||
}
|
||||
status, err := r.refundStarter.StartRefund(ctx, orderID, totalCent, refundBizAdmin, "后台人工退款")
|
||||
// 押金暂扣:人工退款也必须尊重暂扣状态,避免绕过押金挂起直接全额退款。
|
||||
refundAmountCent := order.ApplyDepositHold(totalCent, order.DepositAmountCent)
|
||||
if refundAmountCent <= 0 {
|
||||
order.RefundStatus = "none"
|
||||
order.RefundAmountCent = 0
|
||||
return tx.Save(&order).Error
|
||||
}
|
||||
if r.refundStarter == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
action, err := r.prepareRefund(&order, refundAmountCent, refundBizAdmin, "后台人工退款")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refund = action
|
||||
return tx.Save(&order).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := ""
|
||||
if refund != nil {
|
||||
var err error
|
||||
status, err = r.refundStarter.StartRefund(ctx, refund.OrderID, refund.RefundAmountCent, refund.BizType, refund.Remark)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 重新读取订单,拿到 payment 模块更新后的退款字段。
|
||||
var order model.RentalOrder
|
||||
if err := db.First(&order, orderID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -371,7 +397,15 @@ func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) err
|
||||
if order.RefundAmountCent <= 0 {
|
||||
return errors.New("退款金额无效")
|
||||
}
|
||||
action, err := r.prepareRefund(&order, order.RefundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
|
||||
// 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。
|
||||
refundAmountCent := order.ApplyDepositHold(order.RefundAmountCent, order.DepositAmountCent)
|
||||
if refundAmountCent <= 0 {
|
||||
// 全部押金被暂扣且无其他可退金额,直接标记为已处理,等待后续手动退押金。
|
||||
order.RefundStatus = "none"
|
||||
order.RefundAmountCent = 0
|
||||
return tx.Save(&order).Error
|
||||
}
|
||||
action, err := r.prepareRefund(&order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -469,7 +503,9 @@ func (r *Repository) AdminSeal(ctx context.Context, adminID uint64, orderID uint
|
||||
sealAssets(listing, account)
|
||||
if beforeOrderStatus != orderStatusPendingPayment {
|
||||
totalCent := order.RentAmountCent + order.DepositAmountCent
|
||||
action, err := r.prepareRefund(order, totalCent, refundBizAdminSeal, "客服封存订单原路退款")
|
||||
// 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。
|
||||
refundAmountCent := order.ApplyDepositHold(totalCent, order.DepositAmountCent)
|
||||
action, err := r.prepareRefund(order, refundAmountCent, refundBizAdminSeal, "客服封存订单原路退款")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -77,10 +77,12 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement
|
||||
|
||||
func (r *Repository) prepareCheckoutRefund(order *model.RentalOrder, settlement checkoutSettlement) (*refundAction, error) {
|
||||
renterRefundTotalCent := settlement.RentRefundCent + settlement.DepositRefundCent
|
||||
if renterRefundTotalCent <= 0 {
|
||||
// 押金暂扣:若订单已暂扣,拦截本次结账中应退还租客的押金部分挂起不退,其余(租金退款)照常原路退。
|
||||
actualRefundCent := order.ApplyDepositHold(renterRefundTotalCent, settlement.DepositRefundCent)
|
||||
if actualRefundCent <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return r.prepareRefund(order, renterRefundTotalCent, refundBizCheckout, "结账退款原路退还")
|
||||
return r.prepareRefund(order, actualRefundCent, refundBizCheckout, "结账退款原路退还")
|
||||
}
|
||||
|
||||
func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutSettlement) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package order
|
||||
|
||||
import "hfb_sys/backend/internal/model"
|
||||
|
||||
const (
|
||||
orderStatusPendingPayment = "pending_payment"
|
||||
orderStatusPendingHandoff = "pending_handoff"
|
||||
@@ -66,4 +68,9 @@ const (
|
||||
refundBizAdminSeal = "admin_seal_refund"
|
||||
refundBizAdmin = "admin_refund"
|
||||
refundBizCheckout = "checkout_refund"
|
||||
refundBizDeposit = "deposit_refund"
|
||||
|
||||
depositHoldStatusNone = model.DepositHoldStatusNone
|
||||
depositHoldStatusHeld = model.DepositHoldStatusHeld
|
||||
depositHoldStatusReleased = model.DepositHoldStatusReleased
|
||||
)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// AdminHoldDeposit 客服对进行中订单的押金进行暂扣。
|
||||
// 暂扣本身不移动任何资金:租客押金全程在支付渠道,系统只记录“暂扣中”标记;
|
||||
// 待订单结算/取消/关闭/仲裁时,本应原路退还租客的押金部分会被拦截挂起,
|
||||
// 后续由客服通过 AdminReleaseDeposit 手动原路归还。
|
||||
func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order model.RentalOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !canHoldDeposit(order) {
|
||||
return ErrDepositCannotHold
|
||||
}
|
||||
beforeStatus := order.DepositHoldStatus
|
||||
now := time.Now()
|
||||
order.DepositHoldStatus = depositHoldStatusHeld
|
||||
order.DepositHoldReason = req.Reason
|
||||
order.DepositHeldBy = &adminID
|
||||
order.DepositHeldAt = &now
|
||||
order.DepositHoldReleasedAt = nil
|
||||
if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
"renter_id": order.RenterID,
|
||||
"reason": req.Reason,
|
||||
"before_hold_status": beforeStatus,
|
||||
"after_hold_status": order.DepositHoldStatus,
|
||||
"deposit_amount_cent": order.DepositAmountCent,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Save(&order).Error
|
||||
})
|
||||
}
|
||||
|
||||
// AdminReleaseDeposit 客服手动归还此前暂扣的押金,通过支付渠道原路退回。
|
||||
// 仅支持归还,不支持扣款(系统内无平台资金池承接扣款)。
|
||||
func (r *Repository) AdminReleaseDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||
var refund *refundAction
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order model.RentalOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.DepositHoldStatus != depositHoldStatusHeld {
|
||||
return ErrDepositNotHeld
|
||||
}
|
||||
holdAmountCent := order.DepositHoldAmountCent
|
||||
if holdAmountCent <= 0 {
|
||||
return ErrDepositHoldAmountEmpty
|
||||
}
|
||||
now := time.Now()
|
||||
order.DepositHoldStatus = depositHoldStatusReleased
|
||||
order.DepositHoldReleasedAt = &now
|
||||
action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refund = action
|
||||
if err := appendAuditLog(tx, adminID, "order.deposit_release", "order", order.ID, meta, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
"renter_id": order.RenterID,
|
||||
"reason": req.Reason,
|
||||
"deposit_hold_amount_cent": holdAmountCent,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Save(&order).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.startRefundBestEffort(ctx, refund)
|
||||
return nil
|
||||
}
|
||||
|
||||
// canHoldDeposit 判断订单当前是否允许暂扣押金:
|
||||
// 仅进行中(非终态、押金退款尚未发生)且有实付押金、且未处于暂扣态的订单。
|
||||
func canHoldDeposit(order model.RentalOrder) bool {
|
||||
if order.DepositHoldStatus != depositHoldStatusNone {
|
||||
return false
|
||||
}
|
||||
if order.DepositAmountCent <= 0 {
|
||||
return false
|
||||
}
|
||||
switch order.Status {
|
||||
case orderStatusPendingHandoff,
|
||||
orderStatusRenting,
|
||||
orderStatusOverdue,
|
||||
orderStatusPendingCheckoutConfirm,
|
||||
orderStatusPendingCheckoutAccept,
|
||||
orderStatusCheckoutDisputing:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ type OrderDTO struct {
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
RefundStatus string `json:"refund_status,omitempty"`
|
||||
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||
DepositHoldStatus string `json:"deposit_hold_status,omitempty"`
|
||||
DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"`
|
||||
DepositHoldReason string `json:"deposit_hold_reason,omitempty"`
|
||||
DepositHeldAt *time.Time `json:"deposit_held_at,omitempty"`
|
||||
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at,omitempty"`
|
||||
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
|
||||
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
||||
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
|
||||
|
||||
@@ -74,6 +74,14 @@ func (h *Handler) AdminResetHandoff(c *gin.Context) {
|
||||
h.adminAction(c, h.service.AdminResetHandoff, gin.H{"reset": true})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminHoldDeposit(c *gin.Context) {
|
||||
h.adminAction(c, h.service.AdminHoldDeposit, gin.H{"held": true})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminReleaseDeposit(c *gin.Context) {
|
||||
h.adminAction(c, h.service.AdminReleaseDeposit, gin.H{"released": true})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminRefund(c *gin.Context) {
|
||||
orderID, ok := parseID(c)
|
||||
if !ok {
|
||||
|
||||
@@ -47,6 +47,12 @@ func writeOrderError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "结账金额不符合规则")
|
||||
case errors.Is(err, ErrPermissionDenied):
|
||||
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单")
|
||||
case errors.Is(err, ErrDepositCannotHold):
|
||||
response.Error(c, http.StatusConflict, "deposit_cannot_hold", "当前订单不可暂扣押金")
|
||||
case errors.Is(err, ErrDepositNotHeld):
|
||||
response.Error(c, http.StatusConflict, "deposit_not_held", "该订单押金未处于暂扣状态")
|
||||
case errors.Is(err, ErrDepositHoldAmountEmpty):
|
||||
response.Error(c, http.StatusConflict, "deposit_hold_amount_empty", "暂扣押金尚未形成可归还金额")
|
||||
case IsNotFound(err):
|
||||
response.Error(c, http.StatusNotFound, "not_found", "订单不存在")
|
||||
default:
|
||||
|
||||
@@ -58,6 +58,11 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
||||
SettlementStatus: row.SettlementStatus,
|
||||
RefundStatus: row.RefundStatus,
|
||||
RefundAmountCent: row.RefundAmountCent,
|
||||
DepositHoldStatus: row.DepositHoldStatus,
|
||||
DepositHoldAmountCent: row.DepositHoldAmountCent,
|
||||
DepositHoldReason: row.DepositHoldReason,
|
||||
DepositHeldAt: row.DepositHeldAt,
|
||||
DepositHoldReleasedAt: row.DepositHoldReleasedAt,
|
||||
AdminActions: adminActionsForOrder(row.RentalOrder),
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
@@ -67,6 +72,12 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
||||
func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
|
||||
dto := row.toAdminDTO()
|
||||
dto.AdminActions = nil
|
||||
// 押金暂扣属于客服内部处置,不向租客/号主暴露。
|
||||
dto.DepositHoldStatus = ""
|
||||
dto.DepositHoldAmountCent = 0
|
||||
dto.DepositHoldReason = ""
|
||||
dto.DepositHeldAt = nil
|
||||
dto.DepositHoldReleasedAt = nil
|
||||
applyOrderPriceView(&dto, row.RentalOrder, userID)
|
||||
return dto
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestOrderDTOForUserHidesDepositHoldFields(t *testing.T) {
|
||||
now := time.Now()
|
||||
row := orderRow{
|
||||
RentalOrder: model.RentalOrder{
|
||||
ID: 1,
|
||||
OwnerID: 10,
|
||||
RenterID: 20,
|
||||
DepositHoldStatus: model.DepositHoldStatusHeld,
|
||||
DepositHoldAmountCent: 1000,
|
||||
DepositHoldReason: "风控复核",
|
||||
DepositHeldAt: &now,
|
||||
DepositHoldReleasedAt: &now,
|
||||
},
|
||||
}
|
||||
|
||||
dto := row.toDTOForUser(20)
|
||||
if dto.DepositHoldStatus != "" {
|
||||
t.Fatalf("deposit hold status = %q, want empty", dto.DepositHoldStatus)
|
||||
}
|
||||
if dto.DepositHoldAmountCent != 0 {
|
||||
t.Fatalf("deposit hold amount = %d, want 0", dto.DepositHoldAmountCent)
|
||||
}
|
||||
if dto.DepositHoldReason != "" {
|
||||
t.Fatalf("deposit hold reason = %q, want empty", dto.DepositHoldReason)
|
||||
}
|
||||
if dto.DepositHeldAt != nil {
|
||||
t.Fatalf("deposit held at = %v, want nil", dto.DepositHeldAt)
|
||||
}
|
||||
if dto.DepositHoldReleasedAt != nil {
|
||||
t.Fatalf("deposit hold released at = %v, want nil", dto.DepositHoldReleasedAt)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ var (
|
||||
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
|
||||
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
ErrDepositCannotHold = errors.New("deposit cannot hold")
|
||||
ErrDepositNotHeld = errors.New("deposit not held")
|
||||
ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty")
|
||||
)
|
||||
|
||||
const internalOrderHours = 24
|
||||
@@ -216,6 +219,26 @@ func (s *Service) AdminResetHandoff(ctx context.Context, adminID uint64, orderID
|
||||
return s.repo.AdminResetHandoff(ctx, adminID, orderID, req, meta)
|
||||
}
|
||||
|
||||
func (s *Service) AdminHoldDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if orderID == 0 || req.Reason == "" {
|
||||
return ErrDepositCannotHold
|
||||
}
|
||||
return s.repo.AdminHoldDeposit(ctx, adminID, orderID, req, meta)
|
||||
}
|
||||
|
||||
func (s *Service) AdminReleaseDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if orderID == 0 || req.Reason == "" {
|
||||
return ErrDepositCannotHold
|
||||
}
|
||||
return s.repo.AdminReleaseDeposit(ctx, adminID, orderID, req, meta)
|
||||
}
|
||||
|
||||
func (s *Service) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -543,6 +543,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus)
|
||||
adminRoutes.POST("/orders/:id/refund/approve", requirePerm("order:close"), orderHandler.AdminApproveRefund)
|
||||
adminRoutes.POST("/orders/:id/refund/reject", requirePerm("order:close"), orderHandler.AdminRejectRefund)
|
||||
adminRoutes.POST("/orders/:id/deposit-hold", requirePerm("order:deposit_hold"), orderHandler.AdminHoldDeposit)
|
||||
adminRoutes.POST("/orders/:id/deposit-release", requirePerm("order:deposit_hold"), orderHandler.AdminReleaseDeposit)
|
||||
adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund)
|
||||
|
||||
// 管理员线下提号(独立于正常订单流程)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- +goose Up
|
||||
|
||||
-- 押金暂扣:客服可对进行中订单的押金退款进行暂扣,订单照常结算/关闭/仲裁,
|
||||
-- 但本应原路退给租客的押金部分不自动退,挂起为“已暂扣”,后续由客服手动归还。
|
||||
ALTER TABLE rental_orders
|
||||
ADD COLUMN deposit_hold_status VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '押金暂扣状态: none无/held已暂扣/released已归还' AFTER refunded_at,
|
||||
ADD COLUMN deposit_hold_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '实际暂扣的押金金额(分),结算时确定' AFTER deposit_hold_status,
|
||||
ADD COLUMN deposit_hold_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '暂扣原因' AFTER deposit_hold_amount_cent,
|
||||
ADD COLUMN deposit_held_by BIGINT UNSIGNED NULL COMMENT '暂扣操作管理员' AFTER deposit_hold_reason,
|
||||
ADD COLUMN deposit_held_at DATETIME NULL COMMENT '暂扣时间' AFTER deposit_held_by,
|
||||
ADD COLUMN deposit_hold_released_at DATETIME NULL COMMENT '押金归还时间' AFTER deposit_held_at;
|
||||
|
||||
CREATE INDEX idx_deposit_hold_status ON rental_orders (deposit_hold_status);
|
||||
|
||||
INSERT INTO permissions (code, name, resource, action)
|
||||
VALUES ('order:deposit_hold', '押金暂扣与归还', 'order', 'deposit_hold');
|
||||
|
||||
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id FROM roles r, permissions p
|
||||
WHERE r.code IN ('cs', 'ops') AND p.code = 'order:deposit_hold';
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP INDEX idx_deposit_hold_status ON rental_orders;
|
||||
|
||||
ALTER TABLE rental_orders
|
||||
DROP COLUMN deposit_hold_status,
|
||||
DROP COLUMN deposit_hold_amount_cent,
|
||||
DROP COLUMN deposit_hold_reason,
|
||||
DROP COLUMN deposit_held_by,
|
||||
DROP COLUMN deposit_held_at,
|
||||
DROP COLUMN deposit_hold_released_at;
|
||||
|
||||
DELETE rp FROM role_permissions rp
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE p.code = 'order:deposit_hold';
|
||||
|
||||
DELETE FROM permissions WHERE code = 'order:deposit_hold';
|
||||
@@ -6,9 +6,11 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import {
|
||||
adminCloseOrder,
|
||||
adminHoldDeposit,
|
||||
adminMarkOrderAbnormal,
|
||||
adminRefundOrder,
|
||||
adminRefundStatus,
|
||||
adminReleaseDeposit,
|
||||
adminResetHandoff,
|
||||
adminSealOrder,
|
||||
fetchAdminHandoffRecords,
|
||||
@@ -41,7 +43,15 @@ const submitting = ref(false)
|
||||
const order = ref<Order | null>(null)
|
||||
const handoffRecords = ref<HandoffRecord[]>([])
|
||||
const paymentRecords = ref<AdminPayment[]>([])
|
||||
const actionType = ref<'close' | 'seal' | 'abnormal' | 'reset' | ''>('')
|
||||
type OrderActionType =
|
||||
| 'close'
|
||||
| 'seal'
|
||||
| 'abnormal'
|
||||
| 'reset'
|
||||
| 'deposit_hold'
|
||||
| 'deposit_release'
|
||||
| ''
|
||||
const actionType = ref<OrderActionType>('')
|
||||
const reason = ref('')
|
||||
const refundStatus = ref<RefundStatus | null>(null)
|
||||
|
||||
@@ -88,16 +98,50 @@ const resetActionLabel = computed(() => {
|
||||
return resetAction.value?.label || '重置'
|
||||
})
|
||||
const canResetHandoff = computed(() => resetAction.value?.enabled === true)
|
||||
// 押金暂扣:仅进行中、有实付押金、且未暂扣过的订单可暂扣。
|
||||
const depositHoldStatus = computed(() => order.value?.deposit_hold_status || 'none')
|
||||
const depositHoldAmountCent = computed(() => Number(order.value?.deposit_hold_amount_cent || 0))
|
||||
const isDepositHeld = computed(() => depositHoldStatus.value === 'held')
|
||||
const canHoldDeposit = computed(
|
||||
() =>
|
||||
canOperate.value &&
|
||||
depositHoldStatus.value === 'none' &&
|
||||
Number(order.value?.deposit_amount_cent || 0) > 0
|
||||
)
|
||||
const canReleaseDeposit = computed(() => isDepositHeld.value && depositHoldAmountCent.value > 0)
|
||||
const depositHoldStatusLabel = computed(() => {
|
||||
switch (depositHoldStatus.value) {
|
||||
case 'held':
|
||||
return '押金已暂扣'
|
||||
case 'released':
|
||||
return '押金已归还'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
const actionTitle = computed(() => {
|
||||
if (actionType.value === 'close') return '客服关闭订单'
|
||||
if (actionType.value === 'seal') return '封存订单'
|
||||
if (actionType.value === 'reset') return `${resetActionLabel.value}(恢复到对应待办)`
|
||||
if (actionType.value === 'deposit_hold') return '暂扣押金'
|
||||
if (actionType.value === 'deposit_release') return '归还暂扣押金'
|
||||
return '标记订单异常'
|
||||
})
|
||||
const closeActionTip = '关闭订单并归档商品/账号;已支付订单会按租金+实付押金全量原路退款。'
|
||||
const actionConfirmButtonType = computed(() =>
|
||||
actionType.value === 'deposit_release' ? 'primary' : 'danger'
|
||||
)
|
||||
const closeActionTip =
|
||||
'关闭订单并归档商品/账号;已支付订单会原路退款,押金已暂扣时押金部分会继续挂起。'
|
||||
const sealActionTip =
|
||||
'封存订单:终止订单并全量原路退款,自动解散关联群聊,关联商品永久封存,号主无法再次编辑或上架。适用于号主失联、无法联系的场景。'
|
||||
const refundActionTip = '仅发起后台人工全量原路退款,不关闭订单或调整商品/账号状态。'
|
||||
'封存订单:终止订单并原路退款,自动解散关联群聊,关联商品永久封存;押金已暂扣时押金部分会继续挂起。'
|
||||
const depositHoldActionTip = '暂扣后订单继续流转;后续本应退给租客的押金会挂起,需客服手动归还。'
|
||||
const depositReleaseActionTip = computed(() =>
|
||||
depositHoldAmountCent.value > 0
|
||||
? `将已暂扣的 ${moneyCent(depositHoldAmountCent.value)} 押金原路归还租客。`
|
||||
: '暂扣金额为 0,需订单结算/关闭/仲裁产生暂扣金额后才能归还。'
|
||||
)
|
||||
const refundActionTip =
|
||||
'仅发起后台人工原路退款,不关闭订单或调整商品/账号状态;押金已暂扣时仅退非暂扣部分。'
|
||||
const refundButtonDisabled = computed(() => refundStatus.value?.refund_status === 'refunded')
|
||||
const orderTotalCent = computed(
|
||||
() => Number(order.value?.rent_amount_cent || 0) + Number(order.value?.deposit_amount_cent || 0)
|
||||
@@ -146,9 +190,7 @@ const ownerLossPriceCent = computed(() => {
|
||||
const hasOwnerRentBreakdown = computed(() => ownerCoinBasePriceCent.value !== null)
|
||||
const fundSplitRows = computed(() => {
|
||||
if (!order.value) return []
|
||||
const rows: FundSplitRow[] = [
|
||||
{ label: '租客租金', amountCent: order.value.rent_amount_cent },
|
||||
]
|
||||
const rows: FundSplitRow[] = [{ label: '租客租金', amountCent: order.value.rent_amount_cent }]
|
||||
if (hasOwnerRentBreakdown.value) {
|
||||
rows.push(
|
||||
{ label: '号主纯币价格', amountCent: ownerCoinBasePriceCent.value },
|
||||
@@ -205,7 +247,7 @@ async function loadRefundStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
function openAction(type: 'close' | 'seal' | 'abnormal' | 'reset') {
|
||||
function openAction(type: Exclude<OrderActionType, ''>) {
|
||||
actionType.value = type
|
||||
reason.value = ''
|
||||
}
|
||||
@@ -214,7 +256,7 @@ async function confirmCloseAction() {
|
||||
if (!order.value || !canOperate.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'客服关闭会终止订单,归档关联商品和账号;若订单已支付,将按租金加实付押金发起全量原路退款。确认继续?',
|
||||
'客服关闭会终止订单,归档关联商品和账号;若订单已支付,将发起原路退款。若押金已暂扣,押金部分会继续挂起。确认继续?',
|
||||
'确认客服关闭',
|
||||
{
|
||||
confirmButtonText: '继续关闭',
|
||||
@@ -232,7 +274,7 @@ async function confirmSealAction() {
|
||||
if (!order.value || !canOperate.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'封存会终止订单并全量原路退款,自动解散关联群聊,关联商品将被永久封存,号主无法再次编辑或上架。适用于号主失联场景。确认继续?',
|
||||
'封存会终止订单并发起原路退款,自动解散关联群聊,关联商品将被永久封存。若押金已暂扣,押金部分会继续挂起。确认继续?',
|
||||
'确认封存订单',
|
||||
{
|
||||
confirmButtonText: '继续封存',
|
||||
@@ -246,6 +288,46 @@ async function confirmSealAction() {
|
||||
openAction('seal')
|
||||
}
|
||||
|
||||
async function confirmHoldDeposit() {
|
||||
if (!order.value || !canHoldDeposit.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'暂扣后订单继续流转;后续结算、关闭或仲裁中本应退给租客的押金会被挂起,需客服手动归还。确认继续?',
|
||||
'确认暂扣押金',
|
||||
{
|
||||
confirmButtonText: '继续暂扣',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
openAction('deposit_hold')
|
||||
}
|
||||
|
||||
async function confirmReleaseDeposit() {
|
||||
if (!order.value || !isDepositHeld.value) return
|
||||
if (!canReleaseDeposit.value) {
|
||||
ElMessage.warning('暂扣押金尚未形成可归还金额')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将已暂扣的 ${moneyCent(depositHoldAmountCent.value)} 押金原路归还租客。确认继续?`,
|
||||
'确认归还押金',
|
||||
{
|
||||
confirmButtonText: '继续归还',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
openAction('deposit_release')
|
||||
}
|
||||
|
||||
async function submitAction() {
|
||||
if (!order.value || !actionType.value) return
|
||||
submitting.value = true
|
||||
@@ -259,6 +341,12 @@ async function submitAction() {
|
||||
} else if (actionType.value === 'reset') {
|
||||
await adminResetHandoff(order.value.id, reason.value)
|
||||
ElMessage.success(`${resetActionLabel.value}成功`)
|
||||
} else if (actionType.value === 'deposit_hold') {
|
||||
await adminHoldDeposit(order.value.id, reason.value)
|
||||
ElMessage.success('押金已暂扣')
|
||||
} else if (actionType.value === 'deposit_release') {
|
||||
await adminReleaseDeposit(order.value.id, reason.value)
|
||||
ElMessage.success('暂扣押金已发起归还')
|
||||
} else {
|
||||
await adminMarkOrderAbnormal(order.value.id, reason.value)
|
||||
ElMessage.success('订单已标记异常')
|
||||
@@ -306,7 +394,9 @@ async function confirmRefund() {
|
||||
)
|
||||
const message = hasRefundInProgress
|
||||
? '当前订单已有退款处理中,继续人工退款可能导致重复全量退款。确认仍要发起?'
|
||||
: '人工退款只会发起全量原路退款,不会关闭订单或调整商品状态。请确认该订单确实需要补发全额退款。'
|
||||
: isDepositHeld.value
|
||||
? '人工退款不会关闭订单或调整商品状态;当前押金已暂扣,本次仅退非暂扣部分。确认继续?'
|
||||
: '人工退款只会发起原路退款,不会关闭订单或调整商品状态。请确认该订单确实需要补发退款。'
|
||||
try {
|
||||
await ElMessageBox.confirm(message, '确认人工退款', {
|
||||
confirmButtonText: '确认退款',
|
||||
@@ -346,6 +436,8 @@ function paymentBizTypeLabel(type: string) {
|
||||
admin_refund: '人工退款',
|
||||
cancel_refund: '取消退款',
|
||||
admin_close_refund: '客服关闭退款',
|
||||
admin_seal_refund: '封存退款',
|
||||
deposit_refund: '暂扣押金归还',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
@@ -499,6 +591,23 @@ function paymentPaidAt(record: AdminPayment) {
|
||||
>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="canHoldDeposit" :content="depositHoldActionTip" placement="top">
|
||||
<span>
|
||||
<el-button type="warning" plain @click="confirmHoldDeposit"> 暂扣押金 </el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="isDepositHeld" :content="depositReleaseActionTip" placement="top">
|
||||
<span>
|
||||
<el-button
|
||||
type="success"
|
||||
plain
|
||||
:disabled="!canReleaseDeposit"
|
||||
@click="confirmReleaseDeposit"
|
||||
>
|
||||
归还押金
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="refundActionTip" placement="top">
|
||||
<span>
|
||||
<el-button
|
||||
@@ -670,6 +779,26 @@ function paymentPaidAt(record: AdminPayment) {
|
||||
<dt>支付流水</dt>
|
||||
<dd>{{ paymentRecords.length }} 条</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>押金暂扣</dt>
|
||||
<dd>{{ depositHoldStatusLabel || '未暂扣' }}</dd>
|
||||
</div>
|
||||
<div v-if="depositHoldStatus !== 'none'">
|
||||
<dt>已暂扣金额</dt>
|
||||
<dd>{{ moneyCent(depositHoldAmountCent) }}</dd>
|
||||
</div>
|
||||
<div v-if="order.deposit_hold_reason" class="wide">
|
||||
<dt>暂扣原因</dt>
|
||||
<dd>{{ order.deposit_hold_reason }}</dd>
|
||||
</div>
|
||||
<div v-if="order.deposit_held_at">
|
||||
<dt>暂扣时间</dt>
|
||||
<dd>{{ formatDateTime(order.deposit_held_at) }}</dd>
|
||||
</div>
|
||||
<div v-if="order.deposit_hold_released_at">
|
||||
<dt>归还时间</dt>
|
||||
<dd>{{ formatDateTime(order.deposit_hold_released_at) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -816,10 +945,15 @@ function paymentPaidAt(record: AdminPayment) {
|
||||
<dd>{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div v-if="snapshotRatio && (snapshotRatio.recycleRatio > 0 || snapshotRatio.sellRatio > 0)" class="ratio-row">
|
||||
<div
|
||||
v-if="snapshotRatio && (snapshotRatio.recycleRatio > 0 || snapshotRatio.sellRatio > 0)"
|
||||
class="ratio-row"
|
||||
>
|
||||
<div v-if="snapshotRatio.recycleRatio > 0" class="ratio-item">
|
||||
<span class="ratio-label">回收比例</span>
|
||||
<strong class="ratio-value">1:{{ formatRatioNumber(snapshotRatio.recycleRatio) }}</strong>
|
||||
<strong class="ratio-value"
|
||||
>1:{{ formatRatioNumber(snapshotRatio.recycleRatio) }}</strong
|
||||
>
|
||||
</div>
|
||||
<div v-if="snapshotRatio.sellRatio > 0" class="ratio-item">
|
||||
<span class="ratio-label">售卖比例</span>
|
||||
@@ -860,7 +994,9 @@ function paymentPaidAt(record: AdminPayment) {
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="actionType = ''">取消</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
|
||||
<el-button :type="actionConfirmButtonType" :loading="submitting" @click="submitAction">
|
||||
确认操作
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
|
||||
@@ -36,6 +36,11 @@ export interface Order {
|
||||
settlement_status: SettlementStatus
|
||||
refund_status?: string
|
||||
refund_amount_cent?: number
|
||||
deposit_hold_status?: string
|
||||
deposit_hold_amount_cent?: number
|
||||
deposit_hold_reason?: string
|
||||
deposit_held_at?: string
|
||||
deposit_hold_released_at?: string
|
||||
active_dispute?: ActiveDispute
|
||||
checkout?: Checkout
|
||||
admin_actions?: AdminActions
|
||||
@@ -387,6 +392,22 @@ export async function adminRejectRefund(id: number, action: 'restore' | 'close')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function adminHoldDeposit(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ held: boolean }>>(
|
||||
`/admin/orders/${id}/deposit-hold`,
|
||||
{ reason }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function adminReleaseDeposit(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ released: boolean }>>(
|
||||
`/admin/orders/${id}/deposit-release`,
|
||||
{ reason }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchPendingRefundOrders() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>(
|
||||
'/admin/orders/refund-pending'
|
||||
|
||||
Reference in New Issue
Block a user