import { computed, type Ref } from 'vue' import { centToYuan, formatMoney } from '@/shared/utils/money' import type { Checkout, Order } from '../api/orders' export interface CheckoutFormSnapshot { content: string coin_consumed_m: number otherAmountYuan?: number } export interface CounterFormSnapshot { consumableAmountYuan: number coin_consumed_m: number depositDeductAmountYuan: number reason: string evidenceText: string } export interface CheckoutResource { key: string label: string price: string mode: string quantity: number unitPrice: number } /** * 结账内容分隔标记:写在「租客手填备注」与「系统结算信息」之间。 * 提交时写入,展示端据此拆分;历史数据没有标记,按行前缀兜底识别。 */ export const CHECKOUT_SYSTEM_SUMMARY_MARKER = '【系统结算信息·参与结算】' /** 系统结算信息的行前缀(单件消耗品、合计、哈夫币、押金)。 */ const CHECKOUT_CONSUMABLE_PREFIX = '额外消耗品:' const CHECKOUT_CONSUMABLE_TOTAL_PREFIX = '额外消耗品合计:' const CHECKOUT_COIN_PREFIX = '哈夫币实际消耗:' const CHECKOUT_DEPOSIT_PREFIX = '押金赔付扣除:' /** 用于识别未带分隔标记的历史数据。 */ const CHECKOUT_SYSTEM_LINE_PREFIXES = [ CHECKOUT_CONSUMABLE_PREFIX, CHECKOUT_CONSUMABLE_TOTAL_PREFIX, CHECKOUT_COIN_PREFIX, CHECKOUT_DEPOSIT_PREFIX, ] export interface CheckoutContentParts { /** 租客手填备注:仅作双方沟通留言,不参与结算计算。 */ remark: string /** 系统按实际消耗生成的结算信息行。 */ systemLines: string[] /** 系统结算信息整体文本,无则为空串。 */ systemText: string } /** * 拆分结账内容为「手填备注」与「系统结算信息」。 * 备注不参与租金、押金赔付和最终结算金额计算,仅用于沟通留痕。 */ export function splitCheckoutContent(content: string | null | undefined): CheckoutContentParts { const lines = linesToList(String(content || '')) const markerIndex = lines.indexOf(CHECKOUT_SYSTEM_SUMMARY_MARKER) if (markerIndex >= 0) { const systemLines = lines.slice(markerIndex + 1) return { remark: lines.slice(0, markerIndex).join('\n'), systemLines, systemText: systemLines.join('\n'), } } // 兼容旧数据:系统行按固定前缀识别,其余行视为手填备注 const remarkLines: string[] = [] const systemLines: string[] = [] for (const line of lines) { const isSystemLine = CHECKOUT_SYSTEM_LINE_PREFIXES.some(prefix => line.startsWith(prefix)) if (isSystemLine) systemLines.push(line) else remarkLines.push(line) } return { remark: remarkLines.join('\n'), systemLines, systemText: systemLines.join('\n') } } /** 单件额外消耗品明细行。 */ export interface CheckoutSystemItem { /** 消耗品名称,如 6级弹。 */ name: string /** 用量与库存上限,如 3/10;无法解析时为空串。 */ count: string /** 金额文本,如 ¥0.3;赠送品为空串。 */ amount: string /** 是否赠送(不扣款)。 */ free: boolean } /** 系统结算信息结构化结果,用于逐行明细展示。 */ export interface CheckoutSystemSummary { /** 额外消耗品明细 */ items: CheckoutSystemItem[] /** 额外消耗品合计文本,如 ¥1.8 */ totalText: string /** 哈夫币实际消耗,如 150M */ coinValue: string /** 哈夫币剩余 / 打超说明 */ coinNote: string /** 押金赔付扣除金额,如 ¥1.80 */ depositValue: string /** 押金扣除说明 */ depositNote: string /** 未识别出的其它系统行,原样展示 */ extraLines: string[] /** 是否没有任何可结构化的系统信息 */ empty: boolean } /** 解析单件消耗品文本:`6级弹 3/10,金额¥0.3` / `8号弹 2/5,赠送不扣款`。 */ function parseCheckoutConsumableSegment(segment: string): CheckoutSystemItem | null { const text = String(segment || '').trim() if (!text) return null const matched = text.match(/^(.*?)\s*([\d.]+\s*\/\s*[\d.]+)\s*(.*)$/) const count = (matched?.[2] || '').replace(/\s+/g, '') if (!count) return { name: text, count: '', amount: '', free: false } const tail = matched?.[3] || '' const amountMatch = tail.match(/金额¥([\d.]+)/) const amountText = amountMatch?.[1] || '' return { name: (matched?.[1] || text).trim(), count, amount: amountText ? `¥${amountText}` : '', free: !amountText && /赠送/.test(tail), } } /** * 把系统结算信息行解析成可逐行渲染的明细。 * 兼容历史数据:多件消耗品挤在同一行(用「;」分隔)时自动拆行, * 且没有合计行时按各件金额累加补出合计。 */ export function parseCheckoutSystemSummary(lines: unknown): CheckoutSystemSummary { const items: CheckoutSystemItem[] = [] const extraLines: string[] = [] let totalText = '' let coinValue = '' let coinNote = '' let depositValue = '' let depositNote = '' let chargedTotal = 0 let chargedAmountKnown = true for (const rawLine of Array.isArray(lines) ? lines : []) { const line = String(rawLine || '').trim() if (!line) continue if (line.startsWith(CHECKOUT_CONSUMABLE_TOTAL_PREFIX)) { const value = line.slice(CHECKOUT_CONSUMABLE_TOTAL_PREFIX.length).replace(/^¥/, '').trim() totalText = `¥${value}` continue } if (line.startsWith(CHECKOUT_CONSUMABLE_PREFIX)) { for (const segment of line.slice(CHECKOUT_CONSUMABLE_PREFIX.length).split(';')) { const item = parseCheckoutConsumableSegment(segment) if (!item) continue items.push(item) if (item.free) continue const amount = Number(item.amount.replace(/^¥/, '')) if (item.amount && Number.isFinite(amount)) chargedTotal = roundMoney(chargedTotal + amount) else chargedAmountKnown = false } continue } if (line.startsWith(CHECKOUT_COIN_PREFIX)) { const [value = '', ...rest] = line.slice(CHECKOUT_COIN_PREFIX.length).trim().split(',') coinValue = value.trim() coinNote = rest.join(',').trim() continue } if (line.startsWith(CHECKOUT_DEPOSIT_PREFIX)) { const body = line.slice(CHECKOUT_DEPOSIT_PREFIX.length).trim() const matched = body.match(/^(.*?)[((](.*?)[))]?$/) if (matched) { depositValue = (matched[1] || '').trim() depositNote = (matched[2] || '').trim() } else { depositValue = body } continue } extraLines.push(line) } const hasChargedItem = items.some(item => !item.free) if (!totalText && hasChargedItem && chargedAmountKnown) { totalText = `¥${money(chargedTotal)}` } return { items, totalText, coinValue, coinNote, depositValue, depositNote, extraLines, empty: items.length === 0 && !totalText && !coinValue && !depositValue && extraLines.length === 0, } } /** 直接从结账内容读取系统结算信息明细。 */ export function readCheckoutSystemSummary( content: string | null | undefined ): CheckoutSystemSummary { return parseCheckoutSystemSummary(splitCheckoutContent(content).systemLines) } 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(snapshotHafCoinM.value - Number(options.checkoutForm.value.coin_consumed_m || 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, depositDeductAmountYuan: options.checkoutForm.value.otherAmountYuan, checkoutResources: checkoutResources.value, resourceUsage: options.resourceUsage.value, remainingHafCoinM: remainingHafCoinM.value, }) } return { checkoutResources, resourceChargeAmount, snapshotHafCoinM, remainingHafCoinM, hydrateResourceUsage, readResourceUsage, isChargedResource, resourceLineAmount, checkoutContentWithSummary, } } export function readSnapshot(order: Order | null) { const snapshot = order?.account_snapshot return isRecord(snapshot) ? snapshot : null } export function readAssetSummary(order: Order | null) { const summary = readSnapshot(order)?.asset_summary return isRecord(summary) ? summary : null } 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 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 readResourceUsageAmount(resourceUsage: Record, key: string) { return Math.max(Number(resourceUsage[key] || 0), 0) } export function isChargedResource(item: CheckoutResource) { return item.mode !== '赠送' } export function calculateResourceChargeAmount( resources: CheckoutResource[], resourceUsage: Record ) { return roundMoney( resources.reduce((sum, item) => { if (!isChargedResource(item)) return sum const used = readResourceUsageAmount(resourceUsage, item.key) return sum + used * item.unitPrice }, 0) ) } export function buildCheckoutContentWithSummary(options: { content: string coinConsumedM: number depositDeductAmountYuan?: number checkoutResources: CheckoutResource[] resourceUsage: Record remainingHafCoinM: number }) { // 手填备注与系统结算信息分开生成:备注仅为留言,结算信息由平台按实际消耗自动计算 const remark = options.content.trim() const systemLines: string[] = [] const usedResources = options.checkoutResources.filter( item => readResourceUsageAmount(options.resourceUsage, item.key) > 0 ) // 每件消耗品单独一行,便于展示端渲染成明细行并补出合计 let consumableTotalYuan = 0 let hasChargedResource = false for (const item of usedResources) { const used = readResourceUsageAmount(options.resourceUsage, item.key) const charged = isChargedResource(item) const amount = roundMoney(used * item.unitPrice) if (charged) { hasChargedResource = true consumableTotalYuan = roundMoney(consumableTotalYuan + amount) } systemLines.push( `${CHECKOUT_CONSUMABLE_PREFIX}${item.label} ${used}/${item.quantity}${ charged ? `,金额¥${money(amount)}` : ',赠送不扣款' }` ) } if (hasChargedResource) { systemLines.push(`${CHECKOUT_CONSUMABLE_TOTAL_PREFIX}¥${money(consumableTotalYuan)}`) } if (Number(options.coinConsumedM || 0) > 0) { const remain = Number(options.remainingHafCoinM || 0) if (remain >= 0) { systemLines.push( `${CHECKOUT_COIN_PREFIX}${quantity(options.coinConsumedM)}M,预计剩余${quantity(remain)}M` ) } else { systemLines.push( `${CHECKOUT_COIN_PREFIX}${quantity(options.coinConsumedM)}M,打超${quantity(-remain)}M(超额按比例从押金结算)` ) } } const depositDeductAmountYuan = Number(options.depositDeductAmountYuan || 0) if (depositDeductAmountYuan > 0) { systemLines.push( `${CHECKOUT_DEPOSIT_PREFIX}¥${depositDeductAmountYuan.toFixed(2)}(将直接从预收押金中扣除)` ) } if (systemLines.length === 0) { return remark || '租客发起结账。' } return [remark, CHECKOUT_SYSTEM_SUMMARY_MARKER, ...systemLines].filter(Boolean).join('\n') } export function hydrateCounterFormFromCheckout(checkout: Checkout): CounterFormSnapshot { // 号主修正只暴露押金赔付扣除;优先用结算字段,兼容旧单 only other 的情况 const depositDeductYuan = amountYuan( checkout.deposit_deduct_amount_cent > 0 ? checkout.deposit_deduct_amount_cent : checkout.other_amount_cent ) return { consumableAmountYuan: amountYuan(checkout.consumable_amount_cent), coin_consumed_m: checkout.coin_consumed_m, depositDeductAmountYuan: depositDeductYuan, reason: '', evidenceText: '', } } export function calculateCounterTotal(counterForm: { consumableAmountYuan: number depositDeductAmountYuan: number }) { return roundMoney(counterForm.consumableAmountYuan + counterForm.depositDeductAmountYuan) } export function getSnapshotHafCoinM(order: Order | null) { const snapshot = readSnapshot(order) return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000) } export function formatDailyLossText(dailyLossM: number) { const rounded = Math.round(Number(dailyLossM || 0) * 10) / 10 if (rounded <= 0) return '' const text = Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1) return `${text}M/天` } export function orderRentalTerms(order: Order | null) { const dailyLossM = readNumber(readAssetSummary(order)?.daily_loss_m) const coinM = getSnapshotHafCoinM(order) if (dailyLossM <= 0 || coinM <= 0) return { rentalDays: 0, dailyLossText: '' } return { rentalDays: Math.max(1, Math.ceil(coinM / dailyLossM)), dailyLossText: formatDailyLossText(dailyLossM), } } export function orderListRentalTerms( item?: { rental_days?: number; daily_loss_m?: number } | null ) { const rentalDays = Math.ceil(readNumber(item?.rental_days)) const dailyLossText = formatDailyLossText(readNumber(item?.daily_loss_m)) return { rentalDays: rentalDays > 0 ? rentalDays : 0, dailyLossText, } } export function orderEstimatedEndAt(order: Order | null) { if (order?.estimated_end_at) return order.estimated_end_at 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 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: '卖家交接', platform_handoff: '客服代交接', admin_force_handoff: '客服一键交接', renter_checkout: '买家结账', owner_counter_checkout: '卖家反驳结账', platform_checkout_counter: '客服修改结账', renter_confirm_checkout: '买家确认结账', owner_accept_checkout: '卖家接受结账', admin_arbitration: '客服仲裁', dispute_opened: '发起申诉', checkout_dispute_opened: '发起结账争议', platform_checkout_dispute_opened: '客服发起结账争议', dispute_cancelled: '取消申诉', checkout_dispute_cancelled: '取消结账争议', } 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 }