支持结账多轮协商与按实际消耗双比例结算
允许双方最多 6 轮修改结账方案;哈夫币按实际消耗与 buyer/seller 比例计价,打超从押金扣,押金不足禁止自动完结并引导人工争议。同步收紧结账明细展示密度。
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user