feat(expenses): add authoritative pre-review workflow
This commit is contained in:
116
server/src/app/api/v1/endpoints/reimbursement_pre_review.py
Normal file
116
server/src/app/api/v1/endpoints/reimbursement_pre_review.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseClaimActionResponse,
|
||||
ExpenseClaimPreReviewRead,
|
||||
ExpenseClaimRead,
|
||||
ExpenseClaimSubmitPayload,
|
||||
)
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
|
||||
from app.services.expense_claim_pre_review_decision import (
|
||||
find_pre_review_flag,
|
||||
pre_review_public_payload,
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
RequestIdHeader = Annotated[
|
||||
str | None,
|
||||
Header(
|
||||
alias="X-Request-ID",
|
||||
description="客户端生成的请求 ID;相同 ID 的预审重试复用同一费用事件。",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def pre_review_expense_claim_or_http_error(
|
||||
service: ExpenseClaimService,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
request_id: str | None,
|
||||
) -> ExpenseClaimRead:
|
||||
try:
|
||||
claim = service.pre_review_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
correlation_id=request_id,
|
||||
idempotency_key=request_id,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
return _claim_response_or_not_found(claim)
|
||||
|
||||
|
||||
def submit_expense_claim_or_http_error(
|
||||
service: ExpenseClaimService,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
payload: ExpenseClaimSubmitPayload | None,
|
||||
) -> ExpenseClaimRead:
|
||||
try:
|
||||
claim = service.submit_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
pre_review_id=str(getattr(payload, "pre_review_id", "") or ""),
|
||||
pre_review_input_fingerprint=str(
|
||||
getattr(payload, "input_fingerprint", "") or ""
|
||||
),
|
||||
)
|
||||
except ExpenseClaimPreReviewBlockedError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": error.code,
|
||||
"message": str(error),
|
||||
"review": error.review,
|
||||
},
|
||||
) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
return _claim_response_or_not_found(claim)
|
||||
|
||||
|
||||
def _claim_response_or_not_found(claim: ExpenseClaim | None) -> ExpenseClaimRead:
|
||||
if claim is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Claim not found",
|
||||
)
|
||||
|
||||
response = ExpenseClaimRead.model_validate(claim)
|
||||
payload = pre_review_public_payload(find_pre_review_flag(claim))
|
||||
pre_review = (
|
||||
ExpenseClaimPreReviewRead.model_validate(payload)
|
||||
if payload is not None
|
||||
else None
|
||||
)
|
||||
return response.model_copy(update={"pre_review": pre_review})
|
||||
|
||||
|
||||
def expense_claim_deletion_response(claim: ExpenseClaim) -> ExpenseClaimActionResponse:
|
||||
claim_no = str(claim.claim_no or "").strip()
|
||||
expense_type = str(claim.expense_type or "").strip().lower()
|
||||
document_label = (
|
||||
"申请单"
|
||||
if is_application_claim_no(claim_no) or expense_type.endswith("_application")
|
||||
else "报销单"
|
||||
)
|
||||
return ExpenseClaimActionResponse(
|
||||
message=f"{claim.claim_no} {document_label}已删除。",
|
||||
claim_id=claim.id,
|
||||
status="deleted",
|
||||
)
|
||||
@@ -21,6 +21,7 @@ from app.schemas.reimbursement import (
|
||||
ExpenseClaimRead,
|
||||
ExpenseClaimReturnPayload,
|
||||
ExpenseClaimStandardAdjustmentPayload,
|
||||
ExpenseClaimSubmitPayload,
|
||||
ExpenseClaimUpdate,
|
||||
ReimbursementCreate,
|
||||
ReimbursementRead,
|
||||
@@ -28,11 +29,17 @@ from app.schemas.reimbursement import (
|
||||
TravelReimbursementCalculatorResponse,
|
||||
)
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.reimbursement import ReimbursementService
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
|
||||
from .reimbursement_pre_review import (
|
||||
RequestIdHeader,
|
||||
expense_claim_deletion_response,
|
||||
pre_review_expense_claim_or_http_error,
|
||||
submit_expense_claim_or_http_error,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
@@ -600,16 +607,18 @@ def delete_expense_claim_item_attachment(
|
||||
},
|
||||
},
|
||||
)
|
||||
def pre_review_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
claim = service.pre_review_claim(claim_id, 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
|
||||
def pre_review_expense_claim(
|
||||
claim_id: str,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
request_id: RequestIdHeader = None,
|
||||
) -> ExpenseClaimRead:
|
||||
return pre_review_expense_claim_or_http_error(
|
||||
ExpenseClaimService(db),
|
||||
claim_id,
|
||||
current_user,
|
||||
request_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -628,16 +637,18 @@ def pre_review_expense_claim(claim_id: str, db: DbSession, current_user: Current
|
||||
},
|
||||
},
|
||||
)
|
||||
def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
claim = service.submit_claim(claim_id, 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
|
||||
def submit_expense_claim(
|
||||
claim_id: str,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
payload: ExpenseClaimSubmitPayload | None = None,
|
||||
) -> ExpenseClaimRead:
|
||||
return submit_expense_claim_or_http_error(
|
||||
ExpenseClaimService(db),
|
||||
claim_id,
|
||||
current_user,
|
||||
payload,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -764,18 +775,7 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
|
||||
claim_no = str(claim.claim_no or "").strip()
|
||||
expense_type = str(claim.expense_type or "").strip().lower()
|
||||
document_label = (
|
||||
"申请单"
|
||||
if is_application_claim_no(claim_no) or expense_type.endswith("_application")
|
||||
else "报销单"
|
||||
)
|
||||
return ExpenseClaimActionResponse(
|
||||
message=f"{claim.claim_no} {document_label}已删除。",
|
||||
claim_id=claim.id,
|
||||
status="deleted",
|
||||
)
|
||||
return expense_claim_deletion_response(claim)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -29,8 +29,17 @@ class BusinessEventPayloadRead(BaseModel):
|
||||
next_approval_stage: str = ""
|
||||
reason: str = ""
|
||||
opinion: str = ""
|
||||
message: str = ""
|
||||
application_claim_no: str = ""
|
||||
reimbursement_claim_no: str = ""
|
||||
claim_no: str = ""
|
||||
file_name: str = ""
|
||||
document_type: str = ""
|
||||
scene_code: str = ""
|
||||
review_status: str = ""
|
||||
passed: bool | None = None
|
||||
blocking_risk_count: int = 0
|
||||
business_stage: str = ""
|
||||
archived_applications: list[ArchivedApplicationRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
@@ -137,6 +137,49 @@ class ExpenseClaimStandardAdjustmentPayload(BaseModel):
|
||||
risks: list[ExpenseClaimStandardAdjustmentRisk] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
class ExpenseClaimPreReviewRemediationRead(BaseModel):
|
||||
action: str
|
||||
target_item_ids: list[str] = Field(default_factory=list)
|
||||
required_fields: list[str] = Field(default_factory=list)
|
||||
alternative_action: str | None = None
|
||||
|
||||
|
||||
class ExpenseClaimPreReviewFindingRead(BaseModel):
|
||||
risk_id: str
|
||||
rule_code: str = ""
|
||||
rule_version: str = ""
|
||||
severity: str
|
||||
disposition: Literal["fix", "review"]
|
||||
resolution_status: Literal["unresolved", "resolved"]
|
||||
actionability: str = ""
|
||||
source: str = "pre_review_finding"
|
||||
business_stage: str = ""
|
||||
risk_domain: str = ""
|
||||
visibility_scope: str = ""
|
||||
item_ids: list[str] = Field(default_factory=list)
|
||||
message: str
|
||||
remediation: ExpenseClaimPreReviewRemediationRead
|
||||
|
||||
|
||||
class ExpenseClaimPreReviewRead(BaseModel):
|
||||
review_id: str
|
||||
input_fingerprint: str
|
||||
rule_set_fingerprint: str
|
||||
review_context_fingerprint: str = ""
|
||||
pipeline_version: str
|
||||
reviewed_at: datetime
|
||||
decision: Literal["ready", "needs_fix", "ready_with_review"]
|
||||
passed: bool
|
||||
blocking_count: int = 0
|
||||
message: str
|
||||
findings: list[ExpenseClaimPreReviewFindingRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExpenseClaimSubmitPayload(BaseModel):
|
||||
pre_review_id: str | None = Field(default=None, max_length=36)
|
||||
input_fingerprint: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class ExpenseClaimRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -172,6 +215,7 @@ class ExpenseClaimRead(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
items: list[ExpenseClaimItemRead] = Field(default_factory=list)
|
||||
pre_review: ExpenseClaimPreReviewRead | None = None
|
||||
|
||||
@field_validator("risk_flags_json", mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -242,6 +242,14 @@ class ExpenseCaseService:
|
||||
self.db.flush()
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def mark_claiming_started(expense_case: ExpenseCase) -> None:
|
||||
"""首个报销资源进入 Case 后推进阶段,但不回退已进入审批的 Case。"""
|
||||
if str(expense_case.current_stage or "").strip() == "approved_to_spend":
|
||||
expense_case.current_stage = "claiming"
|
||||
if str(expense_case.status or "").strip() not in {"closed", "cancelled"}:
|
||||
expense_case.status = "active"
|
||||
|
||||
def record_claim_event(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
@@ -22,7 +23,6 @@ from app.services.expense_claim_workflow_constants import (
|
||||
PAYMENT_PENDING_STATUS,
|
||||
)
|
||||
|
||||
|
||||
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
|
||||
ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"}
|
||||
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
|
||||
@@ -39,7 +39,7 @@ ARCHIVED_REIMBURSEMENT_STAGES = (
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimAccessPolicy:
|
||||
class ExpenseClaimAccessPolicy(ExpenseClaimTenantScopeMixin):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
@@ -697,6 +697,7 @@ class ExpenseClaimAccessPolicy:
|
||||
return [and_(*pending_budget_approval_parts)]
|
||||
|
||||
def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
||||
stmt = self.apply_tenant_scope(stmt, current_user)
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if current_user.is_admin:
|
||||
return stmt.where(ExpenseClaim.status == "submitted")
|
||||
@@ -721,6 +722,7 @@ class ExpenseClaimAccessPolicy:
|
||||
*,
|
||||
include_approval_scope: bool = False,
|
||||
) -> Any:
|
||||
stmt = self.apply_tenant_scope(stmt, current_user)
|
||||
if current_user.is_admin:
|
||||
if include_approval_scope:
|
||||
return stmt
|
||||
@@ -768,6 +770,7 @@ class ExpenseClaimAccessPolicy:
|
||||
return stmt.where(or_(*conditions))
|
||||
|
||||
def apply_archived_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
||||
stmt = self.apply_tenant_scope(stmt, current_user)
|
||||
archived_condition = self.build_archived_claim_condition()
|
||||
if not self.has_archive_center_access(current_user):
|
||||
owned_conditions = self.build_personal_claim_conditions(current_user)
|
||||
|
||||
543
server/src/app/services/expense_claim_application_link.py
Normal file
543
server/src/app/services/expense_claim_application_link.py
Normal file
@@ -0,0 +1,543 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.services.expense_claim_constants import (
|
||||
DOCUMENT_FACT_ITEM_TYPES,
|
||||
SYSTEM_GENERATED_ITEM_TYPES,
|
||||
)
|
||||
|
||||
APPROVED_APPLICATION_LINK_STATUSES = {"approved", "completed"}
|
||||
INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES = {
|
||||
"cancelled",
|
||||
"canceled",
|
||||
"deleted",
|
||||
}
|
||||
|
||||
|
||||
class ExpenseClaimApplicationLinkMixin:
|
||||
def _sync_application_link_draft_without_items(self, claim: ExpenseClaim) -> None:
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
|
||||
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
|
||||
|
||||
def _clear_application_link_placeholder_items(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
) -> None:
|
||||
application_amounts = self._resolve_application_amount_candidates(context_json)
|
||||
for item in list(claim.items or []):
|
||||
if not self._is_application_link_placeholder_item(
|
||||
item,
|
||||
claim=claim,
|
||||
context_json=context_json,
|
||||
application_amounts=application_amounts,
|
||||
):
|
||||
continue
|
||||
claim.items.remove(item)
|
||||
self.db.delete(item)
|
||||
|
||||
def _is_application_link_placeholder_item(
|
||||
self,
|
||||
item: ExpenseClaimItem,
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
context_json: dict[str, Any],
|
||||
application_amounts: set[Decimal],
|
||||
) -> bool:
|
||||
if str(item.invoice_id or "").strip():
|
||||
return False
|
||||
|
||||
item_type = str(item.item_type or "").strip().lower()
|
||||
if item_type in DOCUMENT_FACT_ITEM_TYPES:
|
||||
return False
|
||||
if item_type in SYSTEM_GENERATED_ITEM_TYPES:
|
||||
return True
|
||||
|
||||
claim_type = str(claim.expense_type or "").strip().lower()
|
||||
if item_type and claim_type and item_type != claim_type:
|
||||
return False
|
||||
|
||||
amount = self._parse_context_money_amount(item.item_amount)
|
||||
if (
|
||||
application_amounts
|
||||
and amount is not None
|
||||
and amount > Decimal("0.00")
|
||||
and amount not in application_amounts
|
||||
):
|
||||
return False
|
||||
|
||||
reason = str(item.item_reason or "").strip()
|
||||
if not reason or reason == "待补充":
|
||||
return True
|
||||
|
||||
review_values = self._normalize_context_object(
|
||||
context_json.get("review_form_values")
|
||||
)
|
||||
linked_reasons = {
|
||||
str(review_values.get(key) or "").strip()
|
||||
for key in ("application_reason", "reason", "business_reason")
|
||||
}
|
||||
linked_reasons.add(str(claim.reason or "").strip())
|
||||
return reason in {value for value in linked_reasons if value}
|
||||
|
||||
def _should_skip_application_link_placeholder_item(
|
||||
self,
|
||||
*,
|
||||
claim: ExpenseClaim | None,
|
||||
context_json: dict[str, Any],
|
||||
document_specs: list[dict[str, Any]],
|
||||
attachment_count: int,
|
||||
amount: Decimal | None,
|
||||
) -> bool:
|
||||
if document_specs or attachment_count > 0:
|
||||
return False
|
||||
if self._build_application_link_flag(context_json) is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _resolve_application_amount_candidates(
|
||||
cls,
|
||||
context_json: dict[str, Any],
|
||||
) -> set[Decimal]:
|
||||
review_values = cls._normalize_context_object(
|
||||
context_json.get("review_form_values")
|
||||
)
|
||||
scene_selection = cls._normalize_context_object(
|
||||
context_json.get("expense_scene_selection")
|
||||
)
|
||||
candidates: set[Decimal] = set()
|
||||
for source in (review_values, scene_selection, context_json):
|
||||
for key in (
|
||||
"application_amount",
|
||||
"application_amount_label",
|
||||
"applicationAmount",
|
||||
"applicationAmountLabel",
|
||||
):
|
||||
parsed = cls._parse_context_money_amount(source.get(key))
|
||||
if parsed is not None:
|
||||
candidates.add(parsed)
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _parse_context_money_amount(value: Any) -> Decimal | None:
|
||||
raw_value = str(value or "").strip()
|
||||
if not raw_value:
|
||||
return None
|
||||
compact = re.sub(r"[^\d.\-]", "", raw_value.replace(",", ""))
|
||||
if not compact or compact in {"-", ".", "-."}:
|
||||
return None
|
||||
try:
|
||||
return Decimal(compact).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _merge_application_link_flag(
|
||||
risk_flags: list[Any],
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
) -> list[Any]:
|
||||
link_flag = ExpenseClaimApplicationLinkMixin._build_application_link_flag(
|
||||
context_json
|
||||
)
|
||||
if link_flag is None:
|
||||
return list(risk_flags or [])
|
||||
|
||||
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
|
||||
for flag in list(risk_flags or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
existing_no = str(
|
||||
flag.get("application_claim_no")
|
||||
or flag.get("applicationClaimNo")
|
||||
or ""
|
||||
).strip()
|
||||
if existing_no and existing_no == application_claim_no:
|
||||
return list(risk_flags or [])
|
||||
return [*list(risk_flags or []), link_flag]
|
||||
|
||||
def _build_application_link_block_result(
|
||||
self,
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
target_claim: ExpenseClaim | None,
|
||||
) -> dict[str, Any] | None:
|
||||
link_flag = self._build_application_link_flag(context_json)
|
||||
if link_flag is None:
|
||||
return None
|
||||
|
||||
application_claim = self._find_application_claim_for_link(link_flag)
|
||||
application_claim_no = str(
|
||||
link_flag.get("application_claim_no") or ""
|
||||
).strip()
|
||||
display_no = application_claim_no or "未编号申请单"
|
||||
if application_claim is None or not self._is_expense_application_claim(
|
||||
application_claim
|
||||
):
|
||||
return self._build_application_link_rejected_result(
|
||||
f"未找到可关联的申请单 {display_no}。请先选择已审批通过的申请单。",
|
||||
)
|
||||
|
||||
normalized_status = str(application_claim.status or "").strip().lower()
|
||||
if normalized_status not in APPROVED_APPLICATION_LINK_STATUSES:
|
||||
return self._build_application_link_rejected_result(
|
||||
f"申请单 {application_claim.claim_no} 当前不是已审批通过状态,不能用于快速报销关联。",
|
||||
application_claim=application_claim,
|
||||
)
|
||||
|
||||
existing_reimbursement = self._find_existing_reimbursement_for_application_link(
|
||||
application_claim=application_claim,
|
||||
link_flag=link_flag,
|
||||
target_claim=target_claim,
|
||||
)
|
||||
if existing_reimbursement is not None:
|
||||
return self._build_application_link_rejected_result(
|
||||
(
|
||||
f"申请单 {application_claim.claim_no} 已经关联报销单 "
|
||||
f"{existing_reimbursement.claim_no}。"
|
||||
"请进入该草稿或单据继续补充,不能重复生成。"
|
||||
),
|
||||
application_claim=application_claim,
|
||||
existing_claim=existing_reimbursement,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _find_application_claim_for_link(
|
||||
self,
|
||||
link_flag: dict[str, Any],
|
||||
) -> ExpenseClaim | None:
|
||||
application_claim_id = str(
|
||||
link_flag.get("application_claim_id") or ""
|
||||
).strip()
|
||||
application_claim_no = str(
|
||||
link_flag.get("application_claim_no") or ""
|
||||
).strip()
|
||||
|
||||
if application_claim_id:
|
||||
claim = self.db.get(ExpenseClaim, application_claim_id)
|
||||
if claim is not None and self._is_expense_application_claim(claim):
|
||||
return claim
|
||||
|
||||
if application_claim_no:
|
||||
return self.db.scalar(
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.claim_no == application_claim_no)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _find_existing_reimbursement_for_application_link(
|
||||
self,
|
||||
*,
|
||||
application_claim: ExpenseClaim,
|
||||
link_flag: dict[str, Any],
|
||||
target_claim: ExpenseClaim | None,
|
||||
) -> ExpenseClaim | None:
|
||||
generated_draft = self._find_generated_reimbursement_from_application(
|
||||
application_claim=application_claim,
|
||||
target_claim=target_claim,
|
||||
)
|
||||
if generated_draft is not None:
|
||||
return generated_draft
|
||||
|
||||
linked_ids, linked_nos = self._collect_application_link_reference_values(
|
||||
link_flag
|
||||
)
|
||||
linked_ids.add(str(application_claim.id or "").strip())
|
||||
linked_nos.add(str(application_claim.claim_no or "").strip().upper())
|
||||
linked_ids.discard("")
|
||||
linked_nos.discard("")
|
||||
|
||||
for claim in list(self.db.scalars(select(ExpenseClaim)).all()):
|
||||
if self._is_same_target_claim(claim, target_claim):
|
||||
continue
|
||||
if self._is_expense_application_claim(claim):
|
||||
continue
|
||||
if self._is_inactive_application_link_reimbursement(claim):
|
||||
continue
|
||||
if self._claim_references_application(
|
||||
claim,
|
||||
linked_ids=linked_ids,
|
||||
linked_nos=linked_nos,
|
||||
):
|
||||
return claim
|
||||
return None
|
||||
|
||||
def _find_generated_reimbursement_from_application(
|
||||
self,
|
||||
*,
|
||||
application_claim: ExpenseClaim,
|
||||
target_claim: ExpenseClaim | None,
|
||||
) -> ExpenseClaim | None:
|
||||
for flag in list(application_claim.risk_flags_json or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
generated_draft_id = str(
|
||||
flag.get("generated_draft_claim_id")
|
||||
or flag.get("generatedDraftClaimId")
|
||||
or ""
|
||||
).strip()
|
||||
generated_draft_no = str(
|
||||
flag.get("generated_draft_claim_no")
|
||||
or flag.get("generatedDraftClaimNo")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
claim = (
|
||||
self.db.get(ExpenseClaim, generated_draft_id)
|
||||
if generated_draft_id
|
||||
else None
|
||||
)
|
||||
if claim is None and generated_draft_no:
|
||||
claim = self.db.scalar(
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.claim_no == generated_draft_no)
|
||||
.limit(1)
|
||||
)
|
||||
if claim is None:
|
||||
continue
|
||||
if self._is_same_target_claim(claim, target_claim):
|
||||
continue
|
||||
if self._is_expense_application_claim(claim):
|
||||
continue
|
||||
if self._is_inactive_application_link_reimbursement(claim):
|
||||
continue
|
||||
return claim
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_same_target_claim(
|
||||
claim: ExpenseClaim,
|
||||
target_claim: ExpenseClaim | None,
|
||||
) -> bool:
|
||||
return bool(target_claim is not None and claim.id == target_claim.id)
|
||||
|
||||
@staticmethod
|
||||
def _is_inactive_application_link_reimbursement(claim: ExpenseClaim) -> bool:
|
||||
status = str(claim.status or "").strip().lower()
|
||||
return status in INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES
|
||||
|
||||
@classmethod
|
||||
def _claim_references_application(
|
||||
cls,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
linked_ids: set[str],
|
||||
linked_nos: set[str],
|
||||
) -> bool:
|
||||
for flag in list(claim.risk_flags_json or []):
|
||||
flag_ids, flag_nos = cls._collect_application_link_reference_values(flag)
|
||||
if flag_ids.intersection(linked_ids) or flag_nos.intersection(linked_nos):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _collect_application_link_reference_values(
|
||||
cls,
|
||||
payload: Any,
|
||||
) -> tuple[set[str], set[str]]:
|
||||
ids: set[str] = set()
|
||||
claim_nos: set[str] = set()
|
||||
if not isinstance(payload, dict):
|
||||
return ids, claim_nos
|
||||
|
||||
cls._add_application_link_reference(ids, claim_nos, payload)
|
||||
for key in (
|
||||
"application_detail",
|
||||
"applicationDetail",
|
||||
"review_form_values",
|
||||
"reviewFormValues",
|
||||
"expense_scene_selection",
|
||||
"expenseSceneSelection",
|
||||
):
|
||||
nested_ids, nested_nos = cls._collect_application_link_reference_values(
|
||||
payload.get(key)
|
||||
)
|
||||
ids.update(nested_ids)
|
||||
claim_nos.update(nested_nos)
|
||||
ids.discard("")
|
||||
claim_nos.discard("")
|
||||
return ids, claim_nos
|
||||
|
||||
@staticmethod
|
||||
def _add_application_link_reference(
|
||||
ids: set[str],
|
||||
claim_nos: set[str],
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
for key in ("application_claim_id", "applicationClaimId"):
|
||||
ids.add(str(payload.get(key) or "").strip())
|
||||
for key in ("application_claim_no", "applicationClaimNo"):
|
||||
claim_nos.add(str(payload.get(key) or "").strip().upper())
|
||||
|
||||
@staticmethod
|
||||
def _build_application_link_rejected_result(
|
||||
message: str,
|
||||
*,
|
||||
application_claim: ExpenseClaim | None = None,
|
||||
existing_claim: ExpenseClaim | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
"message": message,
|
||||
"draft_only": False,
|
||||
"status": "blocked",
|
||||
"application_link_blocked": True,
|
||||
"submission_blocked": True,
|
||||
"submission_blocked_reasons": [message],
|
||||
"missing_fields": [message],
|
||||
"risk_flags": ["application_link_blocked"],
|
||||
}
|
||||
if application_claim is not None:
|
||||
result["application_claim_id"] = application_claim.id
|
||||
result["application_claim_no"] = application_claim.claim_no
|
||||
result["application_status"] = application_claim.status
|
||||
if existing_claim is not None:
|
||||
result["existing_claim_id"] = existing_claim.id
|
||||
result["existing_claim_no"] = existing_claim.claim_no
|
||||
result["existing_claim_status"] = existing_claim.status
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _build_application_link_flag(
|
||||
context_json: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
review_values = ExpenseClaimApplicationLinkMixin._normalize_context_object(
|
||||
context_json.get("review_form_values")
|
||||
)
|
||||
scene_selection = ExpenseClaimApplicationLinkMixin._normalize_context_object(
|
||||
context_json.get("expense_scene_selection")
|
||||
)
|
||||
|
||||
def pick(*keys: str) -> str:
|
||||
for source in (review_values, scene_selection, context_json):
|
||||
for key in keys:
|
||||
value = str(source.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
application_claim_no = pick("application_claim_no", "applicationClaimNo")
|
||||
if not application_claim_no:
|
||||
return None
|
||||
|
||||
application_claim_id = pick("application_claim_id", "applicationClaimId")
|
||||
application_amount = pick("application_amount", "applicationAmount")
|
||||
application_amount_label = pick(
|
||||
"application_amount_label", "applicationAmountLabel"
|
||||
)
|
||||
application_reason = pick(
|
||||
"application_reason", "applicationReason", "reason"
|
||||
)
|
||||
application_location = pick(
|
||||
"application_location", "applicationLocation", "location"
|
||||
)
|
||||
application_time = pick(
|
||||
"application_business_time",
|
||||
"applicationBusinessTime",
|
||||
"application_time",
|
||||
"applicationTime",
|
||||
"business_time",
|
||||
"businessTime",
|
||||
"time_range",
|
||||
"timeRange",
|
||||
"time",
|
||||
)
|
||||
application_date = pick("application_date", "applicationDate")
|
||||
application_days = pick("application_days", "applicationDays", "days")
|
||||
application_transport_mode = pick(
|
||||
"application_transport_mode",
|
||||
"applicationTransportMode",
|
||||
"transport_mode",
|
||||
"transportMode",
|
||||
)
|
||||
application_lodging_daily_cap = pick(
|
||||
"application_lodging_daily_cap",
|
||||
"applicationLodgingDailyCap",
|
||||
"lodging_daily_cap",
|
||||
"lodgingDailyCap",
|
||||
)
|
||||
application_subsidy_daily_cap = pick(
|
||||
"application_subsidy_daily_cap",
|
||||
"applicationSubsidyDailyCap",
|
||||
"subsidy_daily_cap",
|
||||
"subsidyDailyCap",
|
||||
)
|
||||
application_transport_policy = pick(
|
||||
"application_transport_policy",
|
||||
"applicationTransportPolicy",
|
||||
"transport_policy",
|
||||
"transportPolicy",
|
||||
)
|
||||
application_policy_estimate = pick(
|
||||
"application_policy_estimate",
|
||||
"applicationPolicyEstimate",
|
||||
"policy_estimate",
|
||||
"policyEstimate",
|
||||
)
|
||||
application_rule_name = pick(
|
||||
"application_rule_name",
|
||||
"applicationRuleName",
|
||||
"rule_name",
|
||||
"ruleName",
|
||||
)
|
||||
application_rule_version = pick(
|
||||
"application_rule_version",
|
||||
"applicationRuleVersion",
|
||||
"rule_version",
|
||||
"ruleVersion",
|
||||
)
|
||||
application_status = pick("application_status", "applicationStatus")
|
||||
application_status_label = pick(
|
||||
"application_status_label", "applicationStatusLabel"
|
||||
)
|
||||
|
||||
return {
|
||||
"source": "application_link",
|
||||
"event_type": "expense_reimbursement_application_linked",
|
||||
"severity": "info",
|
||||
"label": "关联申请单",
|
||||
"message": f"报销草稿已关联申请单 {application_claim_no}。",
|
||||
"application_claim_id": application_claim_id,
|
||||
"application_claim_no": application_claim_no,
|
||||
"application_amount_label": application_amount_label,
|
||||
"application_status": application_status,
|
||||
"application_status_label": application_status_label,
|
||||
"application_detail": {
|
||||
"application_reason": application_reason,
|
||||
"application_location": application_location,
|
||||
"application_amount": application_amount,
|
||||
"application_amount_label": application_amount_label,
|
||||
"application_time": application_time or application_date,
|
||||
"application_business_time": application_time,
|
||||
"application_date": application_date,
|
||||
"application_days": application_days,
|
||||
"application_transport_mode": application_transport_mode,
|
||||
"application_lodging_daily_cap": application_lodging_daily_cap,
|
||||
"application_subsidy_daily_cap": application_subsidy_daily_cap,
|
||||
"application_transport_policy": application_transport_policy,
|
||||
"application_policy_estimate": application_policy_estimate,
|
||||
"application_rule_name": application_rule_name,
|
||||
"application_rule_version": application_rule_version,
|
||||
},
|
||||
"review_form_values": review_values,
|
||||
"expense_scene_selection": scene_selection,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_context_object(value: Any) -> dict[str, Any]:
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
@@ -296,6 +296,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
},
|
||||
expense_case=expense_case,
|
||||
relation_type="generated_reimbursement",
|
||||
update_case_state=False,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.services.expense_claim_risk_stage import (
|
||||
risk_flag_business_stage,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
|
||||
class ExpenseClaimApprovalRoutingMixin:
|
||||
@@ -73,6 +74,14 @@ class ExpenseClaimApprovalRoutingMixin:
|
||||
claim.risk_flags_json,
|
||||
business_stage=business_stage,
|
||||
)
|
||||
application_risk_reasons = (
|
||||
self._collect_application_route_risk_reasons(
|
||||
claim.risk_flags_json,
|
||||
business_stage=business_stage,
|
||||
)
|
||||
if is_application_claim
|
||||
else []
|
||||
)
|
||||
historical_risk_count = self._count_recent_substantive_risky_claims(claim)
|
||||
historical_risk_reasons = (
|
||||
[f"申请人近 {AI_REVIEW_LOOKBACK_DAYS} 天存在 {historical_risk_count} 笔实质风险记录"]
|
||||
@@ -80,7 +89,7 @@ class ExpenseClaimApprovalRoutingMixin:
|
||||
else []
|
||||
)
|
||||
reasons = self._dedupe_reasons(
|
||||
budget_reasons
|
||||
[*budget_reasons, *application_risk_reasons]
|
||||
if is_application_claim
|
||||
else [*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
|
||||
)
|
||||
@@ -95,9 +104,11 @@ class ExpenseClaimApprovalRoutingMixin:
|
||||
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
|
||||
if is_application_claim:
|
||||
message = (
|
||||
"系统根据预算占用阈值判断,该申请单达到 90% 预算复核线,需要预算管理者二次确认。"
|
||||
"系统根据预算占用与高风险复核结果判断,"
|
||||
"该申请单需要预算管理者二次确认。"
|
||||
if requires_budget_review
|
||||
else "系统根据预算占用阈值判断,该申请单未达到 90% 预算复核线,可跳过预算管理者复核。"
|
||||
else "系统根据预算占用与风险复核结果判断,"
|
||||
"该申请单可跳过预算管理者复核。"
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
@@ -184,6 +195,37 @@ class ExpenseClaimApprovalRoutingMixin:
|
||||
reasons.append(f"{label}:{message}" if message else label)
|
||||
return self._dedupe_reasons(reasons)
|
||||
|
||||
def _collect_application_route_risk_reasons(
|
||||
self,
|
||||
risk_flags: list[Any] | None,
|
||||
*,
|
||||
business_stage: str,
|
||||
) -> list[str]:
|
||||
"""申请单仅将高危关注项升级给预算管理者。
|
||||
|
||||
普通预算预警由 90% 占用线决定是否升级,避免中低风险导致
|
||||
所有申请都增加一道审批。
|
||||
"""
|
||||
|
||||
reasons: list[str] = []
|
||||
for flag in list(risk_flags or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
flag_stage = risk_flag_business_stage(flag)
|
||||
if flag_stage and flag_stage != business_stage:
|
||||
continue
|
||||
severity = str(flag.get("severity") or "").strip().lower()
|
||||
event_type = str(flag.get("event_type") or "").strip().lower()
|
||||
is_high_risk = severity in {"high", "critical", "danger"}
|
||||
if not is_high_risk and event_type not in self._ROUTE_RISK_EVENT_TYPES:
|
||||
continue
|
||||
if not self._is_substantive_route_risk_flag(flag):
|
||||
continue
|
||||
label = str(flag.get("label") or event_type or "风险标记").strip()
|
||||
message = str(flag.get("message") or "").strip()
|
||||
reasons.append(f"{label}:{message}" if message else label)
|
||||
return self._dedupe_reasons(reasons)
|
||||
|
||||
def _count_recent_substantive_risky_claims(self, claim: ExpenseClaim) -> int:
|
||||
filters = []
|
||||
if claim.employee_id:
|
||||
@@ -199,6 +241,14 @@ class ExpenseClaimApprovalRoutingMixin:
|
||||
.where(or_(*filters))
|
||||
.where(ExpenseClaim.id != claim.id)
|
||||
.where(ExpenseClaim.occurred_at >= since)
|
||||
.where(
|
||||
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(
|
||||
ExpenseClaimTenantScopeMixin.resolve_claim_tenant_id(
|
||||
self.db,
|
||||
claim.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
return sum(
|
||||
1
|
||||
@@ -211,6 +261,13 @@ class ExpenseClaimApprovalRoutingMixin:
|
||||
)
|
||||
|
||||
def _is_substantive_route_risk_flag(self, flag: dict[str, Any]) -> bool:
|
||||
resolution_status = str(
|
||||
flag.get("resolution_status") or flag.get("resolutionStatus") or ""
|
||||
).strip().lower()
|
||||
if resolution_status in {"resolved", "accepted", "waived"} or bool(
|
||||
flag.get("resolved")
|
||||
):
|
||||
return False
|
||||
source = str(flag.get("source") or "").strip().lower()
|
||||
if source in self._ROUTE_IGNORED_SOURCES:
|
||||
return False
|
||||
|
||||
@@ -322,7 +322,9 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
source_score = cls._attachment_ocr_signal_score(source_receipt_document)
|
||||
upload_score = cls._attachment_ocr_signal_score(upload_ocr_document)
|
||||
if source_score <= 0:
|
||||
return upload_ocr_document if upload_score > 0 else None
|
||||
# OCR 已返回文档但没有任何有效信号时,也要进入高风险校验,
|
||||
# 不能回落成“待识别”的中风险状态放过普通图片或空白附件。
|
||||
return upload_ocr_document
|
||||
if upload_score <= 0:
|
||||
return source_receipt_document
|
||||
|
||||
|
||||
@@ -87,6 +87,9 @@ from app.services.expense_claim_constants import (
|
||||
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
|
||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||
)
|
||||
from app.services.expense_claim_platform_context_tools import (
|
||||
collect_invoice_keys_from_document_info,
|
||||
)
|
||||
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
||||
from app.services.expense_amounts import (
|
||||
extract_amount_candidates,
|
||||
@@ -470,6 +473,10 @@ class ExpenseClaimDocumentItemBuilderMixin:
|
||||
document_info["fields"] = document_info.get("document_fields")
|
||||
return self._collect_invoice_keys_from_document_info(document_info)
|
||||
|
||||
@staticmethod
|
||||
def _collect_invoice_keys_from_document_info(document_info: dict[str, Any]) -> list[str]:
|
||||
return collect_invoice_keys_from_document_info(document_info)
|
||||
|
||||
def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str:
|
||||
document_type = str(document.get("document_type") or "").strip()
|
||||
mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type)
|
||||
|
||||
@@ -1,546 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.ontology import OntologyEntity, OntologyParseResult
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseClaimItemCreate,
|
||||
ExpenseClaimItemUpdate,
|
||||
ExpenseClaimUpdate,
|
||||
TravelReimbursementCalculatorRequest,
|
||||
from app.schemas.ontology import OntologyParseResult
|
||||
from app.services.expense_claim_application_link import (
|
||||
APPROVED_APPLICATION_LINK_STATUSES as APPROVED_APPLICATION_LINK_STATUSES,
|
||||
)
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.document_intelligence import build_document_insight
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_application_link import ExpenseClaimApplicationLinkMixin
|
||||
from app.services.expense_claim_constants import (
|
||||
EXPENSE_TYPE_LABELS,
|
||||
MAX_DRAFT_CLAIMS_PER_USER,
|
||||
EDITABLE_CLAIM_STATUSES,
|
||||
SYSTEM_GENERATED_ITEM_TYPES,
|
||||
TRAVEL_DETAIL_ITEM_TYPES,
|
||||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
||||
DOCUMENT_TYPE_ITEM_TYPE_MAP,
|
||||
DOCUMENT_TYPE_SCENE_MAP,
|
||||
DOCUMENT_FACT_ITEM_TYPES,
|
||||
ROUTE_DESCRIPTION_ITEM_TYPES,
|
||||
DOCUMENT_TRIP_DATE_LABELS,
|
||||
DOCUMENT_TRIP_DATE_REQUIREMENT_LABELS,
|
||||
DOCUMENT_TRIP_DATE_KEYS,
|
||||
DOCUMENT_GENERIC_DATE_KEYS,
|
||||
DOCUMENT_INVOICE_DATE_KEYS,
|
||||
DOCUMENT_TRIP_DATE_LABEL_TOKENS,
|
||||
DOCUMENT_GENERIC_DATE_LABEL_TOKENS,
|
||||
DOCUMENT_INVOICE_DATE_LABEL_TOKENS,
|
||||
DOCUMENT_ROUTE_FORMAT_PATTERN,
|
||||
DOCUMENT_ROUTE_TEXT_PATTERN,
|
||||
DOCUMENT_ROUTE_ORIGIN_LABELS,
|
||||
DOCUMENT_ROUTE_DESTINATION_LABELS,
|
||||
GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES,
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES,
|
||||
EXPENSE_SCENE_KEYWORDS,
|
||||
EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES,
|
||||
DOCUMENT_SCENE_LABELS,
|
||||
DOCUMENT_ASSOCIATION_REVIEW_ACTIONS,
|
||||
PERSISTENT_EXPENSE_REVIEW_ACTIONS,
|
||||
RETURN_REASON_OPTIONS,
|
||||
EDITABLE_CLAIM_STATUSES,
|
||||
MAX_CLAIM_NO_RETRY_ATTEMPTS,
|
||||
DOCUMENT_DATE_PATTERN,
|
||||
SYSTEM_GENERATED_REASON_PREFIXES,
|
||||
LEADING_REASON_TIME_PATTERNS,
|
||||
AI_REVIEW_LOOKBACK_DAYS,
|
||||
AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
|
||||
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT,
|
||||
TRAVEL_REVIEW_RELEVANT_EXPENSE_TYPES,
|
||||
TRAVEL_REVIEW_LONG_DISTANCE_DOCUMENT_TYPES,
|
||||
TRAVEL_POLICY_CITY_TIERS,
|
||||
TRAVEL_POLICY_CITY_MATCH_ORDER,
|
||||
TRAVEL_POLICY_BAND_LABELS,
|
||||
TRAVEL_POLICY_HOTEL_LIMITS,
|
||||
TRAVEL_POLICY_ALLOWED_TRANSPORT_LEVELS,
|
||||
TRAVEL_POLICY_ROUTE_EXCEPTION_KEYWORDS,
|
||||
TRAVEL_POLICY_STANDARD_EXCEPTION_KEYWORDS,
|
||||
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
|
||||
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
|
||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||
MAX_DRAFT_CLAIMS_PER_USER,
|
||||
SYSTEM_GENERATED_ITEM_TYPES,
|
||||
)
|
||||
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
||||
from app.services.expense_amounts import (
|
||||
extract_amount_candidates,
|
||||
format_decimal_amount,
|
||||
is_amount_match_date_fragment,
|
||||
is_date_like_amount_candidate,
|
||||
is_probable_year_amount,
|
||||
parse_document_amount_value,
|
||||
parse_plain_document_amount_value,
|
||||
resolve_document_field_amount,
|
||||
resolve_document_item_amount,
|
||||
resolve_document_text_amount,
|
||||
)
|
||||
from app.services.expense_rule_runtime import (
|
||||
DEFAULT_SCENE_RULE_ASSET_CODE,
|
||||
ExpenseRuleRuntimeService,
|
||||
RuntimeTravelPolicy,
|
||||
build_default_expense_rule_catalog,
|
||||
resolve_document_type_label,
|
||||
)
|
||||
from app.services.ocr import OcrService
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
|
||||
APPROVED_APPLICATION_LINK_STATUSES = {"approved", "completed"}
|
||||
INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES = {"cancelled", "canceled", "deleted"}
|
||||
|
||||
|
||||
class ExpenseClaimApplicationLinkMixin:
|
||||
def _sync_application_link_draft_without_items(self, claim: ExpenseClaim) -> None:
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
|
||||
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
|
||||
|
||||
def _clear_application_link_placeholder_items(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
) -> None:
|
||||
application_amounts = self._resolve_application_amount_candidates(context_json)
|
||||
for item in list(claim.items or []):
|
||||
if not self._is_application_link_placeholder_item(
|
||||
item,
|
||||
claim=claim,
|
||||
context_json=context_json,
|
||||
application_amounts=application_amounts,
|
||||
):
|
||||
continue
|
||||
claim.items.remove(item)
|
||||
self.db.delete(item)
|
||||
|
||||
def _is_application_link_placeholder_item(
|
||||
self,
|
||||
item: ExpenseClaimItem,
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
context_json: dict[str, Any],
|
||||
application_amounts: set[Decimal],
|
||||
) -> bool:
|
||||
if str(item.invoice_id or "").strip():
|
||||
return False
|
||||
|
||||
item_type = str(item.item_type or "").strip().lower()
|
||||
if item_type in DOCUMENT_FACT_ITEM_TYPES:
|
||||
return False
|
||||
if item_type in SYSTEM_GENERATED_ITEM_TYPES:
|
||||
return True
|
||||
|
||||
claim_type = str(claim.expense_type or "").strip().lower()
|
||||
if item_type and claim_type and item_type != claim_type:
|
||||
return False
|
||||
|
||||
amount = self._parse_context_money_amount(item.item_amount)
|
||||
if application_amounts and amount is not None and amount > Decimal("0.00") and amount not in application_amounts:
|
||||
return False
|
||||
|
||||
reason = str(item.item_reason or "").strip()
|
||||
if not reason or reason == "待补充":
|
||||
return True
|
||||
|
||||
review_values = self._normalize_context_object(context_json.get("review_form_values"))
|
||||
linked_reasons = {
|
||||
str(review_values.get(key) or "").strip()
|
||||
for key in ("application_reason", "reason", "business_reason")
|
||||
}
|
||||
linked_reasons.add(str(claim.reason or "").strip())
|
||||
return reason in {value for value in linked_reasons if value}
|
||||
|
||||
def _should_skip_application_link_placeholder_item(
|
||||
self,
|
||||
*,
|
||||
claim: ExpenseClaim | None,
|
||||
context_json: dict[str, Any],
|
||||
document_specs: list[dict[str, Any]],
|
||||
attachment_count: int,
|
||||
amount: Decimal | None,
|
||||
) -> bool:
|
||||
if document_specs or attachment_count > 0:
|
||||
return False
|
||||
if self._build_application_link_flag(context_json) is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _resolve_application_amount_candidates(cls, context_json: dict[str, Any]) -> set[Decimal]:
|
||||
review_values = cls._normalize_context_object(context_json.get("review_form_values"))
|
||||
scene_selection = cls._normalize_context_object(context_json.get("expense_scene_selection"))
|
||||
candidates: set[Decimal] = set()
|
||||
for source in (review_values, scene_selection, context_json):
|
||||
for key in ("application_amount", "application_amount_label", "applicationAmount", "applicationAmountLabel"):
|
||||
parsed = cls._parse_context_money_amount(source.get(key))
|
||||
if parsed is not None:
|
||||
candidates.add(parsed)
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _parse_context_money_amount(value: Any) -> Decimal | None:
|
||||
raw_value = str(value or "").strip()
|
||||
if not raw_value:
|
||||
return None
|
||||
compact = re.sub(r"[^\d.\-]", "", raw_value.replace(",", ""))
|
||||
if not compact or compact in {"-", ".", "-."}:
|
||||
return None
|
||||
try:
|
||||
return Decimal(compact).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _merge_application_link_flag(
|
||||
risk_flags: list[Any],
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
) -> list[Any]:
|
||||
link_flag = ExpenseClaimDraftFlowMixin._build_application_link_flag(context_json)
|
||||
if link_flag is None:
|
||||
return list(risk_flags or [])
|
||||
|
||||
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
|
||||
for flag in list(risk_flags or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
existing_no = str(
|
||||
flag.get("application_claim_no")
|
||||
or flag.get("applicationClaimNo")
|
||||
or ""
|
||||
).strip()
|
||||
if existing_no and existing_no == application_claim_no:
|
||||
return list(risk_flags or [])
|
||||
return [*list(risk_flags or []), link_flag]
|
||||
|
||||
def _build_application_link_block_result(
|
||||
self,
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
target_claim: ExpenseClaim | None,
|
||||
) -> dict[str, Any] | None:
|
||||
link_flag = self._build_application_link_flag(context_json)
|
||||
if link_flag is None:
|
||||
return None
|
||||
|
||||
application_claim = self._find_application_claim_for_link(link_flag)
|
||||
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
|
||||
display_no = application_claim_no or "未编号申请单"
|
||||
if application_claim is None or not self._is_expense_application_claim(application_claim):
|
||||
return self._build_application_link_rejected_result(
|
||||
f"未找到可关联的申请单 {display_no}。请先选择已审批通过的申请单。",
|
||||
)
|
||||
|
||||
normalized_status = str(application_claim.status or "").strip().lower()
|
||||
if normalized_status not in APPROVED_APPLICATION_LINK_STATUSES:
|
||||
return self._build_application_link_rejected_result(
|
||||
f"申请单 {application_claim.claim_no} 当前不是已审批通过状态,不能用于快速报销关联。",
|
||||
application_claim=application_claim,
|
||||
)
|
||||
|
||||
existing_reimbursement = self._find_existing_reimbursement_for_application_link(
|
||||
application_claim=application_claim,
|
||||
link_flag=link_flag,
|
||||
target_claim=target_claim,
|
||||
)
|
||||
if existing_reimbursement is not None:
|
||||
return self._build_application_link_rejected_result(
|
||||
(
|
||||
f"申请单 {application_claim.claim_no} 已经关联报销单 {existing_reimbursement.claim_no}。"
|
||||
"请进入该草稿或单据继续补充,不能重复生成。"
|
||||
),
|
||||
application_claim=application_claim,
|
||||
existing_claim=existing_reimbursement,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _find_application_claim_for_link(self, link_flag: dict[str, Any]) -> ExpenseClaim | None:
|
||||
application_claim_id = str(link_flag.get("application_claim_id") or "").strip()
|
||||
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
|
||||
|
||||
if application_claim_id:
|
||||
claim = self.db.get(ExpenseClaim, application_claim_id)
|
||||
if claim is not None and self._is_expense_application_claim(claim):
|
||||
return claim
|
||||
|
||||
if application_claim_no:
|
||||
return self.db.scalar(
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.claim_no == application_claim_no)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _find_existing_reimbursement_for_application_link(
|
||||
self,
|
||||
*,
|
||||
application_claim: ExpenseClaim,
|
||||
link_flag: dict[str, Any],
|
||||
target_claim: ExpenseClaim | None,
|
||||
) -> ExpenseClaim | None:
|
||||
generated_draft = self._find_generated_reimbursement_from_application(
|
||||
application_claim=application_claim,
|
||||
target_claim=target_claim,
|
||||
)
|
||||
if generated_draft is not None:
|
||||
return generated_draft
|
||||
|
||||
linked_ids, linked_nos = self._collect_application_link_reference_values(link_flag)
|
||||
linked_ids.add(str(application_claim.id or "").strip())
|
||||
linked_nos.add(str(application_claim.claim_no or "").strip().upper())
|
||||
linked_ids.discard("")
|
||||
linked_nos.discard("")
|
||||
|
||||
for claim in list(self.db.scalars(select(ExpenseClaim)).all()):
|
||||
if self._is_same_target_claim(claim, target_claim):
|
||||
continue
|
||||
if self._is_expense_application_claim(claim):
|
||||
continue
|
||||
if self._is_inactive_application_link_reimbursement(claim):
|
||||
continue
|
||||
if self._claim_references_application(claim, linked_ids=linked_ids, linked_nos=linked_nos):
|
||||
return claim
|
||||
return None
|
||||
|
||||
def _find_generated_reimbursement_from_application(
|
||||
self,
|
||||
*,
|
||||
application_claim: ExpenseClaim,
|
||||
target_claim: ExpenseClaim | None,
|
||||
) -> ExpenseClaim | None:
|
||||
for flag in list(application_claim.risk_flags_json or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
generated_draft_id = str(
|
||||
flag.get("generated_draft_claim_id")
|
||||
or flag.get("generatedDraftClaimId")
|
||||
or ""
|
||||
).strip()
|
||||
generated_draft_no = str(
|
||||
flag.get("generated_draft_claim_no")
|
||||
or flag.get("generatedDraftClaimNo")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
claim = self.db.get(ExpenseClaim, generated_draft_id) if generated_draft_id else None
|
||||
if claim is None and generated_draft_no:
|
||||
claim = self.db.scalar(
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.claim_no == generated_draft_no)
|
||||
.limit(1)
|
||||
)
|
||||
if claim is None:
|
||||
continue
|
||||
if self._is_same_target_claim(claim, target_claim):
|
||||
continue
|
||||
if self._is_expense_application_claim(claim):
|
||||
continue
|
||||
if self._is_inactive_application_link_reimbursement(claim):
|
||||
continue
|
||||
return claim
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_same_target_claim(claim: ExpenseClaim, target_claim: ExpenseClaim | None) -> bool:
|
||||
return bool(target_claim is not None and claim.id == target_claim.id)
|
||||
|
||||
@staticmethod
|
||||
def _is_inactive_application_link_reimbursement(claim: ExpenseClaim) -> bool:
|
||||
status = str(claim.status or "").strip().lower()
|
||||
return status in INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES
|
||||
|
||||
@classmethod
|
||||
def _claim_references_application(
|
||||
cls,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
linked_ids: set[str],
|
||||
linked_nos: set[str],
|
||||
) -> bool:
|
||||
for flag in list(claim.risk_flags_json or []):
|
||||
flag_ids, flag_nos = cls._collect_application_link_reference_values(flag)
|
||||
if flag_ids.intersection(linked_ids) or flag_nos.intersection(linked_nos):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _collect_application_link_reference_values(cls, payload: Any) -> tuple[set[str], set[str]]:
|
||||
ids: set[str] = set()
|
||||
claim_nos: set[str] = set()
|
||||
if not isinstance(payload, dict):
|
||||
return ids, claim_nos
|
||||
|
||||
cls._add_application_link_reference(ids, claim_nos, payload)
|
||||
for key in (
|
||||
"application_detail",
|
||||
"applicationDetail",
|
||||
"review_form_values",
|
||||
"reviewFormValues",
|
||||
"expense_scene_selection",
|
||||
"expenseSceneSelection",
|
||||
):
|
||||
nested_ids, nested_nos = cls._collect_application_link_reference_values(payload.get(key))
|
||||
ids.update(nested_ids)
|
||||
claim_nos.update(nested_nos)
|
||||
ids.discard("")
|
||||
claim_nos.discard("")
|
||||
return ids, claim_nos
|
||||
|
||||
@staticmethod
|
||||
def _add_application_link_reference(
|
||||
ids: set[str],
|
||||
claim_nos: set[str],
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
for key in ("application_claim_id", "applicationClaimId"):
|
||||
ids.add(str(payload.get(key) or "").strip())
|
||||
for key in ("application_claim_no", "applicationClaimNo"):
|
||||
claim_nos.add(str(payload.get(key) or "").strip().upper())
|
||||
|
||||
@staticmethod
|
||||
def _build_application_link_rejected_result(
|
||||
message: str,
|
||||
*,
|
||||
application_claim: ExpenseClaim | None = None,
|
||||
existing_claim: ExpenseClaim | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
"message": message,
|
||||
"draft_only": False,
|
||||
"status": "blocked",
|
||||
"application_link_blocked": True,
|
||||
"submission_blocked": True,
|
||||
"submission_blocked_reasons": [message],
|
||||
"missing_fields": [message],
|
||||
"risk_flags": ["application_link_blocked"],
|
||||
}
|
||||
if application_claim is not None:
|
||||
result["application_claim_id"] = application_claim.id
|
||||
result["application_claim_no"] = application_claim.claim_no
|
||||
result["application_status"] = application_claim.status
|
||||
if existing_claim is not None:
|
||||
result["existing_claim_id"] = existing_claim.id
|
||||
result["existing_claim_no"] = existing_claim.claim_no
|
||||
result["existing_claim_status"] = existing_claim.status
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None:
|
||||
review_values = ExpenseClaimDraftFlowMixin._normalize_context_object(
|
||||
context_json.get("review_form_values")
|
||||
)
|
||||
scene_selection = ExpenseClaimDraftFlowMixin._normalize_context_object(
|
||||
context_json.get("expense_scene_selection")
|
||||
)
|
||||
|
||||
def pick(*keys: str) -> str:
|
||||
for source in (review_values, scene_selection, context_json):
|
||||
for key in keys:
|
||||
value = str(source.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
application_claim_no = pick("application_claim_no", "applicationClaimNo")
|
||||
if not application_claim_no:
|
||||
return None
|
||||
|
||||
application_claim_id = pick("application_claim_id", "applicationClaimId")
|
||||
application_amount = pick("application_amount", "applicationAmount")
|
||||
application_amount_label = pick("application_amount_label", "applicationAmountLabel")
|
||||
application_reason = pick("application_reason", "applicationReason", "reason")
|
||||
application_location = pick("application_location", "applicationLocation", "location")
|
||||
application_time = pick(
|
||||
"application_business_time",
|
||||
"applicationBusinessTime",
|
||||
"application_time",
|
||||
"applicationTime",
|
||||
"business_time",
|
||||
"businessTime",
|
||||
"time_range",
|
||||
"timeRange",
|
||||
"time",
|
||||
)
|
||||
application_date = pick("application_date", "applicationDate")
|
||||
application_days = pick("application_days", "applicationDays", "days")
|
||||
application_transport_mode = pick("application_transport_mode", "applicationTransportMode", "transport_mode", "transportMode")
|
||||
application_lodging_daily_cap = pick("application_lodging_daily_cap", "applicationLodgingDailyCap", "lodging_daily_cap", "lodgingDailyCap")
|
||||
application_subsidy_daily_cap = pick("application_subsidy_daily_cap", "applicationSubsidyDailyCap", "subsidy_daily_cap", "subsidyDailyCap")
|
||||
application_transport_policy = pick("application_transport_policy", "applicationTransportPolicy", "transport_policy", "transportPolicy")
|
||||
application_policy_estimate = pick("application_policy_estimate", "applicationPolicyEstimate", "policy_estimate", "policyEstimate")
|
||||
application_rule_name = pick("application_rule_name", "applicationRuleName", "rule_name", "ruleName")
|
||||
application_rule_version = pick("application_rule_version", "applicationRuleVersion", "rule_version", "ruleVersion")
|
||||
application_status = pick("application_status", "applicationStatus")
|
||||
application_status_label = pick("application_status_label", "applicationStatusLabel")
|
||||
|
||||
return {
|
||||
"source": "application_link",
|
||||
"event_type": "expense_reimbursement_application_linked",
|
||||
"severity": "info",
|
||||
"label": "关联申请单",
|
||||
"message": f"报销草稿已关联申请单 {application_claim_no}。",
|
||||
"application_claim_id": application_claim_id,
|
||||
"application_claim_no": application_claim_no,
|
||||
"application_amount_label": application_amount_label,
|
||||
"application_status": application_status,
|
||||
"application_status_label": application_status_label,
|
||||
"application_detail": {
|
||||
"application_reason": application_reason,
|
||||
"application_location": application_location,
|
||||
"application_amount": application_amount,
|
||||
"application_amount_label": application_amount_label,
|
||||
"application_time": application_time or application_date,
|
||||
"application_business_time": application_time,
|
||||
"application_date": application_date,
|
||||
"application_days": application_days,
|
||||
"application_transport_mode": application_transport_mode,
|
||||
"application_lodging_daily_cap": application_lodging_daily_cap,
|
||||
"application_subsidy_daily_cap": application_subsidy_daily_cap,
|
||||
"application_transport_policy": application_transport_policy,
|
||||
"application_policy_estimate": application_policy_estimate,
|
||||
"application_rule_name": application_rule_name,
|
||||
"application_rule_version": application_rule_version,
|
||||
},
|
||||
"review_form_values": review_values,
|
||||
"expense_scene_selection": scene_selection,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_context_object(value: Any) -> dict[str, Any]:
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
class ExpenseClaimDraftAttachmentAssociationMixin:
|
||||
class ExpenseClaimDraftAttachmentAssociationMixin(ExpenseClaimTenantScopeMixin):
|
||||
def _find_target_claim(
|
||||
self,
|
||||
*,
|
||||
@@ -556,7 +40,16 @@ class ExpenseClaimDraftAttachmentAssociationMixin:
|
||||
|
||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
||||
if draft_claim_id:
|
||||
claim = self.db.get(ExpenseClaim, draft_claim_id)
|
||||
claim = self.db.scalar(
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.id == draft_claim_id)
|
||||
.where(
|
||||
self.build_claim_tenant_condition(
|
||||
self.normalize_context_tenant_id(context_json)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if claim is not None and self._is_editable_claim_status(claim.status):
|
||||
return claim
|
||||
return None
|
||||
@@ -573,6 +66,11 @@ class ExpenseClaimDraftAttachmentAssociationMixin:
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.claim_no.in_(claim_codes))
|
||||
.where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES))
|
||||
.where(
|
||||
self.build_claim_tenant_condition(
|
||||
self.normalize_context_tenant_id(context_json)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return self.db.scalar(stmt)
|
||||
@@ -587,7 +85,16 @@ class ExpenseClaimDraftAttachmentAssociationMixin:
|
||||
) -> ExpenseClaim | None:
|
||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
||||
if draft_claim_id:
|
||||
claim = self.db.get(ExpenseClaim, draft_claim_id)
|
||||
claim = self.db.scalar(
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.id == draft_claim_id)
|
||||
.where(
|
||||
self.build_claim_tenant_condition(
|
||||
self.normalize_context_tenant_id(context_json)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if claim is not None and self._is_editable_claim_status(claim.status):
|
||||
return claim
|
||||
|
||||
@@ -612,6 +119,11 @@ class ExpenseClaimDraftAttachmentAssociationMixin:
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES))
|
||||
.where(or_(*owner_filters))
|
||||
.where(
|
||||
self.build_claim_tenant_condition(
|
||||
self.normalize_context_tenant_id(context_json)
|
||||
)
|
||||
)
|
||||
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@@ -1024,6 +536,12 @@ class ExpenseClaimDraftFlowMixin(ExpenseClaimApplicationLinkMixin, ExpenseClaimD
|
||||
claim,
|
||||
event_type=("claim_draft_created" if is_new_claim else "claim_draft_updated"),
|
||||
actor_id=user_id or claim.employee_name or "system",
|
||||
tenant_id=str(
|
||||
context_json.get("tenant_id")
|
||||
or context_json.get("tenantId")
|
||||
or "default"
|
||||
).strip()
|
||||
or "default",
|
||||
correlation_id=run_id,
|
||||
idempotency_key=run_id,
|
||||
previous_status=str((before_json or {}).get("status") or ""),
|
||||
|
||||
@@ -5,3 +5,21 @@ class ExpenseClaimSubmissionBlockedError(ValueError):
|
||||
def __init__(self, issues: list[str]) -> None:
|
||||
self.issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()]
|
||||
super().__init__("提交前请先补全信息:" + ";".join(self.issues))
|
||||
|
||||
|
||||
class ExpenseClaimPreReviewBlockedError(ValueError):
|
||||
def __init__(
|
||||
self,
|
||||
review: dict,
|
||||
*,
|
||||
code: str = "PRE_REVIEW_NEEDS_FIX",
|
||||
) -> None:
|
||||
self.review = dict(review or {})
|
||||
self.code = str(code or "PRE_REVIEW_NEEDS_FIX").strip()
|
||||
fallback = (
|
||||
"提交前风险环境已变化,请确认最新预审结果后重新提交。"
|
||||
if self.code == "PRE_REVIEW_CHANGED"
|
||||
else "预审发现需先整改的风险。"
|
||||
)
|
||||
message = str(self.review.get("message") or fallback).strip()
|
||||
super().__init__(message)
|
||||
|
||||
@@ -20,16 +20,14 @@ from app.services.expense_claim_platform_context_tools import (
|
||||
extract_known_cities_from_text,
|
||||
resolve_first_document_field_value,
|
||||
)
|
||||
from app.services.expense_rule_runtime import (
|
||||
RuntimeTravelPolicy,
|
||||
)
|
||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||
from app.services.expense_claim_platform_route_risk import resolve_multi_city_related_item_ids
|
||||
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
|
||||
from app.services.expense_claim_platform_route_risk import resolve_multi_city_related_item_ids
|
||||
from app.services.expense_claim_platform_text_risk import (
|
||||
collect_vague_goods_description_evidence,
|
||||
)
|
||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||
from app.services.expense_claim_rule_fingerprint import build_risk_manifest_fingerprint
|
||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
|
||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
@@ -38,21 +36,25 @@ from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
class ExpenseClaimPlatformRiskMixin:
|
||||
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
|
||||
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
|
||||
|
||||
def evaluate_platform_risk_rules(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
rule_codes: list[str] | None = None,
|
||||
business_stage: str | None = None,
|
||||
) -> dict[str, list[Any]]:
|
||||
) -> dict[str, Any]:
|
||||
normalized_stage = self._normalize_platform_risk_business_stage(business_stage)
|
||||
manifests = self._load_platform_risk_rule_manifests(
|
||||
rule_codes=rule_codes,
|
||||
business_stage=normalized_stage,
|
||||
)
|
||||
rule_set_fingerprint = build_risk_manifest_fingerprint(manifests)
|
||||
if not manifests:
|
||||
return {"flags": [], "blocking_reasons": []}
|
||||
return {
|
||||
"flags": [],
|
||||
"blocking_reasons": [],
|
||||
"rule_set_fingerprint": rule_set_fingerprint,
|
||||
}
|
||||
|
||||
contexts = self._build_claim_attachment_contexts(claim)
|
||||
contexts.append(
|
||||
@@ -71,7 +73,6 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
for manifest in manifests:
|
||||
if not self._risk_manifest_applies_to_claim(manifest, claim=claim, contexts=contexts):
|
||||
continue
|
||||
|
||||
flag = self._evaluate_platform_risk_manifest(
|
||||
manifest,
|
||||
claim=claim,
|
||||
@@ -94,7 +95,22 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
blocking_reasons.append(str(flag.get("message") or flag.get("label") or "").strip())
|
||||
|
||||
deduplicated_reasons = list(dict.fromkeys(reason for reason in blocking_reasons if reason))
|
||||
return {"flags": flags, "blocking_reasons": deduplicated_reasons}
|
||||
return {
|
||||
"flags": flags,
|
||||
"blocking_reasons": deduplicated_reasons,
|
||||
"rule_set_fingerprint": rule_set_fingerprint,
|
||||
}
|
||||
|
||||
def platform_risk_rule_set_fingerprint(
|
||||
self,
|
||||
*,
|
||||
business_stage: str,
|
||||
) -> str:
|
||||
manifests = self._load_platform_risk_rule_manifests(
|
||||
rule_codes=None,
|
||||
business_stage=self._normalize_platform_risk_business_stage(business_stage),
|
||||
)
|
||||
return build_risk_manifest_fingerprint(manifests)
|
||||
|
||||
def _load_platform_risk_rule_manifests(
|
||||
self,
|
||||
|
||||
@@ -6,8 +6,12 @@ from typing import Any
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
||||
from app.services.expense_claim_pre_review_decision import build_pre_review_decision
|
||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||
from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimPreReviewMixin:
|
||||
@@ -15,6 +19,9 @@ class ExpenseClaimPreReviewMixin:
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
@@ -34,48 +41,32 @@ class ExpenseClaimPreReviewMixin:
|
||||
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
reviewed_at = datetime.now(UTC)
|
||||
if is_application_claim:
|
||||
preserved_flags = [
|
||||
flag
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if not (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
and str(flag.get("hit_source") or "").strip() == "rule_center"
|
||||
)
|
||||
]
|
||||
application_review = self.evaluate_platform_risk_rules(
|
||||
claim,
|
||||
business_stage="expense_application",
|
||||
)
|
||||
review_flags = dedupe_claim_risk_flags(
|
||||
[*preserved_flags, *list(application_review.get("flags") or [])]
|
||||
)
|
||||
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
|
||||
passed = blocking_count <= 0
|
||||
else:
|
||||
review_result = self._run_ai_submission_review(claim)
|
||||
review_flags = list(review_result.get("risk_flags") or [])
|
||||
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
|
||||
passed = blocking_count <= 0
|
||||
|
||||
claim.risk_flags_json = self._replace_ai_pre_review_flag(
|
||||
review_flags,
|
||||
self._build_ai_pre_review_flag(
|
||||
passed=passed,
|
||||
blocking_count=blocking_count,
|
||||
reviewed_at=reviewed_at,
|
||||
business_stage=risk_business_stage_for_claim(
|
||||
is_application_claim=is_application_claim,
|
||||
),
|
||||
),
|
||||
pre_review_flag = self.refresh_claim_pre_review_state(
|
||||
claim,
|
||||
is_application_claim=is_application_claim,
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
if pre_review_flag is None:
|
||||
raise RuntimeError("无法生成费用预审结果。")
|
||||
claim.approval_stage = "待提交" if not is_application_claim else claim.approval_stage
|
||||
claim.submitted_at = None
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
normalized_correlation_id = self._expense_cases.normalize_correlation_id(
|
||||
correlation_id or idempotency_key or str(pre_review_flag.get("review_id") or "")
|
||||
)
|
||||
try:
|
||||
self._record_pre_review_event(
|
||||
claim,
|
||||
pre_review_flag=pre_review_flag,
|
||||
current_user=current_user,
|
||||
is_application_claim=is_application_claim,
|
||||
correlation_id=normalized_correlation_id,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=current_user.name or current_user.username,
|
||||
@@ -87,43 +78,31 @@ class ExpenseClaimPreReviewMixin:
|
||||
)
|
||||
return claim
|
||||
|
||||
@staticmethod
|
||||
def _count_ai_pre_review_blocking_risks(risk_flags: list[Any]) -> int:
|
||||
return sum(
|
||||
1
|
||||
for flag in risk_flags
|
||||
if (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() != "ai_pre_review"
|
||||
and str(flag.get("severity") or "").strip().lower() == "high"
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_ai_pre_review_flag(
|
||||
*,
|
||||
passed: bool,
|
||||
blocking_count: int,
|
||||
reviewed_at: datetime,
|
||||
decision_payload: dict[str, Any],
|
||||
business_stage: str,
|
||||
) -> dict[str, Any]:
|
||||
if passed:
|
||||
message = "自动检测通过,费用明细和附件可提交审批。"
|
||||
else:
|
||||
message = f"自动检测发现 {blocking_count} 条重大风险,请逐条填写原因后再提交审批。"
|
||||
decision = str(decision_payload.get("decision") or "ready_with_review")
|
||||
passed = decision != "needs_fix"
|
||||
blocking_count = int(decision_payload.get("blocking_count") or 0)
|
||||
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
"source": "ai_pre_review",
|
||||
"event_type": "expense_claim_ai_pre_review",
|
||||
"severity": "info" if passed else "high",
|
||||
"label": "自动检测通过" if passed else "自动检测未通过",
|
||||
"message": message,
|
||||
"label": "自动检测通过" if decision == "ready" else (
|
||||
"自动检测待复核" if passed else "自动检测未通过"
|
||||
),
|
||||
"message": str(decision_payload.get("message") or ""),
|
||||
"status": "passed" if passed else "failed",
|
||||
"passed": passed,
|
||||
"blocking_risk_count": blocking_count,
|
||||
**decision_payload,
|
||||
"next_action": "next_step" if passed else "risk_explanation_required",
|
||||
"created_at": reviewed_at.isoformat(),
|
||||
"created_at": str(decision_payload.get("reviewed_at") or ""),
|
||||
},
|
||||
business_stage,
|
||||
)
|
||||
@@ -143,15 +122,29 @@ class ExpenseClaimPreReviewMixin:
|
||||
]
|
||||
return [*preserved_flags, next_flag]
|
||||
|
||||
def refresh_claim_pre_review_state(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
is_application_claim: bool | None = None,
|
||||
reviewed_at: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""业务变更事务内刷新预审快照,不提交、不单独写事件。"""
|
||||
return self._refresh_claim_pre_review_flags(
|
||||
claim,
|
||||
is_application_claim=is_application_claim,
|
||||
reviewed_at=reviewed_at,
|
||||
)
|
||||
|
||||
def _refresh_claim_pre_review_flags(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
is_application_claim: bool | None = None,
|
||||
reviewed_at: datetime | None = None,
|
||||
) -> bool:
|
||||
) -> dict[str, Any] | None:
|
||||
if claim is None:
|
||||
return False
|
||||
return None
|
||||
|
||||
if is_application_claim is None:
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
@@ -174,31 +167,79 @@ class ExpenseClaimPreReviewMixin:
|
||||
review_flags = dedupe_claim_risk_flags(
|
||||
[*preserved_flags, *list(application_review.get("flags") or [])]
|
||||
)
|
||||
platform_rule_set_fingerprint = str(
|
||||
application_review.get("rule_set_fingerprint") or ""
|
||||
)
|
||||
else:
|
||||
review_result = self._run_ai_submission_review(claim)
|
||||
review_flags = list(review_result.get("risk_flags") or [])
|
||||
platform_rule_set_fingerprint = str(
|
||||
review_result.get("rule_set_fingerprint") or ""
|
||||
)
|
||||
|
||||
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
|
||||
business_stage = risk_business_stage_for_claim(
|
||||
is_application_claim=is_application_claim,
|
||||
)
|
||||
decision_payload = build_pre_review_decision(
|
||||
claim,
|
||||
risk_flags=review_flags,
|
||||
business_stage=business_stage,
|
||||
platform_rule_set_fingerprint=platform_rule_set_fingerprint,
|
||||
reviewed_at=reviewed_at,
|
||||
)
|
||||
pre_review_flag = self._build_ai_pre_review_flag(
|
||||
decision_payload=decision_payload,
|
||||
business_stage=business_stage,
|
||||
)
|
||||
claim.risk_flags_json = self._replace_ai_pre_review_flag(
|
||||
review_flags,
|
||||
self._build_ai_pre_review_flag(
|
||||
passed=blocking_count <= 0,
|
||||
blocking_count=blocking_count,
|
||||
reviewed_at=reviewed_at,
|
||||
business_stage=risk_business_stage_for_claim(
|
||||
is_application_claim=is_application_claim,
|
||||
),
|
||||
),
|
||||
pre_review_flag,
|
||||
)
|
||||
if not is_application_claim:
|
||||
claim.approval_stage = "\u5f85\u63d0\u4ea4"
|
||||
claim.submitted_at = None
|
||||
return True
|
||||
return pre_review_flag
|
||||
|
||||
@staticmethod
|
||||
def _has_ai_pre_review_flag(claim: ExpenseClaim) -> bool:
|
||||
return any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "ai_pre_review"
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
def _record_pre_review_event(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
pre_review_flag: dict[str, Any],
|
||||
current_user: CurrentUserContext,
|
||||
is_application_claim: bool,
|
||||
correlation_id: str,
|
||||
):
|
||||
review_id = str(pre_review_flag.get("review_id") or "").strip()
|
||||
if not review_id:
|
||||
raise ValueError("预审结果缺少 review_id。")
|
||||
return self._expense_cases.record_claim_event(
|
||||
claim,
|
||||
event_type=(
|
||||
"application_pre_review_completed"
|
||||
if is_application_claim
|
||||
else "claim_pre_review_completed"
|
||||
),
|
||||
actor_id=current_user.username or current_user.name,
|
||||
tenant_id=current_user.tenant_id,
|
||||
correlation_id=correlation_id,
|
||||
idempotency_key=f"pre-review:{review_id}",
|
||||
update_case_state=False,
|
||||
extra_payload={
|
||||
"review_id": review_id,
|
||||
"input_fingerprint": str(pre_review_flag.get("input_fingerprint") or ""),
|
||||
"rule_set_fingerprint": str(
|
||||
pre_review_flag.get("rule_set_fingerprint") or ""
|
||||
),
|
||||
"review_context_fingerprint": str(
|
||||
pre_review_flag.get("review_context_fingerprint") or ""
|
||||
),
|
||||
"decision": str(pre_review_flag.get("decision") or ""),
|
||||
"review_status": str(pre_review_flag.get("status") or ""),
|
||||
"passed": bool(pre_review_flag.get("passed")),
|
||||
"blocking_risk_count": int(
|
||||
pre_review_flag.get("blocking_risk_count") or 0
|
||||
),
|
||||
"business_stage": str(pre_review_flag.get("business_stage") or ""),
|
||||
"message": str(pre_review_flag.get("message") or ""),
|
||||
},
|
||||
)
|
||||
|
||||
394
server/src/app/services/expense_claim_pre_review_decision.py
Normal file
394
server/src/app/services/expense_claim_pre_review_decision.py
Normal file
@@ -0,0 +1,394 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
|
||||
|
||||
PRE_REVIEW_PIPELINE_VERSION = "2026-07-16.1"
|
||||
PRE_REVIEW_DECISIONS = {"ready", "needs_fix", "ready_with_review"}
|
||||
_DERIVED_RISK_SOURCES = {
|
||||
"ai_pre_review",
|
||||
"application_submission",
|
||||
"approval",
|
||||
"approval_log",
|
||||
"approval_routing",
|
||||
"budget_approval",
|
||||
"expense_claim_approval",
|
||||
"expense_claim_finance_approval",
|
||||
"finance_approval",
|
||||
"manual_approval",
|
||||
"payment",
|
||||
"submission_review",
|
||||
}
|
||||
|
||||
|
||||
def build_pre_review_input_fingerprint(claim: ExpenseClaim) -> str:
|
||||
payload = {
|
||||
"claim": {
|
||||
"id": _text(claim.id),
|
||||
"employee_id": _text(claim.employee_id),
|
||||
"employee_name": _text(claim.employee_name),
|
||||
"department_id": _text(claim.department_id),
|
||||
"department_name": _text(claim.department_name),
|
||||
"project_code": _text(claim.project_code),
|
||||
"expense_type": _text(claim.expense_type),
|
||||
"reason": _text(claim.reason),
|
||||
"location": _text(claim.location),
|
||||
"amount": _money(claim.amount),
|
||||
"currency": _text(claim.currency),
|
||||
"occurred_at": _date_time(claim.occurred_at),
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": _text(item.id),
|
||||
"item_date": _date_time(item.item_date),
|
||||
"item_type": _text(item.item_type),
|
||||
"item_reason": _text(item.item_reason),
|
||||
"item_location": _text(item.item_location),
|
||||
"item_note": _text(item.item_note),
|
||||
"item_amount": _money(item.item_amount),
|
||||
"invoice_id": _text(item.invoice_id),
|
||||
}
|
||||
for item in sorted(list(claim.items or []), key=lambda entry: _text(entry.id))
|
||||
],
|
||||
"risk_inputs": sorted(
|
||||
[
|
||||
_strip_volatile_fields(flag)
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if isinstance(flag, dict)
|
||||
and _text(flag.get("source")).lower() not in _DERIVED_RISK_SOURCES
|
||||
],
|
||||
key=_canonical_json,
|
||||
),
|
||||
}
|
||||
return _fingerprint(payload)
|
||||
|
||||
|
||||
def build_pre_review_rule_set_fingerprint(platform_rule_set_fingerprint: str) -> str:
|
||||
return _fingerprint(
|
||||
{
|
||||
"pipeline_version": PRE_REVIEW_PIPELINE_VERSION,
|
||||
"platform_rule_set_fingerprint": _text(platform_rule_set_fingerprint),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def build_pre_review_decision(
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
risk_flags: list[Any],
|
||||
business_stage: str,
|
||||
platform_rule_set_fingerprint: str,
|
||||
reviewed_at: datetime,
|
||||
) -> dict[str, Any]:
|
||||
input_fingerprint = build_pre_review_input_fingerprint(claim)
|
||||
rule_set_fingerprint = build_pre_review_rule_set_fingerprint(
|
||||
platform_rule_set_fingerprint
|
||||
)
|
||||
findings = _build_findings(risk_flags, business_stage=business_stage)
|
||||
review_context_fingerprint = _fingerprint(findings)
|
||||
blocking_findings = [
|
||||
finding
|
||||
for finding in findings
|
||||
if finding["severity"] in {"critical", "high"}
|
||||
and finding["disposition"] == "fix"
|
||||
and finding["resolution_status"] == "unresolved"
|
||||
]
|
||||
if blocking_findings:
|
||||
decision = "needs_fix"
|
||||
message = (
|
||||
f"自动检测发现 {len(blocking_findings)} 条需先整改的重大风险,"
|
||||
"请按建议处理后重新预审。"
|
||||
)
|
||||
elif findings:
|
||||
decision = "ready_with_review"
|
||||
message = "自动检测已完成,当前风险可随单进入审批并由对应角色复核。"
|
||||
else:
|
||||
decision = "ready"
|
||||
message = "自动检测通过,费用明细和附件可提交审批。"
|
||||
|
||||
review_id = str(
|
||||
uuid.uuid5(
|
||||
uuid.NAMESPACE_URL,
|
||||
":".join(
|
||||
[
|
||||
"expense-claim-pre-review",
|
||||
_text(claim.id),
|
||||
input_fingerprint,
|
||||
rule_set_fingerprint,
|
||||
review_context_fingerprint,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"review_id": review_id,
|
||||
"input_fingerprint": input_fingerprint,
|
||||
"rule_set_fingerprint": rule_set_fingerprint,
|
||||
"review_context_fingerprint": review_context_fingerprint,
|
||||
"pipeline_version": PRE_REVIEW_PIPELINE_VERSION,
|
||||
"reviewed_at": reviewed_at.isoformat(),
|
||||
"decision": decision,
|
||||
"passed": decision != "needs_fix",
|
||||
"blocking_count": len(blocking_findings),
|
||||
"findings": findings,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
def find_pre_review_flag(claim: ExpenseClaim) -> dict[str, Any] | None:
|
||||
for flag in reversed(list(claim.risk_flags_json or [])):
|
||||
if isinstance(flag, dict) and _text(flag.get("source")) == "ai_pre_review":
|
||||
return flag
|
||||
return None
|
||||
|
||||
|
||||
def is_pre_review_current(
|
||||
claim: ExpenseClaim,
|
||||
flag: dict[str, Any] | None,
|
||||
*,
|
||||
platform_rule_set_fingerprint: str,
|
||||
) -> bool:
|
||||
if not isinstance(flag, dict):
|
||||
return False
|
||||
return bool(
|
||||
_text(flag.get("review_id"))
|
||||
and _text(flag.get("input_fingerprint"))
|
||||
== build_pre_review_input_fingerprint(claim)
|
||||
and _text(flag.get("rule_set_fingerprint"))
|
||||
== build_pre_review_rule_set_fingerprint(platform_rule_set_fingerprint)
|
||||
and _text(flag.get("decision")) in PRE_REVIEW_DECISIONS
|
||||
)
|
||||
|
||||
|
||||
def pre_review_identity_matches(
|
||||
flag: dict[str, Any] | None,
|
||||
*,
|
||||
review_id: str,
|
||||
input_fingerprint: str,
|
||||
) -> bool:
|
||||
if not isinstance(flag, dict):
|
||||
return False
|
||||
normalized_review_id = _text(review_id)
|
||||
normalized_input_fingerprint = _text(input_fingerprint)
|
||||
if normalized_review_id and _text(flag.get("review_id")) != normalized_review_id:
|
||||
return False
|
||||
if (
|
||||
normalized_input_fingerprint
|
||||
and _text(flag.get("input_fingerprint")) != normalized_input_fingerprint
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def pre_review_public_payload(flag: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not isinstance(flag, dict) or not _text(flag.get("review_id")):
|
||||
return None
|
||||
return {
|
||||
"review_id": _text(flag.get("review_id")),
|
||||
"input_fingerprint": _text(flag.get("input_fingerprint")),
|
||||
"rule_set_fingerprint": _text(flag.get("rule_set_fingerprint")),
|
||||
"review_context_fingerprint": _text(
|
||||
flag.get("review_context_fingerprint")
|
||||
),
|
||||
"pipeline_version": _text(flag.get("pipeline_version")),
|
||||
"reviewed_at": _text(flag.get("reviewed_at") or flag.get("created_at")),
|
||||
"decision": _text(flag.get("decision")) or "ready_with_review",
|
||||
"passed": bool(flag.get("passed")),
|
||||
"blocking_count": int(
|
||||
flag.get("blocking_count") or flag.get("blocking_risk_count") or 0
|
||||
),
|
||||
"message": _text(flag.get("message")),
|
||||
"findings": [
|
||||
dict(item)
|
||||
for item in list(flag.get("findings") or [])
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_findings(
|
||||
risk_flags: list[Any],
|
||||
*,
|
||||
business_stage: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
findings: list[dict[str, Any]] = []
|
||||
for flag in list(risk_flags or []):
|
||||
if not isinstance(flag, dict) or _text(flag.get("source")) == "ai_pre_review":
|
||||
continue
|
||||
enriched = enrich_risk_flag_semantics(flag, business_stage=business_stage)
|
||||
severity = _text(
|
||||
enriched.get("severity") or enriched.get("tone") or enriched.get("level")
|
||||
).lower()
|
||||
if severity not in {"medium", "high", "critical"}:
|
||||
continue
|
||||
actionability = _text(enriched.get("actionability")).lower()
|
||||
if actionability == "system_trace":
|
||||
continue
|
||||
item_ids = _item_ids(enriched)
|
||||
resolution_status = _resolution_status(enriched)
|
||||
disposition = "fix" if actionability == "fixable_by_submitter" else "review"
|
||||
message = _text(
|
||||
enriched.get("message")
|
||||
or enriched.get("summary")
|
||||
or enriched.get("reason")
|
||||
or enriched.get("label")
|
||||
)
|
||||
risk_key = {
|
||||
"source": _text(enriched.get("source")),
|
||||
"rule_code": _text(enriched.get("rule_code")),
|
||||
"severity": severity,
|
||||
"item_ids": item_ids,
|
||||
"message": message,
|
||||
}
|
||||
findings.append(
|
||||
{
|
||||
"risk_id": _text(enriched.get("risk_id"))
|
||||
or f"risk:{_fingerprint(risk_key).removeprefix('sha256:')[:24]}",
|
||||
"rule_code": _text(enriched.get("rule_code")),
|
||||
"rule_version": _text(enriched.get("rule_version")),
|
||||
"severity": severity,
|
||||
"disposition": disposition,
|
||||
"resolution_status": resolution_status,
|
||||
"actionability": actionability,
|
||||
"source": _text(enriched.get("source")) or "pre_review_finding",
|
||||
"business_stage": _text(enriched.get("business_stage"))
|
||||
or business_stage,
|
||||
"risk_domain": _text(
|
||||
enriched.get("risk_domain") or enriched.get("riskDomain")
|
||||
),
|
||||
"visibility_scope": _text(
|
||||
enriched.get("visibility_scope")
|
||||
or enriched.get("visibilityScope")
|
||||
),
|
||||
"item_ids": item_ids,
|
||||
"message": message,
|
||||
"remediation": _build_remediation(
|
||||
disposition=disposition,
|
||||
item_ids=item_ids,
|
||||
flag=enriched,
|
||||
),
|
||||
}
|
||||
)
|
||||
return sorted(
|
||||
findings,
|
||||
key=lambda finding: (
|
||||
{"critical": 0, "high": 1, "medium": 2}.get(finding["severity"], 9),
|
||||
finding["risk_id"],
|
||||
),
|
||||
)[:50]
|
||||
|
||||
|
||||
def _build_remediation(
|
||||
*,
|
||||
disposition: str,
|
||||
item_ids: list[str],
|
||||
flag: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if disposition != "fix":
|
||||
return {
|
||||
"action": "manual_review",
|
||||
"target_item_ids": item_ids,
|
||||
"required_fields": [],
|
||||
}
|
||||
corpus = " ".join(
|
||||
_text(flag.get(key))
|
||||
for key in ("label", "message", "summary", "rule_code")
|
||||
)
|
||||
remediation = {
|
||||
"action": "edit_item_note" if item_ids else "edit_claim",
|
||||
"target_item_ids": item_ids,
|
||||
"required_fields": ["item_note"] if item_ids else [],
|
||||
}
|
||||
if any(token in corpus for token in ("超标", "标准", "住宿", "金额")):
|
||||
remediation["alternative_action"] = "accept_standard_limit"
|
||||
return remediation
|
||||
|
||||
|
||||
def _resolution_status(flag: dict[str, Any]) -> str:
|
||||
explicit = _text(flag.get("resolution_status") or flag.get("resolutionStatus")).lower()
|
||||
if explicit in {"resolved", "accepted", "waived"}:
|
||||
return "resolved"
|
||||
if explicit in {"unresolved", "open", "pending"}:
|
||||
return "unresolved"
|
||||
return "resolved" if bool(flag.get("resolved")) else "unresolved"
|
||||
|
||||
|
||||
def _item_ids(flag: dict[str, Any]) -> list[str]:
|
||||
raw_values = [
|
||||
flag.get("item_id"),
|
||||
flag.get("itemId"),
|
||||
*_as_list(flag.get("item_ids")),
|
||||
*_as_list(flag.get("itemIds")),
|
||||
]
|
||||
return sorted(
|
||||
dict.fromkeys(_text(value) for value in raw_values if _text(value))
|
||||
)
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, (tuple, set)):
|
||||
return list(value)
|
||||
return [value]
|
||||
|
||||
|
||||
def _strip_volatile_fields(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return sorted(
|
||||
[_strip_volatile_fields(item) for item in value],
|
||||
key=_canonical_json,
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
volatile_keys = {
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"review_id",
|
||||
"input_fingerprint",
|
||||
"rule_set_fingerprint",
|
||||
"reviewed_at",
|
||||
}
|
||||
return {
|
||||
str(key): _strip_volatile_fields(item)
|
||||
for key, item in value.items()
|
||||
if str(key) not in volatile_keys
|
||||
}
|
||||
|
||||
|
||||
def _fingerprint(value: Any) -> str:
|
||||
canonical = _canonical_json(value)
|
||||
return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> str:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
def _date_time(value: Any) -> str:
|
||||
if hasattr(value, "isoformat"):
|
||||
return str(value.isoformat())
|
||||
return _text(value)
|
||||
|
||||
|
||||
def _money(value: Any) -> str:
|
||||
return f"{Decimal(value or Decimal('0.00')).quantize(Decimal('0.01')):.2f}"
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
@@ -154,6 +154,12 @@ class ExpenseClaimReviewPreviewMixin:
|
||||
if str(item).strip()
|
||||
],
|
||||
is_admin=bool(context_json.get("is_admin")),
|
||||
tenant_id=str(
|
||||
context_json.get("tenant_id")
|
||||
or context_json.get("tenantId")
|
||||
or "default"
|
||||
).strip()
|
||||
or "default",
|
||||
department_name=str(context_json.get("department_name") or context_json.get("department") or "").strip(),
|
||||
)
|
||||
|
||||
@@ -344,6 +350,12 @@ class ExpenseClaimReviewPreviewMixin:
|
||||
name=str(context_json.get("name") or user_id or "anonymous").strip() or "anonymous",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
tenant_id=str(
|
||||
context_json.get("tenant_id")
|
||||
or context_json.get("tenantId")
|
||||
or "default"
|
||||
).strip()
|
||||
or "default",
|
||||
),
|
||||
)
|
||||
except ValueError:
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMix
|
||||
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin
|
||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
logger = get_logger("app.services.expense_claim_risk_review")
|
||||
@@ -110,6 +111,9 @@ class ExpenseClaimRiskReviewMixin(
|
||||
"status": "submitted",
|
||||
"approval_stage": "直属领导审批",
|
||||
"risk_flags": final_risk_flags,
|
||||
"rule_set_fingerprint": str(
|
||||
platform_risk_review.get("rule_set_fingerprint") or ""
|
||||
),
|
||||
"message": (
|
||||
f"报销单 {claim.claim_no} 已完成自动检测,"
|
||||
f"现已提交给直属领导 {manager_name or '审批人'} 审批。"
|
||||
@@ -144,9 +148,15 @@ class ExpenseClaimRiskReviewMixin(
|
||||
.where(or_(*filters))
|
||||
.where(ExpenseClaim.id != claim.id)
|
||||
.where(ExpenseClaim.occurred_at >= since)
|
||||
.where(
|
||||
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(
|
||||
ExpenseClaimTenantScopeMixin.resolve_claim_tenant_id(
|
||||
self.db,
|
||||
claim.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
recent_claims = list(self.db.scalars(stmt).all())
|
||||
return sum(1 for item in recent_claims if list(item.risk_flags_json or []))
|
||||
|
||||
|
||||
|
||||
|
||||
27
server/src/app/services/expense_claim_rule_fingerprint.py
Normal file
27
server/src/app/services/expense_claim_rule_fingerprint.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_risk_manifest_fingerprint(manifests: list[dict[str, Any]]) -> str:
|
||||
ordered_manifests = sorted(
|
||||
manifests,
|
||||
key=lambda manifest: json.dumps(
|
||||
manifest,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
),
|
||||
)
|
||||
normalized = json.dumps(
|
||||
ordered_manifests,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
return f"sha256:{digest}"
|
||||
297
server/src/app/services/expense_claim_standard_adjustment.py
Normal file
297
server/src/app/services/expense_claim_standard_adjustment.py
Normal file
@@ -0,0 +1,297 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseClaimStandardAdjustmentPayload,
|
||||
TravelReimbursementCalculatorRequest,
|
||||
)
|
||||
from app.services.expense_claim_constants import STANDARD_ADJUSTMENT_RISK_SOURCE
|
||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
|
||||
|
||||
class ExpenseClaimStandardAdjustmentMixin:
|
||||
@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}"
|
||||
|
||||
@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,
|
||||
*,
|
||||
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._resolve_standard_adjustment_reimbursable_amount(
|
||||
claim=claim,
|
||||
item=item,
|
||||
entry=entry,
|
||||
original_amount=original_amount,
|
||||
current_user=current_user,
|
||||
)
|
||||
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 = dedupe_claim_risk_flags(
|
||||
[*preserved_flags, *adjustment_flags]
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
self.refresh_claim_pre_review_state(claim, is_application_claim=False)
|
||||
|
||||
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
|
||||
73
server/src/app/services/expense_claim_tenant_scope.py
Normal file
73
server/src/app/services/expense_claim_tenant_scope.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.expense_case import ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
|
||||
DEFAULT_TENANT_ID = "default"
|
||||
|
||||
|
||||
class ExpenseClaimTenantScopeMixin:
|
||||
@staticmethod
|
||||
def normalize_tenant_id(value: str | None) -> str:
|
||||
return str(value or DEFAULT_TENANT_ID).strip() or DEFAULT_TENANT_ID
|
||||
|
||||
@classmethod
|
||||
def normalize_context_tenant_id(cls, context_json: dict[str, Any] | None) -> str:
|
||||
context = context_json or {}
|
||||
return cls.normalize_tenant_id(
|
||||
context.get("tenant_id") or context.get("tenantId")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def build_claim_tenant_condition(cls, tenant_id: str | None) -> Any:
|
||||
"""按 Expense Case Link 隔离 Claim;默认租户兼容尚未回填的旧单。"""
|
||||
|
||||
normalized_tenant = cls.normalize_tenant_id(tenant_id)
|
||||
same_tenant_link = (
|
||||
select(ExpenseCaseLink.id)
|
||||
.where(
|
||||
ExpenseCaseLink.resource_type == "expense_claim",
|
||||
ExpenseCaseLink.resource_id == ExpenseClaim.id,
|
||||
ExpenseCaseLink.tenant_id == normalized_tenant,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
if normalized_tenant != DEFAULT_TENANT_ID:
|
||||
return same_tenant_link
|
||||
|
||||
any_tenant_link = (
|
||||
select(ExpenseCaseLink.id)
|
||||
.where(
|
||||
ExpenseCaseLink.resource_type == "expense_claim",
|
||||
ExpenseCaseLink.resource_id == ExpenseClaim.id,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
return or_(same_tenant_link, ~any_tenant_link)
|
||||
|
||||
@classmethod
|
||||
def resolve_claim_tenant_id(cls, db: Any, claim_id: str | None) -> str:
|
||||
"""从 Case Link 解析 Claim 租户;无 Link 的历史单仍归 default。"""
|
||||
|
||||
normalized_claim_id = str(claim_id or "").strip()
|
||||
if not normalized_claim_id:
|
||||
return DEFAULT_TENANT_ID
|
||||
tenant_id = db.scalar(
|
||||
select(ExpenseCaseLink.tenant_id).where(
|
||||
ExpenseCaseLink.resource_type == "expense_claim",
|
||||
ExpenseCaseLink.resource_id == normalized_claim_id,
|
||||
)
|
||||
)
|
||||
return cls.normalize_tenant_id(tenant_id)
|
||||
|
||||
def apply_tenant_scope(
|
||||
self,
|
||||
stmt: Any,
|
||||
current_user: CurrentUserContext,
|
||||
) -> Any:
|
||||
return stmt.where(self.build_claim_tenant_condition(current_user.tenant_id))
|
||||
@@ -1,10 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
@@ -19,9 +18,7 @@ from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseClaimItemCreate,
|
||||
ExpenseClaimItemUpdate,
|
||||
ExpenseClaimStandardAdjustmentPayload,
|
||||
ExpenseClaimUpdate,
|
||||
TravelReimbursementCalculatorRequest,
|
||||
)
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.budget_types import BudgetControlError
|
||||
@@ -37,294 +34,35 @@ from app.services.expense_claim_attachment_operations import ExpenseClaimAttachm
|
||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||
from app.services.expense_claim_constants import (
|
||||
RETURN_REASON_OPTIONS,
|
||||
STANDARD_ADJUSTMENT_RISK_SOURCE,
|
||||
)
|
||||
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
|
||||
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
|
||||
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
|
||||
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
||||
from app.services.expense_claim_errors import (
|
||||
ExpenseClaimPreReviewBlockedError,
|
||||
ExpenseClaimSubmissionBlockedError,
|
||||
)
|
||||
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
|
||||
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
|
||||
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
|
||||
from app.services.expense_claim_pre_review_decision import (
|
||||
pre_review_identity_matches,
|
||||
pre_review_public_payload,
|
||||
)
|
||||
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
||||
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_claim_standard_adjustment import (
|
||||
ExpenseClaimStandardAdjustmentMixin,
|
||||
)
|
||||
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
|
||||
from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin
|
||||
from app.services.receipt_folder import ReceiptFolderService
|
||||
|
||||
|
||||
class ExpenseClaimStandardAdjustmentMixin:
|
||||
@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}"
|
||||
|
||||
@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,
|
||||
*,
|
||||
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._resolve_standard_adjustment_reimbursable_amount(
|
||||
claim=claim,
|
||||
item=item,
|
||||
entry=entry,
|
||||
original_amount=original_amount,
|
||||
current_user=current_user,
|
||||
)
|
||||
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 = dedupe_claim_risk_flags([*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
|
||||
|
||||
|
||||
class ExpenseClaimItemActionMixin:
|
||||
def update_claim_item(
|
||||
self,
|
||||
@@ -497,6 +235,8 @@ class ExpenseClaimItemActionMixin:
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
pre_review_id: str = "",
|
||||
pre_review_input_fingerprint: str = "",
|
||||
before_commit: Callable[[BusinessEvent], None] | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
@@ -516,6 +256,50 @@ class ExpenseClaimItemActionMixin:
|
||||
if missing_fields:
|
||||
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
pre_review_flag = self.refresh_claim_pre_review_state(
|
||||
claim,
|
||||
is_application_claim=is_application_claim,
|
||||
)
|
||||
if pre_review_flag is None:
|
||||
raise RuntimeError("无法生成提交前预审结果。")
|
||||
|
||||
client_review_provided = bool(
|
||||
str(pre_review_id or "").strip()
|
||||
or str(pre_review_input_fingerprint or "").strip()
|
||||
)
|
||||
client_review_matches = pre_review_identity_matches(
|
||||
pre_review_flag,
|
||||
review_id=pre_review_id,
|
||||
input_fingerprint=pre_review_input_fingerprint,
|
||||
)
|
||||
correlation_id = self._expense_cases.normalize_correlation_id(
|
||||
correlation_id or str(pre_review_flag.get("review_id") or "")
|
||||
)
|
||||
_, pre_review_event = self._record_pre_review_event(
|
||||
claim,
|
||||
pre_review_flag=pre_review_flag,
|
||||
current_user=current_user,
|
||||
is_application_claim=is_application_claim,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
correlation_id = pre_review_event.correlation_id
|
||||
decision = str(pre_review_flag.get("decision") or "")
|
||||
review_error_code = (
|
||||
"PRE_REVIEW_NEEDS_FIX"
|
||||
if decision == "needs_fix"
|
||||
else "PRE_REVIEW_CHANGED"
|
||||
if client_review_provided and not client_review_matches
|
||||
else ""
|
||||
)
|
||||
if review_error_code:
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
raise ExpenseClaimPreReviewBlockedError(
|
||||
pre_review_public_payload(pre_review_flag) or {},
|
||||
code=review_error_code,
|
||||
)
|
||||
|
||||
try:
|
||||
budget_flags = self._reserve_budget_for_submission(
|
||||
claim,
|
||||
@@ -526,7 +310,6 @@ class ExpenseClaimItemActionMixin:
|
||||
if is_application_claim:
|
||||
raise
|
||||
budget_flags = list(exc.flags or [])
|
||||
before_json = self._serialize_claim(claim)
|
||||
if is_application_claim:
|
||||
submitted_at = datetime.now(UTC)
|
||||
preserved_flags = [
|
||||
@@ -572,8 +355,6 @@ class ExpenseClaimItemActionMixin:
|
||||
budget_flags,
|
||||
business_stage="reimbursement",
|
||||
)
|
||||
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
|
||||
@@ -587,6 +368,7 @@ class ExpenseClaimItemActionMixin:
|
||||
actor_id=current_user.username,
|
||||
tenant_id=getattr(current_user, "tenant_id", None),
|
||||
correlation_id=correlation_id,
|
||||
causation_id=pre_review_event.id if pre_review_event is not None else None,
|
||||
idempotency_key=(
|
||||
f"submit:{claim.id}:{claim.submitted_at.isoformat()}"
|
||||
if claim.submitted_at is not None
|
||||
@@ -617,7 +399,7 @@ class ExpenseClaimItemActionMixin:
|
||||
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None and current_user.is_admin:
|
||||
candidate_claim = self.db.scalar(
|
||||
candidate_stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
@@ -626,6 +408,9 @@ class ExpenseClaimItemActionMixin:
|
||||
)
|
||||
.where(ExpenseClaim.id == claim_id)
|
||||
)
|
||||
candidate_claim = self.db.scalar(
|
||||
self._access_policy.apply_tenant_scope(candidate_stmt, current_user)
|
||||
)
|
||||
if candidate_claim is not None:
|
||||
claim = candidate_claim
|
||||
if claim is None:
|
||||
|
||||
@@ -215,6 +215,13 @@ class ExpenseReceiptAssociationService:
|
||||
)
|
||||
uploaded_count += 1
|
||||
|
||||
if uploaded_count > 0:
|
||||
# 批次附件全部落入 Claim 后只刷新一次,避免沿用归集前的陈旧预审结论。
|
||||
self.case_service.mark_claiming_started(expense_case)
|
||||
self.claim_service.refresh_claim_pre_review_state(
|
||||
claim,
|
||||
is_application_claim=False,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
self._discard_attachment_backups(mutations)
|
||||
|
||||
@@ -4,8 +4,10 @@ import json
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from zipfile import BadZipFile
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils.exceptions import InvalidFileException
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -15,8 +17,8 @@ from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||
AgentAssetSpreadsheetManager,
|
||||
TRAVEL_SPREADSHEET_RULE_CODES,
|
||||
AgentAssetSpreadsheetManager,
|
||||
)
|
||||
from app.services.expense_rule_runtime_defaults import (
|
||||
DEFAULT_SCENE_MATRIX_CONFIG,
|
||||
@@ -37,10 +39,6 @@ from app.services.expense_rule_runtime_models import (
|
||||
build_default_expense_rule_catalog,
|
||||
resolve_document_type_label,
|
||||
)
|
||||
from app.services.expense_rule_runtime_standards import (
|
||||
build_scene_submission_standard_markdown,
|
||||
build_travel_risk_control_standard_markdown,
|
||||
)
|
||||
from app.services.expense_rule_runtime_spreadsheet_extractors import (
|
||||
extract_hotel_season_limits,
|
||||
extract_normalized_transport_class_limits,
|
||||
@@ -48,8 +46,28 @@ from app.services.expense_rule_runtime_spreadsheet_extractors import (
|
||||
map_transport_grade_row_to_bands,
|
||||
transport_class_level_for_text,
|
||||
)
|
||||
from app.services.expense_rule_runtime_standards import (
|
||||
build_scene_submission_standard_markdown,
|
||||
build_travel_risk_control_standard_markdown,
|
||||
)
|
||||
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SCENE_MATRIX_CONFIG",
|
||||
"DEFAULT_SCENE_RULE_ASSET_CODE",
|
||||
"DEFAULT_TRAVEL_POLICY_CONFIG",
|
||||
"DEFAULT_TRAVEL_RULE_ASSET_CODE",
|
||||
"DOCUMENT_TYPE_LABELS",
|
||||
"ExpenseRuleRuntimeService",
|
||||
"RuntimeTravelPolicy",
|
||||
"SCENE_LABELS",
|
||||
"build_default_expense_rule_catalog",
|
||||
"build_scene_submission_standard_markdown",
|
||||
"build_travel_risk_control_standard_markdown",
|
||||
"resolve_document_type_label",
|
||||
]
|
||||
|
||||
|
||||
class ExpenseRuleRuntimeService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
@@ -225,7 +243,7 @@ class ExpenseRuleRuntimeService:
|
||||
read_only=True,
|
||||
data_only=True,
|
||||
)
|
||||
except (FileNotFoundError, OSError):
|
||||
except (BadZipFile, FileNotFoundError, InvalidFileException, OSError):
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from threading import Lock
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.linked_reimbursement_draft_job import (
|
||||
LinkedReimbursementDraftJobCreate,
|
||||
LinkedReimbursementDraftJobRead,
|
||||
)
|
||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
|
||||
|
||||
TERMINAL_STATUSES = {"succeeded", "failed"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LinkedReimbursementDraftJobState:
|
||||
job_id: str
|
||||
tenant_id: str
|
||||
owner_username: str
|
||||
owner_name: str
|
||||
message: str
|
||||
@@ -68,11 +70,15 @@ def create_linked_reimbursement_draft_job(
|
||||
current_user: CurrentUserContext,
|
||||
) -> LinkedReimbursementDraftJobRead:
|
||||
context_json = dict(payload.context_json or {})
|
||||
tenant_id = ExpenseClaimAccessPolicy.normalize_tenant_id(current_user.tenant_id)
|
||||
context_json.pop("tenantId", None)
|
||||
context_json["tenant_id"] = tenant_id
|
||||
context_json["entry_source"] = context_json.get("entry_source") or "workbench-ai"
|
||||
context_json["session_type"] = context_json.get("session_type") or "expense"
|
||||
job_id = f"linked-reimbursement-draft-{uuid4()}"
|
||||
state = LinkedReimbursementDraftJobState(
|
||||
job_id=job_id,
|
||||
tenant_id=tenant_id,
|
||||
owner_username=str(current_user.username or "").strip(),
|
||||
owner_name=str(current_user.name or "").strip(),
|
||||
message=str(payload.message or "").strip(),
|
||||
@@ -104,7 +110,11 @@ def run_linked_reimbursement_draft_job(
|
||||
_update_job(job_id, status="running", status_message="正在后台生成报销草稿...")
|
||||
try:
|
||||
with session_factory() as db:
|
||||
if _can_use_direct_save_path(db, state.context_json):
|
||||
if _can_use_direct_save_path(
|
||||
db,
|
||||
state.context_json,
|
||||
tenant_id=state.tenant_id,
|
||||
):
|
||||
run_id, result, draft_payload = _run_direct_save_path(
|
||||
db=db,
|
||||
state=state,
|
||||
@@ -156,6 +166,10 @@ def _get_authorized_state(
|
||||
state = _jobs.get(normalized_job_id)
|
||||
if state is None:
|
||||
return None
|
||||
if state.tenant_id != ExpenseClaimAccessPolicy.normalize_tenant_id(
|
||||
current_user.tenant_id
|
||||
):
|
||||
return None
|
||||
if current_user.is_admin:
|
||||
return state
|
||||
username = str(current_user.username or "").strip()
|
||||
@@ -182,7 +196,12 @@ def _resolve_user_id(current_user: CurrentUserContext) -> str:
|
||||
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
|
||||
|
||||
|
||||
def _can_use_direct_save_path(db: Session, context_json: dict[str, Any]) -> bool:
|
||||
def _can_use_direct_save_path(
|
||||
db: Session,
|
||||
context_json: dict[str, Any],
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> bool:
|
||||
review_action = str((context_json or {}).get("review_action") or "").strip()
|
||||
if review_action != "save_draft":
|
||||
return False
|
||||
@@ -193,41 +212,69 @@ def _can_use_direct_save_path(db: Session, context_json: dict[str, Any]) -> bool
|
||||
application_claim_no = str(review_values.get("application_claim_no") or "").strip()
|
||||
if not application_claim_no:
|
||||
return False
|
||||
if application_claim_id:
|
||||
return True
|
||||
return _find_application_claim_by_no(db, application_claim_no) is not None
|
||||
|
||||
|
||||
def _find_application_claim_by_no(db: Session, claim_no: str) -> ExpenseClaim | None:
|
||||
normalized_claim_no = str(claim_no or "").strip()
|
||||
if not normalized_claim_no:
|
||||
return None
|
||||
claim = db.scalar(
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.claim_no == normalized_claim_no)
|
||||
.limit(1)
|
||||
return (
|
||||
_find_application_claim(
|
||||
db,
|
||||
claim_id=application_claim_id,
|
||||
claim_no=application_claim_no,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _find_application_claim(
|
||||
db: Session,
|
||||
*,
|
||||
claim_id: str = "",
|
||||
claim_no: str = "",
|
||||
tenant_id: str,
|
||||
) -> ExpenseClaim | None:
|
||||
normalized_claim_id = str(claim_id or "").strip()
|
||||
normalized_claim_no = str(claim_no or "").strip()
|
||||
if not normalized_claim_id and not normalized_claim_no:
|
||||
return None
|
||||
stmt = select(ExpenseClaim)
|
||||
if normalized_claim_id:
|
||||
stmt = stmt.where(ExpenseClaim.id == normalized_claim_id)
|
||||
if normalized_claim_no:
|
||||
stmt = stmt.where(ExpenseClaim.claim_no == normalized_claim_no)
|
||||
stmt = stmt.where(
|
||||
ExpenseClaimAccessPolicy.build_claim_tenant_condition(tenant_id)
|
||||
)
|
||||
claim = db.scalar(stmt.limit(1))
|
||||
if claim is not None and ExpenseClaimService._is_expense_application_claim(claim):
|
||||
return claim
|
||||
return None
|
||||
|
||||
|
||||
def _build_direct_context_json(db: Session, context_json: dict[str, Any]) -> dict[str, Any]:
|
||||
def _build_direct_context_json(
|
||||
db: Session,
|
||||
context_json: dict[str, Any],
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> dict[str, Any]:
|
||||
direct_context = dict(context_json or {})
|
||||
direct_context.pop("tenantId", None)
|
||||
direct_context["tenant_id"] = ExpenseClaimAccessPolicy.normalize_tenant_id(
|
||||
tenant_id
|
||||
)
|
||||
review_values = dict(direct_context.get("review_form_values") or {})
|
||||
scene_selection = dict(direct_context.get("expense_scene_selection") or {})
|
||||
application_claim_id = str(review_values.get("application_claim_id") or "").strip()
|
||||
application_claim_no = str(review_values.get("application_claim_no") or "").strip()
|
||||
if not application_claim_id and application_claim_no:
|
||||
application_claim = _find_application_claim_by_no(db, application_claim_no)
|
||||
if application_claim is not None:
|
||||
review_values["application_claim_id"] = application_claim.id
|
||||
scene_selection["application_claim_id"] = application_claim.id
|
||||
scene_selection["application_claim_no"] = str(
|
||||
scene_selection.get("application_claim_no")
|
||||
or application_claim.claim_no
|
||||
or application_claim_no
|
||||
).strip()
|
||||
application_claim = _find_application_claim(
|
||||
db,
|
||||
claim_id=application_claim_id,
|
||||
claim_no=application_claim_no,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
if application_claim is None:
|
||||
raise ValueError("关联申请单不存在或不属于当前租户。")
|
||||
review_values["application_claim_id"] = application_claim.id
|
||||
review_values["application_claim_no"] = application_claim.claim_no
|
||||
scene_selection["application_claim_id"] = application_claim.id
|
||||
scene_selection["application_claim_no"] = application_claim.claim_no
|
||||
direct_context["review_form_values"] = review_values
|
||||
if scene_selection:
|
||||
direct_context["expense_scene_selection"] = scene_selection
|
||||
@@ -257,7 +304,11 @@ def _run_direct_save_path(
|
||||
user_id=_resolve_user_id(current_user),
|
||||
message=state.message,
|
||||
ontology=ontology,
|
||||
context_json=_build_direct_context_json(db, state.context_json),
|
||||
context_json=_build_direct_context_json(
|
||||
db,
|
||||
state.context_json,
|
||||
tenant_id=state.tenant_id,
|
||||
),
|
||||
)
|
||||
claim_id = str(result.get("claim_id") or "").strip()
|
||||
claim_no = str(result.get("claim_no") or "").strip()
|
||||
|
||||
@@ -72,6 +72,11 @@ EXPLICIT_ENTERTAINMENT_KEYWORDS = (
|
||||
"商务宴请",
|
||||
"接待餐",
|
||||
)
|
||||
ENGLISH_FINANCE_BUSINESS_KEYWORDS = (
|
||||
"reimbursement",
|
||||
"expenseclaim",
|
||||
"travelapplication",
|
||||
)
|
||||
|
||||
|
||||
class OntologyDetectionMixin:
|
||||
@@ -101,6 +106,8 @@ class OntologyDetectionMixin:
|
||||
|
||||
if self._looks_like_expense_application(compact_query):
|
||||
return True
|
||||
if any(keyword in compact_query for keyword in ENGLISH_FINANCE_BUSINESS_KEYWORDS):
|
||||
return True
|
||||
|
||||
domain_keywords = [
|
||||
keyword
|
||||
|
||||
@@ -537,6 +537,7 @@ class StewardActionExecutor:
|
||||
"session_type": "expense",
|
||||
"entry_source": "steward_action_executor",
|
||||
"review_action": "save_draft",
|
||||
"tenant_id": current_user.tenant_id,
|
||||
"review_form_values": review_form_values,
|
||||
"user_input_text": self._resolve_message(request),
|
||||
"role_codes": current_user.role_codes,
|
||||
|
||||
@@ -385,6 +385,12 @@ class UserAgentReviewMessageMixin:
|
||||
if str(item).strip()
|
||||
],
|
||||
is_admin=bool(payload.context_json.get("is_admin")),
|
||||
tenant_id=str(
|
||||
payload.context_json.get("tenant_id")
|
||||
or payload.context_json.get("tenantId")
|
||||
or "default"
|
||||
).strip()
|
||||
or "default",
|
||||
department_name=str(payload.context_json.get("department_name") or payload.context_json.get("department") or "").strip(),
|
||||
)
|
||||
try:
|
||||
@@ -589,6 +595,12 @@ class UserAgentReviewMessageMixin:
|
||||
if str(item).strip()
|
||||
],
|
||||
is_admin=bool(payload.context_json.get("is_admin")),
|
||||
tenant_id=str(
|
||||
payload.context_json.get("tenant_id")
|
||||
or payload.context_json.get("tenantId")
|
||||
or "default"
|
||||
).strip()
|
||||
or "default",
|
||||
department_name=str(payload.context_json.get("department_name") or payload.context_json.get("department") or "").strip(),
|
||||
)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user