diff --git a/backend/internal/modules/order/checkout.go b/backend/internal/modules/order/checkout.go index 91b8270..adc280c 100644 --- a/backend/internal/modules/order/checkout.go +++ b/backend/internal/modules/order/checkout.go @@ -5,7 +5,6 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" - "hfb_sys/backend/internal/modules/wallet" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -228,108 +227,3 @@ func (r *Repository) AcceptCheckout(userID uint64, orderID uint64) error { r.startRefundBestEffort(refund) return nil } - -func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, checkout *model.OrderCheckout, renterContent string) (*refundAction, error) { - listing, account, err := r.lockListingAccountForOrder(tx, order) - if err != nil { - return nil, err - } - now := time.Now() - order.Status = orderStatusCompleted - order.HandoffStatus = handoffStatusReturned - order.SettlementStatus = settlementStatusSettled - order.SettledAt = &now - order.OwnerSettledAt = &now - archiveAssets(listing, account) - orderID := order.ID - settlement := buildCheckoutSettlement(*order, checkout) - - // 卖家收入进入站内钱包;租客资金不进入站内钱包。 - var ownerEntries []wallet.Entry - if settlement.OwnerRentIncomeCent > 0 { - ownerEntries = append(ownerEntries, wallet.Entry{ - UserID: order.OwnerID, - OrderID: &orderID, - Direction: "in", - AmountCent: settlement.OwnerRentIncomeCent, - BalanceType: "available", - BizType: walletBizOwnerIncome, - BizNo: order.OrderNo, - Remark: "订单结账租金收入", - }) - } - if settlement.DepositCompensationCent > 0 { - ownerEntries = append(ownerEntries, wallet.Entry{ - UserID: order.OwnerID, - OrderID: &orderID, - Direction: "in", - AmountCent: settlement.DepositCompensationCent, - BalanceType: "available", - BizType: walletBizDepositCompensation, - BizNo: order.OrderNo, - Remark: "订单结账押金赔付", - }) - } - if len(ownerEntries) > 0 { - if err := wallet.AppendEntries(tx, ownerEntries...); err != nil { - return nil, err - } - } - - var refund *refundAction - renterRefundTotalCent := settlement.RentRefundCent + settlement.DepositRefundCent - if renterRefundTotalCent > 0 { - action, err := r.prepareRefund(order, renterRefundTotalCent, refundBizCheckout, "结账退款原路退还") - if err != nil { - return nil, err - } - refund = action - } - - checkout.RentAmountCent = settlement.ActualRentAmountCent - checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent - checkout.PlatformFeeCent = settlement.PlatformFeeCent - checkout.RenterRefundAmountCent = settlement.RenterRefundCent - checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent - if err := notification.Append(tx, - notification.Entry{ - UserID: order.RenterID, - Type: "settlement", - Title: "订单已完成", - Content: renterContent, - BizType: "order", - BizID: &orderID, - }, - notification.Entry{ - UserID: order.OwnerID, - Type: "settlement", - Title: "订单已完成", - Content: "订单已完成,结账金额已入账。", - BizType: "order", - BizID: &orderID, - }, - ); err != nil { - return nil, err - } - if err := tx.Save(order).Error; err != nil { - return nil, err - } - if err := tx.Save(checkout).Error; err != nil { - return nil, err - } - if err := tx.Save(listing).Error; err != nil { - return nil, err - } - if err := tx.Save(account).Error; err != nil { - return nil, err - } - return refund, nil -} - -func hasOpenCheckout(tx *gorm.DB, orderID uint64) (bool, error) { - var count int64 - err := tx.Model(&model.OrderCheckout{}). - Where("order_id = ? AND status IN ?", orderID, []string{checkoutStatusSubmitted, checkoutStatusCountered, checkoutStatusAccepted, checkoutStatusDisputed}). - Count(&count).Error - return count > 0, err -} diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go new file mode 100644 index 0000000..0e08ca6 --- /dev/null +++ b/backend/internal/modules/order/checkout_finalize.go @@ -0,0 +1,134 @@ +package order + +import ( + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/modules/wallet" + + "gorm.io/gorm" +) + +func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, checkout *model.OrderCheckout, renterContent string) (*refundAction, error) { + listing, account, err := r.lockListingAccountForOrder(tx, order) + if err != nil { + return nil, err + } + now := time.Now() + order.Status = orderStatusCompleted + order.HandoffStatus = handoffStatusReturned + order.SettlementStatus = settlementStatusSettled + order.SettledAt = &now + order.OwnerSettledAt = &now + archiveAssets(listing, account) + + settlement := buildCheckoutSettlement(*order, checkout) + if err := appendCheckoutOwnerIncome(tx, order, settlement); err != nil { + return nil, err + } + refund, err := r.prepareCheckoutRefund(order, settlement) + if err != nil { + return nil, err + } + applyCheckoutSettlement(checkout, settlement) + if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil { + return nil, err + } + if err := saveFinalizedCheckout(tx, order, checkout, listing, account); err != nil { + return nil, err + } + return refund, nil +} + +func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement checkoutSettlement) error { + orderID := order.ID + var ownerEntries []wallet.Entry + if settlement.OwnerRentIncomeCent > 0 { + ownerEntries = append(ownerEntries, wallet.Entry{ + UserID: order.OwnerID, + OrderID: &orderID, + Direction: "in", + AmountCent: settlement.OwnerRentIncomeCent, + BalanceType: "available", + BizType: walletBizOwnerIncome, + BizNo: order.OrderNo, + Remark: "订单结账租金收入", + }) + } + if settlement.DepositCompensationCent > 0 { + ownerEntries = append(ownerEntries, wallet.Entry{ + UserID: order.OwnerID, + OrderID: &orderID, + Direction: "in", + AmountCent: settlement.DepositCompensationCent, + BalanceType: "available", + BizType: walletBizDepositCompensation, + BizNo: order.OrderNo, + Remark: "订单结账押金赔付", + }) + } + if len(ownerEntries) == 0 { + return nil + } + return wallet.AppendEntries(tx, ownerEntries...) +} + +func (r *Repository) prepareCheckoutRefund(order *model.RentalOrder, settlement checkoutSettlement) (*refundAction, error) { + renterRefundTotalCent := settlement.RentRefundCent + settlement.DepositRefundCent + if renterRefundTotalCent <= 0 { + return nil, nil + } + return r.prepareRefund(order, renterRefundTotalCent, refundBizCheckout, "结账退款原路退还") +} + +func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutSettlement) { + checkout.RentAmountCent = settlement.ActualRentAmountCent + checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent + checkout.PlatformFeeCent = settlement.PlatformFeeCent + checkout.RenterRefundAmountCent = settlement.RenterRefundCent + checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent +} + +func appendCheckoutCompletedNotifications(tx *gorm.DB, order *model.RentalOrder, renterContent string) error { + orderID := order.ID + return notification.Append(tx, + notification.Entry{ + UserID: order.RenterID, + Type: "settlement", + Title: "订单已完成", + Content: renterContent, + BizType: "order", + BizID: &orderID, + }, + notification.Entry{ + UserID: order.OwnerID, + Type: "settlement", + Title: "订单已完成", + Content: "订单已完成,结账金额已入账。", + BizType: "order", + BizID: &orderID, + }, + ) +} + +func saveFinalizedCheckout(tx *gorm.DB, order *model.RentalOrder, checkout *model.OrderCheckout, listing *model.RentalListing, account *model.GameAccount) error { + if err := tx.Save(order).Error; err != nil { + return err + } + if err := tx.Save(checkout).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + return tx.Save(account).Error +} + +func hasOpenCheckout(tx *gorm.DB, orderID uint64) (bool, error) { + var count int64 + err := tx.Model(&model.OrderCheckout{}). + Where("order_id = ? AND status IN ?", orderID, []string{checkoutStatusSubmitted, checkoutStatusCountered, checkoutStatusAccepted, checkoutStatusDisputed}). + Count(&count).Error + return count > 0, err +} diff --git a/frontend/src/features/orders/components/OrderCheckoutSummary.vue b/frontend/src/features/orders/components/OrderCheckoutSummary.vue new file mode 100644 index 0000000..e5f8f08 --- /dev/null +++ b/frontend/src/features/orders/components/OrderCheckoutSummary.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/frontend/src/features/orders/components/OrderHandoffTimeline.vue b/frontend/src/features/orders/components/OrderHandoffTimeline.vue new file mode 100644 index 0000000..24f34ba --- /dev/null +++ b/frontend/src/features/orders/components/OrderHandoffTimeline.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/src/features/orders/components/OrderResourceUsageEditor.vue b/frontend/src/features/orders/components/OrderResourceUsageEditor.vue new file mode 100644 index 0000000..ccbc94b --- /dev/null +++ b/frontend/src/features/orders/components/OrderResourceUsageEditor.vue @@ -0,0 +1,547 @@ + + + + + diff --git a/frontend/src/features/orders/composables/useOrderSnapshot.ts b/frontend/src/features/orders/composables/useOrderSnapshot.ts index 96fee4c..9abcf06 100644 --- a/frontend/src/features/orders/composables/useOrderSnapshot.ts +++ b/frontend/src/features/orders/composables/useOrderSnapshot.ts @@ -1,80 +1,194 @@ -import type { Order } from '../api/orders' +import { computed, type Ref } from 'vue' -export interface SnapshotResource { - key: string - label: string - quantity: number - unitPrice: number - chargeMode: '赠送' | '收费' +import { centToYuan, formatMoney } from '@/shared/utils/money' +import type { Checkout, Order } from '../api/orders' + +export interface CheckoutFormSnapshot { + content: string + coin_consumed_m: number } -export function readSnapshot(order: Order | null) { - if (!order?.listing_snapshot) return null - try { - return JSON.parse(order.listing_snapshot) as Record - } catch { - return null +export interface CounterFormSnapshot { + consumableAmountYuan: number + coin_consumed_m: number + otherAmountYuan: number + depositDeductAmountYuan: number + reason: string + evidenceText: string +} + +export interface CheckoutResource { + key: string + label: string + price: string + mode: string + quantity: number + unitPrice: number +} + +export function useOrderCheckoutSnapshot(options: { + order: Ref + checkoutForm: Ref + resourceUsage: Ref> +}) { + const checkoutResources = computed(() => readSnapshotResources(options.order.value)) + const resourceChargeAmount = computed(() => + calculateResourceChargeAmount(checkoutResources.value, options.resourceUsage.value) + ) + const snapshotHafCoinM = computed(() => getSnapshotHafCoinM(options.order.value)) + const remainingHafCoinM = computed(() => + roundQuantity( + Math.max(snapshotHafCoinM.value - Number(options.checkoutForm.value.coin_consumed_m || 0), 0) + ) + ) + + function hydrateResourceUsage() { + options.resourceUsage.value = normalizeResourceUsage( + checkoutResources.value, + options.resourceUsage.value + ) + } + + function readResourceUsage(key: string) { + return readResourceUsageAmount(options.resourceUsage.value, key) + } + + function resourceLineAmount(item: CheckoutResource) { + return roundMoney(readResourceUsage(item.key) * item.unitPrice) + } + + function checkoutContentWithSummary() { + return buildCheckoutContentWithSummary({ + content: options.checkoutForm.value.content, + coinConsumedM: options.checkoutForm.value.coin_consumed_m, + checkoutResources: checkoutResources.value, + resourceUsage: options.resourceUsage.value, + remainingHafCoinM: remainingHafCoinM.value, + }) + } + + return { + checkoutResources, + resourceChargeAmount, + snapshotHafCoinM, + remainingHafCoinM, + hydrateResourceUsage, + readResourceUsage, + isChargedResource, + resourceLineAmount, + checkoutContentWithSummary, } } -export function readNumber(value: unknown): number { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : 0 +export function readSnapshot(order: Order | null) { + const snapshot = order?.account_snapshot + return isRecord(snapshot) ? snapshot : null } -export function roundQuantity(value: number): number { - return Math.round(value * 10) / 10 +export function readAssetSummary(order: Order | null) { + const summary = readSnapshot(order)?.asset_summary + return isRecord(summary) ? summary : null } -export function roundMoney(value: number): number { - return Math.round(value * 100) / 100 +export function readSnapshotResources(order: Order | null): CheckoutResource[] { + const resources = readAssetSummary(order)?.resources + if (!Array.isArray(resources)) return [] + return resources + .filter(isRecord) + .map(item => { + const key = String(item.key || item.label || '') + const label = String(item.label || key || '额外消耗品') + const price = String(item.price || '') + return { + key, + label, + price, + mode: String(item.mode || '收费'), + quantity: readNumber(item.quantity), + unitPrice: readUnitPrice(price), + } + }) + .filter(item => item.key && item.quantity > 0) } -export function readSnapshotResources(order: Order | null): SnapshotResource[] { - const snapshot = readSnapshot(order) - if (!snapshot?.quantities) return [] - - const quantities = snapshot.quantities as Record[] - return quantities - .map(item => ({ - key: String(item.key || ''), - label: String(item.label || ''), - quantity: readNumber(item.quantity), - unitPrice: readNumber(item.price), - chargeMode: (item.charge_mode === '收费' ? '收费' : '赠送') as SnapshotResource['chargeMode'], - })) - .filter(item => item.key && item.label) +export function normalizeResourceUsage( + resources: CheckoutResource[], + current: Record +) { + const next: Record = {} + for (const item of resources) { + next[item.key] = Math.min(Math.max(Number(current[item.key] || 0), 0), item.quantity) + } + return next } -export function isChargedResource(resource: SnapshotResource): boolean { - return resource.chargeMode === '收费' +export function readResourceUsageAmount(resourceUsage: Record, key: string) { + return Math.max(Number(resourceUsage[key] || 0), 0) } -export function getSnapshotHafCoinM(order: Order | null): number { - const snapshot = readSnapshot(order) - return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000) +export function isChargedResource(item: CheckoutResource) { + return item.mode !== '赠送' } export function calculateResourceChargeAmount( - resources: SnapshotResource[], + resources: CheckoutResource[], resourceUsage: Record -): number { +) { return roundMoney( resources.reduce((sum, item) => { if (!isChargedResource(item)) return sum - const used = resourceUsage[item.key] || 0 + const used = readResourceUsageAmount(resourceUsage, item.key) return sum + used * item.unitPrice }, 0) ) } -export function calculateCheckoutTotal( - resourceCharge: number, - consumableAmount: number, - coinConsumed: number, - otherAmount: number -): number { - return roundMoney(resourceCharge + consumableAmount + coinConsumed + otherAmount) +export function buildCheckoutContentWithSummary(options: { + content: string + coinConsumedM: number + checkoutResources: CheckoutResource[] + resourceUsage: Record + remainingHafCoinM: number +}) { + const lines = [options.content.trim()].filter(Boolean) + const usedResources = options.checkoutResources.filter( + item => readResourceUsageAmount(options.resourceUsage, item.key) > 0 + ) + if (usedResources.length) { + lines.push( + `额外消耗品:${usedResources + .map(item => { + const used = readResourceUsageAmount(options.resourceUsage, item.key) + const amount = roundMoney(used * item.unitPrice) + return `${item.label} ${used}/${item.quantity}${ + isChargedResource(item) ? `,金额¥${money(amount)}` : ',赠送不扣款' + }` + }) + .join(';')}` + ) + } + if (Number(options.coinConsumedM || 0) > 0) { + lines.push( + `哈夫币消耗:${quantity(options.coinConsumedM)}M,预计剩余${quantity( + options.remainingHafCoinM + )}M` + ) + } + if (lines.length === 0) { + lines.push('租客发起结账。') + } + return lines.join('\n') +} + +export function hydrateCounterFormFromCheckout(checkout: Checkout): CounterFormSnapshot { + return { + consumableAmountYuan: amountYuan(checkout.consumable_amount_cent), + coin_consumed_m: checkout.coin_consumed_m, + otherAmountYuan: amountYuan(checkout.other_amount_cent), + depositDeductAmountYuan: amountYuan(checkout.deposit_deduct_amount_cent), + reason: '', + evidenceText: '', + } } export function calculateCounterTotal(counterForm: { @@ -82,7 +196,7 @@ export function calculateCounterTotal(counterForm: { coin_consumed_m: number otherAmountYuan: number depositDeductAmountYuan: number -}): number { +}) { return roundMoney( counterForm.consumableAmountYuan + counterForm.coin_consumed_m + @@ -91,57 +205,90 @@ export function calculateCounterTotal(counterForm: { ) } -export function hydrateResourceUsageFromOrder( - order: Order | null, - resources: SnapshotResource[] -): Record { - const usage: Record = {} - - if (!order?.checkout_info) { - return usage - } - - try { - const info = JSON.parse(order.checkout_info) as Record - const consumedResources = info.consumed_resources as Record | undefined - - if (consumedResources) { - resources.forEach(res => { - if (res.key in consumedResources) { - usage[res.key] = consumedResources[res.key] ?? 0 - } - }) - } - } catch { - // ignore - } - - return usage +export function getSnapshotHafCoinM(order: Order | null) { + const snapshot = readSnapshot(order) + return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000) } -export function hydrateCounterFormFromOrder(order: Order | null) { - if (!order?.counter_info) { - return null - } - - try { - const info = JSON.parse(order.counter_info) as Record - return { - consumableAmountYuan: readNumber(info.consumableAmountYuan), - coin_consumed_m: readNumber(info.coin_consumed_m), - otherAmountYuan: readNumber(info.otherAmountYuan), - depositDeductAmountYuan: readNumber(info.depositDeductAmountYuan), - reason: String(info.reason || ''), - evidenceText: String(info.evidence || ''), - } - } catch { - return null - } +export function orderEstimatedEndAt(order: Order | null) { + const rentedAt = order?.rented_at + const durationHours = Number(order?.estimated_duration_hours || 0) + if (!rentedAt || durationHours <= 0) return undefined + return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString() } -export function readError(error: unknown, fallback: string): string { - if (error && typeof error === 'object' && 'message' in error) { - return String(error.message) +export function orderRentAmount(item: Order, userID: number | undefined | null) { + if (item.owner_id === userID) return amountYuan(item.owner_rent_amount_cent) + if (item.renter_id === userID) return amountYuan(item.rent_amount_cent) + return amountYuan(item.display_amount_cent) +} + +export function ownerActualIncome(item: Order, userID: number | undefined | null) { + if (item.owner_id !== userID) return null + const value = item.checkout?.owner_income_amount_cent + return typeof value === 'number' ? centToYuan(value) : null +} + +export function formatHandoffRecordType(type: string) { + const typeMap: Record = { + owner_handoff: '卖家交接', + renter_checkout: '买家结账', + owner_counter_checkout: '卖家反驳结账', + renter_confirm_checkout: '买家确认结账', + owner_accept_checkout: '卖家接受结账', + admin_arbitration: '客服仲裁', } - return fallback + return typeMap[type] || type +} + +export function linesToList(value: string) { + return value + .split('\n') + .map(item => item.trim()) + .filter(Boolean) +} + +export function money(value: unknown) { + return formatMoney(readNumber(value)) +} + +export function amountYuan(cent: unknown) { + if (cent !== undefined && cent !== null) return centToYuan(readNumber(cent)) + return 0 +} + +export function quantity(value: unknown) { + const rounded = roundQuantity(readNumber(value)) + return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2) +} + +export function readUnitPrice(priceText: string) { + const normalized = priceText.replace(/,/g, ',').trim() + const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/) + if (fractionMatch) { + const amount = Number(fractionMatch[1]) + const count = Number(fractionMatch[2]) + return count > 0 ? roundMoney(amount / count) : 0 + } + const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/) + if (singleMatch) return Number(singleMatch[1]) + const fallback = normalized.match(/(\d+(?:\.\d+)?)/) + return fallback ? Number(fallback[1]) : 0 +} + +export function roundMoney(value: number) { + return Math.round(Number(value || 0) * 10) / 10 +} + +export function roundQuantity(value: number) { + return Math.round(value * 100) / 100 +} + +export function readNumber(value: unknown) { + const number = Number(value || 0) + return Number.isFinite(number) ? number : 0 +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null } diff --git a/frontend/src/features/orders/index.ts b/frontend/src/features/orders/index.ts index e8924a8..76cb1ab 100644 --- a/frontend/src/features/orders/index.ts +++ b/frontend/src/features/orders/index.ts @@ -4,3 +4,6 @@ export * from './composables/useOrderDetail' export * from './composables/useOrderSnapshot' export * from './composables/usePaymentPolling' export * from './composables/useSettlement' +export { default as OrderCheckoutSummary } from './components/OrderCheckoutSummary.vue' +export { default as OrderHandoffTimeline } from './components/OrderHandoffTimeline.vue' +export { default as OrderResourceUsageEditor } from './components/OrderResourceUsageEditor.vue' diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue index dea0793..9920696 100644 --- a/frontend/src/features/orders/views/MobileOrderDetailView.vue +++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue @@ -6,7 +6,7 @@ import { showToast, showDialog } from 'vant' import { fetchOrderChat } from '@/features/chats/api/chats' import { createDispute } from '@/features/disputes/api/disputes' import { uploadFile } from '@/shared/api/files' -import { centToYuan, formatMoney } from '@/shared/utils/money' +import { readError } from '@/shared/utils/error' import { acceptCheckout, cancelOrder, @@ -18,10 +18,25 @@ import { startOrderPayment, submitCheckout, submitHandoff, + OrderCheckoutSummary, + OrderHandoffTimeline, + OrderResourceUsageEditor, type HandoffRecord, type Order, type PaymentOrder, -} from '@/features/orders/api/orders' +} from '@/features/orders' +import { + amountYuan, + hydrateCounterFormFromCheckout, + linesToList, + money, + orderEstimatedEndAt as readOrderEstimatedEndAt, + orderRentAmount, + ownerActualIncome, + readAssetSummary as readOrderAssetSummary, + readSnapshot as readOrderSnapshot, + useOrderCheckoutSnapshot, +} from '@/features/orders/composables/useOrderSnapshot' import { useSessionStore } from '@/stores/session' import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' @@ -79,9 +94,11 @@ const showRejectPopup = ref(false) const isOwner = computed(() => order.value?.owner_id === session.userId) const isRenter = computed(() => order.value?.renter_id === session.userId) const orderAmountLabel = computed(() => (isOwner.value ? '预计租金' : '支付租金')) -const orderRentDisplayAmount = computed(() => (order.value ? orderRentAmount(order.value) : 0)) +const orderRentDisplayAmount = computed(() => + order.value ? orderRentAmount(order.value, session.userId) : 0 +) const ownerIncomeDisplayAmount = computed(() => - order.value ? ownerActualIncome(order.value) : null + order.value ? ownerActualIncome(order.value, session.userId) : null ) const ownerIncomeLabel = computed(() => order.value?.status === 'completed' ? '实际到手' : '结账预计到手' @@ -103,28 +120,15 @@ const isCheckoutDisputeStage = computed(() => { ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status) ) }) -const checkoutResources = computed(() => { - const resources = readSnapshotResources() - return resources.filter(item => item.quantity > 0) -}) -const resourceChargeAmount = computed(() => { - return roundMoney( - checkoutResources.value.reduce((sum, item) => { - if (!isChargedResource(item)) return sum - const used = readResourceUsage(item.key) - return sum + used * item.unitPrice - }, 0) - ) -}) -const snapshotHafCoinM = computed(() => { - const snapshot = readSnapshot() - return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000) -}) -const remainingHafCoinM = computed(() => { - return roundQuantity( - Math.max(snapshotHafCoinM.value - Number(checkoutForm.value.coin_consumed_m || 0), 0) - ) -}) +const { + checkoutResources, + resourceChargeAmount, + snapshotHafCoinM, + remainingHafCoinM, + hydrateResourceUsage, + resourceLineAmount, + checkoutContentWithSummary, +} = useOrderCheckoutSnapshot({ order, checkoutForm, resourceUsage }) onMounted(loadOrder) @@ -406,203 +410,25 @@ async function handleCounterEvidenceUpload(event: Event) { } } -function readError(error: unknown, fallback: string) { - if (typeof error === 'object' && error && 'response' in error) { - const response = (error as { response?: { data?: { message?: string } } }).response - return response?.data?.message || fallback - } - return fallback +function orderEstimatedEndAt() { + return readOrderEstimatedEndAt(order.value) } function orderRentedAt() { return order.value?.rented_at } -function orderEstimatedEndAt() { - if (!order.value) return undefined - const rentedAt = orderRentedAt() - const durationHours = Number(order.value.estimated_duration_hours || 0) - if (!rentedAt || durationHours <= 0) return undefined - return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString() +function readSnapshot() { + return readOrderSnapshot(order.value) as Record | null } -interface CheckoutResource { - key: string - label: string - price: string - mode: string - quantity: number - unitPrice: number -} - -function readSnapshot(): any { - const snapshot = order.value?.account_snapshot - if (isRecord(snapshot)) return snapshot - return null -} - -function readAssetSummary(): any { - const summary = readSnapshot()?.asset_summary - if (isRecord(summary)) return summary - return null -} - -function readSnapshotResources(): CheckoutResource[] { - const resources = readAssetSummary()?.resources - if (!Array.isArray(resources)) return [] - return resources - .filter(isRecord) - .map(item => { - const key = String(item.key || item.label || '') - const label = String(item.label || key || '额外消耗品') - const price = String(item.price || '') - return { - key, - label, - price, - mode: String(item.mode || '收费'), - quantity: readNumber(item.quantity), - unitPrice: readUnitPrice(price), - } - }) - .filter(item => item.key && item.quantity > 0) -} - -function hydrateResourceUsage() { - const next: Record = {} - for (const item of checkoutResources.value) { - next[item.key] = Math.min( - Math.max(Number(resourceUsage.value[item.key] || 0), 0), - item.quantity - ) - } - resourceUsage.value = next -} - -function readResourceUsage(key: string) { - return Math.max(Number(resourceUsage.value[key] || 0), 0) -} - -function isChargedResource(item: CheckoutResource) { - return item.mode !== '赠送' -} - -function resourceLineAmount(item: CheckoutResource) { - return roundMoney(readResourceUsage(item.key) * item.unitPrice) -} - -function checkoutContentWithSummary() { - const lines = [checkoutForm.value.content.trim()].filter(Boolean) - const usedResources = checkoutResources.value.filter(item => readResourceUsage(item.key) > 0) - if (usedResources.length) { - lines.push( - `额外消耗品:${usedResources - .map( - item => - `${item.label} ${readResourceUsage(item.key)}/${item.quantity}${ - isChargedResource(item) ? `,金额¥${money(resourceLineAmount(item))}` : ',赠送不扣款' - }` - ) - .join(';')}` - ) - } - if (Number(checkoutForm.value.coin_consumed_m || 0) > 0) { - lines.push( - `哈夫币消耗:${quantity(Number(checkoutForm.value.coin_consumed_m))}M,预计剩余${quantity( - remainingHafCoinM.value - )}M` - ) - } - if (lines.length === 0) { - lines.push('租客发起结账。') - } - return lines.join('\n') -} - -function readUnitPrice(priceText: string) { - const normalized = priceText.replace(/,/g, ',').trim() - const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/) - if (fractionMatch) { - const amount = Number(fractionMatch[1]) - const count = Number(fractionMatch[2]) - return count > 0 ? roundMoney(amount / count) : 0 - } - const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/) - if (singleMatch) return Number(singleMatch[1]) - const fallback = normalized.match(/(\d+(?:\.\d+)?)/) - return fallback ? Number(fallback[1]) : 0 -} - -function roundMoney(value: number) { - return Math.round(Number(value || 0) * 10) / 10 -} - -function roundQuantity(value: number) { - return Math.round(value * 100) / 100 -} - -function money(value: unknown) { - return formatMoney(readNumber(value)) -} - -function formatHandoffRecordType(type: string) { - const typeMap: Record = { - owner_handoff: '卖家交接', - renter_checkout: '买家结账', - owner_counter_checkout: '卖家反驳结账', - renter_confirm_checkout: '买家确认结账', - owner_accept_checkout: '卖家接受结账', - admin_arbitration: '客服仲裁', - } - return typeMap[type] || type -} - -function amountYuan(cent: unknown) { - if (cent !== undefined && cent !== null) return centToYuan(readNumber(cent)) - return 0 -} - -function orderRentAmount(item: Order) { - if (item.owner_id === session.userId) return amountYuan(item.owner_rent_amount_cent) - if (item.renter_id === session.userId) return amountYuan(item.rent_amount_cent) - return amountYuan(item.display_amount_cent) -} - -function ownerActualIncome(item: Order) { - if (item.owner_id !== session.userId) return null - const value = item.checkout?.owner_income_amount_cent - if (typeof value === 'number') return centToYuan(value) - return null -} - -function quantity(value: unknown) { - const rounded = roundQuantity(readNumber(value)) - return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2) -} - -function readNumber(value: unknown) { - const number = Number(value || 0) - return Number.isFinite(number) ? number : 0 -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null +function readAssetSummary() { + return readOrderAssetSummary(order.value) as Record | null } function hydrateCounterForm() { if (!order.value?.checkout) return - const checkout = order.value.checkout - counterForm.value.consumableAmountYuan = amountYuan(checkout.consumable_amount_cent) - counterForm.value.coin_consumed_m = checkout.coin_consumed_m - counterForm.value.otherAmountYuan = amountYuan(checkout.other_amount_cent) - counterForm.value.depositDeductAmountYuan = amountYuan(checkout.deposit_deduct_amount_cent) -} - -function linesToList(value: string) { - return value - .split('\n') - .map(item => item.trim()) - .filter(Boolean) + counterForm.value = hydrateCounterFormFromCheckout(order.value.checkout) } async function openOrderChat() { @@ -803,187 +629,38 @@ async function copyListingCode() { -
-

交接日志 · 商品编号 {{ listingCode }}

- -
-
-
-
- {{ formatHandoffRecordType(record.type) }} -

{{ record.content }}

- {{ formatDateTime(record.created_at) }} -
-
-
-
+ -
-

退号发起结账

-

使用完毕,请输入在此期间消耗的物资与哈夫币进行结账申请。

- - - - -
- 额外物资消耗登记 -
-
- {{ item.label }} - 上限 {{ item.quantity }} · {{ item.mode }} · {{ item.price }} -
-
- - ¥{{ money(resourceLineAmount(item)) }} -
-
- -
- - - - - -
- 订单快照 {{ quantity(snapshotHafCoinM) }}M,预计剩余 {{ quantity(remainingHafCoinM) }}M -
- - - -
仅填写封禁、违规、资产损坏等需要从押金赔付的费用。
-
-
-
- 额外消耗品已用 - ¥{{ money(resourceChargeAmount) }} - 计入实际结算租金 -
-
- 其他押金赔付 - ¥{{ money(checkoutForm.otherAmountYuan) }} - 从押金赔付扣除 -
-
- - - - - 提交结账归还 - -
+ v-model:checkout-form="checkoutForm" + v-model:resource-usage="resourceUsage" + variant="mobile" + :resources="checkoutResources" + :resource-charge-amount="resourceChargeAmount" + :snapshot-haf-coin-m="snapshotHafCoinM" + :remaining-haf-coin-m="remainingHafCoinM" + :returning="returning" + :resource-line-amount="resourceLineAmount" + @submit="handleSubmitCheckout" + /> -
-

结账结算账单

- - - - - - - - - - -
+ variant="mobile" + :order="order" + :is-owner="isOwner" + :is-renter="isRenter" + />
order.value?.owner_id === session.userId) const isRenter = computed(() => order.value?.renter_id === session.userId) const orderAmountLabel = computed(() => (isOwner.value ? '预计租金' : '支付租金')) -const orderRentDisplayAmount = computed(() => (order.value ? orderRentAmount(order.value) : 0)) +const orderRentDisplayAmount = computed(() => + order.value ? orderRentAmount(order.value, session.userId) : 0 +) const ownerIncomeDisplayAmount = computed(() => - order.value ? ownerActualIncome(order.value) : null + order.value ? ownerActualIncome(order.value, session.userId) : null ) const ownerIncomeLabel = computed(() => order.value?.status === 'completed' ? '实际到手' : '结账预计到手' @@ -108,28 +122,15 @@ const isCheckoutDisputeStage = computed(() => { ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status) ) }) -const checkoutResources = computed(() => { - const resources = readSnapshotResources() - return resources.filter(item => item.quantity > 0) -}) -const resourceChargeAmount = computed(() => { - return roundMoney( - checkoutResources.value.reduce((sum, item) => { - if (!isChargedResource(item)) return sum - const used = readResourceUsage(item.key) - return sum + used * item.unitPrice - }, 0) - ) -}) -const snapshotHafCoinM = computed(() => { - const snapshot = readSnapshot() - return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000) -}) -const remainingHafCoinM = computed(() => { - return roundQuantity( - Math.max(snapshotHafCoinM.value - Number(checkoutForm.value.coin_consumed_m || 0), 0) - ) -}) +const { + checkoutResources, + resourceChargeAmount, + snapshotHafCoinM, + remainingHafCoinM, + hydrateResourceUsage, + resourceLineAmount, + checkoutContentWithSummary, +} = useOrderCheckoutSnapshot({ order, checkoutForm, resourceUsage }) function getOrderStep(status: string) { const stepMap: Record = { @@ -467,191 +468,17 @@ async function openOrderChat() { } } -function readError(error: unknown, fallback: string) { - if (typeof error === 'object' && error && 'response' in error) { - const response = (error as { response?: { data?: { message?: string } } }).response - return response?.data?.message || fallback - } - return fallback +function orderEstimatedEndAt() { + return readOrderEstimatedEndAt(order.value) } function orderRentedAt() { return order.value?.rented_at } -function orderEstimatedEndAt() { - if (!order.value) return undefined - const rentedAt = orderRentedAt() - const durationHours = Number(order.value.estimated_duration_hours || 0) - if (!rentedAt || durationHours <= 0) return undefined - return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString() -} - -interface CheckoutResource { - key: string - label: string - price: string - mode: string - quantity: number - unitPrice: number -} - -function readSnapshot() { - const snapshot = order.value?.account_snapshot - if (isRecord(snapshot)) return snapshot - return null -} - -function readAssetSummary() { - const summary = readSnapshot()?.asset_summary - if (isRecord(summary)) return summary - return null -} - -function readSnapshotResources(): CheckoutResource[] { - const resources = readAssetSummary()?.resources - if (!Array.isArray(resources)) return [] - return resources - .filter(isRecord) - .map(item => { - const key = String(item.key || item.label || '') - const label = String(item.label || key || '额外消耗品') - const price = String(item.price || '') - return { - key, - label, - price, - mode: String(item.mode || '收费'), - quantity: readNumber(item.quantity), - unitPrice: readUnitPrice(price), - } - }) - .filter(item => item.key && item.quantity > 0) -} - -function hydrateResourceUsage() { - const next: Record = {} - for (const item of checkoutResources.value) { - next[item.key] = Math.min( - Math.max(Number(resourceUsage.value[item.key] || 0), 0), - item.quantity - ) - } - resourceUsage.value = next -} - -function readResourceUsage(key: string) { - return Math.max(Number(resourceUsage.value[key] || 0), 0) -} - -function isChargedResource(item: CheckoutResource) { - return item.mode !== '赠送' -} - -function resourceLineAmount(item: CheckoutResource) { - return roundMoney(readResourceUsage(item.key) * item.unitPrice) -} - -function checkoutContentWithSummary() { - const lines = [checkoutForm.value.content.trim()].filter(Boolean) - const usedResources = checkoutResources.value.filter(item => readResourceUsage(item.key) > 0) - if (usedResources.length) { - lines.push( - `额外消耗品:${usedResources - .map( - item => - `${item.label} ${readResourceUsage(item.key)}/${item.quantity}${ - isChargedResource(item) ? `,金额¥${money(resourceLineAmount(item))}` : ',赠送不扣款' - }` - ) - .join(';')}` - ) - } - if (Number(checkoutForm.value.coin_consumed_m || 0) > 0) { - lines.push( - `哈夫币消耗:${quantity(Number(checkoutForm.value.coin_consumed_m))}M,预计剩余${quantity( - remainingHafCoinM.value - )}M` - ) - } - if (lines.length === 0) { - lines.push('租客发起结账。') - } - return lines.join('\n') -} - -function readUnitPrice(priceText: string) { - const normalized = priceText.replace(/,/g, ',').trim() - const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/) - if (fractionMatch) { - const amount = Number(fractionMatch[1]) - const count = Number(fractionMatch[2]) - return count > 0 ? roundMoney(amount / count) : 0 - } - const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/) - if (singleMatch) return Number(singleMatch[1]) - const fallback = normalized.match(/(\d+(?:\.\d+)?)/) - return fallback ? Number(fallback[1]) : 0 -} - -function roundMoney(value: number) { - return Math.round(Number(value || 0) * 10) / 10 -} - -function roundQuantity(value: number) { - return Math.round(value * 100) / 100 -} - -function money(value: unknown) { - return formatMoney(readNumber(value)) -} - -function amountYuan(cent: unknown) { - if (cent !== undefined && cent !== null) return centToYuan(readNumber(cent)) - return 0 -} - -function orderRentAmount(item: Order) { - if (item.owner_id === session.userId) return amountYuan(item.owner_rent_amount_cent) - if (item.renter_id === session.userId) return amountYuan(item.rent_amount_cent) - return amountYuan(item.display_amount_cent) -} - -function ownerActualIncome(item: Order) { - if (item.owner_id !== session.userId) return null - const value = item.checkout?.owner_income_amount_cent - if (typeof value === 'number') return centToYuan(value) - return null -} - -function quantity(value: unknown) { - const rounded = roundQuantity(readNumber(value)) - return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2) -} - -function readNumber(value: unknown) { - const number = Number(value || 0) - return Number.isFinite(number) ? number : 0 -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - function hydrateCounterForm() { if (!order.value?.checkout) return - const checkout = order.value.checkout - counterForm.value.consumableAmountYuan = amountYuan(checkout.consumable_amount_cent) - counterForm.value.coin_consumed_m = checkout.coin_consumed_m - counterForm.value.otherAmountYuan = amountYuan(checkout.other_amount_cent) - counterForm.value.depositDeductAmountYuan = amountYuan(checkout.deposit_deduct_amount_cent) -} - -function linesToList(value: string) { - return value - .split('\n') - .map(item => item.trim()) - .filter(Boolean) + counterForm.value = hydrateCounterFormFromCheckout(order.value.checkout) } function scrollToDispute() { @@ -682,18 +509,6 @@ function scrollToRejectCheckout() { } } -function formatHandoffRecordType(type: string) { - const typeMap: Record = { - owner_handoff: '卖家交接', - renter_checkout: '买家结账', - owner_counter_checkout: '卖家反驳结账', - renter_confirm_checkout: '买家确认结账', - owner_accept_checkout: '卖家接受结账', - admin_arbitration: '客服仲裁', - } - return typeMap[type] || type -} - async function copyListingCode() { if (!order.value) return try { @@ -826,189 +641,31 @@ async function copyListingCode() { -
-
-

交接记录

- 商品编号 {{ listingCode }} -
- - - -
-
{{ formatHandoffRecordType(record.type) }}
-
{{ record.content }}
-
-
-
-
+ -
-
-

发起结账

-
-
- -
-
- 额外消耗品 - 已用金额:¥{{ money(resourceChargeAmount) }} -
- -
-
- {{ item.label }} - 库存 {{ item.quantity }},{{ item.mode }},{{ - item.price || '未设置单价' - }} -
- - ¥{{ money(resourceLineAmount(item)) }} -
-
- - - - 订单快照 {{ quantity(snapshotHafCoinM) }}M,预计剩余 - {{ quantity(remainingHafCoinM) }}M - - - - 不含上方额外消耗品;仅填写封禁、违规、资产损坏等需要从押金赔付的费用。 - - -
-
- 额外消耗品已用 - ¥{{ money(resourceChargeAmount) }} - 按上方物资数量自动计算,计入实际结算租金 -
-
- 其他押金赔付 - ¥{{ money(checkoutForm.otherAmountYuan) }} - 作为押金赔付扣除,和额外消耗品分开展示 -
-
- - 发起结账 -
-
+ v-model:checkout-form="checkoutForm" + v-model:resource-usage="resourceUsage" + :resources="checkoutResources" + :resource-charge-amount="resourceChargeAmount" + :snapshot-haf-coin-m="snapshotHafCoinM" + :remaining-haf-coin-m="remainingHafCoinM" + :returning="returning" + :resource-line-amount="resourceLineAmount" + @submit="handleSubmitCheckout" + /> -
-
-

结账明细

-
-
-
- 实际结算租金 - ¥{{ money(amountYuan(order.checkout.display_amount_cent)) }} -
-
- 预收押金 - - ¥{{ money(amountYuan(order.checkout.deposit_amount_cent)) }} - 已免押 ¥{{ money(amountYuan(order.deposit_waived_amount_cent)) }} - -
-
- 额外消耗品已用 - ¥{{ money(amountYuan(order.checkout.consumable_amount_cent)) }} -
-
- 押金赔付扣除 - ¥{{ money(amountYuan(order.checkout.deposit_deduct_amount_cent)) }} -
-
- 退还租客(未使用租金 + 剩余押金) - ¥{{ money(amountYuan(order.checkout.renter_refund_amount_cent)) }} -
-
- 号主最终收入(租金 + 押金赔付) - ¥{{ money(amountYuan(order.checkout.owner_income_amount_cent)) }} -
-
- -

{{ order.checkout.content }}

-
-
- -

{{ order.checkout.owner_adjustment_reason }}

-
-
-
+ :order="order" + :is-owner="isOwner" + :is-renter="isRenter" + />