- 结账内容写入分隔标记,展示端拆出「结账备注(不参与结算金额)」与「系统结算信息(参与结算金额)」 - 系统结算信息每件消耗品单独一行并给合计,哈夫币/押金各一行;兼容旧数据(同一行用「;」分隔自动拆行并补合计) - 新增 CheckoutSystemDetail 明细组件,接入订单交接记录、结账明细(PC/移动端)与客服订单详情、聊天侧栏 - 结账提交表单与二次确认补充「备注不参与结算金额」提示
537 lines
18 KiB
TypeScript
537 lines
18 KiB
TypeScript
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
|
||
}
|