Files
X-Financial/web/src/views/scripts/useTravelReimbursementComposerTools.js

572 lines
20 KiB
JavaScript
Raw Normal View History

import { computed, nextTick, ref } from 'vue'
const COMMON_DESTINATION_PREFIXES = [
'上海',
'北京',
'广州',
'深圳',
'杭州',
'南京',
'苏州',
'成都',
'重庆',
'武汉',
'西安',
'天津',
'宁波',
'青岛',
'长沙',
'郑州',
'济南',
'合肥',
'福州',
'厦门',
'昆明',
'南昌',
'沈阳',
'大连',
'无锡',
'佛山',
'东莞'
]
const CHINESE_DAY_NUMBERS = {
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10
}
function normalizeComposerText(value) {
return String(value || '').trim().replace(/\s+/g, ' ')
}
function parseDayCount(value) {
const text = String(value || '').trim()
const numericValue = Number.parseInt(text, 10)
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue
}
if (text === '十') {
return 10
}
if (/^十[一二三四五六七八九]$/.test(text)) {
return 10 + (CHINESE_DAY_NUMBERS[text.slice(1)] || 0)
}
if (/^[一二两三四五六七八九]十$/.test(text)) {
return (CHINESE_DAY_NUMBERS[text.slice(0, 1)] || 1) * 10
}
if (/^[一二两三四五六七八九]十[一二三四五六七八九]$/.test(text)) {
return (CHINESE_DAY_NUMBERS[text.slice(0, 1)] || 1) * 10 + (CHINESE_DAY_NUMBERS[text.slice(2)] || 0)
}
return CHINESE_DAY_NUMBERS[text] || 0
}
function calculateBusinessDays(businessTimeContext) {
const startDate = String(businessTimeContext?.start_date || '').trim()
const endDate = String(businessTimeContext?.end_date || startDate).trim()
if (!startDate || !endDate || startDate > endDate) {
return 0
}
const startAt = Date.parse(`${startDate}T00:00:00Z`)
const endAt = Date.parse(`${endDate}T00:00:00Z`)
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
return 0
}
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
}
function stripBusinessTimePrefix(text) {
return normalizeComposerText(text)
.replace(/^(?:业务)?发生时间[:]\s*[^,。\n]+(?:至\s*[^,。\n]+)?[,。\s]*/u, '')
.trim()
}
function resolveDestinationFromText(text) {
const normalized = normalizeComposerText(text).replace(/\s+/g, '')
const targetMatch = normalized.match(/(?:出差|去|到|赴|前往)([^,。;;]+)/u)
const targetText = String(targetMatch?.[1] || '').trim()
if (!targetText) {
return ''
}
const knownDestination = COMMON_DESTINATION_PREFIXES.find((item) => targetText.startsWith(item))
if (knownDestination) {
return knownDestination
}
const verbIndex = targetText.search(/支撑|支持|部署|实施|驻场|出差|拜访|处理|办理|参加|进行|协助|服务器|项目/u)
if (verbIndex > 0) {
return targetText.slice(0, verbIndex)
}
return targetText.slice(0, 12)
}
function resolveTripDaysFromText(text, businessTimeContext) {
const dayMatch = normalizeComposerText(text).match(/(?:出差|共|总计)?\s*([0-9]+|[一二两三四五六七八九十]{1,3})\s*天/u)
const explicitDays = parseDayCount(dayMatch?.[1])
return explicitDays || calculateBusinessDays(businessTimeContext)
}
function resolveReasonFromText(text, destination) {
let reason = normalizeComposerText(text)
.replace(/^(?:出差|去|到|赴|前往)\s*/u, '')
.trim()
if (destination && reason.startsWith(destination)) {
reason = reason.slice(destination.length).trim()
}
return reason
.replace(/[,。\s]*(?:出差|共|总计)?\s*(?:[0-9]+|[一二两三四五六七八九十]{1,3})\s*天/u, '')
.replace(/[,。\s]*(?:申请|发起|办理)?(?:差旅费|差旅|费用)?报销(?:申请)?[。.!]?$/u, '')
.replace(/^[,。;;\s]+|[,。;;\s]+$/gu, '')
.trim()
}
export function buildStructuredComposerSubmitText(rawText, businessTimeContext = null) {
const normalizedText = normalizeComposerText(rawText)
const timeDisplay = String(
businessTimeContext?.business_time ||
businessTimeContext?.time_range ||
businessTimeContext?.display_value ||
''
).trim()
if (!timeDisplay || !normalizedText) {
return normalizedText
}
const bodyText = stripBusinessTimePrefix(normalizedText)
if (!bodyText) {
return `发生时间:${timeDisplay}`
}
const destination = resolveDestinationFromText(bodyText)
const reason = resolveReasonFromText(bodyText, destination)
const days = resolveTripDaysFromText(bodyText, businessTimeContext)
const lines = [`发生时间:${timeDisplay}`]
if (destination) {
lines.push(`地点:${destination}`)
}
if (reason) {
lines.push(`事由:${reason}`)
}
if (days > 0 && (days > 1 || /出差|差旅|至/.test(timeDisplay) || /出差|差旅/.test(bodyText))) {
lines.push(`天数:${days}`)
}
return lines.join('\n')
}
export function useTravelReimbursementComposerTools({
currentUser,
activeReviewPayload,
reviewInlineForm,
latestReviewMessage,
currentInsight,
messages,
composerDraft,
composerTextareaRef,
adjustComposerTextareaHeight,
scrollToBottom,
toast,
calculateTravelReimbursement,
createMessage,
buildReviewSlotMap,
isValidIsoDateString,
buildLocallySyncedReviewPayload,
formatDateInputValue
}) {
const composerDatePickerOpen = ref(false)
const composerDateMode = ref('single')
const composerSingleDate = ref(formatDateInputValue())
const composerRangeStartDate = ref(formatDateInputValue())
const composerRangeEndDate = ref(formatDateInputValue())
const composerBusinessTimeTags = ref([])
const composerBusinessTimeDraftTouched = ref(false)
const travelCalculatorOpen = ref(false)
const travelCalculatorBusy = ref(false)
const travelCalculatorError = ref('')
const travelCalculatorResult = ref(null)
const travelCalculatorForm = ref({
days: '1',
location: ''
})
const composerCanApplyDateSelection = computed(() => {
if (composerDateMode.value === 'single') {
return Boolean(composerSingleDate.value)
}
return Boolean(
composerRangeStartDate.value
&& composerRangeEndDate.value
&& composerRangeStartDate.value <= composerRangeEndDate.value
)
})
const travelCalculatorCanSubmit = computed(() =>
!travelCalculatorBusy.value
&& Number(travelCalculatorForm.value.days) >= 1
&& Boolean(String(travelCalculatorForm.value.location || '').trim())
)
function buildComposerBusinessTimeLabel() {
if (composerDateMode.value === 'single') {
return `发生时间:${composerSingleDate.value}`
}
if (composerRangeStartDate.value === composerRangeEndDate.value) {
return `发生时间:${composerRangeStartDate.value}`
}
return `发生时间:${composerRangeStartDate.value}${composerRangeEndDate.value}`
}
function hasComposerBusinessTimeSelection() {
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
}
function buildComposerBusinessTimeContext() {
if (!hasComposerBusinessTimeSelection()) {
return null
}
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return null
}
const displayValue = mode === 'range' && startDate !== endDate
? `${startDate}${endDate}`
: startDate
return {
mode,
start_date: startDate,
end_date: endDate,
occurred_date: startDate,
time_range: displayValue,
business_time: displayValue,
time_range_raw: buildComposerBusinessTimeLabel()
}
}
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
if (!businessTimeContext) {
return extraContext
}
const baseReviewFormValues =
extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
? extraContext.review_form_values
: {}
return {
...extraContext,
occurred_date: businessTimeContext.occurred_date,
business_time: businessTimeContext.business_time,
business_time_context: {
mode: businessTimeContext.mode,
start_date: businessTimeContext.start_date,
end_date: businessTimeContext.end_date,
display_value: businessTimeContext.business_time
},
review_form_values: {
...baseReviewFormValues,
occurred_date: businessTimeContext.occurred_date,
time_range: businessTimeContext.time_range,
business_time: businessTimeContext.business_time,
time_range_raw: businessTimeContext.time_range_raw
}
}
}
function syncComposerBusinessTimeToReviewCard(businessTimeContext) {
if (!businessTimeContext || !activeReviewPayload.value) {
return
}
const nextInlineState = {
...reviewInlineForm.value,
occurred_date: businessTimeContext.occurred_date
}
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
reviewInlineForm.value = nextInlineState
if (latestReviewMessage.value) {
latestReviewMessage.value.reviewPayload = nextReviewPayload
}
if (currentInsight.value?.agent) {
currentInsight.value = {
...currentInsight.value,
agent: {
...currentInsight.value.agent,
reviewPayload: nextReviewPayload
}
}
}
}
function resolveComposerSubmitText(explicitRawText) {
const draftPart = String(explicitRawText ?? composerDraft.value).trim()
const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join('')
if (!tagPart) {
return draftPart
}
if (!draftPart) {
return tagPart
}
return `${tagPart}${draftPart}`
}
function resolveComposerDisplaySubmitText(rawText) {
const businessTimeContext = buildComposerBusinessTimeContext()
if (!businessTimeContext) {
return String(rawText || '').trim()
}
return buildStructuredComposerSubmitText(rawText, businessTimeContext)
}
function toggleComposerDatePicker() {
composerDatePickerOpen.value = !composerDatePickerOpen.value
if (composerDatePickerOpen.value) {
travelCalculatorOpen.value = false
}
}
function closeComposerDatePicker() {
composerDatePickerOpen.value = false
}
function setComposerDateMode(mode) {
composerDateMode.value = mode === 'range' ? 'range' : 'single'
}
function handleComposerDateInputChange() {
composerBusinessTimeDraftTouched.value = true
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
}
function removeComposerBusinessTimeTag(tagId) {
composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId)
if (!composerBusinessTimeTags.value.length) {
composerBusinessTimeDraftTouched.value = false
}
}
function handleComposerDatePickerOutside(event) {
if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) {
return
}
if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) {
return
}
if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) {
return
}
if (composerDatePickerOpen.value) {
composerDatePickerOpen.value = false
}
if (travelCalculatorOpen.value && !travelCalculatorBusy.value) {
travelCalculatorOpen.value = false
}
}
async function applyComposerDateSelection() {
if (!composerCanApplyDateSelection.value) {
return
}
composerBusinessTimeDraftTouched.value = true
composerBusinessTimeTags.value = [
{
id: `biz-time-${Date.now()}`,
label: buildComposerBusinessTimeLabel()
}
]
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
composerDatePickerOpen.value = false
await nextTick()
adjustComposerTextareaHeight()
composerTextareaRef.value?.focus()
}
function resolveTravelCalculatorInitialDays() {
const businessTimeContext = buildComposerBusinessTimeContext()
if (!businessTimeContext) {
return 1
}
const startDate = businessTimeContext.start_date
const endDate = businessTimeContext.end_date || startDate
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return 1
}
const startAt = Date.parse(`${startDate}T00:00:00Z`)
const endAt = Date.parse(`${endDate}T00:00:00Z`)
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
return 1
}
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
}
function resolveTravelCalculatorInitialLocation() {
const slotMap = buildReviewSlotMap(activeReviewPayload.value)
const candidates = [
reviewInlineForm.value.location,
slotMap.business_location?.normalized_value,
slotMap.business_location?.value,
slotMap.location?.normalized_value,
slotMap.location?.value,
currentUser.value?.location
]
return String(candidates.find((item) => String(item || '').trim()) || '').trim()
}
function openTravelCalculator() {
closeComposerDatePicker()
travelCalculatorError.value = ''
travelCalculatorResult.value = null
travelCalculatorForm.value = {
days: String(resolveTravelCalculatorInitialDays()),
location: resolveTravelCalculatorInitialLocation()
}
travelCalculatorOpen.value = true
}
function toggleTravelCalculator() {
if (travelCalculatorOpen.value) {
closeTravelCalculator()
return
}
openTravelCalculator()
}
function closeTravelCalculator() {
if (travelCalculatorBusy.value) {
return
}
travelCalculatorOpen.value = false
}
function formatTravelCalculatorMoney(value) {
const amount = Number(value)
if (!Number.isFinite(amount)) {
return String(value || '0')
}
return amount.toFixed(2)
}
function buildTravelCalculatorResultText(result) {
const days = Number(result?.days) || 1
const location = String(result?.location || '').trim() || '未填写地点'
const matchedCity = String(result?.matched_city || location).trim()
const grade = String(result?.grade || '').trim() || '当前职级'
const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位'
const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域'
const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则'
const ruleVersion = String(result?.rule_version || '').trim()
const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate)
const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount)
const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate)
const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate)
const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate)
const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount)
const totalAmount = formatTravelCalculatorMoney(result?.total_amount)
const ruleVersionText = ruleVersion ? `${ruleVersion}` : ''
const user = currentUser.value || {}
const displayName = String(user.name || user.display_name || user.username || '').trim()
const greeting = displayName ? `您好,${displayName}` : '您好,'
return [
`${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`,
'',
`**参考可报销合计:${totalAmount} 元**`,
'',
'| 项目 | 标准口径 | 天数 | 小计 |',
'| --- | --- | ---: | ---: |',
`| 住宿费 | ${matchedCity} / ${grade}${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`,
`| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`,
'',
'**计算过程**',
`1. 住宿费:${hotelRate} × ${days} = ${hotelAmount}`,
`2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount}`,
`3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount}`,
'',
`**规则依据**${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`,
'',
'这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。'
].join('\n')
}
async function submitTravelCalculator() {
if (!travelCalculatorCanSubmit.value) {
travelCalculatorError.value = '请填写出差天数和地点后再计算。'
return
}
travelCalculatorBusy.value = true
travelCalculatorError.value = ''
try {
const user = currentUser.value || {}
const payload = await calculateTravelReimbursement({
days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1),
location: String(travelCalculatorForm.value.location || '').trim(),
grade: String(user.grade || '').trim()
})
travelCalculatorResult.value = payload
messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], {
meta: ['差旅计算器'],
metaTone: 'low'
}))
travelCalculatorOpen.value = false
nextTick(scrollToBottom)
} catch (error) {
travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。'
} finally {
travelCalculatorBusy.value = false
}
}
return {
composerDatePickerOpen,
composerDateMode,
composerSingleDate,
composerRangeStartDate,
composerRangeEndDate,
composerBusinessTimeTags,
composerBusinessTimeDraftTouched,
composerCanApplyDateSelection,
travelCalculatorOpen,
travelCalculatorBusy,
travelCalculatorError,
travelCalculatorResult,
travelCalculatorForm,
travelCalculatorCanSubmit,
buildComposerBusinessTimeLabel,
hasComposerBusinessTimeSelection,
buildComposerBusinessTimeContext,
mergeBusinessTimeIntoExtraContext,
syncComposerBusinessTimeToReviewCard,
resolveComposerSubmitText,
resolveComposerDisplaySubmitText,
toggleComposerDatePicker,
closeComposerDatePicker,
setComposerDateMode,
handleComposerDateInputChange,
removeComposerBusinessTimeTag,
handleComposerDatePickerOutside,
applyComposerDateSelection,
resolveTravelCalculatorInitialDays,
resolveTravelCalculatorInitialLocation,
openTravelCalculator,
toggleTravelCalculator,
closeTravelCalculator,
formatTravelCalculatorMoney,
buildTravelCalculatorResultText,
submitTravelCalculator
}
}