fix: restrict application linking for reimbursement drafts

This commit is contained in:
caoxiaozhu
2026-06-03 16:28:09 +08:00
parent 8887cf5a27
commit 04f0951b3d
4 changed files with 537 additions and 5 deletions

View File

@@ -13,7 +13,10 @@ const APPLICATION_TYPE_ALIASES = {
}
const GENERIC_APPLICATION_TYPES = new Set(['application', 'expense_application'])
const BLOCKED_APPLICATION_STATUSES = new Set(['draft', 'returned', 'rejected', 'cancelled', 'canceled', 'deleted'])
const APPROVED_APPLICATION_STATUSES = new Set(['approved', 'completed'])
const APPROVED_APPLICATION_APPROVAL_KEYS = new Set(['completed'])
const BLOCKED_APPLICATION_LINK_STATUSES = new Set(['draft', 'returned', 'rejected', 'archived', 'cancelled', 'canceled', 'deleted'])
const INACTIVE_REIMBURSEMENT_LINK_STATUSES = new Set(['cancelled', 'canceled', 'deleted'])
const STATUS_LABELS = {
submitted: '审批中',
@@ -53,6 +56,10 @@ function normalizeApplicationStatus(claim) {
return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus)
}
function normalizeApprovalKey(claim) {
return normalizeLower(claim?.approvalKey || claim?.approval_key)
}
function normalizeDocumentType(claim) {
return normalizeLower(
claim?.document_type_code
@@ -141,6 +148,99 @@ function resolveApplicationDetailPayload(claim) {
return detail && typeof detail === 'object' ? detail : {}
}
function resolveRiskFlags(claim) {
const flags = claim?.risk_flags_json || claim?.riskFlags || []
return Array.isArray(flags) ? flags : []
}
function createReferenceIndex() {
return {
ids: new Set(),
claimNos: new Set()
}
}
function addApplicationReference(index, idValue, claimNoValue) {
const id = normalizeText(idValue)
if (id) {
index.ids.add(id)
}
const claimNo = normalizeText(claimNoValue).toUpperCase()
if (claimNo) {
index.claimNos.add(claimNo)
}
}
function addApplicationReferencesFromPayload(index, payload) {
if (!payload || typeof payload !== 'object') {
return
}
addApplicationReference(
index,
payload.application_claim_id || payload.applicationClaimId || payload.id,
payload.application_claim_no || payload.applicationClaimNo || payload.claim_no || payload.claimNo
)
}
function collectLinkedApplicationReferences(claim) {
const index = createReferenceIndex()
addApplicationReferencesFromPayload(index, claim?.relatedApplication)
addApplicationReferencesFromPayload(index, claim?.related_application)
resolveRiskFlags(claim).forEach((flag) => {
if (!flag || typeof flag !== 'object') {
return
}
addApplicationReferencesFromPayload(index, flag)
addApplicationReferencesFromPayload(index, flag.application_detail || flag.applicationDetail)
addApplicationReferencesFromPayload(index, flag.review_form_values || flag.reviewFormValues)
addApplicationReferencesFromPayload(index, flag.expense_scene_selection || flag.expenseSceneSelection)
})
return index
}
function hasAnyApplicationReference(index) {
return Boolean(index?.ids?.size || index?.claimNos?.size)
}
function buildLinkedApplicationReferenceIndex(claims) {
const index = createReferenceIndex()
;(Array.isArray(claims) ? claims : []).forEach((claim) => {
if (isExpenseApplicationClaim(claim)) {
return
}
const status = normalizeApplicationStatus(claim)
if (INACTIVE_REIMBURSEMENT_LINK_STATUSES.has(status)) {
return
}
const claimReferences = collectLinkedApplicationReferences(claim)
claimReferences.ids.forEach((id) => index.ids.add(id))
claimReferences.claimNos.forEach((claimNo) => index.claimNos.add(claimNo))
})
return index
}
function isApplicationAlreadyLinked(claim, linkedApplicationReferences) {
if (!linkedApplicationReferences) {
return false
}
const ownReferences = createReferenceIndex()
addApplicationReference(
ownReferences,
claim?.id || claim?.claim_id || claim?.claimId,
claim?.claim_no || claim?.claimNo
)
if (!hasAnyApplicationReference(ownReferences)) {
return false
}
return Array.from(ownReferences.ids).some((id) => linkedApplicationReferences.ids.has(id))
|| Array.from(ownReferences.claimNos).some((claimNo) => linkedApplicationReferences.claimNos.has(claimNo))
}
function toTimestamp(value) {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
@@ -265,9 +365,14 @@ export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
return true
}
export function isUsableRequiredApplicationClaim(claim) {
export function isUsableRequiredApplicationClaim(claim, linkedApplicationReferences = null) {
const status = normalizeApplicationStatus(claim)
return !BLOCKED_APPLICATION_STATUSES.has(status)
const approvalKey = normalizeApprovalKey(claim)
if (BLOCKED_APPLICATION_LINK_STATUSES.has(status)) {
return false
}
return (APPROVED_APPLICATION_STATUSES.has(status) || APPROVED_APPLICATION_APPROVAL_KEYS.has(approvalKey))
&& !isApplicationAlreadyLinked(claim, linkedApplicationReferences)
}
export function normalizeRequiredApplicationCandidate(claim) {
@@ -331,10 +436,12 @@ export function filterRequiredApplicationCandidates(claimsPayload, expenseType,
? claimsPayload.claims
: []
const linkedApplicationReferences = buildLinkedApplicationReferenceIndex(claims)
return claims
.filter((claim) => (
isExpenseApplicationClaim(claim)
&& isUsableRequiredApplicationClaim(claim)
&& isUsableRequiredApplicationClaim(claim, linkedApplicationReferences)
&& isClaimOwnedByCurrentUser(claim, currentUser)
&& matchesRequiredApplicationExpenseType(claim, expenseType)
))

View File

@@ -254,7 +254,7 @@ test('guided reimbursement requires application selection for travel and enterta
reason: '客户招待沟通项目',
location: '武汉',
amount: 600,
status: 'submitted',
status: 'approved',
created_at: '2026-05-21T08:00:00Z'
},
{
@@ -265,6 +265,44 @@ test('guided reimbursement requires application selection for travel and enterta
reason: '草稿出差申请',
status: 'draft'
},
{
id: 'app-submitted',
claim_no: 'AP-202605-005',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '审批中的出差申请',
status: 'submitted'
},
{
id: 'app-archived-stale-key',
claim_no: 'AP-202605-007',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '已归档申请单',
status: 'archived',
approvalKey: 'completed'
},
{
id: 'app-linked',
claim_no: 'AP-202605-006',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '已生成报销草稿的出差申请',
status: 'approved'
},
{
id: 're-linked-draft',
claim_no: 'RE-202605-006',
employee_name: '张小青',
expense_type: 'travel',
reason: '已关联申请单的报销草稿',
status: 'draft',
risk_flags_json: [{
source: 'application_link',
application_claim_id: 'app-linked',
application_claim_no: 'AP-202605-006'
}]
},
{
id: 'app-other-user',
claim_no: 'AP-202605-004',