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

@@ -20,6 +20,7 @@ from app.schemas.reimbursement import (
ExpenseClaimItemUpdate, ExpenseClaimItemUpdate,
ExpenseClaimRead, ExpenseClaimRead,
ExpenseClaimReturnPayload, ExpenseClaimReturnPayload,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate, ExpenseClaimUpdate,
ReimbursementCreate, ReimbursementCreate,
ReimbursementRead, ReimbursementRead,
@@ -233,6 +234,43 @@ def update_expense_claim(
return claim return claim
@router.post(
"/claims/{claim_id}/standard-adjustment",
response_model=ExpenseClaimRead,
summary="接受职级报销标准重算",
description="在草稿报销单存在中高风险但提交人不补充异常说明时,按职级可报销标准重算实际报销金额。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "报销单状态不允许重算或入参不合法。",
},
},
)
def accept_expense_claim_standard_adjustment(
claim_id: str,
payload: ExpenseClaimStandardAdjustmentPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.accept_standard_adjustment(
claim_id=claim_id,
payload=payload,
current_user=current_user,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.patch( @router.patch(
"/claims/{claim_id}/items/{item_id}", "/claims/{claim_id}/items/{item_id}",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,

View File

@@ -121,6 +121,19 @@ class ExpenseClaimUpdate(BaseModel):
reason: str | None = Field(default=None, max_length=500) reason: str | None = Field(default=None, max_length=500)
class ExpenseClaimStandardAdjustmentRisk(BaseModel):
risk_id: str | None = Field(default=None, max_length=120)
item_id: str | None = Field(default=None, max_length=120)
title: str | None = Field(default=None, max_length=120)
risk: str | None = Field(default=None, max_length=500)
original_amount: Decimal | None = None
reimbursable_amount: Decimal | None = None
class ExpenseClaimStandardAdjustmentPayload(BaseModel):
risks: list[ExpenseClaimStandardAdjustmentRisk] = Field(default_factory=list, max_length=20)
class ExpenseClaimRead(BaseModel): class ExpenseClaimRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -26,6 +26,7 @@ EXPENSE_TYPE_LABELS = {
} }
MAX_DRAFT_CLAIMS_PER_USER = 3 MAX_DRAFT_CLAIMS_PER_USER = 3
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned") EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
STANDARD_ADJUSTMENT_RISK_SOURCE = "reimbursement_standard_adjustment"
SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"} SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"}
OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"} OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"}
TRAVEL_DETAIL_ITEM_TYPES = { TRAVEL_DETAIL_ITEM_TYPES = {

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal, InvalidOperation
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
@@ -24,6 +24,7 @@ from app.services.expense_claim_constants import (
DOCUMENT_FACT_ITEM_TYPES, DOCUMENT_FACT_ITEM_TYPES,
LOCATION_REQUIRED_EXPENSE_TYPES, LOCATION_REQUIRED_EXPENSE_TYPES,
OPTIONAL_ATTACHMENT_ITEM_TYPES, OPTIONAL_ATTACHMENT_ITEM_TYPES,
STANDARD_ADJUSTMENT_RISK_SOURCE,
SYSTEM_GENERATED_ITEM_TYPES, SYSTEM_GENERATED_ITEM_TYPES,
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
@@ -329,6 +330,45 @@ class ExpenseClaimItemSyncMixin:
return destination return destination
return "" return ""
@staticmethod
def _parse_standard_adjustment_amount(value: Any) -> Decimal | None:
try:
raw_value = "" if value is None else value
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount >= Decimal("0.00") else None
def _collect_standard_adjusted_amounts(self, claim: ExpenseClaim) -> dict[str, Decimal]:
adjusted_amounts: dict[str, Decimal] = {}
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if str(flag.get("source") or "").strip() != STANDARD_ADJUSTMENT_RISK_SOURCE:
continue
item_id = str(flag.get("item_id") or flag.get("itemId") or "").strip()
if not item_id:
continue
amount = self._parse_standard_adjustment_amount(
flag.get("reimbursable_amount") or flag.get("reimbursableAmount")
)
if amount is None:
continue
adjusted_amounts[item_id] = amount
return adjusted_amounts
def _resolve_item_amount_for_claim_total(
self,
item: ExpenseClaimItem,
adjusted_amounts: dict[str, Decimal],
) -> Decimal:
original_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
item_id = str(item.id or "").strip()
adjusted_amount = adjusted_amounts.get(item_id)
if adjusted_amount is None:
return original_amount
return min(max(adjusted_amount, Decimal("0.00")), original_amount)
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None: def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
self._sync_travel_allowance_item(claim) self._sync_travel_allowance_item(claim)
if not claim.items: if not claim.items:
@@ -346,7 +386,11 @@ class ExpenseClaimItemSyncMixin:
), ),
) )
primary_item = ordered_items[0] primary_item = ordered_items[0]
total_amount = sum((item.item_amount for item in ordered_items), Decimal("0.00")) adjusted_amounts = self._collect_standard_adjusted_amounts(claim)
total_amount = sum(
(self._resolve_item_amount_for_claim_total(item, adjusted_amounts) for item in ordered_items),
Decimal("0.00"),
)
claim.amount = total_amount.quantize(Decimal("0.01")) claim.amount = total_amount.quantize(Decimal("0.01"))
claim.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip()) claim.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip())

