feat(order): 结账备注与系统结算信息分开展示,系统结算信息改为逐行明细

- 结账内容写入分隔标记,展示端拆出「结账备注(不参与结算金额)」与「系统结算信息(参与结算金额)」
- 系统结算信息每件消耗品单独一行并给合计,哈夫币/押金各一行;兼容旧数据(同一行用「;」分隔自动拆行并补合计)
- 新增 CheckoutSystemDetail 明细组件,接入订单交接记录、结账明细(PC/移动端)与客服订单详情、聊天侧栏
- 结账提交表单与二次确认补充「备注不参与结算金额」提示
This commit is contained in:
yml2213
2026-10-15 18:14:31 +08:00
parent 93e102b7c0
commit 832a0628c6
13 changed files with 911 additions and 56 deletions
@@ -26,6 +26,192 @@ export interface CheckoutResource {
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>
@@ -151,43 +337,54 @@ export function buildCheckoutContentWithSummary(options: {
resourceUsage: Record<string, number>
remainingHafCoinM: number
}) {
const lines = [options.content.trim()].filter(Boolean)
// 手填备注与系统结算信息分开生成:备注仅为留言,结算信息由平台按实际消耗自动计算
const remark = options.content.trim()
const systemLines: string[] = []
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(';')}`
// 每件消耗品单独一行,便于展示端渲染成明细行并补出合计
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) {
lines.push(
`哈夫币实际消耗:${quantity(options.coinConsumedM)}M,预计剩余${quantity(remain)}M`
systemLines.push(
`${CHECKOUT_COIN_PREFIX}${quantity(options.coinConsumedM)}M,预计剩余${quantity(remain)}M`
)
} else {
lines.push(
`哈夫币实际消耗:${quantity(options.coinConsumedM)}M,打超${quantity(-remain)}M(超额按比例从押金结算)`
systemLines.push(
`${CHECKOUT_COIN_PREFIX}${quantity(options.coinConsumedM)}M,打超${quantity(-remain)}M(超额按比例从押金结算)`
)
}
}
const depositDeductAmountYuan = Number(options.depositDeductAmountYuan || 0)
if (depositDeductAmountYuan > 0) {
lines.push(`押金赔付扣除:¥${depositDeductAmountYuan.toFixed(2)}(将直接从预收押金中扣除)`)
systemLines.push(
`${CHECKOUT_DEPOSIT_PREFIX}¥${depositDeductAmountYuan.toFixed(2)}(将直接从预收押金中扣除)`
)
}
if (lines.length === 0) {
lines.push('租客发起结账。')
if (systemLines.length === 0) {
return remark || '租客发起结账。'
}
return lines.join('\n')
return [remark, CHECKOUT_SYSTEM_SUMMARY_MARKER, ...systemLines].filter(Boolean).join('\n')
}
export function hydrateCounterFormFromCheckout(checkout: Checkout): CounterFormSnapshot {