修复结账金额和后台类型

This commit is contained in:
yml
2026-05-27 10:28:05 +08:00
parent 6fb5de204c
commit c54e3936ab
7 changed files with 311 additions and 48 deletions
+174 -28
View File
@@ -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
@@ -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)
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { ref, type Ref } from 'vue'
interface AdminQueryOptions<T> {
fetchFn: () => Promise<T>
immediate?: boolean
initialData?: T
}
interface AdminQueryResult<T> {
@@ -15,7 +16,7 @@ interface AdminQueryResult<T> {
export function useAdminQuery<T>(options: AdminQueryOptions<T>): AdminQueryResult<T> {
const loading = ref(false)
const error = ref<string | null>(null)
const data = ref<T>() as Ref<T>
const data = ref<T>(options.initialData as T) as Ref<T>
async function load() {
loading.value = true
@@ -550,7 +550,7 @@ function linesToList(value: string) {
<div class="checkout-resource-panel panel-action">
<div class="checkout-resource-head">
<strong>额外消耗品</strong>
<span>金额合计¥{{ money(resourceChargeAmount) }}</span>
<span>已用金额¥{{ money(resourceChargeAmount) }}</span>
</div>
<el-empty v-if="checkoutResources.length === 0" description="订单快照中暂无额外消耗品" />
<div v-for="item in checkoutResources" v-else :key="item.key" class="checkout-resource-row">
@@ -573,7 +573,7 @@ function linesToList(value: string) {
<el-input-number v-model="checkoutForm.coin_consumed_m" class="full-control" :min="0" :max="snapshotHafCoinM" :precision="2" controls-position="right" />
<span class="field-hint">订单快照 {{ quantity(snapshotHafCoinM) }}M预计剩余 {{ quantity(remainingHafCoinM) }}M</span>
</el-form-item>
<el-form-item label="其他扣款(元)">
<el-form-item label="其他押金扣款(元)">
<el-input-number v-model="checkoutForm.other_amount" class="full-control" :min="0" :precision="0" controls-position="right" />
</el-form-item>
</el-form>
@@ -589,10 +589,10 @@ function linesToList(value: string) {
<div v-if="order && order.checkout && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)" class="order-panel">
<h2>结账明细</h2>
<p>{{ orderAmountLabel }}¥{{ money(order.checkout.display_amount) }}押金¥{{ money(order.checkout.deposit_amount) }}</p>
<p>额外消耗品金额¥{{ money(order.checkout.consumable_amount) }}押金扣除¥{{ money(order.checkout.deposit_deduct_amount) }}</p>
<p v-if="isRenter">退还租客¥{{ money(order.checkout.renter_refund_amount) }}</p>
<p v-if="isOwner">号主收入¥{{ money(order.checkout.owner_income_amount) }}</p>
<p>实际结算租金¥{{ money(order.checkout.display_amount) }}预收押金¥{{ money(order.checkout.deposit_amount) }}</p>
<p>额外消耗品已用¥{{ money(order.checkout.consumable_amount) }}押金赔付扣除¥{{ money(order.checkout.deposit_deduct_amount) }}</p>
<p v-if="isRenter">退还租客未使用租金 + 剩余押金¥{{ money(order.checkout.renter_refund_amount) }}</p>
<p v-if="isOwner">号主最终收入租金 + 押金赔付¥{{ money(order.checkout.owner_income_amount) }}</p>
<p v-if="order.checkout.content">说明{{ order.checkout.content }}</p>
<p v-if="order.checkout.owner_adjustment_reason">修正原因{{ order.checkout.owner_adjustment_reason }}</p>
</div>
@@ -604,13 +604,13 @@ function linesToList(value: string) {
<h2 class="panel-action">修改结账</h2>
<el-form class="form-grid" label-position="top">
<el-form-item label="额外消耗品金额(已含租金)">
<el-form-item label="额外消耗品已用金额">
<el-input-number v-model="counterForm.consumable_amount" class="full-control" :min="0" :precision="0" controls-position="right" />
</el-form-item>
<el-form-item label="消耗哈夫币(M">
<el-input-number v-model="counterForm.coin_consumed_m" class="full-control" :min="0" :precision="2" controls-position="right" />
</el-form-item>
<el-form-item label="其他扣款(元)">
<el-form-item label="其他押金扣款(元)">
<el-input-number v-model="counterForm.other_amount" class="full-control" :min="0" :precision="0" controls-position="right" />
</el-form-item>
<el-form-item label="押金扣除(元)">
@@ -17,8 +17,9 @@ const filters = reactive<AdminListingQuery>({
limit: 200,
})
const { loading, data: listings, load: loadListings } = useAdminTable<Listing>({
const { loading, data: listings, load: loadListings } = useAdminTable<Listing[]>({
fetchFn: () => fetchAdminListings(filters),
initialData: [],
})
const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length)
+2 -1
View File
@@ -8,8 +8,9 @@ import { formatDateTime } from '@/utils/time'
const status = ref('')
const { loading, data: orders, load: loadOrders } = useAdminTable<Order>({
const { loading, data: orders, load: loadOrders } = useAdminTable<Order[]>({
fetchFn: fetchAdminOrders,
initialData: [],
})
const filteredOrders = computed(() => {
@@ -792,12 +792,12 @@ function getStatusTagType(status: string) {
<section v-if="order.checkout && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)" class="card-section">
<h3 class="section-title">结账结算账单</h3>
<van-cell-group inset :border="false">
<van-cell :title="orderAmountLabel" :value="`¥${money(order.checkout.display_amount)}`" />
<van-cell title="实际结算租金" :value="`¥${money(order.checkout.display_amount)}`" />
<van-cell title="押金总额" :value="`¥${money(order.checkout.deposit_amount)}`" />
<van-cell title="额外消耗品金额" :value="`¥${money(order.checkout.consumable_amount)}`" />
<van-cell title="押金扣除" :value="`¥${money(order.checkout.deposit_deduct_amount)}`" value-class="red-text" />
<van-cell v-if="isRenter" title="退还租客押金" :value="`¥${money(order.checkout.renter_refund_amount)}`" value-class="green-text" />
<van-cell v-if="isOwner" title="号主最终收" :value="`¥${money(order.checkout.owner_income_amount)}`" value-class="green-text" />
<van-cell title="额外消耗品已用" :value="`¥${money(order.checkout.consumable_amount)}`" />
<van-cell title="押金赔付扣除" :value="`¥${money(order.checkout.deposit_deduct_amount)}`" value-class="red-text" />
<van-cell v-if="isRenter" title="退还租客" :value="`¥${money(order.checkout.renter_refund_amount)}`" value-class="green-text" />
<van-cell v-if="isOwner" title="号主最终收" :value="`¥${money(order.checkout.owner_income_amount)}`" value-class="green-text" />
<van-cell v-if="order.checkout.content" title="结账备注" :label="order.checkout.content" />
<van-cell v-if="order.checkout.owner_adjustment_reason" title="号主修正原因" :label="order.checkout.owner_adjustment_reason" />
</van-cell-group>
@@ -911,9 +911,9 @@ function getStatusTagType(status: string) {
<button class="popup-close" @click="showCounterPopup = false"></button>
</header>
<div class="popup-body">
<p class="form-hint-info">在此调整扣减租客押金或各项损耗的金额提案将交由租客二次确认</p>
<p class="form-hint-info">在此调整实际使用的租金项目和押金赔付金额提案将交由租客二次确认</p>
<van-cell-group :border="false">
<van-field label="消耗损耗款">
<van-field label="额外消耗品已用">
<template #input>
<input v-model.number="counterForm.consumable_amount" type="number" class="custom-inline-input" />
</template>
@@ -923,12 +923,12 @@ function getStatusTagType(status: string) {
<input v-model.number="counterForm.coin_consumed_m" type="number" class="custom-inline-input" />
</template>
</van-field>
<van-field label="其他损失扣款">
<van-field label="其他押金扣款">
<template #input>
<input v-model.number="counterForm.other_amount" type="number" class="custom-inline-input" />
</template>
</van-field>
<van-field label="扣除押金总额" required>
<van-field label="押金赔付扣除" required>
<template #input>
<input v-model.number="counterForm.deposit_deduct_amount" type="number" class="custom-inline-input" />
</template>