View File

@@ -72,7 +72,11 @@ class ExpenseClaimPolicyReviewMixin:
limit_config=item_limit, limit_config=item_limit,
reason_text="\n".join( reason_text="\n".join(
part part
for part in [reason_corpus, str(item.item_reason or "").strip()] for part in [
reason_corpus,
str(item.item_reason or "").strip(),
str(item.item_note or "").strip(),
]
if part if part
), ),
) )
@@ -333,7 +337,12 @@ class ExpenseClaimPolicyReviewMixin:
f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚," f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚,"
f"当前酒店识别金额约 {nightly_amount} 元/晚。" f"当前酒店识别金额约 {nightly_amount} 元/晚。"
) )
item_reason = str(context["item"].item_reason or "").strip() item_reason = " ".join(
[
str(context["item"].item_reason or "").strip(),
str(context["item"].item_note or "").strip(),
]
).strip()
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords) item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
if has_standard_exception or item_has_exception: if has_standard_exception or item_has_exception:
flags.append( flags.append(
@@ -368,7 +377,12 @@ class ExpenseClaimPolicyReviewMixin:
if allowed_level is None or class_level <= allowed_level: if allowed_level is None or class_level <= allowed_level:
continue continue
item_reason = str(context["item"].item_reason or "").strip() item_reason = " ".join(
[
str(context["item"].item_reason or "").strip(),
str(context["item"].item_note or "").strip(),
]
).strip()
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords) item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
message = f"{band_label} 职级当前默认不可报销 {class_label}" message = f"{band_label} 职级当前默认不可报销 {class_label}"
if has_standard_exception or item_has_exception: if has_standard_exception or item_has_exception:
@@ -463,6 +477,7 @@ class ExpenseClaimPolicyReviewMixin:
parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()] parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()]
for item in claim.items: for item in claim.items:
parts.append(str(item.item_reason or "").strip()) parts.append(str(item.item_reason or "").strip())
parts.append(str(item.item_note or "").strip())
parts.append(str(item.item_location or "").strip()) parts.append(str(item.item_location or "").strip())
return "\n".join(part for part in parts if part) return "\n".join(part for part in parts if part)

View File

