Files
hfb_sys/frontend/src/features/orders/composables/useOrderSnapshot.ts
T
yml2213 832a0628c6 feat(order): 结账备注与系统结算信息分开展示,系统结算信息改为逐行明细
- 结账内容写入分隔标记,展示端拆出「结账备注(不参与结算金额)」与「系统结算信息(参与结算金额)」
- 系统结算信息每件消耗品单独一行并给合计,哈夫币/押金各一行;兼容旧数据(同一行用「;」分隔自动拆行并补合计)
- 新增 CheckoutSystemDetail 明细组件,接入订单交接记录、结账明细(PC/移动端)与客服订单详情、聊天侧栏
- 结账提交表单与二次确认补充「备注不参与结算金额」提示
2026-10-15 18:14:31 +08:00

537 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Order | null>
checkoutForm: Ref<CheckoutFormSnapshot>
resourceUsage: Ref<Record<string, number>>
}) {
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<string, number>
) {
const next: Record<string, number> = {}
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<string, number>, 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<string, number>
) {
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<string, number>
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<string, string> = {
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<string, unknown> {
return typeof value === 'object' && value !== null
}