支持结账多轮协商与按实际消耗双比例结算

允许双方最多 6 轮修改结账方案;哈夫币按实际消耗与 buyer/seller 比例计价,打超从押金扣,押金不足禁止自动完结并引导人工争议。同步收紧结账明细展示密度。
This commit is contained in:
yml2213
2026-07-18 20:35:16 +08:00
parent 98772867a7
commit 1a4776bba0
19 changed files with 713 additions and 140 deletions
+5
View File
@@ -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"`
+113 -34
View File
@@ -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
order.Status = orderStatusPendingCheckoutAccept
order.HandoffStatus = handoffStatusPendingRenterCheckout
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"
+8
View File
@@ -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
+68 -18
View File
@@ -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 {
+10 -8
View File
@@ -19,14 +19,16 @@ var (
ErrOrderCannotReceive = errors.New("order cannot receive")
ErrOrderCannotReturn = errors.New("order cannot return")
ErrOrderCannotComplete = errors.New("order cannot complete")
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
ErrPermissionDenied = errors.New("permission denied")
ErrDepositCannotHold = errors.New("deposit cannot hold")
ErrDepositNotHeld = errors.New("deposit not held")
ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty")
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")
ErrDepositNotHeld = errors.New("deposit not held")
ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty")
)
const internalOrderHours = 24
@@ -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,11 +168,14 @@ export function buildCheckoutContentWithSummary(options: {
)
}
if (Number(options.coinConsumedM || 0) > 0) {
lines.push(
`哈夫币消耗:${quantity(options.coinConsumedM)}M,预计剩余${quantity(
options.remainingHafCoinM
)}M`
)
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(-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>