@@ -27,6 +27,7 @@ from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import ( from app.schemas.reimbursement import (
ExpenseClaimItemCreate, ExpenseClaimItemCreate,
ExpenseClaimItemUpdate, ExpenseClaimItemUpdate,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate, ExpenseClaimUpdate,
TravelReimbursementCalculatorRequest, TravelReimbursementCalculatorRequest,
) )
@@ -109,6 +110,7 @@ from app.services.expense_claim_constants import (
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS, TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS, TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
STANDARD_ADJUSTMENT_RISK_SOURCE,
) )
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
from app.services.expense_amounts import ( from app.services.expense_amounts import (
@@ -290,6 +292,126 @@ class ExpenseClaimService(
return claim return claim
@staticmethod
def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None:
try:
raw_value = "" if value is None else value
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount >= Decimal("0.00") else None
@staticmethod
def _format_adjustment_money(value: Decimal) -> str:
normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01"))
return f"{normalized:.2f}"
def accept_standard_adjustment(
self,
*,
claim_id: str,
payload: ExpenseClaimStandardAdjustmentPayload,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
self._ensure_draft_claim(claim)
if self._is_expense_application_claim(claim):
raise ValueError("费用申请单不支持按报销标准重算。")
risk_entries = list(payload.risks or [])
if not risk_entries:
raise ValueError("请至少选择一条需要按职级标准重算的风险。")
before_json = self._serialize_claim(claim)
item_map = {str(item.id or "").strip(): item for item in list(claim.items or [])}
now_text = datetime.now(UTC).isoformat()
adjustment_flags: list[dict[str, Any]] = []
for index, entry in enumerate(risk_entries, start=1):
item_id = str(entry.item_id or "").strip()
item = item_map.get(item_id)
if item is None:
continue
original_amount = (
self._normalize_standard_adjustment_amount(entry.original_amount)
or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
)
reimbursable_amount = (
self._normalize_standard_adjustment_amount(entry.reimbursable_amount)
or original_amount
)
reimbursable_amount = min(max(reimbursable_amount, Decimal("0.00")), original_amount)
employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(Decimal("0.01"))
item_label = (
str(item.item_reason or "").strip()
or str(entry.title or "").strip()
or f"费用明细第 {index}"
)
source_risk = str(entry.risk or entry.title or "原风险未补充异常说明").strip()
message = (
f"提交人已选择按职级最高报销标准审核:{item_label} 原票据金额 "
f"{self._format_adjustment_money(original_amount)} 元,实际报销金额 "
f"{self._format_adjustment_money(reimbursable_amount)} 元,超出 "
f"{self._format_adjustment_money(employee_absorbed_amount)} 元由员工自行承担。"
)
adjustment_flags.append(
with_risk_business_stage(
{
"source": STANDARD_ADJUSTMENT_RISK_SOURCE,
"event_type": "standard_adjustment_accepted",
"severity": "medium",
"label": "接受职级标准审核",
"title": "提交人接受职级最高报销标准",
"message": message,
"summary": "提交人未补充异常说明,已选择按职级最高报销标准重算实际报销金额。",
"suggestion": "领导和财务审批时请确认该差额由员工自行承担,并按实际报销金额入账。",
"risk_id": str(entry.risk_id or "").strip(),
"source_risk": source_risk,
"item_id": item_id,
"original_amount": self._format_adjustment_money(original_amount),
"reimbursable_amount": self._format_adjustment_money(reimbursable_amount),
"employee_absorbed_amount": self._format_adjustment_money(employee_absorbed_amount),
"risk_domain": "amount",
"actionability": "review_decision",
"visibility_scope": "leader",
"created_at": now_text,
},
"reimbursement",
)
)
if not adjustment_flags:
raise ValueError("未找到可按职级标准重算的费用明细。")
preserved_flags = [
flag
for flag in list(claim.risk_flags_json or [])
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
)
]
claim.risk_flags_json = [*preserved_flags, *adjustment_flags]
self._sync_claim_from_items(claim)
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.standard_adjustment_accept",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
def update_claim_item( def update_claim_item(
self, self,
*, *,
@@ -758,6 +880,3 @@ class ExpenseClaimService(

View File

@@ -78,6 +78,9 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
"application_policy_estimate", "application_policy_estimate",
"application_rule_name", "application_rule_name",
"application_rule_version", "application_rule_version",
"original_amount",
"reimbursable_amount",
"employee_absorbed_amount",
} }
) )

View File

@@ -19,7 +19,12 @@ from app.models.organization import OrganizationUnit
from app.models.role import Role from app.models.role import Role
from app.schemas.ontology import OntologyParseRequest from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate from app.schemas.reimbursement import (
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate,
)
from app.services.agent_conversations import AgentConversationService from app.services.agent_conversations import AgentConversationService
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
@@ -2615,6 +2620,77 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.submitted_at is not None assert submitted.submitted_at is not None
def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
current_user = CurrentUserContext(
username="emp-standard@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E7030",
name="李经理",
email="manager-standard@example.com",
)
employee = Employee(
employee_no="E7031",
name="张三",
email="emp-standard@example.com",
manager=manager,
)
claim = build_claim(expense_type="hotel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.amount = Decimal("880.00")
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_amount = Decimal("880.00")
db.add_all([manager, employee, claim])
db.commit()
service = ExpenseClaimService(db)
adjusted = service.accept_standard_adjustment(
claim_id=claim.id,
payload=ExpenseClaimStandardAdjustmentPayload(
risks=[
{
"risk_id": "risk-hotel-1",
"item_id": claim.items[0].id,
"title": "住宿超标待说明",
"risk": "住宿标准为 600 元/晚,当前酒店识别金额约 880 元/晚。",
"original_amount": Decimal("880.00"),
"reimbursable_amount": Decimal("600.00"),
}
]
),
current_user=current_user,
)
assert adjusted is not None
assert adjusted.amount == Decimal("600.00")
standard_flag = next(
flag
for flag in adjusted.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "880.00"
assert standard_flag["reimbursable_amount"] == "600.00"
assert standard_flag["employee_absorbed_amount"] == "280.00"
assert standard_flag["visibility_scope"] == "leader"
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.amount == Decimal("600.00")
assert any(
isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
for flag in submitted.risk_flags_json
)
def test_pre_review_claim_records_ai_result_without_submitting() -> None: def test_pre_review_claim_records_ai_result_without_submitting() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-pre-review@example.com", username="emp-pre-review@example.com",

View File

@@ -1000,6 +1000,13 @@
line-height: 1.45; line-height: 1.45;
} }
.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
overflow-y: auto;
resize: none;
}
.currency-editor { .currency-editor {
display: grid; display: grid;
grid-template-columns: 34px minmax(0, 1fr); grid-template-columns: 34px minmax(0, 1fr);
@@ -1062,6 +1069,37 @@
white-space: nowrap; 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 { .expense-filled-at strong {
font-size: 12px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
@@ -1950,12 +1988,46 @@
color: #0f172a; color: #0f172a;
} }
.risk-override-card textarea.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
resize: none;
}
.risk-override-card textarea:focus { .risk-override-card textarea:focus {
border-color: #ef4444; border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, .12); box-shadow: 0 0 0 3px rgba(239, 68, 68, .12);
outline: none; 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 { .validation-card {
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
background: #ffffff; 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 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 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 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']) const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
@@ -83,6 +84,45 @@ function parseNumber(value) {
return Number.isFinite(nextValue) ? nextValue : 0 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) { function toDate(value) {
if (!value) { if (!value) {
return null 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)) return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
}) })
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim) const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
const standardAdjustmentMap = buildStandardAdjustmentMapFromClaim(claim)
return sortedItems.map((item, index) => { return sortedItems.map((item, index) => {
const invoiceId = String(item?.invoice_id || '').trim() 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 itemNote = String(item?.item_note || item?.itemNote || '').trim()
const itemAmount = parseNumber(item?.item_amount) const itemAmount = parseNumber(item?.item_amount)
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充' 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 { return {
id, id,
@@ -1297,6 +1342,15 @@ function buildExpenseItems(claim, riskMeta) {
itemLocation, itemLocation,
itemNote, itemNote,
itemAmount, 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, invoiceId,
isSystemGenerated, isSystemGenerated,
dayLabel: resolveExpenseTimeLabel({ dayLabel: resolveExpenseTimeLabel({
@@ -1336,7 +1390,10 @@ export function mapExpenseClaimToRequest(claim) {
const riskSummary = riskMeta.summary const riskSummary = riskMeta.summary
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel) const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
const expenseItems = buildExpenseItems(claim, riskMeta) 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 const amountValue = relatedApplication
? expenseItems.length ? expenseItems.length
? visibleExpenseAmount ? 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 = {}) { export function calculateTravelReimbursement(payload = {}) {
return apiRequest('/reimbursements/travel-calculator', { return apiRequest('/reimbursements/travel-calculator', {
method: 'POST', method: 'POST',

View File

@@ -273,7 +273,12 @@
</div> </div>
</template> </template>
<template v-else> <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> <span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
</template> </template>
</td> </td>
@@ -370,9 +375,11 @@
<div class="cell-editor"> <div class="cell-editor">
<textarea <textarea
v-model="expenseEditor.itemNote" v-model="expenseEditor.itemNote"
class="editor-textarea" class="editor-textarea risk-note-editor-textarea"
rows="3" rows="1"
placeholder="如票据存在异常或风险,请补充原因" placeholder="如票据存在异常或风险,请补充原因"
@input="resizeExpenseNoteInput"
@keydown.enter="resizeExpenseNoteInput"
></textarea> ></textarea>
<span>用于说明改签绕行超标票据异常等情况</span> <span>用于说明改签绕行超标票据异常等情况</span>
</div> </div>
@@ -764,7 +771,7 @@
</div> </div>
<div class="submit-confirm-row"> <div class="submit-confirm-row">
<span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span> <span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span>
<strong>{{ request.amountDisplay || expenseTotal }}</strong> <strong>{{ submitConfirmAmountDisplay }}</strong>
</div> </div>
<div v-if="!isApplicationDocument" class="submit-confirm-row"> <div v-if="!isApplicationDocument" class="submit-confirm-row">
<span>费用明细</span> <span>费用明细</span>
@@ -774,20 +781,20 @@
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog <ConfirmDialog
:open="riskOverrideDialogOpen" :open="riskOverrideDialogOpen"
badge="重大风险" badge="异常说明"
badge-tone="danger" badge-tone="danger"
:title="`当前存在 ${submitRiskWarnings.length} 条重大风险`" :title="`当前存在 ${submitRiskWarnings.length} 条需说明的风险`"
description="如仍需提交审批,请逐条填写每一个重大风险的原因,系统会写入附加说明并用于后续风险统计。" description="请先补充异常说明后提交领导审批;也可以不填写说明,选择按职级最高可报销金额重新计算。"
cancel-text="返回整改" cancel-text="返回整改"
confirm-text="保存原因并继续" confirm-text="按职级标准重算"
busy-text="保存中..." busy-text="处理中..."
confirm-tone="danger" confirm-tone="danger"
confirm-icon="mdi mdi-alert-circle-outline" confirm-icon="mdi mdi-calculator-variant-outline"
:busy="riskOverrideBusy" :busy="riskOverrideBusy"
@close="closeRiskOverrideDialog" @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"> <div class="risk-override-nav">
<button <button
type="button" type="button"
@@ -817,11 +824,26 @@
<p>{{ currentSubmitRiskWarning.risk }}</p> <p>{{ currentSubmitRiskWarning.risk }}</p>
<textarea <textarea
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]" v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
class="risk-note-editor-textarea"
rows="1"
maxlength="160" maxlength="160"
placeholder="请说明为什么仍需提交,例如客户指定酒店、会议高峰、协议酒店满房等" placeholder="请说明原因,例如客户指定酒店、会议高峰、协议酒店满房等"
aria-label="违规提交原因" aria-label="异常说明"
@input="resizeExpenseNoteInput"
@keydown.enter="resizeExpenseNoteInput"
></textarea> ></textarea>
</article> </article>
<div class="risk-override-submit-row">
<button
class="risk-override-save-btn"
type="button"
:disabled="riskOverrideBusy"
@click="confirmRiskOverrideReasons"
>
保存说明并继续提交
</button>
<span>不填写说明时系统会按职级最高报销标准重算金额</span>
</div>
</div> </div>
</ConfirmDialog> </ConfirmDialog>
<TravelRequestDeleteDialog :open="deleteDialogOpen" :badge="deleteActionLabel" :title="deleteDialogTitle" :description="deleteDialogDescription" :busy="deleteBusy" @close="closeDeleteDialog" @confirm="confirmDeleteRequest" /> <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 StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue' import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import { import {
acceptExpenseClaimStandardAdjustment,
approveExpenseClaim, approveExpenseClaim,
calculateTravelReimbursement,
createExpenseClaimItem, createExpenseClaimItem,
deleteExpenseClaimItem, deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment, deleteExpenseClaimItemAttachment,
@@ -88,6 +90,13 @@ import {
resolveSubmitConfirmDescription, resolveSubmitConfirmDescription,
resolveSubmitConfirmText resolveSubmitConfirmText
} from './travelRequestDetailSubmitModel.js' } from './travelRequestDetailSubmitModel.js'
import {
buildCurrentStandardAdjustmentMap,
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
filterSubmitterStandardAdjustedRiskCards as filterSubmitterStandardAdjustedRiskCardsModel,
isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel,
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel
} from './travelRequestDetailStandardAdjustment.js'
import { import {
buildEmployeeProfileAdviceItems, buildEmployeeProfileAdviceItems,
buildTravelReceiptMaterialPrompts buildTravelReceiptMaterialPrompts
@@ -994,9 +1003,16 @@ export default {
} }
const expenseTotal = computed(() => { 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) return formatCurrency(total)
}) })
const submitConfirmAmountDisplay = computed(() =>
isApplicationDocument.value ? (request.value.amountDisplay || expenseTotal.value) : expenseTotal.value
)
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value)) const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value)) const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
@@ -1155,6 +1171,57 @@ export default {
return requestFlags 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) { function resolveAttachmentDisplayName(item) {
const metadata = resolveAttachmentMeta(item) const metadata = resolveAttachmentMeta(item)
return String(metadata?.file_name || item.attachmentHint || '').trim() return String(metadata?.file_name || item.attachmentHint || '').trim()
@@ -1530,7 +1597,7 @@ export default {
: [] : []
const scopedRiskCards = [ const scopedRiskCards = [
...(hasActionableRiskCards ? [] : summaryRiskCards), ...(hasActionableRiskCards ? [] : summaryRiskCards),
...directRiskCards ...filterSubmitterStandardAdjustedRiskCards(directRiskCards, currentBusinessStage)
] ]
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value) const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
@@ -1652,7 +1719,8 @@ export default {
const submitRiskWarnings = computed(() => const submitRiskWarnings = computed(() =>
aiAdvice.value.riskCards aiAdvice.value.riskCards
.filter((card) => normalizeRiskTone(card?.tone) === 'high') .filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.filter((card) => isRiskCardMissingExpenseNote(card))
.map((card, index) => ({ .map((card, index) => ({
...card, ...card,
id: String(card.id || `submit-risk-${index}`), id: String(card.id || `submit-risk-${index}`),
@@ -1663,7 +1731,6 @@ export default {
const riskOverrideIndexLabel = computed(() => const riskOverrideIndexLabel = computed(() =>
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : '' submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : ''
) )
const hasRiskOverrideExplanation = computed(() => detailNoteTags.value.includes('#high_risk'))
function resetDetailNote() { function resetDetailNote() {
detailNoteEditor.value = detailNoteSource.value detailNoteEditor.value = detailNoteSource.value
@@ -1722,6 +1789,18 @@ export default {
riskOverrideDialogOpen.value = false 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() { function goToPreviousSubmitRisk() {
if (!submitRiskWarnings.value.length) { if (!submitRiskWarnings.value.length) {
return return
@@ -1737,17 +1816,6 @@ export default {
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length 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) { function mergeDetailNoteWithRiskOverride(appendix) {
const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value
return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n') 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()) const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim())
if (missingIndex >= 0) { if (missingIndex >= 0) {
riskOverrideIndex.value = missingIndex riskOverrideIndex.value = missingIndex
toast('请为每一条重大风险填写违规提交原因。') toast('请为每一条风险填写异常说明。')
return return
} }
const appendix = buildRiskOverrideAppendix() const itemNoteGroups = new Map()
const nextNote = mergeDetailNoteWithRiskOverride(appendix) const claimLevelRisks = []
if (nextNote.length > 500) { submitRiskWarnings.value.forEach((risk, index) => {
toast('附加说明最多 500 字,请精简风险原因后再继续提交。') const reason = String(riskOverrideReasons[risk.id] || '').trim()
return 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 riskOverrideBusy.value = true
try { try {
await updateExpenseClaim(request.value.claimId, { await Promise.all(
reason: nextNote [...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 riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true submitConfirmDialogOpen.value = true
toast('违规提交原因已写入附加说明。') toast('异常说明已保存,可继续提交审批。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) { } 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 { } finally {
riskOverrideBusy.value = false riskOverrideBusy.value = false
} }
@@ -1809,6 +1940,10 @@ export default {
} }
populateExpenseEditor(item) populateExpenseEditor(item)
void nextTick(() => {
const textarea = document.querySelector('.risk-note-editor-textarea')
resizeExpenseNoteInput({ target: textarea })
})
} }
function validateExpenseEditor() { function validateExpenseEditor() {
@@ -2237,6 +2372,11 @@ export default {
return return
} }
if (submitRiskWarnings.value.length) {
openRiskOverrideDialog()
return
}
submitConfirmDialogOpen.value = true submitConfirmDialogOpen.value = true
} }
@@ -2540,7 +2680,7 @@ export default {
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog, closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog, closeSmartEntryUploadDialog, closeRiskOverrideDialog, closeSmartEntryUploadDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest, closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmPayRequest, confirmRiskOverrideReasons, confirmSmartEntryUpload, confirmPayRequest, confirmRiskOverrideReasons, confirmStandardAdjustment, confirmSmartEntryUpload,
chooseSmartEntryFile, clearSmartEntryFile, chooseSmartEntryFile, clearSmartEntryFile,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion, currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning, currentSubmitRiskWarning,
@@ -2562,7 +2702,7 @@ export default {
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem, payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta, hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle, resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition, resetDetailNote, resizeExpenseNoteInput, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
resolveRiskCardDomId, isHighlightedRiskCard, resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel, returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
@@ -2574,7 +2714,7 @@ export default {
showAiAdvicePanel, showApplicationLeaderOpinion, showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showStageRiskAdvice, showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy, showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings, submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit 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 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 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 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) { export function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0 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) { export function formatCurrency(value) {
return new Intl.NumberFormat('zh-CN', { return new Intl.NumberFormat('zh-CN', {
style: 'currency', style: 'currency',
@@ -395,6 +408,60 @@ export function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, reque
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间' 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()) { export function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other') const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType }) 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 invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim() const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
const attachments = invoiceId ? [attachmentName || invoiceId] : [] 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 amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
const reimbursableAmountDisplay = reimbursableAmount > 0 ? formatCurrency(reimbursableAmount) : '待补充'
const riskText = String(source?.riskText || '').trim() const riskText = String(source?.riskText || '').trim()
const filledAt = formatExpenseFilledTime( const filledAt = formatExpenseFilledTime(
source?.filledAt source?.filledAt
@@ -424,6 +497,15 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
itemLocation, itemLocation,
itemNote, itemNote,
itemAmount, itemAmount,
originalItemAmount,
originalAmountDisplay: originalItemAmount > 0 ? formatCurrency(originalItemAmount) : amountDisplay,
reimbursableAmount,
reimbursableAmountDisplay,
employeeAbsorbedAmount,
employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatCurrency(employeeAbsorbedAmount) : '',
hasStandardAdjustment,
standardAdjustmentAccepted: Boolean(standardAdjustment),
standardAdjustmentMessage: standardAdjustment?.message || '',
invoiceId, invoiceId,
isSystemGenerated, isSystemGenerated,
time: itemDate || '待补充', time: itemDate || '待补充',

View File

@@ -453,6 +453,7 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
summary: normalizeText(analysis?.summary), summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'], ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
suggestion: buildCardSuggestion(analysis, insight), suggestion: buildCardSuggestion(analysis, insight),
source: 'attachment_analysis',
itemType: normalizeText(item?.itemType), itemType: normalizeText(item?.itemType),
documentType: normalizeText(insight?.documentTypeLabel), documentType: normalizeText(insight?.documentTypeLabel),
visibility_scope: 'submitter', visibility_scope: 'submitter',
@@ -645,6 +646,7 @@ export function buildAttachmentRiskCards({
summary, summary,
ruleBasis, ruleBasis,
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }), suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
source,
risk_domain: flag.risk_domain || flag.riskDomain, risk_domain: flag.risk_domain || flag.riskDomain,
visibility_scope: flag.visibility_scope || flag.visibilityScope, visibility_scope: flag.visibility_scope || flag.visibilityScope,
actionability: flag.actionability 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 '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。' return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
} }
if (hasHighRiskWarnings) { if (hasHighRiskWarnings) {
return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。' return '系统自动检测存在风险提示,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
} }
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。' return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
} }

View File

@@ -17,6 +17,7 @@ import {
import { import {
buildExpenseItemViewModel, buildExpenseItemViewModel,
buildDraftBlockingIssues, buildDraftBlockingIssues,
buildStandardAdjustmentMap,
isApplicationDocumentRequest isApplicationDocumentRequest
} from '../src/views/scripts/travelRequestDetailExpenseModel.js' } from '../src/views/scripts/travelRequestDetailExpenseModel.js'
import { 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', () => { test('expense detail table has per-item risk explanation column', () => {
assert.match(detailViewTemplate, /<th class="col-risk-note">异常说明<\/th>/) assert.match(detailViewTemplate, /<th class="col-risk-note">异常说明<\/th>/)
assert.match(detailViewTemplate, /v-model="expenseEditor\.itemNote"/) 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(detailViewTemplate, /hasExpenseRiskOrAbnormal\(item\)[\s\S]*待补充异常说明/)
assert.match(detailViewScript, /itemNote: ''/) assert.match(detailViewScript, /itemNote: ''/)
assert.match(detailViewScript, /expenseEditor\.itemNote = item\.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\(\)/) 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', () => { test('expense item upload remains limited to one receipt per detail row', () => {
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/) assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
assert.doesNotMatch( 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\)/) 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, /:open="riskOverrideDialogOpen"/)
assert.match(detailViewTemplate, /重大风险/) assert.match(detailViewTemplate, /异常说明/)
assert.match(detailViewTemplate, /按职级标准重算/)
assert.match(detailViewTemplate, /保存说明并继续提交/)
assert.match(detailViewTemplate, /goToPreviousSubmitRisk/) assert.match(detailViewTemplate, /goToPreviousSubmitRisk/)
assert.match(detailViewTemplate, /goToNextSubmitRisk/) assert.match(detailViewTemplate, /goToNextSubmitRisk/)
assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/) assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/)
assert.match(detailViewScript, /const submitRiskWarnings = computed/) assert.match(detailViewScript, /const submitRiskWarnings = computed/)
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit') const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest') const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
assert.doesNotMatch(handleSubmit, /openRiskOverrideDialog/) assert.match(handleSubmit, /submitRiskWarnings\.value\.length[\s\S]*openRiskOverrideDialog\(\)/)
assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/) assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/)
assert.doesNotMatch(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/)
assert.match(detailViewScript, /function confirmRiskOverrideReasons\(\)/) assert.match(detailViewScript, /function confirmRiskOverrideReasons\(\)/)
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId,\s*\{\s*reason: nextNote/s) assert.match(detailViewScript, /updateExpenseClaimItem\(request\.value\.claimId, itemId,[\s\S]*item_note: nextNote/s)
assert.match(detailViewScript, /超标说明:\$\{tags\}/) 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, /异常说明/) assert.match(detailViewTemplate, /异常说明/)
}) })