2026-05-23 19:54:42 +08:00
|
|
|
|
export const GUIDED_FLOW_MODE_NONE = ''
|
|
|
|
|
|
export const GUIDED_FLOW_MODE_REIMBURSEMENT = 'reimbursement_guide'
|
|
|
|
|
|
export const GUIDED_FLOW_MODE_STATUS_QUERY = 'status_query_guide'
|
|
|
|
|
|
|
|
|
|
|
|
export const GUIDED_ACTION_START_REIMBURSEMENT = 'start_guided_reimbursement'
|
2026-05-26 09:15:14 +08:00
|
|
|
|
export const GUIDED_ACTION_START_APPLICATION = 'start_guided_application'
|
2026-05-23 19:54:42 +08:00
|
|
|
|
export const GUIDED_ACTION_START_STATUS_QUERY = 'start_guided_status_query'
|
|
|
|
|
|
export const GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR = 'open_travel_calculator'
|
|
|
|
|
|
export const GUIDED_ACTION_SELECT_EXPENSE_TYPE = 'guided_select_expense_type'
|
2026-05-27 14:35:17 +08:00
|
|
|
|
export const GUIDED_ACTION_SELECT_REQUIRED_APPLICATION = 'guided_select_required_application'
|
2026-05-23 19:54:42 +08:00
|
|
|
|
export const GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW = 'guided_confirm_reimbursement_review'
|
|
|
|
|
|
export const GUIDED_ACTION_CONTINUE_FILLING = 'guided_continue_filling'
|
|
|
|
|
|
export const GUIDED_ACTION_PROCESS_INTERRUPTION = 'guided_process_interruption'
|
|
|
|
|
|
export const GUIDED_ACTION_SELECT_QUERY_MODE = 'guided_select_query_mode'
|
|
|
|
|
|
export const GUIDED_ACTION_SELECT_QUERY_STATUS = 'guided_select_query_status'
|
|
|
|
|
|
|
|
|
|
|
|
export const GUIDED_EXPENSE_TYPES = [
|
|
|
|
|
|
{ key: 'travel', label: '差旅费', description: '出差、跨城交通、住宿和补贴', icon: 'mdi mdi-bag-suitcase-outline' },
|
|
|
|
|
|
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车和通行费', icon: 'mdi mdi-car-outline' },
|
|
|
|
|
|
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店票据', icon: 'mdi mdi-bed-outline' },
|
|
|
|
|
|
{ key: 'meal', label: '业务招待费', description: '客户接待、餐饮和工作招待', icon: 'mdi mdi-food-fork-drink' },
|
|
|
|
|
|
{ key: 'office', label: '办公用品费', description: '办公用品、文具和低值易耗品', icon: 'mdi mdi-briefcase-outline' },
|
|
|
|
|
|
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const GUIDED_REIMBURSEMENT_STEPS = {
|
|
|
|
|
|
travel: [
|
|
|
|
|
|
{ key: 'reason', summaryLabel: '事由', prompt: '请先告诉我本次出差事由,例如:去上海支持项目部署。' },
|
|
|
|
|
|
{ key: 'location', summaryLabel: '出差地点', prompt: '本次出差地点是哪里?可以回复城市或具体客户地点。' },
|
|
|
|
|
|
{ key: 'time_range', summaryLabel: '出差时间/天数', prompt: '请补充出差时间或天数,例如:2026-05-20 至 2026-05-23,出差 3 天。' },
|
|
|
|
|
|
{ key: 'amount', summaryLabel: '金额', prompt: '请补充本次预计或实际报销金额。如果还没有汇总,可以回复“待核算”。' },
|
|
|
|
|
|
{ key: 'attachments', summaryLabel: '票据', prompt: '票据可以现在上传,也可以回复“稍后上传”。上传后我会在生成核对信息时一起处理。' }
|
|
|
|
|
|
],
|
|
|
|
|
|
transport: [
|
|
|
|
|
|
{ key: 'reason', summaryLabel: '出行事由', prompt: '请说明本次交通费事由,例如:送客户去机场。' },
|
|
|
|
|
|
{ key: 'time_range', summaryLabel: '出行时间', prompt: '请补充出行时间,例如:2026-05-20 下午。' },
|
|
|
|
|
|
{ key: 'location', summaryLabel: '路线/地点', prompt: '请补充出行路线或地点,例如:公司至机场。' },
|
|
|
|
|
|
{ key: 'amount', summaryLabel: '金额', prompt: '请补充交通费金额。如果票据里再识别金额,可以回复“以票据为准”。' },
|
|
|
|
|
|
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传出租车、网约车、停车或通行费等票据;也可以回复“稍后上传”。' }
|
|
|
|
|
|
],
|
|
|
|
|
|
hotel: [
|
|
|
|
|
|
{ key: 'reason', summaryLabel: '住宿事由', prompt: '请说明住宿事由,例如:项目现场支持期间住宿。' },
|
|
|
|
|
|
{ key: 'location', summaryLabel: '城市/酒店地点', prompt: '住宿城市或酒店地点是哪里?' },
|
|
|
|
|
|
{ key: 'time_range', summaryLabel: '入住离店时间', prompt: '请补充入住和离店时间,例如:2026-05-20 至 2026-05-23。' },
|
|
|
|
|
|
{ key: 'amount', summaryLabel: '金额', prompt: '请补充住宿金额。如果还没有汇总,可以回复“待核算”。' },
|
|
|
|
|
|
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传酒店发票或住宿水单;也可以回复“稍后上传”。' }
|
|
|
|
|
|
],
|
|
|
|
|
|
meal: [
|
|
|
|
|
|
{ key: 'customer_name', summaryLabel: '客户单位', prompt: '请补充客户单位或接待对象。' },
|
|
|
|
|
|
{ key: 'participants', summaryLabel: '参与人员', prompt: '请补充参与人员,例如:客户 2 人,我方 1 人。' },
|
|
|
|
|
|
{ key: 'time_range', summaryLabel: '招待时间', prompt: '请补充招待时间,例如:2026-05-20 晚。' },
|
|
|
|
|
|
{ key: 'location', summaryLabel: '招待地点', prompt: '请补充招待地点或商户名称。' },
|
|
|
|
|
|
{ key: 'amount', summaryLabel: '金额', prompt: '请补充招待金额。' },
|
|
|
|
|
|
{ key: 'reason', summaryLabel: '事由', prompt: '请补充招待事由,例如:项目沟通或客户接待。' },
|
|
|
|
|
|
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传餐饮发票或相关凭证;也可以回复“稍后上传”。' }
|
|
|
|
|
|
],
|
|
|
|
|
|
office: [
|
|
|
|
|
|
{ key: 'reason', summaryLabel: '采购用途', prompt: '请说明采购用途,例如:项目现场临时采购办公用品。' },
|
|
|
|
|
|
{ key: 'location', summaryLabel: '商户/采购地点', prompt: '请补充商户或采购地点。' },
|
|
|
|
|
|
{ key: 'time_range', summaryLabel: '发生时间', prompt: '请补充费用发生时间。' },
|
|
|
|
|
|
{ key: 'amount', summaryLabel: '金额', prompt: '请补充办公用品金额。' },
|
|
|
|
|
|
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传办公用品发票或购物凭证;也可以回复“稍后上传”。' }
|
|
|
|
|
|
],
|
|
|
|
|
|
other: [
|
|
|
|
|
|
{ key: 'reason', summaryLabel: '费用说明', prompt: '请说明这笔费用的具体内容和用途。' },
|
|
|
|
|
|
{ key: 'time_range', summaryLabel: '发生时间', prompt: '请补充费用发生时间。' },
|
|
|
|
|
|
{ key: 'location', summaryLabel: '地点/对象', prompt: '请补充费用发生地点或关联对象。' },
|
|
|
|
|
|
{ key: 'amount', summaryLabel: '金额', prompt: '请补充费用金额。' },
|
|
|
|
|
|
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传相关票据;也可以回复“稍后上传”。' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const GUIDED_QUERY_MODES = [
|
|
|
|
|
|
{ key: 'claim_no', label: '按单号', description: '输入报销单号精准查询', icon: 'mdi mdi-pound' },
|
|
|
|
|
|
{ key: 'status', label: '按状态', description: '查询草稿、审批中或已归档单据', icon: 'mdi mdi-list-status' },
|
|
|
|
|
|
{ key: 'time_range', label: '按时间范围', description: '例如上周、去年、2026-05', icon: 'mdi mdi-calendar-search-outline' },
|
|
|
|
|
|
{ key: 'keyword', label: '按地点/事由', description: '例如北京、上海电力、服务器部署', icon: 'mdi mdi-map-search-outline' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
export const GUIDED_QUERY_STATUS_OPTIONS = [
|
|
|
|
|
|
{ key: 'draft', label: '草稿', description: '还没有正式提交的单据' },
|
|
|
|
|
|
{ key: 'pending', label: '审批中', description: '正在流转审批的单据' },
|
|
|
|
|
|
{ key: 'returned', label: '已退回', description: '需要补充或修改的单据' },
|
|
|
|
|
|
{ key: 'archived', label: '已归档', description: '已完成归档的单据' },
|
2026-05-28 12:09:49 +08:00
|
|
|
|
{ key: 'completed', label: '已完成', description: '已审核完成或已付款的单据' }
|
2026-05-23 19:54:42 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const NO_ATTACHMENT_TEXT_PATTERN = /^(稍后|暂不|不用|没有|待上传|后面|后续|先不|以票据为准)/u
|
|
|
|
|
|
const INTERRUPTION_PATTERN = /(查一下|查询|状态|报销了吗|报销了么|多少|总额|标准|制度|规则|为什么|怎么|可以吗|能不能|差旅计算器|计算一下|解释|风险|打开|跳转|查看|审批|归档|入账|[??])/u
|
|
|
|
|
|
|
|
|
|
|
|
function uniqueValues(values) {
|
|
|
|
|
|
return Array.from(new Set((Array.isArray(values) ? values : []).map((item) => String(item || '').trim()).filter(Boolean)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeText(value) {
|
|
|
|
|
|
return String(value || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeValues(values) {
|
|
|
|
|
|
if (!values || typeof values !== 'object') {
|
|
|
|
|
|
return {}
|
|
|
|
|
|
}
|
|
|
|
|
|
return Object.entries(values).reduce((result, [key, value]) => {
|
|
|
|
|
|
if (key === 'attachment_names') {
|
|
|
|
|
|
result[key] = uniqueValues(value)
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
result[key] = normalizeText(value)
|
|
|
|
|
|
return result
|
|
|
|
|
|
}, {})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
function hasLinkedApplication(values) {
|
|
|
|
|
|
return Boolean(normalizeText(values?.application_claim_id) || normalizeText(values?.application_claim_no))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildApplicationSummaryParts(values) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
normalizeText(values?.application_claim_no),
|
|
|
|
|
|
normalizeText(values?.application_reason),
|
|
|
|
|
|
normalizeText(values?.application_business_time),
|
|
|
|
|
|
normalizeText(values?.application_location),
|
|
|
|
|
|
normalizeText(values?.application_amount_label || values?.application_amount)
|
|
|
|
|
|
].filter(Boolean)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 14:35:17 +08:00
|
|
|
|
function normalizeApplicationCandidates(applications) {
|
|
|
|
|
|
if (!Array.isArray(applications)) {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
return applications
|
|
|
|
|
|
.map((item) => (item && typeof item === 'object' ? item : null))
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.map((item) => ({
|
|
|
|
|
|
id: normalizeText(item.id || item.application_claim_id),
|
|
|
|
|
|
claim_no: normalizeText(item.claim_no || item.application_claim_no),
|
|
|
|
|
|
expense_type: normalizeText(item.expense_type || item.application_expense_type),
|
|
|
|
|
|
reason: normalizeText(item.reason || item.application_reason),
|
|
|
|
|
|
location: normalizeText(item.location || item.application_location),
|
|
|
|
|
|
amount: normalizeText(item.amount || item.application_amount),
|
|
|
|
|
|
amount_label: normalizeText(item.amount_label || item.application_amount_label),
|
2026-06-02 14:01:51 +08:00
|
|
|
|
business_time: normalizeText(item.business_time || item.application_business_time),
|
2026-06-02 16:22:59 +08:00
|
|
|
|
days: normalizeText(item.days || item.application_days),
|
|
|
|
|
|
transport_mode: normalizeText(item.transport_mode || item.application_transport_mode),
|
|
|
|
|
|
lodging_daily_cap: normalizeText(item.lodging_daily_cap || item.application_lodging_daily_cap),
|
|
|
|
|
|
subsidy_daily_cap: normalizeText(item.subsidy_daily_cap || item.application_subsidy_daily_cap),
|
|
|
|
|
|
transport_policy: normalizeText(item.transport_policy || item.application_transport_policy),
|
|
|
|
|
|
policy_estimate: normalizeText(item.policy_estimate || item.application_policy_estimate),
|
|
|
|
|
|
rule_name: normalizeText(item.rule_name || item.application_rule_name),
|
|
|
|
|
|
rule_version: normalizeText(item.rule_version || item.application_rule_version),
|
2026-05-27 14:35:17 +08:00
|
|
|
|
status: normalizeText(item.status || item.application_status),
|
|
|
|
|
|
status_label: normalizeText(item.status_label || item.application_status_label),
|
|
|
|
|
|
application_date: normalizeText(item.application_date)
|
|
|
|
|
|
}))
|
|
|
|
|
|
.filter((item) => item.id || item.claim_no)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 19:54:42 +08:00
|
|
|
|
export function createEmptyGuidedFlowState() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
mode: GUIDED_FLOW_MODE_NONE,
|
|
|
|
|
|
stepKey: '',
|
|
|
|
|
|
expenseType: '',
|
|
|
|
|
|
values: {},
|
2026-05-27 14:35:17 +08:00
|
|
|
|
pendingInterruptionText: '',
|
|
|
|
|
|
applicationCandidates: []
|
2026-05-23 19:54:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function normalizeGuidedFlowState(state) {
|
|
|
|
|
|
const source = state && typeof state === 'object' ? state : {}
|
|
|
|
|
|
const mode = normalizeText(source.mode)
|
|
|
|
|
|
const supportedMode = [GUIDED_FLOW_MODE_REIMBURSEMENT, GUIDED_FLOW_MODE_STATUS_QUERY].includes(mode)
|
|
|
|
|
|
? mode
|
|
|
|
|
|
: GUIDED_FLOW_MODE_NONE
|
|
|
|
|
|
if (!supportedMode) {
|
|
|
|
|
|
return createEmptyGuidedFlowState()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
mode: supportedMode,
|
|
|
|
|
|
stepKey: normalizeText(source.stepKey),
|
|
|
|
|
|
expenseType: normalizeText(source.expenseType),
|
|
|
|
|
|
values: normalizeValues(source.values),
|
2026-05-27 14:35:17 +08:00
|
|
|
|
pendingInterruptionText: normalizeText(source.pendingInterruptionText),
|
|
|
|
|
|
applicationCandidates: normalizeApplicationCandidates(source.applicationCandidates)
|
2026-05-23 19:54:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isGuidedFlowActive(state) {
|
|
|
|
|
|
return Boolean(normalizeGuidedFlowState(state).mode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function getGuidedExpenseType(expenseType) {
|
|
|
|
|
|
const key = normalizeText(expenseType)
|
|
|
|
|
|
return GUIDED_EXPENSE_TYPES.find((item) => item.key === key) || null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function getGuidedExpenseTypeLabel(expenseType) {
|
|
|
|
|
|
return getGuidedExpenseType(expenseType)?.label || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedExpenseTypeActions() {
|
|
|
|
|
|
return GUIDED_EXPENSE_TYPES.map((option) => ({
|
|
|
|
|
|
label: option.label,
|
|
|
|
|
|
description: option.description,
|
|
|
|
|
|
icon: option.icon,
|
|
|
|
|
|
action_type: GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
expense_type: option.key,
|
|
|
|
|
|
expense_type_label: option.label
|
|
|
|
|
|
}
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedReimbursementStartText() {
|
|
|
|
|
|
return [
|
|
|
|
|
|
'请问你要报销的类型?',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'先选一个最贴近的费用场景,我会按对应流程逐项询问。这个过程只做本地引导,不会自动创建草稿。'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function createGuidedReimbursementState() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...createEmptyGuidedFlowState(),
|
|
|
|
|
|
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
|
|
|
|
|
stepKey: 'expense_type'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function selectGuidedExpenseType(state, expenseType) {
|
|
|
|
|
|
const type = getGuidedExpenseType(expenseType)
|
|
|
|
|
|
if (!type) {
|
|
|
|
|
|
return normalizeGuidedFlowState(state)
|
|
|
|
|
|
}
|
|
|
|
|
|
const steps = GUIDED_REIMBURSEMENT_STEPS[type.key] || []
|
|
|
|
|
|
return {
|
|
|
|
|
|
...normalizeGuidedFlowState(state),
|
|
|
|
|
|
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
|
|
|
|
|
expenseType: type.key,
|
|
|
|
|
|
stepKey: steps[0]?.key || 'summary',
|
2026-05-27 14:35:17 +08:00
|
|
|
|
pendingInterruptionText: '',
|
|
|
|
|
|
applicationCandidates: []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function waitForGuidedApplicationSelection(state, expenseType, applications = []) {
|
|
|
|
|
|
const type = getGuidedExpenseType(expenseType)
|
|
|
|
|
|
if (!type) {
|
|
|
|
|
|
return normalizeGuidedFlowState(state)
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
...normalizeGuidedFlowState(state),
|
|
|
|
|
|
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
|
|
|
|
|
expenseType: type.key,
|
|
|
|
|
|
stepKey: 'application_selection',
|
|
|
|
|
|
pendingInterruptionText: '',
|
|
|
|
|
|
applicationCandidates: normalizeApplicationCandidates(applications)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function selectGuidedRequiredApplication(state, application = {}) {
|
|
|
|
|
|
const current = normalizeGuidedFlowState(state)
|
|
|
|
|
|
return {
|
|
|
|
|
|
...current,
|
|
|
|
|
|
values: normalizeValues({
|
|
|
|
|
|
...current.values,
|
|
|
|
|
|
application_claim_id: application.application_claim_id || application.id || '',
|
|
|
|
|
|
application_claim_no: application.application_claim_no || application.claim_no || '',
|
|
|
|
|
|
application_reason: application.application_reason || application.reason || '',
|
|
|
|
|
|
application_location: application.application_location || application.location || '',
|
|
|
|
|
|
application_amount: application.application_amount || application.amount || '',
|
|
|
|
|
|
application_amount_label: application.application_amount_label || application.amount_label || '',
|
2026-06-02 14:01:51 +08:00
|
|
|
|
application_business_time: application.application_business_time || application.business_time || '',
|
2026-06-02 16:22:59 +08:00
|
|
|
|
application_days: application.application_days || application.days || '',
|
|
|
|
|
|
application_transport_mode: application.application_transport_mode || application.transport_mode || '',
|
|
|
|
|
|
application_lodging_daily_cap: application.application_lodging_daily_cap || application.lodging_daily_cap || '',
|
|
|
|
|
|
application_subsidy_daily_cap: application.application_subsidy_daily_cap || application.subsidy_daily_cap || '',
|
|
|
|
|
|
application_transport_policy: application.application_transport_policy || application.transport_policy || '',
|
|
|
|
|
|
application_policy_estimate: application.application_policy_estimate || application.policy_estimate || '',
|
|
|
|
|
|
application_rule_name: application.application_rule_name || application.rule_name || '',
|
|
|
|
|
|
application_rule_version: application.application_rule_version || application.rule_version || '',
|
2026-06-02 14:01:51 +08:00
|
|
|
|
application_status_label: application.application_status_label || application.status_label || '',
|
|
|
|
|
|
application_date: application.application_date || ''
|
2026-05-27 14:35:17 +08:00
|
|
|
|
}),
|
2026-06-02 14:01:51 +08:00
|
|
|
|
stepKey: 'summary',
|
2026-05-27 14:35:17 +08:00
|
|
|
|
pendingInterruptionText: '',
|
|
|
|
|
|
applicationCandidates: []
|
2026-05-23 19:54:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function getGuidedReimbursementSteps(expenseType) {
|
|
|
|
|
|
const key = normalizeText(expenseType)
|
|
|
|
|
|
return GUIDED_REIMBURSEMENT_STEPS[key] || []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function getCurrentGuidedStep(state) {
|
|
|
|
|
|
const current = normalizeGuidedFlowState(state)
|
|
|
|
|
|
if (current.mode !== GUIDED_FLOW_MODE_REIMBURSEMENT || !current.expenseType) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
return getGuidedReimbursementSteps(current.expenseType).find((step) => step.key === current.stepKey) || null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedStepPromptText(state) {
|
|
|
|
|
|
const current = normalizeGuidedFlowState(state)
|
|
|
|
|
|
const step = getCurrentGuidedStep(current)
|
|
|
|
|
|
const typeLabel = getGuidedExpenseTypeLabel(current.expenseType)
|
|
|
|
|
|
if (!step || !typeLabel) {
|
|
|
|
|
|
return buildGuidedReimbursementStartText()
|
|
|
|
|
|
}
|
|
|
|
|
|
const steps = getGuidedReimbursementSteps(current.expenseType)
|
|
|
|
|
|
const stepIndex = Math.max(0, steps.findIndex((item) => item.key === step.key))
|
|
|
|
|
|
return [
|
|
|
|
|
|
`已选择“${typeLabel}”。`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
`第 ${stepIndex + 1} 步:${step.summaryLabel}`,
|
|
|
|
|
|
step.prompt,
|
|
|
|
|
|
'',
|
|
|
|
|
|
'直接回复这一项即可。'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function resolveGuidedExpenseTypeFromText(text) {
|
|
|
|
|
|
const normalized = normalizeText(text)
|
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
const exact = GUIDED_EXPENSE_TYPES.find((item) => normalized === item.label || normalized === item.key)
|
|
|
|
|
|
if (exact) {
|
|
|
|
|
|
return exact.key
|
|
|
|
|
|
}
|
|
|
|
|
|
const matched = GUIDED_EXPENSE_TYPES.find((item) => normalized.includes(item.label))
|
|
|
|
|
|
return matched?.key || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function applyGuidedReimbursementAnswer(state, answerText, attachmentNames = []) {
|
|
|
|
|
|
const current = normalizeGuidedFlowState(state)
|
|
|
|
|
|
const step = getCurrentGuidedStep(current)
|
|
|
|
|
|
if (!step) {
|
|
|
|
|
|
return current
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const answer = normalizeText(answerText)
|
|
|
|
|
|
const nextValues = { ...current.values }
|
|
|
|
|
|
if (step.key === 'attachments') {
|
|
|
|
|
|
const nextAttachmentNames = uniqueValues([
|
|
|
|
|
|
...(Array.isArray(nextValues.attachment_names) ? nextValues.attachment_names : []),
|
|
|
|
|
|
...attachmentNames
|
|
|
|
|
|
])
|
|
|
|
|
|
if (nextAttachmentNames.length) {
|
|
|
|
|
|
nextValues.attachment_names = nextAttachmentNames
|
|
|
|
|
|
}
|
|
|
|
|
|
nextValues.attachments = answer || (nextAttachmentNames.length ? `已选择 ${nextAttachmentNames.length} 份附件` : '稍后上传')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
nextValues[step.key] = answer
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const steps = getGuidedReimbursementSteps(current.expenseType)
|
|
|
|
|
|
const currentIndex = steps.findIndex((item) => item.key === step.key)
|
|
|
|
|
|
const nextStep = steps[currentIndex + 1]
|
|
|
|
|
|
return {
|
|
|
|
|
|
...current,
|
|
|
|
|
|
values: normalizeValues(nextValues),
|
|
|
|
|
|
stepKey: nextStep?.key || 'summary',
|
|
|
|
|
|
pendingInterruptionText: ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isGuidedReimbursementReadyForReview(state) {
|
|
|
|
|
|
const current = normalizeGuidedFlowState(state)
|
|
|
|
|
|
return current.mode === GUIDED_FLOW_MODE_REIMBURSEMENT
|
|
|
|
|
|
&& Boolean(current.expenseType)
|
|
|
|
|
|
&& current.stepKey === 'summary'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedReimbursementSummaryText(state) {
|
|
|
|
|
|
const current = normalizeGuidedFlowState(state)
|
|
|
|
|
|
const typeLabel = getGuidedExpenseTypeLabel(current.expenseType) || '报销'
|
|
|
|
|
|
const steps = getGuidedReimbursementSteps(current.expenseType)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
const linkedApplication = hasLinkedApplication(current.values)
|
2026-05-23 19:54:42 +08:00
|
|
|
|
const lines = [
|
|
|
|
|
|
`已完成“${typeLabel}”的引导填写。`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
'请核查下面的关键信息:'
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
if (linkedApplication) {
|
|
|
|
|
|
const applicationParts = buildApplicationSummaryParts(current.values)
|
2026-05-27 14:35:17 +08:00
|
|
|
|
lines.push(`- 关联申请单:${applicationParts.join(' / ')}`)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
lines.push('- 报销票据:可先生成草稿,随后在草稿详情中上传对应票据。')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
steps.forEach((step) => {
|
|
|
|
|
|
const value = step.key === 'attachments'
|
|
|
|
|
|
? (current.values.attachment_names?.length
|
|
|
|
|
|
? current.values.attachment_names.join('、')
|
|
|
|
|
|
: current.values.attachments || '稍后上传')
|
|
|
|
|
|
: current.values[step.key]
|
|
|
|
|
|
lines.push(`- ${step.summaryLabel}:${value || '待补充'}`)
|
|
|
|
|
|
})
|
2026-05-27 14:35:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 19:54:42 +08:00
|
|
|
|
lines.push('')
|
2026-06-02 14:01:51 +08:00
|
|
|
|
lines.push(
|
|
|
|
|
|
linkedApplication
|
|
|
|
|
|
? '如果关联信息无误,我可以直接生成报销草稿;后续由你在草稿详情中上传和归集票据。'
|
|
|
|
|
|
: '如果这些信息无误,我可以继续生成报销草稿;草稿生成后可继续上传票据或补充信息。'
|
|
|
|
|
|
)
|
2026-05-23 19:54:42 +08:00
|
|
|
|
return lines.join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedReviewConfirmationActions() {
|
|
|
|
|
|
return [{
|
2026-06-02 14:01:51 +08:00
|
|
|
|
label: '生成报销草稿',
|
|
|
|
|
|
description: '使用当前信息生成草稿,票据可在草稿详情继续上传',
|
2026-05-23 19:54:42 +08:00
|
|
|
|
icon: 'mdi mdi-clipboard-check-outline',
|
|
|
|
|
|
action_type: GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW
|
|
|
|
|
|
}]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedReviewSubmitOptions(state, files = []) {
|
|
|
|
|
|
const current = normalizeGuidedFlowState(state)
|
|
|
|
|
|
const type = getGuidedExpenseType(current.expenseType)
|
|
|
|
|
|
const values = current.values || {}
|
|
|
|
|
|
const typeLabel = type?.label || '其他费用'
|
2026-06-02 14:01:51 +08:00
|
|
|
|
const linkedApplication = hasLinkedApplication(values)
|
|
|
|
|
|
const applicationReason = values.application_reason || ''
|
|
|
|
|
|
const applicationLocation = values.application_location || ''
|
|
|
|
|
|
const applicationAmount = values.application_amount || values.application_amount_label || ''
|
|
|
|
|
|
const applicationBusinessTime = values.application_business_time || ''
|
2026-06-02 16:22:59 +08:00
|
|
|
|
const applicationTransportMode = values.application_transport_mode || ''
|
2026-06-02 14:01:51 +08:00
|
|
|
|
const fieldLines = []
|
|
|
|
|
|
if (linkedApplication) {
|
|
|
|
|
|
const applicationParts = buildApplicationSummaryParts(values)
|
|
|
|
|
|
fieldLines.push(`关联申请单:${applicationParts.join(' / ')}`)
|
|
|
|
|
|
fieldLines.push('报销票据:草稿生成后在详情中上传')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
getGuidedReimbursementSteps(current.expenseType).forEach((step) => {
|
|
|
|
|
|
const value = step.key === 'attachments'
|
|
|
|
|
|
? (values.attachment_names?.length ? values.attachment_names.join('、') : values.attachments || '稍后上传')
|
|
|
|
|
|
: values[step.key]
|
|
|
|
|
|
fieldLines.push(`${step.summaryLabel}:${value || '待补充'}`)
|
|
|
|
|
|
})
|
2026-05-27 14:35:17 +08:00
|
|
|
|
}
|
2026-05-23 19:54:42 +08:00
|
|
|
|
const rawText = [
|
|
|
|
|
|
`报销类型:${typeLabel}`,
|
|
|
|
|
|
...fieldLines
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
const reviewFormValues = {
|
|
|
|
|
|
expense_type: typeLabel,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
reason: values.reason || applicationReason || values.customer_name || '',
|
2026-05-23 19:54:42 +08:00
|
|
|
|
customer_name: values.customer_name || '',
|
|
|
|
|
|
participants: values.participants || '',
|
2026-06-02 14:01:51 +08:00
|
|
|
|
location: values.location || applicationLocation || '',
|
|
|
|
|
|
time_range: values.time_range || applicationBusinessTime || '',
|
2026-06-02 16:22:59 +08:00
|
|
|
|
transport_mode: values.transport_mode || applicationTransportMode || '',
|
2026-06-02 14:01:51 +08:00
|
|
|
|
amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''),
|
2026-06-03 15:46:56 +08:00
|
|
|
|
attachments: Array.isArray(values.attachment_names) ? values.attachment_names.join('、') : '',
|
2026-05-27 14:35:17 +08:00
|
|
|
|
application_claim_id: values.application_claim_id || '',
|
|
|
|
|
|
application_claim_no: values.application_claim_no || '',
|
|
|
|
|
|
application_reason: values.application_reason || '',
|
|
|
|
|
|
application_location: values.application_location || '',
|
2026-06-02 14:01:51 +08:00
|
|
|
|
application_amount: values.application_amount || '',
|
|
|
|
|
|
application_amount_label: values.application_amount_label || '',
|
|
|
|
|
|
application_business_time: values.application_business_time || '',
|
2026-06-02 16:22:59 +08:00
|
|
|
|
application_days: values.application_days || '',
|
|
|
|
|
|
application_transport_mode: values.application_transport_mode || '',
|
|
|
|
|
|
application_lodging_daily_cap: values.application_lodging_daily_cap || '',
|
|
|
|
|
|
application_subsidy_daily_cap: values.application_subsidy_daily_cap || '',
|
|
|
|
|
|
application_transport_policy: values.application_transport_policy || '',
|
|
|
|
|
|
application_policy_estimate: values.application_policy_estimate || '',
|
|
|
|
|
|
application_rule_name: values.application_rule_name || '',
|
|
|
|
|
|
application_rule_version: values.application_rule_version || '',
|
2026-06-02 14:01:51 +08:00
|
|
|
|
application_date: values.application_date || ''
|
2026-05-23 19:54:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
rawText,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
userText: '生成报销草稿',
|
|
|
|
|
|
pendingText: '正在生成报销草稿...',
|
2026-05-23 19:54:42 +08:00
|
|
|
|
systemGenerated: true,
|
|
|
|
|
|
files,
|
|
|
|
|
|
extraContext: {
|
|
|
|
|
|
draft_claim_id: '',
|
2026-06-02 14:01:51 +08:00
|
|
|
|
review_action: 'save_draft',
|
2026-05-23 19:54:42 +08:00
|
|
|
|
user_input_text: rawText,
|
|
|
|
|
|
expense_scene_selection: {
|
|
|
|
|
|
expense_type: type?.key || current.expenseType || 'other',
|
|
|
|
|
|
expense_type_label: typeLabel,
|
2026-05-27 14:35:17 +08:00
|
|
|
|
original_message: rawText,
|
|
|
|
|
|
application_claim_id: values.application_claim_id || '',
|
|
|
|
|
|
application_claim_no: values.application_claim_no || ''
|
2026-05-23 19:54:42 +08:00
|
|
|
|
},
|
|
|
|
|
|
review_form_values: reviewFormValues
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function shouldConfirmGuidedInterruption(text, state) {
|
|
|
|
|
|
const current = normalizeGuidedFlowState(state)
|
|
|
|
|
|
if (!current.mode || current.pendingInterruptionText) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
const normalized = normalizeText(text)
|
|
|
|
|
|
if (!normalized || NO_ATTACHMENT_TEXT_PATTERN.test(normalized)) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return INTERRUPTION_PATTERN.test(normalized)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedInterruptionText(text) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
`我看到你刚才输入的是:“${normalizeText(text)}”。`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
'这看起来像一个新的问题。你想继续填写当前引导,还是先暂停当前引导并处理这个问题?'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedInterruptionActions() {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '继续填写',
|
|
|
|
|
|
description: '保留当前引导,继续回答这一项',
|
|
|
|
|
|
icon: 'mdi mdi-pencil-outline',
|
|
|
|
|
|
action_type: GUIDED_ACTION_CONTINUE_FILLING
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '暂停当前引导并处理这个问题',
|
|
|
|
|
|
description: '暂停引导,把刚才输入交给财务助手处理',
|
|
|
|
|
|
icon: 'mdi mdi-chat-processing-outline',
|
|
|
|
|
|
action_type: GUIDED_ACTION_PROCESS_INTERRUPTION
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function createGuidedStatusQueryState() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...createEmptyGuidedFlowState(),
|
|
|
|
|
|
mode: GUIDED_FLOW_MODE_STATUS_QUERY,
|
|
|
|
|
|
stepKey: 'query_mode'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedStatusQueryStartText() {
|
|
|
|
|
|
return [
|
|
|
|
|
|
'你想按什么条件查询单据状态?',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'先选查询方式,我再向你收集对应条件。'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedQueryModeActions() {
|
|
|
|
|
|
return GUIDED_QUERY_MODES.map((option) => ({
|
|
|
|
|
|
label: option.label,
|
|
|
|
|
|
description: option.description,
|
|
|
|
|
|
icon: option.icon,
|
|
|
|
|
|
action_type: GUIDED_ACTION_SELECT_QUERY_MODE,
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
query_mode: option.key,
|
|
|
|
|
|
query_mode_label: option.label
|
|
|
|
|
|
}
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedQueryStatusActions() {
|
|
|
|
|
|
return GUIDED_QUERY_STATUS_OPTIONS.map((option) => ({
|
|
|
|
|
|
label: option.label,
|
|
|
|
|
|
description: option.description,
|
|
|
|
|
|
icon: 'mdi mdi-checkbox-marked-circle-outline',
|
|
|
|
|
|
action_type: GUIDED_ACTION_SELECT_QUERY_STATUS,
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
query_status: option.key,
|
|
|
|
|
|
query_status_label: option.label
|
|
|
|
|
|
}
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function resolveGuidedQueryModeFromText(text) {
|
|
|
|
|
|
const normalized = normalizeText(text)
|
|
|
|
|
|
if (!normalized) return ''
|
|
|
|
|
|
const exact = GUIDED_QUERY_MODES.find((item) => normalized === item.label || normalized === item.key)
|
|
|
|
|
|
if (exact) return exact.key
|
2026-05-26 09:15:14 +08:00
|
|
|
|
if (/单号|编号|EXP-|APP-|AP-|RE-|AD-/i.test(normalized)) return 'claim_no'
|
2026-05-23 19:54:42 +08:00
|
|
|
|
if (/状态|草稿|审批|退回|归档|完成/.test(normalized)) return 'status'
|
|
|
|
|
|
if (/上周|本周|去年|今年|月份|时间|日期|[0-9]{4}-[0-9]{2}/.test(normalized)) return 'time_range'
|
|
|
|
|
|
return 'keyword'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function selectGuidedQueryMode(state, queryMode) {
|
|
|
|
|
|
const current = normalizeGuidedFlowState(state)
|
|
|
|
|
|
const mode = GUIDED_QUERY_MODES.find((item) => item.key === normalizeText(queryMode))
|
|
|
|
|
|
if (!mode) {
|
|
|
|
|
|
return current
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
...current,
|
|
|
|
|
|
mode: GUIDED_FLOW_MODE_STATUS_QUERY,
|
|
|
|
|
|
stepKey: mode.key === 'status' ? 'status_value' : 'query_value',
|
|
|
|
|
|
values: {
|
|
|
|
|
|
...current.values,
|
|
|
|
|
|
query_mode: mode.key,
|
|
|
|
|
|
query_mode_label: mode.label
|
|
|
|
|
|
},
|
|
|
|
|
|
pendingInterruptionText: ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedQueryPromptText(state) {
|
|
|
|
|
|
const current = normalizeGuidedFlowState(state)
|
|
|
|
|
|
const mode = normalizeText(current.values.query_mode)
|
|
|
|
|
|
if (!mode) {
|
|
|
|
|
|
return buildGuidedStatusQueryStartText()
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mode === 'status') {
|
|
|
|
|
|
return [
|
|
|
|
|
|
'请选择要查询的单据状态。',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'我会按所选状态筛选最近的报销单据。'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
const prompts = {
|
2026-05-26 09:15:14 +08:00
|
|
|
|
claim_no: '请输入单据编号,例如 RE-20260525103045-ABCDEFGH。',
|
2026-05-23 19:54:42 +08:00
|
|
|
|
time_range: '请输入查询时间范围,例如:上周、今年 5 月、2025 年全年。',
|
|
|
|
|
|
keyword: '请输入地点、客户或事由关键词,例如:上海电力、北京、服务器部署。'
|
|
|
|
|
|
}
|
|
|
|
|
|
return prompts[mode] || '请补充查询条件。'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildGuidedStatusQueryText(state, valueText) {
|
|
|
|
|
|
const current = normalizeGuidedFlowState(state)
|
|
|
|
|
|
const mode = normalizeText(current.values.query_mode)
|
|
|
|
|
|
const value = normalizeText(valueText)
|
|
|
|
|
|
if (mode === 'claim_no') {
|
|
|
|
|
|
return `帮我查询单号 ${value} 的报销单状态`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mode === 'status') {
|
|
|
|
|
|
return `帮我查询${value}的报销单据,筛选最近的 5 条记录`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mode === 'time_range') {
|
|
|
|
|
|
return `帮我查询${value}提交或发生的报销单据状态,筛选最近的 5 条记录`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `帮我查询地点或事由包含“${value}”的报销单据状态,筛选最近的 5 条记录`
|
|
|
|
|
|
}
|