feat(ai): add expense application feedback ledger
This commit is contained in:
67
web/src/utils/expenseApplicationDecisionFeedback.js
Normal file
67
web/src/utils/expenseApplicationDecisionFeedback.js
Normal file
@@ -0,0 +1,67 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function resolveTrackedFieldKey(fieldKey = '') {
|
||||
const normalized = normalizeText(fieldKey)
|
||||
return normalized === 'time_return' ? 'time' : normalized
|
||||
}
|
||||
|
||||
function resolveExistingChanges(preview = {}) {
|
||||
const feedback = preview?.aiDecisionFeedback
|
||||
return Array.isArray(feedback?.changedFields)
|
||||
? feedback.changedFields.filter((item) => item && typeof item === 'object')
|
||||
: []
|
||||
}
|
||||
|
||||
function resolveTrackedFieldKeys(fieldKeyOrKeys = '') {
|
||||
const fieldKeys = Array.isArray(fieldKeyOrKeys) ? fieldKeyOrKeys : [fieldKeyOrKeys]
|
||||
return [...new Set(fieldKeys.map(resolveTrackedFieldKey).filter(Boolean))]
|
||||
}
|
||||
|
||||
function resolveFeedbackRevision(preview = {}) {
|
||||
const revision = Number(preview?.aiDecisionFeedback?.revision)
|
||||
return Number.isSafeInteger(revision) && revision >= 0 ? revision : 0
|
||||
}
|
||||
|
||||
export function trackApplicationPreviewFieldEdit(
|
||||
previousPreview = {},
|
||||
nextPreview = {},
|
||||
fieldKeyOrKeys = ''
|
||||
) {
|
||||
const trackedFieldKeys = resolveTrackedFieldKeys(fieldKeyOrKeys)
|
||||
if (!trackedFieldKeys.length) return nextPreview
|
||||
|
||||
const existingChanges = resolveExistingChanges(previousPreview)
|
||||
const retainedChanges = existingChanges.filter(
|
||||
(item) => !trackedFieldKeys.includes(resolveTrackedFieldKey(item.fieldKey))
|
||||
)
|
||||
|
||||
for (const trackedFieldKey of trackedFieldKeys) {
|
||||
const existingChange = existingChanges.find(
|
||||
(item) => resolveTrackedFieldKey(item.fieldKey) === trackedFieldKey
|
||||
)
|
||||
const suggestedValue = existingChange
|
||||
? normalizeText(existingChange.suggestedValue)
|
||||
: normalizeText(previousPreview?.fields?.[trackedFieldKey])
|
||||
const finalValue = normalizeText(nextPreview?.fields?.[trackedFieldKey])
|
||||
if (suggestedValue !== finalValue) {
|
||||
retainedChanges.push({
|
||||
fieldKey: trackedFieldKey,
|
||||
suggestedValue,
|
||||
finalValue
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...nextPreview,
|
||||
aiDecisionFeedback: {
|
||||
schemaVersion: 1,
|
||||
revision: resolveFeedbackRevision(previousPreview) + 1,
|
||||
changedFields: retainedChanges.sort((left, right) => (
|
||||
normalizeText(left.fieldKey).localeCompare(normalizeText(right.fieldKey))
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,66 @@ import {
|
||||
resolveApplicationDaysFromDateRange,
|
||||
refreshApplicationPreviewTransportEstimate
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { trackApplicationPreviewFieldEdit } from '../../utils/expenseApplicationDecisionFeedback.js'
|
||||
import {
|
||||
buildWorkbenchDateLabel,
|
||||
canApplyWorkbenchDateSelection,
|
||||
getTodayDateValue
|
||||
} from '../../utils/workbenchComposerDate.js'
|
||||
|
||||
const APPLICATION_ESTIMATE_INPUT_FIELD_KEYS = [
|
||||
'applicationType',
|
||||
'time',
|
||||
'location',
|
||||
'days',
|
||||
'transportMode',
|
||||
'grade'
|
||||
]
|
||||
|
||||
const APPLICATION_ESTIMATE_RESULT_FIELD_KEYS = [
|
||||
'lodgingDailyCap',
|
||||
'subsidyDailyCap',
|
||||
'transportPolicy',
|
||||
'policyEstimate',
|
||||
'amount',
|
||||
'matchedCity',
|
||||
'ruleName',
|
||||
'ruleVersion',
|
||||
'hotelAmount',
|
||||
'allowanceAmount',
|
||||
'transportEstimatedAmount',
|
||||
'transportEstimateDate',
|
||||
'transportQueryLatencyMs',
|
||||
'transportEstimateSource',
|
||||
'transportEstimateConfidence',
|
||||
'policyTotalAmount'
|
||||
]
|
||||
|
||||
function buildApplicationEstimateInputFingerprint(preview = {}) {
|
||||
const fields = preview?.fields || {}
|
||||
return JSON.stringify(
|
||||
APPLICATION_ESTIMATE_INPUT_FIELD_KEYS.map((fieldKey) => String(fields[fieldKey] || '').trim())
|
||||
)
|
||||
}
|
||||
|
||||
function mergeApplicationEstimateResult(currentPreview = {}, refreshedPreview = {}) {
|
||||
const refreshedFields = refreshedPreview?.fields || {}
|
||||
const estimateFields = Object.fromEntries(
|
||||
APPLICATION_ESTIMATE_RESULT_FIELD_KEYS
|
||||
.filter((fieldKey) => Object.hasOwn(refreshedFields, fieldKey))
|
||||
.map((fieldKey) => [fieldKey, refreshedFields[fieldKey]])
|
||||
)
|
||||
return normalizeApplicationPreview({
|
||||
...currentPreview,
|
||||
fields: {
|
||||
...(currentPreview?.fields || {}),
|
||||
...estimateFields
|
||||
},
|
||||
policyEstimate: refreshedPreview?.policyEstimate,
|
||||
policyEstimateStatus: refreshedPreview?.policyEstimateStatus
|
||||
})
|
||||
}
|
||||
|
||||
function parseEditorDateMatches(value) {
|
||||
const text = String(value || '').trim()
|
||||
return [...text.matchAll(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g)]
|
||||
@@ -186,7 +240,14 @@ function validateApplicationPreviewDateRange(value = '') {
|
||||
}
|
||||
|
||||
function shouldRefreshTransportEstimate(fieldKey) {
|
||||
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
|
||||
return [
|
||||
'applicationType',
|
||||
'transportMode',
|
||||
'time',
|
||||
'time_return',
|
||||
'location',
|
||||
'days'
|
||||
].includes(fieldKey)
|
||||
}
|
||||
|
||||
function resolveEditorCurrentUser(currentUser) {
|
||||
@@ -270,6 +331,7 @@ export function useApplicationPreviewEditor({
|
||||
currentUser
|
||||
} = {}) {
|
||||
const applicationPreviewEditor = ref(buildEmptyEditor())
|
||||
const estimateRequestVersions = new WeakMap()
|
||||
|
||||
async function refreshApplicationPreviewEstimate(preview = {}) {
|
||||
const user = resolveEditorCurrentUser(currentUser)
|
||||
@@ -406,14 +468,19 @@ export function useApplicationPreviewEditor({
|
||||
}
|
||||
return false
|
||||
}
|
||||
const nextPreview = normalizeApplicationPreview({
|
||||
...message.applicationPreview,
|
||||
fields: buildEditedApplicationPreviewFields(
|
||||
message.applicationPreview.fields || {},
|
||||
editor,
|
||||
nextValue
|
||||
)
|
||||
})
|
||||
const isDateField = isApplicationPreviewDateField(editor.fieldKey)
|
||||
const nextPreview = trackApplicationPreviewFieldEdit(
|
||||
message.applicationPreview,
|
||||
normalizeApplicationPreview({
|
||||
...message.applicationPreview,
|
||||
fields: buildEditedApplicationPreviewFields(
|
||||
message.applicationPreview.fields || {},
|
||||
editor,
|
||||
nextValue
|
||||
)
|
||||
}),
|
||||
isDateField ? [editor.fieldKey, 'days'] : editor.fieldKey
|
||||
)
|
||||
const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey)
|
||||
message.applicationPreview = needRefreshEstimate
|
||||
? buildTransportEstimatePendingPreview(nextPreview)
|
||||
@@ -422,9 +489,23 @@ export function useApplicationPreviewEditor({
|
||||
cancelApplicationPreviewEditor()
|
||||
persistSessionState?.()
|
||||
if (needRefreshEstimate) {
|
||||
const requestVersion = (estimateRequestVersions.get(message) || 0) + 1
|
||||
estimateRequestVersions.set(message, requestVersion)
|
||||
const estimateInputFingerprint = buildApplicationEstimateInputFingerprint(nextPreview)
|
||||
const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview)
|
||||
message.applicationPreview = refreshedPreview
|
||||
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
|
||||
const isLatestRequest = estimateRequestVersions.get(message) === requestVersion
|
||||
const currentFingerprint = buildApplicationEstimateInputFingerprint(
|
||||
message.applicationPreview
|
||||
)
|
||||
if (!isLatestRequest || currentFingerprint !== estimateInputFingerprint) {
|
||||
return true
|
||||
}
|
||||
const mergedPreview = mergeApplicationEstimateResult(
|
||||
message.applicationPreview,
|
||||
refreshedPreview
|
||||
)
|
||||
message.applicationPreview = mergedPreview
|
||||
message.text = buildLocalApplicationPreviewMessage(mergedPreview)
|
||||
persistSessionState?.()
|
||||
toast?.('已更新出行方式和费用测算。')
|
||||
return true
|
||||
|
||||
@@ -42,6 +42,16 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
|
||||
days: '3天',
|
||||
transportMode: '火车',
|
||||
amount: '1000元'
|
||||
},
|
||||
aiDecisionFeedback: {
|
||||
schemaVersion: 1,
|
||||
changedFields: [
|
||||
{
|
||||
fieldKey: 'transportMode',
|
||||
suggestedValue: '飞机',
|
||||
finalValue: '火车'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
currentUser: { username: 'zhangsan@example.com', name: '张三' },
|
||||
@@ -54,6 +64,13 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
|
||||
assert.equal(body.context_json.session_type, 'application')
|
||||
assert.equal(body.context_json.application_stage, 'expense_application')
|
||||
assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
|
||||
assert.deepEqual(body.context_json.application_preview.aiDecisionFeedback.changedFields, [
|
||||
{
|
||||
fieldKey: 'transportMode',
|
||||
suggestedValue: '飞机',
|
||||
finalValue: '火车'
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
async function testSaveDraftActionUsesFastPreviewEndpoint() {
|
||||
|
||||
140
web/tests/expense-application-decision-feedback.test.mjs
Normal file
140
web/tests/expense-application-decision-feedback.test.mjs
Normal file
@@ -0,0 +1,140 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { trackApplicationPreviewFieldEdit } from '../src/utils/expenseApplicationDecisionFeedback.js'
|
||||
|
||||
function testTracksFirstExplicitEdit() {
|
||||
const previous = {
|
||||
fields: {
|
||||
reason: '客户拜访',
|
||||
location: '上海'
|
||||
}
|
||||
}
|
||||
const next = {
|
||||
...previous,
|
||||
fields: {
|
||||
...previous.fields,
|
||||
reason: '客户现场实施'
|
||||
}
|
||||
}
|
||||
|
||||
const tracked = trackApplicationPreviewFieldEdit(previous, next, 'reason')
|
||||
|
||||
assert.deepEqual(tracked.aiDecisionFeedback.changedFields, [
|
||||
{
|
||||
fieldKey: 'reason',
|
||||
suggestedValue: '客户拜访',
|
||||
finalValue: '客户现场实施'
|
||||
}
|
||||
])
|
||||
assert.equal(tracked.aiDecisionFeedback.revision, 1)
|
||||
}
|
||||
|
||||
function testKeepsOriginalSuggestionAcrossRepeatedEdits() {
|
||||
const first = trackApplicationPreviewFieldEdit(
|
||||
{ fields: { transportMode: '火车' } },
|
||||
{ fields: { transportMode: '飞机' } },
|
||||
'transportMode'
|
||||
)
|
||||
const second = trackApplicationPreviewFieldEdit(
|
||||
first,
|
||||
{ ...first, fields: { transportMode: '自驾' } },
|
||||
'transportMode'
|
||||
)
|
||||
|
||||
assert.deepEqual(second.aiDecisionFeedback.changedFields, [
|
||||
{
|
||||
fieldKey: 'transportMode',
|
||||
suggestedValue: '火车',
|
||||
finalValue: '自驾'
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
function testRemovesDiffWhenUserRestoresSuggestion() {
|
||||
const edited = trackApplicationPreviewFieldEdit(
|
||||
{ fields: { location: '上海' } },
|
||||
{ fields: { location: '北京' } },
|
||||
'location'
|
||||
)
|
||||
const restored = trackApplicationPreviewFieldEdit(
|
||||
edited,
|
||||
{ ...edited, fields: { location: '上海' } },
|
||||
'location'
|
||||
)
|
||||
|
||||
assert.deepEqual(restored.aiDecisionFeedback.changedFields, [])
|
||||
}
|
||||
|
||||
function testReturnDateUsesCanonicalTimeField() {
|
||||
const tracked = trackApplicationPreviewFieldEdit(
|
||||
{ fields: { time: '2026-07-20 至 2026-07-22' } },
|
||||
{ fields: { time: '2026-07-20 至 2026-07-24' } },
|
||||
'time_return'
|
||||
)
|
||||
|
||||
assert.equal(tracked.aiDecisionFeedback.changedFields[0].fieldKey, 'time')
|
||||
}
|
||||
|
||||
function testTracksDateAndDerivedDaysTogether() {
|
||||
const previous = {
|
||||
fields: {
|
||||
time: '2026-02-20 至 2026-02-23',
|
||||
days: '4天'
|
||||
}
|
||||
}
|
||||
const next = {
|
||||
fields: {
|
||||
time: '2026-02-20 至 2026-02-24',
|
||||
days: '5天'
|
||||
}
|
||||
}
|
||||
|
||||
const tracked = trackApplicationPreviewFieldEdit(
|
||||
previous,
|
||||
next,
|
||||
['time_return', 'days']
|
||||
)
|
||||
|
||||
assert.deepEqual(tracked.aiDecisionFeedback.changedFields, [
|
||||
{ fieldKey: 'days', suggestedValue: '4天', finalValue: '5天' },
|
||||
{
|
||||
fieldKey: 'time',
|
||||
suggestedValue: '2026-02-20 至 2026-02-23',
|
||||
finalValue: '2026-02-20 至 2026-02-24'
|
||||
}
|
||||
])
|
||||
assert.equal(tracked.aiDecisionFeedback.revision, 1)
|
||||
|
||||
const editedAgain = trackApplicationPreviewFieldEdit(
|
||||
tracked,
|
||||
{
|
||||
...tracked,
|
||||
fields: {
|
||||
time: '2026-02-20 至 2026-02-25',
|
||||
days: '6天'
|
||||
}
|
||||
},
|
||||
['time', 'days']
|
||||
)
|
||||
assert.equal(editedAgain.aiDecisionFeedback.changedFields[0].suggestedValue, '4天')
|
||||
assert.equal(
|
||||
editedAgain.aiDecisionFeedback.changedFields[1].suggestedValue,
|
||||
'2026-02-20 至 2026-02-23'
|
||||
)
|
||||
assert.equal(editedAgain.aiDecisionFeedback.revision, 2)
|
||||
|
||||
const restored = trackApplicationPreviewFieldEdit(
|
||||
editedAgain,
|
||||
{ ...editedAgain, fields: previous.fields },
|
||||
['time', 'days']
|
||||
)
|
||||
assert.deepEqual(restored.aiDecisionFeedback.changedFields, [])
|
||||
assert.equal(restored.aiDecisionFeedback.revision, 3)
|
||||
}
|
||||
|
||||
testTracksFirstExplicitEdit()
|
||||
testKeepsOriginalSuggestionAcrossRepeatedEdits()
|
||||
testRemovesDiffWhenUserRestoresSuggestion()
|
||||
testReturnDateUsesCanonicalTimeField()
|
||||
testTracksDateAndDerivedDaysTogether()
|
||||
console.log('expense-application-decision-feedback tests passed')
|
||||
@@ -1916,6 +1916,14 @@ test('application preview editor recalculates days and subsidy after date range
|
||||
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
|
||||
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
|
||||
assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/)
|
||||
assert.deepEqual(message.applicationPreview.aiDecisionFeedback.changedFields, [
|
||||
{ fieldKey: 'days', suggestedValue: '1\u5929', finalValue: '4\u5929' },
|
||||
{
|
||||
fieldKey: 'time',
|
||||
suggestedValue: '2026-05-25',
|
||||
finalValue: '2026-02-20 \u81f3 2026-02-23'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('application preview editor can edit return date from table row', async () => {
|
||||
@@ -1981,6 +1989,14 @@ test('application preview editor can edit return date from table row', async ()
|
||||
})
|
||||
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-24')
|
||||
assert.equal(message.applicationPreview.fields.days, '5\u5929')
|
||||
assert.deepEqual(message.applicationPreview.aiDecisionFeedback.changedFields, [
|
||||
{ fieldKey: 'days', suggestedValue: '4\u5929', finalValue: '5\u5929' },
|
||||
{
|
||||
fieldKey: 'time',
|
||||
suggestedValue: '2026-02-20 \u81f3 2026-02-23',
|
||||
finalValue: '2026-02-20 \u81f3 2026-02-24'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('application preview editor can edit return date from inline table input', async () => {
|
||||
@@ -2185,3 +2201,210 @@ test('application preview editor estimates after shorthand return date input', a
|
||||
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/)
|
||||
})
|
||||
|
||||
test('application preview editor ignores stale out-of-order estimate responses', async () => {
|
||||
const message = {
|
||||
id: 'application-preview-stale-estimate-message',
|
||||
applicationPreview: normalizeApplicationPreview({
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
time: '2026-07-20 至 2026-07-21',
|
||||
location: '上海',
|
||||
reason: '客户现场实施',
|
||||
days: '2天',
|
||||
transportMode: '火车',
|
||||
grade: 'P5'
|
||||
}
|
||||
}),
|
||||
text: ''
|
||||
}
|
||||
const pendingEstimates = []
|
||||
const editor = useApplicationPreviewEditor({
|
||||
persistSessionState: () => {},
|
||||
toast: () => {},
|
||||
currentUser: ref({ grade: 'P5' }),
|
||||
calculateTravelReimbursement: (payload) => new Promise((resolve) => {
|
||||
pendingEstimates.push({ payload, resolve })
|
||||
})
|
||||
})
|
||||
|
||||
editor.openApplicationPreviewEditor(message, 'location', '上海')
|
||||
editor.applicationPreviewEditor.value.draftValue = '北京'
|
||||
const firstCommit = editor.commitApplicationPreviewEditor(message)
|
||||
|
||||
editor.openApplicationPreviewEditor(message, 'location', '北京')
|
||||
editor.applicationPreviewEditor.value.draftValue = '广州'
|
||||
const secondCommit = editor.commitApplicationPreviewEditor(message)
|
||||
|
||||
assert.equal(pendingEstimates.length, 2)
|
||||
pendingEstimates[1].resolve({
|
||||
days: 2,
|
||||
location: '广州',
|
||||
matched_city: '广州',
|
||||
grade: 'P5',
|
||||
hotel_rate: 600,
|
||||
hotel_amount: 1200,
|
||||
total_allowance_rate: 100,
|
||||
allowance_amount: 200,
|
||||
total_amount: 1400,
|
||||
rule_name: '第二次估算规则',
|
||||
rule_version: 'v2'
|
||||
})
|
||||
await secondCommit
|
||||
pendingEstimates[0].resolve({
|
||||
days: 2,
|
||||
location: '北京',
|
||||
matched_city: '北京',
|
||||
grade: 'P5',
|
||||
hotel_rate: 300,
|
||||
hotel_amount: 600,
|
||||
total_allowance_rate: 80,
|
||||
allowance_amount: 160,
|
||||
total_amount: 760,
|
||||
rule_name: '过期估算规则',
|
||||
rule_version: 'v1'
|
||||
})
|
||||
await firstCommit
|
||||
|
||||
assert.equal(message.applicationPreview.fields.location, '广州')
|
||||
assert.equal(message.applicationPreview.fields.ruleName, '第二次估算规则')
|
||||
assert.deepEqual(message.applicationPreview.aiDecisionFeedback.changedFields, [
|
||||
{ fieldKey: 'location', suggestedValue: '上海', finalValue: '广州' }
|
||||
])
|
||||
})
|
||||
|
||||
test('application type edit starts a fresh estimate instead of leaving pending state', async () => {
|
||||
const message = {
|
||||
id: 'application-preview-type-estimate-message',
|
||||
applicationPreview: normalizeApplicationPreview({
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
time: '2026-07-20 至 2026-07-21',
|
||||
location: '上海',
|
||||
reason: '客户现场实施',
|
||||
days: '2天',
|
||||
transportMode: '火车',
|
||||
grade: 'P5'
|
||||
}
|
||||
}),
|
||||
text: ''
|
||||
}
|
||||
const pendingEstimates = []
|
||||
const editor = useApplicationPreviewEditor({
|
||||
persistSessionState: () => {},
|
||||
toast: () => {},
|
||||
currentUser: ref({ grade: 'P5' }),
|
||||
calculateTravelReimbursement: (payload) => new Promise((resolve) => {
|
||||
pendingEstimates.push({ payload, resolve })
|
||||
})
|
||||
})
|
||||
|
||||
editor.openApplicationPreviewEditor(message, 'location', '上海')
|
||||
editor.applicationPreviewEditor.value.draftValue = '北京'
|
||||
const firstCommit = editor.commitApplicationPreviewEditor(message)
|
||||
|
||||
editor.openApplicationPreviewEditor(message, 'applicationType', '差旅费用申请')
|
||||
editor.applicationPreviewEditor.value.draftValue = '出差费用申请'
|
||||
const secondCommit = editor.commitApplicationPreviewEditor(message)
|
||||
|
||||
assert.equal(pendingEstimates.length, 2)
|
||||
pendingEstimates[1].resolve({
|
||||
days: 2,
|
||||
location: '北京',
|
||||
matched_city: '北京',
|
||||
grade: 'P5',
|
||||
hotel_rate: 600,
|
||||
hotel_amount: 1200,
|
||||
total_allowance_rate: 100,
|
||||
allowance_amount: 200,
|
||||
total_amount: 1400,
|
||||
rule_name: '申请类型更新规则',
|
||||
rule_version: 'v2'
|
||||
})
|
||||
await secondCommit
|
||||
pendingEstimates[0].resolve({
|
||||
days: 2,
|
||||
location: '北京',
|
||||
matched_city: '北京',
|
||||
grade: 'P5',
|
||||
hotel_rate: 300,
|
||||
hotel_amount: 600,
|
||||
total_allowance_rate: 80,
|
||||
allowance_amount: 160,
|
||||
total_amount: 760,
|
||||
rule_name: '过期申请类型规则',
|
||||
rule_version: 'v1'
|
||||
})
|
||||
await firstCommit
|
||||
|
||||
assert.equal(message.applicationPreview.fields.applicationType, '出差费用申请')
|
||||
assert.equal(message.applicationPreview.fields.ruleName, '申请类型更新规则')
|
||||
assert.equal(message.applicationPreview.policyEstimateStatus, 'completed')
|
||||
assert.doesNotMatch(message.applicationPreview.fields.policyEstimate, /正在/)
|
||||
assert.deepEqual(message.applicationPreview.aiDecisionFeedback.changedFields, [
|
||||
{
|
||||
fieldKey: 'applicationType',
|
||||
suggestedValue: '差旅费用申请',
|
||||
finalValue: '出差费用申请'
|
||||
},
|
||||
{ fieldKey: 'location', suggestedValue: '上海', finalValue: '北京' }
|
||||
])
|
||||
})
|
||||
|
||||
test('application preview editor merges estimate without dropping later edits', async () => {
|
||||
const message = {
|
||||
id: 'application-preview-merge-estimate-message',
|
||||
applicationPreview: normalizeApplicationPreview({
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
time: '2026-07-20 至 2026-07-21',
|
||||
location: '上海',
|
||||
reason: '客户拜访',
|
||||
days: '2天',
|
||||
transportMode: '火车',
|
||||
grade: 'P5'
|
||||
}
|
||||
}),
|
||||
text: ''
|
||||
}
|
||||
let resolveEstimate
|
||||
const editor = useApplicationPreviewEditor({
|
||||
persistSessionState: () => {},
|
||||
toast: () => {},
|
||||
currentUser: ref({ grade: 'P5' }),
|
||||
calculateTravelReimbursement: () => new Promise((resolve) => {
|
||||
resolveEstimate = resolve
|
||||
})
|
||||
})
|
||||
|
||||
editor.openApplicationPreviewEditor(message, 'location', '上海')
|
||||
editor.applicationPreviewEditor.value.draftValue = '北京'
|
||||
const estimateCommit = editor.commitApplicationPreviewEditor(message)
|
||||
|
||||
editor.openApplicationPreviewEditor(message, 'reason', '客户拜访')
|
||||
editor.applicationPreviewEditor.value.draftValue = '客户现场实施'
|
||||
await editor.commitApplicationPreviewEditor(message)
|
||||
|
||||
resolveEstimate({
|
||||
days: 2,
|
||||
location: '北京',
|
||||
matched_city: '北京',
|
||||
grade: 'P5',
|
||||
hotel_rate: 500,
|
||||
hotel_amount: 1000,
|
||||
total_allowance_rate: 100,
|
||||
allowance_amount: 200,
|
||||
total_amount: 1200,
|
||||
rule_name: '并发合并规则',
|
||||
rule_version: 'v1'
|
||||
})
|
||||
await estimateCommit
|
||||
|
||||
assert.equal(message.applicationPreview.fields.location, '北京')
|
||||
assert.equal(message.applicationPreview.fields.reason, '客户现场实施')
|
||||
assert.equal(message.applicationPreview.fields.ruleName, '并发合并规则')
|
||||
assert.deepEqual(message.applicationPreview.aiDecisionFeedback.changedFields, [
|
||||
{ fieldKey: 'location', suggestedValue: '上海', finalValue: '北京' },
|
||||
{ fieldKey: 'reason', suggestedValue: '客户拜访', finalValue: '客户现场实施' }
|
||||
])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user