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 {
|
.application-draft-preview {
|
||||||
width: min(100%, 620px);
|
width: min(100%, 620px);
|
||||||
max-width: 620px;
|
max-width: 620px;
|
||||||
|
|||||||
@@ -532,10 +532,11 @@
|
|||||||
|
|
||||||
.message-answer-markdown :deep(table) {
|
.message-answer-markdown :deep(table) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 460px;
|
min-width: 560px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
|
table-layout: fixed;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
@@ -547,6 +548,25 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
white-space: normal;
|
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) {
|
.message-answer-markdown :deep(th) {
|
||||||
@@ -786,30 +806,6 @@
|
|||||||
border-top: 1px solid #e6edf5;
|
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 {
|
.application-preview-row.editable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,6 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<span
|
<span
|
||||||
class="application-preview-text"
|
class="application-preview-text"
|
||||||
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
|
|
||||||
>{{ row.value }}</span>
|
>{{ row.value }}</span>
|
||||||
<button
|
<button
|
||||||
v-if="row.editable"
|
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_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_APPLICATION = 'application'
|
||||||
export const ASSISTANT_SCOPE_SESSION_EXPENSE = 'expense'
|
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_KNOWLEDGE = 'knowledge'
|
||||||
export const ASSISTANT_SCOPE_SESSION_STEWARD = 'steward'
|
export const ASSISTANT_SCOPE_SESSION_STEWARD = 'steward'
|
||||||
|
|
||||||
const SESSION_SCOPE_CONFIG = {
|
const FALLBACK_SESSION_SCOPE_CONFIG = {
|
||||||
[ASSISTANT_SCOPE_SESSION_STEWARD]: {
|
[ASSISTANT_SCOPE_SESSION_STEWARD]: {
|
||||||
label: '小财管家',
|
label: '小财管家',
|
||||||
icon: 'mdi mdi-account-tie-outline',
|
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 SESSION_SCOPE_TYPES = Object.keys(SESSION_SCOPE_CONFIG)
|
||||||
|
const BUSINESS_SIGNAL_GROUPS = ONTOLOGY_BUSINESS_CONTRACT.businessSignals || {}
|
||||||
|
|
||||||
const APPLICATION_PATTERN =
|
const APPLICATION_PATTERN =
|
||||||
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|申请出差|差旅申请|申请差旅|采购申请|用款申请|预算申请|申请材料|材料清单|先申请|立项申请/
|
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|申请出差|差旅申请|申请差旅|采购申请|用款申请|预算申请|申请材料|材料清单|先申请|立项申请/
|
||||||
@@ -56,6 +62,45 @@ const KNOWLEDGE_PATTERN =
|
|||||||
/制度|政策|标准|规则|规定|流程|口径|依据|上限|额度|补贴|住宿标准|差旅标准|报销标准|票据要求|可不可以|能不能|怎么规定|如何计算|怎么算/
|
/制度|政策|标准|规则|规定|流程|口径|依据|上限|额度|补贴|住宿标准|差旅标准|报销标准|票据要求|可不可以|能不能|怎么规定|如何计算|怎么算/
|
||||||
const EXPENSE_OPERATION_PATTERN = /发起报销|报销单|票据|发票|火车票|高铁票|机票|的士票|草稿|归集|上传|关联单据|继续下一步/
|
const EXPENSE_OPERATION_PATTERN = /发起报销|报销单|票据|发票|火车票|高铁票|机票|的士票|草稿|归集|上传|关联单据|继续下一步/
|
||||||
const CURRENT_CLAIM_RISK_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) {
|
function normalizeSessionType(sessionType) {
|
||||||
const normalized = String(sessionType || '').trim()
|
const normalized = String(sessionType || '').trim()
|
||||||
@@ -131,6 +176,10 @@ export function inferAssistantScopeTarget(rawText, options = {}) {
|
|||||||
return ASSISTANT_SCOPE_SESSION_APPLICATION
|
return ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (FINANCE_OPERATING_PATTERN.test(text)) {
|
||||||
|
return ASSISTANT_SCOPE_SESSION_STEWARD
|
||||||
|
}
|
||||||
|
|
||||||
if (knowledgeMatched && !expenseMatched && !approvalMatched && !applicationMatched) {
|
if (knowledgeMatched && !expenseMatched && !approvalMatched && !applicationMatched) {
|
||||||
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
|
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
|
||||||
}
|
}
|
||||||
@@ -198,10 +247,58 @@ function buildScopeBoundaryText(currentSessionType, targetSessionType) {
|
|||||||
].join('\n')
|
].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 = {}) {
|
export function resolveAssistantScopeGuard(rawText, currentSessionType, options = {}) {
|
||||||
const normalizedCurrent = normalizeSessionType(currentSessionType)
|
const normalizedCurrent = normalizeSessionType(currentSessionType)
|
||||||
const targetSessionType = inferAssistantScopeTarget(rawText, options)
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const LOCATION_BANDS = {
|
|||||||
|
|
||||||
const TRANSPORT_PRICE_BASE = {
|
const TRANSPORT_PRICE_BASE = {
|
||||||
火车: { default: 360, premium: 520, remote: 900, coastal: 520 },
|
火车: { 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 }
|
轮船: { default: 320, premium: 480, remote: 680, coastal: 520 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +125,8 @@ export function buildMockApplicationTransportEstimate({
|
|||||||
queryDate,
|
queryDate,
|
||||||
priceFactor,
|
priceFactor,
|
||||||
simulatedLatencyMs,
|
simulatedLatencyMs,
|
||||||
source: 'mock_ticket_price_query_v1',
|
source: 'fallback_transport_budget_estimate_v1',
|
||||||
confidence: 'mock',
|
confidence: 'fallback',
|
||||||
basisText: `预估交通费用 ${amountDisplay}元`
|
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) {
|
export function resolveTimeRangeText(ontology) {
|
||||||
const range = ontology?.time_range || {}
|
const range = ontology?.time_range || {}
|
||||||
if (range.start_date && range.end_date) {
|
if (range.start_date && range.end_date) {
|
||||||
@@ -261,7 +268,8 @@ function cleanupApplicationReasonCandidate(value, location = '') {
|
|||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
|
|
||||||
text = text
|
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(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/gu, '')
|
||||||
.replace(/(?:出差|申请)?(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/gu, '')
|
.replace(/(?:出差|申请)?(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/gu, '')
|
||||||
.replace(/(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元|人民币)?/gu, '')
|
.replace(/(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元|人民币)?/gu, '')
|
||||||
@@ -281,6 +289,7 @@ function cleanupApplicationReasonCandidate(value, location = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
|
if (isInvalidApplicationReason(text)) return ''
|
||||||
if (/^20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?$/.test(text)) return ''
|
if (/^20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?$/.test(text)) return ''
|
||||||
if (/^(?:\d+|[一二两三四五六七八九十]{1,3})\s*天$/.test(text)) return ''
|
if (/^(?:\d+|[一二两三四五六七八九十]{1,3})\s*天$/.test(text)) return ''
|
||||||
if (/^[\u4e00-\u9fa5]{1,8}$/.test(text) && !/服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(text)) {
|
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 reasonEntity = resolveEntity(ontology, 'reason') || resolveEntity(ontology, 'business_reason')
|
||||||
const entityReason = String(reasonEntity?.normalized_value || reasonEntity?.value || '').trim()
|
const entityReason = String(reasonEntity?.normalized_value || reasonEntity?.value || '').trim()
|
||||||
if (entityReason) {
|
if (entityReason) {
|
||||||
return cleanupApplicationReasonCandidate(entityReason, location) || entityReason
|
const cleanedEntityReason = cleanupApplicationReasonCandidate(entityReason, location)
|
||||||
|
if (cleanedEntityReason && !isInvalidApplicationReason(cleanedEntityReason)) {
|
||||||
|
return cleanedEntityReason
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
|
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
|
||||||
if (labeled) {
|
if (labeled) {
|
||||||
return cleanupApplicationReasonCandidate(labeled, location) || labeled
|
const cleanedLabeledReason = cleanupApplicationReasonCandidate(labeled, location)
|
||||||
|
if (cleanedLabeledReason && !isInvalidApplicationReason(cleanedLabeledReason)) {
|
||||||
|
return cleanedLabeledReason
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = String(prompt || '')
|
const candidates = String(prompt || '')
|
||||||
.split(/[\n,。;;]+/u)
|
.split(/[\n,。;;]+/u)
|
||||||
.map((item) => cleanupApplicationReasonCandidate(item, location))
|
.map((item) => cleanupApplicationReasonCandidate(item, location))
|
||||||
.filter(Boolean)
|
.filter((item) => item && !isSystemGeneratedApplicationReason(item) && !isInvalidApplicationReason(item))
|
||||||
const businessCandidate = candidates.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item))
|
const businessCandidate = candidates.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item))
|
||||||
return businessCandidate || candidates.sort((left, right) => right.length - left.length)[0] || ''
|
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) {
|
function resolveApplicationTransportMode(ontology, prompt) {
|
||||||
const transportEntity = resolveEntity(ontology, 'transport_mode')
|
const transportEntity = resolveEntity(ontology, 'transport_mode')
|
||||||
|| resolveEntity(ontology, 'transport')
|
|| resolveEntity(ontology, 'transport')
|
||||||
@@ -383,6 +414,13 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
|
|||||||
const reason = resolveApplicationReason(prompt, ontology) || '待补充'
|
const reason = resolveApplicationReason(prompt, ontology) || '待补充'
|
||||||
const days = resolvePromptDays(prompt)
|
const days = resolvePromptDays(prompt)
|
||||||
const transportMode = resolveApplicationTransportMode(ontology, 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 = {
|
const fields = {
|
||||||
documentType: documentTypeEntity?.normalized_value || 'expense_application',
|
documentType: documentTypeEntity?.normalized_value || 'expense_application',
|
||||||
@@ -393,6 +431,13 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
|
|||||||
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
|
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
|
||||||
amount: amount.value,
|
amount: amount.value,
|
||||||
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
|
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
|
||||||
|
transportEstimatedAmount,
|
||||||
|
trainEstimatedAmount,
|
||||||
|
flightEstimatedAmount,
|
||||||
|
hotelAmount,
|
||||||
|
allowanceAmount,
|
||||||
|
policyTotalAmount,
|
||||||
|
reimbursementAmount,
|
||||||
timeRange,
|
timeRange,
|
||||||
location,
|
location,
|
||||||
reason,
|
reason,
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
|
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
|
||||||
|
import { evaluateLocalApplicationIntentGate } from './expenseApplicationIntentGate.js'
|
||||||
import {
|
import {
|
||||||
buildMockApplicationTransportEstimate,
|
buildMockApplicationTransportEstimate,
|
||||||
|
formatApplicationEstimateMoney,
|
||||||
|
parseApplicationEstimateMoney,
|
||||||
buildSystemApplicationEstimate
|
buildSystemApplicationEstimate
|
||||||
} from './expenseApplicationEstimate.js'
|
} from './expenseApplicationEstimate.js'
|
||||||
import { getTodayDateValue } from './workbenchComposerDate.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 = [
|
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
|
||||||
{ key: 'applicationType', label: '申请类型' },
|
{ key: 'applicationType', label: '申请类型' },
|
||||||
{ key: 'applicant', label: '姓名', editable: false, required: false },
|
{ key: 'applicant', label: '姓名', editable: false, required: false },
|
||||||
@@ -111,6 +110,29 @@ function buildEndDateFromDays(startText, daysText = '') {
|
|||||||
return formatIsoDate(end)
|
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) {
|
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})/)
|
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 ''
|
if (!match) return ''
|
||||||
@@ -125,6 +147,80 @@ export function resolveApplicationDaysFromDateRange(rangeText) {
|
|||||||
return resolveDaysFromDateRange(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 = '') {
|
export function resolveApplicationDateRange(rangeText, daysText = '') {
|
||||||
const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||||
const startDate = normalizeDateText(matchedDates[0] || '')
|
const startDate = normalizeDateText(matchedDates[0] || '')
|
||||||
@@ -179,14 +275,106 @@ function resolveApplicationType(text) {
|
|||||||
function resolveApplicationAmount(text) {
|
function resolveApplicationAmount(text) {
|
||||||
const compact = compactText(text)
|
const compact = compactText(text)
|
||||||
const labeled = resolveFirstMatch(text, [
|
const labeled = resolveFirstMatch(text, [
|
||||||
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)?/u,
|
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?)/u,
|
||||||
/(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)/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 '待测算'
|
if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
|
||||||
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 = {}) {
|
function resolveCurrentUserGrade(currentUser = {}) {
|
||||||
return String(
|
return String(
|
||||||
currentUser.grade
|
currentUser.grade
|
||||||
@@ -282,12 +470,45 @@ function formatDailyPolicyMoney(value) {
|
|||||||
|
|
||||||
function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
|
function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
|
||||||
const mode = String(transportMode || '').trim()
|
const mode = String(transportMode || '').trim()
|
||||||
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
|
||||||
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
|
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
|
||||||
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||||
return estimate.basisText
|
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 = {}) {
|
function ensureApplicationPolicyFields(fields = {}) {
|
||||||
const nextFields = { ...fields }
|
const nextFields = { ...fields }
|
||||||
if (!String(nextFields.lodgingDailyCap || '').trim()) {
|
if (!String(nextFields.lodgingDailyCap || '').trim()) {
|
||||||
@@ -321,6 +542,11 @@ function resolveApplicationTime(text, daysText = '', options = {}) {
|
|||||||
return `${normalizeDateText(range[1])} 至 ${normalizeDateText(range[2])}`
|
return `${normalizeDateText(range[1])} 至 ${normalizeDateText(range[2])}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shortMonthDayRange = resolveShortMonthDayRange(text, options)
|
||||||
|
if (shortMonthDayRange) {
|
||||||
|
return shortMonthDayRange
|
||||||
|
}
|
||||||
|
|
||||||
const single = resolveFirstMatch(text, [
|
const single = resolveFirstMatch(text, [
|
||||||
/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
|
/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
|
||||||
/(?<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 = {}) {
|
function resolveApplicationTimeWithDefault(text, daysText = '', options = {}) {
|
||||||
const resolvedTime = resolveApplicationTime(text, daysText)
|
const resolvedTime = resolveApplicationTime(text, daysText, options)
|
||||||
if (resolvedTime || !parseApplicationDaysValue(daysText)) {
|
if (resolvedTime || !parseApplicationDaysValue(daysText)) {
|
||||||
return resolvedTime
|
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) {
|
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)
|
const compact = compactText(text)
|
||||||
if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
|
if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
|
||||||
if (/飞机|机票|航班/.test(compact)) return '飞机'
|
if (/飞机|机票|航班/.test(compact)) return '飞机'
|
||||||
@@ -360,6 +618,7 @@ function resolveApplicationTransportMode(text) {
|
|||||||
function stripKnownContextFromReason(value, context = {}) {
|
function stripKnownContextFromReason(value, context = {}) {
|
||||||
const location = String(context.location || '').trim()
|
const location = String(context.location || '').trim()
|
||||||
let cleaned = String(value || '')
|
let cleaned = String(value || '')
|
||||||
|
.replace(/(?:请直接生成申请单核对结果|信息足够时生成申请单|但在入库或提交审批前仍需让我确认|请直接生成报销核对结果|需要创建草稿、绑定附件或提交审批前仍需让我确认)[\s\S]*$/u, '')
|
||||||
.replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
.replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
||||||
.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, '')
|
.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 || '')
|
const segments = String(text || '')
|
||||||
.split(/[,,、。;;\n]+/u)
|
.split(/[,,、。;;\n]+/u)
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean)
|
.filter((item) => item && !isSystemGeneratedReasonText(item))
|
||||||
return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(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 = {}) {
|
function resolveApplicationReason(text, context = {}) {
|
||||||
const labeled = resolveFirstMatch(text, [
|
const labeled = resolveFirstMatch(text, [
|
||||||
/(?:事由|申请事由|出差事由|原因|用途)\s*[::]\s*(?<value>[^,。;;\n]+)/u
|
/(?:事由|申请事由|出差事由|原因|用途)\s*[::]\s*(?<value>[^,。;;\n]+)/u
|
||||||
@@ -401,6 +670,7 @@ function resolveApplicationReason(text, context = {}) {
|
|||||||
const withoutContext = stripKnownContextFromReason(cleaned, context)
|
const withoutContext = stripKnownContextFromReason(cleaned, context)
|
||||||
const businessSegment = pickBusinessReasonSegment(withoutContext)
|
const businessSegment = pickBusinessReasonSegment(withoutContext)
|
||||||
if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
|
if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
|
||||||
|
if (isSystemGeneratedReasonText(withoutContext)) return ''
|
||||||
return withoutContext
|
return withoutContext
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +717,7 @@ function resolveModelRefinedTransportMode(ontologyFields = {}, rawText = '', cur
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
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) {
|
if (Number.isFinite(numericAmount) && numericAmount > 0) {
|
||||||
return `${numericAmount}元`
|
return `${numericAmount}元`
|
||||||
}
|
}
|
||||||
@@ -461,6 +731,14 @@ function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
|||||||
return 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) {
|
function buildMissingFields(fields) {
|
||||||
return APPLICATION_PREVIEW_FIELD_DEFINITIONS
|
return APPLICATION_PREVIEW_FIELD_DEFINITIONS
|
||||||
.filter((item) => item.key !== 'applicationType' && item.required !== false)
|
.filter((item) => item.key !== 'applicationType' && item.required !== false)
|
||||||
@@ -478,6 +756,14 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
|
|||||||
const transportMode = String(fields.transportMode || '').trim()
|
const transportMode = String(fields.transportMode || '').trim()
|
||||||
const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode)
|
const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode)
|
||||||
|
|
||||||
|
if (/差旅|出差/.test(applicationType) && !transportMode) {
|
||||||
|
return {
|
||||||
|
canCalculate: false,
|
||||||
|
reason: '缺少出行方式',
|
||||||
|
payload: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldEstimate || !days || !location) {
|
if (!shouldEstimate || !days || !location) {
|
||||||
return {
|
return {
|
||||||
canCalculate: false,
|
canCalculate: false,
|
||||||
@@ -492,27 +778,88 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
|
|||||||
payload: {
|
payload: {
|
||||||
days,
|
days,
|
||||||
location,
|
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 = {}) {
|
export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, currentUser = {}) {
|
||||||
const fields = { ...(preview?.fields || {}) }
|
const resultTransportMode = String(result?.transport_mode || '').trim()
|
||||||
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
|
const fields = {
|
||||||
|
...(preview?.fields || {}),
|
||||||
|
...(!String(preview?.fields?.transportMode || '').trim() && resultTransportMode
|
||||||
|
? { transportMode: resultTransportMode }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
const hotelRate = formatPolicyMoney(result?.hotel_rate)
|
const hotelRate = formatPolicyMoney(result?.hotel_rate)
|
||||||
const hotelAmount = formatPolicyMoney(result?.hotel_amount)
|
const hotelAmount = formatPolicyMoney(result?.hotel_amount)
|
||||||
const allowanceRate = formatPolicyMoney(result?.total_allowance_rate)
|
const allowanceRate = formatPolicyMoney(result?.total_allowance_rate)
|
||||||
const allowanceAmount = formatPolicyMoney(result?.allowance_amount)
|
const allowanceAmount = formatPolicyMoney(result?.allowance_amount)
|
||||||
const matchedCity = String(result?.matched_city || fields.location || '').trim()
|
const matchedCity = String(result?.matched_city || fields.location || '').trim()
|
||||||
const grade = String(result?.grade || fields.grade || resolveCurrentUserGrade(currentUser)).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,
|
transportMode: fields.transportMode,
|
||||||
location: matchedCity || fields.location,
|
location: matchedCity || fields.location,
|
||||||
time: fields.time,
|
time: fields.time,
|
||||||
lodgingAmount: result?.hotel_amount,
|
lodgingAmount: result?.hotel_amount,
|
||||||
allowanceAmount: result?.allowance_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 transportEstimate = systemEstimate.transportEstimate
|
||||||
const transportText = transportEstimate
|
const transportText = transportEstimate
|
||||||
? `交通 ${systemEstimate.transportAmountDisplay}元 + `
|
? `交通 ${systemEstimate.transportAmountDisplay}元 + `
|
||||||
@@ -621,22 +968,22 @@ export function applyApplicationPolicyEstimateError(preview = {}, error = null,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldUseLocalApplicationPreview(rawText, options = {}) {
|
export function shouldUseLocalApplicationPreview(rawText, options = {}) {
|
||||||
if (String(options.sessionType || '').trim() !== APPLICATION_SESSION_TYPE) return false
|
return evaluateLocalApplicationIntentGate(rawText, options).allowed
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeApplicationPreview(preview = {}) {
|
export function normalizeApplicationPreview(preview = {}) {
|
||||||
const fields = ensureApplicationPolicyFields(preview?.fields || {})
|
const fields = ensureApplicationPolicyFields(preview?.fields || {})
|
||||||
const missingFields = buildMissingFields(fields)
|
const missingFields = buildMissingFields(fields)
|
||||||
|
const validationIssues = [
|
||||||
|
...resolveApplicationValidationIssues(fields),
|
||||||
|
...resolveApplicationSourceValidationIssues(preview?.sourceText, fields, preview)
|
||||||
|
]
|
||||||
return {
|
return {
|
||||||
...preview,
|
...preview,
|
||||||
fields,
|
fields,
|
||||||
missingFields,
|
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),
|
days: resolveProvidedValue(ontologyFields.days, currentFields.days),
|
||||||
transportMode: resolveModelRefinedTransportMode(ontologyFields, rawText, currentFields),
|
transportMode: resolveModelRefinedTransportMode(ontologyFields, rawText, currentFields),
|
||||||
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
|
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)),
|
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
|
||||||
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
|
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
|
||||||
department: resolveProvidedValue(ontologyFields.department, currentFields.department || resolveCurrentUserDepartment(currentUser)),
|
department: resolveProvidedValue(ontologyFields.department, currentFields.department || resolveCurrentUserDepartment(currentUser)),
|
||||||
@@ -827,6 +1184,10 @@ export function buildLocalApplicationPreviewMessage(preview) {
|
|||||||
export function buildApplicationPreviewFooterMessage(preview) {
|
export function buildApplicationPreviewFooterMessage(preview) {
|
||||||
const normalized = normalizeApplicationPreview(preview)
|
const normalized = normalizeApplicationPreview(preview)
|
||||||
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
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) {
|
if (missingFields.length) {
|
||||||
return `当前还需要补充:${missingFields.join('、')}。补齐后我再帮您提交申请。`
|
return `当前还需要补充:${missingFields.join('、')}。补齐后我再帮您提交申请。`
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -125,7 +125,7 @@ export function buildStewardFieldCompletionRawText({
|
|||||||
'已识别信息:',
|
'已识别信息:',
|
||||||
...knownLines,
|
...knownLines,
|
||||||
'',
|
'',
|
||||||
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。',
|
'处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。',
|
||||||
'如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。'
|
'如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。'
|
||||||
].filter((line) => line !== '').join('\n')
|
].filter((line) => line !== '').join('\n')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,13 +508,6 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
|
|||||||
group?.attachmentNames?.length ? `相关附件:${group.attachmentNames.join('、')}` : '',
|
group?.attachmentNames?.length ? `相关附件:${group.attachmentNames.join('、')}` : '',
|
||||||
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
|
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
|
||||||
missingFields ? `还需要补充:${missingFields}` : '',
|
missingFields ? `还需要补充:${missingFields}` : '',
|
||||||
actionType === 'confirm_create_application'
|
|
||||||
? missingFields
|
|
||||||
? '请先追问上述缺失信息,不要直接生成申请单核对表,也不要替用户默认填写。'
|
|
||||||
: '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
|
|
||||||
: missingFields
|
|
||||||
? '请先追问上述缺失信息,不要直接生成报销核对结果,也不要替用户默认填写。'
|
|
||||||
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
|
|
||||||
]
|
]
|
||||||
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
|
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
|
||||||
if (remainingTaskText) {
|
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,
|
resolveApplicationDaysFromDateRange,
|
||||||
refreshApplicationPreviewTransportEstimate
|
refreshApplicationPreviewTransportEstimate
|
||||||
} from '../../utils/expenseApplicationPreview.js'
|
} from '../../utils/expenseApplicationPreview.js'
|
||||||
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
|
|
||||||
import {
|
import {
|
||||||
buildWorkbenchDateLabel,
|
buildWorkbenchDateLabel,
|
||||||
canApplyWorkbenchDateSelection,
|
canApplyWorkbenchDateSelection,
|
||||||
@@ -210,7 +209,6 @@ export function useApplicationPreviewEditor({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey)
|
const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey)
|
||||||
const transportMode = String(nextPreview.fields?.transportMode || '').trim()
|
|
||||||
message.applicationPreview = needRefreshEstimate
|
message.applicationPreview = needRefreshEstimate
|
||||||
? buildTransportEstimatePendingPreview(nextPreview)
|
? buildTransportEstimatePendingPreview(nextPreview)
|
||||||
: nextPreview
|
: nextPreview
|
||||||
@@ -218,13 +216,6 @@ export function useApplicationPreviewEditor({
|
|||||||
cancelApplicationPreviewEditor()
|
cancelApplicationPreviewEditor()
|
||||||
persistSessionState?.()
|
persistSessionState?.()
|
||||||
if (needRefreshEstimate) {
|
if (needRefreshEstimate) {
|
||||||
if (transportMode) {
|
|
||||||
await waitForMockApplicationTransportQuote({
|
|
||||||
transportMode,
|
|
||||||
location: nextPreview.fields.matchedCity || nextPreview.fields.location,
|
|
||||||
time: nextPreview.fields.time
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview)
|
const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview)
|
||||||
message.applicationPreview = refreshedPreview
|
message.applicationPreview = refreshedPreview
|
||||||
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
|
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import {
|
|||||||
buildStewardSuggestedActions,
|
buildStewardSuggestedActions,
|
||||||
normalizeStewardPlan
|
normalizeStewardPlan
|
||||||
} from './stewardPlanModel.js'
|
} from './stewardPlanModel.js'
|
||||||
|
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
|
||||||
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
|
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
|
||||||
|
|
||||||
const STEWARD_TYPEWRITER_INTERVAL_MS = 10
|
const STEWARD_TYPEWRITER_INTERVAL_MS = 10
|
||||||
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
|
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
|
||||||
const STEWARD_TYPEWRITER_CHUNK_SIZE = 4
|
|
||||||
const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5
|
const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5
|
||||||
|
|
||||||
export function useStewardPlanFlow({
|
export function useStewardPlanFlow({
|
||||||
@@ -176,7 +176,7 @@ export function useStewardPlanFlow({
|
|||||||
if (runId !== stewardTypewriterRunId) {
|
if (runId !== stewardTypewriterRunId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE)
|
index = resolveStewardTypewriterNextIndex(chars, index)
|
||||||
const message = messages.value.find((item) => item.id === messageId)
|
const message = messages.value.find((item) => item.id === messageId)
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return
|
return
|
||||||
@@ -188,9 +188,7 @@ export function useStewardPlanFlow({
|
|||||||
...normalizedPlan,
|
...normalizedPlan,
|
||||||
streamStatus: 'typing'
|
streamStatus: 'typing'
|
||||||
}
|
}
|
||||||
if (index % 4 === 0 || index === total) {
|
nextTick(scrollToBottom)
|
||||||
nextTick(scrollToBottom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = messages.value.find((item) => item.id === messageId)
|
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', {
|
startFlowStep('pre-submit-review', {
|
||||||
title: '自动检测与风险识别',
|
title: '自动检测与风险识别',
|
||||||
tool: 'ExpenseClaimService.submit_claim',
|
tool: 'ExpenseClaimService.submit_claim',
|
||||||
detail: '正在校验财务规则、风险规则和审批路径...'
|
detail: '正在校验基础规则、风险规则和审批路径...'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,7 +847,7 @@ export function useTravelReimbursementFlow({
|
|||||||
if (String(response.status || '').trim() === 'submitted') {
|
if (String(response.status || '').trim() === 'submitted') {
|
||||||
return isApplicationSessionActive()
|
return isApplicationSessionActive()
|
||||||
? '申请单提交成功'
|
? '申请单提交成功'
|
||||||
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
: `已完成基础规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||||
}
|
}
|
||||||
if (response.submission_blocked) {
|
if (response.submission_blocked) {
|
||||||
return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批'
|
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,
|
normalizeApplicationPreview,
|
||||||
normalizeTransportModeOption,
|
normalizeTransportModeOption,
|
||||||
resolveApplicationDateRange,
|
resolveApplicationDateRange,
|
||||||
|
shouldRequireApplicationModelReview,
|
||||||
shouldUseLocalApplicationPreview
|
shouldUseLocalApplicationPreview
|
||||||
} from '../../utils/expenseApplicationPreview.js'
|
} from '../../utils/expenseApplicationPreview.js'
|
||||||
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
|
|
||||||
import { fetchOntologyParse } from '../../services/ontology.js'
|
import { fetchOntologyParse } from '../../services/ontology.js'
|
||||||
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
||||||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||||||
@@ -29,11 +29,11 @@ import {
|
|||||||
handleBudgetCompileReportSubmit,
|
handleBudgetCompileReportSubmit,
|
||||||
shouldUseBudgetCompileReport
|
shouldUseBudgetCompileReport
|
||||||
} from './budgetAssistantReportModel.js'
|
} from './budgetAssistantReportModel.js'
|
||||||
|
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
|
||||||
|
|
||||||
const STEWARD_ASSISTANT_NAME = '小财管家'
|
const STEWARD_ASSISTANT_NAME = '小财管家'
|
||||||
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
|
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
|
||||||
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
|
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
|
||||||
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
|
|
||||||
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
|
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
|
||||||
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
||||||
|
|
||||||
@@ -44,6 +44,13 @@ const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
|
|||||||
reason: 'reason',
|
reason: 'reason',
|
||||||
amount: 'amount',
|
amount: 'amount',
|
||||||
transportMode: 'transport_mode',
|
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',
|
department: 'department_name',
|
||||||
applicant: 'employee_name',
|
applicant: 'employee_name',
|
||||||
grade: 'employee_grade'
|
grade: 'employee_grade'
|
||||||
@@ -75,6 +82,13 @@ const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
|
|||||||
reason: 'reason',
|
reason: 'reason',
|
||||||
amount: 'amount',
|
amount: 'amount',
|
||||||
transport_mode: 'transportMode',
|
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',
|
department_name: 'department',
|
||||||
employee_name: 'applicant',
|
employee_name: 'applicant',
|
||||||
employee_grade: 'grade'
|
employee_grade: 'grade'
|
||||||
@@ -87,6 +101,13 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
|
|||||||
reason: '事由',
|
reason: '事由',
|
||||||
amount: '金额',
|
amount: '金额',
|
||||||
transport_mode: '出行方式',
|
transport_mode: '出行方式',
|
||||||
|
transport_estimated_amount: '交通费用预估',
|
||||||
|
train_estimated_amount: '火车费用预估',
|
||||||
|
flight_estimated_amount: '飞机费用预估',
|
||||||
|
hotel_amount: '住宿测算金额',
|
||||||
|
allowance_amount: '出差补贴金额',
|
||||||
|
policy_total_amount: '规则测算合计',
|
||||||
|
reimbursement_amount: '实际报销金额',
|
||||||
attachments: '附件/凭证',
|
attachments: '附件/凭证',
|
||||||
customer_name: '客户或项目对象',
|
customer_name: '客户或项目对象',
|
||||||
merchant_name: '商户/开票方',
|
merchant_name: '商户/开票方',
|
||||||
@@ -97,6 +118,13 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
|
|||||||
|
|
||||||
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
|
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
|
||||||
'amount',
|
'amount',
|
||||||
|
'transport_estimated_amount',
|
||||||
|
'train_estimated_amount',
|
||||||
|
'flight_estimated_amount',
|
||||||
|
'hotel_amount',
|
||||||
|
'allowance_amount',
|
||||||
|
'policy_total_amount',
|
||||||
|
'reimbursement_amount',
|
||||||
'attachments',
|
'attachments',
|
||||||
'employee_no',
|
'employee_no',
|
||||||
'department_name',
|
'department_name',
|
||||||
@@ -600,24 +628,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
|
|
||||||
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
|
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
|
||||||
const normalized = normalizeApplicationPreview(preview)
|
const normalized = normalizeApplicationPreview(preview)
|
||||||
const fields = normalized.fields || {}
|
|
||||||
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
|
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
|
||||||
if (!missingFields.length) {
|
if (!missingFields.length) {
|
||||||
return fallbackText
|
return fallbackText
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingFields.includes('出行方式')) {
|
|
||||||
return [
|
|
||||||
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
|
|
||||||
'',
|
|
||||||
'**原因是:还缺少“出行方式”。**',
|
|
||||||
'',
|
|
||||||
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
|
|
||||||
'',
|
|
||||||
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
|
|
||||||
].join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
|
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
|
||||||
'',
|
'',
|
||||||
@@ -710,13 +725,10 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
if (missingInfo) {
|
if (missingInfo) {
|
||||||
const transportMissing = /出行方式/.test(missingInfo)
|
|
||||||
events.push({
|
events.push({
|
||||||
eventId: `${eventPrefix}-gap`,
|
eventId: `${eventPrefix}-gap`,
|
||||||
title: '判断待补充信息',
|
title: '判断待补充信息',
|
||||||
content: transportMissing
|
content: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
|
||||||
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
|
|
||||||
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
events.push({
|
events.push({
|
||||||
@@ -809,13 +821,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
const chars = Array.from(text)
|
const chars = Array.from(text)
|
||||||
for (let index = 0; index < chars.length;) {
|
for (let index = 0; index < chars.length;) {
|
||||||
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
|
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.text = chars.slice(0, index).join('')
|
||||||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
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, {
|
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) {
|
function isSubmittedApplicationDraftPayload(draftPayload) {
|
||||||
return (
|
return (
|
||||||
String(draftPayload?.draft_type || '').trim() === 'expense_application'
|
isApplicationDraftPayload(draftPayload)
|
||||||
&& String(draftPayload?.status || '').trim() === 'submitted'
|
&& 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) {
|
function buildOperationFeedbackState(context) {
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return null
|
return null
|
||||||
@@ -1190,12 +1226,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
return preview
|
return preview
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const fields = preview?.fields || {}
|
|
||||||
await waitForMockApplicationTransportQuote({
|
|
||||||
transportMode: fields.transportMode,
|
|
||||||
location: fields.location,
|
|
||||||
time: fields.time
|
|
||||||
})
|
|
||||||
const result = await calculateTravelReimbursement(estimateRequest.payload)
|
const result = await calculateTravelReimbursement(estimateRequest.payload)
|
||||||
return applyApplicationPolicyEstimateResult(preview, result, user)
|
return applyApplicationPolicyEstimateResult(preview, result, user)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1204,7 +1234,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.skipModelReview) {
|
const requireModelReview = shouldRequireApplicationModelReview(rawText)
|
||||||
|
if (options.skipModelReview && !requireModelReview) {
|
||||||
return {
|
return {
|
||||||
applicationPreview: await enrichWithPolicyEstimate({
|
applicationPreview: await enrichWithPolicyEstimate({
|
||||||
...localPreview,
|
...localPreview,
|
||||||
@@ -2042,24 +2073,31 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
|
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
const assistantMessage = createMessage('assistant', resolveAssistantResultText(payload, fallbackAnswer), [], {
|
const exposeReviewPayload = shouldExposeReviewPayloadForMessage(payload, { isApplicationSubmitOperation })
|
||||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
const presentationPayload = buildPresentationPayload(payload, { exposeReviewPayload })
|
||||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
const presentationResult = presentationPayload?.result && typeof presentationPayload.result === 'object'
|
||||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
? presentationPayload.result
|
||||||
? payload.result.suggested_actions
|
: {}
|
||||||
: [],
|
const resultReviewPayload = presentationResult.review_payload || null
|
||||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
const resultSuggestedActions = exposeReviewPayload && Array.isArray(presentationResult.suggested_actions)
|
||||||
draftPayload: payload?.result?.draft_payload || null,
|
? presentationResult.suggested_actions
|
||||||
reviewPayload: payload?.result?.review_payload || null,
|
: []
|
||||||
|
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
|
reviewPanelScope: stewardDelegated
|
||||||
? ''
|
? ''
|
||||||
: resolveReviewPanelScope({
|
: resolveReviewPanelScope({
|
||||||
reviewPayload: payload?.result?.review_payload || null,
|
reviewPayload: resultReviewPayload,
|
||||||
reviewAction: reviewActionResult,
|
reviewAction: reviewActionResult,
|
||||||
fileCount: files.length,
|
fileCount: files.length,
|
||||||
rawText
|
rawText
|
||||||
}),
|
}),
|
||||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
|
riskFlags: Array.isArray(presentationResult.risk_flags) ? presentationResult.risk_flags : [],
|
||||||
operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
|
operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
|
||||||
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
|
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
|
||||||
stewardContinuation: options.stewardContinuation || null
|
stewardContinuation: options.stewardContinuation || null
|
||||||
@@ -2084,7 +2122,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
} else {
|
} else {
|
||||||
replaceMessage(pendingMessage.id, assistantMessage)
|
replaceMessage(pendingMessage.id, assistantMessage)
|
||||||
const nextInsight = buildAgentInsight(
|
const nextInsight = buildAgentInsight(
|
||||||
payload,
|
presentationPayload,
|
||||||
effectiveFileNames,
|
effectiveFileNames,
|
||||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
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,
|
normalizeTransportModeOption,
|
||||||
resolveApplicationDateRange,
|
resolveApplicationDateRange,
|
||||||
resolveApplicationTimeLabel,
|
resolveApplicationTimeLabel,
|
||||||
|
shouldRequireApplicationModelReview,
|
||||||
shouldUseLocalApplicationPreview
|
shouldUseLocalApplicationPreview
|
||||||
} from '../src/utils/expenseApplicationPreview.js'
|
} from '../src/utils/expenseApplicationPreview.js'
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
shouldUseBudgetCompileReport
|
shouldUseBudgetCompileReport
|
||||||
} from '../src/views/scripts/budgetAssistantReportModel.js'
|
} 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 { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
|
||||||
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.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)),
|
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||||
'utf8'
|
'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(
|
const stewardPlanFlowScript = readFileSync(
|
||||||
fileURLToPath(new URL('../src/views/scripts/useStewardPlanFlow.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/useStewardPlanFlow.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -131,7 +160,7 @@ function createFlowHarness() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('application intent uses local preview instead of immediate orchestrator call', () => {
|
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(
|
assert.equal(
|
||||||
shouldUseLocalApplicationPreview(prompt, {
|
shouldUseLocalApplicationPreview(prompt, {
|
||||||
sessionType: 'application',
|
sessionType: 'application',
|
||||||
@@ -150,6 +179,33 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
|||||||
}),
|
}),
|
||||||
false
|
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, {
|
const preview = buildLocalApplicationPreview(prompt, {
|
||||||
name: '李文静',
|
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.applicationType, '差旅费用申请')
|
||||||
assert.equal(preview.fields.time, '2026-05-20 至 2026-05-23')
|
assert.equal(preview.fields.time, '2026-05-20 至 2026-05-23')
|
||||||
assert.equal(preview.fields.location, '上海')
|
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.transportMode, '火车')
|
||||||
assert.equal(preview.fields.amount, '2358元')
|
assert.equal(preview.fields.amount, '2358元')
|
||||||
assert.equal(preview.fields.applicant, '李文静')
|
assert.equal(preview.fields.applicant, '李文静')
|
||||||
@@ -175,6 +231,33 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
|||||||
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
|
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', () => {
|
test('travel application submit can continue with conversational planning recommendation', () => {
|
||||||
const preview = normalizeApplicationPreview({
|
const preview = normalizeApplicationPreview({
|
||||||
fields: {
|
fields: {
|
||||||
@@ -273,12 +356,12 @@ test('application estimate builds deterministic mock transport amount and total'
|
|||||||
assert.equal(trainEstimate.amountDisplay, '1,040')
|
assert.equal(trainEstimate.amountDisplay, '1,040')
|
||||||
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
|
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
|
||||||
assert.equal(datedTrainEstimate.amountDisplay, '1,100')
|
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.equal(datedTrainEstimate.basisText, '预估交通费用 1,100元')
|
||||||
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
|
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
|
||||||
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
|
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
|
||||||
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
|
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(shipEstimate.amountDisplay, '1,040')
|
||||||
assert.equal(totalEstimate.transportAmountDisplay, '1,040')
|
assert.equal(totalEstimate.transportAmountDisplay, '1,040')
|
||||||
assert.equal(totalEstimate.totalAmountDisplay, '3,200')
|
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, /发生时间:/)
|
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', () => {
|
test('application preview keeps labeled reason in structured travel form', () => {
|
||||||
const preview = buildLocalApplicationPreview([
|
const preview = buildLocalApplicationPreview([
|
||||||
'发生时间:2026-02-20 至 2026-02-23',
|
'发生时间: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, '火车')
|
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 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, {
|
const localPreview = buildLocalApplicationPreview(rawText, {
|
||||||
name: '\u674e\u6587\u9759',
|
name: '\u674e\u6587\u9759',
|
||||||
@@ -421,10 +739,25 @@ test('application preview ignores model-only transport mode guesses', () => {
|
|||||||
|
|
||||||
assert.equal(localPreview.fields.transportMode, '')
|
assert.equal(localPreview.fields.transportMode, '')
|
||||||
assert.equal(refinedPreview.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)
|
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', () => {
|
test('application preview precomputes a date range from today when only days are provided', () => {
|
||||||
const preview = buildLocalApplicationPreview(
|
const preview = buildLocalApplicationPreview(
|
||||||
'去北京出差3天,支撑国网仿生产环境部署,飞机,预计费用12000元',
|
'去北京出差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', () => {
|
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 localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
|
||||||
const fallbackPreview = buildModelRefinedApplicationPreview(
|
const fallbackPreview = buildModelRefinedApplicationPreview(
|
||||||
localPreview,
|
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.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
|
||||||
assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/)
|
assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/)
|
||||||
assert.match(createViewScript, /useApplicationPreviewEditor/)
|
assert.match(createViewScript, /useApplicationPreviewEditor/)
|
||||||
assert.match(createViewScript, /message-bubble-application-preview/)
|
assert.match(messageActionsScript, /message-bubble-application-preview/)
|
||||||
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
|
assert.match(messageActionsScript, /buildApplicationPreviewFooterMessage/)
|
||||||
assert.match(createViewScript, /function buildApplicationPreviewFooterText\(message\)/)
|
assert.match(messageActionsScript, /function buildApplicationPreviewFooterText\(message\)/)
|
||||||
assert.match(createViewScript, /buildApplicationPreviewSubmitText/)
|
assert.match(stewardRuntimeScript, /buildApplicationPreviewSubmitText/)
|
||||||
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
|
assert.match(stewardRuntimeScript, /user_input_text: applicationSubmitText/)
|
||||||
assert.match(conversationModelScript, /applicationPreview: null/)
|
assert.match(conversationModelScript, /applicationPreview: null/)
|
||||||
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
|
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
|
||||||
assert.match(conversationModelScript, /\|\| message\.applicationPreview/)
|
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.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/)
|
||||||
assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
|
assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
|
||||||
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
|
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, /申请单据已生成/)
|
||||||
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
|
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
|
||||||
assert.match(messageItemTemplate, /报销草稿已生成/)
|
assert.match(messageItemTemplate, /报销草稿已生成/)
|
||||||
@@ -575,9 +908,9 @@ test('application session shows intent flow, persists preview, and supports inli
|
|||||||
assert.match(messageItemTemplate, /查看详情/)
|
assert.match(messageItemTemplate, /查看详情/)
|
||||||
assert.match(messageItemTemplate, /class="reimbursement-draft-pending-detail"/)
|
assert.match(messageItemTemplate, /class="reimbursement-draft-pending-detail"/)
|
||||||
assert.match(messageItemTemplate, /保存后可查看详情/)
|
assert.match(messageItemTemplate, /保存后可查看详情/)
|
||||||
assert.match(createViewScript, /function canOpenDraftDetail\(message\)/)
|
assert.match(messageActionsScript, /function canOpenDraftDetail\(message\)/)
|
||||||
assert.match(createViewScript, /canOpenDraftDetail,/)
|
assert.match(createViewScript, /canOpenDraftDetail,/)
|
||||||
assert.match(createViewScript, /保存后生成/)
|
assert.match(messageActionsScript, /保存后生成/)
|
||||||
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
|
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
|
||||||
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
|
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
|
||||||
assert.ok(
|
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, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
|
||||||
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
|
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
|
||||||
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
|
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
|
||||||
assert.match(createViewScript, /function shouldShowAssistantMessageActions/)
|
assert.match(messageActionsScript, /function shouldShowAssistantMessageActions/)
|
||||||
assert.match(createViewScript, /function buildMessageOperationFeedbackContext/)
|
assert.match(messageActionsScript, /function buildMessageOperationFeedbackContext/)
|
||||||
assert.match(createViewScript, /function isMessageFeedbackSelected/)
|
assert.match(messageActionsScript, /function isMessageFeedbackSelected/)
|
||||||
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
|
assert.match(messageActionsScript, /function submitOperationFeedbackForMessage/)
|
||||||
assert.match(createViewScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
|
assert.match(stewardRuntimeScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
|
||||||
assert.match(createViewScript, /stewardContinuation:\s*stewardSubmitContinuation/)
|
assert.match(stewardRuntimeScript, /stewardContinuation:\s*stewardSubmitContinuation/)
|
||||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
|
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
|
||||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
|
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
|
||||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
|
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/)
|
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 shouldPauseStewardApplicationPreview/)
|
||||||
assert.match(submitComposerScript, /function sanitizeStewardDelegatedTaskSummary/)
|
assert.match(submitComposerScript, /function sanitizeStewardDelegatedTaskSummary/)
|
||||||
assert.match(submitComposerScript, /交通方式和\(\?:预算\|预计\)\?金额待补充/)
|
assert.match(submitComposerScript, /交通方式和\(\?:预算\|预计\)\?金额待补充/)
|
||||||
assert.match(submitComposerScript, /出差费用预算/)
|
assert.match(submitComposerScript, /出差费用预算/)
|
||||||
assert.match(submitComposerScript, /预估\|预计\|预算\)\?费用/)
|
assert.match(submitComposerScript, /预估\|预计\|预算\)\?费用/)
|
||||||
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
|
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
|
||||||
|
assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
|
||||||
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
|
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
|
||||||
assert.match(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
assert.doesNotMatch(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||||
assert.doesNotMatch(submitComposerScript, /缺少“出行方式”[\s\S]{0,500}更新下方核对表/)
|
|
||||||
|
|
||||||
assert.match(createViewScript, /payload\.applicationPreview/)
|
assert.match(suggestedActionsScript, /payload\.applicationPreview/)
|
||||||
assert.match(createViewScript, /function continueStewardApplicationFieldCompletion/)
|
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)
|
||||||
assert.match(createViewScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
|
assert.match(suggestedActionsScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
|
||||||
assert.match(createViewScript, /skipUserMessage:\s*true/)
|
assert.match(suggestedActionsScript, /skipUserMessage:\s*true/)
|
||||||
assert.match(createViewScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
|
assert.match(suggestedActionsScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
|
||||||
assert.match(createViewScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
|
assert.match(suggestedActionsScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
|
||||||
assert.match(createViewScript, /commitApplicationPreviewEditor\(targetMessage\)/)
|
assert.match(suggestedActionsScript, /commitApplicationPreviewEditor\(targetMessage\)/)
|
||||||
assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/)
|
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', () => {
|
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, /地点:北京/)
|
assert.match(carryText, /地点:北京/)
|
||||||
assert.match(carryText, /天数:3天/)
|
assert.match(carryText, /天数:3天/)
|
||||||
assert.match(carryText, /请先根据已补齐字段模拟查询交通票据/)
|
assert.match(carryText, /请先根据已补齐字段按基础规则交通费用预估表/)
|
||||||
|
|
||||||
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
|
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
|
||||||
assert.equal(rebuiltPreview.fields.location, '北京')
|
assert.equal(rebuiltPreview.fields.location, '北京')
|
||||||
@@ -758,7 +1091,7 @@ test('budget compile report does not steal steward delegated application rerun',
|
|||||||
'用户已补充:出行方式:火车。',
|
'用户已补充:出行方式:火车。',
|
||||||
'地点:北京',
|
'地点:北京',
|
||||||
'天数:3天',
|
'天数:3天',
|
||||||
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。'
|
'处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
assert.equal(shouldUseBudgetCompileReport(stewardApplicationText, {
|
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', () => {
|
test('text confirmation submits pending application preview before replanning steward task', () => {
|
||||||
assert.match(stewardServiceScript, /fetchStewardRuntimeDecision/)
|
assert.match(stewardServiceScript, /fetchStewardRuntimeDecision/)
|
||||||
assert.match(stewardServiceScript, /\/steward\/runtime-decisions/)
|
assert.match(stewardServiceScript, /\/steward\/runtime-decisions/)
|
||||||
assert.match(createViewScript, /function buildStewardRuntimeState/)
|
assert.match(stewardRuntimeScript, /function buildStewardRuntimeState/)
|
||||||
assert.match(createViewScript, /function buildStewardRuntimeFastPathDecision/)
|
assert.match(stewardRuntimeScript, /function buildStewardRuntimeFastPathDecision/)
|
||||||
assert.match(createViewScript, /function shouldUseStewardRuntimeLlmDecision/)
|
assert.match(stewardRuntimeScript, /function shouldUseStewardRuntimeLlmDecision/)
|
||||||
assert.match(createViewScript, /function findPendingSlotSuggestedActionContextByInput/)
|
assert.match(stewardRuntimeScript, /function findPendingSlotSuggestedActionContextByInput/)
|
||||||
assert.match(createViewScript, /function shouldPlanNewStewardTasksLocally/)
|
assert.match(stewardRuntimeTextModelScript, /function shouldPlanNewStewardTasksLocally/)
|
||||||
assert.match(createViewScript, /function resolveStewardRuntimeTransportAlias/)
|
assert.match(stewardRuntimeTextModelScript, /function resolveStewardRuntimeTransportAlias/)
|
||||||
assert.match(createViewScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
|
assert.match(stewardRuntimeScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
|
||||||
assert.match(createViewScript, /actionTransportAlias === transportAlias/)
|
assert.match(stewardRuntimeScript, /actionTransportAlias === transportAlias/)
|
||||||
assert.match(createViewScript, /next_action:\s*'continue_next_task'/)
|
assert.match(stewardRuntimeScript, /next_action:\s*'continue_next_task'/)
|
||||||
assert.match(createViewScript, /next_action:\s*'submit_current_application'/)
|
assert.match(stewardRuntimeScript, /next_action:\s*'submit_current_application'/)
|
||||||
assert.match(createViewScript, /next_action:\s*'fill_current_slot'/)
|
assert.match(stewardRuntimeScript, /next_action:\s*'fill_current_slot'/)
|
||||||
assert.match(createViewScript, /next_action:\s*'plan_new_tasks'/)
|
assert.match(stewardRuntimeScript, /next_action:\s*'plan_new_tasks'/)
|
||||||
assert.match(createViewScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
|
assert.match(stewardRuntimeScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
|
||||||
assert.match(createViewScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
|
assert.match(suggestedActionsScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
|
||||||
assert.match(createViewScript, /skipApplicationModelReview:\s*true/)
|
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*true/)
|
||||||
assert.match(createViewScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||||
assert.match(createViewScript, /skipStewardSlotDecision:\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, /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 localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/)
|
||||||
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
|
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
|
||||||
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
|
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
|
||||||
assert.match(submitComposerScript, /const pendingSuggestedActions = Array\.isArray\(finalExtras\.suggestedActions\)/)
|
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(submitComposerScript, /message\.suggestedActions = pendingSuggestedActions[\s\S]*message\.stewardPlan = buildStewardDelegatedPlan\(continuation, \[\.\.\.typedEvents\], 'typing'\)/)
|
||||||
assert.match(createViewScript, /async function handleStewardRuntimeDecision/)
|
assert.match(stewardRuntimeScript, /async function handleStewardRuntimeDecision/)
|
||||||
assert.match(createViewScript, /const runtimeState = buildStewardRuntimeState\(\)/)
|
assert.match(stewardRuntimeScript, /const runtimeState = buildStewardRuntimeState\(\)/)
|
||||||
assert.match(createViewScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
|
assert.match(stewardRuntimeScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
|
||||||
assert.match(createViewScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
|
assert.match(stewardRuntimeScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
|
||||||
assert.match(createViewScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
|
assert.match(stewardRuntimeScript, /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(stewardRuntimeScript, /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(stewardRuntimeScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
|
||||||
assert.match(createViewScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
|
assert.match(stewardRuntimeScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
|
||||||
assert.match(createViewScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
|
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
|
||||||
assert.match(createViewScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
assert.match(stewardRuntimeScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
||||||
assert.match(createViewScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
|
assert.match(stewardRuntimeScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
|
||||||
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
|
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
|
||||||
assert.match(createViewScript, /function isApplicationSubmitConfirmationText/)
|
assert.match(stewardRuntimeTextModelScript, /function isApplicationSubmitConfirmationText/)
|
||||||
assert.match(createViewScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
|
assert.match(stewardRuntimeTextModelScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
|
||||||
assert.match(createViewScript, /function findPendingApplicationSubmitMessage/)
|
assert.match(stewardRuntimeScript, /function findPendingApplicationSubmitMessage/)
|
||||||
assert.match(createViewScript, /normalizedPreview\.readyToSubmit/)
|
assert.match(stewardRuntimeScript, /normalizedPreview\.readyToSubmit/)
|
||||||
assert.match(createViewScript, /async function handleApplicationSubmitConfirmationText/)
|
assert.match(stewardRuntimeScript, /async function handleApplicationSubmitConfirmationText/)
|
||||||
assert.match(createViewScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
|
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, /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(stewardRuntimeScript, /message\.applicationSubmitConfirmed = true/)
|
||||||
assert.match(createViewScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
|
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', () => {
|
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, /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(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, /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(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/)
|
||||||
assert.match(createViewScript, /STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4/)
|
assert.match(stewardFollowupFlowScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
|
||||||
assert.match(createViewScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
|
assert.match(stewardFollowupFlowScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
|
||||||
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE\)/)
|
assert.match(stewardFollowupFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
|
||||||
assert.match(createViewScript, /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', () => {
|
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.match(carryText, /费用类型:差旅/)
|
||||||
assert.doesNotMatch(carryText, /费用类型:travel/)
|
assert.doesNotMatch(carryText, /费用类型:travel/)
|
||||||
assert.match(carryText, /还需要补充:出行方式/)
|
assert.match(carryText, /还需要补充:出行方式/)
|
||||||
assert.match(carryText, /请先追问上述缺失信息/)
|
assert.doesNotMatch(carryText, /请先追问上述缺失信息/)
|
||||||
|
assert.doesNotMatch(carryText, /请直接生成申请单核对结果/)
|
||||||
|
assert.doesNotMatch(carryText, /入库或提交审批前/)
|
||||||
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
|
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
|
||||||
assert.doesNotMatch(carryText, /预计金额|附件\/凭证|员工编号|金额/)
|
assert.doesNotMatch(carryText, /预计金额|附件\/凭证|员工编号|金额/)
|
||||||
assert.equal(currentTask?.task_type, 'expense_application')
|
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, /fetchStewardApplicationSlotDecision/)
|
||||||
assert.match(submitComposerScript, /task_type:\s*'expense_application'/)
|
assert.match(submitComposerScript, /task_type:\s*'expense_application'/)
|
||||||
assert.match(submitComposerScript, /steward_continuation:\s*continuation/)
|
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', () => {
|
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\.question/)
|
||||||
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.rationale/)
|
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.rationale/)
|
||||||
assert.match(submitComposerScript, /normalizeTransportModeOption\(value \|\| label, ''\)/)
|
assert.match(submitComposerScript, /normalizeTransportModeOption\(value \|\| label, ''\)/)
|
||||||
assert.match(createViewScript, /normalizeTransportModeOption\(value, ''\)/)
|
assert.match(suggestedActionsScript, /normalizeTransportModeOption\(value, ''\)/)
|
||||||
assert.equal(normalizeTransportModeOption('高铁', ''), '火车')
|
assert.equal(normalizeTransportModeOption('高铁', ''), '火车')
|
||||||
assert.equal(normalizeTransportModeOption('自驾', ''), '')
|
assert.equal(normalizeTransportModeOption('自驾', ''), '')
|
||||||
assert.match(submitComposerScript, /function resolveBlockingApplicationMissingFieldsForSteward/)
|
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, /<th/)
|
||||||
assert.match(rendered, /<td/)
|
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\(\.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\(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;/)
|
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', () => {
|
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, /buildApplicationDateConflictMessage\(applicationDateConflict\)/)
|
||||||
assert.match(submitComposerScript, /meta: \[STEWARD_ASSISTANT_NAME, '申请日期冲突'\]/)
|
assert.match(submitComposerScript, /meta: \[STEWARD_ASSISTANT_NAME, '申请日期冲突'\]/)
|
||||||
assert.match(submitComposerScript, /applicationPreview: pauseForMissingFields \? null : applicationPreview/)
|
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', () => {
|
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: '李文静',
|
name: '李文静',
|
||||||
grade: 'P5'
|
grade: 'P5'
|
||||||
})
|
})
|
||||||
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5' })
|
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5' })
|
||||||
assert.equal(request.canCalculate, true)
|
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, {
|
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
|
||||||
days: 3,
|
days: 3,
|
||||||
@@ -1103,24 +1490,84 @@ test('application preview merges rule center travel estimate into highlighted ro
|
|||||||
hotel_amount: 1800,
|
hotel_amount: 1800,
|
||||||
total_allowance_rate: 120,
|
total_allowance_rate: 120,
|
||||||
allowance_amount: 360,
|
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_name: '公司差旅费报销规则',
|
||||||
rule_version: '2026版'
|
rule_version: '2026版'
|
||||||
}, { grade: 'P5' })
|
}, { grade: 'P5' })
|
||||||
|
|
||||||
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
|
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
|
||||||
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
|
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.doesNotMatch(estimatedPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
|
||||||
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
|
assert.match(estimatedPreview.fields.policyEstimate, /交通 720元/)
|
||||||
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
|
assert.match(estimatedPreview.fields.policyEstimate, /2,880元/)
|
||||||
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '1,100元')
|
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '720元')
|
||||||
assert.equal(estimatedPreview.fields.transportEstimateDate, '2026-05-25')
|
assert.equal(estimatedPreview.fields.transportEstimateSource, 'basic_rule_transport_estimate')
|
||||||
assert.match(estimatedPreview.fields.transportQueryLatencyMs, /^\d+ms$/)
|
assert.equal(estimatedPreview.fields.transportQueryLatencyMs, '')
|
||||||
assert.equal(estimatedPreview.fields.amount, '3,260元')
|
assert.equal(estimatedPreview.fields.amount, '2,880元')
|
||||||
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
|
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 () => {
|
test('application preview editor refreshes transport estimate after mode change', async () => {
|
||||||
const preview = applyApplicationPolicyEstimateResult(
|
const preview = applyApplicationPolicyEstimateResult(
|
||||||
buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天,服务项目部署', {
|
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(committed, true)
|
||||||
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
|
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
|
||||||
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '2,330元')
|
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '1,380元')
|
||||||
assert.equal(message.applicationPreview.fields.amount, '4,490元')
|
assert.equal(message.applicationPreview.fields.amount, '3,540元')
|
||||||
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 2,330元')
|
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 1,380元')
|
||||||
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
|
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
|
||||||
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
|
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
|
||||||
assert.ok(persistCount >= 2)
|
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)
|
const committed = await editor.commitApplicationPreviewDateEditor(message)
|
||||||
|
|
||||||
assert.equal(committed, true)
|
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.time, '2026-02-20 \u81f3 2026-02-23')
|
||||||
assert.equal(message.applicationPreview.fields.days, '4\u5929')
|
assert.equal(message.applicationPreview.fields.days, '4\u5929')
|
||||||
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\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)),
|
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||||
'utf8'
|
'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(
|
const messageItemStyles = readFileSync(
|
||||||
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-item.css', import.meta.url)),
|
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-item.css', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -40,21 +48,21 @@ test('expense application submit uses rich text link and confirm dialog', () =>
|
|||||||
/href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/
|
/href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/
|
||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
createViewScript,
|
stewardRuntimeScript,
|
||||||
/async function confirmApplicationSubmit\(\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/
|
/async function confirmApplicationSubmit\(options = \{\}\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/
|
||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
createViewScript,
|
stewardRuntimeScript,
|
||||||
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = await submitComposer/
|
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = await submitComposer/
|
||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
createViewScript,
|
stewardRuntimeScript,
|
||||||
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
|
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
|
||||||
)
|
)
|
||||||
assert.match(createViewScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
|
assert.match(stewardRuntimeScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
|
||||||
assert.match(createViewScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
|
assert.match(stewardRuntimeScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
|
||||||
assert.match(createViewScript, /meta:\s*\['行程规划推荐'\]/)
|
assert.match(stewardRuntimeScript, /meta:\s*\['行程规划推荐'\]/)
|
||||||
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
|
assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
|
||||||
assert.match(createViewScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
|
assert.match(suggestedActionsScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
|
||||||
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_SKIP/)
|
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)),
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementGuidedFlow.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const suggestedActionsScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
const guidedModelScript = readFileSync(
|
const guidedModelScript = readFileSync(
|
||||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementGuidedFlowModel.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementGuidedFlowModel.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -85,7 +89,7 @@ const submitComposerScript = readFileSync(
|
|||||||
test('assistant session modes expose independent quick actions', () => {
|
test('assistant session modes expose independent quick actions', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
|
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
|
||||||
['申请助手', '报销助手', '审核助手', '财务知识助手', '预算编制助手']
|
['小财管家', '申请助手', '报销助手', '审核助手', '财务知识助手', '预算编制助手']
|
||||||
)
|
)
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
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(guidedFlowScript, /handleSceneSelectionApplicationGate/)
|
||||||
assert.match(createViewScript, /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, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
||||||
assert.match(createViewScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
assert.match(suggestedActionsScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
||||||
assert.match(createViewScript, /actionPayload\.carry_text/)
|
assert.match(suggestedActionsScript, /actionPayload\.carry_text/)
|
||||||
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
|
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
|
||||||
assert.match(submitComposerScript, /skipScopeGuard/)
|
assert.match(submitComposerScript, /skipScopeGuard/)
|
||||||
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const createViewScript = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const reviewPanelModelScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
const messageItemTemplate = readFileSync(
|
const messageItemTemplate = readFileSync(
|
||||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -173,9 +177,9 @@ test('local transport review no longer uses the travel hotel template', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.equal(isTravelReviewPayload(reviewPayload, { expense_type: '交通费' }), false)
|
assert.equal(isTravelReviewPayload(reviewPayload, { expense_type: '交通费' }), false)
|
||||||
assert.match(createViewScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
|
assert.match(reviewPanelModelScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
|
||||||
assert.doesNotMatch(
|
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'/
|
/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="例如:出租车\/网约车票据 \/ 火车\/高铁票"/)
|
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', () => {
|
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.ok(riskItemsBlock, 'risk item builder should be present')
|
||||||
|
|
||||||
assert.doesNotMatch(createViewTemplate, /review-side-risk-score/)
|
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, /function buildReviewRiskScore/)
|
||||||
assert.doesNotMatch(createViewScript, /const reviewRiskScore/)
|
assert.doesNotMatch(createViewScript, /const reviewRiskScore/)
|
||||||
assert.doesNotMatch(riskItemsBlock[0], /\.slice\(0,\s*6\)/)
|
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(
|
assert.match(
|
||||||
createViewScript,
|
reviewPanelModelScript,
|
||||||
/function resolveReviewRiskBriefs\(reviewPayload\) \{[\s\S]*DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS\.some/
|
/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.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
|
||||||
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
|
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
|
||||||
assert.match(createViewScript, /info:\s*\{[\s\S]*label:\s*'提示'/)
|
assert.match(reviewPanelModelScript, /info:\s*\{[\s\S]*label:\s*'提示'/)
|
||||||
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
|
assert.match(reviewPanelModelScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
|
||||||
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
|
assert.match(reviewPanelModelScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
|
||||||
assert.match(createViewScript, /const isInfo = String\(item\?\.level \|\| ''\)\.trim\(\) === 'info'/)
|
assert.match(reviewPanelModelScript, /const isInfo = String\(item\?\.level \|\| ''\)\.trim\(\) === 'info'/)
|
||||||
assert.match(createViewScript, /\$\{isInfo \? '提示内容' : '风险点'\}:\$\{summary\}/)
|
assert.match(reviewPanelModelScript, /\$\{isInfo \? '提示内容' : '风险点'\}:\$\{summary\}/)
|
||||||
assert.match(createViewScript, /\$\{isInfo \? '处理建议' : '修改建议'\}:\$\{suggestion\}/)
|
assert.match(reviewPanelModelScript, /\$\{isInfo \? '处理建议' : '修改建议'\}:\$\{suggestion\}/)
|
||||||
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
|
assert.match(reviewPanelModelScript, /function normalizeReviewRiskTitle/)
|
||||||
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
|
assert.match(reviewPanelModelScript, /\.replace\(\/AI\\s\*预审/)
|
||||||
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
|
assert.match(reviewPanelModelScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
|
||||||
assert.match(createViewScript, /sourceLabel:\s*meta\.label/)
|
assert.match(reviewPanelModelScript, /sourceLabel:\s*meta\.label/)
|
||||||
assert.doesNotMatch(createViewScript, /normalizedTitle\.includes\('AI预审'\)/)
|
assert.doesNotMatch(reviewPanelModelScript, /normalizedTitle\.includes\('AI预审'\)/)
|
||||||
assert.match(createViewScript, /metaTone:\s*item\.level \|\| 'low'/)
|
assert.match(createViewScript, /metaTone:\s*item\.level \|\| 'low'/)
|
||||||
assert.doesNotMatch(createViewTemplate, /@click="openReviewRiskDetail\(item\)"/)
|
assert.doesNotMatch(createViewTemplate, /@click="openReviewRiskDetail\(item\)"/)
|
||||||
assert.doesNotMatch(createViewTemplate, /review-risk-detail-modal/)
|
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,
|
createViewScript,
|
||||||
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
|
/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 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, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
|
||||||
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
|
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 === 'documents' && hasDocuments[\s\S]*REVIEW_DRAWER_MODE_DOCUMENTS/)
|
||||||
assert.match(reviewDrawerScript, /scope === 'risk' && hasRisks[\s\S]*REVIEW_DRAWER_MODE_RISK/)
|
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(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, /canExposeReviewPanelScope\(item\.reviewPanelScope\)/)
|
||||||
assert.match(createViewScript, /currentInsight\.value\.intent === 'agent' && agent[\s\S]*return null/)
|
assert.match(createViewScript, /currentInsight\.value\.intent === 'agent' && agent[\s\S]*return null/)
|
||||||
assert.match(createViewScript, /function isTravelReviewPayload\(reviewPayload/)
|
assert.match(reviewPanelModelScript, /function isTravelReviewPayload\(reviewPayload/)
|
||||||
assert.match(createViewScript, /function resolveReviewTravelTransportType\(reviewPayload/)
|
assert.match(reviewPanelModelScript, /function resolveReviewTravelTransportType\(reviewPayload/)
|
||||||
assert.match(createViewScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
|
assert.match(reviewPanelModelScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
|
||||||
assert.match(createViewScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
|
assert.match(reviewPanelModelScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
|
||||||
assert.match(createViewScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
|
assert.match(reviewPanelModelScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
|
||||||
assert.match(createViewTemplate, /item\.editor === 'textarea'[\s\S]*<textarea/)
|
assert.match(createViewTemplate, /item\.editor === 'textarea'[\s\S]*<textarea/)
|
||||||
assert.match(createViewTemplate, /wide: item\.wide/)
|
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', () => {
|
test('continuing receipt upload preserves prior review form context', () => {
|
||||||
assert.match(createViewScript, /function buildReviewFormContextFromPayload\(reviewPayload, inlineState = null\)/)
|
assert.match(reviewPanelModelScript, /function buildReviewFormContextFromPayload\(reviewPayload, inlineState = null\)/)
|
||||||
assert.match(createViewScript, /function buildBusinessTimeContextFromReviewValues\(values = \{\}\)/)
|
assert.match(reviewPanelModelScript, /function buildBusinessTimeContextFromReviewValues\(values = \{\}\)/)
|
||||||
assert.match(
|
assert.match(
|
||||||
createViewScript,
|
createViewScript,
|
||||||
/resolvedUploadDisposition === 'continue_existing'[\s\S]*buildReviewFormContextFromPayload\([\s\S]*activeReviewPayload\.value[\s\S]*reviewInlineForm\.value[\s\S]*extraContext\.review_form_values/s
|
/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