fix: handle risk explanation standard adjustment

This commit is contained in:
caoxiaozhu
2026-06-03 17:31:40 +08:00
parent 67b81a1bd8
commit 8e2477587f
19 changed files with 976 additions and 61 deletions

View File

@@ -1000,6 +1000,13 @@
line-height: 1.45;
}
.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
overflow-y: auto;
resize: none;
}
.currency-editor {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
@@ -1062,6 +1069,37 @@
white-space: nowrap;
}
.expense-adjusted-amount {
display: grid;
justify-items: center;
gap: 3px;
}
.expense-original-amount {
color: #b91c1c;
font-size: 12px;
font-weight: 760;
text-decoration-line: line-through;
text-decoration-thickness: 2px;
text-decoration-color: rgba(185, 28, 28, .82);
white-space: nowrap;
}
.expense-reimbursable-amount {
color: #0f172a;
font-size: 13px;
font-weight: 880;
white-space: nowrap;
}
.expense-adjusted-amount em {
color: #991b1b;
font-size: 11px;
font-style: normal;
font-weight: 760;
white-space: nowrap;
}
.expense-filled-at strong {
font-size: 12px;
white-space: nowrap;
@@ -1950,12 +1988,46 @@
color: #0f172a;
}
.risk-override-card textarea.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
resize: none;
}
.risk-override-card textarea:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, .12);
outline: none;
}
.risk-override-submit-row {
display: grid;
gap: 6px;
}
.risk-override-save-btn {
min-height: 34px;
border: 1px solid #bfdbfe;
border-radius: 4px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 850;
cursor: pointer;
}
.risk-override-save-btn:disabled {
cursor: not-allowed;
opacity: .58;
}
.risk-override-submit-row span {
color: #64748b;
font-size: 12px;
line-height: 1.5;
text-align: center;
}
.validation-card {
border: 1px solid #e5e7eb;
background: #ffffff;

View File

@@ -39,6 +39,7 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
])
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
@@ -83,6 +84,45 @@ function parseNumber(value) {
return Number.isFinite(nextValue) ? nextValue : 0
}
function parseOptionalAmount(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return null
}
const amount = Number(value)
return Number.isFinite(amount) && amount >= 0 ? amount : null
}
function buildStandardAdjustmentMapFromClaim(claim = {}) {
const flags = Array.isArray(claim?.risk_flags_json)
? claim.risk_flags_json
: Array.isArray(claim?.riskFlags)
? claim.riskFlags
: []
const adjustmentMap = new Map()
flags.forEach((flag) => {
if (!flag || typeof flag !== 'object') {
return
}
if (String(flag.source || '').trim() !== STANDARD_ADJUSTMENT_RISK_SOURCE) {
return
}
const itemId = String(flag.item_id || flag.itemId || '').trim()
const reimbursableAmount = parseOptionalAmount(flag.reimbursable_amount ?? flag.reimbursableAmount)
if (!itemId || reimbursableAmount === null) {
return
}
adjustmentMap.set(itemId, {
originalAmount: parseOptionalAmount(flag.original_amount ?? flag.originalAmount),
reimbursableAmount,
employeeAbsorbedAmount: parseOptionalAmount(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0,
message: String(flag.message || flag.summary || '').trim()
})
})
return adjustmentMap
}
function toDate(value) {
if (!value) {
return null
@@ -1272,6 +1312,7 @@ function buildExpenseItems(claim, riskMeta) {
return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
})
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
const standardAdjustmentMap = buildStandardAdjustmentMapFromClaim(claim)
return sortedItems.map((item, index) => {
const invoiceId = String(item?.invoice_id || '').trim()
@@ -1286,6 +1327,10 @@ function buildExpenseItems(claim, riskMeta) {
const itemNote = String(item?.item_note || item?.itemNote || '').trim()
const itemAmount = parseNumber(item?.item_amount)
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
const standardAdjustment = standardAdjustmentMap.get(id) || null
const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount
const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount
const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0)
return {
id,
@@ -1297,6 +1342,15 @@ function buildExpenseItems(claim, riskMeta) {
itemLocation,
itemNote,
itemAmount,
originalItemAmount,
originalAmountDisplay: originalItemAmount > 0 ? formatAmount(originalItemAmount) : itemAmountDisplay,
reimbursableAmount,
reimbursableAmountDisplay: reimbursableAmount > 0 ? formatAmount(reimbursableAmount) : '待补充',
employeeAbsorbedAmount,
employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatAmount(employeeAbsorbedAmount) : '',
hasStandardAdjustment: reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount,
standardAdjustmentAccepted: Boolean(standardAdjustment),
standardAdjustmentMessage: standardAdjustment?.message || '',
invoiceId,
isSystemGenerated,
dayLabel: resolveExpenseTimeLabel({
@@ -1336,7 +1390,10 @@ export function mapExpenseClaimToRequest(claim) {
const riskSummary = riskMeta.summary
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
const expenseItems = buildExpenseItems(claim, riskMeta)
const visibleExpenseAmount = expenseItems.reduce((sum, item) => sum + parseNumber(item.itemAmount), 0)
const visibleExpenseAmount = expenseItems.reduce((sum, item) => {
const amount = parseOptionalAmount(item.reimbursableAmount) ?? parseNumber(item.itemAmount)
return sum + amount
}, 0)
const amountValue = relatedApplication
? expenseItems.length
? visibleExpenseAmount

View File

@@ -68,6 +68,13 @@ export function updateExpenseClaim(claimId, payload = {}) {
})
}
export function acceptExpenseClaimStandardAdjustment(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/standard-adjustment`, {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function calculateTravelReimbursement(payload = {}) {
return apiRequest('/reimbursements/travel-calculator', {
method: 'POST',

View File

@@ -273,7 +273,12 @@
</div>
</template>
<template v-else>
<strong>{{ item.amount }}</strong>
<div v-if="item.hasStandardAdjustment" class="expense-adjusted-amount">
<span class="expense-original-amount">{{ item.originalAmountDisplay || item.amount }}</span>
<strong class="expense-reimbursable-amount">{{ item.reimbursableAmountDisplay }}</strong>
<em v-if="item.employeeAbsorbedAmountDisplay">自担 {{ item.employeeAbsorbedAmountDisplay }}</em>
</div>
<strong v-else>{{ item.amount }}</strong>
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
</template>
</td>
@@ -370,9 +375,11 @@
<div class="cell-editor">
<textarea
v-model="expenseEditor.itemNote"
class="editor-textarea"
rows="3"
class="editor-textarea risk-note-editor-textarea"
rows="1"
placeholder="如票据存在异常或风险,请补充原因"
@input="resizeExpenseNoteInput"
@keydown.enter="resizeExpenseNoteInput"
></textarea>
<span>用于说明改签绕行超标票据异常等情况</span>
</div>
@@ -764,7 +771,7 @@
</div>
<div class="submit-confirm-row">
<span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span>
<strong>{{ request.amountDisplay || expenseTotal }}</strong>
<strong>{{ submitConfirmAmountDisplay }}</strong>
</div>
<div v-if="!isApplicationDocument" class="submit-confirm-row">
<span>费用明细</span>
@@ -774,20 +781,20 @@
</ConfirmDialog>
<ConfirmDialog
:open="riskOverrideDialogOpen"
badge="重大风险"
badge="异常说明"
badge-tone="danger"
:title="`当前存在 ${submitRiskWarnings.length} 条重大风险`"
description="如仍需提交审批,请逐条填写每一个重大风险的原因,系统会写入附加说明并用于后续风险统计。"
:title="`当前存在 ${submitRiskWarnings.length} 条需说明的风险`"
description="请先补充异常说明后提交领导审批;也可以不填写说明,选择按职级最高可报销金额重新计算。"
cancel-text="返回整改"
confirm-text="保存原因并继续"
busy-text="保存中..."
confirm-text="按职级标准重算"
busy-text="处理中..."
confirm-tone="danger"
confirm-icon="mdi mdi-alert-circle-outline"
confirm-icon="mdi mdi-calculator-variant-outline"
:busy="riskOverrideBusy"
@close="closeRiskOverrideDialog"
@confirm="confirmRiskOverrideReasons"
@confirm="confirmStandardAdjustment"
>
<div v-if="currentSubmitRiskWarning" class="risk-override-panel" aria-label="重大风险说明">
<div v-if="currentSubmitRiskWarning" class="risk-override-panel" aria-label="异常说明">
<div class="risk-override-nav">
<button
type="button"
@@ -817,11 +824,26 @@
<p>{{ currentSubmitRiskWarning.risk }}</p>
<textarea
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
class="risk-note-editor-textarea"
rows="1"
maxlength="160"
placeholder="请说明为什么仍需提交,例如客户指定酒店、会议高峰、协议酒店满房等"
aria-label="违规提交原因"
placeholder="请说明原因,例如客户指定酒店、会议高峰、协议酒店满房等"
aria-label="异常说明"
@input="resizeExpenseNoteInput"
@keydown.enter="resizeExpenseNoteInput"
></textarea>
</article>
<div class="risk-override-submit-row">
<button
class="risk-override-save-btn"
type="button"
:disabled="riskOverrideBusy"
@click="confirmRiskOverrideReasons"
>
保存说明并继续提交
</button>
<span>不填写说明时系统会按职级最高报销标准重算金额</span>
</div>
</div>
</ConfirmDialog>
<TravelRequestDeleteDialog :open="deleteDialogOpen" :badge="deleteActionLabel" :title="deleteDialogTitle" :description="deleteDialogDescription" :busy="deleteBusy" @close="closeDeleteDialog" @confirm="confirmDeleteRequest" />

View File

@@ -10,7 +10,9 @@ import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDele
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import {
acceptExpenseClaimStandardAdjustment,
approveExpenseClaim,
calculateTravelReimbursement,
createExpenseClaimItem,
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
@@ -88,6 +90,13 @@ import {
resolveSubmitConfirmDescription,
resolveSubmitConfirmText
} from './travelRequestDetailSubmitModel.js'
import {
buildCurrentStandardAdjustmentMap,
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
filterSubmitterStandardAdjustedRiskCards as filterSubmitterStandardAdjustedRiskCardsModel,
isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel,
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel
} from './travelRequestDetailStandardAdjustment.js'
import {
buildEmployeeProfileAdviceItems,
buildTravelReceiptMaterialPrompts
@@ -994,9 +1003,16 @@ export default {
}
const expenseTotal = computed(() => {
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0)
const total = expenseItems.value.reduce((sum, item) => {
const adjustedAmount = Number(item.reimbursableAmount)
const originalAmount = Number(item.itemAmount || 0)
return sum + (Number.isFinite(adjustedAmount) ? adjustedAmount : originalAmount)
}, 0)
return formatCurrency(total)
})
const submitConfirmAmountDisplay = computed(() =>
isApplicationDocument.value ? (request.value.amountDisplay || expenseTotal.value) : expenseTotal.value
)
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
@@ -1155,6 +1171,57 @@ export default {
return requestFlags
}
function resolveCurrentStandardAdjustmentMap() {
return buildCurrentStandardAdjustmentMap(request.value, resolveClaimRiskFlags())
}
function resolveExpenseItemForRiskCard(card) {
return resolveExpenseItemForRiskCardModel(card, expenseItems.value)
}
function filterSubmitterStandardAdjustedRiskCards(cards, businessStage) {
return filterSubmitterStandardAdjustedRiskCardsModel({
cards,
businessStage,
isCurrentApplicant: isCurrentApplicant.value,
expenseItems: expenseItems.value,
standardAdjustmentMap: resolveCurrentStandardAdjustmentMap()
})
}
function isRiskCardMissingExpenseNote(card) {
return isRiskCardMissingExpenseNoteModel(card, expenseItems.value)
}
async function buildStandardAdjustmentPayload() {
return buildStandardAdjustmentPayloadModel({
warnings: submitRiskWarnings.value,
expenseItems: expenseItems.value,
request: request.value,
calculateTravelReimbursement
})
}
function applyStandardAdjustmentResponse(payload = {}) {
const flags = Array.isArray(payload?.risk_flags_json)
? payload.risk_flags_json
: Array.isArray(payload?.riskFlags)
? payload.riskFlags
: resolveClaimRiskFlags()
riskFlagPreviewSnapshot.value = {
claimId: request.value.claimId,
riskFlags: flags
}
const sourceItems = Array.isArray(payload?.items) && payload.items.length
? payload.items
: expenseItems.value
expenseItems.value = rebuildExpenseItems(sourceItems, {
...request.value,
riskFlags: flags,
risk_flags_json: flags
})
}
function resolveAttachmentDisplayName(item) {
const metadata = resolveAttachmentMeta(item)
return String(metadata?.file_name || item.attachmentHint || '').trim()
@@ -1530,7 +1597,7 @@ export default {
: []
const scopedRiskCards = [
...(hasActionableRiskCards ? [] : summaryRiskCards),
...directRiskCards
...filterSubmitterStandardAdjustedRiskCards(directRiskCards, currentBusinessStage)
]
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
@@ -1652,7 +1719,8 @@ export default {
const submitRiskWarnings = computed(() =>
aiAdvice.value.riskCards
.filter((card) => normalizeRiskTone(card?.tone) === 'high')
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.filter((card) => isRiskCardMissingExpenseNote(card))
.map((card, index) => ({
...card,
id: String(card.id || `submit-risk-${index}`),
@@ -1663,7 +1731,6 @@ export default {
const riskOverrideIndexLabel = computed(() =>
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : ''
)
const hasRiskOverrideExplanation = computed(() => detailNoteTags.value.includes('#high_risk'))
function resetDetailNote() {
detailNoteEditor.value = detailNoteSource.value
@@ -1722,6 +1789,18 @@ export default {
riskOverrideDialogOpen.value = false
}
function resizeExpenseNoteInput(event) {
const target = event?.target
if (!target || typeof window === 'undefined') {
return
}
const style = window.getComputedStyle(target)
const lineHeight = Number.parseFloat(style.lineHeight) || 18
const maxHeight = lineHeight * 3 + 18
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px`
}
function goToPreviousSubmitRisk() {
if (!submitRiskWarnings.value.length) {
return
@@ -1737,17 +1816,6 @@ export default {
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length
}
function buildRiskOverrideAppendix() {
return submitRiskWarnings.value
.map((risk, index) => {
const reason = String(riskOverrideReasons[risk.id] || '').trim()
const tags = resolveRiskTags(risk).join(' ')
const title = String(risk.title || risk.label || '重大风险').trim()
return `超标说明:${tags}${index + 1}${title}${reason}`
})
.join('\n')
}
function mergeDetailNoteWithRiskOverride(appendix) {
const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value
return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
@@ -1760,28 +1828,91 @@ export default {
const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim())
if (missingIndex >= 0) {
riskOverrideIndex.value = missingIndex
toast('请为每一条重大风险填写违规提交原因。')
toast('请为每一条风险填写异常说明。')
return
}
const appendix = buildRiskOverrideAppendix()
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
if (nextNote.length > 500) {
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
return
}
const itemNoteGroups = new Map()
const claimLevelRisks = []
submitRiskWarnings.value.forEach((risk, index) => {
const reason = String(riskOverrideReasons[risk.id] || '').trim()
const item = resolveExpenseItemForRiskCard(risk)
if (item?.id) {
const currentGroup = itemNoteGroups.get(item.id) || { item, reasons: [] }
currentGroup.reasons.push(reason)
itemNoteGroups.set(item.id, currentGroup)
} else {
const title = String(risk.title || risk.label || '风险').trim()
claimLevelRisks.push(`异常说明:第${index + 1}${title}${reason}`)
}
})
riskOverrideBusy.value = true
try {
await updateExpenseClaim(request.value.claimId, {
reason: nextNote
await Promise.all(
[...itemNoteGroups.entries()].map(([itemId, group]) => {
const existingNote = String(group.item?.itemNote || '').trim()
const nextNote = [
existingNote,
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
].filter(Boolean).join('\n')
return updateExpenseClaimItem(request.value.claimId, itemId, {
item_note: nextNote
})
})
)
itemNoteGroups.forEach((group, itemId) => {
const existingNote = String(group.item?.itemNote || '').trim()
const nextNote = [
existingNote,
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
].filter(Boolean).join('\n')
applyLocalExpenseItemPatch(itemId, {
itemNote: nextNote
})
})
detailNoteEditor.value = nextNote
if (claimLevelRisks.length) {
const appendix = claimLevelRisks.join('\n')
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
if (nextNote.length > 500) {
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
return
}
await updateExpenseClaim(request.value.claimId, {
reason: nextNote
})
detailNoteEditor.value = nextNote
}
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
toast('违规提交原因已写入附加说明。')
toast('异常说明已保存,可继续提交审批。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '风险原因保存失败,请稍后重试。')
toast(error?.message || '异常说明保存失败,请稍后重试。')
} finally {
riskOverrideBusy.value = false
}
}
async function confirmStandardAdjustment() {
if (riskOverrideBusy.value) {
return
}
riskOverrideBusy.value = true
try {
const payload = await buildStandardAdjustmentPayload()
if (!payload.risks.length) {
toast('当前风险暂未匹配到可重算的费用明细,请先补充异常说明。')
return
}
const response = await acceptExpenseClaimStandardAdjustment(request.value.claimId, payload)
applyStandardAdjustmentResponse(response)
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
toast('已按职级最高报销标准重算实际报销金额。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '按职级标准重算失败,请稍后重试。')
} finally {
riskOverrideBusy.value = false
}
@@ -1809,6 +1940,10 @@ export default {
}
populateExpenseEditor(item)
void nextTick(() => {
const textarea = document.querySelector('.risk-note-editor-textarea')
resizeExpenseNoteInput({ target: textarea })
})
}
function validateExpenseEditor() {
@@ -2237,6 +2372,11 @@ export default {
return
}
if (submitRiskWarnings.value.length) {
openRiskOverrideDialog()
return
}
submitConfirmDialogOpen.value = true
}
@@ -2540,7 +2680,7 @@ export default {
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmPayRequest, confirmRiskOverrideReasons, confirmSmartEntryUpload,
confirmPayRequest, confirmRiskOverrideReasons, confirmStandardAdjustment, confirmSmartEntryUpload,
chooseSmartEntryFile, clearSmartEntryFile,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning,
@@ -2562,7 +2702,7 @@ export default {
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resetDetailNote, resizeExpenseNoteInput, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
@@ -2574,7 +2714,7 @@ export default {
showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}
}

View File

@@ -32,11 +32,24 @@ export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flig
export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
export const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}$/
export const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
export function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
function parseOptionalCurrency(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return null
}
const normalized = String(value).replace(/[^\d.]/g, '')
if (!normalized) {
return null
}
const amount = Number.parseFloat(normalized)
return Number.isFinite(amount) && amount >= 0 ? amount : null
}
export function formatCurrency(value) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
@@ -395,6 +408,60 @@ export function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, reque
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
}
export function buildStandardAdjustmentMap(requestModel = {}) {
const flags = Array.isArray(requestModel?.riskFlags)
? requestModel.riskFlags
: Array.isArray(requestModel?.risk_flags_json)
? requestModel.risk_flags_json
: []
const adjustmentMap = new Map()
flags.forEach((flag) => {
if (!flag || typeof flag !== 'object') {
return
}
if (String(flag.source || '').trim() !== STANDARD_ADJUSTMENT_RISK_SOURCE) {
return
}
const itemId = String(flag.item_id || flag.itemId || '').trim()
if (!itemId) {
return
}
const originalAmount = parseOptionalCurrency(flag.original_amount ?? flag.originalAmount)
const reimbursableAmount = parseOptionalCurrency(flag.reimbursable_amount ?? flag.reimbursableAmount)
if (reimbursableAmount === null) {
return
}
const employeeAbsorbedAmount = parseOptionalCurrency(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0
adjustmentMap.set(itemId, {
originalAmount,
reimbursableAmount,
employeeAbsorbedAmount,
message: String(flag.message || flag.summary || '').trim()
})
})
return adjustmentMap
}
function resolveSourceStandardAdjustment(source, id, requestModel) {
const requestAdjustment = buildStandardAdjustmentMap(requestModel).get(id)
if (requestAdjustment) {
return requestAdjustment
}
const reimbursableAmount = parseOptionalCurrency(source?.reimbursableAmount ?? source?.reimbursable_amount)
if (reimbursableAmount === null) {
return null
}
return {
originalAmount: parseOptionalCurrency(source?.originalItemAmount ?? source?.original_item_amount ?? source?.originalAmount ?? source?.original_amount),
reimbursableAmount,
employeeAbsorbedAmount: parseOptionalCurrency(source?.employeeAbsorbedAmount ?? source?.employee_absorbed_amount) || 0,
message: String(source?.standardAdjustmentMessage || source?.standard_adjustment_message || '').trim()
}
}
export function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
@@ -407,7 +474,13 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
const attachments = invoiceId ? [attachmentName || invoiceId] : []
const standardAdjustment = resolveSourceStandardAdjustment(source, id, requestModel)
const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount
const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount
const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0)
const hasStandardAdjustment = reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
const reimbursableAmountDisplay = reimbursableAmount > 0 ? formatCurrency(reimbursableAmount) : '待补充'
const riskText = String(source?.riskText || '').trim()
const filledAt = formatExpenseFilledTime(
source?.filledAt
@@ -424,6 +497,15 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
itemLocation,
itemNote,
itemAmount,
originalItemAmount,
originalAmountDisplay: originalItemAmount > 0 ? formatCurrency(originalItemAmount) : amountDisplay,
reimbursableAmount,
reimbursableAmountDisplay,
employeeAbsorbedAmount,
employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatCurrency(employeeAbsorbedAmount) : '',
hasStandardAdjustment,
standardAdjustmentAccepted: Boolean(standardAdjustment),
standardAdjustmentMessage: standardAdjustment?.message || '',
invoiceId,
isSystemGenerated,
time: itemDate || '待补充',

View File

@@ -453,6 +453,7 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
suggestion: buildCardSuggestion(analysis, insight),
source: 'attachment_analysis',
itemType: normalizeText(item?.itemType),
documentType: normalizeText(insight?.documentTypeLabel),
visibility_scope: 'submitter',
@@ -645,6 +646,7 @@ export function buildAttachmentRiskCards({
summary,
ruleBasis,
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
source,
risk_domain: flag.risk_domain || flag.riskDomain,
visibility_scope: flag.visibility_scope || flag.visibilityScope,
actionability: flag.actionability

View File

@@ -0,0 +1,174 @@
import {
STANDARD_ADJUSTMENT_RISK_SOURCE,
buildStandardAdjustmentMap
} from './travelRequestDetailExpenseModel.js'
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeAmount(value) {
const amount = Number(value)
return Number.isFinite(amount) && amount > 0 ? amount : 0
}
export function buildCurrentStandardAdjustmentMap(request = {}, riskFlags = []) {
return buildStandardAdjustmentMap({
...request,
riskFlags,
risk_flags_json: riskFlags
})
}
export function resolveExpenseItemForRiskCard(card, expenseItems = []) {
const itemId = normalizeText(card?.itemId || card?.item_id)
const invoiceId = normalizeText(card?.invoiceId || card?.invoice_id)
const itemIndex = Number(card?.itemIndex || card?.item_index || 0)
return expenseItems.find((item) => normalizeText(item.id) === itemId)
|| expenseItems.find((item) => invoiceId && normalizeText(item.invoiceId) === invoiceId)
|| (itemIndex > 0 ? expenseItems[itemIndex - 1] : null)
|| null
}
export function hasStandardAdjustmentForItem(item, standardAdjustmentMap = new Map()) {
const itemId = normalizeText(item?.id)
if (!itemId) {
return false
}
return Boolean(item?.standardAdjustmentAccepted || standardAdjustmentMap.has(itemId))
}
export function isRiskCardMissingExpenseNote(card, expenseItems = []) {
const item = resolveExpenseItemForRiskCard(card, expenseItems)
if (!item) {
return true
}
return !normalizeText(item.itemNote)
}
function isRiskCardCoveredByStandardAdjustment(card, expenseItems, standardAdjustmentMap) {
if (normalizeText(card?.source) === STANDARD_ADJUSTMENT_RISK_SOURCE) {
return true
}
return hasStandardAdjustmentForItem(
resolveExpenseItemForRiskCard(card, expenseItems),
standardAdjustmentMap
)
}
export function filterSubmitterStandardAdjustedRiskCards({
cards = [],
businessStage = 'reimbursement',
isCurrentApplicant = false,
expenseItems = [],
standardAdjustmentMap = new Map()
} = {}) {
if (businessStage !== 'reimbursement' || !isCurrentApplicant) {
return cards
}
return cards.filter((card) => !isRiskCardCoveredByStandardAdjustment(card, expenseItems, standardAdjustmentMap))
}
function extractRiskCardMoneyValues(card) {
const corpus = [
card?.risk,
card?.summary,
card?.suggestion,
card?.title,
...(Array.isArray(card?.ruleBasis) ? card.ruleBasis : [])
].map(normalizeText).filter(Boolean).join(' ')
return [...corpus.matchAll(/(?:¥|¥)?\s*(\d+(?:,\d{3})*(?:\.\d+)?)\s*元/g)]
.map((match) => Number(String(match[1] || '').replace(/,/g, '')))
.filter((amount) => Number.isFinite(amount) && amount > 0)
}
function resolveParsedStandardAmount(card, item) {
const originalAmount = normalizeAmount(item?.itemAmount)
if (originalAmount <= 0) {
return null
}
const candidates = extractRiskCardMoneyValues(card)
.filter((amount) => amount > 0 && amount < originalAmount)
return candidates.length ? Math.max(...candidates) : null
}
function extractRiskCardNightCount(card) {
const corpus = [card?.risk, card?.summary, card?.suggestion, card?.title]
.map(normalizeText)
.join(' ')
const match = corpus.match(/(\d+)\s*(?:晚|夜|间夜)/)
return match ? Math.max(1, Number(match[1]) || 1) : 1
}
async function resolveTravelStandardAmount({ card, item, request, calculateTravelReimbursement }) {
const itemType = normalizeText(item?.itemType)
if (!['hotel_ticket', 'hotel'].includes(itemType)) {
return null
}
const location = normalizeText(item?.itemLocation || request?.location || request?.sceneTarget)
if (!location || typeof calculateTravelReimbursement !== 'function') {
return null
}
const grade = normalizeText(request?.employeeGrade || request?.profileGrade)
const days = extractRiskCardNightCount(card)
try {
const result = await calculateTravelReimbursement({ days, location, grade })
const hotelAmount = Number(result?.hotel_amount ?? result?.hotelAmount)
return Number.isFinite(hotelAmount) && hotelAmount > 0 ? hotelAmount : null
} catch (error) {
return null
}
}
async function resolveRiskStandardReimbursableAmount({ card, item, request, calculateTravelReimbursement }) {
const originalAmount = normalizeAmount(item?.itemAmount)
if (originalAmount <= 0) {
return 0
}
const parsedAmount = resolveParsedStandardAmount(card, item)
if (parsedAmount !== null) {
return Math.min(originalAmount, parsedAmount)
}
const travelStandardAmount = await resolveTravelStandardAmount({
card,
item,
request,
calculateTravelReimbursement
})
if (travelStandardAmount !== null) {
return Math.min(originalAmount, travelStandardAmount)
}
return originalAmount
}
export async function buildStandardAdjustmentPayload({
warnings = [],
expenseItems = [],
request = {},
calculateTravelReimbursement
} = {}) {
const risks = []
for (const warning of warnings) {
const item = resolveExpenseItemForRiskCard(warning, expenseItems)
if (!item) {
continue
}
const originalAmount = normalizeAmount(item.itemAmount)
const reimbursableAmount = await resolveRiskStandardReimbursableAmount({
card: warning,
item,
request,
calculateTravelReimbursement
})
risks.push({
risk_id: warning.id,
item_id: item.id,
title: warning.title,
risk: warning.risk || warning.summary,
original_amount: originalAmount,
reimbursable_amount: reimbursableAmount
})
}
return { risks }
}

View File

@@ -20,7 +20,7 @@ export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHigh
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
}
if (hasHighRiskWarnings) {
return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
return '系统自动检测存在风险提示,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
}
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
}

View File

@@ -17,6 +17,7 @@ import {
import {
buildExpenseItemViewModel,
buildDraftBlockingIssues,
buildStandardAdjustmentMap,
isApplicationDocumentRequest
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
import {
@@ -687,6 +688,9 @@ test('expense detail table shows each item filled time from item creation time',
test('expense detail table has per-item risk explanation column', () => {
assert.match(detailViewTemplate, /<th class="col-risk-note">异常说明<\/th>/)
assert.match(detailViewTemplate, /v-model="expenseEditor\.itemNote"/)
assert.match(detailViewTemplate, /class="editor-textarea risk-note-editor-textarea"[\s\S]*rows="1"/)
assert.match(detailViewTemplate, /@input="resizeExpenseNoteInput"/)
assert.match(detailViewStyle, /\.risk-note-editor-textarea[\s\S]*max-height: 78px/)
assert.match(detailViewTemplate, /hasExpenseRiskOrAbnormal\(item\)[\s\S]*待补充异常说明/)
assert.match(detailViewScript, /itemNote: ''/)
assert.match(detailViewScript, /expenseEditor\.itemNote = item\.itemNote \|\| ''/)
@@ -697,6 +701,49 @@ test('expense detail table has per-item risk explanation column', () => {
assert.match(requestsComposableScript, /const itemNote = String\(item\?\.item_note \|\| item\?\.itemNote \|\| ''\)\.trim\(\)/)
})
test('expense detail shows standard-adjusted reimbursable amount separately from receipt amount', () => {
assert.match(detailViewTemplate, /v-if="item\.hasStandardAdjustment" class="expense-adjusted-amount"/)
assert.match(detailViewTemplate, /class="expense-original-amount"[\s\S]*item\.originalAmountDisplay/)
assert.match(detailViewTemplate, /class="expense-reimbursable-amount"[\s\S]*item\.reimbursableAmountDisplay/)
assert.match(detailViewTemplate, /submitConfirmAmountDisplay/)
assert.match(detailViewStyle, /\.expense-original-amount[\s\S]*text-decoration-line: line-through/)
assert.match(detailViewScript, /const expenseTotal = computed\(\(\) => \{[\s\S]*item\.reimbursableAmount/)
assert.match(detailViewScript, /filterSubmitterStandardAdjustedRiskCards/)
assert.match(detailViewScript, /acceptExpenseClaimStandardAdjustment/)
assert.match(detailExpenseModelScript, /STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'/)
assert.match(requestsComposableScript, /STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'/)
assert.match(requestsComposableScript, /const visibleExpenseAmount = expenseItems\.reduce[\s\S]*item\.reimbursableAmount/)
const riskFlags = [
{
source: 'reimbursement_standard_adjustment',
item_id: 'expense-item-1',
original_amount: '880.00',
reimbursable_amount: '600.00',
employee_absorbed_amount: '280.00'
}
]
const adjustmentMap = buildStandardAdjustmentMap({ riskFlags })
assert.equal(adjustmentMap.get('expense-item-1').reimbursableAmount, 600)
const item = buildExpenseItemViewModel(
{
id: 'expense-item-1',
itemType: 'hotel_ticket',
itemReason: '北京住宿',
itemAmount: 880,
invoiceId: 'hotel.pdf'
},
0,
{ riskFlags }
)
assert.equal(item.itemAmount, 880)
assert.equal(item.reimbursableAmount, 600)
assert.equal(item.employeeAbsorbedAmount, 280)
assert.equal(item.hasStandardAdjustment, true)
})
test('expense item upload remains limited to one receipt per detail row', () => {
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
assert.doesNotMatch(

View File

@@ -63,21 +63,24 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
})
test('detail submit no longer requires a separate high-risk override dialog', () => {
test('detail submit warns on missing risk explanation and supports standard adjustment', () => {
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
assert.match(detailViewTemplate, /重大风险/)
assert.match(detailViewTemplate, /异常说明/)
assert.match(detailViewTemplate, /按职级标准重算/)
assert.match(detailViewTemplate, /保存说明并继续提交/)
assert.match(detailViewTemplate, /goToPreviousSubmitRisk/)
assert.match(detailViewTemplate, /goToNextSubmitRisk/)
assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/)
assert.match(detailViewScript, /const submitRiskWarnings = computed/)
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
assert.doesNotMatch(handleSubmit, /openRiskOverrideDialog/)
assert.match(handleSubmit, /submitRiskWarnings\.value\.length[\s\S]*openRiskOverrideDialog\(\)/)
assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/)
assert.doesNotMatch(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/)
assert.match(detailViewScript, /function confirmRiskOverrideReasons\(\)/)
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId,\s*\{\s*reason: nextNote/s)
assert.match(detailViewScript, /超标说明:\$\{tags\}/)
assert.match(detailViewScript, /updateExpenseClaimItem\(request\.value\.claimId, itemId,[\s\S]*item_note: nextNote/s)
assert.match(detailViewScript, /function confirmStandardAdjustment\(\)/)
assert.match(detailViewScript, /acceptExpenseClaimStandardAdjustment\(request\.value\.claimId, payload\)/)
assert.match(detailExpenseModelScript, /STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'/)
assert.match(detailViewTemplate, /异常说明/)
})