From 1a4776bba004c67566e8b3f4aac2417a302b6da9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 18 Jul 2026 20:35:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=BB=93=E8=B4=A6=E5=A4=9A?= =?UTF-8?q?=E8=BD=AE=E5=8D=8F=E5=95=86=E4=B8=8E=E6=8C=89=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E6=B6=88=E8=80=97=E5=8F=8C=E6=AF=94=E4=BE=8B=E7=BB=93=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许双方最多 6 轮修改结账方案;哈夫币按实际消耗与 buyer/seller 比例计价,打超从押金扣,押金不足禁止自动完结并引导人工争议。同步收紧结账明细展示密度。 --- backend/internal/model/order_checkout.go | 5 + backend/internal/modules/order/checkout.go | 147 ++++++++++++++---- .../modules/order/checkout_finalize.go | 5 + backend/internal/modules/order/constants.go | 6 + backend/internal/modules/order/dto.go | 8 + .../internal/modules/order/handler_error.go | 4 + backend/internal/modules/order/presenter.go | 66 ++++++++ backend/internal/modules/order/pricing.go | 86 +++++++--- backend/internal/modules/order/queries.go | 4 + .../internal/modules/order/repository_test.go | 110 +++++++++++++ backend/internal/modules/order/service.go | 18 ++- .../000033_checkout_multi_round.sql | 20 +++ frontend/src/features/orders/api/orders.ts | 8 + .../components/OrderCheckoutSummary.vue | 101 +++++++++--- .../components/OrderResourceUsageEditor.vue | 21 ++- .../orders/composables/useOrderActions.ts | 64 +++++++- .../orders/composables/useOrderSnapshot.ts | 16 +- .../orders/views/MobileOrderDetailView.vue | 70 +++++++-- .../features/orders/views/OrderDetailView.vue | 94 ++++++++--- 19 files changed, 713 insertions(+), 140 deletions(-) create mode 100644 backend/migrations/000033_checkout_multi_round.sql diff --git a/backend/internal/model/order_checkout.go b/backend/internal/model/order_checkout.go index 601bfeb..637f18b 100644 --- a/backend/internal/model/order_checkout.go +++ b/backend/internal/model/order_checkout.go @@ -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"` diff --git a/backend/internal/modules/order/checkout.go b/backend/internal/modules/order/checkout.go index 764974e..628d0bd 100644 --- a/backend/internal/modules/order/checkout.go +++ b/backend/internal/modules/order/checkout.go @@ -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 +} diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index cea4aa9..d21d59a 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -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 { diff --git a/backend/internal/modules/order/constants.go b/backend/internal/modules/order/constants.go index eb0dd8f..94c2282 100644 --- a/backend/internal/modules/order/constants.go +++ b/backend/internal/modules/order/constants.go @@ -41,6 +41,12 @@ const ( checkoutStatusAccepted = "accepted" checkoutStatusDisputed = "disputed" + checkoutTurnOwner = "owner" + checkoutTurnRenter = "renter" + + // checkoutMaxRounds 结账协商最大提案轮次(含租客首提) + checkoutMaxRounds = 6 + refundStatusPending = "pending" refundStatusPendingReview = "pending_review" refundStatusRefunded = "refunded" diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 6f1fc7f..88285b6 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -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"` diff --git a/backend/internal/modules/order/handler_error.go b/backend/internal/modules/order/handler_error.go index add69c8..f602031 100644 --- a/backend/internal/modules/order/handler_error.go +++ b/backend/internal/modules/order/handler_error.go @@ -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): diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 1f34bf7..cb7eedc 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -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 diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index 6c9d19b..1e19461 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -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 diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index 6cd2625..dc2fa8e 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -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 } diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index 5a44568..6435803 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -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 { diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index bc4020a..d9cf67c 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -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 diff --git a/backend/migrations/000033_checkout_multi_round.sql b/backend/migrations/000033_checkout_multi_round.sql new file mode 100644 index 0000000..8efb0f5 --- /dev/null +++ b/backend/migrations/000033_checkout_multi_round.sql @@ -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; diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 30865d9..50c0ece 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -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 diff --git a/frontend/src/features/orders/components/OrderCheckoutSummary.vue b/frontend/src/features/orders/components/OrderCheckoutSummary.vue index e5f8f08..42baac6 100644 --- a/frontend/src/features/orders/components/OrderCheckoutSummary.vue +++ b/frontend/src/features/orders/components/OrderCheckoutSummary.vue @@ -43,6 +43,28 @@ const checkout = computed(() => props.order.checkout) :value="`¥${money(amountYuan(checkout.deposit_deduct_amount_cent))}`" value-class="red-text" /> + + + + props.order.checkout) ¥{{ money(amountYuan(checkout.deposit_deduct_amount_cent)) }} +
+ 哈夫币实际消耗 + {{ checkout.coin_consumed_m }}M +
+
+ 打超金额(按比例从押金扣) + ¥{{ money(amountYuan(checkout.overshoot_amount_cent)) }} +
+
+ 押金不足差额(禁止自动完结) + ¥{{ money(amountYuan(checkout.shortfall_cent)) }} +
+
+ 协商轮次 + 第 {{ checkout.round_count }}/{{ checkout.max_rounds || 6 }} 轮 +
退还租客(未使用租金 + 剩余押金) ¥{{ money(amountYuan(checkout.renter_refund_amount_cent)) }} @@ -114,18 +152,18 @@ const checkout = computed(() => props.order.checkout) diff --git a/frontend/src/features/orders/components/OrderResourceUsageEditor.vue b/frontend/src/features/orders/components/OrderResourceUsageEditor.vue index 39fb1e8..6490510 100644 --- a/frontend/src/features/orders/components/OrderResourceUsageEditor.vue +++ b/frontend/src/features/orders/components/OrderResourceUsageEditor.vue @@ -104,8 +104,11 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
- 订单快照 {{ quantity(props.snapshotHafCoinM) }}M,预计剩余 - {{ quantity(props.remainingHafCoinM) }}M + 发布量 {{ quantity(props.snapshotHafCoinM) }}M(可填实际消耗,允许超过发布量) + +