397 lines
14 KiB
JavaScript
397 lines
14 KiB
JavaScript
|
|
import { computed, nextTick, ref } from 'vue'
|
|||
|
|
|
|||
|
|
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 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,
|
|||
|
|
toggleComposerDatePicker,
|
|||
|
|
closeComposerDatePicker,
|
|||
|
|
setComposerDateMode,
|
|||
|
|
handleComposerDateInputChange,
|
|||
|
|
removeComposerBusinessTimeTag,
|
|||
|
|
handleComposerDatePickerOutside,
|
|||
|
|
applyComposerDateSelection,
|
|||
|
|
resolveTravelCalculatorInitialDays,
|
|||
|
|
resolveTravelCalculatorInitialLocation,
|
|||
|
|
openTravelCalculator,
|
|||
|
|
toggleTravelCalculator,
|
|||
|
|
closeTravelCalculator,
|
|||
|
|
formatTravelCalculatorMoney,
|
|||
|
|
buildTravelCalculatorResultText,
|
|||
|
|
submitTravelCalculator
|
|||
|
|
}
|
|||
|
|
}
|