feat: 报销预审会话状态管理与工作台交互增强
- 新增差旅报销会话状态管理与对话模型重构 - 增强风险观测服务与运行时聊天上下文作用域 - 优化工作台图标资源、助理意图识别与摘要工具 - 完善报销创建视图样式与差旅详情页标准调整交互 - 补充风险观测、运行时聊天与报销端点测试覆盖
This commit is contained in:
@@ -48,6 +48,7 @@ from app.services.expense_claim_attachment_analysis import ExpenseClaimAttachmen
|
||||
from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin
|
||||
from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin
|
||||
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
|
||||
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
|
||||
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
|
||||
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
|
||||
@@ -278,6 +279,9 @@ class ExpenseClaimService(
|
||||
if payload.reason is not None:
|
||||
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
|
||||
|
||||
if not self._is_expense_application_claim(claim):
|
||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
@@ -306,6 +310,146 @@ class ExpenseClaimService(
|
||||
normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01"))
|
||||
return f"{normalized:.2f}"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_standard_adjustment_days(value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value if 1 <= value <= 365 else None
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
match = re.search(r"\d{1,3}", text)
|
||||
if not match:
|
||||
return None
|
||||
days = int(match.group(0))
|
||||
return days if 1 <= days <= 365 else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_standard_adjustment_text(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text or text in {"-", "N/A", "n/a"}:
|
||||
return ""
|
||||
if text in {"待补充", "未知", "暂无", "非必填"}:
|
||||
return ""
|
||||
return text
|
||||
|
||||
def _iter_standard_adjustment_application_details(self, claim: ExpenseClaim) -> list[dict[str, Any]]:
|
||||
details: list[dict[str, Any]] = []
|
||||
for flag in list(claim.risk_flags_json or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
detail = flag.get("application_detail") or flag.get("applicationDetail")
|
||||
if isinstance(detail, dict):
|
||||
details.append(detail)
|
||||
related = flag.get("related_application") or flag.get("relatedApplication")
|
||||
if isinstance(related, dict):
|
||||
details.append(related)
|
||||
return details
|
||||
|
||||
def _resolve_standard_adjustment_days(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
item: ExpenseClaimItem,
|
||||
entry: Any,
|
||||
) -> int:
|
||||
direct_days = self._normalize_standard_adjustment_days(getattr(entry, "application_days", None))
|
||||
if direct_days is not None:
|
||||
return direct_days
|
||||
|
||||
for detail in self._iter_standard_adjustment_application_details(claim):
|
||||
for key in ("application_days", "applicationDays", "days"):
|
||||
detail_days = self._normalize_standard_adjustment_days(detail.get(key))
|
||||
if detail_days is not None:
|
||||
return detail_days
|
||||
|
||||
candidates = [
|
||||
getattr(entry, "risk", None),
|
||||
getattr(entry, "title", None),
|
||||
item.item_reason,
|
||||
claim.reason,
|
||||
]
|
||||
for text in candidates:
|
||||
match = re.search(r"(\d{1,3})\s*(?:天|晚|夜)", str(text or ""))
|
||||
if match:
|
||||
days = self._normalize_standard_adjustment_days(match.group(1))
|
||||
if days is not None:
|
||||
return days
|
||||
return 1
|
||||
|
||||
def _resolve_standard_adjustment_location(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
item: ExpenseClaimItem,
|
||||
) -> str:
|
||||
for value in (item.item_location, claim.location):
|
||||
text = self._normalize_standard_adjustment_text(value)
|
||||
if text:
|
||||
return text
|
||||
|
||||
for detail in self._iter_standard_adjustment_application_details(claim):
|
||||
for key in ("application_location", "applicationLocation", "location", "city"):
|
||||
text = self._normalize_standard_adjustment_text(detail.get(key))
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
def _resolve_policy_standard_reimbursable_amount(
|
||||
self,
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
item: ExpenseClaimItem,
|
||||
entry: Any,
|
||||
current_user: CurrentUserContext,
|
||||
) -> Decimal | None:
|
||||
item_type = str(item.item_type or "").strip().lower()
|
||||
if item_type not in {"hotel", "hotel_ticket"}:
|
||||
return None
|
||||
|
||||
location = self._resolve_standard_adjustment_location(claim, item)
|
||||
grade = str(claim.employee_grade or current_user.grade or "").strip()
|
||||
if not location or not grade:
|
||||
return None
|
||||
|
||||
try:
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
|
||||
result = TravelReimbursementCalculatorService(self.db).calculate(
|
||||
TravelReimbursementCalculatorRequest(
|
||||
days=self._resolve_standard_adjustment_days(claim, item, entry),
|
||||
location=location,
|
||||
grade=grade,
|
||||
),
|
||||
current_user,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return self._normalize_standard_adjustment_amount(result.hotel_amount)
|
||||
|
||||
def _resolve_standard_adjustment_reimbursable_amount(
|
||||
self,
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
item: ExpenseClaimItem,
|
||||
entry: Any,
|
||||
original_amount: Decimal,
|
||||
current_user: CurrentUserContext,
|
||||
) -> Decimal:
|
||||
policy_amount = self._resolve_policy_standard_reimbursable_amount(
|
||||
claim=claim,
|
||||
item=item,
|
||||
entry=entry,
|
||||
current_user=current_user,
|
||||
)
|
||||
if policy_amount is not None:
|
||||
return min(max(policy_amount, Decimal("0.00")), original_amount)
|
||||
|
||||
entry_amount = self._normalize_standard_adjustment_amount(entry.reimbursable_amount)
|
||||
if entry_amount is not None:
|
||||
return min(max(entry_amount, Decimal("0.00")), original_amount)
|
||||
return original_amount
|
||||
|
||||
def accept_standard_adjustment(
|
||||
self,
|
||||
*,
|
||||
@@ -340,11 +484,13 @@ class ExpenseClaimService(
|
||||
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 = self._resolve_standard_adjustment_reimbursable_amount(
|
||||
claim=claim,
|
||||
item=item,
|
||||
entry=entry,
|
||||
original_amount=original_amount,
|
||||
current_user=current_user,
|
||||
)
|
||||
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()
|
||||
@@ -456,6 +602,7 @@ class ExpenseClaimService(
|
||||
|
||||
self._refresh_item_attachment_analysis(item)
|
||||
self._sync_claim_from_items(claim)
|
||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
@@ -510,6 +657,7 @@ class ExpenseClaimService(
|
||||
self.db.add(item)
|
||||
|
||||
self._sync_claim_from_items(claim)
|
||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
@@ -548,6 +696,7 @@ class ExpenseClaimService(
|
||||
self.db.delete(item)
|
||||
|
||||
self._sync_claim_from_items(claim)
|
||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
@@ -645,12 +794,13 @@ class ExpenseClaimService(
|
||||
budget_flags,
|
||||
business_stage="reimbursement",
|
||||
)
|
||||
review_result = self._run_ai_submission_review(claim)
|
||||
if not self._has_ai_pre_review_flag(claim):
|
||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||
|
||||
claim.status = "submitted"
|
||||
claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE
|
||||
claim.submitted_at = datetime.now(UTC)
|
||||
|
||||
claim.status = str(review_result.get("status") or "supplement")
|
||||
claim.approval_stage = str(review_result.get("approval_stage") or "待补充")
|
||||
claim.risk_flags_json = list(review_result.get("risk_flags") or [])
|
||||
claim.submitted_at = datetime.now(UTC) if claim.status == "submitted" else None
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
@@ -872,11 +1022,3 @@ class ExpenseClaimService(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user