fix: handle risk explanation standard adjustment
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 claimLevelRisks = []
|
||||||
|
submitRiskWarnings.value.forEach((risk, index) => {
|
||||||
|
const reason = String(riskOverrideReasons[risk.id] || '').trim()
|
||||||
|
const item = resolveExpenseItemForRiskCard(risk)
|
||||||
|
if (item?.id) {
|
||||||
|
const currentGroup = itemNoteGroups.get(item.id) || { item, reasons: [] }
|
||||||
|
currentGroup.reasons.push(reason)
|
||||||
|
itemNoteGroups.set(item.id, currentGroup)
|
||||||
|
} else {
|
||||||
|
const title = String(risk.title || risk.label || '风险').trim()
|
||||||
|
claimLevelRisks.push(`异常说明:第${index + 1}条 ${title}:${reason}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
riskOverrideBusy.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
[...itemNoteGroups.entries()].map(([itemId, group]) => {
|
||||||
|
const existingNote = String(group.item?.itemNote || '').trim()
|
||||||
|
const nextNote = [
|
||||||
|
existingNote,
|
||||||
|
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
|
||||||
|
].filter(Boolean).join('\n')
|
||||||
|
return updateExpenseClaimItem(request.value.claimId, itemId, {
|
||||||
|
item_note: nextNote
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
itemNoteGroups.forEach((group, itemId) => {
|
||||||
|
const existingNote = String(group.item?.itemNote || '').trim()
|
||||||
|
const nextNote = [
|
||||||
|
existingNote,
|
||||||
|
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
|
||||||
|
].filter(Boolean).join('\n')
|
||||||
|
applyLocalExpenseItemPatch(itemId, {
|
||||||
|
itemNote: nextNote
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (claimLevelRisks.length) {
|
||||||
|
const appendix = claimLevelRisks.join('\n')
|
||||||
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
|
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
|
||||||
if (nextNote.length > 500) {
|
if (nextNote.length > 500) {
|
||||||
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
|
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
riskOverrideBusy.value = true
|
|
||||||
try {
|
|
||||||
await updateExpenseClaim(request.value.claimId, {
|
await updateExpenseClaim(request.value.claimId, {
|
||||||
reason: nextNote
|
reason: nextNote
|
||||||
})
|
})
|
||||||
detailNoteEditor.value = 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || '待补充',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
174
web/src/views/scripts/travelRequestDetailStandardAdjustment.js
Normal file
174
web/src/views/scripts/travelRequestDetailStandardAdjustment.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import {
|
||||||
|
STANDARD_ADJUSTMENT_RISK_SOURCE,
|
||||||
|
buildStandardAdjustmentMap
|
||||||
|
} from './travelRequestDetailExpenseModel.js'
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAmount(value) {
|
||||||
|
const amount = Number(value)
|
||||||
|
return Number.isFinite(amount) && amount > 0 ? amount : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCurrentStandardAdjustmentMap(request = {}, riskFlags = []) {
|
||||||
|
return buildStandardAdjustmentMap({
|
||||||
|
...request,
|
||||||
|
riskFlags,
|
||||||
|
risk_flags_json: riskFlags
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExpenseItemForRiskCard(card, expenseItems = []) {
|
||||||
|
const itemId = normalizeText(card?.itemId || card?.item_id)
|
||||||
|
const invoiceId = normalizeText(card?.invoiceId || card?.invoice_id)
|
||||||
|
const itemIndex = Number(card?.itemIndex || card?.item_index || 0)
|
||||||
|
|
||||||
|
return expenseItems.find((item) => normalizeText(item.id) === itemId)
|
||||||
|
|| expenseItems.find((item) => invoiceId && normalizeText(item.invoiceId) === invoiceId)
|
||||||
|
|| (itemIndex > 0 ? expenseItems[itemIndex - 1] : null)
|
||||||
|
|| null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasStandardAdjustmentForItem(item, standardAdjustmentMap = new Map()) {
|
||||||
|
const itemId = normalizeText(item?.id)
|
||||||
|
if (!itemId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return Boolean(item?.standardAdjustmentAccepted || standardAdjustmentMap.has(itemId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRiskCardMissingExpenseNote(card, expenseItems = []) {
|
||||||
|
const item = resolveExpenseItemForRiskCard(card, expenseItems)
|
||||||
|
if (!item) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !normalizeText(item.itemNote)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRiskCardCoveredByStandardAdjustment(card, expenseItems, standardAdjustmentMap) {
|
||||||
|
if (normalizeText(card?.source) === STANDARD_ADJUSTMENT_RISK_SOURCE) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return hasStandardAdjustmentForItem(
|
||||||
|
resolveExpenseItemForRiskCard(card, expenseItems),
|
||||||
|
standardAdjustmentMap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSubmitterStandardAdjustedRiskCards({
|
||||||
|
cards = [],
|
||||||
|
businessStage = 'reimbursement',
|
||||||
|
isCurrentApplicant = false,
|
||||||
|
expenseItems = [],
|
||||||
|
standardAdjustmentMap = new Map()
|
||||||
|
} = {}) {
|
||||||
|
if (businessStage !== 'reimbursement' || !isCurrentApplicant) {
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
return cards.filter((card) => !isRiskCardCoveredByStandardAdjustment(card, expenseItems, standardAdjustmentMap))
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRiskCardMoneyValues(card) {
|
||||||
|
const corpus = [
|
||||||
|
card?.risk,
|
||||||
|
card?.summary,
|
||||||
|
card?.suggestion,
|
||||||
|
card?.title,
|
||||||
|
...(Array.isArray(card?.ruleBasis) ? card.ruleBasis : [])
|
||||||
|
].map(normalizeText).filter(Boolean).join(' ')
|
||||||
|
return [...corpus.matchAll(/(?:¥|¥)?\s*(\d+(?:,\d{3})*(?:\.\d+)?)\s*元/g)]
|
||||||
|
.map((match) => Number(String(match[1] || '').replace(/,/g, '')))
|
||||||
|
.filter((amount) => Number.isFinite(amount) && amount > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParsedStandardAmount(card, item) {
|
||||||
|
const originalAmount = normalizeAmount(item?.itemAmount)
|
||||||
|
if (originalAmount <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const candidates = extractRiskCardMoneyValues(card)
|
||||||
|
.filter((amount) => amount > 0 && amount < originalAmount)
|
||||||
|
return candidates.length ? Math.max(...candidates) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRiskCardNightCount(card) {
|
||||||
|
const corpus = [card?.risk, card?.summary, card?.suggestion, card?.title]
|
||||||
|
.map(normalizeText)
|
||||||
|
.join(' ')
|
||||||
|
const match = corpus.match(/(\d+)\s*(?:晚|夜|间夜)/)
|
||||||
|
return match ? Math.max(1, Number(match[1]) || 1) : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTravelStandardAmount({ card, item, request, calculateTravelReimbursement }) {
|
||||||
|
const itemType = normalizeText(item?.itemType)
|
||||||
|
if (!['hotel_ticket', 'hotel'].includes(itemType)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const location = normalizeText(item?.itemLocation || request?.location || request?.sceneTarget)
|
||||||
|
if (!location || typeof calculateTravelReimbursement !== 'function') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const grade = normalizeText(request?.employeeGrade || request?.profileGrade)
|
||||||
|
const days = extractRiskCardNightCount(card)
|
||||||
|
try {
|
||||||
|
const result = await calculateTravelReimbursement({ days, location, grade })
|
||||||
|
const hotelAmount = Number(result?.hotel_amount ?? result?.hotelAmount)
|
||||||
|
return Number.isFinite(hotelAmount) && hotelAmount > 0 ? hotelAmount : null
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRiskStandardReimbursableAmount({ card, item, request, calculateTravelReimbursement }) {
|
||||||
|
const originalAmount = normalizeAmount(item?.itemAmount)
|
||||||
|
if (originalAmount <= 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const parsedAmount = resolveParsedStandardAmount(card, item)
|
||||||
|
if (parsedAmount !== null) {
|
||||||
|
return Math.min(originalAmount, parsedAmount)
|
||||||
|
}
|
||||||
|
const travelStandardAmount = await resolveTravelStandardAmount({
|
||||||
|
card,
|
||||||
|
item,
|
||||||
|
request,
|
||||||
|
calculateTravelReimbursement
|
||||||
|
})
|
||||||
|
if (travelStandardAmount !== null) {
|
||||||
|
return Math.min(originalAmount, travelStandardAmount)
|
||||||
|
}
|
||||||
|
return originalAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildStandardAdjustmentPayload({
|
||||||
|
warnings = [],
|
||||||
|
expenseItems = [],
|
||||||
|
request = {},
|
||||||
|
calculateTravelReimbursement
|
||||||
|
} = {}) {
|
||||||
|
const risks = []
|
||||||
|
for (const warning of warnings) {
|
||||||
|
const item = resolveExpenseItemForRiskCard(warning, expenseItems)
|
||||||
|
if (!item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const originalAmount = normalizeAmount(item.itemAmount)
|
||||||
|
const reimbursableAmount = await resolveRiskStandardReimbursableAmount({
|
||||||
|
card: warning,
|
||||||
|
item,
|
||||||
|
request,
|
||||||
|
calculateTravelReimbursement
|
||||||
|
})
|
||||||
|
risks.push({
|
||||||
|
risk_id: warning.id,
|
||||||
|
item_id: item.id,
|
||||||
|
title: warning.title,
|
||||||
|
risk: warning.risk || warning.summary,
|
||||||
|
original_amount: originalAmount,
|
||||||
|
reimbursable_amount: reimbursableAmount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { risks }
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHigh
|
|||||||
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
|
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
|
||||||
}
|
}
|
||||||
if (hasHighRiskWarnings) {
|
if (hasHighRiskWarnings) {
|
||||||
return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
|
return '系统自动检测存在风险提示,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
|
||||||
}
|
}
|
||||||
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
|
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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, /异常说明/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user