fix: handle risk explanation standard adjustment
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || '待补充',
|
||||
|
||||
@@ -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
|
||||
|
||||
174
web/src/views/scripts/travelRequestDetailStandardAdjustment.js
Normal file
174
web/src/views/scripts/travelRequestDetailStandardAdjustment.js
Normal 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 }
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHigh
|
||||
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
|
||||
}
|
||||
if (hasHighRiskWarnings) {
|
||||
return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
|
||||
return '系统自动检测存在风险提示,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
|
||||
}
|
||||
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user