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 @@ @@ -370,9 +375,11 @@
用于说明改签、绕行、超标、票据异常等情况
@@ -764,7 +771,7 @@
{{ isApplicationDocument ? '预计金额' : '报销金额' }} - {{ request.amountDisplay || expenseTotal }} + {{ submitConfirmAmountDisplay }}
费用明细 @@ -774,20 +781,20 @@ -
+
+ 不填写说明时,系统会按职级最高报销标准重算金额。 +
diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js index 30d15f2..0a7527b 100644 --- a/web/src/views/scripts/TravelRequestDetailView.js +++ b/web/src/views/scripts/TravelRequestDetailView.js @@ -10,7 +10,9 @@ import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDele import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue' import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue' import { + acceptExpenseClaimStandardAdjustment, approveExpenseClaim, + calculateTravelReimbursement, createExpenseClaimItem, deleteExpenseClaimItem, deleteExpenseClaimItemAttachment, @@ -88,6 +90,13 @@ import { resolveSubmitConfirmDescription, resolveSubmitConfirmText } from './travelRequestDetailSubmitModel.js' +import { + buildCurrentStandardAdjustmentMap, + buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel, + filterSubmitterStandardAdjustedRiskCards as filterSubmitterStandardAdjustedRiskCardsModel, + isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel, + resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel +} from './travelRequestDetailStandardAdjustment.js' import { buildEmployeeProfileAdviceItems, buildTravelReceiptMaterialPrompts @@ -994,9 +1003,16 @@ export default { } const expenseTotal = computed(() => { - const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0) + const total = expenseItems.value.reduce((sum, item) => { + const adjustedAmount = Number(item.reimbursableAmount) + const originalAmount = Number(item.itemAmount || 0) + return sum + (Number.isFinite(adjustedAmount) ? adjustedAmount : originalAmount) + }, 0) return formatCurrency(total) }) + const submitConfirmAmountDisplay = computed(() => + isApplicationDocument.value ? (request.value.amountDisplay || expenseTotal.value) : expenseTotal.value + ) const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value)) const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value)) @@ -1155,6 +1171,57 @@ export default { return requestFlags } + function resolveCurrentStandardAdjustmentMap() { + return buildCurrentStandardAdjustmentMap(request.value, resolveClaimRiskFlags()) + } + + function resolveExpenseItemForRiskCard(card) { + return resolveExpenseItemForRiskCardModel(card, expenseItems.value) + } + + function filterSubmitterStandardAdjustedRiskCards(cards, businessStage) { + return filterSubmitterStandardAdjustedRiskCardsModel({ + cards, + businessStage, + isCurrentApplicant: isCurrentApplicant.value, + expenseItems: expenseItems.value, + standardAdjustmentMap: resolveCurrentStandardAdjustmentMap() + }) + } + + function isRiskCardMissingExpenseNote(card) { + return isRiskCardMissingExpenseNoteModel(card, expenseItems.value) + } + + async function buildStandardAdjustmentPayload() { + return buildStandardAdjustmentPayloadModel({ + warnings: submitRiskWarnings.value, + expenseItems: expenseItems.value, + request: request.value, + calculateTravelReimbursement + }) + } + + function applyStandardAdjustmentResponse(payload = {}) { + const flags = Array.isArray(payload?.risk_flags_json) + ? payload.risk_flags_json + : Array.isArray(payload?.riskFlags) + ? payload.riskFlags + : resolveClaimRiskFlags() + riskFlagPreviewSnapshot.value = { + claimId: request.value.claimId, + riskFlags: flags + } + const sourceItems = Array.isArray(payload?.items) && payload.items.length + ? payload.items + : expenseItems.value + expenseItems.value = rebuildExpenseItems(sourceItems, { + ...request.value, + riskFlags: flags, + risk_flags_json: flags + }) + } + function resolveAttachmentDisplayName(item) { const metadata = resolveAttachmentMeta(item) return String(metadata?.file_name || item.attachmentHint || '').trim() @@ -1530,7 +1597,7 @@ export default { : [] const scopedRiskCards = [ ...(hasActionableRiskCards ? [] : summaryRiskCards), - ...directRiskCards + ...filterSubmitterStandardAdjustedRiskCards(directRiskCards, currentBusinessStage) ] const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value) @@ -1652,7 +1719,8 @@ export default { const submitRiskWarnings = computed(() => aiAdvice.value.riskCards - .filter((card) => normalizeRiskTone(card?.tone) === 'high') + .filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))) + .filter((card) => isRiskCardMissingExpenseNote(card)) .map((card, index) => ({ ...card, id: String(card.id || `submit-risk-${index}`), @@ -1663,7 +1731,6 @@ export default { const riskOverrideIndexLabel = computed(() => submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : '' ) - const hasRiskOverrideExplanation = computed(() => detailNoteTags.value.includes('#high_risk')) function resetDetailNote() { detailNoteEditor.value = detailNoteSource.value @@ -1722,6 +1789,18 @@ export default { riskOverrideDialogOpen.value = false } + function resizeExpenseNoteInput(event) { + const target = event?.target + if (!target || typeof window === 'undefined') { + return + } + const style = window.getComputedStyle(target) + const lineHeight = Number.parseFloat(style.lineHeight) || 18 + const maxHeight = lineHeight * 3 + 18 + target.style.height = 'auto' + target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px` + } + function goToPreviousSubmitRisk() { if (!submitRiskWarnings.value.length) { return @@ -1737,17 +1816,6 @@ export default { riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length } - function buildRiskOverrideAppendix() { - return submitRiskWarnings.value - .map((risk, index) => { - const reason = String(riskOverrideReasons[risk.id] || '').trim() - const tags = resolveRiskTags(risk).join(' ') - const title = String(risk.title || risk.label || '重大风险').trim() - return `超标说明:${tags} 第${index + 1}条 ${title}:${reason}` - }) - .join('\n') - } - function mergeDetailNoteWithRiskOverride(appendix) { const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n') @@ -1760,28 +1828,91 @@ export default { const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim()) if (missingIndex >= 0) { riskOverrideIndex.value = missingIndex - toast('请为每一条重大风险填写违规提交原因。') + toast('请为每一条风险填写异常说明。') return } - const appendix = buildRiskOverrideAppendix() - const nextNote = mergeDetailNoteWithRiskOverride(appendix) - if (nextNote.length > 500) { - toast('附加说明最多 500 字,请精简风险原因后再继续提交。') - return - } + const itemNoteGroups = new Map() + const claimLevelRisks = [] + submitRiskWarnings.value.forEach((risk, index) => { + const reason = String(riskOverrideReasons[risk.id] || '').trim() + const item = resolveExpenseItemForRiskCard(risk) + if (item?.id) { + const currentGroup = itemNoteGroups.get(item.id) || { item, reasons: [] } + currentGroup.reasons.push(reason) + itemNoteGroups.set(item.id, currentGroup) + } else { + const title = String(risk.title || risk.label || '风险').trim() + claimLevelRisks.push(`异常说明:第${index + 1}条 ${title}:${reason}`) + } + }) riskOverrideBusy.value = true try { - await updateExpenseClaim(request.value.claimId, { - reason: nextNote + await Promise.all( + [...itemNoteGroups.entries()].map(([itemId, group]) => { + const existingNote = String(group.item?.itemNote || '').trim() + const nextNote = [ + existingNote, + ...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason)) + ].filter(Boolean).join('\n') + return updateExpenseClaimItem(request.value.claimId, itemId, { + item_note: nextNote + }) + }) + ) + itemNoteGroups.forEach((group, itemId) => { + const existingNote = String(group.item?.itemNote || '').trim() + const nextNote = [ + existingNote, + ...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason)) + ].filter(Boolean).join('\n') + applyLocalExpenseItemPatch(itemId, { + itemNote: nextNote + }) }) - detailNoteEditor.value = nextNote + if (claimLevelRisks.length) { + const appendix = claimLevelRisks.join('\n') + const nextNote = mergeDetailNoteWithRiskOverride(appendix) + if (nextNote.length > 500) { + toast('附加说明最多 500 字,请精简风险原因后再继续提交。') + return + } + await updateExpenseClaim(request.value.claimId, { + reason: nextNote + }) + detailNoteEditor.value = nextNote + } riskOverrideDialogOpen.value = false submitConfirmDialogOpen.value = true - toast('违规提交原因已写入附加说明。') + toast('异常说明已保存,可继续提交审批。') + emit('request-updated', { claimId: request.value.claimId }) } catch (error) { - toast(error?.message || '风险原因保存失败,请稍后重试。') + toast(error?.message || '异常说明保存失败,请稍后重试。') + } finally { + riskOverrideBusy.value = false + } + } + + async function confirmStandardAdjustment() { + if (riskOverrideBusy.value) { + return + } + riskOverrideBusy.value = true + try { + const payload = await buildStandardAdjustmentPayload() + if (!payload.risks.length) { + toast('当前风险暂未匹配到可重算的费用明细,请先补充异常说明。') + return + } + const response = await acceptExpenseClaimStandardAdjustment(request.value.claimId, payload) + applyStandardAdjustmentResponse(response) + riskOverrideDialogOpen.value = false + submitConfirmDialogOpen.value = true + toast('已按职级最高报销标准重算实际报销金额。') + emit('request-updated', { claimId: request.value.claimId }) + } catch (error) { + toast(error?.message || '按职级标准重算失败,请稍后重试。') } finally { riskOverrideBusy.value = false } @@ -1809,6 +1940,10 @@ export default { } populateExpenseEditor(item) + void nextTick(() => { + const textarea = document.querySelector('.risk-note-editor-textarea') + resizeExpenseNoteInput({ target: textarea }) + }) } function validateExpenseEditor() { @@ -2237,6 +2372,11 @@ export default { return } + if (submitRiskWarnings.value.length) { + openRiskOverrideDialog() + return + } + submitConfirmDialogOpen.value = true } @@ -2540,7 +2680,7 @@ export default { closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog, closeRiskOverrideDialog, closeSmartEntryUploadDialog, closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest, - confirmPayRequest, confirmRiskOverrideReasons, confirmSmartEntryUpload, + confirmPayRequest, confirmRiskOverrideReasons, confirmStandardAdjustment, confirmSmartEntryUpload, chooseSmartEntryFile, clearSmartEntryFile, currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion, currentSubmitRiskWarning, @@ -2562,7 +2702,7 @@ export default { payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem, hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta, resolveExpenseRiskIndicatorTitle, - resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition, + resetDetailNote, resizeExpenseNoteInput, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues, resolveRiskCardDomId, isHighlightedRiskCard, returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel, @@ -2574,7 +2714,7 @@ export default { showAiAdvicePanel, showApplicationLeaderOpinion, showBudgetAnalysis, showStageRiskAdvice, showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy, - submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings, + submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings, triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit } } diff --git a/web/src/views/scripts/travelRequestDetailExpenseModel.js b/web/src/views/scripts/travelRequestDetailExpenseModel.js index 3b102dd..13959af 100644 --- a/web/src/views/scripts/travelRequestDetailExpenseModel.js +++ b/web/src/views/scripts/travelRequestDetailExpenseModel.js @@ -32,11 +32,24 @@ export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flig export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket']) export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket']) export const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/ +export const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment' export function parseCurrency(value) { return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0 } +function parseOptionalCurrency(value) { + if (value === null || value === undefined || String(value).trim() === '') { + return null + } + const normalized = String(value).replace(/[^\d.]/g, '') + if (!normalized) { + return null + } + const amount = Number.parseFloat(normalized) + return Number.isFinite(amount) && amount >= 0 ? amount : null +} + export function formatCurrency(value) { return new Intl.NumberFormat('zh-CN', { style: 'currency', @@ -395,6 +408,60 @@ export function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, reque return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间' } +export function buildStandardAdjustmentMap(requestModel = {}) { + const flags = Array.isArray(requestModel?.riskFlags) + ? requestModel.riskFlags + : Array.isArray(requestModel?.risk_flags_json) + ? requestModel.risk_flags_json + : [] + const adjustmentMap = new Map() + + flags.forEach((flag) => { + if (!flag || typeof flag !== 'object') { + return + } + if (String(flag.source || '').trim() !== STANDARD_ADJUSTMENT_RISK_SOURCE) { + return + } + const itemId = String(flag.item_id || flag.itemId || '').trim() + if (!itemId) { + return + } + const originalAmount = parseOptionalCurrency(flag.original_amount ?? flag.originalAmount) + const reimbursableAmount = parseOptionalCurrency(flag.reimbursable_amount ?? flag.reimbursableAmount) + if (reimbursableAmount === null) { + return + } + const employeeAbsorbedAmount = parseOptionalCurrency(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0 + adjustmentMap.set(itemId, { + originalAmount, + reimbursableAmount, + employeeAbsorbedAmount, + message: String(flag.message || flag.summary || '').trim() + }) + }) + + return adjustmentMap +} + +function resolveSourceStandardAdjustment(source, id, requestModel) { + const requestAdjustment = buildStandardAdjustmentMap(requestModel).get(id) + if (requestAdjustment) { + return requestAdjustment + } + + const reimbursableAmount = parseOptionalCurrency(source?.reimbursableAmount ?? source?.reimbursable_amount) + if (reimbursableAmount === null) { + return null + } + return { + originalAmount: parseOptionalCurrency(source?.originalItemAmount ?? source?.original_item_amount ?? source?.originalAmount ?? source?.original_amount), + reimbursableAmount, + employeeAbsorbedAmount: parseOptionalCurrency(source?.employeeAbsorbedAmount ?? source?.employee_absorbed_amount) || 0, + message: String(source?.standardAdjustmentMessage || source?.standard_adjustment_message || '').trim() + } +} + export function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) { const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other') const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType }) @@ -407,7 +474,13 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim() const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim() const attachments = invoiceId ? [attachmentName || invoiceId] : [] + const standardAdjustment = resolveSourceStandardAdjustment(source, id, requestModel) + const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount + const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount + const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0) + const hasStandardAdjustment = reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充' + const reimbursableAmountDisplay = reimbursableAmount > 0 ? formatCurrency(reimbursableAmount) : '待补充' const riskText = String(source?.riskText || '').trim() const filledAt = formatExpenseFilledTime( source?.filledAt @@ -424,6 +497,15 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim itemLocation, itemNote, itemAmount, + originalItemAmount, + originalAmountDisplay: originalItemAmount > 0 ? formatCurrency(originalItemAmount) : amountDisplay, + reimbursableAmount, + reimbursableAmountDisplay, + employeeAbsorbedAmount, + employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatCurrency(employeeAbsorbedAmount) : '', + hasStandardAdjustment, + standardAdjustmentAccepted: Boolean(standardAdjustment), + standardAdjustmentMessage: standardAdjustment?.message || '', invoiceId, isSystemGenerated, time: itemDate || '待补充', diff --git a/web/src/views/scripts/travelRequestDetailInsights.js b/web/src/views/scripts/travelRequestDetailInsights.js index f514ace..6551e85 100644 --- a/web/src/views/scripts/travelRequestDetailInsights.js +++ b/web/src/views/scripts/travelRequestDetailInsights.js @@ -453,6 +453,7 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy summary: normalizeText(analysis?.summary), ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'], suggestion: buildCardSuggestion(analysis, insight), + source: 'attachment_analysis', itemType: normalizeText(item?.itemType), documentType: normalizeText(insight?.documentTypeLabel), visibility_scope: 'submitter', @@ -645,6 +646,7 @@ export function buildAttachmentRiskCards({ summary, ruleBasis, suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }), + source, risk_domain: flag.risk_domain || flag.riskDomain, visibility_scope: flag.visibility_scope || flag.visibilityScope, actionability: flag.actionability diff --git a/web/src/views/scripts/travelRequestDetailStandardAdjustment.js b/web/src/views/scripts/travelRequestDetailStandardAdjustment.js new file mode 100644 index 0000000..96b9c3b --- /dev/null +++ b/web/src/views/scripts/travelRequestDetailStandardAdjustment.js @@ -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 } +} diff --git a/web/src/views/scripts/travelRequestDetailSubmitModel.js b/web/src/views/scripts/travelRequestDetailSubmitModel.js index 4b8d004..5cc7329 100644 --- a/web/src/views/scripts/travelRequestDetailSubmitModel.js +++ b/web/src/views/scripts/travelRequestDetailSubmitModel.js @@ -20,7 +20,7 @@ export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHigh return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。' } if (hasHighRiskWarnings) { - return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。' + return '系统自动检测存在风险提示,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。' } return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。' } diff --git a/web/tests/travel-request-detail-risk-advice.test.mjs b/web/tests/travel-request-detail-risk-advice.test.mjs index 1194242..a2ca316 100644 --- a/web/tests/travel-request-detail-risk-advice.test.mjs +++ b/web/tests/travel-request-detail-risk-advice.test.mjs @@ -17,6 +17,7 @@ import { import { buildExpenseItemViewModel, buildDraftBlockingIssues, + buildStandardAdjustmentMap, isApplicationDocumentRequest } from '../src/views/scripts/travelRequestDetailExpenseModel.js' 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', () => { assert.match(detailViewTemplate, /异常说明<\/th>/) 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(detailViewScript, /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\(\)/) }) +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', () => { assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/) assert.doesNotMatch( diff --git a/web/tests/travel-request-detail-submit-confirm.test.mjs b/web/tests/travel-request-detail-submit-confirm.test.mjs index ffc3130..c77bfb4 100644 --- a/web/tests/travel-request-detail-submit-confirm.test.mjs +++ b/web/tests/travel-request-detail-submit-confirm.test.mjs @@ -63,21 +63,24 @@ test('detail submit opens a confirmation dialog before calling submit API', () = 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, /重大风险/) + assert.match(detailViewTemplate, /异常说明/) + assert.match(detailViewTemplate, /按职级标准重算/) + assert.match(detailViewTemplate, /保存说明并继续提交/) assert.match(detailViewTemplate, /goToPreviousSubmitRisk/) assert.match(detailViewTemplate, /goToNextSubmitRisk/) assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/) assert.match(detailViewScript, /const submitRiskWarnings = computed/) const handleSubmit = extractFunction(detailViewScript, 'handleSubmit') const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest') - assert.doesNotMatch(handleSubmit, /openRiskOverrideDialog/) + assert.match(handleSubmit, /submitRiskWarnings\.value\.length[\s\S]*openRiskOverrideDialog\(\)/) assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/) - assert.doesNotMatch(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/) assert.match(detailViewScript, /function confirmRiskOverrideReasons\(\)/) - assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId,\s*\{\s*reason: nextNote/s) - assert.match(detailViewScript, /超标说明:\$\{tags\}/) + assert.match(detailViewScript, /updateExpenseClaimItem\(request\.value\.claimId, itemId,[\s\S]*item_note: nextNote/s) + 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, /异常说明/) })