refactor(travel): split reimbursement create workflow
完整修改内容: - 拆分 TravelReimbursementCreateView:提取审核面板纯模型、消息操作、建议动作处理、生命周期 watcher/UI 映射、小财管家运行时、续办流程和运行时文本模型,减少主组件继续堆叠业务分支。 - 调整申请预览链路:新增本地申请意图 gate,完善复杂差旅申请的大模型复核判断、交通方式缺失/候选识别、规则中心交通费用预估合并和申请冲突处理。 - 优化小财管家流程:抽出 steward typewriter 分段策略,避免 Markdown 表格逐字闪烁;补齐跨助手 carry、字段补齐续办、文本确认提交和行程规划推荐动作。 - 调整消息与样式:移除申请预览日期 chip 样式,收敛申请卡片/报销草稿消息的展示与复制、朗读、反馈入口逻辑。 - 更新测试:将源码锚点迁移到新模块,覆盖申请预览、提交确认、小财管家续办、引导流和审核抽屉相关断言。 验证: - node --check web/src/views/scripts/TravelReimbursementCreateView.js 及新增拆分模块 - npm --prefix web run build - node --test web/tests/expense-application-fast-preview.test.mjs web/tests/expense-application-submit-rich-confirm.test.mjs web/tests/travel-reimbursement-guided-flow.test.mjs 说明: - 后端/规则/容器配置/Audit 页面等工作区已有改动未纳入本提交。 - 容器内后端定向 pytest 曾运行 timeout 180s /tmp/x-financial-server-venv/bin/pytest -q <相关后端测试>,180 秒超时且超时前已有失败标记,未作为通过项记录。 - TravelReimbursementCreateView 当前仍超过 800 行,后续仍需继续拆分;本提交先把新增职责模块控制在 800 行内,阻止主类/主模块继续膨胀。
This commit is contained in:
@@ -1,14 +1,3 @@
|
||||
.application-preview-date-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
|
||||
color: var(--theme-primary-active, #255b7d);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.application-draft-preview {
|
||||
width: min(100%, 620px);
|
||||
max-width: 620px;
|
||||
|
||||
@@ -532,10 +532,11 @@
|
||||
|
||||
.message-answer-markdown :deep(table) {
|
||||
width: 100%;
|
||||
min-width: 460px;
|
||||
min-width: 560px;
|
||||
border: 0;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
background: #ffffff;
|
||||
font-size: inherit;
|
||||
}
|
||||
@@ -547,6 +548,25 @@
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(th:first-child),
|
||||
.message-answer-markdown :deep(td:first-child) {
|
||||
width: 88px;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(th:last-child),
|
||||
.message-answer-markdown :deep(td:last-child) {
|
||||
width: 112px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(th) {
|
||||
@@ -786,30 +806,6 @@
|
||||
border-top: 1px solid #e6edf5;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row {
|
||||
animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row:nth-child(2) {
|
||||
animation-delay: 35ms;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row:nth-child(3) {
|
||||
animation-delay: 70ms;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row:nth-child(4) {
|
||||
animation-delay: 105ms;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row:nth-child(5) {
|
||||
animation-delay: 140ms;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row:nth-child(n + 6) {
|
||||
animation-delay: 165ms;
|
||||
}
|
||||
|
||||
.application-preview-row.editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -207,7 +207,6 @@
|
||||
<template v-else>
|
||||
<span
|
||||
class="application-preview-text"
|
||||
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
|
||||
>{{ row.value }}</span>
|
||||
<button
|
||||
v-if="row.editable"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import ontologyBusinessContract from '../../../shared/ontology_business_contract.json' with { type: 'json' }
|
||||
|
||||
export const ASSISTANT_SCOPE_ACTION_SWITCH = 'switch_assistant_session'
|
||||
export const ASSISTANT_SCOPE_ACTION_UNSUPPORTED = 'unsupported_business_intent'
|
||||
|
||||
export const ASSISTANT_SCOPE_SESSION_APPLICATION = 'application'
|
||||
export const ASSISTANT_SCOPE_SESSION_EXPENSE = 'expense'
|
||||
@@ -6,7 +9,7 @@ export const ASSISTANT_SCOPE_SESSION_APPROVAL = 'approval'
|
||||
export const ASSISTANT_SCOPE_SESSION_KNOWLEDGE = 'knowledge'
|
||||
export const ASSISTANT_SCOPE_SESSION_STEWARD = 'steward'
|
||||
|
||||
const SESSION_SCOPE_CONFIG = {
|
||||
const FALLBACK_SESSION_SCOPE_CONFIG = {
|
||||
[ASSISTANT_SCOPE_SESSION_STEWARD]: {
|
||||
label: '小财管家',
|
||||
icon: 'mdi mdi-account-tie-outline',
|
||||
@@ -34,7 +37,10 @@ const SESSION_SCOPE_CONFIG = {
|
||||
}
|
||||
}
|
||||
|
||||
const ONTOLOGY_BUSINESS_CONTRACT = ontologyBusinessContract || {}
|
||||
const SESSION_SCOPE_CONFIG = ONTOLOGY_BUSINESS_CONTRACT.sessions || FALLBACK_SESSION_SCOPE_CONFIG
|
||||
const SESSION_SCOPE_TYPES = Object.keys(SESSION_SCOPE_CONFIG)
|
||||
const BUSINESS_SIGNAL_GROUPS = ONTOLOGY_BUSINESS_CONTRACT.businessSignals || {}
|
||||
|
||||
const APPLICATION_PATTERN =
|
||||
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|申请出差|差旅申请|申请差旅|采购申请|用款申请|预算申请|申请材料|材料清单|先申请|立项申请/
|
||||
@@ -56,6 +62,45 @@ const KNOWLEDGE_PATTERN =
|
||||
/制度|政策|标准|规则|规定|流程|口径|依据|上限|额度|补贴|住宿标准|差旅标准|报销标准|票据要求|可不可以|能不能|怎么规定|如何计算|怎么算/
|
||||
const EXPENSE_OPERATION_PATTERN = /发起报销|报销单|票据|发票|火车票|高铁票|机票|的士票|草稿|归集|上传|关联单据|继续下一步/
|
||||
const CURRENT_CLAIM_RISK_PATTERN = /这张|当前|本单|该单|单据|风险|超标|异常|重复|待补/
|
||||
const FINANCE_OPERATING_PATTERN = buildKeywordPattern([
|
||||
...(BUSINESS_SIGNAL_GROUPS.budget || []),
|
||||
...(BUSINESS_SIGNAL_GROUPS.accounts_receivable || []),
|
||||
...(BUSINESS_SIGNAL_GROUPS.accounts_payable || [])
|
||||
])
|
||||
const CONTEXTUAL_FOLLOW_UP_PATTERN = buildExactKeywordPattern(ONTOLOGY_BUSINESS_CONTRACT.contextualFollowUps || [])
|
||||
|
||||
export const SUPPORTED_BUSINESS_SCOPE_TEXT = Array.isArray(ONTOLOGY_BUSINESS_CONTRACT.supportedBusinessScopes)
|
||||
? ONTOLOGY_BUSINESS_CONTRACT.supportedBusinessScopes
|
||||
: [
|
||||
'费用申请/事前审批',
|
||||
'报销与票据识别',
|
||||
'审批审核与风险解释',
|
||||
'财务制度、报销标准和流程规则问答',
|
||||
'预算、应收、应付等财务经营查询',
|
||||
'小财管家多任务拆解和附件归集'
|
||||
]
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function buildKeywordPattern(keywords = []) {
|
||||
const source = keywords
|
||||
.map((keyword) => String(keyword || '').trim())
|
||||
.filter(Boolean)
|
||||
.map(escapeRegExp)
|
||||
.join('|')
|
||||
return source ? new RegExp(source) : /$a/
|
||||
}
|
||||
|
||||
function buildExactKeywordPattern(keywords = []) {
|
||||
const source = keywords
|
||||
.map((keyword) => String(keyword || '').trim())
|
||||
.filter(Boolean)
|
||||
.map(escapeRegExp)
|
||||
.join('|')
|
||||
return source ? new RegExp(`^(${source})$`) : /$a/
|
||||
}
|
||||
|
||||
function normalizeSessionType(sessionType) {
|
||||
const normalized = String(sessionType || '').trim()
|
||||
@@ -131,6 +176,10 @@ export function inferAssistantScopeTarget(rawText, options = {}) {
|
||||
return ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||
}
|
||||
|
||||
if (FINANCE_OPERATING_PATTERN.test(text)) {
|
||||
return ASSISTANT_SCOPE_SESSION_STEWARD
|
||||
}
|
||||
|
||||
if (knowledgeMatched && !expenseMatched && !approvalMatched && !applicationMatched) {
|
||||
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
|
||||
}
|
||||
@@ -198,10 +247,58 @@ function buildScopeBoundaryText(currentSessionType, targetSessionType) {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function shouldAllowContextualFollowUp(rawText, currentSessionType, options = {}) {
|
||||
const text = normalizeText(rawText)
|
||||
if (options.hasActiveReviewPayload && CURRENT_CLAIM_RISK_PATTERN.test(text)) {
|
||||
return true
|
||||
}
|
||||
if (!text || !CONTEXTUAL_FOLLOW_UP_PATTERN.test(text)) {
|
||||
return false
|
||||
}
|
||||
return Boolean(
|
||||
options.hasActiveReviewPayload ||
|
||||
options.hasPendingApplicationPreview ||
|
||||
options.reviewAction ||
|
||||
currentSessionType
|
||||
)
|
||||
}
|
||||
|
||||
function buildUnsupportedBusinessScopeText() {
|
||||
const message = ONTOLOGY_BUSINESS_CONTRACT.unsupportedIntentMessage || {}
|
||||
return [
|
||||
message.title || '此意图系统不支持。',
|
||||
'',
|
||||
`当前系统支持的业务范围:${SUPPORTED_BUSINESS_SCOPE_TEXT.join('、')}。`,
|
||||
'',
|
||||
message.body || '你这条内容没有识别到相关财务业务意图,系统暂不支持处理。',
|
||||
'',
|
||||
message.retryHint || '请重新描述你的财务业务要求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildUnsupportedBusinessScopeGuard() {
|
||||
return {
|
||||
targetSessionType: '',
|
||||
targetLabel: '不支持的意图',
|
||||
blocked: true,
|
||||
text: buildUnsupportedBusinessScopeText(),
|
||||
meta: ['意图不支持'],
|
||||
suggestedActions: [],
|
||||
actionType: ASSISTANT_SCOPE_ACTION_UNSUPPORTED
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAssistantScopeGuard(rawText, currentSessionType, options = {}) {
|
||||
const normalizedCurrent = normalizeSessionType(currentSessionType)
|
||||
const targetSessionType = inferAssistantScopeTarget(rawText, options)
|
||||
if (!targetSessionType || targetSessionType === normalizedCurrent) {
|
||||
if (!targetSessionType) {
|
||||
if (shouldAllowContextualFollowUp(rawText, normalizedCurrent, options)) {
|
||||
return null
|
||||
}
|
||||
return normalizeText(rawText) ? buildUnsupportedBusinessScopeGuard() : null
|
||||
}
|
||||
|
||||
if (targetSessionType === normalizedCurrent) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const LOCATION_BANDS = {
|
||||
|
||||
const TRANSPORT_PRICE_BASE = {
|
||||
火车: { default: 360, premium: 520, remote: 900, coastal: 520 },
|
||||
飞机: { default: 850, premium: 1100, remote: 1800, coastal: 1050 },
|
||||
飞机: { default: 600, premium: 650, remote: 1600, coastal: 700 },
|
||||
轮船: { default: 320, premium: 480, remote: 680, coastal: 520 }
|
||||
}
|
||||
|
||||
@@ -125,8 +125,8 @@ export function buildMockApplicationTransportEstimate({
|
||||
queryDate,
|
||||
priceFactor,
|
||||
simulatedLatencyMs,
|
||||
source: 'mock_ticket_price_query_v1',
|
||||
confidence: 'mock',
|
||||
source: 'fallback_transport_budget_estimate_v1',
|
||||
confidence: 'fallback',
|
||||
basisText: `预估交通费用 ${amountDisplay}元`
|
||||
}
|
||||
}
|
||||
|
||||
93
web/src/utils/expenseApplicationIntentGate.js
Normal file
93
web/src/utils/expenseApplicationIntentGate.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const APPLICATION_SESSION_TYPE = 'application'
|
||||
const APPLICATION_ASSISTANT_ECHO_PATTERN = /(?:这是)?费用申请核对结果|申请核对结果|请核对上述信息|确认无误后.*提交至审批流程/
|
||||
const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料清单|需要哪些|制度|标准|规则|怎么规定/
|
||||
const APPLICATION_CREATE_PATTERN = /申请|发起|提交|创建|新建|事前|前置|出差|差旅|采购|会务|会议|培训/
|
||||
const APPLICATION_REASON_PATTERN = /支撑|支持|部署|上线|实施|驻场|拜访|验收|培训|协助|处理|办理|参加|服务/
|
||||
|
||||
export function evaluateLocalApplicationIntentGate(rawText, options = {}) {
|
||||
const compact = compactText(rawText)
|
||||
if (String(options.sessionType || '').trim() !== APPLICATION_SESSION_TYPE) {
|
||||
return block('out_of_scope', 0.98, '不是申请会话。')
|
||||
}
|
||||
if (options.systemGenerated || options.reviewAction || Number(options.attachmentCount || 0) > 0) {
|
||||
return block('out_of_scope', 0.96, '系统生成、审核动作或附件场景不走本地申请预览。')
|
||||
}
|
||||
if (!compact) {
|
||||
return block('chitchat_or_noise', 0.95, '输入为空。')
|
||||
}
|
||||
if (APPLICATION_ASSISTANT_ECHO_PATTERN.test(compact)) {
|
||||
return block('assistant_echo', 0.96, '用户输入是助手申请核对文案回显。')
|
||||
}
|
||||
if (APPLICATION_QUERY_PATTERN.test(compact)) {
|
||||
return block('ask_question', 0.86, '用户更像是在查询或询问。')
|
||||
}
|
||||
|
||||
const fields = collectLocalApplicationIntentFields(rawText)
|
||||
const fieldCount = Object.keys(fields).length
|
||||
const hasCreateSignal = APPLICATION_CREATE_PATTERN.test(compact)
|
||||
if (hasCreateSignal && fieldCount >= 2) {
|
||||
return allow('create_application', 0.78, '申请动作和结构化申请事实同时存在。', fields)
|
||||
}
|
||||
if (hasCreateSignal && fieldCount === 1 && hasStrongBusinessSignal(compact)) {
|
||||
return allow('create_application', 0.72, '申请动作和明确业务事实同时存在。', fields)
|
||||
}
|
||||
return block(
|
||||
hasCreateSignal ? 'unknown' : 'chitchat_or_noise',
|
||||
hasCreateSignal ? 0.58 : 0.82,
|
||||
'未识别到足够的新建申请意图。',
|
||||
fields
|
||||
)
|
||||
}
|
||||
|
||||
function allow(intent, confidence, reason, fields = {}) {
|
||||
return {
|
||||
intent,
|
||||
allowed: true,
|
||||
confidence,
|
||||
reason,
|
||||
fields
|
||||
}
|
||||
}
|
||||
|
||||
function block(intent, confidence, reason, fields = {}) {
|
||||
return {
|
||||
intent,
|
||||
allowed: false,
|
||||
confidence,
|
||||
reason,
|
||||
fields
|
||||
}
|
||||
}
|
||||
|
||||
function collectLocalApplicationIntentFields(rawText) {
|
||||
const text = String(rawText || '')
|
||||
const compact = compactText(text)
|
||||
const fields = {}
|
||||
if (/(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|[1-9]\d?月[1-3]?\d日?)/.test(compact)) {
|
||||
fields.time = 'time_text'
|
||||
}
|
||||
if (/(?:\d+|[一二两三四五六七八九十]{1,3})天/.test(compact)) {
|
||||
fields.days = 'days_text'
|
||||
}
|
||||
if (/\d+(?:\.\d+)?\s*(?:万|千|k|K|元|块|人民币)/.test(text)) {
|
||||
fields.amount = 'amount_text'
|
||||
}
|
||||
if (/(?:飞机|机票|航班|火车|高铁|动车|轮船|船票|客轮|渡轮)/.test(compact)) {
|
||||
fields.transportMode = 'transport_text'
|
||||
}
|
||||
if (/(?:去|到|赴|前往)[\u4e00-\u9fa5]{1,24}(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|$)/.test(compact)) {
|
||||
fields.location = 'location_text'
|
||||
}
|
||||
if (APPLICATION_REASON_PATTERN.test(compact)) {
|
||||
fields.reason = 'reason_text'
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
function hasStrongBusinessSignal(compactTextValue) {
|
||||
return APPLICATION_REASON_PATTERN.test(compactTextValue) || /出差|差旅/.test(compactTextValue)
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return String(value || '').replace(/\s+/g, '')
|
||||
}
|
||||
@@ -127,6 +127,13 @@ export function resolveApplicationAmount(ontology) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveApplicationTypedAmount(ontology, type) {
|
||||
const entity = resolveEntity(ontology, type)
|
||||
const rawValue = entity?.normalized_value || entity?.value || ''
|
||||
const numericValue = Number(String(rawValue).replace(/[^\d.]/g, ''))
|
||||
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : 0
|
||||
}
|
||||
|
||||
export function resolveTimeRangeText(ontology) {
|
||||
const range = ontology?.time_range || {}
|
||||
if (range.start_date && range.end_date) {
|
||||
@@ -261,7 +268,8 @@ function cleanupApplicationReasonCandidate(value, location = '') {
|
||||
if (!text) return ''
|
||||
|
||||
text = text
|
||||
.replace(/^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)\s*[::]\s*/u, '')
|
||||
.replace(/(?:请直接生成申请单核对结果|信息足够时生成申请单|但在入库或提交审批前仍需让我确认|请直接生成报销核对结果|需要创建草稿、绑定附件或提交审批前仍需让我确认)[\s\S]*$/u, '')
|
||||
.replace(/^(?:类型|申请类型|费用类型|报销类型|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)\s*[::]\s*/u, '')
|
||||
.replace(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/gu, '')
|
||||
.replace(/(?:出差|申请)?(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/gu, '')
|
||||
.replace(/(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元|人民币)?/gu, '')
|
||||
@@ -281,6 +289,7 @@ function cleanupApplicationReasonCandidate(value, location = '') {
|
||||
}
|
||||
|
||||
if (!text) return ''
|
||||
if (isInvalidApplicationReason(text)) return ''
|
||||
if (/^20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?$/.test(text)) return ''
|
||||
if (/^(?:\d+|[一二两三四五六七八九十]{1,3})\s*天$/.test(text)) return ''
|
||||
if (/^[\u4e00-\u9fa5]{1,8}$/.test(text) && !/服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(text)) {
|
||||
@@ -303,22 +312,44 @@ export function resolveApplicationReason(prompt, ontology = null) {
|
||||
const reasonEntity = resolveEntity(ontology, 'reason') || resolveEntity(ontology, 'business_reason')
|
||||
const entityReason = String(reasonEntity?.normalized_value || reasonEntity?.value || '').trim()
|
||||
if (entityReason) {
|
||||
return cleanupApplicationReasonCandidate(entityReason, location) || entityReason
|
||||
const cleanedEntityReason = cleanupApplicationReasonCandidate(entityReason, location)
|
||||
if (cleanedEntityReason && !isInvalidApplicationReason(cleanedEntityReason)) {
|
||||
return cleanedEntityReason
|
||||
}
|
||||
}
|
||||
|
||||
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
|
||||
if (labeled) {
|
||||
return cleanupApplicationReasonCandidate(labeled, location) || labeled
|
||||
const cleanedLabeledReason = cleanupApplicationReasonCandidate(labeled, location)
|
||||
if (cleanedLabeledReason && !isInvalidApplicationReason(cleanedLabeledReason)) {
|
||||
return cleanedLabeledReason
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = String(prompt || '')
|
||||
.split(/[\n,。;;]+/u)
|
||||
.map((item) => cleanupApplicationReasonCandidate(item, location))
|
||||
.filter(Boolean)
|
||||
.filter((item) => item && !isSystemGeneratedApplicationReason(item) && !isInvalidApplicationReason(item))
|
||||
const businessCandidate = candidates.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item))
|
||||
return businessCandidate || candidates.sort((left, right) => right.length - left.length)[0] || ''
|
||||
}
|
||||
|
||||
function isInvalidApplicationReason(value = '') {
|
||||
const compact = String(value || '').replace(/\s+/g, '')
|
||||
if (!compact) return true
|
||||
if (/^(?:类型|申请类型|费用类型|报销类型)[::]?/.test(compact)) return true
|
||||
if (/^(?:差旅费用申请|交通费用申请|住宿费用申请|费用申请|差旅费|交通费|住宿费)$/.test(compact)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isSystemGeneratedApplicationReason(value = '') {
|
||||
const compact = String(value || '').replace(/\s+/g, '')
|
||||
return compact.startsWith('小财管家继续执行')
|
||||
|| compact.startsWith('处理要求')
|
||||
|| compact.startsWith('已识别信息')
|
||||
|| compact.startsWith('用户已补充')
|
||||
}
|
||||
|
||||
function resolveApplicationTransportMode(ontology, prompt) {
|
||||
const transportEntity = resolveEntity(ontology, 'transport_mode')
|
||||
|| resolveEntity(ontology, 'transport')
|
||||
@@ -383,6 +414,13 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
|
||||
const reason = resolveApplicationReason(prompt, ontology) || '待补充'
|
||||
const days = resolvePromptDays(prompt)
|
||||
const transportMode = resolveApplicationTransportMode(ontology, prompt)
|
||||
const transportEstimatedAmount = resolveApplicationTypedAmount(ontology, 'transport_estimated_amount')
|
||||
const trainEstimatedAmount = resolveApplicationTypedAmount(ontology, 'train_estimated_amount')
|
||||
const flightEstimatedAmount = resolveApplicationTypedAmount(ontology, 'flight_estimated_amount')
|
||||
const hotelAmount = resolveApplicationTypedAmount(ontology, 'hotel_amount')
|
||||
const allowanceAmount = resolveApplicationTypedAmount(ontology, 'allowance_amount')
|
||||
const policyTotalAmount = resolveApplicationTypedAmount(ontology, 'policy_total_amount')
|
||||
const reimbursementAmount = resolveApplicationTypedAmount(ontology, 'reimbursement_amount')
|
||||
|
||||
const fields = {
|
||||
documentType: documentTypeEntity?.normalized_value || 'expense_application',
|
||||
@@ -393,6 +431,13 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
|
||||
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
|
||||
amount: amount.value,
|
||||
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
|
||||
transportEstimatedAmount,
|
||||
trainEstimatedAmount,
|
||||
flightEstimatedAmount,
|
||||
hotelAmount,
|
||||
allowanceAmount,
|
||||
policyTotalAmount,
|
||||
reimbursementAmount,
|
||||
timeRange,
|
||||
location,
|
||||
reason,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
|
||||
import { evaluateLocalApplicationIntentGate } from './expenseApplicationIntentGate.js'
|
||||
import {
|
||||
buildMockApplicationTransportEstimate,
|
||||
formatApplicationEstimateMoney,
|
||||
parseApplicationEstimateMoney,
|
||||
buildSystemApplicationEstimate
|
||||
} from './expenseApplicationEstimate.js'
|
||||
import { getTodayDateValue } from './workbenchComposerDate.js'
|
||||
|
||||
const APPLICATION_SESSION_TYPE = 'application'
|
||||
const APPLICATION_CREATE_PATTERN = /申请|事前|前置|预算|出差|差旅|采购|会务|会议|培训|办公用品|交通|住宿/
|
||||
const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料清单|需要哪些|制度|标准|规则|怎么规定/
|
||||
|
||||
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
|
||||
{ key: 'applicationType', label: '申请类型' },
|
||||
{ key: 'applicant', label: '姓名', editable: false, required: false },
|
||||
@@ -111,6 +110,29 @@ function buildEndDateFromDays(startText, daysText = '') {
|
||||
return formatIsoDate(end)
|
||||
}
|
||||
|
||||
function buildDateFromMonthDay(year, month, day) {
|
||||
const normalized = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return parseIsoDate(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function resolveShortMonthDayRange(text, options = {}) {
|
||||
const match = String(text || '').match(
|
||||
/(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?\s*(?:至|到|~|—|–|--|-)\s*(?:(?<endMonth>\d{1,2})月)?(?<endDay>\d{1,2})日/u
|
||||
)
|
||||
if (!match?.groups) return ''
|
||||
|
||||
const referenceYear = Number(resolvePreviewToday(options).slice(0, 4))
|
||||
const startMonth = Number(match.groups.startMonth)
|
||||
const startDay = Number(match.groups.startDay)
|
||||
const endMonth = Number(match.groups.endMonth || match.groups.startMonth)
|
||||
const endDay = Number(match.groups.endDay)
|
||||
const startDate = buildDateFromMonthDay(referenceYear, startMonth, startDay)
|
||||
const endYear = endMonth < startMonth ? referenceYear + 1 : referenceYear
|
||||
const endDate = buildDateFromMonthDay(endYear, endMonth, endDay)
|
||||
if (!startDate || !endDate) return ''
|
||||
return startDate === endDate ? startDate : `${startDate} 至 ${endDate}`
|
||||
}
|
||||
|
||||
function resolveDaysFromDateRange(rangeText) {
|
||||
const match = String(rangeText || '').match(/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*至\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/)
|
||||
if (!match) return ''
|
||||
@@ -125,6 +147,80 @@ export function resolveApplicationDaysFromDateRange(rangeText) {
|
||||
return resolveDaysFromDateRange(rangeText)
|
||||
}
|
||||
|
||||
function resolveApplicationValidationIssues(fields = {}) {
|
||||
const issues = []
|
||||
const rangeDaysText = resolveDaysFromDateRange(fields.time)
|
||||
const rangeDays = parseApplicationDaysValue(rangeDaysText)
|
||||
const explicitDays = parseApplicationDaysValue(fields.days)
|
||||
if (rangeDays && explicitDays && rangeDays !== explicitDays) {
|
||||
issues.push({
|
||||
code: 'time_days_conflict',
|
||||
field: 'days',
|
||||
message: `申请时间 ${fields.time} 按自然日为 ${rangeDays} 天,但填写的是 ${explicitDays} 天。`
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function shouldTrustModelApplicationFields(preview = {}) {
|
||||
const status = String(preview?.modelReviewStatus || '').trim()
|
||||
const strategy = String(preview?.parseStrategy || preview?.parse_strategy || '').trim()
|
||||
return Boolean(preview?.modelRefined)
|
||||
|| status === 'completed'
|
||||
|| strategy === 'llm_primary'
|
||||
}
|
||||
|
||||
function resolveApplicationSourceValidationIssues(sourceText = '', fields = {}, preview = {}) {
|
||||
if (shouldTrustModelApplicationFields(preview)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const issues = []
|
||||
const locationCandidates = extractApplicationLocationCandidates(sourceText)
|
||||
if (locationCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'location_candidates_conflict',
|
||||
field: 'location',
|
||||
message: `当前输入里同时出现多个地点:${locationCandidates.join('、')}。请确认本次申请地点。`
|
||||
})
|
||||
}
|
||||
|
||||
const transportCandidates = extractApplicationTransportCandidates(sourceText)
|
||||
if (transportCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'transport_candidates_conflict',
|
||||
field: 'transportMode',
|
||||
message: `当前输入里同时出现多个出行方式:${transportCandidates.join('、')}。请确认本次申请使用哪一种。`
|
||||
})
|
||||
}
|
||||
|
||||
const amountCandidates = extractApplicationAmountCandidates(sourceText)
|
||||
if (amountCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'amount_candidates_conflict',
|
||||
field: 'amount',
|
||||
message: `当前输入里同时出现多个金额:${amountCandidates.join('、')}。请确认本次申请金额。`
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
export function shouldRequireApplicationModelReview(rawText = '') {
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = compactText(text)
|
||||
if (!compact) return false
|
||||
|
||||
const hasDateRange = /(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|(?:\d{1,2}月)?\d{1,2}日?)\s*(?:至|到|~|—|–|--|-)\s*(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|\d{1,2}月?\d{1,2}日?)/u.test(text)
|
||||
const hasTravelIntent = /差旅|出差|去|到|赴|前往/.test(compact)
|
||||
const hasTransport = /高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮/.test(compact)
|
||||
const hasBusinessPurpose = /服务|支撑|支持|辅助|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(compact)
|
||||
const hasInlinePurpose = hasBusinessPurpose && !/(?:事由|申请事由|出差事由|原因|用途)\s*[::]/.test(text)
|
||||
const hasMultipleClauses = /[,,。;;\n]/.test(text) || compact.length >= 24
|
||||
const signalCount = [hasDateRange, hasTravelIntent, hasTransport, hasBusinessPurpose].filter(Boolean).length
|
||||
|
||||
return (hasInlinePurpose && signalCount >= 2) || (hasMultipleClauses && signalCount >= 3)
|
||||
}
|
||||
|
||||
export function resolveApplicationDateRange(rangeText, daysText = '') {
|
||||
const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
const startDate = normalizeDateText(matchedDates[0] || '')
|
||||
@@ -179,14 +275,106 @@ function resolveApplicationType(text) {
|
||||
function resolveApplicationAmount(text) {
|
||||
const compact = compactText(text)
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)?/u,
|
||||
/(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)/u
|
||||
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?)/u,
|
||||
/(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/u
|
||||
])
|
||||
if (labeled) return `${labeled}元`
|
||||
const normalized = normalizeApplicationAmountText(labeled)
|
||||
if (normalized) return normalized
|
||||
if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeApplicationAmountText(value) {
|
||||
const text = String(value || '').replace(/[,,]/g, '').trim()
|
||||
const match = text.match(/(?<number>\d+(?:\.\d+)?)\s*(?<unit>万|千|k|K)?/u)
|
||||
if (!match?.groups) return ''
|
||||
let amount = Number(match.groups.number)
|
||||
if (!Number.isFinite(amount) || amount <= 0) return ''
|
||||
const unit = String(match.groups.unit || '').toLowerCase()
|
||||
if (unit === '万') amount *= 10000
|
||||
if (unit === '千' || unit === 'k') amount *= 1000
|
||||
return `${Number.isInteger(amount) ? amount : Number(amount.toFixed(2))}元`
|
||||
}
|
||||
|
||||
function extractApplicationLocationCandidates(text) {
|
||||
const candidates = []
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])
|
||||
if (labeled) candidates.push(normalizeLocationCandidate(labeled))
|
||||
|
||||
const compact = compactText(text)
|
||||
const patterns = [
|
||||
/(?:去|到|赴|前往)(?<value>[\u4e00-\u9fa5]{1,24})/gu,
|
||||
/(?<value>[\u4e00-\u9fa5]{1,12})(?:出差|驻场)/gu
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
for (const match of compact.matchAll(pattern)) {
|
||||
candidates.push(normalizeLocationCandidate(match.groups?.value || ''))
|
||||
}
|
||||
}
|
||||
return uniqueApplicationCandidates(candidates)
|
||||
.filter((item) => !isInvalidApplicationLocationCandidate(item))
|
||||
}
|
||||
|
||||
function normalizeLocationCandidate(value) {
|
||||
let cleaned = String(value || '').replace(/\s+/g, '')
|
||||
for (const marker of ['前往', '去', '到', '赴']) {
|
||||
if (cleaned.includes(marker)) {
|
||||
cleaned = cleaned.slice(cleaned.lastIndexOf(marker) + marker.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
cleaned = cleaned
|
||||
.replace(/^(?:去|到|赴|前往)/u, '')
|
||||
.replace(/(?:出差|驻场|现场|支撑|支持|部署|上线|实施|拜访|验收|会议|采购|培训|协助|处理|办理|参加|进行).*$/u, '')
|
||||
.replace(/[::,,。;;、\s]/g, '')
|
||||
return cleaned.replace(/^(北京|上海|天津|重庆)市$/u, '$1')
|
||||
}
|
||||
|
||||
function isInvalidApplicationLocationCandidate(value) {
|
||||
const compact = compactText(value)
|
||||
if (!compact) return true
|
||||
if (/^(?:类型|申请类型|费用类型|报销类型)$/.test(compact)) return true
|
||||
if (/^(?:费用申请|差旅费用申请|交通费用申请|住宿费用申请|会务费用申请|采购费用申请|培训费用申请|报销申请|申请)$/.test(compact)) return true
|
||||
if (/(?:类型|申请类型|费用类型|报销类型)/.test(compact)) return true
|
||||
if (/(?:交通方式|出行方式|交通工具|预算|金额|费用|票据|附件|天数|时间|事由|原因|用途)/.test(compact)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function extractApplicationTransportCandidates(text) {
|
||||
const compact = compactText(text)
|
||||
return uniqueApplicationCandidates([
|
||||
resolveApplicationTransportMode(resolveFirstMatch(text, [
|
||||
/(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])),
|
||||
/高铁|动车|火车|铁路/.test(compact) ? '火车' : '',
|
||||
/飞机|机票|航班/.test(compact) ? '飞机' : '',
|
||||
/轮船|船票|客轮|渡轮|邮轮/.test(compact) ? '轮船' : ''
|
||||
])
|
||||
}
|
||||
|
||||
function extractApplicationAmountCandidates(text) {
|
||||
const candidates = []
|
||||
const source = String(text || '')
|
||||
const labelPattern = /(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?\s*(?:元|块|人民币)?)/gu
|
||||
for (const match of source.matchAll(labelPattern)) {
|
||||
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
|
||||
}
|
||||
const amountPattern = /(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/gu
|
||||
for (const match of source.matchAll(amountPattern)) {
|
||||
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
|
||||
}
|
||||
return uniqueApplicationCandidates(candidates)
|
||||
}
|
||||
|
||||
function uniqueApplicationCandidates(values) {
|
||||
return values
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.filter((item, index, list) => list.indexOf(item) === index)
|
||||
}
|
||||
|
||||
function resolveCurrentUserGrade(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.grade
|
||||
@@ -282,12 +470,45 @@ function formatDailyPolicyMoney(value) {
|
||||
|
||||
function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
|
||||
const mode = String(transportMode || '').trim()
|
||||
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
|
||||
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||
return estimate.basisText
|
||||
}
|
||||
|
||||
function buildTransportEstimateFromPolicyResult(result = {}, fields = {}) {
|
||||
const amount = parseMoneyNumber(result?.transport_estimated_amount)
|
||||
if (!amount || amount <= 0) return null
|
||||
const amountDisplay = formatPolicyMoney(amount)
|
||||
const mode = String(result?.transport_mode || fields.transportMode || '').trim()
|
||||
const origin = String(result?.transport_origin || '').trim()
|
||||
const destination = String(result?.transport_destination || result?.matched_city || fields.location || '').trim()
|
||||
const basis = String(result?.transport_estimate_basis || '').trim()
|
||||
const ruleName = String(result?.transport_estimate_rule_name || '交通费用预估表').trim()
|
||||
const routeText = [origin, destination].filter(Boolean).join('-')
|
||||
const modeText = mode ? `${mode}往返` : '往返'
|
||||
const routeModeText = routeText ? `${routeText}${modeText}` : modeText
|
||||
const displayBasis = routeModeText && basis.startsWith(routeModeText)
|
||||
? basis.slice(routeModeText.length).trim()
|
||||
: basis
|
||||
const basisSuffix = displayBasis ? `(${displayBasis})` : ''
|
||||
return {
|
||||
mode,
|
||||
amount,
|
||||
amountDisplay,
|
||||
routeType: '往返',
|
||||
origin,
|
||||
destination,
|
||||
queryDate: String(result?.travel_date || '').trim(),
|
||||
source: String(result?.transport_estimate_source || 'basic_rule_transport_estimate').trim(),
|
||||
confidence: String(result?.transport_estimate_confidence || 'basic_rule').trim(),
|
||||
basis,
|
||||
ruleCode: String(result?.transport_estimate_rule_code || '').trim(),
|
||||
ruleName,
|
||||
ruleVersion: String(result?.transport_estimate_rule_version || '').trim(),
|
||||
basisText: `当前尚未接通实时票务价格查询 API,无法获取当前实际票价;先按《${ruleName}》${routeModeText}${basisSuffix}暂估 ${amountDisplay}元用于申请阶段预算占用,最终报销以实际票据金额为准`
|
||||
}
|
||||
}
|
||||
|
||||
function ensureApplicationPolicyFields(fields = {}) {
|
||||
const nextFields = { ...fields }
|
||||
if (!String(nextFields.lodgingDailyCap || '').trim()) {
|
||||
@@ -321,6 +542,11 @@ function resolveApplicationTime(text, daysText = '', options = {}) {
|
||||
return `${normalizeDateText(range[1])} 至 ${normalizeDateText(range[2])}`
|
||||
}
|
||||
|
||||
const shortMonthDayRange = resolveShortMonthDayRange(text, options)
|
||||
if (shortMonthDayRange) {
|
||||
return shortMonthDayRange
|
||||
}
|
||||
|
||||
const single = resolveFirstMatch(text, [
|
||||
/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
|
||||
/(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
|
||||
@@ -332,7 +558,7 @@ function resolveApplicationTime(text, daysText = '', options = {}) {
|
||||
}
|
||||
|
||||
function resolveApplicationTimeWithDefault(text, daysText = '', options = {}) {
|
||||
const resolvedTime = resolveApplicationTime(text, daysText)
|
||||
const resolvedTime = resolveApplicationTime(text, daysText, options)
|
||||
if (resolvedTime || !parseApplicationDaysValue(daysText)) {
|
||||
return resolvedTime
|
||||
}
|
||||
@@ -349,7 +575,39 @@ function resolveApplicationLocation(text) {
|
||||
])
|
||||
}
|
||||
|
||||
function looksLikeTransportPromptText(text) {
|
||||
const compact = compactText(text)
|
||||
return /(?:还需要补充|当前还缺|缺少|请先补充|请选择|可以选择|可以选|打算怎么出行|怎么出行).{0,24}(?:出行方式|交通方式|交通工具)/u.test(compact)
|
||||
|| /(?:出行方式|交通方式|交通工具).{0,24}(?:待补充|缺失|请选择|可以选择|可以选)/u.test(compact)
|
||||
}
|
||||
|
||||
function resolveApplicationTransportMode(text) {
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])
|
||||
const labeledMode = normalizeTransportModeOption(labeled, '')
|
||||
if (labeledMode && !looksLikeTransportPromptText(labeled) && !/(?:请选择|可以选择|可以选)/u.test(String(labeled || ''))) {
|
||||
return labeledMode
|
||||
}
|
||||
const fullTextLooksLikePrompt = looksLikeTransportPromptText(text)
|
||||
const segments = String(text || '')
|
||||
.split(/[\n,,。;;]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
for (const segment of segments) {
|
||||
if (looksLikeTransportPromptText(segment)) continue
|
||||
const compactSegment = compactText(segment)
|
||||
if (
|
||||
fullTextLooksLikePrompt
|
||||
&& !/(?:申请|出差|去|到|赴|前往|服务|支撑|支持|部署|上线|实施|拜访|会议)/u.test(compactSegment)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (/高铁|动车|火车|铁路/.test(compactSegment)) return '火车'
|
||||
if (/飞机|机票|航班/.test(compactSegment)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮/.test(compactSegment)) return '轮船'
|
||||
}
|
||||
if (fullTextLooksLikePrompt) return ''
|
||||
const compact = compactText(text)
|
||||
if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
|
||||
if (/飞机|机票|航班/.test(compact)) return '飞机'
|
||||
@@ -360,6 +618,7 @@ function resolveApplicationTransportMode(text) {
|
||||
function stripKnownContextFromReason(value, context = {}) {
|
||||
const location = String(context.location || '').trim()
|
||||
let cleaned = String(value || '')
|
||||
.replace(/(?:请直接生成申请单核对结果|信息足够时生成申请单|但在入库或提交审批前仍需让我确认|请直接生成报销核对结果|需要创建草稿、绑定附件或提交审批前仍需让我确认)[\s\S]*$/u, '')
|
||||
.replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
||||
.replace(/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
||||
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\s*(?:至|到|~|—|–|--)\s*20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
|
||||
@@ -387,10 +646,20 @@ function pickBusinessReasonSegment(text) {
|
||||
const segments = String(text || '')
|
||||
.split(/[,,、。;;\n]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.filter((item) => item && !isSystemGeneratedReasonText(item))
|
||||
return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || ''
|
||||
}
|
||||
|
||||
function isSystemGeneratedReasonText(value = '') {
|
||||
const compact = compactText(value)
|
||||
return compact.startsWith('小财管家继续执行')
|
||||
|| /请直接生成申请单核对结果|信息足够时生成申请单|入库或提交审批前|请直接生成报销核对结果/.test(compact)
|
||||
|| compact.startsWith('处理要求')
|
||||
|| compact.startsWith('已识别信息')
|
||||
|| compact.startsWith('用户已补充')
|
||||
|| /^(?:类型|申请类型|费用类型|报销类型)[::]?(?:差旅费用申请|交通费用申请|住宿费用申请|费用申请|差旅费|交通费|住宿费)?$/.test(compact)
|
||||
}
|
||||
|
||||
function resolveApplicationReason(text, context = {}) {
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:事由|申请事由|出差事由|原因|用途)\s*[::]\s*(?<value>[^,。;;\n]+)/u
|
||||
@@ -401,6 +670,7 @@ function resolveApplicationReason(text, context = {}) {
|
||||
const withoutContext = stripKnownContextFromReason(cleaned, context)
|
||||
const businessSegment = pickBusinessReasonSegment(withoutContext)
|
||||
if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
|
||||
if (isSystemGeneratedReasonText(withoutContext)) return ''
|
||||
return withoutContext
|
||||
}
|
||||
|
||||
@@ -447,7 +717,7 @@ function resolveModelRefinedTransportMode(ontologyFields = {}, rawText = '', cur
|
||||
}
|
||||
|
||||
function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
||||
const numericAmount = Number(fields.amount || 0)
|
||||
const numericAmount = Number(fields.amount || fields.policyTotalAmount || fields.reimbursementAmount || 0)
|
||||
if (Number.isFinite(numericAmount) && numericAmount > 0) {
|
||||
return `${numericAmount}元`
|
||||
}
|
||||
@@ -461,6 +731,14 @@ function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeTypedOntologyAmount(value, fallback = '') {
|
||||
const amount = Number(value || 0)
|
||||
if (Number.isFinite(amount) && amount > 0) {
|
||||
return `${amount}元`
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function buildMissingFields(fields) {
|
||||
return APPLICATION_PREVIEW_FIELD_DEFINITIONS
|
||||
.filter((item) => item.key !== 'applicationType' && item.required !== false)
|
||||
@@ -478,6 +756,14 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
|
||||
const transportMode = String(fields.transportMode || '').trim()
|
||||
const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode)
|
||||
|
||||
if (/差旅|出差/.test(applicationType) && !transportMode) {
|
||||
return {
|
||||
canCalculate: false,
|
||||
reason: '缺少出行方式',
|
||||
payload: null
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldEstimate || !days || !location) {
|
||||
return {
|
||||
canCalculate: false,
|
||||
@@ -492,27 +778,88 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
|
||||
payload: {
|
||||
days,
|
||||
location,
|
||||
grade
|
||||
grade,
|
||||
transport_mode: transportMode || null,
|
||||
origin_location: String(
|
||||
currentUser.location
|
||||
|| currentUser.officeLocation
|
||||
|| currentUser.office_location
|
||||
|| currentUser.baseCity
|
||||
|| currentUser.base_city
|
||||
|| ''
|
||||
).trim() || null,
|
||||
travel_date: resolveApplicationTripDateParts(fields).startDate || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, currentUser = {}) {
|
||||
const fields = { ...(preview?.fields || {}) }
|
||||
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
|
||||
const resultTransportMode = String(result?.transport_mode || '').trim()
|
||||
const fields = {
|
||||
...(preview?.fields || {}),
|
||||
...(!String(preview?.fields?.transportMode || '').trim() && resultTransportMode
|
||||
? { transportMode: resultTransportMode }
|
||||
: {})
|
||||
}
|
||||
const hotelRate = formatPolicyMoney(result?.hotel_rate)
|
||||
const hotelAmount = formatPolicyMoney(result?.hotel_amount)
|
||||
const allowanceRate = formatPolicyMoney(result?.total_allowance_rate)
|
||||
const allowanceAmount = formatPolicyMoney(result?.allowance_amount)
|
||||
const matchedCity = String(result?.matched_city || fields.location || '').trim()
|
||||
const grade = String(result?.grade || fields.grade || resolveCurrentUserGrade(currentUser)).trim()
|
||||
const systemEstimate = buildSystemApplicationEstimate({
|
||||
if (isTravelApplicationType(fields.applicationType) && !String(fields.transportMode || '').trim()) {
|
||||
return normalizeApplicationPreview({
|
||||
...preview,
|
||||
fields: {
|
||||
...fields,
|
||||
grade,
|
||||
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
|
||||
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
|
||||
transportPolicy: APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT,
|
||||
policyEstimate: APPLICATION_POLICY_PENDING_TEXT,
|
||||
matchedCity,
|
||||
ruleName: String(result?.rule_name || '').trim(),
|
||||
ruleVersion: String(result?.rule_version || '').trim(),
|
||||
hotelAmount: hotelAmount ? `${hotelAmount}元` : '',
|
||||
allowanceAmount: allowanceAmount ? `${allowanceAmount}元` : '',
|
||||
transportEstimatedAmount: '',
|
||||
transportEstimateDate: '',
|
||||
transportQueryLatencyMs: '',
|
||||
transportEstimateSource: '',
|
||||
transportEstimateConfidence: '',
|
||||
policyTotalAmount: ''
|
||||
},
|
||||
policyEstimateStatus: 'pending'
|
||||
})
|
||||
}
|
||||
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
|
||||
let systemEstimate = buildSystemApplicationEstimate({
|
||||
transportMode: fields.transportMode,
|
||||
location: matchedCity || fields.location,
|
||||
time: fields.time,
|
||||
lodgingAmount: result?.hotel_amount,
|
||||
allowanceAmount: result?.allowance_amount
|
||||
})
|
||||
const policyTransportEstimate = buildTransportEstimateFromPolicyResult(result, fields)
|
||||
if (policyTransportEstimate) {
|
||||
const lodging = parseApplicationEstimateMoney(result?.hotel_amount)
|
||||
const allowance = parseApplicationEstimateMoney(result?.allowance_amount)
|
||||
const backendTotal = parseApplicationEstimateMoney(result?.total_amount)
|
||||
const totalAmount = backendTotal > 0
|
||||
? backendTotal
|
||||
: policyTransportEstimate.amount + lodging + allowance
|
||||
systemEstimate = {
|
||||
transportEstimate: policyTransportEstimate,
|
||||
transportAmount: policyTransportEstimate.amount,
|
||||
lodgingAmount: lodging,
|
||||
allowanceAmount: allowance,
|
||||
totalAmount,
|
||||
transportAmountDisplay: policyTransportEstimate.amountDisplay,
|
||||
lodgingAmountDisplay: formatApplicationEstimateMoney(lodging),
|
||||
allowanceAmountDisplay: formatApplicationEstimateMoney(allowance),
|
||||
totalAmountDisplay: formatApplicationEstimateMoney(totalAmount)
|
||||
}
|
||||
}
|
||||
const transportEstimate = systemEstimate.transportEstimate
|
||||
const transportText = transportEstimate
|
||||
? `交通 ${systemEstimate.transportAmountDisplay}元 + `
|
||||
@@ -621,22 +968,22 @@ export function applyApplicationPolicyEstimateError(preview = {}, error = null,
|
||||
}
|
||||
|
||||
export function shouldUseLocalApplicationPreview(rawText, options = {}) {
|
||||
if (String(options.sessionType || '').trim() !== APPLICATION_SESSION_TYPE) return false
|
||||
if (options.systemGenerated || options.reviewAction || Number(options.attachmentCount || 0) > 0) return false
|
||||
|
||||
const compact = compactText(rawText)
|
||||
if (!compact || APPLICATION_QUERY_PATTERN.test(compact)) return false
|
||||
return APPLICATION_CREATE_PATTERN.test(compact)
|
||||
return evaluateLocalApplicationIntentGate(rawText, options).allowed
|
||||
}
|
||||
|
||||
export function normalizeApplicationPreview(preview = {}) {
|
||||
const fields = ensureApplicationPolicyFields(preview?.fields || {})
|
||||
const missingFields = buildMissingFields(fields)
|
||||
const validationIssues = [
|
||||
...resolveApplicationValidationIssues(fields),
|
||||
...resolveApplicationSourceValidationIssues(preview?.sourceText, fields, preview)
|
||||
]
|
||||
return {
|
||||
...preview,
|
||||
fields,
|
||||
missingFields,
|
||||
readyToSubmit: missingFields.length === 0
|
||||
validationIssues,
|
||||
readyToSubmit: missingFields.length === 0 && validationIssues.length === 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,6 +1035,16 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
|
||||
days: resolveProvidedValue(ontologyFields.days, currentFields.days),
|
||||
transportMode: resolveModelRefinedTransportMode(ontologyFields, rawText, currentFields),
|
||||
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
|
||||
transportEstimatedAmount: normalizeTypedOntologyAmount(
|
||||
ontologyFields.transportEstimatedAmount || ontologyFields.trainEstimatedAmount || ontologyFields.flightEstimatedAmount,
|
||||
currentFields.transportEstimatedAmount
|
||||
),
|
||||
trainEstimatedAmount: normalizeTypedOntologyAmount(ontologyFields.trainEstimatedAmount, currentFields.trainEstimatedAmount),
|
||||
flightEstimatedAmount: normalizeTypedOntologyAmount(ontologyFields.flightEstimatedAmount, currentFields.flightEstimatedAmount),
|
||||
hotelAmount: normalizeTypedOntologyAmount(ontologyFields.hotelAmount, currentFields.hotelAmount),
|
||||
allowanceAmount: normalizeTypedOntologyAmount(ontologyFields.allowanceAmount, currentFields.allowanceAmount),
|
||||
policyTotalAmount: normalizeTypedOntologyAmount(ontologyFields.policyTotalAmount, currentFields.policyTotalAmount),
|
||||
reimbursementAmount: normalizeTypedOntologyAmount(ontologyFields.reimbursementAmount, currentFields.reimbursementAmount),
|
||||
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
|
||||
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
|
||||
department: resolveProvidedValue(ontologyFields.department, currentFields.department || resolveCurrentUserDepartment(currentUser)),
|
||||
@@ -827,6 +1184,10 @@ export function buildLocalApplicationPreviewMessage(preview) {
|
||||
export function buildApplicationPreviewFooterMessage(preview) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
||||
const validationIssues = Array.isArray(normalized.validationIssues) ? normalized.validationIssues : []
|
||||
if (validationIssues.length) {
|
||||
return `${validationIssues[0].message} 请先修正后再提交申请。`
|
||||
}
|
||||
if (missingFields.length) {
|
||||
return `当前还需要补充:${missingFields.join('、')}。补齐后我再帮您提交申请。`
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -125,7 +125,7 @@ export function buildStewardFieldCompletionRawText({
|
||||
'已识别信息:',
|
||||
...knownLines,
|
||||
'',
|
||||
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。',
|
||||
'处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。',
|
||||
'如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。'
|
||||
].filter((line) => line !== '').join('\n')
|
||||
}
|
||||
|
||||
@@ -508,13 +508,6 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
|
||||
group?.attachmentNames?.length ? `相关附件:${group.attachmentNames.join('、')}` : '',
|
||||
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
|
||||
missingFields ? `还需要补充:${missingFields}` : '',
|
||||
actionType === 'confirm_create_application'
|
||||
? missingFields
|
||||
? '请先追问上述缺失信息,不要直接生成申请单核对表,也不要替用户默认填写。'
|
||||
: '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
|
||||
: missingFields
|
||||
? '请先追问上述缺失信息,不要直接生成报销核对结果,也不要替用户默认填写。'
|
||||
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
|
||||
]
|
||||
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
|
||||
if (remainingTaskText) {
|
||||
|
||||
103
web/src/views/scripts/stewardTypewriter.js
Normal file
103
web/src/views/scripts/stewardTypewriter.js
Normal file
@@ -0,0 +1,103 @@
|
||||
export const STEWARD_TYPEWRITER_TEXT_CHUNK_SIZE = 3
|
||||
export const STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE = 2
|
||||
|
||||
export function resolveStewardTypewriterNextIndex(chars = [], index = 0) {
|
||||
const total = chars.length
|
||||
const safeIndex = Math.max(0, Math.min(Number(index) || 0, total))
|
||||
const tableStart = resolveMarkdownTableStart(chars, safeIndex)
|
||||
if (tableStart >= 0) {
|
||||
return resolveMarkdownTableBlockEnd(chars, tableStart)
|
||||
}
|
||||
|
||||
const chunkSize = resolveStewardTypewriterChunkSize(chars, safeIndex)
|
||||
const nextIndex = Math.min(total, safeIndex + chunkSize)
|
||||
const crossedTableStart = findMarkdownTableLineStart(chars, safeIndex, nextIndex)
|
||||
return crossedTableStart >= 0 ? crossedTableStart : nextIndex
|
||||
}
|
||||
|
||||
function resolveStewardTypewriterChunkSize(chars = [], index = 0) {
|
||||
const line = resolveCurrentTypewriterLine(chars, index)
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE
|
||||
if (/^(#{1,6}\s+|[-*]\s+|\d+\.\s+)/.test(trimmed)) {
|
||||
return STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE
|
||||
}
|
||||
return STEWARD_TYPEWRITER_TEXT_CHUNK_SIZE
|
||||
}
|
||||
|
||||
function resolveCurrentTypewriterLine(chars = [], index = 0) {
|
||||
const safeIndex = Math.max(0, Math.min(Number(index) || 0, chars.length))
|
||||
let start = safeIndex
|
||||
while (start > 0 && chars[start - 1] !== '\n') {
|
||||
start -= 1
|
||||
}
|
||||
let end = safeIndex
|
||||
while (end < chars.length && chars[end] !== '\n') {
|
||||
end += 1
|
||||
}
|
||||
return chars.slice(start, end).join('')
|
||||
}
|
||||
|
||||
function resolveMarkdownTableStart(chars = [], index = 0) {
|
||||
const currentLineStart = resolveCurrentLineStart(chars, index)
|
||||
if (isMarkdownTableLine(resolveLine(chars, currentLineStart).trim())) {
|
||||
return currentLineStart
|
||||
}
|
||||
|
||||
if (chars[index] === '\n') {
|
||||
const nextLineStart = index + 1
|
||||
if (isMarkdownTableLine(resolveLine(chars, nextLineStart).trim())) {
|
||||
return nextLineStart
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function resolveMarkdownTableBlockEnd(chars = [], tableStart = 0) {
|
||||
let cursor = tableStart
|
||||
let blockEnd = tableStart
|
||||
while (cursor < chars.length) {
|
||||
const line = resolveLine(chars, cursor)
|
||||
if (!isMarkdownTableLine(line.trim())) {
|
||||
break
|
||||
}
|
||||
const lineEnd = cursor + line.length
|
||||
blockEnd = chars[lineEnd] === '\n' ? lineEnd + 1 : lineEnd
|
||||
cursor = blockEnd
|
||||
}
|
||||
return blockEnd
|
||||
}
|
||||
|
||||
function findMarkdownTableLineStart(chars = [], start = 0, end = 0) {
|
||||
const safeStart = Math.max(0, start)
|
||||
const safeEnd = Math.min(chars.length, Math.max(safeStart, end))
|
||||
for (let index = safeStart; index < safeEnd; index += 1) {
|
||||
if (index !== 0 && chars[index - 1] !== '\n') continue
|
||||
if (isMarkdownTableLine(resolveLine(chars, index).trim())) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function resolveCurrentLineStart(chars = [], index = 0) {
|
||||
let start = Math.max(0, Math.min(Number(index) || 0, chars.length))
|
||||
while (start > 0 && chars[start - 1] !== '\n') {
|
||||
start -= 1
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
function resolveLine(chars = [], start = 0) {
|
||||
let end = Math.max(0, Math.min(Number(start) || 0, chars.length))
|
||||
while (end < chars.length && chars[end] !== '\n') {
|
||||
end += 1
|
||||
}
|
||||
return chars.slice(start, end).join('')
|
||||
}
|
||||
|
||||
function isMarkdownTableLine(line = '') {
|
||||
if (!line.includes('|')) return false
|
||||
if (/^\|?[\s:|-]+\|[\s:|-]+/.test(line)) return true
|
||||
return /^\|.+\|$/.test(line) || line.split('|').length >= 3
|
||||
}
|
||||
322
web/src/views/scripts/travelReimbursementReviewPanelModel.js
Normal file
322
web/src/views/scripts/travelReimbursementReviewPanelModel.js
Normal file
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
DATE_INPUT_FORMAT,
|
||||
buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel,
|
||||
buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel,
|
||||
cloneReviewEditFields,
|
||||
createEmptyInlineReviewState,
|
||||
formatAmountDisplay,
|
||||
formatReviewSceneDisplayValue,
|
||||
isTravelReviewPayload as isTravelReviewPayloadModel,
|
||||
normalizeReviewRiskLevel,
|
||||
buildReviewAttachmentStatus,
|
||||
resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel,
|
||||
shouldShowReviewFactCard
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
|
||||
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
|
||||
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
|
||||
const REVIEW_PANEL_SCOPE_RISK = 'risk'
|
||||
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
|
||||
|
||||
const REVIEW_RISK_LEVEL_META = {
|
||||
high: {
|
||||
label: '高风险',
|
||||
icon: 'mdi mdi-alert-octagon-outline',
|
||||
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
|
||||
},
|
||||
medium: {
|
||||
label: '中风险',
|
||||
icon: 'mdi mdi-alert-circle-outline',
|
||||
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
|
||||
},
|
||||
info: {
|
||||
label: '提示',
|
||||
icon: 'mdi mdi-information-outline',
|
||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
|
||||
},
|
||||
low: {
|
||||
label: '低风险',
|
||||
icon: 'mdi mdi-information-outline',
|
||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
|
||||
}
|
||||
}
|
||||
|
||||
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
|
||||
|
||||
export function normalizeReviewPanelScope(scope) {
|
||||
const normalized = String(scope || '').trim()
|
||||
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
|
||||
? normalized
|
||||
: ''
|
||||
}
|
||||
|
||||
export function canExposeReviewPanelScope(scope) {
|
||||
return Boolean(normalizeReviewPanelScope(scope))
|
||||
}
|
||||
|
||||
export function buildBusinessTimeContextFromReviewValues(values = {}) {
|
||||
return buildBusinessTimeContextFromReviewValuesModel(values)
|
||||
}
|
||||
|
||||
export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
|
||||
return buildReviewFormContextFromPayloadModel(reviewPayload, inlineState)
|
||||
}
|
||||
|
||||
export function buildReviewCorrectionMessage(fields) {
|
||||
const lines = ['请按以下核对后的报销信息更新当前识别结果:']
|
||||
for (const item of cloneReviewEditFields(fields)) {
|
||||
if (!item.label || (!item.value && !item.required)) {
|
||||
continue
|
||||
}
|
||||
lines.push(`${item.label}:${String(item.value || '').trim() || '待补充'}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
return isTravelReviewPayloadModel(reviewPayload, inlineState)
|
||||
}
|
||||
|
||||
export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
|
||||
return resolveReviewTravelTransportTypeModel(reviewPayload, fallbackText)
|
||||
}
|
||||
|
||||
export function resolveReviewRiskBriefs(reviewPayload) {
|
||||
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
|
||||
return reviewPayload.risk_briefs.filter((item) => {
|
||||
const title = String(item?.title || '').trim()
|
||||
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
export function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0))
|
||||
const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0))
|
||||
const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount)
|
||||
const attachmentStatus =
|
||||
pendingAttachmentCount > 0
|
||||
? existingAttachmentCount > 0
|
||||
? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount} 份`
|
||||
: `待保存 ${pendingAttachmentCount} 份`
|
||||
: totalAttachmentCount > 0
|
||||
? `已上传 ${totalAttachmentCount} 份`
|
||||
: buildReviewAttachmentStatus(reviewPayload)
|
||||
|
||||
if (isTravelReviewPayload(reviewPayload, inlineState)) {
|
||||
return [
|
||||
{
|
||||
key: 'occurred_date',
|
||||
label: '发生时间',
|
||||
value: String(inlineState.occurred_date || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-calendar-month-outline',
|
||||
editor: 'date',
|
||||
modelKey: 'occurred_date',
|
||||
placeholder: `例如 ${DATE_INPUT_FORMAT}`
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '金额',
|
||||
value: formatAmountDisplay(inlineState.amount) || '待补充',
|
||||
icon: 'mdi mdi-cash',
|
||||
editor: 'amount',
|
||||
modelKey: 'amount',
|
||||
placeholder: '例如 200.00'
|
||||
},
|
||||
{
|
||||
key: 'transport_type',
|
||||
label: '交通类型',
|
||||
value: String(inlineState.transport_type || '').trim() || '待确认',
|
||||
icon: 'mdi mdi-train-car',
|
||||
editor: 'text',
|
||||
modelKey: 'transport_type',
|
||||
placeholder: '例如 火车/高铁、飞机'
|
||||
},
|
||||
{
|
||||
key: 'hotel_name',
|
||||
label: '酒店名称',
|
||||
value: String(inlineState.merchant_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-bed-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'merchant_name',
|
||||
placeholder: '请输入酒店名称'
|
||||
},
|
||||
{
|
||||
key: 'travel_purpose',
|
||||
label: '出差事宜',
|
||||
value: String(inlineState.reason_value || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-briefcase-edit-outline',
|
||||
editor: 'textarea',
|
||||
modelKey: 'reason_value',
|
||||
placeholder: '请填写本次出差的具体工作内容或业务意图',
|
||||
wide: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
key: 'occurred_date',
|
||||
label: '发生时间',
|
||||
value: String(inlineState.occurred_date || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-calendar-month-outline',
|
||||
editor: 'date',
|
||||
modelKey: 'occurred_date',
|
||||
placeholder: `例如 ${DATE_INPUT_FORMAT}`
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '金额',
|
||||
value: formatAmountDisplay(inlineState.amount) || '待补充',
|
||||
icon: 'mdi mdi-cash',
|
||||
editor: 'amount',
|
||||
modelKey: 'amount',
|
||||
placeholder: '例如 200.00'
|
||||
},
|
||||
{
|
||||
key: 'scene',
|
||||
label: '场景 / 事由',
|
||||
value: formatReviewSceneDisplayValue(inlineState),
|
||||
icon: 'mdi mdi-silverware-fork-knife',
|
||||
editor: 'select',
|
||||
modelKey: 'scene_label',
|
||||
placeholder: '请选择场景'
|
||||
},
|
||||
{
|
||||
key: 'attachments',
|
||||
label: '票据状态',
|
||||
value: attachmentStatus,
|
||||
icon: 'mdi mdi-file-document-outline',
|
||||
editor: 'upload',
|
||||
modelKey: 'attachment_names',
|
||||
placeholder: ''
|
||||
}
|
||||
]
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'customer_name',
|
||||
label: '关联客户',
|
||||
value: String(inlineState.customer_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-domain',
|
||||
editor: 'text',
|
||||
modelKey: 'customer_name',
|
||||
placeholder: '请输入客户名称'
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'location',
|
||||
label: '业务地点',
|
||||
value: String(inlineState.location || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-map-marker-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'location',
|
||||
placeholder: '请输入业务地点'
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'merchant_name',
|
||||
label: '酒店/商户',
|
||||
value: String(inlineState.merchant_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-storefront-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'merchant_name',
|
||||
placeholder: '请输入酒店或商户名称'
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'participants',
|
||||
label: '同行人员',
|
||||
value: String(inlineState.participants || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-account-group-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'participants',
|
||||
placeholder: '例如 客户 2 人,我方 1 人'
|
||||
})
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
|
||||
function normalizeReviewRiskTitle(title, fallbackTitle) {
|
||||
const normalized = String(title || '').trim()
|
||||
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
|
||||
if (!normalized) return fallback
|
||||
const cleaned = normalized
|
||||
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
|
||||
.replace(/(高风险|中风险|低风险)/g, '')
|
||||
.replace(/^[::\-—\s]+|[::\-—\s]+$/g, '')
|
||||
.trim()
|
||||
return cleaned || fallback
|
||||
}
|
||||
|
||||
export function buildReviewRiskItems(reviewPayload) {
|
||||
return resolveReviewRiskBriefs(reviewPayload)
|
||||
.map((brief, index) => {
|
||||
const title = String(brief?.title || '').trim()
|
||||
const content = String(brief?.content || '').trim()
|
||||
const detail = String(brief?.detail || '').trim()
|
||||
const suggestion = String(brief?.suggestion || '').trim()
|
||||
const level = normalizeReviewRiskLevel(brief?.level)
|
||||
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
|
||||
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
|
||||
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
|
||||
const summary = content || normalizedTitle
|
||||
|
||||
if (!normalizedTitle && !summary) return null
|
||||
|
||||
return {
|
||||
key: `${level}-${normalizedTitle}-${index}`,
|
||||
title: normalizedTitle,
|
||||
summary,
|
||||
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
|
||||
level,
|
||||
levelLabel: meta.label,
|
||||
icon: meta.icon,
|
||||
sourceLabel: meta.label,
|
||||
suggestion: suggestion || meta.suggestion
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function buildReviewRiskConversationText(item, detailTarget = {}) {
|
||||
const title = String(item?.title || '风险提示').trim()
|
||||
const summary = String(item?.summary || '').trim()
|
||||
const detail = String(item?.detail || '').trim()
|
||||
const suggestion = String(item?.suggestion || '').trim()
|
||||
const isInfo = String(item?.level || '').trim() === 'info'
|
||||
const detailHref = String(detailTarget?.href || '').trim()
|
||||
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
|
||||
const lines = [`${title}`]
|
||||
|
||||
if (summary) {
|
||||
lines.push('', `${isInfo ? '提示内容' : '风险点'}:${summary}`)
|
||||
}
|
||||
if (detail && detail !== summary) {
|
||||
lines.push('', `规则依据:${detail}`)
|
||||
}
|
||||
if (suggestion) {
|
||||
lines.push('', `${isInfo ? '处理建议' : '修改建议'}:${suggestion}`)
|
||||
}
|
||||
if (detailHref) {
|
||||
lines.push('', `[${detailLabel}](${detailHref})`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function buildReviewMainMessageText(message) {
|
||||
const text = String(message?.text || '')
|
||||
if (!message?.reviewPayload) {
|
||||
return text
|
||||
}
|
||||
return text
|
||||
.replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
255
web/src/views/scripts/travelReimbursementStewardFollowupFlow.js
Normal file
255
web/src/views/scripts/travelReimbursementStewardFollowupFlow.js
Normal file
@@ -0,0 +1,255 @@
|
||||
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
|
||||
import {
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_EXPENSE
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
import {
|
||||
buildStewardFieldItems,
|
||||
formatStewardMissingFieldList,
|
||||
formatStewardOntologyFields
|
||||
} from './stewardPlanModel.js'
|
||||
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
|
||||
import { STEWARD_ASSISTANT_NAME } from './travelReimbursementStewardRuntimeTextModel.js'
|
||||
|
||||
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 10
|
||||
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 8
|
||||
const STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5
|
||||
|
||||
export function buildStewardContinuationAfterAction({
|
||||
createMessage,
|
||||
message,
|
||||
completedLabel = '当前动作已完成'
|
||||
}) {
|
||||
const continuation = message?.stewardContinuation || null
|
||||
const remainingTasks = Array.isArray(continuation?.remainingTasks)
|
||||
? continuation.remainingTasks
|
||||
: []
|
||||
if (!remainingTasks.length) {
|
||||
return null
|
||||
}
|
||||
const nextTask = remainingTasks[0]
|
||||
const nextTaskType = String(nextTask.task_type || nextTask.taskType || '').trim()
|
||||
const targetSessionType = nextTaskType === 'expense_application'
|
||||
? SESSION_TYPE_APPLICATION
|
||||
: SESSION_TYPE_EXPENSE
|
||||
const nextLabel = targetSessionType === SESSION_TYPE_APPLICATION
|
||||
? '继续创建申请单'
|
||||
: '继续填写报销单'
|
||||
const restTasks = remainingTasks.slice(1)
|
||||
return createMessage(
|
||||
'assistant',
|
||||
[
|
||||
`**${completedLabel}。**`,
|
||||
'',
|
||||
'我会重新检查剩余任务队列。',
|
||||
`下一步:${nextTask.title || (targetSessionType === SESSION_TYPE_APPLICATION ? '费用申请' : '费用报销')}。`,
|
||||
'请回复“确定”,我再继续执行。'
|
||||
].join('\n'),
|
||||
[],
|
||||
{
|
||||
assistantName: STEWARD_ASSISTANT_NAME,
|
||||
meta: [STEWARD_ASSISTANT_NAME, '等待用户确认'],
|
||||
suggestedActions: [
|
||||
{
|
||||
label: nextLabel,
|
||||
description: '确认后小财管家继续调用对应助手完成下一步。',
|
||||
icon: targetSessionType === SESSION_TYPE_APPLICATION
|
||||
? 'mdi mdi-file-plus-outline'
|
||||
: 'mdi mdi-receipt-text-plus-outline',
|
||||
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||
payload: {
|
||||
session_type: targetSessionType,
|
||||
carry_text: buildStewardContinuationCarryText(nextTask, restTasks),
|
||||
carry_files: targetSessionType !== SESSION_TYPE_APPLICATION,
|
||||
auto_submit: true,
|
||||
steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation',
|
||||
steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(),
|
||||
steward_current_task: nextTask,
|
||||
steward_remaining_tasks: restTasks
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function buildStewardFollowupPlan(thinkingEvents = [], streamStatus = 'streaming', planId = '') {
|
||||
return {
|
||||
planId: planId || `steward-followup-${Date.now()}`,
|
||||
planStatus: 'delegating',
|
||||
summary: '',
|
||||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
|
||||
initialSummaryOnly: true,
|
||||
thinkingEvents,
|
||||
tasks: [],
|
||||
attachmentGroups: [],
|
||||
confirmationGroups: [],
|
||||
streamStatus
|
||||
}
|
||||
}
|
||||
|
||||
function extractStewardCarryLine(text = '', label = '') {
|
||||
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[::]([^\\n]+)`, 'u'))
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
function extractStewardFollowupNextTitle(text = '') {
|
||||
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[::]([^。\n]+)/u)
|
||||
if (taskMatch?.[1]) {
|
||||
return taskMatch[1].trim()
|
||||
}
|
||||
const nextMatch = String(text || '').match(/下一步[::]([^。\n]+)/u)
|
||||
return nextMatch?.[1]?.trim() || '下一项财务任务'
|
||||
}
|
||||
|
||||
function buildStewardFollowupThinkingEvents(finalMessage = null, actions = []) {
|
||||
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const firstAction = Array.isArray(actions) ? actions[0] : null
|
||||
const actionPayload = firstAction?.payload && typeof firstAction.payload === 'object' ? firstAction.payload : {}
|
||||
const carryText = String(actionPayload.carry_text || '').trim()
|
||||
const finalText = String(finalMessage?.text || '').trim()
|
||||
const nextTitle = extractStewardFollowupNextTitle(carryText || finalText)
|
||||
const nextSummary = extractStewardCarryLine(carryText, '任务摘要')
|
||||
const nextMissing = extractStewardCarryLine(carryText, '还需要补充')
|
||||
return [
|
||||
{
|
||||
eventId: `${eventPrefix}-review`,
|
||||
title: '复盘结果',
|
||||
content: finalText.includes('申请单已完成')
|
||||
? '申请单已经完成,我把当前出差申请标记为已处理,不会重复创建。'
|
||||
: '当前动作已经完成,我会把已完成事项从任务队列中移除。'
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-next`,
|
||||
title: '读取剩余任务',
|
||||
content: nextSummary
|
||||
? `剩余队列里的下一项是“${nextTitle}”:${nextSummary}。`
|
||||
: `剩余队列里的下一项是“${nextTitle}”。`
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-gate`,
|
||||
title: '判断下一步条件',
|
||||
content: nextMissing
|
||||
? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。`
|
||||
: '我会先等你确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function waitStewardFollowupTick(intervalMs) {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, intervalMs)
|
||||
})
|
||||
}
|
||||
|
||||
export async function pushStewardContinuationMessage({
|
||||
finalMessage,
|
||||
messages,
|
||||
nextTick,
|
||||
persistSessionState,
|
||||
scrollToBottom
|
||||
}) {
|
||||
if (!finalMessage) {
|
||||
return
|
||||
}
|
||||
const finalText = String(finalMessage.text || '')
|
||||
const followupPlanId = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const finalActions = Array.isArray(finalMessage.suggestedActions)
|
||||
? finalMessage.suggestedActions
|
||||
: []
|
||||
finalMessage.text = ''
|
||||
finalMessage.assistantName = STEWARD_ASSISTANT_NAME
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '思考中']
|
||||
finalMessage.suggestedActions = []
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([], 'streaming', followupPlanId)
|
||||
messages.value.push(finalMessage)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const typedEvents = []
|
||||
for (const eventData of buildStewardFollowupThinkingEvents(finalMessage, finalActions)) {
|
||||
const event = {
|
||||
eventId: eventData.eventId,
|
||||
stage: 'steward_followup',
|
||||
title: eventData.title,
|
||||
content: '',
|
||||
status: 'running'
|
||||
}
|
||||
typedEvents.push(event)
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const chars = Array.from(eventData.content)
|
||||
for (let index = 0; index < chars.length;) {
|
||||
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
|
||||
index = Math.min(chars.length, index + STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE)
|
||||
event.content = chars.slice(0, index).join('')
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
|
||||
if (index % STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
event.content = eventData.content
|
||||
event.status = 'completed'
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
|
||||
const chars = Array.from(finalText)
|
||||
for (let index = 0; index < chars.length;) {
|
||||
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
|
||||
index = resolveStewardTypewriterNextIndex(chars, index)
|
||||
finalMessage.text = chars.slice(0, index).join('')
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
finalMessage.text = finalText
|
||||
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
|
||||
finalMessage.suggestedActions = finalActions
|
||||
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
export function buildStewardContinuationCarryText(task, restTasks = []) {
|
||||
const taskType = String(task?.task_type || task?.taskType || '').trim()
|
||||
const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType)
|
||||
const missingFields = formatStewardMissingFieldList(
|
||||
task?.missing_fields || task?.missingFields || [],
|
||||
taskType,
|
||||
{ includeHints: false }
|
||||
)
|
||||
const lines = [
|
||||
taskType === 'expense_application'
|
||||
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}。`
|
||||
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}。`,
|
||||
task.summary ? `任务摘要:${task.summary}` : '',
|
||||
fields ? `已识别信息:${fields}` : '',
|
||||
missingFields ? `还需要补充:${missingFields}` : '',
|
||||
missingFields
|
||||
? '请先追问上述缺失信息,不要直接生成核对结果,也不要替用户默认填写。'
|
||||
: '请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。'
|
||||
]
|
||||
if (restTasks.length) {
|
||||
lines.push('当前步骤完成后,请继续引导我处理剩余任务:')
|
||||
restTasks.forEach((item, index) => {
|
||||
lines.push(`${index + 1}. ${item.title || item.task_type || item.taskType}`)
|
||||
})
|
||||
}
|
||||
return lines.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
export function resolveStewardMissingFieldItems(task) {
|
||||
if (Array.isArray(task?.missingFieldItems) && task.missingFieldItems.length) {
|
||||
return task.missingFieldItems
|
||||
}
|
||||
const fields = task?.missingFields || task?.missing_fields || []
|
||||
const taskType = String(task?.taskType || task?.task_type || '').trim()
|
||||
return buildStewardFieldItems(fields, taskType)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export const STEWARD_ASSISTANT_NAME = '小财管家'
|
||||
export const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
||||
|
||||
const APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN = /^(确认|确定|确认提交|确定提交|提交|提交审批|确认审批|确认无误|核对无误|信息无误|无误|没问题|可以提交|确认进入审批|提交至审批流程|确认提交审批|同意提交)$/
|
||||
const APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN = /^(不|否|否定|取消|暂不|先不|不确认|不提交|再检查|再看看|等等|等一下)/
|
||||
const STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN = /^(继续|继续执行|下一步|继续下一步|开始下一步|处理下一项|继续处理|确认开始|确定开始|可以|好的|好|行)$/
|
||||
const STEWARD_RUNTIME_CANCEL_TEXT_PATTERN = /^(取消|暂不|先不|不用|不要|不继续|不处理|先等等|等一下|停止|终止|算了)$/
|
||||
const STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH = 12
|
||||
const STEWARD_RUNTIME_BUSINESS_HINT_PATTERN = /(申请|报销|出差|差旅|招待|交通费|住宿费|餐费|发票|票据|费用|预算|借款|付款|审批|审核)/
|
||||
const STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN = /(今天|明天|后天|昨天|前天|\d{1,2}月\d{1,2}日|\d{4}-\d{1,2}-\d{1,2}|我要|帮我|需要|创建|填写|处理|去|前往)/
|
||||
const STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN = /(当前|这个|这一步|上面|上述|申请单|核对表|出行方式|交通方式|火车|高铁|动车|飞机|轮船|提交|审批|确认)/
|
||||
|
||||
export function isApplicationSubmitConfirmationText(value = '') {
|
||||
const normalized = String(value || '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[,,。.!!??;;::]/g, '')
|
||||
if (!normalized || APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
return APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN.test(normalized)
|
||||
}
|
||||
|
||||
export function normalizeStewardRuntimeInputText(value = '') {
|
||||
return String(value || '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[,,。.!!??;;::]/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function isStewardRuntimeContinueText(value = '') {
|
||||
const normalized = normalizeStewardRuntimeInputText(value)
|
||||
return Boolean(normalized && STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN.test(normalized))
|
||||
}
|
||||
|
||||
export function isStewardRuntimeCancelText(value = '') {
|
||||
const normalized = normalizeStewardRuntimeInputText(value)
|
||||
return Boolean(normalized && STEWARD_RUNTIME_CANCEL_TEXT_PATTERN.test(normalized))
|
||||
}
|
||||
|
||||
export function resolveStewardRuntimeTransportAlias(value = '') {
|
||||
const normalized = normalizeStewardRuntimeInputText(value)
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
const matchedModes = []
|
||||
if (/火车|高铁|动车|列车|铁路/.test(normalized)) {
|
||||
matchedModes.push('火车')
|
||||
}
|
||||
if (/飞机|机票|航班|航空/.test(normalized)) {
|
||||
matchedModes.push('飞机')
|
||||
}
|
||||
if (/轮船|船票|客轮|渡轮|坐船/.test(normalized)) {
|
||||
matchedModes.push('轮船')
|
||||
}
|
||||
return matchedModes.length === 1 ? matchedModes[0] : ''
|
||||
}
|
||||
|
||||
export function shouldPlanNewStewardTasksLocally(rawText = '', runtimeState = {}) {
|
||||
const text = String(rawText || '').trim()
|
||||
const normalized = normalizeStewardRuntimeInputText(text)
|
||||
if (
|
||||
normalized.length < STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH ||
|
||||
isApplicationSubmitConfirmationText(normalized) ||
|
||||
isStewardRuntimeContinueText(normalized) ||
|
||||
isStewardRuntimeCancelText(normalized)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (!STEWARD_RUNTIME_BUSINESS_HINT_PATTERN.test(text) || !STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN.test(text)) {
|
||||
return false
|
||||
}
|
||||
const waitingFor = String(runtimeState?.waiting_for || '').trim()
|
||||
if (waitingFor && STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN.test(text)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
resolveApplicationDaysFromDateRange,
|
||||
refreshApplicationPreviewTransportEstimate
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
|
||||
import {
|
||||
buildWorkbenchDateLabel,
|
||||
canApplyWorkbenchDateSelection,
|
||||
@@ -210,7 +209,6 @@ export function useApplicationPreviewEditor({
|
||||
)
|
||||
})
|
||||
const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey)
|
||||
const transportMode = String(nextPreview.fields?.transportMode || '').trim()
|
||||
message.applicationPreview = needRefreshEstimate
|
||||
? buildTransportEstimatePendingPreview(nextPreview)
|
||||
: nextPreview
|
||||
@@ -218,13 +216,6 @@ export function useApplicationPreviewEditor({
|
||||
cancelApplicationPreviewEditor()
|
||||
persistSessionState?.()
|
||||
if (needRefreshEstimate) {
|
||||
if (transportMode) {
|
||||
await waitForMockApplicationTransportQuote({
|
||||
transportMode,
|
||||
location: nextPreview.fields.matchedCity || nextPreview.fields.location,
|
||||
time: nextPreview.fields.time
|
||||
})
|
||||
}
|
||||
const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview)
|
||||
message.applicationPreview = refreshedPreview
|
||||
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
|
||||
|
||||
@@ -4,11 +4,11 @@ import {
|
||||
buildStewardSuggestedActions,
|
||||
normalizeStewardPlan
|
||||
} from './stewardPlanModel.js'
|
||||
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
|
||||
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
|
||||
|
||||
const STEWARD_TYPEWRITER_INTERVAL_MS = 10
|
||||
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
|
||||
const STEWARD_TYPEWRITER_CHUNK_SIZE = 4
|
||||
const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5
|
||||
|
||||
export function useStewardPlanFlow({
|
||||
@@ -176,7 +176,7 @@ export function useStewardPlanFlow({
|
||||
if (runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE)
|
||||
index = resolveStewardTypewriterNextIndex(chars, index)
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message) {
|
||||
return
|
||||
@@ -188,9 +188,7 @@ export function useStewardPlanFlow({
|
||||
...normalizedPlan,
|
||||
streamStatus: 'typing'
|
||||
}
|
||||
if (index % 4 === 0 || index === total) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import {
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
MAX_ATTACHMENTS,
|
||||
VISIBLE_ATTACHMENT_CHIPS,
|
||||
buildReviewFilePreviewsFromReviewPayload
|
||||
} from './travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
SESSION_TYPE_EXPENSE,
|
||||
createMessage,
|
||||
buildWelcomeInsight
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
|
||||
export function useTravelReimbursementCreateViewLifecycle({
|
||||
activeFlowSteps,
|
||||
activeReviewPanelScope,
|
||||
activeReviewPayload,
|
||||
activeSessionType,
|
||||
adjustComposerTextareaHeight,
|
||||
attachedFiles,
|
||||
clearExpenseSessionForDeletedClaim,
|
||||
clearStewardThinkingTimers,
|
||||
closeAfterBusy,
|
||||
composerDraft,
|
||||
composerFilesExpanded,
|
||||
composerUploadIntent,
|
||||
conversationId,
|
||||
currentInsight,
|
||||
currentUser,
|
||||
draftClaimId,
|
||||
guidedFlowState,
|
||||
handleComposerDatePickerOutside,
|
||||
hasInsightPanelContent,
|
||||
insightPanelCollapsed,
|
||||
linkedRequest,
|
||||
maybeFinalizeDeferredClose,
|
||||
mergeFilesWithLimit,
|
||||
messages,
|
||||
persistSessionState,
|
||||
props,
|
||||
rememberFilePreviews,
|
||||
resetReviewDrawerFromPayload,
|
||||
resolveActiveClaimId,
|
||||
restorePersistedDraftAttachmentPreviews,
|
||||
reviewDocumentDrawerAvailable,
|
||||
reviewDrawerMode,
|
||||
reviewFilePreviews,
|
||||
reviewFlowDrawerAvailable,
|
||||
reviewRiskDrawerAvailable,
|
||||
scrollToBottom,
|
||||
startFlowTick,
|
||||
stopAttachmentRuntime,
|
||||
stopFlowRuntime,
|
||||
submitComposer,
|
||||
toast,
|
||||
workbenchVisible,
|
||||
REVIEW_DRAWER_MODE_DOCUMENTS,
|
||||
REVIEW_DRAWER_MODE_FLOW,
|
||||
REVIEW_DRAWER_MODE_REVIEW,
|
||||
REVIEW_DRAWER_MODE_RISK,
|
||||
SESSION_TYPE_EXPENSE: sessionTypeExpense = SESSION_TYPE_EXPENSE
|
||||
}) {
|
||||
watch(
|
||||
() => [activeReviewPayload.value, activeReviewPanelScope.value],
|
||||
([payload]) => {
|
||||
rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload))
|
||||
const shouldKeepFlowDrawer = reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && activeFlowSteps.value.length > 0
|
||||
resetReviewDrawerFromPayload(payload)
|
||||
if (shouldKeepFlowDrawer) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => hasInsightPanelContent.value,
|
||||
(available) => {
|
||||
if (!available) {
|
||||
insightPanelCollapsed.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => reviewDocumentDrawerAvailable.value,
|
||||
(available) => {
|
||||
if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => reviewRiskDrawerAvailable.value,
|
||||
(available) => {
|
||||
if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => reviewFlowDrawerAvailable.value,
|
||||
(available) => {
|
||||
if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [activeSessionType.value, activeFlowSteps.value.length],
|
||||
([, activeCount], [, previousActiveCount] = []) => {
|
||||
if (activeCount <= 0 || previousActiveCount > 0) {
|
||||
return
|
||||
}
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
||||
insightPanelCollapsed.value = false
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => composerDraft.value,
|
||||
() => {
|
||||
nextTick(adjustComposerTextareaHeight)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
sessionType: activeSessionType.value,
|
||||
conversationId: conversationId.value,
|
||||
draftClaimId: draftClaimId.value,
|
||||
messages: messages.value,
|
||||
currentInsight: currentInsight.value,
|
||||
reviewFilePreviews: reviewFilePreviews.value,
|
||||
composerDraft: composerDraft.value,
|
||||
composerUploadIntent: composerUploadIntent.value,
|
||||
guidedFlowState: guidedFlowState.value,
|
||||
insightPanelCollapsed: insightPanelCollapsed.value
|
||||
}),
|
||||
() => {
|
||||
persistSessionState()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [activeSessionType.value, resolveActiveClaimId()],
|
||||
([sessionType, claimId]) => {
|
||||
if (sessionType !== sessionTypeExpense || !claimId) {
|
||||
return
|
||||
}
|
||||
void restorePersistedDraftAttachmentPreviews(claimId)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.invalidatedDraftClaimId,
|
||||
(claimId) => {
|
||||
clearExpenseSessionForDeletedClaim(claimId)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => workbenchVisible.value,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
maybeFinalizeDeferredClose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.reopenToken,
|
||||
(token, previousToken) => {
|
||||
if (token === previousToken) {
|
||||
return
|
||||
}
|
||||
closeAfterBusy.value = false
|
||||
workbenchVisible.value = true
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleComposerDatePickerOutside)
|
||||
startFlowTick()
|
||||
nextTick(() => {
|
||||
workbenchVisible.value = true
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
})
|
||||
currentInsight.value =
|
||||
currentInsight.value
|
||||
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
|
||||
if (props.initialApplicationPreview && typeof props.initialApplicationPreview === 'object') {
|
||||
const applicationPreview = normalizeApplicationPreview(props.initialApplicationPreview)
|
||||
messages.value.push(createMessage('assistant', buildLocalApplicationPreviewMessage(applicationPreview), [], {
|
||||
meta: ['修改申请'],
|
||||
applicationPreview
|
||||
}))
|
||||
persistSessionState()
|
||||
}
|
||||
if (props.initialPrompt?.trim() || props.initialFiles.length) {
|
||||
const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS)
|
||||
composerDraft.value = props.initialPrompt.trim()
|
||||
attachedFiles.value = initialMerge.files
|
||||
composerFilesExpanded.value = initialMerge.files.length > VISIBLE_ATTACHMENT_CHIPS
|
||||
if (initialMerge.overflowCount > 0) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
})
|
||||
if (props.initialPromptAutoSubmit !== false) {
|
||||
submitComposer()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleComposerDatePickerOutside)
|
||||
clearStewardThinkingTimers()
|
||||
stopFlowRuntime()
|
||||
stopAttachmentRuntime()
|
||||
})
|
||||
}
|
||||
145
web/src/views/scripts/useTravelReimbursementCreateViewUi.js
Normal file
145
web/src/views/scripts/useTravelReimbursementCreateViewUi.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function useTravelReimbursementCreateViewUi(ctx) {
|
||||
const messageItemUi = computed(() => ({
|
||||
ASSISTANT_DISPLAY_NAME: ctx.ASSISTANT_DISPLAY_NAME,
|
||||
aiAvatar: ctx.aiAvatar,
|
||||
userAvatar: ctx.userAvatar,
|
||||
submitting: ctx.submitting.value,
|
||||
reviewActionBusy: ctx.reviewActionBusy.value,
|
||||
sessionSwitchBusy: ctx.sessionSwitchBusy.value,
|
||||
applicationPreviewEditor: ctx.applicationPreviewEditor.value,
|
||||
buildMessageBubbleClass: ctx.buildMessageBubbleClass,
|
||||
resolveStewardMissingFieldItems: ctx.resolveStewardMissingFieldItems,
|
||||
buildReviewMainMessageText: ctx.buildReviewMainMessageText,
|
||||
renderMarkdown: ctx.renderMarkdown,
|
||||
handleAssistantMarkdownClick: ctx.handleAssistantMarkdownClick,
|
||||
resolveApplicationPreviewRows: ctx.resolveApplicationPreviewRows,
|
||||
resolveApplicationPreviewEditorControl: ctx.resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions: ctx.resolveApplicationPreviewEditorOptions,
|
||||
resolveApplicationPreviewMissingFields: ctx.resolveApplicationPreviewMissingFields,
|
||||
isApplicationPreviewEditing: ctx.isApplicationPreviewEditing,
|
||||
isApplicationPreviewDateEditorOpen: ctx.isApplicationPreviewDateEditorOpen,
|
||||
openApplicationPreviewEditor: ctx.openApplicationPreviewEditorFromUi,
|
||||
commitApplicationPreviewEditor: ctx.commitApplicationPreviewEditor,
|
||||
commitApplicationPreviewDateEditor: ctx.commitApplicationPreviewDateEditor,
|
||||
setApplicationPreviewDateMode: ctx.setApplicationPreviewDateMode,
|
||||
canApplyApplicationPreviewDateSelection: ctx.canApplyApplicationPreviewDateSelection,
|
||||
handleApplicationPreviewEditorKeydown: ctx.handleApplicationPreviewEditorKeydown,
|
||||
buildApplicationPreviewFooterText: ctx.buildApplicationPreviewFooterText,
|
||||
isApplicationDraftPayload: ctx.isApplicationDraftPayload,
|
||||
resolveApplicationDraftStatusLabel: ctx.resolveApplicationDraftStatusLabel,
|
||||
buildApplicationDraftSummaryItems: ctx.buildApplicationDraftSummaryItems,
|
||||
shouldShowDraftSavedCard: ctx.shouldShowDraftSavedCard,
|
||||
canOpenDraftDetail: ctx.canOpenDraftDetail,
|
||||
resolveReimbursementDraftClaimNo: ctx.resolveReimbursementDraftClaimNo,
|
||||
openApplicationDraftDetail: ctx.openApplicationDraftDetail,
|
||||
shouldShowAssistantMessageActions: ctx.shouldShowAssistantMessageActions,
|
||||
copyAssistantMessage: ctx.copyAssistantMessage,
|
||||
speakAssistantMessage: ctx.speakAssistantMessage,
|
||||
isMessageFeedbackSelected: ctx.isMessageFeedbackSelected,
|
||||
submitOperationFeedbackForMessage: ctx.submitOperationFeedbackForMessage,
|
||||
runWelcomeQuickAction: ctx.runShortcut,
|
||||
handleSuggestedAction: ctx.handleSuggestedAction,
|
||||
isSuggestedActionSelected: ctx.isSuggestedActionSelected,
|
||||
buildExpenseQueryWindowLabel: ctx.buildExpenseQueryWindowLabel,
|
||||
buildExpenseQueryHint: ctx.buildExpenseQueryHint,
|
||||
getExpenseQueryActivePage: ctx.getExpenseQueryActivePage,
|
||||
getExpenseQueryTotalPages: ctx.getExpenseQueryTotalPages,
|
||||
getExpenseQueryVisibleRecords: ctx.getExpenseQueryVisibleRecords,
|
||||
handleExpenseQueryRecordClick: ctx.handleExpenseQueryRecordClick,
|
||||
appendExpenseQueryRiskToConversation: ctx.appendExpenseQueryRiskToConversation,
|
||||
shiftExpenseQueryPage: ctx.shiftExpenseQueryPage,
|
||||
setExpenseQueryPage: ctx.setExpenseQueryPage,
|
||||
buildReviewPlainFollowupForMessage: ctx.buildReviewPlainFollowupForMessage,
|
||||
canUseInlineSaveDraft: ctx.canUseInlineSaveDraft,
|
||||
handleInlineSaveDraft: ctx.handleInlineSaveDraft,
|
||||
buildReviewNextStepRichCopyForMessage: ctx.buildReviewNextStepRichCopyForMessage,
|
||||
resolveReviewFooterActions: ctx.resolveReviewFooterActions,
|
||||
handleReviewAction: ctx.handleReviewAction,
|
||||
buildReviewPrimaryButtonLabel: ctx.buildReviewPrimaryButtonLabel
|
||||
}))
|
||||
|
||||
const insightPanelUi = computed(() => ({
|
||||
showInsightPanel: ctx.showInsightPanel.value,
|
||||
isKnowledgeSession: ctx.isKnowledgeSession.value,
|
||||
activeReviewPayload: ctx.activeReviewPayload.value,
|
||||
isReviewFlowDrawer: ctx.isReviewFlowDrawer.value,
|
||||
currentInsight: ctx.currentInsight.value,
|
||||
currentIntentLabel: ctx.currentIntentLabel.value,
|
||||
reviewDrawerTitle: ctx.reviewDrawerTitle.value,
|
||||
reviewOverviewDrawerAvailable: ctx.reviewOverviewDrawerAvailable.value,
|
||||
isReviewOverviewDrawer: ctx.isReviewOverviewDrawer.value,
|
||||
submitting: ctx.submitting.value,
|
||||
reviewActionBusy: ctx.reviewActionBusy.value,
|
||||
switchToReviewOverviewDrawer: ctx.switchToReviewOverviewDrawer,
|
||||
reviewDocumentDrawerAvailable: ctx.reviewDocumentDrawerAvailable.value,
|
||||
isReviewDocumentDrawer: ctx.isReviewDocumentDrawer.value,
|
||||
toggleReviewDocumentDrawer: ctx.toggleReviewDocumentDrawer,
|
||||
reviewDocumentDrawerIcon: ctx.reviewDocumentDrawerIcon.value,
|
||||
reviewRiskDrawerAvailable: ctx.reviewRiskDrawerAvailable.value,
|
||||
isReviewRiskDrawer: ctx.isReviewRiskDrawer.value,
|
||||
toggleReviewRiskDrawer: ctx.toggleReviewRiskDrawer,
|
||||
reviewRiskDrawerIcon: ctx.reviewRiskDrawerIcon.value,
|
||||
reviewFlowDrawerAvailable: ctx.reviewFlowDrawerAvailable.value,
|
||||
flowOverallStatusTone: ctx.flowOverallStatusTone.value,
|
||||
toggleReviewFlowDrawer: ctx.toggleReviewFlowDrawer,
|
||||
reviewFlowDrawerIcon: ctx.reviewFlowDrawerIcon.value,
|
||||
activeSessionType: ctx.activeSessionType.value,
|
||||
reviewDrawerMode: ctx.reviewDrawerMode.value,
|
||||
hotKnowledgeQuestions: ctx.hotKnowledgeQuestions,
|
||||
deleteSessionBusy: ctx.deleteSessionBusy.value,
|
||||
sessionSwitchBusy: ctx.sessionSwitchBusy.value,
|
||||
askHotKnowledgeQuestion: ctx.askHotKnowledgeQuestion,
|
||||
resolveKnowledgeRankTone: ctx.resolveKnowledgeRankTone,
|
||||
resolveKnowledgeRankLabel: ctx.resolveKnowledgeRankLabel,
|
||||
flowOverallStatusText: ctx.flowOverallStatusText.value,
|
||||
flowTotalDurationText: ctx.flowTotalDurationText.value,
|
||||
flowRunId: ctx.flowRunId.value,
|
||||
flowRefreshBusy: ctx.flowRefreshBusy.value,
|
||||
refreshFlowRunDetail: ctx.refreshFlowRunDetail,
|
||||
flowSteps: ctx.activeFlowSteps.value,
|
||||
visibleFlowSteps: ctx.visibleFlowSteps.value,
|
||||
resolveFlowStepStatusLabel: ctx.resolveFlowStepStatusLabel,
|
||||
formatFlowStepDuration: ctx.formatFlowStepDuration,
|
||||
resolveFlowStepDetail: ctx.resolveFlowStepDetail,
|
||||
reviewIntentText: ctx.reviewIntentText.value,
|
||||
reviewFactCards: ctx.reviewFactCards.value,
|
||||
reviewInlineEditorKey: ctx.reviewInlineEditorKey.value,
|
||||
reviewInlineErrors: ctx.reviewInlineErrors.value,
|
||||
reviewInlineForm: ctx.reviewInlineForm.value,
|
||||
DATE_INPUT_FORMAT: ctx.DATE_INPUT_FORMAT,
|
||||
REVIEW_SCENE_OPTIONS: ctx.REVIEW_SCENE_OPTIONS,
|
||||
REVIEW_SCENE_OTHER_OPTION: ctx.REVIEW_SCENE_OTHER_OPTION,
|
||||
clearInlineReviewFieldError: ctx.clearInlineReviewFieldError,
|
||||
commitInlineReviewEditor: ctx.commitInlineReviewEditor,
|
||||
selectInlineScene: ctx.selectInlineScene,
|
||||
reviewInlinePendingFiles: ctx.reviewInlinePendingFiles.value,
|
||||
openInlineReviewEditor: ctx.openInlineReviewEditor,
|
||||
reviewPanelConfidence: ctx.reviewPanelConfidence.value,
|
||||
reviewCategoryOptions: ctx.reviewCategoryOptions.value,
|
||||
selectReviewCategory: ctx.selectReviewCategory,
|
||||
reviewSelectedOtherCategory: ctx.reviewSelectedOtherCategory.value,
|
||||
reviewOtherCategoryOpen: ctx.reviewOtherCategoryOpen.value,
|
||||
reviewOtherCategoryOptions: ctx.reviewOtherCategoryOptions.value,
|
||||
selectReviewOtherCategory: ctx.selectReviewOtherCategory,
|
||||
activeReviewDocumentIndex: ctx.activeReviewDocumentIndex.value,
|
||||
reviewDocumentCount: ctx.reviewDocumentCount.value,
|
||||
goReviewDocument: ctx.goReviewDocument,
|
||||
activeReviewDocument: ctx.activeReviewDocument.value,
|
||||
activeReviewDocumentPreview: ctx.activeReviewDocumentPreview.value,
|
||||
canPreviewActiveReviewDocument: ctx.canPreviewActiveReviewDocument.value,
|
||||
openActiveReviewDocumentPreview: ctx.openActiveReviewDocumentPreview,
|
||||
reviewRiskSummary: ctx.reviewRiskSummary.value,
|
||||
reviewRiskItems: ctx.reviewRiskItems.value,
|
||||
appendReviewRiskBriefToConversation: ctx.appendReviewRiskBriefToConversation,
|
||||
reviewRiskEmpty: ctx.reviewRiskEmpty.value,
|
||||
reviewHasUnsavedChanges: ctx.reviewHasUnsavedChanges.value,
|
||||
saveInlineReviewChanges: ctx.saveInlineReviewChanges
|
||||
}))
|
||||
|
||||
return {
|
||||
insightPanelUi,
|
||||
messageItemUi
|
||||
}
|
||||
}
|
||||
@@ -601,7 +601,7 @@ export function useTravelReimbursementFlow({
|
||||
startFlowStep('pre-submit-review', {
|
||||
title: '自动检测与风险识别',
|
||||
tool: 'ExpenseClaimService.submit_claim',
|
||||
detail: '正在校验财务规则、风险规则和审批路径...'
|
||||
detail: '正在校验基础规则、风险规则和审批路径...'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -847,7 +847,7 @@ export function useTravelReimbursementFlow({
|
||||
if (String(response.status || '').trim() === 'submitted') {
|
||||
return isApplicationSessionActive()
|
||||
? '申请单提交成功'
|
||||
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||
: `已完成基础规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||
}
|
||||
if (response.submission_blocked) {
|
||||
return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批'
|
||||
|
||||
476
web/src/views/scripts/useTravelReimbursementMessageActions.js
Normal file
476
web/src/views/scripts/useTravelReimbursementMessageActions.js
Normal file
@@ -0,0 +1,476 @@
|
||||
import {
|
||||
buildOperationFeedbackPayload,
|
||||
normalizeOperationFeedbackContext
|
||||
} from '../../composables/useOperationFeedback.js'
|
||||
import { createOperationFeedback } from '../../services/operationFeedback.js'
|
||||
import {
|
||||
buildApplicationPreviewFooterMessage,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
|
||||
import {
|
||||
buildReviewNextStepRichCopy,
|
||||
buildReviewRiskLevelCounts,
|
||||
resolveReviewNextStepAction
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
import { buildReviewRiskConversationText } from './travelReimbursementReviewPanelModel.js'
|
||||
|
||||
export function useTravelReimbursementMessageActions({
|
||||
activeSessionType,
|
||||
buildMessageActionRows,
|
||||
conversationId,
|
||||
createMessage,
|
||||
currentInsight,
|
||||
currentUser,
|
||||
draftClaimId,
|
||||
emit,
|
||||
getHandleReviewActionInternal,
|
||||
latestReviewMessage,
|
||||
linkedRequest,
|
||||
messages,
|
||||
nextStepConfirmDialog,
|
||||
nextTick,
|
||||
persistSessionState,
|
||||
props,
|
||||
resolveActiveClaimId,
|
||||
resolveCurrentUserId,
|
||||
reviewActionBusy,
|
||||
router,
|
||||
scrollToBottom,
|
||||
submitComposer,
|
||||
submitting,
|
||||
toast
|
||||
}) {
|
||||
function queryDraftByClaimNo(claimNo) {
|
||||
const normalized = String(claimNo || '').trim()
|
||||
if (!normalized || submitting.value || reviewActionBusy.value) return
|
||||
submitComposer({
|
||||
rawText: `查看报销草稿 ${normalized} 的当前信息`,
|
||||
userText: `查看草稿 ${normalized}`,
|
||||
systemGenerated: true
|
||||
})
|
||||
}
|
||||
|
||||
function appendReviewRiskBriefToConversation(item) {
|
||||
if (!item) return
|
||||
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item, resolveReviewRiskDetailTarget()), [], {
|
||||
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
|
||||
metaTone: item.level || 'low'
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function appendExpenseQueryRiskToConversation(record, risk) {
|
||||
if (!record || !risk) return
|
||||
const claimId = String(record.claimId || '').trim()
|
||||
const claimNo = String(record.claimNo || '该单据').trim()
|
||||
const route = claimId
|
||||
? router.resolve({
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
: null
|
||||
messages.value.push(createMessage(
|
||||
'assistant',
|
||||
buildReviewRiskConversationText(
|
||||
{
|
||||
title: `${claimNo} ${risk.levelLabel || '风险提示'}:${risk.title || '风险提示'}`,
|
||||
summary: risk.summary,
|
||||
detail: risk.detail,
|
||||
suggestion: '请进入单据详情核对费用明细、票据附件和附加说明;如属于合理例外,请补充业务说明后再继续流程。',
|
||||
sourceLabel: risk.levelLabel,
|
||||
level: risk.level
|
||||
},
|
||||
route?.href
|
||||
? {
|
||||
href: route.href,
|
||||
label: `进入 ${claimNo} 详情重新填写`
|
||||
}
|
||||
: {}
|
||||
),
|
||||
[],
|
||||
{
|
||||
meta: [`${claimNo} 风险详情`],
|
||||
metaTone: risk.level || 'medium'
|
||||
}
|
||||
))
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function resolveReviewDetailTarget(message = null) {
|
||||
const latestDraftMessage = [...messages.value].reverse().find((item) => item?.draftPayload)
|
||||
const candidates = [
|
||||
message?.draftPayload,
|
||||
currentInsight.value.agent?.draftPayload,
|
||||
latestReviewMessage.value?.draftPayload,
|
||||
latestDraftMessage?.draftPayload,
|
||||
linkedRequest.value
|
||||
].filter(Boolean)
|
||||
const claimTarget = candidates.find((item) => String(item?.claim_id || item?.claimId || item?.id || '').trim())
|
||||
const claimId = String(claimTarget?.claim_id || claimTarget?.claimId || claimTarget?.id || draftClaimId.value || resolveActiveClaimId() || '').trim()
|
||||
if (!claimId) {
|
||||
return {}
|
||||
}
|
||||
const claimNoTarget = candidates.find((item) => String(item?.claim_no || item?.claimNo || item?.documentNo || '').trim())
|
||||
const claimNo = String(claimNoTarget?.claim_no || claimNoTarget?.claimNo || claimNoTarget?.documentNo || '').trim()
|
||||
const route = router.resolve({
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
return {
|
||||
href: route.href,
|
||||
label: claimNo ? `进入 ${claimNo} 详情重新填写` : '进入该单据详情重新填写'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReviewRiskDetailTarget() {
|
||||
return resolveReviewDetailTarget()
|
||||
}
|
||||
|
||||
function buildReviewNextStepRichCopyForMessage(message) {
|
||||
const target = resolveReviewDetailTarget(message)
|
||||
return buildReviewNextStepRichCopy(message?.reviewPayload, {
|
||||
detailHref: target.href || ''
|
||||
})
|
||||
}
|
||||
|
||||
function buildMessageBubbleClass(message) {
|
||||
if (message?.role === 'assistant' && message?.budgetReport) {
|
||||
return 'message-bubble-budget-report'
|
||||
}
|
||||
if (message?.role === 'assistant' && message?.applicationPreview) {
|
||||
return 'message-bubble-application-preview'
|
||||
}
|
||||
if (message?.role !== 'assistant' || !resolveReviewNextStepAction(message?.reviewPayload)) {
|
||||
return ''
|
||||
}
|
||||
const counts = buildReviewRiskLevelCounts(message.reviewPayload)
|
||||
if (counts.high > 0) {
|
||||
return 'message-bubble-review-risk-high'
|
||||
}
|
||||
if (counts.medium > 0) {
|
||||
return 'message-bubble-review-risk-medium'
|
||||
}
|
||||
if (counts.low > 0) {
|
||||
return 'message-bubble-review-risk-low'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function openReviewNextStepConfirm(message) {
|
||||
const action = resolveReviewNextStepAction(message?.reviewPayload)
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
nextStepConfirmDialog.value = {
|
||||
open: true,
|
||||
message,
|
||||
action
|
||||
}
|
||||
}
|
||||
|
||||
function closeReviewNextStepConfirm() {
|
||||
if (reviewActionBusy.value) {
|
||||
return
|
||||
}
|
||||
nextStepConfirmDialog.value = {
|
||||
open: false,
|
||||
message: null,
|
||||
action: null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReviewNextStepSubmit() {
|
||||
const message = nextStepConfirmDialog.value.message
|
||||
const action = nextStepConfirmDialog.value.action
|
||||
const handleReviewActionInternal = getHandleReviewActionInternal()
|
||||
if (!message || !action || reviewActionBusy.value || !handleReviewActionInternal) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await handleReviewActionInternal(message, action)
|
||||
} finally {
|
||||
nextStepConfirmDialog.value = {
|
||||
open: false,
|
||||
message: null,
|
||||
action: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildApplicationPreviewFooterText(message) {
|
||||
if (!message?.applicationPreview) {
|
||||
return ''
|
||||
}
|
||||
return buildApplicationPreviewFooterMessage(message.applicationPreview)
|
||||
}
|
||||
|
||||
function isApplicationDraftPayload(draftPayload) {
|
||||
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
|
||||
}
|
||||
|
||||
function resolveDraftPayloadBodyField(draftPayload, label) {
|
||||
const body = String(draftPayload?.body || '')
|
||||
const pattern = new RegExp(`^${label}:(.+)$`, 'm')
|
||||
return String(body.match(pattern)?.[1] || '').trim()
|
||||
}
|
||||
|
||||
function resolveApplicationDraftStatusLabel(draftPayload) {
|
||||
const status = String(draftPayload?.status || '').trim()
|
||||
if (status === 'submitted') return '审批中'
|
||||
return status || '已生成'
|
||||
}
|
||||
|
||||
function buildApplicationDraftSummaryItems(draftPayload) {
|
||||
if (!isApplicationDraftPayload(draftPayload)) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{ label: '单号', value: String(draftPayload?.claim_no || '').trim() || '待生成' },
|
||||
{ label: '类型', value: String(draftPayload?.title || '').trim() || '费用申请' },
|
||||
{ label: '节点', value: String(draftPayload?.approval_stage || '').trim() || '直属领导审批' },
|
||||
{ label: '时间', value: resolveDraftPayloadBodyField(draftPayload, '发生时间') },
|
||||
{ label: '费用', value: resolveDraftPayloadBodyField(draftPayload, '用户预估费用') }
|
||||
].filter((item) => String(item.value || '').trim() && item.value !== '待补充')
|
||||
}
|
||||
|
||||
function shouldShowDraftSavedCard(message) {
|
||||
const draftPayload = message?.draftPayload || null
|
||||
return Boolean(
|
||||
draftPayload
|
||||
&& (
|
||||
String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|
||||
|| String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|
||||
|| String(draftPayload.title || '').trim()
|
||||
|| String(draftPayload.body || '').trim()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function canOpenDraftDetail(message) {
|
||||
const draftPayload = message?.draftPayload || {}
|
||||
return Boolean(String(draftPayload.claim_id || draftPayload.claimId || '').trim())
|
||||
}
|
||||
|
||||
function resolveReimbursementDraftClaimNo(draftPayload) {
|
||||
return String(
|
||||
draftPayload?.claim_no
|
||||
|| draftPayload?.claimNo
|
||||
|| draftPayload?.claim_id
|
||||
|| draftPayload?.claimId
|
||||
|| ''
|
||||
).trim() || '保存后生成'
|
||||
}
|
||||
|
||||
function updateMessageOperationFeedback(message, patch = {}) {
|
||||
if (!message?.id) {
|
||||
return
|
||||
}
|
||||
messages.value = messages.value.map((item) => (
|
||||
item.id === message.id
|
||||
? {
|
||||
...item,
|
||||
operationFeedback: {
|
||||
...(item.operationFeedback || {}),
|
||||
...patch
|
||||
}
|
||||
}
|
||||
: item
|
||||
))
|
||||
}
|
||||
|
||||
function shouldShowAssistantMessageActions(message) {
|
||||
return Boolean(
|
||||
message?.role === 'assistant'
|
||||
&& (
|
||||
String(message.text || '').trim()
|
||||
|| message.applicationPreview
|
||||
|| message.reviewPayload
|
||||
|| message.queryPayload
|
||||
|| message.draftPayload
|
||||
|| message.budgetReport
|
||||
)
|
||||
&& !(message.stewardPlan?.streamStatus === 'streaming' && !String(message.text || '').trim())
|
||||
)
|
||||
}
|
||||
|
||||
function buildMessageActionText(message) {
|
||||
const parts = []
|
||||
const text = String(message?.text || '').trim()
|
||||
if (text) {
|
||||
parts.push(text)
|
||||
}
|
||||
const actionRows = buildMessageActionRows(message)
|
||||
if (actionRows.length) {
|
||||
parts.push(actionRows.join('\n'))
|
||||
}
|
||||
if (message?.draftPayload) {
|
||||
const claimNo = resolveReimbursementDraftClaimNo(message.draftPayload)
|
||||
if (claimNo) {
|
||||
parts.push(`单据:${claimNo}`)
|
||||
}
|
||||
}
|
||||
return parts.join('\n\n').trim()
|
||||
}
|
||||
|
||||
async function copyAssistantMessage(message) {
|
||||
const text = buildMessageActionText(message)
|
||||
if (!text) {
|
||||
toast('当前消息没有可复制的内容。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (globalThis.navigator?.clipboard?.writeText) {
|
||||
await globalThis.navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
const textarea = globalThis.document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', 'readonly')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
globalThis.document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
globalThis.document.execCommand('copy')
|
||||
globalThis.document.body.removeChild(textarea)
|
||||
}
|
||||
toast('已复制。')
|
||||
} catch (error) {
|
||||
console.warn('Failed to copy assistant message:', error)
|
||||
toast('复制失败,请稍后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
function speakAssistantMessage(message) {
|
||||
const text = buildMessageActionText(message)
|
||||
if (!text) {
|
||||
toast('当前消息没有可播报的内容。')
|
||||
return
|
||||
}
|
||||
const speech = globalThis.speechSynthesis
|
||||
if (!speech || typeof globalThis.SpeechSynthesisUtterance === 'undefined') {
|
||||
toast('当前浏览器不支持语音播报。')
|
||||
return
|
||||
}
|
||||
speech.cancel()
|
||||
const utterance = new globalThis.SpeechSynthesisUtterance(text.slice(0, 1200))
|
||||
utterance.lang = 'zh-CN'
|
||||
utterance.rate = 1
|
||||
speech.speak(utterance)
|
||||
}
|
||||
|
||||
function buildMessageOperationFeedbackContext(message) {
|
||||
const existingContext = message?.operationFeedback?.context || null
|
||||
if (existingContext) {
|
||||
return existingContext
|
||||
}
|
||||
const messageId = String(message?.id || '').trim()
|
||||
const assistantName = String(message?.assistantName || '').trim()
|
||||
return normalizeOperationFeedbackContext({
|
||||
run_id: messageId.slice(0, 50) || null,
|
||||
conversation_id: String(conversationId.value || '').trim(),
|
||||
user_id: resolveCurrentUserId(),
|
||||
selected_agent: assistantName || (message?.stewardPlan ? STEWARD_ASSISTANT_NAME : 'user_agent'),
|
||||
source: 'assistant_message_action',
|
||||
session_type: activeSessionType.value,
|
||||
operation_type: message?.stewardPlan ? 'steward_message' : 'assistant_message',
|
||||
operation_status: 'succeeded',
|
||||
status: 'succeeded',
|
||||
entry_source: props.entrySource,
|
||||
result_summary: buildMessageActionText(message).slice(0, 500)
|
||||
}, currentUser.value || {})
|
||||
}
|
||||
|
||||
async function submitOperationFeedbackForMessage(message, feedback = {}) {
|
||||
const rating = Number(feedback.rating || 0)
|
||||
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
|
||||
updateMessageOperationFeedback(message, { error: '请选择 1 到 5 星评分。' })
|
||||
return
|
||||
}
|
||||
|
||||
const context = buildMessageOperationFeedbackContext(message)
|
||||
if (!context) {
|
||||
return
|
||||
}
|
||||
|
||||
updateMessageOperationFeedback(message, {
|
||||
submitting: true,
|
||||
rating,
|
||||
reason: String(feedback.reason || '').trim(),
|
||||
context,
|
||||
error: ''
|
||||
})
|
||||
try {
|
||||
await createOperationFeedback(
|
||||
buildOperationFeedbackPayload(context, feedback, currentUser.value || {})
|
||||
)
|
||||
updateMessageOperationFeedback(message, {
|
||||
submitting: false,
|
||||
submitted: true,
|
||||
dismissed: false,
|
||||
rating,
|
||||
reason: String(feedback.reason || '').trim(),
|
||||
error: ''
|
||||
})
|
||||
persistSessionState()
|
||||
} catch (error) {
|
||||
updateMessageOperationFeedback(message, {
|
||||
submitting: false,
|
||||
error: error?.message || '评价提交失败,请稍后重试。'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isMessageFeedbackSelected(message, rating) {
|
||||
return Boolean(
|
||||
message?.operationFeedback?.submitted
|
||||
&& Number(message.operationFeedback.rating || 0) === Number(rating || 0)
|
||||
)
|
||||
}
|
||||
|
||||
async function openApplicationDraftDetail(message) {
|
||||
const draftPayload = message?.draftPayload || {}
|
||||
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|
||||
if (!claimId) {
|
||||
toast('暂未获取到单据 ID,稍后可在单据中心查看。')
|
||||
return
|
||||
}
|
||||
await router.push({
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewMissingFields(message) {
|
||||
if (!message?.applicationPreview) {
|
||||
return []
|
||||
}
|
||||
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
|
||||
return Array.isArray(normalizedPreview.missingFields) ? normalizedPreview.missingFields : []
|
||||
}
|
||||
|
||||
return {
|
||||
appendExpenseQueryRiskToConversation,
|
||||
appendReviewRiskBriefToConversation,
|
||||
buildApplicationDraftSummaryItems,
|
||||
buildApplicationPreviewFooterText,
|
||||
buildMessageBubbleClass,
|
||||
buildReviewNextStepRichCopyForMessage,
|
||||
canOpenDraftDetail,
|
||||
closeReviewNextStepConfirm,
|
||||
confirmReviewNextStepSubmit,
|
||||
copyAssistantMessage,
|
||||
isApplicationDraftPayload,
|
||||
isMessageFeedbackSelected,
|
||||
openApplicationDraftDetail,
|
||||
openReviewNextStepConfirm,
|
||||
queryDraftByClaimNo,
|
||||
resolveApplicationDraftStatusLabel,
|
||||
resolveApplicationPreviewMissingFields,
|
||||
resolveReimbursementDraftClaimNo,
|
||||
shouldShowAssistantMessageActions,
|
||||
shouldShowDraftSavedCard,
|
||||
speakAssistantMessage,
|
||||
submitOperationFeedbackForMessage
|
||||
}
|
||||
}
|
||||
775
web/src/views/scripts/useTravelReimbursementStewardRuntime.js
Normal file
775
web/src/views/scripts/useTravelReimbursementStewardRuntime.js
Normal file
@@ -0,0 +1,775 @@
|
||||
import { fetchStewardRuntimeDecision } from '../../services/steward.js'
|
||||
import {
|
||||
buildApplicationPreviewSubmitText,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
buildTravelPlanningNudgeMessage,
|
||||
buildTravelPlanningSuggestedActions
|
||||
} from '../../utils/travelApplicationPlanning.js'
|
||||
import {
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_STEWARD
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
import {
|
||||
APPLICATION_PREVIEW_FIELD_ACTION_SET,
|
||||
STEWARD_ASSISTANT_NAME,
|
||||
isApplicationSubmitConfirmationText,
|
||||
isStewardRuntimeCancelText,
|
||||
isStewardRuntimeContinueText,
|
||||
normalizeStewardRuntimeInputText,
|
||||
resolveStewardRuntimeTransportAlias,
|
||||
shouldPlanNewStewardTasksLocally
|
||||
} from './travelReimbursementStewardRuntimeTextModel.js'
|
||||
import {
|
||||
buildStewardContinuationAfterAction,
|
||||
pushStewardContinuationMessage,
|
||||
resolveStewardMissingFieldItems
|
||||
} from './travelReimbursementStewardFollowupFlow.js'
|
||||
|
||||
export { STEWARD_ASSISTANT_NAME } from './travelReimbursementStewardRuntimeTextModel.js'
|
||||
|
||||
export function useTravelReimbursementStewardRuntime(ctx) {
|
||||
const {
|
||||
activeSessionType,
|
||||
applicationSubmitConfirmDialog,
|
||||
attachedFiles,
|
||||
composerDraft,
|
||||
createMessage,
|
||||
currentUser,
|
||||
emit,
|
||||
handleSuggestedAction,
|
||||
isStewardSession,
|
||||
linkedRequest,
|
||||
messages,
|
||||
nextTick,
|
||||
persistSessionState,
|
||||
props,
|
||||
reviewActionBusy,
|
||||
scrollToBottom,
|
||||
sessionSwitchBusy,
|
||||
submitComposer,
|
||||
submitStewardPlan,
|
||||
submitting,
|
||||
toast,
|
||||
adjustComposerTextareaHeight,
|
||||
resolveCurrentUserId
|
||||
} = ctx
|
||||
|
||||
function findLatestApplicationPreviewMessage() {
|
||||
for (const message of [...messages.value].reverse()) {
|
||||
if (
|
||||
message?.role !== 'assistant' ||
|
||||
!message.applicationPreview ||
|
||||
message.applicationSubmitConfirmed
|
||||
) {
|
||||
continue
|
||||
}
|
||||
return message
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findPendingApplicationSubmitMessage() {
|
||||
const message = findLatestApplicationPreviewMessage()
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
|
||||
if (normalizedPreview.readyToSubmit) {
|
||||
message.applicationPreview = normalizedPreview
|
||||
return message
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pushApplicationSubmitBlockedMessage(userText = '', message = null, options = {}) {
|
||||
const normalizedPreview = normalizeApplicationPreview(message?.applicationPreview || {})
|
||||
const missingFields = Array.isArray(normalizedPreview.missingFields)
|
||||
? normalizedPreview.missingFields
|
||||
: []
|
||||
const validationIssues = Array.isArray(normalizedPreview.validationIssues)
|
||||
? normalizedPreview.validationIssues
|
||||
: []
|
||||
if (userText && !options.userMessageAlreadyAdded) {
|
||||
messages.value.push(createMessage('user', userText))
|
||||
}
|
||||
messages.value.push(createMessage(
|
||||
'assistant',
|
||||
[
|
||||
'我理解你是在确认当前申请单,但这张申请单还不能提交。',
|
||||
'',
|
||||
missingFields.length
|
||||
? `还需要先补充:**${missingFields.join('、')}**。`
|
||||
: validationIssues.length
|
||||
? `需要先修正:**${validationIssues[0].message}**`
|
||||
: '请先把申请核对表中的待补充信息补齐。',
|
||||
'',
|
||||
'补齐后再输入“确认”,我会继续提交至审批流程。'
|
||||
].join('\n'),
|
||||
[],
|
||||
{
|
||||
assistantName: String(message?.assistantName || '').trim() || undefined,
|
||||
meta: ['等待补充']
|
||||
}
|
||||
))
|
||||
composerDraft.value = ''
|
||||
persistSessionState()
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
|
||||
async function handleApplicationSubmitConfirmationText(options = {}) {
|
||||
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
|
||||
const files = Array.from(options.files ?? attachedFiles.value ?? [])
|
||||
if (!isApplicationSubmitConfirmationText(rawText) || files.length) {
|
||||
return false
|
||||
}
|
||||
const latestApplicationMessage = findLatestApplicationPreviewMessage()
|
||||
if (!latestApplicationMessage) {
|
||||
return false
|
||||
}
|
||||
const targetMessage = findPendingApplicationSubmitMessage()
|
||||
if (!targetMessage) {
|
||||
pushApplicationSubmitBlockedMessage(rawText, latestApplicationMessage)
|
||||
return true
|
||||
}
|
||||
applicationSubmitConfirmDialog.value = {
|
||||
open: true,
|
||||
message: targetMessage
|
||||
}
|
||||
await confirmApplicationSubmit({ userText: rawText })
|
||||
return true
|
||||
}
|
||||
|
||||
function findPendingStewardSuggestedActionContext(decision = null) {
|
||||
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
|
||||
const targetTaskId = String(decision?.target_task_id || decision?.targetTaskId || '').trim()
|
||||
for (const message of [...messages.value].reverse()) {
|
||||
if (
|
||||
message?.role !== 'assistant' ||
|
||||
message.suggestedActionsLocked ||
|
||||
!Array.isArray(message.suggestedActions) ||
|
||||
!message.suggestedActions.length
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (targetMessageId && String(message.id || '') !== targetMessageId) {
|
||||
continue
|
||||
}
|
||||
const action = message.suggestedActions.find((item) => {
|
||||
if (String(item?.action_type || '').trim() === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
|
||||
return false
|
||||
}
|
||||
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}
|
||||
return !targetTaskId ||
|
||||
String(payload.steward_next_task_id || payload.target_task_id || '').trim() === targetTaskId
|
||||
}) || message.suggestedActions[0]
|
||||
if (action) {
|
||||
return { message, action }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findPendingSlotSuggestedActionContext(decision = null) {
|
||||
const fieldKey = String(decision?.field_key || decision?.fieldKey || '').trim()
|
||||
const fieldValue = String(decision?.field_value || decision?.fieldValue || '').trim()
|
||||
for (const message of [...messages.value].reverse()) {
|
||||
if (
|
||||
message?.role !== 'assistant' ||
|
||||
message.suggestedActionsLocked ||
|
||||
!Array.isArray(message.suggestedActions) ||
|
||||
!message.suggestedActions.length
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const action = message.suggestedActions.find((item) => {
|
||||
if (String(item?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) {
|
||||
return false
|
||||
}
|
||||
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}
|
||||
const payloadField = String(payload.field_key || payload.fieldKey || '').trim()
|
||||
const payloadValue = String(payload.value || item?.label || '').trim()
|
||||
return payloadField && (!fieldKey || payloadField === fieldKey) && (!fieldValue || payloadValue === fieldValue)
|
||||
})
|
||||
if (action) {
|
||||
return { message, action }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findPendingSlotSuggestedActionContextByInput(rawText = '') {
|
||||
const normalizedInput = normalizeStewardRuntimeInputText(rawText)
|
||||
if (!normalizedInput) {
|
||||
return null
|
||||
}
|
||||
const transportAlias = resolveStewardRuntimeTransportAlias(normalizedInput)
|
||||
for (const message of [...messages.value].reverse()) {
|
||||
if (
|
||||
message?.role !== 'assistant' ||
|
||||
message.suggestedActionsLocked ||
|
||||
!Array.isArray(message.suggestedActions) ||
|
||||
!message.suggestedActions.length
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const exactMatches = []
|
||||
const fuzzyMatches = []
|
||||
message.suggestedActions.forEach((action) => {
|
||||
if (String(action?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) {
|
||||
return
|
||||
}
|
||||
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const fieldKey = String(payload.field_key || payload.fieldKey || '').trim()
|
||||
const value = String(payload.value || action?.label || '').trim()
|
||||
const label = String(action?.label || value).trim()
|
||||
const tokens = [value, label]
|
||||
.map((item) => normalizeStewardRuntimeInputText(item))
|
||||
.filter(Boolean)
|
||||
if (!fieldKey || !value || !tokens.length) {
|
||||
return
|
||||
}
|
||||
if (tokens.includes(normalizedInput)) {
|
||||
exactMatches.push({ message, action })
|
||||
return
|
||||
}
|
||||
const actionTransportAlias = resolveStewardRuntimeTransportAlias(`${value}${label}`)
|
||||
if (
|
||||
transportAlias &&
|
||||
(
|
||||
tokens.includes(normalizeStewardRuntimeInputText(transportAlias)) ||
|
||||
actionTransportAlias === transportAlias
|
||||
)
|
||||
) {
|
||||
fuzzyMatches.push({ message, action })
|
||||
return
|
||||
}
|
||||
if (tokens.some((token) => token.length >= 2 && normalizedInput.includes(token))) {
|
||||
fuzzyMatches.push({ message, action })
|
||||
}
|
||||
})
|
||||
|
||||
if (exactMatches.length === 1) {
|
||||
return exactMatches[0]
|
||||
}
|
||||
if (exactMatches.length > 1) {
|
||||
return null
|
||||
}
|
||||
const uniqueFuzzyMatches = fuzzyMatches.filter((item, index, list) =>
|
||||
list.findIndex((candidate) => candidate.action === item.action) === index
|
||||
)
|
||||
if (uniqueFuzzyMatches.length === 1) {
|
||||
return uniqueFuzzyMatches[0]
|
||||
}
|
||||
if (uniqueFuzzyMatches.length > 1) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildStewardRuntimeState() {
|
||||
const latestApplicationMessage = findLatestApplicationPreviewMessage()
|
||||
const applicationPreview = latestApplicationMessage?.applicationPreview
|
||||
? normalizeApplicationPreview(latestApplicationMessage.applicationPreview)
|
||||
: null
|
||||
const applicationContinuation = latestApplicationMessage?.stewardContinuation || null
|
||||
const pendingSlotContext = findPendingSlotSuggestedActionContext()
|
||||
const pendingStewardContext = pendingSlotContext ? null : findPendingStewardSuggestedActionContext()
|
||||
const pendingActionPayload = pendingStewardContext?.action?.payload && typeof pendingStewardContext.action.payload === 'object'
|
||||
? pendingStewardContext.action.payload
|
||||
: {}
|
||||
const pendingSlotPayload = pendingSlotContext?.action?.payload && typeof pendingSlotContext.action.payload === 'object'
|
||||
? pendingSlotContext.action.payload
|
||||
: {}
|
||||
const continuation = applicationContinuation || pendingStewardContext?.message?.stewardContinuation || null
|
||||
const remainingTasks = Array.isArray(continuation?.remainingTasks)
|
||||
? continuation.remainingTasks
|
||||
: []
|
||||
const pendingApplication = latestApplicationMessage && applicationPreview
|
||||
? {
|
||||
message_id: String(latestApplicationMessage.id || '').trim(),
|
||||
task_id: String(
|
||||
applicationContinuation?.currentTaskId ||
|
||||
applicationContinuation?.current_task_id ||
|
||||
applicationContinuation?.currentTask?.task_id ||
|
||||
applicationContinuation?.currentTask?.taskId ||
|
||||
''
|
||||
).trim(),
|
||||
ready_to_submit: Boolean(applicationPreview.readyToSubmit),
|
||||
missing_fields: Array.isArray(applicationPreview.missingFields) ? applicationPreview.missingFields : [],
|
||||
fields: applicationPreview.fields || {}
|
||||
}
|
||||
: null
|
||||
return {
|
||||
waiting_for: pendingApplication
|
||||
? (pendingApplication.ready_to_submit ? 'application_submit_confirmation' : 'application_field_completion')
|
||||
: pendingSlotContext
|
||||
? 'application_field_completion'
|
||||
: pendingStewardContext
|
||||
? 'steward_next_task_confirmation'
|
||||
: '',
|
||||
current_task: continuation?.currentTask || continuation?.current_task || null,
|
||||
remaining_tasks: remainingTasks,
|
||||
completed_tasks: messages.value
|
||||
.filter((message) => message?.applicationSubmitConfirmed)
|
||||
.map((message) => ({
|
||||
message_id: String(message.id || '').trim(),
|
||||
task_type: 'expense_application'
|
||||
})),
|
||||
pending_application: pendingApplication,
|
||||
pending_steward_action: pendingStewardContext
|
||||
? {
|
||||
message_id: String(pendingStewardContext.message?.id || '').trim(),
|
||||
action_type: String(pendingStewardContext.action?.action_type || '').trim(),
|
||||
label: String(pendingStewardContext.action?.label || '').trim(),
|
||||
target_task_id: String(pendingActionPayload.steward_next_task_id || pendingActionPayload.target_task_id || '').trim(),
|
||||
payload: pendingActionPayload
|
||||
}
|
||||
: null,
|
||||
pending_slot_action: pendingSlotContext
|
||||
? {
|
||||
message_id: String(pendingSlotContext.message?.id || '').trim(),
|
||||
field_key: String(pendingSlotPayload.field_key || pendingSlotPayload.fieldKey || '').trim(),
|
||||
label: String(pendingSlotContext.action?.label || '').trim(),
|
||||
payload: pendingSlotPayload
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
function hasActiveStewardRuntimeDecisionContext(runtimeState = {}) {
|
||||
return Boolean(
|
||||
String(runtimeState?.waiting_for || '').trim() ||
|
||||
runtimeState?.pending_application ||
|
||||
runtimeState?.pending_steward_action ||
|
||||
runtimeState?.pending_slot_action ||
|
||||
runtimeState?.current_task ||
|
||||
(Array.isArray(runtimeState?.remaining_tasks) && runtimeState.remaining_tasks.length > 0) ||
|
||||
(Array.isArray(runtimeState?.completed_tasks) && runtimeState.completed_tasks.length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function pushStewardRuntimeUserMessage(userText = '') {
|
||||
const normalizedText = String(userText || '').trim()
|
||||
if (!normalizedText) {
|
||||
return false
|
||||
}
|
||||
messages.value.push(createMessage('user', normalizedText))
|
||||
composerDraft.value = ''
|
||||
persistSessionState()
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function pushStewardRuntimeResponse(userText = '', decision = null, options = {}) {
|
||||
if (userText && !options.userMessageAlreadyAdded) {
|
||||
messages.value.push(createMessage('user', userText))
|
||||
}
|
||||
const text = String(decision?.question || decision?.response_text || decision?.responseText || decision?.rationale || '').trim()
|
||||
if (text) {
|
||||
messages.value.push(createMessage('assistant', text, [], {
|
||||
assistantName: STEWARD_ASSISTANT_NAME,
|
||||
meta: [STEWARD_ASSISTANT_NAME]
|
||||
}))
|
||||
}
|
||||
composerDraft.value = ''
|
||||
persistSessionState()
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
|
||||
function buildStewardRuntimeFastPathDecision(rawText = '', runtimeState = {}) {
|
||||
const normalizedText = String(rawText || '').trim()
|
||||
if (!normalizedText) {
|
||||
return null
|
||||
}
|
||||
if (shouldPlanNewStewardTasksLocally(normalizedText, runtimeState)) {
|
||||
return {
|
||||
next_action: 'plan_new_tasks'
|
||||
}
|
||||
}
|
||||
if (isStewardRuntimeCancelText(normalizedText)) {
|
||||
return {
|
||||
next_action: 'cancel_current_action',
|
||||
response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果你要重新规划,请直接告诉我新的财务事项。'
|
||||
}
|
||||
}
|
||||
const slotContext = findPendingSlotSuggestedActionContextByInput(normalizedText)
|
||||
const payload = slotContext?.action?.payload && typeof slotContext.action.payload === 'object'
|
||||
? slotContext.action.payload
|
||||
: {}
|
||||
if (slotContext) {
|
||||
return {
|
||||
next_action: 'fill_current_slot',
|
||||
target_message_id: String(slotContext.message?.id || '').trim(),
|
||||
field_key: String(payload.field_key || payload.fieldKey || '').trim(),
|
||||
field_value: String(payload.value || slotContext.action?.label || normalizedText).trim()
|
||||
}
|
||||
}
|
||||
if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) {
|
||||
if (runtimeState?.pending_application?.ready_to_submit) {
|
||||
return {
|
||||
next_action: 'submit_current_application',
|
||||
target_message_id: runtimeState.pending_application.message_id || ''
|
||||
}
|
||||
}
|
||||
if (runtimeState?.pending_steward_action) {
|
||||
return {
|
||||
next_action: 'continue_next_task',
|
||||
target_message_id: runtimeState.pending_steward_action.message_id || '',
|
||||
target_task_id: runtimeState.pending_steward_action.target_task_id || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
if (String(runtimeState?.waiting_for || '').trim() === 'application_field_completion') {
|
||||
if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) {
|
||||
const missingFields = Array.isArray(runtimeState?.pending_application?.missing_fields)
|
||||
? runtimeState.pending_application.missing_fields
|
||||
: []
|
||||
return {
|
||||
next_action: 'ask_user',
|
||||
response_text: missingFields.length
|
||||
? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}。你可以直接回复对应选项或填写具体内容。`
|
||||
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function shouldUseStewardRuntimeLlmDecision(rawText = '', runtimeState = {}) {
|
||||
if (shouldPlanNewStewardTasksLocally(rawText, runtimeState)) {
|
||||
return false
|
||||
}
|
||||
const normalizedText = normalizeStewardRuntimeInputText(rawText)
|
||||
if (!normalizedText) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
isApplicationSubmitConfirmationText(normalizedText) ||
|
||||
isStewardRuntimeContinueText(normalizedText) ||
|
||||
isStewardRuntimeCancelText(normalizedText)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
findPendingSlotSuggestedActionContextByInput(normalizedText)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function executeStewardRuntimeDecision(decision = null, rawText = '', options = {}) {
|
||||
const nextAction = String(decision?.next_action || decision?.nextAction || '').trim()
|
||||
const userMessageAlreadyAdded = Boolean(options.userMessageAlreadyAdded)
|
||||
if (nextAction === 'submit_current_application') {
|
||||
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
|
||||
const targetMessage = targetMessageId
|
||||
? messages.value.find((message) => String(message.id || '') === targetMessageId)
|
||||
: findPendingApplicationSubmitMessage()
|
||||
if (!targetMessage?.applicationPreview) {
|
||||
return false
|
||||
}
|
||||
const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
|
||||
if (!normalizedPreview.readyToSubmit) {
|
||||
pushApplicationSubmitBlockedMessage(rawText, targetMessage, { userMessageAlreadyAdded })
|
||||
return true
|
||||
}
|
||||
targetMessage.applicationPreview = normalizedPreview
|
||||
applicationSubmitConfirmDialog.value = { open: true, message: targetMessage }
|
||||
await confirmApplicationSubmit({ userText: rawText, skipUserMessage: userMessageAlreadyAdded })
|
||||
return true
|
||||
}
|
||||
if (nextAction === 'continue_next_task') {
|
||||
const context = findPendingStewardSuggestedActionContext(decision)
|
||||
if (!context) {
|
||||
return false
|
||||
}
|
||||
if (rawText && !userMessageAlreadyAdded) {
|
||||
messages.value.push(createMessage('user', rawText))
|
||||
}
|
||||
context.action.confirmedByText = true
|
||||
composerDraft.value = ''
|
||||
persistSessionState()
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
await handleSuggestedAction(context.message, context.action)
|
||||
return true
|
||||
}
|
||||
if (nextAction === 'fill_current_slot') {
|
||||
const context = findPendingSlotSuggestedActionContext(decision)
|
||||
if (!context) {
|
||||
return false
|
||||
}
|
||||
await handleSuggestedAction(context.message, {
|
||||
...context.action,
|
||||
label: String(decision?.field_value || decision?.fieldValue || context.action.label || '').trim(),
|
||||
suppressUserEcho: userMessageAlreadyAdded
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (nextAction === 'ask_user' || nextAction === 'cancel_current_action' || nextAction === 'no_op') {
|
||||
pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function handleStewardRuntimeDecision(options = {}) {
|
||||
if (!isStewardSession.value || options.skipStewardPlan) {
|
||||
return false
|
||||
}
|
||||
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
|
||||
const files = Array.from(options.files ?? attachedFiles.value ?? [])
|
||||
if (!rawText || files.length) {
|
||||
return false
|
||||
}
|
||||
const runtimeState = buildStewardRuntimeState()
|
||||
if (!hasActiveStewardRuntimeDecisionContext(runtimeState)) {
|
||||
return false
|
||||
}
|
||||
const userMessageAlreadyAdded = options.skipUserMessage
|
||||
? false
|
||||
: pushStewardRuntimeUserMessage(rawText)
|
||||
try {
|
||||
const fastDecision = buildStewardRuntimeFastPathDecision(rawText, runtimeState)
|
||||
if (fastDecision) {
|
||||
if (String(fastDecision.next_action || fastDecision.nextAction || '').trim() === 'plan_new_tasks') {
|
||||
await submitStewardPlan({
|
||||
...options,
|
||||
rawText,
|
||||
userText: rawText,
|
||||
skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage
|
||||
})
|
||||
return true
|
||||
}
|
||||
const fastExecuted = await executeStewardRuntimeDecision(fastDecision, rawText, { userMessageAlreadyAdded })
|
||||
if (fastExecuted) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (!shouldUseStewardRuntimeLlmDecision(rawText, runtimeState)) {
|
||||
if (userMessageAlreadyAdded) {
|
||||
pushStewardRuntimeResponse('', {
|
||||
response_text: '我还需要先确认当前等待项。请回复系统刚刚追问的选项或具体补充内容。'
|
||||
}, { userMessageAlreadyAdded: true })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const decision = await fetchStewardRuntimeDecision({
|
||||
user_message: rawText,
|
||||
session_type: SESSION_TYPE_STEWARD,
|
||||
runtime_state: runtimeState,
|
||||
context_json: {
|
||||
entry_source: props.entrySource,
|
||||
user_id: resolveCurrentUserId()
|
||||
}
|
||||
}, {
|
||||
timeoutMs: 45000,
|
||||
timeoutMessage: '小财管家运行时决策超时,已回到当前上下文兜底处理。'
|
||||
})
|
||||
if (String(decision?.next_action || decision?.nextAction || '').trim() === 'plan_new_tasks') {
|
||||
await submitStewardPlan({
|
||||
...options,
|
||||
rawText,
|
||||
userText: rawText,
|
||||
skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage
|
||||
})
|
||||
return true
|
||||
}
|
||||
const executed = await executeStewardRuntimeDecision(decision, rawText, { userMessageAlreadyAdded })
|
||||
if (executed) {
|
||||
return true
|
||||
}
|
||||
if (userMessageAlreadyAdded) {
|
||||
await submitStewardPlan({
|
||||
...options,
|
||||
rawText,
|
||||
userText: rawText,
|
||||
skipUserMessage: true
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.warn('Steward runtime decision failed:', error)
|
||||
if (userMessageAlreadyAdded) {
|
||||
await submitStewardPlan({
|
||||
...options,
|
||||
rawText,
|
||||
userText: rawText,
|
||||
skipUserMessage: true
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function openApplicationSubmitConfirm(message) {
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
if (message.applicationPreview) {
|
||||
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
|
||||
message.applicationPreview = normalizedPreview
|
||||
message.text = buildLocalApplicationPreviewMessage(normalizedPreview)
|
||||
if (!normalizedPreview.readyToSubmit) {
|
||||
const validationIssues = Array.isArray(normalizedPreview.validationIssues)
|
||||
? normalizedPreview.validationIssues
|
||||
: []
|
||||
toast(
|
||||
validationIssues.length
|
||||
? validationIssues[0].message
|
||||
: `请先补充:${normalizedPreview.missingFields.join('、')}。`
|
||||
)
|
||||
persistSessionState()
|
||||
return
|
||||
}
|
||||
}
|
||||
applicationSubmitConfirmDialog.value = {
|
||||
open: true,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
function closeApplicationSubmitConfirm() {
|
||||
if (reviewActionBusy.value) {
|
||||
return
|
||||
}
|
||||
applicationSubmitConfirmDialog.value = {
|
||||
open: false,
|
||||
message: null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveApplicationEditClaimId() {
|
||||
if (activeSessionType.value !== SESSION_TYPE_APPLICATION) {
|
||||
return ''
|
||||
}
|
||||
const request = linkedRequest.value || {}
|
||||
if (!request.applicationEditMode) {
|
||||
return ''
|
||||
}
|
||||
return String(request.claimId || request.claim_id || '').trim()
|
||||
}
|
||||
|
||||
async function confirmApplicationSubmit(options = {}) {
|
||||
const message = applicationSubmitConfirmDialog.value.message
|
||||
if (!message || submitting.value || reviewActionBusy.value) {
|
||||
return
|
||||
}
|
||||
const applicationPreview = message?.applicationPreview && typeof message.applicationPreview === 'object'
|
||||
? normalizeApplicationPreview(message.applicationPreview)
|
||||
: null
|
||||
const applicationSubmitText = applicationPreview
|
||||
? buildApplicationPreviewSubmitText(applicationPreview)
|
||||
: '确认提交'
|
||||
const applicationEditClaimId = resolveApplicationEditClaimId()
|
||||
applicationSubmitConfirmDialog.value = {
|
||||
open: false,
|
||||
message: null
|
||||
}
|
||||
const stewardSubmitContinuation = message?.stewardContinuation || null
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const payload = await submitComposer({
|
||||
rawText: applicationSubmitText,
|
||||
userText: String(options.userText || '').trim() || '确认提交',
|
||||
skipUserMessage: Boolean(options.skipUserMessage),
|
||||
pendingText: '正在提交费用申请...',
|
||||
systemGenerated: true,
|
||||
skipScopeGuard: true,
|
||||
skipStewardPlan: true,
|
||||
stewardContinuation: stewardSubmitContinuation,
|
||||
sessionTypeOverride: SESSION_TYPE_APPLICATION,
|
||||
feedbackOperationType: 'submit_application',
|
||||
extraContext: {
|
||||
application_preview: applicationPreview,
|
||||
user_input_text: applicationSubmitText,
|
||||
...(applicationEditClaimId
|
||||
? {
|
||||
application_edit_claim_id: applicationEditClaimId,
|
||||
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
|
||||
application_edit_mode: true,
|
||||
draft_claim_id: applicationEditClaimId,
|
||||
selected_claim_id: applicationEditClaimId
|
||||
}
|
||||
: {})
|
||||
}
|
||||
})
|
||||
const draftPayload = payload?.result?.draft_payload || {}
|
||||
const claimNo = String(draftPayload.claim_no || '').trim()
|
||||
const claimId = String(draftPayload.claim_id || '').trim()
|
||||
if (String(payload?.status || '').trim() === 'succeeded' && (claimNo || claimId)) {
|
||||
message.applicationSubmitConfirmed = true
|
||||
emit('draft-saved', {
|
||||
claimId,
|
||||
claimNo,
|
||||
status: 'submitted',
|
||||
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
|
||||
documentType: 'application'
|
||||
})
|
||||
}
|
||||
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
|
||||
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
|
||||
...action,
|
||||
payload: {
|
||||
...(action.payload || {}),
|
||||
applicationPreview,
|
||||
draftPayload
|
||||
}
|
||||
}))
|
||||
if (planningText && planningActions.length) {
|
||||
messages.value.push(createMessage('assistant', planningText, [], {
|
||||
meta: ['行程规划推荐'],
|
||||
suggestedActions: planningActions
|
||||
}))
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
const stewardFollowup = buildStewardContinuationAfterAction({
|
||||
createMessage,
|
||||
message,
|
||||
completedLabel: '申请单已完成'
|
||||
})
|
||||
if (stewardFollowup) {
|
||||
await pushStewardContinuationMessage({
|
||||
finalMessage: stewardFollowup,
|
||||
messages,
|
||||
nextTick,
|
||||
persistSessionState,
|
||||
scrollToBottom
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
closeApplicationSubmitConfirm,
|
||||
confirmApplicationSubmit,
|
||||
handleApplicationSubmitConfirmationText,
|
||||
handleStewardRuntimeDecision,
|
||||
isApplicationSubmitConfirmationText,
|
||||
openApplicationSubmitConfirm,
|
||||
resolveStewardMissingFieldItems
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
normalizeApplicationPreview,
|
||||
normalizeTransportModeOption,
|
||||
resolveApplicationDateRange,
|
||||
shouldRequireApplicationModelReview,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
|
||||
import { fetchOntologyParse } from '../../services/ontology.js'
|
||||
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
||||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||||
@@ -29,11 +29,11 @@ import {
|
||||
handleBudgetCompileReportSubmit,
|
||||
shouldUseBudgetCompileReport
|
||||
} from './budgetAssistantReportModel.js'
|
||||
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
|
||||
|
||||
const STEWARD_ASSISTANT_NAME = '小财管家'
|
||||
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
|
||||
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
|
||||
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
|
||||
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
|
||||
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
||||
|
||||
@@ -44,6 +44,13 @@ const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
|
||||
reason: 'reason',
|
||||
amount: 'amount',
|
||||
transportMode: 'transport_mode',
|
||||
transportEstimatedAmount: 'transport_estimated_amount',
|
||||
trainEstimatedAmount: 'train_estimated_amount',
|
||||
flightEstimatedAmount: 'flight_estimated_amount',
|
||||
hotelAmount: 'hotel_amount',
|
||||
allowanceAmount: 'allowance_amount',
|
||||
policyTotalAmount: 'policy_total_amount',
|
||||
reimbursementAmount: 'reimbursement_amount',
|
||||
department: 'department_name',
|
||||
applicant: 'employee_name',
|
||||
grade: 'employee_grade'
|
||||
@@ -75,6 +82,13 @@ const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
|
||||
reason: 'reason',
|
||||
amount: 'amount',
|
||||
transport_mode: 'transportMode',
|
||||
transport_estimated_amount: 'transportEstimatedAmount',
|
||||
train_estimated_amount: 'trainEstimatedAmount',
|
||||
flight_estimated_amount: 'flightEstimatedAmount',
|
||||
hotel_amount: 'hotelAmount',
|
||||
allowance_amount: 'allowanceAmount',
|
||||
policy_total_amount: 'policyTotalAmount',
|
||||
reimbursement_amount: 'reimbursementAmount',
|
||||
department_name: 'department',
|
||||
employee_name: 'applicant',
|
||||
employee_grade: 'grade'
|
||||
@@ -87,6 +101,13 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
|
||||
reason: '事由',
|
||||
amount: '金额',
|
||||
transport_mode: '出行方式',
|
||||
transport_estimated_amount: '交通费用预估',
|
||||
train_estimated_amount: '火车费用预估',
|
||||
flight_estimated_amount: '飞机费用预估',
|
||||
hotel_amount: '住宿测算金额',
|
||||
allowance_amount: '出差补贴金额',
|
||||
policy_total_amount: '规则测算合计',
|
||||
reimbursement_amount: '实际报销金额',
|
||||
attachments: '附件/凭证',
|
||||
customer_name: '客户或项目对象',
|
||||
merchant_name: '商户/开票方',
|
||||
@@ -97,6 +118,13 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
|
||||
|
||||
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
|
||||
'amount',
|
||||
'transport_estimated_amount',
|
||||
'train_estimated_amount',
|
||||
'flight_estimated_amount',
|
||||
'hotel_amount',
|
||||
'allowance_amount',
|
||||
'policy_total_amount',
|
||||
'reimbursement_amount',
|
||||
'attachments',
|
||||
'employee_no',
|
||||
'department_name',
|
||||
@@ -600,24 +628,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const fields = normalized.fields || {}
|
||||
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
|
||||
if (!missingFields.length) {
|
||||
return fallbackText
|
||||
}
|
||||
|
||||
if (missingFields.includes('出行方式')) {
|
||||
return [
|
||||
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
|
||||
'',
|
||||
'**原因是:还缺少“出行方式”。**',
|
||||
'',
|
||||
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
|
||||
'',
|
||||
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
|
||||
'',
|
||||
@@ -710,13 +725,10 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
]
|
||||
if (missingInfo) {
|
||||
const transportMissing = /出行方式/.test(missingInfo)
|
||||
events.push({
|
||||
eventId: `${eventPrefix}-gap`,
|
||||
title: '判断待补充信息',
|
||||
content: transportMissing
|
||||
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
|
||||
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
|
||||
content: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
|
||||
})
|
||||
} else {
|
||||
events.push({
|
||||
@@ -809,13 +821,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const chars = Array.from(text)
|
||||
for (let index = 0; index < chars.length;) {
|
||||
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
|
||||
index = Math.min(chars.length, index + STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE)
|
||||
index = resolveStewardTypewriterNextIndex(chars, index)
|
||||
message.text = chars.slice(0, index).join('')
|
||||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
||||
if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
Object.assign(message, finalExtras, {
|
||||
@@ -839,13 +849,39 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
function isApplicationDraftPayload(draftPayload) {
|
||||
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
|
||||
}
|
||||
|
||||
function isSubmittedApplicationDraftPayload(draftPayload) {
|
||||
return (
|
||||
String(draftPayload?.draft_type || '').trim() === 'expense_application'
|
||||
isApplicationDraftPayload(draftPayload)
|
||||
&& String(draftPayload?.status || '').trim() === 'submitted'
|
||||
)
|
||||
}
|
||||
|
||||
function shouldExposeReviewPayloadForMessage(payload, options = {}) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
if (options.isApplicationSubmitOperation || isApplicationDraftPayload(result.draft_payload)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function buildPresentationPayload(payload, { exposeReviewPayload = true } = {}) {
|
||||
if (exposeReviewPayload) {
|
||||
return payload
|
||||
}
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
return {
|
||||
...payload,
|
||||
result: {
|
||||
...result,
|
||||
review_payload: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildOperationFeedbackState(context) {
|
||||
if (!context) {
|
||||
return null
|
||||
@@ -1190,12 +1226,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return preview
|
||||
}
|
||||
try {
|
||||
const fields = preview?.fields || {}
|
||||
await waitForMockApplicationTransportQuote({
|
||||
transportMode: fields.transportMode,
|
||||
location: fields.location,
|
||||
time: fields.time
|
||||
})
|
||||
const result = await calculateTravelReimbursement(estimateRequest.payload)
|
||||
return applyApplicationPolicyEstimateResult(preview, result, user)
|
||||
} catch (error) {
|
||||
@@ -1204,7 +1234,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.skipModelReview) {
|
||||
const requireModelReview = shouldRequireApplicationModelReview(rawText)
|
||||
if (options.skipModelReview && !requireModelReview) {
|
||||
return {
|
||||
applicationPreview: await enrichWithPolicyEstimate({
|
||||
...localPreview,
|
||||
@@ -2042,24 +2073,31 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
|
||||
})
|
||||
: null
|
||||
const assistantMessage = createMessage('assistant', resolveAssistantResultText(payload, fallbackAnswer), [], {
|
||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||
? payload.result.suggested_actions
|
||||
: [],
|
||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
||||
draftPayload: payload?.result?.draft_payload || null,
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
const exposeReviewPayload = shouldExposeReviewPayloadForMessage(payload, { isApplicationSubmitOperation })
|
||||
const presentationPayload = buildPresentationPayload(payload, { exposeReviewPayload })
|
||||
const presentationResult = presentationPayload?.result && typeof presentationPayload.result === 'object'
|
||||
? presentationPayload.result
|
||||
: {}
|
||||
const resultReviewPayload = presentationResult.review_payload || null
|
||||
const resultSuggestedActions = exposeReviewPayload && Array.isArray(presentationResult.suggested_actions)
|
||||
? presentationResult.suggested_actions
|
||||
: []
|
||||
const assistantMessage = createMessage('assistant', resolveAssistantResultText(presentationPayload, fallbackAnswer), [], {
|
||||
meta: buildMessageMeta(presentationPayload, effectiveFileNames),
|
||||
citations: Array.isArray(presentationResult.citations) ? presentationResult.citations : [],
|
||||
suggestedActions: resultSuggestedActions,
|
||||
queryPayload: normalizeExpenseQueryPayload(presentationResult.query_payload),
|
||||
draftPayload: presentationResult.draft_payload || null,
|
||||
reviewPayload: resultReviewPayload,
|
||||
reviewPanelScope: stewardDelegated
|
||||
? ''
|
||||
: resolveReviewPanelScope({
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
reviewPayload: resultReviewPayload,
|
||||
reviewAction: reviewActionResult,
|
||||
fileCount: files.length,
|
||||
rawText
|
||||
}),
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
|
||||
riskFlags: Array.isArray(presentationResult.risk_flags) ? presentationResult.risk_flags : [],
|
||||
operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
|
||||
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
@@ -2084,7 +2122,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
} else {
|
||||
replaceMessage(pendingMessage.id, assistantMessage)
|
||||
const nextInsight = buildAgentInsight(
|
||||
payload,
|
||||
presentationPayload,
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
|
||||
447
web/src/views/scripts/useTravelReimbursementSuggestedActions.js
Normal file
447
web/src/views/scripts/useTravelReimbursementSuggestedActions.js
Normal file
@@ -0,0 +1,447 @@
|
||||
import {
|
||||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||
normalizeApplicationPreview,
|
||||
normalizeTransportModeOption
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
mergeComposerPrefill,
|
||||
resolveSuggestedActionPrefill
|
||||
} from '../../utils/assistantSuggestedActionPrefill.js'
|
||||
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
|
||||
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||||
import {
|
||||
TRAVEL_PLANNING_ACTION_GENERATE,
|
||||
TRAVEL_PLANNING_ACTION_SKIP,
|
||||
buildTravelPlanningRecommendation
|
||||
} from '../../utils/travelApplicationPlanning.js'
|
||||
import {
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_BUDGET,
|
||||
canUseBudgetAssistantSession
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
|
||||
import {
|
||||
buildStewardFieldCompletionContinuation,
|
||||
buildStewardFieldCompletionRawText
|
||||
} from './stewardFieldCompletionModel.js'
|
||||
import { MAX_ATTACHMENTS, VISIBLE_ATTACHMENT_CHIPS } from './travelReimbursementAttachmentModel.js'
|
||||
|
||||
export const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
||||
|
||||
export function useTravelReimbursementSuggestedActions({
|
||||
applicationPreviewEditor,
|
||||
attachedFiles,
|
||||
buildExpenseSceneSelectionActions,
|
||||
buildExpenseSceneSelectionMessage,
|
||||
commitApplicationPreviewEditor,
|
||||
composerDraft,
|
||||
composerFilesExpanded,
|
||||
composerTextareaRef,
|
||||
createMessage,
|
||||
currentUser,
|
||||
emit,
|
||||
handleGuidedShortcut,
|
||||
handleGuidedSuggestedAction,
|
||||
handleSceneSelectionApplicationGate,
|
||||
lockSuggestedActionMessage,
|
||||
mergeFilesWithLimit,
|
||||
messages,
|
||||
nextTick,
|
||||
openApplicationPreviewEditor,
|
||||
persistSessionState,
|
||||
resolveApplicationPreviewMissingFields,
|
||||
reviewActionBusy,
|
||||
router,
|
||||
scrollToBottom,
|
||||
sessionSwitchBusy,
|
||||
startExpenseSceneSelectionAfterIntentConfirmation,
|
||||
submitComposer,
|
||||
submitComposerInternal,
|
||||
submitting,
|
||||
switchSessionType,
|
||||
toast,
|
||||
adjustComposerTextareaHeight
|
||||
}) {
|
||||
async function runShortcut(shortcut) {
|
||||
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
|
||||
if (shortcut.targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
|
||||
toast('目前暂无权限访问预算编制助手')
|
||||
return
|
||||
}
|
||||
if (shortcut.active) {
|
||||
return
|
||||
}
|
||||
await switchSessionType(shortcut.targetSessionType)
|
||||
return
|
||||
}
|
||||
if (await handleGuidedShortcut(shortcut)) {
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = String(shortcut?.prompt || '').trim()
|
||||
if (!prompt) return
|
||||
composerDraft.value = prompt
|
||||
submitComposer()
|
||||
}
|
||||
|
||||
function isSuggestedActionSelected(message, action) {
|
||||
const selectedKey = String(message?.selectedSuggestedActionKey || '').trim()
|
||||
return Boolean(selectedKey) && selectedKey === buildSuggestedActionKey(action)
|
||||
}
|
||||
|
||||
function buildApplicationPreviewFieldAppliedText(message, fieldLabel = '', value = '') {
|
||||
const missingFields = resolveApplicationPreviewMissingFields(message)
|
||||
const resolvedFieldLabel = String(fieldLabel || '补充项').trim()
|
||||
const resolvedValue = String(value || '').trim()
|
||||
if (missingFields.length) {
|
||||
return [
|
||||
`已更新:**${resolvedFieldLabel}:${resolvedValue}**。`,
|
||||
'',
|
||||
`我重新检查了一遍,当前还需要补充:**${missingFields.join('、')}**。`,
|
||||
'',
|
||||
'请继续补齐下方核对表里的待补充项;补齐后我再继续推进申请提交。'
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`已更新:**${resolvedFieldLabel}:${resolvedValue}**。`,
|
||||
'',
|
||||
'我已经重新同步下方申请核对表和费用测算。',
|
||||
'',
|
||||
'请继续核查表格内容;如果信息无误,点击确认进入审批环节。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function isStewardApplicationPreviewFieldCompletion(targetMessage, payload = {}) {
|
||||
return Boolean(
|
||||
payload.steward_delegated_field_completion ||
|
||||
String(targetMessage?.assistantName || '').trim() === STEWARD_ASSISTANT_NAME ||
|
||||
targetMessage?.stewardPlan
|
||||
)
|
||||
}
|
||||
|
||||
async function continueStewardApplicationFieldCompletion({
|
||||
targetMessage,
|
||||
action,
|
||||
sourcePreview,
|
||||
fieldKey,
|
||||
fieldLabel,
|
||||
value
|
||||
}) {
|
||||
if (!lockSuggestedActionMessage(targetMessage, action)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const continuation = buildStewardFieldCompletionContinuation(
|
||||
targetMessage?.stewardContinuation || null,
|
||||
fieldKey,
|
||||
value
|
||||
)
|
||||
const userText = `选择${fieldLabel || '补充项'}:${value}`
|
||||
const carryText = buildStewardFieldCompletionRawText({
|
||||
preview: sourcePreview,
|
||||
fieldKey,
|
||||
fieldLabel,
|
||||
value,
|
||||
continuation
|
||||
})
|
||||
|
||||
if (!action?.suppressUserEcho) {
|
||||
messages.value.push(createMessage('user', userText))
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
await submitComposerInternal({
|
||||
rawText: carryText,
|
||||
userText,
|
||||
pendingText: '小财管家正在根据补齐信息查询票据并测算费用...',
|
||||
files: [],
|
||||
skipScopeGuard: true,
|
||||
skipApplicationModelReview: true,
|
||||
skipStewardPlan: true,
|
||||
skipUserMessage: true,
|
||||
sessionTypeOverride: SESSION_TYPE_APPLICATION,
|
||||
stewardContinuation: continuation
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
async function applyApplicationPreviewFieldAction(message, action) {
|
||||
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const fieldKey = String(payload.field_key || payload.fieldKey || '').trim()
|
||||
const fieldLabel = String(payload.field_label || payload.fieldLabel || action?.label || '').trim()
|
||||
let value = String(payload.value || action?.label || '').trim()
|
||||
const targetMessage = messages.value.find((item) => String(item.id || '') === String(message?.id || '')) || message
|
||||
const sourcePreview = targetMessage?.applicationPreview ||
|
||||
payload.applicationPreview ||
|
||||
payload.application_preview ||
|
||||
payload.preview ||
|
||||
null
|
||||
if (!sourcePreview || !fieldKey || !value) {
|
||||
return false
|
||||
}
|
||||
if (fieldKey === 'transportMode') {
|
||||
value = normalizeTransportModeOption(value, '')
|
||||
}
|
||||
if (fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(value)) {
|
||||
toast('请选择有效的出行方式。')
|
||||
return true
|
||||
}
|
||||
if (isStewardApplicationPreviewFieldCompletion(targetMessage, payload)) {
|
||||
return continueStewardApplicationFieldCompletion({
|
||||
targetMessage,
|
||||
action,
|
||||
sourcePreview,
|
||||
fieldKey,
|
||||
fieldLabel,
|
||||
value
|
||||
})
|
||||
}
|
||||
if (!lockSuggestedActionMessage(targetMessage, action)) {
|
||||
return true
|
||||
}
|
||||
|
||||
targetMessage.applicationPreview = normalizeApplicationPreview(sourcePreview)
|
||||
messages.value.push(createMessage('user', `选择${fieldLabel || '补充项'}:${value}`))
|
||||
openApplicationPreviewEditor(targetMessage, fieldKey, targetMessage.applicationPreview?.fields?.[fieldKey] || '')
|
||||
applicationPreviewEditor.value = {
|
||||
...applicationPreviewEditor.value,
|
||||
draftValue: value
|
||||
}
|
||||
await commitApplicationPreviewEditor(targetMessage)
|
||||
if (String(targetMessage.assistantName || '').trim() === STEWARD_ASSISTANT_NAME || targetMessage.stewardPlan) {
|
||||
targetMessage.assistantName = STEWARD_ASSISTANT_NAME
|
||||
targetMessage.text = buildApplicationPreviewFieldAppliedText(targetMessage, fieldLabel, value)
|
||||
const nextMeta = Array.isArray(targetMessage.meta) ? targetMessage.meta : []
|
||||
targetMessage.meta = Array.from(new Set([
|
||||
STEWARD_ASSISTANT_NAME,
|
||||
resolveApplicationPreviewMissingFields(targetMessage).length ? '等待补充' : '等待用户确认',
|
||||
...nextMeta.filter((item) => String(item || '').trim() && item !== STEWARD_ASSISTANT_NAME)
|
||||
])).slice(0, 4)
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
return true
|
||||
}
|
||||
|
||||
function pushExpenseSceneSelectionPrompt(originalMessage) {
|
||||
const sourceText = String(originalMessage || '').trim()
|
||||
if (!sourceText) {
|
||||
return
|
||||
}
|
||||
|
||||
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
|
||||
messages.value.push(createMessage('user', '我要报销'))
|
||||
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), [], {
|
||||
meta: ['等待选择场景'],
|
||||
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
function applySuggestedActionPrefill(action) {
|
||||
const prefillText = resolveSuggestedActionPrefill(action)
|
||||
if (!prefillText) {
|
||||
return false
|
||||
}
|
||||
|
||||
composerDraft.value = mergeComposerPrefill(composerDraft.value, prefillText)
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
composerTextareaRef.value?.focus()
|
||||
})
|
||||
persistSessionState()
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleSuggestedAction(message, action) {
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
||||
if (message?.suggestedActionsLocked) return
|
||||
if (applySuggestedActionPrefill(action)) return
|
||||
if (await handleGuidedSuggestedAction(message, action)) return
|
||||
if (await handleSceneSelectionApplicationGate(message, action)) return
|
||||
|
||||
if (actionType === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
|
||||
await applyApplicationPreviewFieldAction(message, action)
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'open_application_detail') {
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
|
||||
if (!claimId) {
|
||||
toast('当前没有可查看的申请单据。')
|
||||
return
|
||||
}
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
await router.push({
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'open_receipt_folder') {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
await router.push({ name: 'app-receiptFolder' })
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'continue_upload_with_unlinked_receipts') {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
await submitComposer({
|
||||
rawText: String(actionPayload.raw_text || composerDraft.value || '').trim(),
|
||||
files: Array.from(attachedFiles.value || []),
|
||||
skipReceiptFolderUnlinkedPrompt: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
|
||||
const sourceDraftPayload = action?.payload?.draftPayload || action?.payload?.draft_payload || null
|
||||
const recommendation = buildTravelPlanningRecommendation(sourcePreview, sourceDraftPayload)
|
||||
if (recommendation) {
|
||||
messages.value.push(createMessage('user', '生成行程规划'))
|
||||
messages.value.push(createMessage('assistant', recommendation, [], {
|
||||
meta: ['行程规划建议']
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
persistSessionState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === TRAVEL_PLANNING_ACTION_SKIP) {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
messages.value.push(createMessage('assistant', '好的,本次先保留申请结果。后续需要规划交通或酒店时,可以继续在这里告诉我。', [], {
|
||||
meta: ['暂不规划']
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
persistSessionState()
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const targetSessionType = String(actionPayload.session_type || '').trim()
|
||||
if (!targetSessionType) return
|
||||
if (targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
|
||||
toast('目前暂无权限访问预算编制助手')
|
||||
return
|
||||
}
|
||||
const carryText = String(actionPayload.carry_text || '').trim()
|
||||
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
if (String(actionPayload.steward_plan_id || '').trim()) {
|
||||
const confirmedByText = Boolean(action.confirmedByText)
|
||||
delete action.confirmedByText
|
||||
await submitComposerInternal({
|
||||
rawText: carryText,
|
||||
userText: action.label || '确定',
|
||||
pendingText: targetSessionType === SESSION_TYPE_APPLICATION
|
||||
? '小财管家正在调用申请助手生成申请单核对结果...'
|
||||
: '小财管家正在调用报销助手整理报销核对结果...',
|
||||
files: carryFiles,
|
||||
skipScopeGuard: true,
|
||||
skipApplicationModelReview: targetSessionType === SESSION_TYPE_APPLICATION,
|
||||
skipStewardSlotDecision: targetSessionType === SESSION_TYPE_APPLICATION,
|
||||
skipStewardPlan: true,
|
||||
skipUserMessage: confirmedByText,
|
||||
sessionTypeOverride: targetSessionType,
|
||||
stewardContinuation: {
|
||||
planId: String(actionPayload.steward_plan_id || '').trim(),
|
||||
currentTaskId: String(actionPayload.steward_next_task_id || '').trim(),
|
||||
currentTask: actionPayload.steward_current_task || null,
|
||||
remainingTasks: Array.isArray(actionPayload.steward_remaining_tasks)
|
||||
? actionPayload.steward_remaining_tasks
|
||||
: []
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
await switchSessionType(targetSessionType)
|
||||
if (carryText) {
|
||||
composerDraft.value = carryText
|
||||
}
|
||||
if (carryFiles.length) {
|
||||
const fileMergeResult = mergeFilesWithLimit([], carryFiles, MAX_ATTACHMENTS)
|
||||
attachedFiles.value = fileMergeResult.files
|
||||
composerFilesExpanded.value = fileMergeResult.files.length > VISIBLE_ATTACHMENT_CHIPS
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
persistSessionState()
|
||||
if (actionPayload.auto_submit && carryText) {
|
||||
await submitComposer({
|
||||
rawText: carryText,
|
||||
userText: action.label || '确认继续处理',
|
||||
pendingText: '正在按确认内容继续处理...',
|
||||
files: carryFiles,
|
||||
skipScopeGuard: true
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'confirm_expense_intent') {
|
||||
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
|
||||
if (!originalMessage) return
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
pushExpenseSceneSelectionPrompt(originalMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType !== 'select_expense_type') {
|
||||
const fallbackText = String(action?.description || action?.label || '').trim()
|
||||
if (!fallbackText) return
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
await submitComposer({
|
||||
rawText: fallbackText,
|
||||
userText: fallbackText,
|
||||
pendingText: '正在继续处理...'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const expenseType = String(actionPayload.expense_type || '').trim()
|
||||
const expenseTypeLabel = String(actionPayload.expense_type_label || action?.label || '').trim()
|
||||
const originalMessage = String(actionPayload.original_message || message?.text || '').trim()
|
||||
if (!expenseTypeLabel || !originalMessage) return
|
||||
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
await submitComposer({
|
||||
rawText: `${originalMessage}\n用户选择报销场景:${expenseTypeLabel}`,
|
||||
userText: `选择${expenseTypeLabel}`,
|
||||
pendingText: `已选择${expenseTypeLabel},正在按该场景识别...`,
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
draft_claim_id: '',
|
||||
user_input_text: originalMessage,
|
||||
expense_scene_selection: {
|
||||
expense_type: expenseType,
|
||||
expense_type_label: expenseTypeLabel,
|
||||
original_message: originalMessage
|
||||
},
|
||||
review_form_values: {
|
||||
expense_type: expenseTypeLabel
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
handleSuggestedAction,
|
||||
isSuggestedActionSelected,
|
||||
runShortcut
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
normalizeTransportModeOption,
|
||||
resolveApplicationDateRange,
|
||||
resolveApplicationTimeLabel,
|
||||
shouldRequireApplicationModelReview,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../src/utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
@@ -50,6 +51,14 @@ import {
|
||||
import {
|
||||
shouldUseBudgetCompileReport
|
||||
} from '../src/views/scripts/budgetAssistantReportModel.js'
|
||||
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
|
||||
import {
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION,
|
||||
ASSISTANT_SCOPE_SESSION_EXPENSE,
|
||||
ASSISTANT_SCOPE_SESSION_STEWARD,
|
||||
inferAssistantScopeTarget,
|
||||
resolveAssistantScopeGuard
|
||||
} from '../src/utils/assistantSessionScope.js'
|
||||
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
|
||||
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
|
||||
|
||||
@@ -65,6 +74,26 @@ const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const messageActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementMessageActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const suggestedActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardRuntimeScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardRuntimeTextModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementStewardRuntimeTextModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardFollowupFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementStewardFollowupFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardPlanFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useStewardPlanFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -131,7 +160,7 @@ function createFlowHarness() {
|
||||
}
|
||||
|
||||
test('application intent uses local preview instead of immediate orchestrator call', () => {
|
||||
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目,出差3天,高铁,预计金额2358元'
|
||||
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目,出差4天,高铁,预计金额2358元'
|
||||
assert.equal(
|
||||
shouldUseLocalApplicationPreview(prompt, {
|
||||
sessionType: 'application',
|
||||
@@ -150,6 +179,33 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
}),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
shouldUseLocalApplicationPreview('小财管家\n23:04\n这是费用申请核对结果,请核对:', {
|
||||
sessionType: 'application',
|
||||
attachmentCount: 0,
|
||||
reviewAction: '',
|
||||
systemGenerated: false
|
||||
}),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
shouldUseLocalApplicationPreview('我要申请', {
|
||||
sessionType: 'application',
|
||||
attachmentCount: 0,
|
||||
reviewAction: '',
|
||||
systemGenerated: false
|
||||
}),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
shouldUseLocalApplicationPreview('去上海出差,支撑国网仿生产环境部署', {
|
||||
sessionType: 'application',
|
||||
attachmentCount: 0,
|
||||
reviewAction: '',
|
||||
systemGenerated: false
|
||||
}),
|
||||
true
|
||||
)
|
||||
|
||||
const preview = buildLocalApplicationPreview(prompt, {
|
||||
name: '李文静',
|
||||
@@ -161,7 +217,7 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
assert.equal(preview.fields.applicationType, '差旅费用申请')
|
||||
assert.equal(preview.fields.time, '2026-05-20 至 2026-05-23')
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.fields.days, '3天')
|
||||
assert.equal(preview.fields.days, '4天')
|
||||
assert.equal(preview.fields.transportMode, '火车')
|
||||
assert.equal(preview.fields.amount, '2358元')
|
||||
assert.equal(preview.fields.applicant, '李文静')
|
||||
@@ -175,6 +231,33 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
|
||||
})
|
||||
|
||||
test('assistant scope guard blocks unsupported non-financial intent', () => {
|
||||
const guard = resolveAssistantScopeGuard('帮我写一首诗,主题是春天', ASSISTANT_SCOPE_SESSION_APPLICATION)
|
||||
|
||||
assert.equal(guard.blocked, true)
|
||||
assert.equal(guard.targetSessionType, '')
|
||||
assert.match(guard.text, /此意图系统不支持/)
|
||||
assert.match(guard.text, /当前系统支持的业务范围/)
|
||||
assert.deepEqual(guard.suggestedActions, [])
|
||||
})
|
||||
|
||||
test('assistant scope guard routes related business intent instead of blocking', () => {
|
||||
const guard = resolveAssistantScopeGuard('帮我查一下报销单状态', ASSISTANT_SCOPE_SESSION_APPLICATION)
|
||||
|
||||
assert.equal(guard.blocked, undefined)
|
||||
assert.equal(guard.targetSessionType, ASSISTANT_SCOPE_SESSION_EXPENSE)
|
||||
assert.match(guard.text, /报销助手/)
|
||||
assert.equal(guard.suggestedActions[0].payload.session_type, ASSISTANT_SCOPE_SESSION_EXPENSE)
|
||||
})
|
||||
|
||||
test('assistant scope guard keeps current supported application intent and steward finance queries', () => {
|
||||
assert.equal(
|
||||
resolveAssistantScopeGuard('申请下周去上海出差,支撑服务器部署', ASSISTANT_SCOPE_SESSION_APPLICATION),
|
||||
null
|
||||
)
|
||||
assert.equal(inferAssistantScopeTarget('查询一下预算余额'), ASSISTANT_SCOPE_SESSION_STEWARD)
|
||||
})
|
||||
|
||||
test('travel application submit can continue with conversational planning recommendation', () => {
|
||||
const preview = normalizeApplicationPreview({
|
||||
fields: {
|
||||
@@ -273,12 +356,12 @@ test('application estimate builds deterministic mock transport amount and total'
|
||||
assert.equal(trainEstimate.amountDisplay, '1,040')
|
||||
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
|
||||
assert.equal(datedTrainEstimate.amountDisplay, '1,100')
|
||||
assert.equal(datedTrainEstimate.source, 'mock_ticket_price_query_v1')
|
||||
assert.equal(datedTrainEstimate.source, 'fallback_transport_budget_estimate_v1')
|
||||
assert.equal(datedTrainEstimate.basisText, '预估交通费用 1,100元')
|
||||
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
|
||||
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
|
||||
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
|
||||
assert.equal(flightEstimate.amountDisplay, '3,600')
|
||||
assert.equal(flightEstimate.amountDisplay, '3,200')
|
||||
assert.equal(shipEstimate.amountDisplay, '1,040')
|
||||
assert.equal(totalEstimate.transportAmountDisplay, '1,040')
|
||||
assert.equal(totalEstimate.totalAmountDisplay, '3,200')
|
||||
@@ -323,6 +406,168 @@ test('application preview uses selected date range and business-specific time la
|
||||
assert.doesNotMatch(submitText, /发生时间:/)
|
||||
})
|
||||
|
||||
test('application preview parses same-month shorthand date range', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'我要申请2月20日-23日去上海出差,辅助国网仿生产项目部署',
|
||||
{
|
||||
name: '曹笑竹',
|
||||
departmentName: '技术部',
|
||||
position: '财务智能化产品经理',
|
||||
managerName: '向万红',
|
||||
grade: 'P5'
|
||||
},
|
||||
{ today: '2026-06-09' }
|
||||
)
|
||||
const rows = buildApplicationPreviewRows(preview)
|
||||
|
||||
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
|
||||
assert.equal(preview.fields.days, '4天')
|
||||
assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20')
|
||||
assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23')
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.fields.reason, '辅助国网仿生产项目部署')
|
||||
assert.doesNotMatch(preview.fields.reason, /小财管家继续执行/)
|
||||
})
|
||||
|
||||
test('application preview blocks submit when date range conflicts with explicit days', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'申请2月20-23日去上海出差3天,辅助国网仿生产服务器部署,火车',
|
||||
{
|
||||
name: '曹笑竹',
|
||||
departmentName: '技术部',
|
||||
position: '财务智能化产品经理',
|
||||
managerName: '向万红',
|
||||
grade: 'P5'
|
||||
},
|
||||
{ today: '2026-06-09' }
|
||||
)
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const footer = buildApplicationPreviewFooterMessage(normalized)
|
||||
|
||||
assert.equal(normalized.fields.time, '2026-02-20 至 2026-02-23')
|
||||
assert.equal(normalized.fields.days, '3天')
|
||||
assert.equal(normalized.readyToSubmit, false)
|
||||
assert.equal(normalized.validationIssues[0].code, 'time_days_conflict')
|
||||
assert.match(footer, /按自然日为 4 天/)
|
||||
assert.match(footer, /填写的是 3 天/)
|
||||
})
|
||||
|
||||
test('application preview blocks submit when location candidates conflict', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'申请2月20-23日去北京出差4天,地点:上海,辅助国网仿生产服务器部署,火车',
|
||||
{
|
||||
name: '曹笑竹',
|
||||
departmentName: '技术部',
|
||||
position: '财务智能化产品经理',
|
||||
managerName: '向万红',
|
||||
grade: 'P5'
|
||||
},
|
||||
{ today: '2026-06-09' }
|
||||
)
|
||||
const footer = buildApplicationPreviewFooterMessage(preview)
|
||||
|
||||
assert.equal(preview.readyToSubmit, false)
|
||||
assert.equal(preview.validationIssues[0].code, 'location_candidates_conflict')
|
||||
assert.match(footer, /同时出现多个地点/)
|
||||
assert.match(footer, /北京/)
|
||||
assert.match(footer, /上海/)
|
||||
})
|
||||
|
||||
test('application preview does not treat application type labels as locations', () => {
|
||||
const preview = normalizeApplicationPreview({
|
||||
sourceText: [
|
||||
'费用申请出差',
|
||||
'任务摘要:交通方式和出差预算待补充',
|
||||
'申请类型:差旅费用申请',
|
||||
'地点:上海',
|
||||
'申请2月20日-23日火车去上海出差,服务国网仿生产服务器部署'
|
||||
].join('\n'),
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
time: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '服务国网仿生产服务器部署',
|
||||
days: '4天',
|
||||
transportMode: '火车',
|
||||
amount: '2120元',
|
||||
grade: 'P5',
|
||||
applicant: '曹笑竹',
|
||||
department: '技术部',
|
||||
position: '产品经理',
|
||||
managerName: '向万红'
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.deepEqual(preview.validationIssues, [])
|
||||
assert.doesNotMatch(buildApplicationPreviewFooterMessage(preview), /多个地点|费用申请/)
|
||||
})
|
||||
|
||||
test('application preview trusts model-refined fields over noisy source candidates', () => {
|
||||
const preview = normalizeApplicationPreview({
|
||||
sourceText: [
|
||||
'任务摘要:交通方式和出差预算待补充',
|
||||
'申请2月20日-23日火车去上海出差,服务国网仿生产服务器部署'
|
||||
].join('\n'),
|
||||
modelRefined: true,
|
||||
modelReviewStatus: 'completed',
|
||||
parseStrategy: 'llm_primary',
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
time: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '服务国网仿生产服务器部署',
|
||||
days: '4天',
|
||||
transportMode: '火车',
|
||||
amount: '2120元',
|
||||
grade: 'P5',
|
||||
applicant: '曹笑竹',
|
||||
department: '技术部',
|
||||
position: '产品经理',
|
||||
managerName: '向万红'
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.deepEqual(preview.validationIssues, [])
|
||||
})
|
||||
|
||||
test('application preview blocks submit when transport candidates conflict', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'申请2月20-23日去上海出差4天,辅助国网仿生产服务器部署,出行方式:飞机,坐火车',
|
||||
{
|
||||
name: '曹笑竹',
|
||||
departmentName: '技术部',
|
||||
position: '财务智能化产品经理',
|
||||
managerName: '向万红',
|
||||
grade: 'P5'
|
||||
},
|
||||
{ today: '2026-06-09' }
|
||||
)
|
||||
|
||||
assert.equal(preview.readyToSubmit, false)
|
||||
assert.equal(preview.validationIssues[0].code, 'transport_candidates_conflict')
|
||||
assert.match(buildApplicationPreviewFooterMessage(preview), /同时出现多个出行方式/)
|
||||
})
|
||||
|
||||
test('application preview normalizes compact amount candidates', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'申请2月20-23日去上海出差4天,辅助国网仿生产服务器部署,火车,预计费用1.8k',
|
||||
{
|
||||
name: '曹笑竹',
|
||||
departmentName: '技术部',
|
||||
position: '财务智能化产品经理',
|
||||
managerName: '向万红',
|
||||
grade: 'P5'
|
||||
},
|
||||
{ today: '2026-06-09' }
|
||||
)
|
||||
|
||||
assert.equal(preview.fields.amount, '1800元')
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.deepEqual(preview.validationIssues, [])
|
||||
})
|
||||
|
||||
test('application preview keeps labeled reason in structured travel form', () => {
|
||||
const preview = buildLocalApplicationPreview([
|
||||
'发生时间:2026-02-20 至 2026-02-23',
|
||||
@@ -392,7 +637,80 @@ test('application preview can be refined by ontology model extraction', () => {
|
||||
assert.equal(refinedPreview.fields.transportMode, '火车')
|
||||
})
|
||||
|
||||
test('application preview ignores model-only transport mode guesses', () => {
|
||||
test('application preview preserves ontology amount roles for travel estimates', () => {
|
||||
const rawText = '申请2月20日-23日火车去上海出差,服务国网仿生产服务器部署'
|
||||
const localPreview = buildLocalApplicationPreview(rawText, { name: '曹笑竹', grade: 'P5' }, { today: '2026-06-13' })
|
||||
const refinedPreview = buildModelRefinedApplicationPreview(
|
||||
localPreview,
|
||||
{
|
||||
parse_strategy: 'llm_primary',
|
||||
entities: [
|
||||
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
|
||||
{ type: 'location', value: '上海', normalized_value: '上海' },
|
||||
{ type: 'reason', value: '服务国网仿生产服务器部署', normalized_value: '服务国网仿生产服务器部署' },
|
||||
{ type: 'transport_mode', value: '火车', normalized_value: '火车' },
|
||||
{ type: 'transport_estimated_amount', value: '720元', normalized_value: '720' },
|
||||
{ type: 'hotel_amount', value: '1000元', normalized_value: '1000' },
|
||||
{ type: 'allowance_amount', value: '400元', normalized_value: '400' },
|
||||
{ type: 'policy_total_amount', value: '2120元', normalized_value: '2120' }
|
||||
],
|
||||
time_range: {
|
||||
start_date: '2026-02-20',
|
||||
end_date: '2026-02-23'
|
||||
},
|
||||
missing_slots: []
|
||||
},
|
||||
rawText,
|
||||
{ name: '曹笑竹', grade: 'P5' }
|
||||
)
|
||||
|
||||
assert.equal(refinedPreview.fields.amount, '2120元')
|
||||
assert.equal(refinedPreview.fields.transportEstimatedAmount, '720元')
|
||||
assert.equal(refinedPreview.fields.hotelAmount, '1000元')
|
||||
assert.equal(refinedPreview.fields.allowanceAmount, '400元')
|
||||
assert.equal(refinedPreview.fields.policyTotalAmount, '2120元')
|
||||
})
|
||||
|
||||
test('application preview ignores model reason polluted by application type', () => {
|
||||
const rawText = '我申请2月20日至23日去上海出差,辅助国网方法生产服务器上线部署,'
|
||||
const localPreview = buildLocalApplicationPreview(rawText, {
|
||||
name: '曹笑竹',
|
||||
grade: 'P5'
|
||||
}, {
|
||||
today: '2026-06-13'
|
||||
})
|
||||
const refinedPreview = buildModelRefinedApplicationPreview(
|
||||
localPreview,
|
||||
{
|
||||
parse_strategy: 'llm_primary',
|
||||
entities: [
|
||||
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
|
||||
{ type: 'location', value: '上海', normalized_value: '上海' },
|
||||
{ type: 'reason', value: '类型:差旅费用申请', normalized_value: '类型:差旅费用申请' }
|
||||
],
|
||||
missing_slots: []
|
||||
},
|
||||
rawText,
|
||||
{ name: '曹笑竹', grade: 'P5' }
|
||||
)
|
||||
|
||||
assert.equal(localPreview.fields.reason, '辅助国网方法生产服务器上线部署')
|
||||
assert.equal(refinedPreview.fields.reason, '辅助国网方法生产服务器上线部署')
|
||||
assert.doesNotMatch(refinedPreview.fields.reason, /类型|差旅费用申请/)
|
||||
})
|
||||
|
||||
test('application preview strips internal steward instruction from reason', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'申请2月20-23日去上海出差,事由:辅助国网仿生产服务器部署请直接生成申请单核对结果,信息足够时生成申请单,但在入库或提交审批前仍需让我确认',
|
||||
{ name: '曹笑竹', grade: 'P5' },
|
||||
{ today: '2026-06-09' }
|
||||
)
|
||||
|
||||
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
|
||||
assert.doesNotMatch(preview.fields.reason, /请直接生成|入库|提交审批/)
|
||||
})
|
||||
|
||||
test('application preview requires explicit transport mode before submit', () => {
|
||||
const rawText = '\u7533\u8bf7 2026-05-25 \u81f3 2026-05-27 \u53bb\u4e0a\u6d77\u51fa\u5dee3\u5929\uff0c\u670d\u52a1\u9879\u76ee\u90e8\u7f72\uff0c\u9884\u8ba1\u8d39\u75281800\u5143'
|
||||
const localPreview = buildLocalApplicationPreview(rawText, {
|
||||
name: '\u674e\u6587\u9759',
|
||||
@@ -421,10 +739,25 @@ test('application preview ignores model-only transport mode guesses', () => {
|
||||
|
||||
assert.equal(localPreview.fields.transportMode, '')
|
||||
assert.equal(refinedPreview.fields.transportMode, '')
|
||||
assert.ok(refinedPreview.missingFields.includes('\u51fa\u884c\u65b9\u5f0f'))
|
||||
assert.equal(refinedPreview.missingFields.includes('\u51fa\u884c\u65b9\u5f0f'), true)
|
||||
assert.equal(refinedPreview.readyToSubmit, false)
|
||||
})
|
||||
|
||||
test('application preview does not treat transport prompt options as selected mode', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'当前还需要补充:出行方式。请先补充出行方式,可以选择火车、飞机或轮船。',
|
||||
{ name: '李文静', grade: 'P5' }
|
||||
)
|
||||
const mixedPreview = buildLocalApplicationPreview(
|
||||
'任务摘要:交通方式和出差预算待补充\n申请2月20日-23日火车去上海出差',
|
||||
{ name: '李文静', grade: 'P5' },
|
||||
{ today: '2026-06-09' }
|
||||
)
|
||||
|
||||
assert.equal(preview.fields.transportMode, '')
|
||||
assert.equal(mixedPreview.fields.transportMode, '火车')
|
||||
})
|
||||
|
||||
test('application preview precomputes a date range from today when only days are provided', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'去北京出差3天,支撑国网仿生产环境部署,飞机,预计费用12000元',
|
||||
@@ -438,7 +771,7 @@ test('application preview precomputes a date range from today when only days are
|
||||
})
|
||||
|
||||
test('application preview keeps rule fallback distinct from model reviewed result', () => {
|
||||
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署,出差3天,火车,预计费用1800元'
|
||||
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署,出差4天,火车,预计费用1800元'
|
||||
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
|
||||
const fallbackPreview = buildModelRefinedApplicationPreview(
|
||||
localPreview,
|
||||
@@ -545,11 +878,11 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
|
||||
assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/)
|
||||
assert.match(createViewScript, /useApplicationPreviewEditor/)
|
||||
assert.match(createViewScript, /message-bubble-application-preview/)
|
||||
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
|
||||
assert.match(createViewScript, /function buildApplicationPreviewFooterText\(message\)/)
|
||||
assert.match(createViewScript, /buildApplicationPreviewSubmitText/)
|
||||
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
|
||||
assert.match(messageActionsScript, /message-bubble-application-preview/)
|
||||
assert.match(messageActionsScript, /buildApplicationPreviewFooterMessage/)
|
||||
assert.match(messageActionsScript, /function buildApplicationPreviewFooterText\(message\)/)
|
||||
assert.match(stewardRuntimeScript, /buildApplicationPreviewSubmitText/)
|
||||
assert.match(stewardRuntimeScript, /user_input_text: applicationSubmitText/)
|
||||
assert.match(conversationModelScript, /applicationPreview: null/)
|
||||
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
|
||||
assert.match(conversationModelScript, /\|\| message\.applicationPreview/)
|
||||
@@ -564,7 +897,7 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
|
||||
assert.match(messageItemTemplate, /application-preview-date-chip/)
|
||||
assert.doesNotMatch(messageItemTemplate, /application-preview-date-chip/)
|
||||
assert.match(messageItemTemplate, /申请单据已生成/)
|
||||
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
|
||||
assert.match(messageItemTemplate, /报销草稿已生成/)
|
||||
@@ -575,9 +908,9 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /查看详情/)
|
||||
assert.match(messageItemTemplate, /class="reimbursement-draft-pending-detail"/)
|
||||
assert.match(messageItemTemplate, /保存后可查看详情/)
|
||||
assert.match(createViewScript, /function canOpenDraftDetail\(message\)/)
|
||||
assert.match(messageActionsScript, /function canOpenDraftDetail\(message\)/)
|
||||
assert.match(createViewScript, /canOpenDraftDetail,/)
|
||||
assert.match(createViewScript, /保存后生成/)
|
||||
assert.match(messageActionsScript, /保存后生成/)
|
||||
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
|
||||
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
|
||||
assert.ok(
|
||||
@@ -619,12 +952,12 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
|
||||
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
|
||||
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
|
||||
assert.match(createViewScript, /function shouldShowAssistantMessageActions/)
|
||||
assert.match(createViewScript, /function buildMessageOperationFeedbackContext/)
|
||||
assert.match(createViewScript, /function isMessageFeedbackSelected/)
|
||||
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
|
||||
assert.match(createViewScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
|
||||
assert.match(createViewScript, /stewardContinuation:\s*stewardSubmitContinuation/)
|
||||
assert.match(messageActionsScript, /function shouldShowAssistantMessageActions/)
|
||||
assert.match(messageActionsScript, /function buildMessageOperationFeedbackContext/)
|
||||
assert.match(messageActionsScript, /function isMessageFeedbackSelected/)
|
||||
assert.match(messageActionsScript, /function submitOperationFeedbackForMessage/)
|
||||
assert.match(stewardRuntimeScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
|
||||
assert.match(stewardRuntimeScript, /stewardContinuation:\s*stewardSubmitContinuation/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
|
||||
@@ -675,26 +1008,26 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(flowScript, /refreshCompleted/)
|
||||
})
|
||||
|
||||
test('steward application missing transport asks before rendering preview table', () => {
|
||||
test('steward application missing transport blocks preview table', () => {
|
||||
assert.match(submitComposerScript, /function shouldPauseStewardApplicationPreview/)
|
||||
assert.match(submitComposerScript, /function sanitizeStewardDelegatedTaskSummary/)
|
||||
assert.match(submitComposerScript, /交通方式和\(\?:预算\|预计\)\?金额待补充/)
|
||||
assert.match(submitComposerScript, /出差费用预算/)
|
||||
assert.match(submitComposerScript, /预估\|预计\|预算\)\?费用/)
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
|
||||
assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
|
||||
assert.match(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
assert.doesNotMatch(submitComposerScript, /缺少“出行方式”[\s\S]{0,500}更新下方核对表/)
|
||||
assert.doesNotMatch(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
|
||||
assert.match(createViewScript, /payload\.applicationPreview/)
|
||||
assert.match(createViewScript, /function continueStewardApplicationFieldCompletion/)
|
||||
assert.match(createViewScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
|
||||
assert.match(createViewScript, /skipUserMessage:\s*true/)
|
||||
assert.match(createViewScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
|
||||
assert.match(createViewScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
|
||||
assert.match(createViewScript, /commitApplicationPreviewEditor\(targetMessage\)/)
|
||||
assert.match(suggestedActionsScript, /payload\.applicationPreview/)
|
||||
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)
|
||||
assert.match(suggestedActionsScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
|
||||
assert.match(suggestedActionsScript, /skipUserMessage:\s*true/)
|
||||
assert.match(suggestedActionsScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
|
||||
assert.match(suggestedActionsScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
|
||||
assert.match(suggestedActionsScript, /commitApplicationPreviewEditor\(targetMessage\)/)
|
||||
assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/)
|
||||
assert.match(stewardFieldCompletionScript, /模拟查询交通票据/)
|
||||
assert.match(stewardFieldCompletionScript, /基础规则交通费用预估表/)
|
||||
})
|
||||
|
||||
test('steward field completion reruns application preview instead of directly rendering table', () => {
|
||||
@@ -739,7 +1072,7 @@ test('steward field completion reruns application preview instead of directly re
|
||||
assert.match(carryText, /用户已补充:出行方式:火车/)
|
||||
assert.match(carryText, /地点:北京/)
|
||||
assert.match(carryText, /天数:3天/)
|
||||
assert.match(carryText, /请先根据已补齐字段模拟查询交通票据/)
|
||||
assert.match(carryText, /请先根据已补齐字段按基础规则交通费用预估表/)
|
||||
|
||||
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
|
||||
assert.equal(rebuiltPreview.fields.location, '北京')
|
||||
@@ -758,7 +1091,7 @@ test('budget compile report does not steal steward delegated application rerun',
|
||||
'用户已补充:出行方式:火车。',
|
||||
'地点:北京',
|
||||
'天数:3天',
|
||||
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。'
|
||||
'处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。'
|
||||
].join('\n')
|
||||
|
||||
assert.equal(shouldUseBudgetCompileReport(stewardApplicationText, {
|
||||
@@ -777,66 +1110,109 @@ test('budget compile report does not steal steward delegated application rerun',
|
||||
test('text confirmation submits pending application preview before replanning steward task', () => {
|
||||
assert.match(stewardServiceScript, /fetchStewardRuntimeDecision/)
|
||||
assert.match(stewardServiceScript, /\/steward\/runtime-decisions/)
|
||||
assert.match(createViewScript, /function buildStewardRuntimeState/)
|
||||
assert.match(createViewScript, /function buildStewardRuntimeFastPathDecision/)
|
||||
assert.match(createViewScript, /function shouldUseStewardRuntimeLlmDecision/)
|
||||
assert.match(createViewScript, /function findPendingSlotSuggestedActionContextByInput/)
|
||||
assert.match(createViewScript, /function shouldPlanNewStewardTasksLocally/)
|
||||
assert.match(createViewScript, /function resolveStewardRuntimeTransportAlias/)
|
||||
assert.match(createViewScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
|
||||
assert.match(createViewScript, /actionTransportAlias === transportAlias/)
|
||||
assert.match(createViewScript, /next_action:\s*'continue_next_task'/)
|
||||
assert.match(createViewScript, /next_action:\s*'submit_current_application'/)
|
||||
assert.match(createViewScript, /next_action:\s*'fill_current_slot'/)
|
||||
assert.match(createViewScript, /next_action:\s*'plan_new_tasks'/)
|
||||
assert.match(createViewScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
|
||||
assert.match(createViewScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
|
||||
assert.match(createViewScript, /skipApplicationModelReview:\s*true/)
|
||||
assert.match(createViewScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(createViewScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(stewardRuntimeScript, /function buildStewardRuntimeState/)
|
||||
assert.match(stewardRuntimeScript, /function buildStewardRuntimeFastPathDecision/)
|
||||
assert.match(stewardRuntimeScript, /function shouldUseStewardRuntimeLlmDecision/)
|
||||
assert.match(stewardRuntimeScript, /function findPendingSlotSuggestedActionContextByInput/)
|
||||
assert.match(stewardRuntimeTextModelScript, /function shouldPlanNewStewardTasksLocally/)
|
||||
assert.match(stewardRuntimeTextModelScript, /function resolveStewardRuntimeTransportAlias/)
|
||||
assert.match(stewardRuntimeScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
|
||||
assert.match(stewardRuntimeScript, /actionTransportAlias === transportAlias/)
|
||||
assert.match(stewardRuntimeScript, /next_action:\s*'continue_next_task'/)
|
||||
assert.match(stewardRuntimeScript, /next_action:\s*'submit_current_application'/)
|
||||
assert.match(stewardRuntimeScript, /next_action:\s*'fill_current_slot'/)
|
||||
assert.match(stewardRuntimeScript, /next_action:\s*'plan_new_tasks'/)
|
||||
assert.match(stewardRuntimeScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
|
||||
assert.match(suggestedActionsScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
|
||||
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*true/)
|
||||
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(suggestedActionsScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/)
|
||||
assert.match(submitComposerScript, /if \(options\.skipModelReview\) \{[\s\S]*结构化快路径/)
|
||||
assert.match(submitComposerScript, /const requireModelReview = shouldRequireApplicationModelReview\(rawText\)/)
|
||||
assert.match(submitComposerScript, /if \(options\.skipModelReview && !requireModelReview\) \{[\s\S]*结构化快路径/)
|
||||
assert.match(submitComposerScript, /const localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/)
|
||||
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
|
||||
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
|
||||
assert.match(submitComposerScript, /const pendingSuggestedActions = Array\.isArray\(finalExtras\.suggestedActions\)/)
|
||||
assert.match(submitComposerScript, /message\.suggestedActions = pendingSuggestedActions[\s\S]*message\.stewardPlan = buildStewardDelegatedPlan\(continuation, \[\.\.\.typedEvents\], 'typing'\)/)
|
||||
assert.match(createViewScript, /async function handleStewardRuntimeDecision/)
|
||||
assert.match(createViewScript, /const runtimeState = buildStewardRuntimeState\(\)/)
|
||||
assert.match(createViewScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
|
||||
assert.match(createViewScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
|
||||
assert.match(createViewScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
|
||||
assert.match(createViewScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
||||
assert.match(createViewScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
|
||||
assert.match(createViewScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
|
||||
assert.match(createViewScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
|
||||
assert.match(createViewScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
||||
assert.match(createViewScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
|
||||
assert.match(stewardRuntimeScript, /async function handleStewardRuntimeDecision/)
|
||||
assert.match(stewardRuntimeScript, /const runtimeState = buildStewardRuntimeState\(\)/)
|
||||
assert.match(stewardRuntimeScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
|
||||
assert.match(stewardRuntimeScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
|
||||
assert.match(stewardRuntimeScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
|
||||
assert.match(stewardRuntimeScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
||||
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
|
||||
assert.match(stewardRuntimeScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
|
||||
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
|
||||
assert.match(stewardRuntimeScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
||||
assert.match(stewardRuntimeScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
|
||||
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
|
||||
assert.match(createViewScript, /function isApplicationSubmitConfirmationText/)
|
||||
assert.match(createViewScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
|
||||
assert.match(createViewScript, /function findPendingApplicationSubmitMessage/)
|
||||
assert.match(createViewScript, /normalizedPreview\.readyToSubmit/)
|
||||
assert.match(createViewScript, /async function handleApplicationSubmitConfirmationText/)
|
||||
assert.match(createViewScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
|
||||
assert.match(stewardRuntimeTextModelScript, /function isApplicationSubmitConfirmationText/)
|
||||
assert.match(stewardRuntimeTextModelScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
|
||||
assert.match(stewardRuntimeScript, /function findPendingApplicationSubmitMessage/)
|
||||
assert.match(stewardRuntimeScript, /normalizedPreview\.readyToSubmit/)
|
||||
assert.match(stewardRuntimeScript, /async function handleApplicationSubmitConfirmationText/)
|
||||
assert.match(stewardRuntimeScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
|
||||
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*if \(isStewardSession\.value && !options\.skipStewardPlan/)
|
||||
assert.match(createViewScript, /message\.applicationSubmitConfirmed = true/)
|
||||
assert.match(createViewScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
|
||||
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed = true/)
|
||||
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
|
||||
})
|
||||
|
||||
test('application submit result does not render reimbursement review followup', () => {
|
||||
assert.match(submitComposerScript, /function shouldExposeReviewPayloadForMessage\(payload, options = \{\}\)/)
|
||||
assert.match(submitComposerScript, /options\.isApplicationSubmitOperation \|\| isApplicationDraftPayload\(result\.draft_payload\)/)
|
||||
assert.match(submitComposerScript, /function buildPresentationPayload\(payload, \{ exposeReviewPayload = true \} = \{\}\)/)
|
||||
assert.match(submitComposerScript, /review_payload:\s*null/)
|
||||
assert.match(submitComposerScript, /const exposeReviewPayload = shouldExposeReviewPayloadForMessage\(payload, \{ isApplicationSubmitOperation \}\)/)
|
||||
assert.match(submitComposerScript, /const presentationPayload = buildPresentationPayload\(payload, \{ exposeReviewPayload \}\)/)
|
||||
assert.match(submitComposerScript, /const resultReviewPayload = presentationResult\.review_payload \|\| null/)
|
||||
assert.match(submitComposerScript, /suggestedActions:\s*resultSuggestedActions/)
|
||||
assert.match(submitComposerScript, /reviewPayload:\s*resultReviewPayload/)
|
||||
assert.match(submitComposerScript, /buildAgentInsight\(\s*presentationPayload,/)
|
||||
})
|
||||
|
||||
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
|
||||
assert.match(stewardPlanFlowScript, /STEWARD_TYPEWRITER_CHUNK_SIZE = 4/)
|
||||
assert.match(stewardPlanFlowScript, /STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5/)
|
||||
assert.match(stewardPlanFlowScript, /index = Math\.min\(total, index \+ STEWARD_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(stewardPlanFlowScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
|
||||
assert.match(stewardPlanFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(submitComposerScript, /STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4/)
|
||||
assert.match(submitComposerScript, /STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5/)
|
||||
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(submitComposerScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
|
||||
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/)
|
||||
assert.match(createViewScript, /STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4/)
|
||||
assert.match(createViewScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
|
||||
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
|
||||
assert.match(stewardFollowupFlowScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
|
||||
assert.match(stewardFollowupFlowScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
|
||||
assert.match(stewardFollowupFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
|
||||
})
|
||||
|
||||
test('steward typewriter renders markdown table blocks at once', () => {
|
||||
const tableText = '这是费用申请核对结果:\n| 字段 | 值 |\n| --- | --- |\n| 地点 | 上海 |\n下一段'
|
||||
const tableChars = Array.from(tableText)
|
||||
const tableIndex = tableText.indexOf('| 字段')
|
||||
const nextParagraphIndex = tableText.indexOf('下一段')
|
||||
const normalIndex = 0
|
||||
|
||||
assert.equal(resolveStewardTypewriterNextIndex(tableChars, normalIndex), 3)
|
||||
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex), nextParagraphIndex)
|
||||
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex - 1), nextParagraphIndex)
|
||||
assert.equal(resolveStewardTypewriterNextIndex(Array.from('### 核对结果'), 0), 2)
|
||||
})
|
||||
|
||||
test('application preview table appears as a whole card instead of row-by-row animation', () => {
|
||||
assert.doesNotMatch(
|
||||
messageItemStyles,
|
||||
/structured-card-reveal-enter-active\s+\.application-preview-row\s*\{[\s\S]*animation:/,
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
messageItemStyles,
|
||||
/application-preview-row:nth-child\([^)]*\)\s*\{[\s\S]*animation-delay:/,
|
||||
)
|
||||
})
|
||||
|
||||
test('complex travel application sentences require model review', () => {
|
||||
assert.equal(
|
||||
shouldRequireApplicationModelReview('申请2月20日-23日火车去上海出差,服务国网仿生产服务器部署'),
|
||||
true
|
||||
)
|
||||
assert.equal(shouldRequireApplicationModelReview('我想发起一笔费用申请'), false)
|
||||
})
|
||||
|
||||
test('steward initial workbench entry shows recognition state before messages arrive', () => {
|
||||
@@ -883,7 +1259,9 @@ test('steward application carry text does not leak transport examples into extra
|
||||
assert.match(carryText, /费用类型:差旅/)
|
||||
assert.doesNotMatch(carryText, /费用类型:travel/)
|
||||
assert.match(carryText, /还需要补充:出行方式/)
|
||||
assert.match(carryText, /请先追问上述缺失信息/)
|
||||
assert.doesNotMatch(carryText, /请先追问上述缺失信息/)
|
||||
assert.doesNotMatch(carryText, /请直接生成申请单核对结果/)
|
||||
assert.doesNotMatch(carryText, /入库或提交审批前/)
|
||||
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
|
||||
assert.doesNotMatch(carryText, /预计金额|附件\/凭证|员工编号|金额/)
|
||||
assert.equal(currentTask?.task_type, 'expense_application')
|
||||
@@ -909,7 +1287,7 @@ test('steward application carry text does not leak transport examples into extra
|
||||
assert.match(submitComposerScript, /fetchStewardApplicationSlotDecision/)
|
||||
assert.match(submitComposerScript, /task_type:\s*'expense_application'/)
|
||||
assert.match(submitComposerScript, /steward_continuation:\s*continuation/)
|
||||
assert.match(createViewScript, /currentTask:\s*actionPayload\.steward_current_task/)
|
||||
assert.match(suggestedActionsScript, /currentTask:\s*actionPayload\.steward_current_task/)
|
||||
})
|
||||
|
||||
test('steward application slot fallback ignores non-blocking application fields', () => {
|
||||
@@ -921,7 +1299,7 @@ test('steward application slot fallback ignores non-blocking application fields'
|
||||
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.question/)
|
||||
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.rationale/)
|
||||
assert.match(submitComposerScript, /normalizeTransportModeOption\(value \|\| label, ''\)/)
|
||||
assert.match(createViewScript, /normalizeTransportModeOption\(value, ''\)/)
|
||||
assert.match(suggestedActionsScript, /normalizeTransportModeOption\(value, ''\)/)
|
||||
assert.equal(normalizeTransportModeOption('高铁', ''), '火车')
|
||||
assert.equal(normalizeTransportModeOption('自驾', ''), '')
|
||||
assert.match(submitComposerScript, /function resolveBlockingApplicationMissingFieldsForSteward/)
|
||||
@@ -1045,8 +1423,10 @@ test('assistant markdown tables render with component-scoped table styling', ()
|
||||
assert.match(rendered, /<th/)
|
||||
assert.match(rendered, /<td/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-table-wrap\) \{[\s\S]*overflow-x: auto;[\s\S]*border: 1px solid #dbe4ee;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 460px;[\s\S]*border-collapse: separate;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 560px;[\s\S]*table-layout: fixed;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;[\s\S]*overflow-wrap: break-word;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:first-child\),[\s\S]*\.message-answer-markdown :deep\(td:first-child\) \{[\s\S]*width: 88px;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:last-child\),[\s\S]*\.message-answer-markdown :deep\(td:last-child\) \{[\s\S]*width: 112px;[\s\S]*text-align: right;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
|
||||
})
|
||||
|
||||
test('assistant reimbursement recognition copy renders structured markdown sections', () => {
|
||||
@@ -1082,17 +1462,24 @@ test('application date overlap blocks steward preview before duplicate applicati
|
||||
assert.match(submitComposerScript, /buildApplicationDateConflictMessage\(applicationDateConflict\)/)
|
||||
assert.match(submitComposerScript, /meta: \[STEWARD_ASSISTANT_NAME, '申请日期冲突'\]/)
|
||||
assert.match(submitComposerScript, /applicationPreview: pauseForMissingFields \? null : applicationPreview/)
|
||||
assert.match(createViewScript, /actionType === 'open_application_detail'/)
|
||||
assert.match(suggestedActionsScript, /actionType === 'open_application_detail'/)
|
||||
})
|
||||
|
||||
test('application preview merges rule center travel estimate into highlighted rows', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天,服务项目部署,火车,预计费用1800元', {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天,服务项目部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
})
|
||||
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5' })
|
||||
assert.equal(request.canCalculate, true)
|
||||
assert.deepEqual(request.payload, { days: 3, location: '上海', grade: 'P5' })
|
||||
assert.deepEqual(request.payload, {
|
||||
days: 3,
|
||||
location: '上海',
|
||||
grade: 'P5',
|
||||
transport_mode: '火车',
|
||||
origin_location: null,
|
||||
travel_date: '2026-05-25'
|
||||
})
|
||||
|
||||
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
|
||||
days: 3,
|
||||
@@ -1103,24 +1490,84 @@ test('application preview merges rule center travel estimate into highlighted ro
|
||||
hotel_amount: 1800,
|
||||
total_allowance_rate: 120,
|
||||
allowance_amount: 360,
|
||||
total_amount: 2160,
|
||||
transport_mode: '火车',
|
||||
transport_origin: '武汉',
|
||||
transport_destination: '上海',
|
||||
transport_estimated_amount: 720,
|
||||
transport_estimate_basis: '武汉-上海火车往返二等座预估',
|
||||
transport_estimate_source: 'basic_rule_transport_estimate',
|
||||
transport_estimate_confidence: '基础规则',
|
||||
total_amount: 2880,
|
||||
rule_name: '公司差旅费报销规则',
|
||||
rule_version: '2026版'
|
||||
}, { grade: 'P5' })
|
||||
|
||||
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
|
||||
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
|
||||
assert.equal(estimatedPreview.fields.transportPolicy, '预估交通费用 1,100元')
|
||||
assert.equal(estimatedPreview.fields.transportPolicy, '当前尚未接通实时票务价格查询 API,无法获取当前实际票价;先按《交通费用预估表》武汉-上海火车往返(二等座预估)暂估 720元用于申请阶段预算占用,最终报销以实际票据金额为准')
|
||||
assert.doesNotMatch(estimatedPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
|
||||
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '1,100元')
|
||||
assert.equal(estimatedPreview.fields.transportEstimateDate, '2026-05-25')
|
||||
assert.match(estimatedPreview.fields.transportQueryLatencyMs, /^\d+ms$/)
|
||||
assert.equal(estimatedPreview.fields.amount, '3,260元')
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /交通 720元/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /2,880元/)
|
||||
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '720元')
|
||||
assert.equal(estimatedPreview.fields.transportEstimateSource, 'basic_rule_transport_estimate')
|
||||
assert.equal(estimatedPreview.fields.transportQueryLatencyMs, '')
|
||||
assert.equal(estimatedPreview.fields.amount, '2,880元')
|
||||
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
|
||||
})
|
||||
|
||||
test('application preview blocks policy estimate when transport mode is missing', () => {
|
||||
const currentUser = { name: '李文静', grade: 'P5', location: '武汉' }
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'我要申请2月20日-23日去上海出差,辅助国网仿生产项目部署',
|
||||
currentUser,
|
||||
{ today: '2026-06-09' }
|
||||
)
|
||||
const request = buildApplicationPolicyEstimateRequest(preview, currentUser)
|
||||
assert.equal(request.canCalculate, false)
|
||||
assert.equal(request.reason, '缺少出行方式')
|
||||
assert.equal(request.payload, null)
|
||||
assert.equal(preview.missingFields.includes('出行方式'), true)
|
||||
assert.equal(preview.readyToSubmit, false)
|
||||
|
||||
const staleEstimateResult = {
|
||||
days: 4,
|
||||
location: '上海',
|
||||
matched_city: '上海',
|
||||
grade: 'P5',
|
||||
hotel_rate: 250,
|
||||
hotel_amount: 1000,
|
||||
total_allowance_rate: 100,
|
||||
allowance_amount: 400,
|
||||
transport_mode: '火车',
|
||||
transport_origin: '武汉',
|
||||
transport_destination: '上海',
|
||||
transport_estimated_amount: 720,
|
||||
transport_estimate_basis: '武汉-上海火车往返二等座预估',
|
||||
transport_estimate_source: 'basic_rule_transport_estimate',
|
||||
transport_estimate_confidence: '基础规则',
|
||||
total_amount: 2120,
|
||||
travel_date: '2026-02-20',
|
||||
rule_name: '差旅住宿报销标准',
|
||||
rule_version: 'v1.0.0'
|
||||
}
|
||||
const blockedEstimatePreview = applyApplicationPolicyEstimateResult(preview, {
|
||||
...staleEstimateResult,
|
||||
transport_mode: ''
|
||||
}, currentUser)
|
||||
const staleEstimatePreview = applyApplicationPolicyEstimateResult(preview, staleEstimateResult, currentUser)
|
||||
|
||||
assert.equal(blockedEstimatePreview.fields.transportMode, '')
|
||||
assert.equal(blockedEstimatePreview.fields.transportEstimatedAmount, '')
|
||||
assert.equal(blockedEstimatePreview.fields.policyEstimate, '填写地点和天数后自动测算')
|
||||
assert.equal(blockedEstimatePreview.missingFields.includes('出行方式'), true)
|
||||
assert.equal(staleEstimatePreview.fields.reason, '辅助国网仿生产项目部署')
|
||||
assert.equal(staleEstimatePreview.fields.transportMode, '火车')
|
||||
assert.equal(staleEstimatePreview.missingFields.includes('出行方式'), false)
|
||||
assert.equal(staleEstimatePreview.fields.transportPolicy, '当前尚未接通实时票务价格查询 API,无法获取当前实际票价;先按《交通费用预估表》武汉-上海火车往返(二等座预估)暂估 720元用于申请阶段预算占用,最终报销以实际票据金额为准')
|
||||
assert.match(staleEstimatePreview.fields.policyEstimate, /交通 720元/)
|
||||
assert.equal(staleEstimatePreview.fields.amount, '2,120元')
|
||||
})
|
||||
|
||||
test('application preview editor refreshes transport estimate after mode change', async () => {
|
||||
const preview = applyApplicationPolicyEstimateResult(
|
||||
buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天,服务项目部署', {
|
||||
@@ -1162,9 +1609,9 @@ test('application preview editor refreshes transport estimate after mode change'
|
||||
|
||||
assert.equal(committed, true)
|
||||
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
|
||||
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '2,330元')
|
||||
assert.equal(message.applicationPreview.fields.amount, '4,490元')
|
||||
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 2,330元')
|
||||
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '1,380元')
|
||||
assert.equal(message.applicationPreview.fields.amount, '3,540元')
|
||||
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 1,380元')
|
||||
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
|
||||
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
|
||||
assert.ok(persistCount >= 2)
|
||||
@@ -1223,7 +1670,14 @@ test('application preview editor recalculates days and subsidy after date range
|
||||
const committed = await editor.commitApplicationPreviewDateEditor(message)
|
||||
|
||||
assert.equal(committed, true)
|
||||
assert.deepEqual(requestedPayloads.at(-1), { days: 4, location: '\u4e0a\u6d77', grade: 'P5' })
|
||||
assert.deepEqual(requestedPayloads.at(-1), {
|
||||
days: 4,
|
||||
location: '\u4e0a\u6d77',
|
||||
grade: 'P5',
|
||||
transport_mode: '\u706b\u8f66',
|
||||
origin_location: null,
|
||||
travel_date: '2026-02-20'
|
||||
})
|
||||
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
|
||||
assert.equal(message.applicationPreview.fields.days, '4\u5929')
|
||||
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
|
||||
|
||||
@@ -13,6 +13,14 @@ const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const suggestedActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardRuntimeScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const messageItemStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-item.css', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -40,21 +48,21 @@ test('expense application submit uses rich text link and confirm dialog', () =>
|
||||
/href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/
|
||||
)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/async function confirmApplicationSubmit\(\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/
|
||||
stewardRuntimeScript,
|
||||
/async function confirmApplicationSubmit\(options = \{\}\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/
|
||||
)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
stewardRuntimeScript,
|
||||
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = await submitComposer/
|
||||
)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
stewardRuntimeScript,
|
||||
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
|
||||
)
|
||||
assert.match(createViewScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
|
||||
assert.match(createViewScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
|
||||
assert.match(createViewScript, /meta:\s*\['行程规划推荐'\]/)
|
||||
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
|
||||
assert.match(createViewScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
|
||||
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_SKIP/)
|
||||
assert.match(stewardRuntimeScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
|
||||
assert.match(stewardRuntimeScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
|
||||
assert.match(stewardRuntimeScript, /meta:\s*\['行程规划推荐'\]/)
|
||||
assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
|
||||
assert.match(suggestedActionsScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
|
||||
assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_SKIP/)
|
||||
})
|
||||
|
||||
@@ -69,6 +69,10 @@ const guidedFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementGuidedFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const suggestedActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const guidedModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementGuidedFlowModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -85,7 +89,7 @@ const submitComposerScript = readFileSync(
|
||||
test('assistant session modes expose independent quick actions', () => {
|
||||
assert.deepEqual(
|
||||
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
|
||||
['申请助手', '报销助手', '审核助手', '财务知识助手', '预算编制助手']
|
||||
['小财管家', '申请助手', '报销助手', '审核助手', '财务知识助手', '预算编制助手']
|
||||
)
|
||||
assert.deepEqual(
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||||
@@ -448,8 +452,8 @@ test('guided flow is local until final confirmation or collected query handoff',
|
||||
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)
|
||||
assert.match(createViewScript, /handleSceneSelectionApplicationGate/)
|
||||
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
||||
assert.match(createViewScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
||||
assert.match(createViewScript, /actionPayload\.carry_text/)
|
||||
assert.match(suggestedActionsScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
||||
assert.match(suggestedActionsScript, /actionPayload\.carry_text/)
|
||||
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
|
||||
assert.match(submitComposerScript, /skipScopeGuard/)
|
||||
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
||||
|
||||
@@ -23,6 +23,10 @@ const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reviewPanelModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const messageItemTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -173,9 +177,9 @@ test('local transport review no longer uses the travel hotel template', () => {
|
||||
}
|
||||
|
||||
assert.equal(isTravelReviewPayload(reviewPayload, { expense_type: '交通费' }), false)
|
||||
assert.match(createViewScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
|
||||
assert.match(reviewPanelModelScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
|
||||
assert.doesNotMatch(
|
||||
createViewScript,
|
||||
reviewPanelModelScript,
|
||||
/key:\s*'customer_name'[\s\S]{0,220}placeholder:\s*'请输入客户名称'[\s\S]{0,80}\},\s*\{[\s\S]{0,80}key:\s*'attachments'/
|
||||
)
|
||||
assert.match(createViewTemplate, /placeholder="例如:出租车\/网约车票据 \/ 火车\/高铁票"/)
|
||||
@@ -279,7 +283,7 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
|
||||
})
|
||||
|
||||
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
|
||||
const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction buildReviewRiskConversationText/)
|
||||
const riskItemsBlock = reviewPanelModelScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nexport function buildReviewRiskConversationText/)
|
||||
assert.ok(riskItemsBlock, 'risk item builder should be present')
|
||||
|
||||
assert.doesNotMatch(createViewTemplate, /review-side-risk-score/)
|
||||
@@ -288,9 +292,9 @@ test('review risk drawer lists risk briefs without score and posts details into
|
||||
assert.doesNotMatch(createViewScript, /function buildReviewRiskScore/)
|
||||
assert.doesNotMatch(createViewScript, /const reviewRiskScore/)
|
||||
assert.doesNotMatch(riskItemsBlock[0], /\.slice\(0,\s*6\)/)
|
||||
assert.match(createViewScript, /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/)
|
||||
assert.match(reviewPanelModelScript, /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
reviewPanelModelScript,
|
||||
/function resolveReviewRiskBriefs\(reviewPayload\) \{[\s\S]*DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS\.some/
|
||||
)
|
||||
|
||||
@@ -300,17 +304,17 @@ test('review risk drawer lists risk briefs without score and posts details into
|
||||
)
|
||||
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
|
||||
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
|
||||
assert.match(createViewScript, /info:\s*\{[\s\S]*label:\s*'提示'/)
|
||||
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
|
||||
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
|
||||
assert.match(createViewScript, /const isInfo = String\(item\?\.level \|\| ''\)\.trim\(\) === 'info'/)
|
||||
assert.match(createViewScript, /\$\{isInfo \? '提示内容' : '风险点'\}:\$\{summary\}/)
|
||||
assert.match(createViewScript, /\$\{isInfo \? '处理建议' : '修改建议'\}:\$\{suggestion\}/)
|
||||
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
|
||||
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
|
||||
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
|
||||
assert.match(createViewScript, /sourceLabel:\s*meta\.label/)
|
||||
assert.doesNotMatch(createViewScript, /normalizedTitle\.includes\('AI预审'\)/)
|
||||
assert.match(reviewPanelModelScript, /info:\s*\{[\s\S]*label:\s*'提示'/)
|
||||
assert.match(reviewPanelModelScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
|
||||
assert.match(reviewPanelModelScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
|
||||
assert.match(reviewPanelModelScript, /const isInfo = String\(item\?\.level \|\| ''\)\.trim\(\) === 'info'/)
|
||||
assert.match(reviewPanelModelScript, /\$\{isInfo \? '提示内容' : '风险点'\}:\$\{summary\}/)
|
||||
assert.match(reviewPanelModelScript, /\$\{isInfo \? '处理建议' : '修改建议'\}:\$\{suggestion\}/)
|
||||
assert.match(reviewPanelModelScript, /function normalizeReviewRiskTitle/)
|
||||
assert.match(reviewPanelModelScript, /\.replace\(\/AI\\s\*预审/)
|
||||
assert.match(reviewPanelModelScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
|
||||
assert.match(reviewPanelModelScript, /sourceLabel:\s*meta\.label/)
|
||||
assert.doesNotMatch(reviewPanelModelScript, /normalizedTitle\.includes\('AI预审'\)/)
|
||||
assert.match(createViewScript, /metaTone:\s*item\.level \|\| 'low'/)
|
||||
assert.doesNotMatch(createViewTemplate, /@click="openReviewRiskDetail\(item\)"/)
|
||||
assert.doesNotMatch(createViewTemplate, /review-risk-detail-modal/)
|
||||
@@ -321,7 +325,7 @@ test('review risk drawer lists risk briefs without score and posts details into
|
||||
createViewScript,
|
||||
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
|
||||
)
|
||||
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
|
||||
assert.match(reviewPanelModelScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
|
||||
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-document-detail'/)
|
||||
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
|
||||
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
|
||||
@@ -335,14 +339,14 @@ test('review drawer default mode is scoped by the current action and travel over
|
||||
assert.match(reviewDrawerScript, /scope === 'documents' && hasDocuments[\s\S]*REVIEW_DRAWER_MODE_DOCUMENTS/)
|
||||
assert.match(reviewDrawerScript, /scope === 'risk' && hasRisks[\s\S]*REVIEW_DRAWER_MODE_RISK/)
|
||||
assert.match(reviewDrawerScript, /scope === 'overview'[\s\S]*REVIEW_DRAWER_MODE_REVIEW/)
|
||||
assert.match(createViewScript, /function normalizeReviewPanelScope\(scope\)/)
|
||||
assert.match(reviewPanelModelScript, /function normalizeReviewPanelScope\(scope\)/)
|
||||
assert.match(createViewScript, /canExposeReviewPanelScope\(item\.reviewPanelScope\)/)
|
||||
assert.match(createViewScript, /currentInsight\.value\.intent === 'agent' && agent[\s\S]*return null/)
|
||||
assert.match(createViewScript, /function isTravelReviewPayload\(reviewPayload/)
|
||||
assert.match(createViewScript, /function resolveReviewTravelTransportType\(reviewPayload/)
|
||||
assert.match(createViewScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
|
||||
assert.match(createViewScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
|
||||
assert.match(createViewScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
|
||||
assert.match(reviewPanelModelScript, /function isTravelReviewPayload\(reviewPayload/)
|
||||
assert.match(reviewPanelModelScript, /function resolveReviewTravelTransportType\(reviewPayload/)
|
||||
assert.match(reviewPanelModelScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
|
||||
assert.match(reviewPanelModelScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
|
||||
assert.match(reviewPanelModelScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
|
||||
assert.match(createViewTemplate, /item\.editor === 'textarea'[\s\S]*<textarea/)
|
||||
assert.match(createViewTemplate, /wide: item\.wide/)
|
||||
})
|
||||
@@ -414,8 +418,8 @@ test('composer exposes travel calculator and posts spreadsheet-backed result int
|
||||
})
|
||||
|
||||
test('continuing receipt upload preserves prior review form context', () => {
|
||||
assert.match(createViewScript, /function buildReviewFormContextFromPayload\(reviewPayload, inlineState = null\)/)
|
||||
assert.match(createViewScript, /function buildBusinessTimeContextFromReviewValues\(values = \{\}\)/)
|
||||
assert.match(reviewPanelModelScript, /function buildReviewFormContextFromPayload\(reviewPayload, inlineState = null\)/)
|
||||
assert.match(reviewPanelModelScript, /function buildBusinessTimeContextFromReviewValues\(values = \{\}\)/)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/resolvedUploadDisposition === 'continue_existing'[\s\S]*buildReviewFormContextFromPayload\([\s\S]*activeReviewPayload\.value[\s\S]*reviewInlineForm\.value[\s\S]*extraContext\.review_form_values/s
|
||||
|
||||
Reference in New Issue
Block a user