feat(ai): add expense application feedback ledger

This commit is contained in:
caoxiaozhu
2026-07-14 11:10:55 +08:00
parent 5ed34c2b8f
commit a662cfe6c3
24 changed files with 2159 additions and 34 deletions

View File

@@ -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() {

View 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')

View File

@@ -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: '客户现场实施' }
])
})