支持结账多轮协商与按实际消耗双比例结算
允许双方最多 6 轮修改结账方案;哈夫币按实际消耗与 buyer/seller 比例计价,打超从押金扣,押金不足禁止自动完结并引导人工争议。同步收紧结账明细展示密度。
This commit is contained in:
@@ -11,6 +11,9 @@ type OrderCheckout struct {
|
||||
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"`
|
||||
RoundCount int `gorm:"not null;default:1" json:"round_count"`
|
||||
Turn string `gorm:"size:16;not null;default:'owner'" json:"turn"`
|
||||
ProposedBy uint64 `gorm:"not null;default:0" json:"proposed_by"`
|
||||
RentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
@@ -21,6 +24,8 @@ type OrderCheckout struct {
|
||||
DepositDeductAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RenterRefundAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
OwnerIncomeAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
ShortfallCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
OvershootAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
Content string `json:"content"`
|
||||
EvidenceURLS datatypes.JSON `gorm:"column:evidence_urls" json:"evidence_urls"`
|
||||
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
|
||||
|
||||
@@ -41,10 +41,14 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
checkout, err := buildCheckout(order, order.RenterID, checkoutStatusSubmitted, req.Content, req.EvidenceURLS, req.ConsumableAmountCent, req.CoinConsumedM, req.OtherAmountCent, 0, false)
|
||||
// 首轮:租客提案,轮到号主确认/还价;押金赔付可由租客申报 other
|
||||
checkout, err := buildCheckout(order, order.RenterID, checkoutStatusSubmitted, req.Content, req.EvidenceURLS, req.ConsumableAmountCent, req.CoinConsumedM, req.OtherAmountCent, req.OtherAmountCent, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
checkout.RoundCount = 1
|
||||
checkout.Turn = checkoutTurnOwner
|
||||
checkout.ProposedBy = order.RenterID
|
||||
if err := tx.Create(&checkout).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -58,7 +62,7 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
|
||||
UserID: order.OwnerID,
|
||||
Type: "checkout",
|
||||
Title: "租客已发起结账",
|
||||
Content: "请检查账号状态和消耗明细,确认无误后完成结算。",
|
||||
Content: "请检查账号状态和消耗明细,确认无误后完成结算;也可修改后交由租客确认。",
|
||||
BizType: "order",
|
||||
BizID: &orderID,
|
||||
}); err != nil {
|
||||
@@ -80,6 +84,7 @@ func (r *Repository) ConfirmReturn(ctx context.Context, userID uint64, orderID u
|
||||
return r.ConfirmCheckout(ctx, userID, orderID)
|
||||
}
|
||||
|
||||
// ConfirmCheckout 号主同意当前提案并完结(轮到号主时)
|
||||
func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID uint64) error {
|
||||
var refund *refundAction
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@@ -93,11 +98,14 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
|
||||
if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout {
|
||||
return ErrCheckoutCannotConfirm
|
||||
}
|
||||
var checkout model.OrderCheckout
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("order_id = ? AND status = ?", order.ID, checkoutStatusSubmitted).
|
||||
Order("id DESC").
|
||||
First(&checkout).Error; err != nil {
|
||||
checkout, err := lockOpenCheckout(tx, order.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalizeCheckoutTurn(checkout) != checkoutTurnOwner {
|
||||
return ErrCheckoutCannotConfirm
|
||||
}
|
||||
if err := ensureCheckoutCompletable(order, checkout); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
@@ -108,7 +116,7 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
|
||||
}
|
||||
checkout.Status = checkoutStatusAccepted
|
||||
checkout.OwnerAdjustedAt = &now
|
||||
action, err := r.finalizeCheckout(tx, &order, &checkout, "号主已确认结账,订单完成。")
|
||||
action, err := r.finalizeCheckout(tx, &order, checkout, "号主已确认结账,订单完成。")
|
||||
refund = action
|
||||
return err
|
||||
})
|
||||
@@ -119,27 +127,42 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
|
||||
return nil
|
||||
}
|
||||
|
||||
// CounterCheckout 任一方在轮到自己时修改结账提案(最多 checkoutMaxRounds 轮)
|
||||
func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) {
|
||||
var checkoutID uint64
|
||||
var orderSnapshot model.RentalOrder
|
||||
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.OwnerID != userID {
|
||||
isOwner := order.OwnerID == userID
|
||||
isRenter := order.RenterID == userID
|
||||
if !isOwner && !isRenter {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout {
|
||||
if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept {
|
||||
return ErrCheckoutCannotCounter
|
||||
}
|
||||
var checkout model.OrderCheckout
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("order_id = ? AND status = ?", order.ID, checkoutStatusSubmitted).
|
||||
Order("id DESC").
|
||||
First(&checkout).Error; err != nil {
|
||||
checkout, err := lockOpenCheckout(tx, order.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 号主修正只认押金赔付扣除;other 与 deduct 对齐,避免双字段语义分裂
|
||||
turn := normalizeCheckoutTurn(checkout)
|
||||
if isOwner && (turn != checkoutTurnOwner || order.Status != orderStatusPendingCheckoutConfirm) {
|
||||
return ErrCheckoutCannotCounter
|
||||
}
|
||||
if isRenter && (turn != checkoutTurnRenter || order.Status != orderStatusPendingCheckoutAccept) {
|
||||
return ErrCheckoutCannotCounter
|
||||
}
|
||||
round := checkout.RoundCount
|
||||
if round <= 0 {
|
||||
round = 1
|
||||
}
|
||||
if round >= checkoutMaxRounds {
|
||||
return ErrCheckoutMaxRounds
|
||||
}
|
||||
|
||||
depositDeductCent := req.DepositDeductAmountCent
|
||||
if depositDeductCent <= 0 && req.OtherAmountCent > 0 {
|
||||
depositDeductCent = req.OtherAmountCent
|
||||
@@ -150,6 +173,8 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
|
||||
}
|
||||
now := time.Now()
|
||||
checkout.Status = checkoutStatusCountered
|
||||
checkout.RoundCount = round + 1
|
||||
checkout.ProposedBy = userID
|
||||
checkout.RentAmountCent = next.RentAmountCent
|
||||
checkout.OwnerRentAmountCent = next.OwnerRentAmountCent
|
||||
checkout.PlatformFeeCent = next.PlatformFeeCent
|
||||
@@ -160,18 +185,33 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
|
||||
checkout.DepositDeductAmountCent = next.DepositDeductAmountCent
|
||||
checkout.RenterRefundAmountCent = next.RenterRefundAmountCent
|
||||
checkout.OwnerIncomeAmountCent = next.OwnerIncomeAmountCent
|
||||
checkout.ShortfallCent = next.ShortfallCent
|
||||
checkout.OvershootAmountCent = next.OvershootAmountCent
|
||||
checkout.OwnerAdjustmentReason = req.Reason
|
||||
checkout.OwnerAdjustedAt = &now
|
||||
checkout.EvidenceURLS = next.EvidenceURLS
|
||||
|
||||
notifyUserID := order.RenterID
|
||||
notifyTitle := "号主已修改结账金额"
|
||||
notifyContent := "请核对对方修正的消耗和结算金额。可同意完结、继续还价(最多 6 轮),或发起争议。"
|
||||
if isOwner {
|
||||
checkout.Turn = checkoutTurnRenter
|
||||
order.Status = orderStatusPendingCheckoutAccept
|
||||
order.HandoffStatus = handoffStatusPendingRenterCheckout
|
||||
} else {
|
||||
checkout.Turn = checkoutTurnOwner
|
||||
order.Status = orderStatusPendingCheckoutConfirm
|
||||
order.HandoffStatus = handoffStatusPendingOwnerCheckout
|
||||
notifyUserID = order.OwnerID
|
||||
notifyTitle = "租客已修改结账金额"
|
||||
}
|
||||
order.SettlementStatus = settlementStatusPending
|
||||
orderID := order.ID
|
||||
if err := notification.Append(tx, notification.Entry{
|
||||
UserID: order.RenterID,
|
||||
UserID: notifyUserID,
|
||||
Type: "checkout",
|
||||
Title: "号主已修改结账金额",
|
||||
Content: "请核对号主修正的消耗和结算金额。同意后订单完成;不同意可发起争议。",
|
||||
Title: notifyTitle,
|
||||
Content: notifyContent,
|
||||
BizType: "order",
|
||||
BizID: &orderID,
|
||||
}); err != nil {
|
||||
@@ -180,10 +220,11 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(&checkout).Error; err != nil {
|
||||
if err := tx.Save(checkout).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
checkoutID = checkout.ID
|
||||
orderSnapshot = order
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -193,15 +234,11 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toCheckoutDTOForUser(*checkout, userID, model.RentalOrder{
|
||||
OwnerID: userID,
|
||||
RentAmountCent: checkout.RentAmountCent,
|
||||
OwnerRentAmountCent: checkout.OwnerRentAmountCent,
|
||||
DepositAmountCent: checkout.DepositAmountCent,
|
||||
})
|
||||
dto := toCheckoutDTOForUser(*checkout, userID, orderSnapshot)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
// AcceptCheckout 租客同意当前提案并完结(轮到租客时)
|
||||
func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID uint64) error {
|
||||
var refund *refundAction
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@@ -215,17 +252,20 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
|
||||
if order.Status != orderStatusPendingCheckoutAccept || order.HandoffStatus != handoffStatusPendingRenterCheckout {
|
||||
return ErrCheckoutCannotConfirm
|
||||
}
|
||||
var checkout model.OrderCheckout
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("order_id = ? AND status = ?", order.ID, checkoutStatusCountered).
|
||||
Order("id DESC").
|
||||
First(&checkout).Error; err != nil {
|
||||
checkout, err := lockOpenCheckout(tx, order.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalizeCheckoutTurn(checkout) != checkoutTurnRenter {
|
||||
return ErrCheckoutCannotConfirm
|
||||
}
|
||||
if err := ensureCheckoutCompletable(order, checkout); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
checkout.Status = checkoutStatusAccepted
|
||||
checkout.RenterConfirmedAt = &now
|
||||
action, err := r.finalizeCheckout(tx, &order, &checkout, "租客已确认修正结账,订单完成。")
|
||||
action, err := r.finalizeCheckout(tx, &order, checkout, "租客已确认结账协商,订单完成。")
|
||||
refund = action
|
||||
return err
|
||||
})
|
||||
@@ -235,3 +275,42 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
|
||||
r.startRefundBestEffort(ctx, refund)
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockOpenCheckout(tx *gorm.DB, orderID uint64) (*model.OrderCheckout, error) {
|
||||
var checkout model.OrderCheckout
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("order_id = ? AND status IN ?", orderID, []string{checkoutStatusSubmitted, checkoutStatusCountered}).
|
||||
Order("id DESC").
|
||||
First(&checkout).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &checkout, nil
|
||||
}
|
||||
|
||||
func normalizeCheckoutTurn(checkout *model.OrderCheckout) string {
|
||||
if checkout == nil {
|
||||
return checkoutTurnOwner
|
||||
}
|
||||
switch checkout.Turn {
|
||||
case checkoutTurnOwner, checkoutTurnRenter:
|
||||
return checkout.Turn
|
||||
default:
|
||||
// 兼容旧数据:已反价默认等租客,否则等号主
|
||||
if checkout.Status == checkoutStatusCountered {
|
||||
return checkoutTurnRenter
|
||||
}
|
||||
return checkoutTurnOwner
|
||||
}
|
||||
}
|
||||
|
||||
func ensureCheckoutCompletable(order model.RentalOrder, checkout *model.OrderCheckout) error {
|
||||
if checkout == nil {
|
||||
return ErrCheckoutCannotConfirm
|
||||
}
|
||||
settlement := buildCheckoutSettlement(order, checkout)
|
||||
if settlement.ShortfallCent > 0 {
|
||||
return ErrCheckoutDepositShortfall
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
||||
}
|
||||
|
||||
settlement := buildCheckoutSettlement(*order, checkout)
|
||||
if settlement.ShortfallCent > 0 {
|
||||
return nil, ErrCheckoutDepositShortfall
|
||||
}
|
||||
if err := appendCheckoutOwnerIncome(tx, order, settlement); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -93,6 +96,8 @@ func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutS
|
||||
checkout.PlatformFeeCent = settlement.PlatformFeeCent
|
||||
checkout.RenterRefundAmountCent = settlement.RenterRefundCent
|
||||
checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent
|
||||
checkout.ShortfallCent = settlement.ShortfallCent
|
||||
checkout.OvershootAmountCent = settlement.OvershootAmountCent
|
||||
}
|
||||
|
||||
func appendCheckoutCompletedNotifications(tx *gorm.DB, order *model.RentalOrder, renterContent string) error {
|
||||
|
||||
@@ -41,6 +41,12 @@ const (
|
||||
checkoutStatusAccepted = "accepted"
|
||||
checkoutStatusDisputed = "disputed"
|
||||
|
||||
checkoutTurnOwner = "owner"
|
||||
checkoutTurnRenter = "renter"
|
||||
|
||||
// checkoutMaxRounds 结账协商最大提案轮次(含租客首提)
|
||||
checkoutMaxRounds = 6
|
||||
|
||||
refundStatusPending = "pending"
|
||||
refundStatusPendingReview = "pending_review"
|
||||
refundStatusRefunded = "refunded"
|
||||
|
||||
@@ -144,6 +144,12 @@ type CheckoutDTO struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
InitiatedBy uint64 `json:"initiated_by"`
|
||||
Status string `json:"status"`
|
||||
RoundCount int `json:"round_count"`
|
||||
Turn string `json:"turn"`
|
||||
ProposedBy uint64 `json:"proposed_by"`
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
CanCounter bool `json:"can_counter"`
|
||||
CanAccept bool `json:"can_accept"`
|
||||
PriceRole string `json:"price_role,omitempty"`
|
||||
DisplayAmountCent int64 `json:"display_amount_cent"`
|
||||
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
||||
@@ -156,6 +162,8 @@ type CheckoutDTO struct {
|
||||
DepositDeductAmountCent int64 `json:"deposit_deduct_amount_cent"`
|
||||
RenterRefundAmountCent *int64 `json:"renter_refund_amount_cent,omitempty"`
|
||||
OwnerIncomeAmountCent *int64 `json:"owner_income_amount_cent,omitempty"`
|
||||
ShortfallCent int64 `json:"shortfall_cent"`
|
||||
OvershootAmountCent int64 `json:"overshoot_amount_cent"`
|
||||
Content string `json:"content"`
|
||||
EvidenceURLS []string `json:"evidence_urls"`
|
||||
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
|
||||
|
||||
@@ -43,6 +43,10 @@ func writeOrderError(c *gin.Context, err error) {
|
||||
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, ErrCheckoutMaxRounds):
|
||||
response.Error(c, http.StatusConflict, "checkout_max_rounds", "结账协商已达 6 轮上限,请同意当前方案或发起争议由人工处理")
|
||||
case errors.Is(err, ErrCheckoutDepositShortfall):
|
||||
response.Error(c, http.StatusConflict, "checkout_deposit_shortfall", "押金不足以覆盖打超/赔付差额,无法自动完结,请发起争议由人工处理")
|
||||
case errors.Is(err, ErrInvalidCheckoutAmount):
|
||||
response.BadRequest(c, "结账金额不符合规则")
|
||||
case errors.Is(err, ErrPermissionDenied):
|
||||
|
||||
@@ -95,11 +95,27 @@ func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
|
||||
platformFeeCent := checkout.PlatformFeeCent
|
||||
renterRefundAmountCent := checkout.RenterRefundAmountCent
|
||||
ownerIncomeAmountCent := checkout.OwnerIncomeAmountCent
|
||||
roundCount := checkout.RoundCount
|
||||
if roundCount <= 0 {
|
||||
roundCount = 1
|
||||
}
|
||||
turn := checkout.Turn
|
||||
if turn != checkoutTurnOwner && turn != checkoutTurnRenter {
|
||||
if checkout.Status == checkoutStatusCountered {
|
||||
turn = checkoutTurnRenter
|
||||
} else {
|
||||
turn = checkoutTurnOwner
|
||||
}
|
||||
}
|
||||
return CheckoutDTO{
|
||||
ID: checkout.ID,
|
||||
OrderID: checkout.OrderID,
|
||||
InitiatedBy: checkout.InitiatedBy,
|
||||
Status: checkout.Status,
|
||||
RoundCount: roundCount,
|
||||
Turn: turn,
|
||||
ProposedBy: checkout.ProposedBy,
|
||||
MaxRounds: checkoutMaxRounds,
|
||||
PriceRole: "admin",
|
||||
DisplayAmountCent: rentAmountCent,
|
||||
RentAmountCent: &rentAmountCent,
|
||||
@@ -112,6 +128,8 @@ func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
|
||||
DepositDeductAmountCent: checkout.DepositDeductAmountCent,
|
||||
RenterRefundAmountCent: &renterRefundAmountCent,
|
||||
OwnerIncomeAmountCent: &ownerIncomeAmountCent,
|
||||
ShortfallCent: checkout.ShortfallCent,
|
||||
OvershootAmountCent: checkout.OvershootAmountCent,
|
||||
Content: checkout.Content,
|
||||
EvidenceURLS: decodeStringList(checkout.EvidenceURLS),
|
||||
OwnerAdjustmentReason: checkout.OwnerAdjustmentReason,
|
||||
@@ -125,10 +143,58 @@ func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
|
||||
|
||||
func toCheckoutDTOForUser(checkout model.OrderCheckout, userID uint64, order model.RentalOrder) CheckoutDTO {
|
||||
dto := toCheckoutAdminDTO(checkout)
|
||||
refreshCheckoutSettlementDTO(&dto, order, checkout)
|
||||
applyCheckoutPriceView(&dto, order, userID)
|
||||
applyCheckoutActionFlags(&dto, checkout, order, userID)
|
||||
return dto
|
||||
}
|
||||
|
||||
// refreshCheckoutSettlementDTO 用当前公式重算展示字段(兼容旧单 shortfall/overshoot 为空)
|
||||
func refreshCheckoutSettlementDTO(dto *CheckoutDTO, order model.RentalOrder, checkout model.OrderCheckout) {
|
||||
if dto == nil {
|
||||
return
|
||||
}
|
||||
if checkout.Status != checkoutStatusSubmitted && checkout.Status != checkoutStatusCountered {
|
||||
return
|
||||
}
|
||||
settlement := calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, checkout.DepositDeductAmountCent)
|
||||
rent := settlement.ActualRentAmountCent
|
||||
ownerRent := settlement.OwnerRentIncomeCent
|
||||
platform := settlement.PlatformFeeCent
|
||||
renterRefund := settlement.RenterRefundCent
|
||||
ownerIncome := settlement.OwnerIncomeCent
|
||||
dto.RentAmountCent = &rent
|
||||
dto.OwnerRentAmountCent = &ownerRent
|
||||
dto.PlatformFeeCent = &platform
|
||||
dto.RenterRefundAmountCent = &renterRefund
|
||||
dto.OwnerIncomeAmountCent = &ownerIncome
|
||||
dto.DisplayAmountCent = rent
|
||||
dto.ShortfallCent = settlement.ShortfallCent
|
||||
dto.OvershootAmountCent = settlement.OvershootAmountCent
|
||||
}
|
||||
|
||||
func applyCheckoutActionFlags(dto *CheckoutDTO, checkout model.OrderCheckout, order model.RentalOrder, userID uint64) {
|
||||
if dto == nil {
|
||||
return
|
||||
}
|
||||
negotiating := order.Status == orderStatusPendingCheckoutConfirm || order.Status == orderStatusPendingCheckoutAccept
|
||||
if !negotiating || (checkout.Status != checkoutStatusSubmitted && checkout.Status != checkoutStatusCountered) {
|
||||
return
|
||||
}
|
||||
turn := dto.Turn
|
||||
round := dto.RoundCount
|
||||
isOwner := userID == order.OwnerID
|
||||
isRenter := userID == order.RenterID
|
||||
if isOwner && turn == checkoutTurnOwner && order.Status == orderStatusPendingCheckoutConfirm {
|
||||
dto.CanAccept = dto.ShortfallCent <= 0
|
||||
dto.CanCounter = round < checkoutMaxRounds
|
||||
}
|
||||
if isRenter && turn == checkoutTurnRenter && order.Status == orderStatusPendingCheckoutAccept {
|
||||
dto.CanAccept = dto.ShortfallCent <= 0
|
||||
dto.CanCounter = round < checkoutMaxRounds
|
||||
}
|
||||
}
|
||||
|
||||
func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64) {
|
||||
if dto == nil {
|
||||
return
|
||||
|
||||
@@ -27,6 +27,8 @@ type checkoutSettlement struct {
|
||||
RenterRefundCent int64
|
||||
PlatformFeeCent int64
|
||||
ActualRentAmountCent int64
|
||||
OvershootAmountCent int64
|
||||
ShortfallCent int64
|
||||
}
|
||||
|
||||
func orderDurationHours(order model.RentalOrder) int {
|
||||
@@ -145,9 +147,6 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c
|
||||
if useExplicitDeduct {
|
||||
deductAmountCent = explicitDeductCent
|
||||
}
|
||||
if deductAmountCent > order.DepositAmountCent {
|
||||
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
|
||||
}
|
||||
settlement := calculateCheckoutSettlement(order, consumableAmountCent, roundQuantity(coinConsumedM), deductAmountCent)
|
||||
evidence, err := marshalStringList(evidenceURLS)
|
||||
if err != nil {
|
||||
@@ -164,9 +163,12 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c
|
||||
ConsumableAmountCent: consumableAmountCent,
|
||||
CoinConsumedM: roundQuantity(coinConsumedM),
|
||||
OtherAmountCent: otherAmountCent,
|
||||
DepositDeductAmountCent: settlement.DepositCompensationCent,
|
||||
// 存用户申报的押金赔付(损坏等),打超由结算自动计入 shortfall/overshoot
|
||||
DepositDeductAmountCent: deductAmountCent,
|
||||
RenterRefundAmountCent: settlement.RenterRefundCent,
|
||||
OwnerIncomeAmountCent: settlement.OwnerIncomeCent,
|
||||
ShortfallCent: settlement.ShortfallCent,
|
||||
OvershootAmountCent: settlement.OvershootAmountCent,
|
||||
Content: content,
|
||||
EvidenceURLS: evidence,
|
||||
}, nil
|
||||
@@ -194,24 +196,61 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
||||
prepaidConsumablePriceCent = maxCent(orderRentAmountCent-buyerCoinBasePriceCent, 0)
|
||||
}
|
||||
prepaidOwnerConsumablePriceCent := maxCent(orderOwnerRentAmountCent-sellerCoinBasePriceCent, 0)
|
||||
|
||||
consumedM := roundQuantity(coinConsumedM)
|
||||
totalCoinM := readOrderSnapshotCoinM(order.AccountSnapshot)
|
||||
coinUseRatio := 1.0
|
||||
if totalCoinM > 0 {
|
||||
coinUseRatio = minRatio(maxRatio(roundQuantity(coinConsumedM)/totalCoinM, 0), 1)
|
||||
buyerRatio := readOrderSnapshotPrice(order.AccountSnapshot, "buyer_ratio")
|
||||
sellerRatio := readOrderSnapshotPrice(order.AccountSnapshot, "seller_ratio")
|
||||
|
||||
// 币费:优先按各自比例 × 实际消耗;无比例时回退到预收币基价 × (消耗/发布量),允许 >1(打超)
|
||||
// 无发布量快照时保持旧行为:按全额使用(ratio=1)
|
||||
coinUseRatio := coinConsumptionRatio(consumedM, totalCoinM)
|
||||
var usedBuyerCoinPriceCent, usedOwnerCoinPriceCent int64
|
||||
if buyerRatio > 0 && (totalCoinM > 0 || consumedM > 0) {
|
||||
// 价(元) = 消耗M×100 / ratio;分 = 元×100
|
||||
usedBuyerCoinPriceCent = int64(math.Round(consumedM * 10000 / buyerRatio))
|
||||
} else {
|
||||
usedBuyerCoinPriceCent = int64(math.Round(float64(buyerCoinBasePriceCent) * coinUseRatio))
|
||||
}
|
||||
usedBuyerCoinPriceCent := int64(math.Round(float64(buyerCoinBasePriceCent) * coinUseRatio))
|
||||
usedOwnerCoinPriceCent := int64(math.Round(float64(sellerCoinBasePriceCent) * coinUseRatio))
|
||||
usedBuyerConsumablePriceCent := minCent(consumableAmountCent, prepaidConsumablePriceCent)
|
||||
consumableUseRatio := 1.0
|
||||
if sellerRatio > 0 && (totalCoinM > 0 || consumedM > 0) {
|
||||
usedOwnerCoinPriceCent = int64(math.Round(consumedM * 10000 / sellerRatio))
|
||||
} else {
|
||||
usedOwnerCoinPriceCent = int64(math.Round(float64(sellerCoinBasePriceCent) * coinUseRatio))
|
||||
}
|
||||
|
||||
// 消耗品按申报金额计价,允许超过预收;号主侧按预收消耗品占比线性外推
|
||||
usedBuyerConsumablePriceCent := maxCent(consumableAmountCent, 0)
|
||||
consumableUseRatio := 0.0
|
||||
if prepaidConsumablePriceCent > 0 {
|
||||
consumableUseRatio = minRatio(maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0), 1)
|
||||
consumableUseRatio = maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0)
|
||||
}
|
||||
usedOwnerConsumablePriceCent := int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio))
|
||||
actualRentAmountCent := minCent(usedBuyerCoinPriceCent+usedBuyerConsumablePriceCent, orderRentAmountCent)
|
||||
ownerRentIncomeCent := minCent(usedOwnerCoinPriceCent+usedOwnerConsumablePriceCent, orderOwnerRentAmountCent)
|
||||
depositCompensationCent := minCent(depositDeductAmountCent, orderDepositAmountCent)
|
||||
rentRefundCent := maxCent(orderRentAmountCent-actualRentAmountCent, 0)
|
||||
depositRefundCent := maxCent(orderDepositAmountCent-depositCompensationCent, 0)
|
||||
|
||||
actualBuyerRentCent := usedBuyerCoinPriceCent + usedBuyerConsumablePriceCent
|
||||
actualOwnerRentCent := usedOwnerCoinPriceCent + usedOwnerConsumablePriceCent
|
||||
if actualOwnerRentCent > actualBuyerRentCent {
|
||||
actualOwnerRentCent = actualBuyerRentCent
|
||||
}
|
||||
|
||||
// 打超:实际租客侧费用超出预收租金的部分,从押金扣
|
||||
overshootCent := maxCent(actualBuyerRentCent-orderRentAmountCent, 0)
|
||||
damageRequestCent := maxCent(depositDeductAmountCent, 0)
|
||||
// 押金先覆盖损坏赔付,再覆盖打超;不足记 shortfall,禁止自动完结
|
||||
damageAppliedCent := minCent(damageRequestCent, orderDepositAmountCent)
|
||||
depositLeftAfterDamage := maxCent(orderDepositAmountCent-damageAppliedCent, 0)
|
||||
overshootCoveredCent := minCent(overshootCent, depositLeftAfterDamage)
|
||||
shortfallCent := maxCent(damageRequestCent-damageAppliedCent, 0) + maxCent(overshootCent-overshootCoveredCent, 0)
|
||||
|
||||
rentFromPrepaidCent := minCent(actualBuyerRentCent, orderRentAmountCent)
|
||||
rentRefundCent := maxCent(orderRentAmountCent-rentFromPrepaidCent, 0)
|
||||
depositUsedCent := damageAppliedCent + overshootCoveredCent
|
||||
depositRefundCent := maxCent(orderDepositAmountCent-depositUsedCent, 0)
|
||||
|
||||
// 可完结时租客实际承担的租金向金额;有 shortfall 时仍展示完整应付便于协商
|
||||
actualRentAmountCent := actualBuyerRentCent
|
||||
ownerRentIncomeCent := actualOwnerRentCent
|
||||
platformFeeCent := maxCent(actualRentAmountCent-ownerRentIncomeCent, 0)
|
||||
depositCompensationCent := damageAppliedCent
|
||||
|
||||
return checkoutSettlement{
|
||||
OwnerRentIncomeCent: ownerRentIncomeCent,
|
||||
@@ -220,8 +259,10 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
||||
RentRefundCent: rentRefundCent,
|
||||
DepositRefundCent: depositRefundCent,
|
||||
RenterRefundCent: rentRefundCent + depositRefundCent,
|
||||
PlatformFeeCent: maxCent(actualRentAmountCent-ownerRentIncomeCent, 0),
|
||||
PlatformFeeCent: platformFeeCent,
|
||||
ActualRentAmountCent: actualRentAmountCent,
|
||||
OvershootAmountCent: overshootCent,
|
||||
ShortfallCent: shortfallCent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +277,15 @@ func readOrderSnapshotCoinM(raw datatypes.JSON) float64 {
|
||||
return roundQuantity(readJSONNumber(snapshot["haf_coin_amount"]) / 1000000)
|
||||
}
|
||||
|
||||
// coinConsumptionRatio 实际消耗相对发布量的比例,允许 >1 表示打超。
|
||||
// publishedM<=0 时视为无快照,回退为全额使用(兼容旧单测/无币价场景)。
|
||||
func coinConsumptionRatio(consumedM, publishedM float64) float64 {
|
||||
if publishedM > 0 {
|
||||
return maxRatio(roundQuantity(consumedM)/publishedM, 0)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func readOrderSnapshotAssetNumber(raw datatypes.JSON, key string) float64 {
|
||||
if len(raw) == 0 {
|
||||
return 0
|
||||
|
||||
@@ -187,6 +187,10 @@ func (r *Repository) latestCheckoutAdminDTO(ctx context.Context, orderID uint64)
|
||||
return nil
|
||||
}
|
||||
dto := toCheckoutAdminDTO(checkout)
|
||||
var order model.RentalOrder
|
||||
if err := r.db.WithContext(ctx).First(&order, orderID).Error; err == nil {
|
||||
refreshCheckoutSettlementDTO(&dto, order, checkout)
|
||||
}
|
||||
return &dto
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,116 @@ func TestCalculateCheckoutSettlementAddsDepositCompensation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateCheckoutSettlementAllowsOvershootFromDeposit(t *testing.T) {
|
||||
// 发布 100M,实际消耗 180M;按基价线性外推,打超从押金扣
|
||||
order := model.RentalOrder{
|
||||
RentAmountCent: 26300, // 仅币预收(无消耗品)
|
||||
OwnerRentAmountCent: 23300,
|
||||
DepositAmountCent: 30000,
|
||||
AccountSnapshot: datatypes.JSON([]byte(`{
|
||||
"haf_coin_amount": 100000000,
|
||||
"asset_summary": {
|
||||
"price_breakdown": {
|
||||
"buyer_coin_base_price": 263,
|
||||
"seller_coin_base_price": 233,
|
||||
"consumable_price": 0
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
}
|
||||
|
||||
settlement := calculateCheckoutSettlement(order, 0, 180, 0)
|
||||
// 180/100 * 26300 = 47340
|
||||
if settlement.ActualRentAmountCent != 47340 {
|
||||
t.Fatalf("ActualRentAmountCent = %d, want 47340", settlement.ActualRentAmountCent)
|
||||
}
|
||||
if settlement.OwnerRentIncomeCent != 41940 { // 180/100 * 23300
|
||||
t.Fatalf("OwnerRentIncomeCent = %d, want 41940", settlement.OwnerRentIncomeCent)
|
||||
}
|
||||
if settlement.OvershootAmountCent != 21040 { // 47340 - 26300
|
||||
t.Fatalf("OvershootAmountCent = %d, want 21040", settlement.OvershootAmountCent)
|
||||
}
|
||||
if settlement.ShortfallCent != 0 {
|
||||
t.Fatalf("ShortfallCent = %d, want 0", settlement.ShortfallCent)
|
||||
}
|
||||
if settlement.RentRefundCent != 0 {
|
||||
t.Fatalf("RentRefundCent = %d, want 0", settlement.RentRefundCent)
|
||||
}
|
||||
// 押金 30000 - 打超 21040 = 8960
|
||||
if settlement.DepositRefundCent != 8960 {
|
||||
t.Fatalf("DepositRefundCent = %d, want 8960", settlement.DepositRefundCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateCheckoutSettlementShortfallWhenDepositInsufficient(t *testing.T) {
|
||||
order := model.RentalOrder{
|
||||
RentAmountCent: 26300,
|
||||
OwnerRentAmountCent: 23300,
|
||||
DepositAmountCent: 5000,
|
||||
AccountSnapshot: datatypes.JSON([]byte(`{
|
||||
"haf_coin_amount": 100000000,
|
||||
"asset_summary": {
|
||||
"price_breakdown": {
|
||||
"buyer_coin_base_price": 263,
|
||||
"seller_coin_base_price": 233,
|
||||
"consumable_price": 0
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
}
|
||||
|
||||
settlement := calculateCheckoutSettlement(order, 0, 180, 0)
|
||||
// overshoot 21040, deposit 5000 → shortfall 16040
|
||||
if settlement.OvershootAmountCent != 21040 {
|
||||
t.Fatalf("OvershootAmountCent = %d, want 21040", settlement.OvershootAmountCent)
|
||||
}
|
||||
if settlement.ShortfallCent != 16040 {
|
||||
t.Fatalf("ShortfallCent = %d, want 16040", settlement.ShortfallCent)
|
||||
}
|
||||
if settlement.DepositRefundCent != 0 {
|
||||
t.Fatalf("DepositRefundCent = %d, want 0", settlement.DepositRefundCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateCheckoutSettlementUsesExplicitRatiosForActualConsume(t *testing.T) {
|
||||
// buyer 1:40, seller 1:45;消耗 180M
|
||||
// 租客: 180*100/40 = 450 元;号主: 180*100/45 = 400 元
|
||||
order := model.RentalOrder{
|
||||
RentAmountCent: 25000, // 100M 预收
|
||||
OwnerRentAmountCent: 22222,
|
||||
DepositAmountCent: 50000,
|
||||
AccountSnapshot: datatypes.JSON([]byte(`{
|
||||
"haf_coin_amount": 100000000,
|
||||
"asset_summary": {
|
||||
"price_breakdown": {
|
||||
"buyer_ratio": 40,
|
||||
"seller_ratio": 45,
|
||||
"buyer_coin_base_price": 250,
|
||||
"seller_coin_base_price": 222.22,
|
||||
"consumable_price": 0
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
}
|
||||
|
||||
settlement := calculateCheckoutSettlement(order, 0, 180, 0)
|
||||
if settlement.ActualRentAmountCent != 45000 {
|
||||
t.Fatalf("ActualRentAmountCent = %d, want 45000", settlement.ActualRentAmountCent)
|
||||
}
|
||||
if settlement.OwnerRentIncomeCent != 40000 {
|
||||
t.Fatalf("OwnerRentIncomeCent = %d, want 40000", settlement.OwnerRentIncomeCent)
|
||||
}
|
||||
if settlement.PlatformFeeCent != 5000 {
|
||||
t.Fatalf("PlatformFeeCent = %d, want 5000", settlement.PlatformFeeCent)
|
||||
}
|
||||
if settlement.OvershootAmountCent != 20000 {
|
||||
t.Fatalf("OvershootAmountCent = %d, want 20000", settlement.OvershootAmountCent)
|
||||
}
|
||||
if settlement.ShortfallCent != 0 {
|
||||
t.Fatalf("ShortfallCent = %d, want 0", settlement.ShortfallCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDepositWaiverUsesSharedRemainingQuota(t *testing.T) {
|
||||
paid, waived := calculateDepositWaiver(500, 300, 0)
|
||||
if paid != 200 || waived != 300 {
|
||||
|
||||
@@ -22,6 +22,8 @@ var (
|
||||
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
|
||||
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
|
||||
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
|
||||
ErrCheckoutMaxRounds = errors.New("checkout max rounds reached")
|
||||
ErrCheckoutDepositShortfall = errors.New("checkout deposit shortfall")
|
||||
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
ErrDepositCannotHold = errors.New("deposit cannot hold")
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE order_checkouts
|
||||
ADD COLUMN round_count INT NOT NULL DEFAULT 1 COMMENT '结账协商轮次(提交为第1轮)' AFTER status,
|
||||
ADD COLUMN turn VARCHAR(16) NOT NULL DEFAULT 'owner' COMMENT '当前待处理方: owner/renter' AFTER round_count,
|
||||
ADD COLUMN proposed_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前提案提出人' AFTER turn,
|
||||
ADD COLUMN shortfall_cent BIGINT NOT NULL DEFAULT 0 COMMENT '押金不足以覆盖打超/赔付的差额(分)' AFTER owner_income_amount_cent,
|
||||
ADD COLUMN overshoot_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '超出预收租金的打超金额(分)' AFTER shortfall_cent;
|
||||
|
||||
-- 历史单:已反价等待租客的,轮到租客
|
||||
UPDATE order_checkouts
|
||||
SET turn = 'renter', round_count = GREATEST(round_count, 2)
|
||||
WHERE status = 'countered';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE order_checkouts
|
||||
DROP COLUMN overshoot_amount_cent,
|
||||
DROP COLUMN shortfall_cent,
|
||||
DROP COLUMN proposed_by,
|
||||
DROP COLUMN turn,
|
||||
DROP COLUMN round_count;
|
||||
@@ -70,6 +70,12 @@ export interface Checkout {
|
||||
order_id: number
|
||||
initiated_by: number
|
||||
status: SettlementStatus
|
||||
round_count?: number
|
||||
turn?: 'owner' | 'renter' | string
|
||||
proposed_by?: number
|
||||
max_rounds?: number
|
||||
can_counter?: boolean
|
||||
can_accept?: boolean
|
||||
price_role?: 'renter' | 'owner' | 'admin' | string
|
||||
display_amount_cent: number
|
||||
rent_amount_cent?: number
|
||||
@@ -82,6 +88,8 @@ export interface Checkout {
|
||||
deposit_deduct_amount_cent: number
|
||||
renter_refund_amount_cent?: number
|
||||
owner_income_amount_cent?: number
|
||||
shortfall_cent?: number
|
||||
overshoot_amount_cent?: number
|
||||
content: string
|
||||
evidence_urls: string[]
|
||||
owner_adjustment_reason: string
|
||||
|
||||
@@ -43,6 +43,28 @@ const checkout = computed(() => props.order.checkout)
|
||||
:value="`¥${money(amountYuan(checkout.deposit_deduct_amount_cent))}`"
|
||||
value-class="red-text"
|
||||
/>
|
||||
<van-cell
|
||||
title="哈夫币实际消耗"
|
||||
:value="`${checkout.coin_consumed_m}M`"
|
||||
/>
|
||||
<van-cell
|
||||
v-if="Number(checkout.overshoot_amount_cent || 0) > 0"
|
||||
title="打超金额(从押金)"
|
||||
:value="`¥${money(amountYuan(checkout.overshoot_amount_cent))}`"
|
||||
value-class="red-text"
|
||||
/>
|
||||
<van-cell
|
||||
v-if="Number(checkout.shortfall_cent || 0) > 0"
|
||||
title="押金不足差额"
|
||||
:value="`¥${money(amountYuan(checkout.shortfall_cent))}`"
|
||||
value-class="red-text"
|
||||
label="无法自动完结,请协商修改或发起争议"
|
||||
/>
|
||||
<van-cell
|
||||
v-if="checkout.round_count"
|
||||
title="协商轮次"
|
||||
:value="`第 ${checkout.round_count}/${checkout.max_rounds || 6} 轮`"
|
||||
/>
|
||||
<van-cell
|
||||
v-if="props.isRenter"
|
||||
title="退还租客"
|
||||
@@ -92,6 +114,22 @@ const checkout = computed(() => props.order.checkout)
|
||||
¥{{ money(amountYuan(checkout.deposit_deduct_amount_cent)) }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>哈夫币实际消耗</span>
|
||||
<strong>{{ checkout.coin_consumed_m }}M</strong>
|
||||
</div>
|
||||
<div v-if="Number(checkout.overshoot_amount_cent || 0) > 0" class="summary-row">
|
||||
<span>打超金额(按比例从押金扣)</span>
|
||||
<strong class="warning">¥{{ money(amountYuan(checkout.overshoot_amount_cent)) }}</strong>
|
||||
</div>
|
||||
<div v-if="Number(checkout.shortfall_cent || 0) > 0" class="summary-row">
|
||||
<span>押金不足差额(禁止自动完结)</span>
|
||||
<strong class="warning">¥{{ money(amountYuan(checkout.shortfall_cent)) }}</strong>
|
||||
</div>
|
||||
<div v-if="checkout.round_count" class="summary-row">
|
||||
<span>协商轮次</span>
|
||||
<strong>第 {{ checkout.round_count }}/{{ checkout.max_rounds || 6 }} 轮</strong>
|
||||
</div>
|
||||
<div v-if="props.isRenter" class="summary-row highlight">
|
||||
<span>退还租客(未使用租金 + 剩余押金)</span>
|
||||
<strong class="amount">¥{{ money(amountYuan(checkout.renter_refund_amount_cent)) }}</strong>
|
||||
@@ -114,18 +152,18 @@ const checkout = computed(() => props.order.checkout)
|
||||
|
||||
<style scoped>
|
||||
.info-section {
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
@@ -133,19 +171,21 @@ const checkout = computed(() => props.order.checkout)
|
||||
|
||||
.checkout-summary-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
gap: 0;
|
||||
padding: 10px 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.summary-row:last-child {
|
||||
@@ -153,35 +193,40 @@ const checkout = computed(() => props.order.checkout)
|
||||
}
|
||||
|
||||
.summary-row.highlight {
|
||||
padding: 16px;
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-row span {
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.summary-row strong {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
font-size: 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary-row strong em {
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.summary-row strong.amount {
|
||||
color: #16a34a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-row strong.warning {
|
||||
@@ -189,9 +234,10 @@ const checkout = computed(() => props.order.checkout)
|
||||
}
|
||||
|
||||
.summary-note {
|
||||
padding: 16px;
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-note.warning {
|
||||
@@ -200,32 +246,32 @@ const checkout = computed(() => props.order.checkout)
|
||||
|
||||
.summary-note label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 2px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-note p {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.card-section {
|
||||
margin: 12px 12px 0;
|
||||
padding: 14px;
|
||||
margin: 10px 12px 0;
|
||||
padding: 10px 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #182232;
|
||||
margin: 0 0 12px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
:deep(.red-text) {
|
||||
@@ -237,4 +283,15 @@ const checkout = computed(() => props.order.checkout)
|
||||
color: #16a34a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 移动端 cell 行高也收紧一点 */
|
||||
.card-section :deep(.van-cell) {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-section :deep(.van-cell__title) {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -104,8 +104,11 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
|
||||
</template>
|
||||
</van-field>
|
||||
<div class="coin-hint">
|
||||
订单快照 {{ quantity(props.snapshotHafCoinM) }}M,预计剩余
|
||||
{{ quantity(props.remainingHafCoinM) }}M
|
||||
发布量 {{ quantity(props.snapshotHafCoinM) }}M(可填实际消耗,允许超过发布量)
|
||||
<template v-if="props.remainingHafCoinM >= 0">
|
||||
,预计剩余 {{ quantity(props.remainingHafCoinM) }}M
|
||||
</template>
|
||||
<template v-else>,打超 {{ quantity(-props.remainingHafCoinM) }}M(超额从押金结算)</template>
|
||||
</div>
|
||||
<van-field label="押金赔付扣除" label-width="100px">
|
||||
<template #input>
|
||||
@@ -201,12 +204,17 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
|
||||
<div class="checkout-number-card coin">
|
||||
<div class="checkout-number-copy">
|
||||
<div class="checkout-number-title">
|
||||
<strong>哈夫币消耗</strong>
|
||||
<span>默认全部使用完成</span>
|
||||
<strong>哈夫币实际消耗</strong>
|
||||
<span>可超过发布量</span>
|
||||
</div>
|
||||
<p>
|
||||
快照 {{ quantity(props.snapshotHafCoinM) }}M,预计剩余
|
||||
{{ quantity(props.remainingHafCoinM) }}M
|
||||
发布 {{ quantity(props.snapshotHafCoinM) }}M
|
||||
<template v-if="props.remainingHafCoinM >= 0">
|
||||
,预计剩余 {{ quantity(props.remainingHafCoinM) }}M
|
||||
</template>
|
||||
<template v-else>
|
||||
,打超 {{ quantity(-props.remainingHafCoinM) }}M(按比例从押金扣)
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="checkout-number-control">
|
||||
@@ -214,7 +222,6 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
|
||||
:model-value="props.checkoutForm.coin_consumed_m"
|
||||
class="checkout-number-input"
|
||||
:min="0"
|
||||
:max="props.snapshotHafCoinM"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
@update:model-value="updateCheckoutForm({ coin_consumed_m: Number($event) || 0 })"
|
||||
|
||||
@@ -191,6 +191,34 @@ export function useOrderActions(options: UseOrderActionsOptions) {
|
||||
'abnormal',
|
||||
].includes(order.value.status)
|
||||
})
|
||||
const canCounterCheckout = computed(() => {
|
||||
if (!order.value?.checkout) return false
|
||||
return Boolean(order.value.checkout.can_counter)
|
||||
})
|
||||
const canAcceptCheckoutProposal = computed(() => {
|
||||
if (!order.value?.checkout) return false
|
||||
// 后端 can_accept 已含 shortfall 校验;无字段时按角色状态兜底
|
||||
if (typeof order.value.checkout.can_accept === 'boolean') {
|
||||
return order.value.checkout.can_accept
|
||||
}
|
||||
return (
|
||||
(isOwner.value && order.value.status === 'pending_checkout_confirm') ||
|
||||
(isRenter.value && order.value.status === 'pending_checkout_accept')
|
||||
)
|
||||
})
|
||||
const checkoutShortfallCent = computed(() =>
|
||||
Number(order.value?.checkout?.shortfall_cent || 0)
|
||||
)
|
||||
const checkoutOvershootCent = computed(() =>
|
||||
Number(order.value?.checkout?.overshoot_amount_cent || 0)
|
||||
)
|
||||
const checkoutRoundLabel = computed(() => {
|
||||
const c = order.value?.checkout
|
||||
if (!c) return ''
|
||||
const round = Number(c.round_count || 1)
|
||||
const max = Number(c.max_rounds || 6)
|
||||
return `第 ${round}/${max} 轮协商`
|
||||
})
|
||||
const canCancelDispute = computed(() => {
|
||||
return (
|
||||
!!order.value &&
|
||||
@@ -365,6 +393,10 @@ export function useOrderActions(options: UseOrderActionsOptions) {
|
||||
|
||||
async function handleConfirmCheckout() {
|
||||
if (!order.value) return
|
||||
if (checkoutShortfallCent.value > 0) {
|
||||
options.notifyWarning('押金不足以覆盖打超/赔付,无法自动完结,请修改方案或发起争议由人工处理')
|
||||
return
|
||||
}
|
||||
if (needsConfirm('confirmCheckout')) {
|
||||
const ok = await options.confirm({
|
||||
title: '确认结账',
|
||||
@@ -386,20 +418,29 @@ export function useOrderActions(options: UseOrderActionsOptions) {
|
||||
|
||||
async function handleCounterCheckout() {
|
||||
if (!order.value) return
|
||||
const reasonText = counterForm.value.reason.trim()
|
||||
if (!reasonText) {
|
||||
options.notifyWarning('请填写修改原因')
|
||||
return
|
||||
}
|
||||
if (!canCounterCheckout.value) {
|
||||
options.notifyWarning('当前不能修改,可能已达 6 轮上限或未轮到你')
|
||||
return
|
||||
}
|
||||
countering.value = true
|
||||
try {
|
||||
const reasonText = counterForm.value.reason.trim()
|
||||
await counterCheckout(order.value.id, {
|
||||
content: reasonText,
|
||||
consumableAmountYuan: counterForm.value.consumableAmountYuan,
|
||||
coin_consumed_m: counterForm.value.coin_consumed_m,
|
||||
// 号主修正只保留「押金赔付扣除」;与 other 同步写入,兼容旧字段语义
|
||||
otherAmountYuan: counterForm.value.depositDeductAmountYuan,
|
||||
depositDeductAmountYuan: counterForm.value.depositDeductAmountYuan,
|
||||
reason: reasonText,
|
||||
evidence_urls: linesToList(counterForm.value.evidenceText),
|
||||
})
|
||||
options.notifySuccess('结账修正已提交,等待租客确认')
|
||||
options.notifySuccess(
|
||||
isOwner.value ? '结账修正已提交,等待租客处理' : '结账还价已提交,等待号主处理'
|
||||
)
|
||||
await loadOrder()
|
||||
options.onCounterCheckoutSuccess?.()
|
||||
} catch (error) {
|
||||
@@ -411,20 +452,24 @@ export function useOrderActions(options: UseOrderActionsOptions) {
|
||||
|
||||
async function handleAcceptCheckout() {
|
||||
if (!order.value) return
|
||||
if (checkoutShortfallCent.value > 0) {
|
||||
options.notifyWarning('押金不足以覆盖打超/赔付,无法自动完结,请修改方案或发起争议由人工处理')
|
||||
return
|
||||
}
|
||||
if (needsConfirm('acceptCheckout')) {
|
||||
const ok = await options.confirm({
|
||||
title: '同意修正',
|
||||
message: '确定同意号主的结账修正提案吗?同意后订单将完成结算。',
|
||||
title: '同意结账方案',
|
||||
message: '确定同意当前结账方案吗?同意后订单将完成结算。',
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
acceptingCheckout.value = true
|
||||
try {
|
||||
await acceptCheckout(order.value.id)
|
||||
options.notifySuccess('已确认修正结账,订单完成')
|
||||
options.notifySuccess('已确认结账,订单完成')
|
||||
await loadOrder()
|
||||
} catch (error) {
|
||||
options.notifyError(readError(error, '确认修正失败'))
|
||||
options.notifyError(readError(error, '确认结账失败'))
|
||||
} finally {
|
||||
acceptingCheckout.value = false
|
||||
}
|
||||
@@ -634,6 +679,11 @@ export function useOrderActions(options: UseOrderActionsOptions) {
|
||||
ownerIncomeLabel,
|
||||
canOpenDispute,
|
||||
canCancelDispute,
|
||||
canCounterCheckout,
|
||||
canAcceptCheckoutProposal,
|
||||
checkoutShortfallCent,
|
||||
checkoutOvershootCent,
|
||||
checkoutRoundLabel,
|
||||
isCheckoutDisputeStage,
|
||||
|
||||
// Methods
|
||||
|
||||
@@ -35,9 +35,10 @@ export function useOrderCheckoutSnapshot(options: {
|
||||
calculateResourceChargeAmount(checkoutResources.value, options.resourceUsage.value)
|
||||
)
|
||||
const snapshotHafCoinM = computed(() => getSnapshotHafCoinM(options.order.value))
|
||||
// 允许为负:表示打超(实际消耗 > 发布量)
|
||||
const remainingHafCoinM = computed(() =>
|
||||
roundQuantity(
|
||||
Math.max(snapshotHafCoinM.value - Number(options.checkoutForm.value.coin_consumed_m || 0), 0)
|
||||
snapshotHafCoinM.value - Number(options.checkoutForm.value.coin_consumed_m || 0)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -167,12 +168,15 @@ export function buildCheckoutContentWithSummary(options: {
|
||||
)
|
||||
}
|
||||
if (Number(options.coinConsumedM || 0) > 0) {
|
||||
const remain = Number(options.remainingHafCoinM || 0)
|
||||
if (remain >= 0) {
|
||||
lines.push(`哈夫币实际消耗:${quantity(options.coinConsumedM)}M,预计剩余${quantity(remain)}M`)
|
||||
} else {
|
||||
lines.push(
|
||||
`哈夫币消耗:${quantity(options.coinConsumedM)}M,预计剩余${quantity(
|
||||
options.remainingHafCoinM
|
||||
)}M`
|
||||
`哈夫币实际消耗:${quantity(options.coinConsumedM)}M,打超${quantity(-remain)}M(超额按比例从押金结算)`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
lines.push('租客发起结账。')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showDialog, showToast } from 'vant'
|
||||
|
||||
@@ -73,6 +73,10 @@ const {
|
||||
ownerIncomeLabel,
|
||||
canOpenDispute,
|
||||
canCancelDispute,
|
||||
canCounterCheckout,
|
||||
canAcceptCheckoutProposal,
|
||||
checkoutShortfallCent,
|
||||
checkoutRoundLabel,
|
||||
isCheckoutDisputeStage,
|
||||
handleCancel,
|
||||
handlePay,
|
||||
@@ -116,6 +120,13 @@ const {
|
||||
const showDisputePopup = ref(false)
|
||||
const showCounterPopup = ref(false)
|
||||
const showRejectPopup = ref(false)
|
||||
const showCheckoutCounterAction = computed(
|
||||
() =>
|
||||
!!order.value &&
|
||||
canCounterCheckout.value &&
|
||||
((isOwner.value && order.value.status === 'pending_checkout_confirm') ||
|
||||
(isRenter.value && order.value.status === 'pending_checkout_accept'))
|
||||
)
|
||||
|
||||
// 状态颜色映射(vant 标签主题)。
|
||||
function getStatusTagType(status: string) {
|
||||
@@ -347,7 +358,11 @@ async function copyListingCode() {
|
||||
>
|
||||
<h3 class="section-title">结账处理</h3>
|
||||
<p class="action-hint">
|
||||
租客已发起结账并归还账号。确认无误后点击“确认结账”;若扣款不足,可以提出“修改结账”反向提案。
|
||||
{{ checkoutRoundLabel || '请核对消耗与扣款' }}。确认无误后同意完结;也可修改方案交对方确认(最多
|
||||
6 轮)。押金不足无法自动完结,请改方案或争议。
|
||||
</p>
|
||||
<p v-if="checkoutShortfallCent > 0" class="action-hint" style="color: #ef4444">
|
||||
押金不足差额 ¥{{ money(amountYuan(checkoutShortfallCent)) }},无法同意完结。
|
||||
</p>
|
||||
<div class="action-buttons">
|
||||
<van-button
|
||||
@@ -355,25 +370,37 @@ async function copyListingCode() {
|
||||
block
|
||||
round
|
||||
:loading="completing"
|
||||
:disabled="!canAcceptCheckoutProposal"
|
||||
loading-text="处理中..."
|
||||
@click="handleConfirmCheckout"
|
||||
>
|
||||
同意并确认结账
|
||||
</van-button>
|
||||
<van-button type="warning" block plain round @click="showCounterPopup = true">
|
||||
修改扣款金额
|
||||
<van-button
|
||||
v-if="showCheckoutCounterAction"
|
||||
type="warning"
|
||||
block
|
||||
plain
|
||||
round
|
||||
@click="showCounterPopup = true"
|
||||
>
|
||||
修改结账方案
|
||||
</van-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Renter Action: Accept Counter Checkout Proposal / Refuse and Dispute -->
|
||||
<!-- Renter Action: Accept / Counter / Dispute -->
|
||||
<section
|
||||
v-if="isRenter && order.status === 'pending_checkout_accept'"
|
||||
class="card-section action-card"
|
||||
>
|
||||
<h3 class="section-title">号主结账修正审批</h3>
|
||||
<h3 class="section-title">结账方案确认</h3>
|
||||
<p class="action-hint">
|
||||
号主修改了您的结账申请并对押金进行了扣除。请审查,同意将完成结算,不同意将进入仲裁申诉。
|
||||
{{ checkoutRoundLabel || '对方已提出结账方案' }}。可同意完结、还价修改(最多 6
|
||||
轮),或发起争议由人工处理。
|
||||
</p>
|
||||
<p v-if="checkoutShortfallCent > 0" class="action-hint" style="color: #ef4444">
|
||||
押金不足差额 ¥{{ money(amountYuan(checkoutShortfallCent)) }},无法同意完结。
|
||||
</p>
|
||||
<div class="action-buttons">
|
||||
<van-button
|
||||
@@ -381,13 +408,24 @@ async function copyListingCode() {
|
||||
block
|
||||
round
|
||||
:loading="acceptingCheckout"
|
||||
:disabled="!canAcceptCheckoutProposal"
|
||||
loading-text="同意中..."
|
||||
@click="handleAcceptCheckout"
|
||||
>
|
||||
同意修正并结账
|
||||
同意并结账
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="showCheckoutCounterAction"
|
||||
type="warning"
|
||||
block
|
||||
plain
|
||||
round
|
||||
@click="showCounterPopup = true"
|
||||
>
|
||||
还价修改方案
|
||||
</van-button>
|
||||
<van-button type="danger" block plain round @click="showRejectPopup = true">
|
||||
拒绝修正并提起申诉
|
||||
发起结账争议
|
||||
</van-button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -436,15 +474,15 @@ async function copyListingCode() {
|
||||
@refresh="refreshPaymentStatus(false)"
|
||||
/>
|
||||
|
||||
<!-- POPUP: Owner Counter Checkout Adjustments -->
|
||||
<!-- POPUP: Counter Checkout Adjustments(号主/租客) -->
|
||||
<van-popup v-model:show="showCounterPopup" position="bottom" round class="mobile-popup-form">
|
||||
<header class="popup-header">
|
||||
<h3>修改结账扣款金额</h3>
|
||||
<h3>修改结账方案</h3>
|
||||
<button class="popup-close" @click="showCounterPopup = false">✕</button>
|
||||
</header>
|
||||
<div class="popup-body">
|
||||
<p class="form-hint-info">
|
||||
在此调整实际使用的租金项目和押金赔付金额,提案将交由租客二次确认。
|
||||
{{ checkoutRoundLabel }}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。
|
||||
</p>
|
||||
<van-cell-group :border="false">
|
||||
<van-field label="额外消耗品已用">
|
||||
@@ -456,7 +494,7 @@ async function copyListingCode() {
|
||||
/>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field label="消耗哈夫币 M">
|
||||
<van-field label="实际消耗哈夫币 M">
|
||||
<template #input>
|
||||
<input
|
||||
v-model.number="counterForm.coin_consumed_m"
|
||||
@@ -465,7 +503,7 @@ async function copyListingCode() {
|
||||
/>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field label="押金赔付扣除" required>
|
||||
<van-field label="押金赔付(损坏等)">
|
||||
<template #input>
|
||||
<input
|
||||
v-model.number="counterForm.depositDeductAmountYuan"
|
||||
@@ -481,7 +519,7 @@ async function copyListingCode() {
|
||||
autosize
|
||||
label="修改原因"
|
||||
type="textarea"
|
||||
placeholder="请说明调整扣款的原因,说服租客确认"
|
||||
placeholder="请说明调整原因(必填)"
|
||||
class="popup-field"
|
||||
/>
|
||||
<van-field
|
||||
@@ -506,7 +544,7 @@ async function copyListingCode() {
|
||||
@click="handleCounterCheckout"
|
||||
class="popup-submit"
|
||||
>
|
||||
提交提案给租客确认
|
||||
提交给对方确认
|
||||
</van-button>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
@@ -72,6 +72,11 @@ const {
|
||||
ownerIncomeLabel,
|
||||
canOpenDispute,
|
||||
canCancelDispute,
|
||||
canCounterCheckout,
|
||||
canAcceptCheckoutProposal,
|
||||
checkoutShortfallCent,
|
||||
checkoutOvershootCent,
|
||||
checkoutRoundLabel,
|
||||
isCheckoutDisputeStage,
|
||||
handleCancel,
|
||||
handlePay,
|
||||
@@ -129,6 +134,13 @@ const hasSidebarActions = computed(() => {
|
||||
(isRenter.value && order.value.status === 'pending_checkout_accept')
|
||||
)
|
||||
})
|
||||
const showCheckoutCounterForm = computed(
|
||||
() =>
|
||||
!!order.value &&
|
||||
canCounterCheckout.value &&
|
||||
((isOwner.value && order.value.status === 'pending_checkout_confirm') ||
|
||||
(isRenter.value && order.value.status === 'pending_checkout_accept'))
|
||||
)
|
||||
const disputeSupportTitle = computed(() => {
|
||||
if (canCancelDispute.value) {
|
||||
return order.value?.status === 'checkout_disputing' ? '结账争议处理中' : '申诉处理中'
|
||||
@@ -480,15 +492,23 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
:is-renter="isRenter"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="order && isOwner && order.status === 'pending_checkout_confirm'"
|
||||
class="form-section counter-checkout-section"
|
||||
>
|
||||
<div v-if="showCheckoutCounterForm" class="form-section counter-checkout-section">
|
||||
<div class="section-header">
|
||||
<h2>修改结账</h2>
|
||||
<h2>修改结账方案</h2>
|
||||
<span v-if="checkoutRoundLabel">{{ checkoutRoundLabel }}</span>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
<h3 class="sub-title">修改结账金额</h3>
|
||||
<p class="form-hint">
|
||||
双方最多协商 6 轮。可填写<strong>实际哈夫币消耗</strong>(允许超过发布量)。
|
||||
押金不足时无法自动完结,需改方案或发起争议。
|
||||
</p>
|
||||
<p v-if="checkoutShortfallCent > 0" class="form-hint" style="color: #dc2626">
|
||||
当前方案押金不足差额 ¥{{ money(amountYuan(checkoutShortfallCent)) }},无法同意完结。
|
||||
</p>
|
||||
<p v-else-if="checkoutOvershootCent > 0" class="form-hint">
|
||||
当前打超 ¥{{ money(amountYuan(checkoutOvershootCent)) }},将从押金扣除。
|
||||
</p>
|
||||
<h3 class="sub-title">调整消耗与扣款</h3>
|
||||
<el-form class="form-grid" label-position="top">
|
||||
<el-form-item label="额外消耗品已用金额">
|
||||
<el-input-number
|
||||
@@ -499,7 +519,7 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="消耗哈夫币(M)">
|
||||
<el-form-item label="实际消耗哈夫币(M)">
|
||||
<el-input-number
|
||||
v-model="counterForm.coin_consumed_m"
|
||||
class="full-control"
|
||||
@@ -508,12 +528,11 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="押金赔付扣除(元)">
|
||||
<el-form-item label="押金赔付扣除(损坏/违规,元)">
|
||||
<el-input-number
|
||||
v-model="counterForm.depositDeductAmountYuan"
|
||||
class="full-control"
|
||||
:min="0"
|
||||
:max="amountYuan(order.deposit_amount_cent)"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
/>
|
||||
@@ -523,7 +542,7 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
v-model="counterForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="填写修改原因"
|
||||
placeholder="填写修改原因(必填)"
|
||||
/>
|
||||
<el-input
|
||||
v-model="counterForm.evidenceText"
|
||||
@@ -536,8 +555,9 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
size="large"
|
||||
:loading="countering"
|
||||
@click="handleCounterCheckout"
|
||||
>提交修正给租客确认</el-button
|
||||
>
|
||||
{{ isOwner ? '提交修正给租客' : '提交还价给号主' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -546,10 +566,12 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
class="form-section reject-checkout-section"
|
||||
>
|
||||
<div class="section-header">
|
||||
<h2>拒绝修正</h2>
|
||||
<h2>不同意并发起争议</h2>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
<p class="form-hint">不同意修正时填写原因,订单会进入争议处理。</p>
|
||||
<p class="form-hint">
|
||||
也可先在上方「修改结账方案」还价。若谈不拢或押金不足,填写原因进入人工争议。
|
||||
</p>
|
||||
<el-input
|
||||
v-model="rejectReason"
|
||||
type="textarea"
|
||||
@@ -562,7 +584,7 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
size="large"
|
||||
:loading="rejectingCheckout"
|
||||
@click="handleRejectCheckout"
|
||||
>拒绝修正并发起争议</el-button
|
||||
>发起结账争议</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,41 +598,69 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
<!-- 确认结账 -->
|
||||
<div v-if="isOwner && order.status === 'pending_checkout_confirm'" class="sidebar-card">
|
||||
<h3 class="sidebar-title">确认结账</h3>
|
||||
<p v-if="checkoutRoundLabel" class="sidebar-hint">{{ checkoutRoundLabel }}</p>
|
||||
<p v-if="checkoutShortfallCent > 0" class="sidebar-hint" style="color: #dc2626">
|
||||
押金不足,无法同意完结
|
||||
</p>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="completing"
|
||||
:disabled="!canAcceptCheckoutProposal"
|
||||
@click="handleConfirmCheckout"
|
||||
>
|
||||
确认结账并完成订单
|
||||
</el-button>
|
||||
<div class="section-divider-mini">
|
||||
<div v-if="canCounterCheckout" class="section-divider-mini">
|
||||
<span>或者</span>
|
||||
</div>
|
||||
<el-button type="warning" plain size="large" @click="scrollToCounterCheckout">
|
||||
需要修改结账金额
|
||||
<el-button
|
||||
v-if="canCounterCheckout"
|
||||
type="warning"
|
||||
plain
|
||||
size="large"
|
||||
@click="scrollToCounterCheckout"
|
||||
>
|
||||
修改结账方案
|
||||
</el-button>
|
||||
<p class="sidebar-hint">如认为金额有误,点击上方按钮修改</p>
|
||||
<p class="sidebar-hint">双方可多轮协商(最多 6 轮);押金不足请改方案或争议</p>
|
||||
</div>
|
||||
|
||||
<!-- 确认修正结账 -->
|
||||
<div v-if="isRenter && order.status === 'pending_checkout_accept'" class="sidebar-card">
|
||||
<h3 class="sidebar-title">确认修正结账</h3>
|
||||
<h3 class="sidebar-title">确认结账方案</h3>
|
||||
<p v-if="checkoutRoundLabel" class="sidebar-hint">{{ checkoutRoundLabel }}</p>
|
||||
<p v-if="checkoutShortfallCent > 0" class="sidebar-hint" style="color: #dc2626">
|
||||
押金不足,无法同意完结
|
||||
</p>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="acceptingCheckout"
|
||||
:disabled="!canAcceptCheckoutProposal"
|
||||
@click="handleAcceptCheckout"
|
||||
>
|
||||
同意修正并完成订单
|
||||
同意并完成订单
|
||||
</el-button>
|
||||
<div v-if="canCounterCheckout" class="section-divider-mini">
|
||||
<span>或者</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="canCounterCheckout"
|
||||
type="warning"
|
||||
plain
|
||||
size="large"
|
||||
@click="scrollToCounterCheckout"
|
||||
>
|
||||
还价修改方案
|
||||
</el-button>
|
||||
<div class="section-divider-mini">
|
||||
<span>或者</span>
|
||||
</div>
|
||||
<el-button type="danger" plain size="large" @click="scrollToRejectCheckout">
|
||||
不同意,发起争议
|
||||
发起争议(人工)
|
||||
</el-button>
|
||||
<p class="sidebar-hint">不同意修正时需填写原因,将进入争议处理</p>
|
||||
<p class="sidebar-hint">可还价或争议;押金不足无法自动完结</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user