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

允许双方最多 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
+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