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 } }