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

@@ -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))
))
}
}
}

View File

@@ -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