From c54e3936ab5c3b285224bbbdb6bb8944556982db Mon Sep 17 00:00:00 2001 From: yml Date: Wed, 27 May 2026 10:28:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BB=93=E8=B4=A6=E9=87=91?= =?UTF-8?q?=E9=A2=9D=E5=92=8C=E5=90=8E=E5=8F=B0=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/order/repository.go | 202 +++++++++++++++--- .../internal/modules/order/repository_test.go | 114 ++++++++++ frontend/src/composables/useAdminTable.ts | 3 +- .../src/views/account/OrderDetailView.vue | 16 +- .../src/views/admin/AdminListingsView.vue | 3 +- frontend/src/views/admin/AdminOrdersView.vue | 3 +- .../views/mobile/MobileOrderDetailView.vue | 18 +- 7 files changed, 311 insertions(+), 48 deletions(-) create mode 100644 backend/internal/modules/order/repository_test.go diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 34d4d14..33df153 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -40,6 +40,17 @@ type orderPricing struct { PlatformFee float64 } +type checkoutSettlement struct { + OwnerRentIncome float64 + DepositCompensation float64 + OwnerIncome float64 + RentRefund float64 + DepositRefund float64 + RenterRefund float64 + PlatformFee float64 + ActualRentAmount float64 +} + func orderDurationHours(order model.RentalOrder) int { if order.EstimatedDurationHours > 0 { return order.EstimatedDurationHours @@ -68,14 +79,21 @@ func buildOrderPricing(listing model.RentalListing, account model.GameAccount) o } func readSnapshotPrice(raw datatypes.JSON, key string) float64 { + return readOrderSnapshotPrice(raw, key) +} + +func readOrderSnapshotPrice(raw datatypes.JSON, key string) float64 { if len(raw) == 0 { return 0 } - var summary map[string]any - if err := json.Unmarshal(raw, &summary); err != nil { + var snapshot map[string]any + if err := json.Unmarshal(raw, &snapshot); err != nil { return 0 } - breakdown, ok := summary["price_breakdown"].(map[string]any) + if assetSummary, ok := snapshot["asset_summary"].(map[string]any); ok { + snapshot = assetSummary + } + breakdown, ok := snapshot["price_breakdown"].(map[string]any) if !ok { return 0 } @@ -927,7 +945,8 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che listing.InTransaction = false account.Status = "published" orderID := order.ID - if err := wallet.AppendEntries(tx, + settlement := buildCheckoutSettlement(*order, checkout) + entries := []wallet.Entry{ wallet.Entry{ UserID: order.RenterID, OrderID: &orderID, @@ -938,29 +957,63 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che BizNo: order.OrderNo, Remark: "订单结账释放冻结金额", }, - wallet.Entry{ + } + if settlement.OwnerRentIncome > 0 { + entries = append(entries, wallet.Entry{ UserID: order.OwnerID, OrderID: &orderID, Direction: "in", - Amount: checkout.OwnerIncomeAmount, + Amount: settlement.OwnerRentIncome, BalanceType: "available", BizType: "owner_income", BizNo: order.OrderNo, - Remark: "订单结账收入", - }, - wallet.Entry{ + Remark: "订单结账租金收入", + }) + } + if settlement.DepositCompensation > 0 { + entries = append(entries, wallet.Entry{ + UserID: order.OwnerID, + OrderID: &orderID, + Direction: "in", + Amount: settlement.DepositCompensation, + BalanceType: "available", + BizType: "deposit_compensation", + BizNo: order.OrderNo, + Remark: "订单结账押金赔付", + }) + } + if settlement.RentRefund > 0 { + entries = append(entries, wallet.Entry{ UserID: order.RenterID, OrderID: &orderID, Direction: "in", - Amount: checkout.RenterRefundAmount, + Amount: settlement.RentRefund, + BalanceType: "available", + BizType: "rent_refund", + BizNo: order.OrderNo, + Remark: "订单结账退回未使用租金", + }) + } + if settlement.DepositRefund > 0 { + entries = append(entries, wallet.Entry{ + UserID: order.RenterID, + OrderID: &orderID, + Direction: "in", + Amount: settlement.DepositRefund, BalanceType: "available", BizType: "deposit_release", BizNo: order.OrderNo, Remark: "订单结账退回押金", - }, - ); err != nil { + }) + } + if err := wallet.AppendEntries(tx, entries...); err != nil { return err } + checkout.RentAmount = settlement.ActualRentAmount + checkout.OwnerRentAmount = settlement.OwnerRentIncome + checkout.PlatformFee = settlement.PlatformFee + checkout.RenterRefundAmount = settlement.RenterRefund + checkout.OwnerIncomeAmount = settlement.OwnerIncome if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, @@ -1049,7 +1102,7 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c if deductAmount > order.DepositAmount { return model.OrderCheckout{}, ErrInvalidCheckoutAmount } - renterRefund := order.DepositAmount - deductAmount + settlement := calculateCheckoutSettlement(order, consumableAmount, roundQuantity(coinConsumedM), deductAmount) evidence, err := marshalStringList(evidenceURLS) if err != nil { return model.OrderCheckout{}, err @@ -1058,21 +1111,80 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c OrderID: order.ID, InitiatedBy: initiatedBy, Status: status, - RentAmount: order.RentAmount, - OwnerRentAmount: order.OwnerRentAmount, - PlatformFee: order.PlatformFee, + RentAmount: settlement.ActualRentAmount, + OwnerRentAmount: settlement.OwnerRentIncome, + PlatformFee: settlement.PlatformFee, DepositAmount: order.DepositAmount, ConsumableAmount: consumableAmount, CoinConsumedM: roundQuantity(coinConsumedM), OtherAmount: otherAmount, DepositDeductAmount: roundMoney(deductAmount), - RenterRefundAmount: roundMoney(renterRefund), - OwnerIncomeAmount: roundMoney(order.OwnerRentAmount + deductAmount), + RenterRefundAmount: settlement.RenterRefund, + OwnerIncomeAmount: settlement.OwnerIncome, Content: content, EvidenceURLS: evidence, }, nil } +func buildCheckoutSettlement(order model.RentalOrder, checkout *model.OrderCheckout) checkoutSettlement { + return calculateCheckoutSettlement(order, checkout.ConsumableAmount, checkout.CoinConsumedM, checkout.DepositDeductAmount) +} + +func calculateCheckoutSettlement(order model.RentalOrder, consumableAmount float64, coinConsumedM float64, depositDeductAmount float64) checkoutSettlement { + buyerCoinBasePrice := readOrderSnapshotPrice(order.AccountSnapshot, "buyer_coin_base_price") + if buyerCoinBasePrice <= 0 || buyerCoinBasePrice > order.RentAmount { + buyerCoinBasePrice = order.RentAmount + } + sellerCoinBasePrice := readOrderSnapshotPrice(order.AccountSnapshot, "seller_coin_base_price") + if sellerCoinBasePrice <= 0 || sellerCoinBasePrice > order.OwnerRentAmount { + sellerCoinBasePrice = order.OwnerRentAmount + } + prepaidConsumablePrice := readOrderSnapshotPrice(order.AccountSnapshot, "consumable_price") + if prepaidConsumablePrice <= 0 || prepaidConsumablePrice > order.RentAmount-buyerCoinBasePrice { + prepaidConsumablePrice = maxMoney(order.RentAmount-buyerCoinBasePrice, 0) + } + prepaidOwnerConsumablePrice := maxMoney(order.OwnerRentAmount-sellerCoinBasePrice, 0) + totalCoinM := readOrderSnapshotCoinM(order.AccountSnapshot) + coinUseRatio := 1.0 + if totalCoinM > 0 { + coinUseRatio = minRatio(maxRatio(roundQuantity(coinConsumedM)/totalCoinM, 0), 1) + } + usedBuyerCoinPrice := roundMoney(buyerCoinBasePrice * coinUseRatio) + usedOwnerCoinPrice := roundMoney(sellerCoinBasePrice * coinUseRatio) + usedBuyerConsumablePrice := minMoney(roundMoney(consumableAmount), prepaidConsumablePrice) + consumableUseRatio := 1.0 + if prepaidConsumablePrice > 0 { + consumableUseRatio = minRatio(maxRatio(usedBuyerConsumablePrice/prepaidConsumablePrice, 0), 1) + } + usedOwnerConsumablePrice := roundMoney(prepaidOwnerConsumablePrice * consumableUseRatio) + actualRentAmount := minMoney(roundMoney(usedBuyerCoinPrice+usedBuyerConsumablePrice), order.RentAmount) + ownerRentIncome := minMoney(roundMoney(usedOwnerCoinPrice+usedOwnerConsumablePrice), order.OwnerRentAmount) + depositCompensation := minMoney(roundMoney(depositDeductAmount), order.DepositAmount) + rentRefund := maxMoney(order.RentAmount-actualRentAmount, 0) + depositRefund := maxMoney(order.DepositAmount-depositCompensation, 0) + return checkoutSettlement{ + OwnerRentIncome: ownerRentIncome, + DepositCompensation: depositCompensation, + OwnerIncome: roundMoney(ownerRentIncome + depositCompensation), + RentRefund: rentRefund, + DepositRefund: depositRefund, + RenterRefund: roundMoney(rentRefund + depositRefund), + PlatformFee: maxMoney(actualRentAmount-ownerRentIncome, 0), + ActualRentAmount: actualRentAmount, + } +} + +func readOrderSnapshotCoinM(raw datatypes.JSON) float64 { + if len(raw) == 0 { + return 0 + } + var snapshot map[string]any + if err := json.Unmarshal(raw, &snapshot); err != nil { + return 0 + } + return roundQuantity(readJSONNumber(snapshot["haf_coin_amount"]) / 1000000) +} + func marshalStringList(items []string) (datatypes.JSON, error) { if items == nil { items = []string{} @@ -1100,6 +1212,34 @@ func roundQuantity(value float64) float64 { return math.Round(value*100) / 100 } +func minMoney(a float64, b float64) float64 { + if a < b { + return roundMoney(a) + } + return roundMoney(b) +} + +func maxMoney(a float64, b float64) float64 { + if a > b { + return roundMoney(a) + } + return roundMoney(b) +} + +func minRatio(a float64, b float64) float64 { + if a < b { + return a + } + return b +} + +func maxRatio(a float64, b float64) float64 { + if a > b { + return a + } + return b +} + func hasOpenCheckout(tx *gorm.DB, orderID uint64) (bool, error) { var count int64 err := tx.Model(&model.OrderCheckout{}). @@ -1288,27 +1428,33 @@ func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID ui if dto == nil { return } + ownerAmount := 0.0 + if dto.OwnerRentAmount != nil { + ownerAmount = *dto.OwnerRentAmount + } + ownerIncomeAmount := 0.0 + if dto.OwnerIncomeAmount != nil { + ownerIncomeAmount = *dto.OwnerIncomeAmount + } + rentAmount := 0.0 + if dto.RentAmount != nil { + rentAmount = *dto.RentAmount + } + renterRefundAmount := 0.0 + if dto.RenterRefundAmount != nil { + renterRefundAmount = *dto.RenterRefundAmount + } dto.PlatformFee = nil dto.RenterRefundAmount = nil dto.OwnerIncomeAmount = nil switch { case userID == order.OwnerID: - ownerAmount := order.OwnerRentAmount - if ownerAmount <= 0 { - ownerAmount = order.RentAmount - } - ownerIncomeAmount := dto.DepositDeductAmount + ownerAmount dto.PriceRole = "owner" dto.DisplayAmount = ownerAmount dto.RentAmount = nil dto.OwnerRentAmount = &ownerAmount dto.OwnerIncomeAmount = &ownerIncomeAmount case userID == order.RenterID: - rentAmount := order.RentAmount - renterRefundAmount := order.DepositAmount - dto.DepositDeductAmount - if renterRefundAmount < 0 { - renterRefundAmount = 0 - } dto.PriceRole = "renter" dto.DisplayAmount = rentAmount dto.RentAmount = &rentAmount diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go new file mode 100644 index 0000000..dd79978 --- /dev/null +++ b/backend/internal/modules/order/repository_test.go @@ -0,0 +1,114 @@ +package order + +import ( + "testing" + + "hfb_sys/backend/internal/model" + + "gorm.io/datatypes" +) + +func TestCalculateCheckoutSettlementRefundsUnusedRent(t *testing.T) { + order := model.RentalOrder{ + RentAmount: 383, + OwnerRentAmount: 353, + DepositAmount: 150, + AccountSnapshot: datatypes.JSON([]byte(`{ + "haf_coin_amount": 100000000, + "asset_summary": { + "price_breakdown": { + "buyer_coin_base_price": 263, + "seller_coin_base_price": 233, + "consumable_price": 120 + } + } + }`)), + } + + settlement := calculateCheckoutSettlement(order, 7, 90, 0) + + if settlement.ActualRentAmount != 244 { + t.Fatalf("ActualRentAmount = %.2f, want 244.00", settlement.ActualRentAmount) + } + if settlement.OwnerRentIncome != 217 { + t.Fatalf("OwnerRentIncome = %.2f, want 217.00", settlement.OwnerRentIncome) + } + if settlement.PlatformFee != 27 { + t.Fatalf("PlatformFee = %.2f, want 27.00", settlement.PlatformFee) + } + if settlement.RentRefund != 139 { + t.Fatalf("RentRefund = %.2f, want 139.00", settlement.RentRefund) + } + if settlement.DepositRefund != 150 { + t.Fatalf("DepositRefund = %.2f, want 150.00", settlement.DepositRefund) + } + if settlement.RenterRefund != 289 { + t.Fatalf("RenterRefund = %.2f, want 289.00", settlement.RenterRefund) + } +} + +func TestCalculateCheckoutSettlementUsesBuyerAndSellerRatiosSeparately(t *testing.T) { + order := model.RentalOrder{ + RentAmount: 383, + OwnerRentAmount: 353, + DepositAmount: 150, + AccountSnapshot: datatypes.JSON([]byte(`{ + "haf_coin_amount": 100000000, + "asset_summary": { + "price_breakdown": { + "buyer_coin_base_price": 263, + "seller_coin_base_price": 233, + "consumable_price": 120 + } + } + }`)), + } + + settlement := calculateCheckoutSettlement(order, 0, 50, 0) + + if settlement.ActualRentAmount != 132 { + t.Fatalf("租客侧实际租金 = %.2f, want 132.00", settlement.ActualRentAmount) + } + if settlement.OwnerRentIncome != 117 { + t.Fatalf("卖家侧租金收入 = %.2f, want 117.00", settlement.OwnerRentIncome) + } + if settlement.PlatformFee != 15 { + t.Fatalf("平台差价 = %.2f, want 15.00", settlement.PlatformFee) + } +} + +func TestCalculateCheckoutSettlementAddsDepositCompensation(t *testing.T) { + order := model.RentalOrder{ + RentAmount: 383, + OwnerRentAmount: 353, + DepositAmount: 150, + AccountSnapshot: datatypes.JSON([]byte(`{ + "haf_coin_amount": 100000000, + "asset_summary": { + "price_breakdown": { + "buyer_coin_base_price": 263, + "seller_coin_base_price": 233, + "consumable_price": 120 + } + } + }`)), + } + + settlement := calculateCheckoutSettlement(order, 120, 100, 30) + + if settlement.ActualRentAmount != 383 { + t.Fatalf("ActualRentAmount = %.2f, want 383.00", settlement.ActualRentAmount) + } + if settlement.OwnerRentIncome != 353 { + t.Fatalf("OwnerRentIncome = %.2f, want 353.00", settlement.OwnerRentIncome) + } + if settlement.DepositCompensation != 30 { + t.Fatalf("DepositCompensation = %.2f, want 30.00", settlement.DepositCompensation) + } + if settlement.OwnerIncome != 383 { + t.Fatalf("OwnerIncome = %.2f, want 383.00", settlement.OwnerIncome) + } + if settlement.RenterRefund != 120 { + t.Fatalf("RenterRefund = %.2f, want 120.00", settlement.RenterRefund) + } +} diff --git a/frontend/src/composables/useAdminTable.ts b/frontend/src/composables/useAdminTable.ts index 9e768cc..6bc80e3 100644 --- a/frontend/src/composables/useAdminTable.ts +++ b/frontend/src/composables/useAdminTable.ts @@ -3,6 +3,7 @@ import { ref, type Ref } from 'vue' interface AdminQueryOptions { fetchFn: () => Promise immediate?: boolean + initialData?: T } interface AdminQueryResult { @@ -15,7 +16,7 @@ interface AdminQueryResult { export function useAdminQuery(options: AdminQueryOptions): AdminQueryResult { const loading = ref(false) const error = ref(null) - const data = ref() as Ref + const data = ref(options.initialData as T) as Ref async function load() { loading.value = true diff --git a/frontend/src/views/account/OrderDetailView.vue b/frontend/src/views/account/OrderDetailView.vue index 7d82a10..1cb0a2c 100644 --- a/frontend/src/views/account/OrderDetailView.vue +++ b/frontend/src/views/account/OrderDetailView.vue @@ -550,7 +550,7 @@ function linesToList(value: string) {
额外消耗品 - 金额合计:¥{{ money(resourceChargeAmount) }} + 已用金额:¥{{ money(resourceChargeAmount) }}
@@ -573,7 +573,7 @@ function linesToList(value: string) { 订单快照 {{ quantity(snapshotHafCoinM) }}M,预计剩余 {{ quantity(remainingHafCoinM) }}M - + @@ -589,10 +589,10 @@ function linesToList(value: string) {

结账明细

-

{{ orderAmountLabel }}:¥{{ money(order.checkout.display_amount) }},押金:¥{{ money(order.checkout.deposit_amount) }}

-

额外消耗品金额:¥{{ money(order.checkout.consumable_amount) }},押金扣除:¥{{ money(order.checkout.deposit_deduct_amount) }}

-

退还租客:¥{{ money(order.checkout.renter_refund_amount) }}

-

号主收入:¥{{ money(order.checkout.owner_income_amount) }}

+

实际结算租金:¥{{ money(order.checkout.display_amount) }},预收押金:¥{{ money(order.checkout.deposit_amount) }}

+

额外消耗品已用:¥{{ money(order.checkout.consumable_amount) }},押金赔付扣除:¥{{ money(order.checkout.deposit_deduct_amount) }}

+

退还租客(未使用租金 + 剩余押金):¥{{ money(order.checkout.renter_refund_amount) }}

+

号主最终收入(租金 + 押金赔付):¥{{ money(order.checkout.owner_income_amount) }}

说明:{{ order.checkout.content }}

修正原因:{{ order.checkout.owner_adjustment_reason }}

@@ -604,13 +604,13 @@ function linesToList(value: string) {

修改结账

- + - + diff --git a/frontend/src/views/admin/AdminListingsView.vue b/frontend/src/views/admin/AdminListingsView.vue index bce7856..3ccc0ba 100644 --- a/frontend/src/views/admin/AdminListingsView.vue +++ b/frontend/src/views/admin/AdminListingsView.vue @@ -17,8 +17,9 @@ const filters = reactive({ limit: 200, }) -const { loading, data: listings, load: loadListings } = useAdminTable({ +const { loading, data: listings, load: loadListings } = useAdminTable({ fetchFn: () => fetchAdminListings(filters), + initialData: [], }) const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length) diff --git a/frontend/src/views/admin/AdminOrdersView.vue b/frontend/src/views/admin/AdminOrdersView.vue index cfccc43..e3834f0 100644 --- a/frontend/src/views/admin/AdminOrdersView.vue +++ b/frontend/src/views/admin/AdminOrdersView.vue @@ -8,8 +8,9 @@ import { formatDateTime } from '@/utils/time' const status = ref('') -const { loading, data: orders, load: loadOrders } = useAdminTable({ +const { loading, data: orders, load: loadOrders } = useAdminTable({ fetchFn: fetchAdminOrders, + initialData: [], }) const filteredOrders = computed(() => { diff --git a/frontend/src/views/mobile/MobileOrderDetailView.vue b/frontend/src/views/mobile/MobileOrderDetailView.vue index 0c3eeaa..d52a295 100644 --- a/frontend/src/views/mobile/MobileOrderDetailView.vue +++ b/frontend/src/views/mobile/MobileOrderDetailView.vue @@ -792,12 +792,12 @@ function getStatusTagType(status: string) {

结账结算账单

- + - - - - + + + + @@ -911,9 +911,9 @@ function getStatusTagType(status: string) {