Compare commits
4 Commits
1b04ee1c4c
...
bc743adef3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc743adef3 | ||
|
|
ded8b39ccb | ||
|
|
ba444a514f | ||
|
|
aa965da69d |
Binary file not shown.
@@ -58,6 +58,14 @@ class ExpenseClaim(Base):
|
|||||||
def employee_position(self) -> str | None:
|
def employee_position(self) -> str | None:
|
||||||
return str(self.employee.position).strip() if self.employee is not None and self.employee.position else None
|
return str(self.employee.position).strip() if self.employee is not None and self.employee.position else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def employee_no(self) -> str | None:
|
||||||
|
return str(self.employee.employee_no).strip() if self.employee is not None and self.employee.employee_no else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def employee_email(self) -> str | None:
|
||||||
|
return str(self.employee.email).strip() if self.employee is not None and self.employee.email else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def employee_grade(self) -> str | None:
|
def employee_grade(self) -> str | None:
|
||||||
return str(self.employee.grade).strip() if self.employee is not None and self.employee.grade else None
|
return str(self.employee.grade).strip() if self.employee is not None and self.employee.grade else None
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ class ExpenseClaimRead(BaseModel):
|
|||||||
claim_no: str
|
claim_no: str
|
||||||
employee_id: str | None
|
employee_id: str | None
|
||||||
employee_name: str
|
employee_name: str
|
||||||
|
employee_no: str | None = None
|
||||||
|
employee_email: str | None = None
|
||||||
department_id: str | None
|
department_id: str | None
|
||||||
department_name: str
|
department_name: str
|
||||||
employee_position: str | None = None
|
employee_position: str | None = None
|
||||||
|
|||||||
@@ -509,6 +509,20 @@ class ExpenseClaimAccessPolicy:
|
|||||||
if employee is not None:
|
if employee is not None:
|
||||||
return employee
|
return employee
|
||||||
|
|
||||||
|
for candidate in normalized_candidates:
|
||||||
|
if self.is_email_like(candidate):
|
||||||
|
continue
|
||||||
|
matches = list(
|
||||||
|
self.db.scalars(
|
||||||
|
select(Employee)
|
||||||
|
.options(*load_options)
|
||||||
|
.where(func.lower(Employee.email).like(f"{candidate.lower()}@%"))
|
||||||
|
.limit(2)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
for candidate in normalized_candidates:
|
for candidate in normalized_candidates:
|
||||||
matches = list(
|
matches = list(
|
||||||
self.db.scalars(
|
self.db.scalars(
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ def test_expense_claims_support_page_envelope_and_keep_legacy_list() -> None:
|
|||||||
assert payload["total_pages"] == 2
|
assert payload["total_pages"] == 2
|
||||||
assert payload["has_next"] is True
|
assert payload["has_next"] is True
|
||||||
assert payload["has_previous"] is False
|
assert payload["has_previous"] is False
|
||||||
|
assert payload["items"][0]["employee_no"] == "E-PAGE"
|
||||||
|
assert payload["items"][0]["employee_email"] == "page-user@example.com"
|
||||||
|
|
||||||
|
|
||||||
def test_employee_directory_supports_backend_pagination() -> None:
|
def test_employee_directory_supports_backend_pagination() -> None:
|
||||||
|
|||||||
@@ -4301,6 +4301,50 @@ def test_list_claims_scopes_to_current_user_id_even_when_names_duplicate() -> No
|
|||||||
assert claims[0].claim_no == "EXP-DUP-001"
|
assert claims[0].claim_no == "EXP-DUP-001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_claims_resolves_short_username_to_unique_employee_email_prefix() -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="caoxiaozhu",
|
||||||
|
name="caoxiaozhu",
|
||||||
|
role_codes=["employee"],
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E90919",
|
||||||
|
name="曹笑竹",
|
||||||
|
email="caoxiaozhu@xf.com",
|
||||||
|
)
|
||||||
|
db.add(employee)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="AP-SHORT-USERNAME-001",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name=employee.name,
|
||||||
|
department_name="研发部",
|
||||||
|
project_code="PRJ-APP",
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="客户现场实施",
|
||||||
|
location="北京",
|
||||||
|
amount=Decimal("1600.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 6, 23, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 6, 23, 10, 0, tzinfo=UTC),
|
||||||
|
status="approved",
|
||||||
|
approval_stage=APPROVAL_DONE_STAGE,
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
claims = ExpenseClaimService(db).list_claims(current_user)
|
||||||
|
|
||||||
|
assert len(claims) == 1
|
||||||
|
assert claims[0].claim_no == "AP-SHORT-USERNAME-001"
|
||||||
|
|
||||||
|
|
||||||
def test_list_claims_limits_finance_to_personal_records() -> None:
|
def test_list_claims_limits_finance_to_personal_records() -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="finance@example.com",
|
username="finance@example.com",
|
||||||
|
|||||||
@@ -207,7 +207,7 @@
|
|||||||
<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 }"
|
:class="{ 'application-preview-date-chip': ['time', 'time_return'].includes(row.key) && !row.missing }"
|
||||||
>{{ row.value }}</span>
|
>{{ row.value }}</span>
|
||||||
<button
|
<button
|
||||||
v-if="row.editable"
|
v-if="row.editable"
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
|
|
||||||
<Transition name="structured-card-reveal" appear>
|
<Transition name="structured-card-reveal" appear>
|
||||||
<div
|
<div
|
||||||
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
|
v-if="message.role === 'assistant' && !message.reviewPayload && (!message.queryPayload || message.queryPayload.selectionMode === 'reimbursement_application_association') && message.suggestedActions?.length"
|
||||||
class="message-suggested-actions"
|
class="message-suggested-actions"
|
||||||
:class="{ 'compact-guidance-actions': message.assistantVariant === 'compact_guidance' }"
|
:class="{ 'compact-guidance-actions': message.assistantVariant === 'compact_guidance' }"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
currentUser,
|
currentUser,
|
||||||
persistCurrentConversation,
|
persistCurrentConversation,
|
||||||
pushInlineUserMessage,
|
pushInlineUserMessage,
|
||||||
|
replaceInlineMessage,
|
||||||
removeWorkbenchDateTag,
|
removeWorkbenchDateTag,
|
||||||
resolveLatestInlineUserPrompt,
|
resolveLatestInlineUserPrompt,
|
||||||
scrollInlineConversationToBottom,
|
scrollInlineConversationToBottom,
|
||||||
@@ -565,6 +566,20 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
return files.length ? '请帮我处理已上传的附件。' : ''
|
return files.length ? '请帮我处理已上传的附件。' : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isReimbursementCreationIntent(prompt = '') {
|
||||||
|
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||||
|
if (!compact || !/报销|报账/.test(compact)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
/^(我要|我想|我需要|帮我|请帮我|需要|发起|新建|创建|提交|办理|走).{0,16}(报销|报账)/.test(compact) ||
|
||||||
|
/^(报销|报账)(一下|一笔|单|流程)?$/.test(compact)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function handleAiAnswerMarkdownClick(event) {
|
function handleAiAnswerMarkdownClick(event) {
|
||||||
const target = event?.target
|
const target = event?.target
|
||||||
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
|
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
|
||||||
@@ -600,6 +615,11 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isReimbursementCreationIntent(cleanPrompt)) {
|
||||||
|
void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || '我要报销')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
|
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
|
||||||
conversationId.value = ''
|
conversationId.value = ''
|
||||||
conversationMessages.value = []
|
conversationMessages.value = []
|
||||||
@@ -636,7 +656,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
|
|
||||||
function runAiModeAction(item) {
|
function runAiModeAction(item) {
|
||||||
if (String(item?.label || '').trim() === '发起报销') {
|
if (String(item?.label || '').trim() === '发起报销') {
|
||||||
expenseFlow.pushInlineExpenseSceneSelectionPrompt(item.prompt, item.label)
|
void expenseFlow.startAiReimbursementAssociationGate(item.prompt, item.label)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startInlineConversation(item.prompt, item, Array.from(selectedFiles.value))
|
startInlineConversation(item.prompt, item, Array.from(selectedFiles.value))
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
AI_ATTACHMENT_OCR_DETAIL_ACTION
|
AI_ATTACHMENT_OCR_DETAIL_ACTION
|
||||||
} from './workbenchAiMessageModel.js'
|
} from './workbenchAiMessageModel.js'
|
||||||
import { SESSION_TYPE_EXPENSE } from './useWorkbenchAiExpenseFlow.js'
|
import { SESSION_TYPE_EXPENSE } from './useWorkbenchAiExpenseFlow.js'
|
||||||
|
import {
|
||||||
|
SKIP_REQUIRED_APPLICATION_LINK_ACTION,
|
||||||
|
SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION
|
||||||
|
} from '../../views/scripts/travelReimbursementAssociationGateModel.js'
|
||||||
|
|
||||||
export function useWorkbenchAiActionRouter({
|
export function useWorkbenchAiActionRouter({
|
||||||
aiExpenseDraft,
|
aiExpenseDraft,
|
||||||
@@ -98,6 +102,23 @@ export function useWorkbenchAiActionRouter({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actionType === SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION) {
|
||||||
|
void expenseFlow.startAiReimbursementAssociationGate(
|
||||||
|
actionPayload.original_message || '我要报销',
|
||||||
|
action.label || '不用草稿,关联申请单新建报销单',
|
||||||
|
{ skipDraftCheck: true }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType === SKIP_REQUIRED_APPLICATION_LINK_ACTION) {
|
||||||
|
expenseFlow.pushInlineExpenseSceneSelectionPrompt(
|
||||||
|
actionPayload.original_message || '我要报销',
|
||||||
|
action.label || '单独新建报销单'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (actionType === 'ai_application_start_inline') {
|
if (actionType === 'ai_application_start_inline') {
|
||||||
aiExpenseDraft.value = null
|
aiExpenseDraft.value = null
|
||||||
void expenseFlow.startAiApplicationPreviewFromAction(action?.payload || {}, action?.label)
|
void expenseFlow.startAiApplicationPreviewFromAction(action?.payload || {}, action?.label)
|
||||||
@@ -109,7 +130,7 @@ export function useWorkbenchAiActionRouter({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (String(action?.payload?.session_type || '').trim() === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
|
if (String(action?.payload?.session_type || '').trim() === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
|
||||||
expenseFlow.pushInlineExpenseSceneSelectionPrompt(carryText, action.label)
|
void expenseFlow.startAiReimbursementAssociationGate(carryText, action.label)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startInlineConversation(carryText, {
|
startInlineConversation(carryText, {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldRefreshInlineApplicationPreviewEstimate(fieldKey = '') {
|
function shouldRefreshInlineApplicationPreviewEstimate(fieldKey = '') {
|
||||||
return ['transportMode', 'time', 'location', 'days'].includes(String(fieldKey || '').trim())
|
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(String(fieldKey || '').trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkbenchAiApplicationPreviewFlow({
|
export function useWorkbenchAiApplicationPreviewFlow({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||||
|
import { runOrchestrator } from '../../services/orchestrator.js'
|
||||||
import {
|
import {
|
||||||
applyAiExpenseAnswer,
|
applyAiExpenseAnswer,
|
||||||
buildAiExpenseStepPrompt,
|
buildAiExpenseStepPrompt,
|
||||||
@@ -15,11 +16,34 @@ import {
|
|||||||
buildRequiredApplicationActions,
|
buildRequiredApplicationActions,
|
||||||
buildRequiredApplicationMissingText,
|
buildRequiredApplicationMissingText,
|
||||||
buildRequiredApplicationSelectionText,
|
buildRequiredApplicationSelectionText,
|
||||||
filterRequiredApplicationCandidates
|
filterRequiredApplicationCandidates,
|
||||||
|
resolveRequiredApplicationReimbursementType
|
||||||
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||||
|
import {
|
||||||
|
buildReimbursementAssociationActions,
|
||||||
|
buildReimbursementAssociationMissingText,
|
||||||
|
buildReimbursementAssociationSubmitOptions,
|
||||||
|
buildReimbursementAssociationThinkingEvents,
|
||||||
|
buildReimbursementAssociationSelectionText,
|
||||||
|
buildReimbursementAssociationQueryFailedText,
|
||||||
|
buildReimbursementDraftActions,
|
||||||
|
buildReimbursementDraftSelectionText,
|
||||||
|
fetchReimbursementAssociationClaims,
|
||||||
|
filterReimbursementAssociationCandidates,
|
||||||
|
filterReimbursementDraftCandidates,
|
||||||
|
REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
|
||||||
|
} from '../../views/scripts/travelReimbursementAssociationGateModel.js'
|
||||||
|
|
||||||
export { SESSION_TYPE_EXPENSE }
|
export { SESSION_TYPE_EXPENSE }
|
||||||
|
|
||||||
|
const AI_REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS = 320
|
||||||
|
|
||||||
|
function waitForReimbursementAssociationStep() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
globalThis.setTimeout(resolve, AI_REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useWorkbenchAiExpenseFlow({
|
export function useWorkbenchAiExpenseFlow({
|
||||||
activateInlineConversation,
|
activateInlineConversation,
|
||||||
aiExpenseDraft,
|
aiExpenseDraft,
|
||||||
@@ -32,11 +56,35 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
currentUser,
|
currentUser,
|
||||||
persistCurrentConversation,
|
persistCurrentConversation,
|
||||||
pushInlineUserMessage,
|
pushInlineUserMessage,
|
||||||
|
replaceInlineMessage = (id, nextMessage) => {
|
||||||
|
const index = conversationMessages.value.findIndex((item) => item.id === id)
|
||||||
|
if (index === -1) {
|
||||||
|
conversationMessages.value.push(nextMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conversationMessages.value.splice(index, 1, nextMessage)
|
||||||
|
},
|
||||||
removeWorkbenchDateTag,
|
removeWorkbenchDateTag,
|
||||||
resolveLatestInlineUserPrompt,
|
resolveLatestInlineUserPrompt,
|
||||||
scrollInlineConversationToBottom,
|
scrollInlineConversationToBottom,
|
||||||
startAiApplicationPreview
|
startAiApplicationPreview,
|
||||||
|
fetchExpenseClaimsForAi = fetchExpenseClaims,
|
||||||
|
runOrchestratorForAi = runOrchestrator,
|
||||||
|
associationQueryTimeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
|
||||||
}) {
|
}) {
|
||||||
|
function replaceInlineAssistantMessage(messageId, content = '', options = {}) {
|
||||||
|
const nextMessage = createInlineMessage('assistant', content, {
|
||||||
|
id: messageId,
|
||||||
|
pending: Boolean(options.pending),
|
||||||
|
stewardPlan: options.stewardPlan || null,
|
||||||
|
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
|
||||||
|
draftPayload: options.draftPayload || null,
|
||||||
|
text: options.text || content
|
||||||
|
})
|
||||||
|
replaceInlineMessage(messageId, nextMessage)
|
||||||
|
return nextMessage
|
||||||
|
}
|
||||||
|
|
||||||
function pushInlineExpenseSceneSelectionPrompt(originalMessage, selectedLabel = '') {
|
function pushInlineExpenseSceneSelectionPrompt(originalMessage, selectedLabel = '') {
|
||||||
const sourceText = String(originalMessage || '我要报销').trim()
|
const sourceText = String(originalMessage || '我要报销').trim()
|
||||||
if (!conversationStarted.value) {
|
if (!conversationStarted.value) {
|
||||||
@@ -65,6 +113,116 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startAiReimbursementAssociationGate(originalMessage = '我要报销', selectedLabel = '', options = {}) {
|
||||||
|
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
|
||||||
|
if (!conversationStarted.value) {
|
||||||
|
activateInlineConversation({
|
||||||
|
title: String(selectedLabel || sourceText || '报销').trim().slice(0, 18) || '报销'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
assistantDraft.value = ''
|
||||||
|
removeWorkbenchDateTag()
|
||||||
|
closeWorkbenchDatePicker()
|
||||||
|
clearAiModeFiles()
|
||||||
|
aiExpenseDraft.value = null
|
||||||
|
pushInlineUserMessage(String(selectedLabel || sourceText).trim())
|
||||||
|
const pendingMessage = createInlineMessage('assistant', '', {
|
||||||
|
pending: true,
|
||||||
|
stewardPlan: {
|
||||||
|
streamStatus: 'streaming',
|
||||||
|
thinkingEvents: buildReimbursementAssociationThinkingEvents('intent')
|
||||||
|
},
|
||||||
|
suggestedActions: []
|
||||||
|
})
|
||||||
|
conversationMessages.value.push(pendingMessage)
|
||||||
|
const pendingMessageId = pendingMessage.id
|
||||||
|
persistCurrentConversation()
|
||||||
|
scrollInlineConversationToBottom()
|
||||||
|
await waitForReimbursementAssociationStep()
|
||||||
|
|
||||||
|
replaceInlineAssistantMessage(pendingMessageId, '', {
|
||||||
|
pending: true,
|
||||||
|
stewardPlan: {
|
||||||
|
streamStatus: 'streaming',
|
||||||
|
thinkingEvents: buildReimbursementAssociationThinkingEvents('query')
|
||||||
|
},
|
||||||
|
suggestedActions: []
|
||||||
|
})
|
||||||
|
scrollInlineConversationToBottom()
|
||||||
|
|
||||||
|
let claims = null
|
||||||
|
try {
|
||||||
|
claims = await fetchReimbursementAssociationClaims({
|
||||||
|
fetchExpenseClaims: fetchExpenseClaimsForAi,
|
||||||
|
timeoutMs: associationQueryTimeoutMs
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
replaceInlineAssistantMessage(pendingMessageId, buildReimbursementAssociationQueryFailedText(error), {
|
||||||
|
stewardPlan: {
|
||||||
|
streamStatus: 'failed',
|
||||||
|
thinkingEvents: buildReimbursementAssociationThinkingEvents('failed')
|
||||||
|
},
|
||||||
|
suggestedActions: buildReimbursementAssociationActions([], sourceText)
|
||||||
|
})
|
||||||
|
persistCurrentConversation()
|
||||||
|
scrollInlineConversationToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftCandidates = options.skipDraftCheck
|
||||||
|
? []
|
||||||
|
: filterReimbursementDraftCandidates(claims, currentUser.value || {})
|
||||||
|
if (draftCandidates.length) {
|
||||||
|
replaceInlineAssistantMessage(pendingMessageId, '', {
|
||||||
|
pending: true,
|
||||||
|
stewardPlan: {
|
||||||
|
streamStatus: 'streaming',
|
||||||
|
thinkingEvents: buildReimbursementAssociationThinkingEvents('filter', { candidateCount: draftCandidates.length })
|
||||||
|
},
|
||||||
|
suggestedActions: []
|
||||||
|
})
|
||||||
|
scrollInlineConversationToBottom()
|
||||||
|
await waitForReimbursementAssociationStep()
|
||||||
|
|
||||||
|
const content = buildReimbursementDraftSelectionText(draftCandidates)
|
||||||
|
replaceInlineAssistantMessage(pendingMessageId, content, {
|
||||||
|
stewardPlan: {
|
||||||
|
streamStatus: 'completed',
|
||||||
|
thinkingEvents: buildReimbursementAssociationThinkingEvents('completed', { candidateCount: draftCandidates.length })
|
||||||
|
},
|
||||||
|
suggestedActions: buildReimbursementDraftActions(draftCandidates, sourceText)
|
||||||
|
})
|
||||||
|
persistCurrentConversation()
|
||||||
|
scrollInlineConversationToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = filterReimbursementAssociationCandidates(claims, currentUser.value || {})
|
||||||
|
replaceInlineAssistantMessage(pendingMessageId, '', {
|
||||||
|
pending: true,
|
||||||
|
stewardPlan: {
|
||||||
|
streamStatus: 'streaming',
|
||||||
|
thinkingEvents: buildReimbursementAssociationThinkingEvents('filter', { candidateCount: candidates.length })
|
||||||
|
},
|
||||||
|
suggestedActions: []
|
||||||
|
})
|
||||||
|
scrollInlineConversationToBottom()
|
||||||
|
await waitForReimbursementAssociationStep()
|
||||||
|
|
||||||
|
const content = candidates.length
|
||||||
|
? buildReimbursementAssociationSelectionText(candidates)
|
||||||
|
: buildReimbursementAssociationMissingText()
|
||||||
|
replaceInlineAssistantMessage(pendingMessageId, content, {
|
||||||
|
stewardPlan: {
|
||||||
|
streamStatus: 'completed',
|
||||||
|
thinkingEvents: buildReimbursementAssociationThinkingEvents('completed', { candidateCount: candidates.length })
|
||||||
|
},
|
||||||
|
suggestedActions: buildReimbursementAssociationActions(candidates, sourceText)
|
||||||
|
})
|
||||||
|
persistCurrentConversation()
|
||||||
|
scrollInlineConversationToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) {
|
function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) {
|
||||||
if (!conversationStarted.value) {
|
if (!conversationStarted.value) {
|
||||||
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
|
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
|
||||||
@@ -109,7 +267,7 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
|
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
|
||||||
let claims = null
|
let claims = null
|
||||||
try {
|
try {
|
||||||
claims = await fetchExpenseClaims()
|
claims = await fetchExpenseClaimsForAi()
|
||||||
} catch {
|
} catch {
|
||||||
aiExpenseDraft.value = null
|
aiExpenseDraft.value = null
|
||||||
conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。'))
|
conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。'))
|
||||||
@@ -146,11 +304,47 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
scrollInlineConversationToBottom()
|
scrollInlineConversationToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkAiExpenseApplication(application = {}) {
|
function buildWorkbenchUserContext() {
|
||||||
const draft = aiExpenseDraft.value
|
const user = currentUser.value || {}
|
||||||
if (!draft) {
|
return {
|
||||||
return
|
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
|
||||||
|
is_admin: Boolean(user.isAdmin),
|
||||||
|
name: user.name || '',
|
||||||
|
role: user.role || '',
|
||||||
|
department: user.department || user.departmentName || '',
|
||||||
|
department_name: user.department || user.departmentName || '',
|
||||||
|
position: user.position || '',
|
||||||
|
employee_position: user.position || user.employeePosition || user.employee_position || '',
|
||||||
|
employee_no: user.employeeNo || user.employee_no || '',
|
||||||
|
employeeNo: user.employeeNo || user.employee_no || '',
|
||||||
|
session_type: SESSION_TYPE_EXPENSE,
|
||||||
|
entry_source: 'workbench-ai'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinkedDraftAction(draftPayload = {}) {
|
||||||
|
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|
||||||
|
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|
||||||
|
if (!claimNo && !claimId) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
label: '查看报销草稿',
|
||||||
|
description: '打开草稿详情继续上传票据或补充信息。',
|
||||||
|
icon: 'mdi mdi-file-document-outline',
|
||||||
|
action_type: 'open_application_detail',
|
||||||
|
payload: {
|
||||||
|
claim_id: claimId,
|
||||||
|
claim_no: claimNo
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkAiExpenseApplication(application = {}) {
|
||||||
|
const draft = aiExpenseDraft.value || (() => {
|
||||||
|
const resolved = resolveRequiredApplicationReimbursementType(application)
|
||||||
|
return createAiExpenseDraft(resolved.expenseType, resolved.expenseTypeLabel)
|
||||||
|
})()
|
||||||
const claimNo = String(application.application_claim_no || '').trim()
|
const claimNo = String(application.application_claim_no || '').trim()
|
||||||
pushInlineUserMessage(`关联申请单 ${claimNo}`.trim())
|
pushInlineUserMessage(`关联申请单 ${claimNo}`.trim())
|
||||||
|
|
||||||
@@ -167,13 +361,60 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
stepKey: 'attachments'
|
stepKey: 'attachments'
|
||||||
}
|
}
|
||||||
aiExpenseDraft.value = linked
|
aiExpenseDraft.value = linked
|
||||||
conversationMessages.value.push(createInlineMessage('assistant', [
|
const pendingMessage = createInlineMessage('assistant', `已关联申请单${claimNo ? ` ${claimNo}` : ''},正在生成报销草稿...`, {
|
||||||
`已关联申请单${claimNo ? ` ${claimNo}` : ''},事由、时间、地点、金额我先用申请单的内容预填了。`,
|
pending: true,
|
||||||
'',
|
suggestedActions: []
|
||||||
'再确认一下票据:可以现在上传,或回复“稍后上传”。'
|
})
|
||||||
].join('\n')))
|
conversationMessages.value.push(pendingMessage)
|
||||||
|
const pendingMessageId = pendingMessage.id
|
||||||
persistCurrentConversation()
|
persistCurrentConversation()
|
||||||
scrollInlineConversationToBottom()
|
scrollInlineConversationToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const submitOptions = buildReimbursementAssociationSubmitOptions(
|
||||||
|
application,
|
||||||
|
application.original_message || resolveLatestInlineUserPrompt() || '我要报销'
|
||||||
|
)
|
||||||
|
const user = currentUser.value || {}
|
||||||
|
const payload = await runOrchestratorForAi(
|
||||||
|
{
|
||||||
|
source: 'user_message',
|
||||||
|
user_id: user.username || user.name || 'anonymous',
|
||||||
|
conversation_id: null,
|
||||||
|
message: submitOptions.rawText,
|
||||||
|
context_json: {
|
||||||
|
...buildWorkbenchUserContext(),
|
||||||
|
...submitOptions.extraContext
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeoutMs: 120000,
|
||||||
|
timeoutMessage: '生成报销草稿超时,请稍后重试。'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const draftPayload = payload?.result?.draft_payload || null
|
||||||
|
const draftClaimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
|
||||||
|
const content = draftClaimNo
|
||||||
|
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||||
|
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||||
|
replaceInlineAssistantMessage(pendingMessageId, content, {
|
||||||
|
draftPayload,
|
||||||
|
suggestedActions: buildLinkedDraftAction(draftPayload)
|
||||||
|
})
|
||||||
|
aiExpenseDraft.value = null
|
||||||
|
persistCurrentConversation()
|
||||||
|
scrollInlineConversationToBottom()
|
||||||
|
} catch {
|
||||||
|
replaceInlineAssistantMessage(
|
||||||
|
pendingMessageId,
|
||||||
|
'生成报销草稿时出现异常。申请单关联信息我先保留在当前会话里,你可以稍后重试或单独新建报销单。',
|
||||||
|
{
|
||||||
|
suggestedActions: []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
persistCurrentConversation()
|
||||||
|
scrollInlineConversationToBottom()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -181,6 +422,7 @@ export function useWorkbenchAiExpenseFlow({
|
|||||||
linkAiExpenseApplication,
|
linkAiExpenseApplication,
|
||||||
pushInlineExpenseSceneSelectionPrompt,
|
pushInlineExpenseSceneSelectionPrompt,
|
||||||
startAiApplicationPreviewFromAction,
|
startAiApplicationPreviewFromAction,
|
||||||
|
startAiReimbursementAssociationGate,
|
||||||
startAiExpenseDraft
|
startAiExpenseDraft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,24 +72,24 @@ async function fetchAllExpenseClaimPages(fetchPage, params = {}) {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchExpenseClaims(params = {}) {
|
export function fetchExpenseClaims(params = {}, options = {}) {
|
||||||
return apiRequest(`/reimbursements/claims${buildListQuery(params)}`)
|
return apiRequest(`/reimbursements/claims${buildListQuery(params)}`, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAllExpenseClaims(params = {}) {
|
export function fetchAllExpenseClaims(params = {}) {
|
||||||
return fetchAllExpenseClaimPages(fetchExpenseClaims, params)
|
return fetchAllExpenseClaimPages(fetchExpenseClaims, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchApprovalExpenseClaims(params = {}) {
|
export function fetchApprovalExpenseClaims(params = {}, options = {}) {
|
||||||
return apiRequest(`/reimbursements/claims/approvals${buildListQuery(params)}`)
|
return apiRequest(`/reimbursements/claims/approvals${buildListQuery(params)}`, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAllApprovalExpenseClaims(params = {}) {
|
export function fetchAllApprovalExpenseClaims(params = {}) {
|
||||||
return fetchAllExpenseClaimPages(fetchApprovalExpenseClaims, params)
|
return fetchAllExpenseClaimPages(fetchApprovalExpenseClaims, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchArchivedExpenseClaims(params = {}) {
|
export function fetchArchivedExpenseClaims(params = {}, options = {}) {
|
||||||
return apiRequest(`/reimbursements/claims/archives${buildListQuery(params)}`)
|
return apiRequest(`/reimbursements/claims/archives${buildListQuery(params)}`, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAllArchivedExpenseClaims(params = {}) {
|
export function fetchAllArchivedExpenseClaims(params = {}) {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export {
|
|||||||
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
|
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
|
||||||
const normalized = normalizeApplicationPreview(preview)
|
const normalized = normalizeApplicationPreview(preview)
|
||||||
const fields = normalized.fields || {}
|
const fields = normalized.fields || {}
|
||||||
const days = parseApplicationDaysValue(fields.days)
|
const days = parseApplicationDaysValue(fields.days) || parseApplicationDaysValue(resolveDaysFromDateRange(fields.time))
|
||||||
const location = String(fields.location || '').trim()
|
const location = String(fields.location || '').trim()
|
||||||
const grade = String(fields.grade || resolveCurrentUserGrade(currentUser)).trim()
|
const grade = String(fields.grade || resolveCurrentUserGrade(currentUser)).trim()
|
||||||
const applicationType = String(fields.applicationType || '').trim()
|
const applicationType = String(fields.applicationType || '').trim()
|
||||||
@@ -112,6 +112,7 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
|
|||||||
fields: {
|
fields: {
|
||||||
...fields,
|
...fields,
|
||||||
grade,
|
grade,
|
||||||
|
days: parseApplicationDaysValue(fields.days) ? fields.days : `${days}天`,
|
||||||
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
|
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
|
||||||
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
|
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
|
||||||
transportPolicy: APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT,
|
transportPolicy: APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT,
|
||||||
@@ -174,6 +175,7 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
|
|||||||
fields: {
|
fields: {
|
||||||
...fields,
|
...fields,
|
||||||
grade,
|
grade,
|
||||||
|
days: parseApplicationDaysValue(fields.days) ? fields.days : `${days}天`,
|
||||||
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
|
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
|
||||||
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
|
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
|
||||||
transportPolicy: buildTransportPolicyText(fields.transportMode, matchedCity || fields.location, transportEstimate, fields.time),
|
transportPolicy: buildTransportPolicyText(fields.transportMode, matchedCity || fields.location, transportEstimate, fields.time),
|
||||||
@@ -388,7 +390,7 @@ export function buildApplicationPreviewRows(preview = {}) {
|
|||||||
key: 'time_return',
|
key: 'time_return',
|
||||||
label: '返回时间',
|
label: '返回时间',
|
||||||
value: tripDates.endDate || '待补充',
|
value: tripDates.endDate || '待补充',
|
||||||
editable: false,
|
editable: item.editable !== false,
|
||||||
highlight: Boolean(item.highlight),
|
highlight: Boolean(item.highlight),
|
||||||
missing
|
missing
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { continueStewardApplicationFieldCompletion, handleSuggestedAction, isSuggestedActionSelected, runShortcut } = useTravelReimbursementSuggestedActions({
|
const { continueStewardApplicationFieldCompletion, handleSuggestedAction, isSuggestedActionSelected, runShortcut } = useTravelReimbursementSuggestedActions({
|
||||||
applicationPreviewEditor, attachedFiles, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, commitApplicationPreviewEditor, composerDraft, composerFilesExpanded, composerTextareaRef, createMessage, currentUser, emit, handleGuidedShortcut, handleGuidedSuggestedAction, handleSceneSelectionApplicationGate, lockSuggestedActionMessage, mergeFilesWithLimit, messages, nextTick, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, persistSessionState, resolveApplicationPreviewMissingFields, reviewActionBusy, router, scrollToBottom, sessionSwitchBusy, startExpenseSceneSelectionAfterIntentConfirmation, submitComposer, submitComposerInternal, submitting, switchSessionType, toast, adjustComposerTextareaHeight
|
applicationPreviewEditor, attachedFiles, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, commitApplicationPreviewEditor, composerDraft, composerFilesExpanded, composerTextareaRef, createMessage, currentUser, emit, fetchExpenseClaims, handleGuidedShortcut, handleGuidedSuggestedAction, handleSceneSelectionApplicationGate, lockSuggestedActionMessage, mergeFilesWithLimit, messages, nextTick, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, persistSessionState, resolveApplicationPreviewMissingFields, reviewActionBusy, router, scrollToBottom, sessionSwitchBusy, startExpenseSceneSelectionAfterIntentConfirmation, submitComposer, submitComposerInternal, submitting, switchSessionType, toast, adjustComposerTextareaHeight
|
||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
canShowTravelCalculator,
|
canShowTravelCalculator,
|
||||||
|
|||||||
@@ -258,6 +258,51 @@ function resolveCandidateFlowExpenseType(flow = {}) {
|
|||||||
return rawType
|
return rawType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStewardExpenseTypeCode(value = '') {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (text === '差旅' || text === '差旅费' || text === 'travel') {
|
||||||
|
return 'travel'
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTaskExpenseType(task = null) {
|
||||||
|
const fields = task?.ontologyFields || task?.ontology_fields || {}
|
||||||
|
const explicitType = normalizeStewardExpenseTypeCode(
|
||||||
|
fields.expense_type ||
|
||||||
|
fields.expenseType ||
|
||||||
|
fields.application_type ||
|
||||||
|
fields.applicationType ||
|
||||||
|
''
|
||||||
|
)
|
||||||
|
if (explicitType) {
|
||||||
|
return explicitType
|
||||||
|
}
|
||||||
|
const taskText = [
|
||||||
|
task?.title,
|
||||||
|
task?.summary,
|
||||||
|
fields.reason,
|
||||||
|
fields.location
|
||||||
|
].map((item) => String(item || '').trim()).join(' ')
|
||||||
|
return /差旅|出差/.test(taskText) ? 'travel' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStewardApplicationPreviewRoutePayload(actionType, task = null) {
|
||||||
|
if (actionType !== 'confirm_create_application') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const expenseType = resolveTaskExpenseType(task)
|
||||||
|
if (expenseType !== 'travel') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
steward_confirm_flow: true,
|
||||||
|
flow_id: 'travel_application',
|
||||||
|
expense_type: expenseType,
|
||||||
|
expense_type_label: FLOW_EXPENSE_TYPE_LABELS[expenseType] || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStewardSuggestedActions(plan) {
|
export function buildStewardSuggestedActions(plan) {
|
||||||
const normalized = normalizeStewardPlan(plan)
|
const normalized = normalizeStewardPlan(plan)
|
||||||
if (isOffTopicPlan(normalized)) {
|
if (isOffTopicPlan(normalized)) {
|
||||||
@@ -320,6 +365,7 @@ export function buildStewardSuggestedActions(plan) {
|
|||||||
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||||
payload: {
|
payload: {
|
||||||
session_type: targetSessionType,
|
session_type: targetSessionType,
|
||||||
|
...buildStewardApplicationPreviewRoutePayload(actionType, task),
|
||||||
carry_text: buildStewardCarryText(actionType, task, group, normalized),
|
carry_text: buildStewardCarryText(actionType, task, group, normalized),
|
||||||
carry_files: actionType !== 'confirm_create_application',
|
carry_files: actionType !== 'confirm_create_application',
|
||||||
auto_submit: true,
|
auto_submit: true,
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ function uniqueValues(values) {
|
|||||||
return Array.from(new Set((Array.isArray(values) ? values : []).map(normalizeText).filter(Boolean)))
|
return Array.from(new Set((Array.isArray(values) ? values : []).map(normalizeText).filter(Boolean)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expandIdentityValues(values) {
|
||||||
|
const expanded = []
|
||||||
|
;(Array.isArray(values) ? values : []).forEach((value) => {
|
||||||
|
const normalized = normalizeText(value)
|
||||||
|
if (!normalized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expanded.push(normalized)
|
||||||
|
const atIndex = normalized.indexOf('@')
|
||||||
|
if (atIndex > 0) {
|
||||||
|
expanded.push(normalized.slice(0, atIndex))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return uniqueValues(expanded)
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeClaimNo(claim) {
|
function normalizeClaimNo(claim) {
|
||||||
return normalizeText(claim?.claim_no || claim?.claimNo).toUpperCase()
|
return normalizeText(claim?.claim_no || claim?.claimNo).toUpperCase()
|
||||||
}
|
}
|
||||||
@@ -205,7 +221,7 @@ function hasAnyApplicationReference(index) {
|
|||||||
return Boolean(index?.ids?.size || index?.claimNos?.size)
|
return Boolean(index?.ids?.size || index?.claimNos?.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLinkedApplicationReferenceIndex(claims) {
|
export function buildLinkedApplicationReferenceIndex(claims) {
|
||||||
const index = createReferenceIndex()
|
const index = createReferenceIndex()
|
||||||
;(Array.isArray(claims) ? claims : []).forEach((claim) => {
|
;(Array.isArray(claims) ? claims : []).forEach((claim) => {
|
||||||
if (isExpenseApplicationClaim(claim)) {
|
if (isExpenseApplicationClaim(claim)) {
|
||||||
@@ -297,6 +313,58 @@ export function getRequiredApplicationExpenseLabel(expenseType) {
|
|||||||
return EXPENSE_TYPE_LABELS[normalizeLower(expenseType)] || '报销'
|
return EXPENSE_TYPE_LABELS[normalizeLower(expenseType)] || '报销'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveRequiredApplicationReimbursementType(application = {}) {
|
||||||
|
const expenseType = normalizeLower(
|
||||||
|
application.application_expense_type
|
||||||
|
|| application.expense_type
|
||||||
|
|| application.expenseType
|
||||||
|
|| application.type_code
|
||||||
|
|| application.typeCode
|
||||||
|
)
|
||||||
|
const source = {
|
||||||
|
expense_type: expenseType,
|
||||||
|
reason: application.application_reason || application.reason,
|
||||||
|
title: application.application_reason || application.reason,
|
||||||
|
description: application.application_reason || application.reason,
|
||||||
|
location: application.application_location || application.location
|
||||||
|
}
|
||||||
|
|
||||||
|
if (APPLICATION_TYPE_ALIASES.travel.has(expenseType)) {
|
||||||
|
return {
|
||||||
|
expenseType: 'travel',
|
||||||
|
expenseTypeLabel: EXPENSE_TYPE_LABELS.travel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (APPLICATION_TYPE_ALIASES.meal.has(expenseType)) {
|
||||||
|
return {
|
||||||
|
expenseType: 'meal',
|
||||||
|
expenseTypeLabel: EXPENSE_TYPE_LABELS.meal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesGenericApplicationByText(source, 'meal')) {
|
||||||
|
return {
|
||||||
|
expenseType: 'meal',
|
||||||
|
expenseTypeLabel: EXPENSE_TYPE_LABELS.meal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesGenericApplicationByText(source, 'travel')) {
|
||||||
|
return {
|
||||||
|
expenseType: 'travel',
|
||||||
|
expenseTypeLabel: EXPENSE_TYPE_LABELS.travel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expenseType: REQUIRED_APPLICATION_EXPENSE_TYPES.has(expenseType) ? expenseType : 'travel',
|
||||||
|
expenseTypeLabel: getRequiredApplicationExpenseLabel(
|
||||||
|
REQUIRED_APPLICATION_EXPENSE_TYPES.has(expenseType) ? expenseType : 'travel'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isExpenseApplicationClaim(claim) {
|
export function isExpenseApplicationClaim(claim) {
|
||||||
const documentType = normalizeDocumentType(claim)
|
const documentType = normalizeDocumentType(claim)
|
||||||
const expenseType = normalizeExpenseType(claim)
|
const expenseType = normalizeExpenseType(claim)
|
||||||
@@ -323,7 +391,7 @@ export function matchesRequiredApplicationExpenseType(claim, expenseType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
|
export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
|
||||||
const userIds = uniqueValues([
|
const userIds = expandIdentityValues([
|
||||||
currentUser.id,
|
currentUser.id,
|
||||||
currentUser.employeeId,
|
currentUser.employeeId,
|
||||||
currentUser.employee_id,
|
currentUser.employee_id,
|
||||||
@@ -332,11 +400,13 @@ export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
|
|||||||
currentUser.username,
|
currentUser.username,
|
||||||
currentUser.email
|
currentUser.email
|
||||||
])
|
])
|
||||||
const claimIds = uniqueValues([
|
const claimIds = expandIdentityValues([
|
||||||
claim?.employee_id,
|
claim?.employee_id,
|
||||||
claim?.employeeId,
|
claim?.employeeId,
|
||||||
claim?.employee_no,
|
claim?.employee_no,
|
||||||
claim?.employeeNo,
|
claim?.employeeNo,
|
||||||
|
claim?.employee_email,
|
||||||
|
claim?.employeeEmail,
|
||||||
claim?.username,
|
claim?.username,
|
||||||
claim?.user_id,
|
claim?.user_id,
|
||||||
claim?.userId
|
claim?.userId
|
||||||
|
|||||||
747
web/src/views/scripts/travelReimbursementAssociationGateModel.js
Normal file
747
web/src/views/scripts/travelReimbursementAssociationGateModel.js
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
import {
|
||||||
|
buildLinkedApplicationReferenceIndex,
|
||||||
|
buildRequiredApplicationActions,
|
||||||
|
isClaimOwnedByCurrentUser,
|
||||||
|
isExpenseApplicationClaim,
|
||||||
|
isUsableRequiredApplicationClaim,
|
||||||
|
normalizeRequiredApplicationCandidate,
|
||||||
|
resolveRequiredApplicationReimbursementType
|
||||||
|
} from './travelReimbursementApplicationLinkModel.js'
|
||||||
|
|
||||||
|
const REIMBURSEMENT_DRAFT_STATUSES = new Set(['draft', 'supplement', 'returned'])
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
draft: '草稿',
|
||||||
|
supplement: '待补充',
|
||||||
|
returned: '已退回',
|
||||||
|
submitted: '审批中',
|
||||||
|
approved: '已审批',
|
||||||
|
completed: '已完成',
|
||||||
|
archived: '已归档',
|
||||||
|
pending_payment: '待付款',
|
||||||
|
paid: '已付款'
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPENSE_TYPE_LABELS = {
|
||||||
|
travel: '差旅费',
|
||||||
|
meal: '业务招待费'
|
||||||
|
}
|
||||||
|
const REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS = 320
|
||||||
|
export const REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS = 12000
|
||||||
|
const REIMBURSEMENT_ASSOCIATION_QUERY_PARAMS = Object.freeze({ page: 1, pageSize: 100 })
|
||||||
|
const REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MESSAGE = '查询可关联申请单超时,请稍后重试。'
|
||||||
|
|
||||||
|
export const SKIP_REQUIRED_APPLICATION_LINK_ACTION = 'skip_required_application_link'
|
||||||
|
export const SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION = 'skip_reimbursement_draft_check'
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLower(value) {
|
||||||
|
return normalizeText(value).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExpenseType(claim) {
|
||||||
|
return normalizeLower(claim?.expense_type || claim?.expenseType || claim?.type_code || claim?.typeCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClaimStatus(claim) {
|
||||||
|
return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractClaims(claimsPayload) {
|
||||||
|
return Array.isArray(claimsPayload)
|
||||||
|
? claimsPayload
|
||||||
|
: Array.isArray(claimsPayload?.items)
|
||||||
|
? claimsPayload.items
|
||||||
|
: Array.isArray(claimsPayload?.claims)
|
||||||
|
? claimsPayload.claims
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value = '') {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimestamp(value) {
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(value) {
|
||||||
|
const numberValue = Number(String(value ?? '').replace(/[^\d.-]/g, ''))
|
||||||
|
if (!Number.isFinite(numberValue) || numberValue <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return `¥${new Intl.NumberFormat('zh-CN', {
|
||||||
|
minimumFractionDigits: Number.isInteger(numberValue) ? 0 : 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(numberValue)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCurrentUser(currentUser) {
|
||||||
|
return currentUser?.value && typeof currentUser.value === 'object'
|
||||||
|
? currentUser.value
|
||||||
|
: currentUser && typeof currentUser === 'object'
|
||||||
|
? currentUser
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForReimbursementAssociationStep() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
globalThis.setTimeout(resolve, REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReimbursementAssociationQueryTimeoutError() {
|
||||||
|
const error = new Error(REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MESSAGE)
|
||||||
|
error.code = 'REQUEST_TIMEOUT'
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReimbursementAssociationQueryTimeoutError(error) {
|
||||||
|
return error?.code === 'REQUEST_TIMEOUT'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReimbursementAssociationQueryFailedText(error) {
|
||||||
|
if (isReimbursementAssociationQueryTimeoutError(error)) {
|
||||||
|
return '查询可关联申请单超时。你可以稍后重试,也可以选择不关联申请单,单独新建报销单。'
|
||||||
|
}
|
||||||
|
return '查询可关联申请单时出现异常。你可以稍后重试,也可以选择不关联申请单,单独新建报销单。'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReimbursementAssociationClaims({
|
||||||
|
fetchExpenseClaims,
|
||||||
|
timeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
|
||||||
|
} = {}) {
|
||||||
|
if (typeof fetchExpenseClaims !== 'function') {
|
||||||
|
return { items: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryPromise = Promise.resolve(fetchExpenseClaims(
|
||||||
|
REIMBURSEMENT_ASSOCIATION_QUERY_PARAMS,
|
||||||
|
{
|
||||||
|
timeoutMs,
|
||||||
|
timeoutMessage: REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MESSAGE
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
const normalizedTimeoutMs = Number(timeoutMs)
|
||||||
|
if (!Number.isFinite(normalizedTimeoutMs) || normalizedTimeoutMs <= 0) {
|
||||||
|
return queryPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutId = 0
|
||||||
|
const timeoutPromise = new Promise((resolve, reject) => {
|
||||||
|
timeoutId = globalThis.setTimeout(() => {
|
||||||
|
reject(createReimbursementAssociationQueryTimeoutError())
|
||||||
|
}, normalizedTimeoutMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([queryPromise, timeoutPromise])
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
globalThis.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterReimbursementAssociationCandidates(claimsPayload, currentUser = {}) {
|
||||||
|
const claims = extractClaims(claimsPayload)
|
||||||
|
const linkedApplicationReferences = buildLinkedApplicationReferenceIndex(claims)
|
||||||
|
|
||||||
|
return claims
|
||||||
|
.filter((claim) => (
|
||||||
|
isExpenseApplicationClaim(claim)
|
||||||
|
&& isUsableRequiredApplicationClaim(claim, linkedApplicationReferences)
|
||||||
|
&& isClaimOwnedByCurrentUser(claim, currentUser)
|
||||||
|
))
|
||||||
|
.map(normalizeRequiredApplicationCandidate)
|
||||||
|
.sort((left, right) => toTimestamp(right.application_date) - toTimestamp(left.application_date))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeReimbursementDraftCandidate(claim = {}) {
|
||||||
|
const status = normalizeClaimStatus(claim)
|
||||||
|
const amount = normalizeText(claim?.amount || claim?.total_amount || claim?.totalAmount)
|
||||||
|
const createdAt = normalizeText(
|
||||||
|
claim?.updated_at
|
||||||
|
|| claim?.updatedAt
|
||||||
|
|| claim?.created_at
|
||||||
|
|| claim?.createdAt
|
||||||
|
|| claim?.submitted_at
|
||||||
|
|| claim?.submittedAt
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: normalizeText(claim?.id || claim?.claim_id || claim?.claimId),
|
||||||
|
claim_no: normalizeText(claim?.claim_no || claim?.claimNo),
|
||||||
|
expense_type: normalizeExpenseType(claim),
|
||||||
|
reason: normalizeText(claim?.reason || claim?.business_reason || claim?.description || claim?.title),
|
||||||
|
location: normalizeText(claim?.location || claim?.business_location || claim?.businessLocation),
|
||||||
|
amount,
|
||||||
|
amount_label: formatAmount(amount),
|
||||||
|
status,
|
||||||
|
status_label: STATUS_LABELS[status] || normalizeText(claim?.status_label || claim?.statusLabel || claim?.approval_stage || claim?.approvalStage || status),
|
||||||
|
created_at: createdAt,
|
||||||
|
application_date: createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterReimbursementDraftCandidates(claimsPayload, currentUser = {}) {
|
||||||
|
return extractClaims(claimsPayload)
|
||||||
|
.filter((claim) => (
|
||||||
|
!isExpenseApplicationClaim(claim)
|
||||||
|
&& REIMBURSEMENT_DRAFT_STATUSES.has(normalizeClaimStatus(claim))
|
||||||
|
&& isClaimOwnedByCurrentUser(claim, currentUser)
|
||||||
|
))
|
||||||
|
.map(normalizeReimbursementDraftCandidate)
|
||||||
|
.sort((left, right) => toTimestamp(right.application_date) - toTimestamp(left.application_date))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReimbursementAssociationSelectionText(applications) {
|
||||||
|
const candidates = Array.isArray(applications) ? applications : []
|
||||||
|
return [
|
||||||
|
'### 可关联申请单',
|
||||||
|
'',
|
||||||
|
'我先检查你名下是否有可继续的报销草稿,没有查到可继续的报销草稿。',
|
||||||
|
'',
|
||||||
|
'我先查询可关联申请单,并筛选了你名下已审批且未关联报销的记录。',
|
||||||
|
'',
|
||||||
|
`查到 ${candidates.length} 个已审批且尚未关联报销的申请单。你可以选择关联其中一个,也可以选择不关联、单独新建报销单。`,
|
||||||
|
'',
|
||||||
|
buildReimbursementAssociationCardsHtml(candidates),
|
||||||
|
'',
|
||||||
|
'请通过下方按钮确认是否关联申请单。'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReimbursementAssociationMissingText() {
|
||||||
|
return [
|
||||||
|
'我先检查你名下是否有可继续的报销草稿,没有查到可继续的报销草稿。',
|
||||||
|
'',
|
||||||
|
'我先查询可关联申请单,并筛选了你名下已审批且未关联报销的记录。',
|
||||||
|
'',
|
||||||
|
'暂时没有查到已审批且尚未关联报销的申请单。你仍然可以选择单独新建报销单,后续按报销类型继续补充信息。'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReimbursementDraftSelectionText(drafts) {
|
||||||
|
const candidates = Array.isArray(drafts) ? drafts : []
|
||||||
|
return [
|
||||||
|
'### 可继续报销草稿',
|
||||||
|
'',
|
||||||
|
'我先检查你名下是否有可继续的报销草稿。',
|
||||||
|
'',
|
||||||
|
`查到 ${candidates.length} 个可继续的报销草稿。你可以先继续草稿;如果这次是新的报销,可以跳过草稿后再关联申请单新建报销单。`,
|
||||||
|
'',
|
||||||
|
buildReimbursementDraftCardsHtml(candidates),
|
||||||
|
'',
|
||||||
|
'请通过下方按钮确认继续草稿,或跳过草稿进入申请单关联。'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSkipRequiredApplicationLinkAction(originalMessage = '') {
|
||||||
|
return {
|
||||||
|
label: '不关联,单独新建报销单',
|
||||||
|
description: '跳过申请单关联,继续选择报销类型并新建报销单。',
|
||||||
|
icon: 'mdi mdi-file-plus-outline',
|
||||||
|
action_type: SKIP_REQUIRED_APPLICATION_LINK_ACTION,
|
||||||
|
payload: {
|
||||||
|
original_message: normalizeText(originalMessage) || '我要报销'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSkipReimbursementDraftCheckAction(originalMessage = '') {
|
||||||
|
return {
|
||||||
|
label: '不用草稿,关联申请单新建报销单',
|
||||||
|
description: '跳过已有报销草稿,继续查询可关联申请单。',
|
||||||
|
icon: 'mdi mdi-file-search-outline',
|
||||||
|
action_type: SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION,
|
||||||
|
payload: {
|
||||||
|
original_message: normalizeText(originalMessage) || '我要报销'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReimbursementDraftActions(drafts, originalMessage = '') {
|
||||||
|
const sourceText = normalizeText(originalMessage) || '我要报销'
|
||||||
|
return [
|
||||||
|
...(Array.isArray(drafts) ? drafts : []).map((draft) => {
|
||||||
|
const claimNo = normalizeText(draft.claim_no) || '未编号草稿'
|
||||||
|
return {
|
||||||
|
label: `继续草稿 ${claimNo}`,
|
||||||
|
description: [
|
||||||
|
draft.status_label,
|
||||||
|
draft.created_at && `更新时间:${draft.created_at}`,
|
||||||
|
draft.location && `地点:${draft.location}`,
|
||||||
|
draft.amount_label && `金额:${draft.amount_label}`,
|
||||||
|
draft.reason && `事由:${draft.reason}`
|
||||||
|
].filter(Boolean).join(' · '),
|
||||||
|
icon: 'mdi mdi-file-document-edit-outline',
|
||||||
|
action_type: 'open_application_detail',
|
||||||
|
payload: {
|
||||||
|
claim_id: draft.id,
|
||||||
|
claim_no: draft.claim_no,
|
||||||
|
original_message: sourceText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
buildSkipReimbursementDraftCheckAction(sourceText)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReimbursementAssociationActions(applications, originalMessage = '') {
|
||||||
|
const sourceText = normalizeText(originalMessage) || '我要报销'
|
||||||
|
return [
|
||||||
|
...buildRequiredApplicationActions(applications, 'select_required_application').map((action) => ({
|
||||||
|
...action,
|
||||||
|
payload: {
|
||||||
|
...(action.payload || {}),
|
||||||
|
original_message: sourceText
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
buildSkipRequiredApplicationLinkAction(sourceText)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssociationCardFieldHtml(label = '', value = '', options = {}) {
|
||||||
|
const text = normalizeText(value)
|
||||||
|
if (!text) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const fieldClass = options.fieldClass ? ` ${options.fieldClass}` : ''
|
||||||
|
const valueClass = options.valueClass ? ` ${options.valueClass}` : ''
|
||||||
|
return [
|
||||||
|
`<div class="ai-document-card__field${fieldClass}">`,
|
||||||
|
`<span class="ai-document-card__label">${escapeHtml(label)}</span>`,
|
||||||
|
`<strong class="ai-document-card__value${valueClass}">${escapeHtml(text)}</strong>`,
|
||||||
|
'</div>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReimbursementDraftCardHtml(draft = {}) {
|
||||||
|
const statusLabel = normalizeText(draft.status_label) || '草稿'
|
||||||
|
const title = normalizeText(EXPENSE_TYPE_LABELS[normalizeLower(draft.expense_type)] || draft.expense_type) || '报销草稿'
|
||||||
|
const summaryHtml = [
|
||||||
|
buildAssociationCardFieldHtml('金额', draft.amount_label || draft.amount || '待确认', {
|
||||||
|
valueClass: 'ai-document-card__amount'
|
||||||
|
}),
|
||||||
|
buildAssociationCardFieldHtml('更新时间', draft.created_at || '待确认')
|
||||||
|
].join('')
|
||||||
|
const detailsHtml = [
|
||||||
|
buildAssociationCardFieldHtml('地点', draft.location || '待补充'),
|
||||||
|
buildAssociationCardFieldHtml('单据编号', draft.claim_no || '未编号草稿', {
|
||||||
|
valueClass: 'ai-document-card__number'
|
||||||
|
}),
|
||||||
|
buildAssociationCardFieldHtml('事由', draft.reason || '待补充'),
|
||||||
|
buildAssociationCardFieldHtml('单据类型', `报销单 · ${title}`),
|
||||||
|
buildAssociationCardFieldHtml('操作', '使用下方按钮继续', {
|
||||||
|
fieldClass: 'ai-document-card__field--action'
|
||||||
|
})
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<article class="ai-document-card ai-document-card--expense is-pending" aria-label="报销草稿详情">',
|
||||||
|
'<header class="ai-document-card__head">',
|
||||||
|
`<strong class="ai-document-card__reason">${escapeHtml(title)}</strong>`,
|
||||||
|
`<span class="ai-document-card__status">${escapeHtml(statusLabel)}</span>`,
|
||||||
|
'</header>',
|
||||||
|
'<div class="ai-document-card__body">',
|
||||||
|
summaryHtml ? `<div class="ai-document-card__summary">${summaryHtml}</div>` : '',
|
||||||
|
'<div class="ai-document-card__details">',
|
||||||
|
detailsHtml,
|
||||||
|
'</div>',
|
||||||
|
'</div>',
|
||||||
|
'</article>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReimbursementDraftCardsHtml(drafts = []) {
|
||||||
|
const candidates = (Array.isArray(drafts) ? drafts : []).slice(0, 5)
|
||||||
|
if (!candidates.length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'<!-- ai-trusted-html:start -->',
|
||||||
|
'<section class="ai-document-card-list" aria-label="可继续报销草稿">',
|
||||||
|
...candidates.map((draft) => buildReimbursementDraftCardHtml(draft)),
|
||||||
|
'</section>',
|
||||||
|
'<!-- ai-trusted-html:end -->'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReimbursementAssociationCardHtml(application = {}) {
|
||||||
|
const statusLabel = normalizeText(application.status_label) || '已审批'
|
||||||
|
const statusTone = ['已审批', '已完成', '已归档'].some((item) => statusLabel.includes(item))
|
||||||
|
? 'is-success'
|
||||||
|
: 'is-pending'
|
||||||
|
const title = normalizeText(resolveRequiredApplicationReimbursementType(application).expenseTypeLabel) || '费用申请'
|
||||||
|
const summaryHtml = [
|
||||||
|
buildAssociationCardFieldHtml('时间', application.business_time || '待补充'),
|
||||||
|
buildAssociationCardFieldHtml('预计金额', application.amount_label || application.amount || '待确认', {
|
||||||
|
valueClass: 'ai-document-card__amount'
|
||||||
|
})
|
||||||
|
].join('')
|
||||||
|
const detailsHtml = [
|
||||||
|
buildAssociationCardFieldHtml('地点', application.location || '待补充'),
|
||||||
|
buildAssociationCardFieldHtml('单据编号', application.claim_no || '未编号申请单', {
|
||||||
|
valueClass: 'ai-document-card__number'
|
||||||
|
}),
|
||||||
|
buildAssociationCardFieldHtml('事由', application.reason || '待补充'),
|
||||||
|
buildAssociationCardFieldHtml('单据类型', `申请单 · ${title}`),
|
||||||
|
buildAssociationCardFieldHtml('操作', '使用下方按钮关联', {
|
||||||
|
fieldClass: 'ai-document-card__field--action'
|
||||||
|
})
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
return [
|
||||||
|
`<article class="ai-document-card ai-document-card--application ${statusTone}" aria-label="申请单详情">`,
|
||||||
|
'<header class="ai-document-card__head">',
|
||||||
|
`<strong class="ai-document-card__reason">${escapeHtml(title)}</strong>`,
|
||||||
|
`<span class="ai-document-card__status">${escapeHtml(statusLabel)}</span>`,
|
||||||
|
'</header>',
|
||||||
|
'<div class="ai-document-card__body">',
|
||||||
|
summaryHtml ? `<div class="ai-document-card__summary">${summaryHtml}</div>` : '',
|
||||||
|
'<div class="ai-document-card__details">',
|
||||||
|
detailsHtml,
|
||||||
|
'</div>',
|
||||||
|
'</div>',
|
||||||
|
'</article>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReimbursementAssociationCardsHtml(applications = []) {
|
||||||
|
const candidates = (Array.isArray(applications) ? applications : []).slice(0, 5)
|
||||||
|
if (!candidates.length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'<!-- ai-trusted-html:start -->',
|
||||||
|
'<section class="ai-document-card-list" aria-label="可关联申请单">',
|
||||||
|
...candidates.map((application) => buildReimbursementAssociationCardHtml(application)),
|
||||||
|
'</section>',
|
||||||
|
'<!-- ai-trusted-html:end -->'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAssociationStatusGroup(application = {}) {
|
||||||
|
const status = normalizeLower(application.status)
|
||||||
|
if (['approved', 'completed', 'archived'].includes(status)) {
|
||||||
|
return { key: 'completed', label: '已审批' }
|
||||||
|
}
|
||||||
|
if (['submitted', 'review', 'pending'].includes(status)) {
|
||||||
|
return { key: 'in_progress', label: '审批中' }
|
||||||
|
}
|
||||||
|
if (['returned', 'rejected'].includes(status)) {
|
||||||
|
return { key: 'draft', label: '待完善' }
|
||||||
|
}
|
||||||
|
return { key: 'other', label: '其他状态' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReimbursementAssociationQueryPayload(applications = []) {
|
||||||
|
const candidates = Array.isArray(applications) ? applications : []
|
||||||
|
const records = candidates.map((application) => {
|
||||||
|
const statusGroup = resolveAssociationStatusGroup(application)
|
||||||
|
return {
|
||||||
|
claimId: normalizeText(application.id || application.claim_no),
|
||||||
|
claimNo: normalizeText(application.claim_no) || '未编号申请单',
|
||||||
|
employeeName: '',
|
||||||
|
expenseType: normalizeText(application.expense_type),
|
||||||
|
expenseTypeLabel: resolveRequiredApplicationReimbursementType(application).expenseTypeLabel,
|
||||||
|
amount: Number(String(application.amount || '').replace(/,/g, '')) || 0,
|
||||||
|
amountDisplay: normalizeText(application.amount_label || application.amount) || '待确认',
|
||||||
|
status: normalizeText(application.status),
|
||||||
|
statusLabel: normalizeText(application.status_label) || statusGroup.label,
|
||||||
|
statusGroup: statusGroup.key,
|
||||||
|
statusGroupLabel: statusGroup.label,
|
||||||
|
approvalStage: '',
|
||||||
|
documentDate: normalizeText(application.application_date),
|
||||||
|
occurredAt: '',
|
||||||
|
reason: normalizeText(application.reason) || '费用申请',
|
||||||
|
location: normalizeText(application.location),
|
||||||
|
riskItems: [],
|
||||||
|
summary: normalizeText(application.reason) || '费用申请',
|
||||||
|
dateDisplay: normalizeText(application.business_time || application.application_date) || '待补充日期'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const statusGroups = records.reduce((groups, record) => {
|
||||||
|
const key = record.statusGroup || 'other'
|
||||||
|
const existing = groups.get(key) || {
|
||||||
|
key,
|
||||||
|
label: record.statusGroupLabel || '其他状态',
|
||||||
|
count: 0
|
||||||
|
}
|
||||||
|
existing.count += 1
|
||||||
|
groups.set(key, existing)
|
||||||
|
return groups
|
||||||
|
}, new Map())
|
||||||
|
return {
|
||||||
|
resultType: 'expense_claim_list',
|
||||||
|
scopeLabel: '可关联申请单',
|
||||||
|
selectionMode: 'reimbursement_application_association',
|
||||||
|
selectionLocked: false,
|
||||||
|
selectedClaimId: '',
|
||||||
|
title: '可关联申请单',
|
||||||
|
emptyText: '当前没有可关联的已审批申请单。',
|
||||||
|
recentWindowApplied: false,
|
||||||
|
windowDays: null,
|
||||||
|
windowStartDate: '',
|
||||||
|
windowEndDate: '',
|
||||||
|
recordCount: records.length,
|
||||||
|
previewCount: records.length,
|
||||||
|
previewLimit: 5,
|
||||||
|
olderRecordCount: 0,
|
||||||
|
hasMoreInWindow: false,
|
||||||
|
totalAmount: records.reduce((sum, record) => sum + Number(record.amount || 0), 0),
|
||||||
|
statusGroups: Array.from(statusGroups.values()),
|
||||||
|
records,
|
||||||
|
currentPage: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReimbursementAssociationThinkingEvents(stage = 'intent', options = {}) {
|
||||||
|
const candidateCount = Number(options.candidateCount || 0)
|
||||||
|
const failed = stage === 'failed'
|
||||||
|
const stageOrder = {
|
||||||
|
intent: 0,
|
||||||
|
query: 1,
|
||||||
|
filter: 2,
|
||||||
|
completed: 4,
|
||||||
|
failed: 4
|
||||||
|
}
|
||||||
|
const currentOrder = stageOrder[stage] ?? 0
|
||||||
|
const resolveStatus = (index) => {
|
||||||
|
if (failed && index >= currentOrder - 1) {
|
||||||
|
return 'failed'
|
||||||
|
}
|
||||||
|
if (currentOrder > index) {
|
||||||
|
return 'completed'
|
||||||
|
}
|
||||||
|
if (currentOrder === index) {
|
||||||
|
return 'running'
|
||||||
|
}
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
eventId: 'reimbursement-association-intent',
|
||||||
|
title: '判断用户意图',
|
||||||
|
content: currentOrder > 0
|
||||||
|
? '已识别为报销创建请求,需要先检查是否已有报销草稿。'
|
||||||
|
: '正在判断用户是否要创建报销单,并确认是否需要先进入草稿与申请单关联检查。',
|
||||||
|
status: resolveStatus(0)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'reimbursement-draft-check',
|
||||||
|
title: '检查报销草稿',
|
||||||
|
content: currentOrder > 1
|
||||||
|
? '已完成报销草稿检查,继续判断是否需要进入申请单关联。'
|
||||||
|
: '正在查询你名下是否存在可继续的报销草稿。',
|
||||||
|
status: resolveStatus(1)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'reimbursement-association-query',
|
||||||
|
title: '查询可关联申请单',
|
||||||
|
content: currentOrder > 2
|
||||||
|
? `已完成申请单查询与筛选,命中 ${candidateCount} 张可推荐单据。`
|
||||||
|
: '如未发现可继续草稿,就查询你名下已审批且尚未关联报销的申请单。',
|
||||||
|
status: resolveStatus(2)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'reimbursement-association-card',
|
||||||
|
title: '生成单据卡片',
|
||||||
|
content: currentOrder > 3
|
||||||
|
? '已按草稿优先、申请单后置的顺序生成单据卡片和快捷按钮。'
|
||||||
|
: '等待查询结果返回后,以卡片形式展示可继续或可关联的单据。',
|
||||||
|
status: resolveStatus(3)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReimbursementAssociationSubmitOptions(application = {}, originalMessage = '') {
|
||||||
|
const sourceText = normalizeText(originalMessage) || '我要报销'
|
||||||
|
const resolvedType = resolveRequiredApplicationReimbursementType(application)
|
||||||
|
const applicationId = normalizeText(application.application_claim_id || application.id)
|
||||||
|
const applicationNo = normalizeText(application.application_claim_no || application.claim_no)
|
||||||
|
const applicationReason = normalizeText(application.application_reason || application.reason)
|
||||||
|
const applicationLocation = normalizeText(application.application_location || application.location)
|
||||||
|
const applicationBusinessTime = normalizeText(application.application_business_time || application.business_time)
|
||||||
|
const applicationTransportMode = normalizeText(application.application_transport_mode || application.transport_mode)
|
||||||
|
|
||||||
|
const rawText = [
|
||||||
|
sourceText,
|
||||||
|
`用户选择报销场景:${resolvedType.expenseTypeLabel}`,
|
||||||
|
applicationNo && `关联申请单:${applicationNo}`
|
||||||
|
].filter(Boolean).join('\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawText,
|
||||||
|
userText: `关联申请单 ${applicationNo || ''}`.trim() || '关联申请单',
|
||||||
|
pendingText: `已关联申请单,正在生成${resolvedType.expenseTypeLabel}草稿...`,
|
||||||
|
systemGenerated: true,
|
||||||
|
skipUserMessage: true,
|
||||||
|
skipDraftAssociationPrompt: true,
|
||||||
|
associationConfirmed: true,
|
||||||
|
extraContext: {
|
||||||
|
draft_claim_id: '',
|
||||||
|
review_action: 'save_draft',
|
||||||
|
user_input_text: sourceText,
|
||||||
|
expense_scene_selection: {
|
||||||
|
expense_type: resolvedType.expenseType,
|
||||||
|
expense_type_label: resolvedType.expenseTypeLabel,
|
||||||
|
original_message: sourceText,
|
||||||
|
application_claim_id: applicationId,
|
||||||
|
application_claim_no: applicationNo
|
||||||
|
},
|
||||||
|
review_form_values: {
|
||||||
|
expense_type: resolvedType.expenseTypeLabel,
|
||||||
|
reason: applicationReason,
|
||||||
|
location: applicationLocation,
|
||||||
|
time_range: applicationBusinessTime,
|
||||||
|
transport_mode: applicationTransportMode,
|
||||||
|
amount: '',
|
||||||
|
application_claim_id: applicationId,
|
||||||
|
application_claim_no: applicationNo,
|
||||||
|
application_reason: applicationReason,
|
||||||
|
application_location: applicationLocation,
|
||||||
|
application_amount: application.application_amount || application.amount || '',
|
||||||
|
application_amount_label: application.application_amount_label || application.amount_label || '',
|
||||||
|
application_business_time: applicationBusinessTime,
|
||||||
|
application_days: application.application_days || application.days || '',
|
||||||
|
application_transport_mode: applicationTransportMode,
|
||||||
|
application_lodging_daily_cap: application.application_lodging_daily_cap || application.lodging_daily_cap || '',
|
||||||
|
application_subsidy_daily_cap: application.application_subsidy_daily_cap || application.subsidy_daily_cap || '',
|
||||||
|
application_transport_policy: application.application_transport_policy || application.transport_policy || '',
|
||||||
|
application_policy_estimate: application.application_policy_estimate || application.policy_estimate || '',
|
||||||
|
application_rule_name: application.application_rule_name || application.rule_name || '',
|
||||||
|
application_rule_version: application.application_rule_version || application.rule_version || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildReimbursementAssociationPromptPayload({
|
||||||
|
originalMessage = '我要报销',
|
||||||
|
fetchExpenseClaims,
|
||||||
|
currentUser,
|
||||||
|
skipDraftCheck = false,
|
||||||
|
queryTimeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
|
||||||
|
} = {}) {
|
||||||
|
const sourceText = normalizeText(originalMessage) || '我要报销'
|
||||||
|
try {
|
||||||
|
const claimsPayload = await fetchReimbursementAssociationClaims({
|
||||||
|
fetchExpenseClaims,
|
||||||
|
timeoutMs: queryTimeoutMs
|
||||||
|
})
|
||||||
|
const user = resolveCurrentUser(currentUser)
|
||||||
|
const drafts = skipDraftCheck ? [] : filterReimbursementDraftCandidates(claimsPayload, user)
|
||||||
|
if (drafts.length) {
|
||||||
|
return {
|
||||||
|
text: buildReimbursementDraftSelectionText(drafts),
|
||||||
|
meta: ['等待选择报销草稿'],
|
||||||
|
suggestedActions: buildReimbursementDraftActions(drafts, sourceText),
|
||||||
|
queryPayload: null,
|
||||||
|
candidateCount: drafts.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const candidates = filterReimbursementAssociationCandidates(claimsPayload, user)
|
||||||
|
return {
|
||||||
|
text: candidates.length
|
||||||
|
? buildReimbursementAssociationSelectionText(candidates)
|
||||||
|
: buildReimbursementAssociationMissingText(),
|
||||||
|
meta: candidates.length ? ['等待关联申请单'] : ['无可关联申请单'],
|
||||||
|
suggestedActions: buildReimbursementAssociationActions(candidates, sourceText),
|
||||||
|
queryPayload: buildReimbursementAssociationQueryPayload(candidates),
|
||||||
|
candidateCount: candidates.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Fetch reimbursement association applications failed:', error)
|
||||||
|
return {
|
||||||
|
text: buildReimbursementAssociationQueryFailedText(error),
|
||||||
|
meta: ['申请单查询失败'],
|
||||||
|
suggestedActions: buildReimbursementAssociationActions([], sourceText),
|
||||||
|
queryPayload: buildReimbursementAssociationQueryPayload([]),
|
||||||
|
candidateCount: 0,
|
||||||
|
failed: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushReimbursementAssociationPromptMessage({
|
||||||
|
rawText,
|
||||||
|
createMessage,
|
||||||
|
messages,
|
||||||
|
nextTick,
|
||||||
|
scrollToBottom,
|
||||||
|
persistSessionState,
|
||||||
|
fetchExpenseClaims,
|
||||||
|
currentUser,
|
||||||
|
skipDraftCheck = false,
|
||||||
|
queryTimeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
|
||||||
|
} = {}) {
|
||||||
|
const pendingMessage = createMessage('assistant', '', [], {
|
||||||
|
pending: true,
|
||||||
|
meta: ['思考中'],
|
||||||
|
stewardPlan: {
|
||||||
|
streamStatus: 'streaming',
|
||||||
|
thinkingEvents: buildReimbursementAssociationThinkingEvents('intent')
|
||||||
|
},
|
||||||
|
suggestedActions: []
|
||||||
|
})
|
||||||
|
messages.value.push(pendingMessage)
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
persistSessionState()
|
||||||
|
await waitForReimbursementAssociationStep()
|
||||||
|
|
||||||
|
pendingMessage.stewardPlan = {
|
||||||
|
...(pendingMessage.stewardPlan || {}),
|
||||||
|
streamStatus: 'streaming',
|
||||||
|
thinkingEvents: buildReimbursementAssociationThinkingEvents('query')
|
||||||
|
}
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
|
||||||
|
const associationPrompt = await buildReimbursementAssociationPromptPayload({
|
||||||
|
originalMessage: rawText,
|
||||||
|
fetchExpenseClaims,
|
||||||
|
currentUser,
|
||||||
|
skipDraftCheck,
|
||||||
|
queryTimeoutMs
|
||||||
|
})
|
||||||
|
pendingMessage.stewardPlan = {
|
||||||
|
...(pendingMessage.stewardPlan || {}),
|
||||||
|
streamStatus: associationPrompt.failed ? 'failed' : 'streaming',
|
||||||
|
thinkingEvents: buildReimbursementAssociationThinkingEvents(
|
||||||
|
associationPrompt.failed ? 'failed' : 'filter',
|
||||||
|
{ candidateCount: associationPrompt.candidateCount }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
if (!associationPrompt.failed) {
|
||||||
|
await waitForReimbursementAssociationStep()
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingMessage.text = associationPrompt.text
|
||||||
|
pendingMessage.pending = false
|
||||||
|
pendingMessage.meta = associationPrompt.meta
|
||||||
|
pendingMessage.suggestedActions = associationPrompt.suggestedActions
|
||||||
|
pendingMessage.queryPayload = associationPrompt.queryPayload
|
||||||
|
pendingMessage.stewardPlan = {
|
||||||
|
...(pendingMessage.stewardPlan || {}),
|
||||||
|
streamStatus: associationPrompt.failed ? 'failed' : 'completed',
|
||||||
|
thinkingEvents: buildReimbursementAssociationThinkingEvents(
|
||||||
|
associationPrompt.failed ? 'failed' : 'completed',
|
||||||
|
{ candidateCount: associationPrompt.candidateCount }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
persistSessionState()
|
||||||
|
}
|
||||||
@@ -247,6 +247,9 @@ export function buildExpenseQueryWindowLabel(queryPayload) {
|
|||||||
if (queryPayload.selectionMode === 'draft_association') {
|
if (queryPayload.selectionMode === 'draft_association') {
|
||||||
return '先选择要关联的草稿,确认后我再识别附件并归集到该单据。'
|
return '先选择要关联的草稿,确认后我再识别附件并归集到该单据。'
|
||||||
}
|
}
|
||||||
|
if (queryPayload.selectionMode === 'reimbursement_application_association') {
|
||||||
|
return '先确认是否关联申请单;选择关联后我会用申请单信息生成报销草稿。'
|
||||||
|
}
|
||||||
|
|
||||||
if (queryPayload.windowStartDate && queryPayload.windowEndDate) {
|
if (queryPayload.windowStartDate && queryPayload.windowEndDate) {
|
||||||
return `${queryPayload.windowStartDate} 至 ${queryPayload.windowEndDate}`
|
return `${queryPayload.windowStartDate} 至 ${queryPayload.windowEndDate}`
|
||||||
|
|||||||
@@ -17,9 +17,15 @@ import {
|
|||||||
getTodayDateValue
|
getTodayDateValue
|
||||||
} from '../../utils/workbenchComposerDate.js'
|
} from '../../utils/workbenchComposerDate.js'
|
||||||
|
|
||||||
function parseEditorDateValue(value) {
|
function parseEditorDateMatches(value) {
|
||||||
const text = String(value || '').trim()
|
const text = String(value || '').trim()
|
||||||
const matches = [...text.matchAll(/20\d{2}-\d{1,2}-\d{1,2}/g)].map((item) => item[0])
|
return [...text.matchAll(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g)]
|
||||||
|
.map((item) => normalizeEditorIsoDate(item[0]))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEditorDateValue(value) {
|
||||||
|
const matches = parseEditorDateMatches(value)
|
||||||
const startDate = matches[0] || getTodayDateValue()
|
const startDate = matches[0] || getTodayDateValue()
|
||||||
const endDate = matches[1] || startDate
|
const endDate = matches[1] || startDate
|
||||||
return {
|
return {
|
||||||
@@ -30,6 +36,113 @@ function parseEditorDateValue(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isApplicationPreviewDateField(fieldKey = '') {
|
||||||
|
return ['time', 'time_return'].includes(String(fieldKey || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatApplicationPreviewDateRange(startDate = '', endDate = '') {
|
||||||
|
const start = String(startDate || '').trim()
|
||||||
|
const end = String(endDate || '').trim()
|
||||||
|
if (!start && !end) return ''
|
||||||
|
if (!start) return end
|
||||||
|
if (!end || end === start) return start
|
||||||
|
return `${start} 至 ${end}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditorIsoDate(value = '') {
|
||||||
|
const match = String(value || '').trim().match(/^(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})$/)
|
||||||
|
if (!match) return ''
|
||||||
|
return buildEditorIsoDate(Number(match[1]), Number(match[2]), Number(match[3]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEditorIsoDate(year, month, day) {
|
||||||
|
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return ''
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day))
|
||||||
|
if (
|
||||||
|
Number.isNaN(date.getTime()) ||
|
||||||
|
date.getUTCFullYear() !== year ||
|
||||||
|
date.getUTCMonth() + 1 !== month ||
|
||||||
|
date.getUTCDate() !== day
|
||||||
|
) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
String(year).padStart(4, '0'),
|
||||||
|
String(month).padStart(2, '0'),
|
||||||
|
String(day).padStart(2, '0')
|
||||||
|
].join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEditorDateParts(value = '') {
|
||||||
|
const normalized = normalizeEditorIsoDate(value)
|
||||||
|
const match = normalized.match(/^(20\d{2})-(\d{2})-(\d{2})$/)
|
||||||
|
if (!match) return null
|
||||||
|
return {
|
||||||
|
year: Number(match[1]),
|
||||||
|
month: Number(match[2]),
|
||||||
|
day: Number(match[3]),
|
||||||
|
value: normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDaysToEditorDate(value = '', offset = 0) {
|
||||||
|
const parts = parseEditorDateParts(value)
|
||||||
|
if (!parts) return ''
|
||||||
|
const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
|
||||||
|
date.setUTCDate(date.getUTCDate() + offset)
|
||||||
|
return buildEditorIsoDate(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEditorDateFromMonthDay(referenceDate = '', month = 0, day = 0) {
|
||||||
|
const reference = parseEditorDateParts(referenceDate) || parseEditorDateParts(getTodayDateValue())
|
||||||
|
if (!reference) return ''
|
||||||
|
const year = month < reference.month ? reference.year + 1 : reference.year
|
||||||
|
return buildEditorIsoDate(year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEditorDateFromDay(referenceDate = '', day = 0) {
|
||||||
|
const reference = parseEditorDateParts(referenceDate) || parseEditorDateParts(getTodayDateValue())
|
||||||
|
if (!reference) return ''
|
||||||
|
let year = reference.year
|
||||||
|
let month = reference.month
|
||||||
|
let candidate = buildEditorIsoDate(year, month, day)
|
||||||
|
if (candidate && candidate < reference.value) {
|
||||||
|
month += 1
|
||||||
|
if (month > 12) {
|
||||||
|
month = 1
|
||||||
|
year += 1
|
||||||
|
}
|
||||||
|
candidate = buildEditorIsoDate(year, month, day)
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditorDateInput(value = '', referenceDate = '') {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const fullDate = text.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/)
|
||||||
|
if (fullDate) {
|
||||||
|
return normalizeEditorIsoDate(fullDate[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthDay = text.match(/(\d{1,2})\s*月\s*(\d{1,2})\s*日?/) || text.match(/(\d{1,2})[/-](\d{1,2})/)
|
||||||
|
if (monthDay) {
|
||||||
|
return buildEditorDateFromMonthDay(referenceDate, Number(monthDay[1]), Number(monthDay[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayOnly = text.match(/^(\d{1,2})\s*日?$/)
|
||||||
|
if (dayOnly) {
|
||||||
|
return buildEditorDateFromDay(referenceDate, Number(dayOnly[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/大后天/.test(text)) return addDaysToEditorDate(referenceDate || getTodayDateValue(), 3)
|
||||||
|
if (/后天/.test(text)) return addDaysToEditorDate(referenceDate || getTodayDateValue(), 2)
|
||||||
|
if (/明天/.test(text)) return addDaysToEditorDate(referenceDate || getTodayDateValue(), 1)
|
||||||
|
if (/今天/.test(text)) return normalizeEditorIsoDate(referenceDate || getTodayDateValue())
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
function buildEmptyEditor() {
|
function buildEmptyEditor() {
|
||||||
return {
|
return {
|
||||||
messageId: '',
|
messageId: '',
|
||||||
@@ -44,7 +157,7 @@ function buildEmptyEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldRefreshTransportEstimate(fieldKey) {
|
function shouldRefreshTransportEstimate(fieldKey) {
|
||||||
return ['transportMode', 'time', 'location', 'days'].includes(fieldKey)
|
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveEditorCurrentUser(currentUser) {
|
function resolveEditorCurrentUser(currentUser) {
|
||||||
@@ -55,11 +168,12 @@ function resolveEditorCurrentUser(currentUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildEditedApplicationPreviewFields(fields = {}, editor = {}, nextValue = '') {
|
function buildEditedApplicationPreviewFields(fields = {}, editor = {}, nextValue = '') {
|
||||||
|
const isDateField = isApplicationPreviewDateField(editor.fieldKey)
|
||||||
const nextFields = {
|
const nextFields = {
|
||||||
...fields,
|
...fields,
|
||||||
[editor.fieldKey]: nextValue
|
[isDateField ? 'time' : editor.fieldKey]: nextValue
|
||||||
}
|
}
|
||||||
if (editor.fieldKey === 'time') {
|
if (isDateField) {
|
||||||
const resolvedDays = resolveApplicationDaysFromDateRange(nextValue)
|
const resolvedDays = resolveApplicationDaysFromDateRange(nextValue)
|
||||||
if (resolvedDays) {
|
if (resolvedDays) {
|
||||||
nextFields.days = resolvedDays
|
nextFields.days = resolvedDays
|
||||||
@@ -68,6 +182,45 @@ function buildEditedApplicationPreviewFields(fields = {}, editor = {}, nextValue
|
|||||||
return nextFields
|
return nextFields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildApplicationPreviewDateSelectionValue(editor = {}) {
|
||||||
|
return buildWorkbenchDateLabel({
|
||||||
|
mode: editor.dateMode,
|
||||||
|
singleDate: editor.singleDate,
|
||||||
|
rangeStartDate: editor.rangeStartDate,
|
||||||
|
rangeEndDate: editor.rangeEndDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApplicationPreviewTextDateValue(fields = {}, editor = {}) {
|
||||||
|
const currentDateState = parseEditorDateValue(fields.time)
|
||||||
|
const referenceDate = currentDateState.rangeStartDate || currentDateState.singleDate || getTodayDateValue()
|
||||||
|
const typedDates = parseEditorDateMatches(editor.draftValue)
|
||||||
|
if (typedDates.length >= 2) {
|
||||||
|
return formatApplicationPreviewDateRange(typedDates[0], typedDates[typedDates.length - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedDate = typedDates[0] || normalizeEditorDateInput(editor.draftValue, referenceDate) || String(editor.draftValue || '').trim()
|
||||||
|
if (!typedDate) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editor.fieldKey === 'time_return') {
|
||||||
|
return formatApplicationPreviewDateRange(currentDateState.rangeStartDate, typedDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEndDate = currentDateState.rangeEndDate || typedDate
|
||||||
|
return formatApplicationPreviewDateRange(typedDate, currentEndDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApplicationPreviewEditorValue(fields = {}, editor = {}, options = {}) {
|
||||||
|
if (!isApplicationPreviewDateField(editor.fieldKey)) {
|
||||||
|
return String(editor.draftValue || '').trim()
|
||||||
|
}
|
||||||
|
return options.useDateSelection
|
||||||
|
? buildApplicationPreviewDateSelectionValue(editor)
|
||||||
|
: buildApplicationPreviewTextDateValue(fields, editor)
|
||||||
|
}
|
||||||
|
|
||||||
function buildTransportEstimatePendingPreview(preview = {}) {
|
function buildTransportEstimatePendingPreview(preview = {}) {
|
||||||
const fields = preview?.fields || {}
|
const fields = preview?.fields || {}
|
||||||
return normalizeApplicationPreview({
|
return normalizeApplicationPreview({
|
||||||
@@ -110,7 +263,7 @@ export function useApplicationPreviewEditor({
|
|||||||
|
|
||||||
function resolveApplicationPreviewEditorControl(fieldKey) {
|
function resolveApplicationPreviewEditorControl(fieldKey) {
|
||||||
if (fieldKey === 'transportMode') return 'select'
|
if (fieldKey === 'transportMode') return 'select'
|
||||||
if (fieldKey === 'time') return 'date'
|
if (isApplicationPreviewDateField(fieldKey)) return 'date'
|
||||||
return 'text'
|
return 'text'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +284,10 @@ export function useApplicationPreviewEditor({
|
|||||||
.find((row) => row.key === fieldKey)
|
.find((row) => row.key === fieldKey)
|
||||||
if (targetRow && targetRow.editable === false) return
|
if (targetRow && targetRow.editable === false) return
|
||||||
const normalizedValue = String(value || '').trim() === '待补充' ? '' : String(value || '')
|
const normalizedValue = String(value || '').trim() === '待补充' ? '' : String(value || '')
|
||||||
const dateState = fieldKey === 'time' ? parseEditorDateValue(normalizedValue) : {}
|
const fields = message.applicationPreview.fields || {}
|
||||||
|
const dateState = isApplicationPreviewDateField(fieldKey)
|
||||||
|
? parseEditorDateValue(fields.time || normalizedValue)
|
||||||
|
: {}
|
||||||
applicationPreviewEditor.value = {
|
applicationPreviewEditor.value = {
|
||||||
messageId: String(message.id || ''),
|
messageId: String(message.id || ''),
|
||||||
fieldKey,
|
fieldKey,
|
||||||
@@ -148,7 +304,10 @@ export function useApplicationPreviewEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isApplicationPreviewDateEditorOpen(message) {
|
function isApplicationPreviewDateEditorOpen(message) {
|
||||||
return isApplicationPreviewEditing(message, 'time')
|
return (
|
||||||
|
isApplicationPreviewEditing(message, 'time') ||
|
||||||
|
isApplicationPreviewEditing(message, 'time_return')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setApplicationPreviewDateMode(mode) {
|
function setApplicationPreviewDateMode(mode) {
|
||||||
@@ -165,17 +324,7 @@ export function useApplicationPreviewEditor({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildApplicationPreviewDateDraftValue() {
|
async function commitApplicationPreviewEditor(message, options = {}) {
|
||||||
const editor = applicationPreviewEditor.value
|
|
||||||
return buildWorkbenchDateLabel({
|
|
||||||
mode: editor.dateMode,
|
|
||||||
singleDate: editor.singleDate,
|
|
||||||
rangeStartDate: editor.rangeStartDate,
|
|
||||||
rangeEndDate: editor.rangeEndDate
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commitApplicationPreviewEditor(message) {
|
|
||||||
const editor = applicationPreviewEditor.value
|
const editor = applicationPreviewEditor.value
|
||||||
if (editor.committing) {
|
if (editor.committing) {
|
||||||
return false
|
return false
|
||||||
@@ -189,10 +338,12 @@ export function useApplicationPreviewEditor({
|
|||||||
committing: true
|
committing: true
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextValue = editor.fieldKey === 'time'
|
const nextValue = buildApplicationPreviewEditorValue(
|
||||||
? buildApplicationPreviewDateDraftValue()
|
message.applicationPreview.fields || {},
|
||||||
: String(editor.draftValue || '').trim()
|
editor,
|
||||||
if (editor.fieldKey === 'time' && !nextValue) {
|
options
|
||||||
|
)
|
||||||
|
if (isApplicationPreviewDateField(editor.fieldKey) && !nextValue) {
|
||||||
toast?.('请先选择有效日期。')
|
toast?.('请先选择有效日期。')
|
||||||
applicationPreviewEditor.value = {
|
applicationPreviewEditor.value = {
|
||||||
...applicationPreviewEditor.value,
|
...applicationPreviewEditor.value,
|
||||||
@@ -232,7 +383,7 @@ export function useApplicationPreviewEditor({
|
|||||||
toast?.('请确认结束日期不早于开始日期。')
|
toast?.('请确认结束日期不早于开始日期。')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return commitApplicationPreviewEditor(message)
|
return commitApplicationPreviewEditor(message, { useDateSelection: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleApplicationPreviewEditorKeydown(event, message) {
|
function handleApplicationPreviewEditorKeydown(event, message) {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
|
|
||||||
|
function isApplicationPreviewDateField(fieldKey = '') {
|
||||||
|
return ['time', 'time_return'].includes(String(fieldKey || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
export function useTravelReimbursementApplicationPreviewDateEditor({
|
export function useTravelReimbursementApplicationPreviewDateEditor({
|
||||||
applicationPreviewEditor,
|
applicationPreviewEditor,
|
||||||
cancelApplicationPreviewEditor,
|
cancelApplicationPreviewEditor,
|
||||||
@@ -17,7 +21,7 @@ export function useTravelReimbursementApplicationPreviewDateEditor({
|
|||||||
}) {
|
}) {
|
||||||
function applyLinkedApplicationPreviewDateSelection(selection) {
|
function applyLinkedApplicationPreviewDateSelection(selection) {
|
||||||
const editor = applicationPreviewEditor.value
|
const editor = applicationPreviewEditor.value
|
||||||
if (editor.fieldKey !== 'time' || !editor.messageId) {
|
if (!isApplicationPreviewDateField(editor.fieldKey) || !editor.messageId) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,13 +55,13 @@ export function useTravelReimbursementApplicationPreviewDateEditor({
|
|||||||
|
|
||||||
function openApplicationPreviewEditorFromUi(message, fieldKey, value) {
|
function openApplicationPreviewEditorFromUi(message, fieldKey, value) {
|
||||||
openApplicationPreviewEditor(message, fieldKey, value)
|
openApplicationPreviewEditor(message, fieldKey, value)
|
||||||
if (fieldKey === 'time' && isApplicationPreviewEditing(message, 'time')) {
|
if (isApplicationPreviewDateField(fieldKey) && isApplicationPreviewEditing(message, fieldKey)) {
|
||||||
syncComposerDateFromApplicationEditor()
|
syncComposerDateFromApplicationEditor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(composerDatePickerOpen, (open, previousOpen) => {
|
watch(composerDatePickerOpen, (open, previousOpen) => {
|
||||||
if (!open && previousOpen && applicationPreviewEditor.value.fieldKey === 'time') {
|
if (!open && previousOpen && isApplicationPreviewDateField(applicationPreviewEditor.value.fieldKey)) {
|
||||||
cancelApplicationPreviewEditor()
|
cancelApplicationPreviewEditor()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
buildOperationFeedbackState,
|
buildOperationFeedbackState,
|
||||||
resolveAssistantResultText
|
resolveAssistantResultText
|
||||||
} from './travelReimbursementSubmitResponseModel.js'
|
} from './travelReimbursementSubmitResponseModel.js'
|
||||||
|
import {
|
||||||
|
pushReimbursementAssociationPromptMessage
|
||||||
|
} from './travelReimbursementAssociationGateModel.js'
|
||||||
|
|
||||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||||
const {
|
const {
|
||||||
@@ -446,10 +449,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (waitForExpenseSceneSelection) {
|
if (waitForExpenseSceneSelection) {
|
||||||
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(rawText), [], {
|
await pushReimbursementAssociationPromptMessage({
|
||||||
meta: ['等待选择场景'],
|
rawText,
|
||||||
suggestedActions: buildExpenseSceneSelectionActions(rawText)
|
createMessage,
|
||||||
}))
|
messages,
|
||||||
|
nextTick,
|
||||||
|
scrollToBottom,
|
||||||
|
persistSessionState,
|
||||||
|
fetchExpenseClaims,
|
||||||
|
currentUser
|
||||||
|
})
|
||||||
composerDraft.value = ''
|
composerDraft.value = ''
|
||||||
composerBusinessTimeTags.value = []
|
composerBusinessTimeTags.value = []
|
||||||
composerBusinessTimeDraftTouched.value = false
|
composerBusinessTimeDraftTouched.value = false
|
||||||
|
|||||||
@@ -17,8 +17,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
SESSION_TYPE_APPLICATION,
|
SESSION_TYPE_APPLICATION,
|
||||||
SESSION_TYPE_BUDGET,
|
SESSION_TYPE_BUDGET,
|
||||||
|
SESSION_TYPE_EXPENSE,
|
||||||
canUseBudgetAssistantSession
|
canUseBudgetAssistantSession
|
||||||
} from './travelReimbursementConversationModel.js'
|
} from './travelReimbursementConversationModel.js'
|
||||||
|
import {
|
||||||
|
SKIP_REQUIRED_APPLICATION_LINK_ACTION,
|
||||||
|
SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION,
|
||||||
|
buildReimbursementAssociationSubmitOptions,
|
||||||
|
pushReimbursementAssociationPromptMessage
|
||||||
|
} from './travelReimbursementAssociationGateModel.js'
|
||||||
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
|
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
|
||||||
import {
|
import {
|
||||||
buildStewardFieldCompletionContinuation,
|
buildStewardFieldCompletionContinuation,
|
||||||
@@ -40,6 +47,7 @@ export function useTravelReimbursementSuggestedActions({
|
|||||||
createMessage,
|
createMessage,
|
||||||
currentUser,
|
currentUser,
|
||||||
emit,
|
emit,
|
||||||
|
fetchExpenseClaims = async () => ({ items: [] }),
|
||||||
handleGuidedShortcut,
|
handleGuidedShortcut,
|
||||||
handleGuidedSuggestedAction,
|
handleGuidedSuggestedAction,
|
||||||
handleSceneSelectionApplicationGate,
|
handleSceneSelectionApplicationGate,
|
||||||
@@ -224,14 +232,14 @@ export function useTravelReimbursementSuggestedActions({
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushExpenseSceneSelectionPrompt(originalMessage) {
|
function pushExpenseSceneSelectionPrompt(originalMessage, userEcho = '我要报销') {
|
||||||
const sourceText = String(originalMessage || '').trim()
|
const sourceText = String(originalMessage || '').trim()
|
||||||
if (!sourceText) {
|
if (!sourceText) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
|
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
|
||||||
messages.value.push(createMessage('user', '我要报销'))
|
messages.value.push(createMessage('user', String(userEcho || '我要报销').trim() || '我要报销'))
|
||||||
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), [], {
|
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), [], {
|
||||||
meta: ['等待选择场景'],
|
meta: ['等待选择场景'],
|
||||||
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
|
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
|
||||||
@@ -240,6 +248,23 @@ export function useTravelReimbursementSuggestedActions({
|
|||||||
persistSessionState()
|
persistSessionState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pushExpenseAssociationGatePrompt(originalMessage, options = {}) {
|
||||||
|
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
|
||||||
|
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
|
||||||
|
messages.value.push(createMessage('user', String(options.userText || '我要报销').trim() || '我要报销'))
|
||||||
|
await pushReimbursementAssociationPromptMessage({
|
||||||
|
rawText: sourceText,
|
||||||
|
createMessage,
|
||||||
|
messages,
|
||||||
|
nextTick,
|
||||||
|
scrollToBottom,
|
||||||
|
persistSessionState,
|
||||||
|
fetchExpenseClaims,
|
||||||
|
currentUser,
|
||||||
|
skipDraftCheck: Boolean(options.skipDraftCheck)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function applySuggestedActionPrefill(action) {
|
function applySuggestedActionPrefill(action) {
|
||||||
const prefillText = resolveSuggestedActionPrefill(action)
|
const prefillText = resolveSuggestedActionPrefill(action)
|
||||||
if (!prefillText) {
|
if (!prefillText) {
|
||||||
@@ -263,6 +288,35 @@ export function useTravelReimbursementSuggestedActions({
|
|||||||
if (await handleGuidedSuggestedAction(message, action)) return
|
if (await handleGuidedSuggestedAction(message, action)) return
|
||||||
if (await handleSceneSelectionApplicationGate(message, action)) return
|
if (await handleSceneSelectionApplicationGate(message, action)) return
|
||||||
|
|
||||||
|
if (actionType === SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION) {
|
||||||
|
const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销'
|
||||||
|
if (!lockSuggestedActionMessage(message, action)) return
|
||||||
|
await pushExpenseAssociationGatePrompt(originalMessage, {
|
||||||
|
skipDraftCheck: true,
|
||||||
|
userText: action?.label || '不用草稿,关联申请单新建报销单'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType === SKIP_REQUIRED_APPLICATION_LINK_ACTION) {
|
||||||
|
const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销'
|
||||||
|
if (!lockSuggestedActionMessage(message, action)) return
|
||||||
|
pushExpenseSceneSelectionPrompt(originalMessage, action?.label || '不关联,单独新建报销单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType === 'select_required_application') {
|
||||||
|
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
|
const applicationNo = String(actionPayload.application_claim_no || actionPayload.claim_no || '').trim()
|
||||||
|
const originalMessage = String(actionPayload.original_message || message?.text || '我要报销').trim() || '我要报销'
|
||||||
|
if (!lockSuggestedActionMessage(message, action)) return
|
||||||
|
messages.value.push(createMessage('user', `关联申请单 ${applicationNo || ''}`.trim() || '关联申请单'))
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
persistSessionState()
|
||||||
|
await submitComposer(buildReimbursementAssociationSubmitOptions(actionPayload, originalMessage))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (actionType === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
|
if (actionType === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
|
||||||
await applyApplicationPreviewFieldAction(message, action)
|
await applyApplicationPreviewFieldAction(message, action)
|
||||||
return
|
return
|
||||||
@@ -340,7 +394,7 @@ export function useTravelReimbursementSuggestedActions({
|
|||||||
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
|
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
|
||||||
if (!lockSuggestedActionMessage(message, action)) return
|
if (!lockSuggestedActionMessage(message, action)) return
|
||||||
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
|
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
|
||||||
pushExpenseSceneSelectionPrompt(carryText)
|
await pushExpenseAssociationGatePrompt(carryText)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (String(actionPayload.steward_plan_id || '').trim()) {
|
if (String(actionPayload.steward_plan_id || '').trim()) {
|
||||||
@@ -400,7 +454,7 @@ export function useTravelReimbursementSuggestedActions({
|
|||||||
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
|
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
|
||||||
if (!originalMessage) return
|
if (!originalMessage) return
|
||||||
if (!lockSuggestedActionMessage(message, action)) return
|
if (!lockSuggestedActionMessage(message, action)) return
|
||||||
pushExpenseSceneSelectionPrompt(originalMessage)
|
await pushExpenseAssociationGatePrompt(originalMessage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,8 +117,12 @@ test('attachment upload association uses conversation selection instead of legac
|
|||||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const flowToolSource = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementFlowToolModel.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
const conversationSource = readFileSync(
|
const conversationSource = readFileSync(
|
||||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationSessionModel.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,7 +144,7 @@ test('attachment upload association uses conversation selection instead of legac
|
|||||||
assert.match(submitComposerSource, /const appendToCurrentFlow = Boolean\(options\.appendToCurrentFlow\)/)
|
assert.match(submitComposerSource, /const appendToCurrentFlow = Boolean\(options\.appendToCurrentFlow\)/)
|
||||||
assert.match(submitComposerSource, /if \(!appendToCurrentFlow\) \{\s*resetFlowRun\(\)\s*\} else \{\s*clearFlowSimulationTimers\(\)/)
|
assert.match(submitComposerSource, /if \(!appendToCurrentFlow\) \{\s*resetFlowRun\(\)\s*\} else \{\s*clearFlowSimulationTimers\(\)/)
|
||||||
assert.match(flowSource, /link_to_existing_draft:\s*\{[\s\S]*key:\s*'attachment-association'/)
|
assert.match(flowSource, /link_to_existing_draft:\s*\{[\s\S]*key:\s*'attachment-association'/)
|
||||||
assert.match(flowSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/)
|
assert.match(flowToolSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/)
|
||||||
assert.match(flowSource, /'draft-risk-review'/)
|
assert.match(flowSource, /'draft-risk-review'/)
|
||||||
assert.match(flowSource, /草稿风险识别/)
|
assert.match(flowSource, /草稿风险识别/)
|
||||||
assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/)
|
assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/)
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ test('application preview uses selected date range and business-specific time la
|
|||||||
assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20')
|
assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20')
|
||||||
assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间')
|
assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间')
|
||||||
assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23')
|
assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23')
|
||||||
|
assert.equal(rows.find((row) => row.key === 'time_return')?.editable, true)
|
||||||
assert.match(submitText, /出发时间:2026-02-20/)
|
assert.match(submitText, /出发时间:2026-02-20/)
|
||||||
assert.match(submitText, /返回时间:2026-02-23/)
|
assert.match(submitText, /返回时间:2026-02-23/)
|
||||||
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
|
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
|
||||||
@@ -1610,6 +1611,52 @@ test('application preview calculates base policy estimate when transport mode is
|
|||||||
assert.equal(staleEstimatePreview.fields.amount, '1,400元(不含交通)')
|
assert.equal(staleEstimatePreview.fields.amount, '1,400元(不含交通)')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('application preview estimate infers days from completed date range', () => {
|
||||||
|
const currentUser = { name: '\u674e\u6587\u9759', grade: 'P5', location: '\u6b66\u6c49' }
|
||||||
|
const preview = normalizeApplicationPreview({
|
||||||
|
fields: {
|
||||||
|
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
|
||||||
|
time: '2026-06-23 \u81f3 2026-06-25',
|
||||||
|
location: '\u5317\u4eac',
|
||||||
|
reason: '\u652f\u6491\u5ba2\u6237\u73b0\u573a\u5b9e\u65bd',
|
||||||
|
days: '',
|
||||||
|
transportMode: '',
|
||||||
|
grade: 'P5'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const request = buildApplicationPolicyEstimateRequest(preview, currentUser)
|
||||||
|
|
||||||
|
assert.equal(request.canCalculate, true)
|
||||||
|
assert.deepEqual(request.payload, {
|
||||||
|
days: 3,
|
||||||
|
location: '\u5317\u4eac',
|
||||||
|
grade: 'P5',
|
||||||
|
transport_mode: null,
|
||||||
|
origin_location: '\u6b66\u6c49',
|
||||||
|
travel_date: '2026-06-23'
|
||||||
|
})
|
||||||
|
|
||||||
|
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
|
||||||
|
days: 3,
|
||||||
|
location: '\u5317\u4eac',
|
||||||
|
matched_city: '\u5317\u4eac',
|
||||||
|
grade: 'P5',
|
||||||
|
hotel_rate: 450,
|
||||||
|
hotel_amount: 1350,
|
||||||
|
total_allowance_rate: 100,
|
||||||
|
allowance_amount: 300,
|
||||||
|
total_amount: 1650,
|
||||||
|
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
|
||||||
|
rule_version: 'v1.0.0'
|
||||||
|
}, currentUser)
|
||||||
|
|
||||||
|
assert.equal(estimatedPreview.fields.days, '3\u5929')
|
||||||
|
assert.equal(estimatedPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
|
||||||
|
assert.equal(estimatedPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
|
||||||
|
assert.equal(estimatedPreview.fields.amount, '1,650\u5143\uff08\u4e0d\u542b\u4ea4\u901a\uff09')
|
||||||
|
assert.match(estimatedPreview.fields.policyEstimate, /\u4ea4\u901a\u5f85\u8865\u5145/)
|
||||||
|
})
|
||||||
|
|
||||||
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天,服务项目部署', {
|
||||||
@@ -1726,3 +1773,197 @@ test('application preview editor recalculates days and subsidy after date range
|
|||||||
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
|
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
|
||||||
assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/)
|
assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('application preview editor can edit return date from table row', async () => {
|
||||||
|
const preview = normalizeApplicationPreview({
|
||||||
|
fields: {
|
||||||
|
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
|
||||||
|
time: '2026-02-20 \u81f3 2026-02-23',
|
||||||
|
location: '\u4e0a\u6d77',
|
||||||
|
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
|
||||||
|
days: '4\u5929',
|
||||||
|
transportMode: '\u706b\u8f66',
|
||||||
|
amount: '',
|
||||||
|
grade: 'P5',
|
||||||
|
applicant: '\u674e\u6587\u9759',
|
||||||
|
department: '\u6280\u672f\u90e8',
|
||||||
|
position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406',
|
||||||
|
managerName: '\u5411\u4e07\u7ea2'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const message = {
|
||||||
|
id: 'application-preview-editor-return-date-message',
|
||||||
|
applicationPreview: preview,
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
const requestedPayloads = []
|
||||||
|
const editor = useApplicationPreviewEditor({
|
||||||
|
persistSessionState: () => {},
|
||||||
|
toast: () => {},
|
||||||
|
currentUser: ref({ grade: 'P5' }),
|
||||||
|
calculateTravelReimbursement: async (payload) => {
|
||||||
|
requestedPayloads.push(payload)
|
||||||
|
return {
|
||||||
|
days: payload.days,
|
||||||
|
location: payload.location,
|
||||||
|
matched_city: payload.location,
|
||||||
|
grade: payload.grade,
|
||||||
|
hotel_rate: 450,
|
||||||
|
hotel_amount: 2250,
|
||||||
|
total_allowance_rate: 100,
|
||||||
|
allowance_amount: 500,
|
||||||
|
total_amount: 2750,
|
||||||
|
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
|
||||||
|
rule_version: 'v1.0.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
|
||||||
|
assert.equal(editor.resolveApplicationPreviewEditorControl('time_return'), 'date')
|
||||||
|
assert.equal(editor.applicationPreviewEditor.value.dateMode, 'range')
|
||||||
|
assert.equal(editor.applicationPreviewEditor.value.rangeStartDate, '2026-02-20')
|
||||||
|
editor.applicationPreviewEditor.value.rangeEndDate = '2026-02-24'
|
||||||
|
const committed = await editor.commitApplicationPreviewDateEditor(message)
|
||||||
|
|
||||||
|
assert.equal(committed, true)
|
||||||
|
assert.deepEqual(requestedPayloads.at(-1), {
|
||||||
|
days: 5,
|
||||||
|
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-24')
|
||||||
|
assert.equal(message.applicationPreview.fields.days, '5\u5929')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('application preview editor can edit return date from inline table input', async () => {
|
||||||
|
const preview = normalizeApplicationPreview({
|
||||||
|
fields: {
|
||||||
|
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
|
||||||
|
time: '2026-02-20 \u81f3 2026-02-23',
|
||||||
|
location: '\u4e0a\u6d77',
|
||||||
|
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
|
||||||
|
days: '4\u5929',
|
||||||
|
transportMode: '\u706b\u8f66',
|
||||||
|
amount: '',
|
||||||
|
grade: 'P5',
|
||||||
|
applicant: '\u674e\u6587\u9759',
|
||||||
|
department: '\u6280\u672f\u90e8',
|
||||||
|
position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406',
|
||||||
|
managerName: '\u5411\u4e07\u7ea2'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const message = {
|
||||||
|
id: 'application-preview-editor-inline-return-date-message',
|
||||||
|
applicationPreview: preview,
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
const requestedPayloads = []
|
||||||
|
const editor = useApplicationPreviewEditor({
|
||||||
|
persistSessionState: () => {},
|
||||||
|
toast: () => {},
|
||||||
|
currentUser: ref({ grade: 'P5' }),
|
||||||
|
calculateTravelReimbursement: async (payload) => {
|
||||||
|
requestedPayloads.push(payload)
|
||||||
|
return {
|
||||||
|
days: payload.days,
|
||||||
|
location: payload.location,
|
||||||
|
matched_city: payload.location,
|
||||||
|
grade: payload.grade,
|
||||||
|
hotel_rate: 450,
|
||||||
|
hotel_amount: 2250,
|
||||||
|
total_allowance_rate: 100,
|
||||||
|
allowance_amount: 500,
|
||||||
|
total_amount: 2750,
|
||||||
|
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
|
||||||
|
rule_version: 'v1.0.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
|
||||||
|
editor.applicationPreviewEditor.value.draftValue = '2026-02-24'
|
||||||
|
const committed = await editor.commitApplicationPreviewEditor(message)
|
||||||
|
|
||||||
|
assert.equal(committed, true)
|
||||||
|
assert.deepEqual(requestedPayloads.at(-1), {
|
||||||
|
days: 5,
|
||||||
|
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-24')
|
||||||
|
assert.equal(message.applicationPreview.fields.time_return, undefined)
|
||||||
|
assert.equal(message.applicationPreview.fields.days, '5\u5929')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('application preview editor estimates after shorthand return date input', async () => {
|
||||||
|
const preview = normalizeApplicationPreview({
|
||||||
|
fields: {
|
||||||
|
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
|
||||||
|
time: '2026-06-23',
|
||||||
|
location: '\u5317\u4eac',
|
||||||
|
reason: '\u652f\u6491\u5ba2\u6237\u73b0\u573a\u5b9e\u65bd',
|
||||||
|
days: '',
|
||||||
|
transportMode: '',
|
||||||
|
amount: '',
|
||||||
|
grade: 'P5',
|
||||||
|
applicant: '\u674e\u6587\u9759',
|
||||||
|
department: '\u6280\u672f\u90e8',
|
||||||
|
position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406',
|
||||||
|
managerName: '\u5411\u4e07\u7ea2'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const message = {
|
||||||
|
id: 'application-preview-editor-shorthand-return-date-message',
|
||||||
|
applicationPreview: preview,
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
const requestedPayloads = []
|
||||||
|
const editor = useApplicationPreviewEditor({
|
||||||
|
persistSessionState: () => {},
|
||||||
|
toast: () => {},
|
||||||
|
currentUser: ref({ grade: 'P5', location: '\u6b66\u6c49' }),
|
||||||
|
calculateTravelReimbursement: async (payload) => {
|
||||||
|
requestedPayloads.push(payload)
|
||||||
|
return {
|
||||||
|
days: payload.days,
|
||||||
|
location: payload.location,
|
||||||
|
matched_city: payload.location,
|
||||||
|
grade: payload.grade,
|
||||||
|
hotel_rate: 450,
|
||||||
|
hotel_amount: 1350,
|
||||||
|
total_allowance_rate: 100,
|
||||||
|
allowance_amount: 300,
|
||||||
|
total_amount: 1650,
|
||||||
|
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
|
||||||
|
rule_version: 'v1.0.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.openApplicationPreviewEditor(message, 'time_return', '\u5f85\u8865\u5145')
|
||||||
|
editor.applicationPreviewEditor.value.draftValue = '6\u670825\u65e5'
|
||||||
|
const committed = await editor.commitApplicationPreviewEditor(message)
|
||||||
|
|
||||||
|
assert.equal(committed, true)
|
||||||
|
assert.deepEqual(requestedPayloads.at(-1), {
|
||||||
|
days: 3,
|
||||||
|
location: '\u5317\u4eac',
|
||||||
|
grade: 'P5',
|
||||||
|
transport_mode: null,
|
||||||
|
origin_location: '\u6b66\u6c49',
|
||||||
|
travel_date: '2026-06-23'
|
||||||
|
})
|
||||||
|
assert.equal(message.applicationPreview.fields.time, '2026-06-23 \u81f3 2026-06-25')
|
||||||
|
assert.equal(message.applicationPreview.fields.days, '3\u5929')
|
||||||
|
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
|
||||||
|
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
|
||||||
|
assert.equal(message.applicationPreview.fields.amount, '1,650\u5143\uff08\u4e0d\u542b\u4ea4\u901a\uff09')
|
||||||
|
assert.match(message.applicationPreview.fields.policyEstimate, /\u4ea4\u901a\u5f85\u8865\u5145/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -83,3 +83,44 @@ test('steward pending flow confirmation builds candidate actions', () => {
|
|||||||
assert.equal(actions[0].payload.flow_id, 'travel_application')
|
assert.equal(actions[0].payload.flow_id, 'travel_application')
|
||||||
assert.equal(actions[1].payload.flow_id, 'travel_reimbursement')
|
assert.equal(actions[1].payload.flow_id, 'travel_reimbursement')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('steward ready application confirmation routes workbench action to inline preview table', () => {
|
||||||
|
const actions = buildStewardSuggestedActions({
|
||||||
|
plan_id: 'steward-plan-ready-application',
|
||||||
|
plan_status: 'ready',
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
task_id: 'task-application-beijing',
|
||||||
|
task_type: 'expense_application',
|
||||||
|
title: '费用申请 2026-06-23 北京',
|
||||||
|
summary: '明天前往北京出差3天,支撑客户现场实施。',
|
||||||
|
assigned_agent: 'application_assistant',
|
||||||
|
ontology_fields: {
|
||||||
|
expense_type: 'travel',
|
||||||
|
time_range: '2026-06-23 至 2026-06-25',
|
||||||
|
location: '北京',
|
||||||
|
days: '3天',
|
||||||
|
reason: '支撑客户现场实施'
|
||||||
|
},
|
||||||
|
missing_fields: ['transport_mode']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
confirmation_groups: [
|
||||||
|
{
|
||||||
|
confirmation_id: 'confirm-application-beijing',
|
||||||
|
action_type: 'confirm_create_application',
|
||||||
|
target_task_id: 'task-application-beijing'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(actions.length, 1)
|
||||||
|
assert.equal(actions[0].label, '确定,先创建申请单')
|
||||||
|
assert.equal(actions[0].payload.steward_confirm_flow, true)
|
||||||
|
assert.equal(actions[0].payload.flow_id, 'travel_application')
|
||||||
|
assert.equal(actions[0].payload.expense_type, 'travel')
|
||||||
|
assert.equal(actions[0].payload.expense_type_label, '差旅费')
|
||||||
|
assert.match(actions[0].payload.carry_text, /支撑客户现场实施/)
|
||||||
|
assert.match(actions[0].payload.carry_text, /北京/)
|
||||||
|
assert.match(actions[0].payload.carry_text, /2026-06-23 至 2026-06-25/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ import {
|
|||||||
filterRequiredApplicationCandidates,
|
filterRequiredApplicationCandidates,
|
||||||
requiresApplicationBeforeReimbursement
|
requiresApplicationBeforeReimbursement
|
||||||
} from '../src/views/scripts/travelReimbursementApplicationLinkModel.js'
|
} from '../src/views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||||
|
import {
|
||||||
|
SKIP_REQUIRED_APPLICATION_LINK_ACTION,
|
||||||
|
buildReimbursementAssociationActions,
|
||||||
|
buildReimbursementAssociationMissingText,
|
||||||
|
buildReimbursementAssociationQueryPayload,
|
||||||
|
buildReimbursementAssociationSelectionText,
|
||||||
|
buildReimbursementAssociationSubmitOptions,
|
||||||
|
buildReimbursementAssociationThinkingEvents
|
||||||
|
} from '../src/views/scripts/travelReimbursementAssociationGateModel.js'
|
||||||
import {
|
import {
|
||||||
ASSISTANT_SCOPE_ACTION_SWITCH,
|
ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||||
resolveAssistantScopeGuard
|
resolveAssistantScopeGuard
|
||||||
@@ -335,6 +344,28 @@ test('guided reimbursement requires application selection for travel and enterta
|
|||||||
assert.equal(actions[0].action_type, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION)
|
assert.equal(actions[0].action_type, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION)
|
||||||
assert.equal(actions[0].payload.application_claim_no, 'AP-202605-001')
|
assert.equal(actions[0].payload.application_claim_no, 'AP-202605-001')
|
||||||
|
|
||||||
|
const associationActions = buildReimbursementAssociationActions(travelApplications, '我要报销')
|
||||||
|
assert.equal(associationActions[0].action_type, 'select_required_application')
|
||||||
|
assert.equal(associationActions[0].payload.application_claim_no, 'AP-202605-001')
|
||||||
|
assert.equal(associationActions.at(-1).action_type, SKIP_REQUIRED_APPLICATION_LINK_ACTION)
|
||||||
|
assert.match(buildReimbursementAssociationSelectionText(travelApplications), /单独新建报销单/)
|
||||||
|
assert.match(buildReimbursementAssociationSelectionText(travelApplications), /ai-document-card-list/)
|
||||||
|
assert.match(buildReimbursementAssociationSelectionText(travelApplications), /ai-document-card--application/)
|
||||||
|
assert.match(buildReimbursementAssociationMissingText(), /单独新建报销单/)
|
||||||
|
const associationQueryPayload = buildReimbursementAssociationQueryPayload(travelApplications)
|
||||||
|
assert.equal(associationQueryPayload.selectionMode, 'reimbursement_application_association')
|
||||||
|
assert.equal(associationQueryPayload.records[0].claimNo, 'AP-202605-001')
|
||||||
|
const completedThinking = buildReimbursementAssociationThinkingEvents('completed', { candidateCount: 1 })
|
||||||
|
assert.equal(completedThinking[0].title, '判断用户意图')
|
||||||
|
assert.equal(completedThinking.at(-1).status, 'completed')
|
||||||
|
const associationSubmitOptions = buildReimbursementAssociationSubmitOptions(
|
||||||
|
associationActions[0].payload,
|
||||||
|
'我要报销'
|
||||||
|
)
|
||||||
|
assert.equal(associationSubmitOptions.skipDraftAssociationPrompt, true)
|
||||||
|
assert.equal(associationSubmitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')
|
||||||
|
assert.equal(associationSubmitOptions.extraContext.review_form_values.application_business_time, '2026-05-20 至 2026-05-23')
|
||||||
|
|
||||||
let state = waitForGuidedApplicationSelection(createGuidedReimbursementState(), 'travel', travelApplications)
|
let state = waitForGuidedApplicationSelection(createGuidedReimbursementState(), 'travel', travelApplications)
|
||||||
assert.equal(state.stepKey, 'application_selection')
|
assert.equal(state.stepKey, 'application_selection')
|
||||||
assert.equal(state.applicationCandidates[0].claim_no, 'AP-202605-001')
|
assert.equal(state.applicationCandidates[0].claim_no, 'AP-202605-001')
|
||||||
@@ -459,7 +490,13 @@ test('guided flow is local until final confirmation or collected query handoff',
|
|||||||
assert.match(messageHandlersScript, /if \(await handleGuidedComposerSubmit\(options\)\) return null[\s\S]*return submitComposerInternal\(options\)/)
|
assert.match(messageHandlersScript, /if \(await handleGuidedComposerSubmit\(options\)\) return null[\s\S]*return submitComposerInternal\(options\)/)
|
||||||
assert.match(suggestedActionsScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
assert.match(suggestedActionsScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
||||||
assert.match(suggestedActionsScript, /actionPayload\.carry_text/)
|
assert.match(suggestedActionsScript, /actionPayload\.carry_text/)
|
||||||
assert.match(suggestedActionsScript, /targetSessionType === SESSION_TYPE_EXPENSE[\s\S]*carryText === '我要报销'[\s\S]*pushExpenseSceneSelectionPrompt\(carryText\)/)
|
assert.match(suggestedActionsScript, /targetSessionType === SESSION_TYPE_EXPENSE[\s\S]*carryText === '我要报销'[\s\S]*pushExpenseAssociationGatePrompt\(carryText\)/)
|
||||||
|
assert.match(suggestedActionsScript, /actionType === SKIP_REQUIRED_APPLICATION_LINK_ACTION[\s\S]*pushExpenseSceneSelectionPrompt/)
|
||||||
|
assert.match(suggestedActionsScript, /actionType === 'confirm_expense_intent'[\s\S]*pushExpenseAssociationGatePrompt\(originalMessage\)/)
|
||||||
|
assert.match(suggestedActionsScript, /pushReimbursementAssociationPromptMessage\(\{[\s\S]*skipDraftCheck: Boolean\(options\.skipDraftCheck\)/)
|
||||||
|
assert.match(submitComposerScript, /waitForExpenseSceneSelection[\s\S]*pushReimbursementAssociationPromptMessage\(\{[\s\S]*rawText/)
|
||||||
|
assert.match(submitComposerScript, /fetchExpenseClaims[\s\S]*currentUser/)
|
||||||
|
assert.doesNotMatch(submitComposerScript, /if \(waitForExpenseSceneSelection\) \{[\s\S]{0,260}buildExpenseSceneSelectionMessage/)
|
||||||
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\)/)
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ test('submit composer scopes the side panel to intent overview, document upload,
|
|||||||
|
|
||||||
test('expense query answers keep one clear result structure with document center jump link', () => {
|
test('expense query answers keep one clear result structure with document center jump link', () => {
|
||||||
assert.doesNotMatch(createViewTemplateSurface, /message\.meta\?\.length/)
|
assert.doesNotMatch(createViewTemplateSurface, /message\.meta\?\.length/)
|
||||||
assert.match(createViewTemplateSurface, /!message\.reviewPayload && !message\.queryPayload && message\.suggestedActions\?\.length/)
|
assert.match(createViewTemplateSurface, /!message\.reviewPayload && \(!message\.queryPayload \|\| message\.queryPayload\.selectionMode === 'reimbursement_application_association'\) && message\.suggestedActions\?\.length/)
|
||||||
assert.match(createViewTemplateSurface, /!message\.reviewPayload && !message\.queryPayload && message\.citations\?\.length/)
|
assert.match(createViewTemplateSurface, /!message\.reviewPayload && !message\.queryPayload && message\.citations\?\.length/)
|
||||||
assert.match(createViewTemplateSurface, /message\.queryPayload\.title \|\| \(message\.queryPayload\.selectionMode === 'draft_association' \? '选择关联草稿' : '最近 5 条筛选结果'\)/)
|
assert.match(createViewTemplateSurface, /message\.queryPayload\.title \|\| \(message\.queryPayload\.selectionMode === 'draft_association' \? '选择关联草稿' : '最近 5 条筛选结果'\)/)
|
||||||
assert.match(createViewTemplateSurface, /v-html="ui\.renderMarkdown\(ui\.buildExpenseQueryHint\(message\.queryPayload\)\)"/)
|
assert.match(createViewTemplateSurface, /v-html="ui\.renderMarkdown\(ui\.buildExpenseQueryHint\(message\.queryPayload\)\)"/)
|
||||||
|
|||||||
138
web/tests/workbench-ai-action-router.test.mjs
Normal file
138
web/tests/workbench-ai-action-router.test.mjs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||||
|
import { buildStewardSuggestedActions } from '../src/views/scripts/stewardPlanModel.js'
|
||||||
|
import { useWorkbenchAiActionRouter } from '../src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js'
|
||||||
|
|
||||||
|
test('workbench steward application confirmation opens inline application preview directly', () => {
|
||||||
|
const [action] = buildStewardSuggestedActions({
|
||||||
|
plan_id: 'steward-plan-ready-application',
|
||||||
|
plan_status: 'ready',
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
task_id: 'task-application-beijing',
|
||||||
|
task_type: 'expense_application',
|
||||||
|
title: '费用申请 2026-06-23 北京',
|
||||||
|
summary: '明天前往北京出差3天,支撑客户现场实施。',
|
||||||
|
assigned_agent: 'application_assistant',
|
||||||
|
ontology_fields: {
|
||||||
|
expense_type: 'travel',
|
||||||
|
time_range: '2026-06-23 至 2026-06-25',
|
||||||
|
location: '北京',
|
||||||
|
days: '3天',
|
||||||
|
reason: '支撑客户现场实施'
|
||||||
|
},
|
||||||
|
missing_fields: ['transport_mode']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
confirmation_groups: [
|
||||||
|
{
|
||||||
|
confirmation_id: 'confirm-application-beijing',
|
||||||
|
action_type: 'confirm_create_application',
|
||||||
|
target_task_id: 'task-application-beijing'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
let previewPayload = null
|
||||||
|
let fallbackConversationStarted = false
|
||||||
|
const router = useWorkbenchAiActionRouter({
|
||||||
|
aiExpenseDraft: { value: null },
|
||||||
|
applicationFlow: {
|
||||||
|
isInlineSuggestedActionDisabled: () => false,
|
||||||
|
executeInlineApplicationPreviewAction: () => {}
|
||||||
|
},
|
||||||
|
assistantDraft: { value: '' },
|
||||||
|
attachmentFlow: {
|
||||||
|
confirmAiAttachmentAssociation: () => {}
|
||||||
|
},
|
||||||
|
emit: () => {},
|
||||||
|
expenseFlow: {
|
||||||
|
linkAiExpenseApplication: () => {},
|
||||||
|
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||||||
|
startAiApplicationPreviewFromAction: (payload) => {
|
||||||
|
previewPayload = payload
|
||||||
|
},
|
||||||
|
startAiExpenseDraft: () => {}
|
||||||
|
},
|
||||||
|
focusAiModeInput: () => {},
|
||||||
|
hasInlineAttachmentOcrDetails: () => false,
|
||||||
|
resolveLatestInlineUserPrompt: () => '',
|
||||||
|
selectedFiles: { value: [] },
|
||||||
|
startInlineConversation: () => {
|
||||||
|
fallbackConversationStarted = true
|
||||||
|
},
|
||||||
|
toast: () => {},
|
||||||
|
toggleInlineAttachmentOcrDetails: () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.handleInlineSuggestedAction(action)
|
||||||
|
|
||||||
|
assert.equal(fallbackConversationStarted, false)
|
||||||
|
assert.equal(previewPayload?.flow_id, 'travel_application')
|
||||||
|
assert.equal(previewPayload?.expense_type, 'travel')
|
||||||
|
assert.equal(previewPayload?.expense_type_label, '差旅费')
|
||||||
|
assert.match(previewPayload?.carry_text || '', /支撑客户现场实施/)
|
||||||
|
|
||||||
|
const preview = buildInlineApplicationPreview(previewPayload.expense_type_label, previewPayload.carry_text, {
|
||||||
|
name: '测试用户',
|
||||||
|
departmentName: '交付部',
|
||||||
|
position: '实施顾问',
|
||||||
|
managerName: '张经理',
|
||||||
|
grade: 'P5'
|
||||||
|
})
|
||||||
|
assert.equal(preview.fields.time, '2026-06-23 至 2026-06-25')
|
||||||
|
assert.equal(preview.fields.location, '北京')
|
||||||
|
assert.equal(preview.fields.reason, '支撑客户现场实施')
|
||||||
|
assert.equal(preview.fields.days, '3天')
|
||||||
|
assert.equal(preview.fields.transportMode, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench reimbursement skip link action opens new reimbursement flow', () => {
|
||||||
|
let sceneSelectionPayload = null
|
||||||
|
let fallbackConversationStarted = false
|
||||||
|
const router = useWorkbenchAiActionRouter({
|
||||||
|
aiExpenseDraft: { value: null },
|
||||||
|
applicationFlow: {
|
||||||
|
isInlineSuggestedActionDisabled: () => false,
|
||||||
|
executeInlineApplicationPreviewAction: () => {}
|
||||||
|
},
|
||||||
|
assistantDraft: { value: '' },
|
||||||
|
attachmentFlow: {
|
||||||
|
confirmAiAttachmentAssociation: () => {}
|
||||||
|
},
|
||||||
|
emit: () => {},
|
||||||
|
expenseFlow: {
|
||||||
|
linkAiExpenseApplication: () => {},
|
||||||
|
pushInlineExpenseSceneSelectionPrompt: (sourceText, label) => {
|
||||||
|
sceneSelectionPayload = { sourceText, label }
|
||||||
|
},
|
||||||
|
startAiApplicationPreviewFromAction: () => {},
|
||||||
|
startAiExpenseDraft: () => {}
|
||||||
|
},
|
||||||
|
focusAiModeInput: () => {},
|
||||||
|
hasInlineAttachmentOcrDetails: () => false,
|
||||||
|
resolveLatestInlineUserPrompt: () => '',
|
||||||
|
selectedFiles: { value: [] },
|
||||||
|
startInlineConversation: () => {
|
||||||
|
fallbackConversationStarted = true
|
||||||
|
},
|
||||||
|
toast: () => {},
|
||||||
|
toggleInlineAttachmentOcrDetails: () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.handleInlineSuggestedAction({
|
||||||
|
label: '不关联,单独新建报销单',
|
||||||
|
action_type: 'skip_required_application_link',
|
||||||
|
payload: {
|
||||||
|
original_message: '我要报销'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(fallbackConversationStarted, false)
|
||||||
|
assert.deepEqual(sceneSelectionPayload, {
|
||||||
|
sourceText: '我要报销',
|
||||||
|
label: '不关联,单独新建报销单'
|
||||||
|
})
|
||||||
|
})
|
||||||
326
web/tests/workbench-ai-reimbursement-association-gate.test.mjs
Normal file
326
web/tests/workbench-ai-reimbursement-association-gate.test.mjs
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { useWorkbenchAiExpenseFlow } from '../src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js'
|
||||||
|
|
||||||
|
const personalWorkbenchAiMode = readFileSync(
|
||||||
|
join(process.cwd(), 'web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
function createInlineMessage(role, content, extras = {}) {
|
||||||
|
return {
|
||||||
|
id: `${role}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
text: content,
|
||||||
|
...extras
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFlow(options = {}) {
|
||||||
|
const conversationMessages = { value: [] }
|
||||||
|
const aiExpenseDraft = { value: null }
|
||||||
|
const activated = []
|
||||||
|
let persisted = 0
|
||||||
|
const scrolled = []
|
||||||
|
|
||||||
|
const flow = useWorkbenchAiExpenseFlow({
|
||||||
|
activateInlineConversation: (payload) => {
|
||||||
|
activated.push(payload)
|
||||||
|
conversationStarted.value = true
|
||||||
|
},
|
||||||
|
aiExpenseDraft,
|
||||||
|
assistantDraft: { value: '我要报销' },
|
||||||
|
clearAiModeFiles: () => {},
|
||||||
|
closeWorkbenchDatePicker: () => {},
|
||||||
|
conversationMessages,
|
||||||
|
conversationStarted,
|
||||||
|
createInlineMessage,
|
||||||
|
currentUser: { value: options.currentUser || { name: '张小青', username: 'xiaoqing.zhang' } },
|
||||||
|
fetchExpenseClaimsForAi: options.fetchExpenseClaimsForAi,
|
||||||
|
runOrchestratorForAi: options.runOrchestratorForAi,
|
||||||
|
associationQueryTimeoutMs: options.associationQueryTimeoutMs,
|
||||||
|
persistCurrentConversation: () => {
|
||||||
|
persisted += 1
|
||||||
|
},
|
||||||
|
pushInlineUserMessage: (text) => {
|
||||||
|
conversationMessages.value.push(createInlineMessage('user', text))
|
||||||
|
},
|
||||||
|
removeWorkbenchDateTag: () => {},
|
||||||
|
resolveLatestInlineUserPrompt: () => '我要报销',
|
||||||
|
scrollInlineConversationToBottom: (payload) => {
|
||||||
|
scrolled.push(payload || {})
|
||||||
|
},
|
||||||
|
startAiApplicationPreview: () => {}
|
||||||
|
})
|
||||||
|
return { activated, aiExpenseDraft, conversationMessages, flow, get persisted() { return persisted }, scrolled }
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationStarted = { value: false }
|
||||||
|
|
||||||
|
test('reimbursement intent checks drafts before recommending approved application documents', async () => {
|
||||||
|
conversationStarted.value = false
|
||||||
|
let queried = 0
|
||||||
|
const { conversationMessages, flow } = buildFlow({
|
||||||
|
fetchExpenseClaimsForAi: async () => {
|
||||||
|
queried += 1
|
||||||
|
return {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'app-travel-1',
|
||||||
|
claim_no: 'AP-202606-001',
|
||||||
|
employee_name: '张小青',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: '北京客户现场实施',
|
||||||
|
location: '北京',
|
||||||
|
status: 'approved',
|
||||||
|
start_date: '2026-06-23',
|
||||||
|
end_date: '2026-06-25',
|
||||||
|
amount: 1650
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 're-linked-1',
|
||||||
|
claim_no: 'RE-202606-001',
|
||||||
|
employee_name: '张小青',
|
||||||
|
expense_type: 'travel',
|
||||||
|
status: 'submitted',
|
||||||
|
risk_flags_json: [{
|
||||||
|
source: 'application_link',
|
||||||
|
application_claim_no: 'AP-202606-002'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-linked-1',
|
||||||
|
claim_no: 'AP-202606-002',
|
||||||
|
employee_name: '张小青',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: '已被关联的申请',
|
||||||
|
status: 'approved'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flow.startAiReimbursementAssociationGate('我要报销')
|
||||||
|
|
||||||
|
assert.equal(queried, 1)
|
||||||
|
assert.equal(conversationMessages.value[0]?.role, 'user')
|
||||||
|
assert.equal(conversationMessages.value[0]?.content, '我要报销')
|
||||||
|
const assistantMessage = conversationMessages.value.at(-1)
|
||||||
|
assert.equal(assistantMessage.role, 'assistant')
|
||||||
|
assert.match(assistantMessage.content, /先检查.*报销草稿/)
|
||||||
|
assert.match(assistantMessage.content, /没有查到可继续的报销草稿/)
|
||||||
|
assert.match(assistantMessage.content, /先查询.*可关联申请单/)
|
||||||
|
assert.match(assistantMessage.content, /AP-202606-001/)
|
||||||
|
assert.doesNotMatch(assistantMessage.content, /AP-202606-002/)
|
||||||
|
assert.equal(assistantMessage.suggestedActions[0].action_type, 'select_required_application')
|
||||||
|
assert.equal(assistantMessage.suggestedActions[0].payload.application_claim_no, 'AP-202606-001')
|
||||||
|
assert.equal(assistantMessage.suggestedActions.at(-1).action_type, 'skip_required_application_link')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reimbursement intent stops at existing reimbursement drafts before application association', async () => {
|
||||||
|
conversationStarted.value = false
|
||||||
|
const { conversationMessages, flow } = buildFlow({
|
||||||
|
fetchExpenseClaimsForAi: async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'draft-travel-1',
|
||||||
|
claim_no: 'RE-202606-010',
|
||||||
|
employee_name: '张小青',
|
||||||
|
expense_type: 'travel',
|
||||||
|
reason: '北京客户现场实施报销',
|
||||||
|
location: '北京',
|
||||||
|
status: 'draft',
|
||||||
|
amount: 650,
|
||||||
|
created_at: '2026-06-23T10:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-travel-1',
|
||||||
|
claim_no: 'AP-202606-001',
|
||||||
|
employee_name: '张小青',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: '北京客户现场实施',
|
||||||
|
location: '北京',
|
||||||
|
status: 'approved',
|
||||||
|
start_date: '2026-06-23',
|
||||||
|
end_date: '2026-06-25',
|
||||||
|
amount: 1650
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await flow.startAiReimbursementAssociationGate('我要报销')
|
||||||
|
|
||||||
|
const assistantMessage = conversationMessages.value.at(-1)
|
||||||
|
assert.match(assistantMessage.content, /先检查.*报销草稿/)
|
||||||
|
assert.match(assistantMessage.content, /查到 1 个可继续的报销草稿/)
|
||||||
|
assert.match(assistantMessage.content, /RE-202606-010/)
|
||||||
|
assert.doesNotMatch(assistantMessage.content, /AP-202606-001/)
|
||||||
|
assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail')
|
||||||
|
assert.match(assistantMessage.suggestedActions[0].label, /继续草稿/)
|
||||||
|
assert.equal(assistantMessage.suggestedActions.at(-1).action_type, 'skip_reimbursement_draft_check')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reimbursement association gate shows thinking before querying and renders application cards', async () => {
|
||||||
|
conversationStarted.value = false
|
||||||
|
let queried = 0
|
||||||
|
let resolveClaims = null
|
||||||
|
const claimsPromise = new Promise((resolve) => {
|
||||||
|
resolveClaims = resolve
|
||||||
|
})
|
||||||
|
const { conversationMessages, flow } = buildFlow({
|
||||||
|
fetchExpenseClaimsForAi: async () => {
|
||||||
|
queried += 1
|
||||||
|
return claimsPromise
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const gatePromise = flow.startAiReimbursementAssociationGate('我要报销')
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
assert.equal(queried, 0)
|
||||||
|
assert.equal(conversationMessages.value[0]?.role, 'user')
|
||||||
|
const thinkingMessage = conversationMessages.value[1]
|
||||||
|
assert.equal(thinkingMessage.role, 'assistant')
|
||||||
|
assert.equal(thinkingMessage.pending, true)
|
||||||
|
assert.equal(thinkingMessage.stewardPlan.streamStatus, 'streaming')
|
||||||
|
assert.match(thinkingMessage.stewardPlan.thinkingEvents[0].title, /判断用户意图/)
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 360))
|
||||||
|
assert.equal(queried, 1)
|
||||||
|
const queryMessage = conversationMessages.value[1]
|
||||||
|
assert.notEqual(queryMessage, thinkingMessage)
|
||||||
|
assert.match(queryMessage.stewardPlan.thinkingEvents[1].title, /检查报销草稿/)
|
||||||
|
resolveClaims({
|
||||||
|
items: [{
|
||||||
|
id: 'app-travel-card',
|
||||||
|
claim_no: 'AP-202606-006',
|
||||||
|
employee_name: '张小青',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: '北京客户现场实施',
|
||||||
|
location: '北京',
|
||||||
|
status: 'approved',
|
||||||
|
start_date: '2026-06-23',
|
||||||
|
end_date: '2026-06-25',
|
||||||
|
amount: 1650
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
await gatePromise
|
||||||
|
|
||||||
|
const finalMessage = conversationMessages.value[1]
|
||||||
|
assert.notEqual(finalMessage, queryMessage)
|
||||||
|
assert.equal(finalMessage.pending, false)
|
||||||
|
assert.equal(finalMessage.stewardPlan.streamStatus, 'completed')
|
||||||
|
assert.match(finalMessage.content, /没有查到可继续的报销草稿/)
|
||||||
|
assert.match(finalMessage.content, /ai-document-card-list/)
|
||||||
|
assert.match(finalMessage.content, /ai-document-card--application/)
|
||||||
|
assert.match(finalMessage.content, /AP-202606-006/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reimbursement association gate times out stalled claim query and unlocks fallback actions', async () => {
|
||||||
|
conversationStarted.value = false
|
||||||
|
const { conversationMessages, flow } = buildFlow({
|
||||||
|
associationQueryTimeoutMs: 5,
|
||||||
|
fetchExpenseClaimsForAi: async () => new Promise(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
const gatePromise = flow.startAiReimbursementAssociationGate('发起报销')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 360))
|
||||||
|
await gatePromise
|
||||||
|
|
||||||
|
const assistantMessage = conversationMessages.value.at(-1)
|
||||||
|
assert.equal(assistantMessage.pending, false)
|
||||||
|
assert.equal(assistantMessage.stewardPlan.streamStatus, 'failed')
|
||||||
|
assert.match(assistantMessage.content, /查询可关联申请单.*超时|查询可关联申请单时出现异常/)
|
||||||
|
assert.equal(assistantMessage.suggestedActions.at(-1).action_type, 'skip_required_application_link')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reimbursement association gate matches short username with returned employee email', async () => {
|
||||||
|
conversationStarted.value = false
|
||||||
|
const { conversationMessages, flow } = buildFlow({
|
||||||
|
currentUser: { name: 'caoxiaozhu', username: 'caoxiaozhu' },
|
||||||
|
fetchExpenseClaimsForAi: async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'app-short-owner',
|
||||||
|
claim_no: 'AVF9ST8TT',
|
||||||
|
employee_id: 'emp-caoxiaozhu',
|
||||||
|
employee_name: '曹笑竹',
|
||||||
|
employee_email: 'caoxiaozhu@xf.com',
|
||||||
|
employee_no: 'E90919',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: '参加相关残联会议',
|
||||||
|
location: '上海',
|
||||||
|
status: 'approved',
|
||||||
|
amount: 1200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await flow.startAiReimbursementAssociationGate('发起报销')
|
||||||
|
|
||||||
|
const assistantMessage = conversationMessages.value.at(-1)
|
||||||
|
assert.match(assistantMessage.content, /AVF9ST8TT/)
|
||||||
|
assert.equal(assistantMessage.suggestedActions[0].action_type, 'select_required_application')
|
||||||
|
assert.equal(assistantMessage.suggestedActions[0].payload.application_claim_no, 'AVF9ST8TT')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('linked application selection can create reimbursement draft from association gate', async () => {
|
||||||
|
conversationStarted.value = false
|
||||||
|
const orchestratorCalls = []
|
||||||
|
const { aiExpenseDraft, conversationMessages, flow } = buildFlow({
|
||||||
|
fetchExpenseClaimsForAi: async () => ({ items: [] }),
|
||||||
|
runOrchestratorForAi: async (payload, options) => {
|
||||||
|
orchestratorCalls.push({ payload, options })
|
||||||
|
return {
|
||||||
|
status: 'succeeded',
|
||||||
|
conversation_id: 'conv-linked-draft',
|
||||||
|
result: {
|
||||||
|
draft_payload: {
|
||||||
|
claim_id: 'draft-linked-1',
|
||||||
|
claim_no: 'RE-202606-009',
|
||||||
|
status: 'draft',
|
||||||
|
expense_type: 'travel',
|
||||||
|
reason: '北京客户现场实施'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flow.linkAiExpenseApplication({
|
||||||
|
application_claim_no: 'AP-202606-001',
|
||||||
|
application_expense_type: 'travel_application',
|
||||||
|
application_reason: '北京客户现场实施',
|
||||||
|
application_location: '北京',
|
||||||
|
application_business_time: '2026-06-23 至 2026-06-25',
|
||||||
|
application_amount_label: '1,650元'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(orchestratorCalls.length, 1)
|
||||||
|
assert.equal(orchestratorCalls[0].payload.context_json.review_action, 'save_draft')
|
||||||
|
assert.equal(orchestratorCalls[0].payload.context_json.expense_scene_selection.application_claim_no, 'AP-202606-001')
|
||||||
|
assert.equal(orchestratorCalls[0].payload.context_json.review_form_values.application_claim_no, 'AP-202606-001')
|
||||||
|
assert.equal(aiExpenseDraft.value, null)
|
||||||
|
assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-009 已生成/)
|
||||||
|
assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-009')
|
||||||
|
assert.equal(conversationMessages.value.at(-1).suggestedActions[0].action_type, 'open_application_detail')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
|
||||||
|
assert.match(personalWorkbenchAiMode, /function isReimbursementCreationIntent\(prompt = ''\)/)
|
||||||
|
const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
|
||||||
|
const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)
|
||||||
|
const stewardIndex = personalWorkbenchAiMode.indexOf('stewardFlow.requestInlineAssistantReply(cleanPrompt', startConversationIndex)
|
||||||
|
|
||||||
|
assert.ok(startConversationIndex >= 0)
|
||||||
|
assert.ok(gateIndex > startConversationIndex)
|
||||||
|
assert.ok(stewardIndex > gateIndex)
|
||||||
|
assert.match(personalWorkbenchAiMode, /function runAiModeAction\(item\)[\s\S]*expenseFlow\.startAiReimbursementAssociationGate\(item\.prompt, item\.label\)/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user