feat: 财务看板口径重构与半年模拟数据及报销状态注册表

- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选
- 引入 expense_claim_status_registry 统一报销状态流转
- 完善报销草稿流程、Item Sync 与本体解析器
- 优化总览页趋势图、分页组件与请求进度步骤
- 增强报销申请快速预览、本体工具与详情展示
- 新增半年报销模拟数据种子脚本与状态审计工具
- 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 16:22:59 +08:00
parent ca691f3ee0
commit 0c74b4ab4a
54 changed files with 6810 additions and 1238 deletions

View File

@@ -30,6 +30,92 @@ function pickDetailValue(detail, request, keys = [], fallback = '') {
return normalizeText(fallback)
}
function isTravelApplicationDetail(detail = {}, request = {}) {
const typeText = [
detail.application_type,
detail.applicationType,
request.typeCode,
request.typeLabel,
request.documentTypeLabel
].map(normalizeText).join(' ')
return /travel_application|差旅|出差/.test(typeText)
}
function isEntertainmentApplicationDetail(detail = {}, request = {}) {
const typeText = [
detail.application_type,
detail.applicationType,
request.typeCode,
request.typeLabel
].map(normalizeText).join(' ')
return /entertainment|招待/.test(typeText)
}
function extractDateRange(value) {
const dates = normalizeText(value).match(/\d{4}-\d{2}-\d{2}/g) || []
return {
startDate: dates[0] || '',
endDate: dates[dates.length - 1] || ''
}
}
function extractDayCount(value) {
const match = normalizeText(value).replace(/\s+/g, '').match(/(\d{1,2})天/)
return match ? Number(match[1]) || 0 : 0
}
function addDays(dateText, days) {
if (!dateText || days <= 1) {
return dateText
}
const date = new Date(`${dateText}T00:00:00`)
if (Number.isNaN(date.getTime())) {
return dateText
}
date.setDate(date.getDate() + days - 1)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function buildApplicationTimeRows(detail, request) {
const timeValue = pickDetailValue(detail, request, ['time', 'occurredDisplay', 'period'], request.occurredDisplay)
if (!isProvided(timeValue)) {
return []
}
if (isTravelApplicationDetail(detail, request)) {
const days = extractDayCount(pickDetailValue(detail, request, ['days']))
const range = extractDateRange(timeValue)
const startDate = range.startDate || timeValue
const endDate = range.endDate && range.endDate !== range.startDate
? range.endDate
: addDays(range.startDate, days)
return [
{
key: 'trip_start_time',
label: '出发时间',
value: startDate
},
{
key: 'trip_return_time',
label: '返回时间',
value: endDate
}
]
}
return [
{
key: 'time',
label: isEntertainmentApplicationDetail(detail, request) ? '招待时间' : '申请时间',
value: timeValue
}
]
}
export function buildApplicationDetailFactItems(request = {}) {
const detail = resolveApplicationDetailPayload(request)
const amountDisplay = normalizeText(request.amountDisplay || request.amount)
@@ -39,11 +125,7 @@ export function buildApplicationDetailFactItems(request = {}) {
label: '申请类型',
value: pickDetailValue(detail, request, ['application_type', 'typeLabel'], request.typeLabel)
},
{
key: 'time',
label: '发生时间',
value: pickDetailValue(detail, request, ['time', 'occurredDisplay', 'period'], request.occurredDisplay)
},
...buildApplicationTimeRows(detail, request),
{
key: 'location',
label: '地点',
@@ -107,6 +189,12 @@ export function buildApplicationDetailFactItems(request = {}) {
export function buildRelatedApplicationFactItems(request = {}) {
const related = request.relatedApplication || {}
const relatedRange = extractDateRange(related.time)
const relatedStartDate = normalizeText(related.tripStartDate) || relatedRange.startDate
const relatedEndDate = normalizeText(related.tripEndDate) || relatedRange.endDate || addDays(
relatedStartDate,
extractDayCount(related.days)
)
const rows = [
{
key: 'claim_no',
@@ -119,6 +207,16 @@ export function buildRelatedApplicationFactItems(request = {}) {
label: '申请内容',
value: related.content
},
{
key: 'trip_start_date',
label: '出发时间',
value: relatedStartDate
},
{
key: 'trip_end_date',
label: '返回时间',
value: relatedEndDate
},
{
key: 'days',
label: '申请天数',
@@ -135,9 +233,37 @@ export function buildRelatedApplicationFactItems(request = {}) {
value: related.location
},
{
key: 'time',
label: '申请时间',
value: related.time
key: 'transport_mode',
label: '出行方式',
value: related.transportMode
},
{
key: 'lodging_daily_cap',
label: '住宿上限/天',
value: related.lodgingDailyCap,
highlight: true
},
{
key: 'subsidy_daily_cap',
label: '补贴标准/天',
value: related.subsidyDailyCap,
highlight: true
},
{
key: 'transport_policy',
label: '交通费用口径',
value: related.transportPolicy
},
{
key: 'policy_estimate',
label: '规则测算参考',
value: related.policyEstimate,
highlight: true
},
{
key: 'rule',
label: '规则依据',
value: related.ruleLabel
},
{
key: 'amount',
@@ -145,11 +271,6 @@ export function buildRelatedApplicationFactItems(request = {}) {
value: related.amountLabel,
highlight: true,
emphasis: true
},
{
key: 'transport_mode',
label: '出行方式',
value: related.transportMode
}
]

View File

@@ -127,7 +127,7 @@ export function buildMockApplicationTransportEstimate({
simulatedLatencyMs,
source: 'mock_ticket_price_query_v1',
confidence: 'mock',
basisText: `已查询 ${queryLabel} ${mode}参考票价,按${bandLabel}往返 ${amountDisplay}元估算(查询耗时 ${simulatedLatencyMs}ms`
basisText: `预估交通费用 ${amountDisplay}`
}
}

View File

@@ -99,6 +99,19 @@ export function resolveExpenseTypeCode(ontology) {
return String(entity?.normalized_value || entity?.value || 'other').trim() || 'other'
}
function looksLikeStructuredTravelApplication(prompt) {
const text = String(prompt || '')
return /(?:发生时间|业务发生时间|申请时间|时间)\s*[:]/.test(text)
&& /(?:地点|业务地点|发生地点|目的地)\s*[:]/.test(text)
&& /(?:天数|出差天数|申请天数)\s*[:]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(text)
}
function resolveApplicationExpenseTypeCode(ontology, prompt) {
const code = resolveExpenseTypeCode(ontology)
if (code !== 'other') return code
return looksLikeStructuredTravelApplication(prompt) ? 'travel' : code
}
export function resolveExpenseTypeLabel(code) {
return EXPENSE_TYPE_LABELS[String(code || '').trim()] || EXPENSE_TYPE_LABELS.other
}
@@ -358,7 +371,7 @@ export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
}
export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser = {}) {
const expenseTypeCode = resolveExpenseTypeCode(ontology)
const expenseTypeCode = resolveApplicationExpenseTypeCode(ontology, prompt)
const amount = resolveApplicationAmount(ontology)
const documentTypeEntity = resolveEntity(ontology, 'document_type')
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')

View File

@@ -31,11 +31,11 @@ export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动生成交通参考票价,报销阶段按真实票据复核'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
export function resolveApplicationTimeLabel(applicationType = '') {
const label = String(applicationType || '').trim()
if (/差旅|出差/.test(label)) return '行程时间'
if (/差旅|出差/.test(label)) return '出发时间'
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
return '申请时间'
}
@@ -47,10 +47,36 @@ function resolveApplicationFieldLabel(item, fields = {}) {
return item.label
}
function isTravelApplicationType(applicationType = '') {
return /差旅|出差/.test(String(applicationType || '').trim())
}
function resolveApplicationTripDateParts(fields = {}) {
const timeText = String(fields.time || '').trim()
const matchedDates = timeText.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
const startDate = normalizeDateText(matchedDates[0] || timeText)
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
? explicitEndDate
: buildEndDateFromDays(startDate, fields.days)
return {
startDate,
endDate: inferredEndDate || explicitEndDate || startDate
}
}
function compactText(value) {
return String(value || '').replace(/\s+/g, '')
}
function looksLikeStructuredTravelApplication(text) {
const source = String(text || '')
return /(?:发生时间|业务发生时间|申请时间|时间)\s*[:]/.test(source)
&& /(?:地点|业务地点|发生地点|目的地)\s*[:]/.test(source)
&& /(?:天数|出差天数|申请天数)\s*[:]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(source)
}
function resolveFirstMatch(text, patterns = []) {
for (const pattern of patterns) {
const match = text.match(pattern)
@@ -106,6 +132,7 @@ function resolvePreviewToday(options = {}) {
function resolveApplicationType(text) {
const compact = compactText(text)
if (looksLikeStructuredTravelApplication(text)) return '差旅费用申请'
if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
if (/住宿|酒店/.test(compact)) return '住宿费用申请'
@@ -224,7 +251,7 @@ function buildTransportPolicyText(transportMode, location = '', transportEstimat
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
return `${estimate.basisText},报销阶段按真实票据复核`
return estimate.basisText
}
function ensureApplicationPolicyFields(fields = {}) {
@@ -437,9 +464,8 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
allowanceAmount: result?.allowance_amount
})
const transportEstimate = systemEstimate.transportEstimate
const queryLabel = transportEstimate?.queryDate || '出行日期待确认'
const transportText = transportEstimate
? `交通 ${systemEstimate.transportAmountDisplay}(按 ${queryLabel} 参考票价) + `
? `交通 ${systemEstimate.transportAmountDisplay}元 + `
: ''
const totalAmount = systemEstimate.totalAmountDisplay
const amount = totalAmount ? `${totalAmount}` : fields.amount
@@ -499,7 +525,6 @@ export function refreshApplicationPreviewTransportEstimate(preview = {}) {
const hotelAmount = formatPolicyMoney(hotelAmountSource)
const allowanceAmount = formatPolicyMoney(allowanceAmountSource)
const hasPolicyAmounts = parseMoneyNumber(hotelAmountSource) > 0 || parseMoneyNumber(allowanceAmountSource) > 0
const queryLabel = transportEstimate.queryDate || '出行日期待确认'
const nextFields = {
...fields,
transportPolicy: buildTransportPolicyText(fields.transportMode, location, transportEstimate, fields.time),
@@ -513,7 +538,7 @@ export function refreshApplicationPreviewTransportEstimate(preview = {}) {
if (hasPolicyAmounts) {
const days = Number(policyResult.days) || parseApplicationDaysValue(fields.days) || 1
const totalAmount = systemEstimate.totalAmountDisplay
nextFields.policyEstimate = `交通 ${systemEstimate.transportAmountDisplay}(按 ${queryLabel} 参考票价) + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`
nextFields.policyEstimate = `交通 ${systemEstimate.transportAmountDisplay}元 + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`
nextFields.amount = totalAmount ? `${totalAmount}` : nextFields.amount
nextFields.policyTotalAmount = totalAmount ? `${totalAmount}` : ''
}
@@ -639,17 +664,41 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
export function buildApplicationPreviewRows(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
return APPLICATION_PREVIEW_FIELD_DEFINITIONS.map((item) => {
return APPLICATION_PREVIEW_FIELD_DEFINITIONS.flatMap((item) => {
if (item.key === 'time' && isTravelApplicationType(fields.applicationType)) {
const tripDates = resolveApplicationTripDateParts(fields)
const rawValue = fields[item.key]
const missing = item.required !== false && !isApplicationPreviewValueProvided(rawValue)
return [
{
...item,
label: '出发时间',
value: tripDates.startDate || '待补充',
editable: item.editable !== false,
highlight: Boolean(item.highlight),
missing
},
{
key: 'time_return',
label: '返回时间',
value: tripDates.endDate || '待补充',
editable: false,
highlight: Boolean(item.highlight),
missing
}
]
}
const rawValue = fields[item.key]
const value = String(rawValue || '').trim() || '待补充'
return {
return [{
...item,
label: resolveApplicationFieldLabel(item, fields),
value,
editable: item.editable !== false,
highlight: Boolean(item.highlight),
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
}
}]
})
}