diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py
index f238e34..41c3acd 100644
--- a/server/src/app/api/v1/endpoints/reimbursements.py
+++ b/server/src/app/api/v1/endpoints/reimbursements.py
@@ -20,6 +20,7 @@ from app.schemas.reimbursement import (
ExpenseClaimItemUpdate,
ExpenseClaimRead,
ExpenseClaimReturnPayload,
+ ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate,
ReimbursementCreate,
ReimbursementRead,
@@ -233,6 +234,43 @@ def update_expense_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(
"/claims/{claim_id}/items/{item_id}",
response_model=ExpenseClaimRead,
diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py
index 5189e54..2be2513 100644
--- a/server/src/app/schemas/reimbursement.py
+++ b/server/src/app/schemas/reimbursement.py
@@ -121,6 +121,19 @@ class ExpenseClaimUpdate(BaseModel):
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):
model_config = ConfigDict(from_attributes=True)
diff --git a/server/src/app/services/expense_claim_constants.py b/server/src/app/services/expense_claim_constants.py
index 506fb0f..4105327 100644
--- a/server/src/app/services/expense_claim_constants.py
+++ b/server/src/app/services/expense_claim_constants.py
@@ -26,6 +26,7 @@ EXPENSE_TYPE_LABELS = {
}
MAX_DRAFT_CLAIMS_PER_USER = 3
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
+STANDARD_ADJUSTMENT_RISK_SOURCE = "reimbursement_standard_adjustment"
SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"}
OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"}
TRAVEL_DETAIL_ITEM_TYPES = {
diff --git a/server/src/app/services/expense_claim_item_sync.py b/server/src/app/services/expense_claim_item_sync.py
index 9d6c559..a2dc9e2 100644
--- a/server/src/app/services/expense_claim_item_sync.py
+++ b/server/src/app/services/expense_claim_item_sync.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from datetime import UTC, date, datetime, timedelta
-from decimal import Decimal
+from decimal import Decimal, InvalidOperation
from types import SimpleNamespace
from typing import Any
@@ -24,6 +24,7 @@ from app.services.expense_claim_constants import (
DOCUMENT_FACT_ITEM_TYPES,
LOCATION_REQUIRED_EXPENSE_TYPES,
OPTIONAL_ATTACHMENT_ITEM_TYPES,
+ STANDARD_ADJUSTMENT_RISK_SOURCE,
SYSTEM_GENERATED_ITEM_TYPES,
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
@@ -329,6 +330,45 @@ class ExpenseClaimItemSyncMixin:
return destination
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:
self._sync_travel_allowance_item(claim)
if not claim.items:
@@ -346,7 +386,11 @@ class ExpenseClaimItemSyncMixin:
),
)
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.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip())
diff --git a/server/src/app/services/expense_claim_policy_review.py b/server/src/app/services/expense_claim_policy_review.py
index b06c0d6..d2c346c 100644
--- a/server/src/app/services/expense_claim_policy_review.py
+++ b/server/src/app/services/expense_claim_policy_review.py
@@ -72,7 +72,11 @@ class ExpenseClaimPolicyReviewMixin:
limit_config=item_limit,
reason_text="\n".join(
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
),
)
@@ -333,7 +337,12 @@ class ExpenseClaimPolicyReviewMixin:
f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚,"
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)
if has_standard_exception or item_has_exception:
flags.append(
@@ -368,7 +377,12 @@ class ExpenseClaimPolicyReviewMixin:
if allowed_level is None or class_level <= allowed_level:
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)
message = f"{band_label} 职级当前默认不可报销 {class_label}。"
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()]
for item in claim.items:
parts.append(str(item.item_reason or "").strip())
+ parts.append(str(item.item_note or "").strip())
parts.append(str(item.item_location or "").strip())
return "\n".join(part for part in parts if part)
diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py
index f24d425..262b381 100644
--- a/server/src/app/services/expense_claims.py
+++ b/server/src/app/services/expense_claims.py
@@ -27,6 +27,7 @@ from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import (
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
+ ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate,
TravelReimbursementCalculatorRequest,
)
@@ -109,6 +110,7 @@ from app.services.expense_claim_constants import (
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
+ STANDARD_ADJUSTMENT_RISK_SOURCE,
)
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
from app.services.expense_amounts import (
@@ -290,6 +292,126 @@ class ExpenseClaimService(
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(
self,
*,
@@ -758,6 +880,3 @@ class ExpenseClaimService(
-
-
-
diff --git a/server/src/app/services/ontology_field_registry.py b/server/src/app/services/ontology_field_registry.py
index c151a12..dd355be 100644
--- a/server/src/app/services/ontology_field_registry.py
+++ b/server/src/app/services/ontology_field_registry.py
@@ -78,6 +78,9 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
"application_policy_estimate",
"application_rule_name",
"application_rule_version",
+ "original_amount",
+ "reimbursable_amount",
+ "employee_absorbed_amount",
}
)
diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py
index 4cebd93..15aff73 100644
--- a/server/tests/test_expense_claim_service.py
+++ b/server/tests/test_expense_claim_service.py
@@ -19,7 +19,12 @@ from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.schemas.ontology import OntologyParseRequest
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.budget import BudgetService
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
+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:
current_user = CurrentUserContext(
username="emp-pre-review@example.com",
diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css
index 816c1e3..66c08eb 100644
--- a/web/src/assets/styles/views/travel-request-detail-view.css
+++ b/web/src/assets/styles/views/travel-request-detail-view.css
@@ -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;
diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js
index 641cbef..ff2cc8c 100644
--- a/web/src/composables/useRequests.js
+++ b/web/src/composables/useRequests.js
@@ -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
diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js
index 496b9a1..bc96563 100644
--- a/web/src/services/reimbursements.js
+++ b/web/src/services/reimbursements.js
@@ -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',
diff --git a/web/src/views/TravelRequestDetailView.vue b/web/src/views/TravelRequestDetailView.vue
index e146f19..dab76b8 100644
--- a/web/src/views/TravelRequestDetailView.vue
+++ b/web/src/views/TravelRequestDetailView.vue
@@ -273,7 +273,12 @@
- {{ item.amount }}
+